fix(memory): make the markdown backend upsert, and forget every copy - #434
Closed
sulthannauval wants to merge 1 commit into
Closed
fix(memory): make the markdown backend upsert, and forget every copy#434sulthannauval wants to merge 1 commit into
sulthannauval wants to merge 1 commit into
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/31258425113 Detected blocking line issues (sample):
Detected advisory line issues (sample):
|
Member
Author
|
Superseded by #438 — identical commit. Closed automatically when the branch was renamed to |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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