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/src/engine.ts CHANGED
@@ -11,6 +11,9 @@ import { atomicWriteText, epochSeconds, humaniseRelativeTime, projectStorageDir,
11
11
  import { LocalBackend } from "./storage/local-backend.js";
12
12
  import { Embedder, selectBackend } from "./indexer/embedder.js";
13
13
  import { HybridRetriever } from "./retrieval/retriever.js";
14
+ import { LocalReranker } from "./retrieval/reranker.js";
15
+ import { assessQuery, type QueryAssessment } from "./retrieval/confidence.js";
16
+ import { QueryParser } from "./retrieval/query-parser.js";
14
17
  import { Compressor } from "./compression/compressor.js";
15
18
  import { MemoryDb, memoryDbPath, scrubPii, setPiiRedaction, type Bucket } from "./memory/db.js";
16
19
  import { SessionCapture } from "./memory/session-capture.js";
@@ -126,6 +129,22 @@ export interface EngineOptions {
126
129
  runtime?: { backend: LocalBackend; embedder: Embedder };
127
130
  }
128
131
 
132
+ /** R13: cheap keyword extraction for decision tags (no LLM): distinctive
133
+ * lowercased tokens, stopwords stripped, longest first for stability. */
134
+ const KEYWORD_STOPWORDS = new Set([
135
+ "the", "and", "for", "with", "that", "this", "from", "into", "use", "using", "was", "are",
136
+ "when", "then", "than", "will", "shall", "should", "would", "could", "have", "has", "been",
137
+ "because", "since", "given", "instead", "only", "also", "just", "like", "over", "under",
138
+ ]);
139
+ function keywordsOf(text: string, max = 5): string[] {
140
+ const tokens = text
141
+ .toLowerCase()
142
+ .replace(/[^a-z0-9_.-]+/g, " ")
143
+ .split(" ")
144
+ .filter((w) => w.length >= 4 && !KEYWORD_STOPWORDS.has(w));
145
+ return [...new Set(tokens)].sort((a, b) => b.length - a.length).slice(0, max);
146
+ }
147
+
129
148
  export class ContextEngine {
130
149
  readonly projectDir: string;
131
150
  readonly projectName: string;
@@ -187,7 +206,14 @@ export class ContextEngine {
187
206
 
188
207
  async getRetriever(): Promise<HybridRetriever> {
189
208
  if (!this.retrieverInstance) {
190
- this.retrieverInstance = new HybridRetriever(this.backend, await this.getEmbedder());
209
+ this.retrieverInstance = new HybridRetriever(this.backend, await this.getEmbedder(), {
210
+ expandContext: this.config.retrievalExpandContext,
211
+ autoElaborate: this.config.retrievalAutoElaborate,
212
+ reranker:
213
+ this.config.retrievalRerank === "local"
214
+ ? new LocalReranker(this.config.retrievalRerankModel)
215
+ : null,
216
+ });
191
217
  }
192
218
  return this.retrieverInstance;
193
219
  }
@@ -297,6 +323,10 @@ export class ContextEngine {
297
323
  callbacks: { phase: options.onProgress },
298
324
  runtime: this.injectedRuntime,
299
325
  });
326
+ // R7: graph edges changed → refresh the PageRank prior and force the
327
+ // retriever to pick it up on the next query.
328
+ this.backend.recomputePriors();
329
+ this.retrieverInstance = null;
300
330
  return {
301
331
  indexed: result.indexedFiles.length,
302
332
  chunks: result.totalChunks,
@@ -308,9 +338,16 @@ export class ContextEngine {
308
338
 
309
339
  // ── Memory writes ─────────────────────────────────────────────────────
310
340
 
311
- async recordDecision(decisionInput: string, reasonInput: string): Promise<string> {
341
+ async recordDecision(
342
+ decisionInput: string,
343
+ reasonInput: string,
344
+ opts: { relatedTo?: number[]; tags?: string[]; files?: string[] } = {},
345
+ ): Promise<string> {
312
346
  const decision = scrubPii(decisionInput.trim());
313
347
  const reason = scrubPii(reasonInput.trim());
348
+ // R13: structured attributes - scrubbed before any write, like the text.
349
+ const tags = (opts.tags ?? []).map((x) => scrubPii(x.trim())).filter(Boolean).slice(0, 8);
350
+ const files = (opts.files ?? []).map((x) => scrubPii(x.trim())).filter(Boolean).slice(0, 8);
314
351
  this.sessionCapture.recordDecision(this.sessionId, decision, reason);
315
352
  this.sessionCapture.persist(this.sessionId);
316
353
  this.searchesSinceDecision = 0;
@@ -324,11 +361,32 @@ export class ContextEngine {
324
361
  this.recordBucket("grammar", stored.rawTokens + storedReason.rawTokens, stored.compressedTokens + storedReason.compressedTokens);
325
362
  const result = memory.db
326
363
  .prepare(
327
- "INSERT INTO decisions (session_id, decision, reason, source, created_at_epoch, created_at) VALUES (?, ?, ?, 'manual', ?, ?)",
364
+ "INSERT INTO decisions (session_id, decision, reason, source, created_at_epoch, created_at, tags, files) VALUES (?, ?, ?, 'manual', ?, ?, ?, ?)",
328
365
  )
329
- .run(this.sessionId, stored.text, storedReason.text, epoch, new Date(epoch * 1000).toISOString());
366
+ .run(
367
+ this.sessionId,
368
+ stored.text,
369
+ storedReason.text,
370
+ epoch,
371
+ new Date(epoch * 1000).toISOString(),
372
+ JSON.stringify([...tags, ...keywordsOf(decision)].slice(0, 8)),
373
+ JSON.stringify(files),
374
+ );
375
+ const newId = Number(result.lastInsertRowid);
330
376
  const embedder = await this.getEmbedder();
331
- await memory.recordDecisionVec(embedder, Number(result.lastInsertRowid), stored.text, storedReason.text);
377
+ await memory.recordDecisionVec(embedder, newId, stored.text, storedReason.text);
378
+ // R13 auto-linking: connect similar past decisions with related_to edges.
379
+ const linked = await memory.autoLinkDecision(embedder, newId, stored.text + " - " + storedReason.text, { cap: 3 });
380
+ // R13 memory evolution: an explicit relatedTo annotates an earlier
381
+ // decision (original text stays immutable; the note lives on the edge).
382
+ for (const target of opts.relatedTo ?? []) {
383
+ memory.linkDecisions(newId, Number(target), "annotates", reason || null);
384
+ }
385
+ if (linked.length > 0) {
386
+ const heads = linked.map((id) => memory.decisionHeadline(id)).filter(Boolean).join("; ");
387
+ if (heads) return decision + " (linked: " + heads + ") [#" + newId + "]";
388
+ }
389
+ return decision + " [#" + newId + "]";
332
390
  }
333
391
  return decision;
334
392
  }
@@ -494,13 +552,21 @@ export class ContextEngine {
494
552
  inline: Chunk[];
495
553
  overflow: Chunk[];
496
554
  droppedLowValue: number;
555
+ /** R8: query-level confidence assessment for this call. */
556
+ queryAssessment: QueryAssessment;
497
557
  }> {
498
558
  const retriever = await this.getRetriever();
499
559
  const compressor = await this.getCompressor();
500
560
  const topK = Math.max(1, Math.min(opts.topK ?? 10, MAX_TOP_K));
501
561
  const maxTokens = Math.max(500, Math.min(opts.maxTokens ?? 8000, 50_000));
502
562
 
503
- const stats: { candidates?: number; selected?: number; droppedLowValue?: number } = {};
563
+ const stats: {
564
+ candidates?: number;
565
+ selected?: number;
566
+ droppedLowValue?: number;
567
+ querySignals?: import("./retrieval/retriever.js").QuerySignals;
568
+ nearMissFiles?: string[];
569
+ } = {};
504
570
  let chunks = await retriever.retrieve(query, {
505
571
  topK: topK * 2,
506
572
  confidenceThreshold: this.config.retrievalConfidenceThreshold,
@@ -508,7 +574,14 @@ export class ContextEngine {
508
574
  maxTokens: null,
509
575
  statsOut: stats,
510
576
  });
511
- chunks = await compressor.compress(chunks, this.config.compressionLevel);
577
+ // R3: the compressor keeps lines that answer THIS query when it has to
578
+ // truncate. Query-agnostic setups (or `compression.query_aware: false`)
579
+ // keep the old head-truncation behaviour.
580
+ chunks = await compressor.compress(
581
+ chunks,
582
+ this.config.compressionLevel,
583
+ this.config.compressionQueryAware ? query : undefined,
584
+ );
512
585
  chunks = chunks.filter((c) => !isPceConfig(c.filePath));
513
586
 
514
587
  const inline: Chunk[] = [];
@@ -569,8 +642,101 @@ export class ContextEngine {
569
642
  this.searchesSinceDecision += 1;
570
643
 
571
644
  const body = formatResults(inline, overflow);
645
+ // R8: query-confidence line — silent when high, actionable hints when the
646
+ // evidence says the query was weak (predicted query performance tracks
647
+ // answer quality; telling the agent beats silently serving junk).
648
+ const assessment = assessQuery(
649
+ stats.querySignals ?? { topScore: 0, margin: 0, agreement: 0, candidates: 0 },
650
+ new QueryParser().parse(query),
651
+ { high: this.config.retrievalConfidenceHigh, low: this.config.retrievalConfidenceLow },
652
+ );
653
+ const bodyWithConfidence =
654
+ assessment.level === "high" || (inline.length === 0 && overflow.length === 0)
655
+ ? body
656
+ : `${body}\n[query confidence: ${assessment.level}${assessment.hints.length > 0 ? ` — ${assessment.hints.join("; ")}` : ""}]`;
657
+ // R9: one `refine:` line — the next query candidates the retriever can
658
+ // already see (sibling symbol, just-missed file, an alternate term), so
659
+ // the search tool becomes a guided loop instead of a one-shot.
660
+ const refine = this.buildRefineLine(query, inline, stats.nearMissFiles ?? []);
661
+ const finalBody = refine ? `${bodyWithConfidence}\n${refine}` : bodyWithConfidence;
572
662
  this.recordQuery(rawTokens, servedTokens, fullFileTokens);
573
- return { body, inline, overflow, droppedLowValue: stats.droppedLowValue ?? 0 };
663
+ return {
664
+ body: finalBody,
665
+ inline,
666
+ overflow,
667
+ droppedLowValue: stats.droppedLowValue ?? 0,
668
+ queryAssessment: assessment,
669
+ };
670
+ }
671
+
672
+ /** Chunker fallback node names that are never useful refine hints. */
673
+ private static readonly REFINE_JUNK_NAMES = new Set([
674
+ "function",
675
+ "class",
676
+ "module",
677
+ "const",
678
+ "export",
679
+ "import",
680
+ "interface",
681
+ "return",
682
+ "async",
683
+ ]);
684
+
685
+ /**
686
+ * R9: deterministic refine suggestions from what the retriever already
687
+ * knows: a sibling symbol from the top hit's file (graph defines), a file
688
+ * that ranked 11-15 but was not served, and a term frequent across the
689
+ * top hits but absent from the query. Capped at 3 items and 160 chars
690
+ * (≤40 tokens) per call.
691
+ */
692
+ private buildRefineLine(query: string, hits: Chunk[], nearMissFiles: string[]): string | null {
693
+ if (!this.config.retrievalRefineHints) return null;
694
+ const items: string[] = [];
695
+ const top = hits[0];
696
+ if (top) {
697
+ try {
698
+ const units = new Set(
699
+ hits
700
+ .filter((h) => h.filePath === top.filePath)
701
+ .map((h) => {
702
+ const u = h.metadata["unit"];
703
+ return typeof u === "string" && u.includes(".") ? u.slice(u.lastIndexOf(".") + 1) : typeof u === "string" ? u : "";
704
+ }),
705
+ );
706
+ const sibling = this.backend
707
+ .getNodesByFile(top.filePath)
708
+ .filter((n) => n.nodeType === "function" || n.nodeType === "class")
709
+ .map((n) => n.name)
710
+ .filter((n) => !ContextEngine.REFINE_JUNK_NAMES.has(n) && /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(n))
711
+ .find((n) => !units.has(n));
712
+ if (sibling) items.push(`"${sibling}"`);
713
+ } catch {
714
+ // graph lookup is best-effort
715
+ }
716
+ }
717
+ const servedFiles = new Set(hits.map((h) => h.filePath));
718
+ for (const f of nearMissFiles.slice(0, 1)) {
719
+ if (!servedFiles.has(f)) items.push(`file:${f}`);
720
+ }
721
+ const termFreq = new Map<string, number>();
722
+ for (const chunk of hits.slice(0, 3)) {
723
+ const text = chunk.compressedContent || chunk.content;
724
+ const local = new Set<string>();
725
+ for (const m of text.matchAll(/[A-Za-z_$][A-Za-z0-9_$]{4,}/g)) {
726
+ const w = m[0];
727
+ if (!query.includes(w)) local.add(w);
728
+ }
729
+ for (const w of local) termFreq.set(w, (termFreq.get(w) ?? 0) + 1);
730
+ }
731
+ const alt = [...termFreq.entries()]
732
+ .filter(([w, n]) => n >= 2 && !ContextEngine.REFINE_JUNK_NAMES.has(w))
733
+ .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
734
+ .map(([w]) => `"${w}"`)
735
+ .find((w) => !items.includes(w));
736
+ if (alt) items.push(alt);
737
+ if (items.length === 0) return null;
738
+ const line = `[refine: ${items.slice(0, 3).join(" · ")}]`;
739
+ return line.length <= 160 ? line : null;
574
740
  }
575
741
 
576
742
  /** Memory nudge appended to search results when the agent never records. */
@@ -823,9 +989,20 @@ export class ContextEngine {
823
989
  if (!r) continue;
824
990
  const recency = humaniseRelativeTime(r.created_at_epoch);
825
991
  let tail = recency ? ` · ${recency}` : "";
992
+ // R13: surface linked decisions so the agent can walk the graph.
993
+ const links = memory.getDecisionLinks(r.id);
994
+ if (links.relatedTo.length > 0) {
995
+ tail += ` · related: ${links.relatedTo.join(", ")}`;
996
+ }
997
+ if (links.relatedFrom.length > 0) {
998
+ tail += ` · relatedFrom: ${links.relatedFrom.join(", ")}`;
999
+ }
1000
+ for (const ann of links.annotatedBy.slice(0, 2)) {
1001
+ tail += ` · annotation #${ann.id}${ann.note ? ": " + ann.note.slice(0, 80) : ""}`;
1002
+ }
826
1003
  if (r.session_id) tail += ` · → session_timeline("${r.session_id}")`;
827
1004
  out.push(
828
- `[decision src=${r.source}|sid:${r.session_id ?? "-"}] ${expand(r.decision)} — ${expand(r.reason)}${tail}`,
1005
+ `[decision #${r.id} src=${r.source}|sid:${r.session_id ?? "-"}] ${expand(r.decision)} — ${expand(r.reason)}${tail}`,
829
1006
  );
830
1007
  }
831
1008
  return out;
@@ -986,9 +1163,26 @@ export class ContextEngine {
986
1163
  }
987
1164
 
988
1165
  relatedContext(chunkId: string): string {
989
- const neighbors = this.backend.graphNeighbors(chunkId);
990
- if (neighbors.length === 0) return "No related context found for this chunk.";
991
- return neighbors.map((n) => `- ${n.nodeType}: ${n.name} (${n.filePath})`).join("\n");
1166
+ // R7: with real calls edges, a chunk's outgoing calls are genuine
1167
+ // neighbours. Node ids that are not calls sources (e.g. file nodes,
1168
+ // whose DEFINES edges point at their chunks) keep working via the
1169
+ // unfiltered lookup, then file-level imports as the last resort.
1170
+ // Before R7 all of this was dead: imports edges pointed at nodes that
1171
+ // never existed.
1172
+ const render = (ns: { nodeType: string; name: string; filePath: string }[]): string =>
1173
+ ns.map((n) => `- ${n.nodeType}: ${n.name} (${n.filePath})`).join("\n");
1174
+ const callTargets = this.backend.graphNeighbors(chunkId, "calls");
1175
+ if (callTargets.length > 0) return render(callTargets);
1176
+ const any = this.backend.graphNeighbors(chunkId);
1177
+ if (any.length > 0) return render(any);
1178
+ const chunk = this.backend.getChunkById(chunkId);
1179
+ if (chunk) {
1180
+ const related = this.backend.getRelatedFilePaths([chunk.filePath]);
1181
+ if (related.length > 0) {
1182
+ return related.map((fp) => `- file: ${fp}`).join("\n");
1183
+ }
1184
+ }
1185
+ return "No related context found for this chunk.";
992
1186
  }
993
1187
 
994
1188
  /** Cost note like ", $4.37 saved" for the status line. */
package/src/extension.ts CHANGED
@@ -178,12 +178,23 @@ export default function contextEngineExtension(pi: ExtensionAPI) {
178
178
  parameters: Type.Object({
179
179
  decision: Type.String({ description: "What was decided" }),
180
180
  reason: Type.String({ description: "Why — the constraint or tradeoff" }),
181
+ related_to: Type.Optional(
182
+ Type.Array(Type.Integer(), {
183
+ description: "Ids of earlier decisions this one annotates — ids appear in record_decision replies ([#N]) and session_recall lines ([decision #N …])",
184
+ }),
185
+ ),
186
+ tags: Type.Optional(Type.Array(Type.String(), { description: "Short labels for later filtering" })),
187
+ files: Type.Optional(Type.Array(Type.String(), { description: "Affected file paths (relative)" })),
181
188
  }),
182
189
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
183
190
  const decision = (params.decision ?? "").trim();
184
191
  const reason = (params.reason ?? "").trim();
185
192
  if (!decision) return text("decision is required.");
186
- const stored = await getEngine(ctx.cwd).recordDecision(decision, reason);
193
+ const stored = await getEngine(ctx.cwd).recordDecision(decision, reason, {
194
+ relatedTo: params.related_to,
195
+ tags: params.tags,
196
+ files: params.files,
197
+ });
187
198
  return text(`✓ Decision recorded: ${stored}`);
188
199
  },
189
200
  });
package/src/guidance.ts CHANGED
@@ -69,6 +69,7 @@ RULES:
69
69
  - A "\`expand_chunk\`" reference list means results were cut by the token budget - expand only the ones you actually need, never every entry
70
70
  - Confidence below ~0.5 usually means the index has nothing better; rephrase rather than trusting a weak hit
71
71
  - Results come from the index, not the live file: before editing, open the file with \`read\` to see its current state
72
+ - A \`[query confidence: low|medium - ...]\` line at the end means the RETRIEVER itself judged the query weak (top score, retrieval-path agreement, file margin). Act on its hints instead of re-reading files: name a file or symbol, or drop vague terms. \`high\` confidence is reported by silence, not a line
72
73
 
73
74
  ### Before editing code
74
75