@andespindola/brainlink 1.0.6 → 1.1.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 +22 -0
- package/README.md +73 -0
- package/dist/application/find-similar-notes.js +75 -0
- package/dist/application/get-graph-node.js +2 -2
- package/dist/application/get-graph-summary.js +2 -2
- package/dist/application/get-graph.js +2 -2
- package/dist/application/index-vault-phases.js +5 -4
- package/dist/application/index-vault.js +4 -4
- package/dist/application/list-agents.js +2 -2
- package/dist/application/list-links.js +3 -3
- package/dist/application/memory-suggestions.js +4 -1
- package/dist/application/ports/knowledge-store.js +1 -0
- package/dist/application/ranking/run-search.js +177 -0
- package/dist/application/search-graph-node-ids.js +3 -2
- package/dist/application/search-knowledge.js +22 -5
- package/dist/application/vault-git.js +180 -0
- package/dist/application/vault-snapshot.js +87 -0
- package/dist/cli/commands/vault-sync-commands.js +115 -0
- package/dist/cli/main.js +2 -0
- package/dist/domain/context.js +27 -14
- package/dist/domain/diversity.js +64 -0
- package/dist/domain/embeddings.js +117 -1
- package/dist/domain/markdown.js +10 -1
- package/dist/domain/scoring.js +42 -0
- package/dist/domain/tokens.js +17 -1
- package/dist/infrastructure/config.js +31 -1
- package/dist/infrastructure/file-index.js +99 -328
- package/dist/infrastructure/knowledge-store/binary-store.js +163 -0
- package/dist/infrastructure/knowledge-store/index.js +36 -0
- package/dist/infrastructure/knowledge-store/stored-index.js +216 -0
- package/dist/mcp/server.js +16 -1
- package/dist/mcp/tools/read-tools.js +33 -0
- package/dist/mcp/tools/write-tools.js +106 -0
- package/dist/mcp/tools.js +2 -2
- package/docs/AGENT_USAGE.md +4 -1
- package/docs/ARCHITECTURE.md +4 -3
- package/package.json +3 -2
|
@@ -0,0 +1,180 @@
|
|
|
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';
|
|
13
|
+
import { indexVault } from './index-vault.js';
|
|
14
|
+
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
|
+
};
|
|
133
|
+
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
|
+
// Pull from origin and reindex when Markdown changed, so the vault is usable
|
|
140
|
+
// immediately. On a merge conflict the merge is aborted and a clear error is
|
|
141
|
+
// raised rather than leaving conflict markers in the Markdown.
|
|
142
|
+
export const pullVault = async (vaultPath, options = {}) => {
|
|
143
|
+
const root = await ensureVault(vaultPath);
|
|
144
|
+
const before = (await runGit(root, ['rev-parse', 'HEAD']).catch(() => '')).trim();
|
|
145
|
+
try {
|
|
146
|
+
await runGit(root, ['pull', '--no-rebase', '--no-edit', 'origin']);
|
|
147
|
+
}
|
|
148
|
+
catch (error) {
|
|
149
|
+
await runGit(root, ['merge', '--abort']).catch(() => undefined);
|
|
150
|
+
throw error instanceof GitCommandError
|
|
151
|
+
? new GitCommandError(['pull'], 'merge conflict; aborted. Resolve manually, then commit.')
|
|
152
|
+
: error;
|
|
153
|
+
}
|
|
154
|
+
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
|
+
: '';
|
|
158
|
+
const changedMarkdown = markdownOnly(diff.split('\n').map((line) => line.trim()).filter(Boolean));
|
|
159
|
+
const index = await reindexAfterMarkdownChange(vaultPath, changedMarkdown, options.skipIndex === true);
|
|
160
|
+
return { changedMarkdown, index };
|
|
161
|
+
};
|
|
162
|
+
export const restoreVault = async (vaultPath, ref, options = {}) => {
|
|
163
|
+
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(() => '');
|
|
166
|
+
await runGit(root, ['checkout', ref, '--', '.']);
|
|
167
|
+
const changedMarkdown = markdownOnly(diff.split('\n').map((line) => line.trim()).filter(Boolean));
|
|
168
|
+
const index = await reindexAfterMarkdownChange(vaultPath, changedMarkdown, options.skipIndex === true);
|
|
169
|
+
void head;
|
|
170
|
+
return { changedMarkdown, index };
|
|
171
|
+
};
|
|
172
|
+
// Clone a remote vault and build its index from scratch so it is immediately
|
|
173
|
+
// searchable. The clone target must not already exist.
|
|
174
|
+
export const cloneVault = async (remote, targetPath, options = {}) => {
|
|
175
|
+
assertSafeRemote(remote);
|
|
176
|
+
await runGit(process.cwd(), ['clone', remote, targetPath]);
|
|
177
|
+
const root = await ensureVault(targetPath);
|
|
178
|
+
const index = options.skipIndex === true ? undefined : await indexVault(root, { full: true });
|
|
179
|
+
return { root, index };
|
|
180
|
+
};
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Portable vault snapshot (`.blvault`): a single gzip-compressed envelope of the
|
|
2
|
+
// canonical Markdown (with per-file SHA-256), self-contained and movable to any
|
|
3
|
+
// machine. The derived index is never included — import rebuilds it so the vault
|
|
4
|
+
// is immediately usable. Conflicts on import are preserved as `*.conflict-<ts>.md`
|
|
5
|
+
// rather than overwriting local edits.
|
|
6
|
+
import { createHash } from 'node:crypto';
|
|
7
|
+
import { gzipSync, gunzipSync } from 'node:zlib';
|
|
8
|
+
import { extname, relative } from 'node:path';
|
|
9
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
10
|
+
import { indexVault } from './index-vault.js';
|
|
11
|
+
import { ensureVault, listVaultFiles, writeMarkdownFile } from '../infrastructure/file-system-vault.js';
|
|
12
|
+
const snapshotVersion = 1;
|
|
13
|
+
const isMarkdown = (path) => extname(path).toLowerCase() === '.md';
|
|
14
|
+
const toPosix = (path) => path.split('\\').join('/');
|
|
15
|
+
const sha256 = (data) => createHash('sha256').update(data).digest('hex');
|
|
16
|
+
const conflictName = (path) => {
|
|
17
|
+
const stamp = new Date().toISOString().replace(/[-:]/g, '').replace(/\..+$/, 'Z');
|
|
18
|
+
const extension = extname(path);
|
|
19
|
+
const base = extension ? path.slice(0, -extension.length) : path;
|
|
20
|
+
return `${base}.conflict-${stamp}${extension}`;
|
|
21
|
+
};
|
|
22
|
+
export const exportVaultSnapshot = async (vaultPath, outputFile) => {
|
|
23
|
+
const root = await ensureVault(vaultPath);
|
|
24
|
+
const markdownFiles = (await listVaultFiles(root)).filter(isMarkdown);
|
|
25
|
+
const entries = await Promise.all(markdownFiles.map(async (absolutePath) => {
|
|
26
|
+
const content = await readFile(absolutePath);
|
|
27
|
+
const path = toPosix(relative(root, absolutePath));
|
|
28
|
+
return {
|
|
29
|
+
manifest: { path, sha256: sha256(content), bytes: content.byteLength },
|
|
30
|
+
file: { path, contentB64: content.toString('base64') }
|
|
31
|
+
};
|
|
32
|
+
}));
|
|
33
|
+
const envelope = {
|
|
34
|
+
version: snapshotVersion,
|
|
35
|
+
createdAt: new Date().toISOString(),
|
|
36
|
+
manifest: entries.map((entry) => entry.manifest),
|
|
37
|
+
files: entries.map((entry) => entry.file)
|
|
38
|
+
};
|
|
39
|
+
const packed = gzipSync(Buffer.from(JSON.stringify(envelope), 'utf8'), { level: 9 });
|
|
40
|
+
await writeFile(outputFile, packed, { mode: 0o600 });
|
|
41
|
+
return { file: outputFile, fileCount: entries.length, bytes: packed.byteLength };
|
|
42
|
+
};
|
|
43
|
+
const parseEnvelope = (raw) => {
|
|
44
|
+
const envelope = JSON.parse(gunzipSync(raw).toString('utf8'));
|
|
45
|
+
if (!envelope || envelope.version !== snapshotVersion || !Array.isArray(envelope.files)) {
|
|
46
|
+
throw new Error('Unrecognized .blvault snapshot format.');
|
|
47
|
+
}
|
|
48
|
+
return envelope;
|
|
49
|
+
};
|
|
50
|
+
export const importVaultSnapshot = async (snapshotFile, vaultPath, options = {}) => {
|
|
51
|
+
const root = await ensureVault(vaultPath);
|
|
52
|
+
const envelope = parseEnvelope(await readFile(snapshotFile));
|
|
53
|
+
const manifestByPath = new Map(envelope.manifest.map((entry) => [entry.path, entry]));
|
|
54
|
+
let imported = 0;
|
|
55
|
+
let conflicted = 0;
|
|
56
|
+
for (const file of envelope.files) {
|
|
57
|
+
if (!isMarkdown(file.path)) {
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
const content = Buffer.from(file.contentB64, 'base64');
|
|
61
|
+
const expected = manifestByPath.get(file.path)?.sha256;
|
|
62
|
+
if (expected && sha256(content) !== expected) {
|
|
63
|
+
throw new Error(`Checksum mismatch for ${file.path}; snapshot is corrupt.`);
|
|
64
|
+
}
|
|
65
|
+
// Preserve local edits: write divergent incoming files under a conflict name
|
|
66
|
+
// instead of overwriting. writeMarkdownFile guards against path traversal.
|
|
67
|
+
let existing = null;
|
|
68
|
+
try {
|
|
69
|
+
existing = await readFile(`${root}/${file.path}`);
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
existing = null;
|
|
73
|
+
}
|
|
74
|
+
if (existing && !existing.equals(content)) {
|
|
75
|
+
await writeMarkdownFile(root, conflictName(file.path), content.toString('utf8'));
|
|
76
|
+
conflicted += 1;
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (existing && existing.equals(content)) {
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
await writeMarkdownFile(root, file.path, content.toString('utf8'));
|
|
83
|
+
imported += 1;
|
|
84
|
+
}
|
|
85
|
+
const index = !options.skipIndex && imported + conflicted > 0 ? await indexVault(root) : undefined;
|
|
86
|
+
return { imported, conflicted, index };
|
|
87
|
+
};
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { cloneVault, commitVault, initVaultGit, pullVault, pushVault, restoreVault, vaultGitStatus, vaultHistory } from '../../application/vault-git.js';
|
|
2
|
+
import { exportVaultSnapshot, importVaultSnapshot } from '../../application/vault-snapshot.js';
|
|
3
|
+
import { print, resolveOptions } from '../runtime.js';
|
|
4
|
+
export const registerVaultSyncCommands = (program) => {
|
|
5
|
+
program
|
|
6
|
+
.command('vault-init-git')
|
|
7
|
+
.option('--vault <vault>', 'vault directory')
|
|
8
|
+
.option('--remote <url>', 'git remote URL (SSH or credential-helper backed; inline tokens are rejected)')
|
|
9
|
+
.option('--json', 'print machine-readable JSON')
|
|
10
|
+
.description('initialize git versioning for the vault (ignores the derived .brainlink/)')
|
|
11
|
+
.action(async (options) => {
|
|
12
|
+
const { vault } = await resolveOptions(options);
|
|
13
|
+
const result = await initVaultGit(vault, { remote: options.remote });
|
|
14
|
+
print(options.json, result, () => `${result.initialized ? 'Initialized' : 'Reused'} git repo at ${result.root}${options.remote ? ` (origin: ${options.remote})` : ''}.`);
|
|
15
|
+
});
|
|
16
|
+
program
|
|
17
|
+
.command('vault-commit')
|
|
18
|
+
.requiredOption('-m, --message <message>', 'commit message')
|
|
19
|
+
.option('--vault <vault>', 'vault directory')
|
|
20
|
+
.option('--json', 'print machine-readable JSON')
|
|
21
|
+
.description('commit the vault Markdown')
|
|
22
|
+
.action(async (options) => {
|
|
23
|
+
const { vault } = await resolveOptions(options);
|
|
24
|
+
const hash = await commitVault(vault, options.message ?? 'Update vault');
|
|
25
|
+
print(options.json, { committed: hash != null, hash }, () => hash ? `Committed ${hash.slice(0, 10)}.` : 'Nothing to commit.');
|
|
26
|
+
});
|
|
27
|
+
program
|
|
28
|
+
.command('vault-status')
|
|
29
|
+
.option('--vault <vault>', 'vault directory')
|
|
30
|
+
.option('--json', 'print machine-readable JSON')
|
|
31
|
+
.description('show git status of the vault Markdown')
|
|
32
|
+
.action(async (options) => {
|
|
33
|
+
const { vault } = await resolveOptions(options);
|
|
34
|
+
const status = await vaultGitStatus(vault);
|
|
35
|
+
print(options.json, status, () => `Branch ${status.branch}; ${status.dirty ? `${status.changedMarkdown.length} changed markdown file(s)` : 'clean'}.`);
|
|
36
|
+
});
|
|
37
|
+
program
|
|
38
|
+
.command('vault-history')
|
|
39
|
+
.option('--vault <vault>', 'vault directory')
|
|
40
|
+
.option('--limit <n>', 'number of entries', '20')
|
|
41
|
+
.option('--json', 'print machine-readable JSON')
|
|
42
|
+
.description('show vault commit history')
|
|
43
|
+
.action(async (options) => {
|
|
44
|
+
const { vault } = await resolveOptions(options);
|
|
45
|
+
const entries = await vaultHistory(vault, Number.parseInt(options.limit ?? '20', 10));
|
|
46
|
+
print(options.json, entries, () => entries.map((entry) => `${entry.hash.slice(0, 10)} ${entry.date} ${entry.subject}`).join('\n') || 'No history.');
|
|
47
|
+
});
|
|
48
|
+
program
|
|
49
|
+
.command('vault-push')
|
|
50
|
+
.option('--vault <vault>', 'vault directory')
|
|
51
|
+
.option('--json', 'print machine-readable JSON')
|
|
52
|
+
.description('push the vault to its git remote')
|
|
53
|
+
.action(async (options) => {
|
|
54
|
+
const { vault } = await resolveOptions(options);
|
|
55
|
+
await pushVault(vault);
|
|
56
|
+
print(options.json, { pushed: true }, () => 'Pushed to origin.');
|
|
57
|
+
});
|
|
58
|
+
program
|
|
59
|
+
.command('vault-pull')
|
|
60
|
+
.option('--vault <vault>', 'vault directory')
|
|
61
|
+
.option('--no-index', 'skip reindexing after pull')
|
|
62
|
+
.option('--json', 'print machine-readable JSON')
|
|
63
|
+
.description('pull the vault from git and reindex changed notes')
|
|
64
|
+
.action(async (options) => {
|
|
65
|
+
const { vault } = await resolveOptions(options);
|
|
66
|
+
const result = await pullVault(vault, { skipIndex: options.index === false });
|
|
67
|
+
print(options.json, result, () => `Pulled. ${result.changedMarkdown.length} markdown file(s) changed${result.index ? `; reindexed ${result.index.documentCount} docs` : ''}.`);
|
|
68
|
+
});
|
|
69
|
+
program
|
|
70
|
+
.command('vault-restore')
|
|
71
|
+
.argument('<ref>', 'git ref (commit, tag, branch) to restore Markdown from')
|
|
72
|
+
.option('--vault <vault>', 'vault directory')
|
|
73
|
+
.option('--no-index', 'skip reindexing after restore')
|
|
74
|
+
.option('--json', 'print machine-readable JSON')
|
|
75
|
+
.description('restore vault Markdown from a git ref and reindex')
|
|
76
|
+
.action(async (ref, options) => {
|
|
77
|
+
const { vault } = await resolveOptions(options);
|
|
78
|
+
const result = await restoreVault(vault, ref, { skipIndex: options.index === false });
|
|
79
|
+
print(options.json, result, () => `Restored from ${ref}. ${result.changedMarkdown.length} markdown file(s) changed${result.index ? `; reindexed ${result.index.documentCount} docs` : ''}.`);
|
|
80
|
+
});
|
|
81
|
+
program
|
|
82
|
+
.command('vault-clone')
|
|
83
|
+
.argument('<remote>', 'git remote URL')
|
|
84
|
+
.argument('<dir>', 'target directory')
|
|
85
|
+
.option('--no-index', 'skip building the index after clone')
|
|
86
|
+
.option('--json', 'print machine-readable JSON')
|
|
87
|
+
.description('clone a remote vault and build its index')
|
|
88
|
+
.action(async (remote, dir, options) => {
|
|
89
|
+
const result = await cloneVault(remote, dir, { skipIndex: options.index === false });
|
|
90
|
+
print(options.json, result, () => `Cloned to ${result.root}${result.index ? `; indexed ${result.index.documentCount} docs` : ''}.`);
|
|
91
|
+
});
|
|
92
|
+
program
|
|
93
|
+
.command('vault-export')
|
|
94
|
+
.requiredOption('-o, --output <file>', 'output .blvault file')
|
|
95
|
+
.option('--vault <vault>', 'vault directory')
|
|
96
|
+
.option('--json', 'print machine-readable JSON')
|
|
97
|
+
.description('export the vault Markdown to a portable .blvault snapshot')
|
|
98
|
+
.action(async (options) => {
|
|
99
|
+
const { vault } = await resolveOptions(options);
|
|
100
|
+
const result = await exportVaultSnapshot(vault, options.output ?? 'vault.blvault');
|
|
101
|
+
print(options.json, result, () => `Exported ${result.fileCount} files to ${result.file} (${result.bytes} bytes).`);
|
|
102
|
+
});
|
|
103
|
+
program
|
|
104
|
+
.command('vault-import')
|
|
105
|
+
.argument('<file>', 'input .blvault file')
|
|
106
|
+
.option('--vault <vault>', 'vault directory')
|
|
107
|
+
.option('--no-index', 'skip reindexing after import')
|
|
108
|
+
.option('--json', 'print machine-readable JSON')
|
|
109
|
+
.description('import a .blvault snapshot into the vault and reindex')
|
|
110
|
+
.action(async (file, options) => {
|
|
111
|
+
const { vault } = await resolveOptions(options);
|
|
112
|
+
const result = await importVaultSnapshot(file, vault, { skipIndex: options.index === false });
|
|
113
|
+
print(options.json, result, () => `Imported ${result.imported} files (${result.conflicted} conflicts)${result.index ? `; reindexed ${result.index.documentCount} docs` : ''}.`);
|
|
114
|
+
});
|
|
115
|
+
};
|
package/dist/cli/main.js
CHANGED
|
@@ -10,6 +10,7 @@ import { registerConfigCommands } from './commands/config-commands.js';
|
|
|
10
10
|
import { registerPracticalCommands } from './commands/practical-commands.js';
|
|
11
11
|
import { registerReadCommands } from './commands/read-commands.js';
|
|
12
12
|
import { registerVaultCommands } from './commands/vault-commands.js';
|
|
13
|
+
import { registerVaultSyncCommands } from './commands/vault-sync-commands.js';
|
|
13
14
|
import { registerWriteCommands } from './commands/write-commands.js';
|
|
14
15
|
const readPackageMetadata = () => {
|
|
15
16
|
const packagePath = join(dirname(fileURLToPath(import.meta.url)), '../../package.json');
|
|
@@ -63,6 +64,7 @@ registerReadCommands(program);
|
|
|
63
64
|
registerPracticalCommands(program);
|
|
64
65
|
registerConfigCommands(program);
|
|
65
66
|
registerVaultCommands(program);
|
|
67
|
+
registerVaultSyncCommands(program);
|
|
66
68
|
registerAgentCommands(program);
|
|
67
69
|
await maybePrintUpdateNotice(packageMetadata);
|
|
68
70
|
program.parseAsync().catch((error) => {
|
package/dist/domain/context.js
CHANGED
|
@@ -1,21 +1,32 @@
|
|
|
1
1
|
import { middleOutIndices } from './middle-out.js';
|
|
2
|
+
import { estimateTokenCount } from './tokens.js';
|
|
3
|
+
import { orderByMaximalMarginalRelevance } from './diversity.js';
|
|
4
|
+
const contentTokenPattern = /[\p{L}\p{N}_-]+/gu;
|
|
5
|
+
const tokenizeForDiversity = (text) => new Set((text.toLowerCase().match(contentTokenPattern) ?? []).filter((token) => token.length > 1));
|
|
2
6
|
const maxSectionsPerDocument = 3;
|
|
3
|
-
|
|
7
|
+
// Character budget used only for truncating an overflowing chunk back down to a
|
|
8
|
+
// token target; the conservative per-token character ratio keeps the rendered
|
|
9
|
+
// slice within budget even though estimateTokenCount is the source of truth for
|
|
10
|
+
// counting.
|
|
11
|
+
const truncationCharsPerToken = 4;
|
|
4
12
|
const sectionSeparatorTokens = 1;
|
|
5
13
|
const headerReserveTokens = 8;
|
|
6
|
-
const estimateTokens = (text) => Math.ceil(text.length / charsPerToken);
|
|
7
14
|
const truncateToTokens = (text, maxTokens) => {
|
|
8
|
-
const maxChars = Math.max(0, maxTokens) *
|
|
15
|
+
const maxChars = Math.max(0, maxTokens) * truncationCharsPerToken;
|
|
9
16
|
return text.length <= maxChars ? text : text.slice(0, maxChars);
|
|
10
17
|
};
|
|
18
|
+
// Prefer the token count cached on the chunk at index time; fall back to a live
|
|
19
|
+
// estimate when the result did not carry one (legacy indexes, pack fallback).
|
|
20
|
+
const contentTokens = (result) => result.tokenCount ?? estimateTokenCount(result.content);
|
|
11
21
|
// Mirror the per-section framing that formatContextPackage renders (heading,
|
|
12
22
|
// Source/Tags/Score/Mode lines and the trailing separator) so the budget
|
|
13
23
|
// reflects the package the agent actually receives, not just chunk content.
|
|
14
|
-
const
|
|
24
|
+
const estimateFramingTokens = (result) => {
|
|
15
25
|
const tagsLine = result.tags.length > 0 ? `Tags: ${result.tags.map((tag) => `#${tag}`).join(' ')}\n` : '';
|
|
16
26
|
const framing = `## . ${result.title}\nSource: ${result.path}\n${tagsLine}Score: 0.000\nMode: ${result.searchMode}\n\n`;
|
|
17
|
-
return
|
|
27
|
+
return estimateTokenCount(framing) + sectionSeparatorTokens;
|
|
18
28
|
};
|
|
29
|
+
const estimateSectionTokens = (result) => contentTokens(result) + estimateFramingTokens(result);
|
|
19
30
|
const byScore = (left, right) => right.score - left.score || left.title.localeCompare(right.title);
|
|
20
31
|
const byOrdinal = (left, right) => (left.chunkOrdinal ?? Number.MAX_SAFE_INTEGER) - (right.chunkOrdinal ?? Number.MAX_SAFE_INTEGER);
|
|
21
32
|
const middleOutDocumentResults = (results) => {
|
|
@@ -36,14 +47,16 @@ export const selectContextSections = (results, maxTokens) => {
|
|
|
36
47
|
state.set(result.documentId, [...current, result]);
|
|
37
48
|
return state;
|
|
38
49
|
}, new Map());
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
.
|
|
46
|
-
.map((
|
|
50
|
+
// Order documents by maximal marginal relevance so the budget is not spent on
|
|
51
|
+
// several near-duplicate notes: each pick trades its own relevance against how
|
|
52
|
+
// similar it is to documents already chosen. The strongest document still
|
|
53
|
+
// leads (no redundancy penalty on an empty selection).
|
|
54
|
+
const documentCandidates = Array.from(grouped.entries()).map(([documentId, group]) => ({
|
|
55
|
+
id: documentId,
|
|
56
|
+
relevance: group.reduce((max, result) => Math.max(max, result.score), Number.NEGATIVE_INFINITY),
|
|
57
|
+
tokens: tokenizeForDiversity(group.map((result) => result.content).join(' '))
|
|
58
|
+
}));
|
|
59
|
+
const documentOrder = orderByMaximalMarginalRelevance(documentCandidates);
|
|
47
60
|
const selected = documentOrder.reduce((state, documentId) => {
|
|
48
61
|
const ordered = middleOutDocumentResults(grouped.get(documentId) ?? []);
|
|
49
62
|
let usedTokens = state.usedTokens;
|
|
@@ -91,7 +104,7 @@ export const selectContextSections = (results, maxTokens) => {
|
|
|
91
104
|
// brainlink_context consistent with brainlink_search instead of reporting
|
|
92
105
|
// "No relevant context found." whenever the strongest chunk is large.
|
|
93
106
|
const topResult = [...results].sort(byScore)[0];
|
|
94
|
-
const framingTokens =
|
|
107
|
+
const framingTokens = estimateFramingTokens(topResult);
|
|
95
108
|
const contentTokenBudget = maxTokens - headerReserveTokens - framingTokens;
|
|
96
109
|
return [
|
|
97
110
|
{
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// Maximal Marginal Relevance ordering. Greedily orders candidates so each pick
|
|
2
|
+
// balances its own relevance against how redundant it is versus everything
|
|
3
|
+
// already chosen, preventing a context window from filling with near-duplicate
|
|
4
|
+
// material. Pure and deterministic: ties resolve by relevance then id.
|
|
5
|
+
const jaccardSimilarity = (left, right) => {
|
|
6
|
+
if (left.size === 0 || right.size === 0) {
|
|
7
|
+
return 0;
|
|
8
|
+
}
|
|
9
|
+
let intersection = 0;
|
|
10
|
+
const [small, large] = left.size <= right.size ? [left, right] : [right, left];
|
|
11
|
+
for (const token of small) {
|
|
12
|
+
if (large.has(token)) {
|
|
13
|
+
intersection += 1;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
const union = left.size + right.size - intersection;
|
|
17
|
+
return union === 0 ? 0 : intersection / union;
|
|
18
|
+
};
|
|
19
|
+
const normalizeRelevance = (candidates) => {
|
|
20
|
+
// Reduce instead of Math.min(...values): the candidate list can be as large as
|
|
21
|
+
// the caller's result limit, and spreading a very large array overflows the
|
|
22
|
+
// call stack.
|
|
23
|
+
const bounds = candidates.reduce((range, candidate) => ({
|
|
24
|
+
min: Math.min(range.min, candidate.relevance),
|
|
25
|
+
max: Math.max(range.max, candidate.relevance)
|
|
26
|
+
}), { min: Infinity, max: -Infinity });
|
|
27
|
+
const span = bounds.max - bounds.min;
|
|
28
|
+
return new Map(candidates.map((candidate) => [candidate.id, span === 0 ? 1 : (candidate.relevance - bounds.min) / span]));
|
|
29
|
+
};
|
|
30
|
+
// lambda in [0,1]: 1 ignores redundancy (pure relevance order), lower values
|
|
31
|
+
// push diverse picks higher. The default balances relevance and diversity; the
|
|
32
|
+
// strongest candidate still always leads (no redundancy penalty on an empty
|
|
33
|
+
// selection).
|
|
34
|
+
export const orderByMaximalMarginalRelevance = (candidates, lambda = 0.5) => {
|
|
35
|
+
if (candidates.length <= 1) {
|
|
36
|
+
return candidates.map((candidate) => candidate.id);
|
|
37
|
+
}
|
|
38
|
+
const relevanceById = normalizeRelevance(candidates);
|
|
39
|
+
const remaining = new Map(candidates.map((candidate) => [candidate.id, candidate]));
|
|
40
|
+
const selected = [];
|
|
41
|
+
const order = [];
|
|
42
|
+
while (remaining.size > 0) {
|
|
43
|
+
let best = null;
|
|
44
|
+
for (const candidate of remaining.values()) {
|
|
45
|
+
const relevance = relevanceById.get(candidate.id) ?? 0;
|
|
46
|
+
const redundancy = selected.reduce((max, picked) => Math.max(max, jaccardSimilarity(candidate.tokens, picked.tokens)), 0);
|
|
47
|
+
const score = lambda * relevance - (1 - lambda) * redundancy;
|
|
48
|
+
if (best === null ||
|
|
49
|
+
score > best.score ||
|
|
50
|
+
(score === best.score &&
|
|
51
|
+
(relevance > best.relevance ||
|
|
52
|
+
(relevance === best.relevance && candidate.id.localeCompare(best.candidate.id) < 0)))) {
|
|
53
|
+
best = { candidate, score, relevance };
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (best === null) {
|
|
57
|
+
break;
|
|
58
|
+
}
|
|
59
|
+
selected.push(best.candidate);
|
|
60
|
+
order.push(best.candidate.id);
|
|
61
|
+
remaining.delete(best.candidate.id);
|
|
62
|
+
}
|
|
63
|
+
return order;
|
|
64
|
+
};
|
|
@@ -141,4 +141,120 @@ export const createLocalEmbeddingProvider = () => ({
|
|
|
141
141
|
name: 'local',
|
|
142
142
|
embed: async (input) => input.map(createLocalEmbedding)
|
|
143
143
|
});
|
|
144
|
-
|
|
144
|
+
const defaultExternalTimeoutMs = 10_000;
|
|
145
|
+
const defaultExternalBatchSize = 64;
|
|
146
|
+
const ollamaDefaultUrl = 'http://127.0.0.1:11434';
|
|
147
|
+
const ollamaDefaultModel = 'nomic-embed-text';
|
|
148
|
+
const openAiDefaultUrl = 'https://api.openai.com/v1';
|
|
149
|
+
const openAiDefaultModel = 'text-embedding-3-small';
|
|
150
|
+
const trimTrailingSlash = (value) => value.replace(/\/+$/, '');
|
|
151
|
+
const chunkBatches = (items, size) => {
|
|
152
|
+
const batchSize = Number.isFinite(size) && size > 0 ? Math.floor(size) : defaultExternalBatchSize;
|
|
153
|
+
const batches = [];
|
|
154
|
+
for (let index = 0; index < items.length; index += batchSize) {
|
|
155
|
+
batches.push(items.slice(index, index + batchSize));
|
|
156
|
+
}
|
|
157
|
+
return batches;
|
|
158
|
+
};
|
|
159
|
+
const isNumberArray = (value) => Array.isArray(value) && value.every((entry) => typeof entry === 'number' && Number.isFinite(entry));
|
|
160
|
+
const fetchJson = async (url, init, timeoutMs, providerName) => {
|
|
161
|
+
const controller = new AbortController();
|
|
162
|
+
const timeout = setTimeout(() => controller.abort(), Math.max(1, timeoutMs));
|
|
163
|
+
try {
|
|
164
|
+
const response = await fetch(url, { ...init, signal: controller.signal });
|
|
165
|
+
if (!response.ok) {
|
|
166
|
+
throw new Error(`${providerName} embedding request failed with HTTP ${response.status}`);
|
|
167
|
+
}
|
|
168
|
+
return (await response.json());
|
|
169
|
+
}
|
|
170
|
+
catch (error) {
|
|
171
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
172
|
+
throw new Error(`${providerName} embedding request to ${url} failed: ${reason}`);
|
|
173
|
+
}
|
|
174
|
+
finally {
|
|
175
|
+
clearTimeout(timeout);
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
// External providers return vectors in their own space; L2-normalize so dot
|
|
179
|
+
// product equals cosine similarity, matching the contract local embeddings rely
|
|
180
|
+
// on across the retrieval and bucket-prefilter paths.
|
|
181
|
+
const createOllamaEmbeddingProvider = (config) => {
|
|
182
|
+
const baseUrl = trimTrailingSlash(config.url?.trim() || ollamaDefaultUrl);
|
|
183
|
+
const model = config.model?.trim() || ollamaDefaultModel;
|
|
184
|
+
const timeoutMs = config.timeoutMs ?? defaultExternalTimeoutMs;
|
|
185
|
+
return {
|
|
186
|
+
name: 'ollama',
|
|
187
|
+
embed: async (input) => {
|
|
188
|
+
const vectors = [];
|
|
189
|
+
for (const batch of chunkBatches(input, config.batchSize ?? defaultExternalBatchSize)) {
|
|
190
|
+
const payload = await fetchJson(`${baseUrl}/api/embed`, {
|
|
191
|
+
method: 'POST',
|
|
192
|
+
headers: { 'content-type': 'application/json' },
|
|
193
|
+
body: JSON.stringify({ model, input: batch })
|
|
194
|
+
}, timeoutMs, 'ollama');
|
|
195
|
+
const embeddings = payload.embeddings;
|
|
196
|
+
if (!Array.isArray(embeddings) || embeddings.length !== batch.length) {
|
|
197
|
+
throw new Error('ollama embedding response shape was invalid');
|
|
198
|
+
}
|
|
199
|
+
for (const embedding of embeddings) {
|
|
200
|
+
if (!isNumberArray(embedding) || embedding.length === 0) {
|
|
201
|
+
throw new Error('ollama embedding response contained an invalid vector');
|
|
202
|
+
}
|
|
203
|
+
vectors.push(normalizeVector(embedding));
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return vectors;
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
};
|
|
210
|
+
const createOpenAiEmbeddingProvider = (config) => {
|
|
211
|
+
const baseUrl = trimTrailingSlash(config.url?.trim() || openAiDefaultUrl);
|
|
212
|
+
const model = config.model?.trim() || openAiDefaultModel;
|
|
213
|
+
const timeoutMs = config.timeoutMs ?? defaultExternalTimeoutMs;
|
|
214
|
+
const apiKeyEnv = config.apiKeyEnv?.trim() || 'OPENAI_API_KEY';
|
|
215
|
+
return {
|
|
216
|
+
name: 'openai',
|
|
217
|
+
embed: async (input) => {
|
|
218
|
+
const apiKey = process.env[apiKeyEnv]?.trim();
|
|
219
|
+
if (!apiKey) {
|
|
220
|
+
throw new Error(`openai embedding requires the ${apiKeyEnv} environment variable to be set`);
|
|
221
|
+
}
|
|
222
|
+
const vectors = [];
|
|
223
|
+
for (const batch of chunkBatches(input, config.batchSize ?? defaultExternalBatchSize)) {
|
|
224
|
+
const payload = await fetchJson(`${baseUrl}/embeddings`, {
|
|
225
|
+
method: 'POST',
|
|
226
|
+
headers: {
|
|
227
|
+
'content-type': 'application/json',
|
|
228
|
+
authorization: `Bearer ${apiKey}`
|
|
229
|
+
},
|
|
230
|
+
body: JSON.stringify({ model, input: batch })
|
|
231
|
+
}, timeoutMs, 'openai');
|
|
232
|
+
const data = payload.data;
|
|
233
|
+
if (!Array.isArray(data) || data.length !== batch.length) {
|
|
234
|
+
throw new Error('openai embedding response shape was invalid');
|
|
235
|
+
}
|
|
236
|
+
const ordered = [...data].sort((left, right) => (left.index ?? 0) - (right.index ?? 0));
|
|
237
|
+
for (const entry of ordered) {
|
|
238
|
+
const embedding = entry.embedding;
|
|
239
|
+
if (!isNumberArray(embedding) || embedding.length === 0) {
|
|
240
|
+
throw new Error('openai embedding response contained an invalid vector');
|
|
241
|
+
}
|
|
242
|
+
vectors.push(normalizeVector(embedding));
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return vectors;
|
|
246
|
+
}
|
|
247
|
+
};
|
|
248
|
+
};
|
|
249
|
+
export const createEmbeddingProvider = (name, config = {}) => {
|
|
250
|
+
switch (name) {
|
|
251
|
+
case 'local':
|
|
252
|
+
return createLocalEmbeddingProvider();
|
|
253
|
+
case 'ollama':
|
|
254
|
+
return createOllamaEmbeddingProvider(config);
|
|
255
|
+
case 'openai':
|
|
256
|
+
return createOpenAiEmbeddingProvider(config);
|
|
257
|
+
default:
|
|
258
|
+
return createDisabledEmbeddingProvider();
|
|
259
|
+
}
|
|
260
|
+
};
|