@andespindola/brainlink 0.1.0-beta.121 → 0.1.0-beta.122
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/README.md +3 -0
- package/dist/application/build-context.js +16 -10
- package/dist/application/get-graph-layout.js +35 -3
- package/dist/application/get-graph-view.js +122 -0
- package/dist/application/server/routes.js +15 -0
- package/dist/cli/commands/write-commands.js +24 -0
- package/dist/domain/context.js +1 -0
- package/dist/infrastructure/volatile-memory.js +100 -0
- package/dist/mcp/server.js +11 -1
- package/dist/mcp/tools.js +34 -0
- package/docs/AGENT_USAGE.md +4 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -535,6 +535,8 @@ Available tools:
|
|
|
535
535
|
- `brainlink_resolve_duplicate`: resolve duplicate pairs (`merge`, `link`, `ignore`) with connectivity-safe fallback edges.
|
|
536
536
|
- `brainlink_add_note`: write durable Markdown memory and reindex.
|
|
537
537
|
- `brainlink_add_file`: ingest a local file as a note and reindex.
|
|
538
|
+
- `brainlink_volatile_add`: write temporary agent-decided memory with TTL; volatile sections are included in context and never create durable graph edges.
|
|
539
|
+
- `brainlink_volatile_clear`: clear temporary memory for the current vault/agent namespace.
|
|
538
540
|
- `brainlink_index`: rebuild the vault index.
|
|
539
541
|
- `brainlink_stats`: read indexed vault statistics.
|
|
540
542
|
- `brainlink_validate`: validate broken links and orphan notes.
|
|
@@ -623,6 +625,7 @@ Routes:
|
|
|
623
625
|
- `GET /api/agents`
|
|
624
626
|
- `GET /api/graph`
|
|
625
627
|
- `GET /api/graph-layout`
|
|
628
|
+
- `GET /api/graph-view?x=<x>&y=<y>&w=<width>&h=<height>&scale=<scale>`
|
|
626
629
|
- `GET /api/graph-node?id=<node-id>`
|
|
627
630
|
- `GET /api/search?q=<query>&limit=10&mode=hybrid`
|
|
628
631
|
- `GET /api/context?q=<query>&limit=12&tokens=2000&mode=hybrid`
|
|
@@ -1,18 +1,21 @@
|
|
|
1
1
|
import { stat } from 'node:fs/promises';
|
|
2
2
|
import { formatContextPackage, selectContextSections } from '../domain/context.js';
|
|
3
3
|
import { indexStoragePath } from '../infrastructure/file-index.js';
|
|
4
|
+
import { searchVolatileMemory, volatileMemoryStoragePath } from '../infrastructure/volatile-memory.js';
|
|
4
5
|
import { searchKnowledge } from './search-knowledge.js';
|
|
5
6
|
const contextCacheTtlMs = 45_000;
|
|
6
7
|
const contextCacheMaxEntries = 200;
|
|
7
8
|
const contextCache = new Map();
|
|
8
|
-
const
|
|
9
|
+
const readFileSignature = async (path) => {
|
|
9
10
|
try {
|
|
10
|
-
|
|
11
|
+
const info = await stat(path);
|
|
12
|
+
return `${Math.floor(info.mtimeMs)}:${info.size}`;
|
|
11
13
|
}
|
|
12
14
|
catch {
|
|
13
|
-
return 0;
|
|
15
|
+
return '0:0';
|
|
14
16
|
}
|
|
15
17
|
};
|
|
18
|
+
const readContextDataSignature = async (vaultPath) => `${await readFileSignature(indexStoragePath(vaultPath))}|${await readFileSignature(volatileMemoryStoragePath(vaultPath))}`;
|
|
16
19
|
const toCacheKey = (vaultPath, query, limit, maxTokens, agentId, mode) => JSON.stringify({
|
|
17
20
|
vaultPath,
|
|
18
21
|
query: query.trim().toLowerCase(),
|
|
@@ -21,12 +24,12 @@ const toCacheKey = (vaultPath, query, limit, maxTokens, agentId, mode) => JSON.s
|
|
|
21
24
|
agentId: agentId?.trim().toLowerCase() ?? '*',
|
|
22
25
|
mode: mode ?? 'default'
|
|
23
26
|
});
|
|
24
|
-
const contextCacheGet = (key,
|
|
27
|
+
const contextCacheGet = (key, dataSignature) => {
|
|
25
28
|
const entry = contextCache.get(key);
|
|
26
29
|
if (!entry) {
|
|
27
30
|
return undefined;
|
|
28
31
|
}
|
|
29
|
-
const fresh = Date.now() - entry.createdAt <= contextCacheTtlMs && entry.
|
|
32
|
+
const fresh = Date.now() - entry.createdAt <= contextCacheTtlMs && entry.dataSignature === dataSignature;
|
|
30
33
|
if (!fresh) {
|
|
31
34
|
contextCache.delete(key);
|
|
32
35
|
return undefined;
|
|
@@ -44,22 +47,25 @@ const contextCacheSet = (entry) => {
|
|
|
44
47
|
};
|
|
45
48
|
export const buildContextPackage = async (vaultPath, query, limit, maxTokens, agentId, mode) => {
|
|
46
49
|
const cacheKey = toCacheKey(vaultPath, query, limit, maxTokens, agentId, mode);
|
|
47
|
-
const
|
|
48
|
-
const cached = contextCacheGet(cacheKey,
|
|
50
|
+
const dataSignature = await readContextDataSignature(vaultPath);
|
|
51
|
+
const cached = contextCacheGet(cacheKey, dataSignature);
|
|
49
52
|
if (cached) {
|
|
50
53
|
return cached;
|
|
51
54
|
}
|
|
52
55
|
const results = await searchKnowledge(vaultPath, query, limit, agentId, mode);
|
|
53
|
-
const
|
|
56
|
+
const durableSections = selectContextSections(results, maxTokens);
|
|
57
|
+
const volatileSections = await searchVolatileMemory(vaultPath, query, Math.min(3, limit), agentId, mode ?? 'hybrid');
|
|
58
|
+
const sections = [...volatileSections, ...durableSections];
|
|
54
59
|
const context = {
|
|
55
60
|
query,
|
|
56
61
|
sections,
|
|
57
|
-
content: formatContextPackage(query, sections)
|
|
62
|
+
content: formatContextPackage(query, sections),
|
|
63
|
+
...(volatileSections.length > 0 ? { volatileSections } : {})
|
|
58
64
|
};
|
|
59
65
|
contextCacheSet({
|
|
60
66
|
key: cacheKey,
|
|
61
67
|
createdAt: Date.now(),
|
|
62
|
-
|
|
68
|
+
dataSignature,
|
|
63
69
|
context
|
|
64
70
|
});
|
|
65
71
|
return context;
|
|
@@ -1,9 +1,27 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
|
-
import { stat } from 'node:fs/promises';
|
|
2
|
+
import { mkdir, readFile, rename, stat, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
3
4
|
import { createCauliflowerGraphLayout } from '../domain/graph-layout.js';
|
|
4
5
|
import { indexStoragePath } from '../infrastructure/file-index.js';
|
|
5
6
|
import { getGraphSummary } from './get-graph-summary.js';
|
|
6
7
|
const graphLayoutCache = new Map();
|
|
8
|
+
const graphLayoutStoragePath = (vaultPath, agentId) => join(vaultPath, '.brainlink', `graph-layout-${agentId?.replace(/[^a-zA-Z0-9_-]/g, '_') ?? 'all'}.json`);
|
|
9
|
+
const readPersistedLayout = async (vaultPath, databaseSignature, agentId) => {
|
|
10
|
+
try {
|
|
11
|
+
const parsed = JSON.parse(await readFile(graphLayoutStoragePath(vaultPath, agentId), 'utf8'));
|
|
12
|
+
return parsed.databaseSignature === databaseSignature ? parsed : null;
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
const writePersistedLayout = async (vaultPath, agentId, cached) => {
|
|
19
|
+
const target = graphLayoutStoragePath(vaultPath, agentId);
|
|
20
|
+
const temp = `${target}.tmp`;
|
|
21
|
+
await mkdir(dirname(target), { recursive: true, mode: 0o700 });
|
|
22
|
+
await writeFile(temp, `${JSON.stringify(cached)}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
23
|
+
await rename(temp, target);
|
|
24
|
+
};
|
|
7
25
|
const readDatabaseSignature = async (vaultPath) => {
|
|
8
26
|
try {
|
|
9
27
|
const info = await stat(indexStoragePath(vaultPath));
|
|
@@ -32,10 +50,24 @@ export const getGraphLayout = async (vaultPath, agentId) => {
|
|
|
32
50
|
layout: cached.layout
|
|
33
51
|
};
|
|
34
52
|
}
|
|
53
|
+
const persisted = await readPersistedLayout(vaultPath, databaseSignature, agentId);
|
|
54
|
+
if (persisted) {
|
|
55
|
+
graphLayoutCache.set(cacheKey, persisted);
|
|
56
|
+
return {
|
|
57
|
+
signature: persisted.signature,
|
|
58
|
+
layout: persisted.layout
|
|
59
|
+
};
|
|
60
|
+
}
|
|
35
61
|
const graph = await getGraphSummary(vaultPath, agentId);
|
|
36
62
|
const signature = createGraphSignature(graph);
|
|
37
|
-
const
|
|
38
|
-
|
|
63
|
+
const rawLayout = createCauliflowerGraphLayout(graph);
|
|
64
|
+
const layout = {
|
|
65
|
+
...rawLayout,
|
|
66
|
+
nodes: rawLayout.nodes.map((node) => ({ ...node, content: '' }))
|
|
67
|
+
};
|
|
68
|
+
const nextCache = { databaseSignature, signature, layout };
|
|
69
|
+
graphLayoutCache.set(cacheKey, nextCache);
|
|
70
|
+
await writePersistedLayout(vaultPath, agentId, nextCache);
|
|
39
71
|
return {
|
|
40
72
|
signature,
|
|
41
73
|
layout
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { getGraphLayout } from './get-graph-layout.js';
|
|
2
|
+
const macroScale = 0.24;
|
|
3
|
+
const microCoverage = 0.72;
|
|
4
|
+
const nodeLimit = 1200;
|
|
5
|
+
const edgeLimit = 5000;
|
|
6
|
+
const inViewport = (item, input) => {
|
|
7
|
+
const radius = item.radius ?? 48;
|
|
8
|
+
return (item.x + radius >= input.x &&
|
|
9
|
+
item.x - radius <= input.x + input.width &&
|
|
10
|
+
item.y + radius >= input.y &&
|
|
11
|
+
item.y - radius <= input.y + input.height);
|
|
12
|
+
};
|
|
13
|
+
const groupCoverage = (group, input) => {
|
|
14
|
+
const viewportRadius = Math.max(input.width, input.height) / 2;
|
|
15
|
+
const centerX = input.x + input.width / 2;
|
|
16
|
+
const centerY = input.y + input.height / 2;
|
|
17
|
+
const centerDistance = Math.hypot(group.x - centerX, group.y - centerY);
|
|
18
|
+
const fitCoverage = Math.min(1, group.radius / Math.max(viewportRadius, 1));
|
|
19
|
+
const centerCoverage = 1 - Math.min(1, centerDistance / Math.max(viewportRadius, 1));
|
|
20
|
+
return fitCoverage * 0.72 + centerCoverage * 0.28;
|
|
21
|
+
};
|
|
22
|
+
const groupNode = (group) => [
|
|
23
|
+
`group:${group.id}`,
|
|
24
|
+
group.title,
|
|
25
|
+
group.x,
|
|
26
|
+
group.y,
|
|
27
|
+
group.group,
|
|
28
|
+
group.segment,
|
|
29
|
+
'group'
|
|
30
|
+
];
|
|
31
|
+
const realNode = (node) => [
|
|
32
|
+
node.id,
|
|
33
|
+
node.title,
|
|
34
|
+
node.x,
|
|
35
|
+
node.y,
|
|
36
|
+
node.group,
|
|
37
|
+
node.segment,
|
|
38
|
+
'node'
|
|
39
|
+
];
|
|
40
|
+
const descendants = (group, groupById) => group.nodeIds.length > 0
|
|
41
|
+
? group.nodeIds
|
|
42
|
+
: group.childGroupIds.flatMap((childId) => {
|
|
43
|
+
const child = groupById.get(childId);
|
|
44
|
+
return child ? descendants(child, groupById) : [];
|
|
45
|
+
});
|
|
46
|
+
const aggregateGroupEdges = (groups, edges, groupById) => {
|
|
47
|
+
const groupNodeByNodeId = new Map();
|
|
48
|
+
groups.forEach((group) => {
|
|
49
|
+
descendants(group, groupById).forEach((nodeId) => groupNodeByNodeId.set(nodeId, `group:${group.id}`));
|
|
50
|
+
});
|
|
51
|
+
const selected = new Map();
|
|
52
|
+
edges.forEach((edge) => {
|
|
53
|
+
if (!edge.target)
|
|
54
|
+
return;
|
|
55
|
+
const source = groupNodeByNodeId.get(edge.source);
|
|
56
|
+
const target = groupNodeByNodeId.get(edge.target);
|
|
57
|
+
if (!source || !target || source === target)
|
|
58
|
+
return;
|
|
59
|
+
const key = source < target ? `${source}|${target}` : `${target}|${source}`;
|
|
60
|
+
const current = selected.get(key);
|
|
61
|
+
if (current && current[2] >= edge.weight)
|
|
62
|
+
return;
|
|
63
|
+
selected.set(key, [source, target, edge.weight, edge.priority]);
|
|
64
|
+
});
|
|
65
|
+
return Array.from(selected.values()).slice(0, edgeLimit);
|
|
66
|
+
};
|
|
67
|
+
const realEdges = (edges, nodeIds) => edges
|
|
68
|
+
.filter((edge) => Boolean(edge.target && nodeIds.has(edge.source) && nodeIds.has(edge.target)))
|
|
69
|
+
.slice(0, edgeLimit)
|
|
70
|
+
.map((edge) => [edge.source, edge.target, edge.weight, edge.priority]);
|
|
71
|
+
export const getGraphView = async (vaultPath, input) => {
|
|
72
|
+
const { signature, layout } = await getGraphLayout(vaultPath, input.agentId);
|
|
73
|
+
const groups = layout.groups ?? [];
|
|
74
|
+
const groupById = new Map(groups.map((group) => [group.id, group]));
|
|
75
|
+
if (groups.length === 0) {
|
|
76
|
+
const nodes = layout.nodes.filter((node) => inViewport(node, input)).slice(0, nodeLimit);
|
|
77
|
+
const nodeIds = new Set(nodes.map((node) => node.id));
|
|
78
|
+
return {
|
|
79
|
+
signature,
|
|
80
|
+
mode: 'flat',
|
|
81
|
+
nodes: nodes.map(realNode),
|
|
82
|
+
edges: realEdges(layout.edges, nodeIds),
|
|
83
|
+
totals: {
|
|
84
|
+
nodes: layout.nodes.length,
|
|
85
|
+
edges: layout.edges.length
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
const visibleGroups = groups.filter((group) => group.parentId === null && inViewport(group, input));
|
|
90
|
+
const focused = groups
|
|
91
|
+
.filter((group) => group.nodeIds.length > 0 && inViewport(group, input))
|
|
92
|
+
.map((group) => ({ group, coverage: groupCoverage(group, input) }))
|
|
93
|
+
.sort((left, right) => right.coverage - left.coverage)[0];
|
|
94
|
+
if (focused && input.scale >= macroScale && focused.coverage >= microCoverage) {
|
|
95
|
+
const nodeIds = new Set(focused.group.nodeIds);
|
|
96
|
+
const nodes = layout.nodes
|
|
97
|
+
.filter((node) => nodeIds.has(node.id) && inViewport(node, input))
|
|
98
|
+
.slice(0, nodeLimit);
|
|
99
|
+
const visibleNodeIds = new Set(nodes.map((node) => node.id));
|
|
100
|
+
return {
|
|
101
|
+
signature,
|
|
102
|
+
mode: 'micro',
|
|
103
|
+
nodes: nodes.map(realNode),
|
|
104
|
+
edges: realEdges(layout.edges, visibleNodeIds),
|
|
105
|
+
totals: {
|
|
106
|
+
nodes: layout.nodes.length,
|
|
107
|
+
edges: layout.edges.length
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
const groupsToRender = (visibleGroups.length > 0 ? visibleGroups : groups.filter((group) => group.parentId === null)).slice(0, nodeLimit);
|
|
112
|
+
return {
|
|
113
|
+
signature,
|
|
114
|
+
mode: 'macro',
|
|
115
|
+
nodes: groupsToRender.map(groupNode),
|
|
116
|
+
edges: aggregateGroupEdges(groupsToRender, layout.edges, groupById),
|
|
117
|
+
totals: {
|
|
118
|
+
nodes: layout.nodes.length,
|
|
119
|
+
edges: layout.edges.length
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
};
|
|
@@ -3,6 +3,7 @@ import { buildContextPackage } from '../build-context.js';
|
|
|
3
3
|
import { getGraph } from '../get-graph.js';
|
|
4
4
|
import { getGraphNode } from '../get-graph-node.js';
|
|
5
5
|
import { getGraphLayout } from '../get-graph-layout.js';
|
|
6
|
+
import { getGraphView } from '../get-graph-view.js';
|
|
6
7
|
import { listAgents } from '../list-agents.js';
|
|
7
8
|
import { listBacklinks, listLinks } from '../list-links.js';
|
|
8
9
|
import { searchGraphNodeIds } from '../search-graph-node-ids.js';
|
|
@@ -52,6 +53,10 @@ const sameEntityTag = (candidate, signature) => {
|
|
|
52
53
|
return decodeEntityTag(candidate) === signature;
|
|
53
54
|
};
|
|
54
55
|
const readAgentQuery = (url) => url.searchParams.get('agent') ?? undefined;
|
|
56
|
+
const parseNumber = (value, fallback) => {
|
|
57
|
+
const parsed = Number(value);
|
|
58
|
+
return Number.isFinite(parsed) ? parsed : fallback;
|
|
59
|
+
};
|
|
55
60
|
const compactGraphLayoutThreshold = 12_000;
|
|
56
61
|
const compactGraphLayoutEdgeLimit = 60_000;
|
|
57
62
|
const compactGraphLayoutEdgeLimitFor = (nodeCount) => {
|
|
@@ -182,6 +187,16 @@ export const route = async (request, url, vaultPath) => {
|
|
|
182
187
|
}
|
|
183
188
|
};
|
|
184
189
|
}
|
|
190
|
+
if (isReadMethod(request) && url.pathname === '/api/graph-view') {
|
|
191
|
+
return createResponse(createJsonResponse(await getGraphView(vaultPath, {
|
|
192
|
+
x: parseNumber(url.searchParams.get('x'), -1000),
|
|
193
|
+
y: parseNumber(url.searchParams.get('y'), -1000),
|
|
194
|
+
width: parseNumber(url.searchParams.get('w'), 2000),
|
|
195
|
+
height: parseNumber(url.searchParams.get('h'), 2000),
|
|
196
|
+
scale: parseNumber(url.searchParams.get('scale'), 1),
|
|
197
|
+
agentId: readAgentQuery(url)
|
|
198
|
+
})), 200, contentTypes['.json']);
|
|
199
|
+
}
|
|
185
200
|
if (isReadMethod(request) && url.pathname === '/api/graph-node') {
|
|
186
201
|
const id = url.searchParams.get('id')?.trim() ?? '';
|
|
187
202
|
if (!id) {
|
|
@@ -17,6 +17,7 @@ import { defaultBrainlinkConfig, sanitizeSearchMode } from '../../infrastructure
|
|
|
17
17
|
import { loadBrainlinkConfig } from '../../infrastructure/config.js';
|
|
18
18
|
import { assertVaultAllowed, ensureVault } from '../../infrastructure/file-system-vault.js';
|
|
19
19
|
import { getBootstrapPolicy, getBootstrapSessionStatus, touchBootstrapSession } from '../../infrastructure/session-state.js';
|
|
20
|
+
import { addVolatileMemory, clearVolatileMemory } from '../../infrastructure/volatile-memory.js';
|
|
20
21
|
import { installAgentIntegration } from './agent-commands.js';
|
|
21
22
|
import { parsePositiveInteger, print, resolveOptions } from '../runtime.js';
|
|
22
23
|
const resolveAddContent = (options) => {
|
|
@@ -729,6 +730,29 @@ export const registerWriteCommands = (program) => {
|
|
|
729
730
|
return `${summary}${indexMessage}${dryRunMessage}`;
|
|
730
731
|
});
|
|
731
732
|
});
|
|
733
|
+
program
|
|
734
|
+
.command('volatile')
|
|
735
|
+
.option('-c, --content <content>', 'temporary memory content to add')
|
|
736
|
+
.option('--ttl <minutes>', 'time-to-live in minutes', '240')
|
|
737
|
+
.option('--tag <tag...>', 'volatile memory tag')
|
|
738
|
+
.option('-v, --vault <vault>', 'vault directory')
|
|
739
|
+
.option('-a, --agent <agent>', 'agent memory namespace')
|
|
740
|
+
.option('--clear', 'clear volatile memory for the current agent namespace')
|
|
741
|
+
.option('--json', 'print machine-readable JSON')
|
|
742
|
+
.description('add or clear temporary agent-decided memory')
|
|
743
|
+
.action(async (options) => {
|
|
744
|
+
const resolved = await resolveOptions(options);
|
|
745
|
+
if (options.clear) {
|
|
746
|
+
const cleared = await clearVolatileMemory(resolved.vault, resolved.agent);
|
|
747
|
+
print(options.json, { cleared, agent: resolved.agent ?? 'shared' }, () => `Cleared ${cleared} volatile memories.`);
|
|
748
|
+
return;
|
|
749
|
+
}
|
|
750
|
+
if (!options.content || options.content.trim().length === 0) {
|
|
751
|
+
throw new Error('Use --content to add volatile memory, or --clear to remove it.');
|
|
752
|
+
}
|
|
753
|
+
const entry = await addVolatileMemory(resolved.vault, options.content, resolved.agent ?? 'shared', parsePositiveInteger(options.ttl ?? '240', 240), options.tag ?? []);
|
|
754
|
+
print(options.json, { entry }, () => `Stored volatile memory until ${entry.expiresAt}.`);
|
|
755
|
+
});
|
|
732
756
|
program
|
|
733
757
|
.command('add')
|
|
734
758
|
.argument('<title>', 'note title')
|
package/dist/domain/context.js
CHANGED
|
@@ -76,6 +76,7 @@ export const formatContextPackage = (query, sections) => {
|
|
|
76
76
|
section.tags.length > 0 ? `Tags: ${section.tags.map((tag) => `#${tag}`).join(' ')}` : null,
|
|
77
77
|
`Score: ${section.score.toFixed(3)}`,
|
|
78
78
|
`Mode: ${section.searchMode}`,
|
|
79
|
+
section.volatile ? `Volatile: true${section.expiresAt ? `, expires ${section.expiresAt}` : ''}` : null,
|
|
79
80
|
'',
|
|
80
81
|
section.content
|
|
81
82
|
]
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
export const volatileMemoryStoragePath = (vaultPath) => join(vaultPath, '.brainlink', 'volatile.json');
|
|
4
|
+
const emptyStore = () => ({
|
|
5
|
+
version: 1,
|
|
6
|
+
entries: []
|
|
7
|
+
});
|
|
8
|
+
const normalizeToken = (value) => value
|
|
9
|
+
.normalize('NFKD')
|
|
10
|
+
.replace(/\p{Diacritic}/gu, '')
|
|
11
|
+
.toLowerCase();
|
|
12
|
+
const tokens = (value) => value
|
|
13
|
+
.match(/[\p{L}\p{N}_-]+/gu)
|
|
14
|
+
?.map(normalizeToken)
|
|
15
|
+
.filter((token) => token.length > 1) ?? [];
|
|
16
|
+
const readStore = async (vaultPath) => {
|
|
17
|
+
try {
|
|
18
|
+
const parsed = JSON.parse(await readFile(volatileMemoryStoragePath(vaultPath), 'utf8'));
|
|
19
|
+
return {
|
|
20
|
+
version: 1,
|
|
21
|
+
entries: Array.isArray(parsed.entries) ? parsed.entries.filter(isEntry) : []
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return emptyStore();
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
const writeStore = async (vaultPath, store) => {
|
|
29
|
+
const target = volatileMemoryStoragePath(vaultPath);
|
|
30
|
+
const temp = `${target}.tmp`;
|
|
31
|
+
await mkdir(dirname(target), { recursive: true, mode: 0o700 });
|
|
32
|
+
await writeFile(temp, `${JSON.stringify(store, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
33
|
+
await rename(temp, target);
|
|
34
|
+
};
|
|
35
|
+
const isEntry = (value) => {
|
|
36
|
+
const record = value;
|
|
37
|
+
return Boolean(record &&
|
|
38
|
+
typeof record.id === 'string' &&
|
|
39
|
+
typeof record.agentId === 'string' &&
|
|
40
|
+
typeof record.content === 'string' &&
|
|
41
|
+
typeof record.createdAt === 'string' &&
|
|
42
|
+
typeof record.expiresAt === 'string' &&
|
|
43
|
+
Array.isArray(record.tags));
|
|
44
|
+
};
|
|
45
|
+
const activeEntries = (entries, now = Date.now()) => entries.filter((entry) => Date.parse(entry.expiresAt) > now);
|
|
46
|
+
const scoreEntry = (entry, query) => {
|
|
47
|
+
const queryTokens = tokens(query);
|
|
48
|
+
if (queryTokens.length === 0) {
|
|
49
|
+
return 1;
|
|
50
|
+
}
|
|
51
|
+
const haystack = normalizeToken([entry.content, entry.tags.join(' ')].join(' '));
|
|
52
|
+
return queryTokens.reduce((score, token) => score + (haystack.includes(token) ? 1 : 0), 0);
|
|
53
|
+
};
|
|
54
|
+
export const addVolatileMemory = async (vaultPath, content, agentId = 'shared', ttlMinutes = 240, tags = []) => {
|
|
55
|
+
const now = new Date();
|
|
56
|
+
const entry = {
|
|
57
|
+
id: `volatile-${now.getTime()}-${Math.random().toString(36).slice(2, 10)}`,
|
|
58
|
+
agentId,
|
|
59
|
+
content,
|
|
60
|
+
createdAt: now.toISOString(),
|
|
61
|
+
expiresAt: new Date(now.getTime() + Math.max(1, ttlMinutes) * 60_000).toISOString(),
|
|
62
|
+
source: 'agent',
|
|
63
|
+
tags
|
|
64
|
+
};
|
|
65
|
+
const store = await readStore(vaultPath);
|
|
66
|
+
await writeStore(vaultPath, {
|
|
67
|
+
version: 1,
|
|
68
|
+
entries: [...activeEntries(store.entries), entry]
|
|
69
|
+
});
|
|
70
|
+
return entry;
|
|
71
|
+
};
|
|
72
|
+
export const searchVolatileMemory = async (vaultPath, query, limit, agentId, mode = 'hybrid') => {
|
|
73
|
+
const store = await readStore(vaultPath);
|
|
74
|
+
const entries = activeEntries(store.entries)
|
|
75
|
+
.filter((entry) => (agentId ? entry.agentId === agentId : true))
|
|
76
|
+
.map((entry) => ({ entry, score: scoreEntry(entry, query) }))
|
|
77
|
+
.filter(({ score }) => score > 0)
|
|
78
|
+
.sort((left, right) => right.score - left.score || right.entry.createdAt.localeCompare(left.entry.createdAt))
|
|
79
|
+
.slice(0, Math.max(0, limit));
|
|
80
|
+
if (entries.length !== store.entries.length) {
|
|
81
|
+
await writeStore(vaultPath, { version: 1, entries: activeEntries(store.entries) });
|
|
82
|
+
}
|
|
83
|
+
return entries.map(({ entry, score }) => ({
|
|
84
|
+
title: 'Volatile Memory',
|
|
85
|
+
path: `volatile://${entry.id}`,
|
|
86
|
+
content: entry.content,
|
|
87
|
+
score,
|
|
88
|
+
searchMode: mode,
|
|
89
|
+
tags: ['volatile', ...entry.tags],
|
|
90
|
+
volatile: true,
|
|
91
|
+
expiresAt: entry.expiresAt
|
|
92
|
+
}));
|
|
93
|
+
};
|
|
94
|
+
export const clearVolatileMemory = async (vaultPath, agentId) => {
|
|
95
|
+
const store = await readStore(vaultPath);
|
|
96
|
+
const active = activeEntries(store.entries);
|
|
97
|
+
const kept = agentId ? active.filter((entry) => entry.agentId !== agentId) : [];
|
|
98
|
+
await writeStore(vaultPath, { version: 1, entries: kept });
|
|
99
|
+
return active.length - kept.length;
|
|
100
|
+
};
|
package/dist/mcp/server.js
CHANGED
|
@@ -2,7 +2,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
|
2
2
|
import { readFileSync } from 'node:fs';
|
|
3
3
|
import { dirname, join } from 'node:path';
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
|
-
import { addNoteInputSchema, addFileInputSchema, addFileTool, addNoteTool, 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 } from './tools.js';
|
|
5
|
+
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 } from './tools.js';
|
|
6
6
|
const readPackageVersion = () => {
|
|
7
7
|
const packagePath = join(dirname(fileURLToPath(import.meta.url)), '../../package.json');
|
|
8
8
|
const metadata = JSON.parse(readFileSync(packagePath, 'utf8'));
|
|
@@ -55,6 +55,16 @@ export const createBrainlinkMcpServer = () => {
|
|
|
55
55
|
description: 'Write durable Markdown memory, then reindex the vault. Include explicit [[wiki links]] for connected graph memory. Add priority markers near links, such as priority: high, #important or #critical, when a relationship should be weighted higher.',
|
|
56
56
|
inputSchema: addNoteInputSchema
|
|
57
57
|
}, addNoteTool);
|
|
58
|
+
server.registerTool('brainlink_volatile_add', {
|
|
59
|
+
title: 'Add Volatile Brainlink Memory',
|
|
60
|
+
description: 'Write temporary agent-decided memory with TTL. Use for transient task state without polluting durable Markdown memory.',
|
|
61
|
+
inputSchema: volatileAddInputSchema
|
|
62
|
+
}, volatileAddTool);
|
|
63
|
+
server.registerTool('brainlink_volatile_clear', {
|
|
64
|
+
title: 'Clear Volatile Brainlink Memory',
|
|
65
|
+
description: 'Clear active volatile memory for the current vault/agent namespace.',
|
|
66
|
+
inputSchema: volatileClearInputSchema
|
|
67
|
+
}, volatileClearTool);
|
|
58
68
|
server.registerTool('brainlink_add_file', {
|
|
59
69
|
title: 'Ingest Markdown File',
|
|
60
70
|
description: 'Read a local markdown/text file and ingest it as a Brainlink note. Reindex defaults to true.',
|
package/dist/mcp/tools.js
CHANGED
|
@@ -11,6 +11,7 @@ import { searchKnowledge } from '../application/search-knowledge.js';
|
|
|
11
11
|
import { resolveAgentRuntimeDefaults, sanitizeSearchMode } from '../infrastructure/config.js';
|
|
12
12
|
import { loadBrainlinkConfig } from '../infrastructure/config.js';
|
|
13
13
|
import { assertVaultAllowed } from '../infrastructure/file-system-vault.js';
|
|
14
|
+
import { addVolatileMemory, clearVolatileMemory } from '../infrastructure/volatile-memory.js';
|
|
14
15
|
import { getBootstrapPolicy, getBootstrapSessionStatus, getContextSessionStatus, setBootstrapPolicy, touchBootstrapSession, touchContextSession } from '../infrastructure/session-state.js';
|
|
15
16
|
const positiveInteger = (fallback) => z
|
|
16
17
|
.number()
|
|
@@ -237,6 +238,20 @@ export const addNoteInputSchema = {
|
|
|
237
238
|
allowSensitive: z.boolean().optional().default(false).describe('Allow content that looks like a secret.'),
|
|
238
239
|
autoIndex: z.boolean().optional().default(true).describe('Reindex vault after writing note.')
|
|
239
240
|
};
|
|
241
|
+
export const volatileAddInputSchema = {
|
|
242
|
+
...vaultInput,
|
|
243
|
+
...agentInput,
|
|
244
|
+
content: z
|
|
245
|
+
.string()
|
|
246
|
+
.min(1)
|
|
247
|
+
.describe('Temporary agent-decided memory. Use for current task state, hypotheses, transient user preferences and unconfirmed findings.'),
|
|
248
|
+
ttlMinutes: optionalPositiveInteger().describe('Minutes before this volatile memory expires. Defaults to 240.'),
|
|
249
|
+
tags: z.array(z.string()).optional().default([]).describe('Optional tags for volatile retrieval.')
|
|
250
|
+
};
|
|
251
|
+
export const volatileClearInputSchema = {
|
|
252
|
+
...vaultInput,
|
|
253
|
+
...agentInput
|
|
254
|
+
};
|
|
240
255
|
export const addFileInputSchema = {
|
|
241
256
|
...vaultInput,
|
|
242
257
|
...agentInput,
|
|
@@ -401,6 +416,25 @@ export const addNoteTool = async (input) => {
|
|
|
401
416
|
...(index ? { index } : {})
|
|
402
417
|
});
|
|
403
418
|
};
|
|
419
|
+
export const volatileAddTool = async (input) => {
|
|
420
|
+
const context = await resolveExecutionContext(input);
|
|
421
|
+
const entry = await addVolatileMemory(context.vault, input.content, context.agent ?? 'shared', input.ttlMinutes ?? 240, input.tags);
|
|
422
|
+
return jsonResult({
|
|
423
|
+
vault: context.vault,
|
|
424
|
+
agent: context.agent,
|
|
425
|
+
volatile: true,
|
|
426
|
+
entry
|
|
427
|
+
});
|
|
428
|
+
};
|
|
429
|
+
export const volatileClearTool = async (input) => {
|
|
430
|
+
const context = await resolveExecutionContext(input);
|
|
431
|
+
const cleared = await clearVolatileMemory(context.vault, context.agent);
|
|
432
|
+
return jsonResult({
|
|
433
|
+
vault: context.vault,
|
|
434
|
+
agent: context.agent,
|
|
435
|
+
cleared
|
|
436
|
+
});
|
|
437
|
+
};
|
|
404
438
|
export const addFileTool = async (input) => {
|
|
405
439
|
const context = await resolveExecutionContext(input);
|
|
406
440
|
const content = await readFile(input.filePath, 'utf8');
|
package/docs/AGENT_USAGE.md
CHANGED
|
@@ -670,6 +670,8 @@ Available MCP tools:
|
|
|
670
670
|
- `brainlink_resolve_duplicate`
|
|
671
671
|
- `brainlink_add_note`
|
|
672
672
|
- `brainlink_add_file`
|
|
673
|
+
- `brainlink_volatile_add`
|
|
674
|
+
- `brainlink_volatile_clear`
|
|
673
675
|
- `brainlink_index`
|
|
674
676
|
- `brainlink_stats`
|
|
675
677
|
- `brainlink_validate`
|
|
@@ -689,6 +691,7 @@ MCP clients can pass `vault` and `agent` arguments per tool call. Set `BRAINLINK
|
|
|
689
691
|
|
|
690
692
|
`brainlink_graph` returns weighted edges. Agents should prefer higher `weight` and stronger `priority` when deciding which related notes matter most.
|
|
691
693
|
`brainlink_add_note` and `brainlink_add_file` return `writeConnectivity` metadata and guarantee at least one edge for new notes.
|
|
694
|
+
Agents should use `brainlink_volatile_add` for temporary task state, hypotheses, local execution details and unconfirmed findings. Volatile memory is included in `brainlink_context` with `Volatile: true`, expires by TTL and does not create durable Markdown notes or graph edges.
|
|
692
695
|
|
|
693
696
|
```bash
|
|
694
697
|
export BRAINLINK_ALLOWED_VAULTS="/absolute/path/to/project-vault"
|
|
@@ -699,6 +702,7 @@ export BRAINLINK_ALLOWED_VAULTS="/absolute/path/to/project-vault"
|
|
|
699
702
|
```txt
|
|
700
703
|
GET /api/graph
|
|
701
704
|
GET /api/graph-layout
|
|
705
|
+
GET /api/graph-view?x=<x>&y=<y>&w=<width>&h=<height>&scale=<scale>
|
|
702
706
|
GET /api/graph-node?id=<node-id>
|
|
703
707
|
GET /api/graph-filter?q=<query>&limit=<n>
|
|
704
708
|
GET /api/search?q=<query>&limit=10&mode=hybrid
|
package/package.json
CHANGED