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

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 sparse strongest-link edges, zoom-in fits the framed node's full child-graph circumference with extra margin before expanding, 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,11 @@ 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 hierarchyChildGraphFitMargin = 1.38
19
20
  const minNodePixelRadius = 2.3
20
21
  const viewportPaddingPx = 280
21
22
  const worldCoordinateLimit = 5_000_000
@@ -82,6 +83,7 @@ const state = {
82
83
  lastManualZoomAt: 0,
83
84
  lastZoomFocus: { x: 0, y: 0, at: 0 },
84
85
  hierarchyFocusGroupId: null,
86
+ hierarchyFocusStack: [],
85
87
  zoomTransition: {
86
88
  active: false,
87
89
  source: 'generic',
@@ -1330,14 +1332,33 @@ const interpolateNodeFromGroup = (node, origin, progress) => {
1330
1332
  const parentHierarchyGroups = () =>
1331
1333
  state.groups.filter(group => group.parentId === null)
1332
1334
 
1335
+ const activeHierarchyParentGroup = () => {
1336
+ for (let index = state.hierarchyFocusStack.length - 1; index >= 0; index -= 1) {
1337
+ const group = state.groupById.get(state.hierarchyFocusStack[index])
1338
+ if (group && group.childGroupIds.length > 0) {
1339
+ return group
1340
+ }
1341
+ }
1342
+ return null
1343
+ }
1344
+
1345
+ const groupsBelongToParent = (groups, parentGroup) =>
1346
+ Boolean(parentGroup) && groups.some(group =>
1347
+ group.parentId === parentGroup.id || parentGroup.childGroupIds.includes(group.id)
1348
+ )
1349
+
1333
1350
  const hierarchyGroupsForScale = () => {
1334
1351
  if (state.groups.length === 0) {
1335
1352
  return []
1336
1353
  }
1337
- if (state.transform.scale < hierarchyExpansionStartScale) {
1338
- return arrangeGraphLevelNodes(parentHierarchyGroups(), groupRenderRadius)
1354
+ const parentGroup = activeHierarchyParentGroup()
1355
+ if (parentGroup) {
1356
+ const childGroups = parentGroup.childGroupIds
1357
+ .map(groupId => state.groupById.get(groupId))
1358
+ .filter(Boolean)
1359
+ return arrangeGraphLevelNodes(childGroups, groupRenderRadius)
1339
1360
  }
1340
- return arrangeGraphLevelNodes(state.leafGroups, groupRenderRadius)
1361
+ return arrangeGraphLevelNodes(parentHierarchyGroups(), groupRenderRadius)
1341
1362
  }
1342
1363
 
1343
1364
  const groupViewportCoverage = (group, viewport) => {
@@ -1386,6 +1407,17 @@ const selectViewportItemsWithFill = (items, viewport, limit = renderNodeBudget)
1386
1407
  }
1387
1408
 
1388
1409
  const updateHierarchyFocusGroup = (groups, viewport) => {
1410
+ const activeGroupIds = new Set(groups.map(group => group.id))
1411
+ state.hierarchyFocusStack = state.hierarchyFocusStack.filter(groupId => state.groupById.has(groupId))
1412
+ const parentGroup = activeHierarchyParentGroup()
1413
+ if (!groupsBelongToParent(groups, parentGroup)) {
1414
+ while (
1415
+ state.hierarchyFocusStack.length > 0 &&
1416
+ !activeGroupIds.has(state.hierarchyFocusStack[state.hierarchyFocusStack.length - 1])
1417
+ ) {
1418
+ state.hierarchyFocusStack.pop()
1419
+ }
1420
+ }
1389
1421
  const current = state.hierarchyFocusGroupId
1390
1422
  ? groups.find(group => group.id === state.hierarchyFocusGroupId) ?? null
1391
1423
  : null
@@ -1396,6 +1428,9 @@ const updateHierarchyFocusGroup = (groups, viewport) => {
1396
1428
  state.transform.scale >= hierarchyMicroExitScale &&
1397
1429
  currentCoverage >= hierarchyMicroExitCoverage
1398
1430
  ) {
1431
+ if (current.childGroupIds.length > 0 && state.hierarchyFocusStack.at(-1) !== current.id) {
1432
+ state.hierarchyFocusStack = [...state.hierarchyFocusStack, current.id]
1433
+ }
1399
1434
  return current
1400
1435
  }
1401
1436
 
@@ -1409,9 +1444,15 @@ const updateHierarchyFocusGroup = (groups, viewport) => {
1409
1444
  candidate.coverage >= hierarchyMicroEnterCoverage
1410
1445
  ) {
1411
1446
  state.hierarchyFocusGroupId = candidate.group.id
1447
+ if (candidate.group.childGroupIds.length > 0 && state.hierarchyFocusStack.at(-1) !== candidate.group.id) {
1448
+ state.hierarchyFocusStack = [...state.hierarchyFocusStack, candidate.group.id]
1449
+ }
1412
1450
  return candidate.group
1413
1451
  }
1414
1452
 
1453
+ if (state.hierarchyFocusStack.length > 0 && state.transform.scale < hierarchyMicroExitScale) {
1454
+ state.hierarchyFocusStack = state.hierarchyFocusStack.slice(0, -1)
1455
+ }
1415
1456
  state.hierarchyFocusGroupId = null
1416
1457
  return null
1417
1458
  }
@@ -1486,6 +1527,7 @@ const groupEdgesForRenderedGroups = (groupNodes) => {
1486
1527
  const computeHierarchyRenderVisibility = (viewport) => {
1487
1528
  if (state.groups.length === 0 || state.visibleNodes.length <= 1000) {
1488
1529
  state.hierarchyFocusGroupId = null
1530
+ state.hierarchyFocusStack = []
1489
1531
  return false
1490
1532
  }
1491
1533
 
@@ -1502,12 +1544,20 @@ const computeHierarchyRenderVisibility = (viewport) => {
1502
1544
 
1503
1545
  const focusIds = new Set(focus.nodeIds)
1504
1546
  const rawChildNodes = state.visibleNodes.filter(node => focusIds.has(node.id))
1547
+ const focusChildGroups = focus.childGroupIds
1548
+ .map(groupId => state.groupById.get(groupId))
1549
+ .filter(Boolean)
1505
1550
  const childLimit = Math.max(90, Math.min(renderNodeBudget, Math.floor(renderNodeBudget * progress)))
1506
1551
  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))
1552
+ const arrangedChildren = focusChildGroups.length > 0
1553
+ ? arrangeGraphLevelNodes(focusChildGroups, groupRenderRadius).map(group => ({
1554
+ ...createGroupRenderNode(group),
1555
+ x: focusRenderNode.x + group.x,
1556
+ y: focusRenderNode.y + group.y
1557
+ }))
1558
+ : arrangeChildGraphNodes(rawChildNodes, focus, focusRenderNode)
1559
+ const childNodes = selectStableSampleNodes(arrangedChildren, childLimit)
1560
+ .map(node => interpolateNodeFromGroup(node, focusRenderNode, progress))
1511
1561
  const childIds = new Set(childNodes.map(node => node.id))
1512
1562
  const childById = new Map(childNodes.map(node => [node.id, node]))
1513
1563
  const isMicroView = progress >= 0.72
@@ -1516,11 +1566,13 @@ const computeHierarchyRenderVisibility = (viewport) => {
1516
1566
  : groupNodes.filter(node => node.groupId !== focus.id || progress < 0.92)
1517
1567
  const groupEdges = isMicroView ? [] : groupEdgesForRenderedGroups(visibleGroupNodes)
1518
1568
  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
- }))
1569
+ ? focusChildGroups.length > 0
1570
+ ? groupEdgesForRenderedGroups(childNodes)
1571
+ : collectVisibleEdgesForNodes(childIds).map(edge => ({
1572
+ ...edge,
1573
+ sourceNode: childById.get(edge.source) ?? edge.sourceNode,
1574
+ targetNode: childById.get(edge.target) ?? edge.targetNode
1575
+ }))
1524
1576
  : []
1525
1577
 
1526
1578
  state.renderNodes = mergeUniqueNodes(childNodes, visibleGroupNodes, Math.max(renderNodeBudget, childLimit + visibleGroupNodes.length))
@@ -1996,6 +2048,11 @@ const fitView = (options = { useFiltered: true, preferHubCenter: true }) => {
1996
2048
 
1997
2049
  const resetView = () => fitView({ useFiltered: false, preferHubCenter: false })
1998
2050
 
2051
+ const resetHierarchyFocus = () => {
2052
+ state.hierarchyFocusGroupId = null
2053
+ state.hierarchyFocusStack = []
2054
+ }
2055
+
1999
2056
  const focusPrimaryHub = () => {
2000
2057
  const hub = state.primaryHub
2001
2058
  if (!hub) {
@@ -2018,6 +2075,30 @@ const focusPrimaryHub = () => {
2018
2075
  markRenderDirty()
2019
2076
  }
2020
2077
 
2078
+ const childGraphFitScaleForGroup = (group, viewportWidth, viewportHeight) => {
2079
+ const graphDiameter = Math.max(childGraphRenderRadius(group) * 2 * hierarchyChildGraphFitMargin, 1)
2080
+ const available = Math.max(Math.min(viewportWidth, viewportHeight) - 96, 240)
2081
+ return clampScale(available / graphDiameter)
2082
+ }
2083
+
2084
+ const childGraphFitTransition = (node, group) => {
2085
+ const rect = canvas.getBoundingClientRect()
2086
+ const width = Math.max(rect.width, 320)
2087
+ const height = Math.max(rect.height, 320)
2088
+ const fitScale = childGraphFitScaleForGroup(group, width, height)
2089
+ const nextScale = clampScale(Math.max(fitScale, state.transform.scale * 1.16, hierarchyMicroEnterScale * 1.04))
2090
+
2091
+ return {
2092
+ active: true,
2093
+ source: 'group',
2094
+ screenX: width / 2,
2095
+ screenY: height / 2,
2096
+ worldX: node.x,
2097
+ worldY: node.y,
2098
+ targetScale: nextScale
2099
+ }
2100
+ }
2101
+
2021
2102
  const layoutDensityScaleForNodeCount = (nodeCount) => {
2022
2103
  if (nodeCount > 50000) return 0.26
2023
2104
  if (nodeCount > 20000) return 0.3
@@ -2857,19 +2938,21 @@ const openContentDialog = async node => {
2857
2938
  const selectNode = (node, options = { openContent: false }) => {
2858
2939
  if (node?.isGroupNode) {
2859
2940
  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 = {
2941
+ const group = state.groupById.get(node.groupId)
2942
+ state.zoomTransition = group ? childGraphFitTransition(node, group) : {
2863
2943
  active: true,
2864
2944
  source: 'group',
2865
- screenX: Math.max(rect.width, 320) / 2,
2866
- screenY: Math.max(rect.height, 320) / 2,
2945
+ screenX: state.viewport.width / 2,
2946
+ screenY: state.viewport.height / 2,
2867
2947
  worldX: node.x,
2868
2948
  worldY: node.y,
2869
- targetScale
2949
+ targetScale: clampScale(Math.max(state.transform.scale * 1.16, hierarchyMicroEnterScale * 1.04))
2870
2950
  }
2871
2951
  state.lastZoomFocus = { x: node.x, y: node.y, at: performance.now() }
2872
2952
  state.hierarchyFocusGroupId = node.groupId
2953
+ if (group?.childGroupIds.length && state.hierarchyFocusStack.at(-1) !== group.id) {
2954
+ state.hierarchyFocusStack = [...state.hierarchyFocusStack, group.id]
2955
+ }
2873
2956
  markRenderDirty()
2874
2957
  return
2875
2958
  }
@@ -3003,10 +3086,12 @@ const bindEvents = () => {
3003
3086
  })
3004
3087
  if (elements.fit) {
3005
3088
  elements.fit.addEventListener('click', () => {
3089
+ resetHierarchyFocus()
3006
3090
  focusPrimaryHub()
3007
3091
  })
3008
3092
  }
3009
3093
  elements.reset.addEventListener('click', () => {
3094
+ resetHierarchyFocus()
3010
3095
  resetView()
3011
3096
  })
3012
3097
  elements.contentClose.addEventListener('click', () => elements.contentDialog.close())
@@ -3175,6 +3260,7 @@ const loadGraph = async (options = { reset: false }) => {
3175
3260
  state.nodes = layout.nodes
3176
3261
  state.groups = layout.groups
3177
3262
  state.hierarchyFocusGroupId = null
3263
+ state.hierarchyFocusStack = []
3178
3264
  state.groupById = new Map(state.groups.map(group => [group.id, group]))
3179
3265
  state.leafGroups = state.groups.filter(group => group.nodeIds.length > 0)
3180
3266
  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 as a sparse strongest-link graph of group nodes, zoom-in fits the focused node's full child-graph circumference with extra margin before expanding, 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.131",
4
4
  "description": "Local-first knowledge memory for agents with Markdown, backlinks, indexing and context retrieval.",
5
5
  "type": "module",
6
6
  "license": "MIT",