192 lines
5.8 KiB
TypeScript
192 lines
5.8 KiB
TypeScript
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
|
import type { ExtensionAPI, ExtensionContext, FileOperations } from "@mariozechner/pi-coding-agent";
|
|
import { estimateTokens, generateSummary } from "@mariozechner/pi-coding-agent";
|
|
|
|
import { DEFAULT_CONTEXT_TOKENS } from "../defaults.js";
|
|
|
|
const MAX_CHUNK_RATIO = 0.4;
|
|
const FALLBACK_SUMMARY =
|
|
"Summary unavailable due to context limits. Older messages were truncated.";
|
|
const TURN_PREFIX_INSTRUCTIONS =
|
|
"This summary covers the prefix of a split turn. Focus on the original request," +
|
|
" early progress, and any details needed to understand the retained suffix.";
|
|
|
|
function computeFileLists(fileOps: FileOperations): {
|
|
readFiles: string[];
|
|
modifiedFiles: string[];
|
|
} {
|
|
const modified = new Set([...fileOps.edited, ...fileOps.written]);
|
|
const readFiles = [...fileOps.read].filter((f) => !modified.has(f)).sort();
|
|
const modifiedFiles = [...modified].sort();
|
|
return { readFiles, modifiedFiles };
|
|
}
|
|
|
|
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")}`;
|
|
}
|
|
|
|
function chunkMessages(messages: AgentMessage[], maxTokens: number): AgentMessage[][] {
|
|
if (messages.length === 0) return [];
|
|
|
|
const chunks: AgentMessage[][] = [];
|
|
let currentChunk: AgentMessage[] = [];
|
|
let currentTokens = 0;
|
|
|
|
for (const message of messages) {
|
|
const messageTokens = estimateTokens(message);
|
|
if (currentChunk.length > 0 && currentTokens + messageTokens > maxTokens) {
|
|
chunks.push(currentChunk);
|
|
currentChunk = [];
|
|
currentTokens = 0;
|
|
}
|
|
|
|
currentChunk.push(message);
|
|
currentTokens += messageTokens;
|
|
|
|
if (messageTokens > maxTokens) {
|
|
// Split oversized messages to avoid unbounded chunk growth.
|
|
chunks.push(currentChunk);
|
|
currentChunk = [];
|
|
currentTokens = 0;
|
|
}
|
|
}
|
|
|
|
if (currentChunk.length > 0) {
|
|
chunks.push(currentChunk);
|
|
}
|
|
|
|
return chunks;
|
|
}
|
|
|
|
async function summarizeChunks(params: {
|
|
messages: AgentMessage[];
|
|
model: NonNullable<ExtensionContext["model"]>;
|
|
apiKey: string;
|
|
signal: AbortSignal;
|
|
reserveTokens: number;
|
|
maxChunkTokens: number;
|
|
customInstructions?: string;
|
|
previousSummary?: string;
|
|
}): Promise<string> {
|
|
if (params.messages.length === 0) {
|
|
return params.previousSummary ?? "No prior history.";
|
|
}
|
|
|
|
const chunks = chunkMessages(params.messages, params.maxChunkTokens);
|
|
let summary = params.previousSummary;
|
|
|
|
for (const chunk of chunks) {
|
|
summary = await generateSummary(
|
|
chunk,
|
|
params.model,
|
|
params.reserveTokens,
|
|
params.apiKey,
|
|
params.signal,
|
|
params.customInstructions,
|
|
summary,
|
|
);
|
|
}
|
|
|
|
return summary ?? "No prior history.";
|
|
}
|
|
|
|
export default function compactionSafeguardExtension(api: ExtensionAPI): void {
|
|
api.on("session_before_compact", async (event, ctx) => {
|
|
const { preparation, customInstructions, signal } = event;
|
|
const { readFiles, modifiedFiles } = computeFileLists(preparation.fileOps);
|
|
const fileOpsSummary = formatFileOperations(readFiles, modifiedFiles);
|
|
const fallbackSummary = `${FALLBACK_SUMMARY}${fileOpsSummary}`;
|
|
|
|
const model = ctx.model;
|
|
if (!model) {
|
|
return {
|
|
compaction: {
|
|
summary: fallbackSummary,
|
|
firstKeptEntryId: preparation.firstKeptEntryId,
|
|
tokensBefore: preparation.tokensBefore,
|
|
details: { readFiles, modifiedFiles },
|
|
},
|
|
};
|
|
}
|
|
|
|
const apiKey = await ctx.modelRegistry.getApiKey(model);
|
|
if (!apiKey) {
|
|
return {
|
|
compaction: {
|
|
summary: fallbackSummary,
|
|
firstKeptEntryId: preparation.firstKeptEntryId,
|
|
tokensBefore: preparation.tokensBefore,
|
|
details: { readFiles, modifiedFiles },
|
|
},
|
|
};
|
|
}
|
|
|
|
try {
|
|
const contextWindowTokens = Math.max(
|
|
1,
|
|
Math.floor(model.contextWindow ?? DEFAULT_CONTEXT_TOKENS),
|
|
);
|
|
const maxChunkTokens = Math.max(1, Math.floor(contextWindowTokens * MAX_CHUNK_RATIO));
|
|
const reserveTokens = Math.max(1, Math.floor(preparation.settings.reserveTokens));
|
|
|
|
const historySummary = await summarizeChunks({
|
|
messages: preparation.messagesToSummarize,
|
|
model,
|
|
apiKey,
|
|
signal,
|
|
reserveTokens,
|
|
maxChunkTokens,
|
|
customInstructions,
|
|
previousSummary: preparation.previousSummary,
|
|
});
|
|
|
|
let summary = historySummary;
|
|
if (preparation.isSplitTurn && preparation.turnPrefixMessages.length > 0) {
|
|
const prefixSummary = await summarizeChunks({
|
|
messages: preparation.turnPrefixMessages,
|
|
model,
|
|
apiKey,
|
|
signal,
|
|
reserveTokens,
|
|
maxChunkTokens,
|
|
customInstructions: TURN_PREFIX_INSTRUCTIONS,
|
|
});
|
|
summary = `${historySummary}\n\n---\n\n**Turn Context (split turn):**\n\n${prefixSummary}`;
|
|
}
|
|
|
|
summary += fileOpsSummary;
|
|
|
|
return {
|
|
compaction: {
|
|
summary,
|
|
firstKeptEntryId: preparation.firstKeptEntryId,
|
|
tokensBefore: preparation.tokensBefore,
|
|
details: { readFiles, modifiedFiles },
|
|
},
|
|
};
|
|
} catch (error) {
|
|
console.warn(
|
|
`Compaction summarization failed; truncating history: ${
|
|
error instanceof Error ? error.message : String(error)
|
|
}`,
|
|
);
|
|
return {
|
|
compaction: {
|
|
summary: fallbackSummary,
|
|
firstKeptEntryId: preparation.firstKeptEntryId,
|
|
tokensBefore: preparation.tokensBefore,
|
|
details: { readFiles, modifiedFiles },
|
|
},
|
|
};
|
|
}
|
|
});
|
|
}
|