import type { SessionEntry } from "@earendil-works/pi-coding-agent"; import type { ImageContent, TextContent } from "@earendil-works/pi-ai"; import { estimateTokens, stableStringify, toBoundedJson, truncateMiddle } from "./json.js"; const MESSAGE_BUDGET = 10_000; const TOOL_BUDGET = 10_000; const MESSAGE_ENTRY_CAP = 2_000; const TOOL_ENTRY_CAP = 1_000; const MAX_RECENT_NON_USER = 40; export type TranscriptKind = | "user" | "assistant" | "tool-call" | "tool-result" | "compaction-summary" | "branch-summary"; interface Evidence { number: number; kind: TranscriptKind; budget: "message" | "tool"; text: string; tokens: number; } export interface CompactTranscript { text: string; omitted: boolean; retainedEntries: number; } function contentText(content: string | (TextContent | ImageContent)[]): string { if (typeof content === "string") return content; return content .map((item) => (item.type === "text" ? item.text : ``)) .join("\n"); } function collect(entries: readonly SessionEntry[]): Evidence[] { const evidence: Evidence[] = []; let number = 0; const add = (kind: TranscriptKind, budget: "message" | "tool", text: string): void => { number += 1; const cap = budget === "message" ? MESSAGE_ENTRY_CAP : TOOL_ENTRY_CAP; const bounded = truncateMiddle(text, cap).text; evidence.push({ number, kind, budget, text: bounded, tokens: estimateTokens(bounded) }); }; for (const entry of entries) { if (entry.type === "compaction") { add("compaction-summary", "message", entry.summary); continue; } if (entry.type === "branch_summary") { add("branch-summary", "message", entry.summary); continue; } if (entry.type !== "message") continue; const message = entry.message; if (message.role === "user") { add("user", "message", contentText(message.content)); continue; } if (message.role === "assistant") { const assistantText = message.content .filter((item): item is TextContent => item.type === "text") .map((item) => item.text) .join("\n"); if (assistantText.length > 0) add("assistant", "message", assistantText); for (const item of message.content) { if (item.type !== "toolCall") continue; const bounded = toBoundedJson({ name: item.name, arguments: item.arguments }, TOOL_ENTRY_CAP); add("tool-call", "tool", stableStringify(bounded.value)); } continue; } if (message.role === "toolResult") { add("tool-result", "tool", `${message.toolName} (${message.isError ? "error" : "success"}):\n${contentText(message.content)}`); } // Custom/extension and hidden-thinking context is intentionally excluded. } return evidence; } function addIfFits(selected: Set, entry: Evidence, budget: { message: number; tool: number }): boolean { if (selected.has(entry.number) || entry.tokens > budget[entry.budget]) return false; selected.add(entry.number); budget[entry.budget] -= entry.tokens; return true; } export function compactTranscript( contextEntries: readonly SessionEntry[], branchEntries: readonly SessionEntry[], ): CompactTranscript { const contextIds = new Set(contextEntries.map((entry) => entry.id)); const historicalUsers = branchEntries.filter( (entry) => entry.type === "message" && entry.message.role === "user" && !contextIds.has(entry.id), ); const evidence = collect([...historicalUsers, ...contextEntries]); const selected = new Set(); const budget = { message: MESSAGE_BUDGET, tool: TOOL_BUDGET }; const users = evidence.filter((entry) => entry.kind === "user"); const latestCompactionSummary = evidence.filter((entry) => entry.kind === "compaction-summary").at(-1); const latestBranchSummary = evidence.filter((entry) => entry.kind === "branch-summary").at(-1); if (latestCompactionSummary !== undefined) addIfFits(selected, latestCompactionSummary, budget); if (latestBranchSummary !== undefined) addIfFits(selected, latestBranchSummary, budget); const firstUser = users[0]; const latestUser = users.at(-1); if (firstUser !== undefined) addIfFits(selected, firstUser, budget); if (latestUser !== undefined) addIfFits(selected, latestUser, budget); for (let index = users.length - 2; index >= 1; index -= 1) { addIfFits(selected, users[index]!, budget); } let nonUserCount = 0; for (let index = evidence.length - 1; index >= 0 && nonUserCount < MAX_RECENT_NON_USER; index -= 1) { const entry = evidence[index]!; if (entry.kind === "user") continue; if (addIfFits(selected, entry, budget)) nonUserCount += 1; } const retained = evidence.filter((entry) => selected.has(entry.number)); const omitted = retained.length !== evidence.length; const lines = retained.map( (entry) => `\n${entry.text}\n`, ); if (omitted) lines.push("Some conversation entries were omitted."); return { text: lines.join("\n\n"), omitted, retainedEntries: retained.length }; }