@andespindola/brainlink 0.1.0-beta.137 → 0.1.0-beta.139

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
@@ -85,11 +85,10 @@ Legacy `.jsonl.gz` packs are upgraded to `.blpk` automatically on first search/c
85
85
  - Graph renderer optimized for large datasets with viewport-driven node culling and edge lookup by visible nodes.
86
86
  - Canvas graph rendering uses the same batched node and edge pipeline for every graph size, reducing per-frame draw calls while keeping selected and hovered items highlighted.
87
87
  - WebGL acceleration is used when available for dense node and edge drawing, with Canvas 2D preserved as the interaction and fallback layer.
88
- - Graph rendering keeps the flat node scene and adds stable hierarchical mesh groups for vaults above 1000 notes, with every visible graph level filled toward 1000 nodes, each group capped at 1000 child nodes, and recursive parent groups when a level itself exceeds 1000 groups.
89
88
  - Large graph layout API automatically uses compact payload encoding with link-coverage-aware edge selection to reduce initial client load without hiding major relationships.
90
89
  - Large-segment layout spacing now grows logarithmically to keep initial visual density consistent between medium and very large vaults (for example, ~1k vs ~50k notes).
91
90
  - Graph coordinates are visually compacted across graph sizes so reset starts from a stable fitted scene and zoom-in progressively reveals local detail.
92
- - Zoomed-out graph LOD renders hierarchy groups as sparse relationship graph nodes and expands a group from the focused node's current viewport position only after it is framed, progressively hiding sibling groups in micro view.
91
+ - Zoomed-out graph LOD keeps a single flat graph scene, with viewport-driven node sampling and edge coverage safeguards to preserve visible relationships without switching to nested subgraphs.
93
92
  - Graph reset fits the full graph scene instead of starting in a separate macro overview mode.
94
93
  - Graph filtering runs in a dedicated browser worker to keep the UI thread responsive during heavy datasets.
95
94
  - Edge rendering budgets adapt to zoom level to prevent frame spikes on large graph panoramas.
@@ -598,16 +597,16 @@ The graph UI shows:
598
597
  - realtime refresh while `--watch` is enabled
599
598
  - graph controls for zoom in, zoom out, fit visible nodes and reset-to-fit-all
600
599
  - wheel zoom (including `cmd+scroll` and `ctrl+scroll`) anchored to cursor position for faster navigation in large graphs
601
- - continuous target-scale interpolation for wheel/button zoom to avoid abrupt jumps while keeping cursor-anchored focus
600
+ - wheel/button zoom updates immediately at the cursor anchor without delayed focus-transition interpolation
602
601
  - 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
603
602
  - zoom-out floor for large and massive graphs to keep the scene reachable without switching into a separate macro graph mode
604
603
  - keyboard shortcuts: `+` zoom in, `-` zoom out, `0` reset fit
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
604
+ - click on a node opens its details panel; double-click on empty canvas zooms in at cursor position
606
605
  - floating graph totals (notes, links, tags) below the Brainlink title
607
606
  - graph rendering safeguards (batched canvas drawing across graph sizes, edge draw caps, lower redraw rate, zoom-aware interaction)
608
607
  - adaptive CPU safeguards for large graphs: idle frame pacing, throttled background physics updates and cached viewport dimensions to reduce redraw/layout overhead while preserving interaction responsiveness
609
608
  - WebGL node and edge acceleration when supported, falling back to Canvas 2D without changing graph behavior
610
- - large graph LOD keeps a recursive graph-of-graphs model: zoom-out fills the projected macro level toward 1000 lightweight group nodes with every aggregated link between visible groups, zoom-in grows the focused node into a local child graph anchored at the same point, once the child graph is framed the sibling groups stop rendering, groups with child groups open another graph level instead of jumping to leaf notes, and zoom-out restores sibling groups
609
+ - large graph LOD keeps a single-level graph model across zoom levels, preserving visual continuity while adjusting node/edge density from the active viewport
611
610
 
612
611
  The server indexes before starting by default. Use `--no-index` to skip that step:
613
612
 
@@ -749,13 +749,7 @@ const selectStableSampleNodes = (sourceNodes, limit) => {
749
749
  return sourceNodes
750
750
  }
751
751
 
752
- const now = performance.now()
753
- const cursorPoint = cursorWorldPoint()
754
- const recentZoomFocus =
755
- now - state.lastZoomFocus.at <= 1500
756
- ? { x: state.lastZoomFocus.x, y: state.lastZoomFocus.y }
757
- : null
758
- const anchor = cursorPoint ?? recentZoomFocus ?? viewportCenterWorldPoint()
752
+ const anchor = viewportCenterWorldPoint()
759
753
  const previousIds = new Set(state.renderNodes.map((node) => node.id))
760
754
  const preferAnchorDistance = state.visibleNodes.length > massiveGraphNodeThreshold && state.transform.scale >= 0.28
761
755
 
@@ -815,14 +809,17 @@ const edgeRelevanceScore = edge => {
815
809
  return score
816
810
  }
817
811
 
818
- const collectVisibleEdgesForNodes = nodeIds => {
812
+ const collectVisibleEdgesForNodes = (nodeIds, options = { preferComplete: false }) => {
819
813
  if (nodeIds.size === 0) {
820
814
  return []
821
815
  }
822
816
 
823
817
  const seen = new Set()
824
818
  const candidates = []
825
- const limit = edgeBudgetForCurrentFrame()
819
+ const bestEdgeByNode = new Map()
820
+ const limit = options.preferComplete
821
+ ? hierarchyAbsoluteEdgeSafetyCap
822
+ : edgeBudgetForCurrentFrame()
826
823
 
827
824
  nodeIds.forEach(nodeId => {
828
825
  const candidateEdges = state.visibleEdgeByNode.get(nodeId) ?? []
@@ -836,6 +833,15 @@ const collectVisibleEdgesForNodes = nodeIds => {
836
833
 
837
834
  seen.add(key)
838
835
  candidates.push(edge)
836
+ const score = edgeRelevanceScore(edge)
837
+ const currentSource = bestEdgeByNode.get(edge.source)
838
+ if (!currentSource || score > currentSource.score) {
839
+ bestEdgeByNode.set(edge.source, { edge, score })
840
+ }
841
+ const currentTarget = bestEdgeByNode.get(edge.target)
842
+ if (!currentTarget || score > currentTarget.score) {
843
+ bestEdgeByNode.set(edge.target, { edge, score })
844
+ }
839
845
  }
840
846
  })
841
847
 
@@ -843,7 +849,7 @@ const collectVisibleEdgesForNodes = nodeIds => {
843
849
  return candidates
844
850
  }
845
851
 
846
- return candidates
852
+ const ranked = candidates
847
853
  .sort((left, right) => {
848
854
  const scoreDelta = edgeRelevanceScore(right) - edgeRelevanceScore(left)
849
855
  if (scoreDelta !== 0) {
@@ -853,7 +859,38 @@ const collectVisibleEdgesForNodes = nodeIds => {
853
859
  const rightKey = edgeIdentityKey(right)
854
860
  return leftKey.localeCompare(rightKey)
855
861
  })
856
- .slice(0, limit)
862
+
863
+ if (options.preferComplete) {
864
+ return ranked.slice(0, limit)
865
+ }
866
+
867
+ const coverageFloor = Math.min(4_200, Math.max(limit, Math.floor(nodeIds.size * 1.6)))
868
+ const resolvedLimit = Math.min(coverageFloor, ranked.length)
869
+ const selected = []
870
+ const selectedKeys = new Set()
871
+
872
+ nodeIds.forEach(nodeId => {
873
+ const edge = bestEdgeByNode.get(nodeId)?.edge
874
+ if (!edge) return
875
+ const key = edgeIdentityKey(edge)
876
+ if (selectedKeys.has(key)) return
877
+ selectedKeys.add(key)
878
+ selected.push(edge)
879
+ })
880
+
881
+ if (selected.length >= resolvedLimit) {
882
+ return selected.slice(0, resolvedLimit)
883
+ }
884
+
885
+ for (let index = 0; index < ranked.length && selected.length < resolvedLimit; index += 1) {
886
+ const edge = ranked[index]
887
+ const key = edgeIdentityKey(edge)
888
+ if (selectedKeys.has(key)) continue
889
+ selectedKeys.add(key)
890
+ selected.push(edge)
891
+ }
892
+
893
+ return selected
857
894
  }
858
895
 
859
896
  const edgeOpacityForScale = (edge, scale) => {
@@ -1119,10 +1156,7 @@ const partitionGraphForAcceleratedRenderer = () => {
1119
1156
  }
1120
1157
 
1121
1158
  const drawGraphLabels = nodes => {
1122
- const isRenderingHierarchyChildGraph = state.groups.length > 0 && state.renderNodes.some(node => !node.isGroupNode)
1123
- const shouldDrawLabels = isRenderingHierarchyChildGraph
1124
- ? false
1125
- : state.nodes.length > largeGraphNodeThreshold
1159
+ const shouldDrawLabels = state.nodes.length > largeGraphNodeThreshold
1126
1160
  ? state.transform.scale >= 1.25 && state.renderNodes.length <= 420
1127
1161
  : state.transform.scale >= 0.62 && state.renderNodes.length <= 1200
1128
1162
 
@@ -1358,10 +1392,18 @@ const projectNodesIntoChildGraph = (nodes, focusRenderNode, group) => {
1358
1392
 
1359
1393
  const centerX = (minX + maxX) / 2
1360
1394
  const centerY = (minY + maxY) / 2
1361
- const spanX = Math.max(1, maxX - minX)
1362
- const spanY = Math.max(1, maxY - minY)
1363
- const targetDiameter = Math.max(1, childGraphRenderRadius(group) * 2 * 0.9)
1364
- const scale = targetDiameter / Math.max(spanX, spanY)
1395
+ const maxDistanceFromCenter = nodes.reduce((maxDistance, node) => {
1396
+ const distance = Math.hypot(node.x - centerX, node.y - centerY)
1397
+ return Math.max(maxDistance, distance)
1398
+ }, 1)
1399
+ const subgraphFillByNodeCount = (nodeCount) => {
1400
+ if (nodeCount <= 24) return 0.62
1401
+ if (nodeCount <= 80) return 0.7
1402
+ if (nodeCount <= 200) return 0.78
1403
+ return 0.84
1404
+ }
1405
+ const targetRadius = Math.max(1, childGraphRenderRadius(group) * subgraphFillByNodeCount(nodes.length))
1406
+ const scale = targetRadius / Math.max(maxDistanceFromCenter, 1)
1365
1407
 
1366
1408
  return nodes.map(node => ({
1367
1409
  ...node,
@@ -1715,7 +1757,7 @@ const computeHierarchyRenderVisibility = (viewport) => {
1715
1757
  const childEdges = (isMicroView || revealProgress > 0)
1716
1758
  ? focusChildGroups.length > 0
1717
1759
  ? groupEdgesForRenderedGroups(childNodes)
1718
- : collectVisibleEdgesForNodes(childIds).map(edge => ({
1760
+ : collectVisibleEdgesForNodes(childIds, { preferComplete: true }).map(edge => ({
1719
1761
  ...edge,
1720
1762
  sourceNode: childById.get(edge.source) ?? edge.sourceNode,
1721
1763
  targetNode: childById.get(edge.target) ?? edge.targetNode
@@ -2138,10 +2180,7 @@ const fitView = (options = { useFiltered: true, preferHubCenter: true }) => {
2138
2180
  const width = Math.max(rect.width, 320)
2139
2181
  const height = Math.max(rect.height, 320)
2140
2182
  const nodes = options.useFiltered ? filteredNodes() : state.nodes
2141
- const hierarchyFitNodes = state.groups.length > 0 && nodes.length > 1000
2142
- ? arrangeGraphLevelNodes(parentHierarchyGroups(), groupRenderRadius).map(createGroupRenderNode)
2143
- : null
2144
- const fitNodes = hierarchyFitNodes ?? nodes
2183
+ const fitNodes = nodes
2145
2184
  const bounds = graphBounds(fitNodes)
2146
2185
 
2147
2186
  if (!bounds) {
@@ -2169,14 +2208,10 @@ const fitView = (options = { useFiltered: true, preferHubCenter: true }) => {
2169
2208
  const biasedScale = clampScale(fitScale * fitScaleBiasByNodeCount(fitNodes.length))
2170
2209
  const scaleRange = autoFitScaleRangeByNodeCount(fitNodes.length)
2171
2210
  const baselineScale = clampScale(Math.min(scaleRange.max, Math.max(scaleRange.min, biasedScale)))
2172
- const resolvedScale = nodes.length > massiveGraphNodeThreshold && !hierarchyFitNodes
2211
+ const resolvedScale = nodes.length > massiveGraphNodeThreshold
2173
2212
  ? clampScale(Math.min(baselineScale, massiveAutoFitMacroScale))
2174
2213
  : baselineScale
2175
- const hubCenter =
2176
- hierarchyFitNodes
2177
- ? null
2178
- :
2179
- options.preferHubCenter && isDominantHub(state.primaryHub, nodes.length) && nodes.some((node) => node.id === state.primaryHub.id)
2214
+ const hubCenter = options.preferHubCenter && isDominantHub(state.primaryHub, nodes.length) && nodes.some((node) => node.id === state.primaryHub.id)
2180
2215
  ? state.primaryHub
2181
2216
  : null
2182
2217
  const centerX = hubCenter ? hubCenter.x : (bounds.minX + bounds.maxX) / 2
@@ -2740,14 +2775,10 @@ const computeRenderVisibility = () => {
2740
2775
  state.lastViewportKey = viewportKey
2741
2776
  state.renderVisibilityDirty = false
2742
2777
 
2743
- if (computeHierarchyRenderVisibility(viewport)) {
2744
- return
2745
- }
2746
-
2747
2778
  if (state.visibleNodes.length <= 2000) {
2748
2779
  state.renderNodes = state.visibleNodes
2749
2780
  const ids = new Set(state.renderNodes.map((node) => node.id))
2750
- state.renderEdges = limitRenderEdges(state.renderNodes, collectVisibleEdgesForNodes(ids))
2781
+ state.renderEdges = limitRenderEdges(state.renderNodes, collectVisibleEdgesForNodes(ids, { preferComplete: true }))
2751
2782
  return
2752
2783
  }
2753
2784
 
@@ -2772,12 +2803,17 @@ const computeRenderVisibility = () => {
2772
2803
  : viewportNodes.length > 0
2773
2804
  ? viewportNodes
2774
2805
  : sampleMassiveOverviewNodes(sampleLimit)
2775
- const carryMargin = Math.max(240, Math.min(1200, 340 / Math.max(state.transform.scale, 0.0001)))
2806
+ const shouldCarryOverNodes = state.transform.scale < 0.18
2807
+ const carryMargin = Math.max(180, Math.min(640, 260 / Math.max(state.transform.scale, 0.0001)))
2776
2808
  const carryViewport = expandViewportBounds(viewport, carryMargin)
2777
- const carryOverLimit = Math.max(180, Math.min(sampleLimit, Math.floor(sampleLimit * 0.5)))
2778
- const carryOverNodes = (state.renderNodes ?? [])
2779
- .filter((node) => isNodeInViewport(node, carryViewport))
2780
- .slice(0, carryOverLimit)
2809
+ const carryOverLimit = shouldCarryOverNodes
2810
+ ? Math.max(80, Math.min(sampleLimit, Math.floor(sampleLimit * 0.24)))
2811
+ : 0
2812
+ const carryOverNodes = shouldCarryOverNodes
2813
+ ? (state.renderNodes ?? [])
2814
+ .filter((node) => isNodeInViewport(node, carryViewport))
2815
+ .slice(0, carryOverLimit)
2816
+ : []
2781
2817
  const sourceWithCarry = mergeUniqueNodes(
2782
2818
  sourceNodes,
2783
2819
  carryOverNodes,
@@ -3276,23 +3312,15 @@ const selectNodeById = id => {
3276
3312
 
3277
3313
  const zoomAtPoint = (screenX, screenY, factor, source = 'generic') => {
3278
3314
  state.lastManualZoomAt = performance.now()
3279
- const baseScale = state.zoomTransition.active
3280
- ? state.zoomTransition.targetScale
3281
- : state.transform.scale
3315
+ const baseScale = state.transform.scale
3282
3316
  const boundedFactor = source === 'wheel'
3283
3317
  ? Math.max(wheelZoomInputFloorCap, Math.min(wheelZoomInputCeilCap, factor))
3284
3318
  : factor
3285
3319
  const nextScale = clampScale(baseScale * boundedFactor)
3286
- if (nextScale === baseScale && !state.zoomTransition.active) {
3320
+ if (nextScale === baseScale) {
3287
3321
  return
3288
3322
  }
3289
- const worldPointAtCursor =
3290
- state.zoomTransition.active &&
3291
- state.zoomTransition.source === source &&
3292
- state.zoomTransition.screenX === screenX &&
3293
- state.zoomTransition.screenY === screenY
3294
- ? { x: state.zoomTransition.worldX, y: state.zoomTransition.worldY }
3295
- : resolveZoomAnchorWorldPoint(screenX, screenY)
3323
+ const worldPointAtCursor = resolveZoomAnchorWorldPoint(screenX, screenY)
3296
3324
  const worldX = worldPointAtCursor.x
3297
3325
  const worldY = worldPointAtCursor.y
3298
3326
  state.lastZoomFocus = {
@@ -3300,15 +3328,10 @@ const zoomAtPoint = (screenX, screenY, factor, source = 'generic') => {
3300
3328
  y: worldY,
3301
3329
  at: performance.now()
3302
3330
  }
3303
- state.zoomTransition = {
3304
- active: true,
3305
- source,
3306
- screenX,
3307
- screenY,
3308
- worldX,
3309
- worldY,
3310
- targetScale: nextScale
3311
- }
3331
+ state.transform.scale = nextScale
3332
+ state.transform.x = clampTransformCoordinate(screenX - worldX * nextScale)
3333
+ state.transform.y = clampTransformCoordinate(screenY - worldY * nextScale)
3334
+ clearZoomTransition()
3312
3335
  state.offscreenFrameCount = 0
3313
3336
  markRenderDirty()
3314
3337
  }
@@ -3388,7 +3411,7 @@ const bindEvents = () => {
3388
3411
  if (elements.fit) {
3389
3412
  elements.fit.addEventListener('click', () => {
3390
3413
  resetHierarchyFocus()
3391
- focusPrimaryHub()
3414
+ fitView({ useFiltered: true, preferHubCenter: false })
3392
3415
  })
3393
3416
  }
3394
3417
  elements.reset.addEventListener('click', () => {
@@ -3477,13 +3500,7 @@ const bindEvents = () => {
3477
3500
  }
3478
3501
  if (!state.pointer.moved) {
3479
3502
  const clickedNode = draggedNode ?? state.hovered
3480
- if (clickedNode?.isGroupNode) {
3481
- handleGroupNodePrimaryClick(clickedNode)
3482
- } else if (clickedNode && state.hierarchyFocusGroupId) {
3483
- handleLeafNodePrimaryClick(clickedNode)
3484
- } else {
3485
- selectNode(clickedNode, { openContent: true })
3486
- }
3503
+ selectNode(clickedNode, { openContent: true })
3487
3504
  }
3488
3505
  state.pointer = { x: 0, y: 0, down: false, dragNode: null, moved: false }
3489
3506
  canvas.releasePointerCapture(event.pointerId)
@@ -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 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.
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 and applied immediately without delayed focus interpolation. Keyboard shortcuts are `+` (zoom in), `-` (zoom out) and `0` (reset fit). Double-click on empty canvas zooms in at cursor position. Clicking a node 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 keep the same single graph scene and use viewport-driven LOD sampling with edge-coverage safeguards to preserve visible relationships without switching into nested subgraph views.
611
611
  During graph filtering, Brainlink keeps hub context nodes visible (`Memory Hub`/`MOC`/high-degree fallback) so filtered views still show relationship anchors.
612
612
 
613
613
  The command reindexes by default, then serves:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andespindola/brainlink",
3
- "version": "0.1.0-beta.137",
3
+ "version": "0.1.0-beta.139",
4
4
  "description": "Local-first knowledge memory for agents with Markdown, backlinks, indexing and context retrieval.",
5
5
  "type": "module",
6
6
  "license": "MIT",