pi-memory-evolution 0.2.3 → 0.2.5
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 +26 -14
- package/docs/recovery.md +130 -0
- package/docs/testing.md +13 -2
- package/docs/usage.md +44 -25
- package/package.json +2 -2
- 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 +19 -6
- package/src/memory/legacy.ts +7 -2
- package/src/memory/memory-store.ts +114 -49
- package/src/memory/processing-state.ts +53 -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 +64 -0
- package/src/memory/search.ts +2 -2
|
@@ -1,24 +1,65 @@
|
|
|
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','rate_limit'].includes(code)) {
|
|
48
|
+
scope = `provider:${row.provider}`;
|
|
49
|
+
delay = code === 'auth' ? 900_000 : code === 'quota' ? 3_600_000 : 60_000;
|
|
50
|
+
} else if (['request','context_limit'].includes(code)) { scope = `model:${row.model}`; delay = 3_600_000; }
|
|
51
|
+
else if (['provider','timeout','interrupted','invalid_output','output_limit'].includes(code)) {
|
|
52
|
+
const family = ['invalid_output','output_limit'].includes(code) ? "'invalid_output','output_limit'" : "'provider','timeout','interrupted'";
|
|
53
|
+
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);
|
|
54
|
+
if (count >= 2) { scope = `model:${row.model}`; delay = FAILURE_WINDOW_MS; }
|
|
55
|
+
}
|
|
56
|
+
if (scope) db.prepare('INSERT INTO route_health VALUES (?,?,?) ON CONFLICT(id) DO UPDATE SET until=MAX(until,excluded.until),code=excluded.code')
|
|
57
|
+
.run(scope, now + Math.max(delay, diagnostic.retryAfterMs ?? 0), code);
|
|
17
58
|
}
|
|
18
|
-
export function
|
|
19
|
-
db.prepare(
|
|
59
|
+
export function routeUntil(db: Database, model: string, provider: string, now: number): number {
|
|
60
|
+
const row = db.prepare('SELECT MAX(until) AS until FROM route_health WHERE id IN (?,?)').get(`model:${model}`, `provider:${provider}`);
|
|
61
|
+
return Math.max(now, Number(row?.until ?? 0));
|
|
20
62
|
}
|
|
21
|
-
/** Fixed keys/hashes only; atomic callers prevent duplicate warnings across reload/processes. */
|
|
22
63
|
export function takeNotice(db: Database, identity: string, now: number): boolean {
|
|
23
64
|
const key = fingerprint(identity);
|
|
24
65
|
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,64 @@
|
|
|
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 } 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
|
+
const fallback = available.filter(m => m.provider !== primary.provider && m.input?.includes('text') && ((m as Model & { output?: string[] }).output?.includes('text') ?? true)
|
|
19
|
+
&& (!order.length || order.includes(modelKey(m))))
|
|
20
|
+
.sort((a,b) => (order.length ? order.indexOf(modelKey(a)) - order.indexOf(modelKey(b)) : price(a) - price(b))
|
|
21
|
+
|| (a.reasoning === b.reasoning ? 0 : a.reasoning ? 1 : -1) || modelKey(a).localeCompare(modelKey(b)));
|
|
22
|
+
return [primary, ...[...new Map(fallback.map(m => [modelKey(m),m])).values()].slice(0,32)];
|
|
23
|
+
}
|
|
24
|
+
function contextFor(ctx: ExtensionContext, model: Model): ExtensionContext {
|
|
25
|
+
const child = Object.create(ctx) as ExtensionContext;
|
|
26
|
+
Object.defineProperty(child, 'model', { value: model });
|
|
27
|
+
return child;
|
|
28
|
+
}
|
|
29
|
+
/** At most two immediate attempts. Delayed retries are persisted, never sleeping in the queue. */
|
|
30
|
+
export async function evolveRouted(store: MemoryStore, id: string, ctx: ExtensionContext, signal: AbortSignal,
|
|
31
|
+
complete: CompleteMemory = completeMemory, retry: RetryMode = false, timeoutMs = store.policy.timeoutMs): Promise<boolean> {
|
|
32
|
+
const candidates = routeCandidates(ctx, store);
|
|
33
|
+
// Preserve the single-model/old-host error path and dependency-injected test seam.
|
|
34
|
+
if (!candidates.length) return evolve(store, id, ctx, signal, complete, retry, timeoutMs);
|
|
35
|
+
const attempted = new Set<string>();
|
|
36
|
+
let lastError: unknown;
|
|
37
|
+
for (let pass = 0; pass < (retry === true ? 1 : 2); pass++) {
|
|
38
|
+
signal.throwIfAborted();
|
|
39
|
+
const info = store.routingInfo(id);
|
|
40
|
+
const candidate = candidates.find(m => !attempted.has(modelKey(m))
|
|
41
|
+
&& (retry === true || (store.routeAvailable(modelKey(m), m.provider)
|
|
42
|
+
&& !(info.outputFailures >= 2 && info.model === modelKey(m) && ['invalid_output','output_limit'].includes(info.error))
|
|
43
|
+
&& (info.models.includes(modelKey(m)) || info.models.length < store.policy.sourceModels))));
|
|
44
|
+
if (!candidate) break;
|
|
45
|
+
attempted.add(modelKey(candidate));
|
|
46
|
+
try {
|
|
47
|
+
// Only a known route failure justifies bypassing source backoff for an alternate model.
|
|
48
|
+
const result = await evolve(store, id, contextFor(ctx, candidate), signal, complete, pass ? 'fallback' : retry, timeoutMs);
|
|
49
|
+
if (result) return true;
|
|
50
|
+
// A concurrent claim, terminal source or shared budget rejection cannot authorize another call.
|
|
51
|
+
break;
|
|
52
|
+
} catch (error) {
|
|
53
|
+
lastError = error;
|
|
54
|
+
const code = failureCode(error, signal);
|
|
55
|
+
const reroute = ['auth','quota','rate_limit','request','context_limit'].includes(code)
|
|
56
|
+
|| (['provider','timeout','invalid_output','output_limit','interrupted'].includes(code)
|
|
57
|
+
&& !store.routeAvailable(modelKey(candidate), candidate.provider));
|
|
58
|
+
if (!reroute || retry === true || signal.aborted) throw error;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
store.checked(id); // Round-robin past sources waiting on unavailable routes, without consuming attempts.
|
|
62
|
+
if (lastError) throw lastError;
|
|
63
|
+
return false;
|
|
64
|
+
}
|
package/src/memory/search.ts
CHANGED
|
@@ -61,7 +61,7 @@ export function features(text: string, includeSingle = false): Set<string> {
|
|
|
61
61
|
const result = new Set<string>();
|
|
62
62
|
let prose = redact(text).replace(/\[REDACTED[^\]\n]*\]/gu, " ");
|
|
63
63
|
prose = prose.replace(LITERALS, (literal) => {
|
|
64
|
-
const clean = literal.replace(/[.:]+$/u,
|
|
64
|
+
const clean = literal.replace(/[.:]+$/u, ''); // Literal resource identity is case-sensitive, unlike prose.
|
|
65
65
|
result.add(`literal:${clean}`);
|
|
66
66
|
result.add(`literal:${clean.split("/").at(-1)}`);
|
|
67
67
|
return " ";
|
|
@@ -94,7 +94,7 @@ export function featureOffset(text: string, feature: string): number {
|
|
|
94
94
|
pattern.lastIndex = 0;
|
|
95
95
|
return pattern.exec(prose)?.index ?? -1;
|
|
96
96
|
}
|
|
97
|
-
return
|
|
97
|
+
return feature.startsWith('literal:') ? text.indexOf(feature.slice(8)) : prose.toLowerCase().indexOf(feature);
|
|
98
98
|
}
|
|
99
99
|
|
|
100
100
|
export function validSearchTerms(value: unknown): value is string[] | undefined {
|