opencode-rag-plugin 1.19.3 → 1.19.5

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 (82) hide show
  1. package/dist/api.d.ts +1 -1
  2. package/dist/api.js +2 -0
  3. package/dist/chunker/base.js +19 -5
  4. package/dist/chunker/factory.js +27 -9
  5. package/dist/chunker/grammar.d.ts +18 -1
  6. package/dist/chunker/grammar.js +48 -10
  7. package/dist/chunker/pdf.js +30 -14
  8. package/dist/cli/commands/init-helpers.js +15 -2
  9. package/dist/cli/commands/init.js +31 -21
  10. package/dist/cli/commands/query.js +2 -0
  11. package/dist/cli/commands/quirk.js +9 -3
  12. package/dist/cli/commands/setup.js +5 -2
  13. package/dist/cli/commands/status.js +14 -5
  14. package/dist/cli/commands/ui.js +23 -9
  15. package/dist/cli/commands/update.js +4 -5
  16. package/dist/cli/format.d.ts +5 -2
  17. package/dist/cli/format.js +14 -5
  18. package/dist/content/image.js +33 -11
  19. package/dist/content/reader.js +79 -17
  20. package/dist/core/bootstrap.js +10 -3
  21. package/dist/core/config.js +31 -0
  22. package/dist/core/desc-cache.d.ts +8 -2
  23. package/dist/core/desc-cache.js +10 -3
  24. package/dist/core/doc-progress.js +5 -2
  25. package/dist/core/interfaces.d.ts +8 -0
  26. package/dist/core/interfaces.js +8 -1
  27. package/dist/core/provider-defaults.d.ts +2 -0
  28. package/dist/core/provider-defaults.js +19 -4
  29. package/dist/core/runtime-overrides.d.ts +0 -6
  30. package/dist/core/version-check.d.ts +5 -0
  31. package/dist/core/version-check.js +8 -2
  32. package/dist/describer/anthropic.d.ts +2 -2
  33. package/dist/describer/anthropic.js +19 -5
  34. package/dist/describer/describer.js +15 -2
  35. package/dist/describer/gemini.js +25 -10
  36. package/dist/embedder/factory.d.ts +5 -3
  37. package/dist/embedder/factory.js +41 -8
  38. package/dist/embedder/health.js +19 -19
  39. package/dist/embedder/http.d.ts +14 -1
  40. package/dist/embedder/http.js +60 -6
  41. package/dist/eval/session-logger.js +7 -0
  42. package/dist/eval/storage.js +8 -0
  43. package/dist/indexer/git-diff.d.ts +1 -1
  44. package/dist/indexer/git-diff.js +5 -1
  45. package/dist/indexer/pipeline.js +421 -344
  46. package/dist/indexer/stats.d.ts +2 -0
  47. package/dist/indexer/stats.js +1 -0
  48. package/dist/indexer/watch.js +8 -1
  49. package/dist/indexer/worker.js +21 -0
  50. package/dist/mcp/cli.js +4 -0
  51. package/dist/mcp/handlers.d.ts +1 -1
  52. package/dist/mcp/handlers.js +23 -6
  53. package/dist/mcp/server.js +3 -0
  54. package/dist/opencode/create-read-tool.d.ts +1 -1
  55. package/dist/opencode/create-read-tool.js +17 -5
  56. package/dist/opencode/tool-args.js +23 -1
  57. package/dist/opencode/tools.d.ts +1 -1
  58. package/dist/opencode/tools.js +3 -1
  59. package/dist/plugin.d.ts +1 -1
  60. package/dist/plugin.js +69 -152
  61. package/dist/quirks/auto-capture.js +5 -0
  62. package/dist/quirks/quirk-store.d.ts +1 -1
  63. package/dist/quirks/quirk-store.js +56 -17
  64. package/dist/retriever/context-optimizer.js +18 -4
  65. package/dist/retriever/keyword-index.d.ts +2 -0
  66. package/dist/retriever/keyword-index.js +38 -4
  67. package/dist/retriever/retriever.js +6 -1
  68. package/dist/tui.js +41 -4
  69. package/dist/vectorstore/lancedb.d.ts +25 -1
  70. package/dist/vectorstore/lancedb.js +157 -11
  71. package/dist/vectorstore/memory.js +5 -1
  72. package/dist/watcher.js +30 -4
  73. package/dist/web/api.d.ts +6 -2
  74. package/dist/web/api.js +198 -70
  75. package/dist/web/server.d.ts +2 -0
  76. package/dist/web/server.js +66 -28
  77. package/dist/web/static.d.ts +5 -2
  78. package/dist/web/static.js +9 -5
  79. package/dist/web/ui/assets/index-BDPYdtA1.js +3 -0
  80. package/dist/web/ui/index.html +1 -1
  81. package/package.json +1 -1
  82. package/dist/web/ui/assets/index-CJBvt6e0.js +0 -3
@@ -66,9 +66,18 @@ export async function runIndexPass(options) {
66
66
  if (lock.pid && !isPidAlive(lock.pid)) {
67
67
  logger.debug(`Stale lock from dead process ${lock.pid} — continuing`);
68
68
  }
69
+ else if (lock.pid) {
70
+ // A LIVE process owns the lock — always skip, regardless of age.
71
+ // (Long re-indexes routinely exceed LOCK_MAX_AGE_MS; allowing a second
72
+ // process through the age check defeats the lock entirely and causes
73
+ // concurrent LanceDB writes.)
74
+ logger.warn(`Another index pass is running (PID ${lock.pid}). Skipping.`);
75
+ return { ...createIndexStats(0, "missing"), skipped: true };
76
+ }
69
77
  else if (lock.startedAt && age < LOCK_MAX_AGE_MS) {
70
- logger.warn(`Another index pass is running (PID ${lock.pid ?? "unknown"}). Skipping.`);
71
- return createIndexStats(0, "missing");
78
+ // No readable PID but a fresh lock treat as active.
79
+ logger.warn(`Another index pass is running (no PID). Skipping.`);
80
+ return { ...createIndexStats(0, "missing"), skipped: true };
72
81
  }
73
82
  }
74
83
  catch {
@@ -77,8 +86,10 @@ export async function runIndexPass(options) {
77
86
  try {
78
87
  await fs.writeFile(lockPath, JSON.stringify({ pid: process.pid, startedAt: Date.now() }), "utf-8");
79
88
  }
80
- catch {
81
- // Best-effort lock
89
+ catch (err) {
90
+ // Best-effort lock — but surface it: without the lock file, the
91
+ // concurrent-pass protection above silently stops working.
92
+ logger.debug(`Could not write index lock file: ${err.message} — concurrent-pass protection disabled`);
82
93
  }
83
94
  try {
84
95
  return await runIndexPassInner(options, logger);
@@ -159,7 +170,24 @@ async function runIndexPassInner(options, logger) {
159
170
  changedSet.add(f);
160
171
  for (const f of untracked)
161
172
  changedSet.add(f);
162
- filterPaths = Array.from(changedSet);
173
+ // Git paths are relative to the REPO ROOT. When the workspace is a
174
+ // subdirectory of the repo (monorepos), convert them to workspace-
175
+ // relative paths before passing them to the scan — resolving repo-root
176
+ // paths against `cwd` would silently miss every change.
177
+ const toCwdRelative = (p) => {
178
+ const abs = path.resolve(repoRoot, p);
179
+ const rel = path.relative(options.cwd, abs);
180
+ if (rel.startsWith("..") || path.isAbsolute(rel))
181
+ return null;
182
+ return rel;
183
+ };
184
+ const cwdRelative = [];
185
+ for (const p of changedSet) {
186
+ const rel = toCwdRelative(p);
187
+ if (rel !== null)
188
+ cwdRelative.push(rel);
189
+ }
190
+ filterPaths = cwdRelative;
163
191
  gitDeletedPaths = diffResult.deletedFiles;
164
192
  logger.debug(`Git incremental: ${filterPaths.length} changed/untracked, ${gitDeletedPaths.length} deleted since ${manifest.lastGitCommit.slice(0, 8)}`);
165
193
  }
@@ -212,7 +240,6 @@ async function runIndexPassInner(options, logger) {
212
240
  let effectiveStore = options.store;
213
241
  let tempStorePath;
214
242
  if (options.force || (manifestStatus !== "ok" && existingCount > 0)) {
215
- options.keywordIndex?.clear();
216
243
  for (const key of Object.keys(manifest.files)) {
217
244
  delete manifest.files[key];
218
245
  }
@@ -233,6 +260,10 @@ async function runIndexPassInner(options, logger) {
233
260
  catch { /* may not exist */ }
234
261
  effectiveStore = createVectorStore(options.config, tempStorePath, options.dimension);
235
262
  logger.debug(`Rebuilding index in temporary store at ${tempStorePath}`);
263
+ // Only clear the shared in-memory keyword index once the rebuild is
264
+ // guaranteed to proceed — the guard below may still bail out, and
265
+ // clearing it there would degrade live hybrid search for nothing.
266
+ options.keywordIndex?.clear();
236
267
  }
237
268
  else if (existingCount > 0) {
238
269
  // NEVER destroy existing data when we can't do an atomic rebuild.
@@ -244,6 +275,7 @@ async function runIndexPassInner(options, logger) {
244
275
  }
245
276
  else {
246
277
  // No existing data — safe to proceed with in-place indexing (no clear needed)
278
+ options.keywordIndex?.clear();
247
279
  logger.debug("No existing data; indexing from scratch.");
248
280
  }
249
281
  }
@@ -305,28 +337,30 @@ async function runIndexPassInner(options, logger) {
305
337
  let chunkedDone = 0;
306
338
  const chunkProgressInterval = Math.max(1, Math.floor(activeCount / 20));
307
339
  const chunkPhaseStart = Date.now();
308
- const prepared = await Promise.all(workspaceFiles.map((file) => limit(async () => {
309
- const fileLabel = path.relative(options.cwd, file.normalizedPath).replace(/\\/g, "/");
310
- const isActive = !file.isEmpty && !file.isTooSmall &&
311
- (!manifest.files[file.normalizedPath] || manifest.files[file.normalizedPath].hash !== file.hash);
312
- if (isActive) {
313
- options.progress?.startFile(fileLabel);
314
- }
315
- const prep = await prepareFile(file, options.cwd, manifest.files[file.normalizedPath], options.config, options.keywordIndex, options.descriptionProvider, logger, deferDescriptions, descHash);
316
- if (isActive) {
317
- chunkedDone++;
318
- if (chunkedDone % chunkProgressInterval === 0 || chunkedDone === activeCount) {
319
- const pct = ((chunkedDone / activeCount) * 100).toFixed(1);
320
- logger.info(` Chunking: ${chunkedDone}/${activeCount} files (${pct}%)`);
340
+ /** Chunk one window of files (used by the windowed pipeline loop below). */
341
+ async function prepareWindow(windowFiles) {
342
+ const prepared = await Promise.all(windowFiles.map((file) => limit(async () => {
343
+ const fileLabel = path.relative(options.cwd, file.normalizedPath).replace(/\\/g, "/");
344
+ const isActive = !file.isEmpty && !file.isTooSmall &&
345
+ (!manifest.files[file.normalizedPath] || manifest.files[file.normalizedPath].hash !== file.hash);
346
+ if (isActive) {
347
+ options.progress?.startFile(fileLabel);
321
348
  }
322
- }
323
- if (prep.earlyResult && isActive) {
324
- options.progress?.finishFile(fileLabel);
325
- }
326
- return prep;
327
- })));
328
- const chunkPhaseSec = ((Date.now() - chunkPhaseStart) / 1000).toFixed(1);
329
- logger.info(`Chunking complete: ${activeCount} files in ${chunkPhaseSec}s`);
349
+ const prep = await prepareFile(file, options.cwd, manifest.files[file.normalizedPath], options.config, options.keywordIndex, options.descriptionProvider, logger, deferDescriptions, descHash);
350
+ if (isActive) {
351
+ chunkedDone++;
352
+ if (chunkedDone % chunkProgressInterval === 0 || chunkedDone === activeCount) {
353
+ const pct = ((chunkedDone / activeCount) * 100).toFixed(1);
354
+ logger.info(` Chunking: ${chunkedDone}/${activeCount} files (${pct}%)`);
355
+ }
356
+ }
357
+ if (prep.earlyResult && isActive) {
358
+ options.progress?.finishFile(fileLabel);
359
+ }
360
+ return prep;
361
+ })));
362
+ return prepared;
363
+ }
330
364
  const aborted = () => options.abortSignal?.aborted ?? false;
331
365
  // Shared progress logger: "stage <file> (chunk i/n) — X/total remaining (P%)".
332
366
  const logChunkProgress = (stage, fileLabel, index, count, completed, total) => {
@@ -334,356 +368,401 @@ async function runIndexPassInner(options, logger) {
334
368
  const pct = total > 0 ? ((remaining / total) * 100).toFixed(1) : "0.0";
335
369
  logger.info(`${stage} ${fileLabel} (chunk ${index}/${count}) — ${remaining}/${total} remaining (${pct}%)`);
336
370
  };
337
- if (deferDescriptions) {
338
- const deferredPreps = prepared.filter((p) => p.chunks && p.chunks.length > 0 && p.relPath !== undefined);
339
- if (deferredPreps.length > 0) {
340
- const allChunks = [];
341
- const oversizedChunks = [];
342
- const maxContentChars = options.config.description?.maxContentChars;
343
- logger.info(`Description phase: ${deferredPreps.length} files with chunks to describe`);
344
- for (const prep of deferredPreps) {
345
- for (const chunk of prep.chunks) {
346
- if (chunk.metadata.contentType !== "image") {
347
- if (maxContentChars && chunk.content.length > maxContentChars) {
348
- oversizedChunks.push(chunk);
349
- }
350
- else {
351
- allChunks.push(chunk);
371
+ // File metadata look-up for manifest entries
372
+ const fileMeta = new Map(workspaceFiles.map((f) => [f.normalizedPath, { mtime: f.mtime, size: f.size }]));
373
+ // Serialised manifest-save queue — prevents concurrent write races and acts
374
+ // as a checkpoint for Ctrl+C resilience. Each worker appends to this chain
375
+ // after a successful store, so previously completed files are never lost.
376
+ // During a temp-store rebuild, saves go to the temp path so the real
377
+ // manifest stays consistent with the real store if the process is aborted.
378
+ const manifestTargetPath = () => tempStorePath ?? options.storePath;
379
+ let manifestSaveChain = Promise.resolve(undefined);
380
+ function enqueueManifestSave() {
381
+ manifestSaveChain = manifestSaveChain.then(() => saveManifest(manifestTargetPath(), manifest).catch((err) => {
382
+ options.logger?.warn?.(`Failed to save manifest: ${err.message}`);
383
+ }));
384
+ }
385
+ // ── Windowed pipeline ────────────────────────────────────────────────────
386
+ // The prepare→describe→embed→store chain used to materialize ALL chunks,
387
+ // embed texts, and vectors of the whole workspace simultaneously (hundreds
388
+ // of MB on large repos). Processing bounded windows of files keeps peak
389
+ // memory proportional to the window size instead of the workspace size.
390
+ const WINDOW_SIZE = Math.max(50, (options.config.indexing.concurrency ?? 4) * 10);
391
+ const allFinalResults = [];
392
+ let abortedInWindow = false;
393
+ for (let windowStart = 0; windowStart < workspaceFiles.length; windowStart += WINDOW_SIZE) {
394
+ if (aborted()) {
395
+ abortedInWindow = true;
396
+ break;
397
+ }
398
+ const windowFiles = workspaceFiles.slice(windowStart, windowStart + WINDOW_SIZE);
399
+ const prepared = await prepareWindow(windowFiles);
400
+ if (windowStart === 0) {
401
+ const chunkPhaseSec = ((Date.now() - chunkPhaseStart) / 1000).toFixed(1);
402
+ logger.info(`Chunking complete: ${activeCount} files in ${chunkPhaseSec}s`);
403
+ }
404
+ if (deferDescriptions) {
405
+ const deferredPreps = prepared.filter((p) => p.chunks && p.chunks.length > 0 && p.relPath !== undefined);
406
+ if (deferredPreps.length > 0) {
407
+ const allChunks = [];
408
+ const oversizedChunks = [];
409
+ const maxContentChars = options.config.description?.maxContentChars;
410
+ logger.info(`Description phase: ${deferredPreps.length} files with chunks to describe`);
411
+ for (const prep of deferredPreps) {
412
+ for (const chunk of prep.chunks) {
413
+ if (chunk.metadata.contentType !== "image") {
414
+ if (maxContentChars && chunk.content.length > maxContentChars) {
415
+ oversizedChunks.push(chunk);
416
+ }
417
+ else {
418
+ allChunks.push(chunk);
419
+ }
352
420
  }
353
421
  }
354
422
  }
355
- }
356
- for (const chunk of oversizedChunks) {
357
- chunk.description = buildFallbackDescription(chunk);
358
- }
359
- // Per-file chunk index + label for clear progress reporting, plus a shared
360
- // progress logger emitting "stage <file> (chunk i/n) — X/total remaining (P%)".
361
- const chunkToFileLabel = new Map();
362
- for (const prep of deferredPreps) {
363
- for (const chunk of prep.chunks ?? []) {
364
- chunkToFileLabel.set(chunk.id, prep.fileLabel);
365
- }
366
- }
367
- const chunkMeta = new Map();
368
- {
369
- const perFileCount = new Map();
370
- for (const chunk of allChunks) {
371
- perFileCount.set(chunk.metadata.filePath, (perFileCount.get(chunk.metadata.filePath) ?? 0) + 1);
372
- }
373
- const perFileSeen = new Map();
374
- for (const chunk of allChunks) {
375
- const seen = perFileSeen.get(chunk.metadata.filePath) ?? 0;
376
- perFileSeen.set(chunk.metadata.filePath, seen + 1);
377
- chunkMeta.set(chunk.id, {
378
- fileLabel: chunkToFileLabel.get(chunk.id) ?? chunk.metadata.filePath,
379
- index: seen + 1,
380
- count: perFileCount.get(chunk.metadata.filePath) ?? 1,
381
- });
423
+ for (const chunk of oversizedChunks) {
424
+ chunk.description = buildFallbackDescription(chunk);
382
425
  }
383
- }
384
- // Advance progress to Description stage before descriptions start
385
- for (const prep of deferredPreps) {
386
- options.progress?.finishStage(prep.fileLabel);
387
- }
388
- if (allChunks.length > 0 && descHash) {
389
- // Check description cache first — reuse cached descriptions for unchanged chunks
390
- const cacheHits = [];
391
- const cacheMisses = [];
392
- for (const chunk of allChunks) {
393
- const cacheKey = DescriptionCache.codeKey(chunk.content, descHash);
394
- const cached = descCache.get(cacheKey);
395
- if (cached) {
396
- cacheHits.push({ chunk, desc: cached });
397
- }
398
- else {
399
- cacheMisses.push(chunk);
426
+ // Per-file chunk index + label for clear progress reporting, plus a shared
427
+ // progress logger emitting "stage <file> (chunk i/n) — X/total remaining (P%)".
428
+ const chunkToFileLabel = new Map();
429
+ for (const prep of deferredPreps) {
430
+ for (const chunk of prep.chunks ?? []) {
431
+ chunkToFileLabel.set(chunk.id, prep.fileLabel);
400
432
  }
401
433
  }
402
- if (cacheHits.length > 0) {
403
- logger.debug(` Using ${cacheHits.length} cached descriptions`);
404
- for (const { chunk, desc } of cacheHits) {
405
- chunk.description = desc;
434
+ const chunkMeta = new Map();
435
+ {
436
+ const perFileCount = new Map();
437
+ for (const chunk of allChunks) {
438
+ perFileCount.set(chunk.metadata.filePath, (perFileCount.get(chunk.metadata.filePath) ?? 0) + 1);
439
+ }
440
+ const perFileSeen = new Map();
441
+ for (const chunk of allChunks) {
442
+ const seen = perFileSeen.get(chunk.metadata.filePath) ?? 0;
443
+ perFileSeen.set(chunk.metadata.filePath, seen + 1);
444
+ chunkMeta.set(chunk.id, {
445
+ fileLabel: chunkToFileLabel.get(chunk.id) ?? chunk.metadata.filePath,
446
+ index: seen + 1,
447
+ count: perFileCount.get(chunk.metadata.filePath) ?? 1,
448
+ });
406
449
  }
407
450
  }
408
- // Build chunkId prep map for tracking description failures per-file
409
- const chunkToPrep = new Map();
451
+ // Advance progress to Description stage before descriptions start
410
452
  for (const prep of deferredPreps) {
411
- for (const chunk of prep.chunks ?? []) {
412
- chunkToPrep.set(chunk.id, prep);
413
- }
453
+ options.progress?.finishStage(prep.fileLabel);
414
454
  }
415
- // Process cache misses in parallel waves with throttled cache saves.
416
- // Parallel sub-batches reduce serial LLM turnaround time.
417
- // Cache saves are throttled to avoid O(n^2) full-JSON serialization I/O.
418
- if (cacheMisses.length > 0) {
419
- const totalMisses = cacheMisses.length;
420
- const descConcurrency = options.config.indexing.descriptionConcurrency ?? 4;
421
- const SUB_BATCH = 10;
422
- let describedDone = 0;
423
- let describedSinceLastSave = 0;
424
- const SAVE_INTERVAL = 200;
425
- const descLimit = pLimit(descConcurrency);
426
- const subBatchTasks = [];
427
- for (let i = 0; i < totalMisses; i += SUB_BATCH) {
428
- subBatchTasks.push({ i, chunks: cacheMisses.slice(i, i + SUB_BATCH) });
455
+ if (allChunks.length > 0 && descHash) {
456
+ // Check description cache first reuse cached descriptions for unchanged chunks.
457
+ // The key includes the chunk's file context: identical content in two
458
+ // files must not reuse a contextually wrong description.
459
+ const chunkContext = (c) => `${c.metadata.filePath}:${c.metadata.startLine}-${c.metadata.endLine}`;
460
+ const cacheHits = [];
461
+ const cacheMisses = [];
462
+ for (const chunk of allChunks) {
463
+ const cacheKey = DescriptionCache.codeKey(chunk.content, descHash, chunkContext(chunk));
464
+ const cached = descCache.get(cacheKey);
465
+ if (cached) {
466
+ cacheHits.push({ chunk, desc: cached });
467
+ }
468
+ else {
469
+ cacheMisses.push(chunk);
470
+ }
429
471
  }
430
- await Promise.all(subBatchTasks.map(({ i, chunks: subBatch }) => descLimit(async () => {
431
- if (aborted())
432
- return;
433
- try {
434
- const batchResult = await options.descriptionProvider.generateBatchDescriptions(subBatch, logger, {
435
- total: totalMisses,
436
- onProgress: (chunk) => {
437
- describedDone++;
438
- const meta = chunkMeta.get(chunk.id);
439
- if (meta)
440
- logChunkProgress("Describing", meta.fileLabel, meta.index, meta.count, describedDone, totalMisses);
441
- },
442
- });
443
- const newCacheEntries = [];
444
- for (const chunk of subBatch) {
445
- const desc = batchResult.get(chunk.id);
446
- if (desc && desc.trim().length > 0) {
447
- chunk.description = desc;
448
- newCacheEntries.push([DescriptionCache.codeKey(chunk.content, descHash), desc]);
472
+ if (cacheHits.length > 0) {
473
+ logger.debug(` Using ${cacheHits.length} cached descriptions`);
474
+ for (const { chunk, desc } of cacheHits) {
475
+ chunk.description = desc;
476
+ }
477
+ }
478
+ // Build chunkId → prep map for tracking description failures per-file
479
+ const chunkToPrep = new Map();
480
+ for (const prep of deferredPreps) {
481
+ for (const chunk of prep.chunks ?? []) {
482
+ chunkToPrep.set(chunk.id, prep);
483
+ }
484
+ }
485
+ // Process cache misses in parallel waves with throttled cache saves.
486
+ // Parallel sub-batches reduce serial LLM turnaround time.
487
+ // Cache saves are throttled to avoid O(n^2) full-JSON serialization I/O.
488
+ if (cacheMisses.length > 0) {
489
+ const totalMisses = cacheMisses.length;
490
+ const descConcurrency = options.config.indexing.descriptionConcurrency ?? 4;
491
+ const SUB_BATCH = 10;
492
+ let describedDone = 0;
493
+ let describedSinceLastSave = 0;
494
+ const SAVE_INTERVAL = 200;
495
+ const descLimit = pLimit(descConcurrency);
496
+ const subBatchTasks = [];
497
+ for (let i = 0; i < totalMisses; i += SUB_BATCH) {
498
+ subBatchTasks.push({ i, chunks: cacheMisses.slice(i, i + SUB_BATCH) });
499
+ }
500
+ await Promise.all(subBatchTasks.map(({ i, chunks: subBatch }) => descLimit(async () => {
501
+ if (aborted())
502
+ return;
503
+ try {
504
+ const batchResult = await options.descriptionProvider.generateBatchDescriptions(subBatch, logger, {
505
+ total: totalMisses,
506
+ onProgress: (chunk) => {
507
+ describedDone++;
508
+ const meta = chunkMeta.get(chunk.id);
509
+ if (meta)
510
+ logChunkProgress("Describing", meta.fileLabel, meta.index, meta.count, describedDone, totalMisses);
511
+ },
512
+ });
513
+ const newCacheEntries = [];
514
+ for (const chunk of subBatch) {
515
+ const desc = batchResult.get(chunk.id);
516
+ if (desc && desc.trim().length > 0) {
517
+ chunk.description = desc;
518
+ newCacheEntries.push([DescriptionCache.codeKey(chunk.content, descHash, chunkContext(chunk)), desc]);
519
+ }
449
520
  }
450
- }
451
- if (newCacheEntries.length > 0) {
452
- descCache.setMany(newCacheEntries);
453
- describedSinceLastSave += newCacheEntries.length;
454
- if (describedSinceLastSave >= SAVE_INTERVAL) {
455
- await descCache.save();
456
- describedSinceLastSave = 0;
521
+ if (newCacheEntries.length > 0) {
522
+ descCache.setMany(newCacheEntries);
523
+ describedSinceLastSave += newCacheEntries.length;
524
+ if (describedSinceLastSave >= SAVE_INTERVAL) {
525
+ await descCache.save();
526
+ describedSinceLastSave = 0;
527
+ }
457
528
  }
458
529
  }
459
- }
460
- catch (err) {
461
- logger.warn(` Description sub-batch failed (${i}-${i + subBatch.length}): ${err.message}`);
462
- const failedPreps = new Set();
463
- for (const chunk of subBatch) {
464
- const prep = chunkToPrep.get(chunk.id);
465
- if (prep)
466
- failedPreps.add(prep);
467
- }
468
- for (const prep of failedPreps) {
469
- prep.descriptionFailed = true;
530
+ catch (err) {
531
+ logger.warn(` Description sub-batch failed (${i}-${i + subBatch.length}): ${err.message}`);
532
+ const failedPreps = new Set();
533
+ for (const chunk of subBatch) {
534
+ const prep = chunkToPrep.get(chunk.id);
535
+ if (prep)
536
+ failedPreps.add(prep);
537
+ }
538
+ for (const prep of failedPreps) {
539
+ prep.descriptionFailed = true;
540
+ }
470
541
  }
542
+ })));
543
+ if (describedSinceLastSave > 0) {
544
+ await descCache.save();
471
545
  }
472
- })));
473
- if (describedSinceLastSave > 0) {
474
- await descCache.save();
546
+ logger.debug(`Descriptions generated for ${totalMisses} chunks (concurrency: ${descConcurrency})`);
475
547
  }
476
- logger.debug(`Descriptions generated for ${totalMisses} chunks (concurrency: ${descConcurrency})`);
477
548
  }
478
- }
479
- else if (allChunks.length > 0) {
480
- // No descHash available (no description provider) — still generate descriptions
481
- let describedDone = 0;
482
- try {
483
- const batchResult = await options.descriptionProvider.generateBatchDescriptions(allChunks, logger, {
484
- total: allChunks.length,
485
- onProgress: (chunk) => {
486
- describedDone++;
487
- const meta = chunkMeta.get(chunk.id);
488
- if (meta)
489
- logChunkProgress("Describing", meta.fileLabel, meta.index, meta.count, describedDone, allChunks.length);
490
- },
491
- });
492
- for (const chunk of allChunks) {
493
- const desc = batchResult.get(chunk.id);
494
- if (desc && desc.trim().length > 0) {
495
- chunk.description = desc;
549
+ else if (allChunks.length > 0) {
550
+ // No descHash available (no description provider) — still generate descriptions
551
+ let describedDone = 0;
552
+ try {
553
+ const batchResult = await options.descriptionProvider.generateBatchDescriptions(allChunks, logger, {
554
+ total: allChunks.length,
555
+ onProgress: (chunk) => {
556
+ describedDone++;
557
+ const meta = chunkMeta.get(chunk.id);
558
+ if (meta)
559
+ logChunkProgress("Describing", meta.fileLabel, meta.index, meta.count, describedDone, allChunks.length);
560
+ },
561
+ });
562
+ for (const chunk of allChunks) {
563
+ const desc = batchResult.get(chunk.id);
564
+ if (desc && desc.trim().length > 0) {
565
+ chunk.description = desc;
566
+ }
496
567
  }
497
568
  }
498
- }
499
- catch (err) {
500
- logger.warn(` Global description generation failed: ${err.message}`);
501
- for (const prep of deferredPreps) {
502
- if (prep.chunks.some((c) => c.metadata.contentType !== "image")) {
503
- prep.descriptionFailed = true;
569
+ catch (err) {
570
+ logger.warn(` Global description generation failed: ${err.message}`);
571
+ for (const prep of deferredPreps) {
572
+ if (prep.chunks.some((c) => c.metadata.contentType !== "image")) {
573
+ prep.descriptionFailed = true;
574
+ }
504
575
  }
505
576
  }
506
577
  }
578
+ for (const prep of deferredPreps) {
579
+ prep.textToEmbed = buildTextsToEmbed(prep.chunks, prep.relPath, prep.metaHeader ?? "", prep.docPrefix ?? "", prep.isImageFile ?? false);
580
+ }
581
+ const totalDescribedChunks = deferredPreps.reduce((s, p) => s + (p.chunks?.length ?? 0), 0);
582
+ logger.info(`Description phase complete: ${totalDescribedChunks} chunks across ${deferredPreps.length} files`);
507
583
  }
508
- for (const prep of deferredPreps) {
509
- prep.textToEmbed = buildTextsToEmbed(prep.chunks, prep.relPath, prep.metaHeader ?? "", prep.docPrefix ?? "", prep.isImageFile ?? false);
510
- }
511
- const totalDescribedChunks = deferredPreps.reduce((s, p) => s + (p.chunks?.length ?? 0), 0);
512
- logger.info(`Description phase complete: ${totalDescribedChunks} chunks across ${deferredPreps.length} files`);
513
584
  }
514
- }
515
- // Cross-file embedding batch: collect all texts into a single queue,
516
- // embed in one batched call (with concurrency), then distribute back.
517
- const isOllama = options.embedder.name === "ollama";
518
- const defaultBatchSize = options.config.indexing.embedBatchSize;
519
- const defaultConcurrency = options.config.indexing.embedConcurrency ?? 1;
520
- // File metadata look-up for manifest entries
521
- const fileMeta = new Map(workspaceFiles.map((f) => [f.normalizedPath, { mtime: f.mtime, size: f.size }]));
522
- // Serialised manifest-save queue — prevents concurrent write races and acts
523
- // as a checkpoint for Ctrl+C resilience. Each worker appends to this chain
524
- // after a successful store, so previously completed files are never lost.
525
- // During a temp-store rebuild, saves go to the temp path so the real
526
- // manifest stays consistent with the real store if the process is aborted.
527
- const manifestTargetPath = () => tempStorePath ?? options.storePath;
528
- let manifestSaveChain = Promise.resolve(undefined);
529
- function enqueueManifestSave() {
530
- manifestSaveChain = manifestSaveChain.then(() => saveManifest(manifestTargetPath(), manifest).catch((err) => {
531
- options.logger?.warn?.(`Failed to save manifest: ${err.message}`);
532
- }));
533
- }
534
- // ── Phase 1: Collect embed queue + handle early results ────────────────
535
- const embedQueue = [];
536
- let totalEmbedChunks = 0;
537
- const earlyWorkerResults = new Map();
538
- for (let fi = 0; fi < prepared.length; fi++) {
539
- const prep = prepared[fi];
540
- if (prep.earlyResult) {
541
- if (prep.earlyResult.isRemoved) {
542
- await effectiveStore.deleteByFilePath(prep.normalizedPath);
543
- options.keywordIndex?.removeByFilePath(prep.normalizedPath);
544
- delete manifest.files[prep.normalizedPath];
545
- enqueueManifestSave();
585
+ // Cross-file embedding batch: collect this window's texts into a single queue,
586
+ // embed in one batched call (with concurrency), then distribute back.
587
+ const isOllama = options.embedder.name === "ollama";
588
+ const defaultBatchSize = options.config.indexing.embedBatchSize;
589
+ const defaultConcurrency = options.config.indexing.embedConcurrency ?? 1;
590
+ // ── Phase 1: Collect embed queue + handle early results ────────────────
591
+ const embedQueue = [];
592
+ let totalEmbedChunks = 0;
593
+ const earlyWorkerResults = new Map();
594
+ for (let fi = 0; fi < prepared.length; fi++) {
595
+ const prep = prepared[fi];
596
+ if (prep.earlyResult) {
597
+ if (prep.earlyResult.isRemoved) {
598
+ await effectiveStore.deleteByFilePath(prep.normalizedPath);
599
+ options.keywordIndex?.removeByFilePath(prep.normalizedPath);
600
+ delete manifest.files[prep.normalizedPath];
601
+ enqueueManifestSave();
602
+ }
603
+ earlyWorkerResults.set(fi, prep.earlyResult);
604
+ continue;
546
605
  }
547
- earlyWorkerResults.set(fi, prep.earlyResult);
548
- continue;
549
- }
550
- if (!prep.chunks || !prep.textToEmbed || prep.textToEmbed.length === 0) {
551
- options.progress?.finishFile(prep.fileLabel);
552
- earlyWorkerResults.set(fi, {
553
- normalizedPath: prep.normalizedPath, hash: prep.hash, chunkCount: 0,
554
- fileLabel: prep.fileLabel,
555
- isNew: false, isModified: false, isUnchanged: false, isEmpty: false,
556
- isTooSmall: false, isRemoved: true, hadChunks: false,
557
- descriptionFailed: prep.descriptionFailed,
558
- });
559
- continue;
560
- }
561
- options.progress?.finishStage(prep.fileLabel);
562
- for (let ci = 0; ci < prep.textToEmbed.length; ci++) {
563
- embedQueue.push({ fileIdx: fi, chunkIdx: ci, text: prep.textToEmbed[ci] });
564
- }
565
- totalEmbedChunks += prep.textToEmbed.length;
566
- }
567
- // ── Phase 2: Embed all texts in a single batched call ──────────────────
568
- const batchSize = isOllama
569
- ? Math.min(options.config.indexing.ollamaMaxBatchSize ?? 500, defaultBatchSize)
570
- : defaultBatchSize;
571
- let embeddedDone = 0;
572
- const allTexts = embedQueue.map(item => item.text);
573
- let allEmbeddings = [];
574
- const embedPhaseStart = Date.now();
575
- if (allTexts.length > 0) {
576
- logger.info(`Embedding phase: ${allTexts.length} texts in batches of ${batchSize}...`);
577
- try {
578
- allEmbeddings = await embedBatch(options.embedder, allTexts, batchSize, "document", defaultConcurrency, (completed, total) => {
579
- embeddedDone = completed;
580
- logChunkProgress("Embedding", "", completed, total, embeddedDone, totalEmbedChunks);
581
- });
582
- logger.info(`Embedding complete: ${allTexts.length} texts in ${((Date.now() - embedPhaseStart) / 1000).toFixed(1)}s`);
583
- }
584
- catch (err) {
585
- logger.warn(` Global embedding failed: ${err.message}`);
586
- for (const { fileIdx } of embedQueue) {
587
- options.progress?.failFile(prepared[fileIdx].fileLabel);
588
- earlyWorkerResults.set(fileIdx, {
589
- normalizedPath: prepared[fileIdx].normalizedPath,
590
- hash: prepared[fileIdx].hash,
591
- chunkCount: 0, fileLabel: prepared[fileIdx].fileLabel,
606
+ if (!prep.chunks || !prep.textToEmbed || prep.textToEmbed.length === 0) {
607
+ options.progress?.finishFile(prep.fileLabel);
608
+ earlyWorkerResults.set(fi, {
609
+ normalizedPath: prep.normalizedPath, hash: prep.hash, chunkCount: 0,
610
+ fileLabel: prep.fileLabel,
592
611
  isNew: false, isModified: false, isUnchanged: false, isEmpty: false,
593
- isTooSmall: false, isRemoved: false, hadChunks: false,
594
- descriptionFailed: prepared[fileIdx].descriptionFailed,
612
+ isTooSmall: false, isRemoved: true, hadChunks: false,
613
+ descriptionFailed: prep.descriptionFailed,
614
+ });
615
+ continue;
616
+ }
617
+ options.progress?.finishStage(prep.fileLabel);
618
+ for (let ci = 0; ci < prep.textToEmbed.length; ci++) {
619
+ embedQueue.push({ fileIdx: fi, chunkIdx: ci, text: prep.textToEmbed[ci] });
620
+ }
621
+ totalEmbedChunks += prep.textToEmbed.length;
622
+ }
623
+ // ── Phase 2: Embed all texts in a single batched call ──────────────────
624
+ const batchSize = isOllama
625
+ ? Math.min(options.config.indexing.ollamaMaxBatchSize ?? 500, defaultBatchSize)
626
+ : defaultBatchSize;
627
+ let embeddedDone = 0;
628
+ const allTexts = embedQueue.map(item => item.text);
629
+ let allEmbeddings = [];
630
+ const embedPhaseStart = Date.now();
631
+ if (allTexts.length > 0) {
632
+ logger.info(`Embedding phase: ${allTexts.length} texts in batches of ${batchSize}...`);
633
+ try {
634
+ allEmbeddings = await embedBatch(options.embedder, allTexts, batchSize, "document", defaultConcurrency, (completed, total) => {
635
+ embeddedDone = completed;
636
+ logChunkProgress("Embedding", "", completed, total, embeddedDone, totalEmbedChunks);
595
637
  });
638
+ logger.info(`Embedding complete: ${allTexts.length} texts in ${((Date.now() - embedPhaseStart) / 1000).toFixed(1)}s`);
639
+ }
640
+ catch (err) {
641
+ logger.warn(` Global embedding failed: ${err.message}`);
642
+ for (const { fileIdx } of embedQueue) {
643
+ options.progress?.failFile(prepared[fileIdx].fileLabel);
644
+ earlyWorkerResults.set(fileIdx, {
645
+ normalizedPath: prepared[fileIdx].normalizedPath,
646
+ hash: prepared[fileIdx].hash,
647
+ chunkCount: 0, fileLabel: prepared[fileIdx].fileLabel,
648
+ isNew: false, isModified: false, isUnchanged: false, isEmpty: false,
649
+ isTooSmall: false, isRemoved: false, hadChunks: false,
650
+ descriptionFailed: prepared[fileIdx].descriptionFailed,
651
+ });
652
+ }
653
+ embedQueue.length = 0; // prevent double-processing in store phase
596
654
  }
597
- embedQueue.length = 0; // prevent double-processing in store phase
598
655
  }
599
- }
600
- // ── Distribute embeddings back to per-file chunks ─────────────────────
601
- for (let i = 0; i < embedQueue.length; i++) {
602
- const { fileIdx, chunkIdx } = embedQueue[i];
603
- const emb = allEmbeddings[i];
604
- const prep = prepared[fileIdx];
605
- if (prep.chunks && prep.chunks[chunkIdx] && Array.isArray(emb) && emb.length > 0 && typeof emb[0] === "number") {
606
- prep.chunks[chunkIdx].embedding = emb;
656
+ // ── Distribute embeddings back to per-file chunks ─────────────────────
657
+ for (let i = 0; i < embedQueue.length; i++) {
658
+ const { fileIdx, chunkIdx } = embedQueue[i];
659
+ const emb = allEmbeddings[i];
660
+ const prep = prepared[fileIdx];
661
+ if (prep.chunks && prep.chunks[chunkIdx] && Array.isArray(emb) && emb.length > 0 && typeof emb[0] === "number") {
662
+ prep.chunks[chunkIdx].embedding = emb;
663
+ }
607
664
  }
608
- }
609
- // ── Phase 3: Store + manifest update per file (parallel) ──────────────
610
- const filesToStore = prepared.filter((p) => !earlyWorkerResults.has(prepared.indexOf(p)) && p.chunks && (p.textToEmbed?.length ?? 0) > 0).length;
611
- const storePhaseStart = Date.now();
612
- logger.info(`Store phase: storing ${filesToStore} file(s) into vector database...`);
613
- let storedFiles = 0;
614
- const storeLimit = pLimit(options.config.indexing.concurrency);
615
- const storeResults = await Promise.all(prepared.map((prep, fi) => storeLimit(async () => {
616
- if (aborted()) {
617
- return { normalizedPath: prep.normalizedPath, skipped: true };
618
- }
619
- // Return early results from phase 1
620
- const earlyResult = earlyWorkerResults.get(fi);
621
- if (earlyResult)
622
- return earlyResult;
623
- // No-embed path (shouldn't reach here but guard anyway)
624
- if (!prep.chunks || prep.textToEmbed?.length === 0) {
625
- options.progress?.finishFile(prep.fileLabel);
626
- return {
627
- normalizedPath: prep.normalizedPath, hash: prep.hash, chunkCount: 0,
665
+ // ── Phase 3: Store + manifest update per file (parallel) ──────────────
666
+ const filesToStore = prepared.filter((p) => !earlyWorkerResults.has(prepared.indexOf(p)) && p.chunks && (p.textToEmbed?.length ?? 0) > 0).length;
667
+ const storePhaseStart = Date.now();
668
+ logger.info(`Store phase: storing ${filesToStore} file(s) into vector database...`);
669
+ let storedFiles = 0;
670
+ const storeLimit = pLimit(options.config.indexing.concurrency);
671
+ const storeResults = await Promise.all(prepared.map((prep, fi) => storeLimit(async () => {
672
+ if (aborted()) {
673
+ return { normalizedPath: prep.normalizedPath, skipped: true };
674
+ }
675
+ // Return early results from phase 1
676
+ const earlyResult = earlyWorkerResults.get(fi);
677
+ if (earlyResult)
678
+ return earlyResult;
679
+ // No-embed path (shouldn't reach here but guard anyway)
680
+ if (!prep.chunks || prep.textToEmbed?.length === 0) {
681
+ options.progress?.finishFile(prep.fileLabel);
682
+ return {
683
+ normalizedPath: prep.normalizedPath, hash: prep.hash, chunkCount: 0,
684
+ fileLabel: prep.fileLabel,
685
+ isNew: false, isModified: false, isUnchanged: false, isEmpty: false,
686
+ isTooSmall: false, isRemoved: true, hadChunks: false,
687
+ descriptionFailed: prep.descriptionFailed,
688
+ };
689
+ }
690
+ // Store chunks with pre-attached embeddings
691
+ const validChunks = (prep.chunks ?? []).filter((c) => c.embedding && c.embedding.length > 0);
692
+ // Partial embed failure: some chunks have no vector. Do NOT record the
693
+ // file as complete — keep the previous manifest entry (or none, for
694
+ // new files) so the next pass retries the missing chunks.
695
+ const allEmbedded = validChunks.length === (prep.chunks?.length ?? 0);
696
+ if (!allEmbedded && (prep.chunks?.length ?? 0) > 0) {
697
+ options.logger?.warn?.(` ${prep.fileLabel}: ${validChunks.length}/${prep.chunks?.length} chunks embedded — marking for retry on next pass`);
698
+ }
699
+ if (validChunks.length > 0) {
700
+ await effectiveStore.addChunks(validChunks);
701
+ }
702
+ const result = {
703
+ normalizedPath: prep.normalizedPath,
704
+ hash: prep.hash,
705
+ chunkCount: validChunks.length,
628
706
  fileLabel: prep.fileLabel,
629
- isNew: false, isModified: false, isUnchanged: false, isEmpty: false,
630
- isTooSmall: false, isRemoved: true, hadChunks: false,
707
+ isNew: !prep.isModified,
708
+ isModified: prep.isModified,
709
+ isUnchanged: false,
710
+ isEmpty: false,
711
+ isTooSmall: false,
712
+ isRemoved: (prep.chunks?.length ?? 0) > 0 && validChunks.length === 0 ? false : validChunks.length === 0,
713
+ hadChunks: (prep.chunks?.length ?? 0) > 0,
631
714
  descriptionFailed: prep.descriptionFailed,
715
+ descHash: prep.descHash,
632
716
  };
633
- }
634
- // Store chunks with pre-attached embeddings
635
- const validChunks = (prep.chunks ?? []).filter((c) => c.embedding && c.embedding.length > 0);
636
- if (validChunks.length > 0) {
637
- await effectiveStore.addChunks(validChunks);
638
- }
639
- const result = {
640
- normalizedPath: prep.normalizedPath,
641
- hash: prep.hash,
642
- chunkCount: validChunks.length,
643
- fileLabel: prep.fileLabel,
644
- isNew: !prep.isModified,
645
- isModified: prep.isModified,
646
- isUnchanged: false,
647
- isEmpty: false,
648
- isTooSmall: false,
649
- isRemoved: (prep.chunks?.length ?? 0) > 0 && validChunks.length === 0 ? false : validChunks.length === 0,
650
- hadChunks: (prep.chunks?.length ?? 0) > 0,
651
- descriptionFailed: prep.descriptionFailed,
652
- descHash: prep.descHash,
653
- };
654
- // Update manifest
655
- if (result.chunkCount > 0 && !result.isRemoved) {
656
- const meta = fileMeta.get(result.normalizedPath);
657
- const entry = {
658
- hash: result.hash,
659
- chunkCount: result.chunkCount,
660
- indexedAt: Date.now(),
661
- mtime: meta?.mtime,
662
- size: meta?.size,
663
- descriptionFailed: result.descriptionFailed,
664
- };
665
- if (result.descHash) {
666
- entry.descHash = result.descHash;
717
+ // Update manifest — only when ALL chunks were embedded. A partial
718
+ // write must not bump the hash/chunkCount, otherwise the missing
719
+ // chunks would never be retried (hash match on the next pass).
720
+ if (result.chunkCount > 0 && !result.isRemoved && allEmbedded) {
721
+ const meta = fileMeta.get(result.normalizedPath);
722
+ const entry = {
723
+ hash: result.hash,
724
+ chunkCount: result.chunkCount,
725
+ indexedAt: Date.now(),
726
+ mtime: meta?.mtime,
727
+ size: meta?.size,
728
+ descriptionFailed: result.descriptionFailed,
729
+ };
730
+ if (result.descHash) {
731
+ entry.descHash = result.descHash;
732
+ }
733
+ manifest.files[result.normalizedPath] = entry;
734
+ enqueueManifestSave();
735
+ }
736
+ else if (result.isRemoved) {
737
+ delete manifest.files[result.normalizedPath];
738
+ enqueueManifestSave();
667
739
  }
668
- manifest.files[result.normalizedPath] = entry;
669
- enqueueManifestSave();
670
- }
671
- else if (result.isRemoved) {
672
- delete manifest.files[result.normalizedPath];
673
- enqueueManifestSave();
674
- }
675
- options.progress?.finishFile(prep.fileLabel);
676
- storedFiles++;
677
- logChunkProgress("Storing", prep.fileLabel, storedFiles, filesToStore, storedFiles, filesToStore);
678
- return result;
679
- })));
680
- const workerResults = storeResults;
681
- const finalResults = [];
682
- for (const r of workerResults) {
683
- if (r.skipped)
740
+ options.progress?.finishFile(prep.fileLabel);
741
+ storedFiles++;
742
+ logChunkProgress("Storing", prep.fileLabel, storedFiles, filesToStore, storedFiles, filesToStore);
743
+ return result;
744
+ })));
745
+ const workerResults = storeResults;
746
+ for (const r of workerResults) {
747
+ if (r.skipped) {
748
+ abortedInWindow = true;
749
+ break;
750
+ }
751
+ allFinalResults.push(r);
752
+ }
753
+ aggregateStats(stats, allFinalResults.slice(-workerResults.length));
754
+ const storePhaseSec = ((Date.now() - storePhaseStart) / 1000).toFixed(1);
755
+ logger.info(`Store phase: ${storedFiles} files stored in ${storePhaseSec}s (running total ${stats.totalChunks} chunks)`);
756
+ // Free this window's chunk payloads so GC can reclaim them before the
757
+ // next window is prepared.
758
+ for (const prep of prepared) {
759
+ prep.chunks = undefined;
760
+ prep.textToEmbed = undefined;
761
+ }
762
+ if (abortedInWindow)
684
763
  break;
685
- finalResults.push(r);
686
- }
764
+ } // end windowed pipeline loop
765
+ const finalResults = allFinalResults;
687
766
  // Drain any in-flight manifest saves so all file entries are durable
688
767
  await manifestSaveChain;
689
768
  // Update mtime/size for unchanged files (speeds up the next scan)
@@ -694,9 +773,7 @@ async function runIndexPassInner(options, logger) {
694
773
  entry.size = size;
695
774
  }
696
775
  }
697
- const storePhaseSec = ((Date.now() - storePhaseStart) / 1000).toFixed(1);
698
- logger.info(`Store phase complete: ${storedFiles} files stored in ${storePhaseSec}s (${stats.totalChunks} total chunks)`);
699
- aggregateStats(stats, finalResults);
776
+ logger.info(`Index pass complete: ${stats.totalChunks} total chunks (${finalResults.length} files processed)`);
700
777
  // Update timestamps; advance lastGitCommit ONLY on a complete pass
701
778
  manifest.lastIndexedAt = Date.now();
702
779
  if (!aborted()) {