@bugabinga/pi-ext-ctx 0.1.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/index.ts ADDED
@@ -0,0 +1,552 @@
1
+ import { spawn } from "node:child_process";
2
+ import { createHash } from "node:crypto";
3
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
4
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
5
+ import { tmpdir } from "node:os";
6
+ import { dirname, isAbsolute, join, resolve } from "node:path";
7
+ import { Type } from "typebox";
8
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
9
+
10
+ const INTENT_SEARCH_THRESHOLD = 5_000;
11
+ const LARGE_OUTPUT_THRESHOLD = 102_400;
12
+ const MAX_RETURN_BYTES = 12_000;
13
+ const MAX_CHUNK_BYTES = 16_000;
14
+ const MAX_CHUNK_LINES = 200;
15
+ const ERROR_QUERY = "errors failures exceptions panic traceback assertion failed error";
16
+
17
+ type TextContent = { type: "text"; text: string };
18
+ type ToolPatch = { content: TextContent[]; details?: Record<string, unknown>; isError?: boolean };
19
+
20
+ export type CtxToolParams =
21
+ | { op: "run-sh"; script: string; query?: string | string[]; timeoutMs?: number }
22
+ | { op: "run-js"; code: string; query?: string | string[]; timeoutMs?: number }
23
+ | { op: "run-py"; code: string; query?: string | string[]; timeoutMs?: number }
24
+ | { op: "file-sh"; path: string; script: string; query?: string | string[]; timeoutMs?: number }
25
+ | { op: "file-js"; path: string; code: string; query?: string | string[]; timeoutMs?: number }
26
+ | { op: "file-py"; path: string; code: string; query?: string | string[]; timeoutMs?: number }
27
+ | { op: "search"; query: string | string[]; source?: string; limit?: number };
28
+
29
+ interface SourceRow {
30
+ id: number;
31
+ kind: string;
32
+ label: string;
33
+ bytes: number;
34
+ created_at: string;
35
+ }
36
+
37
+ interface SearchRow {
38
+ source_id: number;
39
+ title: string;
40
+ text: string;
41
+ label: string;
42
+ kind: string;
43
+ score: number;
44
+ }
45
+
46
+ interface RunResult {
47
+ stdout: string;
48
+ stderr: string;
49
+ exitCode: number | null;
50
+ timedOut: boolean;
51
+ }
52
+
53
+ function byteLen(text: string): number {
54
+ return Buffer.byteLength(text, "utf8");
55
+ }
56
+
57
+ function truncateBytes(text: string, max = MAX_RETURN_BYTES): string {
58
+ if (byteLen(text) <= max) return text;
59
+ let out = "";
60
+ let used = 0;
61
+ for (const ch of text) {
62
+ const n = byteLen(ch);
63
+ if (used + n > max) break;
64
+ out += ch;
65
+ used += n;
66
+ }
67
+ return `${out}\n… truncated to ${max}B`;
68
+ }
69
+
70
+ function textOf(content: unknown): string {
71
+ if (!Array.isArray(content)) return String(content ?? "");
72
+ return content.map((c: any) => c?.type === "text" && typeof c.text === "string" ? c.text : "").join("\n");
73
+ }
74
+
75
+ function queryTerms(query: string): string[] {
76
+ return [...query.matchAll(/[\p{L}\p{N}_]{2,}/gu)]
77
+ .map(match => match[0].toLowerCase())
78
+ .filter(term => !["and", "or", "not", "near"].includes(term));
79
+ }
80
+
81
+ function snippetForQuery(text: string, query: string, max = 2500): string {
82
+ if (byteLen(text) <= max) return text.trim();
83
+ const lower = text.toLowerCase();
84
+ let pos = -1;
85
+ for (const term of queryTerms(query)) {
86
+ const next = lower.indexOf(term);
87
+ if (next >= 0 && (pos < 0 || next < pos)) pos = next;
88
+ }
89
+ if (pos < 0) return truncateBytes(text.trim(), max);
90
+ let start = Math.max(0, pos - Math.floor(max / 3));
91
+ const newline = text.lastIndexOf("\n", pos);
92
+ if (newline >= 0 && newline >= start - 500) start = newline + 1;
93
+ let snippet = text.slice(start);
94
+ if (start > 0) snippet = `…\n${snippet}`;
95
+ return truncateBytes(snippet.trim(), max);
96
+ }
97
+
98
+ function shortHash(text: string): string {
99
+ return createHash("sha256").update(text).digest("hex").slice(0, 8);
100
+ }
101
+
102
+ export function defaultDbPath(cwd = process.cwd(), ctx?: any): string {
103
+ if (process.env.PI_CTX_DB_PATH) return process.env.PI_CTX_DB_PATH;
104
+ const sessionFile = ctx?.sessionManager?.getSessionFile?.()
105
+ ?? ctx?.sessionManager?.sessionFile
106
+ ?? ctx?.sessionFile;
107
+ const managerDir = ctx?.sessionManager?.getSessionDir?.()
108
+ ?? ctx?.sessionManager?.sessionDir;
109
+ const sessionDir = typeof sessionFile === "string" && sessionFile
110
+ ? dirname(sessionFile)
111
+ : typeof managerDir === "string" && managerDir
112
+ ? managerDir
113
+ : process.env.PI_CODING_AGENT_SESSION_DIR;
114
+ if (typeof sessionDir !== "string" || !sessionDir) {
115
+ throw new Error("ctx requires a Pi session dir; use PI_CTX_DB_PATH for tests/no-session runs");
116
+ }
117
+ const base = join(sessionDir, "ctx");
118
+ mkdirSync(base, { recursive: true });
119
+ const hash = createHash("sha256").update(resolve(cwd)).digest("hex").slice(0, 16);
120
+ return join(base, `${hash}.db`);
121
+ }
122
+
123
+ function jsonText(value: unknown): string {
124
+ try { return JSON.stringify(value); } catch { return String(value); }
125
+ }
126
+
127
+ function normalizeQueries(query: string | string[] | undefined): string[] {
128
+ if (Array.isArray(query)) return query.map(q => q.trim()).filter(Boolean);
129
+ if (typeof query === "string" && query.trim()) return [query.trim()];
130
+ return [];
131
+ }
132
+
133
+ function sourceFilter(source: string | undefined): string | undefined {
134
+ const s = source?.trim();
135
+ return s ? `%${s}%` : undefined;
136
+ }
137
+
138
+ function chunkText(text: string): Array<{ title: string; text: string }> {
139
+ const lines = text.split(/\r?\n/);
140
+ const chunks: Array<{ title: string; text: string }> = [];
141
+ let current: string[] = [];
142
+ let currentBytes = 0;
143
+ let start = 1;
144
+ const flush = (end: number) => {
145
+ if (current.length === 0) return;
146
+ chunks.push({ title: `lines ${start}-${end}`, text: current.join("\n") });
147
+ current = [];
148
+ currentBytes = 0;
149
+ start = end + 1;
150
+ };
151
+ for (let i = 0; i < lines.length; i++) {
152
+ const line = lines[i] ?? "";
153
+ const n = byteLen(line) + 1;
154
+ if (current.length > 0 && (current.length >= MAX_CHUNK_LINES || currentBytes + n > MAX_CHUNK_BYTES)) {
155
+ flush(i);
156
+ }
157
+ current.push(line);
158
+ currentBytes += n;
159
+ }
160
+ flush(lines.length);
161
+ return chunks.length ? chunks : [{ title: "output", text }];
162
+ }
163
+
164
+ export class CtxStore {
165
+ #db: any;
166
+ readonly dbPath: string;
167
+
168
+ constructor(dbPath = defaultDbPath()) {
169
+ this.dbPath = dbPath;
170
+ mkdirSync(dirname(dbPath), { recursive: true });
171
+ const { Database } = require("bun:sqlite") as typeof import("bun:sqlite");
172
+ this.#db = new Database(dbPath);
173
+ this.#db.exec("PRAGMA journal_mode=WAL");
174
+ this.#db.exec("PRAGMA busy_timeout=30000");
175
+ this.#db.exec(`
176
+ CREATE TABLE IF NOT EXISTS sources (
177
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
178
+ kind TEXT NOT NULL,
179
+ label TEXT NOT NULL,
180
+ bytes INTEGER NOT NULL,
181
+ metadata TEXT,
182
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
183
+ );
184
+ CREATE VIRTUAL TABLE IF NOT EXISTS chunks_fts USING fts5(
185
+ source_id UNINDEXED,
186
+ title,
187
+ text,
188
+ label UNINDEXED,
189
+ kind UNINDEXED,
190
+ tokenize='porter unicode61'
191
+ );
192
+ `);
193
+ }
194
+
195
+ indexText(input: { kind: string; label: string; text: string; metadata?: unknown }): { sourceId: number; chunkCount: number; bytes: number; label: string } {
196
+ const bytes = byteLen(input.text);
197
+ const source = this.#db.prepare("INSERT INTO sources(kind, label, bytes, metadata) VALUES (?, ?, ?, ?)")
198
+ .run(input.kind, input.label, bytes, input.metadata === undefined ? null : jsonText(input.metadata));
199
+ const sourceId = Number(source.lastInsertRowid);
200
+ const insert = this.#db.prepare("INSERT INTO chunks_fts(source_id, title, text, label, kind) VALUES (?, ?, ?, ?, ?)");
201
+ const chunks = chunkText(input.text);
202
+ const tx = this.#db.transaction(() => {
203
+ for (const chunk of chunks) insert.run(sourceId, chunk.title, chunk.text, input.label, input.kind);
204
+ });
205
+ tx();
206
+ return { sourceId, chunkCount: chunks.length, bytes, label: input.label };
207
+ }
208
+
209
+ search(queries: string[], opts: { source?: string; limit?: number } = {}): string {
210
+ const clean = queries.map(q => q.trim()).filter(Boolean);
211
+ if (clean.length === 0) return "ctx: empty query";
212
+ const limit = Math.max(1, Math.min(opts.limit ?? 3, 10));
213
+ const src = sourceFilter(opts.source);
214
+ const lines: string[] = [];
215
+ for (const q of clean) {
216
+ const rows = src
217
+ ? this.#db.prepare("SELECT source_id, title, text, label, kind, bm25(chunks_fts) AS score FROM chunks_fts WHERE chunks_fts MATCH ? AND label LIKE ? ORDER BY score LIMIT ?").all(q, src, limit) as SearchRow[]
218
+ : this.#db.prepare("SELECT source_id, title, text, label, kind, bm25(chunks_fts) AS score FROM chunks_fts WHERE chunks_fts MATCH ? ORDER BY score LIMIT ?").all(q, limit) as SearchRow[];
219
+ lines.push(`## ${q}`);
220
+ if (rows.length === 0) {
221
+ lines.push("no matches");
222
+ continue;
223
+ }
224
+ for (const row of rows) {
225
+ lines.push(`### ${row.label} · ${row.title}`);
226
+ lines.push(snippetForQuery(row.text, q, 2500));
227
+ }
228
+ }
229
+ return lines.join("\n");
230
+ }
231
+
232
+ intentSearch(indexed: { sourceId: number; chunkCount: number; bytes: number; label: string }, query: string, maxResults = 5): string {
233
+ const rows = this.#db.prepare("SELECT source_id, title, text, label, kind, bm25(chunks_fts) AS score FROM chunks_fts WHERE chunks_fts MATCH ? AND source_id = ? ORDER BY score LIMIT ?")
234
+ .all(query, indexed.sourceId, maxResults) as SearchRow[];
235
+ const kb = (indexed.bytes / 1024).toFixed(1);
236
+ if (rows.length === 0) {
237
+ return [
238
+ `ctx:stored ${indexed.label} (${kb}KB, ${indexed.chunkCount} chunks).`,
239
+ `No sections matched "${query}".`,
240
+ `Use ctx search with source "${indexed.label}".`,
241
+ ].join("\n");
242
+ }
243
+ const lines = [
244
+ `ctx:stored ${indexed.label} (${kb}KB, ${indexed.chunkCount} chunks).`,
245
+ `${rows.length} sections matched "${query}":`,
246
+ "",
247
+ ];
248
+ for (const row of rows) {
249
+ const preview = row.text.split(/\r?\n/).find(Boolean)?.slice(0, 160) ?? "";
250
+ lines.push(`- ${row.title}: ${preview}`);
251
+ }
252
+ lines.push("");
253
+ lines.push(`Use ctx search with source "${indexed.label}" for details.`);
254
+ return lines.join("\n");
255
+ }
256
+
257
+ pointer(indexed: { chunkCount: number; bytes: number; label: string }): string {
258
+ const kb = (indexed.bytes / 1024).toFixed(1);
259
+ return `ctx:stored ${indexed.label} (${kb}KB, ${indexed.chunkCount} chunks).\nUse ctx search with source "${indexed.label}".`;
260
+ }
261
+
262
+ stats(): { sources: number; bytes: number; recent: SourceRow[] } {
263
+ const row = this.#db.prepare("SELECT COUNT(*) AS sources, COALESCE(SUM(bytes), 0) AS bytes FROM sources").get() as { sources: number; bytes: number };
264
+ const recent = this.#db.prepare("SELECT id, kind, label, bytes, created_at FROM sources ORDER BY id DESC LIMIT 8").all() as SourceRow[];
265
+ return { sources: Number(row.sources), bytes: Number(row.bytes), recent };
266
+ }
267
+
268
+ formatStats(): string {
269
+ const stats = this.stats();
270
+ const lines = [`ctx: ${stats.sources} sources, ${(stats.bytes / 1024).toFixed(1)}KB stored`];
271
+ if (stats.recent.length) {
272
+ lines.push("recent:");
273
+ for (const s of stats.recent) lines.push(`- ${s.label} ${s.kind} ${(s.bytes / 1024).toFixed(1)}KB`);
274
+ }
275
+ lines.push("usage: /ctx <query> | /ctx --purge");
276
+ return lines.join("\n");
277
+ }
278
+
279
+ purge(): void {
280
+ this.#db.exec("DELETE FROM chunks_fts; DELETE FROM sources;");
281
+ }
282
+
283
+ close(): void {
284
+ this.#db.close();
285
+ }
286
+ }
287
+
288
+ async function runProcess(command: string, args: string[], opts: { cwd: string; input?: string; env?: NodeJS.ProcessEnv; timeoutMs?: number; signal?: AbortSignal }): Promise<RunResult> {
289
+ return await new Promise((resolve) => {
290
+ let stdout = "";
291
+ let stderr = "";
292
+ let settled = false;
293
+ const child = spawn(command, args, {
294
+ cwd: opts.cwd,
295
+ env: opts.env ?? process.env,
296
+ stdio: [opts.input === undefined ? "ignore" : "pipe", "pipe", "pipe"],
297
+ signal: opts.signal,
298
+ });
299
+ const finish = (result: RunResult) => {
300
+ if (settled) return;
301
+ settled = true;
302
+ if (timer) clearTimeout(timer);
303
+ resolve(result);
304
+ };
305
+ const timer = opts.timeoutMs && opts.timeoutMs > 0 ? setTimeout(() => {
306
+ try { child.kill("SIGTERM"); } catch { /* noop */ }
307
+ finish({ stdout, stderr, exitCode: null, timedOut: true });
308
+ }, opts.timeoutMs) : undefined;
309
+ child.stdout?.setEncoding("utf8");
310
+ child.stderr?.setEncoding("utf8");
311
+ child.stdout?.on("data", (chunk: string) => { stdout += chunk; });
312
+ child.stderr?.on("data", (chunk: string) => { stderr += chunk; });
313
+ child.on("error", (err) => finish({ stdout, stderr: stderr + String(err), exitCode: 1, timedOut: false }));
314
+ child.on("close", (code) => finish({ stdout, stderr, exitCode: code, timedOut: false }));
315
+ if (opts.input !== undefined && child.stdin) {
316
+ child.stdin.end(opts.input);
317
+ }
318
+ });
319
+ }
320
+
321
+ async function withTempFile<T>(prefix: string, suffix: string, content: string, fn: (path: string) => Promise<T>): Promise<T> {
322
+ const dir = await mkdtemp(join(tmpdir(), prefix));
323
+ try {
324
+ const file = join(dir, `script${suffix}`);
325
+ await writeFile(file, content, "utf8");
326
+ return await fn(file);
327
+ } finally {
328
+ await rm(dir, { recursive: true, force: true }).catch(() => {});
329
+ }
330
+ }
331
+
332
+ function resultText(result: RunResult): { text: string; isError: boolean } {
333
+ const isError = result.timedOut || (result.exitCode !== 0 && result.exitCode !== null);
334
+ let text = result.stdout;
335
+ if (result.stderr) text += (text ? "\n" : "") + result.stderr;
336
+ if (result.timedOut) text += `\n(timed out)`;
337
+ if (!text) text = "(no output)";
338
+ return { text, isError };
339
+ }
340
+
341
+ function finalizeOutput(store: CtxStore, source: { kind: string; label: string; metadata?: unknown }, text: string, isError: boolean, query?: string | string[]): ToolPatch {
342
+ const bytes = byteLen(text);
343
+ const queries = normalizeQueries(query);
344
+ if (queries.length && bytes > INTENT_SEARCH_THRESHOLD) {
345
+ const indexed = store.indexText({ ...source, text });
346
+ return { content: [{ type: "text", text: store.intentSearch(indexed, queries.join(" ")) }], isError };
347
+ }
348
+ if (isError && bytes > INTENT_SEARCH_THRESHOLD) {
349
+ const indexed = store.indexText({ ...source, text });
350
+ return { content: [{ type: "text", text: store.intentSearch(indexed, ERROR_QUERY) }], isError };
351
+ }
352
+ if (bytes > LARGE_OUTPUT_THRESHOLD) {
353
+ const indexed = store.indexText({ ...source, text });
354
+ return { content: [{ type: "text", text: store.pointer(indexed) }], isError };
355
+ }
356
+ return { content: [{ type: "text", text: truncateBytes(text) }], isError };
357
+ }
358
+
359
+ export async function executeCtxOperation(store: CtxStore, params: CtxToolParams, cwd: string, signal?: AbortSignal): Promise<ToolPatch> {
360
+ const timeoutMs = "timeoutMs" in params ? params.timeoutMs : undefined;
361
+ const query = "query" in params ? params.query : undefined;
362
+ switch (params.op) {
363
+ case "search": {
364
+ return { content: [{ type: "text", text: store.search(normalizeQueries(params.query), { source: params.source, limit: params.limit }) }] };
365
+ }
366
+ case "run-sh": {
367
+ const r = await runProcess("/bin/sh", ["-c", params.script], { cwd, timeoutMs, signal });
368
+ const { text, isError } = resultText(r);
369
+ return finalizeOutput(store, { kind: "run-sh", label: `run-sh:${shortHash(params.script)}`, metadata: { script: params.script } }, text, isError, query);
370
+ }
371
+ case "run-js": {
372
+ return await withTempFile("pi-ctx-js-", ".js", params.code, async (file) => {
373
+ const r = await runProcess("bun", [file], { cwd, timeoutMs, signal });
374
+ const { text, isError } = resultText(r);
375
+ return finalizeOutput(store, { kind: "run-js", label: `run-js:${shortHash(params.code)}`, metadata: { code: params.code } }, text, isError, query);
376
+ });
377
+ }
378
+ case "run-py": {
379
+ return await withTempFile("pi-ctx-py-", ".py", params.code, async (file) => {
380
+ const r = await runProcess("python3", [file], { cwd, timeoutMs, signal });
381
+ const { text, isError } = resultText(r);
382
+ return finalizeOutput(store, { kind: "run-py", label: `run-py:${shortHash(params.code)}`, metadata: { code: params.code } }, text, isError, query);
383
+ });
384
+ }
385
+ case "file-sh": {
386
+ const path = isAbsolute(params.path) ? params.path : resolve(cwd, params.path);
387
+ const r = await runProcess("/bin/sh", ["-c", params.script], { cwd, timeoutMs, signal, env: { ...process.env, CTX_FILE: path } });
388
+ const { text, isError } = resultText(r);
389
+ return finalizeOutput(store, { kind: "file-sh", label: `file-sh:${params.path}`, metadata: { path: params.path, script: params.script } }, text, isError, query);
390
+ }
391
+ case "file-js": {
392
+ const path = isAbsolute(params.path) ? params.path : resolve(cwd, params.path);
393
+ const wrapper = `const path = ${JSON.stringify(path)};\nconst text = await Bun.file(path).text();\n${params.code}\n`;
394
+ return await withTempFile("pi-ctx-file-js-", ".js", wrapper, async (file) => {
395
+ const r = await runProcess("bun", [file], { cwd, timeoutMs, signal });
396
+ const { text, isError } = resultText(r);
397
+ return finalizeOutput(store, { kind: "file-js", label: `file-js:${params.path}`, metadata: { path: params.path, code: params.code } }, text, isError, query);
398
+ });
399
+ }
400
+ case "file-py": {
401
+ const path = isAbsolute(params.path) ? params.path : resolve(cwd, params.path);
402
+ const wrapper = `path = ${JSON.stringify(path)}\ntext = open(path, 'r', encoding='utf-8', errors='replace').read()\n${params.code}\n`;
403
+ return await withTempFile("pi-ctx-file-py-", ".py", wrapper, async (file) => {
404
+ const r = await runProcess("python3", [file], { cwd, timeoutMs, signal });
405
+ const { text, isError } = resultText(r);
406
+ return finalizeOutput(store, { kind: "file-py", label: `file-py:${params.path}`, metadata: { path: params.path, code: params.code } }, text, isError, query);
407
+ });
408
+ }
409
+ }
410
+ }
411
+
412
+ export function handleBashToolResult(store: CtxStore, event: any): ToolPatch | undefined {
413
+ if (event?.toolName !== "bash") return undefined;
414
+ const visible = textOf(event.content);
415
+ const fullPath = event.details?.fullOutputPath;
416
+ let full = visible;
417
+ if (typeof fullPath === "string") {
418
+ try {
419
+ if (existsSync(fullPath)) full = readFileSync(fullPath, "utf8");
420
+ } catch (err) {
421
+ return {
422
+ content: [{ type: "text", text: `ctx:suppressed bash output; failed to read fullOutputPath (${err instanceof Error ? err.message : String(err)})` }],
423
+ details: { ...(event.details ?? {}), ctxSuppressed: true },
424
+ isError: event.isError,
425
+ };
426
+ }
427
+ }
428
+ const bytes = byteLen(full);
429
+ if (!event.isError && bytes <= LARGE_OUTPUT_THRESHOLD) return undefined;
430
+ if (event.isError && bytes <= INTENT_SEARCH_THRESHOLD) return undefined;
431
+ try {
432
+ const command = String(event.input?.command ?? "bash");
433
+ const label = `${event.isError ? "bash-error" : "bash"}:${shortHash(command)}`;
434
+ const indexed = store.indexText({ kind: event.isError ? "bash-error" : "bash", label, text: full, metadata: { command } });
435
+ const text = event.isError ? store.intentSearch(indexed, ERROR_QUERY) : store.pointer(indexed);
436
+ return {
437
+ content: [{ type: "text", text }],
438
+ details: { ...(event.details ?? {}), ctxStored: true, ctxSource: label },
439
+ isError: event.isError,
440
+ };
441
+ } catch (err) {
442
+ return {
443
+ content: [{ type: "text", text: `ctx:suppressed ${bytes}B bash output; storage failed (${err instanceof Error ? err.message : String(err)})` }],
444
+ details: { ...(event.details ?? {}), ctxSuppressed: true },
445
+ isError: event.isError,
446
+ };
447
+ }
448
+ }
449
+
450
+ function makeStore(ctx?: any): CtxStore {
451
+ const cwd = ctx?.cwd ?? process.cwd();
452
+ return new CtxStore(defaultDbPath(cwd, ctx));
453
+ }
454
+
455
+ const Params = Type.Object({
456
+ op: Type.Union([
457
+ Type.Literal("run-sh"), Type.Literal("run-js"), Type.Literal("run-py"),
458
+ Type.Literal("file-sh"), Type.Literal("file-js"), Type.Literal("file-py"), Type.Literal("search"),
459
+ ], { description: "Operation" }),
460
+ script: Type.Optional(Type.String({ description: "Shell script for run-sh/file-sh" })),
461
+ code: Type.Optional(Type.String({ description: "JS/Python code for run-js/run-py/file-js/file-py" })),
462
+ path: Type.Optional(Type.String({ description: "File path for file-* ops" })),
463
+ query: Type.Optional(Type.Union([Type.String(), Type.Array(Type.String())], { description: "Search query / intent" })),
464
+ source: Type.Optional(Type.String({ description: "Source label substring for search" })),
465
+ limit: Type.Optional(Type.Number({ description: "Search results per query" })),
466
+ timeoutMs: Type.Optional(Type.Number({ description: "Timeout in ms" })),
467
+ });
468
+
469
+ function normalizeParams(raw: any): CtxToolParams {
470
+ if (!raw || typeof raw !== "object") throw new Error("ctx params object required");
471
+ switch (raw.op) {
472
+ case "run-sh": if (typeof raw.script === "string") return raw; break;
473
+ case "run-js": if (typeof raw.code === "string") return raw; break;
474
+ case "run-py": if (typeof raw.code === "string") return raw; break;
475
+ case "file-sh": if (typeof raw.path === "string" && typeof raw.script === "string") return raw; break;
476
+ case "file-js": if (typeof raw.path === "string" && typeof raw.code === "string") return raw; break;
477
+ case "file-py": if (typeof raw.path === "string" && typeof raw.code === "string") return raw; break;
478
+ case "search": if (typeof raw.query === "string" || Array.isArray(raw.query)) return raw; break;
479
+ }
480
+ throw new Error("invalid ctx params for op");
481
+ }
482
+
483
+ export function registerCtxExtension(pi: Pick<ExtensionAPI, "registerTool" | "registerCommand" | "on">): void {
484
+ pi.on("tool_result", (event: any, ctx: any) => {
485
+ let store: CtxStore | undefined;
486
+ try {
487
+ store = makeStore(ctx);
488
+ return handleBashToolResult(store, event);
489
+ } catch (err) {
490
+ const text = textOf(event?.content);
491
+ const maybeLarge = event?.toolName === "bash" && (event?.details?.fullOutputPath || byteLen(text) > LARGE_OUTPUT_THRESHOLD);
492
+ if (!maybeLarge) return undefined;
493
+ return {
494
+ content: [{ type: "text", text: `ctx:suppressed bash output; store unavailable (${err instanceof Error ? err.message : String(err)})` }],
495
+ details: { ...(event.details ?? {}), ctxSuppressed: true },
496
+ isError: event.isError,
497
+ };
498
+ } finally {
499
+ store?.close();
500
+ }
501
+ });
502
+
503
+ pi.registerTool({
504
+ name: "ctx",
505
+ label: "ctx",
506
+ description: "Run JS/Py/sh analysis off-context; search stored large bash output.",
507
+ promptSnippet: "Run JS/Py/sh analysis off-context; search stored large bash output.",
508
+ promptGuidelines: [
509
+ "Use ctx run-* for large-output analysis; print only findings.",
510
+ "Use ctx file-* for large file analysis without reading whole files into context.",
511
+ "Use ctx search when bash output was stored as ctx:stored.",
512
+ ],
513
+ parameters: Params,
514
+ async execute(_toolCallId: string, raw: unknown, signal?: AbortSignal, _onUpdate?: unknown, ctx?: any) {
515
+ const store = makeStore(ctx);
516
+ try {
517
+ const params = normalizeParams(raw);
518
+ return await executeCtxOperation(store, params, ctx?.cwd ?? process.cwd(), signal);
519
+ } catch (err) {
520
+ return { content: [{ type: "text", text: `ctx error: ${err instanceof Error ? err.message : String(err)}` }], details: {}, isError: true };
521
+ } finally {
522
+ store.close();
523
+ }
524
+ },
525
+ });
526
+
527
+ pi.registerCommand("ctx", {
528
+ description: "ctx stats/search/purge",
529
+ handler: async (args: unknown, ctx: any) => {
530
+ const q = String(args ?? "").trim();
531
+ const store = makeStore(ctx);
532
+ try {
533
+ if (q === "--purge") {
534
+ if (ctx?.hasUI) {
535
+ const ok = await ctx.ui.confirm("ctx purge", "Delete ctx index for this project?");
536
+ if (!ok) return { text: "ctx purge cancelled" };
537
+ }
538
+ store.purge();
539
+ return { text: "ctx purged" };
540
+ }
541
+ if (!q) return { text: store.formatStats() };
542
+ return { text: store.search([q], { limit: 5 }) };
543
+ } finally {
544
+ store.close();
545
+ }
546
+ },
547
+ });
548
+ }
549
+
550
+ export default function (pi: ExtensionAPI): void {
551
+ registerCtxExtension(pi);
552
+ }
package/package.json ADDED
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "@bugabinga/pi-ext-ctx",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "main": "index.ts",
6
+ "scripts": {
7
+ "test": "bun test ./__tests__/*.test.ts"
8
+ },
9
+ "peerDependencies": {
10
+ "@earendil-works/pi-coding-agent": "*",
11
+ "@earendil-works/pi-tui": "*",
12
+ "typebox": "*"
13
+ },
14
+ "devDependencies": {
15
+ "@marcfargas/pi-test-harness": "git+ssh://git@github.com/bugabinga/pi-test-harness.git#0cb5ce5f10fb18c9a80e904e2e3310cc98a28e29"
16
+ },
17
+ "license": "MIT",
18
+ "description": "Off-context execution and large-output search for Pi.",
19
+ "keywords": [
20
+ "pi",
21
+ "pi-extension"
22
+ ]
23
+ }