import type { AssistantMessage, ImageContent, Message, MessageAttribution, ProviderPayload, TextContent, ToolResultMessage, } from "@oh-my-pi/pi-ai"; import { prompt } from "@oh-my-pi/pi-utils"; import type { AgentMessage } from "../types"; import branchSummaryContextPrompt from "./prompts/branch-summary-context.md" with { type: "text" }; import compactionSummaryContextPrompt from "./prompts/compaction-summary-context.md" with { type: "text" }; const COMPACTION_SUMMARY_TEMPLATE = compactionSummaryContextPrompt; const BRANCH_SUMMARY_TEMPLATE = branchSummaryContextPrompt; export interface CustomMessage { role: "custom"; customType: string; content: string | (TextContent | ImageContent)[]; display: boolean; details?: T; /** Who initiated this message for billing/attribution semantics. */ attribution?: MessageAttribution; timestamp: number; } /** Legacy hook message type (pre-extensions). Kept for session migration. */ export interface HookMessage { role: "hookMessage"; customType: string; content: string | (TextContent | ImageContent)[]; display: boolean; details?: T; /** Who initiated this message for billing/attribution semantics. */ attribution?: MessageAttribution; timestamp: number; } export interface BranchSummaryMessage { role: "branchSummary"; summary: string; fromId: string; timestamp: number; } export interface CompactionSummaryMessage { role: "compactionSummary"; summary: string; shortSummary?: string; tokensBefore: number; providerPayload?: ProviderPayload; /** Snapcompact frames archived by this compaction; appended as image blocks after the summary text. */ images?: ImageContent[]; timestamp: number; } export type CoreCompactionMessage = CustomMessage | HookMessage | BranchSummaryMessage | CompactionSummaryMessage; declare module "../types" { interface CustomAgentMessages { custom: CustomMessage; hookMessage: HookMessage; branchSummary: BranchSummaryMessage; compactionSummary: CompactionSummaryMessage; } } export type ConvertToLlm = (messages: AgentMessage[]) => Message[]; function getPrunedToolResultContent(message: ToolResultMessage): (TextContent | ImageContent)[] { if (message.prunedAt === undefined) { return message.content; } const textBlocks = message.content.filter((content): content is TextContent => content.type === "text"); const text = textBlocks.map(block => block.text).join("") || "[Output truncated]"; return [{ type: "text", text }]; } export function renderBranchSummaryContext(summary: string): string { return prompt.render(BRANCH_SUMMARY_TEMPLATE, { summary }); } export function renderCompactionSummaryContext(summary: string): string { return prompt.render(COMPACTION_SUMMARY_TEMPLATE, { summary }); } export function createBranchSummaryMessage(summary: string, fromId: string, timestamp: string): BranchSummaryMessage { return { role: "branchSummary", summary, fromId, timestamp: new Date(timestamp).getTime(), }; } export function createCompactionSummaryMessage( summary: string, tokensBefore: number, timestamp: string, shortSummary?: string, providerPayload?: ProviderPayload, images?: ImageContent[], ): CompactionSummaryMessage { return { role: "compactionSummary", summary, shortSummary, tokensBefore, providerPayload, images: images && images.length > 0 ? images : undefined, timestamp: new Date(timestamp).getTime(), }; } export function createCustomMessage( customType: string, content: string | (TextContent | ImageContent)[], display: boolean, details: unknown | undefined, timestamp: string, attribution?: MessageAttribution, ): CustomMessage { return { role: "custom", customType, content, display, details, attribution, timestamp: new Date(timestamp).getTime(), }; } function isCoreCompactionMessage(message: AgentMessage): message is AgentMessage & CoreCompactionMessage { return ( message.role === "custom" || message.role === "hookMessage" || message.role === "branchSummary" || message.role === "compactionSummary" ); } /** * Transform a single core-domain agent message to its LLM form; `undefined` * drops it from the provider request. * * Single source of truth for the core roles (user/developer/assistant/ * toolResult) and the compaction messages owned by this package. Embedders * with their own app messages (e.g. the coding agent) handle their custom * roles and delegate every core role here — duplicating these cases is how * snapcompact frames once silently fell off the provider request. */ export function convertMessageToLlm(message: AgentMessage): Message | undefined { if (isCoreCompactionMessage(message)) { switch (message.role) { case "custom": case "hookMessage": { const content = typeof message.content === "string" ? [{ type: "text" as const, text: message.content }] : message.content; return { role: "developer", content, attribution: message.attribution, timestamp: message.timestamp, }; } case "branchSummary": return { role: "user", content: [ { type: "text" as const, text: renderBranchSummaryContext(message.summary), }, ], attribution: "agent", timestamp: message.timestamp, }; case "compactionSummary": return { role: "user", content: [ { type: "text" as const, text: renderCompactionSummaryContext(message.summary), }, ...(message.images ?? []), ], attribution: "agent", providerPayload: message.providerPayload, timestamp: message.timestamp, }; } } switch (message.role) { case "user": return { ...message, attribution: message.attribution ?? "user" }; case "developer": return { ...message, attribution: message.attribution ?? "agent" }; case "assistant": return message as AssistantMessage; case "toolResult": return { ...message, content: getPrunedToolResultContent(message as ToolResultMessage), attribution: message.attribution ?? "agent", }; default: return undefined; } } /** * Default compaction-domain transformer. * * Embedders with their own app messages should pass a richer transformer through * `SummaryOptions.convertToLlm`; this default intentionally preserves only the * core LLM roles and the compaction messages owned by this package. */ export function defaultConvertToLlm(messages: AgentMessage[]): Message[] { return messages.map(convertMessageToLlm).filter(message => message !== undefined); }