@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 CHANGED
@@ -39,33 +39,40 @@ pi -e ./pithos.squiggle
39
39
  ```yaml
40
40
  pi:
41
41
  extensions:
42
- "@pithos-kit/squiggle": "npm:0.4.1"
42
+ "@pithos-kit/squiggle": "npm:0.6.0"
43
43
  ```
44
44
 
45
45
  ## Configuration
46
46
 
47
- Create `.pi/squiggle.json` in your project:
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 file:
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 --help # show toggle usage
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/squiggle.ts";
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.4.1",
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, and input limit." },
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": {
@@ -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
- }