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

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
@@ -71,6 +71,7 @@ Legacy `.jsonl.gz` packs are upgraded to `.blpk` automatically on first search/c
71
71
  - Full-text, semantic and hybrid retrieval on a local file index.
72
72
  - Middle-out context assembly around the strongest chunk per document.
73
73
  - In-process index and context caching with automatic invalidation on index updates.
74
+ - HTTP graph server caches generated frontend assets and graph-layout JSON payloads by signature, and skips layout serialization when ETag returns `304`.
74
75
  - Compressed-space prefiltering for `.blpk` packs before decryption and scan.
75
76
  - Incremental indexing that reprocesses only changed markdown files and reuses existing chunks/embeddings for unchanged notes.
76
77
  - Adaptive compressed-pack rebuild policy to keep indexing fast during small edit batches.
@@ -589,7 +590,7 @@ The graph UI shows:
589
590
 
590
591
  - notes as nodes
591
592
  - representative `[[wiki links]]` as weighted edges
592
- - details opened on node click (tags, outgoing links, backlinks, full Markdown content)
593
+ - details opened in a non-modal side panel (tags, outgoing links, backlinks, full Markdown content), so zoom and pan remain available while inspecting data
593
594
  - neutral graph nodes with segment/group metadata
594
595
  - agent selector (id-only labels) for isolated views
595
596
  - graph filter matches title, path, tags and note content
@@ -601,7 +602,7 @@ The graph UI shows:
601
602
  - 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
602
603
  - zoom-out floor for large and massive graphs to keep the scene reachable without switching into a separate macro graph mode
603
604
  - keyboard shortcuts: `+` zoom in, `-` zoom out, `0` reset fit
604
- - double-click on canvas zooms in at cursor position
605
+ - click on a hierarchy group expands that group from the same world position; clicking the same focused group again opens its details panel; double-click on empty canvas zooms in at cursor position
605
606
  - floating graph totals (notes, links, tags) below the Brainlink title
606
607
  - graph rendering safeguards (batched canvas drawing across graph sizes, edge draw caps, lower redraw rate, zoom-aware interaction)
607
608
  - 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
@@ -262,14 +262,20 @@ li small {
262
262
  }
263
263
 
264
264
  .content-dialog {
265
- width: min(1240px, calc(100vw - 24px));
266
- max-height: calc(100svh - 20px);
265
+ position: fixed;
266
+ top: 84px;
267
+ right: 12px;
268
+ margin: 0;
269
+ width: min(440px, calc(100vw - 24px));
270
+ height: min(calc(100svh - 124px), 820px);
271
+ max-height: calc(100svh - 124px);
267
272
  padding: 0;
268
273
  border: 1px solid var(--line);
269
274
  border-radius: 8px;
270
275
  background: var(--panel);
271
276
  color: var(--text);
272
277
  box-shadow: 0 24px 80px rgba(0, 0, 0, 0.48);
278
+ overflow: hidden;
273
279
  }
274
280
 
275
281
  .content-dialog::backdrop {
@@ -280,7 +286,8 @@ li small {
280
286
  .content-dialog article {
281
287
  display: grid;
282
288
  grid-template-rows: auto auto minmax(0, 1fr);
283
- max-height: calc(100svh - 22px);
289
+ height: 100%;
290
+ max-height: 100%;
284
291
  }
285
292
 
286
293
  .content-dialog header {
@@ -299,7 +306,7 @@ li small {
299
306
 
300
307
  .content-dialog h2 {
301
308
  margin-top: 6px;
302
- font-size: 24px;
309
+ font-size: 19px;
303
310
  line-height: 1.15;
304
311
  overflow-wrap: anywhere;
305
312
  }
@@ -367,7 +374,7 @@ li small {
367
374
  min-height: 0;
368
375
  border: 0;
369
376
  border-radius: 0;
370
- padding: 22px;
377
+ padding: 14px;
371
378
  }
372
379
 
373
380
  .app-footer {
@@ -415,6 +422,16 @@ li small {
415
422
  flex-direction: column;
416
423
  }
417
424
 
425
+ .content-dialog {
426
+ top: auto;
427
+ right: 12px;
428
+ left: 12px;
429
+ bottom: 38px;
430
+ width: auto;
431
+ height: min(calc(100svh - 170px), 640px);
432
+ max-height: calc(100svh - 170px);
433
+ }
434
+
418
435
  .metric-chip {
419
436
  min-width: 82px;
420
437
  }
@@ -56,7 +56,7 @@ export const createClientHtml = () => `<!doctype html>
56
56
  <article>
57
57
  <header>
58
58
  <div>
59
- <span class="eyebrow">Markdown content</span>
59
+ <span class="eyebrow">Node details</span>
60
60
  <h2 id="contentTitle">Selected note</h2>
61
61
  <p id="contentPath"></p>
62
62
  </div>
@@ -18,9 +18,12 @@ const hierarchyMicroEnterScale = 0.18
18
18
  const hierarchyMicroExitScale = 0.12
19
19
  const hierarchyFocusAcquireCoverage = 0.52
20
20
  const hierarchyFocusAcquireScale = 0.16
21
- const hierarchyChildRevealPower = 4
22
- const hierarchyFocusedOnlyProgress = 0.64
21
+ const hierarchyChildRevealStartProgress = 0.28
22
+ const hierarchyChildRevealExponent = 1.8
23
+ const hierarchyFocusedOnlyProgress = 0.86
23
24
  const hierarchyChildGraphFitMargin = 1.28
25
+ const hierarchyChildRevealGrowthRatio = 0.3
26
+ const hierarchyChildRevealGrowthFloor = 2
24
27
  const minNodePixelRadius = 2.3
25
28
  const viewportPaddingPx = 280
26
29
  const worldCoordinateLimit = 5_000_000
@@ -88,6 +91,8 @@ const state = {
88
91
  lastZoomFocus: { x: 0, y: 0, at: 0 },
89
92
  hierarchyFocusGroupId: null,
90
93
  hierarchyFocusStack: [],
94
+ hierarchyRevealFocusGroupId: null,
95
+ hierarchyRevealBudget: 1,
91
96
  zoomTransition: {
92
97
  active: false,
93
98
  source: 'generic',
@@ -1225,6 +1230,15 @@ const smoothProgress = value => {
1225
1230
  return bounded * bounded * (3 - 2 * bounded)
1226
1231
  }
1227
1232
 
1233
+ const childRevealProgressForFocus = progress => {
1234
+ const bounded = Math.max(0, Math.min(1, progress))
1235
+ if (bounded <= hierarchyChildRevealStartProgress) {
1236
+ return 0
1237
+ }
1238
+ const shifted = (bounded - hierarchyChildRevealStartProgress) / Math.max(1 - hierarchyChildRevealStartProgress, 0.0001)
1239
+ return Math.pow(smoothProgress(shifted), hierarchyChildRevealExponent)
1240
+ }
1241
+
1228
1242
  const groupRenderRadius = group => {
1229
1243
  const childCount = Math.max(group.nodeIds.length, group.childGroupIds.length, 1)
1230
1244
  return 10 + Math.min(Math.log2(childCount + 1) * 4.2, 22)
@@ -1447,7 +1461,7 @@ const selectViewportItemsWithFill = (items, viewport, limit = renderNodeBudget)
1447
1461
  return visible.concat(fill)
1448
1462
  }
1449
1463
 
1450
- const updateHierarchyFocusGroup = (groups, viewport) => {
1464
+ const updateHierarchyFocusGroup = (groups, _viewport) => {
1451
1465
  const activeGroupIds = new Set(groups.map(group => group.id))
1452
1466
  state.hierarchyFocusStack = state.hierarchyFocusStack.filter(groupId => state.groupById.has(groupId))
1453
1467
  const parentGroup = activeHierarchyParentGroup()
@@ -1462,7 +1476,6 @@ const updateHierarchyFocusGroup = (groups, viewport) => {
1462
1476
  const current = state.hierarchyFocusGroupId
1463
1477
  ? groups.find(group => group.id === state.hierarchyFocusGroupId) ?? null
1464
1478
  : null
1465
- const currentCoverage = current ? groupViewportCoverage(current, viewport) : 0
1466
1479
  const hasActiveFocusedTransition =
1467
1480
  Boolean(current) &&
1468
1481
  state.zoomTransition.active &&
@@ -1473,46 +1486,47 @@ const updateHierarchyFocusGroup = (groups, viewport) => {
1473
1486
  if (hasActiveFocusedTransition) {
1474
1487
  return current
1475
1488
  }
1489
+ const selectedGroupId = state.selected?.isGroupNode ? state.selected.groupId : null
1490
+ const selectedGroup = selectedGroupId
1491
+ ? groups.find(group => group.id === selectedGroupId) ?? null
1492
+ : null
1476
1493
 
1477
- if (
1478
- current &&
1479
- state.transform.scale >= hierarchyMicroExitScale &&
1480
- currentCoverage >= hierarchyMicroExitCoverage
1481
- ) {
1482
- if (current.childGroupIds.length > 0 && state.hierarchyFocusStack.at(-1) !== current.id) {
1483
- state.hierarchyFocusStack = [...state.hierarchyFocusStack, current.id]
1484
- }
1485
- return current
1486
- }
1487
-
1488
- const candidate = groups
1489
- .map(group => groupWithCoverage(group, viewport))
1490
- .sort((left, right) => right.coverage - left.coverage)[0]
1491
-
1492
- if (
1493
- candidate &&
1494
- state.transform.scale >= hierarchyFocusAcquireScale &&
1495
- candidate.coverage >= hierarchyFocusAcquireCoverage
1496
- ) {
1497
- state.hierarchyFocusGroupId = candidate.group.id
1498
- if (candidate.group.childGroupIds.length > 0 && state.hierarchyFocusStack.at(-1) !== candidate.group.id) {
1499
- state.hierarchyFocusStack = [...state.hierarchyFocusStack, candidate.group.id]
1500
- }
1501
- return candidate.group
1494
+ if (selectedGroup && state.transform.scale >= hierarchyMicroExitScale) {
1495
+ state.hierarchyFocusGroupId = selectedGroup.id
1496
+ return selectedGroup
1502
1497
  }
1503
1498
 
1504
1499
  if (state.hierarchyFocusStack.length > 0 && state.transform.scale < hierarchyMicroExitScale) {
1505
1500
  state.hierarchyFocusStack = state.hierarchyFocusStack.slice(0, -1)
1501
+ if (state.selected?.isGroupNode) {
1502
+ state.selected = null
1503
+ }
1506
1504
  }
1507
1505
  state.hierarchyFocusGroupId = null
1508
1506
  return null
1509
1507
  }
1510
1508
 
1511
- const hierarchyViewportProgress = (group, viewport) => {
1512
- const coverage = groupViewportCoverage(group, viewport)
1513
- const coverageProgress = (coverage - hierarchyFocusAcquireCoverage) / (hierarchyMicroEnterCoverage - hierarchyFocusAcquireCoverage)
1514
- const scaleProgress = (state.transform.scale - hierarchyFocusAcquireScale) / (hierarchyMicroEnterScale - hierarchyFocusAcquireScale)
1515
- const viewportProgress = smoothProgress(Math.min(coverageProgress, scaleProgress))
1509
+ const updateHierarchyChildRevealBudget = (focusGroupId, targetLimit) => {
1510
+ if (state.hierarchyRevealFocusGroupId !== focusGroupId) {
1511
+ state.hierarchyRevealFocusGroupId = focusGroupId
1512
+ state.hierarchyRevealBudget = 1
1513
+ }
1514
+
1515
+ if (state.hierarchyRevealBudget > targetLimit) {
1516
+ state.hierarchyRevealBudget = targetLimit
1517
+ return targetLimit
1518
+ }
1519
+
1520
+ const remaining = Math.max(0, targetLimit - state.hierarchyRevealBudget)
1521
+ const growth = Math.max(
1522
+ hierarchyChildRevealGrowthFloor,
1523
+ Math.floor(remaining * hierarchyChildRevealGrowthRatio)
1524
+ )
1525
+ state.hierarchyRevealBudget = Math.min(targetLimit, state.hierarchyRevealBudget + growth)
1526
+ return state.hierarchyRevealBudget
1527
+ }
1528
+
1529
+ const hierarchyViewportProgress = (group, _viewport) => {
1516
1530
  const hasActiveFocusedTransition =
1517
1531
  state.zoomTransition.active &&
1518
1532
  state.zoomTransition.source === 'group' &&
@@ -1520,13 +1534,12 @@ const hierarchyViewportProgress = (group, viewport) => {
1520
1534
  state.selected.groupId === group.id
1521
1535
 
1522
1536
  if (!hasActiveFocusedTransition) {
1523
- return viewportProgress
1537
+ return 1
1524
1538
  }
1525
1539
 
1526
1540
  const targetSpan = Math.max(state.zoomTransition.targetScale - hierarchyFocusAcquireScale, 0.0001)
1527
1541
  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)
1542
+ return Math.pow(Math.max(0, Math.min(1, transitionScaleProgress)), 2.8)
1530
1543
  }
1531
1544
 
1532
1545
  const groupEdgesForRenderedGroups = (groupNodes) => {
@@ -1582,15 +1595,20 @@ const computeHierarchyRenderVisibility = (viewport) => {
1582
1595
  if (state.groups.length === 0 || state.visibleNodes.length <= 1000) {
1583
1596
  state.hierarchyFocusGroupId = null
1584
1597
  state.hierarchyFocusStack = []
1598
+ state.hierarchyRevealFocusGroupId = null
1599
+ state.hierarchyRevealBudget = 1
1585
1600
  return false
1586
1601
  }
1587
1602
 
1588
1603
  const groups = selectViewportItemsWithFill(hierarchyGroupsForScale(), viewport, renderNodeBudget)
1589
1604
  const focus = updateHierarchyFocusGroup(groups, viewport)
1590
1605
  const progress = focus ? hierarchyViewportProgress(focus, viewport) : 0
1606
+ const revealProgress = childRevealProgressForFocus(progress)
1591
1607
  const groupNodes = groups.map(createGroupRenderNode)
1592
1608
 
1593
- if (!focus || progress <= 0.02) {
1609
+ if (!focus || revealProgress <= 0) {
1610
+ state.hierarchyRevealFocusGroupId = null
1611
+ state.hierarchyRevealBudget = 1
1594
1612
  state.renderNodes = groupNodes
1595
1613
  state.renderEdges = groupEdgesForRenderedGroups(groupNodes)
1596
1614
  return true
@@ -1601,22 +1619,22 @@ const computeHierarchyRenderVisibility = (viewport) => {
1601
1619
  const focusChildGroups = focus.childGroupIds
1602
1620
  .map(groupId => state.groupById.get(groupId))
1603
1621
  .filter(Boolean)
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)))
1622
+ const childTargetLimit = Math.min(renderNodeBudget, Math.max(1, Math.floor(renderNodeBudget * revealProgress)))
1623
+ const childLimit = updateHierarchyChildRevealBudget(focus.id, childTargetLimit)
1606
1624
  const focusRenderNode = groupNodes.find(node => node.groupId === focus.id) ?? createGroupRenderNode(focus)
1607
1625
  const arrangedChildren = focusChildGroups.length > 0
1608
1626
  ? arrangeChildGroupNodes(focusChildGroups, focus, focusRenderNode)
1609
1627
  : arrangeChildGraphNodes(rawChildNodes, focus, focusRenderNode)
1610
1628
  const childNodes = selectStableSampleNodes(arrangedChildren, childLimit)
1611
- .map(node => interpolateNodeFromGroup(node, focusRenderNode, progress))
1629
+ .map(node => interpolateNodeFromGroup(node, focusRenderNode, revealProgress))
1612
1630
  const childIds = new Set(childNodes.map(node => node.id))
1613
1631
  const childById = new Map(childNodes.map(node => [node.id, node]))
1614
- const isMicroView = progress >= hierarchyFocusedOnlyProgress
1632
+ const isMicroView = revealProgress >= hierarchyFocusedOnlyProgress
1615
1633
  const visibleGroupNodes = isMicroView
1616
1634
  ? []
1617
- : groupNodes.filter(node => node.groupId !== focus.id || progress < 0.92)
1635
+ : groupNodes.filter(node => node.groupId !== focus.id || revealProgress < 0.96)
1618
1636
  const groupEdges = isMicroView ? [] : groupEdgesForRenderedGroups(visibleGroupNodes)
1619
- const childEdges = (isMicroView || progress > 0.02)
1637
+ const childEdges = (isMicroView || revealProgress > 0)
1620
1638
  ? focusChildGroups.length > 0
1621
1639
  ? groupEdgesForRenderedGroups(childNodes)
1622
1640
  : collectVisibleEdgesForNodes(childIds).map(edge => ({
@@ -2102,6 +2120,8 @@ const resetView = () => fitView({ useFiltered: false, preferHubCenter: false })
2102
2120
  const resetHierarchyFocus = () => {
2103
2121
  state.hierarchyFocusGroupId = null
2104
2122
  state.hierarchyFocusStack = []
2123
+ state.hierarchyRevealFocusGroupId = null
2124
+ state.hierarchyRevealBudget = 1
2105
2125
  }
2106
2126
 
2107
2127
  const focusPrimaryHub = () => {
@@ -2920,6 +2940,66 @@ const linkedNodes = node => {
2920
2940
  return { outgoing, incoming }
2921
2941
  }
2922
2942
 
2943
+ const linkedGroupsForGroupNode = groupNode => {
2944
+ const group = state.groupById.get(groupNode.groupId)
2945
+ if (!group) {
2946
+ return { outgoing: [], incoming: [], internalEdgeCount: 0, externalEdgeCount: 0 }
2947
+ }
2948
+
2949
+ const nodeIds = new Set(group.nodeIds)
2950
+ const externalByGroup = new Map()
2951
+ let internalEdgeCount = 0
2952
+ let externalEdgeCount = 0
2953
+
2954
+ for (let index = 0; index < state.visibleEdges.length; index += 1) {
2955
+ const edge = state.visibleEdges[index]
2956
+ if (!edge.target) continue
2957
+ const sourceInside = nodeIds.has(edge.source)
2958
+ const targetInside = nodeIds.has(edge.target)
2959
+ if (sourceInside && targetInside) {
2960
+ internalEdgeCount += 1
2961
+ continue
2962
+ }
2963
+ if (!sourceInside && !targetInside) continue
2964
+
2965
+ externalEdgeCount += 1
2966
+ const externalNodeId = sourceInside ? edge.target : edge.source
2967
+ const externalGroup = state.nodeLeafGroupById.get(externalNodeId)
2968
+ const externalKey = externalGroup?.id ?? 'unknown'
2969
+ const externalTitle = externalGroup?.title ?? (edge.targetTitle || 'Unknown group')
2970
+ const direction = sourceInside ? 'outgoing' : 'incoming'
2971
+ const current = externalByGroup.get(externalKey)
2972
+
2973
+ if (current) {
2974
+ current.weight += edgeWeight(edge)
2975
+ if (direction === 'outgoing') current.outgoing += 1
2976
+ if (direction === 'incoming') current.incoming += 1
2977
+ continue
2978
+ }
2979
+
2980
+ externalByGroup.set(externalKey, {
2981
+ id: '',
2982
+ title: externalTitle,
2983
+ path: 'Group relation - ' + direction,
2984
+ weight: edgeWeight(edge),
2985
+ priority: edge.priority || 'normal',
2986
+ outgoing: direction === 'outgoing' ? 1 : 0,
2987
+ incoming: direction === 'incoming' ? 1 : 0
2988
+ })
2989
+ }
2990
+
2991
+ const external = Array.from(externalByGroup.values())
2992
+ .sort((left, right) => right.weight - left.weight || left.title.localeCompare(right.title))
2993
+ const outgoing = external
2994
+ .filter(item => item.outgoing > 0)
2995
+ .slice(0, 18)
2996
+ const incoming = external
2997
+ .filter(item => item.incoming > 0)
2998
+ .slice(0, 18)
2999
+
3000
+ return { outgoing, incoming, internalEdgeCount, externalEdgeCount }
3001
+ }
3002
+
2923
3003
  const fetchNodeDetails = async node => {
2924
3004
  const cached = state.nodeDetails.get(node.id)
2925
3005
  if (cached) {
@@ -2942,8 +3022,43 @@ const fetchNodeDetails = async node => {
2942
3022
 
2943
3023
  const wait = async (milliseconds) => new Promise(resolve => setTimeout(resolve, milliseconds))
2944
3024
 
3025
+ const openGroupDialog = groupNode => {
3026
+ const group = state.groupById.get(groupNode.groupId)
3027
+ if (!group) return
3028
+
3029
+ const groupLinks = linkedGroupsForGroupNode(groupNode)
3030
+ const title = group.title || 'Group'
3031
+ const groupType = group.childGroupIds.length > 0 ? 'Hierarchical group' : 'Leaf group'
3032
+ const nodeCount = group.nodeIds.length
3033
+ const childGroupCount = group.childGroupIds.length
3034
+ const radius = Math.round(childGraphRenderRadius(group))
3035
+
3036
+ elements.contentTitle.textContent = title
3037
+ elements.contentPath.textContent = groupType + ' - level ' + group.level
3038
+ elements.contentTags.innerHTML = [
3039
+ '<span>segment: ' + escapeHtml(group.segment || 'root') + '</span>',
3040
+ '<span>cluster: ' + escapeHtml(group.group || 'root') + '</span>'
3041
+ ].join('')
3042
+ elements.contentOutgoing.innerHTML = list(groupLinks.outgoing)
3043
+ elements.contentIncoming.innerHTML = list(groupLinks.incoming)
3044
+ elements.contentBody.textContent = [
3045
+ 'Nodes: ' + nodeCount,
3046
+ 'Child groups: ' + childGroupCount,
3047
+ 'Internal links: ' + groupLinks.internalEdgeCount,
3048
+ 'External links: ' + groupLinks.externalEdgeCount,
3049
+ 'Render radius: ' + radius
3050
+ ].join('\\n')
3051
+ if (!elements.contentDialog.open) {
3052
+ elements.contentDialog.show()
3053
+ }
3054
+ }
3055
+
2945
3056
  const openContentDialog = async node => {
2946
- if (!node || node.isGroupNode) return
3057
+ if (!node) return
3058
+ if (node.isGroupNode) {
3059
+ openGroupDialog(node)
3060
+ return
3061
+ }
2947
3062
  elements.contentTitle.textContent = node.title || 'Loading...'
2948
3063
  elements.contentPath.textContent = node.path || 'Loading...'
2949
3064
  elements.contentTags.innerHTML = Array.isArray(node.tags) && node.tags.length
@@ -2954,7 +3069,7 @@ const openContentDialog = async node => {
2954
3069
  elements.contentIncoming.innerHTML = list(initialLinks.incoming)
2955
3070
  elements.contentBody.textContent = 'Loading note content...'
2956
3071
  if (!elements.contentDialog.open) {
2957
- elements.contentDialog.showModal()
3072
+ elements.contentDialog.show()
2958
3073
  }
2959
3074
 
2960
3075
  const applyDetailToDialog = detail => {
@@ -2986,7 +3101,8 @@ const openContentDialog = async node => {
2986
3101
  }
2987
3102
  }
2988
3103
 
2989
- const selectNode = (node, options = { openContent: false }) => {
3104
+ const expandGroupNode = node => {
3105
+ if (!node?.isGroupNode) return
2990
3106
  if (node?.isGroupNode) {
2991
3107
  state.selected = node
2992
3108
  const group = state.groupById.get(node.groupId)
@@ -3007,6 +3123,38 @@ const selectNode = (node, options = { openContent: false }) => {
3007
3123
  markRenderDirty()
3008
3124
  return
3009
3125
  }
3126
+ }
3127
+
3128
+ const handleGroupNodePrimaryClick = node => {
3129
+ if (!node?.isGroupNode) return
3130
+ const alreadyFocused = state.selected?.isGroupNode && state.selected.groupId === node.groupId
3131
+ if (alreadyFocused && !state.zoomTransition.active) {
3132
+ selectNode(node, { openContent: true })
3133
+ return
3134
+ }
3135
+ expandGroupNode(node)
3136
+ }
3137
+
3138
+ const selectNode = (node, options = { openContent: false }) => {
3139
+ if (!node) {
3140
+ state.selected = null
3141
+ if (elements.contentDialog.open) {
3142
+ elements.contentDialog.close()
3143
+ }
3144
+ markRenderDirty()
3145
+ return
3146
+ }
3147
+
3148
+ if (node?.isGroupNode) {
3149
+ state.selected = node
3150
+ if (options.openContent) {
3151
+ openContentDialog(node).catch(() => {
3152
+ elements.contentBody.textContent = 'Unable to load group details.'
3153
+ })
3154
+ }
3155
+ markRenderDirty()
3156
+ return
3157
+ }
3010
3158
  state.selected = node
3011
3159
  if (node && options.openContent) {
3012
3160
  openContentDialog(node).catch(() => {
@@ -3087,10 +3235,6 @@ const wheelZoomFactor = event => {
3087
3235
  }
3088
3236
 
3089
3237
  const handleWheelZoom = event => {
3090
- if (elements.contentDialog?.open) {
3091
- return
3092
- }
3093
-
3094
3238
  event.preventDefault()
3095
3239
  const rect = canvas.getBoundingClientRect()
3096
3240
  const rawCursorX = Number.isFinite(event.offsetX) ? event.offsetX : event.clientX - rect.left
@@ -3159,7 +3303,9 @@ const bindEvents = () => {
3159
3303
  const point = worldPoint(event)
3160
3304
  const node = hitNode(point)
3161
3305
  if (node) {
3162
- selectNode(node, { openContent: true })
3306
+ if (!node.isGroupNode) {
3307
+ selectNode(node, { openContent: true })
3308
+ }
3163
3309
  return
3164
3310
  }
3165
3311
 
@@ -3223,8 +3369,14 @@ const bindEvents = () => {
3223
3369
  settleNeighborhoodAroundNode(draggedNode)
3224
3370
  markRenderDirty()
3225
3371
  }
3226
- if (draggedNode && !state.pointer.moved) selectNode(draggedNode, { openContent: false })
3227
- if (!draggedNode && !state.pointer.moved) selectNode(state.hovered, { openContent: false })
3372
+ if (!state.pointer.moved) {
3373
+ const clickedNode = draggedNode ?? state.hovered
3374
+ if (clickedNode?.isGroupNode) {
3375
+ handleGroupNodePrimaryClick(clickedNode)
3376
+ } else {
3377
+ selectNode(clickedNode, { openContent: true })
3378
+ }
3379
+ }
3228
3380
  state.pointer = { x: 0, y: 0, down: false, dragNode: null, moved: false }
3229
3381
  canvas.releasePointerCapture(event.pointerId)
3230
3382
  })
@@ -3312,6 +3464,8 @@ const loadGraph = async (options = { reset: false }) => {
3312
3464
  state.groups = layout.groups
3313
3465
  state.hierarchyFocusGroupId = null
3314
3466
  state.hierarchyFocusStack = []
3467
+ state.hierarchyRevealFocusGroupId = null
3468
+ state.hierarchyRevealBudget = 1
3315
3469
  state.groupById = new Map(state.groups.map(group => [group.id, group]))
3316
3470
  state.leafGroups = state.groups.filter(group => group.nodeIds.length > 0)
3317
3471
  state.nodeLeafGroupById = new Map(state.leafGroups.flatMap(group => group.nodeIds.map(nodeId => [nodeId, group])))
@@ -59,6 +59,36 @@ const parseNumber = (value, fallback) => {
59
59
  };
60
60
  const compactGraphLayoutThreshold = 12_000;
61
61
  const compactGraphLayoutEdgeLimit = 60_000;
62
+ const graphLayoutBodyCacheLimit = 8;
63
+ const graphLayoutBodyCache = new Map();
64
+ let cachedClientHtml = null;
65
+ let cachedClientCss = null;
66
+ let cachedClientJs = null;
67
+ let cachedClientWorkerJs = null;
68
+ const readClientHtml = () => {
69
+ if (cachedClientHtml === null) {
70
+ cachedClientHtml = createClientHtml();
71
+ }
72
+ return cachedClientHtml;
73
+ };
74
+ const readClientCss = () => {
75
+ if (cachedClientCss === null) {
76
+ cachedClientCss = createClientCss();
77
+ }
78
+ return cachedClientCss;
79
+ };
80
+ const readClientJs = () => {
81
+ if (cachedClientJs === null) {
82
+ cachedClientJs = createClientJs();
83
+ }
84
+ return cachedClientJs;
85
+ };
86
+ const readClientWorkerJs = () => {
87
+ if (cachedClientWorkerJs === null) {
88
+ cachedClientWorkerJs = createClientWorkerJs();
89
+ }
90
+ return cachedClientWorkerJs;
91
+ };
62
92
  const compactGraphLayoutEdgeLimitFor = (nodeCount) => {
63
93
  if (nodeCount > 100_000)
64
94
  return 15_000;
@@ -146,18 +176,31 @@ const compactLayoutPayload = (layout) => {
146
176
  };
147
177
  };
148
178
  const encodeLayoutPayload = (layout) => layout.nodes.length > compactGraphLayoutThreshold ? compactLayoutPayload(layout) : { compact: false, layout: stripLayoutContent(layout) };
179
+ const readGraphLayoutBody = (signature) => graphLayoutBodyCache.get(signature) ?? null;
180
+ const storeGraphLayoutBody = (signature, body) => {
181
+ if (graphLayoutBodyCache.has(signature)) {
182
+ graphLayoutBodyCache.delete(signature);
183
+ }
184
+ graphLayoutBodyCache.set(signature, body);
185
+ while (graphLayoutBodyCache.size > graphLayoutBodyCacheLimit) {
186
+ const oldest = graphLayoutBodyCache.keys().next().value;
187
+ if (!oldest)
188
+ break;
189
+ graphLayoutBodyCache.delete(oldest);
190
+ }
191
+ };
149
192
  export const route = async (request, url, vaultPath) => {
150
193
  if (isReadMethod(request) && (url.pathname === '/' || url.pathname === '/index.html')) {
151
- return createResponse(createClientHtml(), 200, contentTypes['.html']);
194
+ return createResponse(readClientHtml(), 200, contentTypes['.html']);
152
195
  }
153
196
  if (isReadMethod(request) && url.pathname === '/styles.css') {
154
- return createResponse(createClientCss(), 200, contentTypes['.css']);
197
+ return createResponse(readClientCss(), 200, contentTypes['.css']);
155
198
  }
156
199
  if (isReadMethod(request) && url.pathname === '/app.js') {
157
- return createResponse(createClientJs(), 200, contentTypes['.js']);
200
+ return createResponse(readClientJs(), 200, contentTypes['.js']);
158
201
  }
159
202
  if (isReadMethod(request) && url.pathname === '/app-worker.js') {
160
- return createResponse(createClientWorkerJs(), 200, contentTypes['.js']);
203
+ return createResponse(readClientWorkerJs(), 200, contentTypes['.js']);
161
204
  }
162
205
  if (isReadMethod(request) && url.pathname === '/api/graph') {
163
206
  return createResponse(createJsonResponse(await getGraph(vaultPath, readAgentQuery(url))), 200, contentTypes['.json']);
@@ -167,8 +210,6 @@ export const route = async (request, url, vaultPath) => {
167
210
  const requestEtags = request.headers['if-none-match'];
168
211
  const notModified = sameEntityTag(requestEtags, signature);
169
212
  const etag = encodeEntityTag(signature);
170
- const body = createJsonResponse({ signature, ...encodeLayoutPayload(layout) });
171
- const jsonResponse = createResponse(body, 200, contentTypes['.json']);
172
213
  const notModifiedResponse = createResponse('', 304, contentTypes['.json']);
173
214
  if (notModified) {
174
215
  return {
@@ -179,6 +220,12 @@ export const route = async (request, url, vaultPath) => {
179
220
  }
180
221
  };
181
222
  }
223
+ const cachedBody = readGraphLayoutBody(signature);
224
+ const body = cachedBody ?? createJsonResponse({ signature, ...encodeLayoutPayload(layout) });
225
+ if (!cachedBody) {
226
+ storeGraphLayoutBody(signature, body);
227
+ }
228
+ const jsonResponse = createResponse(body, 200, contentTypes['.json']);
182
229
  return {
183
230
  ...jsonResponse,
184
231
  headers: {
@@ -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 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.
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 empty canvas zooms in at cursor position. Clicking a hierarchy group expands that group from the same world position; clicking the same focused group again opens its details panel. Totals for notes, links and tags stay visible as floating metrics under the Brainlink title, and node details open in a non-modal side panel (tags, outgoing links, backlinks and Markdown content), so zoom and pan remain available during inspection. 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:
@@ -171,6 +171,7 @@ HTTP request
171
171
  ```
172
172
 
173
173
  The HTTP API is local-first and unauthenticated. It is meant for local agents, browser UI, and development workflows.
174
+ The route adapter caches generated frontend assets (`/`, `/styles.css`, `/app.js`, `/app-worker.js`) and graph-layout JSON payloads by layout signature to reduce repeated CPU and allocation pressure during navigation.
174
175
 
175
176
  ## MCP Flow
176
177
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andespindola/brainlink",
3
- "version": "0.1.0-beta.133",
3
+ "version": "0.1.0-beta.135",
4
4
  "description": "Local-first knowledge memory for agents with Markdown, backlinks, indexing and context retrieval.",
5
5
  "type": "module",
6
6
  "license": "MIT",