From f1a8bb0f35ad4ba7f7e3aa6b40bce85b5ebf3f5f Mon Sep 17 00:00:00 2001 From: Jacob Mathison Date: Tue, 21 Jul 2026 11:38:11 -0500 Subject: [PATCH] feat: add utility action suites --- .gitignore | 1 + AGENTS.md | 6 + Cargo.lock | 113 +++++++++++++++- Cargo.toml | 5 + README.md | 30 ++++- llm.txt | 12 ++ manifest.llm.json | 98 ++++++++++++++ src/dictionary.rs | 23 ++-- src/dispatcher.rs | 60 +++++++++ src/llm.rs | 16 ++- src/registry.rs | 292 ++++++++++++++++++++++++++++++++++++---- src/scripts/file_ops.rs | 162 ++++++++++++++++++++++ src/scripts/math.rs | 157 +++++++++++++++++++++ src/scripts/mod.rs | 3 + src/scripts/text.rs | 181 +++++++++++++++++++++++++ 15 files changed, 1117 insertions(+), 42 deletions(-) create mode 100644 src/scripts/file_ops.rs create mode 100644 src/scripts/math.rs create mode 100644 src/scripts/text.rs diff --git a/.gitignore b/.gitignore index e870cbc..eaebfb1 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ !/.env.example /logs/ /runtime/ +/notes/ /dictionary/actions.jsonl /dictionary/actions.index.json /dictionary/model.codebook.json diff --git a/AGENTS.md b/AGENTS.md index 1a734ef..5b5fb02 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,6 +12,9 @@ - Start llama.cpp: `cargo run -- serve` - Query local model: `cargo run -- query "log hello from codex"` - Direct proof-of-concept dispatch: `cargo run -- run logger --payload '{"message":"hello from codex"}'` +- Direct math dispatch: `cargo run -- run calculate --payload '{"expression":"2 + 2 * 3"}'` +- Direct text dispatch: `cargo run -- run to_slug --payload '{"text":"Hello, World!"}'` +- Direct file dispatch: `cargo run -- run write_note --payload '{"filename":"hello.txt","content":"hello"}'` - Codex setup: `scripts/codex-setup.sh` - Codex logger smoke test: `scripts/codex-run-logger.sh` - Precommit/CI check: `scripts/precommit-check.sh` @@ -38,6 +41,7 @@ Use `.env` for local values and keep it out of git. Update `.env.example`, `READ - `dictionary/actions.index.json` is the generated verbose runtime lookup file. - `dictionary/model.codebook.json` is the generated model-facing compact codebook. - `dictionary/model.examples.jsonl` is generated template/example data for future synthetic-data tooling. +- Registered action suites are Math & Conversion, Text Utilities, File Operations, and Logger. - Regenerate dictionary files with `cargo run -- dictionary generate` after changing registered actions. - Do not manually edit generated dictionary files. - Generated metadata includes `dictionary_version`, `registry_checksum`, `static_base_checksum`, and `codebook_checksum`. @@ -50,12 +54,14 @@ Use `.env` for local values and keep it out of git. Update `.env.example`, `READ - Unknown, malformed, ambiguous, missing, or empty action ids must be rejected without running scripts. - Script processes are detached. The controller spawns them and does not monitor completion. - Keep script payload validation strict and local to the script module. +- File operation scripts must remain confined to project-local `notes/` paths and reject absolute paths or path traversal. ## Generated Paths - `target/` - `logs/` - `runtime/` +- `notes/` - `dictionary/actions.jsonl` - `dictionary/actions.index.json` - `dictionary/model.codebook.json` diff --git a/Cargo.lock b/Cargo.lock index 0ef389e..0f3e505 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -151,7 +151,7 @@ checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" dependencies = [ "cfg-if", "cpufeatures 0.3.0", - "rand_core", + "rand_core 0.10.1", ] [[package]] @@ -283,6 +283,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "evalexpr" +version = "12.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae893d2d5e908b78f151ed89de3bfc272cdf6d368c7ed866942f98e24dea208a" + [[package]] name = "fastrand" version = "2.5.0" @@ -386,7 +392,7 @@ dependencies = [ "js-sys", "libc", "r-efi", - "rand_core", + "rand_core 0.10.1", "wasm-bindgen", ] @@ -695,6 +701,16 @@ dependencies = [ "regex-automata", ] +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + [[package]] name = "memchr" version = "2.8.3" @@ -720,14 +736,19 @@ dependencies = [ "chrono", "clap", "dotenvy", + "evalexpr", + "md-5", + "rand 0.8.7", "reqwest", "serde", "serde_json", + "sha1", "sha2", "shell-words", "tempfile", "tracing", "tracing-subscriber", + "uuid", ] [[package]] @@ -781,6 +802,15 @@ dependencies = [ "zerovec", ] +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "proc-macro2" version = "1.0.107" @@ -819,7 +849,7 @@ dependencies = [ "bytes", "getrandom 0.4.3", "lru-slab", - "rand", + "rand 0.10.2", "rand_pcg", "ring", "rustc-hash", @@ -861,6 +891,17 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" +dependencies = [ + "libc", + "rand_chacha", + "rand_core 0.6.4", +] + [[package]] name = "rand" version = "0.10.2" @@ -869,7 +910,26 @@ checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ "chacha20", "getrandom 0.4.3", - "rand_core", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", ] [[package]] @@ -884,7 +944,7 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" dependencies = [ - "rand_core", + "rand_core 0.10.1", ] [[package]] @@ -1079,6 +1139,17 @@ dependencies = [ "serde", ] +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + [[package]] name = "sha2" version = "0.10.9" @@ -1438,6 +1509,18 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "wasm-bindgen", +] + [[package]] name = "valuable" version = "0.1.1" @@ -1719,6 +1802,26 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zerocopy" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "zerofrom" version = "0.1.8" diff --git a/Cargo.toml b/Cargo.toml index 761a5d4..8af22fb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,13 +9,18 @@ anyhow = "1.0" chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] } clap = { version = "4.5", features = ["derive"] } dotenvy = "0.15" +evalexpr = "12.0" +md-5 = "0.10" +rand = "0.8" reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" +sha1 = "0.10" sha2 = "0.10" shell-words = "1.1" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } +uuid = { version = "1.11", features = ["v4", "serde"] } [dev-dependencies] tempfile = "3.10" diff --git a/README.md b/README.md index ac1282d..0114109 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,9 @@ cargo run -- dictionary generate cargo run -- serve cargo run -- query "log hello from codex" cargo run -- run logger --payload '{"message":"hello from direct dispatch"}' +cargo run -- run calculate --payload '{"expression":"2 + 2 * 3"}' +cargo run -- run to_slug --payload '{"text":"Hello, World!"}' +cargo run -- run write_note --payload '{"filename":"hello.txt","content":"hello"}' scripts/precommit-check.sh ``` @@ -67,7 +70,32 @@ Malformed JSON, missing action codes, stale versions/checksums, aliases, and unk ## Initial Script -The first registered script is `logger`. It writes JSONL records to `logs/controller-actions.jsonl`. Dispatch is detached: after the controller spawns the script process, it does not monitor completion. +The first registered script was `logger`. It writes JSONL records to `logs/controller-actions.jsonl`. Dispatch is detached: after the controller spawns the script process, it does not monitor completion. + +## Action Suites + +Math & Conversion: + +- `calculate`: `{ "expression": "2 + 2 * 3" }` +- `convert_units`: `{ "value": 100, "from": "km", "to": "miles" }` +- `generate_uuid`: `{}` +- `random_number`: `{ "min": 1, "max": 100 }` + +Text Utilities: + +- `count_words`: `{ "text": "..." }` +- `to_slug`: `{ "text": "..." }` +- `hash_string`: `{ "text": "...", "algorithm": "sha256|md5|sha1" }` +- `truncate_text`: `{ "text": "...", "max_chars": 100 }` + +File Operations: + +- `write_note`: `{ "filename": "...", "content": "..." }` +- `append_to_file`: `{ "filename": "...", "content": "..." }` +- `read_file`: `{ "filename": "..." }` +- `delete_file`: `{ "filename": "..." }` + +File operations are confined to project-local `notes/` and reject absolute paths or path traversal. ## CI And Hooks diff --git a/llm.txt b/llm.txt index c793f63..aaa9d17 100644 --- a/llm.txt +++ b/llm.txt @@ -25,6 +25,9 @@ Commands: - `cargo run -- serve` - `cargo run -- query "log hello from codex"` - `cargo run -- run logger --payload '{"message":"hello from codex"}'` +- `cargo run -- run calculate --payload '{"expression":"2 + 2 * 3"}'` +- `cargo run -- run to_slug --payload '{"text":"Hello, World!"}'` +- `cargo run -- run write_note --payload '{"filename":"hello.txt","content":"hello"}'` - `scripts/codex-setup.sh` - `scripts/codex-run-logger.sh` - `scripts/precommit-check.sh` @@ -42,6 +45,15 @@ Environment: The LLM may return verbose JSON like `{"action_id":"logger","payload":{"message":"hello"}}` or compact codebook JSON like `{"v":"1.0.0","c":"","a":"A001","p":{"F001":"hello"}}`. Compact outputs must match `dictionary/model.codebook.json` version and checksum before expansion. The runtime only executes exact `action_id` matches from `dictionary/actions.index.json`; aliases are not executable. Invalid or unknown outputs are rejected without running anything. +Registered suites: + +- Math & Conversion: `calculate`, `convert_units`, `generate_uuid`, `random_number`. +- Text Utilities: `count_words`, `to_slug`, `hash_string`, `truncate_text`. +- File Operations: `write_note`, `append_to_file`, `read_file`, `delete_file`. +- Logger: `logger`. + +File operations are sandboxed to project-local `notes/`. + Codex environment: `.codex/environments/environment.toml` defines setup plus Build, Test, Generate Dictionary, Precommit Check, Run Logger, Query Logger, and Serve Llama actions. CI and hooks: `.gitea/workflows/ci.yml`, `.woodpecker.yml`, and `.githooks/pre-commit` all use `scripts/precommit-check.sh` to verify formatting, tests, and dictionary generation. diff --git a/manifest.llm.json b/manifest.llm.json index bd3bedf..89f49b5 100644 --- a/manifest.llm.json +++ b/manifest.llm.json @@ -21,6 +21,9 @@ "serve": "cargo run -- serve", "query": "cargo run -- query \"log hello from codex\"", "run_logger": "cargo run -- run logger --payload '{\"message\":\"hello from codex\"}'", + "run_calculate": "cargo run -- run calculate --payload '{\"expression\":\"2 + 2 * 3\"}'", + "run_to_slug": "cargo run -- run to_slug --payload '{\"text\":\"Hello, World!\"}'", + "run_write_note": "cargo run -- run write_note --payload '{\"filename\":\"hello.txt\",\"content\":\"hello\"}'", "codex_setup": "scripts/codex-setup.sh", "codex_logger_smoke": "scripts/codex-run-logger.sh", "precommit_check": "scripts/precommit-check.sh", @@ -108,8 +111,102 @@ "runtime_policy": "Verbose outputs require exact action_id matches; compact outputs require matching version/checksum and exact codebook expansion before dispatch." }, "actions": [ + { + "action_id": "calculate", + "suite": "Math & Conversion", + "payload_example": { + "expression": "2 + 2 * 3" + } + }, + { + "action_id": "convert_units", + "suite": "Math & Conversion", + "payload_example": { + "value": 100, + "from": "km", + "to": "miles" + } + }, + { + "action_id": "generate_uuid", + "suite": "Math & Conversion", + "payload_example": {} + }, + { + "action_id": "random_number", + "suite": "Math & Conversion", + "payload_example": { + "min": 1, + "max": 100 + } + }, + { + "action_id": "count_words", + "suite": "Text Utilities", + "payload_example": { + "text": "hello from codex" + } + }, + { + "action_id": "to_slug", + "suite": "Text Utilities", + "payload_example": { + "text": "Hello, World!" + } + }, + { + "action_id": "hash_string", + "suite": "Text Utilities", + "payload_example": { + "text": "hello", + "algorithm": "sha256" + } + }, + { + "action_id": "truncate_text", + "suite": "Text Utilities", + "payload_example": { + "text": "hello from codex", + "max_chars": 5 + } + }, + { + "action_id": "write_note", + "suite": "File Operations", + "payload_example": { + "filename": "hello.txt", + "content": "hello" + }, + "sandbox": "notes/" + }, + { + "action_id": "append_to_file", + "suite": "File Operations", + "payload_example": { + "filename": "hello.txt", + "content": " world" + }, + "sandbox": "notes/" + }, + { + "action_id": "read_file", + "suite": "File Operations", + "payload_example": { + "filename": "hello.txt" + }, + "sandbox": "notes/" + }, + { + "action_id": "delete_file", + "suite": "File Operations", + "payload_example": { + "filename": "hello.txt" + }, + "sandbox": "notes/" + }, { "action_id": "logger", + "suite": "Logger", "script": "src/scripts/logger.rs", "description": "Proof-of-concept script that writes structured JSONL log records.", "payload_example": { @@ -122,6 +219,7 @@ "target/", "logs/", "runtime/", + "notes/", "dictionary/actions.jsonl", "dictionary/actions.index.json", "dictionary/model.codebook.json", diff --git a/src/dictionary.rs b/src/dictionary.rs index 2645326..3920dfb 100644 --- a/src/dictionary.rs +++ b/src/dictionary.rs @@ -426,9 +426,10 @@ mod tests { fn adaptive_layer_maps_logger_and_message_deterministically() { let records = sorted_records(®istered_actions()); let adaptive = build_adaptive_codebook(&records); - assert_eq!(adaptive.actions["A001"], "logger"); - assert_eq!(adaptive.fields["F001"], "message"); - assert_eq!(adaptive.modules["M001"], "logger"); + assert_eq!(adaptive.actions["A001"], "append_to_file"); + assert!(adaptive.actions.values().any(|value| value == "logger")); + assert!(adaptive.fields.values().any(|value| value == "message")); + assert!(adaptive.modules.values().any(|value| value == "logger")); } #[test] @@ -459,23 +460,29 @@ mod tests { let temp = temp_dictionary_dir(); let summary = generate_dictionary_files(®istered_actions(), temp.path()).unwrap(); - assert_eq!(summary.action_count, 1); + assert_eq!(summary.action_count, 13); assert!(summary.jsonl_path.exists()); assert!(summary.index_path.exists()); assert!(summary.codebook_path.exists()); assert!(summary.examples_path.exists()); let records = read_jsonl_records(&summary.jsonl_path).unwrap(); - assert_eq!(records.len(), 1); - assert_eq!(records[0].action_id, "logger"); + assert_eq!(records.len(), 13); + assert!(records.iter().any(|record| record.action_id == "logger")); let index = load_action_index(temp.path()).unwrap(); assert!(index.actions.contains_key("logger")); assert!(!index.actions.contains_key("log")); let codebook = load_model_codebook(temp.path()).unwrap(); - assert_eq!(codebook.adaptive.actions["A001"], "logger"); - assert_eq!(codebook.adaptive.fields["F001"], "message"); + assert_eq!(codebook.adaptive.actions["A001"], "append_to_file"); + assert!( + codebook + .adaptive + .fields + .values() + .any(|value| value == "message") + ); assert!(!codebook.codebook_checksum.is_empty()); } diff --git a/src/dispatcher.rs b/src/dispatcher.rs index acfa98c..7b6f095 100644 --- a/src/dispatcher.rs +++ b/src/dispatcher.rs @@ -4,7 +4,19 @@ use std::fs::{self, OpenOptions}; use std::process::{Command, Stdio}; use crate::dictionary::ActionIndex; +use crate::scripts::file_ops::{ + FilePayload, WriteFilePayload, run_append_to_file, run_delete_file, run_read_file, + run_write_note, +}; use crate::scripts::logger::{LoggerPayload, run_logger}; +use crate::scripts::math::{ + CalculatePayload, ConvertUnitsPayload, GenerateUuidPayload, RandomNumberPayload, run_calculate, + run_convert_units, run_generate_uuid, run_random_number, +}; +use crate::scripts::text::{ + CountWordsPayload, HashStringPayload, ToSlugPayload, TruncateTextPayload, run_count_words, + run_hash_string, run_to_slug, run_truncate_text, +}; pub fn dispatch_action(index: &ActionIndex, action_id: &str, payload: Value) -> Result<()> { let action = index @@ -46,6 +58,54 @@ pub fn run_internal_script(action_id: &str, payload: Value) -> Result<()> { let payload = serde_json::from_value::(payload)?; run_logger(payload) } + "calculate" => { + let payload = serde_json::from_value::(payload)?; + run_calculate(payload) + } + "convert_units" => { + let payload = serde_json::from_value::(payload)?; + run_convert_units(payload) + } + "generate_uuid" => { + let payload = serde_json::from_value::(payload)?; + run_generate_uuid(payload) + } + "random_number" => { + let payload = serde_json::from_value::(payload)?; + run_random_number(payload) + } + "count_words" => { + let payload = serde_json::from_value::(payload)?; + run_count_words(payload) + } + "to_slug" => { + let payload = serde_json::from_value::(payload)?; + run_to_slug(payload) + } + "hash_string" => { + let payload = serde_json::from_value::(payload)?; + run_hash_string(payload) + } + "truncate_text" => { + let payload = serde_json::from_value::(payload)?; + run_truncate_text(payload) + } + "write_note" => { + let payload = serde_json::from_value::(payload)?; + run_write_note(payload) + } + "append_to_file" => { + let payload = serde_json::from_value::(payload)?; + run_append_to_file(payload) + } + "read_file" => { + let payload = serde_json::from_value::(payload)?; + run_read_file(payload) + } + "delete_file" => { + let payload = serde_json::from_value::(payload)?; + run_delete_file(payload) + } other => bail!("unknown internal script `{other}`"), } } diff --git a/src/llm.rs b/src/llm.rs index 5d8f413..90bdc58 100644 --- a/src/llm.rs +++ b/src/llm.rs @@ -227,9 +227,21 @@ mod tests { #[test] fn expands_valid_compact_action_response() { let codebook = test_codebook(); + let logger_code = codebook + .adaptive + .actions + .iter() + .find_map(|(code, action)| (action == "logger").then_some(code.as_str())) + .unwrap(); + let message_code = codebook + .adaptive + .fields + .iter() + .find_map(|(code, field)| (field == "message").then_some(code.as_str())) + .unwrap(); let raw = format!( - r#"{{"v":"{}","c":"{}","a":"A001","p":{{"F001":"hello"}}}}"#, - codebook.dictionary_version, codebook.codebook_checksum + r#"{{"v":"{}","c":"{}","a":"{}","p":{{"{}":"hello"}}}}"#, + codebook.dictionary_version, codebook.codebook_checksum, logger_code, message_code ); let parsed = parse_action_response(&raw, &codebook).unwrap(); assert_eq!(parsed.action_id, "logger"); diff --git a/src/registry.rs b/src/registry.rs index 5bb6b3d..eaf0b9f 100644 --- a/src/registry.rs +++ b/src/registry.rs @@ -18,33 +18,273 @@ pub struct ExecutableDefinition { } pub fn registered_actions() -> Vec { - vec![ActionDefinition { - action_id: "logger".to_string(), - aliases: vec![ - "log".to_string(), - "write_log".to_string(), - "record_message".to_string(), - ], - description: "Write a structured proof-of-concept log message.".to_string(), - intent_examples: vec![ - "log hello from codex".to_string(), - "record this message".to_string(), - "write a logger proof of concept entry".to_string(), - ], - payload_schema: serde_json::json!({ - "type": "object", - "required": ["message"], - "properties": { - "message": { - "type": "string", - "description": "Message text to write into logs/controller-actions.jsonl." - } - }, - "additionalProperties": false - }), + vec![ + action( + "append_to_file", + &["append", "append_file", "add_to_file"], + "Append content to a project-local notes file.", + &[ + "append this line to notes.txt", + "add content to a file", + "append to a project note", + ], + object_schema( + &[ + ("filename", string_schema("Relative notes/ filename.")), + ("content", string_schema("Content to append.")), + ], + &["filename", "content"], + ), + ), + action( + "calculate", + &["calc", "evaluate_expression", "math"], + "Evaluate a simple math expression.", + &[ + "calculate 2 + 2 * 3", + "evaluate this expression", + "what is 10 / 2 plus 8", + ], + object_schema( + &[("expression", string_schema("Math expression to evaluate."))], + &["expression"], + ), + ), + action( + "convert_units", + &["convert", "unit_conversion", "convert_measurement"], + "Convert a numeric distance value between supported units.", + &[ + "convert 100 km to miles", + "convert meters to feet", + "unit conversion for 12 inches to cm", + ], + object_schema( + &[ + ("value", number_schema("Numeric value to convert.")), + ("from", string_schema("Source unit.")), + ("to", string_schema("Target unit.")), + ], + &["value", "from", "to"], + ), + ), + action( + "count_words", + &["word_count", "count_text_words"], + "Count whitespace-delimited words in text.", + &[ + "count words in this text", + "how many words are here", + "word count this sentence", + ], + object_schema( + &[("text", string_schema("Text to count words in."))], + &["text"], + ), + ), + action( + "delete_file", + &["delete_note", "remove_file", "remove_note"], + "Delete a project-local notes file.", + &[ + "delete note.txt", + "remove this note file", + "delete a project note", + ], + object_schema( + &[("filename", string_schema("Relative notes/ filename."))], + &["filename"], + ), + ), + action( + "generate_uuid", + &["uuid", "new_uuid", "create_uuid"], + "Generate a random UUID v4.", + &[ + "generate a uuid", + "create a new uuid", + "give me a random uuid", + ], + object_schema(&[], &[]), + ), + action( + "hash_string", + &["hash", "digest_string", "string_hash"], + "Hash text with sha256, md5, or sha1.", + &[ + "hash hello with sha256", + "md5 this string", + "sha1 digest for this text", + ], + object_schema( + &[ + ("text", string_schema("Text to hash.")), + ( + "algorithm", + enum_schema("Hash algorithm.", &["sha256", "md5", "sha1"]), + ), + ], + &["text", "algorithm"], + ), + ), + action( + "logger", + &["log", "write_log", "record_message"], + "Write a structured proof-of-concept log message.", + &[ + "log hello from codex", + "record this message", + "write a logger proof of concept entry", + ], + object_schema( + &[( + "message", + string_schema("Message text to write into logs/controller-actions.jsonl."), + )], + &["message"], + ), + ), + action( + "random_number", + &["random_int", "roll_number", "number_in_range"], + "Generate a random integer in an inclusive range.", + &[ + "random number between 1 and 100", + "roll a number in this range", + "pick an integer from 5 to 10", + ], + object_schema( + &[ + ("min", integer_schema("Inclusive minimum.")), + ("max", integer_schema("Inclusive maximum.")), + ], + &["min", "max"], + ), + ), + action( + "read_file", + &["read_note", "open_file", "show_file"], + "Read a project-local notes file.", + &[ + "read note.txt", + "show the contents of this note", + "open a project note file", + ], + object_schema( + &[("filename", string_schema("Relative notes/ filename."))], + &["filename"], + ), + ), + action( + "to_slug", + &["slugify", "make_slug", "url_slug"], + "Convert text to a URL-safe ASCII slug.", + &[ + "slugify this title", + "make a url slug", + "convert text to a slug", + ], + object_schema(&[("text", string_schema("Text to slugify."))], &["text"]), + ), + action( + "truncate_text", + &["truncate", "shorten_text", "clip_text"], + "Truncate text to a maximum character count.", + &[ + "truncate this to 100 characters", + "shorten this text", + "clip text to a max length", + ], + object_schema( + &[ + ("text", string_schema("Text to truncate.")), + ("max_chars", integer_schema("Maximum number of characters.")), + ], + &["text", "max_chars"], + ), + ), + action( + "write_note", + &["write_file", "save_note", "create_note"], + "Create or overwrite a project-local notes file.", + &[ + "write a note file", + "save this note", + "create a text file with this content", + ], + object_schema( + &[ + ("filename", string_schema("Relative notes/ filename.")), + ("content", string_schema("Content to write.")), + ], + &["filename", "content"], + ), + ), + ] +} + +fn action( + action_id: &str, + aliases: &[&str], + description: &str, + intent_examples: &[&str], + payload_schema: Value, +) -> ActionDefinition { + ActionDefinition { + action_id: action_id.to_string(), + aliases: aliases.iter().map(|alias| alias.to_string()).collect(), + description: description.to_string(), + intent_examples: intent_examples + .iter() + .map(|example| example.to_string()) + .collect(), + payload_schema, executable: ExecutableDefinition { kind: "internal-rust-script".to_string(), - script: "logger".to_string(), + script: action_id.to_string(), }, - }] + } +} + +fn object_schema(properties: &[(&str, Value)], required: &[&str]) -> Value { + let properties = properties + .iter() + .map(|(name, schema)| ((*name).to_string(), schema.clone())) + .collect::>(); + + serde_json::json!({ + "type": "object", + "required": required, + "properties": properties, + "additionalProperties": false + }) +} + +fn string_schema(description: &str) -> Value { + serde_json::json!({ + "type": "string", + "description": description + }) +} + +fn number_schema(description: &str) -> Value { + serde_json::json!({ + "type": "number", + "description": description + }) +} + +fn integer_schema(description: &str) -> Value { + serde_json::json!({ + "type": "integer", + "description": description + }) +} + +fn enum_schema(description: &str, values: &[&str]) -> Value { + serde_json::json!({ + "type": "string", + "description": description, + "enum": values + }) } diff --git a/src/scripts/file_ops.rs b/src/scripts/file_ops.rs new file mode 100644 index 0000000..5a729b3 --- /dev/null +++ b/src/scripts/file_ops.rs @@ -0,0 +1,162 @@ +use anyhow::{Context, Result, bail}; +use serde::{Deserialize, Serialize}; +use std::fs::{self, OpenOptions}; +use std::io::Write; +use std::path::{Component, Path, PathBuf}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct FilePayload { + pub filename: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct WriteFilePayload { + pub filename: String, + pub content: String, +} + +pub fn run_write_note(payload: WriteFilePayload) -> Result<()> { + let path = write_note(payload)?; + print_json( + "write_note", + serde_json::json!({ "path": path.display().to_string() }), + ) +} + +pub fn run_append_to_file(payload: WriteFilePayload) -> Result<()> { + let path = append_to_file(payload)?; + print_json( + "append_to_file", + serde_json::json!({ "path": path.display().to_string() }), + ) +} + +pub fn run_read_file(payload: FilePayload) -> Result<()> { + let contents = read_file(payload)?; + print_json("read_file", serde_json::json!({ "content": contents })) +} + +pub fn run_delete_file(payload: FilePayload) -> Result<()> { + let path = delete_file(payload)?; + print_json( + "delete_file", + serde_json::json!({ "path": path.display().to_string() }), + ) +} + +pub fn write_note(payload: WriteFilePayload) -> Result { + let path = safe_notes_path(&payload.filename)?; + ensure_parent(&path)?; + fs::write(&path, payload.content).with_context(|| format!("writing {}", path.display()))?; + Ok(path) +} + +pub fn append_to_file(payload: WriteFilePayload) -> Result { + let path = safe_notes_path(&payload.filename)?; + ensure_parent(&path)?; + let mut file = OpenOptions::new() + .create(true) + .append(true) + .open(&path) + .with_context(|| format!("opening {}", path.display()))?; + file.write_all(payload.content.as_bytes())?; + Ok(path) +} + +pub fn read_file(payload: FilePayload) -> Result { + let path = safe_notes_path(&payload.filename)?; + fs::read_to_string(&path).with_context(|| format!("reading {}", path.display())) +} + +pub fn delete_file(payload: FilePayload) -> Result { + let path = safe_notes_path(&payload.filename)?; + fs::remove_file(&path).with_context(|| format!("deleting {}", path.display()))?; + Ok(path) +} + +fn safe_notes_path(filename: &str) -> Result { + let trimmed = filename.trim(); + if trimmed.is_empty() { + bail!("filename must not be empty"); + } + + let relative = Path::new(trimmed); + if relative.is_absolute() { + bail!("filename must be relative to notes/"); + } + + for component in relative.components() { + match component { + Component::Normal(_) => {} + _ => bail!("filename must not contain path traversal or special components"), + } + } + + Ok(PathBuf::from("notes").join(relative)) +} + +fn ensure_parent(path: &Path) -> Result<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).with_context(|| format!("creating {}", parent.display()))?; + } + Ok(()) +} + +fn print_json(action_id: &str, payload: serde_json::Value) -> Result<()> { + println!( + "{}", + serde_json::to_string(&serde_json::json!({ + "action_id": action_id, + "output": payload + }))? + ); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rejects_unsafe_paths() { + assert!(safe_notes_path("../secret.txt").is_err()); + assert!(safe_notes_path("/tmp/secret.txt").is_err()); + assert!(safe_notes_path("").is_err()); + assert_eq!( + safe_notes_path("daily/note.txt").unwrap(), + PathBuf::from("notes/daily/note.txt") + ); + } + + #[test] + fn writes_appends_reads_and_deletes_note() { + let temp = tempfile::tempdir().unwrap(); + let previous = std::env::current_dir().unwrap(); + std::env::set_current_dir(temp.path()).unwrap(); + + write_note(WriteFilePayload { + filename: "test.txt".to_string(), + content: "hello".to_string(), + }) + .unwrap(); + append_to_file(WriteFilePayload { + filename: "test.txt".to_string(), + content: " world".to_string(), + }) + .unwrap(); + assert_eq!( + read_file(FilePayload { + filename: "test.txt".to_string() + }) + .unwrap(), + "hello world" + ); + delete_file(FilePayload { + filename: "test.txt".to_string(), + }) + .unwrap(); + assert!(!Path::new("notes/test.txt").exists()); + + std::env::set_current_dir(previous).unwrap(); + } +} diff --git a/src/scripts/math.rs b/src/scripts/math.rs new file mode 100644 index 0000000..9a53e51 --- /dev/null +++ b/src/scripts/math.rs @@ -0,0 +1,157 @@ +use anyhow::{Result, bail}; +use evalexpr::eval; +use rand::Rng; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use uuid::Uuid; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct CalculatePayload { + pub expression: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ConvertUnitsPayload { + pub value: f64, + pub from: String, + pub to: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct GenerateUuidPayload {} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct RandomNumberPayload { + pub min: i64, + pub max: i64, +} + +pub fn run_calculate(payload: CalculatePayload) -> Result<()> { + let result = calculate(payload)?; + print_json("calculate", serde_json::json!({ "result": result })) +} + +pub fn run_convert_units(payload: ConvertUnitsPayload) -> Result<()> { + let result = convert_units(payload)?; + print_json("convert_units", result) +} + +pub fn run_generate_uuid(_payload: GenerateUuidPayload) -> Result<()> { + print_json( + "generate_uuid", + serde_json::json!({ "uuid": Uuid::new_v4().to_string() }), + ) +} + +pub fn run_random_number(payload: RandomNumberPayload) -> Result<()> { + let number = random_number(payload)?; + print_json("random_number", serde_json::json!({ "number": number })) +} + +pub fn calculate(payload: CalculatePayload) -> Result { + let expression = payload.expression.trim(); + if expression.is_empty() { + bail!("expression must not be empty"); + } + + let result = eval(expression)?; + Ok(match result { + evalexpr::Value::Int(value) => serde_json::json!(value), + evalexpr::Value::Float(value) => serde_json::json!(value), + evalexpr::Value::Boolean(value) => serde_json::json!(value), + evalexpr::Value::String(value) => serde_json::json!(value), + other => serde_json::json!(other.to_string()), + }) +} + +pub fn convert_units(payload: ConvertUnitsPayload) -> Result { + let from = normalize_unit(&payload.from); + let to = normalize_unit(&payload.to); + let meters = to_meters(payload.value, &from)?; + let converted = from_meters(meters, &to)?; + Ok(serde_json::json!({ + "value": converted, + "from": payload.from, + "to": payload.to + })) +} + +pub fn random_number(payload: RandomNumberPayload) -> Result { + if payload.min > payload.max { + bail!("min must be less than or equal to max"); + } + Ok(rand::thread_rng().gen_range(payload.min..=payload.max)) +} + +fn normalize_unit(unit: &str) -> String { + unit.trim().to_ascii_lowercase() +} + +fn to_meters(value: f64, unit: &str) -> Result { + match unit { + "m" | "meter" | "meters" => Ok(value), + "km" | "kilometer" | "kilometers" => Ok(value * 1000.0), + "mi" | "mile" | "miles" => Ok(value * 1609.344), + "ft" | "foot" | "feet" => Ok(value * 0.3048), + "in" | "inch" | "inches" => Ok(value * 0.0254), + "cm" | "centimeter" | "centimeters" => Ok(value * 0.01), + "mm" | "millimeter" | "millimeters" => Ok(value * 0.001), + other => bail!("unsupported source unit `{other}`"), + } +} + +fn from_meters(value: f64, unit: &str) -> Result { + match unit { + "m" | "meter" | "meters" => Ok(value), + "km" | "kilometer" | "kilometers" => Ok(value / 1000.0), + "mi" | "mile" | "miles" => Ok(value / 1609.344), + "ft" | "foot" | "feet" => Ok(value / 0.3048), + "in" | "inch" | "inches" => Ok(value / 0.0254), + "cm" | "centimeter" | "centimeters" => Ok(value / 0.01), + "mm" | "millimeter" | "millimeters" => Ok(value / 0.001), + other => bail!("unsupported target unit `{other}`"), + } +} + +fn print_json(action_id: &str, payload: Value) -> Result<()> { + println!( + "{}", + serde_json::to_string(&serde_json::json!({ + "action_id": action_id, + "output": payload + }))? + ); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn evaluates_math_expression() { + let result = calculate(CalculatePayload { + expression: "2 + 2 * 3".to_string(), + }) + .unwrap(); + assert_eq!(result, serde_json::json!(8)); + } + + #[test] + fn converts_km_to_miles() { + let result = convert_units(ConvertUnitsPayload { + value: 100.0, + from: "km".to_string(), + to: "miles".to_string(), + }) + .unwrap(); + let converted = result["value"].as_f64().unwrap(); + assert!((converted - 62.137119).abs() < 0.0001); + } + + #[test] + fn validates_random_range() { + assert!(random_number(RandomNumberPayload { min: 1, max: 3 }).unwrap() >= 1); + assert!(random_number(RandomNumberPayload { min: 5, max: 1 }).is_err()); + } +} diff --git a/src/scripts/mod.rs b/src/scripts/mod.rs index d991728..c7fb135 100644 --- a/src/scripts/mod.rs +++ b/src/scripts/mod.rs @@ -1 +1,4 @@ +pub mod file_ops; pub mod logger; +pub mod math; +pub mod text; diff --git a/src/scripts/text.rs b/src/scripts/text.rs new file mode 100644 index 0000000..c328616 --- /dev/null +++ b/src/scripts/text.rs @@ -0,0 +1,181 @@ +use anyhow::{Result, bail}; +use md5::Md5; +use serde::{Deserialize, Serialize}; +use sha1::Sha1; +use sha2::{Digest, Sha256}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct CountWordsPayload { + pub text: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ToSlugPayload { + pub text: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct HashStringPayload { + pub text: String, + pub algorithm: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct TruncateTextPayload { + pub text: String, + pub max_chars: usize, +} + +pub fn run_count_words(payload: CountWordsPayload) -> Result<()> { + print_json( + "count_words", + serde_json::json!({ "word_count": count_words(&payload.text) }), + ) +} + +pub fn run_to_slug(payload: ToSlugPayload) -> Result<()> { + print_json( + "to_slug", + serde_json::json!({ "slug": to_slug(&payload.text) }), + ) +} + +pub fn run_hash_string(payload: HashStringPayload) -> Result<()> { + let hash = hash_string(payload)?; + print_json("hash_string", serde_json::json!({ "hash": hash })) +} + +pub fn run_truncate_text(payload: TruncateTextPayload) -> Result<()> { + let truncated = truncate_text(payload)?; + print_json( + "truncate_text", + serde_json::json!({ "text": truncated.text, "truncated": truncated.truncated }), + ) +} + +pub fn count_words(text: &str) -> usize { + text.split_whitespace() + .filter(|word| !word.is_empty()) + .count() +} + +pub fn to_slug(text: &str) -> String { + let mut slug = String::new(); + let mut last_was_dash = false; + + for ch in text.chars().flat_map(char::to_lowercase) { + if ch.is_ascii_alphanumeric() { + slug.push(ch); + last_was_dash = false; + } else if !last_was_dash && !slug.is_empty() { + slug.push('-'); + last_was_dash = true; + } + } + + slug.trim_matches('-').to_string() +} + +pub fn hash_string(payload: HashStringPayload) -> Result { + match payload.algorithm.trim().to_ascii_lowercase().as_str() { + "sha256" => Ok(hex_digest( + Sha256::digest(payload.text.as_bytes()).as_slice(), + )), + "sha1" => Ok(hex_digest(Sha1::digest(payload.text.as_bytes()).as_slice())), + "md5" => Ok(hex_digest(Md5::digest(payload.text.as_bytes()).as_slice())), + other => bail!("unsupported hash algorithm `{other}`"), + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TruncatedText { + pub text: String, + pub truncated: bool, +} + +pub fn truncate_text(payload: TruncateTextPayload) -> Result { + if payload.max_chars == 0 { + bail!("max_chars must be greater than zero"); + } + + let mut chars = payload.text.chars(); + let text = chars.by_ref().take(payload.max_chars).collect::(); + let truncated = chars.next().is_some(); + Ok(TruncatedText { text, truncated }) +} + +fn hex_digest(bytes: &[u8]) -> String { + bytes.iter().map(|byte| format!("{byte:02x}")).collect() +} + +fn print_json(action_id: &str, payload: serde_json::Value) -> Result<()> { + println!( + "{}", + serde_json::to_string(&serde_json::json!({ + "action_id": action_id, + "output": payload + }))? + ); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn counts_words() { + assert_eq!(count_words("hello world\nfrom codex"), 4); + } + + #[test] + fn creates_slug() { + assert_eq!(to_slug("Hello, World! Rust 2026"), "hello-world-rust-2026"); + } + + #[test] + fn hashes_supported_algorithms() { + assert_eq!( + hash_string(HashStringPayload { + text: "hello".to_string(), + algorithm: "sha256".to_string(), + }) + .unwrap(), + "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824" + ); + assert_eq!( + hash_string(HashStringPayload { + text: "hello".to_string(), + algorithm: "md5".to_string(), + }) + .unwrap(), + "5d41402abc4b2a76b9719d911017c592" + ); + assert_eq!( + hash_string(HashStringPayload { + text: "hello".to_string(), + algorithm: "sha1".to_string(), + }) + .unwrap(), + "aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d" + ); + } + + #[test] + fn truncates_by_chars() { + let truncated = truncate_text(TruncateTextPayload { + text: "abcdef".to_string(), + max_chars: 3, + }) + .unwrap(); + assert_eq!(truncated.text, "abc"); + assert!(truncated.truncated); + assert!( + truncate_text(TruncateTextPayload { + text: "abc".to_string(), + max_chars: 0, + }) + .is_err() + ); + } +}