@andespindola/brainlink 0.1.0-beta.117 → 0.1.0-beta.119

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -84,11 +84,11 @@ Legacy `.jsonl.gz` packs are upgraded to `.blpk` automatically on first search/c
84
84
  - Graph renderer optimized for large datasets with viewport-driven node culling and edge lookup by visible nodes.
85
85
  - Canvas graph rendering uses the same batched node and edge pipeline for every graph size, reducing per-frame draw calls while keeping selected and hovered items highlighted.
86
86
  - WebGL acceleration is used when available for dense node and edge drawing, with Canvas 2D preserved as the interaction and fallback layer.
87
- - Graph rendering uses a rebuilt Bloom-like flat scene: no macro galaxy, no recursive subgraphs, no cluster replacement layer and no synthetic 3D projection.
87
+ - Graph rendering keeps the flat node scene and adds stable hierarchical groups for vaults above 1000 notes, with recursive parent groups when a level itself exceeds 1000 groups.
88
88
  - Large graph layout API automatically uses compact payload encoding with link-coverage-aware edge selection to reduce initial client load without hiding major relationships.
89
89
  - Large-segment layout spacing now grows logarithmically to keep initial visual density consistent between medium and very large vaults (for example, ~1k vs ~50k notes).
90
90
  - Graph coordinates are visually compacted across graph sizes so reset starts from a stable fitted scene and zoom-in progressively reveals local detail.
91
- - Zoomed-out graph LOD samples visible nodes and priority links without switching to nested subgraphs or cluster markers.
91
+ - Zoomed-out graph LOD samples visible nodes, priority links and stable hierarchy groups without replacing the whole scene during zoom.
92
92
  - Graph reset fits the full graph scene instead of starting in a separate macro overview mode.
93
93
  - Graph filtering runs in a dedicated browser worker to keep the UI thread responsive during heavy datasets.
94
94
  - Edge rendering budgets adapt to zoom level to prevent frame spikes on large graph panoramas.
@@ -596,7 +596,7 @@ The graph UI shows:
596
596
  - graph controls for zoom in, zoom out, fit visible nodes and reset-to-fit-all
597
597
  - wheel zoom (including `cmd+scroll` and `ctrl+scroll`) anchored to cursor position for faster navigation in large graphs
598
598
  - continuous target-scale interpolation for wheel/button zoom to avoid abrupt jumps while keeping cursor-anchored focus
599
- - Bloom-like scene navigation: reset fits the current graph scene, wheel zoom stays anchored to the cursor, and WebGL acceleration is used only to draw the same flat graph faster
599
+ - Bloom-like scene navigation: reset fits the current graph scene, wheel zoom stays anchored to the cursor, and WebGL acceleration draws the dense node and edge layer faster
600
600
  - zoom-out floor for large and massive graphs to keep the scene reachable without switching into a separate macro graph mode
601
601
  - keyboard shortcuts: `+` zoom in, `-` zoom out, `0` reset fit
602
602
  - double-click on canvas zooms in at cursor position
@@ -604,7 +604,7 @@ The graph UI shows:
604
604
  - graph rendering safeguards (batched canvas drawing across graph sizes, edge draw caps, lower redraw rate, zoom-aware interaction)
605
605
  - adaptive CPU safeguards for large graphs: idle frame pacing, throttled background physics updates and cached viewport dimensions to reduce redraw/layout overhead while preserving interaction responsiveness
606
606
  - WebGL node and edge acceleration when supported, falling back to Canvas 2D without changing graph behavior
607
- - large graph LOD keeps the same scene model as smaller graphs: visible nodes are sampled near the viewport/focus, real links are prioritized, and no macro galaxy, recursive graph-of-graphs, cluster replacement layer or synthetic 3D projection is applied
607
+ - large graph LOD keeps the same real-node scene model as smaller graphs: visible nodes are sampled near the viewport/focus, real links are prioritized, and stable hierarchy groups provide zoom-out context without a synthetic 3D projection
608
608
 
609
609
  The server indexes before starting by default. Use `--no-index` to skip that step:
610
610
 
@@ -8,7 +8,10 @@ const renderNodeBudget = 900
8
8
  const zoomedMassiveRenderNodeBudget = 2200
9
9
  const massiveOverviewRenderNodeBudget = 1800
10
10
  const massiveOverviewScaleThreshold = 0.065
11
+ const massiveSegmentedScaleThreshold = 0.45
12
+ const massiveSegmentRepresentativeBudget = 760
11
13
  const massiveAutoFitMacroScale = 0.018
14
+ const hierarchyGroupScaleThreshold = 0.62
12
15
  const minNodePixelRadius = 2.3
13
16
  const viewportPaddingPx = 280
14
17
  const worldCoordinateLimit = 5_000_000
@@ -36,6 +39,9 @@ const physicsStepDeltaCapMs = 96
36
39
  const state = {
37
40
  graph: { nodes: [], edges: [] },
38
41
  nodes: [],
42
+ groups: [],
43
+ groupById: new Map(),
44
+ leafGroups: [],
39
45
  nodeById: new Map(),
40
46
  edges: [],
41
47
  visibleNodes: [],
@@ -1149,6 +1155,7 @@ const drawAcceleratedGraph = (width, height, drawEdges) => {
1149
1155
  ctx.save()
1150
1156
  ctx.translate(state.transform.x, state.transform.y)
1151
1157
  ctx.scale(state.transform.scale, state.transform.scale)
1158
+ drawHierarchyGroups()
1152
1159
  if (drawEdges) {
1153
1160
  graphParts.selectedEdges.forEach(edge => drawGraphEdge(edge))
1154
1161
  }
@@ -1159,6 +1166,101 @@ const drawAcceleratedGraph = (width, height, drawEdges) => {
1159
1166
  return true
1160
1167
  }
1161
1168
 
1169
+ const focusedGroup = () => {
1170
+ if (state.groups.length === 0) {
1171
+ return null
1172
+ }
1173
+
1174
+ const selectedId = state.selected?.id ?? state.hovered?.id
1175
+ if (selectedId) {
1176
+ const selectedGroup = state.leafGroups.find(group => group.nodeIds.includes(selectedId))
1177
+ if (selectedGroup) {
1178
+ return selectedGroup
1179
+ }
1180
+ }
1181
+
1182
+ const focusPoint = performance.now() - state.lastZoomFocus.at <= 1800
1183
+ ? state.lastZoomFocus
1184
+ : viewportCenterWorldPoint()
1185
+ return state.leafGroups
1186
+ .map(group => ({
1187
+ group,
1188
+ distance: Math.hypot(group.x - focusPoint.x, group.y - focusPoint.y)
1189
+ }))
1190
+ .sort((left, right) => left.distance - right.distance)[0]?.group ?? null
1191
+ }
1192
+
1193
+ const hierarchyGroupOpacity = (group, focus) => {
1194
+ if (state.transform.scale >= hierarchyGroupScaleThreshold || state.groups.length === 0) {
1195
+ return 0
1196
+ }
1197
+
1198
+ const base = state.transform.scale < 0.08 ? 0.28 : state.transform.scale < 0.22 ? 0.2 : 0.12
1199
+ if (!focus) {
1200
+ return base
1201
+ }
1202
+ if (group.id === focus.id || group.childGroupIds.includes(focus.id) || group.id === focus.parentId) {
1203
+ return Math.min(0.36, base + 0.12)
1204
+ }
1205
+
1206
+ return base * 0.58
1207
+ }
1208
+
1209
+ const visibleHierarchyGroups = () => {
1210
+ if (state.groups.length === 0 || state.transform.scale >= hierarchyGroupScaleThreshold) {
1211
+ return []
1212
+ }
1213
+
1214
+ const viewport = expandViewportBounds(worldViewportBounds(), 900 / Math.max(state.transform.scale, 0.0001))
1215
+ const groups = state.groups.filter(group =>
1216
+ group.x + group.radius >= viewport.minX &&
1217
+ group.x - group.radius <= viewport.maxX &&
1218
+ group.y + group.radius >= viewport.minY &&
1219
+ group.y - group.radius <= viewport.maxY
1220
+ )
1221
+ const level = groups.some(group => group.parentId === null && group.childGroupIds.length > 0 && state.transform.scale < 0.18)
1222
+ ? 1
1223
+ : 0
1224
+
1225
+ return groups
1226
+ .filter(group => group.level === level || group.parentId === null)
1227
+ .slice(0, 1400)
1228
+ }
1229
+
1230
+ const drawHierarchyGroups = () => {
1231
+ const groups = visibleHierarchyGroups()
1232
+ if (groups.length === 0) {
1233
+ return
1234
+ }
1235
+
1236
+ const focus = focusedGroup()
1237
+ for (let index = 0; index < groups.length; index += 1) {
1238
+ const group = groups[index]
1239
+ const opacity = hierarchyGroupOpacity(group, focus)
1240
+ if (opacity <= 0) {
1241
+ continue
1242
+ }
1243
+
1244
+ const isFocus = focus && (group.id === focus.id || group.childGroupIds.includes(focus.id) || group.id === focus.parentId)
1245
+ ctx.beginPath()
1246
+ ctx.arc(group.x, group.y, group.radius * (isFocus ? 1.04 : 1), 0, Math.PI * 2)
1247
+ ctx.fillStyle = 'rgba(148, 163, 184, ' + (opacity * 0.12) + ')'
1248
+ ctx.fill()
1249
+ ctx.lineWidth = isFocus ? 2.2 : 1.2
1250
+ ctx.strokeStyle = 'rgba(148, 163, 184, ' + opacity + ')'
1251
+ ctx.stroke()
1252
+
1253
+ if (state.transform.scale >= 0.12 && groups.length <= 220) {
1254
+ ctx.fillStyle = 'rgba(226, 232, 240, ' + Math.min(0.86, opacity + 0.34) + ')'
1255
+ ctx.font = '12px Inter, system-ui, sans-serif'
1256
+ ctx.textAlign = 'center'
1257
+ ctx.textBaseline = 'middle'
1258
+ ctx.fillText(group.title.slice(0, 36), group.x, group.y)
1259
+ }
1260
+ }
1261
+ ctx.globalAlpha = 1
1262
+ }
1263
+
1162
1264
  const edgePairKey = (source, target) =>
1163
1265
  source < target ? source + '|' + target : target + '|' + source
1164
1266
 
@@ -1316,6 +1418,77 @@ const sampleMassiveOverviewNodes = (limit) => {
1316
1418
  return ensureHubNodesInRenderedSet(sampled)
1317
1419
  }
1318
1420
 
1421
+ const representativeNodeFromBucket = bucket => {
1422
+ if (!bucket || bucket.length === 0) {
1423
+ return null
1424
+ }
1425
+
1426
+ let representative = bucket[0]
1427
+ let representativeDegree = state.nodeDegrees.get(representative.id) ?? 0
1428
+
1429
+ for (let index = 1; index < bucket.length; index += 1) {
1430
+ const candidate = bucket[index]
1431
+ const candidateDegree = state.nodeDegrees.get(candidate.id) ?? 0
1432
+ if (candidateDegree <= representativeDegree) {
1433
+ continue
1434
+ }
1435
+ representative = candidate
1436
+ representativeDegree = candidateDegree
1437
+ }
1438
+
1439
+ return representative
1440
+ }
1441
+
1442
+ const sampleMassiveSegmentRepresentatives = (limit) => {
1443
+ const spatial = state.visibleNodeSpatial
1444
+ if (!spatial || spatial.buckets.size === 0 || limit <= 0) {
1445
+ return []
1446
+ }
1447
+
1448
+ const keys = [...spatial.buckets.keys()].sort()
1449
+ const maxNodes = Math.min(limit, keys.length)
1450
+ const step = Math.max(1, Math.ceil(keys.length / maxNodes))
1451
+ const representatives = []
1452
+
1453
+ for (let index = 0; index < keys.length && representatives.length < maxNodes; index += step) {
1454
+ const representative = representativeNodeFromBucket(spatial.buckets.get(keys[index]))
1455
+ if (representative) {
1456
+ representatives.push(representative)
1457
+ }
1458
+ }
1459
+
1460
+ return representatives
1461
+ }
1462
+
1463
+ const massiveSegmentRepresentativeLimit = (scale, limit) => {
1464
+ if (scale >= massiveSegmentedScaleThreshold) {
1465
+ return 0
1466
+ }
1467
+ if (scale < 0.09) {
1468
+ return Math.min(massiveSegmentRepresentativeBudget, Math.floor(limit * 0.5))
1469
+ }
1470
+ if (scale < 0.18) {
1471
+ return Math.min(620, Math.floor(limit * 0.42))
1472
+ }
1473
+ if (scale < 0.28) {
1474
+ return Math.min(460, Math.floor(limit * 0.34))
1475
+ }
1476
+ return Math.min(260, Math.floor(limit * 0.22))
1477
+ }
1478
+
1479
+ const sampleMassiveSegmentedNodes = (limit, viewport) => {
1480
+ const representativeLimit = massiveSegmentRepresentativeLimit(state.transform.scale, limit)
1481
+ const representatives = sampleMassiveSegmentRepresentatives(representativeLimit)
1482
+ const localLimit = Math.max(1, limit - representatives.length)
1483
+ const localMargin = Math.max(520, Math.min(5200, 780 / Math.max(state.transform.scale, 0.0001)))
1484
+ const localViewport = expandViewportBounds(viewport, localMargin)
1485
+ const localViewportNodes = viewportNodesFromSpatialIndex(localViewport)
1486
+ const localSource = localViewportNodes.length > 0 ? localViewportNodes : state.visibleNodes
1487
+ const localNodes = selectStableSampleNodes(localSource, localLimit)
1488
+
1489
+ return mergeUniqueNodes(representatives, localNodes, limit)
1490
+ }
1491
+
1319
1492
  const enrichSampleWithNeighbors = (nodes) => {
1320
1493
  if (nodes.length === 0) {
1321
1494
  return {
@@ -1694,6 +1867,44 @@ const layoutDensityScaleForNodeCount = (nodeCount) => {
1694
1867
  return 0.88
1695
1868
  }
1696
1869
 
1870
+ const createHierarchyGroups = (graph, densityScale) => {
1871
+ const groupRows = Array.isArray(graph.groups) ? graph.groups : []
1872
+
1873
+ return groupRows.map(group => {
1874
+ if (Array.isArray(group)) {
1875
+ const [id, level, parentId, title, x, y, radius, segment, folderGroup, nodeIds, childGroupIds] = group
1876
+ return {
1877
+ id: typeof id === 'string' ? id : '',
1878
+ level: Number.isFinite(level) ? level : 0,
1879
+ parentId: typeof parentId === 'string' ? parentId : null,
1880
+ title: typeof title === 'string' ? title : 'Group',
1881
+ x: Number.isFinite(x) ? x * densityScale : 0,
1882
+ y: Number.isFinite(y) ? y * densityScale : 0,
1883
+ radius: Number.isFinite(radius) ? radius * densityScale : 120,
1884
+ segment: typeof segment === 'string' ? segment : 'root',
1885
+ group: typeof folderGroup === 'string' ? folderGroup : 'root',
1886
+ nodeIds: Array.isArray(nodeIds) ? nodeIds.filter(nodeId => typeof nodeId === 'string') : [],
1887
+ childGroupIds: Array.isArray(childGroupIds) ? childGroupIds.filter(groupId => typeof groupId === 'string') : []
1888
+ }
1889
+ }
1890
+
1891
+ return {
1892
+ ...group,
1893
+ id: typeof group.id === 'string' ? group.id : '',
1894
+ level: Number.isFinite(group.level) ? group.level : 0,
1895
+ parentId: typeof group.parentId === 'string' ? group.parentId : null,
1896
+ title: typeof group.title === 'string' ? group.title : 'Group',
1897
+ x: Number.isFinite(group.x) ? group.x * densityScale : 0,
1898
+ y: Number.isFinite(group.y) ? group.y * densityScale : 0,
1899
+ radius: Number.isFinite(group.radius) ? group.radius * densityScale : 120,
1900
+ segment: typeof group.segment === 'string' ? group.segment : 'root',
1901
+ group: typeof group.group === 'string' ? group.group : 'root',
1902
+ nodeIds: Array.isArray(group.nodeIds) ? group.nodeIds.filter(nodeId => typeof nodeId === 'string') : [],
1903
+ childGroupIds: Array.isArray(group.childGroupIds) ? group.childGroupIds.filter(groupId => typeof groupId === 'string') : []
1904
+ }
1905
+ }).filter(group => group.id)
1906
+ }
1907
+
1697
1908
  const createLayout = graph => {
1698
1909
  const nodeRows = Array.isArray(graph.nodes) ? graph.nodes : []
1699
1910
  const edgeRows = Array.isArray(graph.edges) ? graph.edges : []
@@ -1726,6 +1937,7 @@ const createLayout = graph => {
1726
1937
  }
1727
1938
  })
1728
1939
  const nodeMap = new Map(nodes.map(node => [node.id, node]))
1940
+ const groups = createHierarchyGroups(graph, densityScale)
1729
1941
  const edges = edgeRows
1730
1942
  .map(edge => {
1731
1943
  if (Array.isArray(edge)) {
@@ -1742,7 +1954,7 @@ const createLayout = graph => {
1742
1954
  })
1743
1955
  .filter(edge => edge.target && nodeMap.has(edge.source) && nodeMap.has(edge.target))
1744
1956
  .map(edge => ({ ...edge, sourceNode: nodeMap.get(edge.source), targetNode: nodeMap.get(edge.target) }))
1745
- return { nodes, edges }
1957
+ return { nodes, edges, groups }
1746
1958
  }
1747
1959
 
1748
1960
  const encodeEntityTag = (value) => {
@@ -2146,7 +2358,16 @@ const computeRenderVisibility = () => {
2146
2358
  }
2147
2359
 
2148
2360
  const viewportNodes = viewportNodesFromSpatialIndex(viewport)
2149
- const sourceNodes = viewportNodes.length > 0 ? viewportNodes : sampleMassiveOverviewNodes(sampleLimit)
2361
+ const segmentedNodes =
2362
+ state.transform.scale < massiveSegmentedScaleThreshold
2363
+ ? sampleMassiveSegmentedNodes(sampleLimit, viewport)
2364
+ : []
2365
+ const sourceNodes =
2366
+ segmentedNodes.length > 0
2367
+ ? segmentedNodes
2368
+ : viewportNodes.length > 0
2369
+ ? viewportNodes
2370
+ : sampleMassiveOverviewNodes(sampleLimit)
2150
2371
  const carryMargin = Math.max(240, Math.min(1200, 340 / Math.max(state.transform.scale, 0.0001)))
2151
2372
  const carryViewport = expandViewportBounds(viewport, carryMargin)
2152
2373
  const carryOverLimit = Math.max(180, Math.min(sampleLimit, Math.floor(sampleLimit * 0.5)))
@@ -2356,6 +2577,7 @@ const render = now => {
2356
2577
  ctx.save()
2357
2578
  ctx.translate(state.transform.x, state.transform.y)
2358
2579
  ctx.scale(state.transform.scale, state.transform.scale)
2580
+ drawHierarchyGroups()
2359
2581
  if (drawEdges) {
2360
2582
  drawGraphEdges()
2361
2583
  }
@@ -2761,6 +2983,9 @@ const loadGraph = async (options = { reset: false }) => {
2761
2983
  state.graphSignature = signature
2762
2984
  state.graph = graph
2763
2985
  state.nodes = layout.nodes
2986
+ state.groups = layout.groups
2987
+ state.groupById = new Map(state.groups.map(group => [group.id, group]))
2988
+ state.leafGroups = state.groups.filter(group => group.nodeIds.length > 0)
2764
2989
  state.nodeById = new Map(state.nodes.map((node) => [node.id, node]))
2765
2990
  state.edges = layout.edges
2766
2991
  state.nodeDegrees = state.edges.reduce((degrees, edge) => {
@@ -114,11 +114,25 @@ const compactLayoutPayload = (layout) => {
114
114
  const compactNodes = layout.nodes.map((node) => [node.id, node.title, node.x, node.y, node.group, node.segment]);
115
115
  const compactEdgeRows = compactEdges
116
116
  .map((edge) => [edge.source, edge.target, edge.weight, edge.priority]);
117
+ const compactGroups = layout.groups?.map((group) => [
118
+ group.id,
119
+ group.level,
120
+ group.parentId,
121
+ group.title,
122
+ group.x,
123
+ group.y,
124
+ group.radius,
125
+ group.segment,
126
+ group.group,
127
+ group.nodeIds,
128
+ group.childGroupIds
129
+ ]);
117
130
  return {
118
131
  compact: true,
119
132
  layout: {
120
133
  nodes: compactNodes,
121
- edges: compactEdgeRows
134
+ edges: compactEdgeRows,
135
+ ...(compactGroups && compactGroups.length > 0 ? { groups: compactGroups } : {})
122
136
  },
123
137
  totals: {
124
138
  nodes: layout.nodes.length,
@@ -1,3 +1,4 @@
1
+ const hierarchyGroupNodeLimit = 1000;
1
2
  const groupLabels = {
2
3
  '00-maps': 'maps',
3
4
  '10-agent-memory': 'agent-memory',
@@ -192,6 +193,132 @@ const createSegmentNodes = (segments, degrees, segmentCount) => ([segment, nodes
192
193
  });
193
194
  };
194
195
  const distanceBetween = (left, right) => Math.hypot(right.x - left.x, right.y - left.y);
196
+ const layoutBounds = (nodes) => {
197
+ if (nodes.length === 0) {
198
+ return { x: 0, y: 0, radius: 1 };
199
+ }
200
+ const bounds = nodes.reduce((current, node) => ({
201
+ minX: Math.min(current.minX, node.x),
202
+ maxX: Math.max(current.maxX, node.x),
203
+ minY: Math.min(current.minY, node.y),
204
+ maxY: Math.max(current.maxY, node.y)
205
+ }), {
206
+ minX: Number.POSITIVE_INFINITY,
207
+ maxX: Number.NEGATIVE_INFINITY,
208
+ minY: Number.POSITIVE_INFINITY,
209
+ maxY: Number.NEGATIVE_INFINITY
210
+ });
211
+ const x = (bounds.minX + bounds.maxX) / 2;
212
+ const y = (bounds.minY + bounds.maxY) / 2;
213
+ const radius = nodes.reduce((largest, node) => Math.max(largest, Math.hypot(node.x - x, node.y - y)), 1);
214
+ return { x, y, radius: Math.max(radius + 72, 120) };
215
+ };
216
+ const edgeTouchesGroup = (edge, nodeIds) => nodeIds.has(edge.source) || Boolean(edge.target && nodeIds.has(edge.target));
217
+ const edgeInsideGroup = (edge, nodeIds) => nodeIds.has(edge.source) && Boolean(edge.target && nodeIds.has(edge.target));
218
+ const groupTitle = (segment, level, index, nodes) => nodes.length === 1
219
+ ? nodes[0]?.title ?? segment
220
+ : `${segment} ${level + 1}.${index + 1}`;
221
+ const chunkNodes = (nodes, degrees, groupNodeLimit = hierarchyGroupNodeLimit) => {
222
+ const sortedNodes = [...nodes].sort((left, right) => {
223
+ const segmentDelta = left.segment.localeCompare(right.segment);
224
+ if (segmentDelta !== 0)
225
+ return segmentDelta;
226
+ const groupDelta = left.group.localeCompare(right.group);
227
+ if (groupDelta !== 0)
228
+ return groupDelta;
229
+ const degreeDelta = (degrees.get(right.id) ?? 0) - (degrees.get(left.id) ?? 0);
230
+ if (degreeDelta !== 0)
231
+ return degreeDelta;
232
+ return left.title.localeCompare(right.title);
233
+ });
234
+ const chunks = [];
235
+ for (let index = 0; index < sortedNodes.length; index += groupNodeLimit) {
236
+ chunks.push(sortedNodes.slice(index, index + groupNodeLimit));
237
+ }
238
+ return chunks;
239
+ };
240
+ const groupEdges = (edges, nodeIds) => ({
241
+ internalEdges: edges.filter((edge) => edgeInsideGroup(edge, nodeIds)),
242
+ externalEdges: edges.filter((edge) => edgeTouchesGroup(edge, nodeIds) && !edgeInsideGroup(edge, nodeIds))
243
+ });
244
+ const groupBounds = (groups) => {
245
+ if (groups.length === 0) {
246
+ return { x: 0, y: 0, radius: 1 };
247
+ }
248
+ const nodes = groups.map((group) => ({
249
+ x: group.x,
250
+ y: group.y,
251
+ radius: group.radius
252
+ }));
253
+ const x = nodes.reduce((sum, node) => sum + node.x, 0) / nodes.length;
254
+ const y = nodes.reduce((sum, node) => sum + node.y, 0) / nodes.length;
255
+ const radius = nodes.reduce((largest, node) => Math.max(largest, Math.hypot(node.x - x, node.y - y) + node.radius), 1);
256
+ return { x, y, radius: Math.max(radius + 120, 180) };
257
+ };
258
+ const descendantNodeIds = (groups) => groups.flatMap((group) => group.nodeIds);
259
+ const createParentGroups = (groups, edges, level, groupNodeLimit) => {
260
+ if (groups.length <= groupNodeLimit) {
261
+ return groups;
262
+ }
263
+ const parentGroups = [];
264
+ for (let index = 0; index < groups.length; index += groupNodeLimit) {
265
+ const chunk = groups.slice(index, index + groupNodeLimit);
266
+ const nodeIds = new Set(descendantNodeIds(chunk));
267
+ const bounds = groupBounds(chunk);
268
+ const segment = chunk[0]?.segment ?? 'root';
269
+ const group = chunk[0]?.group ?? 'root';
270
+ const groupIndex = index / groupNodeLimit;
271
+ const id = ['root', level, groupIndex, chunk[0]?.id ?? 'empty', chunk.length].join(':');
272
+ const edgeGroups = groupEdges(edges, nodeIds);
273
+ parentGroups.push({
274
+ id,
275
+ level,
276
+ parentId: null,
277
+ title: `${segment} ${level + 1}.${Math.floor(groupIndex) + 1}`,
278
+ segment,
279
+ group,
280
+ x: bounds.x,
281
+ y: bounds.y,
282
+ radius: bounds.radius,
283
+ nodeIds: [],
284
+ childGroupIds: chunk.map((child) => child.id),
285
+ internalEdges: edgeGroups.internalEdges,
286
+ externalEdges: edgeGroups.externalEdges
287
+ });
288
+ }
289
+ const relinkedChildren = groups.map((group) => {
290
+ const parent = parentGroups.find((candidate) => candidate.childGroupIds.includes(group.id));
291
+ return parent ? { ...group, parentId: parent.id } : group;
292
+ });
293
+ return [...createParentGroups(parentGroups, edges, level + 1, groupNodeLimit), ...relinkedChildren];
294
+ };
295
+ export const createGraphLayoutHierarchy = (nodes, edges, degrees, groupNodeLimit = hierarchyGroupNodeLimit) => {
296
+ if (nodes.length <= groupNodeLimit) {
297
+ return [];
298
+ }
299
+ const leafGroups = chunkNodes(nodes, degrees, groupNodeLimit).map((chunk, index) => {
300
+ const nodeIds = new Set(chunk.map((node) => node.id));
301
+ const bounds = layoutBounds(chunk);
302
+ const segment = chunk[0]?.segment ?? 'root';
303
+ const group = chunk[0]?.group ?? 'root';
304
+ const id = ['leaf', 0, index, chunk[0]?.id ?? 'empty', chunk.length].join(':');
305
+ return {
306
+ id,
307
+ level: 0,
308
+ parentId: null,
309
+ title: groupTitle(segment, 0, index, chunk),
310
+ segment,
311
+ group,
312
+ x: bounds.x,
313
+ y: bounds.y,
314
+ radius: bounds.radius,
315
+ nodeIds: chunk.map((node) => node.id),
316
+ childGroupIds: [],
317
+ ...groupEdges(edges, nodeIds)
318
+ };
319
+ });
320
+ return createParentGroups(leafGroups, edges, 1, groupNodeLimit);
321
+ };
195
322
  const resolveCollisionPair = (left, right, minDistance) => {
196
323
  const dx = right.x - left.x;
197
324
  const dy = right.y - left.y;
@@ -291,9 +418,11 @@ export const createCauliflowerGraphLayout = (graph) => {
291
418
  const nodes = relaxCollisions(segmentGroups.flatMap(createSegmentNodes(segments, degrees, segmentGroups.length)));
292
419
  const primaryHubId = selectPrimaryHubId(graph.nodes, degrees);
293
420
  const centeredNodes = centerLayoutByNode(nodes, primaryHubId);
421
+ const groups = createGraphLayoutHierarchy(centeredNodes, graph.edges, degrees);
294
422
  return {
295
423
  nodes: centeredNodes,
296
- edges: graph.edges
424
+ edges: graph.edges,
425
+ ...(groups.length > 0 ? { groups } : {})
297
426
  };
298
427
  };
299
428
  export const getMinimumLayoutDistance = (nodes) => nodes.reduce((minimumDistance, leftNode, leftIndex) => nodes.slice(leftIndex + 1).reduce((innerMinimum, rightNode) => Math.min(innerMinimum, distanceBetween(leftNode, rightNode)), minimumDistance), Number.POSITIVE_INFINITY);
@@ -607,7 +607,7 @@ Without `--vault`, the graph UI serves `$HOME/.brainlink/vault`.
607
607
 
608
608
  The frontend includes an agent selector that shows only the agent id. Selecting an agent calls the same read APIs with `agent=<agent-id>` and renders that namespace instead of merging every agent into one graph.
609
609
 
610
- Graph navigation controls include zoom in, zoom out, fit visible nodes and reset-to-fit-all nodes. Mouse wheel zoom (including `cmd+scroll` and `ctrl+scroll`) is anchored to the cursor. Keyboard shortcuts are `+` (zoom in), `-` (zoom out) and `0` (reset fit). Double-click on canvas zooms in at cursor position. Totals for notes, links and tags stay visible as floating metrics under the Brainlink title, and node details open on click in a modal (tags, outgoing links, backlinks and Markdown content).
610
+ Graph navigation controls include zoom in, zoom out, fit visible nodes and reset-to-fit-all nodes. Mouse wheel zoom (including `cmd+scroll` and `ctrl+scroll`) is anchored to the cursor. Keyboard shortcuts are `+` (zoom in), `-` (zoom out) and `0` (reset fit). Double-click on canvas zooms in at cursor position. Totals for notes, links and tags stay visible as floating metrics under the Brainlink title, and node details open on click in a modal (tags, outgoing links, backlinks and Markdown content). Vaults above 1000 notes also expose stable hierarchy groups of up to 1000 direct nodes, with recursive parent groups when a level exceeds 1000 groups; zoom-out draws those groups as context while preserving the real-node scene.
611
611
  During graph filtering, Brainlink keeps hub context nodes visible (`Memory Hub`/`MOC`/high-degree fallback) so filtered views still show relationship anchors.
612
612
 
613
613
  The command reindexes by default, then serves:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andespindola/brainlink",
3
- "version": "0.1.0-beta.117",
3
+ "version": "0.1.0-beta.119",
4
4
  "description": "Local-first knowledge memory for agents with Markdown, backlinks, indexing and context retrieval.",
5
5
  "type": "module",
6
6
  "license": "MIT",