@andespindola/brainlink 0.1.0-beta.133 → 0.1.0-beta.135
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 +3 -2
- package/dist/application/frontend/client-css.js +22 -5
- package/dist/application/frontend/client-html.js +1 -1
- package/dist/application/frontend/client-js.js +208 -54
- package/dist/application/server/routes.js +53 -6
- package/docs/AGENT_USAGE.md +1 -1
- package/docs/ARCHITECTURE.md +1 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -71,6 +71,7 @@ Legacy `.jsonl.gz` packs are upgraded to `.blpk` automatically on first search/c
|
|
|
71
71
|
- Full-text, semantic and hybrid retrieval on a local file index.
|
|
72
72
|
- Middle-out context assembly around the strongest chunk per document.
|
|
73
73
|
- In-process index and context caching with automatic invalidation on index updates.
|
|
74
|
+
- HTTP graph server caches generated frontend assets and graph-layout JSON payloads by signature, and skips layout serialization when ETag returns `304`.
|
|
74
75
|
- Compressed-space prefiltering for `.blpk` packs before decryption and scan.
|
|
75
76
|
- Incremental indexing that reprocesses only changed markdown files and reuses existing chunks/embeddings for unchanged notes.
|
|
76
77
|
- Adaptive compressed-pack rebuild policy to keep indexing fast during small edit batches.
|
|
@@ -589,7 +590,7 @@ The graph UI shows:
|
|
|
589
590
|
|
|
590
591
|
- notes as nodes
|
|
591
592
|
- representative `[[wiki links]]` as weighted edges
|
|
592
|
-
- details opened
|
|
593
|
+
- details opened in a non-modal side panel (tags, outgoing links, backlinks, full Markdown content), so zoom and pan remain available while inspecting data
|
|
593
594
|
- neutral graph nodes with segment/group metadata
|
|
594
595
|
- agent selector (id-only labels) for isolated views
|
|
595
596
|
- graph filter matches title, path, tags and note content
|
|
@@ -601,7 +602,7 @@ The graph UI shows:
|
|
|
601
602
|
- Bloom-like scene navigation: reset fits the current graph scene, wheel zoom stays anchored to the cursor, and WebGL acceleration draws the dense node and edge layer faster
|
|
602
603
|
- zoom-out floor for large and massive graphs to keep the scene reachable without switching into a separate macro graph mode
|
|
603
604
|
- keyboard shortcuts: `+` zoom in, `-` zoom out, `0` reset fit
|
|
604
|
-
- double-click on canvas zooms in at cursor position
|
|
605
|
+
- click on a hierarchy group expands that group from the same world position; clicking the same focused group again opens its details panel; double-click on empty canvas zooms in at cursor position
|
|
605
606
|
- floating graph totals (notes, links, tags) below the Brainlink title
|
|
606
607
|
- graph rendering safeguards (batched canvas drawing across graph sizes, edge draw caps, lower redraw rate, zoom-aware interaction)
|
|
607
608
|
- 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
|
|
@@ -262,14 +262,20 @@ li small {
|
|
|
262
262
|
}
|
|
263
263
|
|
|
264
264
|
.content-dialog {
|
|
265
|
-
|
|
266
|
-
|
|
265
|
+
position: fixed;
|
|
266
|
+
top: 84px;
|
|
267
|
+
right: 12px;
|
|
268
|
+
margin: 0;
|
|
269
|
+
width: min(440px, calc(100vw - 24px));
|
|
270
|
+
height: min(calc(100svh - 124px), 820px);
|
|
271
|
+
max-height: calc(100svh - 124px);
|
|
267
272
|
padding: 0;
|
|
268
273
|
border: 1px solid var(--line);
|
|
269
274
|
border-radius: 8px;
|
|
270
275
|
background: var(--panel);
|
|
271
276
|
color: var(--text);
|
|
272
277
|
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.48);
|
|
278
|
+
overflow: hidden;
|
|
273
279
|
}
|
|
274
280
|
|
|
275
281
|
.content-dialog::backdrop {
|
|
@@ -280,7 +286,8 @@ li small {
|
|
|
280
286
|
.content-dialog article {
|
|
281
287
|
display: grid;
|
|
282
288
|
grid-template-rows: auto auto minmax(0, 1fr);
|
|
283
|
-
|
|
289
|
+
height: 100%;
|
|
290
|
+
max-height: 100%;
|
|
284
291
|
}
|
|
285
292
|
|
|
286
293
|
.content-dialog header {
|
|
@@ -299,7 +306,7 @@ li small {
|
|
|
299
306
|
|
|
300
307
|
.content-dialog h2 {
|
|
301
308
|
margin-top: 6px;
|
|
302
|
-
font-size:
|
|
309
|
+
font-size: 19px;
|
|
303
310
|
line-height: 1.15;
|
|
304
311
|
overflow-wrap: anywhere;
|
|
305
312
|
}
|
|
@@ -367,7 +374,7 @@ li small {
|
|
|
367
374
|
min-height: 0;
|
|
368
375
|
border: 0;
|
|
369
376
|
border-radius: 0;
|
|
370
|
-
padding:
|
|
377
|
+
padding: 14px;
|
|
371
378
|
}
|
|
372
379
|
|
|
373
380
|
.app-footer {
|
|
@@ -415,6 +422,16 @@ li small {
|
|
|
415
422
|
flex-direction: column;
|
|
416
423
|
}
|
|
417
424
|
|
|
425
|
+
.content-dialog {
|
|
426
|
+
top: auto;
|
|
427
|
+
right: 12px;
|
|
428
|
+
left: 12px;
|
|
429
|
+
bottom: 38px;
|
|
430
|
+
width: auto;
|
|
431
|
+
height: min(calc(100svh - 170px), 640px);
|
|
432
|
+
max-height: calc(100svh - 170px);
|
|
433
|
+
}
|
|
434
|
+
|
|
418
435
|
.metric-chip {
|
|
419
436
|
min-width: 82px;
|
|
420
437
|
}
|
|
@@ -56,7 +56,7 @@ export const createClientHtml = () => `<!doctype html>
|
|
|
56
56
|
<article>
|
|
57
57
|
<header>
|
|
58
58
|
<div>
|
|
59
|
-
<span class="eyebrow">
|
|
59
|
+
<span class="eyebrow">Node details</span>
|
|
60
60
|
<h2 id="contentTitle">Selected note</h2>
|
|
61
61
|
<p id="contentPath"></p>
|
|
62
62
|
</div>
|
|
@@ -18,9 +18,12 @@ const hierarchyMicroEnterScale = 0.18
|
|
|
18
18
|
const hierarchyMicroExitScale = 0.12
|
|
19
19
|
const hierarchyFocusAcquireCoverage = 0.52
|
|
20
20
|
const hierarchyFocusAcquireScale = 0.16
|
|
21
|
-
const
|
|
22
|
-
const
|
|
21
|
+
const hierarchyChildRevealStartProgress = 0.28
|
|
22
|
+
const hierarchyChildRevealExponent = 1.8
|
|
23
|
+
const hierarchyFocusedOnlyProgress = 0.86
|
|
23
24
|
const hierarchyChildGraphFitMargin = 1.28
|
|
25
|
+
const hierarchyChildRevealGrowthRatio = 0.3
|
|
26
|
+
const hierarchyChildRevealGrowthFloor = 2
|
|
24
27
|
const minNodePixelRadius = 2.3
|
|
25
28
|
const viewportPaddingPx = 280
|
|
26
29
|
const worldCoordinateLimit = 5_000_000
|
|
@@ -88,6 +91,8 @@ const state = {
|
|
|
88
91
|
lastZoomFocus: { x: 0, y: 0, at: 0 },
|
|
89
92
|
hierarchyFocusGroupId: null,
|
|
90
93
|
hierarchyFocusStack: [],
|
|
94
|
+
hierarchyRevealFocusGroupId: null,
|
|
95
|
+
hierarchyRevealBudget: 1,
|
|
91
96
|
zoomTransition: {
|
|
92
97
|
active: false,
|
|
93
98
|
source: 'generic',
|
|
@@ -1225,6 +1230,15 @@ const smoothProgress = value => {
|
|
|
1225
1230
|
return bounded * bounded * (3 - 2 * bounded)
|
|
1226
1231
|
}
|
|
1227
1232
|
|
|
1233
|
+
const childRevealProgressForFocus = progress => {
|
|
1234
|
+
const bounded = Math.max(0, Math.min(1, progress))
|
|
1235
|
+
if (bounded <= hierarchyChildRevealStartProgress) {
|
|
1236
|
+
return 0
|
|
1237
|
+
}
|
|
1238
|
+
const shifted = (bounded - hierarchyChildRevealStartProgress) / Math.max(1 - hierarchyChildRevealStartProgress, 0.0001)
|
|
1239
|
+
return Math.pow(smoothProgress(shifted), hierarchyChildRevealExponent)
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1228
1242
|
const groupRenderRadius = group => {
|
|
1229
1243
|
const childCount = Math.max(group.nodeIds.length, group.childGroupIds.length, 1)
|
|
1230
1244
|
return 10 + Math.min(Math.log2(childCount + 1) * 4.2, 22)
|
|
@@ -1447,7 +1461,7 @@ const selectViewportItemsWithFill = (items, viewport, limit = renderNodeBudget)
|
|
|
1447
1461
|
return visible.concat(fill)
|
|
1448
1462
|
}
|
|
1449
1463
|
|
|
1450
|
-
const updateHierarchyFocusGroup = (groups,
|
|
1464
|
+
const updateHierarchyFocusGroup = (groups, _viewport) => {
|
|
1451
1465
|
const activeGroupIds = new Set(groups.map(group => group.id))
|
|
1452
1466
|
state.hierarchyFocusStack = state.hierarchyFocusStack.filter(groupId => state.groupById.has(groupId))
|
|
1453
1467
|
const parentGroup = activeHierarchyParentGroup()
|
|
@@ -1462,7 +1476,6 @@ const updateHierarchyFocusGroup = (groups, viewport) => {
|
|
|
1462
1476
|
const current = state.hierarchyFocusGroupId
|
|
1463
1477
|
? groups.find(group => group.id === state.hierarchyFocusGroupId) ?? null
|
|
1464
1478
|
: null
|
|
1465
|
-
const currentCoverage = current ? groupViewportCoverage(current, viewport) : 0
|
|
1466
1479
|
const hasActiveFocusedTransition =
|
|
1467
1480
|
Boolean(current) &&
|
|
1468
1481
|
state.zoomTransition.active &&
|
|
@@ -1473,46 +1486,47 @@ const updateHierarchyFocusGroup = (groups, viewport) => {
|
|
|
1473
1486
|
if (hasActiveFocusedTransition) {
|
|
1474
1487
|
return current
|
|
1475
1488
|
}
|
|
1489
|
+
const selectedGroupId = state.selected?.isGroupNode ? state.selected.groupId : null
|
|
1490
|
+
const selectedGroup = selectedGroupId
|
|
1491
|
+
? groups.find(group => group.id === selectedGroupId) ?? null
|
|
1492
|
+
: null
|
|
1476
1493
|
|
|
1477
|
-
if (
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
currentCoverage >= hierarchyMicroExitCoverage
|
|
1481
|
-
) {
|
|
1482
|
-
if (current.childGroupIds.length > 0 && state.hierarchyFocusStack.at(-1) !== current.id) {
|
|
1483
|
-
state.hierarchyFocusStack = [...state.hierarchyFocusStack, current.id]
|
|
1484
|
-
}
|
|
1485
|
-
return current
|
|
1486
|
-
}
|
|
1487
|
-
|
|
1488
|
-
const candidate = groups
|
|
1489
|
-
.map(group => groupWithCoverage(group, viewport))
|
|
1490
|
-
.sort((left, right) => right.coverage - left.coverage)[0]
|
|
1491
|
-
|
|
1492
|
-
if (
|
|
1493
|
-
candidate &&
|
|
1494
|
-
state.transform.scale >= hierarchyFocusAcquireScale &&
|
|
1495
|
-
candidate.coverage >= hierarchyFocusAcquireCoverage
|
|
1496
|
-
) {
|
|
1497
|
-
state.hierarchyFocusGroupId = candidate.group.id
|
|
1498
|
-
if (candidate.group.childGroupIds.length > 0 && state.hierarchyFocusStack.at(-1) !== candidate.group.id) {
|
|
1499
|
-
state.hierarchyFocusStack = [...state.hierarchyFocusStack, candidate.group.id]
|
|
1500
|
-
}
|
|
1501
|
-
return candidate.group
|
|
1494
|
+
if (selectedGroup && state.transform.scale >= hierarchyMicroExitScale) {
|
|
1495
|
+
state.hierarchyFocusGroupId = selectedGroup.id
|
|
1496
|
+
return selectedGroup
|
|
1502
1497
|
}
|
|
1503
1498
|
|
|
1504
1499
|
if (state.hierarchyFocusStack.length > 0 && state.transform.scale < hierarchyMicroExitScale) {
|
|
1505
1500
|
state.hierarchyFocusStack = state.hierarchyFocusStack.slice(0, -1)
|
|
1501
|
+
if (state.selected?.isGroupNode) {
|
|
1502
|
+
state.selected = null
|
|
1503
|
+
}
|
|
1506
1504
|
}
|
|
1507
1505
|
state.hierarchyFocusGroupId = null
|
|
1508
1506
|
return null
|
|
1509
1507
|
}
|
|
1510
1508
|
|
|
1511
|
-
const
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1509
|
+
const updateHierarchyChildRevealBudget = (focusGroupId, targetLimit) => {
|
|
1510
|
+
if (state.hierarchyRevealFocusGroupId !== focusGroupId) {
|
|
1511
|
+
state.hierarchyRevealFocusGroupId = focusGroupId
|
|
1512
|
+
state.hierarchyRevealBudget = 1
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1515
|
+
if (state.hierarchyRevealBudget > targetLimit) {
|
|
1516
|
+
state.hierarchyRevealBudget = targetLimit
|
|
1517
|
+
return targetLimit
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
const remaining = Math.max(0, targetLimit - state.hierarchyRevealBudget)
|
|
1521
|
+
const growth = Math.max(
|
|
1522
|
+
hierarchyChildRevealGrowthFloor,
|
|
1523
|
+
Math.floor(remaining * hierarchyChildRevealGrowthRatio)
|
|
1524
|
+
)
|
|
1525
|
+
state.hierarchyRevealBudget = Math.min(targetLimit, state.hierarchyRevealBudget + growth)
|
|
1526
|
+
return state.hierarchyRevealBudget
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
const hierarchyViewportProgress = (group, _viewport) => {
|
|
1516
1530
|
const hasActiveFocusedTransition =
|
|
1517
1531
|
state.zoomTransition.active &&
|
|
1518
1532
|
state.zoomTransition.source === 'group' &&
|
|
@@ -1520,13 +1534,12 @@ const hierarchyViewportProgress = (group, viewport) => {
|
|
|
1520
1534
|
state.selected.groupId === group.id
|
|
1521
1535
|
|
|
1522
1536
|
if (!hasActiveFocusedTransition) {
|
|
1523
|
-
return
|
|
1537
|
+
return 1
|
|
1524
1538
|
}
|
|
1525
1539
|
|
|
1526
1540
|
const targetSpan = Math.max(state.zoomTransition.targetScale - hierarchyFocusAcquireScale, 0.0001)
|
|
1527
1541
|
const transitionScaleProgress = (state.transform.scale - hierarchyFocusAcquireScale) / targetSpan
|
|
1528
|
-
|
|
1529
|
-
return Math.max(viewportProgress, transitionProgress)
|
|
1542
|
+
return Math.pow(Math.max(0, Math.min(1, transitionScaleProgress)), 2.8)
|
|
1530
1543
|
}
|
|
1531
1544
|
|
|
1532
1545
|
const groupEdgesForRenderedGroups = (groupNodes) => {
|
|
@@ -1582,15 +1595,20 @@ const computeHierarchyRenderVisibility = (viewport) => {
|
|
|
1582
1595
|
if (state.groups.length === 0 || state.visibleNodes.length <= 1000) {
|
|
1583
1596
|
state.hierarchyFocusGroupId = null
|
|
1584
1597
|
state.hierarchyFocusStack = []
|
|
1598
|
+
state.hierarchyRevealFocusGroupId = null
|
|
1599
|
+
state.hierarchyRevealBudget = 1
|
|
1585
1600
|
return false
|
|
1586
1601
|
}
|
|
1587
1602
|
|
|
1588
1603
|
const groups = selectViewportItemsWithFill(hierarchyGroupsForScale(), viewport, renderNodeBudget)
|
|
1589
1604
|
const focus = updateHierarchyFocusGroup(groups, viewport)
|
|
1590
1605
|
const progress = focus ? hierarchyViewportProgress(focus, viewport) : 0
|
|
1606
|
+
const revealProgress = childRevealProgressForFocus(progress)
|
|
1591
1607
|
const groupNodes = groups.map(createGroupRenderNode)
|
|
1592
1608
|
|
|
1593
|
-
if (!focus ||
|
|
1609
|
+
if (!focus || revealProgress <= 0) {
|
|
1610
|
+
state.hierarchyRevealFocusGroupId = null
|
|
1611
|
+
state.hierarchyRevealBudget = 1
|
|
1594
1612
|
state.renderNodes = groupNodes
|
|
1595
1613
|
state.renderEdges = groupEdgesForRenderedGroups(groupNodes)
|
|
1596
1614
|
return true
|
|
@@ -1601,22 +1619,22 @@ const computeHierarchyRenderVisibility = (viewport) => {
|
|
|
1601
1619
|
const focusChildGroups = focus.childGroupIds
|
|
1602
1620
|
.map(groupId => state.groupById.get(groupId))
|
|
1603
1621
|
.filter(Boolean)
|
|
1604
|
-
const
|
|
1605
|
-
const childLimit =
|
|
1622
|
+
const childTargetLimit = Math.min(renderNodeBudget, Math.max(1, Math.floor(renderNodeBudget * revealProgress)))
|
|
1623
|
+
const childLimit = updateHierarchyChildRevealBudget(focus.id, childTargetLimit)
|
|
1606
1624
|
const focusRenderNode = groupNodes.find(node => node.groupId === focus.id) ?? createGroupRenderNode(focus)
|
|
1607
1625
|
const arrangedChildren = focusChildGroups.length > 0
|
|
1608
1626
|
? arrangeChildGroupNodes(focusChildGroups, focus, focusRenderNode)
|
|
1609
1627
|
: arrangeChildGraphNodes(rawChildNodes, focus, focusRenderNode)
|
|
1610
1628
|
const childNodes = selectStableSampleNodes(arrangedChildren, childLimit)
|
|
1611
|
-
.map(node => interpolateNodeFromGroup(node, focusRenderNode,
|
|
1629
|
+
.map(node => interpolateNodeFromGroup(node, focusRenderNode, revealProgress))
|
|
1612
1630
|
const childIds = new Set(childNodes.map(node => node.id))
|
|
1613
1631
|
const childById = new Map(childNodes.map(node => [node.id, node]))
|
|
1614
|
-
const isMicroView =
|
|
1632
|
+
const isMicroView = revealProgress >= hierarchyFocusedOnlyProgress
|
|
1615
1633
|
const visibleGroupNodes = isMicroView
|
|
1616
1634
|
? []
|
|
1617
|
-
: groupNodes.filter(node => node.groupId !== focus.id ||
|
|
1635
|
+
: groupNodes.filter(node => node.groupId !== focus.id || revealProgress < 0.96)
|
|
1618
1636
|
const groupEdges = isMicroView ? [] : groupEdgesForRenderedGroups(visibleGroupNodes)
|
|
1619
|
-
const childEdges = (isMicroView ||
|
|
1637
|
+
const childEdges = (isMicroView || revealProgress > 0)
|
|
1620
1638
|
? focusChildGroups.length > 0
|
|
1621
1639
|
? groupEdgesForRenderedGroups(childNodes)
|
|
1622
1640
|
: collectVisibleEdgesForNodes(childIds).map(edge => ({
|
|
@@ -2102,6 +2120,8 @@ const resetView = () => fitView({ useFiltered: false, preferHubCenter: false })
|
|
|
2102
2120
|
const resetHierarchyFocus = () => {
|
|
2103
2121
|
state.hierarchyFocusGroupId = null
|
|
2104
2122
|
state.hierarchyFocusStack = []
|
|
2123
|
+
state.hierarchyRevealFocusGroupId = null
|
|
2124
|
+
state.hierarchyRevealBudget = 1
|
|
2105
2125
|
}
|
|
2106
2126
|
|
|
2107
2127
|
const focusPrimaryHub = () => {
|
|
@@ -2920,6 +2940,66 @@ const linkedNodes = node => {
|
|
|
2920
2940
|
return { outgoing, incoming }
|
|
2921
2941
|
}
|
|
2922
2942
|
|
|
2943
|
+
const linkedGroupsForGroupNode = groupNode => {
|
|
2944
|
+
const group = state.groupById.get(groupNode.groupId)
|
|
2945
|
+
if (!group) {
|
|
2946
|
+
return { outgoing: [], incoming: [], internalEdgeCount: 0, externalEdgeCount: 0 }
|
|
2947
|
+
}
|
|
2948
|
+
|
|
2949
|
+
const nodeIds = new Set(group.nodeIds)
|
|
2950
|
+
const externalByGroup = new Map()
|
|
2951
|
+
let internalEdgeCount = 0
|
|
2952
|
+
let externalEdgeCount = 0
|
|
2953
|
+
|
|
2954
|
+
for (let index = 0; index < state.visibleEdges.length; index += 1) {
|
|
2955
|
+
const edge = state.visibleEdges[index]
|
|
2956
|
+
if (!edge.target) continue
|
|
2957
|
+
const sourceInside = nodeIds.has(edge.source)
|
|
2958
|
+
const targetInside = nodeIds.has(edge.target)
|
|
2959
|
+
if (sourceInside && targetInside) {
|
|
2960
|
+
internalEdgeCount += 1
|
|
2961
|
+
continue
|
|
2962
|
+
}
|
|
2963
|
+
if (!sourceInside && !targetInside) continue
|
|
2964
|
+
|
|
2965
|
+
externalEdgeCount += 1
|
|
2966
|
+
const externalNodeId = sourceInside ? edge.target : edge.source
|
|
2967
|
+
const externalGroup = state.nodeLeafGroupById.get(externalNodeId)
|
|
2968
|
+
const externalKey = externalGroup?.id ?? 'unknown'
|
|
2969
|
+
const externalTitle = externalGroup?.title ?? (edge.targetTitle || 'Unknown group')
|
|
2970
|
+
const direction = sourceInside ? 'outgoing' : 'incoming'
|
|
2971
|
+
const current = externalByGroup.get(externalKey)
|
|
2972
|
+
|
|
2973
|
+
if (current) {
|
|
2974
|
+
current.weight += edgeWeight(edge)
|
|
2975
|
+
if (direction === 'outgoing') current.outgoing += 1
|
|
2976
|
+
if (direction === 'incoming') current.incoming += 1
|
|
2977
|
+
continue
|
|
2978
|
+
}
|
|
2979
|
+
|
|
2980
|
+
externalByGroup.set(externalKey, {
|
|
2981
|
+
id: '',
|
|
2982
|
+
title: externalTitle,
|
|
2983
|
+
path: 'Group relation - ' + direction,
|
|
2984
|
+
weight: edgeWeight(edge),
|
|
2985
|
+
priority: edge.priority || 'normal',
|
|
2986
|
+
outgoing: direction === 'outgoing' ? 1 : 0,
|
|
2987
|
+
incoming: direction === 'incoming' ? 1 : 0
|
|
2988
|
+
})
|
|
2989
|
+
}
|
|
2990
|
+
|
|
2991
|
+
const external = Array.from(externalByGroup.values())
|
|
2992
|
+
.sort((left, right) => right.weight - left.weight || left.title.localeCompare(right.title))
|
|
2993
|
+
const outgoing = external
|
|
2994
|
+
.filter(item => item.outgoing > 0)
|
|
2995
|
+
.slice(0, 18)
|
|
2996
|
+
const incoming = external
|
|
2997
|
+
.filter(item => item.incoming > 0)
|
|
2998
|
+
.slice(0, 18)
|
|
2999
|
+
|
|
3000
|
+
return { outgoing, incoming, internalEdgeCount, externalEdgeCount }
|
|
3001
|
+
}
|
|
3002
|
+
|
|
2923
3003
|
const fetchNodeDetails = async node => {
|
|
2924
3004
|
const cached = state.nodeDetails.get(node.id)
|
|
2925
3005
|
if (cached) {
|
|
@@ -2942,8 +3022,43 @@ const fetchNodeDetails = async node => {
|
|
|
2942
3022
|
|
|
2943
3023
|
const wait = async (milliseconds) => new Promise(resolve => setTimeout(resolve, milliseconds))
|
|
2944
3024
|
|
|
3025
|
+
const openGroupDialog = groupNode => {
|
|
3026
|
+
const group = state.groupById.get(groupNode.groupId)
|
|
3027
|
+
if (!group) return
|
|
3028
|
+
|
|
3029
|
+
const groupLinks = linkedGroupsForGroupNode(groupNode)
|
|
3030
|
+
const title = group.title || 'Group'
|
|
3031
|
+
const groupType = group.childGroupIds.length > 0 ? 'Hierarchical group' : 'Leaf group'
|
|
3032
|
+
const nodeCount = group.nodeIds.length
|
|
3033
|
+
const childGroupCount = group.childGroupIds.length
|
|
3034
|
+
const radius = Math.round(childGraphRenderRadius(group))
|
|
3035
|
+
|
|
3036
|
+
elements.contentTitle.textContent = title
|
|
3037
|
+
elements.contentPath.textContent = groupType + ' - level ' + group.level
|
|
3038
|
+
elements.contentTags.innerHTML = [
|
|
3039
|
+
'<span>segment: ' + escapeHtml(group.segment || 'root') + '</span>',
|
|
3040
|
+
'<span>cluster: ' + escapeHtml(group.group || 'root') + '</span>'
|
|
3041
|
+
].join('')
|
|
3042
|
+
elements.contentOutgoing.innerHTML = list(groupLinks.outgoing)
|
|
3043
|
+
elements.contentIncoming.innerHTML = list(groupLinks.incoming)
|
|
3044
|
+
elements.contentBody.textContent = [
|
|
3045
|
+
'Nodes: ' + nodeCount,
|
|
3046
|
+
'Child groups: ' + childGroupCount,
|
|
3047
|
+
'Internal links: ' + groupLinks.internalEdgeCount,
|
|
3048
|
+
'External links: ' + groupLinks.externalEdgeCount,
|
|
3049
|
+
'Render radius: ' + radius
|
|
3050
|
+
].join('\\n')
|
|
3051
|
+
if (!elements.contentDialog.open) {
|
|
3052
|
+
elements.contentDialog.show()
|
|
3053
|
+
}
|
|
3054
|
+
}
|
|
3055
|
+
|
|
2945
3056
|
const openContentDialog = async node => {
|
|
2946
|
-
if (!node
|
|
3057
|
+
if (!node) return
|
|
3058
|
+
if (node.isGroupNode) {
|
|
3059
|
+
openGroupDialog(node)
|
|
3060
|
+
return
|
|
3061
|
+
}
|
|
2947
3062
|
elements.contentTitle.textContent = node.title || 'Loading...'
|
|
2948
3063
|
elements.contentPath.textContent = node.path || 'Loading...'
|
|
2949
3064
|
elements.contentTags.innerHTML = Array.isArray(node.tags) && node.tags.length
|
|
@@ -2954,7 +3069,7 @@ const openContentDialog = async node => {
|
|
|
2954
3069
|
elements.contentIncoming.innerHTML = list(initialLinks.incoming)
|
|
2955
3070
|
elements.contentBody.textContent = 'Loading note content...'
|
|
2956
3071
|
if (!elements.contentDialog.open) {
|
|
2957
|
-
elements.contentDialog.
|
|
3072
|
+
elements.contentDialog.show()
|
|
2958
3073
|
}
|
|
2959
3074
|
|
|
2960
3075
|
const applyDetailToDialog = detail => {
|
|
@@ -2986,7 +3101,8 @@ const openContentDialog = async node => {
|
|
|
2986
3101
|
}
|
|
2987
3102
|
}
|
|
2988
3103
|
|
|
2989
|
-
const
|
|
3104
|
+
const expandGroupNode = node => {
|
|
3105
|
+
if (!node?.isGroupNode) return
|
|
2990
3106
|
if (node?.isGroupNode) {
|
|
2991
3107
|
state.selected = node
|
|
2992
3108
|
const group = state.groupById.get(node.groupId)
|
|
@@ -3007,6 +3123,38 @@ const selectNode = (node, options = { openContent: false }) => {
|
|
|
3007
3123
|
markRenderDirty()
|
|
3008
3124
|
return
|
|
3009
3125
|
}
|
|
3126
|
+
}
|
|
3127
|
+
|
|
3128
|
+
const handleGroupNodePrimaryClick = node => {
|
|
3129
|
+
if (!node?.isGroupNode) return
|
|
3130
|
+
const alreadyFocused = state.selected?.isGroupNode && state.selected.groupId === node.groupId
|
|
3131
|
+
if (alreadyFocused && !state.zoomTransition.active) {
|
|
3132
|
+
selectNode(node, { openContent: true })
|
|
3133
|
+
return
|
|
3134
|
+
}
|
|
3135
|
+
expandGroupNode(node)
|
|
3136
|
+
}
|
|
3137
|
+
|
|
3138
|
+
const selectNode = (node, options = { openContent: false }) => {
|
|
3139
|
+
if (!node) {
|
|
3140
|
+
state.selected = null
|
|
3141
|
+
if (elements.contentDialog.open) {
|
|
3142
|
+
elements.contentDialog.close()
|
|
3143
|
+
}
|
|
3144
|
+
markRenderDirty()
|
|
3145
|
+
return
|
|
3146
|
+
}
|
|
3147
|
+
|
|
3148
|
+
if (node?.isGroupNode) {
|
|
3149
|
+
state.selected = node
|
|
3150
|
+
if (options.openContent) {
|
|
3151
|
+
openContentDialog(node).catch(() => {
|
|
3152
|
+
elements.contentBody.textContent = 'Unable to load group details.'
|
|
3153
|
+
})
|
|
3154
|
+
}
|
|
3155
|
+
markRenderDirty()
|
|
3156
|
+
return
|
|
3157
|
+
}
|
|
3010
3158
|
state.selected = node
|
|
3011
3159
|
if (node && options.openContent) {
|
|
3012
3160
|
openContentDialog(node).catch(() => {
|
|
@@ -3087,10 +3235,6 @@ const wheelZoomFactor = event => {
|
|
|
3087
3235
|
}
|
|
3088
3236
|
|
|
3089
3237
|
const handleWheelZoom = event => {
|
|
3090
|
-
if (elements.contentDialog?.open) {
|
|
3091
|
-
return
|
|
3092
|
-
}
|
|
3093
|
-
|
|
3094
3238
|
event.preventDefault()
|
|
3095
3239
|
const rect = canvas.getBoundingClientRect()
|
|
3096
3240
|
const rawCursorX = Number.isFinite(event.offsetX) ? event.offsetX : event.clientX - rect.left
|
|
@@ -3159,7 +3303,9 @@ const bindEvents = () => {
|
|
|
3159
3303
|
const point = worldPoint(event)
|
|
3160
3304
|
const node = hitNode(point)
|
|
3161
3305
|
if (node) {
|
|
3162
|
-
|
|
3306
|
+
if (!node.isGroupNode) {
|
|
3307
|
+
selectNode(node, { openContent: true })
|
|
3308
|
+
}
|
|
3163
3309
|
return
|
|
3164
3310
|
}
|
|
3165
3311
|
|
|
@@ -3223,8 +3369,14 @@ const bindEvents = () => {
|
|
|
3223
3369
|
settleNeighborhoodAroundNode(draggedNode)
|
|
3224
3370
|
markRenderDirty()
|
|
3225
3371
|
}
|
|
3226
|
-
if (
|
|
3227
|
-
|
|
3372
|
+
if (!state.pointer.moved) {
|
|
3373
|
+
const clickedNode = draggedNode ?? state.hovered
|
|
3374
|
+
if (clickedNode?.isGroupNode) {
|
|
3375
|
+
handleGroupNodePrimaryClick(clickedNode)
|
|
3376
|
+
} else {
|
|
3377
|
+
selectNode(clickedNode, { openContent: true })
|
|
3378
|
+
}
|
|
3379
|
+
}
|
|
3228
3380
|
state.pointer = { x: 0, y: 0, down: false, dragNode: null, moved: false }
|
|
3229
3381
|
canvas.releasePointerCapture(event.pointerId)
|
|
3230
3382
|
})
|
|
@@ -3312,6 +3464,8 @@ const loadGraph = async (options = { reset: false }) => {
|
|
|
3312
3464
|
state.groups = layout.groups
|
|
3313
3465
|
state.hierarchyFocusGroupId = null
|
|
3314
3466
|
state.hierarchyFocusStack = []
|
|
3467
|
+
state.hierarchyRevealFocusGroupId = null
|
|
3468
|
+
state.hierarchyRevealBudget = 1
|
|
3315
3469
|
state.groupById = new Map(state.groups.map(group => [group.id, group]))
|
|
3316
3470
|
state.leafGroups = state.groups.filter(group => group.nodeIds.length > 0)
|
|
3317
3471
|
state.nodeLeafGroupById = new Map(state.leafGroups.flatMap(group => group.nodeIds.map(nodeId => [nodeId, group])))
|
|
@@ -59,6 +59,36 @@ const parseNumber = (value, fallback) => {
|
|
|
59
59
|
};
|
|
60
60
|
const compactGraphLayoutThreshold = 12_000;
|
|
61
61
|
const compactGraphLayoutEdgeLimit = 60_000;
|
|
62
|
+
const graphLayoutBodyCacheLimit = 8;
|
|
63
|
+
const graphLayoutBodyCache = new Map();
|
|
64
|
+
let cachedClientHtml = null;
|
|
65
|
+
let cachedClientCss = null;
|
|
66
|
+
let cachedClientJs = null;
|
|
67
|
+
let cachedClientWorkerJs = null;
|
|
68
|
+
const readClientHtml = () => {
|
|
69
|
+
if (cachedClientHtml === null) {
|
|
70
|
+
cachedClientHtml = createClientHtml();
|
|
71
|
+
}
|
|
72
|
+
return cachedClientHtml;
|
|
73
|
+
};
|
|
74
|
+
const readClientCss = () => {
|
|
75
|
+
if (cachedClientCss === null) {
|
|
76
|
+
cachedClientCss = createClientCss();
|
|
77
|
+
}
|
|
78
|
+
return cachedClientCss;
|
|
79
|
+
};
|
|
80
|
+
const readClientJs = () => {
|
|
81
|
+
if (cachedClientJs === null) {
|
|
82
|
+
cachedClientJs = createClientJs();
|
|
83
|
+
}
|
|
84
|
+
return cachedClientJs;
|
|
85
|
+
};
|
|
86
|
+
const readClientWorkerJs = () => {
|
|
87
|
+
if (cachedClientWorkerJs === null) {
|
|
88
|
+
cachedClientWorkerJs = createClientWorkerJs();
|
|
89
|
+
}
|
|
90
|
+
return cachedClientWorkerJs;
|
|
91
|
+
};
|
|
62
92
|
const compactGraphLayoutEdgeLimitFor = (nodeCount) => {
|
|
63
93
|
if (nodeCount > 100_000)
|
|
64
94
|
return 15_000;
|
|
@@ -146,18 +176,31 @@ const compactLayoutPayload = (layout) => {
|
|
|
146
176
|
};
|
|
147
177
|
};
|
|
148
178
|
const encodeLayoutPayload = (layout) => layout.nodes.length > compactGraphLayoutThreshold ? compactLayoutPayload(layout) : { compact: false, layout: stripLayoutContent(layout) };
|
|
179
|
+
const readGraphLayoutBody = (signature) => graphLayoutBodyCache.get(signature) ?? null;
|
|
180
|
+
const storeGraphLayoutBody = (signature, body) => {
|
|
181
|
+
if (graphLayoutBodyCache.has(signature)) {
|
|
182
|
+
graphLayoutBodyCache.delete(signature);
|
|
183
|
+
}
|
|
184
|
+
graphLayoutBodyCache.set(signature, body);
|
|
185
|
+
while (graphLayoutBodyCache.size > graphLayoutBodyCacheLimit) {
|
|
186
|
+
const oldest = graphLayoutBodyCache.keys().next().value;
|
|
187
|
+
if (!oldest)
|
|
188
|
+
break;
|
|
189
|
+
graphLayoutBodyCache.delete(oldest);
|
|
190
|
+
}
|
|
191
|
+
};
|
|
149
192
|
export const route = async (request, url, vaultPath) => {
|
|
150
193
|
if (isReadMethod(request) && (url.pathname === '/' || url.pathname === '/index.html')) {
|
|
151
|
-
return createResponse(
|
|
194
|
+
return createResponse(readClientHtml(), 200, contentTypes['.html']);
|
|
152
195
|
}
|
|
153
196
|
if (isReadMethod(request) && url.pathname === '/styles.css') {
|
|
154
|
-
return createResponse(
|
|
197
|
+
return createResponse(readClientCss(), 200, contentTypes['.css']);
|
|
155
198
|
}
|
|
156
199
|
if (isReadMethod(request) && url.pathname === '/app.js') {
|
|
157
|
-
return createResponse(
|
|
200
|
+
return createResponse(readClientJs(), 200, contentTypes['.js']);
|
|
158
201
|
}
|
|
159
202
|
if (isReadMethod(request) && url.pathname === '/app-worker.js') {
|
|
160
|
-
return createResponse(
|
|
203
|
+
return createResponse(readClientWorkerJs(), 200, contentTypes['.js']);
|
|
161
204
|
}
|
|
162
205
|
if (isReadMethod(request) && url.pathname === '/api/graph') {
|
|
163
206
|
return createResponse(createJsonResponse(await getGraph(vaultPath, readAgentQuery(url))), 200, contentTypes['.json']);
|
|
@@ -167,8 +210,6 @@ export const route = async (request, url, vaultPath) => {
|
|
|
167
210
|
const requestEtags = request.headers['if-none-match'];
|
|
168
211
|
const notModified = sameEntityTag(requestEtags, signature);
|
|
169
212
|
const etag = encodeEntityTag(signature);
|
|
170
|
-
const body = createJsonResponse({ signature, ...encodeLayoutPayload(layout) });
|
|
171
|
-
const jsonResponse = createResponse(body, 200, contentTypes['.json']);
|
|
172
213
|
const notModifiedResponse = createResponse('', 304, contentTypes['.json']);
|
|
173
214
|
if (notModified) {
|
|
174
215
|
return {
|
|
@@ -179,6 +220,12 @@ export const route = async (request, url, vaultPath) => {
|
|
|
179
220
|
}
|
|
180
221
|
};
|
|
181
222
|
}
|
|
223
|
+
const cachedBody = readGraphLayoutBody(signature);
|
|
224
|
+
const body = cachedBody ?? createJsonResponse({ signature, ...encodeLayoutPayload(layout) });
|
|
225
|
+
if (!cachedBody) {
|
|
226
|
+
storeGraphLayoutBody(signature, body);
|
|
227
|
+
}
|
|
228
|
+
const jsonResponse = createResponse(body, 200, contentTypes['.json']);
|
|
182
229
|
return {
|
|
183
230
|
...jsonResponse,
|
|
184
231
|
headers: {
|
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
|
|
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 empty canvas zooms in at cursor position. Clicking a hierarchy group expands that group from the same world position; clicking the same focused group again opens its details panel. Totals for notes, links and tags stay visible as floating metrics under the Brainlink title, and node details open in a non-modal side panel (tags, outgoing links, backlinks and Markdown content), so zoom and pan remain available during inspection. 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 with every aggregated link between visible group nodes, zoom-in grows the focused node into a local child graph anchored at the same point, once the child graph is framed the sibling groups stop rendering, and groups with child groups open another graph level instead of jumping directly to leaf notes.
|
|
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/docs/ARCHITECTURE.md
CHANGED
|
@@ -171,6 +171,7 @@ HTTP request
|
|
|
171
171
|
```
|
|
172
172
|
|
|
173
173
|
The HTTP API is local-first and unauthenticated. It is meant for local agents, browser UI, and development workflows.
|
|
174
|
+
The route adapter caches generated frontend assets (`/`, `/styles.css`, `/app.js`, `/app-worker.js`) and graph-layout JSON payloads by layout signature to reduce repeated CPU and allocation pressure during navigation.
|
|
174
175
|
|
|
175
176
|
## MCP Flow
|
|
176
177
|
|
package/package.json
CHANGED