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

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
@@ -601,6 +601,7 @@ The graph UI shows:
601
601
  - details opened in a non-modal side panel (tags, outgoing links, backlinks, full Markdown content), so zoom and pan remain available while inspecting data
602
602
  - neutral graph nodes with segment/group metadata
603
603
  - agent selector (id-only labels) for isolated views
604
+ - context selector for segment-scoped star subgraphs derived from the visual graph context
604
605
  - graph filter matches title, path, tags and note content
605
606
  - graph filter keeps hub context nodes visible (`Memory Hub`/`MOC`/high-degree fallback) to preserve relationship readability
606
607
  - realtime refresh while `--watch` is enabled
@@ -632,6 +633,7 @@ The server always refuses non-loopback hosts. Brainlink HTTP only runs on localh
632
633
  Routes:
633
634
 
634
635
  - `GET /api/agents`
636
+ - `GET /api/graph-contexts`
635
637
  - `GET /api/graph`
636
638
  - `GET /api/graph-layout`
637
639
  - `GET /api/graph-view?x=<x>&y=<y>&w=<width>&h=<height>&scale=<scale>`
@@ -650,6 +652,7 @@ Read routes accept `agent=<agent-id>`:
650
652
 
651
653
  ```txt
652
654
  /api/graph-layout?agent=coding-agent
655
+ /api/graph-layout?agent=coding-agent&context=Architecture
653
656
  /api/search?q=typescript&agent=coding-agent&mode=hybrid
654
657
  /api/context?q=module-boundaries&agent=coding-agent&mode=semantic
655
658
  ```
@@ -113,7 +113,8 @@ select {
113
113
  min-width: 220px;
114
114
  }
115
115
 
116
- .agent-filter {
116
+ .agent-filter,
117
+ .context-filter {
117
118
  width: min(220px, 28vw);
118
119
  }
119
120
 
@@ -125,7 +126,8 @@ select {
125
126
  }
126
127
 
127
128
  .search input,
128
- .agent-filter select {
129
+ .agent-filter select,
130
+ .context-filter select {
129
131
  width: 100%;
130
132
  height: 40px;
131
133
  border: 1px solid var(--line);
@@ -137,7 +139,8 @@ select {
137
139
  }
138
140
 
139
141
  .search input:focus,
140
- .agent-filter select:focus {
142
+ .agent-filter select:focus,
143
+ .context-filter select:focus {
141
144
  border-color: var(--accent);
142
145
  }
143
146
 
@@ -405,7 +408,8 @@ li small {
405
408
  order: 3;
406
409
  }
407
410
 
408
- .agent-filter {
411
+ .agent-filter,
412
+ .context-filter {
409
413
  width: 100%;
410
414
  }
411
415
 
@@ -35,6 +35,9 @@ export const createClientHtml = () => `<!doctype html>
35
35
  <label class="agent-filter">
36
36
  <select id="agent"></select>
37
37
  </label>
38
+ <label class="context-filter">
39
+ <select id="context"></select>
40
+ </label>
38
41
  <div class="toolbar" aria-label="Graph controls">
39
42
  <button id="zoomIn" type="button" title="Zoom in">+</button>
40
43
  <button id="zoomOut" type="button" title="Zoom out">-</button>
@@ -4,6 +4,7 @@ const byId = (id) => document.getElementById(id)
4
4
  const elements = {
5
5
  search: byId('search'),
6
6
  agent: byId('agent'),
7
+ context: byId('context'),
7
8
  nodeCount: byId('nodeCount'),
8
9
  edgeCount: byId('edgeCount'),
9
10
  tagCount: byId('tagCount'),
@@ -49,6 +50,7 @@ const state = {
49
50
  rendererMode: 'worker',
50
51
  renderWorker: null,
51
52
  agentId: '',
53
+ contextId: '',
52
54
  graphSignature: '',
53
55
  graphMode: 'near',
54
56
  chunk: {
@@ -77,6 +79,7 @@ const zoomRange = {
77
79
  }
78
80
 
79
81
  const selectedAgentStorageKey = 'brainlink:selected-agent'
82
+ const selectedContextStorageKey = 'brainlink:selected-context'
80
83
 
81
84
  const escapeHtml = (value) => String(value)
82
85
  .replaceAll('&', '&amp;')
@@ -104,6 +107,25 @@ const writeStoredAgent = (agentId) => {
104
107
  } catch {}
105
108
  }
106
109
 
110
+ const readStoredContext = () => {
111
+ try {
112
+ const value = window.localStorage.getItem(selectedContextStorageKey)?.trim() ?? ''
113
+ return value.length > 0 ? value : ''
114
+ } catch {
115
+ return ''
116
+ }
117
+ }
118
+
119
+ const writeStoredContext = (contextId) => {
120
+ try {
121
+ if (!contextId) {
122
+ window.localStorage.removeItem(selectedContextStorageKey)
123
+ return
124
+ }
125
+ window.localStorage.setItem(selectedContextStorageKey, contextId)
126
+ } catch {}
127
+ }
128
+
107
129
  const syncAgentInUrl = (agentId) => {
108
130
  try {
109
131
  const url = new URL(window.location.href)
@@ -116,6 +138,18 @@ const syncAgentInUrl = (agentId) => {
116
138
  } catch {}
117
139
  }
118
140
 
141
+ const syncContextInUrl = (contextId) => {
142
+ try {
143
+ const url = new URL(window.location.href)
144
+ if (contextId && contextId.trim().length > 0) {
145
+ url.searchParams.set('context', contextId)
146
+ } else {
147
+ url.searchParams.delete('context')
148
+ }
149
+ window.history.replaceState({}, '', url.toString())
150
+ } catch {}
151
+ }
152
+
119
153
  const initialAgentFromUrl = (() => {
120
154
  try {
121
155
  const raw = new URL(window.location.href).searchParams.get('agent')
@@ -126,7 +160,28 @@ const initialAgentFromUrl = (() => {
126
160
  }
127
161
  })()
128
162
 
129
- const agentQuery = (separator = '?') => state.agentId ? separator + 'agent=' + encodeURIComponent(state.agentId) : ''
163
+ const initialContextFromUrl = (() => {
164
+ try {
165
+ const raw = new URL(window.location.href).searchParams.get('context')
166
+ const value = raw?.trim() ?? ''
167
+ return value.length > 0 ? value : ''
168
+ } catch {
169
+ return ''
170
+ }
171
+ })()
172
+
173
+ const scopeQuery = (separator = '?') => {
174
+ const params = new URLSearchParams()
175
+ if (state.agentId) {
176
+ params.set('agent', state.agentId)
177
+ }
178
+ if (state.contextId) {
179
+ params.set('context', state.contextId)
180
+ }
181
+ const query = params.toString()
182
+
183
+ return query ? separator + query : ''
184
+ }
130
185
 
131
186
  const parseColor = (hex) => {
132
187
  const normalized = String(hex || '#ffffff').replace('#', '')
@@ -375,7 +430,7 @@ const loadNodeDetails = async (nodeId) => {
375
430
  return
376
431
  }
377
432
 
378
- const response = await fetch('/api/graph-node?id=' + encodeURIComponent(nodeId) + agentQuery('&'))
433
+ const response = await fetch('/api/graph-node?id=' + encodeURIComponent(nodeId) + scopeQuery('&'))
379
434
  if (!response.ok) {
380
435
  throw new Error('Failed to load graph node details')
381
436
  }
@@ -479,6 +534,9 @@ const fetchChunk = async ({ fit } = { fit: false }) => {
479
534
  if (state.agentId) {
480
535
  params.set('agent', state.agentId)
481
536
  }
537
+ if (state.contextId) {
538
+ params.set('context', state.contextId)
539
+ }
482
540
 
483
541
  const response = await fetch('/api/graph-stream?' + params.toString(), { signal: controller.signal })
484
542
  if (!response.ok) {
@@ -751,7 +809,7 @@ const setupControls = () => {
751
809
  return
752
810
  }
753
811
 
754
- fetch('/api/graph-filter?q=' + encodeURIComponent(query) + '&limit=1800' + agentQuery('&'))
812
+ fetch('/api/graph-filter?q=' + encodeURIComponent(query) + '&limit=1800' + scopeQuery('&'))
755
813
  .then((response) => response.json())
756
814
  .then((payload) => {
757
815
  if (token !== state.searchToken) {
@@ -795,12 +853,50 @@ const loadAgents = async () => {
795
853
  state.agentId = elements.agent.value || ''
796
854
  writeStoredAgent(state.agentId)
797
855
  syncAgentInUrl(state.agentId)
798
- scheduleChunkFetch({ fit: true })
856
+ loadContexts().then(() => scheduleChunkFetch({ fit: true })).catch((error) => console.error(error))
799
857
  })
800
858
 
801
859
  syncAgentInUrl(state.agentId)
802
860
  }
803
861
 
862
+ const loadContexts = async () => {
863
+ const response = await fetch('/api/graph-contexts' + (state.agentId ? '?agent=' + encodeURIComponent(state.agentId) : ''))
864
+ if (!response.ok) {
865
+ throw new Error('Failed to load graph contexts')
866
+ }
867
+
868
+ const payload = await response.json()
869
+ const contexts = Array.isArray(payload?.contexts) ? payload.contexts : []
870
+ const options = [
871
+ '<option value="">All contexts</option>',
872
+ ...contexts.map((context) => {
873
+ const id = String(context?.id || '')
874
+ const title = String(context?.title || id || 'Untitled')
875
+ const count = Number.isFinite(context?.nodeCount) ? context.nodeCount : 0
876
+ return '<option value="' + escapeHtml(id) + '">' + escapeHtml(title) + ' (' + count + ')</option>'
877
+ })
878
+ ]
879
+
880
+ elements.context.innerHTML = options.join('')
881
+
882
+ const preferredContext = initialContextFromUrl || readStoredContext()
883
+ const hasPreferred = preferredContext && contexts.some((context) => context?.id === preferredContext)
884
+ state.contextId = hasPreferred ? preferredContext : ''
885
+ elements.context.value = state.contextId
886
+ writeStoredContext(state.contextId)
887
+ syncContextInUrl(state.contextId)
888
+ }
889
+
890
+ const setupContextControl = () => {
891
+ elements.context.addEventListener('change', () => {
892
+ state.contextId = elements.context.value || ''
893
+ state.selectedNodeId = null
894
+ writeStoredContext(state.contextId)
895
+ syncContextInUrl(state.contextId)
896
+ scheduleChunkFetch({ fit: true })
897
+ })
898
+ }
899
+
804
900
  const setupRenderWorker = () => {
805
901
  const hasWorker = typeof Worker !== 'undefined'
806
902
  const canTransfer = typeof canvas.transferControlToOffscreen === 'function'
@@ -892,6 +988,7 @@ const bootstrap = async () => {
892
988
  setupRenderWorker()
893
989
  setupInput()
894
990
  setupControls()
991
+ setupContextControl()
895
992
  wireNodeLinkClicks()
896
993
 
897
994
  window.addEventListener('resize', () => {
@@ -900,12 +997,11 @@ const bootstrap = async () => {
900
997
  })
901
998
 
902
999
  await loadAgents()
1000
+ await loadContexts()
903
1001
  updateTotals()
904
1002
  updateTagCount()
905
1003
 
906
- if (state.rendererMode === 'fallback') {
907
- scheduleChunkFetch({ fit: true })
908
- }
1004
+ scheduleChunkFetch({ fit: true })
909
1005
  }
910
1006
 
911
1007
  bootstrap().catch((error) => {
@@ -0,0 +1,19 @@
1
+ import { getGraphLayout } from './get-graph-layout.js';
2
+ export const getGraphContexts = async (vaultPath, agentId) => {
3
+ const { layout } = await getGraphLayout(vaultPath, { agentId });
4
+ const nodeIdsByContext = layout.nodes.reduce((contexts, node) => {
5
+ const title = node.segment || node.group || 'root';
6
+ const nodeIds = contexts.get(title) ?? new Set();
7
+ nodeIds.add(node.id);
8
+ contexts.set(title, nodeIds);
9
+ return contexts;
10
+ }, new Map());
11
+ return Array.from(nodeIdsByContext.entries())
12
+ .map(([title, nodeIds]) => ({
13
+ id: title,
14
+ title,
15
+ nodeCount: nodeIds.size,
16
+ edgeCount: layout.edges.filter((edge) => nodeIds.has(edge.source) && Boolean(edge.target && nodeIds.has(edge.target))).length
17
+ }))
18
+ .sort((left, right) => right.nodeCount - left.nodeCount || left.title.localeCompare(right.title));
19
+ };
@@ -4,20 +4,25 @@ import { dirname, join } from 'node:path';
4
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 = 3;
7
+ const graphLayoutVersion = 4;
8
8
  const graphLayoutCache = new Map();
9
- const graphLayoutStoragePath = (vaultPath, agentId) => join(vaultPath, '.brainlink', `graph-layout-${agentId?.replace(/[^a-zA-Z0-9_-]/g, '_') ?? 'all'}.json`);
10
- const readPersistedLayout = async (vaultPath, databaseSignature, agentId) => {
9
+ const safeCacheSegment = (value, fallback) => value?.replace(/[^a-zA-Z0-9_-]/g, '_') || fallback;
10
+ const graphLayoutStoragePath = (vaultPath, options) => {
11
+ const agent = safeCacheSegment(options.agentId, 'all');
12
+ const context = safeCacheSegment(options.context, 'all-contexts');
13
+ return join(vaultPath, '.brainlink', `graph-layout-${agent}-${context}.json`);
14
+ };
15
+ const readPersistedLayout = async (vaultPath, databaseSignature, options) => {
11
16
  try {
12
- const parsed = JSON.parse(await readFile(graphLayoutStoragePath(vaultPath, agentId), 'utf8'));
17
+ const parsed = JSON.parse(await readFile(graphLayoutStoragePath(vaultPath, options), 'utf8'));
13
18
  return parsed.databaseSignature === databaseSignature && parsed.layoutVersion === graphLayoutVersion ? parsed : null;
14
19
  }
15
20
  catch {
16
21
  return null;
17
22
  }
18
23
  };
19
- const writePersistedLayout = async (vaultPath, agentId, cached) => {
20
- const target = graphLayoutStoragePath(vaultPath, agentId);
24
+ const writePersistedLayout = async (vaultPath, options, cached) => {
25
+ const target = graphLayoutStoragePath(vaultPath, options);
21
26
  const temp = `${target}.tmp`;
22
27
  await mkdir(dirname(target), { recursive: true, mode: 0o700 });
23
28
  await writeFile(temp, `${JSON.stringify(cached)}\n`, { encoding: 'utf8', mode: 0o600 });
@@ -41,9 +46,27 @@ const createGraphSignature = (graph) => {
41
46
  .update(`${graph.nodes.length}:${nodesSignature}|${graph.edges.length}:${edgesSignature}`)
42
47
  .digest('hex');
43
48
  };
44
- export const getGraphLayout = async (vaultPath, agentId) => {
49
+ const createLayout = (graph) => {
50
+ const rawLayout = createStarGraphLayout(graph);
51
+ return {
52
+ ...rawLayout,
53
+ nodes: rawLayout.nodes.map((node) => ({ ...node, content: '' }))
54
+ };
55
+ };
56
+ const filterGraphByContext = (graph, context) => {
57
+ const baseLayout = createStarGraphLayout(graph);
58
+ const selectedNodeIds = new Set(baseLayout.nodes
59
+ .filter((node) => node.segment === context)
60
+ .map((node) => node.id));
61
+ return {
62
+ nodes: graph.nodes.filter((node) => selectedNodeIds.has(node.id)),
63
+ edges: graph.edges.filter((edge) => selectedNodeIds.has(edge.source) && Boolean(edge.target && selectedNodeIds.has(edge.target)))
64
+ };
65
+ };
66
+ export const getGraphLayout = async (vaultPath, optionsOrAgentId) => {
67
+ const options = typeof optionsOrAgentId === 'string' ? { agentId: optionsOrAgentId } : optionsOrAgentId ?? {};
45
68
  const databaseSignature = await readDatabaseSignature(vaultPath);
46
- const cacheKey = `${vaultPath}:${agentId ?? ''}`;
69
+ const cacheKey = `${vaultPath}:${options.agentId ?? ''}:${options.context ?? ''}`;
47
70
  const cached = graphLayoutCache.get(cacheKey);
48
71
  if (cached?.databaseSignature === databaseSignature && cached.layoutVersion === graphLayoutVersion) {
49
72
  return {
@@ -51,7 +74,7 @@ export const getGraphLayout = async (vaultPath, agentId) => {
51
74
  layout: cached.layout
52
75
  };
53
76
  }
54
- const persisted = await readPersistedLayout(vaultPath, databaseSignature, agentId);
77
+ const persisted = await readPersistedLayout(vaultPath, databaseSignature, options);
55
78
  if (persisted) {
56
79
  graphLayoutCache.set(cacheKey, persisted);
57
80
  return {
@@ -59,16 +82,13 @@ export const getGraphLayout = async (vaultPath, agentId) => {
59
82
  layout: persisted.layout
60
83
  };
61
84
  }
62
- const graph = await getGraphSummary(vaultPath, agentId);
63
- const signature = createGraphSignature(graph);
64
- const rawLayout = createStarGraphLayout(graph);
65
- const layout = {
66
- ...rawLayout,
67
- nodes: rawLayout.nodes.map((node) => ({ ...node, content: '' }))
68
- };
85
+ const graph = await getGraphSummary(vaultPath, options.agentId);
86
+ const scopedGraph = options.context ? filterGraphByContext(graph, options.context) : graph;
87
+ const signature = createGraphSignature(scopedGraph);
88
+ const layout = createLayout(scopedGraph);
69
89
  const nextCache = { layoutVersion: graphLayoutVersion, databaseSignature, signature, layout };
70
90
  graphLayoutCache.set(cacheKey, nextCache);
71
- await writePersistedLayout(vaultPath, agentId, nextCache);
91
+ await writePersistedLayout(vaultPath, options, nextCache);
72
92
  return {
73
93
  signature,
74
94
  layout
@@ -244,7 +244,10 @@ const normalizeBudget = (value, fallback, min, max) => {
244
244
  export const getGraphStreamChunk = async (vaultPath, input) => {
245
245
  const nodeBudget = normalizeBudget(input.nodeBudget, 1800, 80, 12_000);
246
246
  const edgeBudget = normalizeBudget(input.edgeBudget, 5000, 120, 60_000);
247
- const { signature, layout } = await getGraphLayout(vaultPath, input.agentId);
247
+ const { signature, layout } = await getGraphLayout(vaultPath, {
248
+ agentId: input.agentId,
249
+ context: input.context
250
+ });
248
251
  const groups = layout.groups ?? [];
249
252
  const cache = getOrCreateLayoutCache(signature, layout.nodes, layout.edges, groups);
250
253
  if (layout.nodes.length === 0) {
@@ -180,7 +180,10 @@ const arrangeChildGraphNodes = (nodes, group, degrees) => {
180
180
  };
181
181
  const limitEdges = (edges) => edges.slice(0, edgeLimit);
182
182
  export const getGraphView = async (vaultPath, input) => {
183
- const { signature, layout } = await getGraphLayout(vaultPath, input.agentId);
183
+ const { signature, layout } = await getGraphLayout(vaultPath, {
184
+ agentId: input.agentId,
185
+ context: input.context
186
+ });
184
187
  const groups = layout.groups ?? [];
185
188
  const degrees = degreeMap(layout.edges);
186
189
  const groupById = new Map(groups.map((group) => [group.id, group]));
@@ -1,6 +1,7 @@
1
1
  import { getBrokenLinksReport, getOrphansReport, getStats, validateVault } from '../analyze-vault.js';
2
2
  import { buildContextPackage } from '../build-context.js';
3
3
  import { getGraph } from '../get-graph.js';
4
+ import { getGraphContexts } from '../get-graph-contexts.js';
4
5
  import { getGraphNode } from '../get-graph-node.js';
5
6
  import { getGraphLayout } from '../get-graph-layout.js';
6
7
  import { getGraphView } from '../get-graph-view.js';
@@ -55,6 +56,10 @@ const sameEntityTag = (candidate, signature) => {
55
56
  return decodeEntityTag(candidate) === signature;
56
57
  };
57
58
  const readAgentQuery = (url) => url.searchParams.get('agent') ?? undefined;
59
+ const readContextQuery = (url) => {
60
+ const value = url.searchParams.get('context')?.trim() ?? '';
61
+ return value.length > 0 ? value : undefined;
62
+ };
58
63
  const parseNumber = (value, fallback) => {
59
64
  const parsed = Number(value);
60
65
  return Number.isFinite(parsed) ? parsed : fallback;
@@ -218,7 +223,10 @@ export const route = async (request, url, vaultPath) => {
218
223
  return createResponse(createJsonResponse(await getGraph(vaultPath, readAgentQuery(url))), 200, contentTypes['.json']);
219
224
  }
220
225
  if (isReadMethod(request) && url.pathname === '/api/graph-layout') {
221
- const { signature, layout } = await getGraphLayout(vaultPath, readAgentQuery(url));
226
+ const { signature, layout } = await getGraphLayout(vaultPath, {
227
+ agentId: readAgentQuery(url),
228
+ context: readContextQuery(url)
229
+ });
222
230
  const requestEtags = request.headers['if-none-match'];
223
231
  const notModified = sameEntityTag(requestEtags, signature);
224
232
  const etag = encodeEntityTag(signature);
@@ -253,7 +261,8 @@ export const route = async (request, url, vaultPath) => {
253
261
  width: parseNumber(url.searchParams.get('w'), 2000),
254
262
  height: parseNumber(url.searchParams.get('h'), 2000),
255
263
  scale: parseNumber(url.searchParams.get('scale'), 1),
256
- agentId: readAgentQuery(url)
264
+ agentId: readAgentQuery(url),
265
+ context: readContextQuery(url)
257
266
  })), 200, contentTypes['.json']);
258
267
  }
259
268
  if (isReadMethod(request) && url.pathname === '/api/graph-stream') {
@@ -272,7 +281,8 @@ export const route = async (request, url, vaultPath) => {
272
281
  scale,
273
282
  nodeBudget,
274
283
  edgeBudget,
275
- agentId: readAgentQuery(url)
284
+ agentId: readAgentQuery(url),
285
+ context: readContextQuery(url)
276
286
  })), 200, contentTypes['.json']);
277
287
  }
278
288
  if (isReadMethod(request) && url.pathname === '/api/graph-node') {
@@ -298,6 +308,9 @@ export const route = async (request, url, vaultPath) => {
298
308
  if (isReadMethod(request) && url.pathname === '/api/agents') {
299
309
  return createResponse(createJsonResponse({ agents: await listAgents(vaultPath) }), 200, contentTypes['.json']);
300
310
  }
311
+ if (isReadMethod(request) && url.pathname === '/api/graph-contexts') {
312
+ return createResponse(createJsonResponse({ contexts: await getGraphContexts(vaultPath, readAgentQuery(url)) }), 200, contentTypes['.json']);
313
+ }
301
314
  if (isReadMethod(request) && url.pathname === '/api/search') {
302
315
  const query = url.searchParams.get('q') ?? '';
303
316
  const limit = parsePositiveInteger(url.searchParams.get('limit'), 10);
@@ -154,8 +154,10 @@ server command
154
154
  -> optional index rebuild
155
155
  -> HTTP server
156
156
  -> /api/agents lists indexed namespaces
157
+ -> /api/graph-contexts lists visual graph contexts
157
158
  -> /api/graph reads indexed documents and links
158
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
161
  -> browser renders graph canvas
160
162
  ```
161
163
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andespindola/brainlink",
3
- "version": "0.1.0-beta.152",
3
+ "version": "0.1.0-beta.153",
4
4
  "description": "Local-first knowledge memory for agents with Markdown, backlinks, indexing and context retrieval.",
5
5
  "type": "module",
6
6
  "license": "MIT",