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:
2026-09-18 12:59:38 +10:00
parent d89e848b8d
commit bf4494c49d
10 changed files with 5438 additions and 0 deletions

View 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;
}
});
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,16 @@
{
"name": "fast-jev-compaction",
"version": "1.0.0",
"description": "Fast Jev System One context compaction extension for Pi coding agent",
"type": "module",
"main": "index.ts",
"pi": {
"extensions": ["./index.ts"]
},
"dependencies": {},
"peerDependencies": {
"@earendil-works/pi-agent-core": "*",
"@earendil-works/pi-coding-agent": "*"
},
"devDependencies": {}
}

View File

@@ -0,0 +1,43 @@
import { buildJevRequest, parseJevResponse } from './request.js';
import type { JevAsker, JevQuestions, JevResponse, JevState } from './types.js';
export interface JevClientOptions {
/** Defaults to `process.env.TYPESAFE_API_KEY`. */
apiKey?: string;
/** Defaults to `jev-latest`. */
model?: string;
/** Defaults to the System One endpoint. */
baseUrl?: string;
/** Defaults to the global `fetch`. */
fetch?: typeof fetch;
}
/** Asks Jev over HTTP with the global `fetch` (or an injected one). */
export class JevClient implements JevAsker {
private readonly apiKey: string;
private readonly model: string | undefined;
private readonly baseUrl: string | undefined;
private readonly fetcher: typeof fetch;
constructor(options: JevClientOptions = {}) {
this.apiKey = options.apiKey ?? process.env.TYPESAFE_API_KEY ?? '';
this.model = options.model;
this.baseUrl = options.baseUrl;
this.fetcher = options.fetch ?? fetch;
}
async ask(state: JevState, questions: JevQuestions): Promise<JevResponse> {
if (!this.apiKey) throw new Error('TYPESAFE_API_KEY is not configured');
const request = buildJevRequest(
{ apiKey: this.apiKey, model: this.model, baseUrl: this.baseUrl },
state,
questions,
);
const response = await this.fetcher(request.url, {
method: request.method,
headers: request.headers,
body: request.body,
});
return parseJevResponse(response.status, response.ok, await response.text());
}
}

View File

@@ -0,0 +1,309 @@
import { noulAnswer } from './request.js';
import { collectToolCalls, estimateTokens, fitState } from './state.js';
import type {
CallAnswer,
CallDecision,
CompactOptions,
CompactResult,
CompactionState,
JevAsker,
JevQuestions,
Message,
ResolvedCompactOptions,
ToolCall,
ToolUse,
} from './types.js';
export const DEFAULT_OPTIONS: ResolvedCompactOptions = {
goal: '',
keepThreshold: 0.5,
preserveRecentMessages: 6,
maxStateTokens: 25_000,
maxRequestTokens: 30_000,
truncateHeadChars: 300,
};
/** Tokens the request envelope (`model`, key names) adds around state and questions. */
const REQUEST_OVERHEAD_TOKENS = 20;
function finite(value: number | undefined, fallback: number): number {
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
}
export function resolveOptions(options: CompactOptions = {}): ResolvedCompactOptions {
return {
goal: options.goal ?? DEFAULT_OPTIONS.goal,
keepThreshold: finite(options.keepThreshold, DEFAULT_OPTIONS.keepThreshold),
preserveRecentMessages: Math.max(
0,
Math.floor(
finite(options.preserveRecentMessages, DEFAULT_OPTIONS.preserveRecentMessages),
),
),
maxStateTokens: Math.max(1, finite(options.maxStateTokens, DEFAULT_OPTIONS.maxStateTokens)),
maxRequestTokens: Math.max(
1,
finite(options.maxRequestTokens, DEFAULT_OPTIONS.maxRequestTokens),
),
truncateHeadChars: Math.max(
0,
Math.floor(finite(options.truncateHeadChars, DEFAULT_OPTIONS.truncateHeadChars)),
),
};
}
/** The two `noul` questions asked about one call: keep the call, keep its result. */
export function questionsFor(call: ToolCall): JevQuestions {
return {
[`call_${call.id}`]: {
type: 'noul',
instructions: `Tool call ${call.id} (${call.tool}) should stay in the history: knowing this call was made, with its input, still matters for what the assistant does next`,
},
[`result_${call.id}`]: {
type: 'noul',
instructions: `The full output of tool call ${call.id} (${call.tool}, ${call.resultChars} chars) should stay in the history verbatim: the assistant still needs its contents and re-running the tool would not do`,
},
};
}
/**
* Splits the candidate calls into batches whose questions, together with the
* (always complete) state, fit one request.
*/
export function batchCalls(
calls: readonly ToolCall[],
stateTokens: number,
options: Pick<ResolvedCompactOptions, 'maxRequestTokens'>,
): ToolCall[][] {
const budget = options.maxRequestTokens - stateTokens - REQUEST_OVERHEAD_TOKENS;
const batches: ToolCall[][] = [];
let current: ToolCall[] = [];
let currentTokens = 0;
for (const call of calls) {
const tokens = estimateTokens(JSON.stringify(questionsFor(call)));
if (current.length > 0 && currentTokens + tokens > budget) {
batches.push(current);
current = [];
currentTokens = 0;
}
if (current.length === 0 && tokens > budget) {
throw new Error(
`state leaves no room for questions (~${stateTokens} of ${options.maxRequestTokens} tokens)`,
);
}
current.push(call);
currentTokens += tokens;
}
if (current.length > 0) batches.push(current);
return batches;
}
export function decideCall(
call: Pick<ToolCall, 'id' | 'tool' | 'pinned'>,
answer: CallAnswer,
options: Pick<ResolvedCompactOptions, 'keepThreshold'>,
): CallDecision {
const base = { id: call.id, tool: call.tool, ...answer };
if (call.pinned) return { ...base, action: 'keep', reason: 'pinned' };
if (answer.keepResult >= options.keepThreshold) {
return { ...base, action: 'keep', reason: 'kept' };
}
if (answer.keepCall >= options.keepThreshold) {
return { ...base, action: 'drop_result', reason: 'result_dropped' };
}
return { ...base, action: 'drop_call', reason: 'call_dropped' };
}
async function askBatch(
asker: JevAsker,
state: CompactionState,
batch: readonly ToolCall[],
): Promise<Map<string, CallAnswer>> {
const questions: JevQuestions = Object.assign({}, ...batch.map(questionsFor));
const { answers } = await asker.ask(state, questions);
return new Map(
batch.map((call) => [
call.id,
{
keepCall: noulAnswer(answers, `call_${call.id}`),
keepResult: noulAnswer(answers, `result_${call.id}`),
},
]),
);
}
function truncatedResultText(text: string, isError: boolean, headChars: number): string {
if (text.length <= headChars + 120) return text;
const head = headChars > 0 ? `${text.slice(0, headChars)}\n` : '';
return `${head}[fast-jev-compaction truncated ${text.length - headChars} chars of this tool result${
isError ? ' (error)' : ''
}; re-run the tool if needed]`;
}
/**
* Rebuilds the conversation from the decisions. A dropped call disappears
* together with its result; a dropped result keeps a bounded head and note.
* Messages that lose all their content are removed; untouched messages are
* returned as the same objects they came in as.
*/
export function applyDecisions(
messages: readonly Message[],
decisions: readonly CallDecision[],
calls: readonly ToolCall[],
headChars: number,
): Message[] {
const byId = new Map(calls.map((call) => [call.id, call]));
const actions = new Map<string, CallDecision['action']>();
for (const decision of decisions) {
const call = byId.get(decision.id);
if (call && decision.action !== 'keep') actions.set(call.tool_use_id, decision.action);
}
const kept: Message[] = [];
for (const message of messages) {
const touched =
message.toolUses.some((tool) => actions.has(tool.tool_use_id)) ||
(message.toolResults ?? []).some((result) => actions.has(result.tool_use_id));
if (!touched) {
kept.push(message);
continue;
}
const toolUses = message.toolUses
.filter((tool) => actions.get(tool.tool_use_id) !== 'drop_call')
.map((tool) => {
if (actions.get(tool.tool_use_id) !== 'drop_result') return tool;
const text = truncatedResultText(
tool.text ?? '',
tool.isError ?? false,
headChars,
);
if ((tool.text ?? '') === text) return tool;
const copy: ToolUse = {
tool_use_id: tool.tool_use_id,
tool: tool.tool,
input: tool.input,
text,
};
if (tool.isError) copy.isError = true;
return copy;
});
const toolResults = (message.toolResults ?? [])
.filter((result) => actions.get(result.tool_use_id) !== 'drop_call')
.map((result) => {
if (actions.get(result.tool_use_id) !== 'drop_result') return result;
const text = truncatedResultText(result.text, result.isError ?? false, headChars);
return text === result.text
? result
: {
tool_use_id: result.tool_use_id,
text,
isError: result.isError,
};
});
if (
!message.toolUses.some(
(tool) => actions.get(tool.tool_use_id) === 'drop_call',
) &&
!(message.toolResults ?? []).some(
(result) => actions.get(result.tool_use_id) === 'drop_call',
) &&
toolUses.every((tool, index) => tool === message.toolUses[index]) &&
toolResults.every(
(result, index) => result === message.toolResults?.[index],
)
) {
kept.push(message);
continue;
}
if (message.text.trim().length === 0 && toolUses.length === 0 && toolResults.length === 0) {
continue;
}
const rebuilt: Message = { role: message.role, text: message.text, toolUses };
if (toolResults.length > 0) rebuilt.toolResults = toolResults;
kept.push(rebuilt);
}
return kept;
}
/** Characters of text, tool input and tool output a message holds. */
export function messageChars(message: Message): number {
let total = message.text.length;
for (const tool of message.toolUses) {
try {
total += JSON.stringify(tool.input).length;
} catch {
total += 20;
}
}
for (const result of message.toolResults ?? []) total += result.text.length;
return total;
}
export function reductionRatio(result: Pick<CompactResult, 'stats'>): number {
const { charsBefore, charsAfter } = result.stats;
return charsBefore === 0 ? 0 : (charsBefore - charsAfter) / charsBefore;
}
function count(decisions: readonly CallDecision[], reason: CallDecision['reason']): number {
return decisions.filter((decision) => decision.reason === reason).length;
}
/**
* Compacts a transcript by asking Jev, for every tool call outside the pinned
* first and newest messages, whether the call and whether its result must
* stay. The whole history (results omitted, fitted into `maxStateTokens`) is
* sent as state with every batch of questions. Throws when Jev fails or the
* history cannot be fitted; the caller decides whether to fall back.
*/
export async function compact(
messages: readonly Message[],
asker: JevAsker,
options: CompactOptions = {},
): Promise<CompactResult> {
const started = Date.now();
const resolved = resolveOptions(options);
const calls = collectToolCalls(messages, resolved.preserveRecentMessages);
const candidates = calls.filter((call) => !call.pinned);
const charsBefore = messages.reduce((sum, message) => sum + messageChars(message), 0);
let fitted: { tokens: number; stage: string } = { tokens: 0, stage: '' };
let batches: ToolCall[][] = [];
const answers = new Map<string, CallAnswer>();
if (candidates.length > 0) {
const state = fitState(messages, calls, resolved);
fitted = state;
batches = batchCalls(candidates, state.tokens, resolved);
const answered = await Promise.all(
batches.map((batch) => askBatch(asker, state.state, batch)),
);
for (const map of answered) for (const [id, answer] of map) answers.set(id, answer);
}
const decisions = calls.map((call) =>
decideCall(call, answers.get(call.id) ?? { keepCall: 1, keepResult: 1 }, resolved),
);
const kept = applyDecisions(
messages,
decisions,
calls,
resolved.truncateHeadChars,
);
return {
messages: kept,
decisions,
stats: {
messagesBefore: messages.length,
messagesAfter: kept.length,
charsBefore,
charsAfter: kept.reduce((sum, message) => sum + messageChars(message), 0),
calls: calls.length,
kept: count(decisions, 'kept'),
resultsDropped: count(decisions, 'result_dropped'),
callsDropped: count(decisions, 'call_dropped'),
pinned: count(decisions, 'pinned'),
stateTokens: fitted.tokens,
stateStage: fitted.stage,
requests: batches.length,
ms: Date.now() - started,
},
};
}

View File

@@ -0,0 +1,6 @@
export * from './types.js';
export * from './request.js';
export * from './client.js';
export * from './state.js';
export * from './compact.js';
export * from './messages.js';

View File

@@ -0,0 +1,13 @@
import { JevClient, type JevClientOptions } from './client.js';
import { compact } from './compact.js';
import type { CompactOptions, CompactResult, Message } from './types.js';
export type CompactMessagesOptions = CompactOptions & JevClientOptions;
/** `compact` with a `JevClient` built from the options (key from `TYPESAFE_API_KEY` by default). */
export function compactMessages(
messages: readonly Message[],
options: CompactMessagesOptions = {},
): Promise<CompactResult> {
return compact(messages, new JevClient(options), options);
}

View File

@@ -0,0 +1,80 @@
import type { JevAnswer, JevQuestions, JevResponse, JevState } from './types.js';
export const SYSTEM_ONE_URL = 'https://api.typesafe.ai/v1/systemone';
export const DEFAULT_MODEL = 'jev-latest';
export interface JevRequest {
url: string;
method: 'POST';
headers: Record<string, string>;
body: string;
}
/** The HTTP request for one Jev call, for any fetch-like transport. */
export function buildJevRequest(
params: {
apiKey: string;
model?: string;
baseUrl?: string;
},
state: JevState,
questions: JevQuestions,
): JevRequest {
return {
url: params.baseUrl ?? SYSTEM_ONE_URL,
method: 'POST',
headers: {
authorization: `Bearer ${params.apiKey}`,
'content-type': 'application/json',
},
body: JSON.stringify({
model: params.model ?? DEFAULT_MODEL,
state,
questions,
}),
};
}
/** Validates a Jev response body; throws on anything but an `answers` object. */
export function parseJevResponse(
status: number,
ok: boolean,
text: string,
): JevResponse {
if (!ok) {
throw new Error(`Jev request failed (${status}): ${text.slice(0, 200)}`);
}
let parsed: unknown;
try {
parsed = JSON.parse(text);
} catch {
throw new Error('Jev returned malformed JSON');
}
if (
parsed === null ||
typeof parsed !== 'object' ||
!('answers' in parsed) ||
parsed.answers === null ||
typeof parsed.answers !== 'object'
) {
throw new Error('Jev response is missing answers');
}
return parsed as JevResponse;
}
/** The `noul` probability of one answer; throws when it is not there. */
export function noulAnswer(
answers: Record<string, JevAnswer>,
name: string,
): number {
const answer = answers[name];
if (
!answer ||
!('noul' in answer) ||
typeof answer.noul !== 'number' ||
!Number.isFinite(answer.noul)
) {
throw new Error(`Invalid Jev answer for ${name}`);
}
return answer.noul;
}

View File

@@ -0,0 +1,304 @@
import type {
CompactionState,
FittedState,
HistoryEntry,
Message,
ResolvedCompactOptions,
ToolCall,
ToolResult,
} from './types.js';
export const STATE_CONTEXT =
'A coding assistant conversation is being compacted to free context. `history` is the whole conversation so far, oldest first; tool outputs are replaced by a short `result` note and long texts may be abridged. Each question asks whether one tool call, or the full output of that call, still needs to stay in the history verbatim. Whatever is not kept is deleted permanently, but the assistant can always re-run a tool or re-read a file.';
/** Successive caps on the serialised tool input included per call. */
const INPUT_CHARS = [1000, 200, 60] as const;
const TEXT_HEAD = 400;
const TEXT_TAIL = 150;
const TOKEN_PIECES = /[A-Za-z]+|\d+|[^\sA-Za-z\d]/g;
/**
* Estimates tokens without a tokenizer: a word costs one token per six
* letters, a digit half a token, any other symbol nine tenths. Calibrated
* against the usage Jev reports for real transcripts, where it lands 2–18%
* above the true count; a plain characters-per-token ratio undercounts the
* JSON-heavy states by up to 40%.
*/
export function estimateTokens(text: string): number {
let tokens = 0;
for (const [piece] of text.matchAll(TOKEN_PIECES)) {
const first = piece.charCodeAt(0);
if (first >= 48 && first <= 57) tokens += piece.length / 2;
else if ((first >= 65 && first <= 90) || (first >= 97 && first <= 122)) {
tokens += 1 + Math.floor((piece.length - 1) / 6);
} else tokens += 0.9;
}
return Math.ceil(tokens);
}
export function truncate(text: string, limit: number): string {
return text.length <= limit ? text : `${text.slice(0, Math.max(0, limit - 1))}…`;
}
function abridge(text: string, head: number, tail: number): string {
if (text.length <= head + tail + 40) return text;
const omitted = text.length - head - tail;
return `${text.slice(0, head)}\n[… ${omitted} chars omitted …]\n${text.slice(-tail)}`;
}
export function isPinned(
index: number,
total: number,
preserveRecentMessages: number,
): boolean {
return index === 0 || index >= total - preserveRecentMessages;
}
/**
* Pairs every tool_use with its tool_result by `tool_use_id`. Calls without a
* result are not candidates (there is nothing to drop yet).
*/
export function collectToolCalls(
messages: readonly Message[],
preserveRecentMessages: number,
): ToolCall[] {
const results = new Map<string, { index: number; result: ToolResult }>();
messages.forEach((message, index) => {
for (const result of message.toolResults ?? []) {
results.set(result.tool_use_id, { index, result });
}
});
const calls: ToolCall[] = [];
messages.forEach((message, callIndex) => {
for (const tool of message.toolUses) {
const found = results.get(tool.tool_use_id);
if (!found) continue;
calls.push({
id: `t${calls.length + 1}`,
tool_use_id: tool.tool_use_id,
tool: tool.tool,
input: tool.input,
callIndex,
resultIndex: found.index,
resultChars: found.result.text.length,
isError: found.result.isError ?? false,
pinned:
isPinned(callIndex, messages.length, preserveRecentMessages) ||
isPinned(found.index, messages.length, preserveRecentMessages),
});
}
});
return calls;
}
function inputText(input: Record<string, unknown>, limit: number): string {
let json = '';
try {
json = JSON.stringify(input);
} catch {
json = '[unserializable input]';
}
return truncate(json, limit);
}
function resultNote(call: ToolCall): string {
return `${call.isError ? 'error' : 'ok'}, ${call.resultChars} chars (omitted)`;
}
/** One call as a single line, for when the structured form is too costly. */
function compactCall(call: ToolCall): string {
const input = Object.entries(call.input)
.map(([key, value]) => {
const text = typeof value === 'string' ? value : inputText({ [key]: value }, 200);
return `${key}=${text.replace(/\s+/g, ' ')}`;
})
.join(' ');
return `${call.id} ${call.tool} ${truncate(input, INPUT_CHARS[2])} → ${
call.isError ? 'error' : 'ok'
} ${call.resultChars}ch`;
}
/**
* Folds runs of adjacent call-only entries into one entry each, so the
* per-entry envelope is paid once per run; the call lines keep their ids.
*/
function mergeCallRuns(history: readonly HistoryEntry[], pinned: (e: HistoryEntry) => boolean): HistoryEntry[] {
const merged: HistoryEntry[] = [];
for (const entry of history) {
const previous = merged[merged.length - 1];
const foldable = (e: HistoryEntry): boolean =>
!pinned(e) && e.text.length === 0 && typeof e.tool_calls?.[0] === 'string';
if (previous && foldable(previous) && foldable(entry) && previous.role === entry.role) {
previous.tool_calls = [...(previous.tool_calls as string[]), ...(entry.tool_calls as string[])];
continue;
}
merged.push({ ...entry });
}
return merged;
}
function callsByMessage(calls: readonly ToolCall[]): Map<number, ToolCall[]> {
const byMessage = new Map<number, ToolCall[]>();
for (const call of calls) {
const list = byMessage.get(call.callIndex) ?? [];
list.push(call);
byMessage.set(call.callIndex, list);
}
return byMessage;
}
function historyEntries(
messages: readonly Message[],
calls: readonly ToolCall[],
inputChars: number,
): HistoryEntry[] {
const byMessage = callsByMessage(calls);
const entries: HistoryEntry[] = [];
messages.forEach((message, i) => {
const toolCalls = (byMessage.get(i) ?? []).map((call) => ({
id: call.id,
tool: call.tool,
input: inputText(call.input, inputChars),
result: resultNote(call),
}));
if (message.text.trim().length === 0 && toolCalls.length === 0) return;
const entry: HistoryEntry = { i, role: message.role, text: message.text };
if (toolCalls.length > 0) entry.tool_calls = toolCalls;
entries.push(entry);
});
return entries;
}
/** The last three user prompts, as the default `goal`. */
export function goalFromMessages(messages: readonly Message[]): string {
return messages
.filter(
(message) =>
message.role === 'user' &&
message.text.trim().length > 0 &&
(message.toolResults ?? []).length === 0,
)
.slice(-3)
.map((message) => truncate(message.text, 500))
.join('\n');
}
/**
* Builds the Jev state from the whole conversation and shrinks it in stages
* until it fits `maxStateTokens`: tool inputs are truncated, then long texts
* are abridged oldest-first (pinned messages last), then old messages collapse
* to a one-line note, then old tool calls shrink to one line each, then old
* messages that carry no call are left out, then runs of old call-only
* messages are folded into one entry. Throws when even that is too big.
*/
export function fitState(
messages: readonly Message[],
calls: readonly ToolCall[],
options: Pick<ResolvedCompactOptions, 'maxStateTokens' | 'preserveRecentMessages' | 'goal'>,
): FittedState {
const goal = options.goal || goalFromMessages(messages);
const stateOf = (history: HistoryEntry[]): CompactionState => ({
context: STATE_CONTEXT,
goal,
history,
});
const entryTokens = (entry: HistoryEntry): number => estimateTokens(JSON.stringify(entry)) + 1;
const baseTokens = estimateTokens(JSON.stringify(stateOf([])));
const fitted = (history: HistoryEntry[], tokens: number, stage: string): FittedState => ({
state: stateOf(history),
tokens,
stage,
});
let history: HistoryEntry[] = [];
let perEntry: number[] = [];
let tokens = 0;
const rebuild = (inputChars: number): void => {
history = historyEntries(messages, calls, inputChars);
perEntry = history.map(entryTokens);
tokens = baseTokens + perEntry.reduce((sum, n) => sum + n, 0);
};
const fits = (): boolean => tokens <= options.maxStateTokens;
const shrink = (index: number, change: (entry: HistoryEntry) => void): void => {
const entry = history[index];
if (!entry) return;
change(entry);
const now = entryTokens(entry);
tokens += now - (perEntry[index] ?? 0);
perEntry[index] = now;
};
rebuild(INPUT_CHARS[0]);
if (fits()) return fitted(history, tokens, 'full');
for (const limit of INPUT_CHARS.slice(1)) {
rebuild(limit);
if (fits()) return fitted(history, tokens, `inputs<=${limit}`);
}
const pinned = (entry: HistoryEntry): boolean =>
isPinned(entry.i, messages.length, options.preserveRecentMessages);
const indices = history.map((_, index) => index);
const order = [
...indices.filter((index) => !pinned(history[index]!)),
...indices.filter((index) => pinned(history[index]!)),
];
for (const index of order) {
const entry = history[index]!;
if (entry.text.length <= TEXT_HEAD + TEXT_TAIL + 40) continue;
shrink(index, (e) => {
e.text = abridge(e.text, TEXT_HEAD, TEXT_TAIL);
});
if (fits()) return fitted(history, tokens, 'texts abridged');
}
for (const index of order) {
const entry = history[index]!;
if (pinned(entry) || entry.text.length === 0) continue;
const original = messages[entry.i]?.text.length ?? entry.text.length;
shrink(index, (e) => {
e.text = `[… ${original} chars omitted …]`;
});
if (fits()) return fitted(history, tokens, 'old messages collapsed');
}
const byMessage = callsByMessage(calls);
for (const index of order) {
const entry = history[index]!;
const own = byMessage.get(entry.i);
if (pinned(entry) || !own) continue;
shrink(index, (e) => {
e.tool_calls = own.map(compactCall);
});
if (fits()) return fitted(history, tokens, 'old calls compacted');
}
const left = new Set<number>();
for (const index of order) {
const entry = history[index]!;
if (pinned(entry) || entry.tool_calls) continue;
left.add(index);
tokens -= perEntry[index] ?? 0;
if (fits()) {
return fitted(
history.filter((_, i) => !left.has(i)),
tokens,
'old messages left out',
);
}
}
history = mergeCallRuns(
history.filter((_, i) => !left.has(i)),
pinned,
);
perEntry = history.map(entryTokens);
tokens = baseTokens + perEntry.reduce((sum, n) => sum + n, 0);
if (fits()) return fitted(history, tokens, 'old calls merged');
throw new Error(
`history too large for Jev (~${tokens} tokens after truncation, limit ${options.maxStateTokens})`,
);
}

View File

@@ -0,0 +1,202 @@
export type Role = 'user' | 'assistant';
/**
* A tool_use block of an assistant message. `text` and `isError` mirror the
* outcome once the transcript holds it (Claude Code attaches them).
*/
export interface ToolUse {
tool_use_id: string;
tool: string;
input: Record<string, unknown>;
text?: string;
isError?: boolean;
}
/** A tool_result block of a user message. */
export interface ToolResult {
tool_use_id: string;
text: string;
isError?: boolean;
}
/**
* One transcript message. The shape is a subset of Claude Code's
* `SessionMessage`, so a session transcript can be passed in as is.
*/
export interface Message {
role: Role;
text: string;
toolUses: ToolUse[];
toolResults?: ToolResult[];
}
/** A tool call paired with its result by `tool_use_id`. */
export interface ToolCall {
/** Short id used in the Jev state and question names (`t1`, `t2`, ...). */
id: string;
tool_use_id: string;
tool: string;
input: Record<string, unknown>;
/** Index of the message holding the tool_use block. */
callIndex: number;
/** Index of the message holding the tool_result block. */
resultIndex: number;
resultChars: number;
isError: boolean;
/** In the first or the newest preserved messages; never a candidate. */
pinned: boolean;
}
export interface CallAnswer {
/** Jev's probability that the call itself still matters. */
keepCall: number;
/** Jev's probability that the full result still needs to stay verbatim. */
keepResult: number;
}
export type CallAction = 'keep' | 'drop_result' | 'drop_call';
export interface CallDecision extends CallAnswer {
id: string;
tool: string;
action: CallAction;
reason: 'pinned' | 'kept' | 'result_dropped' | 'call_dropped';
}
export interface HistoryToolCall {
id: string;
tool: string;
input: string;
result: string;
}
export interface HistoryEntry {
i: number;
role: Role;
text: string;
/** Structured per call, or one compact line per call once the state has to shrink. */
tool_calls?: HistoryToolCall[] | string[];
}
/** The state sent with every Jev request: the whole history, results omitted. */
export interface CompactionState {
context: string;
goal: string;
history: HistoryEntry[];
}
export interface FittedState {
state: CompactionState;
tokens: number;
/** Which fitting stage produced the state, for diagnostics. */
stage: string;
}
export interface CompactOptions {
/** Ongoing task description; defaults to the last few user prompts. */
goal?: string;
/** Minimum keep probability for a call or result to stay. Default 0.5. */
keepThreshold?: number;
/** Newest messages never touched (the first message is always kept). Default 6. */
preserveRecentMessages?: number;
/** Estimated token ceiling for the state. Default 25000. */
maxStateTokens?: number;
/** Estimated token ceiling for state plus one batch of questions. Default 30000. */
maxRequestTokens?: number;
/** Characters of a dropped tool result to retain. Default 300. */
truncateHeadChars?: number;
}
export interface ResolvedCompactOptions {
goal: string;
keepThreshold: number;
preserveRecentMessages: number;
maxStateTokens: number;
maxRequestTokens: number;
truncateHeadChars: number;
}
export interface CompactResult {
/** The compacted transcript; untouched messages are the input objects. */
messages: Message[];
decisions: CallDecision[];
stats: {
messagesBefore: number;
messagesAfter: number;
charsBefore: number;
charsAfter: number;
calls: number;
kept: number;
resultsDropped: number;
callsDropped: number;
pinned: number;
stateTokens: number;
/** Which fitting stage the state needed, '' when no request was made. */
stateStage: string;
requests: number;
ms: number;
};
}
/** The `state` of a Jev request: a string or any JSON-serialisable object. */
export type JevState = string | object;
export interface NoulQuestion {
type: 'noul';
instructions: string;
criteria?: {
true?: string;
false?: string;
};
}
export interface ChoiceQuestion {
type: 'choice';
instructions: string;
criteria: Record<string, string | null>;
}
export interface ScoreQuestion {
type: 'score';
instructions: string;
criteria: string[];
}
export type JevQuestion = NoulQuestion | ChoiceQuestion | ScoreQuestion;
export type JevQuestions = Record<string, JevQuestion>;
export interface NoulAnswer {
type?: 'noul';
noul: number;
}
export interface ChoiceAnswer {
type?: 'choice';
choice: string;
confidence: number;
probabilities: Record<string, number>;
}
export interface ScoreAnswer {
type?: 'score';
score: number;
confidence: number;
probabilities: Record<string, number>;
}
export type JevAnswer = NoulAnswer | ChoiceAnswer | ScoreAnswer;
export interface JevResponse {
model?: string;
answers: Record<string, JevAnswer>;
usage?: {
input_tokens?: number;
output_tokens?: number;
};
[key: string]: unknown;
}
/** Anything that can answer Jev questions: `JevClient`, or a host-provided adapter. */
export interface JevAsker {
ask(state: JevState, questions: JevQuestions): Promise<JevResponse>;
}