import type { JsonValue } from "@earendil-works/chord/json"; import type { AssistantMessage, ImageContent, ToolResultMessage } from "@earendil-works/pi-ai"; import type { AgentState, CompactionFailure, EntryRecord, InboxState, LiveState, NestedToolSlot, RunFailure, ToolSlot, TranscriptItem, TranscriptView, } from "@earendil-works/pi-durable"; type ArtifactState = { artifacts: ArtifactInfo[] }; import { AGENT_MESSAGE_ENTRY, type AgentMessage, AgentMessageSchema, peerMessage } from "../runtime/entries.ts"; import type { ArtifactInfo } from "../runtime/schemas.ts"; type ConversationPeer = { id: string; title: string }; import { compile } from "@earendil-works/pi-schema"; import { type AssistantCost, cacheHitRate } from "../runtime/conversation-stats.ts"; import { type MessageData, messageData } from "../runtime/input.ts"; import type { Author } from "../runtime/sender.ts"; import { SHELL_ENTRY, shellData } from "../runtime/shell.ts"; // Pure display model of a Durable view: tool calls are joined with their live // slot and result entry, so one card follows a call from request to result. export type ToolStatus = "pending" | "running" | "done" | "error" | "interrupted"; export type ToolView = { id: string; name: string; args: Record; status: ToolStatus; output: string; details?: JsonValue; images: ImageContent[]; /** Calls this call made to other tools (code mode's `tools.()`), in creation order; see `nestedCalls`. */ calls?: readonly NestedCall[]; /** The result's recorded execution time. */ durationMs?: number; }; /** One call a running tool made to another tool, as a status row. */ export type NestedCall = { id: string; name: string; /** The arguments, or their JSON text when a settled list cut it too short to parse. */ args: Record | string; status: ToolStatus; /** 0 for calls of the model's call, 1 for their own nested calls, and so on. */ depth: number; /** Running output, while running. */ output?: string; durationMs?: number; error?: string; }; export type Item = | { type: "user"; key: string; text: string; author?: Author; peer?: Pick; /** A subagent's message (its report, a question or a note), from the entry's data. */ agent?: Pick; images: ImageContent[]; files: string[]; /** The entry this item shows; absent for queued items. */ entryId?: number; /** A display-only entry shown as a person's message (it never reached the model). */ chat?: boolean; /** The message this one answers: shown as a quoted line that leads to it. */ replyTo?: ReplyQuote; /** A plugin's short note about the message, e.g. how much chat a mention passed on. */ note?: string; } | { type: "assistant"; key: string; /** The committed entry; absent while streaming. */ entryId?: number; message: AssistantMessage; tools: ToolView[]; streaming: boolean; } | { type: "failure"; key: string; text: string } | { type: "marker"; key: string; label: string; detail?: string } | { type: "tool"; key: string; tool: ToolView } /** Entries between two loaded ones are missing (`TranscriptStore.fillGap`); `loading` while they load. */ | { type: "gap"; key: string; gap: Gap; loading: boolean }; export { type AssistantCost, cacheHitRate }; type Gap = Extract; export type Transcript = { items: Item[]; /** More entries exist before the first item (`TranscriptStore.loadEarlier`). */ earlier: boolean; artifacts: ArtifactInfo[]; busy: boolean; working: boolean; retry?: { at: number; error: string }; compactions: { id: number; /** What started the compaction (Durable's `CompactionStatus.trigger`). */ trigger: "manual" | "threshold" | "overflow"; blocking: boolean; attempt: number; retry?: { at: number; error: string }; }[]; queued: { id: number; mode: string; text: string }[]; agent: AgentState; }; function text(content: string | readonly { type: string; text?: string }[]): string { return typeof content === "string" ? content : content .filter((part) => part.type === "text") .map((part) => part.text ?? "") .join("\n"); } function resultTool(result: ToolResultMessage, name = result.toolName): ToolView { return { id: result.toolCallId, name, args: {}, status: result.isError ? "error" : "done", output: text(result.content), details: result.details as JsonValue | undefined, images: result.content.filter((part) => part.type === "image"), ...(typeof result.durationMs === "number" ? { durationMs: result.durationMs } : {}), }; } function artifactInfo(value: unknown): ArtifactInfo | undefined { if (!value || typeof value !== "object" || Array.isArray(value)) return; const details = value as Record; if ( typeof details.name !== "string" || !/^[a-z0-9][a-z0-9_-]{0,63}$/.test(details.name) || typeof details.title !== "string" || !["html", "svg", "markdown", "text"].includes(String(details.type)) || typeof details.version !== "number" || !Number.isSafeInteger(details.version) || details.version <= 0 ) return; return details as ArtifactInfo; } const isAgentMessage = compile(AgentMessageSchema).check; /** * Display items of each entry, with what they were built from. Chord keeps unchanged entries (and documents' unchanged * parts) as the same objects, so while an entry, its tools' results and live slots and the busy flags stay the same, * its items are the same objects too, and the memoized components showing them skip rendering. A streaming delta then * re-renders the live message, not the whole transcript. */ const itemCache = new WeakMap(); const liveItems = new WeakMap(); /** The last nested calls this browser saw for each model-issued call, by call ID. */ const seenCalls = new Map(); const settledStatus = { ok: "done", error: "error", cancelled: "interrupted", running: "interrupted" } as const; /** Durable's error for a call a restart cut short (an unsafe tool is not run again): interrupted, not failed. */ const INTERRUPTED_ERROR = /^Tool \S+ was interrupted and may have partially run/; const interrupted = (error: unknown): boolean => typeof error === "string" && INTERRUPTED_ERROR.test(error); /** * The settled list of a result's details, code mode's `CodemodeToolDetails`: * `{ calls: [{ id, name, args (cut JSON), status: "ok" | "error" | "cancelled", durationMs?, error? }] }`. * Only the script's own calls are listed, so every row is at depth 0. */ function detailCalls(callId: string, details: unknown): readonly NestedCall[] | undefined { if (!details || typeof details !== "object" || !("calls" in details) || !Array.isArray(details.calls)) return; const calls: NestedCall[] = []; for (const call of details.calls as unknown[]) { if (!call || typeof call !== "object") return; const row = call as Record; if (typeof row.id !== "number" || typeof row.name !== "string" || typeof row.args !== "string") return; if (!Object.hasOwn(settledStatus, String(row.status))) return; let args: Record | string = row.args; try { const parsed: unknown = JSON.parse(row.args); if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) args = parsed as Record; } catch {} calls.push({ id: `${callId}/c${row.id}`, name: row.name, args, status: row.status === "error" && interrupted(row.error) ? "interrupted" : settledStatus[row.status as keyof typeof settledStatus], depth: 0, ...(typeof row.durationMs === "number" ? { durationMs: row.durationMs } : {}), ...(typeof row.error === "string" ? { error: row.error } : {}), }); } return calls; } /** * The nested calls of model-issued call `callId`, the one source of every nested-call row. While the call runs that is * `pi.live.nestedTools` (`slots`: this call's slots, parents before children); the commit appending its result removes * them. A settled call shows the final list of its result `details` (code mode's `calls`), else the last live list this * browser saw (lost on reload). */ function nestedCalls( callId: string, slots: readonly NestedToolSlot[] | undefined, settled: boolean, details?: unknown, ): readonly NestedCall[] | undefined { const final = detailCalls(callId, details); if (final) { seenCalls.delete(callId); return final; } if (slots?.length) { const depths = new Map(); const calls = slots.map((slot): NestedCall => { const depth = (depths.get(slot.parentCallId) ?? -1) + 1; depths.set(slot.callId, depth); const summary = slot.summary; return { id: slot.callId, // Code mode's internal tools (`pi.codemode.models.classify`) by their script name, as the settled list has them. name: slot.name.replace(/^pi\.codemode\./, ""), args: slot.arguments, // Done without a summary: the scheduler faulted or orphaned the call. status: slot.status === "done" ? summary && !summary.isError ? "done" : interrupted(summary?.error) ? "interrupted" : "error" : slot.status, depth, ...(slot.status === "running" && slot.output ? { output: slot.output } : {}), ...(summary?.durationMs === undefined ? {} : { durationMs: summary.durationMs }), ...(summary?.error ? { error: summary.error } : slot.status === "done" && !summary ? { error: "No result" } : {}), }; }); seenCalls.set(callId, calls); return calls; } const seen = seenCalls.get(callId); // A call that never finished before its caller settled was aborted with it. return settled ? seen?.map((call) => call.status === "running" || call.status === "pending" ? { ...call, status: "interrupted" } : call, ) : seen; } const compactionFailureItems = new WeakMap(); /** Null for a failure that shows nothing (`runFailureText`). */ const runFailureItems = new WeakMap(); const gapItems = new WeakMap(); /** A response's identity from its partial to its committed entry: the time the provider started it. */ function responseKey(message: AssistantMessage): string { return `response-${message.timestamp}`; } function cachedItems(entry: EntryRecord, inputs: readonly unknown[], build: () => Item[]): Item[] { const cached = itemCache.get(entry); if ( cached && cached.inputs.length === inputs.length && cached.inputs.every((input, index) => input === inputs[index]) ) return cached.items; const items = build(); itemCache.set(entry, { inputs, items }); return items; } /** A reply's quoted line: whose message it answers and the start of that message. */ export type ReplyQuote = { entryId: number; name: string; excerpt: string }; /** What a plugin adds to its entry kind's messages (see `TranscriptEntries` in plugins/chat/contract.ts). */ export type EntryDisplayOf = (kind: string, data: unknown) => { replyTo?: ReplyQuote; note?: string } | undefined; /** A `p2.shell` entry as a marker: `$ command`, and the output with how it ended. */ function shellItems(key: string, raw: unknown): Item[] { const data = shellData(raw); if (!data) return []; const fence = "`".repeat(Math.max(3, ...Array.from(data.output.matchAll(/`+/g), (match) => match[0].length + 1))); const status = data.cancelled ? "Cancelled" : data.error ? data.error : data.exitCode !== undefined && data.exitCode !== 0 ? `Exited with code ${data.exitCode}` : ""; const detail = [ data.output ? `${fence}\n${data.output}\n${fence}` : "(no output)", ...(data.truncated ? ["Earlier output was dropped."] : []), ...(status ? [status] : []), `Run by ${data.author.kind === "owner" ? "the owner" : `${data.author.name} (guest)`}${data.includeInContext ? "" : ", not shared with Pi"}.`, ].join("\n\n"); return [{ type: "marker", key, label: `$ ${data.command}`, detail }]; } /** What a run that ended without an answer shows (`pi.live.runFailure`); nothing for a Stop or a new context. */ function runFailureText(failure: RunFailure): string | undefined { const detail = failure.detail?.trim(); switch (failure.reason) { // The detail names the error, or the missing model ("Model faux/gone is not available"). case "model_error": case "no_model": return `The model could not answer this input.${detail ? ` ${detail}` : ""}`; case "faulted": return `The run failed${detail ? `: ${detail}` : "."}`; case "orphaned": return `The run could not continue${detail ? `: ${detail}` : "."}`; case "interrupted": return "The answer was interrupted: the server stopped while the model was answering."; // The detail says what was cut off how often ("The generation was interrupted 5 times in its request phase"). case "restart_limit": return `The run was stopped after repeated restarts.${detail ? ` ${detail}.` : ""}`; case "aborted": case "reset": return undefined; } } /** * The display items of a conversation's transcript (`TranscriptStore`): its entries in ID order with each gap, and the * live state of the view's documents. People's messages come from entry data (src/runtime/input.ts); `display` lets a * plugin add to its kinds' messages. `filling` names the gaps being loaded (`gapKey`). */ export function buildTranscript( view: TranscriptView, display?: EntryDisplayOf, filling: ReadonlySet = new Set(), ): Transcript { const live = view.docs["pi.live"] as unknown as LiveState | undefined; const compactionFailure = live?.compactionFailure; const compactionError = compactionFailure ? `Compaction failed: ${compactionFailure.error}` : undefined; const inbox = view.docs["pi.inbox"] as unknown as InboxState | undefined; const artifactState = view.docs["p2.artifacts"] as unknown as ArtifactState | undefined; const busy = Boolean(live?.run); const results = new Map(); let latest: EntryRecord | undefined; for (const item of view.items) { if (item.kind !== "entry") continue; const message = item.entry.model?.[0]; if (message?.role === "toolResult") results.set(message.toolCallId, message); else if (message?.role === "assistant") latest = item.entry; } const slots = new Map((live?.tools ?? []).map((slot) => [slot.callId, slot])); // Nested slots by the model-issued call they descend from; the list holds parents before their children. const nested = new Map(); const rootOf = new Map(); for (const slot of live?.nestedTools ?? []) { const root = rootOf.get(slot.parentCallId) ?? slot.parentCallId; rootOf.set(slot.callId, root); const group = nested.get(root); if (group) group.push(slot); else nested.set(root, [slot]); } const called = new Set(); /** Keys of responses already placed: a second response started in the same millisecond keys by its entry. */ const responseKeys = new Set(); const items: Item[] = []; /** A person's message: who wrote it and what, from its data; a plugin may add a reply quote or a note. */ const personItem = (entry: EntryRecord, person: MessageData, images: ImageContent[], chat: boolean): Item => ({ type: "user", key: String(entry.id), entryId: entry.id, images, author: person.author, text: person.text, files: person.attachments.map((file) => file.name), ...(chat ? { chat } : {}), ...display?.(entry.kind, entry.data), }); for (const transcriptItem of view.items) { if (transcriptItem.kind === "earlier") continue; if (transcriptItem.kind === "gap") { const loading = filling.has(`gap-${transcriptItem.after}`); let cached = gapItems.get(transcriptItem); if (cached?.loading !== loading) { cached = { loading, item: { type: "gap", key: `gap-${transcriptItem.after}`, gap: transcriptItem, loading }, }; gapItems.set(transcriptItem, cached); } items.push(cached.item); continue; } const entry = transcriptItem.entry; const message = entry.model?.[0]; const key = String(entry.id); if (entry.kind === "app.model-error") { // Notices earlier p2 versions wrote for inputs the model could not answer; `runFailure` replaced them. const data = entry.data; const detail = data && typeof data === "object" && !Array.isArray(data) ? data.detail : undefined; items.push( ...cachedItems(entry, [], () => [ { type: "failure", key, text: `The model could not answer this input.${typeof detail === "string" && detail.trim() ? ` ${detail}` : ""}`, }, ]), ); } else if (entry.kind === "pi.compaction") { items.push( ...cachedItems(entry, [], () => [ { type: "marker", key, label: "Context compacted", detail: message ? text(message.content) : "" }, ]), ); } else if (entry.kind === "pi.reset") { items.push( ...cachedItems(entry, [], () => [ { type: "marker", key, label: "New context", ...(message?.role === "user" ? { detail: text(message.content) } : {}), }, ]), ); } else if (entry.kind === SHELL_ENTRY) { // A user shell command (`!cmd`/`!!cmd`): read-only, its output in a fenced block. items.push(...cachedItems(entry, [], () => shellItems(key, entry.data))); } else if (message?.role === "user") { items.push( ...cachedItems(entry, [display], () => { const images = typeof message.content === "string" ? [] : message.content.filter((part) => part.type === "image"); // A person's input from its data. const person = messageData(entry.data); if (person) return [personItem(entry, person, images, false)]; if (entry.kind === AGENT_MESSAGE_ENTRY && isAgentMessage(entry.data)) { const { from, intent, text } = entry.data; return [{ type: "user", key, entryId: entry.id, images, agent: { from, intent }, text, files: [] }]; } // A peer session's message from its data, or a tool's plain input. const peer = peerMessage(entry.kind, entry.data); if (peer) return [ { type: "user", key, entryId: entry.id, images, peer: peer.from, text: peer.text, files: [] }, ]; return [{ type: "user", key, entryId: entry.id, images, text: text(message.content), files: [] }]; }), ); } else if (message?.role === "assistant") { // Keep empty abnormal responses visible; context filtering is not a display filter. Calls of the newest // response without a result or slot are pending while busy; older ones were interrupted. const current = entry === latest; const calls = message.content.flatMap((part) => (part.type === "toolCall" ? [part] : [])); for (const call of calls) called.add(call.id); // What this entry's card shows besides the entry: each call's result and live slot, and whether a call with // neither is pending or interrupted. Ending a run thus rebuilds only cards with unfinished calls. const inputs = [ current, ...calls.flatMap((call) => { const result = results.get(call.id); const slot = slots.get(call.id); return [result, slot, result || slot ? undefined : busy && current, ...(nested.get(call.id) ?? [])]; }), ]; // Keyed by the response's start time, which the streaming partial carries too: when the answer commits, // React keeps the component, so expanded thinking and tool cards stay open and the reading position holds. const [item] = cachedItems(entry, inputs, () => [ { ...assistantItem(responseKey(message), message, calls, current), entryId: entry.id }, ]); if (item) items.push(responseKeys.has(item.key) ? { ...item, key } : item); if (item) responseKeys.add(item.key); } else if (message?.role === "toolResult" && !called.has(message.toolCallId)) { items.push(...cachedItems(entry, [], () => [{ type: "tool", key, tool: resultTool(message) }])); } else if (!message) { items.push( ...cachedItems(entry, [display], () => { const person = messageData(entry.data); return person ? [personItem(entry, person, [], true)] : []; }), ); } } function assistantItem( key: string, message: AssistantMessage, calls: readonly { id: string; name: string; arguments: Record }[], current: boolean, ): Item { const tools = calls.map((part): ToolView => { const result = results.get(part.id); // Live slots belong to the current tool round; an unfinished older call is interrupted. const slot = slots.get(part.id); const calls = nestedCalls(part.id, nested.get(part.id), Boolean(result) || !current || !busy, result?.details); if (result) return { ...resultTool(result, part.name), args: part.arguments, ...(slot?.output && !text(result.content) ? { output: slot.output } : {}), ...(calls ? { calls } : {}), }; const status: ToolStatus = slot ? slot.status === "done" ? "error" : slot.status : busy && current ? "pending" : "interrupted"; return { id: part.id, name: part.name, args: part.arguments, status, output: slot?.output ?? "", details: slot?.details, images: [], ...(calls ? { calls } : {}), }; }); return { type: "assistant", key, message, tools, streaming: false }; } const partial = live?.generation?.message as unknown as AssistantMessage | undefined; if (partial) { let item = liveItems.get(partial); if (!item) { item = { type: "assistant", key: responseKey(partial), message: partial, tools: [], streaming: true }; liveItems.set(partial, item); } items.push(responseKeys.has(item.key) ? { ...item, key: "live" } : item); } if (compactionFailure) { let item = compactionFailureItems.get(compactionFailure); if (!item) { item = { type: "failure", key: `compaction-failure-${compactionFailure.taskId}`, text: `Compaction failed: ${compactionFailure.error}`, }; compactionFailureItems.set(compactionFailure, item); } // State-derived, not a retained entry: it persists while idle and disappears when Durable clears it. // Both the browser and p2 TUI render failure items in their existing error-notice style. items.push(item); } // Why the last run ended without an answer, until the next run starts; a blocking compaction failure is shown once. const runFailure = live?.runFailure; if (runFailure && !(compactionError !== undefined && runFailure.detail === compactionError)) { let item = runFailureItems.get(runFailure); if (item === undefined) { const text = runFailureText(runFailure); item = text === undefined ? null : { type: "failure", key: `run-failure-${runFailure.taskId}`, text }; runFailureItems.set(runFailure, item); } if (item) items.push(item); } return { items, earlier: view.items[0]?.kind === "earlier", artifacts: Array.isArray(artifactState?.artifacts) ? artifactState.artifacts.flatMap((artifact) => artifactInfo(artifact) ?? []).toReversed() : [], busy, // Keep one stable activity signal through thinking, text, tools and compaction. // It disappears only when the viewed conversation is actually idle. working: busy || Boolean(live?.compactions?.length), ...(live?.generation?.retry ? { retry: live.generation.retry } : {}), compactions: (live?.compactions ?? []).map((status) => ({ id: status.taskId, trigger: status.trigger, blocking: status.blocking, attempt: status.attempt, ...(status.retry ? { retry: status.retry } : {}), })), queued: (inbox?.items ?? []).flatMap((item) => item.mode === "write" ? [] : [ { id: item.id, mode: item.mode, text: messageData(item.entry?.data)?.text ?? (item.entry && peerMessage(item.entry.kind, item.entry.data)?.text) ?? text(item.content), }, ], ), agent: (view.docs["pi.agent"] ?? {}) as AgentState, }; }