@andespindola/brainlink 0.1.0-beta.134 → 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,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
@@ -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,13 +1534,12 @@ 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
1545
  const groupEdgesForRenderedGroups = (groupNodes) => {
@@ -1614,9 +1603,10 @@ const computeHierarchyRenderVisibility = (viewport) => {
1614
1603
  const groups = selectViewportItemsWithFill(hierarchyGroupsForScale(), viewport, renderNodeBudget)
1615
1604
  const focus = updateHierarchyFocusGroup(groups, viewport)
1616
1605
  const progress = focus ? hierarchyViewportProgress(focus, viewport) : 0
1606
+ const revealProgress = childRevealProgressForFocus(progress)
1617
1607
  const groupNodes = groups.map(createGroupRenderNode)
1618
1608
 
1619
- if (!focus || progress <= 0.02) {
1609
+ if (!focus || revealProgress <= 0) {
1620
1610
  state.hierarchyRevealFocusGroupId = null
1621
1611
  state.hierarchyRevealBudget = 1
1622
1612
  state.renderNodes = groupNodes
@@ -1629,23 +1619,22 @@ const computeHierarchyRenderVisibility = (viewport) => {
1629
1619
  const focusChildGroups = focus.childGroupIds
1630
1620
  .map(groupId => state.groupById.get(groupId))
1631
1621
  .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)))
1622
+ const childTargetLimit = Math.min(renderNodeBudget, Math.max(1, Math.floor(renderNodeBudget * revealProgress)))
1634
1623
  const childLimit = updateHierarchyChildRevealBudget(focus.id, childTargetLimit)
1635
1624
  const focusRenderNode = groupNodes.find(node => node.groupId === focus.id) ?? createGroupRenderNode(focus)
1636
1625
  const arrangedChildren = focusChildGroups.length > 0
1637
1626
  ? arrangeChildGroupNodes(focusChildGroups, focus, focusRenderNode)
1638
1627
  : arrangeChildGraphNodes(rawChildNodes, focus, focusRenderNode)
1639
1628
  const childNodes = selectStableSampleNodes(arrangedChildren, childLimit)
1640
- .map(node => interpolateNodeFromGroup(node, focusRenderNode, progress))
1629
+ .map(node => interpolateNodeFromGroup(node, focusRenderNode, revealProgress))
1641
1630
  const childIds = new Set(childNodes.map(node => node.id))
1642
1631
  const childById = new Map(childNodes.map(node => [node.id, node]))
1643
- const isMicroView = progress >= hierarchyFocusedOnlyProgress
1632
+ const isMicroView = revealProgress >= hierarchyFocusedOnlyProgress
1644
1633
  const visibleGroupNodes = isMicroView
1645
1634
  ? []
1646
- : groupNodes.filter(node => node.groupId !== focus.id || progress < 0.92)
1635
+ : groupNodes.filter(node => node.groupId !== focus.id || revealProgress < 0.96)
1647
1636
  const groupEdges = isMicroView ? [] : groupEdgesForRenderedGroups(visibleGroupNodes)
1648
- const childEdges = (isMicroView || progress > 0.02)
1637
+ const childEdges = (isMicroView || revealProgress > 0)
1649
1638
  ? focusChildGroups.length > 0
1650
1639
  ? groupEdgesForRenderedGroups(childNodes)
1651
1640
  : collectVisibleEdgesForNodes(childIds).map(edge => ({
@@ -2951,6 +2940,66 @@ const linkedNodes = node => {
2951
2940
  return { outgoing, incoming }
2952
2941
  }
2953
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
+
2954
3003
  const fetchNodeDetails = async node => {
2955
3004
  const cached = state.nodeDetails.get(node.id)
2956
3005
  if (cached) {
@@ -2973,8 +3022,43 @@ const fetchNodeDetails = async node => {
2973
3022
 
2974
3023
  const wait = async (milliseconds) => new Promise(resolve => setTimeout(resolve, milliseconds))
2975
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
+
2976
3056
  const openContentDialog = async node => {
2977
- if (!node || node.isGroupNode) return
3057
+ if (!node) return
3058
+ if (node.isGroupNode) {
3059
+ openGroupDialog(node)
3060
+ return
3061
+ }
2978
3062
  elements.contentTitle.textContent = node.title || 'Loading...'
2979
3063
  elements.contentPath.textContent = node.path || 'Loading...'
2980
3064
  elements.contentTags.innerHTML = Array.isArray(node.tags) && node.tags.length
@@ -2985,7 +3069,7 @@ const openContentDialog = async node => {
2985
3069
  elements.contentIncoming.innerHTML = list(initialLinks.incoming)
2986
3070
  elements.contentBody.textContent = 'Loading note content...'
2987
3071
  if (!elements.contentDialog.open) {
2988
- elements.contentDialog.showModal()
3072
+ elements.contentDialog.show()
2989
3073
  }
2990
3074
 
2991
3075
  const applyDetailToDialog = detail => {
@@ -3017,7 +3101,8 @@ const openContentDialog = async node => {
3017
3101
  }
3018
3102
  }
3019
3103
 
3020
- const selectNode = (node, options = { openContent: false }) => {
3104
+ const expandGroupNode = node => {
3105
+ if (!node?.isGroupNode) return
3021
3106
  if (node?.isGroupNode) {
3022
3107
  state.selected = node
3023
3108
  const group = state.groupById.get(node.groupId)
@@ -3038,6 +3123,38 @@ const selectNode = (node, options = { openContent: false }) => {
3038
3123
  markRenderDirty()
3039
3124
  return
3040
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
+ }
3041
3158
  state.selected = node
3042
3159
  if (node && options.openContent) {
3043
3160
  openContentDialog(node).catch(() => {
@@ -3118,10 +3235,6 @@ const wheelZoomFactor = event => {
3118
3235
  }
3119
3236
 
3120
3237
  const handleWheelZoom = event => {
3121
- if (elements.contentDialog?.open) {
3122
- return
3123
- }
3124
-
3125
3238
  event.preventDefault()
3126
3239
  const rect = canvas.getBoundingClientRect()
3127
3240
  const rawCursorX = Number.isFinite(event.offsetX) ? event.offsetX : event.clientX - rect.left
@@ -3190,7 +3303,9 @@ const bindEvents = () => {
3190
3303
  const point = worldPoint(event)
3191
3304
  const node = hitNode(point)
3192
3305
  if (node) {
3193
- selectNode(node, { openContent: true })
3306
+ if (!node.isGroupNode) {
3307
+ selectNode(node, { openContent: true })
3308
+ }
3194
3309
  return
3195
3310
  }
3196
3311
 
@@ -3254,8 +3369,14 @@ const bindEvents = () => {
3254
3369
  settleNeighborhoodAroundNode(draggedNode)
3255
3370
  markRenderDirty()
3256
3371
  }
3257
- if (draggedNode && !state.pointer.moved) selectNode(draggedNode, { openContent: false })
3258
- 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
+ }
3259
3380
  state.pointer = { x: 0, y: 0, down: false, dragNode: null, moved: false }
3260
3381
  canvas.releasePointerCapture(event.pointerId)
3261
3382
  })
@@ -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.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",