@andespindola/brainlink 0.1.0-beta.11 → 0.1.0-beta.12

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/README.md CHANGED
@@ -58,7 +58,8 @@ LLMs do not have infinite context. Brainlink gives agents an external memory lay
58
58
 
59
59
  Markdown is the source of truth. `.brainlink/brainlink.db` is only a rebuildable index.
60
60
  Brainlink now keeps an automatic rollback snapshot at `.brainlink/brainlink.db.backup`. If the main SQLite file is corrupted, Brainlink automatically restores from snapshot (or recreates a clean index when no snapshot exists).
61
- After each index run, Brainlink also writes compressed search packs at `.brainlink/search-packs/*.jsonl.gz`. If SQLite is unavailable, search falls back to these packs automatically.
61
+ After each index run, Brainlink also writes private encrypted search packs at `.brainlink/search-packs/*.blpk`. If SQLite is unavailable, search falls back to these packs automatically.
62
+ Pack decryption uses a Brainlink key from `$BRAINLINK_HOME/keys` or from `BRAINLINK_SEARCH_PACK_KEY` when explicitly configured.
62
63
 
63
64
  ## Features
64
65
 
@@ -0,0 +1,73 @@
1
+ import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto';
2
+ import { brotliCompressSync, brotliDecompressSync } from 'node:zlib';
3
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
4
+ import { dirname, join } from 'node:path';
5
+ import { getBrainlinkHomePath } from './paths.js';
6
+ const magic = Buffer.from('BLPK2', 'ascii');
7
+ const version = 1;
8
+ const nonceLength = 12;
9
+ const authTagLength = 16;
10
+ const algorithm = 'aes-256-gcm';
11
+ const keyFilePath = (vaultPath) => {
12
+ const vaultHash = createHash('sha256').update(vaultPath).digest('hex').slice(0, 24);
13
+ return join(getBrainlinkHomePath(), 'keys', `search-pack-${vaultHash}.key`);
14
+ };
15
+ const deriveKeyFromSecret = (secret) => createHash('sha256').update(secret, 'utf8').digest();
16
+ const readOrCreateKey = async (vaultPath) => {
17
+ const envSecret = process.env.BRAINLINK_SEARCH_PACK_KEY?.trim();
18
+ if (envSecret && envSecret.length > 0) {
19
+ return deriveKeyFromSecret(envSecret);
20
+ }
21
+ const path = keyFilePath(vaultPath);
22
+ try {
23
+ const existing = (await readFile(path, 'utf8')).trim();
24
+ if (existing.length > 0) {
25
+ return deriveKeyFromSecret(existing);
26
+ }
27
+ }
28
+ catch (error) {
29
+ if (!(error instanceof Error) || !('code' in error) || error.code !== 'ENOENT') {
30
+ throw error;
31
+ }
32
+ }
33
+ const secret = randomBytes(48).toString('base64url');
34
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
35
+ await writeFile(path, `${secret}\n`, { encoding: 'utf8', mode: 0o600 });
36
+ return deriveKeyFromSecret(secret);
37
+ };
38
+ const parseHeader = (payload) => {
39
+ if (payload.length < magic.length + 1 + nonceLength + authTagLength) {
40
+ throw new Error('Invalid private pack payload: too short.');
41
+ }
42
+ const payloadMagic = payload.subarray(0, magic.length);
43
+ const payloadVersion = payload[magic.length];
44
+ if (!payloadMagic.equals(magic) || payloadVersion !== version) {
45
+ throw new Error('Invalid private pack payload: unsupported format.');
46
+ }
47
+ const nonceStart = magic.length + 1;
48
+ const authTagStart = nonceStart + nonceLength;
49
+ const dataStart = authTagStart + authTagLength;
50
+ return {
51
+ nonce: payload.subarray(nonceStart, authTagStart),
52
+ authTag: payload.subarray(authTagStart, dataStart),
53
+ ciphertext: payload.subarray(dataStart)
54
+ };
55
+ };
56
+ export const encodePrivatePack = async (vaultPath, content) => {
57
+ const key = await readOrCreateKey(vaultPath);
58
+ const nonce = randomBytes(nonceLength);
59
+ const compressed = brotliCompressSync(content);
60
+ const cipher = createCipheriv(algorithm, key, nonce);
61
+ const ciphertext = Buffer.concat([cipher.update(compressed), cipher.final()]);
62
+ const authTag = cipher.getAuthTag();
63
+ return Buffer.concat([magic, Buffer.from([version]), nonce, authTag, ciphertext]);
64
+ };
65
+ export const decodePrivatePack = async (vaultPath, payload) => {
66
+ const key = await readOrCreateKey(vaultPath);
67
+ const { nonce, authTag, ciphertext } = parseHeader(payload);
68
+ const decipher = createDecipheriv(algorithm, key, nonce);
69
+ decipher.setAuthTag(authTag);
70
+ const compressed = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
71
+ return brotliDecompressSync(compressed);
72
+ };
73
+ export const isPrivatePackPayload = (payload) => payload.length >= magic.length + 1 && payload.subarray(0, magic.length).equals(magic);
@@ -1,18 +1,22 @@
1
- import { gunzipSync, gzipSync } from 'node:zlib';
1
+ import { gunzipSync } from 'node:zlib';
2
2
  import { mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
+ import { decodePrivatePack, encodePrivatePack, isPrivatePackPayload } from './private-pack-codec.js';
4
5
  const packsDirectoryName = 'search-packs';
5
6
  const manifestFileName = 'manifest.json';
6
7
  const rowChunkSize = 5_000;
7
8
  const queryTokenPattern = /[\p{L}\p{N}_-]+/gu;
8
9
  const toPackDirectory = (vaultPath) => join(vaultPath, '.brainlink', packsDirectoryName);
9
10
  const toManifestPath = (vaultPath) => join(toPackDirectory(vaultPath), manifestFileName);
10
- const parseRowsFromPack = (content) => gunzipSync(content)
11
- .toString('utf8')
12
- .split('\n')
13
- .map((line) => line.trim())
14
- .filter((line) => line.length > 0)
15
- .map((line) => JSON.parse(line));
11
+ const parseRowsFromPack = async (vaultPath, content) => {
12
+ const raw = isPrivatePackPayload(content) ? await decodePrivatePack(vaultPath, content) : gunzipSync(content);
13
+ return raw
14
+ .toString('utf8')
15
+ .split('\n')
16
+ .map((line) => line.trim())
17
+ .filter((line) => line.length > 0)
18
+ .map((line) => JSON.parse(line));
19
+ };
16
20
  const toRows = (documents) => documents.flatMap((document) => document.chunks.map((chunk) => ({
17
21
  documentId: document.document.id,
18
22
  agentId: document.document.agentId,
@@ -86,7 +90,7 @@ const sortedPackFiles = async (vaultPath) => {
86
90
  try {
87
91
  const files = await readdir(toPackDirectory(vaultPath));
88
92
  return files
89
- .filter((file) => file.endsWith('.jsonl.gz'))
93
+ .filter((file) => file.endsWith('.blpk') || file.endsWith('.jsonl.gz'))
90
94
  .sort((left, right) => left.localeCompare(right));
91
95
  }
92
96
  catch (error) {
@@ -102,20 +106,21 @@ export const buildSearchPacks = async (vaultPath, documents) => {
102
106
  await mkdir(directory, { recursive: true });
103
107
  const current = await readdir(directory);
104
108
  await Promise.all(current
105
- .filter((name) => name.endsWith('.jsonl.gz') || name === manifestFileName)
109
+ .filter((name) => name.endsWith('.blpk') || name.endsWith('.jsonl.gz') || name === manifestFileName)
106
110
  .map((name) => rm(join(directory, name), { force: true })));
107
111
  const chunks = chunkRows(rows, rowChunkSize);
108
112
  await Promise.all(chunks.map(async (chunk, index) => {
109
- const fileName = `pack-${String(index + 1).padStart(4, '0')}.jsonl.gz`;
113
+ const fileName = `pack-${String(index + 1).padStart(4, '0')}.blpk`;
110
114
  const serialized = `${chunk.map((row) => JSON.stringify(row)).join('\n')}\n`;
111
- const compressed = gzipSync(Buffer.from(serialized, 'utf8'), { level: 6 });
115
+ const compressed = await encodePrivatePack(vaultPath, Buffer.from(serialized, 'utf8'));
112
116
  await writeFile(join(directory, fileName), compressed);
113
117
  }));
114
118
  await writeManifest(vaultPath, {
115
- version: 1,
119
+ version: 2,
116
120
  createdAt: new Date().toISOString(),
117
121
  packCount: chunks.length,
118
- recordCount: rows.length
122
+ recordCount: rows.length,
123
+ format: 'private-v2'
119
124
  });
120
125
  return {
121
126
  packCount: chunks.length,
@@ -134,7 +139,7 @@ export const searchInPacks = async (vaultPath, query, limit, agentId) => {
134
139
  }
135
140
  const scored = [];
136
141
  for (const file of files) {
137
- const rows = parseRowsFromPack(await readFile(join(toPackDirectory(vaultPath), file)));
142
+ const rows = await parseRowsFromPack(vaultPath, await readFile(join(toPackDirectory(vaultPath), file)));
138
143
  rows.forEach((row) => {
139
144
  if (normalizedAgent && row.agentId !== normalizedAgent) {
140
145
  return;
@@ -635,7 +635,8 @@ GET /api/validate
635
635
  The HTTP API is read-only. Use the CLI for writes and indexing.
636
636
 
637
637
  Brainlink maintains an automatic SQLite rollback snapshot at `.brainlink/brainlink.db.backup`. When `.brainlink/brainlink.db` is corrupted, Brainlink restores from snapshot automatically or recreates a clean index if no snapshot exists yet.
638
- Indexing also writes compressed search packs at `.brainlink/search-packs/*.jsonl.gz`; when SQLite cannot be opened, Brainlink falls back to pack-based search automatically.
638
+ Indexing also writes private encrypted search packs at `.brainlink/search-packs/*.blpk`; when SQLite cannot be opened, Brainlink falls back to pack-based search automatically.
639
+ Pack decryption keys are resolved from `$BRAINLINK_HOME/keys` (or `BRAINLINK_SEARCH_PACK_KEY` when explicitly set).
639
640
 
640
641
  ## Agent Integration Contract
641
642
 
@@ -301,7 +301,8 @@ Markdown keeps the system portable, inspectable, Git-friendly, and compatible wi
301
301
  SQLite gives fast local search, local vector storage and rebuildable retrieval without forcing users to run external infrastructure.
302
302
  Hybrid retrieval also uses a short-lived in-memory cache keyed by vault/query/agent and invalidated by index file mtime to reduce repeated query latency.
303
303
  Brainlink also writes a local rollback snapshot (`.brainlink/brainlink.db.backup`) after successful indexing. On corruption detection (`quick_check`/SQLite malformed errors), Brainlink restores from snapshot automatically before reopening the index.
304
- Indexing additionally exports compressed pack files (`.brainlink/search-packs/*.jsonl.gz`) from indexed chunks. Search falls back to these packs when SQLite is unavailable, preserving retrieval continuity in degraded mode.
304
+ Indexing additionally exports private encrypted pack files (`.brainlink/search-packs/*.blpk`) from indexed chunks. Search falls back to these packs when SQLite is unavailable, preserving retrieval continuity in degraded mode.
305
+ Pack encryption keys are resolved from `$BRAINLINK_HOME/keys` or from `BRAINLINK_SEARCH_PACK_KEY` when configured.
305
306
 
306
307
  ### CLI First
307
308
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andespindola/brainlink",
3
- "version": "0.1.0-beta.11",
3
+ "version": "0.1.0-beta.12",
4
4
  "description": "Local-first knowledge memory for agents with Markdown, backlinks, indexing and context retrieval.",
5
5
  "type": "module",
6
6
  "license": "MIT",