@andespindola/brainlink 0.1.0-beta.151 → 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/AGENTS.md CHANGED
@@ -6,7 +6,7 @@ This file tells coding agents and AI assistants how to use this repository.
6
6
 
7
7
  Brainlink is a local-first knowledge memory for agents.
8
8
 
9
- It reads a Markdown vault, extracts `[[wiki links]]` and `#tags`, builds a local file index at `.brainlink/index.json`, and returns compact context packages that agents can inject into prompts.
9
+ It reads a Markdown vault, extracts concise graph links from `## Context Links`, extracts `#tags`, builds a local file index at `.brainlink/index.json`, and returns compact context packages that agents can inject into prompts.
10
10
 
11
11
  ## Source Of Truth
12
12
 
@@ -27,15 +27,15 @@ By default, the installed Brainlink CLI uses `$HOME/.brainlink/vault` as its vau
27
27
  Use this loop when using Brainlink as memory:
28
28
 
29
29
  1. Write durable knowledge into Markdown notes.
30
- 2. Link related notes with explicit `[[Note Title]]` wiki links inside the note body.
30
+ 2. Link related notes with explicit `[[Note Title]]` wiki links inside a `## Context Links` section.
31
31
  3. Add explicit `#tags` for retrieval.
32
32
  4. Run `index` after writes.
33
33
  5. Run `context "<task or question>"` before answering.
34
34
  6. Use the returned sources as grounded context.
35
35
 
36
- `context` is read-only. It does not create notes, backlinks, graph edges or durable memory by itself. A relationship exists only when a Markdown note contains a `[[wiki link]]` to another note and the vault has been indexed after that write.
36
+ `context` is read-only. It does not create notes, backlinks, graph edges or durable memory by itself. A relationship exists only when a Markdown note contains a `[[wiki link]]` inside `## Context Links` and the vault has been indexed after that write.
37
37
 
38
- When an agent adds durable memory, it should connect the new note to at least one existing concept unless the note is intentionally a root concept. Prefer exact note titles in links, for example `[[Architecture]]`, and run `broken-links`, `orphans` or `validate` when the graph looks disconnected.
38
+ When an agent adds durable memory, it should add only the canonical relationships to `## Context Links`. Prefer exact note titles in links, for example `[[Architecture]]`, and run `broken-links`, `orphans` or `validate` when graph health matters.
39
39
 
40
40
  Agents can mark important relationships by placing priority hints on the same line as a wiki link, for example `[[Architecture]] priority: high`, `[[Incident Runbook]] #important` or `[[Incident Runbook]] #critical`. Indexed graph edges expose `weight` and `priority` so agents can sort related notes by importance.
41
41
 
package/README.md CHANGED
@@ -210,19 +210,19 @@ Only store knowledge that is likely to matter later:
210
210
  ```bash
211
211
  blink add "Testing Policy" \
212
212
  --agent "$BLINK_AGENT" \
213
- --content "Run npm run check before final delivery. Related: [[Release Checklist]]. #testing #process"
213
+ --content $'Run npm run check before final delivery. #testing #process\n\n## Context Links\n\n- [[Release Checklist]]'
214
214
  ```
215
215
 
216
- Brainlink does not infer durable graph relationships from generated context. A context result is only a read package for the model. To create a real link in the knowledge graph, the agent must write Markdown that contains an explicit `[[Note Title]]` wiki link.
216
+ Brainlink does not infer durable graph relationships from generated context. A context result is only a read package for the model. To create a real graph link, write a concise `## Context Links` section and put the canonical `[[Note Title]]` links there.
217
217
 
218
218
  Writes with `blink add` reindex the vault automatically by default. This can be disabled with `--no-auto-index` and controlled globally with `autoIndexOnWrite` in `brainlink.config.json`.
219
219
 
220
220
  When adding memory, follow this contract:
221
221
 
222
- - Link the new note to at least one existing note when there is a related concept.
222
+ - Link the new note to existing notes through `## Context Links` when there is a related concept.
223
223
  - Use the exact target note title inside `[[...]]`.
224
224
  - Add retrieval tags such as `#architecture`, `#decision`, `#runbook` or `#preference`.
225
- - Do not leave isolated notes unless they are intentionally root concepts.
225
+ - General wiki-link mentions outside `## Context Links` remain searchable Markdown content, but they do not become graph edges.
226
226
 
227
227
  If you disable auto-index, run `blink index` after batched writes.
228
228
 
@@ -554,7 +554,7 @@ If you disable `autoBootstrapOnRead` through `brainlink_policy`, read tools retu
554
554
  `brainlink_bootstrap`, `brainlink_policy` and preflight responses include structured `nextActions` so MCP clients can continue automatically without custom parsing.
555
555
  For one-call planning, use `brainlink_recommendations` to get the recommended tool sequence for the current vault/agent/query.
556
556
 
557
- The same linking rule applies through MCP: `brainlink_context` is read-only, and real graph links require Markdown notes with explicit `[[wiki links]]`. `brainlink_add_note` and `brainlink_add_file` reindex by default and include index + `writeConnectivity` metadata. Brainlink guarantees at least one edge per new note by auto-linking when needed.
557
+ The same linking rule applies through MCP: `brainlink_context` is read-only, and real graph links require Markdown notes with explicit `## Context Links` sections. `brainlink_add_note` and `brainlink_add_file` reindex by default and include index + `writeConnectivity` metadata. Brainlink does not auto-link new notes to fallback hubs.
558
558
 
559
559
  Agents can raise the importance of a relationship by putting priority markers on the same line as a wiki link:
560
560
 
@@ -563,7 +563,14 @@ Agents can raise the importance of a relationship by putting priority markers on
563
563
  Related: [[Incident Runbook]] #critical
564
564
  ```
565
565
 
566
- Indexed edges expose `weight` and `priority` (`low`, `normal`, `high`, `critical`) through CLI JSON, HTTP graph APIs and `brainlink_graph`. Brainlink indexes every non-self Markdown `[[wiki link]]` as a graph edge, including structural hub links such as `Memory Hub`, `Knowledge Root`, `MOC` and map notes. Old indexes are rebuilt automatically when their graph link model version is missing or stale.
566
+ Indexed edges expose `weight` and `priority` (`low`, `normal`, `high`, `critical`) through CLI JSON, HTTP graph APIs and `brainlink_graph`. Brainlink indexes every non-self `[[wiki link]]` inside `## Context Links` as a graph edge. Old indexes are rebuilt automatically when their graph link model version is missing or stale.
567
+
568
+ To migrate older vaults without deleting existing Markdown, generate concise context-link sections from current wiki-link mentions:
569
+
570
+ ```bash
571
+ blink migrate-context-links --vault ./vault --limit 5
572
+ blink index --vault ./vault --full
573
+ ```
567
574
 
568
575
  ## Graph UI
569
576
 
@@ -589,11 +596,12 @@ When native GUI is used, the GUI window automatically closes when the `blink ser
589
596
  The graph UI shows:
590
597
 
591
598
  - notes as nodes
592
- - all non-self `[[wiki links]]` as weighted edges
599
+ - all non-self `[[wiki links]]` inside `## Context Links` as weighted edges
593
600
  - default star layout centered on the primary hub, without rewriting or flattening underlying relationships
594
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
595
602
  - neutral graph nodes with segment/group metadata
596
603
  - agent selector (id-only labels) for isolated views
604
+ - context selector for segment-scoped star subgraphs derived from the visual graph context
597
605
  - graph filter matches title, path, tags and note content
598
606
  - graph filter keeps hub context nodes visible (`Memory Hub`/`MOC`/high-degree fallback) to preserve relationship readability
599
607
  - realtime refresh while `--watch` is enabled
@@ -625,6 +633,7 @@ The server always refuses non-loopback hosts. Brainlink HTTP only runs on localh
625
633
  Routes:
626
634
 
627
635
  - `GET /api/agents`
636
+ - `GET /api/graph-contexts`
628
637
  - `GET /api/graph`
629
638
  - `GET /api/graph-layout`
630
639
  - `GET /api/graph-view?x=<x>&y=<y>&w=<width>&h=<height>&scale=<scale>`
@@ -643,6 +652,7 @@ Read routes accept `agent=<agent-id>`:
643
652
 
644
653
  ```txt
645
654
  /api/graph-layout?agent=coding-agent
655
+ /api/graph-layout?agent=coding-agent&context=Architecture
646
656
  /api/search?q=typescript&agent=coding-agent&mode=hybrid
647
657
  /api/context?q=module-boundaries&agent=coding-agent&mode=semantic
648
658
  ```
@@ -1,8 +1,5 @@
1
- import { access } from 'node:fs/promises';
2
- import { join } from 'node:path';
3
1
  import { writeMarkdownFile } from '../infrastructure/file-system-vault.js';
4
2
  import { sanitizeAgentId, sharedAgentId } from '../domain/agents.js';
5
- import { extractWikiLinks } from '../domain/markdown.js';
6
3
  import { validateNoteInput } from '../domain/note-safety.js';
7
4
  import { ensureVault } from '../infrastructure/file-system-vault.js';
8
5
  const slugify = (title) => title
@@ -11,10 +8,6 @@ const slugify = (title) => title
11
8
  .toLowerCase()
12
9
  .replace(/[^a-z0-9]+/g, '-')
13
10
  .replace(/^-+|-+$/g, '');
14
- const systemHubTitle = 'Memory Hub';
15
- const systemRootTitle = 'Knowledge Root';
16
- const normalizeTitle = (title) => title.trim().replace(/\.md$/i, '').toLowerCase();
17
- const noteFilename = (agentId, title) => `agents/${agentId}/${slugify(title) || 'untitled'}.md`;
18
11
  const buildNote = (title, content, agentId) => [
19
12
  `---`,
20
13
  `title: "${title.replaceAll('"', '\\"')}"`,
@@ -26,38 +19,6 @@ const buildNote = (title, content, agentId) => [
26
19
  content.trim(),
27
20
  ''
28
21
  ].join('\n');
29
- const ensureSystemNote = async (vaultPath, absoluteVaultPath, agentId, title, content) => {
30
- const filename = noteFilename(agentId, title);
31
- const absolutePath = join(absoluteVaultPath, filename);
32
- try {
33
- await access(absolutePath);
34
- return;
35
- }
36
- catch { }
37
- await writeMarkdownFile(vaultPath, filename, buildNote(title, content, agentId));
38
- };
39
- const ensureNonOrphanContent = async (vaultPath, absoluteVaultPath, title, content, agentId) => {
40
- const links = extractWikiLinks(content).filter((link) => normalizeTitle(link) !== normalizeTitle(title));
41
- if (links.length > 0) {
42
- return {
43
- content: content.trim(),
44
- autoLinked: false,
45
- linkTarget: null
46
- };
47
- }
48
- const fallbackTitle = normalizeTitle(title) === normalizeTitle(systemHubTitle) ? systemRootTitle : systemHubTitle;
49
- if (fallbackTitle === systemRootTitle) {
50
- await ensureSystemNote(vaultPath, absoluteVaultPath, agentId, systemRootTitle, `Entry point for agent memory. [[${systemHubTitle}]] #memory #root`);
51
- }
52
- else {
53
- await ensureSystemNote(vaultPath, absoluteVaultPath, agentId, systemHubTitle, 'Central memory index for this agent namespace. #memory #hub');
54
- }
55
- return {
56
- content: `${content.trim()}\n\nRelated: [[${fallbackTitle}]]`,
57
- autoLinked: true,
58
- linkTarget: fallbackTitle
59
- };
60
- };
61
22
  export const addNoteWithMetadata = async (vaultPath, title, content, agentId = sharedAgentId, options = {}) => {
62
23
  validateNoteInput({
63
24
  title,
@@ -65,15 +26,14 @@ export const addNoteWithMetadata = async (vaultPath, title, content, agentId = s
65
26
  allowSensitive: options.allowSensitive
66
27
  });
67
28
  const sanitizedAgentId = sanitizeAgentId(agentId);
68
- const absoluteVaultPath = await ensureVault(vaultPath);
69
29
  const filename = `agents/${sanitizedAgentId}/${slugify(title) || 'untitled'}.md`;
70
- const linkedContent = await ensureNonOrphanContent(vaultPath, absoluteVaultPath, title, content, sanitizedAgentId);
71
- const note = buildNote(title, linkedContent.content, sanitizedAgentId);
30
+ await ensureVault(vaultPath);
31
+ const note = buildNote(title, content.trim(), sanitizedAgentId);
72
32
  const path = await writeMarkdownFile(vaultPath, filename, note);
73
33
  return {
74
34
  path,
75
- autoLinked: linkedContent.autoLinked,
76
- linkTarget: linkedContent.linkTarget
35
+ autoLinked: false,
36
+ linkTarget: null
77
37
  };
78
38
  };
79
39
  export const addNote = async (vaultPath, title, content, agentId = sharedAgentId, options = {}) => (await addNoteWithMetadata(vaultPath, title, content, agentId, options)).path;
@@ -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('#', '')
@@ -298,51 +353,6 @@ const list = (items) => {
298
353
  .join('')
299
354
  }
300
355
 
301
- const extractContextLinks = (content) => {
302
- if (typeof content !== 'string' || content.length === 0) {
303
- return []
304
- }
305
- const lines = content.split(/\\r?\\n/)
306
- let start = -1
307
- for (let index = 0; index < lines.length; index += 1) {
308
- if (/^#{1,6}\\s+(?:context\\s+links?|links?\\s+de\\s+contexto)\\b/i.test(lines[index].trim())) {
309
- start = index + 1
310
- break
311
- }
312
- }
313
- if (start < 0) {
314
- return []
315
- }
316
-
317
- const links = []
318
- const seenTitles = new Set()
319
- for (let index = start; index < lines.length; index += 1) {
320
- const line = lines[index].trim()
321
- if (!line) {
322
- continue
323
- }
324
- if (/^#{1,6}\\s+/.test(line)) {
325
- break
326
- }
327
- const matches = Array.from(line.matchAll(/\\[\\[([^\\]]+)\\]\\]/g))
328
- if (matches.length === 0) {
329
- continue
330
- }
331
- const priorityMatch = line.match(/#(critical|important)\\b|priority:\\s*(high|critical)/i)
332
- const priority = priorityMatch ? String(priorityMatch[1] || priorityMatch[2] || 'normal').toLowerCase() : 'normal'
333
-
334
- for (let matchIndex = 0; matchIndex < matches.length; matchIndex += 1) {
335
- const title = String(matches[matchIndex][1] || '').trim()
336
- if (!title || seenTitles.has(title.toLowerCase())) {
337
- continue
338
- }
339
- seenTitles.add(title.toLowerCase())
340
- links.push({ title, priority })
341
- }
342
- }
343
- return links
344
- }
345
-
346
356
  const buildFacts = (node, outgoingCount, incomingCount) => {
347
357
  const content = typeof node?.content === 'string' ? node.content : ''
348
358
  const words = content.trim().length > 0 ? content.trim().split(/\\s+/).length : 0
@@ -368,6 +378,21 @@ const listContextLinks = (links) => {
368
378
  .join('')
369
379
  }
370
380
 
381
+ const nodeContextLinks = (node, outgoing) => {
382
+ const titles = Array.isArray(node?.contextLinks) ? node.contextLinks : []
383
+ const outgoingByTitle = new Map(normalizeList(outgoing).map((link) => [String(link.title || '').toLowerCase(), link]))
384
+
385
+ return titles
386
+ .map((title) => {
387
+ const match = outgoingByTitle.get(String(title).toLowerCase())
388
+ return {
389
+ title,
390
+ priority: match?.priority || 'normal'
391
+ }
392
+ })
393
+ .filter((link) => typeof link.title === 'string' && link.title.trim().length > 0)
394
+ }
395
+
371
396
  const linkedNodes = (node) => {
372
397
  const nodeById = new Map((state.chunk.nodes || []).map((item) => [item[0], item]))
373
398
  const edges = normalizeList(state.chunk.edges)
@@ -405,7 +430,7 @@ const loadNodeDetails = async (nodeId) => {
405
430
  return
406
431
  }
407
432
 
408
- const response = await fetch('/api/graph-node?id=' + encodeURIComponent(nodeId) + agentQuery('&'))
433
+ const response = await fetch('/api/graph-node?id=' + encodeURIComponent(nodeId) + scopeQuery('&'))
409
434
  if (!response.ok) {
410
435
  throw new Error('Failed to load graph node details')
411
436
  }
@@ -431,7 +456,7 @@ const loadNodeDetails = async (nodeId) => {
431
456
  : '<span>No tags</span>'
432
457
 
433
458
  const related = linkedNodes(node)
434
- const contextLinks = extractContextLinks(node.content)
459
+ const contextLinks = nodeContextLinks(node, related.outgoing)
435
460
  const facts = buildFacts(node, related.outgoing.length, related.incoming.length)
436
461
  elements.contentFacts.innerHTML = listFacts(facts)
437
462
  elements.contentContextLinks.innerHTML = listContextLinks(contextLinks)
@@ -509,6 +534,9 @@ const fetchChunk = async ({ fit } = { fit: false }) => {
509
534
  if (state.agentId) {
510
535
  params.set('agent', state.agentId)
511
536
  }
537
+ if (state.contextId) {
538
+ params.set('context', state.contextId)
539
+ }
512
540
 
513
541
  const response = await fetch('/api/graph-stream?' + params.toString(), { signal: controller.signal })
514
542
  if (!response.ok) {
@@ -781,7 +809,7 @@ const setupControls = () => {
781
809
  return
782
810
  }
783
811
 
784
- fetch('/api/graph-filter?q=' + encodeURIComponent(query) + '&limit=1800' + agentQuery('&'))
812
+ fetch('/api/graph-filter?q=' + encodeURIComponent(query) + '&limit=1800' + scopeQuery('&'))
785
813
  .then((response) => response.json())
786
814
  .then((payload) => {
787
815
  if (token !== state.searchToken) {
@@ -825,12 +853,50 @@ const loadAgents = async () => {
825
853
  state.agentId = elements.agent.value || ''
826
854
  writeStoredAgent(state.agentId)
827
855
  syncAgentInUrl(state.agentId)
828
- scheduleChunkFetch({ fit: true })
856
+ loadContexts().then(() => scheduleChunkFetch({ fit: true })).catch((error) => console.error(error))
829
857
  })
830
858
 
831
859
  syncAgentInUrl(state.agentId)
832
860
  }
833
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
+
834
900
  const setupRenderWorker = () => {
835
901
  const hasWorker = typeof Worker !== 'undefined'
836
902
  const canTransfer = typeof canvas.transferControlToOffscreen === 'function'
@@ -922,6 +988,7 @@ const bootstrap = async () => {
922
988
  setupRenderWorker()
923
989
  setupInput()
924
990
  setupControls()
991
+ setupContextControl()
925
992
  wireNodeLinkClicks()
926
993
 
927
994
  window.addEventListener('resize', () => {
@@ -930,12 +997,11 @@ const bootstrap = async () => {
930
997
  })
931
998
 
932
999
  await loadAgents()
1000
+ await loadContexts()
933
1001
  updateTotals()
934
1002
  updateTagCount()
935
1003
 
936
- if (state.rendererMode === 'fallback') {
937
- scheduleChunkFetch({ fit: true })
938
- }
1004
+ scheduleChunkFetch({ fit: true })
939
1005
  }
940
1006
 
941
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]));
@@ -3,7 +3,7 @@ import { access } from 'node:fs/promises';
3
3
  import { basename, extname, join, relative, resolve } from 'node:path';
4
4
  import { pathToFileURL } from 'node:url';
5
5
  import { promisify } from 'node:util';
6
- import { extractTags, extractWikiLinks } from '../domain/markdown.js';
6
+ import { extractTags } from '../domain/markdown.js';
7
7
  import { sanitizeAgentId, sharedAgentId } from '../domain/agents.js';
8
8
  import { ensureVault, listVaultFiles, writeMarkdownFile } from '../infrastructure/file-system-vault.js';
9
9
  import { getBrainlinkHomePath } from '../infrastructure/paths.js';
@@ -17,9 +17,6 @@ const agentColumnCandidates = ['agent', 'agent_id', 'namespace', 'scope'];
17
17
  const tagColumnCandidates = ['tags', 'tag_list', 'keywords'];
18
18
  const createdColumnCandidates = ['created_at', 'createdat', 'created', 'ctime'];
19
19
  const updatedColumnCandidates = ['updated_at', 'updatedat', 'updated', 'mtime'];
20
- const systemHubTitle = 'Memory Hub';
21
- const systemRootTitle = 'Knowledge Root';
22
- const normalizeTitle = (title) => title.trim().replace(/\.md$/i, '').toLowerCase();
23
20
  const slugify = (title) => title
24
21
  .normalize('NFKD')
25
22
  .replace(/[\u0300-\u036f]/g, '')
@@ -203,31 +200,6 @@ const reserveUniquePath = (agentId, title, reserved) => {
203
200
  }
204
201
  throw new Error(`Could not allocate unique path for imported note: ${title}`);
205
202
  };
206
- const ensureSystemNote = async (vaultPath, reserved, created, agentId, title, content, dryRun) => {
207
- const filename = noteRelativePath(agentId, slugify(title));
208
- if (reserved.has(filename)) {
209
- return;
210
- }
211
- reserved.add(filename);
212
- created.add(filename);
213
- if (dryRun) {
214
- return;
215
- }
216
- await writeMarkdownFile(vaultPath, filename, buildNote(title, content, agentId));
217
- };
218
- const applyConnectivityRule = async (vaultPath, reserved, created, title, content, agentId, dryRun) => {
219
- const links = extractWikiLinks(content).filter((link) => normalizeTitle(link) !== normalizeTitle(title));
220
- if (links.length > 0) {
221
- return content.trim();
222
- }
223
- const normalized = normalizeTitle(title);
224
- if (normalized === normalizeTitle(systemHubTitle)) {
225
- await ensureSystemNote(vaultPath, reserved, created, agentId, systemRootTitle, `Entry point for agent memory. [[${systemHubTitle}]] #memory #root`, dryRun);
226
- return `${content.trim()}\n\nRelated: [[${systemRootTitle}]]`;
227
- }
228
- await ensureSystemNote(vaultPath, reserved, created, agentId, systemHubTitle, 'Central memory index for this agent namespace. #memory #hub', dryRun);
229
- return `${content.trim()}\n\nRelated: [[${systemHubTitle}]]`;
230
- };
231
203
  const importRowsFromMapping = async (vaultPath, dbPath, mapping, options, reserved) => {
232
204
  const limit = Number.isFinite(options.limit) && (options.limit ?? 0) > 0 ? Math.floor(options.limit ?? 0) : undefined;
233
205
  const sql = [
@@ -243,7 +215,6 @@ const importRowsFromMapping = async (vaultPath, dbPath, mapping, options, reserv
243
215
  ...(limit ? [`LIMIT ${limit}`] : [])
244
216
  ].join(' ');
245
217
  const rows = await runSqliteQuery(dbPath, sql);
246
- const createdSystemNotes = new Set();
247
218
  const importedFiles = [];
248
219
  let imported = 0;
249
220
  let skipped = 0;
@@ -256,8 +227,7 @@ const importRowsFromMapping = async (vaultPath, dbPath, mapping, options, reserv
256
227
  const agentId = sanitizeAgentId(options.agentOverride || row.agent || sharedAgentId);
257
228
  const filename = reserveUniquePath(agentId, row.title, reserved);
258
229
  const mergedContent = appendMissingTags(row.content, row.tags);
259
- const connectedContent = await applyConnectivityRule(vaultPath, reserved, createdSystemNotes, row.title, mergedContent, agentId, options.dryRun === true);
260
- const note = buildNote(row.title, connectedContent, agentId);
230
+ const note = buildNote(row.title, mergedContent.trim(), agentId);
261
231
  if (options.dryRun !== true) {
262
232
  await writeMarkdownFile(vaultPath, filename, note);
263
233
  }
@@ -268,7 +238,7 @@ const importRowsFromMapping = async (vaultPath, dbPath, mapping, options, reserv
268
238
  rowsRead: rows.length,
269
239
  imported,
270
240
  skipped,
271
- createdSystemNotes: createdSystemNotes.size,
241
+ createdSystemNotes: 0,
272
242
  importedFiles
273
243
  };
274
244
  };
@@ -0,0 +1,79 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { ensureVault, readMarkdownFileSummaries, writeMarkdownFile } from '../infrastructure/file-system-vault.js';
3
+ import { extractContextLinkWeights, extractWikiLinkWeights, hasContextLinksSection, parseMarkdownDocument } from '../domain/markdown.js';
4
+ const defaultContextLinkLimit = 5;
5
+ const normalizeTitle = (title) => title.trim().replace(/\.md$/i, '').toLowerCase();
6
+ const formatPriority = (priority) => priority === 'normal' ? '' : ` priority: ${priority}`;
7
+ const formatContextLinksSection = (links) => [
8
+ '## Context Links',
9
+ '',
10
+ ...links.map((link) => `- [[${link.title}]]${formatPriority(link.priority)}`)
11
+ ].join('\n');
12
+ const appendContextLinksSection = (content, links) => `${content.trimEnd()}\n\n${formatContextLinksSection(links)}\n`;
13
+ const selectContextLinkCandidates = (content, title, limit) => extractWikiLinkWeights(content)
14
+ .filter((link) => normalizeTitle(link.title) !== normalizeTitle(title))
15
+ .slice(0, limit)
16
+ .map((link) => ({
17
+ title: link.title,
18
+ priority: link.priority,
19
+ weight: link.weight
20
+ }));
21
+ export const migrateContextLinks = async (vaultPath, options = {}) => {
22
+ const absoluteVaultPath = await ensureVault(vaultPath);
23
+ const limit = Math.max(1, Math.floor(options.limit ?? defaultContextLinkLimit));
24
+ const summaries = await readMarkdownFileSummaries(absoluteVaultPath);
25
+ const entries = [];
26
+ for (const summary of summaries) {
27
+ const content = await readFile(summary.absolutePath, 'utf8');
28
+ const document = parseMarkdownDocument({
29
+ absolutePath: summary.absolutePath,
30
+ vaultPath: absoluteVaultPath,
31
+ content,
32
+ createdAt: summary.createdAt,
33
+ updatedAt: summary.updatedAt
34
+ });
35
+ if (options.agentId && document.agentId !== options.agentId) {
36
+ continue;
37
+ }
38
+ if (hasContextLinksSection(content)) {
39
+ entries.push({
40
+ path: summary.relativePath,
41
+ title: document.title,
42
+ changed: false,
43
+ reason: 'already-has-context-links',
44
+ links: extractContextLinkWeights(content)
45
+ });
46
+ continue;
47
+ }
48
+ const links = selectContextLinkCandidates(content, document.title, limit);
49
+ if (links.length === 0) {
50
+ entries.push({
51
+ path: summary.relativePath,
52
+ title: document.title,
53
+ changed: false,
54
+ reason: 'no-link-candidates',
55
+ links
56
+ });
57
+ continue;
58
+ }
59
+ if (!options.dryRun) {
60
+ await writeMarkdownFile(vaultPath, summary.relativePath, appendContextLinksSection(content, links));
61
+ }
62
+ entries.push({
63
+ path: summary.relativePath,
64
+ title: document.title,
65
+ changed: true,
66
+ reason: 'added-context-links',
67
+ links
68
+ });
69
+ }
70
+ const changed = entries.filter((entry) => entry.changed).length;
71
+ return {
72
+ dryRun: options.dryRun === true,
73
+ scanned: entries.length,
74
+ changed,
75
+ skipped: entries.length - changed,
76
+ limit,
77
+ entries
78
+ };
79
+ };
@@ -1,10 +1,61 @@
1
+ import { stat } from 'node:fs/promises';
1
2
  import { ensureVault } from '../infrastructure/file-system-vault.js';
2
- import { openFileIndex } from '../infrastructure/file-index.js';
3
+ import { indexStoragePath, openFileIndex } from '../infrastructure/file-index.js';
4
+ const graphSearchCacheTtlMs = 20_000;
5
+ const graphSearchCacheMaxEntries = 120;
6
+ const graphSearchCache = new Map();
7
+ const readIndexSignature = async (vaultPath) => {
8
+ try {
9
+ const info = await stat(indexStoragePath(vaultPath));
10
+ return `${Math.floor(info.mtimeMs)}:${info.size}`;
11
+ }
12
+ catch {
13
+ return '0:0';
14
+ }
15
+ };
16
+ const cacheKey = (vaultPath, query, limit, agentId) => JSON.stringify({
17
+ vaultPath,
18
+ query: query.trim().toLowerCase(),
19
+ limit,
20
+ agentId: agentId?.trim().toLowerCase() ?? '*'
21
+ });
22
+ const readCached = (key, indexSignature) => {
23
+ const entry = graphSearchCache.get(key);
24
+ if (!entry) {
25
+ return undefined;
26
+ }
27
+ const fresh = Date.now() - entry.createdAt <= graphSearchCacheTtlMs && entry.indexSignature === indexSignature;
28
+ if (!fresh) {
29
+ graphSearchCache.delete(key);
30
+ return undefined;
31
+ }
32
+ return entry.nodeIds;
33
+ };
34
+ const writeCached = (key, entry) => {
35
+ graphSearchCache.set(key, entry);
36
+ if (graphSearchCache.size <= graphSearchCacheMaxEntries) {
37
+ return;
38
+ }
39
+ const overflow = graphSearchCache.size - graphSearchCacheMaxEntries;
40
+ Array.from(graphSearchCache.keys()).slice(0, overflow).forEach((cacheKey) => graphSearchCache.delete(cacheKey));
41
+ };
3
42
  export const searchGraphNodeIds = async (vaultPath, query, limit, agentId) => {
4
43
  const absoluteVaultPath = await ensureVault(vaultPath);
44
+ const indexSignature = await readIndexSignature(absoluteVaultPath);
45
+ const key = cacheKey(absoluteVaultPath, query, limit, agentId);
46
+ const cached = readCached(key, indexSignature);
47
+ if (cached) {
48
+ return cached;
49
+ }
5
50
  const index = openFileIndex(absoluteVaultPath);
6
51
  try {
7
- return await index.searchGraphNodeIds(query, limit, agentId);
52
+ const nodeIds = await index.searchGraphNodeIds(query, limit, agentId);
53
+ writeCached(key, {
54
+ createdAt: Date.now(),
55
+ indexSignature,
56
+ nodeIds
57
+ });
58
+ return nodeIds;
8
59
  }
9
60
  finally {
10
61
  index.close();
@@ -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);
@@ -8,6 +8,7 @@ import { buildContextPackage } from '../../application/build-context.js';
8
8
  import { resolveDuplicateNotes, scanDuplicateNotes } from '../../application/dedupe-notes.js';
9
9
  import { importLegacySqliteDatabase } from '../../application/import-legacy-sqlite.js';
10
10
  import { indexVault, indexVaultWithOptions } from '../../application/index-vault.js';
11
+ import { migrateContextLinks } from '../../application/migrate-context-links.js';
11
12
  import { migrateVaultContent, planVaultMigration, previewVaultMigration, shouldMigrateDefaultVault } from '../../application/migrate-vault.js';
12
13
  import { createOfflinePackBackup } from '../../application/offline-pack-backup.js';
13
14
  import { startServer } from '../../application/start-server.js';
@@ -699,6 +700,37 @@ export const registerWriteCommands = (program) => {
699
700
  return `${summary}${indexMessage}${reportMessage}`;
700
701
  });
701
702
  });
703
+ program
704
+ .command('migrate-context-links')
705
+ .option('-v, --vault <vault>', 'vault directory')
706
+ .option('-a, --agent <agent>', 'agent memory namespace')
707
+ .option('-l, --limit <limit>', 'maximum context links to add per note', '5')
708
+ .option('--dry-run', 'preview context-link migration without writing files')
709
+ .option('--no-index', 'skip reindexing after migration')
710
+ .option('--json', 'print machine-readable JSON')
711
+ .description('add concise Context Links sections from existing wiki-link mentions')
712
+ .action(async (options) => {
713
+ const resolved = await resolveOptions(options);
714
+ const result = await migrateContextLinks(resolved.vault, {
715
+ dryRun: options.dryRun === true,
716
+ limit: parsePositiveInteger(options.limit ?? '5', 5),
717
+ agentId: resolved.agent
718
+ });
719
+ const shouldIndex = options.index !== false && !result.dryRun && result.changed > 0;
720
+ const index = shouldIndex ? await indexVault(resolved.vault, { full: true }) : undefined;
721
+ print(options.json, {
722
+ vault: resolved.vault,
723
+ agent: resolved.agent ?? 'shared',
724
+ ...result,
725
+ ...(index ? { index } : {})
726
+ }, () => {
727
+ const mode = result.dryRun ? 'Previewed' : 'Migrated';
728
+ const indexMessage = index
729
+ ? ` Fully reindexed ${index.documentCount} documents, ${index.chunkCount} chunks and ${index.linkCount} context links.`
730
+ : '';
731
+ return `${mode} ${result.scanned} notes: changed=${result.changed}, skipped=${result.skipped}, limit=${result.limit}.${indexMessage}`;
732
+ });
733
+ });
702
734
  program
703
735
  .command('db-import')
704
736
  .option('-v, --vault <vault>', 'vault directory')
@@ -792,7 +824,7 @@ export const registerWriteCommands = (program) => {
792
824
  writeConnectivity: {
793
825
  autoLinked: added.autoLinked,
794
826
  linkTarget: added.linkTarget,
795
- guaranteedEdge: true
827
+ guaranteedEdge: false
796
828
  },
797
829
  possibleDuplicates,
798
830
  ...(index ? { index } : {})
@@ -6,6 +6,7 @@ const frontmatterPattern = /^---\n([\s\S]*?)\n---\n?/;
6
6
  const wikiLinkPattern = /\[\[([^\]|#]+)(?:#[^\]|]+)?(?:\|[^\]]+)?\]\]/g;
7
7
  const tagPattern = /(^|\s)#([A-Za-z0-9][A-Za-z0-9_-]*)/g;
8
8
  const headingPattern = /^#\s+(.+)$/m;
9
+ const contextLinksHeadingPattern = /^(#{1,6})\s+(?:context\s+links?|links?\s+de\s+contexto)\b/i;
9
10
  const priorityRanks = {
10
11
  low: 0,
11
12
  normal: 1,
@@ -18,7 +19,7 @@ const priorityBoosts = {
18
19
  high: 3,
19
20
  critical: 6
20
21
  };
21
- export const graphLinkModelVersion = 3;
22
+ export const graphLinkModelVersion = 4;
22
23
  const priorityPatterns = [
23
24
  ['critical', /\b(?:priority|prioridade|importance|importancia|importância)\s*[:=]\s*(?:critical|critica|crítica|urgent|urgente|p0)\b/i],
24
25
  ['critical', /#(?:critical|critica|crítica|urgent|urgente|p0)\b/i],
@@ -113,6 +114,35 @@ const compareGraphLinks = (left, right) => {
113
114
  export const selectGraphWikiLinkWeights = (links) => {
114
115
  return [...links].sort(compareGraphLinks);
115
116
  };
117
+ const contextLinkLines = (content) => {
118
+ const lines = visibleMarkdownLines(content);
119
+ const selected = [];
120
+ let insideContextLinks = false;
121
+ let headingDepth = 0;
122
+ for (let index = 0; index < lines.length; index += 1) {
123
+ const line = lines[index];
124
+ const trimmed = line.content.trim();
125
+ if (line.fenced) {
126
+ continue;
127
+ }
128
+ const heading = trimmed.match(/^(#{1,6})\s+/);
129
+ const contextHeading = trimmed.match(contextLinksHeadingPattern);
130
+ if (contextHeading) {
131
+ insideContextLinks = true;
132
+ headingDepth = contextHeading[1].length;
133
+ continue;
134
+ }
135
+ if (insideContextLinks && heading && heading[1].length <= headingDepth) {
136
+ insideContextLinks = false;
137
+ }
138
+ if (insideContextLinks) {
139
+ selected.push(line.content);
140
+ }
141
+ }
142
+ return selected;
143
+ };
144
+ export const hasContextLinksSection = (content) => visibleMarkdownLines(content).some((line) => !line.fenced && contextLinksHeadingPattern.test(line.content.trim()));
145
+ export const extractContextLinkWeights = (content) => selectGraphWikiLinkWeights(extractWikiLinkWeights(contextLinkLines(content).join('\n')));
116
146
  const extractTitle = (filePath, content, frontmatter) => {
117
147
  if (frontmatter.title) {
118
148
  return normalizeTitle(frontmatter.title);
@@ -197,6 +227,7 @@ export const parseMarkdownDocument = (input) => {
197
227
  content: input.content,
198
228
  tags: extractTags(input.content),
199
229
  links: extractWikiLinks(input.content),
230
+ contextLinks: extractContextLinkWeights(input.content).map((link) => link.title),
200
231
  frontmatter,
201
232
  createdAt: input.createdAt.toISOString(),
202
233
  updatedAt: input.updatedAt.toISOString()
@@ -204,7 +235,7 @@ export const parseMarkdownDocument = (input) => {
204
235
  };
205
236
  export const createIndexedDocument = (document, titleToDocumentId, maxChunkCharacters = 1200) => {
206
237
  const chunks = splitIntoChunks(document.id, document.content, maxChunkCharacters);
207
- const graphLinkWeights = selectGraphWikiLinkWeights(extractWikiLinkWeights(document.content).filter((link) => link.title.toLowerCase() !== document.title.toLowerCase()));
238
+ const graphLinkWeights = selectGraphWikiLinkWeights(extractContextLinkWeights(document.content).filter((link) => link.title.toLowerCase() !== document.title.toLowerCase()));
208
239
  const linkWeights = new Map(graphLinkWeights.map((link) => [link.title.toLowerCase(), link]));
209
240
  const links = graphLinkWeights
210
241
  .map((link) => ({
@@ -266,7 +266,8 @@ export const openFileIndex = (vaultPath) => {
266
266
  title: document.title,
267
267
  path: document.path,
268
268
  content: document.content,
269
- tags: document.tags
269
+ tags: document.tags,
270
+ contextLinks: document.contextLinks ?? []
270
271
  })),
271
272
  edges
272
273
  };
@@ -292,7 +293,8 @@ export const openFileIndex = (vaultPath) => {
292
293
  title: document.title,
293
294
  path: document.path,
294
295
  content: '',
295
- tags: document.tags
296
+ tags: document.tags,
297
+ contextLinks: document.contextLinks ?? []
296
298
  })),
297
299
  edges
298
300
  };
@@ -309,7 +311,8 @@ export const openFileIndex = (vaultPath) => {
309
311
  title: document.title,
310
312
  path: document.path,
311
313
  content: document.content,
312
- tags: document.tags
314
+ tags: document.tags,
315
+ contextLinks: document.contextLinks ?? []
313
316
  }
314
317
  : undefined;
315
318
  },
package/dist/mcp/tools.js CHANGED
@@ -420,7 +420,7 @@ export const addNoteTool = async (input) => {
420
420
  writeConnectivity: {
421
421
  autoLinked: added.autoLinked,
422
422
  linkTarget: added.linkTarget,
423
- guaranteedEdge: true
423
+ guaranteedEdge: false
424
424
  },
425
425
  possibleDuplicates,
426
426
  ...(index ? { index } : {})
@@ -467,7 +467,7 @@ export const addFileTool = async (input) => {
467
467
  writeConnectivity: {
468
468
  autoLinked: added.autoLinked,
469
469
  linkTarget: added.linkTarget,
470
- guaranteedEdge: true
470
+ guaranteedEdge: false
471
471
  },
472
472
  ...(index ? { index } : {})
473
473
  });
@@ -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.151",
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",