diff --git a/src/agents/index.ts b/src/agents/index.ts index e2b5a35e9..9e475cf59 100644 --- a/src/agents/index.ts +++ b/src/agents/index.ts @@ -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"; diff --git a/src/auto-reply/claude.ts b/src/auto-reply/claude.ts index ca3cfa9df..bd7841dad 100644 --- a/src/auto-reply/claude.ts +++ b/src/auto-reply/claude.ts @@ -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. diff --git a/src/auto-reply/command-reply.ts b/src/auto-reply/command-reply.ts index f79ff45e2..9dafb1f13 100644 --- a/src/auto-reply/command-reply.ts +++ b/src/auto-reply/command-reply.ts @@ -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 || ""}`); 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 }; diff --git a/src/auto-reply/reply.ts b/src/auto-reply/reply.ts index d1d930885..1266568b8 100644 --- a/src/auto-reply/reply.ts +++ b/src/auto-reply/reply.ts @@ -263,7 +263,6 @@ export async function getReplyFromConfig( }; sessionStore[sessionKey] = sessionEntry; await saveSessionStore(storePath, sessionStore); - systemSent = true; } const prefixedBody = diff --git a/src/index.core.test.ts b/src/index.core.test.ts index 8e56d6309..ffe4c748a 100644 --- a/src/index.core.test.ts +++ b/src/index.core.test.ts @@ -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 () => { diff --git a/src/logger.test.ts b/src/logger.test.ts index 709fcca35..4fa8f38b5 100644 --- a/src/logger.test.ts +++ b/src/logger.test.ts @@ -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", () => { diff --git a/src/logging.ts b/src/logging.ts index cabe9b277..85a8c26ae 100644 --- a/src/logging.ts +++ b/src/logging.ts @@ -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); diff --git a/src/media/server.test.ts b/src/media/server.test.ts index 875088cbf..46ddc0b2c 100644 --- a/src/media/server.test.ts +++ b/src/media/server.test.ts @@ -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)); diff --git a/src/media/server.ts b/src/media/server.ts index 1c37c2a33..ad830da32 100644 --- a/src/media/server.ts +++ b/src/media/server.ts @@ -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"); } diff --git a/src/media/store.ts b/src/media/store.ts index 291d048e8..31664aab9 100644 --- a/src/media/store.ts +++ b/src/media/store.ts @@ -48,9 +48,21 @@ async function downloadToFile( url: string, dest: string, headers?: Record, + 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 }; } diff --git a/src/process/tau-rpc.ts b/src/process/tau-rpc.ts index 9d28e7ed5..cf3450d6f 100644 --- a/src/process/tau-rpc.ts +++ b/src/process/tau-rpc.ts @@ -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((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()); diff --git a/src/web/outbound.ts b/src/web/outbound.ts index f1d0af851..6e1b3cf0c 100644 --- a/src/web/outbound.ts +++ b/src/web/outbound.ts @@ -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";