@andespindola/brainlink 0.1.0-beta.53 → 0.1.0-beta.55

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.
@@ -18,6 +18,7 @@ const transformCoordinateLimit = 20_000_000
18
18
  const hoverHitTestIntervalMs = 64
19
19
  const overviewClusterMaxCount = 1400
20
20
  const zoomRecoveryGuardMs = 1500
21
+ const zoomCapTargetViewportShare = 0.72
21
22
  const state = {
22
23
  graph: { nodes: [], edges: [] },
23
24
  nodes: [],
@@ -53,6 +54,7 @@ const state = {
53
54
  macroCenter: { x: 0, y: 0 },
54
55
  macroRepresentative: null,
55
56
  primaryHub: null,
57
+ hubNeighborDistance: Number.POSITIVE_INFINITY,
56
58
  filterWorker: null,
57
59
  filterReady: false,
58
60
  lastHoverHitAt: 0,
@@ -101,6 +103,39 @@ const initialAgentFromUrl = (() => {
101
103
  }
102
104
  })()
103
105
 
106
+ const selectedAgentStorageKey = 'brainlink:selected-agent'
107
+
108
+ const readStoredAgent = () => {
109
+ try {
110
+ const value = window.localStorage.getItem(selectedAgentStorageKey)?.trim() ?? ''
111
+ return value.length > 0 ? value : ''
112
+ } catch {
113
+ return ''
114
+ }
115
+ }
116
+
117
+ const writeStoredAgent = (agentId) => {
118
+ try {
119
+ if (!agentId) {
120
+ window.localStorage.removeItem(selectedAgentStorageKey)
121
+ return
122
+ }
123
+ window.localStorage.setItem(selectedAgentStorageKey, agentId)
124
+ } catch {}
125
+ }
126
+
127
+ const syncAgentInUrl = (agentId) => {
128
+ try {
129
+ const url = new URL(window.location.href)
130
+ if (agentId && agentId.trim().length > 0) {
131
+ url.searchParams.set('agent', agentId)
132
+ } else {
133
+ url.searchParams.delete('agent')
134
+ }
135
+ window.history.replaceState({}, '', url.toString())
136
+ } catch {}
137
+ }
138
+
104
139
  const agentQuery = (separator = '?') => state.agentId ? separator + 'agent=' + encodeURIComponent(state.agentId) : ''
105
140
 
106
141
  const setGraphStatus = text => {
@@ -199,6 +234,19 @@ const resize = () => {
199
234
  const normalizeQuery = value => value.trim().toLowerCase()
200
235
  const hubNodeRetentionLimit = 2
201
236
  const hubNodePattern = /\b(memory\s*hub|knowledge\s*hub|hub|moc|map|memory\s*map|mapa)\b/i
237
+ const memoryHubPathPattern = /\bmemory[-_\s]*hub\b/i
238
+
239
+ const hubNodeScore = node => {
240
+ const title = node.title.trim().toLowerCase()
241
+ if (title === 'memory hub') return 6
242
+ if (title === 'knowledge hub') return 5
243
+ if (memoryHubPathPattern.test(node.path || '')) return 4
244
+ if (node.tags.some(tag => tag.trim().toLowerCase() === 'memory-hub')) return 3
245
+ if (/\bmoc\b/i.test(node.title)) return 2
246
+ return hubNodePattern.test(node.title) || hubNodePattern.test(node.path || '') || node.tags.some(tag => hubNodePattern.test(tag))
247
+ ? 1
248
+ : 0
249
+ }
202
250
 
203
251
  const localFilteredNodes = query =>
204
252
  state.nodes.filter(node =>
@@ -213,8 +261,10 @@ const rankedHubNodes = () => {
213
261
  }
214
262
 
215
263
  const byTitleAndDegree = [...state.nodes]
216
- .filter(node => hubNodePattern.test(node.title) || hubNodePattern.test(node.path) || node.tags.some(tag => hubNodePattern.test(tag)))
264
+ .filter(node => hubNodeScore(node) > 0)
217
265
  .sort((left, right) => {
266
+ const byHubScore = hubNodeScore(right) - hubNodeScore(left)
267
+ if (byHubScore !== 0) return byHubScore
218
268
  const byDegree = (state.nodeDegrees.get(right.id) ?? 0) - (state.nodeDegrees.get(left.id) ?? 0)
219
269
  if (byDegree !== 0) return byDegree
220
270
  return left.title.localeCompare(right.title)
@@ -259,7 +309,10 @@ const resolveMacroRepresentative = (nodes) => {
259
309
  return null
260
310
  }
261
311
 
262
- let best = nodes[0]
312
+ const hubCandidate = state.primaryHub && nodes.some(node => node.id === state.primaryHub.id)
313
+ ? state.primaryHub
314
+ : null
315
+ let best = hubCandidate ?? nodes[0]
263
316
  let bestDegree = state.nodeDegrees.get(best.id) ?? 0
264
317
 
265
318
  for (let index = 1; index < nodes.length; index += 1) {
@@ -274,6 +327,24 @@ const resolveMacroRepresentative = (nodes) => {
274
327
  return best
275
328
  }
276
329
 
330
+ const nearestHubNeighborDistance = (hub, nodes) => {
331
+ if (!hub || nodes.length <= 1) {
332
+ return Number.POSITIVE_INFINITY
333
+ }
334
+
335
+ let minimum = Number.POSITIVE_INFINITY
336
+ for (let index = 0; index < nodes.length; index += 1) {
337
+ const node = nodes[index]
338
+ if (node.id === hub.id) continue
339
+ const distance = Math.hypot(node.x - hub.x, node.y - hub.y)
340
+ if (distance < minimum) {
341
+ minimum = distance
342
+ }
343
+ }
344
+
345
+ return minimum
346
+ }
347
+
277
348
  const recomputeVisibility = () => {
278
349
  const nodes = filteredNodes()
279
350
  const ids = new Set(nodes.map(node => node.id))
@@ -289,15 +360,17 @@ const recomputeVisibility = () => {
289
360
  state.visibleNodeSpatial = createSpatialIndex(nodes)
290
361
  state.visibleEdgeByNode = createVisibleEdgeLookup(limitedEdges)
291
362
  state.overviewClusters = nodes.length > massiveGraphNodeThreshold ? buildOverviewClusters(nodes) : []
363
+ const primaryHub = rankedHubNodes()[0] ?? null
364
+ state.primaryHub = primaryHub
365
+ state.hubNeighborDistance = nearestHubNeighborDistance(primaryHub, nodes)
292
366
  const bounds = graphBounds(nodes)
293
367
  state.macroCenter = bounds
294
368
  ? {
295
- x: (bounds.minX + bounds.maxX) / 2,
296
- y: (bounds.minY + bounds.maxY) / 2
369
+ x: primaryHub ? primaryHub.x : (bounds.minX + bounds.maxX) / 2,
370
+ y: primaryHub ? primaryHub.y : (bounds.minY + bounds.maxY) / 2
297
371
  }
298
372
  : { x: 0, y: 0 }
299
373
  state.macroRepresentative = resolveMacroRepresentative(nodes)
300
- state.primaryHub = rankedHubNodes()[0] ?? null
301
374
  markRenderDirty()
302
375
  }
303
376
 
@@ -608,23 +681,61 @@ const ensureHubNodesInRenderedSet = (nodes) => {
608
681
  return nodes
609
682
  }
610
683
 
611
- const maxNodes = Math.max(renderNodeBudget, nodes.length)
684
+ const maxNodes = Math.max(Math.min(renderNodeBudget, nodes.length), 1)
612
685
  const ids = new Set(nodes.map((node) => node.id))
613
686
  const hubs = rankedHubNodes()
614
687
  const merged = [...nodes]
615
688
 
616
- for (let index = 0; index < hubs.length && merged.length < maxNodes; index += 1) {
689
+ for (let index = 0; index < hubs.length; index += 1) {
617
690
  const hub = hubs[index]
618
- if (!ids.has(hub.id)) {
691
+ if (ids.has(hub.id)) {
692
+ continue
693
+ }
694
+
695
+ if (merged.length < maxNodes) {
619
696
  merged.push(hub)
620
697
  ids.add(hub.id)
698
+ continue
699
+ }
700
+
701
+ const replacementIndex = merged.findIndex((node) => !hubs.some((candidate) => candidate.id === node.id))
702
+ if (replacementIndex >= 0) {
703
+ ids.delete(merged[replacementIndex].id)
704
+ merged[replacementIndex] = hub
705
+ ids.add(hub.id)
621
706
  }
622
707
  }
623
708
 
624
709
  return merged
625
710
  }
626
711
 
627
- const clampScale = value => Math.max(zoomRange.min, Math.min(zoomRange.max, value))
712
+ const zoomCapByNodeCount = (nodeCount) => {
713
+ if (nodeCount > 50000) return 0.88
714
+ if (nodeCount > 20000) return 1.15
715
+ if (nodeCount > 6000) return 1.65
716
+ if (nodeCount > 2000) return 2.2
717
+ return zoomRange.max
718
+ }
719
+
720
+ const zoomCapByHubDistance = (distance) => {
721
+ if (!Number.isFinite(distance) || distance <= 0) {
722
+ return zoomRange.max
723
+ }
724
+
725
+ const rect = canvas.getBoundingClientRect()
726
+ const viewportWidth = Math.max(rect.width, 320)
727
+ const viewportHeight = Math.max(rect.height, 320)
728
+ const reference = Math.max(220, Math.min(viewportWidth, viewportHeight) * zoomCapTargetViewportShare)
729
+ return Math.max(0.3, Math.min(zoomRange.max, reference / distance))
730
+ }
731
+
732
+ const currentZoomMax = () => {
733
+ const nodeCount = state.visibleNodes.length > 0 ? state.visibleNodes.length : state.nodes.length
734
+ const capped = Math.min(zoomCapByNodeCount(nodeCount), zoomCapByHubDistance(state.hubNeighborDistance))
735
+ return Math.max(zoomRange.min * 2, capped)
736
+ }
737
+
738
+ const clampScale = value => Math.max(zoomRange.min, Math.min(currentZoomMax(), value))
628
739
  const isFiniteNumber = value => Number.isFinite(value)
629
740
  const isReasonableCoordinate = value => isFiniteNumber(value) && Math.abs(value) <= worldCoordinateLimit
630
741
  const clampTransformCoordinate = value => {
@@ -1090,7 +1201,7 @@ const computeRenderVisibility = () => {
1090
1201
  if (shouldRenderMacroGalaxy) {
1091
1202
  const viewportNodes = viewportNodesFromSpatialIndex(viewport)
1092
1203
  const sourceNodes = viewportNodes.length > 0 ? viewportNodes : state.visibleNodes
1093
- const representative = state.macroRepresentative ?? sourceNodes[0] ?? null
1204
+ const representative = state.primaryHub ?? state.macroRepresentative ?? sourceNodes[0] ?? null
1094
1205
  if (representative) {
1095
1206
  state.renderClusters = [
1096
1207
  {
@@ -1338,6 +1449,13 @@ const render = now => {
1338
1449
  ctx.lineWidth = 1.4 / safeScale
1339
1450
  ctx.strokeStyle = isMacro ? '#ffffff' : graphTheme.nodeStroke
1340
1451
  ctx.stroke()
1452
+ if (isMacro && cluster.representative?.title) {
1453
+ ctx.fillStyle = '#edf2f7'
1454
+ ctx.font = 12 / safeScale + 'px Inter, system-ui, sans-serif'
1455
+ ctx.textAlign = 'center'
1456
+ ctx.textBaseline = 'top'
1457
+ ctx.fillText(cluster.representative.title.slice(0, 28), cluster.x, cluster.y + (radiusPx + 9) / safeScale)
1458
+ }
1341
1459
  // Keep cluster markers minimal and faster to draw on large graphs.
1342
1460
  })
1343
1461
  } else {
@@ -1545,6 +1663,8 @@ const bindEvents = () => {
1545
1663
  })
1546
1664
  elements.agent.addEventListener('change', event => {
1547
1665
  state.agentId = event.target.value
1666
+ writeStoredAgent(state.agentId)
1667
+ syncAgentInUrl(state.agentId)
1548
1668
  state.selected = null
1549
1669
  state.nodeDetails = new Map()
1550
1670
  resetContentFilter()
@@ -1671,7 +1791,7 @@ const loadAgents = async () => {
1671
1791
  const response = await fetch('/api/agents')
1672
1792
  const payload = await response.json()
1673
1793
  const agents = Array.isArray(payload.agents) ? payload.agents : []
1674
- const preferredAgent = state.agentId || initialAgentFromUrl
1794
+ const preferredAgent = state.agentId || initialAgentFromUrl || readStoredAgent()
1675
1795
  const currentExists = agents.some(agent => agent.id === preferredAgent)
1676
1796
  const selected = currentExists
1677
1797
  ? preferredAgent
@@ -1679,6 +1799,8 @@ const loadAgents = async () => {
1679
1799
  const signature = JSON.stringify(agents.map(agent => [agent.id, agent.documentCount]))
1680
1800
 
1681
1801
  state.agentId = selected
1802
+ writeStoredAgent(selected)
1803
+ syncAgentInUrl(selected)
1682
1804
  if (signature !== state.agentsSignature) {
1683
1805
  const formatAgentLabel = (agent) => agent.id
1684
1806
  elements.agent.innerHTML = agents.length
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andespindola/brainlink",
3
- "version": "0.1.0-beta.53",
3
+ "version": "0.1.0-beta.55",
4
4
  "description": "Local-first knowledge memory for agents with Markdown, backlinks, indexing and context retrieval.",
5
5
  "type": "module",
6
6
  "license": "MIT",