Skip to content

fix(core,python): a forgotten fact stays forgotten - #715

Draft
p-diogo wants to merge 1 commit into
mainfrom
fix/forget-sticks-605
Draft

p-diogo wants to merge 1 commit into
mainfrom
fix/forget-sticks-605

Conversation

@p-diogo

@p-diogo p-diogo commented Nov 3, 2026

Copy link
Copy Markdown
Collaborator

Refs graphprotocol/totalreclaw-internal#605, graphprotocol/totalreclaw-internal#640.

Summary

A fact the user asks to forget now stays forgotten. Before, totalreclaw_forget tombstoned it, and a later auto-extraction stored it again.

Root cause

Auto-extraction keeps reading text that restates the forgotten fact:

  • the turn that first said it, when it shares an extraction window with the forget (interval 3);
  • the forget turn itself ("forget that Inês proposed PgBouncer");
  • an assistant answer built from Hermes' own session_search over the old transcript (rc3 S5);
  • the session Crystal, which reads the whole conversation.

The write path has no record of the deletion. Line numbers are on main. The dedup context comes from client.recall(...) over active facts only (python/src/totalreclaw/agent/lifecycle.py:122). totalreclaw_forget (hermes/tools.py:323) calls client.forget (client.py:1120), which only writes a tombstone. So the restatement looks new, passes the near-duplicate check (lifecycle.py:401), and _auto_extract_inner stores it (lifecycle.py:373-441). A note about the deletion ("User previously identified as vegetarian and asked for that record to be removed", internal 640) is stored the same way.

Fix

Write-time, client-side, no on-chain change.

  • Core (rust/totalreclaw-core/src/forget_guard.rs), pure logic shared by all clients:
    • match_forgotten(text, embedding, entries): exact (normalized text equal), subset (adds no content word, cos >= 0.70), superset (keeps every content word, cos >= 0.80), anchor (2+ and at least half of the forgotten text's capitalized or numeric words, cos >= 0.80).
    • Cosine alone can't do this: with Harrier, "favorite color is blue" vs "green" is 0.94 and "lives in Lisbon" vs "Porto" is 0.90. A candidate that both drops and adds ordinary words never matches, so a new value in the same slot still stores.
    • is_forget_request(text): notes about a deletion. It needs a request verb before "to forget", or a memory/record target. "Tends to forget appointments" and "wants to delete their Facebook account" pass.
    • Bindings: PyO3 match_forgotten / is_forget_request, WASM matchForgotten / isForgetRequest. Shared fixture tests/parity/fixtures/forget-guard-v1.json (21 match, 14 request, 11 token/anchor cases).
  • Python:
    • ForgetLedger (forget_guard.py): ~/.totalreclaw/forgotten-<eoa>.enc, XChaCha20-Poly1305 under the vault key, mode 0600, capped at 500. Plaintext lives only in process memory.
    • client.forget(fact_id, suppress_reextraction=True) decrypts the fact first (operations.fetch_fact_plaintext) and records it once the tombstone lands. Only totalreclaw_forget passes True. UPDATE tombstones, re-crystallization and the USER.md mirror's replace don't.
    • _auto_extract_inner drops ADD/UPDATE candidates that restate a ledger entry or are deletion notes. This covers turn, on_pre_compress, session end and the pending drain.
    • session_debrief drops matching Crystal sentences and items. If no narrative is left, no Crystal is stored.
    • An explicit totalreclaw_remember clears the matching entry, so the user can bring a fact back. Mirrored Hermes memory writes (_from_mirror) can't bring it back.
    • The Python port is used until core 2.7.0 is installed; then the core binding is used.

Tests

  • python/tests/test_forget_sticks_605.py: reproduction with a real TotalReclaw client, a mocked relay and chain write, and the real Hermes tools. On main, the 605, 640, Crystal, mirror and UPDATE cases fail; with the fix all 7 pass.
  • python/tests/test_forget_guard.py: fixture parity (Python port, and the core binding when present), ledger encryption, wrong key, cap and release, fetch_fact_plaintext, and client.forget wiring.
  • cargo test (core): 717 passed, including 9 forget_guard tests.
  • tests/parity/forget-guard-parity.test.ts: WASM, 36 passed. Added as a CI step.
  • Full Python suite: 2819 passed on PyPI core 2.6.1; 2840 passed on a core wheel built from this branch.
  • check-pyfunction-parity.py, check-phrase-safety.sh and check-docs.sh are green.
  • Real Harrier check on QA-shaped strings: 20 of 20 come out as expected. The rc3 restatement matches as anchor (cos 0.905). The rc2 "outdated" note matches as subset (0.739). Porto, vegan, green and "brother Rui" all store.

Known limits

  • The ledger is per machine. A second device, or the TS clients, can't see it yet. Syncing it would need an encrypted on-chain record, which is a format decision.
  • superset can over-suppress. "User's sister is vegetarian" is dropped once "User is vegetarian" was forgotten (cos 0.884).
  • A loose paraphrase with no shared content word still gets through ("User does not eat meat").
  • Crystal scrubbing uses the same conservative matcher. A sentence that mixes the forgotten fact with other content is kept.
  • Facts that were re-created before this fix are not cleaned up.

Overlap

Needs the same fix (not in this PR)

  • OpenClaw plugin: storeExtractedFacts (skill/plugin/index.ts:1551) and the forget tool path (skill/plugin/memory/).
  • MCP: mcp/src/tools/forget.ts, and the batch write path in mcp/src/index.ts (around lines 848 and 2218).
  • Both can use the WASM matchForgotten / isForgetRequest once core 2.7.0 ships.

No version bump, no RC, no publish.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Bd3G1BkVMFSXkcdAQ7nFax

totalreclaw_forget tombstoned the fact, but auto-extraction kept reading
text that restated it (the original turn, the forget turn, an answer
built from Hermes' session search, the session summary). The dedup
context lists active facts only, so the write path never knew the user
had deleted it, and the fact was stored again.

- core: new forget_guard module. match_forgotten (exact / subset /
  superset / anchor; lexical plus cosine, so value swaps still store)
  and is_forget_request (deletion notes). PyO3 + WASM bindings, shared
  fixture tests/parity/fixtures/forget-guard-v1.json.
- python: ForgetLedger, an encrypted per-account file of facts the user
  forgot (vault key, never plaintext, nothing on-chain). A user-asked
  forget snapshots the fact first and records it. Every auto-extracted
  write and the session Crystal drop restatements and deletion notes.
  An explicit remember clears the entry; mirrored Hermes memory writes
  can't; internal tombstones don't record.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bd3G1BkVMFSXkcdAQ7nFax

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant