/** * 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, ``])); // Single-pass replacement for /[^>]*>[\s\S]*?/g, which was // quadratic on caller text with many unclosed meta-tag opens (CWE-1333): // every 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 `` is body; `` IS an open of `example`) // * body is lazy — the FIRST '' after the open closes it // * on a failed open the regex engine retries at i+1, so the scan // continues at the next '<'; once '' 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) { // '', 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; // '' // 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: 'Xname b>c' // must leave 'Xname b>c' 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 `…` match, never less. The scan is // manual because any `[^>]*>` regex is still quadratic when no '>' // remains on the residual caller text (`= 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, '') // "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') // Bare YYYY-MM-DD lines (date-only) when standalone .replace(/(?') // 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, '') // Working directory lines (Claude Code prepends each turn). // // v2.0.78 (audit H-3): the previous form // `'$&'.replace(/[^::]+$/, ' ')` // was a parse-time evaluation of `'$&'.replace(...)` which // collapsed to the literal string `' '` — 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 ') // "Current time:" / "Time:" lines (same fix as above) .replace(/(^[ \t]*[-•]?\s*(?:Current\s+(?:date|time)|Time)\s*[::])[^\n]*/gim, '$1