@andespindola/brainlink 0.1.0-beta.153 → 0.1.0-beta.155

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.
package/AGENTS.md CHANGED
@@ -78,10 +78,10 @@ http://127.0.0.1:4321/
78
78
  http://127.0.0.1:4321/api/graph
79
79
  ```
80
80
 
81
- Use watch mode while editing notes:
81
+ The graph server watches Markdown files by default while editing notes:
82
82
 
83
83
  ```bash
84
- npm run dev -- server --vault ./vault --watch
84
+ npm run dev -- server --vault ./vault
85
85
  npm run dev -- watch --vault ./vault
86
86
  npm run dev -- bench --vault ./vault
87
87
  npm run dev -- bench --vault ./vault --watch
package/README.md CHANGED
@@ -265,7 +265,7 @@ blink search "jwt auth" --vault ./vault
265
265
 
266
266
  blink context "how does auth work?" --vault ./vault
267
267
 
268
- blink server --vault ./vault --watch
268
+ blink server --vault ./vault
269
269
  ```
270
270
 
271
271
  Open the graph UI:
@@ -536,6 +536,7 @@ Available tools:
536
536
  - `brainlink_resolve_duplicate`: resolve duplicate pairs (`merge`, `link`, `ignore`) with connectivity-safe fallback edges.
537
537
  - `brainlink_add_note`: write durable Markdown memory and reindex.
538
538
  - `brainlink_add_file`: ingest a local file as a note and reindex.
539
+ - `brainlink_canonicalize_context_links`: ensure existing notes link to inferred context hubs.
539
540
  - `brainlink_volatile_add`: write temporary agent-decided memory with TTL; volatile sections are included in context and never create durable graph edges.
540
541
  - `brainlink_volatile_clear`: clear temporary memory for the current vault/agent namespace.
541
542
  - `brainlink_index`: rebuild the vault index. Pass `full=true` for a complete source reindex.
@@ -543,6 +544,7 @@ Available tools:
543
544
  - `brainlink_validate`: validate broken links and orphan notes.
544
545
  - `brainlink_sync`: run index, stats, validation, broken-link and orphan checks in one call.
545
546
  - `brainlink_graph`: read indexed graph nodes and weighted links.
547
+ - `brainlink_graph_contexts`: list the visual graph contexts used by the local server.
546
548
  - `brainlink_broken_links`: list unresolved wiki links.
547
549
  - `brainlink_orphans`: list disconnected notes.
548
550
 
@@ -577,10 +579,11 @@ blink index --vault ./vault --full
577
579
  Start the local frontend:
578
580
 
579
581
  ```bash
580
- blink server --host 127.0.0.1 --port 4321 --watch
582
+ blink server --host 127.0.0.1 --port 4321
581
583
  ```
582
584
 
583
585
  By default, the server uses `$HOME/.brainlink/vault`. Pass `--vault ./vault` only when you want to inspect a custom vault.
586
+ By default, the server watches Markdown files in local filesystem vaults and reindexes after note changes. Use `--no-watch` to disable realtime reindexing.
584
587
  By default, `blink server` tries to open the graph in a native desktop GUI window:
585
588
  - macOS: Swift + WebKit
586
589
  - Windows: PowerShell WinForms WebBrowser
@@ -604,7 +607,7 @@ The graph UI shows:
604
607
  - context selector for segment-scoped star subgraphs derived from the visual graph context
605
608
  - graph filter matches title, path, tags and note content
606
609
  - graph filter keeps hub context nodes visible (`Memory Hub`/`MOC`/high-degree fallback) to preserve relationship readability
607
- - realtime refresh while `--watch` is enabled
610
+ - realtime refresh while watch mode is enabled
608
611
  - graph controls for zoom in, zoom out, fit visible nodes and reset-to-fit-all
609
612
  - wheel zoom (including `cmd+scroll` and `ctrl+scroll`) anchored to cursor position for faster navigation in large graphs
610
613
  - wheel/button zoom updates immediately at the cursor anchor without delayed focus-transition interpolation
@@ -944,15 +947,26 @@ blink watch --vault ./vault
944
947
 
945
948
  Watches Markdown files and rebuilds the index when notes change.
946
949
 
950
+ ### `canonicalize-context-links`
951
+
952
+ ```bash
953
+ blink canonicalize-context-links --vault ./vault --dry-run
954
+ blink canonicalize-context-links --vault ./vault
955
+ ```
956
+
957
+ Ensures existing notes have canonical `## Context Links` entries to inferred context hubs such as `User Preferences Hub`, `GitHub Repositories Hub` or `Brainlink Hub`. The command is idempotent, creates missing hub notes by default, and fully reindexes after writes unless `--no-index` is passed.
958
+
947
959
  ### `server`
948
960
 
949
961
  ```bash
950
- blink server --watch
951
- blink server --vault ./vault --watch
952
- blink server --vault ./vault --watch --no-open
962
+ blink server
963
+ blink server --vault ./vault
964
+ blink server --vault ./vault --no-open
965
+ blink server --vault ./vault --no-watch
953
966
  ```
954
967
 
955
968
  Starts the local read-only graph UI and HTTP API.
969
+ Watch mode is enabled by default for Markdown changes in local filesystem vaults. Use `--no-watch` to run without the watcher.
956
970
  By default, it tries to open a native desktop GUI window for the graph URL.
957
971
  On Linux, native GUI is disabled by default; enable it with `BRAINLINK_LINUX_NATIVE_GUI=1`.
958
972
  If native GUI launch is unavailable, it falls back to dedicated app-window mode and then browser open.
@@ -993,6 +1007,7 @@ If no `vault` is configured and no `--vault` flag is passed, Brainlink uses `$HO
993
1007
  "allowedVaults": [".brainlink-vault"],
994
1008
  "defaultAgent": "shared",
995
1009
  "autoIndexOnWrite": true,
1010
+ "autoCanonicalContextLinks": true,
996
1011
  "defaultSearchLimit": 10,
997
1012
  "defaultContextTokens": 2000,
998
1013
  "embeddingProvider": "local",
@@ -1022,6 +1037,7 @@ If no `vault` is configured and no `--vault` flag is passed, Brainlink uses `$HO
1022
1037
  `agentProfiles` is optional. When present, CLI and MCP resolve `mode`, `limit` and `tokens` per agent automatically, then fallback to global defaults.
1023
1038
 
1024
1039
  `autoIndexOnWrite` is optional and defaults to `true`. Set it to `false` to defer indexing after writes.
1040
+ `autoCanonicalContextLinks` is optional and defaults to `true`. When enabled, `blink add`, `brainlink_add_note` and `brainlink_add_file` add a canonical `## Context Links` entry to the inferred context hub, creating that hub when needed.
1025
1041
 
1026
1042
  Use `"embeddingProvider": "none"` when you want FTS-only indexing.
1027
1043
 
@@ -1100,7 +1116,7 @@ Local CLI:
1100
1116
 
1101
1117
  ```bash
1102
1118
  npm run dev -- --help
1103
- npm run dev -- server --vault .brainlink-vault --watch
1119
+ npm run dev -- server --vault .brainlink-vault
1104
1120
  ```
1105
1121
 
1106
1122
  Package smoke test:
@@ -2,6 +2,7 @@ import { writeMarkdownFile } from '../infrastructure/file-system-vault.js';
2
2
  import { sanitizeAgentId, sharedAgentId } from '../domain/agents.js';
3
3
  import { validateNoteInput } from '../domain/note-safety.js';
4
4
  import { ensureVault } from '../infrastructure/file-system-vault.js';
5
+ import { addCanonicalContextLinkToContent, ensureCanonicalContextHub } from './canonical-context-links.js';
5
6
  const slugify = (title) => title
6
7
  .normalize('NFKD')
7
8
  .replace(/[\u0300-\u036f]/g, '')
@@ -28,12 +29,20 @@ export const addNoteWithMetadata = async (vaultPath, title, content, agentId = s
28
29
  const sanitizedAgentId = sanitizeAgentId(agentId);
29
30
  const filename = `agents/${sanitizedAgentId}/${slugify(title) || 'untitled'}.md`;
30
31
  await ensureVault(vaultPath);
31
- const note = buildNote(title, content.trim(), sanitizedAgentId);
32
+ const canonical = options.autoContextLinks === false
33
+ ? null
34
+ : addCanonicalContextLinkToContent(title, content.trim());
35
+ const hub = canonical?.changed
36
+ ? await ensureCanonicalContextHub(vaultPath, canonical.context, sanitizedAgentId)
37
+ : null;
38
+ const note = buildNote(title, canonical?.content ?? content.trim(), sanitizedAgentId);
32
39
  const path = await writeMarkdownFile(vaultPath, filename, note);
33
40
  return {
34
41
  path,
35
- autoLinked: false,
36
- linkTarget: null
42
+ autoLinked: canonical?.changed ?? false,
43
+ linkTarget: canonical?.changed ? canonical.hubTitle : null,
44
+ context: canonical?.context ?? null,
45
+ hubCreated: hub?.created ?? false
37
46
  };
38
47
  };
39
48
  export const addNote = async (vaultPath, title, content, agentId = sharedAgentId, options = {}) => (await addNoteWithMetadata(vaultPath, title, content, agentId, options)).path;
@@ -0,0 +1,209 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { inferVisualGraphContext } from '../domain/graph-contexts.js';
4
+ import { sanitizeAgentId, sharedAgentId } from '../domain/agents.js';
5
+ import { extractContextLinkWeights, parseMarkdownDocument } from '../domain/markdown.js';
6
+ import { ensureVault, readMarkdownFileSummaries, writeMarkdownFile } from '../infrastructure/file-system-vault.js';
7
+ const canonicalPriority = 'high';
8
+ const slugify = (title) => title
9
+ .normalize('NFKD')
10
+ .replace(/[\u0300-\u036f]/g, '')
11
+ .toLowerCase()
12
+ .replace(/[^a-z0-9]+/g, '-')
13
+ .replace(/^-+|-+$/g, '');
14
+ export const hubTitleForContext = (contextTitle) => `${contextTitle} Hub`;
15
+ const hubPathForContext = (contextTitle, agentId) => {
16
+ if (contextTitle === 'GitHub Repositories')
17
+ return 'github-repos/github-repositories-hub.md';
18
+ if (contextTitle === 'GitHub Organizations')
19
+ return 'github-org-repos/github-organizations-hub.md';
20
+ if (contextTitle === 'Machine Configuration')
21
+ return 'machine-config/machine-configuration-hub.md';
22
+ return join('agents', sanitizeAgentId(agentId), `${slugify(hubTitleForContext(contextTitle))}.md`).replaceAll('\\', '/');
23
+ };
24
+ const normalizeTitle = (title) => title.trim().replace(/\.md$/i, '').toLowerCase();
25
+ const hasCanonicalLink = (content, hubTitle) => extractContextLinkWeights(content).some((link) => normalizeTitle(link.title) === normalizeTitle(hubTitle));
26
+ const linkLine = (hubTitle) => `- [[${hubTitle}]] priority: ${canonicalPriority}`;
27
+ const contextLinksHeading = (line) => line.match(/^(#{2,6})\s+(Context Links|Links de Contexto)\s*$/i);
28
+ export const upsertCanonicalContextLink = (content, hubTitle) => {
29
+ if (hasCanonicalLink(content, hubTitle)) {
30
+ return content;
31
+ }
32
+ const lines = content.replace(/\s+$/u, '').split('\n');
33
+ const headingIndex = lines.findIndex((line) => contextLinksHeading(line.trim()));
34
+ if (headingIndex === -1) {
35
+ return `${lines.join('\n')}\n\n## Context Links\n\n${linkLine(hubTitle)}\n`;
36
+ }
37
+ const heading = contextLinksHeading(lines[headingIndex].trim());
38
+ const headingDepth = heading?.[1]?.length ?? 2;
39
+ const insertIndex = lines.findIndex((line, index) => {
40
+ if (index <= headingIndex)
41
+ return false;
42
+ const candidate = line.match(/^(#{2,6})\s+/);
43
+ return Boolean(candidate && candidate[1].length <= headingDepth);
44
+ });
45
+ const targetIndex = insertIndex === -1 ? lines.length : insertIndex;
46
+ const before = lines.slice(0, targetIndex);
47
+ const after = lines.slice(targetIndex);
48
+ const needsSpacer = before[before.length - 1]?.trim() !== '';
49
+ const nextLines = [...before, ...(needsSpacer ? [''] : []), linkLine(hubTitle), ...after];
50
+ return `${nextLines.join('\n').replace(/\s+$/u, '')}\n`;
51
+ };
52
+ const buildHubContent = (hubTitle, contextTitle, agentId) => [
53
+ '---',
54
+ `title: "${hubTitle.replaceAll('"', '\\"')}"`,
55
+ `agent: "${sanitizeAgentId(agentId)}"`,
56
+ '---',
57
+ '',
58
+ `# ${hubTitle}`,
59
+ '',
60
+ `Canonical hub for the ${contextTitle} context. #memory #hub`,
61
+ ''
62
+ ].join('\n');
63
+ const readNotes = async (vaultPath) => {
64
+ const absoluteVaultPath = await ensureVault(vaultPath);
65
+ const summaries = await readMarkdownFileSummaries(absoluteVaultPath);
66
+ return Promise.all(summaries.map(async (summary) => {
67
+ const content = await readFile(summary.absolutePath, 'utf8');
68
+ const document = parseMarkdownDocument({
69
+ absolutePath: summary.absolutePath,
70
+ vaultPath: absoluteVaultPath,
71
+ content,
72
+ createdAt: summary.createdAt,
73
+ updatedAt: summary.updatedAt
74
+ });
75
+ return {
76
+ summary,
77
+ content,
78
+ document
79
+ };
80
+ }));
81
+ };
82
+ export const ensureCanonicalContextHub = async (vaultPath, contextTitle, agentId = sharedAgentId) => {
83
+ const hubTitle = hubTitleForContext(contextTitle);
84
+ const notes = await readNotes(vaultPath);
85
+ const existing = notes.find((note) => normalizeTitle(note.document.title) === normalizeTitle(hubTitle));
86
+ const hubPath = existing?.summary.relativePath ?? hubPathForContext(contextTitle, agentId);
87
+ if (existing) {
88
+ return {
89
+ created: false,
90
+ title: hubTitle,
91
+ path: hubPath
92
+ };
93
+ }
94
+ const path = await writeMarkdownFile(vaultPath, hubPath, buildHubContent(hubTitle, contextTitle, agentId));
95
+ return {
96
+ created: true,
97
+ title: hubTitle,
98
+ path
99
+ };
100
+ };
101
+ export const canonicalizeContextLinks = async (vaultPath, options = {}) => {
102
+ const agentId = options.agentId ? sanitizeAgentId(options.agentId) : undefined;
103
+ const createMissingHubs = options.createMissingHubs !== false;
104
+ const notes = await readNotes(vaultPath);
105
+ const scopedNotes = agentId ? notes.filter((note) => note.document.agentId === agentId) : notes;
106
+ const knownTitles = new Set(notes.map((note) => normalizeTitle(note.document.title)));
107
+ const entries = [];
108
+ const ensureHub = async (contextTitle, hubTitle, targetAgentId) => {
109
+ if (knownTitles.has(normalizeTitle(hubTitle))) {
110
+ return true;
111
+ }
112
+ const path = hubPathForContext(contextTitle, targetAgentId);
113
+ if (!createMissingHubs) {
114
+ entries.push({
115
+ path,
116
+ title: hubTitle,
117
+ context: contextTitle,
118
+ hubTitle,
119
+ changed: false,
120
+ reason: 'missing-hub'
121
+ });
122
+ return false;
123
+ }
124
+ knownTitles.add(normalizeTitle(hubTitle));
125
+ if (!options.dryRun) {
126
+ await writeMarkdownFile(vaultPath, path, buildHubContent(hubTitle, contextTitle, targetAgentId));
127
+ }
128
+ entries.push({
129
+ path,
130
+ title: hubTitle,
131
+ context: contextTitle,
132
+ hubTitle,
133
+ changed: true,
134
+ reason: 'created-hub'
135
+ });
136
+ return true;
137
+ };
138
+ for (const note of scopedNotes) {
139
+ const context = inferVisualGraphContext(note.document);
140
+ const hubTitle = hubTitleForContext(context.title);
141
+ const isHub = normalizeTitle(note.document.title) === normalizeTitle(hubTitle);
142
+ if (isHub) {
143
+ entries.push({
144
+ path: note.summary.relativePath,
145
+ title: note.document.title,
146
+ context: context.title,
147
+ hubTitle,
148
+ changed: false,
149
+ reason: 'hub-note'
150
+ });
151
+ continue;
152
+ }
153
+ const hubAvailable = await ensureHub(context.title, hubTitle, note.document.agentId || sharedAgentId);
154
+ if (!hubAvailable) {
155
+ continue;
156
+ }
157
+ if (hasCanonicalLink(note.content, hubTitle)) {
158
+ entries.push({
159
+ path: note.summary.relativePath,
160
+ title: note.document.title,
161
+ context: context.title,
162
+ hubTitle,
163
+ changed: false,
164
+ reason: 'already-linked'
165
+ });
166
+ continue;
167
+ }
168
+ const nextContent = upsertCanonicalContextLink(note.content, hubTitle);
169
+ if (!options.dryRun) {
170
+ await writeMarkdownFile(vaultPath, note.summary.relativePath, nextContent);
171
+ }
172
+ entries.push({
173
+ path: note.summary.relativePath,
174
+ title: note.document.title,
175
+ context: context.title,
176
+ hubTitle,
177
+ changed: true,
178
+ reason: 'added-context-link'
179
+ });
180
+ }
181
+ const changed = entries.filter((entry) => entry.changed).length;
182
+ const createdHubs = entries.filter((entry) => entry.reason === 'created-hub' && entry.changed).length;
183
+ return {
184
+ dryRun: options.dryRun === true,
185
+ scanned: scopedNotes.length,
186
+ changed,
187
+ createdHubs,
188
+ skipped: entries.length - changed,
189
+ entries
190
+ };
191
+ };
192
+ export const addCanonicalContextLinkToContent = (title, content) => {
193
+ const context = inferVisualGraphContext({
194
+ id: '',
195
+ agentId: sharedAgentId,
196
+ title,
197
+ path: '',
198
+ content,
199
+ tags: [],
200
+ });
201
+ const hubTitle = hubTitleForContext(context.title);
202
+ const nextContent = normalizeTitle(title) === normalizeTitle(hubTitle) ? content : upsertCanonicalContextLink(content, hubTitle);
203
+ return {
204
+ content: nextContent,
205
+ context: context.title,
206
+ hubTitle,
207
+ changed: nextContent !== content
208
+ };
209
+ };
@@ -99,10 +99,87 @@ select {
99
99
  cursor: grab;
100
100
  }
101
101
 
102
+ #graph.is-node-hover {
103
+ cursor: pointer;
104
+ }
105
+
106
+ #graph.is-node-dragging {
107
+ cursor: move;
108
+ }
109
+
102
110
  #graph:active {
103
111
  cursor: grabbing;
104
112
  }
105
113
 
114
+ .graph-labels {
115
+ position: absolute;
116
+ inset: 0;
117
+ pointer-events: none;
118
+ overflow: hidden;
119
+ }
120
+
121
+ .graph-label {
122
+ position: absolute;
123
+ max-width: 220px;
124
+ transform: translate(-50%, calc(-100% - 10px));
125
+ padding: 4px 7px;
126
+ border: 1px solid rgba(129, 146, 170, 0.28);
127
+ border-radius: 6px;
128
+ background: rgba(13, 16, 20, 0.78);
129
+ color: var(--text);
130
+ font-size: 11px;
131
+ line-height: 1.25;
132
+ white-space: nowrap;
133
+ overflow: hidden;
134
+ text-overflow: ellipsis;
135
+ box-shadow: 0 8px 22px rgba(0, 0, 0, 0.28);
136
+ }
137
+
138
+ .graph-label.is-focused {
139
+ border-color: rgba(53, 208, 162, 0.72);
140
+ color: #dffbf3;
141
+ }
142
+
143
+ .graph-tooltip {
144
+ position: absolute;
145
+ z-index: 4;
146
+ max-width: min(320px, calc(100vw - 32px));
147
+ padding: 8px 10px;
148
+ border: 1px solid var(--line);
149
+ border-radius: 6px;
150
+ background: rgba(13, 16, 20, 0.94);
151
+ color: var(--text);
152
+ font-size: 12px;
153
+ line-height: 1.35;
154
+ pointer-events: none;
155
+ box-shadow: 0 16px 40px rgba(0, 0, 0, 0.38);
156
+ }
157
+
158
+ .graph-tooltip strong,
159
+ .graph-tooltip small {
160
+ display: block;
161
+ overflow: hidden;
162
+ text-overflow: ellipsis;
163
+ }
164
+
165
+ .graph-tooltip small {
166
+ margin-top: 3px;
167
+ color: var(--muted);
168
+ }
169
+
170
+ .mini-map {
171
+ position: absolute;
172
+ right: 14px;
173
+ bottom: 14px;
174
+ z-index: 3;
175
+ width: 180px;
176
+ height: 120px;
177
+ border: 1px solid rgba(129, 146, 170, 0.28);
178
+ border-radius: 8px;
179
+ background: rgba(13, 16, 20, 0.78);
180
+ box-shadow: 0 16px 42px rgba(0, 0, 0, 0.38);
181
+ }
182
+
106
183
  .eyebrow {
107
184
  color: var(--muted);
108
185
  font-size: 12px;
@@ -42,12 +42,16 @@ export const createClientHtml = () => `<!doctype html>
42
42
  <button id="zoomIn" type="button" title="Zoom in">+</button>
43
43
  <button id="zoomOut" type="button" title="Zoom out">-</button>
44
44
  <button id="fit" type="button" title="Focus central hub">◎</button>
45
+ <button id="releaseNode" type="button" title="Release selected node">◇</button>
45
46
  <button id="reset" type="button" title="Reset view">⌂</button>
46
47
  </div>
47
48
  </div>
48
49
  </header>
49
50
  <div class="graph-stage">
50
51
  <canvas id="graph" aria-label="Brainlink knowledge graph"></canvas>
52
+ <div id="graphLabels" class="graph-labels" aria-hidden="true"></div>
53
+ <div id="graphTooltip" class="graph-tooltip" role="tooltip" hidden></div>
54
+ <canvas id="miniMap" class="mini-map" aria-label="Graph overview"></canvas>
51
55
  </div>
52
56
  </section>
53
57
  </main>