@andespindola/brainlink 0.1.0-beta.130 → 0.1.0-beta.132

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
@@ -606,7 +606,7 @@ The graph UI shows:
606
606
  - graph rendering safeguards (batched canvas drawing across graph sizes, edge draw caps, lower redraw rate, zoom-aware interaction)
607
607
  - 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
608
608
  - WebGL node and edge acceleration when supported, falling back to Canvas 2D without changing graph behavior
609
- - large graph LOD keeps a recursive graph-of-graphs model: zoom-out fills the projected macro level toward 1000 lightweight group nodes with sparse strongest-link edges, zoom-in expands the framed node from its current viewport position into its own radial child graph capped at 1000 nodes, micro view renders only that focused subgraph with dense-node label suppression in a local frame anchored to the rendered group node, and zoom-out restores sibling groups
609
+ - large graph LOD keeps a recursive graph-of-graphs model: zoom-out fills the projected macro level toward 1000 lightweight group nodes with every aggregated link between visible groups, zoom-in grows the focused node into a local child graph anchored at the same point, once the child graph is framed the sibling groups stop rendering, groups with child groups open another graph level instead of jumping to leaf notes, and zoom-out restores sibling groups
610
610
 
611
611
  The server indexes before starting by default. Use `--no-index` to skip that step:
612
612
 
@@ -12,10 +12,13 @@ const massiveSegmentedScaleThreshold = 0.45
12
12
  const massiveSegmentRepresentativeBudget = 760
13
13
  const massiveAutoFitMacroScale = 0.018
14
14
  const hierarchyExpansionStartScale = 0.18
15
- const hierarchyMicroEnterCoverage = 0.72
16
- const hierarchyMicroExitCoverage = 0.52
17
- const hierarchyMicroEnterScale = 0.24
18
- const hierarchyMicroExitScale = 0.16
15
+ const hierarchyMicroEnterCoverage = 0.58
16
+ const hierarchyMicroExitCoverage = 0.38
17
+ const hierarchyMicroEnterScale = 0.18
18
+ const hierarchyMicroExitScale = 0.12
19
+ const hierarchyFocusedOnlyProgress = 0.5
20
+ const hierarchyFocusedOnlyCoverage = 0.4
21
+ const hierarchyChildGraphFitMargin = 1.28
19
22
  const minNodePixelRadius = 2.3
20
23
  const viewportPaddingPx = 280
21
24
  const worldCoordinateLimit = 5_000_000
@@ -82,6 +85,7 @@ const state = {
82
85
  lastManualZoomAt: 0,
83
86
  lastZoomFocus: { x: 0, y: 0, at: 0 },
84
87
  hierarchyFocusGroupId: null,
88
+ hierarchyFocusStack: [],
85
89
  zoomTransition: {
86
90
  active: false,
87
91
  source: 'generic',
@@ -645,7 +649,7 @@ const createVisibleEdgeLookup = edges => {
645
649
 
646
650
  const edgeBudgetForCurrentFrame = () => {
647
651
  const zoom = state.transform.scale
648
- if (zoom < 0.12) return 380
652
+ if (zoom < 0.12) return state.groups.length > 0 ? 1200 : 380
649
653
  if (zoom < 0.18) return 900
650
654
  if (zoom < 0.28) return 1700
651
655
  if (zoom < 0.45) return 2800
@@ -1317,6 +1321,43 @@ const arrangeChildGraphNodes = (nodes, group, origin = group) => {
1317
1321
  return arranged
1318
1322
  }
1319
1323
 
1324
+ const arrangeChildGroupNodes = (groups, parentGroup, origin) => {
1325
+ if (groups.length <= 1) {
1326
+ return groups.map(group => ({
1327
+ ...createGroupRenderNode(group),
1328
+ x: origin.x,
1329
+ y: origin.y
1330
+ }))
1331
+ }
1332
+
1333
+ const targetRadius = childGraphRenderRadius(parentGroup)
1334
+ const centerGroup = groups
1335
+ .map(group => ({
1336
+ group,
1337
+ score: Math.max(group.nodeIds.length, group.childGroupIds.length, 1)
1338
+ }))
1339
+ .sort((left, right) => right.score - left.score || left.group.title.localeCompare(right.group.title))[0]?.group
1340
+ const outerGroups = groups
1341
+ .filter(group => group.id !== centerGroup?.id)
1342
+ .sort((left, right) => left.segment.localeCompare(right.segment) || left.title.localeCompare(right.title))
1343
+ const goldenAngle = Math.PI * (3 - Math.sqrt(5))
1344
+ const arranged = centerGroup
1345
+ ? [{ ...createGroupRenderNode(centerGroup), x: origin.x, y: origin.y }]
1346
+ : []
1347
+
1348
+ outerGroups.forEach((group, index) => {
1349
+ const ringRadius = targetRadius * Math.sqrt((index + 1) / Math.max(outerGroups.length, 1))
1350
+ const angle = index * goldenAngle
1351
+ arranged.push({
1352
+ ...createGroupRenderNode(group),
1353
+ x: origin.x + Math.cos(angle) * ringRadius,
1354
+ y: origin.y + Math.sin(angle) * ringRadius
1355
+ })
1356
+ })
1357
+
1358
+ return arranged
1359
+ }
1360
+
1320
1361
  const interpolateNodeFromGroup = (node, origin, progress) => {
1321
1362
  return {
1322
1363
  ...node,
@@ -1330,20 +1371,39 @@ const interpolateNodeFromGroup = (node, origin, progress) => {
1330
1371
  const parentHierarchyGroups = () =>
1331
1372
  state.groups.filter(group => group.parentId === null)
1332
1373
 
1374
+ const activeHierarchyParentGroup = () => {
1375
+ for (let index = state.hierarchyFocusStack.length - 1; index >= 0; index -= 1) {
1376
+ const group = state.groupById.get(state.hierarchyFocusStack[index])
1377
+ if (group && group.childGroupIds.length > 0) {
1378
+ return group
1379
+ }
1380
+ }
1381
+ return null
1382
+ }
1383
+
1384
+ const groupsBelongToParent = (groups, parentGroup) =>
1385
+ Boolean(parentGroup) && groups.some(group =>
1386
+ group.parentId === parentGroup.id || parentGroup.childGroupIds.includes(group.id)
1387
+ )
1388
+
1333
1389
  const hierarchyGroupsForScale = () => {
1334
1390
  if (state.groups.length === 0) {
1335
1391
  return []
1336
1392
  }
1337
- if (state.transform.scale < hierarchyExpansionStartScale) {
1338
- return arrangeGraphLevelNodes(parentHierarchyGroups(), groupRenderRadius)
1393
+ const parentGroup = activeHierarchyParentGroup()
1394
+ if (parentGroup) {
1395
+ const childGroups = parentGroup.childGroupIds
1396
+ .map(groupId => state.groupById.get(groupId))
1397
+ .filter(Boolean)
1398
+ return arrangeGraphLevelNodes(childGroups, groupRenderRadius)
1339
1399
  }
1340
- return arrangeGraphLevelNodes(state.leafGroups, groupRenderRadius)
1400
+ return arrangeGraphLevelNodes(parentHierarchyGroups(), groupRenderRadius)
1341
1401
  }
1342
1402
 
1343
1403
  const groupViewportCoverage = (group, viewport) => {
1344
1404
  const viewportWidth = Math.max(viewport.maxX - viewport.minX, 1)
1345
1405
  const viewportHeight = Math.max(viewport.maxY - viewport.minY, 1)
1346
- const viewportRadius = Math.max(viewportWidth, viewportHeight) / 2
1406
+ const viewportRadius = Math.min(viewportWidth, viewportHeight) / 2
1347
1407
  const centerX = (viewport.minX + viewport.maxX) / 2
1348
1408
  const centerY = (viewport.minY + viewport.maxY) / 2
1349
1409
  const centerDistance = Math.hypot(group.x - centerX, group.y - centerY)
@@ -1386,6 +1446,17 @@ const selectViewportItemsWithFill = (items, viewport, limit = renderNodeBudget)
1386
1446
  }
1387
1447
 
1388
1448
  const updateHierarchyFocusGroup = (groups, viewport) => {
1449
+ const activeGroupIds = new Set(groups.map(group => group.id))
1450
+ state.hierarchyFocusStack = state.hierarchyFocusStack.filter(groupId => state.groupById.has(groupId))
1451
+ const parentGroup = activeHierarchyParentGroup()
1452
+ if (!groupsBelongToParent(groups, parentGroup)) {
1453
+ while (
1454
+ state.hierarchyFocusStack.length > 0 &&
1455
+ !activeGroupIds.has(state.hierarchyFocusStack[state.hierarchyFocusStack.length - 1])
1456
+ ) {
1457
+ state.hierarchyFocusStack.pop()
1458
+ }
1459
+ }
1389
1460
  const current = state.hierarchyFocusGroupId
1390
1461
  ? groups.find(group => group.id === state.hierarchyFocusGroupId) ?? null
1391
1462
  : null
@@ -1396,6 +1467,9 @@ const updateHierarchyFocusGroup = (groups, viewport) => {
1396
1467
  state.transform.scale >= hierarchyMicroExitScale &&
1397
1468
  currentCoverage >= hierarchyMicroExitCoverage
1398
1469
  ) {
1470
+ if (current.childGroupIds.length > 0 && state.hierarchyFocusStack.at(-1) !== current.id) {
1471
+ state.hierarchyFocusStack = [...state.hierarchyFocusStack, current.id]
1472
+ }
1399
1473
  return current
1400
1474
  }
1401
1475
 
@@ -1405,13 +1479,19 @@ const updateHierarchyFocusGroup = (groups, viewport) => {
1405
1479
 
1406
1480
  if (
1407
1481
  candidate &&
1408
- state.transform.scale >= hierarchyMicroEnterScale &&
1409
- candidate.coverage >= hierarchyMicroEnterCoverage
1482
+ state.transform.scale >= hierarchyMicroExitScale &&
1483
+ candidate.coverage >= hierarchyMicroExitCoverage
1410
1484
  ) {
1411
1485
  state.hierarchyFocusGroupId = candidate.group.id
1486
+ if (candidate.group.childGroupIds.length > 0 && state.hierarchyFocusStack.at(-1) !== candidate.group.id) {
1487
+ state.hierarchyFocusStack = [...state.hierarchyFocusStack, candidate.group.id]
1488
+ }
1412
1489
  return candidate.group
1413
1490
  }
1414
1491
 
1492
+ if (state.hierarchyFocusStack.length > 0 && state.transform.scale < hierarchyMicroExitScale) {
1493
+ state.hierarchyFocusStack = state.hierarchyFocusStack.slice(0, -1)
1494
+ }
1415
1495
  state.hierarchyFocusGroupId = null
1416
1496
  return null
1417
1497
  }
@@ -1467,25 +1547,15 @@ const groupEdgesForRenderedGroups = (groupNodes) => {
1467
1547
  })
1468
1548
  }
1469
1549
 
1470
- const degreeCounts = new Map()
1471
1550
  return Array.from(selected.values())
1472
1551
  .sort((left, right) => edgeWeight(right) - edgeWeight(left) || left.source.localeCompare(right.source) || left.target.localeCompare(right.target))
1473
- .filter((edge) => {
1474
- const sourceCount = degreeCounts.get(edge.source) ?? 0
1475
- const targetCount = degreeCounts.get(edge.target) ?? 0
1476
- if (sourceCount >= 3 || targetCount >= 3) {
1477
- return false
1478
- }
1479
- degreeCounts.set(edge.source, sourceCount + 1)
1480
- degreeCounts.set(edge.target, targetCount + 1)
1481
- return true
1482
- })
1483
1552
  .slice(0, Math.min(hierarchyGroupEdgeLimit, edgeBudgetForCurrentFrame()))
1484
1553
  }
1485
1554
 
1486
1555
  const computeHierarchyRenderVisibility = (viewport) => {
1487
1556
  if (state.groups.length === 0 || state.visibleNodes.length <= 1000) {
1488
1557
  state.hierarchyFocusGroupId = null
1558
+ state.hierarchyFocusStack = []
1489
1559
  return false
1490
1560
  }
1491
1561
 
@@ -1502,25 +1572,33 @@ const computeHierarchyRenderVisibility = (viewport) => {
1502
1572
 
1503
1573
  const focusIds = new Set(focus.nodeIds)
1504
1574
  const rawChildNodes = state.visibleNodes.filter(node => focusIds.has(node.id))
1505
- const childLimit = Math.max(90, Math.min(renderNodeBudget, Math.floor(renderNodeBudget * progress)))
1575
+ const focusChildGroups = focus.childGroupIds
1576
+ .map(groupId => state.groupById.get(groupId))
1577
+ .filter(Boolean)
1578
+ const childLimit = Math.min(renderNodeBudget, Math.max(1, Math.floor(renderNodeBudget * progress)))
1506
1579
  const focusRenderNode = groupNodes.find(node => node.groupId === focus.id) ?? createGroupRenderNode(focus)
1507
- const childNodes = selectStableSampleNodes(
1508
- arrangeChildGraphNodes(rawChildNodes, focus, focusRenderNode),
1509
- childLimit
1510
- ).map(node => interpolateNodeFromGroup(node, focusRenderNode, progress))
1580
+ const arrangedChildren = focusChildGroups.length > 0
1581
+ ? arrangeChildGroupNodes(focusChildGroups, focus, focusRenderNode)
1582
+ : arrangeChildGraphNodes(rawChildNodes, focus, focusRenderNode)
1583
+ const childNodes = selectStableSampleNodes(arrangedChildren, childLimit)
1584
+ .map(node => interpolateNodeFromGroup(node, focusRenderNode, progress))
1511
1585
  const childIds = new Set(childNodes.map(node => node.id))
1512
1586
  const childById = new Map(childNodes.map(node => [node.id, node]))
1513
- const isMicroView = progress >= 0.72
1587
+ const isMicroView =
1588
+ progress >= hierarchyFocusedOnlyProgress ||
1589
+ groupViewportCoverage(focus, viewport) >= hierarchyFocusedOnlyCoverage
1514
1590
  const visibleGroupNodes = isMicroView
1515
1591
  ? []
1516
1592
  : groupNodes.filter(node => node.groupId !== focus.id || progress < 0.92)
1517
1593
  const groupEdges = isMicroView ? [] : groupEdgesForRenderedGroups(visibleGroupNodes)
1518
- const childEdges = progress > 0.32
1519
- ? collectVisibleEdgesForNodes(childIds).map(edge => ({
1520
- ...edge,
1521
- sourceNode: childById.get(edge.source) ?? edge.sourceNode,
1522
- targetNode: childById.get(edge.target) ?? edge.targetNode
1523
- }))
1594
+ const childEdges = (isMicroView || progress > 0.02)
1595
+ ? focusChildGroups.length > 0
1596
+ ? groupEdgesForRenderedGroups(childNodes)
1597
+ : collectVisibleEdgesForNodes(childIds).map(edge => ({
1598
+ ...edge,
1599
+ sourceNode: childById.get(edge.source) ?? edge.sourceNode,
1600
+ targetNode: childById.get(edge.target) ?? edge.targetNode
1601
+ }))
1524
1602
  : []
1525
1603
 
1526
1604
  state.renderNodes = mergeUniqueNodes(childNodes, visibleGroupNodes, Math.max(renderNodeBudget, childLimit + visibleGroupNodes.length))
@@ -1996,6 +2074,11 @@ const fitView = (options = { useFiltered: true, preferHubCenter: true }) => {
1996
2074
 
1997
2075
  const resetView = () => fitView({ useFiltered: false, preferHubCenter: false })
1998
2076
 
2077
+ const resetHierarchyFocus = () => {
2078
+ state.hierarchyFocusGroupId = null
2079
+ state.hierarchyFocusStack = []
2080
+ }
2081
+
1999
2082
  const focusPrimaryHub = () => {
2000
2083
  const hub = state.primaryHub
2001
2084
  if (!hub) {
@@ -2018,6 +2101,30 @@ const focusPrimaryHub = () => {
2018
2101
  markRenderDirty()
2019
2102
  }
2020
2103
 
2104
+ const childGraphFitScaleForGroup = (group, viewportWidth, viewportHeight) => {
2105
+ const graphDiameter = Math.max(childGraphRenderRadius(group) * 2 * hierarchyChildGraphFitMargin, 1)
2106
+ const available = Math.max(Math.min(viewportWidth, viewportHeight) - 96, 240)
2107
+ return clampScale(available / graphDiameter)
2108
+ }
2109
+
2110
+ const childGraphFitTransition = (node, group) => {
2111
+ const rect = canvas.getBoundingClientRect()
2112
+ const width = Math.max(rect.width, 320)
2113
+ const height = Math.max(rect.height, 320)
2114
+ const fitScale = childGraphFitScaleForGroup(group, width, height)
2115
+ const nextScale = clampScale(Math.max(fitScale, state.transform.scale * 1.08, hierarchyMicroExitScale * 1.02))
2116
+
2117
+ return {
2118
+ active: true,
2119
+ source: 'group',
2120
+ screenX: width / 2,
2121
+ screenY: height / 2,
2122
+ worldX: node.x,
2123
+ worldY: node.y,
2124
+ targetScale: nextScale
2125
+ }
2126
+ }
2127
+
2021
2128
  const layoutDensityScaleForNodeCount = (nodeCount) => {
2022
2129
  if (nodeCount > 50000) return 0.26
2023
2130
  if (nodeCount > 20000) return 0.3
@@ -2857,19 +2964,21 @@ const openContentDialog = async node => {
2857
2964
  const selectNode = (node, options = { openContent: false }) => {
2858
2965
  if (node?.isGroupNode) {
2859
2966
  state.selected = node
2860
- const rect = canvas.getBoundingClientRect()
2861
- const targetScale = clampScale(Math.max(state.transform.scale * 1.8, hierarchyExpansionStartScale * 1.08))
2862
- state.zoomTransition = {
2967
+ const group = state.groupById.get(node.groupId)
2968
+ state.zoomTransition = group ? childGraphFitTransition(node, group) : {
2863
2969
  active: true,
2864
2970
  source: 'group',
2865
- screenX: Math.max(rect.width, 320) / 2,
2866
- screenY: Math.max(rect.height, 320) / 2,
2971
+ screenX: state.viewport.width / 2,
2972
+ screenY: state.viewport.height / 2,
2867
2973
  worldX: node.x,
2868
2974
  worldY: node.y,
2869
- targetScale
2975
+ targetScale: clampScale(Math.max(state.transform.scale * 1.08, hierarchyMicroExitScale * 1.02))
2870
2976
  }
2871
2977
  state.lastZoomFocus = { x: node.x, y: node.y, at: performance.now() }
2872
2978
  state.hierarchyFocusGroupId = node.groupId
2979
+ if (group?.childGroupIds.length && state.hierarchyFocusStack.at(-1) !== group.id) {
2980
+ state.hierarchyFocusStack = [...state.hierarchyFocusStack, group.id]
2981
+ }
2873
2982
  markRenderDirty()
2874
2983
  return
2875
2984
  }
@@ -3003,10 +3112,12 @@ const bindEvents = () => {
3003
3112
  })
3004
3113
  if (elements.fit) {
3005
3114
  elements.fit.addEventListener('click', () => {
3115
+ resetHierarchyFocus()
3006
3116
  focusPrimaryHub()
3007
3117
  })
3008
3118
  }
3009
3119
  elements.reset.addEventListener('click', () => {
3120
+ resetHierarchyFocus()
3010
3121
  resetView()
3011
3122
  })
3012
3123
  elements.contentClose.addEventListener('click', () => elements.contentDialog.close())
@@ -3175,6 +3286,7 @@ const loadGraph = async (options = { reset: false }) => {
3175
3286
  state.nodes = layout.nodes
3176
3287
  state.groups = layout.groups
3177
3288
  state.hierarchyFocusGroupId = null
3289
+ state.hierarchyFocusStack = []
3178
3290
  state.groupById = new Map(state.groups.map(group => [group.id, group]))
3179
3291
  state.leafGroups = state.groups.filter(group => group.nodeIds.length > 0)
3180
3292
  state.nodeLeafGroupById = new Map(state.leafGroups.flatMap(group => group.nodeIds.map(nodeId => [nodeId, group])))
@@ -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 that fill each visible graph level toward 1000 nodes while keeping every group capped at 1000 child nodes; zoom-out renders the macro level as a sparse strongest-link graph of group nodes, zoom-in expands a group from the focused node's current viewport position only after it is framed, and micro view renders only the focused radial child graph with dense-node label suppression in a local frame anchored to the rendered group node until zoom-out restores sibling groups.
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 that fill each visible graph level toward 1000 nodes while keeping every group capped at 1000 child nodes; zoom-out renders the macro level with every aggregated link between visible group nodes, zoom-in grows the focused node into a local child graph anchored at the same point, once the child graph is framed the sibling groups stop rendering, and groups with child groups open another graph level instead of jumping directly to leaf notes.
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.130",
3
+ "version": "0.1.0-beta.132",
4
4
  "description": "Local-first knowledge memory for agents with Markdown, backlinks, indexing and context retrieval.",
5
5
  "type": "module",
6
6
  "license": "MIT",