feat(agent-memory): opt-in write-time fact consolidation#299
Conversation
Add consolidateFacts() to the MemoryStore tier (TS and Python): distill
selected memories into atomic, deduplicated facts via a caller-provided
extractFacts LLM seam and write each as its own memory, keeping the source
memories so recall is preserved. Facts are reconciled by subject (newest
date wins, tombstones drop a subject) and each fact's asserted date is
preserved in its content.
Off by default; enable via the consolidation constructor flag
(true or { enabled, factSource, factImportance }). Prior fact memories are
excluded from the candidate scan so a re-run never re-distills its output.
reconcile emitted a noop as soon as an incoming fact matched the prior statement, before comparing dates. A later restatement carrying a newer date was dropped, so consolidateFacts could persist a stale [date] prefix despite the documented newest-date-wins rule. Same-statement restatements now update when the incoming date is strictly newer (refreshing the stored date) and stay a noop for equal/older/dateless dates (no needless rewrite). Fixed in TS and Python for parity, with regression tests in both.
isNewer compared date strings with a `?? ''` fallback, so a dateless
candidate ('') lost to any dated prior. A later dateless assertion with a
different statement was therefore dropped, contradicting the documented
rule that a dateless fact is the latest assertion and should win.
isNewer now returns true when the candidate is dateless (it is the newest
thing we know) and otherwise compares its date against the prior (a
dateless prior counts as the epoch). Fixed in TS and Python for parity,
with regression tests in both.
…ries consolidateFacts reconciled each batch against an empty existing set, so re-runs wrote duplicate fact rows and cross-run tombstones/updates never superseded prior fact memories. Persist the reconcile key (subject) on each fact memory, load the stored facts for the scope, and reconcile against them: a re-run over unchanged sources rewrites nothing (idempotent), a newer dated statement supersedes (deletes) the prior memory, and a tombstone retracts it. The result now reports created (ids written) and deleted (prior fact memories superseded or retracted). Writes happen before deletes so a crash between the two can only leave a duplicate, never lose a fact. TypeScript and Python in lockstep with mirrored regression tests.
| 'DIALECT', | ||
| '2', | ||
| ); | ||
| return parseFtSearchResponse(raw).map((hit) => parseMemoryItem(this.name, hit)); |
There was a problem hiding this comment.
Subject missing from search index
High Severity
Fact consolidation loads prior fact rows with FT.SEARCH and RETURN on subject, but subject is never added to the memory index schema (unlike source). Search cannot reliably return that field, so stored facts lose their reconcile key and cross-run idempotency, supersede, and tombstone behavior breaks—or the search may fail outright.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 58b2502. Configure here.
There was a problem hiding this comment.
False positive. valkey-search RETURN loads fields straight from the underlying hash document, so it returns non-indexed fields too. Verified on the shipping version (valkey 9.1.0, search module 66048): an index whose schema is only content + source still returns subject via FT.SEARCH ... RETURN 3 content subject source. subject is deliberately not in the schema because it is only ever returned, never filtered (unlike source, which backs the @source / -@source clauses); indexing it as a TAG would just bloat a high-cardinality index for no query benefit. Cross-run reconcile works as shipped.
|
Two correctness edges worth fixing before this is relied on:
Nit: one-line conditional at |
…t dupes
Two correctness edges in write-time fact consolidation (TS + Python mirror):
- [date] statement ambiguity: a dateless fact whose statement starts with
a bracket (e.g. "[Q3] revenue target is 5M") round-tripped through the
content regex into a spurious dated fact (date="Q3"), and isNewer's
string compare then dropped a genuinely newer dated restatement
('2' < 'Q'). Persist the fact's date in its own hash field and use it as
the source of truth for datedness; strip the known "[date] " prefix only
when a date is present. Content encoding is unchanged, so the diff logic
is untouched.
- Consolidation race: existingBySubject used map-set, so two concurrent
consolidateFacts runs that each wrote a fact for the same subject left a
duplicate that was silently dropped from tracking and orphaned forever.
Keep one canonical row per subject and retract the extras on the next
run (self-heal).
Also brace the one-line isNewer guard. Adds regression tests on both sides
(dateless-bracket round-trip, newer-dated-wins, duplicate retraction).
these should be fixed as well |
…oncile factContent/storedFactToFact/buildMemoryRecord already treat date === "" as dateless, but isNewer and isStaleTombstone only special-cased undefined. So a fact with date: "" sorted as an empty string (before every real date): it failed to supersede a dated stored fact, and a date: "" tombstone looked stale so the subject was not retracted. Centralize the rule with a factDate helper (empty string -> dateless) and use it in isNewer, isStaleTombstone, and the same-statement restatement check, so every comparison agrees with the storage encoding. Mirrored in the Python package. Adds regression tests on both sides (empty-date supersede and empty-date tombstone retract).
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
There are 2 total unresolved issues (including 1 from previous review).
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit c83543e. Configure here.
| // collisions (within the batch AND against prior runs) resolve to the newest | ||
| // dated statement and tombstones drop retracted subjects. | ||
| const extracted = await options.extractFacts(candidates); | ||
| const curated = applyOps(priorFacts, reconcile(extracted, priorFacts)); |
There was a problem hiding this comment.
Duplicate facts use search order
Medium Severity
When multiple stored fact memories share a subject but differ in content, priorFacts is built from every row and reconcile/applyOps keep whichever duplicate appears last in the FT.SEARCH result, not the semantically newest fact. The canonical row tracked for deletes is the first duplicate. If extraction omits that subject, the run can delete the newer canonical memory and persist an older statement.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit c83543e. Configure here.
jamby77
left a comment
There was a problem hiding this comment.
Approved. Clean productization of the facts lever — recall-safe (sources kept, write-before-delete), idempotent re-runs, datedness read from the persisted date field rather than inferred from content brackets, and faithful TS↔Python parity with strong test coverage.
Non-blocking note: the duplicate-subject race-recovery path dedups priorFacts by last-in-FT.SEARCH-order while existingBySubject keeps the first-seen row as canonical, so a run over pre-existing duplicate rows could write the older-dated statement and delete the newer one. Self-heals next run and no source data is lost, but consider deduping by newest-date. CI red is the unrelated pre-existing cache-proposals-sqlite failure (this PR only touches agent-memory packages).


Summary
Adds opt-in write-time fact consolidation to the @betterdb/agent-memory MemoryStore tier (TypeScript and Python). consolidateFacts() distills a set of memories into atomic, deduplicated facts and writes each as its own memory while keeping the source memories, so recall is preserved. This productizes the LongMemEval "facts" lever (previously benchmark-only code in packages/retrieval/eval/longmemeval/facts.ts) as a first-class, tested library feature. It is off by default and gated behind a constructor flag.
Changes
Checklist
(177 TS tests, 125 Python tests passing)
roborev review --branchor/roborev-review-branchin Claude Code (internal)Note
Medium Risk
New Valkey write/delete path can change stored fact rows when enabled; behavior depends on caller
extractFactsquality and date/subject reconciliation rules, though it is gated off by default and does not delete source memories.Overview
Adds opt-in fact consolidation to
MemoryStorein TypeScript and Python:consolidateFacts/consolidate_factsselects source memories (scope, tags, age, importance), runs a caller-suppliedextractFactsLLM seam, and writes additive fact memories while leaving sources intact (unlike lossyconsolidate).Facts are keyed by
subject, reconciled with newestdatewins and tombstones, and persisted with optionalsubject/datehash fields plus dated content prefixes. Runs are stateful: existing fact rows are loaded, compared for idempotent re-runs, superseded facts are write-then-delete, and duplicate subjects from races are cleaned up. The feature is off by default via a newconsolidationconstructor flag (ConsolidationConfig); calling it when disabled errors. Candidate scans exclude prior factsourcevalues; consolidate filters gainincludeSourcefor loading stored facts.New
reconcile/applyOpshelpers and types (Fact,ConsolidateFactsResult, etc.) are exported; README API docs and unit tests cover reconciliation edge cases (bracketed dateless statements, tombstones, idempotency).Reviewed by Cursor Bugbot for commit c83543e. Bugbot is set up for automated code reviews on this repo. Configure here.