@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.
- package/AGENTS.md +2 -0
- package/README.md +83 -7
- package/SECURITY.md +14 -2
- package/dist/application/frontend/client-js.js +80 -18
- package/dist/application/get-graph-layout.js +26 -1
- package/dist/application/index-vault.js +11 -3
- package/dist/application/server/host-security.js +3 -3
- package/dist/application/server/routes.js +45 -1
- package/dist/application/start-server.js +2 -2
- package/dist/application/watch-vault.js +4 -1
- package/dist/cli/commands/read-commands.js +10 -10
- package/dist/cli/commands/write-commands.js +4 -6
- package/dist/cli/runtime.js +2 -1
- package/dist/domain/agents.js +2 -1
- package/dist/domain/graph-layout.js +90 -29
- package/dist/domain/markdown.js +80 -3
- package/dist/infrastructure/bucket-vault.js +171 -0
- package/dist/infrastructure/config.js +5 -0
- package/dist/infrastructure/file-system-vault.js +21 -3
- package/dist/infrastructure/sqlite/document-writer.js +4 -3
- package/dist/infrastructure/sqlite/graph-reader.js +22 -10
- package/dist/infrastructure/sqlite/schema.js +12 -1
- package/dist/infrastructure/sqlite-index.js +6 -1
- package/dist/mcp/server.js +13 -3
- package/dist/mcp/tools.js +100 -42
- package/docs/AGENT_USAGE.md +64 -1
- package/docs/ARCHITECTURE.md +22 -1
- package/docs/RELEASE.md +1 -1
- package/docs/templates/agent-namespace-bootstrap.md +27 -0
- package/docs/templates/agent-note-template.md +31 -0
- package/package.json +5 -2
|
@@ -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'
|
|
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,
|
|
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:
|
|
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
|
});
|
package/dist/cli/runtime.js
CHANGED
|
@@ -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) => {
|
package/dist/domain/agents.js
CHANGED
|
@@ -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
|
|
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
|
|
38
|
-
|
|
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
|
|
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
|
|
151
|
+
return;
|
|
143
152
|
}
|
|
144
153
|
const push = (minDistance - distance) / 2;
|
|
145
154
|
const ux = dx / distance;
|
|
146
155
|
const uy = dy / distance;
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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);
|
package/dist/domain/markdown.js
CHANGED
|
@@ -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(
|
|
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 =
|
|
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
|
});
|