import type { APIRoute } from 'astro'; import { spawn } from "child_process"; import * as readline from "readline"; import * as fs from "fs"; import * as path from "path"; import { logUsage, extractUsageFromResult } from "@palace/sdk/usage"; const SESSION_DIR = path.join(process.cwd(), ".sessions"); async function claudeSend( conversationId: string, message: string, model?: string, ): Promise<{ reply: string }> { if (!fs.existsSync(SESSION_DIR)) fs.mkdirSync(SESSION_DIR, { recursive: true }); const stateFile = path.join(SESSION_DIR, `${conversationId}.session`); let sessionId: string | null = null; if (fs.existsSync(stateFile)) { sessionId = fs.readFileSync(stateFile, "utf8").trim() || null; } const args = [ "-p", "--output-format", "stream-json", "--input-format", "stream-json", "--dangerously-skip-permissions", "--max-turns", "10", "--verbose", ]; if (model) args.push("--model", model); if (sessionId) args.push("--resume", sessionId); const proc = spawn("claude", args, { stdio: ["pipe", "pipe", "pipe"], env: { ...process.env, CLAUDECODE: "" }, }); const rl = readline.createInterface({ input: proc.stdout }); let reply = ""; let newSessionId: string | null = sessionId; const result = await new Promise<{ reply: string }>((resolve, reject) => { const timeout = setTimeout(() => { proc.kill(); reject(new Error("timeout")); }, 5 * 60 * 1000); rl.on("line", (line) => { if (!line.trim()) return; let msg: any; try { msg = JSON.parse(line); } catch { return; } if (msg.type === "system" && msg.subtype === "init" && msg.session_id) { newSessionId = msg.session_id; } if (msg.type === "assistant" && msg.message?.content) { for (const block of msg.message.content) { if (block.type === "text") reply += block.text; } } if (msg.type === "result") { clearTimeout(timeout); logUsage({ ...extractUsageFromResult(msg), channel: "palacebutler", model: "haiku" }); resolve({ reply: reply.trim() }); } }); proc.on("error", (err) => { clearTimeout(timeout); reject(err); }); proc.on("exit", (code) => { clearTimeout(timeout); if (reply) resolve({ reply: reply.trim() }); else reject(new Error(`claude exited with code ${code}`)); }); proc.stdin!.write( JSON.stringify({ type: "user", message: { role: "user", content: [{ type: "text", text: message }] } }) + "\n" ); }); if (newSessionId) fs.writeFileSync(stateFile, newSessionId); return result; } export const POST: APIRoute = async ({ request }) => { try { const { message, url, elements, pageContent } = await request.json(); const elList = (Array.isArray(elements) ? elements as any[] : []) .filter((e) => e != null && e.tag != null) .map((e) => ` ${e.tag}${e.text ? ` "${e.text}"` : ''}${e.sel ? ` [${e.sel}]` : ''}`) .join('\n'); const contentSection = pageContent ? `\nPage content:\n${pageContent}\n` : ''; const prompt = `You are controlling a mobile browser. Current page: ${url} ${contentSection} Interactive elements visible on page: ${elList || ' (none)'} User command: "${message}" Respond with exactly one JSON object on one line. No markdown, no explanation, nothing else. {"do":"click","sel":"","say":""} {"do":"nav","href":"","say":""} {"do":"say","say":""}`; const { reply } = await claudeSend('butler-agent', prompt, 'claude-haiku-4-5-20251001'); const match = reply.match(/\{[^{}]+\}/); if (match) { try { const action = JSON.parse(match[0]); return new Response(JSON.stringify(action), { headers: { 'Content-Type': 'application/json' }, }); } catch {} } return new Response(JSON.stringify({ do: 'say', say: reply.trim().slice(0, 100) }), { headers: { 'Content-Type': 'application/json' }, }); } catch { return new Response(JSON.stringify({ do: 'say', say: 'Error — try again.' }), { headers: { 'Content-Type': 'application/json' }, }); } };