@andespindola/brainlink 0.1.0-beta.14 → 0.1.0-beta.140

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 (55) hide show
  1. package/AGENTS.md +8 -5
  2. package/CHANGELOG.md +26 -2
  3. package/CONTRIBUTING.md +2 -2
  4. package/COPYRIGHT.md +5 -0
  5. package/README.md +144 -22
  6. package/SECURITY.md +1 -1
  7. package/dist/application/analyze-vault.js +1 -15
  8. package/dist/application/build-context.js +64 -3
  9. package/dist/application/dedupe-notes.js +226 -0
  10. package/dist/application/frontend/client-css.js +110 -45
  11. package/dist/application/frontend/client-html.js +35 -26
  12. package/dist/application/frontend/client-js.js +2987 -153
  13. package/dist/application/frontend/client-worker-js.js +66 -0
  14. package/dist/application/get-graph-layout.js +39 -6
  15. package/dist/application/get-graph-node.js +3 -3
  16. package/dist/application/get-graph-summary.js +3 -3
  17. package/dist/application/get-graph-view.js +243 -0
  18. package/dist/application/get-graph.js +3 -3
  19. package/dist/application/import-legacy-sqlite.js +296 -0
  20. package/dist/application/index-vault.js +253 -25
  21. package/dist/application/list-agents.js +3 -3
  22. package/dist/application/list-links.js +5 -5
  23. package/dist/application/offline-pack-backup.js +44 -0
  24. package/dist/application/search-graph-node-ids.js +3 -3
  25. package/dist/application/search-knowledge.js +4 -5
  26. package/dist/application/server/routes.js +156 -5
  27. package/dist/application/start-server.js +75 -4
  28. package/dist/application/watch-vault.js +23 -2
  29. package/dist/benchmarks/large-vault.js +1 -1
  30. package/dist/cli/commands/agent-commands.js +7 -0
  31. package/dist/cli/commands/write-commands.js +842 -8
  32. package/dist/domain/context.js +54 -11
  33. package/dist/domain/graph-layout.js +181 -3
  34. package/dist/domain/markdown.js +29 -9
  35. package/dist/domain/middle-out.js +18 -0
  36. package/dist/infrastructure/config.js +38 -0
  37. package/dist/infrastructure/file-index.js +358 -0
  38. package/dist/infrastructure/file-system-vault.js +15 -0
  39. package/dist/infrastructure/index-state.js +58 -0
  40. package/dist/infrastructure/private-pack-codec.js +71 -10
  41. package/dist/infrastructure/search-packs.js +276 -87
  42. package/dist/infrastructure/volatile-memory.js +100 -0
  43. package/dist/mcp/server.js +21 -1
  44. package/dist/mcp/tools.js +96 -0
  45. package/docs/AGENT_USAGE.md +101 -19
  46. package/docs/ARCHITECTURE.md +23 -28
  47. package/docs/QUICKSTART.md +7 -0
  48. package/package.json +6 -4
  49. package/dist/infrastructure/sqlite/document-writer.js +0 -51
  50. package/dist/infrastructure/sqlite/graph-reader.js +0 -267
  51. package/dist/infrastructure/sqlite/recovery.js +0 -163
  52. package/dist/infrastructure/sqlite/schema.js +0 -114
  53. package/dist/infrastructure/sqlite/search-reader.js +0 -188
  54. package/dist/infrastructure/sqlite/types.js +0 -1
  55. package/dist/infrastructure/sqlite-index.js +0 -38
@@ -0,0 +1,226 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { createEmbeddingBuckets, createLocalEmbedding, cosineSimilarity } from '../domain/embeddings.js';
3
+ import { parseMarkdownDocument } from '../domain/markdown.js';
4
+ import { writeMarkdownFile, ensureVault, readMarkdownFiles } from '../infrastructure/file-system-vault.js';
5
+ import { indexVault } from './index-vault.js';
6
+ const tokenPattern = /[\p{L}\p{N}_-]+/gu;
7
+ const frontmatterPattern = /^---\n[\s\S]*?\n---\n?/m;
8
+ const rootHeadingPattern = /^#\s+.+\n+/m;
9
+ const maxCandidatesPerBucket = 240;
10
+ const normalizePath = (path) => path.replaceAll('\\', '/').replace(/^\.\//, '');
11
+ const toComparableBody = (content) => content
12
+ .replace(frontmatterPattern, '')
13
+ .replace(rootHeadingPattern, '')
14
+ .replaceAll('\r\n', '\n')
15
+ .trim();
16
+ const normalizeStrictContent = (content) => toComparableBody(content);
17
+ const normalizeSemanticContent = (content) => toComparableBody(content)
18
+ .replace(/\s+/g, ' ')
19
+ .trim();
20
+ const toHash = (value) => createHash('sha256').update(value, 'utf8').digest('hex');
21
+ const toCandidateId = (leftPath, rightPath) => [normalizePath(leftPath), normalizePath(rightPath)].sort((left, right) => left.localeCompare(right)).join('|');
22
+ const hasSharedTokens = (left, right) => {
23
+ const leftTokens = new Set((left.match(tokenPattern) ?? []).map((token) => token.toLowerCase()).filter((token) => token.length > 2));
24
+ const rightTokens = new Set((right.match(tokenPattern) ?? []).map((token) => token.toLowerCase()).filter((token) => token.length > 2));
25
+ if (leftTokens.size === 0 || rightTokens.size === 0) {
26
+ return false;
27
+ }
28
+ for (const token of leftTokens) {
29
+ if (rightTokens.has(token)) {
30
+ return true;
31
+ }
32
+ }
33
+ return false;
34
+ };
35
+ const relatedMarker = (targetTitle) => `Related: [[${targetTitle}]] priority: low #related-to`;
36
+ const ensureRelatedEdgeLine = (content, targetTitle) => {
37
+ const linkPattern = new RegExp(`\\[\\[\\s*${targetTitle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*(?:[\\]|#])?`, 'i');
38
+ if (linkPattern.test(content)) {
39
+ return content;
40
+ }
41
+ const trimmed = content.trimEnd();
42
+ return `${trimmed}\n\n${relatedMarker(targetTitle)}\n`;
43
+ };
44
+ const ensureMergedMarker = (content, targetTitle) => {
45
+ const marker = `Merged into [[${targetTitle}]]`;
46
+ if (content.includes(marker)) {
47
+ return content;
48
+ }
49
+ return `${content.trimEnd()}\n\n${marker} priority: low #related-to\n`;
50
+ };
51
+ const appendMergedContent = (baseContent, mergedTitle, mergedContent) => {
52
+ const marker = `## Merged Memory From [[${mergedTitle}]]`;
53
+ if (baseContent.includes(marker)) {
54
+ return baseContent;
55
+ }
56
+ const mergedBody = normalizeSemanticContent(mergedContent);
57
+ return `${baseContent.trimEnd()}\n\n${marker}\n\n${mergedBody}\n`;
58
+ };
59
+ const loadNoteRecords = async (vaultPath, agentId) => {
60
+ const absoluteVaultPath = await ensureVault(vaultPath);
61
+ const files = await readMarkdownFiles(vaultPath);
62
+ return files
63
+ .map((file) => {
64
+ const parsed = parseMarkdownDocument({
65
+ absolutePath: file.absolutePath,
66
+ vaultPath: absoluteVaultPath,
67
+ content: file.content,
68
+ createdAt: file.createdAt,
69
+ updatedAt: file.updatedAt
70
+ });
71
+ const strict = normalizeStrictContent(parsed.content);
72
+ const semantic = normalizeSemanticContent(parsed.content);
73
+ const embedding = createLocalEmbedding(`${parsed.title}\n${semantic}`);
74
+ return {
75
+ title: parsed.title,
76
+ path: normalizePath(parsed.path),
77
+ agentId: parsed.agentId,
78
+ content: parsed.content,
79
+ normalizedStrictContent: strict,
80
+ semanticContent: semantic,
81
+ embedding,
82
+ buckets: createEmbeddingBuckets(embedding, 20)
83
+ };
84
+ })
85
+ .filter((record) => (agentId ? record.agentId === agentId : true));
86
+ };
87
+ const pairToCandidate = (left, right, kind, score, reason) => ({
88
+ id: toCandidateId(left.path, right.path),
89
+ possibleDuplicate: true,
90
+ kind,
91
+ score: Number(score.toFixed(4)),
92
+ left: {
93
+ title: left.title,
94
+ path: left.path,
95
+ agentId: left.agentId
96
+ },
97
+ right: {
98
+ title: right.title,
99
+ path: right.path,
100
+ agentId: right.agentId
101
+ },
102
+ reason
103
+ });
104
+ const indexCandidatePairs = (notes) => {
105
+ const bucketMap = new Map();
106
+ notes.forEach((note, index) => {
107
+ note.buckets.forEach((bucket) => {
108
+ const current = bucketMap.get(bucket) ?? [];
109
+ if (current.length < maxCandidatesPerBucket) {
110
+ current.push(index);
111
+ bucketMap.set(bucket, current);
112
+ }
113
+ });
114
+ });
115
+ const pairKeys = new Set();
116
+ const pairs = [];
117
+ bucketMap.forEach((indexes) => {
118
+ for (let leftIndex = 0; leftIndex < indexes.length; leftIndex += 1) {
119
+ for (let rightIndex = leftIndex + 1; rightIndex < indexes.length; rightIndex += 1) {
120
+ const left = Math.min(indexes[leftIndex] ?? 0, indexes[rightIndex] ?? 0);
121
+ const right = Math.max(indexes[leftIndex] ?? 0, indexes[rightIndex] ?? 0);
122
+ const key = `${left}|${right}`;
123
+ if (!pairKeys.has(key)) {
124
+ pairKeys.add(key);
125
+ pairs.push([left, right]);
126
+ }
127
+ }
128
+ }
129
+ });
130
+ return pairs;
131
+ };
132
+ export const scanDuplicateNotes = async (vaultPath, options = {}) => {
133
+ const notes = await loadNoteRecords(vaultPath, options.agentId);
134
+ if (notes.length < 2) {
135
+ return [];
136
+ }
137
+ const minSemanticScore = options.minSemanticScore ?? 0.92;
138
+ const includeSemantic = options.includeSemantic !== false;
139
+ const seen = new Map();
140
+ const byHash = notes.reduce((state, note) => {
141
+ const key = toHash(note.normalizedStrictContent);
142
+ const current = state.get(key) ?? [];
143
+ current.push(note);
144
+ state.set(key, current);
145
+ return state;
146
+ }, new Map());
147
+ byHash.forEach((group) => {
148
+ if (group.length < 2) {
149
+ return;
150
+ }
151
+ const [base, ...rest] = group.sort((left, right) => left.path.localeCompare(right.path));
152
+ rest.forEach((note) => {
153
+ const candidate = pairToCandidate(base, note, 'exact', 1, 'Exact content hash match');
154
+ seen.set(candidate.id, candidate);
155
+ });
156
+ });
157
+ if (includeSemantic) {
158
+ const pairs = indexCandidatePairs(notes);
159
+ pairs.forEach(([leftIndex, rightIndex]) => {
160
+ const left = notes[leftIndex];
161
+ const right = notes[rightIndex];
162
+ if (!left || !right || left.path === right.path) {
163
+ return;
164
+ }
165
+ const id = toCandidateId(left.path, right.path);
166
+ if (seen.has(id)) {
167
+ return;
168
+ }
169
+ const score = cosineSimilarity(left.embedding, right.embedding);
170
+ const titleShared = hasSharedTokens(left.title, right.title);
171
+ const contentShared = hasSharedTokens(left.semanticContent, right.semanticContent);
172
+ if (score >= minSemanticScore && (titleShared || contentShared || score >= 0.975)) {
173
+ const candidate = pairToCandidate(left, right, 'semantic', score, 'High semantic similarity');
174
+ seen.set(id, candidate);
175
+ }
176
+ });
177
+ }
178
+ const focusPath = options.focusPath ? normalizePath(options.focusPath) : undefined;
179
+ const limited = Array.from(seen.values())
180
+ .filter((item) => (focusPath ? item.left.path === focusPath || item.right.path === focusPath : true))
181
+ .sort((left, right) => right.score - left.score || left.left.path.localeCompare(right.left.path))
182
+ .slice(0, Math.max(1, options.limit ?? 25));
183
+ return limited;
184
+ };
185
+ export const resolveDuplicateNotes = async (vaultPath, options) => {
186
+ const leftPath = normalizePath(options.leftPath);
187
+ const rightPath = normalizePath(options.rightPath);
188
+ if (leftPath === rightPath) {
189
+ throw new Error('leftPath and rightPath must be different notes.');
190
+ }
191
+ const notes = await loadNoteRecords(vaultPath);
192
+ const byPath = new Map(notes.map((note) => [note.path, note]));
193
+ const left = byPath.get(leftPath);
194
+ const right = byPath.get(rightPath);
195
+ if (!left || !right) {
196
+ throw new Error(`Duplicate resolution paths were not found in vault index source: ${leftPath}, ${rightPath}`);
197
+ }
198
+ const updates = new Map();
199
+ const leftRelated = ensureRelatedEdgeLine(left.content, right.title);
200
+ const rightRelated = ensureRelatedEdgeLine(right.content, left.title);
201
+ if (options.action === 'link') {
202
+ updates.set(left.path, leftRelated);
203
+ updates.set(right.path, rightRelated);
204
+ }
205
+ else if (options.action === 'ignore') {
206
+ updates.set(left.path, leftRelated);
207
+ }
208
+ else {
209
+ const mergedLeft = appendMergedContent(leftRelated, right.title, right.content);
210
+ const mergedRight = ensureMergedMarker(rightRelated, left.title);
211
+ updates.set(left.path, mergedLeft);
212
+ updates.set(right.path, mergedRight);
213
+ }
214
+ for (const [path, content] of updates) {
215
+ await writeMarkdownFile(vaultPath, path, content);
216
+ }
217
+ const shouldIndex = options.autoIndex !== false;
218
+ const index = shouldIndex ? await indexVault(vaultPath) : undefined;
219
+ return {
220
+ action: options.action,
221
+ leftPath,
222
+ rightPath,
223
+ updatedPaths: Array.from(updates.keys()).sort((leftValue, rightValue) => leftValue.localeCompare(rightValue)),
224
+ ...(index ? { index } : {})
225
+ };
226
+ };
@@ -25,6 +25,13 @@ body {
25
25
  font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
26
26
  }
27
27
 
28
+ body {
29
+ display: flex;
30
+ flex-direction: column;
31
+ min-height: 100vh;
32
+ min-height: 100dvh;
33
+ }
34
+
28
35
  button,
29
36
  input,
30
37
  select {
@@ -32,50 +39,73 @@ select {
32
39
  }
33
40
 
34
41
  .shell {
42
+ flex: 1 1 auto;
35
43
  width: 100%;
36
- height: 100svh;
44
+ min-height: 0;
37
45
  overflow: hidden;
38
46
  }
39
47
 
40
48
  .workspace {
49
+ display: grid;
50
+ grid-template-rows: auto minmax(0, 1fr);
41
51
  position: relative;
52
+ width: 100%;
53
+ height: 100%;
42
54
  min-width: 0;
43
55
  min-height: 0;
44
56
  }
45
57
 
46
- #graph {
47
- display: block;
58
+ .graph-header {
59
+ z-index: 5;
60
+ display: flex;
61
+ align-items: center;
62
+ gap: 12px;
63
+ min-height: 72px;
64
+ padding: 10px 16px;
65
+ border-bottom: 1px solid var(--line);
66
+ background: linear-gradient(180deg, rgba(17, 21, 27, 0.96) 0%, rgba(17, 21, 27, 0.86) 100%);
67
+ backdrop-filter: blur(8px);
68
+ }
69
+
70
+ .brand-block {
71
+ display: grid;
72
+ gap: 2px;
73
+ min-width: max-content;
74
+ }
75
+
76
+ .brand-block strong {
77
+ font-size: 18px;
78
+ }
79
+
80
+ .graph-stage {
81
+ position: relative;
48
82
  width: 100%;
49
83
  height: 100%;
50
84
  background:
51
85
  radial-gradient(circle at 18% 20%, rgba(53, 208, 162, 0.12), transparent 28rem),
52
86
  linear-gradient(135deg, #0d0f12 0%, #12161c 55%, #0a0d10 100%);
53
- cursor: grab;
87
+ overflow: hidden;
54
88
  }
55
89
 
56
- #graph:active {
57
- cursor: grabbing;
90
+ #graph,
91
+ #graphGl {
92
+ display: block;
93
+ position: absolute;
94
+ inset: 0;
95
+ width: 100%;
96
+ height: 100%;
58
97
  }
59
98
 
60
- .topbar {
61
- position: absolute;
62
- top: 18px;
63
- left: 18px;
64
- right: 18px;
65
- display: flex;
66
- align-items: center;
67
- justify-content: space-between;
68
- gap: 18px;
69
- pointer-events: none;
99
+ #graph {
100
+ cursor: grab;
70
101
  }
71
102
 
72
- .topbar > div {
73
- display: flex;
74
- align-items: center;
103
+ #graphGl {
104
+ pointer-events: none;
75
105
  }
76
106
 
77
- .topbar strong {
78
- font-size: 18px;
107
+ #graph:active {
108
+ cursor: grabbing;
79
109
  }
80
110
 
81
111
  .eyebrow {
@@ -84,13 +114,19 @@ select {
84
114
  }
85
115
 
86
116
  .search {
87
- width: min(420px, 42vw);
88
- pointer-events: auto;
117
+ flex: 1 1 320px;
118
+ min-width: 220px;
89
119
  }
90
120
 
91
121
  .agent-filter {
92
122
  width: min(220px, 28vw);
93
- pointer-events: auto;
123
+ }
124
+
125
+ .header-actions {
126
+ display: flex;
127
+ align-items: center;
128
+ gap: 10px;
129
+ margin-left: auto;
94
130
  }
95
131
 
96
132
  .search input,
@@ -111,9 +147,6 @@ select {
111
147
  }
112
148
 
113
149
  .toolbar {
114
- position: absolute;
115
- left: 18px;
116
- bottom: 18px;
117
150
  display: flex;
118
151
  gap: 8px;
119
152
  }
@@ -134,12 +167,9 @@ select {
134
167
  }
135
168
 
136
169
  .floating-metrics {
137
- position: absolute;
138
- top: 66px;
139
- left: 18px;
140
170
  display: flex;
141
171
  gap: 10px;
142
- pointer-events: none;
172
+ flex-wrap: wrap;
143
173
  }
144
174
 
145
175
  .metric-chip {
@@ -232,14 +262,20 @@ li small {
232
262
  }
233
263
 
234
264
  .content-dialog {
235
- width: min(920px, calc(100vw - 32px));
236
- max-height: calc(100svh - 32px);
265
+ position: fixed;
266
+ top: 84px;
267
+ right: 12px;
268
+ margin: 0;
269
+ width: min(440px, calc(100vw - 24px));
270
+ height: min(calc(100svh - 124px), 820px);
271
+ max-height: calc(100svh - 124px);
237
272
  padding: 0;
238
273
  border: 1px solid var(--line);
239
274
  border-radius: 8px;
240
275
  background: var(--panel);
241
276
  color: var(--text);
242
277
  box-shadow: 0 24px 80px rgba(0, 0, 0, 0.48);
278
+ overflow: hidden;
243
279
  }
244
280
 
245
281
  .content-dialog::backdrop {
@@ -250,7 +286,8 @@ li small {
250
286
  .content-dialog article {
251
287
  display: grid;
252
288
  grid-template-rows: auto auto minmax(0, 1fr);
253
- max-height: calc(100svh - 34px);
289
+ height: 100%;
290
+ max-height: 100%;
254
291
  }
255
292
 
256
293
  .content-dialog header {
@@ -269,7 +306,7 @@ li small {
269
306
 
270
307
  .content-dialog h2 {
271
308
  margin-top: 6px;
272
- font-size: 24px;
309
+ font-size: 19px;
273
310
  line-height: 1.15;
274
311
  overflow-wrap: anywhere;
275
312
  }
@@ -326,7 +363,7 @@ li small {
326
363
 
327
364
  .content-meta-section ul,
328
365
  .content-meta-section .tags {
329
- max-height: 140px;
366
+ max-height: 220px;
330
367
  overflow: auto;
331
368
  align-content: flex-start;
332
369
  padding-right: 4px;
@@ -337,34 +374,62 @@ li small {
337
374
  min-height: 0;
338
375
  border: 0;
339
376
  border-radius: 0;
340
- padding: 22px;
377
+ padding: 14px;
378
+ }
379
+
380
+ .app-footer {
381
+ flex: 0 0 28px;
382
+ height: 28px;
383
+ display: flex;
384
+ align-items: center;
385
+ justify-content: center;
386
+ background: transparent;
387
+ }
388
+
389
+ .app-footer small {
390
+ color: var(--muted);
391
+ font-size: 11px;
392
+ letter-spacing: 0.02em;
341
393
  }
342
394
 
343
395
  @media (max-width: 860px) {
344
- .topbar {
396
+ .graph-header {
345
397
  align-items: stretch;
346
- flex-direction: column;
398
+ flex-wrap: wrap;
399
+ padding: 10px 12px;
400
+ min-height: 0;
347
401
  }
348
402
 
349
403
  .search {
350
404
  width: 100%;
405
+ flex-basis: 100%;
406
+ order: 3;
351
407
  }
352
408
 
353
409
  .agent-filter {
354
410
  width: 100%;
355
411
  }
356
412
 
413
+ .header-actions {
414
+ width: 100%;
415
+ margin-left: 0;
416
+ justify-content: space-between;
417
+ order: 4;
418
+ }
419
+
357
420
  .content-dialog header {
358
421
  align-items: stretch;
359
422
  flex-direction: column;
360
423
  }
361
424
 
362
- .floating-metrics {
363
- top: 116px;
364
- right: 18px;
365
- left: 18px;
366
- justify-content: flex-start;
367
- flex-wrap: wrap;
425
+ .content-dialog {
426
+ top: auto;
427
+ right: 12px;
428
+ left: 12px;
429
+ bottom: 38px;
430
+ width: auto;
431
+ height: min(calc(100svh - 170px), 640px);
432
+ max-height: calc(100svh - 170px);
368
433
  }
369
434
 
370
435
  .metric-chip {
@@ -9,45 +9,54 @@ export const createClientHtml = () => `<!doctype html>
9
9
  <body>
10
10
  <main class="shell">
11
11
  <section class="workspace" aria-label="Knowledge graph">
12
- <canvas id="graph" aria-label="Brainlink knowledge graph"></canvas>
13
- <div class="topbar">
14
- <div>
12
+ <header class="graph-header" aria-label="Graph actions">
13
+ <div class="brand-block">
15
14
  <strong>Brainlink</strong>
15
+ <span class="eyebrow">Knowledge Graph</span>
16
+ </div>
17
+ <div class="floating-metrics" aria-label="Graph totals">
18
+ <div class="metric-chip">
19
+ <strong id="nodeCount">0</strong>
20
+ <small>Notes</small>
21
+ </div>
22
+ <div class="metric-chip">
23
+ <strong id="edgeCount">0</strong>
24
+ <small>Links</small>
25
+ </div>
26
+ <div class="metric-chip">
27
+ <strong id="tagCount">0</strong>
28
+ <small>Tags</small>
29
+ </div>
16
30
  </div>
17
31
  <label class="search">
18
32
  <input id="search" type="search" placeholder="Filter notes, tags or paths" autocomplete="off" />
19
33
  </label>
20
- <label class="agent-filter">
21
- <select id="agent"></select>
22
- </label>
23
- </div>
24
- <div class="floating-metrics" aria-label="Graph totals">
25
- <div class="metric-chip">
26
- <strong id="nodeCount">0</strong>
27
- <small>Notes</small>
34
+ <div class="header-actions">
35
+ <label class="agent-filter">
36
+ <select id="agent"></select>
37
+ </label>
38
+ <div class="toolbar" aria-label="Graph controls">
39
+ <button id="zoomIn" type="button" title="Zoom in">+</button>
40
+ <button id="zoomOut" type="button" title="Zoom out">-</button>
41
+ <button id="fit" type="button" title="Focus central hub">◎</button>
42
+ <button id="reset" type="button" title="Reset view">⌂</button>
43
+ </div>
28
44
  </div>
29
- <div class="metric-chip">
30
- <strong id="edgeCount">0</strong>
31
- <small>Links</small>
32
- </div>
33
- <div class="metric-chip">
34
- <strong id="tagCount">0</strong>
35
- <small>Tags</small>
36
- </div>
37
- </div>
38
- <div class="toolbar" aria-label="Graph controls">
39
- <button id="zoomIn" type="button" title="Zoom in">+</button>
40
- <button id="zoomOut" type="button" title="Zoom out">-</button>
41
- <button id="fit" type="button" title="Fit visible nodes">◎</button>
42
- <button id="reset" type="button" title="Reset view">⌂</button>
45
+ </header>
46
+ <div class="graph-stage">
47
+ <canvas id="graphGl" aria-hidden="true"></canvas>
48
+ <canvas id="graph" aria-label="Brainlink knowledge graph"></canvas>
43
49
  </div>
44
50
  </section>
45
51
  </main>
52
+ <footer class="app-footer" aria-label="Copyright notice">
53
+ <small>Copyright © 2026 Substructa</small>
54
+ </footer>
46
55
  <dialog id="contentDialog" class="content-dialog" aria-labelledby="contentTitle">
47
56
  <article>
48
57
  <header>
49
58
  <div>
50
- <span class="eyebrow">Markdown content</span>
59
+ <span class="eyebrow">Node details</span>
51
60
  <h2 id="contentTitle">Selected note</h2>
52
61
  <p id="contentPath"></p>
53
62
  </div>