Ctrl K
ComunidadGuías…

src/conversation-pool.js

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
/**
 * Cascade conversation reuse pool (experimental).
 *
 * Goal: when a multi-turn chat continues a previous exchange, reuse the same
 * Windsurf `cascade_id` instead of starting a fresh one. This lets the
 * Windsurf backend keep its own per-cascade context cached — we avoid
 * resending the full history on each turn and the server responds faster.
 *
 * The key is a "state digest" of the caller-visible trajectory up to (but
 * not including) the newest user/tool result turn. v2.0.25 upgraded the key
 * from a relaxed "user text only" projection to a server-state semantic key
 * that includes assistant text + tool_calls digest, normalized system,
 * stable media digests, and (when tool-emulating) the tool schema digest.
 * This trades some hit rate for correctness: when the client's prior
 * assistant / system / tool context drifts, we miss instead of silently
 * resuming a stale upstream cascade.
 *
 * Safety rails:
 *   - Entries are pinned to a specific (apiKey, lsPort) pair. We must reuse
 *     the same LS and the same account or the cascade_id is meaningless.
 *   - A checked-out entry is removed from the pool. Concurrent second request
 *     with the same fingerprint falls back to a fresh cascade.
 *   - TTL defaults to 30 min (override with CASCADE_POOL_TTL_MS); LRU eviction
 *     at 500 entries.
 */

import { createHash } from 'crypto';

function positiveIntEnv(name, fallback) {
  const n = parseInt(process.env[name] || '', 10);
  return Number.isFinite(n) && n > 0 ? n : fallback;
}

const POOL_TTL_MS = positiveIntEnv('CASCADE_POOL_TTL_MS', 30 * 60 * 1000);
const POOL_MAX = positiveIntEnv('CASCADE_POOL_MAX', 500);
const KEY_VERSION = 2;

// When enabled, a secondary index maps (callerKey + modelKey) → latest
// fingerprint. If the primary fingerprint lookup misses but the caller has
// a recent cascade for the same model, we fall back to it. This makes
// reuse robust against fingerprint drift (system-prompt changes, tool-list
// reordering, etc.) within a single Claude Code / Cline session.
const REUSE_BY_CALLER = process.env.CASCADE_REUSE_BY_CALLER === '1';

const _pool = new Map();
// Secondary index: callerKey:modelKey → fingerprint (latest). Only
// populated when REUSE_BY_CALLER is on.
const _callerLatest = new Map();

const stats = { hits: 0, misses: 0, stores: 0, evictions: 0, expired: 0, callerFallbackHits: 0 };

function sha256(s) {
  return createHash('sha256').update(s).digest('hex');
}

function shortDigest(s, n = 16) {
  return sha256(String(s ?? '')).slice(0, n);
}

// Client-injected meta tags whose bodies change every turn (cwd snapshot,
// todo state, current time, hook output, slash-command echo). If we hash
// these, the fingerprint drifts even when the real user text is unchanged
// and Cascade reuse silently falls back to fresh for every call
// (issue #24). Strip them before hashing.
const META_TAG_NAMES = new Set([
  'system-reminder',
  'command-message',
  'command-name',
  'command-args',
  'local-command-stdout',
  'local-command-stderr',
  'user-prompt-submit-hook',
  'analysis',
  'summary',
  'example',
]);

const META_TAG_CLOSERS = new Map([...META_TAG_NAMES].map(t => [t, `</${t}>`]));

// Single-pass replacement for /<names>[^>]*>[\s\S]*?</\1>/g, which was
// quadratic on caller text with many unclosed meta-tag opens (CWE-1333):
// every <name> open rescanned the rest of the string for its closer via
// backreference + lazy body (measured ~7s on a 256KB bomb). indexOf() gives
// identical semantics without backtracking:
//   * open = '<' + name-prefix + '[^>]*' + first '>' — embedded '<'s and
//     quotes are allowed inside attrs, exactly like [^>]* (so `<a x="1>`
//     IS an open and `2">` is body; `<examplefoo>` IS an open of `example`)
//   * body is lazy — the FIRST '</name>' after the open closes it
//   * on a failed open the regex engine retries at i+1, so the scan
//     continues at the next '<'; once '</name>' is absent past a point it
//     is absent forever → the name is dead and later opens skip the search
//   * with no '>' left at all, no later open (or closer) can form → done
function stripMetaTagSpans(s) {
  const dead = new Set();
  const parts = [];
  let last = 0;
  let i = s.indexOf('<');
  while (i !== -1) {
    // '</…' can never begin a regex match — skip without a name lookup.
    if (s.charCodeAt(i + 1) === 47) { i = s.indexOf('<', i + 1); continue; }
    let name = null;
    for (const t of META_TAG_NAMES) {          // regex alternation = first
      if (s.startsWith(t, i + 1)) { name = t; break; }   // Set-order prefix match
    }
    if (!name || dead.has(name)) { i = s.indexOf('<', i + 1); continue; }
    const gt = s.indexOf('>', i + 1 + name.length);
    if (gt === -1) break;
    const ci = s.indexOf(META_TAG_CLOSERS.get(name), gt + 1);
    if (ci === -1) { dead.add(name); i = s.indexOf('<', i + 1); continue; }
    parts.push(s.slice(last, i));
    last = ci + name.length + 3;               // '</' + name + '>'
    // The consumed span is never rescanned — but the engine still requires
    // a literal '<' to open the next match, so resume at the NEXT real '<',
    // not at `last` itself (which can be any character: '</a>Xname b>c</a>'
    // must leave 'Xname b>c</a>' as literal text, not parse 'Xname' as an
    // open — L1 meta-review finding, regex keeps it verbatim).
    i = s.indexOf('<', last);
  }
  if (!parts.length) return s;
  parts.push(s.slice(last));
  return parts.join('');
}

function stripMetaTags(s) {
  if (typeof s !== 'string' || !s) return s;
  // This output only feeds the reuse fingerprint, so bounding the scanned
  // length on pathological inputs is safe (worst case is a fingerprint
  // cache miss). Linear scan below; the cap bounds the constant.
  if (s.length > 262144) s = s.slice(0, 262144);
  const stripped = stripMetaTagSpans(s).replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim();
  // Unknown tags are caller content. Never learn them into the global
  // stripping set or fingerprints stop being a pure function of the request.
  // Audit is open-tag only (no pairing): unclosed opens now log too — more
  // coverage than the old `<name>…</name>` match, never less. The scan is
  // manual because any `[^>]*>` regex is still quadratic when no '>'
  // remains on the residual caller text (`<x` ×131k took 42s pre-fix).
  const unknown = new Set();
  let ai = stripped.indexOf('<');
  while (ai !== -1) {
    const c1 = stripped.charCodeAt(ai + 1);
    if (c1 >= 97 && c1 <= 122) {               // '<' + [a-z] → open candidate
      const gt = stripped.indexOf('>', ai + 1);
      if (gt === -1) break;                    // no '>' left → no more opens
      let ne = ai + 1;
      while (ne < gt) {                        // name = maximal [a-z][-a-z_]*
        const cc = stripped.charCodeAt(ne);    // run (stops before '>' anyway)
        if (!((cc >= 97 && cc <= 122) || cc === 45 || cc === 95)) break;
        ne++;
      }
      const tagName = stripped.slice(ai + 1, ne);
      if (!META_TAG_NAMES.has(tagName)) unknown.add(tagName);
      ai = stripped.indexOf('<', gt + 1);      // regex /g resumes past the '>'
    } else {
      ai = stripped.indexOf('<', ai + 1);
    }
  }
  if (unknown.size) {
    console.error(`[META_TAG_AUDIT] Unknown XML tags in user message: ${[...unknown].join(', ')}`);
  }
  return stripped;
}

// Test-only handle (same `__` convention as src/auth.js) so the strip
// semantics can be asserted directly instead of through hash equality.
export function __stripMetaTags(s) { return stripMetaTags(s); }

// v2.0.61 (#111) — normalize dynamic chunks of the system prompt that
// drift across turns (today's date, ISO timestamps, working directory,
// session UUIDs) so the same logical Claude Code session keeps the
// same cascade fingerprint instead of cache-missing every turn.
//
// Without this, Claude Code's 26KB system prompt (which embeds the
// current date / cwd / session id) hashed differently every request,
// reuse silently fell back to fresh, and the model looked like it was
// "looping" because each call started a new cascade.
//
// Patterns are conservative — only normalize tokens that are
// (a) verifiably temporal/identifier-shaped and (b) common enough in
// real Claude Code system prompts that their presence dominated the
// hash. Plain prose drift remains in the hash so genuine prompt edits
// still create a fresh cascade.
function normalizeSystemPromptForHash(s) {
  let out = String(s || '');
  // ─── temporal / identifier tokens ────────────────────────────
  out = out
    // ISO 8601 timestamps (with or without ms / tz)
    .replace(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?/g, '<ts>')
    // "Today's date is YYYY-MM-DD" / "Today is YYYY-MM-DD" / etc.
    .replace(/\b(Today(?:'s)?\s+(?:date|is)(?:\s+is)?\s*[:\-]?\s*)\d{4}-\d{2}-\d{2}/gi, '$1<date>')
    // Bare YYYY-MM-DD lines (date-only) when standalone
    .replace(/(?<!\d)\d{4}-\d{2}-\d{2}(?!\d|T)/g, '<date>')
    // UUIDs (8-4-4-4-12 hex) — session/account ids, always
    .replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, '<uuid>')
    // Working directory lines (Claude Code prepends each turn).
    //
    // v2.0.78 (audit H-3): the previous form
    //   `'$&'.replace(/[^::]+$/, ' <cwd>')`
    // was a parse-time evaluation of `'$&'.replace(...)` which
    // collapsed to the literal string `' <cwd>'` — every match was
    // replaced by the bare placeholder, dropping the label too. Two
    // sessions whose only label difference was `Working directory:`
    // vs `cwd:` would hash identically (potential cross-session
    // reuse). Now uses a real capture group so the label is
    // preserved.
    .replace(/(^[ \t]*[-•]?\s*(?:Working\s+directory|Current\s+working\s+directory|cwd|CWD)\s*[::])[^\n]*/gim, '$1 <cwd>')
    // "Current time:" / "Time:" lines (same fix as above)
    .replace(/(^[ \t]*[-•]?\s*(?:Current\s+(?:date|time)|Time)\s*[::])[^\n]*/gim, '$1 <time>')
    // Session ID lines (Claude Code 2.x emits these)
    .replace(/(^[ \t]*[-•]?\s*(?:Session\s*ID|sessionId|session_id)\s*[::])[^\n]*/gim, '$1 <sessionid>')
    // Epoch timestamps in seconds (10 digits) or ms (13 digits) when
    // bare (not part of a longer number). Claude Code's status line and
    // some MCP servers emit these. 1700000000 (2023-11) to 2099999999
    // (2036) covers the realistic range without hitting other 10-digit
    // numbers like phone numbers or IDs.
    .replace(/(?<![\d.])(?:1[7-9]|20)\d{8}(?:\d{3})?(?![\d.])/g, '<epoch>');

  // ─── git status / recent commits block (Claude Code 2.x) ─────
  //
  // v2.0.74 (#116 zhangzhang-bit). Claude Code prepends a `gitStatus:`
  // block to the system prompt:
  //
  //     gitStatus: This is the git status at the start of the conversation.
  //
  //     Current branch: master
  //     Main branch (you will usually use this for PRs): master
  //     Git user: dwgx
  //     Status:
  //     M src/foo.js
  //     ?? newfile.txt
  //
  //     Recent commits:
  //     abc1234 release: 2.0.73 — ...
  //     def5678 release: 2.0.72 — ...
  //
  // The body shifts every time the user commits or touches a file but
  // the labels are invariant. zhangzhang-bit's #116 log shows
  // 26892-byte system prompts hashing differently across 30 turns —
  // commit-hash diffs in this block keep total length stable while
  // changing content, so length-based detection misses it.
  //
  // Strategy: collapse the body of `Status:`, `Recent commits:`, and
  // `Recent files:` to a stable placeholder. Headings + Branch / Main
  // branch / Git user keep their literal values (those rarely move
  // and meaningfully separate caches when they do). Lookahead anchors
  // the body extent at the next labelled heading or a blank line; if
  // the block runs to end-of-input, $(?![\s\S]) catches that too
  // since plain `$` under /m only matches end-of-line in JS.
  const NEXT_HEADING = '(?:Status|Recent commits|Recent files|gitStatus|Current branch|Main branch|Git user)\\s*:';
  const blockEnd = `(?=^[ \\t]*${NEXT_HEADING}|^\\s*$|$(?![\\s\\S]))`;
  out = out.replace(
    new RegExp(`^([ \\t]*Status\\s*:)[ \\t]*\\n[\\s\\S]*?${blockEnd}`, 'gim'),
    '$1\n<git-status>\n',
  );
  out = out.replace(
    new RegExp(`^([ \\t]*Recent commits\\s*:)[ \\t]*\\n[\\s\\S]*?${blockEnd}`, 'gim'),
    '$1\n<recent-commits>\n',
  );
  out = out.replace(
    new RegExp(`^([ \\t]*Recent files\\s*:)[ \\t]*\\n[\\s\\S]*?${blockEnd}`, 'gim'),
    '$1\n<recent-files>\n',
  );

  // ─── git short hashes ────────────────────────────────────────
  // After Recent commits is folded above we still want to catch stray
  // short hashes that callers paste inline ("see commit abc1234"). 7-12
  // hex chars with word boundaries; require at least one digit AND at
  // least one a-f letter so we skip both ordinary words like deadbeef
  // (no digits) and bare integers like 1234567890 (no hex letters).
  // Skip if wrapped in quotes (likely a literal string the user is
  // asking about — preserving it lets distinct queries hash distinctly).
  out = out.replace(/(?<![`'"\w])(?=[a-f0-9]*\d)(?=[a-f0-9]*[a-f])[a-f0-9]{7,12}(?![`'"\w])/gi, '<gitsha>');

  return out;
}

// Stable JSON: recursively sort object keys so {b:1,a:2} and {a:2,b:1}
// produce the same string. Without this, two equivalent inputs hash
// differently when client serialization order varies.
function stableStringify(v) {
  if (v === null || typeof v !== 'object') return JSON.stringify(v);
  if (Array.isArray(v)) return '[' + v.map(stableStringify).join(',') + ']';
  const keys = Object.keys(v).sort();
  return '{' + keys.map(k => JSON.stringify(k) + ':' + stableStringify(v[k])).join(',') + '}';
}

// Project one content block into a typed canonical record. Returns
// { type, ...payload } where payload uses stable hashes for binary media.
// `unhashable=true` flags blocks we cannot stably digest — caller's
// canonicalise() turns this into a `null` fingerprint, disabling reuse for
// the request rather than silently colliding distinct media inputs.
function canonicalContentBlock(part) {
  if (typeof part?.text === 'string') return { type: 'text', text: stripMetaTags(part.text) };
  if (typeof part === 'string') return { type: 'text', text: stripMetaTags(part) };
  const type = String(part?.type || '').toLowerCase();
  // image_url block (OpenAI / Anthropic image_url style)
  if (type === 'image_url' || type === 'image' || type === 'input_image') {
    const url = part?.image_url?.url || part?.url || '';
    if (typeof url === 'string' && url.startsWith('data:')) {
      const comma = url.indexOf(',');
      const meta = comma > 0 ? url.slice(5, comma) : '';
      const data = comma > 0 ? url.slice(comma + 1) : url;
      return { type: 'image', meta, hash: shortDigest(data, 16) };
    }
    if (typeof url === 'string' && url) return { type: 'image', url };
    if (typeof part?.source === 'object') {
      const src = part.source;
      if (src.type === 'base64' && typeof src.data === 'string') {
        return { type: 'image', meta: src.media_type || '', hash: shortDigest(src.data, 16) };
      }
      if (src.type === 'url' && typeof src.url === 'string') {
        return { type: 'image', url: src.url };
      }
      if (typeof src.file_id === 'string') return { type: 'image', file_id: src.file_id };
    }
    return { type: 'image', unhashable: true };
  }
  // file / document block
  if (type === 'document' || type === 'file' || type === 'input_file') {
    const fileId = part?.file_id || part?.source?.file_id;
    if (typeof fileId === 'string') return { type: 'file', file_id: fileId };
    if (part?.source?.type === 'base64' && typeof part.source.data === 'string') {
      return { type: 'file', meta: part.source.media_type || '', hash: shortDigest(part.source.data, 16) };
    }
    if (typeof part?.source?.url === 'string') return { type: 'file', url: part.source.url };
    return { type: 'file', unhashable: true };
  }
  // Any other typed block — stable JSON of the whole part. Catches things
  // like { type: 'tool_use', ... } when they appear in mixed content arrays.
  return { type: type || 'unknown', json: stableStringify(part ?? '') };
}

function canonicaliseContent(content) {
  if (typeof content === 'string') return [{ type: 'text', text: stripMetaTags(content) }];
  if (!Array.isArray(content)) return [{ type: 'json', json: stableStringify(content ?? '') }];
  return content.map(canonicalContentBlock);
}

function hasUnhashableMedia(blocks) {
  return Array.isArray(blocks) && blocks.some(b => b?.unhashable === true);
}

// Project assistant tool_calls into a stable digest. Both OpenAI
// `tool_calls: [{id, function:{name, arguments}}]` and Anthropic
// `content: [{type:'tool_use', name, input}]` shapes need to map to the
// same canonical form so the same logical call digests identically.
function projectAssistantToolCalls(m) {
  const calls = [];
  if (Array.isArray(m?.tool_calls)) {
    for (const tc of m.tool_calls) {
      const name = tc?.function?.name || tc?.name || '';
      const args = tc?.function?.arguments;
      let argsCanonical;
      if (typeof args === 'string') {
        try { argsCanonical = stableStringify(JSON.parse(args)); }
        catch { argsCanonical = args; }
      } else if (args !== undefined) {
        argsCanonical = stableStringify(args);
      } else if (tc?.input !== undefined) {
        argsCanonical = stableStringify(tc.input);
      } else {
        argsCanonical = '';
      }
      calls.push({ name, args: argsCanonical });
    }
  }
  if (Array.isArray(m?.content)) {
    for (const part of m.content) {
      if (part?.type === 'tool_use') {
        calls.push({ name: part.name || '', args: stableStringify(part.input ?? null) });
      }
    }
  }
  return calls;
}

function projectMessage(m) {
  const role = m?.role;
  if (role === 'system') {
    const blocks = canonicaliseContent(m.content);
    return { role: 'system', content: blocks };
  }
  if (role === 'user') {
    const blocks = canonicaliseContent(m.content);
    return { role: 'user', content: blocks };
  }
  if (role === 'tool') {
    return {
      role: 'tool_result',
      tool_call_id: typeof m?.tool_call_id === 'string' ? m.tool_call_id : '',
      content: canonicaliseContent(m.content),
    };
  }
  if (role === 'assistant') {
    // Project to a stable text + tool_calls digest. Drop reasoning / metadata
    // / id fields that drift across re-renders.
    const blocks = canonicaliseContent(m.content);
    const text = blocks
      .filter(b => b.type === 'text')
      .map(b => (b.text || '').replace(/\s+/g, ' ').trim())
      .join('\n')
      .trim();
    const toolCalls = projectAssistantToolCalls(m);
    return { role: 'assistant', text, tool_calls: toolCalls };
  }
  // Unknown role — preserve as-is so it's neither swallowed nor confused
  // with a known projection.
  return { role: String(role || 'unknown'), content: canonicaliseContent(m?.content) };
}

function systemDigest(messages) {
  // CASCADE_REUSE_HASH_SYSTEM=0 is an explicit opt-out for callers whose
  // system prompt drifts every turn (Claude Code with `cwd` snapshots etc.)
  // and who care more about hit rate than strict isolation. Default ON
  // since the audit found that "default exclude system" caused silent
  // cross-system reuse.
  if (process.env.CASCADE_REUSE_HASH_SYSTEM === '0') return '';
  const sys = messages.filter(m => m?.role === 'system');
  if (!sys.length) return '';
  // v2.0.61 (#111) — apply normalizeSystemPromptForHash to each text
  // block so dynamic chunks (today's date / cwd / session id / ISO ts /
  // UUIDs) don't drift the system fingerprint each turn.
  const normalized = sys.map(m => {
    const projected = projectMessage(m);
    if (Array.isArray(projected.content)) {
      projected.content = projected.content.map(b => {
        if (b?.type === 'text' && typeof b.text === 'string') {
          return { ...b, text: normalizeSystemPromptForHash(b.text) };
        }
        return b;
      });
    }
    return projected;
  });
  return shortDigest(stableStringify(normalized), 32);
}

function toolContextDigest(opts = {}) {
  if (!opts.emulateTools) return '';
  // v2.0.61 (#111) — sort tools by name before hashing so client-side
  // ordering changes (Claude Code 2.x occasionally reshuffles its 70+
  // tool list across turns) don't drift the tool fingerprint.
  const tools = (Array.isArray(opts.tools) ? opts.tools.map(t => {
    const fn = t?.function || t;
    return {
      name: fn?.name || '',
      description: fn?.description || '',
      parameters: fn?.parameters ?? fn?.input_schema ?? null,
    };
  }) : []).sort((a, b) => (a.name || '').localeCompare(b.name || ''));
  return shortDigest(stableStringify({
    tools,
    tool_choice: opts.toolChoice ?? null,
    preambleTier: opts.preambleTier ?? null,
    toolPreambleHash: opts.toolPreamble ? shortDigest(opts.toolPreamble, 16) : '',
  }), 32);
}

// Build the array of stable turns up to (but not including) the newest user
// or tool turn. This is what fpBefore digests. It includes every assistant
// turn and every system/user/tool turn except the trailing user/tool turn.
function priorTurnsForBefore(messages) {
  if (!Array.isArray(messages)) return null;
  // Find newest user/tool turn — that's the "newest" we drop.
  let newestStable = -1;
  for (let i = messages.length - 1; i >= 0; i--) {
    const r = messages[i]?.role;
    if (r === 'user' || r === 'tool') { newestStable = i; break; }
  }
  if (newestStable < 0) return null;
  // Need at least one prior turn to make reuse meaningful.
  if (newestStable === 0) return null;
  return messages.slice(0, newestStable);
}

function projectTurns(turns) {
  if (!Array.isArray(turns)) return null;
  const projected = [];
  for (const m of turns) {
    if (m?.role === 'system') continue; // system handled separately
    const p = projectMessage(m);
    if (Array.isArray(p.content) && hasUnhashableMedia(p.content)) return { unhashable: true };
    projected.push(p);
  }
  return { turns: projected };
}

function buildKeyPayload({ messages, modelKey, callerKey, opts, scope }) {
  const sys = systemDigest(messages);
  const tools = toolContextDigest(opts);
  const turnSlice = scope === 'after' ? messages : priorTurnsForBefore(messages);
  if (!turnSlice) return null;
  const projection = projectTurns(turnSlice);
  if (!projection) return null;
  if (projection.unhashable) return null;
  return stableStringify({
    v: KEY_VERSION,
    caller: String(callerKey || ''),
    model: String(modelKey || ''),
    route: opts?.route || 'chat',
    sys,
    tools,
    turns: projection.turns,
  });
}

/**
 * Fingerprint for "I'm about to send this newest user turn — find me a
 * cascade I can resume." Hashes everything before the newest user/tool
 * turn (including assistant text + tool_calls digest, system, tools).
 *
 * Signatures (backward-compatible):
 *   fingerprintBefore(messages)
 *   fingerprintBefore(messages, modelKey)
 *   fingerprintBefore(messages, modelKey, callerKey)
 *   fingerprintBefore(messages, modelKey, callerKey, opts)
 * where opts = { tools, toolChoice, toolPreamble, emulateTools,
 *                preambleTier, route }
 *
 * Returns null when reuse should be disabled (single-turn, unhashable
 * media in prior history, etc.).
 */
export function fingerprintBefore(messages, modelKey = '', callerKey = '', opts = {}) {
  const payload = buildKeyPayload({ messages, modelKey, callerKey, opts, scope: 'before' });
  if (!payload) return null;
  return sha256(payload);
}

/**
 * Fingerprint for "I just finished a turn — store the cascade under the
 * key the next request will look up." Same shape as fingerprintBefore but
 * over the FULL message list (the newest user turn is included so the
 * post-turn fingerprint represents server state right after that turn).
 */
export function fingerprintAfter(messages, modelKey = '', callerKey = '', opts = {}) {
  if (!Array.isArray(messages) || !messages.length) return null;
  // For "after" we want the entire trajectory we've seen, including the
  // newest user/tool/assistant turn the caller just exchanged with us.
  const sys = systemDigest(messages);
  const tools = toolContextDigest(opts);
  const projection = projectTurns(messages.filter(m => m?.role !== 'system'));
  if (!projection || projection.unhashable) return null;
  return sha256(stableStringify({
    v: KEY_VERSION,
    caller: String(callerKey || ''),
    model: String(modelKey || ''),
    route: opts?.route || 'chat',
    sys,
    tools,
    turns: projection.turns,
  }));
}

function effectiveTtl(entry) {
  const hint = Number(entry?.ttlHintMs);
  return Number.isFinite(hint) && hint > 0 ? hint : POOL_TTL_MS;
}

function tenantOf(callerKey) {
  const i = callerKey.indexOf(':');
  if (i < 0) return callerKey;
  const j = callerKey.indexOf(':', i + 1);
  return j < 0 ? callerKey : callerKey.slice(0, j);
}

function prune(now, admittingTenant = null) {
  for (const [fp, e] of _pool) {
    if (now - e.lastAccess > effectiveTtl(e)) { _pool.delete(fp); stats.expired++; }
  }
  if (_pool.size <= POOL_MAX) {
    _pruneCallerIndex();
    return;
  }
  const entries = [..._pool.entries()].sort((a, b) => a[1].lastAccess - b[1].lastAccess);
  // Recount only under pressure; checkout, aliases and generation invalidation
  // need no additional lifetime bookkeeping and cannot leak tenant counters.
  const counts = new Map();
  for (const [, e] of entries) {
    const tenant = tenantOf(e.callerKey || '');
    counts.set(tenant, (counts.get(tenant) || 0) + 1);
  }
  const fairShare = Math.max(1, Math.floor(POOL_MAX / counts.size));
  for (const [fp, e] of entries) {
    if (_pool.size <= POOL_MAX) break;
    const tenant = tenantOf(e.callerKey || '');
    if (counts.get(tenant) <= fairShare) continue;
    _pool.delete(fp);
    counts.set(tenant, counts.get(tenant) - 1);
    stats.evictions++;
  }
  // More tenants than slots cannot each retain a slot. Reject the admitting
  // tenant's entries instead of evicting another tenant's last live entry.
  // With T <= POOL_MAX the over-share pass above always makes enough room.
  if (_pool.size > POOL_MAX && admittingTenant !== null) {
    for (const [fp, e] of entries) {
      if (_pool.size <= POOL_MAX) break;
      if (tenantOf(e.callerKey || '') !== admittingTenant || !_pool.has(fp)) continue;
      _pool.delete(fp);
      stats.evictions++;
    }
  }
  _pruneCallerIndex();
}

function _pruneCallerIndex() {
  if (!REUSE_BY_CALLER || !_callerLatest.size) return;
  for (const [ck, fp] of _callerLatest) {
    if (!_pool.has(fp)) _callerLatest.delete(ck);
  }
}

/**
 * Check out a conversation if we have a matching fingerprint AND the caller
 * is willing to use the same (apiKey, lsPort) we stored. Removes the entry
 * from the pool — caller is expected to call `checkin()` with a new
 * fingerprint on success (or just drop it on failure and a fresh cascade
 * will be created next turn).
 *
 * v2.0.25 added optional `expected` for atomic owner verification at the
 * pool boundary (MED-3). Pass `{ apiKey, lsPort, lsGeneration }` and a
 * mismatch returns null + counts a miss without leaking the entry.
 */
export function checkout(fingerprint, callerKey = '', expected = null, modelKey = '') {
  if (!fingerprint) {
    // No fingerprint — try caller-based fallback before giving up.
    if (REUSE_BY_CALLER && callerKey && modelKey) {
      const fallback = _callerFallbackCheckout(callerKey, modelKey, expected);
      if (fallback) return fallback;
    }
    stats.misses++;
    return null;
  }
  const entry = _pool.get(fingerprint);
  if (!entry) {
    // Fingerprint miss — try caller-based fallback.
    if (REUSE_BY_CALLER && callerKey && modelKey) {
      const fallback = _callerFallbackCheckout(callerKey, modelKey, expected);
      if (fallback) return fallback;
    }
    stats.misses++;
    return null;
  }

  // Validate BEFORE removing from the pool. The previous order
  // (`delete` first, then check) had a subtle leak: when a caller's
  // request fingerprinted the same as someone else's (different
  // callerKey) we deleted the rightful owner's entry on the way to
  // returning null, so the legitimate caller lost their cascade
  // resume forever. Keep the entry in place on mismatch so the
  // owner's next turn still finds it.
  if (entry.callerKey && callerKey && entry.callerKey !== callerKey) {
    stats.misses++;
    return null;
  }
  if (Date.now() - entry.lastAccess > effectiveTtl(entry)) {
    _pool.delete(fingerprint);
    stats.expired++;
    stats.misses++;
    return null;
  }
  if (expected) {
    if (expected.apiKey && entry.apiKey && expected.apiKey !== entry.apiKey) { stats.misses++; return null; }
    if (expected.lsPort && entry.lsPort && expected.lsPort !== entry.lsPort) { stats.misses++; return null; }
    if (expected.lsGeneration != null && entry.lsGeneration != null && expected.lsGeneration !== entry.lsGeneration) {
      stats.misses++;
      return null;
    }
  }

  // Validated. Now remove and hand to the caller.
  _pool.delete(fingerprint);
  // Clean up caller index if it pointed at this fingerprint.
  if (REUSE_BY_CALLER && entry.callerKey) {
    for (const [ck, fp] of _callerLatest) {
      if (fp === fingerprint) _callerLatest.delete(ck);
    }
  }
  stats.hits++;
  return entry;
}

// Caller-based fallback: look up the latest fingerprint for this
// callerKey+model pair and check out that entry. Same validation as
// the primary path.
function _callerFallbackCheckout(callerKey, modelKey, expected) {
  const ck = `${callerKey}\0${modelKey}`;
  const fp = _callerLatest.get(ck);
  if (!fp) return null;
  const entry = _pool.get(fp);
  if (!entry) { _callerLatest.delete(ck); return null; }
  if (entry.callerKey && callerKey && entry.callerKey !== callerKey) return null;
  if (Date.now() - entry.lastAccess > effectiveTtl(entry)) {
    _pool.delete(fp);
    _callerLatest.delete(ck);
    stats.expired++;
    return null;
  }
  if (expected) {
    if (expected.apiKey && entry.apiKey && expected.apiKey !== entry.apiKey) return null;
    if (expected.lsPort && entry.lsPort && expected.lsPort !== entry.lsPort) return null;
    if (expected.lsGeneration != null && entry.lsGeneration != null && expected.lsGeneration !== entry.lsGeneration) return null;
  }
  _pool.delete(fp);
  _callerLatest.delete(ck);
  stats.callerFallbackHits++;
  stats.hits++;
  return entry;
}

/**
 * Store (or restore) a conversation entry under a new fingerprint.
 *
 * `fingerprint` accepts a single string OR an array of strings. When
 * an array is given the SAME entry is indexed under each fingerprint —
 * used by v2.0.87 auto-fallback (#129 wnfilm) to keep the cascade
 * findable under both the original-model fingerprint AND the
 * fallback-model fingerprint, so the next turn from the client (under
 * the original model name) doesn't miss the pool and force the LLM to
 * re-read history from scratch.
 *
 * `ttlHintMs` (optional) extends this entry's expiry past the pool's
 * default 30 min — used to honour Anthropic prompt-caching markers that
 * request a 1h ttl. Pass `undefined` (default) to keep the existing
 * entry-level hint when restoring across turns. Pass `0` (or negative)
 * to clear any inherited hint and fall back to the default TTL — used
 * when the next request explicitly does NOT carry a 1h marker so a stale
 * 1h window doesn't outlive its source request (MED-2).
 */
export function checkin(fingerprint, entry, callerKey = '', ttlHintMs) {
  if (!entry) return;
  const fingerprints = Array.isArray(fingerprint)
    ? fingerprint.filter((fp) => typeof fp === 'string' && fp)
    : (fingerprint ? [fingerprint] : []);
  if (!fingerprints.length) return;
  const now = Date.now();
  let resolvedHint;
  if (ttlHintMs === undefined) {
    resolvedHint = entry.ttlHintMs;
  } else if (ttlHintMs === null || !Number.isFinite(ttlHintMs) || ttlHintMs <= 0) {
    resolvedHint = undefined;
  } else {
    resolvedHint = ttlHintMs;
  }
  // Build the canonical entry once, then write under each requested
  // fingerprint. Each Map slot holds a separate object instance so a
  // future invalidate/checkout under one key doesn't accidentally
  // mutate the others.
  for (const fp of fingerprints) {
    _pool.set(fp, {
      cascadeId: entry.cascadeId,
      sessionId: entry.sessionId,
      lsPort: entry.lsPort,
      lsGeneration: entry.lsGeneration,
      apiKey: entry.apiKey,
      callerKey: callerKey || entry.callerKey || '',
      modelKey: entry.modelKey || '',
      stepOffset: Number.isFinite(entry.stepOffset) ? entry.stepOffset : 0,
      // D5/F6: null means "unknown window start", not record 0. Coercing it to 0
      // made the next resume ask the metadata RPC for the whole cascade and
      // report every earlier turn's tokens as that turn's usage. Keeping null
      // makes the client re-derive the count (or abstain from aggregate usage).
      generatorOffset: Number.isFinite(entry.generatorOffset) ? entry.generatorOffset : null,
      historyCoverage: entry.historyCoverage || null,
      createdAt: entry.createdAt || now,
      lastAccess: now,
      ...(Number.isFinite(resolvedHint) && resolvedHint > 0 ? { ttlHintMs: resolvedHint } : {}),
    });
  }
  // v2.0.88 (audit M-1): count once per logical checkin (one
  // conversation), not once per fingerprint slot. Otherwise the
  // dashboard's "stores" counter doubles whenever auto-fallback fires
  // and operators read pool efficiency wrong.
  stats.stores++;
  if (fingerprints.length > 1) stats.aliasWrites = (stats.aliasWrites || 0) + (fingerprints.length - 1);
  // Update caller-based secondary index.
  if (REUSE_BY_CALLER && (callerKey || entry.callerKey)) {
    const ck = callerKey || entry.callerKey;
    const mk = entry.modelKey || '';
    if (ck && fingerprints[0]) {
      _callerLatest.set(`${ck}\0${mk}`, fingerprints[0]);
    }
  }
  prune(now, tenantOf(callerKey || entry.callerKey || ''));
}

/**
 * Drop any entries that belong to a (apiKey, lsPort, lsGeneration) tuple
 * that just went away (account removed, LS restarted, LS replaced on the
 * same port). Keeps the pool honest.
 */
export function invalidateFor({ apiKey, lsPort, lsGeneration } = {}) {
  let dropped = 0;
  // v2.0.88 (audit H-2) — two-pass scan. v2.0.87 dual-write checkin
  // can index the same cascadeId under multiple fingerprint slots.
  // The previous single-pass `if (lsPort) delete` would only drop the
  // first slot it scanned; if the per-port scan iterated past a sibling
  // slot under different ordering, that slot stayed in the pool
  // pointing to a now-dead cascadeId on a now-restarted LS. Next turn
  // would hit `cascade not found`, set reuseEntryDead, and force a
  // full history replay — silent one-turn failure on every LS restart.
  // Pass 1: collect every cascadeId tied to the going-away tuple.
  // Pass 2: drop every slot pointing at any of those cascadeIds.
  const targetCascadeIds = new Set();
  for (const [, e] of _pool) {
    let hit = false;
    if (apiKey && e.apiKey === apiKey) hit = true;
    if (!hit && lsPort && e.lsPort === lsPort) {
      if (lsGeneration == null || e.lsGeneration == null || e.lsGeneration === lsGeneration) hit = true;
    }
    if (hit && e.cascadeId) targetCascadeIds.add(e.cascadeId);
  }
  for (const [fp, e] of _pool) {
    let drop = false;
    if (apiKey && e.apiKey === apiKey) drop = true;
    if (!drop && lsPort && e.lsPort === lsPort) {
      if (lsGeneration == null || e.lsGeneration == null || e.lsGeneration === lsGeneration) drop = true;
    }
    // Sibling-cleanup: any slot pointing at a cascadeId we already
    // decided to drop must also go, even if its own apiKey/lsPort
    // happens not to match (e.g. an alias slot whose apiKey was set
    // from the entry instead of the going-away account).
    if (!drop && e.cascadeId && targetCascadeIds.has(e.cascadeId)) drop = true;
    if (drop) {
      _pool.delete(fp);
      dropped++;
    }
  }
  // Clean caller index for dropped cascadeIds.
  if (REUSE_BY_CALLER && dropped > 0) {
    for (const [ck, fp] of _callerLatest) {
      if (!_pool.has(fp)) _callerLatest.delete(ck);
    }
  }
  return dropped;
}

export function poolStats() {
  return {
    size: _pool.size,
    maxSize: POOL_MAX,
    ttlMs: POOL_TTL_MS,
    reuseByCaller: REUSE_BY_CALLER,
    callerLatestSize: _callerLatest.size,
    ...stats,
    hitRate: stats.hits + stats.misses > 0
      ? ((stats.hits / (stats.hits + stats.misses)) * 100).toFixed(1)
      : '0.0',
  };
}

export function poolClear() {
  const n = _pool.size;
  _pool.clear();
  _callerLatest.clear();
  return n;
}

// Background prune — without this, expired entries accumulate when there
// are no checkin() calls for a while (e.g. a quiet weekend). .unref() so
// this timer never holds the process open past real work.
setInterval(() => prune(Date.now()), 5 * 60 * 1000).unref();