@andespindola/brainlink 0.1.0-alpha.9 → 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.
@@ -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)) {
@@ -1,4 +1,5 @@
1
1
  import { createEmbeddingBuckets } from '../../domain/embeddings.js';
2
+ const toTitleKey = (title) => title.toLowerCase();
2
3
  export const createIndexWriter = (database) => ({
3
4
  reset: () => {
4
5
  database.exec(`
@@ -27,8 +28,8 @@ export const createIndexWriter = (database) => ({
27
28
  VALUES (?, ?)
28
29
  `);
29
30
  const insertLink = database.prepare(`
30
- INSERT INTO links (from_document_id, to_title, to_document_id)
31
- VALUES (?, ?, ?)
31
+ INSERT INTO links (from_document_id, to_title, to_title_key, to_document_id, weight, priority)
32
+ VALUES (?, ?, ?, ?, ?, ?)
32
33
  `);
33
34
  const transaction = database.transaction(() => {
34
35
  documents.forEach(({ document, chunks, links }) => {
@@ -41,7 +42,7 @@ export const createIndexWriter = (database) => ({
41
42
  });
42
43
  documents.forEach(({ links }) => {
43
44
  links.forEach((link) => {
44
- insertLink.run(link.fromDocumentId, link.toTitle, link.toDocumentId);
45
+ insertLink.run(link.fromDocumentId, link.toTitle, toTitleKey(link.toTitle), link.toDocumentId, link.weight, link.priority);
45
46
  });
46
47
  });
47
48
  });