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

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/README.md CHANGED
@@ -82,14 +82,14 @@ Legacy `.jsonl.gz` packs are upgraded to `.blpk` automatically on first search/c
82
82
  - Built-in MCP stdio server for agent tool integration.
83
83
  - Local HTTP API.
84
84
  - Realtime graph UI with agent selector and colored knowledge groups.
85
- - Graph renderer uses a star layout centered on the primary hub while preserving real weighted `[[wiki link]]` edges for backlinks, ranking and context traversal.
85
+ - Graph renderer uses a cauliflower-style hub layout: the primary hub stays centered, segment hubs anchor surrounding lobes, and real weighted `[[wiki link]]` edges stay preserved for backlinks, ranking and context traversal.
86
86
  - The full filtered graph stays visible during zoom/pan, rendering every visible node and edge without viewport culling or edge caps in the main view.
87
87
  - Graph exploration uses viewport-first chunk streaming (`/api/graph-stream`) with explicit node/edge budgets.
88
88
  - Render pipeline uses WebGL in a dedicated worker through `OffscreenCanvas`, keeping the main thread focused on UI controls and details panels.
89
89
  - Large graph layout API automatically uses compact payload encoding with link-coverage-aware edge selection to reduce initial client load without hiding major relationships.
90
90
  - Large-segment layout spacing now grows logarithmically to keep initial visual density consistent between medium and very large vaults (for example, ~1k vs ~50k notes).
91
91
  - Graph coordinates are visually compacted across graph sizes so reset starts from a stable fitted scene and zoom-in progressively reveals local detail.
92
- - Zoomed-out graph keeps the same flat graph scene and preserves complete filtered relationships without switching to nested subgraphs.
92
+ - Zoomed-out graph summarizes the scene as segment hub clusters, then progressively reveals individual nodes as the user zooms in.
93
93
  - Graph reset fits the full graph scene instead of starting in a separate macro overview mode.
94
94
  - Graph filtering runs in a dedicated browser worker to keep the UI thread responsive during heavy datasets.
95
95
  - Node titles are shown as the user zooms closer, while labels remain bounded to visible on-screen nodes in very large graphs.
@@ -600,11 +600,11 @@ The graph UI shows:
600
600
 
601
601
  - notes as nodes
602
602
  - all non-self `[[wiki links]]` inside `## Context Links` as weighted edges
603
- - default star layout centered on the primary hub, without rewriting or flattening underlying relationships
603
+ - default cauliflower-style layout centered on the primary hub, with segment hubs anchoring surrounding lobes and without rewriting or flattening underlying relationships
604
604
  - details opened in a non-modal side panel (tags, outgoing links, backlinks, full Markdown content), so zoom and pan remain available while inspecting data
605
605
  - neutral graph nodes with segment/group metadata
606
606
  - agent selector (id-only labels) for isolated views
607
- - context selector for segment-scoped star subgraphs derived from the visual graph context
607
+ - context selector for segment-scoped cauliflower subgraphs derived from the visual graph context
608
608
  - graph filter matches title, path, tags and note content
609
609
  - graph filter keeps hub context nodes visible (`Memory Hub`/`MOC`/high-degree fallback) to preserve relationship readability
610
610
  - realtime refresh while watch mode is enabled
@@ -612,14 +612,14 @@ The graph UI shows:
612
612
  - wheel zoom (including `cmd+scroll` and `ctrl+scroll`) anchored to cursor position for faster navigation in large graphs
613
613
  - wheel/button zoom updates immediately at the cursor anchor without delayed focus-transition interpolation
614
614
  - Bloom-like scene navigation: reset fits the current graph scene, wheel zoom stays anchored to the cursor, and worker-driven WebGL rendering keeps pan/zoom interaction responsive
615
- - zoom-out floor for large and massive graphs to keep the scene reachable without switching into a separate macro graph mode
615
+ - zoom-out cluster mode that shows segment hub clusters first, then reveals local nodes as zoom increases
616
616
  - keyboard shortcuts: `+` zoom in, `-` zoom out, `0` reset fit
617
617
  - click on a node opens its details panel; double-click on empty canvas zooms in at cursor position
618
618
  - floating graph totals (notes, links, tags) below the Brainlink title
619
619
  - graph rendering safeguards (batched GPU draw calls, lower redraw rate, zoom-aware interaction)
620
620
  - adaptive CPU safeguards for large graphs: idle frame pacing, throttled background physics updates and cached viewport dimensions to reduce redraw/layout overhead while preserving interaction responsiveness
621
621
  - worker-first WebGL rendering with Canvas fallback when `OffscreenCanvas` or worker rendering is unavailable
622
- - large graph view keeps a single-level graph model across zoom levels, renders the full filtered scene instead of viewport-sampled subsets, and shows node titles as zoom approaches readable scale
622
+ - large graph view keeps one semantic graph model across zoom levels, uses segment clusters at high zoom-out, and shows node titles as zoom approaches readable scale
623
623
 
624
624
  The server indexes before starting by default. Use `--no-index` to skip that step:
625
625
 
@@ -1,5 +1,5 @@
1
1
  export const createClientJs = () => `const canvas = document.getElementById('graph')
2
- const ctx2dFallback = canvas.getContext('2d')
2
+ let ctx2dFallback = null
3
3
  const byId = (id) => document.getElementById(id)
4
4
  const elements = {
5
5
  search: byId('search'),
@@ -476,7 +476,11 @@ const setFocusedNodeIds = (ids) => {
476
476
  }
477
477
 
478
478
  const drawFallback = () => {
479
- if (state.rendererMode !== 'fallback' || !ctx2dFallback) {
479
+ if (state.rendererMode !== 'fallback') {
480
+ return
481
+ }
482
+ ctx2dFallback = ctx2dFallback ?? canvas.getContext('2d')
483
+ if (!ctx2dFallback) {
480
484
  return
481
485
  }
482
486
  const width = state.viewport.width
@@ -1,19 +1,33 @@
1
1
  import { getGraphLayout } from './get-graph-layout.js';
2
2
  export const getGraphContexts = async (vaultPath, agentId) => {
3
3
  const { layout } = await getGraphLayout(vaultPath, { agentId });
4
- const nodeIdsByContext = layout.nodes.reduce((contexts, node) => {
4
+ const nodeIdsByContext = new Map();
5
+ const contextByNodeId = new Map();
6
+ layout.nodes.forEach((node) => {
5
7
  const title = node.segment || node.group || 'root';
6
- const nodeIds = contexts.get(title) ?? new Set();
8
+ const nodeIds = nodeIdsByContext.get(title) ?? new Set();
7
9
  nodeIds.add(node.id);
8
- contexts.set(title, nodeIds);
9
- return contexts;
10
- }, new Map());
10
+ nodeIdsByContext.set(title, nodeIds);
11
+ contextByNodeId.set(node.id, title);
12
+ });
13
+ const edgeCountByContext = new Map();
14
+ layout.edges.forEach((edge) => {
15
+ if (!edge.target) {
16
+ return;
17
+ }
18
+ const sourceContext = contextByNodeId.get(edge.source);
19
+ const targetContext = contextByNodeId.get(edge.target);
20
+ if (!sourceContext || sourceContext !== targetContext) {
21
+ return;
22
+ }
23
+ edgeCountByContext.set(sourceContext, (edgeCountByContext.get(sourceContext) ?? 0) + 1);
24
+ });
11
25
  return Array.from(nodeIdsByContext.entries())
12
26
  .map(([title, nodeIds]) => ({
13
27
  id: title,
14
28
  title,
15
29
  nodeCount: nodeIds.size,
16
- edgeCount: layout.edges.filter((edge) => nodeIds.has(edge.source) && Boolean(edge.target && nodeIds.has(edge.target))).length
30
+ edgeCount: edgeCountByContext.get(title) ?? 0
17
31
  }))
18
32
  .sort((left, right) => right.nodeCount - left.nodeCount || left.title.localeCompare(right.title));
19
33
  };
@@ -2,10 +2,10 @@ import { createHash } from 'node:crypto';
2
2
  import { mkdir, readFile, rename, stat, writeFile } from 'node:fs/promises';
3
3
  import { dirname, join } from 'node:path';
4
4
  import { addVisualContextEdges } from '../domain/graph-contexts.js';
5
- import { createStarGraphLayout } from '../domain/graph-layout.js';
5
+ import { createCauliflowerGraphLayout } from '../domain/graph-layout.js';
6
6
  import { indexStoragePath } from '../infrastructure/file-index.js';
7
7
  import { getGraphSummary } from './get-graph-summary.js';
8
- const graphLayoutVersion = 6;
8
+ const graphLayoutVersion = 7;
9
9
  const graphLayoutCache = new Map();
10
10
  const safeCacheSegment = (value, fallback) => value?.replace(/[^a-zA-Z0-9_-]/g, '_') || fallback;
11
11
  const graphLayoutStoragePath = (vaultPath, options) => {
@@ -48,14 +48,14 @@ const createGraphSignature = (graph) => {
48
48
  .digest('hex');
49
49
  };
50
50
  const createLayout = (graph) => {
51
- const rawLayout = createStarGraphLayout(graph);
51
+ const rawLayout = createCauliflowerGraphLayout(graph);
52
52
  return {
53
53
  ...rawLayout,
54
54
  nodes: rawLayout.nodes.map((node) => ({ ...node, content: '' }))
55
55
  };
56
56
  };
57
57
  const filterGraphByContext = (graph, context) => {
58
- const baseLayout = createStarGraphLayout(graph);
58
+ const baseLayout = createCauliflowerGraphLayout(graph);
59
59
  const selectedNodeIds = new Set(baseLayout.nodes
60
60
  .filter((node) => node.segment === context)
61
61
  .map((node) => node.id));
@@ -145,8 +145,8 @@ const selectMidNodes = (nodes, cache, input) => {
145
145
  relevance.get(node.id) ?? 0
146
146
  ]);
147
147
  };
148
- const selectFarClusters = (groups, input, nodeBudget) => {
149
- const roots = groups.filter((group) => group.parentId === null);
148
+ const selectFarClusters = (nodes, input, nodeBudget) => {
149
+ const roots = createSegmentClusters(nodes);
150
150
  const padding = Math.max(input.width, input.height) * 0.12;
151
151
  const candidates = roots.filter((group) => inViewport(group, input, padding));
152
152
  const relevance = new Map();
@@ -166,10 +166,42 @@ const selectFarClusters = (groups, input, nodeBudget) => {
166
166
  relevance.get(group.id) ?? 0
167
167
  ]);
168
168
  };
169
+ const createSegmentClusters = (nodes) => {
170
+ const nodesBySegment = new Map();
171
+ nodes.forEach((node) => {
172
+ const bucket = nodesBySegment.get(node.segment) ?? [];
173
+ bucket.push(node);
174
+ nodesBySegment.set(node.segment, bucket);
175
+ });
176
+ return Array.from(nodesBySegment.entries()).map(([segment, segmentNodes]) => {
177
+ const center = segmentNodes.reduce((state, node) => ({
178
+ x: state.x + node.x,
179
+ y: state.y + node.y
180
+ }), { x: 0, y: 0 });
181
+ const x = center.x / Math.max(segmentNodes.length, 1);
182
+ const y = center.y / Math.max(segmentNodes.length, 1);
183
+ const radius = segmentNodes.reduce((largest, node) => Math.max(largest, Math.hypot(node.x - x, node.y - y)), 80);
184
+ return {
185
+ id: `segment:${segment}`,
186
+ level: 0,
187
+ parentId: null,
188
+ title: segment,
189
+ segment,
190
+ group: segmentNodes[0]?.group ?? segment,
191
+ x,
192
+ y,
193
+ radius: Math.max(radius + 96, 160),
194
+ nodeIds: segmentNodes.map((node) => node.id),
195
+ childGroupIds: [],
196
+ internalEdges: [],
197
+ externalEdges: []
198
+ };
199
+ });
200
+ };
169
201
  const collectEdgesForNodes = (allEdges, cache, nodeRows, edgeBudget, maxEdgesPerNode) => {
170
202
  const isClusterMode = nodeRows.length > 0 && nodeRows[0]?.[6] === 'cluster';
171
203
  if (isClusterMode) {
172
- const clusterIds = new Set(nodeRows.map((row) => row[0].replace(/^cluster:/, '')));
204
+ const clusterSegments = new Set(nodeRows.map((row) => row[5]));
173
205
  const clusterEdges = new Map();
174
206
  for (let index = 0; index < allEdges.length; index += 1) {
175
207
  const edge = allEdges[index];
@@ -179,9 +211,9 @@ const collectEdgesForNodes = (allEdges, cache, nodeRows, edgeBudget, maxEdgesPer
179
211
  const targetNode = cache.nodeById.get(edge.target);
180
212
  if (!sourceNode || !targetNode)
181
213
  continue;
182
- const sourceCluster = sourceNode.group;
183
- const targetCluster = targetNode.group;
184
- if (!clusterIds.has(sourceCluster) || !clusterIds.has(targetCluster) || sourceCluster === targetCluster)
214
+ const sourceCluster = sourceNode.segment;
215
+ const targetCluster = targetNode.segment;
216
+ if (!clusterSegments.has(sourceCluster) || !clusterSegments.has(targetCluster) || sourceCluster === targetCluster)
185
217
  continue;
186
218
  const key = sourceCluster < targetCluster ? `${sourceCluster}|${targetCluster}` : `${targetCluster}|${sourceCluster}`;
187
219
  const current = clusterEdges.get(key);
@@ -193,12 +225,12 @@ const collectEdgesForNodes = (allEdges, cache, nodeRows, edgeBudget, maxEdgesPer
193
225
  .sort((left, right) => edgeRank(right) - edgeRank(left))
194
226
  .slice(0, Math.max(1, edgeBudget))
195
227
  .map((edge) => [
196
- `cluster:${cache.nodeById.get(edge.source)?.group ?? ''}`,
197
- `cluster:${cache.nodeById.get(edge.target ?? '')?.group ?? ''}`,
228
+ `cluster:segment:${cache.nodeById.get(edge.source)?.segment ?? ''}`,
229
+ `cluster:segment:${cache.nodeById.get(edge.target ?? '')?.segment ?? ''}`,
198
230
  edge.weight,
199
231
  edge.priority
200
232
  ])
201
- .filter((edge) => edge[0] !== edge[1] && edge[0] !== 'cluster:' && edge[1] !== 'cluster:');
233
+ .filter((edge) => edge[0] !== edge[1] && edge[0] !== 'cluster:segment:' && edge[1] !== 'cluster:segment:');
202
234
  }
203
235
  const nodeIds = new Set(nodeRows.map((row) => row[0]));
204
236
  const collected = new Map();
@@ -267,8 +299,8 @@ export const getGraphStreamChunk = async (vaultPath, input) => {
267
299
  : input.scale < midScaleThreshold
268
300
  ? 'mid'
269
301
  : 'near';
270
- const nodes = mode === 'far' && groups.length > 0
271
- ? selectFarClusters(groups, input, nodeBudget)
302
+ const nodes = mode === 'far'
303
+ ? selectFarClusters(layout.nodes, input, nodeBudget)
272
304
  : mode === 'mid'
273
305
  ? selectMidNodes(layout.nodes, cache, {
274
306
  ...input,
@@ -193,29 +193,66 @@ const compareByStarOrder = (levels, degrees, segmentIndexByName, segments) => (l
193
193
  const degreeDelta = (degrees.get(right.id) ?? 0) - (degrees.get(left.id) ?? 0);
194
194
  return degreeDelta === 0 ? left.title.localeCompare(right.title) : degreeDelta;
195
195
  };
196
- const petalSpreadForSegmentSize = (size) => {
196
+ const petalRadiusForSegmentSize = (size) => {
197
197
  const safeSize = Math.max(size, 1);
198
- return 180 + Math.log2(safeSize + 1) * 6;
198
+ return Math.max(260, Math.sqrt(safeSize) * 96);
199
199
  };
200
- const createSegmentNodes = (segments, degrees, segmentCount) => ([segment, nodes], segmentIndex) => {
200
+ const selectSegmentHub = (nodes, degrees, primaryHubId) => {
201
+ const primary = nodes.find((node) => node.id === primaryHubId);
202
+ if (primary) {
203
+ return primary;
204
+ }
205
+ return [...nodes].sort((left, right) => {
206
+ const hubDelta = hubScore(right) - hubScore(left);
207
+ if (hubDelta !== 0)
208
+ return hubDelta;
209
+ const degreeDelta = (degrees.get(right.id) ?? 0) - (degrees.get(left.id) ?? 0);
210
+ if (degreeDelta !== 0)
211
+ return degreeDelta;
212
+ return left.title.localeCompare(right.title);
213
+ })[0] ?? null;
214
+ };
215
+ const segmentCenterRadius = (segments) => {
216
+ if (segments.length <= 1) {
217
+ return 0;
218
+ }
219
+ const circumference = segments.reduce((total, [, nodes]) => total + petalRadiusForSegmentSize(nodes.length) * 2 + 180, 0);
220
+ return Math.max(520, circumference / (Math.PI * 2));
221
+ };
222
+ const createCauliflowerSegmentNodes = (segments, degrees, primaryHubId, segmentGroups) => ([segment, nodes], segmentIndex) => {
201
223
  const sortedNodes = [...nodes].sort(byDegreeThenTitle(degrees));
202
- const angle = segmentAngle(segment, segmentIndex, segmentCount);
203
- const baseRadius = segmentCount === 1 ? 0 : 340 + Math.min(sortedNodes.length, 22) * 10;
204
- const centerX = Math.cos(angle) * baseRadius;
205
- const centerY = Math.sin(angle) * (baseRadius * 0.78);
206
- const petalSpread = petalSpreadForSegmentSize(sortedNodes.length);
207
- return sortedNodes.map((node, index) => {
208
- const localAngle = index * 2.399963 + jitter(node.title, 0.42);
209
- const localRadius = Math.sqrt(index + 1) * petalSpread;
210
- const hubPull = segmentCount === 1 ? 0 : Math.min(degrees.get(node.id) ?? 0, 12) * 12;
224
+ const segmentHub = selectSegmentHub(sortedNodes, degrees, primaryHubId);
225
+ const angle = segmentAngle(segment, segmentIndex, segmentGroups.length);
226
+ const globalRadius = segmentCenterRadius(segmentGroups);
227
+ const petalRadius = petalRadiusForSegmentSize(sortedNodes.length);
228
+ const isPrimarySegment = Boolean(segmentHub && segmentHub.id === primaryHubId);
229
+ const centerX = isPrimarySegment || globalRadius === 0 ? 0 : Math.cos(angle) * globalRadius;
230
+ const centerY = isPrimarySegment || globalRadius === 0 ? 0 : Math.sin(angle) * (globalRadius * 0.86);
231
+ const nonHubNodes = sortedNodes.filter((node) => node.id !== segmentHub?.id);
232
+ const hubNode = segmentHub
233
+ ? [{
234
+ ...segmentHub,
235
+ group: groupLabel(groupKey(segmentHub)),
236
+ segment: segments.get(segmentHub.id) ?? segment,
237
+ x: centerX,
238
+ y: centerY
239
+ }]
240
+ : [];
241
+ const petalNodes = nonHubNodes.map((node, index) => {
242
+ const localAngle = index * 2.399963 + jitter(node.title, 0.5);
243
+ const radialLayer = Math.sqrt(index + 1) / Math.sqrt(Math.max(nonHubNodes.length, 1));
244
+ const localRadius = 150 + radialLayer * petalRadius + jitter(node.id, 34);
245
+ const degreePull = Math.min(degrees.get(node.id) ?? 0, 16) * 8;
246
+ const radius = Math.max(126, localRadius - degreePull);
211
247
  return {
212
248
  ...node,
213
249
  group: groupLabel(groupKey(node)),
214
250
  segment: segments.get(node.id) ?? segment,
215
- x: centerX + Math.cos(localAngle) * localRadius - Math.cos(angle) * hubPull + jitter(node.id, 24),
216
- y: centerY + Math.sin(localAngle) * localRadius * 0.78 - Math.sin(angle) * hubPull + jitter(node.path, 24)
251
+ x: centerX + Math.cos(localAngle) * radius + jitter(node.title, 20),
252
+ y: centerY + Math.sin(localAngle) * radius * 0.84 + jitter(node.path, 20)
217
253
  };
218
254
  });
255
+ return [...hubNode, ...petalNodes];
219
256
  };
220
257
  const distanceBetween = (left, right) => Math.hypot(right.x - left.x, right.y - left.y);
221
258
  const layoutBounds = (nodes) => {
@@ -539,8 +576,8 @@ export const createCauliflowerGraphLayout = (graph) => {
539
576
  const segments = assignSegments(graph.nodes, graph.edges, degrees);
540
577
  const segmentGroups = Array.from(groupNodesBySegment(graph.nodes, segments).entries())
541
578
  .sort(([left], [right]) => left.localeCompare(right));
542
- const nodes = relaxCollisions(segmentGroups.flatMap(createSegmentNodes(segments, degrees, segmentGroups.length)));
543
579
  const primaryHubId = selectPrimaryHubId(graph.nodes, degrees);
580
+ const nodes = relaxCollisions(segmentGroups.flatMap(createCauliflowerSegmentNodes(segments, degrees, primaryHubId, segmentGroups)), 156, 28);
544
581
  const centeredNodes = centerLayoutByNode(nodes, primaryHubId);
545
582
  const groups = createGraphLayoutHierarchy(centeredNodes, graph.edges, degrees);
546
583
  return {
@@ -610,7 +610,7 @@ Without `--vault`, the graph UI serves `$HOME/.brainlink/vault`.
610
610
 
611
611
  The frontend includes an agent selector that shows only the agent id. Selecting an agent calls the same read APIs with `agent=<agent-id>` and renders that namespace instead of merging every agent into one graph.
612
612
 
613
- Graph navigation controls include zoom in, zoom out, fit visible nodes and reset-to-fit-all nodes. Mouse wheel zoom (including `cmd+scroll` and `ctrl+scroll`) is anchored to the cursor and applied immediately without delayed focus interpolation. Keyboard shortcuts are `+` (zoom in), `-` (zoom out) and `0` (reset fit). Double-click on empty canvas zooms in at cursor position. Clicking a node opens its details panel. Totals for notes, links and tags stay visible as floating metrics under the Brainlink title, and node details open in a non-modal side panel (tags, outgoing links, backlinks and Markdown content), so zoom and pan remain available during inspection. The visual layout is a star centered on the primary hub, while underlying weighted wiki-link edges are preserved for backlinks, ranking and context traversal. Vaults above 1000 notes keep the same single graph scene and render the full filtered node/edge set during navigation, without viewport-sampled subgraph switching. Node titles appear as zoom approaches readable scale, limited to on-screen nodes in very large graphs.
613
+ Graph navigation controls include zoom in, zoom out, fit visible nodes and reset-to-fit-all nodes. Mouse wheel zoom (including `cmd+scroll` and `ctrl+scroll`) is anchored to the cursor and applied immediately without delayed focus interpolation. Keyboard shortcuts are `+` (zoom in), `-` (zoom out) and `0` (reset fit). Double-click on empty canvas zooms in at cursor position. Clicking a node opens its details panel. Totals for notes, links and tags stay visible as floating metrics under the Brainlink title, and node details open in a non-modal side panel (tags, outgoing links, backlinks and Markdown content), so zoom and pan remain available during inspection. The visual layout is a cauliflower hub layout: the primary hub stays centered, segment hubs anchor surrounding lobes, and underlying weighted wiki-link edges are preserved for backlinks, ranking and context traversal. At high zoom-out, graph streams show segment hub clusters first; zooming in progressively reveals the individual nodes inside those lobes. Node titles appear as zoom approaches readable scale, limited to on-screen nodes in very large graphs.
614
614
  During graph filtering, Brainlink keeps hub context nodes visible (`Memory Hub`/`MOC`/high-degree fallback) so filtered views still show relationship anchors.
615
615
 
616
616
  The command reindexes by default, then serves:
@@ -156,13 +156,13 @@ server command
156
156
  -> /api/agents lists indexed namespaces
157
157
  -> /api/graph-contexts lists visual graph contexts
158
158
  -> /api/graph reads indexed documents and links
159
- -> /api/graph-layout derives a star layout from indexed graph data
160
- -> optional context query narrows the layout to a segment-scoped star subgraph
159
+ -> /api/graph-layout derives a cauliflower hub layout from indexed graph data
160
+ -> optional context query narrows the layout to a segment-scoped cauliflower subgraph
161
161
  -> browser renders graph canvas
162
162
  ```
163
163
 
164
164
  The graph UI is intentionally read-only. Markdown remains the write interface and index artifacts remain derived data.
165
- The star layout is visual only: it keeps the indexed weighted wiki-link edges unchanged so backlinks, ranking and context traversal keep their original semantics.
165
+ The cauliflower layout is visual only: it keeps the indexed weighted wiki-link edges unchanged so backlinks, ranking and context traversal keep their original semantics. The primary hub is centered, segment hubs anchor surrounding lobes, and zoomed-out graph streams summarize those lobes as segment clusters before revealing individual nodes on zoom-in.
166
166
 
167
167
  ## HTTP API Flow
168
168
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andespindola/brainlink",
3
- "version": "0.1.0-beta.155",
3
+ "version": "0.1.0-beta.157",
4
4
  "description": "Local-first knowledge memory for agents with Markdown, backlinks, indexing and context retrieval.",
5
5
  "type": "module",
6
6
  "license": "MIT",