import { convertToLlm, serializeConversation } from "@earendil-works/pi-coding-agent"; export interface AgentMessageLike { id?: string; role: string; content?: unknown; toolName?: string; toolCalls?: unknown; [_: string]: unknown; } export function serializeMessage(m: AgentMessageLike): string { try { // convertToLlm + serializeConversation handle user/assistant/toolResult // including thinking, tool calls, and tool results exactly like the // built-in compaction does. NOTE: tool results are truncated to ~2000 // chars by this path — fine for the candidate packet, NOT for storage. return serializeConversation(convertToLlm([m as never])); } catch { return fallbackText(m); } } /** * Full, untruncated text of a message extracted directly from its content * blocks. Used for the persistent store: the store must never lose content. */ export function fullText(m: AgentMessageLike): string { const parts: string[] = []; if (Array.isArray(m.content)) { for (const c of m.content as Array>) { if (typeof c?.text === "string") parts.push(c.text); else if (typeof c?.thinking === "string") parts.push(`[thinking] ${c.thinking}`); else if (c && typeof c === "object" && Object.keys(c).length > 0) parts.push(JSON.stringify(c).slice(0, 2000)); } } else if (typeof m.content === "string") { parts.push(m.content); } // Tool calls attached to assistant messages. if (Array.isArray(m.toolCalls)) { for (const tc of m.toolCalls as Array>) { parts.push(`[tool call] ${JSON.stringify(tc).slice(0, 2000)}`); } } const text = parts.join("\n"); return text || serializeMessage(m); } function fallbackText(m: AgentMessageLike): string { const label = m.role === "user" ? "User" : m.role === "assistant" ? "Assistant" : m.role === "toolResult" ? `Tool result (${m.toolName ?? "?"})` : `Message (${m.role})`; const parts: string[] = [label + ":"]; if (Array.isArray(m.content)) { for (const c of m.content as Array>) { if (typeof c?.text === "string") parts.push(c.text); else if (typeof c?.thinking === "string") parts.push(`[thinking] ${c.thinking}`); else parts.push(`[${c?.type ?? "block"}]`); } } else if (typeof m.content === "string") { parts.push(m.content); } return parts.join("\n"); } export function kindOf(m: AgentMessageLike): string { switch (m.role) { case "user": return "user"; case "assistant": return "assistant"; case "toolResult": return "tool_result"; default: return "other"; } } export function estimateTokens(text: string): number { return Math.max(1, Math.ceil(text.length / 4)); } export function truncate(text: string, maxChars: number): string { if (text.length <= maxChars) return text; return text.slice(0, maxChars) + `\n...[truncated ${text.length - maxChars} chars]`; } /** * Distributed sample for long tool output. First-only truncation regularly * misses results near the middle or end of files/logs. Storage still receives * fullText(); this affects only the bounded compaction-agent packet. */ export function sampleLongText(text: string, maxChars: number): string { if (text.length <= maxChars) return text; if (maxChars < 300) return truncate(text, maxChars); // Reserve part of the packet for windows around likely result-bearing words. // This is deliberately small and deterministic; it is not a replacement for // full storage or model judgment. const salientBudget = Math.floor(maxChars * 0.35); const salient: string[] = []; const pattern = /\b(fact|error|failed|failure|result|total|requirement|decision|version|expected|actual|todo|warning)\b/gi; let match: RegExpExecArray | null; let used = 0; while ((match = pattern.exec(text)) && used < salientBudget) { const start = Math.max(0, match.index - 140); const window = text.slice(start, Math.min(text.length, match.index + 260)); if (!salient.includes(window)) { salient.push(window); used += window.length; } } const salientText = salient.join("\n--- salient match ---\n").slice(0, salientBudget); const markerAllowance = 240 + salientText.length; const chunk = Math.max(60, Math.floor((maxChars - markerAllowance) / 3)); const middleStart = Math.max(chunk, Math.floor((text.length - chunk) / 2)); const endStart = Math.max(middleStart + chunk, text.length - chunk); const omitted = Math.max(0, text.length - chunk * 3 - salientText.length); return [ `[start of tool output]`, text.slice(0, chunk), salientText ? `\n[salient result windows]\n${salientText}` : "", `\n...[distributed sample; ${omitted} chars omitted]...\n`, `[middle of tool output]`, text.slice(middleStart, middleStart + chunk), `\n...[distributed sample]...\n`, `[end of tool output]`, text.slice(endStart), ].filter(Boolean).join("\n"); }