@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 +41 -0
- package/package.json +19 -0
- package/squiggle/README.md +86 -0
- package/squiggle/extensions/squiggle.ts +209 -0
- package/squiggle/index.ts +1 -0
- package/squiggle/package.json +27 -0
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
|
+
}
|