@andespindola/brainlink 0.1.0-beta.41 → 0.1.0-beta.42

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/CHANGELOG.md CHANGED
@@ -33,6 +33,11 @@
33
33
  - Fixed graph modal content loading by correcting agent query parameter composition for `/api/graph-node` and `/api/graph-filter` requests.
34
34
  - Improved 50k+ graph rendering performance with viewport-aware spatial node culling, cached render visibility, and node-adjacent edge selection to avoid full graph scans every frame.
35
35
  - Added incremental vault indexing with file snapshots to reuse unchanged documents/chunks/embeddings, plus adaptive search-pack rebuild thresholds to avoid full re-compression on small edits.
36
+ - Reduced large-graph HTTP payload size with compact `/api/graph-layout` encoding for high-node vaults and capped transmitted edges to improve UI load responsiveness.
37
+ - Added aggressive graph LOD clustering when zoomed out, dynamic per-zoom edge render budgets, and a dedicated frontend worker for off-main-thread graph filter matching.
38
+ - Improved Linux browser fallback launch stability by auto-applying Chromium compatibility flags (`--ozone-platform=x11`, `--disable-gpu`, `--disable-features=Vulkan,VaapiVideoDecoder`, `--disable-background-networking`) for app-window/browser modes.
39
+ - Improved massive-graph UI responsiveness with stricter render budgets, adaptive heavy-graph frame throttling, reduced interaction hit-test frequency, and URL-first agent selection on initial graph load.
40
+ - Improved 50k+ graph LOD behavior so zoomed-out views render lightweight cluster overviews and progressively reveal nodes/edges only as zoom increases.
36
41
 
37
42
  ## 0.1.0-beta.3
38
43
 
package/README.md CHANGED
@@ -81,6 +81,10 @@ Legacy `.jsonl.gz` packs are upgraded to `.blpk` automatically on first search/c
81
81
  - Local HTTP API.
82
82
  - Realtime graph UI with agent selector and colored knowledge groups.
83
83
  - Graph renderer optimized for large datasets with viewport-driven node culling and edge lookup by visible nodes.
84
+ - Large graph layout API automatically uses compact payload encoding and edge-cap transmission to reduce initial client load on very large vaults.
85
+ - Zoomed-out graph LOD now clusters dense regions and progressively expands nodes as zoom increases.
86
+ - Graph filtering runs in a dedicated browser worker to keep the UI thread responsive during heavy datasets.
87
+ - Edge rendering budgets adapt to zoom level to prevent frame spikes on large graph panoramas.
84
88
 
85
89
  ## Install
86
90
 
@@ -566,6 +570,7 @@ By default, `blink server` tries to open the graph in a native desktop GUI windo
566
570
 
567
571
  On Linux, native GUI is disabled by default for better startup performance. Enable it with `BRAINLINK_LINUX_NATIVE_GUI=1`.
568
572
  If native GUI launch is unavailable on your system, it falls back to dedicated app-window mode and then to the default browser.
573
+ For Chromium-family browsers on Linux (`chromium`, `chromium-browser`, `google-chrome`, `microsoft-edge`, `brave-browser`), Brainlink now auto-applies compatibility flags during launch (`--ozone-platform=x11`, `--disable-gpu`, `--disable-features=Vulkan,VaapiVideoDecoder`, `--disable-background-networking`) to avoid common Wayland/Vulkan/VAAPI startup issues.
569
574
  Use `--no-open` to keep it headless.
570
575
  When native GUI is used, the GUI window automatically closes when the `blink server` process stops.
571
576
 
@@ -585,6 +590,7 @@ The graph UI shows:
585
590
  - double-click on canvas zooms in at cursor position
586
591
  - floating graph totals (notes, links, tags) below the Brainlink title
587
592
  - large-graph rendering safeguards (edge draw caps, lower redraw rate, zoom-aware interaction)
593
+ - massive-graph LOD progression: zoomed-out views prefer lightweight clusters, then progressively reveal nodes and edges as zoom increases
588
594
 
589
595
  The server indexes before starting by default. Use `--no-index` to skip that step:
590
596
 
@@ -884,6 +890,7 @@ Starts the local read-only graph UI and HTTP API.
884
890
  By default, it tries to open a native desktop GUI window for the graph URL.
885
891
  On Linux, native GUI is disabled by default; enable it with `BRAINLINK_LINUX_NATIVE_GUI=1`.
886
892
  If native GUI launch is unavailable, it falls back to dedicated app-window mode and then browser open.
893
+ When fallback opens Chromium-family browsers on Linux, Brainlink automatically uses compatibility launch flags for stable rendering on Ubuntu/Wayland setups.
887
894
  Use `--no-open` to skip that behavior.
888
895
 
889
896
  The HTTP server only binds to loopback hosts such as `127.0.0.1`, `localhost` or `::1`.
@@ -1,13 +1,19 @@
1
1
  export const createClientJs = () => `const canvas = document.getElementById('graph')
2
2
  const ctx = canvas.getContext('2d')
3
3
  const largeGraphNodeThreshold = 4000
4
+ const massiveGraphNodeThreshold = 20000
4
5
  const largeGraphEdgeRenderLimit = 16000
5
- const renderNodeBudget = 1800
6
- const renderEdgeBudget = 5200
7
- const minNodePixelRadius = 1.8
6
+ const renderNodeBudget = 900
7
+ const renderEdgeBudget = 2400
8
+ const clusterActivationNodeThreshold = 600
9
+ const clusterZoomThreshold = 0.18
10
+ const clusterCellPixelSize = 64
11
+ const minNodePixelRadius = 2.3
8
12
  const viewportPaddingPx = 280
9
13
  const worldCoordinateLimit = 5_000_000
10
14
  const transformCoordinateLimit = 20_000_000
15
+ const hoverHitTestIntervalMs = 64
16
+ const overviewClusterMaxCount = 1400
11
17
  const state = {
12
18
  graph: { nodes: [], edges: [] },
13
19
  nodes: [],
@@ -16,6 +22,7 @@ const state = {
16
22
  visibleEdges: [],
17
23
  renderNodes: [],
18
24
  renderEdges: [],
25
+ renderClusters: [],
19
26
  nodeDegrees: new Map(),
20
27
  selected: null,
21
28
  hovered: null,
@@ -29,13 +36,18 @@ const state = {
29
36
  cursor: { x: 0, y: 0, inCanvas: false },
30
37
  graphSignature: '',
31
38
  graphStatus: '',
39
+ graphTotals: { nodes: 0, edges: 0 },
32
40
  last: performance.now(),
33
41
  offscreenFrameCount: 0,
34
42
  recoveringViewport: false,
35
43
  renderVisibilityDirty: true,
36
44
  lastViewportKey: '',
37
45
  visibleNodeSpatial: { cellSize: 220, minX: 0, minY: 0, maxX: 0, maxY: 0, buckets: new Map() },
38
- visibleEdgeByNode: new Map()
46
+ visibleEdgeByNode: new Map(),
47
+ overviewClusters: [],
48
+ filterWorker: null,
49
+ filterReady: false,
50
+ lastHoverHitAt: 0
39
51
  }
40
52
 
41
53
  const byId = id => document.getElementById(id)
@@ -66,10 +78,20 @@ const elements = {
66
78
  }
67
79
 
68
80
  const zoomRange = {
69
- min: 0.05,
81
+ min: 0.0002,
70
82
  max: 4.5
71
83
  }
72
84
 
85
+ const initialAgentFromUrl = (() => {
86
+ try {
87
+ const raw = new URL(window.location.href).searchParams.get('agent')
88
+ const value = raw?.trim() ?? ''
89
+ return value.length > 0 ? value : ''
90
+ } catch {
91
+ return ''
92
+ }
93
+ })()
94
+
73
95
  const agentQuery = (separator = '?') => state.agentId ? separator + 'agent=' + encodeURIComponent(state.agentId) : ''
74
96
 
75
97
  const setGraphStatus = text => {
@@ -93,6 +115,67 @@ const graphTheme = {
93
115
  label: '#edf2f7'
94
116
  }
95
117
 
118
+ const initFilterWorker = () => {
119
+ if (typeof Worker === 'undefined') {
120
+ return
121
+ }
122
+ try {
123
+ const worker = new Worker('/app-worker.js')
124
+ worker.onmessage = event => {
125
+ const payload = event.data
126
+ if (!payload || typeof payload !== 'object') return
127
+
128
+ if (payload.type === 'ready') {
129
+ state.filterReady = true
130
+ if (state.nodes.length > 0) {
131
+ worker.postMessage({
132
+ type: 'load-nodes',
133
+ nodes: state.nodes.map(node => ({
134
+ id: node.id,
135
+ title: node.title,
136
+ path: node.path || '',
137
+ tags: Array.isArray(node.tags) ? node.tags : []
138
+ }))
139
+ })
140
+ }
141
+ return
142
+ }
143
+
144
+ if (payload.type === 'filter-result') {
145
+ const token = payload.token
146
+ if (token !== state.contentFilter.token) {
147
+ return
148
+ }
149
+
150
+ const ids = Array.isArray(payload.ids) ? payload.ids.filter(id => typeof id === 'string') : []
151
+ state.contentFilter.query = normalizeQuery(state.query)
152
+ state.contentFilter.ids = new Set(ids)
153
+ recomputeVisibility()
154
+ }
155
+ }
156
+ state.filterWorker = worker
157
+ } catch {
158
+ state.filterWorker = null
159
+ state.filterReady = false
160
+ }
161
+ }
162
+
163
+ const pushNodesToFilterWorker = () => {
164
+ if (!state.filterWorker || !state.filterReady) {
165
+ return
166
+ }
167
+
168
+ state.filterWorker.postMessage({
169
+ type: 'load-nodes',
170
+ nodes: state.nodes.map(node => ({
171
+ id: node.id,
172
+ title: node.title,
173
+ path: node.path || '',
174
+ tags: Array.isArray(node.tags) ? node.tags : []
175
+ }))
176
+ })
177
+ }
178
+
96
179
  const resize = () => {
97
180
  const rect = canvas.getBoundingClientRect()
98
181
  const width = Math.max(rect.width, 320)
@@ -111,7 +194,7 @@ const hubNodePattern = /\b(memory\s*hub|knowledge\s*hub|hub|moc|map|memory\s*map
111
194
  const localFilteredNodes = query =>
112
195
  state.nodes.filter(node =>
113
196
  node.title.toLowerCase().includes(query) ||
114
- node.path.toLowerCase().includes(query) ||
197
+ (node.path || '').toLowerCase().includes(query) ||
115
198
  node.tags.some(tag => tag.toLowerCase().includes(query))
116
199
  )
117
200
 
@@ -176,6 +259,7 @@ const recomputeVisibility = () => {
176
259
  state.visibleEdges = limitedEdges
177
260
  state.visibleNodeSpatial = createSpatialIndex(nodes)
178
261
  state.visibleEdgeByNode = createVisibleEdgeLookup(limitedEdges)
262
+ state.overviewClusters = nodes.length > massiveGraphNodeThreshold ? buildOverviewClusters(nodes) : []
179
263
  markRenderDirty()
180
264
  }
181
265
 
@@ -280,6 +364,94 @@ const createVisibleEdgeLookup = edges => {
280
364
  return lookup
281
365
  }
282
366
 
367
+ const buildOverviewClusters = nodes => {
368
+ if (nodes.length === 0) {
369
+ return []
370
+ }
371
+
372
+ const bounds = graphBounds(nodes)
373
+ if (!bounds) {
374
+ return []
375
+ }
376
+
377
+ const longest = Math.max(bounds.width, bounds.height, 1)
378
+ const cellSize = Math.max(longest / 56, 900)
379
+ const buckets = new Map()
380
+
381
+ for (let index = 0; index < nodes.length; index += 1) {
382
+ const node = nodes[index]
383
+ const keyX = Math.floor((node.x - bounds.minX) / cellSize)
384
+ const keyY = Math.floor((node.y - bounds.minY) / cellSize)
385
+ const key = keyX + ':' + keyY
386
+ const degree = state.nodeDegrees.get(node.id) ?? 0
387
+ const current = buckets.get(key)
388
+ if (current) {
389
+ current.count += 1
390
+ current.sumX += node.x
391
+ current.sumY += node.y
392
+ if (degree > current.degree) {
393
+ current.representative = node
394
+ current.degree = degree
395
+ }
396
+ continue
397
+ }
398
+
399
+ buckets.set(key, {
400
+ id: key,
401
+ count: 1,
402
+ sumX: node.x,
403
+ sumY: node.y,
404
+ representative: node,
405
+ degree
406
+ })
407
+ }
408
+
409
+ return Array.from(buckets.values())
410
+ .sort((left, right) => right.count - left.count)
411
+ .slice(0, overviewClusterMaxCount)
412
+ .map((cluster) => ({
413
+ id: cluster.id,
414
+ x: cluster.sumX / Math.max(cluster.count, 1),
415
+ y: cluster.sumY / Math.max(cluster.count, 1),
416
+ count: cluster.count,
417
+ representative: cluster.representative
418
+ }))
419
+ }
420
+
421
+ const filterOverviewClustersByViewport = viewport =>
422
+ state.overviewClusters.filter((cluster) =>
423
+ cluster.x >= viewport.minX &&
424
+ cluster.x <= viewport.maxX &&
425
+ cluster.y >= viewport.minY &&
426
+ cluster.y <= viewport.maxY
427
+ )
428
+
429
+ const edgeBudgetForCurrentFrame = () => {
430
+ const zoom = state.transform.scale
431
+ if (zoom < 0.12) return 380
432
+ if (zoom < 0.18) return 700
433
+ if (zoom < 0.28) return 1100
434
+ if (zoom < 0.45) return 1600
435
+ if (zoom < 0.7) return 2100
436
+ return renderEdgeBudget
437
+ }
438
+
439
+ const clusterBudgetForScale = (scale) => {
440
+ if (scale < 0.008) return 90
441
+ if (scale < 0.014) return 150
442
+ if (scale < 0.022) return 240
443
+ if (scale < 0.035) return 360
444
+ return 520
445
+ }
446
+
447
+ const nodeBudgetForScale = (scale) => {
448
+ if (scale < 0.035) return 220
449
+ if (scale < 0.06) return 360
450
+ if (scale < 0.09) return 520
451
+ if (scale < 0.14) return 720
452
+ return renderNodeBudget
453
+ }
454
+
283
455
  const collectVisibleEdgesForNodes = nodeIds => {
284
456
  if (nodeIds.size === 0) {
285
457
  return []
@@ -287,6 +459,7 @@ const collectVisibleEdgesForNodes = nodeIds => {
287
459
 
288
460
  const seen = new Set()
289
461
  const collected = []
462
+ const limit = edgeBudgetForCurrentFrame()
290
463
 
291
464
  nodeIds.forEach(nodeId => {
292
465
  const candidateEdges = state.visibleEdgeByNode.get(nodeId) ?? []
@@ -302,7 +475,7 @@ const collectVisibleEdgesForNodes = nodeIds => {
302
475
 
303
476
  seen.add(key)
304
477
  collected.push(edge)
305
- if (collected.length >= renderEdgeBudget) {
478
+ if (collected.length >= limit) {
306
479
  return
307
480
  }
308
481
  }
@@ -327,9 +500,35 @@ const fallbackViewportNodes = () => {
327
500
  return nodes
328
501
  }
329
502
 
503
+ const sampleVisibleNodes = (limit = renderNodeBudget) => {
504
+ if (state.visibleNodes.length === 0 || limit <= 0) {
505
+ return []
506
+ }
507
+
508
+ const nodes = []
509
+ const maxNodes = Math.min(Math.max(limit, 1), state.visibleNodes.length)
510
+ const step = Math.max(1, Math.ceil(state.visibleNodes.length / maxNodes))
511
+
512
+ for (let index = 0; index < state.visibleNodes.length && nodes.length < maxNodes; index += step) {
513
+ nodes.push(state.visibleNodes[index])
514
+ }
515
+
516
+ if (state.selected && !nodes.find(node => node.id === state.selected.id)) {
517
+ nodes.push(state.selected)
518
+ }
519
+
520
+ return nodes
521
+ }
522
+
330
523
  const clampScale = value => Math.max(zoomRange.min, Math.min(zoomRange.max, value))
331
524
  const isFiniteNumber = value => Number.isFinite(value)
332
525
  const isReasonableCoordinate = value => isFiniteNumber(value) && Math.abs(value) <= worldCoordinateLimit
526
+ const clampTransformCoordinate = value => {
527
+ if (!isFiniteNumber(value)) return 0
528
+ if (value > transformCoordinateLimit) return transformCoordinateLimit
529
+ if (value < -transformCoordinateLimit) return -transformCoordinateLimit
530
+ return value
531
+ }
333
532
 
334
533
  const graphBounds = nodes => {
335
534
  if (nodes.length === 0) return null
@@ -375,7 +574,7 @@ const autoFitScaleRangeByNodeCount = nodeCount => {
375
574
  if (nodeCount <= 600) return { min: 0.12, max: 0.72 }
376
575
  if (nodeCount <= 2000) return { min: 0.08, max: 0.52 }
377
576
  if (nodeCount <= 6000) return { min: 0.06, max: 0.32 }
378
- return { min: zoomRange.min, max: 0.24 }
577
+ return { min: 0.0008, max: 0.24 }
379
578
  }
380
579
 
381
580
  const fitView = (options = { useFiltered: true }) => {
@@ -387,6 +586,8 @@ const fitView = (options = { useFiltered: true }) => {
387
586
 
388
587
  if (!bounds) {
389
588
  state.transform = { x: width / 2, y: height / 2, scale: 1 }
589
+ state.offscreenFrameCount = 0
590
+ state.recoveringViewport = false
390
591
  markRenderDirty()
391
592
  return
392
593
  }
@@ -411,25 +612,62 @@ const fitView = (options = { useFiltered: true }) => {
411
612
  const centerY = (bounds.minY + bounds.maxY) / 2
412
613
 
413
614
  state.transform = {
414
- x: width / 2 - centerX * scale,
415
- y: height / 2 - centerY * scale,
416
- scale
615
+ x: clampTransformCoordinate(width / 2 - centerX * scale),
616
+ y: clampTransformCoordinate(height / 2 - centerY * scale),
617
+ scale: clampScale(scale)
417
618
  }
619
+ state.offscreenFrameCount = 0
620
+ state.recoveringViewport = false
418
621
  markRenderDirty()
419
622
  }
420
623
 
421
624
  const resetView = () => fitView({ useFiltered: false })
422
625
 
423
626
  const createLayout = graph => {
424
- const nodes = graph.nodes.map(node => ({
425
- ...node,
426
- x: Number.isFinite(node.x) ? node.x : 0,
427
- y: Number.isFinite(node.y) ? node.y : 0,
428
- vx: Number.isFinite(node.vx) ? node.vx : 0,
429
- vy: Number.isFinite(node.vy) ? node.vy : 0
430
- }))
627
+ const nodeRows = Array.isArray(graph.nodes) ? graph.nodes : []
628
+ const edgeRows = Array.isArray(graph.edges) ? graph.edges : []
629
+ const nodes = nodeRows.map(node => {
630
+ if (Array.isArray(node)) {
631
+ const [id, title, x, y, group, segment] = node
632
+ return {
633
+ id: typeof id === 'string' ? id : '',
634
+ title: typeof title === 'string' ? title : 'Untitled',
635
+ path: '',
636
+ tags: [],
637
+ group: typeof group === 'string' ? group : 'root',
638
+ segment: typeof segment === 'string' ? segment : 'root',
639
+ x: Number.isFinite(x) ? x : 0,
640
+ y: Number.isFinite(y) ? y : 0,
641
+ vx: 0,
642
+ vy: 0
643
+ }
644
+ }
645
+
646
+ return {
647
+ ...node,
648
+ path: typeof node.path === 'string' ? node.path : '',
649
+ tags: Array.isArray(node.tags) ? node.tags : [],
650
+ x: Number.isFinite(node.x) ? node.x : 0,
651
+ y: Number.isFinite(node.y) ? node.y : 0,
652
+ vx: Number.isFinite(node.vx) ? node.vx : 0,
653
+ vy: Number.isFinite(node.vy) ? node.vy : 0
654
+ }
655
+ })
431
656
  const nodeMap = new Map(nodes.map(node => [node.id, node]))
432
- const edges = graph.edges
657
+ const edges = edgeRows
658
+ .map(edge => {
659
+ if (Array.isArray(edge)) {
660
+ const [source, target, weight, priority] = edge
661
+ return {
662
+ source: typeof source === 'string' ? source : '',
663
+ target: typeof target === 'string' ? target : null,
664
+ targetTitle: '',
665
+ weight: Number.isFinite(weight) ? weight : 1,
666
+ priority: typeof priority === 'string' ? priority : 'normal'
667
+ }
668
+ }
669
+ return edge
670
+ })
433
671
  .filter(edge => edge.target && nodeMap.has(edge.source) && nodeMap.has(edge.target))
434
672
  .map(edge => ({ ...edge, sourceNode: nodeMap.get(edge.source), targetNode: nodeMap.get(edge.target) }))
435
673
  return { nodes, edges }
@@ -484,7 +722,8 @@ const syncContentFilter = async (query, token) => {
484
722
  }
485
723
 
486
724
  state.contentFilter.query = query
487
- state.contentFilter.ids = new Set(nodeIds)
725
+ const merged = new Set([...(state.contentFilter.ids instanceof Set ? state.contentFilter.ids : []), ...nodeIds])
726
+ state.contentFilter.ids = merged
488
727
  recomputeVisibility()
489
728
  }
490
729
 
@@ -505,6 +744,14 @@ const scheduleContentFilterSync = () => {
505
744
  ids: state.contentFilter.ids,
506
745
  token,
507
746
  timer: setTimeout(() => {
747
+ if (state.filterWorker && state.filterReady) {
748
+ state.filterWorker.postMessage({
749
+ type: 'filter',
750
+ query,
751
+ token,
752
+ limit: Math.max(state.nodes.length, 1)
753
+ })
754
+ }
508
755
  syncContentFilter(query, token).catch(() => {})
509
756
  }, 180)
510
757
  }
@@ -513,7 +760,11 @@ const scheduleContentFilterSync = () => {
513
760
  const tick = delta => {
514
761
  const nodes = state.renderNodes.length > 0 ? state.renderNodes : state.visibleNodes
515
762
  const edges = state.renderEdges.length > 0 ? state.renderEdges : state.visibleEdges
516
- if (nodes.length > 1200) {
763
+ const shouldRunPhysics =
764
+ state.nodes.length <= 8000 &&
765
+ nodes.length <= 320 &&
766
+ state.transform.scale >= 0.08
767
+ if (!shouldRunPhysics) {
517
768
  return
518
769
  }
519
770
  const strength = Math.min(delta / 16, 2)
@@ -587,7 +838,10 @@ const worldPoint = event => {
587
838
 
588
839
  const hitNode = point => {
589
840
  computeRenderVisibility()
590
- if (state.nodes.length > largeGraphNodeThreshold && state.transform.scale < 0.55) {
841
+ if (state.renderClusters.length > 0) {
842
+ return null
843
+ }
844
+ if (state.nodes.length > largeGraphNodeThreshold && state.transform.scale < 0.9) {
591
845
  return null
592
846
  }
593
847
 
@@ -648,7 +902,60 @@ const viewportNodeStride = () => {
648
902
  return 8
649
903
  }
650
904
 
905
+ const shouldRenderClusters = viewportNodes =>
906
+ state.transform.scale <= clusterZoomThreshold && viewportNodes.length >= clusterActivationNodeThreshold
907
+
908
+ const clusterViewportNodes = viewportNodes => {
909
+ if (!shouldRenderClusters(viewportNodes)) {
910
+ return []
911
+ }
912
+
913
+ const worldCellSize = Math.max(clusterCellPixelSize / Math.max(state.transform.scale, 0.0001), 1)
914
+ const buckets = new Map()
915
+
916
+ for (let index = 0; index < viewportNodes.length; index += 1) {
917
+ const node = viewportNodes[index]
918
+ const keyX = Math.floor(node.x / worldCellSize)
919
+ const keyY = Math.floor(node.y / worldCellSize)
920
+ const key = keyX + ':' + keyY
921
+ const current = buckets.get(key)
922
+ if (current) {
923
+ current.count += 1
924
+ current.sumX += node.x
925
+ current.sumY += node.y
926
+ if ((state.nodeDegrees.get(node.id) ?? 0) > current.degree) {
927
+ current.representative = node
928
+ current.degree = state.nodeDegrees.get(node.id) ?? 0
929
+ }
930
+ continue
931
+ }
932
+
933
+ buckets.set(key, {
934
+ id: key,
935
+ count: 1,
936
+ sumX: node.x,
937
+ sumY: node.y,
938
+ representative: node,
939
+ degree: state.nodeDegrees.get(node.id) ?? 0
940
+ })
941
+ }
942
+
943
+ return Array.from(buckets.values())
944
+ .sort((left, right) => right.count - left.count)
945
+ .slice(0, Math.min(renderNodeBudget, 900))
946
+ .map((cluster) => ({
947
+ id: cluster.id,
948
+ x: cluster.sumX / Math.max(cluster.count, 1),
949
+ y: cluster.sumY / Math.max(cluster.count, 1),
950
+ count: cluster.count,
951
+ representative: cluster.representative
952
+ }))
953
+ }
954
+
651
955
  const computeRenderVisibility = () => {
956
+ if (!hasValidTransform()) {
957
+ fitView({ useFiltered: true })
958
+ }
652
959
  const viewport = worldViewportBounds()
653
960
  const viewportKey =
654
961
  Math.round(viewport.minX * 10) + ':' +
@@ -665,12 +972,70 @@ const computeRenderVisibility = () => {
665
972
 
666
973
  if (state.visibleNodes.length <= 2000) {
667
974
  state.renderNodes = state.visibleNodes
975
+ state.renderClusters = []
668
976
  const ids = new Set(state.renderNodes.map((node) => node.id))
669
977
  state.renderEdges = collectVisibleEdgesForNodes(ids)
670
978
  return
671
979
  }
672
980
 
981
+ if (state.visibleNodes.length > massiveGraphNodeThreshold && state.transform.scale <= 0.035) {
982
+ const viewportClusters = filterOverviewClustersByViewport(viewport)
983
+ const clusters = viewportClusters.length > 0
984
+ ? viewportClusters
985
+ : state.overviewClusters.slice(0, Math.min(220, state.overviewClusters.length))
986
+ const clusterLimit = clusterBudgetForScale(state.transform.scale)
987
+ const limitedClusters = clusters.slice(0, Math.min(clusterLimit, clusters.length))
988
+ if (limitedClusters.length > 0) {
989
+ state.renderClusters = limitedClusters
990
+ state.renderNodes = limitedClusters.map((cluster) => cluster.representative)
991
+ state.renderEdges = []
992
+ return
993
+ }
994
+ }
995
+
996
+ if (state.visibleNodes.length > massiveGraphNodeThreshold && state.transform.scale <= 0.06) {
997
+ const viewportClusters = filterOverviewClustersByViewport(viewport)
998
+ const clusters = viewportClusters.length > 0
999
+ ? viewportClusters
1000
+ : state.overviewClusters.slice(0, Math.min(400, state.overviewClusters.length))
1001
+ const clusterLimit = clusterBudgetForScale(state.transform.scale)
1002
+ const limitedClusters = clusters.slice(0, Math.min(clusterLimit, clusters.length))
1003
+ if (limitedClusters.length > 0) {
1004
+ state.renderClusters = limitedClusters
1005
+ state.renderNodes = limitedClusters.map((cluster) => cluster.representative)
1006
+ state.renderEdges = []
1007
+ return
1008
+ }
1009
+ }
1010
+
1011
+ if (state.visibleNodes.length > massiveGraphNodeThreshold) {
1012
+ const sampleLimit = nodeBudgetForScale(state.transform.scale)
1013
+ const sampled = sampleVisibleNodes(Math.min(sampleLimit, renderNodeBudget))
1014
+ const sampledIds = new Set(sampled.map((node) => node.id))
1015
+ state.renderClusters = []
1016
+ state.renderNodes = sampled
1017
+ state.renderEdges = state.transform.scale >= 0.12 ? collectVisibleEdgesForNodes(sampledIds) : []
1018
+ return
1019
+ }
1020
+
1021
+ if (state.transform.scale <= 0.0015) {
1022
+ const sampled = sampleVisibleNodes(Math.min(renderNodeBudget, 900))
1023
+ const sampledIds = new Set(sampled.map((node) => node.id))
1024
+ state.renderClusters = []
1025
+ state.renderNodes = sampled
1026
+ state.renderEdges = collectVisibleEdgesForNodes(sampledIds)
1027
+ return
1028
+ }
1029
+
673
1030
  const viewportNodes = viewportNodesFromSpatialIndex(viewport)
1031
+ const clusters = clusterViewportNodes(viewportNodes)
1032
+ if (clusters.length > 0) {
1033
+ state.renderClusters = clusters
1034
+ state.renderNodes = clusters.map(cluster => cluster.representative)
1035
+ state.renderEdges = []
1036
+ return
1037
+ }
1038
+ state.renderClusters = []
674
1039
  const stride = viewportNodeStride()
675
1040
  const picked = []
676
1041
 
@@ -693,6 +1058,7 @@ const computeRenderVisibility = () => {
693
1058
  const fallbackNodes = fallbackViewportNodes()
694
1059
  const fallbackIds = new Set(fallbackNodes.map((node) => node.id))
695
1060
  state.renderNodes = fallbackNodes
1061
+ state.renderClusters = []
696
1062
  state.renderEdges = collectVisibleEdgesForNodes(fallbackIds)
697
1063
  return
698
1064
  }
@@ -702,6 +1068,14 @@ const computeRenderVisibility = () => {
702
1068
 
703
1069
  state.renderNodes = nodes
704
1070
  state.renderEdges = edges
1071
+
1072
+ if (state.renderNodes.length === 0 && state.visibleNodes.length > 0) {
1073
+ const fallbackNodes = sampleVisibleNodes(Math.min(renderNodeBudget, 260))
1074
+ const fallbackIds = new Set(fallbackNodes.map((node) => node.id))
1075
+ state.renderClusters = []
1076
+ state.renderNodes = fallbackNodes
1077
+ state.renderEdges = collectVisibleEdgesForNodes(fallbackIds)
1078
+ }
705
1079
  }
706
1080
 
707
1081
  const isNodeVisibleOnScreen = (node, width, height) => {
@@ -732,16 +1106,29 @@ const sanitizeNodePosition = node => {
732
1106
  if (!isFiniteNumber(node.vy) || Math.abs(node.vy) > worldCoordinateLimit) node.vy = 0
733
1107
  }
734
1108
 
735
- const sanitizeGraphState = () => {
1109
+ const sanitizeAllNodePositions = () => {
736
1110
  state.nodes.forEach(sanitizeNodePosition)
737
1111
  state.visibleNodes.forEach(sanitizeNodePosition)
1112
+ }
1113
+
1114
+ const sanitizeGraphState = () => {
738
1115
  state.renderNodes.forEach(sanitizeNodePosition)
739
1116
  }
740
1117
 
741
1118
  const render = now => {
742
1119
  const delta = now - state.last
743
1120
  state.last = now
744
- const minFrameIntervalMs = state.nodes.length > largeGraphNodeThreshold ? 48 : 16
1121
+ const backgroundFrameIntervalMs =
1122
+ state.nodes.length > massiveGraphNodeThreshold
1123
+ ? (state.transform.scale < 0.035 ? 130 : state.transform.scale < 0.08 ? 110 : 86)
1124
+ : state.nodes.length > largeGraphNodeThreshold
1125
+ ? 64
1126
+ : 16
1127
+ const isInteracting =
1128
+ state.pointer.down ||
1129
+ state.renderVisibilityDirty ||
1130
+ state.recoveringViewport
1131
+ const minFrameIntervalMs = isInteracting ? 16 : backgroundFrameIntervalMs
745
1132
  if (delta < minFrameIntervalMs) {
746
1133
  requestAnimationFrame(render)
747
1134
  return
@@ -782,7 +1169,9 @@ const render = now => {
782
1169
  } else {
783
1170
  state.offscreenFrameCount = 0
784
1171
  }
785
- const drawEdges = !(state.nodes.length > largeGraphNodeThreshold && state.transform.scale < 0.22)
1172
+ const drawEdges =
1173
+ state.renderClusters.length === 0 &&
1174
+ !(state.nodes.length > largeGraphNodeThreshold && state.transform.scale < 0.22)
786
1175
  if (drawEdges) {
787
1176
  state.renderEdges.forEach(edge => {
788
1177
  const selectedEdge = state.selected && (edge.source === state.selected.id || edge.target === state.selected.id)
@@ -795,7 +1184,27 @@ const render = now => {
795
1184
  })
796
1185
  }
797
1186
 
798
- state.renderNodes.forEach(node => {
1187
+ if (state.renderClusters.length > 0) {
1188
+ const safeScale = Math.max(state.transform.scale, 0.0001)
1189
+ state.renderClusters.forEach(cluster => {
1190
+ const radiusPx = Math.max(8, Math.min(28, 8 + Math.log2(cluster.count + 1) * 3))
1191
+ const radius = radiusPx / safeScale
1192
+ const haloRadius = (radiusPx + 4) / safeScale
1193
+ ctx.beginPath()
1194
+ ctx.arc(cluster.x, cluster.y, haloRadius, 0, Math.PI * 2)
1195
+ ctx.fillStyle = graphTheme.nodeHalo
1196
+ ctx.fill()
1197
+ ctx.beginPath()
1198
+ ctx.arc(cluster.x, cluster.y, radius, 0, Math.PI * 2)
1199
+ ctx.fillStyle = graphTheme.node
1200
+ ctx.fill()
1201
+ ctx.lineWidth = 1.4 / safeScale
1202
+ ctx.strokeStyle = graphTheme.nodeStroke
1203
+ ctx.stroke()
1204
+ // Keep cluster markers minimal and faster to draw on large graphs.
1205
+ })
1206
+ } else {
1207
+ state.renderNodes.forEach(node => {
799
1208
  const radius = nodeRadius(node)
800
1209
  const isSelected = state.selected?.id === node.id
801
1210
  const isHovered = state.hovered?.id === node.id
@@ -822,10 +1231,11 @@ const render = now => {
822
1231
  ctx.textBaseline = 'top'
823
1232
  ctx.fillText(node.title.slice(0, 34), node.x, node.y + radius + 8)
824
1233
  }
825
- })
1234
+ })
1235
+ }
826
1236
 
827
1237
  ctx.restore()
828
- if (state.renderNodes.length === 0) {
1238
+ if (state.renderNodes.length === 0 && state.renderClusters.length === 0) {
829
1239
  ctx.fillStyle = '#99a5b5'
830
1240
  ctx.font = '12px Inter, system-ui, sans-serif'
831
1241
  ctx.textAlign = 'center'
@@ -845,11 +1255,11 @@ const linkedNodes = node => {
845
1255
  weight: edge.weight,
846
1256
  priority: edge.priority
847
1257
  } : null
848
- const outgoing = state.graph.edges
1258
+ const outgoing = state.edges
849
1259
  .filter(edge => edge.source === node.id)
850
- .map(edge => withEdgeMeta(edge.target ? nodeById.get(edge.target) : { title: edge.targetTitle + ' (unresolved)', path: 'Missing note' }, edge))
1260
+ .map(edge => withEdgeMeta(edge.target ? nodeById.get(edge.target) : { title: (edge.targetTitle || 'Unknown') + ' (unresolved)', path: 'Missing note' }, edge))
851
1261
  .filter(Boolean)
852
- const incoming = state.graph.edges
1262
+ const incoming = state.edges
853
1263
  .filter(edge => edge.target === node.id)
854
1264
  .map(edge => withEdgeMeta(nodeById.get(edge.source), edge))
855
1265
  .filter(Boolean)
@@ -879,14 +1289,14 @@ const fetchNodeDetails = async node => {
879
1289
 
880
1290
  const openContentDialog = async node => {
881
1291
  if (!node) return
882
- const { outgoing, incoming } = linkedNodes(node)
883
- elements.contentTitle.textContent = node.title
884
- elements.contentPath.textContent = node.path
885
- elements.contentTags.innerHTML = node.tags.length
1292
+ elements.contentTitle.textContent = node.title || 'Loading...'
1293
+ elements.contentPath.textContent = node.path || 'Loading...'
1294
+ elements.contentTags.innerHTML = Array.isArray(node.tags) && node.tags.length
886
1295
  ? node.tags.map(tag => '<span>#' + escapeHtml(tag) + '</span>').join('')
887
1296
  : '<span>No tags</span>'
888
- elements.contentOutgoing.innerHTML = list(outgoing)
889
- elements.contentIncoming.innerHTML = list(incoming)
1297
+ const initialLinks = linkedNodes(node)
1298
+ elements.contentOutgoing.innerHTML = list(initialLinks.outgoing)
1299
+ elements.contentIncoming.innerHTML = list(initialLinks.incoming)
890
1300
  elements.contentBody.textContent = 'Loading note content...'
891
1301
  if (!elements.contentDialog.open) {
892
1302
  elements.contentDialog.showModal()
@@ -897,6 +1307,11 @@ const openContentDialog = async node => {
897
1307
  if (state.selected?.id !== node.id) {
898
1308
  return
899
1309
  }
1310
+ elements.contentTitle.textContent = detailedNode.title
1311
+ elements.contentPath.textContent = detailedNode.path
1312
+ elements.contentTags.innerHTML = detailedNode.tags.length
1313
+ ? detailedNode.tags.map(tag => '<span>#' + escapeHtml(tag) + '</span>').join('')
1314
+ : '<span>No tags</span>'
900
1315
  elements.contentBody.textContent = detailedNode.content
901
1316
  } catch {
902
1317
  elements.contentBody.textContent = 'Unable to load note content.'
@@ -922,9 +1337,10 @@ const zoomAtPoint = (screenX, screenY, factor) => {
922
1337
  if (nextScale === state.transform.scale) return
923
1338
  const worldX = (screenX - state.transform.x) / state.transform.scale
924
1339
  const worldY = (screenY - state.transform.y) / state.transform.scale
925
- state.transform.scale = nextScale
926
- state.transform.x = screenX - worldX * nextScale
927
- state.transform.y = screenY - worldY * nextScale
1340
+ state.transform.scale = clampScale(nextScale)
1341
+ state.transform.x = clampTransformCoordinate(screenX - worldX * nextScale)
1342
+ state.transform.y = clampTransformCoordinate(screenY - worldY * nextScale)
1343
+ state.offscreenFrameCount = 0
928
1344
  markRenderDirty()
929
1345
  }
930
1346
 
@@ -1034,7 +1450,17 @@ const bindEvents = () => {
1034
1450
  })
1035
1451
  canvas.addEventListener('pointermove', event => {
1036
1452
  const point = worldPoint(event)
1037
- state.hovered = hitNode(point)
1453
+ const now = performance.now()
1454
+ const canHoverHitTest =
1455
+ !(state.nodes.length > massiveGraphNodeThreshold && state.transform.scale < 0.12)
1456
+ const shouldHitTest = canHoverHitTest &&
1457
+ (state.pointer.down || now - state.lastHoverHitAt >= hoverHitTestIntervalMs)
1458
+ if (shouldHitTest) {
1459
+ state.hovered = hitNode(point)
1460
+ state.lastHoverHitAt = now
1461
+ } else if (!canHoverHitTest) {
1462
+ state.hovered = null
1463
+ }
1038
1464
  state.cursor = { x: event.clientX, y: event.clientY, inCanvas: true }
1039
1465
  if (!state.pointer.down) return
1040
1466
  const dx = event.clientX - state.pointer.x
@@ -1050,6 +1476,9 @@ const bindEvents = () => {
1050
1476
  }
1051
1477
  state.transform.x += dx
1052
1478
  state.transform.y += dy
1479
+ state.transform.x = clampTransformCoordinate(state.transform.x)
1480
+ state.transform.y = clampTransformCoordinate(state.transform.y)
1481
+ state.offscreenFrameCount = 0
1053
1482
  markRenderDirty()
1054
1483
  })
1055
1484
  canvas.addEventListener('pointerup', event => {
@@ -1093,9 +1522,10 @@ const loadAgents = async () => {
1093
1522
  const response = await fetch('/api/agents')
1094
1523
  const payload = await response.json()
1095
1524
  const agents = Array.isArray(payload.agents) ? payload.agents : []
1096
- const currentExists = agents.some(agent => agent.id === state.agentId)
1525
+ const preferredAgent = state.agentId || initialAgentFromUrl
1526
+ const currentExists = agents.some(agent => agent.id === preferredAgent)
1097
1527
  const selected = currentExists
1098
- ? state.agentId
1528
+ ? preferredAgent
1099
1529
  : (agents.find(agent => agent.id === 'shared')?.id ?? agents[0]?.id ?? 'shared')
1100
1530
  const signature = JSON.stringify(agents.map(agent => [agent.id, agent.documentCount]))
1101
1531
 
@@ -1125,6 +1555,10 @@ const loadGraph = async (options = { reset: false }) => {
1125
1555
 
1126
1556
  const payload = await response.json()
1127
1557
  const graph = payload?.layout ?? payload
1558
+ state.graphTotals = {
1559
+ nodes: Number.isFinite(payload?.totals?.nodes) ? payload.totals.nodes : (Array.isArray(graph.nodes) ? graph.nodes.length : 0),
1560
+ edges: Number.isFinite(payload?.totals?.edges) ? payload.totals.edges : (Array.isArray(graph.edges) ? graph.edges.length : 0)
1561
+ }
1128
1562
  const signature = payload?.signature ?? graphSignature(graph)
1129
1563
  if (!options.reset && signature === state.graphSignature) return
1130
1564
  const selectedId = state.selected?.id
@@ -1141,13 +1575,15 @@ const loadGraph = async (options = { reset: false }) => {
1141
1575
  return degrees
1142
1576
  }, new Map())
1143
1577
  state.nodeDetails = new Map()
1578
+ pushNodesToFilterWorker()
1144
1579
  resetContentFilter()
1580
+ sanitizeAllNodePositions()
1145
1581
  recomputeVisibility()
1146
1582
  scheduleContentFilterSync()
1147
- const tags = new Set(graph.nodes.flatMap(node => node.tags))
1148
- setGraphStatus(state.agentId + ' · ' + graph.nodes.length + ' notes · ' + graph.edges.length + ' links · live')
1149
- elements.nodeCount.textContent = graph.nodes.length
1150
- elements.edgeCount.textContent = graph.edges.length
1583
+ const tags = new Set(state.nodes.flatMap(node => node.tags))
1584
+ setGraphStatus(state.agentId + ' · ' + state.graphTotals.nodes + ' notes · ' + state.graphTotals.edges + ' links · live')
1585
+ elements.nodeCount.textContent = state.graphTotals.nodes
1586
+ elements.edgeCount.textContent = state.graphTotals.edges
1151
1587
  elements.tagCount.textContent = tags.size
1152
1588
  resize()
1153
1589
  if (options.reset) resetView()
@@ -1159,6 +1595,7 @@ const loadGraph = async (options = { reset: false }) => {
1159
1595
  }
1160
1596
 
1161
1597
  bindEvents()
1598
+ initFilterWorker()
1162
1599
  requestAnimationFrame(() => {
1163
1600
  resize()
1164
1601
  resetView()
@@ -0,0 +1,66 @@
1
+ export const createClientWorkerJs = () => `const normalize = value => String(value || '')
2
+ .normalize('NFKD')
3
+ .replace(/\\p{Diacritic}/gu, '')
4
+ .toLowerCase()
5
+
6
+ let nodeIndex = []
7
+
8
+ const toNodeIndex = nodes =>
9
+ (Array.isArray(nodes) ? nodes : [])
10
+ .map(node => {
11
+ const id = typeof node.id === 'string' ? node.id : ''
12
+ if (!id) {
13
+ return null
14
+ }
15
+ const title = normalize(node.title)
16
+ const path = normalize(node.path)
17
+ const tags = Array.isArray(node.tags) ? node.tags.map(tag => normalize(tag)) : []
18
+ return {
19
+ id,
20
+ text: [title, path, ...tags].join(' ')
21
+ }
22
+ })
23
+ .filter(Boolean)
24
+
25
+ const scoreText = (text, query) => {
26
+ if (!query) return 0
27
+ if (!text.includes(query)) return 0
28
+ if (text.startsWith(query)) return 4
29
+ return 1
30
+ }
31
+
32
+ const filterIds = (query, limit) => {
33
+ const normalizedQuery = normalize(query).trim()
34
+ if (!normalizedQuery) {
35
+ return []
36
+ }
37
+ const rows = []
38
+ for (let index = 0; index < nodeIndex.length; index += 1) {
39
+ const row = nodeIndex[index]
40
+ const score = scoreText(row.text, normalizedQuery)
41
+ if (score > 0) {
42
+ rows.push({ id: row.id, score })
43
+ }
44
+ }
45
+ rows.sort((left, right) => right.score - left.score || left.id.localeCompare(right.id))
46
+ return rows.slice(0, Math.max(1, Number.isFinite(limit) ? limit : rows.length)).map(row => row.id)
47
+ }
48
+
49
+ self.onmessage = event => {
50
+ const payload = event.data
51
+ if (!payload || typeof payload !== 'object') {
52
+ return
53
+ }
54
+ if (payload.type === 'load-nodes') {
55
+ nodeIndex = toNodeIndex(payload.nodes)
56
+ return
57
+ }
58
+ if (payload.type === 'filter') {
59
+ const token = payload.token
60
+ const ids = filterIds(payload.query, payload.limit)
61
+ self.postMessage({ type: 'filter-result', token, ids })
62
+ }
63
+ }
64
+
65
+ self.postMessage({ type: 'ready' })
66
+ `;
@@ -11,6 +11,7 @@ import { loadBrainlinkConfig, sanitizeSearchMode } from '../../infrastructure/co
11
11
  import { createClientCss } from '../frontend/client-css.js';
12
12
  import { createClientHtml } from '../frontend/client-html.js';
13
13
  import { createClientJs } from '../frontend/client-js.js';
14
+ import { createClientWorkerJs } from '../frontend/client-worker-js.js';
14
15
  import { contentTypes, createJsonResponse, isReadMethod, parsePositiveInteger } from './http.js';
15
16
  const readSearchMode = async (url) => {
16
17
  const config = await loadBrainlinkConfig();
@@ -51,10 +52,41 @@ const sameEntityTag = (candidate, signature) => {
51
52
  return decodeEntityTag(candidate) === signature;
52
53
  };
53
54
  const readAgentQuery = (url) => url.searchParams.get('agent') ?? undefined;
55
+ const compactGraphLayoutThreshold = 12_000;
56
+ const compactGraphLayoutEdgeLimit = 60_000;
57
+ const compactGraphLayoutEdgeLimitFor = (nodeCount) => {
58
+ if (nodeCount > 100_000)
59
+ return 15_000;
60
+ if (nodeCount > 50_000)
61
+ return 22_000;
62
+ if (nodeCount > 25_000)
63
+ return 30_000;
64
+ return compactGraphLayoutEdgeLimit;
65
+ };
54
66
  const stripLayoutContent = (layout) => ({
55
67
  ...layout,
56
68
  nodes: layout.nodes.map(({ content, ...node }) => node)
57
69
  });
70
+ const compactLayoutPayload = (layout) => {
71
+ const edgeLimit = compactGraphLayoutEdgeLimitFor(layout.nodes.length);
72
+ const compactNodes = layout.nodes.map((node) => [node.id, node.title, node.x, node.y, node.group, node.segment]);
73
+ const compactEdges = [...layout.edges]
74
+ .sort((left, right) => (right.weight ?? 1) - (left.weight ?? 1))
75
+ .slice(0, edgeLimit)
76
+ .map((edge) => [edge.source, edge.target, edge.weight, edge.priority]);
77
+ return {
78
+ compact: true,
79
+ layout: {
80
+ nodes: compactNodes,
81
+ edges: compactEdges
82
+ },
83
+ totals: {
84
+ nodes: layout.nodes.length,
85
+ edges: layout.edges.length
86
+ }
87
+ };
88
+ };
89
+ const encodeLayoutPayload = (layout) => layout.nodes.length > compactGraphLayoutThreshold ? compactLayoutPayload(layout) : { compact: false, layout: stripLayoutContent(layout) };
58
90
  export const route = async (request, url, vaultPath) => {
59
91
  if (isReadMethod(request) && (url.pathname === '/' || url.pathname === '/index.html')) {
60
92
  return createResponse(createClientHtml(), 200, contentTypes['.html']);
@@ -65,6 +97,9 @@ export const route = async (request, url, vaultPath) => {
65
97
  if (isReadMethod(request) && url.pathname === '/app.js') {
66
98
  return createResponse(createClientJs(), 200, contentTypes['.js']);
67
99
  }
100
+ if (isReadMethod(request) && url.pathname === '/app-worker.js') {
101
+ return createResponse(createClientWorkerJs(), 200, contentTypes['.js']);
102
+ }
68
103
  if (isReadMethod(request) && url.pathname === '/api/graph') {
69
104
  return createResponse(createJsonResponse(await getGraph(vaultPath, readAgentQuery(url))), 200, contentTypes['.json']);
70
105
  }
@@ -73,7 +108,7 @@ export const route = async (request, url, vaultPath) => {
73
108
  const requestEtags = request.headers['if-none-match'];
74
109
  const notModified = sameEntityTag(requestEtags, signature);
75
110
  const etag = encodeEntityTag(signature);
76
- const body = createJsonResponse({ signature, layout: stripLayoutContent(layout) });
111
+ const body = createJsonResponse({ signature, ...encodeLayoutPayload(layout) });
77
112
  const jsonResponse = createResponse(body, 200, contentTypes['.json']);
78
113
  const notModifiedResponse = createResponse('', 304, contentTypes['.json']);
79
114
  if (notModified) {
@@ -1,9 +1,78 @@
1
1
  import { createServer } from 'node:http';
2
+ import { brotliCompressSync, constants, gzipSync } from 'node:zlib';
2
3
  import { indexVault } from './index-vault.js';
3
4
  import { startVaultWatcher } from './watch-vault.js';
4
5
  import { assertLoopbackHost } from './server/host-security.js';
5
6
  import { contentTypes, createJsonResponse, isHttpError } from './server/http.js';
6
7
  import { route } from './server/routes.js';
8
+ const compressionThresholdBytes = 1024;
9
+ const normalizeEncodingToken = (value) => value.trim().toLowerCase();
10
+ const supportsEncoding = (acceptEncoding, target) => {
11
+ if (!acceptEncoding) {
12
+ return false;
13
+ }
14
+ return acceptEncoding
15
+ .split(',')
16
+ .map((entry) => entry.split(';')[0] ?? '')
17
+ .map(normalizeEncodingToken)
18
+ .includes(target);
19
+ };
20
+ const isCompressibleContentType = (contentType) => {
21
+ const normalized = contentType?.toLowerCase() ?? '';
22
+ return (normalized.includes('application/json') ||
23
+ normalized.includes('text/javascript') ||
24
+ normalized.includes('text/css') ||
25
+ normalized.includes('text/html') ||
26
+ normalized.startsWith('text/'));
27
+ };
28
+ const maybeCompressResponse = (requestHeaders, statusCode, headers, body) => {
29
+ if (statusCode === 204 || statusCode === 304) {
30
+ return { headers, body: '' };
31
+ }
32
+ if (!isCompressibleContentType(headers['content-type'])) {
33
+ return { headers, body };
34
+ }
35
+ const bodyBuffer = Buffer.from(body, 'utf8');
36
+ if (bodyBuffer.byteLength < compressionThresholdBytes) {
37
+ return { headers, body };
38
+ }
39
+ if (headers['content-encoding']) {
40
+ return { headers, body };
41
+ }
42
+ const acceptEncodingHeader = Array.isArray(requestHeaders['accept-encoding'])
43
+ ? requestHeaders['accept-encoding'].join(',')
44
+ : requestHeaders['accept-encoding'];
45
+ const vary = headers.vary ? `${headers.vary}, Accept-Encoding` : 'Accept-Encoding';
46
+ const withVary = {
47
+ ...headers,
48
+ vary
49
+ };
50
+ if (supportsEncoding(acceptEncodingHeader, 'br')) {
51
+ return {
52
+ headers: {
53
+ ...withVary,
54
+ 'content-encoding': 'br'
55
+ },
56
+ body: brotliCompressSync(bodyBuffer, {
57
+ params: {
58
+ [constants.BROTLI_PARAM_QUALITY]: 5
59
+ }
60
+ })
61
+ };
62
+ }
63
+ if (supportsEncoding(acceptEncodingHeader, 'gzip')) {
64
+ return {
65
+ headers: {
66
+ ...withVary,
67
+ 'content-encoding': 'gzip'
68
+ },
69
+ body: gzipSync(bodyBuffer, {
70
+ level: 6
71
+ })
72
+ };
73
+ }
74
+ return { headers: withVary, body };
75
+ };
7
76
  export const startServer = async (input) => {
8
77
  assertLoopbackHost(input.host);
9
78
  if (input.shouldIndex) {
@@ -19,14 +88,16 @@ export const startServer = async (input) => {
19
88
  const url = new URL(request.url ?? '/', `http://${request.headers.host ?? input.host}`);
20
89
  route(request, url, input.vaultPath)
21
90
  .then((result) => {
22
- response.writeHead(result.statusCode, result.headers);
23
- response.end(result.body);
91
+ const encoded = maybeCompressResponse(request.headers, result.statusCode, result.headers, result.body);
92
+ response.writeHead(result.statusCode, encoded.headers);
93
+ response.end(encoded.body);
24
94
  })
25
95
  .catch((error) => {
26
96
  const message = error instanceof Error ? error.message : String(error);
27
97
  const statusCode = isHttpError(error) ? error.statusCode : 500;
28
- response.writeHead(statusCode, { 'content-type': contentTypes['.json'] });
29
- response.end(createJsonResponse({ error: message }));
98
+ const fallback = maybeCompressResponse(request.headers, statusCode, { 'content-type': contentTypes['.json'] }, createJsonResponse({ error: message }));
99
+ response.writeHead(statusCode, fallback.headers);
100
+ response.end(fallback.body);
30
101
  });
31
102
  });
32
103
  await new Promise((resolve, reject) => {
@@ -37,9 +37,13 @@ const parseScore = (value, fallback) => {
37
37
  }
38
38
  return parsed;
39
39
  };
40
- const spawnDetached = (command, args) => {
40
+ const spawnDetached = (command, args, envOverrides) => {
41
41
  try {
42
- const child = spawn(command, args, { detached: true, stdio: 'ignore' });
42
+ const child = spawn(command, args, {
43
+ detached: true,
44
+ stdio: 'ignore',
45
+ env: envOverrides ? { ...process.env, ...envOverrides } : process.env
46
+ });
43
47
  child.unref();
44
48
  return true;
45
49
  }
@@ -219,6 +223,7 @@ const commandExists = (command) => {
219
223
  };
220
224
  const envFlagEnabled = (name) => process.env[name] === '1' || process.env[name] === 'true';
221
225
  const spawnAnyDetached = (candidates) => candidates.some(([command, args]) => spawnDetached(command, args));
226
+ const spawnAnyDetachedWithEnv = (candidates) => candidates.some(([command, args, env]) => spawnDetached(command, args, env));
222
227
  const windowsStartCandidates = (program, args = []) => [
223
228
  ['cmd', ['/c', 'start', '', program, ...args]]
224
229
  ];
@@ -318,6 +323,16 @@ const openGraphInAppWindow = (url) => {
318
323
  ]);
319
324
  }
320
325
  const appArgument = `--app=${url}`;
326
+ const linuxChromiumStableFlags = [
327
+ '--ozone-platform=x11',
328
+ '--disable-gpu',
329
+ '--disable-features=Vulkan,VaapiVideoDecoder',
330
+ '--disable-background-networking'
331
+ ];
332
+ const linuxChromiumEnv = {
333
+ GDK_BACKEND: 'x11',
334
+ OZONE_PLATFORM: 'x11'
335
+ };
321
336
  const linuxAppWindowCandidates = [
322
337
  'microsoft-edge',
323
338
  'microsoft-edge-stable',
@@ -327,7 +342,11 @@ const openGraphInAppWindow = (url) => {
327
342
  'chromium-browser',
328
343
  'brave-browser'
329
344
  ].filter((candidate) => commandExists(candidate));
330
- return spawnAnyDetached(linuxAppWindowCandidates.map((command) => [command, [appArgument, '--new-window']]));
345
+ return spawnAnyDetachedWithEnv(linuxAppWindowCandidates.map((command) => [
346
+ command,
347
+ [...linuxChromiumStableFlags, appArgument, '--new-window'],
348
+ linuxChromiumEnv
349
+ ]));
331
350
  };
332
351
  const openGraphInDetectedBrowser = (url) => {
333
352
  if (platform() === 'win32') {
@@ -339,18 +358,28 @@ const openGraphInDetectedBrowser = (url) => {
339
358
  ...windowsStartCandidates('brave', [url])
340
359
  ]);
341
360
  }
361
+ const linuxChromiumStableFlags = [
362
+ '--ozone-platform=x11',
363
+ '--disable-gpu',
364
+ '--disable-features=Vulkan,VaapiVideoDecoder',
365
+ '--disable-background-networking'
366
+ ];
367
+ const linuxChromiumEnv = {
368
+ GDK_BACKEND: 'x11',
369
+ OZONE_PLATFORM: 'x11'
370
+ };
342
371
  const linuxBrowserCandidates = [
343
- ['microsoft-edge', [url]],
344
- ['microsoft-edge-stable', [url]],
345
- ['google-chrome', [url]],
346
- ['google-chrome-stable', [url]],
347
- ['chromium', [url]],
348
- ['chromium-browser', [url]],
349
- ['brave-browser', [url]],
350
- ['firefox', ['-new-window', url]]
372
+ ['microsoft-edge', [...linuxChromiumStableFlags, url], linuxChromiumEnv],
373
+ ['microsoft-edge-stable', [...linuxChromiumStableFlags, url], linuxChromiumEnv],
374
+ ['google-chrome', [...linuxChromiumStableFlags, url], linuxChromiumEnv],
375
+ ['google-chrome-stable', [...linuxChromiumStableFlags, url], linuxChromiumEnv],
376
+ ['chromium', [...linuxChromiumStableFlags, url], linuxChromiumEnv],
377
+ ['chromium-browser', [...linuxChromiumStableFlags, url], linuxChromiumEnv],
378
+ ['brave-browser', [...linuxChromiumStableFlags, url], linuxChromiumEnv],
379
+ ['firefox', ['-new-window', url], undefined]
351
380
  ];
352
381
  const available = linuxBrowserCandidates.filter(([command]) => commandExists(command));
353
- return spawnAnyDetached(available);
382
+ return spawnAnyDetachedWithEnv(available);
354
383
  };
355
384
  const openUrlInUi = (url, parentPid) => {
356
385
  const openDisabled = process.env.BRAINLINK_NO_BROWSER === '1' ||
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andespindola/brainlink",
3
- "version": "0.1.0-beta.41",
3
+ "version": "0.1.0-beta.42",
4
4
  "description": "Local-first knowledge memory for agents with Markdown, backlinks, indexing and context retrieval.",
5
5
  "type": "module",
6
6
  "license": "MIT",