import type { Message } from 'src/types/message.js' /** * Estimated characters per token (conservative for mixed code/text). */ const CHARS_PER_TOKEN = 4 /** * Minimum message count before nudging the model to consider snipping. */ const SNIP_NUDGE_THRESHOLD = 30 /** * Text shown to the model as a nudge when the conversation is long enough * to benefit from snipping. */ export const SNIP_NUDGE_TEXT: string = 'The conversation history is getting long. Consider using the /force-snip command or the snip tool to compress older messages, freeing context window space for continued work.' /** * Check whether a message is an internal snip marker (not user-facing). * Snip markers are system messages injected by the snip tool to track * which messages have been registered for future removal. */ export function isSnipMarkerMessage(message: Message): boolean { if (message.type !== 'system') return false return (message as Record).subtype === 'snip_marker' } /** * Estimate the token count of a single message by serialising its content. * This is a rough heuristic (~4 chars per token) used to report * tokensFreed; it does not need to be exact. */ function estimateMessageTokens(message: Message): number { const content = message.message?.content let chars = 0 if (typeof content === 'string') { chars = content.length } else if (Array.isArray(content)) { for (const block of content) { if (typeof block === 'string') { chars += (block as string).length } else if (block && typeof block === 'object') { const obj = block as unknown as Record const text = obj.text ?? obj.content if (typeof text === 'string') { chars += text.length } else { chars += JSON.stringify(block).length } } } } else if (content !== null && content !== undefined) { chars = JSON.stringify(content).length } return Math.max(1, Math.ceil(chars / CHARS_PER_TOKEN)) } /** * Scan the message array for the last `snip_boundary` system message and, * if found, remove all messages whose UUIDs appear in its * `snipMetadata.removedUuids`. * * This is the core memory-saving function. When a snip boundary exists: * 1. All messages listed in `removedUuids` are filtered out. * 2. The boundary message itself is kept (it records what was removed). * 3. Messages not in `removedUuids` (including post-boundary messages) * are preserved. * * Called from: * - `query.ts` — strips snipped messages from the model-facing array * before sending to the API. * - `QueryEngine.ts` `snipReplay` — trims `mutableMessages` so the * in-memory store does not grow without bound in long SDK sessions. * * @param messages Full message array (may contain a snip_boundary). * @param options `force` — if true, always execute when a boundary is * present. Without `force`, the function still executes * if a boundary is found (the "if needed" refers to * whether a boundary exists, not a token threshold). */ export function snipCompactIfNeeded( messages: Message[], options?: { force?: boolean }, ): { messages: Message[] executed: boolean tokensFreed: number boundaryMessage?: Message } { // Find the last snip_boundary message let boundaryIdx = -1 let removedUuids: string[] | undefined for (let i = messages.length - 1; i >= 0; i--) { const msg = messages[i]! if ( msg.type === 'system' && (msg as Record).subtype === 'snip_boundary' ) { boundaryIdx = i const meta = (msg as Record).snipMetadata as | { removedUuids?: string[] } | undefined removedUuids = meta?.removedUuids break } } if (boundaryIdx === -1) { return { messages, executed: false, tokensFreed: 0 } } const boundaryMessage = messages[boundaryIdx]! // No removedUuids metadata — fallback: keep boundary + everything after if (!removedUuids || removedUuids.length === 0) { const kept = messages.slice(boundaryIdx) return { messages: kept, executed: true, tokensFreed: 0, boundaryMessage, } } // Filter out messages whose UUIDs are listed in removedUuids const removedSet = new Set(removedUuids) const kept: Message[] = [] let tokensFreed = 0 for (const msg of messages) { if (removedSet.has(msg.uuid)) { tokensFreed += estimateMessageTokens(msg) continue } kept.push(msg) } return { messages: kept, executed: true, tokensFreed, boundaryMessage, } } /** * Returns true when the snip runtime is active. * Because this module is only loaded when the HISTORY_SNIP feature flag * is enabled, this always returns true. */ export function isSnipRuntimeEnabled(): boolean { return true } /** * Determine whether the conversation is long enough to warrant a nudge * to the model to consider snipping. Uses a simple message-count * threshold rather than an expensive token count. */ export function shouldNudgeForSnips(messages: Message[]): boolean { return messages.length >= SNIP_NUDGE_THRESHOLD } /** * Maximum total character length of message content before proactive * truncation kicks in. ~150 MB of string data corresponds to roughly * 1.5x the default 200k-token context window at 4 chars/token — well * beyond what any model can actually use in a single request. */ const PROACTIVE_TRUNCATE_CHARS = 150_000_000 /** * Minimum number of messages to keep when falling back to tail-only * retention (i.e. when no compact_boundary exists in the array). */ const PROACTIVE_TRUNCATE_MIN_TAIL = 50 /** * Proactively truncate old messages when the in-memory store grows too * large. Unlike `snipCompactIfNeeded` (which waits for a snip_boundary * from the API), this runs client-side after every push — ensuring * unbounded growth cannot happen even when the API never returns a * compact_boundary (e.g. third-party compat layers). * * Strategy: * 1. If a `compact_boundary` exists, keep it and everything after it. * 2. Otherwise, keep only the last `PROACTIVE_TRUNCATE_MIN_TAIL` messages. * * Returns the same array reference when no truncation is needed. */ export function proactiveTruncate(messages: Message[]): Message[] { if (messages.length < PROACTIVE_TRUNCATE_MIN_TAIL) return messages let totalChars = 0 for (const msg of messages) { const content = msg.message?.content if (typeof content === 'string') { totalChars += content.length } else if (Array.isArray(content)) { for (const block of content) { if (typeof block === 'string') { totalChars += (block as string).length } else if (block && typeof block === 'object') { const obj = block as unknown as Record const text = obj.text ?? obj.content if (typeof text === 'string') { totalChars += text.length } } } } } if (totalChars < PROACTIVE_TRUNCATE_CHARS) return messages // Find last compact_boundary — the standard anchor point let boundaryIdx = -1 for (let i = messages.length - 1; i >= 0; i--) { const msg = messages[i]! if ( msg.type === 'system' && (msg as Record).subtype === 'compact_boundary' ) { boundaryIdx = i break } } const keepFrom = boundaryIdx >= 0 ? boundaryIdx : Math.max(0, messages.length - PROACTIVE_TRUNCATE_MIN_TAIL) if (keepFrom === 0) return messages return messages.slice(keepFrom) }