@andespindola/brainlink 0.1.0-beta.14 → 0.1.0-beta.141

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 (55) 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 +144 -22
  6. package/SECURITY.md +1 -1
  7. package/dist/application/analyze-vault.js +1 -15
  8. package/dist/application/build-context.js +64 -3
  9. package/dist/application/dedupe-notes.js +226 -0
  10. package/dist/application/frontend/client-css.js +110 -45
  11. package/dist/application/frontend/client-html.js +35 -26
  12. package/dist/application/frontend/client-js.js +2990 -161
  13. package/dist/application/frontend/client-worker-js.js +66 -0
  14. package/dist/application/get-graph-layout.js +39 -6
  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-view.js +243 -0
  18. package/dist/application/get-graph.js +3 -3
  19. package/dist/application/import-legacy-sqlite.js +296 -0
  20. package/dist/application/index-vault.js +253 -25
  21. package/dist/application/list-agents.js +3 -3
  22. package/dist/application/list-links.js +5 -5
  23. package/dist/application/offline-pack-backup.js +44 -0
  24. package/dist/application/search-graph-node-ids.js +3 -3
  25. package/dist/application/search-knowledge.js +4 -5
  26. package/dist/application/server/routes.js +156 -5
  27. package/dist/application/start-server.js +75 -4
  28. package/dist/application/watch-vault.js +23 -2
  29. package/dist/benchmarks/large-vault.js +1 -1
  30. package/dist/cli/commands/agent-commands.js +7 -0
  31. package/dist/cli/commands/write-commands.js +842 -8
  32. package/dist/domain/context.js +54 -11
  33. package/dist/domain/graph-layout.js +181 -3
  34. package/dist/domain/markdown.js +29 -9
  35. package/dist/domain/middle-out.js +18 -0
  36. package/dist/infrastructure/config.js +38 -0
  37. package/dist/infrastructure/file-index.js +358 -0
  38. package/dist/infrastructure/file-system-vault.js +15 -0
  39. package/dist/infrastructure/index-state.js +58 -0
  40. package/dist/infrastructure/private-pack-codec.js +71 -10
  41. package/dist/infrastructure/search-packs.js +276 -87
  42. package/dist/infrastructure/volatile-memory.js +100 -0
  43. package/dist/mcp/server.js +21 -1
  44. package/dist/mcp/tools.js +96 -0
  45. package/docs/AGENT_USAGE.md +101 -19
  46. package/docs/ARCHITECTURE.md +23 -28
  47. package/docs/QUICKSTART.md +7 -0
  48. package/package.json +6 -4
  49. package/dist/infrastructure/sqlite/document-writer.js +0 -51
  50. package/dist/infrastructure/sqlite/graph-reader.js +0 -267
  51. package/dist/infrastructure/sqlite/recovery.js +0 -163
  52. package/dist/infrastructure/sqlite/schema.js +0 -114
  53. package/dist/infrastructure/sqlite/search-reader.js +0 -188
  54. package/dist/infrastructure/sqlite/types.js +0 -1
  55. package/dist/infrastructure/sqlite-index.js +0 -38
@@ -1,16 +1,21 @@
1
- import Database from 'better-sqlite3';
2
1
  import { gunzipSync } from 'node:zlib';
3
2
  import { mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises';
4
3
  import { join } from 'node:path';
5
- import { existsSync } from 'node:fs';
4
+ import { middleOutIndices } from '../domain/middle-out.js';
6
5
  import { decodePrivatePack, encodePrivatePack, isPrivatePackPayload } from './private-pack-codec.js';
7
6
  const packsDirectoryName = 'search-packs';
8
7
  const manifestFileName = 'manifest.json';
9
- const rowChunkSize = 5_000;
8
+ const defaultBuildOptions = {
9
+ rowChunkSize: 5_000,
10
+ compressionLevel: 5,
11
+ useDictionary: true
12
+ };
10
13
  const queryTokenPattern = /[\p{L}\p{N}_-]+/gu;
14
+ const bloomBytes = 256;
15
+ const bloomBitSize = bloomBytes * 8;
16
+ const bloomSeeds = [0x9e3779b1, 0x85ebca6b, 0xc2b2ae35];
11
17
  const toPackDirectory = (vaultPath) => join(vaultPath, '.brainlink', packsDirectoryName);
12
18
  const toManifestPath = (vaultPath) => join(toPackDirectory(vaultPath), manifestFileName);
13
- const toDatabasePath = (vaultPath) => join(vaultPath, '.brainlink', 'brainlink.db');
14
19
  const parseRowsFromPack = async (vaultPath, content) => {
15
20
  const raw = isPrivatePackPayload(content) ? await decodePrivatePack(vaultPath, content) : gunzipSync(content);
16
21
  return raw
@@ -18,7 +23,29 @@ const parseRowsFromPack = async (vaultPath, content) => {
18
23
  .split('\n')
19
24
  .map((line) => line.trim())
20
25
  .filter((line) => line.length > 0)
21
- .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
+ });
22
49
  };
23
50
  const toRows = (documents) => documents.flatMap((document) => document.chunks.map((chunk) => ({
24
51
  documentId: document.document.id,
@@ -26,21 +53,121 @@ const toRows = (documents) => documents.flatMap((document) => document.chunks.ma
26
53
  title: document.document.title,
27
54
  path: document.document.path,
28
55
  chunkId: chunk.id,
56
+ chunkOrdinal: chunk.ordinal,
29
57
  content: chunk.content,
30
58
  tags: document.document.tags
31
59
  })));
32
60
  const writeManifest = async (vaultPath, manifest) => {
33
61
  await writeFile(toManifestPath(vaultPath), `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
34
62
  };
35
- const parseTags = (value) => {
63
+ const readManifest = async (vaultPath) => {
36
64
  try {
37
- const parsed = JSON.parse(value);
38
- return Array.isArray(parsed) ? parsed.filter((item) => typeof item === 'string') : [];
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;
39
135
  }
40
136
  catch {
41
- return [];
137
+ return null;
42
138
  }
43
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
+ };
44
171
  const chunkRows = (rows, size) => {
45
172
  const chunks = [];
46
173
  for (let index = 0; index < rows.length; index += size) {
@@ -69,6 +196,51 @@ const countOccurrences = (text, token) => {
69
196
  }
70
197
  return hits;
71
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
+ };
72
244
  const computeTextScore = (row, tokens) => {
73
245
  if (tokens.length === 0) {
74
246
  return 0;
@@ -91,6 +263,7 @@ const toSearchResult = (row, score) => ({
91
263
  title: row.title,
92
264
  path: row.path,
93
265
  chunkId: row.chunkId,
266
+ chunkOrdinal: row.chunkOrdinal,
94
267
  content: row.content,
95
268
  score,
96
269
  textScore: score,
@@ -112,7 +285,8 @@ const sortedPackFiles = async (vaultPath) => {
112
285
  throw error;
113
286
  }
114
287
  };
115
- const writeRowsAsPrivatePacks = async (vaultPath, rows, clearExisting) => {
288
+ const writeRowsAsPrivatePacks = async (vaultPath, rows, clearExisting, options) => {
289
+ const startedAt = process.hrtime.bigint();
116
290
  const directory = toPackDirectory(vaultPath);
117
291
  await mkdir(directory, { recursive: true });
118
292
  if (clearExisting) {
@@ -121,88 +295,102 @@ const writeRowsAsPrivatePacks = async (vaultPath, rows, clearExisting) => {
121
295
  .filter((name) => name.endsWith('.blpk') || name.endsWith('.jsonl.gz') || name === manifestFileName)
122
296
  .map((name) => rm(join(directory, name), { force: true })));
123
297
  }
124
- const chunks = chunkRows(rows, rowChunkSize);
125
- await Promise.all(chunks.map(async (chunk, index) => {
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];
126
304
  const fileName = `pack-${String(index + 1).padStart(4, '0')}.blpk`;
127
305
  const serialized = `${chunk.map((row) => JSON.stringify(row)).join('\n')}\n`;
128
- 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));
129
311
  await writeFile(join(directory, fileName), compressed);
130
- }));
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
+ }
131
321
  await writeManifest(vaultPath, {
132
- version: 2,
322
+ version: 3,
133
323
  createdAt: new Date().toISOString(),
134
324
  packCount: chunks.length,
135
325
  recordCount: rows.length,
136
- 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
+ }
137
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);
138
343
  return {
139
344
  packCount: chunks.length,
140
- recordCount: rows.length
345
+ recordCount: rows.length,
346
+ compression: {
347
+ inputBytes,
348
+ outputBytes,
349
+ ratio: outputBytes / safeInput,
350
+ savedBytes
351
+ },
352
+ durationMs
141
353
  };
142
354
  };
143
- const tableExists = (database, table) => {
144
- const row = database.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?").get(table);
145
- return row?.name === table;
146
- };
147
- const tableColumns = (database, table) => {
148
- const rows = database.prepare(`SELECT name FROM pragma_table_info('${table.replaceAll("'", "''")}')`).all();
149
- return new Set(rows.map((row) => row.name));
150
- };
151
- const loadRowsFromLegacySqlite = (vaultPath) => {
152
- const databasePath = toDatabasePath(vaultPath);
153
- if (!existsSync(databasePath)) {
355
+ const selectCandidatePackFiles = async (vaultPath, tokens, agentId) => {
356
+ const allFiles = await sortedPackFiles(vaultPath);
357
+ if (allFiles.length === 0) {
154
358
  return [];
155
359
  }
156
- const database = new Database(databasePath, { readonly: true, fileMustExist: true });
157
- try {
158
- if (!tableExists(database, 'documents') || !tableExists(database, 'chunks')) {
159
- return [];
160
- }
161
- const documentColumns = tableColumns(database, 'documents');
162
- const chunkColumns = tableColumns(database, 'chunks');
163
- if (!documentColumns.has('id') || !documentColumns.has('title') || !chunkColumns.has('document_id')) {
164
- return [];
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;
165
375
  }
166
- const agentExpr = documentColumns.has('agent_id') ? 'documents.agent_id' : "'shared'";
167
- const pathExpr = documentColumns.has('path') ? 'documents.path' : "documents.title";
168
- const tagsExpr = documentColumns.has('tags_json') ? 'documents.tags_json' : "'[]'";
169
- const chunkIdExpr = chunkColumns.has('id') ? 'chunks.id' : "documents.id || ':' || chunks.rowid";
170
- const chunkContentExpr = chunkColumns.has('content')
171
- ? 'chunks.content'
172
- : documentColumns.has('content')
173
- ? 'documents.content'
174
- : "''";
175
- const chunkOrderExpr = chunkColumns.has('ordinal') ? 'chunks.ordinal' : 'chunks.rowid';
176
- const statement = database.prepare(`
177
- SELECT
178
- documents.id AS document_id,
179
- ${agentExpr} AS agent_id,
180
- documents.title AS title,
181
- ${pathExpr} AS path,
182
- ${chunkIdExpr} AS chunk_id,
183
- ${chunkContentExpr} AS content,
184
- ${tagsExpr} AS tags_json
185
- FROM chunks
186
- JOIN documents ON documents.id = chunks.document_id
187
- ORDER BY documents.title, ${chunkOrderExpr}
188
- `);
189
- const rows = statement.all();
190
- return rows.map((row) => ({
191
- documentId: row.document_id,
192
- agentId: typeof row.agent_id === 'string' && row.agent_id.length > 0 ? row.agent_id : 'shared',
193
- title: row.title,
194
- path: row.path,
195
- chunkId: row.chunk_id,
196
- content: row.content ?? '',
197
- tags: parseTags(row.tags_json)
198
- }));
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);
199
381
  }
200
- finally {
201
- database.close();
382
+ if (byToken.length > 0) {
383
+ return byToken.map((entry) => entry.fileName);
202
384
  }
385
+ return byAgent.length > 0 ? byAgent.map((entry) => entry.fileName) : allFiles;
203
386
  };
204
- export const buildSearchPacks = async (vaultPath, documents) => {
205
- return writeRowsAsPrivatePacks(vaultPath, toRows(documents), true);
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);
206
394
  };
207
395
  export const ensurePrivatePacksFromLegacyIndex = async (vaultPath) => {
208
396
  const files = await sortedPackFiles(vaultPath);
@@ -216,38 +404,39 @@ export const ensurePrivatePacksFromLegacyIndex = async (vaultPath) => {
216
404
  const parsed = await parseRowsFromPack(vaultPath, await readFile(join(toPackDirectory(vaultPath), file)));
217
405
  rows.push(...parsed);
218
406
  }
219
- const report = await writeRowsAsPrivatePacks(vaultPath, rows, true);
407
+ const report = await writeRowsAsPrivatePacks(vaultPath, rows, true, defaultBuildOptions);
220
408
  return {
221
409
  imported: true,
222
410
  source: 'legacy-packs',
223
411
  ...report
224
412
  };
225
413
  }
226
- const legacyRows = loadRowsFromLegacySqlite(vaultPath);
227
- if (legacyRows.length === 0) {
228
- return { imported: false };
229
- }
230
- const report = await writeRowsAsPrivatePacks(vaultPath, legacyRows, true);
231
- return {
232
- imported: true,
233
- source: 'legacy-sqlite',
234
- ...report
235
- };
414
+ return { imported: false };
236
415
  };
416
+ export const toSearchPackBuildOptions = (config) => ({
417
+ rowChunkSize: config.searchPack.rowChunkSize,
418
+ compressionLevel: config.searchPack.compressionLevel,
419
+ useDictionary: config.searchPack.useDictionary
420
+ });
237
421
  export const searchInPacks = async (vaultPath, query, limit, agentId) => {
238
422
  const normalizedAgent = agentId?.trim();
239
423
  const tokens = tokenize(query);
240
424
  if (limit <= 0 || tokens.length === 0) {
241
425
  return [];
242
426
  }
243
- const files = await sortedPackFiles(vaultPath);
427
+ const files = await selectCandidatePackFiles(vaultPath, tokens, normalizedAgent);
244
428
  if (files.length === 0) {
245
429
  return [];
246
430
  }
247
431
  const scored = [];
248
432
  for (const file of files) {
249
433
  const rows = await parseRowsFromPack(vaultPath, await readFile(join(toPackDirectory(vaultPath), file)));
250
- 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
+ }
251
440
  if (normalizedAgent && row.agentId !== normalizedAgent) {
252
441
  return;
253
442
  }
@@ -0,0 +1,100 @@
1
+ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
2
+ import { dirname, join } from 'node:path';
3
+ export const volatileMemoryStoragePath = (vaultPath) => join(vaultPath, '.brainlink', 'volatile.json');
4
+ const emptyStore = () => ({
5
+ version: 1,
6
+ entries: []
7
+ });
8
+ const normalizeToken = (value) => value
9
+ .normalize('NFKD')
10
+ .replace(/\p{Diacritic}/gu, '')
11
+ .toLowerCase();
12
+ const tokens = (value) => value
13
+ .match(/[\p{L}\p{N}_-]+/gu)
14
+ ?.map(normalizeToken)
15
+ .filter((token) => token.length > 1) ?? [];
16
+ const readStore = async (vaultPath) => {
17
+ try {
18
+ const parsed = JSON.parse(await readFile(volatileMemoryStoragePath(vaultPath), 'utf8'));
19
+ return {
20
+ version: 1,
21
+ entries: Array.isArray(parsed.entries) ? parsed.entries.filter(isEntry) : []
22
+ };
23
+ }
24
+ catch {
25
+ return emptyStore();
26
+ }
27
+ };
28
+ const writeStore = async (vaultPath, store) => {
29
+ const target = volatileMemoryStoragePath(vaultPath);
30
+ const temp = `${target}.tmp`;
31
+ await mkdir(dirname(target), { recursive: true, mode: 0o700 });
32
+ await writeFile(temp, `${JSON.stringify(store, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
33
+ await rename(temp, target);
34
+ };
35
+ const isEntry = (value) => {
36
+ const record = value;
37
+ return Boolean(record &&
38
+ typeof record.id === 'string' &&
39
+ typeof record.agentId === 'string' &&
40
+ typeof record.content === 'string' &&
41
+ typeof record.createdAt === 'string' &&
42
+ typeof record.expiresAt === 'string' &&
43
+ Array.isArray(record.tags));
44
+ };
45
+ const activeEntries = (entries, now = Date.now()) => entries.filter((entry) => Date.parse(entry.expiresAt) > now);
46
+ const scoreEntry = (entry, query) => {
47
+ const queryTokens = tokens(query);
48
+ if (queryTokens.length === 0) {
49
+ return 1;
50
+ }
51
+ const haystack = normalizeToken([entry.content, entry.tags.join(' ')].join(' '));
52
+ return queryTokens.reduce((score, token) => score + (haystack.includes(token) ? 1 : 0), 0);
53
+ };
54
+ export const addVolatileMemory = async (vaultPath, content, agentId = 'shared', ttlMinutes = 240, tags = []) => {
55
+ const now = new Date();
56
+ const entry = {
57
+ id: `volatile-${now.getTime()}-${Math.random().toString(36).slice(2, 10)}`,
58
+ agentId,
59
+ content,
60
+ createdAt: now.toISOString(),
61
+ expiresAt: new Date(now.getTime() + Math.max(1, ttlMinutes) * 60_000).toISOString(),
62
+ source: 'agent',
63
+ tags
64
+ };
65
+ const store = await readStore(vaultPath);
66
+ await writeStore(vaultPath, {
67
+ version: 1,
68
+ entries: [...activeEntries(store.entries), entry]
69
+ });
70
+ return entry;
71
+ };
72
+ export const searchVolatileMemory = async (vaultPath, query, limit, agentId, mode = 'hybrid') => {
73
+ const store = await readStore(vaultPath);
74
+ const entries = activeEntries(store.entries)
75
+ .filter((entry) => (agentId ? entry.agentId === agentId : true))
76
+ .map((entry) => ({ entry, score: scoreEntry(entry, query) }))
77
+ .filter(({ score }) => score > 0)
78
+ .sort((left, right) => right.score - left.score || right.entry.createdAt.localeCompare(left.entry.createdAt))
79
+ .slice(0, Math.max(0, limit));
80
+ if (entries.length !== store.entries.length) {
81
+ await writeStore(vaultPath, { version: 1, entries: activeEntries(store.entries) });
82
+ }
83
+ return entries.map(({ entry, score }) => ({
84
+ title: 'Volatile Memory',
85
+ path: `volatile://${entry.id}`,
86
+ content: entry.content,
87
+ score,
88
+ searchMode: mode,
89
+ tags: ['volatile', ...entry.tags],
90
+ volatile: true,
91
+ expiresAt: entry.expiresAt
92
+ }));
93
+ };
94
+ export const clearVolatileMemory = async (vaultPath, agentId) => {
95
+ const store = await readStore(vaultPath);
96
+ const active = activeEntries(store.entries);
97
+ const kept = agentId ? active.filter((entry) => entry.agentId !== agentId) : [];
98
+ await writeStore(vaultPath, { version: 1, entries: kept });
99
+ return active.length - kept.length;
100
+ };
@@ -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, volatileAddInputSchema, volatileAddTool, volatileClearInputSchema, volatileClearTool, 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,11 +40,31 @@ 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.',
46
56
  inputSchema: addNoteInputSchema
47
57
  }, addNoteTool);
58
+ server.registerTool('brainlink_volatile_add', {
59
+ title: 'Add Volatile Brainlink Memory',
60
+ description: 'Write temporary agent-decided memory with TTL. Use for transient task state without polluting durable Markdown memory.',
61
+ inputSchema: volatileAddInputSchema
62
+ }, volatileAddTool);
63
+ server.registerTool('brainlink_volatile_clear', {
64
+ title: 'Clear Volatile Brainlink Memory',
65
+ description: 'Clear active volatile memory for the current vault/agent namespace.',
66
+ inputSchema: volatileClearInputSchema
67
+ }, volatileClearTool);
48
68
  server.registerTool('brainlink_add_file', {
49
69
  title: 'Ingest Markdown File',
50
70
  description: 'Read a local markdown/text file and ingest it as a Brainlink note. Reindex defaults to true.',