@andespindola/brainlink 0.1.0-beta.123 → 0.1.0-beta.125

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 rendering keeps the flat node scene and adds stable hierarchical mesh groups for vaults above 1000 notes, with every visible graph level capped at 1000 nodes and recursive parent groups when a level itself exceeds 1000 groups.
87
+ - Graph rendering keeps the flat node scene and adds stable hierarchical mesh groups for vaults above 1000 notes, with every visible graph level capped at 1000 nodes, the macro level projected as its own graph, and recursive parent groups when a level itself exceeds 1000 groups.
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 fitted scene and zoom-in progressively reveals local detail.
@@ -606,7 +606,7 @@ The graph UI shows:
606
606
  - graph rendering safeguards (batched canvas drawing across graph sizes, edge draw caps, lower redraw rate, zoom-aware interaction)
607
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
608
608
  - WebGL node and edge acceleration when supported, falling back to Canvas 2D without changing graph behavior
609
- - large graph LOD keeps a recursive graph-of-graphs mesh model: zoom-out shows one level capped at 1000 group nodes, zoom-in expands the framed node into its child graph capped at 1000 nodes, micro view renders only that focused subgraph, and zoom-out restores sibling groups
609
+ - large graph LOD keeps a recursive graph-of-graphs mesh model: zoom-out shows one projected graph level capped at 1000 group nodes, zoom-in expands the framed node into its child graph capped at 1000 nodes, micro view renders only that focused subgraph in a local frame anchored to the group node, and zoom-out restores sibling groups
610
610
 
611
611
  The server indexes before starting by default. Use `--no-index` to skip that step:
612
612
 
@@ -11,7 +11,6 @@ const massiveOverviewScaleThreshold = 0.065
11
11
  const massiveSegmentedScaleThreshold = 0.45
12
12
  const massiveSegmentRepresentativeBudget = 760
13
13
  const massiveAutoFitMacroScale = 0.018
14
- const hierarchyGroupScaleThreshold = 0.62
15
14
  const hierarchyExpansionStartScale = 0.18
16
15
  const hierarchyMicroEnterCoverage = 0.72
17
16
  const hierarchyMicroExitCoverage = 0.52
@@ -1213,6 +1212,11 @@ const groupRenderRadius = group => {
1213
1212
  return 10 + Math.min(Math.log2(childCount + 1) * 4.2, 22)
1214
1213
  }
1215
1214
 
1215
+ const childGraphRenderRadius = group => {
1216
+ const childCount = Math.max(group.nodeIds.length, group.childGroupIds.length, 1)
1217
+ return Math.max(420, Math.min(1800, Math.sqrt(childCount) * 24))
1218
+ }
1219
+
1216
1220
  const createGroupRenderNode = group => ({
1217
1221
  id: groupRenderNodeId(group),
1218
1222
  groupId: group.id,
@@ -1229,13 +1233,89 @@ const createGroupRenderNode = group => ({
1229
1233
  radius: groupRenderRadius(group)
1230
1234
  })
1231
1235
 
1232
- const interpolateNodeFromGroup = (node, group, progress) => ({
1233
- ...node,
1234
- x: group.x + (node.x - group.x) * progress,
1235
- y: group.y + (node.y - group.y) * progress,
1236
- vx: 0,
1237
- vy: 0
1238
- })
1236
+ const arrangeGraphLevelNodes = (nodes, radiusForNode = () => 1) => {
1237
+ if (nodes.length <= 1) {
1238
+ return nodes
1239
+ }
1240
+
1241
+ const centerNode = nodes
1242
+ .map(node => ({
1243
+ node,
1244
+ score: Math.max(node.nodeIds?.length ?? 0, node.childGroupIds?.length ?? 0, 1) + (node.externalEdges?.length ?? 0)
1245
+ }))
1246
+ .sort((left, right) => right.score - left.score || left.node.title.localeCompare(right.node.title))[0]?.node
1247
+ const outerNodes = nodes
1248
+ .filter(node => node.id !== centerNode?.id)
1249
+ .sort((left, right) => left.segment.localeCompare(right.segment) || left.title.localeCompare(right.title))
1250
+ const baseRadius = Math.max(520, Math.min(2200, Math.sqrt(nodes.length) * 135))
1251
+ const goldenAngle = Math.PI * (3 - Math.sqrt(5))
1252
+ const arranged = centerNode
1253
+ ? [{ ...centerNode, x: 0, y: 0, radius: radiusForNode(centerNode) }]
1254
+ : []
1255
+
1256
+ outerNodes.forEach((node, index) => {
1257
+ const ringRadius = baseRadius * Math.sqrt((index + 1) / Math.max(outerNodes.length, 1))
1258
+ const angle = index * goldenAngle
1259
+ arranged.push({
1260
+ ...node,
1261
+ x: Math.cos(angle) * ringRadius,
1262
+ y: Math.sin(angle) * ringRadius,
1263
+ radius: radiusForNode(node)
1264
+ })
1265
+ })
1266
+
1267
+ return arranged
1268
+ }
1269
+
1270
+ const localGraphBounds = nodes => {
1271
+ if (nodes.length === 0) {
1272
+ return { centerX: 0, centerY: 0, radius: 1 }
1273
+ }
1274
+
1275
+ const bounds = nodes.reduce(
1276
+ (current, node) => ({
1277
+ minX: Math.min(current.minX, node.x),
1278
+ maxX: Math.max(current.maxX, node.x),
1279
+ minY: Math.min(current.minY, node.y),
1280
+ maxY: Math.max(current.maxY, node.y)
1281
+ }),
1282
+ {
1283
+ minX: Number.POSITIVE_INFINITY,
1284
+ maxX: Number.NEGATIVE_INFINITY,
1285
+ minY: Number.POSITIVE_INFINITY,
1286
+ maxY: Number.NEGATIVE_INFINITY
1287
+ }
1288
+ )
1289
+ const centerX = (bounds.minX + bounds.maxX) / 2
1290
+ const centerY = (bounds.minY + bounds.maxY) / 2
1291
+ const radius = nodes.reduce(
1292
+ (largest, node) => Math.max(largest, Math.hypot(node.x - centerX, node.y - centerY)),
1293
+ 1
1294
+ )
1295
+
1296
+ return { centerX, centerY, radius }
1297
+ }
1298
+
1299
+ const projectNodeIntoGroupGraph = (node, group, bounds) => {
1300
+ const targetRadius = childGraphRenderRadius(group)
1301
+ const scale = targetRadius / Math.max(bounds.radius, 1)
1302
+
1303
+ return {
1304
+ x: group.x + (node.x - bounds.centerX) * scale,
1305
+ y: group.y + (node.y - bounds.centerY) * scale
1306
+ }
1307
+ }
1308
+
1309
+ const interpolateNodeFromGroup = (node, group, bounds, progress) => {
1310
+ const projected = projectNodeIntoGroupGraph(node, group, bounds)
1311
+ return {
1312
+ ...node,
1313
+ x: group.x + (projected.x - group.x) * progress,
1314
+ y: group.y + (projected.y - group.y) * progress,
1315
+ vx: 0,
1316
+ vy: 0
1317
+ }
1318
+ }
1239
1319
 
1240
1320
  const parentHierarchyGroups = () =>
1241
1321
  state.groups.filter(group => group.parentId === null)
@@ -1245,9 +1325,9 @@ const hierarchyGroupsForScale = () => {
1245
1325
  return []
1246
1326
  }
1247
1327
  if (state.transform.scale < hierarchyExpansionStartScale) {
1248
- return parentHierarchyGroups()
1328
+ return arrangeGraphLevelNodes(parentHierarchyGroups(), groupRenderRadius)
1249
1329
  }
1250
- return state.leafGroups
1330
+ return arrangeGraphLevelNodes(state.leafGroups, groupRenderRadius)
1251
1331
  }
1252
1332
 
1253
1333
  const groupViewportCoverage = (group, viewport) => {
@@ -1257,7 +1337,7 @@ const groupViewportCoverage = (group, viewport) => {
1257
1337
  const centerX = (viewport.minX + viewport.maxX) / 2
1258
1338
  const centerY = (viewport.minY + viewport.maxY) / 2
1259
1339
  const centerDistance = Math.hypot(group.x - centerX, group.y - centerY)
1260
- const fitCoverage = Math.min(1, group.radius / Math.max(viewportRadius, 1))
1340
+ const fitCoverage = Math.min(1, childGraphRenderRadius(group) / Math.max(viewportRadius, 1))
1261
1341
  const centerCoverage = 1 - Math.min(1, centerDistance / Math.max(viewportRadius, 1))
1262
1342
 
1263
1343
  return fitCoverage * 0.72 + centerCoverage * 0.28
@@ -1269,7 +1349,9 @@ const groupWithCoverage = (group, viewport) => ({
1269
1349
  })
1270
1350
 
1271
1351
  const updateHierarchyFocusGroup = (groups, viewport) => {
1272
- const current = state.hierarchyFocusGroupId ? state.groupById.get(state.hierarchyFocusGroupId) : null
1352
+ const current = state.hierarchyFocusGroupId
1353
+ ? groups.find(group => group.id === state.hierarchyFocusGroupId) ?? null
1354
+ : null
1273
1355
  const currentCoverage = current ? groupViewportCoverage(current, viewport) : 0
1274
1356
 
1275
1357
  if (
@@ -1352,7 +1434,7 @@ const groupEdgesForRenderedGroups = (groupNodes) => {
1352
1434
  }
1353
1435
 
1354
1436
  const computeHierarchyRenderVisibility = (viewport) => {
1355
- if (state.groups.length === 0 || state.visibleNodes.length <= 1000 || state.transform.scale >= hierarchyGroupScaleThreshold) {
1437
+ if (state.groups.length === 0 || state.visibleNodes.length <= 1000) {
1356
1438
  state.hierarchyFocusGroupId = null
1357
1439
  return false
1358
1440
  }
@@ -1376,11 +1458,13 @@ const computeHierarchyRenderVisibility = (viewport) => {
1376
1458
  }
1377
1459
 
1378
1460
  const focusIds = new Set(focus.nodeIds)
1461
+ const rawChildNodes = state.visibleNodes.filter(node => focusIds.has(node.id))
1462
+ const childBounds = localGraphBounds(rawChildNodes)
1379
1463
  const childLimit = Math.max(160, Math.min(zoomedMassiveRenderNodeBudget, Math.floor(renderNodeBudget * progress * 2.4)))
1380
1464
  const childNodes = selectStableSampleNodes(
1381
- state.visibleNodes.filter(node => focusIds.has(node.id)),
1465
+ rawChildNodes,
1382
1466
  childLimit
1383
- ).map(node => interpolateNodeFromGroup(node, focus, progress))
1467
+ ).map(node => interpolateNodeFromGroup(node, focus, childBounds, progress))
1384
1468
  const childIds = new Set(childNodes.map(node => node.id))
1385
1469
  const childById = new Map(childNodes.map(node => [node.id, node]))
1386
1470
  const isMicroView = progress >= 0.72
@@ -1925,7 +2009,11 @@ const fitView = (options = { useFiltered: true, preferHubCenter: true }) => {
1925
2009
  const width = Math.max(rect.width, 320)
1926
2010
  const height = Math.max(rect.height, 320)
1927
2011
  const nodes = options.useFiltered ? filteredNodes() : state.nodes
1928
- const bounds = graphBounds(nodes)
2012
+ const hierarchyFitNodes = state.groups.length > 0 && nodes.length > 1000
2013
+ ? arrangeGraphLevelNodes(parentHierarchyGroups(), groupRenderRadius).map(createGroupRenderNode)
2014
+ : null
2015
+ const fitNodes = hierarchyFitNodes ?? nodes
2016
+ const bounds = graphBounds(fitNodes)
1929
2017
 
1930
2018
  if (!bounds) {
1931
2019
  state.transform = { x: width / 2, y: height / 2, scale: 1 }
@@ -1945,17 +2033,20 @@ const fitView = (options = { useFiltered: true, preferHubCenter: true }) => {
1945
2033
  if (nodeCount <= 2000) return 140
1946
2034
  return 180
1947
2035
  }
1948
- const padding = paddingByNodeCount(nodes.length)
2036
+ const padding = paddingByNodeCount(fitNodes.length)
1949
2037
  const scaleX = width / (bounds.width + padding * 2)
1950
2038
  const scaleY = height / (bounds.height + padding * 2)
1951
2039
  const fitScale = Math.min(scaleX, scaleY)
1952
- const biasedScale = clampScale(fitScale * fitScaleBiasByNodeCount(nodes.length))
1953
- const scaleRange = autoFitScaleRangeByNodeCount(nodes.length)
2040
+ const biasedScale = clampScale(fitScale * fitScaleBiasByNodeCount(fitNodes.length))
2041
+ const scaleRange = autoFitScaleRangeByNodeCount(fitNodes.length)
1954
2042
  const baselineScale = clampScale(Math.min(scaleRange.max, Math.max(scaleRange.min, biasedScale)))
1955
- const resolvedScale = nodes.length > massiveGraphNodeThreshold
2043
+ const resolvedScale = nodes.length > massiveGraphNodeThreshold && !hierarchyFitNodes
1956
2044
  ? clampScale(Math.min(baselineScale, massiveAutoFitMacroScale))
1957
2045
  : baselineScale
1958
2046
  const hubCenter =
2047
+ hierarchyFitNodes
2048
+ ? null
2049
+ :
1959
2050
  options.preferHubCenter && isDominantHub(state.primaryHub, nodes.length) && nodes.some((node) => node.id === state.primaryHub.id)
1960
2051
  ? state.primaryHub
1961
2052
  : null
@@ -16,10 +16,48 @@ const groupCoverage = (group, input) => {
16
16
  const centerX = input.x + input.width / 2;
17
17
  const centerY = input.y + input.height / 2;
18
18
  const centerDistance = Math.hypot(group.x - centerX, group.y - centerY);
19
- const fitCoverage = Math.min(1, group.radius / Math.max(viewportRadius, 1));
19
+ const fitCoverage = Math.min(1, childGraphRenderRadius(group) / Math.max(viewportRadius, 1));
20
20
  const centerCoverage = 1 - Math.min(1, centerDistance / Math.max(viewportRadius, 1));
21
21
  return fitCoverage * 0.72 + centerCoverage * 0.28;
22
22
  };
23
+ const childGraphRenderRadius = (group) => {
24
+ const childCount = Math.max(group.nodeIds.length, group.childGroupIds.length, 1);
25
+ return Math.max(420, Math.min(1800, Math.sqrt(childCount) * 24));
26
+ };
27
+ const groupRenderRadius = (group) => {
28
+ const childCount = Math.max(group.nodeIds.length, group.childGroupIds.length, 1);
29
+ return 10 + Math.min(Math.log2(childCount + 1) * 4.2, 22);
30
+ };
31
+ const arrangeGraphLevelGroups = (groups) => {
32
+ if (groups.length <= 1) {
33
+ return groups.map((group) => ({ ...group, radius: groupRenderRadius(group) }));
34
+ }
35
+ const centerGroup = groups
36
+ .map((group) => ({
37
+ group,
38
+ score: Math.max(group.nodeIds.length, group.childGroupIds.length, 1) + group.externalEdges.length
39
+ }))
40
+ .sort((left, right) => right.score - left.score || left.group.title.localeCompare(right.group.title))[0]?.group;
41
+ const outerGroups = groups
42
+ .filter((group) => group.id !== centerGroup?.id)
43
+ .sort((left, right) => left.segment.localeCompare(right.segment) || left.title.localeCompare(right.title));
44
+ const baseRadius = Math.max(520, Math.min(2200, Math.sqrt(groups.length) * 135));
45
+ const goldenAngle = Math.PI * (3 - Math.sqrt(5));
46
+ const arranged = centerGroup
47
+ ? [{ ...centerGroup, x: 0, y: 0, radius: groupRenderRadius(centerGroup) }]
48
+ : [];
49
+ outerGroups.forEach((group, index) => {
50
+ const ringRadius = baseRadius * Math.sqrt((index + 1) / Math.max(outerGroups.length, 1));
51
+ const angle = index * goldenAngle;
52
+ arranged.push({
53
+ ...group,
54
+ x: Math.cos(angle) * ringRadius,
55
+ y: Math.sin(angle) * ringRadius,
56
+ radius: groupRenderRadius(group)
57
+ });
58
+ });
59
+ return arranged;
60
+ };
23
61
  const groupNode = (group) => [
24
62
  `group:${group.id}`,
25
63
  group.title,
@@ -38,6 +76,34 @@ const realNode = (node) => [
38
76
  node.segment,
39
77
  'node'
40
78
  ];
79
+ const graphBounds = (nodes) => {
80
+ if (nodes.length === 0) {
81
+ return { centerX: 0, centerY: 0, radius: 1 };
82
+ }
83
+ const bounds = nodes.reduce((current, node) => ({
84
+ minX: Math.min(current.minX, node.x),
85
+ maxX: Math.max(current.maxX, node.x),
86
+ minY: Math.min(current.minY, node.y),
87
+ maxY: Math.max(current.maxY, node.y)
88
+ }), {
89
+ minX: Number.POSITIVE_INFINITY,
90
+ maxX: Number.NEGATIVE_INFINITY,
91
+ minY: Number.POSITIVE_INFINITY,
92
+ maxY: Number.NEGATIVE_INFINITY
93
+ });
94
+ const centerX = (bounds.minX + bounds.maxX) / 2;
95
+ const centerY = (bounds.minY + bounds.maxY) / 2;
96
+ const radius = nodes.reduce((largest, node) => Math.max(largest, Math.hypot(node.x - centerX, node.y - centerY)), 1);
97
+ return { centerX, centerY, radius };
98
+ };
99
+ const projectNodeIntoGroupGraph = (node, group, bounds) => {
100
+ const scale = childGraphRenderRadius(group) / Math.max(bounds.radius, 1);
101
+ return {
102
+ ...node,
103
+ x: group.x + (node.x - bounds.centerX) * scale,
104
+ y: group.y + (node.y - bounds.centerY) * scale
105
+ };
106
+ };
41
107
  const descendants = (group, groupById) => group.nodeIds.length > 0
42
108
  ? group.nodeIds
43
109
  : group.childGroupIds.flatMap((childId) => {
@@ -113,16 +179,27 @@ export const getGraphView = async (vaultPath, input) => {
113
179
  }
114
180
  };
115
181
  }
116
- const visibleGroups = groups.filter((group) => group.parentId === null && inViewport(group, input));
117
- const focused = groups
182
+ const rootGroups = arrangeGraphLevelGroups(groups.filter((group) => group.parentId === null));
183
+ const leafGroups = arrangeGraphLevelGroups(groups.filter((group) => group.nodeIds.length > 0));
184
+ const visibleGroups = rootGroups.filter((group) => inViewport(group, input));
185
+ const focused = leafGroups
118
186
  .filter((group) => group.nodeIds.length > 0 && inViewport(group, input))
119
187
  .map((group) => ({ group, coverage: groupCoverage(group, input) }))
120
188
  .sort((left, right) => right.coverage - left.coverage)[0];
121
189
  if (focused && input.scale >= macroScale && focused.coverage >= microCoverage) {
122
190
  const nodeIds = new Set(focused.group.nodeIds);
123
- const nodes = layout.nodes
124
- .filter((node) => nodeIds.has(node.id) && inViewport(node, input))
191
+ const projectedNodes = layout.nodes
192
+ .filter((node) => nodeIds.has(node.id));
193
+ const bounds = graphBounds(projectedNodes);
194
+ const nodesInViewport = projectedNodes
195
+ .map((node) => projectNodeIntoGroupGraph(node, focused.group, bounds))
196
+ .filter((node) => inViewport(node, input))
125
197
  .slice(0, nodeLimit);
198
+ const nodes = nodesInViewport.length > 0
199
+ ? nodesInViewport
200
+ : projectedNodes
201
+ .slice(0, nodeLimit)
202
+ .map((node) => projectNodeIntoGroupGraph(node, focused.group, bounds));
126
203
  const visibleNodeIds = new Set(nodes.map((node) => node.id));
127
204
  const viewNodes = nodes.map(realNode);
128
205
  return {
@@ -136,7 +213,7 @@ export const getGraphView = async (vaultPath, input) => {
136
213
  }
137
214
  };
138
215
  }
139
- const groupsToRender = (visibleGroups.length > 0 ? visibleGroups : groups.filter((group) => group.parentId === null)).slice(0, nodeLimit);
216
+ const groupsToRender = (visibleGroups.length > 0 ? visibleGroups : rootGroups).slice(0, nodeLimit);
140
217
  const viewNodes = groupsToRender.map(groupNode);
141
218
  return {
142
219
  signature,
@@ -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 mesh groups of up to 1000 direct nodes, with recursive parent groups when a level exceeds 1000 groups; zoom-out renders those groups as normal mesh graph nodes, zoom-in expands a group only after it is framed in the viewport, and micro view renders only the focused subgraph until zoom-out restores sibling groups.
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 mesh groups of up to 1000 direct nodes, with recursive parent groups when a level exceeds 1000 groups; zoom-out renders the macro level as a projected mesh graph of group nodes, zoom-in expands a group only after it is framed in the viewport, and micro view renders only the focused subgraph in a local frame anchored to the group node until zoom-out restores sibling groups.
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.123",
3
+ "version": "0.1.0-beta.125",
4
4
  "description": "Local-first knowledge memory for agents with Markdown, backlinks, indexing and context retrieval.",
5
5
  "type": "module",
6
6
  "license": "MIT",