@andespindola/brainlink 0.1.0-beta.7 → 0.1.0-beta.70

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 +138 -103
  12. package/dist/application/frontend/client-html.js +47 -41
  13. package/dist/application/frontend/client-js.js +2111 -128
  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
@@ -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,91 @@
1
+ import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises';
2
+ import { dirname, extname, isAbsolute, join, relative } from 'node:path';
3
+ import { ensureVault, isBucketVaultPath, listVaultFiles, resolveVaultPath, writeMarkdownFile } from '../infrastructure/file-system-vault.js';
4
+ const directoryMode = 0o700;
5
+ const fileMode = 0o600;
6
+ const isMarkdownPath = (path) => extname(path).toLowerCase() === '.md';
7
+ const timestamp = () => new Date().toISOString().replace(/[-:]/g, '').replace(/\..+$/, 'Z');
8
+ const isPathInside = (parent, child) => {
9
+ const path = relative(parent, child);
10
+ return path === '' || (!path.startsWith('..') && !isAbsolute(path));
11
+ };
12
+ const conflictPath = (targetPath) => {
13
+ const extension = extname(targetPath);
14
+ const base = extension ? targetPath.slice(0, -extension.length) : targetPath;
15
+ return `${base}.conflict-${timestamp()}${extension}`;
16
+ };
17
+ const writePreservedFile = async (absolutePath, content) => {
18
+ await mkdir(dirname(absolutePath), { recursive: true, mode: directoryMode });
19
+ await writeFile(absolutePath, content, { mode: fileMode });
20
+ await chmod(absolutePath, fileMode);
21
+ };
22
+ const writeMigratedFile = async (targetVault, targetRoot, absolutePath, content) => {
23
+ if (isBucketVaultPath(targetVault)) {
24
+ await writeMarkdownFile(targetVault, relative(targetRoot, absolutePath), content.toString('utf8'));
25
+ return;
26
+ }
27
+ await writePreservedFile(absolutePath, content);
28
+ };
29
+ export const planVaultMigration = async (source, target) => {
30
+ const sourceFiles = (await listVaultFiles(source)).filter(isMarkdownPath);
31
+ return sourceFiles.reduce(async (statePromise, sourceFile) => {
32
+ const state = await statePromise;
33
+ const targetFile = join(target, relative(source, sourceFile));
34
+ if (!isPathInside(target, targetFile)) {
35
+ return state;
36
+ }
37
+ const sourceContent = await readFile(sourceFile);
38
+ try {
39
+ const targetContent = await readFile(targetFile);
40
+ if (sourceContent.equals(targetContent)) {
41
+ return [...state, { kind: 'unchanged', sourcePath: sourceFile, targetPath: targetFile, sourceContent }];
42
+ }
43
+ return [...state, { kind: 'conflict', sourcePath: sourceFile, targetPath: conflictPath(targetFile), sourceContent }];
44
+ }
45
+ catch (error) {
46
+ if (!(error instanceof Error) || !('code' in error) || error.code !== 'ENOENT') {
47
+ throw error;
48
+ }
49
+ return [...state, { kind: 'copy', sourcePath: sourceFile, targetPath: targetFile, sourceContent }];
50
+ }
51
+ }, Promise.resolve([]));
52
+ };
53
+ export const previewVaultMigration = async (sourceVault, targetVault) => {
54
+ const source = await ensureVault(sourceVault);
55
+ const target = await ensureVault(targetVault);
56
+ if (source === target) {
57
+ return { source, target, copied: 0, unchanged: 0, conflicted: 0 };
58
+ }
59
+ const actions = await planVaultMigration(source, target);
60
+ const copied = actions.filter((action) => action.kind === 'copy').length;
61
+ const unchanged = actions.filter((action) => action.kind === 'unchanged').length;
62
+ const conflicted = actions.filter((action) => action.kind === 'conflict').length;
63
+ return { source, target, copied, unchanged, conflicted };
64
+ };
65
+ export const migrateVaultContent = async (sourceVault, targetVault) => {
66
+ const source = await ensureVault(sourceVault);
67
+ const target = await ensureVault(targetVault);
68
+ if (source === target) {
69
+ return { source, target, copied: 0, unchanged: 0, conflicted: 0 };
70
+ }
71
+ const actions = await planVaultMigration(source, target);
72
+ for (const action of actions) {
73
+ if (action.kind === 'unchanged') {
74
+ continue;
75
+ }
76
+ await writeMigratedFile(targetVault, target, action.targetPath, action.sourceContent);
77
+ }
78
+ const copied = actions.filter((action) => action.kind === 'copy').length;
79
+ const unchanged = actions.filter((action) => action.kind === 'unchanged').length;
80
+ const conflicted = actions.filter((action) => action.kind === 'conflict').length;
81
+ return { source, target, copied, unchanged, conflicted };
82
+ };
83
+ export const shouldMigrateDefaultVault = async (sourceVault, targetVault) => {
84
+ const source = resolveVaultPath(sourceVault);
85
+ const target = resolveVaultPath(targetVault);
86
+ if (source === target) {
87
+ return false;
88
+ }
89
+ const [sourceFiles, targetFiles] = await Promise.all([listVaultFiles(source), listVaultFiles(target)]);
90
+ return sourceFiles.filter(isMarkdownPath).length > 0 && targetFiles.filter(isMarkdownPath).length === 0;
91
+ };
@@ -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,19 +1,89 @@
1
+ import { stat } from 'node:fs/promises';
1
2
  import { ensureVault } from '../infrastructure/file-system-vault.js';
2
- 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';
3
5
  import { createEmbeddingProvider } from '../domain/embeddings.js';
4
6
  import { loadBrainlinkConfig, sanitizeSearchMode } from '../infrastructure/config.js';
7
+ const hybridCacheTtlMs = 30_000;
8
+ const hybridCacheMaxEntries = 200;
9
+ const hybridSearchCache = new Map();
10
+ const readIndexMtimeMs = async (vaultPath) => {
11
+ try {
12
+ return (await stat(indexStoragePath(vaultPath))).mtimeMs;
13
+ }
14
+ catch {
15
+ return 0;
16
+ }
17
+ };
18
+ const toCacheKey = (vaultPath, query, limit, agentId) => JSON.stringify({
19
+ vaultPath,
20
+ query: query.trim().toLowerCase(),
21
+ limit,
22
+ agentId: agentId?.trim().toLowerCase() ?? '*'
23
+ });
24
+ const cacheGet = (key, indexMtimeMs) => {
25
+ const entry = hybridSearchCache.get(key);
26
+ if (!entry) {
27
+ return undefined;
28
+ }
29
+ const fresh = Date.now() - entry.createdAt <= hybridCacheTtlMs && entry.indexMtimeMs === indexMtimeMs;
30
+ if (!fresh) {
31
+ hybridSearchCache.delete(key);
32
+ return undefined;
33
+ }
34
+ return entry.results;
35
+ };
36
+ const cacheSet = (entry) => {
37
+ hybridSearchCache.set(entry.key, entry);
38
+ if (hybridSearchCache.size <= hybridCacheMaxEntries) {
39
+ return;
40
+ }
41
+ const overflow = hybridSearchCache.size - hybridCacheMaxEntries;
42
+ const keys = Array.from(hybridSearchCache.keys()).slice(0, overflow);
43
+ keys.forEach((key) => hybridSearchCache.delete(key));
44
+ };
5
45
  export const searchKnowledge = async (vaultPath, query, limit, agentId, mode) => {
6
46
  const absoluteVaultPath = await ensureVault(vaultPath);
7
47
  const config = await loadBrainlinkConfig();
8
48
  const searchMode = sanitizeSearchMode(mode, config.defaultSearchMode);
49
+ await ensurePrivatePacksFromLegacyIndex(absoluteVaultPath);
50
+ const cacheKey = searchMode === 'hybrid' ? toCacheKey(absoluteVaultPath, query, limit, agentId) : undefined;
51
+ const indexMtimeMs = cacheKey ? await readIndexMtimeMs(absoluteVaultPath) : 0;
52
+ const cached = cacheKey ? cacheGet(cacheKey, indexMtimeMs) : undefined;
53
+ if (cached) {
54
+ return cached;
55
+ }
9
56
  const provider = createEmbeddingProvider(config.embeddingProvider);
10
57
  const shouldEmbedQuery = searchMode !== 'fts' && provider.name !== 'none';
11
58
  const queryEmbedding = shouldEmbedQuery ? (await provider.embed([query]))[0] ?? [] : [];
12
- const index = openSqliteIndex(absoluteVaultPath);
13
59
  try {
14
- return 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
+ }
15
76
  }
16
- finally {
17
- index.close();
77
+ catch {
78
+ const fallbackResults = await searchInPacks(absoluteVaultPath, query, limit, agentId);
79
+ if (cacheKey) {
80
+ cacheSet({
81
+ key: cacheKey,
82
+ createdAt: Date.now(),
83
+ indexMtimeMs,
84
+ results: fallbackResults
85
+ });
86
+ }
87
+ return fallbackResults;
18
88
  }
19
89
  };