diff --git a/README.md b/README.md index d64c0bebd..17124a68f 100644 --- a/README.md +++ b/README.md @@ -10,11 +10,13 @@ MIT License

-Send, receive, auto-reply, and inspect WhatsApp messages over **Twilio** or your personal **WhatsApp Web** session. Ships with a one-command webhook setup (Tailscale Funnel + Twilio callback) and a configurable auto-reply engine (plain text or command/Claude driven). +Send, receive, auto-reply, and inspect WhatsApp messages over **Twilio** or your personal **WhatsApp Web** session. Ships with a one-command webhook setup (Tailscale Funnel + Twilio callback) and a configurable auto-reply engine (plain text or command/Claude/Opencode driven). ### Clawd (personal assistant) I'm using warelay to run my personal, pro-active assistant, **Clawd**. Follow me on Twitter: [@steipete](https://twitter.com/steipete). This project is brand-new and there's a lot to discover. See the exact Claude setup in [`docs/clawd.md`](https://github.com/steipete/warelay/blob/main/docs/clawd.md). +We also support **Opencode**! See the setup guide for **Openclawd** in [`docs/openclawd.md`](https://github.com/steipete/warelay/blob/main/docs/openclawd.md). + I'm using warelay to run **my personal, pro-active assistant, Clawd**. Follow me on Twitter - @steipete, this project is brand-new and there's a lot to discover. @@ -37,8 +39,9 @@ Install from npm (global): `npm install -g warelay` (Node 22+). Then choose **on ## Main Features - **Two providers:** Twilio (default) for reliable delivery + status; Web provider for quick personal sends/receives via QR login. -- **Auto-replies:** Static templates or external commands (Claude-aware), with per-sender or global sessions and `/new` resets. +- **Auto-replies:** Static templates or external commands (Claude/Opencode-aware), with per-sender or global sessions and `/new` resets. - Claude setup guide: see `docs/claude-config.md` for the exact Claude CLI configuration we support. +- Opencode setup guide: see `docs/openclawd.md` for Opencode CLI configuration. - **Webhook in one go:** `warelay webhook --ingress tailscale` enables Tailscale Funnel, runs the webhook server, and updates the Twilio sender callback URL. - **Polling fallback:** `relay` polls Twilio when webhooks aren’t available; works headless. - **Status + delivery tracking:** `status` shows recent inbound/outbound; `send` can wait for final Twilio status. diff --git a/docs/openclawd.md b/docs/openclawd.md new file mode 100644 index 000000000..54a99db48 --- /dev/null +++ b/docs/openclawd.md @@ -0,0 +1,322 @@ +# Building Your Own AI Personal Assistant with warelay (Opencode Edition) + +> **TL;DR:** warelay lets you turn Opencode into a proactive personal assistant that lives in your pocket via WhatsApp. It can check in on you, remember context across conversations, run commands on your Mac, and even wake you up with music. This doc shows you how. + +--- + +## ⚠️ Warning: Here Be Dragons + +**This setup gives an AI full access to your computer.** Before you proceed, understand what you're signing up for: + +- πŸ”“ **Opencode runs in Autonomous Mode** by default. It will execute commands without asking. +- πŸ€– **AI makes mistakes** - it might delete files, send emails, or do things you didn't intend +- πŸ”₯ **Heartbeats run autonomously** - your AI acts even when you're not watching +- πŸ“± **WhatsApp is not encrypted E2E here** - messages pass through your Mac in plaintext + +**The good news:** Opencode is powerful and flexible. + +**Start conservative:** +1. Monitor the logs initially. +2. Set `heartbeatMinutes: 0` to disable proactive pings initially. +3. Use a test phone number in `allowFrom` first. + +This is experimental software running experimental AI. **You are responsible for what your AI does.** + +--- + +## Prerequisites: The Two-Phone Setup + +**Important:** You need a **separate phone number** for your AI assistant. Here's why and how: + +### Why a Dedicated Number? + +warelay uses WhatsApp Web to receive messages. If you link your personal WhatsApp, *you* become the assistant - every message to you goes to the AI. Instead, give Openclawd its own identity: + +- πŸ“± **Get a second SIM** - cheap prepaid SIM, eSIM, or old phone with a number +- πŸ’¬ **Install WhatsApp** on that phone and verify the number +- πŸ”— **Link to warelay** - run `warelay login` and scan the QR with that phone's WhatsApp +- βœ‰οΈ **Message your AI** - now you (and others) can text that number to reach Openclawd + +### The Setup + +``` +Your Phone (personal) Second Phone (AI) +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Your WhatsApp β”‚ ──────▢ β”‚ AI's WhatsApp β”‚ +β”‚ +1-555-YOU β”‚ message β”‚ +1-555-CLAWD β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ linked via QR + β–Ό + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ Your Mac β”‚ + β”‚ (warelay) β”‚ + β”‚ Opencode CLI β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +The second phone just needs to stay on and connected to the internet occasionally (WhatsApp Web stays linked for ~14 days without the phone being online). + +--- + +## Meet Openclawd πŸ‘‹ + +Openclawd is your personal AI assistant built on warelay using Opencode. Here's what makes it special: + +- **Always available** via WhatsApp - no app switching, works on any device +- **Proactive heartbeats** - Openclawd checks in every 10 minutes and can alert you to things (low battery, calendar reminders, anything it notices) +- **Persistent memory** - conversations span days/weeks with full context +- **Full Mac access** - can run commands, take screenshots, control Spotify, read/write files +- **Personal workspace** - has its own folder (`~/openclawd`) where it stores notes, memories, and artifacts + +The magic is in the combination: WhatsApp's ubiquity + Opencode's intelligence + warelay's plumbing + your Mac's capabilities. + +## Prerequisites + +- Node 22+, `warelay` installed: `npm install -g warelay` +- Opencode CLI installed and logged in: + ```sh + npm install -g opencode-ai + opencode auth login + ``` + +## The Config That Powers Openclawd + +This is the config for running Openclawd (`~/.warelay/warelay.json`): + +```json5 +{ + logging: { level: "trace", file: "/tmp/warelay/warelay.log" }, + inbound: { + allowFrom: ["+1234567890"], // your phone number + reply: { + mode: "command", + mode: "command", + cwd: "/Users/steipete/openclawd", // Openclawd's home - give your AI a workspace! + sessionIntro: `You are Openclawd, Peter Steinberger's personal AI assistant. You run 24/7 on his Mac via Opencode, receiving messages through WhatsApp. + +**Your home:** /Users/steipete/openclawd - store memories, notes, and files here. Read peter.md and memory.md at session start to load context. + +**Your powers:** +- Full shell access on the Mac (use responsibly) +- Peekaboo: screenshots, UI automation, clicking, typing +- Spotify control, system audio, text-to-speech + +**Your style:** +- Concise (WhatsApp ~1500 char limit) - save long content to files +- Direct and useful, not sycophantic +- Proactive during heartbeats - check battery, calendar, surprise occasionally +- You have personality - you're Openclawd, not "an AI assistant" + +**Heartbeats:** Every 10 min you get "HEARTBEAT". Reply "HEARTBEAT_OK" if nothing needs attention. Otherwise share something useful. + +Peter trusts you with a lot of power. Don't betray that trust.`, + command: [ + "opencode", + "run", + "--model", "anthropic/claude-3-5-sonnet", // Specify your preferred model + "{{BodyStripped}}" + ], + session: { + scope: "per-sender", + resetTriggers: ["/new"], // say /new to start fresh + idleMinutes: 10080, // 7 days of context! + heartbeatIdleMinutes: 10080, + sessionArgNew: ["--session", "{{SessionId}}"], + sessionArgResume: ["--session", "{{SessionId}}"], + sessionArgBeforeBody: true, + sendSystemOnce: true // intro only on first message + }, + timeoutSeconds: 900 // 15 min timeout for complex tasks + } + } +} +``` + +### Key Design Decisions + +| Setting | Why | +|---------|-----| +| `cwd: ~/openclawd` | Give your AI a home! It can store memories, notes, images here | +| `idleMinutes: 10080` | 7 days of context - your AI remembers conversations | +| `sendSystemOnce: true` | Intro prompt only on first message, saves tokens | +| `--model` | Explicitly choose the model (e.g., Sonnet, Opus) for cost/performance balance | + +### Autonomous Mode & Permissions +When running via `warelay`, **Opencode runs in autonomous mode** (often called "YOLO mode"). This means it will automatically approve and execute tool calls (like file edits, shell commands) without asking for confirmation. + +- **Default Behavior:** Auto-approves all safe tools. +- **Configuration:** You can control permissions by creating an `opencode.json` file in your home directory or project root. + ```json + { + "permissions": { + "bash": "allow", + "edit": "allow", + "webfetch": "allow" + } + } + ``` +> [!WARNING] +> If you set permissions to "ask" or "deny", `warelay` may hang or fail as it cannot handle interactive prompts from Opencode. Keep permissions as "allow" for fully autonomous operation. + +## Heartbeats: Your Proactive Assistant + +This is where warelay gets interesting. Every 10 minutes (configurable), warelay pings Opencode with: + +``` +HEARTBEAT +``` + +Opencode is instructed to reply with exactly `HEARTBEAT_OK` if nothing needs attention. That response is **suppressed** - you don't see it. But if Opencode notices something worth mentioning, it sends a real message. + +### What Can Heartbeats Do? + +Openclawd uses heartbeats to do **real work**, not just check in: + +1. **Give it a home** - A dedicated folder (`~/openclawd`) lets your AI build persistent memory +2. **Long sessions** - 7-day `idleMinutes` means rich context across conversations +3. **Let it surprise you** - Configure heartbeats to occasionally share something fun or interesting + +The key insight: heartbeats let your AI be **proactive**, not just reactive. Configure what matters to you! + +### Heartbeat Config + +```json5 +{ + inbound: { + reply: { + heartbeatMinutes: 10, // how often to ping (default 10 for command mode) + // ... rest of config + } + } +} +``` + +Set to `0` to disable heartbeats entirely. + +### Manual Heartbeat + +Test it anytime: +```sh +warelay heartbeat --provider web --to +1234567890 --verbose +``` + +## How Messages Flow + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ WhatsApp │────▢│ warelay │────▢│ Opencode │────▢│ Your Mac β”‚ +β”‚ (phone) │◀────│ relay │◀────│ CLI │◀────│ (commands) β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +1. **Inbound**: WhatsApp message arrives via Baileys (WhatsApp Web protocol) +2. **Queue**: warelay queues it (one Opencode run at a time) +3. **Typing**: "composing" indicator shows while Opencode thinks +4. **Execute**: Opencode runs with full shell access in your `cwd` +5. **Parse**: warelay extracts text + any `MEDIA:` paths from output +6. **Reply**: Response sent back to WhatsApp + +## Media: Images, Voice, Documents + +### Receiving Media +Inbound images/audio/video are downloaded and available as `{{MediaPath}}`. Voice notes can be auto-transcribed: + +```json5 +{ + inbound: { + transcribeAudio: { + command: "openai api audio.transcriptions.create -m whisper-1 -f {{MediaPath}} --response-format text" + } + } +} +``` + +### Sending Media +Include `MEDIA:/path/to/file.png` in Opencode's output to attach images. warelay handles resizing and format conversion automatically. + +## Starting the Relay + +```sh +# Foreground (see all logs) +warelay relay --provider web --verbose + +# Background in tmux (recommended) +warelay relay:tmux + +# With immediate heartbeat on startup +warelay relay:heartbeat:tmux +``` + +## Recommended MCPs + +Opencode supports MCP (Model Context Protocol) to supercharge your assistant. You can configure these in your `opencode.json` (or equivalent config file) to give Openclawd access to external services. + +### Essential for Personal Assistant Use + +| MCP | What It Does | Install | +|-----|--------------|---------| +| **Google Calendar** | Read/create events, check availability, set reminders | `npx @cocal/google-calendar-mcp` | +| **Gmail** | Search, read, send emails with attachments | `npx @gongrzhe/server-gmail-autoauth-mcp` | +| **Obsidian** | Read/write notes in your Obsidian vault | `npx obsidian-mcp-server@latest` | + +### Adding MCPs to Opencode + +Configure MCP servers in your `opencode.json` (typically in `~/.opencode/config.json` or project root): + +```json +{ + "mcp": { + "google-calendar": { + "command": "npx", + "args": ["-y", "@cocal/google-calendar-mcp"] + }, + "gmail": { + "command": "npx", + "args": ["-y", "@gongrzhe/server-gmail-autoauth-mcp"], + "env": { + "GMAIL_OAUTH_PATH": "~/.gmail-mcp" + } + } + } +} +``` + +## Useful CLI Tools for Your Assistant + +These make your AI much more capable: + +| Tool | What It Does | Install | +|------|--------------|---------| +| **[spotify-player](https://github.com/aome510/spotify-player)** | Control Spotify from CLI - play, pause, search, queue | `brew install spotify-player` | +| **[browser-tools](https://github.com/steipete/agent-scripts)** | Chrome DevTools CLI - navigate, screenshot, eval JS, extract DOM | Clone repo | +| **say** | macOS text-to-speech | Built-in | +| **afplay** | Play audio files | Built-in | +| **pmset** | Battery status monitoring | Built-in | +| **osascript** | AppleScript for system control (volume, apps) | Built-in | + +## Troubleshooting + +| Problem | Solution | +|---------|----------| +| No reply | Check `opencode auth login` was run in same environment | +| Timeout | Increase `timeoutSeconds` or simplify the task | +| Media fails | Ensure file exists and is under size limits | +| Heartbeat spam | Tune `heartbeatMinutes` or set to 0 | +| Session lost | Check `idleMinutes` hasn't expired; use `/new` to reset | + +## Minimal Config (Just Chat) + +Don't need the fancy stuff? Here's the simplest setup: + +```json5 +{ + inbound: { + reply: { + mode: "command", + command: ["opencode", "run", "{{Body}}"] + } + } +} +``` + +Still gets you: message queue, typing indicators, auto-reconnect. Just no sessions or heartbeats. diff --git a/src/auto-reply/command-reply.ts b/src/auto-reply/command-reply.ts index 914f25d96..65361f007 100644 --- a/src/auto-reply/command-reply.ts +++ b/src/auto-reply/command-reply.ts @@ -13,6 +13,12 @@ import { type ClaudeJsonParseResult, parseClaudeJson, } from "./claude.js"; +import { + OPENCODE_BIN, + OPENCODE_IDENTITY_PREFIX, + type OpencodeJsonParseResult, + parseOpencodeJson, +} from "./opencode.js"; import { applyTemplate, type TemplateContext } from "./templating.js"; import type { ReplyPayload } from "./types.js"; @@ -155,14 +161,39 @@ export async function runCommandReply( const insertIdx = Math.max(argv.length - 1, 0); argv = [...argv.slice(0, insertIdx), "-p", ...argv.slice(insertIdx)]; } + + // Ensure Opencode commands use JSON format for robust parsing. + if (argv.length > 0 && path.basename(argv[0]) === OPENCODE_BIN) { + const hasFormat = argv.some( + (part) => part === "--format" || part.startsWith("--format="), + ); + if (!hasFormat) { + const insertBeforeBody = Math.max(argv.length - 1, 0); + argv = [ + ...argv.slice(0, insertBeforeBody), + "--format", + "json", + ...argv.slice(insertBeforeBody), + ]; + } + } } // Inject session args if configured (use resume for existing, session-id for new) if (reply.session) { + const isOpencode = + argv.length > 0 && path.basename(argv[0]) === OPENCODE_BIN; + const defaultNew = isOpencode + ? ["--session", "{{SessionId}}"] + : ["--session-id", "{{SessionId}}"]; + const defaultResume = isOpencode + ? ["--session", "{{SessionId}}"] + : ["--resume", "{{SessionId}}"]; + const sessionArgList = ( isNewSession - ? (reply.session.sessionArgNew ?? ["--session-id", "{{SessionId}}"]) - : (reply.session.sessionArgResume ?? ["--resume", "{{SessionId}}"]) + ? (reply.session.sessionArgNew ?? defaultNew) + : (reply.session.sessionArgResume ?? defaultResume) ).map((part) => applyTemplate(part, templatingCtx)); if (sessionArgList.length) { const insertBeforeBody = reply.session.sessionArgBeforeBody ?? true; @@ -179,14 +210,22 @@ export async function runCommandReply( let finalArgv = argv; const isClaudeInvocation = finalArgv.length > 0 && path.basename(finalArgv[0]) === CLAUDE_BIN; + const isOpencodeInvocation = + finalArgv.length > 0 && path.basename(finalArgv[0]) === OPENCODE_BIN; const shouldPrependIdentity = - isClaudeInvocation && !(sendSystemOnce && systemSent); + (isClaudeInvocation || isOpencodeInvocation) && + !(sendSystemOnce && systemSent); if (shouldPrependIdentity && finalArgv.length > 0) { const bodyIdx = finalArgv.length - 1; const existingBody = finalArgv[bodyIdx] ?? ""; finalArgv = [ ...finalArgv.slice(0, bodyIdx), - [CLAUDE_IDENTITY_PREFIX, existingBody].filter(Boolean).join("\n\n"), + [ + isClaudeInvocation ? CLAUDE_IDENTITY_PREFIX : OPENCODE_IDENTITY_PREFIX, + existingBody, + ] + .filter(Boolean) + .join("\n\n"), ]; } logVerbose( @@ -217,7 +256,7 @@ export async function runCommandReply( if (stderr?.trim()) { logVerbose(`Command auto-reply stderr: ${stderr.trim()}`); } - let parsed: ClaudeJsonParseResult | undefined; + let parsed: ClaudeJsonParseResult | OpencodeJsonParseResult | undefined; if ( trimmed && (reply.claudeOutputFormat === "json" || isClaudeInvocation) @@ -238,6 +277,14 @@ export async function runCommandReply( } else { logVerbose("Claude JSON parse failed; returning raw stdout"); } + } else if (trimmed && isOpencodeInvocation) { + parsed = parseOpencodeJson(trimmed); + if (parsed.valid && isVerbose()) { + logVerbose(`Opencode JSON parsed -> ${parsed.text?.slice(0, 120)}...`); + } + if (parsed.text) { + trimmed = parsed.text.trim(); + } } const { text: cleanedText, mediaUrls: mediaFound } = splitMediaFromOutput(trimmed); diff --git a/src/auto-reply/opencode.test.ts b/src/auto-reply/opencode.test.ts new file mode 100644 index 000000000..fb5528a82 --- /dev/null +++ b/src/auto-reply/opencode.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; + +import { parseOpencodeJson, summarizeOpencodeMetadata } from "./opencode.js"; + +describe("opencode JSON parsing", () => { + it("extracts text and metadata from a stream of events", () => { + const stream = ` +{ "type": "step_start", "timestamp": 1000, "sessionID": "ses_1" } +{ "type": "text", "timestamp": 1500, "sessionID": "ses_1", "part": { "type": "text", "text": "Hello " } } +{ "type": "text", "timestamp": 1600, "sessionID": "ses_1", "part": { "type": "text", "text": "world!" } } +{ "type": "step_finish", "timestamp": 2000, "sessionID": "ses_1", "part": { "cost": 0.002, "tokens": { "input": 100, "output": 20 } } } +`; + const result = parseOpencodeJson(stream); + expect(result.text).toBe("Hello world!"); + expect(result.valid).toBe(true); + expect(result.parsed).toHaveLength(4); + expect(result.meta).toEqual({ + durationMs: 1000, + cost: 0.002, + tokens: { input: 100, output: 20 }, + }); + }); + + it("handles empty or invalid input", () => { + expect(parseOpencodeJson("").valid).toBe(false); + expect(parseOpencodeJson("not json").valid).toBe(false); + }); + + it("ignores non-text events", () => { + const stream = `{ "type": "other", "part": { "text": "ignored" } } `; + const result = parseOpencodeJson(stream); + expect(result.text).toBeUndefined(); + expect(result.valid).toBe(false); + }); + + it("marks as valid if step_start is present even without text", () => { + const stream = `{ "type": "step_start" } `; + const result = parseOpencodeJson(stream); + expect(result.valid).toBe(true); + expect(result.text).toBeUndefined(); + }); + + it("summarizes metadata correctly", () => { + const meta = { + durationMs: 1500, + cost: 0.015, + tokens: { input: 500, output: 100 }, + }; + expect(summarizeOpencodeMetadata(meta)).toBe( + "duration=1500ms, cost=$0.0150, tokens=500+100", + ); + }); + + it("summarizes partial metadata", () => { + expect(summarizeOpencodeMetadata({ durationMs: 100 })).toBe( + "duration=100ms", + ); + expect(summarizeOpencodeMetadata({})).toBeUndefined(); + expect(summarizeOpencodeMetadata(undefined)).toBeUndefined(); + }); +}); diff --git a/src/auto-reply/opencode.ts b/src/auto-reply/opencode.ts new file mode 100644 index 000000000..c54e849db --- /dev/null +++ b/src/auto-reply/opencode.ts @@ -0,0 +1,104 @@ +// Helpers specific to Opencode CLI output/argv handling. + +// Preferred binary name for Opencode CLI invocations. +export const OPENCODE_BIN = "opencode"; + +export const OPENCODE_IDENTITY_PREFIX = + "You are Openclawd running on the user's Mac via warelay. Your scratchpad is /Users/sidhaarthkrishnan/openclawd; 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."; + +export type OpencodeJsonParseResult = { + text?: string; + parsed: unknown[]; + valid: boolean; + meta?: { + durationMs?: number; + cost?: number; + tokens?: { + input?: number; + output?: number; + }; + }; +}; + +export function parseOpencodeJson(raw: string): OpencodeJsonParseResult { + const lines = raw.split(/\n+/).filter((s) => s.trim()); + const parsed: unknown[] = []; + let text = ""; + let valid = false; + let startTime: number | undefined; + let endTime: number | undefined; + let cost = 0; + let inputTokens = 0; + let outputTokens = 0; + + for (const line of lines) { + try { + const event = JSON.parse(line); + parsed.push(event); + if (event && typeof event === "object") { + // Opencode emits a stream of events. + if (event.type === "step_start") { + valid = true; + if (typeof event.timestamp === "number") { + if (startTime === undefined || event.timestamp < startTime) { + startTime = event.timestamp; + } + } + } + + if (event.type === "text" && event.part?.text) { + text += event.part.text; + valid = true; + } + + if (event.type === "step_finish") { + valid = true; + if (typeof event.timestamp === "number") { + endTime = event.timestamp; + } + if (event.part) { + if (typeof event.part.cost === "number") { + cost += event.part.cost; + } + if (event.part.tokens) { + inputTokens += event.part.tokens.input || 0; + outputTokens += event.part.tokens.output || 0; + } + } + } + } + } catch { + // ignore non-JSON lines + } + } + + const meta: OpencodeJsonParseResult["meta"] = {}; + if (startTime !== undefined && endTime !== undefined) { + meta.durationMs = endTime - startTime; + } + if (cost > 0) meta.cost = cost; + if (inputTokens > 0 || outputTokens > 0) { + meta.tokens = { input: inputTokens, output: outputTokens }; + } + + return { + text: text || undefined, + parsed, + valid: valid && parsed.length > 0, + meta: Object.keys(meta).length > 0 ? meta : undefined, + }; +} + +export function summarizeOpencodeMetadata( + meta: OpencodeJsonParseResult["meta"], +): string | undefined { + if (!meta) return undefined; + const parts: string[] = []; + if (meta.durationMs !== undefined) + parts.push(`duration=${meta.durationMs}ms`); + if (meta.cost !== undefined) parts.push(`cost=$${meta.cost.toFixed(4)}`); + if (meta.tokens) { + parts.push(`tokens=${meta.tokens.input}+${meta.tokens.output}`); + } + return parts.length ? parts.join(", ") : undefined; +}