pi-memory-evolution 0.2.4 → 0.2.6

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.
@@ -2,19 +2,21 @@ import { Database } from "./sqlite.ts";
2
2
  import { chmodSync, closeSync, lstatSync, mkdirSync, openSync } from "node:fs";
3
3
  import { join, resolve } from "node:path";
4
4
  import { randomUUID } from "node:crypto";
5
- import { extractStructuredMemories, type Claim } from "./extractor.ts";
6
- import { loadLegacyImport } from './legacy.ts';
5
+ import { extractStructuredMemories, MAX_CLAIM_CHARS, MIN_CLAIM_CHARS, type Claim } from "./extractor.ts";
6
+ import { loadLegacyImport, emptyLegacyDigest } from './legacy.ts';
7
7
  import { legacyFiles } from './legacy-files.ts';
8
8
  import { clipBytes, fingerprint, redact } from "./privacy.ts";
9
9
  import { validSearchTerms } from "./search.ts";
10
10
  import { sourceEvidence, validEvidence, validFeedback, mayReplace, FEEDBACK_VERDICTS, type Evidence, type MemoryFeedback, type FeedbackVerdict } from "./quality.ts";
11
11
  import { EVOLUTION_TIMEOUT_MS, LEASE_GRACE_MS, MAX_FAILURES, MAX_OUTPUT_FAILURES, PAUSED_SQL, FAILURE_CODES, EvolutionError, retryAt, type FailureCode } from "./recovery.ts";
12
12
  import { modelLabel, OUTPUT_PROTOCOL_VERSION, parseDiagnostic, validDiagnostic, type Diagnostic } from './diagnostics.ts';
13
- import { budgetUntil, reserveCall, finishCall, takeNotice } from './processing-state.ts';
13
+ import { budgetUntil, reserveCall, finishCall, takeNotice, routeUntil, estimatedCost, type CallOptions } from './processing-state.ts';
14
+ import { loadRoutingPolicy, type RoutingPolicy } from './routing-policy.ts';
14
15
 
15
- export type RetryMode = boolean | "auto";
16
- // Rechecked atomically when claiming: selection alone never grants model-call authority.
17
- const automaticEligibility = `((state='pending' AND retry_at<=?) OR (state='failed' AND NOT ${PAUSED_SQL} AND retry_at<=?))`;
16
+ export type RetryMode = boolean | 'auto' | 'fallback';
17
+ const pausedSQL = (p: RoutingPolicy) => `(${PAUSED_SQL} OR calls>=${p.sourceCalls} OR call_ms>=${p.sourceTimeMs})`;
18
+ // Selection and claim both check source budgets; route/global waits never modify source retry_at.
19
+ const automaticEligibility = (p: RoutingPolicy) => `((state='pending' OR state='failed') AND NOT ${pausedSQL(p)} AND retry_at<=?)`;
18
20
 
19
21
  export type MemoryKind = "fact" | "preference" | "decision" | "project_state";
20
22
  export interface DurableMemory {
@@ -53,6 +55,8 @@ export interface EvolutionRun {
53
55
  outputFailures: number;
54
56
  previousError: FailureCode | '';
55
57
  previousDiagnostic: Diagnostic;
58
+ timeoutMs: number;
59
+ correctOutput: boolean;
56
60
  }
57
61
  interface Event {
58
62
  id: string;
@@ -72,8 +76,10 @@ export class MemoryStore {
72
76
  private db: Database;
73
77
  private cache = new Map<string, { version: number; memories: DurableMemory[] }>();
74
78
  readonly stateDir: string;
79
+ readonly policy: RoutingPolicy;
75
80
  constructor(stateDir: string) {
76
81
  this.stateDir = stateDir;
82
+ this.policy = loadRoutingPolicy(stateDir);
77
83
  mkdirSync(stateDir, { recursive: true, mode: 0o700 });
78
84
  const file = join(stateDir, "memory.sqlite");
79
85
  try { const fd = openSync(file, "wx", 0o600); closeSync(fd); }
@@ -85,7 +91,7 @@ export class MemoryStore {
85
91
  this.db.exec("PRAGMA busy_timeout=5000");
86
92
  if (this.db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='metadata'").get()) {
87
93
  const schema = this.db.prepare("SELECT value FROM metadata WHERE key='schema'").get();
88
- if (schema && !["2", "3", "4", "5", "6"].includes(String(schema.value))) throw new Error("Unsupported memory database version");
94
+ if (schema && !["2", "3", "4", "5", "6", "7"].includes(String(schema.value))) throw new Error("Unsupported memory database version");
89
95
  }
90
96
  this.db.exec(`PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL;
91
97
  CREATE TABLE IF NOT EXISTS metadata (key TEXT PRIMARY KEY, value TEXT NOT NULL);
@@ -98,8 +104,8 @@ export class MemoryStore {
98
104
  CREATE TABLE IF NOT EXISTS blocked (scope TEXT NOT NULL, hash TEXT NOT NULL, PRIMARY KEY(scope,hash));`);
99
105
  this.transaction(() => {
100
106
  const schema = this.db.prepare("SELECT value FROM metadata WHERE key='schema'").get();
101
- if (schema && !["2", "3", "4", "5", "6"].includes(String(schema.value))) throw new Error("Unsupported memory database version");
102
- if (!["4", "5", "6"].includes(String(schema?.value))) {
107
+ if (schema && !["2", "3", "4", "5", "6", "7"].includes(String(schema.value))) throw new Error("Unsupported memory database version");
108
+ if (!["4", "5", "6", "7"].includes(String(schema?.value))) {
103
109
  const columns = new Set(this.db.prepare("PRAGMA table_info(sources)").all().map((r) => r.name));
104
110
  for (const [name, type] of [["failures", "INTEGER NOT NULL DEFAULT 0"], ["retry_at", "INTEGER NOT NULL DEFAULT 0"],
105
111
  ["failed_at", "INTEGER NOT NULL DEFAULT 0"], ["last_error", "TEXT NOT NULL DEFAULT ''"]]) {
@@ -109,7 +115,7 @@ export class MemoryStore {
109
115
  this.db.exec("UPDATE sources SET failures=MIN(MAX(attempt,1),5),last_error='unknown' WHERE state='failed' AND failures=0");
110
116
  }
111
117
  const columns = new Set(this.db.prepare('PRAGMA table_info(sources)').all().map(r => r.name));
112
- for (const [name, type] of [['output_failures', 'INTEGER NOT NULL DEFAULT 0'], ['diagnostic', "TEXT NOT NULL DEFAULT '{}'"]]) {
118
+ for (const [name, type] of [['output_failures', 'INTEGER NOT NULL DEFAULT 0'], ['diagnostic', "TEXT NOT NULL DEFAULT '{}'"], ['calls', 'INTEGER NOT NULL DEFAULT 0'], ['call_ms', 'INTEGER NOT NULL DEFAULT 0'], ['call_models', "TEXT NOT NULL DEFAULT '[]'"], ['last_checked', 'INTEGER NOT NULL DEFAULT 0'], ['corrections', 'INTEGER NOT NULL DEFAULT 0']]) {
113
119
  if (!columns.has(name)) this.db.exec(`ALTER TABLE sources ADD COLUMN ${name} ${type}`);
114
120
  }
115
121
  // Historical attempts have no detailed response history. Preserve their budgets, don't invent counts.
@@ -118,6 +124,23 @@ export class MemoryStore {
118
124
  CREATE INDEX IF NOT EXISTS model_calls_window ON model_calls(model,at);
119
125
  CREATE INDEX IF NOT EXISTS model_failures_window ON model_calls(model,finished_at) WHERE outcome='failed';
120
126
  CREATE TABLE IF NOT EXISTS recovery_notices (id TEXT PRIMARY KEY, at INTEGER NOT NULL);`);
127
+ const callColumns = new Set(this.db.prepare('PRAGMA table_info(model_calls)').all().map(r => r.name));
128
+ for (const [name, type] of [['provider', "TEXT NOT NULL DEFAULT ''"], ['code', "TEXT NOT NULL DEFAULT ''"], ['reserved_usd', 'REAL'], ['charged_usd', 'REAL'], ['input_tokens', 'INTEGER'], ['output_tokens', 'INTEGER']]) {
129
+ if (!callColumns.has(name)) this.db.exec(`ALTER TABLE model_calls ADD COLUMN ${name} ${type}`);
130
+ }
131
+ this.db.exec('CREATE TABLE IF NOT EXISTS route_health (id TEXT PRIMARY KEY, until INTEGER NOT NULL, code TEXT NOT NULL)');
132
+ if (schema?.value === '6') {
133
+ // v6 mixed route waits into source backoff. Restore only the known v6 scheduling formula.
134
+ this.db.exec("UPDATE sources SET retry_at=0 WHERE state='pending'");
135
+ for (const row of this.db.prepare("SELECT id,failures,failed_at FROM sources WHERE state='failed' AND failed_at>0").all())
136
+ this.db.prepare('UPDATE sources SET retry_at=MIN(retry_at,?) WHERE id=?').run(retryAt(Number(row.failures), Number(row.failed_at)), row.id);
137
+ for (const row of this.db.prepare('SELECT source_id,COUNT(*) AS n FROM model_calls GROUP BY source_id').all())
138
+ this.db.prepare('UPDATE sources SET calls=? WHERE id=?').run(row.n, row.source_id);
139
+ for (const row of this.db.prepare('SELECT id FROM sources WHERE calls>0').all()) {
140
+ const models = this.db.prepare('SELECT DISTINCT model FROM model_calls WHERE source_id=?').all(row.id).map(r => String(r.model));
141
+ this.db.prepare('UPDATE sources SET call_models=? WHERE id=?').run(JSON.stringify(models), row.id);
142
+ }
143
+ }
121
144
  this.db.exec("CREATE INDEX IF NOT EXISTS sources_recovery ON sources(state,retry_at); CREATE INDEX IF NOT EXISTS sources_running_lease ON sources(lease) WHERE state='running'");
122
145
  this.db.exec("CREATE TABLE IF NOT EXISTS feedback_receipts (source_id TEXT NOT NULL, memory_id TEXT NOT NULL, verdict TEXT NOT NULL, at INTEGER NOT NULL, PRIMARY KEY(source_id,memory_id)); CREATE INDEX IF NOT EXISTS feedback_recent ON feedback_receipts(memory_id,verdict,at)");
123
146
  if (!this.db.prepare("SELECT 1 FROM metadata WHERE key='legacy_import'").get()) {
@@ -125,7 +148,10 @@ export class MemoryStore {
125
148
  const legacy = this.db.prepare("SELECT 1 FROM memories WHERE scope='legacy' LIMIT 1").get();
126
149
  this.setImportState({ state: !schema ? 'pending' : imported ? 'completed' : legacy ? 'unknown' : 'not_found' });
127
150
  }
128
- this.db.prepare("INSERT INTO metadata VALUES ('schema','6') ON CONFLICT(key) DO UPDATE SET value='6'").run();
151
+ const imported = this.importState();
152
+ if (imported.state === 'completed' && imported.count === 0 && emptyLegacyDigest(imported.digest)
153
+ && !this.db.prepare("SELECT 1 FROM events WHERE json_extract(data,'$.actor')='migration' LIMIT 1").get()) this.setImportState({ state: 'not_found' });
154
+ this.db.prepare("INSERT INTO metadata VALUES ('schema','7') ON CONFLICT(key) DO UPDATE SET value='7'").run();
129
155
  });
130
156
  if (this.importState().state === 'pending') {
131
157
  try { this.importLegacy(); } catch { /* Persisted failure blocks learning but leaves status/repair commands available. */ }
@@ -156,7 +182,7 @@ export class MemoryStore {
156
182
  return this.transaction(() => {
157
183
  const current = this.importState().state;
158
184
  if (current === 'completed' || current === 'unknown') return { state: current, imported: 0 };
159
- if (!snapshot.found) {
185
+ if (!snapshot.found || !snapshot.hasRecords) {
160
186
  // A failed import cannot be bypassed by pointing at an empty directory.
161
187
  if (current !== 'failed') this.setImportState({ state: 'not_found' });
162
188
  return { state: current === 'failed' ? 'failed' : 'not_found', imported: 0 };
@@ -234,18 +260,20 @@ export class MemoryStore {
234
260
  const original = this.db.prepare("SELECT data FROM sources WHERE id=?").get(memory.sourceEntryId);
235
261
  const body = original ? parseSource(original.data).content : undefined;
236
262
  // A claim may have been repeated in several sources, not only its first parent.
237
- const pending = this.db.prepare("SELECT id,data FROM sources WHERE json_extract(data,'$.scope')=? AND state!='done'").all(memory.scope);
263
+ const pending = this.db.prepare("SELECT id,data,attempt FROM sources WHERE json_extract(data,'$.scope')=? AND state!='done'").all(memory.scope);
238
264
  for (const row of pending) {
239
265
  if (row.id === keepSource) continue;
240
266
  const source = parseSource(row.data);
241
267
  if (source.id === memory.sourceEntryId || source.targets?.includes(memory.id) || source.content === body || source.content.includes(memory.content)
242
- || extractStructuredMemories(source.content, Infinity).some((claim) => fingerprint(claim.content) === hash))
268
+ || extractStructuredMemories(source.content, Infinity).some((claim) => fingerprint(claim.content) === hash)) {
269
+ finishCall(this.db, source.id, Number(row.attempt), 'cancelled', Date.now(), 'cancelled');
243
270
  this.db.prepare("UPDATE sources SET state='done',lease=0 WHERE id=?").run(source.id);
271
+ }
244
272
  }
245
273
  }
246
274
  private claim(source: Source, item: Claim, method: "local" | "model" = "local"): DurableMemory | undefined {
247
275
  const content = redact(item.content).trim();
248
- if (!validSearchTerms(item.searchTerms) || !MEMORY_KINDS.has(item.kind) || content.length < 4 || content.length > 480 || content.includes("[REDACTED")) throw new Error("Invalid or sensitive claim");
276
+ if (!validSearchTerms(item.searchTerms) || !MEMORY_KINDS.has(item.kind) || content.length < MIN_CLAIM_CHARS || content.length > MAX_CLAIM_CHARS || content.includes("[REDACTED")) throw new Error("Invalid or sensitive claim");
249
277
  const id = fingerprint(JSON.stringify([source.scope, item.kind, content]));
250
278
  if (this.get(id) || this.db.prepare("SELECT 1 FROM blocked WHERE scope=? AND hash=?").get(source.scope, fingerprint(content))) return undefined;
251
279
  // Also respect forgotten legacy records whose ids predate content-addressing.
@@ -275,27 +303,37 @@ export class MemoryStore {
275
303
  }
276
304
  pending(scope?: string, retry: RetryMode = false, now = Date.now()): string | undefined {
277
305
  const row = this.db.prepare(`SELECT id FROM sources WHERE ${scope === undefined ? "" : "json_extract(data,'$.scope')=? AND"}
278
- ${retry === "auto" ? automaticEligibility : `(state='pending' OR (state='running' AND lease<=?) ${retry ? "OR state='failed'" : ""})`}
279
- ORDER BY ${retry === "auto" ? "retry_at ASC, rowid ASC" : "rowid DESC"} LIMIT 1`)
280
- .get(...(scope === undefined ? [] : [scope]), now, ...(retry === "auto" ? [now] : []));
306
+ ${retry === 'auto' ? automaticEligibility(this.policy) : `(state='pending' OR (state='running' AND lease<=?) ${retry ? "OR state='failed'" : ""})`}
307
+ ORDER BY ${retry === 'auto' ? 'last_checked ASC, retry_at ASC, rowid ASC' : 'rowid DESC'} LIMIT 1`)
308
+ .get(...(scope === undefined ? [] : [scope]), now);
281
309
  return row ? String(row.id) : undefined;
282
310
  }
283
- beginEvolution(id: string, retry: RetryMode = false, timeoutMs = EVOLUTION_TIMEOUT_MS, now = Date.now(), model?: string): EvolutionRun | undefined {
311
+ beginEvolution(id: string, retry: RetryMode = false, timeoutMs = EVOLUTION_TIMEOUT_MS, now = Date.now(), model?: string, call?: CallOptions): EvolutionRun | undefined {
284
312
  this.assertLearningReady();
285
313
  return this.transaction(() => {
286
- const eligibility = retry === true ? `(state='pending' OR (state='running' AND lease<=?) OR state='failed')` : automaticEligibility;
287
- const args = [id, now, ...(retry === true ? [] : [now])];
288
- if (!this.db.prepare(`SELECT 1 FROM sources WHERE id=? AND ${eligibility}`).get(...args)) return undefined;
289
- if (model !== undefined && retry !== true) {
290
- const until = budgetUntil(this.db, modelLabel(model), now);
291
- if (until > now) {
292
- this.db.prepare('UPDATE sources SET retry_at=MAX(retry_at,?) WHERE id=?').run(until, id);
293
- return undefined; // No lease, attempt or failure consumed while waiting for shared budget.
294
- }
314
+ const eligibility = retry === true ? `(state='pending' OR (state='running' AND lease<=?) OR state='failed')`
315
+ : retry === 'fallback' ? `(state IN ('pending','failed') AND NOT ${pausedSQL(this.policy)} AND ? >= 0)` : automaticEligibility(this.policy);
316
+ const row = this.db.prepare(`SELECT * FROM sources WHERE id=? AND ${eligibility}`).get(id, now);
317
+ if (!row) return undefined;
318
+ this.db.prepare('UPDATE sources SET last_checked=? WHERE id=?').run(now, id);
319
+ const priorModels = parseModels(row.call_models);
320
+ const correctOutput = Number(row.corrections) === 0 && Number(row.output_failures) === 1 && ['invalid_output','output_limit'].includes(String(row.last_error));
321
+ if (model !== undefined) {
322
+ model = modelLabel(model);
323
+ if (retry !== true && !priorModels.includes(model) && priorModels.length >= this.policy.sourceModels) return undefined;
324
+ const provider = modelLabel(call?.provider ?? model.split('/')[0]);
325
+ if (retry !== true && routeUntil(this.db, model, provider, now) > now) return undefined;
326
+ const source = parseSource(row.data);
327
+ const bytes = Buffer.byteLength(JSON.stringify(source)) + this.readMemories(source.scope)
328
+ .filter(active).sort((a,b) => Date.parse(b.updatedAt)-Date.parse(a.updatedAt)).slice(0,32)
329
+ .reduce((sum,m) => sum + Buffer.byteLength(JSON.stringify(m)), 0);
330
+ const reserve = estimatedCost(bytes, call);
331
+ if (budgetUntil(this.db, model, now, this.policy, reserve) > now) return undefined;
332
+ reserveCall(this.db, id, Number(row.attempt) + 1, model, now, provider, reserve);
333
+ this.db.prepare('UPDATE sources SET calls=calls+1,call_models=?,corrections=corrections+? WHERE id=?').run(JSON.stringify([...new Set([...priorModels,model])]), Number(correctOutput), id);
295
334
  }
335
+ timeoutMs = Math.min(timeoutMs, this.policy.timeoutMs, retry === true ? timeoutMs : Math.max(1, this.policy.sourceTimeMs - Number(row.call_ms)));
296
336
  this.db.prepare(`UPDATE sources SET state='running', attempt=attempt+1, lease=? WHERE id=?`).run(now + timeoutMs + LEASE_GRACE_MS, id);
297
- const row = this.db.prepare("SELECT data,attempt,output_failures,diagnostic,last_error FROM sources WHERE id=?").get(id)!;
298
- if (model !== undefined) reserveCall(this.db, id, Number(row.attempt), modelLabel(model), now);
299
337
  const source = parseSource(row.data);
300
338
  if (source.id !== id) throw new Error("Invalid source identity");
301
339
  const memories = this.readMemories(source.scope).filter((m) => m.scope === source.scope && active(m)
@@ -303,7 +341,7 @@ export class MemoryStore {
303
341
  .sort((a,b) => Date.parse(b.updatedAt)-Date.parse(a.updatedAt)).slice(0, 32);
304
342
  // The stored diagnostic explains the last completed outcome. Claiming an attempt must not erase it:
305
343
  // a cancelled or interrupted run would otherwise leave a paused source with no recorded reason.
306
- return { source, attempt: Number(row.attempt), generation: this.generation(source.scope), memories,
344
+ return { source, attempt: Number(row.attempt) + 1, generation: this.generation(source.scope), memories, timeoutMs, correctOutput,
307
345
  outputFailures: Number(row.output_failures), previousDiagnostic: parseDiagnostic(row.diagnostic),
308
346
  previousError: FAILURE_CODES.includes(row.last_error as FailureCode) ? row.last_error as FailureCode : '' };
309
347
  });
@@ -377,16 +415,16 @@ export class MemoryStore {
377
415
  const event = this.record("model", `${model}: ${run.source.id}${weakerConflicts ? `; weaker replacements withheld=${weakerConflicts}` : ""}`, [...after.values()], run.source.scope);
378
416
  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=?")
379
417
  .run(JSON.stringify(diagnostic), run.source.id);
380
- finishCall(this.db, run.source.id, run.attempt, 'done', Date.now());
418
+ finishCall(this.db, run.source.id, run.attempt, 'done', Date.now(), '', diagnostic);
381
419
  return event;
382
420
  });
383
421
  }
384
- failEvolution(run: Pick<EvolutionRun, "source" | "attempt">, code: FailureCode = "unknown", now = Date.now(), diagnostic: Diagnostic = {}): void {
422
+ failEvolution(run: Pick<EvolutionRun, "source" | "attempt">, code: FailureCode = "unknown", now = Date.now(), diagnostic: Diagnostic = {}, jitter = false): void {
385
423
  if (!FAILURE_CODES.includes(code)) throw new Error("Invalid failure code");
386
424
  if (!validDiagnostic(diagnostic)) throw new Error('Invalid memory diagnostics');
387
425
  this.transaction(() => {
388
- const job = this.db.prepare(`SELECT failures,output_failures,diagnostic,${PAUSED_SQL} AS paused FROM sources WHERE id=? AND attempt=? AND state='running'`).get(run.source.id, run.attempt);
389
- finishCall(this.db, run.source.id, run.attempt, !job || code === 'cancelled' ? 'cancelled' : 'failed', now);
426
+ const job = this.db.prepare(`SELECT failures,output_failures,diagnostic,${pausedSQL(this.policy)} AS paused FROM sources WHERE id=? AND attempt=? AND state='running'`).get(run.source.id, run.attempt);
427
+ finishCall(this.db, run.source.id, run.attempt, !job || code === 'cancelled' ? 'cancelled' : 'failed', now, code, diagnostic);
390
428
  if (!job) return; // A newer owner or manual suppression wins.
391
429
  if (code === "cancelled") {
392
430
  // Shutdown/reload cannot exhaust or silently reset either failure budget.
@@ -396,12 +434,14 @@ export class MemoryStore {
396
434
  }
397
435
  const failures = Number(job.failures) + 1;
398
436
  const outputFailures = Number(job.output_failures) + (['invalid_output', 'output_limit'].includes(code) ? 1 : 0);
399
- const paused = failures >= MAX_FAILURES || outputFailures >= MAX_OUTPUT_FAILURES || ['write_rejected', 'unavailable', 'auth', 'request'].includes(code);
437
+ const paused = failures >= MAX_FAILURES || outputFailures >= MAX_OUTPUT_FAILURES || ['write_rejected', 'unavailable', 'safety'].includes(code);
400
438
  // Report one outcome, never a blend: a new reason must not inherit an older attempt's field path.
401
439
  // An interrupted run supplies none, so the previous explanation is kept rather than blanked.
402
440
  const details = Object.keys(diagnostic).length ? diagnostic : parseDiagnostic(job.diagnostic);
441
+ let due = paused ? 0 : ['auth','quota','rate_limit','context_limit','request'].includes(code) ? now : retryAt(failures, now);
442
+ if (jitter && due > now) due += Math.floor((due - now) * Math.random() * 0.2);
403
443
  this.db.prepare("UPDATE sources SET state='failed',lease=0,failures=?,output_failures=?,retry_at=?,failed_at=?,last_error=?,diagnostic=? WHERE id=?")
404
- .run(failures, outputFailures, paused ? 0 : retryAt(failures, now), now, code, JSON.stringify(details), run.source.id);
444
+ .run(failures, outputFailures, due, now, code, JSON.stringify(details), run.source.id);
405
445
  });
406
446
  }
407
447
  /** Crash recovery is local; an expired lease consumes a failure budget, not infinite restarts. */
@@ -411,37 +451,57 @@ export class MemoryStore {
411
451
  }
412
452
  }
413
453
  pausedJobs(): number {
414
- return Number(this.db.prepare(`SELECT COUNT(*) AS n FROM sources WHERE state='failed' AND ${PAUSED_SQL}`).get()!.n);
454
+ return Number(this.db.prepare(`SELECT COUNT(*) AS n FROM sources WHERE state IN ('pending','failed') AND ${pausedSQL(this.policy)}`).get()!.n);
415
455
  }
416
456
  takeNotice(identity: string, now = Date.now()): boolean {
417
457
  return this.transaction(() => takeNotice(this.db, identity, now));
418
458
  }
419
459
  jobNoticeKey(id: string): string {
420
- const row = this.db.prepare(`SELECT last_error,diagnostic,${PAUSED_SQL} AS paused FROM sources WHERE id=?`).get(id);
460
+ const row = this.db.prepare(`SELECT last_error,diagnostic,${pausedSQL(this.policy)} AS paused FROM sources WHERE id=?`).get(id);
421
461
  return JSON.stringify([id, row?.last_error, row ? parseDiagnostic(row.diagnostic).reason : '', !!row?.paused]);
422
462
  }
423
463
  pausedNoticeKey(): string {
424
- return JSON.stringify(this.db.prepare(`SELECT id,last_error FROM sources WHERE state='failed' AND ${PAUSED_SQL} ORDER BY id`).all());
464
+ return JSON.stringify(this.db.prepare(`SELECT id,last_error FROM sources WHERE state IN ('pending','failed') AND ${pausedSQL(this.policy)} ORDER BY id`).all());
425
465
  }
426
466
  budgetStatus(model: string, now = Date.now()): string {
427
- const until = budgetUntil(this.db, modelLabel(model), now);
428
- return until > now ? `Shared model budget: waiting until ${new Date(until).toISOString()} (manual evolve is a one-call override).` : 'Shared model budget: available.';
467
+ const until = budgetUntil(this.db, modelLabel(model), now, this.policy);
468
+ return until > now ? `Shared model budget: waiting until ${new Date(until).toISOString()} (manual evolve does not bypass shared ceilings).` : 'Shared model budget: available.';
469
+ }
470
+ routeAvailable(model: string, provider: string, now = Date.now()): boolean {
471
+ return routeUntil(this.db, modelLabel(model), modelLabel(provider), now) <= now;
472
+ }
473
+ routingInfo(id: string): { models: string[]; calls: number; outputFailures: number; error: string; model?: string } {
474
+ const row = this.db.prepare('SELECT call_models,calls,output_failures,last_error,diagnostic FROM sources WHERE id=?').get(id);
475
+ const last = this.db.prepare('SELECT model FROM model_calls WHERE source_id=? ORDER BY attempt DESC LIMIT 1').get(id);
476
+ return row ? { models: parseModels(row.call_models), calls: Number(row.calls), outputFailures: Number(row.output_failures), error: String(row.last_error), model: last ? String(last.model) : parseDiagnostic(row.diagnostic).model }
477
+ : { models: [], calls: 0, outputFailures: 0, error: '' };
478
+ }
479
+ checked(id: string): void { this.transaction(() => { this.db.prepare('UPDATE sources SET last_checked=? WHERE id=?').run(Date.now(), id); }); }
480
+ routingStatus(now = Date.now()): string {
481
+ const totals = this.db.prepare('SELECT COUNT(*) AS n,SUM(COALESCE(charged_usd,reserved_usd,0)) AS usd,SUM(charged_usd IS NULL AND reserved_usd IS NULL) AS unknown FROM model_calls WHERE at>?').get(now - 86_400_000)!;
482
+ const calls = this.db.prepare('SELECT COUNT(*) AS n FROM model_calls WHERE at>?').get(now - 3_600_000)!;
483
+ const routes = this.db.prepare('SELECT id,until,code FROM route_health WHERE until>? ORDER BY until LIMIT 10').all(now);
484
+ const recent = this.db.prepare('SELECT model,outcome,code FROM model_calls ORDER BY at DESC,rowid DESC LIMIT 5').all();
485
+ return [`Routing: default follows Pi; cross-provider fallback=${this.policy.crossProviderFallback}; models/source<=${this.policy.sourceModels}; calls/source<=${this.policy.sourceCalls}; time/source<=${this.policy.sourceTimeMs}ms`,
486
+ `Shared calls/hour=${calls.n}/${this.policy.callsPerHour}; last24h=${totals.n}; catalog-estimated/reported USD=${Number(totals.usd ?? 0).toFixed(4)}; unknown-cost calls=${totals.unknown ?? 0}; estimated daily ceiling=${this.policy.dailyEstimatedUsd ?? 'disabled'}`,
487
+ ...routes.map(r => `${modelLabel(String(r.id))}: ${r.code}; availableAfter=${new Date(Number(r.until)).toISOString()}`),
488
+ ...recent.map(r => `Attempt ${modelLabel(String(r.model))}: ${r.outcome}${r.code ? `/${r.code}` : ''}`)].join('\n');
429
489
  }
430
490
  /** Bounded diagnostics: only fixed codes/times/counts, never provider bodies or source text. */
431
491
  recoveryStatus(): string {
432
- const count = Number(this.db.prepare("SELECT COUNT(*) AS n FROM sources WHERE state='failed'").get()!.n);
433
- const rows = this.db.prepare(`SELECT id,attempt,failures,output_failures,retry_at,failed_at,last_error,diagnostic,${PAUSED_SQL} AS paused FROM sources WHERE state='failed' ORDER BY failed_at DESC,rowid DESC LIMIT 5`).all();
492
+ const count = Number(this.db.prepare(`SELECT COUNT(*) AS n FROM sources WHERE state='failed' OR (state='pending' AND ${pausedSQL(this.policy)})`).get()!.n);
493
+ const rows = this.db.prepare(`SELECT id,attempt,failures,output_failures,calls,call_ms,retry_at,failed_at,last_error,diagnostic,${pausedSQL(this.policy)} AS paused FROM sources WHERE state='failed' OR (state='pending' AND ${pausedSQL(this.policy)}) ORDER BY failed_at DESC,rowid DESC LIMIT 5`).all();
434
494
  const paused = this.pausedJobs();
435
495
  const details = rows.map((r) => {
436
496
  const code = FAILURE_CODES.includes(r.last_error as FailureCode) ? r.last_error : "unknown";
437
497
  const failedAt = r.failed_at ? new Date(Number(r.failed_at)).toISOString() : "unknown (legacy)";
438
498
  const next = r.paused ? "paused; inspect diagnostics, /memory evolve <source-id> for one extra attempt"
439
499
  : `nextRetry=${r.retry_at ? new Date(Number(r.retry_at)).toISOString() : "due now"}`;
440
- return `${clipBytes(redact(String(r.id)), 160)}: ${code}; attempts=${r.attempt}; failures=${r.failures}/${MAX_FAILURES}; outputFailures=${r.output_failures}/${MAX_OUTPUT_FAILURES}; failedAt=${failedAt}; ${next}\n diagnostics=${JSON.stringify(parseDiagnostic(r.diagnostic))}`;
500
+ return `${clipBytes(redact(String(r.id)), 160)}: ${code}; attempts=${r.attempt}; failures=${r.failures}/${MAX_FAILURES}; outputFailures=${r.output_failures}/${MAX_OUTPUT_FAILURES}; calls=${r.calls}/${this.policy.sourceCalls}; requestMs=${r.call_ms}/${this.policy.sourceTimeMs}; failedAt=${failedAt}; ${next}\n diagnostics=${JSON.stringify(parseDiagnostic(r.diagnostic))}`;
441
501
  });
442
502
  const deferred = this.db.prepare("SELECT COUNT(*) AS n,MIN(retry_at) AS next FROM sources WHERE state='pending' AND retry_at>?").get(Date.now())!;
443
503
  return [`Automatic recovery: retrying=${count - paused}, paused=${paused} (failure limit ${MAX_FAILURES}; output limit ${MAX_OUTPUT_FAILURES}; non-retryable errors pause immediately)`,
444
- ...(Number(deferred.n) ? [`Budget-deferred sources=${deferred.n}; nextEligible=${new Date(Number(deferred.next)).toISOString()}`] : []), ...details,
504
+ ...(Number(deferred.n) ? [`Source-backoff waiting=${deferred.n}; nextEligible=${new Date(Number(deferred.next)).toISOString()}`] : []), ...details,
445
505
  ...(count > 5 ? [`${count - 5} more failed sources.`] : [])].join("\n");
446
506
  }
447
507
  /** Explicit exact-ID user feedback only. No inferred usage or self-reinforcement.
@@ -492,7 +552,7 @@ export class MemoryStore {
492
552
  switch (type) {
493
553
  case "correct": {
494
554
  const content = redact(value ?? "").trim();
495
- if (content.length < 4 || content.length > 480 || content.includes("[REDACTED")) throw new Error("Correction must be 4–480 characters without credentials");
555
+ if (content.length < MIN_CLAIM_CHARS || content.length > MAX_CLAIM_CHARS || content.includes("[REDACTED")) throw new Error(`Correction must be ${MIN_CLAIM_CHARS}–${MAX_CLAIM_CHARS} characters without credentials`);
496
556
  next = { ...next, content, status: "confirmed", searchTerms: undefined, feedback: undefined,
497
557
  evidence: { basis: "manual_correction", method: "manual", sourceId: `manual:${randomUUID()}`, at } }; break;
498
558
  }
@@ -548,15 +608,15 @@ export class MemoryStore {
548
608
  ...(rows.length ? [] : ['No model transactions yet.']), 'Use /memory learning for the last capture/nomination decision.'].join('\n');
549
609
  }
550
610
  status(): string {
551
- if (this.db.prepare("SELECT value FROM metadata WHERE key='schema'").get()?.value !== "6") throw new Error("Invalid memory schema marker");
611
+ if (this.db.prepare("SELECT value FROM metadata WHERE key='schema'").get()?.value !== "7") throw new Error("Invalid memory schema marker");
552
612
  const health = this.db.prepare("PRAGMA quick_check").get();
553
613
  if (health?.quick_check !== "ok") throw new Error("Memory database integrity check failed");
554
- for (const row of this.db.prepare("SELECT id,data,state,attempt,lease,failures,output_failures,retry_at,failed_at,last_error,diagnostic FROM sources").iterate()) {
614
+ for (const row of this.db.prepare("SELECT id,data,state,attempt,lease,failures,output_failures,retry_at,failed_at,last_error,diagnostic,calls,call_ms,call_models,last_checked,corrections FROM sources").iterate()) {
555
615
  const source = parseSource(row.data);
556
616
  if (source.id !== row.id || !["pending", "running", "done", "failed"].includes(String(row.state))
557
- || ![row.attempt, row.lease, row.failures, row.output_failures, row.retry_at, row.failed_at].every((v) => Number.isSafeInteger(v) && Number(v) >= 0)
617
+ || ![row.attempt, row.lease, row.failures, row.output_failures, row.retry_at, row.failed_at, row.calls, row.call_ms, row.last_checked, row.corrections].every((v) => Number.isSafeInteger(v) && Number(v) >= 0)
558
618
  || (row.last_error !== "" && !FAILURE_CODES.includes(row.last_error as FailureCode))) throw new Error("Invalid source job");
559
- parseDiagnostic(row.diagnostic);
619
+ parseDiagnostic(row.diagnostic); parseModels(row.call_models);
560
620
  }
561
621
  for (const row of this.db.prepare("SELECT id,scope,data FROM events").iterate()) parseEvent(row.data, row.id, row.scope);
562
622
  for (const row of this.db.prepare("SELECT source_id,memory_id,verdict,at FROM feedback_receipts").iterate()) {
@@ -564,10 +624,15 @@ export class MemoryStore {
564
624
  || !FEEDBACK_VERDICTS.has(row.verdict as FeedbackVerdict) || !Number.isSafeInteger(row.at)) throw new Error("Invalid feedback receipt");
565
625
  }
566
626
  const jobs = this.db.prepare("SELECT state,COUNT(*) AS n FROM sources GROUP BY state").all();
567
- return `${this.readMemories().length} memories; ${jobs.map((j) => `${j.state}=${j.n}`).join(", ") || "no sources"}; SQLite ok (schema 6)\nState directory: ${redact(this.stateDir)}\n${this.recoveryStatus()}\n${this.legacyStatus()}\n${this.processingStatus()}`;
627
+ return `${this.readMemories().length} memories; ${jobs.map((j) => `${j.state}=${j.n}`).join(", ") || "no sources"}; SQLite ok (schema 7)\nState directory: ${redact(this.stateDir)}\n${this.recoveryStatus()}\n${this.routingStatus()}\n${this.legacyStatus()}\n${this.processingStatus()}`;
568
628
  }
569
629
  }
570
630
 
631
+ function parseModels(value: unknown): string[] {
632
+ const models: unknown = JSON.parse(String(value));
633
+ if (!Array.isArray(models) || !models.every(m => typeof m === 'string' && modelLabel(m) === m)) throw new Error('Invalid source models');
634
+ return models;
635
+ }
571
636
  function isSource(value: unknown): value is Source {
572
637
  if (!value || typeof value !== "object") return false;
573
638
  const s = value as Source;
@@ -1,4 +1,4 @@
1
- import type { Claim } from './extractor.ts';
1
+ import { MAX_CLAIM_CHARS, MIN_CLAIM_CHARS, type Claim } from './extractor.ts';
2
2
  import { MEMORY_KINDS } from './memory-store.ts';
3
3
  import { EvolutionError } from './recovery.ts';
4
4
  import { OUTPUT_PROTOCOL_VERSION, type Diagnostic, type DiagnosticReason } from './diagnostics.ts';
@@ -67,7 +67,7 @@ export function parseMemoryOutput(text: string): { claims: Claim[]; diagnostic:
67
67
  if (!MEMORY_KINDS.has(c.kind as Claim['kind'])) fail('invalid_kind', `${field}.kind`);
68
68
  if (typeof c.content !== 'string') fail('content_type', `${field}.content`);
69
69
  const content = (c.content as string).trim();
70
- if (content.length < 4 || content.length > 480) fail('content_length', `${field}.content`, content.length);
70
+ if (content.length < MIN_CLAIM_CHARS || content.length > MAX_CLAIM_CHARS) fail('content_length', `${field}.content`, content.length);
71
71
  if (c.replaces !== undefined && (typeof c.replaces !== 'string' || !c.replaces.trim())) fail('invalid_replaces', `${field}.replaces`);
72
72
  // Aliases are optional recall hints, never authority. Drop, don't repair or echo, invalid values.
73
73
  const searchTerms: string[] = [];
@@ -1,24 +1,70 @@
1
1
  import type { Database } from './sqlite.ts';
2
2
  import { fingerprint } from './privacy.ts';
3
- import { CALL_WINDOW_MS, MAX_CALLS_PER_WINDOW, FAILURE_WINDOW_MS, MAX_WINDOW_FAILURES, NOTICE_COOLDOWN_MS } from './recovery.ts';
3
+ import { CALL_WINDOW_MS, FAILURE_WINDOW_MS, MAX_WINDOW_FAILURES, NOTICE_COOLDOWN_MS, type FailureCode } from './recovery.ts';
4
+ import { DEFAULT_POLICY, type RoutingPolicy } from './routing-policy.ts';
5
+ import type { Diagnostic } from './diagnostics.ts';
4
6
 
5
- /** Called inside the store's write transaction, so multiple Pi processes share one budget. */
6
- export function budgetUntil(db: Database, model: string, now: number): number {
7
- const calls = db.prepare('SELECT at FROM model_calls WHERE model=? AND at>? ORDER BY at DESC').all(model, now - CALL_WINDOW_MS);
8
- const failures = db.prepare("SELECT finished_at AS at FROM model_calls WHERE model=? AND outcome='failed' AND finished_at>? ORDER BY finished_at DESC")
7
+ export interface CallPricing { input: number; output: number; cacheRead: number; cacheWrite: number; tiers?: { input: number; output: number; cacheRead: number; cacheWrite: number }[] }
8
+ export interface CallOptions { provider: string; pricing?: CallPricing; outputTokens?: number; promptBytes?: number }
9
+ export function estimatedCost(inputBytes: number, options?: CallOptions): number | null {
10
+ const rates = options?.pricing ? [options.pricing, ...(options.pricing.tiers ?? [])] : [];
11
+ if (!rates.length || rates.some(r => ![r.input,r.output,r.cacheRead,r.cacheWrite].every(n => Number.isFinite(n) && n >= 0))) return null;
12
+ const input = Math.max(...rates.flatMap(r => [r.input,r.cacheRead,r.cacheWrite]));
13
+ const output = Math.max(...rates.map(r => r.output));
14
+ // All-zero custom catalog pricing is frequently missing, not proof of a free account.
15
+ if (!input && !output) return null;
16
+ return ((inputBytes + (options?.promptBytes ?? 20_000)) * input + (options?.outputTokens ?? 8192) * output) / 1_000_000;
17
+ }
18
+ /** Atomic callers share the hard request ceiling across models/providers and Pi processes. */
19
+ export function budgetUntil(db: Database, model: string, now: number, policy: RoutingPolicy = DEFAULT_POLICY, reserveUsd?: number | null): number {
20
+ const calls = db.prepare('SELECT at FROM model_calls WHERE at>? ORDER BY at DESC').all(now - CALL_WINDOW_MS);
21
+ const failures = db.prepare("SELECT finished_at AS at FROM model_calls WHERE model=? AND outcome='failed' AND code IN ('provider','timeout','interrupted') AND finished_at>? ORDER BY finished_at DESC")
9
22
  .all(model, now - FAILURE_WINDOW_MS);
10
- return Math.max(calls.length >= MAX_CALLS_PER_WINDOW ? Number(calls[MAX_CALLS_PER_WINDOW - 1].at) + CALL_WINDOW_MS : 0,
23
+ let until = Math.max(calls.length >= policy.callsPerHour ? Number(calls[policy.callsPerHour - 1].at) + CALL_WINDOW_MS : 0,
11
24
  failures.length >= MAX_WINDOW_FAILURES ? Number(failures[MAX_WINDOW_FAILURES - 1].at) + FAILURE_WINDOW_MS : 0);
25
+ if (policy.dailyEstimatedUsd !== null) {
26
+ const day = db.prepare('SELECT at,reserved_usd,charged_usd FROM model_calls WHERE at>? ORDER BY at').all(now - 86_400_000);
27
+ const cost = day.reduce((sum, r) => sum + Number(r.charged_usd ?? r.reserved_usd ?? 0), 0);
28
+ if (reserveUsd === null || day.some(r => r.charged_usd === null && r.reserved_usd === null)
29
+ || cost + (reserveUsd ?? 0) > policy.dailyEstimatedUsd) until = Math.max(until, Number(day[0]?.at ?? now) + 86_400_000);
30
+ }
31
+ return until;
12
32
  }
13
- export function reserveCall(db: Database, source: string, attempt: number, model: string, now: number): void {
14
- // Retain at most a day's operational receipts, not model bodies or token-level traces.
33
+ export function reserveCall(db: Database, source: string, attempt: number, model: string, now: number, provider = model.split('/')[0], reserveUsd: number | null = null): void {
15
34
  db.prepare("DELETE FROM model_calls WHERE at<? AND outcome!='running'").run(now - 86_400_000);
16
- db.prepare('INSERT INTO model_calls(source_id,attempt,model,at) VALUES (?,?,?,?)').run(source, attempt, model, now);
35
+ db.prepare('INSERT INTO model_calls(source_id,attempt,model,provider,at,reserved_usd) VALUES (?,?,?,?,?,?)').run(source, attempt, model, provider, now, reserveUsd);
36
+ }
37
+ export function finishCall(db: Database, source: string, attempt: number, outcome: 'done' | 'failed' | 'cancelled', now: number, code: FailureCode | '' = '', diagnostic: Diagnostic = {}): void {
38
+ const row = db.prepare("SELECT at,model,provider FROM model_calls WHERE source_id=? AND attempt=? AND outcome='running'").get(source, attempt);
39
+ if (!row) return;
40
+ // Zero usage on an error/timeout is not a receipt proving a request was free.
41
+ const reported = diagnostic.reportedUsd !== undefined && (outcome === 'done' || diagnostic.inputTokens || diagnostic.outputTokens) ? diagnostic.reportedUsd : null;
42
+ db.prepare("UPDATE model_calls SET outcome=?,finished_at=?,code=?,charged_usd=?,input_tokens=?,output_tokens=? WHERE source_id=? AND attempt=?")
43
+ .run(outcome, now, code, reported, diagnostic.inputTokens ?? null, diagnostic.outputTokens ?? null, source, attempt);
44
+ db.prepare('UPDATE sources SET call_ms=call_ms+? WHERE id=?').run(Math.max(0, now - Number(row.at)), source);
45
+ if (outcome !== 'failed' || !code) return;
46
+ let scope = '', delay = 0;
47
+ if (['auth','quota'].includes(code)) {
48
+ // Credentials and account quota are provider-wide: no sibling on that provider can help.
49
+ scope = `provider:${row.provider}`;
50
+ delay = code === 'auth' ? 900_000 : 3_600_000;
51
+ } else if (code === 'rate_limit') {
52
+ // tpm/rpm ceilings are per model on the major providers, so a provider-wide wait would also
53
+ // block a sibling holding its own budget. Retry-After still widens the wait when supplied.
54
+ scope = `model:${row.model}`; delay = 60_000;
55
+ } else if (['request','context_limit'].includes(code)) { scope = `model:${row.model}`; delay = 3_600_000; }
56
+ else if (['provider','timeout','interrupted','invalid_output','output_limit'].includes(code)) {
57
+ const family = ['invalid_output','output_limit'].includes(code) ? "'invalid_output','output_limit'" : "'provider','timeout','interrupted'";
58
+ const count = Number(db.prepare(`SELECT COUNT(*) AS n FROM model_calls WHERE model=? AND outcome='failed' AND code IN (${family}) AND finished_at>?`).get(row.model, now - FAILURE_WINDOW_MS)!.n);
59
+ if (count >= 2) { scope = `model:${row.model}`; delay = FAILURE_WINDOW_MS; }
60
+ }
61
+ if (scope) db.prepare('INSERT INTO route_health VALUES (?,?,?) ON CONFLICT(id) DO UPDATE SET until=MAX(until,excluded.until),code=excluded.code')
62
+ .run(scope, now + Math.max(delay, diagnostic.retryAfterMs ?? 0), code);
17
63
  }
18
- export function finishCall(db: Database, source: string, attempt: number, outcome: 'done' | 'failed' | 'cancelled', now: number): void {
19
- db.prepare("UPDATE model_calls SET outcome=?,finished_at=? WHERE source_id=? AND attempt=? AND outcome='running'").run(outcome, now, source, attempt);
64
+ export function routeUntil(db: Database, model: string, provider: string, now: number): number {
65
+ const row = db.prepare('SELECT MAX(until) AS until FROM route_health WHERE id IN (?,?)').get(`model:${model}`, `provider:${provider}`);
66
+ return Math.max(now, Number(row?.until ?? 0));
20
67
  }
21
- /** Fixed keys/hashes only; atomic callers prevent duplicate warnings across reload/processes. */
22
68
  export function takeNotice(db: Database, identity: string, now: number): boolean {
23
69
  const key = fingerprint(identity);
24
70
  db.prepare('DELETE FROM recovery_notices WHERE at<=?').run(now - NOTICE_COOLDOWN_MS);
@@ -25,7 +25,7 @@ export function nominateProgress(memories: readonly DurableMemory[], input: { sc
25
25
  const body = features(memory.content), aliases = features((memory.searchTerms ?? []).join(' '));
26
26
  let resourceScore = 0, resourceReason = '', resourceConflict = false;
27
27
  for (const resource of resources) {
28
- const path = resource.path.toLowerCase();
28
+ const path = resource.path; // Do not merge case-distinct files/directories on the host.
29
29
  const literals = [...body].filter(w => w.startsWith('literal:/')).map(w => w.slice(8));
30
30
  const exact = literals.some(l => l === path || (resource.kind === 'directory' && l.startsWith(path + '/')));
31
31
  const named = resource.kind === 'directory' && names(memory.content, resource.name);
@@ -6,16 +6,16 @@ export const EVOLUTION_MAX_TOKENS = 8192;
6
6
  export const RECOVERY_POLL_MS = 15_000;
7
7
  export const LEASE_GRACE_MS = 30_000;
8
8
  export const MAX_FAILURES = 5;
9
- export const MAX_OUTPUT_FAILURES = 2;
9
+ export const MAX_OUTPUT_FAILURES = 3; // Initial output, one correction, at most one alternate model.
10
10
  export const CALL_WINDOW_MS = 3_600_000;
11
11
  export const MAX_CALLS_PER_WINDOW = 20;
12
12
  export const FAILURE_WINDOW_MS = 900_000;
13
13
  export const MAX_WINDOW_FAILURES = 5;
14
14
  export const NOTICE_COOLDOWN_MS = 3_600_000;
15
- export const PAUSED_SQL = `(failures>=${MAX_FAILURES} OR output_failures>=${MAX_OUTPUT_FAILURES} OR last_error IN ('write_rejected','unavailable','auth','request'))`;
15
+ export const PAUSED_SQL = `(failures>=${MAX_FAILURES} OR output_failures>=${MAX_OUTPUT_FAILURES} OR last_error IN ('write_rejected','unavailable','safety'))`;
16
16
  const RETRY_DELAYS_MS = [60_000, 300_000, 900_000, 3_600_000];
17
17
 
18
- export const FAILURE_CODES = ["timeout", "cancelled", "output_limit", "invalid_output", "stale", "write_rejected", "unavailable", "provider", "auth", "request", "rate_limit", "interrupted", "unknown"] as const;
18
+ export const FAILURE_CODES = ["timeout", "cancelled", "output_limit", "invalid_output", "stale", "write_rejected", "unavailable", "provider", "auth", "request", "rate_limit", "quota", "context_limit", "safety", "interrupted", "unknown"] as const;
19
19
  export type FailureCode = typeof FAILURE_CODES[number];
20
20
 
21
21
  /** Never persist raw exception messages/provider bodies (they may contain secrets). */
@@ -0,0 +1,31 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+
4
+ /** Extension policy only: model catalog, credentials and the default model belong to Pi. */
5
+ export interface RoutingPolicy {
6
+ crossProviderFallback: boolean;
7
+ fallbackModels: string[];
8
+ callsPerHour: number;
9
+ sourceCalls: number;
10
+ sourceModels: number;
11
+ timeoutMs: number;
12
+ sourceTimeMs: number;
13
+ dailyEstimatedUsd: number | null;
14
+ }
15
+ export const DEFAULT_POLICY: RoutingPolicy = {
16
+ crossProviderFallback: true, fallbackModels: [], callsPerHour: 20, sourceCalls: 4, sourceModels: 2,
17
+ timeoutMs: 120_000, sourceTimeMs: 300_000, dailyEstimatedUsd: null,
18
+ };
19
+ export function loadRoutingPolicy(dir: string): RoutingPolicy {
20
+ let value: unknown;
21
+ try { const text = readFileSync(join(dir, 'recovery.json'), 'utf8'); if (Buffer.byteLength(text) > 8192) throw new Error(); value = JSON.parse(text); }
22
+ catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { ...DEFAULT_POLICY, fallbackModels: [] }; throw new Error('Invalid recovery.json'); }
23
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Invalid recovery.json');
24
+ const p = { ...DEFAULT_POLICY, ...value } as RoutingPolicy;
25
+ const bounds = { callsPerHour: [1, 1000], sourceCalls: [1, 8], sourceModels: [1, 3], timeoutMs: [1000, 120_000], sourceTimeMs: [1000, 600_000] };
26
+ if (Object.keys(value).some(k => !Object.hasOwn(DEFAULT_POLICY, k)) || typeof p.crossProviderFallback !== 'boolean'
27
+ || !Array.isArray(p.fallbackModels) || p.fallbackModels.length > 16 || !p.fallbackModels.every(m => typeof m === 'string' && m.length <= 200 && /^[^\s/]+\/.+$/u.test(m))
28
+ || Object.entries(bounds).some(([k, [min, max]]) => !Number.isSafeInteger(p[k as keyof typeof bounds]) || p[k as keyof typeof bounds] < min || p[k as keyof typeof bounds] > max)
29
+ || (p.dailyEstimatedUsd !== null && (!Number.isFinite(p.dailyEstimatedUsd) || p.dailyEstimatedUsd <= 0 || p.dailyEstimatedUsd > 1000))) throw new Error('Invalid recovery.json');
30
+ return p;
31
+ }
@@ -0,0 +1,76 @@
1
+ import type { ExtensionContext } from '@earendil-works/pi-coding-agent';
2
+ import { completeMemory, type CompleteMemory } from '../adapter/pi-api.ts';
3
+ import { modelLabel } from './diagnostics.ts';
4
+ import { evolve } from './evolution.ts';
5
+ import { failureCode, type FailureCode } from './recovery.ts';
6
+ import type { MemoryStore, RetryMode } from './memory-store.ts';
7
+
8
+ type Model = NonNullable<ExtensionContext['model']>;
9
+ export const modelKey = (model: Model): string => modelLabel(`${model.provider}/${model.id}`);
10
+ /** Catalog lookup only: no paid probes, credential reads, or changes to the foreground model. */
11
+ export function routeCandidates(ctx: ExtensionContext, store: MemoryStore): Model[] {
12
+ const primary = ctx.model;
13
+ if (!primary) return [];
14
+ if (!store.policy.crossProviderFallback) return [primary];
15
+ const available = typeof ctx.modelRegistry?.getAvailable === 'function' ? ctx.modelRegistry.getAvailable() : [];
16
+ const order = store.policy.fallbackModels;
17
+ const price = (m: Model) => m.cost && m.cost.input + m.cost.output > 0 ? m.cost.input + m.cost.output : Infinity;
18
+ // A sibling on the same provider shares credentials and account quota, so it is never chosen
19
+ // automatically. An explicit allowlist entry is the operator overriding that, and it is the only
20
+ // redundancy available when Pi has a single configured provider.
21
+ const fallback = available.filter(m => modelKey(m) !== modelKey(primary)
22
+ && (m.provider !== primary.provider || order.includes(modelKey(m)))
23
+ && m.input?.includes('text') && ((m as Model & { output?: string[] }).output?.includes('text') ?? true)
24
+ && (!order.length || order.includes(modelKey(m))))
25
+ .sort((a,b) => (order.length ? order.indexOf(modelKey(a)) - order.indexOf(modelKey(b)) : price(a) - price(b))
26
+ || (a.reasoning === b.reasoning ? 0 : a.reasoning ? 1 : -1) || modelKey(a).localeCompare(modelKey(b)));
27
+ return [primary, ...[...new Map(fallback.map(m => [modelKey(m),m])).values()].slice(0,32)];
28
+ }
29
+ /** Shared credentials and account quota mean a sibling only helps when the failure was model-specific. */
30
+ const SIBLING_RECOVERABLE: readonly FailureCode[] = ['rate_limit', 'invalid_output', 'output_limit', 'context_limit'];
31
+ export const siblingEligible = (code?: FailureCode): boolean => !code || SIBLING_RECOVERABLE.includes(code);
32
+ function contextFor(ctx: ExtensionContext, model: Model): ExtensionContext {
33
+ const child = Object.create(ctx) as ExtensionContext;
34
+ Object.defineProperty(child, 'model', { value: model });
35
+ return child;
36
+ }
37
+ /** At most two immediate attempts. Delayed retries are persisted, never sleeping in the queue. */
38
+ export async function evolveRouted(store: MemoryStore, id: string, ctx: ExtensionContext, signal: AbortSignal,
39
+ complete: CompleteMemory = completeMemory, retry: RetryMode = false, timeoutMs = store.policy.timeoutMs): Promise<boolean> {
40
+ const candidates = routeCandidates(ctx, store);
41
+ // Preserve the single-model/old-host error path and dependency-injected test seam.
42
+ if (!candidates.length) return evolve(store, id, ctx, signal, complete, retry, timeoutMs);
43
+ const attempted = new Set<string>();
44
+ const sibling = (m: Model) => m.provider === candidates[0].provider && modelKey(m) !== modelKey(candidates[0]);
45
+ let lastError: unknown;
46
+ let lastCode: FailureCode | undefined;
47
+ for (let pass = 0; pass < (retry === true ? 1 : 2); pass++) {
48
+ signal.throwIfAborted();
49
+ const info = store.routingInfo(id);
50
+ const candidate = candidates.find(m => !attempted.has(modelKey(m))
51
+ && !(sibling(m) && !siblingEligible(lastCode))
52
+ && (retry === true || (store.routeAvailable(modelKey(m), m.provider)
53
+ && !(info.outputFailures >= 2 && info.model === modelKey(m) && ['invalid_output','output_limit'].includes(info.error))
54
+ && (info.models.includes(modelKey(m)) || info.models.length < store.policy.sourceModels))));
55
+ if (!candidate) break;
56
+ attempted.add(modelKey(candidate));
57
+ try {
58
+ // Only a known route failure justifies bypassing source backoff for an alternate model.
59
+ const result = await evolve(store, id, contextFor(ctx, candidate), signal, complete, pass ? 'fallback' : retry, timeoutMs);
60
+ if (result) return true;
61
+ // A concurrent claim, terminal source or shared budget rejection cannot authorize another call.
62
+ break;
63
+ } catch (error) {
64
+ lastError = error;
65
+ const code = failureCode(error, signal);
66
+ lastCode = code;
67
+ const reroute = ['auth','quota','rate_limit','request','context_limit'].includes(code)
68
+ || (['provider','timeout','invalid_output','output_limit','interrupted'].includes(code)
69
+ && !store.routeAvailable(modelKey(candidate), candidate.provider));
70
+ if (!reroute || retry === true || signal.aborted) throw error;
71
+ }
72
+ }
73
+ store.checked(id); // Round-robin past sources waiting on unavailable routes, without consuming attempts.
74
+ if (lastError) throw lastError;
75
+ return false;
76
+ }