/** * Concise markdown transcript serializer for `history://` URLs. * * Unlike `session-dump-format.ts` (verbose `/dump` export), this emits a * compressed transcript: full user/assistant/developer text, tool call + * result pairs collapsed to single lines, thinking elided, custom messages * as one-liners. No system prompt, no tool catalog, no config sections. */ import type { AgentMessage } from "@earendil-works/pi-agent-core"; import type { AssistantMessage, ImageContent, TextContent, ToolResultMessage } from "@earendil-works/pi-ai"; import type { BashExecutionMessage, BranchSummaryMessage, CompactionSummaryMessage, CustomMessage, FileMentionMessage, HookMessage, PythonExecutionMessage, } from "./messages"; import type { PullTranscriptDisplayItem } from "./types"; const INTENT_FIELD = "i"; export interface HistoryFormatOptions { /** Optional H1 prepended to the transcript. */ title?: string; /** Render assistant thinking blocks (default: elided). */ includeThinking?: boolean; /** Render tool intent comment before tool call lines. */ includeToolIntent?: boolean; /** Render watched-session roles as inline `**agent**:` / `**user**:` labels (collapsing consecutive same-role messages) instead of `## ` headings, so a primary transcript embedded inside an advisor turn stays visually distinct. */ watchedRoles?: boolean; /** * Expand the primary agent's injected constraint context — plan mode's rules * (`plan-mode-context`) and the approved plan it implements * (`plan-mode-reference`) — verbatim instead of as a truncated one-liner, * wrapped in a `` tag so a reviewer reads it as the primary's * instructions, not its own. The advisor sets this: a truncated rule (plan * mode's "NEVER create files … except the plan file") makes it raise false * blockers. See {@link PRIMARY_CONTEXT_CUSTOM_TYPES}. Other custom messages * still collapse to a one-liner. */ expandPrimaryContext?: boolean; /** * Append the full unified diff (from a tool result's `details.diff`) below * edit/apply_patch tool lines, instead of just the path. The advisor sets * this so it sees what changed without re-reading the file. */ expandEditDiffs?: boolean; /** Collect compact user, agent, and merged tool rows for the Advisor Overlay Pull block. */ displayItems?: PullTranscriptDisplayItem[]; } /** Max length of the primary-arg summary inside `→ tool(...)` lines. */ const PRIMARY_ARG_MAX = 120; /** Per-tool preference order for the most informative scalar argument. */ const PRIMARY_ARG_KEYS = [ "path", "file_path", "filePath", "command", "cmd", "pattern", "url", "query", "prompt", "assignment", "message", "op", "name", "id", ] as const; /** Collapse whitespace runs and truncate to `max` chars with an ellipsis. */ function oneLine(text: string, max = PRIMARY_ARG_MAX): string { const flat = text.replace(/\s+/g, " ").trim(); return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat; } /** Join the text blocks of a string-or-blocks content field. Images become `[image]`. */ function contentToText(content: string | readonly (TextContent | ImageContent)[]): string { if (typeof content === "string") return content; const parts: string[] = []; for (const block of content) { if (block.type === "text") parts.push(block.text); else parts.push("[image]"); } return parts.join("\n"); } function lineCount(text: string): number { if (!text) return 0; return text.split("\n").length; } export function escapeXmlText(input: string): string { return input.replace(/[<>&'"]/g, (char) => { switch (char) { case "<": return "<"; case ">": return ">"; case "&": return "&"; case "'": return "'"; case '"': return """; default: return char; } }); } function primaryArgValue(value: unknown): string { if (typeof value === "string" && value.length > 0) return value; if (Array.isArray(value) && value.length > 0 && value.every((v) => typeof v === "string")) { return value.join(", "); } return ""; } /** Pick the most informative scalar argument of a tool call. */ function primaryArg(name: string, args: Record | undefined): string { if (!args || typeof args !== "object") return ""; // Advisor advice is the most informative summary; preserve kind too. if (name === "advise") { const advice = typeof args.advice === "string" ? args.advice : ""; const kind = typeof args.kind === "string" ? args.kind : ""; if (advice && kind) return oneLine(`${kind}: ${advice}`); if (advice) return oneLine(advice); if (kind) return oneLine(kind); return "{}"; } if (name === "grep") { const pattern = primaryArgValue(args.pattern); const paths = primaryArgValue(args.path) || primaryArgValue(args.paths); if (pattern && paths) return oneLine(`${pattern} @ ${paths}`); if (pattern) return oneLine(pattern); if (paths) return oneLine(paths); } if (name === "glob") { const paths = primaryArgValue(args.path) || primaryArgValue(args.paths); if (paths) return oneLine(paths); } if (name === "ast_grep") { const pattern = primaryArgValue(args.pat); if (pattern) return oneLine(pattern); } for (const key of PRIMARY_ARG_KEYS) { const value = args[key]; const summary = primaryArgValue(value); if (summary) return oneLine(summary); } // Fallback: first non-intent string arg, then a compact JSON of the args. const rest: Record = {}; let restCount = 0; for (const key in args) { if (key === INTENT_FIELD) continue; const value = args[key]; if (typeof value === "string" && value.length > 0) return oneLine(value); rest[key] = value; restCount++; } if (restCount === 0) return "{}"; try { return oneLine(JSON.stringify(rest)); } catch { return ""; } } /** * Wrap a diff body in a backtick fence sized to outlast the longest backtick * run inside it, so a diff that touches markdown (triple backticks) can't break * out of the fence. Info string `diff` for syntax highlighting. */ function fenceDiff(diff: string): string { const longest = diff.match(/`+/g)?.reduce((m, run) => Math.max(m, run.length), 0) ?? 0; const fence = "`".repeat(Math.max(3, longest + 1)); return `${fence}diff\n${diff}\n${fence}`; } /** One line per tool call: `→ read(src/foo.ts:50-80) ⇒ ok · 31 lines`. */ function toolCallLine( name: string, args: Record | undefined, result: ToolResultMessage | undefined, includeToolIntent?: boolean, expandEditDiffs?: boolean, ): string { const head = `→ ${name}(${primaryArg(name, args)})`; let base: string; if (!result) { base = `${head} ⇒ pending`; } else { const text = contentToText(result.content); const lines = lineCount(text); const count = `${lines} ${lines === 1 ? "line" : "lines"}`; if (result.isError) { const firstLine = oneLine(text.split("\n", 1)[0] ?? ""); base = firstLine ? `${head} ⇒ error · ${count} — ${firstLine}` : `${head} ⇒ error · ${count}`; } else { base = `${head} ⇒ ok · ${count}`; } } if (expandEditDiffs) { const diff = (result?.details as { diff?: unknown } | undefined)?.diff; if (typeof diff === "string" && diff.trim()) { base = `${base}\n${fenceDiff(diff)}`; } } const intent = includeToolIntent ? args?.[INTENT_FIELD] : undefined; if (typeof intent === "string" && intent.trim()) { const formattedIntent = oneLine(intent, 80); return `// ${formattedIntent}\n${base}`; } return base; } /** One line for a user-initiated `!`/`$` execution. */ function executionLine( kind: "bash" | "python", source: string, msg: BashExecutionMessage | PythonExecutionMessage, ): string { const status = msg.cancelled ? "cancelled" : msg.exitCode !== undefined && msg.exitCode !== 0 ? `error · exit ${msg.exitCode}` : "ok"; const lines = lineCount(msg.output); return `→ ${kind}! ${oneLine(source)} ⇒ ${status} · ${lines} ${lines === 1 ? "line" : "lines"}`; } /** * Hidden custom messages that inject the primary agent's operative *constraints* * — plan mode's rules and the approved plan it implements. A reviewer (the * advisor) must read these verbatim; truncating them hides load-bearing * exceptions (e.g. plan mode permits exactly one plan file). Every other custom * type stays a one-liner. * * Deliberately excludes `goal-mode-context`: its body carries live budget * counters (tokens/seconds used) that change every turn, so it can neither be * deduped against a prior copy nor expanded each turn without flooding the * reviewer — and its constraints don't drive the file-write misreads this * targets. */ export const PRIMARY_CONTEXT_CUSTOM_TYPES: ReadonlySet = new Set(["plan-mode-context", "plan-mode-reference"]); /** One-liner for custom/hook messages: `[irc] A → B: body…`. */ function customOneLiner(msg: CustomMessage | HookMessage): string { const details = (msg.details ?? {}) as Record; const str = (key: string): string => (typeof details[key] === "string" ? (details[key] as string) : ""); switch (msg.customType) { case "irc:incoming": return `[irc] ${str("from") || "?"} → me: ${oneLine(str("message"))}`; case "irc:relay": return `[irc] ${str("from") || "?"} → ${str("to") || "?"}: ${oneLine(str("body"))}`; case "async-result": { const jobs = Array.isArray(details.jobs) && details.jobs.length > 0 ? details.jobs : [details]; const labels = jobs .map((job) => { const j = (job ?? {}) as Record; return typeof j.label === "string" && j.label ? j.label : typeof j.jobId === "string" ? j.jobId : "job"; }) .join(", "); return `[async-result] ${oneLine(labels)}`; } default: return `[${msg.customType}] ${oneLine(contentToText(msg.content))}`; } } /** * Format a session's message array as a concise markdown transcript. * * `messages` is the session's in-memory message array (or the read-only * equivalent loaded from a session file) — the same shapes * `session-dump-format.ts` consumes. */ export function formatSessionHistoryMarkdown(messages: unknown[], opts?: HistoryFormatOptions): string { const typed = messages as AgentMessage[]; const lines: string[] = []; if (opts?.title) { lines.push(`# ${opts.title}`, ""); } // Index tool results by call id so each toolCall collapses to one line. const resultsByCallId = new Map(); for (const msg of typed) { if (msg.role === "toolResult") { resultsByCallId.set(msg.toolCallId, msg); } } const consumed = new Set(); // In watched mode, consecutive same-role messages collapse under one label // (the watched agent emits one assistant message per tool call, so otherwise // every call repeats `**agent**:`). Cleared whenever a // non-role-labeled line is emitted so the next turn re-labels. let lastWatchedLabel: string | undefined; for (const msg of typed) { switch (msg.role) { case "user": case "developer": { const text = contentToText(msg.content); if (!text.trim()) break; opts?.displayItems?.push({ kind: "user", text }); if (opts?.watchedRoles) { const label = `**${msg.role}**:`; if (lastWatchedLabel === label) { lines.push(text, ""); } else { lines.push(label, text, ""); lastWatchedLabel = label; } } else { lines.push(`## ${msg.role}`, "", text, ""); } break; } case "assistant": { const assistantMsg = msg as AssistantMessage; const body: string[] = []; for (const block of assistantMsg.content) { if (block.type === "text") { if (block.text.trim()) { body.push(block.text); opts?.displayItems?.push({ kind: "agent", text: block.text }); } } else if (block.type === "toolCall") { const result = resultsByCallId.get(block.id); if (result) consumed.add(block.id); body.push( toolCallLine(block.name, block.arguments, result, opts?.includeToolIntent, opts?.expandEditDiffs), ); opts?.displayItems?.push({ kind: "tool", text: toolCallLine(block.name, block.arguments, result, false, false), }); } else if (opts?.includeThinking && block.type === "thinking" && block.thinking.trim()) { body.push(`_thinking:_ ${block.thinking}`); } // Provider-hidden thinking has no readable text to render. } if (body.length === 0) break; if (opts?.watchedRoles) { const label = "**agent**:"; if (lastWatchedLabel === label) { lines.push(...body, ""); } else { lines.push(label, ...body, ""); lastWatchedLabel = label; } } else { lines.push("## assistant", "", ...body, ""); } break; } case "toolResult": { // Normally consumed by its toolCall; orphans (e.g. truncated history) get their own line. if (consumed.has(msg.toolCallId)) break; lines.push(toolCallLine(msg.toolName, undefined, msg, opts?.includeToolIntent, opts?.expandEditDiffs), ""); opts?.displayItems?.push({ kind: "tool", text: toolCallLine(msg.toolName, undefined, msg, false, false) }); lastWatchedLabel = undefined; break; } case "bashExecution": { const bashMsg = msg as BashExecutionMessage; if (bashMsg.excludeFromContext) break; const rendered = executionLine("bash", bashMsg.command, bashMsg); lines.push(rendered, ""); opts?.displayItems?.push({ kind: "tool", text: rendered }); lastWatchedLabel = undefined; break; } case "pythonExecution": { const pythonMsg = msg as PythonExecutionMessage; if (pythonMsg.excludeFromContext) break; const rendered = executionLine("python", pythonMsg.code, pythonMsg); lines.push(rendered, ""); opts?.displayItems?.push({ kind: "tool", text: rendered }); lastWatchedLabel = undefined; break; } case "custom": case "hookMessage": { const custom = msg as CustomMessage | HookMessage; if (opts?.expandPrimaryContext && PRIMARY_CONTEXT_CUSTOM_TYPES.has(custom.customType)) { const text = contentToText(custom.content).trim(); if (text) { lines.push(``, escapeXmlText(text), "", ""); opts?.displayItems?.push({ kind: "agent", text }); } } else { const rendered = customOneLiner(custom); lines.push(rendered, ""); opts?.displayItems?.push({ kind: "agent", text: rendered }); } lastWatchedLabel = undefined; break; } case "branchSummary": { const branchMsg = msg as BranchSummaryMessage; const rendered = `[branch] from ${branchMsg.fromId}: ${oneLine(branchMsg.summary)}`; lines.push(rendered, ""); opts?.displayItems?.push({ kind: "agent", text: rendered }); lastWatchedLabel = undefined; break; } case "compactionSummary": { const compactMsg = msg as CompactionSummaryMessage; const rendered = `[compaction] ${oneLine(compactMsg.summary)}`; lines.push(rendered, ""); opts?.displayItems?.push({ kind: "agent", text: rendered }); lastWatchedLabel = undefined; break; } case "fileMention": { const fileMsg = msg as FileMentionMessage; const rendered = `[file-mention] ${oneLine(fileMsg.files.map((f) => f.path).join(", "))}`; lines.push(rendered, ""); opts?.displayItems?.push({ kind: "agent", text: rendered }); lastWatchedLabel = undefined; break; } } } return `${lines.join("\n").trim()}\n`; }