pi-context-engine 0.1.3 → 0.2.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 +59 -0
- package/docs/IMPLEMENTATION-PLAN.md +500 -0
- package/docs/RESEARCH-RETRIEVAL.md +532 -0
- package/package.json +83 -79
- package/src/compression/compressor.ts +119 -20
- package/src/config.ts +59 -3
- package/src/engine.ts +206 -12
- package/src/extension.ts +12 -1
- 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 +51 -3
- package/src/indexer/manifest.ts +8 -4
- package/src/indexer/pipeline.ts +223 -18
- package/src/memory/db.ts +128 -1
- 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 -0
- package/src/storage/vector-store.ts +22 -0
package/src/indexer/pipeline.ts
CHANGED
|
@@ -10,14 +10,15 @@ import { promisify } from "node:util";
|
|
|
10
10
|
import type { Config } from "../config.js";
|
|
11
11
|
import { projectStorageDir, sha256Hex } 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;
|
|
@@ -411,12 +419,29 @@ async function runIndexingLocked(
|
|
|
411
419
|
cacheMisses: 0,
|
|
412
420
|
};
|
|
413
421
|
|
|
422
|
+
// PR #7 review fix: migrations below call manifest.clearEntries(), which
|
|
423
|
+
// also erases the file list pruneFilesNotIn() needs to detect files that
|
|
424
|
+
// vanished BEFORE this run. Snapshot the paths first so the prune step
|
|
425
|
+
// can still delete their indexed chunks instead of orphaning them.
|
|
426
|
+
const preMigrationPaths = Object.keys(manifest.fileEntries);
|
|
427
|
+
|
|
414
428
|
if (backend.metricRebuilt) {
|
|
415
429
|
manifest.clearEntries();
|
|
416
430
|
log?.(" [migration] vector index rebuilt (cosine metric) — reindexing all files");
|
|
417
431
|
}
|
|
418
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) {
|
|
437
|
+
log?.(
|
|
438
|
+
` [migration] index format v${manifest.schemaVersion} → v${CURRENT_SCHEMA_VERSION} — reindexing all files`,
|
|
439
|
+
);
|
|
440
|
+
manifest.clearEntries();
|
|
441
|
+
}
|
|
442
|
+
|
|
419
443
|
const ignoreSet = new Set(config.indexerIgnore);
|
|
444
|
+
const rootIgnoreSet = new Set(ROOT_ONLY_IGNORE);
|
|
420
445
|
const cceignorePatterns = loadIgnorePatterns(projectDir);
|
|
421
446
|
if (cceignorePatterns.length > 0) log?.(` [.cceignore] ${cceignorePatterns.length} pattern(s) loaded`);
|
|
422
447
|
|
|
@@ -449,6 +474,7 @@ async function runIndexingLocked(
|
|
|
449
474
|
targetExcluded(rel, {
|
|
450
475
|
isDir: false,
|
|
451
476
|
ignoreSet,
|
|
477
|
+
rootIgnoreSet,
|
|
452
478
|
redactSecrets: config.indexerRedactSecrets,
|
|
453
479
|
patterns: cceignorePatterns,
|
|
454
480
|
})
|
|
@@ -471,6 +497,7 @@ async function runIndexingLocked(
|
|
|
471
497
|
const excluded = targetExcluded(rel, {
|
|
472
498
|
isDir: true,
|
|
473
499
|
ignoreSet,
|
|
500
|
+
rootIgnoreSet,
|
|
474
501
|
redactSecrets: config.indexerRedactSecrets,
|
|
475
502
|
patterns: cceignorePatterns,
|
|
476
503
|
});
|
|
@@ -479,7 +506,7 @@ async function runIndexingLocked(
|
|
|
479
506
|
fileList = [];
|
|
480
507
|
} else {
|
|
481
508
|
fileList = [
|
|
482
|
-
...iterProjectFiles(target, projectDir, ignoreSet, {
|
|
509
|
+
...iterProjectFiles(target, projectDir, ignoreSet, rootIgnoreSet, {
|
|
483
510
|
redactSecrets: config.indexerRedactSecrets,
|
|
484
511
|
cceignorePatterns,
|
|
485
512
|
}),
|
|
@@ -507,7 +534,7 @@ async function runIndexingLocked(
|
|
|
507
534
|
}
|
|
508
535
|
} else {
|
|
509
536
|
fileList = [
|
|
510
|
-
...iterProjectFiles(projectDir, projectDir, ignoreSet, {
|
|
537
|
+
...iterProjectFiles(projectDir, projectDir, ignoreSet, rootIgnoreSet, {
|
|
511
538
|
redactSecrets: config.indexerRedactSecrets,
|
|
512
539
|
cceignorePatterns,
|
|
513
540
|
}),
|
|
@@ -522,6 +549,18 @@ async function runIndexingLocked(
|
|
|
522
549
|
}
|
|
523
550
|
if (languages.size > 0) await warmupLanguages(languages);
|
|
524
551
|
|
|
552
|
+
// R7: every rel path this run touches (changed or not) plus everything
|
|
553
|
+
// already indexed forms the resolution universe for import specifiers.
|
|
554
|
+
const knownFiles = new Set<string>(Object.keys(manifest.fileEntries));
|
|
555
|
+
for (const f of fileList) knownFiles.add(path.relative(projectDir, f).replace(/\\/g, "/"));
|
|
556
|
+
// R7: cross-file call-graph state, filled during the batch loop, emitted
|
|
557
|
+
// as chunk-to-chunk `calls` edges after ALL batches (so early files can
|
|
558
|
+
// call symbols defined in late ones — order-independent).
|
|
559
|
+
const runChunks: { id: string; filePath: string; identifiers: string[] }[] = [];
|
|
560
|
+
const symbolOwners = new Map<string, { id: string; filePath: string }[]>();
|
|
561
|
+
// PR #7 review: files whose chunks were (re)built this run, across ALL batches.
|
|
562
|
+
const changedRelPaths = new Set<string>();
|
|
563
|
+
|
|
525
564
|
// ── Lazy embedder + cache ─────────────────────────────────────────────
|
|
526
565
|
const holders: { embedder: Embedder | null; cache: EmbeddingCache | null } = {
|
|
527
566
|
embedder: runtime?.embedder ?? null,
|
|
@@ -603,13 +642,19 @@ async function runIndexingLocked(
|
|
|
603
642
|
const batchIndexed: string[] = [];
|
|
604
643
|
|
|
605
644
|
for (const item of toChunk) {
|
|
645
|
+
changedRelPaths.add(item.relPath);
|
|
606
646
|
let chunks: Chunk[] = [];
|
|
607
647
|
let importedModules: string[] = [];
|
|
648
|
+
let importSpecs: string[] = [];
|
|
608
649
|
try {
|
|
609
650
|
if (isAstLanguage(item.language)) {
|
|
610
|
-
const out = chunker.chunkWithImports(item.content, item.relPath, item.language
|
|
651
|
+
const out = chunker.chunkWithImports(item.content, item.relPath, item.language, {
|
|
652
|
+
chunking: config.indexerChunking,
|
|
653
|
+
budgetChars: config.indexerChunkBudgetChars,
|
|
654
|
+
});
|
|
611
655
|
chunks = out.chunks;
|
|
612
656
|
importedModules = out.imports;
|
|
657
|
+
importSpecs = out.importSpecs;
|
|
613
658
|
}
|
|
614
659
|
if (chunks.length === 0) {
|
|
615
660
|
// Fallback: module-level chunk (upstream _fallback_chunk).
|
|
@@ -652,18 +697,27 @@ async function runIndexingLocked(
|
|
|
652
697
|
filePath: item.relPath,
|
|
653
698
|
properties: {},
|
|
654
699
|
});
|
|
655
|
-
for (const
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
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
|
+
});
|
|
715
|
+
}
|
|
662
716
|
}
|
|
663
717
|
for (const chunk of chunks) {
|
|
664
|
-
const nodeName =
|
|
665
|
-
|
|
666
|
-
: chunk.id;
|
|
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);
|
|
667
721
|
batchNodes.push({
|
|
668
722
|
id: chunk.id,
|
|
669
723
|
nodeType: CHUNK_TO_NODE_TYPE[chunk.chunkType] ?? "module",
|
|
@@ -677,6 +731,31 @@ async function runIndexingLocked(
|
|
|
677
731
|
edgeType: "defines",
|
|
678
732
|
properties: {},
|
|
679
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 });
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
if (chunk.chunkType !== "module") {
|
|
747
|
+
runChunks.push({
|
|
748
|
+
id: chunk.id,
|
|
749
|
+
filePath: item.relPath,
|
|
750
|
+
identifiers: extractIdentifiers(chunk.content),
|
|
751
|
+
});
|
|
752
|
+
}
|
|
753
|
+
}
|
|
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);
|
|
680
759
|
}
|
|
681
760
|
batchChunks.push(...chunks);
|
|
682
761
|
batchManifestUpdates.push([item.relPath, item.contentHash]);
|
|
@@ -709,6 +788,64 @@ async function runIndexingLocked(
|
|
|
709
788
|
result.skippedFiles.push(...toChunk.filter((t) => !batchIndexed.includes(t.relPath)).map((t) => t.relPath));
|
|
710
789
|
}
|
|
711
790
|
|
|
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
|
+
}
|
|
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;
|
|
839
|
+
if (out >= CALLS_EDGES_PER_CHUNK) break;
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
if (callsEdges.length > 0) {
|
|
844
|
+
backend.ingest([], [], callsEdges);
|
|
845
|
+
log?.(` [graph] ${callsEdges.length} cross-file call edge(s)`);
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
|
|
712
849
|
// ── Git history on full runs ──────────────────────────────────────────
|
|
713
850
|
if (full && !targetPath && fs.existsSync(path.join(projectDir, ".git"))) {
|
|
714
851
|
try {
|
|
@@ -731,7 +868,7 @@ async function runIndexingLocked(
|
|
|
731
868
|
|
|
732
869
|
// ── Prune files that vanished from disk ───────────────────────────────
|
|
733
870
|
if (!targetPath) {
|
|
734
|
-
const previousPaths = new Set(Object.keys(manifest.fileEntries));
|
|
871
|
+
const previousPaths = new Set([...Object.keys(manifest.fileEntries), ...preMigrationPaths]);
|
|
735
872
|
const removed = [...previousPaths].filter((rel) => !currentRelPaths.has(rel));
|
|
736
873
|
if (removed.length > 0) {
|
|
737
874
|
backend.deleteByFiles(removed);
|
|
@@ -767,3 +904,71 @@ function cceignoreMatchesFile(relPosix: string, patterns: string[]): boolean {
|
|
|
767
904
|
}
|
|
768
905
|
return false;
|
|
769
906
|
}
|
|
907
|
+
|
|
908
|
+
// ── R7 helpers: import resolution + call-graph vocabulary ─────────────────
|
|
909
|
+
|
|
910
|
+
/** Cap per chunk for calls edges: a prior, not a faithful call graph. */
|
|
911
|
+
const CALLS_EDGES_PER_CHUNK = 8;
|
|
912
|
+
/** Max unique identifiers kept per chunk (first occurrences win). */
|
|
913
|
+
const IDENTIFIERS_PER_CHUNK = 60;
|
|
914
|
+
|
|
915
|
+
/** Words that name a chunk never worth a calls edge. */
|
|
916
|
+
const CALL_GRAPH_STOPLIST = new Set([
|
|
917
|
+
"const", "let", "var", "function", "class", "return", "string", "number", "boolean", "object",
|
|
918
|
+
"void", "null", "undefined", "true", "false", "this", "import", "export", "default", "type",
|
|
919
|
+
"interface", "extends", "implements", "constructor", "static", "readonly", "public", "private",
|
|
920
|
+
"protected", "async", "await", "Promise", "Object", "Array", "String", "Number", "Boolean",
|
|
921
|
+
"Math", "JSON", "Error", "console", "process", "require", "module", "exports", "Map", "Set",
|
|
922
|
+
"Date", "Symbol", "Buffer", "path", "fs", "os", "unknown", "never", "text", "params", "options",
|
|
923
|
+
"value", "values", "result", "results", "data", "content", "item", "items", "name", "file", "files",
|
|
924
|
+
"line", "lines", "index", "length", "score", "query", "chunk", "chunks",
|
|
925
|
+
]);
|
|
926
|
+
|
|
927
|
+
/** Unique identifier-ish words in a chunk, capped (first occurrences win). */
|
|
928
|
+
function extractIdentifiers(content: string): string[] {
|
|
929
|
+
const seen = new Set<string>();
|
|
930
|
+
const re = /[A-Za-z_$][A-Za-z0-9_$]*/g;
|
|
931
|
+
let m: RegExpExecArray | null;
|
|
932
|
+
while ((m = re.exec(content)) !== null && seen.size < IDENTIFIERS_PER_CHUNK) {
|
|
933
|
+
const w = m[0];
|
|
934
|
+
if (w.length >= 3 && !CALL_GRAPH_STOPLIST.has(w)) seen.add(w);
|
|
935
|
+
}
|
|
936
|
+
return [...seen];
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
/**
|
|
940
|
+
* Resolve a relative import specifier against the set of indexed files.
|
|
941
|
+
* Handles extensionless specs ("./chunker"), .js to .ts rewrites (TypeScript
|
|
942
|
+
* emits .js specifiers), and directory index files. Returns null for bare
|
|
943
|
+
* package imports and unresolvable specs.
|
|
944
|
+
*/
|
|
945
|
+
function resolveImportPath(spec: string, importerRel: string, knownFiles: Set<string>): string | null {
|
|
946
|
+
if (!spec.startsWith(".") && !spec.startsWith("/")) return null;
|
|
947
|
+
const dir = importerRel.includes("/") ? importerRel.slice(0, importerRel.lastIndexOf("/")) : "";
|
|
948
|
+
const joined = spec.startsWith("/") ? spec.slice(1) : `${dir}/${spec}`;
|
|
949
|
+
const stack = joined.split("/");
|
|
950
|
+
const outStack: string[] = [];
|
|
951
|
+
for (const part of stack) {
|
|
952
|
+
if (part === "" || part === ".") continue;
|
|
953
|
+
if (part === "..") outStack.pop();
|
|
954
|
+
else outStack.push(part);
|
|
955
|
+
}
|
|
956
|
+
const base = outStack.join("/");
|
|
957
|
+
if (base === "") return null;
|
|
958
|
+
const candidates = [base];
|
|
959
|
+
const lower = base.toLowerCase();
|
|
960
|
+
if (lower.endsWith(".js")) {
|
|
961
|
+
candidates.push(base.slice(0, -3) + ".ts", base.slice(0, -3) + ".tsx");
|
|
962
|
+
} else if (lower.endsWith(".mjs") || lower.endsWith(".cjs")) {
|
|
963
|
+
candidates.push(base.slice(0, -4) + ".ts");
|
|
964
|
+
} else if (!/\.[a-z]+$/i.test(base)) {
|
|
965
|
+
candidates.push(base + ".ts", base + ".tsx", base + ".mts", base + ".js");
|
|
966
|
+
}
|
|
967
|
+
for (const c of candidates) {
|
|
968
|
+
if (knownFiles.has(c)) return c;
|
|
969
|
+
}
|
|
970
|
+
for (const ext of ["/index.ts", "/index.tsx", "/index.js"]) {
|
|
971
|
+
if (knownFiles.has(base + ext)) return base + ext;
|
|
972
|
+
}
|
|
973
|
+
return null;
|
|
974
|
+
}
|
package/src/memory/db.ts
CHANGED
|
@@ -11,7 +11,7 @@ import { batchedParams, epochSeconds } from "../utils.js";
|
|
|
11
11
|
import { redactPii } from "../indexer/secrets.js";
|
|
12
12
|
import type { Embedder } from "../indexer/embedder.js";
|
|
13
13
|
|
|
14
|
-
export const CURRENT_VERSION =
|
|
14
|
+
export const CURRENT_VERSION = 4;
|
|
15
15
|
const VEC_DIM = 384; // bootstrap dim; rebuilt automatically on mismatch
|
|
16
16
|
|
|
17
17
|
/** Canonical savings buckets — keep in sync with the savings renderer. */
|
|
@@ -170,6 +170,20 @@ CREATE TABLE IF NOT EXISTS savings_log (
|
|
|
170
170
|
CREATE INDEX IF NOT EXISTS idx_savings_bucket_ts ON savings_log(bucket, ts);
|
|
171
171
|
`;
|
|
172
172
|
|
|
173
|
+
const SCHEMA_V4 = `
|
|
174
|
+
CREATE TABLE IF NOT EXISTS decision_links (
|
|
175
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
176
|
+
from_id INTEGER NOT NULL REFERENCES decisions(id) ON DELETE CASCADE,
|
|
177
|
+
to_id INTEGER NOT NULL REFERENCES decisions(id) ON DELETE CASCADE,
|
|
178
|
+
kind TEXT NOT NULL CHECK(kind IN ('related_to','annotates')),
|
|
179
|
+
note TEXT,
|
|
180
|
+
created_at_epoch INTEGER NOT NULL,
|
|
181
|
+
UNIQUE(from_id, to_id, kind)
|
|
182
|
+
);
|
|
183
|
+
CREATE INDEX IF NOT EXISTS idx_decision_links_from ON decision_links(from_id);
|
|
184
|
+
CREATE INDEX IF NOT EXISTS idx_decision_links_to ON decision_links(to_id);
|
|
185
|
+
`;
|
|
186
|
+
|
|
173
187
|
const VEC_TRIGGERS = `
|
|
174
188
|
CREATE TRIGGER IF NOT EXISTS decisions_vec_ad AFTER DELETE ON decisions BEGIN
|
|
175
189
|
DELETE FROM decisions_vec WHERE rowid = old.id;
|
|
@@ -210,6 +224,8 @@ export class MemoryDb {
|
|
|
210
224
|
this.db.exec(VEC_TRIGGERS);
|
|
211
225
|
}
|
|
212
226
|
this.db.exec(SCHEMA_V3);
|
|
227
|
+
this.applyV4Columns();
|
|
228
|
+
this.db.exec(SCHEMA_V4);
|
|
213
229
|
this.db
|
|
214
230
|
.prepare("INSERT INTO schema_versions (version, applied_at_epoch) VALUES (?, ?)")
|
|
215
231
|
.run(this.hasVec ? CURRENT_VERSION : 1, epochSeconds());
|
|
@@ -225,6 +241,10 @@ export class MemoryDb {
|
|
|
225
241
|
this.db.exec(VEC_TRIGGERS);
|
|
226
242
|
}
|
|
227
243
|
if (current < 3) this.db.exec(SCHEMA_V3);
|
|
244
|
+
if (current < 4) {
|
|
245
|
+
this.applyV4Columns();
|
|
246
|
+
this.db.exec(SCHEMA_V4);
|
|
247
|
+
}
|
|
228
248
|
if (current < 2 && !this.hasVec) return; // vec step still pending
|
|
229
249
|
this.db
|
|
230
250
|
.prepare("INSERT INTO schema_versions (version, applied_at_epoch) VALUES (?, ?)")
|
|
@@ -233,6 +253,113 @@ export class MemoryDb {
|
|
|
233
253
|
run();
|
|
234
254
|
}
|
|
235
255
|
|
|
256
|
+
/** v4 adds `decisions.tags` / `decisions.files` (JSON arrays); ALTER is guarded so re-runs and partial states stay safe. */
|
|
257
|
+
private applyV4Columns(): void {
|
|
258
|
+
const cols = new Set(
|
|
259
|
+
(this.db.prepare("PRAGMA table_info(decisions)").all() as { name: string }[]).map((c) => c.name),
|
|
260
|
+
);
|
|
261
|
+
if (!cols.has("tags")) this.db.exec("ALTER TABLE decisions ADD COLUMN tags TEXT");
|
|
262
|
+
if (!cols.has("files")) this.db.exec("ALTER TABLE decisions ADD COLUMN files TEXT");
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// ── R13: linked memory (A-MEM style) ─────────────────────────────────────
|
|
266
|
+
|
|
267
|
+
/** Outgoing `related_to` links a single decision may accumulate. */
|
|
268
|
+
static readonly MAX_OUTGOING_RELATED = 6;
|
|
269
|
+
/** Inbound `related_to` links a hub decision accepts before new links skip it. */
|
|
270
|
+
static readonly MAX_INBOUND_RELATED = 8;
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Create a directed link between two decisions. Refuses self-links,
|
|
274
|
+
* unknown ids and cap overflow; dedupes via UNIQUE(from_id, to_id, kind)
|
|
275
|
+
* (returns false on conflict instead of throwing).
|
|
276
|
+
*/
|
|
277
|
+
linkDecisions(fromId: number, toId: number, kind: "related_to" | "annotates", note: string | null = null): boolean {
|
|
278
|
+
if (!Number.isInteger(fromId) || !Number.isInteger(toId) || fromId === toId) return false;
|
|
279
|
+
const fromRow = this.db.prepare("SELECT 1 FROM decisions WHERE id = ?").get(fromId);
|
|
280
|
+
const toRow = this.db.prepare("SELECT 1 FROM decisions WHERE id = ?").get(toId);
|
|
281
|
+
if (!fromRow || !toRow) return false;
|
|
282
|
+
if (kind === "related_to") {
|
|
283
|
+
const outN = (
|
|
284
|
+
this.db.prepare("SELECT COUNT(*) AS n FROM decision_links WHERE from_id = ? AND kind = 'related_to'").get(fromId) as { n: number }
|
|
285
|
+
).n;
|
|
286
|
+
if (outN >= MemoryDb.MAX_OUTGOING_RELATED) return false;
|
|
287
|
+
const inN = (
|
|
288
|
+
this.db.prepare("SELECT COUNT(*) AS n FROM decision_links WHERE to_id = ? AND kind = 'related_to'").get(toId) as { n: number }
|
|
289
|
+
).n;
|
|
290
|
+
if (inN >= MemoryDb.MAX_INBOUND_RELATED) return false;
|
|
291
|
+
}
|
|
292
|
+
try {
|
|
293
|
+
this.db
|
|
294
|
+
.prepare("INSERT INTO decision_links (from_id, to_id, kind, note, created_at_epoch) VALUES (?, ?, ?, ?, ?)")
|
|
295
|
+
.run(fromId, toId, kind, note, epochSeconds());
|
|
296
|
+
return true;
|
|
297
|
+
} catch {
|
|
298
|
+
return false; // duplicate or FK violation — already linked
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Both directions of a decision's links, ready for recall rendering. */
|
|
303
|
+
getDecisionLinks(id: number): {
|
|
304
|
+
relatedTo: number[];
|
|
305
|
+
relatedFrom: number[];
|
|
306
|
+
annotates: { id: number; note: string | null }[];
|
|
307
|
+
annotatedBy: { id: number; note: string | null }[];
|
|
308
|
+
} {
|
|
309
|
+
const rows = this.db
|
|
310
|
+
.prepare("SELECT from_id, to_id, kind, note FROM decision_links WHERE from_id = ? OR to_id = ?")
|
|
311
|
+
.all(id, id) as { from_id: number; to_id: number; kind: string; note: string | null }[];
|
|
312
|
+
const out = {
|
|
313
|
+
relatedTo: [] as number[],
|
|
314
|
+
relatedFrom: [] as number[],
|
|
315
|
+
annotates: [] as { id: number; note: string | null }[],
|
|
316
|
+
annotatedBy: [] as { id: number; note: string | null }[],
|
|
317
|
+
};
|
|
318
|
+
for (const r of rows) {
|
|
319
|
+
if (r.kind === "related_to") {
|
|
320
|
+
if (r.from_id === id) out.relatedTo.push(r.to_id);
|
|
321
|
+
else out.relatedFrom.push(r.from_id);
|
|
322
|
+
} else {
|
|
323
|
+
if (r.from_id === id) out.annotates.push({ id: r.to_id, note: r.note });
|
|
324
|
+
else out.annotatedBy.push({ id: r.from_id, note: r.note });
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
return out;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** Short one-line text of a decision (for link rendering). */
|
|
331
|
+
decisionHeadline(id: number): string | null {
|
|
332
|
+
const row = this.db.prepare("SELECT decision FROM decisions WHERE id = ?").get(id) as { decision: string } | undefined;
|
|
333
|
+
return row ? row.decision : null;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* R13 auto-linking: embed the new decision, find similar past decisions
|
|
338
|
+
* and connect them with `related_to` edges (A-MEM's dynamic indexing).
|
|
339
|
+
* The similarity gate is deliberately tighter than recall (0.85 vs 0.92
|
|
340
|
+
* L2 on normalized vectors ≈ cosine ≥ 0.64): a wrong link is worse than a
|
|
341
|
+
* missing one, because recall walks them.
|
|
342
|
+
*/
|
|
343
|
+
autoLinkDecision(
|
|
344
|
+
embedder: Embedder,
|
|
345
|
+
newId: number,
|
|
346
|
+
text: string,
|
|
347
|
+
opts: { k?: number; maxDistance?: number; cap?: number } = {},
|
|
348
|
+
): Promise<number[]> {
|
|
349
|
+
const { k = 4, maxDistance = 0.85, cap = 3 } = opts;
|
|
350
|
+
return (async () => {
|
|
351
|
+
if (!this.hasVecTables() || !text.trim()) return [];
|
|
352
|
+
const ids = await this.searchDecisionsVec(embedder, text, k, maxDistance);
|
|
353
|
+
const linked: number[] = [];
|
|
354
|
+
for (const id of ids) {
|
|
355
|
+
if (id === newId) continue;
|
|
356
|
+
if (linked.length >= cap) break;
|
|
357
|
+
if (this.linkDecisions(newId, id, "related_to")) linked.push(id);
|
|
358
|
+
}
|
|
359
|
+
return linked;
|
|
360
|
+
})();
|
|
361
|
+
}
|
|
362
|
+
|
|
236
363
|
private vecTableStmts(dim: number): string {
|
|
237
364
|
return `
|
|
238
365
|
CREATE VIRTUAL TABLE IF NOT EXISTS decisions_vec USING vec0(embedding float[${dim}]);
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
* Weighted sum: vector similarity 0.5, keyword/file-hint 0.4, recency 0.1.
|
|
4
4
|
*/
|
|
5
5
|
import type { Chunk } from "../types.js";
|
|
6
|
+
import type { ParsedQuery } from "./query-parser.js";
|
|
7
|
+
import type { QuerySignals } from "./retriever.js";
|
|
6
8
|
|
|
7
9
|
const VECTOR_WEIGHT = 0.5;
|
|
8
10
|
const KEYWORD_WEIGHT = 0.4;
|
|
@@ -27,3 +29,54 @@ export class ConfidenceScorer {
|
|
|
27
29
|
return Math.pow(0.5, ageSeconds / RECENCY_HALF_LIFE);
|
|
28
30
|
}
|
|
29
31
|
}
|
|
32
|
+
|
|
33
|
+
// ── R8: query-level confidence ─────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
export type QueryConfidence = "high" | "medium" | "low";
|
|
36
|
+
|
|
37
|
+
export interface QueryAssessment {
|
|
38
|
+
level: QueryConfidence;
|
|
39
|
+
/** Composite 0..1 quality score (reported for observability). */
|
|
40
|
+
quality: number;
|
|
41
|
+
/** 1-3 deterministic recovery hints; non-empty only below `high`. */
|
|
42
|
+
hints: string[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Signal weights for the composite quality score. */
|
|
46
|
+
const SIGNAL_WEIGHTS = { topScore: 0.5, agreement: 0.35, margin: 0.15 };
|
|
47
|
+
/** Candidate pools this small cap the level at `medium` (tiny indexes lie). */
|
|
48
|
+
const MIN_CANDIDATES = 5;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Map query signals to high/medium/low plus recovery hints. Thresholds come
|
|
52
|
+
* from config (calibrated on the eval fixture); hints are derived from the
|
|
53
|
+
* parsed query so the agent gets actionable, deterministic advice instead of
|
|
54
|
+
* a silent rewrite it cannot inspect.
|
|
55
|
+
*/
|
|
56
|
+
export function assessQuery(
|
|
57
|
+
signals: QuerySignals,
|
|
58
|
+
parsed: ParsedQuery,
|
|
59
|
+
thresholds: { high: number; low: number },
|
|
60
|
+
): QueryAssessment {
|
|
61
|
+
const marginNorm = Math.min(1, Math.max(0, signals.margin));
|
|
62
|
+
const quality =
|
|
63
|
+
SIGNAL_WEIGHTS.topScore * Math.min(1, Math.max(0, signals.topScore)) +
|
|
64
|
+
SIGNAL_WEIGHTS.agreement * Math.min(1, Math.max(0, signals.agreement)) +
|
|
65
|
+
SIGNAL_WEIGHTS.margin * marginNorm;
|
|
66
|
+
|
|
67
|
+
let level: QueryConfidence;
|
|
68
|
+
if (quality >= thresholds.high) level = "high";
|
|
69
|
+
else if (quality >= thresholds.low) level = "medium";
|
|
70
|
+
else level = "low";
|
|
71
|
+
if (signals.candidates < MIN_CANDIDATES && level === "high") level = "medium";
|
|
72
|
+
|
|
73
|
+
const hints: string[] = [];
|
|
74
|
+
if (level !== "high") {
|
|
75
|
+
if (parsed.fileHints.length === 0) hints.push("name a file or path fragment (e.g. retriever.ts)");
|
|
76
|
+
const identifierLike = parsed.keywords.some((k) => k.length >= 4 && /[a-z][A-Z]|[A-Z][a-z]/.test(k));
|
|
77
|
+
if (!identifierLike) hints.push("use an exact symbol name (function or class, e.g. HybridRetriever)");
|
|
78
|
+
if (parsed.keywords.length > 8) hints.push("drop vague terms; keep 2-4 specific keywords");
|
|
79
|
+
if (signals.agreement < 0.2) hints.push("results disagree between retrieval paths; try a more specific query");
|
|
80
|
+
}
|
|
81
|
+
return { level, quality, hints: hints.slice(0, 3) };
|
|
82
|
+
}
|
|
@@ -80,3 +80,36 @@ export class QueryParser {
|
|
|
80
80
|
return result;
|
|
81
81
|
}
|
|
82
82
|
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* R8: deterministic query elaboration. Strips stopwords, caps the raw terms,
|
|
86
|
+
* and injects up to three identifier-like words that appear in ≥2 of the
|
|
87
|
+
* near-miss FTS hits. No LLM, no network, fully reproducible — the raw
|
|
88
|
+
* query's own ranking is never modified (callers fuse the variant as an
|
|
89
|
+
* additional, lower-weighted candidate source only).
|
|
90
|
+
*/
|
|
91
|
+
export function elaborateQuery(query: string, nearMissChunks: { content: string }[]): string | null {
|
|
92
|
+
const words = query.match(/[a-zA-Z_$][a-zA-Z0-9_$]*/g) ?? [];
|
|
93
|
+
const kept: string[] = [];
|
|
94
|
+
for (const w of words) {
|
|
95
|
+
if (STOP_WORDS.has(w.toLowerCase()) || w.length < 3) continue;
|
|
96
|
+
if (!kept.includes(w)) kept.push(w);
|
|
97
|
+
}
|
|
98
|
+
const freq = new Map<string, number>();
|
|
99
|
+
for (const chunk of nearMissChunks) {
|
|
100
|
+
const local = new Set<string>();
|
|
101
|
+
for (const m of chunk.content.matchAll(/[A-Za-z_$][A-Za-z0-9_$]{3,}/g)) {
|
|
102
|
+
const w = m[0];
|
|
103
|
+
if (!kept.includes(w)) local.add(w);
|
|
104
|
+
}
|
|
105
|
+
for (const w of local) freq.set(w, (freq.get(w) ?? 0) + 1);
|
|
106
|
+
}
|
|
107
|
+
const extras = [...freq.entries()]
|
|
108
|
+
.filter(([, n]) => n >= 2)
|
|
109
|
+
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
|
110
|
+
.slice(0, 3)
|
|
111
|
+
.map(([w]) => w);
|
|
112
|
+
const variant = [...kept.slice(0, 8), ...extras].join(" ");
|
|
113
|
+
if (variant.length < 3 || variant === query.trim()) return null;
|
|
114
|
+
return variant;
|
|
115
|
+
}
|