/ src / lib / server / memory / memory-policy.ts
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  }