UNPKG

@phren/cli

Version:

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

488 lines (487 loc) • 25.7 kB
import { createHash } from "node:crypto"; import { readFileSync } from "node:fs"; import { open } from "node:fs/promises"; import { z } from "zod"; import { atomicInPrivateDir, object, objects, provider } from "./protocol.js"; import { transcriptPath, unwrapPastedContent } from "./transcripts.js"; /** A message is kept this long, by its id, so a late status or a retry still * finds it: a busy Codex turn can run far past the old ten minutes. */ export const DELIVERY_RETENTION_MS = 24 * 3_600_000; /** An agent that ended its turn after the message was typed and then stayed * idle this long without taking it has dropped it. */ export const DELIVERY_GIVE_UP_MS = 30_000; const MAX_RECORDS = 512; /** How often a message still held by a working agent is looked for in its transcript. */ const LANDED_CHECK_MS = 3_000; /** The transcript scan starts this long before a message was typed (clock skew), so entries up to this far back may belong to an earlier message. */ const ENTRY_SLACK_MS = 5_000; /** What the agent hands its UserPromptSubmit hook is the terminal's pasted * form of what Phren typed; compare the words, not the wrapping. Claude Code * also takes each attached picture's path line out of the text and puts an * "[Image #N]" label at the front, so neither side keeps picture paths or * labels. */ export function promptKey(text) { return unwrapPastedContent(text).split("\n").filter(line => !PICTURE_PATH_LINE.test(line)).join("\n") .replace(/\[Image #\d+\]/g, " ").replace(/\s+/g, " ").trim(); } const PICTURE_PATH_LINE = /^\s*\/.*\.(?:png|jpe?g|gif|webp)\s*$/i; export const promptHash = (text) => createHash("sha256").update(promptKey(text)).digest("hex"); const recordSchema = z.object({ id: z.string().regex(/^[A-Za-z0-9_-]{8,64}$/).optional(), hash: z.string().regex(/^[0-9a-f]{64}$/), server: z.string().min(1).max(200), pane: z.string().min(1).max(200), source: provider, session: z.string().min(1).max(200), terminal: z.string().max(200).optional(), state: z.enum(["typed", "queued", "delivered", "failed"]), reason: z.string().max(500).optional(), at: z.number(), settledAt: z.number().optional(), stoppedAt: z.number().optional(), /** The transcript entry (byte offset) that settled it, so no other message claims the same one. */ entry: z.number().optional(), }).strict(); /** One message per record, bound to the pane it was typed into (server, pane, * agent kind), not to the conversation: a Codex thread or a Claude /clear can * replace the conversation while the agent still holds the typed text, and * the conversation that submits it is the one it reached. Records hold a hash * of the words, never the text, and those with a phone id are written to * `file` so a Hook restart keeps their state. */ export class PromptDeliveries { file; now; landed; records = []; waiters = new Map(); writing = Promise.resolve(); /** Panes (server, pane, agent) that sent a UserPromptSubmit or Stop since the Hook started. */ hookPanes = new Set(); paneKey(target) { return [target.server, target.pane, target.source].join("|"); } static open(record) { return record.state === "typed" || record.state === "queued"; } /** Open, or failed with its own hook's claim still waiting for its row (a late delivery). */ static claimable(record) { return PromptDeliveries.open(record) || (record.state === "failed" && !!record.hookClaim); } constructor(file, now = Date.now, landed = transcriptEntries) { this.file = file; this.now = now; this.landed = landed; if (!file) return; try { for (const row of objects(object(JSON.parse(readFileSync(file, "utf8"))).records)) { const parsed = recordSchema.safeParse(row); // A message the Hook was typing when it stopped is in the pane or // nowhere: wait for the agent, and give up as for any other. if (parsed.success) this.records.push({ ...parsed.data, state: parsed.data.state === "typed" ? "queued" : parsed.data.state }); } } catch { /* No file yet, or an unreadable one: start empty. */ } this.prune(); } samePane(record, target) { return record.server === target.server && record.pane === target.pane && record.source === target.source; } byId(id) { return this.records.find(record => record.id === id); } prune() { const cutoff = this.now() - DELIVERY_RETENTION_MS; this.records = this.records.filter(record => record.at >= cutoff || this.waiters.has(record)); while (this.records.length > MAX_RECORDS) this.records.shift(); } save() { if (!this.file) return; // A record without a phone id is kept once it claimed a transcript row, so after a restart that row is not offered to another message. const rows = this.records.filter(record => record.id || record.entry !== undefined).map(({ idleSince: _, checkedAt: __, hookClaim: ___, seenRow: ____, ...row }) => row); const file = this.file; this.writing = this.writing.then(() => atomicInPrivateDir(file, { records: rows })).catch(() => undefined); } /** Settled writes, for tests and an orderly shutdown. */ flush() { return this.writing; } /** Register a message about to be typed into `target`'s pane. Settles * "delivered" once a conversation in that pane submits the words, or * "pending" after `waitMs`; the record outlives the wait. A retry of a * failed id replaces its record. Aborting (the terminal refused before * writing) drops it. */ expect(target, text, waitMs, signal, id, terminal) { if (!promptKey(text) || signal?.aborted) return Promise.resolve("pending"); this.prune(); if (id) this.records = this.records.filter(record => record.id !== id); const record = { ...(id ? { id } : {}), hash: promptHash(text), server: target.server, pane: target.pane, source: target.source, session: target.session, ...(typeof terminal === "string" ? { terminal } : {}), state: "typed", at: this.now() }; this.records.push(record); this.save(); return new Promise(resolve => { const timer = setTimeout(() => this.release(record, "pending"), waitMs); timer.unref?.(); this.waiters.set(record, { resolve, timer }); signal?.addEventListener("abort", () => { this.release(record, "pending"); if (record.state === "typed") { this.records = this.records.filter(entry => entry !== record); this.save(); } }, { once: true }); }); } release(record, outcome) { const waiter = this.waiters.get(record); if (!waiter) return; clearTimeout(waiter.timer); waiter.resolve?.(outcome); waiter.resolve = undefined; if (outcome !== "pending") waiter.late?.(outcome); if (outcome !== "pending" || !waiter.late) this.waiters.delete(record); } open(target, text) { const hash = promptHash(text); return this.records.find(record => record.hash === hash && this.samePane(record, target) && (record.state === "typed" || record.state === "queued")); } /** A message Phren typed into `target`'s pane that no conversation there has submitted yet. */ pending(target, text) { return !!this.open(target, text); } /** Wait again for a message `expect` reported pending, after a second Enter. */ awaitLate(target, text, waitMs = 2_500) { const record = this.open(target, text); if (!record) return Promise.resolve("pending"); return new Promise(resolve => { const waiter = this.waiters.get(record) ?? {}; const timer = setTimeout(() => { waiter.late = undefined; if (!waiter.resolve) this.waiters.delete(record); resolve("pending"); }, waitMs); timer.unref?.(); waiter.late = outcome => { clearTimeout(timer); waiter.late = undefined; resolve(outcome); }; this.waiters.set(record, waiter); }); } find(id, target) { const record = this.byId(id); return record && this.samePane(record, target) && this.now() - record.at <= DELIVERY_RETENTION_MS ? record : undefined; } /** The Hook answered `id` as typed and held by the agent. */ queue(id, target) { const record = this.find(id, target); if (record?.state === "typed") { record.state = "queued"; this.save(); } } /** Delivered without a hook: an app-server turn or a served OpenCode prompt. */ delivered(id, target, text) { if (!id) return; this.records = this.records.filter(record => record.id !== id); this.records.push({ id, hash: promptHash(text), server: target.server, pane: target.pane, source: target.source, session: target.session, state: "delivered", at: this.now(), settledAt: this.now() }); this.save(); } /** The message did not reach the agent, and why. It stays matchable, so a * late submission still turns it delivered. */ fail(id, target, reason) { const record = id ? this.find(id, target) : undefined; if (!record || record.state === "delivered") return; record.state = "failed"; record.reason = reason.slice(0, 500); record.settledAt = this.now(); this.save(); } /** The pane's Codex server moved to another thread (/new, /resume): * input still pending on the old one stays there, out of the pane's view. * Fails loudly; a late UserPromptSubmit from the old thread still settles it. */ threadChanged(previous) { let changed = false; for (const record of this.records) if ((record.state === "typed" || record.state === "queued") && this.samePane(record, previous) && record.session === previous.session) { record.state = "failed"; record.settledAt = this.now(); changed = true; record.reason = "The pane moved to another Codex conversation before the agent took the message."; } if (changed) this.save(); } /** False when `id` names another message: other words or another pane. */ sameMessage(id, target, text) { const record = this.byId(id); return !record || (this.samePane(record, target) && record.hash === promptHash(text)); } /** What became of phone message `id` in `target`'s pane. `unknown` while the * Hook has not answered it, for another pane, or past retention. */ status(id, target) { const record = this.find(id, target); if (!record || record.state === "typed") return { state: "unknown" }; return { state: record.state, ...(record.reason && record.state === "failed" ? { reason: record.reason } : {}), session: record.session }; } /** Every known phone message in `target`'s pane, for its conversation's stream. */ forPane(target) { return this.records.filter(record => record.id && this.samePane(record, target)).map(record => ({ deliveryId: record.id, ...this.status(record.id, target) })) .filter(item => item.state !== "unknown"); } /** Conversation `target` just submitted `prompt`. Its own record wins, then * the oldest open one in the same pane: the agent kept the text across a * conversation change, and the conversation now in the pane is where it * went. A prompt nothing matches was typed locally. Never refused. * For Claude the transcript is consulted first and the submission claims * the earliest unclaimed user row with these words, through the same * `settle` and the same table of claimed entries the idle check uses: a * callback for a row already claimed (the idle check settled it, the hook * came late) settles nothing more. */ async submitted(target, prompt) { const hash = promptHash(prompt); this.hookPanes.add(this.paneKey(target)); const pick = () => { const open = this.records.filter(record => record.hash === hash && this.samePane(record, target) && record.state !== "delivered"); return open.find(entry => entry.session === target.session) ?? open[0]; }; let record = pick(); if (!record) return; let entry; if (target.source === "claude") { // A hook settles a message only by claiming a row for it: rows all claimed by earlier messages is a // callback for a turn already accounted for. No row yet (not written or not readable, or a transcript // that cannot be read) settles nothing now: the claim waits on the record for its row to appear. let rows; try { rows = (await this.landed({ source: target.source, session: target.session, hash, at: record.at })).filter(row => row.hook); } catch { rows = undefined; } record = pick(); if (!record) return; // Claims are per conversation: another conversation's rows can sit at the same byte offset. const claimed = new Set(this.records.filter(other => other.hash === hash && this.samePane(other, target) && other.session === target.session && other.entry !== undefined).map(other => other.entry)); // A transcript that cannot be read settles on the hook's word, unless a message with these words already // claimed a row in this conversation: the callback may be that message's, late, so it waits for a row. if (!rows && !claimed.size) { this.settle(record, undefined, target.session); this.save(); return; } // The claim is looked for in the conversation that made it (it may have replaced the one the message was typed for). if (!rows?.length) { record.hookClaim = true; record.session = target.session; return; } const free = rows.filter(row => !claimed.has(row.offset)).sort((a, b) => a.offset - b.offset)[0]; if (!free) return; entry = free.offset; } this.settle(record, entry, target.session); this.save(); } /** Marks `record` delivered, with the transcript entry that settled it when known. */ settle(record, entry, session) { record.state = "delivered"; record.settledAt = this.now(); delete record.reason; delete record.hookClaim; delete record.seenRow; if (session) record.session = session; if (entry !== undefined) record.entry = entry; this.release(record, "delivered"); } /** Whether the transcript shows the agent took `record`'s message, claiming the entry it settled with. * An entry settles one message: the table of records' `entry` offsets (per conversation) is the only * state, so neither this path nor the hook's can settle two messages with one turn. A user row is a * turn start whose UserPromptSubmit hook settles the message itself, so while the agent works only a * mid-turn queue removal (no hook fires for it) settles from the transcript; once the agent is idle * (`working` false) a user row settles too, for a hook that never came. Of several identical open * messages the oldest take the entries. `pass` carries the entries still unclaimed within one observe. */ async taken(record, pass, working = false) { // Only Claude's transcript says which entry a message settled with; Codex keeps the any-match check. if (record.source !== "claude") return (await this.landed(record).catch(() => [])).length > 0; const same = (other) => other.hash === record.hash && other.server === record.server && other.pane === record.pane && other.session === record.session; const open = this.records.filter(other => same(other) && PromptDeliveries.claimable(other)).sort((a, b) => a.at - b.at); if (open[0] !== record) return false; const key = [record.hash, record.server, record.pane, record.session, working].join("|"); let free = pass.get(key); if (!free) { const found = (await this.landed(record).catch(() => [])).sort((a, b) => a.offset - b.offset); const used = new Set(this.records.filter(other => same(other) && other.entry !== undefined).map(other => other.entry)); // A message the agent's hook settled took a user row without recording it (a transcript it could not read): // the oldest user rows are theirs, whether the agent works or not. let hooked = this.records.filter(other => same(other) && other.state === "delivered" && other.entry === undefined && (other.settledAt ?? 0) >= record.at - ENTRY_SLACK_MS).length; const unclaimed = found.filter(entry => !used.has(entry.offset)).filter(entry => !(entry.hook && hooked-- > 0)); if (working) { // A user row is its hook's to settle, unless that hook already came (before the row could be read), or // the pane sends no callbacks at all: then a row two checks in a row saw unclaimed settles it. const row = unclaimed.find(entry => entry.hook); const hookless = !this.hookPanes.has(this.paneKey(record)); const own = row && (record.hookClaim || (hookless && record.seenRow === row.offset)); if (row && hookless && !own) record.seenRow = row.offset; free = unclaimed.filter(entry => !entry.hook || (own && entry === row)); } else free = unclaimed; } // The record may have settled (its hook claimed a row) while the transcript was read. if (!PromptDeliveries.claimable(record)) return false; const entry = free.shift(); pass.set(key, free); if (entry === undefined) return false; record.entry = entry.offset; return true; } /** A turn in `target`'s pane ended: the agent now takes held input or never will. */ stopped(target) { const at = this.now(); this.hookPanes.add(this.paneKey(target)); let marked = false; for (const record of this.records) if (this.samePane(record, target) && (record.state === "typed" || record.state === "queued") && record.at < at) { record.stoppedAt = at; marked = true; } // Persisted: after a restart an idle pane's message is still failed or settled by its Stop. if (marked) this.save(); } /** Called on the Hook's activity tick with a server's panes. A message * still held fails loudly when its pane closed, its agent restarted, or the * agent ended a turn and stayed idle without taking it (unless its * transcript shows it after all). */ async observe(server, panes) { const now = this.now(); let changed = false; const pass = new Map(); for (const record of this.records) { if (record.server !== server) continue; // A failed message whose own hook came late, before its row: settled when the row appears. if (record.state === "failed" && record.hookClaim) { if (record.source === "claude" && (record.checkedAt === undefined || now - record.checkedAt >= LANDED_CHECK_MS)) { record.checkedAt = now; if (await this.taken(record, pass, true)) { this.settle(record); changed = true; } } continue; } if (record.state !== "typed" && record.state !== "queued") continue; const pane = panes.find(entry => entry.pane_id === record.pane); if (!pane || (record.terminal && pane.terminal_id !== record.terminal) || (pane.agent && pane.agent !== record.source)) { if (await this.taken(record, pass)) { this.settle(record); changed = true; continue; } if (!PromptDeliveries.open(record)) continue; record.state = "failed"; record.settledAt = now; changed = true; record.reason = pane ? "The agent in this pane restarted before it took the message." : "The agent's pane closed before it took the message."; continue; } if (!["idle", "done"].includes(String(pane.agent_status))) { record.idleSince = undefined; // A message the agent took into its running turn (Claude's mid-turn steer) never reaches // its UserPromptSubmit hook; its transcript is the only place that says so. if (record.source === "claude" && (record.checkedAt === undefined || now - record.checkedAt >= LANDED_CHECK_MS)) { record.checkedAt = now; if (await this.taken(record, pass, true)) { this.settle(record); changed = true; } } continue; } record.idleSince ??= now; if (record.stoppedAt === undefined || now - Math.max(record.idleSince, record.stoppedAt) < DELIVERY_GIVE_UP_MS) continue; if (await this.taken(record, pass)) { this.settle(record); changed = true; continue; } // Settled delivered by its hook while the transcript was read: never downgraded. if (!PromptDeliveries.open(record)) continue; record.state = "failed"; record.settledAt = now; changed = true; record.reason = "The agent finished its turn without taking the message."; } if (changed) this.save(); } } /** The byte offsets of the user rows with these words that reached the conversation's transcript * after the message was typed: the agent took it without its hook saying so * (a mid-turn steer). Reads at most the last 512 KiB. */ /** The byte offsets (in the file, `start` being the buffer's own offset) of the rows in `buffer` * whose user text has `record`'s words, newest first. One offset per row. */ export function matchingOffsets(buffer, start, record) { const entries = []; const lines = buffer.toString("utf8").split("\n"); let offset = start + buffer.length; for (let index = lines.length - 1; index >= 0; index--) { const line = lines[index]; offset -= Buffer.byteLength(line) + (index < lines.length - 1 ? 1 : 0); let row; try { row = object(JSON.parse(line)); } catch { continue; } const stamp = Date.parse(String(row.timestamp ?? "")); if (Number.isFinite(stamp) && stamp < record.at - 5_000) break; // One entry per row, even when its content repeats the words. if (userTexts(row).some(text => promptHash(text) === record.hash)) entries.push({ offset, hook: row.type === "user" }); } return entries; } async function transcriptEntries(record) { const file = await open(await transcriptPath(record.source, record.session), "r"); try { const size = (await file.stat()).size, start = Math.max(0, size - 524_288), buffer = Buffer.alloc(size - start); await file.read(buffer, 0, buffer.length, start); return matchingOffsets(buffer, start, record); } finally { await file.close(); } } /** A transcript row's user-typed text: Claude's user rows, Codex's * user_message events and user response items. */ export function userTexts(row) { const texts = []; const blocks = (content) => { if (typeof content === "string") texts.push(content); else for (const block of objects(content)) if (typeof block.text === "string") texts.push(block.text); }; if (row.type === "user" && !row.isMeta) blocks(object(row.message).content); // Claude Code takes a message typed mid-turn out of its queue into the running turn: // `enqueue` when typed, then `remove` (not `dequeue`, which starts a turn of its own) with the same words. // A `remove` also drops input without the agent seeing it (reason "cleared_on_cancel"): only these reasons mean it was taken. if (row.type === "queue-operation" && row.operation === "remove" && typeof row.content === "string" && (row.reason === "absorbed_mid_turn" || row.reason === "delivered_to_agent")) texts.push(row.content); const payload = object(row.payload); if (payload.type === "user_message" && typeof payload.message === "string") texts.push(payload.message); if (payload.type === "message" && payload.role === "user") blocks(payload.content); return texts; }