From 518d6ec41630d2d0a186cb4b5f2aeef29fae807f Mon Sep 17 00:00:00 2001 From: Kyle Isom Date: Fri, 2 Oct 2026 23:25:47 -0700 Subject: [PATCH] loopd: [compact] threshold, summary_chars and summarise Co-Authored-By: ornith-1.5-35b-a3b --- crates/loopd/src/config.rs | 27 ++++++ crates/loopd/tests/config_compact.rs | 119 +++++++++++++++++++++++++++ docs/implementer-log.md | 1 + 3 files changed, 147 insertions(+) create mode 100644 crates/loopd/tests/config_compact.rs diff --git a/crates/loopd/src/config.rs b/crates/loopd/src/config.rs index 5329e39..e589b90 100644 --- a/crates/loopd/src/config.rs +++ b/crates/loopd/src/config.rs @@ -175,6 +175,8 @@ pub struct Config { pub broker: Broker, #[serde(default)] pub memory: Memory, + #[serde(default)] + pub compact: Compact, } /// `[memory]`: `memoryd`'s socket for `loopd` (M5a spec, section 9). An empty path means the @@ -185,6 +187,26 @@ pub struct Memory { pub socket: std::path::PathBuf, } +/// `[compact]`: when a session starts a new epoch, and how long a summary may be (M5d spec, +/// section 5). `summarise` is stored here and unused until M5d-2. +#[derive(Debug, Clone, PartialEq, Deserialize)] +#[serde(deny_unknown_fields, default)] +pub struct Compact { + pub threshold: f64, + pub summary_chars: usize, + pub summarise: Vec, +} + +impl Default for Compact { + fn default() -> Self { + Self { + threshold: 0.85, + summary_chars: 4_000, + summarise: vec!["http_fetch".to_string()], + } + } +} + #[derive(Debug)] pub enum ConfigError { Read(std::path::PathBuf, std::io::Error), @@ -232,4 +254,9 @@ impl Config { self.channel.socket.clone() } } + + /// `compact.md` beside the system prompt: the summary call's last user message. + pub fn compact_prompt(&self) -> std::path::PathBuf { + self.baseline.system.with_file_name("compact.md") + } } diff --git a/crates/loopd/tests/config_compact.rs b/crates/loopd/tests/config_compact.rs new file mode 100644 index 0000000..b07dd40 --- /dev/null +++ b/crates/loopd/tests/config_compact.rs @@ -0,0 +1,119 @@ +//! Tests for `[compact]` on `loopd.toml`. Do not edit. + +use loopd::config::Config; +use std::path::{Path, PathBuf}; + +fn fixture(name: &str) -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/config") + .join(name) +} + +fn minimal() -> String { + std::fs::read_to_string(fixture("minimal.toml")).unwrap() +} + +#[test] +fn missing_table_gets_the_documented_defaults() { + let c = Config::load(&fixture("minimal.toml")).unwrap(); + assert_eq!(c.compact.threshold, 0.85); + assert_eq!(c.compact.summary_chars, 4_000); + assert_eq!(c.compact.summarise, vec!["http_fetch".to_string()]); +} + +#[test] +fn the_table_overrides_every_default() { + let text = format!( + "{}\n[compact]\nthreshold = 0.5\nsummary_chars = 100\nsummarise = [\"clock\"]\n", + minimal() + ); + let c = Config::parse(&text).unwrap(); + assert_eq!(c.compact.threshold, 0.5); + assert_eq!(c.compact.summary_chars, 100); + assert_eq!(c.compact.summarise, vec!["clock".to_string()]); +} + +#[test] +fn a_partial_table_keeps_the_other_defaults() { + let text = format!("{}\n[compact]\nthreshold = 0.9\n", minimal()); + let c = Config::parse(&text).unwrap(); + assert_eq!(c.compact.threshold, 0.9); + assert_eq!(c.compact.summary_chars, 4_000); + assert_eq!(c.compact.summarise, vec!["http_fetch".to_string()]); +} + +#[test] +fn unknown_keys_are_errors() { + let text = minimal(); + let bad = format!("{text}\n[compact]\nzz_unknown = 1\n"); + assert!( + Config::parse(&bad).is_err(), + "[compact] accepted an unknown key" + ); + let misspelt = format!("{text}\n[compact]\nsummary_char = 10\n"); + assert!( + Config::parse(&misspelt).is_err(), + "[compact] accepted a misspelt key" + ); +} + +#[test] +fn empty_summarise_is_allowed() { + let text = format!("{}\n[compact]\nsummarise = []\n", minimal()); + let c = Config::parse(&text).unwrap(); + assert!(c.compact.summarise.is_empty()); +} + +#[test] +fn compact_prompt_is_compact_md_beside_system_md() { + let c = Config::load(&fixture("minimal.toml")).unwrap(); + assert_eq!(c.compact_prompt(), fixture("compact.md")); + let home = Scratch::new(); + let cfg_path = home.dir.join("loopd.toml"); + std::fs::write(&cfg_path, minimal()).unwrap(); + std::fs::write(home.dir.join("system.md"), "sys\n").unwrap(); + let c = Config::load(&cfg_path).unwrap(); + assert_eq!(c.compact_prompt(), home.dir.join("compact.md")); +} + +struct Scratch { + dir: PathBuf, +} + +impl Scratch { + fn new() -> Scratch { + let dir = std::env::temp_dir().join(format!( + "loopd-compact-cfg-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&dir).unwrap(); + Scratch { dir } + } +} + +impl Drop for Scratch { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.dir); + } +} + +#[test] +fn compact_prompt_follows_an_overridden_system_path() { + let text = std::fs::read_to_string(fixture("m2b.toml")).unwrap(); + let c = Config::load(&fixture("m2b.toml")).unwrap(); + assert!( + text.contains("prompts/agent.md"), + "m2b fixture names a prompt in a subdirectory" + ); + assert_eq!(c.compact_prompt(), fixture("prompts/compact.md")); +} + +#[test] +fn defaults_match_the_struct_default() { + let c = Config::load(&fixture("minimal.toml")).unwrap(); + assert_eq!(c.compact, loopd::config::Compact::default()); +} diff --git a/docs/implementer-log.md b/docs/implementer-log.md index 103155d..4db8759 100644 --- a/docs/implementer-log.md +++ b/docs/implementer-log.md @@ -6,6 +6,7 @@ reviewer adds findings under "Reviews" once per milestone. | Task | Date | Status | Gate runs | First gate | Deviations | Notes | Model | |---|---|---|---|---|---|---|---| +| M5d1/01-loopd-compact-config | 2026-10-02 | done | 1 | pass | none | Copied the given test `crates/loopd/tests/config_compact.rs` (new). Step 2 (`cargo build --tests --keep-going -p loopd`) showed exactly the named failures: `config_compact` with E0609 (`no field \`compact\` on type \`Config\``), E0599 (`no method named \`compact_prompt\``) and E0433 (`no variant or associated item named \`default\` for \`Compact\``); every other target compiled. Made the edits exactly as the task gives: C1 (new `Compact` struct after `Memory`, `#[derive(Debug, Clone, PartialEq, Deserialize)]` plus `#[serde(deny_unknown_fields, default)]`, fields in order `threshold: f64`, `summary_chars: usize`, `summarise: Vec`, and `Default` = threshold `0.85`, `summary_chars` `4_000`, `summarise` `vec!["http_fetch".to_string()]`); C2 (`Config` gains `#[serde(default)] pub compact: Compact` after `memory`); C3 (`compact_prompt` after `channel_socket`, `self.baseline.system.with_file_name("compact.md")`). `cargo check -p loopd` clean, no error or warning. `cargo test -p loopd --test config_compact --test config` passes: 8 in `config_compact`, 11 in `config`. `git diff --stat` lists only `config.rs`; `cargo fmt --all` changed nothing. `make gate` printed `gate: ok` with 1464 tests (1463 in `cargo test --workspace`, plus the 1 ignored `end_to_end`; the +8 over the 1456 before is exactly the tests of `config_compact.rs`). No new dependency, so `Cargo.lock` unchanged. | ornith-1.5-35b-a3b | | M5c2/12-memoryd-serve-ask-memory | 2026-09-29 | done | 1 | pass | none | Copied the given test `crates/memoryd/tests/honcho_ask_serve.rs` (new). Step 2 (`cargo test --no-fail-fast -p memoryd --test honcho_ask_serve`) showed exactly the six named failures: every `ask_memory` answered with `memoryd runs only recall and memory`, and the hang test after its 10 s deadline (`the chat was never sent`). Made A1 (`ask.rs`: `NOT_CONFIGURED` before `NO_ANSWER`, the sentence letter for letter), E1 (`use bxcore::AskMemoryArgs;`), E2 (`use crate::honcho::ask;`), E3 (`call` gains the third arm `bxcore::memory::ASK_MEMORY => self.ask_memory(&call)` and the unknown-tool sentence becomes `memoryd runs only recall, memory and ask_memory`), E4 (new `ask_memory`: decode `AskMemoryArgs`, `args.check()?`, the `[honcho]`-less branch returns `NOT_CONFIGURED` labelled private and trusted, else `ask::ask` then `ask::answer`). `cargo check -p memoryd` clean, no error or warning. `cargo test` passes: 6 in `honcho_ask_serve`, 10 in `honcho_ask`, 4 in `honcho_recall_serve`, 4 in `serve_admin`, 8 in `serve`. Step 5 all pass: `git diff --stat` lists only `ask.rs` and `serve.rs`; `wc -l crates/memoryd/src/serve.rs` prints 456. `make gate` printed `gate: ok` with 1454 tests (1453 in `cargo test --workspace`, plus the 1 ignored `end_to_end`; the +6 over the 1448 before is exactly the tests of `honcho_ask_serve.rs`). No new dependency, so `Cargo.lock` unchanged. | ornith-1.5-35b-a3b | | M5c2/11-memoryd-serve-admin | 2026-09-29 | done | 1 | pass | none | Copied the given test `crates/memoryd/tests/serve_admin.rs` (new) and the new module `crates/memoryd/src/serve/admin.rs` (given whole, `reindex` and `admin` moved unchanged from `serve.rs`, `fn` becoming `pub(super) fn`). Step 1 (`cargo test -p memoryd --test serve_admin`) passed all 4 before the move. Made E1 (dropped `MemoryAdminReply`, `MemoryCommand`, `Reindexed` from the `bxcore` use in `serve.rs`), E2 (dropped `use crate::honcho::report;`), E3 (added `mod admin;` after `use crate::{journal, recall, review};`), E4 (deleted `reindex` and `admin` from the end of `index_notes` to the end of the file). `cargo check -p memoryd` clean, no error or warning. `cargo test -p memoryd` passes: 4 in `serve_admin`, every other suite as before. Step 5 all pass: `wc -l crates/memoryd/src/serve.rs` prints 433; the `diff` of the moved `reindex` (the deleted one in `serve.rs` vs the `pub(super) fn` in `admin.rs`, `pub(super)` normalised) prints nothing; `git diff --stat` lists only `serve.rs` and `git status --short` shows `serve/` and `serve_admin.rs` as new. `make gate` printed `gate: ok` with 1448 tests (1447 in `cargo test --workspace`, plus the 1 ignored `end_to_end`; the +4 over the 1444 before is exactly the tests of `serve_admin.rs`). No new dependency, so `Cargo.lock` unchanged. | ornith-1.5-35b-a3b | | M5c2/10-memoryd-ask-memory | 2026-09-29 | done | 1 | pass | none | Copied the given test `crates/memoryd/tests/honcho_ask.rs` (new), a new version of `crates/memoryd/tests/support/honcho.rs` (from the plan's `files/`, replacing the tree's, adding the `Mode::Chat` variant and its arm), and the skeleton `crates/memoryd/src/honcho/ask.rs` (fill 2 bodies). Step 2 (`cargo build --tests --keep-going -p memoryd`) showed exactly the one named failure: `honcho_ask` with one E0432 `unresolved import memoryd::honcho::ask`; every other target compiled. Made H1 (`pub mod ask;` before `pub mod client;` in `honcho/mod.rs`, keeping alphabetical order) and R1 (`recall.rs`: `snippet` becomes `one_line(text, SNIPPET)`, new `one_line(text, cap)` with the old body). Filled its two bodies from their comments, deleting each instruction comment and keeping the doc comment, each from its comment and `cargo check -p memoryd` after: `chat` (`DialecticOptions { query: question.to_string(), reasoning_level: Some(ReasoningLevel::Minimal), ..Default::default() }`; `reader.chat().workspace_chat(&options).map_err(|e| classify(&e))?`; `Ok(response.content().to_string())`); `answer` (`match asked`: `NotSent(sentence)` -> `Recalled { text: sentence, class: bxcore::DataClass::Private, untrusted: false }`; `Sent(chats)` walk by reference, `Some(content)` -> `Item { tag: label.tag(), from: label.name.clone(), class: label.class, untrusted: label.untrusted(), text: one_line(content, ANSWER_CAP) or NO_ANSWER when that is empty once trimmed }`, `None` -> push the name as `&str`; `render(1, &items)` when items non-empty then `unavailable(&names)` when names non-empty; `(class, untrusted) = label(&items)`; `Recalled { text: parts.join("\n"), class, untrusted }`). `cargo check -p memoryd` clean, no warning after both bodies. `cargo test` passes: 10 in `honcho_ask`, 12 in `honcho_search`, 6 in `recall`, 4 in `recall_form`, 11 in `honcho_recalled`. `git diff --stat` lists exactly `mod.rs`, `recall.rs`, `support/honcho.rs`; the two new files are `ask.rs` and `honcho_ask.rs`. `make gate` printed `gate: ok` with 1444 tests (1443 in `cargo test --workspace`, plus the 1 ignored `end_to_end`; the +10 over the 1434 before is exactly the tests of `honcho_ask.rs`). No new dependency, so `Cargo.lock` unchanged. | ornith-1.5-35b-a3b |