@dougharness/cli 1.0.1

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 (144) hide show
  1. package/LICENSE +21 -0
  2. package/dist/ablate.d.ts +96 -0
  3. package/dist/ablate.js +440 -0
  4. package/dist/ablate.js.map +1 -0
  5. package/dist/apply.d.ts +2 -0
  6. package/dist/apply.js +19 -0
  7. package/dist/apply.js.map +1 -0
  8. package/dist/bin.d.ts +2 -0
  9. package/dist/bin.js +80 -0
  10. package/dist/bin.js.map +1 -0
  11. package/dist/board-page.d.ts +15 -0
  12. package/dist/board-page.js +27 -0
  13. package/dist/board-page.js.map +1 -0
  14. package/dist/board-serve-process.d.ts +24 -0
  15. package/dist/board-serve-process.js +227 -0
  16. package/dist/board-serve-process.js.map +1 -0
  17. package/dist/board-serve.d.ts +9 -0
  18. package/dist/board-serve.js +302 -0
  19. package/dist/board-serve.js.map +1 -0
  20. package/dist/board.d.ts +6 -0
  21. package/dist/board.js +515 -0
  22. package/dist/board.js.map +1 -0
  23. package/dist/detect/fs.d.ts +6 -0
  24. package/dist/detect/fs.js +50 -0
  25. package/dist/detect/fs.js.map +1 -0
  26. package/dist/detect/index.d.ts +3 -0
  27. package/dist/detect/index.js +13 -0
  28. package/dist/detect/index.js.map +1 -0
  29. package/dist/detect/node.d.ts +3 -0
  30. package/dist/detect/node.js +179 -0
  31. package/dist/detect/node.js.map +1 -0
  32. package/dist/detect/python.d.ts +2 -0
  33. package/dist/detect/python.js +388 -0
  34. package/dist/detect/python.js.map +1 -0
  35. package/dist/detect/repo.d.ts +2 -0
  36. package/dist/detect/repo.js +102 -0
  37. package/dist/detect/repo.js.map +1 -0
  38. package/dist/detect/types.d.ts +98 -0
  39. package/dist/detect/types.js +2 -0
  40. package/dist/detect/types.js.map +1 -0
  41. package/dist/diff.d.ts +12 -0
  42. package/dist/diff.js +105 -0
  43. package/dist/diff.js.map +1 -0
  44. package/dist/generate/agents.d.ts +19 -0
  45. package/dist/generate/agents.js +184 -0
  46. package/dist/generate/agents.js.map +1 -0
  47. package/dist/generate/claude-md.d.ts +9 -0
  48. package/dist/generate/claude-md.js +111 -0
  49. package/dist/generate/claude-md.js.map +1 -0
  50. package/dist/generate/config.d.ts +51 -0
  51. package/dist/generate/config.js +155 -0
  52. package/dist/generate/config.js.map +1 -0
  53. package/dist/generate/proposal.d.ts +11 -0
  54. package/dist/generate/proposal.js +175 -0
  55. package/dist/generate/proposal.js.map +1 -0
  56. package/dist/generate/settings.d.ts +35 -0
  57. package/dist/generate/settings.js +213 -0
  58. package/dist/generate/settings.js.map +1 -0
  59. package/dist/generate/skills.d.ts +8 -0
  60. package/dist/generate/skills.js +264 -0
  61. package/dist/generate/skills.js.map +1 -0
  62. package/dist/init.d.ts +10 -0
  63. package/dist/init.js +69 -0
  64. package/dist/init.js.map +1 -0
  65. package/dist/trace.d.ts +9 -0
  66. package/dist/trace.js +106 -0
  67. package/dist/trace.js.map +1 -0
  68. package/node_modules/@dougharness/flow/.claude-plugin/plugin.json +10 -0
  69. package/node_modules/@dougharness/flow/LICENSE +21 -0
  70. package/node_modules/@dougharness/flow/agents/adversary-claude.md +33 -0
  71. package/node_modules/@dougharness/flow/agents/adversary.md +29 -0
  72. package/node_modules/@dougharness/flow/agents/implementer.md +23 -0
  73. package/node_modules/@dougharness/flow/agents/lead.md +35 -0
  74. package/node_modules/@dougharness/flow/agents/planner.md +70 -0
  75. package/node_modules/@dougharness/flow/agents/researcher.md +31 -0
  76. package/node_modules/@dougharness/flow/agents/reviewer.md +26 -0
  77. package/node_modules/@dougharness/flow/agents/verifier.md +24 -0
  78. package/node_modules/@dougharness/flow/lib/board.mjs +840 -0
  79. package/node_modules/@dougharness/flow/lib/code-index.mjs +629 -0
  80. package/node_modules/@dougharness/flow/lib/cost.mjs +392 -0
  81. package/node_modules/@dougharness/flow/lib/decisions.mjs +314 -0
  82. package/node_modules/@dougharness/flow/lib/embeddings.mjs +539 -0
  83. package/node_modules/@dougharness/flow/lib/land.mjs +219 -0
  84. package/node_modules/@dougharness/flow/lib/learn.mjs +742 -0
  85. package/node_modules/@dougharness/flow/lib/memory-import.mjs +145 -0
  86. package/node_modules/@dougharness/flow/lib/memory.mjs +1641 -0
  87. package/node_modules/@dougharness/flow/lib/models.mjs +148 -0
  88. package/node_modules/@dougharness/flow/lib/plan.mjs +773 -0
  89. package/node_modules/@dougharness/flow/lib/rehearse.mjs +825 -0
  90. package/node_modules/@dougharness/flow/lib/worktrees.mjs +215 -0
  91. package/node_modules/@dougharness/flow/package.json +19 -0
  92. package/node_modules/@dougharness/flow/scripts/board.mjs +260 -0
  93. package/node_modules/@dougharness/flow/scripts/cost.mjs +66 -0
  94. package/node_modules/@dougharness/flow/scripts/learn.mjs +161 -0
  95. package/node_modules/@dougharness/flow/scripts/memory.mjs +1274 -0
  96. package/node_modules/@dougharness/flow/scripts/plan.mjs +547 -0
  97. package/node_modules/@dougharness/flow/scripts/rehearse.mjs +129 -0
  98. package/node_modules/@dougharness/flow/skills/core-next/SKILL.md +92 -0
  99. package/node_modules/@dougharness/flow/skills/doug-approve/SKILL.md +15 -0
  100. package/node_modules/@dougharness/flow/skills/doug-decide/SKILL.md +26 -0
  101. package/node_modules/@dougharness/flow/skills/doug-implement/SKILL.md +22 -0
  102. package/node_modules/@dougharness/flow/skills/doug-learn/SKILL.md +20 -0
  103. package/node_modules/@dougharness/flow/skills/doug-next/SKILL.md +87 -0
  104. package/node_modules/@dougharness/flow/skills/doug-plan/SKILL.md +22 -0
  105. package/node_modules/@dougharness/flow/skills/doug-swarm/SKILL.md +55 -0
  106. package/node_modules/@dougharness/flow/skills/harness-fix/SKILL.md +85 -0
  107. package/node_modules/@dougharness/flow/skills/research/SKILL.md +41 -0
  108. package/node_modules/@dougharness/flow/skills/run-report/SKILL.md +51 -0
  109. package/node_modules/@dougharness/flow/skills/swarm-launch/SKILL.md +27 -0
  110. package/node_modules/@dougharness/flow/workflows/doug-implement.js +2687 -0
  111. package/node_modules/@dougharness/gates/.claude-plugin/plugin.json +10 -0
  112. package/node_modules/@dougharness/gates/LICENSE +21 -0
  113. package/node_modules/@dougharness/gates/hooks/hooks.json +184 -0
  114. package/node_modules/@dougharness/gates/lib/agent-context.mjs +111 -0
  115. package/node_modules/@dougharness/gates/lib/anchor.mjs +110 -0
  116. package/node_modules/@dougharness/gates/lib/baseline.mjs +160 -0
  117. package/node_modules/@dougharness/gates/lib/bash-rules.mjs +1522 -0
  118. package/node_modules/@dougharness/gates/lib/block-ledger.mjs +127 -0
  119. package/node_modules/@dougharness/gates/lib/checkpoint.mjs +97 -0
  120. package/node_modules/@dougharness/gates/lib/config.mjs +168 -0
  121. package/node_modules/@dougharness/gates/lib/evidence.mjs +59 -0
  122. package/node_modules/@dougharness/gates/lib/glob.mjs +86 -0
  123. package/node_modules/@dougharness/gates/lib/io.mjs +91 -0
  124. package/node_modules/@dougharness/gates/lib/paths.mjs +101 -0
  125. package/node_modules/@dougharness/gates/lib/proposals.mjs +201 -0
  126. package/node_modules/@dougharness/gates/lib/run.mjs +147 -0
  127. package/node_modules/@dougharness/gates/lib/scope.mjs +117 -0
  128. package/node_modules/@dougharness/gates/lib/secret-rules.mjs +268 -0
  129. package/node_modules/@dougharness/gates/lib/state.mjs +114 -0
  130. package/node_modules/@dougharness/gates/lib/trace.mjs +196 -0
  131. package/node_modules/@dougharness/gates/package.json +17 -0
  132. package/node_modules/@dougharness/gates/scripts/edit-loop.mjs +39 -0
  133. package/node_modules/@dougharness/gates/scripts/format-on-edit.mjs +45 -0
  134. package/node_modules/@dougharness/gates/scripts/guard-bash.mjs +32 -0
  135. package/node_modules/@dougharness/gates/scripts/protect-paths.mjs +49 -0
  136. package/node_modules/@dougharness/gates/scripts/reanchor.mjs +81 -0
  137. package/node_modules/@dougharness/gates/scripts/research-cap.mjs +295 -0
  138. package/node_modules/@dougharness/gates/scripts/secret-scan.mjs +26 -0
  139. package/node_modules/@dougharness/gates/scripts/session-start-baseline.mjs +57 -0
  140. package/node_modules/@dougharness/gates/scripts/statusline.mjs +133 -0
  141. package/node_modules/@dougharness/gates/scripts/stop-gate.mjs +1422 -0
  142. package/node_modules/@dougharness/gates/scripts/trace.mjs +75 -0
  143. package/package.json +33 -0
  144. package/templates/board-app.js +533 -0
@@ -0,0 +1,629 @@
1
+ // The opt-in semantic code index (card semantic-index): a cache of tracked-file chunks in the same node:sqlite
2
+ // store lib/memory.mjs opens (schema v6: code_files, code_chunks, code_chunks_fts, code_index_builds). Keyword-
3
+ // only over FTS5 with nothing installed, hybrid with an embedding provider configured — same product-first shape
4
+ // as lib/memory.mjs's lessons store. This module never opens the database itself; every export takes the `m`
5
+ // handle lib/memory.mjs's openMemory() returns.
6
+ //
7
+ // Reuse, not a second matcher: the path-glob dialect (`**`, `*`, `?`) is lib/memory.mjs's own (isGlobPattern,
8
+ // globToRegExp), exported from there for exactly this reuse — both modules live in this plugin's lib/, so there
9
+ // is no cross-plugin boundary excusing a duplicate the way learn.mjs's local copy of doug-gates/lib/glob.mjs is.
10
+ import { readFileSync, existsSync, statSync, openSync, readSync, closeSync } from "node:fs";
11
+ import { join } from "node:path";
12
+ import { createHash } from "node:crypto";
13
+ import { spawnSync } from "node:child_process";
14
+ import { isGlobPattern, globToRegExp, ftsMatchQuery } from "./memory.mjs";
15
+ import { checkEmbeddingSanity } from "./embeddings.mjs";
16
+
17
+ const MAX_INDEXABLE_FILE_BYTES = 256 * 1024;
18
+ const BINARY_SNIFF_BYTES = 8192;
19
+ const CHUNK_CHAR_CAP = 2000;
20
+ const LOCKFILE_NAMES = new Set(["pnpm-lock.yaml", "package-lock.json", "yarn.lock"]);
21
+ const SKIPPED_DIR_NAMES = new Set(["node_modules", "dist", ".git"]);
22
+
23
+ function sha256Hex(data) {
24
+ return createHash("sha256").update(data).digest("hex");
25
+ }
26
+
27
+ function matchesAnyGlob(patterns, value) {
28
+ for (const p of patterns) {
29
+ if (isGlobPattern(p) ? globToRegExp(p).test(value) : p === value) return true;
30
+ }
31
+ return false;
32
+ }
33
+
34
+ function isLockfilePath(path) {
35
+ return LOCKFILE_NAMES.has(path.split("/").pop());
36
+ }
37
+
38
+ function isUnderSkippedDir(path) {
39
+ return path.split("/").some((seg) => SKIPPED_DIR_NAMES.has(seg));
40
+ }
41
+
42
+ function isMinFile(path) {
43
+ return path.split("/").pop().includes(".min.");
44
+ }
45
+
46
+ // Binary iff the file's first 8KB contains a NUL byte. Any read failure (permissions, a symlink race) is
47
+ // treated as "not binary" here — listIndexableFiles' own statSync/readFileSync calls are what actually decide
48
+ // whether the file is usable, this is only the content sniff.
49
+ function looksBinary(absPath) {
50
+ let fd;
51
+ try {
52
+ fd = openSync(absPath, "r");
53
+ const buf = Buffer.alloc(BINARY_SNIFF_BYTES);
54
+ const bytesRead = readSync(fd, buf, 0, BINARY_SNIFF_BYTES, 0);
55
+ for (let i = 0; i < bytesRead; i++) {
56
+ if (buf[i] === 0) return true;
57
+ }
58
+ return false;
59
+ } catch {
60
+ return false;
61
+ } finally {
62
+ if (fd !== undefined) {
63
+ try {
64
+ closeSync(fd);
65
+ } catch {
66
+ // already closed
67
+ }
68
+ }
69
+ }
70
+ }
71
+
72
+ function gitHeadSha(dir) {
73
+ const res = spawnSync("git", ["rev-parse", "HEAD"], { cwd: dir, encoding: "utf8" });
74
+ if (res.status !== 0) return null;
75
+ const sha = res.stdout.trim();
76
+ return sha || null;
77
+ }
78
+
79
+ // Tracked files from `git ls-files -z`, filtered by the default policy (skip oversize, binary, lockfiles,
80
+ // node_modules/dist/.git, *.min.*) and then by cfg.include/cfg.exclude globs. A non-git dir or a git failure
81
+ // throws a clear error naming the directory and git's own stderr; the CLI turns that into exit 1 with the
82
+ // message (docs/memory.md documents this the same way lib/memory.mjs's own thrown errors surface).
83
+ export function listIndexableFiles(dir, cfg = {}) {
84
+ const include = Array.isArray(cfg.include) ? cfg.include : [];
85
+ const exclude = Array.isArray(cfg.exclude) ? cfg.exclude : [];
86
+ const res = spawnSync("git", ["ls-files", "-z"], { cwd: dir, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
87
+ if (res.error) throw new Error(`git ls-files failed in ${dir}: ${res.error.message}`);
88
+ if (res.status !== 0) {
89
+ throw new Error(`git ls-files failed in ${dir} (exit ${res.status}): ${(res.stderr || "").trim() || "not a git repository"}`);
90
+ }
91
+ const tracked = (res.stdout || "").split("\0").filter(Boolean);
92
+ const out = [];
93
+ for (const path of tracked) {
94
+ if (isLockfilePath(path)) continue;
95
+ if (isUnderSkippedDir(path)) continue;
96
+ if (isMinFile(path)) continue;
97
+ if (include.length && !matchesAnyGlob(include, path)) continue;
98
+ if (exclude.length && matchesAnyGlob(exclude, path)) continue;
99
+ const abs = join(dir, path);
100
+ let stat;
101
+ try {
102
+ stat = statSync(abs);
103
+ } catch {
104
+ continue; // tracked by git but missing on disk (rare race, e.g. a case-only rename) - skip quietly
105
+ }
106
+ if (!stat.isFile()) continue;
107
+ if (stat.size > MAX_INDEXABLE_FILE_BYTES) continue;
108
+ if (looksBinary(abs)) continue;
109
+ out.push(path);
110
+ }
111
+ out.sort();
112
+ return out;
113
+ }
114
+
115
+ // Deterministic, language-agnostic chunking: blank-line-separated blocks, merged forward while the merged span
116
+ // stays within chunkLines lines and CHUNK_CHAR_CAP characters, a single block already over that split by lines.
117
+ // Line numbers are 1-based and inclusive, and map back to the exact source lines (including any blank lines a
118
+ // merged chunk's text spans internally). Whitespace-only input yields no chunks.
119
+ export function chunkText(text, { chunkLines = 60 } = {}) {
120
+ const lines = String(text ?? "").split(/\r?\n/);
121
+ // A trailing "" from a final newline is not a real line; an input with no trailing newline keeps its last line.
122
+ if (lines.length > 1 && lines[lines.length - 1] === "") lines.pop();
123
+
124
+ const blocks = [];
125
+ let start = null;
126
+ for (let i = 0; i < lines.length; i++) {
127
+ if (lines[i].trim() === "") {
128
+ if (start !== null) {
129
+ blocks.push([start, i]); // i is 0-based here, i.e. the 1-based line number of the previous line
130
+ start = null;
131
+ }
132
+ } else if (start === null) {
133
+ start = i + 1;
134
+ }
135
+ }
136
+ if (start !== null) blocks.push([start, lines.length]);
137
+
138
+ const sliceText = (s, e) => lines.slice(s - 1, e).join("\n");
139
+
140
+ function splitOversizeBlock(s, e) {
141
+ const spans = [];
142
+ let gs = s;
143
+ while (gs <= e) {
144
+ let ge = Math.min(gs + chunkLines - 1, e);
145
+ let candidate = sliceText(gs, ge);
146
+ while (candidate.length > CHUNK_CHAR_CAP && ge > gs) {
147
+ ge--;
148
+ candidate = sliceText(gs, ge);
149
+ }
150
+ spans.push([gs, ge]);
151
+ gs = ge + 1;
152
+ }
153
+ return spans;
154
+ }
155
+
156
+ const spans = [];
157
+ for (const [s, e] of blocks) {
158
+ const lineCount = e - s + 1;
159
+ const text2 = sliceText(s, e);
160
+ if (lineCount <= chunkLines && text2.length <= CHUNK_CHAR_CAP) spans.push([s, e]);
161
+ else spans.push(...splitOversizeBlock(s, e));
162
+ }
163
+
164
+ const merged = [];
165
+ for (const [s, e] of spans) {
166
+ const last = merged[merged.length - 1];
167
+ if (last) {
168
+ const combinedLines = e - last[0] + 1;
169
+ const combinedText = sliceText(last[0], e);
170
+ if (combinedLines <= chunkLines && combinedText.length <= CHUNK_CHAR_CAP) {
171
+ last[1] = e;
172
+ continue;
173
+ }
174
+ }
175
+ merged.push([s, e]);
176
+ }
177
+
178
+ return merged
179
+ .map(([s, e]) => ({ startLine: s, endLine: e, text: sliceText(s, e) }))
180
+ .filter((c) => c.text.trim() !== "");
181
+ }
182
+
183
+ // `n` is the 0-based occurrence index of this content hash within the file (0 for the first). Without it, two
184
+ // identical blocks in the same file (e.g. repeated boilerplate) collide on one id and collapse to one row while
185
+ // code_files.chunks still counts both (review MINOR 4) - carrying `n` gives each occurrence its own id, so a
186
+ // truly unmoved chunk still keeps its id and embedding across rebuilds and a genuine duplicate still gets its
187
+ // own row.
188
+ function chunkId(path, contentHash, n = 0) {
189
+ return sha256Hex(`${path}\n${contentHash}\n${n}`).slice(0, 16);
190
+ }
191
+
192
+ // Assigns each chunk its id from its content hash and its occurrence index among chunks sharing that hash within
193
+ // this one file's chunk list, in order - shared by buildIndex and indexStatus so both derive the exact same ids.
194
+ function idsForChunks(path, chunks) {
195
+ const counts = new Map();
196
+ return chunks.map((c) => {
197
+ const contentHash = sha256Hex(c.text);
198
+ const n = counts.get(contentHash) || 0;
199
+ counts.set(contentHash, n + 1);
200
+ return { id: chunkId(path, contentHash, n), contentHash, ...c };
201
+ });
202
+ }
203
+
204
+ // True when the code index holds at least one chunk but none of them are embedded under provider.model/dims -
205
+ // a model or dims change, including the very first embed the code index has ever run. Mirrors
206
+ // scripts/memory.mjs's embeddingChangeDetected for lessons, over code_chunks instead.
207
+ function codeIndexEmbeddingChangeDetected(m, provider) {
208
+ const total = m.prepare("SELECT COUNT(*) AS n FROM code_chunks").get().n;
209
+ if (total === 0) return false;
210
+ const matching = m
211
+ .prepare("SELECT COUNT(*) AS n FROM code_chunks WHERE embedding_model = ? AND embedding_dims = ?")
212
+ .get(provider.model, provider.dims).n;
213
+ return matching === 0;
214
+ }
215
+
216
+ function vectorToBlob(vector) {
217
+ return Buffer.from(vector.buffer, vector.byteOffset, vector.byteLength);
218
+ }
219
+
220
+ function blobToVector(blob) {
221
+ if (!blob) return null;
222
+ const bytes = blob instanceof Uint8Array ? blob : new Uint8Array(blob);
223
+ const copy = new Uint8Array(bytes.length);
224
+ copy.set(bytes);
225
+ return new Float32Array(copy.buffer);
226
+ }
227
+
228
+ // Embeds every code_chunks row not yet embedded under provider.model/dims, batch texts at a time, exactly the
229
+ // way lib/memory.mjs's embedLessons embeds lesson rows: a batch the provider fails is counted in `failed` and
230
+ // left unembedded (never thrown), the next batch still runs.
231
+ async function embedCodeChunks(m, provider, { batch = 32, onProgress } = {}) {
232
+ const todo = m
233
+ .prepare(
234
+ `SELECT id, text FROM code_chunks WHERE embedding IS NULL OR embedding_model IS NOT ? OR embedding_dims IS NOT ?
235
+ ORDER BY id ASC`
236
+ )
237
+ .all(provider.model, provider.dims);
238
+ let embedded = 0;
239
+ let failed = 0;
240
+ let reason = null;
241
+ for (let i = 0; i < todo.length; i += batch) {
242
+ const slice = todo.slice(i, i + batch);
243
+ const result = await provider.embed(
244
+ slice.map((c) => c.text),
245
+ { inputType: "document" }
246
+ );
247
+ if (!result.ok) {
248
+ failed += slice.length;
249
+ reason = result.reason;
250
+ } else {
251
+ for (let j = 0; j < slice.length; j++) {
252
+ m.prepare("UPDATE code_chunks SET embedding_model = ?, embedding_dims = ?, embedding = ? WHERE id = ?").run(
253
+ provider.model,
254
+ provider.dims,
255
+ vectorToBlob(result.vectors[j]),
256
+ slice[j].id
257
+ );
258
+ embedded++;
259
+ }
260
+ }
261
+ if (onProgress) onProgress({ embedded, total: todo.length, failed, reason });
262
+ }
263
+ return { total: todo.length, embedded, failed, reason };
264
+ }
265
+
266
+ function toIsoTimestamp(now) {
267
+ const d = now instanceof Date ? now : new Date(now ?? Date.now());
268
+ return d.toISOString();
269
+ }
270
+
271
+ // Incrementally (re)builds the code index: hashes every indexable file, re-chunks and re-embeds only what
272
+ // changed, drops rows for files no longer indexable, then (with a provider) embeds pending chunks in batches -
273
+ // running the embed-check cosine sanity check first whenever this is the first time the index has ever used
274
+ // provider.model/dims (see codeIndexEmbeddingChangeDetected), the same gate scripts/memory.mjs's reembed applies
275
+ // to lessons. A failed check, or no provider at all, still lets the build complete keyword-only (never a thrown
276
+ // error for a provider or check problem) - only a bad `dir` (listIndexableFiles) or a database problem throws.
277
+ export async function buildIndex(m, dir, opts = {}) {
278
+ const { cfg = {}, provider = null, batch = 32, check = true, pairs = null, onProgress, now } = opts;
279
+ const startedAt = toIsoTimestamp(now);
280
+ const chunkLines = cfg.chunkLines || 60;
281
+
282
+ const files = listIndexableFiles(dir, cfg);
283
+ const fileSet = new Set(files);
284
+
285
+ let filesChanged = 0;
286
+ for (const path of files) {
287
+ let bytes;
288
+ try {
289
+ bytes = readFileSync(join(dir, path));
290
+ } catch {
291
+ continue; // disappeared between listing and reading; the next build will see it as removed
292
+ }
293
+ const hash = sha256Hex(bytes);
294
+ const existingFile = m.prepare("SELECT hash FROM code_files WHERE path = ?").get(path);
295
+ if (existingFile && existingFile.hash === hash) continue;
296
+
297
+ filesChanged++;
298
+ const text = bytes.toString("utf8");
299
+ const chunks = chunkText(text, { chunkLines });
300
+ const newRows = idsForChunks(path, chunks);
301
+ const newIds = new Set(newRows.map((r) => r.id));
302
+ const existingIds = m
303
+ .prepare("SELECT id FROM code_chunks WHERE path = ?")
304
+ .all(path)
305
+ .map((r) => r.id);
306
+ const indexedAt = toIsoTimestamp(now);
307
+
308
+ m.exec("BEGIN");
309
+ try {
310
+ for (const id of existingIds) {
311
+ if (newIds.has(id)) continue;
312
+ m.prepare("DELETE FROM code_chunks WHERE id = ?").run(id);
313
+ m.prepare("DELETE FROM code_chunks_fts WHERE id = ?").run(id);
314
+ }
315
+ for (const row of newRows) {
316
+ const already = m.prepare("SELECT id FROM code_chunks WHERE id = ?").get(row.id);
317
+ if (already) {
318
+ m.prepare("UPDATE code_chunks SET start_line = ?, end_line = ?, indexed = ? WHERE id = ?").run(
319
+ row.startLine,
320
+ row.endLine,
321
+ indexedAt,
322
+ row.id
323
+ );
324
+ } else {
325
+ m.prepare(
326
+ `INSERT INTO code_chunks (id, path, start_line, end_line, content_hash, text, indexed)
327
+ VALUES (?, ?, ?, ?, ?, ?, ?)`
328
+ ).run(row.id, path, row.startLine, row.endLine, row.contentHash, row.text, indexedAt);
329
+ m.prepare("INSERT INTO code_chunks_fts (id, path, text) VALUES (?, ?, ?)").run(row.id, path, row.text);
330
+ }
331
+ }
332
+ m.prepare(
333
+ `INSERT INTO code_files (path, hash, bytes, chunks, indexed) VALUES (?, ?, ?, ?, ?)
334
+ ON CONFLICT(path) DO UPDATE SET hash = excluded.hash, bytes = excluded.bytes, chunks = excluded.chunks, indexed = excluded.indexed`
335
+ ).run(path, hash, bytes.byteLength, newRows.length, indexedAt);
336
+ m.exec("COMMIT");
337
+ } catch (err) {
338
+ m.exec("ROLLBACK");
339
+ throw err;
340
+ }
341
+ }
342
+
343
+ let filesRemoved = 0;
344
+ const storedPaths = m
345
+ .prepare("SELECT path FROM code_files")
346
+ .all()
347
+ .map((r) => r.path);
348
+ for (const path of storedPaths) {
349
+ if (fileSet.has(path)) continue;
350
+ filesRemoved++;
351
+ const ids = m
352
+ .prepare("SELECT id FROM code_chunks WHERE path = ?")
353
+ .all(path)
354
+ .map((r) => r.id);
355
+ m.exec("BEGIN");
356
+ try {
357
+ for (const id of ids) {
358
+ m.prepare("DELETE FROM code_chunks WHERE id = ?").run(id);
359
+ m.prepare("DELETE FROM code_chunks_fts WHERE id = ?").run(id);
360
+ }
361
+ m.prepare("DELETE FROM code_files WHERE path = ?").run(path);
362
+ m.exec("COMMIT");
363
+ } catch (err) {
364
+ m.exec("ROLLBACK");
365
+ throw err;
366
+ }
367
+ }
368
+
369
+ const totalFiles = m.prepare("SELECT COUNT(*) AS n FROM code_files").get().n;
370
+ const totalChunks = m.prepare("SELECT COUNT(*) AS n FROM code_chunks").get().n;
371
+
372
+ let chunksEmbedded = 0;
373
+ let chunksFailed = 0;
374
+ let embedSeconds = null;
375
+ let checkStatus = "not-needed";
376
+ let reason = null;
377
+
378
+ if (!provider) {
379
+ reason = "no provider configured";
380
+ } else {
381
+ const pendingCount = m
382
+ .prepare("SELECT COUNT(*) AS n FROM code_chunks WHERE embedding IS NULL OR embedding_model IS NOT ? OR embedding_dims IS NOT ?")
383
+ .get(provider.model, provider.dims).n;
384
+
385
+ if (pendingCount === 0) {
386
+ checkStatus = "not-needed";
387
+ } else if (check === false) {
388
+ checkStatus = "skipped";
389
+ } else if (check === "force" || codeIndexEmbeddingChangeDetected(m, provider)) {
390
+ if (!pairs) {
391
+ checkStatus = "failed";
392
+ reason = "no sanity pairs file available to check a new model/dims pair";
393
+ } else {
394
+ const sanity = await checkEmbeddingSanity(provider, pairs);
395
+ checkStatus = sanity.ok ? "ok" : "failed";
396
+ if (!sanity.ok) reason = sanity.reason;
397
+ }
398
+ } else {
399
+ checkStatus = "not-needed";
400
+ }
401
+
402
+ if (checkStatus !== "failed") {
403
+ const embedStart = Date.now();
404
+ const embedResult = await embedCodeChunks(m, provider, { batch, onProgress });
405
+ embedSeconds = (Date.now() - embedStart) / 1000;
406
+ chunksEmbedded = embedResult.embedded;
407
+ chunksFailed = embedResult.failed;
408
+ if (embedResult.reason) reason = embedResult.reason;
409
+ }
410
+ }
411
+
412
+ const finishedAt = toIsoTimestamp(now);
413
+ const headSha = gitHeadSha(dir);
414
+ m.prepare(
415
+ `INSERT INTO code_index_builds
416
+ (started, finished, head_sha, files, chunks, files_changed, chunks_embedded, chunks_failed, embed_seconds, provider, model, dims, reason)
417
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
418
+ ).run(
419
+ startedAt,
420
+ finishedAt,
421
+ headSha,
422
+ totalFiles,
423
+ totalChunks,
424
+ filesChanged,
425
+ chunksEmbedded,
426
+ chunksFailed,
427
+ embedSeconds,
428
+ provider ? provider.name : null,
429
+ provider ? provider.model : null,
430
+ provider ? provider.dims : null,
431
+ reason
432
+ );
433
+
434
+ return {
435
+ files: totalFiles,
436
+ chunks: totalChunks,
437
+ filesChanged,
438
+ filesRemoved,
439
+ chunksEmbedded,
440
+ chunksFailed,
441
+ embedSeconds,
442
+ check: checkStatus,
443
+ reason,
444
+ };
445
+ }
446
+
447
+ // The index's size and its refresh cost, without writing anything. Refresh cost re-lists and re-hashes every
448
+ // indexable file (no chunking beyond the added/changed set, no writes) and counts exactly the chunks the next
449
+ // buildIndex would find new - the same id derivation buildIndex uses, so the two never disagree.
450
+ export function indexStatus(m, dir, opts = {}) {
451
+ const { cfg = {}, provider = null } = opts;
452
+ const chunkLines = cfg.chunkLines || 60;
453
+
454
+ const filesRow = m.prepare("SELECT COUNT(*) AS n FROM code_files").get();
455
+ const chunksRow = m.prepare("SELECT COUNT(*) AS n, COALESCE(SUM(LENGTH(text)), 0) AS bytes FROM code_chunks").get();
456
+ const dbBytes = existsSync(m.file) ? statSync(m.file).size : 0;
457
+
458
+ let embedded = 0;
459
+ if (provider) {
460
+ embedded = m
461
+ .prepare("SELECT COUNT(*) AS n FROM code_chunks WHERE embedding_model = ? AND embedding_dims = ?")
462
+ .get(provider.model, provider.dims).n;
463
+ }
464
+
465
+ const lastBuild = m.prepare("SELECT * FROM code_index_builds ORDER BY id DESC LIMIT 1").get() || null;
466
+ const currentHead = gitHeadSha(dir);
467
+
468
+ const files = listIndexableFiles(dir, cfg);
469
+ const fileSet = new Set(files);
470
+ const storedFiles = m.prepare("SELECT path, hash FROM code_files").all();
471
+ const storedByPath = new Map(storedFiles.map((r) => [r.path, r.hash]));
472
+
473
+ let added = 0;
474
+ let changed = 0;
475
+ let removed = 0;
476
+ let chunksToEmbed = 0;
477
+ for (const path of files) {
478
+ let bytes;
479
+ try {
480
+ bytes = readFileSync(join(dir, path));
481
+ } catch {
482
+ continue;
483
+ }
484
+ const hash = sha256Hex(bytes);
485
+ const storedHash = storedByPath.get(path);
486
+ const isNew = storedHash === undefined;
487
+ const isChanged = !isNew && storedHash !== hash;
488
+ if (!isNew && !isChanged) continue;
489
+ if (isNew) added++;
490
+ else changed++;
491
+
492
+ const text = bytes.toString("utf8");
493
+ for (const row of idsForChunks(path, chunkText(text, { chunkLines }))) {
494
+ const exists = m.prepare("SELECT 1 AS x FROM code_chunks WHERE id = ?").get(row.id);
495
+ if (!exists) chunksToEmbed++;
496
+ }
497
+ }
498
+ for (const path of storedByPath.keys()) {
499
+ if (!fileSet.has(path)) removed++;
500
+ }
501
+
502
+ // The rate comes from the most recent build that actually measured one, not necessarily the last build overall
503
+ // (review MAJOR 1): a no-op rebuild or a keyword-only build (no provider, or a failed check) writes a row with
504
+ // embed_seconds/chunks_embedded at 0, and taking the rate from that row alone would wipe a perfectly good
505
+ // estimate an earlier, real embed pass already measured.
506
+ const lastMeasuredBuild = m
507
+ .prepare("SELECT chunks_embedded, embed_seconds FROM code_index_builds WHERE embed_seconds > 0 AND chunks_embedded > 0 ORDER BY id DESC LIMIT 1")
508
+ .get();
509
+ let rate = null;
510
+ let refreshSeconds = null;
511
+ let rateReason = null;
512
+ if (lastMeasuredBuild) {
513
+ rate = lastMeasuredBuild.chunks_embedded / lastMeasuredBuild.embed_seconds;
514
+ refreshSeconds = rate > 0 ? chunksToEmbed / rate : null;
515
+ } else {
516
+ rateReason = "no measured build";
517
+ }
518
+
519
+ return {
520
+ files: filesRow.n,
521
+ chunks: chunksRow.n,
522
+ textBytes: chunksRow.bytes,
523
+ dbBytes,
524
+ provider: provider ? provider.name : "none",
525
+ model: provider ? provider.model : null,
526
+ dims: provider ? provider.dims : null,
527
+ embedded,
528
+ built: lastBuild
529
+ ? { finished: lastBuild.finished, headSha: lastBuild.head_sha, current: !!currentHead && currentHead === lastBuild.head_sha }
530
+ : null,
531
+ refresh: { added, changed, removed, chunksToEmbed, seconds: refreshSeconds, rate, rateReason },
532
+ };
533
+ }
534
+
535
+ function rowsByIdMap(m, ids) {
536
+ if (!ids.length) return new Map();
537
+ const placeholders = ids.map(() => "?").join(",");
538
+ const rows = m.prepare(`SELECT id, path, start_line, end_line, text FROM code_chunks WHERE id IN (${placeholders})`).all(...ids);
539
+ return new Map(rows.map((r) => [r.id, r]));
540
+ }
541
+
542
+ // Hybrid search in the shape of recallLessons' steps (b)-(d): BM25 over code_chunks_fts (top 50) fused with
543
+ // cosine over chunks stored under the provider's model/dims (top 50) by reciprocal rank fusion (1/(60+rank) per
544
+ // side) - no recency term, code has none. `files` globs restrict candidates by path on both sides: in SQL for
545
+ // the BM25 side (a precomputed allow-list of matching paths, so the ranking itself runs restricted), in memory
546
+ // for the vector side. Never throws for a provider problem; a query with no FTS tokens simply has no BM25 side.
547
+ export async function searchIndex(m, query, opts = {}) {
548
+ const { k = 8, files = [], provider = null } = opts;
549
+
550
+ const allowedPaths = files.length
551
+ ? new Set(
552
+ m
553
+ .prepare("SELECT DISTINCT path FROM code_chunks")
554
+ .all()
555
+ .map((r) => r.path)
556
+ .filter((p) => matchesAnyGlob(files, p))
557
+ )
558
+ : null;
559
+ const pathsExcluded = allowedPaths !== null && allowedPaths.size === 0;
560
+
561
+ // review MINOR 5: a `path IN (?, ?, ...)` clause with one bound parameter per matching path can exceed
562
+ // SQLITE_MAX_VARIABLE_NUMBER on a large repo with a broad --files glob. Take the raw top 200 BM25 matches (no
563
+ // path filter in SQL) and restrict to allowedPaths in memory instead, the same way the vector side already
564
+ // does, then cut to the usual top 50.
565
+ let bm25Ranked = [];
566
+ const match = ftsMatchQuery(query);
567
+ if (match && !pathsExcluded) {
568
+ const rows = m
569
+ .prepare("SELECT id, path FROM code_chunks_fts WHERE code_chunks_fts MATCH ? ORDER BY bm25(code_chunks_fts) LIMIT 200")
570
+ .all(match);
571
+ const filtered = allowedPaths ? rows.filter((r) => allowedPaths.has(r.path)) : rows;
572
+ bm25Ranked = filtered.slice(0, 50).map((r) => r.id);
573
+ }
574
+
575
+ let mode = "keyword-only";
576
+ let reason = "no provider configured";
577
+ let providerName = null;
578
+ let vectorRanked = [];
579
+ if (provider) {
580
+ providerName = provider.name;
581
+ let vectorRows = m
582
+ .prepare("SELECT id, path, embedding FROM code_chunks WHERE embedding_model = ? AND embedding_dims = ?")
583
+ .all(provider.model, provider.dims);
584
+ if (allowedPaths) vectorRows = vectorRows.filter((r) => allowedPaths.has(r.path));
585
+ if (!vectorRows.length) {
586
+ reason = `no vectors stored for ${provider.model}/${provider.dims}`;
587
+ } else {
588
+ const embedResult = await provider.embed([String(query || "")], { inputType: "query" });
589
+ if (!embedResult.ok) {
590
+ reason = embedResult.reason;
591
+ } else {
592
+ const qVec = embedResult.vectors[0];
593
+ const scored = vectorRows.map((r) => {
594
+ const vec = blobToVector(r.embedding);
595
+ let dot = 0;
596
+ for (let i = 0; i < qVec.length && i < vec.length; i++) dot += qVec[i] * vec[i];
597
+ return { id: r.id, score: dot };
598
+ });
599
+ scored.sort((a, b) => b.score - a.score);
600
+ vectorRanked = scored.slice(0, 50).map((s) => s.id);
601
+ mode = "hybrid";
602
+ reason = null;
603
+ }
604
+ }
605
+ }
606
+
607
+ const fused = new Map();
608
+ bm25Ranked.forEach((id, idx) => fused.set(id, (fused.get(id) || 0) + 1 / (60 + idx + 1)));
609
+ vectorRanked.forEach((id, idx) => fused.set(id, (fused.get(id) || 0) + 1 / (60 + idx + 1)));
610
+
611
+ const ranked = [...fused.entries()].sort((a, b) => b[1] - a[1]).slice(0, k);
612
+ const rows = rowsByIdMap(
613
+ m,
614
+ ranked.map(([id]) => id)
615
+ );
616
+
617
+ const chunks = ranked
618
+ .map(([id, score]) => {
619
+ const row = rows.get(id);
620
+ if (!row) return null;
621
+ const sides = [];
622
+ if (bm25Ranked.includes(id)) sides.push("bm25");
623
+ if (vectorRanked.includes(id)) sides.push("vector");
624
+ return { id, path: row.path, startLine: row.start_line, endLine: row.end_line, score, sides, text: row.text };
625
+ })
626
+ .filter(Boolean);
627
+
628
+ return { mode, reason: mode === "hybrid" ? null : reason, provider: providerName, chunks };
629
+ }