/** * Capture Heuristic — lightweight salience filter + pattern-based signal extraction. * * Borrowed from UltraMemory's auto-capture.ts, adapted for RecallNest: * - Reuses RecallNest's own noise-filter (richer than UltraMemory's) * - Extended Chinese patterns (primary user language) * - Returns items compatible with CaptureMemoryInput for batch persistence * * Zero LLM calls — pure regex/heuristic. * Persistence is the caller's responsibility (via persistMemoryBatch). */ import { isNoise } from "./noise-filter.js"; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- export interface AutoCaptureItem { text: string; category: "profile" | "preferences" | "events" | "cases" | "patterns"; importance: number; sourceContext: string; /** Replay priority for consolidation: higher = consolidate first. */ replayPriority: number; } export interface AutoCaptureResult { /** True if text was rejected by salience pre-filter (too short / noise / greeting) */ skippedSalience: boolean; items: AutoCaptureItem[]; } // --------------------------------------------------------------------------- // Salience pre-filter // --------------------------------------------------------------------------- /** Max items extracted per turn to avoid flooding the store. */ const MAX_ITEMS_PER_TURN = 5; /** Minimum text length worth analyzing (below this is almost always noise). */ const MIN_TEXT_LENGTH = 20; /** Greetings / affirmations / single-word responses (EN + ZH). */ const GREETING_RE = /^(hi|hello|hey|thanks|thank you|ok|yes|no|sure|got it|好的|谢谢|嗯|是的|好吧|行|可以|没问题|收到|明白|了解|知道了)[\s!.!。]*$/i; /** * Heuristic salience check — should this conversation turn be analyzed * for memory extraction? */ export function shouldCapture(text: string): boolean { if (!text || text.trim().length < MIN_TEXT_LENGTH) return false; if (isNoise(text)) return false; if (GREETING_RE.test(text.trim().toLowerCase())) return false; return true; } // --------------------------------------------------------------------------- // Heuristic extraction patterns // --------------------------------------------------------------------------- interface SignalPattern { re: RegExp; category: AutoCaptureItem["category"]; importance: number; sourceContext: string; /** Replay priority: corrections/explicit-memory > decisions > preferences/patterns. */ replayPriority: number; } const SIGNAL_PATTERNS: SignalPattern[] = [ // --- Preference signals (0.8) --- { re: /(?:\b(?:i prefer|i like|i don't like|i hate|i love|i want|i need|i always use|i never use)\b|我喜欢|我不喜欢|我偏好|我想要|我习惯|我一般用|我从不用|我讨厌|以后都用|以后不要)/i, category: "preferences", importance: 0.8, sourceContext: "preference signal", replayPriority: 0.5, }, // --- Identity / profile signals (0.9) --- { re: /(?:\b(?:my name is|i am a|i'm a|i work at|i live in|my role is|i'm responsible for)\b|我叫|我是|我在|我的角色|我负责|我的工作是|我住在)/i, category: "profile", importance: 0.9, sourceContext: "identity signal", replayPriority: 0.6, }, // --- Decision signals (0.7) --- { re: /(?:\b(?:i decided|we decided|let's go with|the decision is|we agreed|final call)\b|决定了|我们选择|最终方案|确定用|就这么定了|敲定)/i, category: "events", importance: 0.7, sourceContext: "decision signal", replayPriority: 0.7, }, // --- Correction signals (0.85 — high value, user explicitly correcting agent) --- { re: /(?:\b(?:actually|no,? not|that's wrong|correction:|you're wrong|that's incorrect)\b|更正|其实不是|不对|搞错了|纠正一下|你说错了|应该是)/i, category: "cases", importance: 0.85, sourceContext: "correction signal", replayPriority: 0.9, }, // --- Explicit memory instruction signals (0.85) --- { re: /(?:\b(?:remember that|don't forget|keep in mind|note that|important:)\b|记住|别忘了|注意|以后记得|帮我记|你要记住)/i, category: "events", importance: 0.85, sourceContext: "explicit memory instruction", replayPriority: 0.9, }, // --- Pattern / workflow signals (0.75) --- { re: /(?:\b(?:the pattern is|the workflow is|the process is|step 1|the rule is|always do)\b|流程是|步骤是|规则是|每次都要|固定做法|标准流程)/i, category: "patterns", importance: 0.75, sourceContext: "pattern signal", replayPriority: 0.6, }, ]; /** * Extract memory-worthy items from conversation text using simple heuristics. * * Lightweight alternative to LLM-based extraction. * Returns items ready for persistence via persistMemoryBatch. */ export function extractHeuristic(text: string): AutoCaptureItem[] { const items: AutoCaptureItem[] = []; const hasCJK = /[\u4e00-\u9fff\u3040-\u309f\u30a0-\u30ff\uac00-\ud7af]/.test(text); const minSentenceLen = hasCJK ? 6 : 15; const sentences = text .split(/[.!?。!?\n]+/) .map((s) => s.trim()) .filter((s) => s.length > minSentenceLen); for (let i = 0; i < sentences.length; i++) { const sentence = sentences[i]; for (const pattern of SIGNAL_PATTERNS) { if (pattern.re.test(sentence)) { // For correction signals, include the next sentence to capture // the actual corrected fact (e.g. "Actually, that's wrong. The limit is 200.") let capturedText = sentence; if (pattern.sourceContext === "correction signal" && i + 1 < sentences.length) { capturedText = `${sentence}. ${sentences[i + 1]}`; i++; // skip next sentence since we consumed it } items.push({ text: capturedText, category: pattern.category, importance: pattern.importance, sourceContext: pattern.sourceContext, replayPriority: pattern.replayPriority, }); break; // first matching pattern wins for this sentence } } if (items.length >= MAX_ITEMS_PER_TURN) break; } return items.slice(0, MAX_ITEMS_PER_TURN); } /** * Full auto-capture pipeline: salience check → heuristic extraction. */ export function autoCapture(text: string): AutoCaptureResult { if (!shouldCapture(text)) { return { skippedSalience: true, items: [] }; } return { skippedSalience: false, items: extractHeuristic(text) }; }