diff --git a/README.md b/README.md index cb46a24..c4b09bc 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Automatically reduces token usage in OpenCode by removing obsolete tool outputs from conversation history. -![DCP in action](dcp-demo3.png) +![DCP in action](dcp-demo5.png) ## Installation @@ -19,8 +19,6 @@ Add to your OpenCode config: Using `@latest` ensures you always get the newest version automatically when OpenCode starts. -> **Note:** If you use OAuth plugins (e.g., for Google or other services), place this plugin last in your `plugin` array to avoid interfering with their authentication flows. - Restart OpenCode. The plugin will automatically start optimizing your sessions. ## How Pruning Works @@ -49,6 +47,8 @@ LLM providers like Anthropic and OpenAI cache prompts based on exact prefix matc **Trade-off:** You lose some cache read benefits but gain larger token savings from reduced context size and performance improvements through reduced context poisoning. In most cases, the token savings outweigh the cache miss cost—especially in long sessions where context bloat becomes significant. +> **Note:** In testing, cache hit rates were approximately 65% with DCP enabled vs 85% without. + **Best use case:** Providers that count usage in requests, such as Github Copilot and Google Antigravity have no negative price impact. ## Configuration @@ -71,6 +71,8 @@ DCP uses its own config file: "debug": false, // Notification display: "off", "minimal", or "detailed" "pruneNotification": "detailed", + // Enable or disable slash commands (/dcp) + "commands": true, // Protect from pruning for message turns "turnProtection": { "enabled": false, @@ -126,6 +128,14 @@ DCP uses its own config file: +### Commands + +DCP provides a `/dcp` slash command: + +- `/dcp` — Shows available DCP commands +- `/dcp context` — Shows a breakdown of your current session's token usage by category (system, user, assistant, tools, etc.) and how much has been saved through pruning. +- `/dcp stats` — Shows cumulative pruning statistics across all sessions. + ### Turn Protection When enabled, turn protection prevents tool outputs from being pruned for a configurable number of message turns. This gives the AI time to reference recent tool outputs before they become prunable. Applies to both `discard` and `extract` tools, as well as automatic strategies. diff --git a/dcp-demo4.png b/dcp-demo4.png new file mode 100644 index 0000000..c2baf5f Binary files /dev/null and b/dcp-demo4.png differ diff --git a/dcp-demo5.png b/dcp-demo5.png new file mode 100644 index 0000000..a86e035 Binary files /dev/null and b/dcp-demo5.png differ diff --git a/dcp.schema.json b/dcp.schema.json index 39823f2..6e0b01a 100644 --- a/dcp.schema.json +++ b/dcp.schema.json @@ -26,6 +26,11 @@ "default": "detailed", "description": "Level of notification shown when pruning occurs" }, + "commands": { + "type": "boolean", + "default": true, + "description": "Enable DCP slash commands (/dcp)" + }, "turnProtection": { "type": "object", "description": "Protect recent tool outputs from being pruned", diff --git a/index.ts b/index.ts index 0802afb..6b39ed5 100644 --- a/index.ts +++ b/index.ts @@ -3,7 +3,11 @@ import { getConfig } from "./lib/config" import { Logger } from "./lib/logger" import { createSessionState } from "./lib/state" import { createDiscardTool, createExtractTool } from "./lib/strategies" -import { createChatMessageTransformHandler, createSystemPromptHandler } from "./lib/hooks" +import { + createChatMessageTransformHandler, + createCommandExecuteHandler, + createSystemPromptHandler, +} from "./lib/hooks" const plugin: Plugin = (async (ctx) => { const config = getConfig(ctx) @@ -64,8 +68,14 @@ const plugin: Plugin = (async (ctx) => { }), }, config: async (opencodeConfig) => { - // Add enabled tools to primary_tools by mutating the opencode config - // This works because config is cached and passed by reference + if (config.commands) { + opencodeConfig.command ??= {} + opencodeConfig.command["dcp"] = { + template: "", + description: "Show available DCP commands", + } + } + const toolsToAdd: string[] = [] if (config.tools.discard.enabled) toolsToAdd.push("discard") if (config.tools.extract.enabled) toolsToAdd.push("extract") @@ -81,6 +91,7 @@ const plugin: Plugin = (async (ctx) => { ) } }, + "command.execute.before": createCommandExecuteHandler(ctx.client, state, logger, config), } }) satisfies Plugin diff --git a/lib/commands/context.ts b/lib/commands/context.ts new file mode 100644 index 0000000..0c3a2fb --- /dev/null +++ b/lib/commands/context.ts @@ -0,0 +1,232 @@ +/** + * DCP Context command handler. + * Shows a visual breakdown of token usage in the current session. + */ + +import type { Logger } from "../logger" +import type { SessionState, WithParts } from "../state" +import { sendIgnoredMessage } from "../ui/notification" +import { formatTokenCount } from "../ui/utils" +import { isMessageCompacted } from "../shared-utils" +import { isIgnoredUserMessage } from "../messages/utils" +import { countTokens, getCurrentParams } from "../strategies/utils" +import type { AssistantMessage, TextPart, ToolPart } from "@opencode-ai/sdk/v2" + +export interface ContextCommandContext { + client: any + state: SessionState + logger: Logger + sessionId: string + messages: WithParts[] +} + +interface TokenBreakdown { + system: number + user: number + assistant: number + reasoning: number + tools: number + pruned: number + total: number +} + +function analyzeTokens(state: SessionState, messages: WithParts[]): TokenBreakdown { + const breakdown: TokenBreakdown = { + system: 0, + user: 0, + assistant: 0, + reasoning: 0, + tools: 0, + pruned: state.stats.totalPruneTokens, + total: 0, + } + + let firstAssistant: AssistantMessage | undefined + for (const msg of messages) { + if (msg.info.role === "assistant") { + const assistantInfo = msg.info as AssistantMessage + if (assistantInfo.tokens?.input > 0 || assistantInfo.tokens?.cache?.read > 0) { + firstAssistant = assistantInfo + break + } + } + } + + let firstUserTokens = 0 + for (const msg of messages) { + if (msg.info.role === "user" && !isIgnoredUserMessage(msg)) { + for (const part of msg.parts) { + if (part.type === "text") { + const textPart = part as TextPart + firstUserTokens += countTokens(textPart.text || "") + } + } + break + } + } + + // Calculate system tokens: first response's total input minus first user message + if (firstAssistant) { + const firstInput = + (firstAssistant.tokens?.input || 0) + (firstAssistant.tokens?.cache?.read || 0) + breakdown.system = Math.max(0, firstInput - firstUserTokens) + } + + let lastAssistant: AssistantMessage | undefined + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i] + if (msg.info.role === "assistant") { + const assistantInfo = msg.info as AssistantMessage + if (assistantInfo.tokens?.output > 0) { + lastAssistant = assistantInfo + break + } + } + } + + // Get total from API + // Total = input + output + reasoning + cache.read + cache.write + const apiInput = lastAssistant?.tokens?.input || 0 + const apiOutput = lastAssistant?.tokens?.output || 0 + const apiReasoning = lastAssistant?.tokens?.reasoning || 0 + const apiCacheRead = lastAssistant?.tokens?.cache?.read || 0 + const apiCacheWrite = lastAssistant?.tokens?.cache?.write || 0 + const apiTotal = apiInput + apiOutput + apiReasoning + apiCacheRead + apiCacheWrite + + for (const msg of messages) { + if (isMessageCompacted(state, msg)) { + continue + } + + if (msg.info.role === "user" && isIgnoredUserMessage(msg)) { + continue + } + + const info = msg.info + const role = info.role + + for (const part of msg.parts) { + switch (part.type) { + case "text": { + const textPart = part as TextPart + const tokens = countTokens(textPart.text || "") + if (role === "user") { + breakdown.user += tokens + } else { + breakdown.assistant += tokens + } + break + } + case "tool": { + const toolPart = part as ToolPart + + if (toolPart.state?.input) { + const inputStr = + typeof toolPart.state.input === "string" + ? toolPart.state.input + : JSON.stringify(toolPart.state.input) + breakdown.tools += countTokens(inputStr) + } + + if (toolPart.state?.status === "completed" && toolPart.state?.output) { + const outputStr = + typeof toolPart.state.output === "string" + ? toolPart.state.output + : JSON.stringify(toolPart.state.output) + breakdown.tools += countTokens(outputStr) + } + break + } + } + } + } + + breakdown.tools = Math.max(0, breakdown.tools - breakdown.pruned) + + // Calculate reasoning as the difference between API total and our counted parts + // This handles both interleaved thinking and non-interleaved models correctly + const countedParts = breakdown.system + breakdown.user + breakdown.assistant + breakdown.tools + breakdown.reasoning = Math.max(0, apiTotal - countedParts) + + breakdown.total = apiTotal + + return breakdown +} + +function createBar(value: number, maxValue: number, width: number, char: string = "█"): string { + if (maxValue === 0) return "" + const filled = Math.round((value / maxValue) * width) + const bar = char.repeat(Math.max(0, filled)) + return bar +} + +function formatContextMessage(breakdown: TokenBreakdown): string { + const lines: string[] = [] + const barWidth = 30 + + const values = [ + breakdown.system, + breakdown.user, + breakdown.assistant, + breakdown.reasoning, + breakdown.tools, + ] + const maxValue = Math.max(...values) + + const categories = [ + { label: "System", value: breakdown.system, char: "█" }, + { label: "User", value: breakdown.user, char: "▓" }, + { label: "Assistant", value: breakdown.assistant, char: "▒" }, + { label: "Reasoning", value: breakdown.reasoning, char: "░" }, + { label: "Tools", value: breakdown.tools, char: "⣿" }, + ] as const + + lines.push("╭───────────────────────────────────────────────────────────╮") + lines.push("│ DCP Context Analysis │") + lines.push("╰───────────────────────────────────────────────────────────╯") + lines.push("") + lines.push("Session Context Breakdown:") + lines.push("─".repeat(60)) + lines.push("") + + for (const cat of categories) { + const bar = createBar(cat.value, maxValue, barWidth, cat.char) + const percentage = + breakdown.total > 0 ? ((cat.value / breakdown.total) * 100).toFixed(1) : "0.0" + const labelWithPct = `${cat.label.padEnd(9)} ${percentage.padStart(5)}% ` + const valueStr = formatTokenCount(cat.value).padStart(13) + lines.push(`${labelWithPct}│${bar.padEnd(barWidth)}│${valueStr}`) + } + + lines.push("") + lines.push("─".repeat(60)) + lines.push("") + + lines.push("Summary:") + + if (breakdown.pruned > 0) { + const withoutPruning = breakdown.total + breakdown.pruned + const savingsPercent = ((breakdown.pruned / withoutPruning) * 100).toFixed(1) + lines.push( + ` Current context: ~${formatTokenCount(breakdown.total)} (${savingsPercent}% saved)`, + ) + lines.push(` Without DCP: ~${formatTokenCount(withoutPruning)}`) + } else { + lines.push(` Current context: ~${formatTokenCount(breakdown.total)}`) + } + + lines.push("") + + return lines.join("\n") +} + +export async function handleContextCommand(ctx: ContextCommandContext): Promise { + const { client, state, logger, sessionId, messages } = ctx + + const breakdown = analyzeTokens(state, messages) + + const message = formatContextMessage(breakdown) + + const params = getCurrentParams(state, messages, logger) + await sendIgnoredMessage(client, sessionId, message, params, logger) +} diff --git a/lib/commands/help.ts b/lib/commands/help.ts new file mode 100644 index 0000000..56d4316 --- /dev/null +++ b/lib/commands/help.ts @@ -0,0 +1,47 @@ +/** + * DCP Help command handler. + * Shows available DCP commands and their descriptions. + */ + +import type { Logger } from "../logger" +import type { SessionState, WithParts } from "../state" +import { sendIgnoredMessage } from "../ui/notification" +import { getCurrentParams } from "../strategies/utils" + +export interface HelpCommandContext { + client: any + state: SessionState + logger: Logger + sessionId: string + messages: WithParts[] +} + +function formatHelpMessage(): string { + const lines: string[] = [] + + lines.push("╭───────────────────────────────────────────────────────────╮") + lines.push("│ DCP Commands │") + lines.push("╰───────────────────────────────────────────────────────────╯") + lines.push("") + lines.push("Available commands:") + lines.push(" context - Show token usage breakdown for current session") + lines.push(" stats - Show DCP pruning statistics") + lines.push("") + lines.push("Examples:") + lines.push(" /dcp context") + lines.push(" /dcp stats") + lines.push("") + + return lines.join("\n") +} + +export async function handleHelpCommand(ctx: HelpCommandContext): Promise { + const { client, state, logger, sessionId, messages } = ctx + + const message = formatHelpMessage() + + const params = getCurrentParams(state, messages, logger) + await sendIgnoredMessage(client, sessionId, message, params, logger) + + logger.info("Help command executed") +} diff --git a/lib/commands/stats.ts b/lib/commands/stats.ts new file mode 100644 index 0000000..2463594 --- /dev/null +++ b/lib/commands/stats.ts @@ -0,0 +1,67 @@ +/** + * DCP Stats command handler. + * Shows pruning statistics for the current session and all-time totals. + */ + +import type { Logger } from "../logger" +import type { SessionState, WithParts } from "../state" +import { sendIgnoredMessage } from "../ui/notification" +import { formatTokenCount } from "../ui/utils" +import { loadAllSessionStats, type AggregatedStats } from "../state/persistence" +import { getCurrentParams } from "../strategies/utils" + +export interface StatsCommandContext { + client: any + state: SessionState + logger: Logger + sessionId: string + messages: WithParts[] +} + +function formatStatsMessage( + sessionTokens: number, + sessionTools: number, + allTime: AggregatedStats, +): string { + const lines: string[] = [] + + lines.push("╭───────────────────────────────────────────────────────────╮") + lines.push("│ DCP Statistics │") + lines.push("╰───────────────────────────────────────────────────────────╯") + lines.push("") + lines.push("Session:") + lines.push("─".repeat(60)) + lines.push(` Tokens pruned: ~${formatTokenCount(sessionTokens)}`) + lines.push(` Tools pruned: ${sessionTools}`) + lines.push("") + lines.push("All-time:") + lines.push("─".repeat(60)) + lines.push(` Tokens saved: ~${formatTokenCount(allTime.totalTokens)}`) + lines.push(` Tools pruned: ${allTime.totalTools}`) + lines.push(` Sessions: ${allTime.sessionCount}`) + + return lines.join("\n") +} + +export async function handleStatsCommand(ctx: StatsCommandContext): Promise { + const { client, state, logger, sessionId, messages } = ctx + + // Session stats from in-memory state + const sessionTokens = state.stats.totalPruneTokens + const sessionTools = state.prune.toolIds.length + + // All-time stats from storage files + const allTime = await loadAllSessionStats(logger) + + const message = formatStatsMessage(sessionTokens, sessionTools, allTime) + + const params = getCurrentParams(state, messages, logger) + await sendIgnoredMessage(client, sessionId, message, params, logger) + + logger.info("Stats command executed", { + sessionTokens, + sessionTools, + allTimeTokens: allTime.totalTokens, + allTimeTools: allTime.totalTools, + }) +} diff --git a/lib/config.ts b/lib/config.ts index d1bbba5..1547dad 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -49,6 +49,7 @@ export interface PluginConfig { enabled: boolean debug: boolean pruneNotification: "off" | "minimal" | "detailed" + commands: boolean turnProtection: TurnProtection protectedFilePatterns: string[] tools: Tools @@ -84,6 +85,7 @@ export const VALID_CONFIG_KEYS = new Set([ "turnProtection.enabled", "turnProtection.turns", "protectedFilePatterns", + "commands", "tools", "tools.settings", "tools.settings.nudgeEnabled", @@ -196,6 +198,16 @@ function validateConfigTypes(config: Record): ValidationError[] { } } + // Commands validator + const commands = config.commands + if (commands !== undefined && typeof commands !== "boolean") { + errors.push({ + key: "commands", + expected: "boolean", + actual: typeof commands, + }) + } + // Tools validators const tools = config.tools if (tools) { @@ -388,6 +400,7 @@ const defaultConfig: PluginConfig = { enabled: true, debug: false, pruneNotification: "detailed", + commands: true, turnProtection: { enabled: false, turns: 4, @@ -498,6 +511,8 @@ function createDefaultConfig(): void { "debug": false, // Notification display: "off", "minimal", or "detailed" "pruneNotification": "detailed", + // Enable or disable slash commands (/dcp) + "commands": true, // Protect from pruning for message turns "turnProtection": { "enabled": false, @@ -637,9 +652,18 @@ function mergeTools( } } +function mergeCommands( + base: PluginConfig["commands"], + override?: Partial, +): PluginConfig["commands"] { + if (override === undefined) return base + return override as boolean +} + function deepCloneConfig(config: PluginConfig): PluginConfig { return { ...config, + commands: config.commands, turnProtection: { ...config.turnProtection }, protectedFilePatterns: [...config.protectedFilePatterns], tools: { @@ -693,6 +717,7 @@ export function getConfig(ctx: PluginInput): PluginConfig { enabled: result.data.enabled ?? config.enabled, debug: result.data.debug ?? config.debug, pruneNotification: result.data.pruneNotification ?? config.pruneNotification, + commands: mergeCommands(config.commands, result.data.commands as any), turnProtection: { enabled: result.data.turnProtection?.enabled ?? config.turnProtection.enabled, turns: result.data.turnProtection?.turns ?? config.turnProtection.turns, @@ -735,6 +760,7 @@ export function getConfig(ctx: PluginInput): PluginConfig { enabled: result.data.enabled ?? config.enabled, debug: result.data.debug ?? config.debug, pruneNotification: result.data.pruneNotification ?? config.pruneNotification, + commands: mergeCommands(config.commands, result.data.commands as any), turnProtection: { enabled: result.data.turnProtection?.enabled ?? config.turnProtection.enabled, turns: result.data.turnProtection?.turns ?? config.turnProtection.turns, @@ -774,6 +800,7 @@ export function getConfig(ctx: PluginInput): PluginConfig { enabled: result.data.enabled ?? config.enabled, debug: result.data.debug ?? config.debug, pruneNotification: result.data.pruneNotification ?? config.pruneNotification, + commands: mergeCommands(config.commands, result.data.commands as any), turnProtection: { enabled: result.data.turnProtection?.enabled ?? config.turnProtection.enabled, turns: result.data.turnProtection?.turns ?? config.turnProtection.turns, diff --git a/lib/hooks.ts b/lib/hooks.ts index fc5e479..0533e37 100644 --- a/lib/hooks.ts +++ b/lib/hooks.ts @@ -6,6 +6,9 @@ import { deduplicate, supersedeWrites, purgeErrors } from "./strategies" import { prune, insertPruneToolContext } from "./messages" import { checkSession } from "./state" import { loadPrompt } from "./prompts" +import { handleStatsCommand } from "./commands/stats" +import { handleContextCommand } from "./commands/context" +import { handleHelpCommand } from "./commands/help" const INTERNAL_AGENT_SIGNATURES = [ "You are a title generator", @@ -76,3 +79,61 @@ export function createChatMessageTransformHandler( } } } + +export function createCommandExecuteHandler( + client: any, + state: SessionState, + logger: Logger, + config: PluginConfig, +) { + return async ( + input: { command: string; sessionID: string; arguments: string }, + _output: { parts: any[] }, + ) => { + if (!config.commands) { + return + } + + if (input.command === "dcp") { + const args = (input.arguments || "").trim().split(/\s+/).filter(Boolean) + const subcommand = args[0]?.toLowerCase() || "" + const _subArgs = args.slice(1) + + const messagesResponse = await client.session.messages({ + path: { id: input.sessionID }, + }) + const messages = (messagesResponse.data || messagesResponse) as WithParts[] + + if (subcommand === "context") { + await handleContextCommand({ + client, + state, + logger, + sessionId: input.sessionID, + messages, + }) + throw new Error("__DCP_CONTEXT_HANDLED__") + } + + if (subcommand === "stats") { + await handleStatsCommand({ + client, + state, + logger, + sessionId: input.sessionID, + messages, + }) + throw new Error("__DCP_STATS_HANDLED__") + } + + await handleHelpCommand({ + client, + state, + logger, + sessionId: input.sessionID, + messages, + }) + throw new Error("__DCP_HELP_HANDLED__") + } + } +} diff --git a/lib/logger.ts b/lib/logger.ts index c86a53d..972a1fb 100644 --- a/lib/logger.ts +++ b/lib/logger.ts @@ -168,12 +168,18 @@ export class Logger { callID: part.callID, } + if (part.state?.status) { + toolPart.status = part.state.status + } if (part.state?.input) { toolPart.input = part.state.input } if (part.state?.output) { toolPart.output = part.state.output } + if (part.state?.error) { + toolPart.error = part.state.error + } return toolPart } diff --git a/lib/prompts/system/both.ts b/lib/prompts/system/both.ts index e101e38..f5551aa 100644 --- a/lib/prompts/system/both.ts +++ b/lib/prompts/system/both.ts @@ -2,7 +2,9 @@ export const SYSTEM_PROMPT_BOTH = ` ENVIRONMENT -You are operating in a context-constrained environment and thus must proactively manage your context window using the \`discard\` and \`extract\` tools. A list is injected by the environment as a user message, and always contains up to date information. Use this information when deciding what to prune. +You are operating in a context-constrained environment and thus must proactively manage your context window using the \`discard\` and \`extract\` tools. The environment calls the \`context_info\` tool to provide an up-to-date list after each assistant turn. Use this information when deciding what to prune. + +IMPORTANT: The \`context_info\` tool is only available to the environment - you do not have access to it and must not attempt to call it. TWO TOOLS FOR CONTEXT MANAGEMENT - \`discard\`: Remove tool outputs that are no longer needed (completed tasks, noise, outdated info). No preservation of content. @@ -42,7 +44,7 @@ There may be tools in session context that do not appear in the -After each assistant turn, the environment may inject a user message containing a list and optional nudge instruction. This injected message is NOT from the user and is invisible to them. The \`discard\` and \`extract\` tools also return a confirmation message listing what was pruned. +After each assistant turn, the environment calls the \`context_info\` tool to inject an assistant message containing a list and optional nudge instruction. This tool is only available to the environment - you do not have access to it. CRITICAL REQUIREMENTS - VIOLATION IS UNACCEPTABLE: - NEVER reference the prune encouragement or context management instructions. Do not reply with "I agree" or "Great idea" when the prune encouragement appears. diff --git a/lib/prompts/system/discard.ts b/lib/prompts/system/discard.ts index b164114..1bf661f 100644 --- a/lib/prompts/system/discard.ts +++ b/lib/prompts/system/discard.ts @@ -2,7 +2,9 @@ export const SYSTEM_PROMPT_DISCARD = ` ENVIRONMENT -You are operating in a context-constrained environment and thus must proactively manage your context window using the \`discard\` tool. A list is injected by the environment as a user message, and always contains up to date information. Use this information when deciding what to discard. +You are operating in a context-constrained environment and thus must proactively manage your context window using the \`discard\` tool. The environment calls the \`context_info\` tool to provide an up-to-date list after each assistant turn. Use this information when deciding what to discard. + +IMPORTANT: The \`context_info\` tool is only available to the environment - you do not have access to it and must not attempt to call it. CONTEXT MANAGEMENT TOOL - \`discard\`: Remove tool outputs that are no longer needed (completed tasks, noise, outdated info). No preservation of content. @@ -33,7 +35,7 @@ There may be tools in session context that do not appear in the -After each assistant turn, the environment may inject a user message containing a list and optional nudge instruction. This injected message is NOT from the user and is invisible to them. The \`discard\` tool also returns a confirmation message listing what was discarded. +After each assistant turn, the environment calls the \`context_info\` tool to inject an assistant message containing a list and optional nudge instruction. This tool is only available to the environment - you do not have access to it. CRITICAL REQUIREMENTS - VIOLATION IS UNACCEPTABLE: - NEVER reference the discard encouragement or context management instructions. Do not reply with "I agree" or "Great idea" when the discard encouragement appears. diff --git a/lib/prompts/system/extract.ts b/lib/prompts/system/extract.ts index c73960e..859f36d 100644 --- a/lib/prompts/system/extract.ts +++ b/lib/prompts/system/extract.ts @@ -2,7 +2,9 @@ export const SYSTEM_PROMPT_EXTRACT = ` ENVIRONMENT -You are operating in a context-constrained environment and thus must proactively manage your context window using the \`extract\` tool. A list is injected by the environment as a user message, and always contains up to date information. Use this information when deciding what to extract. +You are operating in a context-constrained environment and thus must proactively manage your context window using the \`extract\` tool. The environment calls the \`context_info\` tool to provide an up-to-date list after each assistant turn. Use this information when deciding what to extract. + +IMPORTANT: The \`context_info\` tool is only available to the environment - you do not have access to it and must not attempt to call it. CONTEXT MANAGEMENT TOOL - \`extract\`: Extract key findings from tools into distilled knowledge before removing the raw content from context. Use this to preserve important information while reducing context size. @@ -33,7 +35,7 @@ There may be tools in session context that do not appear in the -After each assistant turn, the environment may inject a user message containing a list and optional nudge instruction. This injected message is NOT from the user and is invisible to them. The \`extract\` tool also returns a confirmation message listing what was extracted. +After each assistant turn, the environment calls the \`context_info\` tool to inject an assistant message containing a list and optional nudge instruction. This tool is only available to the environment - you do not have access to it. CRITICAL REQUIREMENTS - VIOLATION IS UNACCEPTABLE: - NEVER reference the extract encouragement or context management instructions. Do not reply with "I agree" or "Great idea" when the extract encouragement appears. diff --git a/lib/state/persistence.ts b/lib/state/persistence.ts index ccd4859..172ff75 100644 --- a/lib/state/persistence.ts +++ b/lib/state/persistence.ts @@ -99,3 +99,48 @@ export async function loadSessionState( return null } } + +export interface AggregatedStats { + totalTokens: number + totalTools: number + sessionCount: number +} + +export async function loadAllSessionStats(logger: Logger): Promise { + const result: AggregatedStats = { + totalTokens: 0, + totalTools: 0, + sessionCount: 0, + } + + try { + if (!existsSync(STORAGE_DIR)) { + return result + } + + const files = await fs.readdir(STORAGE_DIR) + const jsonFiles = files.filter((f) => f.endsWith(".json")) + + for (const file of jsonFiles) { + try { + const filePath = join(STORAGE_DIR, file) + const content = await fs.readFile(filePath, "utf-8") + const state = JSON.parse(content) as PersistedSessionState + + if (state?.stats?.totalPruneTokens && state?.prune?.toolIds) { + result.totalTokens += state.stats.totalPruneTokens + result.totalTools += state.prune.toolIds.length + result.sessionCount++ + } + } catch { + // Skip invalid files + } + } + + logger.debug("Loaded all-time stats", result) + } catch (error: any) { + logger.warn("Failed to load all-time stats", { error: error?.message }) + } + + return result +} diff --git a/lib/state/tool-cache.ts b/lib/state/tool-cache.ts index f9d3d3c..057bcf1 100644 --- a/lib/state/tool-cache.ts +++ b/lib/state/tool-cache.ts @@ -42,7 +42,9 @@ export async function syncToolCache( turnProtectionTurns > 0 && state.currentTurn - turnCounter < turnProtectionTurns - state.lastToolPrune = part.tool === "discard" || part.tool === "extract" + state.lastToolPrune = + (part.tool === "discard" || part.tool === "extract") && + part.state.status === "completed" const allProtectedTools = config.tools.settings.protectedTools diff --git a/lib/strategies/tools.ts b/lib/strategies/tools.ts index e3d8e03..44f6742 100644 --- a/lib/strategies/tools.ts +++ b/lib/strategies/tools.ts @@ -39,7 +39,9 @@ async function executePruneOperation( if (!ids || ids.length === 0) { logger.debug(`${toolName} tool called but ids is empty or undefined`) - return `No IDs provided. Check the list for available IDs to ${toolName.toLowerCase()}.` + throw new Error( + `No IDs provided. Check the list for available IDs to ${toolName.toLowerCase()}.`, + ) } const numericToolIds: number[] = ids @@ -48,7 +50,7 @@ async function executePruneOperation( if (numericToolIds.length === 0) { logger.debug(`No numeric tool IDs provided for ${toolName}: ` + JSON.stringify(ids)) - return "No numeric IDs provided. Format: ids: [id1, id2, ...]" + throw new Error("No numeric IDs provided. Format: ids: [id1, id2, ...]") } // Fetch messages to calculate tokens and find current agent @@ -65,7 +67,9 @@ async function executePruneOperation( // Validate that all numeric IDs are within bounds if (numericToolIds.some((id) => id < 0 || id >= toolIdList.length)) { logger.debug("Invalid tool IDs provided: " + numericToolIds.join(", ")) - return "Invalid IDs provided. Only use numeric IDs from the list." + throw new Error( + "Invalid IDs provided. Only use numeric IDs from the list.", + ) } // Validate that all IDs exist in cache and aren't protected @@ -78,7 +82,9 @@ async function executePruneOperation( "Rejecting prune request - ID not in cache (turn-protected or hallucinated)", { index, id }, ) - return "Invalid IDs provided. Only use numeric IDs from the list." + throw new Error( + "Invalid IDs provided. Only use numeric IDs from the list.", + ) } const allProtectedTools = config.tools.settings.protectedTools if (allProtectedTools.includes(metadata.tool)) { @@ -87,7 +93,9 @@ async function executePruneOperation( id, tool: metadata.tool, }) - return "Invalid IDs provided. Only use numeric IDs from the list." + throw new Error( + "Invalid IDs provided. Only use numeric IDs from the list.", + ) } const filePath = getFilePathFromParameters(metadata.parameters) @@ -98,7 +106,9 @@ async function executePruneOperation( tool: metadata.tool, filePath, }) - return "Invalid IDs provided. Only use numeric IDs from the list." + throw new Error( + "Invalid IDs provided. Only use numeric IDs from the list.", + ) } } @@ -158,7 +168,9 @@ export function createDiscardTool(ctx: PruneToolContext): ReturnType encode(text).length) + return anthropicCountTokens(text) } catch { - return texts.map((text) => Math.round(text.length / 4)) + return Math.round(text.length / 4) } } -/** - * Calculates approximate tokens saved by pruning the given tool call IDs. - */ +function estimateTokensBatch(texts: string[]): number[] { + return texts.map(countTokens) +} + export const calculateTokensSaved = ( state: SessionState, messages: WithParts[], diff --git a/package-lock.json b/package-lock.json index 0b7035a..77d4705 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,8 +9,8 @@ "version": "1.2.3", "license": "MIT", "dependencies": { + "@anthropic-ai/tokenizer": "^0.0.4", "@opencode-ai/sdk": "^1.1.3", - "gpt-tokenizer": "^3.4.0", "jsonc-parser": "^3.3.1", "zod": "^4.1.13" }, @@ -25,6 +25,31 @@ "@opencode-ai/plugin": ">=0.13.7" } }, + "node_modules/@anthropic-ai/tokenizer": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/@anthropic-ai/tokenizer/-/tokenizer-0.0.4.tgz", + "integrity": "sha512-EHRKbxlxlc8W4KCBEseByJ7YwyYCmgu9OyN59H9+IYIGPoKv8tXyQXinkeGDI+cI8Tiuz9wk2jZb/kK7AyvL7g==", + "license": "Apache-2.0", + "dependencies": { + "@types/node": "^18.11.18", + "tiktoken": "^1.0.10" + } + }, + "node_modules/@anthropic-ai/tokenizer/node_modules/@types/node": { + "version": "18.19.130", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", + "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@anthropic-ai/tokenizer/node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "license": "MIT" + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.0.tgz", @@ -579,12 +604,6 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, - "node_modules/gpt-tokenizer": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/gpt-tokenizer/-/gpt-tokenizer-3.4.0.tgz", - "integrity": "sha512-wxFLnhIXTDjYebd9A9pGl3e31ZpSypbpIJSOswbgop5jLte/AsZVDvjlbEuVFlsqZixVKqbcoNmRlFDf6pz/UQ==", - "license": "MIT" - }, "node_modules/jsonc-parser": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", @@ -617,6 +636,12 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, + "node_modules/tiktoken": { + "version": "1.0.22", + "resolved": "https://registry.npmjs.org/tiktoken/-/tiktoken-1.0.22.tgz", + "integrity": "sha512-PKvy1rVF1RibfF3JlXBSP0Jrcw2uq3yXdgcEXtKTYn3QJ/cBRBHDnrJ5jHky+MENZ6DIPwNUGWpkVx+7joCpNA==", + "license": "MIT" + }, "node_modules/tsx": { "version": "4.21.0", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", diff --git a/package.json b/package.json index 13c9c72..052f0db 100644 --- a/package.json +++ b/package.json @@ -40,8 +40,8 @@ "@opencode-ai/plugin": ">=0.13.7" }, "dependencies": { + "@anthropic-ai/tokenizer": "^0.0.4", "@opencode-ai/sdk": "^1.1.3", - "gpt-tokenizer": "^3.4.0", "jsonc-parser": "^3.3.1", "zod": "^4.1.13" },