@andespindola/brainlink 0.1.0-beta.134 → 0.1.0-beta.136

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,8 +18,9 @@ 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
24
25
  const hierarchyChildRevealGrowthRatio = 0.3
25
26
  const hierarchyChildRevealGrowthFloor = 2
@@ -29,7 +30,7 @@ const worldCoordinateLimit = 5_000_000
29
30
  const transformCoordinateLimit = 20_000_000
30
31
  const hoverHitTestIntervalMs = 64
31
32
  const zoomRecoveryGuardMs = 4200
32
- const hierarchyGroupEdgeLimit = 900
33
+ const hierarchyAbsoluteEdgeSafetyCap = 24_000
33
34
  const dragNeighborhoodMaxAffected = 180
34
35
  const dragSettleRounds = 3
35
36
  const wheelZoomExponent = 0.0009
@@ -1229,6 +1230,15 @@ const smoothProgress = value => {
1229
1230
  return bounded * bounded * (3 - 2 * bounded)
1230
1231
  }
1231
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
+
1232
1242
  const groupRenderRadius = group => {
1233
1243
  const childCount = Math.max(group.nodeIds.length, group.childGroupIds.length, 1)
1234
1244
  return 10 + Math.min(Math.log2(childCount + 1) * 4.2, 22)
@@ -1451,7 +1461,7 @@ const selectViewportItemsWithFill = (items, viewport, limit = renderNodeBudget)
1451
1461
  return visible.concat(fill)
1452
1462
  }
1453
1463
 
1454
- const updateHierarchyFocusGroup = (groups, viewport) => {
1464
+ const updateHierarchyFocusGroup = (groups, _viewport) => {
1455
1465
  const activeGroupIds = new Set(groups.map(group => group.id))
1456
1466
  state.hierarchyFocusStack = state.hierarchyFocusStack.filter(groupId => state.groupById.has(groupId))
1457
1467
  const parentGroup = activeHierarchyParentGroup()
@@ -1466,7 +1476,6 @@ const updateHierarchyFocusGroup = (groups, viewport) => {
1466
1476
  const current = state.hierarchyFocusGroupId
1467
1477
  ? groups.find(group => group.id === state.hierarchyFocusGroupId) ?? null
1468
1478
  : null
1469
- const currentCoverage = current ? groupViewportCoverage(current, viewport) : 0
1470
1479
  const hasActiveFocusedTransition =
1471
1480
  Boolean(current) &&
1472
1481
  state.zoomTransition.active &&
@@ -1477,36 +1486,21 @@ const updateHierarchyFocusGroup = (groups, viewport) => {
1477
1486
  if (hasActiveFocusedTransition) {
1478
1487
  return current
1479
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
1480
1493
 
1481
- if (
1482
- current &&
1483
- state.transform.scale >= hierarchyMicroExitScale &&
1484
- currentCoverage >= hierarchyMicroExitCoverage
1485
- ) {
1486
- if (current.childGroupIds.length > 0 && state.hierarchyFocusStack.at(-1) !== current.id) {
1487
- state.hierarchyFocusStack = [...state.hierarchyFocusStack, current.id]
1488
- }
1489
- return current
1490
- }
1491
-
1492
- const candidate = groups
1493
- .map(group => groupWithCoverage(group, viewport))
1494
- .sort((left, right) => right.coverage - left.coverage)[0]
1495
-
1496
- if (
1497
- candidate &&
1498
- state.transform.scale >= hierarchyFocusAcquireScale &&
1499
- candidate.coverage >= hierarchyFocusAcquireCoverage
1500
- ) {
1501
- state.hierarchyFocusGroupId = candidate.group.id
1502
- if (candidate.group.childGroupIds.length > 0 && state.hierarchyFocusStack.at(-1) !== candidate.group.id) {
1503
- state.hierarchyFocusStack = [...state.hierarchyFocusStack, candidate.group.id]
1504
- }
1505
- return candidate.group
1494
+ if (selectedGroup && state.transform.scale >= hierarchyMicroExitScale) {
1495
+ state.hierarchyFocusGroupId = selectedGroup.id
1496
+ return selectedGroup
1506
1497
  }
1507
1498
 
1508
1499
  if (state.hierarchyFocusStack.length > 0 && state.transform.scale < hierarchyMicroExitScale) {
1509
1500
  state.hierarchyFocusStack = state.hierarchyFocusStack.slice(0, -1)
1501
+ if (state.selected?.isGroupNode) {
1502
+ state.selected = null
1503
+ }
1510
1504
  }
1511
1505
  state.hierarchyFocusGroupId = null
1512
1506
  return null
@@ -1532,11 +1526,7 @@ const updateHierarchyChildRevealBudget = (focusGroupId, targetLimit) => {
1532
1526
  return state.hierarchyRevealBudget
1533
1527
  }
1534
1528
 
1535
- const hierarchyViewportProgress = (group, viewport) => {
1536
- const coverage = groupViewportCoverage(group, viewport)
1537
- const coverageProgress = (coverage - hierarchyFocusAcquireCoverage) / (hierarchyMicroEnterCoverage - hierarchyFocusAcquireCoverage)
1538
- const scaleProgress = (state.transform.scale - hierarchyFocusAcquireScale) / (hierarchyMicroEnterScale - hierarchyFocusAcquireScale)
1539
- const viewportProgress = smoothProgress(Math.min(coverageProgress, scaleProgress))
1529
+ const hierarchyViewportProgress = (group, _viewport) => {
1540
1530
  const hasActiveFocusedTransition =
1541
1531
  state.zoomTransition.active &&
1542
1532
  state.zoomTransition.source === 'group' &&
@@ -1544,16 +1534,15 @@ const hierarchyViewportProgress = (group, viewport) => {
1544
1534
  state.selected.groupId === group.id
1545
1535
 
1546
1536
  if (!hasActiveFocusedTransition) {
1547
- return viewportProgress
1537
+ return 1
1548
1538
  }
1549
1539
 
1550
1540
  const targetSpan = Math.max(state.zoomTransition.targetScale - hierarchyFocusAcquireScale, 0.0001)
1551
1541
  const transitionScaleProgress = (state.transform.scale - hierarchyFocusAcquireScale) / targetSpan
1552
- const transitionProgress = Math.pow(Math.max(0, Math.min(1, transitionScaleProgress)), 2.8)
1553
- return Math.max(viewportProgress, transitionProgress)
1542
+ return Math.pow(Math.max(0, Math.min(1, transitionScaleProgress)), 2.8)
1554
1543
  }
1555
1544
 
1556
- const groupEdgesForRenderedGroups = (groupNodes) => {
1545
+ const groupEdgesForRenderedGroups = (groupNodes, options = { preferComplete: false }) => {
1557
1546
  if (groupNodes.length <= 1) {
1558
1547
  return []
1559
1548
  }
@@ -1597,9 +1586,12 @@ const groupEdgesForRenderedGroups = (groupNodes) => {
1597
1586
  })
1598
1587
  }
1599
1588
 
1600
- return Array.from(selected.values())
1589
+ const sorted = Array.from(selected.values())
1601
1590
  .sort((left, right) => edgeWeight(right) - edgeWeight(left) || left.source.localeCompare(right.source) || left.target.localeCompare(right.target))
1602
- .slice(0, Math.min(hierarchyGroupEdgeLimit, edgeBudgetForCurrentFrame()))
1591
+ if (options.preferComplete) {
1592
+ return sorted.slice(0, Math.min(sorted.length, hierarchyAbsoluteEdgeSafetyCap))
1593
+ }
1594
+ return sorted.slice(0, Math.min(edgeBudgetForCurrentFrame(), hierarchyAbsoluteEdgeSafetyCap))
1603
1595
  }
1604
1596
 
1605
1597
  const computeHierarchyRenderVisibility = (viewport) => {
@@ -1614,13 +1606,14 @@ const computeHierarchyRenderVisibility = (viewport) => {
1614
1606
  const groups = selectViewportItemsWithFill(hierarchyGroupsForScale(), viewport, renderNodeBudget)
1615
1607
  const focus = updateHierarchyFocusGroup(groups, viewport)
1616
1608
  const progress = focus ? hierarchyViewportProgress(focus, viewport) : 0
1609
+ const revealProgress = childRevealProgressForFocus(progress)
1617
1610
  const groupNodes = groups.map(createGroupRenderNode)
1618
1611
 
1619
- if (!focus || progress <= 0.02) {
1612
+ if (!focus || revealProgress <= 0) {
1620
1613
  state.hierarchyRevealFocusGroupId = null
1621
1614
  state.hierarchyRevealBudget = 1
1622
1615
  state.renderNodes = groupNodes
1623
- state.renderEdges = groupEdgesForRenderedGroups(groupNodes)
1616
+ state.renderEdges = groupEdgesForRenderedGroups(groupNodes, { preferComplete: true })
1624
1617
  return true
1625
1618
  }
1626
1619
 
@@ -1629,23 +1622,22 @@ const computeHierarchyRenderVisibility = (viewport) => {
1629
1622
  const focusChildGroups = focus.childGroupIds
1630
1623
  .map(groupId => state.groupById.get(groupId))
1631
1624
  .filter(Boolean)
1632
- const childRevealProgress = Math.pow(Math.max(0, Math.min(1, progress)), hierarchyChildRevealPower)
1633
- const childTargetLimit = Math.min(renderNodeBudget, Math.max(1, Math.floor(renderNodeBudget * childRevealProgress)))
1625
+ const childTargetLimit = Math.min(renderNodeBudget, Math.max(1, Math.floor(renderNodeBudget * revealProgress)))
1634
1626
  const childLimit = updateHierarchyChildRevealBudget(focus.id, childTargetLimit)
1635
1627
  const focusRenderNode = groupNodes.find(node => node.groupId === focus.id) ?? createGroupRenderNode(focus)
1636
1628
  const arrangedChildren = focusChildGroups.length > 0
1637
1629
  ? arrangeChildGroupNodes(focusChildGroups, focus, focusRenderNode)
1638
1630
  : arrangeChildGraphNodes(rawChildNodes, focus, focusRenderNode)
1639
1631
  const childNodes = selectStableSampleNodes(arrangedChildren, childLimit)
1640
- .map(node => interpolateNodeFromGroup(node, focusRenderNode, progress))
1632
+ .map(node => interpolateNodeFromGroup(node, focusRenderNode, revealProgress))
1641
1633
  const childIds = new Set(childNodes.map(node => node.id))
1642
1634
  const childById = new Map(childNodes.map(node => [node.id, node]))
1643
- const isMicroView = progress >= hierarchyFocusedOnlyProgress
1635
+ const isMicroView = revealProgress >= hierarchyFocusedOnlyProgress
1644
1636
  const visibleGroupNodes = isMicroView
1645
1637
  ? []
1646
- : groupNodes.filter(node => node.groupId !== focus.id || progress < 0.92)
1638
+ : groupNodes.filter(node => node.groupId !== focus.id || revealProgress < 0.96)
1647
1639
  const groupEdges = isMicroView ? [] : groupEdgesForRenderedGroups(visibleGroupNodes)
1648
- const childEdges = (isMicroView || progress > 0.02)
1640
+ const childEdges = (isMicroView || revealProgress > 0)
1649
1641
  ? focusChildGroups.length > 0
1650
1642
  ? groupEdgesForRenderedGroups(childNodes)
1651
1643
  : collectVisibleEdgesForNodes(childIds).map(edge => ({
@@ -2951,6 +2943,66 @@ const linkedNodes = node => {
2951
2943
  return { outgoing, incoming }
2952
2944
  }
2953
2945
 
2946
+ const linkedGroupsForGroupNode = groupNode => {
2947
+ const group = state.groupById.get(groupNode.groupId)
2948
+ if (!group) {
2949
+ return { outgoing: [], incoming: [], internalEdgeCount: 0, externalEdgeCount: 0 }
2950
+ }
2951
+
2952
+ const nodeIds = new Set(group.nodeIds)
2953
+ const externalByGroup = new Map()
2954
+ let internalEdgeCount = 0
2955
+ let externalEdgeCount = 0
2956
+
2957
+ for (let index = 0; index < state.visibleEdges.length; index += 1) {
2958
+ const edge = state.visibleEdges[index]
2959
+ if (!edge.target) continue
2960
+ const sourceInside = nodeIds.has(edge.source)
2961
+ const targetInside = nodeIds.has(edge.target)
2962
+ if (sourceInside && targetInside) {
2963
+ internalEdgeCount += 1
2964
+ continue
2965
+ }
2966
+ if (!sourceInside && !targetInside) continue
2967
+
2968
+ externalEdgeCount += 1
2969
+ const externalNodeId = sourceInside ? edge.target : edge.source
2970
+ const externalGroup = state.nodeLeafGroupById.get(externalNodeId)
2971
+ const externalKey = externalGroup?.id ?? 'unknown'
2972
+ const externalTitle = externalGroup?.title ?? (edge.targetTitle || 'Unknown group')
2973
+ const direction = sourceInside ? 'outgoing' : 'incoming'
2974
+ const current = externalByGroup.get(externalKey)
2975
+
2976
+ if (current) {
2977
+ current.weight += edgeWeight(edge)
2978
+ if (direction === 'outgoing') current.outgoing += 1
2979
+ if (direction === 'incoming') current.incoming += 1
2980
+ continue
2981
+ }
2982
+
2983
+ externalByGroup.set(externalKey, {
2984
+ id: '',
2985
+ title: externalTitle,
2986
+ path: 'Group relation - ' + direction,
2987
+ weight: edgeWeight(edge),
2988
+ priority: edge.priority || 'normal',
2989
+ outgoing: direction === 'outgoing' ? 1 : 0,
2990
+ incoming: direction === 'incoming' ? 1 : 0
2991
+ })
2992
+ }
2993
+
2994
+ const external = Array.from(externalByGroup.values())
2995
+ .sort((left, right) => right.weight - left.weight || left.title.localeCompare(right.title))
2996
+ const outgoing = external
2997
+ .filter(item => item.outgoing > 0)
2998
+ .slice(0, 18)
2999
+ const incoming = external
3000
+ .filter(item => item.incoming > 0)
3001
+ .slice(0, 18)
3002
+
3003
+ return { outgoing, incoming, internalEdgeCount, externalEdgeCount }
3004
+ }
3005
+
2954
3006
  const fetchNodeDetails = async node => {
2955
3007
  const cached = state.nodeDetails.get(node.id)
2956
3008
  if (cached) {
@@ -2973,8 +3025,43 @@ const fetchNodeDetails = async node => {
2973
3025
 
2974
3026
  const wait = async (milliseconds) => new Promise(resolve => setTimeout(resolve, milliseconds))
2975
3027
 
3028
+ const openGroupDialog = groupNode => {
3029
+ const group = state.groupById.get(groupNode.groupId)
3030
+ if (!group) return
3031
+
3032
+ const groupLinks = linkedGroupsForGroupNode(groupNode)
3033
+ const title = group.title || 'Group'
3034
+ const groupType = group.childGroupIds.length > 0 ? 'Hierarchical group' : 'Leaf group'
3035
+ const nodeCount = group.nodeIds.length
3036
+ const childGroupCount = group.childGroupIds.length
3037
+ const radius = Math.round(childGraphRenderRadius(group))
3038
+
3039
+ elements.contentTitle.textContent = title
3040
+ elements.contentPath.textContent = groupType + ' - level ' + group.level
3041
+ elements.contentTags.innerHTML = [
3042
+ '<span>segment: ' + escapeHtml(group.segment || 'root') + '</span>',
3043
+ '<span>cluster: ' + escapeHtml(group.group || 'root') + '</span>'
3044
+ ].join('')
3045
+ elements.contentOutgoing.innerHTML = list(groupLinks.outgoing)
3046
+ elements.contentIncoming.innerHTML = list(groupLinks.incoming)
3047
+ elements.contentBody.textContent = [
3048
+ 'Nodes: ' + nodeCount,
3049
+ 'Child groups: ' + childGroupCount,
3050
+ 'Internal links: ' + groupLinks.internalEdgeCount,
3051
+ 'External links: ' + groupLinks.externalEdgeCount,
3052
+ 'Render radius: ' + radius
3053
+ ].join('\\n')
3054
+ if (!elements.contentDialog.open) {
3055
+ elements.contentDialog.show()
3056
+ }
3057
+ }
3058
+
2976
3059
  const openContentDialog = async node => {
2977
- if (!node || node.isGroupNode) return
3060
+ if (!node) return
3061
+ if (node.isGroupNode) {
3062
+ openGroupDialog(node)
3063
+ return
3064
+ }
2978
3065
  elements.contentTitle.textContent = node.title || 'Loading...'
2979
3066
  elements.contentPath.textContent = node.path || 'Loading...'
2980
3067
  elements.contentTags.innerHTML = Array.isArray(node.tags) && node.tags.length
@@ -2985,7 +3072,7 @@ const openContentDialog = async node => {
2985
3072
  elements.contentIncoming.innerHTML = list(initialLinks.incoming)
2986
3073
  elements.contentBody.textContent = 'Loading note content...'
2987
3074
  if (!elements.contentDialog.open) {
2988
- elements.contentDialog.showModal()
3075
+ elements.contentDialog.show()
2989
3076
  }
2990
3077
 
2991
3078
  const applyDetailToDialog = detail => {
@@ -3017,7 +3104,8 @@ const openContentDialog = async node => {
3017
3104
  }
3018
3105
  }
3019
3106
 
3020
- const selectNode = (node, options = { openContent: false }) => {
3107
+ const expandGroupNode = node => {
3108
+ if (!node?.isGroupNode) return
3021
3109
  if (node?.isGroupNode) {
3022
3110
  state.selected = node
3023
3111
  const group = state.groupById.get(node.groupId)
@@ -3038,6 +3126,38 @@ const selectNode = (node, options = { openContent: false }) => {
3038
3126
  markRenderDirty()
3039
3127
  return
3040
3128
  }
3129
+ }
3130
+
3131
+ const handleGroupNodePrimaryClick = node => {
3132
+ if (!node?.isGroupNode) return
3133
+ const alreadyFocused = state.selected?.isGroupNode && state.selected.groupId === node.groupId
3134
+ if (alreadyFocused && !state.zoomTransition.active) {
3135
+ selectNode(node, { openContent: true })
3136
+ return
3137
+ }
3138
+ expandGroupNode(node)
3139
+ }
3140
+
3141
+ const selectNode = (node, options = { openContent: false }) => {
3142
+ if (!node) {
3143
+ state.selected = null
3144
+ if (elements.contentDialog.open) {
3145
+ elements.contentDialog.close()
3146
+ }
3147
+ markRenderDirty()
3148
+ return
3149
+ }
3150
+
3151
+ if (node?.isGroupNode) {
3152
+ state.selected = node
3153
+ if (options.openContent) {
3154
+ openContentDialog(node).catch(() => {
3155
+ elements.contentBody.textContent = 'Unable to load group details.'
3156
+ })
3157
+ }
3158
+ markRenderDirty()
3159
+ return
3160
+ }
3041
3161
  state.selected = node
3042
3162
  if (node && options.openContent) {
3043
3163
  openContentDialog(node).catch(() => {
@@ -3118,10 +3238,6 @@ const wheelZoomFactor = event => {
3118
3238
  }
3119
3239
 
3120
3240
  const handleWheelZoom = event => {
3121
- if (elements.contentDialog?.open) {
3122
- return
3123
- }
3124
-
3125
3241
  event.preventDefault()
3126
3242
  const rect = canvas.getBoundingClientRect()
3127
3243
  const rawCursorX = Number.isFinite(event.offsetX) ? event.offsetX : event.clientX - rect.left
@@ -3190,7 +3306,9 @@ const bindEvents = () => {
3190
3306
  const point = worldPoint(event)
3191
3307
  const node = hitNode(point)
3192
3308
  if (node) {
3193
- selectNode(node, { openContent: true })
3309
+ if (!node.isGroupNode) {
3310
+ selectNode(node, { openContent: true })
3311
+ }
3194
3312
  return
3195
3313
  }
3196
3314
 
@@ -3254,8 +3372,14 @@ const bindEvents = () => {
3254
3372
  settleNeighborhoodAroundNode(draggedNode)
3255
3373
  markRenderDirty()
3256
3374
  }
3257
- if (draggedNode && !state.pointer.moved) selectNode(draggedNode, { openContent: false })
3258
- if (!draggedNode && !state.pointer.moved) selectNode(state.hovered, { openContent: false })
3375
+ if (!state.pointer.moved) {
3376
+ const clickedNode = draggedNode ?? state.hovered
3377
+ if (clickedNode?.isGroupNode) {
3378
+ handleGroupNodePrimaryClick(clickedNode)
3379
+ } else {
3380
+ selectNode(clickedNode, { openContent: true })
3381
+ }
3382
+ }
3259
3383
  state.pointer = { x: 0, y: 0, down: false, dragNode: null, moved: false }
3260
3384
  canvas.releasePointerCapture(event.pointerId)
3261
3385
  })
@@ -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.134",
3
+ "version": "0.1.0-beta.136",
4
4
  "description": "Local-first knowledge memory for agents with Markdown, backlinks, indexing and context retrieval.",
5
5
  "type": "module",
6
6
  "license": "MIT",