smart-context-mcp 1.19.0 → 1.20.0

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.
@@ -11,6 +11,7 @@ import { buildPathsResult } from '../graph-paths.js';
11
11
  import { projectRoot } from '../utils/paths.js';
12
12
  import { resolveSafePath } from '../utils/fs.js';
13
13
  import { countTokens } from '../tokenCounter.js';
14
+ import { consumeTokenBudget, normalizeTokenBudget, resolveTokenBudgetWindow } from '../utils/task-budget.js';
14
15
  import { persistMetrics } from '../metrics.js';
15
16
  import { predictContextFiles, recordContextAccess } from '../context-patterns.js';
16
17
  import { recordToolUsage } from '../usage-feedback.js';
@@ -411,6 +412,7 @@ export const smartContext = async ({
411
412
  task,
412
413
  intent,
413
414
  maxTokens = 12000,
415
+ tokenBudget,
414
416
  entryFile,
415
417
  diff,
416
418
  detail = 'balanced',
@@ -423,6 +425,9 @@ export const smartContext = async ({
423
425
  }) => {
424
426
  const progress = enableProgress ? createProgressReporter('smart_context') : null;
425
427
  const startTime = Date.now();
428
+ const normalizedTokenBudget = normalizeTokenBudget(tokenBudget);
429
+ const budgetWindow = resolveTokenBudgetWindow({ tokenBudget: normalizedTokenBudget, maxTokens });
430
+ const effectiveMaxTokens = budgetWindow.effectiveMaxTokens ?? 12000;
426
431
 
427
432
  if (paths && typeof paths === 'object' && paths.from && paths.to && !task) {
428
433
  if (progress) {
@@ -650,7 +655,7 @@ export const smartContext = async ({
650
655
  attachSymbolEvidence(expanded, index, symbolCandidates);
651
656
  normalizePrimaryCandidate(expanded, task, resolvedIntent);
652
657
 
653
- const readPlan = allocateReads(expanded, maxTokens, resolvedIntent, detailMode);
658
+ const readPlan = allocateReads(expanded, effectiveMaxTokens, resolvedIntent, detailMode);
654
659
 
655
660
  const context = [];
656
661
  let totalRawTokens = 0;
@@ -661,7 +666,7 @@ export const smartContext = async ({
661
666
  for (const item of readPlan) {
662
667
  const basePayload = buildContextItemPayload(item, index, detailMode);
663
668
  const baseTokens = countTokens(JSON.stringify(basePayload));
664
- if (totalCompressedTokens + baseTokens > maxTokens && context.length > 0) break;
669
+ if (totalCompressedTokens + baseTokens > effectiveMaxTokens && context.length > 0) break;
665
670
 
666
671
  const contextIndex = context.length;
667
672
  context.push(basePayload);
@@ -700,7 +705,7 @@ export const smartContext = async ({
700
705
  const newTokens = countTokens(JSON.stringify(enrichedPayload));
701
706
  const tokenDelta = newTokens - oldTokens;
702
707
 
703
- if (totalCompressedTokens + tokenDelta > maxTokens && pending.contextIndex > 0) continue;
708
+ if (totalCompressedTokens + tokenDelta > effectiveMaxTokens && pending.contextIndex > 0) continue;
704
709
 
705
710
  context[pending.contextIndex] = enrichedPayload;
706
711
  filesWithContent.add(pending.item.rel);
@@ -727,7 +732,7 @@ export const smartContext = async ({
727
732
  content: filtered,
728
733
  };
729
734
  const symbolTokens = countTokens(JSON.stringify(symbolPayload));
730
- if (totalCompressedTokens + symbolTokens <= maxTokens) {
735
+ if (totalCompressedTokens + symbolTokens <= effectiveMaxTokens) {
731
736
  context.push(symbolPayload);
732
737
  totalCompressedTokens += symbolTokens;
733
738
 
@@ -881,6 +886,7 @@ export const smartContext = async ({
881
886
  filesIncluded,
882
887
  filesEvaluated: expanded.size,
883
888
  detailMode,
889
+ maxTokens: effectiveMaxTokens,
884
890
  totalTokens: countTokens(context.map((c) => c.content || '').join('')),
885
891
  ...(prefetchResult ? {
886
892
  prefetch: {
@@ -894,6 +900,14 @@ export const smartContext = async ({
894
900
  ...(includeSet.has('hints') ? { hints } : {}),
895
901
  };
896
902
 
903
+ if (normalizedTokenBudget) {
904
+ result.taskBudget = normalizedTokenBudget;
905
+ result.remainingBudget = consumeTokenBudget({
906
+ tokenBudget: normalizedTokenBudget,
907
+ usedTokens: totalCompressedTokens,
908
+ });
909
+ }
910
+
897
911
  if (diffSummary) {
898
912
  diffSummary.included = context.filter((c) => c.role === 'primary').length;
899
913
  result.diffSummary = diffSummary;
@@ -1,10 +1,11 @@
1
1
  import { smartRead } from './smart-read.js';
2
2
  import { countTokens } from '../tokenCounter.js';
3
3
 
4
- export const smartReadBatch = async ({ files, maxTokens }) => {
4
+ export const smartReadBatch = async ({ files, maxTokens, tokenBudget }) => {
5
5
  const results = [];
6
6
  let totalTokens = 0;
7
7
  let filesSkipped = 0;
8
+ let budgetStoppedAt = null;
8
9
 
9
10
  for (const item of files) {
10
11
  try {
@@ -15,6 +16,7 @@ export const smartReadBatch = async ({ files, maxTokens }) => {
15
16
  startLine: item.startLine,
16
17
  endLine: item.endLine,
17
18
  maxTokens: item.maxTokens,
19
+ tokenBudget,
18
20
  });
19
21
 
20
22
  if (readResult.error) {
@@ -30,6 +32,7 @@ export const smartReadBatch = async ({ files, maxTokens }) => {
30
32
 
31
33
  if (maxTokens && totalTokens + itemTokens > maxTokens && results.length > 0) {
32
34
  filesSkipped = files.length - results.length;
35
+ budgetStoppedAt = item.path;
33
36
  break;
34
37
  }
35
38
 
@@ -40,7 +43,8 @@ export const smartReadBatch = async ({ files, maxTokens }) => {
40
43
  truncated: readResult.truncated,
41
44
  content: readResult.content,
42
45
  ...(readResult.indexHint !== undefined ? { indexHint: readResult.indexHint } : {}),
43
- ...(readResult.chosenMode ? { chosenMode: readResult.chosenMode, budgetApplied: true } : {}),
46
+ ...(readResult.chosenMode ? { chosenMode: readResult.chosenMode } : {}),
47
+ ...(readResult.budgetApplied ? { budgetApplied: true, budgetDetails: readResult.budgetDetails } : {}),
44
48
  });
45
49
 
46
50
  totalTokens += itemTokens;
@@ -53,7 +57,7 @@ export const smartReadBatch = async ({ files, maxTokens }) => {
53
57
  }
54
58
  }
55
59
 
56
- return {
60
+ const response = {
57
61
  results,
58
62
  metrics: {
59
63
  totalTokens,
@@ -61,4 +65,23 @@ export const smartReadBatch = async ({ files, maxTokens }) => {
61
65
  filesSkipped,
62
66
  },
63
67
  };
68
+
69
+ if (maxTokens && filesSkipped > 0) {
70
+ response.budgetApplied = true;
71
+ response.budgetDetails = {
72
+ scope: 'batch',
73
+ maxTokens,
74
+ actions: ['batch_stopped_early'],
75
+ filesRead: results.length,
76
+ filesSkipped,
77
+ stopReason: 'batch_token_limit',
78
+ ...(budgetStoppedAt ? { stoppedBefore: budgetStoppedAt } : {}),
79
+ };
80
+ }
81
+
82
+ if (tokenBudget) {
83
+ response.taskBudget = tokenBudget;
84
+ }
85
+
86
+ return response;
64
87
  };
@@ -9,10 +9,13 @@ import { isDockerfile, readTextFile } from '../utils/fs.js';
9
9
  import { projectRoot } from '../utils/paths.js';
10
10
  import { truncate } from '../utils/text.js';
11
11
  import { countTokens } from '../tokenCounter.js';
12
+ import { consumeTokenBudget, normalizeTokenBudget, resolveTokenBudgetWindow } from '../utils/task-budget.js';
12
13
  import { recordToolUsage } from '../usage-feedback.js';
13
14
  import { recordDecision, DECISION_REASONS, EXPECTED_BENEFITS } from '../decision-explainer.js';
14
15
  import { recordDevctxOperation } from '../missed-opportunities.js';
15
16
  import { createProgressReporter } from '../streaming.js';
17
+ import { createHash } from 'node:crypto';
18
+ import { getReadCache, setReadCache } from '../storage/sqlite.js';
16
19
 
17
20
  const execFile = promisify(execFileCb);
18
21
  import { summarizeGo, summarizeRust, summarizeJava, summarizeShell, summarizeTerraform, summarizeDockerfile, summarizeSql, extractGoSymbol, extractRustSymbol, extractJavaSymbol, summarizeCsharp, extractCsharpSymbol, summarizeKotlin, extractKotlinSymbol, summarizePhp, extractPhpSymbol, summarizeSwift, extractSwiftSymbol } from './smart-read/additional-languages.js';
@@ -44,6 +47,8 @@ const MAX_CACHE_ENTRIES = 200;
44
47
  const buildCacheKey = (fullPath, mode, extra) =>
45
48
  extra ? `${fullPath}::${mode}::${extra}` : `${fullPath}::${mode}`;
46
49
 
50
+ const buildContentHash = (content) => createHash('sha256').update(content).digest('hex');
51
+
47
52
  const getFileMtime = (fullPath) => Math.floor(fs.statSync(fullPath).mtimeMs);
48
53
 
49
54
  const getCached = (key, mtime) => {
@@ -154,7 +159,55 @@ const resolveParserType = (extension, fullPath) => {
154
159
  return 'fallback';
155
160
  };
156
161
 
157
- const MODE_CASCADE = ['full', 'outline', 'signatures'];
162
+ const MODE_BUDGET_CASCADE = {
163
+ full: ['signatures', 'outline'],
164
+ signatures: ['signatures', 'outline'],
165
+ outline: ['outline'],
166
+ };
167
+
168
+ const getBudgetCascade = (mode) => MODE_BUDGET_CASCADE[mode] ?? [mode];
169
+
170
+ const buildFullModeMetadata = ({ requestedMode, effectiveMode, validBudget }) => {
171
+ if (requestedMode !== 'full') {
172
+ return null;
173
+ }
174
+
175
+ if (effectiveMode === 'full') {
176
+ return {
177
+ requested: true,
178
+ used: true,
179
+ reason: 'explicit_request',
180
+ };
181
+ }
182
+
183
+ return {
184
+ requested: true,
185
+ used: false,
186
+ reason: validBudget ? 'degraded_for_budget' : 'not_used',
187
+ fallbackMode: effectiveMode,
188
+ };
189
+ };
190
+
191
+ const buildBudgetDetails = ({ requestedMode, effectiveMode, validBudget, truncated }) => {
192
+ if (!validBudget) {
193
+ return null;
194
+ }
195
+
196
+ const actions = [];
197
+ if (effectiveMode !== requestedMode) actions.push('mode_degraded');
198
+ if (truncated) actions.push('content_truncated');
199
+
200
+ if (actions.length === 0) {
201
+ return null;
202
+ }
203
+
204
+ return {
205
+ scope: 'content',
206
+ maxTokens: validBudget,
207
+ finalMode: effectiveMode,
208
+ actions,
209
+ };
210
+ };
158
211
 
159
212
  const generateContent = (fullPath, extension, content, mode) => {
160
213
  if (mode === 'full') return truncate(content, 12000);
@@ -207,27 +260,52 @@ const truncateByTokens = (text, maxTokens) => {
207
260
  tokens += lineTokens;
208
261
  }
209
262
 
210
- kept.push(marker);
211
- return kept.join('\n');
263
+ let result = `${kept.join('\n')}${marker}`;
264
+ while (kept.length > 0 && countTokens(result) > maxTokens) {
265
+ kept.pop();
266
+ result = `${kept.join('\n')}${marker}`;
267
+ }
268
+
269
+ return result;
212
270
  };
213
271
 
214
- const cachedGenerate = (fullPath, extension, content, mode, mtime) => {
272
+ const cachedGenerate = async (fullPath, extension, content, mode, mtime, root = projectRoot, selector = '') => {
215
273
  const key = buildCacheKey(fullPath, mode);
216
274
  const hit = getCached(key, mtime);
217
275
  if (hit !== null) return { text: hit, cached: true };
276
+
277
+ const relPath = path.relative(root, fullPath).replace(/\\/g, '/');
278
+ const contentHash = buildContentHash(content);
279
+ const persistent = await getReadCache({ relPath, mode, selector, contentHash });
280
+ if (persistent?.payload?.text) {
281
+ setCache(key, mtime, persistent.payload.text);
282
+ return { text: persistent.payload.text, cached: true };
283
+ }
284
+
218
285
  const text = generateContent(fullPath, extension, content, mode);
219
286
  setCache(key, mtime, text);
287
+ await setReadCache({ relPath, mode, selector, contentHash, payload: { text }, tokens: countTokens(text) });
220
288
  return { text, cached: false };
221
289
  };
222
290
 
223
- const cachedSymbol = (fullPath, content, symbol, mtime) => {
291
+ const cachedSymbol = async (fullPath, content, symbol, mtime, root = projectRoot) => {
224
292
  const symbols = Array.isArray(symbol) ? symbol : [symbol];
225
293
  const extra = symbols.join(',');
226
294
  const key = buildCacheKey(fullPath, 'symbol', extra);
227
295
  const hit = getCached(key, mtime);
228
296
  if (hit !== null) return { text: hit.text, indexHint: hit.indexHint, cached: true };
297
+
298
+ const relPath = path.relative(root, fullPath).replace(/\\/g, '/');
299
+ const contentHash = buildContentHash(content);
300
+ const persistent = await getReadCache({ relPath, mode: 'symbol', selector: extra, contentHash });
301
+ if (persistent?.payload?.text) {
302
+ setCache(key, mtime, { text: persistent.payload.text, indexHint: persistent.payload.indexHint });
303
+ return { text: persistent.payload.text, indexHint: persistent.payload.indexHint, cached: true };
304
+ }
305
+
229
306
  const result = generateSymbolContent(fullPath, content, symbol);
230
307
  setCache(key, mtime, { text: result.text, indexHint: result.indexHint });
308
+ await setReadCache({ relPath, mode: 'symbol', selector: extra, contentHash, payload: result, tokens: countTokens(result.text) });
231
309
  return { ...result, cached: false };
232
310
  };
233
311
 
@@ -410,9 +488,11 @@ const formatContextSections = (sections) => {
410
488
  return parts.length > 0 ? '\n' + parts.join('\n') : '';
411
489
  };
412
490
 
413
- export const smartRead = async ({ filePath, mode = 'outline', startLine, endLine, symbol, maxTokens, context: includeContext, cwd, progress: enableProgress = false }) => {
491
+ export const smartRead = async ({ filePath, mode = 'outline', startLine, endLine, symbol, maxTokens, tokenBudget, context: includeContext, cwd, progress: enableProgress = false }) => {
414
492
  const progress = enableProgress ? createProgressReporter('smart_read') : null;
415
493
  const startTime = Date.now();
494
+ const normalizedTokenBudget = normalizeTokenBudget(tokenBudget);
495
+ const budgetWindow = resolveTokenBudgetWindow({ tokenBudget: normalizedTokenBudget, maxTokens });
416
496
 
417
497
  let fullPath, content;
418
498
  const effectiveRoot = cwd || projectRoot;
@@ -448,11 +528,13 @@ export const smartRead = async ({ filePath, mode = 'outline', startLine, endLine
448
528
  const extension = path.extname(fullPath).toLowerCase();
449
529
  const mtime = getFileMtime(fullPath);
450
530
 
451
- const validBudget = Number.isFinite(maxTokens) && maxTokens >= 1 ? maxTokens : null;
531
+ const effectiveMaxTokens = budgetWindow.effectiveMaxTokens;
532
+ const validBudget = Number.isFinite(effectiveMaxTokens) && effectiveMaxTokens >= 1 ? effectiveMaxTokens : null;
452
533
  let effectiveMode = mode;
453
534
  let indexHintUsed = false;
454
535
  let compressedText;
455
536
  let cacheHit = false;
537
+ let fullModeMetadata = null;
456
538
 
457
539
  if (mode === 'range') {
458
540
  const r = cachedRange(content, startLine, endLine, fullPath, mtime);
@@ -463,15 +545,24 @@ export const smartRead = async ({ filePath, mode = 'outline', startLine, endLine
463
545
  const start = Math.max(0, (startLine ?? 1) - 1);
464
546
  const end = endLine ?? lines.length;
465
547
  const rangeContent = lines.slice(start, end).join('\n');
466
- const g = cachedGenerate(fullPath, extension, rangeContent, 'outline', mtime);
548
+ const g = await cachedGenerate(fullPath, extension, rangeContent, 'outline', mtime, effectiveRoot, `${startLine ?? 1}-${endLine ?? ''}`);
467
549
  compressedText = g.text;
468
550
  cacheHit = g.cached;
469
551
  effectiveMode = 'outline';
470
552
  } else if (mode === 'symbol') {
471
- const sym = cachedSymbol(fullPath, content, symbol, mtime);
553
+ const sym = await cachedSymbol(fullPath, content, symbol, mtime, effectiveRoot);
472
554
  compressedText = sym.text;
473
555
  indexHintUsed = sym.indexHint;
474
556
  cacheHit = sym.cached;
557
+ if (validBudget && normalizedTokenBudget?.shared && countTokens(compressedText) > validBudget) {
558
+ for (const candidate of ['signatures', 'outline']) {
559
+ const g = await cachedGenerate(fullPath, extension, content, candidate, mtime, effectiveRoot);
560
+ compressedText = g.text;
561
+ if (g.cached) cacheHit = true;
562
+ effectiveMode = candidate;
563
+ if (countTokens(compressedText) <= validBudget) break;
564
+ }
565
+ }
475
566
  } else if (mode === 'explain') {
476
567
  if (!symbol) {
477
568
  compressedText = 'Error: symbol parameter is required for explain mode';
@@ -488,12 +579,20 @@ export const smartRead = async ({ filePath, mode = 'outline', startLine, endLine
488
579
  if (anyCached) cacheHit = true;
489
580
  indexHintUsed = anyIndex;
490
581
  }
582
+ if (validBudget && normalizedTokenBudget?.shared && countTokens(compressedText) > validBudget) {
583
+ for (const candidate of ['signatures', 'outline']) {
584
+ const g = await cachedGenerate(fullPath, extension, content, candidate, mtime, effectiveRoot);
585
+ compressedText = g.text;
586
+ if (g.cached) cacheHit = true;
587
+ effectiveMode = candidate;
588
+ if (countTokens(compressedText) <= validBudget) break;
589
+ }
590
+ }
491
591
  } else if (validBudget) {
492
- const cascadeFrom = MODE_CASCADE.indexOf(effectiveMode);
493
- const cascade = cascadeFrom >= 0 ? MODE_CASCADE.slice(cascadeFrom) : [effectiveMode];
592
+ const cascade = getBudgetCascade(effectiveMode);
494
593
 
495
594
  for (const candidate of cascade) {
496
- const g = cachedGenerate(fullPath, extension, content, candidate, mtime);
595
+ const g = await cachedGenerate(fullPath, extension, content, candidate, mtime, effectiveRoot);
497
596
  compressedText = g.text;
498
597
  if (g.cached) cacheHit = true;
499
598
  effectiveMode = candidate;
@@ -504,11 +603,13 @@ export const smartRead = async ({ filePath, mode = 'outline', startLine, endLine
504
603
  compressedText = truncateByTokens(compressedText, validBudget);
505
604
  }
506
605
  } else {
507
- const g = cachedGenerate(fullPath, extension, content, mode, mtime);
606
+ const g = await cachedGenerate(fullPath, extension, content, mode, mtime, effectiveRoot);
508
607
  compressedText = g.text;
509
608
  cacheHit = g.cached;
510
609
  }
511
610
 
611
+ fullModeMetadata = buildFullModeMetadata({ requestedMode: mode, effectiveMode, validBudget });
612
+
512
613
  if (progress) {
513
614
  const compressedTokens = countTokens(compressedText);
514
615
  const rawTokens = countTokens(content);
@@ -542,6 +643,7 @@ export const smartRead = async ({ filePath, mode = 'outline', startLine, endLine
542
643
  const rawMode = effectiveMode === 'full' || effectiveMode === 'range';
543
644
  const parser = mode === 'explain' ? 'structural' : (rawMode ? 'raw' : resolveParserType(extension, fullPath));
544
645
  const truncated = compressedText.includes('[truncated ');
646
+ const budgetDetails = buildBudgetDetails({ requestedMode: mode, effectiveMode, validBudget, truncated });
545
647
 
546
648
  const metrics = buildMetrics({
547
649
  tool: 'smart_read',
@@ -598,12 +700,23 @@ export const smartRead = async ({ filePath, mode = 'outline', startLine, endLine
598
700
  content: compressedText,
599
701
  };
600
702
  if (mode === 'symbol' || mode === 'explain') result.indexHint = indexHintUsed;
601
- if (mode === 'explain') result.cached = cacheHit;
602
- if (validBudget && effectiveMode !== mode) {
703
+ result.cached = cacheHit;
704
+ if (normalizedTokenBudget) result.taskBudget = normalizedTokenBudget;
705
+ if (normalizedTokenBudget) {
706
+ result.remainingBudget = consumeTokenBudget({
707
+ tokenBudget: normalizedTokenBudget,
708
+ usedTokens: countTokens(compressedText),
709
+ });
710
+ }
711
+ if (effectiveMode !== mode) {
603
712
  result.chosenMode = effectiveMode;
713
+ }
714
+ if (budgetDetails) {
604
715
  result.budgetApplied = true;
716
+ result.budgetDetails = budgetDetails;
605
717
  }
606
718
  if (contextResult) Object.assign(result, contextResult);
719
+ if (fullModeMetadata) result.fullMode = fullModeMetadata;
607
720
 
608
721
  return result;
609
722
  };