@andespindola/brainlink 0.1.0-beta.9 → 0.1.0-beta.91

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 (53) 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 +146 -17
  6. package/SECURITY.md +1 -1
  7. package/dist/application/analyze-vault.js +7 -7
  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 +154 -102
  11. package/dist/application/frontend/client-html.js +49 -40
  12. package/dist/application/frontend/client-js.js +3118 -167
  13. package/dist/application/frontend/client-worker-js.js +66 -0
  14. package/dist/application/get-graph-layout.js +18 -6
  15. package/dist/application/get-graph-node.js +12 -0
  16. package/dist/application/get-graph-summary.js +12 -0
  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 +252 -19
  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 +12 -0
  24. package/dist/application/search-knowledge.js +25 -10
  25. package/dist/application/server/routes.js +102 -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 +20 -3
  30. package/dist/cli/commands/write-commands.js +818 -8
  31. package/dist/domain/context.js +53 -11
  32. package/dist/domain/embeddings.js +2 -1
  33. package/dist/domain/graph-layout.js +67 -16
  34. package/dist/domain/middle-out.js +18 -0
  35. package/dist/infrastructure/config.js +38 -0
  36. package/dist/infrastructure/file-index.js +358 -0
  37. package/dist/infrastructure/file-system-vault.js +15 -0
  38. package/dist/infrastructure/index-state.js +56 -0
  39. package/dist/infrastructure/private-pack-codec.js +134 -0
  40. package/dist/infrastructure/search-packs.js +452 -0
  41. package/dist/infrastructure/session-state.js +57 -2
  42. package/dist/mcp/server.js +11 -1
  43. package/dist/mcp/tools.js +215 -3
  44. package/docs/AGENT_USAGE.md +103 -16
  45. package/docs/ARCHITECTURE.md +25 -26
  46. package/docs/QUICKSTART.md +9 -1
  47. package/package.json +6 -4
  48. package/dist/infrastructure/sqlite/document-writer.js +0 -51
  49. package/dist/infrastructure/sqlite/graph-reader.js +0 -120
  50. package/dist/infrastructure/sqlite/schema.js +0 -111
  51. package/dist/infrastructure/sqlite/search-reader.js +0 -156
  52. package/dist/infrastructure/sqlite/types.js +0 -1
  53. package/dist/infrastructure/sqlite-index.js +0 -25
@@ -3,13 +3,15 @@ import { dirname, join } from 'node:path';
3
3
  import { getBrainlinkHomePath } from './paths.js';
4
4
  const defaultPolicy = {
5
5
  enforceBootstrap: true,
6
+ enforceContextFirst: true,
6
7
  autoBootstrapOnRead: true,
7
8
  autoBootstrapOnStartup: true,
8
9
  staleAfterMinutes: 120
9
10
  };
10
11
  const defaultState = {
11
12
  policy: defaultPolicy,
12
- bootstraps: []
13
+ bootstraps: [],
14
+ contexts: []
13
15
  };
14
16
  const sessionStatePath = () => join(getBrainlinkHomePath(), 'session-state.json');
15
17
  const normalizeAgent = (agent) => agent?.trim() ? agent.trim() : '*';
@@ -21,6 +23,7 @@ const sanitizeState = (value) => {
21
23
  const record = value;
22
24
  const policyRecord = typeof record.policy === 'object' && record.policy !== null ? record.policy : {};
23
25
  const rawBootstraps = Array.isArray(record.bootstraps) ? record.bootstraps : [];
26
+ const rawContexts = Array.isArray(record.contexts) ? record.contexts : [];
24
27
  const bootstraps = rawBootstraps.flatMap((entry) => {
25
28
  if (typeof entry !== 'object' || entry === null) {
26
29
  return [];
@@ -31,9 +34,22 @@ const sanitizeState = (value) => {
31
34
  const lastBootstrappedAt = typeof row.lastBootstrappedAt === 'string' && row.lastBootstrappedAt.trim().length > 0 ? row.lastBootstrappedAt.trim() : undefined;
32
35
  return vault && agent && lastBootstrappedAt ? [{ vault, agent, lastBootstrappedAt }] : [];
33
36
  });
37
+ const contexts = rawContexts.flatMap((entry) => {
38
+ if (typeof entry !== 'object' || entry === null) {
39
+ return [];
40
+ }
41
+ const row = entry;
42
+ const vault = typeof row.vault === 'string' && row.vault.trim().length > 0 ? row.vault.trim() : undefined;
43
+ const agent = typeof row.agent === 'string' && row.agent.trim().length > 0 ? row.agent.trim() : undefined;
44
+ const lastContextAt = typeof row.lastContextAt === 'string' && row.lastContextAt.trim().length > 0 ? row.lastContextAt.trim() : undefined;
45
+ return vault && agent && lastContextAt ? [{ vault, agent, lastContextAt }] : [];
46
+ });
34
47
  return {
35
48
  policy: {
36
49
  enforceBootstrap: typeof policyRecord.enforceBootstrap === 'boolean' ? policyRecord.enforceBootstrap : defaultPolicy.enforceBootstrap,
50
+ enforceContextFirst: typeof policyRecord.enforceContextFirst === 'boolean'
51
+ ? policyRecord.enforceContextFirst
52
+ : defaultPolicy.enforceContextFirst,
37
53
  autoBootstrapOnRead: typeof policyRecord.autoBootstrapOnRead === 'boolean'
38
54
  ? policyRecord.autoBootstrapOnRead
39
55
  : defaultPolicy.autoBootstrapOnRead,
@@ -42,7 +58,8 @@ const sanitizeState = (value) => {
42
58
  : defaultPolicy.autoBootstrapOnStartup,
43
59
  staleAfterMinutes: safePositive(policyRecord.staleAfterMinutes, defaultPolicy.staleAfterMinutes)
44
60
  },
45
- bootstraps
61
+ bootstraps,
62
+ contexts
46
63
  };
47
64
  };
48
65
  const readState = async () => {
@@ -68,6 +85,7 @@ export const setBootstrapPolicy = async (patch) => {
68
85
  const state = await readState();
69
86
  const next = {
70
87
  enforceBootstrap: typeof patch.enforceBootstrap === 'boolean' ? patch.enforceBootstrap : state.policy.enforceBootstrap,
88
+ enforceContextFirst: typeof patch.enforceContextFirst === 'boolean' ? patch.enforceContextFirst : state.policy.enforceContextFirst,
71
89
  autoBootstrapOnRead: typeof patch.autoBootstrapOnRead === 'boolean' ? patch.autoBootstrapOnRead : state.policy.autoBootstrapOnRead,
72
90
  autoBootstrapOnStartup: typeof patch.autoBootstrapOnStartup === 'boolean' ? patch.autoBootstrapOnStartup : state.policy.autoBootstrapOnStartup,
73
91
  staleAfterMinutes: safePositive(patch.staleAfterMinutes, state.policy.staleAfterMinutes)
@@ -96,6 +114,24 @@ export const touchBootstrapSession = async (vault, agent) => {
96
114
  });
97
115
  return entry;
98
116
  };
117
+ export const touchContextSession = async (vault, agent) => {
118
+ const state = await readState();
119
+ const normalizedAgent = normalizeAgent(agent);
120
+ const entry = {
121
+ vault,
122
+ agent: normalizedAgent,
123
+ lastContextAt: new Date().toISOString()
124
+ };
125
+ const contexts = [
126
+ entry,
127
+ ...state.contexts.filter((item) => !(item.vault === entry.vault && item.agent === entry.agent))
128
+ ].slice(0, 500);
129
+ await writeState({
130
+ ...state,
131
+ contexts
132
+ });
133
+ return entry;
134
+ };
99
135
  export const getBootstrapSessionStatus = async (vault, agent) => {
100
136
  const state = await readState();
101
137
  const normalizedAgent = normalizeAgent(agent);
@@ -115,3 +151,22 @@ export const getBootstrapSessionStatus = async (vault, agent) => {
115
151
  ageMinutes
116
152
  };
117
153
  };
154
+ export const getContextSessionStatus = async (vault, agent) => {
155
+ const state = await readState();
156
+ const normalizedAgent = normalizeAgent(agent);
157
+ const match = state.contexts.find((entry) => entry.vault === vault && entry.agent === normalizedAgent);
158
+ if (!match) {
159
+ return {
160
+ ready: false,
161
+ stale: true
162
+ };
163
+ }
164
+ const ageMinutes = Math.max(0, (Date.now() - new Date(match.lastContextAt).getTime()) / 60000);
165
+ const stale = ageMinutes > state.policy.staleAfterMinutes;
166
+ return {
167
+ ready: !stale,
168
+ stale,
169
+ lastContextAt: match.lastContextAt,
170
+ ageMinutes
171
+ };
172
+ };
@@ -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,13 +4,14 @@ 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';
10
11
  import { resolveAgentRuntimeDefaults, sanitizeSearchMode } from '../infrastructure/config.js';
11
12
  import { loadBrainlinkConfig } from '../infrastructure/config.js';
12
13
  import { assertVaultAllowed } from '../infrastructure/file-system-vault.js';
13
- import { getBootstrapPolicy, getBootstrapSessionStatus, setBootstrapPolicy, touchBootstrapSession } from '../infrastructure/session-state.js';
14
+ import { getBootstrapPolicy, getBootstrapSessionStatus, getContextSessionStatus, setBootstrapPolicy, touchBootstrapSession, touchContextSession } from '../infrastructure/session-state.js';
14
15
  const positiveInteger = (fallback) => z
15
16
  .number()
16
17
  .int()
@@ -143,6 +144,73 @@ const ensureBootstrapReady = async (context, input, toolName) => {
143
144
  }, nextActions))
144
145
  };
145
146
  };
147
+ const ensureContextReady = async (context, input, toolName) => {
148
+ const policy = await getBootstrapPolicy();
149
+ if (!policy.enforceContextFirst) {
150
+ return {
151
+ context: {
152
+ policy,
153
+ statusBefore: {
154
+ ready: true,
155
+ stale: false
156
+ },
157
+ reason: 'Context-first enforcement is disabled by policy.'
158
+ }
159
+ };
160
+ }
161
+ const status = await getContextSessionStatus(context.vault, context.agent);
162
+ if (status.ready) {
163
+ return {
164
+ context: {
165
+ policy,
166
+ statusBefore: status,
167
+ reason: 'Context session is already fresh for this vault/agent.'
168
+ }
169
+ };
170
+ }
171
+ const queryFromInput = typeof input.query === 'string' && input.query.trim().length > 0
172
+ ? input.query
173
+ : typeof input.contextQuery === 'string' && input.contextQuery.trim().length > 0
174
+ ? input.contextQuery
175
+ : '<task>';
176
+ const mode = sanitizeSearchMode(typeof input.mode === 'string' ? input.mode : undefined, context.defaults.defaultSearchMode);
177
+ const limit = typeof input.limit === 'number' && Number.isFinite(input.limit) && input.limit > 0
178
+ ? input.limit
179
+ : typeof input.contextLimit === 'number' && Number.isFinite(input.contextLimit) && input.contextLimit > 0
180
+ ? input.contextLimit
181
+ : context.defaults.defaultSearchLimit;
182
+ const tokens = typeof input.tokens === 'number' && Number.isFinite(input.tokens) && input.tokens > 0
183
+ ? input.tokens
184
+ : typeof input.contextTokens === 'number' && Number.isFinite(input.contextTokens) && input.contextTokens > 0
185
+ ? input.contextTokens
186
+ : context.defaults.defaultContextTokens;
187
+ const contextArgs = {
188
+ vault: context.vault,
189
+ ...(context.agent ? { agent: context.agent } : {}),
190
+ query: queryFromInput,
191
+ mode,
192
+ limit,
193
+ tokens
194
+ };
195
+ const nextActions = [
196
+ {
197
+ tool: 'brainlink_context',
198
+ reason: 'Context must be loaded first so Brainlink is the primary retrieval source before other read tools.',
199
+ args: contextArgs
200
+ }
201
+ ];
202
+ return {
203
+ preflight: preflightResult(withNextActions({
204
+ vault: context.vault,
205
+ agent: context.agent,
206
+ blockedTool: toolName,
207
+ policy,
208
+ contextStatus: status,
209
+ guidance: 'Run brainlink_context first for this vault/agent before other read tools so answers are grounded on Brainlink context.',
210
+ contextArgs
211
+ }, nextActions))
212
+ };
213
+ };
146
214
  export const contextInputSchema = {
147
215
  ...vaultInput,
148
216
  ...agentInput,
@@ -225,6 +293,7 @@ export const policyInputSchema = {
225
293
  ...agentInput,
226
294
  preset: z.enum(['fully-auto', 'strict']).optional().describe('Apply an opinionated policy preset before explicit overrides.'),
227
295
  enforceBootstrap: z.boolean().optional().describe('Enable or disable bootstrap enforcement for MCP read tools.'),
296
+ enforceContextFirst: z.boolean().optional().describe('Require brainlink_context before other MCP read tools.'),
228
297
  autoBootstrapOnRead: z
229
298
  .boolean()
230
299
  .optional()
@@ -243,6 +312,20 @@ export const recommendationsInputSchema = {
243
312
  limit: optionalPositiveInteger().describe('Optional context limit override for generated recommendations.'),
244
313
  tokens: optionalPositiveInteger().describe('Optional context token budget override for generated recommendations.')
245
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
+ };
246
329
  export const contextTool = async (input) => {
247
330
  const context = await resolveExecutionContext(input);
248
331
  const readiness = await ensureBootstrapReady(context, input, 'brainlink_context');
@@ -253,12 +336,14 @@ export const contextTool = async (input) => {
253
336
  const limit = input.limit ?? context.defaults.defaultSearchLimit;
254
337
  const tokens = input.tokens ?? context.defaults.defaultContextTokens;
255
338
  const contextPackage = await buildContextPackage(context.vault, input.query, limit, tokens, context.agent, mode);
339
+ const contextSession = await touchContextSession(context.vault, context.agent);
256
340
  return jsonResult({
257
341
  vault: context.vault,
258
342
  agent: context.agent,
259
343
  mode,
260
344
  limit,
261
345
  tokens,
346
+ contextSession,
262
347
  ...(readiness.bootstrap ? { bootstrap: readiness.bootstrap } : {}),
263
348
  ...contextPackage
264
349
  });
@@ -269,6 +354,10 @@ export const searchTool = async (input) => {
269
354
  if (readiness.preflight) {
270
355
  return readiness.preflight;
271
356
  }
357
+ const contextReadiness = await ensureContextReady(context, input, 'brainlink_search');
358
+ if (contextReadiness.preflight) {
359
+ return contextReadiness.preflight;
360
+ }
272
361
  const mode = sanitizeSearchMode(input.mode, context.defaults.defaultSearchMode);
273
362
  const limit = input.limit ?? context.defaults.defaultSearchLimit;
274
363
  const results = await searchKnowledge(context.vault, input.query, limit, context.agent, mode);
@@ -279,6 +368,7 @@ export const searchTool = async (input) => {
279
368
  limit,
280
369
  mode,
281
370
  ...(readiness.bootstrap ? { bootstrap: readiness.bootstrap } : {}),
371
+ ...(contextReadiness.context ? { contextReadiness: contextReadiness.context } : {}),
282
372
  results
283
373
  });
284
374
  };
@@ -289,6 +379,14 @@ export const addNoteTool = async (input) => {
289
379
  allowSensitive: input.allowSensitive
290
380
  });
291
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
+ });
292
390
  return jsonResult({
293
391
  vault: context.vault,
294
392
  title: input.title,
@@ -299,6 +397,7 @@ export const addNoteTool = async (input) => {
299
397
  linkTarget: added.linkTarget,
300
398
  guaranteedEdge: true
301
399
  },
400
+ possibleDuplicates,
302
401
  ...(index ? { index } : {})
303
402
  });
304
403
  };
@@ -343,11 +442,16 @@ export const validateTool = async (input) => {
343
442
  if (readiness.preflight) {
344
443
  return readiness.preflight;
345
444
  }
445
+ const contextReadiness = await ensureContextReady(context, input, 'brainlink_validate');
446
+ if (contextReadiness.preflight) {
447
+ return contextReadiness.preflight;
448
+ }
346
449
  const validation = await validateVault(context.vault, context.agent);
347
450
  return jsonResult({
348
451
  vault: context.vault,
349
452
  agent: context.agent,
350
453
  ...(readiness.bootstrap ? { bootstrap: readiness.bootstrap } : {}),
454
+ ...(contextReadiness.context ? { contextReadiness: contextReadiness.context } : {}),
351
455
  ...validation
352
456
  });
353
457
  };
@@ -357,11 +461,16 @@ export const graphTool = async (input) => {
357
461
  if (readiness.preflight) {
358
462
  return readiness.preflight;
359
463
  }
464
+ const contextReadiness = await ensureContextReady(context, input, 'brainlink_graph');
465
+ if (contextReadiness.preflight) {
466
+ return contextReadiness.preflight;
467
+ }
360
468
  const graph = await getGraph(context.vault, context.agent);
361
469
  return jsonResult({
362
470
  vault: context.vault,
363
471
  agent: context.agent,
364
472
  ...(readiness.bootstrap ? { bootstrap: readiness.bootstrap } : {}),
473
+ ...(contextReadiness.context ? { contextReadiness: contextReadiness.context } : {}),
365
474
  ...graph
366
475
  });
367
476
  };
@@ -371,11 +480,16 @@ export const brokenLinksTool = async (input) => {
371
480
  if (readiness.preflight) {
372
481
  return readiness.preflight;
373
482
  }
483
+ const contextReadiness = await ensureContextReady(context, input, 'brainlink_broken_links');
484
+ if (contextReadiness.preflight) {
485
+ return contextReadiness.preflight;
486
+ }
374
487
  const brokenLinks = await getBrokenLinksReport(context.vault, context.agent);
375
488
  return jsonResult({
376
489
  vault: context.vault,
377
490
  agent: context.agent,
378
491
  ...(readiness.bootstrap ? { bootstrap: readiness.bootstrap } : {}),
492
+ ...(contextReadiness.context ? { contextReadiness: contextReadiness.context } : {}),
379
493
  brokenLinks
380
494
  });
381
495
  };
@@ -385,11 +499,16 @@ export const orphansTool = async (input) => {
385
499
  if (readiness.preflight) {
386
500
  return readiness.preflight;
387
501
  }
502
+ const contextReadiness = await ensureContextReady(context, input, 'brainlink_orphans');
503
+ if (contextReadiness.preflight) {
504
+ return contextReadiness.preflight;
505
+ }
388
506
  const orphans = await getOrphansReport(context.vault, context.agent);
389
507
  return jsonResult({
390
508
  vault: context.vault,
391
509
  agent: context.agent,
392
510
  ...(readiness.bootstrap ? { bootstrap: readiness.bootstrap } : {}),
511
+ ...(contextReadiness.context ? { contextReadiness: contextReadiness.context } : {}),
393
512
  orphans
394
513
  });
395
514
  };
@@ -399,11 +518,16 @@ export const statsTool = async (input) => {
399
518
  if (readiness.preflight) {
400
519
  return readiness.preflight;
401
520
  }
521
+ const contextReadiness = await ensureContextReady(context, input, 'brainlink_stats');
522
+ if (contextReadiness.preflight) {
523
+ return contextReadiness.preflight;
524
+ }
402
525
  const stats = await getStats(context.vault, context.agent);
403
526
  return jsonResult({
404
527
  vault: context.vault,
405
528
  agent: context.agent,
406
529
  ...(readiness.bootstrap ? { bootstrap: readiness.bootstrap } : {}),
530
+ ...(contextReadiness.context ? { contextReadiness: contextReadiness.context } : {}),
407
531
  stats
408
532
  });
409
533
  };
@@ -413,6 +537,10 @@ export const syncTool = async (input) => {
413
537
  if (readiness.preflight) {
414
538
  return readiness.preflight;
415
539
  }
540
+ const contextReadiness = await ensureContextReady(context, input, 'brainlink_sync');
541
+ if (contextReadiness.preflight) {
542
+ return contextReadiness.preflight;
543
+ }
416
544
  const index = await indexVault(context.vault);
417
545
  const stats = await getStats(context.vault, context.agent);
418
546
  const validation = await validateVault(context.vault, context.agent);
@@ -422,6 +550,7 @@ export const syncTool = async (input) => {
422
550
  vault: context.vault,
423
551
  agent: context.agent,
424
552
  ...(readiness.bootstrap ? { bootstrap: readiness.bootstrap } : {}),
553
+ ...(contextReadiness.context ? { contextReadiness: contextReadiness.context } : {}),
425
554
  index,
426
555
  stats,
427
556
  validation,
@@ -435,10 +564,12 @@ export const syncTool = async (input) => {
435
564
  const contextLimit = input.contextLimit ?? context.defaults.defaultSearchLimit;
436
565
  const contextTokens = input.contextTokens ?? context.defaults.defaultContextTokens;
437
566
  const contextPackage = await buildContextPackage(context.vault, input.contextQuery, contextLimit, contextTokens, context.agent, mode);
567
+ const contextSession = await touchContextSession(context.vault, context.agent);
438
568
  return jsonResult({
439
569
  ...response,
440
570
  context: {
441
571
  mode,
572
+ contextSession,
442
573
  ...contextPackage
443
574
  }
444
575
  });
@@ -454,6 +585,7 @@ export const bootstrapTool = async (input) => {
454
585
  const contextPackage = input.query
455
586
  ? await buildContextPackage(context.vault, input.query, limit, tokens, context.agent, mode)
456
587
  : undefined;
588
+ const contextSession = input.query ? await touchContextSession(context.vault, context.agent) : undefined;
457
589
  const guidance = stats.documentCount === 0
458
590
  ? 'Vault indexed with zero documents. Add durable notes with brainlink_add_note, then run brainlink_bootstrap again.'
459
591
  : input.query
@@ -522,7 +654,8 @@ export const bootstrapTool = async (input) => {
522
654
  policy,
523
655
  session,
524
656
  guidance,
525
- ...(contextPackage ? { context: contextPackage } : {})
657
+ ...(contextPackage ? { context: contextPackage } : {}),
658
+ ...(contextSession ? { contextSession } : {})
526
659
  }, nextActions));
527
660
  };
528
661
  export const policyTool = async (input) => {
@@ -530,30 +663,35 @@ export const policyTool = async (input) => {
530
663
  const presetPatch = input.preset === 'strict'
531
664
  ? {
532
665
  enforceBootstrap: true,
666
+ enforceContextFirst: true,
533
667
  autoBootstrapOnRead: false,
534
668
  autoBootstrapOnStartup: false
535
669
  }
536
670
  : input.preset === 'fully-auto'
537
671
  ? {
538
672
  enforceBootstrap: true,
673
+ enforceContextFirst: true,
539
674
  autoBootstrapOnRead: true,
540
675
  autoBootstrapOnStartup: true
541
676
  }
542
677
  : {};
543
678
  const policy = input.preset !== undefined ||
544
679
  typeof input.enforceBootstrap === 'boolean' ||
680
+ typeof input.enforceContextFirst === 'boolean' ||
545
681
  typeof input.autoBootstrapOnRead === 'boolean' ||
546
682
  typeof input.autoBootstrapOnStartup === 'boolean' ||
547
683
  typeof input.staleAfterMinutes === 'number'
548
684
  ? await setBootstrapPolicy({
549
685
  ...presetPatch,
550
686
  ...(typeof input.enforceBootstrap === 'boolean' ? { enforceBootstrap: input.enforceBootstrap } : {}),
687
+ ...(typeof input.enforceContextFirst === 'boolean' ? { enforceContextFirst: input.enforceContextFirst } : {}),
551
688
  ...(typeof input.autoBootstrapOnRead === 'boolean' ? { autoBootstrapOnRead: input.autoBootstrapOnRead } : {}),
552
689
  ...(typeof input.autoBootstrapOnStartup === 'boolean' ? { autoBootstrapOnStartup: input.autoBootstrapOnStartup } : {}),
553
690
  ...(typeof input.staleAfterMinutes === 'number' ? { staleAfterMinutes: input.staleAfterMinutes } : {})
554
691
  })
555
692
  : await getBootstrapPolicy();
556
693
  const bootstrapStatus = await getBootstrapSessionStatus(context.vault, context.agent);
694
+ const contextStatus = await getContextSessionStatus(context.vault, context.agent);
557
695
  const nextActions = bootstrapStatus.ready
558
696
  ? []
559
697
  : [
@@ -567,18 +705,37 @@ export const policyTool = async (input) => {
567
705
  }
568
706
  }
569
707
  ];
708
+ const withContextAction = policy.enforceContextFirst && !contextStatus.ready
709
+ ? [
710
+ ...nextActions,
711
+ {
712
+ tool: 'brainlink_context',
713
+ reason: 'Context-first policy is enabled. Load context before other read tools.',
714
+ args: {
715
+ vault: context.vault,
716
+ ...(context.agent ? { agent: context.agent } : {}),
717
+ query: '<task>',
718
+ mode: context.defaults.defaultSearchMode,
719
+ limit: context.defaults.defaultSearchLimit,
720
+ tokens: context.defaults.defaultContextTokens
721
+ }
722
+ }
723
+ ]
724
+ : nextActions;
570
725
  return jsonResult(withNextActions({
571
726
  vault: context.vault,
572
727
  agent: context.agent,
573
728
  policy,
574
729
  bootstrapStatus,
730
+ contextStatus,
575
731
  ...(input.preset ? { presetApplied: input.preset } : {})
576
- }, nextActions));
732
+ }, withContextAction));
577
733
  };
578
734
  export const recommendationsTool = async (input) => {
579
735
  const context = await resolveExecutionContext(input);
580
736
  const policy = await getBootstrapPolicy();
581
737
  const bootstrapStatus = await getBootstrapSessionStatus(context.vault, context.agent);
738
+ const contextStatus = await getContextSessionStatus(context.vault, context.agent);
582
739
  const stats = await getStats(context.vault, context.agent);
583
740
  const mode = sanitizeSearchMode(input.mode, context.defaults.defaultSearchMode);
584
741
  const limit = input.limit ?? context.defaults.defaultSearchLimit;
@@ -610,6 +767,22 @@ export const recommendationsTool = async (input) => {
610
767
  }
611
768
  ]
612
769
  : []),
770
+ ...(policy.enforceContextFirst && !contextStatus.ready
771
+ ? [
772
+ {
773
+ tool: 'brainlink_context',
774
+ reason: 'Context-first policy is enabled. Load context before other read operations.',
775
+ args: {
776
+ vault: context.vault,
777
+ ...(context.agent ? { agent: context.agent } : {}),
778
+ query: query ?? '<task>',
779
+ mode,
780
+ limit,
781
+ tokens
782
+ }
783
+ }
784
+ ]
785
+ : []),
613
786
  ...(stats.documentCount === 0
614
787
  ? [
615
788
  {
@@ -643,6 +816,17 @@ export const recommendationsTool = async (input) => {
643
816
  tokens
644
817
  }
645
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
+ },
646
830
  {
647
831
  tool: 'brainlink_add_note',
648
832
  reason: 'Persist durable outcomes after task completion (write responses include connectivity metadata).',
@@ -664,7 +848,35 @@ export const recommendationsTool = async (input) => {
664
848
  },
665
849
  policy,
666
850
  bootstrapStatus,
851
+ contextStatus,
667
852
  stats,
668
853
  recommendations
669
854
  });
670
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
+ };