@andespindola/brainlink 1.0.5 → 1.0.7

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 (61) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +46 -0
  3. package/dist/application/add-note.js +2 -2
  4. package/dist/application/build-context.js +16 -10
  5. package/dist/application/canonical-context-links.js +44 -5
  6. package/dist/application/check-package-update.js +105 -0
  7. package/dist/application/find-similar-notes.js +75 -0
  8. package/dist/application/frontend/client/chunk-fetch.js +236 -0
  9. package/dist/application/frontend/client/controls.js +178 -0
  10. package/dist/application/frontend/client/elements.js +122 -0
  11. package/dist/application/frontend/client/input.js +202 -0
  12. package/dist/application/frontend/client/node-details.js +191 -0
  13. package/dist/application/frontend/client/rendering.js +296 -0
  14. package/dist/application/frontend/client/scope-theme.js +114 -0
  15. package/dist/application/frontend/client/spatial.js +98 -0
  16. package/dist/application/frontend/client/storage.js +215 -0
  17. package/dist/application/frontend/client/upload.js +90 -0
  18. package/dist/application/frontend/client/worker-bootstrap.js +147 -0
  19. package/dist/application/frontend/client-js.js +24 -1837
  20. package/dist/application/frontend/client-render-worker-js.js +1 -1
  21. package/dist/application/index-vault-phases.js +190 -0
  22. package/dist/application/index-vault.js +46 -167
  23. package/dist/application/memory-suggestions.js +4 -1
  24. package/dist/application/search-knowledge.js +19 -3
  25. package/dist/cli/commands/write/dedupe-commands.js +59 -0
  26. package/dist/cli/commands/write/index-commands.js +205 -0
  27. package/dist/cli/commands/write/link-commands.js +68 -0
  28. package/dist/cli/commands/write/note-commands.js +146 -0
  29. package/dist/cli/commands/write/server-commands.js +553 -0
  30. package/dist/cli/commands/write/shared.js +35 -0
  31. package/dist/cli/commands/write/vault-lifecycle-commands.js +270 -0
  32. package/dist/cli/commands/write-commands.js +12 -1303
  33. package/dist/cli/main.js +39 -3
  34. package/dist/domain/context.js +60 -11
  35. package/dist/domain/diversity.js +64 -0
  36. package/dist/domain/embeddings.js +148 -6
  37. package/dist/domain/graph-contexts.js +62 -57
  38. package/dist/domain/graph-layout/cauliflower-layout.js +116 -0
  39. package/dist/domain/graph-layout/collisions.js +100 -0
  40. package/dist/domain/graph-layout/hierarchy.js +135 -0
  41. package/dist/domain/graph-layout/metrics.js +111 -0
  42. package/dist/domain/graph-layout/segments.js +76 -0
  43. package/dist/domain/graph-layout/star-layout.js +110 -0
  44. package/dist/domain/graph-layout.js +4 -625
  45. package/dist/domain/markdown.js +10 -1
  46. package/dist/domain/scoring.js +42 -0
  47. package/dist/domain/tokens.js +17 -1
  48. package/dist/infrastructure/config.js +33 -1
  49. package/dist/infrastructure/file-index.js +227 -60
  50. package/dist/infrastructure/semantic-prefilter.js +24 -0
  51. package/dist/mcp/server.js +23 -1
  52. package/dist/mcp/tool-guard.js +29 -0
  53. package/dist/mcp/tools/maintenance-tools.js +409 -0
  54. package/dist/mcp/tools/read-tools.js +537 -0
  55. package/dist/mcp/tools/shared.js +216 -0
  56. package/dist/mcp/tools/write-tools.js +353 -0
  57. package/dist/mcp/tools.js +3 -1357
  58. package/docs/AGENT_USAGE.md +4 -1
  59. package/docs/ARCHITECTURE.md +4 -3
  60. package/docs/QUICKSTART.md +4 -0
  61. package/package.json +2 -2
@@ -0,0 +1,42 @@
1
+ // Pure retrieval scoring primitives: BM25 lexical relevance and min-max
2
+ // normalization. Kept dependency-free and deterministic so the hot retrieval
3
+ // loop in the infrastructure layer stays testable and the domain owns the
4
+ // ranking math.
5
+ export const defaultBm25Parameters = {
6
+ k1: 1.2,
7
+ b: 0.75
8
+ };
9
+ // Robertson/Sparck-Jones probabilistic IDF with the +0.5 smoothing that keeps
10
+ // the value finite for terms present in every chunk. Floored at 0 so a term
11
+ // shared by the whole candidate set can never push a score negative.
12
+ export const inverseDocumentFrequency = (documentCount, documentFrequency) => {
13
+ if (documentCount <= 0) {
14
+ return 0;
15
+ }
16
+ const raw = Math.log(1 + (documentCount - documentFrequency + 0.5) / (documentFrequency + 0.5));
17
+ return raw > 0 ? raw : 0;
18
+ };
19
+ // BM25 contribution for a single query term given its (field-weighted)
20
+ // frequency in a chunk. `documentLength` and `averageLength` are in the same
21
+ // weighted unit so length normalization stays consistent with how the caller
22
+ // counts terms.
23
+ export const bm25TermScore = (params) => {
24
+ if (params.termFrequency <= 0) {
25
+ return 0;
26
+ }
27
+ const { k1, b } = params.parameters ?? defaultBm25Parameters;
28
+ const idf = inverseDocumentFrequency(params.documentCount, params.documentFrequency);
29
+ const normalizedLength = params.averageLength > 0 ? params.documentLength / params.averageLength : 1;
30
+ const denominator = params.termFrequency + k1 * (1 - b + b * normalizedLength);
31
+ return denominator > 0 ? (idf * (params.termFrequency * (k1 + 1))) / denominator : 0;
32
+ };
33
+ // Scale a value into [0, 1] given the observed range. When every candidate
34
+ // shares the same score (range collapses) a positive score maps to 1 and a
35
+ // zero score to 0, so a single result is never silently nulled out.
36
+ export const minMaxNormalize = (value, minimum, maximum) => {
37
+ if (maximum <= minimum) {
38
+ return value > 0 ? 1 : 0;
39
+ }
40
+ const scaled = (value - minimum) / (maximum - minimum);
41
+ return scaled < 0 ? 0 : scaled > 1 ? 1 : scaled;
42
+ };
@@ -1 +1,17 @@
1
- export const estimateTokenCount = (content) => Math.ceil(content.trim().split(/\s+/).filter(Boolean).join(' ').length / 4);
1
+ // Subword tokenizers emit roughly 1.3 tokens per whitespace word for natural
2
+ // language. Pure prose tracks that ratio; punctuation- or symbol-dense content
3
+ // (code, URLs, long unbroken strings) tokenizes far denser, which the word
4
+ // count alone underestimates. Taking the max with the character heuristic keeps
5
+ // the estimate conservative — never below chars/4 — so callers that budget
6
+ // against it can rely on the rendered output staying within the token limit.
7
+ const tokensPerWord = 1.3;
8
+ const charactersPerToken = 4;
9
+ export const estimateTokenCount = (content) => {
10
+ const words = content.trim().split(/\s+/).filter(Boolean);
11
+ if (words.length === 0) {
12
+ return 0;
13
+ }
14
+ const characterEstimate = Math.ceil(words.join(' ').length / charactersPerToken);
15
+ const wordEstimate = Math.ceil(words.length * tokensPerWord);
16
+ return Math.max(characterEstimate, wordEstimate);
17
+ };
@@ -11,11 +11,14 @@ export const defaultBrainlinkConfig = {
11
11
  defaultAgent: undefined,
12
12
  autoIndexOnWrite: true,
13
13
  autoCanonicalContextLinks: true,
14
+ autoUpdateCheck: true,
15
+ updateCheckIntervalMs: 86_400_000,
14
16
  defaultSearchLimit: 8,
15
17
  defaultContextTokens: 1500,
16
18
  defaultContextStrategy: 'auto',
17
19
  defaultContextCacheTtlMs: 120_000,
18
20
  embeddingProvider: 'local',
21
+ embedding: {},
19
22
  defaultSearchMode: 'hybrid',
20
23
  chunkSize: 1200,
21
24
  searchPack: {
@@ -41,10 +44,34 @@ const safeCwd = () => {
41
44
  }
42
45
  };
43
46
  const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
44
- const embeddingProviders = new Set(['none', 'local']);
47
+ const embeddingProviders = new Set(['none', 'local', 'ollama', 'openai']);
45
48
  const searchModes = new Set(['fts', 'semantic', 'hybrid']);
46
49
  const contextStrategies = new Set(['rag', 'cag', 'auto']);
47
50
  const sanitizeEmbeddingProvider = (value) => typeof value === 'string' && embeddingProviders.has(value) ? value : defaultBrainlinkConfig.embeddingProvider;
51
+ const sanitizeOptionalString = (value) => typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined;
52
+ const sanitizeEmbeddingConfig = (value) => {
53
+ if (!isRecord(value)) {
54
+ return {};
55
+ }
56
+ const url = sanitizeOptionalString(value.url);
57
+ const model = sanitizeOptionalString(value.model);
58
+ const apiKeyEnv = sanitizeOptionalString(value.apiKeyEnv);
59
+ const timeoutMs = sanitizePositiveNumber(value.timeoutMs);
60
+ const batchSize = sanitizePositiveNumber(value.batchSize);
61
+ return {
62
+ ...(url ? { url } : {}),
63
+ ...(model ? { model } : {}),
64
+ ...(apiKeyEnv ? { apiKeyEnv } : {}),
65
+ ...(timeoutMs ? { timeoutMs: Math.floor(timeoutMs) } : {}),
66
+ ...(batchSize ? { batchSize: Math.floor(batchSize) } : {})
67
+ };
68
+ };
69
+ // Stable identity of the embedding space, used to decide when stored vectors
70
+ // must be rebuilt. A model swap on an external provider changes the space even
71
+ // though the provider name is unchanged, so the model is folded in.
72
+ export const embeddingSignature = (config) => config.embeddingProvider === 'ollama' || config.embeddingProvider === 'openai'
73
+ ? `${config.embeddingProvider}:${config.embedding.model?.trim() || 'default'}`
74
+ : config.embeddingProvider;
48
75
  export const sanitizeSearchMode = (value, fallback = defaultBrainlinkConfig.defaultSearchMode) => typeof value === 'string' && searchModes.has(value) ? value : fallback;
49
76
  export const sanitizeContextStrategy = (value, fallback = defaultBrainlinkConfig.defaultContextStrategy) => typeof value === 'string' && contextStrategies.has(value) ? value : fallback;
50
77
  const sanitizeAllowedVaults = (value) => Array.isArray(value) ? value.filter((item) => typeof item === 'string' && item.trim().length > 0) : [];
@@ -177,6 +204,10 @@ const sanitizeConfig = (value) => ({
177
204
  autoCanonicalContextLinks: typeof value.autoCanonicalContextLinks === 'boolean'
178
205
  ? value.autoCanonicalContextLinks
179
206
  : defaultBrainlinkConfig.autoCanonicalContextLinks,
207
+ autoUpdateCheck: typeof value.autoUpdateCheck === 'boolean' ? value.autoUpdateCheck : defaultBrainlinkConfig.autoUpdateCheck,
208
+ updateCheckIntervalMs: typeof value.updateCheckIntervalMs === 'number' && Number.isFinite(value.updateCheckIntervalMs) && value.updateCheckIntervalMs > 0
209
+ ? Math.floor(value.updateCheckIntervalMs)
210
+ : defaultBrainlinkConfig.updateCheckIntervalMs,
180
211
  defaultSearchLimit: typeof value.defaultSearchLimit === 'number' && value.defaultSearchLimit > 0
181
212
  ? value.defaultSearchLimit
182
213
  : defaultBrainlinkConfig.defaultSearchLimit,
@@ -191,6 +222,7 @@ const sanitizeConfig = (value) => ({
191
222
  chunkSize: typeof value.chunkSize === 'number' && value.chunkSize > 0 ? value.chunkSize : defaultBrainlinkConfig.chunkSize,
192
223
  searchPack: sanitizeSearchPackConfig(value.searchPack),
193
224
  embeddingProvider: sanitizeEmbeddingProvider(value.embeddingProvider),
225
+ embedding: sanitizeEmbeddingConfig(value.embedding),
194
226
  defaultSearchMode: sanitizeSearchMode(value.defaultSearchMode),
195
227
  agentProfiles: sanitizeAgentProfiles(value.agentProfiles)
196
228
  });
@@ -1,6 +1,8 @@
1
1
  import { mkdir, readFile, rename, stat, writeFile } from 'node:fs/promises';
2
2
  import { dirname, join } from 'node:path';
3
- import { cosineSimilarity } from '../domain/embeddings.js';
3
+ import { createEmbeddingBuckets, dotProduct } from '../domain/embeddings.js';
4
+ import { bm25TermScore, minMaxNormalize } from '../domain/scoring.js';
5
+ import { selectSemanticCandidates } from './semantic-prefilter.js';
4
6
  const queryTokenPattern = /[\p{L}\p{N}_-]+/gu;
5
7
  const indexCacheMaxEntries = 16;
6
8
  const indexCache = new Map();
@@ -11,8 +13,48 @@ const emptyIndex = () => ({
11
13
  chunks: [],
12
14
  links: []
13
15
  });
16
+ // Raised when index.json exists but cannot be parsed. Search treats it as a
17
+ // signal to fall back to the independent compressed search packs instead of
18
+ // silently returning an empty result set.
19
+ export class IndexCorruptionError extends Error {
20
+ indexPath;
21
+ constructor(indexPath, cause) {
22
+ super(`Corrupted index at ${indexPath}: ${cause instanceof Error ? cause.message : String(cause)}`);
23
+ this.indexPath = indexPath;
24
+ this.name = 'IndexCorruptionError';
25
+ }
26
+ }
14
27
  export const indexStoragePath = (vaultPath) => join(vaultPath, '.brainlink', 'index.json');
15
- const readIndex = async (vaultPath) => {
28
+ const isMissingFileError = (error) => error instanceof Error && 'code' in error && error.code === 'ENOENT';
29
+ // Paths already reported as corrupt, so a non-removable corrupt index (read-only
30
+ // vault, locked file) does not spam stderr on every read. Cleared when a fresh
31
+ // index is written to the path.
32
+ const warnedCorruptIndexPaths = new Set();
33
+ // Move a corrupt index aside so it is not read again and the next index run
34
+ // rebuilds from Markdown source, and surface a warning instead of failing
35
+ // silently. Quarantine is best-effort: a read-only vault simply keeps the file.
36
+ const quarantineCorruptIndex = async (path, cause) => {
37
+ indexCache.delete(path);
38
+ if (!warnedCorruptIndexPaths.has(path)) {
39
+ warnedCorruptIndexPaths.add(path);
40
+ process.stderr.write(`brainlink: index at ${path} is unreadable and was quarantined; rebuild with 'index --full' (${cause instanceof Error ? cause.message : String(cause)})\n`);
41
+ }
42
+ try {
43
+ await rename(path, `${path}.corrupt`);
44
+ }
45
+ catch {
46
+ // The primary name can already exist (a prior corruption on a filesystem
47
+ // where rename does not overwrite, e.g. Windows). Fall back to a unique name
48
+ // so the damaged file is still moved out of the read path.
49
+ try {
50
+ await rename(path, `${path}.corrupt-${Date.now()}`);
51
+ }
52
+ catch {
53
+ // Leave the file in place when it cannot be renamed; the warning still fired.
54
+ }
55
+ }
56
+ };
57
+ const readIndex = async (vaultPath, strict = false) => {
16
58
  const path = indexStoragePath(vaultPath);
17
59
  let stats = null;
18
60
  try {
@@ -20,9 +62,8 @@ const readIndex = async (vaultPath) => {
20
62
  stats = { mtimeMs: fileStats.mtimeMs, size: fileStats.size };
21
63
  }
22
64
  catch (error) {
23
- if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
65
+ if (isMissingFileError(error)) {
24
66
  indexCache.delete(path);
25
- return emptyIndex();
26
67
  }
27
68
  return emptyIndex();
28
69
  }
@@ -30,31 +71,46 @@ const readIndex = async (vaultPath) => {
30
71
  if (cached && cached.mtimeMs === stats.mtimeMs && cached.size === stats.size) {
31
72
  return cached.index;
32
73
  }
74
+ let raw;
33
75
  try {
34
- const parsed = JSON.parse(await readFile(path, 'utf8'));
35
- const loaded = {
36
- version: 1,
37
- updatedAt: typeof parsed.updatedAt === 'string' ? parsed.updatedAt : new Date().toISOString(),
38
- documents: Array.isArray(parsed.documents) ? parsed.documents : [],
39
- chunks: Array.isArray(parsed.chunks) ? parsed.chunks : [],
40
- links: Array.isArray(parsed.links) ? parsed.links : []
41
- };
42
- indexCache.set(path, { ...stats, index: loaded });
43
- if (indexCache.size > indexCacheMaxEntries) {
44
- const oldest = indexCache.keys().next().value;
45
- if (typeof oldest === 'string') {
46
- indexCache.delete(oldest);
47
- }
76
+ raw = await readFile(path, 'utf8');
77
+ }
78
+ catch (error) {
79
+ indexCache.delete(path);
80
+ // The file existed at stat but vanished before the read — a concurrent
81
+ // rebuild or quarantine. Strict (search) callers throw so they fall back to
82
+ // packs rather than racing to an empty result; tolerant callers get empty.
83
+ if (strict) {
84
+ throw new IndexCorruptionError(path, error);
48
85
  }
49
- return loaded;
86
+ return emptyIndex();
87
+ }
88
+ let parsed;
89
+ try {
90
+ parsed = JSON.parse(raw);
50
91
  }
51
92
  catch (error) {
52
- if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
53
- indexCache.delete(path);
54
- return emptyIndex();
93
+ await quarantineCorruptIndex(path, error);
94
+ if (strict) {
95
+ throw new IndexCorruptionError(path, error);
55
96
  }
56
97
  return emptyIndex();
57
98
  }
99
+ const loaded = {
100
+ version: 1,
101
+ updatedAt: typeof parsed.updatedAt === 'string' ? parsed.updatedAt : new Date().toISOString(),
102
+ documents: Array.isArray(parsed.documents) ? parsed.documents : [],
103
+ chunks: Array.isArray(parsed.chunks) ? parsed.chunks : [],
104
+ links: Array.isArray(parsed.links) ? parsed.links : []
105
+ };
106
+ indexCache.set(path, { ...stats, index: loaded });
107
+ if (indexCache.size > indexCacheMaxEntries) {
108
+ const oldest = indexCache.keys().next().value;
109
+ if (typeof oldest === 'string') {
110
+ indexCache.delete(oldest);
111
+ }
112
+ }
113
+ return loaded;
58
114
  };
59
115
  const writeIndex = async (vaultPath, index) => {
60
116
  const target = indexStoragePath(vaultPath);
@@ -62,6 +118,7 @@ const writeIndex = async (vaultPath, index) => {
62
118
  await mkdir(dirname(target), { recursive: true, mode: 0o700 });
63
119
  await writeFile(temp, `${JSON.stringify(index)}\n`, { encoding: 'utf8', mode: 0o600 });
64
120
  await rename(temp, target);
121
+ warnedCorruptIndexPaths.delete(target);
65
122
  const fileStats = await stat(target);
66
123
  indexCache.set(target, {
67
124
  mtimeMs: fileStats.mtimeMs,
@@ -90,40 +147,89 @@ const countOccurrences = (text, token) => {
90
147
  }
91
148
  return hits;
92
149
  };
93
- const textScore = (row, tokens) => {
150
+ const titleFieldWeight = 5;
151
+ const tagFieldWeight = 4;
152
+ const pathFieldWeight = 2;
153
+ const contentHitCap = 6;
154
+ const hybridTextWeight = 0.6;
155
+ const hybridSemanticWeight = 0.4;
156
+ const normalizeFields = (row) => ({
157
+ title: normalizeToken(row.title),
158
+ path: normalizeToken(row.path),
159
+ content: normalizeToken(row.content),
160
+ tags: normalizeToken(row.tags.join(' '))
161
+ });
162
+ // Field-weighted term frequency: title/tag/path matches count more than body
163
+ // matches, and body hits saturate at a cap so a term repeated many times in one
164
+ // chunk cannot dominate. This weighted frequency feeds BM25 below.
165
+ const weightedTermFrequency = (fields, token) => {
166
+ const titleHits = countOccurrences(fields.title, token);
167
+ const tagHits = countOccurrences(fields.tags, token);
168
+ const pathHits = countOccurrences(fields.path, token);
169
+ const contentHits = countOccurrences(fields.content, token);
170
+ return (titleHits * titleFieldWeight +
171
+ tagHits * tagFieldWeight +
172
+ pathHits * pathFieldWeight +
173
+ Math.min(contentHits, contentHitCap));
174
+ };
175
+ // Naive additive field score retained for graph-node title/path lookup, where
176
+ // the candidate set is documents (not chunks) and corpus-wide BM25 statistics
177
+ // add no signal over a direct weighted hit count.
178
+ const lexicalFieldScore = (row, tokens) => {
94
179
  if (tokens.length === 0) {
95
180
  return 0;
96
181
  }
97
- const title = normalizeToken(row.title);
98
- const path = normalizeToken(row.path);
99
- const content = normalizeToken(row.content);
100
- const tags = normalizeToken(row.tags.join(' '));
101
- return tokens.reduce((score, token) => {
102
- const titleHits = countOccurrences(title, token);
103
- const tagHits = countOccurrences(tags, token);
104
- const pathHits = countOccurrences(path, token);
105
- const contentHits = countOccurrences(content, token);
106
- return score + titleHits * 5 + tagHits * 4 + pathHits * 2 + Math.min(contentHits, 6);
107
- }, 0);
182
+ const fields = normalizeFields(row);
183
+ return tokens.reduce((score, token) => score + weightedTermFrequency(fields, token), 0);
108
184
  };
109
- const semanticScore = (row, queryEmbedding) => queryEmbedding.length > 0 && row.embedding.length > 0 ? cosineSimilarity(queryEmbedding, row.embedding) : 0;
110
- const toResult = (row, mode, text, semantic) => {
111
- const score = mode === 'fts' ? text : mode === 'semantic' ? semantic : text + semantic * 8;
185
+ // A single chunk with a non-finite cached tokenCount (legacy index, partial
186
+ // write, manual edit) would otherwise poison the shared averageLength and zero
187
+ // out lexical scoring for the whole corpus, so coerce to a safe positive length.
188
+ const safeDocumentLength = (tokenCount) => Number.isFinite(tokenCount) && tokenCount > 0 ? tokenCount : 1;
189
+ const buildCorpusStatistics = (prepared, tokens) => {
190
+ const documentCount = prepared.length;
191
+ const totalLength = prepared.reduce((sum, entry) => sum + safeDocumentLength(entry.row.tokenCount), 0);
192
+ const documentFrequencyByTerm = new Map();
193
+ for (const term of tokens) {
194
+ let documentFrequency = 0;
195
+ for (const entry of prepared) {
196
+ if ((entry.weightedTermFrequencies.get(term) ?? 0) > 0) {
197
+ documentFrequency += 1;
198
+ }
199
+ }
200
+ documentFrequencyByTerm.set(term, documentFrequency);
201
+ }
112
202
  return {
113
- documentId: row.documentId,
114
- agentId: row.agentId,
115
- title: row.title,
116
- path: row.path,
117
- chunkId: row.chunkId,
118
- chunkOrdinal: row.chunkOrdinal,
119
- content: row.content,
120
- score,
121
- textScore: text,
122
- semanticScore: semantic,
123
- searchMode: mode,
124
- tags: row.tags
203
+ documentCount,
204
+ averageLength: documentCount > 0 ? totalLength / documentCount : 0,
205
+ documentFrequencyByTerm
125
206
  };
126
207
  };
208
+ const bm25TextScore = (entry, tokens, stats) => tokens.reduce((score, term) => score +
209
+ bm25TermScore({
210
+ termFrequency: entry.weightedTermFrequencies.get(term) ?? 0,
211
+ documentLength: safeDocumentLength(entry.row.tokenCount),
212
+ documentCount: stats.documentCount,
213
+ documentFrequency: stats.documentFrequencyByTerm.get(term) ?? 0,
214
+ averageLength: stats.averageLength
215
+ }), 0);
216
+ const semanticScore = (row, queryEmbedding) => queryEmbedding.length > 0 && row.embedding.length > 0 ? dotProduct(queryEmbedding, row.embedding) : 0;
217
+ const rangeOf = (values) => values.reduce((range, value) => ({ min: Math.min(range.min, value), max: Math.max(range.max, value) }), { min: Infinity, max: -Infinity });
218
+ const toResult = (row, mode, score, text, semantic) => ({
219
+ documentId: row.documentId,
220
+ agentId: row.agentId,
221
+ title: row.title,
222
+ path: row.path,
223
+ chunkId: row.chunkId,
224
+ chunkOrdinal: row.chunkOrdinal,
225
+ content: row.content,
226
+ score,
227
+ textScore: text,
228
+ semanticScore: semantic,
229
+ tokenCount: row.tokenCount,
230
+ searchMode: mode,
231
+ tags: row.tags
232
+ });
127
233
  const toGraphLink = (link, documentsById) => {
128
234
  const source = documentsById.get(link.fromDocumentId);
129
235
  const target = link.toDocumentId ? documentsById.get(link.toDocumentId) : undefined;
@@ -186,7 +292,9 @@ export const openFileIndex = (vaultPath) => {
186
292
  .sort((left, right) => left.document.path.localeCompare(right.document.path));
187
293
  },
188
294
  search: async (query, limit, agentId, mode = 'hybrid', queryEmbedding = []) => {
189
- const index = await load();
295
+ // Strict load: a corrupt index throws so the caller can fall back to the
296
+ // independent search packs rather than reporting "no results".
297
+ const index = await readIndex(vaultPath, true);
190
298
  const documentsById = new Map(index.documents.map((document) => [document.id, document]));
191
299
  const rows = index.chunks.flatMap((chunk) => {
192
300
  const document = documentsById.get(chunk.documentId);
@@ -205,22 +313,80 @@ export const openFileIndex = (vaultPath) => {
205
313
  chunkId: chunk.id,
206
314
  chunkOrdinal: chunk.ordinal,
207
315
  content: chunk.content,
316
+ tokenCount: chunk.tokenCount,
208
317
  tags: document.tags,
209
- embedding: chunk.embedding
318
+ embedding: chunk.embedding,
319
+ buckets: chunk.buckets
210
320
  }
211
321
  ];
212
322
  });
213
323
  const tokens = tokenize(query);
214
- const results = rows
215
- .map((row) => {
216
- const text = textScore(row, tokens);
217
- const semantic = semanticScore(row, queryEmbedding);
218
- return toResult(row, mode, text, semantic);
219
- })
220
- .filter((row) => row.score > 0 || tokens.length === 0)
324
+ // Pure-semantic scoring on a large vault can skip chunks that share no
325
+ // embedding bucket with the query. Hybrid/fts keep every row so lexical
326
+ // matches are never dropped; the prefilter also falls back to a full scan
327
+ // on small or partially-indexed vaults.
328
+ const candidates = mode === 'semantic' && queryEmbedding.length > 0
329
+ ? selectSemanticCandidates(rows, createEmbeddingBuckets(queryEmbedding), limit)
330
+ : rows;
331
+ // Pass 1: field-weighted term frequencies per candidate, then corpus-wide
332
+ // statistics (document frequency, average length) needed for BM25 IDF and
333
+ // length normalization.
334
+ const prepared = candidates.map((row) => {
335
+ const fields = normalizeFields(row);
336
+ const weightedTermFrequencies = new Map();
337
+ for (const term of tokens) {
338
+ const frequency = weightedTermFrequency(fields, term);
339
+ if (frequency > 0) {
340
+ weightedTermFrequencies.set(term, frequency);
341
+ }
342
+ }
343
+ return { row, weightedTermFrequencies };
344
+ });
345
+ const stats = buildCorpusStatistics(prepared, tokens);
346
+ // Pass 2: raw lexical (BM25) and semantic (cosine) component scores.
347
+ const componentScored = prepared.map((entry) => ({
348
+ row: entry.row,
349
+ text: tokens.length > 0 ? bm25TextScore(entry, tokens, stats) : 0,
350
+ semantic: semanticScore(entry.row, queryEmbedding)
351
+ }));
352
+ // Filter on raw relevance before normalization so a legitimate match that
353
+ // happens to be the weakest in its mode is never min-max scaled to zero
354
+ // and dropped. fts stays lexical-only and semantic stays vector-only.
355
+ const relevant = componentScored.filter((entry) => {
356
+ if (tokens.length === 0) {
357
+ return true;
358
+ }
359
+ if (mode === 'fts') {
360
+ return entry.text > 0;
361
+ }
362
+ if (mode === 'semantic') {
363
+ return entry.semantic > 0;
364
+ }
365
+ return entry.text > 0 || entry.semantic > 0;
366
+ });
367
+ if (relevant.length === 0) {
368
+ return [];
369
+ }
370
+ const textRange = rangeOf(relevant.map((entry) => entry.text));
371
+ const semanticRange = rangeOf(relevant.map((entry) => entry.semantic));
372
+ const finalScore = (entry) => {
373
+ if (mode === 'fts') {
374
+ return entry.text;
375
+ }
376
+ if (mode === 'semantic') {
377
+ return entry.semantic;
378
+ }
379
+ // Hybrid mixes the two components on a shared [0, 1] scale so neither the
380
+ // unbounded BM25 magnitude nor the bounded cosine value can swamp the
381
+ // other, replacing the previous fixed `text + semantic * 8` blend.
382
+ const textNorm = minMaxNormalize(entry.text, textRange.min, textRange.max);
383
+ const semanticNorm = minMaxNormalize(entry.semantic, semanticRange.min, semanticRange.max);
384
+ return textNorm * hybridTextWeight + semanticNorm * hybridSemanticWeight;
385
+ };
386
+ return relevant
387
+ .map((entry) => toResult(entry.row, mode, finalScore(entry), entry.text, entry.semantic))
221
388
  .sort((left, right) => right.score - left.score || left.title.localeCompare(right.title))
222
389
  .slice(0, Math.max(0, limit));
223
- return results;
224
390
  },
225
391
  listLinks: async (agentId) => {
226
392
  const index = await load();
@@ -326,7 +492,7 @@ export const openFileIndex = (vaultPath) => {
326
492
  const scored = index.documents
327
493
  .filter((document) => (!agentId || document.agentId === agentId))
328
494
  .map((document) => {
329
- const score = textScore({
495
+ const score = lexicalFieldScore({
330
496
  documentId: document.id,
331
497
  agentId: document.agentId,
332
498
  title: document.title,
@@ -334,6 +500,7 @@ export const openFileIndex = (vaultPath) => {
334
500
  chunkId: document.id,
335
501
  chunkOrdinal: 0,
336
502
  content: document.content,
503
+ tokenCount: 0,
337
504
  tags: document.tags,
338
505
  embedding: []
339
506
  }, tokens);
@@ -0,0 +1,24 @@
1
+ // Below this chunk count a full scan is already sub-millisecond, so the
2
+ // prefilter only engages on large vaults where pruning candidates pays off.
3
+ export const semanticPrefilterMinChunks = 4000;
4
+ // Pure-semantic candidate prefilter. Returns the subset of rows that share at
5
+ // least one embedding bucket with the query, or ALL rows when the prefilter
6
+ // should not or cannot safely engage:
7
+ // - the index is small (full scan is cheap),
8
+ // - the query produced no buckets,
9
+ // - any row is missing buckets (a pre-feature / partially indexed vault), or
10
+ // - too few candidates survive (protect recall — fall back to a full scan).
11
+ // It is only sound for pure-semantic scoring; hybrid/fts must keep every row so
12
+ // lexical matches are not dropped.
13
+ export const selectSemanticCandidates = (rows, queryBuckets, limit, minChunks = semanticPrefilterMinChunks) => {
14
+ if (rows.length <= minChunks || queryBuckets.length === 0) {
15
+ return rows;
16
+ }
17
+ if (rows.some((row) => !row.buckets || row.buckets.length === 0)) {
18
+ return rows;
19
+ }
20
+ const bucketSet = new Set(queryBuckets);
21
+ const candidates = rows.filter((row) => row.buckets.some((bucket) => bucketSet.has(bucket)));
22
+ const recallFloor = Math.max(limit * 4, 64);
23
+ return candidates.length >= recallFloor ? candidates : rows;
24
+ };
@@ -1,6 +1,7 @@
1
1
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
- import { addNoteInputSchema, addFileInputSchema, addFileTool, addNoteTool, deleteNoteInputSchema, deleteNoteTool, volatileAddInputSchema, volatileAddTool, volatileClearInputSchema, volatileClearTool, dedupeInputSchema, dedupeResolveInputSchema, dedupeResolveTool, dedupeTool, brokenLinksInputSchema, brokenLinksTool, bootstrapInputSchema, bootstrapTool, canonicalizeContextLinksInputSchema, canonicalizeContextLinksTool, contextInputSchema, contextPacksInputSchema, contextPacksTool, contextTool, doctorActionsInputSchema, doctorActionsTool, graphContextsInputSchema, graphContextsTool, graphInputSchema, graphTool, explainInputSchema, explainTool, indexInputSchema, indexTool, inboxAddInputSchema, inboxAddTool, inboxListInputSchema, inboxListTool, inboxProcessInputSchema, inboxProcessTool, orphansInputSchema, orphansTool, policyInputSchema, policyTool, projectInitInputSchema, projectInitTool, recommendationsInputSchema, recommendationsTool, rememberInputSchema, rememberTool, repairLinksInputSchema, repairLinksTool, searchInputSchema, searchTool, sessionCloseInputSchema, sessionCloseTool, statsInputSchema, statsTool, suggestLinksInputSchema, suggestLinksTool, syncInputSchema, syncTool, validateInputSchema, validateTool, versionInputSchema, versionTool } from './tools.js';
2
+ import { addNoteInputSchema, addFileInputSchema, addFileTool, addNoteTool, addNotesInputSchema, addNotesTool, deleteNoteInputSchema, deleteNoteTool, deleteNotesInputSchema, deleteNotesTool, volatileAddInputSchema, volatileAddTool, volatileClearInputSchema, volatileClearTool, dedupeInputSchema, dedupeResolveInputSchema, dedupeResolveTool, dedupeTool, brokenLinksInputSchema, brokenLinksTool, bootstrapInputSchema, bootstrapTool, canonicalizeContextLinksInputSchema, canonicalizeContextLinksTool, contextInputSchema, contextPacksInputSchema, contextPacksTool, contextTool, doctorActionsInputSchema, doctorActionsTool, graphContextsInputSchema, graphContextsTool, graphInputSchema, graphTool, explainInputSchema, explainTool, indexInputSchema, indexTool, inboxAddInputSchema, inboxAddTool, inboxListInputSchema, inboxListTool, inboxProcessInputSchema, inboxProcessTool, orphansInputSchema, orphansTool, policyInputSchema, policyTool, projectInitInputSchema, projectInitTool, recommendationsInputSchema, recommendationsTool, rememberInputSchema, rememberTool, repairLinksInputSchema, repairLinksTool, searchInputSchema, searchTool, similarNotesInputSchema, similarNotesTool, sessionCloseInputSchema, sessionCloseTool, statsInputSchema, statsTool, suggestLinksInputSchema, suggestLinksTool, syncInputSchema, syncTool, validateInputSchema, validateTool, versionInputSchema, versionTool } from './tools.js';
3
3
  import { getRuntimeVersion } from './runtime.js';
4
+ import { guardToolHandler } from './tool-guard.js';
4
5
  export const createBrainlinkMcpServer = () => {
5
6
  const server = new McpServer({
6
7
  name: 'brainlink',
@@ -8,6 +9,12 @@ export const createBrainlinkMcpServer = () => {
8
9
  version: getRuntimeVersion(),
9
10
  description: 'Local-first Markdown memory tools for AI agents.'
10
11
  });
12
+ // Route every tool registration through the error guard so a thrown handler
13
+ // returns a structured isError result instead of propagating to the client.
14
+ // Wrapping registerTool once keeps the call sites below unchanged and also
15
+ // covers any tools added later.
16
+ const baseRegisterTool = server.registerTool.bind(server);
17
+ server.registerTool = ((name, config, handler) => baseRegisterTool(name, config, guardToolHandler(name, handler)));
11
18
  server.registerTool('brainlink_bootstrap', {
12
19
  title: 'Bootstrap Brainlink For A Task (Default Entrypoint)',
13
20
  description: 'Default entrypoint for agents. Run this first to index/check memory state, then optionally retrieve context for the current task query.',
@@ -43,6 +50,11 @@ export const createBrainlinkMcpServer = () => {
43
50
  description: 'Search indexed Brainlink notes with FTS, semantic or hybrid retrieval.',
44
51
  inputSchema: searchInputSchema
45
52
  }, searchTool);
53
+ server.registerTool('brainlink_similar_notes', {
54
+ title: 'Find Similar Brainlink Notes',
55
+ description: 'Find the notes most similar to a given note (selected by title or path), using the note content as the retrieval query and excluding the note itself.',
56
+ inputSchema: similarNotesInputSchema
57
+ }, similarNotesTool);
46
58
  server.registerTool('brainlink_explain', {
47
59
  title: 'Explain Brainlink Search Results',
48
60
  description: 'Explain why indexed notes matched a query, including score components and match reasons.',
@@ -63,6 +75,11 @@ export const createBrainlinkMcpServer = () => {
63
75
  description: 'Write durable Markdown memory, then reindex the vault. Include explicit [[wiki links]] for connected graph memory. Add priority markers near links, such as priority: high, #important or #critical, when a relationship should be weighted higher.',
64
76
  inputSchema: addNoteInputSchema
65
77
  }, addNoteTool);
78
+ server.registerTool('brainlink_add_notes', {
79
+ title: 'Add Brainlink Notes (batch)',
80
+ description: 'Write several durable Markdown notes in one call, reindexing the vault once after all are written. Use for bulk capture instead of repeated brainlink_add_note calls.',
81
+ inputSchema: addNotesInputSchema
82
+ }, addNotesTool);
66
83
  server.registerTool('brainlink_remember', {
67
84
  title: 'Capture Assisted Brainlink Memory',
68
85
  description: 'Capture durable memory with inferred title, tags and Context Links. Supports dry-run preview before writing.',
@@ -88,6 +105,11 @@ export const createBrainlinkMcpServer = () => {
88
105
  description: 'Delete a durable Markdown note from the vault after explicit confirmation. Select by title or path; reindexes by default.',
89
106
  inputSchema: deleteNoteInputSchema
90
107
  }, deleteNoteTool);
108
+ server.registerTool('brainlink_delete_notes', {
109
+ title: 'Delete Brainlink Notes (batch)',
110
+ description: 'Delete several notes in one confirmed call, reindexing once after the batch. Each note is selected by exactly one of title or path; per-note failures are reported without aborting the batch.',
111
+ inputSchema: deleteNotesInputSchema
112
+ }, deleteNotesTool);
91
113
  server.registerTool('brainlink_volatile_add', {
92
114
  title: 'Add Volatile Brainlink Memory',
93
115
  description: 'Write temporary agent-decided memory with TTL. Use for transient task state without polluting durable Markdown memory.',
@@ -0,0 +1,29 @@
1
+ const errorResult = (toolName, error) => {
2
+ const message = error instanceof Error ? error.message : String(error);
3
+ const payload = {
4
+ error: message,
5
+ tool: toolName
6
+ };
7
+ return {
8
+ isError: true,
9
+ content: [
10
+ {
11
+ type: 'text',
12
+ text: JSON.stringify(payload, null, 2)
13
+ }
14
+ ],
15
+ structuredContent: payload
16
+ };
17
+ };
18
+ // Wrap a tool handler so an unexpected throw becomes a structured error result
19
+ // instead of propagating a raw exception to the MCP client. Agents then receive
20
+ // an actionable `isError` payload naming the failing tool rather than a stack
21
+ // trace or a dropped call.
22
+ export const guardToolHandler = (toolName, handler) => async (args, extra) => {
23
+ try {
24
+ return await handler(args, extra);
25
+ }
26
+ catch (error) {
27
+ return errorResult(toolName, error);
28
+ }
29
+ };