pi-context-engine 0.1.4 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +63 -0
- package/docs/IMPLEMENTATION-PLAN.md +30 -13
- package/package.json +83 -81
- package/src/compression/compressor.ts +119 -20
- package/src/config.ts +63 -3
- package/src/engine.ts +238 -13
- package/src/extension.ts +120 -9
- package/src/guidance.ts +1 -0
- package/src/indexer/chunker.ts +321 -21
- package/src/indexer/context-header.ts +86 -0
- package/src/indexer/embedder.ts +32 -5
- package/src/indexer/manifest.ts +41 -19
- package/src/indexer/pipeline.ts +435 -197
- package/src/memory/db.ts +166 -53
- package/src/retrieval/confidence.ts +53 -0
- package/src/retrieval/query-parser.ts +33 -0
- package/src/retrieval/reranker.ts +112 -0
- package/src/retrieval/retriever.ts +351 -14
- package/src/storage/fts-store.ts +48 -6
- package/src/storage/graph-store.ts +160 -4
- package/src/storage/local-backend.ts +36 -4
- package/src/storage/vector-store.ts +35 -29
- package/src/utils.ts +13 -0
package/src/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(
|
|
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(
|
|
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,
|
|
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: {
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
990
|
-
|
|
991
|
-
|
|
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
|
-
:
|
|
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
|
-
//
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
|