From 0a1c206d50ff8b9720ad854b680e403d6f3a5e6a Mon Sep 17 00:00:00 2001 From: "K. Isom" Date: Thu, 24 Sep 2026 18:46:34 -0700 Subject: [PATCH] bxctl: reindex, and memory log, undo, pending, accept, reject Implemented-By: OpenCode session (model recorded in docs/implementer-log.md) --- crates/bxctl/src/cli.rs | 14 ++- crates/bxctl/src/lib.rs | 1 + crates/bxctl/src/main.rs | 10 ++ crates/bxctl/src/memory.rs | 140 +++++++++++++++++++++++ crates/bxctl/tests/cli_memory.rs | 185 +++++++++++++++++++++++++++++++ docs/implementer-log.md | 1 + 6 files changed, 350 insertions(+), 1 deletion(-) create mode 100644 crates/bxctl/src/memory.rs create mode 100644 crates/bxctl/tests/cli_memory.rs diff --git a/crates/bxctl/src/cli.rs b/crates/bxctl/src/cli.rs index fc552ba..a0a346a 100644 --- a/crates/bxctl/src/cli.rs +++ b/crates/bxctl/src/cli.rs @@ -28,7 +28,13 @@ usage: bxctl [options] Check that the grants load. bxctl audit verify [--home DIR] - Check the audit log's hash chain, reading the files directly."; + Check the audit log's hash chain, reading the files directly. + + bxctl reindex [--memory-socket PATH] + Rebuild memoryd's index from the session logs and notes. + + bxctl memory log [N] | undo | pending | accept | reject [--memory-socket PATH] + List memory edits, undo the latest edit of a file, or answer writes waiting for review."; // The options every `chat` call carries. `socket` is the loop socket; `admin_socket` is the admin // socket the other subcommands use. Both default to the paths under `$BOXMAKER_HOME`. @@ -90,6 +96,10 @@ pub enum Command { AuditVerify { home: PathBuf, }, + Memory { + socket: PathBuf, + ask: crate::memory::Ask, + }, } pub fn parse(args: &[String], home: &Path) -> Result { @@ -104,6 +114,8 @@ pub fn parse(args: &[String], home: &Path) -> Result { "refuse" | "deny" => parse_refuse(rest, home), "grants" => parse_grants(rest, home), "audit" => parse_audit(rest, home), + "reindex" => crate::memory::parse_reindex(rest, home), + "memory" => crate::memory::parse_memory(rest, home), // A flag before the command, or any unknown command, is not valid. _ => Err(UsageError), } diff --git a/crates/bxctl/src/lib.rs b/crates/bxctl/src/lib.rs index f53c8f8..d334b4c 100644 --- a/crates/bxctl/src/lib.rs +++ b/crates/bxctl/src/lib.rs @@ -4,4 +4,5 @@ pub mod admin; pub mod chat; pub mod cli; pub mod escape; +pub mod memory; pub mod verify; diff --git a/crates/bxctl/src/main.rs b/crates/bxctl/src/main.rs index e5ed98b..48c5f68 100644 --- a/crates/bxctl/src/main.rs +++ b/crates/bxctl/src/main.rs @@ -49,6 +49,16 @@ fn main() -> ExitCode { reason, } => exit(|| cmd_refuse(&admin_socket, approval, reason.as_deref())), Command::GrantsCheck { admin_socket } => exit(|| cmd_grants_check(&admin_socket)), + Command::Memory { socket, ask } => { + let mut out = std::io::stdout().lock(); + match bxctl::memory::run(&socket, &ask, bxctl::memory::MEMORY_TIMEOUT, &mut out) { + Ok(()) => ExitCode::SUCCESS, + Err(e) => { + eprintln!("bxctl: {e}"); + ExitCode::from(1) + } + } + } Command::AuditVerify { home } => { let mut out = std::io::stdout().lock(); match bxctl::verify::run(&home, &mut out) { diff --git a/crates/bxctl/src/memory.rs b/crates/bxctl/src/memory.rs new file mode 100644 index 0000000..3997001 --- /dev/null +++ b/crates/bxctl/src/memory.rs @@ -0,0 +1,140 @@ +//! The owner's memory commands over `memoryd`'s owner socket: `reindex`, and `memory log`, +//! `undo`, `pending`, `accept`, `reject` (M5a spec, section 10). + +use std::io::Write; +use std::os::unix::net::UnixStream; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use proto::{ + Empty, Envelope, MemoryAdmin, MemoryCommand, Message, PROTOCOL_VERSION, read_frame, write_frame, +}; + +use crate::cli::{Command, UsageError}; +use crate::escape::escape_json_text; + +/// How long `bxctl` waits for `memoryd`: a reindex reads every session log. +pub const MEMORY_TIMEOUT: Duration = Duration::from_secs(300); + +pub fn default_socket(home: &Path) -> PathBuf { + home.join("run").join("owner-memory").join("admin.sock") +} + +/// What to ask `memoryd`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Ask { + Reindex, + Admin(MemoryCommand, String), +} + +/// `bxctl reindex [--memory-socket PATH]`. +pub fn parse_reindex(args: &[String], home: &Path) -> Result { + let (socket, rest) = socket_flag(args, home)?; + if !rest.is_empty() { + return Err(UsageError); + } + Ok(Command::Memory { + socket, + ask: Ask::Reindex, + }) +} + +/// `bxctl memory log [N] | undo | pending | accept | reject `, each with an optional +/// `--memory-socket PATH`. +pub fn parse_memory(args: &[String], home: &Path) -> Result { + let (socket, rest) = socket_flag(args, home)?; + let rest: Vec<&str> = rest.iter().map(String::as_str).collect(); + let ask = match rest.as_slice() { + ["log"] => Ask::Admin(MemoryCommand::Log, String::new()), + ["log", n] => { + if n.parse::().is_err() { + return Err(UsageError); + } + Ask::Admin(MemoryCommand::Log, n.to_string()) + } + ["pending"] => Ask::Admin(MemoryCommand::Pending, String::new()), + ["undo", id] => Ask::Admin(MemoryCommand::Undo, id.to_string()), + ["accept", id] => Ask::Admin(MemoryCommand::Accept, id.to_string()), + ["reject", id] => Ask::Admin(MemoryCommand::Reject, id.to_string()), + _ => return Err(UsageError), + }; + Ok(Command::Memory { socket, ask }) +} + +/// Take `--memory-socket PATH` out of `args`, once at most; the rest in order. +fn socket_flag(args: &[String], home: &Path) -> Result<(PathBuf, Vec), UsageError> { + let mut socket = default_socket(home); + let mut rest = Vec::with_capacity(args.len()); + let mut seen = false; + let mut i = 0; + while i < args.len() { + if args[i] == "--memory-socket" { + if seen { + return Err(UsageError); + } + seen = true; + socket = PathBuf::from(args.get(i + 1).cloned().ok_or(UsageError)?); + i += 2; + } else { + rest.push(args[i].clone()); + i += 1; + } + } + Ok((socket, rest)) +} + +/// `n` with commas between thousands. +fn grouped(n: u64) -> String { + let digits = n.to_string(); + let mut out = String::with_capacity(digits.len() + digits.len() / 3); + for (i, c) in digits.chars().enumerate() { + if i > 0 && (digits.len() - i).is_multiple_of(3) { + out.push(','); + } + out.push(c); + } + out +} + +/// Ask `memoryd` and print its answer. `Err` is the line for stderr. +pub fn run(socket: &Path, ask: &Ask, timeout: Duration, out: &mut dyn Write) -> Result<(), String> { + let mut stream = UnixStream::connect(socket) + .map_err(|e| format!("cannot reach memoryd at {}: {e}", socket.display()))?; + stream + .set_read_timeout(Some(timeout)) + .map_err(|e| format!("cannot reach memoryd at {}: {e}", socket.display()))?; + stream + .set_write_timeout(Some(timeout)) + .map_err(|e| format!("cannot reach memoryd at {}: {e}", socket.display()))?; + + let msg = match ask { + Ask::Reindex => Message::Reindex(Empty {}), + Ask::Admin(command, arg) => Message::MemoryAdmin(MemoryAdmin { + command: *command, + arg: arg.clone(), + }), + }; + let env = Envelope { + v: PROTOCOL_VERSION, + id: 1, + r#final: true, + msg, + }; + write_frame(&mut stream, &env) + .map_err(|e| format!("cannot reach memoryd at {}: {e}", socket.display()))?; + + let reply = read_frame(&mut stream).map_err(|e| format!("memoryd did not answer: {e}"))?; + let text = match reply.msg { + Message::Reindexed(r) => format!( + "reindexed: {} logs, {} records, {} notes", + grouped(r.logs), + grouped(r.records), + grouped(r.notes) + ), + Message::MemoryAdminReply(reply) => reply.text, + Message::Error(e) => return Err(format!("memoryd: {}", escape_json_text(&e.detail))), + _ => return Err("memoryd answered with the wrong message".to_string()), + }; + + writeln!(out, "{text}").map_err(|e| e.to_string()) +} diff --git a/crates/bxctl/tests/cli_memory.rs b/crates/bxctl/tests/cli_memory.rs new file mode 100644 index 0000000..7fd3785 --- /dev/null +++ b/crates/bxctl/tests/cli_memory.rs @@ -0,0 +1,185 @@ +//! `bxctl reindex` and `bxctl memory`: parsing, what is sent to `memoryd`, and what is printed +//! (M5a spec, section 10). Do not edit. + +use std::os::unix::net::UnixListener; +use std::path::{Path, PathBuf}; +use std::sync::mpsc; +use std::time::Duration; + +use bxctl::cli::{Command, UsageError, parse}; +use bxctl::memory::{Ask, run}; +use proto::{ + Envelope, ErrorCode, MemoryAdminReply, MemoryCommand, Message, Reindexed, WireError, + read_frame, write_frame, +}; + +fn args(line: &str) -> Vec { + line.split_whitespace().map(str::to_string).collect() +} + +fn home() -> PathBuf { + PathBuf::from("/srv/bx") +} + +fn memory(ask: Ask) -> Result { + Ok(Command::Memory { + socket: PathBuf::from("/srv/bx/run/owner-memory/admin.sock"), + ask, + }) +} + +#[test] +fn the_commands_parse() { + let h = home(); + assert_eq!(parse(&args("reindex"), &h), memory(Ask::Reindex)); + assert_eq!( + parse(&args("memory log"), &h), + memory(Ask::Admin(MemoryCommand::Log, String::new())) + ); + assert_eq!( + parse(&args("memory log 3"), &h), + memory(Ask::Admin(MemoryCommand::Log, "3".to_string())) + ); + assert_eq!( + parse(&args("memory undo 2026-09-24-0007"), &h), + memory(Ask::Admin( + MemoryCommand::Undo, + "2026-09-24-0007".to_string() + )) + ); + assert_eq!( + parse(&args("memory pending"), &h), + memory(Ask::Admin(MemoryCommand::Pending, String::new())) + ); + assert_eq!( + parse(&args("memory accept p-1"), &h), + memory(Ask::Admin(MemoryCommand::Accept, "p-1".to_string())) + ); + assert_eq!( + parse(&args("memory reject p-1"), &h), + memory(Ask::Admin(MemoryCommand::Reject, "p-1".to_string())) + ); + assert_eq!( + parse(&args("reindex --memory-socket /r/m.sock"), &h), + Ok(Command::Memory { + socket: PathBuf::from("/r/m.sock"), + ask: Ask::Reindex + }) + ); + assert_eq!( + parse(&args("memory --memory-socket /r/m.sock undo x"), &h), + Ok(Command::Memory { + socket: PathBuf::from("/r/m.sock"), + ask: Ask::Admin(MemoryCommand::Undo, "x".to_string()) + }) + ); +} + +#[test] +fn bad_lines_are_usage_errors() { + for line in [ + "memory", + "memory log ten", + "memory log 1 2", + "memory undo", + "memory undo a b", + "memory forget x", + "memory pending x", + "reindex now", + "reindex --memory-socket", + "reindex --memory-socket /a --memory-socket /b", + ] { + assert_eq!(parse(&args(line), &home()), Err(UsageError), "{line}"); + } +} + +/// A fake `memoryd` answering one request with `answer`; hands back what it was sent. +fn fake(socket: &Path, answer: Message) -> mpsc::Receiver { + let listener = UnixListener::bind(socket).unwrap(); + let (tx, rx) = mpsc::channel(); + std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let env = read_frame(&mut stream).unwrap(); + tx.send(env.msg).unwrap(); + let reply = Envelope { + v: 1, + id: env.id, + r#final: true, + msg: answer, + }; + write_frame(&mut stream, &reply).unwrap(); + }); + rx +} + +fn dir(tag: &str) -> PathBuf { + let path = std::env::temp_dir().join(format!("bx-mem-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&path); + std::fs::create_dir_all(&path).unwrap(); + path +} + +#[test] +fn reindex_prints_the_counts() { + let d = dir("reindex"); + let socket = d.join("admin.sock"); + let seen = fake( + &socket, + Message::Reindexed(Reindexed { + logs: 42, + records: 1208, + notes: 7, + }), + ); + let mut out = Vec::new(); + run(&socket, &Ask::Reindex, Duration::from_secs(5), &mut out).unwrap(); + assert_eq!( + String::from_utf8(out).unwrap(), + "reindexed: 42 logs, 1,208 records, 7 notes\n" + ); + assert!(matches!(seen.recv().unwrap(), Message::Reindex(_))); +} + +#[test] +fn an_admin_command_prints_the_reply_and_an_error_is_escaped() { + let d = dir("admin"); + let socket = d.join("admin.sock"); + let seen = fake( + &socket, + Message::MemoryAdminReply(MemoryAdminReply { + text: "undone: 2026-09-24-0007".to_string(), + }), + ); + let mut out = Vec::new(); + let ask = Ask::Admin(MemoryCommand::Undo, "2026-09-24-0007".to_string()); + run(&socket, &ask, Duration::from_secs(5), &mut out).unwrap(); + assert_eq!(String::from_utf8(out).unwrap(), "undone: 2026-09-24-0007\n"); + match seen.recv().unwrap() { + Message::MemoryAdmin(m) => { + assert_eq!( + (m.command, m.arg.as_str()), + (MemoryCommand::Undo, "2026-09-24-0007") + ) + } + other => panic!("{other:?}"), + } + let socket = d.join("refuses.sock"); + let _seen = fake( + &socket, + Message::Error(WireError { + code: ErrorCode::Internal, + detail: "user.md has changed\u{1b}[31m".to_string(), + }), + ); + let err = run(&socket, &ask, Duration::from_secs(5), &mut Vec::new()).unwrap_err(); + assert!(err.starts_with("memoryd: user.md has changed"), "{err}"); + assert!(!err.contains('\u{1b}'), "escaped: {err}"); + let err = run( + &d.join("nobody.sock"), + &ask, + Duration::from_secs(1), + &mut Vec::new(), + ) + .unwrap_err(); + assert!(err.starts_with("cannot reach memoryd at "), "{err}"); +} diff --git a/docs/implementer-log.md b/docs/implementer-log.md index 6d1bade..6f8264f 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 | |---|---|---|---|---|---|---|---| +| M5a/14-bxctl-memory | 2026-09-24 | done | 1 | pass | none | Copied the skeleton `crates/bxctl/src/memory.rs` and the test `crates/bxctl/tests/cli_memory.rs` into place, and added `pub mod memory;` after `pub mod escape;` in `lib.rs`. In `cli.rs` opened the last USAGE entry (the audit line lost its closing `";`) and appended the `reindex` and `memory` blocks, added the `Memory { socket, ask }` variant to `Command` after `AuditVerify`, and the `reindex`/`memory` arms to `parse`. In `main.rs` added the `Command::Memory` arm before `AuditVerify`. Filled the five functions the skeleton left as `todo!()` in `memory.rs`: `parse_reindex` (`socket_flag`, then any argument left is a usage error, else `Ask::Reindex`); `parse_memory` (`socket_flag`, then a slice match on the rest — `["log"]`, `["log", n]` when `n` parses as usize, `["pending"]`, `["undo"|"accept"|"reject", id]`, anything else a usage error); `socket_flag` (walk args, `--memory-socket` takes the next argument as the socket, a second one or a missing value is a usage error, every other argument kept in order, the socket defaults to `default_socket(home)`); `grouped` (the digits of n with a comma before each char at index i>0 where `len-i` is a multiple of 3); and `run` (connect to the socket, set read and write timeouts, write the id-1 final envelope, read the reply, print the reindexed counts or the reply text, escape an error detail, a write error as a String). `cargo fmt --all`; `cli_memory` 4 passed (they failed on the `todo!()`s first), the full bxctl suite green; `make gate` printed `gate: ok` on the first run. No new dependency, so `Cargo.lock` is unchanged. | ? | | M5a/13-loopd-memory | 2026-09-24 | done | 1 | pass | none | Copied the skeleton `crates/loopd/src/memory.rs` and the tests `crates/loopd/tests/memory.rs` plus new versions of `crates/loopd/tests/baseline.rs`, `crates/loopd/tests/pointers.rs` and `crates/loopd/tests/channel.rs` into place, and made the runbook changes (replaced `## core-memory-unreadable` with the contents of `runbook-memory-unavailable.md`, appended `runbook-memory-index-damaged.md` to the end). Added `pub mod memory;` after `pub mod llama;` in `lib.rs`. In `config.rs` added `#[serde(default)] pub memory: Memory,` after `pub broker: Broker,` in `Config`, the `Memory` struct (empty socket means the default under `home`) after `struct Config`, and `memory_socket()` before `channel_socket`. In `baseline.rs` changed the module comment ("the memory block and core tool schemas"), removed the `Core(PathBuf, io::Error)` variant and its Display arm (the `#core-memory-unreadable` one), and replaced `assemble` whole with `assemble` (calls `assemble_with(cfg, registry, "")`) and `assemble_with` (system trimmed, then a blank line and the trimmed block when non-empty). In `tools.rs` added `m5a()` before `core_schemas`: `m3a().entries` with `recall` (id 1) and `memory` (id 2) inserted as core. In `channel.rs` added `use crate::memory::MemorySource;`, the `memory: Box` field on `Context` and a last `memory` param on `Context::new`; in `handle` replaced the `Baseline::assemble` call with `ctx.memory.block().and_then(|block| Baseline::assemble_with(...))`, and after `drop(held)` in step 5 added the best-effort `ctx.memory.updated(&turn.session)` for a finished turn. In `main.rs` imported `MEMORY_WAIT, MemoryPort` and built `MemoryPort::new(cfg.memory_socket(), MEMORY_WAIT)` with `Registry::m5a()`. Appended the memory sentence to `config/system.md`. Filled the three `todo!()`s in `memory.rs`: `MemoryPort::ask` (connect, set read and write timeouts to `self.wait`, write the final id-1 envelope, read it — `Ok(its msg)`; every error `format!("memoryd at {}: {e}; {UNAVAILABLE}", socket)`); `block` (`MemoryBlock(Empty)` -> `MemoryBlockReply.reply.text`; `Error` -> `memoryd: {detail}; {UNAVAILABLE}`; else `wrong message; {UNAVAILABLE}`); `updated` (`SessionUpdated` -> `Ok(())`; `Error` -> `memoryd: {detail}`; else `wrong message`). `cargo fmt --all`; `memory` 4 and `pointers` 7 passed, the full loopd suite green; `make gate` printed `gate: ok` on the first run. No new dependency, so `Cargo.lock` is unchanged. | ? | | M5a/12-brokerd-memory-port | 2026-09-24 | done | 2 | fail | none | Copied the skeleton `crates/brokerd/src/memory_port.rs` and the test `crates/brokerd/tests/memory_port.rs` into place, a new `support/rig.rs` (its `Config` literal gains `memory: Default::default()`), and appended the `memoryd-unreachable` runbook entry. Added `pub mod memory_port;` after `pub mod ledger;` in `lib.rs`. In `policy/mod.rs` added the private `state` field to `Decision`, the `state()` accessor after `request()`, and threaded `state` through the `decide`/`redecide` literals (both already take it as a parameter). In `runner.rs` widened the `use` to `::{Decision, Label, SessionState}`; `RunSpec` gained a `state` field and `state()`; `Runtime` gained a default `run_labelled` that calls `run` and returns no label so existing runtimes are unchanged; `run` now calls `run_labelled` and keeps the stricter of the found label and the grant's (higher class via `.max`, untrusted if either). In `container.rs` added the `IN_MEMORYD` const and a `run` guard that refuses a memory tool before spawning. In `config.rs` added the `Memory` struct before `default_podman`, the `memory` field on `Config`, and `memory_socket()` before `audit_dir`. In `main.rs` imported `MemoryPort`/`Routed`/`MEMORY_WAIT` and wrapped the runtime in `Routed { memory: MemoryPort::new(cfg.memory_socket(), MEMORY_WAIT), other }`. Filled `MemoryPort::ask` (build the `MemoryCall` with the spec's session/call/tool/canonical arguments and the state's taint/untrusted, connect, set read and write timeouts to `wait`, write the final envelope, read it — `Some` only on a `Message::ToolResponse`, else `None`); `MemoryPort::run_labelled` (`Result` -> `Ok((output, Some(label)))`, `Failed` -> `Failed`, anything else or `None` -> `Unreachable`); `Routed::run_labelled` (memory tools to `memoryd`, everything else to `other`). `cargo fmt --all`; `memory_port` 5 passed and the full brokerd suite green; `make gate` printed `gate: ok` on the second run — the first failed on rustfmt import order in `memory_port.rs`, fixed by re-running `cargo fmt --all`. No new dependency, so `Cargo.lock` is unchanged. | ? | | M5a/11-brokerd-memory-tools | 2026-09-24 | done | 2 | pass | none | Copied the skeleton `crates/brokerd/src/builtin.rs` and the tests `crates/brokerd/tests/memory_tools.rs` and a new `grants.rs` (the empty-directory test now asserts `matchable()` sees the two built-ins rather than comparing to an empty set), and added `pub mod builtin;` after `pub mod broker;` in `lib.rs`. Filled `builtin::grants`, the only `todo!()`: for each `(id, text)` of `[("builtin-memory", MEMORY), ("builtin-recall", RECALL)]`, `toml::from_str::` and `sha256(text.as_bytes())` each error as a `GrantProblem { file: id, line: None, problem: e }`, then a `LoadedGrant { id, grant, sha256 }`, collected into `Result, GrantProblem>` (first error wins); the tests failed on the `todo!()` first, then passed. In `args.rs` added `use proto::{MemoryArgs, RecallArgs}`; the `Recall`/`Memory` variants of `ToolName` with their `parse`/`as_str` arms, the `is_memory` method, and the `Recall`/`Memory` variants of `ToolArgs` with their `tool`/`canonical_json`/`parse` arms. In `grants.rs` added the `builtin` field to `GrantSet`, the `builtin` join at the end of `from_grants` (only when the set passed), `matchable`, Rule 2b for ids starting `builtin-`, and the built-in sentence for a memory tool in `check_grant`. In `policy/matching.rs` matched against `matchable()` and gave the memory tools a constraint-free `covers` arm; in `runner.rs` routed them to no mounts/egress; in `config.rs` gave them the read_file time limit. `cargo fmt --all`; `memory_tools` 4 and `grants` 17 passed; the full brokerd suite green; `make gate` printed `gate: ok` on the first run. No new dependency, so `Cargo.lock` is unchanged. | ? |