import { guardLiveToolResults, } from "./live-tool-result-guard.js";
import { safeSliceHead, safeSliceTail } from "../utils/safe-slice.js";
/**
 * Build an optimized copy of messages for LLM consumption.
 *
 * Pipeline (order matters):
 * 1. Optionally preserve the first message (task prompt)
 * 2. Optional sliding window to keep only the last N rounds
 * 3. Stable pruning of processed tool results (already consumed by LLM):
 *    - Snapshot results → always replaced by a one-line "[snapshot omitted …]"
 *      summary (ephemeral page state is useless once consumed).
 *    - Screenshot results → hard-omitted (large, opaque blobs that the LLM
 *      no longer references once it has acted on them).
 *    - Generic results larger than hardOmitThreshold → hard_omit.
 *    - Generic results larger than softTrimThreshold → soft_trim (head + tail).
 *    Unprocessed results (no assistant message after them) are NEVER touched.
 *    The pruning decision depends only on content type + size — NOT on how
 *    old the message is — so once a result is processed its serialized form
 *    is fixed forever, which is what lets the provider prompt cache hit.
 * 4. Slim assistant tool-call args once their results were consumed, using
 *    the same content-only criterion.
 */
export function optimizeMessages(messages, options = {}) {
    const { windowRounds, softTrimThreshold = 1000, hardOmitThreshold = 8000, softTrimHead = 500, softTrimTail = 500, preserveFirst = false, processedToolArgThreshold = 2000, processedToolArgPreview = 160, } = options;
    if (messages.length <= 3)
        return messages;
    let firstMessage;
    let rest;
    if (preserveFirst) {
        firstMessage = messages[0];
        rest = messages.slice(1);
    }
    else {
        rest = [...messages];
    }
    // --- Sliding window: keep last N rounds (1 round = 1 assistant message + its tool results) ---
    if (windowRounds !== undefined) {
        let assistantCount = 0;
        let cutIndex = 0;
        for (let i = rest.length - 1; i >= 0; i--) {
            if (rest[i].role === "assistant") {
                assistantCount++;
                if (assistantCount >= windowRounds) {
                    cutIndex = i;
                    break;
                }
            }
        }
        if (assistantCount >= windowRounds) {
            if (!firstMessage) {
                for (let i = cutIndex; i >= 0; i--) {
                    if (rest[i].role === "user") {
                        cutIndex = i;
                        break;
                    }
                }
            }
            rest = rest.slice(cutIndex);
        }
    }
    // --- Compute age of each message ---
    // Age = number of assistant messages that appear AFTER this message.
    // Count assistant messages from the end to build a running total.
    let assistantsSeen = 0;
    const ageMap = new Array(rest.length);
    for (let i = rest.length - 1; i >= 0; i--) {
        ageMap[i] = assistantsSeen;
        if (rest[i].role === "assistant") {
            assistantsSeen++;
        }
    }
    // --- Compute user-turn age of each message ---
    // userAge = number of user messages that appear AFTER this message.
    // This is the right yardstick for image historization: within a single
    // user turn the assistant may run many tool iterations (each of which
    // increments assistant-age) but the original user-supplied image still
    // needs to be visible to the model while it's processing that turn.
    // Only once a NEW user turn starts is it safe to drop the bytes.
    //
    // Note: tool-result messages have role === "tool" in this codebase
    // (not "user"), so this counter is clean — it only counts genuine
    // user inputs.
    let usersSeen = 0;
    const userAgeMap = new Array(rest.length);
    for (let i = rest.length - 1; i >= 0; i--) {
        userAgeMap[i] = usersSeen;
        if (rest[i].role === "user") {
            usersSeen++;
        }
    }
    // --- Identify unprocessed tool results ---
    // A tool result is "unprocessed" if no assistant message appears after it (age === 0).
    const unprocessedIds = new Set();
    for (let i = 0; i < rest.length; i++) {
        const msg = rest[i];
        if (msg.role === "tool" && msg.toolCallId && ageMap[i] === 0) {
            unprocessedIds.add(msg.toolCallId);
        }
    }
    // --- Map tool_call_id → tool name ---
    // Filled by walking assistant tool_calls so the omitted-result placeholder
    // can say which tool was dropped ("[browser result omitted, was N chars]")
    // instead of an opaque "[result omitted ...]". Helpful for the LLM when it
    // wants to reason about what it has versus hasn't seen.
    const toolNameById = new Map();
    for (const msg of rest) {
        if (msg.role === "assistant" && msg.toolCalls) {
            for (const tc of msg.toolCalls) {
                toolNameById.set(tc.id, tc.name);
            }
        }
    }
    const pruneMap = new Map();
    for (let i = 0; i < rest.length; i++) {
        const msg = rest[i];
        if (msg.role !== "tool" || !msg.toolCallId)
            continue;
        if (unprocessedIds.has(msg.toolCallId))
            continue; // never touch unprocessed
        const content = typeof msg.content === "string"
            ? msg.content
            : JSON.stringify(msg.content);
        // Snapshots: ephemeral page state, always collapse to a one-line summary.
        if (isSnapshotResult(content)) {
            pruneMap.set(msg.toolCallId, "snapshot_omit");
            continue;
        }
        // Screenshots: large opaque blobs the LLM no longer references after
        // it has acted on them.
        if (isScreenshotResult(content)) {
            pruneMap.set(msg.toolCallId, "hard_omit");
            continue;
        }
        // Generic results: hard-omit only the very large blobs, soft-trim
        // anything bigger than softTrimThreshold. Both thresholds are absolute
        // (no age input) so the output is byte-stable.
        if (content.length > hardOmitThreshold) {
            pruneMap.set(msg.toolCallId, "hard_omit");
        }
        else if (content.length > softTrimThreshold) {
            pruneMap.set(msg.toolCallId, "soft_trim");
        }
    }
    // Collect all IDs that are hard-omitted (for slimming assistant tool call args)
    const hardOmitIds = new Set();
    for (const [id, action] of pruneMap) {
        if (action === "hard_omit" || action === "snapshot_omit") {
            hardOmitIds.add(id);
        }
    }
    // --- Build result ---
    // Walks each message once. A single message can need both kinds of
    // transform (e.g. an assistant message with processed tool calls AND
    // multimodal content), so we accumulate changes on `working` and only
    // shallow-clone on the way out.
    const optimized = rest.map((msg, index) => {
        const age = ageMap[index] ?? 0;
        let working = msg;
        // Slim down assistant tool call arguments once their result has already
        // been consumed. Latest unprocessed calls must stay exact because the
        // provider replays the tool_use/tool_result pair in the next request.
        if (working.role === "assistant" && working.toolCalls) {
            let changed = false;
            const slimCalls = working.toolCalls.map((tc) => {
                if (unprocessedIds.has(tc.id))
                    return tc;
                if (hardOmitIds.has(tc.id)) {
                    const slimmed = slimToolCallArguments(tc, {
                        threshold: 200,
                        previewChars: 80,
                        mode: "hard_omit",
                    });
                    if (slimmed !== tc)
                        changed = true;
                    return slimmed;
                }
                if (age > 0) {
                    const slimmed = slimToolCallArguments(tc, {
                        threshold: processedToolArgThreshold,
                        previewChars: processedToolArgPreview,
                        mode: "processed",
                    });
                    if (slimmed !== tc)
                        changed = true;
                    return slimmed;
                }
                return tc;
            });
            if (changed) {
                working = { ...working, toolCalls: slimCalls };
            }
        }
        // Historize image content blocks. Image base64 strings are huge (hundreds
        // of KB) and dominate the cache prefix once they're a few user turns old.
        // Gated on userAge — not assistant age — because within one user turn the
        // model may iterate through many tool calls and still need to look at the
        // user's image while writing the final answer (e.g. "describe this bug
        // screenshot, then file an issue" calls 4-5 browser tools but must keep
        // the screenshot visible the whole time). Only once a NEW user turn
        // starts is it safe to drop the bytes.
        if ((working.role === "user" || working.role === "assistant") &&
            Array.isArray(working.content) &&
            userAgeMap[index] >= 1) {
            const historized = historizeImageBlocks(working.content);
            if (historized !== working.content) {
                working = { ...working, content: historized };
            }
        }
        // Process tool results
        if (working.role === "tool" && working.toolCallId) {
            const action = pruneMap.get(working.toolCallId);
            if (!action)
                return working;
            const content = typeof working.content === "string"
                ? working.content
                : JSON.stringify(working.content);
            if (action === "snapshot_omit") {
                return { ...working, content: buildSnapshotSummary(content) };
            }
            if (action === "hard_omit") {
                const toolName = toolNameById.get(working.toolCallId);
                return {
                    ...working,
                    content: toolName
                        ? `[${toolName} result omitted, was ${content.length} chars]`
                        : `[result omitted, was ${content.length} chars]`,
                };
            }
            if (action === "soft_trim") {
                // safeSliceHead / safeSliceTail align cuts to a UTF-16 code-point
                // edge so a non-BMP char (emoji etc.) is never split across the
                // boundary. A lone surrogate here would make the eventual Anthropic
                // request body invalid JSON and fail with 400 "invalid high
                // surrogate in string".
                const head = safeSliceHead(content, softTrimHead);
                const tail = safeSliceTail(content, softTrimTail);
                const trimmed = content.length - head.length - tail.length;
                return {
                    ...working,
                    content: `${head}\n[...trimmed ${trimmed} chars...]\n${tail}`,
                };
            }
        }
        return working;
    });
    return firstMessage ? [firstMessage, ...optimized] : optimized;
}
export function optimizeMessagesWithStats(messages, options = {}) {
    const optimized = optimizeMessages(messages, options);
    const guarded = guardLiveToolResults(optimized, {
        liveToolResultMaxChars: options.liveToolResultMaxChars,
        liveToolResultAggregateMaxChars: options.liveToolResultAggregateMaxChars,
    });
    return {
        messages: guarded.messages,
        liveToolResultGuard: guarded.stats,
    };
}
/**
 * Replace image blocks in a content array with short text placeholders.
 * Returns the original reference when no images are present, so callers can
 * skip the shallow-clone entirely (and so this transform is a no-op on
 * already-historized messages — preserving prompt-cache stability).
 */
function historizeImageBlocks(content) {
    let hasImage = false;
    for (const block of content) {
        if (block.type === "image" && block.imageData) {
            hasImage = true;
            break;
        }
    }
    if (!hasImage)
        return content;
    return content.map((block) => {
        if (block.type === "image" && block.imageData) {
            const bytes = block.imageData.length;
            const mt = block.mediaType ?? "image/*";
            return {
                type: "text",
                text: `[image omitted from history — was ${mt}, ${bytes} chars base64]`,
            };
        }
        return block;
    });
}
/**
 * Detect whether a tool result is a browser snapshot.
 * Snapshot results from BrowserActions.snapshot() start with "Page: ...\nURL: ..."
 */
function isSnapshotResult(content) {
    return content.startsWith("Page: ") && content.includes("\nURL: ");
}
/**
 * Detect whether a tool result is a screenshot.
 */
function isScreenshotResult(content) {
    return content.startsWith("Screenshot taken (") && content.includes(" bytes)");
}
function stringifyToolArgValue(value) {
    return typeof value === "string" ? value : (JSON.stringify(value) ?? String(value));
}
function slimToolCallArguments(toolCall, opts) {
    let changed = false;
    const slim = {};
    if ("action" in toolCall.arguments) {
        slim.action = toolCall.arguments.action;
    }
    for (const [key, val] of Object.entries(toolCall.arguments)) {
        if (key === "action")
            continue;
        const valStr = stringifyToolArgValue(val);
        if (valStr.length > opts.threshold) {
            changed = true;
            // Use bracket format so LLM won't mistake placeholders for executable code.
            if (key === "script" && toolCall.name === "browser") {
                const preview = safeSliceHead(valStr, opts.previewChars).replace(/\n/g, " ");
                slim[key] = `[previous script omitted, ${valStr.length} chars, started with: ${preview}...]`;
            }
            else if (opts.mode === "hard_omit") {
                slim[key] = `[omitted, ${valStr.length} chars]`;
            }
            else {
                const preview = safeSliceHead(valStr, opts.previewChars)
                    .replace(/\s+/g, " ")
                    .trim();
                slim[key] = preview
                    ? `[previous ${key} omitted, ${valStr.length} chars, started with: ${preview}...]`
                    : `[previous ${key} omitted, ${valStr.length} chars]`;
            }
        }
        else {
            slim[key] = val;
        }
    }
    return changed ? { ...toolCall, arguments: slim } : toolCall;
}
/**
 * Build a compact one-line summary for an omitted snapshot result.
 * Extracts the page title and URL from the standard snapshot format.
 */
function buildSnapshotSummary(content) {
    const titleMatch = content.match(/^Page: (.+)/);
    const urlMatch = content.match(/\nURL: (.+)/);
    const title = titleMatch?.[1] ?? "unknown";
    const url = urlMatch?.[1] ?? "unknown";
    return `[snapshot omitted — was "${title}" at ${url}, ${content.length} chars]`;
}
//# sourceMappingURL=message-optimizer.js.map