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

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/AGENTS.md CHANGED
@@ -78,10 +78,10 @@ http://127.0.0.1:4321/
78
78
  http://127.0.0.1:4321/api/graph
79
79
  ```
80
80
 
81
- Use watch mode while editing notes:
81
+ The graph server watches Markdown files by default while editing notes:
82
82
 
83
83
  ```bash
84
- npm run dev -- server --vault ./vault --watch
84
+ npm run dev -- server --vault ./vault
85
85
  npm run dev -- watch --vault ./vault
86
86
  npm run dev -- bench --vault ./vault
87
87
  npm run dev -- bench --vault ./vault --watch
package/README.md CHANGED
@@ -265,7 +265,7 @@ blink search "jwt auth" --vault ./vault
265
265
 
266
266
  blink context "how does auth work?" --vault ./vault
267
267
 
268
- blink server --vault ./vault --watch
268
+ blink server --vault ./vault
269
269
  ```
270
270
 
271
271
  Open the graph UI:
@@ -536,6 +536,7 @@ Available tools:
536
536
  - `brainlink_resolve_duplicate`: resolve duplicate pairs (`merge`, `link`, `ignore`) with connectivity-safe fallback edges.
537
537
  - `brainlink_add_note`: write durable Markdown memory and reindex.
538
538
  - `brainlink_add_file`: ingest a local file as a note and reindex.
539
+ - `brainlink_canonicalize_context_links`: ensure existing notes link to inferred context hubs.
539
540
  - `brainlink_volatile_add`: write temporary agent-decided memory with TTL; volatile sections are included in context and never create durable graph edges.
540
541
  - `brainlink_volatile_clear`: clear temporary memory for the current vault/agent namespace.
541
542
  - `brainlink_index`: rebuild the vault index. Pass `full=true` for a complete source reindex.
@@ -543,6 +544,7 @@ Available tools:
543
544
  - `brainlink_validate`: validate broken links and orphan notes.
544
545
  - `brainlink_sync`: run index, stats, validation, broken-link and orphan checks in one call.
545
546
  - `brainlink_graph`: read indexed graph nodes and weighted links.
547
+ - `brainlink_graph_contexts`: list the visual graph contexts used by the local server.
546
548
  - `brainlink_broken_links`: list unresolved wiki links.
547
549
  - `brainlink_orphans`: list disconnected notes.
548
550
 
@@ -577,10 +579,11 @@ blink index --vault ./vault --full
577
579
  Start the local frontend:
578
580
 
579
581
  ```bash
580
- blink server --host 127.0.0.1 --port 4321 --watch
582
+ blink server --host 127.0.0.1 --port 4321
581
583
  ```
582
584
 
583
585
  By default, the server uses `$HOME/.brainlink/vault`. Pass `--vault ./vault` only when you want to inspect a custom vault.
586
+ By default, the server watches Markdown files in local filesystem vaults and reindexes after note changes. Use `--no-watch` to disable realtime reindexing.
584
587
  By default, `blink server` tries to open the graph in a native desktop GUI window:
585
588
  - macOS: Swift + WebKit
586
589
  - Windows: PowerShell WinForms WebBrowser
@@ -601,9 +604,10 @@ The graph UI shows:
601
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
602
605
  - neutral graph nodes with segment/group metadata
603
606
  - agent selector (id-only labels) for isolated views
607
+ - context selector for segment-scoped star subgraphs derived from the visual graph context
604
608
  - graph filter matches title, path, tags and note content
605
609
  - graph filter keeps hub context nodes visible (`Memory Hub`/`MOC`/high-degree fallback) to preserve relationship readability
606
- - realtime refresh while `--watch` is enabled
610
+ - realtime refresh while watch mode is enabled
607
611
  - graph controls for zoom in, zoom out, fit visible nodes and reset-to-fit-all
608
612
  - wheel zoom (including `cmd+scroll` and `ctrl+scroll`) anchored to cursor position for faster navigation in large graphs
609
613
  - wheel/button zoom updates immediately at the cursor anchor without delayed focus-transition interpolation
@@ -632,6 +636,7 @@ The server always refuses non-loopback hosts. Brainlink HTTP only runs on localh
632
636
  Routes:
633
637
 
634
638
  - `GET /api/agents`
639
+ - `GET /api/graph-contexts`
635
640
  - `GET /api/graph`
636
641
  - `GET /api/graph-layout`
637
642
  - `GET /api/graph-view?x=<x>&y=<y>&w=<width>&h=<height>&scale=<scale>`
@@ -650,6 +655,7 @@ Read routes accept `agent=<agent-id>`:
650
655
 
651
656
  ```txt
652
657
  /api/graph-layout?agent=coding-agent
658
+ /api/graph-layout?agent=coding-agent&context=Architecture
653
659
  /api/search?q=typescript&agent=coding-agent&mode=hybrid
654
660
  /api/context?q=module-boundaries&agent=coding-agent&mode=semantic
655
661
  ```
@@ -941,15 +947,26 @@ blink watch --vault ./vault
941
947
 
942
948
  Watches Markdown files and rebuilds the index when notes change.
943
949
 
950
+ ### `canonicalize-context-links`
951
+
952
+ ```bash
953
+ blink canonicalize-context-links --vault ./vault --dry-run
954
+ blink canonicalize-context-links --vault ./vault
955
+ ```
956
+
957
+ Ensures existing notes have canonical `## Context Links` entries to inferred context hubs such as `User Preferences Hub`, `GitHub Repositories Hub` or `Brainlink Hub`. The command is idempotent, creates missing hub notes by default, and fully reindexes after writes unless `--no-index` is passed.
958
+
944
959
  ### `server`
945
960
 
946
961
  ```bash
947
- blink server --watch
948
- blink server --vault ./vault --watch
949
- blink server --vault ./vault --watch --no-open
962
+ blink server
963
+ blink server --vault ./vault
964
+ blink server --vault ./vault --no-open
965
+ blink server --vault ./vault --no-watch
950
966
  ```
951
967
 
952
968
  Starts the local read-only graph UI and HTTP API.
969
+ Watch mode is enabled by default for Markdown changes in local filesystem vaults. Use `--no-watch` to run without the watcher.
953
970
  By default, it tries to open a native desktop GUI window for the graph URL.
954
971
  On Linux, native GUI is disabled by default; enable it with `BRAINLINK_LINUX_NATIVE_GUI=1`.
955
972
  If native GUI launch is unavailable, it falls back to dedicated app-window mode and then browser open.
@@ -990,6 +1007,7 @@ If no `vault` is configured and no `--vault` flag is passed, Brainlink uses `$HO
990
1007
  "allowedVaults": [".brainlink-vault"],
991
1008
  "defaultAgent": "shared",
992
1009
  "autoIndexOnWrite": true,
1010
+ "autoCanonicalContextLinks": true,
993
1011
  "defaultSearchLimit": 10,
994
1012
  "defaultContextTokens": 2000,
995
1013
  "embeddingProvider": "local",
@@ -1019,6 +1037,7 @@ If no `vault` is configured and no `--vault` flag is passed, Brainlink uses `$HO
1019
1037
  `agentProfiles` is optional. When present, CLI and MCP resolve `mode`, `limit` and `tokens` per agent automatically, then fallback to global defaults.
1020
1038
 
1021
1039
  `autoIndexOnWrite` is optional and defaults to `true`. Set it to `false` to defer indexing after writes.
1040
+ `autoCanonicalContextLinks` is optional and defaults to `true`. When enabled, `blink add`, `brainlink_add_note` and `brainlink_add_file` add a canonical `## Context Links` entry to the inferred context hub, creating that hub when needed.
1022
1041
 
1023
1042
  Use `"embeddingProvider": "none"` when you want FTS-only indexing.
1024
1043
 
@@ -1097,7 +1116,7 @@ Local CLI:
1097
1116
 
1098
1117
  ```bash
1099
1118
  npm run dev -- --help
1100
- npm run dev -- server --vault .brainlink-vault --watch
1119
+ npm run dev -- server --vault .brainlink-vault
1101
1120
  ```
1102
1121
 
1103
1122
  Package smoke test:
@@ -2,6 +2,7 @@ import { writeMarkdownFile } from '../infrastructure/file-system-vault.js';
2
2
  import { sanitizeAgentId, sharedAgentId } from '../domain/agents.js';
3
3
  import { validateNoteInput } from '../domain/note-safety.js';
4
4
  import { ensureVault } from '../infrastructure/file-system-vault.js';
5
+ import { addCanonicalContextLinkToContent, ensureCanonicalContextHub } from './canonical-context-links.js';
5
6
  const slugify = (title) => title
6
7
  .normalize('NFKD')
7
8
  .replace(/[\u0300-\u036f]/g, '')
@@ -28,12 +29,20 @@ export const addNoteWithMetadata = async (vaultPath, title, content, agentId = s
28
29
  const sanitizedAgentId = sanitizeAgentId(agentId);
29
30
  const filename = `agents/${sanitizedAgentId}/${slugify(title) || 'untitled'}.md`;
30
31
  await ensureVault(vaultPath);
31
- const note = buildNote(title, content.trim(), sanitizedAgentId);
32
+ const canonical = options.autoContextLinks === false
33
+ ? null
34
+ : addCanonicalContextLinkToContent(title, content.trim());
35
+ const hub = canonical?.changed
36
+ ? await ensureCanonicalContextHub(vaultPath, canonical.context, sanitizedAgentId)
37
+ : null;
38
+ const note = buildNote(title, canonical?.content ?? content.trim(), sanitizedAgentId);
32
39
  const path = await writeMarkdownFile(vaultPath, filename, note);
33
40
  return {
34
41
  path,
35
- autoLinked: false,
36
- linkTarget: null
42
+ autoLinked: canonical?.changed ?? false,
43
+ linkTarget: canonical?.changed ? canonical.hubTitle : null,
44
+ context: canonical?.context ?? null,
45
+ hubCreated: hub?.created ?? false
37
46
  };
38
47
  };
39
48
  export const addNote = async (vaultPath, title, content, agentId = sharedAgentId, options = {}) => (await addNoteWithMetadata(vaultPath, title, content, agentId, options)).path;
@@ -0,0 +1,209 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { inferVisualGraphContext } from '../domain/graph-contexts.js';
4
+ import { sanitizeAgentId, sharedAgentId } from '../domain/agents.js';
5
+ import { extractContextLinkWeights, parseMarkdownDocument } from '../domain/markdown.js';
6
+ import { ensureVault, readMarkdownFileSummaries, writeMarkdownFile } from '../infrastructure/file-system-vault.js';
7
+ const canonicalPriority = 'high';
8
+ const slugify = (title) => title
9
+ .normalize('NFKD')
10
+ .replace(/[\u0300-\u036f]/g, '')
11
+ .toLowerCase()
12
+ .replace(/[^a-z0-9]+/g, '-')
13
+ .replace(/^-+|-+$/g, '');
14
+ export const hubTitleForContext = (contextTitle) => `${contextTitle} Hub`;
15
+ const hubPathForContext = (contextTitle, agentId) => {
16
+ if (contextTitle === 'GitHub Repositories')
17
+ return 'github-repos/github-repositories-hub.md';
18
+ if (contextTitle === 'GitHub Organizations')
19
+ return 'github-org-repos/github-organizations-hub.md';
20
+ if (contextTitle === 'Machine Configuration')
21
+ return 'machine-config/machine-configuration-hub.md';
22
+ return join('agents', sanitizeAgentId(agentId), `${slugify(hubTitleForContext(contextTitle))}.md`).replaceAll('\\', '/');
23
+ };
24
+ const normalizeTitle = (title) => title.trim().replace(/\.md$/i, '').toLowerCase();
25
+ const hasCanonicalLink = (content, hubTitle) => extractContextLinkWeights(content).some((link) => normalizeTitle(link.title) === normalizeTitle(hubTitle));
26
+ const linkLine = (hubTitle) => `- [[${hubTitle}]] priority: ${canonicalPriority}`;
27
+ const contextLinksHeading = (line) => line.match(/^(#{2,6})\s+(Context Links|Links de Contexto)\s*$/i);
28
+ export const upsertCanonicalContextLink = (content, hubTitle) => {
29
+ if (hasCanonicalLink(content, hubTitle)) {
30
+ return content;
31
+ }
32
+ const lines = content.replace(/\s+$/u, '').split('\n');
33
+ const headingIndex = lines.findIndex((line) => contextLinksHeading(line.trim()));
34
+ if (headingIndex === -1) {
35
+ return `${lines.join('\n')}\n\n## Context Links\n\n${linkLine(hubTitle)}\n`;
36
+ }
37
+ const heading = contextLinksHeading(lines[headingIndex].trim());
38
+ const headingDepth = heading?.[1]?.length ?? 2;
39
+ const insertIndex = lines.findIndex((line, index) => {
40
+ if (index <= headingIndex)
41
+ return false;
42
+ const candidate = line.match(/^(#{2,6})\s+/);
43
+ return Boolean(candidate && candidate[1].length <= headingDepth);
44
+ });
45
+ const targetIndex = insertIndex === -1 ? lines.length : insertIndex;
46
+ const before = lines.slice(0, targetIndex);
47
+ const after = lines.slice(targetIndex);
48
+ const needsSpacer = before[before.length - 1]?.trim() !== '';
49
+ const nextLines = [...before, ...(needsSpacer ? [''] : []), linkLine(hubTitle), ...after];
50
+ return `${nextLines.join('\n').replace(/\s+$/u, '')}\n`;
51
+ };
52
+ const buildHubContent = (hubTitle, contextTitle, agentId) => [
53
+ '---',
54
+ `title: "${hubTitle.replaceAll('"', '\\"')}"`,
55
+ `agent: "${sanitizeAgentId(agentId)}"`,
56
+ '---',
57
+ '',
58
+ `# ${hubTitle}`,
59
+ '',
60
+ `Canonical hub for the ${contextTitle} context. #memory #hub`,
61
+ ''
62
+ ].join('\n');
63
+ const readNotes = async (vaultPath) => {
64
+ const absoluteVaultPath = await ensureVault(vaultPath);
65
+ const summaries = await readMarkdownFileSummaries(absoluteVaultPath);
66
+ return Promise.all(summaries.map(async (summary) => {
67
+ const content = await readFile(summary.absolutePath, 'utf8');
68
+ const document = parseMarkdownDocument({
69
+ absolutePath: summary.absolutePath,
70
+ vaultPath: absoluteVaultPath,
71
+ content,
72
+ createdAt: summary.createdAt,
73
+ updatedAt: summary.updatedAt
74
+ });
75
+ return {
76
+ summary,
77
+ content,
78
+ document
79
+ };
80
+ }));
81
+ };
82
+ export const ensureCanonicalContextHub = async (vaultPath, contextTitle, agentId = sharedAgentId) => {
83
+ const hubTitle = hubTitleForContext(contextTitle);
84
+ const notes = await readNotes(vaultPath);
85
+ const existing = notes.find((note) => normalizeTitle(note.document.title) === normalizeTitle(hubTitle));
86
+ const hubPath = existing?.summary.relativePath ?? hubPathForContext(contextTitle, agentId);
87
+ if (existing) {
88
+ return {
89
+ created: false,
90
+ title: hubTitle,
91
+ path: hubPath
92
+ };
93
+ }
94
+ const path = await writeMarkdownFile(vaultPath, hubPath, buildHubContent(hubTitle, contextTitle, agentId));
95
+ return {
96
+ created: true,
97
+ title: hubTitle,
98
+ path
99
+ };
100
+ };
101
+ export const canonicalizeContextLinks = async (vaultPath, options = {}) => {
102
+ const agentId = options.agentId ? sanitizeAgentId(options.agentId) : undefined;
103
+ const createMissingHubs = options.createMissingHubs !== false;
104
+ const notes = await readNotes(vaultPath);
105
+ const scopedNotes = agentId ? notes.filter((note) => note.document.agentId === agentId) : notes;
106
+ const knownTitles = new Set(notes.map((note) => normalizeTitle(note.document.title)));
107
+ const entries = [];
108
+ const ensureHub = async (contextTitle, hubTitle, targetAgentId) => {
109
+ if (knownTitles.has(normalizeTitle(hubTitle))) {
110
+ return true;
111
+ }
112
+ const path = hubPathForContext(contextTitle, targetAgentId);
113
+ if (!createMissingHubs) {
114
+ entries.push({
115
+ path,
116
+ title: hubTitle,
117
+ context: contextTitle,
118
+ hubTitle,
119
+ changed: false,
120
+ reason: 'missing-hub'
121
+ });
122
+ return false;
123
+ }
124
+ knownTitles.add(normalizeTitle(hubTitle));
125
+ if (!options.dryRun) {
126
+ await writeMarkdownFile(vaultPath, path, buildHubContent(hubTitle, contextTitle, targetAgentId));
127
+ }
128
+ entries.push({
129
+ path,
130
+ title: hubTitle,
131
+ context: contextTitle,
132
+ hubTitle,
133
+ changed: true,
134
+ reason: 'created-hub'
135
+ });
136
+ return true;
137
+ };
138
+ for (const note of scopedNotes) {
139
+ const context = inferVisualGraphContext(note.document);
140
+ const hubTitle = hubTitleForContext(context.title);
141
+ const isHub = normalizeTitle(note.document.title) === normalizeTitle(hubTitle);
142
+ if (isHub) {
143
+ entries.push({
144
+ path: note.summary.relativePath,
145
+ title: note.document.title,
146
+ context: context.title,
147
+ hubTitle,
148
+ changed: false,
149
+ reason: 'hub-note'
150
+ });
151
+ continue;
152
+ }
153
+ const hubAvailable = await ensureHub(context.title, hubTitle, note.document.agentId || sharedAgentId);
154
+ if (!hubAvailable) {
155
+ continue;
156
+ }
157
+ if (hasCanonicalLink(note.content, hubTitle)) {
158
+ entries.push({
159
+ path: note.summary.relativePath,
160
+ title: note.document.title,
161
+ context: context.title,
162
+ hubTitle,
163
+ changed: false,
164
+ reason: 'already-linked'
165
+ });
166
+ continue;
167
+ }
168
+ const nextContent = upsertCanonicalContextLink(note.content, hubTitle);
169
+ if (!options.dryRun) {
170
+ await writeMarkdownFile(vaultPath, note.summary.relativePath, nextContent);
171
+ }
172
+ entries.push({
173
+ path: note.summary.relativePath,
174
+ title: note.document.title,
175
+ context: context.title,
176
+ hubTitle,
177
+ changed: true,
178
+ reason: 'added-context-link'
179
+ });
180
+ }
181
+ const changed = entries.filter((entry) => entry.changed).length;
182
+ const createdHubs = entries.filter((entry) => entry.reason === 'created-hub' && entry.changed).length;
183
+ return {
184
+ dryRun: options.dryRun === true,
185
+ scanned: scopedNotes.length,
186
+ changed,
187
+ createdHubs,
188
+ skipped: entries.length - changed,
189
+ entries
190
+ };
191
+ };
192
+ export const addCanonicalContextLinkToContent = (title, content) => {
193
+ const context = inferVisualGraphContext({
194
+ id: '',
195
+ agentId: sharedAgentId,
196
+ title,
197
+ path: '',
198
+ content,
199
+ tags: [],
200
+ });
201
+ const hubTitle = hubTitleForContext(context.title);
202
+ const nextContent = normalizeTitle(title) === normalizeTitle(hubTitle) ? content : upsertCanonicalContextLink(content, hubTitle);
203
+ return {
204
+ content: nextContent,
205
+ context: context.title,
206
+ hubTitle,
207
+ changed: nextContent !== content
208
+ };
209
+ };
@@ -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
+ };