@andespindola/brainlink 0.1.0-beta.9 → 0.1.0-beta.90

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 (53) hide show
  1. package/AGENTS.md +8 -5
  2. package/CHANGELOG.md +26 -2
  3. package/CONTRIBUTING.md +2 -2
  4. package/COPYRIGHT.md +5 -0
  5. package/README.md +146 -17
  6. package/SECURITY.md +1 -1
  7. package/dist/application/analyze-vault.js +7 -7
  8. package/dist/application/build-context.js +56 -1
  9. package/dist/application/dedupe-notes.js +226 -0
  10. package/dist/application/frontend/client-css.js +154 -102
  11. package/dist/application/frontend/client-html.js +49 -40
  12. package/dist/application/frontend/client-js.js +3130 -166
  13. package/dist/application/frontend/client-worker-js.js +66 -0
  14. package/dist/application/get-graph-layout.js +18 -6
  15. package/dist/application/get-graph-node.js +12 -0
  16. package/dist/application/get-graph-summary.js +12 -0
  17. package/dist/application/get-graph.js +3 -3
  18. package/dist/application/import-legacy-sqlite.js +296 -0
  19. package/dist/application/index-vault.js +252 -19
  20. package/dist/application/list-agents.js +3 -3
  21. package/dist/application/list-links.js +5 -5
  22. package/dist/application/offline-pack-backup.js +44 -0
  23. package/dist/application/search-graph-node-ids.js +12 -0
  24. package/dist/application/search-knowledge.js +25 -10
  25. package/dist/application/server/routes.js +102 -1
  26. package/dist/application/start-server.js +75 -4
  27. package/dist/application/watch-vault.js +23 -2
  28. package/dist/benchmarks/large-vault.js +1 -1
  29. package/dist/cli/commands/agent-commands.js +20 -3
  30. package/dist/cli/commands/write-commands.js +818 -8
  31. package/dist/domain/context.js +53 -11
  32. package/dist/domain/embeddings.js +2 -1
  33. package/dist/domain/graph-layout.js +67 -16
  34. package/dist/domain/middle-out.js +18 -0
  35. package/dist/infrastructure/config.js +38 -0
  36. package/dist/infrastructure/file-index.js +358 -0
  37. package/dist/infrastructure/file-system-vault.js +15 -0
  38. package/dist/infrastructure/index-state.js +56 -0
  39. package/dist/infrastructure/private-pack-codec.js +134 -0
  40. package/dist/infrastructure/search-packs.js +452 -0
  41. package/dist/infrastructure/session-state.js +57 -2
  42. package/dist/mcp/server.js +11 -1
  43. package/dist/mcp/tools.js +215 -3
  44. package/docs/AGENT_USAGE.md +103 -16
  45. package/docs/ARCHITECTURE.md +25 -26
  46. package/docs/QUICKSTART.md +9 -1
  47. package/package.json +6 -4
  48. package/dist/infrastructure/sqlite/document-writer.js +0 -51
  49. package/dist/infrastructure/sqlite/graph-reader.js +0 -120
  50. package/dist/infrastructure/sqlite/schema.js +0 -111
  51. package/dist/infrastructure/sqlite/search-reader.js +0 -156
  52. package/dist/infrastructure/sqlite/types.js +0 -1
  53. package/dist/infrastructure/sqlite-index.js +0 -25
@@ -1,9 +1,12 @@
1
+ import { readFile } from 'node:fs/promises';
1
2
  import { createIndexedDocument, parseMarkdownDocument } from '../domain/markdown.js';
2
3
  import { sharedAgentId } from '../domain/agents.js';
3
4
  import { createEmbeddingProvider } from '../domain/embeddings.js';
4
5
  import { loadBrainlinkConfig } from '../infrastructure/config.js';
5
- import { ensureVault, readMarkdownFiles } from '../infrastructure/file-system-vault.js';
6
- import { openSqliteIndex } from '../infrastructure/sqlite-index.js';
6
+ import { ensureVault, readMarkdownFileSummaries } from '../infrastructure/file-system-vault.js';
7
+ import { readIndexState, writeIndexState } from '../infrastructure/index-state.js';
8
+ import { buildSearchPacks, ensureSearchPackManifest, toSearchPackBuildOptions } from '../infrastructure/search-packs.js';
9
+ import { openFileIndex } from '../infrastructure/file-index.js';
7
10
  const toTitleKey = (title) => title.toLowerCase();
8
11
  const appendTitleEntry = (map, document) => {
9
12
  const key = toTitleKey(document.title);
@@ -33,6 +36,9 @@ const createScopedTitleResolver = (document, titleMaps) => ({
33
36
  get: (title) => titleMaps.byAgent.get(document.agentId)?.get(title)?.id ?? titleMaps.shared.get(title)?.id
34
37
  });
35
38
  const embedIndexedDocuments = async (documents, providerName) => {
39
+ if (documents.length === 0) {
40
+ return documents;
41
+ }
36
42
  const provider = createEmbeddingProvider(providerName);
37
43
  const chunks = documents.flatMap((document) => document.chunks);
38
44
  const embeddings = await provider.embed(chunks.map((chunk) => chunk.content));
@@ -46,28 +52,255 @@ const embedIndexedDocuments = async (documents, providerName) => {
46
52
  }))
47
53
  }));
48
54
  };
55
+ const relinkIndexedDocument = (indexedDocument, titleMaps) => {
56
+ const resolver = createScopedTitleResolver(indexedDocument.document, titleMaps);
57
+ return {
58
+ ...indexedDocument,
59
+ links: indexedDocument.links
60
+ .map((link) => ({
61
+ ...link,
62
+ toDocumentId: resolver.get(link.toTitle.toLowerCase()) ?? null
63
+ }))
64
+ .filter((link) => link.toDocumentId !== indexedDocument.document.id)
65
+ };
66
+ };
67
+ const toIndexResult = (documents) => ({
68
+ documentCount: documents.length,
69
+ chunkCount: documents.reduce((total, document) => total + document.chunks.length, 0),
70
+ linkCount: documents.reduce((total, document) => total + document.links.length, 0)
71
+ });
72
+ const toSnapshot = (summaries) => summaries.map((summary) => ({
73
+ path: summary.relativePath,
74
+ mtimeMs: summary.updatedAt.getTime(),
75
+ size: summary.size
76
+ }));
77
+ const createSnapshotMap = (snapshot) => new Map(snapshot.map((entry) => [entry.path, entry]));
78
+ const readChangedDocuments = async (absoluteVaultPath, changedSummaries) => {
79
+ const parsed = await Promise.all(changedSummaries.map(async (summary) => parseMarkdownDocument({
80
+ absolutePath: summary.absolutePath,
81
+ vaultPath: absoluteVaultPath,
82
+ content: await readFile(summary.absolutePath, 'utf8'),
83
+ createdAt: summary.createdAt,
84
+ updatedAt: summary.updatedAt
85
+ })));
86
+ return new Map(parsed.map((document) => [document.path, document]));
87
+ };
49
88
  export const indexVault = async (vaultPath) => {
89
+ return indexVaultWithOptions(vaultPath, {});
90
+ };
91
+ export const indexVaultWithOptions = async (vaultPath, options) => {
92
+ const startedAt = process.hrtime.bigint();
93
+ const elapsedMs = () => Number(process.hrtime.bigint() - startedAt) / 1_000_000;
94
+ const emit = (phase, status, message, details) => {
95
+ options.onProgress?.({
96
+ phase,
97
+ status,
98
+ message,
99
+ elapsedMs: elapsedMs(),
100
+ timestamp: new Date().toISOString(),
101
+ details
102
+ });
103
+ };
104
+ emit('start', 'start', 'Indexing started');
50
105
  const absoluteVaultPath = await ensureVault(vaultPath);
51
106
  const config = await loadBrainlinkConfig();
52
- const files = await readMarkdownFiles(absoluteVaultPath);
53
- const documents = files.map((file) => parseMarkdownDocument({
54
- absolutePath: file.absolutePath,
55
- vaultPath: absoluteVaultPath,
56
- content: file.content,
57
- createdAt: file.createdAt,
58
- updatedAt: file.updatedAt
59
- }));
60
- const titleMaps = createTitleMaps(documents);
61
- const indexedDocuments = await embedIndexedDocuments(documents.map((document) => createIndexedDocument(document, createScopedTitleResolver(document, titleMaps), config.chunkSize)), config.embeddingProvider);
62
- const index = openSqliteIndex(absoluteVaultPath);
107
+ emit('scan', 'start', 'Scanning markdown files');
108
+ const [summaries, previousState] = await Promise.all([
109
+ readMarkdownFileSummaries(absoluteVaultPath),
110
+ readIndexState(absoluteVaultPath)
111
+ ]);
112
+ emit('scan', 'finish', 'Scan complete', {
113
+ markdownFiles: summaries.length,
114
+ hasPreviousState: previousState != null
115
+ });
116
+ const index = openFileIndex(absoluteVaultPath);
63
117
  try {
64
- index.reset();
65
- index.saveDocuments(indexedDocuments);
66
- return {
67
- documentCount: indexedDocuments.length,
68
- chunkCount: indexedDocuments.reduce((total, document) => total + document.chunks.length, 0),
69
- linkCount: indexedDocuments.reduce((total, document) => total + document.links.length, 0)
118
+ const existingIndexedDocuments = await index.getIndexedDocuments();
119
+ const existingByPath = new Map(existingIndexedDocuments.map((document) => [document.document.path, document]));
120
+ const currentSnapshot = toSnapshot(summaries);
121
+ const currentSnapshotMap = createSnapshotMap(currentSnapshot);
122
+ const previousSnapshotMap = createSnapshotMap(previousState?.files ?? []);
123
+ const settingsChanged = previousState == null ||
124
+ previousState.chunkSize !== config.chunkSize ||
125
+ previousState.embeddingProvider !== config.embeddingProvider;
126
+ const packSettingsChanged = previousState == null ||
127
+ previousState.searchPackRowChunkSize !== config.searchPack.rowChunkSize ||
128
+ previousState.searchPackCompressionLevel !== config.searchPack.compressionLevel ||
129
+ previousState.searchPackUseDictionary !== config.searchPack.useDictionary;
130
+ const changedPaths = new Set();
131
+ for (let index = 0; index < summaries.length; index += 1) {
132
+ const summary = summaries[index];
133
+ const previous = previousSnapshotMap.get(summary.relativePath);
134
+ const changed = settingsChanged ||
135
+ previous == null ||
136
+ previous.mtimeMs !== summary.updatedAt.getTime() ||
137
+ previous.size !== summary.size ||
138
+ !existingByPath.has(summary.relativePath);
139
+ if (changed) {
140
+ changedPaths.add(summary.relativePath);
141
+ }
142
+ }
143
+ const hasDeletes = previousState
144
+ ? previousState.files.some((entry) => !currentSnapshotMap.has(entry.path))
145
+ : false;
146
+ const manifestRecovery = await ensureSearchPackManifest(absoluteVaultPath);
147
+ if (changedPaths.size === 0 &&
148
+ !hasDeletes &&
149
+ existingIndexedDocuments.length === summaries.length &&
150
+ previousState != null) {
151
+ const result = {
152
+ ...toIndexResult(existingIndexedDocuments),
153
+ elapsedMs: elapsedMs(),
154
+ changedDocumentCount: 0,
155
+ packs: {
156
+ rebuilt: false,
157
+ reason: manifestRecovery.repaired ? 'No changes detected; pack manifest repaired' : 'No changes detected'
158
+ }
159
+ };
160
+ emit('complete', 'skip', 'Index skipped: no changes detected', {
161
+ elapsedMs: result.elapsedMs,
162
+ manifestRepaired: manifestRecovery.repaired,
163
+ manifestRecoverySource: manifestRecovery.source
164
+ });
165
+ return result;
166
+ }
167
+ const changedSummaries = summaries.filter((summary) => changedPaths.has(summary.relativePath));
168
+ emit('parse', 'start', 'Parsing changed markdown files', {
169
+ changedFiles: changedSummaries.length
170
+ });
171
+ const changedDocumentsByPath = await readChangedDocuments(absoluteVaultPath, changedSummaries);
172
+ emit('parse', 'finish', 'Parse complete', {
173
+ changedDocuments: changedDocumentsByPath.size
174
+ });
175
+ const documents = summaries.flatMap((summary) => {
176
+ const changed = changedDocumentsByPath.get(summary.relativePath);
177
+ if (changed) {
178
+ return [changed];
179
+ }
180
+ const existing = existingByPath.get(summary.relativePath);
181
+ return existing ? [existing.document] : [];
182
+ });
183
+ const titleMaps = createTitleMaps(documents);
184
+ emit('embed', 'start', 'Embedding changed chunks', {
185
+ changedDocuments: changedDocumentsByPath.size
186
+ });
187
+ const changedIndexedDocuments = changedDocumentsByPath.size > 0
188
+ ? await embedIndexedDocuments(Array.from(changedDocumentsByPath.values()).map((document) => createIndexedDocument(document, createScopedTitleResolver(document, titleMaps), config.chunkSize)), config.embeddingProvider)
189
+ : [];
190
+ emit('embed', changedDocumentsByPath.size > 0 ? 'finish' : 'skip', changedDocumentsByPath.size > 0 ? 'Embedding complete' : 'Embedding skipped', {
191
+ changedIndexedDocuments: changedIndexedDocuments.length
192
+ });
193
+ const changedIndexedByPath = new Map(changedIndexedDocuments.map((document) => [document.document.path, document]));
194
+ const needsRelink = settingsChanged || hasDeletes || changedPaths.size > 0;
195
+ const indexedDocuments = documents.map((document) => {
196
+ const changed = changedIndexedByPath.get(document.path);
197
+ if (changed) {
198
+ return changed;
199
+ }
200
+ const existing = existingByPath.get(document.path);
201
+ if (!existing) {
202
+ return createIndexedDocument(document, createScopedTitleResolver(document, titleMaps), config.chunkSize);
203
+ }
204
+ return needsRelink ? relinkIndexedDocument(existing, titleMaps) : existing;
205
+ });
206
+ emit('persist', 'start', 'Persisting index');
207
+ await index.reset();
208
+ await index.saveDocuments(indexedDocuments);
209
+ emit('persist', 'finish', 'Index persisted', {
210
+ indexedDocuments: indexedDocuments.length
211
+ });
212
+ const existingPackManifest = manifestRecovery.repaired || manifestRecovery.source === 'not-needed';
213
+ const changedCount = changedPaths.size;
214
+ const documentCount = Math.max(indexedDocuments.length, 1);
215
+ const changeRatio = changedCount / documentCount;
216
+ const previousPendingPackChanges = previousState?.pendingPackChanges ?? 0;
217
+ const pendingPackChanges = previousPendingPackChanges + changedCount;
218
+ const shouldRebuildPacks = !existingPackManifest ||
219
+ settingsChanged ||
220
+ packSettingsChanged ||
221
+ hasDeletes ||
222
+ changedCount >= 400 ||
223
+ changeRatio >= 0.04 ||
224
+ pendingPackChanges >= 1200;
225
+ let packResult;
226
+ const packReason = !existingPackManifest
227
+ ? 'Missing pack manifest'
228
+ : manifestRecovery.repaired
229
+ ? 'Pack manifest repaired from existing packs'
230
+ : settingsChanged
231
+ ? 'Index settings changed'
232
+ : packSettingsChanged
233
+ ? 'Search pack settings changed'
234
+ : hasDeletes
235
+ ? 'Document deletions detected'
236
+ : changedCount >= 400
237
+ ? 'Changed file count threshold reached'
238
+ : changeRatio >= 0.04
239
+ ? 'Change ratio threshold reached'
240
+ : pendingPackChanges >= 1200
241
+ ? 'Pending pack changes threshold reached'
242
+ : 'Pack rebuild skipped';
243
+ if (shouldRebuildPacks) {
244
+ emit('packs', 'start', 'Rebuilding compressed search packs', {
245
+ reason: packReason
246
+ });
247
+ try {
248
+ packResult = await buildSearchPacks(absoluteVaultPath, indexedDocuments, toSearchPackBuildOptions(config));
249
+ emit('packs', 'finish', 'Compressed packs rebuilt', {
250
+ reason: packReason,
251
+ packCount: packResult.packCount,
252
+ recordCount: packResult.recordCount,
253
+ durationMs: packResult.durationMs,
254
+ compressionRatio: packResult.compression.ratio
255
+ });
256
+ }
257
+ catch {
258
+ // Pack generation is best-effort. The JSON index remains the primary path.
259
+ emit('packs', 'skip', 'Pack rebuild failed; continuing with JSON index', {
260
+ reason: packReason
261
+ });
262
+ }
263
+ }
264
+ else {
265
+ emit('packs', 'skip', 'Pack rebuild not required', {
266
+ reason: packReason
267
+ });
268
+ }
269
+ const packsRebuilt = packResult != null;
270
+ const packResultReason = shouldRebuildPacks && !packsRebuilt ? `${packReason} (failed)` : packReason;
271
+ await writeIndexState(absoluteVaultPath, {
272
+ chunkSize: config.chunkSize,
273
+ embeddingProvider: config.embeddingProvider,
274
+ searchPackRowChunkSize: config.searchPack.rowChunkSize,
275
+ searchPackCompressionLevel: config.searchPack.compressionLevel,
276
+ searchPackUseDictionary: config.searchPack.useDictionary,
277
+ files: currentSnapshot,
278
+ pendingPackChanges: packsRebuilt ? 0 : pendingPackChanges
279
+ });
280
+ const result = {
281
+ ...toIndexResult(indexedDocuments),
282
+ elapsedMs: elapsedMs(),
283
+ changedDocumentCount: changedDocumentsByPath.size,
284
+ packs: {
285
+ rebuilt: packsRebuilt,
286
+ reason: packResultReason,
287
+ ...(packResult
288
+ ? {
289
+ packCount: packResult.packCount,
290
+ recordCount: packResult.recordCount,
291
+ durationMs: packResult.durationMs,
292
+ compression: packResult.compression
293
+ }
294
+ : {})
295
+ }
70
296
  };
297
+ emit('complete', 'finish', 'Indexing complete', {
298
+ documentCount: result.documentCount,
299
+ chunkCount: result.chunkCount,
300
+ linkCount: result.linkCount,
301
+ elapsedMs: result.elapsedMs
302
+ });
303
+ return result;
71
304
  }
72
305
  finally {
73
306
  index.close();
@@ -1,10 +1,10 @@
1
1
  import { ensureVault } from '../infrastructure/file-system-vault.js';
2
- import { openSqliteIndex } from '../infrastructure/sqlite-index.js';
2
+ import { openFileIndex } from '../infrastructure/file-index.js';
3
3
  export const listAgents = async (vaultPath) => {
4
4
  const absoluteVaultPath = await ensureVault(vaultPath);
5
- const index = openSqliteIndex(absoluteVaultPath);
5
+ const index = openFileIndex(absoluteVaultPath);
6
6
  try {
7
- return index.listAgents();
7
+ return await index.listAgents();
8
8
  }
9
9
  finally {
10
10
  index.close();
@@ -1,10 +1,10 @@
1
1
  import { ensureVault } from '../infrastructure/file-system-vault.js';
2
- import { openSqliteIndex } from '../infrastructure/sqlite-index.js';
2
+ import { openFileIndex } from '../infrastructure/file-index.js';
3
3
  export const listLinks = async (vaultPath, agentId) => {
4
4
  const absoluteVaultPath = await ensureVault(vaultPath);
5
- const index = openSqliteIndex(absoluteVaultPath);
5
+ const index = openFileIndex(absoluteVaultPath);
6
6
  try {
7
- return index.listLinks(agentId);
7
+ return await index.listLinks(agentId);
8
8
  }
9
9
  finally {
10
10
  index.close();
@@ -12,9 +12,9 @@ export const listLinks = async (vaultPath, agentId) => {
12
12
  };
13
13
  export const listBacklinks = async (vaultPath, title, agentId) => {
14
14
  const absoluteVaultPath = await ensureVault(vaultPath);
15
- const index = openSqliteIndex(absoluteVaultPath);
15
+ const index = openFileIndex(absoluteVaultPath);
16
16
  try {
17
- return index.listBacklinks(title, agentId);
17
+ return await index.listBacklinks(title, agentId);
18
18
  }
19
19
  finally {
20
20
  index.close();
@@ -0,0 +1,44 @@
1
+ import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises';
2
+ import { dirname, join } from 'node:path';
3
+ import { gzipSync } from 'node:zlib';
4
+ import { ensureVault } from '../infrastructure/file-system-vault.js';
5
+ const packsDirectory = (vaultPath) => join(vaultPath, '.brainlink', 'search-packs');
6
+ const toSortedBackupFiles = async (vaultPath) => {
7
+ const directory = packsDirectory(vaultPath);
8
+ const names = await readdir(directory);
9
+ return names
10
+ .filter((name) => name.endsWith('.blpk') || name === 'manifest.json')
11
+ .sort((left, right) => left.localeCompare(right));
12
+ };
13
+ export const createOfflinePackBackup = async (input) => {
14
+ const vaultPath = await ensureVault(input.vaultPath);
15
+ const fileNames = await toSortedBackupFiles(vaultPath);
16
+ const files = [];
17
+ let inputBytes = 0;
18
+ for (const name of fileNames) {
19
+ const content = await readFile(join(packsDirectory(vaultPath), name));
20
+ inputBytes += content.byteLength;
21
+ files.push({
22
+ name,
23
+ contentB64: content.toString('base64')
24
+ });
25
+ }
26
+ const envelope = {
27
+ version: 1,
28
+ createdAt: new Date().toISOString(),
29
+ files
30
+ };
31
+ const serialized = Buffer.from(JSON.stringify(envelope), 'utf8');
32
+ const compressed = gzipSync(serialized, { level: 9 });
33
+ await mkdir(dirname(input.outputPath), { recursive: true });
34
+ await writeFile(input.outputPath, compressed);
35
+ const safeInput = Math.max(inputBytes, 1);
36
+ return {
37
+ outputPath: input.outputPath,
38
+ fileCount: files.length,
39
+ inputBytes,
40
+ outputBytes: compressed.byteLength,
41
+ ratio: compressed.byteLength / safeInput,
42
+ savedBytes: Math.max(inputBytes - compressed.byteLength, 0)
43
+ };
44
+ };
@@ -0,0 +1,12 @@
1
+ import { ensureVault } from '../infrastructure/file-system-vault.js';
2
+ import { openFileIndex } from '../infrastructure/file-index.js';
3
+ export const searchGraphNodeIds = async (vaultPath, query, limit, agentId) => {
4
+ const absoluteVaultPath = await ensureVault(vaultPath);
5
+ const index = openFileIndex(absoluteVaultPath);
6
+ try {
7
+ return await index.searchGraphNodeIds(query, limit, agentId);
8
+ }
9
+ finally {
10
+ index.close();
11
+ }
12
+ };
@@ -1,7 +1,7 @@
1
1
  import { stat } from 'node:fs/promises';
2
- import { join } from 'node:path';
3
2
  import { ensureVault } from '../infrastructure/file-system-vault.js';
4
- import { openSqliteIndex } from '../infrastructure/sqlite-index.js';
3
+ import { ensurePrivatePacksFromLegacyIndex, searchInPacks } from '../infrastructure/search-packs.js';
4
+ import { indexStoragePath, openFileIndex } from '../infrastructure/file-index.js';
5
5
  import { createEmbeddingProvider } from '../domain/embeddings.js';
6
6
  import { loadBrainlinkConfig, sanitizeSearchMode } from '../infrastructure/config.js';
7
7
  const hybridCacheTtlMs = 30_000;
@@ -9,7 +9,7 @@ const hybridCacheMaxEntries = 200;
9
9
  const hybridSearchCache = new Map();
10
10
  const readIndexMtimeMs = async (vaultPath) => {
11
11
  try {
12
- return (await stat(join(vaultPath, '.brainlink', 'brainlink.db'))).mtimeMs;
12
+ return (await stat(indexStoragePath(vaultPath))).mtimeMs;
13
13
  }
14
14
  catch {
15
15
  return 0;
@@ -46,6 +46,7 @@ export const searchKnowledge = async (vaultPath, query, limit, agentId, mode) =>
46
46
  const absoluteVaultPath = await ensureVault(vaultPath);
47
47
  const config = await loadBrainlinkConfig();
48
48
  const searchMode = sanitizeSearchMode(mode, config.defaultSearchMode);
49
+ await ensurePrivatePacksFromLegacyIndex(absoluteVaultPath);
49
50
  const cacheKey = searchMode === 'hybrid' ? toCacheKey(absoluteVaultPath, query, limit, agentId) : undefined;
50
51
  const indexMtimeMs = cacheKey ? await readIndexMtimeMs(absoluteVaultPath) : 0;
51
52
  const cached = cacheKey ? cacheGet(cacheKey, indexMtimeMs) : undefined;
@@ -55,20 +56,34 @@ export const searchKnowledge = async (vaultPath, query, limit, agentId, mode) =>
55
56
  const provider = createEmbeddingProvider(config.embeddingProvider);
56
57
  const shouldEmbedQuery = searchMode !== 'fts' && provider.name !== 'none';
57
58
  const queryEmbedding = shouldEmbedQuery ? (await provider.embed([query]))[0] ?? [] : [];
58
- const index = openSqliteIndex(absoluteVaultPath);
59
59
  try {
60
- const results = index.search(query, limit, agentId, searchMode, queryEmbedding);
60
+ const index = openFileIndex(absoluteVaultPath);
61
+ try {
62
+ const results = await index.search(query, limit, agentId, searchMode, queryEmbedding);
63
+ if (cacheKey) {
64
+ cacheSet({
65
+ key: cacheKey,
66
+ createdAt: Date.now(),
67
+ indexMtimeMs,
68
+ results
69
+ });
70
+ }
71
+ return results;
72
+ }
73
+ finally {
74
+ index.close();
75
+ }
76
+ }
77
+ catch {
78
+ const fallbackResults = await searchInPacks(absoluteVaultPath, query, limit, agentId);
61
79
  if (cacheKey) {
62
80
  cacheSet({
63
81
  key: cacheKey,
64
82
  createdAt: Date.now(),
65
83
  indexMtimeMs,
66
- results
84
+ results: fallbackResults
67
85
  });
68
86
  }
69
- return results;
70
- }
71
- finally {
72
- index.close();
87
+ return fallbackResults;
73
88
  }
74
89
  };
@@ -1,14 +1,17 @@
1
1
  import { getBrokenLinksReport, getOrphansReport, getStats, validateVault } from '../analyze-vault.js';
2
2
  import { buildContextPackage } from '../build-context.js';
3
3
  import { getGraph } from '../get-graph.js';
4
+ import { getGraphNode } from '../get-graph-node.js';
4
5
  import { getGraphLayout } from '../get-graph-layout.js';
5
6
  import { listAgents } from '../list-agents.js';
6
7
  import { listBacklinks, listLinks } from '../list-links.js';
8
+ import { searchGraphNodeIds } from '../search-graph-node-ids.js';
7
9
  import { searchKnowledge } from '../search-knowledge.js';
8
10
  import { loadBrainlinkConfig, sanitizeSearchMode } from '../../infrastructure/config.js';
9
11
  import { createClientCss } from '../frontend/client-css.js';
10
12
  import { createClientHtml } from '../frontend/client-html.js';
11
13
  import { createClientJs } from '../frontend/client-js.js';
14
+ import { createClientWorkerJs } from '../frontend/client-worker-js.js';
12
15
  import { contentTypes, createJsonResponse, isReadMethod, parsePositiveInteger } from './http.js';
13
16
  const readSearchMode = async (url) => {
14
17
  const config = await loadBrainlinkConfig();
@@ -49,6 +52,81 @@ const sameEntityTag = (candidate, signature) => {
49
52
  return decodeEntityTag(candidate) === signature;
50
53
  };
51
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
+ };
66
+ const edgeWeight = (weight) => Number.isFinite(weight) ? Number(weight) : 1;
67
+ const edgeKey = (source, target, priority) => `${source}|${target}|${priority}`;
68
+ const selectCompactEdges = (layout, limit) => {
69
+ const resolvedEdges = layout.edges.filter((edge) => typeof edge.target === 'string' && edge.target.length > 0);
70
+ if (resolvedEdges.length <= limit) {
71
+ return resolvedEdges;
72
+ }
73
+ const bestEdgeByEndpoint = new Map();
74
+ for (let index = 0; index < resolvedEdges.length; index += 1) {
75
+ const edge = resolvedEdges[index];
76
+ const endpoints = [edge.source, edge.target];
77
+ for (let endpointIndex = 0; endpointIndex < endpoints.length; endpointIndex += 1) {
78
+ const endpoint = endpoints[endpointIndex];
79
+ const previous = bestEdgeByEndpoint.get(endpoint);
80
+ if (!previous || edgeWeight(edge.weight) > edgeWeight(previous.weight)) {
81
+ bestEdgeByEndpoint.set(endpoint, edge);
82
+ }
83
+ }
84
+ }
85
+ const selected = new Map();
86
+ for (const edge of bestEdgeByEndpoint.values()) {
87
+ selected.set(edgeKey(edge.source, edge.target, edge.priority), edge);
88
+ }
89
+ if (selected.size > limit) {
90
+ return Array.from(selected.values())
91
+ .sort((left, right) => edgeWeight(right.weight) - edgeWeight(left.weight))
92
+ .slice(0, limit);
93
+ }
94
+ const byWeight = [...resolvedEdges].sort((left, right) => edgeWeight(right.weight) - edgeWeight(left.weight));
95
+ for (let index = 0; index < byWeight.length; index += 1) {
96
+ if (selected.size >= limit) {
97
+ break;
98
+ }
99
+ const edge = byWeight[index];
100
+ const key = edgeKey(edge.source, edge.target, edge.priority);
101
+ if (!selected.has(key)) {
102
+ selected.set(key, edge);
103
+ }
104
+ }
105
+ return Array.from(selected.values());
106
+ };
107
+ const stripLayoutContent = (layout) => ({
108
+ ...layout,
109
+ nodes: layout.nodes.map(({ content, ...node }) => node)
110
+ });
111
+ const compactLayoutPayload = (layout) => {
112
+ const edgeLimit = compactGraphLayoutEdgeLimitFor(layout.nodes.length);
113
+ const compactEdges = selectCompactEdges(layout, edgeLimit);
114
+ const compactNodes = layout.nodes.map((node) => [node.id, node.title, node.x, node.y, node.group, node.segment]);
115
+ const compactEdgeRows = compactEdges
116
+ .map((edge) => [edge.source, edge.target, edge.weight, edge.priority]);
117
+ return {
118
+ compact: true,
119
+ layout: {
120
+ nodes: compactNodes,
121
+ edges: compactEdgeRows
122
+ },
123
+ totals: {
124
+ nodes: layout.nodes.length,
125
+ edges: layout.edges.length
126
+ }
127
+ };
128
+ };
129
+ const encodeLayoutPayload = (layout) => layout.nodes.length > compactGraphLayoutThreshold ? compactLayoutPayload(layout) : { compact: false, layout: stripLayoutContent(layout) };
52
130
  export const route = async (request, url, vaultPath) => {
53
131
  if (isReadMethod(request) && (url.pathname === '/' || url.pathname === '/index.html')) {
54
132
  return createResponse(createClientHtml(), 200, contentTypes['.html']);
@@ -59,6 +137,9 @@ export const route = async (request, url, vaultPath) => {
59
137
  if (isReadMethod(request) && url.pathname === '/app.js') {
60
138
  return createResponse(createClientJs(), 200, contentTypes['.js']);
61
139
  }
140
+ if (isReadMethod(request) && url.pathname === '/app-worker.js') {
141
+ return createResponse(createClientWorkerJs(), 200, contentTypes['.js']);
142
+ }
62
143
  if (isReadMethod(request) && url.pathname === '/api/graph') {
63
144
  return createResponse(createJsonResponse(await getGraph(vaultPath, readAgentQuery(url))), 200, contentTypes['.json']);
64
145
  }
@@ -67,7 +148,7 @@ export const route = async (request, url, vaultPath) => {
67
148
  const requestEtags = request.headers['if-none-match'];
68
149
  const notModified = sameEntityTag(requestEtags, signature);
69
150
  const etag = encodeEntityTag(signature);
70
- const body = createJsonResponse({ signature, layout });
151
+ const body = createJsonResponse({ signature, ...encodeLayoutPayload(layout) });
71
152
  const jsonResponse = createResponse(body, 200, contentTypes['.json']);
72
153
  const notModifiedResponse = createResponse('', 304, contentTypes['.json']);
73
154
  if (notModified) {
@@ -87,6 +168,26 @@ export const route = async (request, url, vaultPath) => {
87
168
  }
88
169
  };
89
170
  }
171
+ if (isReadMethod(request) && url.pathname === '/api/graph-node') {
172
+ const id = url.searchParams.get('id')?.trim() ?? '';
173
+ if (!id) {
174
+ return createResponse(createJsonResponse({ error: 'Missing id query parameter' }), 400, contentTypes['.json']);
175
+ }
176
+ const node = await getGraphNode(vaultPath, id, readAgentQuery(url));
177
+ if (!node) {
178
+ return createResponse(createJsonResponse({ error: 'Node not found' }), 404, contentTypes['.json']);
179
+ }
180
+ return createResponse(createJsonResponse({ node }), 200, contentTypes['.json']);
181
+ }
182
+ if (isReadMethod(request) && url.pathname === '/api/graph-filter') {
183
+ const query = url.searchParams.get('q')?.trim() ?? '';
184
+ const limit = parsePositiveInteger(url.searchParams.get('limit'), 1200);
185
+ if (!query) {
186
+ return createResponse(createJsonResponse({ query, nodeIds: [] }), 200, contentTypes['.json']);
187
+ }
188
+ const nodeIds = await searchGraphNodeIds(vaultPath, query, limit, readAgentQuery(url));
189
+ return createResponse(createJsonResponse({ query, nodeIds }), 200, contentTypes['.json']);
190
+ }
90
191
  if (isReadMethod(request) && url.pathname === '/api/agents') {
91
192
  return createResponse(createJsonResponse({ agents: await listAgents(vaultPath) }), 200, contentTypes['.json']);
92
193
  }