@andespindola/brainlink 0.1.0-beta.87 → 0.1.0-beta.88

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
@@ -84,7 +84,7 @@ Legacy `.jsonl.gz` packs are upgraded to `.blpk` automatically on first search/c
84
84
  - Graph renderer optimized for large datasets with viewport-driven node culling and edge lookup by visible nodes.
85
85
  - 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.
86
86
  - WebGL acceleration is used when available for dense node and edge drawing, with Canvas 2D preserved as the interaction and fallback layer.
87
- - Graph zoom-out renders hierarchical ecosystem subgraphs only above 1000 notes: distant groups stay as small sand-like points and expand near the user's focus into smaller graph meshes before individual notes are rendered.
87
+ - Graph zoom-out renders hierarchical ecosystem subgraphs only above 1000 notes: the memory hub stays centered, 1000-note groups stay as compact sand-like points, and focused groups gradually expand into smaller graph meshes before individual notes are rendered.
88
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.
89
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).
90
90
  - Graph coordinates are visually compacted across graph sizes so reset starts from a stable macro mass and zoom-in progressively expands toward local detail.
@@ -602,7 +602,7 @@ The graph UI shows:
602
602
  - WebGL node and edge acceleration when supported, falling back to Canvas 2D without changing graph behavior
603
603
  - compact macro-to-micro density progression so reset keeps the graph mass oriented and zoom-in separates local neighborhoods progressively
604
604
  - graph camera treats hub-centered navigation as structural only when the hub is dominant; diffuse stress graphs reset and zoom around the full graph mass
605
- - graph LOD progression: graphs up to 1000 notes render directly; larger graphs use connected sand-like ecosystem points of up to 1000 notes, zoom-in spreads only focused clusters into 250-note and 60-note subgraphs with aggregated real links, then progressively raises the focused node budget so local areas keep nearby notes and links visible
605
+ - graph LOD progression: graphs up to 1000 notes render directly; larger graphs use a compact memory-hub-centered mesh of connected 1000-note points, zoom-in spreads only focused clusters into 250-note and 60-note subgraphs with aggregated real links, then progressively raises the focused node budget so local areas keep nearby notes and links visible
606
606
 
607
607
  The server indexes before starting by default. Use `--no-index` to skip that step:
608
608
 
@@ -25,6 +25,7 @@ const ecosystemGroupSize = 1000
25
25
  const ecosystemActivationNodeThreshold = 1000
26
26
  const ecosystemGroupSizes = [1000, 250, 60]
27
27
  const ecosystemClusterEdgeLimit = 520
28
+ const ecosystemHubEdgeLimit = 120
28
29
  const ecosystemClusterScaleThreshold = 0.32
29
30
  const ecosystemSubgraphScaleThreshold = 0.18
30
31
  const ecosystemMicroScaleThreshold = 0.08
@@ -74,6 +75,8 @@ const state = {
74
75
  visibleEdgeByNode: new Map(),
75
76
  ecosystemClusters: [],
76
77
  ecosystemClustersBySize: new Map(),
78
+ ecosystemNodeClusterBySize: new Map(),
79
+ ecosystemHubCluster: null,
77
80
  macroCenter: { x: 0, y: 0 },
78
81
  macroRepresentative: null,
79
82
  primaryHub: null,
@@ -570,11 +573,6 @@ const recomputeVisibility = () => {
570
573
  state.visibleEdges = limitedEdges
571
574
  state.visibleNodeSpatial = createSpatialIndex(nodes)
572
575
  state.visibleEdgeByNode = createVisibleEdgeLookup(limitedEdges)
573
- const ecosystemGraph = nodes.length > ecosystemActivationNodeThreshold
574
- ? buildEcosystemGraph(nodes)
575
- : { clusters: [], clustersBySize: new Map() }
576
- state.ecosystemClusters = ecosystemGraph.clusters
577
- state.ecosystemClustersBySize = ecosystemGraph.clustersBySize
578
576
  const primaryHub = rankedHubNodes()[0] ?? null
579
577
  state.primaryHub = primaryHub
580
578
  state.hubNeighborDistance = nearestHubNeighborDistance(primaryHub, nodes)
@@ -586,6 +584,13 @@ const recomputeVisibility = () => {
586
584
  y: macroHub ? macroHub.y : (bounds.minY + bounds.maxY) / 2
587
585
  }
588
586
  : { x: 0, y: 0 }
587
+ const ecosystemGraph = nodes.length > ecosystemActivationNodeThreshold
588
+ ? buildEcosystemGraph(nodes, state.macroCenter, primaryHub)
589
+ : { clusters: [], clustersBySize: new Map(), nodeClusterBySize: new Map(), hubCluster: null }
590
+ state.ecosystemClusters = ecosystemGraph.clusters
591
+ state.ecosystemClustersBySize = ecosystemGraph.clustersBySize
592
+ state.ecosystemNodeClusterBySize = ecosystemGraph.nodeClusterBySize
593
+ state.ecosystemHubCluster = ecosystemGraph.hubCluster
589
594
  state.macroRepresentative = resolveMacroRepresentative(nodes)
590
595
  markRenderDirty()
591
596
  }
@@ -730,18 +735,32 @@ const selectEcosystemRepresentative = nodes => {
730
735
  return representative
731
736
  }
732
737
 
733
- const buildEcosystemCluster = (nodes, index) => {
738
+ const ecosystemLayoutSpacingForSize = size => {
739
+ if (size >= 1000) return 360
740
+ if (size >= 250) return 92
741
+ return 28
742
+ }
743
+
744
+ const ecosystemCompactPoint = (index, total, center, spacing) => {
745
+ if (total <= 1) {
746
+ return { x: center.x, y: center.y }
747
+ }
748
+ const angle = index * 2.399963229728653
749
+ const radius = spacing * Math.sqrt(index + 1)
750
+ return {
751
+ x: center.x + Math.cos(angle) * radius,
752
+ y: center.y + Math.sin(angle) * radius
753
+ }
754
+ }
755
+
756
+ const buildEcosystemCluster = (nodes, index, point) => {
734
757
  const count = Math.max(nodes.length, 1)
735
- const sum = nodes.reduce((accumulator, node) => ({
736
- x: accumulator.x + node.x,
737
- y: accumulator.y + node.y
738
- }), { x: 0, y: 0 })
739
758
  const representative = selectEcosystemRepresentative(nodes)
740
759
 
741
760
  return {
742
761
  id: 'ecosystem-' + index,
743
- x: sum.x / count,
744
- y: sum.y / count,
762
+ x: point.x,
763
+ y: point.y,
745
764
  count,
746
765
  nodeIds: nodes.map(node => node.id),
747
766
  representative,
@@ -749,15 +768,42 @@ const buildEcosystemCluster = (nodes, index) => {
749
768
  }
750
769
  }
751
770
 
752
- const buildEcosystemLevel = (sortedNodes, size, parentLookup) => {
771
+ const buildEcosystemHubCluster = (hub, center) => hub
772
+ ? {
773
+ id: 'ecosystem-hub',
774
+ x: center.x,
775
+ y: center.y,
776
+ count: 1,
777
+ size: 1,
778
+ nodeIds: [hub.id],
779
+ representative: hub,
780
+ label: hub.title || 'Memory Hub',
781
+ parentId: null,
782
+ parentX: null,
783
+ parentY: null,
784
+ isHub: true
785
+ }
786
+ : null
787
+
788
+ const buildEcosystemLevel = (sortedNodes, size, parentLookup, center) => {
753
789
  const clusters = []
754
790
  const clusterByNodeId = new Map()
791
+ const parentChildIndex = new Map()
755
792
 
756
793
  for (let offset = 0; offset < sortedNodes.length; offset += size) {
757
794
  const clusterNodes = sortedNodes.slice(offset, offset + size)
758
795
  const parentCluster = parentLookup?.get(clusterNodes[0]?.id)
796
+ const siblingIndex = parentCluster
797
+ ? (parentChildIndex.get(parentCluster.id) ?? 0)
798
+ : clusters.length
799
+ if (parentCluster) {
800
+ parentChildIndex.set(parentCluster.id, siblingIndex + 1)
801
+ }
802
+ const point = parentCluster
803
+ ? ecosystemCompactPoint(siblingIndex, Math.ceil((parentCluster.count || size) / size), parentCluster, ecosystemLayoutSpacingForSize(size))
804
+ : ecosystemCompactPoint(clusters.length, Math.ceil(sortedNodes.length / size), center, ecosystemLayoutSpacingForSize(size))
759
805
  const cluster = {
760
- ...buildEcosystemCluster(clusterNodes, clusters.length),
806
+ ...buildEcosystemCluster(clusterNodes, clusters.length, point),
761
807
  id: 'ecosystem-' + size + '-' + clusters.length,
762
808
  size,
763
809
  parentId: parentCluster?.id ?? null,
@@ -773,25 +819,32 @@ const buildEcosystemLevel = (sortedNodes, size, parentLookup) => {
773
819
  return { clusters, clusterByNodeId }
774
820
  }
775
821
 
776
- const buildEcosystemGraph = (nodes) => {
822
+ const buildEcosystemGraph = (nodes, center, hub) => {
777
823
  if (nodes.length === 0) {
778
- return { clusters: [], clustersBySize: new Map() }
824
+ return { clusters: [], clustersBySize: new Map(), nodeClusterBySize: new Map(), hubCluster: null }
779
825
  }
780
826
 
781
- const sortedNodes = [...nodes].sort(compareNodesForEcosystem)
827
+ const hubCluster = buildEcosystemHubCluster(hub, center)
828
+ const sortedNodes = nodes
829
+ .filter(node => node.id !== hub?.id)
830
+ .sort(compareNodesForEcosystem)
782
831
  const clustersBySize = new Map()
832
+ const nodeClusterBySize = new Map()
783
833
  let parentLookup = null
784
834
 
785
835
  for (let index = 0; index < ecosystemGroupSizes.length; index += 1) {
786
836
  const size = ecosystemGroupSizes[index]
787
- const level = buildEcosystemLevel(sortedNodes, size, parentLookup)
837
+ const level = buildEcosystemLevel(sortedNodes, size, parentLookup, center)
788
838
  clustersBySize.set(size, level.clusters)
839
+ nodeClusterBySize.set(size, level.clusterByNodeId)
789
840
  parentLookup = level.clusterByNodeId
790
841
  }
791
842
 
792
843
  return {
793
844
  clusters: clustersBySize.get(ecosystemGroupSize) ?? [],
794
- clustersBySize
845
+ clustersBySize,
846
+ nodeClusterBySize,
847
+ hubCluster
795
848
  }
796
849
  }
797
850
 
@@ -832,21 +885,22 @@ const smoothStep = value => {
832
885
  const zoomProgress = (scale, start, end) =>
833
886
  smoothStep((scale - start) / Math.max(end - start, 0.0001))
834
887
 
835
- const ecosystemPlanForScale = scale => {
836
- if (scale <= ecosystemMicroScaleThreshold) {
837
- return { baseSize: 1000, childSize: null, spread: 0 }
838
- }
839
- if (scale <= ecosystemSubgraphScaleThreshold) {
840
- return {
841
- baseSize: 1000,
842
- childSize: 250,
843
- spread: zoomProgress(scale, ecosystemMicroScaleThreshold, ecosystemSubgraphScaleThreshold)
844
- }
845
- }
888
+ const expandFocusedClusters = (parentClusters, childSize, spread, viewport) => {
889
+ const focusPoint = ecosystemFocusPoint()
890
+ const expandedParentIds = new Set(nearestEcosystemParentIds(
891
+ parentClusters,
892
+ focusPoint,
893
+ ecosystemFocusedParentLimit
894
+ ))
895
+ const childClusters = state.ecosystemClustersBySize.get(childSize) ?? []
896
+ const visibleChildClusters = childClusters
897
+ .filter(cluster => expandedParentIds.has(cluster.parentId))
898
+ .map(cluster => spreadChildClusterFromParent(cluster, spread))
899
+ .filter(cluster => isClusterInViewport(cluster, viewport))
900
+
846
901
  return {
847
- baseSize: 250,
848
- childSize: 60,
849
- spread: zoomProgress(scale, ecosystemSubgraphScaleThreshold, ecosystemClusterScaleThreshold)
902
+ expandedParentIds,
903
+ childClusters: visibleChildClusters
850
904
  }
851
905
  }
852
906
 
@@ -863,50 +917,84 @@ const spreadChildClusterFromParent = (cluster, spread) => {
863
917
  }
864
918
 
865
919
  const selectHierarchicalEcosystemClusters = viewport => {
866
- const plan = ecosystemPlanForScale(state.transform.scale)
867
- const baseClusters = state.ecosystemClustersBySize.get(plan.baseSize) ?? state.ecosystemClusters
920
+ const baseClusters = state.ecosystemClustersBySize.get(ecosystemGroupSize) ?? state.ecosystemClusters
868
921
  const visibleBaseClusters = filterEcosystemClustersByViewport(baseClusters, viewport)
922
+ const hubClusters = state.ecosystemHubCluster ? [state.ecosystemHubCluster] : []
869
923
 
870
- if (!plan.childSize) {
871
- return visibleBaseClusters
924
+ if (state.transform.scale <= ecosystemMicroScaleThreshold) {
925
+ return [...hubClusters, ...visibleBaseClusters]
872
926
  }
873
927
 
874
- const focusPoint = ecosystemFocusPoint()
875
- const expandedParentIds = new Set(nearestEcosystemParentIds(
928
+ const midExpansion = expandFocusedClusters(
876
929
  visibleBaseClusters,
877
- focusPoint,
878
- ecosystemFocusedParentLimit
879
- ))
880
- const childClusters = state.ecosystemClustersBySize.get(plan.childSize) ?? []
881
- const visibleChildClusters = childClusters.filter(cluster =>
882
- expandedParentIds.has(cluster.parentId) &&
883
- isClusterInViewport(cluster, viewport)
884
- ).map(cluster => spreadChildClusterFromParent(cluster, plan.spread))
930
+ 250,
931
+ state.transform.scale <= ecosystemSubgraphScaleThreshold
932
+ ? zoomProgress(state.transform.scale, ecosystemMicroScaleThreshold, ecosystemSubgraphScaleThreshold)
933
+ : 1,
934
+ viewport
935
+ )
936
+ if (midExpansion.childClusters.length === 0) {
937
+ return [...hubClusters, ...visibleBaseClusters]
938
+ }
885
939
 
886
- if (visibleChildClusters.length === 0) {
887
- return visibleBaseClusters
940
+ const remainingBaseClusters = visibleBaseClusters.filter(cluster => !midExpansion.expandedParentIds.has(cluster.id))
941
+ if (state.transform.scale <= ecosystemSubgraphScaleThreshold) {
942
+ return [
943
+ ...hubClusters,
944
+ ...remainingBaseClusters,
945
+ ...midExpansion.childClusters
946
+ ]
947
+ }
948
+
949
+ const microExpansion = expandFocusedClusters(
950
+ midExpansion.childClusters,
951
+ 60,
952
+ zoomProgress(state.transform.scale, ecosystemSubgraphScaleThreshold, ecosystemClusterScaleThreshold),
953
+ viewport
954
+ )
955
+ if (microExpansion.childClusters.length === 0) {
956
+ return [
957
+ ...hubClusters,
958
+ ...remainingBaseClusters,
959
+ ...midExpansion.childClusters
960
+ ]
888
961
  }
889
962
 
890
963
  return [
891
- ...visibleBaseClusters.filter(cluster => !expandedParentIds.has(cluster.id)),
892
- ...visibleChildClusters
964
+ ...hubClusters,
965
+ ...remainingBaseClusters,
966
+ ...midExpansion.childClusters.filter(cluster => !microExpansion.expandedParentIds.has(cluster.id)),
967
+ ...microExpansion.childClusters
893
968
  ]
894
969
  }
895
970
 
896
971
  const ecosystemEdgesForClusters = clusters => {
897
- const clusterByNodeId = new Map()
898
- for (let clusterIndex = 0; clusterIndex < clusters.length; clusterIndex += 1) {
899
- const cluster = clusters[clusterIndex]
900
- for (let nodeIndex = 0; nodeIndex < cluster.nodeIds.length; nodeIndex += 1) {
901
- clusterByNodeId.set(cluster.nodeIds[nodeIndex], cluster)
972
+ const clusterById = new Map(clusters.map(cluster => [cluster.id, cluster]))
973
+ const clusterIds = new Set(clusterById.keys())
974
+ const levelBySize = new Map()
975
+ for (let index = 0; index < clusters.length; index += 1) {
976
+ const cluster = clusters[index]
977
+ if (!cluster.size || cluster.isHub) continue
978
+ levelBySize.set(cluster.size, state.ecosystemNodeClusterBySize.get(cluster.size) ?? new Map())
979
+ }
980
+ const resolveClusterForNode = nodeId => {
981
+ if (state.ecosystemHubCluster?.nodeIds.includes(nodeId) && clusterIds.has(state.ecosystemHubCluster.id)) {
982
+ return state.ecosystemHubCluster
983
+ }
984
+ for (const [size, lookup] of levelBySize) {
985
+ const cluster = lookup.get(nodeId)
986
+ if (cluster && clusterIds.has(cluster.id)) {
987
+ return clusterById.get(cluster.id) ?? cluster
988
+ }
902
989
  }
990
+ return null
903
991
  }
904
992
 
905
993
  const edgeByClusterPair = new Map()
906
994
  for (let index = 0; index < state.visibleEdges.length; index += 1) {
907
995
  const edge = state.visibleEdges[index]
908
- const sourceCluster = clusterByNodeId.get(edge.source)
909
- const targetCluster = clusterByNodeId.get(edge.target)
996
+ const sourceCluster = resolveClusterForNode(edge.source)
997
+ const targetCluster = resolveClusterForNode(edge.target)
910
998
  if (!sourceCluster || !targetCluster || sourceCluster.id === targetCluster.id) {
911
999
  continue
912
1000
  }
@@ -929,9 +1017,34 @@ const ecosystemEdgesForClusters = clusters => {
929
1017
  })
930
1018
  }
931
1019
 
932
- return Array.from(edgeByClusterPair.values())
1020
+ const edges = Array.from(edgeByClusterPair.values())
933
1021
  .sort((left, right) => right.weight - left.weight)
934
1022
  .slice(0, ecosystemClusterEdgeLimit)
1023
+ const hubCluster = state.ecosystemHubCluster && clusterIds.has(state.ecosystemHubCluster.id)
1024
+ ? state.ecosystemHubCluster
1025
+ : null
1026
+ if (!hubCluster) {
1027
+ return edges
1028
+ }
1029
+
1030
+ const existingHubTargets = new Set(edges.flatMap(edge =>
1031
+ edge.sourceCluster.id === hubCluster.id
1032
+ ? [edge.targetCluster.id]
1033
+ : edge.targetCluster.id === hubCluster.id
1034
+ ? [edge.sourceCluster.id]
1035
+ : []
1036
+ ))
1037
+ const syntheticHubEdges = clusters
1038
+ .filter(cluster => cluster.id !== hubCluster.id && !existingHubTargets.has(cluster.id))
1039
+ .slice(0, ecosystemHubEdgeLimit)
1040
+ .map(cluster => ({
1041
+ id: hubCluster.id + ':' + cluster.id,
1042
+ sourceCluster: hubCluster,
1043
+ targetCluster: cluster,
1044
+ weight: 1,
1045
+ inferred: true
1046
+ }))
1047
+ return edges.concat(syntheticHubEdges)
935
1048
  }
936
1049
 
937
1050
  const edgeBudgetForCurrentFrame = () => {
@@ -2372,6 +2485,9 @@ const clusterRadiusPx = cluster => {
2372
2485
  if (cluster.id === 'macro-galaxy') {
2373
2486
  return 10
2374
2487
  }
2488
+ if (cluster.isHub) {
2489
+ return 5.2
2490
+ }
2375
2491
  if (String(cluster.id).startsWith('ecosystem-')) {
2376
2492
  const base = cluster.size >= 1000 ? 2.4 : cluster.size >= 250 ? 2.1 : 1.8
2377
2493
  return Math.max(1.8, Math.min(4.2, base + Math.log10(cluster.count + 1) * 0.28))
@@ -2807,10 +2923,11 @@ const render = now => {
2807
2923
  state.renderClusters.forEach(cluster => {
2808
2924
  const isMacro = cluster.id === 'macro-galaxy'
2809
2925
  const isEcosystem = String(cluster.id).startsWith('ecosystem-')
2926
+ const isHub = Boolean(cluster.isHub)
2810
2927
  const radiusPx = clusterRadiusPx(cluster)
2811
2928
  const radius = radiusPx / safeScale
2812
- const haloRadius = (radiusPx + (isMacro ? 8 : isEcosystem ? 1.1 : 4)) / safeScale
2813
- if (!isEcosystem || state.transform.scale >= ecosystemSubgraphScaleThreshold) {
2929
+ const haloRadius = (radiusPx + (isMacro ? 8 : isHub ? 4 : isEcosystem ? 1.1 : 4)) / safeScale
2930
+ if (isHub || !isEcosystem || state.transform.scale >= ecosystemSubgraphScaleThreshold) {
2814
2931
  ctx.beginPath()
2815
2932
  ctx.arc(cluster.x, cluster.y, haloRadius, 0, Math.PI * 2)
2816
2933
  ctx.fillStyle = isMacro ? 'rgba(243, 247, 251, 0.28)' : graphTheme.nodeHalo
@@ -2820,7 +2937,7 @@ const render = now => {
2820
2937
  ctx.arc(cluster.x, cluster.y, radius, 0, Math.PI * 2)
2821
2938
  ctx.fillStyle = isMacro ? '#f3f7fb' : graphTheme.node
2822
2939
  ctx.fill()
2823
- ctx.lineWidth = (isEcosystem ? 0.7 : 1.4) / safeScale
2940
+ ctx.lineWidth = (isEcosystem && !isHub ? 0.7 : 1.4) / safeScale
2824
2941
  ctx.strokeStyle = isMacro ? '#ffffff' : graphTheme.nodeStroke
2825
2942
  ctx.stroke()
2826
2943
  if (isMacro && cluster.representative?.title) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andespindola/brainlink",
3
- "version": "0.1.0-beta.87",
3
+ "version": "0.1.0-beta.88",
4
4
  "description": "Local-first knowledge memory for agents with Markdown, backlinks, indexing and context retrieval.",
5
5
  "type": "module",
6
6
  "license": "MIT",