Files
2026-07-13 13:39:12 +08:00

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();
}