@anton-kochev/pi-extensions 0.1.3 → 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 +2 -0
- package/echo/README.md +98 -0
- package/echo/extensions/index.ts +20 -0
- package/echo/package.json +32 -0
- package/echo/src/echo.ts +722 -0
- package/package.json +10 -3
- package/squiggle/README.md +6 -3
- package/squiggle/extensions/squiggle.ts +71 -9
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
|
+
}
|
package/echo/src/echo.ts
ADDED
|
@@ -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.
|
|
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
|
}
|
package/squiggle/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Quietly polish grammar and spelling in your pi prompts.
|
|
4
4
|
|
|
5
|
-
The extension intercepts user input, corrects spelling and grammar using a configured model, shows a colored diff, and submits the corrected prompt automatically without confirmation. Named after the red squiggle from your favorite spell-checker.
|
|
5
|
+
The extension intercepts user input, shows a `squiggling...` spinner while processing, corrects spelling and grammar using a configured model, shows a colored diff, and submits the corrected prompt automatically without confirmation. Named after the red squiggle from your favorite spell-checker.
|
|
6
6
|
|
|
7
7
|
## Install
|
|
8
8
|
|
|
@@ -68,14 +68,17 @@ SQUIGGLE_MODEL=openai-codex/gpt-5.4-mini pi
|
|
|
68
68
|
SQUIGGLE_MAX_CHARS=1000 pi
|
|
69
69
|
```
|
|
70
70
|
|
|
71
|
-
##
|
|
71
|
+
## Commands
|
|
72
72
|
|
|
73
73
|
Inside pi:
|
|
74
74
|
|
|
75
75
|
```text
|
|
76
|
-
/squiggle
|
|
76
|
+
/squiggle toggle # switch between on/off
|
|
77
|
+
/squiggle-status # show status
|
|
77
78
|
```
|
|
78
79
|
|
|
80
|
+
The toggle state is saved in the current pi session and overrides `.pi/squiggle.json` and environment configuration for that session.
|
|
81
|
+
|
|
79
82
|
## Notes
|
|
80
83
|
|
|
81
84
|
This package imports pi runtime packages as peer dependencies:
|
|
@@ -4,23 +4,44 @@ import { complete, type UserMessage } from "@earendil-works/pi-ai";
|
|
|
4
4
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
5
5
|
|
|
6
6
|
export default function squiggle(pi: ExtensionAPI) {
|
|
7
|
+
let runtimeMode: SquiggleConfig["mode"] | undefined;
|
|
8
|
+
|
|
9
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
10
|
+
runtimeMode = restoreRuntimeMode(ctx);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
pi.registerCommand("squiggle", {
|
|
14
|
+
description: "Toggle squiggle on/off",
|
|
15
|
+
handler: async (args, ctx) => {
|
|
16
|
+
const command = args.trim().toLowerCase();
|
|
17
|
+
if (command !== "toggle") {
|
|
18
|
+
ctx.ui.notify("Usage: /squiggle toggle", "warning");
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const config = loadEffectiveConfig(ctx.cwd, runtimeMode);
|
|
23
|
+
runtimeMode = config.mode === "on" ? "off" : "on";
|
|
24
|
+
persistRuntimeMode(pi, runtimeMode);
|
|
25
|
+
ctx.ui.notify(formatStatus(ctx, loadEffectiveConfig(ctx.cwd, runtimeMode)), "info");
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
|
|
7
29
|
pi.registerCommand("squiggle-status", {
|
|
8
30
|
description: "Show whether squiggle is loaded",
|
|
9
31
|
handler: async (_args, ctx) => {
|
|
10
|
-
|
|
11
|
-
const model = selectCorrectionModel(ctx, config);
|
|
12
|
-
ctx.ui.notify(`squiggle is loaded (${config.mode}, ${formatModel(model)}).`, "info");
|
|
32
|
+
ctx.ui.notify(formatStatus(ctx, loadEffectiveConfig(ctx.cwd, runtimeMode)), "info");
|
|
13
33
|
},
|
|
14
34
|
});
|
|
15
35
|
|
|
16
36
|
pi.on("input", async (event, ctx) => {
|
|
17
37
|
if (event.source === "extension") return { action: "continue" };
|
|
18
38
|
|
|
19
|
-
const config =
|
|
39
|
+
const config = loadEffectiveConfig(ctx.cwd, runtimeMode);
|
|
20
40
|
if (config.mode === "off") return { action: "continue" };
|
|
21
41
|
if (!event.text.trim()) return { action: "continue" };
|
|
22
42
|
|
|
23
|
-
const
|
|
43
|
+
const stopIndicator = startSquiggleIndicator(ctx);
|
|
44
|
+
const corrected = await correctWithModel(event.text, ctx, config).finally(stopIndicator);
|
|
24
45
|
if (!corrected || corrected === event.text) return { action: "continue" };
|
|
25
46
|
|
|
26
47
|
if (ctx.hasUI) ctx.ui.notify(formatColoredDiff(event.text, corrected), "info");
|
|
@@ -100,6 +121,28 @@ function loadConfig(cwd: string): SquiggleConfig {
|
|
|
100
121
|
};
|
|
101
122
|
}
|
|
102
123
|
|
|
124
|
+
function loadEffectiveConfig(cwd: string, runtimeMode: SquiggleConfig["mode"] | undefined): SquiggleConfig {
|
|
125
|
+
const config = loadConfig(cwd);
|
|
126
|
+
return { ...config, mode: runtimeMode ?? config.mode };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function restoreRuntimeMode(ctx: ExtensionContext): SquiggleConfig["mode"] | undefined {
|
|
130
|
+
for (const entry of [...ctx.sessionManager.getEntries()].reverse()) {
|
|
131
|
+
if (entry.type !== "custom" || entry.customType !== "squiggle-mode") continue;
|
|
132
|
+
const data = (entry as { data?: { mode?: unknown } }).data;
|
|
133
|
+
return normalizeMode(data?.mode);
|
|
134
|
+
}
|
|
135
|
+
return undefined;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function persistRuntimeMode(pi: ExtensionAPI, mode: SquiggleConfig["mode"]): void {
|
|
139
|
+
pi.appendEntry("squiggle-mode", { mode });
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function formatStatus(ctx: ExtensionContext, config: SquiggleConfig): string {
|
|
143
|
+
return `squiggle is ${config.mode} (${formatModel(selectCorrectionModel(ctx, config))}).`;
|
|
144
|
+
}
|
|
145
|
+
|
|
103
146
|
function readConfigFile(cwd: string): Partial<SquiggleConfig> {
|
|
104
147
|
const path = join(cwd, ".pi", "squiggle.json");
|
|
105
148
|
if (!existsSync(path)) return {};
|
|
@@ -143,27 +186,46 @@ function formatModel(model: ReturnType<typeof selectCorrectionModel>): string {
|
|
|
143
186
|
return model ? `${model.provider}/${model.id}` : "no model";
|
|
144
187
|
}
|
|
145
188
|
|
|
189
|
+
function startSquiggleIndicator(ctx: ExtensionContext): () => void {
|
|
190
|
+
if (!ctx.hasUI) return () => {};
|
|
191
|
+
|
|
192
|
+
const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
193
|
+
let frame = 0;
|
|
194
|
+
let timer: ReturnType<typeof setInterval> | undefined;
|
|
195
|
+
|
|
196
|
+
const render = () => {
|
|
197
|
+
const theme = ctx.ui.theme;
|
|
198
|
+
ctx.ui.setStatus("squiggle", theme.fg("accent", frames[frame]!) + theme.fg("dim", " squiggling..."));
|
|
199
|
+
frame = (frame + 1) % frames.length;
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
render();
|
|
203
|
+
timer = setInterval(render, 120);
|
|
204
|
+
|
|
205
|
+
return () => {
|
|
206
|
+
if (timer) clearInterval(timer);
|
|
207
|
+
ctx.ui.setStatus("squiggle", undefined);
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
146
211
|
type DiffOp = {
|
|
147
212
|
type: "same" | "add" | "remove";
|
|
148
213
|
text: string;
|
|
149
214
|
};
|
|
150
215
|
|
|
151
216
|
function formatColoredDiff(before: string, after: string): string {
|
|
152
|
-
const dim = "\x1b[90;3m";
|
|
153
217
|
const same = "\x1b[90;3m";
|
|
154
218
|
const added = "\x1b[32;3m";
|
|
155
219
|
const removed = "\x1b[31;3m";
|
|
156
220
|
const reset = "\x1b[0m";
|
|
157
221
|
|
|
158
|
-
|
|
222
|
+
return diffChars(before.trim(), after.trim())
|
|
159
223
|
.map((op) => {
|
|
160
224
|
if (op.type === "add") return `${added}${op.text}${reset}`;
|
|
161
225
|
if (op.type === "remove") return `${removed}${op.text}${reset}`;
|
|
162
226
|
return `${same}${op.text}${reset}`;
|
|
163
227
|
})
|
|
164
228
|
.join("");
|
|
165
|
-
|
|
166
|
-
return `${dim}squiggle:${reset}\n${rendered}`;
|
|
167
229
|
}
|
|
168
230
|
|
|
169
231
|
function diffChars(before: string, after: string): DiffOp[] {
|