@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,163 @@
1
+ // Binary storage backend. Stores chunk vectors as raw little-endian Float32 in a
2
+ // dense `vectors.f32` file (~4x smaller than the JSON backend's decimal text and
3
+ // far cheaper to load) and the document/chunk/link metadata as Brotli-compressed
4
+ // JSON. Reads reconstruct the shared `StoredIndex` shape and answer through the
5
+ // same query core as the JSON backend, so results are behavior-identical (vector
6
+ // values are stored at Float32 precision, the standard for embeddings).
7
+ //
8
+ // Migration is seamless: until the first index run materializes the binary store,
9
+ // reads transparently fall back to a legacy `index.json`; the first save writes
10
+ // the binary store and removes the legacy file.
11
+ import { brotliCompressSync, brotliDecompressSync, constants as zlibConstants } from 'node:zlib';
12
+ import { mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
13
+ import { join } from 'node:path';
14
+ import { indexStoragePath, readJsonStoredIndex } from '../file-index.js';
15
+ import { createStoredIndexQueries, toStoredIndex } from './stored-index.js';
16
+ const storeVersion = 1;
17
+ export const binaryStoreDirectory = (vaultPath) => join(vaultPath, '.brainlink', 'store');
18
+ const storeFile = (vaultPath, name) => join(binaryStoreDirectory(vaultPath), name);
19
+ const metaPath = (vaultPath) => storeFile(vaultPath, 'meta.json');
20
+ const brotli = (value) => brotliCompressSync(Buffer.from(value, 'utf8'), {
21
+ params: { [zlibConstants.BROTLI_PARAM_QUALITY]: 5, [zlibConstants.BROTLI_PARAM_MODE]: zlibConstants.BROTLI_MODE_TEXT }
22
+ });
23
+ const unbrotli = (buffer) => brotliDecompressSync(buffer).toString('utf8');
24
+ export const binaryStoreExists = async (vaultPath) => {
25
+ try {
26
+ await stat(metaPath(vaultPath));
27
+ return true;
28
+ }
29
+ catch {
30
+ return false;
31
+ }
32
+ };
33
+ // Pack chunk embeddings into one dense Float32 buffer (chunk i at i*dims floats).
34
+ const encodeVectors = (chunks, dims) => {
35
+ if (dims === 0) {
36
+ return Buffer.alloc(0);
37
+ }
38
+ const buffer = Buffer.alloc(chunks.length * dims * 4);
39
+ chunks.forEach((chunk, index) => {
40
+ const base = index * dims * 4;
41
+ for (let dimension = 0; dimension < dims; dimension += 1) {
42
+ buffer.writeFloatLE(chunk.embedding[dimension] ?? 0, base + dimension * 4);
43
+ }
44
+ });
45
+ return buffer;
46
+ };
47
+ const decodeVector = (vectors, index, dims) => {
48
+ const out = new Array(dims);
49
+ const base = index * dims * 4;
50
+ for (let dimension = 0; dimension < dims; dimension += 1) {
51
+ out[dimension] = vectors.readFloatLE(base + dimension * 4);
52
+ }
53
+ return out;
54
+ };
55
+ const writeAtomic = async (path, data) => {
56
+ const temp = `${path}.tmp`;
57
+ await writeFile(temp, data, { mode: 0o600 });
58
+ await rename(temp, path);
59
+ };
60
+ const persistStoredIndex = async (vaultPath, stored) => {
61
+ const dims = stored.chunks.reduce((max, chunk) => Math.max(max, chunk.embedding.length), 0);
62
+ const chunkMeta = stored.chunks.map((chunk) => ({
63
+ id: chunk.id,
64
+ documentId: chunk.documentId,
65
+ ordinal: chunk.ordinal,
66
+ content: chunk.content,
67
+ tokenCount: chunk.tokenCount,
68
+ embeddingProvider: chunk.embeddingProvider,
69
+ ...(chunk.buckets ? { buckets: chunk.buckets } : {}),
70
+ hasEmbedding: chunk.embedding.length > 0
71
+ }));
72
+ const meta = {
73
+ version: storeVersion,
74
+ updatedAt: stored.updatedAt,
75
+ dims,
76
+ documentCount: stored.documents.length,
77
+ chunkCount: stored.chunks.length
78
+ };
79
+ await mkdir(binaryStoreDirectory(vaultPath), { recursive: true, mode: 0o700 });
80
+ // Write data files first, then meta.json last as the commit point: a reader
81
+ // checks meta first, so a crash mid-write leaves the previous meta (and its
82
+ // matching data) in place rather than a torn state.
83
+ await writeAtomic(storeFile(vaultPath, 'documents.json.br'), brotli(JSON.stringify(stored.documents)));
84
+ await writeAtomic(storeFile(vaultPath, 'chunks.json.br'), brotli(JSON.stringify(chunkMeta)));
85
+ await writeAtomic(storeFile(vaultPath, 'links.json.br'), brotli(JSON.stringify(stored.links)));
86
+ await writeAtomic(storeFile(vaultPath, 'vectors.f32'), encodeVectors(stored.chunks, dims));
87
+ await writeAtomic(metaPath(vaultPath), Buffer.from(`${JSON.stringify(meta)}\n`, 'utf8'));
88
+ // Migration complete: the binary store now holds everything, so drop the
89
+ // legacy JSON index. Markdown remains the canonical source either way.
90
+ await rm(indexStoragePath(vaultPath), { force: true });
91
+ };
92
+ const persist = (vaultPath, documents) => persistStoredIndex(vaultPath, toStoredIndex(documents));
93
+ // One-time, lossless format conversion: if the binary store is absent but a
94
+ // legacy index.json exists, rewrite its contents (embeddings included, no
95
+ // re-embedding) into the binary store and drop the legacy file. Deduped per
96
+ // vault so concurrent operations convert once.
97
+ const inFlightMigrations = new Map();
98
+ export const migrateLegacyIndexToBinary = async (vaultPath) => {
99
+ if (await binaryStoreExists(vaultPath)) {
100
+ return;
101
+ }
102
+ const existing = inFlightMigrations.get(vaultPath);
103
+ if (existing) {
104
+ return existing;
105
+ }
106
+ const run = (async () => {
107
+ const legacy = await readJsonStoredIndex(vaultPath, false);
108
+ if (legacy.documents.length === 0 && legacy.chunks.length === 0) {
109
+ return;
110
+ }
111
+ await persistStoredIndex(vaultPath, legacy);
112
+ })().finally(() => inFlightMigrations.delete(vaultPath));
113
+ inFlightMigrations.set(vaultPath, run);
114
+ return run;
115
+ };
116
+ const loadBinary = async (vaultPath) => {
117
+ const meta = JSON.parse(await readFile(metaPath(vaultPath), 'utf8'));
118
+ const [documentsRaw, chunkMetaRaw, linksRaw, vectors] = await Promise.all([
119
+ readFile(storeFile(vaultPath, 'documents.json.br')),
120
+ readFile(storeFile(vaultPath, 'chunks.json.br')),
121
+ readFile(storeFile(vaultPath, 'links.json.br')),
122
+ readFile(storeFile(vaultPath, 'vectors.f32'))
123
+ ]);
124
+ const documents = JSON.parse(unbrotli(documentsRaw));
125
+ const chunkMeta = JSON.parse(unbrotli(chunkMetaRaw));
126
+ const links = JSON.parse(unbrotli(linksRaw));
127
+ const chunks = chunkMeta.map((chunk, index) => ({
128
+ id: chunk.id,
129
+ documentId: chunk.documentId,
130
+ ordinal: chunk.ordinal,
131
+ content: chunk.content,
132
+ tokenCount: chunk.tokenCount,
133
+ embeddingProvider: chunk.embeddingProvider,
134
+ embedding: chunk.hasEmbedding && meta.dims > 0 ? decodeVector(vectors, index, meta.dims) : [],
135
+ ...(chunk.buckets ? { buckets: chunk.buckets } : {})
136
+ }));
137
+ return {
138
+ version: 1,
139
+ updatedAt: typeof meta.updatedAt === 'string' ? meta.updatedAt : new Date().toISOString(),
140
+ documents,
141
+ chunks,
142
+ links
143
+ };
144
+ };
145
+ // Binary backend. While its store does not yet exist, reads fall back to the
146
+ // legacy JSON index so search/graph stay available mid-migration; the first save
147
+ // materializes the binary store and removes the legacy file.
148
+ export const openBinaryStore = (vaultPath) => {
149
+ const load = async () => (await binaryStoreExists(vaultPath)) ? loadBinary(vaultPath) : readJsonStoredIndex(vaultPath, false);
150
+ const loadStrict = async () => (await binaryStoreExists(vaultPath)) ? loadBinary(vaultPath) : readJsonStoredIndex(vaultPath, true);
151
+ return {
152
+ reset: async () => {
153
+ await persist(vaultPath, []);
154
+ },
155
+ saveDocuments: async (documents) => {
156
+ await persist(vaultPath, documents);
157
+ },
158
+ ...createStoredIndexQueries({ load, loadStrict }),
159
+ close: () => {
160
+ // File-based store has no persistent connection.
161
+ }
162
+ };
163
+ };
@@ -0,0 +1,36 @@
1
+ import { loadBrainlinkConfig } from '../config.js';
2
+ import { openFileIndex } from '../file-index.js';
3
+ import { migrateLegacyIndexToBinary, openBinaryStore } from './binary-store.js';
4
+ const resolveBackend = async (vaultPath) => {
5
+ const config = await loadBrainlinkConfig();
6
+ if (config.storageBackend === 'binary') {
7
+ await migrateLegacyIndexToBinary(vaultPath);
8
+ return openBinaryStore(vaultPath);
9
+ }
10
+ return openFileIndex(vaultPath);
11
+ };
12
+ export const openKnowledgeStore = (vaultPath) => {
13
+ let backend = null;
14
+ // Resolve (and memoize) the backend once per store instance. A config-load
15
+ // failure degrades to the always-available JSON backend rather than throwing.
16
+ const resolve = () => {
17
+ backend ??= resolveBackend(vaultPath).catch(() => openFileIndex(vaultPath));
18
+ return backend;
19
+ };
20
+ return {
21
+ reset: async () => (await resolve()).reset(),
22
+ saveDocuments: async (documents) => (await resolve()).saveDocuments(documents),
23
+ getIndexedDocuments: async (agentId) => (await resolve()).getIndexedDocuments(agentId),
24
+ search: async (query, limit, agentId, mode, queryEmbedding) => (await resolve()).search(query, limit, agentId, mode, queryEmbedding),
25
+ listLinks: async (agentId) => (await resolve()).listLinks(agentId),
26
+ listBacklinks: async (title, agentId) => (await resolve()).listBacklinks(title, agentId),
27
+ getGraph: async (agentId) => (await resolve()).getGraph(agentId),
28
+ getGraphSummary: async (agentId) => (await resolve()).getGraphSummary(agentId),
29
+ getGraphNode: async (id, agentId) => (await resolve()).getGraphNode(id, agentId),
30
+ searchGraphNodeIds: async (query, limit, agentId) => (await resolve()).searchGraphNodeIds(query, limit, agentId),
31
+ listAgents: async () => (await resolve()).listAgents(),
32
+ close: () => {
33
+ // Backends hold no persistent connection; nothing to release.
34
+ }
35
+ };
36
+ };
@@ -0,0 +1,216 @@
1
+ // Shared in-memory query core for the knowledge index. Both storage backends
2
+ // (the JSON file and the binary store) load their data into the same
3
+ // `StoredIndex` shape and then expose identical read semantics through these
4
+ // queries. Keeping the graph/search/link logic here — instead of duplicating it
5
+ // per backend — guarantees the two backends answer the same way and that the
6
+ // only thing a backend owns is how bytes are loaded and persisted.
7
+ import { createEmbeddingBuckets } from '../../domain/embeddings.js';
8
+ import { lexicalFieldScore, normalizeToken, runSearch, tokenize } from '../../application/ranking/run-search.js';
9
+ import { selectSemanticCandidates } from '../semantic-prefilter.js';
10
+ export const emptyIndex = () => ({
11
+ version: 1,
12
+ updatedAt: new Date().toISOString(),
13
+ documents: [],
14
+ chunks: [],
15
+ links: []
16
+ });
17
+ const toGraphLink = (link, documentsById) => {
18
+ const source = documentsById.get(link.fromDocumentId);
19
+ const target = link.toDocumentId ? documentsById.get(link.toDocumentId) : undefined;
20
+ return {
21
+ agentId: source?.agentId ?? 'shared',
22
+ fromTitle: source?.title ?? 'Unknown',
23
+ fromPath: source?.path ?? 'Unknown',
24
+ toTitle: target?.title ?? link.toTitle,
25
+ toPath: target?.path ?? null,
26
+ weight: link.weight,
27
+ priority: link.priority
28
+ };
29
+ };
30
+ const toGraphNode = (document, includeContent) => ({
31
+ id: document.id,
32
+ agentId: document.agentId,
33
+ title: document.title,
34
+ path: document.path,
35
+ content: includeContent ? document.content : '',
36
+ tags: document.tags,
37
+ contextLinks: document.contextLinks ?? []
38
+ });
39
+ // Build the shared read methods over a backend's loaders. The returned object is
40
+ // the read half of the KnowledgeStore port; backends add reset/saveDocuments/
41
+ // close around it.
42
+ export const createStoredIndexQueries = ({ load, loadStrict }) => ({
43
+ getIndexedDocuments: async (agentId) => {
44
+ const index = await load();
45
+ const documents = agentId ? index.documents.filter((document) => document.agentId === agentId) : index.documents;
46
+ const selectedDocumentIds = new Set(documents.map((document) => document.id));
47
+ const chunksByDocumentId = index.chunks.reduce((state, chunk) => {
48
+ if (!selectedDocumentIds.has(chunk.documentId)) {
49
+ return state;
50
+ }
51
+ const current = state.get(chunk.documentId) ?? [];
52
+ current.push(chunk);
53
+ state.set(chunk.documentId, current);
54
+ return state;
55
+ }, new Map());
56
+ const linksByDocumentId = index.links.reduce((state, link) => {
57
+ if (!selectedDocumentIds.has(link.fromDocumentId)) {
58
+ return state;
59
+ }
60
+ const current = state.get(link.fromDocumentId) ?? [];
61
+ current.push(link);
62
+ state.set(link.fromDocumentId, current);
63
+ return state;
64
+ }, new Map());
65
+ return documents
66
+ .map((document) => ({
67
+ document,
68
+ chunks: [...(chunksByDocumentId.get(document.id) ?? [])].sort((left, right) => left.ordinal - right.ordinal),
69
+ links: linksByDocumentId.get(document.id) ?? []
70
+ }))
71
+ .sort((left, right) => left.document.path.localeCompare(right.document.path));
72
+ },
73
+ search: async (query, limit, agentId, mode = 'hybrid', queryEmbedding = []) => {
74
+ const index = await loadStrict();
75
+ const documentsById = new Map(index.documents.map((document) => [document.id, document]));
76
+ const rows = index.chunks.flatMap((chunk) => {
77
+ const document = documentsById.get(chunk.documentId);
78
+ if (!document) {
79
+ return [];
80
+ }
81
+ if (agentId && document.agentId !== agentId) {
82
+ return [];
83
+ }
84
+ return [
85
+ {
86
+ documentId: document.id,
87
+ agentId: document.agentId,
88
+ title: document.title,
89
+ path: document.path,
90
+ chunkId: chunk.id,
91
+ chunkOrdinal: chunk.ordinal,
92
+ content: chunk.content,
93
+ tokenCount: chunk.tokenCount,
94
+ tags: document.tags,
95
+ embedding: chunk.embedding,
96
+ buckets: chunk.buckets
97
+ }
98
+ ];
99
+ });
100
+ // Pure-semantic scoring on a large vault can skip chunks that share no
101
+ // embedding bucket with the query. Hybrid/fts keep every row so lexical
102
+ // matches are never dropped; the prefilter also falls back to a full scan
103
+ // on small or partially-indexed vaults. Candidate selection is the
104
+ // backend's job; the shared ranker then scores and orders the candidates.
105
+ const candidates = mode === 'semantic' && queryEmbedding.length > 0
106
+ ? selectSemanticCandidates(rows, createEmbeddingBuckets(queryEmbedding), limit)
107
+ : rows;
108
+ return runSearch(candidates, query, limit, mode, queryEmbedding);
109
+ },
110
+ listLinks: async (agentId) => {
111
+ const index = await load();
112
+ const documentsById = new Map(index.documents.map((document) => [document.id, document]));
113
+ return index.links
114
+ .filter((link) => {
115
+ const source = documentsById.get(link.fromDocumentId);
116
+ return agentId ? source?.agentId === agentId : true;
117
+ })
118
+ .map((link) => toGraphLink(link, documentsById))
119
+ .sort((left, right) => left.fromTitle.localeCompare(right.fromTitle));
120
+ },
121
+ listBacklinks: async (title, agentId) => {
122
+ const index = await load();
123
+ const titleKey = title.toLowerCase();
124
+ const documentsById = new Map(index.documents.map((document) => [document.id, document]));
125
+ return index.links
126
+ .filter((link) => link.toTitle.toLowerCase() === titleKey)
127
+ .filter((link) => {
128
+ const source = documentsById.get(link.fromDocumentId);
129
+ return agentId ? source?.agentId === agentId : true;
130
+ })
131
+ .map((link) => toGraphLink(link, documentsById))
132
+ .sort((left, right) => right.weight - left.weight || left.fromTitle.localeCompare(right.fromTitle));
133
+ },
134
+ getGraph: async (agentId) => {
135
+ const index = await load();
136
+ const documents = agentId ? index.documents.filter((document) => document.agentId === agentId) : index.documents;
137
+ const documentIds = new Set(documents.map((document) => document.id));
138
+ const edges = index.links
139
+ .filter((link) => documentIds.has(link.fromDocumentId))
140
+ .map((link) => ({
141
+ source: link.fromDocumentId,
142
+ target: link.toDocumentId,
143
+ targetTitle: link.toTitle,
144
+ weight: link.weight,
145
+ priority: link.priority
146
+ }));
147
+ return { nodes: documents.map((document) => toGraphNode(document, true)), edges };
148
+ },
149
+ getGraphSummary: async (agentId) => {
150
+ const index = await load();
151
+ const documents = agentId ? index.documents.filter((document) => document.agentId === agentId) : index.documents;
152
+ const documentIds = new Set(documents.map((document) => document.id));
153
+ const edges = index.links
154
+ .filter((link) => documentIds.has(link.fromDocumentId))
155
+ .map((link) => ({
156
+ source: link.fromDocumentId,
157
+ target: link.toDocumentId,
158
+ targetTitle: link.toTitle,
159
+ weight: link.weight,
160
+ priority: link.priority
161
+ }));
162
+ return { nodes: documents.map((document) => toGraphNode(document, false)), edges };
163
+ },
164
+ getGraphNode: async (id, agentId) => {
165
+ const index = await load();
166
+ const document = index.documents.find((row) => row.id === id && (!agentId || row.agentId === agentId));
167
+ return document ? toGraphNode(document, true) : undefined;
168
+ },
169
+ searchGraphNodeIds: async (query, limit, agentId) => {
170
+ const index = await load();
171
+ const normalized = normalizeToken(query);
172
+ if (normalized.length === 0 || limit <= 0) {
173
+ return [];
174
+ }
175
+ const tokens = tokenize(query);
176
+ const scored = index.documents
177
+ .filter((document) => !agentId || document.agentId === agentId)
178
+ .map((document) => {
179
+ const score = lexicalFieldScore({
180
+ documentId: document.id,
181
+ agentId: document.agentId,
182
+ title: document.title,
183
+ path: document.path,
184
+ chunkId: document.id,
185
+ chunkOrdinal: 0,
186
+ content: document.content,
187
+ tokenCount: 0,
188
+ tags: document.tags,
189
+ embedding: []
190
+ }, tokens);
191
+ return { id: document.id, score };
192
+ })
193
+ .filter((row) => row.score > 0)
194
+ .sort((left, right) => right.score - left.score || left.id.localeCompare(right.id))
195
+ .slice(0, limit);
196
+ return scored.map((row) => row.id);
197
+ },
198
+ listAgents: async () => {
199
+ const index = await load();
200
+ const counts = index.documents.reduce((state, document) => {
201
+ state.set(document.agentId, (state.get(document.agentId) ?? 0) + 1);
202
+ return state;
203
+ }, new Map());
204
+ return Array.from(counts.entries())
205
+ .sort((left, right) => left[0].localeCompare(right[0]))
206
+ .map(([id, documentCount]) => ({ id, documentCount }));
207
+ }
208
+ });
209
+ // Flatten indexed documents into the stored shape persisted by both backends.
210
+ export const toStoredIndex = (documents) => ({
211
+ version: 1,
212
+ updatedAt: new Date().toISOString(),
213
+ documents: documents.map((document) => document.document),
214
+ chunks: documents.flatMap((document) => document.chunks),
215
+ links: documents.flatMap((document) => document.links)
216
+ });
@@ -1,5 +1,5 @@
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
4
  import { guardToolHandler } from './tool-guard.js';
5
5
  export const createBrainlinkMcpServer = () => {
@@ -50,6 +50,11 @@ export const createBrainlinkMcpServer = () => {
50
50
  description: 'Search indexed Brainlink notes with FTS, semantic or hybrid retrieval.',
51
51
  inputSchema: searchInputSchema
52
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);
53
58
  server.registerTool('brainlink_explain', {
54
59
  title: 'Explain Brainlink Search Results',
55
60
  description: 'Explain why indexed notes matched a query, including score components and match reasons.',
@@ -70,6 +75,11 @@ export const createBrainlinkMcpServer = () => {
70
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.',
71
76
  inputSchema: addNoteInputSchema
72
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);
73
83
  server.registerTool('brainlink_remember', {
74
84
  title: 'Capture Assisted Brainlink Memory',
75
85
  description: 'Capture durable memory with inferred title, tags and Context Links. Supports dry-run preview before writing.',
@@ -95,6 +105,11 @@ export const createBrainlinkMcpServer = () => {
95
105
  description: 'Delete a durable Markdown note from the vault after explicit confirmation. Select by title or path; reindexes by default.',
96
106
  inputSchema: deleteNoteInputSchema
97
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);
98
113
  server.registerTool('brainlink_volatile_add', {
99
114
  title: 'Add Volatile Brainlink Memory',
100
115
  description: 'Write temporary agent-decided memory with TTL. Use for transient task state without polluting durable Markdown memory.',
@@ -6,6 +6,7 @@ import { getGraphContexts } from '../../application/get-graph-contexts.js';
6
6
  import { explainSearchResults, suggestBrokenLinkFixes, suggestContextLinks } from '../../application/memory-suggestions.js';
7
7
  import { buildActionableDoctor } from '../../application/operational-workflows.js';
8
8
  import { searchKnowledge } from '../../application/search-knowledge.js';
9
+ import { findSimilarNotes } from '../../application/find-similar-notes.js';
9
10
  import { sanitizeContextStrategy, sanitizeSearchMode } from '../../infrastructure/config.js';
10
11
  import { clearContextPacks, listContextPacks } from '../../infrastructure/context-packs.js';
11
12
  import { getBootstrapPolicy, getBootstrapSessionStatus, getContextSessionStatus, touchContextSession } from '../../infrastructure/session-state.js';
@@ -40,6 +41,14 @@ export const explainInputSchema = {
40
41
  query: z.string().min(1).describe('Search query to explain.'),
41
42
  limit: optionalPositiveInteger().describe('Maximum result count.')
42
43
  };
44
+ export const similarNotesInputSchema = {
45
+ ...vaultInput,
46
+ ...agentInput,
47
+ ...searchModeInput,
48
+ title: z.string().min(1).optional().describe('Title of the note to find neighbors for. Use agent to disambiguate.'),
49
+ path: z.string().min(1).optional().describe('Vault-relative or absolute path of the note to find neighbors for.'),
50
+ limit: optionalPositiveInteger().describe('Maximum number of similar notes to return. Defaults to the configured search limit.')
51
+ };
43
52
  export const validateInputSchema = {
44
53
  ...vaultInput,
45
54
  ...agentInput
@@ -162,6 +171,30 @@ export const searchTool = async (input) => {
162
171
  results
163
172
  });
164
173
  };
174
+ export const similarNotesTool = async (input) => {
175
+ const context = await resolveExecutionContext(input);
176
+ const readiness = await ensureBootstrapReady(context, input, 'brainlink_similar_notes');
177
+ if (readiness.preflight) {
178
+ return readiness.preflight;
179
+ }
180
+ const mode = sanitizeSearchMode(input.mode, context.defaults.defaultSearchMode);
181
+ const limit = input.limit ?? context.defaults.defaultSearchLimit;
182
+ const result = await findSimilarNotes(context.vault, {
183
+ title: input.title,
184
+ path: input.path,
185
+ agentId: context.agent,
186
+ limit,
187
+ mode
188
+ });
189
+ return jsonResult({
190
+ vault: context.vault,
191
+ agent: context.agent,
192
+ mode,
193
+ limit,
194
+ ...(readiness.bootstrap ? { bootstrap: readiness.bootstrap } : {}),
195
+ ...result
196
+ });
197
+ };
165
198
  export const explainTool = async (input) => {
166
199
  const context = await resolveExecutionContext(input);
167
200
  const readiness = await ensureBootstrapReady(context, input, 'brainlink_explain');
@@ -59,6 +59,38 @@ export const deleteNoteInputSchema = {
59
59
  confirm: z.boolean().describe('Must be true to confirm deletion.'),
60
60
  autoIndex: z.boolean().optional().default(true).describe('Reindex vault after deletion. Defaults to true.')
61
61
  };
62
+ export const addNotesInputSchema = {
63
+ ...vaultInput,
64
+ ...agentInput,
65
+ notes: z
66
+ .array(z.object({
67
+ title: z.string().min(1).describe('Markdown note title.'),
68
+ content: z.string().min(1).describe('Durable Markdown memory. Include [[wiki links]] and #tags to connect it.')
69
+ }))
70
+ .min(1)
71
+ .max(100)
72
+ .describe('Notes to add in a single batch. The vault is reindexed once after all notes are written.'),
73
+ allowSensitive: z.boolean().optional().default(false).describe('Allow content that looks like a secret.'),
74
+ autoIndex: z.boolean().optional().default(true).describe('Reindex vault once after writing all notes.'),
75
+ autoContextLinks: z
76
+ .boolean()
77
+ .optional()
78
+ .describe('Automatically add canonical Context Links to the inferred context hub. Defaults to Brainlink config.')
79
+ };
80
+ export const deleteNotesInputSchema = {
81
+ ...vaultInput,
82
+ ...agentInput,
83
+ notes: z
84
+ .array(z.object({
85
+ title: z.string().min(1).optional().describe('Note title to delete.'),
86
+ path: z.string().min(1).optional().describe('Vault-relative or absolute Markdown note path to delete.')
87
+ }))
88
+ .min(1)
89
+ .max(100)
90
+ .describe('Notes to delete in a single batch, each selected by exactly one of title or path.'),
91
+ confirm: z.boolean().describe('Must be true to confirm deletion of every listed note.'),
92
+ autoIndex: z.boolean().optional().default(true).describe('Reindex vault once after all deletions.')
93
+ };
62
94
  export const volatileAddInputSchema = {
63
95
  ...vaultInput,
64
96
  ...agentInput,
@@ -196,6 +228,80 @@ export const deleteNoteTool = async (input) => {
196
228
  ...result
197
229
  });
198
230
  };
231
+ export const addNotesTool = async (input) => {
232
+ const context = await resolveExecutionContext(input);
233
+ const autoContextLinks = input.autoContextLinks ?? context.config.autoCanonicalContextLinks;
234
+ // Per-note isolation mirrors deleteNotesTool: one failing note (secret,
235
+ // validation, FS error) is reported without discarding the notes that did
236
+ // write or skipping the single end-of-batch reindex.
237
+ const notes = [];
238
+ for (const note of input.notes) {
239
+ try {
240
+ const added = await addNoteWithMetadata(context.vault, note.title, note.content, context.agent, {
241
+ allowSensitive: input.allowSensitive,
242
+ autoContextLinks
243
+ });
244
+ notes.push({
245
+ ok: true,
246
+ title: note.title,
247
+ path: added.path,
248
+ writeConnectivity: {
249
+ autoLinked: added.autoLinked,
250
+ linkTarget: added.linkTarget,
251
+ context: added.context,
252
+ hubCreated: added.hubCreated,
253
+ guaranteedEdge: added.autoLinked
254
+ }
255
+ });
256
+ }
257
+ catch (error) {
258
+ notes.push({ ok: false, title: note.title, error: error instanceof Error ? error.message : String(error) });
259
+ }
260
+ }
261
+ const added = notes.filter((entry) => entry.ok === true).length;
262
+ const index = isTruthy(input.autoIndex) && added > 0 ? await indexVault(context.vault) : undefined;
263
+ return jsonResult({
264
+ vault: context.vault,
265
+ agent: context.agent,
266
+ added,
267
+ failed: notes.length - added,
268
+ notes,
269
+ ...(index ? { index } : {})
270
+ });
271
+ };
272
+ export const deleteNotesTool = async (input) => {
273
+ const context = await resolveExecutionContext(input);
274
+ if (!input.confirm) {
275
+ throw new Error('Refusing to delete notes without explicit confirmation.');
276
+ }
277
+ const results = [];
278
+ for (const note of input.notes) {
279
+ try {
280
+ // Defer indexing: reindex once after the whole batch instead of per note.
281
+ const result = await deleteNote(context.vault, {
282
+ title: note.title,
283
+ path: note.path,
284
+ agentId: context.agent,
285
+ confirm: true,
286
+ autoIndex: false
287
+ });
288
+ results.push({ ok: true, title: result.title, path: result.relativePath });
289
+ }
290
+ catch (error) {
291
+ results.push({ ok: false, selector: note, error: error instanceof Error ? error.message : String(error) });
292
+ }
293
+ }
294
+ const deleted = results.filter((entry) => entry.ok === true).length;
295
+ const index = isTruthy(input.autoIndex) && deleted > 0 ? await indexVault(context.vault) : undefined;
296
+ return jsonResult({
297
+ vault: context.vault,
298
+ agent: context.agent,
299
+ deleted,
300
+ failed: results.length - deleted,
301
+ results,
302
+ ...(index ? { index } : {})
303
+ });
304
+ };
199
305
  export const volatileAddTool = async (input) => {
200
306
  const context = await resolveExecutionContext(input);
201
307
  const entry = await addVolatileMemory(context.vault, input.content, context.agent ?? 'shared', input.ttlMinutes ?? 240, input.tags);
package/dist/mcp/tools.js CHANGED
@@ -1,3 +1,3 @@
1
- export { brokenLinksInputSchema, brokenLinksTool, contextInputSchema, contextPacksInputSchema, contextPacksTool, contextTool, doctorActionsInputSchema, doctorActionsTool, explainInputSchema, explainTool, graphContextsInputSchema, graphContextsTool, graphInputSchema, graphTool, orphansInputSchema, orphansTool, recommendationsInputSchema, recommendationsTool, searchInputSchema, searchTool, statsInputSchema, statsTool, suggestLinksInputSchema, suggestLinksTool, validateInputSchema, validateTool, versionInputSchema, versionTool } from './tools/read-tools.js';
2
- export { addFileInputSchema, addFileTool, addNoteInputSchema, addNoteTool, deleteNoteInputSchema, deleteNoteTool, inboxAddInputSchema, inboxAddTool, inboxListInputSchema, inboxListTool, inboxProcessInputSchema, inboxProcessTool, rememberInputSchema, rememberTool, volatileAddInputSchema, volatileAddTool, volatileClearInputSchema, volatileClearTool } from './tools/write-tools.js';
1
+ export { brokenLinksInputSchema, brokenLinksTool, contextInputSchema, contextPacksInputSchema, contextPacksTool, contextTool, doctorActionsInputSchema, doctorActionsTool, explainInputSchema, explainTool, graphContextsInputSchema, graphContextsTool, graphInputSchema, graphTool, orphansInputSchema, orphansTool, recommendationsInputSchema, recommendationsTool, searchInputSchema, searchTool, similarNotesInputSchema, similarNotesTool, statsInputSchema, statsTool, suggestLinksInputSchema, suggestLinksTool, validateInputSchema, validateTool, versionInputSchema, versionTool } from './tools/read-tools.js';
2
+ export { addFileInputSchema, addFileTool, addNoteInputSchema, addNoteTool, addNotesInputSchema, addNotesTool, deleteNoteInputSchema, deleteNoteTool, deleteNotesInputSchema, deleteNotesTool, inboxAddInputSchema, inboxAddTool, inboxListInputSchema, inboxListTool, inboxProcessInputSchema, inboxProcessTool, rememberInputSchema, rememberTool, volatileAddInputSchema, volatileAddTool, volatileClearInputSchema, volatileClearTool } from './tools/write-tools.js';
3
3
  export { bootstrapInputSchema, bootstrapTool, canonicalizeContextLinksInputSchema, canonicalizeContextLinksTool, dedupeInputSchema, dedupeResolveInputSchema, dedupeResolveTool, dedupeTool, indexInputSchema, indexTool, policyInputSchema, policyTool, projectInitInputSchema, projectInitTool, repairLinksInputSchema, repairLinksTool, sessionCloseInputSchema, sessionCloseTool, syncInputSchema, syncTool } from './tools/maintenance-tools.js';