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/package.json CHANGED
@@ -1,81 +1,85 @@
1
1
  {
2
- "name": "pi-context-engine",
3
- "version": "0.1.3",
4
- "description": "Semantic code context for pi: tree-sitter index + hybrid vector/BM25 retrieval + cross-session memory, all local. Port of code-context-engine (MIT, elara-labs) as a native pi extension.",
5
- "keywords": [
6
- "pi-package",
7
- "pi-extension",
8
- "context-engine",
9
- "code-search",
10
- "semantic-search",
11
- "vector-search",
12
- "tree-sitter",
13
- "token-savings",
14
- "memory"
15
- ],
16
- "license": "MIT",
17
- "type": "module",
18
- "main": "src/extension.ts",
19
- "files": [
20
- "src",
21
- "docs",
22
- "README.md",
23
- "LICENSE"
24
- ],
25
- "scripts": {
26
- "test": "vitest run",
27
- "test:watch": "vitest watch",
28
- "typecheck": "tsc --noEmit",
29
- "check": "npm run typecheck && npm run test",
30
- "smoke:env": "node scripts/smoke-env.mjs",
31
- "smoke:loader": "node scripts/smoke-loader.mjs",
32
- "smoke:real-world": "tsx scripts/smoke-real-world.mjs",
33
- "prepublishOnly": "node -e \"if(!process.env.CI)throw new Error('publish via release.yml CI: push a v* tag. Manual npm publish is blocked to guarantee provenance + version sync.')\""
34
- },
35
- "pi": {
36
- "extensions": [
37
- "./src/extension.ts"
38
- ]
39
- },
40
- "engines": {
41
- "node": ">=22.0.0"
42
- },
43
- "allowScripts": {
44
- "better-sqlite3": true
45
- },
46
- "dependencies": {
47
- "better-sqlite3": "^13.0.3",
48
- "chokidar": "^5.0.0",
49
- "sqlite-vec": "^0.1.9",
50
- "tree-sitter-wasms": "^0.1.13",
51
- "web-tree-sitter": "^0.22.6",
52
- "yaml": "^2.9.1"
53
- },
54
- "peerDependencies": {
55
- "@earendil-works/pi-ai": "*",
56
- "@earendil-works/pi-coding-agent": "*",
57
- "typebox": "*"
58
- },
59
- "devDependencies": {
60
- "@earendil-works/pi-ai": ">=0.80.0",
61
- "@earendil-works/pi-coding-agent": ">=0.99.2",
62
- "@types/node": "^22.0.0",
63
- "@huggingface/transformers": "^4.3.1",
64
- "typebox": "^1.0.0",
65
- "typescript": "^5.9.0",
66
- "vitest": "^3.1.0"
67
- },
68
- "publishConfig": {
69
- "access": "public",
70
- "provenance": true
71
- },
72
- "author": "alessandro@pungitore.ch",
73
- "repository": {
74
- "type": "git",
75
- "url": "git+https://github.com/pungggi/pi-context-engine.git"
76
- },
77
- "bugs": {
78
- "url": "https://github.com/pungggi/pi-context-engine/issues"
79
- },
80
- "homepage": "https://github.com/pungggi/pi-context-engine#readme"
2
+ "name": "pi-context-engine",
3
+ "version": "0.2.0",
4
+ "description": "Semantic code context for pi: tree-sitter index + hybrid vector/BM25 retrieval + cross-session memory, all local. Port of code-context-engine (MIT, elara-labs) as a native pi extension.",
5
+ "keywords": [
6
+ "pi-package",
7
+ "pi-extension",
8
+ "context-engine",
9
+ "code-search",
10
+ "semantic-search",
11
+ "vector-search",
12
+ "tree-sitter",
13
+ "token-savings",
14
+ "memory"
15
+ ],
16
+ "license": "MIT",
17
+ "type": "module",
18
+ "main": "src/extension.ts",
19
+ "files": [
20
+ "src",
21
+ "docs",
22
+ "README.md",
23
+ "LICENSE"
24
+ ],
25
+ "scripts": {
26
+ "test": "vitest run",
27
+ "test:watch": "vitest watch",
28
+ "typecheck": "tsc --noEmit",
29
+ "check": "npm run typecheck && npm run test",
30
+ "smoke:env": "node scripts/smoke-env.mjs",
31
+ "smoke:loader": "node scripts/smoke-loader.mjs",
32
+ "smoke:real-world": "tsx scripts/smoke-real-world.mjs",
33
+ "eval": "tsx scripts/eval-retrieval.mts",
34
+ "prepublishOnly": "node -e \"if(!process.env.CI)throw new Error('publish via release.yml CI: push a v* tag. Manual npm publish is blocked to guarantee provenance + version sync.')\""
35
+ },
36
+ "pi": {
37
+ "extensions": [
38
+ "./src/extension.ts"
39
+ ]
40
+ },
41
+ "engines": {
42
+ "node": ">=22.0.0"
43
+ },
44
+ "allowScripts": {
45
+ "better-sqlite3": true
46
+ },
47
+ "dependencies": {
48
+ "better-sqlite3": "^13.0.3",
49
+ "chokidar": "^5.0.0",
50
+ "sqlite-vec": "^0.1.9",
51
+ "tree-sitter-wasms": "^0.1.13",
52
+ "web-tree-sitter": "^0.22.6",
53
+ "yaml": "^2.9.1"
54
+ },
55
+ "optionalDependencies": {
56
+ "@huggingface/transformers": "^4.3.1"
57
+ },
58
+ "peerDependencies": {
59
+ "@earendil-works/pi-ai": "*",
60
+ "@earendil-works/pi-coding-agent": "*",
61
+ "typebox": "*"
62
+ },
63
+ "devDependencies": {
64
+ "@earendil-works/pi-ai": ">=0.80.0",
65
+ "@earendil-works/pi-coding-agent": ">=0.99.2",
66
+ "@types/node": "^22.0.0",
67
+ "typebox": "^1.0.0",
68
+ "tsx": "^4.23.15",
69
+ "typescript": "^5.9.0",
70
+ "vitest": "^3.1.0"
71
+ },
72
+ "publishConfig": {
73
+ "access": "public",
74
+ "provenance": true
75
+ },
76
+ "author": "alessandro@pungitore.ch",
77
+ "repository": {
78
+ "type": "git",
79
+ "url": "git+https://github.com/pungggi/pi-context-engine.git"
80
+ },
81
+ "bugs": {
82
+ "url": "https://github.com/pungggi/pi-context-engine/issues"
83
+ },
84
+ "homepage": "https://github.com/pungggi/pi-context-engine#readme"
81
85
  }
@@ -25,7 +25,16 @@ const PROMPT_MAP: Partial<Record<ChunkType, string>> = {
25
25
  };
26
26
 
27
27
  const TRUNCATION_LIMITS: Record<string, number> = { minimal: 100, standard: 300, full: 800 };
28
- const FULL_PASSTHROUGH_THRESHOLD = 0.8;
28
+ // Query-aware selection: keep matched lines within the level budget, never more.
29
+ const MAX_QUERY_LINES = 40;
30
+ /** Words that must not win the line budget just because they recur. */
31
+ const QUERY_NOISE_TERMS = new Set([
32
+ "the", "and", "for", "with", "that", "this", "from", "are", "was", "not",
33
+ "you", "all", "can", "has", "its", "but", "how", "what", "when", "where",
34
+ "which", "who", "why", "does", "did", "should", "would", "into", "than",
35
+ "then", "them", "they", "use", "used", "using", "show", "find", "call",
36
+ "calls", "make", "made", "need", "needs", "get", "set", "returns", "return",
37
+ ]);
29
38
  // TTL for the Ollama liveness probe so compress() calls don't all pay it.
30
39
  const OLLAMA_PROBE_TTL_MS = 30_000;
31
40
 
@@ -153,35 +162,58 @@ export class Compressor {
153
162
  return this.probeInFlight;
154
163
  }
155
164
 
156
- async compress(chunks: Chunk[], level = "standard"): Promise<Chunk[]> {
165
+ /**
166
+ * Compress chunks for serving.
167
+ *
168
+ * `query` enables R3 query-aware selection: when a truncation would cut
169
+ * lines (minimal/standard without the LLM path), keep the lines that
170
+ * mention the query's terms within the same character budget instead of
171
+ * the first N lines. That stops the compressor from deleting the only
172
+ * line that answers the question, which fixed head-cuts did routinely.
173
+ *
174
+ * `queryAware=false` preserves the old head-truncation behaviour. `full`
175
+ * never truncates: the retrieval token budget decides (plan R3), so
176
+ * there is nothing for the query to protect at that level.
177
+ */
178
+ async compress(chunks: Chunk[], level = "standard", query?: string, queryAware = true): Promise<Chunk[]> {
157
179
  const ollamaAvailable = await this.isOllamaAvailable();
180
+ const queryText = (query ?? "").trim();
181
+ // Query selection only applies where truncation would otherwise cut.
182
+ // An LLM summary is query-independent (and therefore cacheable), so it
183
+ // keeps precedence when Ollama is up at standard/full.
184
+ const querySelection =
185
+ queryAware && queryText.length > 0 && level !== "full" && !(ollamaAvailable && level !== "minimal");
158
186
  for (const chunk of chunks) {
159
- if (level === "full" && this.isFullPassthrough(chunk)) {
187
+ if (level === "full") {
188
+ // Budget-driven: the engine's maxTokens packing trims, not us.
160
189
  chunk.compressedContent = chunk.content;
161
190
  continue;
162
191
  }
163
- const cached = this.cache?.getCachedCompression(chunk.id, level);
164
- if (cached !== null && cached !== undefined) {
165
- chunk.compressedContent = cached;
166
- continue;
192
+ if (!querySelection) {
193
+ const cached = this.cache?.getCachedCompression(chunk.id, level);
194
+ if (cached !== null && cached !== undefined) {
195
+ chunk.compressedContent = cached;
196
+ continue;
197
+ }
167
198
  }
168
- if (ollamaAvailable && level !== "minimal") {
169
- chunk.compressedContent = await this.llmCompress(chunk, level);
199
+ let compressed: string;
200
+ if (querySelection) {
201
+ // Never read or write the cache here: its key is (chunk, level)
202
+ // and would leak a stale selection from another query.
203
+ compressed = this.queryAwareFallback(chunk, level, queryText);
204
+ } else if (ollamaAvailable && level !== "minimal") {
205
+ compressed = await this.llmCompress(chunk, level);
170
206
  } else {
171
- chunk.compressedContent = this.fallbackCompress(chunk, level);
207
+ compressed = this.fallbackCompress(chunk, level);
172
208
  }
173
- if (this.cache && chunk.compressedContent) {
174
- this.cache.putCachedCompression(chunk.id, level, chunk.compressedContent);
209
+ chunk.compressedContent = compressed;
210
+ if (this.cache && compressed && !querySelection) {
211
+ this.cache.putCachedCompression(chunk.id, level, compressed);
175
212
  }
176
213
  }
177
214
  return chunks;
178
215
  }
179
216
 
180
- private isFullPassthrough(chunk: Chunk): boolean {
181
- if (chunk.confidenceScore > FULL_PASSTHROUGH_THRESHOLD) return true;
182
- return chunk.embedding === null;
183
- }
184
-
185
217
  private async llmCompress(chunk: Chunk, level: string): Promise<string> {
186
218
  const prompt = PROMPT_MAP[chunk.chunkType] ?? CODE_PROMPT;
187
219
  try {
@@ -195,11 +227,57 @@ export class Compressor {
195
227
 
196
228
  private fallbackCompress(chunk: Chunk, level: string): string {
197
229
  const limit = TRUNCATION_LIMITS[level] ?? 300;
230
+ // The R1 header is retrieval context, not serving context: the result
231
+ // formatter already prints `[path:start]`, so paying for it again inside
232
+ // a 100-char signature would waste most of the budget.
233
+ const content = stripContextHeader(chunk.content);
198
234
  if (chunk.chunkType === "function" || chunk.chunkType === "class") {
199
- return this.extractSignature(chunk.content, limit);
235
+ return this.extractSignature(content, limit);
236
+ }
237
+ if (content.length <= limit) return content;
238
+ return content.slice(0, limit) + "...";
239
+ }
240
+
241
+ /**
242
+ * R3: keep the lines that answer the query instead of the first N lines.
243
+ *
244
+ * The level's character cap still bounds the output, so serving size never
245
+ * grows: matched lines simply win the budget over leading boilerplate, then
246
+ * the selection is restored to source order for readability. When nothing
247
+ * matches (a vague query), plain truncation is returned — a weak query must
248
+ * not blank the result.
249
+ */
250
+ private queryAwareFallback(chunk: Chunk, level: string, query: string): string {
251
+ const limit = TRUNCATION_LIMITS[level] ?? 300;
252
+ const terms = queryTerms(query);
253
+ const body = stripContextHeader(chunk.content);
254
+ if (terms.length === 0) return this.fallbackCompress(chunk, level);
255
+
256
+ const lines = body.split("\n");
257
+ const matches: { line: string; idx: number; hits: number }[] = [];
258
+ for (let i = 0; i < lines.length; i++) {
259
+ const line = lines[i]!;
260
+ if (line.trim().length === 0) continue;
261
+ const lower = line.toLowerCase();
262
+ let hits = 0;
263
+ for (const t of terms) if (lower.includes(t)) hits++;
264
+ if (hits > 0) matches.push({ line, idx: i, hits });
265
+ }
266
+ if (matches.length === 0) return this.fallbackCompress(chunk, level);
267
+
268
+ matches.sort((a, b) => b.hits - a.hits || a.idx - b.idx);
269
+ let budget = limit;
270
+ const selected: typeof matches = [];
271
+ let used = 0;
272
+ for (const m of matches) {
273
+ if (used + m.line.length + 1 > budget) continue;
274
+ selected.push(m);
275
+ used += m.line.length + 1;
276
+ if (selected.length >= MAX_QUERY_LINES) break;
200
277
  }
201
- if (chunk.content.length <= limit) return chunk.content;
202
- return chunk.content.slice(0, limit) + "...";
278
+ if (selected.length === 0) return this.fallbackCompress(chunk, level);
279
+ selected.sort((a, b) => a.idx - b.idx);
280
+ return selected.map((m) => m.line).join("\n");
203
281
  }
204
282
 
205
283
  private extractSignature(content: string, limit: number): string {
@@ -220,3 +298,24 @@ export class Compressor {
220
298
  return resultLines.join("\n");
221
299
  }
222
300
  }
301
+
302
+ /** Drop the R1 `// file: …` header line (serving shows `[path:start]` already). */
303
+ function stripContextHeader(content: string): string {
304
+ if (!content.startsWith("// file: ")) return content;
305
+ const nl = content.indexOf("\n");
306
+ return nl < 0 ? content : content.slice(nl + 1);
307
+ }
308
+
309
+ /** Distinct query terms (>=3 chars, non-boilerplate) for line matching. */
310
+ function queryTerms(query: string): string[] {
311
+ const tokens = query.toLowerCase().match(/[a-z0-9_]+/g) ?? [];
312
+ const seen = new Set<string>();
313
+ const out: string[] = [];
314
+ for (const t of tokens) {
315
+ if (t.length < 3 || QUERY_NOISE_TERMS.has(t) || seen.has(t)) continue;
316
+ seen.add(t);
317
+ out.push(t);
318
+ if (out.length >= 40) break;
319
+ }
320
+ return out;
321
+ }
package/src/config.ts CHANGED
@@ -19,7 +19,7 @@ export const DEFAULT_IGNORE: string[] = [
19
19
  // Dependencies (JS, PHP, Python, Ruby, Go, Rust, Java, .NET)
20
20
  "node_modules", "vendor", "bower_components",
21
21
  ".pnpm-store", ".pnpm", ".yarn",
22
- ".venv", "venv", "env", ".env",
22
+ ".venv", "venv", ".env",
23
23
  ".tox", ".nox", ".mypy_cache", ".pytest_cache",
24
24
  ".ruff_cache", ".cache",
25
25
  "Pods",
@@ -41,13 +41,23 @@ export const DEFAULT_IGNORE: string[] = [
41
41
  // Package locks (huge, not useful)
42
42
  "package-lock.json", "yarn.lock", "pnpm-lock.yaml",
43
43
  "composer.lock", "poetry.lock",
44
- // Storage / logs
45
- "storage", "logs", "tmp", "temp",
46
44
  ];
47
45
 
46
+ /**
47
+ * Directory names that are ambiguous: equally plausible as junk at the project
48
+ * root (`./storage`, `./logs`, `./tmp`) as they are as real source trees
49
+ * nested below it (`src/storage/`, `app/logs/`). Matching them at any depth
50
+ * silently hid whole source trees from the index — including this package's
51
+ * own `src/storage/*.ts`. They are therefore ignored ONLY at the project root;
52
+ * nested dirs with these names are indexed.
53
+ */
54
+ export const ROOT_ONLY_IGNORE: string[] = ["storage", "logs", "tmp", "temp", "env"];
55
+
48
56
  export interface Config {
49
57
  compressionLevel: "minimal" | "standard" | "full";
50
58
  compressionModel: string;
59
+ /** R3: keep query-matching lines when truncating instead of the head. */
60
+ compressionQueryAware: boolean;
51
61
  ollamaUrl: string;
52
62
  embeddingModel: string;
53
63
  ollamaEmbedModel: string;
@@ -60,7 +70,29 @@ export interface Config {
60
70
  retrievalConfidenceThreshold: number;
61
71
  retrievalTopK: number;
62
72
  retrievalMarginalRatio: number;
73
+ /** R6: attach a hit's module parent + adjacent chunk (small-to-big serving). */
74
+ retrievalExpandContext: boolean;
75
+ /** R8: composite quality score at/above which a query reports `high`. */
76
+ retrievalConfidenceHigh: number;
77
+ /** R8: composite quality score below which a query reports `low` + hints. */
78
+ retrievalConfidenceLow: number;
79
+ /** R8: deterministic query elaboration fused as an extra candidate source. */
80
+ retrievalAutoElaborate: boolean;
81
+ /** R9: append a `[refine: ...]` hint line to search results. */
82
+ retrievalRefineHints: boolean;
83
+ /** R10: opt-in reranking of the fused list. `off` (default) | `local`. */
84
+ retrievalRerank: "off" | "local";
85
+ /** R10: HF model id for the local cross-encoder reranker. */
86
+ retrievalRerankModel: string;
87
+ /** R10: how many fused candidates the reranker may reorder. */
88
+ retrievalRerankTopn: number;
89
+ /** R12: chunking strategy — `legacy` function/class walk (default) or `ast-density` split-merge. */
90
+ indexerChunking: "legacy" | "ast-density";
91
+ /** R12: non-whitespace char budget per ast-density chunk. */
92
+ indexerChunkBudgetChars: number;
63
93
  indexerIgnore: string[];
94
+ /** Prepend the R1 context header (path/lang/unit/imports) to chunk content. */
95
+ indexerContextHeaders: boolean;
64
96
  indexerRedactSecrets: boolean;
65
97
  memoryRedactPii: boolean;
66
98
  pricingModel: string;
@@ -73,6 +105,7 @@ export function defaultConfig(): Config {
73
105
  return {
74
106
  compressionLevel: "standard",
75
107
  compressionModel: "phi3:mini",
108
+ compressionQueryAware: true,
76
109
  ollamaUrl: "http://localhost:11434",
77
110
  embeddingModel: "local",
78
111
  ollamaEmbedModel: "nomic-embed-text",
@@ -80,7 +113,18 @@ export function defaultConfig(): Config {
80
113
  retrievalConfidenceThreshold: 0.2,
81
114
  retrievalTopK: 20,
82
115
  retrievalMarginalRatio: 0.75,
116
+ retrievalExpandContext: true,
117
+ retrievalConfidenceHigh: 0.55,
118
+ retrievalConfidenceLow: 0.45,
119
+ retrievalAutoElaborate: false,
120
+ retrievalRefineHints: true,
121
+ retrievalRerank: "off",
122
+ retrievalRerankModel: "Xenova/ms-marco-MiniLM-L-6-v2",
123
+ retrievalRerankTopn: 30,
124
+ indexerChunking: "legacy",
125
+ indexerChunkBudgetChars: 1200,
83
126
  indexerIgnore: [...DEFAULT_IGNORE],
127
+ indexerContextHeaders: true,
84
128
  indexerRedactSecrets: true,
85
129
  memoryRedactPii: true,
86
130
  pricingModel: "opus",
@@ -94,6 +138,7 @@ export function defaultConfig(): Config {
94
138
  const MAPPING: Record<string, keyof Config> = {
95
139
  "compression.level": "compressionLevel",
96
140
  "compression.model": "compressionModel",
141
+ "compression.query_aware": "compressionQueryAware",
97
142
  "compression.ollama_url": "ollamaUrl",
98
143
  "guidance.enabled": "guidanceEnabled",
99
144
  "embedding.model": "embeddingModel",
@@ -101,7 +146,18 @@ const MAPPING: Record<string, keyof Config> = {
101
146
  "retrieval.confidence_threshold": "retrievalConfidenceThreshold",
102
147
  "retrieval.top_k": "retrievalTopK",
103
148
  "retrieval.marginal_ratio": "retrievalMarginalRatio",
149
+ "retrieval.expand_context": "retrievalExpandContext",
150
+ "retrieval.confidence_high": "retrievalConfidenceHigh",
151
+ "retrieval.confidence_low": "retrievalConfidenceLow",
152
+ "retrieval.auto_elaborate": "retrievalAutoElaborate",
153
+ "retrieval.refine_hints": "retrievalRefineHints",
154
+ "retrieval.rerank": "retrievalRerank",
155
+ "retrieval.rerank_model": "retrievalRerankModel",
156
+ "retrieval.rerank_topn": "retrievalRerankTopn",
157
+ "indexer.chunking": "indexerChunking",
158
+ "indexer.chunk_budget_chars": "indexerChunkBudgetChars",
104
159
  "indexer.ignore": "indexerIgnore",
160
+ "indexer.context_headers": "indexerContextHeaders",
105
161
  "indexer.redact_secrets": "indexerRedactSecrets",
106
162
  "memory.redact_pii": "memoryRedactPii",
107
163
  "storage.path": "storagePath",