@andespindola/brainlink 0.1.0-beta.16 → 0.1.0-beta.161
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 +9 -6
- package/CHANGELOG.md +27 -0
- package/COPYRIGHT.md +5 -0
- package/README.md +177 -20
- package/dist/application/add-note.js +13 -44
- package/dist/application/auto-migrate-configured-vault.js +37 -0
- package/dist/application/build-context.js +64 -3
- package/dist/application/canonical-context-links.js +209 -0
- package/dist/application/dedupe-notes.js +226 -0
- package/dist/application/frontend/client-css.js +258 -51
- package/dist/application/frontend/client-html.js +50 -27
- package/dist/application/frontend/client-js.js +1369 -605
- package/dist/application/frontend/client-render-worker-js.js +645 -0
- package/dist/application/frontend/client-worker-js.js +66 -0
- package/dist/application/get-graph-contexts.js +33 -0
- package/dist/application/get-graph-layout.js +62 -8
- package/dist/application/get-graph-stream-chunk.js +326 -0
- package/dist/application/get-graph-view.js +246 -0
- package/dist/application/graph-view-state.js +66 -0
- package/dist/application/import-legacy-sqlite.js +266 -0
- package/dist/application/index-vault.js +262 -23
- package/dist/application/migrate-context-links.js +79 -0
- package/dist/application/offline-pack-backup.js +44 -0
- package/dist/application/search-graph-node-ids.js +63 -3
- package/dist/application/server/routes.js +247 -7
- package/dist/application/start-server.js +75 -4
- package/dist/application/watch-vault.js +23 -2
- package/dist/cli/commands/agent-commands.js +7 -0
- package/dist/cli/commands/write-commands.js +924 -14
- package/dist/cli/runtime.js +10 -2
- package/dist/domain/context.js +54 -11
- package/dist/domain/graph-contexts.js +180 -0
- package/dist/domain/graph-layout.js +389 -18
- package/dist/domain/markdown.js +53 -9
- package/dist/domain/middle-out.js +18 -0
- package/dist/infrastructure/config.js +121 -4
- package/dist/infrastructure/file-index.js +76 -6
- package/dist/infrastructure/file-system-vault.js +15 -0
- package/dist/infrastructure/index-state.js +58 -0
- package/dist/infrastructure/private-pack-codec.js +71 -10
- package/dist/infrastructure/search-packs.js +286 -15
- package/dist/infrastructure/vault-migration-state.js +69 -0
- package/dist/infrastructure/volatile-memory.js +100 -0
- package/dist/mcp/runtime.js +20 -0
- package/dist/mcp/server.js +39 -11
- package/dist/mcp/tools.js +183 -7
- package/docs/AGENT_USAGE.md +96 -5
- package/docs/ARCHITECTURE.md +8 -0
- package/docs/QUICKSTART.md +7 -0
- package/package.json +7 -2
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { createIndexedDocument, graphLinkModelVersion, 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';
|
|
7
9
|
import { openFileIndex } from '../infrastructure/file-index.js';
|
|
8
10
|
const toTitleKey = (title) => title.toLowerCase();
|
|
9
11
|
const appendTitleEntry = (map, document) => {
|
|
@@ -34,6 +36,9 @@ const createScopedTitleResolver = (document, titleMaps) => ({
|
|
|
34
36
|
get: (title) => titleMaps.byAgent.get(document.agentId)?.get(title)?.id ?? titleMaps.shared.get(title)?.id
|
|
35
37
|
});
|
|
36
38
|
const embedIndexedDocuments = async (documents, providerName) => {
|
|
39
|
+
if (documents.length === 0) {
|
|
40
|
+
return documents;
|
|
41
|
+
}
|
|
37
42
|
const provider = createEmbeddingProvider(providerName);
|
|
38
43
|
const chunks = documents.flatMap((document) => document.chunks);
|
|
39
44
|
const embeddings = await provider.embed(chunks.map((chunk) => chunk.content));
|
|
@@ -47,34 +52,268 @@ const embedIndexedDocuments = async (documents, providerName) => {
|
|
|
47
52
|
}))
|
|
48
53
|
}));
|
|
49
54
|
};
|
|
50
|
-
|
|
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
|
+
};
|
|
88
|
+
export const indexVault = async (vaultPath, options = {}) => {
|
|
89
|
+
return indexVaultWithOptions(vaultPath, options);
|
|
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');
|
|
51
105
|
const absoluteVaultPath = await ensureVault(vaultPath);
|
|
52
106
|
const config = await loadBrainlinkConfig();
|
|
53
|
-
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
const
|
|
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 fullReindex = options.full === true;
|
|
63
117
|
const index = openFileIndex(absoluteVaultPath);
|
|
64
118
|
try {
|
|
65
|
-
await index.
|
|
119
|
+
const existingIndexedDocuments = await index.getIndexedDocuments();
|
|
120
|
+
const existingByPath = new Map(existingIndexedDocuments.map((document) => [document.document.path, document]));
|
|
121
|
+
const currentSnapshot = toSnapshot(summaries);
|
|
122
|
+
const currentSnapshotMap = createSnapshotMap(currentSnapshot);
|
|
123
|
+
const previousSnapshotMap = createSnapshotMap(previousState?.files ?? []);
|
|
124
|
+
const graphLinkModelChanged = previousState != null &&
|
|
125
|
+
previousState.graphLinkModelVersion !== graphLinkModelVersion;
|
|
126
|
+
const fullSourceReindex = fullReindex || graphLinkModelChanged;
|
|
127
|
+
const settingsChanged = previousState == null ||
|
|
128
|
+
previousState.chunkSize !== config.chunkSize ||
|
|
129
|
+
previousState.embeddingProvider !== config.embeddingProvider ||
|
|
130
|
+
graphLinkModelChanged;
|
|
131
|
+
const packSettingsChanged = previousState == null ||
|
|
132
|
+
previousState.searchPackRowChunkSize !== config.searchPack.rowChunkSize ||
|
|
133
|
+
previousState.searchPackCompressionLevel !== config.searchPack.compressionLevel ||
|
|
134
|
+
previousState.searchPackUseDictionary !== config.searchPack.useDictionary;
|
|
135
|
+
const changedPaths = new Set();
|
|
136
|
+
for (let index = 0; index < summaries.length; index += 1) {
|
|
137
|
+
const summary = summaries[index];
|
|
138
|
+
const previous = previousSnapshotMap.get(summary.relativePath);
|
|
139
|
+
const changed = fullSourceReindex ||
|
|
140
|
+
settingsChanged ||
|
|
141
|
+
previous == null ||
|
|
142
|
+
previous.mtimeMs !== summary.updatedAt.getTime() ||
|
|
143
|
+
previous.size !== summary.size ||
|
|
144
|
+
!existingByPath.has(summary.relativePath);
|
|
145
|
+
if (changed) {
|
|
146
|
+
changedPaths.add(summary.relativePath);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
const hasDeletes = previousState
|
|
150
|
+
? previousState.files.some((entry) => !currentSnapshotMap.has(entry.path))
|
|
151
|
+
: false;
|
|
152
|
+
const manifestRecovery = await ensureSearchPackManifest(absoluteVaultPath);
|
|
153
|
+
if (changedPaths.size === 0 &&
|
|
154
|
+
!hasDeletes &&
|
|
155
|
+
existingIndexedDocuments.length === summaries.length &&
|
|
156
|
+
previousState != null &&
|
|
157
|
+
!fullReindex) {
|
|
158
|
+
const result = {
|
|
159
|
+
...toIndexResult(existingIndexedDocuments),
|
|
160
|
+
elapsedMs: elapsedMs(),
|
|
161
|
+
changedDocumentCount: 0,
|
|
162
|
+
packs: {
|
|
163
|
+
rebuilt: false,
|
|
164
|
+
reason: manifestRecovery.repaired ? 'No changes detected; pack manifest repaired' : 'No changes detected'
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
emit('complete', 'skip', 'Index skipped: no changes detected', {
|
|
168
|
+
elapsedMs: result.elapsedMs,
|
|
169
|
+
manifestRepaired: manifestRecovery.repaired,
|
|
170
|
+
manifestRecoverySource: manifestRecovery.source
|
|
171
|
+
});
|
|
172
|
+
return result;
|
|
173
|
+
}
|
|
174
|
+
const changedSummaries = summaries.filter((summary) => changedPaths.has(summary.relativePath));
|
|
175
|
+
emit('parse', 'start', 'Parsing changed markdown files', {
|
|
176
|
+
changedFiles: changedSummaries.length
|
|
177
|
+
});
|
|
178
|
+
const changedDocumentsByPath = await readChangedDocuments(absoluteVaultPath, changedSummaries);
|
|
179
|
+
emit('parse', 'finish', 'Parse complete', {
|
|
180
|
+
changedDocuments: changedDocumentsByPath.size
|
|
181
|
+
});
|
|
182
|
+
const documents = summaries.flatMap((summary) => {
|
|
183
|
+
const changed = changedDocumentsByPath.get(summary.relativePath);
|
|
184
|
+
if (changed) {
|
|
185
|
+
return [changed];
|
|
186
|
+
}
|
|
187
|
+
const existing = existingByPath.get(summary.relativePath);
|
|
188
|
+
return existing ? [existing.document] : [];
|
|
189
|
+
});
|
|
190
|
+
const titleMaps = createTitleMaps(documents);
|
|
191
|
+
emit('embed', 'start', 'Embedding changed chunks', {
|
|
192
|
+
changedDocuments: changedDocumentsByPath.size
|
|
193
|
+
});
|
|
194
|
+
const changedIndexedDocuments = changedDocumentsByPath.size > 0
|
|
195
|
+
? await embedIndexedDocuments(Array.from(changedDocumentsByPath.values()).map((document) => createIndexedDocument(document, createScopedTitleResolver(document, titleMaps), config.chunkSize)), config.embeddingProvider)
|
|
196
|
+
: [];
|
|
197
|
+
emit('embed', changedDocumentsByPath.size > 0 ? 'finish' : 'skip', changedDocumentsByPath.size > 0 ? 'Embedding complete' : 'Embedding skipped', {
|
|
198
|
+
changedIndexedDocuments: changedIndexedDocuments.length
|
|
199
|
+
});
|
|
200
|
+
const changedIndexedByPath = new Map(changedIndexedDocuments.map((document) => [document.document.path, document]));
|
|
201
|
+
const needsRelink = settingsChanged || hasDeletes || changedPaths.size > 0;
|
|
202
|
+
const indexedDocuments = documents.map((document) => {
|
|
203
|
+
const changed = changedIndexedByPath.get(document.path);
|
|
204
|
+
if (changed) {
|
|
205
|
+
return changed;
|
|
206
|
+
}
|
|
207
|
+
const existing = existingByPath.get(document.path);
|
|
208
|
+
if (!existing) {
|
|
209
|
+
return createIndexedDocument(document, createScopedTitleResolver(document, titleMaps), config.chunkSize);
|
|
210
|
+
}
|
|
211
|
+
return needsRelink ? relinkIndexedDocument(existing, titleMaps) : existing;
|
|
212
|
+
});
|
|
213
|
+
emit('persist', 'start', 'Persisting index');
|
|
66
214
|
await index.saveDocuments(indexedDocuments);
|
|
67
|
-
|
|
68
|
-
|
|
215
|
+
emit('persist', 'finish', 'Index persisted', {
|
|
216
|
+
indexedDocuments: indexedDocuments.length
|
|
217
|
+
});
|
|
218
|
+
const existingPackManifest = manifestRecovery.repaired || manifestRecovery.source === 'not-needed';
|
|
219
|
+
const changedCount = changedPaths.size;
|
|
220
|
+
const documentCount = Math.max(indexedDocuments.length, 1);
|
|
221
|
+
const changeRatio = changedCount / documentCount;
|
|
222
|
+
const previousPendingPackChanges = previousState?.pendingPackChanges ?? 0;
|
|
223
|
+
const pendingPackChanges = previousPendingPackChanges + changedCount;
|
|
224
|
+
const shouldRebuildPacks = !existingPackManifest ||
|
|
225
|
+
fullReindex ||
|
|
226
|
+
graphLinkModelChanged ||
|
|
227
|
+
settingsChanged ||
|
|
228
|
+
packSettingsChanged ||
|
|
229
|
+
hasDeletes ||
|
|
230
|
+
changedCount >= 400 ||
|
|
231
|
+
changeRatio >= 0.04 ||
|
|
232
|
+
pendingPackChanges >= 1200;
|
|
233
|
+
let packResult;
|
|
234
|
+
const packReason = !existingPackManifest
|
|
235
|
+
? 'Missing pack manifest'
|
|
236
|
+
: fullReindex
|
|
237
|
+
? 'Full reindex requested'
|
|
238
|
+
: graphLinkModelChanged
|
|
239
|
+
? 'Graph link model changed'
|
|
240
|
+
: manifestRecovery.repaired
|
|
241
|
+
? 'Pack manifest repaired from existing packs'
|
|
242
|
+
: settingsChanged
|
|
243
|
+
? 'Index settings changed'
|
|
244
|
+
: packSettingsChanged
|
|
245
|
+
? 'Search pack settings changed'
|
|
246
|
+
: hasDeletes
|
|
247
|
+
? 'Document deletions detected'
|
|
248
|
+
: changedCount >= 400
|
|
249
|
+
? 'Changed file count threshold reached'
|
|
250
|
+
: changeRatio >= 0.04
|
|
251
|
+
? 'Change ratio threshold reached'
|
|
252
|
+
: pendingPackChanges >= 1200
|
|
253
|
+
? 'Pending pack changes threshold reached'
|
|
254
|
+
: 'Pack rebuild skipped';
|
|
255
|
+
if (shouldRebuildPacks) {
|
|
256
|
+
emit('packs', 'start', 'Rebuilding compressed search packs', {
|
|
257
|
+
reason: packReason
|
|
258
|
+
});
|
|
259
|
+
try {
|
|
260
|
+
packResult = await buildSearchPacks(absoluteVaultPath, indexedDocuments, toSearchPackBuildOptions(config));
|
|
261
|
+
emit('packs', 'finish', 'Compressed packs rebuilt', {
|
|
262
|
+
reason: packReason,
|
|
263
|
+
packCount: packResult.packCount,
|
|
264
|
+
recordCount: packResult.recordCount,
|
|
265
|
+
durationMs: packResult.durationMs,
|
|
266
|
+
compressionRatio: packResult.compression.ratio
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
catch {
|
|
270
|
+
// Pack generation is best-effort. The JSON index remains the primary path.
|
|
271
|
+
emit('packs', 'skip', 'Pack rebuild failed; continuing with JSON index', {
|
|
272
|
+
reason: packReason
|
|
273
|
+
});
|
|
274
|
+
}
|
|
69
275
|
}
|
|
70
|
-
|
|
71
|
-
|
|
276
|
+
else {
|
|
277
|
+
emit('packs', 'skip', 'Pack rebuild not required', {
|
|
278
|
+
reason: packReason
|
|
279
|
+
});
|
|
72
280
|
}
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
281
|
+
const packsRebuilt = packResult != null;
|
|
282
|
+
const packResultReason = shouldRebuildPacks && !packsRebuilt ? `${packReason} (failed)` : packReason;
|
|
283
|
+
await writeIndexState(absoluteVaultPath, {
|
|
284
|
+
chunkSize: config.chunkSize,
|
|
285
|
+
embeddingProvider: config.embeddingProvider,
|
|
286
|
+
graphLinkModelVersion,
|
|
287
|
+
searchPackRowChunkSize: config.searchPack.rowChunkSize,
|
|
288
|
+
searchPackCompressionLevel: config.searchPack.compressionLevel,
|
|
289
|
+
searchPackUseDictionary: config.searchPack.useDictionary,
|
|
290
|
+
files: currentSnapshot,
|
|
291
|
+
pendingPackChanges: packsRebuilt ? 0 : pendingPackChanges
|
|
292
|
+
});
|
|
293
|
+
const result = {
|
|
294
|
+
...toIndexResult(indexedDocuments),
|
|
295
|
+
elapsedMs: elapsedMs(),
|
|
296
|
+
changedDocumentCount: changedDocumentsByPath.size,
|
|
297
|
+
packs: {
|
|
298
|
+
rebuilt: packsRebuilt,
|
|
299
|
+
reason: packResultReason,
|
|
300
|
+
...(packResult
|
|
301
|
+
? {
|
|
302
|
+
packCount: packResult.packCount,
|
|
303
|
+
recordCount: packResult.recordCount,
|
|
304
|
+
durationMs: packResult.durationMs,
|
|
305
|
+
compression: packResult.compression
|
|
306
|
+
}
|
|
307
|
+
: {})
|
|
308
|
+
}
|
|
77
309
|
};
|
|
310
|
+
emit('complete', 'finish', 'Indexing complete', {
|
|
311
|
+
documentCount: result.documentCount,
|
|
312
|
+
chunkCount: result.chunkCount,
|
|
313
|
+
linkCount: result.linkCount,
|
|
314
|
+
elapsedMs: result.elapsedMs
|
|
315
|
+
});
|
|
316
|
+
return result;
|
|
78
317
|
}
|
|
79
318
|
finally {
|
|
80
319
|
index.close();
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { ensureVault, readMarkdownFileSummaries, writeMarkdownFile } from '../infrastructure/file-system-vault.js';
|
|
3
|
+
import { extractContextLinkWeights, extractWikiLinkWeights, hasContextLinksSection, parseMarkdownDocument } from '../domain/markdown.js';
|
|
4
|
+
const defaultContextLinkLimit = 5;
|
|
5
|
+
const normalizeTitle = (title) => title.trim().replace(/\.md$/i, '').toLowerCase();
|
|
6
|
+
const formatPriority = (priority) => priority === 'normal' ? '' : ` priority: ${priority}`;
|
|
7
|
+
const formatContextLinksSection = (links) => [
|
|
8
|
+
'## Context Links',
|
|
9
|
+
'',
|
|
10
|
+
...links.map((link) => `- [[${link.title}]]${formatPriority(link.priority)}`)
|
|
11
|
+
].join('\n');
|
|
12
|
+
const appendContextLinksSection = (content, links) => `${content.trimEnd()}\n\n${formatContextLinksSection(links)}\n`;
|
|
13
|
+
const selectContextLinkCandidates = (content, title, limit) => extractWikiLinkWeights(content)
|
|
14
|
+
.filter((link) => normalizeTitle(link.title) !== normalizeTitle(title))
|
|
15
|
+
.slice(0, limit)
|
|
16
|
+
.map((link) => ({
|
|
17
|
+
title: link.title,
|
|
18
|
+
priority: link.priority,
|
|
19
|
+
weight: link.weight
|
|
20
|
+
}));
|
|
21
|
+
export const migrateContextLinks = async (vaultPath, options = {}) => {
|
|
22
|
+
const absoluteVaultPath = await ensureVault(vaultPath);
|
|
23
|
+
const limit = Math.max(1, Math.floor(options.limit ?? defaultContextLinkLimit));
|
|
24
|
+
const summaries = await readMarkdownFileSummaries(absoluteVaultPath);
|
|
25
|
+
const entries = [];
|
|
26
|
+
for (const summary of summaries) {
|
|
27
|
+
const content = await readFile(summary.absolutePath, 'utf8');
|
|
28
|
+
const document = parseMarkdownDocument({
|
|
29
|
+
absolutePath: summary.absolutePath,
|
|
30
|
+
vaultPath: absoluteVaultPath,
|
|
31
|
+
content,
|
|
32
|
+
createdAt: summary.createdAt,
|
|
33
|
+
updatedAt: summary.updatedAt
|
|
34
|
+
});
|
|
35
|
+
if (options.agentId && document.agentId !== options.agentId) {
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
if (hasContextLinksSection(content)) {
|
|
39
|
+
entries.push({
|
|
40
|
+
path: summary.relativePath,
|
|
41
|
+
title: document.title,
|
|
42
|
+
changed: false,
|
|
43
|
+
reason: 'already-has-context-links',
|
|
44
|
+
links: extractContextLinkWeights(content)
|
|
45
|
+
});
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
const links = selectContextLinkCandidates(content, document.title, limit);
|
|
49
|
+
if (links.length === 0) {
|
|
50
|
+
entries.push({
|
|
51
|
+
path: summary.relativePath,
|
|
52
|
+
title: document.title,
|
|
53
|
+
changed: false,
|
|
54
|
+
reason: 'no-link-candidates',
|
|
55
|
+
links
|
|
56
|
+
});
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (!options.dryRun) {
|
|
60
|
+
await writeMarkdownFile(vaultPath, summary.relativePath, appendContextLinksSection(content, links));
|
|
61
|
+
}
|
|
62
|
+
entries.push({
|
|
63
|
+
path: summary.relativePath,
|
|
64
|
+
title: document.title,
|
|
65
|
+
changed: true,
|
|
66
|
+
reason: 'added-context-links',
|
|
67
|
+
links
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
const changed = entries.filter((entry) => entry.changed).length;
|
|
71
|
+
return {
|
|
72
|
+
dryRun: options.dryRun === true,
|
|
73
|
+
scanned: entries.length,
|
|
74
|
+
changed,
|
|
75
|
+
skipped: entries.length - changed,
|
|
76
|
+
limit,
|
|
77
|
+
entries
|
|
78
|
+
};
|
|
79
|
+
};
|
|
@@ -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
|
+
};
|
|
@@ -1,10 +1,70 @@
|
|
|
1
|
+
import { stat } from 'node:fs/promises';
|
|
1
2
|
import { ensureVault } from '../infrastructure/file-system-vault.js';
|
|
2
|
-
import { openFileIndex } from '../infrastructure/file-index.js';
|
|
3
|
-
|
|
3
|
+
import { indexStoragePath, openFileIndex } from '../infrastructure/file-index.js';
|
|
4
|
+
import { getGraphLayout } from './get-graph-layout.js';
|
|
5
|
+
const graphSearchCacheTtlMs = 20_000;
|
|
6
|
+
const graphSearchCacheMaxEntries = 120;
|
|
7
|
+
const graphSearchCache = new Map();
|
|
8
|
+
const readIndexSignature = async (vaultPath) => {
|
|
9
|
+
try {
|
|
10
|
+
const info = await stat(indexStoragePath(vaultPath));
|
|
11
|
+
return `${Math.floor(info.mtimeMs)}:${info.size}`;
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return '0:0';
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
const cacheKey = (vaultPath, query, limit, agentId, context) => JSON.stringify({
|
|
18
|
+
vaultPath,
|
|
19
|
+
query: query.trim().toLowerCase(),
|
|
20
|
+
limit,
|
|
21
|
+
agentId: agentId?.trim().toLowerCase() ?? '*',
|
|
22
|
+
context: context?.trim().toLowerCase() ?? '*'
|
|
23
|
+
});
|
|
24
|
+
const readCached = (key, indexSignature) => {
|
|
25
|
+
const entry = graphSearchCache.get(key);
|
|
26
|
+
if (!entry) {
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
const fresh = Date.now() - entry.createdAt <= graphSearchCacheTtlMs && entry.indexSignature === indexSignature;
|
|
30
|
+
if (!fresh) {
|
|
31
|
+
graphSearchCache.delete(key);
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
return entry.nodeIds;
|
|
35
|
+
};
|
|
36
|
+
const writeCached = (key, entry) => {
|
|
37
|
+
graphSearchCache.set(key, entry);
|
|
38
|
+
if (graphSearchCache.size <= graphSearchCacheMaxEntries) {
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
const overflow = graphSearchCache.size - graphSearchCacheMaxEntries;
|
|
42
|
+
Array.from(graphSearchCache.keys()).slice(0, overflow).forEach((cacheKey) => graphSearchCache.delete(cacheKey));
|
|
43
|
+
};
|
|
44
|
+
export const searchGraphNodeIds = async (vaultPath, query, limit, agentId, context) => {
|
|
4
45
|
const absoluteVaultPath = await ensureVault(vaultPath);
|
|
46
|
+
const indexSignature = await readIndexSignature(absoluteVaultPath);
|
|
47
|
+
const key = cacheKey(absoluteVaultPath, query, limit, agentId, context);
|
|
48
|
+
const cached = readCached(key, indexSignature);
|
|
49
|
+
if (cached) {
|
|
50
|
+
return cached;
|
|
51
|
+
}
|
|
52
|
+
const contextNodeIds = context
|
|
53
|
+
? new Set((await getGraphLayout(absoluteVaultPath, { agentId, context })).layout.nodes.map((node) => node.id))
|
|
54
|
+
: new Set();
|
|
5
55
|
const index = openFileIndex(absoluteVaultPath);
|
|
6
56
|
try {
|
|
7
|
-
|
|
57
|
+
const searchLimit = context ? Math.max(limit, 5000) : limit;
|
|
58
|
+
const foundNodeIds = await index.searchGraphNodeIds(query, searchLimit, agentId);
|
|
59
|
+
const nodeIds = context
|
|
60
|
+
? foundNodeIds.filter((nodeId) => contextNodeIds.has(nodeId)).slice(0, limit)
|
|
61
|
+
: foundNodeIds;
|
|
62
|
+
writeCached(key, {
|
|
63
|
+
createdAt: Date.now(),
|
|
64
|
+
indexSignature,
|
|
65
|
+
nodeIds
|
|
66
|
+
});
|
|
67
|
+
return nodeIds;
|
|
8
68
|
}
|
|
9
69
|
finally {
|
|
10
70
|
index.close();
|