@anton-kochev/pi-extensions 0.2.0 → 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 CHANGED
@@ -19,6 +19,7 @@ pi install npm:@anton-kochev/pi-extensions@<version>
19
19
  ## Extensions
20
20
 
21
21
  - [`squiggle/`](./squiggle) — quietly polish grammar and spelling in user prompts.
22
+ - [`echo/`](./echo) — read-only side-channel question asker for pi sessions and project code.
22
23
 
23
24
  ## Local development
24
25
 
@@ -26,6 +27,7 @@ From a checkout of this repo:
26
27
 
27
28
  ```bash
28
29
  pi install -l ./squiggle
30
+ pi install -l ./echo
29
31
  ```
30
32
 
31
33
  Each subdirectory has its own `package.json` so individual extensions remain installable in isolation.
package/echo/README.md ADDED
@@ -0,0 +1,98 @@
1
+ # echo
2
+
3
+ A read-only side-channel question asker for pi sessions and project code.
4
+
5
+ Echo adds a `/ask` command that spawns an isolated pi side-process with only read-only tools enabled. Answers are shown to you and stored in extension history, but they are not injected into the main agent context — so you can probe the session and project without polluting the conversation that's doing real work.
6
+
7
+ ## Install
8
+
9
+ This extension ships as part of the [`pi-extensions`](https://github.com/anton-kochev/pi-extensions) repository. The simplest install path is via the root pi-package — see the [repo README](../README.md).
10
+
11
+ For local development from a checkout of `pi-extensions`:
12
+
13
+ ```bash
14
+ pi install ./echo
15
+ ```
16
+
17
+ Project-local install:
18
+
19
+ ```bash
20
+ pi install ./echo -l
21
+ ```
22
+
23
+ Temporary test run:
24
+
25
+ ```bash
26
+ pi -e ./echo
27
+ ```
28
+
29
+ ## Pithos `.pithos` config
30
+
31
+ ```yaml
32
+ pi:
33
+ extensions:
34
+ echo: "git:https://github.com/anton-kochev/pi-extensions.git#main"
35
+ ```
36
+
37
+ Pin to a tag for reproducibility:
38
+
39
+ ```yaml
40
+ pi:
41
+ extensions:
42
+ echo: "git:https://github.com/anton-kochev/pi-extensions.git#v0.1.0"
43
+ ```
44
+
45
+ ## Commands
46
+
47
+ Inside pi:
48
+
49
+ ```text
50
+ /ask [--model <provider/model>] [--] <question>
51
+ /asked
52
+ /ask-clear
53
+ ```
54
+
55
+ - `/ask` — ask Echo a question.
56
+ - `/asked` — browse previous Echo answers interactively with ↑/↓ and open one with Enter.
57
+ - `/ask-clear` — clears any stale Echo widget left by older versions.
58
+
59
+ ## What Echo can access
60
+
61
+ Echo launches an isolated Pi side process with only read-only tools enabled:
62
+
63
+ - `read`
64
+ - `grep`
65
+ - `find`
66
+ - `ls`
67
+
68
+ It does **not** receive `bash`, `edit`, or `write`. Echo runs from the current project directory, so it can inspect source code and project files read-only.
69
+
70
+ ## Session context strategy
71
+
72
+ Echo uses progressive disclosure to keep token usage low:
73
+
74
+ 1. A small recent-session excerpt is included in the side-agent prompt.
75
+ 2. The full current-session transcript is written to a temporary markdown file.
76
+ 3. The side agent is instructed to inspect that transcript only when needed, preferably with targeted `grep`/`read` calls.
77
+ 4. The temporary transcript is deleted after the side process exits.
78
+
79
+ ## Examples
80
+
81
+ ```text
82
+ /ask what is this session about?
83
+ /ask what files define the extension loading behavior?
84
+ /ask --model anthropic/claude-haiku-4-5 summarize the recent decisions
85
+ /asked
86
+ ```
87
+
88
+ ## Notes
89
+
90
+ This package imports pi runtime packages as peer dependencies:
91
+
92
+ - `@earendil-works/pi-coding-agent`
93
+ - `@earendil-works/pi-tui`
94
+ - `typebox`
95
+
96
+ Do not bundle those dependencies; pi provides them at runtime.
97
+
98
+ `extensions/index.ts` is a thin jiti trampoline that disables jiti's module cache for `src/echo.ts`, so editing the implementation and running `/reload` always evaluates the newest code.
@@ -0,0 +1,20 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { createJiti } from "jiti";
3
+
4
+ /**
5
+ * Thin jiti trampoline.
6
+ *
7
+ * Pi already loads extensions through jiti, but this wrapper disables jiti's
8
+ * module cache for the implementation module so editing src/echo.ts and
9
+ * running /reload always evaluates the newest code.
10
+ */
11
+ export default async function echoHotReload(pi: ExtensionAPI) {
12
+ const jiti = createJiti(import.meta.url, {
13
+ moduleCache: false,
14
+ fsCache: false,
15
+ });
16
+
17
+ const mod = await jiti.import<typeof import("../src/echo")>("../src/echo.ts");
18
+ const factory = mod.default;
19
+ return factory(pi);
20
+ }
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "echo",
3
+ "version": "0.1.0",
4
+ "description": "Echo: a read-only side-channel question asker for pi sessions and project code.",
5
+ "keywords": [
6
+ "pi-package",
7
+ "pi-extension",
8
+ "echo",
9
+ "ask",
10
+ "read-only"
11
+ ],
12
+ "license": "MIT",
13
+ "type": "module",
14
+ "dependencies": {
15
+ "jiti": "^2.4.2"
16
+ },
17
+ "peerDependencies": {
18
+ "@earendil-works/pi-coding-agent": "*",
19
+ "@earendil-works/pi-tui": "*",
20
+ "typebox": "*"
21
+ },
22
+ "pi": {
23
+ "extensions": [
24
+ "./extensions"
25
+ ]
26
+ },
27
+ "files": [
28
+ "extensions",
29
+ "src",
30
+ "README.md"
31
+ ]
32
+ }
@@ -0,0 +1,722 @@
1
+ import { spawn } from "node:child_process";
2
+ import * as fs from "node:fs";
3
+ import * as os from "node:os";
4
+ import * as path from "node:path";
5
+ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
6
+ import { BorderedLoader, DynamicBorder, getMarkdownTheme } from "@earendil-works/pi-coding-agent";
7
+ import { Container, Markdown, matchesKey, SelectList, Spacer, Text, type SelectItem } from "@earendil-works/pi-tui";
8
+
9
+ type QaUsage = {
10
+ input: number;
11
+ output: number;
12
+ cacheRead: number;
13
+ cacheWrite: number;
14
+ cost: number;
15
+ turns: number;
16
+ };
17
+
18
+ type AskOptions = {
19
+ question: string;
20
+ model?: string;
21
+ };
22
+
23
+ type QaResult = {
24
+ question: string;
25
+ answer: string;
26
+ exitCode: number;
27
+ stderr: string;
28
+ model?: string;
29
+ usage: QaUsage;
30
+ tools?: string[];
31
+ sessionSnapshot?: {
32
+ entries: number;
33
+ truncated: boolean;
34
+ };
35
+ options?: Omit<AskOptions, "question">;
36
+ stopReason?: string;
37
+ errorMessage?: string;
38
+ };
39
+
40
+ const HISTORY_CUSTOM_TYPE = "echo.history";
41
+ const READ_ONLY_TOOLS = ["read", "grep", "find", "ls"];
42
+ const INLINE_RECENT_MESSAGES = 8;
43
+ const MAX_INLINE_SESSION_CHARS = 12_000;
44
+ const MAX_INLINE_MESSAGE_CHARS = 2_500;
45
+ const MAX_SNAPSHOT_FILE_CHARS = 2_000_000;
46
+ const MAX_SNAPSHOT_MESSAGE_CHARS = 100_000;
47
+
48
+ const ASK_HELP = `Usage: /ask [options] [--] question
49
+
50
+ Ask an isolated side-channel pi process. Echo receives progressive read-only access to the current session and only has read-only tools: read, grep, find, ls. The answer is shown to you and saved in Echo history, but it is not injected into the main agent context.
51
+
52
+ Options:
53
+ --model <model> Use a specific model (default: current model)
54
+ --help, -h Show this help
55
+
56
+ Examples:
57
+ /ask what did we decide about the API shape?
58
+ /ask what files have we touched so far?
59
+ /ask --model anthropic/claude-haiku-4-5 summarize the open questions`;
60
+
61
+ function formatCount(count: number): string {
62
+ if (count < 1000) return String(count);
63
+ if (count < 10_000) return `${(count / 1000).toFixed(1)}k`;
64
+ if (count < 1_000_000) return `${Math.round(count / 1000)}k`;
65
+ return `${(count / 1_000_000).toFixed(1)}M`;
66
+ }
67
+
68
+ function formatUsage(result: Pick<QaResult, "usage" | "model">): string {
69
+ const parts: string[] = [];
70
+ if (result.usage.turns) parts.push(`${result.usage.turns} turn${result.usage.turns === 1 ? "" : "s"}`);
71
+ if (result.usage.input) parts.push(`↑${formatCount(result.usage.input)}`);
72
+ if (result.usage.output) parts.push(`↓${formatCount(result.usage.output)}`);
73
+ if (result.usage.cacheRead) parts.push(`R${formatCount(result.usage.cacheRead)}`);
74
+ if (result.usage.cacheWrite) parts.push(`W${formatCount(result.usage.cacheWrite)}`);
75
+ if (result.usage.cost) parts.push(`$${result.usage.cost.toFixed(4)}`);
76
+ if (result.model) parts.push(result.model);
77
+ return parts.join(" ");
78
+ }
79
+
80
+ function tokenizeArgs(input: string): string[] {
81
+ const tokens: string[] = [];
82
+ let current = "";
83
+ let quote: "'" | '"' | undefined;
84
+ let escaping = false;
85
+
86
+ for (const char of input) {
87
+ if (escaping) {
88
+ current += char;
89
+ escaping = false;
90
+ continue;
91
+ }
92
+ if (char === "\\") {
93
+ escaping = true;
94
+ continue;
95
+ }
96
+ if (quote) {
97
+ if (char === quote) quote = undefined;
98
+ else current += char;
99
+ continue;
100
+ }
101
+ if (char === "'" || char === '"') {
102
+ quote = char;
103
+ continue;
104
+ }
105
+ if (/\s/.test(char)) {
106
+ if (current) {
107
+ tokens.push(current);
108
+ current = "";
109
+ }
110
+ continue;
111
+ }
112
+ current += char;
113
+ }
114
+
115
+ if (escaping) current += "\\";
116
+ if (current) tokens.push(current);
117
+ return tokens;
118
+ }
119
+
120
+ type ParseResult =
121
+ | { type: "ok"; options: AskOptions }
122
+ | { type: "help" }
123
+ | { type: "error"; message: string };
124
+
125
+ function parseAskArgs(rawArgs: string): ParseResult {
126
+ const tokens = tokenizeArgs(rawArgs.trim());
127
+ let model: string | undefined;
128
+ let questionTokens: string[] = [];
129
+
130
+ for (let i = 0; i < tokens.length; i++) {
131
+ const token = tokens[i];
132
+ if (token === "--") {
133
+ questionTokens = tokens.slice(i + 1);
134
+ break;
135
+ }
136
+ if (token === "--help" || token === "-h") return { type: "help" };
137
+ if (token === "--model") {
138
+ const value = tokens[++i];
139
+ if (!value) return { type: "error", message: `${token} requires a value` };
140
+ model = value;
141
+ continue;
142
+ }
143
+ if (token.startsWith("--model=")) {
144
+ model = token.slice("--model=".length);
145
+ if (!model) return { type: "error", message: "--model requires a value" };
146
+ continue;
147
+ }
148
+ if (token.startsWith("--")) return { type: "error", message: `Unknown /ask option: ${token}` };
149
+
150
+ questionTokens = tokens.slice(i);
151
+ break;
152
+ }
153
+
154
+ return {
155
+ type: "ok",
156
+ options: {
157
+ question: questionTokens.join(" ").trim(),
158
+ model,
159
+ },
160
+ };
161
+ }
162
+
163
+ function getPiInvocation(args: string[]): { command: string; args: string[] } {
164
+ const currentScript = process.argv[1];
165
+ const isBunVirtualScript = currentScript?.startsWith("/$bunfs/root/");
166
+ if (currentScript && !isBunVirtualScript && fs.existsSync(currentScript)) {
167
+ return { command: process.execPath, args: [currentScript, ...args] };
168
+ }
169
+
170
+ const execName = path.basename(process.execPath).toLowerCase();
171
+ const isGenericRuntime = /^(node|bun)(\.exe)?$/.test(execName);
172
+ if (!isGenericRuntime) return { command: process.execPath, args };
173
+ return { command: "pi", args };
174
+ }
175
+
176
+ function truncateText(text: string, maxChars: number): { text: string; truncated: boolean } {
177
+ if (text.length <= maxChars) return { text, truncated: false };
178
+ const head = Math.floor(maxChars * 0.35);
179
+ const tail = Math.max(0, maxChars - head - 80);
180
+ return {
181
+ text: `${text.slice(0, head)}\n\n[... ${text.length - head - tail} characters omitted ...]\n\n${text.slice(-tail)}`,
182
+ truncated: true,
183
+ };
184
+ }
185
+
186
+ function contentToText(content: any): string {
187
+ if (typeof content === "string") return content;
188
+ if (!Array.isArray(content)) return JSON.stringify(content ?? "");
189
+ return content
190
+ .map((part) => {
191
+ if (part?.type === "text") return part.text ?? "";
192
+ if (part?.type === "thinking") return "[thinking omitted]";
193
+ if (part?.type === "image") return `[image: ${part.mimeType ?? part.mediaType ?? "unknown"}]`;
194
+ if (part?.type === "toolCall") return `[tool call: ${part.name} ${JSON.stringify(part.arguments ?? {})}]`;
195
+ return JSON.stringify(part);
196
+ })
197
+ .filter(Boolean)
198
+ .join("\n");
199
+ }
200
+
201
+ function messageToTranscript(message: any): string {
202
+ switch (message?.role) {
203
+ case "user":
204
+ return `User:\n${contentToText(message.content)}`;
205
+ case "assistant":
206
+ return `Assistant${message.model ? ` (${message.model})` : ""}:\n${contentToText(message.content)}`;
207
+ case "toolResult":
208
+ return `Tool result (${message.toolName ?? "tool"}${message.isError ? ", error" : ""}):\n${contentToText(message.content)}`;
209
+ case "bashExecution":
210
+ return `User bash (${message.exitCode ?? "unknown"}): ${message.command}\n${message.output ?? ""}`;
211
+ case "custom":
212
+ return message.display ? `Custom (${message.customType ?? "extension"}):\n${contentToText(message.content)}` : "";
213
+ case "branchSummary":
214
+ return `Branch summary:\n${message.summary ?? ""}`;
215
+ case "compactionSummary":
216
+ return `Compaction summary:\n${message.summary ?? ""}`;
217
+ default:
218
+ return JSON.stringify(message ?? {});
219
+ }
220
+ }
221
+
222
+ function getSessionMessages(ctx: ExtensionCommandContext): any[] {
223
+ const manager = ctx.sessionManager as any;
224
+ try {
225
+ const built = manager.buildSessionContext?.();
226
+ if (built?.messages && Array.isArray(built.messages)) return built.messages;
227
+ } catch {
228
+ // Fall back to branch serialization below.
229
+ }
230
+ return manager
231
+ .getBranch()
232
+ .filter((entry: any) => entry.type === "message")
233
+ .map((entry: any) => entry.message);
234
+ }
235
+
236
+ type SessionSnapshot = {
237
+ fileText: string;
238
+ inlineText: string;
239
+ entries: number;
240
+ recentCount: number;
241
+ fileTruncated: boolean;
242
+ inlineTruncated: boolean;
243
+ };
244
+
245
+ function buildSessionSnapshot(ctx: ExtensionCommandContext): SessionSnapshot {
246
+ const messages = getSessionMessages(ctx);
247
+ let fileTruncated = false;
248
+ let inlineTruncated = false;
249
+
250
+ const renderSection = (message: any, index: number, maxChars: number) => {
251
+ const rendered = messageToTranscript(message).trim();
252
+ if (!rendered) return "";
253
+ const clipped = truncateText(rendered, maxChars);
254
+ return {
255
+ text: `### ${index + 1}. ${message?.role ?? "entry"}\n${clipped.text}`,
256
+ truncated: clipped.truncated,
257
+ };
258
+ };
259
+
260
+ const fullParts = messages
261
+ .map((message, index) => {
262
+ const section = renderSection(message, index, MAX_SNAPSHOT_MESSAGE_CHARS);
263
+ if (!section) return "";
264
+ if (section.truncated) fileTruncated = true;
265
+ return section.text;
266
+ })
267
+ .filter(Boolean);
268
+
269
+ let fileText = fullParts.join("\n\n---\n\n") || "(current session is empty)";
270
+ const clippedFile = truncateText(fileText, MAX_SNAPSHOT_FILE_CHARS);
271
+ if (clippedFile.truncated) fileTruncated = true;
272
+ fileText = clippedFile.text;
273
+
274
+ const recentStart = Math.max(0, messages.length - INLINE_RECENT_MESSAGES);
275
+ const recentMessages = messages.slice(recentStart);
276
+ const inlineParts = recentMessages
277
+ .map((message, offset) => {
278
+ const section = renderSection(message, recentStart + offset, MAX_INLINE_MESSAGE_CHARS);
279
+ if (!section) return "";
280
+ if (section.truncated) inlineTruncated = true;
281
+ return section.text;
282
+ })
283
+ .filter(Boolean);
284
+
285
+ let inlineText = inlineParts.join("\n\n---\n\n") || "(current session is empty)";
286
+ const clippedInline = truncateText(inlineText, MAX_INLINE_SESSION_CHARS);
287
+ if (clippedInline.truncated) inlineTruncated = true;
288
+ inlineText = clippedInline.text;
289
+
290
+ return {
291
+ fileText,
292
+ inlineText,
293
+ entries: messages.length,
294
+ recentCount: recentMessages.length,
295
+ fileTruncated,
296
+ inlineTruncated,
297
+ };
298
+ }
299
+
300
+ async function writeSessionSnapshotFile(snapshot: SessionSnapshot): Promise<{ dir: string; filePath: string }> {
301
+ const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "pi-ask-session-"));
302
+ const filePath = path.join(dir, "current-session.md");
303
+ const header = [
304
+ "# Current session snapshot for /ask",
305
+ "",
306
+ `- Messages: ${snapshot.entries}`,
307
+ `- Snapshot truncated: ${snapshot.fileTruncated ? "yes" : "no"}`,
308
+ `- Generated: ${new Date().toISOString()}`,
309
+ "",
310
+ "This temporary transcript is read-only context for the isolated /ask side agent.",
311
+ "",
312
+ ].join("\n");
313
+ await fs.promises.writeFile(filePath, header + snapshot.fileText, { encoding: "utf8", mode: 0o600 });
314
+ return { dir, filePath };
315
+ }
316
+
317
+ function buildPrompt(options: AskOptions, snapshot: SessionSnapshot, snapshotFilePath: string): string {
318
+ return [
319
+ "Answer this user question in an isolated side-channel session.",
320
+ "You have read-only access only. You may inspect project files with read, grep, find, and ls, but you must not modify files or run shell commands.",
321
+ "Use progressive disclosure for the main session context:",
322
+ "1. Start with the recent-session excerpt included below.",
323
+ `2. If the question needs older or more precise conversation history, inspect the full temporary session transcript at: ${snapshotFilePath}`,
324
+ "3. Prefer grep/find/read targeted ranges over reading the whole transcript when possible.",
325
+ "Do not mention the temporary transcript path unless the user asks for implementation details.",
326
+ "This side-channel answer will be shown to the user only; it will not be injected into the main agent context.",
327
+ "If you cannot answer confidently, say what information is missing rather than guessing.",
328
+ "",
329
+ `Recent session excerpt (${snapshot.recentCount} of ${snapshot.entries} messages${snapshot.inlineTruncated ? ", truncated" : ""}):`,
330
+ "<recent_session>",
331
+ snapshot.inlineText,
332
+ "</recent_session>",
333
+ "",
334
+ `Question: ${options.question}`,
335
+ ].join("\n");
336
+ }
337
+
338
+ async function runSideQuestion(
339
+ options: AskOptions,
340
+ ctx: ExtensionCommandContext,
341
+ signal?: AbortSignal,
342
+ ): Promise<QaResult> {
343
+ const args = [
344
+ "--mode",
345
+ "json",
346
+ "-p",
347
+ "--no-session",
348
+ "--no-extensions",
349
+ "--no-context-files",
350
+ "--no-skills",
351
+ "--no-prompt-templates",
352
+ "--tools",
353
+ READ_ONLY_TOOLS.join(","),
354
+ ];
355
+
356
+ const selectedModel = options.model ?? (ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined);
357
+ if (selectedModel) args.push("--model", selectedModel);
358
+
359
+ const sessionSnapshot = buildSessionSnapshot(ctx);
360
+ const snapshotFile = await writeSessionSnapshotFile(sessionSnapshot);
361
+ args.push(buildPrompt(options, sessionSnapshot, snapshotFile.filePath));
362
+
363
+ const result: QaResult = {
364
+ question: options.question,
365
+ answer: "",
366
+ exitCode: 0,
367
+ stderr: "",
368
+ model: selectedModel,
369
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0 },
370
+ tools: [...READ_ONLY_TOOLS],
371
+ sessionSnapshot: { entries: sessionSnapshot.entries, truncated: sessionSnapshot.fileTruncated },
372
+ options: { model: options.model },
373
+ };
374
+
375
+ const invocation = getPiInvocation(args);
376
+ let wasAborted = false;
377
+
378
+ result.exitCode = await new Promise<number>((resolve) => {
379
+ const proc = spawn(invocation.command, invocation.args, {
380
+ cwd: ctx.cwd,
381
+ shell: false,
382
+ stdio: ["ignore", "pipe", "pipe"],
383
+ env: { ...process.env, PI_SKIP_VERSION_CHECK: "1" },
384
+ });
385
+
386
+ let buffer = "";
387
+ let closed = false;
388
+ let killTimer: NodeJS.Timeout | undefined;
389
+ let abortHandler: (() => void) | undefined;
390
+
391
+ const cleanup = () => {
392
+ closed = true;
393
+ if (killTimer) clearTimeout(killTimer);
394
+ if (abortHandler) signal?.removeEventListener("abort", abortHandler);
395
+ };
396
+
397
+ const processLine = (line: string) => {
398
+ if (!line.trim()) return;
399
+ let event: any;
400
+ try {
401
+ event = JSON.parse(line);
402
+ } catch {
403
+ return;
404
+ }
405
+
406
+ if (event.type === "message_update" && event.assistantMessageEvent?.type === "text_delta") {
407
+ result.answer += event.assistantMessageEvent.delta ?? "";
408
+ }
409
+
410
+ if (event.type === "message_end" && event.message?.role === "assistant") {
411
+ result.usage.turns++;
412
+ result.model = event.message.model ?? result.model;
413
+ result.stopReason = event.message.stopReason ?? result.stopReason;
414
+ result.errorMessage = event.message.errorMessage ?? result.errorMessage;
415
+ const usage = event.message.usage;
416
+ if (usage) {
417
+ result.usage.input += usage.input || 0;
418
+ result.usage.output += usage.output || 0;
419
+ result.usage.cacheRead += usage.cacheRead || 0;
420
+ result.usage.cacheWrite += usage.cacheWrite || 0;
421
+ result.usage.cost += usage.cost?.total || 0;
422
+ }
423
+
424
+ if (!result.answer.trim() && Array.isArray(event.message.content)) {
425
+ result.answer = event.message.content
426
+ .filter((part: any) => part?.type === "text" && typeof part.text === "string")
427
+ .map((part: any) => part.text)
428
+ .join("\n");
429
+ }
430
+ }
431
+
432
+ if (event.type === "error") {
433
+ result.errorMessage = event.error?.message ?? event.message ?? result.errorMessage;
434
+ }
435
+ };
436
+
437
+ proc.stdout.on("data", (data) => {
438
+ buffer += data.toString();
439
+ const lines = buffer.split("\n");
440
+ buffer = lines.pop() ?? "";
441
+ for (const line of lines) processLine(line);
442
+ });
443
+
444
+ proc.stderr.on("data", (data) => {
445
+ result.stderr += data.toString();
446
+ });
447
+
448
+ proc.on("close", (code) => {
449
+ cleanup();
450
+ if (buffer.trim()) processLine(buffer);
451
+ resolve(code ?? 0);
452
+ });
453
+
454
+ proc.on("error", (error) => {
455
+ cleanup();
456
+ result.stderr += String(error?.message ?? error);
457
+ resolve(1);
458
+ });
459
+
460
+ const kill = () => {
461
+ if (closed) return;
462
+ wasAborted = true;
463
+ proc.kill("SIGTERM");
464
+ killTimer = setTimeout(() => {
465
+ if (!closed) proc.kill("SIGKILL");
466
+ }, 3000);
467
+ killTimer.unref?.();
468
+ };
469
+
470
+ abortHandler = kill;
471
+ if (signal?.aborted) kill();
472
+ else signal?.addEventListener("abort", kill, { once: true });
473
+ });
474
+
475
+ try {
476
+ await fs.promises.rm(snapshotFile.dir, { recursive: true, force: true });
477
+ } catch {
478
+ // Best-effort cleanup of the temporary session transcript.
479
+ }
480
+
481
+ if (wasAborted) throw new Error("Echo was aborted");
482
+ result.answer = result.answer.trim();
483
+ if (result.exitCode !== 0 && !result.answer) {
484
+ throw new Error(
485
+ result.errorMessage || result.stderr.trim().split("\n").slice(-4).join("\n") || `Side agent exited with ${result.exitCode}`,
486
+ );
487
+ }
488
+ return result;
489
+ }
490
+
491
+ async function showMarkdown(title: string, markdown: string, ctx: ExtensionCommandContext) {
492
+ if (!ctx.hasUI) {
493
+ console.log(markdown);
494
+ return;
495
+ }
496
+
497
+ await ctx.ui.custom((_tui, theme, _kb, done) => {
498
+ const container = new Container();
499
+ const border = new DynamicBorder((s: string) => theme.fg("accent", s));
500
+ container.addChild(border);
501
+ container.addChild(new Text(theme.fg("accent", theme.bold(title)), 1, 0));
502
+ container.addChild(new Spacer(1));
503
+ container.addChild(new Markdown(markdown, 1, 0, getMarkdownTheme()));
504
+ container.addChild(new Text(theme.fg("dim", "Press Enter or Esc to close"), 1, 0));
505
+ container.addChild(border);
506
+
507
+ return {
508
+ render: (width: number) => container.render(width),
509
+ invalidate: () => container.invalidate(),
510
+ handleInput: (data: string) => {
511
+ if (matchesKey(data, "enter") || matchesKey(data, "escape")) done(undefined);
512
+ return true;
513
+ },
514
+ };
515
+ });
516
+ }
517
+
518
+ async function showAnswer(result: QaResult, ctx: ExtensionCommandContext) {
519
+ if (!ctx.hasUI) {
520
+ console.log(result.answer);
521
+ return;
522
+ }
523
+
524
+ const usage = formatUsage(result);
525
+ await ctx.ui.custom((_tui, theme, _kb, done) => {
526
+ const container = new Container();
527
+ const border = new DynamicBorder((s: string) => theme.fg("accent", s));
528
+ container.addChild(border);
529
+ container.addChild(new Text(theme.fg("accent", theme.bold("Echo (isolated)")), 1, 0));
530
+ container.addChild(new Text(theme.fg("muted", `Q: ${result.question}`), 1, 0));
531
+ container.addChild(new Text(theme.fg("dim", `Tools: ${result.tools?.join(",") || "none"}`), 1, 0));
532
+ container.addChild(new Spacer(1));
533
+ container.addChild(new Markdown(result.answer || "(no answer)", 1, 0, getMarkdownTheme()));
534
+ if (result.errorMessage) {
535
+ container.addChild(new Spacer(1));
536
+ container.addChild(new Text(theme.fg("error", result.errorMessage), 1, 0));
537
+ }
538
+ if (result.stderr.trim()) {
539
+ container.addChild(new Spacer(1));
540
+ container.addChild(new Text(theme.fg("warning", result.stderr.trim().split("\n").slice(-4).join("\n")), 1, 0));
541
+ }
542
+ if (usage) container.addChild(new Text(theme.fg("dim", usage), 1, 0));
543
+ container.addChild(new Text(theme.fg("dim", "Press Enter or Esc to close"), 1, 0));
544
+ container.addChild(border);
545
+
546
+ return {
547
+ render: (width: number) => container.render(width),
548
+ invalidate: () => container.invalidate(),
549
+ handleInput: (data: string) => {
550
+ if (matchesKey(data, "enter") || matchesKey(data, "escape")) done(undefined);
551
+ return true;
552
+ },
553
+ };
554
+ });
555
+ }
556
+
557
+ async function askWithOptionalLoader(options: AskOptions, ctx: ExtensionCommandContext): Promise<QaResult | null> {
558
+ if (!ctx.hasUI) return runSideQuestion(options, ctx, ctx.signal);
559
+
560
+ type LoaderResult = QaResult | { error: string } | null;
561
+ const modelLabel = options.model ?? (ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : "default model");
562
+ const toolsLabel = READ_ONLY_TOOLS.join(",");
563
+
564
+ const loaderResult = await ctx.ui.custom<LoaderResult>((tui, theme, _kb, done) => {
565
+ const loader = new BorderedLoader(tui, theme, `Asking isolated agent (${modelLabel}; ${toolsLabel}; current session)...`, {
566
+ cancellable: true,
567
+ });
568
+ let finished = false;
569
+ const finish = (value: LoaderResult) => {
570
+ if (finished) return;
571
+ finished = true;
572
+ done(value);
573
+ };
574
+
575
+ loader.onAbort = () => finish(null);
576
+ runSideQuestion(options, ctx, loader.signal)
577
+ .then((result) => finish(result))
578
+ .catch((error) => {
579
+ if (loader.signal.aborted) finish(null);
580
+ else finish({ error: error instanceof Error ? error.message : String(error) });
581
+ });
582
+
583
+ return loader;
584
+ });
585
+
586
+ if (loaderResult === null) return null;
587
+ if ("error" in loaderResult) throw new Error(loaderResult.error);
588
+ return loaderResult;
589
+ }
590
+
591
+ export default function echo(pi: ExtensionAPI) {
592
+ pi.registerCommand("ask", {
593
+ description: "Ask Echo, an isolated read-only side agent; answer is not added to the main LLM context",
594
+ handler: async (args, ctx) => {
595
+ const parsed = parseAskArgs(args);
596
+ if (parsed.type === "help") {
597
+ await showMarkdown("/ask help", ASK_HELP, ctx);
598
+ return;
599
+ }
600
+ if (parsed.type === "error") {
601
+ if (ctx.hasUI) ctx.ui.notify(`${parsed.message}. Use /ask --help for usage.`, "error");
602
+ else console.error(parsed.message);
603
+ return;
604
+ }
605
+
606
+ const options = parsed.options;
607
+ if (!options.question) {
608
+ const question = ctx.hasUI
609
+ ? await ctx.ui.input("Ask isolated question:", "What do you want to know?")
610
+ : undefined;
611
+ if (!question?.trim()) return;
612
+ options.question = question.trim();
613
+ }
614
+
615
+ ctx.ui.setStatus("echo", "asking…");
616
+ try {
617
+ const result = await askWithOptionalLoader(options, ctx);
618
+ if (!result) {
619
+ if (ctx.hasUI) ctx.ui.notify("Echo cancelled", "info");
620
+ return;
621
+ }
622
+
623
+ pi.appendEntry(HISTORY_CUSTOM_TYPE, { ...result, timestamp: Date.now() });
624
+ if (ctx.hasUI) {
625
+ ctx.ui.setWidget("echo", undefined);
626
+ ctx.ui.setWidget("session-qa", undefined);
627
+ }
628
+ await showAnswer(result, ctx);
629
+ } catch (error) {
630
+ const message = error instanceof Error ? error.message : String(error);
631
+ if (ctx.hasUI) ctx.ui.notify(`Echo failed: ${message}`, "error");
632
+ else console.error(`Echo failed: ${message}`);
633
+ } finally {
634
+ ctx.ui.setStatus("echo", undefined);
635
+ }
636
+ },
637
+ });
638
+
639
+ pi.registerCommand("ask-clear", {
640
+ description: "Hide any stale Echo answer widget",
641
+ handler: async (_args, ctx) => {
642
+ if (ctx.hasUI) {
643
+ ctx.ui.setWidget("echo", undefined);
644
+ ctx.ui.setWidget("session-qa", undefined);
645
+ }
646
+ },
647
+ });
648
+
649
+ const showAskedHistory = async (_args: string, ctx: ExtensionCommandContext) => {
650
+ const items = ctx.sessionManager
651
+ .getEntries()
652
+ .filter((entry: any) => entry.type === "custom" && entry.customType === HISTORY_CUSTOM_TYPE)
653
+ .map((entry: any) => entry.data as QaResult & { timestamp?: number });
654
+
655
+ if (items.length === 0) {
656
+ if (ctx.hasUI) ctx.ui.notify("No Echo history yet", "info");
657
+ return;
658
+ }
659
+
660
+ if (!ctx.hasUI) {
661
+ const markdown = [...items]
662
+ .reverse()
663
+ .map((item, index) => {
664
+ const when = item.timestamp ? new Date(item.timestamp).toLocaleString() : "";
665
+ const usage = item.usage ? formatUsage(item) : "";
666
+ const metadata = [when, usage].filter(Boolean).join(" · ");
667
+ return `## ${items.length - index}. ${item.question}${metadata ? `\n_${metadata}_` : ""}\n\n${item.answer}`;
668
+ })
669
+ .join("\n\n---\n\n");
670
+ console.log(markdown);
671
+ return;
672
+ }
673
+
674
+ const newestFirst = [...items].reverse();
675
+ const selectItems: SelectItem[] = newestFirst.map((item, index) => {
676
+ const when = item.timestamp ? new Date(item.timestamp).toLocaleString() : "";
677
+ const usage = item.usage ? formatUsage(item) : "";
678
+ return {
679
+ value: String(index),
680
+ label: item.question,
681
+ description: [when, usage].filter(Boolean).join(" · "),
682
+ };
683
+ });
684
+
685
+ const selectedIndex = await ctx.ui.custom<number | null>((tui, theme, _kb, done) => {
686
+ const container = new Container();
687
+ const border = new DynamicBorder((s: string) => theme.fg("accent", s));
688
+ container.addChild(border);
689
+ container.addChild(new Text(theme.fg("accent", theme.bold("Echo History")), 1, 0));
690
+ const selectList = new SelectList(selectItems, Math.min(selectItems.length, 12), {
691
+ selectedPrefix: (s: string) => theme.fg("accent", s),
692
+ selectedText: (s: string) => theme.fg("accent", s),
693
+ description: (s: string) => theme.fg("muted", s),
694
+ scrollInfo: (s: string) => theme.fg("dim", s),
695
+ noMatch: (s: string) => theme.fg("warning", s),
696
+ });
697
+ selectList.onSelect = (item) => done(Number(item.value));
698
+ selectList.onCancel = () => done(null);
699
+ container.addChild(selectList);
700
+ container.addChild(new Text(theme.fg("dim", "↑↓ navigate • Enter open • Esc cancel"), 1, 0));
701
+ container.addChild(border);
702
+ return {
703
+ render: (width: number) => container.render(width),
704
+ invalidate: () => container.invalidate(),
705
+ handleInput: (data: string) => {
706
+ selectList.handleInput(data);
707
+ tui.requestRender();
708
+ return true;
709
+ },
710
+ };
711
+ });
712
+
713
+ if (selectedIndex === null || selectedIndex === undefined) return;
714
+ await showAnswer(newestFirst[selectedIndex], ctx);
715
+ };
716
+
717
+ pi.registerCommand("asked", {
718
+ description: "Interactively browse previous /ask answers",
719
+ handler: showAskedHistory,
720
+ });
721
+
722
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@anton-kochev/pi-extensions",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Pi extensions.",
5
5
  "keywords": [
6
6
  "pi-package"
@@ -13,18 +13,25 @@
13
13
  },
14
14
  "files": [
15
15
  "squiggle",
16
+ "echo",
16
17
  "README.md"
17
18
  ],
18
19
  "publishConfig": {
19
20
  "access": "public"
20
21
  },
22
+ "dependencies": {
23
+ "jiti": "^2.4.2"
24
+ },
21
25
  "peerDependencies": {
22
26
  "@earendil-works/pi-ai": "*",
23
- "@earendil-works/pi-coding-agent": "*"
27
+ "@earendil-works/pi-coding-agent": "*",
28
+ "@earendil-works/pi-tui": "*",
29
+ "typebox": "*"
24
30
  },
25
31
  "pi": {
26
32
  "extensions": [
27
- "./squiggle/extensions"
33
+ "./squiggle/extensions",
34
+ "./echo/extensions"
28
35
  ]
29
36
  }
30
37
  }