memory-policy.ts
1 import type { MemoryEntry } from '@/types' 2 3 // Shape subset — we only need the boolean signals the LLM classifier emits. 4 // Typed loosely here to avoid a circular import with chat-execution. 5 type ClassificationHint = { 6 isCurrentThreadRecall?: boolean 7 isGreeting?: boolean 8 isAcknowledgement?: boolean 9 isMemoryWriteIntent?: boolean 10 } | null | undefined 11 12 // The regexes below are kept as fallbacks: when the LLM classifier returns 13 // null (timeout, no provider), these cover the common English phrasings so 14 // the system degrades gracefully. Paraphrases, non-English, or novel wordings 15 // are handled by the classifier path in callers. 16 const ACK_RE = /^(?:ok(?:ay)?|cool|nice|got it|makes sense|thanks|thank you|thx|roger|copy|sounds good|sgtm|yep|yup|y|nope?|nah|kk|done)[.! ]*$/i 17 const GREETING_RE = /^(?:hi|hello|hey|yo|morning|good morning|good afternoon|good evening)[.! ]*$/i 18 const MEMORY_META_RE = /\b(?:remember|memory|memorize|store this|save this|forget)\b/i 19 const LOW_SIGNAL_RESPONSE_RE = /^(?:HEARTBEAT_OK|NO_MESSAGE)\b/i 20 const CURRENT_THREAD_RECALL_MARKER_RE = /\b(?:this conversation|this chat|this thread|current conversation|current chat|current thread|same thread|same chat|same conversation|earlier in (?:this )?(?:conversation|chat|thread)|from (?:this|our) (?:conversation|chat|thread)|you just stored|you just said|you just gave|you just told|you just answered|you just replied|i just (?:said|asked|gave|told|mentioned)|we just (?:discussed|decided|talked)|your last (?:reply|answer|response|message)|my last (?:question|message)|above in (?:this |the )?(?:chat|thread|conversation)|(?:both|two|all) (?:answers|numbers|results|replies|responses))\b/i 21 const CURRENT_THREAD_RECALL_INTENT_RE = /\b(?:what|which|who|when|where|did|remind|recap|summarize|repeat|list|tell me|answer|confirm|recall|mention)\b/i 22 const DIRECT_MEMORY_WRITE_MARKER_RE = /\b(?:remember|memorize|store (?:this|that|the fact|it)|save (?:this|that|the fact|it) (?:to|in) memory|write to memory|add to memory|update.*memory|correct.*memory)\b/i 23 const DIRECT_MEMORY_WRITE_FOLLOWUP_RE = /\b(?:confirm|recap|repeat|summarize|what you just stored|what you saved|what you updated)\b/i 24 25 function normalizeWhitespace(value: string): string { 26 return value.replace(/\s+/g, ' ').trim() 27 } 28 29 function lower(value: string | null | undefined): string { 30 return normalizeWhitespace(value || '').toLowerCase() 31 } 32 33 export function shouldInjectMemoryContext( 34 message: string, 35 classification?: ClassificationHint, 36 ): boolean { 37 const trimmed = normalizeWhitespace(message) 38 if (!trimmed) return false 39 // Prefer the LLM classifier's judgment when available — it generalizes across 40 // paraphrases and non-English phrasings that the static regexes miss. 41 if (classification) { 42 if (classification.isGreeting === true) return false 43 if (classification.isAcknowledgement === true) return false 44 if (classification.isMemoryWriteIntent === true && trimmed.length < 24) return false 45 return true 46 } 47 // Regex fallback for when classifier is unavailable. 48 if (trimmed.length < 16 && (ACK_RE.test(trimmed) || GREETING_RE.test(trimmed))) return false 49 if (trimmed.length < 24 && MEMORY_META_RE.test(trimmed)) return false 50 return true 51 } 52 53 export function isCurrentThreadRecallRequest( 54 message: string, 55 classification?: ClassificationHint, 56 ): boolean { 57 const trimmed = normalizeWhitespace(message) 58 if (!trimmed) return false 59 if (classification?.isCurrentThreadRecall === true) return true 60 // Regex fallback. Skip when classifier confidently said "not thread recall" 61 // (isCurrentThreadRecall === false explicitly — not just missing). 62 if (classification && classification.isCurrentThreadRecall === false) return false 63 if (!CURRENT_THREAD_RECALL_MARKER_RE.test(trimmed)) return false 64 if (DIRECT_MEMORY_WRITE_MARKER_RE.test(trimmed) && DIRECT_MEMORY_WRITE_FOLLOWUP_RE.test(trimmed)) return false 65 if (/\b(?:remember|store|save)\b/i.test(trimmed) && !/\?\s*$/.test(trimmed) && !/\b(?:what|which|who|when|where|did|confirm|recap|summarize|repeat|list|tell me|answer|recall)\b/i.test(trimmed)) { 66 return false 67 } 68 return CURRENT_THREAD_RECALL_INTENT_RE.test(trimmed) || /\?\s*$/.test(trimmed) 69 } 70 71 export function shouldAutoCaptureMemoryTurn(message: string, response: string): boolean { 72 const normalizedMessage = normalizeWhitespace(message) 73 const normalizedResponse = normalizeWhitespace(response) 74 if (normalizedMessage.length < 20 || normalizedResponse.length < 40) return false 75 if (ACK_RE.test(normalizedMessage) || GREETING_RE.test(normalizedMessage)) return false 76 if (LOW_SIGNAL_RESPONSE_RE.test(normalizedResponse)) return false 77 if (MEMORY_META_RE.test(normalizedMessage) && normalizedMessage.length < 120) return false 78 if (/^(?:sorry|i can(?:not|'t)|unable to|i do not have|i don't have)\b/i.test(normalizedResponse)) return false 79 return true 80 } 81 82 export function shouldAutoCaptureMemory( 83 input: { message?: string | null; response?: string | null } | string, 84 response?: string, 85 ): boolean { 86 if (typeof input === 'string') { 87 return shouldAutoCaptureMemoryTurn(input, response || '') 88 } 89 return shouldAutoCaptureMemoryTurn(input.message || '', input.response || '') 90 } 91 92 // eslint-disable-next-line @typescript-eslint/no-unused-vars 93 export function normalizeMemoryCategory(input: string | null | undefined, _title?: string | null, _content?: string | null): string { 94 const explicit = lower(input) 95 96 const mapExplicit = (value: string): string | null => { 97 if (!value || value === 'note' || value === 'notes') return null 98 if (['preference', 'preferences', 'likes', 'dislikes'].includes(value)) return 'identity/preferences' 99 if (['identity', 'profile', 'persona'].includes(value)) return 'identity/profile' 100 if (['relationship', 'relationships', 'people'].includes(value)) return 'identity/relationships' 101 if (['contact', 'contacts'].includes(value)) return 'identity/contacts' 102 if (['routine', 'routines', 'schedule', 'habit', 'habits'].includes(value)) return 'identity/routines' 103 if (['event', 'events', 'life event', 'life events', 'significant', 'milestone'].includes(value)) return 'identity/events' 104 if (['goal', 'goals', 'objective', 'objectives', 'target', 'targets'].includes(value)) return 'identity/goals' 105 if (['instruction', 'instructions', 'directive', 'directives', 'standing order', 'rule', 'rules'].includes(value)) return 'knowledge/instructions' 106 if (['decision', 'decisions', 'choice'].includes(value)) return 'projects/decisions' 107 if (['learning', 'learnings', 'lesson', 'lessons'].includes(value)) return 'projects/learnings' 108 if (['project', 'projects', 'task', 'tasks'].includes(value)) return 'projects/context' 109 if (['error', 'errors', 'incident', 'incidents', 'failure', 'failures'].includes(value)) return 'execution/errors' 110 if (['breadcrumb', 'execution', 'run', 'runs'].includes(value)) return 'operations/execution' 111 if (['fact', 'facts', 'knowledge', 'reference'].includes(value)) return 'knowledge/facts' 112 if (['working', 'scratch', 'draft'].includes(value)) return 'working/scratch' 113 if (value.includes('/')) return value 114 return value 115 } 116 117 const explicitMapped = mapExplicit(explicit) 118 if (explicitMapped) return explicitMapped 119 120 // No content-sniffing regex — the agent picks the category via the guidance 121 // in its memory policy block. We just normalize explicit aliases above and 122 // fall back to knowledge/facts for uncategorized entries. 123 return explicit && explicit !== 'note' && explicit !== 'notes' ? explicit : 'knowledge/facts' 124 } 125 126 export function buildMemoryDoctorReport(entries: MemoryEntry[], agentId?: string | null): string { 127 const topLevelCounts = new Map<string, number>() 128 let pinned = 0 129 let linked = 0 130 let shared = 0 131 132 for (const entry of entries) { 133 const category = normalizeMemoryCategory(entry.category, entry.title, entry.content) 134 const topLevel = category.split('/')[0] || 'other' 135 topLevelCounts.set(topLevel, (topLevelCounts.get(topLevel) || 0) + 1) 136 if (entry.pinned) pinned += 1 137 if (entry.linkedMemoryIds?.length) linked += 1 138 if (entry.sharedWith?.length) shared += 1 139 } 140 141 const categories = [...topLevelCounts.entries()] 142 .sort((left, right) => right[1] - left[1]) 143 .map(([name, count]) => `- ${name}: ${count}`) 144 145 return [ 146 'Memory Doctor', 147 `Agent scope: ${agentId || 'global/all'}`, 148 `Visible memories: ${entries.length}`, 149 `Pinned: ${pinned}`, 150 `Linked: ${linked}`, 151 `Shared: ${shared}`, 152 categories.length ? 'Top-level categories:' : 'Top-level categories: none', 153 ...(categories.length ? categories : []), 154 ].join('\n') 155 } 156 157 export function inferAutomaticMemoryCategory(message: string, response: string): string { 158 return normalizeMemoryCategory('note', message, response) 159 }