/ src / lib / server / memory / memory-graph.ts
memory-graph.ts
  1  import type { AppSettings } from '@/types'
  2  
  3  export const DEFAULT_MEMORY_REFERENCE_DEPTH = 3
  4  export const DEFAULT_MAX_MEMORIES_PER_LOOKUP = 20
  5  export const DEFAULT_MAX_LINKED_MEMORIES_EXPANDED = 60
  6  
  7  const MAX_MEMORY_REFERENCE_DEPTH = 12
  8  const MAX_MEMORIES_PER_LOOKUP = 200
  9  const MAX_LINKED_MEMORIES_EXPANDED = 1000
 10  
 11  export interface MemoryLookupLimits {
 12    maxDepth: number
 13    maxPerLookup: number
 14    maxLinkedExpansion: number
 15  }
 16  
 17  export interface MemoryLookupRequest {
 18    depth?: number | null
 19    limit?: number | null
 20    linkedLimit?: number | null
 21  }
 22  
 23  export interface LinkedMemoryNode {
 24    id: string
 25    linkedMemoryIds?: string[]
 26  }
 27  
 28  export interface TraversalResult<TNode extends LinkedMemoryNode> {
 29    entries: TNode[]
 30    truncated: boolean
 31    expandedLinkedCount: number
 32  }
 33  
 34  function parseIntSetting(value: unknown): number | null {
 35    const parsed = typeof value === 'number'
 36      ? value
 37      : typeof value === 'string'
 38        ? Number.parseInt(value, 10)
 39        : Number.NaN
 40    if (!Number.isFinite(parsed)) return null
 41    return Math.trunc(parsed)
 42  }
 43  
 44  function clamp(value: number, min: number, max: number): number {
 45    return Math.max(min, Math.min(max, value))
 46  }
 47  
 48  export function normalizeMemoryLookupLimits(
 49    settings: Partial<AppSettings> | Record<string, unknown>,
 50  ): MemoryLookupLimits {
 51    const depthRaw = parseIntSetting(settings.memoryReferenceDepth ?? settings.memoryMaxDepth)
 52    const perLookupRaw = parseIntSetting(settings.maxMemoriesPerLookup ?? settings.memoryMaxPerLookup)
 53    const linkedRaw = parseIntSetting(settings.maxLinkedMemoriesExpanded)
 54  
 55    const maxDepth = clamp(depthRaw ?? DEFAULT_MEMORY_REFERENCE_DEPTH, 0, MAX_MEMORY_REFERENCE_DEPTH)
 56    const maxPerLookup = clamp(perLookupRaw ?? DEFAULT_MAX_MEMORIES_PER_LOOKUP, 1, MAX_MEMORIES_PER_LOOKUP)
 57    const maxLinkedExpansion = clamp(linkedRaw ?? DEFAULT_MAX_LINKED_MEMORIES_EXPANDED, 0, MAX_LINKED_MEMORIES_EXPANDED)
 58  
 59    return { maxDepth, maxPerLookup, maxLinkedExpansion }
 60  }
 61  
 62  export function resolveLookupRequest(
 63    defaults: MemoryLookupLimits,
 64    request: MemoryLookupRequest = {},
 65  ): MemoryLookupLimits {
 66    const depth = parseIntSetting(request.depth)
 67    const limit = parseIntSetting(request.limit)
 68    const linkedLimit = parseIntSetting(request.linkedLimit)
 69  
 70    return {
 71      maxDepth: clamp(depth ?? defaults.maxDepth, 0, defaults.maxDepth),
 72      maxPerLookup: clamp(limit ?? defaults.maxPerLookup, 1, defaults.maxPerLookup),
 73      maxLinkedExpansion: clamp(linkedLimit ?? defaults.maxLinkedExpansion, 0, defaults.maxLinkedExpansion),
 74    }
 75  }
 76  
 77  export function normalizeLinkedMemoryIds(input: unknown, selfId?: string): string[] {
 78    if (!Array.isArray(input)) return []
 79    const out: string[] = []
 80    const seen = new Set<string>()
 81    for (const raw of input) {
 82      const id = typeof raw === 'string' ? raw.trim() : ''
 83      if (!id || id === selfId || seen.has(id)) continue
 84      seen.add(id)
 85      out.push(id)
 86    }
 87    return out
 88  }
 89  
 90  export function traverseLinkedMemoryGraph<TNode extends LinkedMemoryNode>(
 91    seedNodes: TNode[],
 92    opts: MemoryLookupLimits,
 93    fetchByIds: (ids: string[]) => TNode[],
 94  ): TraversalResult<TNode> {
 95    if (!seedNodes.length || opts.maxPerLookup <= 0) {
 96      return { entries: [], truncated: false, expandedLinkedCount: 0 }
 97    }
 98  
 99    const seen = new Set<string>()
100    const seedIds = new Set(seedNodes.map((n) => n.id))
101    const out: TNode[] = []
102    let queue: TNode[] = [...seedNodes]
103    let depth = 0
104    let truncated = false
105    let expandedLinkedCount = 0
106  
107    while (queue.length > 0 && depth <= opts.maxDepth) {
108      const nextQueue: TNode[] = []
109      for (const entry of queue) {
110        if (seen.has(entry.id)) continue
111  
112        const isLinkedExpansion = !seedIds.has(entry.id)
113        if (isLinkedExpansion) {
114          if (expandedLinkedCount >= opts.maxLinkedExpansion) {
115            truncated = true
116            return { entries: out, truncated, expandedLinkedCount }
117          }
118          expandedLinkedCount++
119        }
120  
121        seen.add(entry.id)
122        out.push(entry)
123        if (out.length >= opts.maxPerLookup) {
124          truncated = true
125          return { entries: out, truncated, expandedLinkedCount }
126        }
127  
128        if (depth >= opts.maxDepth) continue
129        const linkedIds = normalizeLinkedMemoryIds(entry.linkedMemoryIds, entry.id).filter((id) => !seen.has(id))
130        if (!linkedIds.length) continue
131        const linkedEntries = fetchByIds(linkedIds)
132        for (const linked of linkedEntries) {
133          if (!linked?.id || seen.has(linked.id)) continue
134          nextQueue.push(linked)
135        }
136      }
137      queue = nextQueue
138      depth++
139    }
140  
141    return { entries: out, truncated, expandedLinkedCount }
142  }