UNPKG

@phren/cli

Version:

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

443 lines (442 loc) • 22.8 kB
import { createHash } from "node:crypto"; import { mkdir, open, readFile, stat } from "node:fs/promises"; import { codexHome } from "../home-paths.js"; import path from "node:path"; import { atomic, bridgeRoot, object, objects, sessionId } from "./protocol.js"; /** Codex 0.155 keeps a thread's history in `thread_history_1.sqlite` and no * longer writes the rollout file its `state_5.sqlite` still names. The Hook * materializes such a thread into a rollout-shaped JSONL of its own, append * only, so every reader of Codex transcripts keeps working unchanged. */ const MAX_ITEMS = 20_000, OUTPUT_TAIL = 4_000; const STALLED_AFTER_MS = 10 * 60 * 1_000; export function materializedRoot() { return path.join(bridgeRoot(), "codex-threads"); } export function materializedPath(session) { return path.join(materializedRoot(), `${session}.jsonl`); } async function openReadOnly(file) { try { await stat(file); const sqlite = await import("node:sqlite"); return new sqlite.DatabaseSync(file, { readOnly: true }); } catch { return undefined; } } /** A working pane whose projection cursor stopped at an unfinished turn can * keep accepting input even though none of it will be readable again. */ export async function threadHealth(session, agentStatus) { if (!sessionId.safeParse(session).success || !["working", "blocked", "waiting"].includes(String(agentStatus))) return { stalled: false }; const stateFile = materializedPath(session) + ".state.json"; let cursor, cursorMtime; try { cursor = { lastOrdinal: -1, maxUpdated: -1, count: 0, done: {}, ...object(JSON.parse(await readFile(stateFile, "utf8"))) }; cursorMtime = (await stat(stateFile)).mtimeMs; } catch { return { stalled: false }; } if (Date.now() - cursorMtime < STALLED_AFTER_MS) return { stalled: false }; const history = await openReadOnly(path.join(codexHome(), "thread_history_1.sqlite")); if (!history) return { stalled: false }; try { const summary = object(history.prepare("select count(*) as n, max(rollout_ordinal) as last, max(updated_at_ordinal) as updated from thread_items where thread_id = ?").get(session)); // The store may have advanced just before the materializer catches up. if (cursor.count !== Number(summary.n) || cursor.lastOrdinal !== Number(summary.last) || cursor.maxUpdated !== Number(summary.updated)) return { stalled: false }; const turn = object(history.prepare("select status, rollout_end_ordinal from thread_turns where thread_id = ? order by rollout_ordinal desc limit 1").get(session)); if (turn.status !== "inProgress" || turn.rollout_end_ordinal !== null) return { stalled: false }; return { stalled: true, since: new Date(cursorMtime).toISOString() }; } catch { return { stalled: false }; } finally { history.close(); } } const materializations = new Map(); /** The thread store accumulates agent-message deltas in place. Only text * from the newest unfinished message is public; reasoning is never read out. */ function pendingMessage(history, session) { const turn = object(history.prepare("select turn_id, status, started_at from thread_turns where thread_id = ? order by rollout_ordinal desc limit 1").get(session)); if (turn.status !== "inProgress") return undefined; // Queued steering can be appended while the current reply is still growing. // It does not finish that reply or make its partial text safe to persist. const row = object(history.prepare(`select item_json from thread_items where thread_id = ? and turn_id = ? and not (item_type = 'userMessage' and case when json_valid(item_json) then coalesce(json_extract(item_json, '$.status'), '') else '' end = 'queued') order by rollout_ordinal desc limit 1`).get(session, turn.turn_id)); let item; try { item = object(JSON.parse(String(row.item_json))); } catch { return undefined; } if (item.type !== "agentMessage" || finished(item) || item.delivery === "async") return undefined; const started = Number(turn.started_at); if (!Number.isFinite(started) || started <= 0) return undefined; return { id: String(item.id), turnStartedAt: new Date(started > 1e12 ? started : started * 1000).toISOString(), text: typeof item.text === "string" ? item.text.slice(0, 32_768) : "" }; } export async function codexThreadPreview(session) { if (!sessionId.safeParse(session).success) return undefined; const history = await openReadOnly(path.join(codexHome(), "thread_history_1.sqlite")); if (!history) return undefined; try { const pending = pendingMessage(history, session); return pending?.text ? { turnStartedAt: pending.turnStartedAt, text: pending.text } : undefined; } catch { return undefined; } finally { history.close(); } } /** Bring the materialized file up to date with the store. Returns the file * when the thread exists there, undefined otherwise. Safe to call often: * an unchanged thread costs one aggregate query. */ export async function materializeCodexThread(session) { if (!sessionId.safeParse(session).success) return undefined; const key = materializedPath(session); const pending = materializations.get(key); if (pending) return pending; const work = materializeThread(session); materializations.set(key, work); try { return await work; } finally { materializations.delete(key); } } async function materializeThread(session) { if (!sessionId.safeParse(session).success) return undefined; const history = await openReadOnly(path.join(codexHome(), "thread_history_1.sqlite")); if (!history) return undefined; try { const summary = object(history.prepare("select count(*) as n, max(rollout_ordinal) as last, max(updated_at_ordinal) as updated from thread_items where thread_id = ?").get(session)); if (!Number(summary.n)) return undefined; const file = materializedPath(session), stateFile = file + ".state.json"; await mkdir(materializedRoot(), { recursive: true, mode: 0o700 }); let state = { lastOrdinal: -1, maxUpdated: -1, count: 0, done: {} }; try { state = { ...state, ...object(JSON.parse(await readFile(stateFile, "utf8"))) }; } catch { /* first time */ } const fresh = state.count === 0; const turn = object(history.prepare("select turn_id, status from thread_turns where thread_id = ? order by rollout_ordinal desc limit 1").get(session)); const turnSignature = JSON.stringify(turn); if (!fresh && state.count === Number(summary.n) && state.lastOrdinal === Number(summary.last) && state.maxUpdated === Number(summary.updated) && state.turnSignature === turnSignature) return file; const pending = pendingMessage(history, session); const rows = objects(history.prepare("select rollout_ordinal, item_type, item_json, updated_at_ordinal from thread_items where thread_id = ? order by rollout_ordinal limit ?").all(session, MAX_ITEMS)); const lines = []; if (fresh) { const meta = await threadMeta(session); // A child thread's row names its parent inside `source`; the child // agent tree verifies that link from the first line, so it rides along. lines.push(JSON.stringify({ type: "session_meta", payload: { id: session, ...(meta.cwd ? { cwd: meta.cwd } : {}), source: meta.spawn ? { subagent: { thread_spawn: meta.spawn } } : "codex-thread-store" } })); if (meta.model) lines.push(JSON.stringify({ type: "turn_context", payload: { model: meta.model } })); } for (const row of rows) { let item; try { item = object(JSON.parse(String(row.item_json))); } catch { continue; } const id = String(item.id ?? `ordinal-${row.rollout_ordinal}`), emitted = state.done[id]; if (item.type === "agentMessage" && id === pending?.id) continue; const calls = callRows(item), output = outputRow(item); // Preserve only queue state actually recorded by the harness. The // append-only transcript pairs the queued user row with a content-free // consumption marker when that same item leaves the queue. if (emitted === "queued" && item.type === "userMessage" && item.status !== "queued") { lines.push(JSON.stringify({ type: "phren_queue_consumed", key: queueKey(session, id) })); state.done[id] = "done"; continue; } if (!emitted) { if (calls) { for (const call of calls) lines.push(JSON.stringify(call)); state.done[id] = "call"; } else if (subAgentRow(item)) { // A child Codex agent (spawn, interaction, completion) rides along // as the event the rollout carried before 0.155, so the child // agent tree keeps finding native subagents. lines.push(JSON.stringify(subAgentRow(item))); state.done[id] = "done"; continue; } else if (messageRow(item)) { const message = messageRow(item); const queued = item.type === "userMessage" && item.status === "queued"; lines.push(JSON.stringify(queued ? { ...message, phrenQueued: true, phrenQueueKey: queueKey(session, id) } : message)); // A question the agent asked (delivered async) rides along as the // same event the rollout would carry, so the pending scan sees it. const asked = questionEvent(item); if (asked) lines.push(JSON.stringify(asked)); state.done[id] = queued ? "queued" : "done"; continue; } else if (isQueuedQuestionItem(item)) { // The queued follow-up question reads in the transcript as the // asking sentence; the Hook answers it from the store, not by // quoting a reply, so no async event rides along here. const title = questionTitle(objects(item.questions)); if (title) lines.push(JSON.stringify({ type: "response_item", payload: { type: "message", role: "assistant", content: [{ type: "output_text", text: title }] } })); state.done[id] = "done"; continue; } else continue; } if (state.done[id] === "call" && output) { lines.push(JSON.stringify(output)); state.done[id] = "done"; } } if (lines.length) { const handle = await open(file, "a", 0o600); try { await handle.appendFile(lines.join("\n") + "\n"); } finally { await handle.close(); } } state.count = Number(summary.n); state.lastOrdinal = Number(summary.last); state.maxUpdated = Number(summary.updated); state.turnSignature = turnSignature; await atomic(stateFile, JSON.stringify(state)); return file; } catch { return undefined; } finally { history.close(); } } /** Codex 0.155 stores a child agent's lifecycle as `subAgentActivity` items * with camelCase fields; the rollout shape the child tree parses is the * `SubAgentActivity` event with snake_case fields. */ function subAgentRow(item) { if (item.type !== "subAgentActivity") return undefined; const kind = String(item.kind ?? ""), child = String(item.agentThreadId ?? item.agent_thread_id ?? ""); if (!child) return undefined; return { type: "event_msg", payload: { type: "item_completed", item: { type: "SubAgentActivity", id: String(item.id ?? ""), kind, agent_thread_id: child, agent_path: String(item.agentPath ?? item.agent_path ?? ""), } } }; } async function threadMeta(session) { const db = await openReadOnly(path.join(codexHome(), "state_5.sqlite")); if (!db) return {}; try { const row = object(db.prepare("select model, cwd from threads where id = ?").get(session)); let spawn; try { // Older stores have no `source` column; a child thread's names its parent there. const source = object(db.prepare("select source from threads where id = ?").get(session)).source; if (typeof source === "string" && source.startsWith("{")) { const parsed = object(object(JSON.parse(source)).subagent).thread_spawn; if (parsed && typeof parsed === "object" && Object.keys(parsed).length) spawn = parsed; } } catch { /* no source column or plain text */ } return { ...(typeof row.model === "string" && row.model ? { model: row.model } : {}), ...(typeof row.cwd === "string" && row.cwd ? { cwd: row.cwd } : {}), ...(spawn ? { spawn } : {}) }; } catch { return {}; } finally { db.close(); } } function finished(item) { return ["completed", "failed", "declined", "error"].includes(String(item.status)) || item.exitCode !== undefined && item.exitCode !== null; } function queueKey(session, id) { return createHash("sha256").update(`${session}\0${id}`).digest("hex"); } /** A message row, for the two message kinds. */ function messageRow(item) { if (item.type === "userMessage") { const text = objects(item.content).filter(b => b.type === "text" && typeof b.text === "string").map(b => String(b.text)).join("\n"); return text ? { type: "response_item", payload: { type: "message", role: "user", content: [{ type: "input_text", text }] } } : undefined; } if (item.type === "agentMessage" && typeof item.text === "string" && item.text) { return { type: "response_item", payload: { type: "message", role: "assistant", content: [{ type: "output_text", text: item.text }] } }; } return undefined; } /** Codex 0.155 asks through an agent message with `questions` attached. */ function questionEvent(item) { if (item.type !== "agentMessage" || item.delivery !== "async" || !Array.isArray(item.questions) || !item.questions.length) return undefined; return { type: "event_msg", payload: { type: "item_completed", item: { type: "AgentMessage", id: String(item.id ?? ""), content: [{ type: "Text", text: String(item.text ?? "") }], delivery: "async", questions: objects(item.questions).slice(0, 8) } } }; } /** Codex 0.155 records a queued follow-up question as its own thread item * (`question` or `requestUserInput`), not as a delivered async agent message. * It sits under the terminal's "Queued follow-up inputs" until alt+up opens * it, so the Hook reads its text and options straight from the store. */ const QUEUED_QUESTION_TYPES = new Set(["question", "requestUserInput"]); function isQueuedQuestionItem(item) { return QUEUED_QUESTION_TYPES.has(String(item.type)); } function questionAnswered(item) { if (item.answers !== undefined && item.answers !== null) return true; return ["answered", "completed", "resolved", "cancelled", "canceled", "error", "declined"].includes(String(item.status ?? "")); } function questionTitle(questions) { return questions.map(raw => { const q = object(raw); const text = [q.question, q.title].find(v => typeof v === "string" && v.trim()); return typeof text === "string" ? text.trim() : ""; }).filter(Boolean).join("\n\n").slice(0, 4_000); } function questionOptionLabels(questions) { const first = object(questions[0]); const raw = first.options; if (!Array.isArray(raw)) return []; return raw.map(option => { if (typeof option === "string") return option.trim(); const label = object(option).label; return typeof label === "string" ? label.trim() : ""; }).filter(Boolean).slice(0, 12); } /** The pending queued question as the pane's choice shape: the asking * sentence and one numbered option per choice (Codex answers with number * keys once alt+up has opened the queue). Undefined when nothing answerable * remains. */ export async function queuedQuestion(session) { if (!sessionId.safeParse(session).success) return undefined; const db = await openReadOnly(path.join(codexHome(), "thread_history_1.sqlite")); if (!db) return undefined; try { const rows = objects(db.prepare("select item_json from thread_items where thread_id = ? and item_type in ('question', 'requestUserInput') order by rollout_ordinal desc limit 32").all(session)); for (const row of rows) { let item; try { item = object(JSON.parse(String(row.item_json))); } catch { continue; } if (questionAnswered(item)) continue; const questions = objects(item.questions); const title = questionTitle(questions); if (!title) continue; const labels = questionOptionLabels(questions); return { title, options: labels.map((label, index) => ({ label, key: String(index + 1) })) }; } return undefined; } catch { return undefined; } finally { db.close(); } } /** Codex 0.155 code mode stores a generic tool call whose input is JavaScript; * whichever key holds it, the Hook projects the source in the rollout reader. */ function codeModeInput(item) { for (const key of ["input", "code", "script"]) { const value = item[key]; if (typeof value === "string") return value; if (value && typeof value === "object" && ["code", "source", "script"].some(k => typeof object(value)[k] === "string")) return value; } return undefined; } /** The call row(s) an item starts with; tool inputs, not reasoning. */ function callRows(item) { const id = String(item.id ?? ""); switch (item.type) { case "commandExecution": return [{ type: "response_item", payload: { type: "function_call", name: "shell", call_id: id, arguments: JSON.stringify({ command: String(item.command ?? "").slice(0, 4000), ...(typeof item.cwd === "string" ? { workdir: item.cwd } : {}) }) } }]; case "fileChange": return [{ type: "response_item", payload: { type: "function_call", name: "apply_patch", call_id: id, arguments: JSON.stringify({ files: objects(item.changes).slice(0, 50).map(c => ({ path: String(c.path ?? ""), kind: String(object(c.kind).type ?? c.kind ?? "") })) }) } }]; case "mcpToolCall": return [{ type: "response_item", payload: { type: "function_call", name: `mcp__${String(item.server ?? "mcp")}__${String(item.tool ?? "tool")}`, call_id: id, arguments: JSON.stringify(object(item.arguments)) } }]; case "webSearch": return [{ type: "response_item", payload: { type: "function_call", name: "web_search", call_id: id, arguments: JSON.stringify({ query: String(item.query ?? "").slice(0, 2000) }) } }]; default: { const input = codeModeInput(item); if (input === undefined) return undefined; return [{ type: "response_item", payload: { type: "custom_tool_call", name: String(item.name ?? item.tool ?? "exec"), call_id: id, input } }]; } } } /** The output row once the item finished. */ function outputRow(item) { if (!finished(item)) return undefined; const id = String(item.id ?? ""); switch (item.type) { case "commandExecution": { const text = typeof item.aggregatedOutput === "string" ? item.aggregatedOutput.slice(-OUTPUT_TAIL) : ""; const status = typeof item.exitCode === "number" ? `[exit ${item.exitCode}]` : `[${String(item.status)}]`; return { type: "response_item", payload: { type: "function_call_output", call_id: id, output: `${text}${text && !text.endsWith("\n") ? "\n" : ""}${status}` } }; } case "fileChange": { const changes = objects(item.changes).slice(0, 50); const kind = (c) => { const t = String(object(c.kind).type ?? c.kind ?? "update"); return t === "add" ? "A" : t === "delete" ? "D" : "M"; }; const files = changes.map(c => ({ path: String(c.path ?? ""), status: kind(c), ...(typeof c.diff === "string" ? { patch: c.diff.slice(0, 200_000) } : {}) })); const output = changes.length ? `${changes.length} file(s) changed\n${files.map(f => f.path).join("\n")}` : "0 file(s) changed"; return { type: "response_item", payload: { type: "function_call_output", call_id: id, output }, ...(files.length ? { phren_changes: { [id]: files } } : {}) }; } case "mcpToolCall": { const parts = objects(object(item.result).content).filter(b => b.type === "text" && typeof b.text === "string").map(b => String(b.text)); const output = parts.length ? parts.join("\n").slice(-OUTPUT_TAIL) : typeof item.error === "string" ? item.error.slice(0, 2000) : String(item.status); return { type: "response_item", payload: { type: "function_call_output", call_id: id, output } }; } case "webSearch": { const results = objects(item.results).slice(0, 10).map(r => [r.title, r.snippet].filter(v => typeof v === "string" && v).join(": ")).join("\n"); return { type: "response_item", payload: { type: "function_call_output", call_id: id, output: results || "searched" } }; } default: { if (codeModeInput(item) === undefined) return undefined; const text = [item.aggregatedOutput, item.output, item.result].find(v => typeof v === "string" && v); return { type: "response_item", payload: { type: "custom_tool_call_output", call_id: id, output: (text ?? String(item.status ?? "completed")).slice(-OUTPUT_TAIL) } }; } } }