@andespindola/brainlink 0.1.0-beta.126 → 0.1.0-beta.128
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 mesh groups for vaults above 1000 notes, with every visible graph level
|
|
87
|
+
- Graph rendering keeps the flat node scene and adds stable hierarchical mesh groups for vaults above 1000 notes, with every visible graph level filled toward 1000 nodes, each group capped at 1000 child 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 mesh graph nodes and expands a group only after it is framed
|
|
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.
|
|
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
|
|
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
|
|
610
610
|
|
|
611
611
|
The server indexes before starting by default. Use `--no-index` to skip that step:
|
|
612
612
|
|
|
@@ -23,8 +23,8 @@ const transformCoordinateLimit = 20_000_000
|
|
|
23
23
|
const hoverHitTestIntervalMs = 64
|
|
24
24
|
const zoomRecoveryGuardMs = 4200
|
|
25
25
|
const meshEdgeScaleThreshold = 0.09
|
|
26
|
-
const meshEdgeMinBudget =
|
|
27
|
-
const meshEdgeMaxBudget =
|
|
26
|
+
const meshEdgeMinBudget = 90
|
|
27
|
+
const meshEdgeMaxBudget = 850
|
|
28
28
|
const dragNeighborhoodMaxAffected = 180
|
|
29
29
|
const dragSettleRounds = 3
|
|
30
30
|
const wheelZoomExponent = 0.0009
|
|
@@ -1281,9 +1281,9 @@ const arrangeGraphLevelNodes = (nodes, radiusForNode = () => 1) => {
|
|
|
1281
1281
|
return arranged
|
|
1282
1282
|
}
|
|
1283
1283
|
|
|
1284
|
-
const arrangeChildGraphNodes = (nodes, group) => {
|
|
1284
|
+
const arrangeChildGraphNodes = (nodes, group, origin = group) => {
|
|
1285
1285
|
if (nodes.length <= 1) {
|
|
1286
|
-
return nodes.map(node => ({ ...node, x:
|
|
1286
|
+
return nodes.map(node => ({ ...node, x: origin.x, y: origin.y }))
|
|
1287
1287
|
}
|
|
1288
1288
|
|
|
1289
1289
|
const targetRadius = childGraphRenderRadius(group)
|
|
@@ -1303,7 +1303,7 @@ const arrangeChildGraphNodes = (nodes, group) => {
|
|
|
1303
1303
|
})
|
|
1304
1304
|
const goldenAngle = Math.PI * (3 - Math.sqrt(5))
|
|
1305
1305
|
const arranged = centerNode
|
|
1306
|
-
? [{ ...centerNode, x:
|
|
1306
|
+
? [{ ...centerNode, x: origin.x, y: origin.y }]
|
|
1307
1307
|
: []
|
|
1308
1308
|
|
|
1309
1309
|
outerNodes.forEach((node, index) => {
|
|
@@ -1311,19 +1311,19 @@ const arrangeChildGraphNodes = (nodes, group) => {
|
|
|
1311
1311
|
const angle = index * goldenAngle
|
|
1312
1312
|
arranged.push({
|
|
1313
1313
|
...node,
|
|
1314
|
-
x:
|
|
1315
|
-
y:
|
|
1314
|
+
x: origin.x + Math.cos(angle) * ringRadius,
|
|
1315
|
+
y: origin.y + Math.sin(angle) * ringRadius
|
|
1316
1316
|
})
|
|
1317
1317
|
})
|
|
1318
1318
|
|
|
1319
1319
|
return arranged
|
|
1320
1320
|
}
|
|
1321
1321
|
|
|
1322
|
-
const interpolateNodeFromGroup = (node,
|
|
1322
|
+
const interpolateNodeFromGroup = (node, origin, progress) => {
|
|
1323
1323
|
return {
|
|
1324
1324
|
...node,
|
|
1325
|
-
x:
|
|
1326
|
-
y:
|
|
1325
|
+
x: origin.x + (node.x - origin.x) * progress,
|
|
1326
|
+
y: origin.y + (node.y - origin.y) * progress,
|
|
1327
1327
|
vx: 0,
|
|
1328
1328
|
vy: 0
|
|
1329
1329
|
}
|
|
@@ -1360,6 +1360,33 @@ const groupWithCoverage = (group, viewport) => ({
|
|
|
1360
1360
|
coverage: groupViewportCoverage(group, viewport)
|
|
1361
1361
|
})
|
|
1362
1362
|
|
|
1363
|
+
const distanceToViewportCenter = (item, viewport) => {
|
|
1364
|
+
const centerX = (viewport.minX + viewport.maxX) / 2
|
|
1365
|
+
const centerY = (viewport.minY + viewport.maxY) / 2
|
|
1366
|
+
return Math.hypot(item.x - centerX, item.y - centerY)
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
const selectViewportItemsWithFill = (items, viewport, limit = renderNodeBudget) => {
|
|
1370
|
+
const visible = items.filter(item =>
|
|
1371
|
+
item.x + item.radius >= viewport.minX &&
|
|
1372
|
+
item.x - item.radius <= viewport.maxX &&
|
|
1373
|
+
item.y + item.radius >= viewport.minY &&
|
|
1374
|
+
item.y - item.radius <= viewport.maxY
|
|
1375
|
+
)
|
|
1376
|
+
|
|
1377
|
+
if (visible.length >= limit) {
|
|
1378
|
+
return visible.slice(0, limit)
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
const selectedIds = new Set(visible.map(item => item.id))
|
|
1382
|
+
const fill = items
|
|
1383
|
+
.filter(item => !selectedIds.has(item.id))
|
|
1384
|
+
.sort((left, right) => distanceToViewportCenter(left, viewport) - distanceToViewportCenter(right, viewport) || left.id.localeCompare(right.id))
|
|
1385
|
+
.slice(0, Math.max(0, limit - visible.length))
|
|
1386
|
+
|
|
1387
|
+
return visible.concat(fill)
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1363
1390
|
const updateHierarchyFocusGroup = (groups, viewport) => {
|
|
1364
1391
|
const current = state.hierarchyFocusGroupId
|
|
1365
1392
|
? groups.find(group => group.id === state.hierarchyFocusGroupId) ?? null
|
|
@@ -1451,14 +1478,7 @@ const computeHierarchyRenderVisibility = (viewport) => {
|
|
|
1451
1478
|
return false
|
|
1452
1479
|
}
|
|
1453
1480
|
|
|
1454
|
-
const groups = hierarchyGroupsForScale()
|
|
1455
|
-
.filter(group =>
|
|
1456
|
-
group.x + group.radius >= viewport.minX &&
|
|
1457
|
-
group.x - group.radius <= viewport.maxX &&
|
|
1458
|
-
group.y + group.radius >= viewport.minY &&
|
|
1459
|
-
group.y - group.radius <= viewport.maxY
|
|
1460
|
-
)
|
|
1461
|
-
.slice(0, renderNodeBudget)
|
|
1481
|
+
const groups = selectViewportItemsWithFill(hierarchyGroupsForScale(), viewport, renderNodeBudget)
|
|
1462
1482
|
const focus = updateHierarchyFocusGroup(groups, viewport)
|
|
1463
1483
|
const progress = focus ? hierarchyViewportProgress(focus, viewport) : 0
|
|
1464
1484
|
const groupNodes = groups.map(createGroupRenderNode)
|
|
@@ -1472,10 +1492,11 @@ const computeHierarchyRenderVisibility = (viewport) => {
|
|
|
1472
1492
|
const focusIds = new Set(focus.nodeIds)
|
|
1473
1493
|
const rawChildNodes = state.visibleNodes.filter(node => focusIds.has(node.id))
|
|
1474
1494
|
const childLimit = Math.max(90, Math.min(renderNodeBudget, Math.floor(renderNodeBudget * progress)))
|
|
1495
|
+
const focusRenderNode = groupNodes.find(node => node.groupId === focus.id) ?? createGroupRenderNode(focus)
|
|
1475
1496
|
const childNodes = selectStableSampleNodes(
|
|
1476
|
-
arrangeChildGraphNodes(rawChildNodes, focus),
|
|
1497
|
+
arrangeChildGraphNodes(rawChildNodes, focus, focusRenderNode),
|
|
1477
1498
|
childLimit
|
|
1478
|
-
).map(node => interpolateNodeFromGroup(node,
|
|
1499
|
+
).map(node => interpolateNodeFromGroup(node, focusRenderNode, progress))
|
|
1479
1500
|
const childIds = new Set(childNodes.map(node => node.id))
|
|
1480
1501
|
const childById = new Map(childNodes.map(node => [node.id, node]))
|
|
1481
1502
|
const isMicroView = progress >= 0.72
|
|
@@ -1554,12 +1575,12 @@ const buildMeshEdgesForNodes = (nodes, existingEdges) => {
|
|
|
1554
1575
|
|
|
1555
1576
|
const desiredBudget = Math.min(
|
|
1556
1577
|
meshEdgeMaxBudget,
|
|
1557
|
-
Math.max(meshEdgeMinBudget, Math.floor(edgeBudgetForCurrentFrame() * 0.
|
|
1578
|
+
Math.max(meshEdgeMinBudget, Math.floor(edgeBudgetForCurrentFrame() * 0.36))
|
|
1558
1579
|
)
|
|
1559
1580
|
const perNodeNeighborCount =
|
|
1560
|
-
state.transform.scale >= 1.05 ?
|
|
1561
|
-
: state.transform.scale >= 0.62 ?
|
|
1562
|
-
:
|
|
1581
|
+
state.transform.scale >= 1.05 ? 3
|
|
1582
|
+
: state.transform.scale >= 0.62 ? 2
|
|
1583
|
+
: 1
|
|
1563
1584
|
const cellSize = Math.max(120, 280 / Math.max(state.transform.scale, 0.0001))
|
|
1564
1585
|
const maxDistance = 980
|
|
1565
1586
|
const maxDistanceSquared = maxDistance * maxDistance
|
|
@@ -4,12 +4,13 @@ import { dirname, join } from 'node:path';
|
|
|
4
4
|
import { createCauliflowerGraphLayout } from '../domain/graph-layout.js';
|
|
5
5
|
import { indexStoragePath } from '../infrastructure/file-index.js';
|
|
6
6
|
import { getGraphSummary } from './get-graph-summary.js';
|
|
7
|
+
const graphLayoutVersion = 2;
|
|
7
8
|
const graphLayoutCache = new Map();
|
|
8
9
|
const graphLayoutStoragePath = (vaultPath, agentId) => join(vaultPath, '.brainlink', `graph-layout-${agentId?.replace(/[^a-zA-Z0-9_-]/g, '_') ?? 'all'}.json`);
|
|
9
10
|
const readPersistedLayout = async (vaultPath, databaseSignature, agentId) => {
|
|
10
11
|
try {
|
|
11
12
|
const parsed = JSON.parse(await readFile(graphLayoutStoragePath(vaultPath, agentId), 'utf8'));
|
|
12
|
-
return parsed.databaseSignature === databaseSignature ? parsed : null;
|
|
13
|
+
return parsed.databaseSignature === databaseSignature && parsed.layoutVersion === graphLayoutVersion ? parsed : null;
|
|
13
14
|
}
|
|
14
15
|
catch {
|
|
15
16
|
return null;
|
|
@@ -44,7 +45,7 @@ export const getGraphLayout = async (vaultPath, agentId) => {
|
|
|
44
45
|
const databaseSignature = await readDatabaseSignature(vaultPath);
|
|
45
46
|
const cacheKey = `${vaultPath}:${agentId ?? ''}`;
|
|
46
47
|
const cached = graphLayoutCache.get(cacheKey);
|
|
47
|
-
if (cached?.databaseSignature === databaseSignature) {
|
|
48
|
+
if (cached?.databaseSignature === databaseSignature && cached.layoutVersion === graphLayoutVersion) {
|
|
48
49
|
return {
|
|
49
50
|
signature: cached.signature,
|
|
50
51
|
layout: cached.layout
|
|
@@ -65,7 +66,7 @@ export const getGraphLayout = async (vaultPath, agentId) => {
|
|
|
65
66
|
...rawLayout,
|
|
66
67
|
nodes: rawLayout.nodes.map((node) => ({ ...node, content: '' }))
|
|
67
68
|
};
|
|
68
|
-
const nextCache = { databaseSignature, signature, layout };
|
|
69
|
+
const nextCache = { layoutVersion: graphLayoutVersion, databaseSignature, signature, layout };
|
|
69
70
|
graphLayoutCache.set(cacheKey, nextCache);
|
|
70
71
|
await writePersistedLayout(vaultPath, agentId, nextCache);
|
|
71
72
|
return {
|
|
@@ -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 =
|
|
6
|
-
const meshEdgeLimit =
|
|
5
|
+
const edgeLimit = 3200;
|
|
6
|
+
const meshEdgeLimit = 1200;
|
|
7
7
|
const inViewport = (item, input) => {
|
|
8
8
|
const radius = item.radius ?? 48;
|
|
9
9
|
return (item.x + radius >= input.x &&
|
|
@@ -58,6 +58,23 @@ const arrangeGraphLevelGroups = (groups) => {
|
|
|
58
58
|
});
|
|
59
59
|
return arranged;
|
|
60
60
|
};
|
|
61
|
+
const distanceToViewportCenter = (item, input) => {
|
|
62
|
+
const centerX = input.x + input.width / 2;
|
|
63
|
+
const centerY = input.y + input.height / 2;
|
|
64
|
+
return Math.hypot(item.x - centerX, item.y - centerY);
|
|
65
|
+
};
|
|
66
|
+
const selectViewportItemsWithFill = (items, input, limit = nodeLimit) => {
|
|
67
|
+
const visible = items.filter((item) => inViewport(item, input));
|
|
68
|
+
if (visible.length >= limit) {
|
|
69
|
+
return visible.slice(0, limit);
|
|
70
|
+
}
|
|
71
|
+
const selectedIds = new Set(visible.map((item) => item.id));
|
|
72
|
+
const fill = items
|
|
73
|
+
.filter((item) => !selectedIds.has(item.id))
|
|
74
|
+
.sort((left, right) => distanceToViewportCenter(left, input) - distanceToViewportCenter(right, input) || left.id.localeCompare(right.id))
|
|
75
|
+
.slice(0, Math.max(0, limit - visible.length));
|
|
76
|
+
return visible.concat(fill);
|
|
77
|
+
};
|
|
61
78
|
const groupNode = (group) => [
|
|
62
79
|
`group:${group.id}`,
|
|
63
80
|
group.title,
|
|
@@ -195,7 +212,7 @@ export const getGraphView = async (vaultPath, input) => {
|
|
|
195
212
|
}
|
|
196
213
|
const rootGroups = arrangeGraphLevelGroups(groups.filter((group) => group.parentId === null));
|
|
197
214
|
const leafGroups = arrangeGraphLevelGroups(groups.filter((group) => group.nodeIds.length > 0));
|
|
198
|
-
const visibleGroups = rootGroups
|
|
215
|
+
const visibleGroups = selectViewportItemsWithFill(rootGroups, input);
|
|
199
216
|
const focused = leafGroups
|
|
200
217
|
.filter((group) => group.nodeIds.length > 0 && inViewport(group, input))
|
|
201
218
|
.map((group) => ({ group, coverage: groupCoverage(group, input) }))
|
|
@@ -222,7 +239,7 @@ export const getGraphView = async (vaultPath, input) => {
|
|
|
222
239
|
}
|
|
223
240
|
};
|
|
224
241
|
}
|
|
225
|
-
const groupsToRender =
|
|
242
|
+
const groupsToRender = visibleGroups.slice(0, nodeLimit);
|
|
226
243
|
const viewNodes = groupsToRender.map(groupNode);
|
|
227
244
|
return {
|
|
228
245
|
signature,
|
|
@@ -231,9 +231,13 @@ const chunkNodes = (nodes, degrees, groupNodeLimit = hierarchyGroupNodeLimit) =>
|
|
|
231
231
|
return degreeDelta;
|
|
232
232
|
return left.title.localeCompare(right.title);
|
|
233
233
|
});
|
|
234
|
+
const groupCountTarget = Math.min(groupNodeLimit, sortedNodes.length);
|
|
235
|
+
const chunkSize = sortedNodes.length <= groupNodeLimit * groupNodeLimit
|
|
236
|
+
? Math.max(1, Math.ceil(sortedNodes.length / groupCountTarget))
|
|
237
|
+
: groupNodeLimit;
|
|
234
238
|
const chunks = [];
|
|
235
|
-
for (let index = 0; index < sortedNodes.length; index +=
|
|
236
|
-
chunks.push(sortedNodes.slice(index, index +
|
|
239
|
+
for (let index = 0; index < sortedNodes.length; index += chunkSize) {
|
|
240
|
+
chunks.push(sortedNodes.slice(index, index + chunkSize));
|
|
237
241
|
}
|
|
238
242
|
return chunks;
|
|
239
243
|
};
|
package/docs/AGENT_USAGE.md
CHANGED
|
@@ -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
|
|
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.
|
|
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