pi-context-engine 0.1.4 → 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.
@@ -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
- if (ignoreSet.has(entry.name)) continue;
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 module of importedModules) {
656
- batchEdges.push({
657
- sourceId: fileNodeId,
658
- targetId: `module_${module}`,
659
- edgeType: "imports",
660
- properties: {},
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 = chunk.content.includes("(")
665
- ? chunk.content.split("(")[0]!.split(":").pop()!.trim()
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 = 3;
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
+ }