feat: add logger and twilio poll backoff

This commit is contained in:
Peter Steinberger
2025-11-25 03:48:49 +01:00
parent 8bd406f6b1
commit 7fa071267c
8 changed files with 151 additions and 28 deletions

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { parseClaudeJsonText } from "./claude.js";
import { parseClaudeJson, parseClaudeJsonText } from "./claude.js";
describe("claude JSON parsing", () => {
it("extracts text from single JSON object", () => {
@@ -16,5 +16,17 @@ describe("claude JSON parsing", () => {
it("returns undefined on invalid JSON", () => {
expect(parseClaudeJsonText("not json")).toBeUndefined();
});
});
it("extracts text from Claude CLI result field and preserves metadata", () => {
const sample = {
type: "result",
subtype: "success",
result: "hello from result field",
duration_ms: 1234,
usage: { server_tool_use: { tool_a: 2 } },
};
const parsed = parseClaudeJson(JSON.stringify(sample));
expect(parsed?.text).toBe("hello from result field");
expect(parsed?.parsed).toMatchObject({ duration_ms: 1234 });
});
});

View File

@@ -16,6 +16,7 @@ function extractClaudeText(payload: unknown): string | undefined {
}
if (typeof payload === "object") {
const obj = payload as Record<string, unknown>;
if (typeof obj.result === "string") return obj.result;
if (typeof obj.text === "string") return obj.text;
if (typeof obj.completion === "string") return obj.completion;
if (typeof obj.output === "string") return obj.output;
@@ -45,17 +46,32 @@ function extractClaudeText(payload: unknown): string | undefined {
return undefined;
}
export function parseClaudeJsonText(raw: string): string | undefined {
// Handle a single JSON blob or newline-delimited JSON; return the first extracted text.
export type ClaudeJsonParseResult = {
text?: string;
parsed: unknown;
};
export function parseClaudeJson(raw: string): ClaudeJsonParseResult | undefined {
// Handle a single JSON blob or newline-delimited JSON; return the first parsed payload.
let firstParsed: unknown;
const candidates = [raw, ...raw.split(/\n+/).map((s) => s.trim()).filter(Boolean)];
for (const candidate of candidates) {
try {
const parsed = JSON.parse(candidate);
if (firstParsed === undefined) firstParsed = parsed;
const text = extractClaudeText(parsed);
if (text) return text;
if (text) return { parsed, text };
} catch {
// ignore parse errors; try next candidate
}
}
if (firstParsed !== undefined) {
return { parsed: firstParsed, text: extractClaudeText(firstParsed) };
}
return undefined;
}
export function parseClaudeJsonText(raw: string): string | undefined {
const parsed = parseClaudeJson(raw);
return parsed?.text;
}

View File

@@ -1,7 +1,7 @@
import crypto from "node:crypto";
import path from "node:path";
import { CLAUDE_BIN, parseClaudeJsonText } from "./claude.js";
import { CLAUDE_BIN, parseClaudeJson } from "./claude.js";
import {
applyTemplate,
type MsgContext,
@@ -39,6 +39,52 @@ type GetReplyOptions = {
onReplyStart?: () => Promise<void> | void;
};
function summarizeClaudeMetadata(payload: unknown): string | undefined {
if (!payload || typeof payload !== "object") return undefined;
const obj = payload as Record<string, unknown>;
const parts: string[] = [];
if (typeof obj.duration_ms === "number") {
parts.push(`duration=${obj.duration_ms}ms`);
}
if (typeof obj.duration_api_ms === "number") {
parts.push(`api=${obj.duration_api_ms}ms`);
}
if (typeof obj.num_turns === "number") {
parts.push(`turns=${obj.num_turns}`);
}
if (typeof obj.total_cost_usd === "number") {
parts.push(`cost=$${obj.total_cost_usd.toFixed(4)}`);
}
const usage = obj.usage;
if (usage && typeof usage === "object") {
const serverToolUse = (usage as { server_tool_use?: Record<string, unknown> })
.server_tool_use;
if (serverToolUse && typeof serverToolUse === "object") {
const toolCalls = Object.values(serverToolUse).reduce((sum, val) => {
if (typeof val === "number") return sum + val;
return sum;
}, 0);
if (toolCalls > 0) parts.push(`tool_calls=${toolCalls}`);
}
}
const modelUsage = obj.modelUsage;
if (modelUsage && typeof modelUsage === "object") {
const models = Object.keys(modelUsage as Record<string, unknown>);
if (models.length) {
const display =
models.length > 2
? `${models.slice(0, 2).join(",")}+${models.length - 2}`
: models.join(",");
parts.push(`models=${display}`);
}
}
return parts.length ? parts.join(", ") : undefined;
}
export async function getReplyFromConfig(
ctx: MsgContext,
opts?: GetReplyOptions,
@@ -216,18 +262,26 @@ export async function getReplyFromConfig(
finalArgv,
timeoutMs,
);
let trimmed = stdout.trim();
const rawStdout = stdout.trim();
let trimmed = rawStdout;
if (stderr?.trim()) {
logVerbose(`Command auto-reply stderr: ${stderr.trim()}`);
}
if (reply.claudeOutputFormat === "json" && trimmed) {
// Claude JSON mode: extract the human text for both logging and reply.
const extracted = parseClaudeJsonText(trimmed);
if (extracted) {
// Claude JSON mode: extract the human text for both logging and reply while keeping metadata.
const parsed = parseClaudeJson(trimmed);
if (parsed?.parsed && isVerbose()) {
const summary = summarizeClaudeMetadata(parsed.parsed);
if (summary) logVerbose(`Claude JSON meta: ${summary}`);
logVerbose(
`Claude JSON parsed -> ${extracted.slice(0, 120)}${extracted.length > 120 ? "…" : ""}`,
`Claude JSON raw: ${JSON.stringify(parsed.parsed, null, 2)}`,
);
trimmed = extracted.trim();
}
if (parsed?.text) {
logVerbose(
`Claude JSON parsed -> ${parsed.text.slice(0, 120)}${parsed.text.length > 120 ? "…" : ""}`,
);
trimmed = parsed.text.trim();
} else {
logVerbose("Claude JSON parse failed; returning raw stdout");
}