pi-memory-evolution 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,20 @@
2
2
 
3
3
  All notable changes to pi-memory-evolution are documented here.
4
4
 
5
+ ## [0.4.0](https://github.com/btnalit/pi-memory-evolution/compare/v0.3.2...v0.4.0) (2026-09-10)
6
+
7
+
8
+ ### Features
9
+
10
+ * confirm memories from evidence already in hand, and let every kind go dormant ([#28](https://github.com/btnalit/pi-memory-evolution/issues/28)) ([150bdbd](https://github.com/btnalit/pi-memory-evolution/commit/150bdbd82a86b57eb43d1197251411d2bba35b3c))
11
+
12
+ ## [0.3.2](https://github.com/btnalit/pi-memory-evolution/compare/v0.3.1...v0.3.2) (2026-09-10)
13
+
14
+
15
+ ### Bug Fixes
16
+
17
+ * apply the candidate cap after the containment filter, not before ([#26](https://github.com/btnalit/pi-memory-evolution/issues/26)) ([58cccac](https://github.com/btnalit/pi-memory-evolution/commit/58cccac3ee9367a3c9c386ac9d0686198493a926))
18
+
5
19
  ## [0.3.1](https://github.com/btnalit/pi-memory-evolution/compare/v0.3.0...v0.3.1) (2026-09-10)
6
20
 
7
21
 
@@ -67,8 +67,9 @@ not inherit old utility/accuracy feedback. A literal correction clears old alias
67
67
  feedback; undo restores the actual prior metadata.
68
68
 
69
69
  **Limit:** conflict detection still depends on the model identifying a `replaces` target
70
- in its candidate set, which the host now selects by what the source mentions rather than by
71
- what is recent. Records the source never mentions are out of reach for that source. Arbitrary contradictory additions, paraphrases
70
+ in its candidate set, which the host selects by what the source mentions, then caps by recency.
71
+ Records the source never mentions are out of reach for that source, and so are mentioned ones
72
+ older than the 32 most recent that qualify. Arbitrary contradictory additions, paraphrases
72
73
  and cross-origin identities are not automatically resolved. Multiple source events are
73
74
  not treated as independent corroboration; repeated summaries may share the same root
74
75
  observation. There is no reinforcement count or model-generated confidence score.
@@ -82,18 +83,38 @@ refresh is required. All times are numeric and negative ages clamp to zero.
82
83
  freshness = floor + (1 - floor) * 2 ** (-ageDays / halfLifeDays)
83
84
  ```
84
85
 
85
- | Kind | Half-life of the decaying portion | Floor | Hard recall expiry |
86
+ | Kind | Half-life of the decaying portion | Floor | Dormant after |
86
87
  |---|---:|---:|---|
87
- | project_state | 3 days | 0.50 | after 7 days |
88
- | fact | 90 days | 0.75 | none |
89
- | decision | 180 days | 0.85 | none |
90
- | preference | 365 days | 0.95 | none |
91
-
92
- Pinning sets freshness to 1 and exempts age expiry, but adds no credibility, cannot
93
- revive a conflict, and cannot bypass relevance. Facts/decisions/preferences never vanish
94
- just because they are old. The project-state cap remains unchanged so upgrading does not
95
- revive already expired project claims. This first step classifies by memory kind, not
96
- semantic subtypes such as completed/blocked tasks or volatile configuration facts.
88
+ | project_state | 3 days | 0.50 | 7 days |
89
+ | fact | 60 days | 0.50 | 180 days |
90
+ | decision | 120 days | 0.60 | 365 days |
91
+ | preference | 240 days | 0.70 | 730 days |
92
+
93
+ Age is not evidence of falsity, so time never deletes anything: only contradicting evidence
94
+ does, through `replaces`. **Dormant means no longer offered, not gone.** A dormant record is
95
+ still stored, still returned by an explicit recall that asks for it, and still shown to the
96
+ model as a replacement candidate which is the channel that revives it, with no human step.
97
+
98
+ Decay and dormancy run from the last time a record was **confirmed**, not the last time it was
99
+ edited. Two signals already present in a learning call supply that, so neither costs a request:
100
+ the model re-deriving content this origin already holds, and a record the host measured the
101
+ source to mention being left standing beside it. The second means *not disputed by evidence
102
+ that mentioned it* — never *verified* — so it feeds the clock and nothing else, and it is not
103
+ counted for `project_state`, whose states go stale silently, nor for `progress` sources, whose
104
+ candidates are host-nominated rather than measured. Confirmation never moves `updatedAt`: that
105
+ is the replacement authority gate, and moving it would make a refreshed record refuse a
106
+ legitimately older queued source. A known residual: a weak model that fails to supersede a
107
+ false record keeps its clock alive by leaving it standing. That is the existing supersession
108
+ failure, not a new one.
109
+
110
+ The floors used to be high because nothing could refresh a record, which left decay nearly
111
+ inert — a preference could lose at most 5 percentage points in its entire life. Reinforcement
112
+ is that missing mechanism, so the floors come down and freshness does real ranking work.
113
+
114
+ Pinning sets freshness to 1 and exempts a record from dormancy, but adds no credibility, cannot
115
+ revive a conflict, and cannot bypass relevance. The project-state horizon is unchanged, so
116
+ upgrading does not revive already dormant project claims. This first step classifies by memory
117
+ kind, not semantic subtypes such as completed/blocked tasks or volatile configuration facts.
97
118
 
98
119
  The existing lexical score and subject/literal/coverage/relative-cutoff gates run first.
99
120
  The 75% relative cutoff is based on **raw relevance**, not quality-adjusted scores.
package/docs/design.md CHANGED
@@ -112,11 +112,20 @@ are clipped with an ellipsis/hash suffix. Origins are provenance hints, not evid
112
112
  that another project's fact applies here. Identical content is deduplicated only within
113
113
  one origin: equal port/path text from different contexts can mean different facts.
114
114
 
115
- Forgotten/conflicted claims never recall. Unpinned project-state claims expire from recall
116
- after 7 days; facts/preferences/decisions have no automatic age deletion. All kinds have
117
- bounded gradual freshness decay, with separate half-lives/floors and pin exemption.
115
+ Forgotten/conflicted claims never recall. Every unpinned kind becomes **dormant** past its own
116
+ horizon 7 days for project state, 180 for facts, 365 for decisions, 730 for preferences which
117
+ stops it being offered for injection without deleting it: it stays stored, stays recallable on
118
+ request, and stays a replacement candidate, so later evidence can revive or retire it with no
119
+ human step. No kind has automatic age deletion. All kinds have bounded gradual freshness decay,
120
+ with separate half-lives/floors and pin exemption. Decay and dormancy run from the last
121
+ confirmation rather than the last edit; `updatedAt`, the replacement authority gate, never moves
122
+ for a confirmation.
118
123
  Pin/unpin, legacy annotation, explicit feedback and conflict resolution preserve the evidence
119
- date, and undo restores the prior date. Event history separately records when an operation occurred.
124
+ date, and undo restores the prior date. Undo also ignores the confirmation stamp when deciding
125
+ whether a record changed, and carries it forward rather than reverting it: confirmation writes no
126
+ event, so an event snapshot can never carry a later stamp, and comparing it would make every
127
+ confirmed record permanently un-undoable. Event history separately records when an operation
128
+ occurred.
120
129
 
121
130
  See [core-quality.md](core-quality.md) for the evidence contract, exact ranking policy,
122
131
  weaker-replacement guard, replay-safe feedback and read-only mid-task `memory_recall` tool.
@@ -178,6 +187,10 @@ Jaccard, because a source is orders of magnitude longer than a claim. A progress
178
187
  uses exactly the records nominated in `targets`.
179
188
 
180
189
  This is a **filter, never a ranking**, and the qualifying records keep the original recency order.
190
+ The cap applies **after** the filter, not before: capping by recency first meant a scope holding more
191
+ than 32 records could never show an older one again, however squarely the source was about it, so it
192
+ could never be superseded — only accumulated alongside. Reach is still bounded by the 32 most recent
193
+ qualifying records, and ordering stays by update time, never by recency of confirmation.
181
194
  Containment is highest for a record the source merely restates and lower for the one it
182
195
  contradicts, because the changed value is exactly the term that is missing; ordering by it and
183
196
  cutting to a small cap would drop the record that most needed superseding, and both versions
package/docs/usage.md CHANGED
@@ -111,7 +111,9 @@ rename the tool to hide the conflict; an old installation would still run its ho
111
111
  - Each processing attempt makes at most one background model call, using up to 32
112
112
  recently updated active memories from that source's capture origin **that the source
113
113
  actually mentions** — the host filters the rest out, so a source cannot replace a
114
- record it never talks about. This is a
114
+ record it never talks about. The filter runs **before** the cap, so an older record the
115
+ source is squarely about is no longer hidden behind newer unrelated ones; reach is still
116
+ bounded by the 32 most recent records that qualify. This is a
115
117
  conservative automatic-replacement safeguard, **not a recall restriction**.
116
118
  It defaults to **the current Pi session model and Pi's own provider/auth resolution**.
117
119
  With no session override, this is Pi's configured default. Quota/rate limits or repeated
@@ -213,9 +215,16 @@ history, correction, pinning and undo rather than treating generated claims as v
213
215
  is withheld and its new variant quarantined, with history. Current user corrections
214
216
  and new tool-backed project progress can still update automatically; pins remain protected.
215
217
  - Freshness decreases smoothly by type: project state fastest, then facts, decisions,
216
- preferences. Stable kinds retain a nonzero floor and do not expire. Project states keep
217
- the seven-day safety cap; no upgrade revives old states. Read/search/injection, pinning,
218
- alias enrichment, feedback and conflict resolution do not reset the evidence clock.
218
+ preferences. Every kind retains a nonzero floor, and past its own horizon becomes
219
+ **dormant** no longer injected automatically, but still stored, still returned by the
220
+ `memory_recall` tool and by `/memory search`, and still offered to learning, so later evidence
221
+ revives or retires it without you doing anything. Dormancy governs what is pushed into a
222
+ session unprompted; it never makes a record unfindable for someone looking for it.
223
+ Project states keep the seven-day cap; no upgrade revives old states. Read/search/injection,
224
+ pinning, alias enrichment, feedback and conflict resolution never move the evidence date,
225
+ and nothing does except a replacement. The separate decay clock is moved only by a later
226
+ source that mentions a record and either leaves it standing or reaffirms it outright — never
227
+ for project states, whose seven-day cap nothing resets.
219
228
  - Ranking keeps **relevance, evidence, freshness and feedback separate**. Quality cannot
220
229
  rescue an unrelated/weak lexical match. `useful` is not `accurate`; neither is independent
221
230
  verification. Repeated retrieval or repeated positive feedback earns no cumulative boost.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-memory-evolution",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "description": "Memory that maintains itself. Pi learns what matters, injects what this session needs, and recalls the rest — nothing to configure, no commands to learn.",
5
5
  "license": "MIT",
6
6
  "publishConfig": {
package/src/index.ts CHANGED
@@ -195,7 +195,9 @@ export default async function memoryEvolution(pi: ExtensionAPI, dependencies: Me
195
195
  if (lifetime.signal.aborted || signal?.aborted) throw new Error("Memory recall cancelled");
196
196
  try {
197
197
  const query = resolveRecallQuery(redact(params.query));
198
- const result = retrieveMemories(query.query ? getStore().readMemories() : [], query);
198
+ // Asked for explicitly, so dormancy does not apply: it governs what is pushed into a session
199
+ // unprompted, not what can be found. Dormant records come back marked `aging`.
200
+ const result = retrieveMemories(query.query ? getStore().readMemories() : [], query, 3, Date.now(), { includeDormant: true });
199
201
  const text = buildRuntimeDigest(result.selected, query) ?? "No matching recallable memory. This is not proof the subject was never stored; try a specific subject or known alias, not arbitrary recent records.";
200
202
  return { content: [{ type: "text" as const, text }], details: { matches: text.split('\n').filter(line => line.startsWith('{')).length } };
201
203
  } catch { throw new Error("Memory recall failed; inspect /memory status. No memory update was performed."); }
@@ -249,7 +251,9 @@ export default async function memoryEvolution(pi: ExtensionAPI, dependencies: Me
249
251
  : operation === "list" && id === "here" ? scope : undefined);
250
252
  let pageInfo = "";
251
253
  if (operation === "show") memories = memories.filter((m) => m.id === id);
252
- else if (operation === "search") memories = selectRelevantMemories(memories, recallQuery([id, value].filter(Boolean).join(" ")), 10);
254
+ // An explicit search must find what is stored, including dormant records: dormancy stops a
255
+ // record being offered unprompted, and must never make it unfindable for someone looking.
256
+ else if (operation === "search") memories = selectRelevantMemories(memories, recallQuery([id, value].filter(Boolean).join(" ")), 10, Date.now(), { includeDormant: true });
253
257
  else {
254
258
  const filtered = id === "all" || id === "legacy" || id === "here";
255
259
  const pageText = (filtered ? value : id) || "1";
@@ -16,7 +16,9 @@ export function buildRuntimeDigest(memories: readonly DurableMemory[], prompt: R
16
16
  const text = excerpt(memory.content, prompt, 400);
17
17
  const line = JSON.stringify({ id: label(memory.id), kind: memory.kind, status: memory.status,
18
18
  origin: label(memory.scope), source: label(memory.evidence?.sourceId ?? memory.sourceEntryId), updated: memory.updatedAt.slice(0,10),
19
- evidence: `${quality.basis}/${quality.method}`, aging: quality.freshness < 0.85,
19
+ // The freshness anchor, shown only when later evidence actually moved it past the edit date.
20
+ ...(quality.confirmedAt.slice(0,10) > memory.updatedAt.slice(0,10) ? { confirmed: quality.confirmedAt.slice(0,10) } : {}),
21
+ evidence: `${quality.basis}/${quality.method}`, aging: quality.aging,
20
22
  ...(memory.feedback?.accuracy ? { assessment: memory.feedback.accuracy.verdict } : {}), text }) + "\n";
21
23
  if (Buffer.byteLength(digest + line) <= MAX_BYTES) digest += line;
22
24
  }
@@ -35,9 +35,21 @@ export const MAX_SEARCH_TERM_CHARS = 64;
35
35
  * source merely restates and lower for the one it contradicts — the changed value is exactly
36
36
  * the term that is missing — so ordering by it drops the record that most needs superseding.
37
37
  * IDF weighting makes that worse, not better: the missing term is the rare one. Qualifying
38
- * records therefore keep the original recency order, and the cap only bounds the payload. */
38
+ * records therefore keep the original recency order, and the cap below takes the most recent. */
39
39
  export const RELATED_CONTAINMENT = 0.4;
40
- /** Unchanged from the recency-only selection this filter narrows, so nothing once shown is cut. */
40
+ /** How many qualifying records may be sent. Applied AFTER the containment filter, never before:
41
+ * capping by recency first meant a scope with more than 32 records could never show an older one
42
+ * again, however squarely the source was about it, so it could never be superseded — only
43
+ * accumulated alongside. Measured on a live 89-record scope, 46 relevant records were unreachable
44
+ * that way, including the exact record a user correction was aimed at (rank 63, top containment).
45
+ *
46
+ * Nothing once shown is cut: a record inside the recency top-32 overall is necessarily among the
47
+ * 32 most recent qualifying records, so the old selection is a subset of this one.
48
+ *
49
+ * Residual, deliberately accepted: reach is still bounded by "the 32 most recent that qualify".
50
+ * Containment saturates on long summary sources (38 of 89 records scored 1.00), so in a busy scope
51
+ * the oldest still do not re-enter. Ordering stays by `updatedAt` and must not become recency of
52
+ * confirmation, or records would be shown because they were recently shown. */
41
53
  export const MAX_CANDIDATES = 32;
42
54
 
43
55
  /** What a reply may cost us, derived from the contract above rather than invented. These are
@@ -37,6 +37,11 @@ export interface DurableMemory {
37
37
  /** Exact superseded content, carried across explicit legacy adoption. */
38
38
  suppressedHashes?: string[];
39
39
  searchTerms?: string[];
40
+ /** When this record was last confirmed by later evidence, as distinct from last changed. Decay
41
+ * and dormancy run from it; `updatedAt` does not move, because it is the replacement authority
42
+ * gate and moving it would make a refreshed record refuse a legitimately older queued source.
43
+ * Missing on records nothing has reconfirmed, which simply means decay runs from `updatedAt`. */
44
+ reinforcedAt?: string;
40
45
  /** Host-assigned provenance; missing on old records means unknown, never verified. */
41
46
  evidence?: Evidence;
42
47
  feedback?: MemoryFeedback;
@@ -245,6 +250,25 @@ export class MemoryStore {
245
250
  private generation(scope: string): number {
246
251
  return Number(this.db.prepare("SELECT COALESCE(MAX(rowid),0) AS n FROM events WHERE scope=?").get(scope)!.n);
247
252
  }
253
+ /** Confirmation is not a change: no content, evidence, status or `updatedAt` moves, so it writes
254
+ * no event and creates no undo point - there is nothing to undo about having been mentioned. Only
255
+ * `reinforcedAt` moves, and only forward, so replay or an out-of-order source cannot roll it back.
256
+ * Pinned records are skipped because their freshness is already fixed at 1. */
257
+ private reinforce(ids: Iterable<string>, at: string): void {
258
+ const stamp = Date.parse(at);
259
+ if (!Number.isFinite(stamp)) return;
260
+ let touched = false;
261
+ for (const id of ids) {
262
+ const memory = this.get(id);
263
+ if (!memory || !active(memory) || memory.layer === "pinned") continue;
264
+ if (stamp <= Math.max(Date.parse(memory.updatedAt), Date.parse(memory.reinforcedAt ?? "") || 0)) continue;
265
+ const next = { ...memory, reinforcedAt: new Date(stamp).toISOString() };
266
+ if (!isMemory(next)) throw new Error("Invalid memory update");
267
+ this.db.prepare("UPDATE memories SET data=? WHERE id=?").run(JSON.stringify(next), id);
268
+ touched = true;
269
+ }
270
+ if (touched) this.cache.clear();
271
+ }
248
272
  private record(actor: string, reason: string, after: DurableMemory[], scope: string): string {
249
273
  const before = after.map((m) => this.get(m.id) ?? null);
250
274
  const at = new Date().toISOString();
@@ -314,6 +338,39 @@ export class MemoryStore {
314
338
  .get(...(scope === undefined ? [] : [scope]), now);
315
339
  return row ? String(row.id) : undefined;
316
340
  }
341
+ /** The single definition of what a source may reason about locally and what it may be shown.
342
+ * The reservation estimate and the run itself must call this same function: an estimate cheaper
343
+ * than the payload it authorizes is how a call gets admitted that the provider then refuses.
344
+ *
345
+ * A progress source arrives with its targets already nominated, so those are its candidates.
346
+ * For everything else the host drops records this source never mentions: it cannot supersede
347
+ * a fact it does not talk about, and retrieval is the host's job — deterministic and free —
348
+ * not something to pay a model to do by handing it every recent record to search through.
349
+ *
350
+ * `memories` is NOT read-only: `finishEvolution` writes through it, replacing `searchTerms`
351
+ * wholesale on an exact-content match and refreshing a duplicate's evidence. Those writes were
352
+ * always bounded by the recency window, and must stay bounded, or model output would rewrite
353
+ * records the model was never shown — losing aliases it could not have preserved and resetting
354
+ * the aging clock on records it never named. So `memories` is the recency window plus whatever
355
+ * was actually shown, and nothing else: `candidates` stays a subset, and every record the host
356
+ * may write through is one that was either recent or in front of the model. */
357
+ private selectCandidates(source: Source): { memories: DurableMemory[]; candidates: DurableMemory[] } {
358
+ const scoped = this.readMemories(source.scope).filter((m) => m.scope === source.scope && active(m)
359
+ && (source.kind !== "progress" || (m.kind === "project_state" && source.targets!.includes(m.id))))
360
+ .sort((a,b) => Date.parse(b.updatedAt)-Date.parse(a.updatedAt));
361
+ // Order is left alone deliberately. Containment filters; it must never rank. See limits.ts.
362
+ // The cap is applied AFTER the filter. Capping first hid every matching record that had aged
363
+ // past the 32 most recent, so in any scope with more than 32 records an older one could never
364
+ // be shown again, and therefore never superseded — only accumulated alongside.
365
+ const vocabulary = source.kind === "progress" ? undefined : features(source.content);
366
+ const candidates = (vocabulary === undefined ? scoped
367
+ : scoped.filter((m) => mentions(vocabulary, m.content, m.searchTerms) >= RELATED_CONTAINMENT)).slice(0, MAX_CANDIDATES);
368
+ const recent = scoped.slice(0, MAX_CANDIDATES);
369
+ const known = new Set(recent.map((m) => m.id));
370
+ // Older shown records follow the recency window in age order, so this stays recency-ordered.
371
+ return { memories: [...recent, ...candidates.filter((m) => !known.has(m.id))], candidates };
372
+ }
373
+
317
374
  beginEvolution(id: string, retry: RetryMode = false, timeoutMs = EVOLUTION_TIMEOUT_MS, now = Date.now(), model?: string, call?: CallOptions): EvolutionRun | undefined {
318
375
  this.assertLearningReady();
319
376
  return this.transaction(() => {
@@ -324,14 +381,18 @@ export class MemoryStore {
324
381
  this.db.prepare('UPDATE sources SET last_checked=? WHERE id=?').run(now, id);
325
382
  const priorModels = parseModels(row.call_models);
326
383
  const correctOutput = Number(row.corrections) === 0 && Number(row.output_failures) === 1 && ['invalid_output','output_limit'].includes(String(row.last_error));
384
+ const source = parseSource(row.data);
385
+ // Selecting candidates scans the whole scope; this transaction holds the write lock, so it is
386
+ // computed at most once per attempt and only once a route is actually available. The estimate
387
+ // and the run must see the same set anyway — a cheaper estimate authorizes a larger payload.
388
+ let selected: { memories: DurableMemory[]; candidates: DurableMemory[] } | undefined;
389
+ const select = () => (selected ??= this.selectCandidates(source));
327
390
  if (model !== undefined) {
328
391
  model = modelLabel(model);
329
392
  if (retry !== true && !priorModels.includes(model) && priorModels.length >= this.policy.sourceModels) return undefined;
330
393
  const provider = modelLabel(call?.provider ?? model.split('/')[0]);
331
394
  if (retry !== true && routeUntil(this.db, model, provider, now) > now) return undefined;
332
- const source = parseSource(row.data);
333
- const bytes = Buffer.byteLength(JSON.stringify(source)) + this.readMemories(source.scope)
334
- .filter(active).sort((a,b) => Date.parse(b.updatedAt)-Date.parse(a.updatedAt)).slice(0,32)
395
+ const bytes = Buffer.byteLength(JSON.stringify(source)) + select().candidates
335
396
  .reduce((sum,m) => sum + Buffer.byteLength(JSON.stringify(m)), 0);
336
397
  const reserve = estimatedCost(bytes, call);
337
398
  if (budgetUntil(this.db, model, now, this.policy, reserve) > now) return undefined;
@@ -340,19 +401,8 @@ export class MemoryStore {
340
401
  }
341
402
  timeoutMs = Math.min(timeoutMs, this.policy.timeoutMs, retry === true ? timeoutMs : Math.max(1, this.policy.sourceTimeMs - Number(row.call_ms)));
342
403
  this.db.prepare(`UPDATE sources SET state='running', attempt=attempt+1, lease=? WHERE id=?`).run(now + timeoutMs + LEASE_GRACE_MS, id);
343
- const source = parseSource(row.data);
344
404
  if (source.id !== id) throw new Error("Invalid source identity");
345
- const memories = this.readMemories(source.scope).filter((m) => m.scope === source.scope && active(m)
346
- && (source.kind !== "progress" || (m.kind === "project_state" && source.targets!.includes(m.id))))
347
- .sort((a,b) => Date.parse(b.updatedAt)-Date.parse(a.updatedAt)).slice(0, MAX_CANDIDATES);
348
- // A progress source arrives with its targets already nominated, so those are its candidates.
349
- // For everything else the host drops records this source never mentions: it cannot supersede
350
- // a fact it does not talk about, and retrieval is the host's job — deterministic and free —
351
- // not something to pay a model to do by handing it every recent record to search through.
352
- // Order is left alone deliberately. Containment filters; it must never rank. See limits.ts.
353
- const vocabulary = source.kind === "progress" ? undefined : features(source.content);
354
- const candidates = vocabulary === undefined ? memories
355
- : memories.filter((m) => mentions(vocabulary, m.content, m.searchTerms) >= RELATED_CONTAINMENT);
405
+ const { memories, candidates } = select();
356
406
  // The stored diagnostic explains the last completed outcome. Claiming an attempt must not erase it:
357
407
  // a cancelled or interrupted run would otherwise leave a paused source with no recorded reason.
358
408
  return { source, attempt: Number(row.attempt) + 1, generation: this.generation(source.scope), memories, candidates, timeoutMs, correctOutput,
@@ -367,6 +417,8 @@ export class MemoryStore {
367
417
  if (job?.state !== "running" || job.attempt !== run.attempt || this.generation(run.source.scope) !== run.generation) throw new EvolutionError("stale");
368
418
  const after = new Map<string, DurableMemory>();
369
419
  const targets = new Set<string>();
420
+ const confirmed = new Set<string>();
421
+ const reaffirmed = new Set<string>();
370
422
  let weakerConflicts = 0;
371
423
  const incoming = sourceEvidence(run.source, "model");
372
424
  // Two different things used to throw the same bare Error and land on write_rejected, which
@@ -412,6 +464,11 @@ export class MemoryStore {
412
464
  targets.add(old.id);
413
465
  if (claim.kind !== old.kind) broke('replaces_kind', index, 'kind');
414
466
  if (fingerprint(old.content) === fingerprint(claim.content)) {
467
+ // Naming a record and replacing it with itself is an explicit "this still holds", so it
468
+ // must count at least as much as leaving it standing silently. Without this, a summary
469
+ // source that reaffirms a record outright moves nothing while one that says nothing
470
+ // about it confirms it - the stronger signal worth less than the weaker one.
471
+ reaffirmed.add(old.id);
415
472
  if (run.source.kind !== "summary" && mayReplace(old, incoming)) after.set(old.id, { ...old, sourceEntryId: run.source.id,
416
473
  updatedAt: run.source.createdAt, evidence: incoming, status: "provisional", revision: old.revision + 1 });
417
474
  annotate(old, claim); continue;
@@ -447,7 +504,26 @@ export class MemoryStore {
447
504
  else annotate(run.memories.find((m) => m.kind === claim.kind && fingerprint(m.content) === fingerprint(claim.content)), claim);
448
505
  }
449
506
  }
507
+ // Shown, measured to be mentioned by this source, and either left standing or explicitly
508
+ // replaced by identical content: the model saw the record beside fresh evidence about the same
509
+ // terms and did not contradict it. That is "not disputed by evidence that mentioned it", not
510
+ // "verified" - enough to keep a record out of dormancy, never enough to raise its authority.
511
+ //
512
+ // The model producing content the store already holds is deliberately NOT a signal. The whole
513
+ // content of every candidate is in front of it and the prompt asks for aliases on unchanged
514
+ // records, so re-emitting one is the cheapest move available, not independent re-derivation.
515
+ // Progress sources are excluded because their candidates are host-nominated targets, not
516
+ // records measured to be mentioned. Today that guard is subsumed by the project_state rule
517
+ // below - `selectCandidates` only ever nominates project_state for a progress source - so it
518
+ // has no test of its own. It stays because the two rules answer different questions, and
519
+ // dropping it would make project_state's rule silently load-bearing for both.
520
+ // project_state is excluded for the same reason - states go stale silently, and silence is far
521
+ // too weak to keep resetting the one seven-day safety cap that actually does work.
522
+ if (run.source.kind !== "progress") for (const shown of run.candidates)
523
+ if (shown.kind !== "project_state" && (!targets.has(shown.id) || reaffirmed.has(shown.id))) confirmed.add(shown.id);
450
524
  const event = this.record("model", `${model}: ${run.source.id}${weakerConflicts ? `; weaker replacements withheld=${weakerConflicts}` : ""}`, [...after.values()], run.source.scope);
525
+ // After `record`, so a record this batch also changed is confirmed on top of that change.
526
+ this.reinforce(confirmed, run.source.createdAt);
451
527
  this.db.prepare("UPDATE sources SET state='done',lease=0,failures=0,output_failures=0,retry_at=0,failed_at=0,last_error='',diagnostic=? WHERE id=?")
452
528
  .run(JSON.stringify(diagnostic), run.source.id);
453
529
  finishCall(this.db, run.source.id, run.attempt, 'done', Date.now(), '', diagnostic);
@@ -630,10 +706,18 @@ export class MemoryStore {
630
706
  if (!row) throw new Error("Unknown event id");
631
707
  const event = parseEvent(row.data, id, row.scope);
632
708
  if (!event.after.length) throw new Error("Event has no memory changes");
709
+ // `reinforcedAt` is deliberately outside this comparison. Confirmation writes no event, so an
710
+ // event's snapshot can never carry a stamp written after it; comparing it would make every
711
+ // confirmed record permanently un-undoable. It is also not part of what an undo restores -
712
+ // there is nothing to undo about having been mentioned - so the current stamp is carried
713
+ // forward onto the restored record rather than reverted with it.
714
+ const settled = (memory: DurableMemory | undefined) => memory && JSON.stringify({ ...memory, reinforcedAt: undefined });
633
715
  const restored = event.after.map((after, i) => {
634
- if (JSON.stringify(this.get(after.id)) !== JSON.stringify(after)) throw new Error("Memory changed since this event; undo refused");
716
+ const current = this.get(after.id);
717
+ if (settled(current) !== settled(after)) throw new Error("Memory changed since this event; undo refused");
635
718
  this.block(after);
636
719
  return { ...(event.before[i] ?? { ...after, status: "forgotten" as const }), revision: after.revision + 1,
720
+ ...(current?.reinforcedAt ? { reinforcedAt: current.reinforcedAt } : {}),
637
721
  suppressedHashes: [...new Set([...(event.before[i]?.suppressedHashes ?? []), ...(after.suppressedHashes ?? []), fingerprint(after.content)])] };
638
722
  });
639
723
  return this.record("manual", `Undo ${id}`, restored, event.scope);
@@ -711,6 +795,7 @@ function isMemory(value: unknown): value is DurableMemory {
711
795
  && (m.suppressedHashes === undefined || (Array.isArray(m.suppressedHashes) && m.suppressedHashes.every((h) => typeof h === "string" && /^[a-f0-9]{24}$/u.test(h))))
712
796
  && typeof m.createdAt === "string" && typeof m.updatedAt === "string"
713
797
  && Number.isFinite(Date.parse(m.createdAt)) && Number.isFinite(Date.parse(m.updatedAt))
798
+ && (m.reinforcedAt === undefined || (typeof m.reinforcedAt === "string" && Number.isFinite(Date.parse(m.reinforcedAt))))
714
799
  && Number.isInteger(m.revision) && m.revision > 0 && ["durable", "pinned"].includes(m.layer)
715
800
  && ["provisional", "confirmed", "forgotten", "conflicted"].includes(m.status);
716
801
  }
@@ -56,26 +56,47 @@ export function mayReplace(old: DurableMemory, incoming: Evidence): boolean {
56
56
  }
57
57
 
58
58
  const DAY = 86400_000;
59
- /** No new expiry for stable facts/preferences/decisions and no revival of old states.
60
- * Floors preserve old useful knowledge; project states retain their seven-day safety cap. */
61
- export const AGING: Record<MemoryKind, { halfLifeDays: number; floor: number; expiresDays?: number }> = {
62
- project_state: { halfLifeDays: 3, floor: 0.5, expiresDays: 7 },
63
- fact: { halfLifeDays: 90, floor: 0.75 },
64
- decision: { halfLifeDays: 180, floor: 0.85 },
65
- preference: { halfLifeDays: 365, floor: 0.95 },
59
+ /** Age is not evidence of falsity. A preference nobody has restated in a year is almost certainly
60
+ * still true, and a fact nobody has restated in three months might be false — age alone cannot tell
61
+ * them apart, so time never deletes anything here. Only contradicting evidence does, through
62
+ * `replaces`. What time does is stop a record being *offered*: past `dormantDays` it is no longer
63
+ * injected, while staying stored, recallable on request and eligible as a replacement candidate, so
64
+ * a source that mentions it again revives it with no human step. See `reinforcedAt`.
65
+ *
66
+ * The horizons are reasoned, not fitted - the store is far too young to fit them to. A statement
67
+ * about in-flight work is worthless after a week. An environment fact nobody has confirmed in half
68
+ * a year is unreliable. A decision unreferenced for a year has usually been overtaken by unrecorded
69
+ * practice. A preference is the most persistent thing a user tells us, so it gets two years.
70
+ *
71
+ * The floors were high because nothing could refresh a record: they had to protect knowledge that
72
+ * had no other way to stay fresh, which left decay inert (a preference could lose at most 5pp in
73
+ * its whole life). Reinforcement is that other way, so the floors come down and decay does real
74
+ * ranking work again. A floor is what a never-reconfirmed record still counts for at its horizon. */
75
+ export const AGING: Record<MemoryKind, { halfLifeDays: number; floor: number; dormantDays: number }> = {
76
+ project_state: { halfLifeDays: 3, floor: 0.5, dormantDays: 7 },
77
+ fact: { halfLifeDays: 60, floor: 0.5, dormantDays: 180 },
78
+ decision: { halfLifeDays: 120, floor: 0.6, dormantDays: 365 },
79
+ preference: { halfLifeDays: 240, floor: 0.7, dormantDays: 730 },
66
80
  };
67
81
  export function memoryQuality(memory: DurableMemory, now = Date.now()) {
68
82
  const policy = AGING[memory.kind];
69
- const ageDays = Math.max(0, (now - Date.parse(memory.updatedAt)) / DAY);
83
+ // Decay runs from the last time the record was confirmed, not the last time it was edited.
84
+ // `updatedAt` remains the replacement authority gate and is never moved by confirmation.
85
+ const confirmed = Math.max(Date.parse(memory.updatedAt), Date.parse(memory.reinforcedAt ?? "") || 0);
86
+ const ageDays = Math.max(0, (now - confirmed) / DAY);
70
87
  const pinned = memory.layer === "pinned";
71
88
  const freshness = pinned ? 1 : policy.floor + (1 - policy.floor) * 2 ** (-ageDays / policy.halfLifeDays);
72
- const expired = !pinned && policy.expiresDays !== undefined && ageDays > policy.expiresDays;
89
+ const dormant = !pinned && ageDays > policy.dormantDays;
90
+ // Shown to the model as a caution once a record is halfway to dormancy, relative to its own
91
+ // horizon rather than a fixed freshness number, which the floors would make meaningless.
92
+ const aging = !pinned && ageDays > policy.dormantDays / 2;
73
93
  const basis = memory.evidence?.basis ?? "unknown";
74
94
  const priority = evidencePriority(memory.kind, memory.evidence?.basis);
75
95
  const evidenceWeight = 1 + priority * 0.04;
76
96
  // Last explicit verdict wins, not frequency. Usefulness never increases evidence priority.
77
97
  const utility = memory.feedback?.utility?.verdict === "useful" ? 1.05 : memory.feedback?.utility?.verdict === "unhelpful" ? 0.9 : 1;
78
98
  const accuracy = memory.feedback?.accuracy?.verdict === "accurate" ? 1.05 : memory.feedback?.accuracy?.verdict === "incorrect" ? 0.5 : 1;
79
- return { basis, method: memory.evidence?.method ?? "unknown", ageDays, freshness, expired,
99
+ return { basis, method: memory.evidence?.method ?? "unknown", ageDays, freshness, dormant, aging,
100
+ confirmedAt: new Date(confirmed).toISOString(),
80
101
  evidenceWeight, utility, accuracy, factor: freshness * evidenceWeight * utility * accuracy };
81
102
  }
@@ -12,7 +12,7 @@ function overlap(text: string, query: Set<string>): number {
12
12
 
13
13
  /** Relevance scores are NOT confidence/truth scores. No authority bonus for cwd,
14
14
  * legacy labels, source IDs or dates. Metadata can only help an explicit origin query. */
15
- type RecallOptions = { includeExpiredProjectState?: boolean };
15
+ type RecallOptions = { includeDormant?: boolean };
16
16
  type RankedMemory = { memory: DurableMemory; score: number; rankScore: number; quality: ReturnType<typeof memoryQuality>; coverage: number; matches: string[]; reason?: string };
17
17
  export interface RecallDiagnostics {
18
18
  mode: string;
@@ -34,7 +34,7 @@ function evaluate(memories: readonly DurableMemory[], prompt: RecallInput, now:
34
34
  const qualities = new Map(memories.map(m => [m.id, memoryQuality(m, now)]));
35
35
  const excludedReason = (m: DurableMemory) => ["forgotten", "conflicted"].includes(m.status) ? m.status
36
36
  : m.feedback?.accuracy?.verdict === "incorrect" ? "disputed"
37
- : !options.includeExpiredProjectState && qualities.get(m.id)!.expired ? "expired-project-state" : undefined;
37
+ : !options.includeDormant && qualities.get(m.id)!.dormant ? "dormant" : undefined;
38
38
  const active = memories.filter(m => !excludedReason(m));
39
39
  const diagnostics: RecallDiagnostics = { mode: plan.mode, query: [...query].slice(0, 32), context: [...context].slice(0, 32),
40
40
  eligible: active.length, excluded: memories.length - active.length, matched: 0, selected: [], candidates: [],