import { type Context, type Model, normalizeContext, uuidv7 } from "@earendil-works/pi-ai"; import { buildSessionContext, type ExtensionContext, type SessionEntry, } from "@earendil-works/pi-coding-agent"; import type { CodexConversionConfig, VoiceContextModel, } from "../adapter/activation/config.ts"; import { createNativeVoiceContextSummary } from "./native-context.ts"; import { projectTreeCheckpointBranch } from "../context-management/tree-checkpoint.ts"; import { REALTIME_DELEGATION_MESSAGE_TYPE } from "./ui.ts"; const VOICE_CONTEXT_SYSTEM_PROMPT = `Summarize the current Pi conversation for a realtime voice assistant joining the same session. Preserve the user's goal, relevant preferences, decisions, current state, unresolved questions, and next step. Treat the conversation as history: do not continue its work or answer it. Return only the self-contained continuity summary.`; const VOICE_CONTEXT_REQUEST = "Create the voice continuity summary now."; const VOICE_STARTUP_CONTEXT_HEADER = "Earlier Pi conversation, possibly summarized. Use as background; do not repeat unless relevant."; const SUMMARIZABLE_CUSTOM_TYPES = new Set([ REALTIME_DELEGATION_MESSAGE_TYPE, "codex-realtime-voice-tail", ]); const SUMMARY_CACHE_LIMIT = 32; const summaryCache = new Map(); export interface RealtimeInitialMessageItem { type: "message"; role: "developer" | "user" | "assistant"; content: Array<{ type: "input_text" | "output_text"; text: string; }>; } export async function buildRealtimeInitialItems(args: { ctx: ExtensionContext; config: CodexConversionConfig; onSummaryGenerated?: ((summary: string) => void) | undefined; onSummaryStatus?: ((active: boolean) => void) | undefined; signal?: AbortSignal | undefined; sourceLeafId?: string | undefined; forceSummary?: boolean | undefined; }): Promise { const selected = args.config.voice.contextModel; const initialItems: RealtimeInitialMessageItem[] = []; if (selected) { const reasoning = args.config.voice.contextReasoning; const cacheKey = voiceContextCacheKey(args.ctx, selected, reasoning, args.sourceLeafId); let text = args.forceSummary ? undefined : summaryCache.get(cacheKey); if (!text) { args.onSummaryStatus?.(true); try { const generated = await createVoiceContextSummary( args.ctx, selected, reasoning, args.signal, args.sourceLeafId, ); if (generated) { text = generated; if (cacheKey === voiceContextCacheKey(args.ctx, selected, reasoning, args.sourceLeafId)) { summaryCache.set(cacheKey, text); while (summaryCache.size > SUMMARY_CACHE_LIMIT) summaryCache.delete(summaryCache.keys().next().value!); } args.onSummaryGenerated?.(text); } } finally { args.onSummaryStatus?.(false); } } if (text) { initialItems.push({ type: "message", role: "developer", content: [{ type: "input_text", text: renderVoiceStartupContext(text) }], }); } } return initialItems.length > 0 ? initialItems : undefined; } export function renderVoiceStartupContext(summary: string): string { return `${VOICE_STARTUP_CONTEXT_HEADER}\n\n${summary}\n`; } async function createVoiceContextSummary( ctx: ExtensionContext, selected: VoiceContextModel, reasoning: CodexConversionConfig["voice"]["contextReasoning"], signal?: AbortSignal, sourceLeafId?: string, ): Promise { const entries = projectTreeCheckpointBranch( ctx.sessionManager.getBranch(sourceLeafId), ctx.sessionManager.getEntries(), ); if (latestCompactionIsOpaque(entries)) return requireSummary( await createNativeVoiceContextSummary({ ctx, entries, model: { ...selected, reasoning }, systemPrompt: VOICE_CONTEXT_SYSTEM_PROMPT, request: VOICE_CONTEXT_REQUEST, ...(signal ? { signal } : {}), }), ); const messages = buildSessionContext([...entries]).messages; const conversation = serializeVoiceConversation(messages); if (!conversation) return undefined; const model = resolveSelectedModel(ctx, selected); return requireSummary( await completeWithSelectedModel( ctx, model, { systemPrompt: VOICE_CONTEXT_SYSTEM_PROMPT, messages: [ { role: "user", content: [ { type: "text", text: `## Conversation History\n\n${conversation}\n\n${VOICE_CONTEXT_REQUEST}`, }, ], timestamp: Date.now(), }, ], }, reasoning, signal, ), ); } function serializeVoiceConversation( messages: ReturnType["messages"], ): string { const turns: string[] = []; for (const message of messages) { if (message.role === "user") { appendTurn(turns, "User", textContent(message.content)); continue; } if (message.role === "assistant") { if (message.stopReason !== "toolUse") appendTurn(turns, "Assistant", textContent(message.content)); continue; } if (message.role === "compactionSummary") { appendTurn(turns, "Conversation summary", message.summary); continue; } if (message.role === "branchSummary") { appendTurn(turns, "Branch summary", message.summary); continue; } if ( message.role === "custom" && SUMMARIZABLE_CUSTOM_TYPES.has(message.customType) ) appendTurn(turns, "Realtime voice", textContent(message.content)); } return turns.join("\n\n"); } function textContent(content: unknown): string { if (typeof content === "string") return content.trim(); if (!Array.isArray(content)) return ""; return content .filter( (part): part is { type: "text"; text: string } => !!part && typeof part === "object" && "type" in part && part.type === "text" && "text" in part && typeof part.text === "string", ) .map((part) => part.text) .join("\n") .trim(); } function appendTurn(turns: string[], label: string, text: string): void { if (text) turns.push(`[${label}]: ${text}`); } function resolveSelectedModel( ctx: ExtensionContext, selected: VoiceContextModel, ): Model { const model = ctx.modelRegistry.find(selected.provider, selected.modelId); if (!model) throw new Error( `Voice context model is unavailable: ${selected.provider}/${selected.modelId}`, ); return model; } async function completeWithSelectedModel( ctx: ExtensionContext, model: Model, context: Context, reasoning: CodexConversionConfig["voice"]["contextReasoning"], signal?: AbortSignal, ): Promise { const provider = ctx.modelRegistry.getProvider(model.provider); if (!provider) throw new Error(`Voice context provider is unavailable: ${model.provider}`); const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model); if (!auth.ok) throw new Error(auth.error); const requestModel = auth.baseUrl ? { ...model, baseUrl: auth.baseUrl } : model; let completed: | { content: Array<{ type: string; text?: string }> } | undefined; for await (const event of provider.streamSimple(requestModel, normalizeContext(context), { ...(auth.apiKey ? { apiKey: auth.apiKey } : {}), ...(auth.headers ? { headers: auth.headers } : {}), ...(auth.env ? { env: auth.env } : {}), ...(signal ? { signal } : {}), maxTokens: requestModel.maxTokens, cacheRetention: "none", sessionId: uuidv7(), ...(requestModel.reasoning && reasoning !== "off" ? { reasoning } : {}), })) { if (event.type === "done") completed = event.message; if (event.type === "error") throw new Error(event.error.errorMessage || "Voice context model failed"); } return ( completed?.content .filter((part) => part.type === "text" && typeof part.text === "string") .map((part) => part.text) .join("\n") ?? "" ); } function latestCompactionIsOpaque(entries: readonly SessionEntry[]): boolean { const latest = entries .findLast((entry) => entry.type === "compaction"); if (!latest || latest.type !== "compaction") return false; const details = latest.details; return ( !!details && typeof details === "object" && "strategy" in details && (details.strategy === "openai-responses-compaction-v2" || details.strategy === "openai-native-compact-v1") ); } function requireSummary(value: string): string { const summary = value.trim(); if (!summary) throw new Error("Voice context model returned an empty summary"); return summary; } function voiceContextCacheKey( ctx: ExtensionContext, model: VoiceContextModel, reasoning: CodexConversionConfig["voice"]["contextReasoning"], sourceLeafId?: string, ): string { const boundary = ctx.sessionManager .getBranch(sourceLeafId) .findLast( (entry) => entry.type === "message" || entry.type === "compaction" || entry.type === "branch_summary" || (entry.type === "custom_message" && SUMMARIZABLE_CUSTOM_TYPES.has(entry.customType)), )?.id; return `${ctx.sessionManager.getSessionId()}:${boundary ?? "empty"}:${model.provider}/${model.modelId}:${reasoning}`; }