pi-context-engine 0.2.0 → 0.3.0

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