/** * 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 = 500; const KEY_VERSION = 2; const _pool = new Map(); const stats = { hits: 0, misses: 0, stores: 0, evictions: 0, expired: 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', ]); function buildMetaTagRe() { const escaped = [...META_TAG_NAMES].map(t => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')); return new RegExp( `<(${escaped.join('|')})[^>]*>[\\s\\S]*?`, 'g' ); } let META_TAG_RE = buildMetaTagRe(); function stripMetaTags(s) { if (typeof s !== 'string' || !s) return s; const stripped = s.replace(META_TAG_RE, '').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. const remaining = stripped.match(/<([a-z][-a-z_]*)[^>]*>[\s\S]*?<\/\1>/g); if (remaining?.length) { const tagNames = remaining.map(m => m.match(/^<([a-z][-a-z_]*)/)?.[1]).filter(Boolean); const unknown = tagNames.filter(t => !META_TAG_NAMES.has(t)); if (unknown.length) { console.error(`[META_TAG_AUDIT] Unknown XML tags in user message: ${[...new Set(unknown)].join(', ')}`); } } return stripped; } // 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