@andespindola/brainlink 0.1.0-beta.152 → 0.1.0-beta.154
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +2 -2
- package/README.md +26 -7
- package/dist/application/add-note.js +12 -3
- package/dist/application/canonical-context-links.js +209 -0
- package/dist/application/frontend/client-css.js +8 -4
- package/dist/application/frontend/client-html.js +3 -0
- package/dist/application/frontend/client-js.js +103 -7
- package/dist/application/get-graph-contexts.js +19 -0
- package/dist/application/get-graph-layout.js +38 -17
- package/dist/application/get-graph-stream-chunk.js +4 -1
- package/dist/application/get-graph-view.js +4 -1
- package/dist/application/search-graph-node-ids.js +14 -5
- package/dist/application/server/routes.js +17 -4
- 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/docs/ARCHITECTURE.md +2 -0
- package/package.json +1 -1
|
@@ -1,23 +1,29 @@
|
|
|
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
|
-
const
|
|
10
|
-
const
|
|
10
|
+
const safeCacheSegment = (value, fallback) => value?.replace(/[^a-zA-Z0-9_-]/g, '_') || fallback;
|
|
11
|
+
const graphLayoutStoragePath = (vaultPath, options) => {
|
|
12
|
+
const agent = safeCacheSegment(options.agentId, 'all');
|
|
13
|
+
const context = safeCacheSegment(options.context, 'all-contexts');
|
|
14
|
+
return join(vaultPath, '.brainlink', `graph-layout-${agent}-${context}.json`);
|
|
15
|
+
};
|
|
16
|
+
const readPersistedLayout = async (vaultPath, databaseSignature, options) => {
|
|
11
17
|
try {
|
|
12
|
-
const parsed = JSON.parse(await readFile(graphLayoutStoragePath(vaultPath,
|
|
18
|
+
const parsed = JSON.parse(await readFile(graphLayoutStoragePath(vaultPath, options), 'utf8'));
|
|
13
19
|
return parsed.databaseSignature === databaseSignature && parsed.layoutVersion === graphLayoutVersion ? parsed : null;
|
|
14
20
|
}
|
|
15
21
|
catch {
|
|
16
22
|
return null;
|
|
17
23
|
}
|
|
18
24
|
};
|
|
19
|
-
const writePersistedLayout = async (vaultPath,
|
|
20
|
-
const target = graphLayoutStoragePath(vaultPath,
|
|
25
|
+
const writePersistedLayout = async (vaultPath, options, cached) => {
|
|
26
|
+
const target = graphLayoutStoragePath(vaultPath, options);
|
|
21
27
|
const temp = `${target}.tmp`;
|
|
22
28
|
await mkdir(dirname(target), { recursive: true, mode: 0o700 });
|
|
23
29
|
await writeFile(temp, `${JSON.stringify(cached)}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
@@ -41,9 +47,27 @@ const createGraphSignature = (graph) => {
|
|
|
41
47
|
.update(`${graph.nodes.length}:${nodesSignature}|${graph.edges.length}:${edgesSignature}`)
|
|
42
48
|
.digest('hex');
|
|
43
49
|
};
|
|
44
|
-
|
|
50
|
+
const createLayout = (graph) => {
|
|
51
|
+
const rawLayout = createStarGraphLayout(graph);
|
|
52
|
+
return {
|
|
53
|
+
...rawLayout,
|
|
54
|
+
nodes: rawLayout.nodes.map((node) => ({ ...node, content: '' }))
|
|
55
|
+
};
|
|
56
|
+
};
|
|
57
|
+
const filterGraphByContext = (graph, context) => {
|
|
58
|
+
const baseLayout = createStarGraphLayout(graph);
|
|
59
|
+
const selectedNodeIds = new Set(baseLayout.nodes
|
|
60
|
+
.filter((node) => node.segment === context)
|
|
61
|
+
.map((node) => node.id));
|
|
62
|
+
return {
|
|
63
|
+
nodes: graph.nodes.filter((node) => selectedNodeIds.has(node.id)),
|
|
64
|
+
edges: graph.edges.filter((edge) => selectedNodeIds.has(edge.source) && Boolean(edge.target && selectedNodeIds.has(edge.target)))
|
|
65
|
+
};
|
|
66
|
+
};
|
|
67
|
+
export const getGraphLayout = async (vaultPath, optionsOrAgentId) => {
|
|
68
|
+
const options = typeof optionsOrAgentId === 'string' ? { agentId: optionsOrAgentId } : optionsOrAgentId ?? {};
|
|
45
69
|
const databaseSignature = await readDatabaseSignature(vaultPath);
|
|
46
|
-
const cacheKey = `${vaultPath}:${agentId ?? ''}`;
|
|
70
|
+
const cacheKey = `${vaultPath}:${options.agentId ?? ''}:${options.context ?? ''}`;
|
|
47
71
|
const cached = graphLayoutCache.get(cacheKey);
|
|
48
72
|
if (cached?.databaseSignature === databaseSignature && cached.layoutVersion === graphLayoutVersion) {
|
|
49
73
|
return {
|
|
@@ -51,7 +75,7 @@ export const getGraphLayout = async (vaultPath, agentId) => {
|
|
|
51
75
|
layout: cached.layout
|
|
52
76
|
};
|
|
53
77
|
}
|
|
54
|
-
const persisted = await readPersistedLayout(vaultPath, databaseSignature,
|
|
78
|
+
const persisted = await readPersistedLayout(vaultPath, databaseSignature, options);
|
|
55
79
|
if (persisted) {
|
|
56
80
|
graphLayoutCache.set(cacheKey, persisted);
|
|
57
81
|
return {
|
|
@@ -59,16 +83,13 @@ export const getGraphLayout = async (vaultPath, agentId) => {
|
|
|
59
83
|
layout: persisted.layout
|
|
60
84
|
};
|
|
61
85
|
}
|
|
62
|
-
const graph = await getGraphSummary(vaultPath, agentId);
|
|
63
|
-
const
|
|
64
|
-
const
|
|
65
|
-
const layout =
|
|
66
|
-
...rawLayout,
|
|
67
|
-
nodes: rawLayout.nodes.map((node) => ({ ...node, content: '' }))
|
|
68
|
-
};
|
|
86
|
+
const graph = addVisualContextEdges(await getGraphSummary(vaultPath, options.agentId));
|
|
87
|
+
const scopedGraph = options.context ? filterGraphByContext(graph, options.context) : graph;
|
|
88
|
+
const signature = createGraphSignature(scopedGraph);
|
|
89
|
+
const layout = createLayout(scopedGraph);
|
|
69
90
|
const nextCache = { layoutVersion: graphLayoutVersion, databaseSignature, signature, layout };
|
|
70
91
|
graphLayoutCache.set(cacheKey, nextCache);
|
|
71
|
-
await writePersistedLayout(vaultPath,
|
|
92
|
+
await writePersistedLayout(vaultPath, options, nextCache);
|
|
72
93
|
return {
|
|
73
94
|
signature,
|
|
74
95
|
layout
|
|
@@ -244,7 +244,10 @@ const normalizeBudget = (value, fallback, min, max) => {
|
|
|
244
244
|
export const getGraphStreamChunk = async (vaultPath, input) => {
|
|
245
245
|
const nodeBudget = normalizeBudget(input.nodeBudget, 1800, 80, 12_000);
|
|
246
246
|
const edgeBudget = normalizeBudget(input.edgeBudget, 5000, 120, 60_000);
|
|
247
|
-
const { signature, layout } = await getGraphLayout(vaultPath,
|
|
247
|
+
const { signature, layout } = await getGraphLayout(vaultPath, {
|
|
248
|
+
agentId: input.agentId,
|
|
249
|
+
context: input.context
|
|
250
|
+
});
|
|
248
251
|
const groups = layout.groups ?? [];
|
|
249
252
|
const cache = getOrCreateLayoutCache(signature, layout.nodes, layout.edges, groups);
|
|
250
253
|
if (layout.nodes.length === 0) {
|
|
@@ -180,7 +180,10 @@ const arrangeChildGraphNodes = (nodes, group, degrees) => {
|
|
|
180
180
|
};
|
|
181
181
|
const limitEdges = (edges) => edges.slice(0, edgeLimit);
|
|
182
182
|
export const getGraphView = async (vaultPath, input) => {
|
|
183
|
-
const { signature, layout } = await getGraphLayout(vaultPath,
|
|
183
|
+
const { signature, layout } = await getGraphLayout(vaultPath, {
|
|
184
|
+
agentId: input.agentId,
|
|
185
|
+
context: input.context
|
|
186
|
+
});
|
|
184
187
|
const groups = layout.groups ?? [];
|
|
185
188
|
const degrees = degreeMap(layout.edges);
|
|
186
189
|
const groupById = new Map(groups.map((group) => [group.id, group]));
|
|
@@ -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,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { getBrokenLinksReport, getOrphansReport, getStats, validateVault } from '../analyze-vault.js';
|
|
2
2
|
import { buildContextPackage } from '../build-context.js';
|
|
3
3
|
import { getGraph } from '../get-graph.js';
|
|
4
|
+
import { getGraphContexts } from '../get-graph-contexts.js';
|
|
4
5
|
import { getGraphNode } from '../get-graph-node.js';
|
|
5
6
|
import { getGraphLayout } from '../get-graph-layout.js';
|
|
6
7
|
import { getGraphView } from '../get-graph-view.js';
|
|
@@ -55,6 +56,10 @@ const sameEntityTag = (candidate, signature) => {
|
|
|
55
56
|
return decodeEntityTag(candidate) === signature;
|
|
56
57
|
};
|
|
57
58
|
const readAgentQuery = (url) => url.searchParams.get('agent') ?? undefined;
|
|
59
|
+
const readContextQuery = (url) => {
|
|
60
|
+
const value = url.searchParams.get('context')?.trim() ?? '';
|
|
61
|
+
return value.length > 0 ? value : undefined;
|
|
62
|
+
};
|
|
58
63
|
const parseNumber = (value, fallback) => {
|
|
59
64
|
const parsed = Number(value);
|
|
60
65
|
return Number.isFinite(parsed) ? parsed : fallback;
|
|
@@ -218,7 +223,10 @@ export const route = async (request, url, vaultPath) => {
|
|
|
218
223
|
return createResponse(createJsonResponse(await getGraph(vaultPath, readAgentQuery(url))), 200, contentTypes['.json']);
|
|
219
224
|
}
|
|
220
225
|
if (isReadMethod(request) && url.pathname === '/api/graph-layout') {
|
|
221
|
-
const { signature, layout } = await getGraphLayout(vaultPath,
|
|
226
|
+
const { signature, layout } = await getGraphLayout(vaultPath, {
|
|
227
|
+
agentId: readAgentQuery(url),
|
|
228
|
+
context: readContextQuery(url)
|
|
229
|
+
});
|
|
222
230
|
const requestEtags = request.headers['if-none-match'];
|
|
223
231
|
const notModified = sameEntityTag(requestEtags, signature);
|
|
224
232
|
const etag = encodeEntityTag(signature);
|
|
@@ -253,7 +261,8 @@ export const route = async (request, url, vaultPath) => {
|
|
|
253
261
|
width: parseNumber(url.searchParams.get('w'), 2000),
|
|
254
262
|
height: parseNumber(url.searchParams.get('h'), 2000),
|
|
255
263
|
scale: parseNumber(url.searchParams.get('scale'), 1),
|
|
256
|
-
agentId: readAgentQuery(url)
|
|
264
|
+
agentId: readAgentQuery(url),
|
|
265
|
+
context: readContextQuery(url)
|
|
257
266
|
})), 200, contentTypes['.json']);
|
|
258
267
|
}
|
|
259
268
|
if (isReadMethod(request) && url.pathname === '/api/graph-stream') {
|
|
@@ -272,7 +281,8 @@ export const route = async (request, url, vaultPath) => {
|
|
|
272
281
|
scale,
|
|
273
282
|
nodeBudget,
|
|
274
283
|
edgeBudget,
|
|
275
|
-
agentId: readAgentQuery(url)
|
|
284
|
+
agentId: readAgentQuery(url),
|
|
285
|
+
context: readContextQuery(url)
|
|
276
286
|
})), 200, contentTypes['.json']);
|
|
277
287
|
}
|
|
278
288
|
if (isReadMethod(request) && url.pathname === '/api/graph-node') {
|
|
@@ -292,12 +302,15 @@ export const route = async (request, url, vaultPath) => {
|
|
|
292
302
|
if (!query) {
|
|
293
303
|
return createResponse(createJsonResponse({ query, nodeIds: [] }), 200, contentTypes['.json']);
|
|
294
304
|
}
|
|
295
|
-
const nodeIds = await searchGraphNodeIds(vaultPath, query, limit, readAgentQuery(url));
|
|
305
|
+
const nodeIds = await searchGraphNodeIds(vaultPath, query, limit, readAgentQuery(url), readContextQuery(url));
|
|
296
306
|
return createResponse(createJsonResponse({ query, nodeIds }), 200, contentTypes['.json']);
|
|
297
307
|
}
|
|
298
308
|
if (isReadMethod(request) && url.pathname === '/api/agents') {
|
|
299
309
|
return createResponse(createJsonResponse({ agents: await listAgents(vaultPath) }), 200, contentTypes['.json']);
|
|
300
310
|
}
|
|
311
|
+
if (isReadMethod(request) && url.pathname === '/api/graph-contexts') {
|
|
312
|
+
return createResponse(createJsonResponse({ contexts: await getGraphContexts(vaultPath, readAgentQuery(url)) }), 200, contentTypes['.json']);
|
|
313
|
+
}
|
|
301
314
|
if (isReadMethod(request) && url.pathname === '/api/search') {
|
|
302
315
|
const query = url.searchParams.get('q') ?? '';
|
|
303
316
|
const limit = parsePositiveInteger(url.searchParams.get('limit'), 10);
|
|
@@ -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.',
|