@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 CHANGED
@@ -39,7 +39,7 @@ pi -e ./pithos.squiggle
39
39
  ```yaml
40
40
  pi:
41
41
  extensions:
42
- "@pithos-kit/squiggle": "npm:0.4.0"
42
+ "@pithos-kit/squiggle": "npm:0.5.0"
43
43
  ```
44
44
 
45
45
  ## Configuration
@@ -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 default function squiggle(pi: ExtensionAPI) {
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 (_event, ctx) => {
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 correctWithModel(event.text, ctx, config).finally(stopIndicator);
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") return null;
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
- return response.content
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
- } catch {
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/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.0",
3
+ "version": "0.5.0",
4
4
  "description": "Quietly polish grammar and spelling in your Pi prompts.",
5
5
  "keywords": [
6
6
  "pi-package",