@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 +4 -4
- package/README.md +14 -7
- package/dist/application/add-note.js +4 -44
- package/dist/application/frontend/client-js.js +16 -46
- package/dist/application/import-legacy-sqlite.js +3 -33
- package/dist/application/migrate-context-links.js +79 -0
- package/dist/application/search-graph-node-ids.js +53 -2
- package/dist/cli/commands/write-commands.js +33 -1
- package/dist/domain/markdown.js +33 -2
- package/dist/infrastructure/file-index.js +6 -3
- package/dist/mcp/tools.js +2 -2
- package/package.json +1 -1
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
|
|
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
|
|
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]]`
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
-
|
|
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 `
|
|
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
|
|
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
|
-
|
|
71
|
-
const note = buildNote(title,
|
|
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:
|
|
76
|
-
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 =
|
|
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
|
|
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
|
|
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:
|
|
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
|
-
|
|
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:
|
|
827
|
+
guaranteedEdge: false
|
|
796
828
|
},
|
|
797
829
|
possibleDuplicates,
|
|
798
830
|
...(index ? { index } : {})
|
package/dist/domain/markdown.js
CHANGED
|
@@ -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 =
|
|
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(
|
|
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:
|
|
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:
|
|
470
|
+
guaranteedEdge: false
|
|
471
471
|
},
|
|
472
472
|
...(index ? { index } : {})
|
|
473
473
|
});
|
package/package.json
CHANGED