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/README.md
CHANGED
|
@@ -97,6 +97,30 @@ unchanged repo took 237 ms.
|
|
|
97
97
|
|
|
98
98
|
No Python. No build tools. No API key.
|
|
99
99
|
|
|
100
|
+
### Embedding model choice (measured, not claimed)
|
|
101
|
+
|
|
102
|
+
Evaluated on this package's own 76-query suite (`npm run eval -- --local`, same
|
|
103
|
+
code state, CPU only):
|
|
104
|
+
|
|
105
|
+
| local model | R@5 | R@10 | nDCG@10 | distraction@10 | served tokens | p50/p95 |
|
|
106
|
+
|---|---|---|---|---|---|---|
|
|
107
|
+
| `Xenova/all-MiniLM-L6-v2` (default, ~25 MB) | 0.752 | 0.827 | 0.744 | 0.713 | 702 | 28/33 ms |
|
|
108
|
+
| `michaelfeil/jina-embeddings-v2-base-code` (recommended for code) | **0.798** | **0.899** | **0.813** | **0.640** | 654 | 55/61 ms |
|
|
109
|
+
|
|
110
|
+
Why the split default: the code-trained model wins every quality metric on
|
|
111
|
+
this codebase (natural-language queries most of all: R@10 0.433 → 0.700) at
|
|
112
|
+
~2× CPU latency and a larger first download. Keep MiniLM as the default for
|
|
113
|
+
fast general use; point code-heavy projects at the jina model:
|
|
114
|
+
|
|
115
|
+
```yaml
|
|
116
|
+
embedding:
|
|
117
|
+
model: michaelfeil/jina-embeddings-v2-base-code
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Long inputs are pre-truncated to ~2000 chars per embedding so long-context
|
|
121
|
+
models cannot OOM the CPU runtime, and changing the model (dimension change)
|
|
122
|
+
forces a full reindex automatically.
|
|
123
|
+
|
|
100
124
|
## Configuration
|
|
101
125
|
|
|
102
126
|
Zero config by default. Override in `~/.pi-context-engine/config.yaml` or a
|
|
@@ -107,16 +131,28 @@ retrieval:
|
|
|
107
131
|
top_k: 20
|
|
108
132
|
confidence_threshold: 0.2
|
|
109
133
|
marginal_ratio: 0.75 # stop adding chunks below this fraction of the top score
|
|
134
|
+
expand_context: true # serve a hit's module parent + adjacent chunk (small-to-big)
|
|
135
|
+
confidence_high: 0.55 # query-quality composite at/above which a search reports `high`
|
|
136
|
+
confidence_low: 0.45 # below this the result gets a `[query confidence: low ...]` line with hints
|
|
137
|
+
auto_elaborate: false # opt-in: fuse a deterministic query variant as an extra candidate source
|
|
138
|
+
refine_hints: true # append a `[refine: ...]` line with next-query candidates
|
|
139
|
+
rerank: off # off | local — opt-in cross-encoder reranking of the top 30 fused hits
|
|
140
|
+
rerank_model: Xenova/ms-marco-MiniLM-L-6-v2
|
|
141
|
+
rerank_topn: 30
|
|
110
142
|
guidance:
|
|
111
143
|
enabled: true # inject context_search guidance into the system prompt
|
|
112
144
|
compression:
|
|
113
145
|
level: standard # minimal | standard | full
|
|
114
146
|
ollama_url: http://localhost:11434
|
|
147
|
+
query_aware: true # keep query-relevant lines when truncating
|
|
115
148
|
embedding:
|
|
116
149
|
model: local # or a specific transformers.js model id
|
|
117
150
|
ollama_model: nomic-embed-text
|
|
118
151
|
indexer:
|
|
119
152
|
redact_secrets: true
|
|
153
|
+
context_headers: true # prepend `// file: ... | unit: ... | imports: ...` to chunk text
|
|
154
|
+
chunking: legacy # legacy (default) | ast-density — cAST-style budgeted split-merge (experimental)
|
|
155
|
+
chunk_budget_chars: 1200 # non-whitespace budget per ast-density chunk
|
|
120
156
|
memory:
|
|
121
157
|
redact_pii: true
|
|
122
158
|
pricing:
|
|
@@ -130,6 +166,19 @@ Environment overrides:
|
|
|
130
166
|
- **`PCE_EMBED_BACKEND`**: force `local` or `ollama` instead of auto-detect.
|
|
131
167
|
- **`PCE_RECALL_DISPLAY_CAP`**: how many recall hits to show (default 7).
|
|
132
168
|
|
|
169
|
+
**Reranking caveat**: `retrieval.rerank: local` is opt-in because it downloads
|
|
170
|
+
an ~80 MB cross-encoder model on first use (into the same models dir as the
|
|
171
|
+
embedder) and adds CPU latency per query. It reorders only the top 30 fused
|
|
172
|
+
candidates; if the model cannot load, retrieval falls back to the fused order
|
|
173
|
+
instead of failing.
|
|
174
|
+
|
|
175
|
+
**Chunking caveat**: `indexer.chunking: ast-density` (cAST-style budgeted
|
|
176
|
+
split-merge) is experimental and measured WORSE than the legacy walk on this
|
|
177
|
+
package's eval (R@10 0.818 vs 0.936 with the deterministic embedder): tiling
|
|
178
|
+
trivia into chunks dilutes fixed-size result sets. It stays behind a flag for
|
|
179
|
+
A/B on other workloads; chunks concatenate byte-for-byte back to the source,
|
|
180
|
+
so it is safe to try on a branch.
|
|
181
|
+
|
|
133
182
|
**Embedding backends**: two options, picked automatically per project.
|
|
134
183
|
|
|
135
184
|
- **Ollama** (default when reachable): no extra model files on disk beyond the
|
|
@@ -33,24 +33,41 @@ before the changes lets us prove each improvement instead of guessing.
|
|
|
33
33
|
|
|
34
34
|
| ID | Title | Phase | Priority | Effort | Status |
|
|
35
35
|
|----|-------|-------|----------|--------|--------|
|
|
36
|
-
| R4 | Labeled retrieval eval harness | 0 | P0 | M |
|
|
37
|
-
| R1 | Contextual chunk headers | 1 | P0 | M |
|
|
38
|
-
| R2 | BM25 phrases, column weights, symbol matches | 1 | P0 | S |
|
|
39
|
-
| R3 | Query-aware compression | 1 | P0 | M |
|
|
40
|
-
| R5 | Multi-path retrieval with symbol path | 2 | P1 | M |
|
|
41
|
-
| R6 | Small-to-big context expansion | 2 | P1 | L |
|
|
42
|
-
| R7 | Graph context packs + centrality prior | 2 | P1 | M |
|
|
43
|
-
| R8 | Query-confidence signal + optional elaboration | 2 | P1 | M |
|
|
44
|
-
| R9 | Refine suggestions in search results | 2 | P1 | S |
|
|
45
|
-
| R10 | Opt-in local reranker | 3 | P2 | M |
|
|
46
|
-
| R11 | Code-specialized embedder option | 3 | P2 | S |
|
|
47
|
-
| R12 | AST density chunking (split-merge) | 3 | P2 | L |
|
|
48
|
-
| R13 | Linked memory (A-MEM style) | 3 | P2 | M |
|
|
36
|
+
| R4 | Labeled retrieval eval harness | 0 | P0 | M | Done (2026-10-18) |
|
|
37
|
+
| R1 | Contextual chunk headers | 1 | P0 | M | Done (2026-10-18) |
|
|
38
|
+
| R2 | BM25 phrases, column weights, symbol matches | 1 | P0 | S | Done (2026-10-18) |
|
|
39
|
+
| R3 | Query-aware compression | 1 | P0 | M | Done (2026-10-18) |
|
|
40
|
+
| R5 | Multi-path retrieval with symbol path | 2 | P1 | M | Done (2026-10-18) |
|
|
41
|
+
| R6 | Small-to-big context expansion | 2 | P1 | L | Done (2026-10-18) |
|
|
42
|
+
| R7 | Graph context packs + centrality prior | 2 | P1 | M | Done (2026-10-18) |
|
|
43
|
+
| R8 | Query-confidence signal + optional elaboration | 2 | P1 | M | Done (2026-10-18) |
|
|
44
|
+
| R9 | Refine suggestions in search results | 2 | P1 | S | Done (2026-10-18) |
|
|
45
|
+
| R10 | Opt-in local reranker | 3 | P2 | M | Done (2026-10-18, default off) |
|
|
46
|
+
| R11 | Code-specialized embedder option | 3 | P2 | S | Done (2026-10-18) |
|
|
47
|
+
| R12 | AST density chunking (split-merge) | 3 | P2 | L | Done (2026-10-18, flag only — lost A/B) |
|
|
48
|
+
| R13 | Linked memory (A-MEM style) | 3 | P2 | M | Done (2026-10-18) |
|
|
49
49
|
|
|
50
50
|
**Blocked by nothing right now:** only R4 (phase 0). Everything else waits on
|
|
51
51
|
R4's baseline so its impact can be measured.
|
|
52
52
|
|
|
53
53
|
**Status history**
|
|
54
|
+
- 2026-10-18: all items R1-R13 implemented and measured. Eval (fake
|
|
55
|
+
embedder, 76 queries): R@10 .910-.936, nDCG .813-.825, distraction
|
|
56
|
+
~.695-.699 across the run series (gate green at every step; final
|
|
57
|
+
2026-10-18 run: 214 tests, 24 files). Notable outcomes:
|
|
58
|
+
- R10 reranker shipped default-off; real-model eval not run (model
|
|
59
|
+
repo gated/offline for jina, layout broken for the unixcoder ONNX
|
|
60
|
+
port); pipeline ordering covered by stub tests.
|
|
61
|
+
- R11 measured: jina-code beats MiniLM on every quality metric
|
|
62
|
+
(R@10 .899 vs .827, nDCG .813 vs .744, nl R@10 .700 vs .433) at
|
|
63
|
+
~2x CPU latency; MiniLM stays the default, README carries the table.
|
|
64
|
+
- R12 ast-density measured and REJECTED as default: R@10 .818 vs
|
|
65
|
+
.936 legacy (trivia tiling dilutes fixed-size result sets). Ships
|
|
66
|
+
behind `indexer.chunking: ast-density` with byte-exact concat
|
|
67
|
+
property and tests; default stays `legacy`.
|
|
68
|
+
- R13 linked memory: schema v4 (decision_links + tags/files columns),
|
|
69
|
+
auto-link (top-3, L2 <= 0.85, inbound cap 8), explicit `related_to`
|
|
70
|
+
annotations, links surfaced in session_recall.
|
|
54
71
|
- 2026-10-17: plan created from the research note. Implementation delayed by
|
|
55
72
|
owner decision. All items set to **Not started**.
|
|
56
73
|
|
package/package.json
CHANGED
|
@@ -1,83 +1,85 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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"
|
|
83
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
|
-
|
|
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
|
-
|
|
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"
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
|
|
169
|
-
|
|
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
|
-
|
|
207
|
+
compressed = this.fallbackCompress(chunk, level);
|
|
172
208
|
}
|
|
173
|
-
|
|
174
|
-
|
|
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(
|
|
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 (
|
|
202
|
-
|
|
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", "
|
|
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",
|