@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 +1 -0
- package/CHANGELOG.md +5 -0
- package/README.md +22 -1
- package/dist/application/index-vault.js +32 -28
- package/dist/application/offline-pack-backup.js +44 -0
- package/dist/cli/commands/write-commands.js +102 -1
- package/dist/infrastructure/config.js +38 -0
- package/dist/infrastructure/index-state.js +6 -0
- package/dist/infrastructure/private-pack-codec.js +71 -10
- package/dist/infrastructure/search-packs.js +98 -9
- package/docs/AGENT_USAGE.md +15 -0
- package/package.json +1 -1
package/AGENTS.md
CHANGED
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
|
|
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 =
|
|
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
|
-
:
|
|
232
|
-
? '
|
|
233
|
-
:
|
|
234
|
-
? '
|
|
235
|
-
:
|
|
236
|
-
? '
|
|
237
|
-
:
|
|
238
|
-
? '
|
|
239
|
-
:
|
|
240
|
-
? '
|
|
241
|
-
:
|
|
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
|
|
7
|
+
const legacyVersion = 1;
|
|
8
|
+
const currentVersion = 2;
|
|
8
9
|
const nonceLength = 12;
|
|
9
10
|
const authTagLength = 16;
|
|
10
11
|
const algorithm = 'aes-256-gcm';
|
|
12
|
+
const compressionLevelMask = 0x0f;
|
|
13
|
+
const compressionDictionaryMask = 0x10;
|
|
14
|
+
const defaultCompressionLevel = 5;
|
|
15
|
+
const builtinDictionary = Buffer.from([
|
|
16
|
+
'"documentId","agentId","title","path","chunkId","chunkOrdinal","content","tags"',
|
|
17
|
+
'"searchMode","textScore","semanticScore","weight","priority","shared"',
|
|
18
|
+
'agents/shared memory-hub architecture context index search graph markdown tags links',
|
|
19
|
+
'#memory #architecture #context #graph #search #index [[Memory Hub]] [[Architecture]]',
|
|
20
|
+
'The quick brown fox jumps over the lazy dog. Brainlink context package metadata.',
|
|
21
|
+
'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-:/.#[]{}(), '
|
|
22
|
+
].join('\n'), 'utf8');
|
|
11
23
|
const keyFilePath = (vaultPath) => {
|
|
12
24
|
const vaultHash = createHash('sha256').update(vaultPath).digest('hex').slice(0, 24);
|
|
13
25
|
return join(getBrainlinkHomePath(), 'keys', `search-pack-${vaultHash}.key`);
|
|
@@ -40,34 +52,83 @@ const parseHeader = (payload) => {
|
|
|
40
52
|
throw new Error('Invalid private pack payload: too short.');
|
|
41
53
|
}
|
|
42
54
|
const payloadMagic = payload.subarray(0, magic.length);
|
|
43
|
-
const payloadVersion = payload[magic.length];
|
|
44
|
-
if (!payloadMagic.equals(magic) || payloadVersion !==
|
|
55
|
+
const payloadVersion = payload[magic.length] ?? 0;
|
|
56
|
+
if (!payloadMagic.equals(magic) || (payloadVersion !== legacyVersion && payloadVersion !== currentVersion)) {
|
|
45
57
|
throw new Error('Invalid private pack payload: unsupported format.');
|
|
46
58
|
}
|
|
47
|
-
const
|
|
59
|
+
const hasCompressionSettings = payloadVersion >= 2;
|
|
60
|
+
const settingsByte = hasCompressionSettings ? payload[magic.length + 1] ?? 0 : null;
|
|
61
|
+
const nonceStart = magic.length + 1 + (hasCompressionSettings ? 1 : 0);
|
|
48
62
|
const authTagStart = nonceStart + nonceLength;
|
|
49
63
|
const dataStart = authTagStart + authTagLength;
|
|
50
64
|
return {
|
|
65
|
+
compression: settingsByte != null
|
|
66
|
+
? {
|
|
67
|
+
compressionLevel: settingsByte & compressionLevelMask,
|
|
68
|
+
useDictionary: (settingsByte & compressionDictionaryMask) !== 0
|
|
69
|
+
}
|
|
70
|
+
: {
|
|
71
|
+
compressionLevel: defaultCompressionLevel,
|
|
72
|
+
useDictionary: false
|
|
73
|
+
},
|
|
51
74
|
nonce: payload.subarray(nonceStart, authTagStart),
|
|
52
75
|
authTag: payload.subarray(authTagStart, dataStart),
|
|
53
76
|
ciphertext: payload.subarray(dataStart)
|
|
54
77
|
};
|
|
55
78
|
};
|
|
56
|
-
|
|
79
|
+
const toCompressionLevel = (value) => {
|
|
80
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
81
|
+
return defaultCompressionLevel;
|
|
82
|
+
}
|
|
83
|
+
const normalized = Math.round(value);
|
|
84
|
+
if (normalized < 0) {
|
|
85
|
+
return 0;
|
|
86
|
+
}
|
|
87
|
+
if (normalized > 11) {
|
|
88
|
+
return 11;
|
|
89
|
+
}
|
|
90
|
+
return normalized;
|
|
91
|
+
};
|
|
92
|
+
const encodeCompressionSettings = (settings) => (settings.compressionLevel & compressionLevelMask) | (settings.useDictionary ? compressionDictionaryMask : 0);
|
|
93
|
+
const brotliEncode = (content, settings) => {
|
|
94
|
+
const options = {
|
|
95
|
+
params: {
|
|
96
|
+
[zlibConstants.BROTLI_PARAM_MODE]: zlibConstants.BROTLI_MODE_TEXT,
|
|
97
|
+
[zlibConstants.BROTLI_PARAM_QUALITY]: settings.compressionLevel
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
if (settings.useDictionary) {
|
|
101
|
+
options.dictionary = builtinDictionary;
|
|
102
|
+
}
|
|
103
|
+
return brotliCompressSync(content, options);
|
|
104
|
+
};
|
|
105
|
+
const brotliDecode = (content, settings) => {
|
|
106
|
+
const options = {};
|
|
107
|
+
if (settings.useDictionary) {
|
|
108
|
+
options.dictionary = builtinDictionary;
|
|
109
|
+
}
|
|
110
|
+
return brotliDecompressSync(content, options);
|
|
111
|
+
};
|
|
112
|
+
export const encodePrivatePack = async (vaultPath, content, settings) => {
|
|
57
113
|
const key = await readOrCreateKey(vaultPath);
|
|
58
114
|
const nonce = randomBytes(nonceLength);
|
|
59
|
-
const
|
|
115
|
+
const normalizedSettings = {
|
|
116
|
+
compressionLevel: toCompressionLevel(settings?.compressionLevel),
|
|
117
|
+
useDictionary: settings?.useDictionary ?? true
|
|
118
|
+
};
|
|
119
|
+
const compressed = brotliEncode(content, normalizedSettings);
|
|
60
120
|
const cipher = createCipheriv(algorithm, key, nonce);
|
|
61
121
|
const ciphertext = Buffer.concat([cipher.update(compressed), cipher.final()]);
|
|
62
122
|
const authTag = cipher.getAuthTag();
|
|
63
|
-
|
|
123
|
+
const settingsByte = Buffer.from([encodeCompressionSettings(normalizedSettings)]);
|
|
124
|
+
return Buffer.concat([magic, Buffer.from([currentVersion]), settingsByte, nonce, authTag, ciphertext]);
|
|
64
125
|
};
|
|
65
126
|
export const decodePrivatePack = async (vaultPath, payload) => {
|
|
66
127
|
const key = await readOrCreateKey(vaultPath);
|
|
67
|
-
const { nonce, authTag, ciphertext } = parseHeader(payload);
|
|
128
|
+
const { nonce, authTag, ciphertext, compression } = parseHeader(payload);
|
|
68
129
|
const decipher = createDecipheriv(algorithm, key, nonce);
|
|
69
130
|
decipher.setAuthTag(authTag);
|
|
70
131
|
const compressed = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
71
|
-
return
|
|
132
|
+
return brotliDecode(compressed, compression);
|
|
72
133
|
};
|
|
73
134
|
export const isPrivatePackPayload = (payload) => payload.length >= magic.length + 1 && payload.subarray(0, magic.length).equals(magic);
|
|
@@ -5,7 +5,11 @@ import { middleOutIndices } from '../domain/middle-out.js';
|
|
|
5
5
|
import { decodePrivatePack, encodePrivatePack, isPrivatePackPayload } from './private-pack-codec.js';
|
|
6
6
|
const packsDirectoryName = 'search-packs';
|
|
7
7
|
const manifestFileName = 'manifest.json';
|
|
8
|
-
const
|
|
8
|
+
const defaultBuildOptions = {
|
|
9
|
+
rowChunkSize: 5_000,
|
|
10
|
+
compressionLevel: 5,
|
|
11
|
+
useDictionary: true
|
|
12
|
+
};
|
|
9
13
|
const queryTokenPattern = /[\p{L}\p{N}_-]+/gu;
|
|
10
14
|
const bloomBytes = 256;
|
|
11
15
|
const bloomBitSize = bloomBytes * 8;
|
|
@@ -94,7 +98,37 @@ const readManifest = async (vaultPath) => {
|
|
|
94
98
|
packCount: typeof parsed.packCount === 'number' ? parsed.packCount : packIndex.length,
|
|
95
99
|
recordCount: typeof parsed.recordCount === 'number' ? parsed.recordCount : 0,
|
|
96
100
|
format: 'private-v2',
|
|
97
|
-
packIndex
|
|
101
|
+
packIndex,
|
|
102
|
+
...(parsed.packConfig && typeof parsed.packConfig === 'object'
|
|
103
|
+
? {
|
|
104
|
+
packConfig: {
|
|
105
|
+
rowChunkSize: typeof parsed.packConfig.rowChunkSize === 'number'
|
|
106
|
+
? parsed.packConfig.rowChunkSize
|
|
107
|
+
: defaultBuildOptions.rowChunkSize,
|
|
108
|
+
compressionLevel: typeof parsed.packConfig.compressionLevel === 'number'
|
|
109
|
+
? parsed.packConfig.compressionLevel
|
|
110
|
+
: defaultBuildOptions.compressionLevel,
|
|
111
|
+
useDictionary: typeof parsed.packConfig.useDictionary === 'boolean'
|
|
112
|
+
? parsed.packConfig.useDictionary
|
|
113
|
+
: defaultBuildOptions.useDictionary
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
: {}),
|
|
117
|
+
...(parsed.compression &&
|
|
118
|
+
typeof parsed.compression === 'object' &&
|
|
119
|
+
typeof parsed.compression.inputBytes === 'number' &&
|
|
120
|
+
typeof parsed.compression.outputBytes === 'number' &&
|
|
121
|
+
typeof parsed.compression.ratio === 'number' &&
|
|
122
|
+
typeof parsed.compression.savedBytes === 'number'
|
|
123
|
+
? {
|
|
124
|
+
compression: {
|
|
125
|
+
inputBytes: parsed.compression.inputBytes,
|
|
126
|
+
outputBytes: parsed.compression.outputBytes,
|
|
127
|
+
ratio: parsed.compression.ratio,
|
|
128
|
+
savedBytes: parsed.compression.savedBytes
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
: {})
|
|
98
132
|
};
|
|
99
133
|
}
|
|
100
134
|
return null;
|
|
@@ -103,6 +137,37 @@ const readManifest = async (vaultPath) => {
|
|
|
103
137
|
return null;
|
|
104
138
|
}
|
|
105
139
|
};
|
|
140
|
+
export const ensureSearchPackManifest = async (vaultPath) => {
|
|
141
|
+
const manifest = await readManifest(vaultPath);
|
|
142
|
+
if (manifest) {
|
|
143
|
+
return {
|
|
144
|
+
repaired: false,
|
|
145
|
+
source: 'not-needed',
|
|
146
|
+
packCount: manifest.packCount
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
const files = await sortedPackFiles(vaultPath);
|
|
150
|
+
const packFiles = files.filter((file) => file.endsWith('.blpk'));
|
|
151
|
+
if (packFiles.length === 0) {
|
|
152
|
+
return {
|
|
153
|
+
repaired: false,
|
|
154
|
+
source: 'no-packs',
|
|
155
|
+
packCount: 0
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
await writeManifest(vaultPath, {
|
|
159
|
+
version: 2,
|
|
160
|
+
createdAt: new Date().toISOString(),
|
|
161
|
+
packCount: packFiles.length,
|
|
162
|
+
recordCount: 0,
|
|
163
|
+
format: 'private-v2'
|
|
164
|
+
});
|
|
165
|
+
return {
|
|
166
|
+
repaired: true,
|
|
167
|
+
source: 'existing-packs',
|
|
168
|
+
packCount: packFiles.length
|
|
169
|
+
};
|
|
170
|
+
};
|
|
106
171
|
const chunkRows = (rows, size) => {
|
|
107
172
|
const chunks = [];
|
|
108
173
|
for (let index = 0; index < rows.length; index += size) {
|
|
@@ -220,7 +285,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
|
-
|
|
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);
|
package/docs/AGENT_USAGE.md
CHANGED
|
@@ -52,6 +52,8 @@ Use `blink config where` and `blink config doctor` to inspect active paths and e
|
|
|
52
52
|
|
|
53
53
|
You can also set `defaultAgent` in `brainlink.config.json` / `.brainlink.json` (for example `"defaultAgent": "coding-agent"`). When set, CLI commands and MCP calls reuse it when `--agent`/`agent` is not passed.
|
|
54
54
|
You can set `agentProfiles` to define per-agent defaults for `defaultSearchMode`, `defaultSearchLimit` and `defaultContextTokens`.
|
|
55
|
+
You can tune search-pack compression with `searchPack.rowChunkSize`, `searchPack.compressionLevel` and `searchPack.useDictionary`.
|
|
56
|
+
Guardrails for benchmark acceptance are configured with `searchPack.guardrailMinSavingsPercent` and `searchPack.guardrailMaxLatencyRegressionPercent`.
|
|
55
57
|
|
|
56
58
|
`autoIndexOnWrite` (default: `true`) controls whether `add` and MCP write tools index right after writing.
|
|
57
59
|
|
|
@@ -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