@andespindola/brainlink 0.1.0-alpha.9 → 0.1.0-beta.1
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 +98 -18
- 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 +20 -7
- 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 +7 -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 +18 -3
- package/dist/mcp/tools.js +145 -43
- package/docs/AGENT_USAGE.md +72 -3
- 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
|
@@ -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,
|
|
24
|
-
print(options.json, { query, agent:
|
|
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,
|
|
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,
|
|
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),
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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`);
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
1
2
|
import { addNote } from '../../application/add-note.js';
|
|
2
3
|
import { indexVault } from '../../application/index-vault.js';
|
|
3
4
|
import { startServer } from '../../application/start-server.js';
|
|
@@ -6,6 +7,15 @@ import { doctorVault } from '../../application/analyze-vault.js';
|
|
|
6
7
|
import { loadBrainlinkConfig } from '../../infrastructure/config.js';
|
|
7
8
|
import { assertVaultAllowed, ensureVault } from '../../infrastructure/file-system-vault.js';
|
|
8
9
|
import { parsePositiveInteger, print, resolveOptions } from '../runtime.js';
|
|
10
|
+
const resolveAddContent = (options) => {
|
|
11
|
+
if (options.content != null && options.content.trim().length > 0) {
|
|
12
|
+
return options.content;
|
|
13
|
+
}
|
|
14
|
+
if (options.contentFile == null || options.contentFile.trim().length === 0) {
|
|
15
|
+
throw new Error('Use --content or --content-file to provide note content.');
|
|
16
|
+
}
|
|
17
|
+
return readFileSync(options.contentFile, 'utf8');
|
|
18
|
+
};
|
|
9
19
|
export const registerWriteCommands = (program) => {
|
|
10
20
|
program
|
|
11
21
|
.command('init')
|
|
@@ -20,18 +30,23 @@ export const registerWriteCommands = (program) => {
|
|
|
20
30
|
program
|
|
21
31
|
.command('add')
|
|
22
32
|
.argument('<title>', 'note title')
|
|
23
|
-
.
|
|
33
|
+
.option('-c, --content <content>', 'markdown content')
|
|
34
|
+
.option('-f, --content-file <contentFile>', 'read markdown content from a file')
|
|
24
35
|
.option('-v, --vault <vault>', 'vault directory')
|
|
25
|
-
.option('-a, --agent <agent>', 'agent memory namespace'
|
|
36
|
+
.option('-a, --agent <agent>', 'agent memory namespace')
|
|
26
37
|
.option('--allow-sensitive', 'allow writing content that looks like a secret')
|
|
38
|
+
.option('--no-auto-index', 'skip reindexing after add')
|
|
27
39
|
.option('--json', 'print machine-readable JSON')
|
|
28
40
|
.description('add a markdown note to the vault')
|
|
29
41
|
.action(async (title, options) => {
|
|
30
42
|
const resolved = await resolveOptions(options);
|
|
31
|
-
const
|
|
43
|
+
const content = resolveAddContent(options);
|
|
44
|
+
const notePath = await addNote(resolved.vault, title, content, resolved.agent, {
|
|
32
45
|
allowSensitive: Boolean(options.allowSensitive)
|
|
33
46
|
});
|
|
34
|
-
|
|
47
|
+
const shouldAutoIndex = options.autoIndex !== false && resolved.config.autoIndexOnWrite;
|
|
48
|
+
const index = shouldAutoIndex ? await indexVault(resolved.vault) : undefined;
|
|
49
|
+
print(options.json, { title, agent: resolved.agent ?? 'shared', path: notePath, ...(index ? { index } : {}) }, () => `Created note at ${notePath}`);
|
|
35
50
|
});
|
|
36
51
|
program
|
|
37
52
|
.command('index')
|
|
@@ -89,7 +104,6 @@ export const registerWriteCommands = (program) => {
|
|
|
89
104
|
.option('-p, --port <port>', 'server port', '4321')
|
|
90
105
|
.option('--no-index', 'skip indexing before starting the server')
|
|
91
106
|
.option('-w, --watch', 'watch markdown files and reindex on changes')
|
|
92
|
-
.option('--allow-public', 'allow binding the server to a non-loopback host')
|
|
93
107
|
.option('--json', 'print machine-readable JSON')
|
|
94
108
|
.description('start a local web UI for the knowledge graph')
|
|
95
109
|
.action(async (options) => {
|
|
@@ -99,8 +113,7 @@ export const registerWriteCommands = (program) => {
|
|
|
99
113
|
host: options.host ?? resolved.config.host,
|
|
100
114
|
port: parsePositiveInteger(options.port ?? String(resolved.config.port), resolved.config.port),
|
|
101
115
|
shouldIndex: options.index,
|
|
102
|
-
shouldWatch: Boolean(options.watch)
|
|
103
|
-
allowPublic: Boolean(options.allowPublic)
|
|
116
|
+
shouldWatch: Boolean(options.watch)
|
|
104
117
|
});
|
|
105
118
|
print(options.json, { url: server.url, watch: Boolean(options.watch), readonly: true }, () => `Brainlink graph server running at ${server.url}`);
|
|
106
119
|
});
|
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,14 @@
|
|
|
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,
|
|
11
|
+
autoIndexOnWrite: true,
|
|
9
12
|
defaultSearchLimit: 10,
|
|
10
13
|
defaultContextTokens: 2000,
|
|
11
14
|
embeddingProvider: 'local',
|
|
@@ -40,6 +43,10 @@ const sanitizeConfig = (value) => ({
|
|
|
40
43
|
...defaultBrainlinkConfig,
|
|
41
44
|
...value,
|
|
42
45
|
port: typeof value.port === 'number' && value.port > 0 ? value.port : defaultBrainlinkConfig.port,
|
|
46
|
+
defaultAgent: typeof value.defaultAgent === 'string' && value.defaultAgent.trim().length > 0
|
|
47
|
+
? sanitizeAgentId(value.defaultAgent)
|
|
48
|
+
: defaultBrainlinkConfig.defaultAgent,
|
|
49
|
+
autoIndexOnWrite: typeof value.autoIndexOnWrite === 'boolean' ? value.autoIndexOnWrite : defaultBrainlinkConfig.autoIndexOnWrite,
|
|
43
50
|
defaultSearchLimit: typeof value.defaultSearchLimit === 'number' && value.defaultSearchLimit > 0
|
|
44
51
|
? value.defaultSearchLimit
|
|
45
52
|
: defaultBrainlinkConfig.defaultSearchLimit,
|