@abarcenas/ai-workflow-template 1.49.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.
- package/.agents/instructions/knowledgebase.instructions.md +2 -2
- package/.agents/instructions/learned-knowledge.instructions.md +47 -2
- package/.agents/instructions/memory-schema.instructions.md +1 -1
- package/.github/workflows/npm-publish.yml +69 -1
- package/.opencode/plugins/graphify.js +22 -0
- package/LICENSE +15 -0
- package/README.md +56 -22
- package/memory-bank/.vocabulary.json +3 -0
- package/opencode.mcp.example.json +13 -0
- package/package.json +20 -10
- package/scripts/bump-version.js +18 -2
- package/scripts/mcp/playwright-mcp-launcher.js +45 -2
- package/scripts/bump-version.test.js +0 -238
- package/scripts/knowledgebase-cli.test.js +0 -576
- package/scripts/knowledgebase-index.test.js +0 -759
- package/scripts/mcp/playwright-mcp-launcher.test.js +0 -217
- package/scripts/mcp-knowledgebase-server.test.js +0 -745
- package/scripts/mcp-memory-server.test.js +0 -398
- package/scripts/memory-cli.test.js +0 -297
- package/scripts/memory-index.test.js +0 -1173
- package/scripts/normalize-memory.test.js +0 -753
- package/scripts/setup/discover.test.js +0 -530
- package/scripts/setup/hooks.test.js +0 -722
- package/scripts/setup/husky-init.test.js +0 -311
- package/scripts/setup/index.test.js +0 -1021
- package/scripts/setup/knowledgebase.test.js +0 -352
- package/scripts/setup/prepare.test.js +0 -718
- package/scripts/setup/skills-phase.test.js +0 -1274
- package/scripts/setup/sync-phase.test.js +0 -373
- package/scripts/setup/ui.test.js +0 -579
- package/scripts/setup/utils.test.js +0 -586
- package/scripts/sync-unit.test.js +0 -1881
- package/scripts/sync.test.js +0 -472
- package/scripts/validate-memory-schema.test.js +0 -328
- 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
|
-
})
|