@andespindola/brainlink 0.1.0-beta.40 → 0.1.0-beta.42

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.
@@ -0,0 +1,66 @@
1
+ export const createClientWorkerJs = () => `const normalize = value => String(value || '')
2
+ .normalize('NFKD')
3
+ .replace(/\\p{Diacritic}/gu, '')
4
+ .toLowerCase()
5
+
6
+ let nodeIndex = []
7
+
8
+ const toNodeIndex = nodes =>
9
+ (Array.isArray(nodes) ? nodes : [])
10
+ .map(node => {
11
+ const id = typeof node.id === 'string' ? node.id : ''
12
+ if (!id) {
13
+ return null
14
+ }
15
+ const title = normalize(node.title)
16
+ const path = normalize(node.path)
17
+ const tags = Array.isArray(node.tags) ? node.tags.map(tag => normalize(tag)) : []
18
+ return {
19
+ id,
20
+ text: [title, path, ...tags].join(' ')
21
+ }
22
+ })
23
+ .filter(Boolean)
24
+
25
+ const scoreText = (text, query) => {
26
+ if (!query) return 0
27
+ if (!text.includes(query)) return 0
28
+ if (text.startsWith(query)) return 4
29
+ return 1
30
+ }
31
+
32
+ const filterIds = (query, limit) => {
33
+ const normalizedQuery = normalize(query).trim()
34
+ if (!normalizedQuery) {
35
+ return []
36
+ }
37
+ const rows = []
38
+ for (let index = 0; index < nodeIndex.length; index += 1) {
39
+ const row = nodeIndex[index]
40
+ const score = scoreText(row.text, normalizedQuery)
41
+ if (score > 0) {
42
+ rows.push({ id: row.id, score })
43
+ }
44
+ }
45
+ rows.sort((left, right) => right.score - left.score || left.id.localeCompare(right.id))
46
+ return rows.slice(0, Math.max(1, Number.isFinite(limit) ? limit : rows.length)).map(row => row.id)
47
+ }
48
+
49
+ self.onmessage = event => {
50
+ const payload = event.data
51
+ if (!payload || typeof payload !== 'object') {
52
+ return
53
+ }
54
+ if (payload.type === 'load-nodes') {
55
+ nodeIndex = toNodeIndex(payload.nodes)
56
+ return
57
+ }
58
+ if (payload.type === 'filter') {
59
+ const token = payload.token
60
+ const ids = filterIds(payload.query, payload.limit)
61
+ self.postMessage({ type: 'filter-result', token, ids })
62
+ }
63
+ }
64
+
65
+ self.postMessage({ type: 'ready' })
66
+ `;
@@ -1,8 +1,11 @@
1
+ import { readFile, stat } from 'node:fs/promises';
2
+ import { join } from 'node:path';
1
3
  import { createIndexedDocument, parseMarkdownDocument } from '../domain/markdown.js';
2
4
  import { sharedAgentId } from '../domain/agents.js';
3
5
  import { createEmbeddingProvider } from '../domain/embeddings.js';
4
6
  import { loadBrainlinkConfig } from '../infrastructure/config.js';
5
- import { ensureVault, readMarkdownFiles } from '../infrastructure/file-system-vault.js';
7
+ import { ensureVault, readMarkdownFileSummaries } from '../infrastructure/file-system-vault.js';
8
+ import { readIndexState, writeIndexState } from '../infrastructure/index-state.js';
6
9
  import { buildSearchPacks } from '../infrastructure/search-packs.js';
7
10
  import { openFileIndex } from '../infrastructure/file-index.js';
8
11
  const toTitleKey = (title) => title.toLowerCase();
@@ -34,6 +37,9 @@ const createScopedTitleResolver = (document, titleMaps) => ({
34
37
  get: (title) => titleMaps.byAgent.get(document.agentId)?.get(title)?.id ?? titleMaps.shared.get(title)?.id
35
38
  });
36
39
  const embedIndexedDocuments = async (documents, providerName) => {
40
+ if (documents.length === 0) {
41
+ return documents;
42
+ }
37
43
  const provider = createEmbeddingProvider(providerName);
38
44
  const chunks = documents.flatMap((document) => document.chunks);
39
45
  const embeddings = await provider.embed(chunks.map((chunk) => chunk.content));
@@ -47,34 +53,144 @@ const embedIndexedDocuments = async (documents, providerName) => {
47
53
  }))
48
54
  }));
49
55
  };
56
+ const relinkIndexedDocument = (indexedDocument, titleMaps) => {
57
+ const resolver = createScopedTitleResolver(indexedDocument.document, titleMaps);
58
+ return {
59
+ ...indexedDocument,
60
+ links: indexedDocument.links
61
+ .map((link) => ({
62
+ ...link,
63
+ toDocumentId: resolver.get(link.toTitle.toLowerCase()) ?? null
64
+ }))
65
+ .filter((link) => link.toDocumentId !== indexedDocument.document.id)
66
+ };
67
+ };
68
+ const toIndexResult = (documents) => ({
69
+ documentCount: documents.length,
70
+ chunkCount: documents.reduce((total, document) => total + document.chunks.length, 0),
71
+ linkCount: documents.reduce((total, document) => total + document.links.length, 0)
72
+ });
73
+ const toSnapshot = (summaries) => summaries.map((summary) => ({
74
+ path: summary.relativePath,
75
+ mtimeMs: summary.updatedAt.getTime(),
76
+ size: summary.size
77
+ }));
78
+ const createSnapshotMap = (snapshot) => new Map(snapshot.map((entry) => [entry.path, entry]));
79
+ const packManifestPath = (vaultPath) => join(vaultPath, '.brainlink', 'search-packs', 'manifest.json');
80
+ const hasPackManifest = async (vaultPath) => {
81
+ try {
82
+ await stat(packManifestPath(vaultPath));
83
+ return true;
84
+ }
85
+ catch {
86
+ return false;
87
+ }
88
+ };
89
+ const readChangedDocuments = async (absoluteVaultPath, changedSummaries) => {
90
+ const parsed = await Promise.all(changedSummaries.map(async (summary) => parseMarkdownDocument({
91
+ absolutePath: summary.absolutePath,
92
+ vaultPath: absoluteVaultPath,
93
+ content: await readFile(summary.absolutePath, 'utf8'),
94
+ createdAt: summary.createdAt,
95
+ updatedAt: summary.updatedAt
96
+ })));
97
+ return new Map(parsed.map((document) => [document.path, document]));
98
+ };
50
99
  export const indexVault = async (vaultPath) => {
51
100
  const absoluteVaultPath = await ensureVault(vaultPath);
52
101
  const config = await loadBrainlinkConfig();
53
- const files = await readMarkdownFiles(absoluteVaultPath);
54
- const documents = files.map((file) => parseMarkdownDocument({
55
- absolutePath: file.absolutePath,
56
- vaultPath: absoluteVaultPath,
57
- content: file.content,
58
- createdAt: file.createdAt,
59
- updatedAt: file.updatedAt
60
- }));
61
- const titleMaps = createTitleMaps(documents);
62
- const indexedDocuments = await embedIndexedDocuments(documents.map((document) => createIndexedDocument(document, createScopedTitleResolver(document, titleMaps), config.chunkSize)), config.embeddingProvider);
102
+ const [summaries, previousState] = await Promise.all([
103
+ readMarkdownFileSummaries(absoluteVaultPath),
104
+ readIndexState(absoluteVaultPath)
105
+ ]);
63
106
  const index = openFileIndex(absoluteVaultPath);
64
107
  try {
108
+ const existingIndexedDocuments = await index.getIndexedDocuments();
109
+ const existingByPath = new Map(existingIndexedDocuments.map((document) => [document.document.path, document]));
110
+ const currentSnapshot = toSnapshot(summaries);
111
+ const currentSnapshotMap = createSnapshotMap(currentSnapshot);
112
+ const previousSnapshotMap = createSnapshotMap(previousState?.files ?? []);
113
+ const settingsChanged = previousState == null ||
114
+ previousState.chunkSize !== config.chunkSize ||
115
+ previousState.embeddingProvider !== config.embeddingProvider;
116
+ const changedPaths = new Set();
117
+ for (let index = 0; index < summaries.length; index += 1) {
118
+ const summary = summaries[index];
119
+ const previous = previousSnapshotMap.get(summary.relativePath);
120
+ const changed = settingsChanged ||
121
+ previous == null ||
122
+ previous.mtimeMs !== summary.updatedAt.getTime() ||
123
+ previous.size !== summary.size ||
124
+ !existingByPath.has(summary.relativePath);
125
+ if (changed) {
126
+ changedPaths.add(summary.relativePath);
127
+ }
128
+ }
129
+ const hasDeletes = previousState
130
+ ? previousState.files.some((entry) => !currentSnapshotMap.has(entry.path))
131
+ : false;
132
+ if (changedPaths.size === 0 &&
133
+ !hasDeletes &&
134
+ existingIndexedDocuments.length === summaries.length &&
135
+ previousState != null) {
136
+ return toIndexResult(existingIndexedDocuments);
137
+ }
138
+ const changedSummaries = summaries.filter((summary) => changedPaths.has(summary.relativePath));
139
+ const changedDocumentsByPath = await readChangedDocuments(absoluteVaultPath, changedSummaries);
140
+ const documents = summaries.flatMap((summary) => {
141
+ const changed = changedDocumentsByPath.get(summary.relativePath);
142
+ if (changed) {
143
+ return [changed];
144
+ }
145
+ const existing = existingByPath.get(summary.relativePath);
146
+ return existing ? [existing.document] : [];
147
+ });
148
+ const titleMaps = createTitleMaps(documents);
149
+ const changedIndexedDocuments = changedDocumentsByPath.size > 0
150
+ ? await embedIndexedDocuments(Array.from(changedDocumentsByPath.values()).map((document) => createIndexedDocument(document, createScopedTitleResolver(document, titleMaps), config.chunkSize)), config.embeddingProvider)
151
+ : [];
152
+ const changedIndexedByPath = new Map(changedIndexedDocuments.map((document) => [document.document.path, document]));
153
+ const needsRelink = settingsChanged || hasDeletes || changedPaths.size > 0;
154
+ const indexedDocuments = documents.map((document) => {
155
+ const changed = changedIndexedByPath.get(document.path);
156
+ if (changed) {
157
+ return changed;
158
+ }
159
+ const existing = existingByPath.get(document.path);
160
+ if (!existing) {
161
+ return createIndexedDocument(document, createScopedTitleResolver(document, titleMaps), config.chunkSize);
162
+ }
163
+ return needsRelink ? relinkIndexedDocument(existing, titleMaps) : existing;
164
+ });
65
165
  await index.reset();
66
166
  await index.saveDocuments(indexedDocuments);
67
- try {
68
- await buildSearchPacks(absoluteVaultPath, indexedDocuments);
167
+ const existingPackManifest = await hasPackManifest(absoluteVaultPath);
168
+ const changedCount = changedPaths.size;
169
+ const documentCount = Math.max(indexedDocuments.length, 1);
170
+ const changeRatio = changedCount / documentCount;
171
+ const previousPendingPackChanges = previousState?.pendingPackChanges ?? 0;
172
+ const pendingPackChanges = previousPendingPackChanges + changedCount;
173
+ const shouldRebuildPacks = !existingPackManifest ||
174
+ settingsChanged ||
175
+ hasDeletes ||
176
+ changedCount >= 400 ||
177
+ changeRatio >= 0.04 ||
178
+ pendingPackChanges >= 1200;
179
+ if (shouldRebuildPacks) {
180
+ try {
181
+ await buildSearchPacks(absoluteVaultPath, indexedDocuments);
182
+ }
183
+ catch {
184
+ // Pack generation is best-effort. The JSON index remains the primary path.
185
+ }
69
186
  }
70
- catch {
71
- // Pack generation is best-effort. The JSON index remains the primary path.
72
- }
73
- return {
74
- documentCount: indexedDocuments.length,
75
- chunkCount: indexedDocuments.reduce((total, document) => total + document.chunks.length, 0),
76
- linkCount: indexedDocuments.reduce((total, document) => total + document.links.length, 0)
77
- };
187
+ await writeIndexState(absoluteVaultPath, {
188
+ chunkSize: config.chunkSize,
189
+ embeddingProvider: config.embeddingProvider,
190
+ files: currentSnapshot,
191
+ pendingPackChanges: shouldRebuildPacks ? 0 : pendingPackChanges
192
+ });
193
+ return toIndexResult(indexedDocuments);
78
194
  }
79
195
  finally {
80
196
  index.close();
@@ -11,6 +11,7 @@ import { loadBrainlinkConfig, sanitizeSearchMode } from '../../infrastructure/co
11
11
  import { createClientCss } from '../frontend/client-css.js';
12
12
  import { createClientHtml } from '../frontend/client-html.js';
13
13
  import { createClientJs } from '../frontend/client-js.js';
14
+ import { createClientWorkerJs } from '../frontend/client-worker-js.js';
14
15
  import { contentTypes, createJsonResponse, isReadMethod, parsePositiveInteger } from './http.js';
15
16
  const readSearchMode = async (url) => {
16
17
  const config = await loadBrainlinkConfig();
@@ -51,10 +52,41 @@ const sameEntityTag = (candidate, signature) => {
51
52
  return decodeEntityTag(candidate) === signature;
52
53
  };
53
54
  const readAgentQuery = (url) => url.searchParams.get('agent') ?? undefined;
55
+ const compactGraphLayoutThreshold = 12_000;
56
+ const compactGraphLayoutEdgeLimit = 60_000;
57
+ const compactGraphLayoutEdgeLimitFor = (nodeCount) => {
58
+ if (nodeCount > 100_000)
59
+ return 15_000;
60
+ if (nodeCount > 50_000)
61
+ return 22_000;
62
+ if (nodeCount > 25_000)
63
+ return 30_000;
64
+ return compactGraphLayoutEdgeLimit;
65
+ };
54
66
  const stripLayoutContent = (layout) => ({
55
67
  ...layout,
56
68
  nodes: layout.nodes.map(({ content, ...node }) => node)
57
69
  });
70
+ const compactLayoutPayload = (layout) => {
71
+ const edgeLimit = compactGraphLayoutEdgeLimitFor(layout.nodes.length);
72
+ const compactNodes = layout.nodes.map((node) => [node.id, node.title, node.x, node.y, node.group, node.segment]);
73
+ const compactEdges = [...layout.edges]
74
+ .sort((left, right) => (right.weight ?? 1) - (left.weight ?? 1))
75
+ .slice(0, edgeLimit)
76
+ .map((edge) => [edge.source, edge.target, edge.weight, edge.priority]);
77
+ return {
78
+ compact: true,
79
+ layout: {
80
+ nodes: compactNodes,
81
+ edges: compactEdges
82
+ },
83
+ totals: {
84
+ nodes: layout.nodes.length,
85
+ edges: layout.edges.length
86
+ }
87
+ };
88
+ };
89
+ const encodeLayoutPayload = (layout) => layout.nodes.length > compactGraphLayoutThreshold ? compactLayoutPayload(layout) : { compact: false, layout: stripLayoutContent(layout) };
58
90
  export const route = async (request, url, vaultPath) => {
59
91
  if (isReadMethod(request) && (url.pathname === '/' || url.pathname === '/index.html')) {
60
92
  return createResponse(createClientHtml(), 200, contentTypes['.html']);
@@ -65,6 +97,9 @@ export const route = async (request, url, vaultPath) => {
65
97
  if (isReadMethod(request) && url.pathname === '/app.js') {
66
98
  return createResponse(createClientJs(), 200, contentTypes['.js']);
67
99
  }
100
+ if (isReadMethod(request) && url.pathname === '/app-worker.js') {
101
+ return createResponse(createClientWorkerJs(), 200, contentTypes['.js']);
102
+ }
68
103
  if (isReadMethod(request) && url.pathname === '/api/graph') {
69
104
  return createResponse(createJsonResponse(await getGraph(vaultPath, readAgentQuery(url))), 200, contentTypes['.json']);
70
105
  }
@@ -73,7 +108,7 @@ export const route = async (request, url, vaultPath) => {
73
108
  const requestEtags = request.headers['if-none-match'];
74
109
  const notModified = sameEntityTag(requestEtags, signature);
75
110
  const etag = encodeEntityTag(signature);
76
- const body = createJsonResponse({ signature, layout: stripLayoutContent(layout) });
111
+ const body = createJsonResponse({ signature, ...encodeLayoutPayload(layout) });
77
112
  const jsonResponse = createResponse(body, 200, contentTypes['.json']);
78
113
  const notModifiedResponse = createResponse('', 304, contentTypes['.json']);
79
114
  if (notModified) {
@@ -1,9 +1,78 @@
1
1
  import { createServer } from 'node:http';
2
+ import { brotliCompressSync, constants, gzipSync } from 'node:zlib';
2
3
  import { indexVault } from './index-vault.js';
3
4
  import { startVaultWatcher } from './watch-vault.js';
4
5
  import { assertLoopbackHost } from './server/host-security.js';
5
6
  import { contentTypes, createJsonResponse, isHttpError } from './server/http.js';
6
7
  import { route } from './server/routes.js';
8
+ const compressionThresholdBytes = 1024;
9
+ const normalizeEncodingToken = (value) => value.trim().toLowerCase();
10
+ const supportsEncoding = (acceptEncoding, target) => {
11
+ if (!acceptEncoding) {
12
+ return false;
13
+ }
14
+ return acceptEncoding
15
+ .split(',')
16
+ .map((entry) => entry.split(';')[0] ?? '')
17
+ .map(normalizeEncodingToken)
18
+ .includes(target);
19
+ };
20
+ const isCompressibleContentType = (contentType) => {
21
+ const normalized = contentType?.toLowerCase() ?? '';
22
+ return (normalized.includes('application/json') ||
23
+ normalized.includes('text/javascript') ||
24
+ normalized.includes('text/css') ||
25
+ normalized.includes('text/html') ||
26
+ normalized.startsWith('text/'));
27
+ };
28
+ const maybeCompressResponse = (requestHeaders, statusCode, headers, body) => {
29
+ if (statusCode === 204 || statusCode === 304) {
30
+ return { headers, body: '' };
31
+ }
32
+ if (!isCompressibleContentType(headers['content-type'])) {
33
+ return { headers, body };
34
+ }
35
+ const bodyBuffer = Buffer.from(body, 'utf8');
36
+ if (bodyBuffer.byteLength < compressionThresholdBytes) {
37
+ return { headers, body };
38
+ }
39
+ if (headers['content-encoding']) {
40
+ return { headers, body };
41
+ }
42
+ const acceptEncodingHeader = Array.isArray(requestHeaders['accept-encoding'])
43
+ ? requestHeaders['accept-encoding'].join(',')
44
+ : requestHeaders['accept-encoding'];
45
+ const vary = headers.vary ? `${headers.vary}, Accept-Encoding` : 'Accept-Encoding';
46
+ const withVary = {
47
+ ...headers,
48
+ vary
49
+ };
50
+ if (supportsEncoding(acceptEncodingHeader, 'br')) {
51
+ return {
52
+ headers: {
53
+ ...withVary,
54
+ 'content-encoding': 'br'
55
+ },
56
+ body: brotliCompressSync(bodyBuffer, {
57
+ params: {
58
+ [constants.BROTLI_PARAM_QUALITY]: 5
59
+ }
60
+ })
61
+ };
62
+ }
63
+ if (supportsEncoding(acceptEncodingHeader, 'gzip')) {
64
+ return {
65
+ headers: {
66
+ ...withVary,
67
+ 'content-encoding': 'gzip'
68
+ },
69
+ body: gzipSync(bodyBuffer, {
70
+ level: 6
71
+ })
72
+ };
73
+ }
74
+ return { headers: withVary, body };
75
+ };
7
76
  export const startServer = async (input) => {
8
77
  assertLoopbackHost(input.host);
9
78
  if (input.shouldIndex) {
@@ -19,14 +88,16 @@ export const startServer = async (input) => {
19
88
  const url = new URL(request.url ?? '/', `http://${request.headers.host ?? input.host}`);
20
89
  route(request, url, input.vaultPath)
21
90
  .then((result) => {
22
- response.writeHead(result.statusCode, result.headers);
23
- response.end(result.body);
91
+ const encoded = maybeCompressResponse(request.headers, result.statusCode, result.headers, result.body);
92
+ response.writeHead(result.statusCode, encoded.headers);
93
+ response.end(encoded.body);
24
94
  })
25
95
  .catch((error) => {
26
96
  const message = error instanceof Error ? error.message : String(error);
27
97
  const statusCode = isHttpError(error) ? error.statusCode : 500;
28
- response.writeHead(statusCode, { 'content-type': contentTypes['.json'] });
29
- response.end(createJsonResponse({ error: message }));
98
+ const fallback = maybeCompressResponse(request.headers, statusCode, { 'content-type': contentTypes['.json'] }, createJsonResponse({ error: message }));
99
+ response.writeHead(statusCode, fallback.headers);
100
+ response.end(fallback.body);
30
101
  });
31
102
  });
32
103
  await new Promise((resolve, reject) => {
@@ -37,9 +37,13 @@ const parseScore = (value, fallback) => {
37
37
  }
38
38
  return parsed;
39
39
  };
40
- const spawnDetached = (command, args) => {
40
+ const spawnDetached = (command, args, envOverrides) => {
41
41
  try {
42
- const child = spawn(command, args, { detached: true, stdio: 'ignore' });
42
+ const child = spawn(command, args, {
43
+ detached: true,
44
+ stdio: 'ignore',
45
+ env: envOverrides ? { ...process.env, ...envOverrides } : process.env
46
+ });
43
47
  child.unref();
44
48
  return true;
45
49
  }
@@ -219,6 +223,7 @@ const commandExists = (command) => {
219
223
  };
220
224
  const envFlagEnabled = (name) => process.env[name] === '1' || process.env[name] === 'true';
221
225
  const spawnAnyDetached = (candidates) => candidates.some(([command, args]) => spawnDetached(command, args));
226
+ const spawnAnyDetachedWithEnv = (candidates) => candidates.some(([command, args, env]) => spawnDetached(command, args, env));
222
227
  const windowsStartCandidates = (program, args = []) => [
223
228
  ['cmd', ['/c', 'start', '', program, ...args]]
224
229
  ];
@@ -318,6 +323,16 @@ const openGraphInAppWindow = (url) => {
318
323
  ]);
319
324
  }
320
325
  const appArgument = `--app=${url}`;
326
+ const linuxChromiumStableFlags = [
327
+ '--ozone-platform=x11',
328
+ '--disable-gpu',
329
+ '--disable-features=Vulkan,VaapiVideoDecoder',
330
+ '--disable-background-networking'
331
+ ];
332
+ const linuxChromiumEnv = {
333
+ GDK_BACKEND: 'x11',
334
+ OZONE_PLATFORM: 'x11'
335
+ };
321
336
  const linuxAppWindowCandidates = [
322
337
  'microsoft-edge',
323
338
  'microsoft-edge-stable',
@@ -327,7 +342,11 @@ const openGraphInAppWindow = (url) => {
327
342
  'chromium-browser',
328
343
  'brave-browser'
329
344
  ].filter((candidate) => commandExists(candidate));
330
- return spawnAnyDetached(linuxAppWindowCandidates.map((command) => [command, [appArgument, '--new-window']]));
345
+ return spawnAnyDetachedWithEnv(linuxAppWindowCandidates.map((command) => [
346
+ command,
347
+ [...linuxChromiumStableFlags, appArgument, '--new-window'],
348
+ linuxChromiumEnv
349
+ ]));
331
350
  };
332
351
  const openGraphInDetectedBrowser = (url) => {
333
352
  if (platform() === 'win32') {
@@ -339,18 +358,28 @@ const openGraphInDetectedBrowser = (url) => {
339
358
  ...windowsStartCandidates('brave', [url])
340
359
  ]);
341
360
  }
361
+ const linuxChromiumStableFlags = [
362
+ '--ozone-platform=x11',
363
+ '--disable-gpu',
364
+ '--disable-features=Vulkan,VaapiVideoDecoder',
365
+ '--disable-background-networking'
366
+ ];
367
+ const linuxChromiumEnv = {
368
+ GDK_BACKEND: 'x11',
369
+ OZONE_PLATFORM: 'x11'
370
+ };
342
371
  const linuxBrowserCandidates = [
343
- ['microsoft-edge', [url]],
344
- ['microsoft-edge-stable', [url]],
345
- ['google-chrome', [url]],
346
- ['google-chrome-stable', [url]],
347
- ['chromium', [url]],
348
- ['chromium-browser', [url]],
349
- ['brave-browser', [url]],
350
- ['firefox', ['-new-window', url]]
372
+ ['microsoft-edge', [...linuxChromiumStableFlags, url], linuxChromiumEnv],
373
+ ['microsoft-edge-stable', [...linuxChromiumStableFlags, url], linuxChromiumEnv],
374
+ ['google-chrome', [...linuxChromiumStableFlags, url], linuxChromiumEnv],
375
+ ['google-chrome-stable', [...linuxChromiumStableFlags, url], linuxChromiumEnv],
376
+ ['chromium', [...linuxChromiumStableFlags, url], linuxChromiumEnv],
377
+ ['chromium-browser', [...linuxChromiumStableFlags, url], linuxChromiumEnv],
378
+ ['brave-browser', [...linuxChromiumStableFlags, url], linuxChromiumEnv],
379
+ ['firefox', ['-new-window', url], undefined]
351
380
  ];
352
381
  const available = linuxBrowserCandidates.filter(([command]) => commandExists(command));
353
- return spawnAnyDetached(available);
382
+ return spawnAnyDetachedWithEnv(available);
354
383
  };
355
384
  const openUrlInUi = (url, parentPid) => {
356
385
  const openDisabled = process.env.BRAINLINK_NO_BROWSER === '1' ||
@@ -155,6 +155,36 @@ export const openFileIndex = (vaultPath) => {
155
155
  links
156
156
  });
157
157
  },
158
+ getIndexedDocuments: async (agentId) => {
159
+ const index = await load();
160
+ const documents = agentId ? index.documents.filter((document) => document.agentId === agentId) : index.documents;
161
+ const selectedDocumentIds = new Set(documents.map((document) => document.id));
162
+ const chunksByDocumentId = index.chunks.reduce((state, chunk) => {
163
+ if (!selectedDocumentIds.has(chunk.documentId)) {
164
+ return state;
165
+ }
166
+ const current = state.get(chunk.documentId) ?? [];
167
+ current.push(chunk);
168
+ state.set(chunk.documentId, current);
169
+ return state;
170
+ }, new Map());
171
+ const linksByDocumentId = index.links.reduce((state, link) => {
172
+ if (!selectedDocumentIds.has(link.fromDocumentId)) {
173
+ return state;
174
+ }
175
+ const current = state.get(link.fromDocumentId) ?? [];
176
+ current.push(link);
177
+ state.set(link.fromDocumentId, current);
178
+ return state;
179
+ }, new Map());
180
+ return documents
181
+ .map((document) => ({
182
+ document,
183
+ chunks: [...(chunksByDocumentId.get(document.id) ?? [])].sort((left, right) => left.ordinal - right.ordinal),
184
+ links: linksByDocumentId.get(document.id) ?? []
185
+ }))
186
+ .sort((left, right) => left.document.path.localeCompare(right.document.path));
187
+ },
158
188
  search: async (query, limit, agentId, mode = 'hybrid', queryEmbedding = []) => {
159
189
  const index = await load();
160
190
  const documentsById = new Map(index.documents.map((document) => [document.id, document]));
@@ -76,6 +76,21 @@ export const readMarkdownFiles = async (vaultPath) => {
76
76
  };
77
77
  }));
78
78
  };
79
+ export const readMarkdownFileSummaries = async (vaultPath) => {
80
+ const absoluteVaultPath = await ensureVault(vaultPath);
81
+ const paths = await walkMarkdownFiles(absoluteVaultPath);
82
+ const summaries = await Promise.all(paths.map(async (absolutePath) => {
83
+ const fileStats = await stat(absolutePath);
84
+ return {
85
+ absolutePath,
86
+ relativePath: relative(absoluteVaultPath, absolutePath),
87
+ createdAt: fileStats.birthtime,
88
+ updatedAt: fileStats.mtime,
89
+ size: fileStats.size
90
+ };
91
+ }));
92
+ return summaries.sort((left, right) => left.relativePath.localeCompare(right.relativePath));
93
+ };
79
94
  export const listVaultFiles = async (vaultPath) => {
80
95
  const absoluteVaultPath = await ensureVault(vaultPath);
81
96
  return walkVaultFiles(absoluteVaultPath);
@@ -0,0 +1,50 @@
1
+ import { readFile, writeFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ const indexStateFileName = 'index-state.json';
4
+ const toIndexStatePath = (vaultPath) => join(vaultPath, '.brainlink', indexStateFileName);
5
+ export const readIndexState = async (vaultPath) => {
6
+ try {
7
+ const parsed = JSON.parse(await readFile(toIndexStatePath(vaultPath), 'utf8'));
8
+ if (parsed.version !== 1 || !Array.isArray(parsed.files)) {
9
+ return null;
10
+ }
11
+ const files = parsed.files.flatMap((entry) => {
12
+ if (!entry || typeof entry !== 'object') {
13
+ return [];
14
+ }
15
+ const row = entry;
16
+ if (typeof row.path !== 'string' || typeof row.mtimeMs !== 'number' || typeof row.size !== 'number') {
17
+ return [];
18
+ }
19
+ return [
20
+ {
21
+ path: row.path,
22
+ mtimeMs: row.mtimeMs,
23
+ size: row.size
24
+ }
25
+ ];
26
+ });
27
+ return {
28
+ version: 1,
29
+ updatedAt: typeof parsed.updatedAt === 'string' ? parsed.updatedAt : new Date().toISOString(),
30
+ chunkSize: typeof parsed.chunkSize === 'number' ? parsed.chunkSize : 1200,
31
+ embeddingProvider: typeof parsed.embeddingProvider === 'string' ? parsed.embeddingProvider : 'none',
32
+ files,
33
+ pendingPackChanges: typeof parsed.pendingPackChanges === 'number' && parsed.pendingPackChanges >= 0 ? parsed.pendingPackChanges : 0
34
+ };
35
+ }
36
+ catch {
37
+ return null;
38
+ }
39
+ };
40
+ export const writeIndexState = async (vaultPath, state) => {
41
+ const payload = {
42
+ version: 1,
43
+ updatedAt: new Date().toISOString(),
44
+ chunkSize: state.chunkSize,
45
+ embeddingProvider: state.embeddingProvider,
46
+ files: [...state.files].sort((left, right) => left.path.localeCompare(right.path)),
47
+ pendingPackChanges: Math.max(0, Math.floor(state.pendingPackChanges))
48
+ };
49
+ await writeFile(toIndexStatePath(vaultPath), `${JSON.stringify(payload)}\n`, 'utf8');
50
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andespindola/brainlink",
3
- "version": "0.1.0-beta.40",
3
+ "version": "0.1.0-beta.42",
4
4
  "description": "Local-first knowledge memory for agents with Markdown, backlinks, indexing and context retrieval.",
5
5
  "type": "module",
6
6
  "license": "MIT",