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 }