@andespindola/brainlink 0.1.0-alpha.8 → 0.1.0-beta.0

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.
@@ -20,8 +20,8 @@ export const registerReadCommands = (program) => {
20
20
  const resolved = await resolveOptions(options);
21
21
  const limit = parsePositiveInteger(options.limit ?? String(resolved.config.defaultSearchLimit), resolved.config.defaultSearchLimit);
22
22
  const mode = sanitizeSearchMode(options.mode, resolved.config.defaultSearchMode);
23
- const results = await searchKnowledge(resolved.vault, query, limit, options.agent, mode);
24
- print(options.json, { query, agent: options.agent, limit, mode, results }, () => results
23
+ const results = await searchKnowledge(resolved.vault, query, limit, resolved.agent, mode);
24
+ print(options.json, { query, agent: resolved.agent, limit, mode, results }, () => results
25
25
  .map((result, index) => [`${index + 1}. ${result.title} (${result.path}) score=${result.score.toFixed(3)} mode=${result.searchMode}`, result.content].join('\n'))
26
26
  .join('\n\n'));
27
27
  });
@@ -33,7 +33,7 @@ export const registerReadCommands = (program) => {
33
33
  .description('list indexed wiki links')
34
34
  .action(async (options) => {
35
35
  const resolved = await resolveOptions(options);
36
- const links = await listLinks(resolved.vault, options.agent);
36
+ const links = await listLinks(resolved.vault, resolved.agent);
37
37
  print(options.json, { links }, () => links
38
38
  .map((link) => {
39
39
  const target = link.toPath ? `${link.toTitle} (${link.toPath})` : `${link.toTitle} (unresolved)`;
@@ -50,7 +50,7 @@ export const registerReadCommands = (program) => {
50
50
  .description('list notes linking to a target note')
51
51
  .action(async (title, options) => {
52
52
  const resolved = await resolveOptions(options);
53
- const backlinks = await listBacklinks(resolved.vault, title, options.agent);
53
+ const backlinks = await listBacklinks(resolved.vault, title, resolved.agent);
54
54
  print(options.json, { title, backlinks }, () => backlinks.map((link) => `${link.fromTitle} (${link.fromPath}) -> ${link.toTitle}`).join('\n'));
55
55
  });
56
56
  program
@@ -66,7 +66,7 @@ export const registerReadCommands = (program) => {
66
66
  .action(async (query, options) => {
67
67
  const resolved = await resolveOptions(options);
68
68
  const mode = sanitizeSearchMode(options.mode, resolved.config.defaultSearchMode);
69
- const contextPackage = await buildContextPackage(resolved.vault, query, parsePositiveInteger(options.limit ?? '12', 12), parsePositiveInteger(options.tokens ?? String(resolved.config.defaultContextTokens), resolved.config.defaultContextTokens), options.agent, mode);
69
+ const contextPackage = await buildContextPackage(resolved.vault, query, parsePositiveInteger(options.limit ?? '12', 12), parsePositiveInteger(options.tokens ?? String(resolved.config.defaultContextTokens), resolved.config.defaultContextTokens), resolved.agent, mode);
70
70
  print(options.json, contextPackage, () => contextPackage.content);
71
71
  });
72
72
  program
@@ -77,7 +77,7 @@ export const registerReadCommands = (program) => {
77
77
  .description('print indexed graph data')
78
78
  .action(async (options) => {
79
79
  const resolved = await resolveOptions(options);
80
- const graph = await getGraph(resolved.vault, options.agent);
80
+ const graph = await getGraph(resolved.vault, resolved.agent);
81
81
  print(options.json, graph, () => JSON.stringify(graph, null, 2));
82
82
  });
83
83
  program
@@ -98,7 +98,7 @@ export const registerReadCommands = (program) => {
98
98
  .description('print indexed vault statistics')
99
99
  .action(async (options) => {
100
100
  const resolved = await resolveOptions(options);
101
- const stats = await getStats(resolved.vault, options.agent);
101
+ const stats = await getStats(resolved.vault, resolved.agent);
102
102
  print(options.json, stats, () => [
103
103
  `Documents: ${stats.documentCount}`,
104
104
  `Links: ${stats.linkCount}`,
@@ -116,7 +116,7 @@ export const registerReadCommands = (program) => {
116
116
  .description('list unresolved wiki links')
117
117
  .action(async (options) => {
118
118
  const resolved = await resolveOptions(options);
119
- const brokenLinks = await getBrokenLinksReport(resolved.vault, options.agent);
119
+ const brokenLinks = await getBrokenLinksReport(resolved.vault, resolved.agent);
120
120
  print(options.json, { brokenLinks }, () => brokenLinks.length === 0
121
121
  ? 'No broken links found'
122
122
  : brokenLinks.map((link) => `${link.fromTitle} (${link.fromPath}) -> ${link.toTitle}`).join('\n'));
@@ -129,7 +129,7 @@ export const registerReadCommands = (program) => {
129
129
  .description('list indexed notes without incoming or outgoing links')
130
130
  .action(async (options) => {
131
131
  const resolved = await resolveOptions(options);
132
- const orphans = await getOrphansReport(resolved.vault, options.agent);
132
+ const orphans = await getOrphansReport(resolved.vault, resolved.agent);
133
133
  print(options.json, { orphans }, () => orphans.length === 0 ? 'No orphan notes found' : orphans.map((node) => `${node.title} (${node.path})`).join('\n'));
134
134
  });
135
135
  program
@@ -140,7 +140,7 @@ export const registerReadCommands = (program) => {
140
140
  .description('validate indexed vault graph health')
141
141
  .action(async (options) => {
142
142
  const resolved = await resolveOptions(options);
143
- const validation = await validateVault(resolved.vault, options.agent);
143
+ const validation = await validateVault(resolved.vault, resolved.agent);
144
144
  print(options.json, validation, () => validation.ok
145
145
  ? 'Vault validation passed'
146
146
  : `Vault validation failed: ${validation.brokenLinks.length} broken links, ${validation.orphans.length} orphan notes`);
@@ -22,16 +22,16 @@ export const registerWriteCommands = (program) => {
22
22
  .argument('<title>', 'note title')
23
23
  .requiredOption('-c, --content <content>', 'markdown content')
24
24
  .option('-v, --vault <vault>', 'vault directory')
25
- .option('-a, --agent <agent>', 'agent memory namespace', 'shared')
25
+ .option('-a, --agent <agent>', 'agent memory namespace')
26
26
  .option('--allow-sensitive', 'allow writing content that looks like a secret')
27
27
  .option('--json', 'print machine-readable JSON')
28
28
  .description('add a markdown note to the vault')
29
29
  .action(async (title, options) => {
30
30
  const resolved = await resolveOptions(options);
31
- const path = await addNote(resolved.vault, title, options.content, options.agent, {
31
+ const path = await addNote(resolved.vault, title, options.content, resolved.agent, {
32
32
  allowSensitive: Boolean(options.allowSensitive)
33
33
  });
34
- print(options.json, { title, agent: options.agent ?? 'shared', path }, () => `Created note at ${path}`);
34
+ print(options.json, { title, agent: resolved.agent ?? 'shared', path }, () => `Created note at ${path}`);
35
35
  });
36
36
  program
37
37
  .command('index')
@@ -89,7 +89,6 @@ export const registerWriteCommands = (program) => {
89
89
  .option('-p, --port <port>', 'server port', '4321')
90
90
  .option('--no-index', 'skip indexing before starting the server')
91
91
  .option('-w, --watch', 'watch markdown files and reindex on changes')
92
- .option('--allow-public', 'allow binding the server to a non-loopback host')
93
92
  .option('--json', 'print machine-readable JSON')
94
93
  .description('start a local web UI for the knowledge graph')
95
94
  .action(async (options) => {
@@ -99,8 +98,7 @@ export const registerWriteCommands = (program) => {
99
98
  host: options.host ?? resolved.config.host,
100
99
  port: parsePositiveInteger(options.port ?? String(resolved.config.port), resolved.config.port),
101
100
  shouldIndex: options.index,
102
- shouldWatch: Boolean(options.watch),
103
- allowPublic: Boolean(options.allowPublic)
101
+ shouldWatch: Boolean(options.watch)
104
102
  });
105
103
  print(options.json, { url: server.url, watch: Boolean(options.watch), readonly: true }, () => `Brainlink graph server running at ${server.url}`);
106
104
  });
@@ -10,7 +10,8 @@ export const resolveOptions = async (options) => {
10
10
  const allowedVault = assertVaultAllowed(vault, config.allowedVaults);
11
11
  return {
12
12
  config,
13
- vault: allowedVault
13
+ vault: allowedVault,
14
+ agent: options.agent ?? config.defaultAgent
14
15
  };
15
16
  };
16
17
  export const print = (json, value, human) => {
@@ -6,6 +6,7 @@ export const sanitizeAgentId = (agentId) => agentId
6
6
  .replace(/[^a-z0-9_-]+/g, '-')
7
7
  .replace(/^-+|-+$/g, '') || sharedAgentId;
8
8
  export const resolveAgentIdFromPath = (path) => {
9
- const [scope, agentId] = path.split('/');
9
+ const normalizedPath = path.replace(/\\/g, '/');
10
+ const [scope, agentId] = normalizedPath.split('/');
10
11
  return scope === 'agents' && agentId ? sanitizeAgentId(agentId) : sharedAgentId;
11
12
  };
@@ -34,8 +34,17 @@ const groupKey = (node) => {
34
34
  return segments[0] ?? 'root';
35
35
  };
36
36
  const groupLabel = (key) => groupLabels[key] ?? key;
37
- const incrementDegree = (degrees, id) => new Map([...degrees, [id, (degrees.get(id) ?? 0) + 1]]);
38
- const countDegrees = (edges) => edges.reduce((degrees, edge) => (edge.target ? incrementDegree(incrementDegree(degrees, edge.source), edge.target) : incrementDegree(degrees, edge.source)), new Map());
37
+ const incrementDegreeBy = (degrees, id, amount) => {
38
+ degrees.set(id, (degrees.get(id) ?? 0) + amount);
39
+ return degrees;
40
+ };
41
+ const edgeDegreeWeight = (edge) => Math.max(1, Math.min(edge.weight, 8));
42
+ const countDegrees = (edges) => edges.reduce((degrees, edge) => {
43
+ const weight = edgeDegreeWeight(edge);
44
+ return edge.target
45
+ ? incrementDegreeBy(incrementDegreeBy(degrees, edge.source, weight), edge.target, weight)
46
+ : incrementDegreeBy(degrees, edge.source, weight);
47
+ }, new Map());
39
48
  const uniqueIds = (ids) => Array.from(new Set(ids));
40
49
  const createAdjacency = (nodes, edges) => {
41
50
  const nodeIds = new Set(nodes.map((node) => node.id));
@@ -134,44 +143,96 @@ const createSegmentNodes = (segments, degrees, segmentCount) => ([segment, nodes
134
143
  });
135
144
  };
136
145
  const distanceBetween = (left, right) => Math.hypot(right.x - left.x, right.y - left.y);
137
- const separatePair = (minDistance, left, right) => {
146
+ const resolveCollisionPair = (left, right, minDistance) => {
138
147
  const dx = right.x - left.x;
139
148
  const dy = right.y - left.y;
140
149
  const distance = Math.max(Math.hypot(dx, dy), 0.001);
141
150
  if (distance >= minDistance) {
142
- return [left, right];
151
+ return;
143
152
  }
144
153
  const push = (minDistance - distance) / 2;
145
154
  const ux = dx / distance;
146
155
  const uy = dy / distance;
147
- return [
148
- {
149
- ...left,
150
- x: left.x - ux * push,
151
- y: left.y - uy * push
152
- },
153
- {
154
- ...right,
155
- x: right.x + ux * push,
156
- y: right.y + uy * push
156
+ left.x -= ux * push;
157
+ left.y -= uy * push;
158
+ right.x += ux * push;
159
+ right.y += uy * push;
160
+ };
161
+ const buildCollisionGrid = (nodes, cellSize) => {
162
+ const grid = new Map();
163
+ nodes.forEach((node, index) => {
164
+ const x = Math.floor(node.x / cellSize);
165
+ const y = Math.floor(node.y / cellSize);
166
+ const key = `${x},${y}`;
167
+ const bucket = grid.get(key);
168
+ if (bucket) {
169
+ bucket.push(index);
170
+ return;
157
171
  }
158
- ];
172
+ grid.set(key, [index]);
173
+ });
174
+ return grid;
159
175
  };
160
- const relaxCollisions = (nodes, minDistance = 132, rounds = 32) => {
161
- const relaxRound = (currentNodes) => currentNodes.reduce((resolvedNodes, node) => {
162
- const resolved = resolvedNodes.reduce((state, previousNode) => {
163
- const [nextPrevious, nextCurrent] = separatePair(minDistance, previousNode, state.current);
164
- return {
165
- previous: [...state.previous, nextPrevious],
166
- current: nextCurrent
167
- };
168
- }, {
169
- previous: [],
170
- current: node
176
+ const neighborCellKeys = (x, y) => [
177
+ `${x - 1},${y - 1}`,
178
+ `${x},${y - 1}`,
179
+ `${x + 1},${y - 1}`,
180
+ `${x - 1},${y}`,
181
+ `${x},${y}`,
182
+ `${x + 1},${y}`,
183
+ `${x - 1},${y + 1}`,
184
+ `${x},${y + 1}`,
185
+ `${x + 1},${y + 1}`
186
+ ];
187
+ const resolveCollisionsSpatial = (nodes, minDistance) => {
188
+ const gridCellSize = minDistance * 1.05;
189
+ const grid = buildCollisionGrid(nodes, gridCellSize);
190
+ for (let index = 0; index < nodes.length; index += 1) {
191
+ const left = nodes[index];
192
+ const leftCellX = Math.floor(left.x / gridCellSize);
193
+ const leftCellY = Math.floor(left.y / gridCellSize);
194
+ neighborCellKeys(leftCellX, leftCellY).forEach((key) => {
195
+ const candidateIndices = grid.get(key) ?? [];
196
+ candidateIndices.forEach((candidateIndex) => {
197
+ if (candidateIndex <= index) {
198
+ return;
199
+ }
200
+ resolveCollisionPair(left, nodes[candidateIndex], minDistance);
201
+ });
171
202
  });
172
- return [...resolved.previous, resolved.current];
173
- }, []);
174
- return Array.from({ length: rounds }).reduce((currentNodes) => relaxRound(currentNodes), nodes);
203
+ }
204
+ };
205
+ const resolveCollisionsBrute = (nodes, minDistance) => {
206
+ for (let leftIndex = 0; leftIndex < nodes.length; leftIndex += 1) {
207
+ const left = nodes[leftIndex];
208
+ for (let rightIndex = leftIndex + 1; rightIndex < nodes.length; rightIndex += 1) {
209
+ resolveCollisionPair(left, nodes[rightIndex], minDistance);
210
+ }
211
+ }
212
+ };
213
+ const relaxCollisions = (nodes, minDistance = 132, rounds = 32) => {
214
+ if (nodes.length <= 1) {
215
+ return nodes;
216
+ }
217
+ const effectiveRounds = nodes.length > 1000
218
+ ? Math.min(rounds, 12)
219
+ : nodes.length > 500
220
+ ? Math.min(rounds, 20)
221
+ : Math.min(rounds, 26);
222
+ const layoutNodes = nodes.map((node) => ({
223
+ ...node,
224
+ x: Number.isFinite(node.x) ? node.x : 0,
225
+ y: Number.isFinite(node.y) ? node.y : 0
226
+ }));
227
+ for (let round = 0; round < effectiveRounds; round += 1) {
228
+ if (nodes.length <= 250) {
229
+ resolveCollisionsBrute(layoutNodes, minDistance);
230
+ }
231
+ else {
232
+ resolveCollisionsSpatial(layoutNodes, minDistance);
233
+ }
234
+ }
235
+ return layoutNodes;
175
236
  };
176
237
  export const createCauliflowerGraphLayout = (graph) => {
177
238
  const degrees = countDegrees(graph.edges);
@@ -6,8 +6,31 @@ const frontmatterPattern = /^---\n([\s\S]*?)\n---\n?/;
6
6
  const wikiLinkPattern = /\[\[([^\]|#]+)(?:#[^\]|]+)?(?:\|[^\]]+)?\]\]/g;
7
7
  const tagPattern = /(^|\s)#([A-Za-z0-9][A-Za-z0-9_-]*)/g;
8
8
  const headingPattern = /^#\s+(.+)$/m;
9
+ const priorityRanks = {
10
+ low: 0,
11
+ normal: 1,
12
+ high: 2,
13
+ critical: 3
14
+ };
15
+ const priorityBoosts = {
16
+ low: 0,
17
+ normal: 1,
18
+ high: 3,
19
+ critical: 6
20
+ };
21
+ const priorityPatterns = [
22
+ ['critical', /\b(?:priority|prioridade|importance|importancia|importância)\s*[:=]\s*(?:critical|critica|crítica|urgent|urgente|p0)\b/i],
23
+ ['critical', /#(?:critical|critica|crítica|urgent|urgente|p0)\b/i],
24
+ ['high', /\b(?:priority|prioridade|importance|importancia|importância)\s*[:=]\s*(?:high|alta|important|importante|p1)\b/i],
25
+ ['high', /#(?:high-priority|important|importante|p1)\b/i],
26
+ ['normal', /\b(?:priority|prioridade|importance|importancia|importância)\s*[:=]\s*(?:normal|medium|media|média|p2)\b/i],
27
+ ['normal', /#(?:normal-priority|medium-priority|p2)\b/i],
28
+ ['low', /\b(?:priority|prioridade|importance|importancia|importância)\s*[:=]\s*(?:low|baixa|p3)\b/i],
29
+ ['low', /#(?:low-priority|baixa-prioridade|p3)\b/i]
30
+ ];
9
31
  const normalizeTitle = (title) => title.trim().replace(/\.md$/i, '');
10
32
  const unique = (values) => Array.from(new Set(values.map((value) => value.trim()).filter(Boolean)));
33
+ const maxPriority = (left, right) => priorityRanks[left] >= priorityRanks[right] ? left : right;
11
34
  const parseFrontmatter = (content) => {
12
35
  const match = content.match(frontmatterPattern);
13
36
  if (!match) {
@@ -24,6 +47,57 @@ const parseFrontmatter = (content) => {
24
47
  };
25
48
  const stripFrontmatter = (content) => content.replace(frontmatterPattern, '');
26
49
  const stripFencedCodeBlocks = (content) => content.replace(/```[\s\S]*?```/g, '').replace(/~~~[\s\S]*?~~~/g, '');
50
+ const visibleMarkdownLines = (content) => content.split('\n').reduce((state, line) => {
51
+ const togglesFence = /^\s*(?:```|~~~)/.test(line);
52
+ const fenced = togglesFence ? !state.fenced : state.fenced;
53
+ state.lines.push({ content: line, fenced });
54
+ return {
55
+ lines: state.lines,
56
+ fenced
57
+ };
58
+ }, {
59
+ lines: [],
60
+ fenced: false
61
+ }).lines;
62
+ const linePriority = (line) => priorityPatterns.find(([, pattern]) => pattern.test(line))?.[0] ?? null;
63
+ const linkReferenceWeight = (line, priority) => {
64
+ const headingBoost = /^\s{0,3}#{1,6}\s+/.test(line) ? 2 : 0;
65
+ const taskBoost = /^\s*[-*]\s+\[[ x]\]/i.test(line) ? 1 : 0;
66
+ return 1 + (priority ? priorityBoosts[priority] : 0) + headingBoost + taskBoost;
67
+ };
68
+ export const extractWikiLinkReferences = (content) => visibleMarkdownLines(content)
69
+ .filter((line) => !line.fenced)
70
+ .flatMap((line) => {
71
+ const priority = linePriority(line.content);
72
+ const weight = linkReferenceWeight(line.content, priority);
73
+ return Array.from(line.content.matchAll(wikiLinkPattern), (match) => ({
74
+ title: normalizeTitle(match[1]),
75
+ weight,
76
+ priority
77
+ }));
78
+ });
79
+ const priorityFromWeight = (weight) => weight >= 8 ? 'critical' : weight >= 4 ? 'high' : 'normal';
80
+ export const extractWikiLinkWeights = (content) => {
81
+ const weights = extractWikiLinkReferences(content).reduce((state, reference) => {
82
+ const titleKey = reference.title.toLowerCase();
83
+ const current = state.get(titleKey);
84
+ const weight = (current?.weight ?? 0) + reference.weight;
85
+ const explicitPriority = reference.priority
86
+ ? maxPriority(current?.priority ?? reference.priority, reference.priority)
87
+ : current?.priority;
88
+ const derivedPriority = priorityFromWeight(weight);
89
+ const priority = explicitPriority === 'low' && weight === 1
90
+ ? 'low'
91
+ : maxPriority(explicitPriority ?? derivedPriority, derivedPriority);
92
+ state.set(titleKey, {
93
+ title: current?.title ?? reference.title,
94
+ weight,
95
+ priority
96
+ });
97
+ return state;
98
+ }, new Map());
99
+ return Array.from(weights.values());
100
+ };
27
101
  const extractTitle = (filePath, content, frontmatter) => {
28
102
  if (frontmatter.title) {
29
103
  return normalizeTitle(frontmatter.title);
@@ -34,8 +108,8 @@ const extractTitle = (filePath, content, frontmatter) => {
34
108
  }
35
109
  return normalizeTitle(basename(filePath));
36
110
  };
37
- export const extractWikiLinks = (content) => unique(Array.from(stripFencedCodeBlocks(content).matchAll(wikiLinkPattern), (match) => normalizeTitle(match[1])));
38
- export const extractTags = (content) => unique(Array.from(stripFencedCodeBlocks(content).matchAll(tagPattern), (match) => match[2]));
111
+ export const extractWikiLinks = (content) => unique(extractWikiLinkReferences(content).map((reference) => reference.title));
112
+ export const extractTags = (content) => unique(Array.from(stripFencedCodeBlocks(stripFrontmatter(content)).matchAll(tagPattern), (match) => match[2]));
39
113
  const normalizeChunkContent = (content) => content
40
114
  .split('\n')
41
115
  .map((line) => line.trim())
@@ -87,10 +161,13 @@ export const parseMarkdownDocument = (input) => {
87
161
  };
88
162
  export const createIndexedDocument = (document, titleToDocumentId, maxChunkCharacters = 1200) => {
89
163
  const chunks = splitIntoChunks(document.id, document.content, maxChunkCharacters);
164
+ const linkWeights = new Map(extractWikiLinkWeights(document.content).map((link) => [link.title.toLowerCase(), link]));
90
165
  const links = document.links.map((toTitle) => ({
91
166
  fromDocumentId: document.id,
92
167
  toTitle,
93
- toDocumentId: titleToDocumentId.get(toTitle.toLowerCase()) ?? null
168
+ toDocumentId: titleToDocumentId.get(toTitle.toLowerCase()) ?? null,
169
+ weight: linkWeights.get(toTitle.toLowerCase())?.weight ?? 1,
170
+ priority: linkWeights.get(toTitle.toLowerCase())?.priority ?? 'normal'
94
171
  }));
95
172
  return {
96
173
  document,
@@ -0,0 +1,171 @@
1
+ import { GetObjectCommand, ListObjectsV2Command, PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
2
+ import { chmod, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
3
+ import { createHash } from 'node:crypto';
4
+ import { dirname, isAbsolute, join, relative } from 'node:path';
5
+ import { posix } from 'node:path';
6
+ import { getBrainlinkHomePath } from './paths.js';
7
+ const directoryMode = 0o700;
8
+ const fileMode = 0o600;
9
+ const bucketScheme = 's3:';
10
+ const manifestPath = '.brainlink/bucket-manifest.json';
11
+ const excludedSegments = new Set(['.brainlink', '.git', 'node_modules', 'dist']);
12
+ export const isBucketVaultUri = (value) => value.trim().toLowerCase().startsWith('s3://');
13
+ const trimSlashes = (value) => value.replace(/^\/+|\/+$/g, '');
14
+ const normalizePrefix = (value) => trimSlashes(posix.normalize(trimSlashes(value))).replace(/^\.$/, '');
15
+ export const parseBucketVaultUri = (uri) => {
16
+ const parsed = new URL(uri);
17
+ if (parsed.protocol !== bucketScheme || !parsed.hostname) {
18
+ throw new Error(`Unsupported bucket vault URI: ${uri}. Use s3://bucket/prefix.`);
19
+ }
20
+ return {
21
+ uri: formatBucketVaultUri(parsed.hostname, normalizePrefix(decodeURIComponent(parsed.pathname))),
22
+ bucket: parsed.hostname,
23
+ prefix: normalizePrefix(decodeURIComponent(parsed.pathname))
24
+ };
25
+ };
26
+ export const formatBucketVaultUri = (bucket, prefix) => prefix ? `s3://${bucket}/${prefix}` : `s3://${bucket}`;
27
+ export const getBucketVaultCachePath = (uri) => {
28
+ const hash = createHash('sha256').update(parseBucketVaultUri(uri).uri).digest('hex').slice(0, 24);
29
+ return join(getBrainlinkHomePath(), 'bucket-cache', hash);
30
+ };
31
+ const ensureDirectory = async (path) => {
32
+ await mkdir(path, { recursive: true, mode: directoryMode });
33
+ await chmod(path, directoryMode);
34
+ };
35
+ const isPathInside = (parent, child) => {
36
+ const path = relative(parent, child);
37
+ return path === '' || (!path.startsWith('..') && !isAbsolute(path));
38
+ };
39
+ const toSafeRelativePath = (key) => {
40
+ const normalized = normalizePrefix(key);
41
+ if (!normalized || normalized.split('/').some((segment) => segment === '..' || excludedSegments.has(segment))) {
42
+ return null;
43
+ }
44
+ return normalized.endsWith('.md') ? normalized : null;
45
+ };
46
+ const toObjectKey = (reference, relativePath) => reference.prefix ? `${reference.prefix}/${relativePath}` : relativePath;
47
+ const toRelativeObjectKey = (reference, objectKey) => {
48
+ const relativePath = reference.prefix
49
+ ? objectKey.startsWith(`${reference.prefix}/`)
50
+ ? objectKey.slice(reference.prefix.length + 1)
51
+ : null
52
+ : objectKey;
53
+ return relativePath ? toSafeRelativePath(relativePath) : null;
54
+ };
55
+ const createBucketClient = () => new S3Client({
56
+ region: process.env.AWS_REGION ?? process.env.BRAINLINK_S3_REGION ?? 'us-east-1',
57
+ endpoint: process.env.BRAINLINK_S3_ENDPOINT ?? process.env.AWS_ENDPOINT_URL,
58
+ forcePathStyle: process.env.BRAINLINK_S3_FORCE_PATH_STYLE === '1'
59
+ });
60
+ const streamToString = async (body) => {
61
+ if (body && typeof body === 'object' && 'transformToString' in body && typeof body.transformToString === 'function') {
62
+ return body.transformToString();
63
+ }
64
+ throw new Error('Unsupported S3 object body.');
65
+ };
66
+ const readManifest = async (cachePath) => {
67
+ try {
68
+ return JSON.parse(await readFile(join(cachePath, manifestPath), 'utf8'));
69
+ }
70
+ catch (error) {
71
+ if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
72
+ return {
73
+ uri: '',
74
+ keys: []
75
+ };
76
+ }
77
+ throw error;
78
+ }
79
+ };
80
+ const writeManifest = async (cachePath, manifest) => {
81
+ const path = join(cachePath, manifestPath);
82
+ await ensureDirectory(dirname(path));
83
+ await writeFile(path, `${JSON.stringify(manifest, null, 2)}\n`, { encoding: 'utf8', mode: fileMode });
84
+ await chmod(path, fileMode);
85
+ };
86
+ const listBucketMarkdownKeys = async (client, reference) => {
87
+ const keys = [];
88
+ let continuationToken;
89
+ do {
90
+ const result = await client.send(new ListObjectsV2Command({
91
+ Bucket: reference.bucket,
92
+ Prefix: reference.prefix ? `${reference.prefix}/` : undefined,
93
+ ContinuationToken: continuationToken
94
+ }));
95
+ keys.push(...(result.Contents ?? []).flatMap((object) => (object.Key ? [object.Key] : [])));
96
+ continuationToken = result.NextContinuationToken;
97
+ } while (continuationToken);
98
+ return keys.flatMap((key) => {
99
+ const relativePath = toRelativeObjectKey(reference, key);
100
+ return relativePath ? [relativePath] : [];
101
+ });
102
+ };
103
+ const removeStaleCachedFiles = async (cachePath, previousKeys, currentKeys) => {
104
+ await Promise.all(previousKeys
105
+ .filter((key) => !currentKeys.has(key))
106
+ .map(async (key) => {
107
+ const absolutePath = join(cachePath, key);
108
+ if (isPathInside(cachePath, absolutePath)) {
109
+ await rm(absolutePath, { force: true });
110
+ }
111
+ }));
112
+ };
113
+ const downloadMarkdownFiles = async (client, reference, cachePath, keys) => {
114
+ await Promise.all(keys.map(async (key) => {
115
+ const absolutePath = join(cachePath, key);
116
+ if (!isPathInside(cachePath, absolutePath)) {
117
+ throw new Error(`Refusing to cache bucket object outside vault cache: ${key}`);
118
+ }
119
+ const result = await client.send(new GetObjectCommand({
120
+ Bucket: reference.bucket,
121
+ Key: toObjectKey(reference, key)
122
+ }));
123
+ await ensureDirectory(dirname(absolutePath));
124
+ await writeFile(absolutePath, await streamToString(result.Body), { encoding: 'utf8', mode: fileMode });
125
+ await chmod(absolutePath, fileMode);
126
+ }));
127
+ };
128
+ export const syncBucketVaultToCache = async (uri) => {
129
+ const reference = parseBucketVaultUri(uri);
130
+ const cachePath = getBucketVaultCachePath(reference.uri);
131
+ const client = createBucketClient();
132
+ const previousManifest = await readManifest(cachePath);
133
+ const keys = await listBucketMarkdownKeys(client, reference);
134
+ const currentKeys = new Set(keys);
135
+ await ensureDirectory(join(cachePath, '.brainlink'));
136
+ await removeStaleCachedFiles(cachePath, previousManifest.uri === reference.uri ? previousManifest.keys : [], currentKeys);
137
+ await downloadMarkdownFiles(client, reference, cachePath, keys);
138
+ await writeManifest(cachePath, {
139
+ uri: reference.uri,
140
+ keys
141
+ });
142
+ return cachePath;
143
+ };
144
+ export const writeBucketMarkdownFile = async (uri, filename, content) => {
145
+ const reference = parseBucketVaultUri(uri);
146
+ const cachePath = getBucketVaultCachePath(reference.uri);
147
+ const relativePath = toSafeRelativePath(filename.endsWith('.md') ? filename : `${filename}.md`);
148
+ if (!relativePath) {
149
+ throw new Error(`Invalid bucket Markdown path: ${filename}`);
150
+ }
151
+ const absolutePath = join(cachePath, relativePath);
152
+ if (!isPathInside(cachePath, absolutePath)) {
153
+ throw new Error(`Refusing to write outside bucket cache: ${absolutePath}`);
154
+ }
155
+ await ensureDirectory(join(cachePath, '.brainlink'));
156
+ await ensureDirectory(dirname(absolutePath));
157
+ await writeFile(absolutePath, content, { encoding: 'utf8', mode: fileMode });
158
+ await chmod(absolutePath, fileMode);
159
+ await createBucketClient().send(new PutObjectCommand({
160
+ Bucket: reference.bucket,
161
+ Key: toObjectKey(reference, relativePath),
162
+ Body: content,
163
+ ContentType: 'text/markdown; charset=utf-8'
164
+ }));
165
+ const manifest = await readManifest(cachePath);
166
+ await writeManifest(cachePath, {
167
+ uri: reference.uri,
168
+ keys: Array.from(new Set([...manifest.keys, relativePath])).sort()
169
+ });
170
+ return `${reference.uri}/${relativePath}`;
171
+ };
@@ -1,11 +1,13 @@
1
1
  import { readFile } from 'node:fs/promises';
2
2
  import { resolve } from 'node:path';
3
+ import { sanitizeAgentId } from '../domain/agents.js';
3
4
  import { getDefaultVaultPath } from './paths.js';
4
5
  export const defaultBrainlinkConfig = {
5
6
  vault: getDefaultVaultPath(),
6
7
  host: '127.0.0.1',
7
8
  port: 4321,
8
9
  allowedVaults: [],
10
+ defaultAgent: undefined,
9
11
  defaultSearchLimit: 10,
10
12
  defaultContextTokens: 2000,
11
13
  embeddingProvider: 'local',
@@ -40,6 +42,9 @@ const sanitizeConfig = (value) => ({
40
42
  ...defaultBrainlinkConfig,
41
43
  ...value,
42
44
  port: typeof value.port === 'number' && value.port > 0 ? value.port : defaultBrainlinkConfig.port,
45
+ defaultAgent: typeof value.defaultAgent === 'string' && value.defaultAgent.trim().length > 0
46
+ ? sanitizeAgentId(value.defaultAgent)
47
+ : defaultBrainlinkConfig.defaultAgent,
43
48
  defaultSearchLimit: typeof value.defaultSearchLimit === 'number' && value.defaultSearchLimit > 0
44
49
  ? value.defaultSearchLimit
45
50
  : defaultBrainlinkConfig.defaultSearchLimit,
@@ -1,6 +1,7 @@
1
1
  import { chmod, mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises';
2
2
  import { dirname, extname, isAbsolute, join, relative, resolve } from 'node:path';
3
3
  import { resolvePath } from './paths.js';
4
+ import { getBucketVaultCachePath, isBucketVaultUri, parseBucketVaultUri, syncBucketVaultToCache, writeBucketMarkdownFile } from './bucket-vault.js';
4
5
  const excludedDirectories = new Set(['.brainlink', '.git', 'node_modules', 'dist']);
5
6
  const directoryMode = 0o700;
6
7
  const fileMode = 0o600;
@@ -15,30 +16,44 @@ const walkMarkdownFiles = async (directory) => {
15
16
  }));
16
17
  return nested.flat();
17
18
  };
18
- export const resolveVaultPath = (vaultPath) => resolvePath(vaultPath);
19
+ export const resolveVaultPath = (vaultPath) => isBucketVaultUri(vaultPath) ? getBucketVaultCachePath(vaultPath) : resolvePath(vaultPath);
20
+ export const isBucketVaultPath = (vaultPath) => isBucketVaultUri(vaultPath);
19
21
  const isPathInside = (parent, child) => {
20
22
  const path = relative(parent, child);
21
23
  return path === '' || (!path.startsWith('..') && !isAbsolute(path));
22
24
  };
25
+ const isBucketPrefixInside = (parent, child) => parent === '' || child === parent || child.startsWith(`${parent}/`);
23
26
  const secureDirectory = async (path) => {
24
27
  await mkdir(path, { recursive: true, mode: directoryMode });
25
28
  await chmod(path, directoryMode);
26
29
  };
27
30
  export const assertVaultAllowed = (vaultPath, allowedVaults) => {
31
+ if (isBucketVaultUri(vaultPath)) {
32
+ const vault = parseBucketVaultUri(vaultPath);
33
+ const allowed = allowedVaults.filter(isBucketVaultUri).map(parseBucketVaultUri);
34
+ if (allowedVaults.length > 0 &&
35
+ !allowed.some((allowedVault) => vault.bucket === allowedVault.bucket && isBucketPrefixInside(allowedVault.prefix, vault.prefix))) {
36
+ throw new Error(`Vault path is not allowed: ${vault.uri}. Configure BRAINLINK_ALLOWED_VAULTS or allowedVaults.`);
37
+ }
38
+ return vault.uri;
39
+ }
28
40
  const absoluteVaultPath = resolveVaultPath(vaultPath);
29
- const allowed = allowedVaults.map(resolveVaultPath);
41
+ const allowed = allowedVaults.filter((allowedVault) => !isBucketVaultUri(allowedVault)).map(resolveVaultPath);
30
42
  if (allowed.length > 0 && !allowed.some((allowedPath) => isPathInside(allowedPath, absoluteVaultPath))) {
31
43
  throw new Error(`Vault path is not allowed: ${absoluteVaultPath}. Configure BRAINLINK_ALLOWED_VAULTS or allowedVaults.`);
32
44
  }
33
45
  return absoluteVaultPath;
34
46
  };
35
47
  export const ensureVault = async (vaultPath) => {
48
+ if (isBucketVaultUri(vaultPath)) {
49
+ return syncBucketVaultToCache(vaultPath);
50
+ }
36
51
  const absoluteVaultPath = resolveVaultPath(vaultPath);
37
52
  await secureDirectory(join(absoluteVaultPath, '.brainlink'));
38
53
  return absoluteVaultPath;
39
54
  };
40
55
  export const readMarkdownFiles = async (vaultPath) => {
41
- const absoluteVaultPath = resolveVaultPath(vaultPath);
56
+ const absoluteVaultPath = await ensureVault(vaultPath);
42
57
  const paths = await walkMarkdownFiles(absoluteVaultPath);
43
58
  return Promise.all(paths.map(async (absolutePath) => {
44
59
  const [content, stats] = await Promise.all([readFile(absolutePath, 'utf8'), stat(absolutePath)]);
@@ -51,6 +66,9 @@ export const readMarkdownFiles = async (vaultPath) => {
51
66
  }));
52
67
  };
53
68
  export const writeMarkdownFile = async (vaultPath, filename, content) => {
69
+ if (isBucketVaultUri(vaultPath)) {
70
+ return writeBucketMarkdownFile(vaultPath, filename, content);
71
+ }
54
72
  const absoluteVaultPath = await ensureVault(vaultPath);
55
73
  const absolutePath = resolve(absoluteVaultPath, filename.endsWith('.md') ? filename : `${filename}.md`);
56
74
  if (!isPathInside(absoluteVaultPath, absolutePath)) {