Spaces:
Sleeping
Sleeping
| /** | |
| * 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 "fingerprint" of the conversation up to (but not including) | |
| * the newest user message. A client sending [u1, a1, u2] looks up fp([u1, a1]); | |
| * a hit means we already drove the cascade to exactly that state. We then | |
| * `SendUserCascadeMessage(u2)` on the stored cascade_id and, on success, | |
| * re-store the entry under fp([u1, a1, u2, a2]) for the next turn. | |
| * | |
| * 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 10 min; LRU eviction at 500 entries. | |
| */ | |
| import { createHash } from 'crypto'; | |
| const POOL_TTL_MS = 10 * 60 * 1000; | |
| const POOL_MAX = 500; | |
| // fingerprint -> { cascadeId, sessionId, lsPort, apiKey, createdAt, lastAccess } | |
| 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'); | |
| } | |
| /** | |
| * Canonicalise a message list for hashing. Strips anything that could drift | |
| * between turns (id, name, tool metadata) and normalises content to a | |
| * string so array/string forms collide correctly. | |
| */ | |
| function canonicalise(messages) { | |
| return messages.map(m => ({ | |
| role: m.role, | |
| content: typeof m.content === 'string' | |
| ? m.content | |
| : Array.isArray(m.content) | |
| ? m.content.map(p => (typeof p?.text === 'string' ? p.text : JSON.stringify(p))).join('') | |
| : JSON.stringify(m.content ?? ''), | |
| })); | |
| } | |
| /** | |
| * Fingerprint for "resume this conversation". Uses all messages except the | |
| * latest user turn, which is the one we're about to forward. | |
| * Returns null when there's nothing to resume (first turn or no prior | |
| * assistant reply). | |
| */ | |
| export function fingerprintBefore(messages) { | |
| if (!Array.isArray(messages) || messages.length < 2) return null; | |
| // Must have at least one assistant turn in the history — otherwise the | |
| // previous "cascade" never actually existed from our side. | |
| const history = messages.slice(0, -1); | |
| if (!history.some(m => m.role === 'assistant')) return null; | |
| return sha256(JSON.stringify(canonicalise(history))); | |
| } | |
| /** | |
| * Fingerprint for the full conversation after we append our assistant turn. | |
| * This is what the *next* request's `fingerprintBefore` will look up. | |
| */ | |
| export function fingerprintAfter(messages, assistantText) { | |
| const full = [...messages, { role: 'assistant', content: assistantText || '' }]; | |
| return sha256(JSON.stringify(canonicalise(full))); | |
| } | |
| function prune(now) { | |
| if (_pool.size <= POOL_MAX) return; | |
| // Drop oldest entries until back under the cap. | |
| const entries = [..._pool.entries()].sort((a, b) => a[1].lastAccess - b[1].lastAccess); | |
| const toDrop = entries.length - POOL_MAX; | |
| for (let i = 0; i < toDrop; i++) { | |
| _pool.delete(entries[i][0]); | |
| stats.evictions++; | |
| } | |
| } | |
| /** | |
| * 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). | |
| */ | |
| export function checkout(fingerprint) { | |
| if (!fingerprint) { stats.misses++; return null; } | |
| const entry = _pool.get(fingerprint); | |
| if (!entry) { stats.misses++; return null; } | |
| _pool.delete(fingerprint); | |
| if (Date.now() - entry.lastAccess > POOL_TTL_MS) { | |
| stats.expired++; | |
| return null; | |
| } | |
| stats.hits++; | |
| return entry; | |
| } | |
| /** | |
| * Store (or restore) a conversation entry under a new fingerprint. | |
| */ | |
| export function checkin(fingerprint, entry) { | |
| if (!fingerprint || !entry) return; | |
| const now = Date.now(); | |
| _pool.set(fingerprint, { | |
| cascadeId: entry.cascadeId, | |
| sessionId: entry.sessionId, | |
| lsPort: entry.lsPort, | |
| apiKey: entry.apiKey, | |
| createdAt: entry.createdAt || now, | |
| lastAccess: now, | |
| }); | |
| stats.stores++; | |
| prune(now); | |
| } | |
| /** | |
| * Drop any entries that belong to a (apiKey, lsPort) pair that just went | |
| * away (account removed, LS restarted). Keeps the pool honest. | |
| */ | |
| export function invalidateFor({ apiKey, lsPort }) { | |
| let dropped = 0; | |
| for (const [fp, e] of _pool) { | |
| if ((apiKey && e.apiKey === apiKey) || (lsPort && e.lsPort === lsPort)) { | |
| _pool.delete(fp); | |
| dropped++; | |
| } | |
| } | |
| return dropped; | |
| } | |
| export function poolStats() { | |
| return { | |
| size: _pool.size, | |
| maxSize: POOL_MAX, | |
| ttlMs: POOL_TTL_MS, | |
| ...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(); | |
| return n; | |
| } | |