pi-memory-evolution 0.3.1 → 0.3.2
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 +3 -2
- package/docs/design.md +4 -0
- package/docs/usage.md +3 -1
- package/package.json +1 -1
- package/src/memory/limits.ts +14 -2
- package/src/memory/memory-store.ts +41 -15
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.3.2](https://github.com/btnalit/pi-memory-evolution/compare/v0.3.1...v0.3.2) (2026-09-10)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
### Bug Fixes
|
|
9
|
+
|
|
10
|
+
* 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))
|
|
11
|
+
|
|
5
12
|
## [0.3.1](https://github.com/btnalit/pi-memory-evolution/compare/v0.3.0...v0.3.1) (2026-09-10)
|
|
6
13
|
|
|
7
14
|
|
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.
|
package/docs/design.md
CHANGED
|
@@ -178,6 +178,10 @@ Jaccard, because a source is orders of magnitude longer than a claim. A progress
|
|
|
178
178
|
uses exactly the records nominated in `targets`.
|
|
179
179
|
|
|
180
180
|
This is a **filter, never a ranking**, and the qualifying records keep the original recency order.
|
|
181
|
+
The cap applies **after** the filter, not before: capping by recency first meant a scope holding more
|
|
182
|
+
than 32 records could never show an older one again, however squarely the source was about it, so it
|
|
183
|
+
could never be superseded — only accumulated alongside. Reach is still bounded by the 32 most recent
|
|
184
|
+
qualifying records, and ordering stays by update time, never by recency of confirmation.
|
|
181
185
|
Containment is highest for a record the source merely restates and lower for the one it
|
|
182
186
|
contradicts, because the changed value is exactly the term that is missing; ordering by it and
|
|
183
187
|
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
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-memory-evolution",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.2",
|
|
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/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
|
|
@@ -314,6 +314,39 @@ export class MemoryStore {
|
|
|
314
314
|
.get(...(scope === undefined ? [] : [scope]), now);
|
|
315
315
|
return row ? String(row.id) : undefined;
|
|
316
316
|
}
|
|
317
|
+
/** The single definition of what a source may reason about locally and what it may be shown.
|
|
318
|
+
* The reservation estimate and the run itself must call this same function: an estimate cheaper
|
|
319
|
+
* than the payload it authorizes is how a call gets admitted that the provider then refuses.
|
|
320
|
+
*
|
|
321
|
+
* A progress source arrives with its targets already nominated, so those are its candidates.
|
|
322
|
+
* For everything else the host drops records this source never mentions: it cannot supersede
|
|
323
|
+
* a fact it does not talk about, and retrieval is the host's job — deterministic and free —
|
|
324
|
+
* not something to pay a model to do by handing it every recent record to search through.
|
|
325
|
+
*
|
|
326
|
+
* `memories` is NOT read-only: `finishEvolution` writes through it, replacing `searchTerms`
|
|
327
|
+
* wholesale on an exact-content match and refreshing a duplicate's evidence. Those writes were
|
|
328
|
+
* always bounded by the recency window, and must stay bounded, or model output would rewrite
|
|
329
|
+
* records the model was never shown — losing aliases it could not have preserved and resetting
|
|
330
|
+
* the aging clock on records it never named. So `memories` is the recency window plus whatever
|
|
331
|
+
* was actually shown, and nothing else: `candidates` stays a subset, and every record the host
|
|
332
|
+
* may write through is one that was either recent or in front of the model. */
|
|
333
|
+
private selectCandidates(source: Source): { memories: DurableMemory[]; candidates: DurableMemory[] } {
|
|
334
|
+
const scoped = this.readMemories(source.scope).filter((m) => m.scope === source.scope && active(m)
|
|
335
|
+
&& (source.kind !== "progress" || (m.kind === "project_state" && source.targets!.includes(m.id))))
|
|
336
|
+
.sort((a,b) => Date.parse(b.updatedAt)-Date.parse(a.updatedAt));
|
|
337
|
+
// Order is left alone deliberately. Containment filters; it must never rank. See limits.ts.
|
|
338
|
+
// The cap is applied AFTER the filter. Capping first hid every matching record that had aged
|
|
339
|
+
// past the 32 most recent, so in any scope with more than 32 records an older one could never
|
|
340
|
+
// be shown again, and therefore never superseded — only accumulated alongside.
|
|
341
|
+
const vocabulary = source.kind === "progress" ? undefined : features(source.content);
|
|
342
|
+
const candidates = (vocabulary === undefined ? scoped
|
|
343
|
+
: scoped.filter((m) => mentions(vocabulary, m.content, m.searchTerms) >= RELATED_CONTAINMENT)).slice(0, MAX_CANDIDATES);
|
|
344
|
+
const recent = scoped.slice(0, MAX_CANDIDATES);
|
|
345
|
+
const known = new Set(recent.map((m) => m.id));
|
|
346
|
+
// Older shown records follow the recency window in age order, so this stays recency-ordered.
|
|
347
|
+
return { memories: [...recent, ...candidates.filter((m) => !known.has(m.id))], candidates };
|
|
348
|
+
}
|
|
349
|
+
|
|
317
350
|
beginEvolution(id: string, retry: RetryMode = false, timeoutMs = EVOLUTION_TIMEOUT_MS, now = Date.now(), model?: string, call?: CallOptions): EvolutionRun | undefined {
|
|
318
351
|
this.assertLearningReady();
|
|
319
352
|
return this.transaction(() => {
|
|
@@ -324,14 +357,18 @@ export class MemoryStore {
|
|
|
324
357
|
this.db.prepare('UPDATE sources SET last_checked=? WHERE id=?').run(now, id);
|
|
325
358
|
const priorModels = parseModels(row.call_models);
|
|
326
359
|
const correctOutput = Number(row.corrections) === 0 && Number(row.output_failures) === 1 && ['invalid_output','output_limit'].includes(String(row.last_error));
|
|
360
|
+
const source = parseSource(row.data);
|
|
361
|
+
// Selecting candidates scans the whole scope; this transaction holds the write lock, so it is
|
|
362
|
+
// computed at most once per attempt and only once a route is actually available. The estimate
|
|
363
|
+
// and the run must see the same set anyway — a cheaper estimate authorizes a larger payload.
|
|
364
|
+
let selected: { memories: DurableMemory[]; candidates: DurableMemory[] } | undefined;
|
|
365
|
+
const select = () => (selected ??= this.selectCandidates(source));
|
|
327
366
|
if (model !== undefined) {
|
|
328
367
|
model = modelLabel(model);
|
|
329
368
|
if (retry !== true && !priorModels.includes(model) && priorModels.length >= this.policy.sourceModels) return undefined;
|
|
330
369
|
const provider = modelLabel(call?.provider ?? model.split('/')[0]);
|
|
331
370
|
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)
|
|
371
|
+
const bytes = Buffer.byteLength(JSON.stringify(source)) + select().candidates
|
|
335
372
|
.reduce((sum,m) => sum + Buffer.byteLength(JSON.stringify(m)), 0);
|
|
336
373
|
const reserve = estimatedCost(bytes, call);
|
|
337
374
|
if (budgetUntil(this.db, model, now, this.policy, reserve) > now) return undefined;
|
|
@@ -340,19 +377,8 @@ export class MemoryStore {
|
|
|
340
377
|
}
|
|
341
378
|
timeoutMs = Math.min(timeoutMs, this.policy.timeoutMs, retry === true ? timeoutMs : Math.max(1, this.policy.sourceTimeMs - Number(row.call_ms)));
|
|
342
379
|
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
380
|
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);
|
|
381
|
+
const { memories, candidates } = select();
|
|
356
382
|
// The stored diagnostic explains the last completed outcome. Claiming an attempt must not erase it:
|
|
357
383
|
// a cancelled or interrupted run would otherwise leave a paused source with no recorded reason.
|
|
358
384
|
return { source, attempt: Number(row.attempt) + 1, generation: this.generation(source.scope), memories, candidates, timeoutMs, correctOutput,
|