@pithos-kit/squiggle 0.4.1 → 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.1"
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
 
@@ -19,17 +20,21 @@ Options:
19
20
 
20
21
  export function registerSquiggle(
21
22
  pi: ExtensionAPI,
22
- correctPrompt: (input: string, ctx: ExtensionContext, config: SquiggleConfig) => Promise<string | null> = correctWithModel,
23
+ correctPrompt: (input: string, ctx: ExtensionContext, config: SquiggleConfig, log?: PithosLogger) => Promise<string | null> = correctWithModel,
23
24
  ) {
25
+ const log = createPithosLogger();
26
+ log.info("extension.register");
24
27
  let runtimeMode: SquiggleConfig["mode"] | undefined;
25
28
 
26
- pi.on("session_start", async (_event, ctx) => {
29
+ pi.on("session_start", async (event, ctx) => {
27
30
  runtimeMode = restoreRuntimeMode(ctx);
31
+ log.info("session.start", { reason: event.reason, sessionId: (ctx.sessionManager as { getSessionId?: () => string } | undefined)?.getSessionId?.(), runtimeMode });
28
32
  });
29
33
 
30
34
  pi.registerCommand("squiggle", {
31
35
  description: "Toggle squiggle on/off",
32
36
  handler: async (args, ctx) => {
37
+ log.info("command.squiggle", { sessionId: (ctx.sessionManager as { getSessionId?: () => string } | undefined)?.getSessionId?.(), args: args.trim() });
33
38
  if (isHelpRequest(args)) return emitHelp(ctx, SQUIGGLE_HELP);
34
39
 
35
40
  const command = args.trim().toLowerCase();
@@ -48,6 +53,7 @@ export function registerSquiggle(
48
53
  pi.registerCommand("squiggle-status", {
49
54
  description: "Show whether squiggle is loaded",
50
55
  handler: async (args, ctx) => {
56
+ log.info("command.squiggle-status", { sessionId: (ctx.sessionManager as { getSessionId?: () => string } | undefined)?.getSessionId?.() });
51
57
  if (isHelpRequest(args)) return emitHelp(ctx, SQUIGGLE_STATUS_HELP);
52
58
  ctx.ui.notify(formatStatus(ctx, loadEffectiveConfig(ctx.cwd, runtimeMode)), "info");
53
59
  },
@@ -61,7 +67,7 @@ export function registerSquiggle(
61
67
  if (!event.text.trim()) return { action: "continue" };
62
68
 
63
69
  const stopIndicator = startSquiggleIndicator(ctx);
64
- const corrected = await correctPrompt(event.text, ctx, config).finally(stopIndicator);
70
+ const corrected = await correctPrompt(event.text, ctx, config, log).finally(stopIndicator);
65
71
  if (!corrected || corrected === event.text) return { action: "continue" };
66
72
 
67
73
  if (ctx.hasUI) ctx.ui.notify(formatColoredDiff(event.text, corrected), "info");
@@ -102,12 +108,14 @@ type SquiggleConfig = {
102
108
  maxInputChars: number;
103
109
  };
104
110
 
105
- 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> {
106
112
  const model = selectCorrectionModel(ctx, config);
107
113
  if (!model) return null;
108
114
  if (input.length > config.maxInputChars) return null;
109
115
 
110
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) });
111
119
  const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
112
120
  if (!auth.ok || !auth.apiKey) return null;
113
121
 
@@ -123,14 +131,20 @@ async function correctWithModel(input: string, ctx: ExtensionContext, config: Sq
123
131
  { apiKey: auth.apiKey, headers: auth.headers },
124
132
  );
125
133
 
126
- 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
+ }
127
138
 
128
- return response.content
139
+ const corrected = response.content
129
140
  .filter((c): c is { type: "text"; text: string } => c.type === "text")
130
141
  .map((c) => c.text)
131
142
  .join("\n")
132
143
  .trim();
133
- } 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) });
134
148
  return null;
135
149
  }
136
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.1",
3
+ "version": "0.5.0",
4
4
  "description": "Quietly polish grammar and spelling in your Pi prompts.",
5
5
  "keywords": [
6
6
  "pi-package",