@andespindola/brainlink 0.1.0-beta.43 → 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
@@ -85,6 +85,7 @@ npm run dev -- server --vault ./vault --watch
85
85
  npm run dev -- watch --vault ./vault
86
86
  npm run dev -- bench --vault ./vault
87
87
  npm run dev -- bench --vault ./vault --watch
88
+ npm run dev -- pack-backup --vault ./vault
88
89
  ```
89
90
 
90
91
  Start MCP over stdio:
package/CHANGELOG.md CHANGED
@@ -39,6 +39,11 @@
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
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.
42
47
 
43
48
  ## 0.1.0-beta.3
44
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
 
@@ -776,8 +777,21 @@ Summary includes compression behavior for `.blpk` packs when rebuild happens:
776
777
  - pack count and pack build duration
777
778
  - uncompressed input bytes vs compressed output bytes
778
779
  - saved percentage
780
+ - objective guardrails (minimum savings and maximum latency regression thresholds)
779
781
 
780
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`).
781
795
 
782
796
  ### `agents`
783
797
 
@@ -951,6 +965,13 @@ If no `vault` is configured and no `--vault` flag is passed, Brainlink uses `$HO
951
965
  "embeddingProvider": "local",
952
966
  "defaultSearchMode": "hybrid",
953
967
  "chunkSize": 1200,
968
+ "searchPack": {
969
+ "rowChunkSize": 5000,
970
+ "compressionLevel": 5,
971
+ "useDictionary": true,
972
+ "guardrailMinSavingsPercent": 8,
973
+ "guardrailMaxLatencyRegressionPercent": 5
974
+ },
954
975
  "agentProfiles": {
955
976
  "coding-agent": {
956
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,
@@ -134,6 +123,10 @@ export const indexVaultWithOptions = async (vaultPath, options) => {
134
123
  const settingsChanged = previousState == null ||
135
124
  previousState.chunkSize !== config.chunkSize ||
136
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;
137
130
  const changedPaths = new Set();
138
131
  for (let index = 0; index < summaries.length; index += 1) {
139
132
  const summary = summaries[index];
@@ -150,6 +143,7 @@ export const indexVaultWithOptions = async (vaultPath, options) => {
150
143
  const hasDeletes = previousState
151
144
  ? previousState.files.some((entry) => !currentSnapshotMap.has(entry.path))
152
145
  : false;
146
+ const manifestRecovery = await ensureSearchPackManifest(absoluteVaultPath);
153
147
  if (changedPaths.size === 0 &&
154
148
  !hasDeletes &&
155
149
  existingIndexedDocuments.length === summaries.length &&
@@ -160,11 +154,13 @@ export const indexVaultWithOptions = async (vaultPath, options) => {
160
154
  changedDocumentCount: 0,
161
155
  packs: {
162
156
  rebuilt: false,
163
- reason: 'No changes detected'
157
+ reason: manifestRecovery.repaired ? 'No changes detected; pack manifest repaired' : 'No changes detected'
164
158
  }
165
159
  };
166
160
  emit('complete', 'skip', 'Index skipped: no changes detected', {
167
- elapsedMs: result.elapsedMs
161
+ elapsedMs: result.elapsedMs,
162
+ manifestRepaired: manifestRecovery.repaired,
163
+ manifestRecoverySource: manifestRecovery.source
168
164
  });
169
165
  return result;
170
166
  }
@@ -213,7 +209,7 @@ export const indexVaultWithOptions = async (vaultPath, options) => {
213
209
  emit('persist', 'finish', 'Index persisted', {
214
210
  indexedDocuments: indexedDocuments.length
215
211
  });
216
- const existingPackManifest = await hasPackManifest(absoluteVaultPath);
212
+ const existingPackManifest = manifestRecovery.repaired || manifestRecovery.source === 'not-needed';
217
213
  const changedCount = changedPaths.size;
218
214
  const documentCount = Math.max(indexedDocuments.length, 1);
219
215
  const changeRatio = changedCount / documentCount;
@@ -221,6 +217,7 @@ export const indexVaultWithOptions = async (vaultPath, options) => {
221
217
  const pendingPackChanges = previousPendingPackChanges + changedCount;
222
218
  const shouldRebuildPacks = !existingPackManifest ||
223
219
  settingsChanged ||
220
+ packSettingsChanged ||
224
221
  hasDeletes ||
225
222
  changedCount >= 400 ||
226
223
  changeRatio >= 0.04 ||
@@ -228,23 +225,27 @@ export const indexVaultWithOptions = async (vaultPath, options) => {
228
225
  let packResult;
229
226
  const packReason = !existingPackManifest
230
227
  ? 'Missing pack manifest'
231
- : settingsChanged
232
- ? 'Index settings changed'
233
- : hasDeletes
234
- ? 'Document deletions detected'
235
- : changedCount >= 400
236
- ? 'Changed file count threshold reached'
237
- : changeRatio >= 0.04
238
- ? 'Change ratio threshold reached'
239
- : pendingPackChanges >= 1200
240
- ? 'Pending pack changes threshold reached'
241
- : 'Pack rebuild skipped';
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';
242
243
  if (shouldRebuildPacks) {
243
244
  emit('packs', 'start', 'Rebuilding compressed search packs', {
244
245
  reason: packReason
245
246
  });
246
247
  try {
247
- packResult = await buildSearchPacks(absoluteVaultPath, indexedDocuments);
248
+ packResult = await buildSearchPacks(absoluteVaultPath, indexedDocuments, toSearchPackBuildOptions(config));
248
249
  emit('packs', 'finish', 'Compressed packs rebuilt', {
249
250
  reason: packReason,
250
251
  packCount: packResult.packCount,
@@ -270,6 +271,9 @@ export const indexVaultWithOptions = async (vaultPath, options) => {
270
271
  await writeIndexState(absoluteVaultPath, {
271
272
  chunkSize: config.chunkSize,
272
273
  embeddingProvider: config.embeddingProvider,
274
+ searchPackRowChunkSize: config.searchPack.rowChunkSize,
275
+ searchPackCompressionLevel: config.searchPack.compressionLevel,
276
+ searchPackUseDictionary: config.searchPack.useDictionary,
273
277
  files: currentSnapshot,
274
278
  pendingPackChanges: packsRebuilt ? 0 : pendingPackChanges
275
279
  });
@@ -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 { 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';
@@ -9,6 +9,7 @@ import { resolveDuplicateNotes, scanDuplicateNotes } from '../../application/ded
9
9
  import { importLegacySqliteDatabase } from '../../application/import-legacy-sqlite.js';
10
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';
@@ -83,6 +84,71 @@ const printBenchSummary = (json, trigger, vault, result) => {
83
84
  ].join('\n');
84
85
  });
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
+ };
86
152
  const spawnDetached = (command, args, envOverrides) => {
87
153
  try {
88
154
  const child = spawn(command, args, {
@@ -371,7 +437,10 @@ const openGraphInAppWindow = (url) => {
371
437
  const appArgument = `--app=${url}`;
372
438
  const linuxChromiumStableFlags = [
373
439
  '--ozone-platform=x11',
440
+ '--ozone-platform-hint=x11',
374
441
  '--disable-gpu',
442
+ '--disable-vulkan',
443
+ '--use-gl=swiftshader',
375
444
  '--disable-features=Vulkan,VaapiVideoDecoder',
376
445
  '--disable-background-networking'
377
446
  ];
@@ -406,7 +475,10 @@ const openGraphInDetectedBrowser = (url) => {
406
475
  }
407
476
  const linuxChromiumStableFlags = [
408
477
  '--ozone-platform=x11',
478
+ '--ozone-platform-hint=x11',
409
479
  '--disable-gpu',
480
+ '--disable-vulkan',
481
+ '--use-gl=swiftshader',
410
482
  '--disable-features=Vulkan,VaapiVideoDecoder',
411
483
  '--disable-background-networking'
412
484
  ];
@@ -689,6 +761,7 @@ export const registerWriteCommands = (program) => {
689
761
  .description('benchmark indexing in realtime, including compressed pack behavior')
690
762
  .action(async (options) => {
691
763
  const resolved = await resolveOptions(options);
764
+ const config = await loadBrainlinkConfig();
692
765
  const emitProgress = (event) => {
693
766
  printBenchRealtimeEvent(options.json, event);
694
767
  };
@@ -697,10 +770,14 @@ export const registerWriteCommands = (program) => {
697
770
  print(options.json, { event: 'bench-error', message }, () => `[bench] error ${message}`);
698
771
  };
699
772
  const runAndPrint = async (trigger) => {
773
+ const baseline = await readBenchHistory(resolved.vault);
700
774
  const result = await indexVaultWithOptions(resolved.vault, {
701
775
  onProgress: emitProgress
702
776
  });
703
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);
704
781
  return result;
705
782
  };
706
783
  if (!options.watch) {
@@ -749,6 +826,30 @@ export const registerWriteCommands = (program) => {
749
826
  });
750
827
  process.exitCode = report.ok ? 0 : 1;
751
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
+ });
752
853
  program
753
854
  .command('watch')
754
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,7 @@ 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) => {
224
289
  const startedAt = process.hrtime.bigint();
225
290
  const directory = toPackDirectory(vaultPath);
226
291
  await mkdir(directory, { recursive: true });
@@ -230,7 +295,7 @@ const writeRowsAsPrivatePacks = async (vaultPath, rows, clearExisting) => {
230
295
  .filter((name) => name.endsWith('.blpk') || name.endsWith('.jsonl.gz') || name === manifestFileName)
231
296
  .map((name) => rm(join(directory, name), { force: true })));
232
297
  }
233
- const chunks = chunkRows(rows, rowChunkSize);
298
+ const chunks = chunkRows(rows, options.rowChunkSize);
234
299
  const packIndex = [];
235
300
  let inputBytes = 0;
236
301
  let outputBytes = 0;
@@ -238,7 +303,10 @@ const writeRowsAsPrivatePacks = async (vaultPath, rows, clearExisting) => {
238
303
  const chunk = chunks[index];
239
304
  const fileName = `pack-${String(index + 1).padStart(4, '0')}.blpk`;
240
305
  const serialized = `${chunk.map((row) => JSON.stringify(row)).join('\n')}\n`;
241
- 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
+ });
242
310
  const tokenBloomB64 = bloomToBase64(bloomFromRows(chunk));
243
311
  await writeFile(join(directory, fileName), compressed);
244
312
  inputBytes += Buffer.byteLength(serialized, 'utf8');
@@ -256,7 +324,18 @@ const writeRowsAsPrivatePacks = async (vaultPath, rows, clearExisting) => {
256
324
  packCount: chunks.length,
257
325
  recordCount: rows.length,
258
326
  format: 'private-v2',
259
- 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
+ }
260
339
  });
261
340
  const durationMs = Number(process.hrtime.bigint() - startedAt) / 1_000_000;
262
341
  const safeInput = Math.max(inputBytes, 1);
@@ -305,8 +384,13 @@ const selectCandidatePackFiles = async (vaultPath, tokens, agentId) => {
305
384
  }
306
385
  return byAgent.length > 0 ? byAgent.map((entry) => entry.fileName) : allFiles;
307
386
  };
308
- export const buildSearchPacks = async (vaultPath, documents) => {
309
- 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);
310
394
  };
311
395
  export const ensurePrivatePacksFromLegacyIndex = async (vaultPath) => {
312
396
  const files = await sortedPackFiles(vaultPath);
@@ -320,7 +404,7 @@ export const ensurePrivatePacksFromLegacyIndex = async (vaultPath) => {
320
404
  const parsed = await parseRowsFromPack(vaultPath, await readFile(join(toPackDirectory(vaultPath), file)));
321
405
  rows.push(...parsed);
322
406
  }
323
- const report = await writeRowsAsPrivatePacks(vaultPath, rows, true);
407
+ const report = await writeRowsAsPrivatePacks(vaultPath, rows, true, defaultBuildOptions);
324
408
  return {
325
409
  imported: true,
326
410
  source: 'legacy-packs',
@@ -329,6 +413,11 @@ export const ensurePrivatePacksFromLegacyIndex = async (vaultPath) => {
329
413
  }
330
414
  return { imported: false };
331
415
  };
416
+ export const toSearchPackBuildOptions = (config) => ({
417
+ rowChunkSize: config.searchPack.rowChunkSize,
418
+ compressionLevel: config.searchPack.compressionLevel,
419
+ useDictionary: config.searchPack.useDictionary
420
+ });
332
421
  export const searchInPacks = async (vaultPath, query, limit, agentId) => {
333
422
  const normalizedAgent = agentId?.trim();
334
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
 
@@ -492,8 +494,21 @@ blink bench --vault ./vault --json
492
494
  - elapsed time and changed document count
493
495
  - pack rebuild status and reason
494
496
  - pack compression metrics (`inputBytes`, `outputBytes`, ratio/saved percentage)
497
+ - objective guardrails (`guardrailMinSavingsPercent`, `guardrailMaxLatencyRegressionPercent`)
495
498
 
496
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.
497
512
 
498
513
  ### Search Knowledge
499
514
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andespindola/brainlink",
3
- "version": "0.1.0-beta.43",
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",