247 lines
6.8 KiB
TypeScript
247 lines
6.8 KiB
TypeScript
/**
|
|
* Custom Compaction Runtime Hook Example
|
|
*
|
|
* Demonstrates message compaction through a plugin runtime hook. The hook runs
|
|
* in `beforeModel`, estimates request size, preserves the first user message
|
|
* and recent working context, and replaces older middle history with one
|
|
* summary message before the provider request.
|
|
*
|
|
* Installation:
|
|
* cline plugin install https://github.com/cline/cline/blob/main/sdk/examples/hooks/custom-compaction-hook.example.ts --cwd .
|
|
*
|
|
* Usage:
|
|
* cline -i "Search the codebase for dispatcher usage, then summarize it"
|
|
*
|
|
* Note: for most plugin-owned message rewrites, prefer registerMessageBuilder().
|
|
* This example exists for cases where you specifically need runtime-hook access
|
|
* to the current request and snapshot.
|
|
*/
|
|
|
|
import type { AgentPlugin } from "@cline/core";
|
|
import { estimateTokens as estimateTokensFromChars } from "@cline/shared";
|
|
|
|
type PluginHooks = NonNullable<AgentPlugin["hooks"]>;
|
|
type BeforeModelHook = NonNullable<PluginHooks["beforeModel"]>;
|
|
type BeforeModelContext = Parameters<BeforeModelHook>[0];
|
|
type AgentMessage = BeforeModelContext["request"]["messages"][number];
|
|
type AgentMessagePart = AgentMessage["content"][number];
|
|
|
|
const MAX_INPUT_TOKENS = 120_000;
|
|
const COMPACT_AT_RATIO = 0.75;
|
|
const PRESERVE_RECENT_TOKENS = 24_000;
|
|
const SUMMARY_PREVIEW_CHARS = 800;
|
|
|
|
function estimateTokens(text: string): number {
|
|
return estimateTokensFromChars(text.length);
|
|
}
|
|
|
|
function preview(text: string, limit = SUMMARY_PREVIEW_CHARS): string {
|
|
const trimmed = text.trim();
|
|
if (trimmed.length <= limit) {
|
|
return trimmed;
|
|
}
|
|
return `${trimmed.slice(0, limit).trim()}\n...[${trimmed.length - limit} more chars summarized]`;
|
|
}
|
|
|
|
function stringifyUnknown(value: unknown): string {
|
|
if (typeof value === "string") {
|
|
return value;
|
|
}
|
|
try {
|
|
return JSON.stringify(value);
|
|
} catch {
|
|
return String(value);
|
|
}
|
|
}
|
|
|
|
function serializePart(message: AgentMessage, part: AgentMessagePart): string {
|
|
switch (part.type) {
|
|
case "text":
|
|
return `[${message.role}]: ${part.text}`;
|
|
case "reasoning":
|
|
return `[assistant reasoning]: ${preview(part.text, 300)}`;
|
|
case "image":
|
|
return `[${message.role} image]: ${part.mediaType ?? "unknown"}`;
|
|
case "file":
|
|
return `[${message.role} file ${part.path}]: ${preview(part.content, 500)}`;
|
|
case "tool-call":
|
|
return `[assistant tool call]: ${part.toolName}(${stringifyUnknown(part.input)})`;
|
|
case "tool-result":
|
|
return `[tool result ${part.toolName}]: ${preview(stringifyUnknown(part.output), 500)}`;
|
|
}
|
|
}
|
|
|
|
function serializeMessage(message: AgentMessage): string {
|
|
return message.content.map((part) => serializePart(message, part)).join("\n");
|
|
}
|
|
|
|
function estimateMessageTokens(message: AgentMessage): number {
|
|
return estimateTokens(serializeMessage(message));
|
|
}
|
|
|
|
function isTurnStartMessage(message: AgentMessage): boolean {
|
|
return (
|
|
message.role === "user" &&
|
|
message.content.some((part) => part.type === "text")
|
|
);
|
|
}
|
|
|
|
function findFirstUserIndex(messages: readonly AgentMessage[]): number {
|
|
return messages.findIndex(isTurnStartMessage);
|
|
}
|
|
|
|
function findRecentStartIndex(messages: readonly AgentMessage[]): number {
|
|
let tokens = 0;
|
|
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
|
const message = messages[index];
|
|
if (!message) {
|
|
continue;
|
|
}
|
|
tokens += estimateMessageTokens(message);
|
|
if (tokens >= PRESERVE_RECENT_TOKENS) {
|
|
return index;
|
|
}
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
function collectToolNames(messages: readonly AgentMessage[]): string[] {
|
|
const names = new Set<string>();
|
|
for (const message of messages) {
|
|
for (const part of message.content) {
|
|
if (part.type === "tool-call" || part.type === "tool-result") {
|
|
names.add(part.toolName);
|
|
}
|
|
}
|
|
}
|
|
return [...names].sort((left, right) => left.localeCompare(right));
|
|
}
|
|
|
|
function collectTouchedFiles(messages: readonly AgentMessage[]): string[] {
|
|
const paths = new Set<string>();
|
|
for (const message of messages) {
|
|
for (const part of message.content) {
|
|
if (part.type === "file") {
|
|
paths.add(part.path);
|
|
}
|
|
if (part.type === "tool-call") {
|
|
for (const value of Object.values(
|
|
typeof part.input === "object" && part.input !== null
|
|
? part.input
|
|
: {},
|
|
)) {
|
|
if (typeof value === "string" && value.includes("/")) {
|
|
paths.add(value);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return [...paths].sort((left, right) => left.localeCompare(right));
|
|
}
|
|
|
|
function buildCompactionSummary(
|
|
compacted: readonly AgentMessage[],
|
|
tokensBefore: number,
|
|
): AgentMessage {
|
|
const roleCounts = compacted.reduce<Record<string, number>>(
|
|
(counts, message) => {
|
|
counts[message.role] = (counts[message.role] ?? 0) + 1;
|
|
return counts;
|
|
},
|
|
{},
|
|
);
|
|
const tools = collectToolNames(compacted);
|
|
const files = collectTouchedFiles(compacted);
|
|
const highlights = compacted
|
|
.map(serializeMessage)
|
|
.map((line) => preview(line, 500))
|
|
.slice(-6);
|
|
|
|
return {
|
|
id: `hook_compaction_${Date.now()}`,
|
|
role: "user",
|
|
createdAt: Date.now(),
|
|
metadata: {
|
|
kind: "example_hook_compaction_summary",
|
|
tokensBefore,
|
|
messagesCompacted: compacted.length,
|
|
},
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: `Context summary:
|
|
|
|
## Compacted Range
|
|
- Messages compacted: ${compacted.length}
|
|
- Estimated tokens before compaction: ${tokensBefore}
|
|
- Roles: ${Object.entries(roleCounts)
|
|
.map(([role, count]) => `${role}=${count}`)
|
|
.join(", ")}
|
|
|
|
## Tool Activity
|
|
${tools.length > 0 ? tools.map((tool) => `- ${tool}`).join("\n") : "- none"}
|
|
|
|
## Files Mentioned
|
|
${files.length > 0 ? files.map((path) => `- ${path}`).join("\n") : "- none"}
|
|
|
|
## Recent Highlights From Compacted History
|
|
${highlights.length > 0 ? highlights.map((item) => `- ${item}`).join("\n") : "- none"}
|
|
|
|
Continue from this summary plus the preserved recent messages below.`,
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
const plugin: AgentPlugin = {
|
|
name: "custom-compaction-before-model-hook",
|
|
manifest: {
|
|
capabilities: ["hooks"],
|
|
},
|
|
|
|
hooks: {
|
|
beforeModel({ request }) {
|
|
const messages = request.messages;
|
|
const totalTokens = messages.reduce(
|
|
(total, message) => total + estimateMessageTokens(message),
|
|
0,
|
|
);
|
|
if (totalTokens < MAX_INPUT_TOKENS * COMPACT_AT_RATIO) {
|
|
return undefined;
|
|
}
|
|
|
|
const firstUserIndex = findFirstUserIndex(messages);
|
|
if (firstUserIndex < 0) {
|
|
return undefined;
|
|
}
|
|
|
|
const recentStartIndex = Math.max(
|
|
firstUserIndex + 1,
|
|
findRecentStartIndex(messages),
|
|
);
|
|
if (recentStartIndex <= firstUserIndex + 1) {
|
|
return undefined;
|
|
}
|
|
|
|
const prefix = messages.slice(0, firstUserIndex + 1);
|
|
const compacted = messages.slice(firstUserIndex + 1, recentStartIndex);
|
|
const recent = messages.slice(recentStartIndex);
|
|
if (compacted.length === 0) {
|
|
return undefined;
|
|
}
|
|
|
|
return {
|
|
messages: [
|
|
...prefix,
|
|
buildCompactionSummary(compacted, totalTokens),
|
|
...recent,
|
|
],
|
|
};
|
|
},
|
|
},
|
|
};
|
|
|
|
export { plugin };
|
|
export default plugin;
|