UNPKG

@phren/cli

Version:

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

223 lines (222 loc) • 10 kB
import { homedir, tmpdir } from "node:os"; import path from "node:path"; import { withTranscriptIndex } from "./transcript-index.js"; import { refreshTranscript, transcriptPath, visibleEvent } from "./transcripts.js"; import { claudeName } from "./models.js"; import { BridgeError, object, objects } from "./protocol.js"; /** What a working agent is doing right now, in the words the lock screen * uses: the newest tool call in its transcript, or that it is writing or * reading. Bounded to the tail; cached until the file changes. */ const cache = new Map(); const modelCache = new Map(); const TAIL_ROWS = 24, MODEL_TAIL_ROWS = 200, LIMIT = 40; // A command often opens with `export FOO=1`-style assignments; they name the // computer's setup, not the work, and can carry an absolute path. const SHELL_ASSIGNMENTS = /^(?:(?:export\s+)?[A-Za-z_][A-Za-z0-9_]*=(?:"[^"]*"|'[^']*'|[^\s;]*)[;\s]+)+/; // The scratchpad a wrapper like Claude Code runs in is a temp path the account // name hides inside; only its last component tells the person anything. const TEMP_ROOTS = [tmpdir(), "/private/tmp", "/tmp"].map(root => root.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")); const TEMP_PATH = new RegExp(`(?:${TEMP_ROOTS.join("|")})(?:/[A-Za-z0-9._@+-]+)+`, "g"); const collapseTemp = (value) => value.replace(TEMP_PATH, m => "…/" + (m.split("/").filter(Boolean).pop() ?? "")); export async function currentStep(source, session) { let file; try { file = await transcriptPath(source, session); await refreshTranscript(file, source, session); } catch (error) { if (error instanceof BridgeError) return undefined; throw error; } return withTranscriptIndex(file, async (handle, index) => { const key = `${index.revision}:${index.lines}`; const cached = cache.get(file); if (cached?.key === key) return cached.step; let step; for await (const row of index.rows(handle, index.lines, Math.max(0, index.lines - TAIL_ROWS))) { if (!row.bytes) continue; let raw; try { raw = visibleEvent(object(JSON.parse(row.bytes.toString())), source); } catch { continue; } if (!raw) continue; const decided = stepOf(raw, source); if (decided) { step = decided; break; } } cache.delete(file); cache.set(file, { key, step }); while (cache.size > 128) cache.delete(cache.keys().next().value); return step; }); } /** The model the agent's newest answer ran on, for the lock screen's row * label. Codex names it once per turn; Claude stamps it on every assistant * row, and its raw id reads as the family and version the phone shows. */ export async function currentModel(source, session) { let file; try { file = await transcriptPath(source, session); await refreshTranscript(file, source, session); } catch (error) { if (error instanceof BridgeError) return undefined; throw error; } return withTranscriptIndex(file, async (handle, index) => { const key = `${index.revision}:${index.lines}`; const cached = modelCache.get(file); if (cached?.key === key) return cached.model; let model; for await (const row of index.rows(handle, index.lines, Math.max(0, index.lines - MODEL_TAIL_ROWS))) { if (!row.bytes) continue; let raw; // Model metadata need not be a visible transcript row (OpenCode strips it). try { raw = object(JSON.parse(row.bytes.toString())); } catch { continue; } if (!raw) continue; const decided = modelOf(raw, source); if (decided) { model = decided; break; } } modelCache.delete(file); modelCache.set(file, { key, model }); while (modelCache.size > 128) modelCache.delete(modelCache.keys().next().value); return model; }); } /** The model a visible row names, in the shape the chat header reads. */ export function modelOf(raw, source) { if (source === "claude" && (raw.type !== "assistant" || raw.isSidechain === true || raw.isMeta === true)) return undefined; if (source === "codex" && raw.type !== "turn_context") return undefined; if ((source === "phren" || source === "opencode") && raw.type !== "assistant/message") return undefined; const message = source === "phren" || source === "opencode" ? object(object(raw.data).message) : object(raw.message); const value = source === "codex" ? object(raw.payload).model : message.model; if (typeof value !== "string" || !value.trim()) return undefined; const name = source === "claude" ? claudeName(value.trim()) : value.trim(); return name.slice(0, 100); } /** One row's verdict, newest first: a tool call names the step; an * assistant text means a reply is being written; a person's turn means the * agent is reading it. Tool results and bookkeeping say nothing. */ export function stepOf(raw, source) { if (source === "codex") { const payload = object(raw.payload); if (raw.type !== "response_item") return undefined; if (["function_call", "custom_tool_call"].includes(String(payload.type))) return describe(String(payload.name ?? ""), payload.arguments ?? payload.input); if (payload.type === "message") return payload.role === "assistant" ? "Writing a reply" : payload.role === "user" ? "Reading your message" : undefined; return undefined; } if (source === "copilot") { const data = object(raw.data); if (raw.type === "tool.execution_start") return describe(String(data.toolName ?? ""), data.arguments); if (raw.type === "assistant.message") return "Writing a reply"; if (raw.type === "user.message") return "Reading your message"; return undefined; } const message = source === "phren" || source === "opencode" ? object(object(raw.data).message) : object(raw.message); const role = source === "phren" || source === "opencode" ? (raw.type === "assistant/message" ? "assistant" : raw.type === "user/message" ? "user" : "tool") : String(raw.type); const blocks = objects(message.content); if (role === "assistant") { const call = [...blocks].reverse().find(b => b.type === "tool_use"); if (call) return describe(String(call.name ?? ""), call.input); return blocks.some(b => b.type === "text") || typeof message.content === "string" ? "Writing a reply" : undefined; } if (role === "user") { if (blocks.some(b => b.type === "tool_result")) return undefined; return blocks.some(b => b.type === "text") || typeof message.content === "string" ? "Reading your message" : undefined; } return undefined; } export function describe(tool, args) { let input = {}; if (typeof args === "string") { try { input = object(JSON.parse(args)); } catch { input = {}; } } else input = object(args); const text = (...keys) => { for (const key of keys) { const v = input[key]; if (typeof v === "string" && v.trim()) return v; if (Array.isArray(v) && v.every(p => typeof p === "string")) return v.join(" "); } return ""; }; const firstLine = (v) => v.split(/\r?\n/).map(l => l.trim()).find(l => l) ?? ""; const base = (v) => path.basename(v.split(" · ")[0].trim()) || "file"; const name = tool.toLowerCase(); let value; if (["bash", "shell", "exec_command", "exec", "parallel", "tools", "write_stdin", "container.exec"].includes(name)) { // Drop the shell wrapper, a leading run of variable assignments, and a // `cd <dir> &&`, and keep the home directory (and the account name in it) // and any scratchpad path off the lock screen. const command = collapseTemp(firstLine(text("command", "cmd")).replace(/^(?:\/\S*\/)?(?:bash|sh|zsh)\s+-l?c\s+/, "") .replace(/^(['"])(.*)\1$/, "$2").replace(SHELL_ASSIGNMENTS, "").replace(/^cd\s+(?:"[^"]*"|'[^']*'|\S+)\s*(?:&&|;)\s*/, "").replace(SHELL_ASSIGNMENTS, "").split(homedir()).join("~")); value = command ? `${tool}: ${command}` : tool; } else if (["edit", "multiedit", "write", "notebookedit", "patch", "apply_patch", "str_replace_editor", "str_replace"].includes(name)) { const patchTarget = /\*\*\* (?:Update|Add|Delete) File: (.+)/.exec(text("input", "patch"))?.[1]; value = `Editing ${base(text("file_path", "filePath", "path") || patchTarget || "")}`; } else if (["read", "ls", "list"].includes(name)) value = `Reading ${base(text("file_path", "filePath", "path"))}`; else if (["grep", "glob", "search", "websearch", "browse"].includes(name)) { const q = firstLine(text("pattern", "query")); value = q ? `Searching ${q}` : tool; } else if (["fetch", "webfetch"].includes(name)) { let host = ""; try { host = new URL(text("url")).host; } catch { /* not a URL */ } value = host ? `Fetching ${host}` : tool; } else if (["task", "agent"].includes(name)) { const d = firstLine(text("description", "name")); value = d ? `Delegating ${d}` : tool; } else { const d = firstLine(text("command", "cmd", "query", "description")); value = d ? `${tool}: ${d}` : tool || "Working"; } return value.length > LIMIT ? value.slice(0, LIMIT - 1) + "…" : value; }