Skip to content

Commit 42612cb

Browse files
authored
Merge pull request #27 from nickroci/feat/reconsolidation-on-retrieval
feat(reconsolidation): mutate memories on retrieval, gated against distortion
2 parents 0f44544 + 793241c commit 42612cb

6 files changed

Lines changed: 226 additions & 38 deletions

File tree

README.md

Lines changed: 3 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -72,20 +72,6 @@ You now have:
7272
- automatic priming on every prompt
7373
- everything under `~/.agent-mem/knowledge/` as plain markdown — local, no cloud, no telemetry
7474

75-
> **Don't also run `/ultan-install`.** It wires a *second* copy of the hooks into
76-
> `settings.json`; with the plugin installed, every hook would then fire twice.
77-
> `/ultan-install` is only for the from-source path — see *Development setup* below.
78-
79-
> **Already running Ultan from source?** If you previously ran `/ultan-install`, remove
80-
> its hooks *before* installing the plugin or every event will be captured twice:
81-
> open `~/.claude/settings.json` (or the project's `.claude/settings.json` if you used
82-
> `--project`) and delete every `hooks` entry whose command points into your agent-mem
83-
> checkout. Stop any manually started daemon too — the plugin's daemon lazy-starts on
84-
> its own. Your library needs no migration: `~/.agent-mem/` is shared by both setups.
85-
86-
> The plugin currently installs from the `main` branch (`@main`) and will move to a
87-
> tagged release before wider promotion.
88-
8975
### Where your memories live
9076

9177
Everything Ultan owns lives under **`~/.agent-mem/`** on your local disk — no cloud sync, no hosted database, no telemetry. Override with `AGENT_MEM_HOME=/some/other/path` if you want a different root.
@@ -433,7 +419,7 @@ We don't need to replicate biology exactly. We need the mechanisms to be *presen
433419
| LTP — reactivation strengthens the trace (Roediger & Karpicke 2006 testing effect; Bjork & Bjork 1992) | `reinforced` counter bumped on reuse | **Done** |
434420
| LTD — passive decay of unused weak traces (Ebbinghaus 1885; Wixted 2004; Bear & Malenka 1994) | Half-life on each entry as `f(encoding_strength, reinforced_count)`; only effective for low/moderate strength entries | Partial — fixed 30-day surfacing-aware sweep + archive shipped (PR #7); the `f(encoding_strength, …)` half-life formula awaits the `encoding_strength` field |
435421
| **Prefrontal inhibition of retrieval** (Anderson & Green 2001 Nature, Think/No-Think paradigm) | **Agent seeing a surfaced memory and not acting on it is functionally a no-think signal.** Counts as weak negative evidence — accelerates decay for low/moderate-strength entries; for high-strength entries it triggers a reconsolidation/update review (relevance-drift, not irrelevance). Asymmetric: "agent explicitly cited / `ultan-search`-fetched" is *strong positive* evidence; "surfaced but ignored" is *weak negative* evidence, requiring multiple instances. Mirrors the brain's asymmetric weighting of presence-of-use vs absence-of-use. | Partial — **positive-use capture shipped** (PR #20): the Librarian judges genuine reliance on a surfaced entry and emits a `used_helpfully` signal; the Scholar deterministically bumps a `fired-helpful` counter on the cited entry, deduped per (session, entry, turn) via a stable seal-time `turn_seq` so a turn re-seen on a later scan never double-counts. **Retrieval-ranking consumption now shipped**: `fired-helpful`/`fired` feeds Tier-1 rank as a gentle, prior-centered usefulness tiebreaker (cold-start-neutral; can't override the reranker's applicability call). The `fired` surface counter is now wired too (incremented per priming surface), supplying the tiebreaker's denominator and the raw material for the negative half. **Still TODO**: feed the signal into *decay resistance* (half-life), and build the negative half ("surfaced but ignored" → weak no-think evidence). |
436-
| **Reconsolidation** — retrieved memories become labile and are re-stored mutated (Nader et al. 2000; Schiller et al. 2010; Lee et al. 2017; Hupbach et al. 2007) | Librarian gets a `drift` salience signal alongside `contradicts`/`novel`/`reinforces`: *"high-strength entry [[X]] keeps surfacing in contexts that don't quite match its current text — propose an `update`."* Scholar evaluates as a partial mutation, not full replacement. Closest published OSS analog: A-MEM's Zettelkasten evolution (Xu et al., NeurIPS 2025). | Partial — `update` action exists; drift-driven proposal pathway does not |
422+
| **Reconsolidation** — retrieved memories become labile and are re-stored mutated, which both updates *and* distorts (Nader et al. 2000; Schiller et al. 2010; Lee et al. 2017; Hupbach et al. 2007; **Bridge & Paller 2012** — every retrieval is a partial re-write that measurably *distorts*) | Librarian has a `drift` salience signal alongside `contradicts`/`novel`/`reinforces`. **Trigger is retrieval-into-use**: when an entry surfaces and is genuinely *used* this turn (the same Tier-2 / `used_helpfully` moment), the Librarian may propose an `update_entry` that folds a new qualifier from the use-context into the entry *or* compresses/sharpens it. Three guardrails keep mutation from becoming distortion (the Bridge & Paller failure mode): (1) **split-on-sprawl** — size is managed structurally, not by a per-entry char cap: when integrating new nuance would make an entry cover more than one claim, the Librarian (whose job is reorg) splits it — trims the original and spins the new material into its own linked entry — rather than letting reconsolidation balloon one file; (2) **claim-preservation gate** — the Scholar diffs old-vs-new and vetoes any change that weakens the load-bearing claim or is mere churn; (3) a **`reconsolidated` counter** (+ `last_reconsolidated`) tracks cumulative mutation so the Scholar tightens on much-churned entries. Scholar evaluates as a partial mutation, not full replacement. Closest published OSS analog: A-MEM's Zettelkasten evolution (Xu et al., NeurIPS 2025). | **Done** — `drift` signal + retrieval-into-use proposal pathway + claim-preservation gate + split-on-sprawl curation + `reconsolidated` counter shipped. **Still TODO**: the ambient-drift trigger (high-`reinforced` entry whose *surfacing* contexts drift even without an explicit use); a re-reconsolidation cooldown; and an optional deterministic size→split backstop if curator judgement proves unreliable |
437423
| Sleep-based selective consolidation (Diekelmann & Born 2010; Stickgold 2005; Wilhelm et al. 2011) | The Scholar's batch reconciliation phase plays this role architecturally — periodic, deliberate, prioritises high-salience entries; no behavioural analog of replay yet | Partial |
438424
| **Reflective abstraction — offline integration of leaf episodes into higher-order rules** (van Kesteren et al. 2012; Jung-Beeman et al. 2004; Kizilirmak et al. 2016; Schlichting & Preston 2015; Tse et al. 2007; Behrens et al. 2018; Preston & Eichenbaum 2013; Eichenbaum 2017; LLM analog: Park et al. 2023 Generative Agents reflection) | `abstract_entries` Librarian action proposes a parent abstraction over ≥2 related leaves during its normal scan — agent judgment, not cosine clustering. Surprise-/aha-gated (remote children + predictive lift + non-obvious + compresses) on both the Librarian propose-side and Scholar veto-side; the Scholar approves or vetoes; on approve, the executor writes the parent (`type: abstraction`) with `[[wikilink]]` backlinks into each child, which stay in place. See *Reflective abstraction* above. | **Done (slice 1)** — encoding_strength-derived parent durability awaits the `encoding_strength` field; ships on the existing `reinforced`/decay lifecycle |
439425
| Rational analysis of memory — retention tracks environmental utility (Anderson & Schooler 1991) | The whole loop is enacting this if encoding-strength + decay + use-tracking work together. The Anderson & Schooler framing is the cleanest theoretical anchor for the system as a whole. | Emergent if the rest lands |
@@ -445,7 +431,7 @@ We don't need to replicate biology exactly. We need the mechanisms to be *presen
445431
**Open implementation questions:**
446432

447433
- What concrete signal does Ultan use to detect "surfaced but ignored"? Candidates: entry appeared in `additionalContext` priming N times in last K days *and* was never `ultan-search`-fetched *and* doesn't appear (as wikilink or paraphrase) in the agent's outputs in those sessions. Likely needs a lightweight surfaced-vs-used log alongside `runs/`.
448-
- How does the Librarian detect drift for the reconsolidation pathway? Probably: high-`reinforced` entry whose recent surfacing contexts have low text-similarity to the entry itself — i.e. the entry is being surfaced for the right reasons by BM25/embeddings but the *frame* around it keeps shifting.
434+
- How does the Librarian detect drift for the reconsolidation pathway? **Resolved for the use-triggered case** (shipped): the labile window opens when an entry is genuinely *used* this turn (the `used_helpfully` / Tier-2 moment), and the Librarian checks whether the use-context adds a qualifier worth integrating or the entry can be compressed. Still open: the *ambient* case — high-`reinforced` entry whose recent surfacing contexts have low text-similarity to the entry itself (surfaced for the right reasons by BM25/embeddings but the *frame* around it keeps shifting), with no explicit use to anchor the window.
449435

450436
Prior art worth borrowing from: **MemoryBank** (Zhong et al., 2024) applies the Ebbinghaus curve to retrieval weighting; **FadeMem** (2025) does biologically-inspired decay end-to-end. Neither (as far as we've seen) couples decay with surprise-calibrated encoding strength + a prefrontal-inhibition analog + a reconsolidation pathway. That combination is the contribution.
451437

@@ -468,6 +454,7 @@ Prior art worth borrowing from: **MemoryBank** (Zhong et al., 2024) applies the
468454
- Diekelmann, S. & Born, J. (2010). *The memory function of sleep.* Nature Reviews Neuroscience, 11(2), 114–126.
469455
- Schiller, D., Monfils, M.-H., Raio, C.M., Johnson, D.C., LeDoux, J.E. & Phelps, E.A. (2010). *Preventing the return of fear in humans using reconsolidation update mechanisms.* Nature, 463(7277), 49–53.
470456
- Wilhelm, I., Diekelmann, S., Molzow, I., Ayoub, A., Mölle, M. & Born, J. (2011). *Sleep selectively enhances memory expected to be of future relevance.* Journal of Neuroscience, 31(5), 1563–1569.
457+
- Bridge, D.J. & Paller, K.A. (2012). *Neural correlates of reactivation and retrieval-induced distortion.* Journal of Neuroscience, 32(35), 12144–12151. (Retrieval reactivates a memory into a labile state and measurably *distorts* it on re-storage — the failure mode the reconsolidation guardrails defend against.)
471458
- Hardt, O., Nader, K. & Nadel, L. (2013). *Decay happens: the role of active forgetting in memory.* Trends in Cognitive Sciences, 17(3), 111–120.
472459
- Nørby, S. (2015). *Why forget? On the adaptive value of memory loss.* Perspectives on Psychological Science, 10(5), 551–578.
473460
- Greve, A., Cooper, E., Kaula, A., Anderson, M.C. & Henson, R. (2017). *Does prediction error drive one-shot declarative learning?* Journal of Memory and Language, 94, 149–165.

daemon/agent_mem_daemon/_schemas.py

Lines changed: 28 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -83,27 +83,34 @@ class _BaseAction(BaseModel):
8383
),
8484
)
8585

86-
salience_signal: Optional[Literal["contradicts", "novel", "reinforces", "used_helpfully"]] = (
87-
Field(
88-
default=None,
89-
description=(
90-
"Why this is worth remembering, in cognitive-science "
91-
"terms. 'contradicts': disagrees with an existing entry "
92-
"(cite path in existing_entry); user has changed their "
93-
"mind. 'novel': not in library AND not derivable from "
94-
"the model's baseline knowledge. 'reinforces': restates "
95-
"an existing entry's claim (cite path in existing_entry); "
96-
"the daemon will bump that entry's reinforced counter "
97-
"and the Scholar may veto the write. 'used_helpfully': "
98-
"the assistant actually RELIED ON / AGREED WITH a "
99-
"surfaced or cited existing entry to answer THIS turn "
100-
"(cite path in existing_entry AND the stable turn id in "
101-
"cited_turn_seq); the daemon bumps that entry's "
102-
"fired-helpful counter once per cited turn. A mere "
103-
"mention is NOT use, and disagreement is 'contradicts', "
104-
"not this. Null if unsure — the Scholar will infer."
105-
),
106-
)
86+
salience_signal: Optional[
87+
Literal["contradicts", "novel", "reinforces", "used_helpfully", "drift"]
88+
] = Field(
89+
default=None,
90+
description=(
91+
"Why this is worth remembering, in cognitive-science "
92+
"terms. 'contradicts': disagrees with an existing entry "
93+
"(cite path in existing_entry); user has changed their "
94+
"mind. 'novel': not in library AND not derivable from "
95+
"the model's baseline knowledge. 'reinforces': restates "
96+
"an existing entry's claim (cite path in existing_entry); "
97+
"the daemon will bump that entry's reinforced counter "
98+
"and the Scholar may veto the write. 'used_helpfully': "
99+
"the assistant actually RELIED ON / AGREED WITH a "
100+
"surfaced or cited existing entry to answer THIS turn "
101+
"(cite path in existing_entry AND the stable turn id in "
102+
"cited_turn_seq); the daemon bumps that entry's "
103+
"fired-helpful counter once per cited turn. 'drift': "
104+
"RECONSOLIDATION — an entry that surfaced/was used this "
105+
"turn should be mutated in place to integrate a genuine "
106+
"new qualifier from the use-context OR to compress/sharpen "
107+
"it (cite the entry in existing_entry, action MUST be "
108+
"update_entry). A reconsolidation may NOT grow the entry "
109+
"past its current size — it pays for new nuance by cutting "
110+
"stale text. A mere mention is NOT use, and disagreement is "
111+
"'contradicts', not this. Null if unsure — the Scholar will "
112+
"infer."
113+
),
107114
)
108115

109116
existing_entry: Optional[str] = Field(

daemon/agent_mem_daemon/librarian_prompt.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -702,6 +702,48 @@ def _slugify(s: str) -> str:
702702
Cite the turn precisely: re-seeing the same turn on a later scan must not \
703703
double-count, which only works if you give the stable ``turn_seq``.
704704
705+
**DRIFT — reconsolidate an entry that was just retrieved** (mutate in \
706+
place, sparingly)
707+
Retrieval makes a memory labile: an entry that surfaced and was USED \
708+
this turn is the right moment to fold in a genuine refinement — but \
709+
reconsolidation also DISTORTS if you let it (every re-write risks eroding \
710+
the original claim). Propose ``drift`` ONLY when there is real signal, \
711+
never as a reflexive tidy-up.
712+
Propose when EITHER:
713+
- the use-context carries a genuine new qualifier, edge case, or \
714+
correction that belongs in the entry (e.g. entry says "use uv"; this turn \
715+
established "…except in the legacy `tools/` dir that pins pip" → integrate \
716+
that caveat), OR
717+
- the entry can be sharpened without changing its claim (a buried \
718+
rule pulled to the top, a stale tangent cut).
719+
Hard rules:
720+
- The load-bearing claim MUST survive intact. You are integrating or \
721+
sharpening, NOT rewriting from scratch. If you can't keep the core claim \
722+
verbatim-in-spirit, this is ``contradicts``, not ``drift``.
723+
- Pure rephrasing that adds no information and sharpens nothing → \
724+
DON'T. That is exactly the churn that turns memories to mush over time.
725+
- **SPLIT instead of cramming when the entry sprawls.** Two cases: \
726+
(a) integrating the new nuance would push the entry to cover more than one \
727+
coherent claim, or (b) the entry you are already reconsolidating has \
728+
ALREADY grown large and spans mixed topics. In either case decompose — \
729+
but gently: only when the topics are genuinely separable, never as \
730+
reflexive tidying. You are the curator; reorg is your job. Spin the \
731+
separable material into its OWN entry and trim the original: emit an \
732+
``update_entry`` on the original (leaner body, with a ``[[link]]`` to the \
733+
new entry) PLUS a ``write_entry`` for the spun-off entry. Don't let \
734+
reconsolidation balloon a single file. (If the split pushes a folder over \
735+
its entry cap, the normal reorg pass handles that.) You only learn an \
736+
entry's true size by ``read_entry``-ing it — so when you reconsolidate, \
737+
read the whole entry first and judge whether it has quietly become several \
738+
topics.
739+
Action: for an in-place refinement, propose ``update_entry`` with \
740+
``salience_signal: "drift"``, cite the entry in ``existing_entry``, and \
741+
put the FULL mutated body (frontmatter + prose) in ``new_body``. For a \
742+
split, emit the trimming ``update_entry`` (also ``salience_signal: \
743+
"drift"``) alongside the new ``write_entry``. Independent of \
744+
``used_helpfully`` (that only counts the hit; ``drift`` actually edits the \
745+
entry) — you may emit both for the same entry in one scan.
746+
705747
**REDUNDANT / no signal** (skip silently — no proposal)
706748
Things every competent assistant would already produce or that don't carry information:
707749
- Code-as-code: "added a for loop", "created a class", "fixed indentation"

daemon/agent_mem_daemon/scholar_executor.py

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,11 @@ def _reserialise(fm: Dict[str, Any], body: str) -> str:
106106
"last_reinforced",
107107
"last_surfaced",
108108
"created",
109+
# Reconsolidation bookkeeping. Preserved verbatim on ordinary rewrites
110+
# (repairs, merges) so the mutation history survives; on a drift
111+
# reconsolidation the stamp step below increments them authoritatively.
112+
"reconsolidated",
113+
"last_reconsolidated",
109114
)
110115

111116

@@ -140,6 +145,29 @@ def _preserve_server_owned_fields(abs_path: Path, proposed_body: str) -> str:
140145
return _reserialise(proposed_fm, body)
141146

142147

148+
def _stamp_reconsolidation(abs_path: Path, proposed_body: str, now: datetime) -> str:
149+
"""Increment the ``reconsolidated`` counter and stamp ``last_reconsolidated``
150+
on a drift (mutation-on-retrieval) update.
151+
152+
The count is taken authoritatively from the PRIOR on-disk entry (+1), never
153+
from whatever the model emitted, so a stale model-supplied value can't reset
154+
the history. Returns ``proposed_body`` unchanged when it carries no
155+
parseable frontmatter to stamp onto."""
156+
prior_count = 0
157+
if abs_path.exists():
158+
prior_fm, _ = _split_body(_safe_read(abs_path))
159+
try:
160+
prior_count = int(prior_fm.get("reconsolidated", 0) or 0)
161+
except (TypeError, ValueError):
162+
prior_count = 0
163+
proposed_fm, body = _split_body(proposed_body)
164+
if not proposed_fm:
165+
return proposed_body
166+
proposed_fm["reconsolidated"] = prior_count + 1
167+
proposed_fm["last_reconsolidated"] = now.date().isoformat()
168+
return _reserialise(proposed_fm, body)
169+
170+
143171
def _atomic_write(path: Path, text: str) -> None:
144172
"""Write ``text`` to ``path`` atomically (tmp + os.replace)."""
145173
path.parent.mkdir(parents=True, exist_ok=True)
@@ -306,14 +334,21 @@ def _apply_write_like(
306334
session_id: str,
307335
now: datetime,
308336
result: ExecResult,
337+
reconsolidation: bool = False,
309338
) -> None:
310339
"""Shared body for write_entry / update_entry. When the entry already
311340
exists on disk (update, or a write that replaces an entry), the
312341
server-owned bookkeeping counters are forced from the prior entry before
313342
the verbatim write so an LLM-emitted default can't clobber them; then
314-
sync the index row and append the log."""
343+
sync the index row and append the log.
344+
345+
``reconsolidation`` marks a drift mutation-on-retrieval update: after the
346+
counters are preserved, the ``reconsolidated`` counter is bumped and
347+
``last_reconsolidated`` is stamped from the prior on-disk value."""
315348
abs_path = (knowledge_dir / rel_path).resolve()
316349
body = _preserve_server_owned_fields(abs_path, body)
350+
if reconsolidation:
351+
body = _stamp_reconsolidation(abs_path, body, now)
317352
_atomic_write(abs_path, body)
318353
fm, fm_body = _split_body(body)
319354
row = _index_row(rel_path, fm, fm_body, session_id=session_id)
@@ -348,6 +383,7 @@ def _do_update(
348383
session_id=session_id,
349384
now=now,
350385
result=result,
386+
reconsolidation=(a.salience_signal == "drift"),
351387
)
352388

353389

0 commit comments

Comments
 (0)