simt-dev 0.3.1 → 0.5.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 (60) hide show
  1. package/CHANGELOG.md +157 -0
  2. package/README.md +271 -62
  3. package/package.json +6 -2
  4. package/rules/core.md +1 -1
  5. package/src/advice/advice.mjs +229 -0
  6. package/src/agenthooks/policy.mjs +10 -2
  7. package/src/agenthooks/prompt.mjs +81 -0
  8. package/src/agenthooks/render.mjs +138 -17
  9. package/src/argv.mjs +28 -1
  10. package/src/bench/grade.mjs +3 -0
  11. package/src/bench/run.mjs +88 -54
  12. package/src/bench/tasks.mjs +38 -0
  13. package/src/bench/variants.mjs +456 -0
  14. package/src/brief/assemble.mjs +368 -0
  15. package/src/brief/kind.mjs +134 -0
  16. package/src/brief/packet.mjs +200 -0
  17. package/src/commands/agenthook.mjs +84 -5
  18. package/src/commands/bench.mjs +72 -15
  19. package/src/commands/brief.mjs +78 -0
  20. package/src/commands/compare.mjs +10 -2
  21. package/src/commands/decide.mjs +116 -0
  22. package/src/commands/distill.mjs +36 -0
  23. package/src/commands/help.mjs +62 -3
  24. package/src/commands/index.mjs +29 -1
  25. package/src/commands/lessons.mjs +127 -0
  26. package/src/commands/local.mjs +97 -0
  27. package/src/commands/map.mjs +42 -7
  28. package/src/commands/repoindex.mjs +4 -1
  29. package/src/commands/review.mjs +65 -0
  30. package/src/commands/rules.mjs +17 -2
  31. package/src/commands/skills.mjs +5 -50
  32. package/src/commands/skillscompile.mjs +255 -0
  33. package/src/commands/sync.mjs +15 -0
  34. package/src/commands/tests.mjs +2 -49
  35. package/src/commands/why.mjs +23 -25
  36. package/src/compare/learn.mjs +680 -0
  37. package/src/decisions/index.mjs +116 -65
  38. package/src/distill/distill.mjs +246 -0
  39. package/src/distill/engine.mjs +187 -0
  40. package/src/federation/client.mjs +188 -0
  41. package/src/federation/config.mjs +134 -0
  42. package/src/federation/query.mjs +48 -0
  43. package/src/graph/compile.mjs +475 -0
  44. package/src/index/gate.mjs +1 -13
  45. package/src/index/govern.mjs +23 -11
  46. package/src/index/map.mjs +251 -17
  47. package/src/index/persist.mjs +74 -7
  48. package/src/index/store.mjs +222 -8
  49. package/src/kernel/variants.mjs +353 -0
  50. package/src/ledger/index.mjs +36 -1
  51. package/src/local/client.mjs +482 -0
  52. package/src/local/config.mjs +269 -0
  53. package/src/mcp/tools.mjs +138 -1
  54. package/src/memory/decide.mjs +462 -0
  55. package/src/memory/lessons.mjs +858 -0
  56. package/src/projection/lock.mjs +39 -7
  57. package/src/projection/rules.mjs +32 -8
  58. package/src/resolve/body.mjs +46 -0
  59. package/src/review/critic.mjs +509 -0
  60. package/src/toolwords.mjs +6 -0
package/src/index/map.mjs CHANGED
@@ -1,19 +1,32 @@
1
- // Ranked repo map (R22, ADR-091).
1
+ // Ranked repo map and hybrid retrieval (R22, R28, ADR-091).
2
2
  //
3
- // PageRank over import and call edges, personalised to the task. An edge is
4
- // followed in both directions: the file that imports a mentioned file is part of
5
- // the task, and so is the file it imports. One direction would hide one of them.
6
- // Iterations and the tie break are fixed so two runs of the same graph return
7
- // the same order. A random walk, or a tie broken by insertion order, would not.
3
+ // PageRank over import and call edges, personalised to the task, fused with
4
+ // FTS5 BM25 (`bm25()`) and resolver signals via reciprocal-rank fusion (RRF).
5
+ // Optional local vector embeddings (stored as SQLite blobs via R37) are scored
6
+ // by brute-force cosine similarity and stay off by default unless they raise
7
+ // gold-file recall@10 by at least 10 percentage points (+0.10).
8
8
 
9
9
  import { readdir, readFile } from 'node:fs/promises';
10
10
  import { join } from 'node:path';
11
11
 
12
- import { resolveVaultDir } from '../bench/mine.mjs';
13
- import { readEdges, readFiles, readSymbols } from './store.mjs';
12
+ import { isTestPath, resolveVaultDir } from '../bench/mine.mjs';
13
+ import { readLocalConfig } from '../local/config.mjs';
14
+ import { localEmbed } from '../local/client.mjs';
15
+ import {
16
+ decodeVectorBlob,
17
+ readEdges,
18
+ readEmbeddings,
19
+ readFiles,
20
+ readFts,
21
+ readSymbols,
22
+ scoreFtsBm25,
23
+ searchFtsBm25,
24
+ } from './store.mjs';
14
25
 
15
26
  export const ITERATIONS = 20;
16
27
  export const DAMPING = 0.85;
28
+ export const RRF_K = 40;
29
+ export const EMBED_RECALL_GAIN_BAR = 0.1;
17
30
  const TOP = 10;
18
31
  export const DEFAULT_BUDGET = 2048;
19
32
 
@@ -26,13 +39,106 @@ const STOP = new Set([
26
39
  ]);
27
40
 
28
41
  /**
29
- * Rank files for one task. No task text ranks the whole graph. A task ranks the
30
- * files it mentions and the files one import or call away from those.
42
+ * Reciprocal-rank fusion (RRF) over multiple ranked lists.
43
+ *
44
+ * Each list entry may be a path string or `{ path }`. Ties break by path ascending
45
+ * so fusion is strictly deterministic.
31
46
  *
32
- * @param {{files: {path: string}[], edges: {src: string, dst: string, kind: string, weight?: number}[], symbols: {path: string, name: string}[], task?: string}} input
47
+ * @param {{items: ({path: string}|string)[], weight?: number}[]} lists
48
+ * @param {number} [k]
33
49
  * @returns {{path: string, rank: number}[]}
34
50
  */
35
- export function rankFiles({ files, edges, symbols, task = '' }) {
51
+ export function rrfFuse(lists, k = RRF_K) {
52
+ const fused = new Map();
53
+ const bestScore = new Map();
54
+ for (const { items = [], weight = 1 } of lists) {
55
+ if (!Array.isArray(items) || weight <= 0) continue;
56
+ for (let i = 0; i < items.length; i += 1) {
57
+ const entry = items[i];
58
+ const path = typeof entry === 'string' ? entry : entry?.path;
59
+ if (!path) continue;
60
+ fused.set(path, (fused.get(path) ?? 0) + weight / (k + i + 1));
61
+ if (typeof entry === 'object' && typeof entry?.score === 'number') {
62
+ bestScore.set(path, Math.max(bestScore.get(path) ?? 0, entry.score));
63
+ }
64
+ }
65
+ }
66
+ return [...fused.entries()]
67
+ .map(([path, rank]) => ({ path, rank, score: bestScore.get(path) ?? 0 }))
68
+ .sort((a, b) => (a.rank !== b.rank ? (a.rank < b.rank ? 1 : -1) : cmp(a.path, b.path)));
69
+ }
70
+
71
+ /**
72
+ * Brute-force cosine similarity between two numeric / Float32Array vectors.
73
+ */
74
+ export function cosineSimilarity(a, b) {
75
+ if (!a || !b) return 0;
76
+ const len = Math.min(a.length ?? 0, b.length ?? 0);
77
+ if (len === 0) return 0;
78
+ let dot = 0;
79
+ let normA = 0;
80
+ let normB = 0;
81
+ for (let i = 0; i < len; i += 1) {
82
+ const va = Number(a[i]) || 0;
83
+ const vb = Number(b[i]) || 0;
84
+ dot += va * vb;
85
+ normA += va * va;
86
+ normB += vb * vb;
87
+ }
88
+ const denom = Math.sqrt(normA) * Math.sqrt(normB);
89
+ if (denom === 0) return 0;
90
+ return dot / denom;
91
+ }
92
+
93
+ /**
94
+ * Rank stored vector embeddings against a query vector by brute-force cosine similarity.
95
+ *
96
+ * @param {{path: string, vec: Float32Array|number[]|Buffer}[]} embeddings
97
+ * @param {Float32Array|number[]|Buffer} queryVector
98
+ * @returns {{path: string, score: number}[]}
99
+ */
100
+ export function rankByCosine(embeddings, queryVector) {
101
+ const qVec = queryVector instanceof Float32Array ? queryVector : decodeVectorBlob(queryVector);
102
+ if (!Array.isArray(embeddings) || embeddings.length === 0 || qVec.length === 0) return [];
103
+ const scored = [];
104
+ for (const row of embeddings) {
105
+ if (!row?.path) continue;
106
+ const vec = row.vec instanceof Float32Array ? row.vec : decodeVectorBlob(row.vec);
107
+ if (vec.length === 0) continue;
108
+ const sim = cosineSimilarity(vec, qVec);
109
+ if (sim > 0) scored.push({ path: row.path, score: sim });
110
+ }
111
+ return scored.sort((a, b) => (a.score !== b.score ? (a.score < b.score ? 1 : -1) : cmp(a.path, b.path)));
112
+ }
113
+
114
+ /**
115
+ * Rank files for one task. No task text ranks the whole graph. A task ranks the
116
+ * files it mentions and the files one import or call away from those, and when
117
+ * FTS / BM25 rows or optional vector embeddings are supplied, fuses BM25 +
118
+ * graph proximity + resolver signals via reciprocal-rank fusion (R28).
119
+ *
120
+ * @param {{
121
+ * files: {path: string}[],
122
+ * edges: {src: string, dst: string, kind: string, weight?: number}[],
123
+ * symbols: {path: string, name: string}[],
124
+ * fts?: {kind?: string, ref?: string, path?: string, body?: string}[],
125
+ * bm25?: {path: string, score?: number}[],
126
+ * embeddings?: {path: string, vec: Float32Array|number[]|Buffer}[],
127
+ * queryVector?: Float32Array|number[]|Buffer|null,
128
+ * task?: string
129
+ * }} input
130
+ * @returns {{path: string, rank: number, score: number}[]}
131
+ */
132
+ export function rankFiles({
133
+ files,
134
+ edges,
135
+ symbols,
136
+ fts = undefined,
137
+ bm25 = undefined,
138
+ embeddings = undefined,
139
+ queryVector = null,
140
+ task = '',
141
+ }) {
36
142
  const nodes = [...new Set(files.map((file) => file.path))].sort();
37
143
  const symbolsByPath = groupSymbols(symbols);
38
144
  const scores = scoreFiles(nodes, symbolsByPath, task);
@@ -43,9 +149,44 @@ export function rankFiles({ files, edges, symbols, task = '' }) {
43
149
  // A file the task mentions stays ahead of a file it does not. PageRank on
44
150
  // this graph otherwise returns the file every module imports, and the files
45
151
  // the task named never reach the top 10.
46
- return kept
152
+ const baseRanked = kept
47
153
  .map((path, i) => ({ path, rank: rank[i], score: scores.get(path) ?? 0 }))
48
154
  .sort(byRank);
155
+
156
+ if (String(task ?? '').trim().length === 0) return baseRanked;
157
+
158
+ const knownPaths = new Set(nodes);
159
+ const rawBm25 = Array.isArray(bm25)
160
+ ? bm25
161
+ : Array.isArray(fts) && fts.length > 0
162
+ ? scoreFtsBm25(fts, task)
163
+ : [];
164
+ const bm25Ranked = rawBm25
165
+ .filter((row) => knownPaths.has(row.path) && !isTestPath(row.path))
166
+ .slice(0, 30);
167
+
168
+ const embedRanked =
169
+ queryVector && Array.isArray(embeddings) && embeddings.length > 0
170
+ ? rankByCosine(embeddings, queryVector)
171
+ .filter((row) => knownPaths.has(row.path) && !isTestPath(row.path))
172
+ .slice(0, 30)
173
+ : [];
174
+
175
+ if (bm25Ranked.length === 0 && embedRanked.length === 0) {
176
+ return baseRanked;
177
+ }
178
+
179
+ const resolverRanked = baseRanked.filter((row) => (row.score ?? 0) > 0);
180
+ const graphRanked = [...baseRanked].sort((a, b) => (a.rank !== b.rank ? (a.rank < b.rank ? 1 : -1) : cmp(a.path, b.path)));
181
+ const lists = [
182
+ { items: resolverRanked, weight: 2.0 },
183
+ { items: graphRanked, weight: 1.0 },
184
+ { items: bm25Ranked, weight: 1.0 },
185
+ ];
186
+ if (embedRanked.length > 0) {
187
+ lists.push({ items: embedRanked, weight: 1.5 });
188
+ }
189
+ return rrfFuse(lists, RRF_K);
49
190
  }
50
191
 
51
192
  /**
@@ -101,13 +242,47 @@ export function goldPaths(patch) {
101
242
  return [...new Set(out)].sort();
102
243
  }
103
244
 
245
+ /**
246
+ * Embed a task query using the repository's configured `local.embed` endpoint.
247
+ *
248
+ * Returns `null` without opening a socket when `embed` is false, `local.embed`
249
+ * is unset, or a non-loopback URL is refused.
250
+ */
251
+ export async function embedTaskQuery(root, task, { embed = false, allowRemote = false, fetchImpl = undefined } = {}) {
252
+ if (!embed || String(task ?? '').trim().length === 0) return null;
253
+ const localCfg = await readLocalConfig(root, { allowRemote });
254
+ if (!localCfg.embed || localCfg.embed.blocked) return null;
255
+ const res = await localEmbed(localCfg.embed, {
256
+ input: String(task).slice(0, 4096),
257
+ allowRemote,
258
+ fetchImpl,
259
+ });
260
+ if (!res.ok || !Array.isArray(res.embeddings?.[0]) || res.embeddings[0].length === 0) {
261
+ return null;
262
+ }
263
+ return Float32Array.from(res.embeddings[0], (v) => Number(v) || 0);
264
+ }
265
+
104
266
  /**
105
267
  * Mean recall@10 over mined tasks whose gold patch is in the vault.
106
268
  * A missing vault is unmeasured, not a zero: the absence of a hidden patch is
107
269
  * not evidence the map missed the file (ADR-076).
108
270
  *
271
+ * When `embed: true` (or stored embeddings are provided), also evaluates recall@10
272
+ * with embeddings fused in and enforces the R28 acceptance gate: embeddings stay off
273
+ * by default unless they raise gold-file recall@10 by at least 10 percentage points
274
+ * (`EMBED_RECALL_GAIN_BAR = 0.1`).
275
+ *
109
276
  * @param {string} root
110
- * @param {{vaultRoot?: string, backend?: string}} [opts]
277
+ * @param {{
278
+ * vaultRoot?: string,
279
+ * backend?: string,
280
+ * embed?: boolean,
281
+ * allowRemote?: boolean,
282
+ * fetchImpl?: typeof fetch,
283
+ * embeddings?: {path: string, vec: Float32Array|number[]|Buffer}[],
284
+ * taskVectors?: Record<string, Float32Array|number[]>|Map<string, Float32Array|number[]>
285
+ * }} [opts]
111
286
  */
112
287
  export async function measureRecall(root, opts = {}) {
113
288
  const tasksDir = join(root, '.simt-dev', 'bench', 'tasks');
@@ -121,9 +296,19 @@ export async function measureRecall(root, opts = {}) {
121
296
  if (files.length === 0) {
122
297
  return { measured: false, reason: 'no index', tasks: 0, missing: 0, recall: null };
123
298
  }
124
- const [symbols, edges] = await Promise.all([readSymbols(root, opts), readEdges(root, opts)]);
299
+ const [symbols, edges, fts] = await Promise.all([
300
+ readSymbols(root, opts),
301
+ readEdges(root, opts),
302
+ readFts(root, opts),
303
+ ]);
304
+ const storedEmbeddings =
305
+ opts.embeddings ?? (opts.embed ? await readEmbeddings(root, opts) : []);
306
+ const checkEmbed = storedEmbeddings.length > 0 || opts.embed === true;
307
+
125
308
  const vault = opts.vaultRoot ?? resolveVaultDir(root);
126
309
  let sum = 0;
310
+ let embedSum = 0;
311
+ let embedEvaluated = 0;
127
312
  let tasks = 0;
128
313
  let missing = 0;
129
314
  for (const name of names) {
@@ -140,12 +325,61 @@ export async function measureRecall(root, opts = {}) {
140
325
  missing += 1;
141
326
  continue;
142
327
  }
143
- const ranked = rankFiles({ files, edges, symbols, task: task.prompt ?? '' });
328
+ const prompt = task.prompt ?? '';
329
+ const bm25 = await searchFtsBm25(root, prompt, { ...opts, fts });
330
+ const ranked = rankFiles({ files, edges, symbols, fts, bm25, task: prompt });
144
331
  sum += recallAt10(ranked, gold);
145
332
  tasks += 1;
333
+
334
+ if (checkEmbed && storedEmbeddings.length > 0) {
335
+ const providedVec =
336
+ opts.taskVectors instanceof Map
337
+ ? opts.taskVectors.get(task.id)
338
+ : opts.taskVectors?.[task.id];
339
+ const queryVector =
340
+ providedVec ??
341
+ (await embedTaskQuery(root, prompt, {
342
+ embed: opts.embed === true,
343
+ allowRemote: opts.allowRemote === true,
344
+ fetchImpl: opts.fetchImpl,
345
+ }));
346
+ if (queryVector) {
347
+ const embedRanked = rankFiles({
348
+ files,
349
+ edges,
350
+ symbols,
351
+ fts,
352
+ bm25,
353
+ embeddings: storedEmbeddings,
354
+ queryVector,
355
+ task: prompt,
356
+ });
357
+ embedSum += recallAt10(embedRanked, gold);
358
+ embedEvaluated += 1;
359
+ } else {
360
+ embedSum += recallAt10(ranked, gold);
361
+ }
362
+ }
146
363
  }
147
364
  if (tasks === 0) return { measured: false, reason: 'no gold patches', tasks: 0, missing, recall: null };
148
- return { measured: true, reason: '', tasks, missing, recall: sum / tasks };
365
+ const baseRecall = sum / tasks;
366
+ const embedRecall = checkEmbed && embedEvaluated > 0 ? embedSum / tasks : null;
367
+ const embedDelta = embedRecall === null ? null : embedRecall - baseRecall;
368
+ // R28 acceptance gate: embeddings stay off by default unless they raise
369
+ // gold-file recall@10 by at least 10 points (+0.10).
370
+ const embedAccepted = embedDelta !== null && embedDelta + 1e-9 >= EMBED_RECALL_GAIN_BAR;
371
+ return {
372
+ measured: true,
373
+ reason: '',
374
+ tasks,
375
+ missing,
376
+ recall: embedAccepted ? embedRecall : baseRecall,
377
+ baseRecall,
378
+ embedRecall,
379
+ embedDelta,
380
+ embedAccepted,
381
+ embeddingsDefault: 'off',
382
+ };
149
383
  }
150
384
 
151
385
  export function formatRecall(result) {
@@ -9,17 +9,42 @@ import { dirname, join } from 'node:path';
9
9
 
10
10
  export const SQLITE_FILE = '.simt-dev/index.sqlite';
11
11
  export const JSONL_FILE = '.simt-dev/index.jsonl';
12
- export const SCHEMA_V = '2';
12
+ export const SCHEMA_V = '3';
13
13
 
14
14
  const SCHEMA = `
15
15
  create table if not exists files(path text primary key, blob text, lang text, bytes int, pkg text);
16
16
  create table if not exists symbols(id integer primary key, path text, name text, kind text, line int, sig text, exported int);
17
17
  create table if not exists edges(src text, dst text, kind text, weight real);
18
18
  create virtual table if not exists fts using fts5(kind, ref, body);
19
+ create table if not exists embeddings(path text primary key, blob text, model text, dims int, vec blob);
19
20
  create table if not exists meta(key text primary key, value text);
20
21
  `;
21
22
 
22
- export async function writeSqlite(root, next, removed, symbols, edges, fts) {
23
+ export function encodeVectorBlob(vec) {
24
+ if (!Array.isArray(vec) && !(vec instanceof Float32Array)) return Buffer.alloc(0);
25
+ const f32 = vec instanceof Float32Array ? vec : Float32Array.from(vec, (v) => Number(v) || 0);
26
+ return Buffer.from(f32.buffer, f32.byteOffset, f32.byteLength);
27
+ }
28
+
29
+ export function decodeVectorBlob(blob) {
30
+ if (!blob) return new Float32Array(0);
31
+ if (blob instanceof Float32Array) return blob;
32
+ if (Array.isArray(blob)) return Float32Array.from(blob, (v) => Number(v) || 0);
33
+ const buf = Buffer.isBuffer(blob)
34
+ ? blob
35
+ : blob instanceof Uint8Array
36
+ ? Buffer.from(blob.buffer, blob.byteOffset, blob.byteLength)
37
+ : typeof blob === 'string'
38
+ ? Buffer.from(blob, 'base64')
39
+ : Buffer.alloc(0);
40
+ const count = Math.floor(buf.byteLength / 4);
41
+ if (count === 0) return new Float32Array(0);
42
+ const copy = new Uint8Array(count * 4);
43
+ copy.set(new Uint8Array(buf.buffer, buf.byteOffset, count * 4));
44
+ return new Float32Array(copy.buffer);
45
+ }
46
+
47
+ export async function writeSqlite(root, next, removed, symbols, edges, fts, embeddings = []) {
23
48
  const { DatabaseSync } = await import('node:sqlite');
24
49
  const file = join(root, SQLITE_FILE);
25
50
  await mkdir(dirname(file), { recursive: true });
@@ -32,25 +57,35 @@ export async function writeSqlite(root, next, removed, symbols, edges, fts) {
32
57
  const dropSymbols = db.prepare('delete from symbols where path = ?');
33
58
  const dropEdges = db.prepare('delete from edges where src = ? or dst = ?');
34
59
  const dropFts = db.prepare('delete from fts where ref = ?');
60
+ const dropEmbed = db.prepare('delete from embeddings where path = ?');
35
61
  const dropFile = db.prepare('delete from files where path = ?');
36
62
  const addSymbol = db.prepare('insert into symbols(path, name, kind, line, sig, exported) values (?, ?, ?, ?, ?, ?)');
37
63
  const addEdge = db.prepare('insert into edges(src, dst, kind, weight) values (?, ?, ?, ?)');
38
64
  const addFts = db.prepare('insert into fts(kind, ref, body) values (?, ?, ?)');
65
+ const upsertEmbed = db.prepare(`insert into embeddings(path, blob, model, dims, vec) values (?, ?, ?, ?, ?)
66
+ on conflict(path) do update set blob = excluded.blob, model = excluded.model, dims = excluded.dims, vec = excluded.vec`);
39
67
  for (const row of next) {
40
68
  upsert.run(row.path, row.blob, row.lang, row.bytes, row.pkg);
41
69
  dropSymbols.run(row.path);
42
70
  dropEdges.run(row.path, row.path);
43
71
  dropFts.run(row.path);
72
+ dropEmbed.run(row.path);
44
73
  }
45
74
  for (const path of removed) {
46
75
  dropFile.run(path);
47
76
  dropSymbols.run(path);
48
77
  dropEdges.run(path, path);
49
78
  dropFts.run(path);
79
+ dropEmbed.run(path);
50
80
  }
51
81
  for (const row of symbols) addSymbol.run(row.path, row.name, row.kind, row.line, row.sig, row.exported);
52
82
  for (const row of edges) addEdge.run(row.src, row.dst, row.kind, row.weight);
53
83
  for (const row of fts) addFts.run(row.kind, row.ref, row.body);
84
+ for (const row of embeddings) {
85
+ const vecBlob = Buffer.isBuffer(row.vec) || row.vec instanceof Uint8Array ? row.vec : encodeVectorBlob(row.vec);
86
+ const dims = Number.isInteger(row.dims) ? row.dims : Math.floor(vecBlob.byteLength / 4);
87
+ upsertEmbed.run(row.path, row.blob ?? '', row.model ?? '', dims, vecBlob);
88
+ }
54
89
  db.prepare('insert into meta(key, value) values (?, ?) on conflict(key) do update set value = excluded.value').run('schema', SCHEMA_V);
55
90
  db.exec('commit');
56
91
  } catch (error) {
@@ -61,7 +96,7 @@ export async function writeSqlite(root, next, removed, symbols, edges, fts) {
61
96
  }
62
97
  }
63
98
 
64
- export async function writeJsonl(root, previous, next, removed, symbols, edges, fts, force) {
99
+ export async function writeJsonl(root, previous, next, removed, symbols, edges, fts, force, embeddings = []) {
65
100
  const gone = new Set(removed);
66
101
  const replaced = new Map(next.map((row) => [row.path, row]));
67
102
  const drop = new Set([...replaced.keys(), ...gone]);
@@ -76,9 +111,14 @@ export async function writeJsonl(root, previous, next, removed, symbols, edges,
76
111
  const keptSymbols = force ? [] : (await readJsonl(root, 'symbols')).filter((row) => !drop.has(row.path));
77
112
  const keptEdges = force ? [] : (await readJsonl(root, 'edges')).filter((row) => !drop.has(row.src) && !drop.has(row.dst));
78
113
  const keptFts = force ? [] : (await readJsonl(root, 'fts')).filter((row) => !drop.has(row.ref));
114
+ const newEmbedPaths = new Set(embeddings.map((row) => row.path));
115
+ const keptEmbeddings = force
116
+ ? []
117
+ : (await readJsonl(root, 'embeddings')).filter((row) => !drop.has(row.path) && !newEmbedPaths.has(row.path));
79
118
  const allSymbols = [...keptSymbols, ...symbols].sort(bySymbol);
80
119
  const allEdges = [...keptEdges, ...edges].sort(byEdge);
81
120
  const allFts = [...keptFts, ...fts].sort((a, b) => cmp(a.ref, b.ref));
121
+ const allEmbeddings = [...keptEmbeddings, ...embeddings].sort(byPath);
82
122
  const file = join(root, JSONL_FILE);
83
123
  await mkdir(dirname(file), { recursive: true });
84
124
  const lines = [
@@ -87,9 +127,21 @@ export async function writeJsonl(root, previous, next, removed, symbols, edges,
87
127
  ...allSymbols.map((row) => JSON.stringify({ table: 'symbols', path: row.path, name: row.name, kind: row.kind, line: row.line, sig: row.sig, exported: row.exported })),
88
128
  ...allEdges.map((row) => JSON.stringify({ table: 'edges', src: row.src, dst: row.dst, kind: row.kind, weight: row.weight })),
89
129
  ...allFts.map((row) => JSON.stringify({ table: 'fts', kind: row.kind, ref: row.ref, body: row.body })),
130
+ ...allEmbeddings.map((row) => {
131
+ const vecBlob = Buffer.isBuffer(row.vec) || row.vec instanceof Uint8Array ? Buffer.from(row.vec.buffer, row.vec.byteOffset, row.vec.byteLength) : encodeVectorBlob(row.vec);
132
+ const dims = Number.isInteger(row.dims) ? row.dims : Math.floor(vecBlob.byteLength / 4);
133
+ return JSON.stringify({
134
+ table: 'embeddings',
135
+ path: row.path,
136
+ blob: row.blob ?? '',
137
+ model: row.model ?? '',
138
+ dims,
139
+ vec: vecBlob.toString('base64'),
140
+ });
141
+ }),
90
142
  ];
91
143
  await writeFile(file, `${lines.join('\n')}\n`);
92
- return { symbols: allSymbols.length, edges: allEdges.length };
144
+ return { symbols: allSymbols.length, edges: allEdges.length, embeddings: allEmbeddings.length };
93
145
  }
94
146
 
95
147
  export async function readJsonl(root, table) {
@@ -109,6 +161,16 @@ export async function readJsonl(root, table) {
109
161
  else if (table === 'symbols') rows.push({ path: row.path, name: row.name, kind: row.kind, line: row.line, sig: row.sig, exported: row.exported });
110
162
  else if (table === 'edges') rows.push({ src: row.src, dst: row.dst, kind: row.kind, weight: row.weight });
111
163
  else if (table === 'fts') rows.push({ kind: row.kind, ref: row.ref, body: row.body });
164
+ else if (table === 'embeddings') {
165
+ const vec = decodeVectorBlob(row.vec);
166
+ rows.push({
167
+ path: row.path,
168
+ blob: row.blob ?? '',
169
+ model: row.model ?? '',
170
+ dims: Number.isInteger(row.dims) ? row.dims : vec.length,
171
+ vec,
172
+ });
173
+ }
112
174
  }
113
175
  if (table === 'symbols') rows.sort(bySymbol);
114
176
  else if (table === 'edges') rows.sort(byEdge);
@@ -117,7 +179,7 @@ export async function readJsonl(root, table) {
117
179
  return rows;
118
180
  }
119
181
 
120
- export async function readSqliteRows(root, sql) {
182
+ export async function readSqliteRows(root, sql, params = []) {
121
183
  let DatabaseSync;
122
184
  try {
123
185
  ({ DatabaseSync } = await import('node:sqlite'));
@@ -132,7 +194,7 @@ export async function readSqliteRows(root, sql) {
132
194
  }
133
195
  const db = new DatabaseSync(file);
134
196
  try {
135
- return db.prepare(sql).all();
197
+ return db.prepare(sql).all(...params);
136
198
  } catch {
137
199
  return [];
138
200
  } finally {
@@ -152,7 +214,12 @@ export async function readSchema(root, backend) {
152
214
  export async function countSqlite(root) {
153
215
  const symbols = await readSqliteRows(root, 'select count(*) as n from symbols');
154
216
  const edges = await readSqliteRows(root, 'select count(*) as n from edges');
155
- return { symbols: Number(symbols[0]?.n ?? 0), edges: Number(edges[0]?.n ?? 0) };
217
+ const embeddings = await readSqliteRows(root, 'select count(*) as n from embeddings');
218
+ return {
219
+ symbols: Number(symbols[0]?.n ?? 0),
220
+ edges: Number(edges[0]?.n ?? 0),
221
+ embeddings: Number(embeddings[0]?.n ?? 0),
222
+ };
156
223
  }
157
224
 
158
225
  function byPath(a, b) {