Merge branch 'fix/media-replies'
This commit is contained in:
@@ -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";
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -263,7 +263,6 @@ export async function getReplyFromConfig(
|
||||
};
|
||||
sessionStore[sessionKey] = sessionEntry;
|
||||
await saveSessionStore(storePath, sessionStore);
|
||||
systemSent = true;
|
||||
}
|
||||
|
||||
const prefixedBody =
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user