opencode-rag-plugin 1.15.0 → 1.17.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 (232) hide show
  1. package/ReadMe.md +6 -7
  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 -1
  14. package/dist/cli/commands/index.js +1 -1
  15. package/dist/cli/commands/init-helpers.d.ts +12 -0
  16. package/dist/cli/commands/init-helpers.js +84 -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/index.js +2 -2
  23. package/dist/content/image.js +24 -3
  24. package/dist/content/reader.d.ts +6 -1
  25. package/dist/content/reader.js +49 -4
  26. package/dist/core/config.d.ts +19 -0
  27. package/dist/core/config.js +34 -4
  28. package/dist/core/desc-cache.d.ts +31 -0
  29. package/dist/core/desc-cache.js +124 -0
  30. package/dist/core/interfaces.d.ts +58 -3
  31. package/dist/core/manifest.d.ts +24 -1
  32. package/dist/core/manifest.js +34 -3
  33. package/dist/core/resolve-api-key.js +4 -2
  34. package/dist/core/runtime-overrides.js +9 -7
  35. package/dist/core/setup-runtime.d.ts +23 -0
  36. package/dist/core/setup-runtime.js +183 -0
  37. package/dist/core/version-check.d.ts +9 -0
  38. package/dist/core/version-check.js +49 -0
  39. package/dist/describer/anthropic.d.ts +2 -2
  40. package/dist/describer/anthropic.js +5 -7
  41. package/dist/describer/describer.d.ts +2 -2
  42. package/dist/describer/describer.js +6 -8
  43. package/dist/describer/gemini.d.ts +2 -2
  44. package/dist/describer/gemini.js +5 -7
  45. package/dist/embedder/factory.d.ts +3 -1
  46. package/dist/embedder/factory.js +7 -1
  47. package/dist/embedder/health.js +4 -0
  48. package/dist/embedder/http.d.ts +1 -1
  49. package/dist/embedder/http.js +74 -43
  50. package/dist/eval/compare-merge.d.ts +10 -0
  51. package/dist/eval/compare-merge.js +537 -0
  52. package/dist/eval/compare-rankings.d.ts +10 -0
  53. package/dist/eval/compare-rankings.js +245 -0
  54. package/dist/eval/dump-descriptions.d.ts +7 -0
  55. package/dist/eval/dump-descriptions.js +58 -0
  56. package/dist/eval/fast-index.d.ts +8 -0
  57. package/dist/eval/fast-index.js +283 -0
  58. package/dist/eval/run-branch-compare.d.ts +7 -0
  59. package/dist/eval/run-branch-compare.js +220 -0
  60. package/dist/eval/run-token-test.js +1 -0
  61. package/dist/eval/test-kw.d.ts +1 -0
  62. package/dist/eval/test-kw.js +22 -0
  63. package/dist/eval/update-descriptions.d.ts +7 -0
  64. package/dist/eval/update-descriptions.js +84 -0
  65. package/dist/index.d.ts +1 -0
  66. package/dist/index.js +1 -0
  67. package/dist/indexer/embed-stage.js +2 -1
  68. package/dist/indexer/git-diff.js +21 -9
  69. package/dist/indexer/pipeline.d.ts +6 -0
  70. package/dist/indexer/pipeline.js +290 -37
  71. package/dist/indexer/watch.js +1 -3
  72. package/dist/indexer/worker.d.ts +15 -2
  73. package/dist/indexer/worker.js +25 -12
  74. package/dist/mcp/handlers.d.ts +9 -0
  75. package/dist/mcp/handlers.js +23 -6
  76. package/dist/mcp/server.js +2 -0
  77. package/dist/opencode/create-read-tool.d.ts +2 -0
  78. package/dist/opencode/create-read-tool.js +8 -2
  79. package/dist/opencode/read-fallback.d.ts +1 -5
  80. package/dist/opencode/read-fallback.js +1 -18
  81. package/dist/opencode/read-format.js +5 -3
  82. package/dist/opencode/tools.js +5 -7
  83. package/dist/plugin.js +196 -81
  84. package/dist/retriever/keyword-index.d.ts +3 -2
  85. package/dist/retriever/keyword-index.js +25 -1
  86. package/dist/retriever/retriever.d.ts +4 -1
  87. package/dist/retriever/retriever.js +34 -56
  88. package/dist/vectorstore/lancedb.d.ts +35 -4
  89. package/dist/vectorstore/lancedb.js +146 -23
  90. package/dist/vectorstore/memory.d.ts +6 -1
  91. package/dist/vectorstore/memory.js +58 -0
  92. package/dist/watcher.js +3 -0
  93. package/dist/web/api.js +10 -2
  94. package/dist/web/server.js +18 -3
  95. package/package.json +8 -9
  96. package/scripts/postinstall-setup.js +82 -0
  97. package/dist/api.js.map +0 -1
  98. package/dist/chunker/base.js.map +0 -1
  99. package/dist/chunker/bash.js.map +0 -1
  100. package/dist/chunker/c.js.map +0 -1
  101. package/dist/chunker/cpp.js.map +0 -1
  102. package/dist/chunker/csharp.js.map +0 -1
  103. package/dist/chunker/css.js.map +0 -1
  104. package/dist/chunker/doc.js.map +0 -1
  105. package/dist/chunker/dockerfile.js.map +0 -1
  106. package/dist/chunker/docx.js.map +0 -1
  107. package/dist/chunker/excel.js.map +0 -1
  108. package/dist/chunker/factory.js.map +0 -1
  109. package/dist/chunker/fallback.js.map +0 -1
  110. package/dist/chunker/go.js.map +0 -1
  111. package/dist/chunker/grammar.js.map +0 -1
  112. package/dist/chunker/html.js.map +0 -1
  113. package/dist/chunker/image.js.map +0 -1
  114. package/dist/chunker/ini.js.map +0 -1
  115. package/dist/chunker/java.js.map +0 -1
  116. package/dist/chunker/javascript.js.map +0 -1
  117. package/dist/chunker/json.js.map +0 -1
  118. package/dist/chunker/kotlin.js.map +0 -1
  119. package/dist/chunker/loader.js.map +0 -1
  120. package/dist/chunker/markdown.js.map +0 -1
  121. package/dist/chunker/pdf.js.map +0 -1
  122. package/dist/chunker/php.js.map +0 -1
  123. package/dist/chunker/powershell.js.map +0 -1
  124. package/dist/chunker/python.js.map +0 -1
  125. package/dist/chunker/razor.js.map +0 -1
  126. package/dist/chunker/ruby.js.map +0 -1
  127. package/dist/chunker/rust.js.map +0 -1
  128. package/dist/chunker/sln.js.map +0 -1
  129. package/dist/chunker/sql.js.map +0 -1
  130. package/dist/chunker/ssl.js.map +0 -1
  131. package/dist/chunker/swift.js.map +0 -1
  132. package/dist/chunker/tex.js.map +0 -1
  133. package/dist/chunker/toml.js.map +0 -1
  134. package/dist/chunker/typescript.js.map +0 -1
  135. package/dist/chunker/uuid.js.map +0 -1
  136. package/dist/chunker/xml.js.map +0 -1
  137. package/dist/chunker/yaml.js.map +0 -1
  138. package/dist/cli/commands/clear.js.map +0 -1
  139. package/dist/cli/commands/describe-image.js.map +0 -1
  140. package/dist/cli/commands/dump.js.map +0 -1
  141. package/dist/cli/commands/eval.js.map +0 -1
  142. package/dist/cli/commands/index-command.js.map +0 -1
  143. package/dist/cli/commands/index.js.map +0 -1
  144. package/dist/cli/commands/init-helpers.js.map +0 -1
  145. package/dist/cli/commands/init.js.map +0 -1
  146. package/dist/cli/commands/list.js.map +0 -1
  147. package/dist/cli/commands/mcp.js.map +0 -1
  148. package/dist/cli/commands/query.js.map +0 -1
  149. package/dist/cli/commands/show.js.map +0 -1
  150. package/dist/cli/commands/status.js.map +0 -1
  151. package/dist/cli/commands/ui.js.map +0 -1
  152. package/dist/cli/commands/update.d.ts +0 -17
  153. package/dist/cli/commands/update.js +0 -79
  154. package/dist/cli/commands/update.js.map +0 -1
  155. package/dist/cli/format.js.map +0 -1
  156. package/dist/cli/helpers.js.map +0 -1
  157. package/dist/cli/index.js.map +0 -1
  158. package/dist/cli/progress.d.ts +0 -42
  159. package/dist/cli/progress.js +0 -137
  160. package/dist/cli/progress.js.map +0 -1
  161. package/dist/cli/types.js.map +0 -1
  162. package/dist/cli.js.map +0 -1
  163. package/dist/content/doc.js.map +0 -1
  164. package/dist/content/docx.js.map +0 -1
  165. package/dist/content/excel.js.map +0 -1
  166. package/dist/content/image.js.map +0 -1
  167. package/dist/content/pdf.js.map +0 -1
  168. package/dist/content/reader.js.map +0 -1
  169. package/dist/content/types.js.map +0 -1
  170. package/dist/core/bootstrap.js.map +0 -1
  171. package/dist/core/config.js.map +0 -1
  172. package/dist/core/doc-progress.js.map +0 -1
  173. package/dist/core/fileLogger.js.map +0 -1
  174. package/dist/core/interfaces.js.map +0 -1
  175. package/dist/core/manifest.js.map +0 -1
  176. package/dist/core/provider-defaults.js.map +0 -1
  177. package/dist/core/rag-injection-flag.js.map +0 -1
  178. package/dist/core/resolve-api-key.js.map +0 -1
  179. package/dist/core/runtime-overrides.js.map +0 -1
  180. package/dist/describer/anthropic.js.map +0 -1
  181. package/dist/describer/describer.js.map +0 -1
  182. package/dist/describer/factory.js.map +0 -1
  183. package/dist/describer/gemini.js.map +0 -1
  184. package/dist/describer/shared.js.map +0 -1
  185. package/dist/embedder/cohere.js.map +0 -1
  186. package/dist/embedder/factory.js.map +0 -1
  187. package/dist/embedder/health.js.map +0 -1
  188. package/dist/embedder/http.js.map +0 -1
  189. package/dist/embedder/ollama.js.map +0 -1
  190. package/dist/embedder/openai.js.map +0 -1
  191. package/dist/eval/index.js.map +0 -1
  192. package/dist/eval/run-token-test.js.map +0 -1
  193. package/dist/eval/session-logger.js.map +0 -1
  194. package/dist/eval/storage.js.map +0 -1
  195. package/dist/eval/token-analysis.js.map +0 -1
  196. package/dist/eval/token-counter.js.map +0 -1
  197. package/dist/eval/types.js.map +0 -1
  198. package/dist/index.js.map +0 -1
  199. package/dist/indexer/description-stage.js.map +0 -1
  200. package/dist/indexer/embed-stage.js.map +0 -1
  201. package/dist/indexer/git-diff.js.map +0 -1
  202. package/dist/indexer/metadata.js.map +0 -1
  203. package/dist/indexer/pipeline.js.map +0 -1
  204. package/dist/indexer/stats.js.map +0 -1
  205. package/dist/indexer/watch.js.map +0 -1
  206. package/dist/indexer/worker.js.map +0 -1
  207. package/dist/indexer.js.map +0 -1
  208. package/dist/mcp/cli.js.map +0 -1
  209. package/dist/mcp/handlers.js.map +0 -1
  210. package/dist/mcp/server.js.map +0 -1
  211. package/dist/opencode/create-read-tool.js.map +0 -1
  212. package/dist/opencode/read-fallback.js.map +0 -1
  213. package/dist/opencode/read-format.js.map +0 -1
  214. package/dist/opencode/read-query.js.map +0 -1
  215. package/dist/opencode/tool-args.js.map +0 -1
  216. package/dist/opencode/tools.js.map +0 -1
  217. package/dist/plugin-entry.js.map +0 -1
  218. package/dist/plugin.js.map +0 -1
  219. package/dist/retriever/context-optimizer.js.map +0 -1
  220. package/dist/retriever/keyword-index.js.map +0 -1
  221. package/dist/retriever/retriever.js.map +0 -1
  222. package/dist/tui.js.map +0 -1
  223. package/dist/updater.d.ts +0 -45
  224. package/dist/updater.js +0 -175
  225. package/dist/updater.js.map +0 -1
  226. package/dist/vectorstore/factory.js.map +0 -1
  227. package/dist/vectorstore/lancedb.js.map +0 -1
  228. package/dist/vectorstore/memory.js.map +0 -1
  229. package/dist/watcher.js.map +0 -1
  230. package/dist/web/api.js.map +0 -1
  231. package/dist/web/server.js.map +0 -1
  232. 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();
@@ -237,12 +276,19 @@ async function runIndexPassInner(options, logger) {
237
276
  if (isActive) {
238
277
  options.progress?.startFile(fileLabel);
239
278
  }
240
- const prep = await prepareFile(file, options.cwd, manifest.files[file.normalizedPath], options.config, options.keywordIndex, options.descriptionProvider, logger, deferDescriptions);
279
+ const prep = await prepareFile(file, options.cwd, manifest.files[file.normalizedPath], options.config, options.keywordIndex, options.descriptionProvider, logger, deferDescriptions, descHash);
241
280
  if (prep.earlyResult && isActive) {
242
281
  options.progress?.finishFile(fileLabel);
243
282
  }
244
283
  return prep;
245
284
  })));
285
+ const aborted = () => options.abortSignal?.aborted ?? false;
286
+ // Shared progress logger: "stage <file> (chunk i/n) — X/total remaining (P%)".
287
+ const logChunkProgress = (stage, fileLabel, index, count, completed, total) => {
288
+ const remaining = total - completed;
289
+ const pct = total > 0 ? ((remaining / total) * 100).toFixed(1) : "0.0";
290
+ logger.info(`${stage} ${fileLabel} (chunk ${index}/${count}) — ${remaining}/${total} remaining (${pct}%)`);
291
+ };
246
292
  if (deferDescriptions) {
247
293
  const deferredPreps = prepared.filter((p) => p.chunks && p.chunks.length > 0 && p.relPath !== undefined);
248
294
  if (deferredPreps.length > 0) {
@@ -264,13 +310,139 @@ async function runIndexPassInner(options, logger) {
264
310
  for (const chunk of oversizedChunks) {
265
311
  chunk.description = buildFallbackDescription(chunk);
266
312
  }
313
+ // Per-file chunk index + label for clear progress reporting, plus a shared
314
+ // progress logger emitting "stage <file> (chunk i/n) — X/total remaining (P%)".
315
+ const chunkToFileLabel = new Map();
316
+ for (const prep of deferredPreps) {
317
+ for (const chunk of prep.chunks ?? []) {
318
+ chunkToFileLabel.set(chunk.id, prep.fileLabel);
319
+ }
320
+ }
321
+ const chunkMeta = new Map();
322
+ {
323
+ const perFileCount = new Map();
324
+ for (const chunk of allChunks) {
325
+ perFileCount.set(chunk.metadata.filePath, (perFileCount.get(chunk.metadata.filePath) ?? 0) + 1);
326
+ }
327
+ const perFileSeen = new Map();
328
+ for (const chunk of allChunks) {
329
+ const seen = perFileSeen.get(chunk.metadata.filePath) ?? 0;
330
+ perFileSeen.set(chunk.metadata.filePath, seen + 1);
331
+ chunkMeta.set(chunk.id, {
332
+ fileLabel: chunkToFileLabel.get(chunk.id) ?? chunk.metadata.filePath,
333
+ index: seen + 1,
334
+ count: perFileCount.get(chunk.metadata.filePath) ?? 1,
335
+ });
336
+ }
337
+ }
267
338
  // Advance progress to Description stage before descriptions start
268
339
  for (const prep of deferredPreps) {
269
340
  options.progress?.finishStage(prep.fileLabel);
270
341
  }
271
- if (allChunks.length > 0) {
342
+ if (allChunks.length > 0 && descHash) {
343
+ // Check description cache first — reuse cached descriptions for unchanged chunks
344
+ const cacheHits = [];
345
+ const cacheMisses = [];
346
+ for (const chunk of allChunks) {
347
+ const cacheKey = DescriptionCache.codeKey(chunk.content, descHash);
348
+ const cached = descCache.get(cacheKey);
349
+ if (cached) {
350
+ cacheHits.push({ chunk, desc: cached });
351
+ }
352
+ else {
353
+ cacheMisses.push(chunk);
354
+ }
355
+ }
356
+ if (cacheHits.length > 0) {
357
+ logger.debug(` Using ${cacheHits.length} cached descriptions`);
358
+ for (const { chunk, desc } of cacheHits) {
359
+ chunk.description = desc;
360
+ }
361
+ }
362
+ // Build chunkId → prep map for tracking description failures per-file
363
+ const chunkToPrep = new Map();
364
+ for (const prep of deferredPreps) {
365
+ for (const chunk of prep.chunks ?? []) {
366
+ chunkToPrep.set(chunk.id, prep);
367
+ }
368
+ }
369
+ // Process cache misses in parallel waves with throttled cache saves.
370
+ // Parallel sub-batches reduce serial LLM turnaround time.
371
+ // Cache saves are throttled to avoid O(n^2) full-JSON serialization I/O.
372
+ if (cacheMisses.length > 0) {
373
+ const totalMisses = cacheMisses.length;
374
+ const descConcurrency = options.config.indexing.descriptionConcurrency ?? 4;
375
+ const SUB_BATCH = 10;
376
+ let describedDone = 0;
377
+ let describedSinceLastSave = 0;
378
+ const SAVE_INTERVAL = 200;
379
+ const descLimit = pLimit(descConcurrency);
380
+ const subBatchTasks = [];
381
+ for (let i = 0; i < totalMisses; i += SUB_BATCH) {
382
+ subBatchTasks.push({ i, chunks: cacheMisses.slice(i, i + SUB_BATCH) });
383
+ }
384
+ await Promise.all(subBatchTasks.map(({ i, chunks: subBatch }) => descLimit(async () => {
385
+ if (aborted())
386
+ return;
387
+ try {
388
+ const batchResult = await options.descriptionProvider.generateBatchDescriptions(subBatch, logger, {
389
+ total: totalMisses,
390
+ onProgress: (chunk) => {
391
+ describedDone++;
392
+ const meta = chunkMeta.get(chunk.id);
393
+ if (meta)
394
+ logChunkProgress("Describing", meta.fileLabel, meta.index, meta.count, describedDone, totalMisses);
395
+ },
396
+ });
397
+ const newCacheEntries = [];
398
+ for (const chunk of subBatch) {
399
+ const desc = batchResult.get(chunk.id);
400
+ if (desc && desc.trim().length > 0) {
401
+ chunk.description = desc;
402
+ newCacheEntries.push([DescriptionCache.codeKey(chunk.content, descHash), desc]);
403
+ }
404
+ }
405
+ if (newCacheEntries.length > 0) {
406
+ descCache.setMany(newCacheEntries);
407
+ describedSinceLastSave += newCacheEntries.length;
408
+ if (describedSinceLastSave >= SAVE_INTERVAL) {
409
+ await descCache.save();
410
+ describedSinceLastSave = 0;
411
+ }
412
+ }
413
+ }
414
+ catch (err) {
415
+ logger.warn(` Description sub-batch failed (${i}-${i + subBatch.length}): ${err.message}`);
416
+ const failedPreps = new Set();
417
+ for (const chunk of subBatch) {
418
+ const prep = chunkToPrep.get(chunk.id);
419
+ if (prep)
420
+ failedPreps.add(prep);
421
+ }
422
+ for (const prep of failedPreps) {
423
+ prep.descriptionFailed = true;
424
+ }
425
+ }
426
+ })));
427
+ if (describedSinceLastSave > 0) {
428
+ await descCache.save();
429
+ }
430
+ logger.debug(`Descriptions generated for ${totalMisses} chunks (concurrency: ${descConcurrency})`);
431
+ }
432
+ }
433
+ else if (allChunks.length > 0) {
434
+ // No descHash available (no description provider) — still generate descriptions
435
+ let describedDone = 0;
272
436
  try {
273
- const batchResult = await options.descriptionProvider.generateBatchDescriptions(allChunks, logger);
437
+ const batchResult = await options.descriptionProvider.generateBatchDescriptions(allChunks, logger, {
438
+ total: allChunks.length,
439
+ onProgress: (chunk) => {
440
+ describedDone++;
441
+ const meta = chunkMeta.get(chunk.id);
442
+ if (meta)
443
+ logChunkProgress("Describing", meta.fileLabel, meta.index, meta.count, describedDone, allChunks.length);
444
+ },
445
+ });
274
446
  for (const chunk of allChunks) {
275
447
  const desc = batchResult.get(chunk.id);
276
448
  if (desc && desc.trim().length > 0) {
@@ -292,10 +464,9 @@ async function runIndexPassInner(options, logger) {
292
464
  }
293
465
  }
294
466
  }
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.
467
+ // Cross-file embedding batch: collect all texts into a single queue,
468
+ // embed in one batched call (with concurrency), then distribute back.
297
469
  const isOllama = options.embedder.name === "ollama";
298
- const ollamaMaxBatch = options.config.indexing.ollamaMaxBatchSize ?? 4000;
299
470
  const defaultBatchSize = options.config.indexing.embedBatchSize;
300
471
  const defaultConcurrency = options.config.indexing.embedConcurrency ?? 1;
301
472
  // File metadata look-up for manifest entries
@@ -303,48 +474,100 @@ async function runIndexPassInner(options, logger) {
303
474
  // Serialised manifest-save queue — prevents concurrent write races and acts
304
475
  // as a checkpoint for Ctrl+C resilience. Each worker appends to this chain
305
476
  // after a successful store, so previously completed files are never lost.
477
+ // During a temp-store rebuild, saves go to the temp path so the real
478
+ // manifest stays consistent with the real store if the process is aborted.
479
+ const manifestTargetPath = () => tempStorePath ?? options.storePath;
306
480
  let manifestSaveChain = Promise.resolve(undefined);
307
481
  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
- }
482
+ manifestSaveChain = manifestSaveChain.then(() => saveManifest(manifestTargetPath(), manifest).catch((err) => {
483
+ options.logger?.warn?.(`Failed to save manifest: ${err.message}`);
484
+ }));
485
+ }
486
+ // ── Phase 1: Collect embed queue + handle early results ────────────────
487
+ const embedQueue = [];
488
+ let totalEmbedChunks = 0;
489
+ const earlyWorkerResults = new Map();
490
+ for (let fi = 0; fi < prepared.length; fi++) {
491
+ const prep = prepared[fi];
316
492
  if (prep.earlyResult) {
317
- // Remove stale manifest/store entries for files that no longer produce chunks
318
493
  if (prep.earlyResult.isRemoved) {
319
494
  await effectiveStore.deleteByFilePath(prep.normalizedPath);
320
495
  options.keywordIndex?.removeByFilePath(prep.normalizedPath);
321
496
  delete manifest.files[prep.normalizedPath];
322
497
  enqueueManifestSave();
323
498
  }
324
- return prep.earlyResult;
499
+ earlyWorkerResults.set(fi, prep.earlyResult);
500
+ continue;
325
501
  }
326
502
  if (!prep.chunks || !prep.textToEmbed || prep.textToEmbed.length === 0) {
327
503
  options.progress?.finishFile(prep.fileLabel);
328
- return {
504
+ earlyWorkerResults.set(fi, {
329
505
  normalizedPath: prep.normalizedPath, hash: prep.hash, chunkCount: 0,
330
506
  fileLabel: prep.fileLabel,
331
507
  isNew: false, isModified: false, isUnchanged: false, isEmpty: false,
332
508
  isTooSmall: false, isRemoved: true, hadChunks: false,
333
509
  descriptionFailed: prep.descriptionFailed,
334
- };
510
+ });
511
+ continue;
335
512
  }
336
- const batchSize = isOllama
337
- ? Math.min(prep.textToEmbed.length, ollamaMaxBatch)
338
- : defaultBatchSize;
339
- const concurrency = isOllama ? 1 : defaultConcurrency;
340
513
  options.progress?.finishStage(prep.fileLabel);
341
- let embeddings;
514
+ for (let ci = 0; ci < prep.textToEmbed.length; ci++) {
515
+ embedQueue.push({ fileIdx: fi, chunkIdx: ci, text: prep.textToEmbed[ci] });
516
+ }
517
+ totalEmbedChunks += prep.textToEmbed.length;
518
+ }
519
+ // ── Phase 2: Embed all texts in a single batched call ──────────────────
520
+ const batchSize = isOllama
521
+ ? Math.min(options.config.indexing.ollamaMaxBatchSize ?? 4000, defaultBatchSize)
522
+ : defaultBatchSize;
523
+ let embeddedDone = 0;
524
+ const allTexts = embedQueue.map(item => item.text);
525
+ let allEmbeddings = [];
526
+ if (allTexts.length > 0) {
342
527
  try {
343
- embeddings = await embedBatch(options.embedder, prep.textToEmbed, batchSize, "document", concurrency);
528
+ allEmbeddings = await embedBatch(options.embedder, allTexts, batchSize, "document", defaultConcurrency, (completed, total) => {
529
+ embeddedDone = completed;
530
+ logChunkProgress("Embedding", "", completed, total, embeddedDone, totalEmbedChunks);
531
+ });
344
532
  }
345
533
  catch (err) {
346
- logger.warn(` ${prep.fileLabel} (embedding failed: ${err.message})`);
347
- options.progress?.failFile(prep.fileLabel);
534
+ logger.warn(` Global embedding failed: ${err.message}`);
535
+ for (const { fileIdx } of embedQueue) {
536
+ options.progress?.failFile(prepared[fileIdx].fileLabel);
537
+ earlyWorkerResults.set(fileIdx, {
538
+ normalizedPath: prepared[fileIdx].normalizedPath,
539
+ hash: prepared[fileIdx].hash,
540
+ chunkCount: 0, fileLabel: prepared[fileIdx].fileLabel,
541
+ isNew: false, isModified: false, isUnchanged: false, isEmpty: false,
542
+ isTooSmall: false, isRemoved: true, hadChunks: false,
543
+ descriptionFailed: prepared[fileIdx].descriptionFailed,
544
+ });
545
+ }
546
+ embedQueue.length = 0; // prevent double-processing in store phase
547
+ }
548
+ }
549
+ // ── Distribute embeddings back to per-file chunks ─────────────────────
550
+ for (let i = 0; i < embedQueue.length; i++) {
551
+ const { fileIdx, chunkIdx } = embedQueue[i];
552
+ const emb = allEmbeddings[i];
553
+ const prep = prepared[fileIdx];
554
+ if (prep.chunks && prep.chunks[chunkIdx] && Array.isArray(emb) && emb.length > 0 && typeof emb[0] === "number") {
555
+ prep.chunks[chunkIdx].embedding = emb;
556
+ }
557
+ }
558
+ // ── Phase 3: Store + manifest update per file (parallel) ──────────────
559
+ const storeLimit = pLimit(options.config.indexing.concurrency);
560
+ const storeResults = await Promise.all(prepared.map((prep, fi) => storeLimit(async () => {
561
+ if (aborted()) {
562
+ return { normalizedPath: prep.normalizedPath, skipped: true };
563
+ }
564
+ // Return early results from phase 1
565
+ const earlyResult = earlyWorkerResults.get(fi);
566
+ if (earlyResult)
567
+ return earlyResult;
568
+ // No-embed path (shouldn't reach here but guard anyway)
569
+ if (!prep.chunks || prep.textToEmbed?.length === 0) {
570
+ options.progress?.finishFile(prep.fileLabel);
348
571
  return {
349
572
  normalizedPath: prep.normalizedPath, hash: prep.hash, chunkCount: 0,
350
573
  fileLabel: prep.fileLabel,
@@ -353,12 +576,30 @@ async function runIndexPassInner(options, logger) {
353
576
  descriptionFailed: prep.descriptionFailed,
354
577
  };
355
578
  }
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 ──
579
+ // Store chunks with pre-attached embeddings
580
+ const validChunks = (prep.chunks ?? []).filter((c) => c.embedding && c.embedding.length > 0);
581
+ if (validChunks.length > 0) {
582
+ await effectiveStore.addChunks(validChunks);
583
+ }
584
+ const result = {
585
+ normalizedPath: prep.normalizedPath,
586
+ hash: prep.hash,
587
+ chunkCount: prep.chunks?.length ?? 0,
588
+ fileLabel: prep.fileLabel,
589
+ isNew: !prep.isModified,
590
+ isModified: prep.isModified,
591
+ isUnchanged: false,
592
+ isEmpty: false,
593
+ isTooSmall: false,
594
+ isRemoved: validChunks.length === 0,
595
+ hadChunks: (prep.chunks?.length ?? 0) > 0,
596
+ descriptionFailed: prep.descriptionFailed,
597
+ descHash: prep.descHash,
598
+ };
599
+ // Update manifest
359
600
  if (result.chunkCount > 0 && !result.isRemoved) {
360
601
  const meta = fileMeta.get(result.normalizedPath);
361
- manifest.files[result.normalizedPath] = {
602
+ const entry = {
362
603
  hash: result.hash,
363
604
  chunkCount: result.chunkCount,
364
605
  indexedAt: Date.now(),
@@ -366,6 +607,10 @@ async function runIndexPassInner(options, logger) {
366
607
  size: meta?.size,
367
608
  descriptionFailed: result.descriptionFailed,
368
609
  };
610
+ if (result.descHash) {
611
+ entry.descHash = result.descHash;
612
+ }
613
+ manifest.files[result.normalizedPath] = entry;
369
614
  enqueueManifestSave();
370
615
  }
371
616
  else if (result.isRemoved) {
@@ -375,6 +620,7 @@ async function runIndexPassInner(options, logger) {
375
620
  options.progress?.finishFile(prep.fileLabel);
376
621
  return result;
377
622
  })));
623
+ const workerResults = storeResults;
378
624
  const finalResults = [];
379
625
  for (const r of workerResults) {
380
626
  if (r.skipped)
@@ -406,9 +652,7 @@ async function runIndexPassInner(options, logger) {
406
652
  // Swap the newly-built temp directory into the real path
407
653
  await swapStoreDirectories(tempStorePath, options.storePath);
408
654
  // 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
- }
655
+ await options.store.reopen?.(options.storePath);
412
656
  logger.debug(`Promoted temporary store ${tempStorePath} → ${options.storePath}`);
413
657
  }
414
658
  catch (err) {
@@ -421,19 +665,28 @@ async function runIndexPassInner(options, logger) {
421
665
  }
422
666
  }
423
667
  else {
424
- // Aborted — discard temp, keep original data intact
668
+ // Aborted — discard temp, keep original data intact.
669
+ // Do NOT save the manifest to the real path — the in-memory manifest
670
+ // was cleared at rebuild start and only partially rebuilt. The old
671
+ // manifest on disk (at the real path) is still consistent with the
672
+ // old store data.
425
673
  effectiveStore.close().catch(() => { });
426
674
  try {
427
675
  await fs.rm(tempStorePath, { recursive: true, force: true });
428
676
  }
429
677
  catch { }
430
678
  logger.debug("Index pass cancelled; discarded temporary store.");
679
+ // Skip final manifest/keyword save since we want the old state preserved
680
+ await descCache.save();
681
+ return stats;
431
682
  }
432
683
  }
433
684
  // Save manifest and keyword index (always to the real store path — after
434
685
  // a successful swap this points to the new data; after an abort it's the old).
435
686
  await saveManifest(options.storePath, manifest);
436
687
  await options.keywordIndex?.save(options.storePath);
688
+ // Persist description cache
689
+ await descCache.save();
437
690
  // Count from the store — after a successful swap, the original handle has
438
691
  // been reopened pointing to the new directory.
439
692
  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);
@@ -5,6 +5,15 @@ import type { EmbeddingProvider, VectorStore, KeywordIndex, SearchResult } from
5
5
  import type { RagConfig } from "../core/config.js";
6
6
  import { type ImageVisionProvider } from "../chunker/image.js";
7
7
  import { retrieve } from "../retriever/retriever.js";
8
+ /**
9
+ * Resolve a user-supplied file path against the worktree root, refusing to
10
+ * escape the worktree. Absolute paths are accepted only if they already reside
11
+ * under the worktree; relative paths are joined to the worktree and any `..`
12
+ * segments that would escape are rejected.
13
+ *
14
+ * @throws {Error} if the resolved path lies outside `worktree`.
15
+ */
16
+ export declare function resolveFilePath(filePath: string, worktree: string): string;
8
17
  /** Parameters for the search_semantic MCP tool. */
9
18
  export interface SearchSemanticParams {
10
19
  /** Natural language search query. */