opencode-rag-plugin 1.19.4 → 1.19.8
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.
- package/dist/chunker/base.js +19 -5
- package/dist/chunker/factory.js +27 -9
- package/dist/chunker/grammar.d.ts +18 -1
- package/dist/chunker/grammar.js +48 -10
- package/dist/chunker/image.js +5 -0
- package/dist/chunker/pdf.js +30 -14
- package/dist/cli/commands/backend-detect.d.ts +61 -0
- package/dist/cli/commands/backend-detect.js +119 -0
- package/dist/cli/commands/index-command.js +11 -0
- package/dist/cli/commands/init-helpers.d.ts +4 -1
- package/dist/cli/commands/init-helpers.js +21 -4
- package/dist/cli/commands/init.js +61 -24
- package/dist/cli/commands/quirk.js +9 -3
- package/dist/cli/commands/setup.js +12 -3
- package/dist/cli/commands/status.js +14 -5
- package/dist/cli/commands/ui.js +23 -9
- package/dist/cli/commands/update.js +4 -5
- package/dist/cli/format.d.ts +5 -2
- package/dist/cli/format.js +14 -5
- package/dist/content/image.js +33 -11
- package/dist/content/reader.js +74 -13
- package/dist/core/bootstrap.js +10 -3
- package/dist/core/config.d.ts +27 -1
- package/dist/core/config.js +41 -2
- package/dist/core/desc-cache.d.ts +8 -2
- package/dist/core/desc-cache.js +10 -3
- package/dist/core/doc-progress.js +5 -2
- package/dist/core/interfaces.d.ts +32 -4
- package/dist/core/manifest.js +1 -1
- package/dist/core/provider-defaults.d.ts +2 -0
- package/dist/core/provider-defaults.js +19 -4
- package/dist/core/runtime-overrides.d.ts +0 -6
- package/dist/core/version-check.d.ts +5 -0
- package/dist/core/version-check.js +8 -2
- package/dist/describer/anthropic.d.ts +2 -2
- package/dist/describer/anthropic.js +19 -5
- package/dist/describer/describer.d.ts +20 -0
- package/dist/describer/describer.js +132 -16
- package/dist/describer/gemini.js +25 -10
- package/dist/describer/shared.d.ts +28 -0
- package/dist/describer/shared.js +60 -0
- package/dist/embedder/factory.d.ts +5 -3
- package/dist/embedder/factory.js +42 -9
- package/dist/embedder/health.js +19 -19
- package/dist/embedder/http.d.ts +14 -1
- package/dist/embedder/http.js +60 -6
- package/dist/embedder/ollama.d.ts +3 -1
- package/dist/embedder/ollama.js +12 -2
- package/dist/eval/session-logger.js +7 -0
- package/dist/eval/storage.js +8 -0
- package/dist/indexer/git-diff.d.ts +1 -1
- package/dist/indexer/git-diff.js +5 -1
- package/dist/indexer/pipeline.js +511 -346
- package/dist/indexer/stats.d.ts +2 -0
- package/dist/indexer/stats.js +1 -0
- package/dist/indexer/watch.js +8 -1
- package/dist/indexer/worker.js +21 -0
- package/dist/mcp/cli.js +4 -0
- package/dist/mcp/handlers.js +16 -5
- package/dist/mcp/server.js +3 -0
- package/dist/opencode/create-read-tool.js +14 -3
- package/dist/opencode/tool-args.js +23 -1
- package/dist/plugin.js +66 -152
- package/dist/quirks/auto-capture.js +5 -0
- package/dist/quirks/quirk-store.d.ts +1 -1
- package/dist/quirks/quirk-store.js +56 -17
- package/dist/retriever/context-optimizer.js +18 -4
- package/dist/retriever/keyword-index.d.ts +2 -0
- package/dist/retriever/keyword-index.js +38 -4
- package/dist/retriever/retriever.js +6 -1
- package/dist/tui.js +41 -4
- package/dist/vectorstore/lancedb.d.ts +104 -8
- package/dist/vectorstore/lancedb.js +345 -71
- package/dist/vectorstore/memory.d.ts +8 -3
- package/dist/vectorstore/memory.js +27 -4
- package/dist/watcher.d.ts +8 -0
- package/dist/watcher.js +237 -85
- package/dist/web/api.d.ts +5 -1
- package/dist/web/api.js +195 -69
- package/dist/web/server.d.ts +2 -0
- package/dist/web/server.js +66 -28
- package/dist/web/static.d.ts +5 -2
- package/dist/web/static.js +9 -5
- package/dist/web/ui/assets/index-BDPYdtA1.js +3 -0
- package/dist/web/ui/index.html +1 -1
- package/package.json +1 -1
- package/dist/web/ui/assets/index-CJBvt6e0.js +0 -3
package/dist/indexer/pipeline.js
CHANGED
|
@@ -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
|
-
|
|
71
|
-
|
|
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
|
-
|
|
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
|
-
|
|
309
|
-
|
|
310
|
-
const
|
|
311
|
-
(
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
if (
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
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);
|
|
348
|
+
}
|
|
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,485 @@ 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
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
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
|
+
/**
|
|
386
|
+
* Store one window's prepared files into the vector store and update the
|
|
387
|
+
* manifest. Runs as a standalone async step so the caller can overlap it
|
|
388
|
+
* with the next window's prepare/describe/embed phases (the store phase is
|
|
389
|
+
* I/O-bound; the embed phase is GPU/API-bound).
|
|
390
|
+
*
|
|
391
|
+
* All chunks in the window are written in ONE bulk transaction (a single
|
|
392
|
+
* table.add plus one delete per modified file) instead of per-file adds
|
|
393
|
+
* plus per-startLine deletes. Per-file transactions caused LanceDB
|
|
394
|
+
* version-manifest accumulation (K+2 versions per file) that made the
|
|
395
|
+
* store phase degrade quadratically as the index grew.
|
|
396
|
+
*/
|
|
397
|
+
async function storeWindow(prepared, windowEarlyResults) {
|
|
398
|
+
const filesToStore = prepared.filter((p) => !windowEarlyResults.has(prepared.indexOf(p)) && p.chunks && (p.textToEmbed?.length ?? 0) > 0).length;
|
|
399
|
+
const storePhaseStart = Date.now();
|
|
400
|
+
logger.info(`Store phase: storing ${filesToStore} file(s) into vector database...`);
|
|
401
|
+
let storedFiles = 0;
|
|
402
|
+
// Compute per-file store payloads first (pure computation, no I/O).
|
|
403
|
+
const storePayloads = [];
|
|
404
|
+
for (const prep of prepared) {
|
|
405
|
+
if (aborted())
|
|
406
|
+
break;
|
|
407
|
+
if (windowEarlyResults.has(prepared.indexOf(prep)))
|
|
408
|
+
continue;
|
|
409
|
+
if (!prep.chunks || prep.textToEmbed?.length === 0)
|
|
410
|
+
continue;
|
|
411
|
+
const validChunks = (prep.chunks ?? []).filter((c) => c.embedding && c.embedding.length > 0);
|
|
412
|
+
// Partial embed failure: some chunks have no vector. Do NOT record the
|
|
413
|
+
// file as complete — keep the previous manifest entry (or none, for
|
|
414
|
+
// new files) so the next pass retries the missing chunks.
|
|
415
|
+
const allEmbedded = validChunks.length === (prep.chunks?.length ?? 0);
|
|
416
|
+
if (!allEmbedded && (prep.chunks?.length ?? 0) > 0) {
|
|
417
|
+
options.logger?.warn?.(` ${prep.fileLabel}: ${validChunks.length}/${prep.chunks?.length} chunks embedded — marking for retry on next pass`);
|
|
355
418
|
}
|
|
356
|
-
|
|
357
|
-
|
|
419
|
+
storePayloads.push({ prep, validChunks, allEmbedded });
|
|
420
|
+
}
|
|
421
|
+
// One bulk write per window. Dedup (per-modified-file deletes) is skipped
|
|
422
|
+
// when the store provably has no prior rows: during a full rebuild the
|
|
423
|
+
// temp store starts empty, and on a first-time index the store counted 0
|
|
424
|
+
// chunks at the start of the pass. Nothing can collide there, so the
|
|
425
|
+
// delete transactions (which would each scan the table) are pure waste.
|
|
426
|
+
const skipDedup = tempStorePath !== undefined || existingCount === 0;
|
|
427
|
+
if (storePayloads.length > 0) {
|
|
428
|
+
const bulkItems = storePayloads.map(({ validChunks }) => ({
|
|
429
|
+
chunks: validChunks,
|
|
430
|
+
dedup: !skipDedup,
|
|
431
|
+
}));
|
|
432
|
+
if (effectiveStore.addChunksBulk) {
|
|
433
|
+
await effectiveStore.addChunksBulk(bulkItems);
|
|
358
434
|
}
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
for (const chunk of prep.chunks ?? []) {
|
|
364
|
-
chunkToFileLabel.set(chunk.id, prep.fileLabel);
|
|
435
|
+
else {
|
|
436
|
+
// Fallback for stores without bulk-write support
|
|
437
|
+
for (const item of bulkItems) {
|
|
438
|
+
await effectiveStore.addChunks(item.chunks, { dedup: item.dedup });
|
|
365
439
|
}
|
|
366
440
|
}
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
441
|
+
}
|
|
442
|
+
// Manifest updates + progress reporting (per file, in-memory only).
|
|
443
|
+
const storeResults = [];
|
|
444
|
+
for (let fi = 0; fi < prepared.length; fi++) {
|
|
445
|
+
if (aborted()) {
|
|
446
|
+
storeResults.push({ normalizedPath: prepared[fi].normalizedPath, skipped: true });
|
|
447
|
+
break;
|
|
448
|
+
}
|
|
449
|
+
// Return early results from phase 1
|
|
450
|
+
const earlyResult = windowEarlyResults.get(fi);
|
|
451
|
+
if (earlyResult) {
|
|
452
|
+
storeResults.push(earlyResult);
|
|
453
|
+
continue;
|
|
454
|
+
}
|
|
455
|
+
const prep = prepared[fi];
|
|
456
|
+
// No-embed path (shouldn't reach here but guard anyway)
|
|
457
|
+
if (!prep.chunks || prep.textToEmbed?.length === 0) {
|
|
458
|
+
options.progress?.finishFile(prep.fileLabel);
|
|
459
|
+
storeResults.push({
|
|
460
|
+
normalizedPath: prep.normalizedPath, hash: prep.hash, chunkCount: 0,
|
|
461
|
+
fileLabel: prep.fileLabel,
|
|
462
|
+
isNew: false, isModified: false, isUnchanged: false, isEmpty: false,
|
|
463
|
+
isTooSmall: false, isRemoved: true, hadChunks: false,
|
|
464
|
+
descriptionFailed: prep.descriptionFailed,
|
|
465
|
+
});
|
|
466
|
+
continue;
|
|
467
|
+
}
|
|
468
|
+
const payload = storePayloads.find((p) => p.prep === prep);
|
|
469
|
+
const validChunks = payload?.validChunks ?? [];
|
|
470
|
+
const allEmbedded = payload?.allEmbedded ?? false;
|
|
471
|
+
const result = {
|
|
472
|
+
normalizedPath: prep.normalizedPath,
|
|
473
|
+
hash: prep.hash,
|
|
474
|
+
chunkCount: validChunks.length,
|
|
475
|
+
fileLabel: prep.fileLabel,
|
|
476
|
+
isNew: !prep.isModified,
|
|
477
|
+
isModified: prep.isModified,
|
|
478
|
+
isUnchanged: false,
|
|
479
|
+
isEmpty: false,
|
|
480
|
+
isTooSmall: false,
|
|
481
|
+
isRemoved: (prep.chunks?.length ?? 0) > 0 && validChunks.length === 0 ? false : validChunks.length === 0,
|
|
482
|
+
hadChunks: (prep.chunks?.length ?? 0) > 0,
|
|
483
|
+
descriptionFailed: prep.descriptionFailed,
|
|
484
|
+
descHash: prep.descHash,
|
|
485
|
+
};
|
|
486
|
+
// Update manifest — only when ALL chunks were embedded. A partial
|
|
487
|
+
// write must not bump the hash/chunkCount, otherwise the missing
|
|
488
|
+
// chunks would never be retried (hash match on the next pass).
|
|
489
|
+
if (result.chunkCount > 0 && !result.isRemoved && allEmbedded) {
|
|
490
|
+
const meta = fileMeta.get(result.normalizedPath);
|
|
491
|
+
const entry = {
|
|
492
|
+
hash: result.hash,
|
|
493
|
+
chunkCount: result.chunkCount,
|
|
494
|
+
indexedAt: Date.now(),
|
|
495
|
+
mtime: meta?.mtime,
|
|
496
|
+
size: meta?.size,
|
|
497
|
+
descriptionFailed: result.descriptionFailed,
|
|
498
|
+
};
|
|
499
|
+
if (result.descHash) {
|
|
500
|
+
entry.descHash = result.descHash;
|
|
382
501
|
}
|
|
502
|
+
manifest.files[result.normalizedPath] = entry;
|
|
503
|
+
enqueueManifestSave();
|
|
383
504
|
}
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
505
|
+
else if (result.isRemoved) {
|
|
506
|
+
delete manifest.files[result.normalizedPath];
|
|
507
|
+
enqueueManifestSave();
|
|
508
|
+
}
|
|
509
|
+
options.progress?.finishFile(prep.fileLabel);
|
|
510
|
+
storedFiles++;
|
|
511
|
+
logChunkProgress("Storing", prep.fileLabel, storedFiles, filesToStore, storedFiles, filesToStore);
|
|
512
|
+
storeResults.push(result);
|
|
513
|
+
}
|
|
514
|
+
const workerResults = storeResults;
|
|
515
|
+
for (const r of workerResults) {
|
|
516
|
+
if (r.skipped) {
|
|
517
|
+
abortedInWindow = true;
|
|
518
|
+
break;
|
|
519
|
+
}
|
|
520
|
+
allFinalResults.push(r);
|
|
521
|
+
}
|
|
522
|
+
aggregateStats(stats, allFinalResults.slice(-workerResults.length));
|
|
523
|
+
const storePhaseSec = ((Date.now() - storePhaseStart) / 1000).toFixed(1);
|
|
524
|
+
logger.info(`Store phase: ${storedFiles} files stored in ${storePhaseSec}s (running total ${stats.totalChunks} chunks)`);
|
|
525
|
+
// Free this window's chunk payloads so GC can reclaim them before the
|
|
526
|
+
// next window is prepared.
|
|
527
|
+
for (const prep of prepared) {
|
|
528
|
+
prep.chunks = undefined;
|
|
529
|
+
prep.textToEmbed = undefined;
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
// ── Windowed pipeline ────────────────────────────────────────────────────
|
|
533
|
+
// The prepare→describe→embed→store chain used to materialize ALL chunks,
|
|
534
|
+
// embed texts, and vectors of the whole workspace simultaneously (hundreds
|
|
535
|
+
// of MB on large repos). Processing bounded windows of files keeps peak
|
|
536
|
+
// memory proportional to the window size instead of the workspace size.
|
|
537
|
+
//
|
|
538
|
+
// The store phase is launched as a non-awaited promise per window so it
|
|
539
|
+
// overlaps the next window's prepare/describe/embed (the store is I/O-bound,
|
|
540
|
+
// the embed is GPU/API-bound). Stores themselves stay serialized: the next
|
|
541
|
+
// window's store only starts after the previous one resolves.
|
|
542
|
+
const WINDOW_SIZE = Math.max(50, (options.config.indexing.concurrency ?? 4) * 10);
|
|
543
|
+
const allFinalResults = [];
|
|
544
|
+
let abortedInWindow = false;
|
|
545
|
+
const earlyWorkerResults = new Map();
|
|
546
|
+
let prevStore = null;
|
|
547
|
+
let windowCount = 0;
|
|
548
|
+
const optimizeInterval = options.config.indexing.optimizeIntervalWindows ?? 8;
|
|
549
|
+
for (let windowStart = 0; windowStart < workspaceFiles.length; windowStart += WINDOW_SIZE) {
|
|
550
|
+
if (aborted()) {
|
|
551
|
+
abortedInWindow = true;
|
|
552
|
+
break;
|
|
553
|
+
}
|
|
554
|
+
windowCount++;
|
|
555
|
+
const windowFiles = workspaceFiles.slice(windowStart, windowStart + WINDOW_SIZE);
|
|
556
|
+
const prepared = await prepareWindow(windowFiles);
|
|
557
|
+
if (windowStart === 0) {
|
|
558
|
+
const chunkPhaseSec = ((Date.now() - chunkPhaseStart) / 1000).toFixed(1);
|
|
559
|
+
logger.info(`Chunking complete: ${activeCount} files in ${chunkPhaseSec}s`);
|
|
560
|
+
}
|
|
561
|
+
if (deferDescriptions) {
|
|
562
|
+
const deferredPreps = prepared.filter((p) => p.chunks && p.chunks.length > 0 && p.relPath !== undefined);
|
|
563
|
+
if (deferredPreps.length > 0) {
|
|
564
|
+
const allChunks = [];
|
|
565
|
+
const oversizedChunks = [];
|
|
566
|
+
const maxContentChars = options.config.description?.maxContentChars;
|
|
567
|
+
logger.info(`Description phase: ${deferredPreps.length} files with chunks to describe`);
|
|
568
|
+
for (const prep of deferredPreps) {
|
|
569
|
+
for (const chunk of prep.chunks) {
|
|
570
|
+
if (chunk.metadata.contentType !== "image") {
|
|
571
|
+
if (maxContentChars && chunk.content.length > maxContentChars) {
|
|
572
|
+
oversizedChunks.push(chunk);
|
|
573
|
+
}
|
|
574
|
+
else {
|
|
575
|
+
allChunks.push(chunk);
|
|
576
|
+
}
|
|
577
|
+
}
|
|
400
578
|
}
|
|
401
579
|
}
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
for (const { chunk, desc } of cacheHits) {
|
|
405
|
-
chunk.description = desc;
|
|
406
|
-
}
|
|
580
|
+
for (const chunk of oversizedChunks) {
|
|
581
|
+
chunk.description = buildFallbackDescription(chunk);
|
|
407
582
|
}
|
|
408
|
-
//
|
|
409
|
-
|
|
583
|
+
// Per-file chunk index + label for clear progress reporting, plus a shared
|
|
584
|
+
// progress logger emitting "stage <file> (chunk i/n) — X/total remaining (P%)".
|
|
585
|
+
const chunkToFileLabel = new Map();
|
|
410
586
|
for (const prep of deferredPreps) {
|
|
411
587
|
for (const chunk of prep.chunks ?? []) {
|
|
412
|
-
|
|
588
|
+
chunkToFileLabel.set(chunk.id, prep.fileLabel);
|
|
413
589
|
}
|
|
414
590
|
}
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
const
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
591
|
+
const chunkMeta = new Map();
|
|
592
|
+
{
|
|
593
|
+
const perFileCount = new Map();
|
|
594
|
+
for (const chunk of allChunks) {
|
|
595
|
+
perFileCount.set(chunk.metadata.filePath, (perFileCount.get(chunk.metadata.filePath) ?? 0) + 1);
|
|
596
|
+
}
|
|
597
|
+
const perFileSeen = new Map();
|
|
598
|
+
for (const chunk of allChunks) {
|
|
599
|
+
const seen = perFileSeen.get(chunk.metadata.filePath) ?? 0;
|
|
600
|
+
perFileSeen.set(chunk.metadata.filePath, seen + 1);
|
|
601
|
+
chunkMeta.set(chunk.id, {
|
|
602
|
+
fileLabel: chunkToFileLabel.get(chunk.id) ?? chunk.metadata.filePath,
|
|
603
|
+
index: seen + 1,
|
|
604
|
+
count: perFileCount.get(chunk.metadata.filePath) ?? 1,
|
|
605
|
+
});
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
// Advance progress to Description stage before descriptions start
|
|
609
|
+
for (const prep of deferredPreps) {
|
|
610
|
+
options.progress?.finishStage(prep.fileLabel);
|
|
611
|
+
}
|
|
612
|
+
if (allChunks.length > 0 && descHash) {
|
|
613
|
+
// Check description cache first — reuse cached descriptions for unchanged chunks.
|
|
614
|
+
// The key includes the chunk's file context: identical content in two
|
|
615
|
+
// files must not reuse a contextually wrong description.
|
|
616
|
+
const chunkContext = (c) => `${c.metadata.filePath}:${c.metadata.startLine}-${c.metadata.endLine}`;
|
|
617
|
+
const cacheHits = [];
|
|
618
|
+
const cacheMisses = [];
|
|
619
|
+
for (const chunk of allChunks) {
|
|
620
|
+
const cacheKey = DescriptionCache.codeKey(chunk.content, descHash, chunkContext(chunk));
|
|
621
|
+
const cached = descCache.get(cacheKey);
|
|
622
|
+
if (cached) {
|
|
623
|
+
cacheHits.push({ chunk, desc: cached });
|
|
624
|
+
}
|
|
625
|
+
else {
|
|
626
|
+
cacheMisses.push(chunk);
|
|
627
|
+
}
|
|
429
628
|
}
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
629
|
+
if (cacheHits.length > 0) {
|
|
630
|
+
logger.debug(` Using ${cacheHits.length} cached descriptions`);
|
|
631
|
+
for (const { chunk, desc } of cacheHits) {
|
|
632
|
+
chunk.description = desc;
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
// Build chunkId → prep map for tracking description failures per-file
|
|
636
|
+
const chunkToPrep = new Map();
|
|
637
|
+
for (const prep of deferredPreps) {
|
|
638
|
+
for (const chunk of prep.chunks ?? []) {
|
|
639
|
+
chunkToPrep.set(chunk.id, prep);
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
// Process cache misses in parallel waves with throttled cache saves.
|
|
643
|
+
// Parallel sub-batches reduce serial LLM turnaround time.
|
|
644
|
+
// Cache saves are throttled to avoid O(n^2) full-JSON serialization I/O.
|
|
645
|
+
if (cacheMisses.length > 0) {
|
|
646
|
+
const totalMisses = cacheMisses.length;
|
|
647
|
+
const descConcurrency = options.config.indexing.descriptionConcurrency ?? 4;
|
|
648
|
+
const SUB_BATCH = 10;
|
|
649
|
+
let describedDone = 0;
|
|
650
|
+
let describedSinceLastSave = 0;
|
|
651
|
+
const SAVE_INTERVAL = 200;
|
|
652
|
+
const descLimit = pLimit(descConcurrency);
|
|
653
|
+
const subBatchTasks = [];
|
|
654
|
+
for (let i = 0; i < totalMisses; i += SUB_BATCH) {
|
|
655
|
+
subBatchTasks.push({ i, chunks: cacheMisses.slice(i, i + SUB_BATCH) });
|
|
656
|
+
}
|
|
657
|
+
await Promise.all(subBatchTasks.map(({ i, chunks: subBatch }) => descLimit(async () => {
|
|
658
|
+
if (aborted())
|
|
659
|
+
return;
|
|
660
|
+
try {
|
|
661
|
+
const batchResult = await options.descriptionProvider.generateBatchDescriptions(subBatch, logger, {
|
|
662
|
+
total: totalMisses,
|
|
663
|
+
onProgress: (chunk) => {
|
|
664
|
+
describedDone++;
|
|
665
|
+
const meta = chunkMeta.get(chunk.id);
|
|
666
|
+
if (meta)
|
|
667
|
+
logChunkProgress("Describing", meta.fileLabel, meta.index, meta.count, describedDone, totalMisses);
|
|
668
|
+
},
|
|
669
|
+
});
|
|
670
|
+
const newCacheEntries = [];
|
|
671
|
+
for (const chunk of subBatch) {
|
|
672
|
+
const desc = batchResult.get(chunk.id);
|
|
673
|
+
if (desc && desc.trim().length > 0) {
|
|
674
|
+
chunk.description = desc;
|
|
675
|
+
newCacheEntries.push([DescriptionCache.codeKey(chunk.content, descHash, chunkContext(chunk)), desc]);
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
if (newCacheEntries.length > 0) {
|
|
679
|
+
descCache.setMany(newCacheEntries);
|
|
680
|
+
describedSinceLastSave += newCacheEntries.length;
|
|
681
|
+
if (describedSinceLastSave >= SAVE_INTERVAL) {
|
|
682
|
+
await descCache.save();
|
|
683
|
+
describedSinceLastSave = 0;
|
|
684
|
+
}
|
|
449
685
|
}
|
|
450
686
|
}
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
687
|
+
catch (err) {
|
|
688
|
+
logger.warn(` Description sub-batch failed (${i}-${i + subBatch.length}): ${err.message}`);
|
|
689
|
+
const failedPreps = new Set();
|
|
690
|
+
for (const chunk of subBatch) {
|
|
691
|
+
const prep = chunkToPrep.get(chunk.id);
|
|
692
|
+
if (prep)
|
|
693
|
+
failedPreps.add(prep);
|
|
694
|
+
}
|
|
695
|
+
for (const prep of failedPreps) {
|
|
696
|
+
prep.descriptionFailed = true;
|
|
457
697
|
}
|
|
458
698
|
}
|
|
699
|
+
})));
|
|
700
|
+
if (describedSinceLastSave > 0) {
|
|
701
|
+
await descCache.save();
|
|
459
702
|
}
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
703
|
+
logger.debug(`Descriptions generated for ${totalMisses} chunks (concurrency: ${descConcurrency})`);
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
else if (allChunks.length > 0) {
|
|
707
|
+
// No descHash available (no description provider) — still generate descriptions
|
|
708
|
+
let describedDone = 0;
|
|
709
|
+
try {
|
|
710
|
+
const batchResult = await options.descriptionProvider.generateBatchDescriptions(allChunks, logger, {
|
|
711
|
+
total: allChunks.length,
|
|
712
|
+
onProgress: (chunk) => {
|
|
713
|
+
describedDone++;
|
|
714
|
+
const meta = chunkMeta.get(chunk.id);
|
|
715
|
+
if (meta)
|
|
716
|
+
logChunkProgress("Describing", meta.fileLabel, meta.index, meta.count, describedDone, allChunks.length);
|
|
717
|
+
},
|
|
718
|
+
});
|
|
719
|
+
for (const chunk of allChunks) {
|
|
720
|
+
const desc = batchResult.get(chunk.id);
|
|
721
|
+
if (desc && desc.trim().length > 0) {
|
|
722
|
+
chunk.description = desc;
|
|
467
723
|
}
|
|
468
|
-
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
catch (err) {
|
|
727
|
+
logger.warn(` Global description generation failed: ${err.message}`);
|
|
728
|
+
for (const prep of deferredPreps) {
|
|
729
|
+
if (prep.chunks.some((c) => c.metadata.contentType !== "image")) {
|
|
469
730
|
prep.descriptionFailed = true;
|
|
470
731
|
}
|
|
471
732
|
}
|
|
472
|
-
})));
|
|
473
|
-
if (describedSinceLastSave > 0) {
|
|
474
|
-
await descCache.save();
|
|
475
733
|
}
|
|
476
|
-
logger.debug(`Descriptions generated for ${totalMisses} chunks (concurrency: ${descConcurrency})`);
|
|
477
734
|
}
|
|
478
|
-
|
|
479
|
-
|
|
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;
|
|
496
|
-
}
|
|
497
|
-
}
|
|
735
|
+
for (const prep of deferredPreps) {
|
|
736
|
+
prep.textToEmbed = buildTextsToEmbed(prep.chunks, prep.relPath, prep.metaHeader ?? "", prep.docPrefix ?? "", prep.isImageFile ?? false);
|
|
498
737
|
}
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
738
|
+
const totalDescribedChunks = deferredPreps.reduce((s, p) => s + (p.chunks?.length ?? 0), 0);
|
|
739
|
+
logger.info(`Description phase complete: ${totalDescribedChunks} chunks across ${deferredPreps.length} files`);
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
// Cross-file embedding batch: collect this window's texts into a single queue,
|
|
743
|
+
// embed in one batched call (with concurrency), then distribute back.
|
|
744
|
+
const isOllama = options.embedder.name === "ollama";
|
|
745
|
+
const defaultBatchSize = options.config.indexing.embedBatchSize;
|
|
746
|
+
const defaultConcurrency = options.config.indexing.embedConcurrency ?? 1;
|
|
747
|
+
// ── Phase 1: Collect embed queue + handle early results ────────────────
|
|
748
|
+
const embedQueue = [];
|
|
749
|
+
let totalEmbedChunks = 0;
|
|
750
|
+
earlyWorkerResults.clear();
|
|
751
|
+
for (let fi = 0; fi < prepared.length; fi++) {
|
|
752
|
+
const prep = prepared[fi];
|
|
753
|
+
if (prep.earlyResult) {
|
|
754
|
+
if (prep.earlyResult.isRemoved) {
|
|
755
|
+
await effectiveStore.deleteByFilePath(prep.normalizedPath);
|
|
756
|
+
options.keywordIndex?.removeByFilePath(prep.normalizedPath);
|
|
757
|
+
delete manifest.files[prep.normalizedPath];
|
|
758
|
+
enqueueManifestSave();
|
|
506
759
|
}
|
|
760
|
+
earlyWorkerResults.set(fi, prep.earlyResult);
|
|
761
|
+
continue;
|
|
507
762
|
}
|
|
508
|
-
|
|
509
|
-
|
|
763
|
+
if (!prep.chunks || !prep.textToEmbed || prep.textToEmbed.length === 0) {
|
|
764
|
+
options.progress?.finishFile(prep.fileLabel);
|
|
765
|
+
earlyWorkerResults.set(fi, {
|
|
766
|
+
normalizedPath: prep.normalizedPath, hash: prep.hash, chunkCount: 0,
|
|
767
|
+
fileLabel: prep.fileLabel,
|
|
768
|
+
isNew: false, isModified: false, isUnchanged: false, isEmpty: false,
|
|
769
|
+
isTooSmall: false, isRemoved: true, hadChunks: false,
|
|
770
|
+
descriptionFailed: prep.descriptionFailed,
|
|
771
|
+
});
|
|
772
|
+
continue;
|
|
510
773
|
}
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
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();
|
|
774
|
+
options.progress?.finishStage(prep.fileLabel);
|
|
775
|
+
for (let ci = 0; ci < prep.textToEmbed.length; ci++) {
|
|
776
|
+
embedQueue.push({ fileIdx: fi, chunkIdx: ci, text: prep.textToEmbed[ci] });
|
|
546
777
|
}
|
|
547
|
-
|
|
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`);
|
|
778
|
+
totalEmbedChunks += prep.textToEmbed.length;
|
|
583
779
|
}
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
780
|
+
// ── Phase 2: Embed all texts in a single batched call ──────────────────
|
|
781
|
+
const batchSize = isOllama
|
|
782
|
+
? Math.min(options.config.indexing.ollamaMaxBatchSize ?? 500, defaultBatchSize)
|
|
783
|
+
: defaultBatchSize;
|
|
784
|
+
let embeddedDone = 0;
|
|
785
|
+
const allTexts = embedQueue.map(item => item.text);
|
|
786
|
+
let allEmbeddings = [];
|
|
787
|
+
const embedPhaseStart = Date.now();
|
|
788
|
+
if (allTexts.length > 0) {
|
|
789
|
+
logger.info(`Embedding phase: ${allTexts.length} texts in batches of ${batchSize}...`);
|
|
790
|
+
try {
|
|
791
|
+
allEmbeddings = await embedBatch(options.embedder, allTexts, batchSize, "document", defaultConcurrency, (completed, total) => {
|
|
792
|
+
embeddedDone = completed;
|
|
793
|
+
logChunkProgress("Embedding", "", completed, total, embeddedDone, totalEmbedChunks);
|
|
595
794
|
});
|
|
795
|
+
logger.info(`Embedding complete: ${allTexts.length} texts in ${((Date.now() - embedPhaseStart) / 1000).toFixed(1)}s`);
|
|
796
|
+
}
|
|
797
|
+
catch (err) {
|
|
798
|
+
logger.warn(` Global embedding failed: ${err.message}`);
|
|
799
|
+
for (const { fileIdx } of embedQueue) {
|
|
800
|
+
options.progress?.failFile(prepared[fileIdx].fileLabel);
|
|
801
|
+
earlyWorkerResults.set(fileIdx, {
|
|
802
|
+
normalizedPath: prepared[fileIdx].normalizedPath,
|
|
803
|
+
hash: prepared[fileIdx].hash,
|
|
804
|
+
chunkCount: 0, fileLabel: prepared[fileIdx].fileLabel,
|
|
805
|
+
isNew: false, isModified: false, isUnchanged: false, isEmpty: false,
|
|
806
|
+
isTooSmall: false, isRemoved: false, hadChunks: false,
|
|
807
|
+
descriptionFailed: prepared[fileIdx].descriptionFailed,
|
|
808
|
+
});
|
|
809
|
+
}
|
|
810
|
+
embedQueue.length = 0; // prevent double-processing in store phase
|
|
596
811
|
}
|
|
597
|
-
embedQueue.length = 0; // prevent double-processing in store phase
|
|
598
812
|
}
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
813
|
+
// ── Distribute embeddings back to per-file chunks ─────────────────────
|
|
814
|
+
for (let i = 0; i < embedQueue.length; i++) {
|
|
815
|
+
const { fileIdx, chunkIdx } = embedQueue[i];
|
|
816
|
+
const emb = allEmbeddings[i];
|
|
817
|
+
const prep = prepared[fileIdx];
|
|
818
|
+
if (prep.chunks && prep.chunks[chunkIdx] && Array.isArray(emb) && emb.length > 0 && typeof emb[0] === "number") {
|
|
819
|
+
prep.chunks[chunkIdx].embedding = emb;
|
|
820
|
+
}
|
|
607
821
|
}
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
//
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
fileLabel: prep.fileLabel,
|
|
629
|
-
isNew: false, isModified: false, isUnchanged: false, isEmpty: false,
|
|
630
|
-
isTooSmall: false, isRemoved: true, hadChunks: false,
|
|
631
|
-
descriptionFailed: prep.descriptionFailed,
|
|
632
|
-
};
|
|
822
|
+
// ── Phase 3: Store + manifest update per file ─────────────────────────
|
|
823
|
+
// The store phase is I/O-bound, so it is launched without awaiting and
|
|
824
|
+
// overlaps the next window's prepare/describe/embed (GPU/API-bound)
|
|
825
|
+
// phases. Stores stay serialized: the previous window's store is drained
|
|
826
|
+
// before the next one starts.
|
|
827
|
+
if (prevStore)
|
|
828
|
+
await prevStore;
|
|
829
|
+
// Snapshot the current window's early results BEFORE the next window's
|
|
830
|
+
// embed phase clears/repopulates the shared map — storeWindow runs
|
|
831
|
+
// asynchronously (overlapped with the next window's prepare/embed), so a
|
|
832
|
+
// shared mutable map would make its per-file loop read the WRONG window's
|
|
833
|
+
// entries and skip every file ("Store phase: storing N → 0 files stored").
|
|
834
|
+
prevStore = storeWindow(prepared, new Map(earlyWorkerResults));
|
|
835
|
+
// Periodically compact fragments and prune old versions so the store
|
|
836
|
+
// phase doesn't slow down as the index grows during long runs.
|
|
837
|
+
if (optimizeInterval > 0 && windowCount % optimizeInterval === 0) {
|
|
838
|
+
await prevStore;
|
|
839
|
+
prevStore = null;
|
|
840
|
+
logger.info("Optimizing vector store (mid-run compaction, pruning old versions)...");
|
|
841
|
+
await effectiveStore.optimize?.(tempStorePath ? { aggressive: true } : undefined);
|
|
633
842
|
}
|
|
634
|
-
|
|
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;
|
|
667
|
-
}
|
|
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)
|
|
843
|
+
if (abortedInWindow)
|
|
684
844
|
break;
|
|
685
|
-
|
|
686
|
-
|
|
845
|
+
} // end windowed pipeline loop
|
|
846
|
+
// Drain the final window's store before finishing.
|
|
847
|
+
if (prevStore)
|
|
848
|
+
await prevStore;
|
|
849
|
+
const finalResults = allFinalResults;
|
|
687
850
|
// Drain any in-flight manifest saves so all file entries are durable
|
|
688
851
|
await manifestSaveChain;
|
|
689
852
|
// Update mtime/size for unchanged files (speeds up the next scan)
|
|
@@ -694,9 +857,7 @@ async function runIndexPassInner(options, logger) {
|
|
|
694
857
|
entry.size = size;
|
|
695
858
|
}
|
|
696
859
|
}
|
|
697
|
-
|
|
698
|
-
logger.info(`Store phase complete: ${storedFiles} files stored in ${storePhaseSec}s (${stats.totalChunks} total chunks)`);
|
|
699
|
-
aggregateStats(stats, finalResults);
|
|
860
|
+
logger.info(`Index pass complete: ${stats.totalChunks} total chunks (${finalResults.length} files processed)`);
|
|
700
861
|
// Update timestamps; advance lastGitCommit ONLY on a complete pass
|
|
701
862
|
manifest.lastIndexedAt = Date.now();
|
|
702
863
|
if (!aborted()) {
|
|
@@ -745,12 +906,16 @@ async function runIndexPassInner(options, logger) {
|
|
|
745
906
|
await saveManifest(options.storePath, manifest);
|
|
746
907
|
await options.keywordIndex?.save(options.storePath);
|
|
747
908
|
// Compact fragments and prune old version manifests so countRows() can't
|
|
748
|
-
// hang on accumulated versions from many add/delete cycles.
|
|
909
|
+
// hang on accumulated versions from many add/delete cycles. After a temp
|
|
910
|
+
// store rebuild, optimize the reopened real store handle (the temp handle
|
|
911
|
+
// was closed and its directory moved); use aggressive pruning since the
|
|
912
|
+
// swapped-in store is private to this process.
|
|
749
913
|
if (!aborted()) {
|
|
750
914
|
logger.info("Optimizing vector store (compacting fragments, pruning old versions)...");
|
|
751
915
|
const optimizeStart = Date.now();
|
|
752
916
|
try {
|
|
753
|
-
|
|
917
|
+
const optimizeTarget = tempStorePath ? options.store : effectiveStore;
|
|
918
|
+
await optimizeTarget.optimize?.(tempStorePath ? { aggressive: true } : undefined);
|
|
754
919
|
logger.info(`Vector store optimized in ${((Date.now() - optimizeStart) / 1000).toFixed(1)}s`);
|
|
755
920
|
}
|
|
756
921
|
catch (err) {
|