@andespindola/brainlink 1.3.0 → 1.5.0

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/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.5.0
4
+
5
+ - **Graph shows every node at any zoom.** The viewport stream previously replaced nodes with per-context clusters below a zoom threshold, so nodes vanished when you zoomed out and reappeared on approach. Graphs that fit a cap (6000 nodes) now render every node in near mode at all zoom levels; larger graphs keep the far/mid/near level-of-detail.
6
+ - **Graph visual refresh.** Bigger nodes with a screen-space radius floor so they stay visible dots at any zoom, brighter and more legible edges, and labels decluttered by collision (placed by importance, skipping overlaps) so density scales smoothly with zoom instead of flipping on at a hard threshold. Labels reposition live during interaction (no flicker) and are throttled to keep pan/zoom fluid.
7
+ - **Crowquill Mono UI font.** The graph UI now ships and uses Crowquill Mono, inlined as base64 woff2 in the served CSS so it is self-contained (graceful monospace fallback if unavailable). The header metrics were made compact so the Notes/Links counters no longer wrap.
8
+ - **New logo and npm image fix.** The README logo is now a brain drawn as a knowledge graph, exported to PNG and referenced by absolute URL so it renders on the npm package page (npm does not render SVG). Copyright holder set to Anderson Espindola.
9
+
10
+ ## 1.4.0
11
+
12
+ - **Shared namespace by default.** `defaultAgent` now defaults to `shared`, so every agent (Claude, Codex, GPT, …) reads and writes the same namespace and sees each other's memory unless a call passes an explicit `--agent`/`agent`. Set `defaultAgent` to another id, or pass `--agent` per call, to scope to a private namespace.
13
+ - **Domain partitioning on write.** `brainlink_add_note`, `brainlink_add_notes` (batch-level and per-note) and `brainlink_remember` accept a `context`. When set, the note is linked to its `<context> Hub` (created if missing) so the knowledge graph stays split into navigable domains instead of collapsing into a single per-namespace context. An explicit context always links, even when auto context links are otherwise disabled.
14
+ - **Lossless namespace merge.** New `brainlink_vault_merge_agents` MCP tool and `blink vaults merge-agents` CLI merge one agent namespace into another within a vault (e.g. `claude-code` → `shared`) without losing data: identical notes are skipped, colliding slugs with different content are kept under a `-from-<agent>` name, everything else is moved, and merged notes are aggregated into domain hubs. Source notes are kept unless `deleteSource` is set; `dryRun` previews the plan.
15
+ - **Stronger on-connect instructions.** The advertised server instructions now encode consult-before-acting, domain partitioning on every write, persistent and volatile memory used together, and shared-by-default so all agents form one continuous brain. The field stays advisory and no tool contract changed.
16
+
3
17
  ## 1.3.0
4
18
 
5
19
  - **MCP server instructions on connect.** The server now advertises `instructions` in its initialize response, so clients that surface them (e.g. Claude Code) inject Brainlink's memory discipline into the agent's context the moment it connects — treat Brainlink as the default context source for everything, bootstrap first with a task query, use the full toolkit (both RAG and CAG via `strategy: auto`, context packs, hybrid search, the knowledge graph, explain/recommendations/validate/stats) instead of shallow retrieval, keep ephemeral facts in volatile memory, and persist durable learnings as connected notes. The field is advisory (clients that ignore it are unaffected) and no tool contract changed.
package/COPYRIGHT.md CHANGED
@@ -1,4 +1,4 @@
1
- Copyright (c) 2026 Substructa
1
+ Copyright (c) 2026 Anderson Espindola
2
2
 
3
3
  This project is licensed under the MIT License.
4
4
 
package/README.md CHANGED
@@ -1,5 +1,5 @@
1
1
  <p align="center">
2
- <img src="./assets/brainlink-logo.svg" alt="Brainlink" width="720">
2
+ <img src="https://raw.githubusercontent.com/andersonflima/brainlink/main/assets/brainlink-logo.png" alt="Brainlink" width="720">
3
3
  </p>
4
4
 
5
5
  # Brainlink
@@ -543,9 +543,9 @@ Available tools:
543
543
  - `brainlink_similar_notes`: find the notes most similar to a given note (by title or path), excluding the note itself.
544
544
  - `brainlink_dedupe`: detect duplicate candidates using exact hash + semantic similarity scores.
545
545
  - `brainlink_resolve_duplicate`: resolve duplicate pairs (`merge`, `link`, `ignore`) with connectivity-safe fallback edges.
546
- - `brainlink_add_note`: write durable Markdown memory and reindex.
547
- - `brainlink_add_notes`: write several notes in one batch, reindexing once.
548
- - `brainlink_remember`: capture durable memory with inferred title, tags and Context Links; supports dry-run.
546
+ - `brainlink_add_note`: write durable Markdown memory and reindex. Pass `context` to partition the note under a `<context> Hub` (created if missing) so the graph stays split by domain.
547
+ - `brainlink_add_notes`: write several notes in one batch, reindexing once. Accepts a batch-level `context` and per-note `context` overrides.
548
+ - `brainlink_remember`: capture durable memory with inferred title, tags and Context Links; supports dry-run. Pass `context` to partition it under a domain hub.
549
549
  - `brainlink_inbox_add`: capture a quick untriaged memory item.
550
550
  - `brainlink_inbox_list`: list untriaged inbox memory items.
551
551
  - `brainlink_inbox_process`: suggest titles, tags and links for inbox items.
@@ -563,6 +563,7 @@ Available tools:
563
563
  - `brainlink_vault_provision`: create a private GitHub repository for the vault with the authenticated `gh` CLI, wire it as `origin`, push the first snapshot, and enable `autoVersion` so later indexed changes are committed and pushed automatically. By default the repository is **encrypted** (`vaultEncryption`): it holds AES-256-GCM ciphertext, decryptable only by a Brainlink instance with the local key.
564
564
  - `brainlink_vault_status` / `brainlink_vault_init` / `brainlink_vault_commit` / `brainlink_vault_history` / `brainlink_vault_pull` / `brainlink_vault_restore`: version the vault's canonical Markdown with git (the derived index is git-ignored). Pull and restore reindex changed files automatically so the vault stays immediately searchable.
565
565
  - `brainlink_vault_export` / `brainlink_vault_import`: write and read a portable `.blvault` snapshot (gzip envelope of the Markdown with per-file SHA-256). Import verifies checksums, preserves divergent local files as `*.conflict-<ts>.md`, and reindexes.
566
+ - `brainlink_vault_merge_agents`: losslessly merge one agent namespace into another within the same vault (e.g. `claude-code` → `shared`). Identical notes are skipped, colliding slugs with different content are kept under a `-from-<agent>` name, everything else is moved, and merged notes are aggregated into domain `<context> Hub` notes. Source notes are kept unless `deleteSource` is set; `dryRun` previews the plan.
566
567
  - `brainlink_graph`: read indexed graph nodes and weighted links.
567
568
  - `brainlink_graph_contexts`: list the visual graph contexts used by the local server.
568
569
  - `brainlink_broken_links`: list unresolved wiki links.
@@ -1184,7 +1185,7 @@ If no `vault` is configured and no `--vault` flag is passed, Brainlink uses `$HO
1184
1185
  }
1185
1186
  ```
1186
1187
 
1187
- `defaultAgent` is optional. When set, CLI and MCP calls that omit `--agent`/`agent` use this value automatically. If not set, behavior remains as before.
1188
+ `defaultAgent` defaults to `shared`, so every agent (Claude, Codex, GPT, …) reads and writes the same shared namespace and sees each other's memory unless a call passes an explicit `--agent`/`agent`. Set it to another id to make a different namespace the default, or pass `--agent` per call to scope to a private namespace.
1188
1189
  `agentProfiles` is optional. When present, CLI and MCP resolve `mode`, `limit`, `tokens` and context `strategy` per agent automatically, then fallback to global defaults.
1189
1190
 
1190
1191
  `autoIndexOnWrite` is optional and defaults to `true`. Set it to `false` to defer indexing after writes.
@@ -1455,7 +1456,7 @@ See [CONTRIBUTING.md](CONTRIBUTING.md).
1455
1456
  ## License
1456
1457
 
1457
1458
  MIT. See [LICENSE](LICENSE).
1458
- Copyright (c) 2026 Substructa. See [COPYRIGHT.md](COPYRIGHT.md).
1459
+ Copyright (c) 2026 Anderson Espindola. See [COPYRIGHT.md](COPYRIGHT.md).
1459
1460
 
1460
1461
  ### Memory Optimization Loop (1-7)
1461
1462
 
Binary file
@@ -1,25 +1,100 @@
1
- <svg width="900" height="220" viewBox="0 0 900 220" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="title desc">
1
+ <svg width="1240" height="440" viewBox="0 0 1240 440" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="title desc">
2
2
  <title id="title">Brainlink</title>
3
- <desc id="desc">Brainlink logo with a linked memory graph mark and wordmark.</desc>
4
- <rect width="900" height="220" fill="transparent"/>
5
- <g transform="translate(42 24)">
6
- <rect x="6" y="6" width="160" height="160" rx="36" fill="#F8FAFC"/>
7
- <rect x="6" y="6" width="160" height="160" rx="36" stroke="#111827" stroke-width="8"/>
8
- <path d="M47 84C47 65.225 62.225 50 81 50H92C110.775 50 126 65.225 126 84C126 102.775 110.775 118 92 118H81C62.225 118 47 102.775 47 84Z" stroke="#111827" stroke-width="12" stroke-linecap="round"/>
9
- <path d="M67 84C67 76.268 73.268 70 81 70H92C99.732 70 106 76.268 106 84C106 91.732 99.732 98 92 98H81C73.268 98 67 91.732 67 84Z" fill="#FFFFFF"/>
10
- <path d="M54 132L43 121L54 110" stroke="#2563EB" stroke-width="10" stroke-linecap="round" stroke-linejoin="round"/>
11
- <path d="M118 110L129 121L118 132" stroke="#2563EB" stroke-width="10" stroke-linecap="round" stroke-linejoin="round"/>
12
- <circle cx="55" cy="43" r="12" fill="#14B8A6"/>
13
- <circle cx="121" cy="43" r="12" fill="#2563EB"/>
14
- <circle cx="86" cy="136" r="12" fill="#111827"/>
15
- <path d="M66.5 47.5L109.5 47.5" stroke="#94A3B8" stroke-width="7" stroke-linecap="round"/>
16
- <path d="M62 54L79 125" stroke="#94A3B8" stroke-width="7" stroke-linecap="round"/>
17
- <path d="M115 54L92 125" stroke="#94A3B8" stroke-width="7" stroke-linecap="round"/>
3
+ <desc id="desc">Brainlink logo: a brain drawn as a knowledge graph of connected memory nodes, for AI agents.</desc>
4
+ <defs>
5
+ <radialGradient id="bg" cx="26%" cy="30%" r="95%">
6
+ <stop offset="0%" stop-color="#0e2233"/>
7
+ <stop offset="55%" stop-color="#0a1926"/>
8
+ <stop offset="100%" stop-color="#06111b"/>
9
+ </radialGradient>
10
+ <linearGradient id="brainStroke" x1="0" y1="0" x2="1" y2="1">
11
+ <stop offset="0%" stop-color="#5aa8ff"/>
12
+ <stop offset="100%" stop-color="#59d0dd"/>
13
+ </linearGradient>
14
+ <linearGradient id="wordmark" x1="0" y1="0" x2="0" y2="1">
15
+ <stop offset="0%" stop-color="#eef5ff"/>
16
+ <stop offset="100%" stop-color="#bcd4f2"/>
17
+ </linearGradient>
18
+ <filter id="glow" x="-60%" y="-60%" width="220%" height="220%">
19
+ <feGaussianBlur stdDeviation="5" result="b"/>
20
+ <feMerge>
21
+ <feMergeNode in="b"/>
22
+ <feMergeNode in="SourceGraphic"/>
23
+ </feMerge>
24
+ </filter>
25
+ </defs>
26
+
27
+ <rect width="1240" height="440" rx="34" fill="url(#bg)"/>
28
+
29
+ <!-- Brain drawn as a graph -->
30
+ <g transform="translate(70 26)" filter="url(#glow)">
31
+ <!-- brain silhouette -->
32
+ <path d="M270,58 C232,40 190,50 168,84 C138,74 110,96 116,130 C88,140 82,180 108,200
33
+ C86,214 92,256 122,266 C120,306 158,336 202,326 C224,352 286,352 312,326
34
+ C356,340 398,312 392,270 C420,258 424,214 398,196 C420,180 416,138 386,132
35
+ C392,98 360,72 322,84 C308,64 288,58 270,58 Z"
36
+ fill="#5aa8ff" fill-opacity="0.05" stroke="url(#brainStroke)" stroke-width="3" stroke-opacity="0.65"/>
37
+ <!-- central sulcus + a few gyri -->
38
+ <path d="M268,72 C246,120 300,150 270,196 C242,240 300,272 272,318"
39
+ fill="none" stroke="#5aa8ff" stroke-width="2.2" stroke-opacity="0.32"/>
40
+ <path d="M150,110 C176,128 176,156 150,176" fill="none" stroke="#5aa8ff" stroke-width="2" stroke-opacity="0.2"/>
41
+ <path d="M360,120 C334,140 336,168 362,186" fill="none" stroke="#5aa8ff" stroke-width="2" stroke-opacity="0.2"/>
42
+ <path d="M196,250 C222,262 250,258 268,240" fill="none" stroke="#5aa8ff" stroke-width="2" stroke-opacity="0.2"/>
43
+
44
+ <!-- graph edges -->
45
+ <g stroke="#7fb2e6" stroke-width="2" stroke-opacity="0.42" stroke-linecap="round">
46
+ <line x1="150" y1="120" x2="210" y2="96"/>
47
+ <line x1="210" y1="96" x2="250" y2="150"/>
48
+ <line x1="150" y1="120" x2="190" y2="170"/>
49
+ <line x1="190" y1="170" x2="250" y2="150"/>
50
+ <line x1="190" y1="170" x2="140" y2="200"/>
51
+ <line x1="140" y1="200" x2="172" y2="248"/>
52
+ <line x1="172" y1="248" x2="225" y2="228"/>
53
+ <line x1="225" y1="228" x2="250" y2="150"/>
54
+ <line x1="172" y1="248" x2="208" y2="298"/>
55
+ <line x1="208" y1="298" x2="266" y2="262"/>
56
+ <line x1="266" y1="262" x2="225" y2="228"/>
57
+ <line x1="250" y1="150" x2="270" y2="205"/>
58
+ <line x1="270" y1="205" x2="315" y2="200"/>
59
+ <line x1="300" y1="112" x2="340" y2="152"/>
60
+ <line x1="300" y1="112" x2="250" y2="150"/>
61
+ <line x1="340" y1="152" x2="315" y2="200"/>
62
+ <line x1="315" y1="200" x2="362" y2="236"/>
63
+ <line x1="362" y1="236" x2="345" y2="292"/>
64
+ <line x1="345" y1="292" x2="300" y2="300"/>
65
+ <line x1="300" y1="300" x2="266" y2="262"/>
66
+ <line x1="300" y1="300" x2="208" y2="298"/>
67
+ <line x1="270" y1="205" x2="300" y2="300"/>
68
+ <line x1="270" y1="205" x2="266" y2="262"/>
69
+ </g>
70
+
71
+ <!-- graph nodes -->
72
+ <g>
73
+ <circle cx="270" cy="205" r="12" fill="#5aa8ff"/>
74
+ <circle cx="210" cy="96" r="8" fill="#59d0dd"/>
75
+ <circle cx="150" cy="120" r="7" fill="#5aa8ff"/>
76
+ <circle cx="250" cy="150" r="10" fill="#7cb6ff"/>
77
+ <circle cx="190" cy="170" r="7" fill="#5ecf92"/>
78
+ <circle cx="140" cy="200" r="7" fill="#5aa8ff"/>
79
+ <circle cx="172" cy="248" r="8" fill="#a88fff"/>
80
+ <circle cx="225" cy="228" r="9" fill="#5aa8ff"/>
81
+ <circle cx="208" cy="298" r="7" fill="#ffb65c"/>
82
+ <circle cx="266" cy="262" r="8" fill="#59d0dd"/>
83
+ <circle cx="300" cy="112" r="8" fill="#ff7dac"/>
84
+ <circle cx="340" cy="152" r="7" fill="#5aa8ff"/>
85
+ <circle cx="315" cy="200" r="10" fill="#5ecf92"/>
86
+ <circle cx="362" cy="236" r="7" fill="#7cb6ff"/>
87
+ <circle cx="345" cy="292" r="8" fill="#5aa8ff"/>
88
+ <circle cx="300" cy="300" r="9" fill="#ff8f6a"/>
89
+ </g>
18
90
  </g>
19
- <g transform="translate(244 68)">
20
- <text x="0" y="62" fill="#111827" font-family="Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="72" font-weight="760">Brainlink</text>
21
- <text x="4" y="102" fill="#475569" font-family="Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="24" font-weight="500">Local-first memory for AI agents</text>
22
- <path d="M0 124H268" stroke="#14B8A6" stroke-width="8" stroke-linecap="round"/>
23
- <path d="M292 124H392" stroke="#2563EB" stroke-width="8" stroke-linecap="round"/>
91
+
92
+ <!-- wordmark -->
93
+ <g transform="translate(560 0)" font-family="Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif">
94
+ <text x="0" y="212" fill="url(#wordmark)" font-size="118" font-weight="780" letter-spacing="-3">Brainlink</text>
95
+ <text x="4" y="262" fill="#7f9ad0" font-size="30" font-weight="500">Canonical memory graph for AI agents</text>
96
+ <line x1="6" y1="292" x2="150" y2="292" stroke="#5aa8ff" stroke-width="6" stroke-linecap="round"/>
97
+ <line x1="166" y1="292" x2="238" y2="292" stroke="#59d0dd" stroke-width="6" stroke-linecap="round"/>
98
+ <line x1="254" y1="292" x2="298" y2="292" stroke="#5ecf92" stroke-width="6" stroke-linecap="round"/>
24
99
  </g>
25
100
  </svg>
@@ -29,9 +29,12 @@ export const addNoteWithMetadata = async (vaultPath, title, content, agentId = s
29
29
  const sanitizedAgentId = sanitizeAgentId(agentId);
30
30
  const filename = `agents/${sanitizedAgentId}/${slugify(title) || 'untitled'}.md`;
31
31
  await ensureVault(vaultPath);
32
- const canonical = options.autoContextLinks === false
32
+ const contextOverride = options.context?.trim() || undefined;
33
+ // An explicit context always partitions the note, even when auto context links
34
+ // are otherwise disabled; without an override, honor the auto-link setting.
35
+ const canonical = options.autoContextLinks === false && !contextOverride
33
36
  ? null
34
- : addCanonicalContextLinkToContent(title, content.trim(), await loadVisualContextRules(vaultPath), filename);
37
+ : addCanonicalContextLinkToContent(title, content.trim(), await loadVisualContextRules(vaultPath), filename, contextOverride);
35
38
  const hub = canonical?.changed
36
39
  ? await ensureCanonicalContextHub(vaultPath, canonical.context, sanitizedAgentId)
37
40
  : null;
@@ -228,15 +228,19 @@ export const canonicalizeContextLinks = async (vaultPath, options = {}) => {
228
228
  entries
229
229
  };
230
230
  };
231
- export const addCanonicalContextLinkToContent = (title, content, rules = [], notePath = '') => {
232
- const context = inferVisualGraphContext({
233
- id: '',
234
- agentId: sharedAgentId,
235
- title,
236
- path: notePath,
237
- content,
238
- tags: [],
239
- }, rules);
231
+ // When an explicit context is provided by the caller, it overrides inference so
232
+ // a note lands in that domain hub instead of the generic per-namespace fallback
233
+ // (which collapses every agent note into a single "Agent Memory" context). An
234
+ // empty/whitespace override is ignored, preserving inference.
235
+ const resolveContextTitle = (contextOverride, title, content, notePath, rules) => {
236
+ const explicit = contextOverride?.trim();
237
+ if (explicit) {
238
+ return explicit;
239
+ }
240
+ return inferVisualGraphContext({ id: '', agentId: sharedAgentId, title, path: notePath, content, tags: [] }, rules).title;
241
+ };
242
+ export const addCanonicalContextLinkToContent = (title, content, rules = [], notePath = '', contextOverride) => {
243
+ const context = { title: resolveContextTitle(contextOverride, title, content, notePath, rules) };
240
244
  const hubTitle = hubTitleForContext(context.title);
241
245
  const nextContent = normalizeTitle(title) === normalizeTitle(hubTitle) ? content : upsertCanonicalContextLink(content, hubTitle);
242
246
  return {
@@ -23,8 +23,8 @@ const drawFallback = () => {
23
23
  nodeById.set(nodes[i][0], nodes[i])
24
24
  }
25
25
 
26
- ctx2dFallback.strokeStyle = 'rgba(151,181,212,0.18)'
27
- ctx2dFallback.lineWidth = 1
26
+ ctx2dFallback.strokeStyle = 'rgba(158,188,220,0.32)'
27
+ ctx2dFallback.lineWidth = 1.2
28
28
  for (let i = 0; i < edges.length; i += 1) {
29
29
  const edge = edges[i]
30
30
  const source = nodeById.get(edge[0])
@@ -43,7 +43,7 @@ const drawFallback = () => {
43
43
  const p = worldToScreen(node[2], node[3])
44
44
  const selected = state.selectedNodeId === node[0]
45
45
  const color = segmentColor(node[5] || node[4] || node[1])
46
- const radius = Math.max(3.2, Math.min(16.5, 5 + node[7] * 0.65))
46
+ const radius = Math.max(3.8, Math.min(18, 5.5 + node[7] * 0.7))
47
47
 
48
48
  ctx2dFallback.beginPath()
49
49
  ctx2dFallback.fillStyle = selected ? '#edf4ff' : color
@@ -179,25 +179,57 @@ const hideTooltip = () => {
179
179
 
180
180
  const labelCandidates = () => {
181
181
  const nodes = normalizeList(state.chunk.nodes)
182
- const visible = nodes.filter((node) => {
182
+ const focused = state.focusedNodeIds
183
+ const isPinned = (node) => focused.has(node[0]) || node[0] === state.hoveredNodeId || node[0] === state.selectedNodeId
184
+ const margin = 80
185
+
186
+ // Collect on-screen nodes with their screen position, ranked by importance
187
+ // (pinned first, then relevance, deterministic id tie-break).
188
+ const scored = []
189
+ for (let i = 0; i < nodes.length; i += 1) {
190
+ const node = nodes[i]
183
191
  const x = Number(node?.[2])
184
192
  const y = Number(node?.[3])
185
- if (!Number.isFinite(x) || !Number.isFinite(y)) return false
193
+ if (!Number.isFinite(x) || !Number.isFinite(y)) continue
186
194
  const point = worldToScreen(x, y)
187
- return point.x >= -80 && point.x <= state.viewport.width + 80 && point.y >= -80 && point.y <= state.viewport.height + 80
195
+ if (point.x < -margin || point.x > state.viewport.width + margin || point.y < -margin || point.y > state.viewport.height + margin) continue
196
+ scored.push({ node, x: point.x, y: point.y, pinned: isPinned(node), relevance: Number(node?.[7] ?? 0) })
197
+ }
198
+ scored.sort((left, right) => {
199
+ const focusDelta = (right.pinned ? 1 : 0) - (left.pinned ? 1 : 0)
200
+ if (focusDelta !== 0) return focusDelta
201
+ const relevanceDelta = right.relevance - left.relevance
202
+ if (relevanceDelta !== 0) return relevanceDelta
203
+ return String(left.node[0]).localeCompare(String(right.node[0]))
188
204
  })
189
- const shouldShowMany = state.camera.scale >= 0.72 || visible.length <= 120
190
- const focused = state.focusedNodeIds
191
205
 
192
- return visible
193
- .filter((node) => shouldShowMany || focused.has(node[0]) || node[0] === state.hoveredNodeId || node[0] === state.selectedNodeId || Number(node?.[7]) > 5.5)
194
- .sort((left, right) => {
195
- const leftFocused = focused.has(left[0]) || left[0] === state.hoveredNodeId || left[0] === state.selectedNodeId ? 1 : 0
196
- const rightFocused = focused.has(right[0]) || right[0] === state.hoveredNodeId || right[0] === state.selectedNodeId ? 1 : 0
197
- if (rightFocused !== leftFocused) return rightFocused - leftFocused
198
- return Number(right?.[7] ?? 0) - Number(left?.[7] ?? 0)
199
- })
200
- .slice(0, state.camera.scale >= 0.72 ? 160 : 48)
206
+ // Greedy overlap-free placement: a label is shown only if its box does not
207
+ // collide with an already-placed one, so density scales smoothly with zoom
208
+ // (few when nodes are packed, more as they spread out) instead of flipping on
209
+ // at a hard zoom threshold. Pinned labels are always kept.
210
+ const gap = 4
211
+ const height = 22
212
+ const maxLabels = state.camera.scale >= 0.72 ? 220 : 120
213
+ const placed = []
214
+ const result = []
215
+ for (let i = 0; i < scored.length && result.length < maxLabels; i += 1) {
216
+ const item = scored[i]
217
+ const title = String(item.node[1] || item.node[0])
218
+ const width = Math.min(220, 18 + title.length * 6.6)
219
+ const box = { left: item.x - width / 2, right: item.x + width / 2, bottom: item.y - 6, top: item.y - 6 - height }
220
+ let overlaps = false
221
+ for (let j = 0; j < placed.length; j += 1) {
222
+ const other = placed[j]
223
+ if (box.left < other.right + gap && box.right > other.left - gap && box.top < other.bottom + gap && box.bottom > other.top - gap) {
224
+ overlaps = true
225
+ break
226
+ }
227
+ }
228
+ if (overlaps && !item.pinned) continue
229
+ placed.push(box)
230
+ result.push(item.node)
231
+ }
232
+ return result
201
233
  }
202
234
 
203
235
  const drawLabels = () => {
@@ -268,6 +300,12 @@ const drawMiniMap = () => {
268
300
 
269
301
  const shouldDeferGraphOverlays = () => state.pointer.down || performance.now() - state.lastWheelAt < 150
270
302
 
303
+ // Rebuilding the label overlay is the main per-frame cost. During interaction we
304
+ // cap it to ~25fps (the canvas keeps 60fps in the worker), which halves the DOM
305
+ // churn while labels still track the nodes. A trailing timer guarantees a final
306
+ // pass so labels settle in the right place when interaction stops.
307
+ const graphOverlayInteractionThrottleMs = 40
308
+
271
309
  const updateGraphOverlays = () => {
272
310
  if (state.overlayScheduled) {
273
311
  return
@@ -275,19 +313,25 @@ const updateGraphOverlays = () => {
275
313
  state.overlayScheduled = true
276
314
  requestAnimationFrame(() => {
277
315
  state.overlayScheduled = false
278
- if (shouldDeferGraphOverlays()) {
279
- elements.labels?.classList.add('is-stale')
316
+ const interacting = shouldDeferGraphOverlays()
317
+
318
+ if (interacting && performance.now() - (state.lastOverlayDrawAt || 0) < graphOverlayInteractionThrottleMs) {
280
319
  if (!state.overlayIdleTimer) {
281
320
  state.overlayIdleTimer = setTimeout(() => {
282
321
  state.overlayIdleTimer = null
283
322
  updateGraphOverlays()
284
- }, 170)
323
+ }, graphOverlayInteractionThrottleMs)
285
324
  }
286
325
  return
287
326
  }
327
+
288
328
  elements.labels?.classList.remove('is-stale')
289
329
  drawLabels()
290
- if (state.miniMapDirty) {
330
+ state.lastOverlayDrawAt = performance.now()
331
+
332
+ // The minimap is heavier and irrelevant mid-gesture, so only refresh it once
333
+ // the camera settles.
334
+ if (!interacting && state.miniMapDirty) {
291
335
  drawMiniMap()
292
336
  state.miniMapDirty = false
293
337
  }
@@ -63,8 +63,8 @@ const graphTheme = {
63
63
  parseColor('#c9945f'),
64
64
  parseColor('#7cb6ff')
65
65
  ],
66
- edge: [0.59, 0.71, 0.83, 0.14],
67
- edgeHeavy: [0.59, 0.71, 0.83, 0.3],
66
+ edge: [0.62, 0.74, 0.86, 0.24],
67
+ edgeHeavy: [0.66, 0.78, 0.9, 0.46],
68
68
  clear: parseColor('#08131d')
69
69
  }
70
70
 
@@ -1,4 +1,29 @@
1
- export const createClientCss = () => `:root {
1
+ import { readFileSync } from 'node:fs';
2
+ import { fileURLToPath } from 'node:url';
3
+ // Crowquill Mono ships with the package under assets/fonts. Inline each face as a
4
+ // base64 woff2 data URI so the served CSS is self-contained (no extra route, no
5
+ // path resolution) and works identically for a global npm install. A missing
6
+ // file degrades gracefully to the monospace fallback stack.
7
+ const embedFontFace = (weight, style, file) => {
8
+ try {
9
+ const path = fileURLToPath(new URL(`../../../assets/fonts/${file}`, import.meta.url));
10
+ const base64 = readFileSync(path).toString('base64');
11
+ return `@font-face{font-family:"Crowquill Mono";font-style:${style};font-weight:${weight};font-display:swap;src:url("data:font/woff2;base64,${base64}") format("woff2")}`;
12
+ }
13
+ catch {
14
+ return '';
15
+ }
16
+ };
17
+ const crowquillFontFaces = () => [
18
+ embedFontFace(400, 'normal', 'CrowquillMono-Regular.woff2'),
19
+ embedFontFace(700, 'normal', 'CrowquillMono-Bold.woff2'),
20
+ embedFontFace(400, 'italic', 'CrowquillMono-Italic.woff2'),
21
+ embedFontFace(700, 'italic', 'CrowquillMono-BoldItalic.woff2')
22
+ ]
23
+ .filter(Boolean)
24
+ .join('\n');
25
+ export const createClientCss = () => `${crowquillFontFaces()}
26
+ :root {
2
27
  color-scheme: dark;
3
28
  --bg: #071019;
4
29
  --panel: #0d1823;
@@ -22,7 +47,7 @@ body {
22
47
  margin: 0;
23
48
  background: var(--bg);
24
49
  color: var(--text);
25
- font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
50
+ font-family: "Crowquill Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
26
51
  }
27
52
 
28
53
  body {
@@ -256,28 +281,30 @@ select {
256
281
 
257
282
  .floating-metrics {
258
283
  display: flex;
259
- gap: 10px;
260
- flex-wrap: wrap;
284
+ gap: 8px;
285
+ flex-wrap: nowrap;
286
+ flex: 0 0 auto;
261
287
  }
262
288
 
263
289
  .metric-chip {
264
- min-width: 94px;
265
- padding: 10px 12px;
290
+ padding: 6px 10px;
266
291
  border: 1px solid var(--line);
267
- border-radius: 10px;
292
+ border-radius: 9px;
268
293
  background: rgba(12, 24, 36, 0.94);
269
- display: grid;
270
- gap: 3px;
294
+ display: flex;
295
+ align-items: baseline;
296
+ gap: 6px;
297
+ white-space: nowrap;
271
298
  }
272
299
 
273
300
  .metric-chip strong {
274
- font-size: 26px;
301
+ font-size: 15px;
275
302
  line-height: 1;
276
303
  }
277
304
 
278
305
  .metric-chip small {
279
306
  color: var(--muted);
280
- font-size: 11px;
307
+ font-size: 10px;
281
308
  letter-spacing: 0.03em;
282
309
  text-transform: uppercase;
283
310
  }
@@ -344,7 +371,7 @@ li small {
344
371
  color: var(--text);
345
372
  white-space: pre-wrap;
346
373
  overflow: auto;
347
- font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
374
+ font-family: "Crowquill Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
348
375
  font-size: 12px;
349
376
  line-height: 1.5;
350
377
  }
@@ -742,7 +769,7 @@ li small {
742
769
  }
743
770
 
744
771
  .metric-chip {
745
- min-width: 82px;
772
+ min-width: 0;
746
773
  }
747
774
 
748
775
  .content-meta {
@@ -124,7 +124,7 @@ export const createClientHtml = () => `<!doctype html>
124
124
  </section>
125
125
  </main>
126
126
  <footer class="app-footer" aria-label="Copyright notice">
127
- <small>Copyright © 2026 Substructa</small>
127
+ <small>Copyright © 2026 Anderson Espindola</small>
128
128
  </footer>
129
129
  <script src="/app.js"></script>
130
130
  </body>
@@ -56,8 +56,8 @@ const defaultTheme = {
56
56
  [0.72, 0.51, 0.33, 1],
57
57
  [0.44, 0.62, 0.85, 1]
58
58
  ],
59
- edge: [0.23, 0.31, 0.42, 0.18],
60
- edgeHeavy: [0.23, 0.31, 0.42, 0.34],
59
+ edge: [0.36, 0.46, 0.60, 0.26],
60
+ edgeHeavy: [0.40, 0.52, 0.68, 0.5],
61
61
  clear: [0.031, 0.075, 0.114, 1]
62
62
  }
63
63
 
@@ -213,8 +213,8 @@ const ensureEdgeCapacity = (count) => {
213
213
  }
214
214
 
215
215
  const nodeRadius = (relevance, kind) => {
216
- const base = kind === 'cluster' ? 10.2 : 6.4
217
- const modifier = Math.min(6.6, Math.max(0, relevance * 0.72))
216
+ const base = kind === 'cluster' ? 11.2 : 7.2
217
+ const modifier = Math.min(7.6, Math.max(0, relevance * 0.78))
218
218
  return base + modifier
219
219
  }
220
220
 
@@ -352,7 +352,9 @@ const drawNodeLayer = (predicate, color, radiusBoost = 1) => {
352
352
  const [sx, sy] = toScreenPoint(state.x[index], state.y[index])
353
353
  pointPositionsBuffer[positionCursor] = sx
354
354
  pointPositionsBuffer[positionCursor + 1] = sy
355
- pointSizesBuffer[sizeCursor] = Math.max(1.2, state.radius[index] * camera.scale * devicePixelRatio * radiusBoost)
355
+ // Screen-space floor so every node stays a visible dot at any zoom (a low
356
+ // floor made nodes sub-pixel until zoomed in). Scaled by devicePixelRatio.
357
+ pointSizesBuffer[sizeCursor] = Math.max(3.0 * devicePixelRatio, state.radius[index] * camera.scale * devicePixelRatio * radiusBoost)
356
358
  positionCursor += 2
357
359
  sizeCursor += 1
358
360
  }
@@ -3,6 +3,9 @@ const layoutCacheBySignature = new Map();
3
3
  const maxLayoutCacheEntries = 6;
4
4
  const farScaleThreshold = 0.22;
5
5
  const midScaleThreshold = 0.78;
6
+ // Graphs with up to this many nodes render every node at all zoom levels (no
7
+ // zoom-based clustering). Above it, the far/mid/near LOD keeps huge graphs fast.
8
+ const renderAllNodeCap = 6000;
6
9
  const viewportPaddingFactor = 0.18;
7
10
  const maxNearEdgePerNode = 24;
8
11
  const maxMidEdgePerNode = 12;
@@ -296,23 +299,29 @@ export const getGraphStreamChunk = async (vaultPath, input) => {
296
299
  }
297
300
  };
298
301
  }
299
- const mode = input.scale < farScaleThreshold
300
- ? 'far'
301
- : input.scale < midScaleThreshold
302
- ? 'mid'
303
- : 'near';
302
+ // When the whole graph fits a sane cap, render every node at every zoom (no
303
+ // clustering / budget-shrink), so nodes never disappear as you zoom out and
304
+ // reappear on approach. Only genuinely large graphs keep the far/mid/near LOD.
305
+ const rendersAll = layout.nodes.length <= renderAllNodeCap;
306
+ const effectiveNodeBudget = rendersAll ? Math.max(nodeBudget, layout.nodes.length) : nodeBudget;
307
+ const effectiveEdgeBudget = rendersAll ? Math.max(edgeBudget, layout.edges.length) : edgeBudget;
308
+ const mode = rendersAll || input.scale >= midScaleThreshold
309
+ ? 'near'
310
+ : input.scale < farScaleThreshold
311
+ ? 'far'
312
+ : 'mid';
304
313
  const nodes = mode === 'far'
305
- ? selectFarClusters(layout.nodes, input, nodeBudget)
314
+ ? selectFarClusters(layout.nodes, input, effectiveNodeBudget)
306
315
  : mode === 'mid'
307
316
  ? selectMidNodes(layout.nodes, cache, {
308
317
  ...input,
309
- nodeBudget
318
+ nodeBudget: effectiveNodeBudget
310
319
  })
311
320
  : selectNearNodes(layout.nodes, cache, {
312
321
  ...input,
313
- nodeBudget
322
+ nodeBudget: effectiveNodeBudget
314
323
  });
315
- const edges = collectEdgesForNodes(layout.edges, cache, nodes, edgeBudget, mode === 'near' ? maxNearEdgePerNode : mode === 'mid' ? maxMidEdgePerNode : maxFarEdgePerCluster);
324
+ const edges = collectEdgesForNodes(layout.edges, cache, nodes, effectiveEdgeBudget, mode === 'near' ? maxNearEdgePerNode : mode === 'mid' ? maxMidEdgePerNode : maxFarEdgePerCluster);
316
325
  return {
317
326
  signature,
318
327
  mode,
@@ -0,0 +1,130 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { createHash } from 'node:crypto';
3
+ import { sanitizeAgentId } from '../domain/agents.js';
4
+ import { canonicalizeContextLinks } from './canonical-context-links.js';
5
+ import { indexVault } from './index-vault.js';
6
+ import { deleteMarkdownFile, ensureVault, readMarkdownFileSummaries, writeMarkdownFile } from '../infrastructure/file-system-vault.js';
7
+ const toPosix = (value) => value.replaceAll('\\', '/');
8
+ const namespacePrefix = (agentId) => `agents/${agentId}/`;
9
+ // Content hash used only to detect exact duplicates during merge, so identical
10
+ // notes collapse instead of producing a redundant "-from-<agent>" copy.
11
+ const contentHash = (content) => createHash('sha256').update(content.replace(/\s+/g, ' ').trim()).digest('hex');
12
+ // Rewrite the `agent:` value inside the leading frontmatter block so a moved
13
+ // note reports the target namespace. Body content is left untouched.
14
+ const rewriteAgentFrontmatter = (content, targetAgent) => {
15
+ const match = content.match(/^(---\r?\n)([\s\S]*?)(\r?\n---)/);
16
+ if (!match) {
17
+ return content;
18
+ }
19
+ const [, open, body, close] = match;
20
+ const rewrittenBody = /^agent\s*:/im.test(body)
21
+ ? body.replace(/^agent\s*:.*$/im, `agent: "${targetAgent}"`)
22
+ : `${body}\nagent: "${targetAgent}"`;
23
+ return `${open}${rewrittenBody}${close}${content.slice(match[0].length)}`;
24
+ };
25
+ const withSuffix = (relativePath, suffix) => relativePath.replace(/\.md$/i, `-${suffix}.md`);
26
+ // Pick a target path that does not clobber an existing note. Same slug + same
27
+ // content is treated by the caller as a no-op; same slug + different content is
28
+ // disambiguated so no data is ever lost.
29
+ const resolveTargetPath = (desiredPath, sourceAgent, taken) => {
30
+ if (!taken.has(desiredPath)) {
31
+ return desiredPath;
32
+ }
33
+ const primary = withSuffix(desiredPath, `from-${sourceAgent}`);
34
+ if (!taken.has(primary)) {
35
+ return primary;
36
+ }
37
+ for (let counter = 2;; counter += 1) {
38
+ const candidate = withSuffix(desiredPath, `from-${sourceAgent}-${counter}`);
39
+ if (!taken.has(candidate)) {
40
+ return candidate;
41
+ }
42
+ }
43
+ };
44
+ // Merge one agent namespace into another within the same vault, losslessly:
45
+ // identical notes are skipped, colliding slugs with different content are
46
+ // renamed, everything else is moved, and the merged set is aggregated into
47
+ // domain hubs. Source notes are kept unless deleteSource is set.
48
+ export const mergeAgentNamespace = async (vaultPath, options) => {
49
+ const sourceAgent = sanitizeAgentId(options.sourceAgent);
50
+ const targetAgent = sanitizeAgentId(options.targetAgent);
51
+ if (sourceAgent === targetAgent) {
52
+ throw new Error(`Cannot merge agent namespace "${sourceAgent}" into itself.`);
53
+ }
54
+ const absoluteVaultPath = await ensureVault(vaultPath);
55
+ const dryRun = options.dryRun === true;
56
+ const deleteSource = options.deleteSource === true;
57
+ const linkContexts = options.linkContexts !== false;
58
+ const summaries = await readMarkdownFileSummaries(absoluteVaultPath);
59
+ const sourcePrefix = namespacePrefix(sourceAgent);
60
+ const targetPrefix = namespacePrefix(targetAgent);
61
+ const sourceNotes = summaries.filter((summary) => toPosix(summary.relativePath).startsWith(sourcePrefix));
62
+ const taken = new Set(summaries.map((summary) => toPosix(summary.relativePath)));
63
+ const targetContentHashes = new Set();
64
+ await Promise.all(summaries
65
+ .filter((summary) => toPosix(summary.relativePath).startsWith(targetPrefix))
66
+ .map(async (summary) => {
67
+ targetContentHashes.add(contentHash(await readFile(summary.absolutePath, 'utf8')));
68
+ }));
69
+ const entries = [];
70
+ let moved = 0;
71
+ let identicalSkipped = 0;
72
+ let renamedCollisions = 0;
73
+ let deletedSource = 0;
74
+ for (const note of sourceNotes) {
75
+ const sourceRelative = toPosix(note.relativePath);
76
+ const raw = await readFile(note.absolutePath, 'utf8');
77
+ const rewritten = rewriteAgentFrontmatter(raw, targetAgent);
78
+ const desiredPath = targetPrefix + sourceRelative.slice(sourcePrefix.length);
79
+ // Exact duplicate already in the target: nothing to write, no data lost.
80
+ if (targetContentHashes.has(contentHash(rewritten))) {
81
+ identicalSkipped += 1;
82
+ entries.push({ sourcePath: sourceRelative, targetPath: desiredPath, action: 'identical-skipped' });
83
+ if (deleteSource && !dryRun) {
84
+ await deleteMarkdownFile(absoluteVaultPath, sourceRelative);
85
+ deletedSource += 1;
86
+ }
87
+ continue;
88
+ }
89
+ const targetPath = resolveTargetPath(desiredPath, sourceAgent, taken);
90
+ const isCollision = targetPath !== desiredPath;
91
+ taken.add(targetPath);
92
+ targetContentHashes.add(contentHash(rewritten));
93
+ if (!dryRun) {
94
+ await writeMarkdownFile(absoluteVaultPath, targetPath, rewritten);
95
+ if (deleteSource) {
96
+ await deleteMarkdownFile(absoluteVaultPath, sourceRelative);
97
+ deletedSource += 1;
98
+ }
99
+ }
100
+ if (isCollision) {
101
+ renamedCollisions += 1;
102
+ entries.push({ sourcePath: sourceRelative, targetPath, action: 'renamed-collision' });
103
+ }
104
+ else {
105
+ moved += 1;
106
+ entries.push({ sourcePath: sourceRelative, targetPath, action: 'moved' });
107
+ }
108
+ }
109
+ // Aggregate the merged notes into domain hubs so they partition by context
110
+ // instead of piling into a single namespace blob.
111
+ const contextLinks = linkContexts && !dryRun
112
+ ? await canonicalizeContextLinks(absoluteVaultPath, { agentId: targetAgent, createMissingHubs: true })
113
+ : null;
114
+ if (!dryRun && options.autoIndex !== false) {
115
+ await indexVault(absoluteVaultPath);
116
+ }
117
+ return {
118
+ sourceAgent,
119
+ targetAgent,
120
+ dryRun,
121
+ scanned: sourceNotes.length,
122
+ moved,
123
+ identicalSkipped,
124
+ renamedCollisions,
125
+ deletedSource,
126
+ contextLinksAdded: contextLinks?.changed ?? 0,
127
+ hubsCreated: contextLinks?.createdHubs ?? 0,
128
+ entries
129
+ };
130
+ };
@@ -1,6 +1,7 @@
1
1
  import { readdir, readFile, rm, stat } from 'node:fs/promises';
2
2
  import { extname, isAbsolute, join } from 'node:path';
3
3
  import { doctorVault } from '../../application/analyze-vault.js';
4
+ import { mergeAgentNamespace } from '../../application/merge-agent-namespace.js';
4
5
  import { defaultBrainlinkConfig, loadBrainlinkConfig, loadRawConfig, writeRawConfig } from '../../infrastructure/config.js';
5
6
  import { assertVaultAllowed, isBucketVaultPath, resolveVaultPath } from '../../infrastructure/file-system-vault.js';
6
7
  import { print } from '../runtime.js';
@@ -144,6 +145,30 @@ export const registerVaultCommands = (program) => {
144
145
  doctor
145
146
  }, () => `Default ${scope} vault set to ${targetVault} in ${configPath}.`);
146
147
  });
148
+ vaultsCommand
149
+ .command('merge-agents <source> <target>')
150
+ .option('--vault <vault>', 'vault to operate on (defaults to the configured vault)')
151
+ .option('--delete-source', 'remove each source note after it is safely present in the target')
152
+ .option('--no-link-contexts', 'skip aggregating merged notes into domain hubs')
153
+ .option('--dry-run', 'preview the merge plan without writing anything')
154
+ .option('--json', 'print machine-readable JSON')
155
+ .description('losslessly merge one agent namespace into another within a vault')
156
+ .action(async (source, target, options) => {
157
+ const config = await loadBrainlinkConfig();
158
+ const vault = options.vault ? normalizeVaultPath(options.vault) : config.vault;
159
+ const result = await mergeAgentNamespace(vault, {
160
+ sourceAgent: source,
161
+ targetAgent: target,
162
+ deleteSource: options.deleteSource === true,
163
+ linkContexts: options.linkContexts !== false,
164
+ dryRun: options.dryRun === true
165
+ });
166
+ print(options.json, { vault, ...result }, () => [
167
+ `${result.dryRun ? 'Planned merge' : 'Merged'} ${result.sourceAgent} → ${result.targetAgent} in ${vault}`,
168
+ `scanned=${result.scanned} moved=${result.moved} identical-skipped=${result.identicalSkipped} renamed-collisions=${result.renamedCollisions}`,
169
+ `deleted-source=${result.deletedSource} hubs-created=${result.hubsCreated} context-links-added=${result.contextLinksAdded}`
170
+ ].join('\n'));
171
+ });
147
172
  vaultsCommand
148
173
  .command('delete <vault>')
149
174
  .option('--yes', 'confirm destructive vault deletion')
@@ -1,14 +1,18 @@
1
1
  import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
2
  import { dirname, join, resolve } from 'node:path';
3
3
  import { homedir } from 'node:os';
4
- import { sanitizeAgentId } from '../domain/agents.js';
4
+ import { sanitizeAgentId, sharedAgentId } from '../domain/agents.js';
5
5
  import { getBrainlinkHomePath, getDefaultVaultPath } from './paths.js';
6
6
  export const defaultBrainlinkConfig = {
7
7
  vault: getDefaultVaultPath(),
8
8
  host: '127.0.0.1',
9
9
  port: 4321,
10
10
  allowedVaults: [],
11
- defaultAgent: undefined,
11
+ // Default every agent (Claude, Codex, GPT, …) to the shared namespace so they
12
+ // all read and write the same memory and see each other's context. Set an
13
+ // explicit `agent` per call, or override defaultAgent in config, to scope to a
14
+ // private namespace.
15
+ defaultAgent: sharedAgentId,
12
16
  autoIndexOnWrite: true,
13
17
  autoCanonicalContextLinks: true,
14
18
  autoVersion: false,
@@ -15,8 +15,17 @@ Use the full potential, not shallow retrieval. Extract the maximum relevant cont
15
15
  - Explainability and quality: brainlink_explain (chunk/score), brainlink_recommendations, brainlink_validate, brainlink_stats, plus link/orphan/duplicate hygiene.
16
16
  - Volatile turn memory: brainlink_volatile_add to stash useful within-session facts (they expire on a TTL); brainlink_volatile_clear to drop them.
17
17
 
18
+ Consult before you act. Before any decision, recommendation, question to the user, or implementation, ground yourself in Brainlink first — never act from memory alone when durable context may exist. This is not optional politeness; it is how you stay coherent across turns and sessions.
19
+
18
20
  Write back — after any durable learning (a preference, decision, pattern, or a change to a repo/config/doc that alters operating context):
19
21
  - Persist it with brainlink_remember (or brainlink_add_note for explicit path/content). Write connected memory: include [[wiki links]], tags, and priority markers so the graph stays navigable — not loose notes.
22
+ - Partition by domain: pass a "context" (e.g. "RSHUB", "Brainlink", "Trading") on every write. Brainlink links the note to that domain's "<context> Hub" (creating it if missing) so the knowledge graph stays split into navigable domains instead of collapsing into one undifferentiated blob. Writing without a context is a degraded mode — always name the domain.
20
23
  - Reindex with brainlink_index and validate with brainlink_validate after writes when applicable.
21
24
 
22
- Capture context early and proactively rather than reconstructing it later. When durability is uncertain but the reuse cost is low, capture anyway; use volatile memory for what is ephemeral. Anchoring everything you learn in Brainlink is what preserves your continuity between turns and sessions.`;
25
+ Persistent and volatile memory use both, like a real brain:
26
+ - Persistent: durable Markdown notes for anything worth remembering across sessions (decisions, preferences, project maps, runbooks, recurring gotchas).
27
+ - Volatile: brainlink_volatile_add for within-session working state — current task, hypotheses, unconfirmed findings, transient preferences. It expires on a TTL; clear it with brainlink_volatile_clear when done. Prefer volatile for the ephemeral so persistent memory stays signal-rich.
28
+
29
+ Shared by default: unless told otherwise, all agents (Claude, Codex, GPT, …) share one namespace ("shared") and one vault, so memory written by any of them is visible to all. Do not silo context into a private namespace without a clear reason — the point is one continuous brain across every agent and session.
30
+
31
+ Capture context early and proactively rather than reconstructing it later. When durability is uncertain but the reuse cost is low, capture anyway; use volatile memory for what is ephemeral. Anchoring everything you learn in Brainlink — connected, partitioned by domain, and shared — is what preserves your continuity between turns, sessions and agents.`;
@@ -1,5 +1,5 @@
1
1
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
- import { addNoteInputSchema, addFileInputSchema, addFileTool, addNoteTool, addNotesInputSchema, addNotesTool, deleteNoteInputSchema, deleteNoteTool, deleteNotesInputSchema, deleteNotesTool, volatileAddInputSchema, volatileAddTool, volatileClearInputSchema, volatileClearTool, dedupeInputSchema, dedupeResolveInputSchema, dedupeResolveTool, dedupeTool, brokenLinksInputSchema, brokenLinksTool, bootstrapInputSchema, bootstrapTool, canonicalizeContextLinksInputSchema, canonicalizeContextLinksTool, contextInputSchema, contextPacksInputSchema, contextPacksTool, contextTool, doctorActionsInputSchema, doctorActionsTool, graphContextsInputSchema, graphContextsTool, graphInputSchema, graphTool, explainInputSchema, explainTool, indexInputSchema, indexTool, inboxAddInputSchema, inboxAddTool, inboxListInputSchema, inboxListTool, inboxProcessInputSchema, inboxProcessTool, orphansInputSchema, orphansTool, policyInputSchema, policyTool, projectInitInputSchema, projectInitTool, recommendationsInputSchema, recommendationsTool, rememberInputSchema, rememberTool, repairLinksInputSchema, repairLinksTool, searchInputSchema, searchTool, similarNotesInputSchema, similarNotesTool, sessionCloseInputSchema, sessionCloseTool, statsInputSchema, statsTool, suggestLinksInputSchema, suggestLinksTool, syncInputSchema, syncTool, validateInputSchema, validateTool, vaultCommitInputSchema, vaultCommitTool, vaultExportInputSchema, vaultExportTool, vaultHistoryInputSchema, vaultHistoryTool, vaultImportInputSchema, vaultImportTool, vaultInitInputSchema, vaultInitTool, vaultProvisionInputSchema, vaultProvisionTool, vaultPullInputSchema, vaultPullTool, vaultRestoreInputSchema, vaultRestoreTool, vaultStatusInputSchema, vaultStatusTool, versionInputSchema, versionTool } from './tools.js';
2
+ import { addNoteInputSchema, addFileInputSchema, addFileTool, addNoteTool, addNotesInputSchema, addNotesTool, deleteNoteInputSchema, deleteNoteTool, deleteNotesInputSchema, deleteNotesTool, volatileAddInputSchema, volatileAddTool, volatileClearInputSchema, volatileClearTool, dedupeInputSchema, dedupeResolveInputSchema, dedupeResolveTool, dedupeTool, brokenLinksInputSchema, brokenLinksTool, bootstrapInputSchema, bootstrapTool, canonicalizeContextLinksInputSchema, canonicalizeContextLinksTool, contextInputSchema, contextPacksInputSchema, contextPacksTool, contextTool, doctorActionsInputSchema, doctorActionsTool, graphContextsInputSchema, graphContextsTool, graphInputSchema, graphTool, explainInputSchema, explainTool, indexInputSchema, indexTool, inboxAddInputSchema, inboxAddTool, inboxListInputSchema, inboxListTool, inboxProcessInputSchema, inboxProcessTool, orphansInputSchema, orphansTool, policyInputSchema, policyTool, projectInitInputSchema, projectInitTool, recommendationsInputSchema, recommendationsTool, rememberInputSchema, rememberTool, repairLinksInputSchema, repairLinksTool, searchInputSchema, searchTool, similarNotesInputSchema, similarNotesTool, sessionCloseInputSchema, sessionCloseTool, statsInputSchema, statsTool, suggestLinksInputSchema, suggestLinksTool, syncInputSchema, syncTool, validateInputSchema, validateTool, vaultCommitInputSchema, vaultCommitTool, vaultExportInputSchema, vaultExportTool, vaultHistoryInputSchema, vaultHistoryTool, vaultImportInputSchema, vaultImportTool, vaultInitInputSchema, vaultInitTool, vaultMergeAgentsInputSchema, vaultMergeAgentsTool, vaultProvisionInputSchema, vaultProvisionTool, vaultPullInputSchema, vaultPullTool, vaultRestoreInputSchema, vaultRestoreTool, vaultStatusInputSchema, vaultStatusTool, versionInputSchema, versionTool } from './tools.js';
3
3
  import { getRuntimeVersion } from './runtime.js';
4
4
  import { brainlinkServerInstructions } from './server-instructions.js';
5
5
  import { guardToolHandler } from './tool-guard.js';
@@ -171,6 +171,11 @@ export const createBrainlinkMcpServer = () => {
171
171
  description: 'Create a private GitHub repository for the vault with the authenticated gh CLI, wire it as origin, push the first snapshot, and enable autoVersion so later indexed changes are committed and pushed automatically. Only Markdown is versioned.',
172
172
  inputSchema: vaultProvisionInputSchema
173
173
  }, vaultProvisionTool);
174
+ server.registerTool('brainlink_vault_merge_agents', {
175
+ title: 'Merge Agent Namespaces',
176
+ description: 'Losslessly merge one agent namespace into another within the same vault (e.g. claude-code → shared): identical notes are skipped, colliding slugs with different content are kept under a "-from-<agent>" name, everything else is moved, and merged notes are aggregated into domain "<context> Hub" notes. Source notes are kept unless deleteSource is set. Use dryRun to preview.',
177
+ inputSchema: vaultMergeAgentsInputSchema
178
+ }, vaultMergeAgentsTool);
174
179
  server.registerTool('brainlink_vault_commit', {
175
180
  title: 'Commit Vault Markdown',
176
181
  description: 'Stage and commit the canonical Markdown (and .gitignore), optionally pushing to origin. Returns null commit when there is nothing to commit.',
@@ -2,6 +2,7 @@ import { z } from 'zod';
2
2
  import { commitVault, initVaultGit, pullVault, pushVault, restoreVault, vaultGitStatus, vaultHistory } from '../../application/vault-git.js';
3
3
  import { provisionVaultRepo } from '../../application/provision-vault.js';
4
4
  import { exportVaultSnapshot, importVaultSnapshot } from '../../application/vault-snapshot.js';
5
+ import { mergeAgentNamespace } from '../../application/merge-agent-namespace.js';
5
6
  import { jsonResult, optionalPositiveInteger, resolveExecutionContext, vaultInput } from './shared.js';
6
7
  export const vaultStatusInputSchema = { ...vaultInput };
7
8
  export const vaultInitInputSchema = {
@@ -45,6 +46,23 @@ export const vaultProvisionInputSchema = {
45
46
  encrypt: z.boolean().optional().default(true).describe('Encrypt the versioned vault: commit AES-256-GCM ciphertext and git-ignore plaintext Markdown. Only a Brainlink instance holding the local key can decrypt.'),
46
47
  message: z.string().min(1).optional().describe('Initial commit message.')
47
48
  };
49
+ export const vaultMergeAgentsInputSchema = {
50
+ ...vaultInput,
51
+ sourceAgent: z.string().min(1).describe('Agent namespace to merge FROM (e.g. "claude-code").'),
52
+ targetAgent: z.string().min(1).describe('Agent namespace to merge INTO (e.g. "shared").'),
53
+ deleteSource: z
54
+ .boolean()
55
+ .optional()
56
+ .default(false)
57
+ .describe('Remove each source note after it is safely present in the target. Defaults to false (non-destructive copy).'),
58
+ linkContexts: z
59
+ .boolean()
60
+ .optional()
61
+ .default(true)
62
+ .describe('Aggregate merged notes into domain "<context> Hub" notes after moving. Defaults to true.'),
63
+ dryRun: z.boolean().optional().default(false).describe('Preview the merge plan without writing anything.'),
64
+ autoIndex: z.boolean().optional().default(true).describe('Reindex the vault once after the merge.')
65
+ };
48
66
  // Vault versioning shells out to the `git` binary and touches the filesystem, so
49
67
  // a missing binary or a git failure is surfaced as an actionable result rather
50
68
  // than an unhandled exception.
@@ -145,6 +163,23 @@ export const vaultImportTool = async (input) => {
145
163
  return failure(context.vault, error);
146
164
  }
147
165
  };
166
+ export const vaultMergeAgentsTool = async (input) => {
167
+ const context = await resolveExecutionContext(input);
168
+ try {
169
+ const result = await mergeAgentNamespace(context.vault, {
170
+ sourceAgent: input.sourceAgent,
171
+ targetAgent: input.targetAgent,
172
+ deleteSource: input.deleteSource,
173
+ linkContexts: input.linkContexts,
174
+ dryRun: input.dryRun,
175
+ autoIndex: input.autoIndex
176
+ });
177
+ return jsonResult({ vault: context.vault, ok: true, ...result });
178
+ }
179
+ catch (error) {
180
+ return failure(context.vault, error);
181
+ }
182
+ };
148
183
  export const vaultProvisionTool = async (input) => {
149
184
  const context = await resolveExecutionContext(input);
150
185
  try {
@@ -16,6 +16,11 @@ export const addNoteInputSchema = {
16
16
  .min(1)
17
17
  .describe('Durable Markdown memory. Include explicit [[wiki links]] and #tags when the memory should be connected. Put priority markers near important links, for example priority: high, #important or #critical.'),
18
18
  ...agentInput,
19
+ context: z
20
+ .string()
21
+ .min(1)
22
+ .optional()
23
+ .describe('Domain context this memory belongs to (e.g. "RSHUB", "Brainlink", "Trading"). Strongly recommended: it partitions the note under a "<context> Hub" so the knowledge graph stays split by domain instead of collapsing into one blob. The hub is created automatically if missing.'),
19
24
  allowSensitive: z.boolean().optional().default(false).describe('Allow content that looks like a secret.'),
20
25
  autoIndex: z.boolean().optional().default(true).describe('Reindex vault after writing note.'),
21
26
  autoContextLinks: z
@@ -28,6 +33,11 @@ export const rememberInputSchema = {
28
33
  ...agentInput,
29
34
  title: z.string().min(1).optional().describe('Optional note title. When omitted, Brainlink infers it from content.'),
30
35
  content: z.string().min(1).describe('Memory content to capture as a durable Markdown note.'),
36
+ context: z
37
+ .string()
38
+ .min(1)
39
+ .optional()
40
+ .describe('Domain context this memory belongs to (e.g. "RSHUB", "Brainlink", "Trading"). Strongly recommended: it partitions the note under a "<context> Hub" so the knowledge graph stays split by domain. The hub is created automatically if missing.'),
31
41
  tags: z.array(z.string()).optional().default([]).describe('Extra tags to include.'),
32
42
  links: z.array(z.string()).optional().default([]).describe('Explicit Context Links to include.'),
33
43
  linkLimit: positiveInteger(5).describe('Maximum suggested Context Links to include.'),
@@ -65,11 +75,21 @@ export const addNotesInputSchema = {
65
75
  notes: z
66
76
  .array(z.object({
67
77
  title: z.string().min(1).describe('Markdown note title.'),
68
- content: z.string().min(1).describe('Durable Markdown memory. Include [[wiki links]] and #tags to connect it.')
78
+ content: z.string().min(1).describe('Durable Markdown memory. Include [[wiki links]] and #tags to connect it.'),
79
+ context: z
80
+ .string()
81
+ .min(1)
82
+ .optional()
83
+ .describe('Per-note domain context override. Falls back to the batch-level context when omitted.')
69
84
  }))
70
85
  .min(1)
71
86
  .max(100)
72
87
  .describe('Notes to add in a single batch. The vault is reindexed once after all notes are written.'),
88
+ context: z
89
+ .string()
90
+ .min(1)
91
+ .optional()
92
+ .describe('Default domain context applied to every note in the batch (partitions them under a "<context> Hub"). Per-note context overrides it. Strongly recommended.'),
73
93
  allowSensitive: z.boolean().optional().default(false).describe('Allow content that looks like a secret.'),
74
94
  autoIndex: z.boolean().optional().default(true).describe('Reindex vault once after writing all notes.'),
75
95
  autoContextLinks: z
@@ -118,7 +138,8 @@ export const addNoteTool = async (input) => {
118
138
  const shouldIndex = isTruthy(input.autoIndex);
119
139
  const added = await addNoteWithMetadata(context.vault, input.title, input.content, context.agent, {
120
140
  allowSensitive: input.allowSensitive,
121
- autoContextLinks: input.autoContextLinks ?? context.config.autoCanonicalContextLinks
141
+ autoContextLinks: input.autoContextLinks ?? context.config.autoCanonicalContextLinks,
142
+ context: input.context
122
143
  });
123
144
  const index = shouldIndex ? await indexVault(context.vault) : undefined;
124
145
  const focusPath = added.path.includes('agents/') ? added.path.slice(added.path.indexOf('agents/')).replaceAll('\\', '/') : undefined;
@@ -166,7 +187,10 @@ export const rememberTool = async (input) => {
166
187
  }
167
188
  const added = await addNoteWithMetadata(context.vault, suggestion.title, suggestion.content, context.agent, {
168
189
  allowSensitive: input.allowSensitive,
169
- autoContextLinks: false
190
+ // remember already curates its own Context Links; only force a canonical hub
191
+ // link when the caller pins an explicit domain context to partition it.
192
+ autoContextLinks: false,
193
+ context: input.context
170
194
  });
171
195
  const index = input.autoIndex ? await indexVault(context.vault) : undefined;
172
196
  return jsonResult({
@@ -175,6 +199,13 @@ export const rememberTool = async (input) => {
175
199
  agent: context.agent,
176
200
  suggestion,
177
201
  path: added.path,
202
+ writeConnectivity: {
203
+ autoLinked: added.autoLinked,
204
+ linkTarget: added.linkTarget,
205
+ context: added.context,
206
+ hubCreated: added.hubCreated,
207
+ guaranteedEdge: added.autoLinked
208
+ },
178
209
  ...(index ? { index } : {})
179
210
  });
180
211
  };
@@ -239,7 +270,8 @@ export const addNotesTool = async (input) => {
239
270
  try {
240
271
  const added = await addNoteWithMetadata(context.vault, note.title, note.content, context.agent, {
241
272
  allowSensitive: input.allowSensitive,
242
- autoContextLinks
273
+ autoContextLinks,
274
+ context: note.context ?? input.context
243
275
  });
244
276
  notes.push({
245
277
  ok: true,
package/dist/mcp/tools.js CHANGED
@@ -1,4 +1,4 @@
1
1
  export { brokenLinksInputSchema, brokenLinksTool, contextInputSchema, contextPacksInputSchema, contextPacksTool, contextTool, doctorActionsInputSchema, doctorActionsTool, explainInputSchema, explainTool, graphContextsInputSchema, graphContextsTool, graphInputSchema, graphTool, orphansInputSchema, orphansTool, recommendationsInputSchema, recommendationsTool, searchInputSchema, searchTool, similarNotesInputSchema, similarNotesTool, statsInputSchema, statsTool, suggestLinksInputSchema, suggestLinksTool, validateInputSchema, validateTool, versionInputSchema, versionTool } from './tools/read-tools.js';
2
2
  export { addFileInputSchema, addFileTool, addNoteInputSchema, addNoteTool, addNotesInputSchema, addNotesTool, deleteNoteInputSchema, deleteNoteTool, deleteNotesInputSchema, deleteNotesTool, inboxAddInputSchema, inboxAddTool, inboxListInputSchema, inboxListTool, inboxProcessInputSchema, inboxProcessTool, rememberInputSchema, rememberTool, volatileAddInputSchema, volatileAddTool, volatileClearInputSchema, volatileClearTool } from './tools/write-tools.js';
3
3
  export { bootstrapInputSchema, bootstrapTool, canonicalizeContextLinksInputSchema, canonicalizeContextLinksTool, dedupeInputSchema, dedupeResolveInputSchema, dedupeResolveTool, dedupeTool, indexInputSchema, indexTool, policyInputSchema, policyTool, projectInitInputSchema, projectInitTool, repairLinksInputSchema, repairLinksTool, sessionCloseInputSchema, sessionCloseTool, syncInputSchema, syncTool } from './tools/maintenance-tools.js';
4
- export { vaultCommitInputSchema, vaultCommitTool, vaultExportInputSchema, vaultExportTool, vaultHistoryInputSchema, vaultHistoryTool, vaultImportInputSchema, vaultImportTool, vaultInitInputSchema, vaultInitTool, vaultProvisionInputSchema, vaultProvisionTool, vaultPullInputSchema, vaultPullTool, vaultRestoreInputSchema, vaultRestoreTool, vaultStatusInputSchema, vaultStatusTool } from './tools/vault-tools.js';
4
+ export { vaultCommitInputSchema, vaultCommitTool, vaultExportInputSchema, vaultExportTool, vaultHistoryInputSchema, vaultHistoryTool, vaultImportInputSchema, vaultImportTool, vaultInitInputSchema, vaultInitTool, vaultMergeAgentsInputSchema, vaultMergeAgentsTool, vaultProvisionInputSchema, vaultProvisionTool, vaultPullInputSchema, vaultPullTool, vaultRestoreInputSchema, vaultRestoreTool, vaultStatusInputSchema, vaultStatusTool } from './tools/vault-tools.js';
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@andespindola/brainlink",
3
- "version": "1.3.0",
3
+ "version": "1.5.0",
4
4
  "description": "Local-first knowledge memory for agents with Markdown, backlinks, indexing and context retrieval.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
- "author": "Substructa",
7
+ "author": "Anderson Espindola",
8
8
  "homepage": "https://github.com/andersonflima/brainlink#readme",
9
9
  "repository": {
10
10
  "type": "git",