@andespindola/brainlink 0.1.0-beta.13 → 0.1.0-beta.130

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 +143 -20
  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 +93 -45
  11. package/dist/application/frontend/client-html.js +34 -25
  12. package/dist/application/frontend/client-js.js +2724 -182
  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 +6 -6
  26. package/dist/application/server/routes.js +105 -1
  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 +313 -17
  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 -18
  46. package/docs/ARCHITECTURE.md +22 -27
  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,8 +262,8 @@ li small {
232
262
  }
233
263
 
234
264
  .content-dialog {
235
- width: min(920px, calc(100vw - 32px));
236
- max-height: calc(100svh - 32px);
265
+ width: min(1240px, calc(100vw - 24px));
266
+ max-height: calc(100svh - 20px);
237
267
  padding: 0;
238
268
  border: 1px solid var(--line);
239
269
  border-radius: 8px;
@@ -250,7 +280,7 @@ li small {
250
280
  .content-dialog article {
251
281
  display: grid;
252
282
  grid-template-rows: auto auto minmax(0, 1fr);
253
- max-height: calc(100svh - 34px);
283
+ max-height: calc(100svh - 22px);
254
284
  }
255
285
 
256
286
  .content-dialog header {
@@ -326,7 +356,7 @@ li small {
326
356
 
327
357
  .content-meta-section ul,
328
358
  .content-meta-section .tags {
329
- max-height: 140px;
359
+ max-height: 220px;
330
360
  overflow: auto;
331
361
  align-content: flex-start;
332
362
  padding-right: 4px;
@@ -340,33 +370,51 @@ li small {
340
370
  padding: 22px;
341
371
  }
342
372
 
373
+ .app-footer {
374
+ flex: 0 0 28px;
375
+ height: 28px;
376
+ display: flex;
377
+ align-items: center;
378
+ justify-content: center;
379
+ background: transparent;
380
+ }
381
+
382
+ .app-footer small {
383
+ color: var(--muted);
384
+ font-size: 11px;
385
+ letter-spacing: 0.02em;
386
+ }
387
+
343
388
  @media (max-width: 860px) {
344
- .topbar {
389
+ .graph-header {
345
390
  align-items: stretch;
346
- flex-direction: column;
391
+ flex-wrap: wrap;
392
+ padding: 10px 12px;
393
+ min-height: 0;
347
394
  }
348
395
 
349
396
  .search {
350
397
  width: 100%;
398
+ flex-basis: 100%;
399
+ order: 3;
351
400
  }
352
401
 
353
402
  .agent-filter {
354
403
  width: 100%;
355
404
  }
356
405
 
406
+ .header-actions {
407
+ width: 100%;
408
+ margin-left: 0;
409
+ justify-content: space-between;
410
+ order: 4;
411
+ }
412
+
357
413
  .content-dialog header {
358
414
  align-items: stretch;
359
415
  flex-direction: column;
360
416
  }
361
417
 
362
- .floating-metrics {
363
- top: 116px;
364
- right: 18px;
365
- left: 18px;
366
- justify-content: flex-start;
367
- flex-wrap: wrap;
368
- }
369
-
370
418
  .metric-chip {
371
419
  min-width: 82px;
372
420
  }
@@ -9,40 +9,49 @@ 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>