pi-context-engine 0.1.4 → 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.
- package/README.md +63 -0
- package/docs/IMPLEMENTATION-PLAN.md +30 -13
- package/package.json +83 -81
- package/src/compression/compressor.ts +119 -20
- package/src/config.ts +63 -3
- package/src/engine.ts +238 -13
- package/src/extension.ts +120 -9
- package/src/guidance.ts +1 -0
- package/src/indexer/chunker.ts +321 -21
- package/src/indexer/context-header.ts +86 -0
- package/src/indexer/embedder.ts +32 -5
- package/src/indexer/manifest.ts +41 -19
- package/src/indexer/pipeline.ts +435 -197
- package/src/memory/db.ts +166 -53
- package/src/retrieval/confidence.ts +53 -0
- package/src/retrieval/query-parser.ts +33 -0
- package/src/retrieval/reranker.ts +112 -0
- package/src/retrieval/retriever.ts +351 -14
- package/src/storage/fts-store.ts +48 -6
- package/src/storage/graph-store.ts +160 -4
- package/src/storage/local-backend.ts +36 -4
- package/src/storage/vector-store.ts +35 -29
- package/src/utils.ts +13 -0
package/src/indexer/pipeline.ts
CHANGED
|
@@ -8,16 +8,17 @@ 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
|
+
import { applyContextHeaderToChunk, stripContextHeader } from "./context-header.js";
|
|
13
14
|
import { Embedder, selectBackend } from "./embedder.js";
|
|
14
15
|
import { EmbeddingCache } from "./embedding-cache.js";
|
|
15
|
-
import { Manifest } from "./manifest.js";
|
|
16
|
+
import { Manifest, CURRENT_SCHEMA_VERSION } from "./manifest.js";
|
|
16
17
|
import { loadIgnorePatterns, matchesAny } from "./ignorefile.js";
|
|
17
18
|
import { isSecretFile, redactSecrets } from "./secrets.js";
|
|
18
19
|
import { LocalBackend } from "../storage/local-backend.js";
|
|
19
20
|
import type { Chunk, ChunkType, GraphEdge, GraphNode, NodeType } from "../types.js";
|
|
20
|
-
import { resolveOllamaUrl } from "../config.js";
|
|
21
|
+
import { resolveOllamaUrl, ROOT_ONLY_IGNORE } from "../config.js";
|
|
21
22
|
|
|
22
23
|
const execFileAsync = promisify(execFile);
|
|
23
24
|
|
|
@@ -159,13 +160,16 @@ function cceignoreMatchesDir(relPosix: string, patterns: string[]): boolean {
|
|
|
159
160
|
*/
|
|
160
161
|
function targetExcluded(
|
|
161
162
|
relPosix: string,
|
|
162
|
-
opts: { isDir: boolean; ignoreSet: Set<string>; redactSecrets: boolean; patterns: string[] },
|
|
163
|
+
opts: { isDir: boolean; ignoreSet: Set<string>; rootIgnoreSet: Set<string>; redactSecrets: boolean; patterns: string[] },
|
|
163
164
|
): boolean {
|
|
164
165
|
if (relPosix === "") return false;
|
|
165
166
|
const segments = relPosix.split("/");
|
|
166
167
|
for (const seg of segments) {
|
|
167
168
|
if (opts.ignoreSet.has(seg)) return true;
|
|
168
169
|
}
|
|
170
|
+
// Only the first segment is project-root level; root-only names must not
|
|
171
|
+
// reject nested paths like `src/storage/x.ts`.
|
|
172
|
+
if (opts.rootIgnoreSet.has(segments[0]!)) return true;
|
|
169
173
|
// The walker only classifies secret names on file entries; a secret-named
|
|
170
174
|
// directory's contents are still gated per file.
|
|
171
175
|
if (!opts.isDir && opts.redactSecrets && isSecretFile(segments[segments.length - 1]!)) return true;
|
|
@@ -187,6 +191,7 @@ function* iterProjectFiles(
|
|
|
187
191
|
/** Project root: `.cceignore` patterns are matched against project-relative paths even when walking from a target. */
|
|
188
192
|
relRoot: string,
|
|
189
193
|
ignoreSet: Set<string>,
|
|
194
|
+
rootIgnoreSet: Set<string>,
|
|
190
195
|
opts: { redactSecrets: boolean; cceignorePatterns: string[] },
|
|
191
196
|
): Generator<string> {
|
|
192
197
|
const seen = new Set<string>();
|
|
@@ -200,7 +205,10 @@ function* iterProjectFiles(
|
|
|
200
205
|
}
|
|
201
206
|
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
202
207
|
for (const entry of entries) {
|
|
203
|
-
|
|
208
|
+
// Root-only names (`storage`, `logs`, ...) only match at the project
|
|
209
|
+
// root — nested `src/storage/` is real source and must be indexed.
|
|
210
|
+
const atProjectRoot = directory === relRoot;
|
|
211
|
+
if (ignoreSet.has(entry.name) || (atProjectRoot && rootIgnoreSet.has(entry.name))) continue;
|
|
204
212
|
const full = path.join(directory, entry.name);
|
|
205
213
|
if (entry.isSymbolicLink()) continue;
|
|
206
214
|
let resolved: string;
|
|
@@ -394,7 +402,8 @@ async function runIndexingLocked(
|
|
|
394
402
|
options: { full?: boolean; targetPath?: string | null; callbacks?: IndexCallbacks },
|
|
395
403
|
runtime?: { backend: LocalBackend; embedder: Embedder },
|
|
396
404
|
): Promise<IndexResult> {
|
|
397
|
-
|
|
405
|
+
let { full = false, targetPath = null } = options;
|
|
406
|
+
const { callbacks = {} } = options;
|
|
398
407
|
const { log, progress, phase } = callbacks;
|
|
399
408
|
|
|
400
409
|
const ownsBackend = runtime === undefined;
|
|
@@ -411,12 +420,39 @@ async function runIndexingLocked(
|
|
|
411
420
|
cacheMisses: 0,
|
|
412
421
|
};
|
|
413
422
|
|
|
414
|
-
|
|
423
|
+
// PR #7 review fix: migrations below call manifest.clearEntries(), which
|
|
424
|
+
// also erases the file list pruneFilesNotIn() needs to detect files that
|
|
425
|
+
// vanished BEFORE this run. Snapshot the paths first so the prune step
|
|
426
|
+
// can still delete their indexed chunks instead of orphaning them.
|
|
427
|
+
const preMigrationPaths = Object.keys(manifest.fileEntries);
|
|
428
|
+
|
|
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) {
|
|
436
|
+
log?.(
|
|
437
|
+
` [rebuild] index format v${manifest.schemaVersion} → v${CURRENT_SCHEMA_VERSION} — wiping stores, reindexing all files`,
|
|
438
|
+
);
|
|
439
|
+
backend.clear();
|
|
415
440
|
manifest.clearEntries();
|
|
416
|
-
|
|
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;
|
|
417
452
|
}
|
|
418
453
|
|
|
419
454
|
const ignoreSet = new Set(config.indexerIgnore);
|
|
455
|
+
const rootIgnoreSet = new Set(ROOT_ONLY_IGNORE);
|
|
420
456
|
const cceignorePatterns = loadIgnorePatterns(projectDir);
|
|
421
457
|
if (cceignorePatterns.length > 0) log?.(` [.cceignore] ${cceignorePatterns.length} pattern(s) loaded`);
|
|
422
458
|
|
|
@@ -449,6 +485,7 @@ async function runIndexingLocked(
|
|
|
449
485
|
targetExcluded(rel, {
|
|
450
486
|
isDir: false,
|
|
451
487
|
ignoreSet,
|
|
488
|
+
rootIgnoreSet,
|
|
452
489
|
redactSecrets: config.indexerRedactSecrets,
|
|
453
490
|
patterns: cceignorePatterns,
|
|
454
491
|
})
|
|
@@ -471,6 +508,7 @@ async function runIndexingLocked(
|
|
|
471
508
|
const excluded = targetExcluded(rel, {
|
|
472
509
|
isDir: true,
|
|
473
510
|
ignoreSet,
|
|
511
|
+
rootIgnoreSet,
|
|
474
512
|
redactSecrets: config.indexerRedactSecrets,
|
|
475
513
|
patterns: cceignorePatterns,
|
|
476
514
|
});
|
|
@@ -479,7 +517,7 @@ async function runIndexingLocked(
|
|
|
479
517
|
fileList = [];
|
|
480
518
|
} else {
|
|
481
519
|
fileList = [
|
|
482
|
-
...iterProjectFiles(target, projectDir, ignoreSet, {
|
|
520
|
+
...iterProjectFiles(target, projectDir, ignoreSet, rootIgnoreSet, {
|
|
483
521
|
redactSecrets: config.indexerRedactSecrets,
|
|
484
522
|
cceignorePatterns,
|
|
485
523
|
}),
|
|
@@ -507,7 +545,7 @@ async function runIndexingLocked(
|
|
|
507
545
|
}
|
|
508
546
|
} else {
|
|
509
547
|
fileList = [
|
|
510
|
-
...iterProjectFiles(projectDir, projectDir, ignoreSet, {
|
|
548
|
+
...iterProjectFiles(projectDir, projectDir, ignoreSet, rootIgnoreSet, {
|
|
511
549
|
redactSecrets: config.indexerRedactSecrets,
|
|
512
550
|
cceignorePatterns,
|
|
513
551
|
}),
|
|
@@ -522,6 +560,18 @@ async function runIndexingLocked(
|
|
|
522
560
|
}
|
|
523
561
|
if (languages.size > 0) await warmupLanguages(languages);
|
|
524
562
|
|
|
563
|
+
// R7: every rel path this run touches (changed or not) plus everything
|
|
564
|
+
// already indexed forms the resolution universe for import specifiers.
|
|
565
|
+
const knownFiles = new Set<string>(Object.keys(manifest.fileEntries));
|
|
566
|
+
for (const f of fileList) knownFiles.add(path.relative(projectDir, f).replace(/\\/g, "/"));
|
|
567
|
+
// R7: cross-file call-graph state, filled during the batch loop, emitted
|
|
568
|
+
// as chunk-to-chunk `calls` edges after ALL batches (so early files can
|
|
569
|
+
// call symbols defined in late ones — order-independent).
|
|
570
|
+
const runChunks: { id: string; filePath: string; identifiers: string[] }[] = [];
|
|
571
|
+
const symbolOwners = new Map<string, { id: string; filePath: string }[]>();
|
|
572
|
+
// PR #7 review: files whose chunks were (re)built this run, across ALL batches.
|
|
573
|
+
const changedRelPaths = new Set<string>();
|
|
574
|
+
|
|
525
575
|
// ── Lazy embedder + cache ─────────────────────────────────────────────
|
|
526
576
|
const holders: { embedder: Embedder | null; cache: EmbeddingCache | null } = {
|
|
527
577
|
embedder: runtime?.embedder ?? null,
|
|
@@ -544,218 +594,338 @@ async function runIndexingLocked(
|
|
|
544
594
|
return { embedder: holders.embedder, cache: holders.cache };
|
|
545
595
|
};
|
|
546
596
|
|
|
547
|
-
//
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
);
|
|
554
|
-
manifest.clearEntries();
|
|
555
|
-
}
|
|
556
|
-
}
|
|
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 {
|
|
557
603
|
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
const relPath = path.relative(projectDir, filePath).replace(/\\/g, "/");
|
|
570
|
-
currentRelPaths.add(relPath);
|
|
571
|
-
let content = safeRead(filePath);
|
|
572
|
-
if (content === null) {
|
|
573
|
-
result.skippedFiles.push(relPath);
|
|
574
|
-
log?.(` [skip] ${relPath} (binary or unreadable)`);
|
|
575
|
-
continue;
|
|
576
|
-
}
|
|
577
|
-
if (config.indexerRedactSecrets) {
|
|
578
|
-
const redacted = redactSecrets(content);
|
|
579
|
-
if (redacted.labels.length > 0) {
|
|
580
|
-
content = redacted.text;
|
|
581
|
-
log?.(` [redact] ${relPath} (${redacted.labels.length} secret(s))`);
|
|
582
|
-
}
|
|
583
|
-
}
|
|
584
|
-
const contentHash = sha256Hex(content);
|
|
585
|
-
if (!full && !manifest.hasChanged(relPath, contentHash)) {
|
|
586
|
-
log?.(` [skip] ${relPath} (unchanged)`);
|
|
587
|
-
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;
|
|
588
615
|
}
|
|
589
|
-
toChunk.push({
|
|
590
|
-
filePath,
|
|
591
|
-
relPath,
|
|
592
|
-
content,
|
|
593
|
-
contentHash,
|
|
594
|
-
language: LANGUAGE_MAP[path.extname(filePath).toLowerCase()] ?? "plaintext",
|
|
595
|
-
});
|
|
596
616
|
}
|
|
597
617
|
|
|
598
|
-
const
|
|
599
|
-
const
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
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;
|
|
613
642
|
}
|
|
614
|
-
if (
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
chunkType: "module",
|
|
621
|
-
filePath: item.relPath,
|
|
622
|
-
startLine: 1,
|
|
623
|
-
endLine: Math.max(1, item.content.trim().split("\n").length),
|
|
624
|
-
language: item.language,
|
|
625
|
-
metadata: {},
|
|
626
|
-
embedding: null,
|
|
627
|
-
confidenceScore: 0,
|
|
628
|
-
compressedContent: null,
|
|
629
|
-
},
|
|
630
|
-
];
|
|
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
|
+
}
|
|
631
649
|
}
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
}
|
|
644
|
-
if (mtime !== null) for (const c of chunks) c.metadata["modified_ts"] = mtime;
|
|
645
|
-
|
|
646
|
-
batchFilesToReplace.push(item.relPath);
|
|
647
|
-
const fileNodeId = `file_${item.relPath}`;
|
|
648
|
-
batchNodes.push({
|
|
649
|
-
id: fileNodeId,
|
|
650
|
-
nodeType: "file",
|
|
651
|
-
name: path.basename(item.filePath),
|
|
652
|
-
filePath: item.relPath,
|
|
653
|
-
properties: {},
|
|
654
|
-
});
|
|
655
|
-
for (const module of importedModules) {
|
|
656
|
-
batchEdges.push({
|
|
657
|
-
sourceId: fileNodeId,
|
|
658
|
-
targetId: `module_${module}`,
|
|
659
|
-
edgeType: "imports",
|
|
660
|
-
properties: {},
|
|
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
661
|
});
|
|
662
662
|
}
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
663
|
+
|
|
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;
|
|
708
|
+
}
|
|
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}`;
|
|
667
721
|
batchNodes.push({
|
|
668
|
-
id:
|
|
669
|
-
nodeType:
|
|
670
|
-
name:
|
|
722
|
+
id: fileNodeId,
|
|
723
|
+
nodeType: "file",
|
|
724
|
+
name: path.basename(item.filePath),
|
|
671
725
|
filePath: item.relPath,
|
|
672
726
|
properties: {},
|
|
673
727
|
});
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
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
|
+
});
|
|
743
|
+
}
|
|
744
|
+
}
|
|
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({
|
|
750
|
+
id: chunk.id,
|
|
751
|
+
nodeType: CHUNK_TO_NODE_TYPE[chunk.chunkType] ?? "module",
|
|
752
|
+
name: nodeName,
|
|
753
|
+
filePath: item.relPath,
|
|
754
|
+
properties: {},
|
|
755
|
+
});
|
|
756
|
+
batchEdges.push({
|
|
757
|
+
sourceId: fileNodeId,
|
|
758
|
+
targetId: chunk.id,
|
|
759
|
+
edgeType: "defines",
|
|
760
|
+
properties: {},
|
|
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
|
+
}
|
|
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);
|
|
680
791
|
}
|
|
681
|
-
batchChunks.push(...chunks);
|
|
682
|
-
batchManifestUpdates.push([item.relPath, item.contentHash]);
|
|
683
|
-
batchIndexed.push(item.relPath);
|
|
684
|
-
}
|
|
685
|
-
|
|
686
|
-
progress?.(Math.min(batchIndex + batchPaths.length, fileList.length), fileList.length);
|
|
687
792
|
|
|
688
|
-
|
|
689
|
-
const { embedder: emb, cache: cch } = await ensureEmbedder();
|
|
690
|
-
if (phase && !announced) {
|
|
691
|
-
phase(`Embedding ${batchChunks.length.toLocaleString()} chunks${totalBatches > 1 ? ` (batch ${Math.floor(batchIndex / BATCH) + 1}/${totalBatches})` : ""}…`);
|
|
692
|
-
announced = true;
|
|
693
|
-
}
|
|
694
|
-
const stats = await embedChunks(batchChunks, emb, cch);
|
|
695
|
-
totalCacheHits += stats.hits;
|
|
696
|
-
totalCacheMisses += stats.misses;
|
|
697
|
-
// Per-batch deletes happen AFTER embed succeeds, so an embed failure
|
|
698
|
-
// can never wipe previously-indexed rows.
|
|
699
|
-
backend.deleteByFiles(batchFilesToReplace);
|
|
700
|
-
backend.ingest(batchChunks, batchNodes, batchEdges);
|
|
701
|
-
for (const [rel, hash] of batchManifestUpdates) manifest.update(rel, hash);
|
|
702
|
-
result.indexedFiles.push(...batchIndexed);
|
|
703
|
-
result.totalChunks += batchChunks.length;
|
|
704
|
-
} else if (batchFilesToReplace.length > 0) {
|
|
705
|
-
backend.deleteByFiles(batchFilesToReplace);
|
|
706
|
-
for (const [rel, hash] of batchManifestUpdates) manifest.update(rel, hash);
|
|
707
|
-
result.indexedFiles.push(...batchIndexed);
|
|
708
|
-
}
|
|
709
|
-
result.skippedFiles.push(...toChunk.filter((t) => !batchIndexed.includes(t.relPath)).map((t) => t.relPath));
|
|
710
|
-
}
|
|
793
|
+
progress?.(Math.min(batchIndex + batchPaths.length, fileList.length), fileList.length);
|
|
711
794
|
|
|
712
|
-
|
|
713
|
-
if (full && !targetPath && fs.existsSync(path.join(projectDir, ".git"))) {
|
|
714
|
-
try {
|
|
715
|
-
const git = await indexCommits(projectDir, manifest.lastGitSha);
|
|
716
|
-
if (git.chunks.length > 0) {
|
|
795
|
+
if (batchChunks.length > 0) {
|
|
717
796
|
const { embedder: emb, cache: cch } = await ensureEmbedder();
|
|
718
|
-
phase
|
|
719
|
-
|
|
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);
|
|
720
802
|
totalCacheHits += stats.hits;
|
|
721
803
|
totalCacheMisses += stats.misses;
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
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);
|
|
726
815
|
}
|
|
727
|
-
|
|
728
|
-
result.errors.push(`git history indexing failed: ${(err as Error).message}`);
|
|
816
|
+
result.skippedFiles.push(...toChunk.filter((t) => !batchIndexed.includes(t.relPath)).map((t) => t.relPath));
|
|
729
817
|
}
|
|
730
|
-
}
|
|
731
818
|
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
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
|
+
}
|
|
742
848
|
}
|
|
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) {
|
|
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
|
+
}
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
if (callsEdges.length > 0) {
|
|
872
|
+
backend.ingest([], [], callsEdges);
|
|
873
|
+
log?.(` [graph] ${callsEdges.length} cross-file call edge(s)`);
|
|
874
|
+
}
|
|
743
875
|
}
|
|
744
|
-
}
|
|
745
876
|
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
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}`);
|
|
894
|
+
}
|
|
895
|
+
}
|
|
754
896
|
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
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
|
+
}
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
|
|
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
|
+
}
|
|
918
|
+
|
|
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
|
+
}
|
|
759
929
|
}
|
|
760
930
|
|
|
761
931
|
/** Directory patterns need re-checking against every ancestor for single files. */
|
|
@@ -767,3 +937,71 @@ function cceignoreMatchesFile(relPosix: string, patterns: string[]): boolean {
|
|
|
767
937
|
}
|
|
768
938
|
return false;
|
|
769
939
|
}
|
|
940
|
+
|
|
941
|
+
// ── R7 helpers: import resolution + call-graph vocabulary ─────────────────
|
|
942
|
+
|
|
943
|
+
/** Cap per chunk for calls edges: a prior, not a faithful call graph. */
|
|
944
|
+
const CALLS_EDGES_PER_CHUNK = 8;
|
|
945
|
+
/** Max unique identifiers kept per chunk (first occurrences win). */
|
|
946
|
+
const IDENTIFIERS_PER_CHUNK = 60;
|
|
947
|
+
|
|
948
|
+
/** Words that name a chunk never worth a calls edge. */
|
|
949
|
+
const CALL_GRAPH_STOPLIST = new Set([
|
|
950
|
+
"const", "let", "var", "function", "class", "return", "string", "number", "boolean", "object",
|
|
951
|
+
"void", "null", "undefined", "true", "false", "this", "import", "export", "default", "type",
|
|
952
|
+
"interface", "extends", "implements", "constructor", "static", "readonly", "public", "private",
|
|
953
|
+
"protected", "async", "await", "Promise", "Object", "Array", "String", "Number", "Boolean",
|
|
954
|
+
"Math", "JSON", "Error", "console", "process", "require", "module", "exports", "Map", "Set",
|
|
955
|
+
"Date", "Symbol", "Buffer", "path", "fs", "os", "unknown", "never", "text", "params", "options",
|
|
956
|
+
"value", "values", "result", "results", "data", "content", "item", "items", "name", "file", "files",
|
|
957
|
+
"line", "lines", "index", "length", "score", "query", "chunk", "chunks",
|
|
958
|
+
]);
|
|
959
|
+
|
|
960
|
+
/** Unique identifier-ish words in a chunk, capped (first occurrences win). */
|
|
961
|
+
function extractIdentifiers(content: string): string[] {
|
|
962
|
+
const seen = new Set<string>();
|
|
963
|
+
const re = /[A-Za-z_$][A-Za-z0-9_$]*/g;
|
|
964
|
+
let m: RegExpExecArray | null;
|
|
965
|
+
while ((m = re.exec(content)) !== null && seen.size < IDENTIFIERS_PER_CHUNK) {
|
|
966
|
+
const w = m[0];
|
|
967
|
+
if (w.length >= 3 && !CALL_GRAPH_STOPLIST.has(w)) seen.add(w);
|
|
968
|
+
}
|
|
969
|
+
return [...seen];
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
/**
|
|
973
|
+
* Resolve a relative import specifier against the set of indexed files.
|
|
974
|
+
* Handles extensionless specs ("./chunker"), .js to .ts rewrites (TypeScript
|
|
975
|
+
* emits .js specifiers), and directory index files. Returns null for bare
|
|
976
|
+
* package imports and unresolvable specs.
|
|
977
|
+
*/
|
|
978
|
+
function resolveImportPath(spec: string, importerRel: string, knownFiles: Set<string>): string | null {
|
|
979
|
+
if (!spec.startsWith(".") && !spec.startsWith("/")) return null;
|
|
980
|
+
const dir = importerRel.includes("/") ? importerRel.slice(0, importerRel.lastIndexOf("/")) : "";
|
|
981
|
+
const joined = spec.startsWith("/") ? spec.slice(1) : `${dir}/${spec}`;
|
|
982
|
+
const stack = joined.split("/");
|
|
983
|
+
const outStack: string[] = [];
|
|
984
|
+
for (const part of stack) {
|
|
985
|
+
if (part === "" || part === ".") continue;
|
|
986
|
+
if (part === "..") outStack.pop();
|
|
987
|
+
else outStack.push(part);
|
|
988
|
+
}
|
|
989
|
+
const base = outStack.join("/");
|
|
990
|
+
if (base === "") return null;
|
|
991
|
+
const candidates = [base];
|
|
992
|
+
const lower = base.toLowerCase();
|
|
993
|
+
if (lower.endsWith(".js")) {
|
|
994
|
+
candidates.push(base.slice(0, -3) + ".ts", base.slice(0, -3) + ".tsx");
|
|
995
|
+
} else if (lower.endsWith(".mjs") || lower.endsWith(".cjs")) {
|
|
996
|
+
candidates.push(base.slice(0, -4) + ".ts");
|
|
997
|
+
} else if (!/\.[a-z]+$/i.test(base)) {
|
|
998
|
+
candidates.push(base + ".ts", base + ".tsx", base + ".mts", base + ".js");
|
|
999
|
+
}
|
|
1000
|
+
for (const c of candidates) {
|
|
1001
|
+
if (knownFiles.has(c)) return c;
|
|
1002
|
+
}
|
|
1003
|
+
for (const ext of ["/index.ts", "/index.tsx", "/index.js"]) {
|
|
1004
|
+
if (knownFiles.has(base + ext)) return base + ext;
|
|
1005
|
+
}
|
|
1006
|
+
return null;
|
|
1007
|
+
}
|