@andespindola/brainlink 1.4.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,12 @@
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
+
3
10
  ## 1.4.0
4
11
 
5
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.
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
@@ -1456,7 +1456,7 @@ See [CONTRIBUTING.md](CONTRIBUTING.md).
1456
1456
  ## License
1457
1457
 
1458
1458
  MIT. See [LICENSE](LICENSE).
1459
- Copyright (c) 2026 Substructa. See [COPYRIGHT.md](COPYRIGHT.md).
1459
+ Copyright (c) 2026 Anderson Espindola. See [COPYRIGHT.md](COPYRIGHT.md).
1460
1460
 
1461
1461
  ### Memory Optimization Loop (1-7)
1462
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>
@@ -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,
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@andespindola/brainlink",
3
- "version": "1.4.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",