@pithos-kit/squiggle 0.4.1 → 0.6.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 +18 -6
- package/extensions/index.ts +478 -0
- package/extensions/logging.ts +152 -0
- package/index.ts +1 -1
- package/package.json +5 -4
- package/extensions/squiggle.ts +0 -294
package/README.md
CHANGED
|
@@ -39,33 +39,40 @@ pi -e ./pithos.squiggle
|
|
|
39
39
|
```yaml
|
|
40
40
|
pi:
|
|
41
41
|
extensions:
|
|
42
|
-
"@pithos-kit/squiggle": "npm:0.
|
|
42
|
+
"@pithos-kit/squiggle": "npm:0.6.0"
|
|
43
43
|
```
|
|
44
44
|
|
|
45
45
|
## Configuration
|
|
46
46
|
|
|
47
|
-
|
|
47
|
+
Run `/squiggle config` to choose an exact correction model from providers with configured authentication, just like Translate's model picker. If none are available, run `/login` first. The picker opens directly and saves to `<cwd>/.pi/squiggle.json` (honors Pi's configured project directory name). There is no session or user-level model configuration.
|
|
48
|
+
|
|
49
|
+
Model precedence is **`SQUIGGLE_MODEL` > project > default**. An environment override may still take precedence after saving, which the completion message shows. Cancelling the picker changes nothing. Existing file fields are preserved, and malformed files are not overwritten.
|
|
50
|
+
|
|
51
|
+
You can still edit `.pi/squiggle.json` directly:
|
|
48
52
|
|
|
49
53
|
```json
|
|
50
54
|
{
|
|
51
55
|
"mode": "on",
|
|
52
56
|
"model": "openai-codex/gpt-5.4-mini",
|
|
53
|
-
"maxInputChars": 500
|
|
57
|
+
"maxInputChars": 500,
|
|
58
|
+
"timeoutMs": 10000
|
|
54
59
|
}
|
|
55
60
|
```
|
|
56
61
|
|
|
57
62
|
Options:
|
|
58
63
|
|
|
59
64
|
- `mode`: `"on"` or `"off"`
|
|
60
|
-
- `model`: pi model spec in `provider/model` format
|
|
65
|
+
- `model`: exact pi model spec in `provider/model` format (model IDs may contain additional slashes)
|
|
61
66
|
- `maxInputChars`: maximum input length to send to the correction model
|
|
67
|
+
- `timeoutMs`: correction deadline in milliseconds, from `1000` to `60000` (default: `10000`)
|
|
62
68
|
|
|
63
|
-
Environment variables override the config
|
|
69
|
+
Environment variables override the project config (except the session on/off toggle):
|
|
64
70
|
|
|
65
71
|
```bash
|
|
66
72
|
SQUIGGLE_MODE=off pi
|
|
67
73
|
SQUIGGLE_MODEL=openai-codex/gpt-5.4-mini pi
|
|
68
74
|
SQUIGGLE_MAX_CHARS=1000 pi
|
|
75
|
+
SQUIGGLE_TIMEOUT_MS=15000 pi
|
|
69
76
|
```
|
|
70
77
|
|
|
71
78
|
## Commands
|
|
@@ -74,7 +81,8 @@ Inside pi:
|
|
|
74
81
|
|
|
75
82
|
```text
|
|
76
83
|
/squiggle toggle # switch between on/off
|
|
77
|
-
/squiggle
|
|
84
|
+
/squiggle config # choose and save the correction model
|
|
85
|
+
/squiggle --help # show toggle/config usage
|
|
78
86
|
/squiggle-status # show status
|
|
79
87
|
/squiggle-status --help # show status-command usage
|
|
80
88
|
```
|
|
@@ -83,6 +91,10 @@ Inside pi:
|
|
|
83
91
|
|
|
84
92
|
The toggle state is saved in the current pi session and overrides `.pi/squiggle.json` and environment configuration for that session.
|
|
85
93
|
|
|
94
|
+
The default correction model is `openai-codex/gpt-5.4-mini`. Squiggle uses only the exact configured model, never silently substituting the active coding model. `/squiggle-status` shows its exact ID, configuration source, and whether it is missing from the registry. Missing models or failed authentication leave the original prompt unchanged.
|
|
95
|
+
|
|
96
|
+
If authentication or correction exceeds the configured deadline, Squiggle cancels the request, clears the spinner, and submits the original prompt unchanged. Session shutdown, reload, and an active Pi cancellation signal also cancel in-flight correction.
|
|
97
|
+
|
|
86
98
|
## Notes
|
|
87
99
|
|
|
88
100
|
This package imports pi runtime packages as peer dependencies:
|
|
@@ -0,0 +1,478 @@
|
|
|
1
|
+
import { existsSync, readFileSync, mkdirSync, writeFileSync, renameSync, rmSync } from "node:fs";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { complete, type UserMessage } from "@earendil-works/pi-ai";
|
|
5
|
+
import * as piRuntime from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import { createPithosLogger, errorMetadata, modelMetadata, usageMetadata, type PithosLogger } from "./logging.ts";
|
|
8
|
+
|
|
9
|
+
// Older supported Pi releases do not export CONFIG_DIR_NAME.
|
|
10
|
+
const CONFIG_DIR_NAME = (piRuntime as { CONFIG_DIR_NAME?: string }).CONFIG_DIR_NAME ?? ".pi";
|
|
11
|
+
|
|
12
|
+
const SQUIGGLE_HELP = `Usage: /squiggle toggle
|
|
13
|
+
/squiggle config
|
|
14
|
+
|
|
15
|
+
Toggle Squiggle on or off for the current session.
|
|
16
|
+
Configure an authenticated exact correction model for the project.
|
|
17
|
+
Model precedence: SQUIGGLE_MODEL > project > default.
|
|
18
|
+
Cancelling configuration makes no changes.
|
|
19
|
+
|
|
20
|
+
Options:
|
|
21
|
+
--help, -h Show this help`;
|
|
22
|
+
|
|
23
|
+
const SQUIGGLE_STATUS_HELP = `Usage: /squiggle-status
|
|
24
|
+
|
|
25
|
+
Show whether Squiggle is enabled and which correction model it uses.
|
|
26
|
+
|
|
27
|
+
Options:
|
|
28
|
+
--help, -h Show this help`;
|
|
29
|
+
|
|
30
|
+
export type SquiggleConfig = {
|
|
31
|
+
mode: "on" | "off";
|
|
32
|
+
model: string;
|
|
33
|
+
modelScope?: "SQUIGGLE_MODEL" | "project" | "default";
|
|
34
|
+
maxInputChars: number;
|
|
35
|
+
timeoutMs: number;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export const DEFAULT_CORRECTION_TIMEOUT_MS = 10_000;
|
|
39
|
+
export const MIN_CORRECTION_TIMEOUT_MS = 1_000;
|
|
40
|
+
export const MAX_CORRECTION_TIMEOUT_MS = 60_000;
|
|
41
|
+
|
|
42
|
+
type CorrectPrompt = (
|
|
43
|
+
input: string,
|
|
44
|
+
ctx: ExtensionContext,
|
|
45
|
+
config: SquiggleConfig,
|
|
46
|
+
log?: PithosLogger,
|
|
47
|
+
signal?: AbortSignal,
|
|
48
|
+
) => Promise<string | null>;
|
|
49
|
+
|
|
50
|
+
export function registerSquiggle(
|
|
51
|
+
pi: ExtensionAPI,
|
|
52
|
+
correctPrompt: CorrectPrompt = correctWithModel,
|
|
53
|
+
) {
|
|
54
|
+
const log = createPithosLogger();
|
|
55
|
+
log.info("extension.register");
|
|
56
|
+
let runtimeMode: SquiggleConfig["mode"] | undefined;
|
|
57
|
+
let correctionScope = new AbortController();
|
|
58
|
+
const effectiveConfig = (cwd: string): SquiggleConfig => loadEffectiveConfig(cwd, runtimeMode);
|
|
59
|
+
|
|
60
|
+
pi.on("session_start", async (event, ctx) => {
|
|
61
|
+
correctionScope.abort();
|
|
62
|
+
correctionScope = new AbortController();
|
|
63
|
+
runtimeMode = restoreRuntimeMode(ctx);
|
|
64
|
+
log.info("session.start", { reason: event.reason, sessionId: (ctx.sessionManager as { getSessionId?: () => string } | undefined)?.getSessionId?.(), runtimeMode });
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
pi.on("session_shutdown", async () => {
|
|
68
|
+
correctionScope.abort();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
pi.registerCommand("squiggle", {
|
|
72
|
+
description: "Toggle squiggle on/off or configure its correction model",
|
|
73
|
+
getArgumentCompletions: (prefix) => ["toggle", "config", "--help"]
|
|
74
|
+
.filter((value) => value.startsWith(prefix)).map((value) => ({ value, label: value })),
|
|
75
|
+
handler: async (args, ctx) => {
|
|
76
|
+
log.info("command.squiggle", { sessionId: (ctx.sessionManager as { getSessionId?: () => string } | undefined)?.getSessionId?.(), args: args.trim() });
|
|
77
|
+
if (isHelpRequest(args)) return emitHelp(ctx, SQUIGGLE_HELP);
|
|
78
|
+
|
|
79
|
+
const command = args.trim().toLowerCase();
|
|
80
|
+
if (command === "config") {
|
|
81
|
+
const model = await chooseModel(ctx);
|
|
82
|
+
if (!model) return;
|
|
83
|
+
try {
|
|
84
|
+
saveModel(join(ctx.cwd, CONFIG_DIR_NAME, "squiggle.json"), model);
|
|
85
|
+
} catch {
|
|
86
|
+
ctx.ui.notify("Could not save Squiggle configuration. Check the config file and its permissions.", "error");
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
const effective = effectiveConfig(ctx.cwd);
|
|
90
|
+
ctx.ui.notify(`Saved ${model}. ${formatStatus(ctx, effective)}`,
|
|
91
|
+
effective.model === model ? "info" : "warning");
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
if (command !== "toggle") {
|
|
95
|
+
ctx.ui.notify("Usage: /squiggle toggle | config", "warning");
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const config = loadEffectiveConfig(ctx.cwd, runtimeMode);
|
|
100
|
+
runtimeMode = config.mode === "on" ? "off" : "on";
|
|
101
|
+
if (runtimeMode === "off") {
|
|
102
|
+
correctionScope.abort();
|
|
103
|
+
correctionScope = new AbortController();
|
|
104
|
+
}
|
|
105
|
+
persistRuntimeMode(pi, runtimeMode);
|
|
106
|
+
ctx.ui.notify(formatStatus(ctx, effectiveConfig(ctx.cwd)), "info");
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
pi.registerCommand("squiggle-status", {
|
|
111
|
+
description: "Show whether squiggle is loaded",
|
|
112
|
+
handler: async (args, ctx) => {
|
|
113
|
+
log.info("command.squiggle-status", { sessionId: (ctx.sessionManager as { getSessionId?: () => string } | undefined)?.getSessionId?.() });
|
|
114
|
+
if (isHelpRequest(args)) return emitHelp(ctx, SQUIGGLE_STATUS_HELP);
|
|
115
|
+
ctx.ui.notify(formatStatus(ctx, effectiveConfig(ctx.cwd)), "info");
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
pi.on("input", async (event, ctx) => {
|
|
120
|
+
if (event.source === "extension") return { action: "continue" };
|
|
121
|
+
|
|
122
|
+
const config = effectiveConfig(ctx.cwd);
|
|
123
|
+
if (config.mode === "off") return { action: "continue" };
|
|
124
|
+
if (!event.text.trim()) return { action: "continue" };
|
|
125
|
+
|
|
126
|
+
const stopIndicator = startSquiggleIndicator(ctx);
|
|
127
|
+
const cancellationSignal = ctx.signal
|
|
128
|
+
? AbortSignal.any([ctx.signal, correctionScope.signal])
|
|
129
|
+
: correctionScope.signal;
|
|
130
|
+
const corrected = await correctPrompt(event.text, ctx, config, log, cancellationSignal).finally(stopIndicator);
|
|
131
|
+
if (!corrected || corrected === event.text) return { action: "continue" };
|
|
132
|
+
|
|
133
|
+
if (ctx.hasUI) ctx.ui.notify(formatColoredDiff(event.text, corrected), "info");
|
|
134
|
+
return { action: "transform", text: corrected };
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export default function squiggle(pi: ExtensionAPI): void {
|
|
139
|
+
registerSquiggle(pi);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function isHelpRequest(args: string): boolean {
|
|
143
|
+
const normalized = args.trim();
|
|
144
|
+
return normalized === "--help" || normalized === "-h";
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function emitHelp(ctx: ExtensionCommandContext, text: string): void {
|
|
148
|
+
if (ctx.hasUI) ctx.ui.notify(text, "info");
|
|
149
|
+
else console.log(text);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const CORRECTION_PROMPT = `You are a conservative grammar and spelling corrector for user prompts sent to a coding assistant.
|
|
153
|
+
|
|
154
|
+
Task:
|
|
155
|
+
- Correct spelling, grammar, capitalization, and punctuation.
|
|
156
|
+
- Preserve the user's meaning, tone, language, and intent.
|
|
157
|
+
- Do not answer the prompt.
|
|
158
|
+
- Do not add explanations, quotes, prefixes, markdown fences, or alternatives.
|
|
159
|
+
- If the input is already acceptable, return it unchanged.
|
|
160
|
+
- Return only the corrected prompt text.`;
|
|
161
|
+
|
|
162
|
+
const DEFAULT_CORRECTION_MODEL = "openai-codex/gpt-5.4-mini";
|
|
163
|
+
const DEFAULT_MAX_LLM_INPUT_CHARS = 500;
|
|
164
|
+
|
|
165
|
+
type CorrectionInterruption = "cancelled" | "timeout";
|
|
166
|
+
|
|
167
|
+
class CorrectionInterruptedError extends Error {
|
|
168
|
+
readonly kind: CorrectionInterruption;
|
|
169
|
+
|
|
170
|
+
constructor(kind: CorrectionInterruption, timeoutMs?: number) {
|
|
171
|
+
super(kind === "timeout" ? `Correction timed out after ${formatDuration(timeoutMs!)}.` : "Correction cancelled.");
|
|
172
|
+
this.kind = kind;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export async function correctWithModel(
|
|
177
|
+
input: string,
|
|
178
|
+
ctx: ExtensionContext,
|
|
179
|
+
config: SquiggleConfig,
|
|
180
|
+
log = createPithosLogger(),
|
|
181
|
+
signal?: AbortSignal,
|
|
182
|
+
completePrompt: typeof complete = complete,
|
|
183
|
+
): Promise<string | null> {
|
|
184
|
+
const model = selectCorrectionModel(ctx, config);
|
|
185
|
+
if (!model) return null;
|
|
186
|
+
if (input.length > config.maxInputChars) return null;
|
|
187
|
+
|
|
188
|
+
const started = Date.now();
|
|
189
|
+
const sessionId = (ctx.sessionManager as { getSessionId?: () => string } | undefined)?.getSessionId?.();
|
|
190
|
+
log.info("model.correct.start", { sessionId, inputChars: input.length, timeoutMs: config.timeoutMs, ...modelMetadata(model) });
|
|
191
|
+
|
|
192
|
+
if (signal?.aborted) {
|
|
193
|
+
log.warn("model.correct.cancelled", { sessionId, durationMs: Date.now() - started, inputChars: input.length, ...modelMetadata(model) });
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const requestController = new AbortController();
|
|
198
|
+
let interruptionKind: CorrectionInterruption | undefined;
|
|
199
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
200
|
+
let removeAbort: (() => void) | undefined;
|
|
201
|
+
const interruption = new Promise<never>((_resolve, reject) => {
|
|
202
|
+
timer = setTimeout(() => {
|
|
203
|
+
interruptionKind = "timeout";
|
|
204
|
+
requestController.abort();
|
|
205
|
+
reject(new CorrectionInterruptedError("timeout", config.timeoutMs));
|
|
206
|
+
}, config.timeoutMs);
|
|
207
|
+
const onAbort = () => {
|
|
208
|
+
if (interruptionKind) return;
|
|
209
|
+
interruptionKind = "cancelled";
|
|
210
|
+
requestController.abort();
|
|
211
|
+
reject(new CorrectionInterruptedError("cancelled"));
|
|
212
|
+
};
|
|
213
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
214
|
+
removeAbort = () => signal?.removeEventListener("abort", onAbort);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
try {
|
|
218
|
+
const auth = await Promise.race([
|
|
219
|
+
ctx.modelRegistry.getApiKeyAndHeaders(model),
|
|
220
|
+
interruption,
|
|
221
|
+
]);
|
|
222
|
+
if (!auth.ok || !auth.apiKey) {
|
|
223
|
+
log.warn("model.correct.unavailable", { sessionId, durationMs: Date.now() - started, inputChars: input.length, ...modelMetadata(model) });
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const userMessage: UserMessage = {
|
|
228
|
+
role: "user",
|
|
229
|
+
content: [{ type: "text", text: input }],
|
|
230
|
+
timestamp: Date.now(),
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
const response = await Promise.race([
|
|
234
|
+
completePrompt(
|
|
235
|
+
model,
|
|
236
|
+
{ systemPrompt: CORRECTION_PROMPT, messages: [userMessage] },
|
|
237
|
+
{ apiKey: auth.apiKey, headers: auth.headers, signal: requestController.signal },
|
|
238
|
+
),
|
|
239
|
+
interruption,
|
|
240
|
+
]);
|
|
241
|
+
|
|
242
|
+
if (response.stopReason === "aborted") {
|
|
243
|
+
const metadata = { sessionId, durationMs: Date.now() - started, inputChars: input.length, ...modelMetadata(model), usage: usageMetadata(response.usage) };
|
|
244
|
+
if (interruptionKind === "timeout") {
|
|
245
|
+
log.warn("model.correct.timeout", { ...metadata, timeoutMs: config.timeoutMs });
|
|
246
|
+
} else if (interruptionKind === "cancelled" || signal?.aborted) {
|
|
247
|
+
log.warn("model.correct.cancelled", metadata);
|
|
248
|
+
} else {
|
|
249
|
+
log.warn("model.correct.aborted", metadata);
|
|
250
|
+
}
|
|
251
|
+
return null;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const corrected = response.content
|
|
255
|
+
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
|
256
|
+
.map((c) => c.text)
|
|
257
|
+
.join("\n")
|
|
258
|
+
.trim();
|
|
259
|
+
log.info("model.correct.complete", { sessionId, durationMs: Date.now() - started, inputChars: input.length, changed: corrected !== input, ...modelMetadata(model), usage: usageMetadata(response.usage) });
|
|
260
|
+
return corrected;
|
|
261
|
+
} catch (error) {
|
|
262
|
+
if (interruptionKind === "timeout" || (error instanceof CorrectionInterruptedError && error.kind === "timeout")) {
|
|
263
|
+
log.warn("model.correct.timeout", { sessionId, durationMs: Date.now() - started, inputChars: input.length, timeoutMs: config.timeoutMs, ...modelMetadata(model) });
|
|
264
|
+
} else if (interruptionKind === "cancelled" || signal?.aborted || error instanceof CorrectionInterruptedError || (error instanceof Error && error.name === "AbortError")) {
|
|
265
|
+
log.warn("model.correct.cancelled", { sessionId, durationMs: Date.now() - started, inputChars: input.length, ...modelMetadata(model) });
|
|
266
|
+
} else {
|
|
267
|
+
log.warn("model.correct.error", { sessionId, durationMs: Date.now() - started, inputChars: input.length, ...modelMetadata(model), error: errorMetadata(error) });
|
|
268
|
+
}
|
|
269
|
+
return null;
|
|
270
|
+
} finally {
|
|
271
|
+
if (timer) clearTimeout(timer);
|
|
272
|
+
removeAbort?.();
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function formatDuration(timeoutMs: number): string {
|
|
277
|
+
return timeoutMs % 1_000 === 0 ? `${timeoutMs / 1_000}s` : `${timeoutMs}ms`;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function loadConfig(cwd: string): SquiggleConfig {
|
|
281
|
+
const fileConfig = readConfigFile(cwd);
|
|
282
|
+
return {
|
|
283
|
+
mode: normalizeMode(process.env.SQUIGGLE_MODE ?? fileConfig.mode) ?? "on",
|
|
284
|
+
model: process.env.SQUIGGLE_MODEL ?? fileConfig.model ?? DEFAULT_CORRECTION_MODEL,
|
|
285
|
+
modelScope: process.env.SQUIGGLE_MODEL !== undefined ? "SQUIGGLE_MODEL" : fileConfig.model !== undefined ? "project" : "default",
|
|
286
|
+
maxInputChars: normalizePositiveInt(process.env.SQUIGGLE_MAX_CHARS ?? fileConfig.maxInputChars) ?? DEFAULT_MAX_LLM_INPUT_CHARS,
|
|
287
|
+
timeoutMs: normalizeTimeoutMs(process.env.SQUIGGLE_TIMEOUT_MS ?? fileConfig.timeoutMs) ?? DEFAULT_CORRECTION_TIMEOUT_MS,
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function loadEffectiveConfig(cwd: string, runtimeMode: SquiggleConfig["mode"] | undefined): SquiggleConfig {
|
|
292
|
+
const config = loadConfig(cwd);
|
|
293
|
+
return { ...config, mode: runtimeMode ?? config.mode };
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function restoreRuntimeMode(ctx: ExtensionContext): SquiggleConfig["mode"] | undefined {
|
|
297
|
+
for (const entry of [...ctx.sessionManager.getEntries()].reverse()) {
|
|
298
|
+
if (entry.type !== "custom" || entry.customType !== "squiggle-mode") continue;
|
|
299
|
+
const data = (entry as { data?: { mode?: unknown } }).data;
|
|
300
|
+
return normalizeMode(data?.mode);
|
|
301
|
+
}
|
|
302
|
+
return undefined;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function saveModel(path: string, model: string): void {
|
|
306
|
+
const current = existsSync(path) ? JSON.parse(readFileSync(path, "utf8")) : {};
|
|
307
|
+
if (!current || typeof current !== "object" || Array.isArray(current)) throw new Error("Invalid config");
|
|
308
|
+
const temporaryPath = `${path}.${randomUUID()}.tmp`;
|
|
309
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
310
|
+
try {
|
|
311
|
+
writeFileSync(temporaryPath, `${JSON.stringify({ ...current, model }, null, 2)}\n`, { mode: 0o600 });
|
|
312
|
+
renameSync(temporaryPath, path);
|
|
313
|
+
} finally {
|
|
314
|
+
rmSync(temporaryPath, { force: true });
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
async function chooseModel(ctx: ExtensionCommandContext): Promise<string | undefined> {
|
|
319
|
+
if (!ctx.hasUI) {
|
|
320
|
+
emitHelp(ctx, "Squiggle configuration requires an interactive UI.");
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
const models = ctx.modelRegistry.getAvailable()
|
|
324
|
+
.filter((model) => ctx.modelRegistry.hasConfiguredAuth(model))
|
|
325
|
+
.sort((a, b) => `${a.provider}/${a.id}`.localeCompare(`${b.provider}/${b.id}`));
|
|
326
|
+
if (!models.length) {
|
|
327
|
+
ctx.ui.notify("No authenticated correction models are available. Configure a provider with /login first.", "error");
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
const choices = models.map((model) => `${model.provider}/${model.id} — ${model.name}`);
|
|
331
|
+
const selected = await ctx.ui.select("Exact correction model", choices);
|
|
332
|
+
const model = models[choices.indexOf(selected ?? "")];
|
|
333
|
+
return model ? `${model.provider}/${model.id}` : undefined;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function persistRuntimeMode(pi: ExtensionAPI, mode: SquiggleConfig["mode"]): void {
|
|
337
|
+
pi.appendEntry("squiggle-mode", { mode });
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function formatStatus(ctx: ExtensionContext, config: SquiggleConfig): string {
|
|
341
|
+
const unavailable = selectCorrectionModel(ctx, config) ? "" : "; unavailable — run /squiggle config";
|
|
342
|
+
const source = config.modelScope === "SQUIGGLE_MODEL" ? "; SQUIGGLE_MODEL" : "";
|
|
343
|
+
return `squiggle is ${config.mode} (${config.model}${source}${unavailable}).`;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function readConfigFile(cwd: string): Partial<SquiggleConfig> {
|
|
347
|
+
return readConfigPath(join(cwd, CONFIG_DIR_NAME, "squiggle.json"));
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function readConfigPath(path: string): Partial<SquiggleConfig> {
|
|
351
|
+
if (!existsSync(path)) return {};
|
|
352
|
+
try {
|
|
353
|
+
const parsed = JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown>;
|
|
354
|
+
return {
|
|
355
|
+
mode: typeof parsed.mode === "string" ? normalizeMode(parsed.mode) : undefined,
|
|
356
|
+
model: typeof parsed.model === "string" ? parsed.model : undefined,
|
|
357
|
+
maxInputChars: normalizePositiveInt(parsed.maxInputChars),
|
|
358
|
+
timeoutMs: normalizeTimeoutMs(parsed.timeoutMs),
|
|
359
|
+
};
|
|
360
|
+
} catch {
|
|
361
|
+
return {};
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function normalizeMode(value: unknown): SquiggleConfig["mode"] | undefined {
|
|
366
|
+
return value === "on" || value === "off" ? value : undefined;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function normalizePositiveInt(value: unknown): number | undefined {
|
|
370
|
+
const parsed = typeof value === "number" ? value : typeof value === "string" ? Number.parseInt(value, 10) : Number.NaN;
|
|
371
|
+
return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
export function normalizeTimeoutMs(value: unknown): number | undefined {
|
|
375
|
+
const parsed = normalizePositiveInt(value);
|
|
376
|
+
return parsed !== undefined && parsed >= MIN_CORRECTION_TIMEOUT_MS && parsed <= MAX_CORRECTION_TIMEOUT_MS
|
|
377
|
+
? parsed
|
|
378
|
+
: undefined;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function selectCorrectionModel(ctx: ExtensionContext, config: SquiggleConfig) {
|
|
382
|
+
const configured = parseModelSpec(config.model);
|
|
383
|
+
if (configured) {
|
|
384
|
+
const model = ctx.modelRegistry.find(configured.provider, configured.model);
|
|
385
|
+
if (model) return model;
|
|
386
|
+
}
|
|
387
|
+
return undefined;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function parseModelSpec(spec: string): { provider: string; model: string } | null {
|
|
391
|
+
const slash = spec.indexOf("/");
|
|
392
|
+
if (slash <= 0 || slash === spec.length - 1) return null;
|
|
393
|
+
return { provider: spec.slice(0, slash), model: spec.slice(slash + 1) };
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function startSquiggleIndicator(ctx: ExtensionContext): () => void {
|
|
397
|
+
if (!ctx.hasUI) return () => {};
|
|
398
|
+
|
|
399
|
+
const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
400
|
+
let frame = 0;
|
|
401
|
+
let timer: ReturnType<typeof setInterval> | undefined;
|
|
402
|
+
|
|
403
|
+
const render = () => {
|
|
404
|
+
const theme = ctx.ui.theme;
|
|
405
|
+
ctx.ui.setStatus("squiggle", theme.fg("accent", frames[frame]!) + theme.fg("dim", " squiggling..."));
|
|
406
|
+
frame = (frame + 1) % frames.length;
|
|
407
|
+
};
|
|
408
|
+
|
|
409
|
+
render();
|
|
410
|
+
timer = setInterval(render, 120);
|
|
411
|
+
|
|
412
|
+
return () => {
|
|
413
|
+
if (timer) clearInterval(timer);
|
|
414
|
+
ctx.ui.setStatus("squiggle", undefined);
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
type DiffOp = {
|
|
419
|
+
type: "same" | "add" | "remove";
|
|
420
|
+
text: string;
|
|
421
|
+
};
|
|
422
|
+
|
|
423
|
+
function formatColoredDiff(before: string, after: string): string {
|
|
424
|
+
const same = "\x1b[90;3m";
|
|
425
|
+
const added = "\x1b[32;3m";
|
|
426
|
+
const removed = "\x1b[31;3m";
|
|
427
|
+
const reset = "\x1b[0m";
|
|
428
|
+
|
|
429
|
+
return diffChars(before.trim(), after.trim())
|
|
430
|
+
.map((op) => {
|
|
431
|
+
if (op.type === "add") return `${added}${op.text}${reset}`;
|
|
432
|
+
if (op.type === "remove") return `${removed}${op.text}${reset}`;
|
|
433
|
+
return `${same}${op.text}${reset}`;
|
|
434
|
+
})
|
|
435
|
+
.join("");
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function diffChars(before: string, after: string): DiffOp[] {
|
|
439
|
+
const beforeChars = Array.from(before);
|
|
440
|
+
const afterChars = Array.from(after);
|
|
441
|
+
const rows = beforeChars.length + 1;
|
|
442
|
+
const cols = afterChars.length + 1;
|
|
443
|
+
const dp: number[][] = Array.from({ length: rows }, () => Array(cols).fill(0));
|
|
444
|
+
|
|
445
|
+
for (let i = beforeChars.length - 1; i >= 0; i--) {
|
|
446
|
+
for (let j = afterChars.length - 1; j >= 0; j--) {
|
|
447
|
+
dp[i]![j] = beforeChars[i] === afterChars[j] ? dp[i + 1]![j + 1]! + 1 : Math.max(dp[i + 1]![j]!, dp[i]![j + 1]!);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
const ops: DiffOp[] = [];
|
|
452
|
+
let i = 0;
|
|
453
|
+
let j = 0;
|
|
454
|
+
while (i < beforeChars.length || j < afterChars.length) {
|
|
455
|
+
if (i < beforeChars.length && j < afterChars.length && beforeChars[i] === afterChars[j]) {
|
|
456
|
+
pushDiffOp(ops, "same", afterChars[j]!);
|
|
457
|
+
i++;
|
|
458
|
+
j++;
|
|
459
|
+
} else if (j < afterChars.length && (i === beforeChars.length || dp[i]![j + 1]! > dp[i + 1]![j]!)) {
|
|
460
|
+
pushDiffOp(ops, "add", afterChars[j]!);
|
|
461
|
+
j++;
|
|
462
|
+
} else if (i < beforeChars.length) {
|
|
463
|
+
pushDiffOp(ops, "remove", beforeChars[i]!);
|
|
464
|
+
i++;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
return ops;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function pushDiffOp(ops: DiffOp[], type: DiffOp["type"], text: string) {
|
|
472
|
+
const last = ops.at(-1);
|
|
473
|
+
if (last?.type === type) {
|
|
474
|
+
last.text += text;
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
ops.push({ type, text });
|
|
478
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
const PACKAGE_NAME = "@pithos-kit/squiggle";
|
|
2
|
+
|
|
3
|
+
import { appendFile, mkdir } from "node:fs/promises";
|
|
4
|
+
import { dirname, resolve } from "node:path";
|
|
5
|
+
|
|
6
|
+
export type PithosLogLevel = "debug" | "info" | "warn" | "error" | "off";
|
|
7
|
+
export type PithosLogger = ReturnType<typeof createPithosLogger>;
|
|
8
|
+
|
|
9
|
+
const LEVELS: Record<PithosLogLevel, number> = {
|
|
10
|
+
debug: 10,
|
|
11
|
+
info: 20,
|
|
12
|
+
warn: 30,
|
|
13
|
+
error: 40,
|
|
14
|
+
off: Number.POSITIVE_INFINITY,
|
|
15
|
+
};
|
|
16
|
+
const MAX_STRING_LENGTH = 500;
|
|
17
|
+
const MAX_ARRAY_LENGTH = 20;
|
|
18
|
+
const MAX_OBJECT_KEYS = 30;
|
|
19
|
+
const MAX_DEPTH = 4;
|
|
20
|
+
const SECRET_KEY_RE = /(api[-_]?key|authorization|bearer|cookie|credential|header|password|secret|(^|[-_])token($|[-_]))/iu;
|
|
21
|
+
|
|
22
|
+
export function createPithosLogger(packageName = PACKAGE_NAME, env: NodeJS.ProcessEnv = process.env) {
|
|
23
|
+
const level = normalizeLevel(env.PITHOS_LOG_LEVEL);
|
|
24
|
+
const file = resolveLogFile(packageName, env);
|
|
25
|
+
const enabled = level !== "off" && file !== undefined;
|
|
26
|
+
|
|
27
|
+
function write(levelName: Exclude<PithosLogLevel, "off">, event: string, metadata?: Record<string, unknown>): void {
|
|
28
|
+
if (!enabled || LEVELS[levelName] < LEVELS[level]) return;
|
|
29
|
+
const entry = JSON.stringify({
|
|
30
|
+
timestamp: new Date().toISOString(),
|
|
31
|
+
level: levelName,
|
|
32
|
+
package: packageName,
|
|
33
|
+
event,
|
|
34
|
+
...(metadata ? { metadata: sanitize(metadata) } : {}),
|
|
35
|
+
}) + "\n";
|
|
36
|
+
void mkdir(dirname(file), { recursive: true })
|
|
37
|
+
.then(() => appendFile(file, entry, "utf8"))
|
|
38
|
+
.catch(() => undefined);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
enabled,
|
|
43
|
+
level,
|
|
44
|
+
file,
|
|
45
|
+
debug: (event: string, metadata?: Record<string, unknown>) => write("debug", event, metadata),
|
|
46
|
+
info: (event: string, metadata?: Record<string, unknown>) => write("info", event, metadata),
|
|
47
|
+
warn: (event: string, metadata?: Record<string, unknown>) => write("warn", event, metadata),
|
|
48
|
+
error: (event: string, metadata?: Record<string, unknown>) => write("error", event, metadata),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function errorMetadata(error: unknown): Record<string, unknown> {
|
|
53
|
+
if (error instanceof Error) {
|
|
54
|
+
return {
|
|
55
|
+
name: error.name,
|
|
56
|
+
message: error.message,
|
|
57
|
+
...("code" in error ? { code: (error as { code?: unknown }).code } : {}),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
return { message: String(error) };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function modelMetadata(model: unknown): Record<string, unknown> | undefined {
|
|
64
|
+
if (!model || typeof model !== "object") return undefined;
|
|
65
|
+
const record = model as Record<string, unknown>;
|
|
66
|
+
return {
|
|
67
|
+
...(typeof record.provider === "string" ? { provider: record.provider } : {}),
|
|
68
|
+
...(typeof record.id === "string" ? { model: record.id } : {}),
|
|
69
|
+
...(typeof record.api === "string" ? { api: record.api } : {}),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function usageMetadata(usage: unknown): Record<string, unknown> | undefined {
|
|
74
|
+
if (!usage || typeof usage !== "object") return undefined;
|
|
75
|
+
const record = usage as Record<string, unknown>;
|
|
76
|
+
const inputTokens = numberField(record, "inputTokens") ?? numberField(record, "input");
|
|
77
|
+
const outputTokens = numberField(record, "outputTokens") ?? numberField(record, "output");
|
|
78
|
+
const cacheReadTokens = numberField(record, "cacheReadTokens") ?? numberField(record, "cacheRead");
|
|
79
|
+
const cacheWriteTokens = numberField(record, "cacheWriteTokens") ?? numberField(record, "cacheWrite");
|
|
80
|
+
const totalTokens = numberField(record, "totalTokens") ?? sumNumbers(inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens);
|
|
81
|
+
const costValue = record.cost;
|
|
82
|
+
const costUsd = numberField(record, "costUsd") ?? numberField(record, "spendUsd") ?? numberField(record, "cost")
|
|
83
|
+
?? (costValue && typeof costValue === "object" ? numberField(costValue as Record<string, unknown>, "total") : undefined);
|
|
84
|
+
const turns = numberField(record, "turns");
|
|
85
|
+
const normalized = {
|
|
86
|
+
...(inputTokens !== undefined ? { inputTokens } : {}),
|
|
87
|
+
...(outputTokens !== undefined ? { outputTokens } : {}),
|
|
88
|
+
...(cacheReadTokens !== undefined ? { cacheReadTokens } : {}),
|
|
89
|
+
...(cacheWriteTokens !== undefined ? { cacheWriteTokens } : {}),
|
|
90
|
+
...(totalTokens !== undefined ? { totalTokens } : {}),
|
|
91
|
+
...(costUsd !== undefined ? { costUsd } : {}),
|
|
92
|
+
...(turns !== undefined ? { turns } : {}),
|
|
93
|
+
...(typeof record.source === "string" ? { source: record.source } : {}),
|
|
94
|
+
...(typeof record.estimated === "boolean" ? { estimated: record.estimated } : {}),
|
|
95
|
+
};
|
|
96
|
+
return Object.keys(normalized).length > 0 ? normalized : undefined;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function normalizeLevel(value: unknown): PithosLogLevel {
|
|
100
|
+
return value === "debug" || value === "info" || value === "warn" || value === "error" || value === "off" ? value : "off";
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function resolveLogFile(packageName: string, env: NodeJS.ProcessEnv): string | undefined {
|
|
104
|
+
if (env.PITHOS_LOG_FILE?.trim()) return resolve(env.PITHOS_LOG_FILE);
|
|
105
|
+
if (!env.PITHOS_LOG_DIR?.trim()) return undefined;
|
|
106
|
+
const fileName = packageName.replace(/^@pithos-kit\//u, "pithos-").replace(/[^a-z0-9.-]+/giu, "-");
|
|
107
|
+
return resolve(env.PITHOS_LOG_DIR, `${fileName}.jsonl`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function numberField(record: Record<string, unknown>, key: string): number | undefined {
|
|
111
|
+
const value = record[key];
|
|
112
|
+
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function sumNumbers(...values: Array<number | undefined>): number | undefined {
|
|
116
|
+
let total = 0;
|
|
117
|
+
let seen = false;
|
|
118
|
+
for (const value of values) {
|
|
119
|
+
if (value === undefined) continue;
|
|
120
|
+
total += value;
|
|
121
|
+
seen = true;
|
|
122
|
+
}
|
|
123
|
+
return seen ? total : undefined;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function sanitize(value: unknown, depth = 0, key = ""): unknown {
|
|
127
|
+
if (SECRET_KEY_RE.test(key)) return "[redacted]";
|
|
128
|
+
if (value === null || value === undefined || typeof value === "boolean" || typeof value === "number") return value;
|
|
129
|
+
if (typeof value === "bigint") return value.toString();
|
|
130
|
+
if (typeof value === "string") return boundString(value);
|
|
131
|
+
if (value instanceof Error) return sanitize(errorMetadata(value), depth, key);
|
|
132
|
+
if (depth >= MAX_DEPTH) return "[truncated]";
|
|
133
|
+
if (Array.isArray(value)) {
|
|
134
|
+
const items = value.slice(0, MAX_ARRAY_LENGTH).map((item) => sanitize(item, depth + 1));
|
|
135
|
+
if (value.length > MAX_ARRAY_LENGTH) items.push(`[${value.length - MAX_ARRAY_LENGTH} more items]`);
|
|
136
|
+
return items;
|
|
137
|
+
}
|
|
138
|
+
if (typeof value === "object") {
|
|
139
|
+
const result: Record<string, unknown> = {};
|
|
140
|
+
const entries = Object.entries(value as Record<string, unknown>).slice(0, MAX_OBJECT_KEYS);
|
|
141
|
+
for (const [entryKey, entryValue] of entries) result[entryKey] = sanitize(entryValue, depth + 1, entryKey);
|
|
142
|
+
const extra = Object.keys(value as Record<string, unknown>).length - entries.length;
|
|
143
|
+
if (extra > 0) result.__truncatedKeys = extra;
|
|
144
|
+
return result;
|
|
145
|
+
}
|
|
146
|
+
return String(value);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function boundString(value: string): string {
|
|
150
|
+
if (value.length <= MAX_STRING_LENGTH) return value;
|
|
151
|
+
return `${value.slice(0, MAX_STRING_LENGTH)}…[${value.length - MAX_STRING_LENGTH} more chars]`;
|
|
152
|
+
}
|
package/index.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export { default } from "./extensions/
|
|
1
|
+
export { default } from "./extensions/index.ts";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pithos-kit/squiggle",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Quietly polish grammar and spelling in your Pi prompts.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"summary": "Quietly polish grammar and spelling in user prompts.",
|
|
32
32
|
"minimumPi": ">=0.83.0",
|
|
33
33
|
"commands": [
|
|
34
|
-
{ "name": "squiggle", "usage": "/squiggle [toggle|--help]", "summary": "Toggle prompt correction." },
|
|
34
|
+
{ "name": "squiggle", "usage": "/squiggle [toggle|config|--help]", "summary": "Toggle prompt correction or configure its model." },
|
|
35
35
|
{ "name": "squiggle-status", "usage": "/squiggle-status [--help]", "summary": "Show correction status and configuration." }
|
|
36
36
|
],
|
|
37
37
|
"tools": [],
|
|
@@ -40,10 +40,11 @@
|
|
|
40
40
|
"themes": [],
|
|
41
41
|
"agents": [],
|
|
42
42
|
"configuration": [
|
|
43
|
-
{ "kind": "file", "key": ".pi/squiggle.json", "summary": "Project correction mode, model,
|
|
43
|
+
{ "kind": "file", "key": ".pi/squiggle.json", "summary": "Project correction mode, model, input limit, and timeout." },
|
|
44
44
|
{ "kind": "environment", "key": "SQUIGGLE_MODE", "summary": "Override correction mode." },
|
|
45
45
|
{ "kind": "environment", "key": "SQUIGGLE_MODEL", "summary": "Override the correction model." },
|
|
46
|
-
{ "kind": "environment", "key": "SQUIGGLE_MAX_CHARS", "summary": "Override the maximum corrected input length." }
|
|
46
|
+
{ "kind": "environment", "key": "SQUIGGLE_MAX_CHARS", "summary": "Override the maximum corrected input length." },
|
|
47
|
+
{ "kind": "environment", "key": "SQUIGGLE_TIMEOUT_MS", "summary": "Override the bounded correction timeout." }
|
|
47
48
|
]
|
|
48
49
|
},
|
|
49
50
|
"pi": {
|
package/extensions/squiggle.ts
DELETED
|
@@ -1,294 +0,0 @@
|
|
|
1
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
-
import { join } from "node:path";
|
|
3
|
-
import { complete, type UserMessage } from "@earendil-works/pi-ai";
|
|
4
|
-
import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
5
|
-
|
|
6
|
-
const SQUIGGLE_HELP = `Usage: /squiggle toggle
|
|
7
|
-
|
|
8
|
-
Toggle Squiggle on or off for the current session.
|
|
9
|
-
|
|
10
|
-
Options:
|
|
11
|
-
--help, -h Show this help`;
|
|
12
|
-
|
|
13
|
-
const SQUIGGLE_STATUS_HELP = `Usage: /squiggle-status
|
|
14
|
-
|
|
15
|
-
Show whether Squiggle is enabled and which correction model it uses.
|
|
16
|
-
|
|
17
|
-
Options:
|
|
18
|
-
--help, -h Show this help`;
|
|
19
|
-
|
|
20
|
-
export function registerSquiggle(
|
|
21
|
-
pi: ExtensionAPI,
|
|
22
|
-
correctPrompt: (input: string, ctx: ExtensionContext, config: SquiggleConfig) => Promise<string | null> = correctWithModel,
|
|
23
|
-
) {
|
|
24
|
-
let runtimeMode: SquiggleConfig["mode"] | undefined;
|
|
25
|
-
|
|
26
|
-
pi.on("session_start", async (_event, ctx) => {
|
|
27
|
-
runtimeMode = restoreRuntimeMode(ctx);
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
pi.registerCommand("squiggle", {
|
|
31
|
-
description: "Toggle squiggle on/off",
|
|
32
|
-
handler: async (args, ctx) => {
|
|
33
|
-
if (isHelpRequest(args)) return emitHelp(ctx, SQUIGGLE_HELP);
|
|
34
|
-
|
|
35
|
-
const command = args.trim().toLowerCase();
|
|
36
|
-
if (command !== "toggle") {
|
|
37
|
-
ctx.ui.notify("Usage: /squiggle toggle", "warning");
|
|
38
|
-
return;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
const config = loadEffectiveConfig(ctx.cwd, runtimeMode);
|
|
42
|
-
runtimeMode = config.mode === "on" ? "off" : "on";
|
|
43
|
-
persistRuntimeMode(pi, runtimeMode);
|
|
44
|
-
ctx.ui.notify(formatStatus(ctx, loadEffectiveConfig(ctx.cwd, runtimeMode)), "info");
|
|
45
|
-
},
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
pi.registerCommand("squiggle-status", {
|
|
49
|
-
description: "Show whether squiggle is loaded",
|
|
50
|
-
handler: async (args, ctx) => {
|
|
51
|
-
if (isHelpRequest(args)) return emitHelp(ctx, SQUIGGLE_STATUS_HELP);
|
|
52
|
-
ctx.ui.notify(formatStatus(ctx, loadEffectiveConfig(ctx.cwd, runtimeMode)), "info");
|
|
53
|
-
},
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
pi.on("input", async (event, ctx) => {
|
|
57
|
-
if (event.source === "extension") return { action: "continue" };
|
|
58
|
-
|
|
59
|
-
const config = loadEffectiveConfig(ctx.cwd, runtimeMode);
|
|
60
|
-
if (config.mode === "off") return { action: "continue" };
|
|
61
|
-
if (!event.text.trim()) return { action: "continue" };
|
|
62
|
-
|
|
63
|
-
const stopIndicator = startSquiggleIndicator(ctx);
|
|
64
|
-
const corrected = await correctPrompt(event.text, ctx, config).finally(stopIndicator);
|
|
65
|
-
if (!corrected || corrected === event.text) return { action: "continue" };
|
|
66
|
-
|
|
67
|
-
if (ctx.hasUI) ctx.ui.notify(formatColoredDiff(event.text, corrected), "info");
|
|
68
|
-
return { action: "transform", text: corrected };
|
|
69
|
-
});
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
export default function squiggle(pi: ExtensionAPI): void {
|
|
73
|
-
registerSquiggle(pi);
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
function isHelpRequest(args: string): boolean {
|
|
77
|
-
const normalized = args.trim();
|
|
78
|
-
return normalized === "--help" || normalized === "-h";
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
function emitHelp(ctx: ExtensionCommandContext, text: string): void {
|
|
82
|
-
if (ctx.hasUI) ctx.ui.notify(text, "info");
|
|
83
|
-
else console.log(text);
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
const CORRECTION_PROMPT = `You are a conservative grammar and spelling corrector for user prompts sent to a coding assistant.
|
|
87
|
-
|
|
88
|
-
Task:
|
|
89
|
-
- Correct spelling, grammar, capitalization, and punctuation.
|
|
90
|
-
- Preserve the user's meaning, tone, language, and intent.
|
|
91
|
-
- Do not answer the prompt.
|
|
92
|
-
- Do not add explanations, quotes, prefixes, markdown fences, or alternatives.
|
|
93
|
-
- If the input is already acceptable, return it unchanged.
|
|
94
|
-
- Return only the corrected prompt text.`;
|
|
95
|
-
|
|
96
|
-
const DEFAULT_CORRECTION_MODEL = "openai-codex/gpt-5.4-mini";
|
|
97
|
-
const DEFAULT_MAX_LLM_INPUT_CHARS = 500;
|
|
98
|
-
|
|
99
|
-
type SquiggleConfig = {
|
|
100
|
-
mode: "on" | "off";
|
|
101
|
-
model: string;
|
|
102
|
-
maxInputChars: number;
|
|
103
|
-
};
|
|
104
|
-
|
|
105
|
-
async function correctWithModel(input: string, ctx: ExtensionContext, config: SquiggleConfig): Promise<string | null> {
|
|
106
|
-
const model = selectCorrectionModel(ctx, config);
|
|
107
|
-
if (!model) return null;
|
|
108
|
-
if (input.length > config.maxInputChars) return null;
|
|
109
|
-
|
|
110
|
-
try {
|
|
111
|
-
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
|
|
112
|
-
if (!auth.ok || !auth.apiKey) return null;
|
|
113
|
-
|
|
114
|
-
const userMessage: UserMessage = {
|
|
115
|
-
role: "user",
|
|
116
|
-
content: [{ type: "text", text: input }],
|
|
117
|
-
timestamp: Date.now(),
|
|
118
|
-
};
|
|
119
|
-
|
|
120
|
-
const response = await complete(
|
|
121
|
-
model,
|
|
122
|
-
{ systemPrompt: CORRECTION_PROMPT, messages: [userMessage] },
|
|
123
|
-
{ apiKey: auth.apiKey, headers: auth.headers },
|
|
124
|
-
);
|
|
125
|
-
|
|
126
|
-
if (response.stopReason === "aborted") return null;
|
|
127
|
-
|
|
128
|
-
return response.content
|
|
129
|
-
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
|
130
|
-
.map((c) => c.text)
|
|
131
|
-
.join("\n")
|
|
132
|
-
.trim();
|
|
133
|
-
} catch {
|
|
134
|
-
return null;
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
function loadConfig(cwd: string): SquiggleConfig {
|
|
139
|
-
const fileConfig = readConfigFile(cwd);
|
|
140
|
-
return {
|
|
141
|
-
mode: normalizeMode(process.env.SQUIGGLE_MODE ?? fileConfig.mode) ?? "on",
|
|
142
|
-
model: process.env.SQUIGGLE_MODEL ?? fileConfig.model ?? DEFAULT_CORRECTION_MODEL,
|
|
143
|
-
maxInputChars: normalizePositiveInt(process.env.SQUIGGLE_MAX_CHARS ?? fileConfig.maxInputChars) ?? DEFAULT_MAX_LLM_INPUT_CHARS,
|
|
144
|
-
};
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
function loadEffectiveConfig(cwd: string, runtimeMode: SquiggleConfig["mode"] | undefined): SquiggleConfig {
|
|
148
|
-
const config = loadConfig(cwd);
|
|
149
|
-
return { ...config, mode: runtimeMode ?? config.mode };
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
function restoreRuntimeMode(ctx: ExtensionContext): SquiggleConfig["mode"] | undefined {
|
|
153
|
-
for (const entry of [...ctx.sessionManager.getEntries()].reverse()) {
|
|
154
|
-
if (entry.type !== "custom" || entry.customType !== "squiggle-mode") continue;
|
|
155
|
-
const data = (entry as { data?: { mode?: unknown } }).data;
|
|
156
|
-
return normalizeMode(data?.mode);
|
|
157
|
-
}
|
|
158
|
-
return undefined;
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
function persistRuntimeMode(pi: ExtensionAPI, mode: SquiggleConfig["mode"]): void {
|
|
162
|
-
pi.appendEntry("squiggle-mode", { mode });
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
function formatStatus(ctx: ExtensionContext, config: SquiggleConfig): string {
|
|
166
|
-
return `squiggle is ${config.mode} (${formatModel(selectCorrectionModel(ctx, config))}).`;
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
function readConfigFile(cwd: string): Partial<SquiggleConfig> {
|
|
170
|
-
const path = join(cwd, ".pi", "squiggle.json");
|
|
171
|
-
if (!existsSync(path)) return {};
|
|
172
|
-
try {
|
|
173
|
-
const parsed = JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown>;
|
|
174
|
-
return {
|
|
175
|
-
mode: typeof parsed.mode === "string" ? normalizeMode(parsed.mode) : undefined,
|
|
176
|
-
model: typeof parsed.model === "string" ? parsed.model : undefined,
|
|
177
|
-
maxInputChars: normalizePositiveInt(parsed.maxInputChars),
|
|
178
|
-
};
|
|
179
|
-
} catch {
|
|
180
|
-
return {};
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
function normalizeMode(value: unknown): SquiggleConfig["mode"] | undefined {
|
|
185
|
-
return value === "on" || value === "off" ? value : undefined;
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
function normalizePositiveInt(value: unknown): number | undefined {
|
|
189
|
-
const parsed = typeof value === "number" ? value : typeof value === "string" ? Number.parseInt(value, 10) : Number.NaN;
|
|
190
|
-
return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined;
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
function selectCorrectionModel(ctx: ExtensionContext, config: SquiggleConfig) {
|
|
194
|
-
const configured = parseModelSpec(config.model);
|
|
195
|
-
if (configured) {
|
|
196
|
-
const model = ctx.modelRegistry.find(configured.provider, configured.model);
|
|
197
|
-
if (model) return model;
|
|
198
|
-
}
|
|
199
|
-
return ctx.model;
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
function parseModelSpec(spec: string): { provider: string; model: string } | null {
|
|
203
|
-
const slash = spec.indexOf("/");
|
|
204
|
-
if (slash <= 0 || slash === spec.length - 1) return null;
|
|
205
|
-
return { provider: spec.slice(0, slash), model: spec.slice(slash + 1) };
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
function formatModel(model: ReturnType<typeof selectCorrectionModel>): string {
|
|
209
|
-
return model ? `${model.provider}/${model.id}` : "no model";
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
function startSquiggleIndicator(ctx: ExtensionContext): () => void {
|
|
213
|
-
if (!ctx.hasUI) return () => {};
|
|
214
|
-
|
|
215
|
-
const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
216
|
-
let frame = 0;
|
|
217
|
-
let timer: ReturnType<typeof setInterval> | undefined;
|
|
218
|
-
|
|
219
|
-
const render = () => {
|
|
220
|
-
const theme = ctx.ui.theme;
|
|
221
|
-
ctx.ui.setStatus("squiggle", theme.fg("accent", frames[frame]!) + theme.fg("dim", " squiggling..."));
|
|
222
|
-
frame = (frame + 1) % frames.length;
|
|
223
|
-
};
|
|
224
|
-
|
|
225
|
-
render();
|
|
226
|
-
timer = setInterval(render, 120);
|
|
227
|
-
|
|
228
|
-
return () => {
|
|
229
|
-
if (timer) clearInterval(timer);
|
|
230
|
-
ctx.ui.setStatus("squiggle", undefined);
|
|
231
|
-
};
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
type DiffOp = {
|
|
235
|
-
type: "same" | "add" | "remove";
|
|
236
|
-
text: string;
|
|
237
|
-
};
|
|
238
|
-
|
|
239
|
-
function formatColoredDiff(before: string, after: string): string {
|
|
240
|
-
const same = "\x1b[90;3m";
|
|
241
|
-
const added = "\x1b[32;3m";
|
|
242
|
-
const removed = "\x1b[31;3m";
|
|
243
|
-
const reset = "\x1b[0m";
|
|
244
|
-
|
|
245
|
-
return diffChars(before.trim(), after.trim())
|
|
246
|
-
.map((op) => {
|
|
247
|
-
if (op.type === "add") return `${added}${op.text}${reset}`;
|
|
248
|
-
if (op.type === "remove") return `${removed}${op.text}${reset}`;
|
|
249
|
-
return `${same}${op.text}${reset}`;
|
|
250
|
-
})
|
|
251
|
-
.join("");
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
function diffChars(before: string, after: string): DiffOp[] {
|
|
255
|
-
const beforeChars = Array.from(before);
|
|
256
|
-
const afterChars = Array.from(after);
|
|
257
|
-
const rows = beforeChars.length + 1;
|
|
258
|
-
const cols = afterChars.length + 1;
|
|
259
|
-
const dp: number[][] = Array.from({ length: rows }, () => Array(cols).fill(0));
|
|
260
|
-
|
|
261
|
-
for (let i = beforeChars.length - 1; i >= 0; i--) {
|
|
262
|
-
for (let j = afterChars.length - 1; j >= 0; j--) {
|
|
263
|
-
dp[i]![j] = beforeChars[i] === afterChars[j] ? dp[i + 1]![j + 1]! + 1 : Math.max(dp[i + 1]![j]!, dp[i]![j + 1]!);
|
|
264
|
-
}
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
const ops: DiffOp[] = [];
|
|
268
|
-
let i = 0;
|
|
269
|
-
let j = 0;
|
|
270
|
-
while (i < beforeChars.length || j < afterChars.length) {
|
|
271
|
-
if (i < beforeChars.length && j < afterChars.length && beforeChars[i] === afterChars[j]) {
|
|
272
|
-
pushDiffOp(ops, "same", afterChars[j]!);
|
|
273
|
-
i++;
|
|
274
|
-
j++;
|
|
275
|
-
} else if (j < afterChars.length && (i === beforeChars.length || dp[i]![j + 1]! > dp[i + 1]![j]!)) {
|
|
276
|
-
pushDiffOp(ops, "add", afterChars[j]!);
|
|
277
|
-
j++;
|
|
278
|
-
} else if (i < beforeChars.length) {
|
|
279
|
-
pushDiffOp(ops, "remove", beforeChars[i]!);
|
|
280
|
-
i++;
|
|
281
|
-
}
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
return ops;
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
function pushDiffOp(ops: DiffOp[], type: DiffOp["type"], text: string) {
|
|
288
|
-
const last = ops.at(-1);
|
|
289
|
-
if (last?.type === type) {
|
|
290
|
-
last.text += text;
|
|
291
|
-
return;
|
|
292
|
-
}
|
|
293
|
-
ops.push({ type, text });
|
|
294
|
-
}
|