@andespindola/brainlink 0.1.0-beta.6 → 0.1.0-beta.60

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 (63) hide show
  1. package/AGENTS.md +8 -5
  2. package/CHANGELOG.md +58 -2
  3. package/CONTRIBUTING.md +2 -2
  4. package/COPYRIGHT.md +5 -0
  5. package/README.md +266 -20
  6. package/SECURITY.md +1 -1
  7. package/dist/application/add-note.js +62 -13
  8. package/dist/application/analyze-vault.js +95 -8
  9. package/dist/application/build-context.js +56 -1
  10. package/dist/application/dedupe-notes.js +226 -0
  11. package/dist/application/frontend/client-css.js +214 -100
  12. package/dist/application/frontend/client-html.js +60 -45
  13. package/dist/application/frontend/client-js.js +1765 -117
  14. package/dist/application/frontend/client-worker-js.js +66 -0
  15. package/dist/application/get-graph-layout.js +18 -6
  16. package/dist/application/get-graph-node.js +12 -0
  17. package/dist/application/get-graph-summary.js +12 -0
  18. package/dist/application/get-graph.js +3 -3
  19. package/dist/application/import-legacy-sqlite.js +296 -0
  20. package/dist/application/index-vault.js +252 -19
  21. package/dist/application/list-agents.js +3 -3
  22. package/dist/application/list-links.js +5 -5
  23. package/dist/application/migrate-vault.js +91 -0
  24. package/dist/application/offline-pack-backup.js +44 -0
  25. package/dist/application/search-graph-node-ids.js +12 -0
  26. package/dist/application/search-knowledge.js +75 -5
  27. package/dist/application/server/routes.js +102 -1
  28. package/dist/application/start-server.js +75 -4
  29. package/dist/application/watch-vault.js +23 -2
  30. package/dist/benchmarks/large-vault.js +1 -1
  31. package/dist/cli/commands/agent-commands.js +419 -0
  32. package/dist/cli/commands/config-commands.js +167 -0
  33. package/dist/cli/commands/read-commands.js +25 -8
  34. package/dist/cli/commands/write-commands.js +989 -10
  35. package/dist/cli/main.js +4 -0
  36. package/dist/cli/runtime.js +5 -2
  37. package/dist/domain/context.js +53 -11
  38. package/dist/domain/embeddings.js +2 -1
  39. package/dist/domain/graph-layout.js +62 -15
  40. package/dist/domain/markdown.js +36 -4
  41. package/dist/domain/middle-out.js +18 -0
  42. package/dist/infrastructure/config.js +132 -8
  43. package/dist/infrastructure/file-index.js +358 -0
  44. package/dist/infrastructure/file-system-vault.js +30 -0
  45. package/dist/infrastructure/index-state.js +56 -0
  46. package/dist/infrastructure/paths.js +9 -1
  47. package/dist/infrastructure/private-pack-codec.js +134 -0
  48. package/dist/infrastructure/search-packs.js +452 -0
  49. package/dist/infrastructure/session-state.js +172 -0
  50. package/dist/mcp/main.js +11 -3
  51. package/dist/mcp/server.js +27 -2
  52. package/dist/mcp/startup.js +35 -0
  53. package/dist/mcp/tools.js +633 -19
  54. package/docs/AGENT_USAGE.md +178 -16
  55. package/docs/ARCHITECTURE.md +37 -26
  56. package/docs/QUICKSTART.md +111 -0
  57. package/package.json +6 -4
  58. package/dist/infrastructure/sqlite/document-writer.js +0 -51
  59. package/dist/infrastructure/sqlite/graph-reader.js +0 -120
  60. package/dist/infrastructure/sqlite/schema.js +0 -111
  61. package/dist/infrastructure/sqlite/search-reader.js +0 -156
  62. package/dist/infrastructure/sqlite/types.js +0 -1
  63. package/dist/infrastructure/sqlite-index.js +0 -25
package/dist/cli/main.js CHANGED
@@ -3,6 +3,8 @@ import { Command } from 'commander';
3
3
  import { readFileSync } from 'node:fs';
4
4
  import { basename, dirname, join } from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
+ import { registerAgentCommands } from './commands/agent-commands.js';
7
+ import { registerConfigCommands } from './commands/config-commands.js';
6
8
  import { registerReadCommands } from './commands/read-commands.js';
7
9
  import { registerWriteCommands } from './commands/write-commands.js';
8
10
  const readPackageVersion = () => {
@@ -21,6 +23,8 @@ program
21
23
  .version(readPackageVersion());
22
24
  registerWriteCommands(program);
23
25
  registerReadCommands(program);
26
+ registerConfigCommands(program);
27
+ registerAgentCommands(program);
24
28
  program.parseAsync().catch((error) => {
25
29
  const message = error instanceof Error ? error.message : String(error);
26
30
  console.error(message);
@@ -1,4 +1,4 @@
1
- import { loadBrainlinkConfig } from '../infrastructure/config.js';
1
+ import { loadBrainlinkConfig, resolveAgentRuntimeDefaults } from '../infrastructure/config.js';
2
2
  import { assertVaultAllowed } from '../infrastructure/file-system-vault.js';
3
3
  export const parsePositiveInteger = (value, fallback) => {
4
4
  const parsed = Number.parseInt(value, 10);
@@ -8,10 +8,13 @@ export const resolveOptions = async (options) => {
8
8
  const config = await loadBrainlinkConfig();
9
9
  const vault = options.vault ?? config.vault;
10
10
  const allowedVault = assertVaultAllowed(vault, config.allowedVaults);
11
+ const agent = options.agent ?? config.defaultAgent;
12
+ const defaults = resolveAgentRuntimeDefaults(config, agent);
11
13
  return {
12
14
  config,
13
15
  vault: allowedVault,
14
- agent: options.agent ?? config.defaultAgent
16
+ agent,
17
+ defaults
15
18
  };
16
19
  };
17
20
  export const print = (json, value, human) => {
@@ -1,13 +1,50 @@
1
+ import { middleOutIndices } from './middle-out.js';
2
+ const maxSectionsPerDocument = 3;
3
+ const byScore = (left, right) => right.score - left.score || left.title.localeCompare(right.title);
4
+ const byOrdinal = (left, right) => (left.chunkOrdinal ?? Number.MAX_SAFE_INTEGER) - (right.chunkOrdinal ?? Number.MAX_SAFE_INTEGER);
5
+ const middleOutDocumentResults = (results) => {
6
+ if (results.length <= 1) {
7
+ return results;
8
+ }
9
+ const sortedByOrdinal = [...results].sort(byOrdinal);
10
+ const pivotChunkId = [...results].sort(byScore)[0]?.chunkId;
11
+ const pivotIndex = sortedByOrdinal.findIndex((result) => result.chunkId === pivotChunkId);
12
+ if (pivotIndex < 0) {
13
+ return [...results].sort(byScore);
14
+ }
15
+ return middleOutIndices(sortedByOrdinal.length, pivotIndex).map((index) => sortedByOrdinal[index]);
16
+ };
1
17
  export const selectContextSections = (results, maxTokens) => {
2
- const selected = results.reduce((state, result) => {
3
- const tokenCost = Math.ceil(result.content.length / 4);
4
- if (state.usedTokens + tokenCost > maxTokens || state.seenDocuments.has(result.documentId)) {
5
- return state;
18
+ const grouped = results.reduce((state, result) => {
19
+ const current = state.get(result.documentId) ?? [];
20
+ state.set(result.documentId, [...current, result]);
21
+ return state;
22
+ }, new Map());
23
+ const documentOrder = Array.from(results.reduce((state, result) => {
24
+ if (!state.has(result.documentId)) {
25
+ state.set(result.documentId, result.score);
6
26
  }
7
- return {
8
- usedTokens: state.usedTokens + tokenCost,
9
- sections: [
10
- ...state.sections,
27
+ return state;
28
+ }, new Map()).entries())
29
+ .sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0]))
30
+ .map(([documentId]) => documentId);
31
+ const selected = documentOrder.reduce((state, documentId) => {
32
+ const ordered = middleOutDocumentResults(grouped.get(documentId) ?? []);
33
+ let usedTokens = state.usedTokens;
34
+ let sections = state.sections;
35
+ let seenChunks = state.seenChunks;
36
+ for (let index = 0; index < ordered.length && index < maxSectionsPerDocument; index += 1) {
37
+ const result = ordered[index];
38
+ if (seenChunks.has(result.chunkId)) {
39
+ continue;
40
+ }
41
+ const tokenCost = Math.ceil(result.content.length / 4);
42
+ if (usedTokens + tokenCost > maxTokens) {
43
+ break;
44
+ }
45
+ usedTokens += tokenCost;
46
+ sections = [
47
+ ...sections,
11
48
  {
12
49
  title: result.title,
13
50
  path: result.path,
@@ -16,13 +53,18 @@ export const selectContextSections = (results, maxTokens) => {
16
53
  searchMode: result.searchMode,
17
54
  tags: result.tags
18
55
  }
19
- ],
20
- seenDocuments: new Set([...state.seenDocuments, result.documentId])
56
+ ];
57
+ seenChunks = new Set([...seenChunks, result.chunkId]);
58
+ }
59
+ return {
60
+ usedTokens,
61
+ sections,
62
+ seenChunks
21
63
  };
22
64
  }, {
23
65
  usedTokens: 0,
24
66
  sections: [],
25
- seenDocuments: new Set()
67
+ seenChunks: new Set()
26
68
  });
27
69
  return selected.sections;
28
70
  };
@@ -58,7 +58,8 @@ const tokenize = (input) => input
58
58
  .match(tokenPattern)
59
59
  ?.map(normalizeToken)
60
60
  .filter((token) => token.length > 1 && !stopWords.has(token)) ?? [];
61
- const expandTokens = (tokens) => tokens.flatMap((token) => [token, ...(aliases[token] ?? [])]);
61
+ const getAliasesForToken = (token) => Object.hasOwn(aliases, token) ? aliases[token] ?? [] : [];
62
+ const expandTokens = (tokens) => tokens.flatMap((token) => [token, ...getAliasesForToken(token)]);
62
63
  const hash = (value) => Array.from(value).reduce((state, char) => Math.imul(state ^ char.charCodeAt(0), 16777619), 2166136261) >>> 0;
63
64
  const featureHash = (feature) => {
64
65
  const value = hash(feature);
@@ -20,6 +20,7 @@ const segmentAngles = {
20
20
  Evaluation: 2.08,
21
21
  Security: 2.82
22
22
  };
23
+ const hubTitlePattern = /\b(memory\s*hub|knowledge\s*root|moc|map)\b/i;
23
24
  const hashText = (value) => Array.from(value).reduce((hash, char) => ((hash << 5) - hash + char.charCodeAt(0)) | 0, 0);
24
25
  const jitter = (value, range) => {
25
26
  const normalized = Math.abs(hashText(value) % 1000) / 1000;
@@ -45,26 +46,61 @@ const countDegrees = (edges) => edges.reduce((degrees, edge) => {
45
46
  ? incrementDegreeBy(incrementDegreeBy(degrees, edge.source, weight), edge.target, weight)
46
47
  : incrementDegreeBy(degrees, edge.source, weight);
47
48
  }, new Map());
48
- const uniqueIds = (ids) => Array.from(new Set(ids));
49
49
  const createAdjacency = (nodes, edges) => {
50
50
  const nodeIds = new Set(nodes.map((node) => node.id));
51
- const emptyAdjacency = new Map(nodes.map((node) => [node.id, []]));
52
- return edges.reduce((adjacency, edge) => {
51
+ const adjacency = new Map(nodes.map((node) => [node.id, new Set()]));
52
+ edges.forEach((edge) => {
53
53
  if (!edge.target || !nodeIds.has(edge.source) || !nodeIds.has(edge.target)) {
54
- return adjacency;
54
+ return;
55
55
  }
56
- return new Map([
57
- ...adjacency,
58
- [edge.source, uniqueIds([...(adjacency.get(edge.source) ?? []), edge.target])],
59
- [edge.target, uniqueIds([...(adjacency.get(edge.target) ?? []), edge.source])]
60
- ]);
61
- }, emptyAdjacency);
56
+ adjacency.get(edge.source)?.add(edge.target);
57
+ adjacency.get(edge.target)?.add(edge.source);
58
+ });
59
+ return new Map(Array.from(adjacency.entries(), ([id, neighbors]) => [id, Array.from(neighbors)]));
62
60
  };
63
61
  const byTitle = (left, right) => left.title.localeCompare(right.title);
64
62
  const byDegreeThenTitle = (degrees) => (left, right) => {
65
63
  const degreeDelta = (degrees.get(right.id) ?? 0) - (degrees.get(left.id) ?? 0);
66
64
  return degreeDelta === 0 ? byTitle(left, right) : degreeDelta;
67
65
  };
66
+ const hubScore = (node) => {
67
+ const title = node.title.trim().toLowerCase();
68
+ if (title === 'memory hub')
69
+ return 5;
70
+ if (title === 'knowledge root')
71
+ return 4;
72
+ if (/\bmoc\b/i.test(node.title))
73
+ return 3;
74
+ return hubTitlePattern.test(node.title) ? 2 : 0;
75
+ };
76
+ const selectPrimaryHubId = (nodes, degrees) => {
77
+ const ranked = [...nodes]
78
+ .filter((node) => hubScore(node) > 0)
79
+ .sort((left, right) => {
80
+ const scoreDelta = hubScore(right) - hubScore(left);
81
+ if (scoreDelta !== 0)
82
+ return scoreDelta;
83
+ const degreeDelta = (degrees.get(right.id) ?? 0) - (degrees.get(left.id) ?? 0);
84
+ if (degreeDelta !== 0)
85
+ return degreeDelta;
86
+ return left.title.localeCompare(right.title);
87
+ });
88
+ return ranked[0]?.id ?? null;
89
+ };
90
+ const centerLayoutByNode = (nodes, nodeId) => {
91
+ if (!nodeId) {
92
+ return nodes;
93
+ }
94
+ const anchor = nodes.find((node) => node.id === nodeId);
95
+ if (!anchor) {
96
+ return nodes;
97
+ }
98
+ return nodes.map((node) => ({
99
+ ...node,
100
+ x: node.x - anchor.x,
101
+ y: node.y - anchor.y
102
+ }));
103
+ };
68
104
  const naturalSegmentSeed = (node) => groupKey(node) === '00-maps' || /\b(moc|map)\b/i.test(node.title);
69
105
  const segmentName = (node) => node.title.replace(/^MOC\s+/i, '').replace(/\s+Memory Map$/i, '').trim() || node.title;
70
106
  const collectComponent = (adjacency, startId, visited) => {
@@ -117,10 +153,19 @@ const assignSegments = (nodes, edges, degrees) => {
117
153
  }
118
154
  return new Map(nodes.map((node) => [node.id, assignments.get(node.id) ?? groupLabel(groupKey(node))]));
119
155
  };
120
- const groupNodesBySegment = (nodes, segments) => nodes.reduce((groups, node) => {
121
- const segment = segments.get(node.id) ?? groupLabel(groupKey(node));
122
- return new Map([...groups, [segment, [...(groups.get(segment) ?? []), node]]]);
123
- }, new Map());
156
+ const groupNodesBySegment = (nodes, segments) => {
157
+ const groups = new Map();
158
+ nodes.forEach((node) => {
159
+ const segment = segments.get(node.id) ?? groupLabel(groupKey(node));
160
+ const bucket = groups.get(segment);
161
+ if (bucket) {
162
+ bucket.push(node);
163
+ return;
164
+ }
165
+ groups.set(segment, [node]);
166
+ });
167
+ return new Map(groups);
168
+ };
124
169
  const segmentAngle = (segment, index, count) => segmentAngles[segment] ?? (Math.PI * 2 * index) / Math.max(count, 1) - Math.PI / 2;
125
170
  const createSegmentNodes = (segments, degrees, segmentCount) => ([segment, nodes], segmentIndex) => {
126
171
  const sortedNodes = [...nodes].sort(byDegreeThenTitle(degrees));
@@ -240,8 +285,10 @@ export const createCauliflowerGraphLayout = (graph) => {
240
285
  const segmentGroups = Array.from(groupNodesBySegment(graph.nodes, segments).entries())
241
286
  .sort(([left], [right]) => left.localeCompare(right));
242
287
  const nodes = relaxCollisions(segmentGroups.flatMap(createSegmentNodes(segments, degrees, segmentGroups.length)));
288
+ const primaryHubId = selectPrimaryHubId(graph.nodes, degrees);
289
+ const centeredNodes = centerLayoutByNode(nodes, primaryHubId);
243
290
  return {
244
- nodes,
291
+ nodes: centeredNodes,
245
292
  edges: graph.edges
246
293
  };
247
294
  };
@@ -77,11 +77,13 @@ export const extractWikiLinkReferences = (content) => visibleMarkdownLines(conte
77
77
  }));
78
78
  });
79
79
  const priorityFromWeight = (weight) => weight >= 8 ? 'critical' : weight >= 4 ? 'high' : 'normal';
80
+ const normalizeAccumulatedWeight = (weight) => Math.max(1, Math.min(12, weight));
80
81
  export const extractWikiLinkWeights = (content) => {
81
82
  const weights = extractWikiLinkReferences(content).reduce((state, reference) => {
82
83
  const titleKey = reference.title.toLowerCase();
83
84
  const current = state.get(titleKey);
84
- const weight = (current?.weight ?? 0) + reference.weight;
85
+ const rawWeight = (current?.weight ?? 0) + reference.weight;
86
+ const weight = normalizeAccumulatedWeight(rawWeight);
85
87
  const explicitPriority = reference.priority
86
88
  ? maxPriority(current?.priority ?? reference.priority, reference.priority)
87
89
  : current?.priority;
@@ -116,10 +118,38 @@ const normalizeChunkContent = (content) => content
116
118
  .join('\n')
117
119
  .replace(/\n{3,}/g, '\n\n')
118
120
  .trim();
121
+ const splitLongParagraph = (paragraph, maxCharacters) => {
122
+ if (paragraph.length <= maxCharacters) {
123
+ return [paragraph];
124
+ }
125
+ const sentences = paragraph
126
+ .split(/(?<=[.!?])\s+/)
127
+ .map((sentence) => sentence.trim())
128
+ .filter(Boolean);
129
+ if (sentences.length <= 1) {
130
+ const chunks = [];
131
+ for (let index = 0; index < paragraph.length; index += maxCharacters) {
132
+ chunks.push(paragraph.slice(index, index + maxCharacters).trim());
133
+ }
134
+ return chunks.filter(Boolean);
135
+ }
136
+ return sentences.reduce((state, sentence) => {
137
+ const last = state.at(-1);
138
+ if (!last) {
139
+ return [sentence];
140
+ }
141
+ const merged = `${last} ${sentence}`;
142
+ if (merged.length <= maxCharacters) {
143
+ return [...state.slice(0, -1), merged];
144
+ }
145
+ return [...state, sentence];
146
+ }, []);
147
+ };
119
148
  export const splitIntoChunks = (documentId, content, maxCharacters = 1200) => {
120
149
  const paragraphs = normalizeChunkContent(stripFrontmatter(content))
121
150
  .split(/\n{2,}/)
122
- .filter(Boolean);
151
+ .filter(Boolean)
152
+ .flatMap((paragraph) => splitLongParagraph(paragraph, maxCharacters));
123
153
  const chunks = paragraphs.reduce((state, paragraph) => {
124
154
  const lastChunk = state.at(-1);
125
155
  if (!lastChunk) {
@@ -162,13 +192,15 @@ export const parseMarkdownDocument = (input) => {
162
192
  export const createIndexedDocument = (document, titleToDocumentId, maxChunkCharacters = 1200) => {
163
193
  const chunks = splitIntoChunks(document.id, document.content, maxChunkCharacters);
164
194
  const linkWeights = new Map(extractWikiLinkWeights(document.content).map((link) => [link.title.toLowerCase(), link]));
165
- const links = document.links.map((toTitle) => ({
195
+ const links = document.links
196
+ .map((toTitle) => ({
166
197
  fromDocumentId: document.id,
167
198
  toTitle,
168
199
  toDocumentId: titleToDocumentId.get(toTitle.toLowerCase()) ?? null,
169
200
  weight: linkWeights.get(toTitle.toLowerCase())?.weight ?? 1,
170
201
  priority: linkWeights.get(toTitle.toLowerCase())?.priority ?? 'normal'
171
- }));
202
+ }))
203
+ .filter((link) => link.toDocumentId !== document.id);
172
204
  return {
173
205
  document,
174
206
  chunks,
@@ -0,0 +1,18 @@
1
+ export const middleOutIndices = (size, pivotIndex) => {
2
+ if (!Number.isFinite(size) || size <= 0) {
3
+ return [];
4
+ }
5
+ const clampedPivot = Math.max(0, Math.min(Math.floor(pivotIndex), size - 1));
6
+ const indices = [clampedPivot];
7
+ for (let offset = 1; indices.length < size; offset += 1) {
8
+ const left = clampedPivot - offset;
9
+ const right = clampedPivot + offset;
10
+ if (left >= 0) {
11
+ indices.push(left);
12
+ }
13
+ if (right < size) {
14
+ indices.push(right);
15
+ }
16
+ }
17
+ return indices;
18
+ };
@@ -1,7 +1,8 @@
1
- import { readFile } from 'node:fs/promises';
2
- import { resolve } from 'node:path';
1
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
+ import { dirname, join, resolve } from 'node:path';
3
+ import { homedir } from 'node:os';
3
4
  import { sanitizeAgentId } from '../domain/agents.js';
4
- import { getDefaultVaultPath } from './paths.js';
5
+ import { getBrainlinkHomePath, getDefaultVaultPath } from './paths.js';
5
6
  export const defaultBrainlinkConfig = {
6
7
  vault: getDefaultVaultPath(),
7
8
  host: '127.0.0.1',
@@ -13,15 +14,98 @@ export const defaultBrainlinkConfig = {
13
14
  defaultContextTokens: 2000,
14
15
  embeddingProvider: 'local',
15
16
  defaultSearchMode: 'hybrid',
16
- chunkSize: 1200
17
+ chunkSize: 1200,
18
+ searchPack: {
19
+ rowChunkSize: 5_000,
20
+ compressionLevel: 5,
21
+ useDictionary: true,
22
+ guardrailMinSavingsPercent: 8,
23
+ guardrailMaxLatencyRegressionPercent: 5
24
+ },
25
+ agentProfiles: {}
17
26
  };
18
27
  const configFilenames = ['brainlink.config.json', '.brainlink.json'];
28
+ const localConfigFilename = 'brainlink.config.json';
29
+ const globalConfigFilename = 'brainlink.config.json';
30
+ const globalConfigDirectoryMode = 0o700;
31
+ const globalConfigFileMode = 0o600;
32
+ const safeCwd = () => {
33
+ try {
34
+ return process.cwd();
35
+ }
36
+ catch {
37
+ return homedir();
38
+ }
39
+ };
19
40
  const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
20
41
  const embeddingProviders = new Set(['none', 'local']);
21
42
  const searchModes = new Set(['fts', 'semantic', 'hybrid']);
22
43
  const sanitizeEmbeddingProvider = (value) => typeof value === 'string' && embeddingProviders.has(value) ? value : defaultBrainlinkConfig.embeddingProvider;
23
44
  export const sanitizeSearchMode = (value, fallback = defaultBrainlinkConfig.defaultSearchMode) => typeof value === 'string' && searchModes.has(value) ? value : fallback;
24
45
  const sanitizeAllowedVaults = (value) => Array.isArray(value) ? value.filter((item) => typeof item === 'string' && item.trim().length > 0) : [];
46
+ const sanitizePositiveNumber = (value) => typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : undefined;
47
+ const sanitizeIntegerInRange = (value, fallback, minimum, maximum) => {
48
+ if (typeof value !== 'number' || !Number.isFinite(value)) {
49
+ return fallback;
50
+ }
51
+ const rounded = Math.round(value);
52
+ if (rounded < minimum) {
53
+ return minimum;
54
+ }
55
+ if (rounded > maximum) {
56
+ return maximum;
57
+ }
58
+ return rounded;
59
+ };
60
+ const sanitizeSearchPackConfig = (value) => {
61
+ const fallback = defaultBrainlinkConfig.searchPack;
62
+ if (!isRecord(value)) {
63
+ return fallback;
64
+ }
65
+ return {
66
+ rowChunkSize: sanitizeIntegerInRange(value.rowChunkSize, fallback.rowChunkSize, 100, 100_000),
67
+ compressionLevel: sanitizeIntegerInRange(value.compressionLevel, fallback.compressionLevel, 0, 11),
68
+ useDictionary: typeof value.useDictionary === 'boolean' ? value.useDictionary : fallback.useDictionary,
69
+ guardrailMinSavingsPercent: typeof value.guardrailMinSavingsPercent === 'number' && Number.isFinite(value.guardrailMinSavingsPercent)
70
+ ? Math.max(0, Math.min(95, value.guardrailMinSavingsPercent))
71
+ : fallback.guardrailMinSavingsPercent,
72
+ guardrailMaxLatencyRegressionPercent: typeof value.guardrailMaxLatencyRegressionPercent === 'number' && Number.isFinite(value.guardrailMaxLatencyRegressionPercent)
73
+ ? Math.max(0, Math.min(300, value.guardrailMaxLatencyRegressionPercent))
74
+ : fallback.guardrailMaxLatencyRegressionPercent
75
+ };
76
+ };
77
+ const sanitizeAgentProfile = (value) => {
78
+ if (!isRecord(value)) {
79
+ return null;
80
+ }
81
+ const defaultSearchLimit = sanitizePositiveNumber(value.defaultSearchLimit);
82
+ const defaultContextTokens = sanitizePositiveNumber(value.defaultContextTokens);
83
+ const defaultSearchMode = typeof value.defaultSearchMode === 'string' && searchModes.has(value.defaultSearchMode)
84
+ ? value.defaultSearchMode
85
+ : undefined;
86
+ const profile = {
87
+ ...(defaultSearchLimit ? { defaultSearchLimit } : {}),
88
+ ...(defaultContextTokens ? { defaultContextTokens } : {}),
89
+ ...(defaultSearchMode ? { defaultSearchMode } : {})
90
+ };
91
+ return Object.keys(profile).length > 0 ? profile : null;
92
+ };
93
+ const sanitizeAgentProfiles = (value) => {
94
+ if (!isRecord(value)) {
95
+ return {};
96
+ }
97
+ return Object.entries(value).reduce((state, [key, profile]) => {
98
+ const normalizedKey = key === '*' ? '*' : sanitizeAgentId(key);
99
+ const sanitizedProfile = sanitizeAgentProfile(profile);
100
+ if (!sanitizedProfile || normalizedKey.length === 0) {
101
+ return state;
102
+ }
103
+ return {
104
+ ...state,
105
+ [normalizedKey]: sanitizedProfile
106
+ };
107
+ }, {});
108
+ };
25
109
  const readAllowedVaultsFromEnv = () => (process.env.BRAINLINK_ALLOWED_VAULTS ?? '')
26
110
  .split(',')
27
111
  .map((value) => value.trim())
@@ -39,6 +123,34 @@ const readJsonConfig = async (path) => {
39
123
  throw error;
40
124
  }
41
125
  };
126
+ export const getGlobalConfigPath = () => join(getBrainlinkHomePath(), globalConfigFilename);
127
+ export const getLocalConfigPath = (cwd = safeCwd()) => resolve(cwd, localConfigFilename);
128
+ export const resolveConfigPath = (scope, cwd = safeCwd()) => scope === 'global' ? getGlobalConfigPath() : getLocalConfigPath(cwd);
129
+ export const loadRawConfig = async (scope, cwd = safeCwd()) => readJsonConfig(resolveConfigPath(scope, cwd));
130
+ export const loadLegacyLocalRawConfig = async (cwd = safeCwd()) => readJsonConfig(resolve(cwd, '.brainlink.json'));
131
+ export const detectVaultConfigSource = async (cwd = safeCwd()) => {
132
+ const [globalConfig, localConfig, legacyLocalConfig] = await Promise.all([
133
+ loadRawConfig('global', cwd),
134
+ loadRawConfig('local', cwd),
135
+ loadLegacyLocalRawConfig(cwd)
136
+ ]);
137
+ if (typeof legacyLocalConfig.vault === 'string' && legacyLocalConfig.vault.trim().length > 0) {
138
+ return 'local-legacy';
139
+ }
140
+ if (typeof localConfig.vault === 'string' && localConfig.vault.trim().length > 0) {
141
+ return 'local';
142
+ }
143
+ if (typeof globalConfig.vault === 'string' && globalConfig.vault.trim().length > 0) {
144
+ return 'global';
145
+ }
146
+ return 'default';
147
+ };
148
+ export const writeRawConfig = async (scope, value, cwd = safeCwd()) => {
149
+ const path = resolveConfigPath(scope, cwd);
150
+ await mkdir(dirname(path), { recursive: true, mode: globalConfigDirectoryMode });
151
+ await writeFile(path, `${JSON.stringify(value, null, 2)}\n`, { encoding: 'utf8', mode: globalConfigFileMode });
152
+ return path;
153
+ };
42
154
  const sanitizeConfig = (value) => ({
43
155
  ...defaultBrainlinkConfig,
44
156
  ...value,
@@ -55,12 +167,24 @@ const sanitizeConfig = (value) => ({
55
167
  : defaultBrainlinkConfig.defaultContextTokens,
56
168
  allowedVaults: [...sanitizeAllowedVaults(value.allowedVaults), ...readAllowedVaultsFromEnv()],
57
169
  chunkSize: typeof value.chunkSize === 'number' && value.chunkSize > 0 ? value.chunkSize : defaultBrainlinkConfig.chunkSize,
170
+ searchPack: sanitizeSearchPackConfig(value.searchPack),
58
171
  embeddingProvider: sanitizeEmbeddingProvider(value.embeddingProvider),
59
- defaultSearchMode: sanitizeSearchMode(value.defaultSearchMode)
172
+ defaultSearchMode: sanitizeSearchMode(value.defaultSearchMode),
173
+ agentProfiles: sanitizeAgentProfiles(value.agentProfiles)
60
174
  });
61
- export const loadBrainlinkConfig = async (cwd = process.cwd()) => {
62
- const configs = await Promise.all(configFilenames.map((filename) => readJsonConfig(resolve(cwd, filename))));
63
- const merged = configs.reduce((state, config) => ({
175
+ export const resolveAgentRuntimeDefaults = (config, agent) => {
176
+ const normalizedAgent = agent?.trim().length ? sanitizeAgentId(agent) : undefined;
177
+ const profile = (normalizedAgent ? config.agentProfiles[normalizedAgent] : undefined) ?? config.agentProfiles['*'];
178
+ return {
179
+ defaultSearchLimit: profile?.defaultSearchLimit ?? config.defaultSearchLimit,
180
+ defaultContextTokens: profile?.defaultContextTokens ?? config.defaultContextTokens,
181
+ defaultSearchMode: profile?.defaultSearchMode ?? config.defaultSearchMode
182
+ };
183
+ };
184
+ export const loadBrainlinkConfig = async (cwd = safeCwd()) => {
185
+ const globalConfig = await readJsonConfig(getGlobalConfigPath());
186
+ const localConfigs = await Promise.all(configFilenames.map((filename) => readJsonConfig(resolve(cwd, filename))));
187
+ const merged = [globalConfig, ...localConfigs].reduce((state, config) => ({
64
188
  ...state,
65
189
  ...config
66
190
  }), {});