/** * Signature Cache — Phase 3 * * Dynamic 3-layer cache for thinking signatures (tool, model family, session). * Replaces hardcoded thinking signature patterns with adaptive detection. */ // 3-layer cache: tool → model family → session // Each layer stores patterns detected from responses interface SignatureContext { tool?: string; modelFamily?: string; sessionId?: string; } type SignatureLayer = Map>; const layers = { tool: new Map>(), // e.g. "cursor" → Set of signature patterns family: new Map>(), // e.g. "claude-sonnet" → Set of signature patterns session: new Map>(), // e.g. sessionId → Set of signature patterns }; // Known default signatures (bootstrap — will be supplemented by learning) const DEFAULT_SIGNATURES = [ "", "", "", "", "", "", ]; // Max entries per cache layer to prevent unbounded growth const MAX_ENTRIES_PER_LAYER = 100; const MAX_PATTERNS_PER_KEY = 20; /** * Get all matching signatures for a given context. * Checks all 3 layers and merges results with defaults. * * @param {object} context - { tool?, modelFamily?, sessionId? } * @returns {string[]} Array of unique signature patterns */ export function getSignatures(context: SignatureContext = {}): string[] { const patterns = new Set(DEFAULT_SIGNATURES); // Layer 1: Tool (e.g., "cursor", "cline", "antigravity") if (context.tool && layers.tool.has(context.tool)) { for (const p of layers.tool.get(context.tool)) patterns.add(p); } // Layer 2: Model family (e.g., "claude-sonnet", "claude-opus") if (context.modelFamily && layers.family.has(context.modelFamily)) { for (const p of layers.family.get(context.modelFamily)) patterns.add(p); } // Layer 3: Session-specific if (context.sessionId && layers.session.has(context.sessionId)) { for (const p of layers.session.get(context.sessionId)) patterns.add(p); } return Array.from(patterns); } /** * Add a discovered signature pattern to the cache. * * @param {string} pattern - The signature pattern (e.g., "") * @param {object} context - { tool?, modelFamily?, sessionId? } */ export function addSignature(pattern: unknown, context: SignatureContext = {}): void { if (!pattern || typeof pattern !== "string") return; const addToLayer = (layer: SignatureLayer, key: string | undefined) => { if (!key) return; if (!layer.has(key)) { if (layer.size >= MAX_ENTRIES_PER_LAYER) { // Evict oldest entry const firstKey = layer.keys().next().value; layer.delete(firstKey); } layer.set(key, new Set()); } const set = layer.get(key); if (set.size < MAX_PATTERNS_PER_KEY) { set.add(pattern); } }; addToLayer(layers.tool, context.tool); addToLayer(layers.family, context.modelFamily); addToLayer(layers.session, context.sessionId); } /** * Detect signatures in a text chunk from streaming response. * Auto-learns new patterns and adds them to cache. * * @param {string} text - Streaming text to scan * @param {object} context - { tool?, modelFamily?, sessionId? } * @returns {{ found: string[], cleaned: string }} Detected tags and cleaned text */ export function detectAndLearn( text: unknown, context: SignatureContext = {} ): { found: string[]; cleaned: unknown } { if (!text || typeof text !== "string") return { found: [], cleaned: text }; const found: string[] = []; let cleaned = text; // Check all known signatures const known = getSignatures(context); for (const sig of known) { if (cleaned.includes(sig)) { found.push(sig); cleaned = cleaned.split(sig).join(""); } } // Auto-detect new XML-like thinking tags const tagRegex = /<\/?([a-zA-Z_][a-zA-Z0-9_]*(?:Thinking|thinking|thought|Thought|internal_thought))>/g; let match; while ((match = tagRegex.exec(text)) !== null) { const tag = match[0]; if (!known.includes(tag)) { found.push(tag); addSignature(tag, context); cleaned = cleaned.split(tag).join(""); } } // Collapse excessive consecutive newlines left after tag removal (fixes #626) if (found.length > 0) { cleaned = cleaned.replace(/\n{3,}/g, "\n\n"); } return { found, cleaned: cleaned.trim() || cleaned }; } /** * Extract model family from model name * "claude-sonnet-4-20250514" → "claude-sonnet" * "gpt-4o-2024-08-06" → "gpt-4o" */ export function getModelFamily(model: unknown): string | null { if (!model) return null; // Remove date suffixes and version numbers const modelName = typeof model === "string" ? model : String(model); const cleaned = modelName .replace(/-\d{4}-\d{2}-\d{2}$/, "") // Remove YYYY-MM-DD suffix .replace(/-\d{8,}$/, "") // Remove YYYYMMDD suffix .replace(/-\d+(\.\d+)*$/, "") // Remove version suffix like -4 .replace(/@.*$/, ""); // Remove @latest etc. // Keep meaningful prefix return cleaned || modelName; } /** * Get cache stats (for dashboard) */ export function getCacheStats() { return { tool: { entries: layers.tool.size, patterns: Array.from(layers.tool.values()).reduce((sum, s) => sum + s.size, 0), }, family: { entries: layers.family.size, patterns: Array.from(layers.family.values()).reduce((sum, s) => sum + s.size, 0), }, session: { entries: layers.session.size, patterns: Array.from(layers.session.values()).reduce((sum, s) => sum + s.size, 0), }, defaultCount: DEFAULT_SIGNATURES.length, }; } /** * Clear all cache layers (for testing) */ export function clearCache() { layers.tool.clear(); layers.family.clear(); layers.session.clear(); }