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.
- package/CHANGELOG.md +14 -0
- package/README.cn.md +3 -3
- package/README.md +8 -4
- package/docs/core-quality.md +1 -1
- package/docs/design.md +27 -15
- package/docs/recovery.md +134 -0
- package/docs/testing.md +13 -2
- package/docs/usage.md +43 -27
- package/package.json +1 -1
- package/src/adapter/http-diagnostics.ts +76 -0
- package/src/adapter/pi-api.ts +18 -8
- package/src/index.ts +11 -6
- package/src/memory/diagnostics.ts +9 -2
- package/src/memory/evolution.ts +22 -8
- package/src/memory/extractor.ts +8 -1
- package/src/memory/legacy.ts +7 -2
- package/src/memory/memory-store.ts +117 -52
- package/src/memory/output.ts +2 -2
- package/src/memory/processing-state.ts +58 -12
- package/src/memory/progress-targets.ts +1 -1
- package/src/memory/recovery.ts +3 -3
- package/src/memory/routing-policy.ts +31 -0
- package/src/memory/scheduler.ts +76 -0
- package/src/memory/search.ts +2 -2
|
@@ -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 |
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
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 <
|
|
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 ===
|
|
279
|
-
ORDER BY ${retry ===
|
|
280
|
-
.get(...(scope === undefined ? [] : [scope]), 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')`
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
if (
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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,${
|
|
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', '
|
|
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,
|
|
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
|
|
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,${
|
|
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
|
|
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
|
|
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(
|
|
433
|
-
const rows = this.db.prepare(`SELECT id,attempt,failures,output_failures,retry_at,failed_at,last_error,diagnostic,${
|
|
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) ? [`
|
|
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 <
|
|
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 !== "
|
|
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
|
|
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;
|
package/src/memory/output.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
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 <
|
|
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,
|
|
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
|
-
|
|
6
|
-
export
|
|
7
|
-
|
|
8
|
-
const
|
|
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
|
-
|
|
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 (
|
|
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
|
|
19
|
-
db.prepare(
|
|
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.
|
|
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);
|
package/src/memory/recovery.ts
CHANGED
|
@@ -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 =
|
|
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','
|
|
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
|
+
}
|