@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.
Files changed (37) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +73 -0
  3. package/dist/application/find-similar-notes.js +75 -0
  4. package/dist/application/get-graph-node.js +2 -2
  5. package/dist/application/get-graph-summary.js +2 -2
  6. package/dist/application/get-graph.js +2 -2
  7. package/dist/application/index-vault-phases.js +5 -4
  8. package/dist/application/index-vault.js +4 -4
  9. package/dist/application/list-agents.js +2 -2
  10. package/dist/application/list-links.js +3 -3
  11. package/dist/application/memory-suggestions.js +4 -1
  12. package/dist/application/ports/knowledge-store.js +1 -0
  13. package/dist/application/ranking/run-search.js +177 -0
  14. package/dist/application/search-graph-node-ids.js +3 -2
  15. package/dist/application/search-knowledge.js +22 -5
  16. package/dist/application/vault-git.js +180 -0
  17. package/dist/application/vault-snapshot.js +87 -0
  18. package/dist/cli/commands/vault-sync-commands.js +115 -0
  19. package/dist/cli/main.js +2 -0
  20. package/dist/domain/context.js +27 -14
  21. package/dist/domain/diversity.js +64 -0
  22. package/dist/domain/embeddings.js +117 -1
  23. package/dist/domain/markdown.js +10 -1
  24. package/dist/domain/scoring.js +42 -0
  25. package/dist/domain/tokens.js +17 -1
  26. package/dist/infrastructure/config.js +31 -1
  27. package/dist/infrastructure/file-index.js +99 -328
  28. package/dist/infrastructure/knowledge-store/binary-store.js +163 -0
  29. package/dist/infrastructure/knowledge-store/index.js +36 -0
  30. package/dist/infrastructure/knowledge-store/stored-index.js +216 -0
  31. package/dist/mcp/server.js +16 -1
  32. package/dist/mcp/tools/read-tools.js +33 -0
  33. package/dist/mcp/tools/write-tools.js +106 -0
  34. package/dist/mcp/tools.js +2 -2
  35. package/docs/AGENT_USAGE.md +4 -1
  36. package/docs/ARCHITECTURE.md +4 -3
  37. 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) => {
@@ -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
- const charsPerToken = 4;
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) * charsPerToken;
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 estimateSectionTokens = (result) => {
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 estimateTokens(result.content) + estimateTokens(framing) + sectionSeparatorTokens;
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
- const documentOrder = Array.from(results.reduce((state, result) => {
40
- if (!state.has(result.documentId)) {
41
- state.set(result.documentId, result.score);
42
- }
43
- return state;
44
- }, new Map()).entries())
45
- .sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0]))
46
- .map(([documentId]) => documentId);
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 = estimateSectionTokens({ ...topResult, content: '' });
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
- export const createEmbeddingProvider = (name) => name === 'local' ? createLocalEmbeddingProvider() : createDisabledEmbeddingProvider();
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
+ };