@andespindola/brainlink 0.1.0-beta.163 → 0.1.0-beta.165

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.
@@ -369,28 +369,28 @@ const parseColor = (hex) => {
369
369
  }
370
370
 
371
371
  const graphTheme = {
372
- node: parseColor('#4c8eda'),
373
- nodeCluster: parseColor('#2f6fb4'),
374
- nodeHighlight: parseColor('#f2b441'),
375
- nodeSelected: parseColor('#172033'),
372
+ node: parseColor('#5aa8ff'),
373
+ nodeCluster: parseColor('#3f7fbd'),
374
+ nodeHighlight: parseColor('#ffcb67'),
375
+ nodeSelected: parseColor('#edf4ff'),
376
376
  nodePalette: [
377
- parseColor('#4c8eda'),
378
- parseColor('#65b96e'),
379
- parseColor('#f0a33a'),
380
- parseColor('#d95f8d'),
381
- parseColor('#8d72d9'),
382
- parseColor('#55bfc4'),
383
- parseColor('#ec6b56'),
384
- parseColor('#9aa6b2'),
385
- parseColor('#b78255'),
386
- parseColor('#6f9fd8')
377
+ parseColor('#5aa8ff'),
378
+ parseColor('#5ecf92'),
379
+ parseColor('#ffb65c'),
380
+ parseColor('#ff7dac'),
381
+ parseColor('#a88fff'),
382
+ parseColor('#59d0dd'),
383
+ parseColor('#ff8f6a'),
384
+ parseColor('#a4b3c3'),
385
+ parseColor('#c9945f'),
386
+ parseColor('#7cb6ff')
387
387
  ],
388
- edge: [0.23, 0.31, 0.42, 0.18],
389
- edgeHeavy: [0.23, 0.31, 0.42, 0.34],
390
- clear: parseColor('#f6f8fb')
388
+ edge: [0.59, 0.71, 0.83, 0.14],
389
+ edgeHeavy: [0.59, 0.71, 0.83, 0.3],
390
+ clear: parseColor('#08131d')
391
391
  }
392
392
 
393
- const segmentPalette = ['#4c8eda', '#65b96e', '#f0a33a', '#d95f8d', '#8d72d9', '#55bfc4', '#ec6b56', '#9aa6b2', '#b78255', '#6f9fd8']
393
+ const segmentPalette = ['#5aa8ff', '#5ecf92', '#ffb65c', '#ff7dac', '#a88fff', '#59d0dd', '#ff8f6a', '#a4b3c3', '#c9945f', '#7cb6ff']
394
394
 
395
395
  const segmentColorIndex = (segment) => {
396
396
  const value = String(segment || '')
@@ -402,6 +402,8 @@ const segmentColorIndex = (segment) => {
402
402
  }
403
403
 
404
404
  const segmentColor = (segment) => segmentPalette[segmentColorIndex(segment)] || segmentPalette[0]
405
+ const nodeKind = (node) => node?.[6] === 'cluster' ? 'cluster' : 'node'
406
+ const isRealGraphNode = (node) => nodeKind(node) === 'node'
405
407
 
406
408
  const clampScale = (scale) => Math.max(zoomRange.min, Math.min(zoomRange.max, scale))
407
409
 
@@ -543,7 +545,7 @@ const drawFallback = () => {
543
545
  canvas.width = Math.floor(width * ratio)
544
546
  canvas.height = Math.floor(height * ratio)
545
547
  ctx2dFallback.setTransform(ratio, 0, 0, ratio, 0, 0)
546
- ctx2dFallback.fillStyle = '#f6f8fb'
548
+ ctx2dFallback.fillStyle = '#08131d'
547
549
  ctx2dFallback.fillRect(0, 0, width, height)
548
550
 
549
551
  const nodes = Array.isArray(state.chunk.nodes) ? state.chunk.nodes : []
@@ -553,7 +555,7 @@ const drawFallback = () => {
553
555
  nodeById.set(nodes[i][0], nodes[i])
554
556
  }
555
557
 
556
- ctx2dFallback.strokeStyle = 'rgba(59,79,108,0.18)'
558
+ ctx2dFallback.strokeStyle = 'rgba(151,181,212,0.18)'
557
559
  ctx2dFallback.lineWidth = 1
558
560
  for (let i = 0; i < edges.length; i += 1) {
559
561
  const edge = edges[i]
@@ -576,12 +578,12 @@ const drawFallback = () => {
576
578
  const radius = Math.max(2.4, Math.min(14, 4 + node[7] * 0.55))
577
579
 
578
580
  ctx2dFallback.beginPath()
579
- ctx2dFallback.fillStyle = selected ? '#ffffff' : color
581
+ ctx2dFallback.fillStyle = selected ? '#edf4ff' : color
580
582
  ctx2dFallback.arc(p.x, p.y, radius, 0, Math.PI * 2)
581
583
  ctx2dFallback.fill()
582
584
  }
583
585
 
584
- ctx2dFallback.fillStyle = '#172033'
586
+ ctx2dFallback.fillStyle = '#97a9bd'
585
587
  ctx2dFallback.font = '12px Inter, system-ui, sans-serif'
586
588
  ctx2dFallback.textAlign = 'center'
587
589
  ctx2dFallback.fillText('Fallback canvas mode', Math.max(width, 320) / 2, 24)
@@ -670,6 +672,28 @@ const updateNodePositionInChunk = (nodeId, x, y) => {
670
672
  updateGraphOverlays()
671
673
  }
672
674
 
675
+ const focusNodeInViewport = (nodeId, nextScale = null) => {
676
+ const node = nodeByIdFromChunk().get(nodeId)
677
+ if (!node) {
678
+ return false
679
+ }
680
+
681
+ const x = Number(node[2])
682
+ const y = Number(node[3])
683
+ if (!Number.isFinite(x) || !Number.isFinite(y)) {
684
+ return false
685
+ }
686
+
687
+ if (Number.isFinite(nextScale)) {
688
+ state.camera.scale = clampScale(Number(nextScale))
689
+ }
690
+ state.camera.x = state.viewport.width / 2 - x * state.camera.scale
691
+ state.camera.y = state.viewport.height / 2 - y * state.camera.scale
692
+ updateWorkerCamera()
693
+ scheduleChunkFetch()
694
+ return true
695
+ }
696
+
673
697
  const showTooltip = (node, pointer) => {
674
698
  if (!elements.tooltip || !node) {
675
699
  return
@@ -743,7 +767,7 @@ const drawMiniMap = () => {
743
767
  miniMap.height = Math.floor(height * ratio)
744
768
  ctx.setTransform(ratio, 0, 0, ratio, 0, 0)
745
769
  ctx.clearRect(0, 0, width, height)
746
- ctx.fillStyle = 'rgba(255, 255, 255, 0.88)'
770
+ ctx.fillStyle = 'rgba(8, 19, 29, 0.88)'
747
771
  ctx.fillRect(0, 0, width, height)
748
772
 
749
773
  const xs = nodes.map((node) => Number(node[2])).filter(Number.isFinite)
@@ -763,7 +787,7 @@ const drawMiniMap = () => {
763
787
  })
764
788
  state.miniMapView = { minX, minY, scale, offsetX, offsetY, width, height }
765
789
 
766
- ctx.fillStyle = 'rgba(76, 142, 218, 0.62)'
790
+ ctx.fillStyle = 'rgba(90, 168, 255, 0.62)'
767
791
  nodes.forEach((node) => {
768
792
  const point = toMini(Number(node[2]), Number(node[3]))
769
793
  ctx.fillRect(point.x - 1, point.y - 1, 2, 2)
@@ -773,7 +797,7 @@ const drawMiniMap = () => {
773
797
  const worldBottomRight = screenToWorld(state.viewport.width, state.viewport.height)
774
798
  const topLeft = toMini(Math.min(worldTopLeft.x, worldBottomRight.x), Math.min(worldTopLeft.y, worldBottomRight.y))
775
799
  const bottomRight = toMini(Math.max(worldTopLeft.x, worldBottomRight.x), Math.max(worldTopLeft.y, worldBottomRight.y))
776
- ctx.strokeStyle = 'rgba(11, 111, 203, 0.86)'
800
+ ctx.strokeStyle = 'rgba(90, 168, 255, 0.86)'
777
801
  ctx.lineWidth = 1
778
802
  ctx.strokeRect(topLeft.x, topLeft.y, Math.max(3, bottomRight.x - topLeft.x), Math.max(3, bottomRight.y - topLeft.y))
779
803
  }
@@ -1128,11 +1152,28 @@ const pickFallbackNodeId = (screenX, screenY) => {
1128
1152
  return typeof node?.[0] === 'string' ? node[0] : ''
1129
1153
  }
1130
1154
 
1155
+ const handlePickedNode = (node) => {
1156
+ const nodeId = typeof node?.id === 'string' ? node.id : typeof node?.[0] === 'string' ? node[0] : ''
1157
+ if (!nodeId) {
1158
+ return
1159
+ }
1160
+
1161
+ const kind = typeof node?.kind === 'string' ? node.kind : nodeKind(node)
1162
+ if (kind === 'cluster') {
1163
+ const currentScale = state.camera.scale
1164
+ const targetScale = currentScale < 0.22 ? 0.28 : Math.min(1.1, currentScale * 1.6)
1165
+ focusNodeInViewport(nodeId, targetScale)
1166
+ return
1167
+ }
1168
+
1169
+ loadNodeDetails(nodeId).catch((error) => console.error(error))
1170
+ }
1171
+
1131
1172
  const pickAt = (screenX, screenY) => {
1132
1173
  if (state.rendererMode === 'fallback') {
1133
- const nodeId = pickFallbackNodeId(screenX, screenY)
1134
- if (nodeId) {
1135
- loadNodeDetails(nodeId).catch((error) => console.error(error))
1174
+ const node = pickFallbackNode(screenX, screenY)
1175
+ if (node) {
1176
+ handlePickedNode(node)
1136
1177
  }
1137
1178
  return
1138
1179
  }
@@ -1170,6 +1211,19 @@ const resolvePointer = (event) => {
1170
1211
 
1171
1212
  const setupInput = () => {
1172
1213
  const dragActivationDistance = 6
1214
+ const resetPointerState = (pointerId = null) => {
1215
+ state.pointer.down = false
1216
+ state.pointer.dragging = false
1217
+ state.pointer.dragNodeId = ''
1218
+ canvas.classList.remove('is-node-dragging')
1219
+ if (pointerId !== null) {
1220
+ try {
1221
+ if (canvas.hasPointerCapture(pointerId)) {
1222
+ canvas.releasePointerCapture(pointerId)
1223
+ }
1224
+ } catch {}
1225
+ }
1226
+ }
1173
1227
 
1174
1228
  canvas.addEventListener('wheel', (event) => {
1175
1229
  event.preventDefault()
@@ -1180,9 +1234,10 @@ const setupInput = () => {
1180
1234
  }, { passive: false })
1181
1235
 
1182
1236
  canvas.addEventListener('pointerdown', (event) => {
1237
+ event.preventDefault()
1183
1238
  const pointer = resolvePointer(event)
1184
1239
  const candidateNode = pickFallbackNode(pointer.x, pointer.y)
1185
- const candidateNodeId = candidateNode?.[6] === 'node' && typeof candidateNode?.[0] === 'string' ? candidateNode[0] : ''
1240
+ const candidateNodeId = isRealGraphNode(candidateNode) && typeof candidateNode?.[0] === 'string' ? candidateNode[0] : ''
1186
1241
  const candidateX = Number(candidateNode?.[2])
1187
1242
  const candidateY = Number(candidateNode?.[3])
1188
1243
  const world = screenToWorld(pointer.x, pointer.y)
@@ -1200,10 +1255,15 @@ const setupInput = () => {
1200
1255
  state.pointer.nodeStartY = candidateNodeId && Number.isFinite(candidateY) ? candidateY : 0
1201
1256
  state.pointer.worldAnchorX = world.x
1202
1257
  state.pointer.worldAnchorY = world.y
1203
- canvas.setPointerCapture(event.pointerId)
1258
+ try {
1259
+ canvas.setPointerCapture(event.pointerId)
1260
+ } catch {}
1204
1261
  })
1205
1262
 
1206
1263
  canvas.addEventListener('pointermove', (event) => {
1264
+ if (state.pointer.down) {
1265
+ event.preventDefault()
1266
+ }
1207
1267
  const pointer = resolvePointer(event)
1208
1268
 
1209
1269
  if (state.pointer.down) {
@@ -1241,7 +1301,7 @@ const setupInput = () => {
1241
1301
  }
1242
1302
 
1243
1303
  const hovered = pickFallbackNode(pointer.x, pointer.y)
1244
- const hoveredId = hovered?.[6] === 'node' && typeof hovered?.[0] === 'string' ? hovered[0] : ''
1304
+ const hoveredId = isRealGraphNode(hovered) && typeof hovered?.[0] === 'string' ? hovered[0] : ''
1245
1305
  if (state.hoveredNodeId !== hoveredId) {
1246
1306
  state.hoveredNodeId = hoveredId
1247
1307
  canvas.classList.toggle('is-node-hover', Boolean(hoveredId))
@@ -1260,11 +1320,7 @@ const setupInput = () => {
1260
1320
  const shouldPick = !state.pointer.dragging && distanceFromStart < dragActivationDistance
1261
1321
  const shouldRefreshAfterDrag = state.pointer.dragging
1262
1322
  const shouldPersistNodePosition = state.pointer.dragging && Boolean(state.pointer.dragNodeId)
1263
- state.pointer.down = false
1264
- state.pointer.dragging = false
1265
- canvas.classList.remove('is-node-dragging')
1266
- state.pointer.dragNodeId = ''
1267
- canvas.releasePointerCapture(event.pointerId)
1323
+ resetPointerState(event.pointerId)
1268
1324
 
1269
1325
  if (shouldPick) {
1270
1326
  pickAt(pointer.x, pointer.y)
@@ -1287,6 +1343,16 @@ const setupInput = () => {
1287
1343
  updateGraphOverlays()
1288
1344
  })
1289
1345
 
1346
+ canvas.addEventListener('pointercancel', (event) => {
1347
+ resetPointerState(event.pointerId)
1348
+ hideTooltip()
1349
+ updateGraphOverlays()
1350
+ })
1351
+
1352
+ canvas.addEventListener('lostpointercapture', () => {
1353
+ resetPointerState()
1354
+ })
1355
+
1290
1356
  elements.miniMap.addEventListener('click', (event) => {
1291
1357
  if (!state.miniMapView) {
1292
1358
  return
@@ -1507,7 +1573,7 @@ const setupRenderWorker = () => {
1507
1573
 
1508
1574
  if (payload.type === 'pick-result') {
1509
1575
  if (payload.node && typeof payload.node.id === 'string' && payload.node.id.length > 0) {
1510
- loadNodeDetails(payload.node.id).catch((error) => console.error(error))
1576
+ handlePickedNode(payload.node)
1511
1577
  }
1512
1578
  return
1513
1579
  }
@@ -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,22 @@ 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);
24
30
  };
25
31
  const hasInvalidSearchMode = (url) => {
26
32
  const mode = url.searchParams.get('mode');
27
33
  return mode !== null && !['fts', 'semantic', 'hybrid'].includes(mode);
28
34
  };
35
+ const hasInvalidContextStrategy = (url) => {
36
+ const strategy = url.searchParams.get('strategy');
37
+ return strategy !== null && !['rag', 'cag', 'auto'].includes(strategy);
38
+ };
29
39
  const createResponse = (body, statusCode = 200, contentType = 'text/plain; charset=utf-8') => ({
30
40
  body,
31
41
  statusCode,
@@ -370,10 +380,14 @@ export const route = async (request, url, vaultPath) => {
370
380
  const limit = parsePositiveInteger(url.searchParams.get('limit'), 12);
371
381
  const tokens = parsePositiveInteger(url.searchParams.get('tokens'), 2000);
372
382
  const mode = await readSearchMode(url);
383
+ const strategy = await readContextStrategy(url);
373
384
  if (hasInvalidSearchMode(url)) {
374
385
  return createResponse(createJsonResponse({ error: 'Invalid mode. Use fts, semantic or hybrid.' }), 400, contentTypes['.json']);
375
386
  }
376
- return createResponse(createJsonResponse(await buildContextPackage(vaultPath, query, limit, tokens, readAgentQuery(url), mode)), 200, contentTypes['.json']);
387
+ if (hasInvalidContextStrategy(url)) {
388
+ return createResponse(createJsonResponse({ error: 'Invalid strategy. Use rag, cag or auto.' }), 400, contentTypes['.json']);
389
+ }
390
+ return createResponse(createJsonResponse(await buildContextPackage(vaultPath, query, limit, tokens, readAgentQuery(url), mode, strategy)), 200, contentTypes['.json']);
377
391
  }
378
392
  if (isReadMethod(request) && url.pathname === '/api/links') {
379
393
  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);
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)
1137
1139
  : null;
1138
1140
  const agentIntegration = options.installAgent === false
1139
1141
  ? null
@@ -13,6 +13,7 @@ export const defaultBrainlinkConfig = {
13
13
  autoCanonicalContextLinks: true,
14
14
  defaultSearchLimit: 10,
15
15
  defaultContextTokens: 2000,
16
+ defaultContextStrategy: 'rag',
16
17
  embeddingProvider: 'local',
17
18
  defaultSearchMode: 'hybrid',
18
19
  chunkSize: 1200,
@@ -41,8 +42,10 @@ const safeCwd = () => {
41
42
  const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
42
43
  const embeddingProviders = new Set(['none', 'local']);
43
44
  const searchModes = new Set(['fts', 'semantic', 'hybrid']);
45
+ const contextStrategies = new Set(['rag', 'cag', 'auto']);
44
46
  const sanitizeEmbeddingProvider = (value) => typeof value === 'string' && embeddingProviders.has(value) ? value : defaultBrainlinkConfig.embeddingProvider;
45
47
  export const sanitizeSearchMode = (value, fallback = defaultBrainlinkConfig.defaultSearchMode) => typeof value === 'string' && searchModes.has(value) ? value : fallback;
48
+ export const sanitizeContextStrategy = (value, fallback = 'rag') => typeof value === 'string' && contextStrategies.has(value) ? value : fallback;
46
49
  const sanitizeAllowedVaults = (value) => Array.isArray(value) ? value.filter((item) => typeof item === 'string' && item.trim().length > 0) : [];
47
50
  const sanitizePositiveNumber = (value) => typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : undefined;
48
51
  const sanitizeIntegerInRange = (value, fallback, minimum, maximum) => {
@@ -84,10 +87,14 @@ const sanitizeAgentProfile = (value) => {
84
87
  const defaultSearchMode = typeof value.defaultSearchMode === 'string' && searchModes.has(value.defaultSearchMode)
85
88
  ? value.defaultSearchMode
86
89
  : undefined;
90
+ const defaultContextStrategy = typeof value.defaultContextStrategy === 'string' && contextStrategies.has(value.defaultContextStrategy)
91
+ ? value.defaultContextStrategy
92
+ : undefined;
87
93
  const profile = {
88
94
  ...(defaultSearchLimit ? { defaultSearchLimit } : {}),
89
95
  ...(defaultContextTokens ? { defaultContextTokens } : {}),
90
- ...(defaultSearchMode ? { defaultSearchMode } : {})
96
+ ...(defaultSearchMode ? { defaultSearchMode } : {}),
97
+ ...(defaultContextStrategy ? { defaultContextStrategy } : {})
91
98
  };
92
99
  return Object.keys(profile).length > 0 ? profile : null;
93
100
  };
@@ -169,6 +176,7 @@ const sanitizeConfig = (value) => ({
169
176
  defaultContextTokens: typeof value.defaultContextTokens === 'number' && value.defaultContextTokens > 0
170
177
  ? value.defaultContextTokens
171
178
  : defaultBrainlinkConfig.defaultContextTokens,
179
+ defaultContextStrategy: sanitizeContextStrategy(value.defaultContextStrategy, defaultBrainlinkConfig.defaultContextStrategy),
172
180
  allowedVaults: [...sanitizeAllowedVaults(value.allowedVaults), ...readAllowedVaultsFromEnv()],
173
181
  chunkSize: typeof value.chunkSize === 'number' && value.chunkSize > 0 ? value.chunkSize : defaultBrainlinkConfig.chunkSize,
174
182
  searchPack: sanitizeSearchPackConfig(value.searchPack),
@@ -182,7 +190,8 @@ export const resolveAgentRuntimeDefaults = (config, agent) => {
182
190
  return {
183
191
  defaultSearchLimit: profile?.defaultSearchLimit ?? config.defaultSearchLimit,
184
192
  defaultContextTokens: profile?.defaultContextTokens ?? config.defaultContextTokens,
185
- defaultSearchMode: profile?.defaultSearchMode ?? config.defaultSearchMode
193
+ defaultSearchMode: profile?.defaultSearchMode ?? config.defaultSearchMode,
194
+ defaultContextStrategy: profile?.defaultContextStrategy ?? config.defaultContextStrategy
186
195
  };
187
196
  };
188
197
  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.',