@andespindola/brainlink 0.1.0-beta.149 → 0.1.0-beta.150

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,7 +82,8 @@ 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 keeps the full filtered graph visible during zoom/pan, rendering every visible node and edge without viewport culling or edge caps in the main view.
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.
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.
86
87
  - Graph exploration uses viewport-first chunk streaming (`/api/graph-stream`) with explicit node/edge budgets.
87
88
  - Render pipeline uses WebGL in a dedicated worker through `OffscreenCanvas`, keeping the main thread focused on UI controls and details panels.
88
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.
@@ -589,6 +590,7 @@ The graph UI shows:
589
590
 
590
591
  - notes as nodes
591
592
  - representative `[[wiki links]]` as weighted edges
593
+ - star layout centered on the primary hub, without rewriting or flattening underlying relationships
592
594
  - details opened in a non-modal side panel (tags, outgoing links, backlinks, full Markdown content), so zoom and pan remain available while inspecting data
593
595
  - neutral graph nodes with segment/group metadata
594
596
  - agent selector (id-only labels) for isolated views
@@ -1,10 +1,10 @@
1
1
  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
- import { createCauliflowerGraphLayout } from '../domain/graph-layout.js';
4
+ import { createStarGraphLayout } from '../domain/graph-layout.js';
5
5
  import { indexStoragePath } from '../infrastructure/file-index.js';
6
6
  import { getGraphSummary } from './get-graph-summary.js';
7
- const graphLayoutVersion = 2;
7
+ const graphLayoutVersion = 3;
8
8
  const graphLayoutCache = new Map();
9
9
  const graphLayoutStoragePath = (vaultPath, agentId) => join(vaultPath, '.brainlink', `graph-layout-${agentId?.replace(/[^a-zA-Z0-9_-]/g, '_') ?? 'all'}.json`);
10
10
  const readPersistedLayout = async (vaultPath, databaseSignature, agentId) => {
@@ -61,7 +61,7 @@ export const getGraphLayout = async (vaultPath, agentId) => {
61
61
  }
62
62
  const graph = await getGraphSummary(vaultPath, agentId);
63
63
  const signature = createGraphSignature(graph);
64
- const rawLayout = createCauliflowerGraphLayout(graph);
64
+ const rawLayout = createStarGraphLayout(graph);
65
65
  const layout = {
66
66
  ...rawLayout,
67
67
  nodes: rawLayout.nodes.map((node) => ({ ...node, content: '' }))
@@ -88,6 +88,10 @@ const selectPrimaryHubId = (nodes, degrees) => {
88
88
  });
89
89
  return ranked[0]?.id ?? null;
90
90
  };
91
+ const selectHighestDegreeNodeId = (nodes, degrees) => [...nodes].sort((left, right) => {
92
+ const degreeDelta = (degrees.get(right.id) ?? 0) - (degrees.get(left.id) ?? 0);
93
+ return degreeDelta === 0 ? left.title.localeCompare(right.title) : degreeDelta;
94
+ })[0]?.id ?? null;
91
95
  const centerLayoutByNode = (nodes, nodeId) => {
92
96
  if (!nodeId) {
93
97
  return nodes;
@@ -168,6 +172,18 @@ const groupNodesBySegment = (nodes, segments) => {
168
172
  return new Map(groups);
169
173
  };
170
174
  const segmentAngle = (segment, index, count) => segmentAngles[segment] ?? (Math.PI * 2 * index) / Math.max(count, 1) - Math.PI / 2;
175
+ const compareByStarOrder = (levels, degrees, segmentIndexByName, segments) => (left, right) => {
176
+ const levelDelta = (levels.get(left.id) ?? 1) - (levels.get(right.id) ?? 1);
177
+ if (levelDelta !== 0)
178
+ return levelDelta;
179
+ const leftSegment = segments.get(left.id) ?? groupLabel(groupKey(left));
180
+ const rightSegment = segments.get(right.id) ?? groupLabel(groupKey(right));
181
+ const segmentDelta = (segmentIndexByName.get(leftSegment) ?? 0) - (segmentIndexByName.get(rightSegment) ?? 0);
182
+ if (segmentDelta !== 0)
183
+ return segmentDelta;
184
+ const degreeDelta = (degrees.get(right.id) ?? 0) - (degrees.get(left.id) ?? 0);
185
+ return degreeDelta === 0 ? left.title.localeCompare(right.title) : degreeDelta;
186
+ };
171
187
  const petalSpreadForSegmentSize = (size) => {
172
188
  const safeSize = Math.max(size, 1);
173
189
  return 180 + Math.log2(safeSize + 1) * 6;
@@ -414,6 +430,84 @@ const relaxCollisions = (nodes, minDistance = 132, rounds = 32) => {
414
430
  }
415
431
  return layoutNodes;
416
432
  };
433
+ const assignStarLevels = (nodes, edges, hubId) => {
434
+ if (!hubId) {
435
+ return new Map(nodes.map((node) => [node.id, 1]));
436
+ }
437
+ const adjacency = createAdjacency(nodes, edges);
438
+ const levels = new Map([[hubId, 0]]);
439
+ const queue = [hubId];
440
+ for (let index = 0; index < queue.length; index += 1) {
441
+ const id = queue[index];
442
+ const nextLevel = (levels.get(id) ?? 0) + 1;
443
+ (adjacency.get(id) ?? []).forEach((nextId) => {
444
+ if (!levels.has(nextId)) {
445
+ levels.set(nextId, nextLevel);
446
+ queue.push(nextId);
447
+ }
448
+ });
449
+ }
450
+ nodes.forEach((node) => {
451
+ if (!levels.has(node.id)) {
452
+ levels.set(node.id, 2);
453
+ }
454
+ });
455
+ return levels;
456
+ };
457
+ const createStarNodes = (nodes, segments, degrees, hubId, levels) => {
458
+ const segmentNames = Array.from(new Set(nodes.map((node) => segments.get(node.id) ?? groupLabel(groupKey(node)))))
459
+ .sort((left, right) => segmentAngle(left, 0, 1) - segmentAngle(right, 0, 1) || left.localeCompare(right));
460
+ const segmentIndexByName = new Map(segmentNames.map((segment, index) => [segment, index]));
461
+ const sortedNodes = [...nodes].sort(compareByStarOrder(levels, degrees, segmentIndexByName, segments));
462
+ const nodesByLevel = sortedNodes.reduce((state, node) => {
463
+ const level = node.id === hubId ? 0 : Math.max(1, levels.get(node.id) ?? 1);
464
+ const levelNodes = state.get(level) ?? [];
465
+ levelNodes.push(node);
466
+ state.set(level, levelNodes);
467
+ return state;
468
+ }, new Map());
469
+ return Array.from(nodesByLevel.entries())
470
+ .sort(([left], [right]) => left - right)
471
+ .flatMap(([level, levelNodes]) => {
472
+ if (level === 0) {
473
+ return levelNodes.map((node) => ({
474
+ ...node,
475
+ group: groupLabel(groupKey(node)),
476
+ segment: segments.get(node.id) ?? groupLabel(groupKey(node)),
477
+ x: 0,
478
+ y: 0
479
+ }));
480
+ }
481
+ const ringRadius = Math.max(300 + (level - 1) * 360, (levelNodes.length * 112) / (Math.PI * 2));
482
+ return levelNodes.map((node, index) => {
483
+ const segment = segments.get(node.id) ?? groupLabel(groupKey(node));
484
+ const segmentOffset = (segmentIndexByName.get(segment) ?? 0) / Math.max(segmentNames.length, 1);
485
+ const angle = Math.PI * 2 * ((index / Math.max(levelNodes.length, 1) + segmentOffset * 0.18) % 1) - Math.PI / 2;
486
+ const radialJitter = jitter(node.id, 48);
487
+ return {
488
+ ...node,
489
+ group: groupLabel(groupKey(node)),
490
+ segment,
491
+ x: Math.cos(angle) * (ringRadius + radialJitter) + jitter(node.title, 18),
492
+ y: Math.sin(angle) * (ringRadius + radialJitter) + jitter(node.path, 18)
493
+ };
494
+ });
495
+ });
496
+ };
497
+ export const createStarGraphLayout = (graph) => {
498
+ const degrees = countDegrees(graph.edges);
499
+ const hubId = selectPrimaryHubId(graph.nodes, degrees) ?? selectHighestDegreeNodeId(graph.nodes, degrees);
500
+ const segments = assignSegments(graph.nodes, graph.edges, degrees);
501
+ const levels = assignStarLevels(graph.nodes, graph.edges, hubId);
502
+ const nodes = relaxCollisions(createStarNodes(graph.nodes, segments, degrees, hubId, levels), 132, 18);
503
+ const centeredNodes = centerLayoutByNode(nodes, hubId);
504
+ const groups = createGraphLayoutHierarchy(centeredNodes, graph.edges, degrees);
505
+ return {
506
+ nodes: centeredNodes,
507
+ edges: graph.edges,
508
+ ...(groups.length > 0 ? { groups } : {})
509
+ };
510
+ };
417
511
  export const createCauliflowerGraphLayout = (graph) => {
418
512
  const degrees = countDegrees(graph.edges);
419
513
  const segments = assignSegments(graph.nodes, graph.edges, degrees);
@@ -607,7 +607,7 @@ Without `--vault`, the graph UI serves `$HOME/.brainlink/vault`.
607
607
 
608
608
  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.
609
609
 
610
- 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. 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.
610
+ 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.
611
611
  During graph filtering, Brainlink keeps hub context nodes visible (`Memory Hub`/`MOC`/high-degree fallback) so filtered views still show relationship anchors.
612
612
 
613
613
  The command reindexes by default, then serves:
@@ -155,10 +155,12 @@ server command
155
155
  -> HTTP server
156
156
  -> /api/agents lists indexed namespaces
157
157
  -> /api/graph reads indexed documents and links
158
+ -> /api/graph-layout derives a star layout from indexed graph data
158
159
  -> browser renders graph canvas
159
160
  ```
160
161
 
161
162
  The graph UI is intentionally read-only. Markdown remains the write interface and index artifacts remain derived data.
163
+ 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.
162
164
 
163
165
  ## HTTP API Flow
164
166
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andespindola/brainlink",
3
- "version": "0.1.0-beta.149",
3
+ "version": "0.1.0-beta.150",
4
4
  "description": "Local-first knowledge memory for agents with Markdown, backlinks, indexing and context retrieval.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -63,6 +63,7 @@
63
63
  "zod": "^4.3.6"
64
64
  },
65
65
  "overrides": {
66
+ "hono": "4.12.21",
66
67
  "qs": "6.15.2"
67
68
  },
68
69
  "devDependencies": {