/** * Vendored token-estimation and max-tokens clamp helpers from pi-ai. * * The compaction extension needs `clampMaxTokensToContext` (from pi-ai's * `api/simple-options`) and `estimateMessageTokens` (from `utils/estimate`) to * predict the exact `max_tokens` pi's `completeSimple` will send. Importing * those deep subpaths directly breaks under the bundled app: pi's extension * loader takes an `alias` branch when pi is imported as a library (not the CLI), * and jiti aliases match `@earendil-works/pi-ai` by prefix, rewriting every * `@earendil-works/pi-ai/` onto pi-ai's compat entry — so * `@earendil-works/pi-ai/api/simple-options` resolves to the nonexistent * `compat.js/api/simple-options` and the app cannot load the extension set * (distribution review 2026-09-09, H1). Only the bare specifier and `/compat`, * `/oauth`, `/providers/all` are aliased exactly; every other subpath is dead * under the app. * * So the two consumed helpers (`clampMaxTokensToContext`, `estimateMessageTokens`) * and the private estimator chain they depend on are copied here from pi-ai * 0.85.0, importing only types from the bare specifier. The two exported helpers * are byte-identical to upstream; the one adaptation is the internal * `estimateContextTokens`, narrowed to accept only `Context` (the sole shape we * call it with) rather than upstream's `Context | Message[]` overload — the * Message[] branch would be dead code here. `test/unit/pi-ai-estimate.test.ts` * imports the real deep paths from node_modules and asserts the two exported * helpers produce identical numbers, so an upstream change is caught rather than * silently drifting. */ import type { Api, Context, ImageContent, Message, Model, TextContent, Tool, Usage } from "@earendil-works/pi-ai"; const CHARS_PER_TOKEN = 4; const ESTIMATED_IMAGE_CHARS = 4800; const CONTEXT_SAFETY_TOKENS = 4096; const MIN_MAX_TOKENS = 1; function calculateContextTokens(usage: Usage): number { return usage.totalTokens || usage.input + usage.output + usage.cacheRead + usage.cacheWrite; } function safeJsonStringify(value: unknown): string { try { return JSON.stringify(value) ?? "undefined"; } catch { return "[unserializable]"; } } function estimateTextAndImageContentChars(content: string | Array): number { if (typeof content === "string") return content.length; let chars = 0; for (const block of content) chars += block.type === "text" ? block.text.length : ESTIMATED_IMAGE_CHARS; return chars; } function estimateTextTokens(text: string): number { return Math.ceil(text.length / CHARS_PER_TOKEN); } function estimateTextAndImageContentTokens(content: string | Array): number { return Math.ceil(estimateTextAndImageContentChars(content) / CHARS_PER_TOKEN); } export function estimateMessageTokens(message: Message): number { let chars = 0; if (message.role === "user") return estimateTextAndImageContentTokens(message.content); if (message.role === "toolResult") return estimateTextAndImageContentTokens(message.content); for (const block of message.content) { if (block.type === "text") { chars += block.text.length; } else if (block.type === "thinking") { chars += block.thinking.length; } else { chars += block.name.length + safeJsonStringify(block.arguments).length; } } return Math.ceil(chars / CHARS_PER_TOKEN); } interface ContextUsageEstimate { tokens: number; usageTokens: number; trailingTokens: number; lastUsageIndex: number | null; } function getLastAssistantUsageInfo(messages: readonly Message[]): { usage: Usage; index: number } | undefined { let latestPrefixTimestamp = Number.NEGATIVE_INFINITY; let usageInfo: { usage: Usage; index: number } | undefined; for (let i = 0; i < messages.length; i++) { const message = messages[i]; if (message.role === "assistant") { const assistant = message; // A newer prefix message was inserted after this response (for example, a // compaction summary), so its usage cannot describe the current prefix. const usageAppliesToPrefix = assistant.timestamp >= latestPrefixTimestamp; if ( usageAppliesToPrefix && assistant.stopReason !== "aborted" && assistant.stopReason !== "error" && calculateContextTokens(assistant.usage) > 0 ) { usageInfo = { usage: assistant.usage, index: i }; } } latestPrefixTimestamp = Math.max(latestPrefixTimestamp, message.timestamp); } return usageInfo; } function estimateMessages(messages: readonly Message[]): ContextUsageEstimate { const usageInfo = getLastAssistantUsageInfo(messages); if (usageInfo) { const usageTokens = calculateContextTokens(usageInfo.usage); let trailingTokens = 0; for (let i = usageInfo.index + 1; i < messages.length; i++) { trailingTokens += estimateMessageTokens(messages[i]); } return { tokens: usageTokens + trailingTokens, usageTokens, trailingTokens, lastUsageIndex: usageInfo.index }; } let tokens = 0; for (const message of messages) tokens += estimateMessageTokens(message); return { tokens, usageTokens: 0, trailingTokens: tokens, lastUsageIndex: null }; } function estimateToolsTokens(tools: Tool[] | undefined): number { if (!tools || tools.length === 0) return 0; return estimateTextTokens(safeJsonStringify(tools)); } function estimateContextTokens(context: Context): ContextUsageEstimate { const estimate = estimateMessages(context.messages); if (estimate.lastUsageIndex !== null) { const addedNames = new Set( context.messages .slice(estimate.lastUsageIndex + 1) .filter((message) => message.role === "toolResult") .flatMap((message) => (message.role === "toolResult" ? message.addedToolNames ?? [] : [])), ); const addedToolTokens = estimateToolsTokens(context.tools?.filter((tool) => addedNames.has(tool.name))); return { tokens: estimate.tokens + addedToolTokens, usageTokens: estimate.usageTokens, trailingTokens: estimate.trailingTokens + addedToolTokens, lastUsageIndex: estimate.lastUsageIndex, }; } const prefixTokens = (context.systemPrompt ? estimateTextTokens(context.systemPrompt) : 0) + estimateToolsTokens(context.tools); return { tokens: estimate.tokens + prefixTokens, usageTokens: estimate.usageTokens, trailingTokens: estimate.trailingTokens + prefixTokens, lastUsageIndex: estimate.lastUsageIndex, }; } export function clampMaxTokensToContext(model: Model, context: Context, maxTokens: number): number { if (model.contextWindow <= 0) return Math.max(MIN_MAX_TOKENS, maxTokens); const available = model.contextWindow - estimateContextTokens(context).tokens - CONTEXT_SAFETY_TOKENS; return Math.min(maxTokens, Math.max(MIN_MAX_TOKENS, available)); }