@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 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, stat } from 'node:fs/promises';
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
- return toIndexResult(existingIndexedDocuments);
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
- const existingPackManifest = await hasPackManifest(absoluteVaultPath);
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: shouldRebuildPacks ? 0 : 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 toIndexResult(indexedDocuments);
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 { indexVault } from './index-vault.js';
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
- indexVault(absoluteVaultPath).then(input.onIndex).catch(input.onError);
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 version = 1;
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 !== version) {
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 nonceStart = magic.length + 1;
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
- export const encodePrivatePack = async (vaultPath, content) => {
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 compressed = brotliCompressSync(content);
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
- return Buffer.concat([magic, Buffer.from([version]), nonce, authTag, ciphertext]);
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 brotliDecompressSync(compressed);
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 rowChunkSize = 5_000;
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
- return writeRowsAsPrivatePacks(vaultPath, toRows(documents), true);
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);
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andespindola/brainlink",
3
- "version": "0.1.0-beta.42",
3
+ "version": "0.1.0-beta.44",
4
4
  "description": "Local-first knowledge memory for agents with Markdown, backlinks, indexing and context retrieval.",
5
5
  "type": "module",
6
6
  "license": "MIT",