@andespindola/brainlink 0.1.0-beta.134 → 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 +175 -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,8 +18,9 @@ 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
|
|
24
25
|
const hierarchyChildRevealGrowthRatio = 0.3
|
|
25
26
|
const hierarchyChildRevealGrowthFloor = 2
|
|
@@ -1229,6 +1230,15 @@ const smoothProgress = value => {
|
|
|
1229
1230
|
return bounded * bounded * (3 - 2 * bounded)
|
|
1230
1231
|
}
|
|
1231
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
|
+
|
|
1232
1242
|
const groupRenderRadius = group => {
|
|
1233
1243
|
const childCount = Math.max(group.nodeIds.length, group.childGroupIds.length, 1)
|
|
1234
1244
|
return 10 + Math.min(Math.log2(childCount + 1) * 4.2, 22)
|
|
@@ -1451,7 +1461,7 @@ const selectViewportItemsWithFill = (items, viewport, limit = renderNodeBudget)
|
|
|
1451
1461
|
return visible.concat(fill)
|
|
1452
1462
|
}
|
|
1453
1463
|
|
|
1454
|
-
const updateHierarchyFocusGroup = (groups,
|
|
1464
|
+
const updateHierarchyFocusGroup = (groups, _viewport) => {
|
|
1455
1465
|
const activeGroupIds = new Set(groups.map(group => group.id))
|
|
1456
1466
|
state.hierarchyFocusStack = state.hierarchyFocusStack.filter(groupId => state.groupById.has(groupId))
|
|
1457
1467
|
const parentGroup = activeHierarchyParentGroup()
|
|
@@ -1466,7 +1476,6 @@ const updateHierarchyFocusGroup = (groups, viewport) => {
|
|
|
1466
1476
|
const current = state.hierarchyFocusGroupId
|
|
1467
1477
|
? groups.find(group => group.id === state.hierarchyFocusGroupId) ?? null
|
|
1468
1478
|
: null
|
|
1469
|
-
const currentCoverage = current ? groupViewportCoverage(current, viewport) : 0
|
|
1470
1479
|
const hasActiveFocusedTransition =
|
|
1471
1480
|
Boolean(current) &&
|
|
1472
1481
|
state.zoomTransition.active &&
|
|
@@ -1477,36 +1486,21 @@ const updateHierarchyFocusGroup = (groups, viewport) => {
|
|
|
1477
1486
|
if (hasActiveFocusedTransition) {
|
|
1478
1487
|
return current
|
|
1479
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
|
|
1480
1493
|
|
|
1481
|
-
if (
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
currentCoverage >= hierarchyMicroExitCoverage
|
|
1485
|
-
) {
|
|
1486
|
-
if (current.childGroupIds.length > 0 && state.hierarchyFocusStack.at(-1) !== current.id) {
|
|
1487
|
-
state.hierarchyFocusStack = [...state.hierarchyFocusStack, current.id]
|
|
1488
|
-
}
|
|
1489
|
-
return current
|
|
1490
|
-
}
|
|
1491
|
-
|
|
1492
|
-
const candidate = groups
|
|
1493
|
-
.map(group => groupWithCoverage(group, viewport))
|
|
1494
|
-
.sort((left, right) => right.coverage - left.coverage)[0]
|
|
1495
|
-
|
|
1496
|
-
if (
|
|
1497
|
-
candidate &&
|
|
1498
|
-
state.transform.scale >= hierarchyFocusAcquireScale &&
|
|
1499
|
-
candidate.coverage >= hierarchyFocusAcquireCoverage
|
|
1500
|
-
) {
|
|
1501
|
-
state.hierarchyFocusGroupId = candidate.group.id
|
|
1502
|
-
if (candidate.group.childGroupIds.length > 0 && state.hierarchyFocusStack.at(-1) !== candidate.group.id) {
|
|
1503
|
-
state.hierarchyFocusStack = [...state.hierarchyFocusStack, candidate.group.id]
|
|
1504
|
-
}
|
|
1505
|
-
return candidate.group
|
|
1494
|
+
if (selectedGroup && state.transform.scale >= hierarchyMicroExitScale) {
|
|
1495
|
+
state.hierarchyFocusGroupId = selectedGroup.id
|
|
1496
|
+
return selectedGroup
|
|
1506
1497
|
}
|
|
1507
1498
|
|
|
1508
1499
|
if (state.hierarchyFocusStack.length > 0 && state.transform.scale < hierarchyMicroExitScale) {
|
|
1509
1500
|
state.hierarchyFocusStack = state.hierarchyFocusStack.slice(0, -1)
|
|
1501
|
+
if (state.selected?.isGroupNode) {
|
|
1502
|
+
state.selected = null
|
|
1503
|
+
}
|
|
1510
1504
|
}
|
|
1511
1505
|
state.hierarchyFocusGroupId = null
|
|
1512
1506
|
return null
|
|
@@ -1532,11 +1526,7 @@ const updateHierarchyChildRevealBudget = (focusGroupId, targetLimit) => {
|
|
|
1532
1526
|
return state.hierarchyRevealBudget
|
|
1533
1527
|
}
|
|
1534
1528
|
|
|
1535
|
-
const hierarchyViewportProgress = (group,
|
|
1536
|
-
const coverage = groupViewportCoverage(group, viewport)
|
|
1537
|
-
const coverageProgress = (coverage - hierarchyFocusAcquireCoverage) / (hierarchyMicroEnterCoverage - hierarchyFocusAcquireCoverage)
|
|
1538
|
-
const scaleProgress = (state.transform.scale - hierarchyFocusAcquireScale) / (hierarchyMicroEnterScale - hierarchyFocusAcquireScale)
|
|
1539
|
-
const viewportProgress = smoothProgress(Math.min(coverageProgress, scaleProgress))
|
|
1529
|
+
const hierarchyViewportProgress = (group, _viewport) => {
|
|
1540
1530
|
const hasActiveFocusedTransition =
|
|
1541
1531
|
state.zoomTransition.active &&
|
|
1542
1532
|
state.zoomTransition.source === 'group' &&
|
|
@@ -1544,13 +1534,12 @@ const hierarchyViewportProgress = (group, viewport) => {
|
|
|
1544
1534
|
state.selected.groupId === group.id
|
|
1545
1535
|
|
|
1546
1536
|
if (!hasActiveFocusedTransition) {
|
|
1547
|
-
return
|
|
1537
|
+
return 1
|
|
1548
1538
|
}
|
|
1549
1539
|
|
|
1550
1540
|
const targetSpan = Math.max(state.zoomTransition.targetScale - hierarchyFocusAcquireScale, 0.0001)
|
|
1551
1541
|
const transitionScaleProgress = (state.transform.scale - hierarchyFocusAcquireScale) / targetSpan
|
|
1552
|
-
|
|
1553
|
-
return Math.max(viewportProgress, transitionProgress)
|
|
1542
|
+
return Math.pow(Math.max(0, Math.min(1, transitionScaleProgress)), 2.8)
|
|
1554
1543
|
}
|
|
1555
1544
|
|
|
1556
1545
|
const groupEdgesForRenderedGroups = (groupNodes) => {
|
|
@@ -1614,9 +1603,10 @@ const computeHierarchyRenderVisibility = (viewport) => {
|
|
|
1614
1603
|
const groups = selectViewportItemsWithFill(hierarchyGroupsForScale(), viewport, renderNodeBudget)
|
|
1615
1604
|
const focus = updateHierarchyFocusGroup(groups, viewport)
|
|
1616
1605
|
const progress = focus ? hierarchyViewportProgress(focus, viewport) : 0
|
|
1606
|
+
const revealProgress = childRevealProgressForFocus(progress)
|
|
1617
1607
|
const groupNodes = groups.map(createGroupRenderNode)
|
|
1618
1608
|
|
|
1619
|
-
if (!focus ||
|
|
1609
|
+
if (!focus || revealProgress <= 0) {
|
|
1620
1610
|
state.hierarchyRevealFocusGroupId = null
|
|
1621
1611
|
state.hierarchyRevealBudget = 1
|
|
1622
1612
|
state.renderNodes = groupNodes
|
|
@@ -1629,23 +1619,22 @@ const computeHierarchyRenderVisibility = (viewport) => {
|
|
|
1629
1619
|
const focusChildGroups = focus.childGroupIds
|
|
1630
1620
|
.map(groupId => state.groupById.get(groupId))
|
|
1631
1621
|
.filter(Boolean)
|
|
1632
|
-
const
|
|
1633
|
-
const childTargetLimit = Math.min(renderNodeBudget, Math.max(1, Math.floor(renderNodeBudget * childRevealProgress)))
|
|
1622
|
+
const childTargetLimit = Math.min(renderNodeBudget, Math.max(1, Math.floor(renderNodeBudget * revealProgress)))
|
|
1634
1623
|
const childLimit = updateHierarchyChildRevealBudget(focus.id, childTargetLimit)
|
|
1635
1624
|
const focusRenderNode = groupNodes.find(node => node.groupId === focus.id) ?? createGroupRenderNode(focus)
|
|
1636
1625
|
const arrangedChildren = focusChildGroups.length > 0
|
|
1637
1626
|
? arrangeChildGroupNodes(focusChildGroups, focus, focusRenderNode)
|
|
1638
1627
|
: arrangeChildGraphNodes(rawChildNodes, focus, focusRenderNode)
|
|
1639
1628
|
const childNodes = selectStableSampleNodes(arrangedChildren, childLimit)
|
|
1640
|
-
.map(node => interpolateNodeFromGroup(node, focusRenderNode,
|
|
1629
|
+
.map(node => interpolateNodeFromGroup(node, focusRenderNode, revealProgress))
|
|
1641
1630
|
const childIds = new Set(childNodes.map(node => node.id))
|
|
1642
1631
|
const childById = new Map(childNodes.map(node => [node.id, node]))
|
|
1643
|
-
const isMicroView =
|
|
1632
|
+
const isMicroView = revealProgress >= hierarchyFocusedOnlyProgress
|
|
1644
1633
|
const visibleGroupNodes = isMicroView
|
|
1645
1634
|
? []
|
|
1646
|
-
: groupNodes.filter(node => node.groupId !== focus.id ||
|
|
1635
|
+
: groupNodes.filter(node => node.groupId !== focus.id || revealProgress < 0.96)
|
|
1647
1636
|
const groupEdges = isMicroView ? [] : groupEdgesForRenderedGroups(visibleGroupNodes)
|
|
1648
|
-
const childEdges = (isMicroView ||
|
|
1637
|
+
const childEdges = (isMicroView || revealProgress > 0)
|
|
1649
1638
|
? focusChildGroups.length > 0
|
|
1650
1639
|
? groupEdgesForRenderedGroups(childNodes)
|
|
1651
1640
|
: collectVisibleEdgesForNodes(childIds).map(edge => ({
|
|
@@ -2951,6 +2940,66 @@ const linkedNodes = node => {
|
|
|
2951
2940
|
return { outgoing, incoming }
|
|
2952
2941
|
}
|
|
2953
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
|
+
|
|
2954
3003
|
const fetchNodeDetails = async node => {
|
|
2955
3004
|
const cached = state.nodeDetails.get(node.id)
|
|
2956
3005
|
if (cached) {
|
|
@@ -2973,8 +3022,43 @@ const fetchNodeDetails = async node => {
|
|
|
2973
3022
|
|
|
2974
3023
|
const wait = async (milliseconds) => new Promise(resolve => setTimeout(resolve, milliseconds))
|
|
2975
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
|
+
|
|
2976
3056
|
const openContentDialog = async node => {
|
|
2977
|
-
if (!node
|
|
3057
|
+
if (!node) return
|
|
3058
|
+
if (node.isGroupNode) {
|
|
3059
|
+
openGroupDialog(node)
|
|
3060
|
+
return
|
|
3061
|
+
}
|
|
2978
3062
|
elements.contentTitle.textContent = node.title || 'Loading...'
|
|
2979
3063
|
elements.contentPath.textContent = node.path || 'Loading...'
|
|
2980
3064
|
elements.contentTags.innerHTML = Array.isArray(node.tags) && node.tags.length
|
|
@@ -2985,7 +3069,7 @@ const openContentDialog = async node => {
|
|
|
2985
3069
|
elements.contentIncoming.innerHTML = list(initialLinks.incoming)
|
|
2986
3070
|
elements.contentBody.textContent = 'Loading note content...'
|
|
2987
3071
|
if (!elements.contentDialog.open) {
|
|
2988
|
-
elements.contentDialog.
|
|
3072
|
+
elements.contentDialog.show()
|
|
2989
3073
|
}
|
|
2990
3074
|
|
|
2991
3075
|
const applyDetailToDialog = detail => {
|
|
@@ -3017,7 +3101,8 @@ const openContentDialog = async node => {
|
|
|
3017
3101
|
}
|
|
3018
3102
|
}
|
|
3019
3103
|
|
|
3020
|
-
const
|
|
3104
|
+
const expandGroupNode = node => {
|
|
3105
|
+
if (!node?.isGroupNode) return
|
|
3021
3106
|
if (node?.isGroupNode) {
|
|
3022
3107
|
state.selected = node
|
|
3023
3108
|
const group = state.groupById.get(node.groupId)
|
|
@@ -3038,6 +3123,38 @@ const selectNode = (node, options = { openContent: false }) => {
|
|
|
3038
3123
|
markRenderDirty()
|
|
3039
3124
|
return
|
|
3040
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
|
+
}
|
|
3041
3158
|
state.selected = node
|
|
3042
3159
|
if (node && options.openContent) {
|
|
3043
3160
|
openContentDialog(node).catch(() => {
|
|
@@ -3118,10 +3235,6 @@ const wheelZoomFactor = event => {
|
|
|
3118
3235
|
}
|
|
3119
3236
|
|
|
3120
3237
|
const handleWheelZoom = event => {
|
|
3121
|
-
if (elements.contentDialog?.open) {
|
|
3122
|
-
return
|
|
3123
|
-
}
|
|
3124
|
-
|
|
3125
3238
|
event.preventDefault()
|
|
3126
3239
|
const rect = canvas.getBoundingClientRect()
|
|
3127
3240
|
const rawCursorX = Number.isFinite(event.offsetX) ? event.offsetX : event.clientX - rect.left
|
|
@@ -3190,7 +3303,9 @@ const bindEvents = () => {
|
|
|
3190
3303
|
const point = worldPoint(event)
|
|
3191
3304
|
const node = hitNode(point)
|
|
3192
3305
|
if (node) {
|
|
3193
|
-
|
|
3306
|
+
if (!node.isGroupNode) {
|
|
3307
|
+
selectNode(node, { openContent: true })
|
|
3308
|
+
}
|
|
3194
3309
|
return
|
|
3195
3310
|
}
|
|
3196
3311
|
|
|
@@ -3254,8 +3369,14 @@ const bindEvents = () => {
|
|
|
3254
3369
|
settleNeighborhoodAroundNode(draggedNode)
|
|
3255
3370
|
markRenderDirty()
|
|
3256
3371
|
}
|
|
3257
|
-
if (
|
|
3258
|
-
|
|
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
|
+
}
|
|
3259
3380
|
state.pointer = { x: 0, y: 0, down: false, dragNode: null, moved: false }
|
|
3260
3381
|
canvas.releasePointerCapture(event.pointerId)
|
|
3261
3382
|
})
|
|
@@ -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