@andespindola/brainlink 0.1.0-beta.128 → 0.1.0-beta.129

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
@@ -88,7 +88,7 @@ Legacy `.jsonl.gz` packs are upgraded to `.blpk` automatically on first search/c
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 mesh 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 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.
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 mesh model: zoom-out fills the projected macro level toward 1000 lightweight group nodes, zoom-in expands the framed node from its current viewport position into its own radial child graph capped at 1000 nodes, micro view renders only that focused subgraph with dense-node label suppression in a local frame anchored to the rendered group node, and zoom-out restores sibling groups
609
+ - large graph LOD keeps a recursive graph-of-graphs model: zoom-out fills the projected macro level toward 1000 lightweight group nodes with sparse strongest-link edges, zoom-in expands the framed node from its current viewport position into its own radial child graph capped at 1000 nodes, micro view renders only that focused subgraph with dense-node label suppression in a local frame anchored to the rendered 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
 
@@ -22,9 +22,7 @@ const worldCoordinateLimit = 5_000_000
22
22
  const transformCoordinateLimit = 20_000_000
23
23
  const hoverHitTestIntervalMs = 64
24
24
  const zoomRecoveryGuardMs = 4200
25
- const meshEdgeScaleThreshold = 0.09
26
- const meshEdgeMinBudget = 90
27
- const meshEdgeMaxBudget = 850
25
+ const hierarchyGroupEdgeLimit = 900
28
26
  const dragNeighborhoodMaxAffected = 180
29
27
  const dragSettleRounds = 3
30
28
  const wheelZoomExponent = 0.0009
@@ -1469,7 +1467,20 @@ const groupEdgesForRenderedGroups = (groupNodes) => {
1469
1467
  })
1470
1468
  }
1471
1469
 
1472
- return Array.from(selected.values()).slice(0, edgeBudgetForCurrentFrame())
1470
+ const degreeCounts = new Map()
1471
+ return Array.from(selected.values())
1472
+ .sort((left, right) => edgeWeight(right) - edgeWeight(left) || left.source.localeCompare(right.source) || left.target.localeCompare(right.target))
1473
+ .filter((edge) => {
1474
+ const sourceCount = degreeCounts.get(edge.source) ?? 0
1475
+ const targetCount = degreeCounts.get(edge.target) ?? 0
1476
+ if (sourceCount >= 3 || targetCount >= 3) {
1477
+ return false
1478
+ }
1479
+ degreeCounts.set(edge.source, sourceCount + 1)
1480
+ degreeCounts.set(edge.target, targetCount + 1)
1481
+ return true
1482
+ })
1483
+ .slice(0, Math.min(hierarchyGroupEdgeLimit, edgeBudgetForCurrentFrame()))
1473
1484
  }
1474
1485
 
1475
1486
  const computeHierarchyRenderVisibility = (viewport) => {
@@ -1517,121 +1528,8 @@ const computeHierarchyRenderVisibility = (viewport) => {
1517
1528
  return true
1518
1529
  }
1519
1530
 
1520
- const edgePairKey = (source, target) =>
1521
- source < target ? source + '|' + target : target + '|' + source
1522
-
1523
- const meshNeighborBuckets = (nodes, cellSize) => {
1524
- const buckets = new Map()
1525
-
1526
- for (let index = 0; index < nodes.length; index += 1) {
1527
- const node = nodes[index]
1528
- const cellX = Math.floor(node.x / cellSize)
1529
- const cellY = Math.floor(node.y / cellSize)
1530
- const key = cellX + ':' + cellY
1531
- const bucket = buckets.get(key)
1532
- if (bucket) {
1533
- bucket.push(node)
1534
- } else {
1535
- buckets.set(key, [node])
1536
- }
1537
- }
1538
-
1539
- return buckets
1540
- }
1541
-
1542
- const meshCandidatesForNode = (node, buckets, cellSize) => {
1543
- const cellX = Math.floor(node.x / cellSize)
1544
- const cellY = Math.floor(node.y / cellSize)
1545
- const candidates = []
1546
-
1547
- for (let offsetX = -1; offsetX <= 1; offsetX += 1) {
1548
- for (let offsetY = -1; offsetY <= 1; offsetY += 1) {
1549
- const bucket = buckets.get((cellX + offsetX) + ':' + (cellY + offsetY))
1550
- if (!bucket) continue
1551
- for (let index = 0; index < bucket.length; index += 1) {
1552
- const candidate = bucket[index]
1553
- if (candidate.id !== node.id) {
1554
- candidates.push(candidate)
1555
- }
1556
- }
1557
- }
1558
- }
1559
-
1560
- return candidates
1561
- }
1562
-
1563
- const buildMeshEdgesForNodes = (nodes, existingEdges) => {
1564
- if (nodes.length < 2 || state.transform.scale < meshEdgeScaleThreshold) {
1565
- return []
1566
- }
1567
-
1568
- const existingKeys = new Set()
1569
- for (let index = 0; index < existingEdges.length; index += 1) {
1570
- const edge = existingEdges[index]
1571
- if (edge.target) {
1572
- existingKeys.add(edgePairKey(edge.source, edge.target))
1573
- }
1574
- }
1575
-
1576
- const desiredBudget = Math.min(
1577
- meshEdgeMaxBudget,
1578
- Math.max(meshEdgeMinBudget, Math.floor(edgeBudgetForCurrentFrame() * 0.36))
1579
- )
1580
- const perNodeNeighborCount =
1581
- state.transform.scale >= 1.05 ? 3
1582
- : state.transform.scale >= 0.62 ? 2
1583
- : 1
1584
- const cellSize = Math.max(120, 280 / Math.max(state.transform.scale, 0.0001))
1585
- const maxDistance = 980
1586
- const maxDistanceSquared = maxDistance * maxDistance
1587
- const buckets = meshNeighborBuckets(nodes, cellSize)
1588
- const meshEdges = []
1589
- const meshKeys = new Set()
1590
-
1591
- for (let index = 0; index < nodes.length && meshEdges.length < desiredBudget; index += 1) {
1592
- const node = nodes[index]
1593
- const candidates = meshCandidatesForNode(node, buckets, cellSize)
1594
- .map((candidate) => ({
1595
- node: candidate,
1596
- distanceSquared: (candidate.x - node.x) ** 2 + (candidate.y - node.y) ** 2
1597
- }))
1598
- .filter((candidate) => candidate.distanceSquared <= maxDistanceSquared)
1599
- .sort((left, right) => left.distanceSquared - right.distanceSquared)
1600
-
1601
- let linked = 0
1602
- for (let candidateIndex = 0; candidateIndex < candidates.length && linked < perNodeNeighborCount && meshEdges.length < desiredBudget; candidateIndex += 1) {
1603
- const candidate = candidates[candidateIndex].node
1604
- const key = edgePairKey(node.id, candidate.id)
1605
- if (existingKeys.has(key) || meshKeys.has(key)) {
1606
- continue
1607
- }
1608
-
1609
- meshKeys.add(key)
1610
- meshEdges.push({
1611
- source: node.id,
1612
- target: candidate.id,
1613
- targetTitle: candidate.title,
1614
- weight: 1,
1615
- priority: 'normal',
1616
- sourceNode: node,
1617
- targetNode: candidate,
1618
- inferred: true
1619
- })
1620
- linked += 1
1621
- }
1622
- }
1623
-
1624
- return meshEdges
1625
- }
1626
-
1627
- const withMeshEdges = (nodes, edges) => {
1628
- const isHierarchyGraphLevel = state.groups.length > 0 && (state.visibleNodes.length > 1000 || state.hierarchyFocusGroupId)
1629
- if (nodes.length === 0 || !isHierarchyGraphLevel || state.transform.scale < meshEdgeScaleThreshold) {
1630
- return edges
1631
- }
1632
-
1633
- const meshEdges = buildMeshEdgesForNodes(nodes, edges)
1634
- return meshEdges.length > 0 ? edges.concat(meshEdges) : edges
1531
+ const limitRenderEdges = (nodes, edges) => {
1532
+ return edges
1635
1533
  }
1636
1534
 
1637
1535
  const fallbackViewportNodes = () => {
@@ -2618,7 +2516,7 @@ const computeRenderVisibility = () => {
2618
2516
  if (state.visibleNodes.length <= 2000) {
2619
2517
  state.renderNodes = state.visibleNodes
2620
2518
  const ids = new Set(state.renderNodes.map((node) => node.id))
2621
- state.renderEdges = withMeshEdges(state.renderNodes, collectVisibleEdgesForNodes(ids))
2519
+ state.renderEdges = limitRenderEdges(state.renderNodes, collectVisibleEdgesForNodes(ids))
2622
2520
  return
2623
2521
  }
2624
2522
 
@@ -2628,7 +2526,7 @@ const computeRenderVisibility = () => {
2628
2526
  const overviewNodes = sampleMassiveOverviewNodes(sampleLimit)
2629
2527
  const overviewIds = new Set(overviewNodes.map((node) => node.id))
2630
2528
  state.renderNodes = overviewNodes
2631
- state.renderEdges = withMeshEdges(overviewNodes, collectVisibleEdgesForNodes(overviewIds))
2529
+ state.renderEdges = limitRenderEdges(overviewNodes, collectVisibleEdgesForNodes(overviewIds))
2632
2530
  return
2633
2531
  }
2634
2532
 
@@ -2685,7 +2583,7 @@ const computeRenderVisibility = () => {
2685
2583
  sampledEdges = collectVisibleEdgesForNodes(sampledWithHubsIds)
2686
2584
  }
2687
2585
  state.renderNodes = sampledNodes
2688
- state.renderEdges = withMeshEdges(sampledNodes, sampledEdges)
2586
+ state.renderEdges = limitRenderEdges(sampledNodes, sampledEdges)
2689
2587
  return
2690
2588
  }
2691
2589
 
@@ -2693,7 +2591,7 @@ const computeRenderVisibility = () => {
2693
2591
  const sampled = sampleVisibleNodes(Math.min(renderNodeBudget, 900))
2694
2592
  const sampledIds = new Set(sampled.map((node) => node.id))
2695
2593
  state.renderNodes = sampled
2696
- state.renderEdges = withMeshEdges(sampled, collectVisibleEdgesForNodes(sampledIds))
2594
+ state.renderEdges = limitRenderEdges(sampled, collectVisibleEdgesForNodes(sampledIds))
2697
2595
  return
2698
2596
  }
2699
2597
 
@@ -2720,7 +2618,7 @@ const computeRenderVisibility = () => {
2720
2618
  const fallbackNodes = fallbackViewportNodes()
2721
2619
  const fallbackIds = new Set(fallbackNodes.map((node) => node.id))
2722
2620
  state.renderNodes = fallbackNodes
2723
- state.renderEdges = withMeshEdges(fallbackNodes, collectVisibleEdgesForNodes(fallbackIds))
2621
+ state.renderEdges = limitRenderEdges(fallbackNodes, collectVisibleEdgesForNodes(fallbackIds))
2724
2622
  return
2725
2623
  }
2726
2624
 
@@ -2729,13 +2627,13 @@ const computeRenderVisibility = () => {
2729
2627
  const edges = collectVisibleEdgesForNodes(nodeIds)
2730
2628
 
2731
2629
  state.renderNodes = normalizedNodes
2732
- state.renderEdges = withMeshEdges(normalizedNodes, edges)
2630
+ state.renderEdges = limitRenderEdges(normalizedNodes, edges)
2733
2631
 
2734
2632
  if (state.renderNodes.length === 0 && state.visibleNodes.length > 0) {
2735
2633
  const fallbackNodes = sampleVisibleNodes(Math.min(renderNodeBudget, 260))
2736
2634
  const fallbackIds = new Set(fallbackNodes.map((node) => node.id))
2737
2635
  state.renderNodes = fallbackNodes
2738
- state.renderEdges = withMeshEdges(fallbackNodes, collectVisibleEdgesForNodes(fallbackIds))
2636
+ state.renderEdges = limitRenderEdges(fallbackNodes, collectVisibleEdgesForNodes(fallbackIds))
2739
2637
  }
2740
2638
  }
2741
2639
 
@@ -2,8 +2,8 @@ import { getGraphLayout } from './get-graph-layout.js';
2
2
  const macroScale = 0.24;
3
3
  const microCoverage = 0.72;
4
4
  const nodeLimit = 1000;
5
- const edgeLimit = 3200;
6
- const meshEdgeLimit = 1200;
5
+ const edgeLimit = 1400;
6
+ const groupEdgeLimit = 900;
7
7
  const inViewport = (item, input) => {
8
8
  const radius = item.radius ?? 48;
9
9
  return (item.x + radius >= input.x &&
@@ -118,7 +118,20 @@ const aggregateGroupEdges = (groups, edges, groupById) => {
118
118
  return;
119
119
  selected.set(key, [source, target, edge.weight, edge.priority]);
120
120
  });
121
- return Array.from(selected.values()).slice(0, edgeLimit);
121
+ const degreeCounts = new Map();
122
+ return Array.from(selected.values())
123
+ .sort((left, right) => right[2] - left[2] || left[0].localeCompare(right[0]) || left[1].localeCompare(right[1]))
124
+ .filter((edge) => {
125
+ const sourceCount = degreeCounts.get(edge[0]) ?? 0;
126
+ const targetCount = degreeCounts.get(edge[1]) ?? 0;
127
+ if (sourceCount >= 3 || targetCount >= 3) {
128
+ return false;
129
+ }
130
+ degreeCounts.set(edge[0], sourceCount + 1);
131
+ degreeCounts.set(edge[1], targetCount + 1);
132
+ return true;
133
+ })
134
+ .slice(0, groupEdgeLimit);
122
135
  };
123
136
  const realEdges = (edges, nodeIds) => edges
124
137
  .filter((edge) => Boolean(edge.target && nodeIds.has(edge.source) && nodeIds.has(edge.target)))
@@ -165,31 +178,7 @@ const arrangeChildGraphNodes = (nodes, group, degrees) => {
165
178
  });
166
179
  return arranged;
167
180
  };
168
- const edgePairKey = (left, right) => left < right ? `${left}|${right}` : `${right}|${left}`;
169
- const meshEdges = (nodes, existingEdges) => {
170
- if (nodes.length < 2) {
171
- return [];
172
- }
173
- const existing = new Set(existingEdges.map((edge) => edgePairKey(edge[0], edge[1])));
174
- const selected = [];
175
- const selectedKeys = new Set();
176
- const maxNeighbors = nodes.length > 500 ? 2 : 3;
177
- const byX = [...nodes].sort((left, right) => left[2] - right[2] || left[3] - right[3] || left[0].localeCompare(right[0]));
178
- for (let index = 0; index < nodes.length && selected.length < meshEdgeLimit; index += 1) {
179
- const node = byX[index];
180
- const candidates = byX.slice(index + 1, index + 1 + maxNeighbors);
181
- candidates.forEach((candidate) => {
182
- const key = edgePairKey(node[0], candidate[0]);
183
- if (existing.has(key) || selectedKeys.has(key) || selected.length >= meshEdgeLimit) {
184
- return;
185
- }
186
- selectedKeys.add(key);
187
- selected.push([node[0], candidate[0], 1, 'low']);
188
- });
189
- }
190
- return selected;
191
- };
192
- const withMeshEdges = (nodes, edges) => [...edges, ...meshEdges(nodes, edges)].slice(0, edgeLimit);
181
+ const limitEdges = (edges) => edges.slice(0, edgeLimit);
193
182
  export const getGraphView = async (vaultPath, input) => {
194
183
  const { signature, layout } = await getGraphLayout(vaultPath, input.agentId);
195
184
  const groups = layout.groups ?? [];
@@ -203,7 +192,7 @@ export const getGraphView = async (vaultPath, input) => {
203
192
  signature,
204
193
  mode: 'flat',
205
194
  nodes: viewNodes,
206
- edges: withMeshEdges(viewNodes, realEdges(layout.edges, nodeIds)),
195
+ edges: limitEdges(realEdges(layout.edges, nodeIds)),
207
196
  totals: {
208
197
  nodes: layout.nodes.length,
209
198
  edges: layout.edges.length
@@ -232,7 +221,7 @@ export const getGraphView = async (vaultPath, input) => {
232
221
  signature,
233
222
  mode: 'micro',
234
223
  nodes: viewNodes,
235
- edges: withMeshEdges(viewNodes, realEdges(layout.edges, visibleNodeIds)),
224
+ edges: limitEdges(realEdges(layout.edges, visibleNodeIds)),
236
225
  totals: {
237
226
  nodes: layout.nodes.length,
238
227
  edges: layout.edges.length
@@ -245,7 +234,7 @@ export const getGraphView = async (vaultPath, input) => {
245
234
  signature,
246
235
  mode: 'macro',
247
236
  nodes: viewNodes,
248
- edges: withMeshEdges(viewNodes, aggregateGroupEdges(groupsToRender, layout.edges, groupById)),
237
+ edges: limitEdges(aggregateGroupEdges(groupsToRender, layout.edges, groupById)),
249
238
  totals: {
250
239
  nodes: layout.nodes.length,
251
240
  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 mesh 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 as a projected mesh graph of group nodes, zoom-in expands a group from the focused node's current viewport position only after it is framed, and micro view renders only the focused radial child graph with dense-node label suppression in a local frame anchored to the rendered group node 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 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 as a sparse strongest-link graph of group nodes, zoom-in expands a group from the focused node's current viewport position only after it is framed, and micro view renders only the focused radial child graph with dense-node label suppression in a local frame anchored to the rendered 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.128",
3
+ "version": "0.1.0-beta.129",
4
4
  "description": "Local-first knowledge memory for agents with Markdown, backlinks, indexing and context retrieval.",
5
5
  "type": "module",
6
6
  "license": "MIT",