UNPKG

@phren/cli

Version:

Knowledge layer for AI agents - CLI, MCP server, and data layer

588 lines (587 loc) • 29.5 kB
import { constants } from "node:fs"; import { open } from "node:fs/promises"; import { codexThreadPreview } from "./codex-threads.js"; import { readPaneText } from "./pane-text.js"; import { object, objects } from "./protocol.js"; import { withTranscriptIndex } from "./transcript-index.js"; import { stripTerminal } from "../terminal-text.js"; /** Harnesses whose preview text is the reply's own Markdown, from their deltas. */ const STREAMED_SOURCES = new Set(["codex", "opencode", "phren"]); export const PREVIEW_INTERVAL_MS = 500; const MAX_TEXT = 32_768; // A JSON-escaped UTF-16 code unit takes at most six bytes, plus metadata. const MAX_PREVIEW_BYTES = MAX_TEXT * 6 + 1_024; /** Only the last Claude reply after the current prompt is eligible. A missing * prompt anchor is deliberately silent: scrollback could belong to an old turn. */ export function claudePanePreview(rendered, prompt, previous = "") { const screen = rendered.split("\n"); const raw = screen.map(stripTerminal); // What the phone shows: the line with Claude's bold as Markdown. Detection // below reads the plain line. const marked = screen.map(line => claudeBoldMarkdown(line).replace(/[\u2500-\u257f]/g, "").trimEnd()); // The rule above the input box can carry the session title // ("───── Docs refresh ─"); it ends the reply, it is never part of it. // A narrow pane leaves the titled rule a single dash ("…title… ─"), so a // line that ends in a rule dash right above the input prompt ends it too. const lines = raw.map((line, index) => (/[─━═]{3,}/.test(line) && !/^\s*[│┃║]/.test(line)) || (/[─━═]\s*$/.test(line) && /^\s*[❯>]\s*$/.test(raw[index + 1] ?? "")) ? "❯" : line.replace(/[\u2500-\u257f]/g, "").trimEnd()); const firstPrompt = prompt.trim().split(/\r?\n/)[0]?.trim(); if (!firstPrompt) return ""; let start = -1; for (let i = 0; i < lines.length; i++) { if (!/^\s*[❯>]\s*\S/.test(lines[i])) continue; const shown = lines[i].replace(/^\s*[❯>]\s*/, "").trim(); if (shown === firstPrompt || (shown.length >= 12 && firstPrompt.startsWith(shown))) start = i + 1; } // Once anchored, a reply can scroll its prompt off screen. Extend only // through a matching text overlap; unrelated terminal lines cannot replace it. const scrolled = start < 0; if (scrolled && !previous) return ""; const reply = [], shown = []; let writing = scrolled; const body = lines.slice(Math.max(0, start)), rawBody = raw.slice(Math.max(0, start)), markedBody = marked.slice(Math.max(0, start)); // A "⏺" block whose next line is a "⎿" result is a tool call, collapsed // ("⏺ Running 1 shell command…") or not; it lands as its own entry. const toolBlock = (index) => { let wrapped = false; for (let next = index + 1; next < body.length; next++) { if (!body[next].trim()) continue; // A result ("⎿") or a sub-agent tree ("├─ Plan · 0 tool uses") follows a tool call. // A narrow pane wraps the call's description ("⏺ Finding retire wording // around Power" / "Portal reports"); its "⎿", indented under the call as // Claude draws it, comes after the wrap. A new block, the prompt or a // blank line after text ends the search. if ((wrapped ? /^\s+(?:│\s*)?[⎿├└]/ : /^\s*(?:│\s*)?[⎿├└]/).test(rawBody[next])) return true; wrapped = true; if (/^\s*[⏺●❯>]/.test(body[next])) return false; if (!body[next + 1]?.trim()) return false; } return false; }; for (const [index, line] of body.entries()) { if (/^\s*[❯>]/.test(line) || /esc(?:ape)? to interrupt/i.test(line)) break; if (/^\s*[✻✽✶✢✳✦·⠁-⣿]/u.test(line) || parseClaudeSpinnerLine(line)) continue; // Claude Code draws a finished or running tool group as an unbulleted, // indented summary under the reply (" Ran 1 shell command", " Called // phren, ran 1 shell command", " Running 1 shell command… grep -rn …"). // It starts a block after a blank line; it and its wrapped command lines // are tool activity, which lands as its own entry. if (!body[index - 1]?.trim() && claudeToolSummary(line)) { writing = false; continue; } // A running tool group ("⏺ Running 2 agents…") and a tool call // ("⏺ Bash(ls)", "⏺ phren - search (MCP)(…)") are not reply // text; it lands as its own entry a moment later. if (/^\s*[⏺●]\s*[\w.:-]+(?: - [\w.:-]+)?(?: \(MCP\))?\(/.test(line) || (/^\s*[⏺●]/.test(line) && toolBlock(index)) || /^\s*[⏺●]\s*(?:Running|Calling|Reading|Searching|Writing|Editing|Fetching|Updating|Listing|Creating)\b[^.!?]*(?:…|\.\.\.)/.test(line)) { writing = false; continue; } if (/^\s*[⏺●]/.test(line)) { reply.length = 0; shown.length = 0; writing = true; } if (!writing) continue; const tidy = (value) => value.replace(/^\s*[⏺●]\s?/, "").replace(/[⠁-⣿✻✽✶✢✳]/gu, ""); const clean = tidy(line); if (/^\s*(?:↳|⎿|ctrl\+|shift\+|\? for shortcuts)/i.test(clean) || claudeChrome(clean)) continue; reply.push(clean.replace(/^ {2}/, "")); // A rule line has no marked twin: it never reaches here. shown.push(tidy(markedBody[index] ?? line).replace(/^ {2}/, "")); } const text = unwrapTerminalLines(reply, shown).trim().slice(0, MAX_TEXT); if (!scrolled) return text; if (text.startsWith(previous)) return text; const overlap = previous.lastIndexOf(text.split("\n", 1)[0].slice(0, 80)); return overlap >= 0 && text.startsWith(previous.slice(overlap)) ? (previous.slice(0, overlap) + text).slice(0, MAX_TEXT) : previous; } const TOOL_SUMMARY_CLAUSE = String.raw `(?:(?:[Cc]alled|[Cc]alling) [\w.-]+(?: \d+ times?)?|(?:[Rr]an|[Rr]unning|[Rr]ead|[Rr]eading|[Ss]earched|[Ss]earching|[Ww]rote|[Ww]riting|[Ee]dited|[Ee]diting|[Uu]pdated|[Uu]pdating|[Ff]etched|[Ff]etching|[Ll]isted|[Ll]isting|[Cc]reated|[Cc]reating|[Ll]aunched|[Ll]aunching) \d+ [a-z]+(?: [a-z]+)?)`; const TOOL_SUMMARY = new RegExp(`^ {1,4}${TOOL_SUMMARY_CLAUSE}(?:, ${TOOL_SUMMARY_CLAUSE})*(?:(?:…|\\.\\.\\.)(?:\\s.*)?)?(?: \\(ctrl\\+o to expand\\))?\\s*$`); /** An unbulleted tool-group summary line, e.g. " Read 1 file, ran 1 shell command". */ export function claudeToolSummary(line) { return TOOL_SUMMARY.test(line); } /** * Claude Code's own screen text, never part of a reply: the update and * restart notices, status lines it marks ✔ / ✗ / ⚠ / ※, tips, and the * context and auto-accept hints drawn around the input box. */ export function claudeChrome(line) { const text = line.trim(); if (!text) return false; return /^[✔✓✗✘⚠※]\s/.test(text) || /\b(?:Update installed|Restart to update|Auto-update failed|Update available|Run \/doctor|Claude Code has been updated)\b/i.test(text) || /^(?:Tip|Hint):\s/i.test(text) || /^(?:⏵⏵|⏸)\s/.test(text) || /\b(?:auto-accept edits|plan mode) (?:on|off)\b/i.test(text) || /^Context left until auto-compact\b/i.test(text) || /^(?:esc to (?:interrupt|cancel)|press esc)\b/i.test(text); } /** A block line that starts its own row even inside a paragraph: a list * item, heading, quote, table row or code fence. */ /** No terminal pane Claude draws in is narrower than this. */ const MIN_WRAP_WIDTH = 40; const BLOCK_START = /^\s*(?:[-*+•]\s|\d+[.)]\s|#{1,6}\s|>|\||```|~~~)/; /** * Undoes the terminal's word wrap. Claude draws a paragraph wrapped to the * pane's width, so the phone would show each wrap as a hard break. A line is * joined to the next when the next line's first word would not have fitted * on it, which is exactly what a soft wrap looks like; the widest line * stands in for the pane width, and below 40 columns nothing is joined. Blank lines, block starts and fenced code * keep their breaks. `display` is what is joined, line for line: the text * with Markdown put back, measured by its plain twin. */ export function unwrapTerminalLines(lines, display = lines) { const width = Math.max(0, ...lines.map(line => line.length)); // Too narrow to be a pane's wrap: short replies keep their own lines. if (width < MIN_WRAP_WIDTH) return display.join("\n"); const out = []; let fenced = false; lines.forEach((line, index) => { const fence = /^\s*(?:```|~~~)/.test(line); const before = index > 0 ? lines[index - 1] : undefined; const firstWord = line.trimStart().split(/\s/, 1)[0] ?? ""; const wrapped = !fenced && !fence && before !== undefined && out.length > 0 && before.trim() !== "" && line.trim() !== "" && !BLOCK_START.test(line) && before.trimEnd().length + 1 + firstWord.length > width; if (wrapped) out[out.length - 1] += " " + display[index].trim(); else out.push(display[index]); if (fence) fenced = !fenced; }); return out.join("\n"); } const SPINNER_LINE = /^\s*[✻✽✶✢✳✦·*⠁-⣿]\s+([A-Z][\p{L}'-]{1,30})(?:…|\.\.\.)\s*\(([^)]*)\)/u; function spinnerSeconds(text) { const match = /^(?:(\d{1,4})h\s*)?(?:(\d{1,4})m\s*)?(?:(\d{1,5})s)?$/.exec(text.trim()); if (!match || (!match[1] && !match[2] && !match[3])) return undefined; return Number(match[1] ?? 0) * 3600 + Number(match[2] ?? 0) * 60 + Number(match[3] ?? 0); } /** One spinner line, or undefined for anything else. The parenthesis must * start with a time or say "esc to interrupt", as Claude's does. */ export function parseClaudeSpinnerLine(line) { const match = SPINNER_LINE.exec(line); if (!match) return undefined; const parts = match[2].split("·").map(part => part.trim()).filter(Boolean); if (!/^\d/.test(parts[0] ?? "") && !parts.some(part => /esc to interrupt/i.test(part))) return undefined; const spinner = { verb: match[1], thinking: false }; for (const part of parts) { const seconds = spinnerSeconds(part); const tokens = /^([↑↓])\s*(\d{1,6}(?:[.,]\d{1,3})?)\s*([kKmM]?)\s+tokens?$/.exec(part); const thought = /^thought for (.+)$/i.exec(part); if (seconds !== undefined && spinner.elapsed === undefined) spinner.elapsed = seconds; else if (tokens) { const scale = /k/i.test(tokens[3]) ? 1_000 : /m/i.test(tokens[3]) ? 1_000_000 : 1; const count = Math.round(Number(tokens[2].replace(",", ".")) * scale); if (Number.isFinite(count) && count <= 1e9) spinner.tokens = { count, direction: tokens[1] === "↑" ? "up" : "down" }; } else if (/^thinking\b/i.test(part)) spinner.thinking = true; else if (thought) { const value = spinnerSeconds(thought[1]); if (value !== undefined) spinner.thoughtFor = value; } } return spinner; } /** The newest spinner line on screen. */ export function claudeSpinner(rendered) { const lines = stripTerminal(rendered).split("\n"); for (let i = lines.length - 1; i >= 0; i--) { const spinner = parseClaudeSpinnerLine(lines[i]); if (spinner) return spinner; } return undefined; } /** The word Claude's own spinner shows ("✻ Pondering… (12s · esc to interrupt)"). */ export function claudeSpinnerVerb(rendered) { return claudeSpinner(rendered)?.verb; } /** Copilot's reasoning state for the running turn, from the headers it draws * above each reasoning block ("⌄ Thought for 16s", "Thinking" while it * streams), newest below the turn's prompt line. Only the header is read: * the reasoning under it ("│ …") never leaves the Hook. */ export function copilotThinking(rendered) { // Copilot draws a scrollbar down the right edge once the chat overflows. const lines = stripTerminal(rendered).split("\n").map(line => line.replace(/\s*┃\s*$/, "")); let from = 0; lines.forEach((line, index) => { if (/^\s*❯\s+(?!\d+[.)]\s)\S/.test(line)) from = index + 1; }); for (let i = lines.length - 1; i >= from; i--) { const header = /^\s*[⌄⌃▾▸›>]?\s*(?:(Thinking)\b.*|Thought for (\S+))\s*$/.exec(lines[i]); if (!header) continue; if (header[1]) return { verb: "Working", thinking: true }; const seconds = spinnerSeconds(header[2]); if (seconds !== undefined) return { verb: "Working", thinking: false, thoughtFor: seconds }; } return undefined; } export function readPreviewPane(target) { // With its styles: Claude's bold becomes Markdown; everything else strips. return readPaneText(target.server, target.pane, { scope: "agent", source: "visible", lines: 80, stripAnsi: false, format: "ansi", timeoutMs: 2_000, what: "Preview pane read" }); } // An SGR sequence (captured) or any other control sequence stripTerminal removes. const PANE_CONTROL = /\x1b\[([0-?]*)[ -/]*([@-~])|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\r/g; /** * One pane line as Markdown bold. Claude draws a reply's **bold** with SGR 1 * and drops the asterisks, so a plain read loses it and the phone's preview * would gain it only when the reply lands. Whitespace stays outside the * markers (Markdown does not close `**a **`); other styles are dropped. */ export function claudeBoldMarkdown(line) { const runs = []; let bold = false, last = 0; const take = (text) => { if (!text) return; const previous = runs[runs.length - 1]; if (previous?.bold === bold) previous.text += text; else runs.push({ text, bold }); }; for (const match of line.matchAll(PANE_CONTROL)) { take(line.slice(last, match.index)); last = match.index + match[0].length; if (match[2] !== "m") continue; const params = (match[1] || "0").split(/[;:]/); for (let i = 0; i < params.length; i++) { const code = Number(params[i] || 0); // Extended colors carry their own numbers: 38;5;1 is not bold. if (code === 38 || code === 48 || code === 58) { i += params[i + 1] === "5" ? 2 : params[i + 1] === "2" ? 4 : 0; continue; } if (code === 1) bold = true; else if (code === 0 || code === 22) bold = false; } } take(line.slice(last)); return runs.map(({ text, bold }) => { const core = text.trim(); if (!bold || !core) return text; const lead = text.slice(0, text.length - text.trimStart().length), trail = text.slice(text.trimEnd().length); return `${lead}**${core}**${trail}`; }).join(""); } /** Older Codex rollouts may carry public text deltas between response items. * Keep a bounded raw cursor, since history intentionally filters these out. */ export class CodexRolloutPreview { revision; cursor = 0; startedAt; text = ""; async read(file) { return withTranscriptIndex(file, async (handle, index) => { if (this.revision !== index.revision) { this.cursor = Math.max(0, index.lines - 2_000); this.startedAt = undefined; this.text = ""; } const rows = []; for await (const row of index.rows(handle, index.lines, Math.max(this.cursor, index.lines - 2_000))) { if (!row.bytes) continue; try { rows.push(object(JSON.parse(row.bytes.toString()))); } catch { /* Incomplete or malformed event. */ } } for (const raw of rows.reverse()) { const payload = object(raw.payload), params = object(raw.params); if (raw.type === "event_msg" && payload.type === "task_started") { const time = payload.started_at; this.startedAt = typeof raw.timestamp === "string" ? raw.timestamp : typeof time === "number" ? new Date(time > 1e12 ? time : time * 1000).toISOString() : undefined; this.text = ""; } else if (raw.type === "event_msg" && ["task_complete", "task_completed", "turn_aborted", "task_aborted", "error"].includes(String(payload.type))) { this.startedAt = undefined; this.text = ""; } else if (raw.type === "response_item" && payload.type === "message" && payload.role === "assistant") this.text = ""; else if (this.startedAt && ((raw.type === "event_msg" && payload.type === "agent_message_delta" && payload.channel !== "analysis") || (raw.method === "item/agentMessage/delta" && params.threadId !== undefined))) { const delta = raw.method ? params.delta : payload.delta; if (typeof delta === "string") this.text = (this.text + delta).slice(0, MAX_TEXT); } } this.cursor = index.lines; this.revision = index.revision; return this.startedAt && Number.isFinite(Date.parse(this.startedAt)) && this.text ? { turnStartedAt: this.startedAt, text: this.text } : null; }); } } export async function readDeltaPreview(target, file, rollout) { if (target.source === "codex") return await codexThreadPreview(target.session) ?? (file && rollout ? await rollout.read(file).catch(() => null) : null); // OpenCode's plugin and phren's agent write the same sidecar beside their // event log (packages/agent/src/session/preview.ts). if ((target.source !== "opencode" && target.source !== "phren") || !file) return null; try { const handle = await open(file + ".preview.json", constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); let bytes; try { const stat = await handle.stat(); if (!stat.isFile() || stat.size > MAX_PREVIEW_BYTES) return null; // Bound the read too: the file can grow between stat and read. const buffer = Buffer.alloc(MAX_PREVIEW_BYTES + 1); const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0); if (bytesRead > MAX_PREVIEW_BYTES) return null; bytes = buffer.subarray(0, bytesRead); } finally { await handle.close(); } const value = object(JSON.parse(bytes.toString("utf8"))); if (typeof value.turnStartedAt !== "string" || !Number.isFinite(Date.parse(value.turnStartedAt)) || typeof value.text !== "string" || !value.text || value.text.length > MAX_TEXT) return null; return { turnStartedAt: value.turnStartedAt, text: value.text }; } catch { return null; } } /** Socket-local, ephemeral state. It never writes transcript rows or advances * the history cursor. Final rows bypass the text throttle and clear atomically. */ export class TranscriptPreviewStream { target; pane; delta; startedAt; prompt = ""; landed = false; ended = false; /** Claude's own spinner word for the running turn, sent beside frames as * `activityVerb` for older phones. */ verb; /** The whole spinner line for the running turn, sent as `activity`; * phones without it ignore the field. */ activity; sentActivity = ""; lastRead = -Infinity; lastSent = -Infinity; current = null; observedLine = -1; wasWorking = false; /** Replies that already landed as entries: a sidecar or thread a tick * behind them must not bring the same text back as a preview. */ completed = new Set(); rollout = new CodexRolloutPreview(); constructor(target, pane = () => readPreviewPane(target), delta = file => readDeltaPreview(target, file, this.rollout)) { this.target = target; this.pane = pane; this.delta = delta; } observe(entries, reset = false) { if (reset) { this.observedLine = -1; this.startedAt = undefined; this.landed = false; this.ended = false; this.wasWorking = false; this.completed.clear(); } for (const { raw, line } of entries) { if (line <= this.observedLine) continue; this.observedLine = line; if (this.target.source === "codex") { // A completed commentary message can land before task_complete while // Codex keeps working on a tool call. const payload = object(raw.payload); if (raw.type === "event_msg" && payload.type === "task_started") { this.ended = false; this.completed.clear(); } else if (raw.type === "response_item" && payload.type === "message" && payload.role === "user" && !raw.phrenQueued) { this.ended = false; this.completed.clear(); } else if (raw.type === "response_item" && payload.type === "message" && payload.role === "assistant") { const text = (typeof payload.content === "string" ? payload.content : objects(payload.content).map(block => typeof block.text === "string" ? block.text : "").join("\n")).trim(); if (text) { this.completed.add(text); if (this.completed.size > 32) this.completed.delete(this.completed.values().next().value); } } else if (raw.type === "event_msg" && ["task_complete", "task_completed", "turn_aborted", "task_aborted"].includes(String(payload.type))) this.ended = true; continue; } if (this.target.source === "phren") { const data = object(raw.data), message = object(data.message); if (raw.type === "user/message") this.completed.clear(); else if (raw.type === "assistant/message") { const text = (typeof message.content === "string" ? message.content : objects(message.content).filter(block => block.type === "text").map(block => String(block.text ?? "")).join("\n")).trim(); if (text) { this.completed.add(text); if (this.completed.size > 32) this.completed.delete(this.completed.values().next().value); } } continue; } if (this.target.source === "copilot") { // A prompt starts the turn; idle or abort ends it (Copilot writes a // turn_start/turn_end pair per model call, not per prompt). if (raw.type === "user.message" && typeof raw.timestamp === "string") { this.startedAt = raw.timestamp; this.ended = false; this.wasWorking = false; this.verb = undefined; this.activity = undefined; } else if (["session.idle", "abort"].includes(String(raw.type))) { this.ended = true; this.verb = undefined; this.activity = undefined; } continue; } if (this.target.source !== "claude") continue; const message = object(raw.message), blocks = objects(message.content); if (raw.type === "user" && !raw.phrenQueued && !raw.isMeta && !blocks.some(b => b.type === "tool_result")) { const prompt = typeof message.content === "string" ? message.content : blocks.filter(b => b.type === "text").map(b => String(b.text ?? "")).join("\n"); if (prompt.trim() && typeof raw.timestamp === "string" && Number.isFinite(Date.parse(raw.timestamp))) { this.startedAt = raw.timestamp; this.prompt = prompt; this.landed = false; this.ended = false; this.wasWorking = false; this.verb = undefined; this.activity = undefined; } } else if (this.startedAt && ((raw.type === "assistant" && message.stop_reason === "end_turn") || (raw.type === "system" && raw.subtype === "turn_duration"))) { // The transcript says the turn is over before the shared snapshot // (up to 2.5 s old) stops saying "working": stop reading the pane now. this.landed = true; this.ended = true; this.verb = undefined; this.activity = undefined; } else if (this.startedAt && (raw.type === "assistant" || blocks.some(b => b.type === "tool_result"))) this.landed = true; } } async update(status, file, now) { const readAt = now ?? Date.now(); let next = null; if (status === "working") { this.wasWorking = true; if (STREAMED_SOURCES.has(this.target.source)) { // These harnesses own a delta source. Never scrape their pane, even // when the source is temporarily empty or unavailable. if (!this.ended) next = await this.delta(file); if (this.target.source !== "opencode" && next) { const text = next.text.trim(); if (text && [...this.completed].some(completed => completed.startsWith(text))) next = null; } } else if (this.target.source === "claude" && this.startedAt && !this.ended) { if (readAt - this.lastRead < PREVIEW_INTERVAL_MS) return undefined; this.lastRead = readAt; // The reply text stops once a real entry lands; Claude's spinner // verb keeps naming the work until the turn ends. const pane = await this.pane(); const text = this.landed ? "" : claudePanePreview(pane, this.prompt, this.current?.turnStartedAt === this.startedAt ? this.current.text : ""); const spinner = claudeSpinner(pane); if (spinner) { this.activity = spinner; this.verb = spinner.verb; } if (text) next = { turnStartedAt: this.startedAt, text }; } else if (this.target.source === "copilot" && this.startedAt && !this.ended) { // No reply text from the pane: only whether Copilot is thinking. if (readAt - this.lastRead < PREVIEW_INTERVAL_MS) return undefined; this.lastRead = readAt; const thinking = copilotThinking(await this.pane()); if (thinking) { this.activity = thinking; this.verb = thinking.verb; } } } else if (["claude", "copilot"].includes(this.target.source) && this.wasWorking) { this.landed = true; this.ended = true; this.verb = undefined; this.activity = undefined; } const sentAt = now ?? Date.now(); // The phone ticks the clock itself: only the verb, tokens and thinking // state are worth a frame of their own. const activity = this.activity ? JSON.stringify({ ...this.activity, elapsed: undefined }) : ""; const previewChanged = next?.text !== this.current?.text || next?.turnStartedAt !== this.current?.turnStartedAt; const activityChanged = activity !== this.sentActivity; if (!previewChanged && !activityChanged) return undefined; if ((next || !previewChanged) && sentAt - this.lastSent < PREVIEW_INTERVAL_MS) return undefined; if (next && STREAMED_SOURCES.has(this.target.source)) { const before = this.current?.turnStartedAt === next.turnStartedAt ? this.current.text : ""; next = { turnStartedAt: next.turnStartedAt, text: next.text, delta: before && next.text.startsWith(before) ? next.text.slice(before.length) : next.text, streamed: true }; } this.current = next; this.sentActivity = activity; if (next || activityChanged) this.lastSent = sentAt; return { preview: next }; } }