pi-memory-evolution 0.3.2 → 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 +7 -0
- package/docs/core-quality.md +31 -11
- package/docs/design.md +13 -4
- package/docs/usage.md +10 -3
- package/package.json +1 -1
- package/src/index.ts +6 -2
- package/src/injector/digest.ts +3 -1
- package/src/memory/memory-store.ts +60 -1
- package/src/memory/quality.ts +31 -10
- package/src/memory/retriever.ts +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,13 @@
|
|
|
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
|
+
|
|
5
12
|
## [0.3.2](https://github.com/btnalit/pi-memory-evolution/compare/v0.3.1...v0.3.2) (2026-09-10)
|
|
6
13
|
|
|
7
14
|
|
package/docs/core-quality.md
CHANGED
|
@@ -83,18 +83,38 @@ refresh is required. All times are numeric and negative ages clamp to zero.
|
|
|
83
83
|
freshness = floor + (1 - floor) * 2 ** (-ageDays / halfLifeDays)
|
|
84
84
|
```
|
|
85
85
|
|
|
86
|
-
| Kind | Half-life of the decaying portion | Floor |
|
|
86
|
+
| Kind | Half-life of the decaying portion | Floor | Dormant after |
|
|
87
87
|
|---|---:|---:|---|
|
|
88
|
-
| project_state | 3 days | 0.50 |
|
|
89
|
-
| fact |
|
|
90
|
-
| decision |
|
|
91
|
-
| preference |
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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.
|
|
98
118
|
|
|
99
119
|
The existing lexical score and subject/literal/coverage/relative-cutoff gates run first.
|
|
100
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.
|
|
116
|
-
|
|
117
|
-
|
|
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.
|
|
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.
|
package/docs/usage.md
CHANGED
|
@@ -215,9 +215,16 @@ history, correction, pinning and undo rather than treating generated claims as v
|
|
|
215
215
|
is withheld and its new variant quarantined, with history. Current user corrections
|
|
216
216
|
and new tool-backed project progress can still update automatically; pins remain protected.
|
|
217
217
|
- Freshness decreases smoothly by type: project state fastest, then facts, decisions,
|
|
218
|
-
preferences.
|
|
219
|
-
|
|
220
|
-
|
|
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.
|
|
221
228
|
- Ranking keeps **relevance, evidence, freshness and feedback separate**. Quality cannot
|
|
222
229
|
rescue an unrelated/weak lexical match. `useful` is not `accurate`; neither is independent
|
|
223
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
|
+
"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
|
-
|
|
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
|
-
|
|
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";
|
package/src/injector/digest.ts
CHANGED
|
@@ -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
|
|
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
|
}
|
|
@@ -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();
|
|
@@ -393,6 +417,8 @@ export class MemoryStore {
|
|
|
393
417
|
if (job?.state !== "running" || job.attempt !== run.attempt || this.generation(run.source.scope) !== run.generation) throw new EvolutionError("stale");
|
|
394
418
|
const after = new Map<string, DurableMemory>();
|
|
395
419
|
const targets = new Set<string>();
|
|
420
|
+
const confirmed = new Set<string>();
|
|
421
|
+
const reaffirmed = new Set<string>();
|
|
396
422
|
let weakerConflicts = 0;
|
|
397
423
|
const incoming = sourceEvidence(run.source, "model");
|
|
398
424
|
// Two different things used to throw the same bare Error and land on write_rejected, which
|
|
@@ -438,6 +464,11 @@ export class MemoryStore {
|
|
|
438
464
|
targets.add(old.id);
|
|
439
465
|
if (claim.kind !== old.kind) broke('replaces_kind', index, 'kind');
|
|
440
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);
|
|
441
472
|
if (run.source.kind !== "summary" && mayReplace(old, incoming)) after.set(old.id, { ...old, sourceEntryId: run.source.id,
|
|
442
473
|
updatedAt: run.source.createdAt, evidence: incoming, status: "provisional", revision: old.revision + 1 });
|
|
443
474
|
annotate(old, claim); continue;
|
|
@@ -473,7 +504,26 @@ export class MemoryStore {
|
|
|
473
504
|
else annotate(run.memories.find((m) => m.kind === claim.kind && fingerprint(m.content) === fingerprint(claim.content)), claim);
|
|
474
505
|
}
|
|
475
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);
|
|
476
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);
|
|
477
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=?")
|
|
478
528
|
.run(JSON.stringify(diagnostic), run.source.id);
|
|
479
529
|
finishCall(this.db, run.source.id, run.attempt, 'done', Date.now(), '', diagnostic);
|
|
@@ -656,10 +706,18 @@ export class MemoryStore {
|
|
|
656
706
|
if (!row) throw new Error("Unknown event id");
|
|
657
707
|
const event = parseEvent(row.data, id, row.scope);
|
|
658
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 });
|
|
659
715
|
const restored = event.after.map((after, i) => {
|
|
660
|
-
|
|
716
|
+
const current = this.get(after.id);
|
|
717
|
+
if (settled(current) !== settled(after)) throw new Error("Memory changed since this event; undo refused");
|
|
661
718
|
this.block(after);
|
|
662
719
|
return { ...(event.before[i] ?? { ...after, status: "forgotten" as const }), revision: after.revision + 1,
|
|
720
|
+
...(current?.reinforcedAt ? { reinforcedAt: current.reinforcedAt } : {}),
|
|
663
721
|
suppressedHashes: [...new Set([...(event.before[i]?.suppressedHashes ?? []), ...(after.suppressedHashes ?? []), fingerprint(after.content)])] };
|
|
664
722
|
});
|
|
665
723
|
return this.record("manual", `Undo ${id}`, restored, event.scope);
|
|
@@ -737,6 +795,7 @@ function isMemory(value: unknown): value is DurableMemory {
|
|
|
737
795
|
&& (m.suppressedHashes === undefined || (Array.isArray(m.suppressedHashes) && m.suppressedHashes.every((h) => typeof h === "string" && /^[a-f0-9]{24}$/u.test(h))))
|
|
738
796
|
&& typeof m.createdAt === "string" && typeof m.updatedAt === "string"
|
|
739
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))))
|
|
740
799
|
&& Number.isInteger(m.revision) && m.revision > 0 && ["durable", "pinned"].includes(m.layer)
|
|
741
800
|
&& ["provisional", "confirmed", "forgotten", "conflicted"].includes(m.status);
|
|
742
801
|
}
|
package/src/memory/quality.ts
CHANGED
|
@@ -56,26 +56,47 @@ export function mayReplace(old: DurableMemory, incoming: Evidence): boolean {
|
|
|
56
56
|
}
|
|
57
57
|
|
|
58
58
|
const DAY = 86400_000;
|
|
59
|
-
/**
|
|
60
|
-
*
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|
}
|
package/src/memory/retriever.ts
CHANGED
|
@@ -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 = {
|
|
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.
|
|
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: [],
|