@andespindola/brainlink 0.1.0-beta.138 → 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
 
@@ -822,6 +816,7 @@ const collectVisibleEdgesForNodes = (nodeIds, options = { preferComplete: false
822
816
 
823
817
  const seen = new Set()
824
818
  const candidates = []
819
+ const bestEdgeByNode = new Map()
825
820
  const limit = options.preferComplete
826
821
  ? hierarchyAbsoluteEdgeSafetyCap
827
822
  : edgeBudgetForCurrentFrame()
@@ -838,6 +833,15 @@ const collectVisibleEdgesForNodes = (nodeIds, options = { preferComplete: false
838
833
 
839
834
  seen.add(key)
840
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
+ }
841
845
  }
842
846
  })
843
847
 
@@ -845,7 +849,7 @@ const collectVisibleEdgesForNodes = (nodeIds, options = { preferComplete: false
845
849
  return candidates
846
850
  }
847
851
 
848
- return candidates
852
+ const ranked = candidates
849
853
  .sort((left, right) => {
850
854
  const scoreDelta = edgeRelevanceScore(right) - edgeRelevanceScore(left)
851
855
  if (scoreDelta !== 0) {
@@ -855,7 +859,38 @@ const collectVisibleEdgesForNodes = (nodeIds, options = { preferComplete: false
855
859
  const rightKey = edgeIdentityKey(right)
856
860
  return leftKey.localeCompare(rightKey)
857
861
  })
858
- .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
859
894
  }
860
895
 
861
896
  const edgeOpacityForScale = (edge, scale) => {
@@ -1121,10 +1156,7 @@ const partitionGraphForAcceleratedRenderer = () => {
1121
1156
  }
1122
1157
 
1123
1158
  const drawGraphLabels = nodes => {
1124
- const isRenderingHierarchyChildGraph = state.groups.length > 0 && state.renderNodes.some(node => !node.isGroupNode)
1125
- const shouldDrawLabels = isRenderingHierarchyChildGraph
1126
- ? false
1127
- : state.nodes.length > largeGraphNodeThreshold
1159
+ const shouldDrawLabels = state.nodes.length > largeGraphNodeThreshold
1128
1160
  ? state.transform.scale >= 1.25 && state.renderNodes.length <= 420
1129
1161
  : state.transform.scale >= 0.62 && state.renderNodes.length <= 1200
1130
1162
 
@@ -2148,10 +2180,7 @@ const fitView = (options = { useFiltered: true, preferHubCenter: true }) => {
2148
2180
  const width = Math.max(rect.width, 320)
2149
2181
  const height = Math.max(rect.height, 320)
2150
2182
  const nodes = options.useFiltered ? filteredNodes() : state.nodes
2151
- const hierarchyFitNodes = state.groups.length > 0 && nodes.length > 1000
2152
- ? arrangeGraphLevelNodes(parentHierarchyGroups(), groupRenderRadius).map(createGroupRenderNode)
2153
- : null
2154
- const fitNodes = hierarchyFitNodes ?? nodes
2183
+ const fitNodes = nodes
2155
2184
  const bounds = graphBounds(fitNodes)
2156
2185
 
2157
2186
  if (!bounds) {
@@ -2179,14 +2208,10 @@ const fitView = (options = { useFiltered: true, preferHubCenter: true }) => {
2179
2208
  const biasedScale = clampScale(fitScale * fitScaleBiasByNodeCount(fitNodes.length))
2180
2209
  const scaleRange = autoFitScaleRangeByNodeCount(fitNodes.length)
2181
2210
  const baselineScale = clampScale(Math.min(scaleRange.max, Math.max(scaleRange.min, biasedScale)))
2182
- const resolvedScale = nodes.length > massiveGraphNodeThreshold && !hierarchyFitNodes
2211
+ const resolvedScale = nodes.length > massiveGraphNodeThreshold
2183
2212
  ? clampScale(Math.min(baselineScale, massiveAutoFitMacroScale))
2184
2213
  : baselineScale
2185
- const hubCenter =
2186
- hierarchyFitNodes
2187
- ? null
2188
- :
2189
- 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)
2190
2215
  ? state.primaryHub
2191
2216
  : null
2192
2217
  const centerX = hubCenter ? hubCenter.x : (bounds.minX + bounds.maxX) / 2
@@ -2750,14 +2775,10 @@ const computeRenderVisibility = () => {
2750
2775
  state.lastViewportKey = viewportKey
2751
2776
  state.renderVisibilityDirty = false
2752
2777
 
2753
- if (computeHierarchyRenderVisibility(viewport)) {
2754
- return
2755
- }
2756
-
2757
2778
  if (state.visibleNodes.length <= 2000) {
2758
2779
  state.renderNodes = state.visibleNodes
2759
2780
  const ids = new Set(state.renderNodes.map((node) => node.id))
2760
- state.renderEdges = limitRenderEdges(state.renderNodes, collectVisibleEdgesForNodes(ids))
2781
+ state.renderEdges = limitRenderEdges(state.renderNodes, collectVisibleEdgesForNodes(ids, { preferComplete: true }))
2761
2782
  return
2762
2783
  }
2763
2784
 
@@ -2782,12 +2803,17 @@ const computeRenderVisibility = () => {
2782
2803
  : viewportNodes.length > 0
2783
2804
  ? viewportNodes
2784
2805
  : sampleMassiveOverviewNodes(sampleLimit)
2785
- 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)))
2786
2808
  const carryViewport = expandViewportBounds(viewport, carryMargin)
2787
- const carryOverLimit = Math.max(180, Math.min(sampleLimit, Math.floor(sampleLimit * 0.5)))
2788
- const carryOverNodes = (state.renderNodes ?? [])
2789
- .filter((node) => isNodeInViewport(node, carryViewport))
2790
- .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
+ : []
2791
2817
  const sourceWithCarry = mergeUniqueNodes(
2792
2818
  sourceNodes,
2793
2819
  carryOverNodes,
@@ -3286,23 +3312,15 @@ const selectNodeById = id => {
3286
3312
 
3287
3313
  const zoomAtPoint = (screenX, screenY, factor, source = 'generic') => {
3288
3314
  state.lastManualZoomAt = performance.now()
3289
- const baseScale = state.zoomTransition.active
3290
- ? state.zoomTransition.targetScale
3291
- : state.transform.scale
3315
+ const baseScale = state.transform.scale
3292
3316
  const boundedFactor = source === 'wheel'
3293
3317
  ? Math.max(wheelZoomInputFloorCap, Math.min(wheelZoomInputCeilCap, factor))
3294
3318
  : factor
3295
3319
  const nextScale = clampScale(baseScale * boundedFactor)
3296
- if (nextScale === baseScale && !state.zoomTransition.active) {
3320
+ if (nextScale === baseScale) {
3297
3321
  return
3298
3322
  }
3299
- const worldPointAtCursor =
3300
- state.zoomTransition.active &&
3301
- state.zoomTransition.source === source &&
3302
- state.zoomTransition.screenX === screenX &&
3303
- state.zoomTransition.screenY === screenY
3304
- ? { x: state.zoomTransition.worldX, y: state.zoomTransition.worldY }
3305
- : resolveZoomAnchorWorldPoint(screenX, screenY)
3323
+ const worldPointAtCursor = resolveZoomAnchorWorldPoint(screenX, screenY)
3306
3324
  const worldX = worldPointAtCursor.x
3307
3325
  const worldY = worldPointAtCursor.y
3308
3326
  state.lastZoomFocus = {
@@ -3310,15 +3328,10 @@ const zoomAtPoint = (screenX, screenY, factor, source = 'generic') => {
3310
3328
  y: worldY,
3311
3329
  at: performance.now()
3312
3330
  }
3313
- state.zoomTransition = {
3314
- active: true,
3315
- source,
3316
- screenX,
3317
- screenY,
3318
- worldX,
3319
- worldY,
3320
- targetScale: nextScale
3321
- }
3331
+ state.transform.scale = nextScale
3332
+ state.transform.x = clampTransformCoordinate(screenX - worldX * nextScale)
3333
+ state.transform.y = clampTransformCoordinate(screenY - worldY * nextScale)
3334
+ clearZoomTransition()
3322
3335
  state.offscreenFrameCount = 0
3323
3336
  markRenderDirty()
3324
3337
  }
@@ -3398,7 +3411,7 @@ const bindEvents = () => {
3398
3411
  if (elements.fit) {
3399
3412
  elements.fit.addEventListener('click', () => {
3400
3413
  resetHierarchyFocus()
3401
- focusPrimaryHub()
3414
+ fitView({ useFiltered: true, preferHubCenter: false })
3402
3415
  })
3403
3416
  }
3404
3417
  elements.reset.addEventListener('click', () => {
@@ -3487,13 +3500,7 @@ const bindEvents = () => {
3487
3500
  }
3488
3501
  if (!state.pointer.moved) {
3489
3502
  const clickedNode = draggedNode ?? state.hovered
3490
- if (clickedNode?.isGroupNode) {
3491
- handleGroupNodePrimaryClick(clickedNode)
3492
- } else if (clickedNode && state.hierarchyFocusGroupId) {
3493
- handleLeafNodePrimaryClick(clickedNode)
3494
- } else {
3495
- selectNode(clickedNode, { openContent: true })
3496
- }
3503
+ selectNode(clickedNode, { openContent: true })
3497
3504
  }
3498
3505
  state.pointer = { x: 0, y: 0, down: false, dragNode: null, moved: false }
3499
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.138",
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",