@andespindola/brainlink 1.0.5 → 1.0.7

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.
Files changed (61) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +46 -0
  3. package/dist/application/add-note.js +2 -2
  4. package/dist/application/build-context.js +16 -10
  5. package/dist/application/canonical-context-links.js +44 -5
  6. package/dist/application/check-package-update.js +105 -0
  7. package/dist/application/find-similar-notes.js +75 -0
  8. package/dist/application/frontend/client/chunk-fetch.js +236 -0
  9. package/dist/application/frontend/client/controls.js +178 -0
  10. package/dist/application/frontend/client/elements.js +122 -0
  11. package/dist/application/frontend/client/input.js +202 -0
  12. package/dist/application/frontend/client/node-details.js +191 -0
  13. package/dist/application/frontend/client/rendering.js +296 -0
  14. package/dist/application/frontend/client/scope-theme.js +114 -0
  15. package/dist/application/frontend/client/spatial.js +98 -0
  16. package/dist/application/frontend/client/storage.js +215 -0
  17. package/dist/application/frontend/client/upload.js +90 -0
  18. package/dist/application/frontend/client/worker-bootstrap.js +147 -0
  19. package/dist/application/frontend/client-js.js +24 -1837
  20. package/dist/application/frontend/client-render-worker-js.js +1 -1
  21. package/dist/application/index-vault-phases.js +190 -0
  22. package/dist/application/index-vault.js +46 -167
  23. package/dist/application/memory-suggestions.js +4 -1
  24. package/dist/application/search-knowledge.js +19 -3
  25. package/dist/cli/commands/write/dedupe-commands.js +59 -0
  26. package/dist/cli/commands/write/index-commands.js +205 -0
  27. package/dist/cli/commands/write/link-commands.js +68 -0
  28. package/dist/cli/commands/write/note-commands.js +146 -0
  29. package/dist/cli/commands/write/server-commands.js +553 -0
  30. package/dist/cli/commands/write/shared.js +35 -0
  31. package/dist/cli/commands/write/vault-lifecycle-commands.js +270 -0
  32. package/dist/cli/commands/write-commands.js +12 -1303
  33. package/dist/cli/main.js +39 -3
  34. package/dist/domain/context.js +60 -11
  35. package/dist/domain/diversity.js +64 -0
  36. package/dist/domain/embeddings.js +148 -6
  37. package/dist/domain/graph-contexts.js +62 -57
  38. package/dist/domain/graph-layout/cauliflower-layout.js +116 -0
  39. package/dist/domain/graph-layout/collisions.js +100 -0
  40. package/dist/domain/graph-layout/hierarchy.js +135 -0
  41. package/dist/domain/graph-layout/metrics.js +111 -0
  42. package/dist/domain/graph-layout/segments.js +76 -0
  43. package/dist/domain/graph-layout/star-layout.js +110 -0
  44. package/dist/domain/graph-layout.js +4 -625
  45. package/dist/domain/markdown.js +10 -1
  46. package/dist/domain/scoring.js +42 -0
  47. package/dist/domain/tokens.js +17 -1
  48. package/dist/infrastructure/config.js +33 -1
  49. package/dist/infrastructure/file-index.js +227 -60
  50. package/dist/infrastructure/semantic-prefilter.js +24 -0
  51. package/dist/mcp/server.js +23 -1
  52. package/dist/mcp/tool-guard.js +29 -0
  53. package/dist/mcp/tools/maintenance-tools.js +409 -0
  54. package/dist/mcp/tools/read-tools.js +537 -0
  55. package/dist/mcp/tools/shared.js +216 -0
  56. package/dist/mcp/tools/write-tools.js +353 -0
  57. package/dist/mcp/tools.js +3 -1357
  58. package/docs/AGENT_USAGE.md +4 -1
  59. package/docs/ARCHITECTURE.md +4 -3
  60. package/docs/QUICKSTART.md +4 -0
  61. package/package.json +2 -2
@@ -0,0 +1,236 @@
1
+ export const createChunkFetchJs = () => `
2
+ const fitFromChunk = () => {
3
+ const nodes = normalizeList(state.chunk.nodes)
4
+ if (nodes.length === 0) {
5
+ return
6
+ }
7
+
8
+ let minX = Infinity
9
+ let minY = Infinity
10
+ let maxX = -Infinity
11
+ let maxY = -Infinity
12
+
13
+ for (let index = 0; index < nodes.length; index += 1) {
14
+ const node = nodes[index]
15
+ const x = Number(node[2])
16
+ const y = Number(node[3])
17
+ if (!Number.isFinite(x) || !Number.isFinite(y)) {
18
+ continue
19
+ }
20
+ if (x < minX) minX = x
21
+ if (y < minY) minY = y
22
+ if (x > maxX) maxX = x
23
+ if (y > maxY) maxY = y
24
+ }
25
+
26
+ if (!Number.isFinite(minX) || !Number.isFinite(minY) || !Number.isFinite(maxX) || !Number.isFinite(maxY)) {
27
+ return
28
+ }
29
+
30
+ const width = Math.max(1, maxX - minX)
31
+ const height = Math.max(1, maxY - minY)
32
+ const scaleX = state.viewport.width / width
33
+ const scaleY = state.viewport.height / height
34
+ const scale = clampScale(Math.min(scaleX, scaleY) * 0.72)
35
+
36
+ state.camera.scale = scale
37
+ state.camera.x = state.viewport.width / 2 - (minX + width / 2) * scale
38
+ state.camera.y = state.viewport.height / 2 - (minY + height / 2) * scale
39
+ updateWorkerCamera()
40
+ }
41
+
42
+ const fetchChunk = async ({ fit } = { fit: false }) => {
43
+ const token = ++state.fetchToken
44
+ if (state.fetchAbortController) {
45
+ state.fetchAbortController.abort()
46
+ }
47
+ const controller = new AbortController()
48
+ state.fetchAbortController = controller
49
+ const worldTopLeft = screenToWorld(0, 0)
50
+ const worldBottomRight = screenToWorld(state.viewport.width, state.viewport.height)
51
+ const x = Math.min(worldTopLeft.x, worldBottomRight.x)
52
+ const y = Math.min(worldTopLeft.y, worldBottomRight.y)
53
+ const w = Math.abs(worldBottomRight.x - worldTopLeft.x)
54
+ const h = Math.abs(worldBottomRight.y - worldTopLeft.y)
55
+
56
+ const params = new URLSearchParams({
57
+ x: String(x),
58
+ y: String(y),
59
+ w: String(Math.max(1, w)),
60
+ h: String(Math.max(1, h)),
61
+ scale: String(state.camera.scale),
62
+ nodeBudget: String(getZoomNodeBudget()),
63
+ edgeBudget: String(getZoomEdgeBudget())
64
+ })
65
+
66
+ if (state.agentId) {
67
+ params.set('agent', state.agentId)
68
+ }
69
+ if (state.contextId) {
70
+ params.set('context', state.contextId)
71
+ }
72
+
73
+ const requestKey = graphStreamRequestKey({ x, y, w, h })
74
+ if (!fit && state.lastChunkRequestKey === requestKey && state.chunk.nodes.length > 0) {
75
+ return
76
+ }
77
+
78
+ const response = await fetch('/api/graph-stream?' + params.toString(), { signal: controller.signal })
79
+ if (!response.ok) {
80
+ throw new Error('Failed to fetch graph stream chunk')
81
+ }
82
+
83
+ const chunk = await response.json()
84
+ if (controller.signal.aborted) {
85
+ return
86
+ }
87
+ if (token !== state.fetchToken) {
88
+ return
89
+ }
90
+
91
+ state.graphSignature = typeof chunk.signature === 'string' ? chunk.signature : ''
92
+ state.lastChunkRequestKey = requestKey
93
+ ensureNodePositionsLoaded()
94
+ await syncNodePositionsFromServer()
95
+ state.graphMode = typeof chunk.mode === 'string' ? chunk.mode : 'near'
96
+ const chunkNodes = applyManualNodePositions(chunk.nodes)
97
+ state.chunk = {
98
+ nodes: chunkNodes,
99
+ edges: normalizeList(chunk.edges)
100
+ }
101
+ state.miniMapDirty = true
102
+ state.spatialIndex.key = ''
103
+ const renderChunk = { ...chunk, nodes: chunkNodes }
104
+ state.totals = {
105
+ nodes: Number.isFinite(chunk?.totals?.nodes) ? Number(chunk.totals.nodes) : state.chunk.nodes.length,
106
+ edges: Number.isFinite(chunk?.totals?.edges) ? Number(chunk.totals.edges) : state.chunk.edges.length
107
+ }
108
+
109
+ updateTotals()
110
+
111
+ if (fit) {
112
+ fitFromChunk()
113
+ }
114
+
115
+ if (state.renderWorker && state.workerReady) {
116
+ state.renderWorker.postMessage({ type: 'chunk', chunk: renderChunk })
117
+ state.renderWorker.postMessage({ type: 'select', id: state.selectedNodeId })
118
+ state.renderWorker.postMessage({ type: 'highlight', ids: Array.from(state.searchResultIds) })
119
+ }
120
+
121
+ updateGraphOverlays()
122
+ drawFallback()
123
+ }
124
+
125
+ const scheduleChunkFetch = ({ fit } = { fit: false }) => {
126
+ if (state.fetchTimer) {
127
+ clearTimeout(state.fetchTimer)
128
+ }
129
+
130
+ const now = performance.now()
131
+ const recentlyWheeling = now - state.lastWheelAt < 320
132
+ const heavyScene = state.lastVisibleNodes > 1200 || state.lastVisibleEdges > 3500
133
+ const delay = fit ? 0 : (state.pointer.down ? 320 : (recentlyWheeling ? (heavyScene ? 420 : 300) : (heavyScene ? 120 : 72)))
134
+ state.fetchTimer = setTimeout(() => {
135
+ state.fetchTimer = null
136
+ fetchChunk({ fit }).catch((error) => {
137
+ if (error && error.name === 'AbortError') {
138
+ return
139
+ }
140
+ console.error(error)
141
+ })
142
+ }, delay)
143
+ }
144
+
145
+ const setViewportFromCanvas = () => {
146
+ const rect = canvas.getBoundingClientRect()
147
+ state.viewport.width = Math.max(320, rect.width)
148
+ state.viewport.height = Math.max(320, rect.height)
149
+ state.viewport.ratio = window.devicePixelRatio || 1
150
+ state.miniMapDirty = true
151
+ updateWorkerSize()
152
+ drawFallback()
153
+ }
154
+
155
+ const pickFallbackNode = (screenX, screenY) => {
156
+ const nodes = spatialCandidates(screenX, screenY)
157
+ if (nodes.length === 0) {
158
+ return null
159
+ }
160
+
161
+ let bestNode = null
162
+ let bestDistance = Infinity
163
+ for (let index = 0; index < nodes.length; index += 1) {
164
+ const node = nodes[index]
165
+ const id = typeof node[0] === 'string' ? node[0] : ''
166
+ if (!id) continue
167
+ const x = Number(node[2])
168
+ const y = Number(node[3])
169
+ const weight = Number(node[7])
170
+ if (!Number.isFinite(x) || !Number.isFinite(y)) continue
171
+ const point = worldToScreen(x, y)
172
+ const radius = Math.max(3.2, Math.min(16.5, 5 + (Number.isFinite(weight) ? weight : 0) * 0.65))
173
+ const distance = Math.hypot(screenX - point.x, screenY - point.y)
174
+ if (distance <= radius && distance < bestDistance) {
175
+ bestDistance = distance
176
+ bestNode = node
177
+ }
178
+ }
179
+
180
+ return bestNode
181
+ }
182
+
183
+ const pickFallbackNodeId = (screenX, screenY) => {
184
+ const node = pickFallbackNode(screenX, screenY)
185
+ return typeof node?.[0] === 'string' ? node[0] : ''
186
+ }
187
+
188
+ const handlePickedNode = (node) => {
189
+ const nodeId = typeof node?.id === 'string' ? node.id : typeof node?.[0] === 'string' ? node[0] : ''
190
+ if (!nodeId) {
191
+ return
192
+ }
193
+
194
+ const kind = typeof node?.kind === 'string' ? node.kind : nodeKind(node)
195
+ if (kind === 'cluster') {
196
+ const currentScale = state.camera.scale
197
+ const targetScale = currentScale < 0.22 ? 0.28 : Math.min(1.1, currentScale * 1.6)
198
+ focusNodeInViewport(nodeId, targetScale)
199
+ return
200
+ }
201
+
202
+ loadNodeDetails(nodeId).catch((error) => console.error(error))
203
+ }
204
+
205
+ const pickAt = (screenX, screenY) => {
206
+ if (state.rendererMode === 'fallback') {
207
+ const node = pickFallbackNode(screenX, screenY)
208
+ if (node) {
209
+ handlePickedNode(node)
210
+ }
211
+ return
212
+ }
213
+
214
+ if (!state.renderWorker || !state.workerReady) {
215
+ return
216
+ }
217
+
218
+ const requestId = Math.random().toString(36).slice(2)
219
+ state.renderWorker.postMessage({
220
+ type: 'pick',
221
+ requestId,
222
+ x: screenX,
223
+ y: screenY
224
+ })
225
+ }
226
+
227
+ const zoomAtPoint = (screenX, screenY, factor) => {
228
+ const clamped = Math.max(0.92, Math.min(1.09, factor))
229
+ const before = screenToWorld(screenX, screenY)
230
+ state.camera.scale = clampScale(state.camera.scale * clamped)
231
+ state.camera.x = screenX - before.x * state.camera.scale
232
+ state.camera.y = screenY - before.y * state.camera.scale
233
+ updateWorkerCamera()
234
+ scheduleChunkFetch()
235
+ }
236
+ `;
@@ -0,0 +1,178 @@
1
+ export const createControlsJs = () => `
2
+ const setupControls = () => {
3
+ elements.zoomIn.addEventListener('click', () => {
4
+ zoomAtPoint(state.viewport.width / 2, state.viewport.height / 2, 1.06)
5
+ })
6
+
7
+ elements.zoomOut.addEventListener('click', () => {
8
+ zoomAtPoint(state.viewport.width / 2, state.viewport.height / 2, 0.944)
9
+ })
10
+
11
+ elements.fit.addEventListener('click', () => {
12
+ fitFromChunk()
13
+ scheduleChunkFetch()
14
+ })
15
+
16
+ elements.releaseNode.addEventListener('click', () => {
17
+ releaseSelectedNodePosition()
18
+ })
19
+
20
+ elements.reset.addEventListener('click', () => {
21
+ clearStoredNodePositions()
22
+ clearNodePositionsOnServer()
23
+ state.camera = { x: 0, y: 0, scale: 0.22 }
24
+ updateWorkerCamera()
25
+ scheduleChunkFetch({ fit: true })
26
+ })
27
+
28
+ elements.contentClose.addEventListener('click', () => {
29
+ closeContentDialog()
30
+ })
31
+
32
+ elements.copyWikiLink.addEventListener('click', () => {
33
+ copySelectedWikiLink().catch((error) => {
34
+ elements.contentActionStatus.textContent = error instanceof Error ? error.message : String(error)
35
+ })
36
+ })
37
+
38
+ elements.suggestNodeLinks.addEventListener('click', () => {
39
+ loadSelectedLinkSuggestions().catch((error) => {
40
+ elements.contentActionStatus.textContent = error instanceof Error ? error.message : String(error)
41
+ })
42
+ })
43
+
44
+ elements.contentLinkSuggestions.addEventListener('click', (event) => {
45
+ const button = event.target.closest('button[data-title]')
46
+ if (!button) {
47
+ return
48
+ }
49
+ const value = '[[' + button.dataset.title + ']]'
50
+ navigator.clipboard.writeText(value).then(() => {
51
+ elements.contentActionStatus.textContent = 'Copied ' + value
52
+ }).catch(() => {
53
+ elements.contentActionStatus.textContent = value
54
+ })
55
+ })
56
+
57
+ elements.contentDialog.addEventListener('click', (event) => {
58
+ if (event.target === elements.contentDialog) {
59
+ closeContentDialog()
60
+ }
61
+ })
62
+
63
+ elements.search.addEventListener('input', () => {
64
+ if (state.searchTimer) {
65
+ clearTimeout(state.searchTimer)
66
+ }
67
+ state.searchTimer = setTimeout(() => {
68
+ state.searchTimer = null
69
+ runGraphSearch().catch((error) => console.error(error))
70
+ }, 160)
71
+ })
72
+ }
73
+
74
+ const runGraphSearch = async () => {
75
+ const token = ++state.searchToken
76
+ const query = (elements.search.value || '').trim()
77
+ if (!query) {
78
+ state.searchResultIds = new Set()
79
+ setFocusedNodeIds(new Set())
80
+ if (state.renderWorker && state.workerReady) {
81
+ state.renderWorker.postMessage({ type: 'highlight', ids: [] })
82
+ }
83
+ return
84
+ }
85
+
86
+ const response = await fetch('/api/graph-filter?q=' + encodeURIComponent(query) + '&limit=1800' + scopeQuery('&'))
87
+ if (!response.ok) {
88
+ throw new Error('Failed to search graph')
89
+ }
90
+ const payload = await response.json()
91
+ if (token !== state.searchToken) {
92
+ return
93
+ }
94
+
95
+ const ids = Array.isArray(payload?.nodeIds) ? payload.nodeIds.filter((id) => typeof id === 'string' && id.length > 0) : []
96
+ state.searchResultIds = new Set(ids)
97
+ setFocusedNodeIds(state.searchResultIds)
98
+ if (state.renderWorker && state.workerReady) {
99
+ state.renderWorker.postMessage({ type: 'highlight', ids })
100
+ }
101
+ if (ids.length > 0 && state.graphMode === 'far') {
102
+ state.camera.scale = Math.max(state.camera.scale, 0.82)
103
+ updateWorkerCamera()
104
+ scheduleChunkFetch()
105
+ }
106
+ }
107
+
108
+ const loadAgents = async () => {
109
+ const response = await fetch('/api/agents')
110
+ if (!response.ok) {
111
+ throw new Error('Failed to load agents')
112
+ }
113
+
114
+ const payload = await response.json()
115
+ const agents = Array.isArray(payload?.agents) ? payload.agents : []
116
+
117
+ elements.agent.innerHTML = agents
118
+ .map((agent) => {
119
+ const id = String(agent?.id || '')
120
+ const count = Number.isFinite(agent?.documentCount) ? agent.documentCount : 0
121
+ const label = id === 'shared' ? 'shared' : id
122
+ return '<option value="' + escapeHtml(id) + '">' + escapeHtml(label) + ' (' + count + ')</option>'
123
+ })
124
+ .join('')
125
+
126
+ const preferredAgent = initialAgentFromUrl || readStoredAgent()
127
+ const hasPreferred = preferredAgent && agents.some((agent) => agent?.id === preferredAgent)
128
+ state.agentId = hasPreferred ? preferredAgent : String(agents[0]?.id || '')
129
+ elements.agent.value = state.agentId
130
+
131
+ elements.agent.addEventListener('change', () => {
132
+ state.agentId = elements.agent.value || ''
133
+ writeStoredAgent(state.agentId)
134
+ syncAgentInUrl(state.agentId)
135
+ loadContexts().then(() => scheduleChunkFetch({ fit: true })).catch((error) => console.error(error))
136
+ })
137
+
138
+ syncAgentInUrl(state.agentId)
139
+ }
140
+
141
+ const loadContexts = async () => {
142
+ const response = await fetch('/api/graph-contexts' + (state.agentId ? '?agent=' + encodeURIComponent(state.agentId) : ''))
143
+ if (!response.ok) {
144
+ throw new Error('Failed to load graph contexts')
145
+ }
146
+
147
+ const payload = await response.json()
148
+ const contexts = Array.isArray(payload?.contexts) ? payload.contexts : []
149
+ const options = [
150
+ '<option value="">All contexts</option>',
151
+ ...contexts.map((context) => {
152
+ const id = String(context?.id || '')
153
+ const title = String(context?.title || id || 'Untitled')
154
+ const count = Number.isFinite(context?.nodeCount) ? context.nodeCount : 0
155
+ return '<option value="' + escapeHtml(id) + '">' + escapeHtml(title) + ' (' + count + ')</option>'
156
+ })
157
+ ]
158
+
159
+ elements.context.innerHTML = options.join('')
160
+
161
+ const preferredContext = initialContextFromUrl || readStoredContext()
162
+ const hasPreferred = preferredContext && contexts.some((context) => context?.id === preferredContext)
163
+ state.contextId = hasPreferred ? preferredContext : ''
164
+ elements.context.value = state.contextId
165
+ writeStoredContext(state.contextId)
166
+ syncContextInUrl(state.contextId)
167
+ }
168
+
169
+ const setupContextControl = () => {
170
+ elements.context.addEventListener('change', () => {
171
+ state.contextId = elements.context.value || ''
172
+ state.selectedNodeId = null
173
+ writeStoredContext(state.contextId)
174
+ syncContextInUrl(state.contextId)
175
+ scheduleChunkFetch({ fit: true })
176
+ })
177
+ }
178
+ `;
@@ -0,0 +1,122 @@
1
+ export const createElementsJs = () => `let canvas = document.getElementById('graph')
2
+ let ctx2dFallback = null
3
+ let inputGlobalsBound = false
4
+ const byId = (id) => document.getElementById(id)
5
+ const elements = {
6
+ search: byId('search'),
7
+ agent: byId('agent'),
8
+ context: byId('context'),
9
+ nodeCount: byId('nodeCount'),
10
+ edgeCount: byId('edgeCount'),
11
+ zoomIn: byId('zoomIn'),
12
+ zoomOut: byId('zoomOut'),
13
+ fit: byId('fit'),
14
+ releaseNode: byId('releaseNode'),
15
+ reset: byId('reset'),
16
+ uploadOpen: byId('uploadOpen'),
17
+ uploadDialog: byId('uploadDialog'),
18
+ uploadForm: byId('uploadForm'),
19
+ uploadFile: byId('uploadFile'),
20
+ uploadTitleInput: byId('uploadTitleInput'),
21
+ uploadAllowSensitive: byId('uploadAllowSensitive'),
22
+ uploadClose: byId('uploadClose'),
23
+ uploadSubmit: byId('uploadSubmit'),
24
+ uploadStatus: byId('uploadStatus'),
25
+ labels: byId('graphLabels'),
26
+ tooltip: byId('graphTooltip'),
27
+ miniMap: byId('miniMap'),
28
+ contentDialog: byId('contentDialog'),
29
+ contentTitle: byId('contentTitle'),
30
+ contentPath: byId('contentPath'),
31
+ contentFacts: byId('contentFacts'),
32
+ contentContextLinks: byId('contentContextLinks'),
33
+ contentTags: byId('contentTags'),
34
+ contentOutgoing: byId('contentOutgoing'),
35
+ contentIncoming: byId('contentIncoming'),
36
+ contentBody: byId('contentBody'),
37
+ contentClose: byId('contentClose'),
38
+ copyWikiLink: byId('copyWikiLink'),
39
+ suggestNodeLinks: byId('suggestNodeLinks'),
40
+ contentActionStatus: byId('contentActionStatus'),
41
+ contentLinkSuggestions: byId('contentLinkSuggestions')
42
+ }
43
+
44
+ const state = {
45
+ camera: {
46
+ x: 0,
47
+ y: 0,
48
+ scale: 0.22
49
+ },
50
+ pointer: {
51
+ down: false,
52
+ moved: false,
53
+ dragging: false,
54
+ dragNodeId: '',
55
+ x: 0,
56
+ y: 0,
57
+ startX: 0,
58
+ startY: 0,
59
+ startWorldX: 0,
60
+ startWorldY: 0,
61
+ nodeStartX: 0,
62
+ nodeStartY: 0,
63
+ worldAnchorX: 0,
64
+ worldAnchorY: 0
65
+ },
66
+ viewport: {
67
+ width: 320,
68
+ height: 320,
69
+ ratio: window.devicePixelRatio || 1
70
+ },
71
+ workerReady: false,
72
+ rendererMode: 'worker',
73
+ renderWorker: null,
74
+ agentId: '',
75
+ contextId: '',
76
+ graphSignature: '',
77
+ graphMode: 'near',
78
+ nodePositionsSignature: '',
79
+ nodePositionsScope: '',
80
+ serverNodePositionsScope: '',
81
+ nodePositions: new Map(),
82
+ hoveredNodeId: '',
83
+ focusedNodeIds: new Set(),
84
+ spatialIndex: {
85
+ key: '',
86
+ cells: new Map()
87
+ },
88
+ miniMapView: null,
89
+ miniMapDirty: true,
90
+ overlayScheduled: false,
91
+ overlayIdleTimer: null,
92
+ chunk: {
93
+ nodes: [],
94
+ edges: []
95
+ },
96
+ selectedNodeId: null,
97
+ searchToken: 0,
98
+ searchTimer: null,
99
+ searchResultIds: new Set(),
100
+ fetchToken: 0,
101
+ fetchTimer: null,
102
+ fetchAbortController: null,
103
+ lastChunkRequestKey: '',
104
+ cameraSyncScheduled: false,
105
+ lastWheelAt: 0,
106
+ lastVisibleNodes: 0,
107
+ lastVisibleEdges: 0,
108
+ totals: {
109
+ nodes: 0,
110
+ edges: 0
111
+ }
112
+ }
113
+
114
+ const zoomRange = {
115
+ min: 0.0002,
116
+ max: 4.5
117
+ }
118
+
119
+ const selectedAgentStorageKey = 'brainlink:selected-agent'
120
+ const selectedContextStorageKey = 'brainlink:selected-context'
121
+ const nodePositionsStoragePrefix = 'brainlink:graph-node-positions:'
122
+ `;