@andespindola/brainlink 0.1.0-beta.153 → 0.1.0-beta.154
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +2 -2
- package/README.md +23 -7
- package/dist/application/add-note.js +12 -3
- package/dist/application/canonical-context-links.js +209 -0
- package/dist/application/get-graph-layout.js +3 -2
- package/dist/application/search-graph-node-ids.js +14 -5
- package/dist/application/server/routes.js +1 -1
- package/dist/cli/commands/write-commands.js +47 -8
- package/dist/domain/graph-contexts.js +159 -0
- package/dist/domain/graph-layout.js +10 -1
- package/dist/infrastructure/config.js +4 -0
- package/dist/mcp/server.js +11 -1
- package/dist/mcp/tools.js +64 -5
- package/docs/AGENT_USAGE.md +7 -2
- package/package.json +1 -1
package/AGENTS.md
CHANGED
|
@@ -78,10 +78,10 @@ http://127.0.0.1:4321/
|
|
|
78
78
|
http://127.0.0.1:4321/api/graph
|
|
79
79
|
```
|
|
80
80
|
|
|
81
|
-
|
|
81
|
+
The graph server watches Markdown files by default while editing notes:
|
|
82
82
|
|
|
83
83
|
```bash
|
|
84
|
-
npm run dev -- server --vault ./vault
|
|
84
|
+
npm run dev -- server --vault ./vault
|
|
85
85
|
npm run dev -- watch --vault ./vault
|
|
86
86
|
npm run dev -- bench --vault ./vault
|
|
87
87
|
npm run dev -- bench --vault ./vault --watch
|
package/README.md
CHANGED
|
@@ -265,7 +265,7 @@ blink search "jwt auth" --vault ./vault
|
|
|
265
265
|
|
|
266
266
|
blink context "how does auth work?" --vault ./vault
|
|
267
267
|
|
|
268
|
-
blink server --vault ./vault
|
|
268
|
+
blink server --vault ./vault
|
|
269
269
|
```
|
|
270
270
|
|
|
271
271
|
Open the graph UI:
|
|
@@ -536,6 +536,7 @@ Available tools:
|
|
|
536
536
|
- `brainlink_resolve_duplicate`: resolve duplicate pairs (`merge`, `link`, `ignore`) with connectivity-safe fallback edges.
|
|
537
537
|
- `brainlink_add_note`: write durable Markdown memory and reindex.
|
|
538
538
|
- `brainlink_add_file`: ingest a local file as a note and reindex.
|
|
539
|
+
- `brainlink_canonicalize_context_links`: ensure existing notes link to inferred context hubs.
|
|
539
540
|
- `brainlink_volatile_add`: write temporary agent-decided memory with TTL; volatile sections are included in context and never create durable graph edges.
|
|
540
541
|
- `brainlink_volatile_clear`: clear temporary memory for the current vault/agent namespace.
|
|
541
542
|
- `brainlink_index`: rebuild the vault index. Pass `full=true` for a complete source reindex.
|
|
@@ -543,6 +544,7 @@ Available tools:
|
|
|
543
544
|
- `brainlink_validate`: validate broken links and orphan notes.
|
|
544
545
|
- `brainlink_sync`: run index, stats, validation, broken-link and orphan checks in one call.
|
|
545
546
|
- `brainlink_graph`: read indexed graph nodes and weighted links.
|
|
547
|
+
- `brainlink_graph_contexts`: list the visual graph contexts used by the local server.
|
|
546
548
|
- `brainlink_broken_links`: list unresolved wiki links.
|
|
547
549
|
- `brainlink_orphans`: list disconnected notes.
|
|
548
550
|
|
|
@@ -577,10 +579,11 @@ blink index --vault ./vault --full
|
|
|
577
579
|
Start the local frontend:
|
|
578
580
|
|
|
579
581
|
```bash
|
|
580
|
-
blink server --host 127.0.0.1 --port 4321
|
|
582
|
+
blink server --host 127.0.0.1 --port 4321
|
|
581
583
|
```
|
|
582
584
|
|
|
583
585
|
By default, the server uses `$HOME/.brainlink/vault`. Pass `--vault ./vault` only when you want to inspect a custom vault.
|
|
586
|
+
By default, the server watches Markdown files in local filesystem vaults and reindexes after note changes. Use `--no-watch` to disable realtime reindexing.
|
|
584
587
|
By default, `blink server` tries to open the graph in a native desktop GUI window:
|
|
585
588
|
- macOS: Swift + WebKit
|
|
586
589
|
- Windows: PowerShell WinForms WebBrowser
|
|
@@ -604,7 +607,7 @@ The graph UI shows:
|
|
|
604
607
|
- context selector for segment-scoped star subgraphs derived from the visual graph context
|
|
605
608
|
- graph filter matches title, path, tags and note content
|
|
606
609
|
- graph filter keeps hub context nodes visible (`Memory Hub`/`MOC`/high-degree fallback) to preserve relationship readability
|
|
607
|
-
- realtime refresh while
|
|
610
|
+
- realtime refresh while watch mode is enabled
|
|
608
611
|
- graph controls for zoom in, zoom out, fit visible nodes and reset-to-fit-all
|
|
609
612
|
- wheel zoom (including `cmd+scroll` and `ctrl+scroll`) anchored to cursor position for faster navigation in large graphs
|
|
610
613
|
- wheel/button zoom updates immediately at the cursor anchor without delayed focus-transition interpolation
|
|
@@ -944,15 +947,26 @@ blink watch --vault ./vault
|
|
|
944
947
|
|
|
945
948
|
Watches Markdown files and rebuilds the index when notes change.
|
|
946
949
|
|
|
950
|
+
### `canonicalize-context-links`
|
|
951
|
+
|
|
952
|
+
```bash
|
|
953
|
+
blink canonicalize-context-links --vault ./vault --dry-run
|
|
954
|
+
blink canonicalize-context-links --vault ./vault
|
|
955
|
+
```
|
|
956
|
+
|
|
957
|
+
Ensures existing notes have canonical `## Context Links` entries to inferred context hubs such as `User Preferences Hub`, `GitHub Repositories Hub` or `Brainlink Hub`. The command is idempotent, creates missing hub notes by default, and fully reindexes after writes unless `--no-index` is passed.
|
|
958
|
+
|
|
947
959
|
### `server`
|
|
948
960
|
|
|
949
961
|
```bash
|
|
950
|
-
blink server
|
|
951
|
-
blink server --vault ./vault
|
|
952
|
-
blink server --vault ./vault --
|
|
962
|
+
blink server
|
|
963
|
+
blink server --vault ./vault
|
|
964
|
+
blink server --vault ./vault --no-open
|
|
965
|
+
blink server --vault ./vault --no-watch
|
|
953
966
|
```
|
|
954
967
|
|
|
955
968
|
Starts the local read-only graph UI and HTTP API.
|
|
969
|
+
Watch mode is enabled by default for Markdown changes in local filesystem vaults. Use `--no-watch` to run without the watcher.
|
|
956
970
|
By default, it tries to open a native desktop GUI window for the graph URL.
|
|
957
971
|
On Linux, native GUI is disabled by default; enable it with `BRAINLINK_LINUX_NATIVE_GUI=1`.
|
|
958
972
|
If native GUI launch is unavailable, it falls back to dedicated app-window mode and then browser open.
|
|
@@ -993,6 +1007,7 @@ If no `vault` is configured and no `--vault` flag is passed, Brainlink uses `$HO
|
|
|
993
1007
|
"allowedVaults": [".brainlink-vault"],
|
|
994
1008
|
"defaultAgent": "shared",
|
|
995
1009
|
"autoIndexOnWrite": true,
|
|
1010
|
+
"autoCanonicalContextLinks": true,
|
|
996
1011
|
"defaultSearchLimit": 10,
|
|
997
1012
|
"defaultContextTokens": 2000,
|
|
998
1013
|
"embeddingProvider": "local",
|
|
@@ -1022,6 +1037,7 @@ If no `vault` is configured and no `--vault` flag is passed, Brainlink uses `$HO
|
|
|
1022
1037
|
`agentProfiles` is optional. When present, CLI and MCP resolve `mode`, `limit` and `tokens` per agent automatically, then fallback to global defaults.
|
|
1023
1038
|
|
|
1024
1039
|
`autoIndexOnWrite` is optional and defaults to `true`. Set it to `false` to defer indexing after writes.
|
|
1040
|
+
`autoCanonicalContextLinks` is optional and defaults to `true`. When enabled, `blink add`, `brainlink_add_note` and `brainlink_add_file` add a canonical `## Context Links` entry to the inferred context hub, creating that hub when needed.
|
|
1025
1041
|
|
|
1026
1042
|
Use `"embeddingProvider": "none"` when you want FTS-only indexing.
|
|
1027
1043
|
|
|
@@ -1100,7 +1116,7 @@ Local CLI:
|
|
|
1100
1116
|
|
|
1101
1117
|
```bash
|
|
1102
1118
|
npm run dev -- --help
|
|
1103
|
-
npm run dev -- server --vault .brainlink-vault
|
|
1119
|
+
npm run dev -- server --vault .brainlink-vault
|
|
1104
1120
|
```
|
|
1105
1121
|
|
|
1106
1122
|
Package smoke test:
|
|
@@ -2,6 +2,7 @@ import { writeMarkdownFile } from '../infrastructure/file-system-vault.js';
|
|
|
2
2
|
import { sanitizeAgentId, sharedAgentId } from '../domain/agents.js';
|
|
3
3
|
import { validateNoteInput } from '../domain/note-safety.js';
|
|
4
4
|
import { ensureVault } from '../infrastructure/file-system-vault.js';
|
|
5
|
+
import { addCanonicalContextLinkToContent, ensureCanonicalContextHub } from './canonical-context-links.js';
|
|
5
6
|
const slugify = (title) => title
|
|
6
7
|
.normalize('NFKD')
|
|
7
8
|
.replace(/[\u0300-\u036f]/g, '')
|
|
@@ -28,12 +29,20 @@ export const addNoteWithMetadata = async (vaultPath, title, content, agentId = s
|
|
|
28
29
|
const sanitizedAgentId = sanitizeAgentId(agentId);
|
|
29
30
|
const filename = `agents/${sanitizedAgentId}/${slugify(title) || 'untitled'}.md`;
|
|
30
31
|
await ensureVault(vaultPath);
|
|
31
|
-
const
|
|
32
|
+
const canonical = options.autoContextLinks === false
|
|
33
|
+
? null
|
|
34
|
+
: addCanonicalContextLinkToContent(title, content.trim());
|
|
35
|
+
const hub = canonical?.changed
|
|
36
|
+
? await ensureCanonicalContextHub(vaultPath, canonical.context, sanitizedAgentId)
|
|
37
|
+
: null;
|
|
38
|
+
const note = buildNote(title, canonical?.content ?? content.trim(), sanitizedAgentId);
|
|
32
39
|
const path = await writeMarkdownFile(vaultPath, filename, note);
|
|
33
40
|
return {
|
|
34
41
|
path,
|
|
35
|
-
autoLinked: false,
|
|
36
|
-
linkTarget: null
|
|
42
|
+
autoLinked: canonical?.changed ?? false,
|
|
43
|
+
linkTarget: canonical?.changed ? canonical.hubTitle : null,
|
|
44
|
+
context: canonical?.context ?? null,
|
|
45
|
+
hubCreated: hub?.created ?? false
|
|
37
46
|
};
|
|
38
47
|
};
|
|
39
48
|
export const addNote = async (vaultPath, title, content, agentId = sharedAgentId, options = {}) => (await addNoteWithMetadata(vaultPath, title, content, agentId, options)).path;
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { inferVisualGraphContext } from '../domain/graph-contexts.js';
|
|
4
|
+
import { sanitizeAgentId, sharedAgentId } from '../domain/agents.js';
|
|
5
|
+
import { extractContextLinkWeights, parseMarkdownDocument } from '../domain/markdown.js';
|
|
6
|
+
import { ensureVault, readMarkdownFileSummaries, writeMarkdownFile } from '../infrastructure/file-system-vault.js';
|
|
7
|
+
const canonicalPriority = 'high';
|
|
8
|
+
const slugify = (title) => title
|
|
9
|
+
.normalize('NFKD')
|
|
10
|
+
.replace(/[\u0300-\u036f]/g, '')
|
|
11
|
+
.toLowerCase()
|
|
12
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
13
|
+
.replace(/^-+|-+$/g, '');
|
|
14
|
+
export const hubTitleForContext = (contextTitle) => `${contextTitle} Hub`;
|
|
15
|
+
const hubPathForContext = (contextTitle, agentId) => {
|
|
16
|
+
if (contextTitle === 'GitHub Repositories')
|
|
17
|
+
return 'github-repos/github-repositories-hub.md';
|
|
18
|
+
if (contextTitle === 'GitHub Organizations')
|
|
19
|
+
return 'github-org-repos/github-organizations-hub.md';
|
|
20
|
+
if (contextTitle === 'Machine Configuration')
|
|
21
|
+
return 'machine-config/machine-configuration-hub.md';
|
|
22
|
+
return join('agents', sanitizeAgentId(agentId), `${slugify(hubTitleForContext(contextTitle))}.md`).replaceAll('\\', '/');
|
|
23
|
+
};
|
|
24
|
+
const normalizeTitle = (title) => title.trim().replace(/\.md$/i, '').toLowerCase();
|
|
25
|
+
const hasCanonicalLink = (content, hubTitle) => extractContextLinkWeights(content).some((link) => normalizeTitle(link.title) === normalizeTitle(hubTitle));
|
|
26
|
+
const linkLine = (hubTitle) => `- [[${hubTitle}]] priority: ${canonicalPriority}`;
|
|
27
|
+
const contextLinksHeading = (line) => line.match(/^(#{2,6})\s+(Context Links|Links de Contexto)\s*$/i);
|
|
28
|
+
export const upsertCanonicalContextLink = (content, hubTitle) => {
|
|
29
|
+
if (hasCanonicalLink(content, hubTitle)) {
|
|
30
|
+
return content;
|
|
31
|
+
}
|
|
32
|
+
const lines = content.replace(/\s+$/u, '').split('\n');
|
|
33
|
+
const headingIndex = lines.findIndex((line) => contextLinksHeading(line.trim()));
|
|
34
|
+
if (headingIndex === -1) {
|
|
35
|
+
return `${lines.join('\n')}\n\n## Context Links\n\n${linkLine(hubTitle)}\n`;
|
|
36
|
+
}
|
|
37
|
+
const heading = contextLinksHeading(lines[headingIndex].trim());
|
|
38
|
+
const headingDepth = heading?.[1]?.length ?? 2;
|
|
39
|
+
const insertIndex = lines.findIndex((line, index) => {
|
|
40
|
+
if (index <= headingIndex)
|
|
41
|
+
return false;
|
|
42
|
+
const candidate = line.match(/^(#{2,6})\s+/);
|
|
43
|
+
return Boolean(candidate && candidate[1].length <= headingDepth);
|
|
44
|
+
});
|
|
45
|
+
const targetIndex = insertIndex === -1 ? lines.length : insertIndex;
|
|
46
|
+
const before = lines.slice(0, targetIndex);
|
|
47
|
+
const after = lines.slice(targetIndex);
|
|
48
|
+
const needsSpacer = before[before.length - 1]?.trim() !== '';
|
|
49
|
+
const nextLines = [...before, ...(needsSpacer ? [''] : []), linkLine(hubTitle), ...after];
|
|
50
|
+
return `${nextLines.join('\n').replace(/\s+$/u, '')}\n`;
|
|
51
|
+
};
|
|
52
|
+
const buildHubContent = (hubTitle, contextTitle, agentId) => [
|
|
53
|
+
'---',
|
|
54
|
+
`title: "${hubTitle.replaceAll('"', '\\"')}"`,
|
|
55
|
+
`agent: "${sanitizeAgentId(agentId)}"`,
|
|
56
|
+
'---',
|
|
57
|
+
'',
|
|
58
|
+
`# ${hubTitle}`,
|
|
59
|
+
'',
|
|
60
|
+
`Canonical hub for the ${contextTitle} context. #memory #hub`,
|
|
61
|
+
''
|
|
62
|
+
].join('\n');
|
|
63
|
+
const readNotes = async (vaultPath) => {
|
|
64
|
+
const absoluteVaultPath = await ensureVault(vaultPath);
|
|
65
|
+
const summaries = await readMarkdownFileSummaries(absoluteVaultPath);
|
|
66
|
+
return Promise.all(summaries.map(async (summary) => {
|
|
67
|
+
const content = await readFile(summary.absolutePath, 'utf8');
|
|
68
|
+
const document = parseMarkdownDocument({
|
|
69
|
+
absolutePath: summary.absolutePath,
|
|
70
|
+
vaultPath: absoluteVaultPath,
|
|
71
|
+
content,
|
|
72
|
+
createdAt: summary.createdAt,
|
|
73
|
+
updatedAt: summary.updatedAt
|
|
74
|
+
});
|
|
75
|
+
return {
|
|
76
|
+
summary,
|
|
77
|
+
content,
|
|
78
|
+
document
|
|
79
|
+
};
|
|
80
|
+
}));
|
|
81
|
+
};
|
|
82
|
+
export const ensureCanonicalContextHub = async (vaultPath, contextTitle, agentId = sharedAgentId) => {
|
|
83
|
+
const hubTitle = hubTitleForContext(contextTitle);
|
|
84
|
+
const notes = await readNotes(vaultPath);
|
|
85
|
+
const existing = notes.find((note) => normalizeTitle(note.document.title) === normalizeTitle(hubTitle));
|
|
86
|
+
const hubPath = existing?.summary.relativePath ?? hubPathForContext(contextTitle, agentId);
|
|
87
|
+
if (existing) {
|
|
88
|
+
return {
|
|
89
|
+
created: false,
|
|
90
|
+
title: hubTitle,
|
|
91
|
+
path: hubPath
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
const path = await writeMarkdownFile(vaultPath, hubPath, buildHubContent(hubTitle, contextTitle, agentId));
|
|
95
|
+
return {
|
|
96
|
+
created: true,
|
|
97
|
+
title: hubTitle,
|
|
98
|
+
path
|
|
99
|
+
};
|
|
100
|
+
};
|
|
101
|
+
export const canonicalizeContextLinks = async (vaultPath, options = {}) => {
|
|
102
|
+
const agentId = options.agentId ? sanitizeAgentId(options.agentId) : undefined;
|
|
103
|
+
const createMissingHubs = options.createMissingHubs !== false;
|
|
104
|
+
const notes = await readNotes(vaultPath);
|
|
105
|
+
const scopedNotes = agentId ? notes.filter((note) => note.document.agentId === agentId) : notes;
|
|
106
|
+
const knownTitles = new Set(notes.map((note) => normalizeTitle(note.document.title)));
|
|
107
|
+
const entries = [];
|
|
108
|
+
const ensureHub = async (contextTitle, hubTitle, targetAgentId) => {
|
|
109
|
+
if (knownTitles.has(normalizeTitle(hubTitle))) {
|
|
110
|
+
return true;
|
|
111
|
+
}
|
|
112
|
+
const path = hubPathForContext(contextTitle, targetAgentId);
|
|
113
|
+
if (!createMissingHubs) {
|
|
114
|
+
entries.push({
|
|
115
|
+
path,
|
|
116
|
+
title: hubTitle,
|
|
117
|
+
context: contextTitle,
|
|
118
|
+
hubTitle,
|
|
119
|
+
changed: false,
|
|
120
|
+
reason: 'missing-hub'
|
|
121
|
+
});
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
knownTitles.add(normalizeTitle(hubTitle));
|
|
125
|
+
if (!options.dryRun) {
|
|
126
|
+
await writeMarkdownFile(vaultPath, path, buildHubContent(hubTitle, contextTitle, targetAgentId));
|
|
127
|
+
}
|
|
128
|
+
entries.push({
|
|
129
|
+
path,
|
|
130
|
+
title: hubTitle,
|
|
131
|
+
context: contextTitle,
|
|
132
|
+
hubTitle,
|
|
133
|
+
changed: true,
|
|
134
|
+
reason: 'created-hub'
|
|
135
|
+
});
|
|
136
|
+
return true;
|
|
137
|
+
};
|
|
138
|
+
for (const note of scopedNotes) {
|
|
139
|
+
const context = inferVisualGraphContext(note.document);
|
|
140
|
+
const hubTitle = hubTitleForContext(context.title);
|
|
141
|
+
const isHub = normalizeTitle(note.document.title) === normalizeTitle(hubTitle);
|
|
142
|
+
if (isHub) {
|
|
143
|
+
entries.push({
|
|
144
|
+
path: note.summary.relativePath,
|
|
145
|
+
title: note.document.title,
|
|
146
|
+
context: context.title,
|
|
147
|
+
hubTitle,
|
|
148
|
+
changed: false,
|
|
149
|
+
reason: 'hub-note'
|
|
150
|
+
});
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
const hubAvailable = await ensureHub(context.title, hubTitle, note.document.agentId || sharedAgentId);
|
|
154
|
+
if (!hubAvailable) {
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
if (hasCanonicalLink(note.content, hubTitle)) {
|
|
158
|
+
entries.push({
|
|
159
|
+
path: note.summary.relativePath,
|
|
160
|
+
title: note.document.title,
|
|
161
|
+
context: context.title,
|
|
162
|
+
hubTitle,
|
|
163
|
+
changed: false,
|
|
164
|
+
reason: 'already-linked'
|
|
165
|
+
});
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
const nextContent = upsertCanonicalContextLink(note.content, hubTitle);
|
|
169
|
+
if (!options.dryRun) {
|
|
170
|
+
await writeMarkdownFile(vaultPath, note.summary.relativePath, nextContent);
|
|
171
|
+
}
|
|
172
|
+
entries.push({
|
|
173
|
+
path: note.summary.relativePath,
|
|
174
|
+
title: note.document.title,
|
|
175
|
+
context: context.title,
|
|
176
|
+
hubTitle,
|
|
177
|
+
changed: true,
|
|
178
|
+
reason: 'added-context-link'
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
const changed = entries.filter((entry) => entry.changed).length;
|
|
182
|
+
const createdHubs = entries.filter((entry) => entry.reason === 'created-hub' && entry.changed).length;
|
|
183
|
+
return {
|
|
184
|
+
dryRun: options.dryRun === true,
|
|
185
|
+
scanned: scopedNotes.length,
|
|
186
|
+
changed,
|
|
187
|
+
createdHubs,
|
|
188
|
+
skipped: entries.length - changed,
|
|
189
|
+
entries
|
|
190
|
+
};
|
|
191
|
+
};
|
|
192
|
+
export const addCanonicalContextLinkToContent = (title, content) => {
|
|
193
|
+
const context = inferVisualGraphContext({
|
|
194
|
+
id: '',
|
|
195
|
+
agentId: sharedAgentId,
|
|
196
|
+
title,
|
|
197
|
+
path: '',
|
|
198
|
+
content,
|
|
199
|
+
tags: [],
|
|
200
|
+
});
|
|
201
|
+
const hubTitle = hubTitleForContext(context.title);
|
|
202
|
+
const nextContent = normalizeTitle(title) === normalizeTitle(hubTitle) ? content : upsertCanonicalContextLink(content, hubTitle);
|
|
203
|
+
return {
|
|
204
|
+
content: nextContent,
|
|
205
|
+
context: context.title,
|
|
206
|
+
hubTitle,
|
|
207
|
+
changed: nextContent !== content
|
|
208
|
+
};
|
|
209
|
+
};
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import { mkdir, readFile, rename, stat, writeFile } from 'node:fs/promises';
|
|
3
3
|
import { dirname, join } from 'node:path';
|
|
4
|
+
import { addVisualContextEdges } from '../domain/graph-contexts.js';
|
|
4
5
|
import { createStarGraphLayout } from '../domain/graph-layout.js';
|
|
5
6
|
import { indexStoragePath } from '../infrastructure/file-index.js';
|
|
6
7
|
import { getGraphSummary } from './get-graph-summary.js';
|
|
7
|
-
const graphLayoutVersion =
|
|
8
|
+
const graphLayoutVersion = 5;
|
|
8
9
|
const graphLayoutCache = new Map();
|
|
9
10
|
const safeCacheSegment = (value, fallback) => value?.replace(/[^a-zA-Z0-9_-]/g, '_') || fallback;
|
|
10
11
|
const graphLayoutStoragePath = (vaultPath, options) => {
|
|
@@ -82,7 +83,7 @@ export const getGraphLayout = async (vaultPath, optionsOrAgentId) => {
|
|
|
82
83
|
layout: persisted.layout
|
|
83
84
|
};
|
|
84
85
|
}
|
|
85
|
-
const graph = await getGraphSummary(vaultPath, options.agentId);
|
|
86
|
+
const graph = addVisualContextEdges(await getGraphSummary(vaultPath, options.agentId));
|
|
86
87
|
const scopedGraph = options.context ? filterGraphByContext(graph, options.context) : graph;
|
|
87
88
|
const signature = createGraphSignature(scopedGraph);
|
|
88
89
|
const layout = createLayout(scopedGraph);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { stat } from 'node:fs/promises';
|
|
2
2
|
import { ensureVault } from '../infrastructure/file-system-vault.js';
|
|
3
3
|
import { indexStoragePath, openFileIndex } from '../infrastructure/file-index.js';
|
|
4
|
+
import { getGraphLayout } from './get-graph-layout.js';
|
|
4
5
|
const graphSearchCacheTtlMs = 20_000;
|
|
5
6
|
const graphSearchCacheMaxEntries = 120;
|
|
6
7
|
const graphSearchCache = new Map();
|
|
@@ -13,11 +14,12 @@ const readIndexSignature = async (vaultPath) => {
|
|
|
13
14
|
return '0:0';
|
|
14
15
|
}
|
|
15
16
|
};
|
|
16
|
-
const cacheKey = (vaultPath, query, limit, agentId) => JSON.stringify({
|
|
17
|
+
const cacheKey = (vaultPath, query, limit, agentId, context) => JSON.stringify({
|
|
17
18
|
vaultPath,
|
|
18
19
|
query: query.trim().toLowerCase(),
|
|
19
20
|
limit,
|
|
20
|
-
agentId: agentId?.trim().toLowerCase() ?? '*'
|
|
21
|
+
agentId: agentId?.trim().toLowerCase() ?? '*',
|
|
22
|
+
context: context?.trim().toLowerCase() ?? '*'
|
|
21
23
|
});
|
|
22
24
|
const readCached = (key, indexSignature) => {
|
|
23
25
|
const entry = graphSearchCache.get(key);
|
|
@@ -39,17 +41,24 @@ const writeCached = (key, entry) => {
|
|
|
39
41
|
const overflow = graphSearchCache.size - graphSearchCacheMaxEntries;
|
|
40
42
|
Array.from(graphSearchCache.keys()).slice(0, overflow).forEach((cacheKey) => graphSearchCache.delete(cacheKey));
|
|
41
43
|
};
|
|
42
|
-
export const searchGraphNodeIds = async (vaultPath, query, limit, agentId) => {
|
|
44
|
+
export const searchGraphNodeIds = async (vaultPath, query, limit, agentId, context) => {
|
|
43
45
|
const absoluteVaultPath = await ensureVault(vaultPath);
|
|
44
46
|
const indexSignature = await readIndexSignature(absoluteVaultPath);
|
|
45
|
-
const key = cacheKey(absoluteVaultPath, query, limit, agentId);
|
|
47
|
+
const key = cacheKey(absoluteVaultPath, query, limit, agentId, context);
|
|
46
48
|
const cached = readCached(key, indexSignature);
|
|
47
49
|
if (cached) {
|
|
48
50
|
return cached;
|
|
49
51
|
}
|
|
52
|
+
const contextNodeIds = context
|
|
53
|
+
? new Set((await getGraphLayout(absoluteVaultPath, { agentId, context })).layout.nodes.map((node) => node.id))
|
|
54
|
+
: new Set();
|
|
50
55
|
const index = openFileIndex(absoluteVaultPath);
|
|
51
56
|
try {
|
|
52
|
-
const
|
|
57
|
+
const searchLimit = context ? Math.max(limit, 5000) : limit;
|
|
58
|
+
const foundNodeIds = await index.searchGraphNodeIds(query, searchLimit, agentId);
|
|
59
|
+
const nodeIds = context
|
|
60
|
+
? foundNodeIds.filter((nodeId) => contextNodeIds.has(nodeId)).slice(0, limit)
|
|
61
|
+
: foundNodeIds;
|
|
53
62
|
writeCached(key, {
|
|
54
63
|
createdAt: Date.now(),
|
|
55
64
|
indexSignature,
|
|
@@ -302,7 +302,7 @@ export const route = async (request, url, vaultPath) => {
|
|
|
302
302
|
if (!query) {
|
|
303
303
|
return createResponse(createJsonResponse({ query, nodeIds: [] }), 200, contentTypes['.json']);
|
|
304
304
|
}
|
|
305
|
-
const nodeIds = await searchGraphNodeIds(vaultPath, query, limit, readAgentQuery(url));
|
|
305
|
+
const nodeIds = await searchGraphNodeIds(vaultPath, query, limit, readAgentQuery(url), readContextQuery(url));
|
|
306
306
|
return createResponse(createJsonResponse({ query, nodeIds }), 200, contentTypes['.json']);
|
|
307
307
|
}
|
|
308
308
|
if (isReadMethod(request) && url.pathname === '/api/agents') {
|
|
@@ -9,6 +9,7 @@ import { resolveDuplicateNotes, scanDuplicateNotes } from '../../application/ded
|
|
|
9
9
|
import { importLegacySqliteDatabase } from '../../application/import-legacy-sqlite.js';
|
|
10
10
|
import { indexVault, indexVaultWithOptions } from '../../application/index-vault.js';
|
|
11
11
|
import { migrateContextLinks } from '../../application/migrate-context-links.js';
|
|
12
|
+
import { canonicalizeContextLinks } from '../../application/canonical-context-links.js';
|
|
12
13
|
import { migrateVaultContent, planVaultMigration, previewVaultMigration, shouldMigrateDefaultVault } from '../../application/migrate-vault.js';
|
|
13
14
|
import { createOfflinePackBackup } from '../../application/offline-pack-backup.js';
|
|
14
15
|
import { startServer } from '../../application/start-server.js';
|
|
@@ -16,7 +17,7 @@ import { startVaultWatcher } from '../../application/watch-vault.js';
|
|
|
16
17
|
import { doctorVault, getStats, validateVault } from '../../application/analyze-vault.js';
|
|
17
18
|
import { defaultBrainlinkConfig, sanitizeSearchMode } from '../../infrastructure/config.js';
|
|
18
19
|
import { loadBrainlinkConfig } from '../../infrastructure/config.js';
|
|
19
|
-
import { assertVaultAllowed, ensureVault } from '../../infrastructure/file-system-vault.js';
|
|
20
|
+
import { assertVaultAllowed, ensureVault, isBucketVaultPath } from '../../infrastructure/file-system-vault.js';
|
|
20
21
|
import { getBootstrapPolicy, getBootstrapSessionStatus, touchBootstrapSession } from '../../infrastructure/session-state.js';
|
|
21
22
|
import { addVolatileMemory, clearVolatileMemory } from '../../infrastructure/volatile-memory.js';
|
|
22
23
|
import { installAgentIntegration } from './agent-commands.js';
|
|
@@ -731,6 +732,37 @@ export const registerWriteCommands = (program) => {
|
|
|
731
732
|
return `${mode} ${result.scanned} notes: changed=${result.changed}, skipped=${result.skipped}, limit=${result.limit}.${indexMessage}`;
|
|
732
733
|
});
|
|
733
734
|
});
|
|
735
|
+
program
|
|
736
|
+
.command('canonicalize-context-links')
|
|
737
|
+
.option('-v, --vault <vault>', 'vault directory')
|
|
738
|
+
.option('-a, --agent <agent>', 'agent memory namespace')
|
|
739
|
+
.option('--dry-run', 'preview canonical context links without writing files')
|
|
740
|
+
.option('--no-create-hubs', 'do not create missing context hub notes')
|
|
741
|
+
.option('--no-index', 'skip reindexing after canonicalization')
|
|
742
|
+
.option('--json', 'print machine-readable JSON')
|
|
743
|
+
.description('ensure notes have canonical Context Links to their inferred context hubs')
|
|
744
|
+
.action(async (options) => {
|
|
745
|
+
const resolved = await resolveOptions(options);
|
|
746
|
+
const result = await canonicalizeContextLinks(resolved.vault, {
|
|
747
|
+
dryRun: options.dryRun === true,
|
|
748
|
+
agentId: resolved.agent,
|
|
749
|
+
createMissingHubs: options.createHubs !== false
|
|
750
|
+
});
|
|
751
|
+
const shouldIndex = options.index !== false && !result.dryRun && result.changed > 0;
|
|
752
|
+
const index = shouldIndex ? await indexVault(resolved.vault, { full: true }) : undefined;
|
|
753
|
+
print(options.json, {
|
|
754
|
+
vault: resolved.vault,
|
|
755
|
+
agent: resolved.agent ?? 'shared',
|
|
756
|
+
...result,
|
|
757
|
+
...(index ? { index } : {})
|
|
758
|
+
}, () => {
|
|
759
|
+
const mode = result.dryRun ? 'Previewed' : 'Canonicalized';
|
|
760
|
+
const indexMessage = index
|
|
761
|
+
? ` Fully reindexed ${index.documentCount} documents, ${index.chunkCount} chunks and ${index.linkCount} context links.`
|
|
762
|
+
: '';
|
|
763
|
+
return `${mode} ${result.scanned} notes: changed=${result.changed}, createdHubs=${result.createdHubs}, skipped=${result.skipped}.${indexMessage}`;
|
|
764
|
+
});
|
|
765
|
+
});
|
|
734
766
|
program
|
|
735
767
|
.command('db-import')
|
|
736
768
|
.option('-v, --vault <vault>', 'vault directory')
|
|
@@ -793,6 +825,7 @@ export const registerWriteCommands = (program) => {
|
|
|
793
825
|
.option('-v, --vault <vault>', 'vault directory')
|
|
794
826
|
.option('-a, --agent <agent>', 'agent memory namespace')
|
|
795
827
|
.option('--allow-sensitive', 'allow writing content that looks like a secret')
|
|
828
|
+
.option('--no-auto-context-links', 'skip canonical Context Links for this note')
|
|
796
829
|
.option('--no-auto-index', 'skip reindexing after add')
|
|
797
830
|
.option('--json', 'print machine-readable JSON')
|
|
798
831
|
.description('add a markdown note to the vault')
|
|
@@ -800,7 +833,8 @@ export const registerWriteCommands = (program) => {
|
|
|
800
833
|
const resolved = await resolveOptions(options);
|
|
801
834
|
const content = resolveAddContent(options);
|
|
802
835
|
const added = await addNoteWithMetadata(resolved.vault, title, content, resolved.agent, {
|
|
803
|
-
allowSensitive: Boolean(options.allowSensitive)
|
|
836
|
+
allowSensitive: Boolean(options.allowSensitive),
|
|
837
|
+
autoContextLinks: options.autoContextLinks !== false && resolved.config.autoCanonicalContextLinks
|
|
804
838
|
});
|
|
805
839
|
const shouldAutoIndex = options.autoIndex !== false && resolved.config.autoIndexOnWrite;
|
|
806
840
|
const index = shouldAutoIndex ? await indexVault(resolved.vault) : undefined;
|
|
@@ -824,7 +858,9 @@ export const registerWriteCommands = (program) => {
|
|
|
824
858
|
writeConnectivity: {
|
|
825
859
|
autoLinked: added.autoLinked,
|
|
826
860
|
linkTarget: added.linkTarget,
|
|
827
|
-
|
|
861
|
+
context: added.context,
|
|
862
|
+
hubCreated: added.hubCreated,
|
|
863
|
+
guaranteedEdge: added.autoLinked
|
|
828
864
|
},
|
|
829
865
|
possibleDuplicates,
|
|
830
866
|
...(index ? { index } : {})
|
|
@@ -832,7 +868,8 @@ export const registerWriteCommands = (program) => {
|
|
|
832
868
|
const duplicateMessage = possibleDuplicates.length > 0
|
|
833
869
|
? `\nPotential duplicates: ${possibleDuplicates.length}. Use "blink dedupe --json" or "blink dedupe-resolve".`
|
|
834
870
|
: '';
|
|
835
|
-
|
|
871
|
+
const linkMessage = added.autoLinked ? ` Linked to [[${added.linkTarget}]].` : '';
|
|
872
|
+
return `Created note at ${added.path}.${linkMessage}${duplicateMessage}`;
|
|
836
873
|
});
|
|
837
874
|
});
|
|
838
875
|
program
|
|
@@ -1037,26 +1074,28 @@ export const registerWriteCommands = (program) => {
|
|
|
1037
1074
|
.option('-p, --port <port>', 'server port', '4321')
|
|
1038
1075
|
.option('--no-index', 'skip indexing before starting the server')
|
|
1039
1076
|
.option('--no-open', 'do not open the graph UI automatically')
|
|
1040
|
-
.option('-w, --watch', 'watch markdown files and reindex on changes')
|
|
1077
|
+
.option('-w, --watch', 'watch markdown files and reindex on changes', true)
|
|
1078
|
+
.option('--no-watch', 'disable markdown file watching')
|
|
1041
1079
|
.option('--json', 'print machine-readable JSON')
|
|
1042
1080
|
.description('start a local web UI for the knowledge graph')
|
|
1043
1081
|
.action(async (options) => {
|
|
1044
1082
|
const resolved = await resolveOptions(options);
|
|
1083
|
+
const shouldWatch = options.watch !== false && !isBucketVaultPath(resolved.vault);
|
|
1045
1084
|
const server = await startServer({
|
|
1046
1085
|
vaultPath: resolved.vault,
|
|
1047
1086
|
host: options.host ?? resolved.config.host,
|
|
1048
1087
|
port: parsePositiveInteger(options.port ?? String(resolved.config.port), resolved.config.port),
|
|
1049
1088
|
shouldIndex: options.index,
|
|
1050
|
-
shouldWatch
|
|
1089
|
+
shouldWatch
|
|
1051
1090
|
});
|
|
1052
1091
|
const openResult = options.open !== false ? openUrlInUi(server.url, process.pid) : { opened: false, mode: 'none' };
|
|
1053
1092
|
print(options.json, {
|
|
1054
1093
|
url: server.url,
|
|
1055
|
-
watch:
|
|
1094
|
+
watch: shouldWatch,
|
|
1056
1095
|
readonly: true,
|
|
1057
1096
|
openedUi: openResult.opened,
|
|
1058
1097
|
openMode: openResult.mode
|
|
1059
|
-
}, () => `Brainlink graph server running at ${server.url}${openResult.opened
|
|
1098
|
+
}, () => `Brainlink graph server running at ${server.url} (${shouldWatch ? 'watching for changes' : 'watch disabled'})${openResult.opened
|
|
1060
1099
|
? openResult.mode === 'native-gui'
|
|
1061
1100
|
? ' (opened in native desktop GUI)'
|
|
1062
1101
|
: openResult.mode === 'app-window'
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
const normalize = (value) => value.trim().toLowerCase();
|
|
2
|
+
const includesAny = (value, patterns) => patterns.some((pattern) => pattern.test(value));
|
|
3
|
+
const contextId = (title) => title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
|
4
|
+
const context = (title) => ({
|
|
5
|
+
id: contextId(title),
|
|
6
|
+
title
|
|
7
|
+
});
|
|
8
|
+
const byTitle = (left, right) => left.title.localeCompare(right.title);
|
|
9
|
+
const edgeKey = (source, target) => source < target ? `${source}|${target}` : `${target}|${source}`;
|
|
10
|
+
const nodeSearchText = (node) => normalize([node.title, node.path, ...node.tags].join(' '));
|
|
11
|
+
export const inferExplicitVisualGraphContext = (node) => {
|
|
12
|
+
const text = nodeSearchText(node);
|
|
13
|
+
const path = normalize(node.path);
|
|
14
|
+
if (includesAny(text, [/\bgithub repositories hub\b/]))
|
|
15
|
+
return context('GitHub Repositories');
|
|
16
|
+
if (includesAny(text, [/\bgithub organizations hub\b/]))
|
|
17
|
+
return context('GitHub Organizations');
|
|
18
|
+
if (includesAny(text, [/\bmachine configuration hub\b/]))
|
|
19
|
+
return context('Machine Configuration');
|
|
20
|
+
if (includesAny(text, [/\buser preferences hub\b/]))
|
|
21
|
+
return context('User Preferences');
|
|
22
|
+
if (includesAny(text, [/\bneovim lazyvim hub\b/]))
|
|
23
|
+
return context('Neovim LazyVim');
|
|
24
|
+
if (includesAny(text, [/\bgit workflow hub\b/]))
|
|
25
|
+
return context('Git Workflow');
|
|
26
|
+
if (includesAny(text, [/\bagent memory hub\b/]))
|
|
27
|
+
return context('Agent Memory');
|
|
28
|
+
if (path.startsWith('github-repos/'))
|
|
29
|
+
return context('GitHub Repositories');
|
|
30
|
+
if (path.startsWith('github-org-repos/'))
|
|
31
|
+
return context('GitHub Organizations');
|
|
32
|
+
if (path.startsWith('machine-config/'))
|
|
33
|
+
return context('Machine Configuration');
|
|
34
|
+
if (includesAny(text, [/\bbrainlink\b/]))
|
|
35
|
+
return context('Brainlink');
|
|
36
|
+
if (includesAny(text, [/\banonspace\b/]))
|
|
37
|
+
return context('AnonSpace');
|
|
38
|
+
if (includesAny(text, [/\bsubstructa\b/]))
|
|
39
|
+
return context('Substructa');
|
|
40
|
+
if (includesAny(text, [/\bnebula\b/]))
|
|
41
|
+
return context('Nebula');
|
|
42
|
+
if (includesAny(text, [/\bsnippets?\b/, /\bupgrader\b/, /\bversion-map\b/]))
|
|
43
|
+
return context('Snippets');
|
|
44
|
+
if (includesAny(text, [/\binkdrop\b/]))
|
|
45
|
+
return context('Inkdrop');
|
|
46
|
+
if (includesAny(text, [
|
|
47
|
+
/\bpreference\b/,
|
|
48
|
+
/\bpreferencia\b/,
|
|
49
|
+
/\bpreferencias\b/,
|
|
50
|
+
/\bplaybook\b/,
|
|
51
|
+
/\bdirective\b/,
|
|
52
|
+
/\bengineering-style\b/,
|
|
53
|
+
/\bglobal-engineering\b/,
|
|
54
|
+
/\bcoding-identity\b/,
|
|
55
|
+
/\bagents\.md\b/
|
|
56
|
+
])) {
|
|
57
|
+
return context('User Preferences');
|
|
58
|
+
}
|
|
59
|
+
if (includesAny(text, [/\blazyvim\b/, /\bneovim\b/, /\bnvim\b/, /\bmason\b/, /\bwrapper\b/]))
|
|
60
|
+
return context('Neovim LazyVim');
|
|
61
|
+
if (includesAny(text, [/\bgit-flow\b/, /\borigin-sync\b/, /\bgit-identidade\b/, /\bcommit\b/, /\bpush\b/]))
|
|
62
|
+
return context('Git Workflow');
|
|
63
|
+
if (includesAny(text, [/\bdocker\b/, /\bkubernetes\b/, /\bdeploy\b/, /\bredeploy\b/]))
|
|
64
|
+
return context('Operations');
|
|
65
|
+
if (path.startsWith('agents/'))
|
|
66
|
+
return context('Agent Memory');
|
|
67
|
+
return null;
|
|
68
|
+
};
|
|
69
|
+
export const inferVisualGraphContext = (node) => {
|
|
70
|
+
const explicit = inferExplicitVisualGraphContext(node);
|
|
71
|
+
if (explicit) {
|
|
72
|
+
return explicit;
|
|
73
|
+
}
|
|
74
|
+
const [root] = node.path.split('/').filter(Boolean);
|
|
75
|
+
return context(root ? root.replace(/[-_]+/g, ' ') : 'Root');
|
|
76
|
+
};
|
|
77
|
+
export const groupNodesByVisualContext = (nodes) => {
|
|
78
|
+
const groups = new Map();
|
|
79
|
+
nodes.forEach((node) => {
|
|
80
|
+
const visualContext = inferVisualGraphContext(node);
|
|
81
|
+
const bucket = groups.get(visualContext.title);
|
|
82
|
+
if (bucket) {
|
|
83
|
+
bucket.push(node);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
groups.set(visualContext.title, [node]);
|
|
87
|
+
});
|
|
88
|
+
return new Map(Array.from(groups.entries(), ([title, groupedNodes]) => [title, [...groupedNodes].sort(byTitle)]));
|
|
89
|
+
};
|
|
90
|
+
const countDegrees = (edges) => edges.reduce((degrees, edge) => {
|
|
91
|
+
degrees.set(edge.source, (degrees.get(edge.source) ?? 0) + edge.weight);
|
|
92
|
+
if (edge.target) {
|
|
93
|
+
degrees.set(edge.target, (degrees.get(edge.target) ?? 0) + edge.weight);
|
|
94
|
+
}
|
|
95
|
+
return degrees;
|
|
96
|
+
}, new Map());
|
|
97
|
+
const selectVisualHub = (contextTitle, nodes, degrees) => {
|
|
98
|
+
const normalizedContext = normalize(contextTitle).replace(/\s+/g, ' ');
|
|
99
|
+
const ranked = [...nodes].sort((left, right) => {
|
|
100
|
+
const leftTitle = normalize(left.title);
|
|
101
|
+
const rightTitle = normalize(right.title);
|
|
102
|
+
const leftHubScore = leftTitle === normalizedContext || leftTitle === `${normalizedContext} hub`
|
|
103
|
+
? 4
|
|
104
|
+
: leftTitle.includes(normalizedContext) && /\bhub\b/.test(leftTitle)
|
|
105
|
+
? 3
|
|
106
|
+
: /\b(memory hub|knowledge root|moc|map|hub)\b/.test(leftTitle)
|
|
107
|
+
? 2
|
|
108
|
+
: 0;
|
|
109
|
+
const rightHubScore = rightTitle === normalizedContext || rightTitle === `${normalizedContext} hub`
|
|
110
|
+
? 4
|
|
111
|
+
: rightTitle.includes(normalizedContext) && /\bhub\b/.test(rightTitle)
|
|
112
|
+
? 3
|
|
113
|
+
: /\b(memory hub|knowledge root|moc|map|hub)\b/.test(rightTitle)
|
|
114
|
+
? 2
|
|
115
|
+
: 0;
|
|
116
|
+
const hubDelta = rightHubScore - leftHubScore;
|
|
117
|
+
if (hubDelta !== 0)
|
|
118
|
+
return hubDelta;
|
|
119
|
+
const degreeDelta = (degrees.get(right.id) ?? 0) - (degrees.get(left.id) ?? 0);
|
|
120
|
+
return degreeDelta === 0 ? left.title.localeCompare(right.title) : degreeDelta;
|
|
121
|
+
});
|
|
122
|
+
return ranked[0] ?? null;
|
|
123
|
+
};
|
|
124
|
+
export const addVisualContextEdges = (graph) => {
|
|
125
|
+
const existingPairs = new Set(graph.edges
|
|
126
|
+
.filter((edge) => Boolean(edge.target))
|
|
127
|
+
.map((edge) => edgeKey(edge.source, edge.target)));
|
|
128
|
+
const degrees = countDegrees(graph.edges);
|
|
129
|
+
const derivedEdges = [];
|
|
130
|
+
for (const [contextTitle, nodes] of groupNodesByVisualContext(graph.nodes).entries()) {
|
|
131
|
+
if (nodes.length <= 1) {
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
const hub = selectVisualHub(contextTitle, nodes, degrees);
|
|
135
|
+
if (!hub) {
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
nodes
|
|
139
|
+
.filter((node) => node.id !== hub.id)
|
|
140
|
+
.forEach((node) => {
|
|
141
|
+
const key = edgeKey(hub.id, node.id);
|
|
142
|
+
if (existingPairs.has(key)) {
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
existingPairs.add(key);
|
|
146
|
+
derivedEdges.push({
|
|
147
|
+
source: hub.id,
|
|
148
|
+
target: node.id,
|
|
149
|
+
targetTitle: node.title,
|
|
150
|
+
weight: 0.5,
|
|
151
|
+
priority: 'low'
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
return {
|
|
156
|
+
nodes: graph.nodes,
|
|
157
|
+
edges: [...graph.edges, ...derivedEdges]
|
|
158
|
+
};
|
|
159
|
+
};
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { inferExplicitVisualGraphContext } from './graph-contexts.js';
|
|
1
2
|
const hierarchyGroupNodeLimit = 1000;
|
|
2
3
|
const groupLabels = {
|
|
3
4
|
'00-maps': 'maps',
|
|
@@ -140,8 +141,16 @@ const selectSegmentSeeds = (nodes, edges, degrees) => {
|
|
|
140
141
|
const assignSegments = (nodes, edges, degrees) => {
|
|
141
142
|
const adjacency = createAdjacency(nodes, edges);
|
|
142
143
|
const seeds = selectSegmentSeeds(nodes, edges, degrees);
|
|
143
|
-
const assignments = new Map(
|
|
144
|
+
const assignments = new Map(nodes.flatMap((node) => {
|
|
145
|
+
const visualContext = inferExplicitVisualGraphContext(node);
|
|
146
|
+
return visualContext ? [[node.id, visualContext.title]] : [];
|
|
147
|
+
}));
|
|
144
148
|
const queue = seeds.map((seed) => seed.id);
|
|
149
|
+
seeds.forEach((seed) => {
|
|
150
|
+
if (!assignments.has(seed.id)) {
|
|
151
|
+
assignments.set(seed.id, segmentName(seed));
|
|
152
|
+
}
|
|
153
|
+
});
|
|
145
154
|
for (let index = 0; index < queue.length; index += 1) {
|
|
146
155
|
const id = queue[index];
|
|
147
156
|
const segment = assignments.get(id);
|
|
@@ -10,6 +10,7 @@ export const defaultBrainlinkConfig = {
|
|
|
10
10
|
allowedVaults: [],
|
|
11
11
|
defaultAgent: undefined,
|
|
12
12
|
autoIndexOnWrite: true,
|
|
13
|
+
autoCanonicalContextLinks: true,
|
|
13
14
|
defaultSearchLimit: 10,
|
|
14
15
|
defaultContextTokens: 2000,
|
|
15
16
|
embeddingProvider: 'local',
|
|
@@ -159,6 +160,9 @@ const sanitizeConfig = (value) => ({
|
|
|
159
160
|
? sanitizeAgentId(value.defaultAgent)
|
|
160
161
|
: defaultBrainlinkConfig.defaultAgent,
|
|
161
162
|
autoIndexOnWrite: typeof value.autoIndexOnWrite === 'boolean' ? value.autoIndexOnWrite : defaultBrainlinkConfig.autoIndexOnWrite,
|
|
163
|
+
autoCanonicalContextLinks: typeof value.autoCanonicalContextLinks === 'boolean'
|
|
164
|
+
? value.autoCanonicalContextLinks
|
|
165
|
+
: defaultBrainlinkConfig.autoCanonicalContextLinks,
|
|
162
166
|
defaultSearchLimit: typeof value.defaultSearchLimit === 'number' && value.defaultSearchLimit > 0
|
|
163
167
|
? value.defaultSearchLimit
|
|
164
168
|
: defaultBrainlinkConfig.defaultSearchLimit,
|
package/dist/mcp/server.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
-
import { addNoteInputSchema, addFileInputSchema, addFileTool, addNoteTool, volatileAddInputSchema, volatileAddTool, volatileClearInputSchema, volatileClearTool, dedupeInputSchema, dedupeResolveInputSchema, dedupeResolveTool, dedupeTool, brokenLinksInputSchema, brokenLinksTool, bootstrapInputSchema, bootstrapTool, contextInputSchema, contextTool, graphInputSchema, graphTool, indexInputSchema, indexTool, orphansInputSchema, orphansTool, policyInputSchema, policyTool, recommendationsInputSchema, recommendationsTool, searchInputSchema, searchTool, statsInputSchema, statsTool, syncInputSchema, syncTool, validateInputSchema, validateTool, versionInputSchema, versionTool } from './tools.js';
|
|
2
|
+
import { addNoteInputSchema, addFileInputSchema, addFileTool, addNoteTool, volatileAddInputSchema, volatileAddTool, volatileClearInputSchema, volatileClearTool, dedupeInputSchema, dedupeResolveInputSchema, dedupeResolveTool, dedupeTool, brokenLinksInputSchema, brokenLinksTool, bootstrapInputSchema, bootstrapTool, canonicalizeContextLinksInputSchema, canonicalizeContextLinksTool, contextInputSchema, contextTool, graphContextsInputSchema, graphContextsTool, graphInputSchema, graphTool, indexInputSchema, indexTool, orphansInputSchema, orphansTool, policyInputSchema, policyTool, recommendationsInputSchema, recommendationsTool, searchInputSchema, searchTool, statsInputSchema, statsTool, syncInputSchema, syncTool, validateInputSchema, validateTool, versionInputSchema, versionTool } from './tools.js';
|
|
3
3
|
import { getRuntimeVersion } from './runtime.js';
|
|
4
4
|
export const createBrainlinkMcpServer = () => {
|
|
5
5
|
const server = new McpServer({
|
|
@@ -68,6 +68,11 @@ export const createBrainlinkMcpServer = () => {
|
|
|
68
68
|
description: 'Read a local markdown/text file and ingest it as a Brainlink note. Reindex defaults to true.',
|
|
69
69
|
inputSchema: addFileInputSchema
|
|
70
70
|
}, addFileTool);
|
|
71
|
+
server.registerTool('brainlink_canonicalize_context_links', {
|
|
72
|
+
title: 'Canonicalize Brainlink Context Links',
|
|
73
|
+
description: 'Ensure notes have canonical Context Links to inferred context hubs. Supports dry-run and can create missing hub notes.',
|
|
74
|
+
inputSchema: canonicalizeContextLinksInputSchema
|
|
75
|
+
}, canonicalizeContextLinksTool);
|
|
71
76
|
server.registerTool('brainlink_index', {
|
|
72
77
|
title: 'Index Brainlink Vault',
|
|
73
78
|
description: 'Rebuild the local Brainlink index from Markdown notes. Pass full=true to force a complete source rebuild.',
|
|
@@ -93,6 +98,11 @@ export const createBrainlinkMcpServer = () => {
|
|
|
93
98
|
description: 'Read indexed graph nodes and wiki-link edges. Edges include weight and priority fields so agents can rank importance and priority.',
|
|
94
99
|
inputSchema: graphInputSchema
|
|
95
100
|
}, graphTool);
|
|
101
|
+
server.registerTool('brainlink_graph_contexts', {
|
|
102
|
+
title: 'List Brainlink Graph Contexts',
|
|
103
|
+
description: 'List visual graph contexts used by the Brainlink server to separate memory domains such as preferences, repositories and machine configuration.',
|
|
104
|
+
inputSchema: graphContextsInputSchema
|
|
105
|
+
}, graphContextsTool);
|
|
96
106
|
server.registerTool('brainlink_broken_links', {
|
|
97
107
|
title: 'List Brainlink Broken Links',
|
|
98
108
|
description: 'List unresolved indexed wiki links.',
|
package/dist/mcp/tools.js
CHANGED
|
@@ -4,8 +4,10 @@ import { z } from 'zod';
|
|
|
4
4
|
import { getBrokenLinksReport, getOrphansReport, getStats, validateVault } from '../application/analyze-vault.js';
|
|
5
5
|
import { addNoteWithMetadata } from '../application/add-note.js';
|
|
6
6
|
import { buildContextPackage } from '../application/build-context.js';
|
|
7
|
+
import { canonicalizeContextLinks } from '../application/canonical-context-links.js';
|
|
7
8
|
import { resolveDuplicateNotes, scanDuplicateNotes } from '../application/dedupe-notes.js';
|
|
8
9
|
import { getGraph } from '../application/get-graph.js';
|
|
10
|
+
import { getGraphContexts } from '../application/get-graph-contexts.js';
|
|
9
11
|
import { indexVault } from '../application/index-vault.js';
|
|
10
12
|
import { searchKnowledge } from '../application/search-knowledge.js';
|
|
11
13
|
import { resolveAgentRuntimeDefaults, sanitizeSearchMode } from '../infrastructure/config.js';
|
|
@@ -237,7 +239,11 @@ export const addNoteInputSchema = {
|
|
|
237
239
|
.describe('Durable Markdown memory. Include explicit [[wiki links]] and #tags when the memory should be connected. Put priority markers near important links, for example priority: high, #important or #critical.'),
|
|
238
240
|
...agentInput,
|
|
239
241
|
allowSensitive: z.boolean().optional().default(false).describe('Allow content that looks like a secret.'),
|
|
240
|
-
autoIndex: z.boolean().optional().default(true).describe('Reindex vault after writing note.')
|
|
242
|
+
autoIndex: z.boolean().optional().default(true).describe('Reindex vault after writing note.'),
|
|
243
|
+
autoContextLinks: z
|
|
244
|
+
.boolean()
|
|
245
|
+
.optional()
|
|
246
|
+
.describe('Automatically add canonical Context Links to the inferred visual context hub. Defaults to Brainlink config.')
|
|
241
247
|
};
|
|
242
248
|
export const volatileAddInputSchema = {
|
|
243
249
|
...vaultInput,
|
|
@@ -261,6 +267,13 @@ export const addFileInputSchema = {
|
|
|
261
267
|
autoIndex: z.boolean().optional().default(true).describe('Reindex vault after ingesting file.'),
|
|
262
268
|
allowSensitive: z.boolean().optional().default(false).describe('Allow content that looks like a secret.')
|
|
263
269
|
};
|
|
270
|
+
export const canonicalizeContextLinksInputSchema = {
|
|
271
|
+
...vaultInput,
|
|
272
|
+
...agentInput,
|
|
273
|
+
dryRun: z.boolean().optional().default(false).describe('Preview canonical context-link writes without changing Markdown.'),
|
|
274
|
+
createHubs: z.boolean().optional().default(true).describe('Create missing context hub notes when needed.'),
|
|
275
|
+
autoIndex: z.boolean().optional().default(true).describe('Reindex after canonicalization when files changed.')
|
|
276
|
+
};
|
|
264
277
|
export const indexInputSchema = {
|
|
265
278
|
...vaultInput,
|
|
266
279
|
full: z
|
|
@@ -277,6 +290,10 @@ export const graphInputSchema = {
|
|
|
277
290
|
...vaultInput,
|
|
278
291
|
...agentInput
|
|
279
292
|
};
|
|
293
|
+
export const graphContextsInputSchema = {
|
|
294
|
+
...vaultInput,
|
|
295
|
+
...agentInput
|
|
296
|
+
};
|
|
280
297
|
export const brokenLinksInputSchema = {
|
|
281
298
|
...vaultInput,
|
|
282
299
|
...agentInput
|
|
@@ -401,7 +418,8 @@ export const addNoteTool = async (input) => {
|
|
|
401
418
|
const context = await resolveExecutionContext(input);
|
|
402
419
|
const shouldIndex = isTruthy(input.autoIndex);
|
|
403
420
|
const added = await addNoteWithMetadata(context.vault, input.title, input.content, context.agent, {
|
|
404
|
-
allowSensitive: input.allowSensitive
|
|
421
|
+
allowSensitive: input.allowSensitive,
|
|
422
|
+
autoContextLinks: input.autoContextLinks ?? context.config.autoCanonicalContextLinks
|
|
405
423
|
});
|
|
406
424
|
const index = shouldIndex ? await indexVault(context.vault) : undefined;
|
|
407
425
|
const focusPath = added.path.includes('agents/') ? added.path.slice(added.path.indexOf('agents/')).replaceAll('\\', '/') : undefined;
|
|
@@ -420,7 +438,9 @@ export const addNoteTool = async (input) => {
|
|
|
420
438
|
writeConnectivity: {
|
|
421
439
|
autoLinked: added.autoLinked,
|
|
422
440
|
linkTarget: added.linkTarget,
|
|
423
|
-
|
|
441
|
+
context: added.context,
|
|
442
|
+
hubCreated: added.hubCreated,
|
|
443
|
+
guaranteedEdge: added.autoLinked
|
|
424
444
|
},
|
|
425
445
|
possibleDuplicates,
|
|
426
446
|
...(index ? { index } : {})
|
|
@@ -455,7 +475,8 @@ export const addFileTool = async (input) => {
|
|
|
455
475
|
}
|
|
456
476
|
const shouldIndex = isTruthy(input.autoIndex);
|
|
457
477
|
const added = await addNoteWithMetadata(context.vault, title, content, context.agent, {
|
|
458
|
-
allowSensitive: input.allowSensitive
|
|
478
|
+
allowSensitive: input.allowSensitive,
|
|
479
|
+
autoContextLinks: context.config.autoCanonicalContextLinks
|
|
459
480
|
});
|
|
460
481
|
const index = shouldIndex ? await indexVault(context.vault) : undefined;
|
|
461
482
|
return jsonResult({
|
|
@@ -467,11 +488,30 @@ export const addFileTool = async (input) => {
|
|
|
467
488
|
writeConnectivity: {
|
|
468
489
|
autoLinked: added.autoLinked,
|
|
469
490
|
linkTarget: added.linkTarget,
|
|
470
|
-
|
|
491
|
+
context: added.context,
|
|
492
|
+
hubCreated: added.hubCreated,
|
|
493
|
+
guaranteedEdge: added.autoLinked
|
|
471
494
|
},
|
|
472
495
|
...(index ? { index } : {})
|
|
473
496
|
});
|
|
474
497
|
};
|
|
498
|
+
export const canonicalizeContextLinksTool = async (input) => {
|
|
499
|
+
const context = await resolveExecutionContext(input);
|
|
500
|
+
const result = await canonicalizeContextLinks(context.vault, {
|
|
501
|
+
agentId: context.agent,
|
|
502
|
+
dryRun: input.dryRun === true,
|
|
503
|
+
createMissingHubs: input.createHubs !== false
|
|
504
|
+
});
|
|
505
|
+
const index = input.autoIndex !== false && !result.dryRun && result.changed > 0
|
|
506
|
+
? await indexVault(context.vault, { full: true })
|
|
507
|
+
: undefined;
|
|
508
|
+
return jsonResult({
|
|
509
|
+
vault: context.vault,
|
|
510
|
+
agent: context.agent,
|
|
511
|
+
...result,
|
|
512
|
+
...(index ? { index } : {})
|
|
513
|
+
});
|
|
514
|
+
};
|
|
475
515
|
export const indexTool = async (input) => {
|
|
476
516
|
const context = await resolveExecutionContext(input);
|
|
477
517
|
const result = await indexVault(context.vault, {
|
|
@@ -520,6 +560,25 @@ export const graphTool = async (input) => {
|
|
|
520
560
|
...graph
|
|
521
561
|
});
|
|
522
562
|
};
|
|
563
|
+
export const graphContextsTool = async (input) => {
|
|
564
|
+
const context = await resolveExecutionContext(input);
|
|
565
|
+
const readiness = await ensureBootstrapReady(context, input, 'brainlink_graph_contexts');
|
|
566
|
+
if (readiness.preflight) {
|
|
567
|
+
return readiness.preflight;
|
|
568
|
+
}
|
|
569
|
+
const contextReadiness = await ensureContextReady(context, input, 'brainlink_graph_contexts');
|
|
570
|
+
if (contextReadiness.preflight) {
|
|
571
|
+
return contextReadiness.preflight;
|
|
572
|
+
}
|
|
573
|
+
const contexts = await getGraphContexts(context.vault, context.agent);
|
|
574
|
+
return jsonResult({
|
|
575
|
+
vault: context.vault,
|
|
576
|
+
agent: context.agent,
|
|
577
|
+
...(readiness.bootstrap ? { bootstrap: readiness.bootstrap } : {}),
|
|
578
|
+
...(contextReadiness.context ? { contextReadiness: contextReadiness.context } : {}),
|
|
579
|
+
contexts
|
|
580
|
+
});
|
|
581
|
+
};
|
|
523
582
|
export const brokenLinksTool = async (input) => {
|
|
524
583
|
const context = await resolveExecutionContext(input);
|
|
525
584
|
const readiness = await ensureBootstrapReady(context, input, 'brainlink_broken_links');
|
package/docs/AGENT_USAGE.md
CHANGED
|
@@ -56,6 +56,7 @@ You can tune search-pack compression with `searchPack.rowChunkSize`, `searchPack
|
|
|
56
56
|
Guardrails for benchmark acceptance are configured with `searchPack.guardrailMinSavingsPercent` and `searchPack.guardrailMaxLatencyRegressionPercent`.
|
|
57
57
|
|
|
58
58
|
`autoIndexOnWrite` (default: `true`) controls whether `add` and MCP write tools index right after writing.
|
|
59
|
+
`autoCanonicalContextLinks` (default: `true`) controls whether CLI/MCP write tools add canonical `## Context Links` to inferred context hubs.
|
|
59
60
|
|
|
60
61
|
## Agent Namespaces
|
|
61
62
|
|
|
@@ -627,12 +628,14 @@ Use `--no-index` when you need to inspect the current index without rebuilding i
|
|
|
627
628
|
blink server --vault ./vault --no-index
|
|
628
629
|
```
|
|
629
630
|
|
|
630
|
-
|
|
631
|
+
The server watches Markdown files by default and keeps the graph updated after edits:
|
|
631
632
|
|
|
632
633
|
```bash
|
|
633
|
-
blink server --vault ./vault
|
|
634
|
+
blink server --vault ./vault
|
|
634
635
|
```
|
|
635
636
|
|
|
637
|
+
Use `--no-watch` when you need to run the graph server without realtime reindexing.
|
|
638
|
+
|
|
636
639
|
### Watch A Vault
|
|
637
640
|
|
|
638
641
|
```bash
|
|
@@ -672,6 +675,7 @@ Available MCP tools:
|
|
|
672
675
|
- `brainlink_resolve_duplicate`
|
|
673
676
|
- `brainlink_add_note`
|
|
674
677
|
- `brainlink_add_file`
|
|
678
|
+
- `brainlink_canonicalize_context_links`
|
|
675
679
|
- `brainlink_volatile_add`
|
|
676
680
|
- `brainlink_volatile_clear`
|
|
677
681
|
- `brainlink_index`
|
|
@@ -679,6 +683,7 @@ Available MCP tools:
|
|
|
679
683
|
- `brainlink_validate`
|
|
680
684
|
- `brainlink_sync`
|
|
681
685
|
- `brainlink_graph`
|
|
686
|
+
- `brainlink_graph_contexts`
|
|
682
687
|
- `brainlink_broken_links`
|
|
683
688
|
- `brainlink_orphans`
|
|
684
689
|
|
package/package.json
CHANGED