Merge branch 'fix/media-replies'

main
Peter Steinberger 2025-12-02 21:07:45 +00:00
commit ccab950d16
12 changed files with 72 additions and 38 deletions

View File

@ -17,4 +17,4 @@ export function getAgentSpec(kind: AgentKind): AgentSpec {
return specs[kind]; return specs[kind];
} }
export { AgentKind, AgentMeta, AgentParseResult } from "./types.js"; export type { AgentKind, AgentMeta, AgentParseResult } from "./types.js";

View File

@ -4,7 +4,7 @@ import { z } from "zod";
// Preferred binary name for Claude CLI invocations. // Preferred binary name for Claude CLI invocations.
export const CLAUDE_BIN = "claude"; export const CLAUDE_BIN = "claude";
export const CLAUDE_IDENTITY_PREFIX = 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."; "You are Clawd (Claude) running on the user's Mac via warelay. Keep WhatsApp replies under ~1500 characters. Your scratchpad is ~/clawd; this is your folder and you can add what you like in markdown files and/or images. You can send media by including MEDIA:/path/to/file.jpg on its own line (no spaces in path). Media limits: 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 { function extractClaudeText(payload: unknown): string | undefined {
// Best-effort walker to find the primary text field in Claude JSON outputs. // Best-effort walker to find the primary text field in Claude JSON outputs.

View File

@ -189,7 +189,7 @@ export async function runCommandReply(
systemSent, systemSent,
identityPrefix: agentCfg.identityPrefix, identityPrefix: agentCfg.identityPrefix,
format: agentCfg.format, format: agentCfg.format,
}) })
: argv; : argv;
logVerbose( logVerbose(
@ -208,7 +208,7 @@ export async function runCommandReply(
const rpcArgv = (() => { const rpcArgv = (() => {
const copy = [...finalArgv]; const copy = [...finalArgv];
copy.splice(bodyIndex, 1); copy.splice(bodyIndex, 1);
const modeIdx = copy.findIndex((a) => a === "--mode"); const modeIdx = copy.indexOf("--mode");
if (modeIdx >= 0 && copy[modeIdx + 1]) { if (modeIdx >= 0 && copy[modeIdx + 1]) {
copy.splice(modeIdx, 2, "--mode", "rpc"); copy.splice(modeIdx, 2, "--mode", "rpc");
} else if (!copy.includes("--mode")) { } else if (!copy.includes("--mode")) {
@ -231,7 +231,9 @@ export async function runCommandReply(
queuedMs = waitMs; queuedMs = waitMs;
queuedAhead = ahead; queuedAhead = ahead;
if (isVerbose()) { if (isVerbose()) {
logVerbose(`Command auto-reply queued for ${waitMs}ms (${queuedAhead} ahead)`); logVerbose(
`Command auto-reply queued for ${waitMs}ms (${queuedAhead} ahead)`,
);
} }
}, },
}); });
@ -266,7 +268,10 @@ export async function runCommandReply(
verboseLog(`Command auto-reply stdout (trimmed): ${trimmed || "<empty>"}`); verboseLog(`Command auto-reply stdout (trimmed): ${trimmed || "<empty>"}`);
const elapsed = Date.now() - started; const elapsed = Date.now() - started;
verboseLog(`Command auto-reply finished in ${elapsed}ms`); verboseLog(`Command auto-reply finished in ${elapsed}ms`);
logger.info({ durationMs: elapsed, agent: agentKind, cwd: reply.cwd }, "command auto-reply finished"); logger.info(
{ durationMs: elapsed, agent: agentKind, cwd: reply.cwd },
"command auto-reply finished",
);
if ((code ?? 0) !== 0) { if ((code ?? 0) !== 0) {
console.error( console.error(
`Command auto-reply exited with code ${code ?? "unknown"} (signal: ${signal ?? "none"})`, `Command auto-reply exited with code ${code ?? "unknown"} (signal: ${signal ?? "none"})`,
@ -357,7 +362,10 @@ export async function runCommandReply(
return { payload, meta }; return { payload, meta };
} catch (err) { } catch (err) {
const elapsed = Date.now() - started; const elapsed = Date.now() - started;
logger.info({ durationMs: elapsed, agent: agentKind, cwd: reply.cwd }, "command auto-reply failed"); logger.info(
{ durationMs: elapsed, agent: agentKind, cwd: reply.cwd },
"command auto-reply failed",
);
const anyErr = err as { killed?: boolean; signal?: string }; const anyErr = err as { killed?: boolean; signal?: string };
const timeoutHit = anyErr.killed === true || anyErr.signal === "SIGKILL"; const timeoutHit = anyErr.killed === true || anyErr.signal === "SIGKILL";
const errorObj = err as { stdout?: string; stderr?: string }; const errorObj = err as { stdout?: string; stderr?: string };

View File

@ -263,7 +263,6 @@ export async function getReplyFromConfig(
}; };
sessionStore[sessionKey] = sessionEntry; sessionStore[sessionKey] = sessionEntry;
await saveSessionStore(storePath, sessionStore); await saveSessionStore(storePath, sessionStore);
systemSent = true;
} }
const prefixedBody = const prefixedBody =

View File

@ -895,7 +895,7 @@ describe("config and templating", () => {
const argv = runSpy.mock.calls[0][0]; const argv = runSpy.mock.calls[0][0];
expect(argv[0]).toBe("claude"); expect(argv[0]).toBe("claude");
expect(argv.at(-1)).toContain("You are Clawd (Claude)"); expect(argv.at(-1)).toContain("You are Clawd (Claude)");
expect(argv.at(-1)).toContain("/Users/steipete/clawd"); expect(argv.at(-1)).toContain("scratchpad");
expect(argv.at(-1)).toMatch(/hi$/); expect(argv.at(-1)).toMatch(/hi$/);
// The helper should auto-add print and output format flags without disturbing the prompt position. // The helper should auto-add print and output format flags without disturbing the prompt position.
expect(argv.includes("-p") || argv.includes("--print")).toBe(true); expect(argv.includes("-p") || argv.includes("--print")).toBe(true);
@ -963,7 +963,7 @@ describe("config and templating", () => {
expect(result?.text).toBe("Sure! What's up?"); expect(result?.text).toBe("Sure! What's up?");
const argv = runSpy.mock.calls[0][0]; const argv = runSpy.mock.calls[0][0];
expect(argv.at(-1)).toContain("You are Clawd (Claude)"); expect(argv.at(-1)).toContain("You are Clawd (Claude)");
expect(argv.at(-1)).toContain("/Users/steipete/clawd"); expect(argv.at(-1)).toContain("scratchpad");
}); });
it("serializes command auto-replies via the queue", async () => { it("serializes command auto-replies via the queue", async () => {

View File

@ -7,11 +7,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { setVerbose } from "./globals.js"; import { setVerbose } from "./globals.js";
import { logDebug, logError, logInfo, logSuccess, logWarn } from "./logger.js"; import { logDebug, logError, logInfo, logSuccess, logWarn } from "./logger.js";
import { import { DEFAULT_LOG_DIR, resetLogger, setLoggerOverride } from "./logging.js";
DEFAULT_LOG_DIR,
resetLogger,
setLoggerOverride,
} from "./logging.js";
import type { RuntimeEnv } from "./runtime.js"; import type { RuntimeEnv } from "./runtime.js";
describe("logger helpers", () => { describe("logger helpers", () => {

View File

@ -133,7 +133,11 @@ function pruneOldRollingLogs(dir: string): void {
const cutoff = Date.now() - MAX_LOG_AGE_MS; const cutoff = Date.now() - MAX_LOG_AGE_MS;
for (const entry of entries) { for (const entry of entries) {
if (!entry.isFile()) continue; if (!entry.isFile()) continue;
if (!entry.name.startsWith(`${LOG_PREFIX}-`) || !entry.name.endsWith(LOG_SUFFIX)) continue; if (
!entry.name.startsWith(`${LOG_PREFIX}-`) ||
!entry.name.endsWith(LOG_SUFFIX)
)
continue;
const fullPath = path.join(dir, entry.name); const fullPath = path.join(dir, entry.name);
try { try {
const stat = fs.statSync(fullPath); const stat = fs.statSync(fullPath);

View File

@ -54,7 +54,9 @@ describe("media server", () => {
const server = await startMediaServer(0, 5_000); const server = await startMediaServer(0, 5_000);
const port = (server.address() as AddressInfo).port; const port = (server.address() as AddressInfo).port;
// URL-encoded "../" to bypass client-side path normalization // URL-encoded "../" to bypass client-side path normalization
const res = await fetch(`http://localhost:${port}/media/%2e%2e%2fpackage.json`); const res = await fetch(
`http://localhost:${port}/media/%2e%2e%2fpackage.json`,
);
expect(res.status).toBe(400); expect(res.status).toBe(400);
expect(await res.text()).toBe("invalid path"); expect(await res.text()).toBe("invalid path");
await new Promise((r) => server.close(r)); await new Promise((r) => server.close(r));

View File

@ -4,6 +4,7 @@ import path from "node:path";
import express, { type Express } from "express"; import express, { type Express } from "express";
import { danger } from "../globals.js"; import { danger } from "../globals.js";
import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
import { detectMime } from "./mime.js";
import { cleanOldMedia, getMediaDir } from "./store.js"; import { cleanOldMedia, getMediaDir } from "./store.js";
const DEFAULT_TTL_MS = 2 * 60 * 1000; const DEFAULT_TTL_MS = 2 * 60 * 1000;
@ -19,7 +20,6 @@ export function attachMediaRoutes(
const id = req.params.id; const id = req.params.id;
const mediaRoot = (await fs.realpath(mediaDir)) + path.sep; const mediaRoot = (await fs.realpath(mediaDir)) + path.sep;
const file = path.resolve(mediaRoot, id); const file = path.resolve(mediaRoot, id);
try { try {
const lstat = await fs.lstat(file); const lstat = await fs.lstat(file);
if (lstat.isSymbolicLink()) { if (lstat.isSymbolicLink()) {
@ -37,13 +37,14 @@ export function attachMediaRoutes(
res.status(410).send("expired"); res.status(410).send("expired");
return; return;
} }
res.sendFile(realPath); const data = await fs.readFile(realPath);
const mime = detectMime({ buffer: data, filePath: realPath });
if (mime) res.type(mime);
res.send(data);
// best-effort single-use cleanup after response ends // best-effort single-use cleanup after response ends
res.on("finish", () => { setTimeout(() => {
setTimeout(() => { fs.rm(realPath).catch(() => {});
fs.rm(realPath).catch(() => {}); }, 500);
}, 500);
});
} catch { } catch {
res.status(404).send("not found"); res.status(404).send("not found");
} }

View File

@ -48,9 +48,21 @@ async function downloadToFile(
url: string, url: string,
dest: string, dest: string,
headers?: Record<string, string>, headers?: Record<string, string>,
maxRedirects = 5,
): Promise<{ headerMime?: string; sniffBuffer: Buffer; size: number }> { ): Promise<{ headerMime?: string; sniffBuffer: Buffer; size: number }> {
return await new Promise((resolve, reject) => { return await new Promise((resolve, reject) => {
const req = request(url, { headers }, (res) => { const req = request(url, { headers }, (res) => {
// Follow redirects
if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400) {
const location = res.headers.location;
if (!location || maxRedirects <= 0) {
reject(new Error(`Redirect loop or missing Location header`));
return;
}
const redirectUrl = new URL(location, url).href;
resolve(downloadToFile(redirectUrl, dest, headers, maxRedirects - 1));
return;
}
if (!res.statusCode || res.statusCode >= 400) { if (!res.statusCode || res.statusCode >= 400) {
reject(new Error(`HTTP ${res.statusCode ?? "?"} downloading media`)); reject(new Error(`HTTP ${res.statusCode ?? "?"} downloading media`));
return; return;
@ -107,9 +119,9 @@ export async function saveMediaSource(
const dir = subdir ? path.join(MEDIA_DIR, subdir) : MEDIA_DIR; const dir = subdir ? path.join(MEDIA_DIR, subdir) : MEDIA_DIR;
await fs.mkdir(dir, { recursive: true }); await fs.mkdir(dir, { recursive: true });
await cleanOldMedia(); await cleanOldMedia();
const id = crypto.randomUUID(); const baseId = crypto.randomUUID();
if (looksLikeUrl(source)) { if (looksLikeUrl(source)) {
const tempDest = path.join(dir, `${id}.tmp`); const tempDest = path.join(dir, `${baseId}.tmp`);
const { headerMime, sniffBuffer, size } = await downloadToFile( const { headerMime, sniffBuffer, size } = await downloadToFile(
source, source,
tempDest, tempDest,
@ -122,7 +134,8 @@ export async function saveMediaSource(
}); });
const ext = const ext =
extensionForMime(mime) ?? path.extname(new URL(source).pathname); extensionForMime(mime) ?? path.extname(new URL(source).pathname);
const finalDest = path.join(dir, ext ? `${id}${ext}` : id); const id = ext ? `${baseId}${ext}` : baseId;
const finalDest = path.join(dir, id);
await fs.rename(tempDest, finalDest); await fs.rename(tempDest, finalDest);
return { id, path: finalDest, size, contentType: mime }; return { id, path: finalDest, size, contentType: mime };
} }
@ -137,7 +150,8 @@ export async function saveMediaSource(
const buffer = await fs.readFile(source); const buffer = await fs.readFile(source);
const mime = detectMime({ buffer, filePath: source }); const mime = detectMime({ buffer, filePath: source });
const ext = extensionForMime(mime) ?? path.extname(source); const ext = extensionForMime(mime) ?? path.extname(source);
const dest = path.join(dir, ext ? `${id}${ext}` : id); const id = ext ? `${baseId}${ext}` : baseId;
const dest = path.join(dir, id);
await fs.writeFile(dest, buffer); await fs.writeFile(dest, buffer);
return { id, path: dest, size: stat.size, contentType: mime }; return { id, path: dest, size: stat.size, contentType: mime };
} }
@ -152,10 +166,11 @@ export async function saveMediaBuffer(
} }
const dir = path.join(MEDIA_DIR, subdir); const dir = path.join(MEDIA_DIR, subdir);
await fs.mkdir(dir, { recursive: true }); await fs.mkdir(dir, { recursive: true });
const id = crypto.randomUUID(); const baseId = crypto.randomUUID();
const mime = detectMime({ buffer, headerMime: contentType }); const mime = detectMime({ buffer, headerMime: contentType });
const ext = extensionForMime(mime); const ext = extensionForMime(mime);
const dest = path.join(dir, ext ? `${id}${ext}` : id); const id = ext ? `${baseId}${ext}` : baseId;
const dest = path.join(dir, id);
await fs.writeFile(dest, buffer); await fs.writeFile(dest, buffer);
return { id, path: dest, size: buffer.byteLength, contentType: mime }; return { id, path: dest, size: buffer.byteLength, contentType: mime };
} }

View File

@ -1,4 +1,4 @@
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process";
import readline from "node:readline"; import readline from "node:readline";
type TauRpcOptions = { type TauRpcOptions = {
@ -22,7 +22,10 @@ class TauRpcClient {
} }
| undefined; | undefined;
constructor(private readonly argv: string[], private readonly cwd: string | undefined) {} constructor(
private readonly argv: string[],
private readonly cwd: string | undefined,
) {}
private ensureChild() { private ensureChild() {
if (this.child) return; if (this.child) return;
@ -37,7 +40,9 @@ class TauRpcClient {
}); });
this.child.on("exit", (code, signal) => { this.child.on("exit", (code, signal) => {
if (this.pending) { if (this.pending) {
this.pending.reject(new Error(`tau rpc exited (code=${code}, signal=${signal})`)); this.pending.reject(
new Error(`tau rpc exited (code=${code}, signal=${signal})`),
);
clearTimeout(this.pending.timer); clearTimeout(this.pending.timer);
this.pending = undefined; this.pending = undefined;
} }
@ -49,7 +54,10 @@ class TauRpcClient {
if (!this.pending) return; if (!this.pending) return;
this.buffer.push(line); this.buffer.push(line);
// Finish on assistant message_end event to mirror parse logic in piSpec // Finish on assistant message_end event to mirror parse logic in piSpec
if (line.includes('"type":"message_end"') && line.includes('"role":"assistant"')) { if (
line.includes('"type":"message_end"') &&
line.includes('"role":"assistant"')
) {
const out = this.buffer.join("\n"); const out = this.buffer.join("\n");
clearTimeout(this.pending.timer); clearTimeout(this.pending.timer);
const pending = this.pending; const pending = this.pending;
@ -64,13 +72,14 @@ class TauRpcClient {
if (this.pending) { if (this.pending) {
throw new Error("tau rpc already handling a request"); throw new Error("tau rpc already handling a request");
} }
const child = this.child!; const child = this.child;
if (!child) throw new Error("tau rpc child not initialized");
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
const ok = child.stdin.write( const ok = child.stdin.write(
JSON.stringify({ `${JSON.stringify({
type: "prompt", type: "prompt",
message: { role: "user", content: [{ type: "text", text: prompt }] }, message: { role: "user", content: [{ type: "text", text: prompt }] },
}) + "\n", })}\n`,
(err) => (err ? reject(err) : resolve()), (err) => (err ? reject(err) : resolve()),
); );
if (!ok) child.stdin.once("drain", () => resolve()); if (!ok) child.stdin.once("drain", () => resolve());

View File

@ -41,7 +41,7 @@ export async function sendMessageWeb(
const mimetype = const mimetype =
media.contentType === "audio/ogg" media.contentType === "audio/ogg"
? "audio/ogg; codecs=opus" ? "audio/ogg; codecs=opus"
: media.contentType ?? "application/octet-stream"; : (media.contentType ?? "application/octet-stream");
payload = { audio: media.buffer, ptt: true, mimetype }; payload = { audio: media.buffer, ptt: true, mimetype };
} else if (media.kind === "video") { } else if (media.kind === "video") {
const mimetype = media.contentType ?? "application/octet-stream"; const mimetype = media.contentType ?? "application/octet-stream";