@andespindola/brainlink 0.1.0-alpha.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.
Files changed (52) hide show
  1. package/AGENTS.md +142 -0
  2. package/CHANGELOG.md +13 -0
  3. package/CONTRIBUTING.md +28 -0
  4. package/LICENSE +23 -0
  5. package/README.md +715 -0
  6. package/SECURITY.md +35 -0
  7. package/dist/application/add-note.js +30 -0
  8. package/dist/application/analyze-vault.js +28 -0
  9. package/dist/application/build-context.js +15 -0
  10. package/dist/application/frontend/client-css.js +294 -0
  11. package/dist/application/frontend/client-html.js +66 -0
  12. package/dist/application/frontend/client-js.js +416 -0
  13. package/dist/application/get-graph-layout.js +3 -0
  14. package/dist/application/get-graph.js +12 -0
  15. package/dist/application/index-vault.js +67 -0
  16. package/dist/application/list-agents.js +12 -0
  17. package/dist/application/list-links.js +22 -0
  18. package/dist/application/search-knowledge.js +19 -0
  19. package/dist/application/server/host-security.js +6 -0
  20. package/dist/application/server/http.js +13 -0
  21. package/dist/application/server/routes.js +88 -0
  22. package/dist/application/server/types.js +1 -0
  23. package/dist/application/start-server.js +54 -0
  24. package/dist/application/watch-vault.js +36 -0
  25. package/dist/benchmarks/large-vault.js +88 -0
  26. package/dist/cli/commands/read-commands.js +149 -0
  27. package/dist/cli/commands/write-commands.js +107 -0
  28. package/dist/cli/main.js +21 -0
  29. package/dist/cli/runtime.js +18 -0
  30. package/dist/cli/types.js +1 -0
  31. package/dist/domain/agents.js +11 -0
  32. package/dist/domain/context.js +44 -0
  33. package/dist/domain/embeddings.js +117 -0
  34. package/dist/domain/graph-analysis.js +48 -0
  35. package/dist/domain/graph-layout.js +187 -0
  36. package/dist/domain/ids.js +2 -0
  37. package/dist/domain/markdown.js +100 -0
  38. package/dist/domain/note-safety.js +54 -0
  39. package/dist/domain/tokens.js +1 -0
  40. package/dist/domain/types.js +1 -0
  41. package/dist/infrastructure/config.js +60 -0
  42. package/dist/infrastructure/file-system-vault.js +62 -0
  43. package/dist/infrastructure/sqlite/document-writer.js +50 -0
  44. package/dist/infrastructure/sqlite/graph-reader.js +108 -0
  45. package/dist/infrastructure/sqlite/schema.js +87 -0
  46. package/dist/infrastructure/sqlite/search-reader.js +156 -0
  47. package/dist/infrastructure/sqlite/types.js +1 -0
  48. package/dist/infrastructure/sqlite-index.js +20 -0
  49. package/docs/AGENT_USAGE.md +477 -0
  50. package/docs/ARCHITECTURE.md +286 -0
  51. package/docs/RELEASE.md +67 -0
  52. package/package.json +67 -0
@@ -0,0 +1,48 @@
1
+ export const getBrokenLinks = (graph) => {
2
+ const nodeById = new Map(graph.nodes.map((node) => [node.id, node]));
3
+ return graph.edges
4
+ .filter((edge) => edge.target === null)
5
+ .map((edge) => {
6
+ const source = nodeById.get(edge.source);
7
+ return {
8
+ fromTitle: source?.title ?? edge.source,
9
+ fromPath: source?.path ?? '',
10
+ toTitle: edge.targetTitle
11
+ };
12
+ });
13
+ };
14
+ export const getOrphanNodes = (graph) => {
15
+ const linkedNodeIds = new Set(graph.edges.flatMap((edge) => (edge.target ? [edge.source, edge.target] : [edge.source])));
16
+ return graph.nodes
17
+ .filter((node) => !linkedNodeIds.has(node.id))
18
+ .map((node) => ({
19
+ title: node.title,
20
+ path: node.path,
21
+ tags: node.tags
22
+ }));
23
+ };
24
+ export const getVaultStats = (graph) => {
25
+ const brokenLinks = getBrokenLinks(graph);
26
+ const orphans = getOrphanNodes(graph);
27
+ const tags = Array.from(new Set(graph.nodes.flatMap((node) => node.tags))).sort((left, right) => left.localeCompare(right));
28
+ return {
29
+ documentCount: graph.nodes.length,
30
+ linkCount: graph.edges.length,
31
+ resolvedLinkCount: graph.edges.filter((edge) => edge.target !== null).length,
32
+ brokenLinkCount: brokenLinks.length,
33
+ orphanCount: orphans.length,
34
+ tagCount: tags.length,
35
+ tags
36
+ };
37
+ };
38
+ export const validateGraph = (graph) => {
39
+ const brokenLinks = getBrokenLinks(graph);
40
+ const orphans = getOrphanNodes(graph);
41
+ const stats = getVaultStats(graph);
42
+ return {
43
+ ok: brokenLinks.length === 0,
44
+ stats,
45
+ brokenLinks,
46
+ orphans
47
+ };
48
+ };
@@ -0,0 +1,187 @@
1
+ const groupLabels = {
2
+ '00-maps': 'maps',
3
+ '10-agent-memory': 'agent-memory',
4
+ '20-concepts': 'concepts',
5
+ '30-architecture': 'architecture',
6
+ '40-agents': 'agents',
7
+ '50-retrieval': 'retrieval',
8
+ '60-operations': 'operations',
9
+ '70-evaluation': 'evaluation',
10
+ '80-sessions': 'sessions',
11
+ '90-security': 'security',
12
+ root: 'root'
13
+ };
14
+ const segmentAngles = {
15
+ Brainlink: -1.58,
16
+ Architecture: -0.74,
17
+ Agents: -0.05,
18
+ Retrieval: 0.68,
19
+ Operations: 1.34,
20
+ Evaluation: 2.08,
21
+ Security: 2.82
22
+ };
23
+ const hashText = (value) => Array.from(value).reduce((hash, char) => ((hash << 5) - hash + char.charCodeAt(0)) | 0, 0);
24
+ const jitter = (value, range) => {
25
+ const normalized = Math.abs(hashText(value) % 1000) / 1000;
26
+ return (normalized - 0.5) * range;
27
+ };
28
+ const pathSegments = (path) => path.split('/').filter(Boolean);
29
+ const groupKey = (node) => {
30
+ const segments = pathSegments(node.path);
31
+ if (segments[0] === 'agents') {
32
+ return segments[2] ?? 'root';
33
+ }
34
+ return segments[0] ?? 'root';
35
+ };
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());
39
+ const uniqueIds = (ids) => Array.from(new Set(ids));
40
+ const createAdjacency = (nodes, edges) => {
41
+ const nodeIds = new Set(nodes.map((node) => node.id));
42
+ const emptyAdjacency = new Map(nodes.map((node) => [node.id, []]));
43
+ return edges.reduce((adjacency, edge) => {
44
+ if (!edge.target || !nodeIds.has(edge.source) || !nodeIds.has(edge.target)) {
45
+ return adjacency;
46
+ }
47
+ return new Map([
48
+ ...adjacency,
49
+ [edge.source, uniqueIds([...(adjacency.get(edge.source) ?? []), edge.target])],
50
+ [edge.target, uniqueIds([...(adjacency.get(edge.target) ?? []), edge.source])]
51
+ ]);
52
+ }, emptyAdjacency);
53
+ };
54
+ const byTitle = (left, right) => left.title.localeCompare(right.title);
55
+ const byDegreeThenTitle = (degrees) => (left, right) => {
56
+ const degreeDelta = (degrees.get(right.id) ?? 0) - (degrees.get(left.id) ?? 0);
57
+ return degreeDelta === 0 ? byTitle(left, right) : degreeDelta;
58
+ };
59
+ const naturalSegmentSeed = (node) => groupKey(node) === '00-maps' || /\b(moc|map)\b/i.test(node.title);
60
+ const segmentName = (node) => node.title.replace(/^MOC\s+/i, '').replace(/\s+Memory Map$/i, '').trim() || node.title;
61
+ const collectComponent = (adjacency, startId, visited) => {
62
+ const queue = [startId];
63
+ const component = [];
64
+ visited.add(startId);
65
+ for (let index = 0; index < queue.length; index += 1) {
66
+ const id = queue[index];
67
+ component.push(id);
68
+ (adjacency.get(id) ?? []).forEach((nextId) => {
69
+ if (!visited.has(nextId)) {
70
+ visited.add(nextId);
71
+ queue.push(nextId);
72
+ }
73
+ });
74
+ }
75
+ return component;
76
+ };
77
+ const connectedComponents = (nodes, adjacency) => {
78
+ const visited = new Set();
79
+ return [...nodes].sort(byTitle).reduce((components, node) => (visited.has(node.id) ? components : [...components, collectComponent(adjacency, node.id, visited)]), []);
80
+ };
81
+ const selectSegmentSeeds = (nodes, edges, degrees) => {
82
+ const adjacency = createAdjacency(nodes, edges);
83
+ const nodeById = new Map(nodes.map((node) => [node.id, node]));
84
+ return connectedComponents(nodes, adjacency).flatMap((component) => {
85
+ const componentNodes = component.map((id) => nodeById.get(id)).filter((node) => Boolean(node));
86
+ const naturalSeeds = componentNodes.filter(naturalSegmentSeed).sort(byDegreeThenTitle(degrees));
87
+ return naturalSeeds.length > 0 ? naturalSeeds : componentNodes.sort(byDegreeThenTitle(degrees)).slice(0, 1);
88
+ });
89
+ };
90
+ const assignSegments = (nodes, edges, degrees) => {
91
+ const adjacency = createAdjacency(nodes, edges);
92
+ const seeds = selectSegmentSeeds(nodes, edges, degrees);
93
+ const assignments = new Map(seeds.map((seed) => [seed.id, segmentName(seed)]));
94
+ const queue = seeds.map((seed) => seed.id);
95
+ for (let index = 0; index < queue.length; index += 1) {
96
+ const id = queue[index];
97
+ const segment = assignments.get(id);
98
+ if (!segment) {
99
+ continue;
100
+ }
101
+ ;
102
+ (adjacency.get(id) ?? []).forEach((nextId) => {
103
+ if (!assignments.has(nextId)) {
104
+ assignments.set(nextId, segment);
105
+ queue.push(nextId);
106
+ }
107
+ });
108
+ }
109
+ return new Map(nodes.map((node) => [node.id, assignments.get(node.id) ?? groupLabel(groupKey(node))]));
110
+ };
111
+ const groupNodesBySegment = (nodes, segments) => nodes.reduce((groups, node) => {
112
+ const segment = segments.get(node.id) ?? groupLabel(groupKey(node));
113
+ return new Map([...groups, [segment, [...(groups.get(segment) ?? []), node]]]);
114
+ }, new Map());
115
+ const segmentAngle = (segment, index, count) => segmentAngles[segment] ?? (Math.PI * 2 * index) / Math.max(count, 1) - Math.PI / 2;
116
+ const createSegmentNodes = (segments, degrees, segmentCount) => ([segment, nodes], segmentIndex) => {
117
+ const sortedNodes = [...nodes].sort(byDegreeThenTitle(degrees));
118
+ const angle = segmentAngle(segment, segmentIndex, segmentCount);
119
+ const baseRadius = segmentCount === 1 ? 0 : 340 + Math.min(sortedNodes.length, 22) * 10;
120
+ const centerX = Math.cos(angle) * baseRadius;
121
+ const centerY = Math.sin(angle) * (baseRadius * 0.78);
122
+ const petalSpread = 40 + Math.sqrt(sortedNodes.length) * 14;
123
+ return sortedNodes.map((node, index) => {
124
+ const localAngle = index * 2.399963 + jitter(node.title, 0.42);
125
+ const localRadius = Math.sqrt(index + 1) * petalSpread;
126
+ const hubPull = segmentCount === 1 ? 0 : Math.min(degrees.get(node.id) ?? 0, 12) * 12;
127
+ return {
128
+ ...node,
129
+ group: groupLabel(groupKey(node)),
130
+ segment: segments.get(node.id) ?? segment,
131
+ x: centerX + Math.cos(localAngle) * localRadius - Math.cos(angle) * hubPull + jitter(node.id, 24),
132
+ y: centerY + Math.sin(localAngle) * localRadius * 0.78 - Math.sin(angle) * hubPull + jitter(node.path, 24)
133
+ };
134
+ });
135
+ };
136
+ const distanceBetween = (left, right) => Math.hypot(right.x - left.x, right.y - left.y);
137
+ const separatePair = (minDistance, left, right) => {
138
+ const dx = right.x - left.x;
139
+ const dy = right.y - left.y;
140
+ const distance = Math.max(Math.hypot(dx, dy), 0.001);
141
+ if (distance >= minDistance) {
142
+ return [left, right];
143
+ }
144
+ const push = (minDistance - distance) / 2;
145
+ const ux = dx / distance;
146
+ 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
157
+ }
158
+ ];
159
+ };
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
171
+ });
172
+ return [...resolved.previous, resolved.current];
173
+ }, []);
174
+ return Array.from({ length: rounds }).reduce((currentNodes) => relaxRound(currentNodes), nodes);
175
+ };
176
+ export const createCauliflowerGraphLayout = (graph) => {
177
+ const degrees = countDegrees(graph.edges);
178
+ const segments = assignSegments(graph.nodes, graph.edges, degrees);
179
+ const segmentGroups = Array.from(groupNodesBySegment(graph.nodes, segments).entries())
180
+ .sort(([left], [right]) => left.localeCompare(right));
181
+ const nodes = relaxCollisions(segmentGroups.flatMap(createSegmentNodes(segments, degrees, segmentGroups.length)));
182
+ return {
183
+ nodes,
184
+ edges: graph.edges
185
+ };
186
+ };
187
+ export const getMinimumLayoutDistance = (nodes) => nodes.reduce((minimumDistance, leftNode, leftIndex) => nodes.slice(leftIndex + 1).reduce((innerMinimum, rightNode) => Math.min(innerMinimum, distanceBetween(leftNode, rightNode)), minimumDistance), Number.POSITIVE_INFINITY);
@@ -0,0 +1,2 @@
1
+ import { createHash } from 'node:crypto';
2
+ export const createStableId = (input) => createHash('sha256').update(input).digest('hex').slice(0, 24);
@@ -0,0 +1,100 @@
1
+ import { basename, relative } from 'node:path';
2
+ import { resolveAgentIdFromPath, sanitizeAgentId } from './agents.js';
3
+ import { createStableId } from './ids.js';
4
+ import { estimateTokenCount } from './tokens.js';
5
+ const frontmatterPattern = /^---\n([\s\S]*?)\n---\n?/;
6
+ const wikiLinkPattern = /\[\[([^\]|#]+)(?:#[^\]|]+)?(?:\|[^\]]+)?\]\]/g;
7
+ const tagPattern = /(^|\s)#([A-Za-z0-9][A-Za-z0-9_-]*)/g;
8
+ const headingPattern = /^#\s+(.+)$/m;
9
+ const normalizeTitle = (title) => title.trim().replace(/\.md$/i, '');
10
+ const unique = (values) => Array.from(new Set(values.map((value) => value.trim()).filter(Boolean)));
11
+ const parseFrontmatter = (content) => {
12
+ const match = content.match(frontmatterPattern);
13
+ if (!match) {
14
+ return {};
15
+ }
16
+ return match[1]
17
+ .split('\n')
18
+ .map((line) => line.match(/^([^:#]+):\s*(.+)$/))
19
+ .filter((match) => Boolean(match))
20
+ .reduce((frontmatter, match) => ({
21
+ ...frontmatter,
22
+ [match[1].trim()]: match[2].trim().replace(/^["']|["']$/g, '')
23
+ }), {});
24
+ };
25
+ const stripFrontmatter = (content) => content.replace(frontmatterPattern, '');
26
+ const stripFencedCodeBlocks = (content) => content.replace(/```[\s\S]*?```/g, '').replace(/~~~[\s\S]*?~~~/g, '');
27
+ const extractTitle = (filePath, content, frontmatter) => {
28
+ if (frontmatter.title) {
29
+ return normalizeTitle(frontmatter.title);
30
+ }
31
+ const heading = content.match(headingPattern);
32
+ if (heading) {
33
+ return normalizeTitle(heading[1]);
34
+ }
35
+ return normalizeTitle(basename(filePath));
36
+ };
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]));
39
+ const normalizeChunkContent = (content) => content
40
+ .split('\n')
41
+ .map((line) => line.trim())
42
+ .join('\n')
43
+ .replace(/\n{3,}/g, '\n\n')
44
+ .trim();
45
+ export const splitIntoChunks = (documentId, content, maxCharacters = 1200) => {
46
+ const paragraphs = normalizeChunkContent(stripFrontmatter(content))
47
+ .split(/\n{2,}/)
48
+ .filter(Boolean);
49
+ const chunks = paragraphs.reduce((state, paragraph) => {
50
+ const lastChunk = state.at(-1);
51
+ if (!lastChunk) {
52
+ return [paragraph];
53
+ }
54
+ const merged = `${lastChunk}\n\n${paragraph}`;
55
+ if (merged.length <= maxCharacters) {
56
+ return [...state.slice(0, -1), merged];
57
+ }
58
+ return [...state, paragraph];
59
+ }, []);
60
+ return chunks.map((chunk, ordinal) => ({
61
+ id: createStableId(`${documentId}:${ordinal}:${chunk}`),
62
+ documentId,
63
+ ordinal,
64
+ content: chunk,
65
+ tokenCount: estimateTokenCount(chunk),
66
+ embeddingProvider: 'none',
67
+ embedding: []
68
+ }));
69
+ };
70
+ export const parseMarkdownDocument = (input) => {
71
+ const relativePath = relative(input.vaultPath, input.absolutePath);
72
+ const frontmatter = parseFrontmatter(input.content);
73
+ const title = extractTitle(input.absolutePath, input.content, frontmatter);
74
+ const agentId = frontmatter.agent ? sanitizeAgentId(frontmatter.agent) : resolveAgentIdFromPath(relativePath);
75
+ return {
76
+ id: createStableId(relativePath),
77
+ agentId,
78
+ title,
79
+ path: relativePath,
80
+ content: input.content,
81
+ tags: extractTags(input.content),
82
+ links: extractWikiLinks(input.content),
83
+ frontmatter,
84
+ createdAt: input.createdAt.toISOString(),
85
+ updatedAt: input.updatedAt.toISOString()
86
+ };
87
+ };
88
+ export const createIndexedDocument = (document, titleToDocumentId, maxChunkCharacters = 1200) => {
89
+ const chunks = splitIntoChunks(document.id, document.content, maxChunkCharacters);
90
+ const links = document.links.map((toTitle) => ({
91
+ fromDocumentId: document.id,
92
+ toTitle,
93
+ toDocumentId: titleToDocumentId.get(toTitle.toLowerCase()) ?? null
94
+ }));
95
+ return {
96
+ document,
97
+ chunks,
98
+ links
99
+ };
100
+ };
@@ -0,0 +1,54 @@
1
+ const maxTitleLength = 160;
2
+ const maxContentLength = 200_000;
3
+ const sensitivePatterns = [
4
+ {
5
+ label: 'private key',
6
+ pattern: /-----BEGIN [A-Z ]*PRIVATE KEY-----/
7
+ },
8
+ {
9
+ label: 'OpenAI API key',
10
+ pattern: /\bsk-[A-Za-z0-9_-]{20,}\b/
11
+ },
12
+ {
13
+ label: 'GitHub token',
14
+ pattern: /\b(?:ghp|gho|ghs|ghr|github_pat)_[A-Za-z0-9_]{20,}\b/
15
+ },
16
+ {
17
+ label: 'AWS access key',
18
+ pattern: /\bAKIA[0-9A-Z]{16}\b/
19
+ },
20
+ {
21
+ label: 'Slack token',
22
+ pattern: /\bxox[abprs]-[A-Za-z0-9-]{20,}\b/
23
+ },
24
+ {
25
+ label: 'JWT',
26
+ pattern: /\beyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/
27
+ },
28
+ {
29
+ label: 'secret assignment',
30
+ pattern: /\b(?:api[_-]?key|secret|password|private[_-]?key|access[_-]?token|auth[_-]?token)\s*[:=]\s*["']?[A-Za-z0-9_./+=-]{12,}/i
31
+ }
32
+ ];
33
+ export const findSensitiveContent = (value) => sensitivePatterns
34
+ .filter(({ pattern }) => pattern.test(value))
35
+ .map(({ label }) => ({ label }));
36
+ export const validateNoteInput = (input) => {
37
+ if (!input.title.trim()) {
38
+ throw new Error('Note title cannot be empty.');
39
+ }
40
+ if (!input.content.trim()) {
41
+ throw new Error('Note content cannot be empty.');
42
+ }
43
+ if (input.title.length > maxTitleLength) {
44
+ throw new Error(`Note title is too long. Maximum length is ${maxTitleLength} characters.`);
45
+ }
46
+ if (input.content.length > maxContentLength) {
47
+ throw new Error(`Note content is too large. Maximum length is ${maxContentLength} characters.`);
48
+ }
49
+ const findings = findSensitiveContent(`${input.title}\n${input.content}`);
50
+ if (findings.length > 0 && !input.allowSensitive) {
51
+ const labels = Array.from(new Set(findings.map((finding) => finding.label))).join(', ');
52
+ throw new Error(`Sensitive memory blocked (${labels}). Remove secrets or pass --allow-sensitive intentionally.`);
53
+ }
54
+ };
@@ -0,0 +1 @@
1
+ export const estimateTokenCount = (content) => Math.ceil(content.trim().split(/\s+/).filter(Boolean).join(' ').length / 4);
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,60 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { resolve } from 'node:path';
3
+ export const defaultBrainlinkConfig = {
4
+ vault: '.',
5
+ host: '127.0.0.1',
6
+ port: 4321,
7
+ allowedVaults: [],
8
+ defaultSearchLimit: 10,
9
+ defaultContextTokens: 2000,
10
+ embeddingProvider: 'local',
11
+ defaultSearchMode: 'hybrid',
12
+ chunkSize: 1200
13
+ };
14
+ const configFilenames = ['brainlink.config.json', '.brainlink.json'];
15
+ const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
16
+ const embeddingProviders = new Set(['none', 'local']);
17
+ const searchModes = new Set(['fts', 'semantic', 'hybrid']);
18
+ const sanitizeEmbeddingProvider = (value) => typeof value === 'string' && embeddingProviders.has(value) ? value : defaultBrainlinkConfig.embeddingProvider;
19
+ export const sanitizeSearchMode = (value, fallback = defaultBrainlinkConfig.defaultSearchMode) => typeof value === 'string' && searchModes.has(value) ? value : fallback;
20
+ const sanitizeAllowedVaults = (value) => Array.isArray(value) ? value.filter((item) => typeof item === 'string' && item.trim().length > 0) : [];
21
+ const readAllowedVaultsFromEnv = () => (process.env.BRAINLINK_ALLOWED_VAULTS ?? '')
22
+ .split(',')
23
+ .map((value) => value.trim())
24
+ .filter(Boolean);
25
+ const readJsonConfig = async (path) => {
26
+ try {
27
+ const raw = await readFile(path, 'utf8');
28
+ const parsed = JSON.parse(raw);
29
+ return isRecord(parsed) ? parsed : {};
30
+ }
31
+ catch (error) {
32
+ if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
33
+ return {};
34
+ }
35
+ throw error;
36
+ }
37
+ };
38
+ const sanitizeConfig = (value) => ({
39
+ ...defaultBrainlinkConfig,
40
+ ...value,
41
+ port: typeof value.port === 'number' && value.port > 0 ? value.port : defaultBrainlinkConfig.port,
42
+ defaultSearchLimit: typeof value.defaultSearchLimit === 'number' && value.defaultSearchLimit > 0
43
+ ? value.defaultSearchLimit
44
+ : defaultBrainlinkConfig.defaultSearchLimit,
45
+ defaultContextTokens: typeof value.defaultContextTokens === 'number' && value.defaultContextTokens > 0
46
+ ? value.defaultContextTokens
47
+ : defaultBrainlinkConfig.defaultContextTokens,
48
+ allowedVaults: [...sanitizeAllowedVaults(value.allowedVaults), ...readAllowedVaultsFromEnv()],
49
+ chunkSize: typeof value.chunkSize === 'number' && value.chunkSize > 0 ? value.chunkSize : defaultBrainlinkConfig.chunkSize,
50
+ embeddingProvider: sanitizeEmbeddingProvider(value.embeddingProvider),
51
+ defaultSearchMode: sanitizeSearchMode(value.defaultSearchMode)
52
+ });
53
+ export const loadBrainlinkConfig = async (cwd = process.cwd()) => {
54
+ const configs = await Promise.all(configFilenames.map((filename) => readJsonConfig(resolve(cwd, filename))));
55
+ const merged = configs.reduce((state, config) => ({
56
+ ...state,
57
+ ...config
58
+ }), {});
59
+ return sanitizeConfig(merged);
60
+ };
@@ -0,0 +1,62 @@
1
+ import { chmod, mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises';
2
+ import { dirname, extname, isAbsolute, join, relative, resolve } from 'node:path';
3
+ const excludedDirectories = new Set(['.brainlink', '.git', 'node_modules', 'dist']);
4
+ const directoryMode = 0o700;
5
+ const fileMode = 0o600;
6
+ const walkMarkdownFiles = async (directory) => {
7
+ const entries = await readdir(directory, { withFileTypes: true });
8
+ const nested = await Promise.all(entries.map(async (entry) => {
9
+ const absolutePath = join(directory, entry.name);
10
+ if (entry.isDirectory()) {
11
+ return excludedDirectories.has(entry.name) ? [] : walkMarkdownFiles(absolutePath);
12
+ }
13
+ return entry.isFile() && extname(entry.name).toLowerCase() === '.md' ? [absolutePath] : [];
14
+ }));
15
+ return nested.flat();
16
+ };
17
+ export const resolveVaultPath = (vaultPath) => resolve(process.cwd(), vaultPath);
18
+ const isPathInside = (parent, child) => {
19
+ const path = relative(parent, child);
20
+ return path === '' || (!path.startsWith('..') && !isAbsolute(path));
21
+ };
22
+ const secureDirectory = async (path) => {
23
+ await mkdir(path, { recursive: true, mode: directoryMode });
24
+ await chmod(path, directoryMode);
25
+ };
26
+ export const assertVaultAllowed = (vaultPath, allowedVaults) => {
27
+ const absoluteVaultPath = resolveVaultPath(vaultPath);
28
+ const allowed = allowedVaults.map(resolveVaultPath);
29
+ if (allowed.length > 0 && !allowed.some((allowedPath) => isPathInside(allowedPath, absoluteVaultPath))) {
30
+ throw new Error(`Vault path is not allowed: ${absoluteVaultPath}. Configure BRAINLINK_ALLOWED_VAULTS or allowedVaults.`);
31
+ }
32
+ return absoluteVaultPath;
33
+ };
34
+ export const ensureVault = async (vaultPath) => {
35
+ const absoluteVaultPath = resolveVaultPath(vaultPath);
36
+ await secureDirectory(join(absoluteVaultPath, '.brainlink'));
37
+ return absoluteVaultPath;
38
+ };
39
+ export const readMarkdownFiles = async (vaultPath) => {
40
+ const absoluteVaultPath = resolveVaultPath(vaultPath);
41
+ const paths = await walkMarkdownFiles(absoluteVaultPath);
42
+ return Promise.all(paths.map(async (absolutePath) => {
43
+ const [content, stats] = await Promise.all([readFile(absolutePath, 'utf8'), stat(absolutePath)]);
44
+ return {
45
+ absolutePath,
46
+ content,
47
+ createdAt: stats.birthtime,
48
+ updatedAt: stats.mtime
49
+ };
50
+ }));
51
+ };
52
+ export const writeMarkdownFile = async (vaultPath, filename, content) => {
53
+ const absoluteVaultPath = await ensureVault(vaultPath);
54
+ const absolutePath = resolve(absoluteVaultPath, filename.endsWith('.md') ? filename : `${filename}.md`);
55
+ if (!isPathInside(absoluteVaultPath, absolutePath)) {
56
+ throw new Error(`Refusing to write outside vault: ${absolutePath}`);
57
+ }
58
+ await secureDirectory(dirname(absolutePath));
59
+ await writeFile(absolutePath, content, { encoding: 'utf8', mode: fileMode });
60
+ await chmod(absolutePath, fileMode);
61
+ return absolutePath;
62
+ };
@@ -0,0 +1,50 @@
1
+ import { createEmbeddingBuckets } from '../../domain/embeddings.js';
2
+ export const createIndexWriter = (database) => ({
3
+ reset: () => {
4
+ database.exec(`
5
+ DELETE FROM embedding_buckets;
6
+ DELETE FROM chunks_fts;
7
+ DELETE FROM links;
8
+ DELETE FROM chunks;
9
+ DELETE FROM documents;
10
+ `);
11
+ },
12
+ saveDocuments: (documents) => {
13
+ const insertDocument = database.prepare(`
14
+ INSERT INTO documents (id, agent_id, title, path, content, tags_json, frontmatter_json, created_at, updated_at)
15
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
16
+ `);
17
+ const insertChunk = database.prepare(`
18
+ INSERT INTO chunks (id, document_id, ordinal, content, token_count, embedding_provider, embedding_json)
19
+ VALUES (?, ?, ?, ?, ?, ?, ?)
20
+ `);
21
+ const insertChunkFts = database.prepare(`
22
+ INSERT INTO chunks_fts (chunk_id, document_id, agent_id, title, content)
23
+ VALUES (?, ?, ?, ?, ?)
24
+ `);
25
+ const insertEmbeddingBucket = database.prepare(`
26
+ INSERT OR IGNORE INTO embedding_buckets (bucket, chunk_id)
27
+ VALUES (?, ?)
28
+ `);
29
+ const insertLink = database.prepare(`
30
+ INSERT INTO links (from_document_id, to_title, to_document_id)
31
+ VALUES (?, ?, ?)
32
+ `);
33
+ const transaction = database.transaction(() => {
34
+ documents.forEach(({ document, chunks, links }) => {
35
+ insertDocument.run(document.id, document.agentId, document.title, document.path, document.content, JSON.stringify(document.tags), JSON.stringify(document.frontmatter), document.createdAt, document.updatedAt);
36
+ chunks.forEach((chunk) => {
37
+ insertChunk.run(chunk.id, chunk.documentId, chunk.ordinal, chunk.content, chunk.tokenCount, chunk.embeddingProvider, JSON.stringify(chunk.embedding));
38
+ insertChunkFts.run(chunk.id, chunk.documentId, document.agentId, document.title, chunk.content);
39
+ createEmbeddingBuckets(chunk.embedding).forEach((bucket) => insertEmbeddingBucket.run(bucket, chunk.id));
40
+ });
41
+ });
42
+ documents.forEach(({ links }) => {
43
+ links.forEach((link) => {
44
+ insertLink.run(link.fromDocumentId, link.toTitle, link.toDocumentId);
45
+ });
46
+ });
47
+ });
48
+ transaction();
49
+ }
50
+ });