@anton-kochev/pi-extensions 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,41 @@
1
+ # pi-extensions
2
+
3
+ Pi extensions for personal use, installed as a single pi-package from this repository.
4
+
5
+ ## Install via pithos
6
+
7
+ In your project's `.pithos`:
8
+
9
+ ```yaml
10
+ pi:
11
+ extensions:
12
+ squiggle: "git:https://github.com/anton-kochev/pi-extensions.git#main"
13
+ ```
14
+
15
+ Pithos's entrypoint passes this to `pi install`, which clones the repo, runs `npm install`, and registers the extensions declared in the root `pi.extensions` manifest.
16
+
17
+ ## Install directly with pi
18
+
19
+ ```bash
20
+ pi install git:github.com/anton-kochev/pi-extensions
21
+ ```
22
+
23
+ Pin to a tag for reproducibility:
24
+
25
+ ```bash
26
+ pi install git:github.com/anton-kochev/pi-extensions@v0.1.0
27
+ ```
28
+
29
+ ## Extensions in this repo
30
+
31
+ - [`squiggle/`](./squiggle) — quietly polish grammar and spelling in user prompts.
32
+
33
+ ## Local development
34
+
35
+ From a checkout of this repo:
36
+
37
+ ```bash
38
+ pi install -l ./squiggle
39
+ ```
40
+
41
+ Each subdirectory has its own `package.json` so individual extensions remain installable in isolation.
package/package.json ADDED
@@ -0,0 +1,19 @@
1
+ {
2
+ "name": "@anton-kochev/pi-extensions",
3
+ "version": "0.1.0",
4
+ "description": "Pi extensions.",
5
+ "keywords": ["pi-package"],
6
+ "license": "MIT",
7
+ "type": "module",
8
+ "files": ["squiggle", "README.md"],
9
+ "publishConfig": {
10
+ "access": "public"
11
+ },
12
+ "peerDependencies": {
13
+ "@earendil-works/pi-ai": "*",
14
+ "@earendil-works/pi-coding-agent": "*"
15
+ },
16
+ "pi": {
17
+ "extensions": ["./squiggle/extensions"]
18
+ }
19
+ }
@@ -0,0 +1,86 @@
1
+ # squiggle
2
+
3
+ Quietly polish grammar and spelling in your pi prompts.
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.
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 ./squiggle
15
+ ```
16
+
17
+ Project-local install:
18
+
19
+ ```bash
20
+ pi install ./squiggle -l
21
+ ```
22
+
23
+ Temporary test run:
24
+
25
+ ```bash
26
+ pi -e ./squiggle
27
+ ```
28
+
29
+ ## Pithos `.pithos` config
30
+
31
+ ```yaml
32
+ pi:
33
+ extensions:
34
+ squiggle: "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
+ squiggle: "git:https://github.com/anton-kochev/pi-extensions.git#v0.1.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
+ ## Status
72
+
73
+ Inside pi:
74
+
75
+ ```text
76
+ /squiggle-status
77
+ ```
78
+
79
+ ## Notes
80
+
81
+ This package imports pi runtime packages as peer dependencies:
82
+
83
+ - `@earendil-works/pi-ai`
84
+ - `@earendil-works/pi-coding-agent`
85
+
86
+ Do not bundle those dependencies; pi provides them at runtime.
@@ -0,0 +1,209 @@
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, ExtensionContext } from "@earendil-works/pi-coding-agent";
5
+
6
+ export default function squiggle(pi: ExtensionAPI) {
7
+ pi.registerCommand("squiggle-status", {
8
+ description: "Show whether squiggle is loaded",
9
+ handler: async (_args, ctx) => {
10
+ const config = loadConfig(ctx.cwd);
11
+ const model = selectCorrectionModel(ctx, config);
12
+ ctx.ui.notify(`squiggle is loaded (${config.mode}, ${formatModel(model)}).`, "info");
13
+ },
14
+ });
15
+
16
+ pi.on("input", async (event, ctx) => {
17
+ if (event.source === "extension") return { action: "continue" };
18
+
19
+ const config = loadConfig(ctx.cwd);
20
+ if (config.mode === "off") return { action: "continue" };
21
+ if (!event.text.trim()) return { action: "continue" };
22
+
23
+ const corrected = await correctWithModel(event.text, ctx, config);
24
+ if (!corrected || corrected === event.text) return { action: "continue" };
25
+
26
+ if (ctx.hasUI) ctx.ui.notify(formatColoredDiff(event.text, corrected), "info");
27
+
28
+ // In interactive mode, `transform` changes what the agent receives, but the
29
+ // already-submitted prompt may still be rendered as originally typed. To make
30
+ // the visible user message corrected too, swallow the original input and
31
+ // resubmit the corrected text as an extension-originated user message. The
32
+ // source guard above prevents a correction loop.
33
+ if (event.source === "interactive") {
34
+ pi.sendUserMessage(corrected);
35
+ return { action: "handled" };
36
+ }
37
+
38
+ return { action: "transform", text: corrected };
39
+ });
40
+ }
41
+
42
+ const CORRECTION_PROMPT = `You are a conservative grammar and spelling corrector for user prompts sent to a coding assistant.
43
+
44
+ Task:
45
+ - Correct spelling, grammar, capitalization, and punctuation.
46
+ - Preserve the user's meaning, tone, language, and intent.
47
+ - Do not answer the prompt.
48
+ - Do not add explanations, quotes, prefixes, markdown fences, or alternatives.
49
+ - If the input is already acceptable, return it unchanged.
50
+ - Return only the corrected prompt text.`;
51
+
52
+ const DEFAULT_CORRECTION_MODEL = "openai-codex/gpt-5.4-mini";
53
+ const DEFAULT_MAX_LLM_INPUT_CHARS = 500;
54
+
55
+ type SquiggleConfig = {
56
+ mode: "on" | "off";
57
+ model: string;
58
+ maxInputChars: number;
59
+ };
60
+
61
+ async function correctWithModel(input: string, ctx: ExtensionContext, config: SquiggleConfig): Promise<string | null> {
62
+ const model = selectCorrectionModel(ctx, config);
63
+ if (!model) return null;
64
+ if (input.length > config.maxInputChars) return null;
65
+
66
+ try {
67
+ const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
68
+ if (!auth.ok || !auth.apiKey) return null;
69
+
70
+ const userMessage: UserMessage = {
71
+ role: "user",
72
+ content: [{ type: "text", text: input }],
73
+ timestamp: Date.now(),
74
+ };
75
+
76
+ const response = await complete(
77
+ model,
78
+ { systemPrompt: CORRECTION_PROMPT, messages: [userMessage] },
79
+ { apiKey: auth.apiKey, headers: auth.headers },
80
+ );
81
+
82
+ if (response.stopReason === "aborted") return null;
83
+
84
+ return response.content
85
+ .filter((c): c is { type: "text"; text: string } => c.type === "text")
86
+ .map((c) => c.text)
87
+ .join("\n")
88
+ .trim();
89
+ } catch {
90
+ return null;
91
+ }
92
+ }
93
+
94
+ function loadConfig(cwd: string): SquiggleConfig {
95
+ const fileConfig = readConfigFile(cwd);
96
+ return {
97
+ mode: normalizeMode(process.env.SQUIGGLE_MODE ?? fileConfig.mode) ?? "on",
98
+ model: process.env.SQUIGGLE_MODEL ?? fileConfig.model ?? DEFAULT_CORRECTION_MODEL,
99
+ maxInputChars: normalizePositiveInt(process.env.SQUIGGLE_MAX_CHARS ?? fileConfig.maxInputChars) ?? DEFAULT_MAX_LLM_INPUT_CHARS,
100
+ };
101
+ }
102
+
103
+ function readConfigFile(cwd: string): Partial<SquiggleConfig> {
104
+ const path = join(cwd, ".pi", "squiggle.json");
105
+ if (!existsSync(path)) return {};
106
+ try {
107
+ const parsed = JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown>;
108
+ return {
109
+ mode: typeof parsed.mode === "string" ? normalizeMode(parsed.mode) : undefined,
110
+ model: typeof parsed.model === "string" ? parsed.model : undefined,
111
+ maxInputChars: normalizePositiveInt(parsed.maxInputChars),
112
+ };
113
+ } catch {
114
+ return {};
115
+ }
116
+ }
117
+
118
+ function normalizeMode(value: unknown): SquiggleConfig["mode"] | undefined {
119
+ return value === "on" || value === "off" ? value : undefined;
120
+ }
121
+
122
+ function normalizePositiveInt(value: unknown): number | undefined {
123
+ const parsed = typeof value === "number" ? value : typeof value === "string" ? Number.parseInt(value, 10) : Number.NaN;
124
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined;
125
+ }
126
+
127
+ function selectCorrectionModel(ctx: ExtensionContext, config: SquiggleConfig) {
128
+ const configured = parseModelSpec(config.model);
129
+ if (configured) {
130
+ const model = ctx.modelRegistry.find(configured.provider, configured.model);
131
+ if (model) return model;
132
+ }
133
+ return ctx.model;
134
+ }
135
+
136
+ function parseModelSpec(spec: string): { provider: string; model: string } | null {
137
+ const slash = spec.indexOf("/");
138
+ if (slash <= 0 || slash === spec.length - 1) return null;
139
+ return { provider: spec.slice(0, slash), model: spec.slice(slash + 1) };
140
+ }
141
+
142
+ function formatModel(model: ReturnType<typeof selectCorrectionModel>): string {
143
+ return model ? `${model.provider}/${model.id}` : "no model";
144
+ }
145
+
146
+ type DiffOp = {
147
+ type: "same" | "add" | "remove";
148
+ text: string;
149
+ };
150
+
151
+ function formatColoredDiff(before: string, after: string): string {
152
+ const dim = "\x1b[90;3m";
153
+ const same = "\x1b[90;3m";
154
+ const added = "\x1b[32;3m";
155
+ const removed = "\x1b[31;3m";
156
+ const reset = "\x1b[0m";
157
+
158
+ const rendered = diffChars(before.trim(), after.trim())
159
+ .map((op) => {
160
+ if (op.type === "add") return `${added}${op.text}${reset}`;
161
+ if (op.type === "remove") return `${removed}${op.text}${reset}`;
162
+ return `${same}${op.text}${reset}`;
163
+ })
164
+ .join("");
165
+
166
+ return `${dim}squiggle:${reset}\n${rendered}`;
167
+ }
168
+
169
+ function diffChars(before: string, after: string): DiffOp[] {
170
+ const beforeChars = Array.from(before);
171
+ const afterChars = Array.from(after);
172
+ const rows = beforeChars.length + 1;
173
+ const cols = afterChars.length + 1;
174
+ const dp: number[][] = Array.from({ length: rows }, () => Array(cols).fill(0));
175
+
176
+ for (let i = beforeChars.length - 1; i >= 0; i--) {
177
+ for (let j = afterChars.length - 1; j >= 0; j--) {
178
+ dp[i]![j] = beforeChars[i] === afterChars[j] ? dp[i + 1]![j + 1]! + 1 : Math.max(dp[i + 1]![j]!, dp[i]![j + 1]!);
179
+ }
180
+ }
181
+
182
+ const ops: DiffOp[] = [];
183
+ let i = 0;
184
+ let j = 0;
185
+ while (i < beforeChars.length || j < afterChars.length) {
186
+ if (i < beforeChars.length && j < afterChars.length && beforeChars[i] === afterChars[j]) {
187
+ pushDiffOp(ops, "same", afterChars[j]!);
188
+ i++;
189
+ j++;
190
+ } else if (j < afterChars.length && (i === beforeChars.length || dp[i]![j + 1]! > dp[i + 1]![j]!)) {
191
+ pushDiffOp(ops, "add", afterChars[j]!);
192
+ j++;
193
+ } else if (i < beforeChars.length) {
194
+ pushDiffOp(ops, "remove", beforeChars[i]!);
195
+ i++;
196
+ }
197
+ }
198
+
199
+ return ops;
200
+ }
201
+
202
+ function pushDiffOp(ops: DiffOp[], type: DiffOp["type"], text: string) {
203
+ const last = ops.at(-1);
204
+ if (last?.type === type) {
205
+ last.text += text;
206
+ return;
207
+ }
208
+ ops.push({ type, text });
209
+ }
@@ -0,0 +1 @@
1
+ export { default } from "./extensions/squiggle.ts";
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "squiggle",
3
+ "version": "0.1.0",
4
+ "description": "Quietly polish grammar and spelling in your pi prompts.",
5
+ "keywords": [
6
+ "pi-package",
7
+ "pi-extension",
8
+ "grammar",
9
+ "writing"
10
+ ],
11
+ "license": "MIT",
12
+ "type": "module",
13
+ "peerDependencies": {
14
+ "@earendil-works/pi-ai": "*",
15
+ "@earendil-works/pi-coding-agent": "*"
16
+ },
17
+ "pi": {
18
+ "extensions": [
19
+ "./extensions"
20
+ ]
21
+ },
22
+ "files": [
23
+ "index.ts",
24
+ "extensions",
25
+ "README.md"
26
+ ]
27
+ }