agim-cli 1.1.11 → 1.2.1

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.
Files changed (73) hide show
  1. package/CHANGELOG.md +169 -0
  2. package/dist/cli.js +78 -0
  3. package/dist/cli.js.map +1 -1
  4. package/dist/core/approval-bus.d.ts +18 -0
  5. package/dist/core/approval-bus.d.ts.map +1 -1
  6. package/dist/core/approval-bus.js +111 -0
  7. package/dist/core/approval-bus.js.map +1 -1
  8. package/dist/core/approval-router.d.ts.map +1 -1
  9. package/dist/core/approval-router.js +12 -0
  10. package/dist/core/approval-router.js.map +1 -1
  11. package/dist/core/audit-log.d.ts +39 -0
  12. package/dist/core/audit-log.d.ts.map +1 -1
  13. package/dist/core/audit-log.js +124 -0
  14. package/dist/core/audit-log.js.map +1 -1
  15. package/dist/core/boot-state.d.ts +17 -0
  16. package/dist/core/boot-state.d.ts.map +1 -0
  17. package/dist/core/boot-state.js +77 -0
  18. package/dist/core/boot-state.js.map +1 -0
  19. package/dist/core/job-recovery.d.ts +41 -1
  20. package/dist/core/job-recovery.d.ts.map +1 -1
  21. package/dist/core/job-recovery.js +216 -4
  22. package/dist/core/job-recovery.js.map +1 -1
  23. package/dist/core/memory-consolidate.d.ts +12 -0
  24. package/dist/core/memory-consolidate.d.ts.map +1 -0
  25. package/dist/core/memory-consolidate.js +242 -0
  26. package/dist/core/memory-consolidate.js.map +1 -0
  27. package/dist/core/memory-distill.d.ts +30 -0
  28. package/dist/core/memory-distill.d.ts.map +1 -0
  29. package/dist/core/memory-distill.js +213 -0
  30. package/dist/core/memory-distill.js.map +1 -0
  31. package/dist/core/memory-rpc.d.ts +11 -0
  32. package/dist/core/memory-rpc.d.ts.map +1 -0
  33. package/dist/core/memory-rpc.js +94 -0
  34. package/dist/core/memory-rpc.js.map +1 -0
  35. package/dist/core/memory-vector.d.ts +47 -0
  36. package/dist/core/memory-vector.d.ts.map +1 -0
  37. package/dist/core/memory-vector.js +386 -0
  38. package/dist/core/memory-vector.js.map +1 -0
  39. package/dist/core/memory.d.ts +140 -0
  40. package/dist/core/memory.d.ts.map +1 -0
  41. package/dist/core/memory.js +714 -0
  42. package/dist/core/memory.js.map +1 -0
  43. package/dist/core/persona.d.ts +24 -0
  44. package/dist/core/persona.d.ts.map +1 -0
  45. package/dist/core/persona.js +80 -0
  46. package/dist/core/persona.js.map +1 -0
  47. package/dist/core/push-rpc.d.ts +26 -0
  48. package/dist/core/push-rpc.d.ts.map +1 -0
  49. package/dist/core/push-rpc.js +123 -0
  50. package/dist/core/push-rpc.js.map +1 -0
  51. package/dist/core/router.d.ts.map +1 -1
  52. package/dist/core/router.js +26 -1
  53. package/dist/core/router.js.map +1 -1
  54. package/dist/core/types.d.ts +41 -0
  55. package/dist/core/types.d.ts.map +1 -1
  56. package/dist/plugins/agents/claude-code/index.d.ts +9 -0
  57. package/dist/plugins/agents/claude-code/index.d.ts.map +1 -1
  58. package/dist/plugins/agents/claude-code/index.js +37 -0
  59. package/dist/plugins/agents/claude-code/index.js.map +1 -1
  60. package/dist/plugins/agents/claude-code/mcp-approval-server.d.ts +8 -0
  61. package/dist/plugins/agents/claude-code/mcp-approval-server.d.ts.map +1 -1
  62. package/dist/plugins/agents/claude-code/mcp-approval-server.js +181 -0
  63. package/dist/plugins/agents/claude-code/mcp-approval-server.js.map +1 -1
  64. package/dist/plugins/messengers/telegram/telegram-adapter.d.ts +5 -1
  65. package/dist/plugins/messengers/telegram/telegram-adapter.d.ts.map +1 -1
  66. package/dist/plugins/messengers/telegram/telegram-adapter.js +85 -0
  67. package/dist/plugins/messengers/telegram/telegram-adapter.js.map +1 -1
  68. package/dist/web/public/settings.html +106 -10
  69. package/dist/web/public/tasks.html +992 -1
  70. package/dist/web/server.d.ts.map +1 -1
  71. package/dist/web/server.js +433 -6
  72. package/dist/web/server.js.map +1 -1
  73. package/package.json +4 -1
@@ -0,0 +1,714 @@
1
+ // memory — long-term agent memory: persona profiles + fact store.
2
+ //
3
+ // Two tables, one SQLite file (~/.agim/memory.db):
4
+ //
5
+ // persona_profile (user_key, summary, updated_at)
6
+ // One row per user. `summary` is a 150-300 token Chinese/English
7
+ // distilled snapshot rebuilt by daily consolidation. Always injected
8
+ // into agent prompts (when present) via router.ts → buildPersonaSnippet.
9
+ //
10
+ // facts (id, user_key, what, who, when_text, where_label, why, category,
11
+ // confidence, last_referenced_at, source, created_at)
12
+ // Append-only fact store. Records written by memory-distill.ts after
13
+ // each agent turn + manual saves from /memo or memory_save MCP. Read
14
+ // via memory_query (hybrid FTS5 + recency) when agent decides to query.
15
+ //
16
+ // `user_key` = `${platform}:${userId}` so a single human across multiple
17
+ // IMs converges to the same memory if userId is consistent. Otherwise
18
+ // each IM persona is independent.
19
+ //
20
+ // Vector index (sqlite-vss) is **optional** for v1.5 — when the extension
21
+ // isn't loaded the helper falls back to FTS5-only retrieval. Vector adds
22
+ // quality, not correctness.
23
+ import { join } from 'node:path';
24
+ import { logger as rootLogger } from './logger.js';
25
+ import { createSqliteHelper } from './sqlite-helper.js';
26
+ import { AGIM_HOME } from './agim-paths.js';
27
+ const MEMORY_DB = join(AGIM_HOME, 'memory.db');
28
+ const log = rootLogger.child({ component: 'memory' });
29
+ const SCHEMA = `
30
+ CREATE TABLE IF NOT EXISTS persona_profile (
31
+ user_key TEXT PRIMARY KEY,
32
+ summary TEXT NOT NULL,
33
+ updated_at INTEGER NOT NULL
34
+ );
35
+
36
+ CREATE TABLE IF NOT EXISTS facts (
37
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
38
+ user_key TEXT NOT NULL,
39
+ what TEXT NOT NULL,
40
+ who TEXT DEFAULT '',
41
+ when_text TEXT DEFAULT '',
42
+ where_label TEXT DEFAULT '',
43
+ why TEXT DEFAULT '',
44
+ category TEXT NOT NULL DEFAULT 'fact',
45
+ confidence REAL NOT NULL DEFAULT 0.6,
46
+ last_referenced_at INTEGER NOT NULL,
47
+ source TEXT DEFAULT '',
48
+ created_at INTEGER NOT NULL
49
+ );
50
+
51
+ CREATE INDEX IF NOT EXISTS idx_facts_user ON facts(user_key, created_at DESC);
52
+ CREATE INDEX IF NOT EXISTS idx_facts_cat ON facts(user_key, category);
53
+
54
+ -- FTS5 virtual table for keyword retrieval. Tokenizer porter handles
55
+ -- English stemming; CJK characters are indexed as bigrams via unicode61
56
+ -- if available. We compose the indexed text from the 5W1H slots.
57
+ CREATE VIRTUAL TABLE IF NOT EXISTS facts_fts USING fts5(
58
+ user_key UNINDEXED,
59
+ text,
60
+ content='',
61
+ tokenize='unicode61'
62
+ );
63
+ `;
64
+ /** v1.6 — vector retrieval columns (added via ALTER, idempotent). */
65
+ function migrateVectorColumns(db) {
66
+ const cols = db.prepare('PRAGMA table_info(facts)').all();
67
+ const have = new Set(cols.map((c) => c.name));
68
+ const adds = [];
69
+ if (!have.has('embedding'))
70
+ adds.push(['embedding', 'BLOB']);
71
+ if (!have.has('embedding_model'))
72
+ adds.push(['embedding_model', 'TEXT']);
73
+ if (!have.has('embedding_dims'))
74
+ adds.push(['embedding_dims', 'INTEGER']);
75
+ if (!have.has('embedded_at'))
76
+ adds.push(['embedded_at', 'INTEGER']);
77
+ for (const [col, type] of adds) {
78
+ try {
79
+ db.prepare(`ALTER TABLE facts ADD COLUMN ${col} ${type}`).run();
80
+ }
81
+ catch (err) {
82
+ // Concurrent migration (different process) — ignore "duplicate column" error.
83
+ if (!/duplicate column name/i.test(String(err))) {
84
+ rootLogger.warn({ event: 'memory.migrate_failed', col, err: String(err) });
85
+ }
86
+ }
87
+ }
88
+ }
89
+ const helper = createSqliteHelper({
90
+ file: MEMORY_DB,
91
+ schema: SCHEMA,
92
+ logger: log,
93
+ component: 'memory',
94
+ init: (d) => { migrateVectorColumns(d); },
95
+ });
96
+ export function userKey(platform, userId) {
97
+ return `${platform}:${userId || 'anon'}`;
98
+ }
99
+ function nowSec() {
100
+ return Math.floor(Date.now() / 1000);
101
+ }
102
+ /**
103
+ * CJK tokenization helper. FTS5's `unicode61` tokenizer treats a run of
104
+ * adjacent CJK characters as a single token (e.g. "用户在杭州工作" → one
105
+ * token), so a query for "杭州" never matches. We force per-character
106
+ * boundaries on both the indexed text AND the query string so each CJK
107
+ * character becomes its own token; multi-char queries then match via
108
+ * FTS5's implicit AND across tokens.
109
+ *
110
+ * Range covers: CJK Unified Ideographs (4E00–9FFF), CJK Extension A
111
+ * (3400–4DBF), Hiragana (3040–309F), Katakana (30A0–30FF),
112
+ * Hangul Syllables (AC00–D7AF).
113
+ */
114
+ const CJK_RE = /[㐀-䶿一-鿿぀-ヿ가-힯]/;
115
+ function cjkSeparate(s) {
116
+ // Insert a space after every CJK char that is immediately followed by
117
+ // another non-space character (CJK or ASCII). Cheap; runs over short text.
118
+ let out = '';
119
+ for (let i = 0; i < s.length; i++) {
120
+ out += s[i];
121
+ if (CJK_RE.test(s[i]) && i + 1 < s.length && s[i + 1] !== ' ')
122
+ out += ' ';
123
+ }
124
+ return out;
125
+ }
126
+ /**
127
+ * Flatten a fact's 5W1H slots into a single string for FTS5 indexing.
128
+ * Repeats `what` twice so it dominates rank — that's the primary content.
129
+ */
130
+ function indexableText(f) {
131
+ const raw = [f.what, f.what, f.who, f.when_text, f.where_label, f.why]
132
+ .filter(Boolean)
133
+ .join(' · ');
134
+ return cjkSeparate(raw);
135
+ }
136
+ /**
137
+ * Build a safe FTS5 MATCH expression from arbitrary user input.
138
+ * Wrapping in a quoted phrase neutralizes operators (`AND`, `OR`, `:`,
139
+ * `(`, `*`, `+`, etc.) that would otherwise crash the prepared statement.
140
+ * Internal double-quotes are replaced with spaces (rare in fact text).
141
+ * Also CJK-separates so per-char tokens match per-char indexed tokens.
142
+ */
143
+ function buildFtsMatch(q) {
144
+ const sep = cjkSeparate(q.replace(/"/g, ' '))
145
+ .replace(/\s+/g, ' ')
146
+ .trim();
147
+ if (!sep)
148
+ return '';
149
+ return `"${sep}"`;
150
+ }
151
+ export function saveFact(input) {
152
+ const db = helper.get();
153
+ if (!db)
154
+ return null;
155
+ const ts = nowSec();
156
+ try {
157
+ const text = indexableText(input);
158
+ // Wrap the two inserts in a transaction — without this, a crash
159
+ // between row and FTS insert leaves the FTS index out of sync.
160
+ const id = db.transaction(() => {
161
+ const r = db.prepare(`
162
+ INSERT INTO facts (user_key, what, who, when_text, where_label, why, category, confidence, last_referenced_at, source, created_at)
163
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
164
+ `).run(input.user_key, input.what, input.who ?? '', input.when_text ?? '', input.where_label ?? '', input.why ?? '', input.category ?? 'fact', Math.max(0, Math.min(1, input.confidence ?? 0.6)), ts, input.source ?? '', ts);
165
+ const factId = Number(r.lastInsertRowid);
166
+ db.prepare('INSERT INTO facts_fts(rowid, user_key, text) VALUES (?, ?, ?)')
167
+ .run(factId, input.user_key, text);
168
+ return factId;
169
+ })();
170
+ // v1.6 — async embed + write to embedding column. Non-blocking — fact
171
+ // is already persisted, vector is a soft add. If the backend is off /
172
+ // not ready / network fails, the fact still works (falls back to FTS5
173
+ // on query). setImmediate ensures we don't hold the caller.
174
+ setImmediate(() => { void embedAndStoreFact(id, text); });
175
+ return id;
176
+ }
177
+ catch (err) {
178
+ log.warn({ event: 'memory.save_failed', err: String(err) });
179
+ return null;
180
+ }
181
+ }
182
+ // Process-lifetime flag so we warn at most once when the vector backend
183
+ // is enabled in env but not actually usable (model not downloaded, no key,
184
+ // etc.). Without this, every saveFact would silently skip the embed step
185
+ // and users would be confused why memory_query falls back to FTS5-only.
186
+ let _embedNotReadyWarned = false;
187
+ async function embedAndStoreFact(factId, text) {
188
+ try {
189
+ const { getActiveBackend, float32ToBuffer } = await import('./memory-vector.js');
190
+ const backend = getActiveBackend();
191
+ if (!backend.ready) {
192
+ if (backend.name !== 'off' && !_embedNotReadyWarned) {
193
+ _embedNotReadyWarned = true;
194
+ log.info({
195
+ event: 'memory.embed.backend_not_ready',
196
+ backend: backend.name, modelId: backend.modelId,
197
+ }, 'vector backend configured but not ready — facts saved without embeddings; run /memory backfill once it becomes ready');
198
+ }
199
+ return;
200
+ }
201
+ const vecs = await backend.embed([text]);
202
+ const v = vecs[0];
203
+ if (!v || v.length === 0)
204
+ return;
205
+ const db = helper.get();
206
+ if (!db)
207
+ return;
208
+ db.prepare(`
209
+ UPDATE facts SET embedding = ?, embedding_model = ?, embedding_dims = ?, embedded_at = ?
210
+ WHERE id = ?
211
+ `).run(float32ToBuffer(v), `${backend.name}:${backend.modelId}`, v.length, nowSec(), factId);
212
+ }
213
+ catch (err) {
214
+ // Embedding failures must never break fact storage. Already persisted.
215
+ log.debug({ event: 'memory.embed_async_failed', factId, err: String(err) });
216
+ }
217
+ }
218
+ export function deleteFact(id, user_key) {
219
+ const db = helper.get();
220
+ if (!db)
221
+ return false;
222
+ try {
223
+ return db.transaction(() => {
224
+ const r = db.prepare('DELETE FROM facts WHERE id = ? AND user_key = ?').run(id, user_key);
225
+ if (r.changes > 0) {
226
+ db.prepare('DELETE FROM facts_fts WHERE rowid = ?').run(id);
227
+ }
228
+ return r.changes > 0;
229
+ })();
230
+ }
231
+ catch (err) {
232
+ log.warn({ event: 'memory.delete_failed', err: String(err) });
233
+ return false;
234
+ }
235
+ }
236
+ /**
237
+ * Hybrid retrieval. v1.5 first cut uses FTS5 + recency boost; v1.6 will
238
+ * layer in sqlite-vss dense vectors and rerank.
239
+ *
240
+ * - Empty query → most-recent N facts.
241
+ * - With query → FTS5 MATCH, sorted by FTS5's bm25() with a recency tie-
242
+ * breaker (newer first).
243
+ *
244
+ * Side effect: bumps `last_referenced_at` for returned rows so weak /
245
+ * unused facts can be retired by future consolidation.
246
+ */
247
+ export function queryFacts(opts) {
248
+ const db = helper.get();
249
+ if (!db)
250
+ return { facts: [], matched: 0 };
251
+ const k = Math.min(Math.max(opts.k ?? 5, 1), 50);
252
+ const cat = opts.category;
253
+ const q = (opts.query || '').trim();
254
+ try {
255
+ let rows;
256
+ if (!q) {
257
+ rows = db.prepare(`
258
+ SELECT * FROM facts
259
+ WHERE user_key = ? ${cat ? 'AND category = ?' : ''}
260
+ ORDER BY created_at DESC LIMIT ?
261
+ `).all(...(cat ? [opts.user_key, cat, k] : [opts.user_key, k]));
262
+ }
263
+ else {
264
+ const safeQ = buildFtsMatch(q);
265
+ if (!safeQ)
266
+ return { facts: [], matched: 0 };
267
+ rows = db.prepare(`
268
+ SELECT f.* FROM facts_fts t
269
+ JOIN facts f ON f.id = t.rowid
270
+ WHERE t.user_key = ? AND facts_fts MATCH ?
271
+ ${cat ? 'AND f.category = ?' : ''}
272
+ ORDER BY bm25(facts_fts), f.created_at DESC
273
+ LIMIT ?
274
+ `).all(...(cat
275
+ ? [opts.user_key, safeQ, cat, k]
276
+ : [opts.user_key, safeQ, k * 3])); // pull 3x for RRF headroom
277
+ }
278
+ // Bump last_referenced_at for everything we returned.
279
+ if (rows.length > 0) {
280
+ const ts = nowSec();
281
+ const stmt = db.prepare('UPDATE facts SET last_referenced_at = ? WHERE id = ?');
282
+ for (const r of rows) {
283
+ try {
284
+ stmt.run(ts, r.id);
285
+ }
286
+ catch { /* ignore */ }
287
+ }
288
+ }
289
+ return { facts: rows.slice(0, k), matched: rows.length };
290
+ }
291
+ catch (err) {
292
+ log.warn({ event: 'memory.query_failed', err: String(err), q });
293
+ return { facts: [], matched: 0 };
294
+ }
295
+ }
296
+ /**
297
+ * v1.6 — async hybrid retrieval. When a vector backend is ready, run FTS5
298
+ * + vector top-K in parallel, fuse with reciprocal-rank-fusion, and return
299
+ * the merged top-K. Falls back to plain queryFacts when backend is off or
300
+ * the embedding model isn't loaded.
301
+ *
302
+ * Use this from memory-rpc.handleMemoryOp('query') so MCP callers get
303
+ * better recall automatically without changing the call site.
304
+ */
305
+ export async function queryFactsHybrid(opts) {
306
+ const k = Math.min(Math.max(opts.k ?? 5, 1), 50);
307
+ const q = (opts.query || '').trim();
308
+ if (!q)
309
+ return queryFacts(opts);
310
+ let backend;
311
+ try {
312
+ backend = (await import('./memory-vector.js')).getActiveBackend();
313
+ }
314
+ catch {
315
+ return queryFacts(opts);
316
+ }
317
+ if (!backend.ready)
318
+ return queryFacts(opts);
319
+ // FTS5 candidates first (synchronous, fast).
320
+ const ftsResult = queryFacts({ ...opts, k: Math.min(k * 3, 30) });
321
+ // Vector candidates next (async, may time out).
322
+ let vecResult = [];
323
+ try {
324
+ const { cosineSim, bufferToFloat32, getHybridWeight } = await import('./memory-vector.js');
325
+ void getHybridWeight; // (currently fixed weight in RRF; reserved for future tuning)
326
+ const queryVecArr = await backend.embed([q]);
327
+ const queryVec = queryVecArr[0];
328
+ if (queryVec && queryVec.length > 0) {
329
+ const db = helper.get();
330
+ if (db) {
331
+ // Pull candidates that match the ACTIVE backend's model + dims.
332
+ // Mixing embeddings from different models in one cosine ranking
333
+ // produces garbage similarities (different vector spaces).
334
+ const expectedModel = `${backend.name}:${backend.modelId}`;
335
+ const candidates = db.prepare(`
336
+ SELECT * FROM facts
337
+ WHERE user_key = ?
338
+ AND embedding IS NOT NULL
339
+ AND embedding_dims = ?
340
+ AND embedding_model = ?
341
+ ${opts.category ? 'AND category = ?' : ''}
342
+ `).all(...(opts.category
343
+ ? [opts.user_key, queryVec.length, expectedModel, opts.category]
344
+ : [opts.user_key, queryVec.length, expectedModel]));
345
+ const scored = candidates.map((row) => ({
346
+ row, score: cosineSim(queryVec, bufferToFloat32(row.embedding)),
347
+ }));
348
+ scored.sort((a, b) => b.score - a.score);
349
+ vecResult = scored.slice(0, k * 3).map((s) => s.row);
350
+ }
351
+ }
352
+ }
353
+ catch (err) {
354
+ log.debug({ event: 'memory.hybrid.vector_leg_failed', err: String(err) });
355
+ }
356
+ // RRF fusion. k_const=60 is the standard RRF damping.
357
+ const RRF_K = 60;
358
+ const scores = new Map();
359
+ const idToFact = new Map();
360
+ ftsResult.facts.forEach((f, i) => {
361
+ scores.set(f.id, (scores.get(f.id) || 0) + 1 / (RRF_K + i + 1));
362
+ idToFact.set(f.id, f);
363
+ });
364
+ vecResult.forEach((f, i) => {
365
+ scores.set(f.id, (scores.get(f.id) || 0) + 1 / (RRF_K + i + 1));
366
+ idToFact.set(f.id, f);
367
+ });
368
+ const ranked = Array.from(scores.entries())
369
+ .sort((a, b) => b[1] - a[1])
370
+ .slice(0, k)
371
+ .map(([id]) => idToFact.get(id))
372
+ .filter(Boolean);
373
+ // Bump last_referenced_at for the merged result set.
374
+ const db = helper.get();
375
+ if (db && ranked.length > 0) {
376
+ const ts = nowSec();
377
+ const stmt = db.prepare('UPDATE facts SET last_referenced_at = ? WHERE id = ?');
378
+ for (const r of ranked) {
379
+ try {
380
+ stmt.run(ts, r.id);
381
+ }
382
+ catch { /* ignore */ }
383
+ }
384
+ }
385
+ return { facts: ranked, matched: ranked.length };
386
+ }
387
+ /** v1.6 — backfill embeddings for facts that don't have one (e.g. existed
388
+ * before vector backend was enabled). Returns counts; safe to call again.
389
+ * Stops early on backend errors (returns partial). */
390
+ export async function backfillEmbeddings(opts) {
391
+ const db = helper.get();
392
+ if (!db)
393
+ return { processed: 0, succeeded: 0, failed: 0, errored: 'db unavailable' };
394
+ let backend;
395
+ try {
396
+ backend = (await import('./memory-vector.js')).getActiveBackend();
397
+ }
398
+ catch (err) {
399
+ return { processed: 0, succeeded: 0, failed: 0, errored: String(err) };
400
+ }
401
+ if (!backend.ready)
402
+ return { processed: 0, succeeded: 0, failed: 0, errored: 'backend not ready' };
403
+ const { float32ToBuffer, getBatchSize } = await import('./memory-vector.js');
404
+ const batchSize = opts.batchSize ?? getBatchSize();
405
+ const maxRows = opts.maxRows ?? 5000;
406
+ const where = opts.user_key
407
+ ? 'WHERE user_key = ? AND (embedding IS NULL OR embedding_model != ?)'
408
+ : 'WHERE embedding IS NULL OR embedding_model != ?';
409
+ const expectedModel = `${backend.name}:${backend.modelId}`;
410
+ const selectParams = opts.user_key
411
+ ? [opts.user_key, expectedModel]
412
+ : [expectedModel];
413
+ const rows = db.prepare(`
414
+ SELECT id, user_key, what, who, when_text, where_label, why FROM facts
415
+ ${where} LIMIT ?
416
+ `).all(...selectParams, maxRows);
417
+ let processed = 0, succeeded = 0, failed = 0;
418
+ for (let i = 0; i < rows.length; i += batchSize) {
419
+ const batch = rows.slice(i, i + batchSize);
420
+ const texts = batch.map((r) => indexableText(r));
421
+ let vecs = [];
422
+ try {
423
+ vecs = await backend.embed(texts);
424
+ }
425
+ catch (err) {
426
+ log.warn({ event: 'memory.backfill.batch_failed', err: String(err) });
427
+ failed += batch.length;
428
+ processed += batch.length;
429
+ continue;
430
+ }
431
+ const ts = nowSec();
432
+ const stmt = db.prepare(`
433
+ UPDATE facts SET embedding = ?, embedding_model = ?, embedding_dims = ?, embedded_at = ?
434
+ WHERE id = ?
435
+ `);
436
+ for (let j = 0; j < batch.length; j++) {
437
+ const v = vecs[j];
438
+ if (!v || v.length === 0) {
439
+ failed++;
440
+ processed++;
441
+ continue;
442
+ }
443
+ try {
444
+ stmt.run(float32ToBuffer(v), expectedModel, v.length, ts, batch[j].id);
445
+ succeeded++;
446
+ }
447
+ catch (err) {
448
+ log.debug({ event: 'memory.backfill.write_failed', id: batch[j].id, err: String(err) });
449
+ failed++;
450
+ }
451
+ processed++;
452
+ }
453
+ // Yield between batches so we don't hog the event loop on large backfills.
454
+ await new Promise((r) => setImmediate(r));
455
+ }
456
+ return { processed, succeeded, failed };
457
+ }
458
+ export function getVectorCoverage(user_key) {
459
+ const db = helper.get();
460
+ if (!db)
461
+ return { total: 0, withEmbedding: 0, withDifferentModel: 0 };
462
+ try {
463
+ const where = user_key ? 'WHERE user_key = ?' : '';
464
+ const params = user_key ? [user_key] : [];
465
+ const total = db.prepare(`SELECT COUNT(*) AS n FROM facts ${where}`).get(...params).n;
466
+ const withEmb = db.prepare(`SELECT COUNT(*) AS n FROM facts ${where} ${where ? 'AND' : 'WHERE'} embedding IS NOT NULL`).get(...params).n;
467
+ return { total, withEmbedding: withEmb, withDifferentModel: 0 };
468
+ }
469
+ catch {
470
+ return { total: 0, withEmbedding: 0, withDifferentModel: 0 };
471
+ }
472
+ }
473
+ /** Clear all embedding fields for a user (or all). Useful when switching
474
+ * backends — embeddings from different models can't be compared. */
475
+ export function clearEmbeddings(user_key) {
476
+ const db = helper.get();
477
+ if (!db)
478
+ return 0;
479
+ try {
480
+ const where = user_key ? 'WHERE user_key = ?' : '';
481
+ const params = user_key ? [user_key] : [];
482
+ const r = db.prepare(`
483
+ UPDATE facts SET embedding = NULL, embedding_model = NULL, embedding_dims = NULL, embedded_at = NULL
484
+ ${where}
485
+ `).run(...params);
486
+ return r.changes;
487
+ }
488
+ catch (err) {
489
+ log.warn({ event: 'memory.clear_embeddings_failed', err: String(err) });
490
+ return 0;
491
+ }
492
+ }
493
+ export function listRecentFacts(user_key, limit = 50) {
494
+ const db = helper.get();
495
+ if (!db)
496
+ return [];
497
+ try {
498
+ return db.prepare(`
499
+ SELECT * FROM facts WHERE user_key = ? ORDER BY created_at DESC LIMIT ?
500
+ `).all(user_key, Math.min(Math.max(limit, 1), 500));
501
+ }
502
+ catch {
503
+ return [];
504
+ }
505
+ }
506
+ export function countFacts(user_key) {
507
+ const db = helper.get();
508
+ if (!db)
509
+ return 0;
510
+ try {
511
+ const r = db.prepare('SELECT COUNT(*) AS n FROM facts WHERE user_key = ?').get(user_key);
512
+ return r.n;
513
+ }
514
+ catch {
515
+ return 0;
516
+ }
517
+ }
518
+ export function getPersona(user_key) {
519
+ const db = helper.get();
520
+ if (!db)
521
+ return null;
522
+ try {
523
+ return db.prepare('SELECT * FROM persona_profile WHERE user_key = ?')
524
+ .get(user_key) ?? null;
525
+ }
526
+ catch {
527
+ return null;
528
+ }
529
+ }
530
+ export function upsertPersona(user_key, summary) {
531
+ const db = helper.get();
532
+ if (!db)
533
+ return false;
534
+ try {
535
+ db.prepare(`
536
+ INSERT INTO persona_profile (user_key, summary, updated_at) VALUES (?, ?, ?)
537
+ ON CONFLICT(user_key) DO UPDATE SET summary = excluded.summary, updated_at = excluded.updated_at
538
+ `).run(user_key, summary, nowSec());
539
+ return true;
540
+ }
541
+ catch (err) {
542
+ log.warn({ event: 'memory.persona_upsert_failed', err: String(err) });
543
+ return false;
544
+ }
545
+ }
546
+ /** Enumerate every user_key the memory system has touched, with quick stats.
547
+ * Used by /api/memory/users to populate the admin selector. */
548
+ export function listUsers() {
549
+ const db = helper.get();
550
+ if (!db)
551
+ return [];
552
+ try {
553
+ const factsRows = db.prepare(`
554
+ SELECT
555
+ user_key,
556
+ COUNT(*) AS fact_count,
557
+ MIN(created_at) AS oldest_fact_at,
558
+ MAX(created_at) AS newest_fact_at
559
+ FROM facts GROUP BY user_key
560
+ `).all();
561
+ const personaRows = db.prepare('SELECT user_key, updated_at FROM persona_profile').all();
562
+ const personaMap = new Map(personaRows.map((p) => [p.user_key, p.updated_at]));
563
+ const out = [];
564
+ const seen = new Set();
565
+ for (const f of factsRows) {
566
+ seen.add(f.user_key);
567
+ out.push({
568
+ user_key: f.user_key,
569
+ fact_count: f.fact_count,
570
+ oldest_fact_at: f.oldest_fact_at,
571
+ newest_fact_at: f.newest_fact_at,
572
+ has_persona: personaMap.has(f.user_key),
573
+ persona_updated_at: personaMap.get(f.user_key) ?? null,
574
+ });
575
+ }
576
+ // Users with a persona but no facts (rare — manual upsertPersona)
577
+ for (const p of personaRows) {
578
+ if (seen.has(p.user_key))
579
+ continue;
580
+ out.push({
581
+ user_key: p.user_key,
582
+ fact_count: 0,
583
+ oldest_fact_at: null,
584
+ newest_fact_at: null,
585
+ has_persona: true,
586
+ persona_updated_at: p.updated_at,
587
+ });
588
+ }
589
+ out.sort((a, b) => (b.newest_fact_at ?? 0) - (a.newest_fact_at ?? 0));
590
+ return out;
591
+ }
592
+ catch (err) {
593
+ log.warn({ event: 'memory.list_users_failed', err: String(err) });
594
+ return [];
595
+ }
596
+ }
597
+ /** Paged fact listing with optional filters. Mirrors queryFacts but adds
598
+ * offset + total count for table pagination. */
599
+ export function listFacts(opts) {
600
+ const db = helper.get();
601
+ if (!db)
602
+ return { total: 0, limit: 0, offset: 0, facts: [] };
603
+ const limit = Math.min(Math.max(opts.limit ?? 50, 1), 500);
604
+ const offset = Math.max(opts.offset ?? 0, 0);
605
+ const cat = opts.category;
606
+ const q = (opts.query || '').trim();
607
+ try {
608
+ let totalSql;
609
+ let totalParams;
610
+ let rowsSql;
611
+ let rowsParams;
612
+ if (!q) {
613
+ totalSql = `SELECT COUNT(*) AS n FROM facts WHERE user_key = ? ${cat ? 'AND category = ?' : ''}`;
614
+ totalParams = cat ? [opts.user_key, cat] : [opts.user_key];
615
+ rowsSql = `SELECT * FROM facts WHERE user_key = ? ${cat ? 'AND category = ?' : ''} ORDER BY created_at DESC LIMIT ? OFFSET ?`;
616
+ rowsParams = cat ? [opts.user_key, cat, limit, offset] : [opts.user_key, limit, offset];
617
+ }
618
+ else {
619
+ const safeQ = buildFtsMatch(q);
620
+ if (!safeQ)
621
+ return { total: 0, limit, offset, facts: [] };
622
+ totalSql = `
623
+ SELECT COUNT(*) AS n FROM facts_fts t JOIN facts f ON f.id = t.rowid
624
+ WHERE t.user_key = ? AND facts_fts MATCH ? ${cat ? 'AND f.category = ?' : ''}
625
+ `;
626
+ totalParams = cat ? [opts.user_key, safeQ, cat] : [opts.user_key, safeQ];
627
+ rowsSql = `
628
+ SELECT f.* FROM facts_fts t JOIN facts f ON f.id = t.rowid
629
+ WHERE t.user_key = ? AND facts_fts MATCH ? ${cat ? 'AND f.category = ?' : ''}
630
+ ORDER BY bm25(facts_fts), f.created_at DESC LIMIT ? OFFSET ?
631
+ `;
632
+ rowsParams = cat ? [opts.user_key, safeQ, cat, limit, offset] : [opts.user_key, safeQ, limit, offset];
633
+ }
634
+ const total = db.prepare(totalSql).get(...totalParams).n;
635
+ const facts = db.prepare(rowsSql).all(...rowsParams);
636
+ return { total, limit, offset, facts };
637
+ }
638
+ catch (err) {
639
+ log.warn({ event: 'memory.list_facts_failed', err: String(err) });
640
+ return { total: 0, limit, offset, facts: [] };
641
+ }
642
+ }
643
+ /** Bulk delete by user + optional filters. Returns row count actually deleted.
644
+ * Defaults are intentionally inert ({user_key} alone deletes ALL facts for
645
+ * that user) — caller must explicitly opt in to clear-all by passing
646
+ * confirm_clear: true. Otherwise needs at least one filter. */
647
+ export function bulkDeleteFacts(opts) {
648
+ const db = helper.get();
649
+ if (!db)
650
+ return 0;
651
+ const filters = ['user_key = ?'];
652
+ const params = [opts.user_key];
653
+ if (opts.ids && opts.ids.length > 0) {
654
+ const placeholders = opts.ids.map(() => '?').join(',');
655
+ filters.push(`id IN (${placeholders})`);
656
+ params.push(...opts.ids);
657
+ }
658
+ if (opts.category) {
659
+ filters.push('category = ?');
660
+ params.push(opts.category);
661
+ }
662
+ if (typeof opts.max_confidence === 'number') {
663
+ filters.push('confidence <= ?');
664
+ params.push(opts.max_confidence);
665
+ }
666
+ // Safety: if no filter beyond user_key, require explicit clear flag.
667
+ if (filters.length === 1 && !opts.confirm_clear) {
668
+ log.warn({ event: 'memory.bulk_delete.refused_unfiltered', user_key: opts.user_key });
669
+ return 0;
670
+ }
671
+ const where = filters.join(' AND ');
672
+ try {
673
+ // Need ids first to also drop from FTS.
674
+ const targets = db.prepare(`SELECT id FROM facts WHERE ${where}`).all(...params);
675
+ if (targets.length === 0)
676
+ return 0;
677
+ const ids = targets.map((t) => t.id);
678
+ const idPlaceholders = ids.map(() => '?').join(',');
679
+ db.prepare(`DELETE FROM facts_fts WHERE rowid IN (${idPlaceholders})`).run(...ids);
680
+ const r = db.prepare(`DELETE FROM facts WHERE id IN (${idPlaceholders})`).run(...ids);
681
+ return r.changes;
682
+ }
683
+ catch (err) {
684
+ log.warn({ event: 'memory.bulk_delete_failed', err: String(err) });
685
+ return 0;
686
+ }
687
+ }
688
+ export function deletePersona(user_key) {
689
+ const db = helper.get();
690
+ if (!db)
691
+ return false;
692
+ try {
693
+ const r = db.prepare('DELETE FROM persona_profile WHERE user_key = ?').run(user_key);
694
+ return r.changes > 0;
695
+ }
696
+ catch (err) {
697
+ log.warn({ event: 'memory.persona_delete_failed', err: String(err) });
698
+ return false;
699
+ }
700
+ }
701
+ /** Full export: persona + all facts for one user, JSON-safe shape. */
702
+ export function exportUserMemory(user_key) {
703
+ return {
704
+ user_key,
705
+ persona: getPersona(user_key),
706
+ facts: listRecentFacts(user_key, 10000),
707
+ exported_at: new Date().toISOString(),
708
+ };
709
+ }
710
+ export function closeMemoryDb() {
711
+ helper.close();
712
+ }
713
+ export const MEMORY_DB_PATH = MEMORY_DB;
714
+ //# sourceMappingURL=memory.js.map