(listing: AgentMemoryListing)
| 54 | * facing signal ("this memory used to exist but doesn't anymore"). |
| 55 | */ |
| 56 | export function buildMemoryGraph(listing: AgentMemoryListing): MemoryGraph { |
| 57 | // slug → entry, used for both reachability and dangling-ref detection. |
| 58 | // `core` lives in this index too so that a `[[core]]` ref from a memory |
| 59 | // back-references correctly (rare but possible). |
| 60 | const bySlug = new Map<string, EngramEntry>(); |
| 61 | if (listing.core) bySlug.set(listing.core.slug, listing.core); |
| 62 | for (const m of listing.memories) bySlug.set(m.slug, m); |
| 63 | |
| 64 | // Track which entries we've already placed in the tree. |
| 65 | const visited = new Set<string>(); |
| 66 | // refSlug → list of slugs that referenced it. Built lazily as we discover |
| 67 | // refs that don't resolve. |
| 68 | const danglingMap = new Map<string, string[]>(); |
| 69 | |
| 70 | function recordDangling(refSlug: string, referencedBy: string): void { |
| 71 | const list = danglingMap.get(refSlug); |
| 72 | if (list) { |
| 73 | // Don't double-count if the same body refs the same dangling slug |
| 74 | // twice — duplicate `[[foo]] [[foo]]` should still surface a single |
| 75 | // referrer in the UI. |
| 76 | if (!list.includes(referencedBy)) list.push(referencedBy); |
| 77 | } else { |
| 78 | danglingMap.set(refSlug, [referencedBy]); |
| 79 | } |
| 80 | } |
| 81 | |
| 82 | function buildNode(entry: EngramEntry): MemoryTreeNode { |
| 83 | visited.add(entry.slug); |
| 84 | const children: MemoryTreeNode[] = []; |
| 85 | for (const refSlug of entry.outgoingRefs) { |
| 86 | const target = bySlug.get(refSlug); |
| 87 | if (!target) { |
| 88 | recordDangling(refSlug, entry.slug); |
| 89 | continue; |
| 90 | } |
| 91 | // Skip self-refs and already-visited nodes; the visited guard makes |
| 92 | // the recursion finite even on cyclic graphs. |
| 93 | if (visited.has(target.slug)) continue; |
| 94 | children.push(buildNode(target)); |
| 95 | } |
| 96 | return { entry, children }; |
| 97 | } |
| 98 | |
| 99 | const rootedTree = listing.core ? buildNode(listing.core) : null; |
| 100 | |
| 101 | // Orphans = every memory not visited during the BFS from core. Sort by |
| 102 | // slug so the UI is deterministic across refetches. |
| 103 | const orphans = listing.memories |
| 104 | .filter((m) => !visited.has(m.slug)) |
| 105 | .slice() |
| 106 | .sort((a, b) => a.slug.localeCompare(b.slug)); |
| 107 | |
| 108 | // Even orphans can themselves cite refs we should surface. Walk them |
| 109 | // (no tree, just ref resolution) so dangling targets are complete. |
| 110 | for (const o of orphans) { |
| 111 | for (const refSlug of o.outgoingRefs) { |
| 112 | if (!bySlug.has(refSlug)) recordDangling(refSlug, o.slug); |
| 113 | } |
no test coverage detected