@andespindola/brainlink 0.1.0-beta.122 → 0.1.0-beta.124

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,11 +84,11 @@ 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 groups for vaults above 1000 notes, with 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 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.
91
- - Zoomed-out graph LOD renders hierarchy groups as normal graph nodes and expands a group only after it is framed in the viewport, progressively hiding sibling groups in micro view.
91
+ - Zoomed-out graph LOD renders hierarchy groups as normal mesh graph nodes and expands a group only after it is framed in the viewport, progressively hiding sibling groups in micro view.
92
92
  - Graph reset fits the full graph scene instead of starting in a separate macro overview mode.
93
93
  - Graph filtering runs in a dedicated browser worker to keep the UI thread responsive during heavy datasets.
94
94
  - Edge rendering budgets adapt to zoom level to prevent frame spikes on large graph panoramas.
@@ -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 model: zoom-out shows one level of group nodes, zoom-in expands the framed node into its child graph, 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 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
 
@@ -4,14 +4,13 @@ const ctx = canvas.getContext('2d')
4
4
  const largeGraphNodeThreshold = 4000
5
5
  const massiveGraphNodeThreshold = 20000
6
6
  const largeGraphEdgeRenderLimit = 120000
7
- const renderNodeBudget = 900
7
+ const renderNodeBudget = 1000
8
8
  const zoomedMassiveRenderNodeBudget = 2200
9
9
  const massiveOverviewRenderNodeBudget = 1800
10
10
  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,55 @@ 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 localGraphBounds = nodes => {
1237
+ if (nodes.length === 0) {
1238
+ return { centerX: 0, centerY: 0, radius: 1 }
1239
+ }
1240
+
1241
+ const bounds = nodes.reduce(
1242
+ (current, node) => ({
1243
+ minX: Math.min(current.minX, node.x),
1244
+ maxX: Math.max(current.maxX, node.x),
1245
+ minY: Math.min(current.minY, node.y),
1246
+ maxY: Math.max(current.maxY, node.y)
1247
+ }),
1248
+ {
1249
+ minX: Number.POSITIVE_INFINITY,
1250
+ maxX: Number.NEGATIVE_INFINITY,
1251
+ minY: Number.POSITIVE_INFINITY,
1252
+ maxY: Number.NEGATIVE_INFINITY
1253
+ }
1254
+ )
1255
+ const centerX = (bounds.minX + bounds.maxX) / 2
1256
+ const centerY = (bounds.minY + bounds.maxY) / 2
1257
+ const radius = nodes.reduce(
1258
+ (largest, node) => Math.max(largest, Math.hypot(node.x - centerX, node.y - centerY)),
1259
+ 1
1260
+ )
1261
+
1262
+ return { centerX, centerY, radius }
1263
+ }
1264
+
1265
+ const projectNodeIntoGroupGraph = (node, group, bounds) => {
1266
+ const targetRadius = childGraphRenderRadius(group)
1267
+ const scale = targetRadius / Math.max(bounds.radius, 1)
1268
+
1269
+ return {
1270
+ x: group.x + (node.x - bounds.centerX) * scale,
1271
+ y: group.y + (node.y - bounds.centerY) * scale
1272
+ }
1273
+ }
1274
+
1275
+ const interpolateNodeFromGroup = (node, group, bounds, progress) => {
1276
+ const projected = projectNodeIntoGroupGraph(node, group, bounds)
1277
+ return {
1278
+ ...node,
1279
+ x: group.x + (projected.x - group.x) * progress,
1280
+ y: group.y + (projected.y - group.y) * progress,
1281
+ vx: 0,
1282
+ vy: 0
1283
+ }
1284
+ }
1239
1285
 
1240
1286
  const parentHierarchyGroups = () =>
1241
1287
  state.groups.filter(group => group.parentId === null)
@@ -1257,7 +1303,7 @@ const groupViewportCoverage = (group, viewport) => {
1257
1303
  const centerX = (viewport.minX + viewport.maxX) / 2
1258
1304
  const centerY = (viewport.minY + viewport.maxY) / 2
1259
1305
  const centerDistance = Math.hypot(group.x - centerX, group.y - centerY)
1260
- const fitCoverage = Math.min(1, group.radius / Math.max(viewportRadius, 1))
1306
+ const fitCoverage = Math.min(1, childGraphRenderRadius(group) / Math.max(viewportRadius, 1))
1261
1307
  const centerCoverage = 1 - Math.min(1, centerDistance / Math.max(viewportRadius, 1))
1262
1308
 
1263
1309
  return fitCoverage * 0.72 + centerCoverage * 0.28
@@ -1352,7 +1398,7 @@ const groupEdgesForRenderedGroups = (groupNodes) => {
1352
1398
  }
1353
1399
 
1354
1400
  const computeHierarchyRenderVisibility = (viewport) => {
1355
- if (state.groups.length === 0 || state.visibleNodes.length <= 1000 || state.transform.scale >= hierarchyGroupScaleThreshold) {
1401
+ if (state.groups.length === 0 || state.visibleNodes.length <= 1000) {
1356
1402
  state.hierarchyFocusGroupId = null
1357
1403
  return false
1358
1404
  }
@@ -1376,11 +1422,13 @@ const computeHierarchyRenderVisibility = (viewport) => {
1376
1422
  }
1377
1423
 
1378
1424
  const focusIds = new Set(focus.nodeIds)
1425
+ const rawChildNodes = state.visibleNodes.filter(node => focusIds.has(node.id))
1426
+ const childBounds = localGraphBounds(rawChildNodes)
1379
1427
  const childLimit = Math.max(160, Math.min(zoomedMassiveRenderNodeBudget, Math.floor(renderNodeBudget * progress * 2.4)))
1380
1428
  const childNodes = selectStableSampleNodes(
1381
- state.visibleNodes.filter(node => focusIds.has(node.id)),
1429
+ rawChildNodes,
1382
1430
  childLimit
1383
- ).map(node => interpolateNodeFromGroup(node, focus, progress))
1431
+ ).map(node => interpolateNodeFromGroup(node, focus, childBounds, progress))
1384
1432
  const childIds = new Set(childNodes.map(node => node.id))
1385
1433
  const childById = new Map(childNodes.map(node => [node.id, node]))
1386
1434
  const isMicroView = progress >= 0.72
@@ -1509,7 +1557,8 @@ const buildMeshEdgesForNodes = (nodes, existingEdges) => {
1509
1557
  }
1510
1558
 
1511
1559
  const withMeshEdges = (nodes, edges) => {
1512
- if (nodes.length === 0 || state.visibleNodes.length <= largeGraphNodeThreshold || state.transform.scale < meshEdgeScaleThreshold) {
1560
+ const isHierarchyGraphLevel = state.groups.length > 0 && (state.visibleNodes.length > 1000 || state.hierarchyFocusGroupId)
1561
+ if (nodes.length === 0 || !isHierarchyGraphLevel || state.transform.scale < meshEdgeScaleThreshold) {
1513
1562
  return edges
1514
1563
  }
1515
1564
 
@@ -1,8 +1,9 @@
1
1
  import { getGraphLayout } from './get-graph-layout.js';
2
2
  const macroScale = 0.24;
3
3
  const microCoverage = 0.72;
4
- const nodeLimit = 1200;
4
+ const nodeLimit = 1000;
5
5
  const edgeLimit = 5000;
6
+ const meshEdgeLimit = 2500;
6
7
  const inViewport = (item, input) => {
7
8
  const radius = item.radius ?? 48;
8
9
  return (item.x + radius >= input.x &&
@@ -15,10 +16,14 @@ const groupCoverage = (group, input) => {
15
16
  const centerX = input.x + input.width / 2;
16
17
  const centerY = input.y + input.height / 2;
17
18
  const centerDistance = Math.hypot(group.x - centerX, group.y - centerY);
18
- const fitCoverage = Math.min(1, group.radius / Math.max(viewportRadius, 1));
19
+ const fitCoverage = Math.min(1, childGraphRenderRadius(group) / Math.max(viewportRadius, 1));
19
20
  const centerCoverage = 1 - Math.min(1, centerDistance / Math.max(viewportRadius, 1));
20
21
  return fitCoverage * 0.72 + centerCoverage * 0.28;
21
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
+ };
22
27
  const groupNode = (group) => [
23
28
  `group:${group.id}`,
24
29
  group.title,
@@ -37,6 +42,34 @@ const realNode = (node) => [
37
42
  node.segment,
38
43
  'node'
39
44
  ];
45
+ const graphBounds = (nodes) => {
46
+ if (nodes.length === 0) {
47
+ return { centerX: 0, centerY: 0, radius: 1 };
48
+ }
49
+ const bounds = nodes.reduce((current, node) => ({
50
+ minX: Math.min(current.minX, node.x),
51
+ maxX: Math.max(current.maxX, node.x),
52
+ minY: Math.min(current.minY, node.y),
53
+ maxY: Math.max(current.maxY, node.y)
54
+ }), {
55
+ minX: Number.POSITIVE_INFINITY,
56
+ maxX: Number.NEGATIVE_INFINITY,
57
+ minY: Number.POSITIVE_INFINITY,
58
+ maxY: Number.NEGATIVE_INFINITY
59
+ });
60
+ const centerX = (bounds.minX + bounds.maxX) / 2;
61
+ const centerY = (bounds.minY + bounds.maxY) / 2;
62
+ const radius = nodes.reduce((largest, node) => Math.max(largest, Math.hypot(node.x - centerX, node.y - centerY)), 1);
63
+ return { centerX, centerY, radius };
64
+ };
65
+ const projectNodeIntoGroupGraph = (node, group, bounds) => {
66
+ const scale = childGraphRenderRadius(group) / Math.max(bounds.radius, 1);
67
+ return {
68
+ ...node,
69
+ x: group.x + (node.x - bounds.centerX) * scale,
70
+ y: group.y + (node.y - bounds.centerY) * scale
71
+ };
72
+ };
40
73
  const descendants = (group, groupById) => group.nodeIds.length > 0
41
74
  ? group.nodeIds
42
75
  : group.childGroupIds.flatMap((childId) => {
@@ -68,6 +101,31 @@ const realEdges = (edges, nodeIds) => edges
68
101
  .filter((edge) => Boolean(edge.target && nodeIds.has(edge.source) && nodeIds.has(edge.target)))
69
102
  .slice(0, edgeLimit)
70
103
  .map((edge) => [edge.source, edge.target, edge.weight, edge.priority]);
104
+ const edgePairKey = (left, right) => left < right ? `${left}|${right}` : `${right}|${left}`;
105
+ const meshEdges = (nodes, existingEdges) => {
106
+ if (nodes.length < 2) {
107
+ return [];
108
+ }
109
+ const existing = new Set(existingEdges.map((edge) => edgePairKey(edge[0], edge[1])));
110
+ const selected = [];
111
+ const selectedKeys = new Set();
112
+ const maxNeighbors = nodes.length > 500 ? 2 : 3;
113
+ const byX = [...nodes].sort((left, right) => left[2] - right[2] || left[3] - right[3] || left[0].localeCompare(right[0]));
114
+ for (let index = 0; index < nodes.length && selected.length < meshEdgeLimit; index += 1) {
115
+ const node = byX[index];
116
+ const candidates = byX.slice(index + 1, index + 1 + maxNeighbors);
117
+ candidates.forEach((candidate) => {
118
+ const key = edgePairKey(node[0], candidate[0]);
119
+ if (existing.has(key) || selectedKeys.has(key) || selected.length >= meshEdgeLimit) {
120
+ return;
121
+ }
122
+ selectedKeys.add(key);
123
+ selected.push([node[0], candidate[0], 1, 'low']);
124
+ });
125
+ }
126
+ return selected;
127
+ };
128
+ const withMeshEdges = (nodes, edges) => [...edges, ...meshEdges(nodes, edges)].slice(0, edgeLimit);
71
129
  export const getGraphView = async (vaultPath, input) => {
72
130
  const { signature, layout } = await getGraphLayout(vaultPath, input.agentId);
73
131
  const groups = layout.groups ?? [];
@@ -75,11 +133,12 @@ export const getGraphView = async (vaultPath, input) => {
75
133
  if (groups.length === 0) {
76
134
  const nodes = layout.nodes.filter((node) => inViewport(node, input)).slice(0, nodeLimit);
77
135
  const nodeIds = new Set(nodes.map((node) => node.id));
136
+ const viewNodes = nodes.map(realNode);
78
137
  return {
79
138
  signature,
80
139
  mode: 'flat',
81
- nodes: nodes.map(realNode),
82
- edges: realEdges(layout.edges, nodeIds),
140
+ nodes: viewNodes,
141
+ edges: withMeshEdges(viewNodes, realEdges(layout.edges, nodeIds)),
83
142
  totals: {
84
143
  nodes: layout.nodes.length,
85
144
  edges: layout.edges.length
@@ -93,15 +152,25 @@ export const getGraphView = async (vaultPath, input) => {
93
152
  .sort((left, right) => right.coverage - left.coverage)[0];
94
153
  if (focused && input.scale >= macroScale && focused.coverage >= microCoverage) {
95
154
  const nodeIds = new Set(focused.group.nodeIds);
96
- const nodes = layout.nodes
97
- .filter((node) => nodeIds.has(node.id) && inViewport(node, input))
155
+ const projectedNodes = layout.nodes
156
+ .filter((node) => nodeIds.has(node.id));
157
+ const bounds = graphBounds(projectedNodes);
158
+ const nodesInViewport = projectedNodes
159
+ .map((node) => projectNodeIntoGroupGraph(node, focused.group, bounds))
160
+ .filter((node) => inViewport(node, input))
98
161
  .slice(0, nodeLimit);
162
+ const nodes = nodesInViewport.length > 0
163
+ ? nodesInViewport
164
+ : projectedNodes
165
+ .slice(0, nodeLimit)
166
+ .map((node) => projectNodeIntoGroupGraph(node, focused.group, bounds));
99
167
  const visibleNodeIds = new Set(nodes.map((node) => node.id));
168
+ const viewNodes = nodes.map(realNode);
100
169
  return {
101
170
  signature,
102
171
  mode: 'micro',
103
- nodes: nodes.map(realNode),
104
- edges: realEdges(layout.edges, visibleNodeIds),
172
+ nodes: viewNodes,
173
+ edges: withMeshEdges(viewNodes, realEdges(layout.edges, visibleNodeIds)),
105
174
  totals: {
106
175
  nodes: layout.nodes.length,
107
176
  edges: layout.edges.length
@@ -109,11 +178,12 @@ export const getGraphView = async (vaultPath, input) => {
109
178
  };
110
179
  }
111
180
  const groupsToRender = (visibleGroups.length > 0 ? visibleGroups : groups.filter((group) => group.parentId === null)).slice(0, nodeLimit);
181
+ const viewNodes = groupsToRender.map(groupNode);
112
182
  return {
113
183
  signature,
114
184
  mode: 'macro',
115
- nodes: groupsToRender.map(groupNode),
116
- edges: aggregateGroupEdges(groupsToRender, layout.edges, groupById),
185
+ nodes: viewNodes,
186
+ edges: withMeshEdges(viewNodes, aggregateGroupEdges(groupsToRender, layout.edges, groupById)),
117
187
  totals: {
118
188
  nodes: layout.nodes.length,
119
189
  edges: layout.edges.length
@@ -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 groups of up to 1000 direct nodes, with recursive parent groups when a level exceeds 1000 groups; zoom-out renders those groups as normal 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 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 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.122",
3
+ "version": "0.1.0-beta.124",
4
4
  "description": "Local-first knowledge memory for agents with Markdown, backlinks, indexing and context retrieval.",
5
5
  "type": "module",
6
6
  "license": "MIT",