@abarcenas/ai-workflow-template 1.48.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/.agents/instructions/knowledgebase.instructions.md +2 -2
  2. package/.agents/instructions/learned-knowledge.instructions.md +47 -2
  3. package/.agents/instructions/memory-schema.instructions.md +1 -1
  4. package/.github/workflows/npm-publish.yml +69 -1
  5. package/.opencode/agents/implementer.md +3 -1
  6. package/.opencode/agents/unit-tester.md +21 -2
  7. package/.opencode/plugins/graphify.js +22 -0
  8. package/LICENSE +15 -0
  9. package/README.md +56 -22
  10. package/memory-bank/.vocabulary.json +3 -0
  11. package/opencode.mcp.example.json +13 -0
  12. package/package.json +20 -10
  13. package/scripts/bump-version.js +18 -2
  14. package/scripts/mcp/playwright-mcp-launcher.js +45 -2
  15. package/scripts/bump-version.test.js +0 -238
  16. package/scripts/knowledgebase-cli.test.js +0 -576
  17. package/scripts/knowledgebase-index.test.js +0 -759
  18. package/scripts/mcp/playwright-mcp-launcher.test.js +0 -217
  19. package/scripts/mcp-knowledgebase-server.test.js +0 -745
  20. package/scripts/mcp-memory-server.test.js +0 -398
  21. package/scripts/memory-cli.test.js +0 -297
  22. package/scripts/memory-index.test.js +0 -1173
  23. package/scripts/normalize-memory.test.js +0 -753
  24. package/scripts/setup/discover.test.js +0 -530
  25. package/scripts/setup/hooks.test.js +0 -722
  26. package/scripts/setup/husky-init.test.js +0 -311
  27. package/scripts/setup/index.test.js +0 -1021
  28. package/scripts/setup/knowledgebase.test.js +0 -352
  29. package/scripts/setup/prepare.test.js +0 -718
  30. package/scripts/setup/skills-phase.test.js +0 -1274
  31. package/scripts/setup/sync-phase.test.js +0 -373
  32. package/scripts/setup/ui.test.js +0 -579
  33. package/scripts/setup/utils.test.js +0 -586
  34. package/scripts/sync-unit.test.js +0 -1881
  35. package/scripts/sync.test.js +0 -472
  36. package/scripts/validate-memory-schema.test.js +0 -328
  37. package/scripts/vocab-sync.test.js +0 -902
@@ -1,1173 +0,0 @@
1
- // ═════════════════════════════════════════════════════════════════════════════
2
- // memory-index.test.js — Unit tests for the sqlite-vec memory index engine
3
- // ═════════════════════════════════════════════════════════════════════════════
4
- //
5
- // The engine keeps module-level state (the sqlite handle, the embedder cache)
6
- // and resolves memory-bank/ + docs/ from process.cwd() at import time, so each
7
- // scenario:
8
- // 1. creates a temp consumer root with its own SQLite DB under
9
- // memory-bank/.index/ (never the repository's real DB),
10
- // 2. chdir()s into it,
11
- // 3. vi.resetModules() + dynamic import so paths and caches are fresh.
12
- //
13
- // better-sqlite3 and sqlite-vec are REAL dependencies and load in this
14
- // environment (verified before writing the tests), so the DB layer is
15
- // exercised end-to-end. Only @xenova/transformers is mocked: the real model is
16
- // a ~90 MB download, and a deterministic bag-of-words embedding lets us assert
17
- // ranking sanity without the network. The mock is wired through the same
18
- // dynamic `import('@xenova/transformers')` the production code performs.
19
- // ═════════════════════════════════════════════════════════════════════════════
20
-
21
- import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
22
- import Database from 'better-sqlite3'
23
- import * as sqliteVec from 'sqlite-vec'
24
- import {
25
- existsSync,
26
- mkdirSync,
27
- mkdtempSync,
28
- realpathSync,
29
- rmSync,
30
- writeFileSync,
31
- } from 'node:fs'
32
- import { dirname, join } from 'node:path'
33
- import { tmpdir } from 'node:os'
34
-
35
- // ── Deterministic embedding mock ────────────────────────────────────────────
36
-
37
- const embedMocks = vi.hoisted(() => {
38
- const DIM = 384
39
- const FAIL_MARKER = '@@EMBED_FAIL@@'
40
-
41
- /** FNV-1a bag-of-words embedding, L2-normalised, 384 dims. */
42
- function embeddingFor(text) {
43
- if (String(text).includes(FAIL_MARKER)) {
44
- throw new Error('synthetic embedding failure')
45
- }
46
- const vector = new Array(DIM).fill(0)
47
- const tokens = String(text).toLowerCase().match(/[a-z0-9]+/g) || []
48
- for (const token of tokens) {
49
- let hash = 2166136261
50
- for (let i = 0; i < token.length; i++) {
51
- hash ^= token.charCodeAt(i)
52
- hash = Math.imul(hash, 16777619)
53
- }
54
- vector[Math.abs(hash) % DIM] += 1
55
- }
56
- const norm = Math.sqrt(vector.reduce((sum, value) => sum + value * value, 0)) || 1
57
- return vector.map((value) => value / norm)
58
- }
59
-
60
- const pipeline = vi.fn(async () => async (text) => ({ data: embeddingFor(text) }))
61
-
62
- return { pipeline, embeddingFor, FAIL_MARKER, DIM }
63
- })
64
-
65
- vi.mock('@xenova/transformers', () => ({ pipeline: embedMocks.pipeline }))
66
-
67
- // ── Fixture helpers ─────────────────────────────────────────────────────────
68
-
69
- const ORIGINAL_CWD = process.cwd()
70
- const ORIGINAL_ARGV = [...process.argv]
71
- const ORIGINAL_INIT_CWD = process.env.INIT_CWD
72
- const tempDirs = new Set()
73
- const loadedModules = new Set()
74
-
75
- function createRoot(prefix = 'memory-index-test-') {
76
- // realpathSync keeps fixture paths identical to the module's resolve()d
77
- // constants on macOS, where /var is a symlink to /private/var.
78
- const root = realpathSync(mkdtempSync(join(tmpdir(), prefix)))
79
- tempDirs.add(root)
80
- mkdirSync(join(root, 'memory-bank'), { recursive: true })
81
- return root
82
- }
83
-
84
- function writeRepoFile(root, relPath, content) {
85
- const filePath = join(root, relPath)
86
- mkdirSync(dirname(filePath), { recursive: true })
87
- writeFileSync(filePath, content, 'utf8')
88
- return filePath
89
- }
90
-
91
- /** Load a fresh copy of the engine rooted at `root`. */
92
- async function loadIndex(root) {
93
- process.chdir(root)
94
- delete process.env.INIT_CWD
95
- process.argv = [ORIGINAL_ARGV[0], 'memory-index.js']
96
- vi.resetModules()
97
- const mod = await import('./memory-index.js')
98
- loadedModules.add(mod)
99
- return mod
100
- }
101
-
102
- /** Open the fixture DB on a second connection (sqlite-vec loaded for vec0). */
103
- function openDb(root, { readonly = false } = {}) {
104
- const db = new Database(join(root, 'memory-bank/.index/memory.db'), { readonly })
105
- sqliteVec.load(db)
106
- return db
107
- }
108
-
109
- function section(title, body) {
110
- return `## ${title}\n\n${body}\n`
111
- }
112
-
113
- const FILE_TWO_SECTIONS = ['# Doc', '', section('Alpha', 'alpha alpha content'), section('Beta', 'beta beta content')].join('\n')
114
-
115
- /** Seed memory-bank/doc.md with two ## sections. */
116
- function writeTwoSectionFile(root) {
117
- return writeRepoFile(root, 'memory-bank/doc.md', FILE_TWO_SECTIONS)
118
- }
119
-
120
- /**
121
- * The vector invariant: vec0 has no foreign keys, so every vec_memory row must
122
- * be referenced by exactly one memory_chunks row, and every non-null
123
- * memory_chunks.vec_rowid must resolve. Chunks with a NULL vec_rowid are
124
- * allowed only when embedding legitimately failed (`expectWithoutVector`).
125
- */
126
- function assertVectorIntegrity(db, { expectWithoutVector = 0 } = {}) {
127
- const count = (sql) => db.prepare(sql).get().count
128
- const vectors = count('SELECT COUNT(*) AS count FROM vec_memory')
129
- const linked = count('SELECT COUNT(*) AS count FROM memory_chunks WHERE vec_rowid IS NOT NULL')
130
- const distinctLinked = count(
131
- 'SELECT COUNT(DISTINCT vec_rowid) AS count FROM memory_chunks WHERE vec_rowid IS NOT NULL',
132
- )
133
- const unlinked = count('SELECT COUNT(*) AS count FROM memory_chunks WHERE vec_rowid IS NULL')
134
- const orphaned = count(`
135
- SELECT COUNT(*) AS count FROM vec_memory v
136
- LEFT JOIN memory_chunks mc ON mc.vec_rowid = v.rowid
137
- WHERE mc.id IS NULL
138
- `)
139
- const dangling = count(`
140
- SELECT COUNT(*) AS count FROM memory_chunks mc
141
- LEFT JOIN vec_memory v ON v.rowid = mc.vec_rowid
142
- WHERE mc.vec_rowid IS NOT NULL AND v.rowid IS NULL
143
- `)
144
-
145
- expect(
146
- unlinked,
147
- `expected ${expectWithoutVector} chunk(s) without a vector, found ${unlinked}`,
148
- ).toBe(expectWithoutVector)
149
- expect(distinctLinked).toBe(linked)
150
- expect(vectors, 'vec_memory must hold exactly one row per linked chunk').toBe(linked)
151
- expect(orphaned, 'vec_memory contains row(s) no chunk references').toBe(0)
152
- expect(dangling, 'memory_chunks.vec_rowid points at a missing vec_memory row').toBe(0)
153
-
154
- return { vectors, linked, distinctLinked, unlinked, orphaned, dangling }
155
- }
156
-
157
- // ── Test lifecycle ─────────────────────────────────────────────────────────
158
-
159
- let mod
160
- let root
161
- let logSpy
162
- let warnSpy
163
-
164
- beforeEach(async () => {
165
- embedMocks.pipeline.mockReset()
166
- embedMocks.pipeline.mockImplementation(
167
- async () => async (text) => ({ data: embedMocks.embeddingFor(text) }),
168
- )
169
- root = createRoot()
170
- mod = await loadIndex(root)
171
- logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
172
- warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
173
- })
174
-
175
- afterEach(() => {
176
- for (const loaded of loadedModules) {
177
- try {
178
- loaded.closeDB()
179
- } catch {
180
- /* already closed */
181
- }
182
- }
183
- loadedModules.clear()
184
- process.chdir(ORIGINAL_CWD)
185
- process.argv = [...ORIGINAL_ARGV]
186
- if (ORIGINAL_INIT_CWD === undefined) delete process.env.INIT_CWD
187
- else process.env.INIT_CWD = ORIGINAL_INIT_CWD
188
- for (const dir of tempDirs) {
189
- try {
190
- rmSync(dir, { recursive: true, force: true })
191
- } catch {
192
- /* best-effort cleanup */
193
- }
194
- }
195
- tempDirs.clear()
196
- vi.restoreAllMocks()
197
- })
198
-
199
- // ── hashContent ────────────────────────────────────────────────────────────
200
-
201
- describe('hashContent', () => {
202
- it('returns a stable 64-character sha256 hex digest', () => {
203
- const first = mod.hashContent('same text')
204
- expect(first).toMatch(/^[0-9a-f]{64}$/)
205
- expect(mod.hashContent('same text')).toBe(first)
206
- })
207
-
208
- it('changes when a single byte changes', () => {
209
- expect(mod.hashContent('a')).not.toBe(mod.hashContent('b'))
210
- })
211
-
212
- it('hashes unicode content as utf8', () => {
213
- expect(mod.hashContent('héllo ✅')).toBe(mod.hashContent('héllo ✅'))
214
- expect(mod.hashContent('héllo ✅')).not.toBe(mod.hashContent('hello'))
215
- })
216
- })
217
-
218
- // ── parseFrontmatter ───────────────────────────────────────────────────────
219
-
220
- describe('parseFrontmatter', () => {
221
- it('returns null frontmatter and the untouched body when absent', () => {
222
- const content = '# Title\n\nBody'
223
- expect(mod.parseFrontmatter(content)).toEqual({ frontmatter: null, body: content })
224
- })
225
-
226
- it('parses scalars, quoted strings and inline arrays', () => {
227
- const content = [
228
- '---',
229
- 'id: doc',
230
- 'title: "Quoted Title"',
231
- 'tags: [alpha, "beta", \'gamma\', ]',
232
- 'category: context',
233
- '---',
234
- '# Body',
235
- ].join('\n')
236
-
237
- const { frontmatter, body } = mod.parseFrontmatter(content)
238
-
239
- expect(frontmatter).toEqual({
240
- id: 'doc',
241
- title: 'Quoted Title',
242
- tags: ['alpha', 'beta', 'gamma'],
243
- category: 'context',
244
- })
245
- expect(body).toBe('# Body')
246
- })
247
-
248
- it('keeps unquoted arrays and single-quoted scalars', () => {
249
- const { frontmatter } = mod.parseFrontmatter("---\ntags: [a, b]\nnote: 'single'\n---\nbody")
250
- expect(frontmatter.tags).toEqual(['a', 'b'])
251
- expect(frontmatter.note).toBe('single')
252
- })
253
-
254
- it('skips non key/value lines and supports dashed keys', () => {
255
- const { frontmatter } = mod.parseFrontmatter('---\nupdated-at: 2024-01-01\n- not a pair\n---\nbody')
256
- expect(frontmatter).toEqual({ 'updated-at': '2024-01-01' })
257
- })
258
-
259
- it('tolerates a closing fence without a trailing newline', () => {
260
- const { frontmatter, body } = mod.parseFrontmatter('---\nid: x\n---')
261
- expect(frontmatter).toEqual({ id: 'x' })
262
- expect(body).toBe('')
263
- })
264
- })
265
-
266
- // ── chunkMarkdown ──────────────────────────────────────────────────────────
267
-
268
- describe('chunkMarkdown', () => {
269
- it('returns [] for an empty body', () => {
270
- expect(mod.chunkMarkdown('')).toEqual([])
271
- expect(mod.chunkMarkdown(' \n\n ')).toEqual([])
272
- })
273
-
274
- it('skips a standalone H1 whose section is too small to index', () => {
275
- expect(mod.chunkMarkdown('# Hi')).toEqual([])
276
- })
277
-
278
- it('splits on ## headings and drops the H1 preamble', () => {
279
- const chunks = mod.chunkMarkdown('# Title\n\nintro text\n\n## One\n\nfirst body\n\n## Two\n\nsecond body')
280
- expect(chunks).toHaveLength(2)
281
- expect(chunks[0]).toBe('## One\n\nfirst body')
282
- expect(chunks[1]).toBe('## Two\n\nsecond body')
283
- })
284
-
285
- it('skips sections shorter than 10 characters', () => {
286
- const chunks = mod.chunkMarkdown('## A\n\nx\n\n## Real Section\n\nhas enough content')
287
- expect(chunks).toHaveLength(1)
288
- expect(chunks[0]).toContain('Real Section')
289
- })
290
-
291
- it('falls back to the whole body when no usable section is found', () => {
292
- const body = 'Just a paragraph with no headings at all.'
293
- expect(mod.chunkMarkdown(body)).toEqual([body])
294
- })
295
-
296
- it('returns one chunk for a long heading-less body', () => {
297
- const body = 'word '.repeat(500).trim()
298
- expect(mod.chunkMarkdown(body)).toEqual([body])
299
- })
300
-
301
- it('preserves every section of a very long file in order', () => {
302
- const sections = Array.from({ length: 40 }, (_, i) => section(`S${i}`, `content number ${i}`))
303
- const chunks = mod.chunkMarkdown(sections.join('\n\n'))
304
- expect(chunks).toHaveLength(40)
305
- expect(chunks[0]).toContain('S0')
306
- expect(chunks[39]).toContain('S39')
307
- })
308
- })
309
-
310
- // ── getStats / closeDB before any DB exists ────────────────────────────────
311
-
312
- describe('getStats before the DB is opened', () => {
313
- it('returns the empty shape without touching the filesystem', () => {
314
- expect(mod.getStats()).toEqual({ totalVectors: 0, totalFiles: 0, dbSize: 'N/A' })
315
- })
316
-
317
- it('closeDB is a no-op when no connection exists', () => {
318
- expect(() => mod.closeDB()).not.toThrow()
319
- expect(mod.getStats().totalVectors).toBe(0)
320
- })
321
- })
322
-
323
- // ── getDB schema creation and migration ────────────────────────────────────
324
-
325
- describe('getDB', () => {
326
- it('creates the schema, the unique index and the vec0 table on first use', async () => {
327
- writeTwoSectionFile(root)
328
- await mod.indexFile(join(root, 'memory-bank/doc.md'))
329
-
330
- const db = openDb(root)
331
- try {
332
- const tables = db
333
- .prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
334
- .all()
335
- .map((row) => row.name)
336
- expect(tables).toContain('memory_chunks')
337
- expect(tables).toContain('vec_memory')
338
-
339
- const indexes = db
340
- .prepare("SELECT name FROM sqlite_master WHERE type='index'")
341
- .all()
342
- .map((row) => row.name)
343
- expect(indexes).toContain('idx_chunk_lookup')
344
-
345
- const columns = db
346
- .prepare("SELECT name FROM pragma_table_info('memory_chunks')")
347
- .all()
348
- .map((row) => row.name)
349
- expect(columns).toEqual(
350
- expect.arrayContaining([
351
- 'id',
352
- 'source_file',
353
- 'chunk_index',
354
- 'content',
355
- 'content_hash',
356
- 'vec_rowid',
357
- 'updated_at',
358
- ]),
359
- )
360
- expect(db.pragma('journal_mode', { simple: true })).toBe('wal')
361
- } finally {
362
- db.close()
363
- }
364
- })
365
-
366
- it('is idempotent across re-opens (schema already present)', async () => {
367
- writeTwoSectionFile(root)
368
- await mod.indexFile(join(root, 'memory-bank/doc.md'))
369
- mod.closeDB()
370
-
371
- const second = await loadIndex(root)
372
- const result = await second.indexFile(join(root, 'memory-bank/doc.md'))
373
-
374
- expect(result).toEqual({ indexed: 0, skipped: 2 })
375
- })
376
-
377
- it('migrates a legacy DB by adding vec_rowid and creating the vec table', async () => {
378
- const legacyRoot = createRoot()
379
- const indexPath = join(legacyRoot, 'memory-bank/.index')
380
- mkdirSync(indexPath, { recursive: true })
381
- const legacy = new Database(join(indexPath, 'memory.db'))
382
- legacy.exec(`
383
- CREATE TABLE memory_chunks (
384
- id INTEGER PRIMARY KEY AUTOINCREMENT,
385
- source_file TEXT NOT NULL,
386
- chunk_index INTEGER NOT NULL,
387
- content TEXT NOT NULL,
388
- content_hash TEXT NOT NULL,
389
- updated_at TEXT DEFAULT CURRENT_TIMESTAMP
390
- );
391
- `)
392
- legacy.close()
393
-
394
- const legacyMod = await loadIndex(legacyRoot)
395
- writeRepoFile(legacyRoot, 'memory-bank/doc.md', FILE_TWO_SECTIONS)
396
- const result = await legacyMod.indexFile(join(legacyRoot, 'memory-bank/doc.md'))
397
-
398
- expect(result.indexed).toBe(2)
399
- const db = openDb(legacyRoot)
400
- try {
401
- const columns = db
402
- .prepare("SELECT name FROM pragma_table_info('memory_chunks')")
403
- .all()
404
- .map((row) => row.name)
405
- expect(columns).toContain('vec_rowid')
406
- expect(db.prepare("SELECT name FROM sqlite_master WHERE name='vec_memory'").get()).toBeTruthy()
407
- } finally {
408
- db.close()
409
- }
410
- })
411
-
412
- it('rejects when the DB file path is not usable', async () => {
413
- mkdirSync(join(root, 'memory-bank/.index/memory.db'), { recursive: true })
414
- writeTwoSectionFile(root)
415
-
416
- await expect(mod.indexFile(join(root, 'memory-bank/doc.md'))).rejects.toThrow()
417
- })
418
-
419
- it('reports a helpful error when better-sqlite3 cannot be loaded', async () => {
420
- vi.doMock('better-sqlite3', () => {
421
- throw new Error('simulated missing dependency')
422
- })
423
- try {
424
- const broken = await loadIndex(root)
425
- await expect(broken.rebuildIndex()).rejects.toThrow(
426
- 'better-sqlite3 not installed. Run: npm install better-sqlite3',
427
- )
428
- } finally {
429
- vi.doUnmock('better-sqlite3')
430
- vi.resetModules()
431
- }
432
- })
433
-
434
- it('reports a helpful error when sqlite-vec cannot be loaded', async () => {
435
- vi.doMock('sqlite-vec', () => {
436
- throw new Error('simulated missing dependency')
437
- })
438
- try {
439
- const broken = await loadIndex(root)
440
- writeTwoSectionFile(root)
441
- await expect(broken.indexFile(join(root, 'memory-bank/doc.md'))).rejects.toThrow(
442
- 'sqlite-vec not installed. Run: npm install sqlite-vec',
443
- )
444
- } finally {
445
- vi.doUnmock('sqlite-vec')
446
- vi.resetModules()
447
- }
448
- })
449
- })
450
-
451
- // ── indexFile ──────────────────────────────────────────────────────────────
452
-
453
- describe('indexFile', () => {
454
- it('indexes every ## section and links vectors to chunks', async () => {
455
- writeTwoSectionFile(root)
456
-
457
- const result = await mod.indexFile(join(root, 'memory-bank/doc.md'))
458
-
459
- expect(result).toEqual({ indexed: 2, skipped: 0 })
460
-
461
- const db = openDb(root)
462
- try {
463
- const chunks = db
464
- .prepare('SELECT source_file, chunk_index, content, content_hash, vec_rowid FROM memory_chunks ORDER BY chunk_index')
465
- .all()
466
- expect(chunks).toHaveLength(2)
467
- expect(chunks[0].source_file).toBe('doc.md')
468
- expect(chunks[0].content).toContain('Alpha')
469
- expect(chunks[0].content_hash).toBe(mod.hashContent(chunks[0].content))
470
- expect(chunks[0].vec_rowid).not.toBeNull()
471
- expect(chunks[1].vec_rowid).not.toBeNull()
472
-
473
- const vectors = db.prepare('SELECT COUNT(*) AS count FROM vec_memory').get()
474
- expect(vectors.count).toBe(2)
475
- } finally {
476
- db.close()
477
- }
478
- })
479
-
480
- it('skips unchanged chunks on re-index', async () => {
481
- writeTwoSectionFile(root)
482
- await mod.indexFile(join(root, 'memory-bank/doc.md'))
483
-
484
- const second = await mod.indexFile(join(root, 'memory-bank/doc.md'))
485
-
486
- expect(second).toEqual({ indexed: 0, skipped: 2 })
487
- expect(mod.getStats().totalVectors).toBe(2)
488
- })
489
-
490
- it('updates changed chunks in place and points them at the new vector', async () => {
491
- writeTwoSectionFile(root)
492
- await mod.indexFile(join(root, 'memory-bank/doc.md'))
493
-
494
- const db = openDb(root)
495
- const before = db
496
- .prepare('SELECT id, content, content_hash, vec_rowid FROM memory_chunks ORDER BY chunk_index')
497
- .all()
498
- db.close()
499
-
500
- writeRepoFile(
501
- root,
502
- 'memory-bank/doc.md',
503
- ['# Doc', '', section('Alpha', 'alpha REWRITTEN content'), section('Beta', 'beta beta content')].join('\n'),
504
- )
505
- const result = await mod.indexFile(join(root, 'memory-bank/doc.md'))
506
-
507
- expect(result).toEqual({ indexed: 1, skipped: 1 })
508
-
509
- const after = openDb(root)
510
- try {
511
- const rows = after
512
- .prepare('SELECT id, content, content_hash, vec_rowid FROM memory_chunks ORDER BY chunk_index')
513
- .all()
514
- expect(rows[0].id).toBe(before[0].id)
515
- expect(rows[0].content).toContain('REWRITTEN')
516
- expect(rows[0].content_hash).not.toBe(before[0].content_hash)
517
- expect(rows[0].vec_rowid).not.toBe(before[0].vec_rowid)
518
- expect(rows[1].content_hash).toBe(before[1].content_hash)
519
- } finally {
520
- after.close()
521
- }
522
- })
523
-
524
- it('inserts a new chunk when a section is appended', async () => {
525
- writeTwoSectionFile(root)
526
- await mod.indexFile(join(root, 'memory-bank/doc.md'))
527
-
528
- writeRepoFile(
529
- root,
530
- 'memory-bank/doc.md',
531
- [FILE_TWO_SECTIONS, section('Gamma', 'gamma gamma content')].join('\n\n'),
532
- )
533
- const result = await mod.indexFile(join(root, 'memory-bank/doc.md'))
534
-
535
- expect(result).toEqual({ indexed: 1, skipped: 2 })
536
- expect(mod.getStats().totalVectors).toBe(3)
537
- })
538
-
539
- it('keeps indexing after a single chunk fails to embed', async () => {
540
- const content = [
541
- '# Doc',
542
- '',
543
- section('Good', 'alpha alpha content'),
544
- section('Bad', `${embedMocks.FAIL_MARKER} broken chunk`),
545
- section('Also Good', 'gamma gamma content'),
546
- ].join('\n\n')
547
- writeRepoFile(root, 'memory-bank/doc.md', content)
548
-
549
- const result = await mod.indexFile(join(root, 'memory-bank/doc.md'))
550
-
551
- expect(result).toEqual({ indexed: 2, skipped: 0 })
552
- expect(warnSpy).toHaveBeenCalledWith(
553
- expect.stringContaining('Failed to embed chunk 1 of doc.md: synthetic embedding failure'),
554
- )
555
-
556
- const db = openDb(root)
557
- try {
558
- const rows = db
559
- .prepare('SELECT chunk_index, vec_rowid FROM memory_chunks ORDER BY chunk_index')
560
- .all()
561
- expect(rows.map((row) => row.vec_rowid === null)).toEqual([false, true, false])
562
- expect(db.prepare('SELECT COUNT(*) AS count FROM vec_memory').get().count).toBe(2)
563
- } finally {
564
- db.close()
565
- }
566
- })
567
-
568
- it('treats an empty file as zero chunks', async () => {
569
- writeRepoFile(root, 'memory-bank/empty.md', '')
570
- expect(await mod.indexFile(join(root, 'memory-bank/empty.md'))).toEqual({ indexed: 0, skipped: 0 })
571
- })
572
-
573
- it('treats a frontmatter-only file as zero chunks', async () => {
574
- writeRepoFile(root, 'memory-bank/meta.md', '---\nid: meta\ntitle: "Meta"\n---\n')
575
- expect(await mod.indexFile(join(root, 'memory-bank/meta.md'))).toEqual({ indexed: 0, skipped: 0 })
576
- })
577
-
578
- it('indexes a heading-less file as a single chunk', async () => {
579
- writeRepoFile(root, 'memory-bank/flat.md', 'A long paragraph without headings but with enough text.')
580
- expect(await mod.indexFile(join(root, 'memory-bank/flat.md'))).toEqual({ indexed: 1, skipped: 0 })
581
- })
582
-
583
- it('rejects for a missing markdown file', async () => {
584
- await expect(mod.indexFile(join(root, 'memory-bank/missing.md'))).rejects.toThrow(/ENOENT/)
585
- })
586
-
587
- it('reopens the DB after closeDB and reuses the cached driver modules', async () => {
588
- writeRepoFile(root, 'memory-bank/one.md', '# One\n\nfirst document content')
589
- writeRepoFile(root, 'memory-bank/two.md', '# Two\n\nsecond document content')
590
-
591
- await mod.indexFile(join(root, 'memory-bank/one.md'))
592
- mod.closeDB()
593
- expect(mod.getStats().totalVectors).toBe(0)
594
-
595
- // Re-opening exercises the "dependency already cached" arms of
596
- // getSQLite()/getVec() while the embedder stays cached too.
597
- const result = await mod.indexFile(join(root, 'memory-bank/two.md'))
598
-
599
- expect(result).toEqual({ indexed: 1, skipped: 0 })
600
- expect(mod.getStats().totalVectors).toBe(2)
601
- })
602
-
603
- it('reuses the cached embedder across files', async () => {
604
- writeRepoFile(root, 'memory-bank/one.md', '# One\n\nfirst document content')
605
- writeRepoFile(root, 'memory-bank/two.md', '# Two\n\nsecond document content')
606
-
607
- await mod.indexFile(join(root, 'memory-bank/one.md'))
608
- await mod.indexFile(join(root, 'memory-bank/two.md'))
609
-
610
- expect(embedMocks.pipeline).toHaveBeenCalledTimes(1)
611
- })
612
- })
613
-
614
- // ── embedder failure modes ─────────────────────────────────────────────────
615
-
616
- describe('embedding dependency failures', () => {
617
- it('warns and skips chunks when @xenova/transformers is unavailable', async () => {
618
- vi.doMock('@xenova/transformers', () => {
619
- throw new Error('simulated missing dependency')
620
- })
621
- try {
622
- const broken = await loadIndex(root)
623
- writeTwoSectionFile(root)
624
-
625
- const result = await broken.indexFile(join(root, 'memory-bank/doc.md'))
626
-
627
- expect(result).toEqual({ indexed: 0, skipped: 0 })
628
- expect(warnSpy).toHaveBeenCalledWith(
629
- '[memory-index] @xenova/transformers not installed. Embedding unavailable.',
630
- )
631
- expect(warnSpy).toHaveBeenCalledWith(
632
- '[memory-index] Failed to embed chunk 0 of doc.md: Embedding model not available',
633
- )
634
- } finally {
635
- // vi.doUnmock() removes the hoisted mock registration for this id too,
636
- // so re-arm it explicitly for the rest of the file.
637
- vi.doMock('@xenova/transformers', () => ({ pipeline: embedMocks.pipeline }))
638
- vi.resetModules()
639
- }
640
- })
641
-
642
- it('warns and skips chunks when the model fails to load', async () => {
643
- embedMocks.pipeline.mockRejectedValueOnce(new Error('model download failed'))
644
- const failing = await loadIndex(root)
645
- writeTwoSectionFile(root)
646
-
647
- const result = await failing.indexFile(join(root, 'memory-bank/doc.md'))
648
-
649
- expect(result.indexed).toBe(0)
650
- expect(warnSpy).toHaveBeenCalledWith(
651
- '[memory-index] Failed to load embedding model:',
652
- 'model download failed',
653
- )
654
- })
655
-
656
- it('surfaces a wrapped error when search cannot embed the query', async () => {
657
- const failing = await loadIndex(root)
658
- embedMocks.pipeline.mockRejectedValueOnce(new Error('model download failed'))
659
-
660
- await expect(failing.search('anything')).rejects.toThrow(
661
- 'Embedding failed: Embedding model not available. Is @xenova/transformers installed?',
662
- )
663
- })
664
- })
665
-
666
- // ── rebuildIndex ───────────────────────────────────────────────────────────
667
-
668
- describe('rebuildIndex', () => {
669
- it('indexes memory-bank and docs recursively and reports stats', async () => {
670
- writeRepoFile(root, 'memory-bank/a.md', '# A\n\nalpha alpha content')
671
- writeRepoFile(root, 'memory-bank/nested/b.md', '# B\n\nbeta beta content')
672
- writeRepoFile(root, 'docs/c.md', '# C\n\ngamma gamma content')
673
- writeRepoFile(root, 'docs/nested/d.md', '# D\n\ndelta delta content')
674
- writeRepoFile(root, 'docs/notes.txt', 'not markdown')
675
-
676
- const result = await mod.rebuildIndex()
677
-
678
- expect(result.indexed).toBe(4)
679
- expect(result.skipped).toBe(0)
680
- expect(result.stats.totalFiles).toBe(4)
681
- expect(result.stats.totalVectors).toBe(4)
682
- expect(result.stats.dbSize).toMatch(/^\d+\.\d MB$/)
683
- })
684
-
685
- it('skips .index and node_modules directories', async () => {
686
- writeRepoFile(root, 'memory-bank/a.md', '# A\n\nalpha alpha content')
687
- writeRepoFile(root, 'memory-bank/.index/hidden.md', '# Hidden\n\nhidden hidden content')
688
- writeRepoFile(root, 'memory-bank/node_modules/dep.md', '# Dep\n\ndep dep content')
689
-
690
- const result = await mod.rebuildIndex()
691
-
692
- expect(result.stats.totalFiles).toBe(1)
693
- })
694
-
695
- it('prunes chunks and vectors of deleted files', async () => {
696
- writeRepoFile(root, 'memory-bank/a.md', '# A\n\nalpha alpha content')
697
- await mod.rebuildIndex()
698
-
699
- rmSync(join(root, 'memory-bank/a.md'))
700
- writeRepoFile(root, 'memory-bank/b.md', '# B\n\nbeta beta content')
701
- const result = await mod.rebuildIndex()
702
-
703
- expect(result.stats.totalFiles).toBe(1)
704
- const db = openDb(root)
705
- try {
706
- expect(db.prepare('SELECT DISTINCT source_file FROM memory_chunks').all()).toEqual([
707
- { source_file: 'b.md' },
708
- ])
709
- expect(db.prepare('SELECT COUNT(*) AS count FROM vec_memory').get().count).toBe(1)
710
- } finally {
711
- db.close()
712
- }
713
- })
714
-
715
- it('handles absent memory-bank and docs directories', async () => {
716
- const emptyRoot = createRoot()
717
- rmSync(join(emptyRoot, 'memory-bank'), { recursive: true, force: true })
718
- const empty = await loadIndex(emptyRoot)
719
-
720
- const result = await empty.rebuildIndex()
721
-
722
- expect(result).toEqual({
723
- indexed: 0,
724
- skipped: 0,
725
- stats: { totalVectors: 0, totalFiles: 0, dbSize: expect.stringMatching(/^\d+\.\d MB$/) },
726
- })
727
- })
728
-
729
- it('does not log a per-file line for files that yield no chunks', async () => {
730
- writeRepoFile(root, 'memory-bank/a.md', FILE_TWO_SECTIONS)
731
- writeRepoFile(root, 'memory-bank/empty.md', '')
732
-
733
- const result = await mod.rebuildIndex()
734
-
735
- expect(result.indexed).toBe(2)
736
- expect(logSpy).not.toHaveBeenCalledWith(expect.stringContaining('Indexed: empty.md'))
737
- })
738
-
739
- it('logs per-file and summary lines', async () => {
740
- writeRepoFile(root, 'memory-bank/a.md', FILE_TWO_SECTIONS)
741
-
742
- await mod.rebuildIndex()
743
-
744
- expect(logSpy).toHaveBeenCalledWith('[memory-index] Full rebuild started...')
745
- expect(logSpy).toHaveBeenCalledWith('[memory-index] Indexed: a.md (2 chunks)')
746
- expect(logSpy).toHaveBeenCalledWith(
747
- expect.stringContaining('[memory-index] Rebuild complete: 2 chunks indexed, 0 skipped.'),
748
- )
749
- })
750
- })
751
-
752
- // ── incrementalUpdate ──────────────────────────────────────────────────────
753
-
754
- describe('incrementalUpdate', () => {
755
- it('skips unchanged files and reports no changes', async () => {
756
- writeRepoFile(root, 'memory-bank/a.md', FILE_TWO_SECTIONS)
757
- writeRepoFile(root, 'docs/b.md', '# B\n\nbeta beta content')
758
- await mod.rebuildIndex()
759
-
760
- const result = await mod.incrementalUpdate()
761
-
762
- expect(result).toEqual({ indexed: 0, skipped: 3, changed: [] })
763
- })
764
-
765
- it('re-indexes a modified file and lists it in changed', async () => {
766
- writeRepoFile(root, 'memory-bank/a.md', FILE_TWO_SECTIONS)
767
- await mod.rebuildIndex()
768
-
769
- writeRepoFile(
770
- root,
771
- 'memory-bank/a.md',
772
- ['# Doc', '', section('Alpha', 'alpha CHANGED content'), section('Beta', 'beta beta content')].join('\n'),
773
- )
774
- const result = await mod.incrementalUpdate()
775
-
776
- expect(result.indexed).toBe(1)
777
- expect(result.changed).toEqual(['a.md'])
778
- })
779
-
780
- it('picks up brand-new files on first run', async () => {
781
- writeRepoFile(root, 'memory-bank/fresh.md', '# Fresh\n\nfresh fresh content')
782
-
783
- const result = await mod.incrementalUpdate()
784
-
785
- expect(result).toEqual({ indexed: 1, skipped: 0, changed: ['fresh.md'] })
786
- })
787
-
788
- it('ignores frontmatter-only and empty files', async () => {
789
- writeRepoFile(root, 'memory-bank/meta.md', '---\nid: meta\n---\n')
790
-
791
- const result = await mod.incrementalUpdate()
792
-
793
- expect(result).toEqual({ indexed: 0, skipped: 0, changed: [] })
794
- })
795
-
796
- it('handles a missing docs directory', async () => {
797
- writeRepoFile(root, 'memory-bank/a.md', FILE_TWO_SECTIONS)
798
-
799
- const result = await mod.incrementalUpdate()
800
-
801
- expect(result.changed).toEqual(['a.md'])
802
- })
803
- })
804
-
805
- // ── vec_memory integrity ───────────────────────────────────────────────────
806
- //
807
- // vec0 has no foreign keys: a vector row is only reachable through
808
- // memory_chunks.vec_rowid, so any row that loses its link becomes an invisible
809
- // storage leak. These tests pin the invariant (one vector per linked chunk, no
810
- // orphans, no dangling links) across the update, prune and failure paths.
811
-
812
- describe('vec_memory integrity', () => {
813
- it('does not orphan the previous vector when a chunk is re-embedded', async () => {
814
- writeTwoSectionFile(root)
815
- await mod.indexFile(join(root, 'memory-bank/doc.md'))
816
-
817
- const before = openDb(root)
818
- let oldVecRowid
819
- let beforeCount
820
- try {
821
- oldVecRowid = before
822
- .prepare('SELECT vec_rowid FROM memory_chunks WHERE chunk_index = 0')
823
- .get().vec_rowid
824
- beforeCount = before.prepare('SELECT COUNT(*) AS count FROM vec_memory').get().count
825
- } finally {
826
- before.close()
827
- }
828
-
829
- writeRepoFile(
830
- root,
831
- 'memory-bank/doc.md',
832
- ['# Doc', '', section('Alpha', 'alpha REWRITTEN content'), section('Beta', 'beta beta content')].join('\n'),
833
- )
834
- expect(await mod.indexFile(join(root, 'memory-bank/doc.md'))).toEqual({ indexed: 1, skipped: 1 })
835
-
836
- const after = openDb(root)
837
- try {
838
- const chunk = after
839
- .prepare('SELECT content, vec_rowid FROM memory_chunks WHERE chunk_index = 0')
840
- .get()
841
- const vectorCount = after.prepare('SELECT COUNT(*) AS count FROM vec_memory').get().count
842
-
843
- expect(vectorCount, 're-embedding a chunk must not grow vec_memory').toBe(beforeCount)
844
- expect(
845
- after.prepare('SELECT rowid FROM vec_memory WHERE rowid = ?').get(oldVecRowid),
846
- `stale vec_memory row ${oldVecRowid} was orphaned by the update`,
847
- ).toBeUndefined()
848
- expect(chunk.content).toContain('REWRITTEN')
849
- expect(after.prepare('SELECT rowid FROM vec_memory WHERE rowid = ?').get(chunk.vec_rowid)).toBeTruthy()
850
- assertVectorIntegrity(after)
851
- } finally {
852
- after.close()
853
- }
854
-
855
- const results = await mod.search('alpha REWRITTEN', { sourceFile: 'doc.md', topK: 1 })
856
- expect(results[0].content).toContain('REWRITTEN')
857
- })
858
-
859
- it('prunes replaced and trailing chunks together with their vectors', async () => {
860
- writeRepoFile(
861
- root,
862
- 'memory-bank/doc.md',
863
- ['# Doc', '', section('Alpha', 'alpha alpha content'), section('Beta', 'beta beta content')].join('\n'),
864
- )
865
- await mod.indexFile(join(root, 'memory-bank/doc.md'))
866
-
867
- const before = openDb(root)
868
- let oldRowids
869
- try {
870
- oldRowids = before
871
- .prepare('SELECT vec_rowid FROM memory_chunks ORDER BY chunk_index')
872
- .all()
873
- .map((row) => row.vec_rowid)
874
- } finally {
875
- before.close()
876
- }
877
-
878
- // Shrink two sections to one: chunk 0 is replaced (new content) and chunk 1
879
- // disappears entirely. Both old vectors must go.
880
- writeRepoFile(root, 'memory-bank/doc.md', ['# Doc', '', section('Gamma', 'gamma gamma content')].join('\n'))
881
- expect(await mod.indexFile(join(root, 'memory-bank/doc.md'))).toEqual({ indexed: 1, skipped: 0 })
882
-
883
- const after = openDb(root)
884
- try {
885
- const chunks = after.prepare('SELECT chunk_index, content, vec_rowid FROM memory_chunks').all()
886
- expect(chunks).toHaveLength(1)
887
- expect(chunks[0].content).toContain('Gamma')
888
- expect(after.prepare('SELECT COUNT(*) AS count FROM vec_memory').get().count).toBe(1)
889
- for (const rowid of oldRowids) {
890
- expect(
891
- after.prepare('SELECT rowid FROM vec_memory WHERE rowid = ?').get(rowid),
892
- `stale vec_memory row ${rowid} survived the chunk prune`,
893
- ).toBeUndefined()
894
- }
895
- assertVectorIntegrity(after)
896
- } finally {
897
- after.close()
898
- }
899
- })
900
-
901
- it('re-embeds chunks whose vector is missing or dangling', async () => {
902
- writeTwoSectionFile(root)
903
- await mod.indexFile(join(root, 'memory-bank/doc.md'))
904
-
905
- const broken = openDb(root)
906
- try {
907
- const rows = broken
908
- .prepare('SELECT id, vec_rowid FROM memory_chunks ORDER BY chunk_index')
909
- .all()
910
- // Chunk 0: the vector never landed (legacy row / embed failure).
911
- broken.prepare('DELETE FROM vec_memory WHERE rowid = ?').run(rows[0].vec_rowid)
912
- broken.prepare('UPDATE memory_chunks SET vec_rowid = NULL WHERE id = ?').run(rows[0].id)
913
- // Chunk 1: the link survives but the vector row does not.
914
- broken.prepare('DELETE FROM vec_memory WHERE rowid = ?').run(rows[1].vec_rowid)
915
- } finally {
916
- broken.close()
917
- }
918
-
919
- expect(await mod.indexFile(join(root, 'memory-bank/doc.md'))).toEqual({ indexed: 2, skipped: 0 })
920
-
921
- const after = openDb(root)
922
- try {
923
- expect(after.prepare('SELECT COUNT(*) AS count FROM vec_memory').get().count).toBe(2)
924
- assertVectorIntegrity(after)
925
- } finally {
926
- after.close()
927
- }
928
- })
929
-
930
- it('retries unchanged content when a chunk still needs a vector', async () => {
931
- writeRepoFile(root, 'memory-bank/a.md', '# A\n\nalpha alpha content')
932
- await mod.rebuildIndex()
933
-
934
- const broken = openDb(root)
935
- try {
936
- const chunk = broken.prepare('SELECT id, vec_rowid FROM memory_chunks').get()
937
- broken.prepare('DELETE FROM vec_memory WHERE rowid = ?').run(chunk.vec_rowid)
938
- broken.prepare('UPDATE memory_chunks SET vec_rowid = NULL WHERE id = ?').run(chunk.id)
939
- } finally {
940
- broken.close()
941
- }
942
-
943
- const result = await mod.incrementalUpdate()
944
-
945
- expect(result).toEqual({ indexed: 1, skipped: 0, changed: ['a.md'] })
946
- const after = openDb(root)
947
- try {
948
- expect(after.prepare('SELECT COUNT(*) AS count FROM vec_memory').get().count).toBe(1)
949
- assertVectorIntegrity(after)
950
- } finally {
951
- after.close()
952
- }
953
- })
954
-
955
- it('prunes chunks and vectors of files deleted before an incremental update', async () => {
956
- writeRepoFile(root, 'memory-bank/keep.md', '# Keep\n\nkeep keep content')
957
- writeRepoFile(root, 'memory-bank/gone.md', '# Gone\n\ngone gone content')
958
- writeRepoFile(root, 'memory-bank/never-embedded.md', `# Bad\n\n${embedMocks.FAIL_MARKER} broken content`)
959
- await mod.rebuildIndex()
960
-
961
- rmSync(join(root, 'memory-bank/gone.md'))
962
- rmSync(join(root, 'memory-bank/never-embedded.md'))
963
- const result = await mod.incrementalUpdate()
964
-
965
- expect(result.changed).toEqual([])
966
- const db = openDb(root)
967
- try {
968
- expect(db.prepare('SELECT DISTINCT source_file FROM memory_chunks').all()).toEqual([
969
- { source_file: 'keep.md' },
970
- ])
971
- expect(db.prepare('SELECT COUNT(*) AS count FROM vec_memory').get().count).toBe(1)
972
- assertVectorIntegrity(db)
973
- } finally {
974
- db.close()
975
- }
976
- })
977
-
978
- it('removes vec_memory rows that no chunk references', async () => {
979
- writeRepoFile(root, 'memory-bank/a.md', '# A\n\nalpha alpha content')
980
- await mod.rebuildIndex()
981
-
982
- const seeded = openDb(root)
983
- let orphanRowid
984
- try {
985
- orphanRowid = Number(
986
- seeded.prepare('INSERT INTO vec_memory (embedding) VALUES (?)').run(
987
- new Float32Array(embedMocks.DIM),
988
- ).lastInsertRowid,
989
- )
990
- expect(seeded.prepare('SELECT COUNT(*) AS count FROM vec_memory').get().count).toBe(2)
991
- } finally {
992
- seeded.close()
993
- }
994
-
995
- await mod.incrementalUpdate()
996
-
997
- const after = openDb(root)
998
- try {
999
- expect(
1000
- after.prepare('SELECT rowid FROM vec_memory WHERE rowid = ?').get(orphanRowid),
1001
- `orphan vec_memory row ${orphanRowid} was not reclaimed`,
1002
- ).toBeUndefined()
1003
- expect(after.prepare('SELECT COUNT(*) AS count FROM vec_memory').get().count).toBe(1)
1004
- assertVectorIntegrity(after)
1005
- } finally {
1006
- after.close()
1007
- }
1008
- })
1009
-
1010
- it('holds the invariant across new, edited, deleted and repeated indexing', async () => {
1011
- writeRepoFile(root, 'memory-bank/one.md', FILE_TWO_SECTIONS)
1012
- writeRepoFile(root, 'docs/two.md', '# Two\n\ntwo two content')
1013
- await mod.rebuildIndex()
1014
-
1015
- writeRepoFile(root, 'memory-bank/three.md', '# Three\n\nthree three content')
1016
- writeRepoFile(
1017
- root,
1018
- 'memory-bank/one.md',
1019
- ['# Doc', '', section('Alpha', 'alpha EDITED content'), section('Beta', 'beta beta content')].join('\n'),
1020
- )
1021
- writeRepoFile(root, 'memory-bank/bad.md', `# Bad\n\n${embedMocks.FAIL_MARKER} will never embed`)
1022
- rmSync(join(root, 'docs/two.md'))
1023
-
1024
- await mod.incrementalUpdate()
1025
- await mod.incrementalUpdate()
1026
-
1027
- const db = openDb(root)
1028
- try {
1029
- const { vectors, linked, unlinked } = assertVectorIntegrity(db, { expectWithoutVector: 1 })
1030
- expect(linked).toBe(3) // one.md (2) + three.md (1); bad.md has no vector
1031
- expect(vectors).toBe(3)
1032
- expect(unlinked).toBe(1)
1033
- expect(
1034
- db.prepare('SELECT DISTINCT source_file FROM memory_chunks ORDER BY source_file').all(),
1035
- ).toEqual([
1036
- { source_file: 'bad.md' },
1037
- { source_file: 'one.md' },
1038
- { source_file: 'three.md' },
1039
- ])
1040
- } finally {
1041
- db.close()
1042
- }
1043
- })
1044
- })
1045
-
1046
- // ── search ─────────────────────────────────────────────────────────────────
1047
-
1048
- describe('search', () => {
1049
- async function seedRankingCorpus() {
1050
- writeRepoFile(root, 'memory-bank/alpha.md', '# Alpha\n\nalpha wolf alpha wolf')
1051
- writeRepoFile(root, 'memory-bank/beta.md', '# Beta\n\nbeta wolf beta wolf')
1052
- writeRepoFile(root, 'memory-bank/gamma.md', '# Gamma\n\ngamma wolf gamma wolf')
1053
- await mod.rebuildIndex()
1054
- }
1055
-
1056
- it('returns the documented result shape with similarity scores', async () => {
1057
- await seedRankingCorpus()
1058
-
1059
- const results = await mod.search('alpha wolf')
1060
-
1061
- expect(results.length).toBeGreaterThan(0)
1062
- for (const result of results) {
1063
- expect(Object.keys(result).sort()).toEqual(['content', 'score', 'source_file'])
1064
- expect(typeof result.source_file).toBe('string')
1065
- expect(typeof result.content).toBe('string')
1066
- expect(typeof result.score).toBe('number')
1067
- expect(result.score).toBe(Math.round(result.score * 1000) / 1000)
1068
- }
1069
- expect(results[0].source_file).toBe('alpha.md')
1070
- expect(results[0].content).toContain('alpha')
1071
- })
1072
-
1073
- it('orders results by descending similarity', async () => {
1074
- await seedRankingCorpus()
1075
-
1076
- const results = await mod.search('beta wolf', { topK: 3 })
1077
- const scores = results.map((result) => result.score)
1078
-
1079
- expect(scores).toEqual([...scores].sort((a, b) => b - a))
1080
- expect(results[0].source_file).toBe('beta.md')
1081
- })
1082
-
1083
- it('respects topK', async () => {
1084
- await seedRankingCorpus()
1085
-
1086
- const results = await mod.search('wolf', { topK: 1 })
1087
-
1088
- expect(results).toHaveLength(1)
1089
- })
1090
-
1091
- it('filters by sourceFile', async () => {
1092
- await seedRankingCorpus()
1093
-
1094
- const results = await mod.search('wolf', { topK: 5, sourceFile: 'beta.md' })
1095
-
1096
- expect(results.length).toBeGreaterThan(0)
1097
- expect(results.every((result) => result.source_file === 'beta.md')).toBe(true)
1098
- })
1099
-
1100
- it('returns [] when nothing is indexed', async () => {
1101
- expect(await mod.search('anything')).toEqual([])
1102
- })
1103
-
1104
- it('wraps embedding failures with an actionable message', async () => {
1105
- await seedRankingCorpus()
1106
- const broken = await loadIndex(root)
1107
- embedMocks.pipeline.mockRejectedValueOnce(new Error('model offline'))
1108
-
1109
- await expect(broken.search('alpha')).rejects.toThrow(
1110
- 'Embedding failed: Embedding model not available. Is @xenova/transformers installed?',
1111
- )
1112
- })
1113
- })
1114
-
1115
- // ── getFile ────────────────────────────────────────────────────────────────
1116
-
1117
- describe('getFile', () => {
1118
- it('reads from memory-bank first', () => {
1119
- writeRepoFile(root, 'memory-bank/projectbrief.md', '# Brief\n\nmemory-bank copy')
1120
- writeRepoFile(root, 'docs/projectbrief.md', '# Brief\n\ndocs copy')
1121
-
1122
- expect(mod.getFile('projectbrief')).toContain('memory-bank copy')
1123
- })
1124
-
1125
- it('falls back to docs/', () => {
1126
- writeRepoFile(root, 'docs/guide.md', '# Guide\n\ndocs guide')
1127
- expect(mod.getFile('guide')).toContain('docs guide')
1128
- })
1129
-
1130
- it('falls back to hidden docs files', () => {
1131
- writeRepoFile(root, 'docs/.tracker-log.md', '# Tracker\n\nhidden doc')
1132
- expect(mod.getFile('tracker-log')).toContain('hidden doc')
1133
- })
1134
-
1135
- it('returns null when the file is nowhere', () => {
1136
- expect(mod.getFile('nope')).toBeNull()
1137
- })
1138
- })
1139
-
1140
- // ── getStats ───────────────────────────────────────────────────────────────
1141
-
1142
- describe('getStats', () => {
1143
- it('reports vector/file counts and DB size after indexing', async () => {
1144
- writeTwoSectionFile(root)
1145
- writeRepoFile(root, 'docs/other.md', '# Other\n\nother other content')
1146
- await mod.rebuildIndex()
1147
-
1148
- const stats = mod.getStats()
1149
-
1150
- expect(stats.totalVectors).toBe(3)
1151
- expect(stats.totalFiles).toBe(2)
1152
- expect(stats.dbSize).toMatch(/^\d+\.\d MB$/)
1153
- })
1154
-
1155
- it('reports 0.0 MB when the DB file disappears', async () => {
1156
- writeTwoSectionFile(root)
1157
- await mod.indexFile(join(root, 'memory-bank/doc.md'))
1158
- expect(existsSync(join(root, 'memory-bank/.index/memory.db'))).toBe(true)
1159
-
1160
- rmSync(join(root, 'memory-bank/.index/memory.db'))
1161
-
1162
- expect(mod.getStats().dbSize).toBe('0.0 MB')
1163
- })
1164
-
1165
- it('returns the empty shape after closeDB', async () => {
1166
- writeTwoSectionFile(root)
1167
- await mod.indexFile(join(root, 'memory-bank/doc.md'))
1168
-
1169
- mod.closeDB()
1170
-
1171
- expect(mod.getStats()).toEqual({ totalVectors: 0, totalFiles: 0, dbSize: 'N/A' })
1172
- })
1173
- })