feat(shared): add dynamic-truncator utility for context-aware output truncation
Extract and generalize dynamic output truncation logic from grep-output-truncator. Provides context window-aware truncation that adapts based on remaining tokens. Features: - truncateToTokenLimit(): Sync truncation with configurable header preservation - getContextWindowUsage(): Get current context window usage from session - dynamicTruncate(): Async truncation that queries context window state - createDynamicTruncator(): Factory for creating truncator instance 🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
This commit is contained in:
164
src/shared/dynamic-truncator.ts
Normal file
164
src/shared/dynamic-truncator.ts
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
import type { PluginInput } from "@opencode-ai/plugin"
|
||||||
|
|
||||||
|
const ANTHROPIC_ACTUAL_LIMIT = 200_000
|
||||||
|
const CHARS_PER_TOKEN_ESTIMATE = 4
|
||||||
|
const DEFAULT_TARGET_MAX_TOKENS = 50_000
|
||||||
|
|
||||||
|
interface AssistantMessageInfo {
|
||||||
|
role: "assistant"
|
||||||
|
tokens: {
|
||||||
|
input: number
|
||||||
|
output: number
|
||||||
|
reasoning: number
|
||||||
|
cache: { read: number; write: number }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MessageWrapper {
|
||||||
|
info: { role: string } & Partial<AssistantMessageInfo>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TruncationResult {
|
||||||
|
result: string
|
||||||
|
truncated: boolean
|
||||||
|
removedCount?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TruncationOptions {
|
||||||
|
targetMaxTokens?: number
|
||||||
|
preserveHeaderLines?: number
|
||||||
|
contextWindowLimit?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function estimateTokens(text: string): number {
|
||||||
|
return Math.ceil(text.length / CHARS_PER_TOKEN_ESTIMATE)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function truncateToTokenLimit(
|
||||||
|
output: string,
|
||||||
|
maxTokens: number,
|
||||||
|
preserveHeaderLines = 3
|
||||||
|
): TruncationResult {
|
||||||
|
const currentTokens = estimateTokens(output)
|
||||||
|
|
||||||
|
if (currentTokens <= maxTokens) {
|
||||||
|
return { result: output, truncated: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = output.split("\n")
|
||||||
|
|
||||||
|
if (lines.length <= preserveHeaderLines) {
|
||||||
|
const maxChars = maxTokens * CHARS_PER_TOKEN_ESTIMATE
|
||||||
|
return {
|
||||||
|
result: output.slice(0, maxChars) + "\n\n[Output truncated due to context window limit]",
|
||||||
|
truncated: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const headerLines = lines.slice(0, preserveHeaderLines)
|
||||||
|
const contentLines = lines.slice(preserveHeaderLines)
|
||||||
|
|
||||||
|
const headerText = headerLines.join("\n")
|
||||||
|
const headerTokens = estimateTokens(headerText)
|
||||||
|
const truncationMessageTokens = 50
|
||||||
|
const availableTokens = maxTokens - headerTokens - truncationMessageTokens
|
||||||
|
|
||||||
|
if (availableTokens <= 0) {
|
||||||
|
return {
|
||||||
|
result: headerText + "\n\n[Content truncated due to context window limit]",
|
||||||
|
truncated: true,
|
||||||
|
removedCount: contentLines.length,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resultLines: string[] = []
|
||||||
|
let currentTokenCount = 0
|
||||||
|
|
||||||
|
for (const line of contentLines) {
|
||||||
|
const lineTokens = estimateTokens(line + "\n")
|
||||||
|
if (currentTokenCount + lineTokens > availableTokens) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
resultLines.push(line)
|
||||||
|
currentTokenCount += lineTokens
|
||||||
|
}
|
||||||
|
|
||||||
|
const truncatedContent = [...headerLines, ...resultLines].join("\n")
|
||||||
|
const removedCount = contentLines.length - resultLines.length
|
||||||
|
|
||||||
|
return {
|
||||||
|
result: truncatedContent + `\n\n[${removedCount} more lines truncated due to context window limit]`,
|
||||||
|
truncated: true,
|
||||||
|
removedCount,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getContextWindowUsage(
|
||||||
|
ctx: PluginInput,
|
||||||
|
sessionID: string
|
||||||
|
): Promise<{ usedTokens: number; remainingTokens: number; usagePercentage: number } | null> {
|
||||||
|
try {
|
||||||
|
const response = await ctx.client.session.messages({
|
||||||
|
path: { id: sessionID },
|
||||||
|
})
|
||||||
|
|
||||||
|
const messages = (response.data ?? response) as MessageWrapper[]
|
||||||
|
|
||||||
|
const assistantMessages = messages
|
||||||
|
.filter((m) => m.info.role === "assistant")
|
||||||
|
.map((m) => m.info as AssistantMessageInfo)
|
||||||
|
|
||||||
|
if (assistantMessages.length === 0) return null
|
||||||
|
|
||||||
|
const lastAssistant = assistantMessages[assistantMessages.length - 1]
|
||||||
|
const lastTokens = lastAssistant.tokens
|
||||||
|
const usedTokens = (lastTokens?.input ?? 0) + (lastTokens?.cache?.read ?? 0)
|
||||||
|
const remainingTokens = ANTHROPIC_ACTUAL_LIMIT - usedTokens
|
||||||
|
|
||||||
|
return {
|
||||||
|
usedTokens,
|
||||||
|
remainingTokens,
|
||||||
|
usagePercentage: usedTokens / ANTHROPIC_ACTUAL_LIMIT,
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function dynamicTruncate(
|
||||||
|
ctx: PluginInput,
|
||||||
|
sessionID: string,
|
||||||
|
output: string,
|
||||||
|
options: TruncationOptions = {}
|
||||||
|
): Promise<TruncationResult> {
|
||||||
|
const { targetMaxTokens = DEFAULT_TARGET_MAX_TOKENS, preserveHeaderLines = 3 } = options
|
||||||
|
|
||||||
|
const usage = await getContextWindowUsage(ctx, sessionID)
|
||||||
|
|
||||||
|
if (!usage) {
|
||||||
|
return { result: output, truncated: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxOutputTokens = Math.min(usage.remainingTokens * 0.5, targetMaxTokens)
|
||||||
|
|
||||||
|
if (maxOutputTokens <= 0) {
|
||||||
|
return {
|
||||||
|
result: "[Output suppressed - context window exhausted]",
|
||||||
|
truncated: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return truncateToTokenLimit(output, maxOutputTokens, preserveHeaderLines)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDynamicTruncator(ctx: PluginInput) {
|
||||||
|
return {
|
||||||
|
truncate: (sessionID: string, output: string, options?: TruncationOptions) =>
|
||||||
|
dynamicTruncate(ctx, sessionID, output, options),
|
||||||
|
|
||||||
|
getUsage: (sessionID: string) => getContextWindowUsage(ctx, sessionID),
|
||||||
|
|
||||||
|
truncateSync: (output: string, maxTokens: number, preserveHeaderLines?: number) =>
|
||||||
|
truncateToTokenLimit(output, maxTokens, preserveHeaderLines),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,3 +9,4 @@ export * from "./pattern-matcher"
|
|||||||
export * from "./hook-disabled"
|
export * from "./hook-disabled"
|
||||||
export * from "./deep-merge"
|
export * from "./deep-merge"
|
||||||
export * from "./file-utils"
|
export * from "./file-utils"
|
||||||
|
export * from "./dynamic-truncator"
|
||||||
|
|||||||
Reference in New Issue
Block a user