From 18039fa2e4a924f1f6a1906479a0a6a5e63cc589 Mon Sep 17 00:00:00 2001 From: Kyle Isom Date: Fri, 2 Oct 2026 23:38:10 -0700 Subject: [PATCH] loopd: open the highest epoch and carry its summary Co-Authored-By: ornith-1.5-35b-a3b --- crates/loopd/src/session.rs | 253 +++++++++++++++++++------- crates/loopd/tests/session_epochs.rs | 259 +++++++++++++++++++++++++++ docs/implementer-log.md | 1 + 3 files changed, 453 insertions(+), 60 deletions(-) create mode 100644 crates/loopd/tests/session_epochs.rs diff --git a/crates/loopd/src/session.rs b/crates/loopd/src/session.rs index 87c061f..23d91df 100644 --- a/crates/loopd/src/session.rs +++ b/crates/loopd/src/session.rs @@ -1,5 +1,6 @@ -//! A session on disk: `sessions//0.baseline.json` and `0.jsonl`. The log is the state — -//! create, open, append and resume all live here, and nothing is durable until `append` syncs it. +//! A session on disk: `sessions//.baseline.json` and `.jsonl`. The log is the +//! state — create, open, append and resume all live here, and nothing is durable until `append` +//! syncs it. Epoch 0 is the first; later epochs appear after compaction. use std::fs::{File, OpenOptions}; use std::io::Write; @@ -21,6 +22,9 @@ pub enum SessionError { }, Baseline(BaselineError), Encode(serde_json::Error), + NoCarry { + path: PathBuf, + }, } impl std::fmt::Display for SessionError { @@ -36,6 +40,11 @@ impl std::fmt::Display for SessionError { ), SessionError::Baseline(err) => write!(f, "{err}"), SessionError::Encode(err) => write!(f, "{err}"), + SessionError::NoCarry { path } => write!( + f, + "{}: compacted epoch is missing its EpochEnd; see docs/runbook.md#session-log-damaged", + path.display() + ), } } } @@ -61,6 +70,11 @@ pub struct Session { records: Vec, log: File, next_call: CallId, + epoch: Epoch, + slot: u32, + carry: Option, + prior_users: u64, + prior_untrusted: bool, } impl Session { @@ -99,6 +113,11 @@ impl Session { records: Vec::new(), log, next_call: CallId(1), + epoch: Epoch(0), + slot, + carry: None, + prior_users: 0, + prior_untrusted: false, }; session.persist(LogRecord::SessionStart { time: Timestamp::now(), @@ -112,51 +131,26 @@ impl Session { pub fn open(home: &Path, id: SessionId) -> Result { let dir = Session::dir_for(home, &id); - let log_path = dir.join("0.jsonl"); - if !log_path.exists() { + let epochs = complete_epochs(&dir)?; + let Some(&n) = epochs.last() else { return Err(SessionError::NotFound(id)); - } - let baseline_path = dir.join("0.baseline.json"); + }; + let epoch = Epoch(n); + let baseline_path = dir.join(format!("{n}.baseline.json")); let baseline = Baseline::load(&baseline_path).map_err(SessionError::Baseline)?; - - let text = std::fs::read_to_string(&log_path) - .map_err(|e| SessionError::Io(log_path.clone(), e))?; - let mut records = Vec::new(); - if !text.is_empty() { - for (idx, line) in text.split_inclusive('\n').enumerate() { - let line_no = idx + 1; - if !line.ends_with('\n') { - return Err(SessionError::Torn { - path: log_path.clone(), - line: line_no, - why: "last line does not end in a newline".to_string(), - }); - } - match serde_json::from_str::(line) { - Ok(record) => records.push(record), - Err(why) => { - return Err(SessionError::Torn { - path: log_path.clone(), - line: line_no, - why: why.to_string(), - }); - } - } - } + let log_path = dir.join(format!("{n}.jsonl")); + let records = load_records(&log_path)?; + let (carry, prior_users, prior_untrusted) = priors(&dir, n)?; + let mut highest = max_call(&records); + for earlier in &epochs[..epochs.len() - 1] { + let path = dir.join(format!("{earlier}.jsonl")); + highest = highest.max(max_call(&load_records(&path)?)); } - - let mut highest = 0u64; - for record in &records { - if let LogRecord::ToolResult { call, .. } = record { - highest = highest.max(call.0); - } - } - + let slot = live_slot(&records, 0); let log = OpenOptions::new() .append(true) .open(&log_path) .map_err(|e| SessionError::Io(log_path.clone(), e))?; - Ok(Session { id, dir, @@ -164,6 +158,11 @@ impl Session { records, log, next_call: CallId(highest + 1), + epoch, + slot, + carry, + prior_users, + prior_untrusted, }) } @@ -183,6 +182,18 @@ impl Session { &self.records } + pub fn epoch(&self) -> Epoch { + self.epoch + } + + pub fn slot(&self) -> u32 { + self.slot + } + + pub fn carry(&self) -> Option<&str> { + self.carry.as_deref() + } + pub fn append(&mut self, record: LogRecord) -> Result<(), SessionError> { self.persist(record) } @@ -197,28 +208,13 @@ impl Session { /// grant, or a recalled memory-tainted note, which `brokerd` labels so. The labels are in /// the log, so this holds after the session is reopened, and it never becomes false again. pub fn untrusted(&self) -> bool { - self.records.iter().any(|record| { - matches!( - record, - LogRecord::ToolResult { - untrusted: true, - .. - } - ) - }) + self.prior_untrusted || any_untrusted(&self.records) } - /// How many turns the session has had: its `User` records, one per turn that reached the - /// log, a failed turn's too. Counted from `records`, which `open` reads back whole, so the - /// count goes on after a reopen or a restart; right after a turn it is that turn's number, - /// the first being 1. Epoch 0 is a session's only log until compaction is built. + /// How many turns the session has had: its `User` records across every epoch, a failed + /// turn's too. The first `User` of the session is turn 1 for its life. pub fn turns(&self) -> u64 { - let users = self - .records - .iter() - .filter(|record| matches!(record, LogRecord::User { .. })) - .count(); - u64::try_from(users).unwrap_or(u64::MAX) + self.prior_users.saturating_add(count_users(&self.records)) } pub fn last_usage(&self) -> Option { @@ -263,6 +259,143 @@ impl Session { } fn log_path(&self) -> PathBuf { - self.dir.join("0.jsonl") + self.dir.join(format!("{}.jsonl", self.epoch.0)) } } + +/// Epoch numbers that have both `.jsonl` and `.baseline.json`, sorted. +fn complete_epochs(dir: &Path) -> Result, SessionError> { + let entries = match std::fs::read_dir(dir) { + Ok(e) => e, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(e) => return Err(SessionError::Io(dir.to_path_buf(), e)), + }; + let mut out = Vec::new(); + for entry in entries { + let entry = entry.map_err(|e| SessionError::Io(dir.to_path_buf(), e))?; + let name = entry.file_name(); + let Some(name) = name.to_str() else { continue }; + let Some(stem) = name.strip_suffix(".jsonl") else { + continue; + }; + let Ok(n) = stem.parse::() else { continue }; + if dir.join(format!("{n}.baseline.json")).exists() { + out.push(n); + } + } + out.sort_unstable(); + Ok(out) +} + +fn load_records(path: &Path) -> Result, SessionError> { + let text = match std::fs::read_to_string(path) { + Ok(t) => t, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + return Err(SessionError::Io(path.to_path_buf(), e)); + } + Err(e) => return Err(SessionError::Io(path.to_path_buf(), e)), + }; + let mut records = Vec::new(); + if text.is_empty() { + return Ok(records); + } + for (idx, line) in text.split_inclusive('\n').enumerate() { + let line_no = idx + 1; + if !line.ends_with('\n') { + return Err(SessionError::Torn { + path: path.to_path_buf(), + line: line_no, + why: "last line does not end in a newline".to_string(), + }); + } + match serde_json::from_str::(line) { + Ok(record) => records.push(record), + Err(why) => { + return Err(SessionError::Torn { + path: path.to_path_buf(), + line: line_no, + why: why.to_string(), + }); + } + } + } + Ok(records) +} + +/// Carry from epoch `n-1`, and user / untrusted counts from every epoch before `n`. +fn priors(dir: &Path, n: u32) -> Result<(Option, u64, bool), SessionError> { + let mut prior_users = 0u64; + let mut prior_untrusted = false; + let mut carry = None; + if n == 0 { + return Ok((None, 0, false)); + } + for earlier in 0..n { + let path = dir.join(format!("{earlier}.jsonl")); + let records = match load_records(&path) { + Ok(r) => r, + Err(SessionError::Io(path, e)) if e.kind() == std::io::ErrorKind::NotFound => { + return Err(SessionError::NoCarry { path }); + } + Err(e) => return Err(e), + }; + prior_users = prior_users.saturating_add(count_users(&records)); + prior_untrusted = prior_untrusted || any_untrusted(&records); + if earlier + 1 == n { + carry = carry_from(&records, Epoch(n)).map(str::to_string); + if carry.is_none() { + return Err(SessionError::NoCarry { path }); + } + } + } + Ok((carry, prior_users, prior_untrusted)) +} + +fn carry_from(records: &[LogRecord], next: Epoch) -> Option<&str> { + records.iter().rev().find_map(|record| match record { + LogRecord::EpochEnd { + next: n, summary, .. + } if *n == next => Some(summary.as_str()), + _ => None, + }) +} + +fn count_users(records: &[LogRecord]) -> u64 { + let users = records + .iter() + .filter(|record| matches!(record, LogRecord::User { .. })) + .count(); + u64::try_from(users).unwrap_or(u64::MAX) +} + +fn any_untrusted(records: &[LogRecord]) -> bool { + records.iter().any(|record| { + matches!( + record, + LogRecord::ToolResult { + untrusted: true, + .. + } + ) + }) +} + +fn max_call(records: &[LogRecord]) -> u64 { + let mut highest = 0u64; + for record in records { + if let LogRecord::ToolResult { call, .. } = record { + highest = highest.max(call.0); + } + } + highest +} + +fn live_slot(records: &[LogRecord], fallback: u32) -> u32 { + records + .iter() + .find_map(|record| match record { + LogRecord::SessionStart { slot, .. } => Some(*slot), + _ => None, + }) + .unwrap_or(fallback) +} diff --git a/crates/loopd/tests/session_epochs.rs b/crates/loopd/tests/session_epochs.rs new file mode 100644 index 0000000..565ead3 --- /dev/null +++ b/crates/loopd/tests/session_epochs.rs @@ -0,0 +1,259 @@ +//! Tests for multi-epoch session open, turns, trust and call ids. Do not edit. + +mod support; + +use bxcore::{CallId, DataClass, Epoch, LogRecord, SessionId, Timestamp}; +use loopd::baseline::Baseline; +use loopd::session::{Session, SessionError}; +use support::Home; + +fn baseline() -> Baseline { + Baseline { + system: "sys".to_string(), + tools: vec![], + } +} + +fn later() -> Baseline { + Baseline { + system: "sys-epoch-1".to_string(), + tools: vec![], + } +} + +fn id(s: &str) -> SessionId { + SessionId::new(s).unwrap() +} + +fn ts() -> Timestamp { + Timestamp::parse("2026-09-18T08:00:00.000Z").unwrap() +} + +fn user(content: &str) -> LogRecord { + LogRecord::User { + time: ts(), + content: content.to_string(), + speaker: None, + } +} + +fn write_jsonl(home: &Home, session: &str, epoch: u32, records: &[LogRecord]) { + let text: String = records + .iter() + .map(|r| format!("{}\n", serde_json::to_string(r).unwrap())) + .collect(); + home.write(&format!("sessions/{session}/{epoch}.jsonl"), &text); +} + +fn write_baseline(home: &Home, session: &str, epoch: u32, b: &Baseline) { + home.write( + &format!("sessions/{session}/{epoch}.baseline.json"), + &b.to_json().unwrap(), + ); +} + +fn start(session: &str, epoch: u32, slot: u32, b: &Baseline) -> LogRecord { + LogRecord::SessionStart { + time: ts(), + session: id(session), + epoch: Epoch(epoch), + slot, + baseline: b.hash().unwrap(), + } +} + +fn epoch_end(next: u32, summary: &str) -> LogRecord { + LogRecord::EpochEnd { + time: ts(), + next: Epoch(next), + summary: summary.to_string(), + } +} + +fn tool_result(call: u64, untrusted: bool) -> LogRecord { + LogRecord::ToolResult { + time: ts(), + call: CallId(call), + tool_call_id: format!("c{call}"), + content: "out".to_string(), + class: DataClass::Public, + untrusted, + truncated: false, + } +} + +#[test] +fn open_loads_the_highest_complete_epoch_and_its_carry() { + let home = Home::new(); + let mut s = Session::create(&home.dir, id("a"), baseline(), 3).unwrap(); + s.append(user("one")).unwrap(); + s.append(user("two")).unwrap(); + s.append(epoch_end(1, "carry-text")).unwrap(); + drop(s); + write_baseline(&home, "a", 1, &later()); + write_jsonl(&home, "a", 1, &[start("a", 1, 3, &later()), user("three")]); + + let s = Session::open(&home.dir, id("a")).unwrap(); + assert_eq!(s.epoch(), Epoch(1)); + assert_eq!(s.slot(), 3); + assert_eq!(s.carry(), Some("carry-text")); + assert_eq!(s.baseline(), &later()); + assert_eq!(s.records().len(), 2); + assert!(matches!(s.records()[1], LogRecord::User { ref content, .. } if content == "three")); +} + +#[test] +fn a_missing_carry_on_epoch_1_is_an_error() { + let home = Home::new(); + let s = Session::create(&home.dir, id("a"), baseline(), 0).unwrap(); + drop(s); + write_baseline(&home, "a", 1, &later()); + write_jsonl(&home, "a", 1, &[start("a", 1, 0, &later())]); + + match Session::open(&home.dir, id("a")) { + Err(SessionError::NoCarry { ref path, .. }) => { + assert!(path.ends_with("0.jsonl"), "{path:?}"); + } + other => panic!("{other:?}"), + } + let e: Box = + Box::new(Session::open(&home.dir, id("a")).map(|_| ()).unwrap_err()); + assert!(e.to_string().contains("0.jsonl"), "{e}"); + assert!( + e.to_string() + .contains("see docs/runbook.md#session-log-damaged"), + "{e}" + ); +} + +#[test] +fn epoch_0_has_no_carry() { + let home = Home::new(); + let s = Session::create(&home.dir, id("a"), baseline(), 7).unwrap(); + drop(s); + let s = Session::open(&home.dir, id("a")).unwrap(); + assert_eq!(s.epoch(), Epoch(0)); + assert_eq!(s.carry(), None); + assert_eq!(s.slot(), 7); +} + +#[test] +fn turns_count_users_across_every_epoch() { + let home = Home::new(); + let mut s = Session::create(&home.dir, id("a"), baseline(), 0).unwrap(); + s.append(user("one")).unwrap(); + s.append(user("two")).unwrap(); + s.append(epoch_end(1, "sum")).unwrap(); + drop(s); + write_baseline(&home, "a", 1, &later()); + write_jsonl(&home, "a", 1, &[start("a", 1, 0, &later()), user("three")]); + let s = Session::open(&home.dir, id("a")).unwrap(); + assert_eq!(s.turns(), 3); + assert_eq!( + s.records() + .iter() + .filter(|r| matches!(r, LogRecord::User { .. })) + .count(), + 1, + "records() is the current epoch only" + ); +} + +#[test] +fn untrusted_reads_every_epoch() { + let home = Home::new(); + let mut s = Session::create(&home.dir, id("a"), baseline(), 0).unwrap(); + s.append(tool_result(1, true)).unwrap(); + s.append(epoch_end(1, "sum")).unwrap(); + drop(s); + write_baseline(&home, "a", 1, &later()); + write_jsonl( + &home, + "a", + 1, + &[start("a", 1, 0, &later()), tool_result(2, false)], + ); + let s = Session::open(&home.dir, id("a")).unwrap(); + assert!(s.untrusted(), "an untrusted result in epoch 0 still taints"); + assert!( + !s.records().iter().any(|r| matches!( + r, + LogRecord::ToolResult { + untrusted: true, + .. + } + )), + "the current epoch's records look clean" + ); +} + +#[test] +fn call_ids_continue_across_epochs() { + let home = Home::new(); + let mut s = Session::create(&home.dir, id("a"), baseline(), 0).unwrap(); + s.append(tool_result(4, false)).unwrap(); + s.append(epoch_end(1, "sum")).unwrap(); + drop(s); + write_baseline(&home, "a", 1, &later()); + write_jsonl( + &home, + "a", + 1, + &[start("a", 1, 0, &later()), tool_result(2, false)], + ); + let mut s = Session::open(&home.dir, id("a")).unwrap(); + assert_eq!( + s.next_call(), + CallId(5), + "one more than the highest call in any epoch" + ); +} + +#[test] +fn append_writes_the_current_epoch_file() { + let home = Home::new(); + let mut s = Session::create(&home.dir, id("a"), baseline(), 0).unwrap(); + s.append(epoch_end(1, "sum")).unwrap(); + drop(s); + write_baseline(&home, "a", 1, &later()); + write_jsonl(&home, "a", 1, &[start("a", 1, 0, &later())]); + let mut s = Session::open(&home.dir, id("a")).unwrap(); + s.append(user("after")).unwrap(); + let text = home.read("sessions/a/1.jsonl"); + assert!(text.contains("after"), "{text}"); + let old = home.read("sessions/a/0.jsonl"); + assert!(!old.contains("after"), "{old}"); +} + +#[test] +fn an_incomplete_next_epoch_is_ignored() { + let home = Home::new(); + let mut s = Session::create(&home.dir, id("a"), baseline(), 0).unwrap(); + s.append(user("still-here")).unwrap(); + drop(s); + write_baseline(&home, "a", 1, &later()); + let s = Session::open(&home.dir, id("a")).unwrap(); + assert_eq!(s.epoch(), Epoch(0)); + assert_eq!(s.carry(), None); + assert_eq!(s.turns(), 1); + drop(s); + write_jsonl(&home, "a", 2, &[start("a", 2, 0, &later())]); + let s = Session::open(&home.dir, id("a")).unwrap(); + assert_eq!(s.epoch(), Epoch(0), "a log without its baseline is ignored"); +} + +#[test] +fn epoch_end_without_the_next_files_stays_on_the_old_epoch() { + let home = Home::new(); + let mut s = Session::create(&home.dir, id("a"), baseline(), 0).unwrap(); + s.append(user("x")).unwrap(); + s.append(epoch_end(1, "sum")).unwrap(); + drop(s); + let s = Session::open(&home.dir, id("a")).unwrap(); + assert_eq!(s.epoch(), Epoch(0)); + assert_eq!(s.carry(), None); + assert_eq!(s.turns(), 1); + assert!( + matches!(s.records().last(), Some(LogRecord::EpochEnd { next, .. }) if *next == Epoch(1)) + ); +} diff --git a/docs/implementer-log.md b/docs/implementer-log.md index 4db8759..7e34873 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/02-loopd-session-epochs | 2026-10-02 | done | 1 | pass | none | Copied the given test `crates/loopd/tests/session_epochs.rs` (new). Step 2 (`cargo build --tests --keep-going -p loopd`) showed exactly the named failures: `session_epochs` with E0599 (`no method named epoch` / `carry` / `slot`) and E0433 (`NoCarry`); every other target compiled and the existing `session` tests still compiled. Made S1 (module doc naming `.baseline.json` / `.jsonl` and epochs after compaction), S2 (`SessionError` gains `NoCarry { path: PathBuf }`), S3 (its Display arm naming the damaged-log runbook pointer), S4 (`Session` gains `epoch`, `slot`, `carry`, `prior_users`, `prior_untrusted` in that order), S5 (`create` inits the five new fields, `slot` from the argument), S6 (whole `open`: `complete_epochs` picks the highest epoch with both files, `records` is the current log, `priors` walks every earlier complete epoch for user/untrusted counts and the epoch-1 carry, `max_call` over every epoch for `next_call`, `live_slot` from the current `SessionStart`), S7 (accessors `epoch` / `slot` / `carry`), S8 (`untrusted` = `prior_untrusted || any_untrusted`, `turns` = `prior_users.saturating_add(count_users)`), S9 (`log_path` to the current epoch's file plus helpers `complete_epochs`, `load_records`, `priors`, `carry_from`, `count_users`, `any_untrusted`, `max_call`, `live_slot`). `cargo check -p loopd` clean, no error or warning. `wc -l crates/loopd/src/session.rs` prints 402. `cargo test -p loopd --test session_epochs --test session` passes: 9 in `session_epochs`, 7 in `session`. `git diff --stat` lists only `session.rs`; `cargo fmt --all` changed nothing. `make gate` printed `gate: ok` with 1473 tests (1472 in `cargo test --workspace`, plus the 1 ignored `end_to_end`; the +9 over the 1464 before is exactly the tests of `session_epochs.rs`). No new dependency, so `Cargo.lock` unchanged. | ornith-1.5-35b-a3b | | 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 |