/** * compact-summary.ts — summary tag helpers, formatting, and merging (Layer 2). * * Extracted from compact.ts (delegate-shell split, Phase D follow-up) so * compact.ts stays under the 300-line soft limit without squeezing. These * functions form a cohesive unit: they all operate on the `` * tag block produced by the COLLAPSE output and consumed by the continuation * message builder. Kept pi-agnostic (only `EngineMessage` type imported). */ import type { EngineMessage } from "./types.js"; function truncate(s: string, max: number): string { return s.length <= max ? s : `${s.slice(0, max)}…`; } function summarizeBlock(m: EngineMessage): string { if (m.role === "tool") return `tool_result ${m.toolName ?? "?"}: ${truncate(m.output ?? m.text, 160)}`; if (m.toolName) return `tool_use ${m.toolName}(${truncate(m.input ?? "", 160)})`; return truncate(m.text, 160); } function stripTag(block: string, tag: string): string { const start = `<${tag}>`; const end = ``; const s = block.indexOf(start); const e = block.indexOf(end); if (s === -1 || e === -1) return block; return block.slice(0, s) + block.slice(e + end.length); } function extractTag(block: string, tag: string): string | undefined { const s = block.indexOf(`<${tag}>`); const e = block.indexOf(``); if (s === -1 || e === -1) return undefined; return block.slice(s + `<${tag}>`.length, e); } /** Normalize a raw summary into user-facing "Summary: ..." text. */ export function formatCompactSummary(summary: string): string { const withoutAnalysis = stripTag(summary, "analysis"); let formatted = withoutAnalysis; const content = extractTag(withoutAnalysis, "summary"); if (content !== undefined) { formatted = withoutAnalysis.replace( `${content}`, `Summary:\n${content.trim()}`, ); } return formatted.replace(/\n{3,}/g, "\n\n").trim(); } /** Extract the prior "highlights" + "timeline" sections from an existing summary. */ export function extractSummaryHighlights(summary: string): string[] { const lines = formatCompactSummary(summary).split("\n"); const out: string[] = []; let inTimeline = false; for (const line of lines) { const t = line.trimEnd(); if (!t || t === "Summary:" || t === "Conversation summary:") continue; if (t === "- Key timeline:") { inTimeline = true; continue; } if (inTimeline) continue; out.push(t); } return out; } export function extractSummaryTimeline(summary: string): string[] { const lines = formatCompactSummary(summary).split("\n"); const out: string[] = []; let inTimeline = false; for (const line of lines) { const t = line.trimEnd(); if (t === "- Key timeline:") { inTimeline = true; continue; } if (!inTimeline) continue; if (!t) break; out.push(t); } return out; } /** Merge an existing compact summary with a new one (accumulate, don't overwrite). */ export function mergeCompactSummaries( existing: string | undefined, newSummary: string, ): string { if (!existing) return newSummary; const prevHighlights = extractSummaryHighlights(existing); const newHighlights = extractSummaryHighlights( formatCompactSummary(newSummary), ); const newTimeline = extractSummaryTimeline(formatCompactSummary(newSummary)); const lines = ["", "Conversation summary:"]; if (prevHighlights.length) { lines.push("- Previously compacted context:"); prevHighlights.forEach((l) => lines.push(` ${l}`)); } if (newHighlights.length) { lines.push("- Newly compacted context:"); newHighlights.forEach((l) => lines.push(` ${l}`)); } if (newTimeline.length) { lines.push("- Key timeline:"); newTimeline.forEach((l) => lines.push(` ${l}`)); } lines.push(""); return lines.join("\n"); } // Private helpers re-exported for the shell's summarizeMessages (which stays // in compact.ts because it depends on the inference helpers there). export { truncate, summarizeBlock };