pi-context-engine 0.5.3 → 0.7.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 +44 -5
- package/package.json +1 -1
- package/src/config.ts +9 -0
- package/src/dashboard/api.ts +155 -0
- package/src/dashboard/page.ts +403 -0
- package/src/dashboard/server.ts +204 -0
- package/src/engine.ts +95 -6
- package/src/extension.ts +107 -1
- package/src/guidance.ts +1 -0
- package/src/indexer/pipeline.ts +25 -11
- package/src/memory/db.ts +162 -0
package/README.md
CHANGED
|
@@ -37,7 +37,7 @@ runs fixed queries. Run it yourself; nothing is cherry-picked.
|
|
|
37
37
|
|
|
38
38
|
## What you get
|
|
39
39
|
|
|
40
|
-
Ten tools the agent can call, plus
|
|
40
|
+
Ten tools the agent can call, plus five slash commands.
|
|
41
41
|
|
|
42
42
|
**Search**
|
|
43
43
|
- **`context_search`**: hybrid vector + BM25 retrieval with confidence scores.
|
|
@@ -56,8 +56,8 @@ Ten tools the agent can call, plus four slash commands.
|
|
|
56
56
|
- **`index_status`**: chunk count and measured savings.
|
|
57
57
|
- **`reindex`**: refresh a path or the whole project.
|
|
58
58
|
|
|
59
|
-
Commands: `/context-index [--full]`, `/context-status`, `/context-savings`,
|
|
60
|
-
`/context-warmup`.
|
|
59
|
+
Commands: `/context-index [--full] [--reset]`, `/context-status`, `/context-savings`,
|
|
60
|
+
`/context-warmup`, `/context-dashboard`.
|
|
61
61
|
|
|
62
62
|
**Shipped guidance.** The package also injects a ~900-token guidance section
|
|
63
63
|
into the system prompt while `context_search` is active: how to formulate
|
|
@@ -119,6 +119,14 @@ unchanged repo took 237 ms.
|
|
|
119
119
|
Migration runs (schema reset, embedding-dimension change) are exempt: their
|
|
120
120
|
manifest stays untouched on disk until the run completes, so an interrupted
|
|
121
121
|
migration resumes cleanly instead of stranding stale rows.
|
|
122
|
+
- **One-command reset**: `/context-index --reset` deletes the index through the
|
|
123
|
+
engine while the session is running: sqlite handles are closed first, so it
|
|
124
|
+
succeeds where manual deletion of the storage directory fails with locked
|
|
125
|
+
file errors (EPERM on Windows). The report shows how many files and bytes
|
|
126
|
+
were freed, and memory, sessions, and the savings ledger survive. The
|
|
127
|
+
embedding cache is deleted too, so a rebuild re-embeds from scratch. The
|
|
128
|
+
next search rebuilds via the lazy kick, guarded by the same file budget as
|
|
129
|
+
every automatic run.
|
|
122
130
|
- **Cold-start visibility**: the first build on an empty index announces its
|
|
123
131
|
start (`building the first index…`) and always closes with a result line
|
|
124
132
|
(`updated: N file(s)` or `no files to index`). This is the one background
|
|
@@ -142,6 +150,26 @@ unchanged repo took 237 ms.
|
|
|
142
150
|
provisioning step (a devcontainer `postCreateCommand`, a CI cache setup),
|
|
143
151
|
keeping first searches fast.
|
|
144
152
|
|
|
153
|
+
### Web dashboard
|
|
154
|
+
|
|
155
|
+
`/context-dashboard` starts a small read-only web server inside the pi process
|
|
156
|
+
and opens it in your browser. Port is picked per session; set `dashboard.port`
|
|
157
|
+
in config for a stable URL.
|
|
158
|
+
|
|
159
|
+
- **Overview**: donut of measured savings, chunk/file counts, live indexing
|
|
160
|
+
state (polled every 5 s), and the active configuration.
|
|
161
|
+
- **Files**: every indexed file with its chunk count, largest first.
|
|
162
|
+
- **Sessions**: past sessions with per-turn summaries and tool events.
|
|
163
|
+
- **Memory**: recorded decisions and code areas.
|
|
164
|
+
- **Savings**: the per-bucket ledger with dollar estimates and a 30-day trend.
|
|
165
|
+
- **Export**: one JSON download of all of the above.
|
|
166
|
+
|
|
167
|
+
The dashboard is strictly read-only: every state change (reindex, reset,
|
|
168
|
+
config) stays in pi commands. It binds to `127.0.0.1` only, serves GET
|
|
169
|
+
requests only, and rejects requests whose `Host` header is not loopback, so a
|
|
170
|
+
malicious page cannot read your index data via DNS rebinding. It stops when
|
|
171
|
+
the session ends.
|
|
172
|
+
|
|
145
173
|
## Requirements
|
|
146
174
|
|
|
147
175
|
- **Node 22+** on macOS, Linux or Windows.
|
|
@@ -212,6 +240,8 @@ indexer:
|
|
|
212
240
|
watch: true # reindex on file changes while the session runs (debounced, incremental)
|
|
213
241
|
memory:
|
|
214
242
|
redact_pii: true
|
|
243
|
+
dashboard:
|
|
244
|
+
port: 0 # 0 = pick a free port; set one for a stable dashboard URL
|
|
215
245
|
pricing:
|
|
216
246
|
model: opus # opus | sonnet | haiku | gpt-4o | gemini-2.5-pro | ...
|
|
217
247
|
storage:
|
|
@@ -261,6 +291,10 @@ by default:
|
|
|
261
291
|
Path arguments are validated against the project root, so a crafted `path` cannot
|
|
262
292
|
read outside it.
|
|
263
293
|
|
|
294
|
+
The web dashboard adds its own guards: loopback binding, GET-only routing, and
|
|
295
|
+
`Host` header validation against DNS rebinding. With no mutating endpoints,
|
|
296
|
+
there is nothing for a cross-site request to trigger.
|
|
297
|
+
|
|
264
298
|
## Supported languages
|
|
265
299
|
|
|
266
300
|
**AST chunking** (tree-sitter): Python, JavaScript, TypeScript, TSX, PHP, Go,
|
|
@@ -278,13 +312,17 @@ This is a port, not a wrapper. Three deliberate changes:
|
|
|
278
312
|
the `curl`-based Claude Code hooks. Session capture rides on pi's own events.
|
|
279
313
|
- **No Python runtime**: embeddings run through transformers.js or Ollama, so
|
|
280
314
|
installation is one npm command.
|
|
315
|
+
- **Dashboard rebuilt, not ported**: upstream serves its dashboard from a
|
|
316
|
+
FastAPI app with a mutating REST surface. Ours is a read-only `node:http`
|
|
317
|
+
server (zero dependencies) with a fresh single-file page — same tabs, no
|
|
318
|
+
write endpoints.
|
|
281
319
|
- **Two upstream bugs fixed**: chunk ids now include column offsets (identical
|
|
282
320
|
spans on one line used to overwrite each other in the index), and the
|
|
283
321
|
placeholder filter no longer suppresses unambiguous vendor keys that happen to
|
|
284
322
|
contain a word like `example`.
|
|
285
323
|
|
|
286
|
-
Not ported (yet):
|
|
287
|
-
|
|
324
|
+
Not ported (yet): output-compression rules and editor bootstrapping for
|
|
325
|
+
non-pi editors.
|
|
288
326
|
|
|
289
327
|
## Development
|
|
290
328
|
|
|
@@ -294,6 +332,7 @@ npm run check # typecheck + 251 unit tests
|
|
|
294
332
|
npm run smoke:env # tree-sitter + sqlite-vec capability check
|
|
295
333
|
npm run smoke:loader # load the extension through pi's jiti loader
|
|
296
334
|
npm run smoke:real-world # index this repo with the local embedder
|
|
335
|
+
npx tsx scripts/smoke-dashboard.mts # boot the dashboard, probe every route, write a static UI preview
|
|
297
336
|
```
|
|
298
337
|
|
|
299
338
|
## Credits
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-context-engine",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
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
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
package/src/config.ts
CHANGED
|
@@ -105,6 +105,8 @@ export interface Config {
|
|
|
105
105
|
indexerWatch: boolean;
|
|
106
106
|
indexerRedactSecrets: boolean;
|
|
107
107
|
memoryRedactPii: boolean;
|
|
108
|
+
/** TCP port for the /context-dashboard web server. 0 = pick a free port (default). */
|
|
109
|
+
dashboardPort: number;
|
|
108
110
|
pricingModel: string;
|
|
109
111
|
pricingInput: number | null;
|
|
110
112
|
pricingOutput: number | null;
|
|
@@ -140,6 +142,7 @@ export function defaultConfig(): Config {
|
|
|
140
142
|
indexerWatch: true,
|
|
141
143
|
indexerRedactSecrets: true,
|
|
142
144
|
memoryRedactPii: true,
|
|
145
|
+
dashboardPort: 0,
|
|
143
146
|
pricingModel: "opus",
|
|
144
147
|
pricingInput: null,
|
|
145
148
|
pricingOutput: null,
|
|
@@ -176,6 +179,7 @@ const MAPPING: Record<string, keyof Config> = {
|
|
|
176
179
|
"indexer.watch": "indexerWatch",
|
|
177
180
|
"indexer.redact_secrets": "indexerRedactSecrets",
|
|
178
181
|
"memory.redact_pii": "memoryRedactPii",
|
|
182
|
+
"dashboard.port": "dashboardPort",
|
|
179
183
|
"storage.path": "storagePath",
|
|
180
184
|
"pricing.model": "pricingModel",
|
|
181
185
|
"pricing.input": "pricingInput",
|
|
@@ -221,6 +225,11 @@ function applyDict(config: Config, data: any): void {
|
|
|
221
225
|
throw new Error(`config ${dotted} must be a non-negative integer`);
|
|
222
226
|
}
|
|
223
227
|
config.indexerMaxAutoFiles = value;
|
|
228
|
+
} else if (attr === "dashboardPort") {
|
|
229
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value < 0 || value > 65535) {
|
|
230
|
+
throw new Error(`config ${dotted} must be an integer between 0 and 65535 (0 = pick a free port)`);
|
|
231
|
+
}
|
|
232
|
+
config.dashboardPort = value;
|
|
224
233
|
} else if (attr === "pricingInput" || attr === "pricingOutput") {
|
|
225
234
|
if (value !== null && typeof value !== "number") throw new Error(`config ${dotted} must be a number or null`);
|
|
226
235
|
(config as any)[attr] = value;
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read-only JSON snapshots for the web dashboard.
|
|
3
|
+
*
|
|
4
|
+
* Every function here is a pure reader over the engine's stores: no writes,
|
|
5
|
+
* no savings accounting, no embedder/model initialization (a dashboard
|
|
6
|
+
* refresh must never trigger a model download). Dollar estimates use the
|
|
7
|
+
* static/cached pricing table only — resolvePricing(config, false).
|
|
8
|
+
*/
|
|
9
|
+
import type { ContextEngine } from "../engine.js";
|
|
10
|
+
import { resolvePricing } from "../pricing.js";
|
|
11
|
+
|
|
12
|
+
export interface ExtDashboardState {
|
|
13
|
+
/** True while a background (auto/watcher) index run is in flight. */
|
|
14
|
+
indexing: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const num = (n: number): number => Math.max(0, Math.floor(n || 0));
|
|
18
|
+
|
|
19
|
+
/** Cheap overview: index size, config summary and savings aggregates. No
|
|
20
|
+
* pricing resolution, no model init — polled every few seconds. */
|
|
21
|
+
export function statusPayload(eng: ContextEngine, ext: ExtDashboardState) {
|
|
22
|
+
const stats = eng.getStats();
|
|
23
|
+
let fileCounts: Record<string, number> = {};
|
|
24
|
+
try {
|
|
25
|
+
fileCounts = eng.backend.fileChunkCounts();
|
|
26
|
+
} catch {
|
|
27
|
+
// index stores may not exist yet
|
|
28
|
+
}
|
|
29
|
+
const saved = num(stats.rawTokens - stats.servedTokens);
|
|
30
|
+
const fullSaved = num(stats.fullFileTokens - stats.servedTokens);
|
|
31
|
+
return {
|
|
32
|
+
project: eng.projectName,
|
|
33
|
+
projectDir: eng.projectDir,
|
|
34
|
+
storageDir: eng.storageBase,
|
|
35
|
+
sessionId: eng.sessionId,
|
|
36
|
+
chunks: eng.chunkCount(),
|
|
37
|
+
files: Object.keys(fileCounts).length,
|
|
38
|
+
indexing: ext.indexing,
|
|
39
|
+
config: {
|
|
40
|
+
embeddingModel: eng.config.embeddingModel,
|
|
41
|
+
topK: eng.config.retrievalTopK,
|
|
42
|
+
rerank: eng.config.retrievalRerank,
|
|
43
|
+
chunking: eng.config.indexerChunking,
|
|
44
|
+
autoIndex: eng.config.indexerAutoIndex,
|
|
45
|
+
watch: eng.config.indexerWatch,
|
|
46
|
+
compression: eng.config.compressionLevel,
|
|
47
|
+
},
|
|
48
|
+
stats: {
|
|
49
|
+
queries: stats.queries,
|
|
50
|
+
rawTokens: stats.rawTokens,
|
|
51
|
+
servedTokens: stats.servedTokens,
|
|
52
|
+
fullFileTokens: stats.fullFileTokens,
|
|
53
|
+
tokensSaved: saved,
|
|
54
|
+
fullFileTokensSaved: fullSaved,
|
|
55
|
+
savingsPct: stats.fullFileTokens > 0 ? Math.floor((fullSaved / stats.fullFileTokens) * 100) : null,
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Bucket ledger + dollar estimates + per-day trend. Never fetches live
|
|
61
|
+
* pricing: the dashboard must stay offline-safe. */
|
|
62
|
+
export async function savingsPayload(eng: ContextEngine) {
|
|
63
|
+
const { label, pricing } = await resolvePricing(eng.config, false);
|
|
64
|
+
const buckets: Record<string, { baseline: number; served: number; calls: number }> = eng.aggregateSavings() ?? {};
|
|
65
|
+
const toUsd = (tokens: number): number => (tokens / 1e6) * pricing.input;
|
|
66
|
+
const rows = Object.entries(buckets)
|
|
67
|
+
.map(([bucket, v]) => ({
|
|
68
|
+
bucket,
|
|
69
|
+
calls: v.calls,
|
|
70
|
+
baseline: v.baseline,
|
|
71
|
+
served: v.served,
|
|
72
|
+
saved: num(v.baseline - v.served),
|
|
73
|
+
savedUsd: toUsd(num(v.baseline - v.served)),
|
|
74
|
+
}))
|
|
75
|
+
.sort((a, b) => b.saved - a.saved);
|
|
76
|
+
let memory = null;
|
|
77
|
+
try {
|
|
78
|
+
memory = eng.getMemory();
|
|
79
|
+
} catch {
|
|
80
|
+
memory = null;
|
|
81
|
+
}
|
|
82
|
+
const byDay = (memory?.savingsByDay(30) ?? []).map((d) => ({
|
|
83
|
+
day: d.day,
|
|
84
|
+
saved: num(d.baseline - d.served),
|
|
85
|
+
}));
|
|
86
|
+
const stats = eng.getStats();
|
|
87
|
+
const fullSaved = num(stats.fullFileTokens - stats.servedTokens);
|
|
88
|
+
return {
|
|
89
|
+
pricing: { model: label, inputUsdPer1M: pricing.input, outputUsdPer1M: pricing.output },
|
|
90
|
+
totals: {
|
|
91
|
+
savedTokens: fullSaved,
|
|
92
|
+
savedUsd: toUsd(fullSaved),
|
|
93
|
+
savingsPct: stats.fullFileTokens > 0 ? Math.floor((fullSaved / stats.fullFileTokens) * 100) : null,
|
|
94
|
+
},
|
|
95
|
+
buckets: rows,
|
|
96
|
+
byDay,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Indexed files with chunk counts, largest first. */
|
|
101
|
+
export function filesPayload(eng: ContextEngine, limit = 500) {
|
|
102
|
+
let counts: Record<string, number> = {};
|
|
103
|
+
try {
|
|
104
|
+
counts = eng.backend.fileChunkCounts();
|
|
105
|
+
} catch {
|
|
106
|
+
// no index yet
|
|
107
|
+
}
|
|
108
|
+
const rows = Object.entries(counts)
|
|
109
|
+
.map(([filePath, chunks]) => ({ filePath, chunks }))
|
|
110
|
+
.sort((a, b) => b.chunks - a.chunks || a.filePath.localeCompare(b.filePath));
|
|
111
|
+
return {
|
|
112
|
+
total: rows.length,
|
|
113
|
+
chunks: rows.reduce((acc, r) => acc + r.chunks, 0),
|
|
114
|
+
files: rows.slice(0, Math.max(1, Math.min(limit, 5000))),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function sessionsPayload(eng: ContextEngine, limit = 50) {
|
|
119
|
+
const memory = eng.getMemory();
|
|
120
|
+
return {
|
|
121
|
+
sessions: memory ? memory.listSessions(limit) : [],
|
|
122
|
+
// JSON snapshots (SessionCapture) survive memory-db failures and carry
|
|
123
|
+
// decisions/code areas even when SQLite is unavailable.
|
|
124
|
+
activeSessionId: eng.sessionId,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function timelinePayload(eng: ContextEngine, sessionId: string) {
|
|
129
|
+
const memory = eng.getMemory();
|
|
130
|
+
if (!memory) return { session: null, turns: [], events: [] };
|
|
131
|
+
const meta = memory.listSessions(500).find((s) => s.id === sessionId) ?? null;
|
|
132
|
+
const detail = memory.sessionDetail(sessionId);
|
|
133
|
+
return { session: meta, turns: detail.turns, events: detail.events };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function decisionsPayload(eng: ContextEngine, limit = 100) {
|
|
137
|
+
const memory = eng.getMemory();
|
|
138
|
+
return {
|
|
139
|
+
decisions: memory?.listDecisions(limit) ?? [],
|
|
140
|
+
codeAreas: memory?.listCodeAreas(limit) ?? [],
|
|
141
|
+
consolidated: eng.sessionCapture.loadConsolidatedDecisions().slice(0, limit),
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Everything, for /api/export. */
|
|
146
|
+
export async function exportPayload(eng: ContextEngine, ext: ExtDashboardState) {
|
|
147
|
+
return {
|
|
148
|
+
generatedAt: new Date().toISOString(),
|
|
149
|
+
status: statusPayload(eng, ext),
|
|
150
|
+
savings: await savingsPayload(eng),
|
|
151
|
+
files: filesPayload(eng),
|
|
152
|
+
sessions: sessionsPayload(eng),
|
|
153
|
+
decisions: decisionsPayload(eng),
|
|
154
|
+
};
|
|
155
|
+
}
|