@pithos-kit/squiggle 0.4.0 → 0.5.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 +1 -1
- package/extensions/{squiggle.ts → index.ts} +28 -18
- package/extensions/logging.ts +152 -0
- package/index.ts +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -2,6 +2,7 @@ import { existsSync, readFileSync } from "node:fs";
|
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { complete, type UserMessage } from "@earendil-works/pi-ai";
|
|
4
4
|
import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import { createPithosLogger, errorMetadata, modelMetadata, usageMetadata, type PithosLogger } from "./logging.ts";
|
|
5
6
|
|
|
6
7
|
const SQUIGGLE_HELP = `Usage: /squiggle toggle
|
|
7
8
|
|
|
@@ -17,16 +18,23 @@ Show whether Squiggle is enabled and which correction model it uses.
|
|
|
17
18
|
Options:
|
|
18
19
|
--help, -h Show this help`;
|
|
19
20
|
|
|
20
|
-
export
|
|
21
|
+
export function registerSquiggle(
|
|
22
|
+
pi: ExtensionAPI,
|
|
23
|
+
correctPrompt: (input: string, ctx: ExtensionContext, config: SquiggleConfig, log?: PithosLogger) => Promise<string | null> = correctWithModel,
|
|
24
|
+
) {
|
|
25
|
+
const log = createPithosLogger();
|
|
26
|
+
log.info("extension.register");
|
|
21
27
|
let runtimeMode: SquiggleConfig["mode"] | undefined;
|
|
22
28
|
|
|
23
|
-
pi.on("session_start", async (
|
|
29
|
+
pi.on("session_start", async (event, ctx) => {
|
|
24
30
|
runtimeMode = restoreRuntimeMode(ctx);
|
|
31
|
+
log.info("session.start", { reason: event.reason, sessionId: (ctx.sessionManager as { getSessionId?: () => string } | undefined)?.getSessionId?.(), runtimeMode });
|
|
25
32
|
});
|
|
26
33
|
|
|
27
34
|
pi.registerCommand("squiggle", {
|
|
28
35
|
description: "Toggle squiggle on/off",
|
|
29
36
|
handler: async (args, ctx) => {
|
|
37
|
+
log.info("command.squiggle", { sessionId: (ctx.sessionManager as { getSessionId?: () => string } | undefined)?.getSessionId?.(), args: args.trim() });
|
|
30
38
|
if (isHelpRequest(args)) return emitHelp(ctx, SQUIGGLE_HELP);
|
|
31
39
|
|
|
32
40
|
const command = args.trim().toLowerCase();
|
|
@@ -45,6 +53,7 @@ export default function squiggle(pi: ExtensionAPI) {
|
|
|
45
53
|
pi.registerCommand("squiggle-status", {
|
|
46
54
|
description: "Show whether squiggle is loaded",
|
|
47
55
|
handler: async (args, ctx) => {
|
|
56
|
+
log.info("command.squiggle-status", { sessionId: (ctx.sessionManager as { getSessionId?: () => string } | undefined)?.getSessionId?.() });
|
|
48
57
|
if (isHelpRequest(args)) return emitHelp(ctx, SQUIGGLE_STATUS_HELP);
|
|
49
58
|
ctx.ui.notify(formatStatus(ctx, loadEffectiveConfig(ctx.cwd, runtimeMode)), "info");
|
|
50
59
|
},
|
|
@@ -58,25 +67,18 @@ export default function squiggle(pi: ExtensionAPI) {
|
|
|
58
67
|
if (!event.text.trim()) return { action: "continue" };
|
|
59
68
|
|
|
60
69
|
const stopIndicator = startSquiggleIndicator(ctx);
|
|
61
|
-
const corrected = await
|
|
70
|
+
const corrected = await correctPrompt(event.text, ctx, config, log).finally(stopIndicator);
|
|
62
71
|
if (!corrected || corrected === event.text) return { action: "continue" };
|
|
63
72
|
|
|
64
73
|
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
74
|
return { action: "transform", text: corrected };
|
|
77
75
|
});
|
|
78
76
|
}
|
|
79
77
|
|
|
78
|
+
export default function squiggle(pi: ExtensionAPI): void {
|
|
79
|
+
registerSquiggle(pi);
|
|
80
|
+
}
|
|
81
|
+
|
|
80
82
|
function isHelpRequest(args: string): boolean {
|
|
81
83
|
const normalized = args.trim();
|
|
82
84
|
return normalized === "--help" || normalized === "-h";
|
|
@@ -106,12 +108,14 @@ type SquiggleConfig = {
|
|
|
106
108
|
maxInputChars: number;
|
|
107
109
|
};
|
|
108
110
|
|
|
109
|
-
async function correctWithModel(input: string, ctx: ExtensionContext, config: SquiggleConfig): Promise<string | null> {
|
|
111
|
+
async function correctWithModel(input: string, ctx: ExtensionContext, config: SquiggleConfig, log = createPithosLogger()): Promise<string | null> {
|
|
110
112
|
const model = selectCorrectionModel(ctx, config);
|
|
111
113
|
if (!model) return null;
|
|
112
114
|
if (input.length > config.maxInputChars) return null;
|
|
113
115
|
|
|
114
116
|
try {
|
|
117
|
+
const started = Date.now();
|
|
118
|
+
log.info("model.correct.start", { sessionId: (ctx.sessionManager as { getSessionId?: () => string } | undefined)?.getSessionId?.(), inputChars: input.length, ...modelMetadata(model) });
|
|
115
119
|
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
|
|
116
120
|
if (!auth.ok || !auth.apiKey) return null;
|
|
117
121
|
|
|
@@ -127,14 +131,20 @@ async function correctWithModel(input: string, ctx: ExtensionContext, config: Sq
|
|
|
127
131
|
{ apiKey: auth.apiKey, headers: auth.headers },
|
|
128
132
|
);
|
|
129
133
|
|
|
130
|
-
if (response.stopReason === "aborted")
|
|
134
|
+
if (response.stopReason === "aborted") {
|
|
135
|
+
log.warn("model.correct.aborted", { sessionId: (ctx.sessionManager as { getSessionId?: () => string } | undefined)?.getSessionId?.(), durationMs: Date.now() - started, ...modelMetadata(model), usage: usageMetadata(response.usage) });
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
131
138
|
|
|
132
|
-
|
|
139
|
+
const corrected = response.content
|
|
133
140
|
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
|
134
141
|
.map((c) => c.text)
|
|
135
142
|
.join("\n")
|
|
136
143
|
.trim();
|
|
137
|
-
|
|
144
|
+
log.info("model.correct.complete", { sessionId: (ctx.sessionManager as { getSessionId?: () => string } | undefined)?.getSessionId?.(), durationMs: Date.now() - started, inputChars: input.length, changed: corrected !== input, ...modelMetadata(model), usage: usageMetadata(response.usage) });
|
|
145
|
+
return corrected;
|
|
146
|
+
} catch (error) {
|
|
147
|
+
log.warn("model.correct.error", { sessionId: (ctx.sessionManager as { getSessionId?: () => string } | undefined)?.getSessionId?.(), inputChars: input.length, ...modelMetadata(model), error: errorMetadata(error) });
|
|
138
148
|
return null;
|
|
139
149
|
}
|
|
140
150
|
}
|
|
@@ -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";
|