190 lines
5.7 KiB
TypeScript
190 lines
5.7 KiB
TypeScript
/**
|
|
* 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<string, Set<string>>;
|
|
|
|
const layers = {
|
|
tool: new Map<string, Set<string>>(), // e.g. "cursor" → Set of signature patterns
|
|
family: new Map<string, Set<string>>(), // e.g. "claude-sonnet" → Set of signature patterns
|
|
session: new Map<string, Set<string>>(), // e.g. sessionId → Set of signature patterns
|
|
};
|
|
|
|
// Known default signatures (bootstrap — will be supplemented by learning)
|
|
const DEFAULT_SIGNATURES = [
|
|
"<antThinking>",
|
|
"</antThinking>",
|
|
"<thinking>",
|
|
"</thinking>",
|
|
"<internal_thought>",
|
|
"</internal_thought>",
|
|
];
|
|
|
|
// 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., "<antThinking>")
|
|
* @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();
|
|
}
|