diff --git a/crates/memoryd/src/serve.rs b/crates/memoryd/src/serve.rs index 1aae69c..d2a3284 100644 --- a/crates/memoryd/src/serve.rs +++ b/crates/memoryd/src/serve.rs @@ -8,9 +8,9 @@ use std::sync::{Arc, Mutex, MutexGuard}; use std::time::Duration; use bxcore::{ - DataClass, Empty, Envelope, ErrorCode, MemoryAdminReply, MemoryArgs, MemoryBlockReply, - MemoryCall, MemoryCommand, Message, PROTOCOL_VERSION, RecallArgs, Reindexed, SessionId, - SessionUpdated, Timestamp, ToolResponse, WireError, read_frame, write_frame, + DataClass, Empty, Envelope, ErrorCode, MemoryArgs, MemoryBlockReply, MemoryCall, Message, + PROTOCOL_VERSION, RecallArgs, SessionId, SessionUpdated, Timestamp, ToolResponse, WireError, + read_frame, write_frame, }; use crate::config::Config; @@ -18,7 +18,6 @@ use crate::edit::{self, Context}; use crate::files::Store; use crate::honcho::ledger::{self, LedgerError}; use crate::honcho::mirror::Mirror; -use crate::honcho::report; use crate::honcho::search; use crate::honcho::state; use crate::honcho::turns::TurnSync; @@ -27,6 +26,8 @@ use crate::index::{self, Index}; use crate::recall::Recalled; use crate::{journal, recall, review}; +mod admin; + /// How long a peer may take to send its request. pub const READ_WAIT: Duration = Duration::from_secs(10); @@ -429,67 +430,4 @@ impl Memoryd { (self.log)(format!("memoryd: notes not indexed: {e}").as_str()); } } - - fn reindex(&self) -> Message { - let mut state = self.lock(); - let max_class = self.config.index.max_class; - match index::rebuild( - &self.config.index_path(), - max_class, - &self.config.sessions_dir(), - &self.store.path("notes.md"), - ) { - Ok((counts, problems)) => { - for problem in problems { - (self.log)(format!("memoryd: not indexed: {problem}").as_str()); - } - match Index::open(&self.config.index_path(), max_class) { - Ok(index) => { - state.index = index; - Message::Reindexed(Reindexed { - logs: counts.logs, - records: counts.records, - notes: counts.notes, - }) - } - Err(e) => error(e), - } - } - Err(e) => error(e), - } - } - - fn admin(&self, command: MemoryCommand, arg: &str) -> Message { - let state = self.lock(); - let now = Timestamp::now(); - let result = match command { - MemoryCommand::Log => { - let n = if arg.is_empty() { - 10 - } else { - match arg.parse::() { - Ok(n) => n, - Err(_) => return error(format!("log takes a count; {arg:?} is not one")), - } - }; - journal::log(&self.store, n) - } - MemoryCommand::Undo => journal::undo(&self.store, arg, now), - MemoryCommand::Pending => review::list(&self.store), - MemoryCommand::Accept => review::accept(&self.store, arg, now), - MemoryCommand::Reject => review::reject(&self.store, arg, now), - MemoryCommand::Sync => Ok(match &self.mirror { - Some(mirror) => mirror.report(), - None => report::NOT_CONFIGURED.to_string(), - }), - }; - if result.is_ok() && matches!(command, MemoryCommand::Undo | MemoryCommand::Accept) { - self.mirror(None); - } - self.index_notes(&state); - match result { - Ok(text) => Message::MemoryAdminReply(MemoryAdminReply { text }), - Err(e) => error(e), - } - } } diff --git a/crates/memoryd/src/serve/admin.rs b/crates/memoryd/src/serve/admin.rs new file mode 100644 index 0000000..8e13e2f --- /dev/null +++ b/crates/memoryd/src/serve/admin.rs @@ -0,0 +1,75 @@ +//! The owner's requests on `memoryd`'s admin socket: `reindex`, and the memory commands `log`, +//! `undo`, `pending`, `accept`, `reject` and `sync` (M5a spec, section 10; M5c spec, section 5). +//! Moved out of `serve.rs` as they were, to keep it under the line limit. + +use bxcore::{MemoryAdminReply, MemoryCommand, Message, Reindexed, Timestamp}; + +use super::{Memoryd, error}; +use crate::honcho::report; +use crate::index::{self, Index}; +use crate::{journal, review}; + +impl Memoryd { + pub(super) fn reindex(&self) -> Message { + let mut state = self.lock(); + let max_class = self.config.index.max_class; + match index::rebuild( + &self.config.index_path(), + max_class, + &self.config.sessions_dir(), + &self.store.path("notes.md"), + ) { + Ok((counts, problems)) => { + for problem in problems { + (self.log)(format!("memoryd: not indexed: {problem}").as_str()); + } + match Index::open(&self.config.index_path(), max_class) { + Ok(index) => { + state.index = index; + Message::Reindexed(Reindexed { + logs: counts.logs, + records: counts.records, + notes: counts.notes, + }) + } + Err(e) => error(e), + } + } + Err(e) => error(e), + } + } + + pub(super) fn admin(&self, command: MemoryCommand, arg: &str) -> Message { + let state = self.lock(); + let now = Timestamp::now(); + let result = match command { + MemoryCommand::Log => { + let n = if arg.is_empty() { + 10 + } else { + match arg.parse::() { + Ok(n) => n, + Err(_) => return error(format!("log takes a count; {arg:?} is not one")), + } + }; + journal::log(&self.store, n) + } + MemoryCommand::Undo => journal::undo(&self.store, arg, now), + MemoryCommand::Pending => review::list(&self.store), + MemoryCommand::Accept => review::accept(&self.store, arg, now), + MemoryCommand::Reject => review::reject(&self.store, arg, now), + MemoryCommand::Sync => Ok(match &self.mirror { + Some(mirror) => mirror.report(), + None => report::NOT_CONFIGURED.to_string(), + }), + }; + if result.is_ok() && matches!(command, MemoryCommand::Undo | MemoryCommand::Accept) { + self.mirror(None); + } + self.index_notes(&state); + match result { + Ok(text) => Message::MemoryAdminReply(MemoryAdminReply { text }), + Err(e) => error(e), + } + } +} diff --git a/crates/memoryd/tests/serve_admin.rs b/crates/memoryd/tests/serve_admin.rs new file mode 100644 index 0000000..0d4d039 --- /dev/null +++ b/crates/memoryd/tests/serve_admin.rs @@ -0,0 +1,173 @@ +//! The owner's commands on `memoryd`'s admin socket, end to end (M5a spec, section 10): `log` +//! shows the last ten edits unless told how many; `reject` discards a held write and `accept` +//! applies one; what an accepted write moves to `notes.md` is found by `recall` at once; a +//! reindex counts the notes. They pin `serve/admin.rs`, which M5c-2 task 11 moved out of +//! `serve.rs`. Do not edit. + +#[path = "support/tmp.rs"] +mod tmp; + +use std::os::unix::net::UnixStream; +use std::path::Path; +use std::sync::Arc; +use std::time::Duration; + +use bxcore::{ + CallId, DataClass, Empty, Envelope, MemoryAdmin, MemoryCall, MemoryCommand, Message, SessionId, + ToolRequest, ToolResponse, read_frame, write_frame, +}; +use memoryd::config::Config; +use memoryd::serve; +use tmp::TempDir; + +struct Running { + config: Config, + /// Removed when the test ends; declared last, so it outlives what writes into it (T44). + dir: TempDir, +} + +fn start(tag: &str, review: bool) -> Running { + let dir = TempDir::new(tag); + dir.write("etc/soul.md", "You are Boxmaker.\n"); + let text = format!( + "[paths]\nhome = \"{}\"\n[memory]\nsoul_starter = \"{}\"\nreview = {review}\n", + dir.path().display(), + dir.path().join("etc/soul.md").display() + ); + let config = Config::parse(&text).unwrap(); + let log: serve::Log = Arc::new(|_: &str| {}); + let running = serve::start(config.clone(), log).unwrap(); + std::thread::spawn(move || running.run()); + Running { config, dir } +} + +fn ask(socket: &Path, msg: Message) -> Message { + let mut stream = UnixStream::connect(socket).unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(10))) + .unwrap(); + let env = Envelope { + v: 1, + id: 9, + r#final: true, + msg, + }; + write_frame(&mut stream, &env).unwrap(); + read_frame(&mut stream).unwrap().msg +} + +/// A `memory` or `recall` call from a clean session; the answer's text. +fn call(r: &Running, tool: &str, arguments: &str) -> String { + let msg = Message::MemoryCall(MemoryCall { + request: ToolRequest { + session: SessionId::new("mm-a").unwrap(), + call: CallId(1), + tool: tool.to_string(), + arguments: arguments.to_string(), + }, + taint: DataClass::Private, + untrusted: false, + }); + match ask(&r.config.broker_socket(), msg) { + Message::ToolResponse(ToolResponse::Result { content, .. }) => content, + other => panic!("not a result: {other:?}"), + } +} + +fn admin(r: &Running, command: MemoryCommand, arg: &str) -> String { + let msg = Message::MemoryAdmin(MemoryAdmin { + command, + arg: arg.to_string(), + }); + match ask(&r.config.admin_socket(), msg) { + Message::MemoryAdminReply(reply) => reply.text, + other => panic!("{other:?}"), + } +} + +fn memory_md(r: &Running) -> String { + std::fs::read_to_string(r.dir.path().join("memory/memory.md")).unwrap_or_default() +} + +/// The id of the newest write waiting for review. +fn newest_pending(r: &Running) -> String { + let pending = admin(r, MemoryCommand::Pending, ""); + let last = pending.lines().last().unwrap_or_default(); + last.split_whitespace() + .next() + .unwrap_or_default() + .to_string() +} + +#[test] +fn log_shows_the_last_ten_edits_unless_told_how_many() { + let r = start("admin-log", false); + for n in 1..=12 { + call( + &r, + "memory", + &format!(r#"{{"action":"note","text":"note {n}"}}"#), + ); + } + assert_eq!(admin(&r, MemoryCommand::Log, "").lines().count(), 10); + assert_eq!(admin(&r, MemoryCommand::Log, "3").lines().count(), 3); + assert_eq!(admin(&r, MemoryCommand::Log, "20").lines().count(), 12); +} + +#[test] +fn reject_discards_a_held_write_and_accept_applies_one() { + let r = start("admin-review", true); + call( + &r, + "memory", + r#"{"action":"add","file":"memory","text":"Rejected fact."}"#, + ); + let rejected = newest_pending(&r); + assert!(admin(&r, MemoryCommand::Reject, &rejected).starts_with("rejected: ")); + assert_eq!(memory_md(&r), ""); + call( + &r, + "memory", + r#"{"action":"add","file":"memory","text":"Accepted fact."}"#, + ); + let accepted = newest_pending(&r); + assert!(admin(&r, MemoryCommand::Accept, &accepted).starts_with("accepted: ")); + assert_eq!(memory_md(&r), "Accepted fact.\n"); +} + +/// An accepted `consolidate` moves an entry to `notes.md`; the admin command indexes it, so +/// `recall` finds it at once, as identity moved out of `memory.md`. +#[test] +fn what_an_accepted_write_moves_to_notes_is_recalled_at_once() { + let r = start("admin-notes", true); + call( + &r, + "memory", + r#"{"action":"add","file":"memory","text":"Zebra crossings are painted on Mondays."}"#, + ); + admin(&r, MemoryCommand::Accept, &newest_pending(&r)); + assert_eq!( + call(&r, "recall", r#"{"query":"zebra"}"#), + "(no matches for \"zebra\")" + ); + call( + &r, + "memory", + r#"{"action":"consolidate","file":"memory","entries":[{"old":"Zebra crossings are painted on Mondays.","move":true}]}"#, + ); + admin(&r, MemoryCommand::Accept, &newest_pending(&r)); + assert_eq!(memory_md(&r), ""); + let found = call(&r, "recall", r#"{"query":"zebra"}"#); + assert!(found.starts_with("[1] identity ยท moved "), "{found}"); +} + +#[test] +fn a_reindex_counts_the_notes() { + let r = start("admin-reindex", false); + call(&r, "memory", r#"{"action":"note","text":"one note"}"#); + let Message::Reindexed(counts) = ask(&r.config.admin_socket(), Message::Reindex(Empty {})) + else { + panic!("not reindexed") + }; + assert_eq!((counts.logs, counts.records, counts.notes), (0, 0, 1)); +} diff --git a/docs/implementer-log.md b/docs/implementer-log.md index aa40b71..c871cb9 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 | |---|---|---|---|---|---|---|---| +| 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 | | M5c2/09-memoryd-recall-honcho | 2026-09-29 | done | 1 | pass | none | Copied the given test `crates/memoryd/tests/honcho_recall_serve.rs` (new). Step 2 (`cargo test --no-fail-fast -p memoryd --test honcho_recall_serve`) showed exactly the two named failures: `a_fleet_hit_makes_the_answer_untrusted` (only the local hit, no Honcho hit shown) and `a_hanging_source_holds_up_no_memory_call` (`the fleet was never searched`); the other two passed already. Made the edits exactly as the task gives: S1 (`search.rs`, `Reading { readers, owner, wait }` and `Reading::recall` = `search` then `answer`, before the `Searched` doc); E1 (`use crate::honcho::search;`); E2 (`Memoryd` gains `reading: Option` after `mirror`, before `log`); E3 (`start` binds `(opened, owner)`, `owner = honcho.owner.clone()` or `String::new()`); E4 (`start` builds `search::Reading { readers: Arc::clone(&opened.readers), owner, wait: search::SOURCE_WAIT }` and the match yields `(honcho, mirror, reading)`); E5 (`reading` in the `Memoryd { .. }`); E6 (`call` drops the outer lock, `recall` now `self.recall(&call)`, `memory` `self.memory(&self.lock(), &call)`); E7 (`recall` without `[honcho]` answers the local index under the lock as before, with it runs `recall::local` in a lock block then `reading.recall(local, &args, &self.log)` with the lock released). `cargo check -p memoryd` clean, no warning. `cargo test` passes: 4 in `honcho_recall_serve`, 12 in `honcho_search`, 6 in `honcho_serve`, 8 in `serve`, 5 in `serve_sanitize`. `git diff --stat` lists exactly `search.rs` and `serve.rs`; `wc -l crates/memoryd/src/serve.rs` prints 495. `cargo fmt --all` put the `owner,` of E4's `Reading` on its own line (rustfmt-canonical, the task's block was one line); the edits otherwise match. `make gate` printed `gate: ok` with 1434 tests (1433 in `cargo test --workspace`, plus the 1 ignored `end_to_end`; the +4 over the 1430 before is exactly the tests of `honcho_recall_serve.rs`). No new dependency, so `Cargo.lock` unchanged. | ornith-1.5-35b-a3b | | M5c2/08-memoryd-honcho-search | 2026-09-29 | done | 1 | pass | none | Copied the given test `crates/memoryd/tests/honcho_search.rs` (new), a new version of `crates/memoryd/tests/support/honcho.rs` (from `honcho.rs-task08`, replacing the tree's), and the skeleton `crates/memoryd/src/honcho/search.rs` (fill 2 bodies). Step 2 (`cargo build --tests --keep-going -p memoryd`) showed exactly the one named failure: `honcho_search` with one E0432 `unresolved import memoryd::honcho::search`; every other target compiled. Made H1 (`pub mod search;` after `pub mod report;` in `honcho/mod.rs`). 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: `source` (`search_workspace` with `limit: Some(k)` via `MessageSearch { limit: Some(k), ..MessageSearch::new(query) }`, error `classify(&e)` then `?`; match `&reader.source`, `Source::Agent(peer)` asks `conclusions(reader, peer, query, k)?` then `conclusions(reader, owner, query, k)?` in that order, any other source both `Vec::new()` with no conclusion asked; `items(&reader.label, query, &messages, &about_agent, &about_owner)`); `answer` (`k = usize::try_from(k).unwrap_or(usize::MAX)`; `Secret` -> `(Vec::new(), SECRET.to_string())`, `Asked { found, unavailable: missing }` (field bound `missing`) -> `interleave(local.items.len(), k, found)` and `unavailable(&names)` with `names` the missing names as `Vec<&str>``; `(class, untrusted) = label(&shown)`; `first = local.items.len() + 1`; `parts = local.items`, push `render(first, &shown)` when shown non-empty, `(no matches for "...")` when parts still empty, then `tail` when non-empty; `Recalled { text: parts.join("\n"), class: local.class.max(class), untrusted: local.untrusted || untrusted }`). `cargo check -p memoryd` clean, no warning. `cargo test` passes: 13 in `honcho_client`, 3 in `honcho_keys` (1 ignored), 8 in `honcho_read`, 12 in `honcho_search`, 12 in `honcho_worker`. `git diff --stat` lists exactly `mod.rs` and `support/honcho.rs`; `git status --short` shows `search.rs` and `honcho_search.rs` as new. `make gate` printed `gate: ok` with 1430 tests (1429 in `cargo test --workspace`, plus the 1 ignored `end_to_end`; the +12 over the 1418 before is exactly the tests of `honcho_search.rs`). No new dependency, so `Cargo.lock` unchanged. | ornith-1.5-35b-a3b |