166 lines
5.7 KiB
TypeScript
166 lines
5.7 KiB
TypeScript
// Helpers specific to Claude CLI output/argv handling.
|
|
import { z } from "zod";
|
|
|
|
// Preferred binary name for Claude CLI invocations.
|
|
export const CLAUDE_BIN = "claude";
|
|
export const CLAUDE_IDENTITY_PREFIX =
|
|
"You are Clawd (Claude) running on the user's Mac via warelay. Your scratchpad is /Users/steipete/clawd; this is your folder and you can add what you like in markdown files and/or images. You don't need to be concise, but WhatsApp replies must stay under ~1500 characters. Media you can send: images ≤6MB, audio/video ≤16MB, documents ≤100MB. The prompt may include a media path and an optional Transcript: section—use them when present. If a prompt is a heartbeat poll and nothing needs attention, reply with exactly HEARTBEAT_OK and nothing else; for any alert, do not include HEARTBEAT_OK.";
|
|
|
|
function extractClaudeText(payload: unknown): string | undefined {
|
|
// Best-effort walker to find the primary text field in Claude JSON outputs.
|
|
if (payload == null) return undefined;
|
|
if (typeof payload === "string") return payload;
|
|
if (Array.isArray(payload)) {
|
|
for (const item of payload) {
|
|
const found = extractClaudeText(item);
|
|
if (found) return found;
|
|
}
|
|
return 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;
|
|
if (obj.message) {
|
|
const inner = extractClaudeText(obj.message);
|
|
if (inner) return inner;
|
|
}
|
|
if (Array.isArray(obj.messages)) {
|
|
const inner = extractClaudeText(obj.messages);
|
|
if (inner) return inner;
|
|
}
|
|
if (Array.isArray(obj.content)) {
|
|
for (const block of obj.content) {
|
|
if (
|
|
block &&
|
|
typeof block === "object" &&
|
|
(block as { type?: string }).type === "text" &&
|
|
typeof (block as { text?: unknown }).text === "string"
|
|
) {
|
|
return (block as { text: string }).text;
|
|
}
|
|
const inner = extractClaudeText(block);
|
|
if (inner) return inner;
|
|
}
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
export type ClaudeJsonParseResult = {
|
|
text?: string;
|
|
parsed: unknown;
|
|
valid: boolean;
|
|
};
|
|
|
|
const ClaudeJsonSchema = z
|
|
.object({
|
|
type: z.string().optional(),
|
|
subtype: z.string().optional(),
|
|
is_error: z.boolean().optional(),
|
|
result: z.string().optional(),
|
|
text: z.string().optional(),
|
|
completion: z.string().optional(),
|
|
output: z.string().optional(),
|
|
message: z.any().optional(),
|
|
messages: z.any().optional(),
|
|
content: z.any().optional(),
|
|
duration_ms: z.number().optional(),
|
|
duration_api_ms: z.number().optional(),
|
|
num_turns: z.number().optional(),
|
|
session_id: z.string().optional(),
|
|
total_cost_usd: z.number().optional(),
|
|
usage: z.record(z.string(), z.any()).optional(),
|
|
modelUsage: z.record(z.string(), z.any()).optional(),
|
|
})
|
|
.passthrough()
|
|
.refine(
|
|
(obj) =>
|
|
typeof obj.result === "string" ||
|
|
typeof obj.text === "string" ||
|
|
typeof obj.completion === "string" ||
|
|
typeof obj.output === "string" ||
|
|
obj.message !== undefined ||
|
|
obj.messages !== undefined ||
|
|
obj.content !== undefined,
|
|
{ message: "Not a Claude JSON payload" },
|
|
);
|
|
|
|
type ClaudeSafeParse = ReturnType<typeof ClaudeJsonSchema.safeParse>;
|
|
|
|
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;
|
|
let validation: ClaudeSafeParse | { success: false };
|
|
try {
|
|
validation = ClaudeJsonSchema.safeParse(parsed);
|
|
} catch {
|
|
validation = { success: false } as const;
|
|
}
|
|
const validated = validation.success ? validation.data : parsed;
|
|
const isLikelyClaude =
|
|
typeof validated === "object" &&
|
|
validated !== null &&
|
|
("result" in validated ||
|
|
"text" in validated ||
|
|
"completion" in validated ||
|
|
"output" in validated);
|
|
const text = extractClaudeText(validated);
|
|
if (text)
|
|
return {
|
|
parsed: validated,
|
|
text,
|
|
// Treat parse as valid when schema passes or we still see Claude-like shape.
|
|
valid: Boolean(validation?.success || isLikelyClaude),
|
|
};
|
|
} catch {
|
|
// ignore parse errors; try next candidate
|
|
}
|
|
}
|
|
if (firstParsed !== undefined) {
|
|
let validation: ClaudeSafeParse | { success: false };
|
|
try {
|
|
validation = ClaudeJsonSchema.safeParse(firstParsed);
|
|
} catch {
|
|
validation = { success: false } as const;
|
|
}
|
|
const validated = validation.success ? validation.data : firstParsed;
|
|
const isLikelyClaude =
|
|
typeof validated === "object" &&
|
|
validated !== null &&
|
|
("result" in validated ||
|
|
"text" in validated ||
|
|
"completion" in validated ||
|
|
"output" in validated);
|
|
return {
|
|
parsed: validated,
|
|
text: extractClaudeText(validated),
|
|
valid: Boolean(validation?.success || isLikelyClaude),
|
|
};
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
export function parseClaudeJsonText(raw: string): string | undefined {
|
|
const parsed = parseClaudeJson(raw);
|
|
return parsed?.text;
|
|
}
|
|
|
|
// Re-export from command-reply for backwards compatibility
|
|
export { summarizeClaudeMetadata } from "./command-reply.js";
|