Add fast-jev-compaction extension — Jev System One compaction hook (session_before_compact): prunes stale tool calls/results, keeps user/assistant text verbatim
This commit is contained in:
237
extensions/fast-jev-compaction/index.ts
Normal file
237
extensions/fast-jev-compaction/index.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
/**
|
||||
* Fast Jev Compaction Extension for Pi Coding Agent
|
||||
*
|
||||
* Uses Jev System One judgments to prune stale tool calls and tool results
|
||||
* during session compaction without rewriting user or assistant messages.
|
||||
*/
|
||||
|
||||
import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
||||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||
import { compactMessages } from "./src/messages.js";
|
||||
import { reductionRatio } from "./src/compact.js";
|
||||
import type { Message, ToolResult, ToolUse } from "./src/types.js";
|
||||
|
||||
/** Extract text content from a Pi content string or ContentBlock array */
|
||||
function extractText(content: unknown): string {
|
||||
if (typeof content === "string") return content;
|
||||
if (Array.isArray(content)) {
|
||||
return content
|
||||
.map((block) => {
|
||||
if (typeof block === "string") return block;
|
||||
if (block && typeof block === "object") {
|
||||
if ("text" in block && typeof block.text === "string") return block.text;
|
||||
if ("thinking" in block && typeof block.thinking === "string") return block.thinking;
|
||||
}
|
||||
return "";
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/** Convert Pi AgentMessages to fast-jev-compaction Message array */
|
||||
export function convertPiToJevMessages(messages: AgentMessage[]): Message[] {
|
||||
const result: Message[] = [];
|
||||
|
||||
for (const msg of messages) {
|
||||
if (msg.role === "user") {
|
||||
result.push({
|
||||
role: "user",
|
||||
text: extractText((msg as any).content),
|
||||
toolUses: [],
|
||||
});
|
||||
} else if (msg.role === "assistant") {
|
||||
const assistantMsg = msg as any;
|
||||
const content = Array.isArray(assistantMsg.content) ? assistantMsg.content : [];
|
||||
|
||||
const textParts: string[] = [];
|
||||
const toolUses: ToolUse[] = [];
|
||||
|
||||
for (const block of content) {
|
||||
if (!block || typeof block !== "object") continue;
|
||||
if (block.type === "text" && typeof block.text === "string") {
|
||||
textParts.push(block.text);
|
||||
} else if (block.type === "thinking" && typeof block.thinking === "string") {
|
||||
textParts.push(`[thinking: ${block.thinking.slice(0, 200)}...]`);
|
||||
} else if (block.type === "toolCall") {
|
||||
toolUses.push({
|
||||
tool_use_id: block.id || `tool_${Math.random().toString(36).slice(2, 9)}`,
|
||||
tool: block.name || "unknown",
|
||||
input: block.arguments || {},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
result.push({
|
||||
role: "assistant",
|
||||
text: textParts.join("\n"),
|
||||
toolUses,
|
||||
});
|
||||
} else if (msg.role === "toolResult") {
|
||||
const toolMsg = msg as any;
|
||||
const tool_use_id = toolMsg.toolCallId || "unknown";
|
||||
const text = extractText(toolMsg.content);
|
||||
const isError = Boolean(toolMsg.isError);
|
||||
|
||||
result.push({
|
||||
role: "user",
|
||||
text: "",
|
||||
toolUses: [],
|
||||
toolResults: [
|
||||
{
|
||||
tool_use_id,
|
||||
text,
|
||||
isError,
|
||||
},
|
||||
],
|
||||
});
|
||||
} else if (msg.role === "bashExecution") {
|
||||
const bashMsg = msg as any;
|
||||
const tool_use_id = `bash_${Math.random().toString(36).slice(2, 9)}`;
|
||||
const command = bashMsg.command || "";
|
||||
const output = bashMsg.output || "";
|
||||
const isError = (bashMsg.exitCode ?? 0) !== 0;
|
||||
|
||||
// Associate as assistant call + user result
|
||||
result.push({
|
||||
role: "assistant",
|
||||
text: "",
|
||||
toolUses: [
|
||||
{
|
||||
tool_use_id,
|
||||
tool: "bash",
|
||||
input: { command },
|
||||
},
|
||||
],
|
||||
});
|
||||
result.push({
|
||||
role: "user",
|
||||
text: "",
|
||||
toolUses: [],
|
||||
toolResults: [
|
||||
{
|
||||
tool_use_id,
|
||||
text: output,
|
||||
isError,
|
||||
},
|
||||
],
|
||||
});
|
||||
} else if (msg.role === "branchSummary" || msg.role === "compactionSummary") {
|
||||
const sumMsg = msg as any;
|
||||
result.push({
|
||||
role: "user",
|
||||
text: `[Previous Summary]: ${sumMsg.summary || ""}`,
|
||||
toolUses: [],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Serialize the Jev-pruned message list into an exact, verbatim transcript.
|
||||
* Dropped calls/results vanish; kept content stays exact (no LLM summarization). */
|
||||
export function serializePrunedTranscript(messages: readonly Message[]): string {
|
||||
const lines: string[] = [];
|
||||
for (const m of messages) {
|
||||
if (m.role === "user") {
|
||||
const text = m.text.trim();
|
||||
const results = (m.toolResults ?? []).map((r) =>
|
||||
` [tool_result${r.isError ? " ERROR" : ""}] ${r.text}`
|
||||
);
|
||||
if (text) lines.push(`USER: ${text}`);
|
||||
if (results.length) lines.push(...results);
|
||||
} else if (m.role === "assistant") {
|
||||
const text = m.text.trim();
|
||||
if (text) lines.push(`ASSISTANT: ${text}`);
|
||||
for (const t of m.toolUses) {
|
||||
const input = JSON.stringify(t.input ?? {});
|
||||
const clipped = input.length > 300 ? `${input.slice(0, 300)}…` : input;
|
||||
lines.push(` [tool_call ${t.tool}] ${clipped}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/** Format decisions + stats as a compact header (audit trail only, content is the transcript). */
|
||||
function formatJevHeader(result: any): string {
|
||||
const { decisions, stats } = result;
|
||||
const kept = decisions.filter((d: any) => d.action === "keep").length;
|
||||
const truncated = decisions.filter((d: any) => d.action === "drop_result").length;
|
||||
const dropped = decisions.filter((d: any) => d.action === "drop_call").length;
|
||||
return (
|
||||
`## Context pruned by fast-jev-compaction (Jev System One)\n\n` +
|
||||
`Dropped ${dropped} stale tool call(s), truncated ${truncated} result(s), kept ${kept} intact. ` +
|
||||
`Messages ${stats.messagesBefore}\u2192${stats.messagesAfter}, chars ${stats.charsBefore}\u2192${stats.charsAfter} ` +
|
||||
`(${(reductionRatio(result) * 100).toFixed(1)}% reduction). User/assistant text is verbatim; nothing was summarized by an LLM.`
|
||||
);
|
||||
}
|
||||
|
||||
export default function fastJevCompactionExtension(pi: ExtensionAPI) {
|
||||
pi.on("session_before_compact", async (event, ctx) => {
|
||||
const apiKey = process.env.JEV_API_KEY || process.env.TYPESAFE_API_KEY;
|
||||
|
||||
if (!apiKey) {
|
||||
ctx.ui.notify("Fast-Jev: JEV_API_KEY not set, using default compaction", "warning");
|
||||
return;
|
||||
}
|
||||
|
||||
const { preparation, signal } = event;
|
||||
const { messagesToSummarize, turnPrefixMessages, tokensBefore, firstKeptEntryId } = preparation;
|
||||
|
||||
const allPiMessages = [...messagesToSummarize, ...(turnPrefixMessages || [])];
|
||||
|
||||
if (allPiMessages.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.ui.notify(
|
||||
`Fast-Jev Compaction: evaluating ${allPiMessages.length} messages with Jev System One...`,
|
||||
"info"
|
||||
);
|
||||
|
||||
try {
|
||||
const jevMessages = convertPiToJevMessages(allPiMessages);
|
||||
|
||||
const result = await compactMessages(jevMessages, {
|
||||
apiKey,
|
||||
preserveRecentMessages: 4,
|
||||
keepThreshold: 0.5,
|
||||
});
|
||||
|
||||
const savedRatio = reductionRatio(result);
|
||||
|
||||
if (savedRatio < 0.15) {
|
||||
ctx.ui.notify(
|
||||
`Fast-Jev: low reduction (${(savedRatio * 100).toFixed(1)}%), using default compaction`,
|
||||
"info"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// The summary holds the EXACT pruned transcript (kept content verbatim,
|
||||
// dropped tool calls/results removed) — not an LLM summary.
|
||||
const transcript = serializePrunedTranscript(result.messages);
|
||||
const summary = `${formatJevHeader(result)}\n\n---\n\n${transcript}`;
|
||||
|
||||
ctx.ui.notify(
|
||||
`Fast-Jev Compaction complete: reduced ${(savedRatio * 100).toFixed(1)}% of tool content`,
|
||||
"info"
|
||||
);
|
||||
|
||||
return {
|
||||
compaction: {
|
||||
summary,
|
||||
firstKeptEntryId,
|
||||
tokensBefore,
|
||||
},
|
||||
};
|
||||
} catch (error: any) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
ctx.ui.notify(`Fast-Jev Compaction failed: ${message}, falling back to default`, "warning");
|
||||
return;
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user