@awareness-sdk/local 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.
@@ -0,0 +1,726 @@
1
+ /**
2
+ * Indexer — SQLite FTS5 full-text search index for Awareness Local.
3
+ *
4
+ * Uses better-sqlite3 in WAL mode for concurrent read access.
5
+ * Manages 7 tables + 2 FTS5 virtual tables + 1 embeddings table.
6
+ */
7
+
8
+ import Database from 'better-sqlite3';
9
+ import { createHash } from 'node:crypto';
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // Schema DDL
13
+ // ---------------------------------------------------------------------------
14
+
15
+ const SCHEMA_SQL = `
16
+ CREATE TABLE IF NOT EXISTS memories (
17
+ id TEXT PRIMARY KEY,
18
+ filepath TEXT NOT NULL UNIQUE,
19
+ type TEXT NOT NULL,
20
+ title TEXT,
21
+ session_id TEXT,
22
+ agent_role TEXT DEFAULT 'builder_agent',
23
+ source TEXT,
24
+ status TEXT DEFAULT 'active',
25
+ tags TEXT,
26
+ created_at TEXT NOT NULL,
27
+ updated_at TEXT NOT NULL,
28
+ content_hash TEXT,
29
+ synced_to_cloud INTEGER DEFAULT 0
30
+ );
31
+
32
+ CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5(
33
+ id UNINDEXED, title, content, tags,
34
+ tokenize='unicode61 remove_diacritics 2'
35
+ );
36
+
37
+ CREATE TABLE IF NOT EXISTS knowledge_cards (
38
+ id TEXT PRIMARY KEY,
39
+ category TEXT NOT NULL,
40
+ title TEXT NOT NULL,
41
+ summary TEXT,
42
+ source_memories TEXT,
43
+ confidence REAL DEFAULT 0.8,
44
+ status TEXT DEFAULT 'active',
45
+ tags TEXT,
46
+ created_at TEXT NOT NULL,
47
+ filepath TEXT NOT NULL UNIQUE
48
+ );
49
+
50
+ CREATE VIRTUAL TABLE IF NOT EXISTS knowledge_fts USING fts5(
51
+ id UNINDEXED, title, summary, content, tags,
52
+ tokenize='unicode61 remove_diacritics 2'
53
+ );
54
+
55
+ CREATE TABLE IF NOT EXISTS tasks (
56
+ id TEXT PRIMARY KEY,
57
+ title TEXT NOT NULL,
58
+ description TEXT,
59
+ status TEXT DEFAULT 'open',
60
+ priority TEXT DEFAULT 'medium',
61
+ agent_role TEXT,
62
+ created_at TEXT NOT NULL,
63
+ updated_at TEXT NOT NULL,
64
+ filepath TEXT NOT NULL UNIQUE
65
+ );
66
+
67
+ CREATE TABLE IF NOT EXISTS sessions (
68
+ id TEXT PRIMARY KEY,
69
+ source TEXT,
70
+ agent_role TEXT,
71
+ started_at TEXT NOT NULL,
72
+ ended_at TEXT,
73
+ memory_count INTEGER DEFAULT 0,
74
+ summary TEXT
75
+ );
76
+
77
+ CREATE TABLE IF NOT EXISTS embeddings (
78
+ memory_id TEXT PRIMARY KEY,
79
+ vector BLOB NOT NULL,
80
+ model_id TEXT NOT NULL,
81
+ created_at TEXT NOT NULL,
82
+ FOREIGN KEY (memory_id) REFERENCES memories(id)
83
+ );
84
+
85
+ CREATE TABLE IF NOT EXISTS sync_state (
86
+ key TEXT PRIMARY KEY,
87
+ value TEXT NOT NULL,
88
+ updated_at TEXT NOT NULL
89
+ );
90
+ `;
91
+
92
+ // ---------------------------------------------------------------------------
93
+ // Helpers
94
+ // ---------------------------------------------------------------------------
95
+
96
+ /**
97
+ * Compute SHA-256 hex digest of a string.
98
+ */
99
+ function sha256(text) {
100
+ return createHash('sha256').update(text, 'utf8').digest('hex');
101
+ }
102
+
103
+ /**
104
+ * Sanitise a query string for FTS5 MATCH — escape double-quotes and wrap
105
+ * each token in double-quotes so special characters don't break the query.
106
+ * Falls back to a simple prefix search when the input is a single token.
107
+ */
108
+ /** FTS5 boolean operators — pass through without quoting. */
109
+ const FTS5_OPS = new Set(['OR', 'AND', 'NOT', 'NEAR']);
110
+
111
+ function sanitiseFtsQuery(raw) {
112
+ if (!raw || typeof raw !== 'string') return null;
113
+ const trimmed = raw.trim();
114
+ if (trimmed.length === 0) return null;
115
+
116
+ // If the query already contains FTS5 operators or quoted phrases, pass it
117
+ // through with minimal sanitisation (just remove dangerous chars).
118
+ if (/\bOR\b|\bAND\b|\bNOT\b|\bNEAR\b/.test(trimmed) || trimmed.includes('"')) {
119
+ // Already structured — strip only chars that would break FTS5 syntax
120
+ return trimmed.replace(/[;\\]/g, '');
121
+ }
122
+
123
+ // Plain text query — quote each token to prevent FTS5 syntax errors.
124
+ const tokens = trimmed.split(/\s+/).map((t) => {
125
+ const escaped = t.replace(/"/g, '""');
126
+ return `"${escaped}"`;
127
+ });
128
+ return tokens.join(' ');
129
+ }
130
+
131
+ /**
132
+ * Current ISO-8601 timestamp.
133
+ */
134
+ function nowISO() {
135
+ return new Date().toISOString();
136
+ }
137
+
138
+ // ---------------------------------------------------------------------------
139
+ // Indexer class
140
+ // ---------------------------------------------------------------------------
141
+
142
+ export class Indexer {
143
+ /**
144
+ * @param {string} dbPath — path to the SQLite database file.
145
+ */
146
+ constructor(dbPath) {
147
+ this.db = new Database(dbPath);
148
+
149
+ // WAL mode for concurrent reads from the daemon & file-watchers.
150
+ this.db.pragma('journal_mode = WAL');
151
+ // Reasonable busy timeout so concurrent writers wait instead of failing.
152
+ this.db.pragma('busy_timeout = 5000');
153
+
154
+ this.initSchema();
155
+ this._prepareStatements();
156
+ }
157
+
158
+ // -----------------------------------------------------------------------
159
+ // Schema initialisation
160
+ // -----------------------------------------------------------------------
161
+
162
+ /**
163
+ * Execute all CREATE TABLE / CREATE VIRTUAL TABLE statements.
164
+ * Safe to call repeatedly — every statement uses IF NOT EXISTS.
165
+ */
166
+ initSchema() {
167
+ this.db.exec(SCHEMA_SQL);
168
+ }
169
+
170
+ // -----------------------------------------------------------------------
171
+ // Prepared-statement cache (lazy, created once)
172
+ // -----------------------------------------------------------------------
173
+
174
+ /** @private */
175
+ _prepareStatements() {
176
+ // -- memories upsert --------------------------------------------------
177
+ this._stmtUpsertMemory = this.db.prepare(`
178
+ INSERT INTO memories (id, filepath, type, title, session_id, agent_role,
179
+ source, status, tags, created_at, updated_at, content_hash, synced_to_cloud)
180
+ VALUES (@id, @filepath, @type, @title, @session_id, @agent_role,
181
+ @source, @status, @tags, @created_at, @updated_at, @content_hash, @synced_to_cloud)
182
+ ON CONFLICT(id) DO UPDATE SET
183
+ filepath = excluded.filepath,
184
+ type = excluded.type,
185
+ title = excluded.title,
186
+ session_id = excluded.session_id,
187
+ agent_role = excluded.agent_role,
188
+ source = excluded.source,
189
+ status = excluded.status,
190
+ tags = excluded.tags,
191
+ updated_at = excluded.updated_at,
192
+ content_hash = excluded.content_hash,
193
+ synced_to_cloud = excluded.synced_to_cloud
194
+ `);
195
+
196
+ this._stmtGetMemoryHash = this.db.prepare(
197
+ `SELECT content_hash FROM memories WHERE id = ?`
198
+ );
199
+
200
+ // -- memories_fts upsert (delete + insert, FTS5 has no ON CONFLICT) ---
201
+ this._stmtDeleteFts = this.db.prepare(
202
+ `DELETE FROM memories_fts WHERE id = ?`
203
+ );
204
+ this._stmtInsertFts = this.db.prepare(`
205
+ INSERT INTO memories_fts (id, title, content, tags)
206
+ VALUES (@id, @title, @content, @tags)
207
+ `);
208
+
209
+ // -- knowledge_cards --------------------------------------------------
210
+ this._stmtUpsertKnowledge = this.db.prepare(`
211
+ INSERT INTO knowledge_cards (id, category, title, summary, source_memories,
212
+ confidence, status, tags, created_at, filepath)
213
+ VALUES (@id, @category, @title, @summary, @source_memories,
214
+ @confidence, @status, @tags, @created_at, @filepath)
215
+ ON CONFLICT(id) DO UPDATE SET
216
+ category = excluded.category,
217
+ title = excluded.title,
218
+ summary = excluded.summary,
219
+ source_memories = excluded.source_memories,
220
+ confidence = excluded.confidence,
221
+ status = excluded.status,
222
+ tags = excluded.tags,
223
+ filepath = excluded.filepath
224
+ `);
225
+
226
+ this._stmtDeleteKnowledgeFts = this.db.prepare(
227
+ `DELETE FROM knowledge_fts WHERE id = ?`
228
+ );
229
+ this._stmtInsertKnowledgeFts = this.db.prepare(`
230
+ INSERT INTO knowledge_fts (id, title, summary, content, tags)
231
+ VALUES (@id, @title, @summary, @content, @tags)
232
+ `);
233
+
234
+ // -- tasks ------------------------------------------------------------
235
+ this._stmtUpsertTask = this.db.prepare(`
236
+ INSERT INTO tasks (id, title, description, status, priority, agent_role,
237
+ created_at, updated_at, filepath)
238
+ VALUES (@id, @title, @description, @status, @priority, @agent_role,
239
+ @created_at, @updated_at, @filepath)
240
+ ON CONFLICT(id) DO UPDATE SET
241
+ title = excluded.title,
242
+ description = excluded.description,
243
+ status = excluded.status,
244
+ priority = excluded.priority,
245
+ agent_role = excluded.agent_role,
246
+ updated_at = excluded.updated_at,
247
+ filepath = excluded.filepath
248
+ `);
249
+
250
+ // -- sessions ---------------------------------------------------------
251
+ this._stmtInsertSession = this.db.prepare(`
252
+ INSERT INTO sessions (id, source, agent_role, started_at)
253
+ VALUES (@id, @source, @agent_role, @started_at)
254
+ `);
255
+
256
+ this._stmtUpdateSession = this.db.prepare(`
257
+ UPDATE sessions
258
+ SET ended_at = COALESCE(@ended_at, ended_at),
259
+ memory_count = COALESCE(@memory_count, memory_count),
260
+ summary = COALESCE(@summary, summary)
261
+ WHERE id = @id
262
+ `);
263
+
264
+ // -- embeddings -------------------------------------------------------
265
+ this._stmtUpsertEmbedding = this.db.prepare(`
266
+ INSERT OR REPLACE INTO embeddings (memory_id, vector, model_id, created_at)
267
+ VALUES (@memory_id, @vector, @model_id, @created_at)
268
+ `);
269
+
270
+ this._stmtGetEmbedding = this.db.prepare(
271
+ `SELECT vector FROM embeddings WHERE memory_id = ?`
272
+ );
273
+
274
+ this._stmtGetAllEmbeddings = this.db.prepare(
275
+ `SELECT memory_id, vector FROM embeddings`
276
+ );
277
+ }
278
+
279
+ // -----------------------------------------------------------------------
280
+ // Memory indexing
281
+ // -----------------------------------------------------------------------
282
+
283
+ /**
284
+ * Upsert a memory record and its FTS5 entry.
285
+ *
286
+ * If the content_hash is unchanged, the write is skipped entirely (no-op).
287
+ *
288
+ * @param {string} id
289
+ * @param {Object} metadata — must include at least { filepath, type, created_at, updated_at }.
290
+ * @param {string} content — the full Markdown body (used for FTS indexing).
291
+ * @returns {{ indexed: boolean }} — true if the record was written.
292
+ */
293
+ indexMemory(id, metadata, content) {
294
+ const contentHash = sha256(content);
295
+
296
+ // Fast path: skip if unchanged.
297
+ const existing = this._stmtGetMemoryHash.get(id);
298
+ if (existing && existing.content_hash === contentHash) {
299
+ return { indexed: false };
300
+ }
301
+
302
+ const now = nowISO();
303
+ const tags =
304
+ typeof metadata.tags === 'string'
305
+ ? metadata.tags
306
+ : Array.isArray(metadata.tags)
307
+ ? JSON.stringify(metadata.tags)
308
+ : null;
309
+
310
+ const row = {
311
+ id,
312
+ filepath: metadata.filepath,
313
+ type: metadata.type || 'turn_summary',
314
+ title: metadata.title || null,
315
+ session_id: metadata.session_id || null,
316
+ agent_role: metadata.agent_role || 'builder_agent',
317
+ source: metadata.source || null,
318
+ status: metadata.status || 'active',
319
+ tags,
320
+ created_at: metadata.created_at || now,
321
+ updated_at: metadata.updated_at || now,
322
+ content_hash: contentHash,
323
+ synced_to_cloud: metadata.synced_to_cloud ? 1 : 0,
324
+ };
325
+
326
+ // Wrap in a transaction so the metadata + FTS rows are atomic.
327
+ const upsert = this.db.transaction(() => {
328
+ this._stmtUpsertMemory.run(row);
329
+ this._stmtDeleteFts.run(id);
330
+ this._stmtInsertFts.run({
331
+ id,
332
+ title: row.title || '',
333
+ content,
334
+ tags: tags || '',
335
+ });
336
+ });
337
+ upsert();
338
+
339
+ return { indexed: true };
340
+ }
341
+
342
+ // -----------------------------------------------------------------------
343
+ // Knowledge card indexing
344
+ // -----------------------------------------------------------------------
345
+
346
+ /**
347
+ * Insert or update a knowledge card and its FTS5 entry.
348
+ *
349
+ * @param {Object} card — { id, category, title, summary, source_memories,
350
+ * confidence, status, tags, created_at, filepath, content }
351
+ * `content` is used only for FTS indexing and is NOT stored in the
352
+ * knowledge_cards table (the full body lives in the Markdown file).
353
+ */
354
+ indexKnowledgeCard(card) {
355
+ const tags =
356
+ typeof card.tags === 'string'
357
+ ? card.tags
358
+ : Array.isArray(card.tags)
359
+ ? JSON.stringify(card.tags)
360
+ : null;
361
+
362
+ const now = nowISO();
363
+ const row = {
364
+ id: card.id,
365
+ category: card.category,
366
+ title: card.title,
367
+ summary: card.summary || null,
368
+ source_memories:
369
+ typeof card.source_memories === 'string'
370
+ ? card.source_memories
371
+ : Array.isArray(card.source_memories)
372
+ ? JSON.stringify(card.source_memories)
373
+ : null,
374
+ confidence: card.confidence ?? 0.8,
375
+ status: card.status || 'active',
376
+ tags,
377
+ created_at: card.created_at || now,
378
+ filepath: card.filepath,
379
+ };
380
+
381
+ const upsert = this.db.transaction(() => {
382
+ this._stmtUpsertKnowledge.run(row);
383
+ this._stmtDeleteKnowledgeFts.run(card.id);
384
+ this._stmtInsertKnowledgeFts.run({
385
+ id: card.id,
386
+ title: card.title || '',
387
+ summary: card.summary || '',
388
+ content: card.content || '',
389
+ tags: tags || '',
390
+ });
391
+ });
392
+ upsert();
393
+ }
394
+
395
+ // -----------------------------------------------------------------------
396
+ // Task indexing
397
+ // -----------------------------------------------------------------------
398
+
399
+ /**
400
+ * Insert or update a task.
401
+ *
402
+ * @param {Object} task — { id, title, description, status, priority,
403
+ * agent_role, created_at, updated_at, filepath }
404
+ */
405
+ indexTask(task) {
406
+ const now = nowISO();
407
+ this._stmtUpsertTask.run({
408
+ id: task.id,
409
+ title: task.title,
410
+ description: task.description || null,
411
+ status: task.status || 'open',
412
+ priority: task.priority || 'medium',
413
+ agent_role: task.agent_role || null,
414
+ created_at: task.created_at || now,
415
+ updated_at: task.updated_at || now,
416
+ filepath: task.filepath,
417
+ });
418
+ }
419
+
420
+ // -----------------------------------------------------------------------
421
+ // Sessions
422
+ // -----------------------------------------------------------------------
423
+
424
+ /**
425
+ * Create a new session.
426
+ *
427
+ * @param {string} source — e.g. 'claude-code', 'openclaw'
428
+ * @param {string} [agentRole='builder_agent']
429
+ * @returns {{ id: string, source: string, agent_role: string, started_at: string }}
430
+ */
431
+ createSession(source, agentRole = 'builder_agent') {
432
+ const now = nowISO();
433
+ const id = `ses_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
434
+ const row = { id, source: source || null, agent_role: agentRole, started_at: now };
435
+ this._stmtInsertSession.run(row);
436
+ return { ...row };
437
+ }
438
+
439
+ /**
440
+ * Update an existing session (e.g. set ended_at, memory_count, summary).
441
+ *
442
+ * @param {string} id
443
+ * @param {Object} updates — any subset of { ended_at, memory_count, summary }.
444
+ */
445
+ updateSession(id, updates = {}) {
446
+ this._stmtUpdateSession.run({
447
+ id,
448
+ ended_at: updates.ended_at ?? null,
449
+ memory_count: updates.memory_count ?? null,
450
+ summary: updates.summary ?? null,
451
+ });
452
+ }
453
+
454
+ // -----------------------------------------------------------------------
455
+ // FTS5 search
456
+ // -----------------------------------------------------------------------
457
+
458
+ /**
459
+ * Full-text search over indexed memories.
460
+ *
461
+ * @param {string} query — natural language or keyword query.
462
+ * @param {Object} [options]
463
+ * @param {string[]} [options.types] — filter by memory type.
464
+ * @param {string[]} [options.tags] — filter by tag (JSON array substring match).
465
+ * @param {number} [options.limit=10]
466
+ * @param {number} [options.offset=0]
467
+ * @returns {Array<Object>} — memory rows with an additional `rank` field (lower = more relevant).
468
+ */
469
+ search(query, options = {}) {
470
+ const ftsQuery = sanitiseFtsQuery(query);
471
+ if (!ftsQuery) return [];
472
+
473
+ const limit = options.limit ?? 10;
474
+ const offset = options.offset ?? 0;
475
+
476
+ // Build dynamic WHERE clauses for optional filters.
477
+ const conditions = [`memories_fts MATCH ?`, `m.status = 'active'`];
478
+ const params = [ftsQuery];
479
+
480
+ if (Array.isArray(options.types) && options.types.length > 0) {
481
+ const placeholders = options.types.map(() => '?').join(', ');
482
+ conditions.push(`m.type IN (${placeholders})`);
483
+ params.push(...options.types);
484
+ }
485
+
486
+ if (Array.isArray(options.tags) && options.tags.length > 0) {
487
+ // tags is stored as a JSON array string — use LIKE for substring match.
488
+ for (const tag of options.tags) {
489
+ conditions.push(`m.tags LIKE ?`);
490
+ params.push(`%${tag}%`);
491
+ }
492
+ }
493
+
494
+ params.push(limit, offset);
495
+
496
+ const sql = `
497
+ SELECT m.*, memories_fts.content AS fts_content, bm25(memories_fts) AS rank
498
+ FROM memories_fts
499
+ JOIN memories m ON m.id = memories_fts.id
500
+ WHERE ${conditions.join(' AND ')}
501
+ ORDER BY rank
502
+ LIMIT ? OFFSET ?
503
+ `;
504
+
505
+ return this.db.prepare(sql).all(...params);
506
+ }
507
+
508
+ /**
509
+ * Full-text search over knowledge cards.
510
+ *
511
+ * @param {string} query
512
+ * @param {Object} [options]
513
+ * @param {string[]} [options.categories]
514
+ * @param {number} [options.limit=10]
515
+ * @param {number} [options.offset=0]
516
+ * @returns {Array<Object>}
517
+ */
518
+ searchKnowledge(query, options = {}) {
519
+ const ftsQuery = sanitiseFtsQuery(query);
520
+ if (!ftsQuery) return [];
521
+
522
+ const limit = options.limit ?? 10;
523
+ const offset = options.offset ?? 0;
524
+
525
+ const conditions = [`knowledge_fts MATCH ?`, `k.status = 'active'`];
526
+ const params = [ftsQuery];
527
+
528
+ if (Array.isArray(options.categories) && options.categories.length > 0) {
529
+ const placeholders = options.categories.map(() => '?').join(', ');
530
+ conditions.push(`k.category IN (${placeholders})`);
531
+ params.push(...options.categories);
532
+ }
533
+
534
+ params.push(limit, offset);
535
+
536
+ const sql = `
537
+ SELECT k.*, bm25(knowledge_fts) AS rank
538
+ FROM knowledge_fts
539
+ JOIN knowledge_cards k ON k.id = knowledge_fts.id
540
+ WHERE ${conditions.join(' AND ')}
541
+ ORDER BY rank
542
+ LIMIT ? OFFSET ?
543
+ `;
544
+
545
+ return this.db.prepare(sql).all(...params);
546
+ }
547
+
548
+ // -----------------------------------------------------------------------
549
+ // Incremental indexing
550
+ // -----------------------------------------------------------------------
551
+
552
+ /**
553
+ * Scan the memory store for new or changed files and index them.
554
+ *
555
+ * @param {Object} memoryStore — a MemoryStore instance with list() and read() methods.
556
+ * @returns {Promise<{ indexed: number, skipped: number }>}
557
+ */
558
+ async incrementalIndex(memoryStore) {
559
+ const files = await memoryStore.list();
560
+ let indexed = 0;
561
+ let skipped = 0;
562
+
563
+ for (const file of files) {
564
+ try {
565
+ const id = file.metadata?.id;
566
+ if (!id) {
567
+ skipped++;
568
+ continue;
569
+ }
570
+ // Ensure filepath is in metadata (list() returns it at top level)
571
+ const meta = { ...file.metadata, filepath: file.filepath || file.metadata.filepath };
572
+ // Derive title from first sentence of content if not in metadata
573
+ if (!meta.title && file.content) {
574
+ const firstSentence = file.content.split(/[.\n!?。!?]/)[0].trim();
575
+ meta.title = firstSentence.length > 80
576
+ ? firstSentence.substring(0, 77) + '...'
577
+ : firstSentence || null;
578
+ }
579
+ const result = this.indexMemory(id, meta, file.content);
580
+ if (result.indexed) {
581
+ indexed++;
582
+ } else {
583
+ skipped++;
584
+ }
585
+ } catch (err) {
586
+ console.error(`[indexer] failed to index ${file.metadata?.id}:`, err.message);
587
+ skipped++;
588
+ }
589
+ }
590
+
591
+ return { indexed, skipped };
592
+ }
593
+
594
+ // -----------------------------------------------------------------------
595
+ // Stats & convenience queries
596
+ // -----------------------------------------------------------------------
597
+
598
+ /**
599
+ * Get aggregate counts for dashboard / healthz.
600
+ */
601
+ getStats() {
602
+ return {
603
+ totalMemories: this.db
604
+ .prepare(`SELECT COUNT(*) AS c FROM memories WHERE status = ?`)
605
+ .get('active').c,
606
+ totalKnowledge: this.db
607
+ .prepare(`SELECT COUNT(*) AS c FROM knowledge_cards WHERE status = ?`)
608
+ .get('active').c,
609
+ totalTasks: this.db
610
+ .prepare(`SELECT COUNT(*) AS c FROM tasks WHERE status = ?`)
611
+ .get('open').c,
612
+ totalSessions: this.db
613
+ .prepare(`SELECT COUNT(*) AS c FROM sessions`)
614
+ .get().c,
615
+ };
616
+ }
617
+
618
+ /**
619
+ * Return the most recently created knowledge cards.
620
+ *
621
+ * @param {number} [limit=5]
622
+ */
623
+ getRecentKnowledge(limit = 5) {
624
+ return this.db
625
+ .prepare(
626
+ `SELECT * FROM knowledge_cards WHERE status = 'active'
627
+ ORDER BY created_at DESC LIMIT ?`
628
+ )
629
+ .all(limit);
630
+ }
631
+
632
+ /**
633
+ * Return open (un-completed) tasks.
634
+ *
635
+ * @param {number} [limit=5]
636
+ */
637
+ getOpenTasks(limit = 5) {
638
+ return this.db
639
+ .prepare(
640
+ `SELECT * FROM tasks WHERE status = 'open'
641
+ ORDER BY created_at DESC LIMIT ?`
642
+ )
643
+ .all(limit);
644
+ }
645
+
646
+ /**
647
+ * Return sessions started within the last N days.
648
+ *
649
+ * @param {number} [days=7]
650
+ */
651
+ getRecentSessions(days = 7) {
652
+ const cutoff = new Date(Date.now() - days * 86_400_000).toISOString();
653
+ return this.db
654
+ .prepare(`SELECT * FROM sessions WHERE started_at > ? ORDER BY started_at DESC`)
655
+ .all(cutoff);
656
+ }
657
+
658
+ // -----------------------------------------------------------------------
659
+ // Embedding storage (SQLite BLOB)
660
+ // -----------------------------------------------------------------------
661
+
662
+ /**
663
+ * Store (or replace) an embedding vector for a memory.
664
+ *
665
+ * @param {string} memoryId
666
+ * @param {Float32Array} vector — 384-dimensional embedding.
667
+ * @param {string} modelId — e.g. 'all-MiniLM-L6-v2' or 'multilingual-e5-small'.
668
+ */
669
+ storeEmbedding(memoryId, vector, modelId) {
670
+ const buf = Buffer.from(vector.buffer, vector.byteOffset, vector.byteLength);
671
+ this._stmtUpsertEmbedding.run({
672
+ memory_id: memoryId,
673
+ vector: buf,
674
+ model_id: modelId,
675
+ created_at: nowISO(),
676
+ });
677
+ }
678
+
679
+ /**
680
+ * Retrieve the embedding vector for a single memory.
681
+ *
682
+ * @param {string} memoryId
683
+ * @returns {Float32Array|null}
684
+ */
685
+ getEmbedding(memoryId) {
686
+ const row = this._stmtGetEmbedding.get(memoryId);
687
+ if (!row) return null;
688
+ return new Float32Array(
689
+ row.vector.buffer,
690
+ row.vector.byteOffset,
691
+ row.vector.byteLength / Float32Array.BYTES_PER_ELEMENT
692
+ );
693
+ }
694
+
695
+ /**
696
+ * Retrieve all embeddings (for brute-force cosine search).
697
+ *
698
+ * @returns {Array<{ memory_id: string, vector: Float32Array }>}
699
+ */
700
+ getAllEmbeddings() {
701
+ const rows = this._stmtGetAllEmbeddings.all();
702
+ return rows.map((row) => ({
703
+ memory_id: row.memory_id,
704
+ vector: new Float32Array(
705
+ row.vector.buffer,
706
+ row.vector.byteOffset,
707
+ row.vector.byteLength / Float32Array.BYTES_PER_ELEMENT
708
+ ),
709
+ }));
710
+ }
711
+
712
+ // -----------------------------------------------------------------------
713
+ // Lifecycle
714
+ // -----------------------------------------------------------------------
715
+
716
+ /**
717
+ * Close the database connection. Safe to call multiple times.
718
+ */
719
+ close() {
720
+ if (this.db && this.db.open) {
721
+ this.db.close();
722
+ }
723
+ }
724
+ }
725
+
726
+ export default Indexer;