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

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 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
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
 
@@ -16,7 +16,11 @@ const hierarchyMicroEnterCoverage = 0.58
16
16
  const hierarchyMicroExitCoverage = 0.38
17
17
  const hierarchyMicroEnterScale = 0.18
18
18
  const hierarchyMicroExitScale = 0.12
19
- const hierarchyChildGraphFitMargin = 1.38
19
+ const hierarchyFocusAcquireCoverage = 0.52
20
+ const hierarchyFocusAcquireScale = 0.16
21
+ const hierarchyChildRevealPower = 4
22
+ const hierarchyFocusedOnlyProgress = 0.64
23
+ const hierarchyChildGraphFitMargin = 1.28
20
24
  const minNodePixelRadius = 2.3
21
25
  const viewportPaddingPx = 280
22
26
  const worldCoordinateLimit = 5_000_000
@@ -647,7 +651,7 @@ const createVisibleEdgeLookup = edges => {
647
651
 
648
652
  const edgeBudgetForCurrentFrame = () => {
649
653
  const zoom = state.transform.scale
650
- if (zoom < 0.12) return 380
654
+ if (zoom < 0.12) return state.groups.length > 0 ? 1200 : 380
651
655
  if (zoom < 0.18) return 900
652
656
  if (zoom < 0.28) return 1700
653
657
  if (zoom < 0.45) return 2800
@@ -1319,6 +1323,43 @@ const arrangeChildGraphNodes = (nodes, group, origin = group) => {
1319
1323
  return arranged
1320
1324
  }
1321
1325
 
1326
+ const arrangeChildGroupNodes = (groups, parentGroup, origin) => {
1327
+ if (groups.length <= 1) {
1328
+ return groups.map(group => ({
1329
+ ...createGroupRenderNode(group),
1330
+ x: origin.x,
1331
+ y: origin.y
1332
+ }))
1333
+ }
1334
+
1335
+ const targetRadius = childGraphRenderRadius(parentGroup)
1336
+ const centerGroup = groups
1337
+ .map(group => ({
1338
+ group,
1339
+ score: Math.max(group.nodeIds.length, group.childGroupIds.length, 1)
1340
+ }))
1341
+ .sort((left, right) => right.score - left.score || left.group.title.localeCompare(right.group.title))[0]?.group
1342
+ const outerGroups = groups
1343
+ .filter(group => group.id !== centerGroup?.id)
1344
+ .sort((left, right) => left.segment.localeCompare(right.segment) || left.title.localeCompare(right.title))
1345
+ const goldenAngle = Math.PI * (3 - Math.sqrt(5))
1346
+ const arranged = centerGroup
1347
+ ? [{ ...createGroupRenderNode(centerGroup), x: origin.x, y: origin.y }]
1348
+ : []
1349
+
1350
+ outerGroups.forEach((group, index) => {
1351
+ const ringRadius = targetRadius * Math.sqrt((index + 1) / Math.max(outerGroups.length, 1))
1352
+ const angle = index * goldenAngle
1353
+ arranged.push({
1354
+ ...createGroupRenderNode(group),
1355
+ x: origin.x + Math.cos(angle) * ringRadius,
1356
+ y: origin.y + Math.sin(angle) * ringRadius
1357
+ })
1358
+ })
1359
+
1360
+ return arranged
1361
+ }
1362
+
1322
1363
  const interpolateNodeFromGroup = (node, origin, progress) => {
1323
1364
  return {
1324
1365
  ...node,
@@ -1364,7 +1405,7 @@ const hierarchyGroupsForScale = () => {
1364
1405
  const groupViewportCoverage = (group, viewport) => {
1365
1406
  const viewportWidth = Math.max(viewport.maxX - viewport.minX, 1)
1366
1407
  const viewportHeight = Math.max(viewport.maxY - viewport.minY, 1)
1367
- const viewportRadius = Math.max(viewportWidth, viewportHeight) / 2
1408
+ const viewportRadius = Math.min(viewportWidth, viewportHeight) / 2
1368
1409
  const centerX = (viewport.minX + viewport.maxX) / 2
1369
1410
  const centerY = (viewport.minY + viewport.maxY) / 2
1370
1411
  const centerDistance = Math.hypot(group.x - centerX, group.y - centerY)
@@ -1422,6 +1463,16 @@ const updateHierarchyFocusGroup = (groups, viewport) => {
1422
1463
  ? groups.find(group => group.id === state.hierarchyFocusGroupId) ?? null
1423
1464
  : null
1424
1465
  const currentCoverage = current ? groupViewportCoverage(current, viewport) : 0
1466
+ const hasActiveFocusedTransition =
1467
+ Boolean(current) &&
1468
+ state.zoomTransition.active &&
1469
+ state.zoomTransition.source === 'group' &&
1470
+ state.selected?.isGroupNode &&
1471
+ state.selected.groupId === current.id
1472
+
1473
+ if (hasActiveFocusedTransition) {
1474
+ return current
1475
+ }
1425
1476
 
1426
1477
  if (
1427
1478
  current &&
@@ -1440,8 +1491,8 @@ const updateHierarchyFocusGroup = (groups, viewport) => {
1440
1491
 
1441
1492
  if (
1442
1493
  candidate &&
1443
- state.transform.scale >= hierarchyMicroEnterScale &&
1444
- candidate.coverage >= hierarchyMicroEnterCoverage
1494
+ state.transform.scale >= hierarchyFocusAcquireScale &&
1495
+ candidate.coverage >= hierarchyFocusAcquireCoverage
1445
1496
  ) {
1446
1497
  state.hierarchyFocusGroupId = candidate.group.id
1447
1498
  if (candidate.group.childGroupIds.length > 0 && state.hierarchyFocusStack.at(-1) !== candidate.group.id) {
@@ -1459,9 +1510,23 @@ const updateHierarchyFocusGroup = (groups, viewport) => {
1459
1510
 
1460
1511
  const hierarchyViewportProgress = (group, viewport) => {
1461
1512
  const coverage = groupViewportCoverage(group, viewport)
1462
- const coverageProgress = (coverage - hierarchyMicroExitCoverage) / (hierarchyMicroEnterCoverage - hierarchyMicroExitCoverage)
1463
- const scaleProgress = (state.transform.scale - hierarchyMicroExitScale) / (hierarchyMicroEnterScale - hierarchyMicroExitScale)
1464
- return smoothProgress(Math.min(coverageProgress, scaleProgress))
1513
+ const coverageProgress = (coverage - hierarchyFocusAcquireCoverage) / (hierarchyMicroEnterCoverage - hierarchyFocusAcquireCoverage)
1514
+ const scaleProgress = (state.transform.scale - hierarchyFocusAcquireScale) / (hierarchyMicroEnterScale - hierarchyFocusAcquireScale)
1515
+ const viewportProgress = smoothProgress(Math.min(coverageProgress, scaleProgress))
1516
+ const hasActiveFocusedTransition =
1517
+ state.zoomTransition.active &&
1518
+ state.zoomTransition.source === 'group' &&
1519
+ state.selected?.isGroupNode &&
1520
+ state.selected.groupId === group.id
1521
+
1522
+ if (!hasActiveFocusedTransition) {
1523
+ return viewportProgress
1524
+ }
1525
+
1526
+ const targetSpan = Math.max(state.zoomTransition.targetScale - hierarchyFocusAcquireScale, 0.0001)
1527
+ const transitionScaleProgress = (state.transform.scale - hierarchyFocusAcquireScale) / targetSpan
1528
+ const transitionProgress = Math.pow(Math.max(0, Math.min(1, transitionScaleProgress)), 2.8)
1529
+ return Math.max(viewportProgress, transitionProgress)
1465
1530
  }
1466
1531
 
1467
1532
  const groupEdgesForRenderedGroups = (groupNodes) => {
@@ -1508,19 +1573,8 @@ const groupEdgesForRenderedGroups = (groupNodes) => {
1508
1573
  })
1509
1574
  }
1510
1575
 
1511
- const degreeCounts = new Map()
1512
1576
  return Array.from(selected.values())
1513
1577
  .sort((left, right) => edgeWeight(right) - edgeWeight(left) || left.source.localeCompare(right.source) || left.target.localeCompare(right.target))
1514
- .filter((edge) => {
1515
- const sourceCount = degreeCounts.get(edge.source) ?? 0
1516
- const targetCount = degreeCounts.get(edge.target) ?? 0
1517
- if (sourceCount >= 3 || targetCount >= 3) {
1518
- return false
1519
- }
1520
- degreeCounts.set(edge.source, sourceCount + 1)
1521
- degreeCounts.set(edge.target, targetCount + 1)
1522
- return true
1523
- })
1524
1578
  .slice(0, Math.min(hierarchyGroupEdgeLimit, edgeBudgetForCurrentFrame()))
1525
1579
  }
1526
1580
 
@@ -1547,25 +1601,22 @@ const computeHierarchyRenderVisibility = (viewport) => {
1547
1601
  const focusChildGroups = focus.childGroupIds
1548
1602
  .map(groupId => state.groupById.get(groupId))
1549
1603
  .filter(Boolean)
1550
- const childLimit = Math.max(90, Math.min(renderNodeBudget, Math.floor(renderNodeBudget * progress)))
1604
+ const childRevealProgress = Math.pow(Math.max(0, Math.min(1, progress)), hierarchyChildRevealPower)
1605
+ const childLimit = Math.min(renderNodeBudget, Math.max(1, Math.floor(renderNodeBudget * childRevealProgress)))
1551
1606
  const focusRenderNode = groupNodes.find(node => node.groupId === focus.id) ?? createGroupRenderNode(focus)
1552
1607
  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
- }))
1608
+ ? arrangeChildGroupNodes(focusChildGroups, focus, focusRenderNode)
1558
1609
  : arrangeChildGraphNodes(rawChildNodes, focus, focusRenderNode)
1559
1610
  const childNodes = selectStableSampleNodes(arrangedChildren, childLimit)
1560
1611
  .map(node => interpolateNodeFromGroup(node, focusRenderNode, progress))
1561
1612
  const childIds = new Set(childNodes.map(node => node.id))
1562
1613
  const childById = new Map(childNodes.map(node => [node.id, node]))
1563
- const isMicroView = progress >= 0.72
1614
+ const isMicroView = progress >= hierarchyFocusedOnlyProgress
1564
1615
  const visibleGroupNodes = isMicroView
1565
1616
  ? []
1566
1617
  : groupNodes.filter(node => node.groupId !== focus.id || progress < 0.92)
1567
1618
  const groupEdges = isMicroView ? [] : groupEdgesForRenderedGroups(visibleGroupNodes)
1568
- const childEdges = progress > 0.32
1619
+ const childEdges = (isMicroView || progress > 0.02)
1569
1620
  ? focusChildGroups.length > 0
1570
1621
  ? groupEdgesForRenderedGroups(childNodes)
1571
1622
  : collectVisibleEdgesForNodes(childIds).map(edge => ({
@@ -2086,7 +2137,7 @@ const childGraphFitTransition = (node, group) => {
2086
2137
  const width = Math.max(rect.width, 320)
2087
2138
  const height = Math.max(rect.height, 320)
2088
2139
  const fitScale = childGraphFitScaleForGroup(group, width, height)
2089
- const nextScale = clampScale(Math.max(fitScale, state.transform.scale * 1.16, hierarchyMicroEnterScale * 1.04))
2140
+ const nextScale = clampScale(Math.max(fitScale, state.transform.scale * 1.08, hierarchyMicroExitScale * 1.02))
2090
2141
 
2091
2142
  return {
2092
2143
  active: true,
@@ -2946,7 +2997,7 @@ const selectNode = (node, options = { openContent: false }) => {
2946
2997
  screenY: state.viewport.height / 2,
2947
2998
  worldX: node.x,
2948
2999
  worldY: node.y,
2949
- targetScale: clampScale(Math.max(state.transform.scale * 1.16, hierarchyMicroEnterScale * 1.04))
3000
+ targetScale: clampScale(Math.max(state.transform.scale * 1.08, hierarchyMicroExitScale * 1.02))
2950
3001
  }
2951
3002
  state.lastZoomFocus = { x: node.x, y: node.y, at: performance.now() }
2952
3003
  state.hierarchyFocusGroupId = node.groupId
@@ -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 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.
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.131",
3
+ "version": "0.1.0-beta.133",
4
4
  "description": "Local-first knowledge memory for agents with Markdown, backlinks, indexing and context retrieval.",
5
5
  "type": "module",
6
6
  "license": "MIT",