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.
- package/README.md +49 -0
- package/docs/IMPLEMENTATION-PLAN.md +30 -13
- package/package.json +83 -81
- package/src/compression/compressor.ts +119 -20
- package/src/config.ts +59 -3
- package/src/engine.ts +206 -12
- package/src/extension.ts +12 -1
- package/src/guidance.ts +1 -0
- package/src/indexer/chunker.ts +321 -21
- package/src/indexer/context-header.ts +86 -0
- package/src/indexer/embedder.ts +16 -1
- package/src/indexer/manifest.ts +8 -4
- package/src/indexer/pipeline.ts +223 -18
- package/src/memory/db.ts +128 -1
- package/src/retrieval/confidence.ts +53 -0
- package/src/retrieval/query-parser.ts +33 -0
- package/src/retrieval/reranker.ts +112 -0
- package/src/retrieval/retriever.ts +351 -14
- package/src/storage/fts-store.ts +48 -6
- package/src/storage/graph-store.ts +160 -4
- package/src/storage/local-backend.ts +36 -0
- package/src/storage/vector-store.ts +22 -0
package/src/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(
|
|
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(
|
|
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,
|
|
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: {
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
990
|
-
|
|
991
|
-
|
|
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
|
|