Extract shared compaction/branch-summarization utils

- New utils.ts with shared functions:
  - FileOperations type and createFileOps()
  - extractFileOpsFromMessage()
  - computeFileLists()
  - formatFileOperations()
  - serializeConversation()
  - SUMMARIZATION_SYSTEM_PROMPT

- branch-summarization.ts now uses:
  - Serialization approach (conversation as text, not LLM messages)
  - completeSimple with system prompt
  - Shared utility functions
This commit is contained in:
Mario Zechner 2025-12-30 00:13:11 +01:00
parent 17ce3814a8
commit 81f4cdf3e3
4 changed files with 193 additions and 199 deletions

View file

@ -7,7 +7,7 @@
import type { AgentMessage } from "@mariozechner/pi-agent-core"; import type { AgentMessage } from "@mariozechner/pi-agent-core";
import type { Model } from "@mariozechner/pi-ai"; import type { Model } from "@mariozechner/pi-ai";
import { complete } from "@mariozechner/pi-ai"; import { completeSimple } from "@mariozechner/pi-ai";
import { import {
convertToLlm, convertToLlm,
createBranchSummaryMessage, createBranchSummaryMessage,
@ -16,6 +16,15 @@ import {
} from "../messages.js"; } from "../messages.js";
import type { ReadonlySessionManager, SessionEntry } from "../session-manager.js"; import type { ReadonlySessionManager, SessionEntry } from "../session-manager.js";
import { estimateTokens } from "./compaction.js"; import { estimateTokens } from "./compaction.js";
import {
computeFileLists,
createFileOps,
extractFileOpsFromMessage,
type FileOperations,
formatFileOperations,
SUMMARIZATION_SYSTEM_PROMPT,
serializeConversation,
} from "./utils.js";
// ============================================================================ // ============================================================================
// Types // Types
@ -35,11 +44,7 @@ export interface BranchSummaryDetails {
modifiedFiles: string[]; modifiedFiles: string[];
} }
export interface FileOperations { export type { FileOperations } from "./utils.js";
read: Set<string>;
written: Set<string>;
edited: Set<string>;
}
export interface BranchPreparation { export interface BranchPreparation {
/** Messages extracted for summarization, in chronological order */ /** Messages extracted for summarization, in chronological order */
@ -159,38 +164,6 @@ function getMessageFromEntry(entry: SessionEntry): AgentMessage | undefined {
} }
} }
/**
* Extract file operations from tool calls in an assistant message.
*/
function extractFileOpsFromMessage(message: AgentMessage, fileOps: FileOperations): void {
if (message.role !== "assistant") return;
if (!("content" in message) || !Array.isArray(message.content)) return;
for (const block of message.content) {
if (typeof block !== "object" || block === null) continue;
if (!("type" in block) || block.type !== "toolCall") continue;
if (!("arguments" in block) || !("name" in block)) continue;
const args = block.arguments as Record<string, unknown> | undefined;
if (!args) continue;
const path = typeof args.path === "string" ? args.path : undefined;
if (!path) continue;
switch (block.name) {
case "read":
fileOps.read.add(path);
break;
case "write":
fileOps.written.add(path);
break;
case "edit":
fileOps.edited.add(path);
break;
}
}
}
/** /**
* Prepare entries for summarization with token budget. * Prepare entries for summarization with token budget.
* *
@ -206,11 +179,7 @@ function extractFileOpsFromMessage(message: AgentMessage, fileOps: FileOperation
*/ */
export function prepareBranchEntries(entries: SessionEntry[], tokenBudget: number = 0): BranchPreparation { export function prepareBranchEntries(entries: SessionEntry[], tokenBudget: number = 0): BranchPreparation {
const messages: AgentMessage[] = []; const messages: AgentMessage[] = [];
const fileOps: FileOperations = { const fileOps = createFileOps();
read: new Set(),
written: new Set(),
edited: new Set(),
};
let totalTokens = 0; let totalTokens = 0;
// First pass: collect file ops from ALL entries (even if they don't fit in token budget) // First pass: collect file ops from ALL entries (even if they don't fit in token budget)
@ -322,24 +291,29 @@ export async function generateBranchSummary(
return { summary: "No content to summarize" }; return { summary: "No content to summarize" };
} }
// Transform to LLM-compatible messages (preserves tool calls, etc.) // Transform to LLM-compatible messages, then serialize to text
const transformedMessages = convertToLlm(messages); // Serialization prevents the model from treating it as a conversation to continue
const llmMessages = convertToLlm(messages);
const conversationText = serializeConversation(llmMessages);
// Build prompt // Build prompt
const instructions = customInstructions || BRANCH_SUMMARY_PROMPT; const instructions = customInstructions || BRANCH_SUMMARY_PROMPT;
const promptText = `<conversation>\n${conversationText}\n</conversation>\n\n${instructions}`;
// Append summarization prompt as final user message
const summarizationMessages = [ const summarizationMessages = [
...transformedMessages,
{ {
role: "user" as const, role: "user" as const,
content: [{ type: "text" as const, text: instructions }], content: [{ type: "text" as const, text: promptText }],
timestamp: Date.now(), timestamp: Date.now(),
}, },
]; ];
// Call LLM for summarization // Call LLM for summarization
const response = await complete(model, { messages: summarizationMessages }, { apiKey, signal, maxTokens: 2048 }); const response = await completeSimple(
model,
{ systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages },
{ apiKey, signal, maxTokens: 2048 },
);
// Check if aborted or errored // Check if aborted or errored
if (response.stopReason === "aborted") { if (response.stopReason === "aborted") {
@ -357,26 +331,13 @@ export async function generateBranchSummary(
// Prepend preamble to provide context about the branch summary // Prepend preamble to provide context about the branch summary
summary = BRANCH_SUMMARY_PREAMBLE + summary; summary = BRANCH_SUMMARY_PREAMBLE + summary;
// Compute file lists // Compute file lists and append to summary
const modified = new Set([...fileOps.edited, ...fileOps.written]); const { readFiles, modifiedFiles } = computeFileLists(fileOps);
const readOnly = [...fileOps.read].filter((f) => !modified.has(f)).sort(); summary += formatFileOperations(readFiles, modifiedFiles);
const modifiedFiles = [...modified].sort();
// Append file lists to summary text (for LLM context and TUI display)
const fileSections: string[] = [];
if (readOnly.length > 0) {
fileSections.push(`<read-files>\n${readOnly.join("\n")}\n</read-files>`);
}
if (modifiedFiles.length > 0) {
fileSections.push(`<modified-files>\n${modifiedFiles.join("\n")}\n</modified-files>`);
}
if (fileSections.length > 0) {
summary += `\n\n${fileSections.join("\n\n")}`;
}
return { return {
summary: summary || "No summary generated", summary: summary || "No summary generated",
readFiles: readOnly, readFiles,
modifiedFiles, modifiedFiles,
}; };
} }

View file

@ -6,10 +6,19 @@
*/ */
import type { AgentMessage } from "@mariozechner/pi-agent-core"; import type { AgentMessage } from "@mariozechner/pi-agent-core";
import type { AssistantMessage, Message, Model, Usage } from "@mariozechner/pi-ai"; import type { AssistantMessage, Model, Usage } from "@mariozechner/pi-ai";
import { complete, completeSimple } from "@mariozechner/pi-ai"; import { complete, completeSimple } from "@mariozechner/pi-ai";
import { convertToLlm, createBranchSummaryMessage, createHookMessage } from "../messages.js"; import { convertToLlm, createBranchSummaryMessage, createHookMessage } from "../messages.js";
import type { CompactionEntry, SessionEntry } from "../session-manager.js"; import type { CompactionEntry, SessionEntry } from "../session-manager.js";
import {
computeFileLists,
createFileOps,
extractFileOpsFromMessage,
type FileOperations,
formatFileOperations,
SUMMARIZATION_SYSTEM_PROMPT,
serializeConversation,
} from "./utils.js";
// ============================================================================ // ============================================================================
// File Operation Tracking // File Operation Tracking
@ -21,44 +30,6 @@ export interface CompactionDetails {
modifiedFiles: string[]; modifiedFiles: string[];
} }
interface FileOperations {
read: Set<string>;
written: Set<string>;
edited: Set<string>;
}
/**
* Extract file operations from tool calls in an assistant message.
*/
function extractFileOpsFromMessage(message: AgentMessage, fileOps: FileOperations): void {
if (message.role !== "assistant") return;
if (!("content" in message) || !Array.isArray(message.content)) return;
for (const block of message.content) {
if (typeof block !== "object" || block === null) continue;
if (!("type" in block) || block.type !== "toolCall") continue;
if (!("arguments" in block) || !("name" in block)) continue;
const args = block.arguments as Record<string, unknown> | undefined;
if (!args) continue;
const path = typeof args.path === "string" ? args.path : undefined;
if (!path) continue;
switch (block.name) {
case "read":
fileOps.read.add(path);
break;
case "write":
fileOps.written.add(path);
break;
case "edit":
fileOps.edited.add(path);
break;
}
}
}
/** /**
* Extract file operations from messages and previous compaction entries. * Extract file operations from messages and previous compaction entries.
*/ */
@ -67,11 +38,7 @@ function extractFileOperations(
entries: SessionEntry[], entries: SessionEntry[],
prevCompactionIndex: number, prevCompactionIndex: number,
): FileOperations { ): FileOperations {
const fileOps: FileOperations = { const fileOps = createFileOps();
read: new Set(),
written: new Set(),
edited: new Set(),
};
// Collect from previous compaction's details (if pi-generated) // Collect from previous compaction's details (if pi-generated)
if (prevCompactionIndex >= 0) { if (prevCompactionIndex >= 0) {
@ -95,91 +62,6 @@ function extractFileOperations(
return fileOps; return fileOps;
} }
/**
* Compute final file lists from file operations.
*/
function computeFileLists(fileOps: FileOperations): { readFiles: string[]; modifiedFiles: string[] } {
const modified = new Set([...fileOps.edited, ...fileOps.written]);
const readOnly = [...fileOps.read].filter((f) => !modified.has(f)).sort();
const modifiedFiles = [...modified].sort();
return { readFiles: readOnly, modifiedFiles };
}
/**
* Format file operations as XML tags for summary.
*/
function formatFileOperations(readFiles: string[], modifiedFiles: string[]): string {
const sections: string[] = [];
if (readFiles.length > 0) {
sections.push(`<read-files>\n${readFiles.join("\n")}\n</read-files>`);
}
if (modifiedFiles.length > 0) {
sections.push(`<modified-files>\n${modifiedFiles.join("\n")}\n</modified-files>`);
}
if (sections.length === 0) return "";
return `\n\n${sections.join("\n\n")}`;
}
/**
* Serialize LLM messages to text for summarization.
* This prevents the model from treating it as a conversation to continue.
* Call convertToLlm() first to handle custom message types.
*/
function serializeConversation(messages: Message[]): string {
const parts: string[] = [];
for (const msg of messages) {
if (msg.role === "user") {
const content =
typeof msg.content === "string"
? msg.content
: msg.content
.filter((c): c is { type: "text"; text: string } => c.type === "text")
.map((c) => c.text)
.join("");
if (content) parts.push(`[User]: ${content}`);
} else if (msg.role === "assistant") {
const textParts: string[] = [];
const thinkingParts: string[] = [];
const toolCalls: string[] = [];
for (const block of msg.content) {
if (block.type === "text") {
textParts.push(block.text);
} else if (block.type === "thinking") {
thinkingParts.push(block.thinking);
} else if (block.type === "toolCall") {
const args = block.arguments as Record<string, unknown>;
const argsStr = Object.entries(args)
.map(([k, v]) => `${k}=${JSON.stringify(v)}`)
.join(", ");
toolCalls.push(`${block.name}(${argsStr})`);
}
}
if (thinkingParts.length > 0) {
parts.push(`[Assistant thinking]: ${thinkingParts.join("\n")}`);
}
if (textParts.length > 0) {
parts.push(`[Assistant]: ${textParts.join("\n")}`);
}
if (toolCalls.length > 0) {
parts.push(`[Assistant tool calls]: ${toolCalls.join("; ")}`);
}
} else if (msg.role === "toolResult") {
const content = msg.content
.filter((c): c is { type: "text"; text: string } => c.type === "text")
.map((c) => c.text)
.join("");
if (content) {
parts.push(`[Tool result]: ${content}`);
}
}
}
return parts.join("\n\n");
}
// ============================================================================ // ============================================================================
// Message Extraction // Message Extraction
// ============================================================================ // ============================================================================
@ -501,10 +383,6 @@ export function findCutPoint(
// Summarization // Summarization
// ============================================================================ // ============================================================================
const SUMMARIZATION_SYSTEM_PROMPT = `You are a context summarization assistant. Your task is to read a conversation between a user and an AI coding assistant, then produce a structured summary following the exact format specified.
Do NOT continue the conversation. Do NOT respond to any questions in the conversation. ONLY output the structured summary.`;
const SUMMARIZATION_PROMPT = `The messages above are a conversation to summarize. Create a structured context checkpoint summary that another LLM will use to continue the work. const SUMMARIZATION_PROMPT = `The messages above are a conversation to summarize. Create a structured context checkpoint summary that another LLM will use to continue the work.
Use this EXACT format: Use this EXACT format:

View file

@ -4,3 +4,4 @@
export * from "./branch-summarization.js"; export * from "./branch-summarization.js";
export * from "./compaction.js"; export * from "./compaction.js";
export * from "./utils.js";

View file

@ -0,0 +1,154 @@
/**
* Shared utilities for compaction and branch summarization.
*/
import type { AgentMessage } from "@mariozechner/pi-agent-core";
import type { Message } from "@mariozechner/pi-ai";
// ============================================================================
// File Operation Tracking
// ============================================================================
export interface FileOperations {
read: Set<string>;
written: Set<string>;
edited: Set<string>;
}
export function createFileOps(): FileOperations {
return {
read: new Set(),
written: new Set(),
edited: new Set(),
};
}
/**
* Extract file operations from tool calls in an assistant message.
*/
export function extractFileOpsFromMessage(message: AgentMessage, fileOps: FileOperations): void {
if (message.role !== "assistant") return;
if (!("content" in message) || !Array.isArray(message.content)) return;
for (const block of message.content) {
if (typeof block !== "object" || block === null) continue;
if (!("type" in block) || block.type !== "toolCall") continue;
if (!("arguments" in block) || !("name" in block)) continue;
const args = block.arguments as Record<string, unknown> | undefined;
if (!args) continue;
const path = typeof args.path === "string" ? args.path : undefined;
if (!path) continue;
switch (block.name) {
case "read":
fileOps.read.add(path);
break;
case "write":
fileOps.written.add(path);
break;
case "edit":
fileOps.edited.add(path);
break;
}
}
}
/**
* Compute final file lists from file operations.
* Returns readFiles (files only read, not modified) and modifiedFiles.
*/
export function computeFileLists(fileOps: FileOperations): { readFiles: string[]; modifiedFiles: string[] } {
const modified = new Set([...fileOps.edited, ...fileOps.written]);
const readOnly = [...fileOps.read].filter((f) => !modified.has(f)).sort();
const modifiedFiles = [...modified].sort();
return { readFiles: readOnly, modifiedFiles };
}
/**
* Format file operations as XML tags for summary.
*/
export function formatFileOperations(readFiles: string[], modifiedFiles: string[]): string {
const sections: string[] = [];
if (readFiles.length > 0) {
sections.push(`<read-files>\n${readFiles.join("\n")}\n</read-files>`);
}
if (modifiedFiles.length > 0) {
sections.push(`<modified-files>\n${modifiedFiles.join("\n")}\n</modified-files>`);
}
if (sections.length === 0) return "";
return `\n\n${sections.join("\n\n")}`;
}
// ============================================================================
// Message Serialization
// ============================================================================
/**
* Serialize LLM messages to text for summarization.
* This prevents the model from treating it as a conversation to continue.
* Call convertToLlm() first to handle custom message types.
*/
export function serializeConversation(messages: Message[]): string {
const parts: string[] = [];
for (const msg of messages) {
if (msg.role === "user") {
const content =
typeof msg.content === "string"
? msg.content
: msg.content
.filter((c): c is { type: "text"; text: string } => c.type === "text")
.map((c) => c.text)
.join("");
if (content) parts.push(`[User]: ${content}`);
} else if (msg.role === "assistant") {
const textParts: string[] = [];
const thinkingParts: string[] = [];
const toolCalls: string[] = [];
for (const block of msg.content) {
if (block.type === "text") {
textParts.push(block.text);
} else if (block.type === "thinking") {
thinkingParts.push(block.thinking);
} else if (block.type === "toolCall") {
const args = block.arguments as Record<string, unknown>;
const argsStr = Object.entries(args)
.map(([k, v]) => `${k}=${JSON.stringify(v)}`)
.join(", ");
toolCalls.push(`${block.name}(${argsStr})`);
}
}
if (thinkingParts.length > 0) {
parts.push(`[Assistant thinking]: ${thinkingParts.join("\n")}`);
}
if (textParts.length > 0) {
parts.push(`[Assistant]: ${textParts.join("\n")}`);
}
if (toolCalls.length > 0) {
parts.push(`[Assistant tool calls]: ${toolCalls.join("; ")}`);
}
} else if (msg.role === "toolResult") {
const content = msg.content
.filter((c): c is { type: "text"; text: string } => c.type === "text")
.map((c) => c.text)
.join("");
if (content) {
parts.push(`[Tool result]: ${content}`);
}
}
}
return parts.join("\n\n");
}
// ============================================================================
// Summarization System Prompt
// ============================================================================
export const SUMMARIZATION_SYSTEM_PROMPT = `You are a context summarization assistant. Your task is to read a conversation between a user and an AI coding assistant, then produce a structured summary following the exact format specified.
Do NOT continue the conversation. Do NOT respond to any questions in the conversation. ONLY output the structured summary.`;