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

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,7 +596,7 @@ 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
@@ -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;
@@ -298,51 +298,6 @@ const list = (items) => {
298
298
  .join('')
299
299
  }
300
300
 
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
301
  const buildFacts = (node, outgoingCount, incomingCount) => {
347
302
  const content = typeof node?.content === 'string' ? node.content : ''
348
303
  const words = content.trim().length > 0 ? content.trim().split(/\\s+/).length : 0
@@ -368,6 +323,21 @@ const listContextLinks = (links) => {
368
323
  .join('')
369
324
  }
370
325
 
326
+ const nodeContextLinks = (node, outgoing) => {
327
+ const titles = Array.isArray(node?.contextLinks) ? node.contextLinks : []
328
+ const outgoingByTitle = new Map(normalizeList(outgoing).map((link) => [String(link.title || '').toLowerCase(), link]))
329
+
330
+ return titles
331
+ .map((title) => {
332
+ const match = outgoingByTitle.get(String(title).toLowerCase())
333
+ return {
334
+ title,
335
+ priority: match?.priority || 'normal'
336
+ }
337
+ })
338
+ .filter((link) => typeof link.title === 'string' && link.title.trim().length > 0)
339
+ }
340
+
371
341
  const linkedNodes = (node) => {
372
342
  const nodeById = new Map((state.chunk.nodes || []).map((item) => [item[0], item]))
373
343
  const edges = normalizeList(state.chunk.edges)
@@ -431,7 +401,7 @@ const loadNodeDetails = async (nodeId) => {
431
401
  : '<span>No tags</span>'
432
402
 
433
403
  const related = linkedNodes(node)
434
- const contextLinks = extractContextLinks(node.content)
404
+ const contextLinks = nodeContextLinks(node, related.outgoing)
435
405
  const facts = buildFacts(node, related.outgoing.length, related.incoming.length)
436
406
  elements.contentFacts.innerHTML = listFacts(facts)
437
407
  elements.contentContextLinks.innerHTML = listContextLinks(contextLinks)
@@ -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();
@@ -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
  });
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.152",
4
4
  "description": "Local-first knowledge memory for agents with Markdown, backlinks, indexing and context retrieval.",
5
5
  "type": "module",
6
6
  "license": "MIT",