@andespindola/brainlink 0.1.0-beta.52 → 0.1.0-beta.54

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.
@@ -52,6 +52,7 @@ const state = {
52
52
  overviewClusters: [],
53
53
  macroCenter: { x: 0, y: 0 },
54
54
  macroRepresentative: null,
55
+ primaryHub: null,
55
56
  filterWorker: null,
56
57
  filterReady: false,
57
58
  lastHoverHitAt: 0,
@@ -100,6 +101,39 @@ const initialAgentFromUrl = (() => {
100
101
  }
101
102
  })()
102
103
 
104
+ const selectedAgentStorageKey = 'brainlink:selected-agent'
105
+
106
+ const readStoredAgent = () => {
107
+ try {
108
+ const value = window.localStorage.getItem(selectedAgentStorageKey)?.trim() ?? ''
109
+ return value.length > 0 ? value : ''
110
+ } catch {
111
+ return ''
112
+ }
113
+ }
114
+
115
+ const writeStoredAgent = (agentId) => {
116
+ try {
117
+ if (!agentId) {
118
+ window.localStorage.removeItem(selectedAgentStorageKey)
119
+ return
120
+ }
121
+ window.localStorage.setItem(selectedAgentStorageKey, agentId)
122
+ } catch {}
123
+ }
124
+
125
+ const syncAgentInUrl = (agentId) => {
126
+ try {
127
+ const url = new URL(window.location.href)
128
+ if (agentId && agentId.trim().length > 0) {
129
+ url.searchParams.set('agent', agentId)
130
+ } else {
131
+ url.searchParams.delete('agent')
132
+ }
133
+ window.history.replaceState({}, '', url.toString())
134
+ } catch {}
135
+ }
136
+
103
137
  const agentQuery = (separator = '?') => state.agentId ? separator + 'agent=' + encodeURIComponent(state.agentId) : ''
104
138
 
105
139
  const setGraphStatus = text => {
@@ -296,6 +330,7 @@ const recomputeVisibility = () => {
296
330
  }
297
331
  : { x: 0, y: 0 }
298
332
  state.macroRepresentative = resolveMacroRepresentative(nodes)
333
+ state.primaryHub = rankedHubNodes()[0] ?? null
299
334
  markRenderDirty()
300
335
  }
301
336
 
@@ -679,7 +714,7 @@ const autoFitScaleRangeByNodeCount = nodeCount => {
679
714
  return { min: 0.0008, max: 0.24 }
680
715
  }
681
716
 
682
- const fitView = (options = { useFiltered: true, macro: false }) => {
717
+ const fitView = (options = { useFiltered: true, macro: false, preferHubCenter: true }) => {
683
718
  const rect = canvas.getBoundingClientRect()
684
719
  const width = Math.max(rect.width, 320)
685
720
  const height = Math.max(rect.height, 320)
@@ -716,8 +751,12 @@ const fitView = (options = { useFiltered: true, macro: false }) => {
716
751
  : nodes.length > massiveGraphNodeThreshold
717
752
  ? clampScale(Math.min(baselineScale, massiveAutoFitMacroScale))
718
753
  : baselineScale
719
- const centerX = (bounds.minX + bounds.maxX) / 2
720
- const centerY = (bounds.minY + bounds.maxY) / 2
754
+ const hubCenter =
755
+ options.preferHubCenter && state.primaryHub && nodes.some((node) => node.id === state.primaryHub.id)
756
+ ? state.primaryHub
757
+ : null
758
+ const centerX = hubCenter ? hubCenter.x : (bounds.minX + bounds.maxX) / 2
759
+ const centerY = hubCenter ? hubCenter.y : (bounds.minY + bounds.maxY) / 2
721
760
 
722
761
  state.transform = {
723
762
  x: clampTransformCoordinate(width / 2 - centerX * scale),
@@ -729,7 +768,7 @@ const fitView = (options = { useFiltered: true, macro: false }) => {
729
768
  markRenderDirty()
730
769
  }
731
770
 
732
- const resetView = () => fitView({ useFiltered: false, macro: true })
771
+ const resetView = () => fitView({ useFiltered: false, macro: true, preferHubCenter: true })
733
772
 
734
773
  const createLayout = graph => {
735
774
  const nodeRows = Array.isArray(graph.nodes) ? graph.nodes : []
@@ -1539,6 +1578,8 @@ const bindEvents = () => {
1539
1578
  })
1540
1579
  elements.agent.addEventListener('change', event => {
1541
1580
  state.agentId = event.target.value
1581
+ writeStoredAgent(state.agentId)
1582
+ syncAgentInUrl(state.agentId)
1542
1583
  state.selected = null
1543
1584
  state.nodeDetails = new Map()
1544
1585
  resetContentFilter()
@@ -1665,7 +1706,7 @@ const loadAgents = async () => {
1665
1706
  const response = await fetch('/api/agents')
1666
1707
  const payload = await response.json()
1667
1708
  const agents = Array.isArray(payload.agents) ? payload.agents : []
1668
- const preferredAgent = state.agentId || initialAgentFromUrl
1709
+ const preferredAgent = state.agentId || initialAgentFromUrl || readStoredAgent()
1669
1710
  const currentExists = agents.some(agent => agent.id === preferredAgent)
1670
1711
  const selected = currentExists
1671
1712
  ? preferredAgent
@@ -1673,6 +1714,8 @@ const loadAgents = async () => {
1673
1714
  const signature = JSON.stringify(agents.map(agent => [agent.id, agent.documentCount]))
1674
1715
 
1675
1716
  state.agentId = selected
1717
+ writeStoredAgent(selected)
1718
+ syncAgentInUrl(selected)
1676
1719
  if (signature !== state.agentsSignature) {
1677
1720
  const formatAgentLabel = (agent) => agent.id
1678
1721
  elements.agent.innerHTML = agents.length
@@ -20,6 +20,7 @@ const segmentAngles = {
20
20
  Evaluation: 2.08,
21
21
  Security: 2.82
22
22
  };
23
+ const hubTitlePattern = /\b(memory\s*hub|knowledge\s*root|moc|map)\b/i;
23
24
  const hashText = (value) => Array.from(value).reduce((hash, char) => ((hash << 5) - hash + char.charCodeAt(0)) | 0, 0);
24
25
  const jitter = (value, range) => {
25
26
  const normalized = Math.abs(hashText(value) % 1000) / 1000;
@@ -62,6 +63,44 @@ const byDegreeThenTitle = (degrees) => (left, right) => {
62
63
  const degreeDelta = (degrees.get(right.id) ?? 0) - (degrees.get(left.id) ?? 0);
63
64
  return degreeDelta === 0 ? byTitle(left, right) : degreeDelta;
64
65
  };
66
+ const hubScore = (node) => {
67
+ const title = node.title.trim().toLowerCase();
68
+ if (title === 'memory hub')
69
+ return 5;
70
+ if (title === 'knowledge root')
71
+ return 4;
72
+ if (/\bmoc\b/i.test(node.title))
73
+ return 3;
74
+ return hubTitlePattern.test(node.title) ? 2 : 0;
75
+ };
76
+ const selectPrimaryHubId = (nodes, degrees) => {
77
+ const ranked = [...nodes]
78
+ .filter((node) => hubScore(node) > 0)
79
+ .sort((left, right) => {
80
+ const scoreDelta = hubScore(right) - hubScore(left);
81
+ if (scoreDelta !== 0)
82
+ return scoreDelta;
83
+ const degreeDelta = (degrees.get(right.id) ?? 0) - (degrees.get(left.id) ?? 0);
84
+ if (degreeDelta !== 0)
85
+ return degreeDelta;
86
+ return left.title.localeCompare(right.title);
87
+ });
88
+ return ranked[0]?.id ?? null;
89
+ };
90
+ const centerLayoutByNode = (nodes, nodeId) => {
91
+ if (!nodeId) {
92
+ return nodes;
93
+ }
94
+ const anchor = nodes.find((node) => node.id === nodeId);
95
+ if (!anchor) {
96
+ return nodes;
97
+ }
98
+ return nodes.map((node) => ({
99
+ ...node,
100
+ x: node.x - anchor.x,
101
+ y: node.y - anchor.y
102
+ }));
103
+ };
65
104
  const naturalSegmentSeed = (node) => groupKey(node) === '00-maps' || /\b(moc|map)\b/i.test(node.title);
66
105
  const segmentName = (node) => node.title.replace(/^MOC\s+/i, '').replace(/\s+Memory Map$/i, '').trim() || node.title;
67
106
  const collectComponent = (adjacency, startId, visited) => {
@@ -246,8 +285,10 @@ export const createCauliflowerGraphLayout = (graph) => {
246
285
  const segmentGroups = Array.from(groupNodesBySegment(graph.nodes, segments).entries())
247
286
  .sort(([left], [right]) => left.localeCompare(right));
248
287
  const nodes = relaxCollisions(segmentGroups.flatMap(createSegmentNodes(segments, degrees, segmentGroups.length)));
288
+ const primaryHubId = selectPrimaryHubId(graph.nodes, degrees);
289
+ const centeredNodes = centerLayoutByNode(nodes, primaryHubId);
249
290
  return {
250
- nodes,
291
+ nodes: centeredNodes,
251
292
  edges: graph.edges
252
293
  };
253
294
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andespindola/brainlink",
3
- "version": "0.1.0-beta.52",
3
+ "version": "0.1.0-beta.54",
4
4
  "description": "Local-first knowledge memory for agents with Markdown, backlinks, indexing and context retrieval.",
5
5
  "type": "module",
6
6
  "license": "MIT",