@andespindola/brainlink 0.1.0-beta.11 → 0.1.0-beta.13
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 +3 -2
- package/dist/application/analyze-vault.js +8 -2
- package/dist/infrastructure/private-pack-codec.js +73 -0
- package/dist/infrastructure/search-packs.js +19 -14
- package/dist/infrastructure/sqlite/recovery.js +89 -9
- package/docs/AGENT_USAGE.md +3 -2
- package/docs/ARCHITECTURE.md +3 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -57,8 +57,9 @@ LLMs do not have infinite context. Brainlink gives agents an external memory lay
|
|
|
57
57
|
6. Brainlink returns compact, source-backed context.
|
|
58
58
|
|
|
59
59
|
Markdown is the source of truth. `.brainlink/brainlink.db` is only a rebuildable index.
|
|
60
|
-
Brainlink now keeps an automatic rollback snapshot at `.brainlink/brainlink.db.backup`. If the main SQLite file is corrupted, Brainlink automatically restores
|
|
61
|
-
After each index run, Brainlink also writes
|
|
60
|
+
Brainlink now keeps an automatic rollback snapshot at `.brainlink/brainlink.db.backup` plus rotating snapshots in `.brainlink/brainlink.db.backup.snapshots/`. If the main SQLite file is corrupted, Brainlink automatically restores the newest valid snapshot (or recreates a clean index when no snapshot exists).
|
|
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
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { stat } from 'node:fs/promises';
|
|
2
|
-
import { existsSync } from 'node:fs';
|
|
2
|
+
import { existsSync, readdirSync } from 'node:fs';
|
|
3
3
|
import { performance } from 'node:perf_hooks';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { validateGraph, getBrokenLinks, getOrphanNodes, getVaultStats } from '../domain/graph-analysis.js';
|
|
@@ -97,7 +97,11 @@ export const doctorVault = async (vaultPath) => {
|
|
|
97
97
|
const graph = await getGraphSummary(absoluteVaultPath);
|
|
98
98
|
const validation = validateGraph(graph);
|
|
99
99
|
const backupPath = join(absoluteVaultPath, '.brainlink', 'brainlink.db.backup');
|
|
100
|
+
const snapshotDirectory = join(absoluteVaultPath, '.brainlink', 'brainlink.db.backup.snapshots');
|
|
100
101
|
const hasBackup = existsSync(backupPath);
|
|
102
|
+
const snapshotCount = existsSync(snapshotDirectory)
|
|
103
|
+
? readdirSync(snapshotDirectory).filter((name) => name.endsWith('.db')).length
|
|
104
|
+
: 0;
|
|
101
105
|
const backupReady = graph.nodes.length === 0 || hasBackup;
|
|
102
106
|
const checks = [
|
|
103
107
|
createCheck('vault', true, `Vault ready at ${absoluteVaultPath}`),
|
|
@@ -105,7 +109,9 @@ export const doctorVault = async (vaultPath) => {
|
|
|
105
109
|
createCheck('index', graph.nodes.length > 0, `${graph.nodes.length} indexed documents found`),
|
|
106
110
|
createCheck('broken-links', validation.brokenLinks.length === 0, `${validation.brokenLinks.length} broken links found`),
|
|
107
111
|
createCheck('index-backup', backupReady, backupReady
|
|
108
|
-
? (hasBackup
|
|
112
|
+
? (hasBackup
|
|
113
|
+
? `SQLite recovery snapshot is available (${snapshotCount} rotating snapshots)`
|
|
114
|
+
: 'No index yet. Snapshot will be created after first indexing run')
|
|
109
115
|
: 'Recovery snapshot missing. Run blink index to create a rollback snapshot')
|
|
110
116
|
];
|
|
111
117
|
const recommendations = files.length === 0 && graph.nodes.length === 0
|
|
@@ -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
|
|
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) =>
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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')}.
|
|
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 =
|
|
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:
|
|
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;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import Database from 'better-sqlite3';
|
|
2
|
-
import { copyFileSync, existsSync, mkdirSync, renameSync, rmSync, unlinkSync } from 'node:fs';
|
|
3
|
-
import { dirname } from 'node:path';
|
|
2
|
+
import { copyFileSync, existsSync, mkdirSync, readdirSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { basename, dirname, join } from 'node:path';
|
|
4
4
|
const sqliteCorruptionHints = [
|
|
5
5
|
'database disk image is malformed',
|
|
6
6
|
'file is not a database',
|
|
@@ -8,6 +8,7 @@ const sqliteCorruptionHints = [
|
|
|
8
8
|
'malformed database schema',
|
|
9
9
|
'sqlite quick_check failed'
|
|
10
10
|
];
|
|
11
|
+
const maxSnapshotFiles = 24;
|
|
11
12
|
const normalizeMessage = (error) => error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
|
|
12
13
|
const isSqliteCorruptionError = (error) => sqliteCorruptionHints.some((hint) => normalizeMessage(error).includes(hint));
|
|
13
14
|
const safeUnlink = (path) => {
|
|
@@ -39,15 +40,19 @@ const archiveCorruptedDatabase = (databasePath) => {
|
|
|
39
40
|
const archivedPath = `${databasePath}.corrupt-${Date.now()}`;
|
|
40
41
|
renameSync(databasePath, archivedPath);
|
|
41
42
|
};
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
clearSidecars(databasePath);
|
|
43
|
+
const snapshotDirectoryPath = (backupPath) => join(dirname(backupPath), `${basename(backupPath)}.snapshots`);
|
|
44
|
+
const snapshotFileName = () => `snapshot-${new Date().toISOString().replace(/[:.]/g, '-')}.db`;
|
|
45
|
+
const cleanupSnapshotOverflow = (backupPath) => {
|
|
46
|
+
const directory = snapshotDirectoryPath(backupPath);
|
|
47
|
+
if (!existsSync(directory)) {
|
|
48
48
|
return;
|
|
49
49
|
}
|
|
50
|
-
|
|
50
|
+
const snapshots = readdirSync(directory)
|
|
51
|
+
.filter((name) => name.endsWith('.db'))
|
|
52
|
+
.sort((left, right) => right.localeCompare(left));
|
|
53
|
+
snapshots.slice(maxSnapshotFiles).forEach((name) => {
|
|
54
|
+
rmSync(join(directory, name), { force: true });
|
|
55
|
+
});
|
|
51
56
|
};
|
|
52
57
|
const openCheckedDatabase = (databasePath) => {
|
|
53
58
|
const database = new Database(databasePath);
|
|
@@ -60,13 +65,88 @@ const openCheckedDatabase = (databasePath) => {
|
|
|
60
65
|
}
|
|
61
66
|
return database;
|
|
62
67
|
};
|
|
68
|
+
const isValidDatabaseSnapshot = (path) => {
|
|
69
|
+
if (!existsSync(path)) {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
try {
|
|
73
|
+
const size = statSync(path).size;
|
|
74
|
+
if (size <= 0) {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
try {
|
|
82
|
+
const database = new Database(path);
|
|
83
|
+
try {
|
|
84
|
+
assertQuickCheck(database);
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
finally {
|
|
88
|
+
database.close();
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
const candidateBackupFiles = (backupPath) => {
|
|
96
|
+
const directory = snapshotDirectoryPath(backupPath);
|
|
97
|
+
const snapshots = existsSync(directory)
|
|
98
|
+
? readdirSync(directory)
|
|
99
|
+
.filter((name) => name.endsWith('.db'))
|
|
100
|
+
.sort((left, right) => right.localeCompare(left))
|
|
101
|
+
.map((name) => join(directory, name))
|
|
102
|
+
: [];
|
|
103
|
+
return [backupPath, ...snapshots];
|
|
104
|
+
};
|
|
105
|
+
const ensureSnapshotDirectory = (backupPath) => {
|
|
106
|
+
mkdirSync(snapshotDirectoryPath(backupPath), { recursive: true, mode: 0o700 });
|
|
107
|
+
};
|
|
108
|
+
const writeRecoveryMarker = (backupPath, restoredFrom) => {
|
|
109
|
+
const markerPath = join(dirname(backupPath), 'recovery-last-restore.json');
|
|
110
|
+
const payload = {
|
|
111
|
+
restoredAt: new Date().toISOString(),
|
|
112
|
+
restoredFrom
|
|
113
|
+
};
|
|
114
|
+
writeFileSync(markerPath, `${JSON.stringify(payload, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
115
|
+
};
|
|
116
|
+
const restoreFromBackupOrReset = (databasePath, backupPath) => {
|
|
117
|
+
clearSidecars(databasePath);
|
|
118
|
+
archiveCorruptedDatabase(databasePath);
|
|
119
|
+
for (const candidate of candidateBackupFiles(backupPath)) {
|
|
120
|
+
if (!isValidDatabaseSnapshot(candidate)) {
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
copyFileSync(candidate, databasePath);
|
|
124
|
+
clearSidecars(databasePath);
|
|
125
|
+
if (isValidDatabaseSnapshot(databasePath)) {
|
|
126
|
+
writeRecoveryMarker(backupPath, candidate);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
rmSync(databasePath, { force: true });
|
|
131
|
+
};
|
|
63
132
|
export const createRecoverySnapshot = (database, backupPath) => {
|
|
64
133
|
const backupDirectory = dirname(backupPath);
|
|
65
134
|
const tempBackupPath = `${backupPath}.tmp`;
|
|
135
|
+
const snapshotDirectory = snapshotDirectoryPath(backupPath);
|
|
136
|
+
const snapshotPath = join(snapshotDirectory, snapshotFileName());
|
|
66
137
|
mkdirSync(backupDirectory, { recursive: true });
|
|
138
|
+
ensureSnapshotDirectory(backupPath);
|
|
67
139
|
rmSync(tempBackupPath, { force: true });
|
|
140
|
+
try {
|
|
141
|
+
database.pragma('wal_checkpoint(PASSIVE)');
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
// Checkpoint is best-effort.
|
|
145
|
+
}
|
|
68
146
|
database.prepare('VACUUM INTO ?').run(tempBackupPath);
|
|
69
147
|
renameSync(tempBackupPath, backupPath);
|
|
148
|
+
copyFileSync(backupPath, snapshotPath);
|
|
149
|
+
cleanupSnapshotOverflow(backupPath);
|
|
70
150
|
};
|
|
71
151
|
export const openDatabaseWithRecovery = (databasePath, backupPath) => {
|
|
72
152
|
mkdirSync(dirname(databasePath), { recursive: true });
|
package/docs/AGENT_USAGE.md
CHANGED
|
@@ -634,8 +634,9 @@ GET /api/validate
|
|
|
634
634
|
|
|
635
635
|
The HTTP API is read-only. Use the CLI for writes and indexing.
|
|
636
636
|
|
|
637
|
-
Brainlink maintains an automatic SQLite rollback snapshot at `.brainlink/brainlink.db.backup`. When `.brainlink/brainlink.db` is corrupted, Brainlink restores
|
|
638
|
-
Indexing also writes
|
|
637
|
+
Brainlink maintains an automatic SQLite rollback snapshot at `.brainlink/brainlink.db.backup` and rotating snapshots in `.brainlink/brainlink.db.backup.snapshots/`. When `.brainlink/brainlink.db` is corrupted, Brainlink restores the newest valid snapshot automatically or recreates a clean index if no snapshot exists yet.
|
|
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
|
|
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -300,8 +300,9 @@ Markdown keeps the system portable, inspectable, Git-friendly, and compatible wi
|
|
|
300
300
|
|
|
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
|
-
Brainlink also writes a local rollback snapshot (`.brainlink/brainlink.db.backup`) after successful indexing. On corruption detection (`quick_check`/SQLite malformed errors), Brainlink restores
|
|
304
|
-
Indexing additionally exports
|
|
303
|
+
Brainlink also writes a local rollback snapshot (`.brainlink/brainlink.db.backup`) plus rotating point-in-time snapshots (`.brainlink/brainlink.db.backup.snapshots/`) after successful indexing. On corruption detection (`quick_check`/SQLite malformed errors), Brainlink restores the newest valid snapshot automatically before reopening the index.
|
|
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