pi-context-engine 0.2.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -0
- package/package.json +1 -2
- package/src/config.ts +4 -0
- package/src/engine.ts +32 -1
- package/src/extension.ts +108 -8
- package/src/indexer/embedder.ts +16 -4
- package/src/indexer/manifest.ts +35 -17
- package/src/indexer/pipeline.ts +324 -286
- package/src/memory/db.ts +45 -59
- package/src/storage/local-backend.ts +0 -4
- package/src/storage/vector-store.ts +13 -29
- package/src/utils.ts +13 -0
package/src/indexer/pipeline.ts
CHANGED
|
@@ -8,7 +8,7 @@ import fs from "node:fs";
|
|
|
8
8
|
import path from "node:path";
|
|
9
9
|
import { promisify } from "node:util";
|
|
10
10
|
import type { Config } from "../config.js";
|
|
11
|
-
import { projectStorageDir, sha256Hex } from "../utils.js";
|
|
11
|
+
import { projectStorageDir, sha256Hex, yieldToEventLoop } from "../utils.js";
|
|
12
12
|
import { Chunker, isAstLanguage, warmupLanguages } from "./chunker.js";
|
|
13
13
|
import { applyContextHeaderToChunk, stripContextHeader } from "./context-header.js";
|
|
14
14
|
import { Embedder, selectBackend } from "./embedder.js";
|
|
@@ -366,7 +366,11 @@ export async function indexCommits(
|
|
|
366
366
|
return { chunks, nodes, edges, headSha };
|
|
367
367
|
}
|
|
368
368
|
|
|
369
|
-
/**
|
|
369
|
+
/**
|
|
370
|
+
* Per-storage-dir indexing lock so concurrent reindex runs (auto-index kick,
|
|
371
|
+
* lazy search kick, manual /context-index; plus a future file watcher, #12)
|
|
372
|
+
* can't interleave.
|
|
373
|
+
*/
|
|
370
374
|
const pipelineLocks = new Map<string, Promise<unknown>>();
|
|
371
375
|
|
|
372
376
|
export async function runIndexing(
|
|
@@ -402,7 +406,8 @@ async function runIndexingLocked(
|
|
|
402
406
|
options: { full?: boolean; targetPath?: string | null; callbacks?: IndexCallbacks },
|
|
403
407
|
runtime?: { backend: LocalBackend; embedder: Embedder },
|
|
404
408
|
): Promise<IndexResult> {
|
|
405
|
-
|
|
409
|
+
let { full = false, targetPath = null } = options;
|
|
410
|
+
const { callbacks = {} } = options;
|
|
406
411
|
const { log, progress, phase } = callbacks;
|
|
407
412
|
|
|
408
413
|
const ownsBackend = runtime === undefined;
|
|
@@ -425,19 +430,29 @@ async function runIndexingLocked(
|
|
|
425
430
|
// can still delete their indexed chunks instead of orphaning them.
|
|
426
431
|
const preMigrationPaths = Object.keys(manifest.fileEntries);
|
|
427
432
|
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
//
|
|
434
|
-
|
|
435
|
-
// hash alone would leave a stale, header-less index behind forever.
|
|
436
|
-
if (manifest.schemaVersion !== CURRENT_SCHEMA_VERSION) {
|
|
433
|
+
// Refuse unrecognized index formats outright (no compat reading): a
|
|
434
|
+
// manifest we can't recognize — pre-versioning, corrupt, or a future
|
|
435
|
+
// format — wipes every store and rebuilds from source below. The same gate
|
|
436
|
+
// covers format changes (e.g. v3 added R1 context headers) that alter chunk
|
|
437
|
+
// content WITHOUT changing the file on disk, which the content hash alone
|
|
438
|
+
// would never catch.
|
|
439
|
+
if (manifest.needsStoreReset) {
|
|
437
440
|
log?.(
|
|
438
|
-
` [
|
|
441
|
+
` [rebuild] index format v${manifest.schemaVersion} → v${CURRENT_SCHEMA_VERSION} — wiping stores, reindexing all files`,
|
|
439
442
|
);
|
|
443
|
+
backend.clear();
|
|
440
444
|
manifest.clearEntries();
|
|
445
|
+
// The wipe dropped the git-history chunks too; without resetting the
|
|
446
|
+
// pointer the next full run would only fetch NEW commits and the old
|
|
447
|
+
// ones would never come back.
|
|
448
|
+
manifest.lastGitSha = null;
|
|
449
|
+
// A wipe must rebuild everything in THIS run — a targeted run would
|
|
450
|
+
// leave the index empty except for the one target file.
|
|
451
|
+
if (targetPath !== null) {
|
|
452
|
+
log?.(" [rebuild] escalating targeted run to full reindex after store reset");
|
|
453
|
+
targetPath = null;
|
|
454
|
+
}
|
|
455
|
+
full = true;
|
|
441
456
|
}
|
|
442
457
|
|
|
443
458
|
const ignoreSet = new Set(config.indexerIgnore);
|
|
@@ -520,7 +535,8 @@ async function runIndexingLocked(
|
|
|
520
535
|
const underScope = (p: string): boolean => (rel === "" ? true : p === rel || p.startsWith(`${rel}/`));
|
|
521
536
|
purgeIndexed(Object.keys(manifest.fileEntries).filter((p) => underScope(p) && !yielded.has(p)));
|
|
522
537
|
} else {
|
|
523
|
-
//
|
|
538
|
+
// Target vanished between runs (deleted file, or a branch switch removed it):
|
|
539
|
+
// prune instead of erroring out.
|
|
524
540
|
const rel = path.relative(projectDir, target).replace(/\\/g, "/");
|
|
525
541
|
if (manifest.getHash(rel) !== undefined) {
|
|
526
542
|
backend.deleteByFiles([rel]);
|
|
@@ -583,316 +599,338 @@ async function runIndexingLocked(
|
|
|
583
599
|
return { embedder: holders.embedder, cache: holders.cache };
|
|
584
600
|
};
|
|
585
601
|
|
|
586
|
-
//
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
);
|
|
593
|
-
manifest.clearEntries();
|
|
594
|
-
}
|
|
595
|
-
}
|
|
602
|
+
// Everything after this point can reject (embed failures, backend
|
|
603
|
+
// crashes, IO). The finally at the end of the function releases the
|
|
604
|
+
// cache/backend handles on every path: an open handle wedges re-runs
|
|
605
|
+
// and tests (on Windows an open handle makes the storage directory
|
|
606
|
+
// undeletable, which is how this was caught).
|
|
607
|
+
try {
|
|
596
608
|
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
const relPath = path.relative(projectDir, filePath).replace(/\\/g, "/");
|
|
609
|
-
currentRelPaths.add(relPath);
|
|
610
|
-
let content = safeRead(filePath);
|
|
611
|
-
if (content === null) {
|
|
612
|
-
result.skippedFiles.push(relPath);
|
|
613
|
-
log?.(` [skip] ${relPath} (binary or unreadable)`);
|
|
614
|
-
continue;
|
|
615
|
-
}
|
|
616
|
-
if (config.indexerRedactSecrets) {
|
|
617
|
-
const redacted = redactSecrets(content);
|
|
618
|
-
if (redacted.labels.length > 0) {
|
|
619
|
-
content = redacted.text;
|
|
620
|
-
log?.(` [redact] ${relPath} (${redacted.labels.length} secret(s))`);
|
|
621
|
-
}
|
|
622
|
-
}
|
|
623
|
-
const contentHash = sha256Hex(content);
|
|
624
|
-
if (!full && !manifest.hasChanged(relPath, contentHash)) {
|
|
625
|
-
log?.(` [skip] ${relPath} (unchanged)`);
|
|
626
|
-
continue;
|
|
609
|
+
// Dimension migration: a backend swap invalidates every stored vector.
|
|
610
|
+
if (fileList.length > 0 && manifest.embeddingDim !== null && manifest.hasEntries) {
|
|
611
|
+
const { embedder: emb } = await ensureEmbedder();
|
|
612
|
+
if (manifest.embeddingDim !== emb.dimension) {
|
|
613
|
+
log?.(
|
|
614
|
+
` [migration] embedding dim changed (${manifest.embeddingDim} → ${emb.dimension}, backend=${emb.backendName}) — forcing full reindex`,
|
|
615
|
+
);
|
|
616
|
+
manifest.clearEntries();
|
|
617
|
+
// The dim swap dropped stored vectors (git history included); reset
|
|
618
|
+
// the pointer so the next full run re-fetches those commits too.
|
|
619
|
+
manifest.lastGitSha = null;
|
|
627
620
|
}
|
|
628
|
-
toChunk.push({
|
|
629
|
-
filePath,
|
|
630
|
-
relPath,
|
|
631
|
-
content,
|
|
632
|
-
contentHash,
|
|
633
|
-
language: LANGUAGE_MAP[path.extname(filePath).toLowerCase()] ?? "plaintext",
|
|
634
|
-
});
|
|
635
621
|
}
|
|
636
622
|
|
|
637
|
-
const
|
|
638
|
-
const
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
623
|
+
const currentRelPaths = new Set<string>();
|
|
624
|
+
const totalBatches = Math.max(1, Math.ceil(fileList.length / BATCH));
|
|
625
|
+
let announced = false;
|
|
626
|
+
let totalCacheHits = 0;
|
|
627
|
+
let totalCacheMisses = 0;
|
|
628
|
+
|
|
629
|
+
for (let batchIndex = 0; batchIndex < fileList.length; batchIndex += BATCH) {
|
|
630
|
+
// Cooperative yield: the loops below do sync I/O, tree-sitter parsing
|
|
631
|
+
// and ONNX inference on the main thread. Without a breather per unit
|
|
632
|
+
// of work the pi UI starves for the whole run (bench-event-loop.mts:
|
|
633
|
+
// ~0.5-0.9 s event-loop stalls per batch before this).
|
|
634
|
+
await yieldToEventLoop();
|
|
635
|
+
const batchPaths = fileList.slice(batchIndex, batchIndex + BATCH);
|
|
636
|
+
const toChunk: { filePath: string; relPath: string; content: string; contentHash: string; language: string }[] = [];
|
|
637
|
+
|
|
638
|
+
for (const filePath of batchPaths) {
|
|
639
|
+
await yieldToEventLoop();
|
|
640
|
+
const relPath = path.relative(projectDir, filePath).replace(/\\/g, "/");
|
|
641
|
+
currentRelPaths.add(relPath);
|
|
642
|
+
let content = safeRead(filePath);
|
|
643
|
+
if (content === null) {
|
|
644
|
+
result.skippedFiles.push(relPath);
|
|
645
|
+
log?.(` [skip] ${relPath} (binary or unreadable)`);
|
|
646
|
+
continue;
|
|
658
647
|
}
|
|
659
|
-
if (
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
chunkType: "module",
|
|
666
|
-
filePath: item.relPath,
|
|
667
|
-
startLine: 1,
|
|
668
|
-
endLine: Math.max(1, item.content.trim().split("\n").length),
|
|
669
|
-
language: item.language,
|
|
670
|
-
metadata: {},
|
|
671
|
-
embedding: null,
|
|
672
|
-
confidenceScore: 0,
|
|
673
|
-
compressedContent: null,
|
|
674
|
-
},
|
|
675
|
-
];
|
|
648
|
+
if (config.indexerRedactSecrets) {
|
|
649
|
+
const redacted = redactSecrets(content);
|
|
650
|
+
if (redacted.labels.length > 0) {
|
|
651
|
+
content = redacted.text;
|
|
652
|
+
log?.(` [redact] ${relPath} (${redacted.labels.length} secret(s))`);
|
|
653
|
+
}
|
|
676
654
|
}
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
655
|
+
const contentHash = sha256Hex(content);
|
|
656
|
+
if (!full && !manifest.hasChanged(relPath, contentHash)) {
|
|
657
|
+
log?.(` [skip] ${relPath} (unchanged)`);
|
|
658
|
+
continue;
|
|
659
|
+
}
|
|
660
|
+
toChunk.push({
|
|
661
|
+
filePath,
|
|
662
|
+
relPath,
|
|
663
|
+
content,
|
|
664
|
+
contentHash,
|
|
665
|
+
language: LANGUAGE_MAP[path.extname(filePath).toLowerCase()] ?? "plaintext",
|
|
666
|
+
});
|
|
680
667
|
}
|
|
681
668
|
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
669
|
+
const batchChunks: Chunk[] = [];
|
|
670
|
+
const batchNodes: GraphNode[] = [];
|
|
671
|
+
const batchEdges: GraphEdge[] = [];
|
|
672
|
+
const batchFilesToReplace: string[] = [];
|
|
673
|
+
const batchManifestUpdates: [string, string][] = [];
|
|
674
|
+
const batchIndexed: string[] = [];
|
|
675
|
+
|
|
676
|
+
for (const item of toChunk) {
|
|
677
|
+
await yieldToEventLoop();
|
|
678
|
+
changedRelPaths.add(item.relPath);
|
|
679
|
+
let chunks: Chunk[] = [];
|
|
680
|
+
let importedModules: string[] = [];
|
|
681
|
+
let importSpecs: string[] = [];
|
|
682
|
+
try {
|
|
683
|
+
if (isAstLanguage(item.language)) {
|
|
684
|
+
const out = chunker.chunkWithImports(item.content, item.relPath, item.language, {
|
|
685
|
+
chunking: config.indexerChunking,
|
|
686
|
+
budgetChars: config.indexerChunkBudgetChars,
|
|
687
|
+
});
|
|
688
|
+
chunks = out.chunks;
|
|
689
|
+
importedModules = out.imports;
|
|
690
|
+
importSpecs = out.importSpecs;
|
|
691
|
+
}
|
|
692
|
+
if (chunks.length === 0) {
|
|
693
|
+
// Fallback: module-level chunk (upstream _fallback_chunk).
|
|
694
|
+
chunks = [
|
|
695
|
+
{
|
|
696
|
+
id: sha256Hex(`${item.relPath}:module`).slice(0, 16),
|
|
697
|
+
content: item.content,
|
|
698
|
+
chunkType: "module",
|
|
699
|
+
filePath: item.relPath,
|
|
700
|
+
startLine: 1,
|
|
701
|
+
endLine: Math.max(1, item.content.trim().split("\n").length),
|
|
702
|
+
language: item.language,
|
|
703
|
+
metadata: {},
|
|
704
|
+
embedding: null,
|
|
705
|
+
confidenceScore: 0,
|
|
706
|
+
compressedContent: null,
|
|
707
|
+
},
|
|
708
|
+
];
|
|
709
|
+
}
|
|
710
|
+
} catch (err) {
|
|
711
|
+
result.errors.push(`Chunking failed for ${item.relPath}: ${(err as Error).message}`);
|
|
712
|
+
continue;
|
|
715
713
|
}
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
714
|
+
|
|
715
|
+
// Stamp mtime so the recency weight has real signal.
|
|
716
|
+
let mtime: number | null = null;
|
|
717
|
+
try {
|
|
718
|
+
mtime = fs.statSync(item.filePath).mtimeMs / 1000;
|
|
719
|
+
} catch {
|
|
720
|
+
mtime = null;
|
|
721
|
+
}
|
|
722
|
+
if (mtime !== null) for (const c of chunks) c.metadata["modified_ts"] = mtime;
|
|
723
|
+
|
|
724
|
+
batchFilesToReplace.push(item.relPath);
|
|
725
|
+
const fileNodeId = `file_${item.relPath}`;
|
|
721
726
|
batchNodes.push({
|
|
722
|
-
id:
|
|
723
|
-
nodeType:
|
|
724
|
-
name:
|
|
727
|
+
id: fileNodeId,
|
|
728
|
+
nodeType: "file",
|
|
729
|
+
name: path.basename(item.filePath),
|
|
725
730
|
filePath: item.relPath,
|
|
726
731
|
properties: {},
|
|
727
732
|
});
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
owners.push({ id: chunk.id, filePath: item.relPath });
|
|
733
|
+
for (const spec of importSpecs) {
|
|
734
|
+
// R7: resolve relative specifiers to a real indexed file. Before this,
|
|
735
|
+
// edges pointed at `module_<spec>` nodes that were never created, so
|
|
736
|
+
// every imports-edge JOIN silently produced nothing and
|
|
737
|
+
// related_context / getRelatedFilePaths were dead code. Bare package
|
|
738
|
+
// imports ("react", "node:fs") stay out: they resolve to nothing in
|
|
739
|
+
// the index and would only add noise.
|
|
740
|
+
const resolved = resolveImportPath(spec, item.relPath, knownFiles);
|
|
741
|
+
if (resolved) {
|
|
742
|
+
batchEdges.push({
|
|
743
|
+
sourceId: fileNodeId,
|
|
744
|
+
targetId: `file_${resolved}`,
|
|
745
|
+
edgeType: "imports",
|
|
746
|
+
properties: {},
|
|
747
|
+
});
|
|
744
748
|
}
|
|
745
749
|
}
|
|
746
|
-
|
|
747
|
-
|
|
750
|
+
for (const chunk of chunks) {
|
|
751
|
+
const nodeName =
|
|
752
|
+
(typeof chunk.metadata["unit"] === "string" && chunk.metadata["unit"]) ||
|
|
753
|
+
(chunk.content.includes("(") ? chunk.content.split("(")[0]!.split(":").pop()!.trim() : chunk.id);
|
|
754
|
+
batchNodes.push({
|
|
748
755
|
id: chunk.id,
|
|
756
|
+
nodeType: CHUNK_TO_NODE_TYPE[chunk.chunkType] ?? "module",
|
|
757
|
+
name: nodeName,
|
|
749
758
|
filePath: item.relPath,
|
|
750
|
-
|
|
759
|
+
properties: {},
|
|
760
|
+
});
|
|
761
|
+
batchEdges.push({
|
|
762
|
+
sourceId: fileNodeId,
|
|
763
|
+
targetId: chunk.id,
|
|
764
|
+
edgeType: "defines",
|
|
765
|
+
properties: {},
|
|
751
766
|
});
|
|
767
|
+
// R7 calls-graph collection (raw content, before the context header
|
|
768
|
+
// prepends file paths and `defines:` lists that would pollute the
|
|
769
|
+
// identifier set).
|
|
770
|
+
const unit = typeof chunk.metadata["unit"] === "string" ? chunk.metadata["unit"] : "";
|
|
771
|
+
if (chunk.chunkType === "function" || chunk.chunkType === "class") {
|
|
772
|
+
const short = unit.includes(".") ? unit.slice(unit.lastIndexOf(".") + 1) : unit;
|
|
773
|
+
if (short.length >= 3 && !CALL_GRAPH_STOPLIST.has(short)) {
|
|
774
|
+
let owners = symbolOwners.get(short);
|
|
775
|
+
if (!owners) symbolOwners.set(short, (owners = []));
|
|
776
|
+
owners.push({ id: chunk.id, filePath: item.relPath });
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
if (chunk.chunkType !== "module") {
|
|
780
|
+
runChunks.push({
|
|
781
|
+
id: chunk.id,
|
|
782
|
+
filePath: item.relPath,
|
|
783
|
+
identifiers: extractIdentifiers(chunk.content),
|
|
784
|
+
});
|
|
785
|
+
}
|
|
752
786
|
}
|
|
787
|
+
// R1 context headers go on LAST: graph node names above derive from raw
|
|
788
|
+
// content, and every downstream consumer (embed, FTS, compression,
|
|
789
|
+
// expand_chunk) reads `chunk.content`, so one prepend covers them all.
|
|
790
|
+
if (config.indexerContextHeaders) {
|
|
791
|
+
for (const chunk of chunks) applyContextHeaderToChunk(chunk, importedModules);
|
|
792
|
+
}
|
|
793
|
+
batchChunks.push(...chunks);
|
|
794
|
+
batchManifestUpdates.push([item.relPath, item.contentHash]);
|
|
795
|
+
batchIndexed.push(item.relPath);
|
|
753
796
|
}
|
|
754
|
-
// R1 context headers go on LAST: graph node names above derive from raw
|
|
755
|
-
// content, and every downstream consumer (embed, FTS, compression,
|
|
756
|
-
// expand_chunk) reads `chunk.content`, so one prepend covers them all.
|
|
757
|
-
if (config.indexerContextHeaders) {
|
|
758
|
-
for (const chunk of chunks) applyContextHeaderToChunk(chunk, importedModules);
|
|
759
|
-
}
|
|
760
|
-
batchChunks.push(...chunks);
|
|
761
|
-
batchManifestUpdates.push([item.relPath, item.contentHash]);
|
|
762
|
-
batchIndexed.push(item.relPath);
|
|
763
|
-
}
|
|
764
797
|
|
|
765
|
-
|
|
798
|
+
progress?.(Math.min(batchIndex + batchPaths.length, fileList.length), fileList.length);
|
|
766
799
|
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
800
|
+
if (batchChunks.length > 0) {
|
|
801
|
+
const { embedder: emb, cache: cch } = await ensureEmbedder();
|
|
802
|
+
if (phase && !announced) {
|
|
803
|
+
phase(`Embedding ${batchChunks.length.toLocaleString()} chunks${totalBatches > 1 ? ` (batch ${Math.floor(batchIndex / BATCH) + 1}/${totalBatches})` : ""}…`);
|
|
804
|
+
announced = true;
|
|
805
|
+
}
|
|
806
|
+
const stats = await embedChunks(batchChunks, emb, cch);
|
|
807
|
+
totalCacheHits += stats.hits;
|
|
808
|
+
totalCacheMisses += stats.misses;
|
|
809
|
+
// Per-batch deletes happen AFTER embed succeeds, so an embed failure
|
|
810
|
+
// can never wipe previously-indexed rows.
|
|
811
|
+
backend.deleteByFiles(batchFilesToReplace);
|
|
812
|
+
backend.ingest(batchChunks, batchNodes, batchEdges);
|
|
813
|
+
for (const [rel, hash] of batchManifestUpdates) manifest.update(rel, hash);
|
|
814
|
+
result.indexedFiles.push(...batchIndexed);
|
|
815
|
+
result.totalChunks += batchChunks.length;
|
|
816
|
+
} else if (batchFilesToReplace.length > 0) {
|
|
817
|
+
backend.deleteByFiles(batchFilesToReplace);
|
|
818
|
+
for (const [rel, hash] of batchManifestUpdates) manifest.update(rel, hash);
|
|
819
|
+
result.indexedFiles.push(...batchIndexed);
|
|
772
820
|
}
|
|
773
|
-
|
|
774
|
-
totalCacheHits += stats.hits;
|
|
775
|
-
totalCacheMisses += stats.misses;
|
|
776
|
-
// Per-batch deletes happen AFTER embed succeeds, so an embed failure
|
|
777
|
-
// can never wipe previously-indexed rows.
|
|
778
|
-
backend.deleteByFiles(batchFilesToReplace);
|
|
779
|
-
backend.ingest(batchChunks, batchNodes, batchEdges);
|
|
780
|
-
for (const [rel, hash] of batchManifestUpdates) manifest.update(rel, hash);
|
|
781
|
-
result.indexedFiles.push(...batchIndexed);
|
|
782
|
-
result.totalChunks += batchChunks.length;
|
|
783
|
-
} else if (batchFilesToReplace.length > 0) {
|
|
784
|
-
backend.deleteByFiles(batchFilesToReplace);
|
|
785
|
-
for (const [rel, hash] of batchManifestUpdates) manifest.update(rel, hash);
|
|
786
|
-
result.indexedFiles.push(...batchIndexed);
|
|
821
|
+
result.skippedFiles.push(...toChunk.filter((t) => !batchIndexed.includes(t.relPath)).map((t) => t.relPath));
|
|
787
822
|
}
|
|
788
|
-
result.skippedFiles.push(...toChunk.filter((t) => !batchIndexed.includes(t.relPath)).map((t) => t.relPath));
|
|
789
|
-
}
|
|
790
823
|
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
824
|
+
// ── R7: emit chunk→chunk calls edges ──────────────────────────────────
|
|
825
|
+
// Runs AFTER all batches so the symbol table is complete regardless of
|
|
826
|
+
// batch order. Cross-file only: intra-file structure is already covered
|
|
827
|
+
// by file/defines edges. Capped per chunk — a retrieval prior, not a
|
|
828
|
+
// faithful call graph.
|
|
829
|
+
{
|
|
830
|
+
// PR #7 review fix (incremental call graph): derive over ALL indexed
|
|
831
|
+
// files, not just this run's changes. deleteByFiles(changed) removes
|
|
832
|
+
// call edges touching changed chunk nodes in BOTH directions, so a
|
|
833
|
+
// run-only derivation silently dropped (a) changed files' calls into
|
|
834
|
+
// unchanged files and (b) unchanged callers' edges into changed files.
|
|
835
|
+
// Re-deriving from every indexed file recreates exactly the deleted
|
|
836
|
+
// edges; unchanged→unchanged duplicates collapse on the edge PK
|
|
837
|
+
// (INSERT OR REPLACE).
|
|
838
|
+
const callsEdges: GraphEdge[] = [];
|
|
839
|
+
const derivationSources = [...runChunks];
|
|
840
|
+
const changedThisRun = changedRelPaths;
|
|
841
|
+
for (const rel of Object.keys(manifest.fileEntries)) {
|
|
842
|
+
if (changedThisRun.has(rel)) continue;
|
|
843
|
+
for (const stored of backend.chunksByFile(rel, 500)) {
|
|
844
|
+
if (stored.chunkType === "module") continue;
|
|
845
|
+
derivationSources.push({
|
|
846
|
+
id: stored.id,
|
|
847
|
+
filePath: rel,
|
|
848
|
+
// Stored content carries the R1 context header; strip it so
|
|
849
|
+
// header tokens cannot pollute the identifier set.
|
|
850
|
+
identifiers: extractIdentifiers(stripContextHeader(stored.content)),
|
|
851
|
+
});
|
|
852
|
+
}
|
|
819
853
|
}
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
for (const ident of src.identifiers) {
|
|
834
|
-
if (out >= CALLS_EDGES_PER_CHUNK) break;
|
|
835
|
-
for (const owner of symbolOwners.get(ident) ?? []) {
|
|
836
|
-
if (owner.filePath === src.filePath) continue;
|
|
837
|
-
callsEdges.push({ sourceId: src.id, targetId: owner.id, edgeType: "calls", properties: {} });
|
|
838
|
-
out += 1;
|
|
854
|
+
// Symbol owners over all files: run chunks first (fresh), then graph
|
|
855
|
+
// nodes of files that did not change (they are still valid).
|
|
856
|
+
for (const node of backend.listSymbolNodes()) {
|
|
857
|
+
if (changedThisRun.has(node.filePath)) continue;
|
|
858
|
+
const short = node.name.includes(".") ? node.name.slice(node.name.lastIndexOf(".") + 1) : node.name;
|
|
859
|
+
if (short.length < 3 || CALL_GRAPH_STOPLIST.has(short)) continue;
|
|
860
|
+
let owners = symbolOwners.get(short);
|
|
861
|
+
if (!owners) symbolOwners.set(short, (owners = []));
|
|
862
|
+
owners.push({ id: node.id, filePath: node.filePath });
|
|
863
|
+
}
|
|
864
|
+
for (const src of derivationSources) {
|
|
865
|
+
let out = 0;
|
|
866
|
+
for (const ident of src.identifiers) {
|
|
839
867
|
if (out >= CALLS_EDGES_PER_CHUNK) break;
|
|
868
|
+
for (const owner of symbolOwners.get(ident) ?? []) {
|
|
869
|
+
if (owner.filePath === src.filePath) continue;
|
|
870
|
+
callsEdges.push({ sourceId: src.id, targetId: owner.id, edgeType: "calls", properties: {} });
|
|
871
|
+
out += 1;
|
|
872
|
+
if (out >= CALLS_EDGES_PER_CHUNK) break;
|
|
873
|
+
}
|
|
840
874
|
}
|
|
841
875
|
}
|
|
876
|
+
if (callsEdges.length > 0) {
|
|
877
|
+
backend.ingest([], [], callsEdges);
|
|
878
|
+
log?.(` [graph] ${callsEdges.length} cross-file call edge(s)`);
|
|
879
|
+
}
|
|
842
880
|
}
|
|
843
|
-
if (callsEdges.length > 0) {
|
|
844
|
-
backend.ingest([], [], callsEdges);
|
|
845
|
-
log?.(` [graph] ${callsEdges.length} cross-file call edge(s)`);
|
|
846
|
-
}
|
|
847
|
-
}
|
|
848
881
|
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
882
|
+
// ── Git history on full runs ──────────────────────────────────────────
|
|
883
|
+
if (full && !targetPath && fs.existsSync(path.join(projectDir, ".git"))) {
|
|
884
|
+
try {
|
|
885
|
+
const git = await indexCommits(projectDir, manifest.lastGitSha);
|
|
886
|
+
if (git.chunks.length > 0) {
|
|
887
|
+
const { embedder: emb, cache: cch } = await ensureEmbedder();
|
|
888
|
+
phase?.(`Embedding ${git.chunks.length} git commits…`);
|
|
889
|
+
const stats = await embedChunks(git.chunks, emb, cch);
|
|
890
|
+
totalCacheHits += stats.hits;
|
|
891
|
+
totalCacheMisses += stats.misses;
|
|
892
|
+
backend.ingest(git.chunks, git.nodes, git.edges);
|
|
893
|
+
result.totalChunks += git.chunks.length;
|
|
894
|
+
if (git.headSha) manifest.lastGitSha = git.headSha;
|
|
895
|
+
log?.(` [git] ${git.chunks.length} commit(s) indexed`);
|
|
896
|
+
}
|
|
897
|
+
} catch (err) {
|
|
898
|
+
result.errors.push(`git history indexing failed: ${(err as Error).message}`);
|
|
863
899
|
}
|
|
864
|
-
} catch (err) {
|
|
865
|
-
result.errors.push(`git history indexing failed: ${(err as Error).message}`);
|
|
866
900
|
}
|
|
867
|
-
}
|
|
868
901
|
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
902
|
+
// ── Prune files that vanished from disk ───────────────────────────────
|
|
903
|
+
if (!targetPath) {
|
|
904
|
+
const previousPaths = new Set([...Object.keys(manifest.fileEntries), ...preMigrationPaths]);
|
|
905
|
+
const removed = [...previousPaths].filter((rel) => !currentRelPaths.has(rel));
|
|
906
|
+
if (removed.length > 0) {
|
|
907
|
+
backend.deleteByFiles(removed);
|
|
908
|
+
for (const rel of removed) {
|
|
909
|
+
manifest.remove(rel);
|
|
910
|
+
result.deletedFiles.push(rel);
|
|
911
|
+
log?.(` [delete] ${rel} (no longer on disk)`);
|
|
912
|
+
}
|
|
879
913
|
}
|
|
880
914
|
}
|
|
881
|
-
}
|
|
882
915
|
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
}
|
|
916
|
+
if (holders.cache) {
|
|
917
|
+
result.cacheHits = holders.cache.hitCount;
|
|
918
|
+
result.cacheMisses = holders.cache.missCount;
|
|
919
|
+
} else {
|
|
920
|
+
result.cacheHits = totalCacheHits;
|
|
921
|
+
result.cacheMisses = totalCacheMisses;
|
|
922
|
+
}
|
|
891
923
|
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
924
|
+
if (holders.embedder) manifest.embeddingDim = holders.embedder.dimension;
|
|
925
|
+
manifest.save();
|
|
926
|
+
return result;
|
|
927
|
+
} finally {
|
|
928
|
+
// Release handles on EVERY path: a mid-run rejection (embed failure,
|
|
929
|
+
// backend crash) must not leak them. Success-path bookkeeping above
|
|
930
|
+
// reads the live counters before this closes the cache.
|
|
931
|
+
if (holders.cache) holders.cache.close();
|
|
932
|
+
if (ownsBackend) backend.close();
|
|
933
|
+
}
|
|
896
934
|
}
|
|
897
935
|
|
|
898
936
|
/** Directory patterns need re-checking against every ancestor for single files. */
|