memoryd: index ToolSummary as kind summary
Co-Authored-By: ornith-1.5-35b-a3b <agent@wntrmute.dev>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
//! The index: SQLite FTS5 over the session logs and `notes.md` (M5a spec, section 6). Disposable:
|
||||
//! it is rebuilt from those files. What is indexed: the owner's messages, the assistant's
|
||||
//! replies, and each tool call by name and arguments; never a tool's result.
|
||||
//! replies, and each tool call by name and arguments; the summary of an opted-in tool result,
|
||||
//! but never the tool's result itself.
|
||||
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -257,6 +258,23 @@ impl Index {
|
||||
}
|
||||
}
|
||||
}
|
||||
LogRecord::ToolSummary {
|
||||
time,
|
||||
content,
|
||||
class,
|
||||
untrusted,
|
||||
..
|
||||
} if !content.trim().is_empty() && *class <= self.max_class => {
|
||||
let src = Source {
|
||||
kind: "summary".to_string(),
|
||||
time: time.to_rfc3339(),
|
||||
role: "summary".to_string(),
|
||||
class: *class,
|
||||
untrusted: *untrusted,
|
||||
..base.clone()
|
||||
};
|
||||
self.add(content, &src)?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
//! Tests that `ToolSummary` is indexed as kind `summary` and found by `recall`. Do not edit.
|
||||
|
||||
#[path = "support/logs.rs"]
|
||||
mod logs;
|
||||
#[path = "support/tmp.rs"]
|
||||
mod tmp;
|
||||
|
||||
use bxcore::{CallId, DataClass, LogRecord, RecallArgs, Timestamp};
|
||||
use logs::{assistant, result, user, write_log};
|
||||
use memoryd::files::Store;
|
||||
use memoryd::index::Index;
|
||||
use memoryd::recall::recall;
|
||||
use tmp::TempDir;
|
||||
|
||||
fn now() -> Timestamp {
|
||||
Timestamp::parse("2026-09-24T21:00:00.000Z").unwrap()
|
||||
}
|
||||
|
||||
fn summary(minute: u32, content: &str, untrusted: bool) -> LogRecord {
|
||||
LogRecord::ToolSummary {
|
||||
time: logs::at(minute),
|
||||
call: CallId(1),
|
||||
tool_call_id: "c0".to_string(),
|
||||
content: content.to_string(),
|
||||
class: DataClass::Public,
|
||||
untrusted,
|
||||
}
|
||||
}
|
||||
|
||||
struct Setup {
|
||||
dir: TempDir,
|
||||
store: Store,
|
||||
index: Index,
|
||||
}
|
||||
|
||||
fn setup(tag: &str) -> Setup {
|
||||
let dir = TempDir::new(tag);
|
||||
let starter = dir.write("etc/soul.md", "Soul.\n");
|
||||
let store = Store::new(&dir.path().join("memory"));
|
||||
store.init(&starter).unwrap();
|
||||
let index = Index::open(&dir.path().join("index/memory.sqlite3"), DataClass::Private).unwrap();
|
||||
Setup { dir, store, index }
|
||||
}
|
||||
|
||||
fn ask(s: &Setup, query: &str) -> memoryd::recall::Recalled {
|
||||
let args = RecallArgs {
|
||||
query: query.to_string(),
|
||||
k: 10,
|
||||
};
|
||||
recall(&s.index, &s.store, &args, "mm-now", now()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tool_summary_is_found_as_kind_summary() {
|
||||
let s = setup("summary-found");
|
||||
write_log(
|
||||
s.dir.path(),
|
||||
"mm-a",
|
||||
&[
|
||||
user(2, "fetch the launch page"),
|
||||
assistant(
|
||||
3,
|
||||
None,
|
||||
&[("http_fetch", r#"{"url":"https://example.org"}"#)],
|
||||
),
|
||||
result(4, "raw html body about tea", DataClass::Public, false),
|
||||
summary(5, "the page is about tea and the launch window", false),
|
||||
],
|
||||
);
|
||||
s.index
|
||||
.catch_up(&s.dir.path().join("sessions"), &s.store.path("notes.md"))
|
||||
.unwrap();
|
||||
let got = ask(&s, "tea");
|
||||
assert!(
|
||||
got.text
|
||||
.contains("the page is about tea and the launch window"),
|
||||
"{got:?}"
|
||||
);
|
||||
assert!(
|
||||
got.text.contains("memory · session mm-a"),
|
||||
"tagged as a session hit: {}",
|
||||
got.text
|
||||
);
|
||||
assert!(!got.untrusted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_raw_tool_result_is_still_not_indexed() {
|
||||
let s = setup("summary-no-raw");
|
||||
write_log(
|
||||
s.dir.path(),
|
||||
"mm-a",
|
||||
&[
|
||||
user(2, "fetch"),
|
||||
assistant(3, None, &[("http_fetch", "{}")]),
|
||||
result(4, "UNIQUE_RAW_BODY_XYZ", DataClass::Public, false),
|
||||
summary(5, "a short account of the page", false),
|
||||
],
|
||||
);
|
||||
s.index
|
||||
.catch_up(&s.dir.path().join("sessions"), &s.store.path("notes.md"))
|
||||
.unwrap();
|
||||
let got = ask(&s, "UNIQUE_RAW_BODY_XYZ");
|
||||
assert!(
|
||||
got.text.contains("(no matches"),
|
||||
"raw tool result stays unindexed: {}",
|
||||
got.text
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_untrusted_summary_taints_the_recall() {
|
||||
let s = setup("summary-taint");
|
||||
write_log(
|
||||
s.dir.path(),
|
||||
"mm-a",
|
||||
&[
|
||||
user(2, "fetch"),
|
||||
assistant(3, None, &[("http_fetch", "{}")]),
|
||||
result(4, "page", DataClass::Public, true),
|
||||
summary(5, "the fetched page mentions the launch window", true),
|
||||
],
|
||||
);
|
||||
s.index
|
||||
.catch_up(&s.dir.path().join("sessions"), &s.store.path("notes.md"))
|
||||
.unwrap();
|
||||
let got = ask(&s, "launch");
|
||||
assert!(got.untrusted, "{got:?}");
|
||||
assert!(got.text.contains("memory-tainted"), "{}", got.text);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_trusted_summary_does_not_taint() {
|
||||
let s = setup("summary-trusted");
|
||||
write_log(
|
||||
s.dir.path(),
|
||||
"mm-a",
|
||||
&[
|
||||
user(2, "fetch"),
|
||||
assistant(3, None, &[("http_fetch", "{}")]),
|
||||
result(4, "page", DataClass::Public, false),
|
||||
summary(5, "the fetched page mentions the launch window", false),
|
||||
],
|
||||
);
|
||||
s.index
|
||||
.catch_up(&s.dir.path().join("sessions"), &s.store.path("notes.md"))
|
||||
.unwrap();
|
||||
let got = ask(&s, "launch");
|
||||
assert!(!got.untrusted, "{got:?}");
|
||||
assert!(got.text.contains("memory · session mm-a"), "{}", got.text);
|
||||
assert!(!got.text.contains("memory-tainted"), "{}", got.text);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_secret_summary_is_skipped_when_the_index_is_private() {
|
||||
let s = setup("summary-secret");
|
||||
write_log(
|
||||
s.dir.path(),
|
||||
"mm-a",
|
||||
&[
|
||||
user(2, "fetch"),
|
||||
assistant(3, None, &[("http_fetch", "{}")]),
|
||||
LogRecord::ToolResult {
|
||||
time: logs::at(4),
|
||||
call: CallId(1),
|
||||
tool_call_id: "c0".to_string(),
|
||||
content: "secret page".to_string(),
|
||||
class: DataClass::Secret,
|
||||
untrusted: false,
|
||||
truncated: false,
|
||||
},
|
||||
LogRecord::ToolSummary {
|
||||
time: logs::at(5),
|
||||
call: CallId(1),
|
||||
tool_call_id: "c0".to_string(),
|
||||
content: "SECRET_SUMMARY_PHRASE".to_string(),
|
||||
class: DataClass::Secret,
|
||||
untrusted: false,
|
||||
},
|
||||
],
|
||||
);
|
||||
s.index
|
||||
.catch_up(&s.dir.path().join("sessions"), &s.store.path("notes.md"))
|
||||
.unwrap();
|
||||
let got = ask(&s, "SECRET_SUMMARY_PHRASE");
|
||||
assert!(
|
||||
got.text.contains("(no matches"),
|
||||
"secret summary above max_class: {}",
|
||||
got.text
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ reviewer adds findings under "Reviews" once per milestone.
|
||||
|
||||
| Task | Date | Status | Gate runs | First gate | Deviations | Notes | Model |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| M5d2/09-memoryd-summary-index | 2026-10-03 | done | 1 | pass | none | Copied the given test `crates/memoryd/tests/tool_summary_index.rs` from `docs/plans/M5d2/files/`. Edited `index_record` in `crates/memoryd/src/index.rs`, adding the `ToolSummary` arm before `_ => {}`: `Source { kind: "summary", role: "summary", class: *class, untrusted: *untrusted, ..base.clone() }` with `time` from the record, guarded by `if !content.trim().is_empty() && *class <= self.max_class`; `DataClass` implements `Ord` so `<=` compiles. The existing `if base.class > self.max_class` guard at the top of `index_record` stays, and the rolling-label update in `index_log` still looks at `ToolResult` only (no `ToolSummary` added there). Updated the module doc "never a tool's result" to say summaries of opted-in results are indexed. `cargo check -p memoryd` clean, no error or warning. All suites pass: `tool_summary_index` 5, `recall` 6. `cargo fmt --all` changed nothing after the edits. `make gate` printed `gate: ok` with 1568 tests (1563 before, plus 5). No new dependency, so `Cargo.lock` unchanged. | ornith-1.5-35b-a3b |
|
||||
| M5d2/08-loopd-tool-summary | 2026-10-03 | done | 1 | pass | none | Copied the given test `crates/loopd/tests/tool_summary_call.rs` and the starter `config/summarise.md` from `docs/plans/M5d2/files/`. Created `crates/loopd/src/summarise.rs` with the `missing` function: walks `session.records()` once, remembering `tool_calls` by `id -> name` and each `ToolSummary`'s `call` in `done`, queueing opted-in `ToolResult`s during the walk (an in-walk `done.contains` still queues), then dropping any whose call is already done after the walk; for each pending it builds `messages_with_carry` plus a `User` message of the trimmed `summarise.md` prompt and the result content capped by `tool_result_cap`, sends on `slots.background` with `tools: Vec::new()` and thinking on, neutralises and caps the completion by `summary_chars`, and appends a `ToolSummary` copying call/tool_call_id/class/untrusted; context-full or other infer errors log `loopd: tool summary: {e}` and skip. Added `pub mod summarise;` after `pub mod sse;` in `lib.rs` (rustfmt order). Edited `finish_turn` in `compact.rs` (F1) to call `summarise::missing` as the first statement of both the `Ok(done)` arm and the `Err(SessionFull)` arm, before `compact_session`. `cargo check -p loopd` clean, no error or warning; `wc -l` prints 96 for summarise.rs and 194 for compact.rs, both under 500. All suites pass: `tool_summary_call` 6, `tool_summary_messages` 3, `compact_turn` 6, `turn` 6. `CallId(pub u64)` `.0` field compiled. `cargo fmt --all` changed nothing after the edits. `make gate` printed `gate: ok` with 1563 tests (1557 before, plus 6). No new dependency, so `Cargo.lock` unchanged. | ornith-1.5-35b-a3b |
|
||||
| M5d2/07-loopd-scheduler-cron | 2026-10-03 | done | 1 | pass | none | Copied the given test `crates/loopd/tests/scheduler_cron.rs` from `docs/plans/M5d2/files/`. Added the cron loop into `tick` in `crates/loopd/src/scheduler.rs`, right after `self.heartbeat(...)` and before `work` is returned (heartbeat untouched): load `jobs` via `jobs::load(&ctx.cfg.paths.home.join("jobs"))`, on `Err` log `loopd: {e}` and return; for each job in order check `cron::due` (`Err` log+skip, `Ok(false)` skip), then `jobs::live_session` on `{home}/jobs/runs.jsonl` (`Some` push `SkippedJob` and continue), then `session_id` (`Err` log+skip), then `ctx.hold` (`Busy` push `SkippedJob`). On a hold: `Baseline::assemble`, `Session::create` on `slots.background` (on `Exists`, `open` instead), append `Started`, run `run_turn` then `compact::finish_turn` with user text `format!("It is {}.\n\n{}", now.to_rfc3339(), job.prompt)` and speaker `None`, append `Finished` on an ok turn else `Failed`, drop the hold, push `TickWork::Job { name, session }`. Added `use crate::cron;` to the imports. `cargo check -p loopd` clean, no error or warning; `wc -l` prints 304, under 500. All four suites pass: `cron` 11, `jobs` 8, `scheduler_cron` 6, `scheduler_heartbeat` 6. `cargo fmt --all` wrapped the `Session::create` call and its `Exists` arm as a nested match; `make gate` printed `gate: ok` with 1557 tests (1551 before, plus the 6 of `scheduler_cron.rs`). No new dependency, so `Cargo.lock` unchanged. | ornith-1.5-35b-a3b |
|
||||
| M5d2/06-loopd-scheduler-heartbeat | 2026-10-03 | done | 2 | fail | none | Copied the given test `crates/loopd/tests/scheduler_heartbeat.rs` from `docs/plans/M5d2/files/`. Made `crates/loopd/src/scheduler.rs` from the skeleton: `tick` closes idle leftovers once on the first tick, then runs `heartbeat`; `heartbeat` returns early when `every_s == 0` or when not due (`now - last_heartbeat.unwrap_or(started) >= every_s * 1000`), reads the prompt first (pushing `HeartbeatUnreadable` and logging once via `heartbeat_unreadable_logged` on a failed read, without holding), then holds `heartbeat` (pushing `SkippedBusy` on `Busy`), opens it or creates it on `NotFound` with `Baseline::assemble` and the background slot, and runs the turn with `speaker None` and a no-op emit, updating `last_heartbeat` and pushing `Heartbeat` only on an ok outcome. `close_idle_leftovers` loads the jobs and, for each with a live `Started` session, holds it -- dropping the hold and appending a `Failed` run when the hold succeeds and skipping when it is `Busy`, rather than calling `jobs::close_leftovers` blindly, so a busy session is never closed. Added `pub mod scheduler;` after `pub mod llama;` in `lib.rs`. Wired `run_serve` to spawn a thread that `tick`s once a second before `channel::serve`. Made `Held` pub, added `pub struct Busy` and `Context::hold` in `channel.rs`. Appended the given `heartbeat-prompt-unreadable` entry to `docs/runbook.md` (heading count 1). First `make gate` failed on clippy `single_match` in `close_idle_leftovers`, fixed by converting the match to `if let Ok(held)` (the `Busy` case is the skip); the second run printed `gate: ok` with 1551 tests (1545 before, plus the 6 of `scheduler_heartbeat.rs`). No new dependency, so `Cargo.lock` unchanged. | ornith-1.5-35b-a3b |
|
||||
|
||||
Reference in New Issue
Block a user