import type { TextContent, ToolCall } from "@earendil-works/pi-ai"; import type { AgentMessage } from "@earendil-works/pi-agent-core"; import type { SessionEntry } from "@earendil-works/pi-coding-agent"; import type { SessionReader } from "../session-reader.js"; import { isWindowMarker, rootWindowId } from "../context/context-window.js"; import { HISTORY_PREVIEW_CHARS } from "../tool-output.js"; export type HistoryItem = { seq: number; windowId: string; role: "user" | "assistant" | "tool_call" | "tool" | "system" | "developer"; content: string; createdAt: string | undefined; toolName?: string; /** Internal pairing key; never serialized. */ toolCallId?: string; /** Arguments for a standalone bash execution, already serialized as JSON. */ toolArgs?: string; // bashExecution only: the persisted output was truncated and the full text lives on disk. outputTruncated?: boolean; fullOutputPath?: string; // toolResult only: the run reported an error. toolError?: boolean; }; export type HistoryRole = "user" | "assistant" | "tool" | "context"; export type HistoryEvent = { seq: number; windowId: string; role: HistoryRole; content: string; createdAt: string | undefined; tool?: string; toolError?: boolean; outputTruncated?: boolean; fullOutputPath?: string; }; export type HistoryWindow = { windowId: string; createdAt?: string; items: HistoryEvent[] }; export type HistoryProjection = { windows: HistoryWindow[]; highestSeq: number; branchSeqs: Set; resultAliases: Map }; export type HistoryFilter = { window_id?: string | null; roles?: HistoryRole[] | null }; function isTextContent(part: unknown): part is TextContent { return typeof part === "object" && part !== null && (part as TextContent).type === "text" && typeof (part as TextContent).text === "string"; } export function contentText(content: unknown): string { if (typeof content === "string") return content; return Array.isArray(content) ? content.filter(isTextContent).map((part) => part.text).join("\n") : ""; } function mapRole(role: AgentMessage["role"]): HistoryItem["role"] | undefined { if (role === "user" || role === "assistant") return role; if (role === "toolResult" || role === "bashExecution") return "tool"; if (role === "custom") return "developer"; if (role === "compactionSummary" || role === "branchSummary") return "system"; return undefined; } function isToolCall(part: unknown): part is ToolCall { return typeof part === "object" && part !== null && (part as { type?: unknown }).type === "toolCall"; } function mappedMessage(message: AgentMessage): boolean { return mapRole(message.role) !== undefined; } /** Seq allocation depends on entry kind and role, never on visible content. */ function sequenceCount(entry: SessionEntry): number { if (entry.type === "compaction" || entry.type === "branch_summary" || entry.type === "custom_message") return 1; if (entry.type !== "message" || !mappedMessage(entry.message)) return 0; if (entry.message.role !== "assistant" || !Array.isArray(entry.message.content)) return 1; return 1 + entry.message.content.filter(isToolCall).length; } function messageContent(message: AgentMessage): string { switch (message.role) { case "bashExecution": return message.output; case "branchSummary": case "compactionSummary": return message.summary; default: return contentText(message.content); } } function toolInfo(message: AgentMessage): Pick { if (message.role === "bashExecution") { // A truncated bash run is only half the record without the on-disk path: surface both. return { toolName: "bash", toolArgs: JSON.stringify({ command: message.command }), outputTruncated: message.truncated || undefined, fullOutputPath: message.truncated ? message.fullOutputPath : undefined }; } if (message.role !== "toolResult") return {}; return { toolName: message.toolName, toolCallId: message.toolCallId, toolError: message.isError === true ? true : undefined }; } function toolCallItems(windowId: string, entry: { id: string; timestamp?: string }, message: AgentMessage, entrySeq: number): HistoryItem[] { if (message.role !== "assistant" || !Array.isArray(message.content)) return []; const items: HistoryItem[] = []; let callIndex = 0; for (const part of message.content) { if (!isToolCall(part)) continue; const callSeq = entrySeq + callIndex + 1; items.push({ seq: callSeq, windowId, role: "tool_call", content: JSON.stringify(part.arguments) ?? "{}", createdAt: entry.timestamp, toolName: part.name, toolCallId: part.id, }); callIndex += 1; } return items; } function toolEvent(item: HistoryItem, result?: HistoryItem): HistoryEvent { const name = item.toolName ?? "unknown"; const args = item.role === "tool_call" ? item.content : item.toolArgs ?? "{}"; const output = result?.content ?? (item.role === "tool" ? item.content : undefined); return { seq: item.seq, windowId: item.windowId, role: "tool", content: `${name} ${args}${output === undefined ? "" : `\n--- output ---\n${output}`}`, createdAt: item.createdAt, tool: name, ...(result?.toolError || item.toolError ? { toolError: true } : {}), ...(result?.outputTruncated || item.outputTruncated ? { outputTruncated: true, fullOutputPath: result?.fullOutputPath ?? item.fullOutputPath } : {}), }; } /** Build stable internal addresses, then project the active branch into public events. */ export function historyFromSession(ctx: SessionReader): HistoryProjection { const seqByEntryId = new Map(); let nextSeq = 1; for (const entry of ctx.sessionManager.getEntries()) { const count = sequenceCount(entry); if (count === 0) continue; seqByEntryId.set(entry.id, nextSeq); nextSeq += count; } const highestSeq = nextSeq - 1; const sessionId = ctx.sessionManager.getSessionId(); let window: { windowId: string; createdAt?: string; items: HistoryItem[] } = { windowId: rootWindowId(sessionId), items: [] }; const rawWindows = [window]; const branchSeqs = new Set(); for (const entry of ctx.sessionManager.getBranch()) { if (isWindowMarker(entry)) { window = { windowId: entry.data.windowId, createdAt: entry.timestamp, items: [] }; rawWindows.push(window); continue; } const entrySeq = seqByEntryId.get(entry.id); if (entrySeq === undefined) continue; branchSeqs.add(entrySeq); if (entry.type === "compaction" || entry.type === "branch_summary") { window.items.push({ seq: entrySeq, windowId: window.windowId, role: "system", content: entry.summary, createdAt: entry.timestamp }); continue; } if (entry.type === "message") { const role = mapRole(entry.message.role); if (!role) continue; window.items.push({ seq: entrySeq, windowId: window.windowId, role, content: messageContent(entry.message), createdAt: entry.timestamp, ...toolInfo(entry.message), }); const calls = toolCallItems(window.windowId, entry, entry.message, entrySeq); for (const call of calls) branchSeqs.add(call.seq); window.items.push(...calls); continue; } if (entry.type === "custom_message") { window.items.push({ seq: entrySeq, windowId: window.windowId, role: "developer", content: contentText(entry.content), createdAt: entry.timestamp, }); } } const raw = rawWindows.flatMap((current) => current.items); const resultsById = new Map(); for (const item of raw) { if (item.role === "tool" && item.toolCallId && !resultsById.has(item.toolCallId)) resultsById.set(item.toolCallId, item); } const resultAliases = new Map(); const consumed = new Set(); const windows: HistoryWindow[] = rawWindows.map((current) => ({ windowId: current.windowId, ...(current.createdAt ? { createdAt: current.createdAt } : {}), items: current.items.flatMap((item): HistoryEvent[] => { if (consumed.has(item.seq)) return []; if (item.role === "tool_call") { const result = item.toolCallId ? resultsById.get(item.toolCallId) : undefined; const paired = result && result.seq > item.seq && !consumed.has(result.seq) ? result : undefined; if (paired) { consumed.add(paired.seq); resultAliases.set(paired.seq, item.seq); } return [toolEvent(item, paired)]; } if (item.role === "tool") return [toolEvent(item)]; return [{ seq: item.seq, windowId: item.windowId, role: item.role === "system" || item.role === "developer" ? "context" : item.role, content: item.content, createdAt: item.createdAt }]; }), })); return { windows, highestSeq, branchSeqs, resultAliases }; } export function visibleItem(item: HistoryEvent, maxChars = HISTORY_PREVIEW_CHARS) { const characters = Array.from(item.content); const truncated = characters.length > maxChars; return { seq: item.seq, window_id: item.windowId, role: item.role, created_at: item.createdAt ?? null, ...(item.tool ? { tool: item.tool } : {}), ...(item.outputTruncated ? { output_truncated: true, full_output_path: item.fullOutputPath ?? null } : {}), ...(item.toolError ? { tool_error: true } : {}), truncated, total_chars: characters.length, content: truncated ? characters.slice(0, maxChars).join("") : item.content, }; } export function allItems(projection: HistoryProjection): HistoryEvent[] { return projection.windows.flatMap((current) => current.items); } export function unknownWindowId(projection: HistoryProjection, params: HistoryFilter): { message: string; known: string[] } | undefined { if (typeof params.window_id !== "string") return undefined; const known = projection.windows.map((current) => current.windowId); return known.includes(params.window_id) ? undefined : { message: `unknown window_id "${params.window_id}"`, known }; } /** Default list is a conversation; explicit roles and search show the requested events. */ export function filteredItems(projection: HistoryProjection, params: HistoryFilter, mode: "list" | "search"): HistoryEvent[] { let items = allItems(projection); if (typeof params.window_id === "string") items = items.filter((item) => item.windowId === params.window_id); if (params.roles) items = items.filter((item) => params.roles!.includes(item.role)); if (!params.roles && mode === "list") items = items.filter((item) => (item.role === "user" || item.role === "assistant") && item.content !== ""); return items.sort((a, b) => a.seq - b.seq); }