pi-memory-evolution 0.2.0
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 +246 -0
- package/LICENSE +21 -0
- package/README.md +106 -0
- package/docs/conversation-recall.md +94 -0
- package/docs/core-quality.md +224 -0
- package/docs/design.md +328 -0
- package/docs/progress-pipeline.md +188 -0
- package/docs/quality-validation.md +85 -0
- package/docs/review-0.2.md +82 -0
- package/docs/testing.md +102 -0
- package/docs/usage.md +386 -0
- package/package.json +61 -0
- package/src/adapter/operations.ts +95 -0
- package/src/adapter/pi-api.ts +24 -0
- package/src/adapter/progress-observation.ts +83 -0
- package/src/adapter/session-context.ts +36 -0
- package/src/child-process.ts +8 -0
- package/src/index.ts +256 -0
- package/src/injector/digest.ts +29 -0
- package/src/memory/evolution.ts +64 -0
- package/src/memory/extractor.ts +63 -0
- package/src/memory/feedback.ts +11 -0
- package/src/memory/learning.ts +24 -0
- package/src/memory/legacy.ts +92 -0
- package/src/memory/memory-store.ts +502 -0
- package/src/memory/privacy.ts +51 -0
- package/src/memory/progress-targets.ts +52 -0
- package/src/memory/quality.ts +81 -0
- package/src/memory/query.ts +87 -0
- package/src/memory/recovery.ts +23 -0
- package/src/memory/retriever.ts +181 -0
- package/src/memory/search.ts +105 -0
- package/src/memory/sqlite.ts +7 -0
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import type { DurableMemory, MemoryKind } from "./memory-store.ts";
|
|
4
|
+
import { extractStructuredMemories } from "./extractor.ts";
|
|
5
|
+
import { fingerprint, redact } from "./privacy.ts";
|
|
6
|
+
|
|
7
|
+
/** One-time, read-only import. Unknown project scope is quarantined, never guessed. */
|
|
8
|
+
export function loadLegacyMemories(dir: string): DurableMemory[] {
|
|
9
|
+
const records = new Map<string, Record<string, any>>();
|
|
10
|
+
for (const record of lines(join(dir, "memories.jsonl"))) {
|
|
11
|
+
if (record.version !== 1 || typeof record.id !== "string" || typeof record.content !== "string"
|
|
12
|
+
|| !record.id || typeof record.sourceEntryId !== "string" || !record.sourceEntryId
|
|
13
|
+
|| typeof record.createdAt !== "string" || !Number.isFinite(Date.parse(record.createdAt))
|
|
14
|
+
|| !["compaction_summary", "fact", "preference", "decision", "project_state"].includes(record.kind)) throw new Error("Invalid legacy memory; import stopped");
|
|
15
|
+
if (!records.has(record.id)) records.set(record.id, { ...record, suppressedHashes: [], status: record.status ?? "provisional" });
|
|
16
|
+
}
|
|
17
|
+
const mutedSources = new Set<string>();
|
|
18
|
+
const suppressedContent = new Set<string>();
|
|
19
|
+
for (const action of lines(join(dir, "memory-actions.jsonl"))) {
|
|
20
|
+
if (action.version !== 1 || typeof action.memoryId !== "string" || typeof action.createdAt !== "string" || !Number.isFinite(Date.parse(action.createdAt))
|
|
21
|
+
|| !["confirm", "correct", "forget", "pin", "unpin", "conflict", "resolve"].includes(action.type)) throw new Error("Invalid legacy action; import stopped");
|
|
22
|
+
const target = records.get(action.memoryId);
|
|
23
|
+
if (!target) throw new Error("Legacy action target missing; import stopped");
|
|
24
|
+
target.updatedAt = action.createdAt;
|
|
25
|
+
if (action.type === "correct") {
|
|
26
|
+
if (typeof action.content !== "string" || !action.content.trim()) throw new Error("Invalid legacy correction");
|
|
27
|
+
const kept = new Set(extractStructuredMemories(action.content, Infinity).map((c) => fingerprint(c.content)));
|
|
28
|
+
const removed = target.kind === "compaction_summary"
|
|
29
|
+
? extractStructuredMemories(target.content, Infinity).map((c) => fingerprint(c.content)).filter((hash) => !kept.has(hash))
|
|
30
|
+
: [fingerprint(redact(target.content))];
|
|
31
|
+
for (const hash of removed) suppressedContent.add(hash);
|
|
32
|
+
target.suppressedHashes = [...new Set([...target.suppressedHashes, ...removed])];
|
|
33
|
+
target.content = action.content; target.status = "confirmed";
|
|
34
|
+
} else if (action.type === "forget") target.status = "forgotten";
|
|
35
|
+
else if (action.type === "confirm" || action.type === "resolve") target.status = "confirmed";
|
|
36
|
+
else if (action.type === "pin") target.layer = "pinned";
|
|
37
|
+
else if (action.type === "unpin") target.layer = "durable";
|
|
38
|
+
else if (action.type === "conflict") {
|
|
39
|
+
const other = records.get(action.conflictWith);
|
|
40
|
+
if (!other) throw new Error("Legacy conflict target missing");
|
|
41
|
+
target.status = other.status = "conflicted";
|
|
42
|
+
mutedSources.add(other.sourceEntryId);
|
|
43
|
+
for (const sibling of records.values()) if (sibling.sourceEntryId === other.sourceEntryId) sibling.status = "conflicted";
|
|
44
|
+
}
|
|
45
|
+
if (["correct", "forget", "conflict"].includes(action.type)) {
|
|
46
|
+
if (target.kind === "compaction_summary") {
|
|
47
|
+
const kept = new Set(action.type === "correct" ? extractStructuredMemories(target.content, Infinity).map((c) => fingerprint(c.content)) : []);
|
|
48
|
+
if (action.type === "correct") mutedSources.delete(target.sourceEntryId);
|
|
49
|
+
for (const sibling of records.values()) {
|
|
50
|
+
if (sibling.id === target.id || sibling.sourceEntryId !== target.sourceEntryId) continue;
|
|
51
|
+
if (kept.has(fingerprint(sibling.content))) sibling.suppressedHashes = [...new Set([...sibling.suppressedHashes, ...target.suppressedHashes])];
|
|
52
|
+
else sibling.status = action.type === "conflict" ? "conflicted" : "forgotten";
|
|
53
|
+
}
|
|
54
|
+
} else {
|
|
55
|
+
mutedSources.add(target.sourceEntryId);
|
|
56
|
+
if (action.type !== "correct") suppressedContent.add(fingerprint(redact(target.content)));
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
const memories: DurableMemory[] = [];
|
|
61
|
+
const childContent = new Set([...records.values()].filter((r) => r.kind !== "compaction_summary").map((r) => fingerprint(r.content)));
|
|
62
|
+
for (const record of records.values()) {
|
|
63
|
+
if (record.kind === "compaction_summary") {
|
|
64
|
+
if (mutedSources.has(record.sourceEntryId) || ["forgotten", "conflicted"].includes(record.status)) continue;
|
|
65
|
+
for (const claim of extractStructuredMemories(record.content)) {
|
|
66
|
+
// Preserve child tombstones rather than re-extracting a forgotten fact.
|
|
67
|
+
if (childContent.has(fingerprint(claim.content)) || suppressedContent.has(fingerprint(claim.content))) continue;
|
|
68
|
+
memories.push(convert({ ...record, ...claim, id: `legacy:${fingerprint(record.id + claim.content)}` }));
|
|
69
|
+
}
|
|
70
|
+
} else memories.push(convert(record));
|
|
71
|
+
}
|
|
72
|
+
return memories;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function convert(record: Record<string, any>): DurableMemory {
|
|
76
|
+
return { id: record.id, kind: record.kind as MemoryKind, content: redact(record.content), scope: "legacy",
|
|
77
|
+
sourceEntryId: record.sourceEntryId, createdAt: new Date(record.createdAt).toISOString(),
|
|
78
|
+
updatedAt: new Date(record.updatedAt ?? record.createdAt).toISOString(), revision: 1,
|
|
79
|
+
layer: record.layer === "pinned" ? "pinned" : "durable", status: record.status,
|
|
80
|
+
...(record.suppressedHashes?.length ? { suppressedHashes: record.suppressedHashes } : {}) };
|
|
81
|
+
}
|
|
82
|
+
function lines(path: string): Record<string, any>[] {
|
|
83
|
+
let text: string;
|
|
84
|
+
try { text = readFileSync(path, "utf8"); }
|
|
85
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return []; throw new Error("Legacy ledger unreadable; import stopped"); }
|
|
86
|
+
return text.split("\n").filter((line) => line.trim()).map((line) => {
|
|
87
|
+
let value: unknown;
|
|
88
|
+
try { value = JSON.parse(line); } catch { throw new Error("Damaged legacy ledger; import stopped (original preserved)"); }
|
|
89
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Invalid legacy ledger row");
|
|
90
|
+
return value as Record<string, any>;
|
|
91
|
+
});
|
|
92
|
+
}
|
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
import { Database } from "./sqlite.ts";
|
|
2
|
+
import { chmodSync, closeSync, lstatSync, mkdirSync, openSync } from "node:fs";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
5
|
+
import { extractStructuredMemories, type Claim } from "./extractor.ts";
|
|
6
|
+
import { loadLegacyMemories } from "./legacy.ts";
|
|
7
|
+
import { clipBytes, fingerprint, redact } from "./privacy.ts";
|
|
8
|
+
import { validSearchTerms } from "./search.ts";
|
|
9
|
+
import { sourceEvidence, validEvidence, validFeedback, mayReplace, FEEDBACK_VERDICTS, type Evidence, type MemoryFeedback, type FeedbackVerdict } from "./quality.ts";
|
|
10
|
+
import { EVOLUTION_TIMEOUT_MS, LEASE_GRACE_MS, MAX_FAILURES, FAILURE_CODES, EvolutionError, retryAt, type FailureCode } from "./recovery.ts";
|
|
11
|
+
|
|
12
|
+
export type RetryMode = boolean | "auto";
|
|
13
|
+
// Rechecked atomically when claiming: selection alone never grants model-call authority.
|
|
14
|
+
const automaticEligibility = "((state='pending' AND retry_at<=?) OR (state='failed' AND failures<" + MAX_FAILURES + " AND retry_at<=?))";
|
|
15
|
+
|
|
16
|
+
export type MemoryKind = "fact" | "preference" | "decision" | "project_state";
|
|
17
|
+
export interface DurableMemory {
|
|
18
|
+
id: string;
|
|
19
|
+
kind: MemoryKind;
|
|
20
|
+
content: string;
|
|
21
|
+
/** Capture origin, not a recall boundary or a guaranteed project identity. */
|
|
22
|
+
scope: string;
|
|
23
|
+
sourceEntryId: string;
|
|
24
|
+
createdAt: string;
|
|
25
|
+
updatedAt: string;
|
|
26
|
+
revision: number;
|
|
27
|
+
layer: "durable" | "pinned";
|
|
28
|
+
status: "provisional" | "confirmed" | "forgotten" | "conflicted";
|
|
29
|
+
/** Exact superseded content, carried across explicit legacy adoption. */
|
|
30
|
+
suppressedHashes?: string[];
|
|
31
|
+
searchTerms?: string[];
|
|
32
|
+
/** Host-assigned provenance; missing on old records means unknown, never verified. */
|
|
33
|
+
evidence?: Evidence;
|
|
34
|
+
feedback?: MemoryFeedback;
|
|
35
|
+
}
|
|
36
|
+
export interface Source {
|
|
37
|
+
id: string;
|
|
38
|
+
scope: string;
|
|
39
|
+
kind: "summary" | "user" | "progress";
|
|
40
|
+
content: string;
|
|
41
|
+
createdAt: string;
|
|
42
|
+
/** Host-selected existing project-state IDs; tools cannot nominate their own targets. */
|
|
43
|
+
targets?: string[];
|
|
44
|
+
}
|
|
45
|
+
export interface EvolutionRun {
|
|
46
|
+
source: Source;
|
|
47
|
+
attempt: number;
|
|
48
|
+
generation: number;
|
|
49
|
+
memories: DurableMemory[];
|
|
50
|
+
}
|
|
51
|
+
interface Event {
|
|
52
|
+
id: string;
|
|
53
|
+
at: string;
|
|
54
|
+
scope: string;
|
|
55
|
+
actor: string;
|
|
56
|
+
reason: string;
|
|
57
|
+
before: (DurableMemory | null)[];
|
|
58
|
+
after: DurableMemory[];
|
|
59
|
+
}
|
|
60
|
+
export type MemoryAction = "correct" | "forget" | "pin" | "unpin" | "conflict" | "resolve" | "adopt";
|
|
61
|
+
export const MEMORY_KINDS = new Set<MemoryKind>(["fact", "preference", "decision", "project_state"]);
|
|
62
|
+
const active = (m: DurableMemory) => m.status !== "forgotten" && m.status !== "conflicted";
|
|
63
|
+
|
|
64
|
+
/** One transactional database: no cross-file commits, process locks or replay scans. */
|
|
65
|
+
export class MemoryStore {
|
|
66
|
+
private db: Database;
|
|
67
|
+
private cache = new Map<string, { version: number; memories: DurableMemory[] }>();
|
|
68
|
+
readonly stateDir: string;
|
|
69
|
+
constructor(stateDir: string) {
|
|
70
|
+
this.stateDir = stateDir;
|
|
71
|
+
mkdirSync(stateDir, { recursive: true, mode: 0o700 });
|
|
72
|
+
const file = join(stateDir, "memory.sqlite");
|
|
73
|
+
try { const fd = openSync(file, "wx", 0o600); closeSync(fd); }
|
|
74
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; }
|
|
75
|
+
if (!lstatSync(file).isFile() || lstatSync(file).isSymbolicLink()) throw new Error("Memory database must be a regular file");
|
|
76
|
+
chmodSync(file, 0o600);
|
|
77
|
+
this.db = new Database(file);
|
|
78
|
+
try {
|
|
79
|
+
this.db.exec("PRAGMA busy_timeout=5000");
|
|
80
|
+
if (this.db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='metadata'").get()) {
|
|
81
|
+
const schema = this.db.prepare("SELECT value FROM metadata WHERE key='schema'").get();
|
|
82
|
+
if (schema && !["2", "3", "4", "5"].includes(String(schema.value))) throw new Error("Unsupported memory database version");
|
|
83
|
+
}
|
|
84
|
+
this.db.exec(`PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL;
|
|
85
|
+
CREATE TABLE IF NOT EXISTS metadata (key TEXT PRIMARY KEY, value TEXT NOT NULL);
|
|
86
|
+
CREATE TABLE IF NOT EXISTS memories (id TEXT PRIMARY KEY, scope TEXT NOT NULL, hash TEXT NOT NULL, data TEXT NOT NULL);
|
|
87
|
+
CREATE INDEX IF NOT EXISTS memories_scope_hash ON memories(scope,hash);
|
|
88
|
+
CREATE TABLE IF NOT EXISTS sources (id TEXT PRIMARY KEY, data TEXT NOT NULL, state TEXT NOT NULL DEFAULT 'pending', attempt INTEGER NOT NULL DEFAULT 0, lease INTEGER NOT NULL DEFAULT 0);
|
|
89
|
+
CREATE INDEX IF NOT EXISTS sources_scope_state ON sources(json_extract(data,'$.scope'),state);
|
|
90
|
+
CREATE TABLE IF NOT EXISTS events (id TEXT PRIMARY KEY, scope TEXT NOT NULL, data TEXT NOT NULL);
|
|
91
|
+
CREATE INDEX IF NOT EXISTS events_scope ON events(scope);
|
|
92
|
+
CREATE TABLE IF NOT EXISTS blocked (scope TEXT NOT NULL, hash TEXT NOT NULL, PRIMARY KEY(scope,hash));`);
|
|
93
|
+
this.transaction(() => {
|
|
94
|
+
const schema = this.db.prepare("SELECT value FROM metadata WHERE key='schema'").get();
|
|
95
|
+
if (schema && !["2", "3", "4", "5"].includes(String(schema.value))) throw new Error("Unsupported memory database version");
|
|
96
|
+
if (!["4", "5"].includes(String(schema?.value))) {
|
|
97
|
+
const columns = new Set(this.db.prepare("PRAGMA table_info(sources)").all().map((r) => r.name));
|
|
98
|
+
for (const [name, type] of [["failures", "INTEGER NOT NULL DEFAULT 0"], ["retry_at", "INTEGER NOT NULL DEFAULT 0"],
|
|
99
|
+
["failed_at", "INTEGER NOT NULL DEFAULT 0"], ["last_error", "TEXT NOT NULL DEFAULT ''"]]) {
|
|
100
|
+
if (!columns.has(name)) this.db.exec(`ALTER TABLE sources ADD COLUMN ${name} ${type}`);
|
|
101
|
+
}
|
|
102
|
+
// Old errors have no known cause/time. Make them eligible without inventing either.
|
|
103
|
+
this.db.exec("UPDATE sources SET failures=MIN(MAX(attempt,1),5),last_error='unknown' WHERE state='failed' AND failures=0");
|
|
104
|
+
}
|
|
105
|
+
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'");
|
|
106
|
+
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)");
|
|
107
|
+
if (!schema) {
|
|
108
|
+
const legacy = loadLegacyMemories(stateDir);
|
|
109
|
+
if (legacy.length) this.record("migration", "Import legacy JSONL; originals unchanged", legacy, "legacy");
|
|
110
|
+
this.db.prepare("INSERT INTO metadata VALUES ('schema','5')").run();
|
|
111
|
+
} else if (schema.value !== "5") {
|
|
112
|
+
this.db.prepare("UPDATE metadata SET value='5' WHERE key='schema'").run();
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
} catch (error) { this.db.close(); throw error; }
|
|
116
|
+
}
|
|
117
|
+
close(): void { this.db.close(); }
|
|
118
|
+
private transaction<T>(fn: () => T): T {
|
|
119
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
120
|
+
try { const value = fn(); this.db.exec("COMMIT"); this.cache.clear(); return value; }
|
|
121
|
+
catch (error) { this.db.exec("ROLLBACK"); this.cache.clear(); throw error; }
|
|
122
|
+
}
|
|
123
|
+
readMemories(scope?: string): DurableMemory[] {
|
|
124
|
+
const version = Number(this.db.prepare("PRAGMA data_version").get()!.data_version);
|
|
125
|
+
const key = scope ?? "";
|
|
126
|
+
let cached = this.cache.get(key);
|
|
127
|
+
if (!cached || cached.version !== version) {
|
|
128
|
+
const rows = scope === undefined ? this.db.prepare("SELECT id,scope,hash,data FROM memories").all()
|
|
129
|
+
: this.db.prepare("SELECT id,scope,hash,data FROM memories WHERE scope=?").all(scope);
|
|
130
|
+
const memories = rows.map((row) => {
|
|
131
|
+
const memory: unknown = JSON.parse(String(row.data));
|
|
132
|
+
if (!isMemory(memory) || memory.id !== row.id || memory.scope !== row.scope || fingerprint(memory.content) !== row.hash) throw new Error("Invalid memory record; recall stopped");
|
|
133
|
+
return memory;
|
|
134
|
+
});
|
|
135
|
+
cached = { version, memories };
|
|
136
|
+
this.cache.set(key, cached);
|
|
137
|
+
}
|
|
138
|
+
return cached.memories.map((m) => structuredClone(m));
|
|
139
|
+
}
|
|
140
|
+
private get(id: string): DurableMemory | undefined {
|
|
141
|
+
const row = this.db.prepare("SELECT scope,hash,data FROM memories WHERE id=?").get(id);
|
|
142
|
+
if (!row) return undefined;
|
|
143
|
+
const data: unknown = JSON.parse(String(row.data));
|
|
144
|
+
if (!isMemory(data) || data.id !== id || data.scope !== row.scope || fingerprint(data.content) !== row.hash) throw new Error("Invalid memory record");
|
|
145
|
+
return data;
|
|
146
|
+
}
|
|
147
|
+
private generation(scope: string): number {
|
|
148
|
+
return Number(this.db.prepare("SELECT COALESCE(MAX(rowid),0) AS n FROM events WHERE scope=?").get(scope)!.n);
|
|
149
|
+
}
|
|
150
|
+
private record(actor: string, reason: string, after: DurableMemory[], scope: string): string {
|
|
151
|
+
const before = after.map((m) => this.get(m.id) ?? null);
|
|
152
|
+
const at = new Date().toISOString();
|
|
153
|
+
const event: Event = { id: randomUUID(), at, actor, reason, scope, before, after };
|
|
154
|
+
for (const memory of after) {
|
|
155
|
+
if (!isMemory(memory)) throw new Error("Invalid memory update");
|
|
156
|
+
this.db.prepare("INSERT INTO memories VALUES (?,?,?,?) ON CONFLICT(id) DO UPDATE SET scope=excluded.scope,hash=excluded.hash,data=excluded.data")
|
|
157
|
+
.run(memory.id, memory.scope, fingerprint(memory.content), JSON.stringify(memory));
|
|
158
|
+
for (const hash of memory.suppressedHashes ?? [])
|
|
159
|
+
this.db.prepare("INSERT OR IGNORE INTO blocked VALUES (?,?)").run(memory.scope, hash);
|
|
160
|
+
}
|
|
161
|
+
this.db.prepare("INSERT INTO events VALUES (?,?,?)").run(event.id, scope, JSON.stringify(event));
|
|
162
|
+
this.cache.clear();
|
|
163
|
+
return event.id;
|
|
164
|
+
}
|
|
165
|
+
private block(memory: DurableMemory, keepSource?: string): void {
|
|
166
|
+
const hash = fingerprint(memory.content);
|
|
167
|
+
this.db.prepare("INSERT OR IGNORE INTO blocked VALUES (?,?)").run(memory.scope, hash);
|
|
168
|
+
const original = this.db.prepare("SELECT data FROM sources WHERE id=?").get(memory.sourceEntryId);
|
|
169
|
+
const body = original ? parseSource(original.data).content : undefined;
|
|
170
|
+
// A claim may have been repeated in several sources, not only its first parent.
|
|
171
|
+
const pending = this.db.prepare("SELECT id,data FROM sources WHERE json_extract(data,'$.scope')=? AND state!='done'").all(memory.scope);
|
|
172
|
+
for (const row of pending) {
|
|
173
|
+
if (row.id === keepSource) continue;
|
|
174
|
+
const source = parseSource(row.data);
|
|
175
|
+
if (source.id === memory.sourceEntryId || source.targets?.includes(memory.id) || source.content === body || source.content.includes(memory.content)
|
|
176
|
+
|| extractStructuredMemories(source.content, Infinity).some((claim) => fingerprint(claim.content) === hash))
|
|
177
|
+
this.db.prepare("UPDATE sources SET state='done',lease=0 WHERE id=?").run(source.id);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
private claim(source: Source, item: Claim, method: "local" | "model" = "local"): DurableMemory | undefined {
|
|
181
|
+
const content = redact(item.content).trim();
|
|
182
|
+
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");
|
|
183
|
+
const id = fingerprint(JSON.stringify([source.scope, item.kind, content]));
|
|
184
|
+
if (this.get(id) || this.db.prepare("SELECT 1 FROM blocked WHERE scope=? AND hash=?").get(source.scope, fingerprint(content))) return undefined;
|
|
185
|
+
// Also respect forgotten legacy records whose ids predate content-addressing.
|
|
186
|
+
if (this.db.prepare("SELECT 1 FROM memories WHERE scope=? AND hash=?").get(source.scope, fingerprint(content))) return undefined;
|
|
187
|
+
return { id, kind: item.kind, content, scope: source.scope, sourceEntryId: source.id,
|
|
188
|
+
createdAt: source.createdAt, updatedAt: source.createdAt, revision: 1, layer: "durable", status: "provisional",
|
|
189
|
+
evidence: sourceEvidence(source, method),
|
|
190
|
+
...(item.searchTerms ? { searchTerms: [...new Set(item.searchTerms)] } : {}) };
|
|
191
|
+
}
|
|
192
|
+
/** Persist raw evidence + bounded local claims once, atomically. Raw sources are never recalled. */
|
|
193
|
+
capture(input: Source): boolean {
|
|
194
|
+
if (!isSource(input)) throw new Error("Invalid memory source");
|
|
195
|
+
const source = { ...input, createdAt: new Date(input.createdAt).toISOString(), content: clipBytes(redact(input.content), 32_000) };
|
|
196
|
+
return this.transaction(() => {
|
|
197
|
+
if (this.db.prepare("SELECT 1 FROM sources WHERE id=?").get(source.id)) return false;
|
|
198
|
+
this.db.prepare("INSERT INTO sources(id,data) VALUES (?,?)").run(source.id, JSON.stringify(source));
|
|
199
|
+
const claims = source.kind === "summary" ? extractStructuredMemories(source.content) : [];
|
|
200
|
+
const memories = new Map<string, DurableMemory>();
|
|
201
|
+
for (const claim of claims) {
|
|
202
|
+
const memory = this.claim(source, claim);
|
|
203
|
+
if (memory && !memories.has(fingerprint(memory.content))) memories.set(fingerprint(memory.content), memory);
|
|
204
|
+
}
|
|
205
|
+
this.record("local", `Capture ${source.id}`, [...memories.values()], source.scope);
|
|
206
|
+
return true;
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
pending(scope?: string, retry: RetryMode = false, now = Date.now()): string | undefined {
|
|
210
|
+
const row = this.db.prepare(`SELECT id FROM sources WHERE ${scope === undefined ? "" : "json_extract(data,'$.scope')=? AND"}
|
|
211
|
+
${retry === "auto" ? automaticEligibility : `(state='pending' OR (state='running' AND lease<=?) ${retry ? "OR state='failed'" : ""})`}
|
|
212
|
+
ORDER BY ${retry === "auto" ? "retry_at ASC, rowid ASC" : "rowid DESC"} LIMIT 1`)
|
|
213
|
+
.get(...(scope === undefined ? [] : [scope]), now, ...(retry === "auto" ? [now] : []));
|
|
214
|
+
return row ? String(row.id) : undefined;
|
|
215
|
+
}
|
|
216
|
+
beginEvolution(id: string, retry: RetryMode = false, timeoutMs = EVOLUTION_TIMEOUT_MS, now = Date.now()): EvolutionRun | undefined {
|
|
217
|
+
return this.transaction(() => {
|
|
218
|
+
const changed = this.db.prepare(`UPDATE sources SET state='running', attempt=attempt+1, lease=? WHERE id=? AND
|
|
219
|
+
${retry === "auto" ? automaticEligibility : `(state='pending' OR (state='running' AND lease<=?) ${retry ? "OR state='failed'" : ""})`}`)
|
|
220
|
+
.run(now + timeoutMs + LEASE_GRACE_MS, id, now, ...(retry === "auto" ? [now] : []));
|
|
221
|
+
if (!changed.changes) return undefined;
|
|
222
|
+
const row = this.db.prepare("SELECT data,attempt FROM sources WHERE id=?").get(id)!;
|
|
223
|
+
const source = parseSource(row.data);
|
|
224
|
+
if (source.id !== id) throw new Error("Invalid source identity");
|
|
225
|
+
const memories = this.readMemories(source.scope).filter((m) => m.scope === source.scope && active(m)
|
|
226
|
+
&& (source.kind !== "progress" || (m.kind === "project_state" && source.targets!.includes(m.id))))
|
|
227
|
+
.sort((a,b) => Date.parse(b.updatedAt)-Date.parse(a.updatedAt)).slice(0, 32);
|
|
228
|
+
return { source, attempt: Number(row.attempt), generation: this.generation(source.scope), memories };
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
finishEvolution(run: EvolutionRun, claims: Claim[], model: string): string {
|
|
232
|
+
return this.transaction(() => {
|
|
233
|
+
const job = this.db.prepare("SELECT state,attempt FROM sources WHERE id=?").get(run.source.id);
|
|
234
|
+
if (job?.state !== "running" || job.attempt !== run.attempt || this.generation(run.source.scope) !== run.generation) throw new EvolutionError("stale");
|
|
235
|
+
const after = new Map<string, DurableMemory>();
|
|
236
|
+
const targets = new Set<string>();
|
|
237
|
+
let weakerConflicts = 0;
|
|
238
|
+
const incoming = sourceEvidence(run.source, "model");
|
|
239
|
+
const stage = (memory: DurableMemory) => {
|
|
240
|
+
if (![...after.values()].some((m) => active(m) && fingerprint(m.content) === fingerprint(memory.content))) after.set(memory.id, memory);
|
|
241
|
+
};
|
|
242
|
+
const annotate = (memory: DurableMemory | undefined, claim: Claim) => {
|
|
243
|
+
if (!memory || !claim.searchTerms || memory.layer === "pinned") return;
|
|
244
|
+
const current = after.get(memory.id) ?? memory;
|
|
245
|
+
const searchTerms = [...new Set(claim.searchTerms)];
|
|
246
|
+
if (active(current) && JSON.stringify(current.searchTerms) !== JSON.stringify(searchTerms))
|
|
247
|
+
after.set(memory.id, { ...current, searchTerms, revision: memory.revision + 1 });
|
|
248
|
+
};
|
|
249
|
+
for (const claim of claims) {
|
|
250
|
+
if (!validSearchTerms(claim.searchTerms)) throw new Error("Invalid search terms");
|
|
251
|
+
if (run.source.kind === "progress" && (claim.kind !== "project_state" || !claim.replaces || !run.source.targets!.includes(claim.replaces)))
|
|
252
|
+
throw new Error("Progress observations may only update nominated project-state records");
|
|
253
|
+
if (claim.replaces) {
|
|
254
|
+
const old = run.memories.find((m) => m.id === claim.replaces);
|
|
255
|
+
if (!old || old.scope !== run.source.scope || targets.has(old.id) || old.layer === "pinned"
|
|
256
|
+
|| (run.source.kind === "progress" && old.kind !== "project_state")
|
|
257
|
+
|| Date.parse(old.updatedAt) > Date.parse(run.source.createdAt)) throw new Error("Invalid replacement target");
|
|
258
|
+
targets.add(old.id);
|
|
259
|
+
if (claim.kind !== old.kind) throw new Error("Replacement cannot change evidence kind");
|
|
260
|
+
if (fingerprint(old.content) === fingerprint(claim.content)) {
|
|
261
|
+
if (run.source.kind !== "summary" && mayReplace(old, incoming)) after.set(old.id, { ...old, sourceEntryId: run.source.id,
|
|
262
|
+
updatedAt: run.source.createdAt, evidence: incoming, status: "provisional", revision: old.revision + 1 });
|
|
263
|
+
annotate(old, claim); continue;
|
|
264
|
+
}
|
|
265
|
+
const next = this.claim(run.source, claim, "model");
|
|
266
|
+
// Local extraction may already have added the replacement from this source.
|
|
267
|
+
const existing = run.memories.find((m) => m.id !== old.id && m.kind === claim.kind && fingerprint(m.content) === fingerprint(claim.content));
|
|
268
|
+
if ((!next && !existing) || (existing && !active(after.get(existing.id) ?? existing))) continue;
|
|
269
|
+
if (existing && claims.some((c) => c.replaces === existing.id)) throw new Error("Cyclic memory replacement");
|
|
270
|
+
if (!mayReplace(old, incoming)) {
|
|
271
|
+
// Quarantine only this source's weaker variant; preserve stronger evidence.
|
|
272
|
+
const weaker = next ?? (existing?.sourceEntryId === run.source.id ? existing : undefined);
|
|
273
|
+
if (weaker) {
|
|
274
|
+
this.block(weaker, run.source.id);
|
|
275
|
+
after.set(weaker.id, { ...weaker, status: "conflicted", revision: weaker.revision + (next ? 0 : 1) });
|
|
276
|
+
for (const staged of after.values()) if (fingerprint(staged.content) === fingerprint(weaker.content))
|
|
277
|
+
after.set(staged.id, { ...staged, status: "conflicted" });
|
|
278
|
+
}
|
|
279
|
+
weakerConflicts++; continue;
|
|
280
|
+
}
|
|
281
|
+
this.block(old, run.source.id);
|
|
282
|
+
after.set(old.id, { ...old, status: "forgotten", updatedAt: run.source.createdAt, revision: old.revision + 1 });
|
|
283
|
+
if (next) stage(next);
|
|
284
|
+
else if (existing && existing.layer !== "pinned" && mayReplace(existing, incoming)
|
|
285
|
+
&& Date.parse(existing.updatedAt) <= Date.parse(run.source.createdAt)) {
|
|
286
|
+
after.set(existing.id, { ...existing, sourceEntryId: run.source.id, updatedAt: run.source.createdAt,
|
|
287
|
+
evidence: incoming, feedback: undefined, status: "provisional", revision: existing.revision + 1 });
|
|
288
|
+
annotate(existing, claim);
|
|
289
|
+
} else annotate(existing, claim);
|
|
290
|
+
} else {
|
|
291
|
+
const next = this.claim(run.source, claim, "model");
|
|
292
|
+
if (next) stage(next);
|
|
293
|
+
else annotate(run.memories.find((m) => m.kind === claim.kind && fingerprint(m.content) === fingerprint(claim.content)), claim);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
const event = this.record("model", `${model}: ${run.source.id}${weakerConflicts ? `; weaker replacements withheld=${weakerConflicts}` : ""}`, [...after.values()], run.source.scope);
|
|
297
|
+
this.db.prepare("UPDATE sources SET state='done',lease=0,failures=0,retry_at=0,failed_at=0,last_error='' WHERE id=?").run(run.source.id);
|
|
298
|
+
return event;
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
failEvolution(run: Pick<EvolutionRun, "source" | "attempt">, code: FailureCode = "unknown", now = Date.now()): void {
|
|
302
|
+
if (!FAILURE_CODES.includes(code)) throw new Error("Invalid failure code");
|
|
303
|
+
this.transaction(() => {
|
|
304
|
+
const job = this.db.prepare("SELECT failures FROM sources WHERE id=? AND attempt=? AND state='running'").get(run.source.id, run.attempt);
|
|
305
|
+
if (!job) return; // A newer owner or manual suppression wins.
|
|
306
|
+
if (code === "cancelled") {
|
|
307
|
+
// Shutdown/reload is not a failed model response and must not exhaust retry budgets.
|
|
308
|
+
this.db.prepare("UPDATE sources SET state=?,lease=0,retry_at=? WHERE id=?")
|
|
309
|
+
.run(Number(job.failures) >= MAX_FAILURES ? "failed" : "pending", now, run.source.id);
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
const failures = Number(job.failures) + 1;
|
|
313
|
+
this.db.prepare("UPDATE sources SET state='failed',lease=0,failures=?,retry_at=?,failed_at=?,last_error=? WHERE id=?")
|
|
314
|
+
.run(failures, retryAt(failures, now), now, code, run.source.id);
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
/** Crash recovery is local; an expired lease consumes a failure budget, not infinite restarts. */
|
|
318
|
+
recoverExpired(now = Date.now()): void {
|
|
319
|
+
for (const row of this.db.prepare("SELECT id,data,attempt FROM sources WHERE state='running' AND lease<=?").all(now)) {
|
|
320
|
+
this.failEvolution({ source: parseSource(row.data), attempt: Number(row.attempt) }, "interrupted", now);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
pausedJobs(): number {
|
|
324
|
+
return Number(this.db.prepare("SELECT COUNT(*) AS n FROM sources WHERE state='failed' AND failures>=?").get(MAX_FAILURES)!.n);
|
|
325
|
+
}
|
|
326
|
+
/** Bounded diagnostics: only fixed codes/times/counts, never provider bodies or source text. */
|
|
327
|
+
recoveryStatus(): string {
|
|
328
|
+
const count = Number(this.db.prepare("SELECT COUNT(*) AS n FROM sources WHERE state='failed'").get()!.n);
|
|
329
|
+
const rows = this.db.prepare("SELECT id,attempt,failures,retry_at,failed_at,last_error FROM sources WHERE state='failed' ORDER BY failed_at DESC,rowid DESC LIMIT 5").all();
|
|
330
|
+
const paused = this.pausedJobs();
|
|
331
|
+
const details = rows.map((r) => {
|
|
332
|
+
const code = FAILURE_CODES.includes(r.last_error as FailureCode) ? r.last_error : "unknown";
|
|
333
|
+
const failedAt = r.failed_at ? new Date(Number(r.failed_at)).toISOString() : "unknown (legacy)";
|
|
334
|
+
const next = Number(r.failures) >= MAX_FAILURES ? "paused; inspect model/auth or output, /memory evolve for one extra attempt"
|
|
335
|
+
: `nextRetry=${r.retry_at ? new Date(Number(r.retry_at)).toISOString() : "due now"}`;
|
|
336
|
+
return `${clipBytes(redact(String(r.id)), 160)}: ${code}; attempts=${r.attempt}; failures=${r.failures}/${MAX_FAILURES}; failedAt=${failedAt}; ${next}`;
|
|
337
|
+
});
|
|
338
|
+
return [`Automatic recovery: retrying=${count - paused}, paused=${paused} (failure limit ${MAX_FAILURES})`, ...details,
|
|
339
|
+
...(count > 5 ? [`${count - 5} more failed sources.`] : [])].join("\n");
|
|
340
|
+
}
|
|
341
|
+
/** Explicit exact-ID user feedback only. No inferred usage or self-reinforcement.
|
|
342
|
+
* Receipts survive undo/restart so replay cannot reapply an old verdict. */
|
|
343
|
+
feedback(id: string, verdict: FeedbackVerdict, sourceId = `manual:${randomUUID()}`, at = new Date().toISOString()): string | undefined {
|
|
344
|
+
if (!FEEDBACK_VERDICTS.has(verdict)) throw new Error("Invalid feedback verdict");
|
|
345
|
+
const signal = { verdict, sourceId, at };
|
|
346
|
+
const key = verdict === "useful" || verdict === "unhelpful" ? "utility" : "accuracy";
|
|
347
|
+
if (!validFeedback({ [key]: signal })) throw new Error("Invalid feedback source");
|
|
348
|
+
return this.transaction(() => {
|
|
349
|
+
const old = this.get(id);
|
|
350
|
+
if (!old) throw new Error("Unknown memory id");
|
|
351
|
+
if (this.db.prepare("SELECT 1 FROM feedback_receipts WHERE source_id=? AND memory_id=?").get(sourceId, id)) return undefined;
|
|
352
|
+
if (!active(old)) throw new Error("Resolve/correct the memory first");
|
|
353
|
+
const last = this.db.prepare(`SELECT MAX(at) AS at FROM feedback_receipts WHERE memory_id=? AND verdict IN (${key === "utility" ? "'useful','unhelpful'" : "'accurate','incorrect'"})`).get(id);
|
|
354
|
+
this.db.prepare("INSERT INTO feedback_receipts VALUES (?,?,?,?)").run(sourceId, id, verdict, Date.parse(at));
|
|
355
|
+
const previous = old.feedback?.[key];
|
|
356
|
+
if (Date.parse(at) < Date.parse(old.updatedAt) || (last?.at != null && Number(last.at) > Date.parse(at))
|
|
357
|
+
|| (previous && (Date.parse(previous.at) > Date.parse(at) || previous.verdict === verdict))) return undefined;
|
|
358
|
+
const next: DurableMemory = { ...old, feedback: { ...old.feedback, [key]: signal }, revision: old.revision + 1 };
|
|
359
|
+
const changes = [next];
|
|
360
|
+
if (verdict === "incorrect") {
|
|
361
|
+
this.block(old); next.status = "conflicted";
|
|
362
|
+
for (const duplicate of this.readMemories(old.scope)) if (duplicate.id !== id && active(duplicate)
|
|
363
|
+
&& fingerprint(duplicate.content) === fingerprint(old.content)) {
|
|
364
|
+
this.block(duplicate);
|
|
365
|
+
changes.push({ ...duplicate, status: "conflicted", feedback: { ...duplicate.feedback, accuracy: signal }, revision: duplicate.revision + 1 });
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
return this.record("manual", `Feedback ${verdict}: ${sourceId}`, changes, old.scope);
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
act(id: string, type: MemoryAction, value?: string): string {
|
|
372
|
+
return this.transaction(() => {
|
|
373
|
+
const old = this.get(id);
|
|
374
|
+
if (!old) throw new Error("Unknown memory id");
|
|
375
|
+
const at = new Date().toISOString();
|
|
376
|
+
let next = { ...old, updatedAt: ["pin", "unpin", "adopt", "conflict", "resolve"].includes(type) ? old.updatedAt : at, revision: old.revision + 1 };
|
|
377
|
+
const changes: DurableMemory[] = [];
|
|
378
|
+
if (type === "forget" || type === "correct") {
|
|
379
|
+
this.block(old);
|
|
380
|
+
next.suppressedHashes = [...new Set([...(old.suppressedHashes ?? []), fingerprint(old.content)])];
|
|
381
|
+
for (const duplicate of this.readMemories(old.scope)) {
|
|
382
|
+
if (duplicate.id !== id && duplicate.scope === old.scope && fingerprint(duplicate.content) === fingerprint(old.content))
|
|
383
|
+
changes.push({ ...duplicate, status: "forgotten", updatedAt: at, revision: duplicate.revision + 1 });
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
switch (type) {
|
|
387
|
+
case "correct": {
|
|
388
|
+
const content = redact(value ?? "").trim();
|
|
389
|
+
if (content.length < 4 || content.length > 480 || content.includes("[REDACTED")) throw new Error("Correction must be 4–480 characters without credentials");
|
|
390
|
+
next = { ...next, content, status: "confirmed", searchTerms: undefined, feedback: undefined,
|
|
391
|
+
evidence: { basis: "manual_correction", method: "manual", sourceId: `manual:${randomUUID()}`, at } }; break;
|
|
392
|
+
}
|
|
393
|
+
case "forget": next.status = "forgotten"; break;
|
|
394
|
+
case "pin": if (!active(old)) throw new Error("Resolve/correct the memory first"); next.layer = "pinned"; break;
|
|
395
|
+
case "unpin": next.layer = "durable"; break;
|
|
396
|
+
case "resolve":
|
|
397
|
+
if (old.status !== "conflicted") throw new Error("Memory is not conflicted");
|
|
398
|
+
next.status = "confirmed"; next.updatedAt = old.updatedAt;
|
|
399
|
+
next.feedback = { ...old.feedback, accuracy: { verdict: "accurate", at, sourceId: `manual:${randomUUID()}` } }; break;
|
|
400
|
+
case "conflict": {
|
|
401
|
+
const other = this.get(value ?? "");
|
|
402
|
+
if (!other || other.id === id || other.scope !== old.scope || !active(other) || !active(old)) throw new Error("Conflict needs two active memories in the same scope");
|
|
403
|
+
this.block(old); this.block(other);
|
|
404
|
+
next.status = "conflicted";
|
|
405
|
+
changes.push({ ...other, status: "conflicted", revision: other.revision + 1 }); break;
|
|
406
|
+
}
|
|
407
|
+
case "adopt":
|
|
408
|
+
if (old.scope !== "legacy" || !value) throw new Error("Only unscoped legacy memories can be adopted");
|
|
409
|
+
next.scope = resolve(value); break;
|
|
410
|
+
default: throw new Error("Unknown memory action");
|
|
411
|
+
}
|
|
412
|
+
return this.record("manual", type, [...changes, next], next.scope);
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
history(scope?: string): Event[] {
|
|
416
|
+
return this.db.prepare(scope ? "SELECT id,scope,data FROM events WHERE scope=? ORDER BY rowid DESC LIMIT 10" : "SELECT id,scope,data FROM events ORDER BY rowid DESC LIMIT 10")
|
|
417
|
+
.all(...(scope ? [scope] : [])).map((row) => parseEvent(row.data, row.id, row.scope));
|
|
418
|
+
}
|
|
419
|
+
undo(id: string): string {
|
|
420
|
+
return this.transaction(() => {
|
|
421
|
+
const row = this.db.prepare("SELECT scope,data FROM events WHERE id=?").get(id);
|
|
422
|
+
if (!row) throw new Error("Unknown event id");
|
|
423
|
+
const event = parseEvent(row.data, id, row.scope);
|
|
424
|
+
if (!event.after.length) throw new Error("Event has no memory changes");
|
|
425
|
+
const restored = event.after.map((after, i) => {
|
|
426
|
+
if (JSON.stringify(this.get(after.id)) !== JSON.stringify(after)) throw new Error("Memory changed since this event; undo refused");
|
|
427
|
+
this.block(after);
|
|
428
|
+
return { ...(event.before[i] ?? { ...after, status: "forgotten" as const }), revision: after.revision + 1,
|
|
429
|
+
suppressedHashes: [...new Set([...(event.before[i]?.suppressedHashes ?? []), ...(after.suppressedHashes ?? []), fingerprint(after.content)])] };
|
|
430
|
+
});
|
|
431
|
+
return this.record("manual", `Undo ${id}`, restored, event.scope);
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
/** Persistent transaction outcomes, not a claim that every completed job learned something. */
|
|
435
|
+
processingStatus(): string {
|
|
436
|
+
const rows = this.db.prepare("SELECT id,scope,data FROM events WHERE json_extract(data,'$.actor')='model' ORDER BY rowid DESC LIMIT 5").all();
|
|
437
|
+
const outcomes = rows.map(row => {
|
|
438
|
+
const event = parseEvent(row.data, row.id, row.scope);
|
|
439
|
+
return `${clipBytes(redact(event.reason), 200)}: changedRecords=${event.after.length}${event.after.length ? '' : ' (no memory changes)'}`;
|
|
440
|
+
});
|
|
441
|
+
return ['Processing outcomes: done means processed/retired, not necessarily learned.', ...outcomes,
|
|
442
|
+
...(rows.length ? [] : ['No model transactions yet.']), 'Use /memory learning for the last capture/nomination decision.'].join('\n');
|
|
443
|
+
}
|
|
444
|
+
status(): string {
|
|
445
|
+
if (this.db.prepare("SELECT value FROM metadata WHERE key='schema'").get()?.value !== "5") throw new Error("Invalid memory schema marker");
|
|
446
|
+
const health = this.db.prepare("PRAGMA quick_check").get();
|
|
447
|
+
if (health?.quick_check !== "ok") throw new Error("Memory database integrity check failed");
|
|
448
|
+
for (const row of this.db.prepare("SELECT id,data,state,attempt,lease,failures,retry_at,failed_at,last_error FROM sources").iterate()) {
|
|
449
|
+
const source = parseSource(row.data);
|
|
450
|
+
if (source.id !== row.id || !["pending", "running", "done", "failed"].includes(String(row.state))
|
|
451
|
+
|| ![row.attempt, row.lease, row.failures, row.retry_at, row.failed_at].every((v) => Number.isSafeInteger(v) && Number(v) >= 0)
|
|
452
|
+
|| (row.last_error !== "" && !FAILURE_CODES.includes(row.last_error as FailureCode))) throw new Error("Invalid source job");
|
|
453
|
+
}
|
|
454
|
+
for (const row of this.db.prepare("SELECT id,scope,data FROM events").iterate()) parseEvent(row.data, row.id, row.scope);
|
|
455
|
+
for (const row of this.db.prepare("SELECT source_id,memory_id,verdict,at FROM feedback_receipts").iterate()) {
|
|
456
|
+
if (typeof row.source_id !== "string" || !row.source_id || typeof row.memory_id !== "string" || !row.memory_id
|
|
457
|
+
|| !FEEDBACK_VERDICTS.has(row.verdict as FeedbackVerdict) || !Number.isSafeInteger(row.at)) throw new Error("Invalid feedback receipt");
|
|
458
|
+
}
|
|
459
|
+
const jobs = this.db.prepare("SELECT state,COUNT(*) AS n FROM sources GROUP BY state").all();
|
|
460
|
+
return `${this.readMemories().length} memories; ${jobs.map((j) => `${j.state}=${j.n}`).join(", ") || "no sources"}; SQLite ok (schema 5)\n${this.recoveryStatus()}\n${this.processingStatus()}`;
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function isSource(value: unknown): value is Source {
|
|
465
|
+
if (!value || typeof value !== "object") return false;
|
|
466
|
+
const s = value as Source;
|
|
467
|
+
return typeof s.id === "string" && !!s.id.trim() && typeof s.scope === "string" && !!s.scope.trim()
|
|
468
|
+
&& ["summary", "user", "progress"].includes(s.kind) && typeof s.content === "string"
|
|
469
|
+
&& (s.kind === "progress" ? (Array.isArray(s.targets) && s.targets.length > 0 && s.targets.length <= 8
|
|
470
|
+
&& s.targets.every((id) => typeof id === "string" && !!id.trim()) && new Set(s.targets).size === s.targets.length) : s.targets === undefined)
|
|
471
|
+
&& typeof s.createdAt === "string" && Number.isFinite(Date.parse(s.createdAt));
|
|
472
|
+
}
|
|
473
|
+
function parseSource(data: unknown): Source {
|
|
474
|
+
const source: unknown = JSON.parse(String(data));
|
|
475
|
+
if (!isSource(source)) throw new Error("Invalid source record");
|
|
476
|
+
return source;
|
|
477
|
+
}
|
|
478
|
+
function parseEvent(data: unknown, id: unknown, scope: unknown): Event {
|
|
479
|
+
const event = JSON.parse(String(data)) as Event | null;
|
|
480
|
+
if (!event || typeof event.id !== "string" || !event.id || typeof event.scope !== "string" || !event.scope
|
|
481
|
+
|| event.id !== id || event.scope !== scope
|
|
482
|
+
|| !["local", "manual", "model", "migration"].includes(event.actor) || typeof event.reason !== "string"
|
|
483
|
+
|| typeof event.at !== "string" || !Number.isFinite(Date.parse(event.at))
|
|
484
|
+
|| !Array.isArray(event.before) || !Array.isArray(event.after) || event.before.length !== event.after.length
|
|
485
|
+
|| !event.after.every((after, i) => isMemory(after) && (event.before[i] === null
|
|
486
|
+
|| (isMemory(event.before[i]) && event.before[i]!.id === after.id)))
|
|
487
|
+
|| new Set(event.after.map((m) => m.id)).size !== event.after.length) throw new Error("Invalid history record");
|
|
488
|
+
return event;
|
|
489
|
+
}
|
|
490
|
+
function isMemory(value: unknown): value is DurableMemory {
|
|
491
|
+
if (!value || typeof value !== "object") return false;
|
|
492
|
+
const m = value as DurableMemory;
|
|
493
|
+
return typeof m.id === "string" && !!m.id && MEMORY_KINDS.has(m.kind) && typeof m.content === "string" && !!m.content.trim()
|
|
494
|
+
&& typeof m.scope === "string" && !!m.scope && typeof m.sourceEntryId === "string" && !!m.sourceEntryId
|
|
495
|
+
&& validSearchTerms(m.searchTerms) && validEvidence(m.evidence) && validFeedback(m.feedback)
|
|
496
|
+
&& (m.evidence?.basis !== "tool_observation" || m.kind === "project_state")
|
|
497
|
+
&& (m.suppressedHashes === undefined || (Array.isArray(m.suppressedHashes) && m.suppressedHashes.every((h) => typeof h === "string" && /^[a-f0-9]{24}$/u.test(h))))
|
|
498
|
+
&& typeof m.createdAt === "string" && typeof m.updatedAt === "string"
|
|
499
|
+
&& Number.isFinite(Date.parse(m.createdAt)) && Number.isFinite(Date.parse(m.updatedAt))
|
|
500
|
+
&& Number.isInteger(m.revision) && m.revision > 0 && ["durable", "pinned"].includes(m.layer)
|
|
501
|
+
&& ["provisional", "confirmed", "forgotten", "conflicted"].includes(m.status);
|
|
502
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
const KEY = String.raw`["']?(?:api[_ -]?key|access[_ -]?token|refresh[_ -]?token|token|password|passwd|secret|credentials?|authorization|密码|口令|密钥)["']?\s*[:=:]`;
|
|
4
|
+
const ASSIGNMENT = new RegExp(KEY, "iu");
|
|
5
|
+
const QUOTED_VALUE = new RegExp(`${KEY}\\s*(?:"(?:\\\\[\\s\\S]|[^"\\\\])*(?:"|$)|'(?:\\\\[\\s\\S]|[^'\\\\])*(?:'|$))`, "giu");
|
|
6
|
+
const SECRET = /\bbearer\s+\S|\b(?:gh[opsu]_|github_pat_|sk-)[A-Za-z0-9_-]+|:\/\/[^\s/@]+:[^\s/@]+@|--(?:password|token|api-key|secret)\s+\S/iu;
|
|
7
|
+
|
|
8
|
+
/** Conservative block/line suppression, shared by ingestion, edits and recall. */
|
|
9
|
+
export function redact(content: string): string {
|
|
10
|
+
// Normalize first: removing a control must not assemble an unchecked password label.
|
|
11
|
+
const clean = content.replace(/\r\n?/gu, "\n")
|
|
12
|
+
.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/gu, "")
|
|
13
|
+
.replace(/-----BEGIN [^-\n]*PRIVATE KEY(?: BLOCK)?-----[\s\S]*?(?:-----END [^-\n]*PRIVATE KEY(?: BLOCK)?-----|$)/gu, "[REDACTED PRIVATE KEY]")
|
|
14
|
+
.replace(QUOTED_VALUE, "[REDACTED sensitive value]");
|
|
15
|
+
const lines: string[] = [];
|
|
16
|
+
let hidden: { indent: number; first: boolean } | undefined;
|
|
17
|
+
for (const line of clean.split("\n")) {
|
|
18
|
+
const indent = line.match(/^\s*/u)![0].length;
|
|
19
|
+
if (hidden) {
|
|
20
|
+
if (!line.trim()) continue;
|
|
21
|
+
if (hidden.first || indent > hidden.indent) { hidden.first = false; continue; }
|
|
22
|
+
hidden = undefined;
|
|
23
|
+
}
|
|
24
|
+
const assignment = ASSIGNMENT.exec(line);
|
|
25
|
+
if (assignment || SECRET.test(line)) {
|
|
26
|
+
lines.push("[REDACTED sensitive line]");
|
|
27
|
+
if (assignment) {
|
|
28
|
+
const tail = line.slice(assignment.index + assignment[0].length).trim();
|
|
29
|
+
hidden = { indent, first: !tail };
|
|
30
|
+
}
|
|
31
|
+
} else lines.push(line);
|
|
32
|
+
}
|
|
33
|
+
return lines.join("\n");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function fingerprint(content: string): string {
|
|
37
|
+
// Case and inner whitespace can be significant in paths, identifiers and literals.
|
|
38
|
+
return createHash("sha256").update(content.trim()).digest("hex").slice(0, 24);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** UTF-8 budget, never split a code point. */
|
|
42
|
+
export function clipBytes(text: string, budget: number): string {
|
|
43
|
+
let bytes = 0;
|
|
44
|
+
let result = "";
|
|
45
|
+
for (const char of text) {
|
|
46
|
+
bytes += Buffer.byteLength(char);
|
|
47
|
+
if (bytes > budget) break;
|
|
48
|
+
result += char;
|
|
49
|
+
}
|
|
50
|
+
return result;
|
|
51
|
+
}
|