@andespindola/brainlink 1.0.4 → 1.0.6

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 (53) hide show
  1. package/README.md +17 -9
  2. package/dist/application/add-note.js +2 -2
  3. package/dist/application/build-context.js +16 -10
  4. package/dist/application/canonical-context-links.js +44 -5
  5. package/dist/application/check-package-update.js +105 -0
  6. package/dist/application/frontend/client/chunk-fetch.js +236 -0
  7. package/dist/application/frontend/client/controls.js +178 -0
  8. package/dist/application/frontend/client/elements.js +122 -0
  9. package/dist/application/frontend/client/input.js +202 -0
  10. package/dist/application/frontend/client/node-details.js +191 -0
  11. package/dist/application/frontend/client/rendering.js +296 -0
  12. package/dist/application/frontend/client/scope-theme.js +114 -0
  13. package/dist/application/frontend/client/spatial.js +98 -0
  14. package/dist/application/frontend/client/storage.js +215 -0
  15. package/dist/application/frontend/client/upload.js +90 -0
  16. package/dist/application/frontend/client/worker-bootstrap.js +147 -0
  17. package/dist/application/frontend/client-js.js +24 -1837
  18. package/dist/application/frontend/client-render-worker-js.js +1 -1
  19. package/dist/application/index-vault-phases.js +189 -0
  20. package/dist/application/index-vault.js +44 -165
  21. package/dist/application/server/routes.js +12 -9
  22. package/dist/cli/commands/write/dedupe-commands.js +59 -0
  23. package/dist/cli/commands/write/index-commands.js +205 -0
  24. package/dist/cli/commands/write/link-commands.js +68 -0
  25. package/dist/cli/commands/write/note-commands.js +146 -0
  26. package/dist/cli/commands/write/server-commands.js +553 -0
  27. package/dist/cli/commands/write/shared.js +35 -0
  28. package/dist/cli/commands/write/vault-lifecycle-commands.js +270 -0
  29. package/dist/cli/commands/write-commands.js +12 -1303
  30. package/dist/cli/main.js +39 -3
  31. package/dist/domain/context.js +39 -3
  32. package/dist/domain/embeddings.js +31 -5
  33. package/dist/domain/graph-contexts.js +62 -57
  34. package/dist/domain/graph-layout/cauliflower-layout.js +116 -0
  35. package/dist/domain/graph-layout/collisions.js +100 -0
  36. package/dist/domain/graph-layout/hierarchy.js +135 -0
  37. package/dist/domain/graph-layout/metrics.js +111 -0
  38. package/dist/domain/graph-layout/segments.js +76 -0
  39. package/dist/domain/graph-layout/star-layout.js +110 -0
  40. package/dist/domain/graph-layout.js +4 -625
  41. package/dist/infrastructure/config.js +10 -4
  42. package/dist/infrastructure/file-index.js +13 -4
  43. package/dist/infrastructure/semantic-prefilter.js +24 -0
  44. package/dist/mcp/server.js +7 -0
  45. package/dist/mcp/tool-guard.js +29 -0
  46. package/dist/mcp/tools/maintenance-tools.js +409 -0
  47. package/dist/mcp/tools/read-tools.js +504 -0
  48. package/dist/mcp/tools/shared.js +216 -0
  49. package/dist/mcp/tools/write-tools.js +247 -0
  50. package/dist/mcp/tools.js +3 -1357
  51. package/docs/AGENT_USAGE.md +4 -4
  52. package/docs/QUICKSTART.md +5 -1
  53. package/package.json +2 -2
@@ -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
+ `;
@@ -0,0 +1,202 @@
1
+ export const createInputJs = () => `
2
+ const resolvePointer = (event) => {
3
+ const rect = canvas.getBoundingClientRect()
4
+ return {
5
+ x: event.clientX - rect.left,
6
+ y: event.clientY - rect.top
7
+ }
8
+ }
9
+
10
+ const setupInput = () => {
11
+ const dragActivationDistance = 6
12
+ const resetPointerState = (pointerId = null) => {
13
+ state.pointer.down = false
14
+ state.pointer.dragging = false
15
+ state.pointer.dragNodeId = ''
16
+ canvas.classList.remove('is-node-dragging')
17
+ if (pointerId !== null) {
18
+ try {
19
+ if (canvas.hasPointerCapture(pointerId)) {
20
+ canvas.releasePointerCapture(pointerId)
21
+ }
22
+ } catch {}
23
+ }
24
+ }
25
+
26
+ canvas.addEventListener('wheel', (event) => {
27
+ event.preventDefault()
28
+ state.lastWheelAt = performance.now()
29
+ const pointer = resolvePointer(event)
30
+ const exponent = Math.max(-0.05, Math.min(0.05, -event.deltaY * 0.001))
31
+ zoomAtPoint(pointer.x, pointer.y, Math.exp(exponent))
32
+ }, { passive: false })
33
+
34
+ canvas.addEventListener('pointerdown', (event) => {
35
+ event.preventDefault()
36
+ const pointer = resolvePointer(event)
37
+ const candidateNode = pickFallbackNode(pointer.x, pointer.y)
38
+ const candidateNodeId = isRealGraphNode(candidateNode) && typeof candidateNode?.[0] === 'string' ? candidateNode[0] : ''
39
+ const candidateX = Number(candidateNode?.[2])
40
+ const candidateY = Number(candidateNode?.[3])
41
+ const world = screenToWorld(pointer.x, pointer.y)
42
+ state.pointer.down = true
43
+ state.pointer.moved = false
44
+ state.pointer.dragging = false
45
+ state.pointer.dragNodeId = candidateNodeId
46
+ state.pointer.x = pointer.x
47
+ state.pointer.y = pointer.y
48
+ state.pointer.startX = pointer.x
49
+ state.pointer.startY = pointer.y
50
+ state.pointer.startWorldX = world.x
51
+ state.pointer.startWorldY = world.y
52
+ state.pointer.nodeStartX = candidateNodeId && Number.isFinite(candidateX) ? candidateX : 0
53
+ state.pointer.nodeStartY = candidateNodeId && Number.isFinite(candidateY) ? candidateY : 0
54
+ state.pointer.worldAnchorX = world.x
55
+ state.pointer.worldAnchorY = world.y
56
+ try {
57
+ canvas.setPointerCapture(event.pointerId)
58
+ } catch {}
59
+ })
60
+
61
+ canvas.addEventListener('pointermove', (event) => {
62
+ if (state.pointer.down) {
63
+ event.preventDefault()
64
+ }
65
+ const pointer = resolvePointer(event)
66
+
67
+ if (state.pointer.down) {
68
+ const dx = pointer.x - state.pointer.x
69
+ const dy = pointer.y - state.pointer.y
70
+ const distanceFromStart = Math.hypot(pointer.x - state.pointer.startX, pointer.y - state.pointer.startY)
71
+ if (distanceFromStart >= dragActivationDistance) {
72
+ state.pointer.moved = true
73
+ state.pointer.dragging = true
74
+ canvas.classList.toggle('is-node-dragging', Boolean(state.pointer.dragNodeId))
75
+ }
76
+ if (!state.pointer.dragging) {
77
+ state.pointer.x = pointer.x
78
+ state.pointer.y = pointer.y
79
+ return
80
+ }
81
+ if (state.pointer.dragNodeId) {
82
+ const world = screenToWorld(pointer.x, pointer.y)
83
+ const x = state.pointer.nodeStartX + world.x - state.pointer.startWorldX
84
+ const y = state.pointer.nodeStartY + world.y - state.pointer.startWorldY
85
+ state.nodePositions.set(state.pointer.dragNodeId, { x, y })
86
+ updateNodePositionInChunk(state.pointer.dragNodeId, x, y)
87
+ state.pointer.x = pointer.x
88
+ state.pointer.y = pointer.y
89
+ drawFallback()
90
+ return
91
+ }
92
+ state.camera.x += dx
93
+ state.camera.y += dy
94
+ state.pointer.x = pointer.x
95
+ state.pointer.y = pointer.y
96
+ updateWorkerCamera()
97
+ drawFallback()
98
+ return
99
+ }
100
+
101
+ const hovered = pickFallbackNode(pointer.x, pointer.y)
102
+ const hoveredId = isRealGraphNode(hovered) && typeof hovered?.[0] === 'string' ? hovered[0] : ''
103
+ if (state.hoveredNodeId !== hoveredId) {
104
+ state.hoveredNodeId = hoveredId
105
+ canvas.classList.toggle('is-node-hover', Boolean(hoveredId))
106
+ updateGraphOverlays()
107
+ }
108
+ if (hoveredId) {
109
+ showTooltip(hovered, pointer)
110
+ } else {
111
+ hideTooltip()
112
+ }
113
+ })
114
+
115
+ canvas.addEventListener('pointerup', (event) => {
116
+ const pointer = resolvePointer(event)
117
+ const distanceFromStart = Math.hypot(pointer.x - state.pointer.startX, pointer.y - state.pointer.startY)
118
+ const shouldPick = !state.pointer.dragging && distanceFromStart < dragActivationDistance
119
+ const shouldRefreshAfterDrag = state.pointer.dragging
120
+ const shouldPersistNodePosition = state.pointer.dragging && Boolean(state.pointer.dragNodeId)
121
+ resetPointerState(event.pointerId)
122
+
123
+ if (shouldPick) {
124
+ pickAt(pointer.x, pointer.y)
125
+ return
126
+ }
127
+ if (shouldPersistNodePosition) {
128
+ writeStoredNodePositions()
129
+ persistNodePositionsToServer()
130
+ return
131
+ }
132
+ if (shouldRefreshAfterDrag) {
133
+ scheduleChunkFetch()
134
+ }
135
+ })
136
+
137
+ canvas.addEventListener('pointerleave', () => {
138
+ state.hoveredNodeId = ''
139
+ canvas.classList.remove('is-node-hover')
140
+ hideTooltip()
141
+ updateGraphOverlays()
142
+ })
143
+
144
+ canvas.addEventListener('pointercancel', (event) => {
145
+ resetPointerState(event.pointerId)
146
+ hideTooltip()
147
+ updateGraphOverlays()
148
+ })
149
+
150
+ canvas.addEventListener('lostpointercapture', () => {
151
+ resetPointerState()
152
+ })
153
+
154
+ // Listeners globais (mini mapa e teclado) só podem ser ligados uma vez —
155
+ // setupInput pode ser re-executado ao trocar o canvas no fallback.
156
+ if (!inputGlobalsBound) {
157
+ elements.miniMap.addEventListener('click', (event) => {
158
+ if (!state.miniMapView) {
159
+ return
160
+ }
161
+ const rect = elements.miniMap.getBoundingClientRect()
162
+ const x = event.clientX - rect.left
163
+ const y = event.clientY - rect.top
164
+ const worldX = state.miniMapView.minX + (x - state.miniMapView.offsetX) / state.miniMapView.scale
165
+ const worldY = state.miniMapView.minY + (y - state.miniMapView.offsetY) / state.miniMapView.scale
166
+ state.camera.x = state.viewport.width / 2 - worldX * state.camera.scale
167
+ state.camera.y = state.viewport.height / 2 - worldY * state.camera.scale
168
+ updateWorkerCamera()
169
+ scheduleChunkFetch()
170
+ })
171
+
172
+ window.addEventListener('keydown', (event) => {
173
+ if (event.key === 'Escape' && !elements.uploadDialog.hidden) {
174
+ closeUploadDialog()
175
+ return
176
+ }
177
+ if (event.key === 'Escape' && !elements.contentDialog.hidden) {
178
+ closeContentDialog()
179
+ return
180
+ }
181
+ if (event.key === '+') {
182
+ zoomAtPoint(state.viewport.width / 2, state.viewport.height / 2, 1.06)
183
+ return
184
+ }
185
+ if (event.key === '-') {
186
+ zoomAtPoint(state.viewport.width / 2, state.viewport.height / 2, 0.944)
187
+ return
188
+ }
189
+ if (event.key === '0') {
190
+ scheduleChunkFetch({ fit: true })
191
+ }
192
+ })
193
+
194
+ inputGlobalsBound = true
195
+ }
196
+
197
+ canvas.addEventListener('dblclick', (event) => {
198
+ const pointer = resolvePointer(event)
199
+ zoomAtPoint(pointer.x, pointer.y, 1.065)
200
+ })
201
+ }
202
+ `;
@@ -0,0 +1,191 @@
1
+ export const createNodeDetailsJs = () => `
2
+ const list = (items) => {
3
+ const rows = normalizeList(items)
4
+ if (rows.length === 0) {
5
+ return '<li><small>No links found.</small></li>'
6
+ }
7
+ return rows
8
+ .map((item) => {
9
+ const title = typeof item?.title === 'string' ? item.title : 'Untitled'
10
+ const id = typeof item?.id === 'string' ? item.id : ''
11
+ const path = typeof item?.path === 'string' ? item.path : ''
12
+ const meta = item?.weight ? ' · weight ' + escapeHtml(item.weight) + ' · ' + escapeHtml(item.priority || 'normal') : ''
13
+ return '<li>' +
14
+ (id ? '<button type="button" data-node-id="' + escapeHtml(id) + '">' + escapeHtml(title) + '</button>' : escapeHtml(title)) +
15
+ '<small>' + escapeHtml(path) + meta + '</small>' +
16
+ '</li>'
17
+ })
18
+ .join('')
19
+ }
20
+
21
+ const buildFacts = (node, outgoingCount, incomingCount) => {
22
+ const content = typeof node?.content === 'string' ? node.content : ''
23
+ const words = content.trim().length > 0 ? content.trim().split(/\\s+/).length : 0
24
+ return [
25
+ { label: 'Agent', value: typeof node?.agentId === 'string' && node.agentId ? node.agentId : 'shared' },
26
+ { label: 'Words', value: String(words) },
27
+ { label: 'Chars', value: String(content.length) },
28
+ { label: 'Outgoing', value: String(outgoingCount) },
29
+ { label: 'Backlinks', value: String(incomingCount) }
30
+ ]
31
+ }
32
+
33
+ const listFacts = (facts) => facts
34
+ .map((fact) => '<li><strong>' + escapeHtml(fact.label) + ':</strong> <small>' + escapeHtml(fact.value) + '</small></li>')
35
+ .join('')
36
+
37
+ const listContextLinks = (links) => {
38
+ if (!Array.isArray(links) || links.length === 0) {
39
+ return '<li><small>No context links found.</small></li>'
40
+ }
41
+ return links
42
+ .map((link) => '<li><span>' + escapeHtml(link.title) + '</span><small>' + escapeHtml(link.priority || 'normal') + '</small></li>')
43
+ .join('')
44
+ }
45
+
46
+ const nodeContextLinks = (node, outgoing) => {
47
+ const titles = Array.isArray(node?.contextLinks) ? node.contextLinks : []
48
+ const outgoingByTitle = new Map(normalizeList(outgoing).map((link) => [String(link.title || '').toLowerCase(), link]))
49
+
50
+ return titles
51
+ .map((title) => {
52
+ const match = outgoingByTitle.get(String(title).toLowerCase())
53
+ return {
54
+ title,
55
+ priority: match?.priority || 'normal'
56
+ }
57
+ })
58
+ .filter((link) => typeof link.title === 'string' && link.title.trim().length > 0)
59
+ }
60
+
61
+ const linkedNodes = (node) => {
62
+ const nodeById = new Map((state.chunk.nodes || []).map((item) => [item[0], item]))
63
+ const edges = normalizeList(state.chunk.edges)
64
+
65
+ const outgoing = []
66
+ const incoming = []
67
+ for (let index = 0; index < edges.length; index += 1) {
68
+ const edge = edges[index]
69
+ if (edge[0] === node.id) {
70
+ const target = nodeById.get(edge[1])
71
+ if (target) {
72
+ outgoing.push({ id: target[0], title: target[1], path: target[4] || '', weight: edge[2], priority: edge[3] })
73
+ }
74
+ }
75
+ if (edge[1] === node.id) {
76
+ const source = nodeById.get(edge[0])
77
+ if (source) {
78
+ incoming.push({ id: source[0], title: source[1], path: source[4] || '', weight: edge[2], priority: edge[3] })
79
+ }
80
+ }
81
+ }
82
+
83
+ return { outgoing, incoming }
84
+ }
85
+
86
+ const openContentDialog = () => {
87
+ elements.contentDialog.hidden = false
88
+ }
89
+
90
+ const closeContentDialog = () => {
91
+ elements.contentDialog.hidden = true
92
+ }
93
+
94
+ const selectedNode = () => {
95
+ if (!state.selectedNodeId) {
96
+ return null
97
+ }
98
+
99
+ const packed = nodeByIdFromChunk().get(state.selectedNodeId)
100
+ if (!packed) {
101
+ return null
102
+ }
103
+
104
+ return {
105
+ id: packed[0],
106
+ title: packed[1],
107
+ path: packed[4] || ''
108
+ }
109
+ }
110
+
111
+ const copySelectedWikiLink = async () => {
112
+ const node = selectedNode()
113
+ if (!node) {
114
+ elements.contentActionStatus.textContent = 'Select a note first.'
115
+ return
116
+ }
117
+
118
+ const value = '[[' + node.title + ']]'
119
+ try {
120
+ await navigator.clipboard.writeText(value)
121
+ elements.contentActionStatus.textContent = 'Copied ' + value
122
+ } catch {
123
+ elements.contentActionStatus.textContent = value
124
+ }
125
+ }
126
+
127
+ const loadSelectedLinkSuggestions = async () => {
128
+ const content = elements.contentBody.textContent || ''
129
+ if (!content.trim()) {
130
+ elements.contentActionStatus.textContent = 'Selected note has no content.'
131
+ return
132
+ }
133
+
134
+ elements.contentActionStatus.textContent = 'Loading suggestions...'
135
+ const response = await fetch('/api/suggest-links?limit=5&content=' + encodeURIComponent(content.slice(0, 2000)) + scopeQuery('&'))
136
+ if (!response.ok) {
137
+ throw new Error('Failed to load link suggestions')
138
+ }
139
+ const payload = await response.json()
140
+ const suggestions = Array.isArray(payload.suggestions) ? payload.suggestions : []
141
+ elements.contentLinkSuggestions.innerHTML = suggestions.length > 0
142
+ ? suggestions.map((item) => '<li><button type="button" data-title="' + escapeHtml(item.title) + '">[[' + escapeHtml(item.title) + ']]</button></li>').join('')
143
+ : '<li>No strong suggestions</li>'
144
+ elements.contentActionStatus.textContent = suggestions.length > 0 ? 'Suggested Context Links' : 'No strong suggestions found.'
145
+ }
146
+
147
+ const loadNodeDetails = async (nodeId) => {
148
+ if (!nodeId) {
149
+ return
150
+ }
151
+
152
+ const response = await fetch('/api/graph-node?id=' + encodeURIComponent(nodeId) + scopeQuery('&'))
153
+ if (!response.ok) {
154
+ throw new Error('Failed to load graph node details')
155
+ }
156
+
157
+ const payload = await response.json()
158
+ if (!payload || typeof payload !== 'object' || !payload.node) {
159
+ throw new Error('Invalid graph node payload')
160
+ }
161
+
162
+ const node = payload.node
163
+ state.selectedNodeId = node.id
164
+ setFocusedNodeIds(linkedNodeIds(node.id))
165
+
166
+ if (state.renderWorker && state.workerReady) {
167
+ state.renderWorker.postMessage({ type: 'select', id: node.id })
168
+ }
169
+
170
+ elements.contentTitle.textContent = node.title || 'Untitled'
171
+ elements.contentPath.textContent = node.path || ''
172
+
173
+ const tags = Array.isArray(node.tags) ? node.tags : []
174
+ elements.contentTags.innerHTML = tags.length > 0
175
+ ? tags.map((tag) => '<span>' + escapeHtml(tag) + '</span>').join('')
176
+ : '<span>No tags</span>'
177
+
178
+ const related = linkedNodes(node)
179
+ const contextLinks = nodeContextLinks(node, related.outgoing)
180
+ const facts = buildFacts(node, related.outgoing.length, related.incoming.length)
181
+ elements.contentFacts.innerHTML = listFacts(facts)
182
+ elements.contentContextLinks.innerHTML = listContextLinks(contextLinks)
183
+ elements.contentOutgoing.innerHTML = list(related.outgoing)
184
+ elements.contentIncoming.innerHTML = list(related.incoming)
185
+ elements.contentBody.textContent = typeof node.content === 'string' ? node.content : ''
186
+ elements.contentActionStatus.textContent = ''
187
+ elements.contentLinkSuggestions.innerHTML = ''
188
+
189
+ openContentDialog()
190
+ }
191
+ `;