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/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;
@@ -147,6 +166,7 @@ export class ContextEngine {
147
166
  private searchesSinceDecision = 0;
148
167
  private hasRecordedDecision = false;
149
168
  private vecBackfillStarted = false;
169
+ private autoIndexDone = false;
150
170
 
151
171
  constructor(options: EngineOptions) {
152
172
  this.projectDir = fs.realpathSync(path.resolve(options.projectDir));
@@ -187,7 +207,14 @@ export class ContextEngine {
187
207
 
188
208
  async getRetriever(): Promise<HybridRetriever> {
189
209
  if (!this.retrieverInstance) {
190
- this.retrieverInstance = new HybridRetriever(this.backend, await this.getEmbedder());
210
+ this.retrieverInstance = new HybridRetriever(this.backend, await this.getEmbedder(), {
211
+ expandContext: this.config.retrievalExpandContext,
212
+ autoElaborate: this.config.retrievalAutoElaborate,
213
+ reranker:
214
+ this.config.retrievalRerank === "local"
215
+ ? new LocalReranker(this.config.retrievalRerankModel)
216
+ : null,
217
+ });
191
218
  }
192
219
  return this.retrieverInstance;
193
220
  }
@@ -297,6 +324,10 @@ export class ContextEngine {
297
324
  callbacks: { phase: options.onProgress },
298
325
  runtime: this.injectedRuntime,
299
326
  });
327
+ // R7: graph edges changed → refresh the PageRank prior and force the
328
+ // retriever to pick it up on the next query.
329
+ this.backend.recomputePriors();
330
+ this.retrieverInstance = null;
300
331
  return {
301
332
  indexed: result.indexedFiles.length,
302
333
  chunks: result.totalChunks,
@@ -306,11 +337,46 @@ export class ContextEngine {
306
337
  };
307
338
  }
308
339
 
340
+ /**
341
+ * One-shot background incremental index, kicked at session start by the
342
+ * extension (config `indexer.auto_index`, default true). Returns null when
343
+ * disabled or when this engine already ran (or skipped) its auto-index, so
344
+ * callers never stack a second background run. A REJECTED run (backend
345
+ * init, model download, embedding crash) un-consumes the one-shot so the
346
+ * next kick retries — the index is still empty, after all. The pipeline
347
+ * lock in runIndexing serializes this with a manual /context-index or a
348
+ * lazy search-triggered run, so races are safe — just wasteful, which the
349
+ * one-shot guard prevents.
350
+ */
351
+ async autoIndexOnce(): Promise<{
352
+ indexed: number;
353
+ chunks: number;
354
+ deleted: number;
355
+ skipped: number;
356
+ errors: string[];
357
+ } | null> {
358
+ if (!this.config.indexerAutoIndex || this.autoIndexDone) return null;
359
+ this.autoIndexDone = true;
360
+ try {
361
+ return await this.reindex({});
362
+ } catch (err) {
363
+ this.autoIndexDone = false;
364
+ throw err;
365
+ }
366
+ }
367
+
309
368
  // ── Memory writes ─────────────────────────────────────────────────────
310
369
 
311
- async recordDecision(decisionInput: string, reasonInput: string): Promise<string> {
370
+ async recordDecision(
371
+ decisionInput: string,
372
+ reasonInput: string,
373
+ opts: { relatedTo?: number[]; tags?: string[]; files?: string[] } = {},
374
+ ): Promise<string> {
312
375
  const decision = scrubPii(decisionInput.trim());
313
376
  const reason = scrubPii(reasonInput.trim());
377
+ // R13: structured attributes - scrubbed before any write, like the text.
378
+ const tags = (opts.tags ?? []).map((x) => scrubPii(x.trim())).filter(Boolean).slice(0, 8);
379
+ const files = (opts.files ?? []).map((x) => scrubPii(x.trim())).filter(Boolean).slice(0, 8);
314
380
  this.sessionCapture.recordDecision(this.sessionId, decision, reason);
315
381
  this.sessionCapture.persist(this.sessionId);
316
382
  this.searchesSinceDecision = 0;
@@ -324,11 +390,32 @@ export class ContextEngine {
324
390
  this.recordBucket("grammar", stored.rawTokens + storedReason.rawTokens, stored.compressedTokens + storedReason.compressedTokens);
325
391
  const result = memory.db
326
392
  .prepare(
327
- "INSERT INTO decisions (session_id, decision, reason, source, created_at_epoch, created_at) VALUES (?, ?, ?, 'manual', ?, ?)",
393
+ "INSERT INTO decisions (session_id, decision, reason, source, created_at_epoch, created_at, tags, files) VALUES (?, ?, ?, 'manual', ?, ?, ?, ?)",
328
394
  )
329
- .run(this.sessionId, stored.text, storedReason.text, epoch, new Date(epoch * 1000).toISOString());
395
+ .run(
396
+ this.sessionId,
397
+ stored.text,
398
+ storedReason.text,
399
+ epoch,
400
+ new Date(epoch * 1000).toISOString(),
401
+ JSON.stringify([...tags, ...keywordsOf(decision)].slice(0, 8)),
402
+ JSON.stringify(files),
403
+ );
404
+ const newId = Number(result.lastInsertRowid);
330
405
  const embedder = await this.getEmbedder();
331
- await memory.recordDecisionVec(embedder, Number(result.lastInsertRowid), stored.text, storedReason.text);
406
+ await memory.recordDecisionVec(embedder, newId, stored.text, storedReason.text);
407
+ // R13 auto-linking: connect similar past decisions with related_to edges.
408
+ const linked = await memory.autoLinkDecision(embedder, newId, stored.text + " - " + storedReason.text, { cap: 3 });
409
+ // R13 memory evolution: an explicit relatedTo annotates an earlier
410
+ // decision (original text stays immutable; the note lives on the edge).
411
+ for (const target of opts.relatedTo ?? []) {
412
+ memory.linkDecisions(newId, Number(target), "annotates", reason || null);
413
+ }
414
+ if (linked.length > 0) {
415
+ const heads = linked.map((id) => memory.decisionHeadline(id)).filter(Boolean).join("; ");
416
+ if (heads) return decision + " (linked: " + heads + ") [#" + newId + "]";
417
+ }
418
+ return decision + " [#" + newId + "]";
332
419
  }
333
420
  return decision;
334
421
  }
@@ -494,13 +581,21 @@ export class ContextEngine {
494
581
  inline: Chunk[];
495
582
  overflow: Chunk[];
496
583
  droppedLowValue: number;
584
+ /** R8: query-level confidence assessment for this call. */
585
+ queryAssessment: QueryAssessment;
497
586
  }> {
498
587
  const retriever = await this.getRetriever();
499
588
  const compressor = await this.getCompressor();
500
589
  const topK = Math.max(1, Math.min(opts.topK ?? 10, MAX_TOP_K));
501
590
  const maxTokens = Math.max(500, Math.min(opts.maxTokens ?? 8000, 50_000));
502
591
 
503
- const stats: { candidates?: number; selected?: number; droppedLowValue?: number } = {};
592
+ const stats: {
593
+ candidates?: number;
594
+ selected?: number;
595
+ droppedLowValue?: number;
596
+ querySignals?: import("./retrieval/retriever.js").QuerySignals;
597
+ nearMissFiles?: string[];
598
+ } = {};
504
599
  let chunks = await retriever.retrieve(query, {
505
600
  topK: topK * 2,
506
601
  confidenceThreshold: this.config.retrievalConfidenceThreshold,
@@ -508,7 +603,14 @@ export class ContextEngine {
508
603
  maxTokens: null,
509
604
  statsOut: stats,
510
605
  });
511
- chunks = await compressor.compress(chunks, this.config.compressionLevel);
606
+ // R3: the compressor keeps lines that answer THIS query when it has to
607
+ // truncate. Query-agnostic setups (or `compression.query_aware: false`)
608
+ // keep the old head-truncation behaviour.
609
+ chunks = await compressor.compress(
610
+ chunks,
611
+ this.config.compressionLevel,
612
+ this.config.compressionQueryAware ? query : undefined,
613
+ );
512
614
  chunks = chunks.filter((c) => !isPceConfig(c.filePath));
513
615
 
514
616
  const inline: Chunk[] = [];
@@ -569,8 +671,101 @@ export class ContextEngine {
569
671
  this.searchesSinceDecision += 1;
570
672
 
571
673
  const body = formatResults(inline, overflow);
674
+ // R8: query-confidence line — silent when high, actionable hints when the
675
+ // evidence says the query was weak (predicted query performance tracks
676
+ // answer quality; telling the agent beats silently serving junk).
677
+ const assessment = assessQuery(
678
+ stats.querySignals ?? { topScore: 0, margin: 0, agreement: 0, candidates: 0 },
679
+ new QueryParser().parse(query),
680
+ { high: this.config.retrievalConfidenceHigh, low: this.config.retrievalConfidenceLow },
681
+ );
682
+ const bodyWithConfidence =
683
+ assessment.level === "high" || (inline.length === 0 && overflow.length === 0)
684
+ ? body
685
+ : `${body}\n[query confidence: ${assessment.level}${assessment.hints.length > 0 ? ` — ${assessment.hints.join("; ")}` : ""}]`;
686
+ // R9: one `refine:` line — the next query candidates the retriever can
687
+ // already see (sibling symbol, just-missed file, an alternate term), so
688
+ // the search tool becomes a guided loop instead of a one-shot.
689
+ const refine = this.buildRefineLine(query, inline, stats.nearMissFiles ?? []);
690
+ const finalBody = refine ? `${bodyWithConfidence}\n${refine}` : bodyWithConfidence;
572
691
  this.recordQuery(rawTokens, servedTokens, fullFileTokens);
573
- return { body, inline, overflow, droppedLowValue: stats.droppedLowValue ?? 0 };
692
+ return {
693
+ body: finalBody,
694
+ inline,
695
+ overflow,
696
+ droppedLowValue: stats.droppedLowValue ?? 0,
697
+ queryAssessment: assessment,
698
+ };
699
+ }
700
+
701
+ /** Chunker fallback node names that are never useful refine hints. */
702
+ private static readonly REFINE_JUNK_NAMES = new Set([
703
+ "function",
704
+ "class",
705
+ "module",
706
+ "const",
707
+ "export",
708
+ "import",
709
+ "interface",
710
+ "return",
711
+ "async",
712
+ ]);
713
+
714
+ /**
715
+ * R9: deterministic refine suggestions from what the retriever already
716
+ * knows: a sibling symbol from the top hit's file (graph defines), a file
717
+ * that ranked 11-15 but was not served, and a term frequent across the
718
+ * top hits but absent from the query. Capped at 3 items and 160 chars
719
+ * (≤40 tokens) per call.
720
+ */
721
+ private buildRefineLine(query: string, hits: Chunk[], nearMissFiles: string[]): string | null {
722
+ if (!this.config.retrievalRefineHints) return null;
723
+ const items: string[] = [];
724
+ const top = hits[0];
725
+ if (top) {
726
+ try {
727
+ const units = new Set(
728
+ hits
729
+ .filter((h) => h.filePath === top.filePath)
730
+ .map((h) => {
731
+ const u = h.metadata["unit"];
732
+ return typeof u === "string" && u.includes(".") ? u.slice(u.lastIndexOf(".") + 1) : typeof u === "string" ? u : "";
733
+ }),
734
+ );
735
+ const sibling = this.backend
736
+ .getNodesByFile(top.filePath)
737
+ .filter((n) => n.nodeType === "function" || n.nodeType === "class")
738
+ .map((n) => n.name)
739
+ .filter((n) => !ContextEngine.REFINE_JUNK_NAMES.has(n) && /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(n))
740
+ .find((n) => !units.has(n));
741
+ if (sibling) items.push(`"${sibling}"`);
742
+ } catch {
743
+ // graph lookup is best-effort
744
+ }
745
+ }
746
+ const servedFiles = new Set(hits.map((h) => h.filePath));
747
+ for (const f of nearMissFiles.slice(0, 1)) {
748
+ if (!servedFiles.has(f)) items.push(`file:${f}`);
749
+ }
750
+ const termFreq = new Map<string, number>();
751
+ for (const chunk of hits.slice(0, 3)) {
752
+ const text = chunk.compressedContent || chunk.content;
753
+ const local = new Set<string>();
754
+ for (const m of text.matchAll(/[A-Za-z_$][A-Za-z0-9_$]{4,}/g)) {
755
+ const w = m[0];
756
+ if (!query.includes(w)) local.add(w);
757
+ }
758
+ for (const w of local) termFreq.set(w, (termFreq.get(w) ?? 0) + 1);
759
+ }
760
+ const alt = [...termFreq.entries()]
761
+ .filter(([w, n]) => n >= 2 && !ContextEngine.REFINE_JUNK_NAMES.has(w))
762
+ .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
763
+ .map(([w]) => `"${w}"`)
764
+ .find((w) => !items.includes(w));
765
+ if (alt) items.push(alt);
766
+ if (items.length === 0) return null;
767
+ const line = `[refine: ${items.slice(0, 3).join(" · ")}]`;
768
+ return line.length <= 160 ? line : null;
574
769
  }
575
770
 
576
771
  /** Memory nudge appended to search results when the agent never records. */
@@ -823,9 +1018,20 @@ export class ContextEngine {
823
1018
  if (!r) continue;
824
1019
  const recency = humaniseRelativeTime(r.created_at_epoch);
825
1020
  let tail = recency ? ` · ${recency}` : "";
1021
+ // R13: surface linked decisions so the agent can walk the graph.
1022
+ const links = memory.getDecisionLinks(r.id);
1023
+ if (links.relatedTo.length > 0) {
1024
+ tail += ` · related: ${links.relatedTo.join(", ")}`;
1025
+ }
1026
+ if (links.relatedFrom.length > 0) {
1027
+ tail += ` · relatedFrom: ${links.relatedFrom.join(", ")}`;
1028
+ }
1029
+ for (const ann of links.annotatedBy.slice(0, 2)) {
1030
+ tail += ` · annotation #${ann.id}${ann.note ? ": " + ann.note.slice(0, 80) : ""}`;
1031
+ }
826
1032
  if (r.session_id) tail += ` · → session_timeline("${r.session_id}")`;
827
1033
  out.push(
828
- `[decision src=${r.source}|sid:${r.session_id ?? "-"}] ${expand(r.decision)} — ${expand(r.reason)}${tail}`,
1034
+ `[decision #${r.id} src=${r.source}|sid:${r.session_id ?? "-"}] ${expand(r.decision)} — ${expand(r.reason)}${tail}`,
829
1035
  );
830
1036
  }
831
1037
  return out;
@@ -986,9 +1192,26 @@ export class ContextEngine {
986
1192
  }
987
1193
 
988
1194
  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");
1195
+ // R7: with real calls edges, a chunk's outgoing calls are genuine
1196
+ // neighbours. Node ids that are not calls sources (e.g. file nodes,
1197
+ // whose DEFINES edges point at their chunks) keep working via the
1198
+ // unfiltered lookup, then file-level imports as the last resort.
1199
+ // Before R7 all of this was dead: imports edges pointed at nodes that
1200
+ // never existed.
1201
+ const render = (ns: { nodeType: string; name: string; filePath: string }[]): string =>
1202
+ ns.map((n) => `- ${n.nodeType}: ${n.name} (${n.filePath})`).join("\n");
1203
+ const callTargets = this.backend.graphNeighbors(chunkId, "calls");
1204
+ if (callTargets.length > 0) return render(callTargets);
1205
+ const any = this.backend.graphNeighbors(chunkId);
1206
+ if (any.length > 0) return render(any);
1207
+ const chunk = this.backend.getChunkById(chunkId);
1208
+ if (chunk) {
1209
+ const related = this.backend.getRelatedFilePaths([chunk.filePath]);
1210
+ if (related.length > 0) {
1211
+ return related.map((fp) => `- file: ${fp}`).join("\n");
1212
+ }
1213
+ }
1214
+ return "No related context found for this chunk.";
992
1215
  }
993
1216
 
994
1217
  /** Cost note like ", $4.37 saved" for the status line. */
@@ -1058,7 +1281,9 @@ export class ContextEngine {
1058
1281
  lines.push(
1059
1282
  chunks > 0
1060
1283
  ? `${chunks.toLocaleString()} chunks indexed and searchable via context_search.`
1061
- : "Index is empty — run /context-index (or call reindex) to build it.",
1284
+ : this.config.indexerAutoIndex
1285
+ ? "Index is empty — a background index starts automatically at session start (indexer.auto_index); retry context_search shortly."
1286
+ : "Index is empty — run /context-index (or call reindex) to build it.",
1062
1287
  );
1063
1288
  return lines.join("\n");
1064
1289
  }
package/src/extension.ts CHANGED
@@ -16,12 +16,61 @@ import { applyContextSearchGuidance } from "./guidance.js";
16
16
  /** Tools whose payloads are worth capturing for session timelines. */
17
17
  const CAPTURED_TOOLS = new Set(["bash", "read", "edit", "write", "grep", "find", "ls"]);
18
18
 
19
+ /**
20
+ * Delay before the session-start auto-index kicks in: long enough that pi's
21
+ * initial render, skill loading and the user's first keystrokes win the event
22
+ * loop, short enough that the index is warm before the agent needs it.
23
+ */
24
+ const AUTO_INDEX_DELAY_MS = 2000;
25
+
19
26
  export default function contextEngineExtension(pi: ExtensionAPI) {
20
27
  let engine: ContextEngine | null = null;
21
28
  let currentCwd = process.cwd();
22
29
  let promptNumber = 0;
23
30
  let lazyIndexStarted = false;
24
31
  let bootstrapSent = false;
32
+ let autoIndexTimer: NodeJS.Timeout | null = null;
33
+ let indexInFlight = false;
34
+
35
+ /**
36
+ * Kick the one-shot background index (session-start auto-index and the
37
+ * first-search lazy path share this). Never stacks: `lazyIndexStarted`
38
+ * guards re-entry, `indexInFlight` lets context_search say "still
39
+ * building" instead of re-kicking, and `engine.autoIndexOnce()` is
40
+ * itself one-shot per engine instance.
41
+ */
42
+ const kickBackgroundIndex = (eng: ContextEngine, notify?: (msg: string, level: "info" | "warning") => void) => {
43
+ lazyIndexStarted = true;
44
+ indexInFlight = true;
45
+ void eng
46
+ .autoIndexOnce()
47
+ .then((result) => {
48
+ if (!result || !notify || engine !== eng) return; // session moved on
49
+ if (result.errors.length > 0) {
50
+ notify(`Context index: background index hit ${result.errors.length} error(s)`, "warning");
51
+ } else if (result.indexed > 0) {
52
+ notify(`Context index updated: ${result.indexed} file(s), ${result.chunks} chunk(s)`, "info");
53
+ }
54
+ })
55
+ .catch((err: unknown) => {
56
+ // Backend init, model download and embedding failures REJECT the
57
+ // reindex (result.errors only covers per-file errors), so without
58
+ // this branch a failed startup index would die silently and leave
59
+ // the lazy path burned on an empty index. Free the lazy path so a
60
+ // later search retries (autoIndexOnce un-consumes its one-shot),
61
+ // and surface the failure when we have a notify channel.
62
+ if (engine === eng) {
63
+ lazyIndexStarted = false;
64
+ if (notify) {
65
+ const detail = (err instanceof Error ? err.message : String(err)).slice(0, 120);
66
+ notify(`Context index: background indexing failed — run /context-index for details (${detail})`, "warning");
67
+ }
68
+ }
69
+ })
70
+ .finally(() => {
71
+ indexInFlight = false;
72
+ });
73
+ };
25
74
 
26
75
  const getEngine = (cwd?: string): ContextEngine => {
27
76
  const dir = cwd ?? currentCwd;
@@ -68,15 +117,29 @@ export default function contextEngineExtension(pi: ExtensionAPI) {
68
117
  if (!query) return text("Query cannot be empty.");
69
118
  if (query.length > 10_000) return text("Query too long (max 10000 characters).");
70
119
 
71
- // Lazy index on first search: kick off in the background and tell the
120
+ // Empty index: a background build may already be running (session-start
121
+ // auto-index or an earlier search). Never stack a second run; tell the
72
122
  // caller to retry instead of blocking for a multi-minute first index.
73
- if (eng.chunkCount() === 0 && !lazyIndexStarted) {
74
- lazyIndexStarted = true;
75
- void eng.reindex({}).catch(() => undefined);
76
- return text(
77
- `Index for ${eng.projectName} is empty — indexing started in the background. ` +
78
- "Retry this search in a few seconds, or run /context-index for a synchronous index.",
79
- );
123
+ // `indexer.auto_index: false` disables this lazy kick too — one switch
124
+ // for all automatic indexing.
125
+ if (eng.chunkCount() === 0) {
126
+ if (indexInFlight) {
127
+ return text(
128
+ `Index for ${eng.projectName} is still building in the background — retry this search in a few seconds.`,
129
+ );
130
+ }
131
+ if (!eng.config.indexerAutoIndex) {
132
+ return text(
133
+ `Index for ${eng.projectName} is empty and indexer.auto_index is off — run /context-index (or call reindex) to build it.`,
134
+ );
135
+ }
136
+ if (!lazyIndexStarted) {
137
+ kickBackgroundIndex(eng);
138
+ return text(
139
+ `Index for ${eng.projectName} is empty — indexing started in the background. ` +
140
+ "Retry this search in a few seconds, or run /context-index for a synchronous index.",
141
+ );
142
+ }
80
143
  }
81
144
 
82
145
  const result = await eng.search(query, { topK: params.top_k, maxTokens: params.max_tokens });
@@ -178,12 +241,23 @@ export default function contextEngineExtension(pi: ExtensionAPI) {
178
241
  parameters: Type.Object({
179
242
  decision: Type.String({ description: "What was decided" }),
180
243
  reason: Type.String({ description: "Why — the constraint or tradeoff" }),
244
+ related_to: Type.Optional(
245
+ Type.Array(Type.Integer(), {
246
+ description: "Ids of earlier decisions this one annotates — ids appear in record_decision replies ([#N]) and session_recall lines ([decision #N …])",
247
+ }),
248
+ ),
249
+ tags: Type.Optional(Type.Array(Type.String(), { description: "Short labels for later filtering" })),
250
+ files: Type.Optional(Type.Array(Type.String(), { description: "Affected file paths (relative)" })),
181
251
  }),
182
252
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
183
253
  const decision = (params.decision ?? "").trim();
184
254
  const reason = (params.reason ?? "").trim();
185
255
  if (!decision) return text("decision is required.");
186
- const stored = await getEngine(ctx.cwd).recordDecision(decision, reason);
256
+ const stored = await getEngine(ctx.cwd).recordDecision(decision, reason, {
257
+ relatedTo: params.related_to,
258
+ tags: params.tags,
259
+ files: params.files,
260
+ });
187
261
  return text(`✓ Decision recorded: ${stored}`);
188
262
  },
189
263
  });
@@ -298,6 +372,39 @@ export default function contextEngineExtension(pi: ExtensionAPI) {
298
372
  engine.startBackgroundWork();
299
373
  lazyIndexStarted = false;
300
374
  bootstrapSent = false;
375
+ if (autoIndexTimer) {
376
+ clearTimeout(autoIndexTimer);
377
+ autoIndexTimer = null;
378
+ }
379
+ // Auto-index (config `indexer.auto_index`): kick an incremental reindex
380
+ // shortly after the session is ready, so the agent never discovers an
381
+ // empty or stale index mid-task. Incremental runs are cheap when nothing
382
+ // changed (walk + hash only); the pipeline lock serializes this with a
383
+ // manual /context-index or a lazy search-triggered run. Errors surface as
384
+ // a notification, never as a session failure.
385
+ if (engine.config.indexerAutoIndex) {
386
+ const eng = engine;
387
+ autoIndexTimer = setTimeout(() => {
388
+ autoIndexTimer = null;
389
+ if (lazyIndexStarted) return; // a search already kicked a background index
390
+ if (engine !== eng) {
391
+ // getEngine switched projects mid-delay (a tool ran with a
392
+ // different cwd): index the LIVE engine instead of the retired
393
+ // one, so the new project still gets its warmup instead of
394
+ // waiting for an unlucky lazy kick.
395
+ if (engine) {
396
+ kickBackgroundIndex(engine, (msg, level) => {
397
+ if (ctx.hasUI) ctx.ui.notify(msg, level);
398
+ });
399
+ }
400
+ return;
401
+ }
402
+ kickBackgroundIndex(eng, (msg, level) => {
403
+ if (ctx.hasUI) ctx.ui.notify(msg, level);
404
+ });
405
+ }, AUTO_INDEX_DELAY_MS);
406
+ autoIndexTimer.unref?.();
407
+ }
301
408
  });
302
409
 
303
410
  pi.on("before_agent_start", async (event, ctx) => {
@@ -344,6 +451,10 @@ export default function contextEngineExtension(pi: ExtensionAPI) {
344
451
  });
345
452
 
346
453
  pi.on("session_shutdown", async () => {
454
+ if (autoIndexTimer) {
455
+ clearTimeout(autoIndexTimer);
456
+ autoIndexTimer = null;
457
+ }
347
458
  await engine?.shutdown().catch(() => undefined);
348
459
  engine = null;
349
460
  });
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