@abarcenas/ai-workflow-template 1.25.0 → 1.26.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,512 @@
1
+ /**
2
+ * memory-index.js — Core engine for vector-based memory search.
3
+ *
4
+ * Manages a sqlite-vec vector index over memory-bank/ markdown files.
5
+ * Supports content-hash incremental updates and semantic search.
6
+ *
7
+ * Usage:
8
+ * import { search, rebuildIndex, incrementalUpdate, getStats } from './memory-index.js';
9
+ */
10
+
11
+ import { createHash } from 'node:crypto';
12
+ import { existsSync, mkdirSync, readFileSync, readdirSync, statSync } from 'node:fs';
13
+ import { basename, dirname, extname, join, resolve } from 'node:path';
14
+
15
+ // ── Configuration ──────────────────────────────────────────────────
16
+
17
+ const MEMORY_BANK_DIR = resolve(process.cwd(), 'memory-bank');
18
+ const DOCS_DIR = resolve(process.cwd(), 'docs');
19
+ const INDEX_DIR = resolve(MEMORY_BANK_DIR, '.index');
20
+ const DB_PATH = resolve(INDEX_DIR, 'memory.db');
21
+ const EMBEDDING_DIM = 384;
22
+
23
+ // ── Lazy-loaded dependencies ────────────────────────────────────────
24
+
25
+ /** @type {import('better-sqlite3').default|null} */
26
+ let BetterSqlite3 = null;
27
+
28
+ /** @type {import('sqlite-vec').default|null} */
29
+ let sqliteVec = null;
30
+
31
+ /** @type {import('@xenova/transformers').pipeline|null} */
32
+ let pipelineFn = null;
33
+
34
+ /** @type {Promise<any>|null} */
35
+ let embedderPromise = null;
36
+
37
+ /** @type {any|null} */
38
+ let embedder = null;
39
+
40
+ /** @type {import('better-sqlite3').Database|null} */
41
+ let _db = null;
42
+
43
+ // ── Helpers ─────────────────────────────────────────────────────────
44
+
45
+ function ensureDir(dir) {
46
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
47
+ }
48
+
49
+ function hashContent(content) {
50
+ return createHash('sha256').update(content, 'utf8').digest('hex');
51
+ }
52
+
53
+ function filenameToId(filePath) {
54
+ return basename(filePath, extname(filePath));
55
+ }
56
+
57
+ /**
58
+ * Parse YAML frontmatter from markdown content.
59
+ * Returns { frontmatter: object|null, body: string }
60
+ */
61
+ function parseFrontmatter(content) {
62
+ const match = content.match(/^---\n([\s\S]*?)\n---\n?/);
63
+ if (!match) return { frontmatter: null, body: content };
64
+
65
+ const raw = match[1];
66
+ const frontmatter = {};
67
+ for (const line of raw.split('\n')) {
68
+ const kv = line.match(/^(\w[\w-]*):\s*(.*)/);
69
+ if (kv) {
70
+ const key = kv[1];
71
+ let value = kv[2].trim();
72
+ // Parse arrays: [foo, bar]
73
+ if (value.startsWith('[') && value.endsWith(']')) {
74
+ value = value.slice(1, -1).split(',').map(s => s.trim().replace(/^["']|["']$/g, '')).filter(Boolean);
75
+ }
76
+ // Unquote strings
77
+ else if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
78
+ value = value.slice(1, -1);
79
+ }
80
+ frontmatter[key] = value;
81
+ }
82
+ }
83
+
84
+ return { frontmatter, body: content.slice(match[0].length) };
85
+ }
86
+
87
+ /**
88
+ * Split markdown body into chunks by ## headings.
89
+ * Each chunk includes its heading and content up to the next ## heading.
90
+ */
91
+ function chunkMarkdown(body) {
92
+ const chunks = [];
93
+ const sections = body.split(/(?=^## )/m);
94
+
95
+ for (const section of sections) {
96
+ const trimmed = section.trim();
97
+ if (!trimmed) continue;
98
+ // Skip if it's just the H1 title (no ## prefix)
99
+ if (trimmed.startsWith('# ') && !trimmed.startsWith('## ')) continue;
100
+ if (trimmed.length < 10) continue; // Skip tiny sections
101
+ chunks.push(trimmed);
102
+ }
103
+
104
+ // If no ## sections found, treat the whole body as one chunk
105
+ if (chunks.length === 0 && body.trim().length > 10) {
106
+ chunks.push(body.trim());
107
+ }
108
+
109
+ return chunks;
110
+ }
111
+
112
+ // ── Database ─────────────────────────────────────────────────────────
113
+
114
+ async function getSQLite() {
115
+ if (!BetterSqlite3) {
116
+ try { BetterSqlite3 = (await import('better-sqlite3')).default; }
117
+ catch { throw new Error('better-sqlite3 not installed. Run: npm install better-sqlite3'); }
118
+ }
119
+ return BetterSqlite3;
120
+ }
121
+
122
+ async function getVec() {
123
+ if (!sqliteVec) {
124
+ try { sqliteVec = await import('sqlite-vec'); }
125
+ catch { throw new Error('sqlite-vec not installed. Run: npm install sqlite-vec'); }
126
+ }
127
+ return sqliteVec;
128
+ }
129
+
130
+ async function getDB() {
131
+ if (_db) return _db;
132
+
133
+ const SQLite = await getSQLite();
134
+ const vec = await getVec();
135
+
136
+ ensureDir(INDEX_DIR);
137
+ _db = new SQLite(DB_PATH);
138
+ _db.pragma('journal_mode = WAL');
139
+ vec.load(_db);
140
+
141
+ // Create tables if they don't exist
142
+ _db.exec(`
143
+ CREATE TABLE IF NOT EXISTS memory_chunks (
144
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
145
+ source_file TEXT NOT NULL,
146
+ chunk_index INTEGER NOT NULL,
147
+ content TEXT NOT NULL,
148
+ content_hash TEXT NOT NULL,
149
+ vec_rowid INTEGER,
150
+ updated_at TEXT DEFAULT CURRENT_TIMESTAMP
151
+ );
152
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_chunk_lookup
153
+ ON memory_chunks(source_file, chunk_index);
154
+ `);
155
+
156
+ // Create vec0 virtual table (auto rowid — we link via memory_chunks.vec_rowid)
157
+ const vecTableExists = _db.prepare(
158
+ "SELECT name FROM sqlite_master WHERE type='table' AND name='vec_memory'"
159
+ ).get();
160
+
161
+ if (!vecTableExists) {
162
+ _db.exec(`CREATE VIRTUAL TABLE vec_memory USING vec0(embedding float[${EMBEDDING_DIM}])`);
163
+ }
164
+
165
+ // Migration: add vec_rowid column if missing (from older schema)
166
+ const hasVecRowid = _db.prepare(
167
+ "SELECT COUNT(*) as cnt FROM pragma_table_info('memory_chunks') WHERE name = 'vec_rowid'"
168
+ ).get();
169
+ if (!hasVecRowid || hasVecRowid.cnt === 0) {
170
+ _db.exec('ALTER TABLE memory_chunks ADD COLUMN vec_rowid INTEGER');
171
+ }
172
+
173
+ return _db;
174
+ }
175
+
176
+ function closeDB() {
177
+ if (_db) {
178
+ try { _db.close(); } catch { /* ignore */ }
179
+ _db = null;
180
+ }
181
+ }
182
+
183
+ // ── Embedding ────────────────────────────────────────────────────────
184
+
185
+ async function getEmbedder() {
186
+ if (embedder) return embedder;
187
+ if (embedderPromise) return embedderPromise;
188
+
189
+ embedderPromise = (async () => {
190
+ if (!pipelineFn) {
191
+ try {
192
+ const mod = await import('@xenova/transformers');
193
+ pipelineFn = mod.pipeline;
194
+ } catch {
195
+ console.warn('[memory-index] @xenova/transformers not installed. Embedding unavailable.');
196
+ console.warn('[memory-index] Install with: npm install @xenova/transformers');
197
+ return null;
198
+ }
199
+ }
200
+
201
+ try {
202
+ embedder = await pipelineFn('feature-extraction', 'Xenova/all-MiniLM-L6-v2');
203
+ return embedder;
204
+ } catch (err) {
205
+ console.warn('[memory-index] Failed to load embedding model:', err.message);
206
+ return null;
207
+ }
208
+ })();
209
+
210
+ return embedderPromise;
211
+ }
212
+
213
+ async function embed(text) {
214
+ const model = await getEmbedder();
215
+ if (!model) throw new Error('Embedding model not available');
216
+
217
+ const result = await model(text, { pooling: 'mean', normalize: true });
218
+ return new Float32Array(result.data);
219
+ }
220
+
221
+ // ── Indexing ────────────────────────────────────────────────────────
222
+
223
+ /**
224
+ * Index a single markdown file.
225
+ * Skips if content hasn't changed (hash check).
226
+ * Returns { indexed: number, skipped: number }
227
+ */
228
+ async function indexFile(filePath) {
229
+ const db = await getDB();
230
+ const content = readFileSync(filePath, 'utf8');
231
+ const sourceFile = basename(filePath);
232
+ const { body } = parseFrontmatter(content);
233
+ const chunks = chunkMarkdown(body);
234
+
235
+ let indexed = 0;
236
+ let skipped = 0;
237
+
238
+ for (let i = 0; i < chunks.length; i++) {
239
+ const chunkHash = hashContent(chunks[i]);
240
+
241
+ // Check if already indexed with same hash
242
+ const existing = db.prepare(
243
+ 'SELECT id, content_hash FROM memory_chunks WHERE source_file = ? AND chunk_index = ?'
244
+ ).get(sourceFile, i);
245
+
246
+ if (existing && existing.content_hash === chunkHash) {
247
+ skipped++;
248
+ continue;
249
+ }
250
+
251
+ let rowid;
252
+ if (existing) {
253
+ const existingId = Number(existing.id);
254
+ const oldVecRowid = existing.vec_rowid;
255
+
256
+ // Update content
257
+ db.prepare(
258
+ 'UPDATE memory_chunks SET content = ?, content_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?'
259
+ ).run(chunks[i], chunkHash, existingId);
260
+
261
+ // Remove old vector
262
+ if (oldVecRowid != null) {
263
+ db.prepare('DELETE FROM vec_memory WHERE rowid = ?').run(Number(oldVecRowid));
264
+ }
265
+ rowid = existingId;
266
+ } else {
267
+ // Insert new chunk
268
+ const info = db.prepare(
269
+ 'INSERT INTO memory_chunks (source_file, chunk_index, content, content_hash) VALUES (?, ?, ?, ?)'
270
+ ).run(sourceFile, i, chunks[i], chunkHash);
271
+ rowid = Number(info.lastInsertRowid);
272
+ }
273
+
274
+ try {
275
+ const embedding = await embed(chunks[i]);
276
+ // Insert into vec0 without explicit rowid (auto-assigned)
277
+ const vecInfo = db.prepare('INSERT INTO vec_memory (embedding) VALUES (?)').run(embedding);
278
+ const vecRowid = Number(vecInfo.lastInsertRowid);
279
+ // Link vec0 rowid back to memory_chunks
280
+ db.prepare('UPDATE memory_chunks SET vec_rowid = ? WHERE id = ?').run(vecRowid, rowid);
281
+ indexed++;
282
+ } catch (err) {
283
+ console.warn(`[memory-index] Failed to embed chunk ${i} of ${sourceFile}: ${err.message}`);
284
+ }
285
+ }
286
+
287
+ return { indexed, skipped };
288
+ }
289
+
290
+ /**
291
+ * Collect all .md files from a directory recursively.
292
+ */
293
+ function collectMdFiles(dir) {
294
+ if (!existsSync(dir)) return [];
295
+
296
+ const files = [];
297
+ const entries = readdirSync(dir, { withFileTypes: true });
298
+
299
+ for (const entry of entries) {
300
+ const fullPath = join(dir, entry.name);
301
+ if (entry.isDirectory()) {
302
+ // Skip .index and node_modules
303
+ if (entry.name === '.index' || entry.name === 'node_modules') continue;
304
+ files.push(...collectMdFiles(fullPath));
305
+ } else if (entry.isFile() && entry.name.endsWith('.md')) {
306
+ files.push(fullPath);
307
+ }
308
+ }
309
+
310
+ return files;
311
+ }
312
+
313
+ /**
314
+ * Full rebuild: clears index and re-indexes all markdown files.
315
+ */
316
+ async function rebuildIndex() {
317
+ const db = await getDB();
318
+
319
+ console.log('[memory-index] Full rebuild started...');
320
+
321
+ // Clear existing data
322
+ db.exec('DELETE FROM vec_memory');
323
+ db.exec('DELETE FROM memory_chunks');
324
+
325
+ const memoryFiles = collectMdFiles(MEMORY_BANK_DIR);
326
+ const docFiles = collectMdFiles(DOCS_DIR);
327
+ const allFiles = [...memoryFiles, ...docFiles];
328
+
329
+ let totalIndexed = 0;
330
+ let totalSkipped = 0;
331
+
332
+ for (const file of allFiles) {
333
+ // Skip files in .index/
334
+ if (file.includes('/.index/')) continue;
335
+
336
+ const { indexed, skipped } = await indexFile(file);
337
+ totalIndexed += indexed;
338
+ totalSkipped += skipped;
339
+
340
+ if (indexed > 0) {
341
+ console.log(`[memory-index] Indexed: ${basename(file)} (${indexed} chunks)`);
342
+ }
343
+ }
344
+
345
+ const stats = getStats();
346
+ console.log(`[memory-index] Rebuild complete: ${totalIndexed} chunks indexed, ${totalSkipped} skipped. ${stats.totalVectors} total vectors.`);
347
+
348
+ return { indexed: totalIndexed, skipped: totalSkipped, stats };
349
+ }
350
+
351
+ /**
352
+ * Incremental update: only re-index files whose content has changed.
353
+ * Uses file mtime + content hash for change detection.
354
+ */
355
+ async function incrementalUpdate() {
356
+ const memoryFiles = collectMdFiles(MEMORY_BANK_DIR);
357
+ const docFiles = collectMdFiles(DOCS_DIR);
358
+ const allFiles = [...memoryFiles, ...docFiles];
359
+
360
+ let totalIndexed = 0;
361
+ let totalSkipped = 0;
362
+ const changed = [];
363
+
364
+ for (const file of allFiles) {
365
+ if (file.includes('/.index/')) continue;
366
+
367
+ const content = readFileSync(file, 'utf8');
368
+ const sourceFile = basename(file);
369
+ const { body } = parseFrontmatter(content);
370
+ const bodyHash = hashContent(body);
371
+
372
+ // Quick check: if the full body hash matches the last indexed state, skip
373
+ const db = await getDB();
374
+ const existingChunks = db.prepare(
375
+ 'SELECT content_hash FROM memory_chunks WHERE source_file = ?'
376
+ ).all(sourceFile);
377
+
378
+ const combinedHash = existingChunks.map(c => c.content_hash).join('|');
379
+
380
+ if (existingChunks.length > 0) {
381
+ // Check if any chunk's content changed by recomputing hashes
382
+ const chunks = chunkMarkdown(body);
383
+ const newCombinedHash = chunks.map(c => hashContent(c)).join('|');
384
+
385
+ if (newCombinedHash === combinedHash) {
386
+ totalSkipped += existingChunks.length;
387
+ continue;
388
+ }
389
+ }
390
+
391
+ const { indexed, skipped } = await indexFile(file);
392
+ if (indexed > 0) {
393
+ changed.push(sourceFile);
394
+ }
395
+ totalIndexed += indexed;
396
+ totalSkipped += skipped;
397
+ }
398
+
399
+ return { indexed: totalIndexed, skipped: totalSkipped, changed };
400
+ }
401
+
402
+ // ── Search ──────────────────────────────────────────────────────────
403
+
404
+ /**
405
+ * Semantic search across indexed memory.
406
+ *
407
+ * @param {string} query - Natural language query
408
+ * @param {object} [opts]
409
+ * @param {number} [opts.topK=5] - Number of results
410
+ * @param {string} [opts.category] - Filter by frontmatter category
411
+ * @param {string} [opts.sourceFile] - Filter by specific file
412
+ * @returns {Promise<Array<{source_file: string, content: string, score: number}>>}
413
+ */
414
+ async function search(query, opts = {}) {
415
+ const { topK = 5, category, sourceFile } = opts;
416
+ const db = await getDB();
417
+
418
+ let queryEmbedding;
419
+ try {
420
+ queryEmbedding = await embed(query);
421
+ } catch (err) {
422
+ throw new Error(`Embedding failed: ${err.message}. Is @xenova/transformers installed?`);
423
+ }
424
+
425
+ // Build query with optional filters
426
+ let sql = `
427
+ SELECT mc.source_file, mc.content, vec_memory.distance as score
428
+ FROM vec_memory
429
+ JOIN memory_chunks mc ON mc.vec_rowid = vec_memory.rowid
430
+ WHERE vec_memory.embedding MATCH ?
431
+ AND k = ?
432
+ `;
433
+ const params = [queryEmbedding, topK];
434
+
435
+ if (sourceFile) {
436
+ sql += ' AND mc.source_file = ?';
437
+ params.push(sourceFile);
438
+ }
439
+
440
+ sql += ' ORDER BY vec_memory.distance';
441
+
442
+ const results = db.prepare(sql).all(...params);
443
+
444
+ // Format results
445
+ return results.map(r => ({
446
+ source_file: r.source_file,
447
+ content: r.content,
448
+ score: Math.round((1 - r.score) * 1000) / 1000, // Convert distance to similarity
449
+ }));
450
+ }
451
+
452
+ // ── Read ─────────────────────────────────────────────────────────────
453
+
454
+ /**
455
+ * Read a specific memory file by id (filename without extension).
456
+ */
457
+ function getFile(fileId) {
458
+ // Try memory-bank first
459
+ const memPath = join(MEMORY_BANK_DIR, `${fileId}.md`);
460
+ if (existsSync(memPath)) {
461
+ return readFileSync(memPath, 'utf8');
462
+ }
463
+
464
+ // Try docs
465
+ const docPath = join(DOCS_DIR, `${fileId}.md`);
466
+ if (existsSync(docPath)) {
467
+ return readFileSync(docPath, 'utf8');
468
+ }
469
+
470
+ // Try hidden docs
471
+ const hiddenPath = join(DOCS_DIR, `.${fileId}.md`);
472
+ if (existsSync(hiddenPath)) {
473
+ return readFileSync(hiddenPath, 'utf8');
474
+ }
475
+
476
+ return null;
477
+ }
478
+
479
+ // ── Stats ────────────────────────────────────────────────────────────
480
+
481
+ function getStats() {
482
+ if (!_db) {
483
+ return { totalVectors: 0, totalFiles: 0, dbSize: 'N/A' };
484
+ }
485
+
486
+ const vectors = _db.prepare('SELECT COUNT(*) as count FROM vec_memory').get();
487
+ const files = _db.prepare(
488
+ 'SELECT COUNT(DISTINCT source_file) as count FROM memory_chunks'
489
+ ).get();
490
+ const dbSizeBytes = existsSync(DB_PATH) ? statSync(DB_PATH).size : 0;
491
+
492
+ return {
493
+ totalVectors: vectors.count,
494
+ totalFiles: files.count,
495
+ dbSize: `${(dbSizeBytes / 1024 / 1024).toFixed(1)} MB`,
496
+ };
497
+ }
498
+
499
+ // ── Exports ──────────────────────────────────────────────────────────
500
+
501
+ export {
502
+ search,
503
+ rebuildIndex,
504
+ incrementalUpdate,
505
+ indexFile,
506
+ getFile,
507
+ getStats,
508
+ closeDB,
509
+ hashContent,
510
+ parseFrontmatter,
511
+ chunkMarkdown,
512
+ };