pi-memory-evolution 0.3.0 → 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 CHANGED
@@ -2,6 +2,20 @@
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
+
12
+ ## [0.3.1](https://github.com/btnalit/pi-memory-evolution/compare/v0.3.0...v0.3.1) (2026-09-10)
13
+
14
+
15
+ ### Bug Fixes
16
+
17
+ * let a model correct a broken output contract instead of losing the source ([#24](https://github.com/btnalit/pi-memory-evolution/issues/24)) ([97cde15](https://github.com/btnalit/pi-memory-evolution/commit/97cde1593d466993ec37ca0de81c0fcba09ef2cc))
18
+
5
19
  ## [0.3.0](https://github.com/btnalit/pi-memory-evolution/compare/v0.2.7...v0.3.0) (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.
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
@@ -272,8 +276,11 @@ cooldowns never contaminate a source's retry_at. Generic runtime backoff is 1 mi
272
276
  5 minutes, 15 minutes, then 1 hour with up to 20% jitter; quota/auth/rate-limit errors can
273
277
  immediately use an allowed other provider. Two recent transport failures pause a model.
274
278
  Defaults allow 4 reserved calls / 2 models / 300 seconds per source, one format correction,
275
- and 20 reservations/hour shared across providers and processes. Unsafe writes/refusals
276
- pause immediately. Three output failures or five generic failures also pause work.
279
+ and 20 reservations/hour shared across providers and processes. A write refused on the store's own authority — pinned,
280
+ cross-origin, or a record already newer than the source pauses immediately, because the same
281
+ evidence would be refused again. A model that breaks the output contract is not the same thing:
282
+ it is reported as `invalid_output` carrying the rule it broke, and is corrected and retried.
283
+ Three output failures or five generic failures also pause work.
277
284
  Shutdown adds no failures, but an already reserved request may still cost money.
278
285
  `/memory evolve` overrides source delay/caps for one attempt, never shared ceilings.
279
286
  Completed/retired jobs are never forced to run again. A source resumed in another directory
package/docs/recovery.md CHANGED
@@ -38,12 +38,26 @@ Last-checked ordering moves temporarily unroutable sources behind other eligible
38
38
  | HTTP 429 without a known quota code | Cool down that **model** for at least 60 seconds and at least `Retry-After`, since tpm/rpm ceilings are usually per model; try another provider, or an allowlisted model of the same provider. |
39
39
  | HTTP 408, timeout, network/5xx failure | Delayed retry; two recent transport failures cool down that model for 15 minutes and permit fallback. |
40
40
  | Context overflow or HTTP 400/404/422 | Do not resend unchanged requests to that model immediately; cool it down for 1 hour and permit a compatible backup. |
41
- | Invalid JSON / output truncation | Initial attempt, at most one corrective prompt, then an alternate model. Three output failures pause the source; two failures do not authorize repeatedly probing the same model. |
41
+ | Invalid JSON / output truncation / **broken output contract** | Initial attempt, at most one corrective prompt naming the rule that was broken, then an alternate model. Three output failures pause the source; two failures do not authorize repeatedly probing the same model. |
42
42
  | Unsafe/unauthorized write or recognized safety/refusal | Reject and pause that source. No fallback to circumvent safety or write guards. |
43
43
  | Stale result | Re-read on a bounded delayed retry; not counted as a provider-health failure. |
44
44
  | Cancellation / shutdown / reload | Release the lease without adding a failure; already-reserved requests may still have consumed quota. |
45
45
  | Unknown error | Safe generic category and bounded transport retry; no guessing that arbitrary error prose means insufficient credit. |
46
46
 
47
+ A claim the store refuses is classified by *whose* mistake it is, because the two need opposite
48
+ handling. A **broken output contract** — a progress source answering with a plain addition instead
49
+ of replacing its nominated record, a replacement id that was never shown, the same target replaced
50
+ twice, a replacement that changes evidence kind, a cyclic batch, or aliases that fail validation —
51
+ is the model's error. It is reported as `invalid_output` with a
52
+ `reason` naming the rule, so the correction prompt can cite it and a sibling model may try. A
53
+ **refusal on the store's own authority** — a pinned record, another origin's record, a record
54
+ already newer than the source, or model output that still redacts to a placeholder — is not
55
+ correctable: the same evidence is refused however often it
56
+ is offered, so it stays `write_rejected` and stops the source rather than burning the budget. The
57
+ last of those is a deliberate refusal to retry rather than an inability: a correction would resend
58
+ the same unredacted source to another call and, because `invalid_output` permits cross-provider
59
+ fallback, to another vendor. One exposure and a stop is the cheaper outcome.
60
+
47
61
  Generic source backoff is 1 minute, 5 minutes, 15 minutes, then 1 hour, with up to 20%
48
62
  positive jitter on runtime failures. Route failures can try one alternate immediately;
49
63
  there are at most two immediate attempts per queue item, not a sleep/retry loop. The
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
@@ -139,7 +141,9 @@ rename the tool to hide the conflict; an old installation would still run its ho
139
141
  backoff is 1 minute, 5 minutes, 15 minutes, then 1 hour, plus up to 20% jitter. Defaults
140
142
  cap each source at **4 reserved calls / 2 models / 300 seconds**, and all models and
141
143
  processes share **20 calls per rolling hour**. Three output failures or five generic
142
- failures also pause work; unsafe writes/refusals pause immediately. `/memory status`
144
+ failures also pause work; a write refused on the store's own authority (pinned, cross-origin,
145
+ or a record already newer than the source) pauses immediately, while a model that breaks the
146
+ output contract is corrected and retried like any other invalid output. `/memory status`
143
147
  explains routes, budgets and actual outcomes. `/memory evolve` overrides source limits
144
148
  for one attempt, never shared ceilings or settled jobs. [Full policy/configuration](recovery.md).
145
149
  - A job lease lasts **150 seconds** (120-second deadline plus 30-second grace). The timer
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-memory-evolution",
3
- "version": "0.3.0",
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": {
@@ -4,6 +4,10 @@ export const OUTPUT_PROTOCOL_VERSION = 2;
4
4
  export const DIAGNOSTIC_REASONS = ['empty_text', 'missing_final', 'ambiguous_final', 'unexpected_tool',
5
5
  'json_syntax', 'ambiguous_json', 'output_too_large', 'result_shape', 'too_many_claims', 'claim_shape',
6
6
  'unknown_field', 'invalid_kind', 'content_type', 'content_length', 'invalid_replaces',
7
+ // Output-contract violations the store finds, not the parser. They name the rule the model broke
8
+ // so a correction can say which one, instead of repeating the whole schema back at it.
9
+ 'progress_contract', 'unknown_replaces', 'duplicate_replaces', 'replaces_kind', 'cyclic_replaces',
10
+ 'invalid_aliases',
7
11
  'http_error', 'abnormal_stop', 'request_failed', 'legacy_import_failed'] as const;
8
12
  export type DiagnosticReason = typeof DIAGNOSTIC_REASONS[number];
9
13
  /** Structural metadata only. Never add output snippets, arbitrary keys, headers or exception messages. */
@@ -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
@@ -1,6 +1,6 @@
1
1
  import { openDatabase, type Database } from "./sqlite.ts";
2
2
  import { features, mentions } from "./search.ts";
3
- import { MAX_CANDIDATES, RELATED_CONTAINMENT } from "./limits.ts";
3
+ import { MAX_CANDIDATES, MAX_CLAIMS, RELATED_CONTAINMENT } from "./limits.ts";
4
4
  import { chmodSync, closeSync, lstatSync, mkdirSync, openSync } from "node:fs";
5
5
  import { join, resolve } from "node:path";
6
6
  import { randomUUID } from "node:crypto";
@@ -11,7 +11,7 @@ import { clipBytes, fingerprint, redact } from "./privacy.ts";
11
11
  import { validSearchTerms } from "./search.ts";
12
12
  import { sourceEvidence, validEvidence, validFeedback, mayReplace, FEEDBACK_VERDICTS, type Evidence, type MemoryFeedback, type FeedbackVerdict } from "./quality.ts";
13
13
  import { EVOLUTION_TIMEOUT_MS, LEASE_GRACE_MS, MAX_FAILURES, MAX_OUTPUT_FAILURES, PAUSED_SQL, FAILURE_CODES, EvolutionError, retryAt, type FailureCode } from "./recovery.ts";
14
- import { modelLabel, OUTPUT_PROTOCOL_VERSION, parseDiagnostic, validDiagnostic, type Diagnostic } from './diagnostics.ts';
14
+ import { modelLabel, OUTPUT_PROTOCOL_VERSION, parseDiagnostic, validDiagnostic, type Diagnostic, type DiagnosticReason } from './diagnostics.ts';
15
15
  import { SCHEMA_VERSION, SUPPORTED_SCHEMAS } from './limits.ts';
16
16
  import { budgetUntil, reserveCall, finishCall, takeNotice, routeUntil, estimatedCost, type CallOptions } from './processing-state.ts';
17
17
  import { loadRoutingPolicy, type RoutingPolicy } from './routing-policy.ts';
@@ -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 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)
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 = 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);
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,
@@ -369,9 +395,25 @@ export class MemoryStore {
369
395
  const targets = new Set<string>();
370
396
  let weakerConflicts = 0;
371
397
  const incoming = sourceEvidence(run.source, "model");
398
+ // Two different things used to throw the same bare Error and land on write_rejected, which
399
+ // pauses a source for good and never even tells the model what it got wrong. They are not the
400
+ // same: a broken output contract is the model's mistake, correctable and worth another model;
401
+ // a refusal grounded in the store's own authority is not, because the same evidence will be
402
+ // refused again. Only the first becomes invalid_output. The second keeps write_rejected below.
403
+ const broke: (reason: DiagnosticReason, index: number, field?: string) => never = (reason, index, field) => {
404
+ // The diagnostic field path only admits the contract's own indices; anything else stays 'result'.
405
+ const at = index <= MAX_CLAIMS - 1 ? `memories[${index}]${field ? `.${field}` : ''}` : 'result';
406
+ throw new EvolutionError('invalid_output', { ...diagnostic, reason, field: at });
407
+ };
372
408
  const stage = (memory: DurableMemory) => {
373
409
  if (![...after.values()].some((m) => active(m) && fingerprint(m.content) === fingerprint(memory.content))) after.set(memory.id, memory);
374
410
  };
411
+ // `claim()` below throws when the model's own output still redacts to a placeholder, meaning
412
+ // it echoed something credential-shaped that the source-side redaction did not catch. Do NOT
413
+ // wrap that in a correctable error: retrying resends the same unredacted source to another
414
+ // call and, since invalid_output is sibling-eligible, to another provider. One exposure then
415
+ // a stop is the cheap outcome; re-sending a secret to a second vendor is not. It stays a bare
416
+ // Error, and therefore write_rejected, deliberately.
375
417
  const annotate = (memory: DurableMemory | undefined, claim: Claim) => {
376
418
  if (!memory || !claim.searchTerms || memory.layer === "pinned") return;
377
419
  const current = after.get(memory.id) ?? memory;
@@ -379,17 +421,22 @@ export class MemoryStore {
379
421
  if (active(current) && JSON.stringify(current.searchTerms) !== JSON.stringify(searchTerms))
380
422
  after.set(memory.id, { ...current, searchTerms, revision: memory.revision + 1 });
381
423
  };
382
- for (const claim of claims) {
383
- if (!validSearchTerms(claim.searchTerms)) throw new Error("Invalid search terms");
424
+ for (const [index, claim] of claims.entries()) {
425
+ if (!validSearchTerms(claim.searchTerms)) broke('invalid_aliases', index, 'searchTerms');
384
426
  if (run.source.kind === "progress" && (claim.kind !== "project_state" || !claim.replaces || !run.source.targets!.includes(claim.replaces)))
385
- throw new Error("Progress observations may only update nominated project-state records");
427
+ broke('progress_contract', index);
386
428
  if (claim.replaces) {
387
429
  const old = run.candidates.find((m) => m.id === claim.replaces);
388
- if (!old || old.scope !== run.source.scope || targets.has(old.id) || old.layer === "pinned"
389
- || (run.source.kind === "progress" && old.kind !== "project_state")
430
+ // The model is shown exactly the records it may name, so naming another is its own error.
431
+ if (!old) broke('unknown_replaces', index, 'replaces');
432
+ if (targets.has(old.id)) broke('duplicate_replaces', index, 'replaces');
433
+ if (run.source.kind === "progress" && old.kind !== "project_state") broke('replaces_kind', index, 'replaces');
434
+ // Authority, not shape: a pinned record, another origin's record, or one already newer
435
+ // than this source will refuse the same evidence however many times it is offered.
436
+ if (old.scope !== run.source.scope || old.layer === "pinned"
390
437
  || Date.parse(old.updatedAt) > Date.parse(run.source.createdAt)) throw new Error("Invalid replacement target");
391
438
  targets.add(old.id);
392
- if (claim.kind !== old.kind) throw new Error("Replacement cannot change evidence kind");
439
+ if (claim.kind !== old.kind) broke('replaces_kind', index, 'kind');
393
440
  if (fingerprint(old.content) === fingerprint(claim.content)) {
394
441
  if (run.source.kind !== "summary" && mayReplace(old, incoming)) after.set(old.id, { ...old, sourceEntryId: run.source.id,
395
442
  updatedAt: run.source.createdAt, evidence: incoming, status: "provisional", revision: old.revision + 1 });
@@ -399,7 +446,7 @@ export class MemoryStore {
399
446
  // Local extraction may already have added the replacement from this source.
400
447
  const existing = run.memories.find((m) => m.id !== old.id && m.kind === claim.kind && fingerprint(m.content) === fingerprint(claim.content));
401
448
  if ((!next && !existing) || (existing && !active(after.get(existing.id) ?? existing))) continue;
402
- if (existing && claims.some((c) => c.replaces === existing.id)) throw new Error("Cyclic memory replacement");
449
+ if (existing && claims.some((c) => c.replaces === existing.id)) broke('cyclic_replaces', index, 'replaces');
403
450
  if (!mayReplace(old, incoming)) {
404
451
  // Quarantine only this source's weaker variant; preserve stronger evidence.
405
452
  const weaker = next ?? (existing?.sourceEntryId === run.source.id ? existing : undefined);