@pithos-kit/squiggle 0.0.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 ADDED
@@ -0,0 +1,93 @@
1
+ # squiggle
2
+
3
+ Quietly polish grammar and spelling in your pi prompts.
4
+
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
+
7
+ ## Install
8
+
9
+ ```bash
10
+ pi install npm:@pithos-kit/squiggle
11
+ ```
12
+
13
+ Pin to a version:
14
+
15
+ ```bash
16
+ pi install npm:@pithos-kit/squiggle@<version>
17
+ ```
18
+
19
+ For local development from a checkout of [`pithos-kit`](https://github.com/anton-kochev/pithos-kit):
20
+
21
+ ```bash
22
+ pi install ./pithos.squiggle
23
+ ```
24
+
25
+ Project-local install:
26
+
27
+ ```bash
28
+ pi install ./pithos.squiggle -l
29
+ ```
30
+
31
+ Temporary test run:
32
+
33
+ ```bash
34
+ pi -e ./pithos.squiggle
35
+ ```
36
+
37
+ ## Pithos `.pithos` config
38
+
39
+ ```yaml
40
+ pi:
41
+ extensions:
42
+ "@pithos-kit/squiggle": "npm:0.4.0"
43
+ ```
44
+
45
+ ## Configuration
46
+
47
+ Create `.pi/squiggle.json` in your project:
48
+
49
+ ```json
50
+ {
51
+ "mode": "on",
52
+ "model": "openai-codex/gpt-5.4-mini",
53
+ "maxInputChars": 500
54
+ }
55
+ ```
56
+
57
+ Options:
58
+
59
+ - `mode`: `"on"` or `"off"`
60
+ - `model`: pi model spec in `provider/model` format
61
+ - `maxInputChars`: maximum input length to send to the correction model
62
+
63
+ Environment variables override the config file:
64
+
65
+ ```bash
66
+ SQUIGGLE_MODE=off pi
67
+ SQUIGGLE_MODEL=openai-codex/gpt-5.4-mini pi
68
+ SQUIGGLE_MAX_CHARS=1000 pi
69
+ ```
70
+
71
+ ## Commands
72
+
73
+ Inside pi:
74
+
75
+ ```text
76
+ /squiggle toggle # switch between on/off
77
+ /squiggle --help # show toggle usage
78
+ /squiggle-status # show status
79
+ /squiggle-status --help # show status-command usage
80
+ ```
81
+
82
+ `-h` is accepted wherever `--help` is shown.
83
+
84
+ The toggle state is saved in the current pi session and overrides `.pi/squiggle.json` and environment configuration for that session.
85
+
86
+ ## Notes
87
+
88
+ This package imports pi runtime packages as peer dependencies:
89
+
90
+ - `@earendil-works/pi-ai`
91
+ - `@earendil-works/pi-coding-agent`
92
+
93
+ Do not bundle those dependencies; pi provides them at runtime.
@@ -0,0 +1,298 @@
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 default function squiggle(pi: ExtensionAPI) {
21
+ let runtimeMode: SquiggleConfig["mode"] | undefined;
22
+
23
+ pi.on("session_start", async (_event, ctx) => {
24
+ runtimeMode = restoreRuntimeMode(ctx);
25
+ });
26
+
27
+ pi.registerCommand("squiggle", {
28
+ description: "Toggle squiggle on/off",
29
+ handler: async (args, ctx) => {
30
+ if (isHelpRequest(args)) return emitHelp(ctx, SQUIGGLE_HELP);
31
+
32
+ const command = args.trim().toLowerCase();
33
+ if (command !== "toggle") {
34
+ ctx.ui.notify("Usage: /squiggle toggle", "warning");
35
+ return;
36
+ }
37
+
38
+ const config = loadEffectiveConfig(ctx.cwd, runtimeMode);
39
+ runtimeMode = config.mode === "on" ? "off" : "on";
40
+ persistRuntimeMode(pi, runtimeMode);
41
+ ctx.ui.notify(formatStatus(ctx, loadEffectiveConfig(ctx.cwd, runtimeMode)), "info");
42
+ },
43
+ });
44
+
45
+ pi.registerCommand("squiggle-status", {
46
+ description: "Show whether squiggle is loaded",
47
+ handler: async (args, ctx) => {
48
+ if (isHelpRequest(args)) return emitHelp(ctx, SQUIGGLE_STATUS_HELP);
49
+ ctx.ui.notify(formatStatus(ctx, loadEffectiveConfig(ctx.cwd, runtimeMode)), "info");
50
+ },
51
+ });
52
+
53
+ pi.on("input", async (event, ctx) => {
54
+ if (event.source === "extension") return { action: "continue" };
55
+
56
+ const config = loadEffectiveConfig(ctx.cwd, runtimeMode);
57
+ if (config.mode === "off") return { action: "continue" };
58
+ if (!event.text.trim()) return { action: "continue" };
59
+
60
+ const stopIndicator = startSquiggleIndicator(ctx);
61
+ const corrected = await correctWithModel(event.text, ctx, config).finally(stopIndicator);
62
+ if (!corrected || corrected === event.text) return { action: "continue" };
63
+
64
+ if (ctx.hasUI) ctx.ui.notify(formatColoredDiff(event.text, corrected), "info");
65
+
66
+ // In interactive mode, `transform` changes what the agent receives, but the
67
+ // already-submitted prompt may still be rendered as originally typed. To make
68
+ // the visible user message corrected too, swallow the original input and
69
+ // resubmit the corrected text as an extension-originated user message. The
70
+ // source guard above prevents a correction loop.
71
+ if (event.source === "interactive") {
72
+ pi.sendUserMessage(corrected);
73
+ return { action: "handled" };
74
+ }
75
+
76
+ return { action: "transform", text: corrected };
77
+ });
78
+ }
79
+
80
+ function isHelpRequest(args: string): boolean {
81
+ const normalized = args.trim();
82
+ return normalized === "--help" || normalized === "-h";
83
+ }
84
+
85
+ function emitHelp(ctx: ExtensionCommandContext, text: string): void {
86
+ if (ctx.hasUI) ctx.ui.notify(text, "info");
87
+ else console.log(text);
88
+ }
89
+
90
+ const CORRECTION_PROMPT = `You are a conservative grammar and spelling corrector for user prompts sent to a coding assistant.
91
+
92
+ Task:
93
+ - Correct spelling, grammar, capitalization, and punctuation.
94
+ - Preserve the user's meaning, tone, language, and intent.
95
+ - Do not answer the prompt.
96
+ - Do not add explanations, quotes, prefixes, markdown fences, or alternatives.
97
+ - If the input is already acceptable, return it unchanged.
98
+ - Return only the corrected prompt text.`;
99
+
100
+ const DEFAULT_CORRECTION_MODEL = "openai-codex/gpt-5.4-mini";
101
+ const DEFAULT_MAX_LLM_INPUT_CHARS = 500;
102
+
103
+ type SquiggleConfig = {
104
+ mode: "on" | "off";
105
+ model: string;
106
+ maxInputChars: number;
107
+ };
108
+
109
+ async function correctWithModel(input: string, ctx: ExtensionContext, config: SquiggleConfig): Promise<string | null> {
110
+ const model = selectCorrectionModel(ctx, config);
111
+ if (!model) return null;
112
+ if (input.length > config.maxInputChars) return null;
113
+
114
+ try {
115
+ const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
116
+ if (!auth.ok || !auth.apiKey) return null;
117
+
118
+ const userMessage: UserMessage = {
119
+ role: "user",
120
+ content: [{ type: "text", text: input }],
121
+ timestamp: Date.now(),
122
+ };
123
+
124
+ const response = await complete(
125
+ model,
126
+ { systemPrompt: CORRECTION_PROMPT, messages: [userMessage] },
127
+ { apiKey: auth.apiKey, headers: auth.headers },
128
+ );
129
+
130
+ if (response.stopReason === "aborted") return null;
131
+
132
+ return response.content
133
+ .filter((c): c is { type: "text"; text: string } => c.type === "text")
134
+ .map((c) => c.text)
135
+ .join("\n")
136
+ .trim();
137
+ } catch {
138
+ return null;
139
+ }
140
+ }
141
+
142
+ function loadConfig(cwd: string): SquiggleConfig {
143
+ const fileConfig = readConfigFile(cwd);
144
+ return {
145
+ mode: normalizeMode(process.env.SQUIGGLE_MODE ?? fileConfig.mode) ?? "on",
146
+ model: process.env.SQUIGGLE_MODEL ?? fileConfig.model ?? DEFAULT_CORRECTION_MODEL,
147
+ maxInputChars: normalizePositiveInt(process.env.SQUIGGLE_MAX_CHARS ?? fileConfig.maxInputChars) ?? DEFAULT_MAX_LLM_INPUT_CHARS,
148
+ };
149
+ }
150
+
151
+ function loadEffectiveConfig(cwd: string, runtimeMode: SquiggleConfig["mode"] | undefined): SquiggleConfig {
152
+ const config = loadConfig(cwd);
153
+ return { ...config, mode: runtimeMode ?? config.mode };
154
+ }
155
+
156
+ function restoreRuntimeMode(ctx: ExtensionContext): SquiggleConfig["mode"] | undefined {
157
+ for (const entry of [...ctx.sessionManager.getEntries()].reverse()) {
158
+ if (entry.type !== "custom" || entry.customType !== "squiggle-mode") continue;
159
+ const data = (entry as { data?: { mode?: unknown } }).data;
160
+ return normalizeMode(data?.mode);
161
+ }
162
+ return undefined;
163
+ }
164
+
165
+ function persistRuntimeMode(pi: ExtensionAPI, mode: SquiggleConfig["mode"]): void {
166
+ pi.appendEntry("squiggle-mode", { mode });
167
+ }
168
+
169
+ function formatStatus(ctx: ExtensionContext, config: SquiggleConfig): string {
170
+ return `squiggle is ${config.mode} (${formatModel(selectCorrectionModel(ctx, config))}).`;
171
+ }
172
+
173
+ function readConfigFile(cwd: string): Partial<SquiggleConfig> {
174
+ const path = join(cwd, ".pi", "squiggle.json");
175
+ if (!existsSync(path)) return {};
176
+ try {
177
+ const parsed = JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown>;
178
+ return {
179
+ mode: typeof parsed.mode === "string" ? normalizeMode(parsed.mode) : undefined,
180
+ model: typeof parsed.model === "string" ? parsed.model : undefined,
181
+ maxInputChars: normalizePositiveInt(parsed.maxInputChars),
182
+ };
183
+ } catch {
184
+ return {};
185
+ }
186
+ }
187
+
188
+ function normalizeMode(value: unknown): SquiggleConfig["mode"] | undefined {
189
+ return value === "on" || value === "off" ? value : undefined;
190
+ }
191
+
192
+ function normalizePositiveInt(value: unknown): number | undefined {
193
+ const parsed = typeof value === "number" ? value : typeof value === "string" ? Number.parseInt(value, 10) : Number.NaN;
194
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined;
195
+ }
196
+
197
+ function selectCorrectionModel(ctx: ExtensionContext, config: SquiggleConfig) {
198
+ const configured = parseModelSpec(config.model);
199
+ if (configured) {
200
+ const model = ctx.modelRegistry.find(configured.provider, configured.model);
201
+ if (model) return model;
202
+ }
203
+ return ctx.model;
204
+ }
205
+
206
+ function parseModelSpec(spec: string): { provider: string; model: string } | null {
207
+ const slash = spec.indexOf("/");
208
+ if (slash <= 0 || slash === spec.length - 1) return null;
209
+ return { provider: spec.slice(0, slash), model: spec.slice(slash + 1) };
210
+ }
211
+
212
+ function formatModel(model: ReturnType<typeof selectCorrectionModel>): string {
213
+ return model ? `${model.provider}/${model.id}` : "no model";
214
+ }
215
+
216
+ function startSquiggleIndicator(ctx: ExtensionContext): () => void {
217
+ if (!ctx.hasUI) return () => {};
218
+
219
+ const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
220
+ let frame = 0;
221
+ let timer: ReturnType<typeof setInterval> | undefined;
222
+
223
+ const render = () => {
224
+ const theme = ctx.ui.theme;
225
+ ctx.ui.setStatus("squiggle", theme.fg("accent", frames[frame]!) + theme.fg("dim", " squiggling..."));
226
+ frame = (frame + 1) % frames.length;
227
+ };
228
+
229
+ render();
230
+ timer = setInterval(render, 120);
231
+
232
+ return () => {
233
+ if (timer) clearInterval(timer);
234
+ ctx.ui.setStatus("squiggle", undefined);
235
+ };
236
+ }
237
+
238
+ type DiffOp = {
239
+ type: "same" | "add" | "remove";
240
+ text: string;
241
+ };
242
+
243
+ function formatColoredDiff(before: string, after: string): string {
244
+ const same = "\x1b[90;3m";
245
+ const added = "\x1b[32;3m";
246
+ const removed = "\x1b[31;3m";
247
+ const reset = "\x1b[0m";
248
+
249
+ return diffChars(before.trim(), after.trim())
250
+ .map((op) => {
251
+ if (op.type === "add") return `${added}${op.text}${reset}`;
252
+ if (op.type === "remove") return `${removed}${op.text}${reset}`;
253
+ return `${same}${op.text}${reset}`;
254
+ })
255
+ .join("");
256
+ }
257
+
258
+ function diffChars(before: string, after: string): DiffOp[] {
259
+ const beforeChars = Array.from(before);
260
+ const afterChars = Array.from(after);
261
+ const rows = beforeChars.length + 1;
262
+ const cols = afterChars.length + 1;
263
+ const dp: number[][] = Array.from({ length: rows }, () => Array(cols).fill(0));
264
+
265
+ for (let i = beforeChars.length - 1; i >= 0; i--) {
266
+ for (let j = afterChars.length - 1; j >= 0; j--) {
267
+ dp[i]![j] = beforeChars[i] === afterChars[j] ? dp[i + 1]![j + 1]! + 1 : Math.max(dp[i + 1]![j]!, dp[i]![j + 1]!);
268
+ }
269
+ }
270
+
271
+ const ops: DiffOp[] = [];
272
+ let i = 0;
273
+ let j = 0;
274
+ while (i < beforeChars.length || j < afterChars.length) {
275
+ if (i < beforeChars.length && j < afterChars.length && beforeChars[i] === afterChars[j]) {
276
+ pushDiffOp(ops, "same", afterChars[j]!);
277
+ i++;
278
+ j++;
279
+ } else if (j < afterChars.length && (i === beforeChars.length || dp[i]![j + 1]! > dp[i + 1]![j]!)) {
280
+ pushDiffOp(ops, "add", afterChars[j]!);
281
+ j++;
282
+ } else if (i < beforeChars.length) {
283
+ pushDiffOp(ops, "remove", beforeChars[i]!);
284
+ i++;
285
+ }
286
+ }
287
+
288
+ return ops;
289
+ }
290
+
291
+ function pushDiffOp(ops: DiffOp[], type: DiffOp["type"], text: string) {
292
+ const last = ops.at(-1);
293
+ if (last?.type === type) {
294
+ last.text += text;
295
+ return;
296
+ }
297
+ ops.push({ type, text });
298
+ }
package/index.ts ADDED
@@ -0,0 +1 @@
1
+ export { default } from "./extensions/squiggle.ts";
package/package.json ADDED
@@ -0,0 +1,83 @@
1
+ {
2
+ "name": "@pithos-kit/squiggle",
3
+ "version": "0.0.0",
4
+ "description": "Quietly polish grammar and spelling in your Pi prompts.",
5
+ "keywords": [
6
+ "pi-package",
7
+ "pi-extension",
8
+ "pithos",
9
+ "grammar",
10
+ "writing"
11
+ ],
12
+ "license": "MIT",
13
+ "type": "module",
14
+ "scripts": {
15
+ "test": "node --test test/*.test.ts"
16
+ },
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/anton-kochev/pithos-kit.git",
20
+ "directory": "pithos.squiggle"
21
+ },
22
+ "publishConfig": {
23
+ "access": "public"
24
+ },
25
+ "peerDependencies": {
26
+ "@earendil-works/pi-ai": "*",
27
+ "@earendil-works/pi-coding-agent": "*"
28
+ },
29
+ "pithosKit": {
30
+ "displayName": "Squiggle",
31
+ "summary": "Quietly polish grammar and spelling in user prompts.",
32
+ "minimumPi": ">=0.83.0",
33
+ "commands": [
34
+ {
35
+ "name": "squiggle",
36
+ "usage": "/squiggle [toggle|--help]",
37
+ "summary": "Toggle prompt correction."
38
+ },
39
+ {
40
+ "name": "squiggle-status",
41
+ "usage": "/squiggle-status [--help]",
42
+ "summary": "Show correction status and configuration."
43
+ }
44
+ ],
45
+ "tools": [],
46
+ "prompts": [],
47
+ "skills": [],
48
+ "themes": [],
49
+ "agents": [],
50
+ "configuration": [
51
+ {
52
+ "kind": "file",
53
+ "key": ".pi/squiggle.json",
54
+ "summary": "Project correction mode, model, and input limit."
55
+ },
56
+ {
57
+ "kind": "environment",
58
+ "key": "SQUIGGLE_MODE",
59
+ "summary": "Override correction mode."
60
+ },
61
+ {
62
+ "kind": "environment",
63
+ "key": "SQUIGGLE_MODEL",
64
+ "summary": "Override the correction model."
65
+ },
66
+ {
67
+ "kind": "environment",
68
+ "key": "SQUIGGLE_MAX_CHARS",
69
+ "summary": "Override the maximum corrected input length."
70
+ }
71
+ ]
72
+ },
73
+ "pi": {
74
+ "extensions": [
75
+ "./extensions"
76
+ ]
77
+ },
78
+ "files": [
79
+ "index.ts",
80
+ "extensions",
81
+ "README.md"
82
+ ]
83
+ }