240 lines
7.5 KiB
TypeScript
240 lines
7.5 KiB
TypeScript
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<string, unknown>).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<string, unknown>
|
|
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<string, unknown>).subtype === 'snip_boundary'
|
|
) {
|
|
boundaryIdx = i
|
|
const meta = (msg as Record<string, unknown>).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<string, unknown>
|
|
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<string, unknown>).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)
|
|
}
|