Thanks to visit codestin.com
Credit goes to github.com

Skip to content

feat(agent-memory): opt-in write-time fact consolidation#299

Merged
KIvanow merged 6 commits into
masterfrom
feat/agent-memory-consolidate-facts
Jul 8, 2026
Merged

feat(agent-memory): opt-in write-time fact consolidation#299
KIvanow merged 6 commits into
masterfrom
feat/agent-memory-consolidate-facts

Conversation

@KIvanow

@KIvanow KIvanow commented Jul 3, 2026

Copy link
Copy Markdown
Member

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

  • Add MemoryStore.consolidateFacts(options): selects source memories by scope/tags/olderThanSeconds/maxImportance, calls a caller-provided extractFacts LLM seam, reconciles the results, and writes dated fact memories additively (no source deletion, unlike the lossy consolidate()).
    • Add subject-keyed reconciliation (reconcile / applyOps): newest date wins, tombstone drops a subject, restatements dedupe. Each fact's asserted date is preserved by prefixing it into the written content.
    • Add the consolidation constructor flag (true or { enabled, factSource, factImportance }), default OFF. Calling consolidateFacts() while disabled throws a clear error. Prior fact memories are excluded from the candidate scan (-@source:{fact}) so a re-run never re-distills its own output.
    • New types: Fact, FactExtractor, ConsolidateFactsOptions/ConsolidateFactsResult, ConsolidationConfig; exported from the package index.
    • Python parity port (betterdb-agent-memory): reconcile_facts.py, consolidate_facts(), matching types and exports.
    • README API docs updated in both packages.

Checklist

  • Unit / integration tests added - new reconcileFacts and consolidateFacts suites in both languages
    (177 TS tests, 125 Python tests passing)
  • Docs added / updated - MemoryStore API sections in both READMEs
  • Roborev review passed — run roborev review --branch or /roborev-review-branch in Claude Code (internal)
  • Competitive analysis done / discussed (internal)
  • Blog post about it discussed (internal)

Note

Medium Risk
New Valkey write/delete path can change stored fact rows when enabled; behavior depends on caller extractFacts quality 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 MemoryStore in TypeScript and Python: consolidateFacts / consolidate_facts selects source memories (scope, tags, age, importance), runs a caller-supplied extractFacts LLM seam, and writes additive fact memories while leaving sources intact (unlike lossy consolidate).

Facts are keyed by subject, reconciled with newest date wins and tombstones, and persisted with optional subject / date hash 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 new consolidation constructor flag (ConsolidationConfig); calling it when disabled errors. Candidate scans exclude prior fact source values; consolidate filters gain includeSource for loading stored facts.

New reconcile / applyOps helpers 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.

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.
Comment thread packages/agent-memory/src/reconcileFacts.ts
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.
Comment thread packages/agent-memory/src/reconcileFacts.ts
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.
Comment thread packages/agent-memory/src/MemoryStore.ts Outdated
…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));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 58b2502. Configure here.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@jamby77

jamby77 commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator

Two correctness edges worth fixing before this is relied on:

  1. [date] statement ambiguity (reconcileFacts.ts:20): a dateless fact whose statement starts with a bracket (e.g. "[Q3] revenue target is 5M") round-trips into a spurious dated fact (date="Q3"), and isNewer's string compare then drops a genuinely newer dated fact. Fix: persist date in its own hash field and use it as the source of truth for datedness, instead of inferring from a leading bracket (content encoding can stay the same, so the diff logic is untouched).

  2. Consolidation race (MemoryStore.ts:923): existingBySubject uses Map.set, so two concurrent consolidateFacts runs that each write a fact for the same subject leave a duplicate that's silently dropped from tracking and orphaned forever. Minimal fix: retract duplicate-subject facts on the next run (self-heal); stronger fix is a per-scope lock. Applies to the Python mirror too.

Nit: one-line conditional at reconcileFacts.ts:44.

…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).
@KIvanow

KIvanow commented Jul 8, 2026

Copy link
Copy Markdown
Member Author

Two correctness edges worth fixing before this is relied on:

  1. [date] statement ambiguity (reconcileFacts.ts:20): a dateless fact whose statement starts with a bracket (e.g. "[Q3] revenue target is 5M") round-trips into a spurious dated fact (date="Q3"), and isNewer's string compare then drops a genuinely newer dated fact. Fix: persist date in its own hash field and use it as the source of truth for datedness, instead of inferring from a leading bracket (content encoding can stay the same, so the diff logic is untouched).
  2. Consolidation race (MemoryStore.ts:923): existingBySubject uses Map.set, so two concurrent consolidateFacts runs that each write a fact for the same subject leave a duplicate that's silently dropped from tracking and orphaned forever. Minimal fix: retract duplicate-subject facts on the next run (self-heal); stronger fix is a per-scope lock. Applies to the Python mirror too.

Nit: one-line conditional at reconcileFacts.ts:44.

these should be fixed as well

Comment thread packages/agent-memory/src/reconcileFacts.ts Outdated
…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).

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Fix All in Cursor

❌ 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));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit c83543e. Configure here.

@jamby77 jamby77 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

@KIvanow KIvanow merged commit 8d5518a into master Jul 8, 2026
2 of 3 checks passed
@KIvanow KIvanow deleted the feat/agent-memory-consolidate-facts branch July 8, 2026 15:46
@github-actions github-actions Bot locked and limited conversation to collaborators Jul 8, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants