@andespindola/brainlink 0.1.0-beta.15 → 0.1.0-beta.151

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 (43) hide show
  1. package/AGENTS.md +3 -0
  2. package/CHANGELOG.md +27 -0
  3. package/COPYRIGHT.md +5 -0
  4. package/README.md +140 -9
  5. package/dist/application/auto-migrate-configured-vault.js +37 -0
  6. package/dist/application/build-context.js +64 -3
  7. package/dist/application/dedupe-notes.js +226 -0
  8. package/dist/application/frontend/client-css.js +111 -47
  9. package/dist/application/frontend/client-html.js +42 -26
  10. package/dist/application/frontend/client-js.js +788 -554
  11. package/dist/application/frontend/client-render-worker-js.js +569 -0
  12. package/dist/application/frontend/client-worker-js.js +66 -0
  13. package/dist/application/get-graph-layout.js +38 -5
  14. package/dist/application/get-graph-stream-chunk.js +289 -0
  15. package/dist/application/get-graph-view.js +243 -0
  16. package/dist/application/import-legacy-sqlite.js +296 -0
  17. package/dist/application/index-vault.js +262 -23
  18. package/dist/application/offline-pack-backup.js +44 -0
  19. package/dist/application/server/routes.js +187 -5
  20. package/dist/application/start-server.js +75 -4
  21. package/dist/application/watch-vault.js +23 -2
  22. package/dist/cli/commands/agent-commands.js +7 -0
  23. package/dist/cli/commands/write-commands.js +849 -10
  24. package/dist/cli/runtime.js +10 -2
  25. package/dist/domain/context.js +54 -11
  26. package/dist/domain/graph-layout.js +275 -3
  27. package/dist/domain/markdown.js +22 -9
  28. package/dist/domain/middle-out.js +18 -0
  29. package/dist/infrastructure/config.js +117 -4
  30. package/dist/infrastructure/file-index.js +70 -3
  31. package/dist/infrastructure/file-system-vault.js +15 -0
  32. package/dist/infrastructure/index-state.js +58 -0
  33. package/dist/infrastructure/private-pack-codec.js +71 -10
  34. package/dist/infrastructure/search-packs.js +286 -15
  35. package/dist/infrastructure/vault-migration-state.js +69 -0
  36. package/dist/infrastructure/volatile-memory.js +100 -0
  37. package/dist/mcp/runtime.js +20 -0
  38. package/dist/mcp/server.js +29 -11
  39. package/dist/mcp/tools.js +119 -2
  40. package/docs/AGENT_USAGE.md +89 -3
  41. package/docs/ARCHITECTURE.md +6 -0
  42. package/docs/QUICKSTART.md +7 -0
  43. package/package.json +7 -2
@@ -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,68 @@ 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;
54
- }
55
-
56
- #graph:active {
57
- cursor: grabbing;
87
+ overflow: hidden;
58
88
  }
59
89
 
60
- .topbar {
90
+ #graph {
91
+ display: block;
61
92
  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;
93
+ inset: 0;
94
+ width: 100%;
95
+ height: 100%;
70
96
  }
71
97
 
72
- .topbar > div {
73
- display: flex;
74
- align-items: center;
98
+ #graph {
99
+ cursor: grab;
75
100
  }
76
101
 
77
- .topbar strong {
78
- font-size: 18px;
102
+ #graph:active {
103
+ cursor: grabbing;
79
104
  }
80
105
 
81
106
  .eyebrow {
@@ -84,13 +109,19 @@ select {
84
109
  }
85
110
 
86
111
  .search {
87
- width: min(420px, 42vw);
88
- pointer-events: auto;
112
+ flex: 1 1 320px;
113
+ min-width: 220px;
89
114
  }
90
115
 
91
116
  .agent-filter {
92
117
  width: min(220px, 28vw);
93
- pointer-events: auto;
118
+ }
119
+
120
+ .header-actions {
121
+ display: flex;
122
+ align-items: center;
123
+ gap: 10px;
124
+ margin-left: auto;
94
125
  }
95
126
 
96
127
  .search input,
@@ -111,9 +142,6 @@ select {
111
142
  }
112
143
 
113
144
  .toolbar {
114
- position: absolute;
115
- left: 18px;
116
- bottom: 18px;
117
145
  display: flex;
118
146
  gap: 8px;
119
147
  }
@@ -134,12 +162,9 @@ select {
134
162
  }
135
163
 
136
164
  .floating-metrics {
137
- position: absolute;
138
- top: 66px;
139
- left: 18px;
140
165
  display: flex;
141
166
  gap: 10px;
142
- pointer-events: none;
167
+ flex-wrap: wrap;
143
168
  }
144
169
 
145
170
  .metric-chip {
@@ -232,14 +257,20 @@ li small {
232
257
  }
233
258
 
234
259
  .content-dialog {
235
- width: min(920px, calc(100vw - 32px));
236
- max-height: calc(100svh - 32px);
260
+ position: fixed;
261
+ top: 74px;
262
+ right: 16px;
263
+ margin: 0;
264
+ width: min(760px, calc(100vw - 32px));
265
+ height: min(calc(100svh - 96px), 920px);
266
+ max-height: calc(100svh - 96px);
237
267
  padding: 0;
238
268
  border: 1px solid var(--line);
239
269
  border-radius: 8px;
240
270
  background: var(--panel);
241
271
  color: var(--text);
242
272
  box-shadow: 0 24px 80px rgba(0, 0, 0, 0.48);
273
+ overflow: hidden;
243
274
  }
244
275
 
245
276
  .content-dialog::backdrop {
@@ -250,7 +281,8 @@ li small {
250
281
  .content-dialog article {
251
282
  display: grid;
252
283
  grid-template-rows: auto auto minmax(0, 1fr);
253
- max-height: calc(100svh - 34px);
284
+ height: 100%;
285
+ max-height: 100%;
254
286
  }
255
287
 
256
288
  .content-dialog header {
@@ -269,7 +301,7 @@ li small {
269
301
 
270
302
  .content-dialog h2 {
271
303
  margin-top: 6px;
272
- font-size: 24px;
304
+ font-size: 19px;
273
305
  line-height: 1.15;
274
306
  overflow-wrap: anywhere;
275
307
  }
@@ -299,7 +331,7 @@ li small {
299
331
 
300
332
  .content-meta {
301
333
  display: grid;
302
- grid-template-columns: repeat(3, minmax(0, 1fr));
334
+ grid-template-columns: repeat(2, minmax(0, 1fr));
303
335
  gap: 10px;
304
336
  padding: 14px 22px;
305
337
  border-bottom: 1px solid var(--line);
@@ -326,45 +358,77 @@ li small {
326
358
 
327
359
  .content-meta-section ul,
328
360
  .content-meta-section .tags {
329
- max-height: 140px;
361
+ max-height: 280px;
330
362
  overflow: auto;
331
363
  align-content: flex-start;
332
364
  padding-right: 4px;
333
365
  }
334
366
 
367
+ .content-meta-section:last-child {
368
+ grid-column: span 2;
369
+ }
370
+
335
371
  .content-dialog .note-content {
336
372
  max-height: none;
337
373
  min-height: 0;
338
374
  border: 0;
339
375
  border-radius: 0;
340
- padding: 22px;
376
+ padding: 14px;
377
+ }
378
+
379
+ .app-footer {
380
+ flex: 0 0 28px;
381
+ height: 28px;
382
+ display: flex;
383
+ align-items: center;
384
+ justify-content: center;
385
+ background: transparent;
386
+ }
387
+
388
+ .app-footer small {
389
+ color: var(--muted);
390
+ font-size: 11px;
391
+ letter-spacing: 0.02em;
341
392
  }
342
393
 
343
394
  @media (max-width: 860px) {
344
- .topbar {
395
+ .graph-header {
345
396
  align-items: stretch;
346
- flex-direction: column;
397
+ flex-wrap: wrap;
398
+ padding: 10px 12px;
399
+ min-height: 0;
347
400
  }
348
401
 
349
402
  .search {
350
403
  width: 100%;
404
+ flex-basis: 100%;
405
+ order: 3;
351
406
  }
352
407
 
353
408
  .agent-filter {
354
409
  width: 100%;
355
410
  }
356
411
 
412
+ .header-actions {
413
+ width: 100%;
414
+ margin-left: 0;
415
+ justify-content: space-between;
416
+ order: 4;
417
+ }
418
+
357
419
  .content-dialog header {
358
420
  align-items: stretch;
359
421
  flex-direction: column;
360
422
  }
361
423
 
362
- .floating-metrics {
363
- top: 116px;
364
- right: 18px;
365
- left: 18px;
366
- justify-content: flex-start;
367
- flex-wrap: wrap;
424
+ .content-dialog {
425
+ top: auto;
426
+ right: 12px;
427
+ left: 12px;
428
+ bottom: 28px;
429
+ width: auto;
430
+ height: min(calc(100svh - 150px), 760px);
431
+ max-height: calc(100svh - 150px);
368
432
  }
369
433
 
370
434
  .metric-chip {
@@ -9,51 +9,67 @@ 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>
28
- </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>
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>
36
44
  </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="graph" aria-label="Brainlink knowledge graph"></canvas>
43
48
  </div>
44
49
  </section>
45
50
  </main>
51
+ <footer class="app-footer" aria-label="Copyright notice">
52
+ <small>Copyright © 2026 Substructa</small>
53
+ </footer>
46
54
  <dialog id="contentDialog" class="content-dialog" aria-labelledby="contentTitle">
47
55
  <article>
48
56
  <header>
49
57
  <div>
50
- <span class="eyebrow">Markdown content</span>
58
+ <span class="eyebrow">Node details</span>
51
59
  <h2 id="contentTitle">Selected note</h2>
52
60
  <p id="contentPath"></p>
53
61
  </div>
54
62
  <button id="contentClose" type="button">Close</button>
55
63
  </header>
56
64
  <div class="content-meta">
65
+ <section class="content-meta-section">
66
+ <h3>Facts</h3>
67
+ <ul id="contentFacts"></ul>
68
+ </section>
69
+ <section class="content-meta-section">
70
+ <h3>Context Links</h3>
71
+ <ul id="contentContextLinks"></ul>
72
+ </section>
57
73
  <section class="content-meta-section">
58
74
  <h3>Tags</h3>
59
75
  <div id="contentTags" class="tags"></div>