@andespindola/brainlink 1.1.0 → 1.2.0
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/CHANGELOG.md +11 -1
- package/README.md +31 -5
- package/dist/application/analyze-vault.js +1 -1
- package/dist/application/build-context.js +14 -7
- package/dist/application/index-vault-phases.js +1 -0
- package/dist/application/index-vault.js +14 -0
- package/dist/application/provision-vault.js +74 -0
- package/dist/application/ranking/run-search.js +38 -3
- package/dist/application/search-knowledge.js +7 -16
- package/dist/application/server/routes.js +6 -1
- package/dist/application/vault-encryption.js +64 -0
- package/dist/application/vault-git-core.js +168 -0
- package/dist/application/vault-git.js +36 -141
- package/dist/cli/commands/read-commands.js +9 -5
- package/dist/cli/commands/vault-sync-commands.js +22 -0
- package/dist/cli/commands/write/vault-lifecycle-commands.js +1 -1
- package/dist/infrastructure/config.js +13 -2
- package/dist/infrastructure/context-packs.js +57 -18
- package/dist/infrastructure/file-index.js +2 -2
- package/dist/infrastructure/index-signature.js +27 -0
- package/dist/infrastructure/index-state.js +6 -0
- package/dist/infrastructure/knowledge-store/binary-store.js +173 -3
- package/dist/infrastructure/knowledge-store/index.js +3 -2
- package/dist/infrastructure/knowledge-store/stored-index.js +2 -2
- package/dist/infrastructure/vault-crypto.js +78 -0
- package/dist/mcp/server.js +46 -1
- package/dist/mcp/tools/maintenance-tools.js +2 -2
- package/dist/mcp/tools/read-tools.js +8 -4
- package/dist/mcp/tools/vault-tools.js +163 -0
- package/dist/mcp/tools.js +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// Git primitives for vault versioning, with no dependency on the indexing layer
|
|
2
|
+
// so they can be reused by the auto-version hook that runs *after* indexing
|
|
3
|
+
// (avoiding a cycle with index-vault). Only the canonical Markdown is versioned;
|
|
4
|
+
// the derived `.brainlink/` directory is git-ignored. Git is driven through the
|
|
5
|
+
// system binary via execFile (never a shell), and a missing binary degrades to a
|
|
6
|
+
// clear, actionable error.
|
|
7
|
+
import { execFile } from 'node:child_process';
|
|
8
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
import { promisify } from 'node:util';
|
|
11
|
+
import { ensureVault } from '../infrastructure/file-system-vault.js';
|
|
12
|
+
import { encryptVaultTree, vaultEncryptionEnabled } from './vault-encryption.js';
|
|
13
|
+
const execFileAsync = promisify(execFile);
|
|
14
|
+
export class GitUnavailableError extends Error {
|
|
15
|
+
constructor() {
|
|
16
|
+
super('git executable not found. Install git to use vault versioning.');
|
|
17
|
+
this.name = 'GitUnavailableError';
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
export class GitCommandError extends Error {
|
|
21
|
+
constructor(args, stderr) {
|
|
22
|
+
super(`git ${args.join(' ')} failed: ${stderr.trim() || 'unknown error'}`);
|
|
23
|
+
this.name = 'GitCommandError';
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
const isMissingBinary = (error) => error instanceof Error && 'code' in error && error.code === 'ENOENT';
|
|
27
|
+
export const runGit = async (cwd, args) => {
|
|
28
|
+
try {
|
|
29
|
+
const { stdout } = await execFileAsync('git', [...args], { cwd, maxBuffer: 64 * 1024 * 1024 });
|
|
30
|
+
return stdout;
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
if (isMissingBinary(error)) {
|
|
34
|
+
throw new GitUnavailableError();
|
|
35
|
+
}
|
|
36
|
+
const stderr = error.stderr ?? (error instanceof Error ? error.message : String(error));
|
|
37
|
+
throw new GitCommandError(args, stderr);
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
const isInsideGitRepo = async (cwd) => {
|
|
41
|
+
try {
|
|
42
|
+
const out = await runGit(cwd, ['rev-parse', '--is-inside-work-tree']);
|
|
43
|
+
return out.trim() === 'true';
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
if (error instanceof GitUnavailableError) {
|
|
47
|
+
throw error;
|
|
48
|
+
}
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
const baseGitignoreLines = ['.brainlink/', '*.tmp', '.DS_Store'];
|
|
53
|
+
// In encrypted mode the plaintext Markdown is never committed — only the
|
|
54
|
+
// `<note>.md.enc` ciphertext is — so the working `.md` files are git-ignored.
|
|
55
|
+
const encryptedGitignoreLines = [...baseGitignoreLines, '*.md'];
|
|
56
|
+
const ensureGitignore = async (vaultRoot, lines) => {
|
|
57
|
+
const path = join(vaultRoot, '.gitignore');
|
|
58
|
+
let current = '';
|
|
59
|
+
try {
|
|
60
|
+
current = await readFile(path, 'utf8');
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
current = '';
|
|
64
|
+
}
|
|
65
|
+
const existing = new Set(current.split('\n').map((line) => line.trim()));
|
|
66
|
+
const missing = lines.filter((line) => !existing.has(line));
|
|
67
|
+
if (missing.length === 0 && current.length > 0) {
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
const next = `${[current.trimEnd(), ...missing].filter(Boolean).join('\n')}\n`;
|
|
71
|
+
await writeFile(path, next, 'utf8');
|
|
72
|
+
};
|
|
73
|
+
// Reject credentials embedded in a remote URL: they would be written to
|
|
74
|
+
// .git/config in clear text. Users should rely on SSH or a git credential
|
|
75
|
+
// helper instead.
|
|
76
|
+
export const assertSafeRemote = (remote) => {
|
|
77
|
+
if (/\/\/[^/@]*:[^/@]*@/.test(remote) || /x-access-token|:[^/]*@github/.test(remote)) {
|
|
78
|
+
throw new Error('Refusing a remote URL with an inline credential. Use SSH or a git credential helper.');
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
export const markdownOnly = (paths) => paths.filter((path) => path.toLowerCase().endsWith('.md'));
|
|
82
|
+
// True when the repo has an 'origin' remote, so auto-versioning knows whether a
|
|
83
|
+
// push is possible.
|
|
84
|
+
export const hasRemote = async (root, remote = 'origin') => {
|
|
85
|
+
const remotes = await runGit(root, ['remote']).catch(() => '');
|
|
86
|
+
return remotes.split('\n').map((line) => line.trim()).includes(remote);
|
|
87
|
+
};
|
|
88
|
+
export const initVaultGit = async (vaultPath, options = {}) => {
|
|
89
|
+
const root = await ensureVault(vaultPath);
|
|
90
|
+
await mkdir(root, { recursive: true });
|
|
91
|
+
const already = await isInsideGitRepo(root);
|
|
92
|
+
if (!already) {
|
|
93
|
+
await runGit(root, ['init']);
|
|
94
|
+
}
|
|
95
|
+
await ensureGitignore(root, (await vaultEncryptionEnabled()) ? encryptedGitignoreLines : baseGitignoreLines);
|
|
96
|
+
if (options.remote) {
|
|
97
|
+
assertSafeRemote(options.remote);
|
|
98
|
+
const verb = (await hasRemote(root)) ? 'set-url' : 'add';
|
|
99
|
+
await runGit(root, ['remote', verb, 'origin', options.remote]);
|
|
100
|
+
}
|
|
101
|
+
return { root, initialized: !already };
|
|
102
|
+
};
|
|
103
|
+
export const vaultGitStatus = async (vaultPath) => {
|
|
104
|
+
const root = await ensureVault(vaultPath);
|
|
105
|
+
const branch = (await runGit(root, ['rev-parse', '--abbrev-ref', 'HEAD']).catch(() => 'HEAD')).trim();
|
|
106
|
+
const porcelain = await runGit(root, ['status', '--porcelain']);
|
|
107
|
+
const changed = porcelain
|
|
108
|
+
.split('\n')
|
|
109
|
+
.map((line) => line.slice(3).trim())
|
|
110
|
+
.filter(Boolean);
|
|
111
|
+
return { branch, dirty: changed.length > 0, changedMarkdown: markdownOnly(changed) };
|
|
112
|
+
};
|
|
113
|
+
// Stage and commit the versioned content. Returns null when there is nothing to
|
|
114
|
+
// commit. In encrypted mode the ciphertext mirror is refreshed first and only
|
|
115
|
+
// `<note>.md.enc` is staged; otherwise the plaintext Markdown is staged.
|
|
116
|
+
export const commitVault = async (vaultPath, message) => {
|
|
117
|
+
const root = await ensureVault(vaultPath);
|
|
118
|
+
const encrypted = await vaultEncryptionEnabled();
|
|
119
|
+
if (encrypted) {
|
|
120
|
+
await encryptVaultTree(root);
|
|
121
|
+
await ensureGitignore(root, encryptedGitignoreLines);
|
|
122
|
+
}
|
|
123
|
+
const patterns = encrypted
|
|
124
|
+
? ['*.md.enc', ':(glob)**/*.md.enc', '.gitignore']
|
|
125
|
+
: ['*.md', ':(glob)**/*.md', '.gitignore'];
|
|
126
|
+
await runGit(root, ['add', '-A', '--', ...patterns]);
|
|
127
|
+
const staged = await runGit(root, ['diff', '--cached', '--name-only']);
|
|
128
|
+
if (staged.trim().length === 0) {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
await runGit(root, ['commit', '-m', message]);
|
|
132
|
+
return (await runGit(root, ['rev-parse', 'HEAD'])).trim();
|
|
133
|
+
};
|
|
134
|
+
export const pushVault = async (vaultPath, branch) => {
|
|
135
|
+
const root = await ensureVault(vaultPath);
|
|
136
|
+
const target = branch ?? (await runGit(root, ['rev-parse', '--abbrev-ref', 'HEAD'])).trim();
|
|
137
|
+
await runGit(root, ['push', '-u', 'origin', target]);
|
|
138
|
+
};
|
|
139
|
+
export const vaultHistory = async (vaultPath, limit = 20) => {
|
|
140
|
+
const root = await ensureVault(vaultPath);
|
|
141
|
+
const out = await runGit(root, [
|
|
142
|
+
'log',
|
|
143
|
+
`-n${Math.max(1, Math.floor(limit))}`,
|
|
144
|
+
'--format=%H%x00%an%x00%aI%x00%s',
|
|
145
|
+
'--',
|
|
146
|
+
'*.md'
|
|
147
|
+
]).catch(() => '');
|
|
148
|
+
return out
|
|
149
|
+
.split('\n')
|
|
150
|
+
.map((line) => line.split('\u0000'))
|
|
151
|
+
.filter((parts) => parts.length === 4)
|
|
152
|
+
.map(([hash, author, date, subject]) => ({ hash: hash, author: author, date: date, subject: subject }));
|
|
153
|
+
};
|
|
154
|
+
// Best-effort commit + push of the canonical Markdown, used by the auto-version
|
|
155
|
+
// hook after a successful index. A no-op unless the vault is a git repo with an
|
|
156
|
+
// origin remote. Commits only when Markdown actually changed.
|
|
157
|
+
export const autoVersionVault = async (vaultPath, message) => {
|
|
158
|
+
const root = await ensureVault(vaultPath);
|
|
159
|
+
if (!(await isInsideGitRepo(root)) || !(await hasRemote(root))) {
|
|
160
|
+
return { committed: null, pushed: false };
|
|
161
|
+
}
|
|
162
|
+
const committed = await commitVault(vaultPath, message);
|
|
163
|
+
if (!committed) {
|
|
164
|
+
return { committed: null, pushed: false };
|
|
165
|
+
}
|
|
166
|
+
await pushVault(vaultPath);
|
|
167
|
+
return { committed, pushed: true };
|
|
168
|
+
};
|
|
@@ -1,141 +1,13 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// Git is driven through the system binary via execFile (never a shell, args as
|
|
7
|
-
// an array) to keep runtime dependencies minimal and history/merge behavior
|
|
8
|
-
// native. A missing git binary degrades to a clear, actionable error.
|
|
9
|
-
import { execFile } from 'node:child_process';
|
|
10
|
-
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
11
|
-
import { join } from 'node:path';
|
|
12
|
-
import { promisify } from 'node:util';
|
|
1
|
+
// Vault versioning operations that reindex after changing Markdown on disk
|
|
2
|
+
// (pull, restore, clone) so the vault stays immediately usable ("no losing the
|
|
3
|
+
// use of the data"). The git primitives live in vault-git-core (which has no
|
|
4
|
+
// indexing dependency) and are re-exported here so callers keep a single import.
|
|
13
5
|
import { indexVault } from './index-vault.js';
|
|
14
6
|
import { ensureVault } from '../infrastructure/file-system-vault.js';
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
super('git executable not found. Install git to use vault versioning.');
|
|
19
|
-
this.name = 'GitUnavailableError';
|
|
20
|
-
}
|
|
21
|
-
}
|
|
22
|
-
export class GitCommandError extends Error {
|
|
23
|
-
constructor(args, stderr) {
|
|
24
|
-
super(`git ${args.join(' ')} failed: ${stderr.trim() || 'unknown error'}`);
|
|
25
|
-
this.name = 'GitCommandError';
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
const isMissingBinary = (error) => error instanceof Error && 'code' in error && error.code === 'ENOENT';
|
|
29
|
-
const runGit = async (cwd, args) => {
|
|
30
|
-
try {
|
|
31
|
-
const { stdout } = await execFileAsync('git', [...args], { cwd, maxBuffer: 64 * 1024 * 1024 });
|
|
32
|
-
return stdout;
|
|
33
|
-
}
|
|
34
|
-
catch (error) {
|
|
35
|
-
if (isMissingBinary(error)) {
|
|
36
|
-
throw new GitUnavailableError();
|
|
37
|
-
}
|
|
38
|
-
const stderr = error.stderr ?? (error instanceof Error ? error.message : String(error));
|
|
39
|
-
throw new GitCommandError(args, stderr);
|
|
40
|
-
}
|
|
41
|
-
};
|
|
42
|
-
const isInsideGitRepo = async (cwd) => {
|
|
43
|
-
try {
|
|
44
|
-
const out = await runGit(cwd, ['rev-parse', '--is-inside-work-tree']);
|
|
45
|
-
return out.trim() === 'true';
|
|
46
|
-
}
|
|
47
|
-
catch (error) {
|
|
48
|
-
if (error instanceof GitUnavailableError) {
|
|
49
|
-
throw error;
|
|
50
|
-
}
|
|
51
|
-
return false;
|
|
52
|
-
}
|
|
53
|
-
};
|
|
54
|
-
const gitignoreLines = ['.brainlink/', '*.tmp', '.DS_Store'];
|
|
55
|
-
const ensureGitignore = async (vaultRoot) => {
|
|
56
|
-
const path = join(vaultRoot, '.gitignore');
|
|
57
|
-
let current = '';
|
|
58
|
-
try {
|
|
59
|
-
current = await readFile(path, 'utf8');
|
|
60
|
-
}
|
|
61
|
-
catch {
|
|
62
|
-
current = '';
|
|
63
|
-
}
|
|
64
|
-
const existing = new Set(current.split('\n').map((line) => line.trim()));
|
|
65
|
-
const missing = gitignoreLines.filter((line) => !existing.has(line));
|
|
66
|
-
if (missing.length === 0 && current.length > 0) {
|
|
67
|
-
return;
|
|
68
|
-
}
|
|
69
|
-
const next = `${[current.trimEnd(), ...missing].filter(Boolean).join('\n')}\n`;
|
|
70
|
-
await writeFile(path, next, 'utf8');
|
|
71
|
-
};
|
|
72
|
-
// Reject credentials embedded in a remote URL: they would be written to
|
|
73
|
-
// .git/config in clear text. Users should rely on SSH or a git credential
|
|
74
|
-
// helper instead.
|
|
75
|
-
const assertSafeRemote = (remote) => {
|
|
76
|
-
if (/\/\/[^/@]*:[^/@]*@/.test(remote) || /x-access-token|:[^/]*@github/.test(remote)) {
|
|
77
|
-
throw new Error('Refusing a remote URL with an inline credential. Use SSH or a git credential helper.');
|
|
78
|
-
}
|
|
79
|
-
};
|
|
80
|
-
const markdownOnly = (paths) => paths.filter((path) => path.toLowerCase().endsWith('.md'));
|
|
81
|
-
export const initVaultGit = async (vaultPath, options = {}) => {
|
|
82
|
-
const root = await ensureVault(vaultPath);
|
|
83
|
-
await mkdir(root, { recursive: true });
|
|
84
|
-
const already = await isInsideGitRepo(root);
|
|
85
|
-
if (!already) {
|
|
86
|
-
await runGit(root, ['init']);
|
|
87
|
-
}
|
|
88
|
-
await ensureGitignore(root);
|
|
89
|
-
if (options.remote) {
|
|
90
|
-
assertSafeRemote(options.remote);
|
|
91
|
-
const remotes = await runGit(root, ['remote']);
|
|
92
|
-
const verb = remotes.split('\n').map((line) => line.trim()).includes('origin') ? 'set-url' : 'add';
|
|
93
|
-
await runGit(root, ['remote', verb, 'origin', options.remote]);
|
|
94
|
-
}
|
|
95
|
-
return { root, initialized: !already };
|
|
96
|
-
};
|
|
97
|
-
export const vaultGitStatus = async (vaultPath) => {
|
|
98
|
-
const root = await ensureVault(vaultPath);
|
|
99
|
-
const branch = (await runGit(root, ['rev-parse', '--abbrev-ref', 'HEAD']).catch(() => 'HEAD')).trim();
|
|
100
|
-
const porcelain = await runGit(root, ['status', '--porcelain']);
|
|
101
|
-
const changed = porcelain
|
|
102
|
-
.split('\n')
|
|
103
|
-
.map((line) => line.slice(3).trim())
|
|
104
|
-
.filter(Boolean);
|
|
105
|
-
return { branch, dirty: changed.length > 0, changedMarkdown: markdownOnly(changed) };
|
|
106
|
-
};
|
|
107
|
-
// Stage and commit only Markdown. Returns null when there is nothing to commit.
|
|
108
|
-
export const commitVault = async (vaultPath, message) => {
|
|
109
|
-
const root = await ensureVault(vaultPath);
|
|
110
|
-
await runGit(root, ['add', '-A', '--', '*.md', ':(glob)**/*.md', '.gitignore']);
|
|
111
|
-
const staged = await runGit(root, ['diff', '--cached', '--name-only']);
|
|
112
|
-
if (staged.trim().length === 0) {
|
|
113
|
-
return null;
|
|
114
|
-
}
|
|
115
|
-
await runGit(root, ['commit', '-m', message]);
|
|
116
|
-
return (await runGit(root, ['rev-parse', 'HEAD'])).trim();
|
|
117
|
-
};
|
|
118
|
-
export const vaultHistory = async (vaultPath, limit = 20) => {
|
|
119
|
-
const root = await ensureVault(vaultPath);
|
|
120
|
-
const out = await runGit(root, [
|
|
121
|
-
'log',
|
|
122
|
-
`-n${Math.max(1, Math.floor(limit))}`,
|
|
123
|
-
'--format=%H%x00%an%x00%aI%x00%s',
|
|
124
|
-
'--',
|
|
125
|
-
'*.md'
|
|
126
|
-
]).catch(() => '');
|
|
127
|
-
return out
|
|
128
|
-
.split('\n')
|
|
129
|
-
.map((line) => line.split('\u0000'))
|
|
130
|
-
.filter((parts) => parts.length === 4)
|
|
131
|
-
.map(([hash, author, date, subject]) => ({ hash: hash, author: author, date: date, subject: subject }));
|
|
132
|
-
};
|
|
7
|
+
import { decryptVaultTree, vaultEncryptionEnabled } from './vault-encryption.js';
|
|
8
|
+
import { assertSafeRemote, GitCommandError, markdownOnly, runGit } from './vault-git-core.js';
|
|
9
|
+
export * from './vault-git-core.js';
|
|
133
10
|
const reindexAfterMarkdownChange = async (vaultPath, changedMarkdown, skipIndex) => !skipIndex && changedMarkdown.length > 0 ? indexVault(vaultPath) : undefined;
|
|
134
|
-
export const pushVault = async (vaultPath, branch) => {
|
|
135
|
-
const root = await ensureVault(vaultPath);
|
|
136
|
-
const target = branch ?? (await runGit(root, ['rev-parse', '--abbrev-ref', 'HEAD'])).trim();
|
|
137
|
-
await runGit(root, ['push', '-u', 'origin', target]);
|
|
138
|
-
};
|
|
139
11
|
// Pull from origin and reindex when Markdown changed, so the vault is usable
|
|
140
12
|
// immediately. On a merge conflict the merge is aborted and a clear error is
|
|
141
13
|
// raised rather than leaving conflict markers in the Markdown.
|
|
@@ -152,21 +24,38 @@ export const pullVault = async (vaultPath, options = {}) => {
|
|
|
152
24
|
: error;
|
|
153
25
|
}
|
|
154
26
|
const after = (await runGit(root, ['rev-parse', 'HEAD']).catch(() => '')).trim();
|
|
155
|
-
const
|
|
156
|
-
|
|
157
|
-
|
|
27
|
+
const changed = Boolean(before && after && before !== after);
|
|
28
|
+
// Encrypted vaults track ciphertext; rebuild the plaintext Markdown from it
|
|
29
|
+
// before reindexing. Individual filenames are not reported (the diff is over
|
|
30
|
+
// opaque `.md.enc`).
|
|
31
|
+
if (await vaultEncryptionEnabled()) {
|
|
32
|
+
if (changed) {
|
|
33
|
+
await decryptVaultTree(root);
|
|
34
|
+
}
|
|
35
|
+
const index = changed ? await reindexAfterMarkdownChange(vaultPath, ['(encrypted)'], options.skipIndex === true) : undefined;
|
|
36
|
+
return { changedMarkdown: changed ? ['(encrypted)'] : [], index };
|
|
37
|
+
}
|
|
38
|
+
const diff = changed ? await runGit(root, ['diff', '--name-only', before, after, '--', '*.md']) : '';
|
|
158
39
|
const changedMarkdown = markdownOnly(diff.split('\n').map((line) => line.trim()).filter(Boolean));
|
|
159
40
|
const index = await reindexAfterMarkdownChange(vaultPath, changedMarkdown, options.skipIndex === true);
|
|
160
41
|
return { changedMarkdown, index };
|
|
161
42
|
};
|
|
162
43
|
export const restoreVault = async (vaultPath, ref, options = {}) => {
|
|
163
44
|
const root = await ensureVault(vaultPath);
|
|
164
|
-
const
|
|
165
|
-
const diff = await runGit(root, ['diff', '--name-only', ref, '--', '*.md']).catch(() => '');
|
|
45
|
+
const encrypted = await vaultEncryptionEnabled();
|
|
46
|
+
const diff = await runGit(root, ['diff', '--name-only', ref, '--', encrypted ? '*.md.enc' : '*.md']).catch(() => '');
|
|
166
47
|
await runGit(root, ['checkout', ref, '--', '.']);
|
|
48
|
+
// Restore is an explicit request to materialize this ref's Markdown, so in
|
|
49
|
+
// encrypted mode always rebuild the plaintext from the (now checked-out)
|
|
50
|
+
// ciphertext — the plaintext is derived and may be missing or stale even when
|
|
51
|
+
// the ciphertext already matched the ref.
|
|
52
|
+
if (encrypted) {
|
|
53
|
+
const rebuilt = await decryptVaultTree(root);
|
|
54
|
+
const index = await reindexAfterMarkdownChange(vaultPath, rebuilt.written > 0 ? ['(encrypted)'] : [], options.skipIndex === true);
|
|
55
|
+
return { changedMarkdown: rebuilt.written > 0 ? ['(encrypted)'] : [], index };
|
|
56
|
+
}
|
|
167
57
|
const changedMarkdown = markdownOnly(diff.split('\n').map((line) => line.trim()).filter(Boolean));
|
|
168
58
|
const index = await reindexAfterMarkdownChange(vaultPath, changedMarkdown, options.skipIndex === true);
|
|
169
|
-
void head;
|
|
170
59
|
return { changedMarkdown, index };
|
|
171
60
|
};
|
|
172
61
|
// Clone a remote vault and build its index from scratch so it is immediately
|
|
@@ -175,6 +64,12 @@ export const cloneVault = async (remote, targetPath, options = {}) => {
|
|
|
175
64
|
assertSafeRemote(remote);
|
|
176
65
|
await runGit(process.cwd(), ['clone', remote, targetPath]);
|
|
177
66
|
const root = await ensureVault(targetPath);
|
|
67
|
+
// Encrypted clone: decrypt the ciphertext mirror before indexing. Requires the
|
|
68
|
+
// vault key on this machine (BRAINLINK_VAULT_KEY or a copied key file);
|
|
69
|
+
// decryption throws a clear error if the key is missing or wrong.
|
|
70
|
+
if (await vaultEncryptionEnabled()) {
|
|
71
|
+
await decryptVaultTree(root);
|
|
72
|
+
}
|
|
178
73
|
const index = options.skipIndex === true ? undefined : await indexVault(root, { full: true });
|
|
179
74
|
return { root, index };
|
|
180
75
|
};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { getBrokenLinksReport, getExtendedStats, getOrphansReport, getStats, validateVault } from '../../application/analyze-vault.js';
|
|
2
|
-
import { buildContextPackage, readContextDataSignature } from '../../application/build-context.js';
|
|
2
|
+
import { buildContextPackage, readContextDataSignature, readVolatileSignature } from '../../application/build-context.js';
|
|
3
3
|
import { getGraph } from '../../application/get-graph.js';
|
|
4
4
|
import { listAgents } from '../../application/list-agents.js';
|
|
5
5
|
import { listBacklinks, listLinks } from '../../application/list-links.js';
|
|
@@ -82,24 +82,28 @@ export const registerReadCommands = (program) => {
|
|
|
82
82
|
const resolved = await resolveOptions(options);
|
|
83
83
|
const mode = sanitizeSearchMode(options.mode, resolved.defaults.defaultSearchMode);
|
|
84
84
|
const strategy = sanitizeContextStrategy(options.strategy, resolved.defaults.defaultContextStrategy);
|
|
85
|
-
const contextPackage = await buildContextPackage(resolved.vault, query, parsePositiveInteger(options.limit ?? String(resolved.defaults.defaultSearchLimit), resolved.defaults.defaultSearchLimit), parsePositiveInteger(options.tokens ?? String(resolved.defaults.defaultContextTokens), resolved.defaults.defaultContextTokens), resolved.agent, mode, strategy, resolved.defaults.defaultContextCacheTtlMs);
|
|
85
|
+
const contextPackage = await buildContextPackage(resolved.vault, query, parsePositiveInteger(options.limit ?? String(resolved.defaults.defaultSearchLimit), resolved.defaults.defaultSearchLimit), parsePositiveInteger(options.tokens ?? String(resolved.defaults.defaultContextTokens), resolved.defaults.defaultContextTokens), resolved.agent, mode, strategy, resolved.defaults.defaultContextCacheTtlMs, resolved.defaults.cagPackTtlMs);
|
|
86
86
|
print(options.json, contextPackage, () => contextPackage.content);
|
|
87
87
|
});
|
|
88
88
|
program
|
|
89
89
|
.command('context-packs')
|
|
90
90
|
.option('-v, --vault <vault>', 'vault directory')
|
|
91
91
|
.option('-a, --agent <agent>', 'accepted for consistency; context packs are already keyed by agent')
|
|
92
|
-
.option('--stale', 'operate only on packs
|
|
92
|
+
.option('--stale', 'operate only on packs whose cited documents (or volatile memory) have changed')
|
|
93
93
|
.option('--clear', 'remove context packs instead of listing them')
|
|
94
94
|
.option('--json', 'print machine-readable JSON')
|
|
95
95
|
.description('list or clear persisted CAG context packs')
|
|
96
96
|
.action(async (options) => {
|
|
97
97
|
const resolved = await resolveOptions(options);
|
|
98
98
|
const dataSignature = await readContextDataSignature(resolved.vault);
|
|
99
|
+
const freshness = {
|
|
100
|
+
volatileSignature: await readVolatileSignature(resolved.vault),
|
|
101
|
+
ttlMs: resolved.defaults.cagPackTtlMs
|
|
102
|
+
};
|
|
99
103
|
if (options.clear) {
|
|
100
104
|
const result = await clearContextPacks(resolved.vault, {
|
|
101
105
|
staleOnly: options.stale === true,
|
|
102
|
-
|
|
106
|
+
freshness
|
|
103
107
|
});
|
|
104
108
|
print(options.json, { vault: resolved.vault, dataSignature, ...result }, () => [
|
|
105
109
|
`Removed context packs: ${result.removed.length}`,
|
|
@@ -107,7 +111,7 @@ export const registerReadCommands = (program) => {
|
|
|
107
111
|
].join('\n'));
|
|
108
112
|
return;
|
|
109
113
|
}
|
|
110
|
-
const packs = await listContextPacks(resolved.vault,
|
|
114
|
+
const packs = await listContextPacks(resolved.vault, freshness);
|
|
111
115
|
const visiblePacks = options.stale ? packs.filter((pack) => pack.stale) : packs;
|
|
112
116
|
print(options.json, { vault: resolved.vault, dataSignature, packs: visiblePacks }, () => visiblePacks.length === 0
|
|
113
117
|
? 'No context packs found.'
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { cloneVault, commitVault, initVaultGit, pullVault, pushVault, restoreVault, vaultGitStatus, vaultHistory } from '../../application/vault-git.js';
|
|
2
|
+
import { provisionVaultRepo } from '../../application/provision-vault.js';
|
|
2
3
|
import { exportVaultSnapshot, importVaultSnapshot } from '../../application/vault-snapshot.js';
|
|
3
4
|
import { print, resolveOptions } from '../runtime.js';
|
|
4
5
|
export const registerVaultSyncCommands = (program) => {
|
|
@@ -13,6 +14,27 @@ export const registerVaultSyncCommands = (program) => {
|
|
|
13
14
|
const result = await initVaultGit(vault, { remote: options.remote });
|
|
14
15
|
print(options.json, result, () => `${result.initialized ? 'Initialized' : 'Reused'} git repo at ${result.root}${options.remote ? ` (origin: ${options.remote})` : ''}.`);
|
|
15
16
|
});
|
|
17
|
+
program
|
|
18
|
+
.command('vault-provision')
|
|
19
|
+
.option('--vault <vault>', 'vault directory')
|
|
20
|
+
.option('--name <name>', 'repository name to create', 'brainlink-vault')
|
|
21
|
+
.option('--public', 'create a public repository instead of private')
|
|
22
|
+
.option('--no-auto-version', 'do not enable autoVersion after provisioning')
|
|
23
|
+
.option('--no-encrypt', 'commit plaintext Markdown instead of encrypted ciphertext')
|
|
24
|
+
.option('-m, --message <message>', 'initial commit message')
|
|
25
|
+
.option('--json', 'print machine-readable JSON')
|
|
26
|
+
.description('create a private GitHub repo for the vault via gh, push it, and enable encrypted auto-versioning')
|
|
27
|
+
.action(async (options) => {
|
|
28
|
+
const { vault } = await resolveOptions(options);
|
|
29
|
+
const result = await provisionVaultRepo(vault, {
|
|
30
|
+
name: options.name,
|
|
31
|
+
private: options.public !== true,
|
|
32
|
+
enableAutoVersion: options.autoVersion !== false,
|
|
33
|
+
encrypt: options.encrypt !== false,
|
|
34
|
+
message: options.message
|
|
35
|
+
});
|
|
36
|
+
print(options.json, result, () => `${result.created ? 'Created' : 'Reused'} ${result.repo} and pushed the vault${result.encrypted ? ' (encrypted)' : ''}${result.autoVersionEnabled ? '; autoVersion enabled' : ''}.`);
|
|
37
|
+
});
|
|
16
38
|
program
|
|
17
39
|
.command('vault-commit')
|
|
18
40
|
.requiredOption('-m, --message <message>', 'commit message')
|
|
@@ -204,7 +204,7 @@ export const registerVaultLifecycleCommands = (program) => {
|
|
|
204
204
|
const policy = await getBootstrapPolicy();
|
|
205
205
|
const bootstrapStatus = await getBootstrapSessionStatus(resolved.vault, resolved.agent);
|
|
206
206
|
const context = options.query
|
|
207
|
-
? await buildContextPackage(resolved.vault, options.query, limit, tokens, resolved.agent, mode, strategy, resolved.defaults.defaultContextCacheTtlMs)
|
|
207
|
+
? await buildContextPackage(resolved.vault, options.query, limit, tokens, resolved.agent, mode, strategy, resolved.defaults.defaultContextCacheTtlMs, resolved.defaults.cagPackTtlMs)
|
|
208
208
|
: null;
|
|
209
209
|
const agentIntegration = options.installAgent === false
|
|
210
210
|
? null
|
|
@@ -11,17 +11,21 @@ export const defaultBrainlinkConfig = {
|
|
|
11
11
|
defaultAgent: undefined,
|
|
12
12
|
autoIndexOnWrite: true,
|
|
13
13
|
autoCanonicalContextLinks: true,
|
|
14
|
+
autoVersion: false,
|
|
15
|
+
vaultEncryption: false,
|
|
14
16
|
autoUpdateCheck: true,
|
|
15
17
|
updateCheckIntervalMs: 86_400_000,
|
|
16
18
|
defaultSearchLimit: 8,
|
|
17
19
|
defaultContextTokens: 1500,
|
|
18
20
|
defaultContextStrategy: 'auto',
|
|
19
21
|
defaultContextCacheTtlMs: 120_000,
|
|
22
|
+
cagPackTtlMs: 0,
|
|
20
23
|
embeddingProvider: 'local',
|
|
21
24
|
embedding: {},
|
|
22
25
|
defaultSearchMode: 'hybrid',
|
|
23
26
|
chunkSize: 1200,
|
|
24
|
-
storageBackend: '
|
|
27
|
+
storageBackend: 'binary',
|
|
28
|
+
hybridFusion: 'rrf',
|
|
25
29
|
searchPack: {
|
|
26
30
|
rowChunkSize: 5_000,
|
|
27
31
|
compressionLevel: 5,
|
|
@@ -49,7 +53,9 @@ const embeddingProviders = new Set(['none', 'local', 'ollama', 'openai']);
|
|
|
49
53
|
const searchModes = new Set(['fts', 'semantic', 'hybrid']);
|
|
50
54
|
const contextStrategies = new Set(['rag', 'cag', 'auto']);
|
|
51
55
|
const storageBackends = new Set(['json', 'binary', 'lmdb']);
|
|
56
|
+
const hybridFusions = new Set(['rrf', 'linear']);
|
|
52
57
|
const sanitizeStorageBackend = (value) => typeof value === 'string' && storageBackends.has(value) ? value : defaultBrainlinkConfig.storageBackend;
|
|
58
|
+
const sanitizeHybridFusion = (value) => typeof value === 'string' && hybridFusions.has(value) ? value : defaultBrainlinkConfig.hybridFusion;
|
|
53
59
|
const sanitizeEmbeddingProvider = (value) => typeof value === 'string' && embeddingProviders.has(value) ? value : defaultBrainlinkConfig.embeddingProvider;
|
|
54
60
|
const sanitizeOptionalString = (value) => typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined;
|
|
55
61
|
const sanitizeEmbeddingConfig = (value) => {
|
|
@@ -207,6 +213,8 @@ const sanitizeConfig = (value) => ({
|
|
|
207
213
|
autoCanonicalContextLinks: typeof value.autoCanonicalContextLinks === 'boolean'
|
|
208
214
|
? value.autoCanonicalContextLinks
|
|
209
215
|
: defaultBrainlinkConfig.autoCanonicalContextLinks,
|
|
216
|
+
autoVersion: typeof value.autoVersion === 'boolean' ? value.autoVersion : defaultBrainlinkConfig.autoVersion,
|
|
217
|
+
vaultEncryption: typeof value.vaultEncryption === 'boolean' ? value.vaultEncryption : defaultBrainlinkConfig.vaultEncryption,
|
|
210
218
|
autoUpdateCheck: typeof value.autoUpdateCheck === 'boolean' ? value.autoUpdateCheck : defaultBrainlinkConfig.autoUpdateCheck,
|
|
211
219
|
updateCheckIntervalMs: typeof value.updateCheckIntervalMs === 'number' && Number.isFinite(value.updateCheckIntervalMs) && value.updateCheckIntervalMs > 0
|
|
212
220
|
? Math.floor(value.updateCheckIntervalMs)
|
|
@@ -221,9 +229,11 @@ const sanitizeConfig = (value) => ({
|
|
|
221
229
|
defaultContextCacheTtlMs: typeof value.defaultContextCacheTtlMs === 'number' && Number.isFinite(value.defaultContextCacheTtlMs) && value.defaultContextCacheTtlMs > 0
|
|
222
230
|
? Math.floor(value.defaultContextCacheTtlMs)
|
|
223
231
|
: defaultBrainlinkConfig.defaultContextCacheTtlMs,
|
|
232
|
+
cagPackTtlMs: sanitizeIntegerInRange(value.cagPackTtlMs, defaultBrainlinkConfig.cagPackTtlMs, 0, 2_147_483_647),
|
|
224
233
|
allowedVaults: [...sanitizeAllowedVaults(value.allowedVaults), ...readAllowedVaultsFromEnv()],
|
|
225
234
|
chunkSize: typeof value.chunkSize === 'number' && value.chunkSize > 0 ? value.chunkSize : defaultBrainlinkConfig.chunkSize,
|
|
226
235
|
storageBackend: sanitizeStorageBackend(value.storageBackend),
|
|
236
|
+
hybridFusion: sanitizeHybridFusion(value.hybridFusion),
|
|
227
237
|
searchPack: sanitizeSearchPackConfig(value.searchPack),
|
|
228
238
|
embeddingProvider: sanitizeEmbeddingProvider(value.embeddingProvider),
|
|
229
239
|
embedding: sanitizeEmbeddingConfig(value.embedding),
|
|
@@ -238,7 +248,8 @@ export const resolveAgentRuntimeDefaults = (config, agent) => {
|
|
|
238
248
|
defaultContextTokens: profile?.defaultContextTokens ?? config.defaultContextTokens,
|
|
239
249
|
defaultSearchMode: profile?.defaultSearchMode ?? config.defaultSearchMode,
|
|
240
250
|
defaultContextStrategy: profile?.defaultContextStrategy ?? config.defaultContextStrategy,
|
|
241
|
-
defaultContextCacheTtlMs: profile?.defaultContextCacheTtlMs ?? config.defaultContextCacheTtlMs
|
|
251
|
+
defaultContextCacheTtlMs: profile?.defaultContextCacheTtlMs ?? config.defaultContextCacheTtlMs,
|
|
252
|
+
cagPackTtlMs: config.cagPackTtlMs
|
|
242
253
|
};
|
|
243
254
|
};
|
|
244
255
|
const mergeConfigLayers = (layers) => layers.reduce((state, config) => ({
|