@andespindola/brainlink 0.1.0-beta.3 → 0.1.0-beta.30

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 (56) hide show
  1. package/AGENTS.md +5 -5
  2. package/CHANGELOG.md +37 -3
  3. package/CONTRIBUTING.md +2 -2
  4. package/COPYRIGHT.md +5 -0
  5. package/README.md +172 -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/frontend/client-css.js +214 -100
  10. package/dist/application/frontend/client-html.js +60 -45
  11. package/dist/application/frontend/client-js.js +525 -88
  12. package/dist/application/get-graph-layout.js +22 -7
  13. package/dist/application/get-graph-node.js +12 -0
  14. package/dist/application/get-graph-summary.js +12 -0
  15. package/dist/application/get-graph.js +3 -3
  16. package/dist/application/import-legacy-sqlite.js +296 -0
  17. package/dist/application/index-vault.js +11 -4
  18. package/dist/application/list-agents.js +3 -3
  19. package/dist/application/list-links.js +5 -5
  20. package/dist/application/migrate-vault.js +91 -0
  21. package/dist/application/search-graph-node-ids.js +12 -0
  22. package/dist/application/search-knowledge.js +75 -5
  23. package/dist/application/server/routes.js +27 -1
  24. package/dist/benchmarks/large-vault.js +1 -1
  25. package/dist/cli/commands/agent-commands.js +412 -0
  26. package/dist/cli/commands/config-commands.js +167 -0
  27. package/dist/cli/commands/read-commands.js +25 -8
  28. package/dist/cli/commands/write-commands.js +205 -4
  29. package/dist/cli/main.js +4 -0
  30. package/dist/cli/runtime.js +5 -2
  31. package/dist/domain/context.js +53 -11
  32. package/dist/domain/embeddings.js +2 -1
  33. package/dist/domain/graph-layout.js +20 -14
  34. package/dist/domain/markdown.js +36 -4
  35. package/dist/domain/middle-out.js +18 -0
  36. package/dist/infrastructure/config.js +94 -8
  37. package/dist/infrastructure/file-index.js +294 -0
  38. package/dist/infrastructure/file-system-vault.js +15 -0
  39. package/dist/infrastructure/paths.js +9 -1
  40. package/dist/infrastructure/private-pack-codec.js +73 -0
  41. package/dist/infrastructure/search-packs.js +348 -0
  42. package/dist/infrastructure/session-state.js +172 -0
  43. package/dist/mcp/main.js +11 -3
  44. package/dist/mcp/server.js +17 -2
  45. package/dist/mcp/startup.js +35 -0
  46. package/dist/mcp/tools.js +571 -19
  47. package/docs/AGENT_USAGE.md +112 -16
  48. package/docs/ARCHITECTURE.md +37 -26
  49. package/docs/QUICKSTART.md +111 -0
  50. package/package.json +2 -3
  51. package/dist/infrastructure/sqlite/document-writer.js +0 -51
  52. package/dist/infrastructure/sqlite/graph-reader.js +0 -120
  53. package/dist/infrastructure/sqlite/schema.js +0 -111
  54. package/dist/infrastructure/sqlite/search-reader.js +0 -156
  55. package/dist/infrastructure/sqlite/types.js +0 -1
  56. package/dist/infrastructure/sqlite-index.js +0 -25
@@ -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,61 @@ export const defaultBrainlinkConfig = {
13
14
  defaultContextTokens: 2000,
14
15
  embeddingProvider: 'local',
15
16
  defaultSearchMode: 'hybrid',
16
- chunkSize: 1200
17
+ chunkSize: 1200,
18
+ agentProfiles: {}
17
19
  };
18
20
  const configFilenames = ['brainlink.config.json', '.brainlink.json'];
21
+ const localConfigFilename = 'brainlink.config.json';
22
+ const globalConfigFilename = 'brainlink.config.json';
23
+ const globalConfigDirectoryMode = 0o700;
24
+ const globalConfigFileMode = 0o600;
25
+ const safeCwd = () => {
26
+ try {
27
+ return process.cwd();
28
+ }
29
+ catch {
30
+ return homedir();
31
+ }
32
+ };
19
33
  const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
20
34
  const embeddingProviders = new Set(['none', 'local']);
21
35
  const searchModes = new Set(['fts', 'semantic', 'hybrid']);
22
36
  const sanitizeEmbeddingProvider = (value) => typeof value === 'string' && embeddingProviders.has(value) ? value : defaultBrainlinkConfig.embeddingProvider;
23
37
  export const sanitizeSearchMode = (value, fallback = defaultBrainlinkConfig.defaultSearchMode) => typeof value === 'string' && searchModes.has(value) ? value : fallback;
24
38
  const sanitizeAllowedVaults = (value) => Array.isArray(value) ? value.filter((item) => typeof item === 'string' && item.trim().length > 0) : [];
39
+ const sanitizePositiveNumber = (value) => typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : undefined;
40
+ const sanitizeAgentProfile = (value) => {
41
+ if (!isRecord(value)) {
42
+ return null;
43
+ }
44
+ const defaultSearchLimit = sanitizePositiveNumber(value.defaultSearchLimit);
45
+ const defaultContextTokens = sanitizePositiveNumber(value.defaultContextTokens);
46
+ const defaultSearchMode = typeof value.defaultSearchMode === 'string' && searchModes.has(value.defaultSearchMode)
47
+ ? value.defaultSearchMode
48
+ : undefined;
49
+ const profile = {
50
+ ...(defaultSearchLimit ? { defaultSearchLimit } : {}),
51
+ ...(defaultContextTokens ? { defaultContextTokens } : {}),
52
+ ...(defaultSearchMode ? { defaultSearchMode } : {})
53
+ };
54
+ return Object.keys(profile).length > 0 ? profile : null;
55
+ };
56
+ const sanitizeAgentProfiles = (value) => {
57
+ if (!isRecord(value)) {
58
+ return {};
59
+ }
60
+ return Object.entries(value).reduce((state, [key, profile]) => {
61
+ const normalizedKey = key === '*' ? '*' : sanitizeAgentId(key);
62
+ const sanitizedProfile = sanitizeAgentProfile(profile);
63
+ if (!sanitizedProfile || normalizedKey.length === 0) {
64
+ return state;
65
+ }
66
+ return {
67
+ ...state,
68
+ [normalizedKey]: sanitizedProfile
69
+ };
70
+ }, {});
71
+ };
25
72
  const readAllowedVaultsFromEnv = () => (process.env.BRAINLINK_ALLOWED_VAULTS ?? '')
26
73
  .split(',')
27
74
  .map((value) => value.trim())
@@ -39,6 +86,34 @@ const readJsonConfig = async (path) => {
39
86
  throw error;
40
87
  }
41
88
  };
89
+ export const getGlobalConfigPath = () => join(getBrainlinkHomePath(), globalConfigFilename);
90
+ export const getLocalConfigPath = (cwd = safeCwd()) => resolve(cwd, localConfigFilename);
91
+ export const resolveConfigPath = (scope, cwd = safeCwd()) => scope === 'global' ? getGlobalConfigPath() : getLocalConfigPath(cwd);
92
+ export const loadRawConfig = async (scope, cwd = safeCwd()) => readJsonConfig(resolveConfigPath(scope, cwd));
93
+ export const loadLegacyLocalRawConfig = async (cwd = safeCwd()) => readJsonConfig(resolve(cwd, '.brainlink.json'));
94
+ export const detectVaultConfigSource = async (cwd = safeCwd()) => {
95
+ const [globalConfig, localConfig, legacyLocalConfig] = await Promise.all([
96
+ loadRawConfig('global', cwd),
97
+ loadRawConfig('local', cwd),
98
+ loadLegacyLocalRawConfig(cwd)
99
+ ]);
100
+ if (typeof legacyLocalConfig.vault === 'string' && legacyLocalConfig.vault.trim().length > 0) {
101
+ return 'local-legacy';
102
+ }
103
+ if (typeof localConfig.vault === 'string' && localConfig.vault.trim().length > 0) {
104
+ return 'local';
105
+ }
106
+ if (typeof globalConfig.vault === 'string' && globalConfig.vault.trim().length > 0) {
107
+ return 'global';
108
+ }
109
+ return 'default';
110
+ };
111
+ export const writeRawConfig = async (scope, value, cwd = safeCwd()) => {
112
+ const path = resolveConfigPath(scope, cwd);
113
+ await mkdir(dirname(path), { recursive: true, mode: globalConfigDirectoryMode });
114
+ await writeFile(path, `${JSON.stringify(value, null, 2)}\n`, { encoding: 'utf8', mode: globalConfigFileMode });
115
+ return path;
116
+ };
42
117
  const sanitizeConfig = (value) => ({
43
118
  ...defaultBrainlinkConfig,
44
119
  ...value,
@@ -56,11 +131,22 @@ const sanitizeConfig = (value) => ({
56
131
  allowedVaults: [...sanitizeAllowedVaults(value.allowedVaults), ...readAllowedVaultsFromEnv()],
57
132
  chunkSize: typeof value.chunkSize === 'number' && value.chunkSize > 0 ? value.chunkSize : defaultBrainlinkConfig.chunkSize,
58
133
  embeddingProvider: sanitizeEmbeddingProvider(value.embeddingProvider),
59
- defaultSearchMode: sanitizeSearchMode(value.defaultSearchMode)
134
+ defaultSearchMode: sanitizeSearchMode(value.defaultSearchMode),
135
+ agentProfiles: sanitizeAgentProfiles(value.agentProfiles)
60
136
  });
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) => ({
137
+ export const resolveAgentRuntimeDefaults = (config, agent) => {
138
+ const normalizedAgent = agent?.trim().length ? sanitizeAgentId(agent) : undefined;
139
+ const profile = (normalizedAgent ? config.agentProfiles[normalizedAgent] : undefined) ?? config.agentProfiles['*'];
140
+ return {
141
+ defaultSearchLimit: profile?.defaultSearchLimit ?? config.defaultSearchLimit,
142
+ defaultContextTokens: profile?.defaultContextTokens ?? config.defaultContextTokens,
143
+ defaultSearchMode: profile?.defaultSearchMode ?? config.defaultSearchMode
144
+ };
145
+ };
146
+ export const loadBrainlinkConfig = async (cwd = safeCwd()) => {
147
+ const globalConfig = await readJsonConfig(getGlobalConfigPath());
148
+ const localConfigs = await Promise.all(configFilenames.map((filename) => readJsonConfig(resolve(cwd, filename))));
149
+ const merged = [globalConfig, ...localConfigs].reduce((state, config) => ({
64
150
  ...state,
65
151
  ...config
66
152
  }), {});
@@ -0,0 +1,294 @@
1
+ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
2
+ import { dirname, join } from 'node:path';
3
+ import { cosineSimilarity } from '../domain/embeddings.js';
4
+ const queryTokenPattern = /[\p{L}\p{N}_-]+/gu;
5
+ const emptyIndex = () => ({
6
+ version: 1,
7
+ updatedAt: new Date().toISOString(),
8
+ documents: [],
9
+ chunks: [],
10
+ links: []
11
+ });
12
+ export const indexStoragePath = (vaultPath) => join(vaultPath, '.brainlink', 'index.json');
13
+ const readIndex = async (vaultPath) => {
14
+ try {
15
+ const parsed = JSON.parse(await readFile(indexStoragePath(vaultPath), 'utf8'));
16
+ return {
17
+ version: 1,
18
+ updatedAt: typeof parsed.updatedAt === 'string' ? parsed.updatedAt : new Date().toISOString(),
19
+ documents: Array.isArray(parsed.documents) ? parsed.documents : [],
20
+ chunks: Array.isArray(parsed.chunks) ? parsed.chunks : [],
21
+ links: Array.isArray(parsed.links) ? parsed.links : []
22
+ };
23
+ }
24
+ catch (error) {
25
+ if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
26
+ return emptyIndex();
27
+ }
28
+ return emptyIndex();
29
+ }
30
+ };
31
+ const writeIndex = async (vaultPath, index) => {
32
+ const target = indexStoragePath(vaultPath);
33
+ const temp = `${target}.tmp`;
34
+ await mkdir(dirname(target), { recursive: true, mode: 0o700 });
35
+ await writeFile(temp, `${JSON.stringify(index)}\n`, { encoding: 'utf8', mode: 0o600 });
36
+ await rename(temp, target);
37
+ };
38
+ const normalizeToken = (value) => value
39
+ .normalize('NFKD')
40
+ .replace(/\p{Diacritic}/gu, '')
41
+ .toLowerCase();
42
+ const tokenize = (query) => query
43
+ .match(queryTokenPattern)
44
+ ?.map(normalizeToken)
45
+ .filter((token) => token.length > 1) ?? [];
46
+ const countOccurrences = (text, token) => {
47
+ let hits = 0;
48
+ let cursor = 0;
49
+ while (cursor < text.length) {
50
+ const index = text.indexOf(token, cursor);
51
+ if (index < 0) {
52
+ break;
53
+ }
54
+ hits += 1;
55
+ cursor = index + token.length;
56
+ }
57
+ return hits;
58
+ };
59
+ const textScore = (row, tokens) => {
60
+ if (tokens.length === 0) {
61
+ return 0;
62
+ }
63
+ const title = normalizeToken(row.title);
64
+ const path = normalizeToken(row.path);
65
+ const content = normalizeToken(row.content);
66
+ const tags = normalizeToken(row.tags.join(' '));
67
+ return tokens.reduce((score, token) => {
68
+ const titleHits = countOccurrences(title, token);
69
+ const tagHits = countOccurrences(tags, token);
70
+ const pathHits = countOccurrences(path, token);
71
+ const contentHits = countOccurrences(content, token);
72
+ return score + titleHits * 5 + tagHits * 4 + pathHits * 2 + Math.min(contentHits, 6);
73
+ }, 0);
74
+ };
75
+ const semanticScore = (row, queryEmbedding) => queryEmbedding.length > 0 && row.embedding.length > 0 ? cosineSimilarity(queryEmbedding, row.embedding) : 0;
76
+ const toResult = (row, mode, text, semantic) => {
77
+ const score = mode === 'fts' ? text : mode === 'semantic' ? semantic : text + semantic * 8;
78
+ return {
79
+ documentId: row.documentId,
80
+ agentId: row.agentId,
81
+ title: row.title,
82
+ path: row.path,
83
+ chunkId: row.chunkId,
84
+ chunkOrdinal: row.chunkOrdinal,
85
+ content: row.content,
86
+ score,
87
+ textScore: text,
88
+ semanticScore: semantic,
89
+ searchMode: mode,
90
+ tags: row.tags
91
+ };
92
+ };
93
+ const toGraphLink = (link, documentsById) => {
94
+ const source = documentsById.get(link.fromDocumentId);
95
+ const target = link.toDocumentId ? documentsById.get(link.toDocumentId) : undefined;
96
+ return {
97
+ agentId: source?.agentId ?? 'shared',
98
+ fromTitle: source?.title ?? 'Unknown',
99
+ fromPath: source?.path ?? 'Unknown',
100
+ toTitle: target?.title ?? link.toTitle,
101
+ toPath: target?.path ?? null,
102
+ weight: link.weight,
103
+ priority: link.priority
104
+ };
105
+ };
106
+ export const openFileIndex = (vaultPath) => {
107
+ const load = async () => readIndex(vaultPath);
108
+ const persist = async (index) => writeIndex(vaultPath, index);
109
+ return {
110
+ reset: async () => {
111
+ await persist(emptyIndex());
112
+ },
113
+ saveDocuments: async (documents) => {
114
+ const chunks = documents.flatMap((document) => document.chunks);
115
+ const links = documents.flatMap((document) => document.links);
116
+ await persist({
117
+ version: 1,
118
+ updatedAt: new Date().toISOString(),
119
+ documents: documents.map((document) => document.document),
120
+ chunks,
121
+ links
122
+ });
123
+ },
124
+ search: async (query, limit, agentId, mode = 'hybrid', queryEmbedding = []) => {
125
+ const index = await load();
126
+ const documentsById = new Map(index.documents.map((document) => [document.id, document]));
127
+ const rows = index.chunks.flatMap((chunk) => {
128
+ const document = documentsById.get(chunk.documentId);
129
+ if (!document) {
130
+ return [];
131
+ }
132
+ if (agentId && document.agentId !== agentId) {
133
+ return [];
134
+ }
135
+ return [
136
+ {
137
+ documentId: document.id,
138
+ agentId: document.agentId,
139
+ title: document.title,
140
+ path: document.path,
141
+ chunkId: chunk.id,
142
+ chunkOrdinal: chunk.ordinal,
143
+ content: chunk.content,
144
+ tags: document.tags,
145
+ embedding: chunk.embedding
146
+ }
147
+ ];
148
+ });
149
+ const tokens = tokenize(query);
150
+ const results = rows
151
+ .map((row) => {
152
+ const text = textScore(row, tokens);
153
+ const semantic = semanticScore(row, queryEmbedding);
154
+ return toResult(row, mode, text, semantic);
155
+ })
156
+ .filter((row) => row.score > 0 || tokens.length === 0)
157
+ .sort((left, right) => right.score - left.score || left.title.localeCompare(right.title))
158
+ .slice(0, Math.max(0, limit));
159
+ return results;
160
+ },
161
+ listLinks: async (agentId) => {
162
+ const index = await load();
163
+ const documentsById = new Map(index.documents.map((document) => [document.id, document]));
164
+ return index.links
165
+ .filter((link) => {
166
+ const source = documentsById.get(link.fromDocumentId);
167
+ return agentId ? source?.agentId === agentId : true;
168
+ })
169
+ .map((link) => toGraphLink(link, documentsById))
170
+ .sort((left, right) => left.fromTitle.localeCompare(right.fromTitle));
171
+ },
172
+ listBacklinks: async (title, agentId) => {
173
+ const index = await load();
174
+ const titleKey = title.toLowerCase();
175
+ const documentsById = new Map(index.documents.map((document) => [document.id, document]));
176
+ return index.links
177
+ .filter((link) => link.toTitle.toLowerCase() === titleKey)
178
+ .filter((link) => {
179
+ const source = documentsById.get(link.fromDocumentId);
180
+ return agentId ? source?.agentId === agentId : true;
181
+ })
182
+ .map((link) => toGraphLink(link, documentsById))
183
+ .sort((left, right) => right.weight - left.weight || left.fromTitle.localeCompare(right.fromTitle));
184
+ },
185
+ getGraph: async (agentId) => {
186
+ const index = await load();
187
+ const documents = agentId ? index.documents.filter((document) => document.agentId === agentId) : index.documents;
188
+ const documentIds = new Set(documents.map((document) => document.id));
189
+ const edges = index.links
190
+ .filter((link) => documentIds.has(link.fromDocumentId))
191
+ .map((link) => ({
192
+ source: link.fromDocumentId,
193
+ target: link.toDocumentId,
194
+ targetTitle: link.toTitle,
195
+ weight: link.weight,
196
+ priority: link.priority
197
+ }));
198
+ return {
199
+ nodes: documents.map((document) => ({
200
+ id: document.id,
201
+ agentId: document.agentId,
202
+ title: document.title,
203
+ path: document.path,
204
+ content: document.content,
205
+ tags: document.tags
206
+ })),
207
+ edges
208
+ };
209
+ },
210
+ getGraphSummary: async (agentId) => {
211
+ const graph = await (async () => {
212
+ const index = await load();
213
+ const documents = agentId ? index.documents.filter((document) => document.agentId === agentId) : index.documents;
214
+ const documentIds = new Set(documents.map((document) => document.id));
215
+ const edges = index.links
216
+ .filter((link) => documentIds.has(link.fromDocumentId))
217
+ .map((link) => ({
218
+ source: link.fromDocumentId,
219
+ target: link.toDocumentId,
220
+ targetTitle: link.toTitle,
221
+ weight: link.weight,
222
+ priority: link.priority
223
+ }));
224
+ return {
225
+ nodes: documents.map((document) => ({
226
+ id: document.id,
227
+ agentId: document.agentId,
228
+ title: document.title,
229
+ path: document.path,
230
+ content: '',
231
+ tags: document.tags
232
+ })),
233
+ edges
234
+ };
235
+ })();
236
+ return graph;
237
+ },
238
+ getGraphNode: async (id, agentId) => {
239
+ const index = await load();
240
+ const document = index.documents.find((row) => row.id === id && (!agentId || row.agentId === agentId));
241
+ return document
242
+ ? {
243
+ id: document.id,
244
+ agentId: document.agentId,
245
+ title: document.title,
246
+ path: document.path,
247
+ content: document.content,
248
+ tags: document.tags
249
+ }
250
+ : undefined;
251
+ },
252
+ searchGraphNodeIds: async (query, limit, agentId) => {
253
+ const index = await load();
254
+ const normalized = normalizeToken(query);
255
+ if (normalized.length === 0 || limit <= 0) {
256
+ return [];
257
+ }
258
+ const tokens = tokenize(query);
259
+ const scored = index.documents
260
+ .filter((document) => (!agentId || document.agentId === agentId))
261
+ .map((document) => {
262
+ const score = textScore({
263
+ documentId: document.id,
264
+ agentId: document.agentId,
265
+ title: document.title,
266
+ path: document.path,
267
+ chunkId: document.id,
268
+ chunkOrdinal: 0,
269
+ content: document.content,
270
+ tags: document.tags,
271
+ embedding: []
272
+ }, tokens);
273
+ return { id: document.id, score };
274
+ })
275
+ .filter((row) => row.score > 0)
276
+ .sort((left, right) => right.score - left.score || left.id.localeCompare(right.id))
277
+ .slice(0, limit);
278
+ return scored.map((row) => row.id);
279
+ },
280
+ listAgents: async () => {
281
+ const index = await load();
282
+ const counts = index.documents.reduce((state, document) => {
283
+ state.set(document.agentId, (state.get(document.agentId) ?? 0) + 1);
284
+ return state;
285
+ }, new Map());
286
+ return Array.from(counts.entries())
287
+ .sort((left, right) => left[0].localeCompare(right[0]))
288
+ .map(([id, documentCount]) => ({ id, documentCount }));
289
+ },
290
+ close: () => {
291
+ // File-based index has no persistent connection.
292
+ }
293
+ };
294
+ };
@@ -16,6 +16,17 @@ const walkMarkdownFiles = async (directory) => {
16
16
  }));
17
17
  return nested.flat();
18
18
  };
19
+ const walkVaultFiles = async (directory) => {
20
+ const entries = await readdir(directory, { withFileTypes: true });
21
+ const nested = await Promise.all(entries.map(async (entry) => {
22
+ const absolutePath = join(directory, entry.name);
23
+ if (entry.isDirectory()) {
24
+ return excludedDirectories.has(entry.name) ? [] : walkVaultFiles(absolutePath);
25
+ }
26
+ return entry.isFile() ? [absolutePath] : [];
27
+ }));
28
+ return nested.flat();
29
+ };
19
30
  export const resolveVaultPath = (vaultPath) => isBucketVaultUri(vaultPath) ? getBucketVaultCachePath(vaultPath) : resolvePath(vaultPath);
20
31
  export const isBucketVaultPath = (vaultPath) => isBucketVaultUri(vaultPath);
21
32
  const isPathInside = (parent, child) => {
@@ -65,6 +76,10 @@ export const readMarkdownFiles = async (vaultPath) => {
65
76
  };
66
77
  }));
67
78
  };
79
+ export const listVaultFiles = async (vaultPath) => {
80
+ const absoluteVaultPath = await ensureVault(vaultPath);
81
+ return walkVaultFiles(absoluteVaultPath);
82
+ };
68
83
  export const writeMarkdownFile = async (vaultPath, filename, content) => {
69
84
  if (isBucketVaultUri(vaultPath)) {
70
85
  return writeBucketMarkdownFile(vaultPath, filename, content);
@@ -2,8 +2,16 @@ import { homedir } from 'node:os';
2
2
  import { isAbsolute, join, resolve } from 'node:path';
3
3
  const defaultHomeDirectoryName = '.brainlink';
4
4
  const defaultVaultDirectoryName = 'vault';
5
+ const resolveSafeCwd = () => {
6
+ try {
7
+ return process.cwd();
8
+ }
9
+ catch {
10
+ return homedir();
11
+ }
12
+ };
5
13
  export const expandHomePath = (path) => path === '~' || path.startsWith('~/') ? join(homedir(), path.slice(2)) : path;
6
- export const resolvePath = (path, cwd = process.cwd()) => {
14
+ export const resolvePath = (path, cwd = resolveSafeCwd()) => {
7
15
  const expandedPath = expandHomePath(path);
8
16
  return isAbsolute(expandedPath) ? expandedPath : resolve(cwd, expandedPath);
9
17
  };
@@ -0,0 +1,73 @@
1
+ import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto';
2
+ import { brotliCompressSync, brotliDecompressSync } from 'node:zlib';
3
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
4
+ import { dirname, join } from 'node:path';
5
+ import { getBrainlinkHomePath } from './paths.js';
6
+ const magic = Buffer.from('BLPK2', 'ascii');
7
+ const version = 1;
8
+ const nonceLength = 12;
9
+ const authTagLength = 16;
10
+ const algorithm = 'aes-256-gcm';
11
+ const keyFilePath = (vaultPath) => {
12
+ const vaultHash = createHash('sha256').update(vaultPath).digest('hex').slice(0, 24);
13
+ return join(getBrainlinkHomePath(), 'keys', `search-pack-${vaultHash}.key`);
14
+ };
15
+ const deriveKeyFromSecret = (secret) => createHash('sha256').update(secret, 'utf8').digest();
16
+ const readOrCreateKey = async (vaultPath) => {
17
+ const envSecret = process.env.BRAINLINK_SEARCH_PACK_KEY?.trim();
18
+ if (envSecret && envSecret.length > 0) {
19
+ return deriveKeyFromSecret(envSecret);
20
+ }
21
+ const path = keyFilePath(vaultPath);
22
+ try {
23
+ const existing = (await readFile(path, 'utf8')).trim();
24
+ if (existing.length > 0) {
25
+ return deriveKeyFromSecret(existing);
26
+ }
27
+ }
28
+ catch (error) {
29
+ if (!(error instanceof Error) || !('code' in error) || error.code !== 'ENOENT') {
30
+ throw error;
31
+ }
32
+ }
33
+ const secret = randomBytes(48).toString('base64url');
34
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
35
+ await writeFile(path, `${secret}\n`, { encoding: 'utf8', mode: 0o600 });
36
+ return deriveKeyFromSecret(secret);
37
+ };
38
+ const parseHeader = (payload) => {
39
+ if (payload.length < magic.length + 1 + nonceLength + authTagLength) {
40
+ throw new Error('Invalid private pack payload: too short.');
41
+ }
42
+ const payloadMagic = payload.subarray(0, magic.length);
43
+ const payloadVersion = payload[magic.length];
44
+ if (!payloadMagic.equals(magic) || payloadVersion !== version) {
45
+ throw new Error('Invalid private pack payload: unsupported format.');
46
+ }
47
+ const nonceStart = magic.length + 1;
48
+ const authTagStart = nonceStart + nonceLength;
49
+ const dataStart = authTagStart + authTagLength;
50
+ return {
51
+ nonce: payload.subarray(nonceStart, authTagStart),
52
+ authTag: payload.subarray(authTagStart, dataStart),
53
+ ciphertext: payload.subarray(dataStart)
54
+ };
55
+ };
56
+ export const encodePrivatePack = async (vaultPath, content) => {
57
+ const key = await readOrCreateKey(vaultPath);
58
+ const nonce = randomBytes(nonceLength);
59
+ const compressed = brotliCompressSync(content);
60
+ const cipher = createCipheriv(algorithm, key, nonce);
61
+ const ciphertext = Buffer.concat([cipher.update(compressed), cipher.final()]);
62
+ const authTag = cipher.getAuthTag();
63
+ return Buffer.concat([magic, Buffer.from([version]), nonce, authTag, ciphertext]);
64
+ };
65
+ export const decodePrivatePack = async (vaultPath, payload) => {
66
+ const key = await readOrCreateKey(vaultPath);
67
+ const { nonce, authTag, ciphertext } = parseHeader(payload);
68
+ const decipher = createDecipheriv(algorithm, key, nonce);
69
+ decipher.setAuthTag(authTag);
70
+ const compressed = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
71
+ return brotliDecompressSync(compressed);
72
+ };
73
+ export const isPrivatePackPayload = (payload) => payload.length >= magic.length + 1 && payload.subarray(0, magic.length).equals(magic);