@andespindola/brainlink 0.1.0-beta.164 → 0.1.0-beta.166

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -71,6 +71,7 @@ Legacy `.jsonl.gz` packs are upgraded to `.blpk` automatically on first search/c
71
71
  - Full-text, semantic and hybrid retrieval on a local file index.
72
72
  - Middle-out context assembly around the strongest chunk per document.
73
73
  - In-process index and context caching with automatic invalidation on index updates.
74
+ - Optional CAG context packs at `.brainlink/context-packs/*.json`, derived from the current index signature and reusable across repeated context calls.
74
75
  - HTTP graph server caches generated frontend assets and graph-layout JSON payloads by signature, and skips layout serialization when ETag returns `304`.
75
76
  - Compressed-space prefiltering for `.blpk` packs before decryption and scan.
76
77
  - Incremental indexing that reprocesses only changed markdown files and reuses existing chunks/embeddings for unchanged notes.
@@ -529,8 +530,9 @@ Available tools:
529
530
 
530
531
  - `brainlink_bootstrap`: plug-and-play entrypoint that runs index + health checks and can return context in one call.
531
532
  - `brainlink_policy`: read or update bootstrap/context-first policy, including presets (`preset: "fully-auto" | "strict"`).
532
- - `brainlink_recommendations`: return an automatic action plan so agents can run Brainlink in the recommended order.
533
- - `brainlink_context`: read indexed context for a task or question.
533
+ - `brainlink_recommendations`: return an automatic action plan so agents can run Brainlink in the recommended order, including RAG/CAG context strategy guidance.
534
+ - `brainlink_context`: read indexed context for a task or question; pass `strategy: "rag"` for fresh retrieval assembly, `strategy: "cag"` for persisted context packs or `strategy: "auto"` for CAG hits with RAG fallback.
535
+ - `brainlink_context_packs`: list or clear persisted CAG context packs.
534
536
  - `brainlink_search`: search indexed notes.
535
537
  - `brainlink_dedupe`: detect duplicate candidates using exact hash + semantic similarity scores.
536
538
  - `brainlink_resolve_duplicate`: resolve duplicate pairs (`merge`, `link`, `ignore`) with connectivity-safe fallback edges.
@@ -555,6 +557,7 @@ By default, Brainlink enforces bootstrap and auto-runs it for read tools when se
555
557
  If you disable `autoBootstrapOnRead` through `brainlink_policy`, read tools return a preflight instruction with suggested `brainlink_bootstrap` arguments.
556
558
  `brainlink_bootstrap`, `brainlink_policy` and preflight responses include structured `nextActions` so MCP clients can continue automatically without custom parsing.
557
559
  For one-call planning, use `brainlink_recommendations` to get the recommended tool sequence for the current vault/agent/query.
560
+ The MCP context tools are plug-and-play by default: omit `strategy` to use the configured default (`rag` unless changed), pass `strategy: "cag"` for repeated/stable task context, or pass `strategy: "auto"` so Brainlink chooses CAG on fresh pack hits and RAG otherwise. `brainlink_recommendations`, preflight responses and policy next actions include executable context arguments so clients can continue without custom parsing.
558
561
 
559
562
  The same linking rule applies through MCP: `brainlink_context` is read-only, and real graph links require Markdown notes with explicit `## Context Links` sections. `brainlink_add_note` and `brainlink_add_file` reindex by default and include index + `writeConnectivity` metadata. Brainlink does not auto-link new notes to fallback hubs.
560
563
 
@@ -864,10 +867,15 @@ Context selection uses a middle-out strategy: it starts from the strongest chunk
864
867
  blink context "question" --vault ./vault --limit 12 --tokens 2000
865
868
  blink context "question" --vault ./vault --agent coding-agent --json
866
869
  blink context "question" --vault ./vault --agent coding-agent --mode hybrid --json
870
+ blink context "question" --vault ./vault --agent coding-agent --strategy cag --json
871
+ blink context "question" --vault ./vault --agent coding-agent --strategy auto --json
872
+ blink context-packs --vault ./vault --json
873
+ blink context-packs --vault ./vault --stale --clear
867
874
  ```
868
875
 
869
876
  Builds a compact context package for an agent.
870
877
  Repeated calls with the same vault, agent, query, mode and token/limit settings are served from a short in-memory cache while the index is unchanged.
878
+ The default strategy is configured by `defaultContextStrategy` and starts as `rag`, which retrieves and assembles context from the current index. `--strategy cag` enables cache-augmented context generation by reading or refreshing a persisted context pack under `.brainlink/context-packs`; `--strategy auto` uses CAG when a fresh pack exists and RAG otherwise, refreshing a pack for future calls. Context responses include `cache`, `metrics`, `requestedStrategy` and `recommendedStrategy` metadata. Packs are derived artifacts and become stale when the index or volatile memory signature changes.
871
879
 
872
880
  ### `links`
873
881
 
@@ -1010,6 +1018,7 @@ If no `vault` is configured and no `--vault` flag is passed, Brainlink uses `$HO
1010
1018
  "autoCanonicalContextLinks": true,
1011
1019
  "defaultSearchLimit": 10,
1012
1020
  "defaultContextTokens": 2000,
1021
+ "defaultContextStrategy": "rag",
1013
1022
  "embeddingProvider": "local",
1014
1023
  "defaultSearchMode": "hybrid",
1015
1024
  "chunkSize": 1200,
@@ -1024,7 +1033,8 @@ If no `vault` is configured and no `--vault` flag is passed, Brainlink uses `$HO
1024
1033
  "coding-agent": {
1025
1034
  "defaultSearchMode": "semantic",
1026
1035
  "defaultSearchLimit": 8,
1027
- "defaultContextTokens": 2400
1036
+ "defaultContextTokens": 2400,
1037
+ "defaultContextStrategy": "auto"
1028
1038
  },
1029
1039
  "*": {
1030
1040
  "defaultSearchMode": "hybrid"
@@ -1034,7 +1044,7 @@ If no `vault` is configured and no `--vault` flag is passed, Brainlink uses `$HO
1034
1044
  ```
1035
1045
 
1036
1046
  `defaultAgent` is optional. When set, CLI and MCP calls that omit `--agent`/`agent` use this value automatically. If not set, behavior remains as before.
1037
- `agentProfiles` is optional. When present, CLI and MCP resolve `mode`, `limit` and `tokens` per agent automatically, then fallback to global defaults.
1047
+ `agentProfiles` is optional. When present, CLI and MCP resolve `mode`, `limit`, `tokens` and context `strategy` per agent automatically, then fallback to global defaults.
1038
1048
 
1039
1049
  `autoIndexOnWrite` is optional and defaults to `true`. Set it to `false` to defer indexing after writes.
1040
1050
  `autoCanonicalContextLinks` is optional and defaults to `true`. When enabled, `blink add`, `brainlink_add_note` and `brainlink_add_file` add a canonical `## Context Links` entry to the inferred context hub, creating that hub when needed.
@@ -50,7 +50,7 @@ export const getExtendedStats = async (vaultPath, agentId) => {
50
50
  await searchKnowledge(absoluteVaultPath, probeQuery, Math.min(defaults.defaultSearchLimit, 8), agentId, 'hybrid');
51
51
  const searchLatency = performance.now() - searchStart;
52
52
  const contextStart = performance.now();
53
- await buildContextPackage(absoluteVaultPath, probeQuery, Math.min(defaults.defaultSearchLimit, 8), defaults.defaultContextTokens, agentId, 'hybrid');
53
+ await buildContextPackage(absoluteVaultPath, probeQuery, Math.min(defaults.defaultSearchLimit, 8), defaults.defaultContextTokens, agentId, 'hybrid', undefined, defaults.defaultContextCacheTtlMs);
54
54
  const contextLatency = performance.now() - contextStart;
55
55
  return {
56
56
  stats,
@@ -1,9 +1,10 @@
1
1
  import { stat } from 'node:fs/promises';
2
+ import { performance } from 'node:perf_hooks';
2
3
  import { formatContextPackage, selectContextSections } from '../domain/context.js';
4
+ import { readContextPack, writeContextPack } from '../infrastructure/context-packs.js';
3
5
  import { indexStoragePath } from '../infrastructure/file-index.js';
4
6
  import { searchVolatileMemory, volatileMemoryStoragePath } from '../infrastructure/volatile-memory.js';
5
7
  import { searchKnowledge } from './search-knowledge.js';
6
- const contextCacheTtlMs = 45_000;
7
8
  const contextCacheMaxEntries = 200;
8
9
  const contextCache = new Map();
9
10
  const readFileSignature = async (path) => {
@@ -15,16 +16,21 @@ const readFileSignature = async (path) => {
15
16
  return '0:0';
16
17
  }
17
18
  };
18
- const readContextDataSignature = async (vaultPath) => `${await readFileSignature(indexStoragePath(vaultPath))}|${await readFileSignature(volatileMemoryStoragePath(vaultPath))}`;
19
- const toCacheKey = (vaultPath, query, limit, maxTokens, agentId, mode) => JSON.stringify({
19
+ export const readContextDataSignature = async (vaultPath) => `${await readFileSignature(indexStoragePath(vaultPath))}|${await readFileSignature(volatileMemoryStoragePath(vaultPath))}`;
20
+ const toCacheKey = (vaultPath, query, limit, maxTokens, agentId, mode, strategy) => JSON.stringify({
20
21
  vaultPath,
21
22
  query: query.trim().toLowerCase(),
22
23
  limit,
23
24
  maxTokens,
24
25
  agentId: agentId?.trim().toLowerCase() ?? '*',
25
- mode: mode ?? 'default'
26
+ mode: mode ?? 'default',
27
+ strategy
26
28
  });
27
- const contextCacheGet = (key, dataSignature) => {
29
+ const withCacheMetadata = (context, cache) => ({
30
+ ...context,
31
+ cache
32
+ });
33
+ const contextCacheGet = (key, dataSignature, contextCacheTtlMs) => {
28
34
  const entry = contextCache.get(key);
29
35
  if (!entry) {
30
36
  return undefined;
@@ -34,7 +40,11 @@ const contextCacheGet = (key, dataSignature) => {
34
40
  contextCache.delete(key);
35
41
  return undefined;
36
42
  }
37
- return entry.context;
43
+ return withCacheMetadata(entry.context, {
44
+ storage: 'memory',
45
+ status: 'hit',
46
+ dataSignature
47
+ });
38
48
  };
39
49
  const contextCacheSet = (entry) => {
40
50
  contextCache.set(entry.key, entry);
@@ -45,32 +55,115 @@ const contextCacheSet = (entry) => {
45
55
  const keys = Array.from(contextCache.keys()).slice(0, overflow);
46
56
  keys.forEach((key) => contextCache.delete(key));
47
57
  };
48
- export const buildContextPackage = async (vaultPath, query, limit, maxTokens, agentId, mode) => {
49
- const cacheKey = toCacheKey(vaultPath, query, limit, maxTokens, agentId, mode);
58
+ const elapsedMs = (start) => Math.round((performance.now() - start) * 1000) / 1000;
59
+ const estimateSectionTokens = (context) => Math.ceil(context.content.length / 4);
60
+ const emptyMetrics = (context, totalMs, overrides = {}) => ({
61
+ totalMs,
62
+ packReadMs: 0,
63
+ searchMs: 0,
64
+ selectionMs: 0,
65
+ volatileMs: 0,
66
+ packWriteMs: 0,
67
+ sectionCount: context.sections.length,
68
+ volatileSectionCount: context.volatileSections?.length ?? 0,
69
+ estimatedTokens: estimateSectionTokens(context),
70
+ ...overrides
71
+ });
72
+ export const buildContextPackage = async (vaultPath, query, limit, maxTokens, agentId, mode, strategy = 'rag', contextCacheTtlMs = 120_000) => {
73
+ const totalStart = performance.now();
74
+ const cacheKey = toCacheKey(vaultPath, query, limit, maxTokens, agentId, mode, strategy);
50
75
  const dataSignature = await readContextDataSignature(vaultPath);
51
- const cached = contextCacheGet(cacheKey, dataSignature);
76
+ const cached = contextCacheGet(cacheKey, dataSignature, contextCacheTtlMs);
52
77
  if (cached) {
53
78
  return cached;
54
79
  }
80
+ const shouldUseContextPack = strategy === 'cag' || strategy === 'auto';
81
+ let packReadMs = 0;
82
+ if (shouldUseContextPack) {
83
+ const packReadStart = performance.now();
84
+ const pack = await readContextPack(vaultPath, { query, limit, maxTokens, agentId, mode }, dataSignature);
85
+ packReadMs = elapsedMs(packReadStart);
86
+ if (pack.status === 'hit') {
87
+ const contextFromPack = {
88
+ ...pack.context,
89
+ strategy: 'cag',
90
+ requestedStrategy: strategy,
91
+ recommendedStrategy: {
92
+ strategy: 'cag',
93
+ reason: strategy === 'auto'
94
+ ? 'A fresh context pack exists, so auto selected CAG.'
95
+ : 'The requested CAG context pack is fresh.'
96
+ },
97
+ metrics: emptyMetrics(pack.context, elapsedMs(totalStart), { packReadMs })
98
+ };
99
+ contextCacheSet({
100
+ key: cacheKey,
101
+ createdAt: Date.now(),
102
+ dataSignature,
103
+ strategy,
104
+ context: contextFromPack
105
+ });
106
+ return contextFromPack;
107
+ }
108
+ }
109
+ const searchStart = performance.now();
55
110
  const results = await searchKnowledge(vaultPath, query, limit, agentId, mode);
111
+ const searchMs = elapsedMs(searchStart);
112
+ const selectionStart = performance.now();
56
113
  const durableSections = selectContextSections(results, maxTokens);
114
+ const selectionMs = elapsedMs(selectionStart);
115
+ const volatileStart = performance.now();
57
116
  const volatileSections = await searchVolatileMemory(vaultPath, query, Math.min(3, limit), agentId, mode ?? 'hybrid');
117
+ const volatileMs = elapsedMs(volatileStart);
58
118
  const sections = [...volatileSections, ...durableSections];
119
+ const effectiveStrategy = strategy === 'cag' ? 'cag' : 'rag';
59
120
  const context = {
60
121
  query,
61
122
  sections,
62
123
  content: formatContextPackage(query, sections),
63
- ...(volatileSections.length > 0 ? { volatileSections } : {})
124
+ ...(volatileSections.length > 0 ? { volatileSections } : {}),
125
+ strategy: effectiveStrategy,
126
+ requestedStrategy: strategy,
127
+ recommendedStrategy: {
128
+ strategy: effectiveStrategy,
129
+ reason: strategy === 'auto'
130
+ ? 'No fresh context pack was available, so auto selected fresh RAG assembly and refreshed a pack for future reuse.'
131
+ : effectiveStrategy === 'cag'
132
+ ? 'CAG was requested; Brainlink refreshed the context pack from current retrieval results.'
133
+ : 'RAG was requested; Brainlink assembled context directly from current retrieval results.'
134
+ }
135
+ };
136
+ const packWriteStart = performance.now();
137
+ const packPath = shouldUseContextPack
138
+ ? await writeContextPack(vaultPath, { query, limit, maxTokens, agentId, mode }, dataSignature, context)
139
+ : undefined;
140
+ const packWriteMs = packPath ? elapsedMs(packWriteStart) : 0;
141
+ const contextWithMetadata = withCacheMetadata(context, {
142
+ storage: shouldUseContextPack ? 'context-pack' : 'memory',
143
+ status: shouldUseContextPack ? 'refresh' : 'miss',
144
+ dataSignature,
145
+ ...(packPath ? { path: packPath } : {})
146
+ });
147
+ const contextWithMetrics = {
148
+ ...contextWithMetadata,
149
+ metrics: emptyMetrics(contextWithMetadata, elapsedMs(totalStart), {
150
+ packReadMs,
151
+ searchMs,
152
+ selectionMs,
153
+ volatileMs,
154
+ packWriteMs
155
+ })
64
156
  };
65
157
  contextCacheSet({
66
158
  key: cacheKey,
67
159
  createdAt: Date.now(),
68
160
  dataSignature,
69
- context
161
+ strategy,
162
+ context: contextWithMetrics
70
163
  });
71
- return context;
164
+ return contextWithMetrics;
72
165
  };
73
- export const buildContext = async (vaultPath, query, limit, maxTokens, agentId, mode) => {
74
- const contextPackage = await buildContextPackage(vaultPath, query, limit, maxTokens, agentId, mode);
166
+ export const buildContext = async (vaultPath, query, limit, maxTokens, agentId, mode, strategy, contextCacheTtlMs = 120_000) => {
167
+ const contextPackage = await buildContextPackage(vaultPath, query, limit, maxTokens, agentId, mode, strategy, contextCacheTtlMs);
75
168
  return contextPackage.content;
76
169
  };
@@ -11,7 +11,7 @@ import { listAgents } from '../list-agents.js';
11
11
  import { listBacklinks, listLinks } from '../list-links.js';
12
12
  import { searchGraphNodeIds } from '../search-graph-node-ids.js';
13
13
  import { searchKnowledge } from '../search-knowledge.js';
14
- import { loadBrainlinkConfig, sanitizeSearchMode } from '../../infrastructure/config.js';
14
+ import { loadBrainlinkConfig, resolveAgentRuntimeDefaults, sanitizeContextStrategy, sanitizeSearchMode } from '../../infrastructure/config.js';
15
15
  import { createClientCss } from '../frontend/client-css.js';
16
16
  import { createClientHtml } from '../frontend/client-html.js';
17
17
  import { createClientJs } from '../frontend/client-js.js';
@@ -20,12 +20,27 @@ import { createClientRenderWorkerJs } from '../frontend/client-render-worker-js.
20
20
  import { contentTypes, createJsonResponse, isReadMethod, parsePositiveInteger } from './http.js';
21
21
  const readSearchMode = async (url) => {
22
22
  const config = await loadBrainlinkConfig();
23
- return sanitizeSearchMode(url.searchParams.get('mode'), config.defaultSearchMode);
23
+ const defaults = resolveAgentRuntimeDefaults(config, readAgentQuery(url));
24
+ return sanitizeSearchMode(url.searchParams.get('mode'), defaults.defaultSearchMode);
25
+ };
26
+ const readContextStrategy = async (url) => {
27
+ const config = await loadBrainlinkConfig();
28
+ const defaults = resolveAgentRuntimeDefaults(config, readAgentQuery(url));
29
+ return sanitizeContextStrategy(url.searchParams.get('strategy'), defaults.defaultContextStrategy);
30
+ };
31
+ const readContextCacheTtlMs = async (url) => {
32
+ const config = await loadBrainlinkConfig();
33
+ const defaults = resolveAgentRuntimeDefaults(config, readAgentQuery(url));
34
+ return defaults.defaultContextCacheTtlMs;
24
35
  };
25
36
  const hasInvalidSearchMode = (url) => {
26
37
  const mode = url.searchParams.get('mode');
27
38
  return mode !== null && !['fts', 'semantic', 'hybrid'].includes(mode);
28
39
  };
40
+ const hasInvalidContextStrategy = (url) => {
41
+ const strategy = url.searchParams.get('strategy');
42
+ return strategy !== null && !['rag', 'cag', 'auto'].includes(strategy);
43
+ };
29
44
  const createResponse = (body, statusCode = 200, contentType = 'text/plain; charset=utf-8') => ({
30
45
  body,
31
46
  statusCode,
@@ -370,10 +385,15 @@ export const route = async (request, url, vaultPath) => {
370
385
  const limit = parsePositiveInteger(url.searchParams.get('limit'), 12);
371
386
  const tokens = parsePositiveInteger(url.searchParams.get('tokens'), 2000);
372
387
  const mode = await readSearchMode(url);
388
+ const strategy = await readContextStrategy(url);
389
+ const contextCacheTtlMs = await readContextCacheTtlMs(url);
373
390
  if (hasInvalidSearchMode(url)) {
374
391
  return createResponse(createJsonResponse({ error: 'Invalid mode. Use fts, semantic or hybrid.' }), 400, contentTypes['.json']);
375
392
  }
376
- return createResponse(createJsonResponse(await buildContextPackage(vaultPath, query, limit, tokens, readAgentQuery(url), mode)), 200, contentTypes['.json']);
393
+ if (hasInvalidContextStrategy(url)) {
394
+ return createResponse(createJsonResponse({ error: 'Invalid strategy. Use rag, cag or auto.' }), 400, contentTypes['.json']);
395
+ }
396
+ return createResponse(createJsonResponse(await buildContextPackage(vaultPath, query, limit, tokens, readAgentQuery(url), mode, strategy, contextCacheTtlMs)), 200, contentTypes['.json']);
377
397
  }
378
398
  if (isReadMethod(request) && url.pathname === '/api/links') {
379
399
  return createResponse(createJsonResponse({ links: await listLinks(vaultPath, readAgentQuery(url)) }), 200, contentTypes['.json']);
@@ -1,10 +1,11 @@
1
1
  import { getBrokenLinksReport, getExtendedStats, getOrphansReport, getStats, validateVault } from '../../application/analyze-vault.js';
2
- import { buildContextPackage } from '../../application/build-context.js';
2
+ import { buildContextPackage, readContextDataSignature } from '../../application/build-context.js';
3
3
  import { getGraph } from '../../application/get-graph.js';
4
4
  import { listAgents } from '../../application/list-agents.js';
5
5
  import { listBacklinks, listLinks } from '../../application/list-links.js';
6
6
  import { searchKnowledge } from '../../application/search-knowledge.js';
7
- import { sanitizeSearchMode } from '../../infrastructure/config.js';
7
+ import { sanitizeContextStrategy, sanitizeSearchMode } from '../../infrastructure/config.js';
8
+ import { clearContextPacks, listContextPacks } from '../../infrastructure/context-packs.js';
8
9
  import { parsePositiveInteger, print, resolveOptions } from '../runtime.js';
9
10
  export const registerReadCommands = (program) => {
10
11
  program
@@ -61,14 +62,49 @@ export const registerReadCommands = (program) => {
61
62
  .option('-l, --limit <limit>', 'maximum search results before context selection')
62
63
  .option('-t, --tokens <tokens>', 'maximum estimated context tokens')
63
64
  .option('-m, --mode <mode>', 'search mode: fts, semantic or hybrid')
65
+ .option('--strategy <strategy>', 'context strategy: rag, cag or auto')
64
66
  .option('--json', 'print machine-readable JSON')
65
67
  .description('build a compact context package for an agent')
66
68
  .action(async (query, options) => {
67
69
  const resolved = await resolveOptions(options);
68
70
  const mode = sanitizeSearchMode(options.mode, resolved.defaults.defaultSearchMode);
69
- const contextPackage = await buildContextPackage(resolved.vault, query, parsePositiveInteger(options.limit ?? String(resolved.defaults.defaultSearchLimit), resolved.defaults.defaultSearchLimit), parsePositiveInteger(options.tokens ?? String(resolved.defaults.defaultContextTokens), resolved.defaults.defaultContextTokens), resolved.agent, mode);
71
+ const strategy = sanitizeContextStrategy(options.strategy, resolved.defaults.defaultContextStrategy);
72
+ const contextPackage = await buildContextPackage(resolved.vault, query, parsePositiveInteger(options.limit ?? String(resolved.defaults.defaultSearchLimit), resolved.defaults.defaultSearchLimit), parsePositiveInteger(options.tokens ?? String(resolved.defaults.defaultContextTokens), resolved.defaults.defaultContextTokens), resolved.agent, mode, strategy, resolved.defaults.defaultContextCacheTtlMs);
70
73
  print(options.json, contextPackage, () => contextPackage.content);
71
74
  });
75
+ program
76
+ .command('context-packs')
77
+ .option('-v, --vault <vault>', 'vault directory')
78
+ .option('-a, --agent <agent>', 'accepted for consistency; context packs are already keyed by agent')
79
+ .option('--stale', 'operate only on packs stale for the current index and volatile-memory signature')
80
+ .option('--clear', 'remove context packs instead of listing them')
81
+ .option('--json', 'print machine-readable JSON')
82
+ .description('list or clear persisted CAG context packs')
83
+ .action(async (options) => {
84
+ const resolved = await resolveOptions(options);
85
+ const dataSignature = await readContextDataSignature(resolved.vault);
86
+ if (options.clear) {
87
+ const result = await clearContextPacks(resolved.vault, {
88
+ staleOnly: options.stale === true,
89
+ dataSignature
90
+ });
91
+ print(options.json, { vault: resolved.vault, dataSignature, ...result }, () => [
92
+ `Removed context packs: ${result.removed.length}`,
93
+ `Kept context packs: ${result.kept.length}`
94
+ ].join('\n'));
95
+ return;
96
+ }
97
+ const packs = await listContextPacks(resolved.vault, dataSignature);
98
+ const visiblePacks = options.stale ? packs.filter((pack) => pack.stale) : packs;
99
+ print(options.json, { vault: resolved.vault, dataSignature, packs: visiblePacks }, () => visiblePacks.length === 0
100
+ ? 'No context packs found.'
101
+ : visiblePacks
102
+ .map((pack) => [
103
+ `${pack.filename} ${pack.stale ? 'stale' : 'fresh'} ${pack.sizeBytes} bytes`,
104
+ pack.key ? `query="${pack.key.query}" agent=${pack.key.agentId ?? '*'} mode=${pack.key.mode ?? 'default'} limit=${pack.key.limit} tokens=${pack.key.maxTokens}` : 'unreadable pack'
105
+ ].join('\n'))
106
+ .join('\n\n'));
107
+ });
72
108
  program
73
109
  .command('graph')
74
110
  .option('-v, --vault <vault>', 'vault directory')
@@ -15,7 +15,7 @@ import { createOfflinePackBackup } from '../../application/offline-pack-backup.j
15
15
  import { startServer } from '../../application/start-server.js';
16
16
  import { startVaultWatcher } from '../../application/watch-vault.js';
17
17
  import { doctorVault, getStats, validateVault } from '../../application/analyze-vault.js';
18
- import { defaultBrainlinkConfig, sanitizeSearchMode } from '../../infrastructure/config.js';
18
+ import { defaultBrainlinkConfig, sanitizeContextStrategy, sanitizeSearchMode } from '../../infrastructure/config.js';
19
19
  import { loadBrainlinkConfig } from '../../infrastructure/config.js';
20
20
  import { assertVaultAllowed, ensureVault, isBucketVaultPath } from '../../infrastructure/file-system-vault.js';
21
21
  import { getBootstrapPolicy, getBootstrapSessionStatus, touchBootstrapSession } from '../../infrastructure/session-state.js';
@@ -1111,6 +1111,7 @@ export const registerWriteCommands = (program) => {
1111
1111
  .option('-a, --agent <agent>', 'agent memory namespace')
1112
1112
  .option('--query <query>', 'optional task query to return immediate grounded context')
1113
1113
  .option('--mode <mode>', 'search mode for context (fts|semantic|hybrid)')
1114
+ .option('--strategy <strategy>', 'context strategy for context (rag|cag|auto)')
1114
1115
  .option('--limit <limit>', 'maximum context sections')
1115
1116
  .option('--tokens <tokens>', 'maximum context token budget')
1116
1117
  .option('--no-install-agent', 'skip agent MCP/plugin installation and upgrade automation')
@@ -1125,6 +1126,7 @@ export const registerWriteCommands = (program) => {
1125
1126
  const limit = parsePositiveInteger(options.limit ?? String(resolved.defaults.defaultSearchLimit), resolved.defaults.defaultSearchLimit);
1126
1127
  const tokens = parsePositiveInteger(options.tokens ?? String(resolved.defaults.defaultContextTokens), resolved.defaults.defaultContextTokens);
1127
1128
  const mode = sanitizeSearchMode(options.mode, resolved.defaults.defaultSearchMode);
1129
+ const strategy = sanitizeContextStrategy(options.strategy, resolved.defaults.defaultContextStrategy);
1128
1130
  const index = await indexVault(resolved.vault);
1129
1131
  const stats = await getStats(resolved.vault, resolved.agent);
1130
1132
  const validation = await validateVault(resolved.vault, resolved.agent);
@@ -1133,7 +1135,7 @@ export const registerWriteCommands = (program) => {
1133
1135
  const policy = await getBootstrapPolicy();
1134
1136
  const bootstrapStatus = await getBootstrapSessionStatus(resolved.vault, resolved.agent);
1135
1137
  const context = options.query
1136
- ? await buildContextPackage(resolved.vault, options.query, limit, tokens, resolved.agent, mode)
1138
+ ? await buildContextPackage(resolved.vault, options.query, limit, tokens, resolved.agent, mode, strategy, resolved.defaults.defaultContextCacheTtlMs)
1137
1139
  : null;
1138
1140
  const agentIntegration = options.installAgent === false
1139
1141
  ? null
@@ -13,6 +13,8 @@ export const defaultBrainlinkConfig = {
13
13
  autoCanonicalContextLinks: true,
14
14
  defaultSearchLimit: 10,
15
15
  defaultContextTokens: 2000,
16
+ defaultContextStrategy: 'rag',
17
+ defaultContextCacheTtlMs: 120_000,
16
18
  embeddingProvider: 'local',
17
19
  defaultSearchMode: 'hybrid',
18
20
  chunkSize: 1200,
@@ -41,8 +43,10 @@ const safeCwd = () => {
41
43
  const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
42
44
  const embeddingProviders = new Set(['none', 'local']);
43
45
  const searchModes = new Set(['fts', 'semantic', 'hybrid']);
46
+ const contextStrategies = new Set(['rag', 'cag', 'auto']);
44
47
  const sanitizeEmbeddingProvider = (value) => typeof value === 'string' && embeddingProviders.has(value) ? value : defaultBrainlinkConfig.embeddingProvider;
45
48
  export const sanitizeSearchMode = (value, fallback = defaultBrainlinkConfig.defaultSearchMode) => typeof value === 'string' && searchModes.has(value) ? value : fallback;
49
+ export const sanitizeContextStrategy = (value, fallback = 'rag') => typeof value === 'string' && contextStrategies.has(value) ? value : fallback;
46
50
  const sanitizeAllowedVaults = (value) => Array.isArray(value) ? value.filter((item) => typeof item === 'string' && item.trim().length > 0) : [];
47
51
  const sanitizePositiveNumber = (value) => typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : undefined;
48
52
  const sanitizeIntegerInRange = (value, fallback, minimum, maximum) => {
@@ -84,10 +88,20 @@ const sanitizeAgentProfile = (value) => {
84
88
  const defaultSearchMode = typeof value.defaultSearchMode === 'string' && searchModes.has(value.defaultSearchMode)
85
89
  ? value.defaultSearchMode
86
90
  : undefined;
91
+ const defaultContextStrategy = typeof value.defaultContextStrategy === 'string' && contextStrategies.has(value.defaultContextStrategy)
92
+ ? value.defaultContextStrategy
93
+ : undefined;
94
+ const defaultContextCacheTtlMs = typeof value.defaultContextCacheTtlMs === 'number' &&
95
+ Number.isFinite(value.defaultContextCacheTtlMs) &&
96
+ value.defaultContextCacheTtlMs > 0
97
+ ? Math.floor(value.defaultContextCacheTtlMs)
98
+ : undefined;
87
99
  const profile = {
88
100
  ...(defaultSearchLimit ? { defaultSearchLimit } : {}),
89
101
  ...(defaultContextTokens ? { defaultContextTokens } : {}),
90
- ...(defaultSearchMode ? { defaultSearchMode } : {})
102
+ ...(defaultSearchMode ? { defaultSearchMode } : {}),
103
+ ...(defaultContextStrategy ? { defaultContextStrategy } : {}),
104
+ ...(defaultContextCacheTtlMs ? { defaultContextCacheTtlMs } : {})
91
105
  };
92
106
  return Object.keys(profile).length > 0 ? profile : null;
93
107
  };
@@ -169,6 +183,10 @@ const sanitizeConfig = (value) => ({
169
183
  defaultContextTokens: typeof value.defaultContextTokens === 'number' && value.defaultContextTokens > 0
170
184
  ? value.defaultContextTokens
171
185
  : defaultBrainlinkConfig.defaultContextTokens,
186
+ defaultContextStrategy: sanitizeContextStrategy(value.defaultContextStrategy, defaultBrainlinkConfig.defaultContextStrategy),
187
+ defaultContextCacheTtlMs: typeof value.defaultContextCacheTtlMs === 'number' && Number.isFinite(value.defaultContextCacheTtlMs) && value.defaultContextCacheTtlMs > 0
188
+ ? Math.floor(value.defaultContextCacheTtlMs)
189
+ : defaultBrainlinkConfig.defaultContextCacheTtlMs,
172
190
  allowedVaults: [...sanitizeAllowedVaults(value.allowedVaults), ...readAllowedVaultsFromEnv()],
173
191
  chunkSize: typeof value.chunkSize === 'number' && value.chunkSize > 0 ? value.chunkSize : defaultBrainlinkConfig.chunkSize,
174
192
  searchPack: sanitizeSearchPackConfig(value.searchPack),
@@ -182,7 +200,9 @@ export const resolveAgentRuntimeDefaults = (config, agent) => {
182
200
  return {
183
201
  defaultSearchLimit: profile?.defaultSearchLimit ?? config.defaultSearchLimit,
184
202
  defaultContextTokens: profile?.defaultContextTokens ?? config.defaultContextTokens,
185
- defaultSearchMode: profile?.defaultSearchMode ?? config.defaultSearchMode
203
+ defaultSearchMode: profile?.defaultSearchMode ?? config.defaultSearchMode,
204
+ defaultContextStrategy: profile?.defaultContextStrategy ?? config.defaultContextStrategy,
205
+ defaultContextCacheTtlMs: profile?.defaultContextCacheTtlMs ?? config.defaultContextCacheTtlMs
186
206
  };
187
207
  };
188
208
  const mergeConfigLayers = (layers) => layers.reduce((state, config) => ({
@@ -0,0 +1,122 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { mkdir, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises';
3
+ import { basename, join } from 'node:path';
4
+ const normalizePackKey = (key) => ({
5
+ query: key.query.trim().toLowerCase(),
6
+ limit: key.limit,
7
+ maxTokens: key.maxTokens,
8
+ agentId: key.agentId?.trim().toLowerCase() || undefined,
9
+ mode: key.mode
10
+ });
11
+ export const contextPacksDirectory = (vaultPath) => join(vaultPath, '.brainlink', 'context-packs');
12
+ export const contextPackPath = (vaultPath, key) => {
13
+ const digest = createHash('sha256').update(JSON.stringify(normalizePackKey(key))).digest('hex');
14
+ return join(contextPacksDirectory(vaultPath), `${digest}.json`);
15
+ };
16
+ const isStoredContextPack = (value) => {
17
+ if (!value || typeof value !== 'object') {
18
+ return false;
19
+ }
20
+ const candidate = value;
21
+ return candidate.version === 1 && typeof candidate.dataSignature === 'string' && Boolean(candidate.context);
22
+ };
23
+ const readStoredContextPack = async (path) => {
24
+ try {
25
+ const parsed = JSON.parse(await readFile(path, 'utf8'));
26
+ return isStoredContextPack(parsed) ? parsed : null;
27
+ }
28
+ catch {
29
+ return null;
30
+ }
31
+ };
32
+ const toContextPackSummary = async (path, dataSignature) => {
33
+ const [info, stored] = await Promise.all([
34
+ stat(path),
35
+ readStoredContextPack(path)
36
+ ]);
37
+ const stale = !stored || (typeof dataSignature === 'string' && stored.dataSignature !== dataSignature);
38
+ return {
39
+ path,
40
+ filename: basename(path),
41
+ createdAt: stored?.createdAt ?? null,
42
+ dataSignature: stored?.dataSignature ?? null,
43
+ key: stored?.key ?? null,
44
+ sizeBytes: info.size,
45
+ stale
46
+ };
47
+ };
48
+ export const listContextPacks = async (vaultPath, dataSignature) => {
49
+ const directory = contextPacksDirectory(vaultPath);
50
+ try {
51
+ const entries = await readdir(directory, { withFileTypes: true });
52
+ const paths = entries
53
+ .filter((entry) => entry.isFile() && entry.name.endsWith('.json'))
54
+ .map((entry) => join(directory, entry.name));
55
+ const summaries = await Promise.all(paths.map((path) => toContextPackSummary(path, dataSignature)));
56
+ return summaries.sort((left, right) => (right.createdAt ?? '').localeCompare(left.createdAt ?? '') || left.filename.localeCompare(right.filename));
57
+ }
58
+ catch (error) {
59
+ if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
60
+ return [];
61
+ }
62
+ throw error;
63
+ }
64
+ };
65
+ export const clearContextPacks = async (vaultPath, options = {}) => {
66
+ const packs = await listContextPacks(vaultPath, options.dataSignature);
67
+ const removed = options.staleOnly ? packs.filter((pack) => pack.stale) : packs;
68
+ const kept = options.staleOnly ? packs.filter((pack) => !pack.stale) : [];
69
+ await Promise.all(removed.map((pack) => rm(pack.path, { force: true })));
70
+ return { removed, kept };
71
+ };
72
+ export const readContextPack = async (vaultPath, key, dataSignature) => {
73
+ const path = contextPackPath(vaultPath, key);
74
+ try {
75
+ const parsed = await readStoredContextPack(path);
76
+ if (!parsed) {
77
+ return { status: 'stale', path };
78
+ }
79
+ if (parsed.dataSignature !== dataSignature) {
80
+ return { status: 'stale', path };
81
+ }
82
+ return {
83
+ status: 'hit',
84
+ path,
85
+ context: {
86
+ ...parsed.context,
87
+ strategy: 'cag',
88
+ cache: {
89
+ storage: 'context-pack',
90
+ status: 'hit',
91
+ dataSignature,
92
+ path
93
+ }
94
+ }
95
+ };
96
+ }
97
+ catch {
98
+ return { status: 'miss', path };
99
+ }
100
+ };
101
+ export const writeContextPack = async (vaultPath, key, dataSignature, context) => {
102
+ const path = contextPackPath(vaultPath, key);
103
+ const stored = {
104
+ version: 1,
105
+ createdAt: new Date().toISOString(),
106
+ dataSignature,
107
+ key: normalizePackKey(key),
108
+ context: {
109
+ ...context,
110
+ strategy: 'cag',
111
+ cache: {
112
+ storage: 'context-pack',
113
+ status: 'refresh',
114
+ dataSignature,
115
+ path
116
+ }
117
+ }
118
+ };
119
+ await mkdir(contextPacksDirectory(vaultPath), { recursive: true });
120
+ await writeFile(path, `${JSON.stringify(stored, null, 2)}\n`, 'utf8');
121
+ return path;
122
+ };
@@ -1,5 +1,5 @@
1
1
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
- import { addNoteInputSchema, addFileInputSchema, addFileTool, addNoteTool, volatileAddInputSchema, volatileAddTool, volatileClearInputSchema, volatileClearTool, dedupeInputSchema, dedupeResolveInputSchema, dedupeResolveTool, dedupeTool, brokenLinksInputSchema, brokenLinksTool, bootstrapInputSchema, bootstrapTool, canonicalizeContextLinksInputSchema, canonicalizeContextLinksTool, contextInputSchema, contextTool, graphContextsInputSchema, graphContextsTool, graphInputSchema, graphTool, indexInputSchema, indexTool, orphansInputSchema, orphansTool, policyInputSchema, policyTool, recommendationsInputSchema, recommendationsTool, searchInputSchema, searchTool, statsInputSchema, statsTool, syncInputSchema, syncTool, validateInputSchema, validateTool, versionInputSchema, versionTool } from './tools.js';
2
+ import { addNoteInputSchema, addFileInputSchema, addFileTool, addNoteTool, volatileAddInputSchema, volatileAddTool, volatileClearInputSchema, volatileClearTool, dedupeInputSchema, dedupeResolveInputSchema, dedupeResolveTool, dedupeTool, brokenLinksInputSchema, brokenLinksTool, bootstrapInputSchema, bootstrapTool, canonicalizeContextLinksInputSchema, canonicalizeContextLinksTool, contextInputSchema, contextPacksInputSchema, contextPacksTool, contextTool, graphContextsInputSchema, graphContextsTool, graphInputSchema, graphTool, indexInputSchema, indexTool, orphansInputSchema, orphansTool, policyInputSchema, policyTool, recommendationsInputSchema, recommendationsTool, searchInputSchema, searchTool, statsInputSchema, statsTool, syncInputSchema, syncTool, validateInputSchema, validateTool, versionInputSchema, versionTool } from './tools.js';
3
3
  import { getRuntimeVersion } from './runtime.js';
4
4
  export const createBrainlinkMcpServer = () => {
5
5
  const server = new McpServer({
@@ -25,14 +25,19 @@ export const createBrainlinkMcpServer = () => {
25
25
  }, versionTool);
26
26
  server.registerTool('brainlink_recommendations', {
27
27
  title: 'Brainlink Recommended MCP Workflow',
28
- description: 'Return a plug-and-play action plan for this vault/agent, including policy, bootstrap, context retrieval and durable write guidance.',
28
+ description: 'Return a plug-and-play action plan for this vault/agent, including policy, bootstrap, RAG/CAG context strategy, retrieval and durable write guidance.',
29
29
  inputSchema: recommendationsInputSchema
30
30
  }, recommendationsTool);
31
31
  server.registerTool('brainlink_context', {
32
32
  title: 'Build Brainlink Context',
33
- description: 'Read indexed Brainlink memory for a task or question. Usually called after brainlink_bootstrap. This is read-only and does not create graph links.',
33
+ description: 'Read indexed Brainlink memory for a task or question. Agents can choose strategy per call: rag for fresh retrieval assembly, cag for persisted context packs, or auto for pack-hit CAG with RAG fallback. Usually called after brainlink_bootstrap. This is read-only and does not create graph links.',
34
34
  inputSchema: contextInputSchema
35
35
  }, contextTool);
36
+ server.registerTool('brainlink_context_packs', {
37
+ title: 'Manage Brainlink Context Packs',
38
+ description: 'List or clear persisted CAG context packs. Packs are derived artifacts and can be rebuilt from Markdown/index state.',
39
+ inputSchema: contextPacksInputSchema
40
+ }, contextPacksTool);
36
41
  server.registerTool('brainlink_search', {
37
42
  title: 'Search Brainlink Memory',
38
43
  description: 'Search indexed Brainlink notes with FTS, semantic or hybrid retrieval.',
package/dist/mcp/tools.js CHANGED
@@ -3,14 +3,15 @@ import { basename, extname } from 'node:path';
3
3
  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
- import { buildContextPackage } from '../application/build-context.js';
6
+ import { buildContextPackage, readContextDataSignature } from '../application/build-context.js';
7
7
  import { canonicalizeContextLinks } from '../application/canonical-context-links.js';
8
8
  import { resolveDuplicateNotes, scanDuplicateNotes } from '../application/dedupe-notes.js';
9
9
  import { getGraph } from '../application/get-graph.js';
10
10
  import { getGraphContexts } from '../application/get-graph-contexts.js';
11
11
  import { indexVault } from '../application/index-vault.js';
12
12
  import { searchKnowledge } from '../application/search-knowledge.js';
13
- import { resolveAgentRuntimeDefaults, sanitizeSearchMode } from '../infrastructure/config.js';
13
+ import { resolveAgentRuntimeDefaults, sanitizeContextStrategy, sanitizeSearchMode } from '../infrastructure/config.js';
14
+ import { clearContextPacks, listContextPacks } from '../infrastructure/context-packs.js';
14
15
  import { loadBrainlinkConfig } from '../infrastructure/config.js';
15
16
  import { assertVaultAllowed } from '../infrastructure/file-system-vault.js';
16
17
  import { addVolatileMemory, clearVolatileMemory } from '../infrastructure/volatile-memory.js';
@@ -40,6 +41,12 @@ const agentInput = {
40
41
  const searchModeInput = {
41
42
  mode: z.enum(['fts', 'semantic', 'hybrid']).optional().describe('Search mode. Defaults to the Brainlink config value.')
42
43
  };
44
+ const contextStrategyInput = {
45
+ strategy: z
46
+ .enum(['rag', 'cag', 'auto'])
47
+ .optional()
48
+ .describe('Context strategy per call. Use rag for fresh retrieval assembly, cag to reuse persisted context packs when fresh, or auto to choose CAG on fresh pack hits and RAG otherwise. Defaults to the Brainlink config value.')
49
+ };
43
50
  const resolveExecutionContext = async (input) => {
44
51
  const config = await loadBrainlinkConfig();
45
52
  const vault = await assertVaultAllowed(input.vault ?? config.vault, config.allowedVaults);
@@ -122,12 +129,14 @@ const ensureBootstrapReady = async (context, input, toolName) => {
122
129
  };
123
130
  }
124
131
  const mode = typeof input.mode === 'string' && ['fts', 'semantic', 'hybrid'].includes(input.mode) ? input.mode : 'hybrid';
132
+ const strategy = sanitizeContextStrategy(typeof input.strategy === 'string' ? input.strategy : undefined, 'rag');
125
133
  const query = typeof input.query === 'string' && input.query.trim().length > 0 ? input.query : undefined;
126
134
  const bootstrapArgs = {
127
135
  vault: context.vault,
128
136
  ...(context.agent ? { agent: context.agent } : {}),
129
137
  ...(query ? { query } : {}),
130
- mode
138
+ mode,
139
+ strategy
131
140
  };
132
141
  const nextActions = [
133
142
  {
@@ -178,6 +187,7 @@ const ensureContextReady = async (context, input, toolName) => {
178
187
  ? input.contextQuery
179
188
  : '<task>';
180
189
  const mode = sanitizeSearchMode(typeof input.mode === 'string' ? input.mode : undefined, context.defaults.defaultSearchMode);
190
+ const strategy = sanitizeContextStrategy(typeof input.strategy === 'string' ? input.strategy : undefined, context.defaults.defaultContextStrategy);
181
191
  const limit = typeof input.limit === 'number' && Number.isFinite(input.limit) && input.limit > 0
182
192
  ? input.limit
183
193
  : typeof input.contextLimit === 'number' && Number.isFinite(input.contextLimit) && input.contextLimit > 0
@@ -193,6 +203,7 @@ const ensureContextReady = async (context, input, toolName) => {
193
203
  ...(context.agent ? { agent: context.agent } : {}),
194
204
  query: queryFromInput,
195
205
  mode,
206
+ strategy,
196
207
  limit,
197
208
  tokens
198
209
  };
@@ -219,10 +230,17 @@ export const contextInputSchema = {
219
230
  ...vaultInput,
220
231
  ...agentInput,
221
232
  ...searchModeInput,
233
+ ...contextStrategyInput,
222
234
  query: z.string().min(1).describe('Task or question to retrieve Brainlink context for.'),
223
235
  limit: optionalPositiveInteger().describe('Maximum search results before context selection.'),
224
236
  tokens: optionalPositiveInteger().describe('Maximum estimated context tokens.')
225
237
  };
238
+ export const contextPacksInputSchema = {
239
+ ...vaultInput,
240
+ ...agentInput,
241
+ action: z.enum(['list', 'clear']).optional().default('list').describe('Action to perform on persisted CAG context packs.'),
242
+ staleOnly: z.boolean().optional().default(false).describe('When clearing, remove only packs stale for the current index and volatile-memory signature.')
243
+ };
226
244
  export const searchInputSchema = {
227
245
  ...vaultInput,
228
246
  ...agentInput,
@@ -311,6 +329,7 @@ export const syncInputSchema = {
311
329
  ...agentInput,
312
330
  contextQuery: z.string().min(1).optional().describe('Optional context smoke query. Omit to skip context probe.'),
313
331
  mode: z.enum(['fts', 'semantic', 'hybrid']).optional().describe('Search mode for the optional context probe. Defaults to config value.'),
332
+ strategy: z.enum(['rag', 'cag', 'auto']).optional().describe('Context strategy for the optional context probe. Defaults to the Brainlink config value.'),
314
333
  contextLimit: optionalPositiveInteger().describe('Context smoke result limit when contextQuery is provided.'),
315
334
  contextTokens: optionalPositiveInteger().describe('Context smoke token target when contextQuery is provided.')
316
335
  };
@@ -318,6 +337,7 @@ export const bootstrapInputSchema = {
318
337
  ...vaultInput,
319
338
  ...agentInput,
320
339
  ...searchModeInput,
340
+ ...contextStrategyInput,
321
341
  query: z
322
342
  .string()
323
343
  .min(1)
@@ -350,6 +370,7 @@ export const recommendationsInputSchema = {
350
370
  ...vaultInput,
351
371
  ...agentInput,
352
372
  ...searchModeInput,
373
+ ...contextStrategyInput,
353
374
  query: z.string().min(1).optional().describe('Optional current task query to generate context-focused recommendations.'),
354
375
  limit: optionalPositiveInteger().describe('Optional context limit override for generated recommendations.'),
355
376
  tokens: optionalPositiveInteger().describe('Optional context token budget override for generated recommendations.')
@@ -375,14 +396,16 @@ export const contextTool = async (input) => {
375
396
  return readiness.preflight;
376
397
  }
377
398
  const mode = sanitizeSearchMode(input.mode, context.defaults.defaultSearchMode);
399
+ const strategy = sanitizeContextStrategy(input.strategy, context.defaults.defaultContextStrategy);
378
400
  const limit = input.limit ?? context.defaults.defaultSearchLimit;
379
401
  const tokens = input.tokens ?? context.defaults.defaultContextTokens;
380
- const contextPackage = await buildContextPackage(context.vault, input.query, limit, tokens, context.agent, mode);
402
+ const contextPackage = await buildContextPackage(context.vault, input.query, limit, tokens, context.agent, mode, strategy, context.defaults.defaultContextCacheTtlMs);
381
403
  const contextSession = await touchContextSession(context.vault, context.agent);
382
404
  return jsonResult({
383
405
  vault: context.vault,
384
406
  agent: context.agent,
385
407
  mode,
408
+ strategy,
386
409
  limit,
387
410
  tokens,
388
411
  contextSession,
@@ -390,6 +413,32 @@ export const contextTool = async (input) => {
390
413
  ...contextPackage
391
414
  });
392
415
  };
416
+ export const contextPacksTool = async (input) => {
417
+ const context = await resolveExecutionContext(input);
418
+ const dataSignature = await readContextDataSignature(context.vault);
419
+ if (input.action === 'clear') {
420
+ const result = await clearContextPacks(context.vault, {
421
+ staleOnly: input.staleOnly === true,
422
+ dataSignature
423
+ });
424
+ return jsonResult({
425
+ vault: context.vault,
426
+ agent: context.agent,
427
+ dataSignature,
428
+ action: 'clear',
429
+ staleOnly: input.staleOnly === true,
430
+ ...result
431
+ });
432
+ }
433
+ const packs = await listContextPacks(context.vault, dataSignature);
434
+ return jsonResult({
435
+ vault: context.vault,
436
+ agent: context.agent,
437
+ dataSignature,
438
+ action: 'list',
439
+ packs
440
+ });
441
+ };
393
442
  export const searchTool = async (input) => {
394
443
  const context = await resolveExecutionContext(input);
395
444
  const readiness = await ensureBootstrapReady(context, input, 'brainlink_search');
@@ -666,14 +715,16 @@ export const syncTool = async (input) => {
666
715
  return jsonResult(response);
667
716
  }
668
717
  const mode = sanitizeSearchMode(input.mode, context.defaults.defaultSearchMode);
718
+ const strategy = sanitizeContextStrategy(input.strategy, context.defaults.defaultContextStrategy);
669
719
  const contextLimit = input.contextLimit ?? context.defaults.defaultSearchLimit;
670
720
  const contextTokens = input.contextTokens ?? context.defaults.defaultContextTokens;
671
- const contextPackage = await buildContextPackage(context.vault, input.contextQuery, contextLimit, contextTokens, context.agent, mode);
721
+ const contextPackage = await buildContextPackage(context.vault, input.contextQuery, contextLimit, contextTokens, context.agent, mode, strategy, context.defaults.defaultContextCacheTtlMs);
672
722
  const contextSession = await touchContextSession(context.vault, context.agent);
673
723
  return jsonResult({
674
724
  ...response,
675
725
  context: {
676
726
  mode,
727
+ strategy,
677
728
  contextSession,
678
729
  ...contextPackage
679
730
  }
@@ -685,10 +736,11 @@ export const bootstrapTool = async (input) => {
685
736
  const stats = await getStats(context.vault, context.agent);
686
737
  const validation = await validateVault(context.vault, context.agent);
687
738
  const mode = sanitizeSearchMode(input.mode, context.defaults.defaultSearchMode);
739
+ const strategy = sanitizeContextStrategy(input.strategy, context.defaults.defaultContextStrategy);
688
740
  const limit = input.limit ?? context.defaults.defaultSearchLimit;
689
741
  const tokens = input.tokens ?? context.defaults.defaultContextTokens;
690
742
  const contextPackage = input.query
691
- ? await buildContextPackage(context.vault, input.query, limit, tokens, context.agent, mode)
743
+ ? await buildContextPackage(context.vault, input.query, limit, tokens, context.agent, mode, strategy, context.defaults.defaultContextCacheTtlMs)
692
744
  : undefined;
693
745
  const contextSession = input.query ? await touchContextSession(context.vault, context.agent) : undefined;
694
746
  const guidance = stats.documentCount === 0
@@ -742,6 +794,7 @@ export const bootstrapTool = async (input) => {
742
794
  ...(context.agent ? { agent: context.agent } : {}),
743
795
  query: '<task>',
744
796
  mode,
797
+ strategy,
745
798
  limit,
746
799
  tokens
747
800
  }
@@ -751,6 +804,7 @@ export const bootstrapTool = async (input) => {
751
804
  vault: context.vault,
752
805
  agent: context.agent,
753
806
  mode,
807
+ strategy,
754
808
  limit,
755
809
  tokens,
756
810
  index,
@@ -806,7 +860,8 @@ export const policyTool = async (input) => {
806
860
  args: {
807
861
  vault: context.vault,
808
862
  ...(context.agent ? { agent: context.agent } : {}),
809
- mode: context.defaults.defaultSearchMode
863
+ mode: context.defaults.defaultSearchMode,
864
+ strategy: context.defaults.defaultContextStrategy
810
865
  }
811
866
  }
812
867
  ];
@@ -821,6 +876,7 @@ export const policyTool = async (input) => {
821
876
  ...(context.agent ? { agent: context.agent } : {}),
822
877
  query: '<task>',
823
878
  mode: context.defaults.defaultSearchMode,
879
+ strategy: context.defaults.defaultContextStrategy,
824
880
  limit: context.defaults.defaultSearchLimit,
825
881
  tokens: context.defaults.defaultContextTokens
826
882
  }
@@ -852,6 +908,7 @@ export const recommendationsTool = async (input) => {
852
908
  const contextStatus = await getContextSessionStatus(context.vault, context.agent);
853
909
  const stats = await getStats(context.vault, context.agent);
854
910
  const mode = sanitizeSearchMode(input.mode, context.defaults.defaultSearchMode);
911
+ const strategy = sanitizeContextStrategy(input.strategy, context.defaults.defaultContextStrategy);
855
912
  const limit = input.limit ?? context.defaults.defaultSearchLimit;
856
913
  const tokens = input.tokens ?? context.defaults.defaultContextTokens;
857
914
  const query = input.query?.trim();
@@ -876,6 +933,7 @@ export const recommendationsTool = async (input) => {
876
933
  vault: context.vault,
877
934
  ...(context.agent ? { agent: context.agent } : {}),
878
935
  mode,
936
+ strategy,
879
937
  ...(query ? { query } : {})
880
938
  }
881
939
  }
@@ -891,6 +949,7 @@ export const recommendationsTool = async (input) => {
891
949
  ...(context.agent ? { agent: context.agent } : {}),
892
950
  query: query ?? '<task>',
893
951
  mode,
952
+ strategy,
894
953
  limit,
895
954
  tokens
896
955
  }
@@ -926,6 +985,7 @@ export const recommendationsTool = async (input) => {
926
985
  ...(context.agent ? { agent: context.agent } : {}),
927
986
  query: query ?? '<task>',
928
987
  mode,
988
+ strategy,
929
989
  limit,
930
990
  tokens
931
991
  }
@@ -957,9 +1017,24 @@ export const recommendationsTool = async (input) => {
957
1017
  agent: context.agent,
958
1018
  defaults: {
959
1019
  mode,
1020
+ strategy,
960
1021
  limit,
961
1022
  tokens
962
1023
  },
1024
+ contextStrategies: [
1025
+ {
1026
+ strategy: 'rag',
1027
+ useWhen: 'Use for fresh retrieval and context assembly from the current index.'
1028
+ },
1029
+ {
1030
+ strategy: 'cag',
1031
+ useWhen: 'Use for repeated or stable task context so Brainlink can reuse a fresh persisted context pack.'
1032
+ },
1033
+ {
1034
+ strategy: 'auto',
1035
+ useWhen: 'Use when the agent wants Brainlink to choose CAG on fresh pack hits and RAG otherwise.'
1036
+ }
1037
+ ],
963
1038
  policy,
964
1039
  bootstrapStatus,
965
1040
  contextStatus,
@@ -51,7 +51,7 @@ Set `BRAINLINK_HOME` when the whole Brainlink home directory should live somewhe
51
51
  Use `blink config where` and `blink config doctor` to inspect active paths and effective source.
52
52
 
53
53
  You can also set `defaultAgent` in `brainlink.config.json` / `.brainlink.json` (for example `"defaultAgent": "coding-agent"`). When set, CLI commands and MCP calls reuse it when `--agent`/`agent` is not passed.
54
- You can set `agentProfiles` to define per-agent defaults for `defaultSearchMode`, `defaultSearchLimit` and `defaultContextTokens`.
54
+ You can set `agentProfiles` to define per-agent defaults for `defaultSearchMode`, `defaultSearchLimit`, `defaultContextTokens`, `defaultContextStrategy` and `defaultContextCacheTtlMs`.
55
55
  You can tune search-pack compression with `searchPack.rowChunkSize`, `searchPack.compressionLevel` and `searchPack.useDictionary`.
56
56
  Guardrails for benchmark acceptance are configured with `searchPack.guardrailMinSavingsPercent` and `searchPack.guardrailMaxLatencyRegressionPercent`.
57
57
 
@@ -542,9 +542,14 @@ blink context "how does authentication work?" --vault ./vault --limit 12 --token
542
542
  blink context "how does authentication work?" --vault ./vault --json
543
543
  blink context "how does authentication work?" --vault ./vault --agent coding-agent --json
544
544
  blink context "how does authentication work?" --vault ./vault --agent coding-agent --mode hybrid --json
545
+ blink context "how does authentication work?" --vault ./vault --agent coding-agent --strategy cag --json
546
+ blink context "how does authentication work?" --vault ./vault --agent coding-agent --strategy auto --json
547
+ blink context-packs --vault ./vault --json
548
+ blink context-packs --vault ./vault --stale --clear
545
549
  ```
546
550
 
547
551
  This returns a Markdown context package optimized for prompt injection.
552
+ The default strategy is configured by `defaultContextStrategy` and starts as `rag`. Use `--strategy cag` when the same agent/task context should be served from a persisted context pack when the index and volatile-memory signatures are unchanged. Use `--strategy auto` when Brainlink should use CAG on fresh pack hits and RAG otherwise. Context responses include `cache`, `metrics`, `requestedStrategy` and `recommendedStrategy` metadata. CAG packs live under `.brainlink/context-packs` and are rebuildable derived artifacts.
548
553
 
549
554
  ### Inspect Links
550
555
 
@@ -670,6 +675,7 @@ Available MCP tools:
670
675
  - `brainlink_policy`
671
676
  - `brainlink_recommendations`
672
677
  - `brainlink_context`
678
+ - `brainlink_context_packs`
673
679
  - `brainlink_search`
674
680
  - `brainlink_dedupe`
675
681
  - `brainlink_resolve_duplicate`
@@ -693,6 +699,7 @@ If `autoBootstrapOnRead` or `enforceContextFirst` are disabled through `brainlin
693
699
  `brainlink_bootstrap`, `brainlink_policy` and preflight responses include structured `nextActions` so clients can continue tool flows automatically.
694
700
  `brainlink_policy` also accepts policy presets (`fully-auto`, `strict`) so MCP clients can switch behavior in one call.
695
701
  `brainlink_recommendations` returns the suggested execution order so an agent can follow Brainlink best practices automatically.
702
+ MCP context is plug-and-play: agents may omit `strategy` for the configured default, pass `strategy: "rag"` for explicit fresh retrieval assembly, pass `strategy: "cag"` to reuse persisted context packs when the task context is stable or repeated, or pass `strategy: "auto"` to use CAG on fresh pack hits and RAG otherwise. `brainlink_recommendations`, policy next actions and preflight responses return executable context arguments, so clients can continue without custom parsing. `brainlink_context_packs` lists or clears persisted CAG packs.
696
703
 
697
704
  MCP clients can pass `vault` and `agent` arguments per tool call. Set `BRAINLINK_ALLOWED_VAULTS` when exposing Brainlink to an external agent process so a tool cannot pass arbitrary vault paths:
698
705
 
@@ -136,6 +136,8 @@ read markdown files
136
136
 
137
137
  ```txt
138
138
  question
139
+ -> selected strategy: rag | cag | auto
140
+ -> if cag/auto and fresh context pack exists, return cached Markdown package
139
141
  -> selected mode: fts | semantic | hybrid
140
142
  -> optional query embedding
141
143
  -> optional compressed pack prefilter (token bloom)
@@ -145,8 +147,12 @@ question
145
147
  -> ranked chunks with textScore and semanticScore
146
148
  -> token-budget selection
147
149
  -> Markdown context package
150
+ -> if cag/auto, persist derived context pack with current data signature
151
+ -> return cache, strategy and timing metrics
148
152
  ```
149
153
 
154
+ Context packs are stored under `.brainlink/context-packs/*.json`. They are derived artifacts keyed by query, agent, mode, limit and token budget, and are invalidated by the combined index and volatile-memory signature. Markdown remains the source of truth. The `auto` strategy uses CAG on fresh pack hits and falls back to RAG assembly otherwise, refreshing a pack for later calls.
155
+
150
156
  ## Graph Server Flow
151
157
 
152
158
  ```txt
@@ -50,7 +50,9 @@ Optional per-agent retrieval defaults in `brainlink.config.json`:
50
50
  "coding-agent": {
51
51
  "defaultSearchMode": "semantic",
52
52
  "defaultSearchLimit": 8,
53
- "defaultContextTokens": 2400
53
+ "defaultContextTokens": 2400,
54
+ "defaultContextStrategy": "auto",
55
+ "defaultContextCacheTtlMs": 120000
54
56
  }
55
57
  }
56
58
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andespindola/brainlink",
3
- "version": "0.1.0-beta.164",
3
+ "version": "0.1.0-beta.166",
4
4
  "description": "Local-first knowledge memory for agents with Markdown, backlinks, indexing and context retrieval.",
5
5
  "type": "module",
6
6
  "license": "MIT",