mnueron 0.1.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.
Files changed (52) hide show
  1. package/ARCHITECTURE.md +161 -0
  2. package/INSTALL.md +262 -0
  3. package/LICENSE +21 -0
  4. package/README.md +305 -0
  5. package/dashboard/index.html +838 -0
  6. package/dist/cli.js +685 -0
  7. package/dist/cli.js.map +1 -0
  8. package/dist/config.js +44 -0
  9. package/dist/config.js.map +1 -0
  10. package/dist/dashboard/server.js +234 -0
  11. package/dist/dashboard/server.js.map +1 -0
  12. package/dist/detectors/claude_code.js +72 -0
  13. package/dist/detectors/claude_code.js.map +1 -0
  14. package/dist/detectors/claude_desktop.js +37 -0
  15. package/dist/detectors/claude_desktop.js.map +1 -0
  16. package/dist/detectors/cursor.js +36 -0
  17. package/dist/detectors/cursor.js.map +1 -0
  18. package/dist/detectors/extra.js +59 -0
  19. package/dist/detectors/extra.js.map +1 -0
  20. package/dist/detectors/index.js +14 -0
  21. package/dist/detectors/index.js.map +1 -0
  22. package/dist/detectors/json_detector.js +95 -0
  23. package/dist/detectors/json_detector.js.map +1 -0
  24. package/dist/detectors/types.js +13 -0
  25. package/dist/detectors/types.js.map +1 -0
  26. package/dist/import/claude.js +82 -0
  27. package/dist/import/claude.js.map +1 -0
  28. package/dist/import/openai.js +102 -0
  29. package/dist/import/openai.js.map +1 -0
  30. package/dist/index.js +77 -0
  31. package/dist/index.js.map +1 -0
  32. package/dist/plugins/loader.js +175 -0
  33. package/dist/plugins/loader.js.map +1 -0
  34. package/dist/plugins/types.js +24 -0
  35. package/dist/plugins/types.js.map +1 -0
  36. package/dist/setup.js +123 -0
  37. package/dist/setup.js.map +1 -0
  38. package/dist/store/chunking.js +150 -0
  39. package/dist/store/chunking.js.map +1 -0
  40. package/dist/store/embeddings.js +126 -0
  41. package/dist/store/embeddings.js.map +1 -0
  42. package/dist/store/local.js +720 -0
  43. package/dist/store/local.js.map +1 -0
  44. package/dist/store/provider.js +7 -0
  45. package/dist/store/provider.js.map +1 -0
  46. package/dist/store/redactor.js +114 -0
  47. package/dist/store/redactor.js.map +1 -0
  48. package/dist/store/remote.js +62 -0
  49. package/dist/store/remote.js.map +1 -0
  50. package/dist/tools.js +312 -0
  51. package/dist/tools.js.map +1 -0
  52. package/package.json +55 -0
@@ -0,0 +1,720 @@
1
+ import Database from 'better-sqlite3';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { mkdirSync } from 'node:fs';
4
+ import { dirname } from 'node:path';
5
+ import * as sqliteVec from 'sqlite-vec';
6
+ import { embed, embedBatch, EMBEDDING_DIM, preload } from './embeddings.js';
7
+ import { chunkContent, shouldChunk, DEFAULT_CHUNK_THRESHOLD } from './chunking.js';
8
+ import { redact } from './redactor.js';
9
+ /**
10
+ * Run pre-save transforms in fixed order:
11
+ * 1. Redact secrets — never store API keys / JWTs / etc.
12
+ * 2. (Later) plugin processors can hook here.
13
+ * Returns the (possibly modified) input. Metadata is augmented with
14
+ * `redacted_count` and `redacted_kinds` when redaction fired.
15
+ */
16
+ function preSaveTransform(input) {
17
+ const r = redact(input.content);
18
+ if (r.count === 0)
19
+ return input;
20
+ return {
21
+ ...input,
22
+ content: r.content,
23
+ metadata: {
24
+ ...(input.metadata ?? {}),
25
+ redacted_count: r.count,
26
+ redacted_kinds: r.kinds,
27
+ },
28
+ };
29
+ }
30
+ // Stop words dropped from FTS5 queries before they hit the index. We keep
31
+ // this list small and English-only on purpose — every word we drop is a
32
+ // word a user can't search for, so this is a precision/recall trade.
33
+ const FTS_STOP_WORDS = new Set([
34
+ 'a', 'an', 'the', 'is', 'are', 'was', 'were', 'be', 'been', 'being',
35
+ 'of', 'to', 'in', 'on', 'at', 'for', 'with', 'by', 'from', 'about', 'as', 'into', 'over', 'under', 'through', 'during',
36
+ 'and', 'or', 'but', 'if', 'then', 'else', 'so', 'than',
37
+ 'do', 'does', 'did', 'done', 'doing', 'have', 'has', 'had', 'having',
38
+ 'what', 'how', 'why', 'when', 'where', 'who', 'which', 'whose', 'whom',
39
+ 'this', 'that', 'these', 'those', 'there', 'here',
40
+ 'i', 'me', 'my', 'mine', 'you', 'your', 'yours', 'he', 'him', 'his', 'she', 'her', 'hers', 'it', 'its', 'we', 'us', 'our', 'they', 'them', 'their', 'theirs',
41
+ 'can', 'could', 'should', 'would', 'may', 'might', 'must', 'will', 'shall',
42
+ 'not', 'no', 'yes', 'some', 'any', 'all', 'each', 'every', 'either', 'neither',
43
+ ]);
44
+ /**
45
+ * Translate a natural-language query into an FTS5 MATCH expression.
46
+ * - strips FTS5 control characters
47
+ * - lowercases
48
+ * - drops stop words and 1-character tokens
49
+ * - prefix-matches each surviving token (`token*`) so "stores" matches "stored"
50
+ * - ORs the tokens — any one is enough, BM25 ranks multi-hit rows higher
51
+ */
52
+ function buildFtsQuery(raw) {
53
+ const cleaned = raw.replace(/["()*:^~]/g, ' ').toLowerCase().trim();
54
+ if (!cleaned)
55
+ return '';
56
+ const tokens = cleaned
57
+ .split(/\s+/)
58
+ .map(t => t.replace(/^[^a-z0-9_]+|[^a-z0-9_]+$/g, ''))
59
+ .filter(t => t.length >= 2 && !FTS_STOP_WORDS.has(t));
60
+ if (tokens.length === 0)
61
+ return '';
62
+ return tokens.map(t => `${t}*`).join(' OR ');
63
+ }
64
+ // Reciprocal-rank-fusion constant. 60 is the value the literature uses and
65
+ // is what both Elasticsearch and our hosted-backend already use.
66
+ const RRF_K = 60;
67
+ /**
68
+ * Pull a human-readable title out of a memory's content. Used by
69
+ * `listThreads` to label conversations in the dashboard.
70
+ * Strategy: prefer the first `# Heading`, then the first non-empty line,
71
+ * then a sentence-aware truncation at 100 chars.
72
+ */
73
+ function extractTitle(content) {
74
+ if (!content)
75
+ return '(empty)';
76
+ const trimmed = content.trim();
77
+ // Markdown H1 / H2 anywhere near the top
78
+ const hMatch = trimmed.slice(0, 600).match(/^#{1,3}\s+(.+)$/m);
79
+ if (hMatch)
80
+ return hMatch[1].trim().slice(0, 100);
81
+ // Strip role-header markdown if present
82
+ const firstLine = trimmed.split(/\r?\n/).map(s => s.trim()).find(Boolean) ?? trimmed;
83
+ const noRole = firstLine.replace(/^\*\*(?:User|Assistant|Claude|ChatGPT|Gemini|System|Human):\*\*\s*/i, '');
84
+ if (noRole.length <= 100)
85
+ return noRole;
86
+ // Otherwise: cut at sentence boundary near 100 chars
87
+ const cut = noRole.slice(0, 100);
88
+ const lastDot = cut.lastIndexOf('. ');
89
+ return (lastDot > 40 ? cut.slice(0, lastDot + 1) : cut).trim() + '…';
90
+ }
91
+ /**
92
+ * Local SQLite provider. Uses FTS5 for keyword search (ships with SQLite)
93
+ * and sqlite-vec + Transformers.js for local vector search. Search is
94
+ * hybrid: BM25 keyword + cosine vector, blended via reciprocal-rank fusion.
95
+ * Everything runs offline; no external API calls.
96
+ */
97
+ export class LocalProvider {
98
+ db;
99
+ vecAvailable = false;
100
+ constructor(dbPath) {
101
+ mkdirSync(dirname(dbPath), { recursive: true });
102
+ this.db = new Database(dbPath);
103
+ this.db.pragma('journal_mode = WAL');
104
+ this.db.pragma('foreign_keys = ON');
105
+ // Load sqlite-vec as a SQLite extension. If anything goes wrong we
106
+ // continue without vector support — FTS5 still works.
107
+ try {
108
+ sqliteVec.load(this.db);
109
+ this.vecAvailable = true;
110
+ }
111
+ catch (e) {
112
+ process.stderr.write(`[mnueron] sqlite-vec failed to load — semantic search disabled. ${e.message}\n`);
113
+ this.vecAvailable = false;
114
+ }
115
+ this.migrate();
116
+ // Warm the embedding model in the background — the first query will
117
+ // hit it; nice if it's already there.
118
+ preload();
119
+ }
120
+ migrate() {
121
+ this.db.exec(`
122
+ CREATE TABLE IF NOT EXISTS memories (
123
+ id TEXT PRIMARY KEY,
124
+ namespace TEXT NOT NULL DEFAULT 'default',
125
+ content TEXT NOT NULL,
126
+ tags_json TEXT NOT NULL DEFAULT '[]',
127
+ source TEXT NOT NULL DEFAULT 'manual',
128
+ source_ref TEXT,
129
+ meta_json TEXT,
130
+ created_at INTEGER NOT NULL,
131
+ updated_at INTEGER NOT NULL
132
+ );
133
+
134
+ CREATE INDEX IF NOT EXISTS idx_memories_namespace
135
+ ON memories(namespace);
136
+ CREATE INDEX IF NOT EXISTS idx_memories_created
137
+ ON memories(created_at DESC);
138
+ CREATE INDEX IF NOT EXISTS idx_memories_source
139
+ ON memories(source);
140
+ CREATE INDEX IF NOT EXISTS idx_memories_source_ref
141
+ ON memories(source_ref);
142
+
143
+ CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts
144
+ USING fts5(content, tags, namespace UNINDEXED, content_id UNINDEXED);
145
+
146
+ -- Keep FTS in sync. We do this manually rather than via triggers so
147
+ -- the FTS row's content column holds raw text (FTS can't reach
148
+ -- inside JSON for tags otherwise).
149
+ `);
150
+ if (this.vecAvailable) {
151
+ // vec0 virtual table. Each row carries the memory_id as an auxiliary
152
+ // column so we can JOIN back to memories without managing rowids.
153
+ this.db.exec(`
154
+ CREATE VIRTUAL TABLE IF NOT EXISTS memories_vec
155
+ USING vec0(
156
+ memory_id TEXT PRIMARY KEY,
157
+ embedding float[${EMBEDDING_DIM}]
158
+ );
159
+ `);
160
+ }
161
+ }
162
+ // ─── write path ──────────────────────────────────────────────────────────
163
+ async save(input) {
164
+ // 1. Redact secrets BEFORE chunking — so partial secret tokens at chunk
165
+ // boundaries can't slip through. Single source of truth for what
166
+ // hits SQLite.
167
+ const transformed = preSaveTransform(input);
168
+ // 2. Long content gets auto-chunked into multiple memories. Each chunk
169
+ // becomes a searchable atomic memory; the original conversation is
170
+ // linkable via `parent_ref` (= source_ref + chunk_index in metadata).
171
+ if (shouldChunk(transformed.content)) {
172
+ const result = await this.saveChunked(transformed);
173
+ return result.first;
174
+ }
175
+ return this.saveOne(transformed);
176
+ }
177
+ /** Save a single, non-chunked memory. The common path. */
178
+ async saveOne(input) {
179
+ const now = Date.now();
180
+ const id = randomUUID();
181
+ const ns = input.namespace ?? 'default';
182
+ const tags = input.tags ?? [];
183
+ // Generate the embedding outside the transaction since it's async.
184
+ // Failure here is non-fatal — we just skip the vec insert.
185
+ const vector = this.vecAvailable ? await embed(input.content) : null;
186
+ const tx = this.db.transaction(() => {
187
+ this.db.prepare(`
188
+ INSERT INTO memories (id, namespace, content, tags_json, source, source_ref, meta_json, created_at, updated_at)
189
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
190
+ `).run(id, ns, input.content, JSON.stringify(tags), input.source ?? 'manual', input.source_ref ?? null, input.metadata ? JSON.stringify(input.metadata) : null, now, now);
191
+ this.db.prepare(`
192
+ INSERT INTO memories_fts (content, tags, namespace, content_id)
193
+ VALUES (?, ?, ?, ?)
194
+ `).run(input.content, tags.join(' '), ns, id);
195
+ if (vector && this.vecAvailable) {
196
+ this.db.prepare(`
197
+ INSERT INTO memories_vec (memory_id, embedding) VALUES (?, ?)
198
+ `).run(id, Buffer.from(vector.buffer));
199
+ }
200
+ });
201
+ tx();
202
+ return this.rowToMemory({
203
+ id, namespace: ns, content: input.content,
204
+ tags_json: JSON.stringify(tags),
205
+ source: input.source ?? 'manual',
206
+ source_ref: input.source_ref ?? null,
207
+ meta_json: input.metadata ? JSON.stringify(input.metadata) : null,
208
+ created_at: now, updated_at: now,
209
+ });
210
+ }
211
+ /**
212
+ * Split long content into chunks and save each as a separate memory.
213
+ * Each chunk carries metadata.parent_ref + chunk_index so the agent
214
+ * (or the dashboard) can reassemble the original thread.
215
+ */
216
+ async saveChunked(input) {
217
+ const chunks = chunkContent(input.content);
218
+ if (chunks.length === 0)
219
+ return { first: await this.saveOne(input), count: 1 };
220
+ if (chunks.length === 1)
221
+ return { first: await this.saveOne(input), count: 1 };
222
+ // The parent reference: prefer the caller's source_ref if present, else
223
+ // generate a stable id so siblings can find each other.
224
+ const parentRef = input.source_ref ?? `chunked:${randomUUID()}`;
225
+ const total = chunks.length;
226
+ const baseTags = input.tags ?? [];
227
+ const saves = chunks.map((c, i) => ({
228
+ content: c.content,
229
+ namespace: input.namespace,
230
+ tags: [...baseTags, 'chunk', ...(c.role ? [`role:${c.role}`] : [])],
231
+ source: input.source ?? 'manual',
232
+ source_ref: parentRef,
233
+ metadata: {
234
+ ...(input.metadata ?? {}),
235
+ parent_ref: parentRef,
236
+ chunk_index: i,
237
+ chunk_count: total,
238
+ ...(c.role ? { role: c.role } : {}),
239
+ },
240
+ }));
241
+ const result = await this.bulkSaveOne(saves);
242
+ if (result.length === 0) {
243
+ // Shouldn't happen, but fall back gracefully.
244
+ return { first: await this.saveOne(input), count: 1 };
245
+ }
246
+ return { first: result[0], count: result.length };
247
+ }
248
+ /** Internal: bulkSave-like path that returns Memory[] rather than counts. */
249
+ async bulkSaveOne(inputs) {
250
+ const vectors = this.vecAvailable ? await embedBatch(inputs.map(i => i.content)) : inputs.map(() => null);
251
+ const out = [];
252
+ const insertMem = this.db.prepare(`
253
+ INSERT INTO memories (id, namespace, content, tags_json, source, source_ref, meta_json, created_at, updated_at)
254
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
255
+ `);
256
+ const insertFts = this.db.prepare(`
257
+ INSERT INTO memories_fts (content, tags, namespace, content_id)
258
+ VALUES (?, ?, ?, ?)
259
+ `);
260
+ const insertVec = this.vecAvailable
261
+ ? this.db.prepare(`INSERT INTO memories_vec (memory_id, embedding) VALUES (?, ?)`)
262
+ : null;
263
+ const tx = this.db.transaction((items) => {
264
+ for (let i = 0; i < items.length; i++) {
265
+ const input = items[i];
266
+ const id = randomUUID();
267
+ const now = Date.now();
268
+ const ns = input.namespace ?? 'default';
269
+ const tags = input.tags ?? [];
270
+ const metaJson = input.metadata ? JSON.stringify(input.metadata) : null;
271
+ insertMem.run(id, ns, input.content, JSON.stringify(tags), input.source ?? 'manual', input.source_ref ?? null, metaJson, now, now);
272
+ insertFts.run(input.content, tags.join(' '), ns, id);
273
+ const vec = vectors[i];
274
+ if (vec && insertVec) {
275
+ insertVec.run(id, Buffer.from(vec.buffer));
276
+ }
277
+ out.push(this.rowToMemory({
278
+ id, namespace: ns, content: input.content,
279
+ tags_json: JSON.stringify(tags),
280
+ source: input.source ?? 'manual',
281
+ source_ref: input.source_ref ?? null,
282
+ meta_json: metaJson,
283
+ created_at: now, updated_at: now,
284
+ }));
285
+ }
286
+ });
287
+ tx(inputs);
288
+ return out;
289
+ }
290
+ async bulkSave(inputs) {
291
+ let saved = 0, errors = 0;
292
+ // 1. Redact secrets up front, same as save().
293
+ const redactedInputs = inputs.map(preSaveTransform);
294
+ // 2. Expand long inputs into per-chunk memories before we save. A backfill
295
+ // of 50 chats where each is 100KB becomes ~500 small memories,
296
+ // searchable independently. The original conversation is linkable via
297
+ // metadata.parent_ref.
298
+ const expanded = [];
299
+ for (const input of redactedInputs) {
300
+ if (shouldChunk(input.content)) {
301
+ const chunks = chunkContent(input.content);
302
+ if (chunks.length > 1) {
303
+ const parentRef = input.source_ref ?? `chunked:${randomUUID()}`;
304
+ const baseTags = input.tags ?? [];
305
+ for (let i = 0; i < chunks.length; i++) {
306
+ const c = chunks[i];
307
+ expanded.push({
308
+ content: c.content,
309
+ namespace: input.namespace,
310
+ tags: [...baseTags, 'chunk', ...(c.role ? [`role:${c.role}`] : [])],
311
+ source: input.source ?? 'manual',
312
+ source_ref: parentRef,
313
+ metadata: {
314
+ ...(input.metadata ?? {}),
315
+ parent_ref: parentRef,
316
+ chunk_index: i,
317
+ chunk_count: chunks.length,
318
+ ...(c.role ? { role: c.role } : {}),
319
+ },
320
+ });
321
+ }
322
+ continue;
323
+ }
324
+ }
325
+ expanded.push(input);
326
+ }
327
+ // Pre-compute embeddings for the whole (expanded) batch in one go —
328
+ // much faster than calling embed() N times because Transformers.js
329
+ // batches the forward pass.
330
+ const vectors = this.vecAvailable
331
+ ? await embedBatch(expanded.map(i => i.content))
332
+ : expanded.map(() => null);
333
+ const insertMem = this.db.prepare(`
334
+ INSERT INTO memories (id, namespace, content, tags_json, source, source_ref, meta_json, created_at, updated_at)
335
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
336
+ `);
337
+ const insertFts = this.db.prepare(`
338
+ INSERT INTO memories_fts (content, tags, namespace, content_id)
339
+ VALUES (?, ?, ?, ?)
340
+ `);
341
+ const insertVec = this.vecAvailable
342
+ ? this.db.prepare(`INSERT INTO memories_vec (memory_id, embedding) VALUES (?, ?)`)
343
+ : null;
344
+ const tx = this.db.transaction((items) => {
345
+ for (let i = 0; i < items.length; i++) {
346
+ const input = items[i];
347
+ try {
348
+ const id = randomUUID();
349
+ const now = Date.now();
350
+ const ns = input.namespace ?? 'default';
351
+ const tags = input.tags ?? [];
352
+ insertMem.run(id, ns, input.content, JSON.stringify(tags), input.source ?? 'manual', input.source_ref ?? null, input.metadata ? JSON.stringify(input.metadata) : null, now, now);
353
+ insertFts.run(input.content, tags.join(' '), ns, id);
354
+ const vec = vectors[i];
355
+ if (vec && insertVec) {
356
+ insertVec.run(id, Buffer.from(vec.buffer));
357
+ }
358
+ saved++;
359
+ }
360
+ catch (e) {
361
+ errors++;
362
+ }
363
+ }
364
+ });
365
+ tx(expanded);
366
+ return { saved, errors };
367
+ }
368
+ // ─── read path: hybrid keyword + vector with RRF ─────────────────────────
369
+ async search(input) {
370
+ const k = input.k ?? 10;
371
+ // FTS5 leg
372
+ const safeQuery = buildFtsQuery(input.query);
373
+ const ftsRanks = new Map(); // id → 1-based rank
374
+ if (safeQuery) {
375
+ let sql = `
376
+ SELECT m.id
377
+ FROM memories_fts f
378
+ JOIN memories m ON m.id = f.content_id
379
+ WHERE memories_fts MATCH ?
380
+ `;
381
+ const params = [safeQuery];
382
+ if (input.namespace) {
383
+ sql += ` AND m.namespace = ?`;
384
+ params.push(input.namespace);
385
+ }
386
+ sql += ` ORDER BY bm25(memories_fts) LIMIT 50`;
387
+ const rows = this.db.prepare(sql).all(...params);
388
+ rows.forEach((r, i) => ftsRanks.set(r.id, i + 1));
389
+ }
390
+ // Vector leg.
391
+ //
392
+ // sqlite-vec requires the k value to be expressed *inside* the WHERE
393
+ // clause as `AND k = ?` — a plain SQL LIMIT is not enough. We also
394
+ // can't JOIN into the same statement without confusing the vec0
395
+ // planner. So: run the bare KNN query first, then filter by namespace
396
+ // in a second SELECT against the regular memories table.
397
+ const vecRanks = new Map();
398
+ if (this.vecAvailable && input.query.trim()) {
399
+ const qvec = await embed(input.query);
400
+ if (qvec) {
401
+ try {
402
+ const rows = this.db.prepare(`
403
+ SELECT memory_id AS id, distance
404
+ FROM memories_vec
405
+ WHERE embedding MATCH ?
406
+ AND k = ?
407
+ ORDER BY distance
408
+ `).all(Buffer.from(qvec.buffer), 50);
409
+ let candidates = rows.map(r => r.id);
410
+ // Namespace filter (after the KNN — sqlite-vec doesn't let us
411
+ // attach this inside the vec0 query).
412
+ if (input.namespace && candidates.length > 0) {
413
+ const placeholders = candidates.map(() => '?').join(',');
414
+ const allowed = this.db.prepare(`SELECT id FROM memories WHERE namespace = ? AND id IN (${placeholders})`).all(input.namespace, ...candidates);
415
+ const allowedSet = new Set(allowed.map(a => a.id));
416
+ candidates = candidates.filter(id => allowedSet.has(id));
417
+ }
418
+ candidates.forEach((id, i) => vecRanks.set(id, i + 1));
419
+ }
420
+ catch (e) {
421
+ // sqlite-vec may not be loaded or syntax mismatch — log and skip.
422
+ process.stderr.write(`[mnueron] vector search skipped: ${e.message}\n`);
423
+ }
424
+ }
425
+ }
426
+ // Fuse via Reciprocal Rank Fusion.
427
+ const fused = new Map();
428
+ for (const [id, r] of ftsRanks) {
429
+ fused.set(id, (fused.get(id) ?? 0) + 1 / (RRF_K + r));
430
+ }
431
+ for (const [id, r] of vecRanks) {
432
+ fused.set(id, (fused.get(id) ?? 0) + 1 / (RRF_K + r));
433
+ }
434
+ if (fused.size === 0)
435
+ return [];
436
+ const sorted = [...fused.entries()]
437
+ .sort((a, b) => b[1] - a[1])
438
+ .slice(0, k);
439
+ const placeholders = sorted.map(() => '?').join(',');
440
+ const rows = this.db.prepare(`SELECT * FROM memories WHERE id IN (${placeholders})`).all(...sorted.map(s => s[0]));
441
+ const byId = new Map(rows.map(r => [r.id, r]));
442
+ let memories = sorted
443
+ .map(([id, score]) => {
444
+ const row = byId.get(id);
445
+ return row ? this.rowToMemory(row, score) : null;
446
+ })
447
+ .filter((m) => m !== null);
448
+ if (input.tags && input.tags.length > 0) {
449
+ const wanted = new Set(input.tags);
450
+ memories = memories.filter(m => m.tags.some(t => wanted.has(t)));
451
+ }
452
+ return memories;
453
+ }
454
+ async list(input) {
455
+ let sql = `SELECT * FROM memories WHERE 1=1`;
456
+ const params = [];
457
+ if (input.namespace) {
458
+ sql += ` AND namespace = ?`;
459
+ params.push(input.namespace);
460
+ }
461
+ if (input.before) {
462
+ sql += ` AND created_at < ?`;
463
+ params.push(input.before);
464
+ }
465
+ sql += ` ORDER BY created_at DESC LIMIT ?`;
466
+ params.push(input.limit ?? 50);
467
+ const rows = this.db.prepare(sql).all(...params);
468
+ let memories = rows.map(r => this.rowToMemory(r));
469
+ if (input.tags && input.tags.length > 0) {
470
+ const wanted = new Set(input.tags);
471
+ memories = memories.filter(m => m.tags.some(t => wanted.has(t)));
472
+ }
473
+ return memories;
474
+ }
475
+ async get(id) {
476
+ const row = this.db.prepare(`SELECT * FROM memories WHERE id = ?`).get(id);
477
+ return row ? this.rowToMemory(row) : null;
478
+ }
479
+ async delete(id) {
480
+ const tx = this.db.transaction(() => {
481
+ this.db.prepare(`DELETE FROM memories_fts WHERE content_id = ?`).run(id);
482
+ if (this.vecAvailable) {
483
+ this.db.prepare(`DELETE FROM memories_vec WHERE memory_id = ?`).run(id);
484
+ }
485
+ const r = this.db.prepare(`DELETE FROM memories WHERE id = ?`).run(id);
486
+ return r.changes > 0;
487
+ });
488
+ return tx();
489
+ }
490
+ async namespaces() {
491
+ const rows = this.db.prepare(`
492
+ SELECT namespace AS name,
493
+ COUNT(*) AS count,
494
+ MAX(updated_at) AS last_updated
495
+ FROM memories
496
+ GROUP BY namespace
497
+ ORDER BY last_updated DESC
498
+ `).all();
499
+ return rows.map(r => ({
500
+ name: r.name,
501
+ count: r.count,
502
+ last_updated: r.last_updated ?? 0,
503
+ }));
504
+ }
505
+ async close() {
506
+ this.db.close();
507
+ }
508
+ // ─── helpers used by CLI / maintenance ───────────────────────────────────
509
+ /**
510
+ * Count of memories that don't have a vector yet. Used by the CLI to
511
+ * decide whether `mnueron rebuild-embeddings` should run.
512
+ *
513
+ * Implementation note: sqlite-vec's vec0 virtual table doesn't support
514
+ * LEFT JOIN with IS NULL predicates the way a normal table would (its
515
+ * xBestIndex implementation rejects the plan). We use a NOT IN subquery
516
+ * against memories_vec, which vec0 does handle.
517
+ */
518
+ countMissingEmbeddings() {
519
+ if (!this.vecAvailable)
520
+ return 0;
521
+ const r = this.db.prepare(`
522
+ SELECT COUNT(*) AS c
523
+ FROM memories
524
+ WHERE id NOT IN (SELECT memory_id FROM memories_vec)
525
+ `).get();
526
+ return r?.c ?? 0;
527
+ }
528
+ /**
529
+ * Generate embeddings for every memory that doesn't have one. Run once
530
+ * after upgrading from a pre-vector version. Streams progress through
531
+ * the callback so the CLI can show a progress bar.
532
+ */
533
+ async rebuildEmbeddings(onProgress) {
534
+ if (!this.vecAvailable)
535
+ return { updated: 0, skipped: 0, errors: 0 };
536
+ const rows = this.db.prepare(`
537
+ SELECT id, content
538
+ FROM memories
539
+ WHERE id NOT IN (SELECT memory_id FROM memories_vec)
540
+ ORDER BY created_at ASC
541
+ `).all();
542
+ const total = rows.length;
543
+ let updated = 0, skipped = 0, errors = 0;
544
+ // Embed in batches of 16 for throughput without spiking memory.
545
+ const BATCH = 16;
546
+ const insertVec = this.db.prepare(`
547
+ INSERT OR REPLACE INTO memories_vec (memory_id, embedding) VALUES (?, ?)
548
+ `);
549
+ for (let i = 0; i < rows.length; i += BATCH) {
550
+ const chunk = rows.slice(i, i + BATCH);
551
+ const vecs = await embedBatch(chunk.map(r => r.content));
552
+ const tx = this.db.transaction(() => {
553
+ for (let j = 0; j < chunk.length; j++) {
554
+ const vec = vecs[j];
555
+ if (!vec) {
556
+ skipped++;
557
+ continue;
558
+ }
559
+ try {
560
+ insertVec.run(chunk[j].id, Buffer.from(vec.buffer));
561
+ updated++;
562
+ }
563
+ catch {
564
+ errors++;
565
+ }
566
+ }
567
+ });
568
+ tx();
569
+ onProgress?.(Math.min(i + BATCH, total), total, chunk[chunk.length - 1]?.content?.slice(0, 60));
570
+ }
571
+ return { updated, skipped, errors };
572
+ }
573
+ /**
574
+ * Look up by source_ref — used by importers and the dashboard's upsert
575
+ * endpoint to avoid double-saving the same chat.
576
+ */
577
+ findBySourceRef(sourceRef, namespace) {
578
+ let sql = `SELECT * FROM memories WHERE source_ref = ?`;
579
+ const params = [sourceRef];
580
+ if (namespace) {
581
+ sql += ` AND namespace = ?`;
582
+ params.push(namespace);
583
+ }
584
+ sql += ` LIMIT 1`;
585
+ const row = this.db.prepare(sql).get(...params);
586
+ return row ? this.rowToMemory(row) : null;
587
+ }
588
+ /**
589
+ * Return every chunk of a thread (i.e. every memory whose
590
+ * metadata.parent_ref matches), ordered by chunk_index. Used by
591
+ * `memory_get_thread` so agents can reassemble a long conversation
592
+ * after finding one relevant turn via memory_recall.
593
+ *
594
+ * `parentRef` can be either the literal parent_ref value or any chunk's
595
+ * memory id (we look up parent_ref from that chunk's metadata first).
596
+ */
597
+ findThread(parentRef) {
598
+ // If the caller passed a memory id, resolve to its parent_ref first.
599
+ let ref = parentRef;
600
+ const maybeChild = this.db.prepare(`SELECT meta_json FROM memories WHERE id = ?`).get(parentRef);
601
+ if (maybeChild?.meta_json) {
602
+ try {
603
+ const meta = JSON.parse(maybeChild.meta_json);
604
+ if (typeof meta?.parent_ref === 'string')
605
+ ref = meta.parent_ref;
606
+ }
607
+ catch { /* ignore */ }
608
+ }
609
+ // Now fetch every memory whose metadata.parent_ref equals ref.
610
+ // JSON field path syntax: json_extract(meta_json, '$.parent_ref')
611
+ const rows = this.db.prepare(`
612
+ SELECT *
613
+ FROM memories
614
+ WHERE json_extract(meta_json, '$.parent_ref') = ?
615
+ ORDER BY COALESCE(json_extract(meta_json, '$.chunk_index'), 0) ASC, created_at ASC
616
+ `).all(ref);
617
+ // Also try a fallback against source_ref for memories chunked via
618
+ // source_ref-as-parent_ref (this is the common case for backfills).
619
+ if (rows.length === 0) {
620
+ const alt = this.db.prepare(`
621
+ SELECT *
622
+ FROM memories
623
+ WHERE source_ref = ?
624
+ ORDER BY COALESCE(json_extract(meta_json, '$.chunk_index'), 0) ASC, created_at ASC
625
+ `).all(ref);
626
+ return alt.map(r => this.rowToMemory(r));
627
+ }
628
+ return rows.map(r => this.rowToMemory(r));
629
+ }
630
+ /**
631
+ * List "threads" — distinct conversations as represented by their
632
+ * parent_ref. Each row in the output represents a conversation that the
633
+ * dashboard can render collapsed (one row = one conversation, expandable
634
+ * into per-turn chunks).
635
+ *
636
+ * Returns: { parent_ref, namespace, count, first_at, last_at, title }
637
+ * — `title` is the content preview of the lowest-chunk_index member
638
+ * (usually the human-readable header at the top of a transcript).
639
+ */
640
+ listThreads(opts = {}) {
641
+ const limit = opts.limit ?? 100;
642
+ const offset = opts.offset ?? 0;
643
+ // We use COALESCE(parent_ref-from-metadata, id) as the bucket key so
644
+ // standalone (non-chunked) memories show up as single-row threads too.
645
+ const sql = `
646
+ WITH grouped AS (
647
+ SELECT
648
+ COALESCE(json_extract(meta_json, '$.parent_ref'), id) AS pref,
649
+ namespace,
650
+ COUNT(*) AS cnt,
651
+ MIN(created_at) AS first_at,
652
+ MAX(updated_at) AS last_at,
653
+ SUM(CASE WHEN json_extract(meta_json, '$.chunk_index') IS NOT NULL THEN 1 ELSE 0 END) AS chunked_n
654
+ FROM memories
655
+ ${opts.namespace ? 'WHERE namespace = ?' : ''}
656
+ GROUP BY pref, namespace
657
+ )
658
+ SELECT
659
+ g.pref AS parent_ref,
660
+ g.namespace,
661
+ g.cnt AS count,
662
+ g.first_at,
663
+ g.last_at,
664
+ g.chunked_n > 0 AS has_chunks,
665
+ (
666
+ SELECT m.content
667
+ FROM memories m
668
+ WHERE COALESCE(json_extract(m.meta_json, '$.parent_ref'), m.id) = g.pref
669
+ AND m.namespace = g.namespace
670
+ ORDER BY COALESCE(json_extract(m.meta_json, '$.chunk_index'), 0) ASC, m.created_at ASC
671
+ LIMIT 1
672
+ ) AS title_source
673
+ FROM grouped g
674
+ ORDER BY g.last_at DESC
675
+ LIMIT ? OFFSET ?
676
+ `;
677
+ const params = opts.namespace ? [opts.namespace, limit, offset] : [limit, offset];
678
+ const rows = this.db.prepare(sql).all(...params);
679
+ return rows.map(r => ({
680
+ parent_ref: r.parent_ref,
681
+ namespace: r.namespace,
682
+ count: r.count,
683
+ first_at: r.first_at ?? 0,
684
+ last_at: r.last_at ?? 0,
685
+ title: extractTitle(r.title_source ?? ''),
686
+ has_chunks: !!r.has_chunks,
687
+ }));
688
+ }
689
+ /**
690
+ * Find memories whose content exceeds `threshold` chars — i.e. ones that
691
+ * predate chunking. Used by `mnueron rechunk` to backfill the new shape.
692
+ */
693
+ findOversizedMemories(threshold = DEFAULT_CHUNK_THRESHOLD) {
694
+ return this.db.prepare(`
695
+ SELECT id, content, namespace, tags_json, source, source_ref, meta_json, created_at
696
+ FROM memories
697
+ WHERE LENGTH(content) > ?
698
+ AND (
699
+ meta_json IS NULL
700
+ OR json_extract(meta_json, '$.chunk_index') IS NULL
701
+ )
702
+ ORDER BY LENGTH(content) DESC
703
+ `).all(threshold);
704
+ }
705
+ rowToMemory(row, score) {
706
+ return {
707
+ id: row.id,
708
+ namespace: row.namespace,
709
+ content: row.content,
710
+ tags: JSON.parse(row.tags_json ?? '[]'),
711
+ source: row.source,
712
+ source_ref: row.source_ref ?? undefined,
713
+ metadata: row.meta_json ? JSON.parse(row.meta_json) : undefined,
714
+ score,
715
+ created_at: row.created_at,
716
+ updated_at: row.updated_at,
717
+ };
718
+ }
719
+ }
720
+ //# sourceMappingURL=local.js.map