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

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
@@ -88,7 +88,7 @@ Legacy `.jsonl.gz` packs are upgraded to `.blpk` automatically on first search/c
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, priority links and stable hierarchy groups without replacing the whole scene during zoom.
91
+ - Zoomed-out graph LOD renders hierarchy groups as normal graph nodes and expands the focused group into child nodes progressively as zoom increases.
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.
@@ -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 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
607
+ - large graph LOD keeps a recursive graph-of-graphs model: zoom-out shows one level of group nodes, zoom-in expands the focused node into its child graph, and deeper zoom repeats the same visual rule without a background glow layer
608
608
 
609
609
  The server indexes before starting by default. Use `--no-index` to skip that step:
610
610
 
@@ -12,6 +12,8 @@ const massiveSegmentedScaleThreshold = 0.45
12
12
  const massiveSegmentRepresentativeBudget = 760
13
13
  const massiveAutoFitMacroScale = 0.018
14
14
  const hierarchyGroupScaleThreshold = 0.62
15
+ const hierarchyExpansionStartScale = 0.18
16
+ const hierarchyExpansionEndScale = 0.62
15
17
  const minNodePixelRadius = 2.3
16
18
  const viewportPaddingPx = 280
17
19
  const worldCoordinateLimit = 5_000_000
@@ -42,6 +44,7 @@ const state = {
42
44
  groups: [],
43
45
  groupById: new Map(),
44
46
  leafGroups: [],
47
+ nodeLeafGroupById: new Map(),
45
48
  nodeById: new Map(),
46
49
  edges: [],
47
50
  visibleNodes: [],
@@ -1155,7 +1158,6 @@ const drawAcceleratedGraph = (width, height, drawEdges) => {
1155
1158
  ctx.save()
1156
1159
  ctx.translate(state.transform.x, state.transform.y)
1157
1160
  ctx.scale(state.transform.scale, state.transform.scale)
1158
- drawHierarchyGroups()
1159
1161
  if (drawEdges) {
1160
1162
  graphParts.selectedEdges.forEach(edge => drawGraphEdge(edge))
1161
1163
  }
@@ -1171,9 +1173,14 @@ const focusedGroup = () => {
1171
1173
  return null
1172
1174
  }
1173
1175
 
1176
+ const selectedGroupId = state.selected?.groupId ?? state.hovered?.groupId
1177
+ if (selectedGroupId) {
1178
+ return state.groupById.get(selectedGroupId) ?? null
1179
+ }
1180
+
1174
1181
  const selectedId = state.selected?.id ?? state.hovered?.id
1175
1182
  if (selectedId) {
1176
- const selectedGroup = state.leafGroups.find(group => group.nodeIds.includes(selectedId))
1183
+ const selectedGroup = state.nodeLeafGroupById.get(selectedId)
1177
1184
  if (selectedGroup) {
1178
1185
  return selectedGroup
1179
1186
  }
@@ -1190,75 +1197,149 @@ const focusedGroup = () => {
1190
1197
  .sort((left, right) => left.distance - right.distance)[0]?.group ?? null
1191
1198
  }
1192
1199
 
1193
- const hierarchyGroupOpacity = (group, focus) => {
1194
- if (state.transform.scale >= hierarchyGroupScaleThreshold || state.groups.length === 0) {
1195
- return 0
1196
- }
1200
+ const groupRenderNodeId = group => 'group:' + group.id
1201
+
1202
+ const hierarchyExpansionProgress = () => {
1203
+ const scale = state.transform.scale
1204
+ if (scale <= hierarchyExpansionStartScale) return 0
1205
+ if (scale >= hierarchyExpansionEndScale) return 1
1206
+ const progress = (scale - hierarchyExpansionStartScale) / (hierarchyExpansionEndScale - hierarchyExpansionStartScale)
1207
+ return progress * progress * (3 - 2 * progress)
1208
+ }
1209
+
1210
+ const groupRenderRadius = group => {
1211
+ const childCount = Math.max(group.nodeIds.length, group.childGroupIds.length, 1)
1212
+ return 10 + Math.min(Math.log2(childCount + 1) * 4.2, 22)
1213
+ }
1214
+
1215
+ const createGroupRenderNode = group => ({
1216
+ id: groupRenderNodeId(group),
1217
+ groupId: group.id,
1218
+ isGroupNode: true,
1219
+ title: group.title,
1220
+ path: '',
1221
+ tags: [],
1222
+ group: group.group,
1223
+ segment: group.segment,
1224
+ x: group.x,
1225
+ y: group.y,
1226
+ vx: 0,
1227
+ vy: 0,
1228
+ radius: groupRenderRadius(group)
1229
+ })
1230
+
1231
+ const interpolateNodeFromGroup = (node, group, progress) => ({
1232
+ ...node,
1233
+ x: group.x + (node.x - group.x) * progress,
1234
+ y: group.y + (node.y - group.y) * progress,
1235
+ vx: 0,
1236
+ vy: 0
1237
+ })
1197
1238
 
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
1239
+ const parentHierarchyGroups = () =>
1240
+ state.groups.filter(group => group.parentId === null)
1241
+
1242
+ const hierarchyGroupsForScale = () => {
1243
+ if (state.groups.length === 0) {
1244
+ return []
1201
1245
  }
1202
- if (group.id === focus.id || group.childGroupIds.includes(focus.id) || group.id === focus.parentId) {
1203
- return Math.min(0.36, base + 0.12)
1246
+ if (state.transform.scale < hierarchyExpansionStartScale) {
1247
+ return parentHierarchyGroups()
1204
1248
  }
1205
-
1206
- return base * 0.58
1249
+ return state.leafGroups
1207
1250
  }
1208
1251
 
1209
- const visibleHierarchyGroups = () => {
1210
- if (state.groups.length === 0 || state.transform.scale >= hierarchyGroupScaleThreshold) {
1252
+ const groupEdgesForRenderedGroups = (groupNodes) => {
1253
+ if (groupNodes.length <= 1) {
1211
1254
  return []
1212
1255
  }
1213
1256
 
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
1257
+ const groupByNodeId = new Map()
1258
+ const groupNodeIds = (group) => {
1259
+ if (!group) return []
1260
+ if (group.nodeIds.length > 0) return group.nodeIds
1261
+ return group.childGroupIds.flatMap(childGroupId => groupNodeIds(state.groupById.get(childGroupId)))
1262
+ }
1263
+
1264
+ groupNodes.forEach((groupNode) => {
1265
+ const group = state.groupById.get(groupNode.groupId)
1266
+ groupNodeIds(group).forEach((nodeId) => {
1267
+ groupByNodeId.set(nodeId, groupNode)
1268
+ })
1269
+ })
1270
+
1271
+ const selected = new Map()
1272
+ for (let index = 0; index < state.visibleEdges.length; index += 1) {
1273
+ const edge = state.visibleEdges[index]
1274
+ if (!edge.target) continue
1275
+ const sourceGroup = groupByNodeId.get(edge.source)
1276
+ const targetGroup = groupByNodeId.get(edge.target)
1277
+ if (!sourceGroup || !targetGroup || sourceGroup.id === targetGroup.id) continue
1278
+
1279
+ const key = sourceGroup.id < targetGroup.id
1280
+ ? sourceGroup.id + '|' + targetGroup.id
1281
+ : targetGroup.id + '|' + sourceGroup.id
1282
+ const current = selected.get(key)
1283
+ if (current && edgeWeight(current) >= edgeWeight(edge)) continue
1284
+
1285
+ selected.set(key, {
1286
+ source: sourceGroup.id,
1287
+ target: targetGroup.id,
1288
+ targetTitle: targetGroup.title,
1289
+ weight: edgeWeight(edge),
1290
+ priority: edge.priority || 'normal',
1291
+ sourceNode: sourceGroup,
1292
+ targetNode: targetGroup
1293
+ })
1294
+ }
1224
1295
 
1225
- return groups
1226
- .filter(group => group.level === level || group.parentId === null)
1227
- .slice(0, 1400)
1296
+ return Array.from(selected.values()).slice(0, edgeBudgetForCurrentFrame())
1228
1297
  }
1229
1298
 
1230
- const drawHierarchyGroups = () => {
1231
- const groups = visibleHierarchyGroups()
1232
- if (groups.length === 0) {
1233
- return
1299
+ const computeHierarchyRenderVisibility = (viewport) => {
1300
+ if (state.groups.length === 0 || state.visibleNodes.length <= 1000 || state.transform.scale >= hierarchyGroupScaleThreshold) {
1301
+ return false
1234
1302
  }
1235
1303
 
1304
+ const progress = hierarchyExpansionProgress()
1236
1305
  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()
1306
+ const groups = hierarchyGroupsForScale()
1307
+ .filter(group =>
1308
+ group.x + group.radius >= viewport.minX &&
1309
+ group.x - group.radius <= viewport.maxX &&
1310
+ group.y + group.radius >= viewport.minY &&
1311
+ group.y - group.radius <= viewport.maxY
1312
+ )
1313
+ .slice(0, renderNodeBudget)
1314
+ const groupNodes = groups.map(createGroupRenderNode)
1315
+
1316
+ if (progress <= 0.02 || !focus) {
1317
+ state.renderNodes = groupNodes
1318
+ state.renderEdges = groupEdgesForRenderedGroups(groupNodes)
1319
+ return true
1320
+ }
1321
+
1322
+ const focusIds = new Set(focus.nodeIds)
1323
+ const childLimit = Math.max(160, Math.min(zoomedMassiveRenderNodeBudget, Math.floor(renderNodeBudget * progress * 2.4)))
1324
+ const childNodes = selectStableSampleNodes(
1325
+ state.visibleNodes.filter(node => focusIds.has(node.id)),
1326
+ childLimit
1327
+ ).map(node => interpolateNodeFromGroup(node, focus, progress))
1328
+ const childIds = new Set(childNodes.map(node => node.id))
1329
+ const childById = new Map(childNodes.map(node => [node.id, node]))
1330
+ const visibleGroupNodes = groupNodes.filter(node => node.groupId !== focus.id || progress < 0.92)
1331
+ const groupEdges = groupEdgesForRenderedGroups(visibleGroupNodes)
1332
+ const childEdges = progress > 0.32
1333
+ ? collectVisibleEdgesForNodes(childIds).map(edge => ({
1334
+ ...edge,
1335
+ sourceNode: childById.get(edge.source) ?? edge.sourceNode,
1336
+ targetNode: childById.get(edge.target) ?? edge.targetNode
1337
+ }))
1338
+ : []
1252
1339
 
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
1340
+ state.renderNodes = mergeUniqueNodes(childNodes, visibleGroupNodes, Math.max(renderNodeBudget, childLimit + visibleGroupNodes.length))
1341
+ state.renderEdges = childEdges.concat(groupEdges).slice(0, edgeBudgetForCurrentFrame())
1342
+ return true
1262
1343
  }
1263
1344
 
1264
1345
  const edgePairKey = (source, target) =>
@@ -1651,6 +1732,7 @@ const ensureHubNodesInRenderedSet = (nodes) => {
1651
1732
  }
1652
1733
 
1653
1734
  const zoomCapByNodeCount = (nodeCount) => {
1735
+ if (state.groups.length > 0) return 512
1654
1736
  if (nodeCount > 50000) return 5.4
1655
1737
  if (nodeCount > 20000) return 4.8
1656
1738
  if (nodeCount > 6000) return 4.2
@@ -2243,7 +2325,7 @@ const settleNeighborhoodAroundNode = (dragNode) => {
2243
2325
  const hitNode = point => {
2244
2326
  computeRenderVisibility()
2245
2327
  const hitScaleFloor = state.nodes.length > massiveGraphNodeThreshold
2246
- ? 0.2
2328
+ ? (state.renderNodes.some(node => node.isGroupNode) ? 0 : 0.2)
2247
2329
  : state.nodes.length > largeGraphNodeThreshold
2248
2330
  ? 0.34
2249
2331
  : 0
@@ -2263,6 +2345,9 @@ const hitNode = point => {
2263
2345
  }
2264
2346
 
2265
2347
  const baseNodeRadius = node => {
2348
+ if (node.isGroupNode && Number.isFinite(node.radius)) {
2349
+ return node.radius
2350
+ }
2266
2351
  const degree = state.nodeDegrees.get(node.id) ?? 0
2267
2352
  return 9 + Math.min(degree, 8) * 1.6
2268
2353
  }
@@ -2340,6 +2425,10 @@ const computeRenderVisibility = () => {
2340
2425
  state.lastViewportKey = viewportKey
2341
2426
  state.renderVisibilityDirty = false
2342
2427
 
2428
+ if (computeHierarchyRenderVisibility(viewport)) {
2429
+ return
2430
+ }
2431
+
2343
2432
  if (state.visibleNodes.length <= 2000) {
2344
2433
  state.renderNodes = state.visibleNodes
2345
2434
  const ids = new Set(state.renderNodes.map((node) => node.id))
@@ -2577,7 +2666,6 @@ const render = now => {
2577
2666
  ctx.save()
2578
2667
  ctx.translate(state.transform.x, state.transform.y)
2579
2668
  ctx.scale(state.transform.scale, state.transform.scale)
2580
- drawHierarchyGroups()
2581
2669
  if (drawEdges) {
2582
2670
  drawGraphEdges()
2583
2671
  }
@@ -2639,7 +2727,7 @@ const fetchNodeDetails = async node => {
2639
2727
  const wait = async (milliseconds) => new Promise(resolve => setTimeout(resolve, milliseconds))
2640
2728
 
2641
2729
  const openContentDialog = async node => {
2642
- if (!node) return
2730
+ if (!node || node.isGroupNode) return
2643
2731
  elements.contentTitle.textContent = node.title || 'Loading...'
2644
2732
  elements.contentPath.textContent = node.path || 'Loading...'
2645
2733
  elements.contentTags.innerHTML = Array.isArray(node.tags) && node.tags.length
@@ -2683,6 +2771,23 @@ const openContentDialog = async node => {
2683
2771
  }
2684
2772
 
2685
2773
  const selectNode = (node, options = { openContent: false }) => {
2774
+ if (node?.isGroupNode) {
2775
+ state.selected = node
2776
+ const rect = canvas.getBoundingClientRect()
2777
+ const targetScale = clampScale(Math.max(state.transform.scale * 1.8, hierarchyExpansionStartScale * 1.08))
2778
+ state.zoomTransition = {
2779
+ active: true,
2780
+ source: 'group',
2781
+ screenX: Math.max(rect.width, 320) / 2,
2782
+ screenY: Math.max(rect.height, 320) / 2,
2783
+ worldX: node.x,
2784
+ worldY: node.y,
2785
+ targetScale
2786
+ }
2787
+ state.lastZoomFocus = { x: node.x, y: node.y, at: performance.now() }
2788
+ markRenderDirty()
2789
+ return
2790
+ }
2686
2791
  state.selected = node
2687
2792
  if (node && options.openContent) {
2688
2793
  openContentDialog(node).catch(() => {
@@ -2986,6 +3091,7 @@ const loadGraph = async (options = { reset: false }) => {
2986
3091
  state.groups = layout.groups
2987
3092
  state.groupById = new Map(state.groups.map(group => [group.id, group]))
2988
3093
  state.leafGroups = state.groups.filter(group => group.nodeIds.length > 0)
3094
+ state.nodeLeafGroupById = new Map(state.leafGroups.flatMap(group => group.nodeIds.map(nodeId => [nodeId, group])))
2989
3095
  state.nodeById = new Map(state.nodes.map((node) => [node.id, node]))
2990
3096
  state.edges = layout.edges
2991
3097
  state.nodeDegrees = state.edges.reduce((degrees, edge) => {
@@ -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). 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.
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 renders those groups as normal graph nodes, and zoom-in expands the focused group into its child graph without drawing a background glow layer.
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.119",
3
+ "version": "0.1.0-beta.120",
4
4
  "description": "Local-first knowledge memory for agents with Markdown, backlinks, indexing and context retrieval.",
5
5
  "type": "module",
6
6
  "license": "MIT",