@andespindola/brainlink 1.1.0 → 1.3.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.
Files changed (33) hide show
  1. package/CHANGELOG.md +15 -1
  2. package/README.md +31 -5
  3. package/dist/application/analyze-vault.js +1 -1
  4. package/dist/application/build-context.js +14 -7
  5. package/dist/application/index-vault-phases.js +1 -0
  6. package/dist/application/index-vault.js +14 -0
  7. package/dist/application/provision-vault.js +74 -0
  8. package/dist/application/ranking/run-search.js +38 -3
  9. package/dist/application/search-knowledge.js +7 -16
  10. package/dist/application/server/routes.js +6 -1
  11. package/dist/application/vault-encryption.js +64 -0
  12. package/dist/application/vault-git-core.js +168 -0
  13. package/dist/application/vault-git.js +36 -141
  14. package/dist/cli/commands/read-commands.js +9 -5
  15. package/dist/cli/commands/vault-sync-commands.js +22 -0
  16. package/dist/cli/commands/write/vault-lifecycle-commands.js +1 -1
  17. package/dist/infrastructure/config.js +13 -2
  18. package/dist/infrastructure/context-packs.js +57 -18
  19. package/dist/infrastructure/file-index.js +2 -2
  20. package/dist/infrastructure/index-signature.js +27 -0
  21. package/dist/infrastructure/index-state.js +6 -0
  22. package/dist/infrastructure/knowledge-store/binary-store.js +173 -3
  23. package/dist/infrastructure/knowledge-store/index.js +3 -2
  24. package/dist/infrastructure/knowledge-store/stored-index.js +2 -2
  25. package/dist/infrastructure/vault-crypto.js +78 -0
  26. package/dist/mcp/server-instructions.js +22 -0
  27. package/dist/mcp/server.js +48 -2
  28. package/dist/mcp/tools/maintenance-tools.js +2 -2
  29. package/dist/mcp/tools/read-tools.js +8 -4
  30. package/dist/mcp/tools/vault-tools.js +163 -0
  31. package/dist/mcp/tools.js +1 -0
  32. package/docs/AGENT_USAGE.md +4 -0
  33. 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
- // Git-backed versioning for the vault's canonical Markdown. The derived
2
- // `.brainlink/` directory is git-ignored; only `.md` is versioned. After any
3
- // operation that changes Markdown on disk (pull, restore), the index is rebuilt
4
- // so the vault stays immediately usable ("no losing the use of the data").
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
- const execFileAsync = promisify(execFile);
16
- export class GitUnavailableError extends Error {
17
- constructor() {
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 diff = before && after && before !== after
156
- ? await runGit(root, ['diff', '--name-only', before, after, '--', '*.md'])
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 head = (await runGit(root, ['rev-parse', 'HEAD']).catch(() => '')).trim();
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 stale for the current index and volatile-memory signature')
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
- dataSignature
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, dataSignature);
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: 'json',
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) => ({