@andespindola/brainlink 0.1.0-beta.121 → 0.1.0-beta.123

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 CHANGED
@@ -84,11 +84,11 @@ Legacy `.jsonl.gz` packs are upgraded to `.blpk` automatically on first search/c
84
84
  - Graph renderer optimized for large datasets with viewport-driven node culling and edge lookup by visible nodes.
85
85
  - Canvas graph rendering uses the same batched node and edge pipeline for every graph size, reducing per-frame draw calls while keeping selected and hovered items highlighted.
86
86
  - WebGL acceleration is used when available for dense node and edge drawing, with Canvas 2D preserved as the interaction and fallback layer.
87
- - Graph rendering keeps the flat node scene and adds stable hierarchical groups for vaults above 1000 notes, with recursive parent groups when a level itself exceeds 1000 groups.
87
+ - Graph rendering keeps the flat node scene and adds stable hierarchical mesh groups for vaults above 1000 notes, with every visible graph level capped at 1000 nodes and recursive parent groups when a level itself exceeds 1000 groups.
88
88
  - Large graph layout API automatically uses compact payload encoding with link-coverage-aware edge selection to reduce initial client load without hiding major relationships.
89
89
  - Large-segment layout spacing now grows logarithmically to keep initial visual density consistent between medium and very large vaults (for example, ~1k vs ~50k notes).
90
90
  - Graph coordinates are visually compacted across graph sizes so reset starts from a stable fitted scene and zoom-in progressively reveals local detail.
91
- - Zoomed-out graph LOD renders hierarchy groups as normal graph nodes and expands a group only after it is framed in the viewport, progressively hiding sibling groups in micro view.
91
+ - Zoomed-out graph LOD renders hierarchy groups as normal mesh graph nodes and expands a group only after it is framed in the viewport, progressively hiding sibling groups in micro view.
92
92
  - Graph reset fits the full graph scene instead of starting in a separate macro overview mode.
93
93
  - Graph filtering runs in a dedicated browser worker to keep the UI thread responsive during heavy datasets.
94
94
  - Edge rendering budgets adapt to zoom level to prevent frame spikes on large graph panoramas.
@@ -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.
@@ -604,7 +606,7 @@ The graph UI shows:
604
606
  - graph rendering safeguards (batched canvas drawing across graph sizes, edge draw caps, lower redraw rate, zoom-aware interaction)
605
607
  - adaptive CPU safeguards for large graphs: idle frame pacing, throttled background physics updates and cached viewport dimensions to reduce redraw/layout overhead while preserving interaction responsiveness
606
608
  - WebGL node and edge acceleration when supported, falling back to Canvas 2D without changing graph behavior
607
- - large graph LOD keeps a recursive graph-of-graphs model: zoom-out shows one level of group nodes, zoom-in expands the framed node into its child graph, micro view renders only that focused subgraph, and zoom-out restores sibling groups
609
+ - large graph LOD keeps a recursive graph-of-graphs mesh model: zoom-out shows one level capped at 1000 group nodes, zoom-in expands the framed node into its child graph capped at 1000 nodes, micro view renders only that focused subgraph, and zoom-out restores sibling groups
608
610
 
609
611
  The server indexes before starting by default. Use `--no-index` to skip that step:
610
612
 
@@ -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 readIndexMtimeMs = async (vaultPath) => {
9
+ const readFileSignature = async (path) => {
9
10
  try {
10
- return (await stat(indexStoragePath(vaultPath))).mtimeMs;
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, indexMtimeMs) => {
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.indexMtimeMs === indexMtimeMs;
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 indexMtimeMs = await readIndexMtimeMs(vaultPath);
48
- const cached = contextCacheGet(cacheKey, indexMtimeMs);
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 sections = selectContextSections(results, maxTokens);
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
- indexMtimeMs,
68
+ dataSignature,
63
69
  context
64
70
  });
65
71
  return context;
@@ -4,7 +4,7 @@ const ctx = canvas.getContext('2d')
4
4
  const largeGraphNodeThreshold = 4000
5
5
  const massiveGraphNodeThreshold = 20000
6
6
  const largeGraphEdgeRenderLimit = 120000
7
- const renderNodeBudget = 900
7
+ const renderNodeBudget = 1000
8
8
  const zoomedMassiveRenderNodeBudget = 2200
9
9
  const massiveOverviewRenderNodeBudget = 1800
10
10
  const massiveOverviewScaleThreshold = 0.065
@@ -1509,7 +1509,8 @@ const buildMeshEdgesForNodes = (nodes, existingEdges) => {
1509
1509
  }
1510
1510
 
1511
1511
  const withMeshEdges = (nodes, edges) => {
1512
- if (nodes.length === 0 || state.visibleNodes.length <= largeGraphNodeThreshold || state.transform.scale < meshEdgeScaleThreshold) {
1512
+ const isHierarchyGraphLevel = state.groups.length > 0 && (state.visibleNodes.length > 1000 || state.hierarchyFocusGroupId)
1513
+ if (nodes.length === 0 || !isHierarchyGraphLevel || state.transform.scale < meshEdgeScaleThreshold) {
1513
1514
  return edges
1514
1515
  }
1515
1516
 
@@ -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 layout = createCauliflowerGraphLayout(graph);
38
- graphLayoutCache.set(cacheKey, { databaseSignature, signature, layout });
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,151 @@
1
+ import { getGraphLayout } from './get-graph-layout.js';
2
+ const macroScale = 0.24;
3
+ const microCoverage = 0.72;
4
+ const nodeLimit = 1000;
5
+ const edgeLimit = 5000;
6
+ const meshEdgeLimit = 2500;
7
+ const inViewport = (item, input) => {
8
+ const radius = item.radius ?? 48;
9
+ return (item.x + radius >= input.x &&
10
+ item.x - radius <= input.x + input.width &&
11
+ item.y + radius >= input.y &&
12
+ item.y - radius <= input.y + input.height);
13
+ };
14
+ const groupCoverage = (group, input) => {
15
+ const viewportRadius = Math.max(input.width, input.height) / 2;
16
+ const centerX = input.x + input.width / 2;
17
+ const centerY = input.y + input.height / 2;
18
+ const centerDistance = Math.hypot(group.x - centerX, group.y - centerY);
19
+ const fitCoverage = Math.min(1, group.radius / Math.max(viewportRadius, 1));
20
+ const centerCoverage = 1 - Math.min(1, centerDistance / Math.max(viewportRadius, 1));
21
+ return fitCoverage * 0.72 + centerCoverage * 0.28;
22
+ };
23
+ const groupNode = (group) => [
24
+ `group:${group.id}`,
25
+ group.title,
26
+ group.x,
27
+ group.y,
28
+ group.group,
29
+ group.segment,
30
+ 'group'
31
+ ];
32
+ const realNode = (node) => [
33
+ node.id,
34
+ node.title,
35
+ node.x,
36
+ node.y,
37
+ node.group,
38
+ node.segment,
39
+ 'node'
40
+ ];
41
+ const descendants = (group, groupById) => group.nodeIds.length > 0
42
+ ? group.nodeIds
43
+ : group.childGroupIds.flatMap((childId) => {
44
+ const child = groupById.get(childId);
45
+ return child ? descendants(child, groupById) : [];
46
+ });
47
+ const aggregateGroupEdges = (groups, edges, groupById) => {
48
+ const groupNodeByNodeId = new Map();
49
+ groups.forEach((group) => {
50
+ descendants(group, groupById).forEach((nodeId) => groupNodeByNodeId.set(nodeId, `group:${group.id}`));
51
+ });
52
+ const selected = new Map();
53
+ edges.forEach((edge) => {
54
+ if (!edge.target)
55
+ return;
56
+ const source = groupNodeByNodeId.get(edge.source);
57
+ const target = groupNodeByNodeId.get(edge.target);
58
+ if (!source || !target || source === target)
59
+ return;
60
+ const key = source < target ? `${source}|${target}` : `${target}|${source}`;
61
+ const current = selected.get(key);
62
+ if (current && current[2] >= edge.weight)
63
+ return;
64
+ selected.set(key, [source, target, edge.weight, edge.priority]);
65
+ });
66
+ return Array.from(selected.values()).slice(0, edgeLimit);
67
+ };
68
+ const realEdges = (edges, nodeIds) => edges
69
+ .filter((edge) => Boolean(edge.target && nodeIds.has(edge.source) && nodeIds.has(edge.target)))
70
+ .slice(0, edgeLimit)
71
+ .map((edge) => [edge.source, edge.target, edge.weight, edge.priority]);
72
+ const edgePairKey = (left, right) => left < right ? `${left}|${right}` : `${right}|${left}`;
73
+ const meshEdges = (nodes, existingEdges) => {
74
+ if (nodes.length < 2) {
75
+ return [];
76
+ }
77
+ const existing = new Set(existingEdges.map((edge) => edgePairKey(edge[0], edge[1])));
78
+ const selected = [];
79
+ const selectedKeys = new Set();
80
+ const maxNeighbors = nodes.length > 500 ? 2 : 3;
81
+ const byX = [...nodes].sort((left, right) => left[2] - right[2] || left[3] - right[3] || left[0].localeCompare(right[0]));
82
+ for (let index = 0; index < nodes.length && selected.length < meshEdgeLimit; index += 1) {
83
+ const node = byX[index];
84
+ const candidates = byX.slice(index + 1, index + 1 + maxNeighbors);
85
+ candidates.forEach((candidate) => {
86
+ const key = edgePairKey(node[0], candidate[0]);
87
+ if (existing.has(key) || selectedKeys.has(key) || selected.length >= meshEdgeLimit) {
88
+ return;
89
+ }
90
+ selectedKeys.add(key);
91
+ selected.push([node[0], candidate[0], 1, 'low']);
92
+ });
93
+ }
94
+ return selected;
95
+ };
96
+ const withMeshEdges = (nodes, edges) => [...edges, ...meshEdges(nodes, edges)].slice(0, edgeLimit);
97
+ export const getGraphView = async (vaultPath, input) => {
98
+ const { signature, layout } = await getGraphLayout(vaultPath, input.agentId);
99
+ const groups = layout.groups ?? [];
100
+ const groupById = new Map(groups.map((group) => [group.id, group]));
101
+ if (groups.length === 0) {
102
+ const nodes = layout.nodes.filter((node) => inViewport(node, input)).slice(0, nodeLimit);
103
+ const nodeIds = new Set(nodes.map((node) => node.id));
104
+ const viewNodes = nodes.map(realNode);
105
+ return {
106
+ signature,
107
+ mode: 'flat',
108
+ nodes: viewNodes,
109
+ edges: withMeshEdges(viewNodes, realEdges(layout.edges, nodeIds)),
110
+ totals: {
111
+ nodes: layout.nodes.length,
112
+ edges: layout.edges.length
113
+ }
114
+ };
115
+ }
116
+ const visibleGroups = groups.filter((group) => group.parentId === null && inViewport(group, input));
117
+ const focused = groups
118
+ .filter((group) => group.nodeIds.length > 0 && inViewport(group, input))
119
+ .map((group) => ({ group, coverage: groupCoverage(group, input) }))
120
+ .sort((left, right) => right.coverage - left.coverage)[0];
121
+ if (focused && input.scale >= macroScale && focused.coverage >= microCoverage) {
122
+ const nodeIds = new Set(focused.group.nodeIds);
123
+ const nodes = layout.nodes
124
+ .filter((node) => nodeIds.has(node.id) && inViewport(node, input))
125
+ .slice(0, nodeLimit);
126
+ const visibleNodeIds = new Set(nodes.map((node) => node.id));
127
+ const viewNodes = nodes.map(realNode);
128
+ return {
129
+ signature,
130
+ mode: 'micro',
131
+ nodes: viewNodes,
132
+ edges: withMeshEdges(viewNodes, realEdges(layout.edges, visibleNodeIds)),
133
+ totals: {
134
+ nodes: layout.nodes.length,
135
+ edges: layout.edges.length
136
+ }
137
+ };
138
+ }
139
+ const groupsToRender = (visibleGroups.length > 0 ? visibleGroups : groups.filter((group) => group.parentId === null)).slice(0, nodeLimit);
140
+ const viewNodes = groupsToRender.map(groupNode);
141
+ return {
142
+ signature,
143
+ mode: 'macro',
144
+ nodes: viewNodes,
145
+ edges: withMeshEdges(viewNodes, aggregateGroupEdges(groupsToRender, layout.edges, groupById)),
146
+ totals: {
147
+ nodes: layout.nodes.length,
148
+ edges: layout.edges.length
149
+ }
150
+ };
151
+ };
@@ -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')
@@ -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
+ };
@@ -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');
@@ -607,7 +607,7 @@ Without `--vault`, the graph UI serves `$HOME/.brainlink/vault`.
607
607
 
608
608
  The frontend includes an agent selector that shows only the agent id. Selecting an agent calls the same read APIs with `agent=<agent-id>` and renders that namespace instead of merging every agent into one graph.
609
609
 
610
- Graph navigation controls include zoom in, zoom out, fit visible nodes and reset-to-fit-all nodes. Mouse wheel zoom (including `cmd+scroll` and `ctrl+scroll`) is anchored to the cursor. Keyboard shortcuts are `+` (zoom in), `-` (zoom out) and `0` (reset fit). Double-click on canvas zooms in at cursor position. Totals for notes, links and tags stay visible as floating metrics under the Brainlink title, and node details open on click in a modal (tags, outgoing links, backlinks and Markdown content). Vaults above 1000 notes also expose stable hierarchy groups of up to 1000 direct nodes, with recursive parent groups when a level exceeds 1000 groups; zoom-out renders those groups as normal graph nodes, zoom-in expands a group only after it is framed in the viewport, and micro view renders only the focused subgraph until zoom-out restores sibling groups.
610
+ Graph navigation controls include zoom in, zoom out, fit visible nodes and reset-to-fit-all nodes. Mouse wheel zoom (including `cmd+scroll` and `ctrl+scroll`) is anchored to the cursor. Keyboard shortcuts are `+` (zoom in), `-` (zoom out) and `0` (reset fit). Double-click on canvas zooms in at cursor position. Totals for notes, links and tags stay visible as floating metrics under the Brainlink title, and node details open on click in a modal (tags, outgoing links, backlinks and Markdown content). Vaults above 1000 notes also expose stable hierarchy mesh groups of up to 1000 direct nodes, with recursive parent groups when a level exceeds 1000 groups; zoom-out renders those groups as normal mesh graph nodes, zoom-in expands a group only after it is framed in the viewport, and micro view renders only the focused subgraph until zoom-out restores sibling groups.
611
611
  During graph filtering, Brainlink keeps hub context nodes visible (`Memory Hub`/`MOC`/high-degree fallback) so filtered views still show relationship anchors.
612
612
 
613
613
  The command reindexes by default, then serves:
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andespindola/brainlink",
3
- "version": "0.1.0-beta.121",
3
+ "version": "0.1.0-beta.123",
4
4
  "description": "Local-first knowledge memory for agents with Markdown, backlinks, indexing and context retrieval.",
5
5
  "type": "module",
6
6
  "license": "MIT",