pi-memory-evolution 0.3.0 → 0.3.1

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,13 @@
2
2
 
3
3
  All notable changes to pi-memory-evolution are documented here.
4
4
 
5
+ ## [0.3.1](https://github.com/btnalit/pi-memory-evolution/compare/v0.3.0...v0.3.1) (2026-09-10)
6
+
7
+
8
+ ### Bug Fixes
9
+
10
+ * 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))
11
+
5
12
  ## [0.3.0](https://github.com/btnalit/pi-memory-evolution/compare/v0.2.7...v0.3.0) (2026-09-10)
6
13
 
7
14
 
package/docs/design.md CHANGED
@@ -272,8 +272,11 @@ cooldowns never contaminate a source's retry_at. Generic runtime backoff is 1 mi
272
272
  5 minutes, 15 minutes, then 1 hour with up to 20% jitter; quota/auth/rate-limit errors can
273
273
  immediately use an allowed other provider. Two recent transport failures pause a model.
274
274
  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.
275
+ and 20 reservations/hour shared across providers and processes. A write refused on the store's own authority — pinned,
276
+ cross-origin, or a record already newer than the source pauses immediately, because the same
277
+ evidence would be refused again. A model that breaks the output contract is not the same thing:
278
+ it is reported as `invalid_output` carrying the rule it broke, and is corrected and retried.
279
+ Three output failures or five generic failures also pause work.
277
280
  Shutdown adds no failures, but an already reserved request may still cost money.
278
281
  `/memory evolve` overrides source delay/caps for one attempt, never shared ceilings.
279
282
  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
@@ -139,7 +139,9 @@ rename the tool to hide the conflict; an old installation would still run its ho
139
139
  backoff is 1 minute, 5 minutes, 15 minutes, then 1 hour, plus up to 20% jitter. Defaults
140
140
  cap each source at **4 reserved calls / 2 models / 300 seconds**, and all models and
141
141
  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`
142
+ failures also pause work; a write refused on the store's own authority (pinned, cross-origin,
143
+ or a record already newer than the source) pauses immediately, while a model that breaks the
144
+ output contract is corrected and retried like any other invalid output. `/memory status`
143
145
  explains routes, budgets and actual outcomes. `/memory evolve` overrides source limits
144
146
  for one attempt, never shared ceilings or settled jobs. [Full policy/configuration](recovery.md).
145
147
  - 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.1",
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. */
@@ -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';
@@ -369,9 +369,25 @@ export class MemoryStore {
369
369
  const targets = new Set<string>();
370
370
  let weakerConflicts = 0;
371
371
  const incoming = sourceEvidence(run.source, "model");
372
+ // Two different things used to throw the same bare Error and land on write_rejected, which
373
+ // pauses a source for good and never even tells the model what it got wrong. They are not the
374
+ // same: a broken output contract is the model's mistake, correctable and worth another model;
375
+ // a refusal grounded in the store's own authority is not, because the same evidence will be
376
+ // refused again. Only the first becomes invalid_output. The second keeps write_rejected below.
377
+ const broke: (reason: DiagnosticReason, index: number, field?: string) => never = (reason, index, field) => {
378
+ // The diagnostic field path only admits the contract's own indices; anything else stays 'result'.
379
+ const at = index <= MAX_CLAIMS - 1 ? `memories[${index}]${field ? `.${field}` : ''}` : 'result';
380
+ throw new EvolutionError('invalid_output', { ...diagnostic, reason, field: at });
381
+ };
372
382
  const stage = (memory: DurableMemory) => {
373
383
  if (![...after.values()].some((m) => active(m) && fingerprint(m.content) === fingerprint(memory.content))) after.set(memory.id, memory);
374
384
  };
385
+ // `claim()` below throws when the model's own output still redacts to a placeholder, meaning
386
+ // it echoed something credential-shaped that the source-side redaction did not catch. Do NOT
387
+ // wrap that in a correctable error: retrying resends the same unredacted source to another
388
+ // call and, since invalid_output is sibling-eligible, to another provider. One exposure then
389
+ // a stop is the cheap outcome; re-sending a secret to a second vendor is not. It stays a bare
390
+ // Error, and therefore write_rejected, deliberately.
375
391
  const annotate = (memory: DurableMemory | undefined, claim: Claim) => {
376
392
  if (!memory || !claim.searchTerms || memory.layer === "pinned") return;
377
393
  const current = after.get(memory.id) ?? memory;
@@ -379,17 +395,22 @@ export class MemoryStore {
379
395
  if (active(current) && JSON.stringify(current.searchTerms) !== JSON.stringify(searchTerms))
380
396
  after.set(memory.id, { ...current, searchTerms, revision: memory.revision + 1 });
381
397
  };
382
- for (const claim of claims) {
383
- if (!validSearchTerms(claim.searchTerms)) throw new Error("Invalid search terms");
398
+ for (const [index, claim] of claims.entries()) {
399
+ if (!validSearchTerms(claim.searchTerms)) broke('invalid_aliases', index, 'searchTerms');
384
400
  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");
401
+ broke('progress_contract', index);
386
402
  if (claim.replaces) {
387
403
  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")
404
+ // The model is shown exactly the records it may name, so naming another is its own error.
405
+ if (!old) broke('unknown_replaces', index, 'replaces');
406
+ if (targets.has(old.id)) broke('duplicate_replaces', index, 'replaces');
407
+ if (run.source.kind === "progress" && old.kind !== "project_state") broke('replaces_kind', index, 'replaces');
408
+ // Authority, not shape: a pinned record, another origin's record, or one already newer
409
+ // than this source will refuse the same evidence however many times it is offered.
410
+ if (old.scope !== run.source.scope || old.layer === "pinned"
390
411
  || Date.parse(old.updatedAt) > Date.parse(run.source.createdAt)) throw new Error("Invalid replacement target");
391
412
  targets.add(old.id);
392
- if (claim.kind !== old.kind) throw new Error("Replacement cannot change evidence kind");
413
+ if (claim.kind !== old.kind) broke('replaces_kind', index, 'kind');
393
414
  if (fingerprint(old.content) === fingerprint(claim.content)) {
394
415
  if (run.source.kind !== "summary" && mayReplace(old, incoming)) after.set(old.id, { ...old, sourceEntryId: run.source.id,
395
416
  updatedAt: run.source.createdAt, evidence: incoming, status: "provisional", revision: old.revision + 1 });
@@ -399,7 +420,7 @@ export class MemoryStore {
399
420
  // Local extraction may already have added the replacement from this source.
400
421
  const existing = run.memories.find((m) => m.id !== old.id && m.kind === claim.kind && fingerprint(m.content) === fingerprint(claim.content));
401
422
  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");
423
+ if (existing && claims.some((c) => c.replaces === existing.id)) broke('cyclic_replaces', index, 'replaces');
403
424
  if (!mayReplace(old, incoming)) {
404
425
  // Quarantine only this source's weaker variant; preserve stronger evidence.
405
426
  const weaker = next ?? (existing?.sourceEntryId === run.source.id ? existing : undefined);