fix(memory): make the markdown backend upsert, and forget every copy - #438
Conversation
Two defects, one root cause.
`MarkdownMemory::store` appended unconditionally, so storing a key twice wrote
two lines. Every other real backend upserts — `SqliteMemory` via
`ON CONFLICT(key) DO UPDATE`, `PostgresMemory` matching it — and `Memory` is one
trait, so this was a contract divergence rather than a backend flavour. The
duplicate inflated `count()`, which `memory stats` renders as `Total:`, showed
the key twice in `list()`, and left `get()` returning whichever copy sorted
first under a `timestamp` that is really the filename.
`forget` then `break`ed at the first file that matched. A key sitting in
`MEMORY.md` *and* a daily log lost one copy, kept the rest, and still returned
`true` — which `memory_forget` renders as "Forgot memory: k" while `get()` still
answers with the entry:
forget returned true but entry survives: MemoryEntry {
key: "dupe", content: "daily copy", category: Daily, ... }
The doc comment on `forget` already argues that answering wrongly about a
deletion is unacceptable; the `break` reintroduced the same class of wrong
answer from the other direction.
- extract `remove_key_from_file`, and have `forget` sweep every file it owns
- have `store` clear the key from *every* file before writing, so a re-store
under a different category moves the entry rather than duplicating it across
two files — matching what sqlite's upsert does to `category`
- drop trailing blank lines when rewriting: the removal leaves the separator
behind and `append_to_file` adds another, so re-storing one key repeatedly
grew the file by a blank line each time. `MEMORY.md` is injected into the
prompt, so that growth is a token cost
- lines `split_stored_entry` cannot parse stay untouched, as before — headers
and operator prose in `MEMORY.md` are not ours to rewrite
Six tests added. Four of them were confirmed to fail against the previous
behaviour by restoring it and re-running: `store_replaces_an_existing_key`,
`store_moves_an_entry_across_categories`, `forget_sweeps_every_file`,
`repeated_store_does_not_grow_the_file`. The other two —
`hand_written_lines_survive_store_and_forget`, `forget_absent_key_rewrites_nothing`
— guard behaviour this change could have broken rather than reproducing the bug,
and pass either way by design.
Only reached when `[memory] backend = "markdown"`; the default is `sqlite`.
Verified: 20 markdown tests pass (14 existing unchanged), 293 `memory::` tests
pass, `cargo fmt` clean, `cargo +1.92.0 clippy --locked --all-targets -- -D
clippy::correctness` clean.
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/31260808433 Detected blocking line issues (sample):
Detected advisory line issues (sample):
|
`tests/memory_comparison.rs` pinned the divergence this branch removes:
// Markdown: append-only, count increases
assert!(md_count >= 2, "Markdown should keep both entries");
That is the defect stated as the expectation. `Memory` is one trait, and one
key means one entry on every backend implementing it — leaving two lines under
one key is what inflated `count()`, showed the key twice in `list()`, and left
`get()` returning whichever copy sorted first.
`compare_forget` in this same file was updated the same way when `forget` stopped
returning `false` unconditionally, and carries a comment saying so. This follows
that precedent.
- assert markdown replaces (`count == 1`, latest value wins), matching the
sqlite assertions directly above
- read the markdown side through `get("pref")` rather than `recall`, so both
backends are checked the same way and the test compares like with like
- correct the printed narration, which still said "append-only, both entries
kept"
Caught by CI, not locally: this is an integration test under `tests/`, and the
local runs behind this branch were `cargo test --lib` only.
Verified: `cargo test --test memory_comparison` 7 passed; `memory_restart` 14,
`migrate_legacy` 10, `profile_lifecycle` 17, `compat_v041_to_v050` 2 — the four
other integration binaries touching memory surfaces. `cargo fmt` clean.
|
Pushed CI caught a test that pinned the defect this PR removes — // Markdown: append-only, count increases
assert!(md_count >= 2, "Markdown should keep both entries");That is the append-only behaviour stated as the expectation. It now asserts markdown replaces, matching the sqlite assertions directly above it, and reads both backends through Why local validation missed it: it is an integration test under |
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
markdownbackend.storeappended unconditionally, so storing a key twice wrote two lines.forgetthenbreaked at the first file that matched, so a key present in more than one file lost one copy and kept the rest — while returningtrue.SqliteMemoryviaON CONFLICT(key) DO UPDATE,PostgresMemorymatching it) andMemoryis one trait, so this was a contract divergence. The duplicate inflatedcount()— whichmemory statsrenders asTotal:— showed the key twice inlist(), and leftget()returning whichever copy sorted first under atimestampthat is really the filename. Worse,MemoryForgetTooladvertises "delete outdated facts or sensitive data" and rendersOk(true)as"Forgot memory: k"whileget()still answered with the entry.remove_key_from_file;forgetnow sweeps every file it owns;storeclears the key from every file before writing, so a re-store under a different category moves the entry rather than duplicating it across two files.get()'s tie-break arbitrary, but changing it touchesparse_entries_from_file,recallordering andlistordering. Same forget()'s|| e.content.contains(key)fallback, which lets a content substring resolve as a key: real, different defect, different blast radius. Neither is bundled here.Evidence from probes against the previous behaviour:
The control matters: the identical two-write-then-forget sequence on
sqlitepasses — one row, deleted cleanly. The doc comment onforgetalready argues that answering wrongly about a deletion is unacceptable; thebreakreintroduced the same class of wrong answer from the other direction.Scope of impact: only reached when
[memory] backend = "markdown". The default issqlite(config/schema.rs).Label Snapshot (required)
risk: low|medium|high):risk: mediummemoryChange 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: six new tests. Four were confirmed to fail against the previous behaviour by restoring it (re-adding the
break, removingstore's call toforget) and re-running:markdown_store_replaces_an_existing_keymarkdown_store_moves_an_entry_across_categoriesmarkdown_forget_sweeps_every_filemarkdown_repeated_store_does_not_grow_the_fileThe other two —
markdown_hand_written_lines_survive_store_and_forget,markdown_forget_absent_key_rewrites_nothing— guard behaviour this change could have broken rather than reproducing the bug, and pass either way by design.If any command is intentionally skipped: full-workspace
cargo testwas not run (disk-bound on this machine); the wholememory::module was run instead.Security Impact (required)
MEMORY.md,memory/*.md), same directory.storenow performs a read-modify-write on files it previously only appended to, and may rewrite a sibling file when moving an entry between categories.Worth stating positively: this improves a security-adjacent property.
memory_forgetexists partly to delete sensitive data, and it previously reported success while leaving a copy behind.Privacy and Data Hygiene (required)
passk,dupe,old value/new value,core copy/daily copy.Compatibility / Migration
storeof that key, and are otherwise harmless (they read as two entries, which is what they already did).Human Verification (required)
CorethenDaily→ entry moves, old file no longer holds it; a key duplicated acrossMEMORY.mdand a daily log →forgetreturnstrueandget()returnsNone.- **key**: valueshaped) survives both astoreof an unrelated key and aforgetof an unrelated key; the# Long-Term Memoryheader survives;forgetof an absent key rewrites nothing (asserted on both contents and mtime — contents alone is the weaker check); re-storing one key five times does not grow the file.[memory] backend = "markdown"configured in a real profile. All evidence is unit-level againstMarkdownMemorydirectly.Side Effects / Blast Radius (required)
memory/markdownonly. No other module changed.storeis now a read-modify-write across every file the backend owns instead of a single append, so it is O(files) per write. The markdown backend is the non-default, human-readable one and is not the performance path.hand_written_lines_survivetest is the specific guard.Agent Collaboration Notes (recommended)
plans/084-markdown-memory-appends-instead-of-upserting.md. The plan sequences the two fixes (drop thebreakfirst, then makestorereplace) because B is correct standalone while A is what stops the duplicate state being created.AGENTS.md+CONTRIBUTING.md) — yes.Rollback Plan (required)
git revert bf09536. Single commit, one file, no schema or config surface.MEMORY.mdor a daily log losing operator-written prose, or losing its header;count()dropping unexpectedly on a markdown profile.Risks and Mitigations
Risk:
storenow rewrites files it previously only appended to, andMEMORY.mdis a file humans edit by hand. A parsing mistake would eat operator prose.remove_key_from_filedrops only linessplit_stored_entryparses into a key equal to the target; anything else — headers, prose, differently-shaped lines — is copied through verbatim.markdown_hand_written_lines_survive_store_and_forgetpins exactly this.Risk: the trailing-blank-line trim is a third behaviour change riding along with the two defects.
append_to_fileadds another, so re-storing one key repeatedly grew the file by a blank line each time — andMEMORY.mdis injected into the prompt, making that an unbounded token cost.markdown_repeated_store_does_not_grow_the_filepins it, and it fails against the previous behaviour.Risk: moving an entry between categories is a semantic choice, not a forced one — the alternative was to reject the re-store.
sqlitedoes (ON CONFLICT(key) DO UPDATE SET category = excluded.category), and matching it is the whole point of this PR. Stated explicitly in the plan and pinned bymarkdown_store_moves_an_entry_across_categories.🤖 Generated with Claude Code