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/README.md
CHANGED
|
@@ -88,6 +88,19 @@ and the index lifecycle.
|
|
|
88
88
|
Re-indexing only touches changed files. In the run above, a second pass over an
|
|
89
89
|
unchanged repo took 237 ms.
|
|
90
90
|
|
|
91
|
+
### Automatic indexing
|
|
92
|
+
|
|
93
|
+
- **Auto-index at session start**: ~2 s after a session opens, an incremental
|
|
94
|
+
reindex runs in the background, so the index is warm before the agent needs
|
|
95
|
+
it. You get a notification only when something changed.
|
|
96
|
+
- **Lazy fallback**: if a search still lands on an empty index, indexing kicks
|
|
97
|
+
off on the spot and the tool asks you to retry in a few seconds.
|
|
98
|
+
- **One switch for both**: `indexer.auto_index: false` disables both behaviors;
|
|
99
|
+
build the index manually with `/context-index` instead.
|
|
100
|
+
- **Why it matters**: incremental runs are cheap (a few hundred ms on an
|
|
101
|
+
unchanged repo), so this removes the classic cold-start failure of searching
|
|
102
|
+
an empty index, without ever blocking the UI.
|
|
103
|
+
|
|
91
104
|
## Requirements
|
|
92
105
|
|
|
93
106
|
- **Node 22+** on macOS, Linux or Windows.
|
|
@@ -97,6 +110,30 @@ unchanged repo took 237 ms.
|
|
|
97
110
|
|
|
98
111
|
No Python. No build tools. No API key.
|
|
99
112
|
|
|
113
|
+
### Embedding model choice (measured, not claimed)
|
|
114
|
+
|
|
115
|
+
Evaluated on this package's own 76-query suite (`npm run eval -- --local`, same
|
|
116
|
+
code state, CPU only):
|
|
117
|
+
|
|
118
|
+
| local model | R@5 | R@10 | nDCG@10 | distraction@10 | served tokens | p50/p95 |
|
|
119
|
+
|---|---|---|---|---|---|---|
|
|
120
|
+
| `Xenova/all-MiniLM-L6-v2` (default, ~25 MB) | 0.752 | 0.827 | 0.744 | 0.713 | 702 | 28/33 ms |
|
|
121
|
+
| `michaelfeil/jina-embeddings-v2-base-code` (recommended for code) | **0.798** | **0.899** | **0.813** | **0.640** | 654 | 55/61 ms |
|
|
122
|
+
|
|
123
|
+
Why the split default: the code-trained model wins every quality metric on
|
|
124
|
+
this codebase (natural-language queries most of all: R@10 0.433 → 0.700) at
|
|
125
|
+
~2× CPU latency and a larger first download. Keep MiniLM as the default for
|
|
126
|
+
fast general use; point code-heavy projects at the jina model:
|
|
127
|
+
|
|
128
|
+
```yaml
|
|
129
|
+
embedding:
|
|
130
|
+
model: michaelfeil/jina-embeddings-v2-base-code
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Long inputs are pre-truncated to ~2000 chars per embedding so long-context
|
|
134
|
+
models cannot OOM the CPU runtime, and changing the model (dimension change)
|
|
135
|
+
forces a full reindex automatically.
|
|
136
|
+
|
|
100
137
|
## Configuration
|
|
101
138
|
|
|
102
139
|
Zero config by default. Override in `~/.pi-context-engine/config.yaml` or a
|
|
@@ -107,16 +144,29 @@ retrieval:
|
|
|
107
144
|
top_k: 20
|
|
108
145
|
confidence_threshold: 0.2
|
|
109
146
|
marginal_ratio: 0.75 # stop adding chunks below this fraction of the top score
|
|
147
|
+
expand_context: true # serve a hit's module parent + adjacent chunk (small-to-big)
|
|
148
|
+
confidence_high: 0.55 # query-quality composite at/above which a search reports `high`
|
|
149
|
+
confidence_low: 0.45 # below this the result gets a `[query confidence: low ...]` line with hints
|
|
150
|
+
auto_elaborate: false # opt-in: fuse a deterministic query variant as an extra candidate source
|
|
151
|
+
refine_hints: true # append a `[refine: ...]` line with next-query candidates
|
|
152
|
+
rerank: off # off | local — opt-in cross-encoder reranking of the top 30 fused hits
|
|
153
|
+
rerank_model: Xenova/ms-marco-MiniLM-L-6-v2
|
|
154
|
+
rerank_topn: 30
|
|
110
155
|
guidance:
|
|
111
156
|
enabled: true # inject context_search guidance into the system prompt
|
|
112
157
|
compression:
|
|
113
158
|
level: standard # minimal | standard | full
|
|
114
159
|
ollama_url: http://localhost:11434
|
|
160
|
+
query_aware: true # keep query-relevant lines when truncating
|
|
115
161
|
embedding:
|
|
116
162
|
model: local # or a specific transformers.js model id
|
|
117
163
|
ollama_model: nomic-embed-text
|
|
118
164
|
indexer:
|
|
119
165
|
redact_secrets: true
|
|
166
|
+
context_headers: true # prepend `// file: ... | unit: ... | imports: ...` to chunk text
|
|
167
|
+
chunking: legacy # legacy (default) | ast-density — cAST-style budgeted split-merge (experimental)
|
|
168
|
+
chunk_budget_chars: 1200 # non-whitespace budget per ast-density chunk
|
|
169
|
+
auto_index: true # incremental background reindex shortly after session start
|
|
120
170
|
memory:
|
|
121
171
|
redact_pii: true
|
|
122
172
|
pricing:
|
|
@@ -130,6 +180,19 @@ Environment overrides:
|
|
|
130
180
|
- **`PCE_EMBED_BACKEND`**: force `local` or `ollama` instead of auto-detect.
|
|
131
181
|
- **`PCE_RECALL_DISPLAY_CAP`**: how many recall hits to show (default 7).
|
|
132
182
|
|
|
183
|
+
**Reranking caveat**: `retrieval.rerank: local` is opt-in because it downloads
|
|
184
|
+
an ~80 MB cross-encoder model on first use (into the same models dir as the
|
|
185
|
+
embedder) and adds CPU latency per query. It reorders only the top 30 fused
|
|
186
|
+
candidates; if the model cannot load, retrieval falls back to the fused order
|
|
187
|
+
instead of failing.
|
|
188
|
+
|
|
189
|
+
**Chunking caveat**: `indexer.chunking: ast-density` (cAST-style budgeted
|
|
190
|
+
split-merge) is experimental and measured WORSE than the legacy walk on this
|
|
191
|
+
package's eval (R@10 0.818 vs 0.936 with the deterministic embedder): tiling
|
|
192
|
+
trivia into chunks dilutes fixed-size result sets. It stays behind a flag for
|
|
193
|
+
A/B on other workloads; chunks concatenate byte-for-byte back to the source,
|
|
194
|
+
so it is safe to try on a branch.
|
|
195
|
+
|
|
133
196
|
**Embedding backends**: two options, picked automatically per project.
|
|
134
197
|
|
|
135
198
|
- **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.3.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,31 @@ 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;
|
|
96
|
+
/** Run an incremental reindex in the background shortly after session start. */
|
|
97
|
+
indexerAutoIndex: boolean;
|
|
64
98
|
indexerRedactSecrets: boolean;
|
|
65
99
|
memoryRedactPii: boolean;
|
|
66
100
|
pricingModel: string;
|
|
@@ -73,6 +107,7 @@ export function defaultConfig(): Config {
|
|
|
73
107
|
return {
|
|
74
108
|
compressionLevel: "standard",
|
|
75
109
|
compressionModel: "phi3:mini",
|
|
110
|
+
compressionQueryAware: true,
|
|
76
111
|
ollamaUrl: "http://localhost:11434",
|
|
77
112
|
embeddingModel: "local",
|
|
78
113
|
ollamaEmbedModel: "nomic-embed-text",
|
|
@@ -80,7 +115,19 @@ export function defaultConfig(): Config {
|
|
|
80
115
|
retrievalConfidenceThreshold: 0.2,
|
|
81
116
|
retrievalTopK: 20,
|
|
82
117
|
retrievalMarginalRatio: 0.75,
|
|
118
|
+
retrievalExpandContext: true,
|
|
119
|
+
retrievalConfidenceHigh: 0.55,
|
|
120
|
+
retrievalConfidenceLow: 0.45,
|
|
121
|
+
retrievalAutoElaborate: false,
|
|
122
|
+
retrievalRefineHints: true,
|
|
123
|
+
retrievalRerank: "off",
|
|
124
|
+
retrievalRerankModel: "Xenova/ms-marco-MiniLM-L-6-v2",
|
|
125
|
+
retrievalRerankTopn: 30,
|
|
126
|
+
indexerChunking: "legacy",
|
|
127
|
+
indexerChunkBudgetChars: 1200,
|
|
83
128
|
indexerIgnore: [...DEFAULT_IGNORE],
|
|
129
|
+
indexerContextHeaders: true,
|
|
130
|
+
indexerAutoIndex: true,
|
|
84
131
|
indexerRedactSecrets: true,
|
|
85
132
|
memoryRedactPii: true,
|
|
86
133
|
pricingModel: "opus",
|
|
@@ -94,6 +141,7 @@ export function defaultConfig(): Config {
|
|
|
94
141
|
const MAPPING: Record<string, keyof Config> = {
|
|
95
142
|
"compression.level": "compressionLevel",
|
|
96
143
|
"compression.model": "compressionModel",
|
|
144
|
+
"compression.query_aware": "compressionQueryAware",
|
|
97
145
|
"compression.ollama_url": "ollamaUrl",
|
|
98
146
|
"guidance.enabled": "guidanceEnabled",
|
|
99
147
|
"embedding.model": "embeddingModel",
|
|
@@ -101,7 +149,19 @@ const MAPPING: Record<string, keyof Config> = {
|
|
|
101
149
|
"retrieval.confidence_threshold": "retrievalConfidenceThreshold",
|
|
102
150
|
"retrieval.top_k": "retrievalTopK",
|
|
103
151
|
"retrieval.marginal_ratio": "retrievalMarginalRatio",
|
|
152
|
+
"retrieval.expand_context": "retrievalExpandContext",
|
|
153
|
+
"retrieval.confidence_high": "retrievalConfidenceHigh",
|
|
154
|
+
"retrieval.confidence_low": "retrievalConfidenceLow",
|
|
155
|
+
"retrieval.auto_elaborate": "retrievalAutoElaborate",
|
|
156
|
+
"retrieval.refine_hints": "retrievalRefineHints",
|
|
157
|
+
"retrieval.rerank": "retrievalRerank",
|
|
158
|
+
"retrieval.rerank_model": "retrievalRerankModel",
|
|
159
|
+
"retrieval.rerank_topn": "retrievalRerankTopn",
|
|
160
|
+
"indexer.chunking": "indexerChunking",
|
|
161
|
+
"indexer.chunk_budget_chars": "indexerChunkBudgetChars",
|
|
104
162
|
"indexer.ignore": "indexerIgnore",
|
|
163
|
+
"indexer.context_headers": "indexerContextHeaders",
|
|
164
|
+
"indexer.auto_index": "indexerAutoIndex",
|
|
105
165
|
"indexer.redact_secrets": "indexerRedactSecrets",
|
|
106
166
|
"memory.redact_pii": "memoryRedactPii",
|
|
107
167
|
"storage.path": "storagePath",
|