Merge branch 'fix/media-replies'

This commit is contained in:
Peter Steinberger
2025-12-02 21:07:45 +00:00
12 changed files with 72 additions and 38 deletions

View File

@@ -17,4 +17,4 @@ export function getAgentSpec(kind: AgentKind): AgentSpec {
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.
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.";
"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 {
// Best-effort walker to find the primary text field in Claude JSON outputs.

View File

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

View File

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

View File

@@ -895,7 +895,7 @@ describe("config and templating", () => {
const argv = runSpy.mock.calls[0][0];
expect(argv[0]).toBe("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$/);
// The helper should auto-add print and output format flags without disturbing the prompt position.
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?");
const argv = runSpy.mock.calls[0][0];
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 () => {

View File

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

View File

@@ -133,7 +133,11 @@ function pruneOldRollingLogs(dir: string): void {
const cutoff = Date.now() - MAX_LOG_AGE_MS;
for (const entry of entries) {
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);
try {
const stat = fs.statSync(fullPath);

View File

@@ -54,7 +54,9 @@ describe("media server", () => {
const server = await startMediaServer(0, 5_000);
const port = (server.address() as AddressInfo).port;
// 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(await res.text()).toBe("invalid path");
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 { danger } from "../globals.js";
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
import { detectMime } from "./mime.js";
import { cleanOldMedia, getMediaDir } from "./store.js";
const DEFAULT_TTL_MS = 2 * 60 * 1000;
@@ -19,7 +20,6 @@ export function attachMediaRoutes(
const id = req.params.id;
const mediaRoot = (await fs.realpath(mediaDir)) + path.sep;
const file = path.resolve(mediaRoot, id);
try {
const lstat = await fs.lstat(file);
if (lstat.isSymbolicLink()) {
@@ -37,13 +37,14 @@ export function attachMediaRoutes(
res.status(410).send("expired");
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
res.on("finish", () => {
setTimeout(() => {
fs.rm(realPath).catch(() => {});
}, 500);
});
setTimeout(() => {
fs.rm(realPath).catch(() => {});
}, 500);
} catch {
res.status(404).send("not found");
}

View File

@@ -48,9 +48,21 @@ async function downloadToFile(
url: string,
dest: string,
headers?: Record<string, string>,
maxRedirects = 5,
): Promise<{ headerMime?: string; sniffBuffer: Buffer; size: number }> {
return await new Promise((resolve, reject) => {
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) {
reject(new Error(`HTTP ${res.statusCode ?? "?"} downloading media`));
return;
@@ -107,9 +119,9 @@ export async function saveMediaSource(
const dir = subdir ? path.join(MEDIA_DIR, subdir) : MEDIA_DIR;
await fs.mkdir(dir, { recursive: true });
await cleanOldMedia();
const id = crypto.randomUUID();
const baseId = crypto.randomUUID();
if (looksLikeUrl(source)) {
const tempDest = path.join(dir, `${id}.tmp`);
const tempDest = path.join(dir, `${baseId}.tmp`);
const { headerMime, sniffBuffer, size } = await downloadToFile(
source,
tempDest,
@@ -122,7 +134,8 @@ export async function saveMediaSource(
});
const ext =
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);
return { id, path: finalDest, size, contentType: mime };
}
@@ -137,7 +150,8 @@ export async function saveMediaSource(
const buffer = await fs.readFile(source);
const mime = detectMime({ buffer, filePath: 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);
return { id, path: dest, size: stat.size, contentType: mime };
}
@@ -152,10 +166,11 @@ export async function saveMediaBuffer(
}
const dir = path.join(MEDIA_DIR, subdir);
await fs.mkdir(dir, { recursive: true });
const id = crypto.randomUUID();
const baseId = crypto.randomUUID();
const mime = detectMime({ buffer, headerMime: contentType });
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);
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";
type TauRpcOptions = {
@@ -22,7 +22,10 @@ class TauRpcClient {
}
| undefined;
constructor(private readonly argv: string[], private readonly cwd: string | undefined) {}
constructor(
private readonly argv: string[],
private readonly cwd: string | undefined,
) {}
private ensureChild() {
if (this.child) return;
@@ -37,7 +40,9 @@ class TauRpcClient {
});
this.child.on("exit", (code, signal) => {
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);
this.pending = undefined;
}
@@ -49,7 +54,10 @@ class TauRpcClient {
if (!this.pending) return;
this.buffer.push(line);
// 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");
clearTimeout(this.pending.timer);
const pending = this.pending;
@@ -64,13 +72,14 @@ class TauRpcClient {
if (this.pending) {
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) => {
const ok = child.stdin.write(
JSON.stringify({
`${JSON.stringify({
type: "prompt",
message: { role: "user", content: [{ type: "text", text: prompt }] },
}) + "\n",
})}\n`,
(err) => (err ? reject(err) : resolve()),
);
if (!ok) child.stdin.once("drain", () => resolve());

View File

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