@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.
- package/CHANGELOG.md +17 -0
- package/README.md +46 -0
- package/dist/application/add-note.js +2 -2
- package/dist/application/build-context.js +16 -10
- package/dist/application/canonical-context-links.js +44 -5
- package/dist/application/check-package-update.js +105 -0
- package/dist/application/find-similar-notes.js +75 -0
- package/dist/application/frontend/client/chunk-fetch.js +236 -0
- package/dist/application/frontend/client/controls.js +178 -0
- package/dist/application/frontend/client/elements.js +122 -0
- package/dist/application/frontend/client/input.js +202 -0
- package/dist/application/frontend/client/node-details.js +191 -0
- package/dist/application/frontend/client/rendering.js +296 -0
- package/dist/application/frontend/client/scope-theme.js +114 -0
- package/dist/application/frontend/client/spatial.js +98 -0
- package/dist/application/frontend/client/storage.js +215 -0
- package/dist/application/frontend/client/upload.js +90 -0
- package/dist/application/frontend/client/worker-bootstrap.js +147 -0
- package/dist/application/frontend/client-js.js +24 -1837
- package/dist/application/frontend/client-render-worker-js.js +1 -1
- package/dist/application/index-vault-phases.js +190 -0
- package/dist/application/index-vault.js +46 -167
- package/dist/application/memory-suggestions.js +4 -1
- package/dist/application/search-knowledge.js +19 -3
- package/dist/cli/commands/write/dedupe-commands.js +59 -0
- package/dist/cli/commands/write/index-commands.js +205 -0
- package/dist/cli/commands/write/link-commands.js +68 -0
- package/dist/cli/commands/write/note-commands.js +146 -0
- package/dist/cli/commands/write/server-commands.js +553 -0
- package/dist/cli/commands/write/shared.js +35 -0
- package/dist/cli/commands/write/vault-lifecycle-commands.js +270 -0
- package/dist/cli/commands/write-commands.js +12 -1303
- package/dist/cli/main.js +39 -3
- package/dist/domain/context.js +60 -11
- package/dist/domain/diversity.js +64 -0
- package/dist/domain/embeddings.js +148 -6
- package/dist/domain/graph-contexts.js +62 -57
- package/dist/domain/graph-layout/cauliflower-layout.js +116 -0
- package/dist/domain/graph-layout/collisions.js +100 -0
- package/dist/domain/graph-layout/hierarchy.js +135 -0
- package/dist/domain/graph-layout/metrics.js +111 -0
- package/dist/domain/graph-layout/segments.js +76 -0
- package/dist/domain/graph-layout/star-layout.js +110 -0
- package/dist/domain/graph-layout.js +4 -625
- 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 +33 -1
- package/dist/infrastructure/file-index.js +227 -60
- package/dist/infrastructure/semantic-prefilter.js +24 -0
- package/dist/mcp/server.js +23 -1
- package/dist/mcp/tool-guard.js +29 -0
- package/dist/mcp/tools/maintenance-tools.js +409 -0
- package/dist/mcp/tools/read-tools.js +537 -0
- package/dist/mcp/tools/shared.js +216 -0
- package/dist/mcp/tools/write-tools.js +353 -0
- package/dist/mcp/tools.js +3 -1357
- package/docs/AGENT_USAGE.md +4 -1
- package/docs/ARCHITECTURE.md +4 -3
- package/docs/QUICKSTART.md +4 -0
- 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
|
+
};
|
package/dist/domain/tokens.js
CHANGED
|
@@ -1 +1,17 @@
|
|
|
1
|
-
|
|
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 {
|
|
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
|
|
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
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
|
86
|
+
return emptyIndex();
|
|
87
|
+
}
|
|
88
|
+
let parsed;
|
|
89
|
+
try {
|
|
90
|
+
parsed = JSON.parse(raw);
|
|
50
91
|
}
|
|
51
92
|
catch (error) {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
|
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
|
|
98
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
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
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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 =
|
|
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
|
+
};
|
package/dist/mcp/server.js
CHANGED
|
@@ -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
|
+
};
|