@andespindola/brainlink 0.1.0-beta.42 → 0.1.0-beta.44
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 +3 -0
- package/CHANGELOG.md +6 -0
- package/README.md +41 -1
- package/dist/application/index-vault.js +128 -18
- package/dist/application/offline-pack-backup.js +44 -0
- package/dist/application/watch-vault.js +23 -2
- package/dist/cli/commands/write-commands.js +201 -2
- package/dist/infrastructure/config.js +38 -0
- package/dist/infrastructure/index-state.js +6 -0
- package/dist/infrastructure/private-pack-codec.js +71 -10
- package/dist/infrastructure/search-packs.js +114 -10
- package/docs/AGENT_USAGE.md +33 -0
- package/package.json +1 -1
package/AGENTS.md
CHANGED
|
@@ -83,6 +83,9 @@ Use watch mode while editing notes:
|
|
|
83
83
|
```bash
|
|
84
84
|
npm run dev -- server --vault ./vault --watch
|
|
85
85
|
npm run dev -- watch --vault ./vault
|
|
86
|
+
npm run dev -- bench --vault ./vault
|
|
87
|
+
npm run dev -- bench --vault ./vault --watch
|
|
88
|
+
npm run dev -- pack-backup --vault ./vault
|
|
86
89
|
```
|
|
87
90
|
|
|
88
91
|
Start MCP over stdio:
|
package/CHANGELOG.md
CHANGED
|
@@ -38,6 +38,12 @@
|
|
|
38
38
|
- Improved Linux browser fallback launch stability by auto-applying Chromium compatibility flags (`--ozone-platform=x11`, `--disable-gpu`, `--disable-features=Vulkan,VaapiVideoDecoder`, `--disable-background-networking`) for app-window/browser modes.
|
|
39
39
|
- Improved massive-graph UI responsiveness with stricter render budgets, adaptive heavy-graph frame throttling, reduced interaction hit-test frequency, and URL-first agent selection on initial graph load.
|
|
40
40
|
- Improved 50k+ graph LOD behavior so zoomed-out views render lightweight cluster overviews and progressively reveal nodes/edges only as zoom increases.
|
|
41
|
+
- Added `blink bench` with realtime index phase telemetry and per-run compressed-pack analysis (input/output bytes, ratio, saved space, rebuild reason and duration), including continuous watch mode.
|
|
42
|
+
- Added tunable single-stage search-pack compression settings (`searchPack.rowChunkSize`, `searchPack.compressionLevel`, `searchPack.useDictionary`).
|
|
43
|
+
- Added benchmark guardrails for compression savings and latency regression (`searchPack.guardrailMinSavingsPercent`, `searchPack.guardrailMaxLatencyRegressionPercent`), reported in `blink bench`.
|
|
44
|
+
- Added `blink pack-backup` for offline second-stage compression backups of encrypted `.blpk` packs, outside the online query path.
|
|
45
|
+
- Hardened Linux browser launch flags for Ubuntu 26 Chromium/Wayland compatibility (`--disable-vulkan`, `--use-gl=swiftshader`, `--ozone-platform-hint=x11`).
|
|
46
|
+
- Improved pack resilience by auto-repairing missing search-pack manifests from existing `.blpk` files, avoiding unnecessary full repacks on small incremental updates.
|
|
41
47
|
|
|
42
48
|
## 0.1.0-beta.3
|
|
43
49
|
|
package/README.md
CHANGED
|
@@ -58,6 +58,7 @@ LLMs do not have infinite context. Brainlink gives agents an external memory lay
|
|
|
58
58
|
|
|
59
59
|
Markdown is the source of truth. `.brainlink/index.json` is a rebuildable index artifact.
|
|
60
60
|
After each index run, Brainlink also writes private encrypted search packs at `.brainlink/search-packs/*.blpk` to preserve fast retrieval and portable recovery.
|
|
61
|
+
Online retrieval always uses a single compression stage per pack; optional second-stage compression is reserved for offline backup artifacts only.
|
|
61
62
|
Pack decryption uses a Brainlink key from `$BRAINLINK_HOME/keys` or from `BRAINLINK_SEARCH_PACK_KEY` when explicitly configured.
|
|
62
63
|
Legacy `.jsonl.gz` packs are upgraded to `.blpk` automatically on first search/context access.
|
|
63
64
|
|
|
@@ -570,7 +571,7 @@ By default, `blink server` tries to open the graph in a native desktop GUI windo
|
|
|
570
571
|
|
|
571
572
|
On Linux, native GUI is disabled by default for better startup performance. Enable it with `BRAINLINK_LINUX_NATIVE_GUI=1`.
|
|
572
573
|
If native GUI launch is unavailable on your system, it falls back to dedicated app-window mode and then to the default browser.
|
|
573
|
-
For Chromium-family browsers on Linux (`chromium`, `chromium-browser`, `google-chrome`, `microsoft-edge`, `brave-browser`), Brainlink now auto-applies compatibility flags during launch (`--ozone-platform=x11`, `--disable-gpu`, `--disable-features=Vulkan,VaapiVideoDecoder`, `--disable-background-networking`) to avoid common Wayland/Vulkan/VAAPI startup issues.
|
|
574
|
+
For Chromium-family browsers on Linux (`chromium`, `chromium-browser`, `google-chrome`, `microsoft-edge`, `brave-browser`), Brainlink now auto-applies compatibility flags during launch (`--ozone-platform=x11`, `--ozone-platform-hint=x11`, `--disable-gpu`, `--disable-vulkan`, `--use-gl=swiftshader`, `--disable-features=Vulkan,VaapiVideoDecoder`, `--disable-background-networking`) to avoid common Wayland/Vulkan/VAAPI startup issues.
|
|
574
575
|
Use `--no-open` to keep it headless.
|
|
575
576
|
When native GUI is used, the GUI window automatically closes when the `blink server` process stops.
|
|
576
577
|
|
|
@@ -760,6 +761,38 @@ blink index --vault ./vault
|
|
|
760
761
|
|
|
761
762
|
Rebuilds the local index from Markdown files.
|
|
762
763
|
|
|
764
|
+
### `bench`
|
|
765
|
+
|
|
766
|
+
```bash
|
|
767
|
+
blink bench --vault ./vault
|
|
768
|
+
blink bench --vault ./vault --watch
|
|
769
|
+
blink bench --vault ./vault --watch --debounce 500
|
|
770
|
+
blink bench --vault ./vault --json
|
|
771
|
+
```
|
|
772
|
+
|
|
773
|
+
Runs indexing with realtime phase telemetry (`start`, `scan`, `parse`, `embed`, `persist`, `packs`, `complete`) and prints a benchmark summary at the end of each run.
|
|
774
|
+
|
|
775
|
+
Summary includes compression behavior for `.blpk` packs when rebuild happens:
|
|
776
|
+
- pack rebuild reason
|
|
777
|
+
- pack count and pack build duration
|
|
778
|
+
- uncompressed input bytes vs compressed output bytes
|
|
779
|
+
- saved percentage
|
|
780
|
+
- objective guardrails (minimum savings and maximum latency regression thresholds)
|
|
781
|
+
|
|
782
|
+
Use `--watch` to keep benchmarking incremental reindex runs after Markdown changes (local filesystem vaults only).
|
|
783
|
+
When `.brainlink/search-packs/manifest.json` is missing but `.blpk` files exist, Brainlink repairs the manifest first and avoids unnecessary full pack rebuild on small edits.
|
|
784
|
+
|
|
785
|
+
### `pack-backup`
|
|
786
|
+
|
|
787
|
+
```bash
|
|
788
|
+
blink pack-backup --vault ./vault
|
|
789
|
+
blink pack-backup --vault ./vault --output ./vault/.brainlink/backups/custom.blpkbak.gz
|
|
790
|
+
blink pack-backup --vault ./vault --json
|
|
791
|
+
```
|
|
792
|
+
|
|
793
|
+
Creates an offline backup artifact of encrypted search packs with a second compression pass.
|
|
794
|
+
This is intentionally outside the online retrieval path (`index`, `search`, `context`).
|
|
795
|
+
|
|
763
796
|
### `agents`
|
|
764
797
|
|
|
765
798
|
```bash
|
|
@@ -932,6 +965,13 @@ If no `vault` is configured and no `--vault` flag is passed, Brainlink uses `$HO
|
|
|
932
965
|
"embeddingProvider": "local",
|
|
933
966
|
"defaultSearchMode": "hybrid",
|
|
934
967
|
"chunkSize": 1200,
|
|
968
|
+
"searchPack": {
|
|
969
|
+
"rowChunkSize": 5000,
|
|
970
|
+
"compressionLevel": 5,
|
|
971
|
+
"useDictionary": true,
|
|
972
|
+
"guardrailMinSavingsPercent": 8,
|
|
973
|
+
"guardrailMaxLatencyRegressionPercent": 5
|
|
974
|
+
},
|
|
935
975
|
"agentProfiles": {
|
|
936
976
|
"coding-agent": {
|
|
937
977
|
"defaultSearchMode": "semantic",
|
|
@@ -1,12 +1,11 @@
|
|
|
1
|
-
import { readFile
|
|
2
|
-
import { join } from 'node:path';
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
3
2
|
import { createIndexedDocument, parseMarkdownDocument } from '../domain/markdown.js';
|
|
4
3
|
import { sharedAgentId } from '../domain/agents.js';
|
|
5
4
|
import { createEmbeddingProvider } from '../domain/embeddings.js';
|
|
6
5
|
import { loadBrainlinkConfig } from '../infrastructure/config.js';
|
|
7
6
|
import { ensureVault, readMarkdownFileSummaries } from '../infrastructure/file-system-vault.js';
|
|
8
7
|
import { readIndexState, writeIndexState } from '../infrastructure/index-state.js';
|
|
9
|
-
import { buildSearchPacks } from '../infrastructure/search-packs.js';
|
|
8
|
+
import { buildSearchPacks, ensureSearchPackManifest, toSearchPackBuildOptions } from '../infrastructure/search-packs.js';
|
|
10
9
|
import { openFileIndex } from '../infrastructure/file-index.js';
|
|
11
10
|
const toTitleKey = (title) => title.toLowerCase();
|
|
12
11
|
const appendTitleEntry = (map, document) => {
|
|
@@ -76,16 +75,6 @@ const toSnapshot = (summaries) => summaries.map((summary) => ({
|
|
|
76
75
|
size: summary.size
|
|
77
76
|
}));
|
|
78
77
|
const createSnapshotMap = (snapshot) => new Map(snapshot.map((entry) => [entry.path, entry]));
|
|
79
|
-
const packManifestPath = (vaultPath) => join(vaultPath, '.brainlink', 'search-packs', 'manifest.json');
|
|
80
|
-
const hasPackManifest = async (vaultPath) => {
|
|
81
|
-
try {
|
|
82
|
-
await stat(packManifestPath(vaultPath));
|
|
83
|
-
return true;
|
|
84
|
-
}
|
|
85
|
-
catch {
|
|
86
|
-
return false;
|
|
87
|
-
}
|
|
88
|
-
};
|
|
89
78
|
const readChangedDocuments = async (absoluteVaultPath, changedSummaries) => {
|
|
90
79
|
const parsed = await Promise.all(changedSummaries.map(async (summary) => parseMarkdownDocument({
|
|
91
80
|
absolutePath: summary.absolutePath,
|
|
@@ -97,12 +86,33 @@ const readChangedDocuments = async (absoluteVaultPath, changedSummaries) => {
|
|
|
97
86
|
return new Map(parsed.map((document) => [document.path, document]));
|
|
98
87
|
};
|
|
99
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');
|
|
100
105
|
const absoluteVaultPath = await ensureVault(vaultPath);
|
|
101
106
|
const config = await loadBrainlinkConfig();
|
|
107
|
+
emit('scan', 'start', 'Scanning markdown files');
|
|
102
108
|
const [summaries, previousState] = await Promise.all([
|
|
103
109
|
readMarkdownFileSummaries(absoluteVaultPath),
|
|
104
110
|
readIndexState(absoluteVaultPath)
|
|
105
111
|
]);
|
|
112
|
+
emit('scan', 'finish', 'Scan complete', {
|
|
113
|
+
markdownFiles: summaries.length,
|
|
114
|
+
hasPreviousState: previousState != null
|
|
115
|
+
});
|
|
106
116
|
const index = openFileIndex(absoluteVaultPath);
|
|
107
117
|
try {
|
|
108
118
|
const existingIndexedDocuments = await index.getIndexedDocuments();
|
|
@@ -113,6 +123,10 @@ export const indexVault = async (vaultPath) => {
|
|
|
113
123
|
const settingsChanged = previousState == null ||
|
|
114
124
|
previousState.chunkSize !== config.chunkSize ||
|
|
115
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;
|
|
116
130
|
const changedPaths = new Set();
|
|
117
131
|
for (let index = 0; index < summaries.length; index += 1) {
|
|
118
132
|
const summary = summaries[index];
|
|
@@ -129,14 +143,35 @@ export const indexVault = async (vaultPath) => {
|
|
|
129
143
|
const hasDeletes = previousState
|
|
130
144
|
? previousState.files.some((entry) => !currentSnapshotMap.has(entry.path))
|
|
131
145
|
: false;
|
|
146
|
+
const manifestRecovery = await ensureSearchPackManifest(absoluteVaultPath);
|
|
132
147
|
if (changedPaths.size === 0 &&
|
|
133
148
|
!hasDeletes &&
|
|
134
149
|
existingIndexedDocuments.length === summaries.length &&
|
|
135
150
|
previousState != null) {
|
|
136
|
-
|
|
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;
|
|
137
166
|
}
|
|
138
167
|
const changedSummaries = summaries.filter((summary) => changedPaths.has(summary.relativePath));
|
|
168
|
+
emit('parse', 'start', 'Parsing changed markdown files', {
|
|
169
|
+
changedFiles: changedSummaries.length
|
|
170
|
+
});
|
|
139
171
|
const changedDocumentsByPath = await readChangedDocuments(absoluteVaultPath, changedSummaries);
|
|
172
|
+
emit('parse', 'finish', 'Parse complete', {
|
|
173
|
+
changedDocuments: changedDocumentsByPath.size
|
|
174
|
+
});
|
|
140
175
|
const documents = summaries.flatMap((summary) => {
|
|
141
176
|
const changed = changedDocumentsByPath.get(summary.relativePath);
|
|
142
177
|
if (changed) {
|
|
@@ -146,9 +181,15 @@ export const indexVault = async (vaultPath) => {
|
|
|
146
181
|
return existing ? [existing.document] : [];
|
|
147
182
|
});
|
|
148
183
|
const titleMaps = createTitleMaps(documents);
|
|
184
|
+
emit('embed', 'start', 'Embedding changed chunks', {
|
|
185
|
+
changedDocuments: changedDocumentsByPath.size
|
|
186
|
+
});
|
|
149
187
|
const changedIndexedDocuments = changedDocumentsByPath.size > 0
|
|
150
188
|
? await embedIndexedDocuments(Array.from(changedDocumentsByPath.values()).map((document) => createIndexedDocument(document, createScopedTitleResolver(document, titleMaps), config.chunkSize)), config.embeddingProvider)
|
|
151
189
|
: [];
|
|
190
|
+
emit('embed', changedDocumentsByPath.size > 0 ? 'finish' : 'skip', changedDocumentsByPath.size > 0 ? 'Embedding complete' : 'Embedding skipped', {
|
|
191
|
+
changedIndexedDocuments: changedIndexedDocuments.length
|
|
192
|
+
});
|
|
152
193
|
const changedIndexedByPath = new Map(changedIndexedDocuments.map((document) => [document.document.path, document]));
|
|
153
194
|
const needsRelink = settingsChanged || hasDeletes || changedPaths.size > 0;
|
|
154
195
|
const indexedDocuments = documents.map((document) => {
|
|
@@ -162,9 +203,13 @@ export const indexVault = async (vaultPath) => {
|
|
|
162
203
|
}
|
|
163
204
|
return needsRelink ? relinkIndexedDocument(existing, titleMaps) : existing;
|
|
164
205
|
});
|
|
206
|
+
emit('persist', 'start', 'Persisting index');
|
|
165
207
|
await index.reset();
|
|
166
208
|
await index.saveDocuments(indexedDocuments);
|
|
167
|
-
|
|
209
|
+
emit('persist', 'finish', 'Index persisted', {
|
|
210
|
+
indexedDocuments: indexedDocuments.length
|
|
211
|
+
});
|
|
212
|
+
const existingPackManifest = manifestRecovery.repaired || manifestRecovery.source === 'not-needed';
|
|
168
213
|
const changedCount = changedPaths.size;
|
|
169
214
|
const documentCount = Math.max(indexedDocuments.length, 1);
|
|
170
215
|
const changeRatio = changedCount / documentCount;
|
|
@@ -172,25 +217,90 @@ export const indexVault = async (vaultPath) => {
|
|
|
172
217
|
const pendingPackChanges = previousPendingPackChanges + changedCount;
|
|
173
218
|
const shouldRebuildPacks = !existingPackManifest ||
|
|
174
219
|
settingsChanged ||
|
|
220
|
+
packSettingsChanged ||
|
|
175
221
|
hasDeletes ||
|
|
176
222
|
changedCount >= 400 ||
|
|
177
223
|
changeRatio >= 0.04 ||
|
|
178
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';
|
|
179
243
|
if (shouldRebuildPacks) {
|
|
244
|
+
emit('packs', 'start', 'Rebuilding compressed search packs', {
|
|
245
|
+
reason: packReason
|
|
246
|
+
});
|
|
180
247
|
try {
|
|
181
|
-
await buildSearchPacks(absoluteVaultPath, indexedDocuments);
|
|
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
|
+
});
|
|
182
256
|
}
|
|
183
257
|
catch {
|
|
184
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
|
+
});
|
|
185
262
|
}
|
|
186
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;
|
|
187
271
|
await writeIndexState(absoluteVaultPath, {
|
|
188
272
|
chunkSize: config.chunkSize,
|
|
189
273
|
embeddingProvider: config.embeddingProvider,
|
|
274
|
+
searchPackRowChunkSize: config.searchPack.rowChunkSize,
|
|
275
|
+
searchPackCompressionLevel: config.searchPack.compressionLevel,
|
|
276
|
+
searchPackUseDictionary: config.searchPack.useDictionary,
|
|
190
277
|
files: currentSnapshot,
|
|
191
|
-
pendingPackChanges:
|
|
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
|
+
}
|
|
296
|
+
};
|
|
297
|
+
emit('complete', 'finish', 'Indexing complete', {
|
|
298
|
+
documentCount: result.documentCount,
|
|
299
|
+
chunkCount: result.chunkCount,
|
|
300
|
+
linkCount: result.linkCount,
|
|
301
|
+
elapsedMs: result.elapsedMs
|
|
192
302
|
});
|
|
193
|
-
return
|
|
303
|
+
return result;
|
|
194
304
|
}
|
|
195
305
|
finally {
|
|
196
306
|
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
|
+
};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { watch } from 'node:fs';
|
|
2
|
-
import {
|
|
2
|
+
import { indexVaultWithOptions } from './index-vault.js';
|
|
3
3
|
import { isBucketVaultPath, resolveVaultPath } from '../infrastructure/file-system-vault.js';
|
|
4
4
|
const shouldIgnore = (filename) => {
|
|
5
5
|
if (!filename) {
|
|
@@ -14,6 +14,27 @@ export const startVaultWatcher = (input) => {
|
|
|
14
14
|
const absoluteVaultPath = resolveVaultPath(input.vaultPath);
|
|
15
15
|
const debounceMs = input.debounceMs ?? 350;
|
|
16
16
|
let timeout = null;
|
|
17
|
+
let running = false;
|
|
18
|
+
let pending = false;
|
|
19
|
+
const runIndex = () => {
|
|
20
|
+
if (running) {
|
|
21
|
+
pending = true;
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
running = true;
|
|
25
|
+
indexVaultWithOptions(absoluteVaultPath, {
|
|
26
|
+
onProgress: input.onProgress
|
|
27
|
+
})
|
|
28
|
+
.then(input.onIndex)
|
|
29
|
+
.catch(input.onError)
|
|
30
|
+
.finally(() => {
|
|
31
|
+
running = false;
|
|
32
|
+
if (pending) {
|
|
33
|
+
pending = false;
|
|
34
|
+
runIndex();
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
};
|
|
17
38
|
const schedule = (filename) => {
|
|
18
39
|
if (shouldIgnore(filename)) {
|
|
19
40
|
return;
|
|
@@ -22,7 +43,7 @@ export const startVaultWatcher = (input) => {
|
|
|
22
43
|
clearTimeout(timeout);
|
|
23
44
|
}
|
|
24
45
|
timeout = setTimeout(() => {
|
|
25
|
-
|
|
46
|
+
runIndex();
|
|
26
47
|
}, debounceMs);
|
|
27
48
|
};
|
|
28
49
|
const watcher = watch(absoluteVaultPath, { recursive: true }, (_eventType, filename) => {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
-
import { mkdir, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
3
3
|
import { dirname, join, relative, resolve } from 'node:path';
|
|
4
4
|
import { platform, tmpdir } from 'node:os';
|
|
5
5
|
import { spawn, spawnSync } from 'node:child_process';
|
|
@@ -7,8 +7,9 @@ import { addNoteWithMetadata } from '../../application/add-note.js';
|
|
|
7
7
|
import { buildContextPackage } from '../../application/build-context.js';
|
|
8
8
|
import { resolveDuplicateNotes, scanDuplicateNotes } from '../../application/dedupe-notes.js';
|
|
9
9
|
import { importLegacySqliteDatabase } from '../../application/import-legacy-sqlite.js';
|
|
10
|
-
import { indexVault } from '../../application/index-vault.js';
|
|
10
|
+
import { indexVault, indexVaultWithOptions } from '../../application/index-vault.js';
|
|
11
11
|
import { migrateVaultContent, planVaultMigration, previewVaultMigration, shouldMigrateDefaultVault } from '../../application/migrate-vault.js';
|
|
12
|
+
import { createOfflinePackBackup } from '../../application/offline-pack-backup.js';
|
|
12
13
|
import { startServer } from '../../application/start-server.js';
|
|
13
14
|
import { startVaultWatcher } from '../../application/watch-vault.js';
|
|
14
15
|
import { doctorVault, getStats, validateVault } from '../../application/analyze-vault.js';
|
|
@@ -37,6 +38,117 @@ const parseScore = (value, fallback) => {
|
|
|
37
38
|
}
|
|
38
39
|
return parsed;
|
|
39
40
|
};
|
|
41
|
+
const formatBytes = (bytes) => {
|
|
42
|
+
if (!Number.isFinite(bytes) || bytes == null) {
|
|
43
|
+
return 'n/a';
|
|
44
|
+
}
|
|
45
|
+
if (bytes < 1024)
|
|
46
|
+
return `${bytes} B`;
|
|
47
|
+
const units = ['KB', 'MB', 'GB', 'TB'];
|
|
48
|
+
let value = bytes / 1024;
|
|
49
|
+
let unitIndex = 0;
|
|
50
|
+
while (value >= 1024 && unitIndex < units.length - 1) {
|
|
51
|
+
value /= 1024;
|
|
52
|
+
unitIndex += 1;
|
|
53
|
+
}
|
|
54
|
+
return `${value.toFixed(value >= 10 ? 1 : 2)} ${units[unitIndex]}`;
|
|
55
|
+
};
|
|
56
|
+
const formatMs = (value) => Number.isFinite(value) && value != null ? `${value.toFixed(value >= 100 ? 0 : 1)}ms` : 'n/a';
|
|
57
|
+
const benchEventLabel = (event) => `${event.phase}:${event.status}`;
|
|
58
|
+
const printBenchRealtimeEvent = (json, event) => {
|
|
59
|
+
print(json, {
|
|
60
|
+
event: 'bench-progress',
|
|
61
|
+
...event
|
|
62
|
+
}, () => `[bench] ${benchEventLabel(event)} ${event.message} (${formatMs(event.elapsedMs)})`);
|
|
63
|
+
};
|
|
64
|
+
const printBenchSummary = (json, trigger, vault, result) => {
|
|
65
|
+
print(json, {
|
|
66
|
+
event: 'bench-result',
|
|
67
|
+
trigger,
|
|
68
|
+
vault,
|
|
69
|
+
result
|
|
70
|
+
}, () => {
|
|
71
|
+
const packs = result.packs;
|
|
72
|
+
const compression = packs?.compression;
|
|
73
|
+
const savedPercent = compression && compression.inputBytes > 0
|
|
74
|
+
? `${((1 - compression.ratio) * 100).toFixed(1)}%`
|
|
75
|
+
: 'n/a';
|
|
76
|
+
return [
|
|
77
|
+
`[bench] trigger=${trigger}`,
|
|
78
|
+
`documents=${result.documentCount} chunks=${result.chunkCount} links=${result.linkCount}`,
|
|
79
|
+
`changedDocuments=${result.changedDocumentCount ?? 0} totalElapsed=${formatMs(result.elapsedMs)}`,
|
|
80
|
+
`packsRebuilt=${packs?.rebuilt ? 'yes' : 'no'} reason=${packs?.reason ?? 'n/a'}`,
|
|
81
|
+
packs?.rebuilt
|
|
82
|
+
? `packCount=${packs.packCount ?? 0} packDuration=${formatMs(packs.durationMs)} input=${formatBytes(compression?.inputBytes)} output=${formatBytes(compression?.outputBytes)} saved=${savedPercent}`
|
|
83
|
+
: 'packCompression=n/a'
|
|
84
|
+
].join('\n');
|
|
85
|
+
});
|
|
86
|
+
};
|
|
87
|
+
const benchHistoryPath = (vaultPath) => join(vaultPath, '.brainlink', 'benchmarks', 'latest.json');
|
|
88
|
+
const readBenchHistory = async (vaultPath) => {
|
|
89
|
+
try {
|
|
90
|
+
const parsed = JSON.parse(await readFile(benchHistoryPath(vaultPath), 'utf8'));
|
|
91
|
+
if (typeof parsed.elapsedMs !== 'number' || typeof parsed.timestamp !== 'string') {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
return {
|
|
95
|
+
elapsedMs: parsed.elapsedMs,
|
|
96
|
+
timestamp: parsed.timestamp,
|
|
97
|
+
...(typeof parsed.compressionRatio === 'number' ? { compressionRatio: parsed.compressionRatio } : {})
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
const writeBenchHistory = async (vaultPath, result) => {
|
|
105
|
+
await mkdir(dirname(benchHistoryPath(vaultPath)), { recursive: true });
|
|
106
|
+
const payload = {
|
|
107
|
+
elapsedMs: result.elapsedMs ?? 0,
|
|
108
|
+
timestamp: new Date().toISOString(),
|
|
109
|
+
...(typeof result.packs?.compression?.ratio === 'number' ? { compressionRatio: result.packs.compression.ratio } : {})
|
|
110
|
+
};
|
|
111
|
+
await writeFile(benchHistoryPath(vaultPath), `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
|
|
112
|
+
};
|
|
113
|
+
const evaluateBenchGuardrails = (config, result, baseline) => {
|
|
114
|
+
const compressionRatio = result.packs?.compression?.ratio;
|
|
115
|
+
const compressionSavingsPercent = typeof compressionRatio === 'number' ? Math.max(0, (1 - compressionRatio) * 100) : undefined;
|
|
116
|
+
const compressionPass = compressionSavingsPercent != null
|
|
117
|
+
? compressionSavingsPercent >= config.searchPack.guardrailMinSavingsPercent
|
|
118
|
+
: undefined;
|
|
119
|
+
const latencyRegressionPercent = baseline && baseline.elapsedMs > 0 && typeof result.elapsedMs === 'number'
|
|
120
|
+
? ((result.elapsedMs - baseline.elapsedMs) / baseline.elapsedMs) * 100
|
|
121
|
+
: undefined;
|
|
122
|
+
const latencyPass = latencyRegressionPercent != null
|
|
123
|
+
? latencyRegressionPercent <= config.searchPack.guardrailMaxLatencyRegressionPercent
|
|
124
|
+
: undefined;
|
|
125
|
+
return {
|
|
126
|
+
...(compressionSavingsPercent != null ? { compressionSavingsPercent } : {}),
|
|
127
|
+
...(compressionPass != null ? { compressionPass } : {}),
|
|
128
|
+
...(latencyRegressionPercent != null ? { latencyRegressionPercent } : {}),
|
|
129
|
+
...(latencyPass != null ? { latencyPass } : {})
|
|
130
|
+
};
|
|
131
|
+
};
|
|
132
|
+
const printBenchGuardrails = (json, vault, config, guardrails) => {
|
|
133
|
+
print(json, {
|
|
134
|
+
event: 'bench-guardrails',
|
|
135
|
+
vault,
|
|
136
|
+
thresholds: {
|
|
137
|
+
minSavingsPercent: config.searchPack.guardrailMinSavingsPercent,
|
|
138
|
+
maxLatencyRegressionPercent: config.searchPack.guardrailMaxLatencyRegressionPercent
|
|
139
|
+
},
|
|
140
|
+
guardrails
|
|
141
|
+
}, () => {
|
|
142
|
+
const savings = guardrails.compressionSavingsPercent;
|
|
143
|
+
const latency = guardrails.latencyRegressionPercent;
|
|
144
|
+
return [
|
|
145
|
+
'[bench] guardrails',
|
|
146
|
+
`minSavings=${config.searchPack.guardrailMinSavingsPercent.toFixed(1)}% maxLatencyRegression=${config.searchPack.guardrailMaxLatencyRegressionPercent.toFixed(1)}%`,
|
|
147
|
+
`compressionSavings=${savings != null ? `${savings.toFixed(2)}%` : 'n/a'} pass=${guardrails.compressionPass != null ? (guardrails.compressionPass ? 'yes' : 'no') : 'n/a'}`,
|
|
148
|
+
`latencyRegression=${latency != null ? `${latency.toFixed(2)}%` : 'n/a'} pass=${guardrails.latencyPass != null ? (guardrails.latencyPass ? 'yes' : 'no') : 'n/a'}`
|
|
149
|
+
].join('\n');
|
|
150
|
+
});
|
|
151
|
+
};
|
|
40
152
|
const spawnDetached = (command, args, envOverrides) => {
|
|
41
153
|
try {
|
|
42
154
|
const child = spawn(command, args, {
|
|
@@ -325,7 +437,10 @@ const openGraphInAppWindow = (url) => {
|
|
|
325
437
|
const appArgument = `--app=${url}`;
|
|
326
438
|
const linuxChromiumStableFlags = [
|
|
327
439
|
'--ozone-platform=x11',
|
|
440
|
+
'--ozone-platform-hint=x11',
|
|
328
441
|
'--disable-gpu',
|
|
442
|
+
'--disable-vulkan',
|
|
443
|
+
'--use-gl=swiftshader',
|
|
329
444
|
'--disable-features=Vulkan,VaapiVideoDecoder',
|
|
330
445
|
'--disable-background-networking'
|
|
331
446
|
];
|
|
@@ -360,7 +475,10 @@ const openGraphInDetectedBrowser = (url) => {
|
|
|
360
475
|
}
|
|
361
476
|
const linuxChromiumStableFlags = [
|
|
362
477
|
'--ozone-platform=x11',
|
|
478
|
+
'--ozone-platform-hint=x11',
|
|
363
479
|
'--disable-gpu',
|
|
480
|
+
'--disable-vulkan',
|
|
481
|
+
'--use-gl=swiftshader',
|
|
364
482
|
'--disable-features=Vulkan,VaapiVideoDecoder',
|
|
365
483
|
'--disable-background-networking'
|
|
366
484
|
];
|
|
@@ -634,6 +752,63 @@ export const registerWriteCommands = (program) => {
|
|
|
634
752
|
const result = await indexVault(resolved.vault);
|
|
635
753
|
print(options.json, result, () => `Indexed ${result.documentCount} documents, ${result.chunkCount} chunks and ${result.linkCount} links`);
|
|
636
754
|
});
|
|
755
|
+
program
|
|
756
|
+
.command('bench')
|
|
757
|
+
.option('-v, --vault <vault>', 'vault directory')
|
|
758
|
+
.option('-w, --watch', 'watch markdown changes and re-run benchmark in realtime')
|
|
759
|
+
.option('--debounce <ms>', 'watch debounce in milliseconds', '350')
|
|
760
|
+
.option('--json', 'print machine-readable JSON events')
|
|
761
|
+
.description('benchmark indexing in realtime, including compressed pack behavior')
|
|
762
|
+
.action(async (options) => {
|
|
763
|
+
const resolved = await resolveOptions(options);
|
|
764
|
+
const config = await loadBrainlinkConfig();
|
|
765
|
+
const emitProgress = (event) => {
|
|
766
|
+
printBenchRealtimeEvent(options.json, event);
|
|
767
|
+
};
|
|
768
|
+
const printBenchError = (error) => {
|
|
769
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
770
|
+
print(options.json, { event: 'bench-error', message }, () => `[bench] error ${message}`);
|
|
771
|
+
};
|
|
772
|
+
const runAndPrint = async (trigger) => {
|
|
773
|
+
const baseline = await readBenchHistory(resolved.vault);
|
|
774
|
+
const result = await indexVaultWithOptions(resolved.vault, {
|
|
775
|
+
onProgress: emitProgress
|
|
776
|
+
});
|
|
777
|
+
printBenchSummary(options.json, trigger, resolved.vault, result);
|
|
778
|
+
const guardrails = evaluateBenchGuardrails(config, result, baseline);
|
|
779
|
+
printBenchGuardrails(options.json, resolved.vault, config, guardrails);
|
|
780
|
+
await writeBenchHistory(resolved.vault, result);
|
|
781
|
+
return result;
|
|
782
|
+
};
|
|
783
|
+
if (!options.watch) {
|
|
784
|
+
await runAndPrint('manual');
|
|
785
|
+
return;
|
|
786
|
+
}
|
|
787
|
+
const debounceMs = parsePositiveInteger(options.debounce ?? '350', 350);
|
|
788
|
+
await runAndPrint('manual');
|
|
789
|
+
print(options.json, {
|
|
790
|
+
event: 'bench-watching',
|
|
791
|
+
vault: resolved.vault,
|
|
792
|
+
debounceMs
|
|
793
|
+
}, () => `[bench] watching ${resolved.vault} (debounce=${debounceMs}ms)`);
|
|
794
|
+
const watcher = startVaultWatcher({
|
|
795
|
+
vaultPath: resolved.vault,
|
|
796
|
+
debounceMs,
|
|
797
|
+
onProgress: emitProgress,
|
|
798
|
+
onIndex: (result) => {
|
|
799
|
+
printBenchSummary(options.json, 'watch', resolved.vault, result);
|
|
800
|
+
},
|
|
801
|
+
onError: printBenchError
|
|
802
|
+
});
|
|
803
|
+
await new Promise((resolveSignal) => {
|
|
804
|
+
const shutdown = () => {
|
|
805
|
+
watcher.close();
|
|
806
|
+
resolveSignal();
|
|
807
|
+
};
|
|
808
|
+
process.once('SIGINT', shutdown);
|
|
809
|
+
process.once('SIGTERM', shutdown);
|
|
810
|
+
});
|
|
811
|
+
});
|
|
637
812
|
program
|
|
638
813
|
.command('doctor')
|
|
639
814
|
.option('-v, --vault <vault>', 'vault directory')
|
|
@@ -651,6 +826,30 @@ export const registerWriteCommands = (program) => {
|
|
|
651
826
|
});
|
|
652
827
|
process.exitCode = report.ok ? 0 : 1;
|
|
653
828
|
});
|
|
829
|
+
program
|
|
830
|
+
.command('pack-backup')
|
|
831
|
+
.option('-v, --vault <vault>', 'vault directory')
|
|
832
|
+
.option('-o, --output <path>', 'output file path (.blpkbak.gz)')
|
|
833
|
+
.option('--json', 'print machine-readable JSON')
|
|
834
|
+
.description('create offline backup with second-stage compression for encrypted search packs')
|
|
835
|
+
.action(async (options) => {
|
|
836
|
+
const resolved = await resolveOptions(options);
|
|
837
|
+
const outputPath = options.output?.trim().length
|
|
838
|
+
? resolve(options.output)
|
|
839
|
+
: join(resolved.vault, '.brainlink', 'backups', `search-packs-${new Date().toISOString().replace(/[:.]/g, '-')}.blpkbak.gz`);
|
|
840
|
+
const backup = await createOfflinePackBackup({
|
|
841
|
+
vaultPath: resolved.vault,
|
|
842
|
+
outputPath
|
|
843
|
+
});
|
|
844
|
+
print(options.json, {
|
|
845
|
+
vault: resolved.vault,
|
|
846
|
+
backup
|
|
847
|
+
}, () => [
|
|
848
|
+
`Offline backup created: ${backup.outputPath}`,
|
|
849
|
+
`files=${backup.fileCount}`,
|
|
850
|
+
`input=${formatBytes(backup.inputBytes)} output=${formatBytes(backup.outputBytes)} saved=${((1 - backup.ratio) * 100).toFixed(2)}%`
|
|
851
|
+
].join('\n'));
|
|
852
|
+
});
|
|
654
853
|
program
|
|
655
854
|
.command('watch')
|
|
656
855
|
.option('-v, --vault <vault>', 'vault directory')
|
|
@@ -15,6 +15,13 @@ export const defaultBrainlinkConfig = {
|
|
|
15
15
|
embeddingProvider: 'local',
|
|
16
16
|
defaultSearchMode: 'hybrid',
|
|
17
17
|
chunkSize: 1200,
|
|
18
|
+
searchPack: {
|
|
19
|
+
rowChunkSize: 5_000,
|
|
20
|
+
compressionLevel: 5,
|
|
21
|
+
useDictionary: true,
|
|
22
|
+
guardrailMinSavingsPercent: 8,
|
|
23
|
+
guardrailMaxLatencyRegressionPercent: 5
|
|
24
|
+
},
|
|
18
25
|
agentProfiles: {}
|
|
19
26
|
};
|
|
20
27
|
const configFilenames = ['brainlink.config.json', '.brainlink.json'];
|
|
@@ -37,6 +44,36 @@ const sanitizeEmbeddingProvider = (value) => typeof value === 'string' && embedd
|
|
|
37
44
|
export const sanitizeSearchMode = (value, fallback = defaultBrainlinkConfig.defaultSearchMode) => typeof value === 'string' && searchModes.has(value) ? value : fallback;
|
|
38
45
|
const sanitizeAllowedVaults = (value) => Array.isArray(value) ? value.filter((item) => typeof item === 'string' && item.trim().length > 0) : [];
|
|
39
46
|
const sanitizePositiveNumber = (value) => typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : undefined;
|
|
47
|
+
const sanitizeIntegerInRange = (value, fallback, minimum, maximum) => {
|
|
48
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
49
|
+
return fallback;
|
|
50
|
+
}
|
|
51
|
+
const rounded = Math.round(value);
|
|
52
|
+
if (rounded < minimum) {
|
|
53
|
+
return minimum;
|
|
54
|
+
}
|
|
55
|
+
if (rounded > maximum) {
|
|
56
|
+
return maximum;
|
|
57
|
+
}
|
|
58
|
+
return rounded;
|
|
59
|
+
};
|
|
60
|
+
const sanitizeSearchPackConfig = (value) => {
|
|
61
|
+
const fallback = defaultBrainlinkConfig.searchPack;
|
|
62
|
+
if (!isRecord(value)) {
|
|
63
|
+
return fallback;
|
|
64
|
+
}
|
|
65
|
+
return {
|
|
66
|
+
rowChunkSize: sanitizeIntegerInRange(value.rowChunkSize, fallback.rowChunkSize, 100, 100_000),
|
|
67
|
+
compressionLevel: sanitizeIntegerInRange(value.compressionLevel, fallback.compressionLevel, 0, 11),
|
|
68
|
+
useDictionary: typeof value.useDictionary === 'boolean' ? value.useDictionary : fallback.useDictionary,
|
|
69
|
+
guardrailMinSavingsPercent: typeof value.guardrailMinSavingsPercent === 'number' && Number.isFinite(value.guardrailMinSavingsPercent)
|
|
70
|
+
? Math.max(0, Math.min(95, value.guardrailMinSavingsPercent))
|
|
71
|
+
: fallback.guardrailMinSavingsPercent,
|
|
72
|
+
guardrailMaxLatencyRegressionPercent: typeof value.guardrailMaxLatencyRegressionPercent === 'number' && Number.isFinite(value.guardrailMaxLatencyRegressionPercent)
|
|
73
|
+
? Math.max(0, Math.min(300, value.guardrailMaxLatencyRegressionPercent))
|
|
74
|
+
: fallback.guardrailMaxLatencyRegressionPercent
|
|
75
|
+
};
|
|
76
|
+
};
|
|
40
77
|
const sanitizeAgentProfile = (value) => {
|
|
41
78
|
if (!isRecord(value)) {
|
|
42
79
|
return null;
|
|
@@ -130,6 +167,7 @@ const sanitizeConfig = (value) => ({
|
|
|
130
167
|
: defaultBrainlinkConfig.defaultContextTokens,
|
|
131
168
|
allowedVaults: [...sanitizeAllowedVaults(value.allowedVaults), ...readAllowedVaultsFromEnv()],
|
|
132
169
|
chunkSize: typeof value.chunkSize === 'number' && value.chunkSize > 0 ? value.chunkSize : defaultBrainlinkConfig.chunkSize,
|
|
170
|
+
searchPack: sanitizeSearchPackConfig(value.searchPack),
|
|
133
171
|
embeddingProvider: sanitizeEmbeddingProvider(value.embeddingProvider),
|
|
134
172
|
defaultSearchMode: sanitizeSearchMode(value.defaultSearchMode),
|
|
135
173
|
agentProfiles: sanitizeAgentProfiles(value.agentProfiles)
|
|
@@ -29,6 +29,9 @@ export const readIndexState = async (vaultPath) => {
|
|
|
29
29
|
updatedAt: typeof parsed.updatedAt === 'string' ? parsed.updatedAt : new Date().toISOString(),
|
|
30
30
|
chunkSize: typeof parsed.chunkSize === 'number' ? parsed.chunkSize : 1200,
|
|
31
31
|
embeddingProvider: typeof parsed.embeddingProvider === 'string' ? parsed.embeddingProvider : 'none',
|
|
32
|
+
searchPackRowChunkSize: typeof parsed.searchPackRowChunkSize === 'number' ? parsed.searchPackRowChunkSize : 5_000,
|
|
33
|
+
searchPackCompressionLevel: typeof parsed.searchPackCompressionLevel === 'number' ? parsed.searchPackCompressionLevel : 5,
|
|
34
|
+
searchPackUseDictionary: typeof parsed.searchPackUseDictionary === 'boolean' ? parsed.searchPackUseDictionary : true,
|
|
32
35
|
files,
|
|
33
36
|
pendingPackChanges: typeof parsed.pendingPackChanges === 'number' && parsed.pendingPackChanges >= 0 ? parsed.pendingPackChanges : 0
|
|
34
37
|
};
|
|
@@ -43,6 +46,9 @@ export const writeIndexState = async (vaultPath, state) => {
|
|
|
43
46
|
updatedAt: new Date().toISOString(),
|
|
44
47
|
chunkSize: state.chunkSize,
|
|
45
48
|
embeddingProvider: state.embeddingProvider,
|
|
49
|
+
searchPackRowChunkSize: state.searchPackRowChunkSize,
|
|
50
|
+
searchPackCompressionLevel: state.searchPackCompressionLevel,
|
|
51
|
+
searchPackUseDictionary: state.searchPackUseDictionary,
|
|
46
52
|
files: [...state.files].sort((left, right) => left.path.localeCompare(right.path)),
|
|
47
53
|
pendingPackChanges: Math.max(0, Math.floor(state.pendingPackChanges))
|
|
48
54
|
};
|
|
@@ -1,13 +1,25 @@
|
|
|
1
1
|
import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto';
|
|
2
|
-
import { brotliCompressSync, brotliDecompressSync } from 'node:zlib';
|
|
2
|
+
import { brotliCompressSync, brotliDecompressSync, constants as zlibConstants } from 'node:zlib';
|
|
3
3
|
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
4
4
|
import { dirname, join } from 'node:path';
|
|
5
5
|
import { getBrainlinkHomePath } from './paths.js';
|
|
6
6
|
const magic = Buffer.from('BLPK2', 'ascii');
|
|
7
|
-
const
|
|
7
|
+
const legacyVersion = 1;
|
|
8
|
+
const currentVersion = 2;
|
|
8
9
|
const nonceLength = 12;
|
|
9
10
|
const authTagLength = 16;
|
|
10
11
|
const algorithm = 'aes-256-gcm';
|
|
12
|
+
const compressionLevelMask = 0x0f;
|
|
13
|
+
const compressionDictionaryMask = 0x10;
|
|
14
|
+
const defaultCompressionLevel = 5;
|
|
15
|
+
const builtinDictionary = Buffer.from([
|
|
16
|
+
'"documentId","agentId","title","path","chunkId","chunkOrdinal","content","tags"',
|
|
17
|
+
'"searchMode","textScore","semanticScore","weight","priority","shared"',
|
|
18
|
+
'agents/shared memory-hub architecture context index search graph markdown tags links',
|
|
19
|
+
'#memory #architecture #context #graph #search #index [[Memory Hub]] [[Architecture]]',
|
|
20
|
+
'The quick brown fox jumps over the lazy dog. Brainlink context package metadata.',
|
|
21
|
+
'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-:/.#[]{}(), '
|
|
22
|
+
].join('\n'), 'utf8');
|
|
11
23
|
const keyFilePath = (vaultPath) => {
|
|
12
24
|
const vaultHash = createHash('sha256').update(vaultPath).digest('hex').slice(0, 24);
|
|
13
25
|
return join(getBrainlinkHomePath(), 'keys', `search-pack-${vaultHash}.key`);
|
|
@@ -40,34 +52,83 @@ const parseHeader = (payload) => {
|
|
|
40
52
|
throw new Error('Invalid private pack payload: too short.');
|
|
41
53
|
}
|
|
42
54
|
const payloadMagic = payload.subarray(0, magic.length);
|
|
43
|
-
const payloadVersion = payload[magic.length];
|
|
44
|
-
if (!payloadMagic.equals(magic) || payloadVersion !==
|
|
55
|
+
const payloadVersion = payload[magic.length] ?? 0;
|
|
56
|
+
if (!payloadMagic.equals(magic) || (payloadVersion !== legacyVersion && payloadVersion !== currentVersion)) {
|
|
45
57
|
throw new Error('Invalid private pack payload: unsupported format.');
|
|
46
58
|
}
|
|
47
|
-
const
|
|
59
|
+
const hasCompressionSettings = payloadVersion >= 2;
|
|
60
|
+
const settingsByte = hasCompressionSettings ? payload[magic.length + 1] ?? 0 : null;
|
|
61
|
+
const nonceStart = magic.length + 1 + (hasCompressionSettings ? 1 : 0);
|
|
48
62
|
const authTagStart = nonceStart + nonceLength;
|
|
49
63
|
const dataStart = authTagStart + authTagLength;
|
|
50
64
|
return {
|
|
65
|
+
compression: settingsByte != null
|
|
66
|
+
? {
|
|
67
|
+
compressionLevel: settingsByte & compressionLevelMask,
|
|
68
|
+
useDictionary: (settingsByte & compressionDictionaryMask) !== 0
|
|
69
|
+
}
|
|
70
|
+
: {
|
|
71
|
+
compressionLevel: defaultCompressionLevel,
|
|
72
|
+
useDictionary: false
|
|
73
|
+
},
|
|
51
74
|
nonce: payload.subarray(nonceStart, authTagStart),
|
|
52
75
|
authTag: payload.subarray(authTagStart, dataStart),
|
|
53
76
|
ciphertext: payload.subarray(dataStart)
|
|
54
77
|
};
|
|
55
78
|
};
|
|
56
|
-
|
|
79
|
+
const toCompressionLevel = (value) => {
|
|
80
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
81
|
+
return defaultCompressionLevel;
|
|
82
|
+
}
|
|
83
|
+
const normalized = Math.round(value);
|
|
84
|
+
if (normalized < 0) {
|
|
85
|
+
return 0;
|
|
86
|
+
}
|
|
87
|
+
if (normalized > 11) {
|
|
88
|
+
return 11;
|
|
89
|
+
}
|
|
90
|
+
return normalized;
|
|
91
|
+
};
|
|
92
|
+
const encodeCompressionSettings = (settings) => (settings.compressionLevel & compressionLevelMask) | (settings.useDictionary ? compressionDictionaryMask : 0);
|
|
93
|
+
const brotliEncode = (content, settings) => {
|
|
94
|
+
const options = {
|
|
95
|
+
params: {
|
|
96
|
+
[zlibConstants.BROTLI_PARAM_MODE]: zlibConstants.BROTLI_MODE_TEXT,
|
|
97
|
+
[zlibConstants.BROTLI_PARAM_QUALITY]: settings.compressionLevel
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
if (settings.useDictionary) {
|
|
101
|
+
options.dictionary = builtinDictionary;
|
|
102
|
+
}
|
|
103
|
+
return brotliCompressSync(content, options);
|
|
104
|
+
};
|
|
105
|
+
const brotliDecode = (content, settings) => {
|
|
106
|
+
const options = {};
|
|
107
|
+
if (settings.useDictionary) {
|
|
108
|
+
options.dictionary = builtinDictionary;
|
|
109
|
+
}
|
|
110
|
+
return brotliDecompressSync(content, options);
|
|
111
|
+
};
|
|
112
|
+
export const encodePrivatePack = async (vaultPath, content, settings) => {
|
|
57
113
|
const key = await readOrCreateKey(vaultPath);
|
|
58
114
|
const nonce = randomBytes(nonceLength);
|
|
59
|
-
const
|
|
115
|
+
const normalizedSettings = {
|
|
116
|
+
compressionLevel: toCompressionLevel(settings?.compressionLevel),
|
|
117
|
+
useDictionary: settings?.useDictionary ?? true
|
|
118
|
+
};
|
|
119
|
+
const compressed = brotliEncode(content, normalizedSettings);
|
|
60
120
|
const cipher = createCipheriv(algorithm, key, nonce);
|
|
61
121
|
const ciphertext = Buffer.concat([cipher.update(compressed), cipher.final()]);
|
|
62
122
|
const authTag = cipher.getAuthTag();
|
|
63
|
-
|
|
123
|
+
const settingsByte = Buffer.from([encodeCompressionSettings(normalizedSettings)]);
|
|
124
|
+
return Buffer.concat([magic, Buffer.from([currentVersion]), settingsByte, nonce, authTag, ciphertext]);
|
|
64
125
|
};
|
|
65
126
|
export const decodePrivatePack = async (vaultPath, payload) => {
|
|
66
127
|
const key = await readOrCreateKey(vaultPath);
|
|
67
|
-
const { nonce, authTag, ciphertext } = parseHeader(payload);
|
|
128
|
+
const { nonce, authTag, ciphertext, compression } = parseHeader(payload);
|
|
68
129
|
const decipher = createDecipheriv(algorithm, key, nonce);
|
|
69
130
|
decipher.setAuthTag(authTag);
|
|
70
131
|
const compressed = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
71
|
-
return
|
|
132
|
+
return brotliDecode(compressed, compression);
|
|
72
133
|
};
|
|
73
134
|
export const isPrivatePackPayload = (payload) => payload.length >= magic.length + 1 && payload.subarray(0, magic.length).equals(magic);
|
|
@@ -5,7 +5,11 @@ import { middleOutIndices } from '../domain/middle-out.js';
|
|
|
5
5
|
import { decodePrivatePack, encodePrivatePack, isPrivatePackPayload } from './private-pack-codec.js';
|
|
6
6
|
const packsDirectoryName = 'search-packs';
|
|
7
7
|
const manifestFileName = 'manifest.json';
|
|
8
|
-
const
|
|
8
|
+
const defaultBuildOptions = {
|
|
9
|
+
rowChunkSize: 5_000,
|
|
10
|
+
compressionLevel: 5,
|
|
11
|
+
useDictionary: true
|
|
12
|
+
};
|
|
9
13
|
const queryTokenPattern = /[\p{L}\p{N}_-]+/gu;
|
|
10
14
|
const bloomBytes = 256;
|
|
11
15
|
const bloomBitSize = bloomBytes * 8;
|
|
@@ -94,7 +98,37 @@ const readManifest = async (vaultPath) => {
|
|
|
94
98
|
packCount: typeof parsed.packCount === 'number' ? parsed.packCount : packIndex.length,
|
|
95
99
|
recordCount: typeof parsed.recordCount === 'number' ? parsed.recordCount : 0,
|
|
96
100
|
format: 'private-v2',
|
|
97
|
-
packIndex
|
|
101
|
+
packIndex,
|
|
102
|
+
...(parsed.packConfig && typeof parsed.packConfig === 'object'
|
|
103
|
+
? {
|
|
104
|
+
packConfig: {
|
|
105
|
+
rowChunkSize: typeof parsed.packConfig.rowChunkSize === 'number'
|
|
106
|
+
? parsed.packConfig.rowChunkSize
|
|
107
|
+
: defaultBuildOptions.rowChunkSize,
|
|
108
|
+
compressionLevel: typeof parsed.packConfig.compressionLevel === 'number'
|
|
109
|
+
? parsed.packConfig.compressionLevel
|
|
110
|
+
: defaultBuildOptions.compressionLevel,
|
|
111
|
+
useDictionary: typeof parsed.packConfig.useDictionary === 'boolean'
|
|
112
|
+
? parsed.packConfig.useDictionary
|
|
113
|
+
: defaultBuildOptions.useDictionary
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
: {}),
|
|
117
|
+
...(parsed.compression &&
|
|
118
|
+
typeof parsed.compression === 'object' &&
|
|
119
|
+
typeof parsed.compression.inputBytes === 'number' &&
|
|
120
|
+
typeof parsed.compression.outputBytes === 'number' &&
|
|
121
|
+
typeof parsed.compression.ratio === 'number' &&
|
|
122
|
+
typeof parsed.compression.savedBytes === 'number'
|
|
123
|
+
? {
|
|
124
|
+
compression: {
|
|
125
|
+
inputBytes: parsed.compression.inputBytes,
|
|
126
|
+
outputBytes: parsed.compression.outputBytes,
|
|
127
|
+
ratio: parsed.compression.ratio,
|
|
128
|
+
savedBytes: parsed.compression.savedBytes
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
: {})
|
|
98
132
|
};
|
|
99
133
|
}
|
|
100
134
|
return null;
|
|
@@ -103,6 +137,37 @@ const readManifest = async (vaultPath) => {
|
|
|
103
137
|
return null;
|
|
104
138
|
}
|
|
105
139
|
};
|
|
140
|
+
export const ensureSearchPackManifest = async (vaultPath) => {
|
|
141
|
+
const manifest = await readManifest(vaultPath);
|
|
142
|
+
if (manifest) {
|
|
143
|
+
return {
|
|
144
|
+
repaired: false,
|
|
145
|
+
source: 'not-needed',
|
|
146
|
+
packCount: manifest.packCount
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
const files = await sortedPackFiles(vaultPath);
|
|
150
|
+
const packFiles = files.filter((file) => file.endsWith('.blpk'));
|
|
151
|
+
if (packFiles.length === 0) {
|
|
152
|
+
return {
|
|
153
|
+
repaired: false,
|
|
154
|
+
source: 'no-packs',
|
|
155
|
+
packCount: 0
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
await writeManifest(vaultPath, {
|
|
159
|
+
version: 2,
|
|
160
|
+
createdAt: new Date().toISOString(),
|
|
161
|
+
packCount: packFiles.length,
|
|
162
|
+
recordCount: 0,
|
|
163
|
+
format: 'private-v2'
|
|
164
|
+
});
|
|
165
|
+
return {
|
|
166
|
+
repaired: true,
|
|
167
|
+
source: 'existing-packs',
|
|
168
|
+
packCount: packFiles.length
|
|
169
|
+
};
|
|
170
|
+
};
|
|
106
171
|
const chunkRows = (rows, size) => {
|
|
107
172
|
const chunks = [];
|
|
108
173
|
for (let index = 0; index < rows.length; index += size) {
|
|
@@ -220,7 +285,8 @@ const sortedPackFiles = async (vaultPath) => {
|
|
|
220
285
|
throw error;
|
|
221
286
|
}
|
|
222
287
|
};
|
|
223
|
-
const writeRowsAsPrivatePacks = async (vaultPath, rows, clearExisting) => {
|
|
288
|
+
const writeRowsAsPrivatePacks = async (vaultPath, rows, clearExisting, options) => {
|
|
289
|
+
const startedAt = process.hrtime.bigint();
|
|
224
290
|
const directory = toPackDirectory(vaultPath);
|
|
225
291
|
await mkdir(directory, { recursive: true });
|
|
226
292
|
if (clearExisting) {
|
|
@@ -229,15 +295,22 @@ const writeRowsAsPrivatePacks = async (vaultPath, rows, clearExisting) => {
|
|
|
229
295
|
.filter((name) => name.endsWith('.blpk') || name.endsWith('.jsonl.gz') || name === manifestFileName)
|
|
230
296
|
.map((name) => rm(join(directory, name), { force: true })));
|
|
231
297
|
}
|
|
232
|
-
const chunks = chunkRows(rows, rowChunkSize);
|
|
298
|
+
const chunks = chunkRows(rows, options.rowChunkSize);
|
|
233
299
|
const packIndex = [];
|
|
300
|
+
let inputBytes = 0;
|
|
301
|
+
let outputBytes = 0;
|
|
234
302
|
for (let index = 0; index < chunks.length; index += 1) {
|
|
235
303
|
const chunk = chunks[index];
|
|
236
304
|
const fileName = `pack-${String(index + 1).padStart(4, '0')}.blpk`;
|
|
237
305
|
const serialized = `${chunk.map((row) => JSON.stringify(row)).join('\n')}\n`;
|
|
238
|
-
const compressed = await encodePrivatePack(vaultPath, Buffer.from(serialized, 'utf8')
|
|
306
|
+
const compressed = await encodePrivatePack(vaultPath, Buffer.from(serialized, 'utf8'), {
|
|
307
|
+
compressionLevel: options.compressionLevel,
|
|
308
|
+
useDictionary: options.useDictionary
|
|
309
|
+
});
|
|
239
310
|
const tokenBloomB64 = bloomToBase64(bloomFromRows(chunk));
|
|
240
311
|
await writeFile(join(directory, fileName), compressed);
|
|
312
|
+
inputBytes += Buffer.byteLength(serialized, 'utf8');
|
|
313
|
+
outputBytes += compressed.byteLength;
|
|
241
314
|
packIndex.push({
|
|
242
315
|
fileName,
|
|
243
316
|
recordCount: chunk.length,
|
|
@@ -251,11 +324,32 @@ const writeRowsAsPrivatePacks = async (vaultPath, rows, clearExisting) => {
|
|
|
251
324
|
packCount: chunks.length,
|
|
252
325
|
recordCount: rows.length,
|
|
253
326
|
format: 'private-v2',
|
|
254
|
-
packIndex
|
|
327
|
+
packIndex,
|
|
328
|
+
packConfig: {
|
|
329
|
+
rowChunkSize: options.rowChunkSize,
|
|
330
|
+
compressionLevel: options.compressionLevel,
|
|
331
|
+
useDictionary: options.useDictionary
|
|
332
|
+
},
|
|
333
|
+
compression: {
|
|
334
|
+
inputBytes,
|
|
335
|
+
outputBytes,
|
|
336
|
+
ratio: outputBytes / Math.max(inputBytes, 1),
|
|
337
|
+
savedBytes: Math.max(inputBytes - outputBytes, 0)
|
|
338
|
+
}
|
|
255
339
|
});
|
|
340
|
+
const durationMs = Number(process.hrtime.bigint() - startedAt) / 1_000_000;
|
|
341
|
+
const safeInput = Math.max(inputBytes, 1);
|
|
342
|
+
const savedBytes = Math.max(inputBytes - outputBytes, 0);
|
|
256
343
|
return {
|
|
257
344
|
packCount: chunks.length,
|
|
258
|
-
recordCount: rows.length
|
|
345
|
+
recordCount: rows.length,
|
|
346
|
+
compression: {
|
|
347
|
+
inputBytes,
|
|
348
|
+
outputBytes,
|
|
349
|
+
ratio: outputBytes / safeInput,
|
|
350
|
+
savedBytes
|
|
351
|
+
},
|
|
352
|
+
durationMs
|
|
259
353
|
};
|
|
260
354
|
};
|
|
261
355
|
const selectCandidatePackFiles = async (vaultPath, tokens, agentId) => {
|
|
@@ -290,8 +384,13 @@ const selectCandidatePackFiles = async (vaultPath, tokens, agentId) => {
|
|
|
290
384
|
}
|
|
291
385
|
return byAgent.length > 0 ? byAgent.map((entry) => entry.fileName) : allFiles;
|
|
292
386
|
};
|
|
293
|
-
export const buildSearchPacks = async (vaultPath, documents) => {
|
|
294
|
-
|
|
387
|
+
export const buildSearchPacks = async (vaultPath, documents, options) => {
|
|
388
|
+
const resolvedOptions = {
|
|
389
|
+
rowChunkSize: options?.rowChunkSize ?? defaultBuildOptions.rowChunkSize,
|
|
390
|
+
compressionLevel: options?.compressionLevel ?? defaultBuildOptions.compressionLevel,
|
|
391
|
+
useDictionary: options?.useDictionary ?? defaultBuildOptions.useDictionary
|
|
392
|
+
};
|
|
393
|
+
return writeRowsAsPrivatePacks(vaultPath, toRows(documents), true, resolvedOptions);
|
|
295
394
|
};
|
|
296
395
|
export const ensurePrivatePacksFromLegacyIndex = async (vaultPath) => {
|
|
297
396
|
const files = await sortedPackFiles(vaultPath);
|
|
@@ -305,7 +404,7 @@ export const ensurePrivatePacksFromLegacyIndex = async (vaultPath) => {
|
|
|
305
404
|
const parsed = await parseRowsFromPack(vaultPath, await readFile(join(toPackDirectory(vaultPath), file)));
|
|
306
405
|
rows.push(...parsed);
|
|
307
406
|
}
|
|
308
|
-
const report = await writeRowsAsPrivatePacks(vaultPath, rows, true);
|
|
407
|
+
const report = await writeRowsAsPrivatePacks(vaultPath, rows, true, defaultBuildOptions);
|
|
309
408
|
return {
|
|
310
409
|
imported: true,
|
|
311
410
|
source: 'legacy-packs',
|
|
@@ -314,6 +413,11 @@ export const ensurePrivatePacksFromLegacyIndex = async (vaultPath) => {
|
|
|
314
413
|
}
|
|
315
414
|
return { imported: false };
|
|
316
415
|
};
|
|
416
|
+
export const toSearchPackBuildOptions = (config) => ({
|
|
417
|
+
rowChunkSize: config.searchPack.rowChunkSize,
|
|
418
|
+
compressionLevel: config.searchPack.compressionLevel,
|
|
419
|
+
useDictionary: config.searchPack.useDictionary
|
|
420
|
+
});
|
|
317
421
|
export const searchInPacks = async (vaultPath, query, limit, agentId) => {
|
|
318
422
|
const normalizedAgent = agentId?.trim();
|
|
319
423
|
const tokens = tokenize(query);
|
package/docs/AGENT_USAGE.md
CHANGED
|
@@ -52,6 +52,8 @@ Use `blink config where` and `blink config doctor` to inspect active paths and e
|
|
|
52
52
|
|
|
53
53
|
You can also set `defaultAgent` in `brainlink.config.json` / `.brainlink.json` (for example `"defaultAgent": "coding-agent"`). When set, CLI commands and MCP calls reuse it when `--agent`/`agent` is not passed.
|
|
54
54
|
You can set `agentProfiles` to define per-agent defaults for `defaultSearchMode`, `defaultSearchLimit` and `defaultContextTokens`.
|
|
55
|
+
You can tune search-pack compression with `searchPack.rowChunkSize`, `searchPack.compressionLevel` and `searchPack.useDictionary`.
|
|
56
|
+
Guardrails for benchmark acceptance are configured with `searchPack.guardrailMinSavingsPercent` and `searchPack.guardrailMaxLatencyRegressionPercent`.
|
|
55
57
|
|
|
56
58
|
`autoIndexOnWrite` (default: `true`) controls whether `add` and MCP write tools index right after writing.
|
|
57
59
|
|
|
@@ -477,6 +479,37 @@ This scans Markdown files and rebuilds:
|
|
|
477
479
|
- links
|
|
478
480
|
- full-text search records
|
|
479
481
|
|
|
482
|
+
### Benchmark Indexing Realtime
|
|
483
|
+
|
|
484
|
+
```bash
|
|
485
|
+
blink bench --vault ./vault
|
|
486
|
+
blink bench --vault ./vault --watch
|
|
487
|
+
blink bench --vault ./vault --watch --debounce 500
|
|
488
|
+
blink bench --vault ./vault --json
|
|
489
|
+
```
|
|
490
|
+
|
|
491
|
+
`bench` runs indexing with realtime phase events and prints a run summary with:
|
|
492
|
+
|
|
493
|
+
- indexed totals (documents, chunks, links)
|
|
494
|
+
- elapsed time and changed document count
|
|
495
|
+
- pack rebuild status and reason
|
|
496
|
+
- pack compression metrics (`inputBytes`, `outputBytes`, ratio/saved percentage)
|
|
497
|
+
- objective guardrails (`guardrailMinSavingsPercent`, `guardrailMaxLatencyRegressionPercent`)
|
|
498
|
+
|
|
499
|
+
Use `--watch` for continuous benchmark runs while editing notes. Watch mode is supported only for local filesystem vaults.
|
|
500
|
+
If pack manifest metadata is missing but encrypted `.blpk` files are present, Brainlink repairs manifest metadata before deciding rebuild policy to avoid unnecessary full repacks on small updates.
|
|
501
|
+
|
|
502
|
+
### Create Offline Pack Backup
|
|
503
|
+
|
|
504
|
+
```bash
|
|
505
|
+
blink pack-backup --vault ./vault
|
|
506
|
+
blink pack-backup --vault ./vault --output ./vault/.brainlink/backups/custom.blpkbak.gz
|
|
507
|
+
blink pack-backup --vault ./vault --json
|
|
508
|
+
```
|
|
509
|
+
|
|
510
|
+
`pack-backup` creates an offline artifact with second-stage compression on top of encrypted `.blpk` packs.
|
|
511
|
+
This is outside the online retrieval path (`index`, `search`, `context`), which keeps a single compression stage.
|
|
512
|
+
|
|
480
513
|
### Search Knowledge
|
|
481
514
|
|
|
482
515
|
```bash
|
package/package.json
CHANGED