chore: format to 2-space and bump changelog
This commit is contained in:
@@ -3,37 +3,37 @@ import { describe, expect, it } from "vitest";
|
||||
import { parseClaudeJson, parseClaudeJsonText } from "./claude.js";
|
||||
|
||||
describe("claude JSON parsing", () => {
|
||||
it("extracts text from single JSON object", () => {
|
||||
const out = parseClaudeJsonText('{"text":"hello"}');
|
||||
expect(out).toBe("hello");
|
||||
});
|
||||
it("extracts text from single JSON object", () => {
|
||||
const out = parseClaudeJsonText('{"text":"hello"}');
|
||||
expect(out).toBe("hello");
|
||||
});
|
||||
|
||||
it("extracts from newline-delimited JSON", () => {
|
||||
const out = parseClaudeJsonText('{"irrelevant":1}\n{"text":"there"}');
|
||||
expect(out).toBe("there");
|
||||
});
|
||||
it("extracts from newline-delimited JSON", () => {
|
||||
const out = parseClaudeJsonText('{"irrelevant":1}\n{"text":"there"}');
|
||||
expect(out).toBe("there");
|
||||
});
|
||||
|
||||
it("returns undefined on invalid JSON", () => {
|
||||
expect(parseClaudeJsonText("not json")).toBeUndefined();
|
||||
});
|
||||
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 });
|
||||
expect(parsed?.valid).toBe(true);
|
||||
});
|
||||
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 });
|
||||
expect(parsed?.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("marks invalid Claude JSON as invalid but still attempts text extraction", () => {
|
||||
const parsed = parseClaudeJson('{"unexpected":1}');
|
||||
expect(parsed?.valid).toBe(false);
|
||||
expect(parsed?.text).toBeUndefined();
|
||||
});
|
||||
it("marks invalid Claude JSON as invalid but still attempts text extraction", () => {
|
||||
const parsed = parseClaudeJson('{"unexpected":1}');
|
||||
expect(parsed?.valid).toBe(false);
|
||||
expect(parsed?.text).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,159 +4,159 @@ 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.";
|
||||
"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.";
|
||||
|
||||
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;
|
||||
// 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;
|
||||
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" },
|
||||
);
|
||||
.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,
|
||||
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;
|
||||
// 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;
|
||||
const parsed = parseClaudeJson(raw);
|
||||
return parsed?.text;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,24 +1,24 @@
|
||||
export type MsgContext = {
|
||||
Body?: string;
|
||||
From?: string;
|
||||
To?: string;
|
||||
MessageSid?: string;
|
||||
MediaPath?: string;
|
||||
MediaUrl?: string;
|
||||
MediaType?: string;
|
||||
Transcript?: string;
|
||||
Body?: string;
|
||||
From?: string;
|
||||
To?: string;
|
||||
MessageSid?: string;
|
||||
MediaPath?: string;
|
||||
MediaUrl?: string;
|
||||
MediaType?: string;
|
||||
Transcript?: string;
|
||||
};
|
||||
|
||||
export type TemplateContext = MsgContext & {
|
||||
BodyStripped?: string;
|
||||
SessionId?: string;
|
||||
IsNewSession?: string;
|
||||
BodyStripped?: string;
|
||||
SessionId?: string;
|
||||
IsNewSession?: string;
|
||||
};
|
||||
|
||||
// Simple {{Placeholder}} interpolation using inbound message context.
|
||||
export function applyTemplate(str: string, ctx: TemplateContext) {
|
||||
return str.replace(/{{\s*(\w+)\s*}}/g, (_, key) => {
|
||||
const value = (ctx as Record<string, unknown>)[key];
|
||||
return value == null ? "" : String(value);
|
||||
});
|
||||
return str.replace(/{{\s*(\w+)\s*}}/g, (_, key) => {
|
||||
const value = (ctx as Record<string, unknown>)[key];
|
||||
return value == null ? "" : String(value);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user