@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.
- package/AGENTS.md +8 -5
- package/CHANGELOG.md +26 -2
- package/CONTRIBUTING.md +2 -2
- package/COPYRIGHT.md +5 -0
- package/README.md +146 -17
- package/SECURITY.md +1 -1
- package/dist/application/analyze-vault.js +7 -7
- package/dist/application/build-context.js +56 -1
- package/dist/application/dedupe-notes.js +226 -0
- package/dist/application/frontend/client-css.js +154 -102
- package/dist/application/frontend/client-html.js +49 -40
- package/dist/application/frontend/client-js.js +3130 -166
- 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/offline-pack-backup.js +44 -0
- package/dist/application/search-graph-node-ids.js +12 -0
- package/dist/application/search-knowledge.js +25 -10
- 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 +20 -3
- package/dist/cli/commands/write-commands.js +818 -8
- package/dist/domain/context.js +53 -11
- package/dist/domain/embeddings.js +2 -1
- package/dist/domain/graph-layout.js +67 -16
- package/dist/domain/middle-out.js +18 -0
- package/dist/infrastructure/config.js +38 -0
- package/dist/infrastructure/file-index.js +358 -0
- package/dist/infrastructure/file-system-vault.js +15 -0
- package/dist/infrastructure/index-state.js +56 -0
- package/dist/infrastructure/private-pack-codec.js +134 -0
- package/dist/infrastructure/search-packs.js +452 -0
- package/dist/infrastructure/session-state.js +57 -2
- package/dist/mcp/server.js +11 -1
- package/dist/mcp/tools.js +215 -3
- package/docs/AGENT_USAGE.md +103 -16
- package/docs/ARCHITECTURE.md +25 -26
- package/docs/QUICKSTART.md +9 -1
- 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,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 {
|
|
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(
|
|
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
|
|
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
|
|
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
|
}
|