import type { CompressionBlock, PersistentSessionSummary } from "./compress";
import {
contentToText,
stableStringify,
splitSentences,
uniqueStrings,
oneLine,
quote,
} from "./deterministic-helpers.js";
export type CompactionReason = "manual" | "threshold" | "overflow" | "unknown";
export interface DeterministicSummaryOptions {
messages: readonly unknown[];
serializedConversation?: string;
previousSummary?: string;
persistentSummary?: PersistentSessionSummary;
blocks?: readonly CompressionBlock[];
maxTranscriptLines?: number;
maxSectionItems?: number;
compactionReason?: CompactionReason;
willRetry?: boolean;
customInstructions?: string;
}
export interface DeterministicSummaryResult {
summary: string;
estimatedTokensAfter: number;
lineCount: number;
}
const DEFAULT_MAX_TRANSCRIPT_LINES = 140;
const DEFAULT_MAX_SECTION_ITEMS = 24;
function estimateTextTokens(text: string): number {
return Math.max(1, Math.ceil(text.length / 4));
}
export function buildDeterministicSummary(
options: DeterministicSummaryOptions,
): DeterministicSummaryResult {
const maxTranscriptLines =
options.maxTranscriptLines ?? DEFAULT_MAX_TRANSCRIPT_LINES;
const maxSectionItems = options.maxSectionItems ?? DEFAULT_MAX_SECTION_ITEMS;
const messages = options.serializedConversation
? [{ role: "conversation", content: options.serializedConversation }]
: (options.messages ?? []);
const persistent = options.persistentSummary;
const blocks = options.blocks ?? [];
const goals = uniqueStrings([
...extractGoals(messages),
...(blocks.length > 0
? [
`Continue from ${blocks.length} durable DCP compression block${blocks.length === 1 ? "" : "s"}.`,
]
: []),
]).slice(0, maxSectionItems);
const filesRead = uniqueStrings([
...(persistent?.files_read ?? []),
...blocks.flatMap((block) => blockFields(block).files_read),
...extractPaths(messages, "read"),
]).slice(-maxSectionItems);
const filesModified = uniqueStrings([
...(persistent?.files_modified ?? []),
...blocks.flatMap((block) => blockFields(block).files_modified),
...extractPaths(messages, "modified"),
]).slice(-maxSectionItems);
const decisions = uniqueStrings([
...(persistent?.decisions ?? []).map((decision) => decision.text),
...blocks.flatMap((block) => blockFields(block).decisions),
...extractDecisionLines(messages),
]).slice(-maxSectionItems);
const nextSteps = uniqueStrings([
...(persistent?.next_steps ?? []).map((step) => step.text),
...blocks.flatMap((block) => blockFields(block).next_steps),
...extractOutstanding(messages),
]).slice(-maxSectionItems);
const transcript = buildBriefTranscript(messages, maxTranscriptLines);
const compactionContext = buildCompactionContext(options);
const sections: string[] = [];
sections.push(
"## Goal",
goals.length
? goals.map((item) => `- ${item}`).join("\n")
: "Continue the current task.",
"",
"## How To Use This Summary",
"- Treat this as compacted observations, not a full transcript; use the live tail for the freshest intent.",
"- If this summary conflicts with current messages or files on disk, trust current messages and disk first.",
"- Before guessing about missing prior context, use DCP recall/expanded blocks when available.",
"- Preserve the distinction between observations (what happened) and decisions (why it happened).",
"",
);
sections.push(
"## Progress",
"### Done",
"- [x] Preserved older context through deterministic DCP compaction.",
"",
"### In Progress",
);
pushBullets(
sections,
nextSteps.length ? nextSteps : ["Continue from the kept recent messages."],
"- [ ] ",
);
sections.push("", "### Blocked", ...extractBlocked(nextSteps), "");
sections.push("## Key Decisions");
pushBullets(
sections,
decisions.length
? decisions
: [
"Use deterministic DCP compaction as the primary context-preservation path.",
],
"- ",
);
sections.push("", "## Critical Context");
if (compactionContext.length > 0) {
sections.push("Compaction metadata:");
pushBullets(sections, compactionContext, "- ");
sections.push("");
}
if (options.previousSummary)
sections.push(
"Previous compaction summary:",
oneLine(options.previousSummary, 1200),
"",
);
pushBullets(
sections,
blocks
.slice(-maxSectionItems)
.map((block) => `${block.topic}: ${oneLine(block.summary, 180)}`),
"- ",
);
sections.push(
"",
"",
...filesRead,
"",
"",
"",
...filesModified,
"",
"",
);
if (transcript.length > 0) {
sections.push("## Brief Transcript", ...transcript);
}
const summary = sections.join("\n").trim();
return {
summary,
estimatedTokensAfter: estimateTextTokens(summary),
lineCount: summary.split("\n").length,
};
}
function blockFields(block: CompressionBlock): {
files_read: string[];
files_modified: string[];
decisions: string[];
next_steps: string[];
} {
const meta = block.metadata as Record | undefined;
return {
files_read: parseStringArray(meta?.files_read),
files_modified: parseStringArray(meta?.files_modified),
decisions: parseStringArray(meta?.decisions),
next_steps: parseStringArray(meta?.next_steps),
};
}
function parseStringArray(value: unknown): string[] {
if (Array.isArray(value)) return value.map(String).filter(Boolean);
if (typeof value === "string")
return value
.split(",")
.map((item) => item.trim())
.filter(Boolean);
return [];
}
function pushBullets(out: string[], items: string[], prefix: string): void {
for (const item of items.filter(Boolean)) out.push(`${prefix}${item}`);
}
function buildCompactionContext(
options: DeterministicSummaryOptions,
): string[] {
const context: string[] = [];
if (options.compactionReason) {
context.push(`Reason: ${options.compactionReason}`);
}
if (typeof options.willRetry === "boolean") {
context.push(
options.willRetry
? "Pi will retry the interrupted turn after compaction. Preserve the latest user intent and recovery context."
: "Pi will not retry an interrupted turn after this compaction.",
);
}
if (options.compactionReason === "overflow") {
context.push(
"Overflow recovery: be conservative and keep split-turn/tool context needed for retry.",
);
}
if (options.customInstructions?.trim()) {
context.push(
`Manual compact instructions: ${oneLine(options.customInstructions, 240)}`,
);
}
return context;
}
function extractBlocked(nextSteps: string[]): string[] {
const blocked = nextSteps.filter((item) =>
/\b(blocked|failing|failed|error)\b/i.test(item),
);
return blocked.length
? blocked.map((item) => `- ${item}`)
: ["- None known."];
}
function buildBriefTranscript(
messages: readonly unknown[],
maxLines: number,
): string[] {
const lines: string[] = [];
let toolRef = 1;
for (const message of messages) {
const role = getRole(message);
if (role === "system") continue;
const text = getMessageText(message);
const toolName = getToolName(message);
if (toolName) {
lines.push(
`* ${toolName} ${quote(oneLine(text || getToolInput(message), 160))} (#${toolRef++})`,
);
continue;
}
if (!text.trim()) continue;
lines.push(`[${role || "message"}]`);
lines.push(oneLine(text, 500));
}
if (lines.length <= maxLines) return lines;
const omitted = lines.length - maxLines;
return [
`...(${omitted} earlier transcript lines omitted)`,
...lines.slice(-maxLines),
];
}
function extractGoals(messages: readonly unknown[]): string[] {
const userTexts = messages
.filter((message) => getRole(message) === "user")
.map(getMessageText)
.filter(Boolean);
const goals: string[] = [];
if (userTexts[0]) goals.push(oneLine(userTexts[0], 220));
for (const text of userTexts.slice(1)) {
if (
/\b(also|actually|instead|new plan|scope|change|please|can you|i want)\b/i.test(
text,
)
) {
goals.push(`[Scope change] ${oneLine(text, 220)}`);
}
}
return goals;
}
function extractDecisionLines(messages: readonly unknown[]): string[] {
const decisions: string[] = [];
const re =
/\b(decided|decision|because|therefore|so we|we should|use .* instead|chosen|chose)\b/i;
for (const message of messages) {
const text = getMessageText(message);
for (const sentence of splitSentences(text)) {
if (re.test(sentence)) decisions.push(oneLine(sentence, 220));
}
}
return decisions;
}
function extractOutstanding(messages: readonly unknown[]): string[] {
const items: string[] = [];
const re =
/\b(todo|next|remaining|still|failing|failed|error|blocked|pending|follow up|not yet)\b/i;
for (const message of messages) {
const text = getMessageText(message);
for (const sentence of splitSentences(text)) {
if (re.test(sentence)) items.push(oneLine(sentence, 220));
}
}
return items;
}
function extractPaths(
messages: readonly unknown[],
mode: "read" | "modified",
): string[] {
const paths: string[] = [];
const writeRe =
/\b(?:modified|created|updated|edited|wrote|deleted|write|edit)\b/i;
const pathRe =
/(?:^|[\s"'`])((?:\.\.?\/)?[\w@.-][\w@./-]*\.[A-Za-z0-9]{1,8})(?=$|[\s"'`,:;)])/g;
for (const message of messages) {
const text = `${getToolName(message)} ${getToolInput(message)} ${getMessageText(message)}`;
const role = getRole(message);
const isModified =
writeRe.test(text) ||
/^(write|edit|strreplace|delete)$/i.test(getToolName(message));
if (mode === "modified" && !isModified) continue;
if (
mode === "read" &&
role !== "tool" &&
!/\b(read|grep|find|ls|glob|cat|sed)\b/i.test(text)
)
continue;
let match: RegExpExecArray | null;
while ((match = pathRe.exec(text)) !== null) paths.push(match[1]);
}
return paths;
}
function getRole(message: unknown): string {
if (!message || typeof message !== "object") return "";
const obj = message as Record;
return String(obj.role ?? obj.type ?? "").toLowerCase();
}
function getToolName(message: unknown): string {
if (!message || typeof message !== "object") return "";
const obj = message as Record;
const direct = obj.toolName ?? obj.tool_name ?? obj.name;
if (typeof direct === "string") return direct;
const call = obj.toolCall ?? obj.tool_call;
if (call && typeof call === "object") {
const callObj = call as Record;
return String(callObj.name ?? callObj.toolName ?? "");
}
return "";
}
function getToolInput(message: unknown): string {
if (!message || typeof message !== "object") return "";
const obj = message as Record;
const input = obj.input ?? obj.args ?? obj.arguments ?? obj.parameters;
if (typeof input === "string") return input;
if (input && typeof input === "object") return stableStringify(input);
return "";
}
function getMessageText(message: unknown): string {
if (!message || typeof message !== "object") return String(message ?? "");
const obj = message as Record;
const content =
obj.content ?? obj.message ?? obj.text ?? obj.output ?? obj.result;
return contentToText(content);
}