@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.
- package/AGENTS.md +8 -5
- package/CHANGELOG.md +58 -2
- package/CONTRIBUTING.md +2 -2
- package/COPYRIGHT.md +5 -0
- package/README.md +266 -20
- package/SECURITY.md +1 -1
- package/dist/application/add-note.js +62 -13
- package/dist/application/analyze-vault.js +95 -8
- package/dist/application/build-context.js +56 -1
- package/dist/application/dedupe-notes.js +226 -0
- package/dist/application/frontend/client-css.js +138 -103
- package/dist/application/frontend/client-html.js +47 -41
- package/dist/application/frontend/client-js.js +2111 -128
- package/dist/application/frontend/client-worker-js.js +66 -0
- package/dist/application/get-graph-layout.js +18 -6
- package/dist/application/get-graph-node.js +12 -0
- package/dist/application/get-graph-summary.js +12 -0
- package/dist/application/get-graph.js +3 -3
- package/dist/application/import-legacy-sqlite.js +296 -0
- package/dist/application/index-vault.js +252 -19
- package/dist/application/list-agents.js +3 -3
- package/dist/application/list-links.js +5 -5
- package/dist/application/migrate-vault.js +91 -0
- package/dist/application/offline-pack-backup.js +44 -0
- package/dist/application/search-graph-node-ids.js +12 -0
- package/dist/application/search-knowledge.js +75 -5
- package/dist/application/server/routes.js +102 -1
- package/dist/application/start-server.js +75 -4
- package/dist/application/watch-vault.js +23 -2
- package/dist/benchmarks/large-vault.js +1 -1
- package/dist/cli/commands/agent-commands.js +419 -0
- package/dist/cli/commands/config-commands.js +167 -0
- package/dist/cli/commands/read-commands.js +25 -8
- package/dist/cli/commands/write-commands.js +989 -10
- package/dist/cli/main.js +4 -0
- package/dist/cli/runtime.js +5 -2
- package/dist/domain/context.js +53 -11
- package/dist/domain/embeddings.js +2 -1
- package/dist/domain/graph-layout.js +62 -15
- package/dist/domain/markdown.js +36 -4
- package/dist/domain/middle-out.js +18 -0
- package/dist/infrastructure/config.js +132 -8
- package/dist/infrastructure/file-index.js +358 -0
- package/dist/infrastructure/file-system-vault.js +30 -0
- package/dist/infrastructure/index-state.js +56 -0
- package/dist/infrastructure/paths.js +9 -1
- package/dist/infrastructure/private-pack-codec.js +134 -0
- package/dist/infrastructure/search-packs.js +452 -0
- package/dist/infrastructure/session-state.js +172 -0
- package/dist/mcp/main.js +11 -3
- package/dist/mcp/server.js +27 -2
- package/dist/mcp/startup.js +35 -0
- package/dist/mcp/tools.js +633 -19
- package/docs/AGENT_USAGE.md +178 -16
- package/docs/ARCHITECTURE.md +37 -26
- package/docs/QUICKSTART.md +111 -0
- package/package.json +6 -4
- package/dist/infrastructure/sqlite/document-writer.js +0 -51
- package/dist/infrastructure/sqlite/graph-reader.js +0 -120
- package/dist/infrastructure/sqlite/schema.js +0 -111
- package/dist/infrastructure/sqlite/search-reader.js +0 -156
- package/dist/infrastructure/sqlite/types.js +0 -1
- 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,
|
|
6
|
-
import {
|
|
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
|
-
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
const
|
|
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.
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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 {
|
|
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 =
|
|
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 {
|
|
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 =
|
|
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 =
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
17
|
-
|
|
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
|
};
|