@andespindola/brainlink 0.1.0-beta.12 → 0.1.0-beta.121

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 (52) hide show
  1. package/AGENTS.md +8 -5
  2. package/CHANGELOG.md +26 -2
  3. package/CONTRIBUTING.md +2 -2
  4. package/COPYRIGHT.md +5 -0
  5. package/README.md +138 -18
  6. package/SECURITY.md +1 -1
  7. package/dist/application/analyze-vault.js +1 -9
  8. package/dist/application/build-context.js +56 -1
  9. package/dist/application/dedupe-notes.js +226 -0
  10. package/dist/application/frontend/client-css.js +93 -45
  11. package/dist/application/frontend/client-html.js +34 -25
  12. package/dist/application/frontend/client-js.js +2698 -181
  13. package/dist/application/frontend/client-worker-js.js +66 -0
  14. package/dist/application/get-graph-layout.js +2 -2
  15. package/dist/application/get-graph-node.js +3 -3
  16. package/dist/application/get-graph-summary.js +3 -3
  17. package/dist/application/get-graph.js +3 -3
  18. package/dist/application/import-legacy-sqlite.js +296 -0
  19. package/dist/application/index-vault.js +250 -24
  20. package/dist/application/list-agents.js +3 -3
  21. package/dist/application/list-links.js +5 -5
  22. package/dist/application/offline-pack-backup.js +44 -0
  23. package/dist/application/search-graph-node-ids.js +3 -3
  24. package/dist/application/search-knowledge.js +6 -6
  25. package/dist/application/server/routes.js +90 -1
  26. package/dist/application/start-server.js +75 -4
  27. package/dist/application/watch-vault.js +23 -2
  28. package/dist/benchmarks/large-vault.js +1 -1
  29. package/dist/cli/commands/agent-commands.js +7 -0
  30. package/dist/cli/commands/write-commands.js +818 -8
  31. package/dist/domain/context.js +53 -11
  32. package/dist/domain/graph-layout.js +177 -3
  33. package/dist/domain/middle-out.js +18 -0
  34. package/dist/infrastructure/config.js +38 -0
  35. package/dist/infrastructure/file-index.js +358 -0
  36. package/dist/infrastructure/file-system-vault.js +15 -0
  37. package/dist/infrastructure/index-state.js +56 -0
  38. package/dist/infrastructure/private-pack-codec.js +71 -10
  39. package/dist/infrastructure/search-packs.js +313 -17
  40. package/dist/mcp/server.js +11 -1
  41. package/dist/mcp/tools.js +62 -0
  42. package/docs/AGENT_USAGE.md +96 -17
  43. package/docs/ARCHITECTURE.md +22 -27
  44. package/docs/QUICKSTART.md +7 -0
  45. package/package.json +6 -4
  46. package/dist/infrastructure/sqlite/document-writer.js +0 -51
  47. package/dist/infrastructure/sqlite/graph-reader.js +0 -267
  48. package/dist/infrastructure/sqlite/recovery.js +0 -83
  49. package/dist/infrastructure/sqlite/schema.js +0 -114
  50. package/dist/infrastructure/sqlite/search-reader.js +0 -188
  51. package/dist/infrastructure/sqlite/types.js +0 -1
  52. package/dist/infrastructure/sqlite-index.js +0 -38
@@ -1,11 +1,19 @@
1
1
  import { gunzipSync } from 'node:zlib';
2
2
  import { mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
+ import { middleOutIndices } from '../domain/middle-out.js';
4
5
  import { decodePrivatePack, encodePrivatePack, isPrivatePackPayload } from './private-pack-codec.js';
5
6
  const packsDirectoryName = 'search-packs';
6
7
  const manifestFileName = 'manifest.json';
7
- const rowChunkSize = 5_000;
8
+ const defaultBuildOptions = {
9
+ rowChunkSize: 5_000,
10
+ compressionLevel: 5,
11
+ useDictionary: true
12
+ };
8
13
  const queryTokenPattern = /[\p{L}\p{N}_-]+/gu;
14
+ const bloomBytes = 256;
15
+ const bloomBitSize = bloomBytes * 8;
16
+ const bloomSeeds = [0x9e3779b1, 0x85ebca6b, 0xc2b2ae35];
9
17
  const toPackDirectory = (vaultPath) => join(vaultPath, '.brainlink', packsDirectoryName);
10
18
  const toManifestPath = (vaultPath) => join(toPackDirectory(vaultPath), manifestFileName);
11
19
  const parseRowsFromPack = async (vaultPath, content) => {
@@ -15,7 +23,29 @@ const parseRowsFromPack = async (vaultPath, content) => {
15
23
  .split('\n')
16
24
  .map((line) => line.trim())
17
25
  .filter((line) => line.length > 0)
18
- .map((line) => JSON.parse(line));
26
+ .map((line) => JSON.parse(line))
27
+ .flatMap((row) => {
28
+ if (typeof row.documentId !== 'string' ||
29
+ typeof row.agentId !== 'string' ||
30
+ typeof row.title !== 'string' ||
31
+ typeof row.path !== 'string' ||
32
+ typeof row.chunkId !== 'string' ||
33
+ typeof row.content !== 'string') {
34
+ return [];
35
+ }
36
+ return [
37
+ {
38
+ documentId: row.documentId,
39
+ agentId: row.agentId,
40
+ title: row.title,
41
+ path: row.path,
42
+ chunkId: row.chunkId,
43
+ chunkOrdinal: typeof row.chunkOrdinal === 'number' ? row.chunkOrdinal : 0,
44
+ content: row.content,
45
+ tags: Array.isArray(row.tags) ? row.tags.filter((item) => typeof item === 'string') : []
46
+ }
47
+ ];
48
+ });
19
49
  };
20
50
  const toRows = (documents) => documents.flatMap((document) => document.chunks.map((chunk) => ({
21
51
  documentId: document.document.id,
@@ -23,12 +53,121 @@ const toRows = (documents) => documents.flatMap((document) => document.chunks.ma
23
53
  title: document.document.title,
24
54
  path: document.document.path,
25
55
  chunkId: chunk.id,
56
+ chunkOrdinal: chunk.ordinal,
26
57
  content: chunk.content,
27
58
  tags: document.document.tags
28
59
  })));
29
60
  const writeManifest = async (vaultPath, manifest) => {
30
61
  await writeFile(toManifestPath(vaultPath), `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
31
62
  };
63
+ const readManifest = async (vaultPath) => {
64
+ try {
65
+ const parsed = JSON.parse(await readFile(toManifestPath(vaultPath), 'utf8'));
66
+ if (parsed.version === 2 && parsed.format === 'private-v2') {
67
+ return {
68
+ version: 2,
69
+ createdAt: typeof parsed.createdAt === 'string' ? parsed.createdAt : new Date().toISOString(),
70
+ packCount: typeof parsed.packCount === 'number' ? parsed.packCount : 0,
71
+ recordCount: typeof parsed.recordCount === 'number' ? parsed.recordCount : 0,
72
+ format: 'private-v2'
73
+ };
74
+ }
75
+ if (parsed.version === 3 && parsed.format === 'private-v2') {
76
+ const packIndex = Array.isArray(parsed.packIndex)
77
+ ? parsed.packIndex.flatMap((entry) => {
78
+ if (!entry || typeof entry !== 'object') {
79
+ return [];
80
+ }
81
+ const candidate = entry;
82
+ if (typeof candidate.fileName !== 'string' || typeof candidate.tokenBloomB64 !== 'string') {
83
+ return [];
84
+ }
85
+ return [
86
+ {
87
+ fileName: candidate.fileName,
88
+ recordCount: typeof candidate.recordCount === 'number' ? candidate.recordCount : 0,
89
+ agents: Array.isArray(candidate.agents) ? candidate.agents.filter((item) => typeof item === 'string') : [],
90
+ tokenBloomB64: candidate.tokenBloomB64
91
+ }
92
+ ];
93
+ })
94
+ : [];
95
+ return {
96
+ version: 3,
97
+ createdAt: typeof parsed.createdAt === 'string' ? parsed.createdAt : new Date().toISOString(),
98
+ packCount: typeof parsed.packCount === 'number' ? parsed.packCount : packIndex.length,
99
+ recordCount: typeof parsed.recordCount === 'number' ? parsed.recordCount : 0,
100
+ format: 'private-v2',
101
+ packIndex,
102
+ ...(parsed.packConfig && typeof parsed.packConfig === 'object'
103
+ ? {
104
+ packConfig: {
105
+ rowChunkSize: typeof parsed.packConfig.rowChunkSize === 'number'
106
+ ? parsed.packConfig.rowChunkSize
107
+ : defaultBuildOptions.rowChunkSize,
108
+ compressionLevel: typeof parsed.packConfig.compressionLevel === 'number'
109
+ ? parsed.packConfig.compressionLevel
110
+ : defaultBuildOptions.compressionLevel,
111
+ useDictionary: typeof parsed.packConfig.useDictionary === 'boolean'
112
+ ? parsed.packConfig.useDictionary
113
+ : defaultBuildOptions.useDictionary
114
+ }
115
+ }
116
+ : {}),
117
+ ...(parsed.compression &&
118
+ typeof parsed.compression === 'object' &&
119
+ typeof parsed.compression.inputBytes === 'number' &&
120
+ typeof parsed.compression.outputBytes === 'number' &&
121
+ typeof parsed.compression.ratio === 'number' &&
122
+ typeof parsed.compression.savedBytes === 'number'
123
+ ? {
124
+ compression: {
125
+ inputBytes: parsed.compression.inputBytes,
126
+ outputBytes: parsed.compression.outputBytes,
127
+ ratio: parsed.compression.ratio,
128
+ savedBytes: parsed.compression.savedBytes
129
+ }
130
+ }
131
+ : {})
132
+ };
133
+ }
134
+ return null;
135
+ }
136
+ catch {
137
+ return null;
138
+ }
139
+ };
140
+ export const ensureSearchPackManifest = async (vaultPath) => {
141
+ const manifest = await readManifest(vaultPath);
142
+ if (manifest) {
143
+ return {
144
+ repaired: false,
145
+ source: 'not-needed',
146
+ packCount: manifest.packCount
147
+ };
148
+ }
149
+ const files = await sortedPackFiles(vaultPath);
150
+ const packFiles = files.filter((file) => file.endsWith('.blpk'));
151
+ if (packFiles.length === 0) {
152
+ return {
153
+ repaired: false,
154
+ source: 'no-packs',
155
+ packCount: 0
156
+ };
157
+ }
158
+ await writeManifest(vaultPath, {
159
+ version: 2,
160
+ createdAt: new Date().toISOString(),
161
+ packCount: packFiles.length,
162
+ recordCount: 0,
163
+ format: 'private-v2'
164
+ });
165
+ return {
166
+ repaired: true,
167
+ source: 'existing-packs',
168
+ packCount: packFiles.length
169
+ };
170
+ };
32
171
  const chunkRows = (rows, size) => {
33
172
  const chunks = [];
34
173
  for (let index = 0; index < rows.length; index += size) {
@@ -57,6 +196,51 @@ const countOccurrences = (text, token) => {
57
196
  }
58
197
  return hits;
59
198
  };
199
+ const hashToken = (token, seed) => {
200
+ let hash = seed >>> 0;
201
+ for (let index = 0; index < token.length; index += 1) {
202
+ hash ^= token.charCodeAt(index);
203
+ hash = Math.imul(hash, 16777619) >>> 0;
204
+ }
205
+ return hash >>> 0;
206
+ };
207
+ const createBloom = () => new Uint8Array(bloomBytes);
208
+ const bloomAdd = (bloom, token) => {
209
+ bloomSeeds.forEach((seed) => {
210
+ const bit = hashToken(token, seed) % bloomBitSize;
211
+ bloom[Math.floor(bit / 8)] |= 1 << (bit % 8);
212
+ });
213
+ };
214
+ const bloomMayContain = (bloom, token) => bloomSeeds.every((seed) => {
215
+ const bit = hashToken(token, seed) % bloomBitSize;
216
+ return (bloom[Math.floor(bit / 8)] & (1 << (bit % 8))) !== 0;
217
+ });
218
+ const bloomFromRows = (rows) => {
219
+ const bloom = createBloom();
220
+ rows.forEach((row) => {
221
+ tokenize([row.title, row.path, row.tags.join(' '), row.content].join(' ')).forEach((token) => bloomAdd(bloom, token));
222
+ });
223
+ return bloom;
224
+ };
225
+ const bloomToBase64 = (bloom) => Buffer.from(bloom).toString('base64url');
226
+ const bloomFromBase64 = (value) => {
227
+ try {
228
+ const decoded = Buffer.from(value, 'base64url');
229
+ if (decoded.byteLength === bloomBytes) {
230
+ return {
231
+ bloom: new Uint8Array(decoded),
232
+ valid: true
233
+ };
234
+ }
235
+ }
236
+ catch {
237
+ // fallback below
238
+ }
239
+ return {
240
+ bloom: createBloom(),
241
+ valid: false
242
+ };
243
+ };
60
244
  const computeTextScore = (row, tokens) => {
61
245
  if (tokens.length === 0) {
62
246
  return 0;
@@ -79,6 +263,7 @@ const toSearchResult = (row, score) => ({
79
263
  title: row.title,
80
264
  path: row.path,
81
265
  chunkId: row.chunkId,
266
+ chunkOrdinal: row.chunkOrdinal,
82
267
  content: row.content,
83
268
  score,
84
269
  textScore: score,
@@ -100,47 +285,158 @@ const sortedPackFiles = async (vaultPath) => {
100
285
  throw error;
101
286
  }
102
287
  };
103
- export const buildSearchPacks = async (vaultPath, documents) => {
288
+ const writeRowsAsPrivatePacks = async (vaultPath, rows, clearExisting, options) => {
289
+ const startedAt = process.hrtime.bigint();
104
290
  const directory = toPackDirectory(vaultPath);
105
- const rows = toRows(documents);
106
291
  await mkdir(directory, { recursive: true });
107
- const current = await readdir(directory);
108
- await Promise.all(current
109
- .filter((name) => name.endsWith('.blpk') || name.endsWith('.jsonl.gz') || name === manifestFileName)
110
- .map((name) => rm(join(directory, name), { force: true })));
111
- const chunks = chunkRows(rows, rowChunkSize);
112
- await Promise.all(chunks.map(async (chunk, index) => {
292
+ if (clearExisting) {
293
+ const current = await readdir(directory);
294
+ await Promise.all(current
295
+ .filter((name) => name.endsWith('.blpk') || name.endsWith('.jsonl.gz') || name === manifestFileName)
296
+ .map((name) => rm(join(directory, name), { force: true })));
297
+ }
298
+ const chunks = chunkRows(rows, options.rowChunkSize);
299
+ const packIndex = [];
300
+ let inputBytes = 0;
301
+ let outputBytes = 0;
302
+ for (let index = 0; index < chunks.length; index += 1) {
303
+ const chunk = chunks[index];
113
304
  const fileName = `pack-${String(index + 1).padStart(4, '0')}.blpk`;
114
305
  const serialized = `${chunk.map((row) => JSON.stringify(row)).join('\n')}\n`;
115
- const compressed = await encodePrivatePack(vaultPath, Buffer.from(serialized, 'utf8'));
306
+ const compressed = await encodePrivatePack(vaultPath, Buffer.from(serialized, 'utf8'), {
307
+ compressionLevel: options.compressionLevel,
308
+ useDictionary: options.useDictionary
309
+ });
310
+ const tokenBloomB64 = bloomToBase64(bloomFromRows(chunk));
116
311
  await writeFile(join(directory, fileName), compressed);
117
- }));
312
+ inputBytes += Buffer.byteLength(serialized, 'utf8');
313
+ outputBytes += compressed.byteLength;
314
+ packIndex.push({
315
+ fileName,
316
+ recordCount: chunk.length,
317
+ agents: Array.from(new Set(chunk.map((row) => row.agentId))).sort((left, right) => left.localeCompare(right)),
318
+ tokenBloomB64
319
+ });
320
+ }
118
321
  await writeManifest(vaultPath, {
119
- version: 2,
322
+ version: 3,
120
323
  createdAt: new Date().toISOString(),
121
324
  packCount: chunks.length,
122
325
  recordCount: rows.length,
123
- format: 'private-v2'
326
+ format: 'private-v2',
327
+ packIndex,
328
+ packConfig: {
329
+ rowChunkSize: options.rowChunkSize,
330
+ compressionLevel: options.compressionLevel,
331
+ useDictionary: options.useDictionary
332
+ },
333
+ compression: {
334
+ inputBytes,
335
+ outputBytes,
336
+ ratio: outputBytes / Math.max(inputBytes, 1),
337
+ savedBytes: Math.max(inputBytes - outputBytes, 0)
338
+ }
124
339
  });
340
+ const durationMs = Number(process.hrtime.bigint() - startedAt) / 1_000_000;
341
+ const safeInput = Math.max(inputBytes, 1);
342
+ const savedBytes = Math.max(inputBytes - outputBytes, 0);
125
343
  return {
126
344
  packCount: chunks.length,
127
- recordCount: rows.length
345
+ recordCount: rows.length,
346
+ compression: {
347
+ inputBytes,
348
+ outputBytes,
349
+ ratio: outputBytes / safeInput,
350
+ savedBytes
351
+ },
352
+ durationMs
128
353
  };
129
354
  };
355
+ const selectCandidatePackFiles = async (vaultPath, tokens, agentId) => {
356
+ const allFiles = await sortedPackFiles(vaultPath);
357
+ if (allFiles.length === 0) {
358
+ return [];
359
+ }
360
+ const manifest = await readManifest(vaultPath);
361
+ if (!manifest || manifest.version !== 3 || !Array.isArray(manifest.packIndex)) {
362
+ return allFiles;
363
+ }
364
+ const normalizedAgent = agentId?.trim();
365
+ const byAgent = manifest.packIndex.filter((entry) => normalizedAgent ? entry.agents.includes(normalizedAgent) : true);
366
+ if (tokens.length === 0) {
367
+ return byAgent.map((entry) => entry.fileName);
368
+ }
369
+ let hasInvalidBloomIndex = false;
370
+ const byToken = byAgent.filter((entry) => {
371
+ const decoded = bloomFromBase64(entry.tokenBloomB64);
372
+ if (!decoded.valid) {
373
+ hasInvalidBloomIndex = true;
374
+ return true;
375
+ }
376
+ return tokens.some((token) => bloomMayContain(decoded.bloom, token));
377
+ });
378
+ // Lossless guarantee: if compressed metadata is partially invalid, do not prune packs.
379
+ if (hasInvalidBloomIndex) {
380
+ return byAgent.map((entry) => entry.fileName);
381
+ }
382
+ if (byToken.length > 0) {
383
+ return byToken.map((entry) => entry.fileName);
384
+ }
385
+ return byAgent.length > 0 ? byAgent.map((entry) => entry.fileName) : allFiles;
386
+ };
387
+ export const buildSearchPacks = async (vaultPath, documents, options) => {
388
+ const resolvedOptions = {
389
+ rowChunkSize: options?.rowChunkSize ?? defaultBuildOptions.rowChunkSize,
390
+ compressionLevel: options?.compressionLevel ?? defaultBuildOptions.compressionLevel,
391
+ useDictionary: options?.useDictionary ?? defaultBuildOptions.useDictionary
392
+ };
393
+ return writeRowsAsPrivatePacks(vaultPath, toRows(documents), true, resolvedOptions);
394
+ };
395
+ export const ensurePrivatePacksFromLegacyIndex = async (vaultPath) => {
396
+ const files = await sortedPackFiles(vaultPath);
397
+ if (files.some((file) => file.endsWith('.blpk'))) {
398
+ return { imported: false };
399
+ }
400
+ const legacyPackFiles = files.filter((file) => file.endsWith('.jsonl.gz'));
401
+ if (legacyPackFiles.length > 0) {
402
+ const rows = [];
403
+ for (const file of legacyPackFiles) {
404
+ const parsed = await parseRowsFromPack(vaultPath, await readFile(join(toPackDirectory(vaultPath), file)));
405
+ rows.push(...parsed);
406
+ }
407
+ const report = await writeRowsAsPrivatePacks(vaultPath, rows, true, defaultBuildOptions);
408
+ return {
409
+ imported: true,
410
+ source: 'legacy-packs',
411
+ ...report
412
+ };
413
+ }
414
+ return { imported: false };
415
+ };
416
+ export const toSearchPackBuildOptions = (config) => ({
417
+ rowChunkSize: config.searchPack.rowChunkSize,
418
+ compressionLevel: config.searchPack.compressionLevel,
419
+ useDictionary: config.searchPack.useDictionary
420
+ });
130
421
  export const searchInPacks = async (vaultPath, query, limit, agentId) => {
131
422
  const normalizedAgent = agentId?.trim();
132
423
  const tokens = tokenize(query);
133
424
  if (limit <= 0 || tokens.length === 0) {
134
425
  return [];
135
426
  }
136
- const files = await sortedPackFiles(vaultPath);
427
+ const files = await selectCandidatePackFiles(vaultPath, tokens, normalizedAgent);
137
428
  if (files.length === 0) {
138
429
  return [];
139
430
  }
140
431
  const scored = [];
141
432
  for (const file of files) {
142
433
  const rows = await parseRowsFromPack(vaultPath, await readFile(join(toPackDirectory(vaultPath), file)));
143
- rows.forEach((row) => {
434
+ const traversal = middleOutIndices(rows.length, Math.floor(rows.length / 2));
435
+ traversal.forEach((rowIndex) => {
436
+ const row = rows[rowIndex];
437
+ if (!row) {
438
+ return;
439
+ }
144
440
  if (normalizedAgent && row.agentId !== normalizedAgent) {
145
441
  return;
146
442
  }
@@ -2,7 +2,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
2
  import { readFileSync } from 'node:fs';
3
3
  import { dirname, join } from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
- import { addNoteInputSchema, addFileInputSchema, addFileTool, addNoteTool, brokenLinksInputSchema, brokenLinksTool, bootstrapInputSchema, bootstrapTool, contextInputSchema, contextTool, graphInputSchema, graphTool, indexInputSchema, indexTool, orphansInputSchema, orphansTool, policyInputSchema, policyTool, recommendationsInputSchema, recommendationsTool, searchInputSchema, searchTool, statsInputSchema, statsTool, syncInputSchema, syncTool, validateInputSchema, validateTool } from './tools.js';
5
+ import { addNoteInputSchema, addFileInputSchema, addFileTool, addNoteTool, dedupeInputSchema, dedupeResolveInputSchema, dedupeResolveTool, dedupeTool, brokenLinksInputSchema, brokenLinksTool, bootstrapInputSchema, bootstrapTool, contextInputSchema, contextTool, graphInputSchema, graphTool, indexInputSchema, indexTool, orphansInputSchema, orphansTool, policyInputSchema, policyTool, recommendationsInputSchema, recommendationsTool, searchInputSchema, searchTool, statsInputSchema, statsTool, syncInputSchema, syncTool, validateInputSchema, validateTool } from './tools.js';
6
6
  const readPackageVersion = () => {
7
7
  const packagePath = join(dirname(fileURLToPath(import.meta.url)), '../../package.json');
8
8
  const metadata = JSON.parse(readFileSync(packagePath, 'utf8'));
@@ -40,6 +40,16 @@ export const createBrainlinkMcpServer = () => {
40
40
  description: 'Search indexed Brainlink notes with FTS, semantic or hybrid retrieval.',
41
41
  inputSchema: searchInputSchema
42
42
  }, searchTool);
43
+ server.registerTool('brainlink_dedupe', {
44
+ title: 'Detect Duplicate Notes',
45
+ description: 'Detect possible duplicate notes using exact content hash and semantic similarity scoring.',
46
+ inputSchema: dedupeInputSchema
47
+ }, dedupeTool);
48
+ server.registerTool('brainlink_resolve_duplicate', {
49
+ title: 'Resolve Duplicate Notes',
50
+ description: 'Resolve a duplicate pair with merge, link or ignore. Non-merge actions still create low-priority related edges.',
51
+ inputSchema: dedupeResolveInputSchema
52
+ }, dedupeResolveTool);
43
53
  server.registerTool('brainlink_add_note', {
44
54
  title: 'Add Brainlink Note',
45
55
  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.',
package/dist/mcp/tools.js CHANGED
@@ -4,6 +4,7 @@ import { z } from 'zod';
4
4
  import { getBrokenLinksReport, getOrphansReport, getStats, validateVault } from '../application/analyze-vault.js';
5
5
  import { addNoteWithMetadata } from '../application/add-note.js';
6
6
  import { buildContextPackage } from '../application/build-context.js';
7
+ import { resolveDuplicateNotes, scanDuplicateNotes } from '../application/dedupe-notes.js';
7
8
  import { getGraph } from '../application/get-graph.js';
8
9
  import { indexVault } from '../application/index-vault.js';
9
10
  import { searchKnowledge } from '../application/search-knowledge.js';
@@ -311,6 +312,20 @@ export const recommendationsInputSchema = {
311
312
  limit: optionalPositiveInteger().describe('Optional context limit override for generated recommendations.'),
312
313
  tokens: optionalPositiveInteger().describe('Optional context token budget override for generated recommendations.')
313
314
  };
315
+ export const dedupeInputSchema = {
316
+ ...vaultInput,
317
+ ...agentInput,
318
+ limit: optionalPositiveInteger().describe('Maximum duplicate candidate pairs to return.'),
319
+ minScore: z.number().min(0).max(1).optional().describe('Minimum semantic similarity score between 0 and 1.'),
320
+ semantic: z.boolean().optional().default(true).describe('Enable semantic duplicate detection in addition to exact content hash matches.')
321
+ };
322
+ export const dedupeResolveInputSchema = {
323
+ ...vaultInput,
324
+ leftPath: z.string().min(1).describe('Left note path from dedupe results.'),
325
+ rightPath: z.string().min(1).describe('Right note path from dedupe results.'),
326
+ action: z.enum(['merge', 'link', 'ignore']).describe('Resolution action.'),
327
+ autoIndex: z.boolean().optional().default(true).describe('Reindex after duplicate resolution.')
328
+ };
314
329
  export const contextTool = async (input) => {
315
330
  const context = await resolveExecutionContext(input);
316
331
  const readiness = await ensureBootstrapReady(context, input, 'brainlink_context');
@@ -364,6 +379,14 @@ export const addNoteTool = async (input) => {
364
379
  allowSensitive: input.allowSensitive
365
380
  });
366
381
  const index = shouldIndex ? await indexVault(context.vault) : undefined;
382
+ const focusPath = added.path.includes('agents/') ? added.path.slice(added.path.indexOf('agents/')).replaceAll('\\', '/') : undefined;
383
+ const possibleDuplicates = await scanDuplicateNotes(context.vault, {
384
+ agentId: context.agent,
385
+ focusPath,
386
+ limit: 5,
387
+ minSemanticScore: 0.92,
388
+ includeSemantic: true
389
+ });
367
390
  return jsonResult({
368
391
  vault: context.vault,
369
392
  title: input.title,
@@ -374,6 +397,7 @@ export const addNoteTool = async (input) => {
374
397
  linkTarget: added.linkTarget,
375
398
  guaranteedEdge: true
376
399
  },
400
+ possibleDuplicates,
377
401
  ...(index ? { index } : {})
378
402
  });
379
403
  };
@@ -792,6 +816,17 @@ export const recommendationsTool = async (input) => {
792
816
  tokens
793
817
  }
794
818
  },
819
+ {
820
+ tool: 'brainlink_dedupe',
821
+ reason: 'Detect and resolve duplicate durable notes to keep memory quality high.',
822
+ args: {
823
+ vault: context.vault,
824
+ ...(context.agent ? { agent: context.agent } : {}),
825
+ limit: 10,
826
+ minScore: 0.92,
827
+ semantic: true
828
+ }
829
+ },
795
830
  {
796
831
  tool: 'brainlink_add_note',
797
832
  reason: 'Persist durable outcomes after task completion (write responses include connectivity metadata).',
@@ -818,3 +853,30 @@ export const recommendationsTool = async (input) => {
818
853
  recommendations
819
854
  });
820
855
  };
856
+ export const dedupeTool = async (input) => {
857
+ const context = await resolveExecutionContext(input);
858
+ const duplicates = await scanDuplicateNotes(context.vault, {
859
+ agentId: context.agent,
860
+ limit: input.limit ?? 25,
861
+ minSemanticScore: input.minScore ?? 0.92,
862
+ includeSemantic: input.semantic !== false
863
+ });
864
+ return jsonResult({
865
+ vault: context.vault,
866
+ agent: context.agent,
867
+ duplicates
868
+ });
869
+ };
870
+ export const dedupeResolveTool = async (input) => {
871
+ const context = await resolveExecutionContext(input);
872
+ const result = await resolveDuplicateNotes(context.vault, {
873
+ leftPath: input.leftPath,
874
+ rightPath: input.rightPath,
875
+ action: input.action,
876
+ autoIndex: isTruthy(input.autoIndex)
877
+ });
878
+ return jsonResult({
879
+ vault: context.vault,
880
+ ...result
881
+ });
882
+ };