fix(memory): re-project MEMORY.md on every memory write, not just the CLI's - #440
Conversation
PR intake checks found warnings (non-blocking)Fast safe checks found advisory issues. CI lint/test/build gates still enforce merge quality.
Action items:
Run logs: https://github.com/RantAI-dev/RantAIClaw/actions/runs/31261900034 Detected blocking line issues (sample):
Detected advisory line issues (sample):
|
… CLI's
`MEMORY.md` is injected into every system prompt unconditionally
(`agent/prompt.rs`), and on the sqlite and lucid backends it is a *projection*
of the `core` rows rather than the store itself. Nothing re-projected on its own:
`refresh_projection` lived privately in `memory/cli.rs` and was called from three
places, all in that file. A grep across `src/tools/`, `src/tui/` and
`src/gateway/` for `project_core_memories|refresh_projection` returned nothing.
So a core memory deleted through the agent's own tool, the TUI, or the HTTP API
was removed from the authoritative store and stayed in the file that reaches the
model:
the prompt-injected file still holds the forgotten entry:
<!-- rantaiclaw:memory:begin -->
- rotation_note: staging credentials rotate weekly
<!-- rantaiclaw:memory:end -->
The store side was wrong in the mirror image: a `core` memory written mid-session
was not in the injected file at all. The comment at `memory/mod.rs` claiming "a
memory stored mid-session lands in the file now" was only ever true on the CLI
path.
The projection is otherwise rebuilt only at backend construction. For
`rantaiclaw run` that is the next process; for the gateway and the TUI — both
long-lived — it is the rest of the process lifetime, and a new session started
inside that process reads the stale file.
- move `refresh_projection` into `memory/snapshot.rs` as a shared `pub fn`
- gate it on `memory.name()` rather than on config. Same decision by
construction, and it asks the caller for a workspace path instead of a
`Config` — which the tools do not hold. `MarkdownMemory` still skips it (that
backend owns `MEMORY.md` directly), as do `postgres` and `none`
- call it after every successful mutation on all six surfaces: `memory_forget`,
`memory_store`, the TUI's `/memory add` and `/memory remove`, and the API's
`memory_create` and `memory_delete`
- thread `workspace_dir` into the two memory tools. `all_tools_with_runtime`
already had it; `memory_flush_tools` (the pre-compaction flush, which writes
memory through the same tools) takes it from the agent
Not done, deliberately: no write-through inside `Memory::forget`/`store`. That
would put filesystem work behind a trait every test mock implements, and
`MarkdownMemory` would recurse into its own file.
Ten tests added. Eight were confirmed to fail against the previous behaviour by
stubbing `refresh_projection` to a no-op and re-running — one per call site plus
the shared helper. The other two assert the gate holds (markdown and `none` must
not be projected) and pass either way by design, as does
`a_blocked_store_does_not_touch_the_projection`.
`state_with_real_memory` in the gateway tests now points `config.workspace_dir`
at the same TempDir as the store; without that the projection lands where the
test cannot see it.
Verified: 371 tests pass across `memory::`, both memory tools, `api_v1`,
`tui::commands::memory` and `agent::agent`; `cargo fmt` clean;
`cargo +1.92.0 clippy --locked --all-targets -- -D clippy::correctness` clean.
Rebasing onto main brought in #437's four new `memory_forget` gate tests, which construct `MemoryForgetTool::new` with two arguments. This branch gives that constructor a third — the workspace path it needs to re-project `MEMORY.md`. Git merged the two changes without a conflict, because they touch different regions of the same file, and GitHub reported the PR MERGEABLE/CLEAN. It did not compile: error[E0061]: this function takes 3 arguments but 2 arguments were supplied --> src/tools/memory_forget.rs:395:20 (and :420, :448, :525, :537) - pass `tmp.path()` at the four `test_mem()`-backed sites, renaming `_tmp` to `tmp` as the rest of this module already does - give the `CountingMemory` test its own `TempDir`; that mock's `name()` is "counting", so the projection is a no-op there, but the constructor still needs a real path rather than a fabricated one Verified on the rebased branch: 388 lib tests pass across `memory::`, both memory tools, `api_v1`, `tui::commands::memory` and `agent::agent`; integration binaries `memory_comparison` 7, `memory_restart` 14, `migrate_legacy` 10, `profile_lifecycle` 17, `compat_v041_to_v050` 2; `cargo fmt` clean; `cargo +1.92.0 clippy --locked --all-targets -- -D clippy::correctness` clean.
7e7a73f to
c2ff80e
Compare
|
Rebased onto The rebase merged cleanly and did not compile. GitHub reported this PR #437 added tests calling The semantic merge was checked by hand as well — #437's gate reordering and this branch's Re-verified on the rebased branch: 388 lib tests ( |
Four memory fixes, all already on `main` (#437, #438, #439, #440). Patch rather than minor: none of them adds a CLI surface or changes an API contract. - forgetting a core memory now removes it from the prompt-injected `MEMORY.md` too; only the CLI re-projected before, so a delete through the tool, the TUI or the HTTP API left the entry reaching the model until the process restarted - the `markdown` backend replaces instead of appending, and `forget` sweeps every file it owns rather than stopping at the first match - `memory stats` labels a capped category breakdown and reports a `count()` failure as `unavailable` instead of `0` - `memory_forget` consults the autonomy gate before resolving `contains`, so a refused call is told it was refused rather than answered from memory contents Release gates, per `docs/contributing/release-process.md`: cargo test --locked --test schema_drift --test config_migration_roundtrip # 5 passed cargo test --locked --lib config::migrations # 19 passed cargo test --locked --lib sessions::migrations # 2 passed `schema_drift` passes without a snapshot update, so the config schema did not move: no migration, and the release rolls back cleanly. `Cargo.lock` regenerated with `cargo check --offline`; the diff is the one version line.
Summary
MEMORY.mdis injected into every system prompt (agent/prompt.rs), and on the sqlite/lucid backends it is a projection of thecorerows rather than the store itself.refresh_projectionlived privately inmemory/cli.rsand was called from three places, all in that file. A grep acrosssrc/tools/,src/tui/,src/gateway/forproject_core_memories|refresh_projectionreturned nothing.memory_forget, the TUI, or the HTTP API left the authoritative store but stayed in the file that reaches the model. The store side was the mirror image: acorememory written mid-session was not in the injected file at all. The projection is otherwise rebuilt only at backend construction — for the gateway and the TUI, both long-lived, that is the rest of the process lifetime, and a new session started inside that process reads the stale file.refresh_projectionmoved tomemory/snapshot.rsas a sharedpub fn, gated onmemory.name()instead of on config, and is now called after every successful mutation on all seven write surfaces.Memory::forget/store— that would put filesystem work behind a trait every test mock implements, andMarkdownMemorywould recurse into its own file. No bidirectional sync betweenMEMORY.mdandbrain.db(explicitly rejected insnapshot.rsand that reasoning still holds). No mid-session prompt rebuild; the prompt is built once per session by design, and this only makes the file correct so the next session is.Evidence that produced this, from a probe against the real tool:
Three controls in that probe passed: the projection did write the entry, the tool reported success, and the store no longer held it. Only the projection was stale.
Label Snapshot (required)
risk: low|medium|high):risk: mediummemory,tool,gateway,agenttool: memory_store,tool: memory_forgetChange Metadata
bugmemoryLinked Issue
Validation Evidence (required)
Clippy is run under
1.92.0because that is what.github/workflows/ci-run.ymlpins; the local default toolchain is 1.97 and reports a large pre-existing diagnostic set that CI does not gate on.Evidence provided: ten new tests. Eight were confirmed to fail against the previous behaviour by stubbing
refresh_projectionto a no-op and re-running — one per call site plus the shared helper:tools::memory_forget::forget_reprojects_memory_mdtools::memory_forget::forget_leaves_the_other_projected_entries_alonetools::memory_store::store_reprojects_memory_mdtools::memory_store::store_with_replaces_drops_the_superseded_entry_from_the_projectiongateway::api_v1::memory_delete_reprojects_memory_mdgateway::api_v1::memory_create_reprojects_memory_mdtui::commands::memory::memory_add_and_remove_reproject_memory_mdmemory::snapshot::refresh_projects_on_sqliteThe remaining three (
refresh_is_a_no_op_on_markdown,refresh_is_a_no_op_when_memory_is_disabled,a_blocked_store_does_not_touch_the_projection) assert the gate holds and pass either way by design — they guard against this change over-reaching, not against the bug returning.If any command is intentionally skipped: full-workspace
cargo testwas not run (disk-bound on this machine, ~27G of target); the six affected modules were run instead and are named above.Security Impact (required)
<workspace>/MEMORY.md; this makes more call paths reach the same writer with the same path. The backend gate keepsmarkdown,postgresandnoneuntouched.Privacy and Data Hygiene (required)
passrotation_note,user_lang,prefers Bahasa Indonesia).Compatibility / Migration
MEMORY.mdblock is corrected on the next write, and was already corrected on the next backend construction.Human Verification (required)
MEMORY.mdvia the tool, the TUI command dispatcher and the HTTP handler; store-then-read via all three;replacesperforming two mutations reflected in one rewrite; aReadOnly-refused store leaving the file byte-identical.markdownbackend must be a no-op (it owns the file);nonebackend must not create the file; a delete must not take the surviving projected entries with it.lucidis gated in alongsidesqliteby name but was not exercised (itsforgetremoves the local copy only, which is pre-existing and documented behaviour).Side Effects / Blast Radius (required)
memory/snapshot,memory/cli, both memory tools, the tool factory, the agent's pre-compaction flush registry, the TUI/memorycommand, the gateway memory API.PROJECTION_MAX_CHARS, and it is what the CLI path already did on everymemory add.memory projection skipped: …viatracing::warn!and never fails the write that already succeeded.Agent Collaboration Notes (recommended)
plans/082-forget-leaves-projected-memory-in-the-prompt.md. The plan left the helper's signature as an open question between "pass&Config" and "gate onmemory.name()"; resolved to the latter after confirming eachclassify_memory_backendbranch constructs a backend whosename()matches, making the two decisions equivalent while asking callers for only a path.AGENTS.md+CONTRIBUTING.md) — yes.Rollback Plan (required)
git revert 7e7a73f. Single commit, no schema or config surface.MEMORY.mdgaining a duplicated or malformedrantaiclaw:memoryblock, ormemory projection skipped: …warnings appearing at a rate that tracks memory writes.Risks and Mitigations
Risk:
memory_flush_tools(agent/agent.rs) was not in the plan's list of call sites — it was found only because the compiler rejected the signature change. Another constructor reached by a path the compiler cannot see would have been missed the same way.MemoryStoreTool::new/MemoryForgetTool::neware the only constructors, both takeworkspace_dirpositionally now, and agrep -rn 'Memory\(Store\|Forget\)Tool::new'shows onlytools/mod.rsandagent/agent.rsoutside the tools' own tests.Risk: the seven call sites each call the helper explicitly, so a write path added later can forget to.
Risk:
state_with_real_memoryin the gateway tests now setsconfig.workspace_dirto the same TempDir as the store. That is a shared helper, so it changes the environment of every test using it.api_v1tests pass with the change.🤖 Generated with Claude Code