aidimag 1.0.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/LICENSE +102 -0
- package/README.md +113 -0
- package/dist/capture/bootstrap.d.ts +25 -0
- package/dist/capture/bootstrap.js +188 -0
- package/dist/capture/commit-miner.d.ts +59 -0
- package/dist/capture/commit-miner.js +381 -0
- package/dist/capture/harvest.d.ts +50 -0
- package/dist/capture/harvest.js +207 -0
- package/dist/capture/pr-miner.d.ts +57 -0
- package/dist/capture/pr-miner.js +185 -0
- package/dist/capture/session-briefing.d.ts +36 -0
- package/dist/capture/session-briefing.js +150 -0
- package/dist/capture/session-extraction.d.ts +23 -0
- package/dist/capture/session-extraction.js +54 -0
- package/dist/capture/triage.d.ts +30 -0
- package/dist/capture/triage.js +108 -0
- package/dist/cli/commands/capture.d.ts +5 -0
- package/dist/cli/commands/capture.js +357 -0
- package/dist/cli/commands/hosts.d.ts +5 -0
- package/dist/cli/commands/hosts.js +98 -0
- package/dist/cli/commands/knowledge.d.ts +5 -0
- package/dist/cli/commands/knowledge.js +121 -0
- package/dist/cli/commands/memory.d.ts +6 -0
- package/dist/cli/commands/memory.js +392 -0
- package/dist/cli/commands/sync.d.ts +5 -0
- package/dist/cli/commands/sync.js +307 -0
- package/dist/cli/commands/tickets.d.ts +6 -0
- package/dist/cli/commands/tickets.js +328 -0
- package/dist/cli/commands/verify.d.ts +5 -0
- package/dist/cli/commands/verify.js +136 -0
- package/dist/cli/index.d.ts +19 -0
- package/dist/cli/index.js +46 -0
- package/dist/cli/shared.d.ts +37 -0
- package/dist/cli/shared.js +175 -0
- package/dist/config.d.ts +55 -0
- package/dist/config.js +51 -0
- package/dist/context/generate.d.ts +24 -0
- package/dist/context/generate.js +115 -0
- package/dist/critique/critique.d.ts +40 -0
- package/dist/critique/critique.js +110 -0
- package/dist/db/schema.d.ts +28 -0
- package/dist/db/schema.js +269 -0
- package/dist/db/store.d.ts +182 -0
- package/dist/db/store.js +906 -0
- package/dist/debug.d.ts +14 -0
- package/dist/debug.js +25 -0
- package/dist/embeddings/provider.d.ts +16 -0
- package/dist/embeddings/provider.js +100 -0
- package/dist/embeddings/search.d.ts +22 -0
- package/dist/embeddings/search.js +95 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +12 -0
- package/dist/knowledge/chunk.d.ts +9 -0
- package/dist/knowledge/chunk.js +78 -0
- package/dist/knowledge/extract.d.ts +34 -0
- package/dist/knowledge/extract.js +113 -0
- package/dist/knowledge/ingest.d.ts +79 -0
- package/dist/knowledge/ingest.js +305 -0
- package/dist/knowledge/llm.d.ts +21 -0
- package/dist/knowledge/llm.js +110 -0
- package/dist/mcp/server.d.ts +11 -0
- package/dist/mcp/server.js +532 -0
- package/dist/security/evidence.d.ts +11 -0
- package/dist/security/evidence.js +15 -0
- package/dist/security/seal.d.ts +3 -0
- package/dist/security/seal.js +28 -0
- package/dist/security/sync-push.d.ts +2 -0
- package/dist/security/sync-push.js +37 -0
- package/dist/security/url.d.ts +6 -0
- package/dist/security/url.js +67 -0
- package/dist/sync/client.d.ts +84 -0
- package/dist/sync/client.js +391 -0
- package/dist/sync/server.d.ts +75 -0
- package/dist/sync/server.js +659 -0
- package/dist/tickets/provider.d.ts +80 -0
- package/dist/tickets/provider.js +375 -0
- package/dist/types.d.ts +133 -0
- package/dist/types.js +5 -0
- package/dist/ui/page.d.ts +5 -0
- package/dist/ui/page.js +841 -0
- package/dist/ui/server.d.ts +10 -0
- package/dist/ui/server.js +437 -0
- package/dist/verify/check.d.ts +38 -0
- package/dist/verify/check.js +121 -0
- package/dist/verify/engine.d.ts +39 -0
- package/dist/verify/engine.js +178 -0
- package/dist/verify/hooks.d.ts +24 -0
- package/dist/verify/hooks.js +125 -0
- package/dist/verify/runners.d.ts +26 -0
- package/dist/verify/runners.js +193 -0
- package/package.json +78 -0
package/dist/db/store.js
ADDED
|
@@ -0,0 +1,906 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MemoryStore — all persistence for aidimag.
|
|
3
|
+
* Local-first: a single SQLite file at <repo>/.aidimag/memory.db
|
|
4
|
+
*/
|
|
5
|
+
import Database from "better-sqlite3";
|
|
6
|
+
import * as sqliteVec from "sqlite-vec";
|
|
7
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
8
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { homedir, hostname } from "node:os";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import { SCHEMA_SQL, SCHEMA_VERSION, MIGRATIONS, EVIDENCE_REBUILD_V6, MEMORIES_REBUILD_V8, PROPOSALS_REBUILD_V8, EVENTS_REBUILD_V9 } from "./schema.js";
|
|
12
|
+
import { debugLog } from "../debug.js";
|
|
13
|
+
export const AIDIMAG_DIR = ".aidimag";
|
|
14
|
+
export const DB_FILE = "memory.db";
|
|
15
|
+
/** Walk up from `start` looking for an existing .aidimag dir (or .git as repo root). */
|
|
16
|
+
export function findRepoRoot(start = process.cwd()) {
|
|
17
|
+
let dir = path.resolve(start);
|
|
18
|
+
for (;;) {
|
|
19
|
+
if (existsSync(path.join(dir, AIDIMAG_DIR)) || existsSync(path.join(dir, ".git"))) {
|
|
20
|
+
return dir;
|
|
21
|
+
}
|
|
22
|
+
const parent = path.dirname(dir);
|
|
23
|
+
if (parent === dir)
|
|
24
|
+
return null;
|
|
25
|
+
dir = parent;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
export function dbPathFor(repoRoot) {
|
|
29
|
+
return path.join(repoRoot, AIDIMAG_DIR, DB_FILE);
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Stable per-machine id (CLOUD_DESIGN: "3 of 4 machines confirm…").
|
|
33
|
+
* Persisted once in ~/.aidimag/machine-id; hostname-prefixed for readability.
|
|
34
|
+
*/
|
|
35
|
+
export function machineId() {
|
|
36
|
+
const p = path.join(homedir(), ".aidimag", "machine-id");
|
|
37
|
+
try {
|
|
38
|
+
const existing = readFileSync(p, "utf8").trim();
|
|
39
|
+
if (existing)
|
|
40
|
+
return existing;
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
// first run
|
|
44
|
+
}
|
|
45
|
+
const id = `${hostname()}-${randomUUID().slice(0, 8)}`;
|
|
46
|
+
try {
|
|
47
|
+
mkdirSync(path.dirname(p), { recursive: true });
|
|
48
|
+
writeFileSync(p, id + "\n", { mode: 0o600 });
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
// unwritable home — fall back to a session-stable value
|
|
52
|
+
}
|
|
53
|
+
return id;
|
|
54
|
+
}
|
|
55
|
+
export class MemoryStore {
|
|
56
|
+
db;
|
|
57
|
+
dbPath;
|
|
58
|
+
/** true if the sqlite-vec extension loaded (vector search available) */
|
|
59
|
+
vecAvailable;
|
|
60
|
+
machine = machineId();
|
|
61
|
+
constructor(dbPath) {
|
|
62
|
+
this.dbPath = dbPath;
|
|
63
|
+
mkdirSync(path.dirname(dbPath), { recursive: true });
|
|
64
|
+
this.db = new Database(dbPath);
|
|
65
|
+
let vecOk = false;
|
|
66
|
+
try {
|
|
67
|
+
sqliteVec.load(this.db);
|
|
68
|
+
vecOk = true;
|
|
69
|
+
}
|
|
70
|
+
catch (err) {
|
|
71
|
+
// vector search unavailable on this platform — FTS-only mode
|
|
72
|
+
debugLog("sqlite-vec load (semantic search disabled)", err);
|
|
73
|
+
}
|
|
74
|
+
this.vecAvailable = vecOk;
|
|
75
|
+
// version BEFORE this open (0 = fresh DB; SCHEMA_SQL below creates current shape)
|
|
76
|
+
let prevVersion = 0;
|
|
77
|
+
try {
|
|
78
|
+
const row = this.db.prepare("SELECT value FROM meta WHERE key = 'schema_version'").get();
|
|
79
|
+
prevVersion = row ? parseInt(row.value, 10) : 0;
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
prevVersion = 0; // meta table doesn't exist yet
|
|
83
|
+
}
|
|
84
|
+
this.db.exec(SCHEMA_SQL);
|
|
85
|
+
for (const m of MIGRATIONS) {
|
|
86
|
+
try {
|
|
87
|
+
this.db.exec(m);
|
|
88
|
+
}
|
|
89
|
+
catch (err) {
|
|
90
|
+
// usually "duplicate column" = already applied; AIDIMAG_DEBUG=1 shows which
|
|
91
|
+
debugLog("schema migration (likely already applied)", err);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
// v6: evidence CHECK gains TICKET_REF — needs a one-time table rebuild
|
|
95
|
+
if (prevVersion > 0 && prevVersion < 6) {
|
|
96
|
+
const tx = this.db.transaction(() => this.db.exec(EVIDENCE_REBUILD_V6));
|
|
97
|
+
tx();
|
|
98
|
+
}
|
|
99
|
+
// v8: memories/proposals CHECK gains GUARDRAIL + SKILL kinds — full table
|
|
100
|
+
// rebuild. Rowids are preserved and FTS is rebuilt; foreign keys are
|
|
101
|
+
// disabled around it (DROP TABLE memories has children), so this runs
|
|
102
|
+
// outside a transaction with manual BEGIN/COMMIT.
|
|
103
|
+
if (prevVersion > 0 && prevVersion < 8) {
|
|
104
|
+
this.db.pragma("foreign_keys = OFF");
|
|
105
|
+
this.db.exec("BEGIN");
|
|
106
|
+
try {
|
|
107
|
+
this.db.exec(MEMORIES_REBUILD_V8);
|
|
108
|
+
this.db.exec(PROPOSALS_REBUILD_V8);
|
|
109
|
+
this.db.exec("COMMIT");
|
|
110
|
+
}
|
|
111
|
+
catch (err) {
|
|
112
|
+
this.db.exec("ROLLBACK");
|
|
113
|
+
this.db.pragma("foreign_keys = ON");
|
|
114
|
+
throw err;
|
|
115
|
+
}
|
|
116
|
+
this.db.pragma("foreign_keys = ON");
|
|
117
|
+
// dropping/recreating the tables removed their triggers and indexes —
|
|
118
|
+
// SCHEMA_SQL recreates them (all CREATE ... IF NOT EXISTS, idempotent).
|
|
119
|
+
this.db.exec(SCHEMA_SQL);
|
|
120
|
+
}
|
|
121
|
+
// v9: events CHECK gains updated, evidence_added, evidence_removed types
|
|
122
|
+
if (prevVersion > 0 && prevVersion < 9) {
|
|
123
|
+
const tx = this.db.transaction(() => this.db.exec(EVENTS_REBUILD_V9));
|
|
124
|
+
tx();
|
|
125
|
+
}
|
|
126
|
+
this.db
|
|
127
|
+
.prepare("INSERT INTO meta (key, value) VALUES ('schema_version', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value")
|
|
128
|
+
.run(String(SCHEMA_VERSION));
|
|
129
|
+
this.migrateEvidenceTrust();
|
|
130
|
+
}
|
|
131
|
+
/** Open the store for the repo containing `cwd`. Throws if not initialized and create=false. */
|
|
132
|
+
static open(cwd = process.cwd(), opts = {}) {
|
|
133
|
+
const root = findRepoRoot(cwd);
|
|
134
|
+
if (!root) {
|
|
135
|
+
throw new Error("Not inside a git repo or aidimag project. Run `dim init` at your repo root.");
|
|
136
|
+
}
|
|
137
|
+
const file = dbPathFor(root);
|
|
138
|
+
if (!existsSync(file) && !opts.create) {
|
|
139
|
+
throw new Error(`aidimag not initialized here. Run \`dim init\` in ${root}.`);
|
|
140
|
+
}
|
|
141
|
+
return new MemoryStore(file);
|
|
142
|
+
}
|
|
143
|
+
// ---------------------------------------------------------------- event log (SaaS sync model)
|
|
144
|
+
/** Append an event to the local log (shipped to the sync server on `dim sync`). */
|
|
145
|
+
recordEvent(type, memoryId, payload = {}) {
|
|
146
|
+
this.db
|
|
147
|
+
.prepare(`INSERT INTO events (id, type, memory_id, payload, machine, schema_version, created_at)
|
|
148
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`)
|
|
149
|
+
.run(randomUUID(), type, memoryId, JSON.stringify(payload), this.machine, SCHEMA_VERSION, new Date().toISOString());
|
|
150
|
+
}
|
|
151
|
+
/** Events not yet pushed to the sync server, oldest first. */
|
|
152
|
+
unsyncedEvents(limit = 500) {
|
|
153
|
+
const rows = this.db
|
|
154
|
+
.prepare("SELECT * FROM events WHERE synced = 0 ORDER BY seq ASC LIMIT ?")
|
|
155
|
+
.all(limit);
|
|
156
|
+
return rows.map((r) => ({
|
|
157
|
+
seq: r.seq,
|
|
158
|
+
id: r.id,
|
|
159
|
+
type: r.type,
|
|
160
|
+
memoryId: r.memory_id ?? null,
|
|
161
|
+
payload: JSON.parse(r.payload || "{}"),
|
|
162
|
+
machine: r.machine,
|
|
163
|
+
schemaVersion: r.schema_version,
|
|
164
|
+
createdAt: r.created_at,
|
|
165
|
+
}));
|
|
166
|
+
}
|
|
167
|
+
markEventsSynced(seqs) {
|
|
168
|
+
if (!seqs.length)
|
|
169
|
+
return;
|
|
170
|
+
const stmt = this.db.prepare("UPDATE events SET synced = 1 WHERE seq = ?");
|
|
171
|
+
const tx = this.db.transaction(() => seqs.forEach((s) => stmt.run(s)));
|
|
172
|
+
tx();
|
|
173
|
+
}
|
|
174
|
+
// ---------------------------------------------------------------- write
|
|
175
|
+
write(input) {
|
|
176
|
+
const id = randomUUID();
|
|
177
|
+
const now = new Date().toISOString();
|
|
178
|
+
const hasEvidence = (input.evidence?.length ?? 0) > 0;
|
|
179
|
+
const insert = this.db.transaction(() => {
|
|
180
|
+
this.db
|
|
181
|
+
.prepare(`INSERT INTO memories (id, kind, claim, confidence, status, created_by, created_at, updated_at, pinned, guardrail_level)
|
|
182
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
183
|
+
.run(id, input.kind, input.claim, hasEvidence ? 0.7 : 0.5, "UNVERIFIED", input.createdBy ?? "human", now, now, input.pinned ? 1 : 0, input.kind === "GUARDRAIL" ? input.guardrailLevel ?? "ask-first" : null);
|
|
184
|
+
const scopeStmt = this.db.prepare("INSERT OR IGNORE INTO memory_scopes (memory_id, scope_type, value) VALUES (?, ?, ?)");
|
|
185
|
+
for (const p of input.paths ?? [])
|
|
186
|
+
scopeStmt.run(id, "path", p);
|
|
187
|
+
for (const s of input.symbols ?? [])
|
|
188
|
+
scopeStmt.run(id, "symbol", s);
|
|
189
|
+
const evStmt = this.db.prepare("INSERT INTO evidence (id, memory_id, type, payload, result) VALUES (?, ?, ?, ?, 'UNKNOWN')");
|
|
190
|
+
for (const ev of input.evidence ?? []) {
|
|
191
|
+
evStmt.run(randomUUID(), id, ev.type, ev.payload);
|
|
192
|
+
if (input.trustExecutableEvidence) {
|
|
193
|
+
this.trustEvidencePayload(ev.payload);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
this.recordEvent("memory_created", id, {
|
|
197
|
+
kind: input.kind,
|
|
198
|
+
claim: input.claim,
|
|
199
|
+
createdBy: input.createdBy ?? "human",
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
insert();
|
|
203
|
+
return this.get(id);
|
|
204
|
+
}
|
|
205
|
+
// ---------------------------------------------------------------- evidence trust gate
|
|
206
|
+
// Executable evidence (STATIC_CHECK / TEST_RESULT / EXEC_TRACE) runs shell
|
|
207
|
+
// commands on THIS machine — including automatically via git hooks. A synced
|
|
208
|
+
// teammate's (or attacker's) memory must not become remote code execution:
|
|
209
|
+
// only payloads explicitly trusted on this machine ever execute.
|
|
210
|
+
// CLI human writes and approved proposals opt in; agents/UI use proposals or `dim verify --trust`.
|
|
211
|
+
static evTrustKey(payload) {
|
|
212
|
+
return `evtrust:${createHash("sha256").update(payload).digest("hex").slice(0, 16)}`;
|
|
213
|
+
}
|
|
214
|
+
/** Mark an evidence payload as approved to execute on this machine. */
|
|
215
|
+
trustEvidencePayload(payload) {
|
|
216
|
+
this.setMeta(MemoryStore.evTrustKey(payload), new Date().toISOString());
|
|
217
|
+
}
|
|
218
|
+
isEvidencePayloadTrusted(payload) {
|
|
219
|
+
return this.getMeta(MemoryStore.evTrustKey(payload)) !== null;
|
|
220
|
+
}
|
|
221
|
+
/** Executable-evidence payloads currently NOT trusted (i.e. arrived via sync). */
|
|
222
|
+
untrustedEvidence() {
|
|
223
|
+
const rows = this.db
|
|
224
|
+
.prepare(`SELECT e.memory_id, m.claim, e.type, e.payload FROM evidence e
|
|
225
|
+
JOIN memories m ON m.id = e.memory_id
|
|
226
|
+
WHERE e.type IN ('STATIC_CHECK','TEST_RESULT','EXEC_TRACE')`)
|
|
227
|
+
.all();
|
|
228
|
+
return rows
|
|
229
|
+
.filter((r) => !this.isEvidencePayloadTrusted(r.payload))
|
|
230
|
+
.map((r) => ({ memoryId: r.memory_id, claim: r.claim, type: r.type, payload: r.payload }));
|
|
231
|
+
}
|
|
232
|
+
/** Trust every currently-untrusted executable payload (after human inspection). Returns count. */
|
|
233
|
+
trustAllEvidence() {
|
|
234
|
+
const pending = this.untrustedEvidence();
|
|
235
|
+
for (const p of pending)
|
|
236
|
+
this.trustEvidencePayload(p.payload);
|
|
237
|
+
return pending.length;
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* One-time migration: evidence that existed before the trust gate shipped
|
|
241
|
+
* was locally authored (sync always re-writes through applyRemoteMemory,
|
|
242
|
+
* which doesn't trust) — grandfather it so `dim verify` keeps working.
|
|
243
|
+
*/
|
|
244
|
+
migrateEvidenceTrust() {
|
|
245
|
+
if (this.getMeta("evtrust_migrated"))
|
|
246
|
+
return;
|
|
247
|
+
try {
|
|
248
|
+
const rows = this.db
|
|
249
|
+
.prepare("SELECT DISTINCT payload FROM evidence WHERE type IN ('STATIC_CHECK','TEST_RESULT','EXEC_TRACE')")
|
|
250
|
+
.all();
|
|
251
|
+
for (const r of rows)
|
|
252
|
+
this.trustEvidencePayload(r.payload);
|
|
253
|
+
}
|
|
254
|
+
catch {
|
|
255
|
+
/* evidence table always exists post-SCHEMA_SQL; belt and braces */
|
|
256
|
+
}
|
|
257
|
+
this.setMeta("evtrust_migrated", new Date().toISOString());
|
|
258
|
+
}
|
|
259
|
+
// ---------------------------------------------------------------- read
|
|
260
|
+
get(id) {
|
|
261
|
+
const row = this.db
|
|
262
|
+
.prepare("SELECT * FROM memories WHERE id = ?")
|
|
263
|
+
.get(id);
|
|
264
|
+
if (!row)
|
|
265
|
+
return null;
|
|
266
|
+
return this.hydrate(row);
|
|
267
|
+
}
|
|
268
|
+
search(opts) {
|
|
269
|
+
const limit = Math.min(opts.limit ?? 10, 50);
|
|
270
|
+
const conditions = [];
|
|
271
|
+
const params = [];
|
|
272
|
+
// FTS match (sanitize: quote each term to avoid FTS syntax errors)
|
|
273
|
+
const ftsQuery = opts.query
|
|
274
|
+
.split(/\s+/)
|
|
275
|
+
.filter(Boolean)
|
|
276
|
+
.map((t) => `"${t.replace(/"/g, '""')}"`)
|
|
277
|
+
.join(" OR ");
|
|
278
|
+
if (opts.kind) {
|
|
279
|
+
conditions.push("m.kind = ?");
|
|
280
|
+
params.push(opts.kind);
|
|
281
|
+
}
|
|
282
|
+
if (opts.status) {
|
|
283
|
+
conditions.push("m.status = ?");
|
|
284
|
+
params.push(opts.status);
|
|
285
|
+
}
|
|
286
|
+
else if (!opts.includeRefuted) {
|
|
287
|
+
conditions.push("m.status != 'REFUTED'");
|
|
288
|
+
}
|
|
289
|
+
if (opts.paths?.length) {
|
|
290
|
+
// memory matches if unscoped (repo-wide) or any scope path prefixes/equals a query path
|
|
291
|
+
const pathClauses = opts.paths
|
|
292
|
+
.map(() => "(? LIKE s.value || '%' OR s.value LIKE ? || '%')")
|
|
293
|
+
.join(" OR ");
|
|
294
|
+
conditions.push(`(
|
|
295
|
+
NOT EXISTS (SELECT 1 FROM memory_scopes s WHERE s.memory_id = m.id AND s.scope_type = 'path')
|
|
296
|
+
OR EXISTS (SELECT 1 FROM memory_scopes s WHERE s.memory_id = m.id AND s.scope_type = 'path' AND (${pathClauses}))
|
|
297
|
+
)`);
|
|
298
|
+
for (const p of opts.paths)
|
|
299
|
+
params.push(p, p);
|
|
300
|
+
}
|
|
301
|
+
const where = conditions.length ? `AND ${conditions.join(" AND ")}` : "";
|
|
302
|
+
// Status-aware ranking (Phase 4 pilot tuning): trust dominates relevance.
|
|
303
|
+
// FTS bm25 rank is negative (more relevant = more negative), so we add a
|
|
304
|
+
// status penalty: VERIFIED +0, UNVERIFIED +2, STALE +10 — a stale memory
|
|
305
|
+
// only surfaces when nothing trustworthy matches.
|
|
306
|
+
const statusPenalty = "(CASE m.status WHEN 'VERIFIED' THEN 0 WHEN 'UNVERIFIED' THEN 2 WHEN 'STALE' THEN 10 ELSE 20 END)";
|
|
307
|
+
let rows;
|
|
308
|
+
if (ftsQuery) {
|
|
309
|
+
rows = this.db
|
|
310
|
+
.prepare(`SELECT m.* FROM memories_fts f
|
|
311
|
+
JOIN memories m ON m.rowid = f.rowid
|
|
312
|
+
WHERE memories_fts MATCH ? ${where}
|
|
313
|
+
ORDER BY (rank + ${statusPenalty}) ASC, m.confidence DESC
|
|
314
|
+
LIMIT ?`)
|
|
315
|
+
.all(ftsQuery, ...params, limit);
|
|
316
|
+
}
|
|
317
|
+
else {
|
|
318
|
+
rows = this.db
|
|
319
|
+
.prepare(`SELECT m.* FROM memories m WHERE 1=1 ${where}
|
|
320
|
+
ORDER BY ${statusPenalty} ASC, m.confidence DESC, m.created_at DESC LIMIT ?`)
|
|
321
|
+
.all(...params, limit);
|
|
322
|
+
}
|
|
323
|
+
return rows.map((r) => this.hydrate(r));
|
|
324
|
+
}
|
|
325
|
+
/** All memories whose scope overlaps the given file paths (plus repo-wide memories). */
|
|
326
|
+
getForFiles(paths, limit = 20) {
|
|
327
|
+
return this.search({ query: "", paths, limit });
|
|
328
|
+
}
|
|
329
|
+
list(limit = 50) {
|
|
330
|
+
const rows = this.db
|
|
331
|
+
.prepare("SELECT * FROM memories ORDER BY created_at DESC LIMIT ?")
|
|
332
|
+
.all(limit);
|
|
333
|
+
return rows.map((r) => this.hydrate(r));
|
|
334
|
+
}
|
|
335
|
+
statusSummary() {
|
|
336
|
+
const byStatus = {
|
|
337
|
+
VERIFIED: 0,
|
|
338
|
+
UNVERIFIED: 0,
|
|
339
|
+
STALE: 0,
|
|
340
|
+
REFUTED: 0,
|
|
341
|
+
};
|
|
342
|
+
for (const r of this.db
|
|
343
|
+
.prepare("SELECT status, COUNT(*) AS n FROM memories GROUP BY status")
|
|
344
|
+
.all()) {
|
|
345
|
+
byStatus[r.status] = r.n;
|
|
346
|
+
}
|
|
347
|
+
const byKind = {};
|
|
348
|
+
for (const r of this.db
|
|
349
|
+
.prepare("SELECT kind, COUNT(*) AS n FROM memories GROUP BY kind")
|
|
350
|
+
.all()) {
|
|
351
|
+
byKind[r.kind] = r.n;
|
|
352
|
+
}
|
|
353
|
+
const total = this.db.prepare("SELECT COUNT(*) AS n FROM memories").get().n;
|
|
354
|
+
const pinned = this.db.prepare("SELECT COUNT(*) AS n FROM memories WHERE pinned = 1").get().n;
|
|
355
|
+
const pendingProposals = this.db.prepare("SELECT COUNT(*) AS n FROM proposals WHERE status = 'PENDING'").get().n;
|
|
356
|
+
return { total, byStatus, byKind, pinned, dbPath: this.dbPath, pendingProposals };
|
|
357
|
+
}
|
|
358
|
+
// ---------------------------------------------------------------- embeddings (semantic recall)
|
|
359
|
+
/**
|
|
360
|
+
* Ensure the vec0 table exists for the given model+dimension.
|
|
361
|
+
* If the model changed since last index, drops and requires reindex.
|
|
362
|
+
* Returns false if vector search is unavailable.
|
|
363
|
+
*/
|
|
364
|
+
ensureVecTable(model, dim) {
|
|
365
|
+
if (!this.vecAvailable)
|
|
366
|
+
return false;
|
|
367
|
+
const current = this.getMeta("embedding_model");
|
|
368
|
+
if (current && current !== `${model}:${dim}`) {
|
|
369
|
+
this.db.exec("DROP TABLE IF EXISTS vec_memories");
|
|
370
|
+
}
|
|
371
|
+
this.db.exec(`CREATE VIRTUAL TABLE IF NOT EXISTS vec_memories USING vec0(memory_rowid integer primary key, embedding float[${dim}])`);
|
|
372
|
+
this.setMeta("embedding_model", `${model}:${dim}`);
|
|
373
|
+
return true;
|
|
374
|
+
}
|
|
375
|
+
upsertEmbedding(memoryId, vector) {
|
|
376
|
+
if (!this.vecAvailable)
|
|
377
|
+
return;
|
|
378
|
+
const row = this.db.prepare("SELECT rowid FROM memories WHERE id = ?").get(memoryId);
|
|
379
|
+
if (!row)
|
|
380
|
+
return;
|
|
381
|
+
this.db.prepare("DELETE FROM vec_memories WHERE memory_rowid = ?").run(BigInt(row.rowid));
|
|
382
|
+
this.db
|
|
383
|
+
.prepare("INSERT INTO vec_memories (memory_rowid, embedding) VALUES (?, ?)")
|
|
384
|
+
.run(BigInt(row.rowid), new Float32Array(vector));
|
|
385
|
+
}
|
|
386
|
+
/** Memory ids missing an embedding (for reindex/backfill). */
|
|
387
|
+
unembeddedIds() {
|
|
388
|
+
if (!this.vecAvailable)
|
|
389
|
+
return [];
|
|
390
|
+
try {
|
|
391
|
+
return this.db
|
|
392
|
+
.prepare(`SELECT m.id FROM memories m
|
|
393
|
+
WHERE m.rowid NOT IN (SELECT memory_rowid FROM vec_memories)`)
|
|
394
|
+
.all().map((r) => r.id);
|
|
395
|
+
}
|
|
396
|
+
catch {
|
|
397
|
+
return this.list(10_000).map((m) => m.id); // vec table doesn't exist yet
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
/** KNN over memory embeddings → [memoryId, distance] pairs, nearest first. */
|
|
401
|
+
knn(queryVector, k = 10) {
|
|
402
|
+
if (!this.vecAvailable)
|
|
403
|
+
return [];
|
|
404
|
+
try {
|
|
405
|
+
return this.db
|
|
406
|
+
.prepare(`SELECT m.id, v.distance FROM vec_memories v
|
|
407
|
+
JOIN memories m ON m.rowid = v.memory_rowid
|
|
408
|
+
WHERE v.embedding MATCH ? AND k = ?
|
|
409
|
+
ORDER BY v.distance`)
|
|
410
|
+
.all(new Float32Array(queryVector), BigInt(k));
|
|
411
|
+
}
|
|
412
|
+
catch {
|
|
413
|
+
return [];
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
// ---------------------------------------------------------------- proposals (Phase 2)
|
|
417
|
+
/** Queue a proposed memory for human review. Returns null if a duplicate already exists. */
|
|
418
|
+
propose(input) {
|
|
419
|
+
const id = randomUUID();
|
|
420
|
+
const now = new Date().toISOString();
|
|
421
|
+
try {
|
|
422
|
+
this.db
|
|
423
|
+
.prepare(`INSERT INTO proposals (id, kind, claim, paths, symbols, evidence, source, source_ref, rationale, created_at, updated_at, ticket_ref, guardrail_level)
|
|
424
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
425
|
+
.run(id, input.kind, input.claim, JSON.stringify(input.paths ?? []), JSON.stringify(input.symbols ?? []), JSON.stringify(input.evidence ?? []), input.source,
|
|
426
|
+
// NOTE: empty string, not NULL — SQLite treats NULLs as distinct in
|
|
427
|
+
// unique indexes, which would break (source, source_ref, claim) dedupe.
|
|
428
|
+
input.sourceRef ?? "", input.rationale ?? null, now, now, input.ticketRef ?? null, input.kind === "GUARDRAIL" ? input.guardrailLevel ?? "ask-first" : null);
|
|
429
|
+
}
|
|
430
|
+
catch (err) {
|
|
431
|
+
// UNIQUE(source, source_ref, claim) — already proposed
|
|
432
|
+
if (err instanceof Error && /UNIQUE constraint/.test(err.message))
|
|
433
|
+
return null;
|
|
434
|
+
throw err;
|
|
435
|
+
}
|
|
436
|
+
this.recordEvent("proposal_created", id, { kind: input.kind, claim: input.claim, source: input.source });
|
|
437
|
+
return this.getProposal(id);
|
|
438
|
+
}
|
|
439
|
+
getProposal(id) {
|
|
440
|
+
const row = this.db.prepare("SELECT * FROM proposals WHERE id = ?").get(id);
|
|
441
|
+
return row ? this.hydrateProposal(row) : null;
|
|
442
|
+
}
|
|
443
|
+
listProposals(status = "PENDING", limit = 50) {
|
|
444
|
+
const rows = this.db
|
|
445
|
+
.prepare("SELECT * FROM proposals WHERE status = ? ORDER BY created_at ASC LIMIT ?")
|
|
446
|
+
.all(status, limit);
|
|
447
|
+
return rows.map((r) => this.hydrateProposal(r));
|
|
448
|
+
}
|
|
449
|
+
/**
|
|
450
|
+
* Approve a proposal: creates the verified memory and removes the proposal row
|
|
451
|
+
* (tombstoned for team sync). `overrides.claim` lets the reviewer reword before promote.
|
|
452
|
+
*/
|
|
453
|
+
approveProposal(id, overrides) {
|
|
454
|
+
const p = this.findProposalByPrefix(id);
|
|
455
|
+
if (!p)
|
|
456
|
+
throw new Error(`No proposal matching id '${id}'`);
|
|
457
|
+
if (p.status !== "PENDING")
|
|
458
|
+
throw new Error(`Proposal ${p.id} is already ${p.status}`);
|
|
459
|
+
const entry = this.write({
|
|
460
|
+
kind: p.kind,
|
|
461
|
+
claim: overrides?.claim?.trim() || p.claim,
|
|
462
|
+
paths: p.paths,
|
|
463
|
+
symbols: p.symbols,
|
|
464
|
+
evidence: p.evidence,
|
|
465
|
+
createdBy: p.source,
|
|
466
|
+
guardrailLevel: p.guardrailLevel,
|
|
467
|
+
// Knowledgebase docs are curated reference material — pin on approval so
|
|
468
|
+
// they don't decay with age (still falsifiable if evidence fails).
|
|
469
|
+
// Callers can override (e.g. requireReview:false auto-approval never pins).
|
|
470
|
+
pinned: overrides?.pinned ?? p.source.startsWith("knowledge:"),
|
|
471
|
+
trustExecutableEvidence: true,
|
|
472
|
+
});
|
|
473
|
+
this.recordEvent("proposal_approved", p.id, { memoryId: entry.id, claim: p.claim });
|
|
474
|
+
this.discardProposal(p.id);
|
|
475
|
+
return entry;
|
|
476
|
+
}
|
|
477
|
+
rejectProposal(id) {
|
|
478
|
+
const p = this.findProposalByPrefix(id);
|
|
479
|
+
if (!p)
|
|
480
|
+
throw new Error(`No proposal matching id '${id}'`);
|
|
481
|
+
if (p.status !== "PENDING")
|
|
482
|
+
throw new Error(`Proposal ${p.id} is already ${p.status}`);
|
|
483
|
+
this.recordEvent("proposal_rejected", p.id, { claim: p.claim });
|
|
484
|
+
this.discardProposal(p.id);
|
|
485
|
+
return { ...p, status: "REJECTED" };
|
|
486
|
+
}
|
|
487
|
+
/** Claims from rejected proposals — used by triage (survives proposal row deletion). */
|
|
488
|
+
listRejectedClaims(limit = 500) {
|
|
489
|
+
const claims = [];
|
|
490
|
+
const rows = this.db
|
|
491
|
+
.prepare(`SELECT payload FROM events WHERE type = 'proposal_rejected' ORDER BY seq DESC LIMIT ?`)
|
|
492
|
+
.all(limit);
|
|
493
|
+
for (const r of rows) {
|
|
494
|
+
try {
|
|
495
|
+
const claim = JSON.parse(r.payload || "{}").claim;
|
|
496
|
+
if (typeof claim === "string" && claim)
|
|
497
|
+
claims.push(claim);
|
|
498
|
+
}
|
|
499
|
+
catch {
|
|
500
|
+
// ignore malformed event payloads
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
return [...new Set(claims)];
|
|
504
|
+
}
|
|
505
|
+
/**
|
|
506
|
+
* Remove resolved proposals (approved/rejected) and tombstone for sync.
|
|
507
|
+
* Use after upgrading brains that still carry historical APPROVED rows.
|
|
508
|
+
*/
|
|
509
|
+
gcResolvedProposals(opts = {}) {
|
|
510
|
+
const rows = this.db
|
|
511
|
+
.prepare("SELECT id FROM proposals WHERE status IN ('APPROVED', 'REJECTED')")
|
|
512
|
+
.all();
|
|
513
|
+
if (!opts.dryRun) {
|
|
514
|
+
for (const r of rows)
|
|
515
|
+
this.discardProposal(r.id);
|
|
516
|
+
}
|
|
517
|
+
return { removed: rows.length };
|
|
518
|
+
}
|
|
519
|
+
findProposalByPrefix(idOrPrefix) {
|
|
520
|
+
const row = this.db
|
|
521
|
+
.prepare("SELECT * FROM proposals WHERE id = ? OR id LIKE ? || '%' LIMIT 1")
|
|
522
|
+
.get(idOrPrefix, idOrPrefix);
|
|
523
|
+
return row ? this.hydrateProposal(row) : null;
|
|
524
|
+
}
|
|
525
|
+
// ---------------------------------------------------------------- search log (passive capture)
|
|
526
|
+
/**
|
|
527
|
+
* Record a memory search (MCP or CLI). Zero-hit queries are coverage gaps:
|
|
528
|
+
* an agent needed knowledge the brain couldn't supply. Local-only, never synced.
|
|
529
|
+
*/
|
|
530
|
+
logSearch(query, paths, resultCount, source = "mcp") {
|
|
531
|
+
const q = query.trim();
|
|
532
|
+
if (!q)
|
|
533
|
+
return;
|
|
534
|
+
this.db
|
|
535
|
+
.prepare("INSERT INTO search_log (id, query, paths, result_count, source, created_at) VALUES (?, ?, ?, ?, ?, ?)")
|
|
536
|
+
.run(randomUUID(), q, JSON.stringify(paths), resultCount, source, new Date().toISOString());
|
|
537
|
+
}
|
|
538
|
+
/**
|
|
539
|
+
* Zero-hit searches grouped by normalized query — the repo's knowledge gaps,
|
|
540
|
+
* most-asked first. `sinceDays` bounds the window (default 30).
|
|
541
|
+
*/
|
|
542
|
+
searchGaps(opts = {}) {
|
|
543
|
+
const since = new Date(Date.now() - (opts.sinceDays ?? 30) * 86_400_000).toISOString();
|
|
544
|
+
const rows = this.db
|
|
545
|
+
.prepare(`SELECT LOWER(TRIM(query)) AS q, MAX(query) AS query, COUNT(*) AS misses,
|
|
546
|
+
MAX(created_at) AS last_asked, MAX(paths) AS paths
|
|
547
|
+
FROM search_log
|
|
548
|
+
WHERE result_count = 0 AND created_at >= ?
|
|
549
|
+
GROUP BY q
|
|
550
|
+
ORDER BY misses DESC, last_asked DESC
|
|
551
|
+
LIMIT ?`)
|
|
552
|
+
.all(since, Math.min(opts.limit ?? 20, 100));
|
|
553
|
+
return rows.map((r) => {
|
|
554
|
+
let paths = [];
|
|
555
|
+
try {
|
|
556
|
+
paths = JSON.parse(r.paths);
|
|
557
|
+
}
|
|
558
|
+
catch {
|
|
559
|
+
/* legacy/garbled row — treat as unscoped */
|
|
560
|
+
}
|
|
561
|
+
return { query: r.query, misses: r.misses, lastAsked: r.last_asked, paths };
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
/** Clear logged searches (e.g. after the gaps have been addressed). */
|
|
565
|
+
clearSearchGaps() {
|
|
566
|
+
return this.db.prepare("DELETE FROM search_log").run().changes;
|
|
567
|
+
}
|
|
568
|
+
/** Last mined commit sha (commit-miner cursor), or null. */
|
|
569
|
+
getMeta(key) {
|
|
570
|
+
const row = this.db.prepare("SELECT value FROM meta WHERE key = ?").get(key);
|
|
571
|
+
return row?.value ?? null;
|
|
572
|
+
}
|
|
573
|
+
setMeta(key, value) {
|
|
574
|
+
this.db
|
|
575
|
+
.prepare("INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value")
|
|
576
|
+
.run(key, value);
|
|
577
|
+
}
|
|
578
|
+
hydrateProposal(row) {
|
|
579
|
+
return {
|
|
580
|
+
id: row.id,
|
|
581
|
+
kind: row.kind,
|
|
582
|
+
claim: row.claim,
|
|
583
|
+
paths: JSON.parse(row.paths),
|
|
584
|
+
symbols: JSON.parse(row.symbols),
|
|
585
|
+
evidence: JSON.parse(row.evidence),
|
|
586
|
+
source: row.source,
|
|
587
|
+
sourceRef: row.source_ref || undefined,
|
|
588
|
+
rationale: row.rationale ?? undefined,
|
|
589
|
+
createdAt: row.created_at,
|
|
590
|
+
status: row.status,
|
|
591
|
+
memoryId: row.memory_id ?? null,
|
|
592
|
+
updatedAt: row.updated_at ?? null,
|
|
593
|
+
ticketRef: row.ticket_ref ?? undefined,
|
|
594
|
+
guardrailLevel: row.guardrail_level ?? undefined,
|
|
595
|
+
};
|
|
596
|
+
}
|
|
597
|
+
// ---------------------------------------------------------------- mutate
|
|
598
|
+
setStatus(id, status) {
|
|
599
|
+
const now = new Date().toISOString();
|
|
600
|
+
const verifiedAt = status === "VERIFIED" ? now : null;
|
|
601
|
+
const res = this.db
|
|
602
|
+
.prepare("UPDATE memories SET status = ?, verified_at = COALESCE(?, verified_at), updated_at = ? WHERE id = ?")
|
|
603
|
+
.run(status, verifiedAt, now, id);
|
|
604
|
+
if (res.changes === 0)
|
|
605
|
+
throw new Error(`No memory with id ${id}`);
|
|
606
|
+
this.recordEvent("status_changed", id, { status });
|
|
607
|
+
}
|
|
608
|
+
setConfidence(id, confidence) {
|
|
609
|
+
this.db
|
|
610
|
+
.prepare("UPDATE memories SET confidence = ?, updated_at = ? WHERE id = ?")
|
|
611
|
+
.run(Math.max(0, Math.min(1, confidence)), new Date().toISOString(), id);
|
|
612
|
+
}
|
|
613
|
+
/**
|
|
614
|
+
* Pin/unpin a memory. Pinned memories never decay with time (but evidence
|
|
615
|
+
* failure still marks them STALE — pinning removes the clock, not the test).
|
|
616
|
+
*/
|
|
617
|
+
setPinned(id, pinned) {
|
|
618
|
+
const res = this.db
|
|
619
|
+
.prepare("UPDATE memories SET pinned = ?, updated_at = ? WHERE id = ?")
|
|
620
|
+
.run(pinned ? 1 : 0, new Date().toISOString(), id);
|
|
621
|
+
if (res.changes === 0)
|
|
622
|
+
throw new Error(`No memory with id ${id}`);
|
|
623
|
+
this.recordEvent("status_changed", id, { pinned });
|
|
624
|
+
}
|
|
625
|
+
touchVerified(id) {
|
|
626
|
+
const now = new Date().toISOString();
|
|
627
|
+
this.db
|
|
628
|
+
.prepare("UPDATE memories SET verified_at = ?, updated_at = ? WHERE id = ?")
|
|
629
|
+
.run(now, now, id);
|
|
630
|
+
}
|
|
631
|
+
updateEvidenceResult(evidenceId, result, lastRun) {
|
|
632
|
+
this.db
|
|
633
|
+
.prepare("UPDATE evidence SET result = ?, last_run = ? WHERE id = ?")
|
|
634
|
+
.run(result, lastRun, evidenceId);
|
|
635
|
+
this.db
|
|
636
|
+
.prepare("UPDATE memories SET updated_at = ? WHERE id = (SELECT memory_id FROM evidence WHERE id = ?)")
|
|
637
|
+
.run(lastRun, evidenceId);
|
|
638
|
+
const owner = this.db
|
|
639
|
+
.prepare("SELECT memory_id, type FROM evidence WHERE id = ?")
|
|
640
|
+
.get(evidenceId);
|
|
641
|
+
if (owner) {
|
|
642
|
+
this.recordEvent("evidence_result", owner.memory_id, {
|
|
643
|
+
evidenceId,
|
|
644
|
+
evidenceType: owner.type,
|
|
645
|
+
result,
|
|
646
|
+
at: lastRun,
|
|
647
|
+
});
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
refute(id, supersededBy) {
|
|
651
|
+
const res = this.db
|
|
652
|
+
.prepare("UPDATE memories SET status = 'REFUTED', superseded_by = ?, updated_at = ? WHERE id = ?")
|
|
653
|
+
.run(supersededBy ?? null, new Date().toISOString(), id);
|
|
654
|
+
if (res.changes === 0)
|
|
655
|
+
throw new Error(`No memory with id ${id}`);
|
|
656
|
+
this.recordEvent("refuted", id, supersededBy ? { supersededBy } : {});
|
|
657
|
+
if (supersededBy)
|
|
658
|
+
this.recordEvent("superseded", id, { by: supersededBy });
|
|
659
|
+
}
|
|
660
|
+
forget(id) {
|
|
661
|
+
const tx = this.db.transaction(() => {
|
|
662
|
+
// clear proposal back-references (older DBs lack ON DELETE SET NULL)
|
|
663
|
+
this.db.prepare("UPDATE proposals SET memory_id = NULL WHERE memory_id = ?").run(id);
|
|
664
|
+
this.db.prepare("UPDATE memories SET superseded_by = NULL WHERE superseded_by = ?").run(id);
|
|
665
|
+
const res = this.db.prepare("DELETE FROM memories WHERE id = ?").run(id);
|
|
666
|
+
if (res.changes === 0)
|
|
667
|
+
throw new Error(`No memory with id ${id}`);
|
|
668
|
+
this.db
|
|
669
|
+
.prepare("INSERT OR REPLACE INTO tombstones (id, tbl, deleted_at) VALUES (?, 'memories', ?)")
|
|
670
|
+
.run(id, new Date().toISOString());
|
|
671
|
+
this.recordEvent("forgotten", id);
|
|
672
|
+
});
|
|
673
|
+
tx();
|
|
674
|
+
}
|
|
675
|
+
/** Delete a proposal row and record a tombstone so team sync drops it on the remote brain. */
|
|
676
|
+
discardProposal(id, deletedAt) {
|
|
677
|
+
const at = deletedAt ?? new Date().toISOString();
|
|
678
|
+
const tx = this.db.transaction(() => {
|
|
679
|
+
const res = this.db.prepare("DELETE FROM proposals WHERE id = ?").run(id);
|
|
680
|
+
if (res.changes === 0)
|
|
681
|
+
throw new Error(`No proposal with id ${id}`);
|
|
682
|
+
this.db
|
|
683
|
+
.prepare("INSERT OR REPLACE INTO tombstones (id, tbl, deleted_at) VALUES (?, 'proposals', ?)")
|
|
684
|
+
.run(id, at);
|
|
685
|
+
});
|
|
686
|
+
tx();
|
|
687
|
+
}
|
|
688
|
+
link(fromId, toId, relation) {
|
|
689
|
+
this.db
|
|
690
|
+
.prepare("INSERT OR IGNORE INTO memory_links (from_id, to_id, relation) VALUES (?, ?, ?)")
|
|
691
|
+
.run(fromId, toId, relation);
|
|
692
|
+
}
|
|
693
|
+
update(id, updates) {
|
|
694
|
+
const sets = [];
|
|
695
|
+
const values = [];
|
|
696
|
+
if (updates.claim !== undefined) {
|
|
697
|
+
sets.push("claim = ?");
|
|
698
|
+
values.push(updates.claim);
|
|
699
|
+
}
|
|
700
|
+
if (updates.kind !== undefined) {
|
|
701
|
+
sets.push("kind = ?");
|
|
702
|
+
values.push(updates.kind);
|
|
703
|
+
}
|
|
704
|
+
if (updates.guardrailLevel !== undefined) {
|
|
705
|
+
sets.push("guardrail_level = ?");
|
|
706
|
+
values.push(updates.guardrailLevel);
|
|
707
|
+
}
|
|
708
|
+
if (sets.length === 0)
|
|
709
|
+
return;
|
|
710
|
+
sets.push("updated_at = ?");
|
|
711
|
+
values.push(new Date().toISOString());
|
|
712
|
+
values.push(id);
|
|
713
|
+
const sql = `UPDATE memories SET ${sets.join(", ")} WHERE id = ?`;
|
|
714
|
+
const res = this.db.prepare(sql).run(...values);
|
|
715
|
+
if (res.changes === 0)
|
|
716
|
+
throw new Error(`No memory with id ${id}`);
|
|
717
|
+
this.recordEvent("updated", id, updates);
|
|
718
|
+
}
|
|
719
|
+
addEvidence(memoryId, ev) {
|
|
720
|
+
const id = randomUUID();
|
|
721
|
+
this.db
|
|
722
|
+
.prepare("INSERT INTO evidence (id, memory_id, type, payload, result) VALUES (?, ?, ?, ?, 'UNKNOWN')")
|
|
723
|
+
.run(id, memoryId, ev.type, ev.payload);
|
|
724
|
+
this.recordEvent("evidence_added", memoryId, { evidenceId: id, type: ev.type });
|
|
725
|
+
return id;
|
|
726
|
+
}
|
|
727
|
+
removeEvidence(evidenceId) {
|
|
728
|
+
const ev = this.db.prepare("SELECT memory_id FROM evidence WHERE id = ?").get(evidenceId);
|
|
729
|
+
if (!ev)
|
|
730
|
+
throw new Error(`No evidence with id ${evidenceId}`);
|
|
731
|
+
const res = this.db.prepare("DELETE FROM evidence WHERE id = ?").run(evidenceId);
|
|
732
|
+
if (res.changes === 0)
|
|
733
|
+
throw new Error(`No evidence with id ${evidenceId}`);
|
|
734
|
+
this.recordEvent("evidence_removed", ev.memory_id, { evidenceId });
|
|
735
|
+
}
|
|
736
|
+
// ---------------------------------------------------------------- sync (Phase 6)
|
|
737
|
+
// State-based LWW sync support consumed by src/sync/client.ts.
|
|
738
|
+
/** Local changes since `sinceIso` (null = everything), for pushing to a sync server. */
|
|
739
|
+
changedSince(sinceIso) {
|
|
740
|
+
const since = sinceIso ?? "";
|
|
741
|
+
const memRows = this.db
|
|
742
|
+
.prepare("SELECT * FROM memories WHERE COALESCE(updated_at, created_at) > ?")
|
|
743
|
+
.all(since);
|
|
744
|
+
const propRows = this.db
|
|
745
|
+
.prepare("SELECT * FROM proposals WHERE COALESCE(updated_at, created_at) > ?")
|
|
746
|
+
.all(since);
|
|
747
|
+
const tombs = this.db
|
|
748
|
+
.prepare("SELECT id, tbl, deleted_at FROM tombstones WHERE deleted_at > ?")
|
|
749
|
+
.all(since);
|
|
750
|
+
return {
|
|
751
|
+
memories: memRows.map((r) => this.hydrate(r)),
|
|
752
|
+
proposals: propRows.map((r) => this.hydrateProposal(r)),
|
|
753
|
+
tombstones: tombs.map((t) => ({ id: t.id, tbl: t.tbl, deletedAt: t.deleted_at })),
|
|
754
|
+
};
|
|
755
|
+
}
|
|
756
|
+
/** Per-memory updated_at (for LWW comparisons). */
|
|
757
|
+
memoryUpdatedAt(id) {
|
|
758
|
+
const row = this.db
|
|
759
|
+
.prepare("SELECT COALESCE(updated_at, created_at) AS u FROM memories WHERE id = ?")
|
|
760
|
+
.get(id);
|
|
761
|
+
return row?.u ?? null;
|
|
762
|
+
}
|
|
763
|
+
isTombstoned(id, tbl) {
|
|
764
|
+
return !!this.db.prepare("SELECT 1 FROM tombstones WHERE id = ? AND tbl = ?").get(id, tbl);
|
|
765
|
+
}
|
|
766
|
+
/** Upsert a remote memory snapshot (caller already did the LWW check). */
|
|
767
|
+
applyRemoteMemory(m) {
|
|
768
|
+
const tx = this.db.transaction(() => {
|
|
769
|
+
this.db
|
|
770
|
+
.prepare(`INSERT INTO memories (id, kind, claim, confidence, status, created_by, created_at, verified_at, superseded_by, updated_at, pinned, guardrail_level)
|
|
771
|
+
VALUES (@id, @kind, @claim, @confidence, @status, @created_by, @created_at, @verified_at, @superseded_by, @updated_at, @pinned, @guardrail_level)
|
|
772
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
773
|
+
kind=excluded.kind, claim=excluded.claim, confidence=excluded.confidence,
|
|
774
|
+
status=excluded.status, verified_at=excluded.verified_at,
|
|
775
|
+
superseded_by=excluded.superseded_by, updated_at=excluded.updated_at,
|
|
776
|
+
pinned=excluded.pinned, guardrail_level=excluded.guardrail_level`)
|
|
777
|
+
.run({
|
|
778
|
+
id: m.id,
|
|
779
|
+
kind: m.kind,
|
|
780
|
+
claim: m.claim,
|
|
781
|
+
confidence: m.confidence,
|
|
782
|
+
status: m.status,
|
|
783
|
+
created_by: m.createdBy,
|
|
784
|
+
created_at: m.createdAt,
|
|
785
|
+
verified_at: m.verifiedAt,
|
|
786
|
+
superseded_by: null, // applied without FK risk; relinked below if target exists
|
|
787
|
+
updated_at: m.updatedAt ?? m.createdAt,
|
|
788
|
+
pinned: m.pinned ? 1 : 0,
|
|
789
|
+
guardrail_level: m.guardrailLevel ?? null,
|
|
790
|
+
});
|
|
791
|
+
if (m.supersededBy) {
|
|
792
|
+
this.db
|
|
793
|
+
.prepare("UPDATE memories SET superseded_by = ? WHERE id = ? AND EXISTS (SELECT 1 FROM memories WHERE id = ?)")
|
|
794
|
+
.run(m.supersededBy, m.id, m.supersededBy);
|
|
795
|
+
}
|
|
796
|
+
this.db.prepare("DELETE FROM memory_scopes WHERE memory_id = ?").run(m.id);
|
|
797
|
+
const scopeStmt = this.db.prepare("INSERT OR IGNORE INTO memory_scopes (memory_id, scope_type, value) VALUES (?, ?, ?)");
|
|
798
|
+
for (const p of m.scope.paths)
|
|
799
|
+
scopeStmt.run(m.id, "path", p);
|
|
800
|
+
for (const s of m.scope.symbols)
|
|
801
|
+
scopeStmt.run(m.id, "symbol", s);
|
|
802
|
+
this.db.prepare("DELETE FROM evidence WHERE memory_id = ?").run(m.id);
|
|
803
|
+
const evStmt = this.db.prepare("INSERT INTO evidence (id, memory_id, type, payload, last_run, result) VALUES (?, ?, ?, ?, ?, ?)");
|
|
804
|
+
for (const e of m.grounding)
|
|
805
|
+
evStmt.run(e.id, m.id, e.type, e.payload, e.lastRun, e.result);
|
|
806
|
+
});
|
|
807
|
+
tx();
|
|
808
|
+
}
|
|
809
|
+
proposalUpdatedAt(id) {
|
|
810
|
+
const row = this.db
|
|
811
|
+
.prepare("SELECT COALESCE(updated_at, created_at) AS u FROM proposals WHERE id = ?")
|
|
812
|
+
.get(id);
|
|
813
|
+
return row?.u ?? null;
|
|
814
|
+
}
|
|
815
|
+
applyRemoteProposal(p) {
|
|
816
|
+
try {
|
|
817
|
+
this.db
|
|
818
|
+
.prepare(`INSERT INTO proposals (id, kind, claim, paths, symbols, evidence, source, source_ref, rationale, created_at, status, memory_id, updated_at, ticket_ref, guardrail_level)
|
|
819
|
+
VALUES (@id, @kind, @claim, @paths, @symbols, @evidence, @source, @source_ref, @rationale, @created_at, @status, NULL, @updated_at, @ticket_ref, @guardrail_level)
|
|
820
|
+
ON CONFLICT(id) DO UPDATE SET status=excluded.status, updated_at=excluded.updated_at`)
|
|
821
|
+
.run({
|
|
822
|
+
id: p.id,
|
|
823
|
+
kind: p.kind,
|
|
824
|
+
claim: p.claim,
|
|
825
|
+
paths: JSON.stringify(p.paths),
|
|
826
|
+
symbols: JSON.stringify(p.symbols),
|
|
827
|
+
evidence: JSON.stringify(p.evidence),
|
|
828
|
+
source: p.source,
|
|
829
|
+
source_ref: p.sourceRef ?? "",
|
|
830
|
+
rationale: p.rationale ?? null,
|
|
831
|
+
created_at: p.createdAt,
|
|
832
|
+
status: p.status,
|
|
833
|
+
updated_at: p.updatedAt ?? p.createdAt,
|
|
834
|
+
ticket_ref: p.ticketRef ?? null,
|
|
835
|
+
guardrail_level: p.guardrailLevel ?? null,
|
|
836
|
+
});
|
|
837
|
+
}
|
|
838
|
+
catch (err) {
|
|
839
|
+
// (source, source_ref, claim) dedupe collision with a different id —
|
|
840
|
+
// the claim is already represented locally; skip rather than fail sync.
|
|
841
|
+
if (err instanceof Error && /UNIQUE constraint/.test(err.message))
|
|
842
|
+
return;
|
|
843
|
+
throw err;
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
/** Apply a remote tombstone: delete locally without creating a new tombstone loop. */
|
|
847
|
+
applyRemoteTombstone(id, tbl, deletedAt) {
|
|
848
|
+
const tx = this.db.transaction(() => {
|
|
849
|
+
if (tbl === "memories") {
|
|
850
|
+
this.db.prepare("UPDATE proposals SET memory_id = NULL WHERE memory_id = ?").run(id);
|
|
851
|
+
this.db.prepare("UPDATE memories SET superseded_by = NULL WHERE superseded_by = ?").run(id);
|
|
852
|
+
this.db.prepare("DELETE FROM memories WHERE id = ?").run(id);
|
|
853
|
+
}
|
|
854
|
+
else {
|
|
855
|
+
this.db.prepare("DELETE FROM proposals WHERE id = ?").run(id);
|
|
856
|
+
}
|
|
857
|
+
this.db
|
|
858
|
+
.prepare("INSERT OR REPLACE INTO tombstones (id, tbl, deleted_at) VALUES (?, ?, ?)")
|
|
859
|
+
.run(id, tbl, deletedAt);
|
|
860
|
+
});
|
|
861
|
+
tx();
|
|
862
|
+
}
|
|
863
|
+
close() {
|
|
864
|
+
this.db.close();
|
|
865
|
+
}
|
|
866
|
+
// ---------------------------------------------------------------- private
|
|
867
|
+
hydrate(row) {
|
|
868
|
+
const scopes = this.db
|
|
869
|
+
.prepare("SELECT scope_type, value FROM memory_scopes WHERE memory_id = ?")
|
|
870
|
+
.all(row.id);
|
|
871
|
+
const evidence = this.db
|
|
872
|
+
.prepare("SELECT * FROM evidence WHERE memory_id = ?")
|
|
873
|
+
.all(row.id);
|
|
874
|
+
const links = this.db
|
|
875
|
+
.prepare("SELECT from_id, to_id, relation FROM memory_links WHERE from_id = ? OR to_id = ?")
|
|
876
|
+
.all(row.id, row.id);
|
|
877
|
+
return {
|
|
878
|
+
id: row.id,
|
|
879
|
+
kind: row.kind,
|
|
880
|
+
claim: row.claim,
|
|
881
|
+
scope: {
|
|
882
|
+
paths: scopes.filter((s) => s.scope_type === "path").map((s) => s.value),
|
|
883
|
+
symbols: scopes.filter((s) => s.scope_type === "symbol").map((s) => s.value),
|
|
884
|
+
},
|
|
885
|
+
confidence: row.confidence,
|
|
886
|
+
status: row.status,
|
|
887
|
+
pinned: !!row.pinned,
|
|
888
|
+
createdBy: row.created_by,
|
|
889
|
+
createdAt: row.created_at,
|
|
890
|
+
verifiedAt: row.verified_at,
|
|
891
|
+
supersededBy: row.superseded_by,
|
|
892
|
+
updatedAt: row.updated_at ?? null,
|
|
893
|
+
grounding: evidence.map((e) => ({
|
|
894
|
+
id: e.id,
|
|
895
|
+
memoryId: e.memory_id,
|
|
896
|
+
type: e.type,
|
|
897
|
+
payload: e.payload,
|
|
898
|
+
lastRun: e.last_run,
|
|
899
|
+
result: e.result,
|
|
900
|
+
})),
|
|
901
|
+
links: links.map((l) => ({ fromId: l.from_id, toId: l.to_id, relation: l.relation })),
|
|
902
|
+
guardrailLevel: row.guardrail_level ?? undefined,
|
|
903
|
+
};
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
//# sourceMappingURL=store.js.map
|