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 +14 -0
- package/docs/core-quality.md +34 -13
- package/docs/design.md +17 -4
- package/docs/usage.md +13 -4
- package/package.json +1 -1
- package/src/index.ts +6 -2
- package/src/injector/digest.ts +3 -1
- package/src/memory/limits.ts +14 -2
- package/src/memory/memory-store.ts +101 -16
- package/src/memory/quality.ts +31 -10
- package/src/memory/retriever.ts +2 -2
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
|
|
package/docs/core-quality.md
CHANGED
|
@@ -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
|
|
71
|
-
|
|
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 |
|
|
86
|
+
| Kind | Half-life of the decaying portion | Floor | Dormant after |
|
|
86
87
|
|---|---:|---:|---|
|
|
87
|
-
| project_state | 3 days | 0.50 |
|
|
88
|
-
| fact |
|
|
89
|
-
| decision |
|
|
90
|
-
| preference |
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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.
|
|
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.
|
|
@@ -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.
|
|
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.
|
|
217
|
-
|
|
218
|
-
|
|
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
|
+
"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
|
}
|
package/src/memory/limits.ts
CHANGED
|
@@ -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
|
|
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
|
-
/**
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
}
|
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: [],
|