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 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 four slash commands.
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): the web dashboard, output-compression rules, and editor
287
- bootstrapping for non-pi editors.
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.5.3",
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
+ }