@andespindola/brainlink 0.1.0-beta.96 → 0.1.0-beta.98

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
@@ -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 a compact memory-hub-centered mesh where each 1000-note point already carries a faint connected 500-note micro-structure, then zoom-in keeps focused child clusters latent at the parent position before fading and separating them through 250-note, 125-note, 60-note, 30-note, 15-note and 8-note subgraphs with aggregated real links plus local sibling mesh links, and in massive graphs keeps this subgraph mode active much longer with finer wheel steps so deep zoom does not abruptly switch to a broad sampled node cloud
605
+ - graph LOD progression: graphs up to 1000 notes render directly; larger graphs use one recursive model where each visible level targets up to 999 non-hub nodes, starts from a memory-hub-centered mesh, and each supernode can expand into another same-shape subgraph level (again up to 999 children) with latent fade-in, aggregated real links and local sibling mesh links so org-heavy and stress-50k follow the same structure at different depths
606
606
 
607
607
  The server indexes before starting by default. Use `--no-index` to skip that step:
608
608
 
@@ -21,9 +21,8 @@ const viewportPaddingPx = 280
21
21
  const worldCoordinateLimit = 5_000_000
22
22
  const transformCoordinateLimit = 20_000_000
23
23
  const hoverHitTestIntervalMs = 64
24
- const ecosystemGroupSize = 1000
24
+ const ecosystemLevelNodeCap = 999
25
25
  const ecosystemActivationNodeThreshold = 1000
26
- const ecosystemGroupSizes = [1000, 500, 250, 125, 60, 30, 15, 8]
27
26
  const ecosystemClusterEdgeLimit = 520
28
27
  const ecosystemHubEdgeLimit = 120
29
28
  const ecosystemSiblingEdgeLimit = 180
@@ -32,15 +31,6 @@ const massiveEcosystemClusterScaleThreshold = 4.2
32
31
  const ecosystemSubgraphScaleThreshold = 0.18
33
32
  const ecosystemMicroScaleThreshold = 0.08
34
33
  const ecosystemFocusedParentLimit = 2
35
- const ecosystemExpansionLevels = [
36
- { parentSize: 1000, childSize: 500, start: 0.04, end: 0.62 },
37
- { parentSize: 500, childSize: 250, start: 0.16, end: 0.72 },
38
- { parentSize: 250, childSize: 125, start: 0.3, end: 0.78 },
39
- { parentSize: 125, childSize: 60, start: 0.46, end: 0.86 },
40
- { parentSize: 60, childSize: 30, start: 0.64, end: 1.06 },
41
- { parentSize: 30, childSize: 15, start: 0.84, end: 1.38 },
42
- { parentSize: 15, childSize: 8, start: 1.08, end: 1.82 }
43
- ]
44
34
  const zoomRecoveryGuardMs = 4200
45
35
  const zoomCapTargetViewportShare = 0.72
46
36
  const meshEdgeScaleThreshold = 0.09
@@ -87,6 +77,9 @@ const state = {
87
77
  ecosystemClusters: [],
88
78
  ecosystemClustersBySize: new Map(),
89
79
  ecosystemNodeClusterBySize: new Map(),
80
+ ecosystemLevelSizes: [],
81
+ ecosystemExpansionLevels: [],
82
+ ecosystemBaseSize: ecosystemLevelNodeCap,
90
83
  ecosystemHubCluster: null,
91
84
  macroCenter: { x: 0, y: 0 },
92
85
  macroRepresentative: null,
@@ -597,10 +590,21 @@ const recomputeVisibility = () => {
597
590
  : { x: 0, y: 0 }
598
591
  const ecosystemGraph = nodes.length > ecosystemActivationNodeThreshold
599
592
  ? buildEcosystemGraph(nodes, state.macroCenter, primaryHub)
600
- : { clusters: [], clustersBySize: new Map(), nodeClusterBySize: new Map(), hubCluster: null }
593
+ : {
594
+ clusters: [],
595
+ clustersBySize: new Map(),
596
+ nodeClusterBySize: new Map(),
597
+ levelSizes: [],
598
+ expansionLevels: [],
599
+ baseSize: ecosystemLevelNodeCap,
600
+ hubCluster: null
601
+ }
601
602
  state.ecosystemClusters = ecosystemGraph.clusters
602
603
  state.ecosystemClustersBySize = ecosystemGraph.clustersBySize
603
604
  state.ecosystemNodeClusterBySize = ecosystemGraph.nodeClusterBySize
605
+ state.ecosystemLevelSizes = ecosystemGraph.levelSizes
606
+ state.ecosystemExpansionLevels = ecosystemGraph.expansionLevels
607
+ state.ecosystemBaseSize = ecosystemGraph.baseSize
604
608
  state.ecosystemHubCluster = ecosystemGraph.hubCluster
605
609
  state.macroRepresentative = resolveMacroRepresentative(nodes)
606
610
  markRenderDirty()
@@ -747,14 +751,56 @@ const selectEcosystemRepresentative = nodes => {
747
751
  }
748
752
 
749
753
  const ecosystemLayoutSpacingForSize = size => {
750
- if (size >= 1000) return 260
751
- if (size >= 500) return 92
752
- if (size >= 250) return 52
753
- if (size >= 125) return 28
754
- if (size >= 60) return 16
755
- if (size >= 30) return 11
756
- if (size >= 15) return 7
757
- return 5
754
+ if (size >= ecosystemLevelNodeCap) return 260
755
+ if (size >= 320) return 110
756
+ if (size >= 120) return 64
757
+ if (size >= 48) return 34
758
+ if (size >= 18) return 18
759
+ if (size >= 8) return 11
760
+ return 7
761
+ }
762
+
763
+ const buildEcosystemLevelSizes = nodeCount => {
764
+ if (nodeCount <= 0) return []
765
+ const sizes = []
766
+ let currentSize = Math.max(1, Math.ceil(nodeCount / ecosystemLevelNodeCap))
767
+ while (currentSize >= 1) {
768
+ sizes.push(currentSize)
769
+ if (currentSize === 1) {
770
+ break
771
+ }
772
+ const nextSize = Math.max(1, Math.ceil(currentSize / ecosystemLevelNodeCap))
773
+ if (nextSize === currentSize) {
774
+ break
775
+ }
776
+ currentSize = nextSize
777
+ }
778
+ return sizes
779
+ }
780
+
781
+ const buildEcosystemExpansionLevels = (levelSizes, nodeCount) => {
782
+ if (levelSizes.length <= 1) {
783
+ return []
784
+ }
785
+ const maxScale = nodeCount > massiveGraphNodeThreshold
786
+ ? massiveEcosystemClusterScaleThreshold
787
+ : ecosystemClusterScaleThreshold
788
+ const startScale = 0.04
789
+ const transitionCount = levelSizes.length - 1
790
+ const usableScale = Math.max(0.08, maxScale - startScale)
791
+ const step = usableScale / transitionCount
792
+ const levels = []
793
+ for (let index = 0; index < transitionCount; index += 1) {
794
+ const start = startScale + step * index * 0.72
795
+ const end = Math.min(maxScale, start + step * 1.85)
796
+ levels.push({
797
+ parentSize: levelSizes[index],
798
+ childSize: levelSizes[index + 1],
799
+ start,
800
+ end
801
+ })
802
+ }
803
+ return levels
758
804
  }
759
805
 
760
806
  const ecosystemCompactPoint = (index, total, center, spacing) => {
@@ -837,19 +883,30 @@ const buildEcosystemLevel = (sortedNodes, size, parentLookup, center) => {
837
883
 
838
884
  const buildEcosystemGraph = (nodes, center, hub) => {
839
885
  if (nodes.length === 0) {
840
- return { clusters: [], clustersBySize: new Map(), nodeClusterBySize: new Map(), hubCluster: null }
886
+ return {
887
+ clusters: [],
888
+ clustersBySize: new Map(),
889
+ nodeClusterBySize: new Map(),
890
+ levelSizes: [],
891
+ expansionLevels: [],
892
+ baseSize: ecosystemLevelNodeCap,
893
+ hubCluster: null
894
+ }
841
895
  }
842
896
 
843
897
  const hubCluster = buildEcosystemHubCluster(hub, center)
844
898
  const sortedNodes = nodes
845
899
  .filter(node => node.id !== hub?.id)
846
900
  .sort(compareNodesForEcosystem)
901
+ const levelSizes = buildEcosystemLevelSizes(sortedNodes.length)
902
+ const expansionLevels = buildEcosystemExpansionLevels(levelSizes, nodes.length)
903
+ const baseSize = levelSizes[0] ?? ecosystemLevelNodeCap
847
904
  const clustersBySize = new Map()
848
905
  const nodeClusterBySize = new Map()
849
906
  let parentLookup = null
850
907
 
851
- for (let index = 0; index < ecosystemGroupSizes.length; index += 1) {
852
- const size = ecosystemGroupSizes[index]
908
+ for (let index = 0; index < levelSizes.length; index += 1) {
909
+ const size = levelSizes[index]
853
910
  const level = buildEcosystemLevel(sortedNodes, size, parentLookup, center)
854
911
  clustersBySize.set(size, level.clusters)
855
912
  nodeClusterBySize.set(size, level.clusterByNodeId)
@@ -857,9 +914,12 @@ const buildEcosystemGraph = (nodes, center, hub) => {
857
914
  }
858
915
 
859
916
  return {
860
- clusters: clustersBySize.get(ecosystemGroupSize) ?? [],
917
+ clusters: clustersBySize.get(baseSize) ?? [],
861
918
  clustersBySize,
862
919
  nodeClusterBySize,
920
+ levelSizes,
921
+ expansionLevels,
922
+ baseSize,
863
923
  hubCluster
864
924
  }
865
925
  }
@@ -876,6 +936,10 @@ const filterEcosystemClustersByViewport = (clusters, viewport) => {
876
936
  }
877
937
 
878
938
  const ecosystemFocusPoint = () => {
939
+ const cursorPoint = cursorWorldPoint()
940
+ if (cursorPoint) {
941
+ return cursorPoint
942
+ }
879
943
  const now = performance.now()
880
944
  if (now - state.lastZoomFocus.at <= 1800) {
881
945
  return { x: state.lastZoomFocus.x, y: state.lastZoomFocus.y }
@@ -887,7 +951,11 @@ const nearestEcosystemParentIds = (clusters, focusPoint, limit) =>
887
951
  clusters
888
952
  .map(cluster => ({
889
953
  cluster,
890
- distance: Math.hypot(cluster.x - focusPoint.x, cluster.y - focusPoint.y)
954
+ distance: Math.max(
955
+ 0,
956
+ Math.hypot(cluster.x - focusPoint.x, cluster.y - focusPoint.y) -
957
+ clusterRadiusPx(cluster) / Math.max(state.transform.scale, 0.0001)
958
+ )
891
959
  }))
892
960
  .sort((left, right) => left.distance - right.distance)
893
961
  .slice(0, limit)
@@ -903,7 +971,7 @@ const zoomProgress = (scale, start, end) =>
903
971
 
904
972
  const semanticZoomSpread = (progress, childSize) => {
905
973
  const curve = Math.pow(progress, 4.2)
906
- if (childSize >= 500) {
974
+ if (childSize >= Math.ceil(ecosystemLevelNodeCap / 2)) {
907
975
  return 0.12 + curve * 0.88
908
976
  }
909
977
  return curve
@@ -911,7 +979,7 @@ const semanticZoomSpread = (progress, childSize) => {
911
979
 
912
980
  const opacityForProgress = (progress, childSize) => {
913
981
  const eased = Math.pow(progress, 2.1)
914
- if (childSize >= 500) {
982
+ if (childSize >= Math.ceil(ecosystemLevelNodeCap / 2)) {
915
983
  return 0.22 + eased * 0.78
916
984
  }
917
985
  return eased
@@ -953,13 +1021,13 @@ const spreadChildClusterFromParent = (cluster, childSize, progress, spread) => {
953
1021
  }
954
1022
 
955
1023
  const selectHierarchicalEcosystemClusters = viewport => {
956
- const baseClusters = state.ecosystemClustersBySize.get(ecosystemGroupSize) ?? state.ecosystemClusters
1024
+ const baseClusters = state.ecosystemClustersBySize.get(state.ecosystemBaseSize) ?? state.ecosystemClusters
957
1025
  const visibleBaseClusters = filterEcosystemClustersByViewport(baseClusters, viewport)
958
1026
  const hubClusters = state.ecosystemHubCluster ? [state.ecosystemHubCluster] : []
959
1027
  const visibleClusters = [...visibleBaseClusters]
960
1028
 
961
- for (let index = 0; index < ecosystemExpansionLevels.length; index += 1) {
962
- const level = ecosystemExpansionLevels[index]
1029
+ for (let index = 0; index < state.ecosystemExpansionLevels.length; index += 1) {
1030
+ const level = state.ecosystemExpansionLevels[index]
963
1031
  const parentClusters = visibleClusters.filter(cluster => cluster.size === level.parentSize)
964
1032
  if (parentClusters.length === 0) {
965
1033
  continue
@@ -1233,6 +1301,29 @@ const viewportCenterWorldPoint = () => {
1233
1301
  }
1234
1302
  }
1235
1303
 
1304
+ const screenToWorldPoint = (screenX, screenY) => ({
1305
+ x: (screenX - state.transform.x) / state.transform.scale,
1306
+ y: (screenY - state.transform.y) / state.transform.scale
1307
+ })
1308
+
1309
+ const cursorWorldPoint = () => {
1310
+ if (!state.cursor.inCanvas) {
1311
+ return null
1312
+ }
1313
+ const rect = canvas.getBoundingClientRect()
1314
+ const screenX = state.cursor.x - rect.left
1315
+ const screenY = state.cursor.y - rect.top
1316
+ const width = Math.max(rect.width, 320)
1317
+ const height = Math.max(rect.height, 320)
1318
+ if (!Number.isFinite(screenX) || !Number.isFinite(screenY)) {
1319
+ return null
1320
+ }
1321
+ if (screenX < 0 || screenX > width || screenY < 0 || screenY > height) {
1322
+ return null
1323
+ }
1324
+ return screenToWorldPoint(screenX, screenY)
1325
+ }
1326
+
1236
1327
  const visibilityScaleBucket = (scale) => {
1237
1328
  const safeScale = Math.max(zoomRange.min, scale)
1238
1329
  if (safeScale < 0.01) return Math.round(safeScale * 300_000)
@@ -1288,11 +1379,12 @@ const selectStableSampleNodes = (sourceNodes, limit) => {
1288
1379
  }
1289
1380
 
1290
1381
  const now = performance.now()
1382
+ const cursorPoint = cursorWorldPoint()
1291
1383
  const recentZoomFocus =
1292
1384
  now - state.lastZoomFocus.at <= 1500
1293
1385
  ? { x: state.lastZoomFocus.x, y: state.lastZoomFocus.y }
1294
1386
  : null
1295
- const anchor = recentZoomFocus ?? viewportCenterWorldPoint()
1387
+ const anchor = cursorPoint ?? recentZoomFocus ?? viewportCenterWorldPoint()
1296
1388
  const previousIds = new Set(state.renderNodes.map((node) => node.id))
1297
1389
  const preferAnchorDistance = state.visibleNodes.length > massiveGraphNodeThreshold && state.transform.scale >= 0.28
1298
1390
 
@@ -1326,11 +1418,12 @@ const selectAccessBridgeNodes = (sourceNodes, limit) => {
1326
1418
  }
1327
1419
 
1328
1420
  const now = performance.now()
1421
+ const cursorPoint = cursorWorldPoint()
1329
1422
  const recentZoomFocus =
1330
1423
  now - state.lastZoomFocus.at <= 1200
1331
1424
  ? { x: state.lastZoomFocus.x, y: state.lastZoomFocus.y }
1332
1425
  : null
1333
- const anchor = recentZoomFocus ?? viewportCenterWorldPoint()
1426
+ const anchor = cursorPoint ?? recentZoomFocus ?? viewportCenterWorldPoint()
1334
1427
  return [...sourceNodes]
1335
1428
  .sort((left, right) => {
1336
1429
  const leftDistance = Math.hypot(left.x - anchor.x, left.y - anchor.y)
@@ -2408,10 +2501,7 @@ const tick = delta => {
2408
2501
 
2409
2502
  const worldPoint = event => {
2410
2503
  const rect = canvas.getBoundingClientRect()
2411
- return {
2412
- x: (event.clientX - rect.left - state.transform.x) / state.transform.scale,
2413
- y: (event.clientY - rect.top - state.transform.y) / state.transform.scale
2414
- }
2504
+ return screenToWorldPoint(event.clientX - rect.left, event.clientY - rect.top)
2415
2505
  }
2416
2506
 
2417
2507
  const connectedNodeIdsFor = (nodeId) => {
@@ -2550,22 +2640,10 @@ const clusterRadiusPx = cluster => {
2550
2640
  return 3.8
2551
2641
  }
2552
2642
  if (String(cluster.id).startsWith('ecosystem-')) {
2553
- const base = cluster.size >= 1000
2554
- ? 0.96
2555
- : cluster.size >= 500
2556
- ? 0.9
2557
- : cluster.size >= 250
2558
- ? 0.82
2559
- : cluster.size >= 125
2560
- ? 0.74
2561
- : cluster.size >= 60
2562
- ? 0.66
2563
- : cluster.size >= 30
2564
- ? 0.62
2565
- : cluster.size >= 15
2566
- ? 0.74
2567
- : 0.9
2568
- return Math.max(0.62, Math.min(2.2, base + Math.log10(cluster.count + 1) * 0.14))
2643
+ const size = Math.max(1, Math.min(ecosystemLevelNodeCap, cluster.size || cluster.count || 1))
2644
+ const sizeBias = 0.56 + Math.log10(size + 1) * 0.28
2645
+ const densityBias = Math.log10((cluster.count || 1) + 1) * 0.12
2646
+ return Math.max(0.62, Math.min(2.4, sizeBias + densityBias))
2569
2647
  }
2570
2648
  return Math.max(8, Math.min(28, 8 + Math.log2(cluster.count + 1) * 3))
2571
2649
  }
@@ -3170,8 +3248,9 @@ const zoomAtPoint = (screenX, screenY, factor, source = 'generic') => {
3170
3248
  if (nextScale === state.transform.scale) {
3171
3249
  return
3172
3250
  }
3173
- const worldX = (screenX - state.transform.x) / state.transform.scale
3174
- const worldY = (screenY - state.transform.y) / state.transform.scale
3251
+ const worldPointAtCursor = screenToWorldPoint(screenX, screenY)
3252
+ const worldX = worldPointAtCursor.x
3253
+ const worldY = worldPointAtCursor.y
3175
3254
  state.lastZoomFocus = {
3176
3255
  x: worldX,
3177
3256
  y: worldY,
@@ -3218,6 +3297,7 @@ const handleWheelZoom = event => {
3218
3297
  const rawCursorY = Number.isFinite(event.offsetY) ? event.offsetY : event.clientY - rect.top
3219
3298
  const cursorX = Math.max(0, Math.min(Math.max(rect.width, 320), rawCursorX))
3220
3299
  const cursorY = Math.max(0, Math.min(Math.max(rect.height, 320), rawCursorY))
3300
+ state.cursor = { x: event.clientX, y: event.clientY, inCanvas: true }
3221
3301
  const factor = wheelZoomFactor(event)
3222
3302
 
3223
3303
  if (!Number.isFinite(factor) || factor <= 0 || factor === 1) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andespindola/brainlink",
3
- "version": "0.1.0-beta.96",
3
+ "version": "0.1.0-beta.98",
4
4
  "description": "Local-first knowledge memory for agents with Markdown, backlinks, indexing and context retrieval.",
5
5
  "type": "module",
6
6
  "license": "MIT",