import type { AgentMessage } from "@earendil-works/pi-agent-core"; import type { ToolResultMessage } from "@earendil-works/pi-ai"; import type { ExtensionContext } from "@earendil-works/pi-coding-agent"; import type { TodoObserverConfig } from "./config.ts"; type ContextConfig = TodoObserverConfig["context"]; function clipMiddle(text: string, maxChars: number): string { if (text.length <= maxChars) return text; const half = Math.max(1, Math.floor((maxChars - 80) / 2)); return `${text.slice(0, half)}\n… [${text.length - half * 2} characters omitted] …\n${text.slice(-half)}`; } function contentToText(content: unknown, config: ContextConfig): string { if (typeof content === "string") return clipMiddle(content, config.maxContentChars); if (!Array.isArray(content)) return ""; const parts: string[] = []; for (const block of content as Array>) { if (block.type === "text" && typeof block.text === "string") { parts.push(clipMiddle(block.text, config.maxContentChars)); } else if (block.type === "image") { parts.push("[image attached]"); } } return parts.join("\n"); } function summarizeToolArgs(name: string, args: unknown, config: ContextConfig): string { if (!args || typeof args !== "object") return "{}"; const input = args as Record; const compact: Record = {}; for (const [key, value] of Object.entries(input)) { if ((key === "content" || key === "newText" || key === "oldText") && typeof value === "string") { compact[key] = `[${value.length} characters]`; } else if (key === "edits" && Array.isArray(value)) { compact[key] = `[${value.length} edit block(s)]`; } else { compact[key] = value; } } return clipMiddle(`${name} ${JSON.stringify(compact)}`, config.maxToolArgsChars); } function assistantToText(message: AgentMessage, config: ContextConfig): string { if (message.role !== "assistant") return ""; const parts: string[] = []; for (const block of message.content as unknown as Array>) { if (block.type === "text" && typeof block.text === "string") { parts.push(clipMiddle(block.text, config.maxContentChars)); } else if (block.type === "toolCall" && typeof block.name === "string") { parts.push(`[tool call] ${summarizeToolArgs(block.name, block.arguments, config)}`); } // Deliberately omit hidden reasoning/thinking blocks. } return parts.join("\n"); } function toolResultToText(message: ToolResultMessage, config: ContextConfig): string { return `[tool result: ${message.toolName}${message.isError ? ", error" : ""}]\n${contentToText(message.content, config)}`; } export function mainMessageToText(message: AgentMessage, config: ContextConfig): string { switch (message.role) { case "user": return `[user]\n${contentToText(message.content, config)}`; case "assistant": return `[assistant]\n${assistantToText(message, config)}`; case "toolResult": return config.includeToolResults ? toolResultToText(message as ToolResultMessage, config) : ""; case "bashExecution": return `[user bash: ${message.command}]\n${clipMiddle(message.output, config.maxContentChars)}`; case "custom": return config.includeExtensionMessages && message.display ? `[extension message]\n${contentToText(message.content, config)}` : ""; case "branchSummary": return `[branch summary]\n${clipMiddle(message.summary, config.maxContentChars)}`; case "compactionSummary": return `[compaction summary]\n${clipMiddle(message.summary, config.maxContentChars)}`; default: return ""; } } export function formatTurnContext( userText: string | undefined, assistantMessage: AgentMessage, toolResults: ToolResultMessage[], config: ContextConfig, ): string { const parts: string[] = []; if (userText?.trim()) { parts.push(`[user input since previous turn]\n${clipMiddle(userText.trim(), config.maxContentChars)}`); } parts.push(mainMessageToText(assistantMessage, config)); if (config.includeToolResults) { for (const result of toolResults) parts.push(toolResultToText(result, config)); } return clipMiddle(parts.filter(Boolean).join("\n\n"), config.maxTurnChars); } function splitLargeEntry(text: string, maxChars: number): string[] { if (text.length <= maxChars) return [text]; const chunks: string[] = []; for (let offset = 0; offset < text.length; offset += maxChars) { const part = text.slice(offset, offset + maxChars); chunks.push(offset === 0 ? part : `[continued session entry]\n${part}`); } return chunks; } /** * Serialize the entire active branch in chronological, bounded chunks. * Every session message is represented; maxBootstrapChars is a per-request * chunk size rather than a total-session truncation limit. */ export function formatBranchContextChunks(ctx: ExtensionContext, config: ContextConfig): string[] { const chunks: string[] = []; let currentParts: string[] = []; let currentLength = 0; const maxChars = config.maxBootstrapChars; const flush = () => { if (currentParts.length === 0) return; chunks.push(currentParts.join("\n\n")); currentParts = []; currentLength = 0; }; const append = (text: string) => { for (const part of splitLargeEntry(text, maxChars)) { const separatorLength = currentParts.length > 0 ? 2 : 0; if (currentParts.length > 0 && currentLength + separatorLength + part.length > maxChars) flush(); currentParts.push(part); currentLength += (currentParts.length > 1 ? 2 : 0) + part.length; if (currentLength >= maxChars) flush(); } }; const branch = ctx.sessionManager.getBranch(); for (let index = 0; index < branch.length; index++) { const entry = branch[index]!; let rendered = ""; if (entry.type === "message") { rendered = mainMessageToText(entry.message, config); } else if (entry.type === "branch_summary") { rendered = `[branch summary]\n${clipMiddle(entry.summary, config.maxContentChars)}`; } else if (entry.type === "custom_message" && config.includeExtensionMessages && entry.display) { rendered = `[extension message: ${entry.customType}]\n${contentToText(entry.content, config)}`; } if (rendered) append(`[session entry ${index + 1}/${branch.length}]\n${rendered}`); } flush(); return chunks; } export function userMessageText(message: AgentMessage, config: ContextConfig): string | undefined { if (message.role !== "user") return undefined; return contentToText(message.content, config); }