opencode-rag-plugin 1.15.1 → 1.17.2

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 (240) hide show
  1. package/ReadMe.md +52 -9
  2. package/dist/api.js +44 -24
  3. package/dist/chunker/base.d.ts +34 -3
  4. package/dist/chunker/base.js +70 -21
  5. package/dist/chunker/factory.d.ts +4 -1
  6. package/dist/chunker/factory.js +12 -1
  7. package/dist/chunker/grammar.js +3 -0
  8. package/dist/chunker/image.js +8 -8
  9. package/dist/chunker/pdf.js +11 -13
  10. package/dist/chunker/xml.d.ts +2 -0
  11. package/dist/chunker/xml.js +2 -0
  12. package/dist/cli/commands/index-command.js +0 -4
  13. package/dist/cli/commands/index.d.ts +1 -0
  14. package/dist/cli/commands/index.js +1 -0
  15. package/dist/cli/commands/init-helpers.d.ts +12 -0
  16. package/dist/cli/commands/init-helpers.js +87 -2
  17. package/dist/cli/commands/init.js +20 -2
  18. package/dist/cli/commands/query.js +1 -0
  19. package/dist/cli/commands/setup.d.ts +2 -0
  20. package/dist/cli/commands/setup.js +113 -0
  21. package/dist/cli/commands/status.js +64 -2
  22. package/dist/cli/commands/update.d.ts +4 -6
  23. package/dist/cli/commands/update.js +44 -58
  24. package/dist/cli/index.js +2 -1
  25. package/dist/content/image.js +24 -3
  26. package/dist/content/reader.d.ts +6 -1
  27. package/dist/content/reader.js +49 -4
  28. package/dist/core/config.d.ts +29 -1
  29. package/dist/core/config.js +105 -8
  30. package/dist/core/desc-cache.d.ts +31 -0
  31. package/dist/core/desc-cache.js +124 -0
  32. package/dist/core/interfaces.d.ts +58 -3
  33. package/dist/core/manifest.d.ts +24 -1
  34. package/dist/core/manifest.js +34 -3
  35. package/dist/core/resolve-api-key.js +4 -2
  36. package/dist/core/runtime-overrides.js +12 -8
  37. package/dist/core/setup-runtime.d.ts +23 -0
  38. package/dist/core/setup-runtime.js +231 -0
  39. package/dist/core/version-check.d.ts +85 -0
  40. package/dist/core/version-check.js +160 -0
  41. package/dist/describer/anthropic.d.ts +2 -2
  42. package/dist/describer/anthropic.js +5 -7
  43. package/dist/describer/describer.d.ts +2 -2
  44. package/dist/describer/describer.js +6 -8
  45. package/dist/describer/gemini.d.ts +2 -2
  46. package/dist/describer/gemini.js +5 -7
  47. package/dist/embedder/cohere.d.ts +1 -1
  48. package/dist/embedder/cohere.js +2 -2
  49. package/dist/embedder/factory.d.ts +3 -1
  50. package/dist/embedder/factory.js +8 -2
  51. package/dist/embedder/health.js +5 -1
  52. package/dist/embedder/http.d.ts +1 -1
  53. package/dist/embedder/http.js +74 -43
  54. package/dist/embedder/ollama.d.ts +1 -1
  55. package/dist/embedder/ollama.js +2 -2
  56. package/dist/embedder/openai.d.ts +1 -1
  57. package/dist/embedder/openai.js +2 -2
  58. package/dist/eval/compare-merge.d.ts +10 -0
  59. package/dist/eval/compare-merge.js +537 -0
  60. package/dist/eval/compare-rankings.d.ts +10 -0
  61. package/dist/eval/compare-rankings.js +245 -0
  62. package/dist/eval/dump-descriptions.d.ts +7 -0
  63. package/dist/eval/dump-descriptions.js +58 -0
  64. package/dist/eval/fast-index.d.ts +8 -0
  65. package/dist/eval/fast-index.js +283 -0
  66. package/dist/eval/run-branch-compare.d.ts +7 -0
  67. package/dist/eval/run-branch-compare.js +220 -0
  68. package/dist/eval/run-token-test.js +1 -0
  69. package/dist/eval/test-kw.d.ts +1 -0
  70. package/dist/eval/test-kw.js +22 -0
  71. package/dist/eval/update-descriptions.d.ts +7 -0
  72. package/dist/eval/update-descriptions.js +84 -0
  73. package/dist/index.d.ts +4 -0
  74. package/dist/index.js +3 -0
  75. package/dist/indexer/embed-stage.js +2 -1
  76. package/dist/indexer/git-diff.js +21 -9
  77. package/dist/indexer/pipeline.d.ts +6 -0
  78. package/dist/indexer/pipeline.js +304 -40
  79. package/dist/indexer/watch.js +1 -3
  80. package/dist/indexer/worker.d.ts +15 -2
  81. package/dist/indexer/worker.js +25 -12
  82. package/dist/mcp/handlers.d.ts +9 -0
  83. package/dist/mcp/handlers.js +23 -6
  84. package/dist/mcp/server.js +2 -0
  85. package/dist/opencode/create-read-tool.d.ts +2 -0
  86. package/dist/opencode/create-read-tool.js +8 -2
  87. package/dist/opencode/read-fallback.d.ts +1 -5
  88. package/dist/opencode/read-fallback.js +1 -18
  89. package/dist/opencode/read-format.js +5 -3
  90. package/dist/opencode/tools.js +5 -7
  91. package/dist/plugin.js +223 -71
  92. package/dist/retriever/keyword-index.d.ts +3 -2
  93. package/dist/retriever/keyword-index.js +25 -1
  94. package/dist/retriever/retriever.d.ts +4 -1
  95. package/dist/retriever/retriever.js +34 -56
  96. package/dist/tui.js +18 -1
  97. package/dist/vectorstore/lancedb.d.ts +35 -4
  98. package/dist/vectorstore/lancedb.js +146 -23
  99. package/dist/vectorstore/memory.d.ts +6 -1
  100. package/dist/vectorstore/memory.js +58 -0
  101. package/dist/watcher.d.ts +2 -0
  102. package/dist/watcher.js +5 -1
  103. package/dist/web/api.js +10 -2
  104. package/dist/web/server.js +18 -3
  105. package/package.json +8 -9
  106. package/scripts/postinstall-setup.js +82 -0
  107. package/dist/api.js.map +0 -1
  108. package/dist/chunker/base.js.map +0 -1
  109. package/dist/chunker/bash.js.map +0 -1
  110. package/dist/chunker/c.js.map +0 -1
  111. package/dist/chunker/cpp.js.map +0 -1
  112. package/dist/chunker/csharp.js.map +0 -1
  113. package/dist/chunker/css.js.map +0 -1
  114. package/dist/chunker/doc.js.map +0 -1
  115. package/dist/chunker/dockerfile.js.map +0 -1
  116. package/dist/chunker/docx.js.map +0 -1
  117. package/dist/chunker/excel.js.map +0 -1
  118. package/dist/chunker/factory.js.map +0 -1
  119. package/dist/chunker/fallback.js.map +0 -1
  120. package/dist/chunker/go.js.map +0 -1
  121. package/dist/chunker/grammar.js.map +0 -1
  122. package/dist/chunker/html.js.map +0 -1
  123. package/dist/chunker/image.js.map +0 -1
  124. package/dist/chunker/ini.js.map +0 -1
  125. package/dist/chunker/java.js.map +0 -1
  126. package/dist/chunker/javascript.js.map +0 -1
  127. package/dist/chunker/json.js.map +0 -1
  128. package/dist/chunker/kotlin.js.map +0 -1
  129. package/dist/chunker/loader.js.map +0 -1
  130. package/dist/chunker/markdown.js.map +0 -1
  131. package/dist/chunker/pdf.js.map +0 -1
  132. package/dist/chunker/php.js.map +0 -1
  133. package/dist/chunker/powershell.js.map +0 -1
  134. package/dist/chunker/python.js.map +0 -1
  135. package/dist/chunker/razor.js.map +0 -1
  136. package/dist/chunker/ruby.js.map +0 -1
  137. package/dist/chunker/rust.js.map +0 -1
  138. package/dist/chunker/sln.js.map +0 -1
  139. package/dist/chunker/sql.js.map +0 -1
  140. package/dist/chunker/ssl.js.map +0 -1
  141. package/dist/chunker/swift.js.map +0 -1
  142. package/dist/chunker/tex.js.map +0 -1
  143. package/dist/chunker/toml.js.map +0 -1
  144. package/dist/chunker/typescript.js.map +0 -1
  145. package/dist/chunker/uuid.js.map +0 -1
  146. package/dist/chunker/xml.js.map +0 -1
  147. package/dist/chunker/yaml.js.map +0 -1
  148. package/dist/cli/commands/clear.js.map +0 -1
  149. package/dist/cli/commands/describe-image.js.map +0 -1
  150. package/dist/cli/commands/dump.js.map +0 -1
  151. package/dist/cli/commands/eval.js.map +0 -1
  152. package/dist/cli/commands/index-command.js.map +0 -1
  153. package/dist/cli/commands/index.js.map +0 -1
  154. package/dist/cli/commands/init-helpers.js.map +0 -1
  155. package/dist/cli/commands/init.js.map +0 -1
  156. package/dist/cli/commands/list.js.map +0 -1
  157. package/dist/cli/commands/mcp.js.map +0 -1
  158. package/dist/cli/commands/query.js.map +0 -1
  159. package/dist/cli/commands/show.js.map +0 -1
  160. package/dist/cli/commands/status.js.map +0 -1
  161. package/dist/cli/commands/ui.js.map +0 -1
  162. package/dist/cli/commands/update.js.map +0 -1
  163. package/dist/cli/format.js.map +0 -1
  164. package/dist/cli/helpers.js.map +0 -1
  165. package/dist/cli/index.js.map +0 -1
  166. package/dist/cli/progress.d.ts +0 -42
  167. package/dist/cli/progress.js +0 -137
  168. package/dist/cli/progress.js.map +0 -1
  169. package/dist/cli/types.js.map +0 -1
  170. package/dist/cli.js.map +0 -1
  171. package/dist/content/doc.js.map +0 -1
  172. package/dist/content/docx.js.map +0 -1
  173. package/dist/content/excel.js.map +0 -1
  174. package/dist/content/image.js.map +0 -1
  175. package/dist/content/pdf.js.map +0 -1
  176. package/dist/content/reader.js.map +0 -1
  177. package/dist/content/types.js.map +0 -1
  178. package/dist/core/bootstrap.js.map +0 -1
  179. package/dist/core/config.js.map +0 -1
  180. package/dist/core/doc-progress.js.map +0 -1
  181. package/dist/core/fileLogger.js.map +0 -1
  182. package/dist/core/interfaces.js.map +0 -1
  183. package/dist/core/manifest.js.map +0 -1
  184. package/dist/core/provider-defaults.js.map +0 -1
  185. package/dist/core/rag-injection-flag.js.map +0 -1
  186. package/dist/core/resolve-api-key.js.map +0 -1
  187. package/dist/core/runtime-overrides.js.map +0 -1
  188. package/dist/describer/anthropic.js.map +0 -1
  189. package/dist/describer/describer.js.map +0 -1
  190. package/dist/describer/factory.js.map +0 -1
  191. package/dist/describer/gemini.js.map +0 -1
  192. package/dist/describer/shared.js.map +0 -1
  193. package/dist/embedder/cohere.js.map +0 -1
  194. package/dist/embedder/factory.js.map +0 -1
  195. package/dist/embedder/health.js.map +0 -1
  196. package/dist/embedder/http.js.map +0 -1
  197. package/dist/embedder/ollama.js.map +0 -1
  198. package/dist/embedder/openai.js.map +0 -1
  199. package/dist/eval/index.js.map +0 -1
  200. package/dist/eval/run-token-test.js.map +0 -1
  201. package/dist/eval/session-logger.js.map +0 -1
  202. package/dist/eval/storage.js.map +0 -1
  203. package/dist/eval/token-analysis.js.map +0 -1
  204. package/dist/eval/token-counter.js.map +0 -1
  205. package/dist/eval/types.js.map +0 -1
  206. package/dist/index.js.map +0 -1
  207. package/dist/indexer/description-stage.js.map +0 -1
  208. package/dist/indexer/embed-stage.js.map +0 -1
  209. package/dist/indexer/git-diff.js.map +0 -1
  210. package/dist/indexer/metadata.js.map +0 -1
  211. package/dist/indexer/pipeline.js.map +0 -1
  212. package/dist/indexer/stats.js.map +0 -1
  213. package/dist/indexer/watch.js.map +0 -1
  214. package/dist/indexer/worker.js.map +0 -1
  215. package/dist/indexer.js.map +0 -1
  216. package/dist/mcp/cli.js.map +0 -1
  217. package/dist/mcp/handlers.js.map +0 -1
  218. package/dist/mcp/server.js.map +0 -1
  219. package/dist/opencode/create-read-tool.js.map +0 -1
  220. package/dist/opencode/read-fallback.js.map +0 -1
  221. package/dist/opencode/read-format.js.map +0 -1
  222. package/dist/opencode/read-query.js.map +0 -1
  223. package/dist/opencode/tool-args.js.map +0 -1
  224. package/dist/opencode/tools.js.map +0 -1
  225. package/dist/plugin-entry.js.map +0 -1
  226. package/dist/plugin.js.map +0 -1
  227. package/dist/retriever/context-optimizer.js.map +0 -1
  228. package/dist/retriever/keyword-index.js.map +0 -1
  229. package/dist/retriever/retriever.js.map +0 -1
  230. package/dist/tui.js.map +0 -1
  231. package/dist/updater.d.ts +0 -45
  232. package/dist/updater.js +0 -175
  233. package/dist/updater.js.map +0 -1
  234. package/dist/vectorstore/factory.js.map +0 -1
  235. package/dist/vectorstore/lancedb.js.map +0 -1
  236. package/dist/vectorstore/memory.js.map +0 -1
  237. package/dist/watcher.js.map +0 -1
  238. package/dist/web/api.js.map +0 -1
  239. package/dist/web/server.js.map +0 -1
  240. package/dist/web/static.js.map +0 -1
@@ -5,12 +5,13 @@ import fs from "node:fs/promises";
5
5
  import path from "node:path";
6
6
  import pLimit from "p-limit";
7
7
  import { scanWorkspaceFiles } from "../content/reader.js";
8
- import { loadManifest, saveManifest, normalizeFilePath } from "../core/manifest.js";
8
+ import { loadManifest, saveManifest, normalizeFilePath, computeDescriptionConfigHash } from "../core/manifest.js";
9
+ import { DescriptionCache } from "../core/desc-cache.js";
9
10
  import { embedBatch } from "../embedder/factory.js";
10
11
  import { createVectorStore } from "../vectorstore/factory.js";
11
12
  import { swapStoreDirectories } from "../vectorstore/lancedb.js";
12
13
  import { createIndexStats } from "./stats.js";
13
- import { prepareFile, buildTextsToEmbed, storeFileChunks } from "./worker.js";
14
+ import { prepareFile, buildTextsToEmbed } from "./worker.js";
14
15
  import { buildFallbackDescription } from "./description-stage.js";
15
16
  import { getCurrentCommit, getChangedFilesSince, getUntrackedFiles, getRepoRoot } from "./git-diff.js";
16
17
  export { createWatchPassScheduler, createWatchIgnore } from "./watch.js";
@@ -99,6 +100,11 @@ async function runIndexPassInner(options, logger) {
99
100
  manifestStatus = "missing";
100
101
  logger.debug("Force mode: ignoring manifest");
101
102
  }
103
+ // Compute a hash of the current description config — used to skip re-description
104
+ // when neither the file content nor the description config has changed.
105
+ const descHash = options.descriptionProvider
106
+ ? computeDescriptionConfigHash(options.config)
107
+ : undefined;
102
108
  // Clear files that had description failures so they are fully re-indexed
103
109
  const descFailedPaths = Object.keys(manifest.files).filter((p) => manifest.files[p]?.descriptionFailed);
104
110
  if (descFailedPaths.length > 0) {
@@ -107,6 +113,39 @@ async function runIndexPassInner(options, logger) {
107
113
  delete manifest.files[p];
108
114
  }
109
115
  }
116
+ // Persistent description cache — survives aborted runs so that descriptions
117
+ // generated before an abort are reused on the next run.
118
+ const descCachePath = options.storePath;
119
+ const descCache = new DescriptionCache(descCachePath);
120
+ await descCache.load();
121
+ // Resume-on-interrupt: detect manifest entries that are inconsistent with
122
+ // the vector store (e.g. indexing was aborted before all chunks were stored,
123
+ // or a full rebuild was aborted leaving a stale manifest pointing to old data).
124
+ // These entries are removed so they will be re-processed in this pass.
125
+ if (!options.force && manifestStatus === "ok" && Object.keys(manifest.files).length > 0) {
126
+ try {
127
+ const storedPaths = await options.store.getFilePaths();
128
+ const storedSet = new Set(storedPaths);
129
+ let removedCount = 0;
130
+ for (const [p, entry] of Object.entries(manifest.files)) {
131
+ // Path missing from store entirely (aborted before store finished)
132
+ if (entry.chunkCount > 0 && !storedSet.has(p)) {
133
+ delete manifest.files[p];
134
+ removedCount++;
135
+ }
136
+ }
137
+ if (removedCount > 0) {
138
+ logger.info(` ${removedCount} file(s) in manifest but missing from store — re-indexing (resume after interruption)`);
139
+ }
140
+ }
141
+ catch {
142
+ logger.debug(" Could not check store paths for resume — proceeding without");
143
+ }
144
+ }
145
+ // Clear files where the stored descHash doesn't match current descHash and the
146
+ // file content IS different — these would be caught by hash comparison below but
147
+ // this pre-clear ensures the description cache is consulted during re-description.
148
+ // (Files with same hash but different descHash already fall through in prepareFile.)
110
149
  let filterPaths;
111
150
  let gitDeletedPaths = [];
112
151
  if (!options.force && manifestStatus === "ok" && manifest.lastGitCommit) {
@@ -127,7 +166,7 @@ async function runIndexPassInner(options, logger) {
127
166
  }
128
167
  }
129
168
  const scanStart = Date.now();
130
- const workspaceFiles = await scanWorkspaceFiles(options.cwd, options.config, logger, options.force ? undefined : manifest, filterPaths);
169
+ const workspaceFiles = await scanWorkspaceFiles(options.cwd, options.config, logger, options.force ? undefined : manifest, filterPaths, options.imageVisionProvider, descCache);
131
170
  const scanSec = ((Date.now() - scanStart) / 1000).toFixed(1);
132
171
  logger.info(`Workspace scan complete: ${workspaceFiles.length} files in ${scanSec}s`);
133
172
  const existingCount = await options.store.count();
@@ -171,10 +210,17 @@ async function runIndexPassInner(options, logger) {
171
210
  effectiveStore = createVectorStore(options.config, tempStorePath, options.dimension);
172
211
  logger.debug(`Rebuilding index in temporary store at ${tempStorePath}`);
173
212
  }
213
+ else if (existingCount > 0) {
214
+ // NEVER destroy existing data when we can't do an atomic rebuild.
215
+ // Abort and ask the user to run 'opencode-rag index --force' manually.
216
+ logger.warn("Cannot rebuild safely without embedding dimension — aborting to protect existing data. " +
217
+ "Run 'opencode-rag index --force' manually to rebuild.");
218
+ // Restore manifest entries we just deleted so the next pass can retry incrementally
219
+ return createIndexStats(workspaceFiles.length, manifestStatus);
220
+ }
174
221
  else {
175
- // Fallback — in-memory store or no dimension: clear in place.
176
- logger.warn("No embedding dimension available; falling back to in-place clear.");
177
- await options.store.clear();
222
+ // No existing data safe to proceed with in-place indexing (no clear needed)
223
+ logger.debug("No existing data; indexing from scratch.");
178
224
  }
179
225
  }
180
226
  const stats = createIndexStats(workspaceFiles.length, manifestStatus);
@@ -237,12 +283,19 @@ async function runIndexPassInner(options, logger) {
237
283
  if (isActive) {
238
284
  options.progress?.startFile(fileLabel);
239
285
  }
240
- const prep = await prepareFile(file, options.cwd, manifest.files[file.normalizedPath], options.config, options.keywordIndex, options.descriptionProvider, logger, deferDescriptions);
286
+ const prep = await prepareFile(file, options.cwd, manifest.files[file.normalizedPath], options.config, options.keywordIndex, options.descriptionProvider, logger, deferDescriptions, descHash);
241
287
  if (prep.earlyResult && isActive) {
242
288
  options.progress?.finishFile(fileLabel);
243
289
  }
244
290
  return prep;
245
291
  })));
292
+ const aborted = () => options.abortSignal?.aborted ?? false;
293
+ // Shared progress logger: "stage <file> (chunk i/n) — X/total remaining (P%)".
294
+ const logChunkProgress = (stage, fileLabel, index, count, completed, total) => {
295
+ const remaining = total - completed;
296
+ const pct = total > 0 ? ((remaining / total) * 100).toFixed(1) : "0.0";
297
+ logger.info(`${stage} ${fileLabel} (chunk ${index}/${count}) — ${remaining}/${total} remaining (${pct}%)`);
298
+ };
246
299
  if (deferDescriptions) {
247
300
  const deferredPreps = prepared.filter((p) => p.chunks && p.chunks.length > 0 && p.relPath !== undefined);
248
301
  if (deferredPreps.length > 0) {
@@ -264,13 +317,139 @@ async function runIndexPassInner(options, logger) {
264
317
  for (const chunk of oversizedChunks) {
265
318
  chunk.description = buildFallbackDescription(chunk);
266
319
  }
320
+ // Per-file chunk index + label for clear progress reporting, plus a shared
321
+ // progress logger emitting "stage <file> (chunk i/n) — X/total remaining (P%)".
322
+ const chunkToFileLabel = new Map();
323
+ for (const prep of deferredPreps) {
324
+ for (const chunk of prep.chunks ?? []) {
325
+ chunkToFileLabel.set(chunk.id, prep.fileLabel);
326
+ }
327
+ }
328
+ const chunkMeta = new Map();
329
+ {
330
+ const perFileCount = new Map();
331
+ for (const chunk of allChunks) {
332
+ perFileCount.set(chunk.metadata.filePath, (perFileCount.get(chunk.metadata.filePath) ?? 0) + 1);
333
+ }
334
+ const perFileSeen = new Map();
335
+ for (const chunk of allChunks) {
336
+ const seen = perFileSeen.get(chunk.metadata.filePath) ?? 0;
337
+ perFileSeen.set(chunk.metadata.filePath, seen + 1);
338
+ chunkMeta.set(chunk.id, {
339
+ fileLabel: chunkToFileLabel.get(chunk.id) ?? chunk.metadata.filePath,
340
+ index: seen + 1,
341
+ count: perFileCount.get(chunk.metadata.filePath) ?? 1,
342
+ });
343
+ }
344
+ }
267
345
  // Advance progress to Description stage before descriptions start
268
346
  for (const prep of deferredPreps) {
269
347
  options.progress?.finishStage(prep.fileLabel);
270
348
  }
271
- if (allChunks.length > 0) {
349
+ if (allChunks.length > 0 && descHash) {
350
+ // Check description cache first — reuse cached descriptions for unchanged chunks
351
+ const cacheHits = [];
352
+ const cacheMisses = [];
353
+ for (const chunk of allChunks) {
354
+ const cacheKey = DescriptionCache.codeKey(chunk.content, descHash);
355
+ const cached = descCache.get(cacheKey);
356
+ if (cached) {
357
+ cacheHits.push({ chunk, desc: cached });
358
+ }
359
+ else {
360
+ cacheMisses.push(chunk);
361
+ }
362
+ }
363
+ if (cacheHits.length > 0) {
364
+ logger.debug(` Using ${cacheHits.length} cached descriptions`);
365
+ for (const { chunk, desc } of cacheHits) {
366
+ chunk.description = desc;
367
+ }
368
+ }
369
+ // Build chunkId → prep map for tracking description failures per-file
370
+ const chunkToPrep = new Map();
371
+ for (const prep of deferredPreps) {
372
+ for (const chunk of prep.chunks ?? []) {
373
+ chunkToPrep.set(chunk.id, prep);
374
+ }
375
+ }
376
+ // Process cache misses in parallel waves with throttled cache saves.
377
+ // Parallel sub-batches reduce serial LLM turnaround time.
378
+ // Cache saves are throttled to avoid O(n^2) full-JSON serialization I/O.
379
+ if (cacheMisses.length > 0) {
380
+ const totalMisses = cacheMisses.length;
381
+ const descConcurrency = options.config.indexing.descriptionConcurrency ?? 4;
382
+ const SUB_BATCH = 10;
383
+ let describedDone = 0;
384
+ let describedSinceLastSave = 0;
385
+ const SAVE_INTERVAL = 200;
386
+ const descLimit = pLimit(descConcurrency);
387
+ const subBatchTasks = [];
388
+ for (let i = 0; i < totalMisses; i += SUB_BATCH) {
389
+ subBatchTasks.push({ i, chunks: cacheMisses.slice(i, i + SUB_BATCH) });
390
+ }
391
+ await Promise.all(subBatchTasks.map(({ i, chunks: subBatch }) => descLimit(async () => {
392
+ if (aborted())
393
+ return;
394
+ try {
395
+ const batchResult = await options.descriptionProvider.generateBatchDescriptions(subBatch, logger, {
396
+ total: totalMisses,
397
+ onProgress: (chunk) => {
398
+ describedDone++;
399
+ const meta = chunkMeta.get(chunk.id);
400
+ if (meta)
401
+ logChunkProgress("Describing", meta.fileLabel, meta.index, meta.count, describedDone, totalMisses);
402
+ },
403
+ });
404
+ const newCacheEntries = [];
405
+ for (const chunk of subBatch) {
406
+ const desc = batchResult.get(chunk.id);
407
+ if (desc && desc.trim().length > 0) {
408
+ chunk.description = desc;
409
+ newCacheEntries.push([DescriptionCache.codeKey(chunk.content, descHash), desc]);
410
+ }
411
+ }
412
+ if (newCacheEntries.length > 0) {
413
+ descCache.setMany(newCacheEntries);
414
+ describedSinceLastSave += newCacheEntries.length;
415
+ if (describedSinceLastSave >= SAVE_INTERVAL) {
416
+ await descCache.save();
417
+ describedSinceLastSave = 0;
418
+ }
419
+ }
420
+ }
421
+ catch (err) {
422
+ logger.warn(` Description sub-batch failed (${i}-${i + subBatch.length}): ${err.message}`);
423
+ const failedPreps = new Set();
424
+ for (const chunk of subBatch) {
425
+ const prep = chunkToPrep.get(chunk.id);
426
+ if (prep)
427
+ failedPreps.add(prep);
428
+ }
429
+ for (const prep of failedPreps) {
430
+ prep.descriptionFailed = true;
431
+ }
432
+ }
433
+ })));
434
+ if (describedSinceLastSave > 0) {
435
+ await descCache.save();
436
+ }
437
+ logger.debug(`Descriptions generated for ${totalMisses} chunks (concurrency: ${descConcurrency})`);
438
+ }
439
+ }
440
+ else if (allChunks.length > 0) {
441
+ // No descHash available (no description provider) — still generate descriptions
442
+ let describedDone = 0;
272
443
  try {
273
- const batchResult = await options.descriptionProvider.generateBatchDescriptions(allChunks, logger);
444
+ const batchResult = await options.descriptionProvider.generateBatchDescriptions(allChunks, logger, {
445
+ total: allChunks.length,
446
+ onProgress: (chunk) => {
447
+ describedDone++;
448
+ const meta = chunkMeta.get(chunk.id);
449
+ if (meta)
450
+ logChunkProgress("Describing", meta.fileLabel, meta.index, meta.count, describedDone, allChunks.length);
451
+ },
452
+ });
274
453
  for (const chunk of allChunks) {
275
454
  const desc = batchResult.get(chunk.id);
276
455
  if (desc && desc.trim().length > 0) {
@@ -292,10 +471,9 @@ async function runIndexPassInner(options, logger) {
292
471
  }
293
472
  }
294
473
  }
295
- // Deletions for modified/re-removed files happen per-worker, right before
296
- // embedding, so that an abort mid-embed doesn't orphan old entries.
474
+ // Cross-file embedding batch: collect all texts into a single queue,
475
+ // embed in one batched call (with concurrency), then distribute back.
297
476
  const isOllama = options.embedder.name === "ollama";
298
- const ollamaMaxBatch = options.config.indexing.ollamaMaxBatchSize ?? 4000;
299
477
  const defaultBatchSize = options.config.indexing.embedBatchSize;
300
478
  const defaultConcurrency = options.config.indexing.embedConcurrency ?? 1;
301
479
  // File metadata look-up for manifest entries
@@ -303,48 +481,102 @@ async function runIndexPassInner(options, logger) {
303
481
  // Serialised manifest-save queue — prevents concurrent write races and acts
304
482
  // as a checkpoint for Ctrl+C resilience. Each worker appends to this chain
305
483
  // after a successful store, so previously completed files are never lost.
484
+ // During a temp-store rebuild, saves go to the temp path so the real
485
+ // manifest stays consistent with the real store if the process is aborted.
486
+ const manifestTargetPath = () => tempStorePath ?? options.storePath;
306
487
  let manifestSaveChain = Promise.resolve(undefined);
307
488
  function enqueueManifestSave() {
308
- manifestSaveChain = manifestSaveChain.then(() => saveManifest(options.storePath, manifest));
309
- }
310
- const aborted = () => options.abortSignal?.aborted ?? false;
311
- const embedStoreLimit = pLimit(options.config.indexing.concurrency);
312
- const workerResults = await Promise.all(prepared.map((prep) => embedStoreLimit(async () => {
313
- if (aborted()) {
314
- return { normalizedPath: prep.normalizedPath, skipped: true };
315
- }
489
+ manifestSaveChain = manifestSaveChain.then(() => saveManifest(manifestTargetPath(), manifest).catch((err) => {
490
+ options.logger?.warn?.(`Failed to save manifest: ${err.message}`);
491
+ }));
492
+ }
493
+ // ── Phase 1: Collect embed queue + handle early results ────────────────
494
+ const embedQueue = [];
495
+ let totalEmbedChunks = 0;
496
+ const earlyWorkerResults = new Map();
497
+ for (let fi = 0; fi < prepared.length; fi++) {
498
+ const prep = prepared[fi];
316
499
  if (prep.earlyResult) {
317
- // Remove stale manifest/store entries for files that no longer produce chunks
318
500
  if (prep.earlyResult.isRemoved) {
319
501
  await effectiveStore.deleteByFilePath(prep.normalizedPath);
320
502
  options.keywordIndex?.removeByFilePath(prep.normalizedPath);
321
503
  delete manifest.files[prep.normalizedPath];
322
504
  enqueueManifestSave();
323
505
  }
324
- return prep.earlyResult;
506
+ earlyWorkerResults.set(fi, prep.earlyResult);
507
+ continue;
325
508
  }
326
509
  if (!prep.chunks || !prep.textToEmbed || prep.textToEmbed.length === 0) {
327
510
  options.progress?.finishFile(prep.fileLabel);
328
- return {
511
+ earlyWorkerResults.set(fi, {
329
512
  normalizedPath: prep.normalizedPath, hash: prep.hash, chunkCount: 0,
330
513
  fileLabel: prep.fileLabel,
331
514
  isNew: false, isModified: false, isUnchanged: false, isEmpty: false,
332
515
  isTooSmall: false, isRemoved: true, hadChunks: false,
333
516
  descriptionFailed: prep.descriptionFailed,
334
- };
517
+ });
518
+ continue;
335
519
  }
336
- const batchSize = isOllama
337
- ? Math.min(prep.textToEmbed.length, ollamaMaxBatch)
338
- : defaultBatchSize;
339
- const concurrency = isOllama ? 1 : defaultConcurrency;
340
520
  options.progress?.finishStage(prep.fileLabel);
341
- let embeddings;
521
+ for (let ci = 0; ci < prep.textToEmbed.length; ci++) {
522
+ embedQueue.push({ fileIdx: fi, chunkIdx: ci, text: prep.textToEmbed[ci] });
523
+ }
524
+ totalEmbedChunks += prep.textToEmbed.length;
525
+ }
526
+ // ── Phase 2: Embed all texts in a single batched call ──────────────────
527
+ const batchSize = isOllama
528
+ ? Math.min(options.config.indexing.ollamaMaxBatchSize ?? 500, defaultBatchSize)
529
+ : defaultBatchSize;
530
+ let embeddedDone = 0;
531
+ const allTexts = embedQueue.map(item => item.text);
532
+ let allEmbeddings = [];
533
+ if (allTexts.length > 0) {
342
534
  try {
343
- embeddings = await embedBatch(options.embedder, prep.textToEmbed, batchSize, "document", concurrency);
535
+ allEmbeddings = await embedBatch(options.embedder, allTexts, batchSize, "document", defaultConcurrency, (completed, total) => {
536
+ embeddedDone = completed;
537
+ logChunkProgress("Embedding", "", completed, total, embeddedDone, totalEmbedChunks);
538
+ });
344
539
  }
345
540
  catch (err) {
346
- logger.warn(` ${prep.fileLabel} (embedding failed: ${err.message})`);
347
- options.progress?.failFile(prep.fileLabel);
541
+ logger.warn(` Global embedding failed: ${err.message}`);
542
+ for (const { fileIdx } of embedQueue) {
543
+ options.progress?.failFile(prepared[fileIdx].fileLabel);
544
+ earlyWorkerResults.set(fileIdx, {
545
+ normalizedPath: prepared[fileIdx].normalizedPath,
546
+ hash: prepared[fileIdx].hash,
547
+ chunkCount: 0, fileLabel: prepared[fileIdx].fileLabel,
548
+ isNew: false, isModified: false, isUnchanged: false, isEmpty: false,
549
+ isTooSmall: false, isRemoved: true, hadChunks: false,
550
+ descriptionFailed: prepared[fileIdx].descriptionFailed,
551
+ });
552
+ }
553
+ embedQueue.length = 0; // prevent double-processing in store phase
554
+ }
555
+ }
556
+ // ── Distribute embeddings back to per-file chunks ─────────────────────
557
+ for (let i = 0; i < embedQueue.length; i++) {
558
+ const { fileIdx, chunkIdx } = embedQueue[i];
559
+ const emb = allEmbeddings[i];
560
+ const prep = prepared[fileIdx];
561
+ if (prep.chunks && prep.chunks[chunkIdx] && Array.isArray(emb) && emb.length > 0 && typeof emb[0] === "number") {
562
+ prep.chunks[chunkIdx].embedding = emb;
563
+ }
564
+ }
565
+ // ── Phase 3: Store + manifest update per file (parallel) ──────────────
566
+ const filesToStore = prepared.filter((p) => !earlyWorkerResults.has(prepared.indexOf(p)) && p.chunks && (p.textToEmbed?.length ?? 0) > 0).length;
567
+ let storedFiles = 0;
568
+ const storeLimit = pLimit(options.config.indexing.concurrency);
569
+ const storeResults = await Promise.all(prepared.map((prep, fi) => storeLimit(async () => {
570
+ if (aborted()) {
571
+ return { normalizedPath: prep.normalizedPath, skipped: true };
572
+ }
573
+ // Return early results from phase 1
574
+ const earlyResult = earlyWorkerResults.get(fi);
575
+ if (earlyResult)
576
+ return earlyResult;
577
+ // No-embed path (shouldn't reach here but guard anyway)
578
+ if (!prep.chunks || prep.textToEmbed?.length === 0) {
579
+ options.progress?.finishFile(prep.fileLabel);
348
580
  return {
349
581
  normalizedPath: prep.normalizedPath, hash: prep.hash, chunkCount: 0,
350
582
  fileLabel: prep.fileLabel,
@@ -353,12 +585,30 @@ async function runIndexPassInner(options, logger) {
353
585
  descriptionFailed: prep.descriptionFailed,
354
586
  };
355
587
  }
356
- // ── Store (new data first; orphan cleanup is handled inside the store) ──
357
- const result = await storeFileChunks(prep, embeddings, effectiveStore, logger);
358
- // ── Update manifest in-memory and enqueue an atomic save ──
588
+ // Store chunks with pre-attached embeddings
589
+ const validChunks = (prep.chunks ?? []).filter((c) => c.embedding && c.embedding.length > 0);
590
+ if (validChunks.length > 0) {
591
+ await effectiveStore.addChunks(validChunks);
592
+ }
593
+ const result = {
594
+ normalizedPath: prep.normalizedPath,
595
+ hash: prep.hash,
596
+ chunkCount: prep.chunks?.length ?? 0,
597
+ fileLabel: prep.fileLabel,
598
+ isNew: !prep.isModified,
599
+ isModified: prep.isModified,
600
+ isUnchanged: false,
601
+ isEmpty: false,
602
+ isTooSmall: false,
603
+ isRemoved: validChunks.length === 0,
604
+ hadChunks: (prep.chunks?.length ?? 0) > 0,
605
+ descriptionFailed: prep.descriptionFailed,
606
+ descHash: prep.descHash,
607
+ };
608
+ // Update manifest
359
609
  if (result.chunkCount > 0 && !result.isRemoved) {
360
610
  const meta = fileMeta.get(result.normalizedPath);
361
- manifest.files[result.normalizedPath] = {
611
+ const entry = {
362
612
  hash: result.hash,
363
613
  chunkCount: result.chunkCount,
364
614
  indexedAt: Date.now(),
@@ -366,6 +616,10 @@ async function runIndexPassInner(options, logger) {
366
616
  size: meta?.size,
367
617
  descriptionFailed: result.descriptionFailed,
368
618
  };
619
+ if (result.descHash) {
620
+ entry.descHash = result.descHash;
621
+ }
622
+ manifest.files[result.normalizedPath] = entry;
369
623
  enqueueManifestSave();
370
624
  }
371
625
  else if (result.isRemoved) {
@@ -373,8 +627,11 @@ async function runIndexPassInner(options, logger) {
373
627
  enqueueManifestSave();
374
628
  }
375
629
  options.progress?.finishFile(prep.fileLabel);
630
+ storedFiles++;
631
+ logChunkProgress("Storing", prep.fileLabel, storedFiles, filesToStore, storedFiles, filesToStore);
376
632
  return result;
377
633
  })));
634
+ const workerResults = storeResults;
378
635
  const finalResults = [];
379
636
  for (const r of workerResults) {
380
637
  if (r.skipped)
@@ -406,9 +663,7 @@ async function runIndexPassInner(options, logger) {
406
663
  // Swap the newly-built temp directory into the real path
407
664
  await swapStoreDirectories(tempStorePath, options.storePath);
408
665
  // Re-open the original store handle so callers can search the new data
409
- if (typeof options.store.reopen === "function") {
410
- await options.store.reopen(options.storePath);
411
- }
666
+ await options.store.reopen?.(options.storePath);
412
667
  logger.debug(`Promoted temporary store ${tempStorePath} → ${options.storePath}`);
413
668
  }
414
669
  catch (err) {
@@ -421,19 +676,28 @@ async function runIndexPassInner(options, logger) {
421
676
  }
422
677
  }
423
678
  else {
424
- // Aborted — discard temp, keep original data intact
679
+ // Aborted — discard temp, keep original data intact.
680
+ // Do NOT save the manifest to the real path — the in-memory manifest
681
+ // was cleared at rebuild start and only partially rebuilt. The old
682
+ // manifest on disk (at the real path) is still consistent with the
683
+ // old store data.
425
684
  effectiveStore.close().catch(() => { });
426
685
  try {
427
686
  await fs.rm(tempStorePath, { recursive: true, force: true });
428
687
  }
429
688
  catch { }
430
689
  logger.debug("Index pass cancelled; discarded temporary store.");
690
+ // Skip final manifest/keyword save since we want the old state preserved
691
+ await descCache.save();
692
+ return stats;
431
693
  }
432
694
  }
433
695
  // Save manifest and keyword index (always to the real store path — after
434
696
  // a successful swap this points to the new data; after an abort it's the old).
435
697
  await saveManifest(options.storePath, manifest);
436
698
  await options.keywordIndex?.save(options.storePath);
699
+ // Persist description cache
700
+ await descCache.save();
437
701
  // Count from the store — after a successful swap, the original handle has
438
702
  // been reopened pointing to the new directory.
439
703
  try {
@@ -66,9 +66,7 @@ export function createWatchPassScheduler(runPass, onError, debounceMs = 300) {
66
66
  rerunRequested = false;
67
67
  schedule();
68
68
  }
69
- else {
70
- resolveWaiters();
71
- }
69
+ resolveWaiters();
72
70
  }
73
71
  }
74
72
  return {
@@ -26,6 +26,8 @@ export interface WorkerResult {
26
26
  hadChunks: boolean;
27
27
  /** Whether description generation failed for this file. */
28
28
  descriptionFailed?: boolean;
29
+ /** Hash of the description config used when generating descriptions for this file. */
30
+ descHash?: string;
29
31
  }
30
32
  /** Intermediate result after chunking a file but before embedding/storing. */
31
33
  export interface PreparedFile {
@@ -53,6 +55,8 @@ export interface PreparedFile {
53
55
  docPrefix?: string;
54
56
  /** Whether the source file is an image. */
55
57
  isImageFile?: boolean;
58
+ /** Hash of the description config used when generating descriptions. */
59
+ descHash?: string;
56
60
  }
57
61
  /**
58
62
  * Build the list of text strings that will be sent to the embedding provider.
@@ -76,6 +80,7 @@ interface ManifestFile {
76
80
  hash: string;
77
81
  chunkCount: number;
78
82
  indexedAt?: number;
83
+ descHash?: string;
79
84
  }
80
85
  /**
81
86
  * Prepare a single workspace file for indexing: chunk the content, build
@@ -92,6 +97,10 @@ interface ManifestFile {
92
97
  * @param deferDescriptions - When true, skip description generation and build
93
98
  * fallback descriptions instead; descriptions are
94
99
  * expected to be generated in a later global pass.
100
+ * @param descHash - Hash of the current description config. When the file
101
+ * is unchanged and `previous.descHash === descHash`,
102
+ * descriptions are skipped entirely (they already exist
103
+ * in the vector store from a prior run).
95
104
  * @returns A prepared file descriptor ready for embedding and storage.
96
105
  */
97
106
  export declare function prepareFile(file: WorkspaceFile, cwd: string, previous: ManifestFile | undefined, config: {
@@ -104,7 +113,10 @@ export declare function prepareFile(file: WorkspaceFile, cwd: string, previous:
104
113
  description?: {
105
114
  maxContentChars?: number;
106
115
  };
107
- }, keywordIndex: KeywordIndex | undefined, descriptionProvider: DescriptionProvider | undefined, logger: Logger, deferDescriptions?: boolean): Promise<PreparedFile>;
116
+ indexing?: {
117
+ maxSvgSizeBytes?: number;
118
+ };
119
+ }, keywordIndex: KeywordIndex | undefined, descriptionProvider: DescriptionProvider | undefined, logger: Logger, deferDescriptions?: boolean, descHash?: string): Promise<PreparedFile>;
108
120
  /**
109
121
  * Store the chunks of a prepared file into the vector store, attaching their
110
122
  * computed embeddings.
@@ -139,6 +151,7 @@ export declare function processFile(file: WorkspaceFile, cwd: string, previous:
139
151
  indexing: {
140
152
  embedBatchSize: number;
141
153
  embedConcurrency?: number;
154
+ maxSvgSizeBytes?: number;
142
155
  };
143
156
  chunking?: {
144
157
  nodeTypes?: Record<string, string[]>;
@@ -146,5 +159,5 @@ export declare function processFile(file: WorkspaceFile, cwd: string, previous:
146
159
  description?: {
147
160
  maxContentChars?: number;
148
161
  };
149
- }, store: VectorStore, keywordIndex: KeywordIndex | undefined, embedder: EmbeddingProvider, descriptionProvider: DescriptionProvider | undefined, logger: Logger): Promise<WorkerResult>;
162
+ }, store: VectorStore, keywordIndex: KeywordIndex | undefined, embedder: EmbeddingProvider, descriptionProvider: DescriptionProvider | undefined, logger: Logger, descHash?: string): Promise<WorkerResult>;
150
163
  export {};
@@ -52,9 +52,13 @@ export function buildTextsToEmbed(chunks, relPath, metaHeader, docPrefix, isImag
52
52
  * @param deferDescriptions - When true, skip description generation and build
53
53
  * fallback descriptions instead; descriptions are
54
54
  * expected to be generated in a later global pass.
55
+ * @param descHash - Hash of the current description config. When the file
56
+ * is unchanged and `previous.descHash === descHash`,
57
+ * descriptions are skipped entirely (they already exist
58
+ * in the vector store from a prior run).
55
59
  * @returns A prepared file descriptor ready for embedding and storage.
56
60
  */
57
- export async function prepareFile(file, cwd, previous, config, keywordIndex, descriptionProvider, logger, deferDescriptions) {
61
+ export async function prepareFile(file, cwd, previous, config, keywordIndex, descriptionProvider, logger, deferDescriptions, descHash) {
58
62
  const fileLabel = path.relative(cwd, file.filePath).replace(/\\/g, "/");
59
63
  if (file.isEmpty) {
60
64
  return {
@@ -77,14 +81,18 @@ export async function prepareFile(file, cwd, previous, config, keywordIndex, des
77
81
  };
78
82
  }
79
83
  if (previous && previous.hash === file.hash) {
80
- return {
81
- normalizedPath: file.normalizedPath, hash: file.hash, fileLabel,
82
- isModified: false,
83
- earlyResult: {
84
- normalizedPath: file.normalizedPath, hash: file.hash, chunkCount: 0, fileLabel,
85
- isNew: false, isModified: false, isUnchanged: true, isEmpty: false, isTooSmall: false, isRemoved: false, hadChunks: false,
86
- },
87
- };
84
+ if (!descHash || previous.descHash === descHash) {
85
+ return {
86
+ normalizedPath: file.normalizedPath, hash: file.hash, fileLabel,
87
+ isModified: false,
88
+ earlyResult: {
89
+ normalizedPath: file.normalizedPath, hash: file.hash, chunkCount: 0, fileLabel,
90
+ isNew: false, isModified: false, isUnchanged: true, isEmpty: false, isTooSmall: false, isRemoved: false, hadChunks: false,
91
+ },
92
+ };
93
+ }
94
+ // File content is unchanged but description config changed — fall through to re-describe
95
+ logger.debug(` ${fileLabel} (unchanged but descHash differs — re-describing)`);
88
96
  }
89
97
  const isModified = !!previous;
90
98
  let chunks;
@@ -102,7 +110,9 @@ export async function prepareFile(file, cwd, previous, config, keywordIndex, des
102
110
  }];
103
111
  }
104
112
  else {
105
- chunks = await chunkFile(file.filePath, file.content, config.chunking?.nodeTypes).catch((err) => {
113
+ chunks = await chunkFile(file.filePath, file.content, config.chunking?.nodeTypes, {
114
+ maxSvgSizeBytes: config.indexing?.maxSvgSizeBytes,
115
+ }).catch((err) => {
106
116
  logger.warn(` ${fileLabel} (chunking failed: ${err.message})`);
107
117
  return null;
108
118
  });
@@ -152,6 +162,7 @@ export async function prepareFile(file, cwd, previous, config, keywordIndex, des
152
162
  metaHeader,
153
163
  docPrefix,
154
164
  isImageFile: isImage,
165
+ descHash,
155
166
  };
156
167
  }
157
168
  let descriptionFailed = false;
@@ -188,6 +199,7 @@ export async function prepareFile(file, cwd, previous, config, keywordIndex, des
188
199
  chunks,
189
200
  textToEmbed,
190
201
  descriptionFailed,
202
+ descHash,
191
203
  };
192
204
  }
193
205
  /**
@@ -235,6 +247,7 @@ export async function storeFileChunks(prep, embeddings, store, _logger) {
235
247
  isRemoved: false,
236
248
  hadChunks: prep.chunks.length > 0,
237
249
  descriptionFailed: prep.descriptionFailed,
250
+ descHash: prep.descHash,
238
251
  };
239
252
  }
240
253
  /**
@@ -253,8 +266,8 @@ export async function storeFileChunks(prep, embeddings, store, _logger) {
253
266
  * @param logger - Logger for diagnostic messages.
254
267
  * @returns A worker result describing the outcome.
255
268
  */
256
- export async function processFile(file, cwd, previous, config, store, keywordIndex, embedder, descriptionProvider, logger) {
257
- const prep = await prepareFile(file, cwd, previous, config, keywordIndex, descriptionProvider, logger);
269
+ export async function processFile(file, cwd, previous, config, store, keywordIndex, embedder, descriptionProvider, logger, descHash) {
270
+ const prep = await prepareFile(file, cwd, previous, config, keywordIndex, descriptionProvider, logger, false, descHash);
258
271
  if (prep.earlyResult) {
259
272
  if ((prep.earlyResult.isEmpty || prep.earlyResult.isTooSmall) && prep.earlyResult.isRemoved) {
260
273
  await store.deleteByFilePath(prep.normalizedPath);