WindsurfAPI / src /conversation-pool.js
github-actions[bot]
Deploy from GitHub: 7495fde758f0be655f95e6331fec2898267f790c
f6266b9
/**
* 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;
}