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,10 +11,12 @@ import { persistMetrics } from '../metrics.js';
11
11
  import { runStorageMaintenance } from '../storage/sqlite.js';
12
12
  import { PRODUCT_QUALITY_ANALYTICS_KIND } from '../analytics/product-quality.js';
13
13
  import { attachSafetyMetadata, buildMutationSafety } from '../utils/mutation-safety.js';
14
+ import { normalizeTokenBudget, peekRemainingBudget } from '../utils/task-budget.js';
14
15
  import { smartContext } from './smart-context.js';
15
16
  import { smartMetrics } from './smart-metrics.js';
16
17
  import { smartSummary } from './smart-summary.js';
17
18
  import { deriveStartActions, deriveEndActions } from '../turn/next-actions.js';
19
+ import { isSimpleTask } from '../orchestration/policy/event-policy.js';
18
20
 
19
21
  const isStorageUnhealthy = (health) =>
20
22
  health && health.status !== 'ok' && health.status !== null && health.status !== undefined;
@@ -30,6 +32,7 @@ const DEFAULT_END_MAX_TOKENS = 500;
30
32
  const DEFAULT_END_EVENT = 'milestone';
31
33
  const DEFAULT_REFRESH_CONTEXT_MAX_TOKENS = 1400;
32
34
  const MAX_PROMPT_PREVIEW = 160;
35
+ const SIMPLE_TASK_SKIP_MAX_LENGTH = 40;
33
36
  const REFRESHED_CONTEXT_FILE_LIMIT = 3;
34
37
  const SAFE_CONTINUITY_STATES = new Set(['aligned', 'resume']);
35
38
  const WORKFLOW_END_EVENTS = new Set(['milestone', 'task_complete', 'session_end', 'blocker']);
@@ -178,6 +181,46 @@ const hasMeaningfulPrompt = (prompt) => {
178
181
  return normalized.length >= 20 && extractTerms(normalized).length >= 4;
179
182
  };
180
183
 
184
+ const buildSimpleTaskStartResult = ({ prompt, tokenBudget, verbosity = DEFAULT_VERBOSITY }) => {
185
+ const normalizedTokenBudget = normalizeTokenBudget(tokenBudget);
186
+ const recommendedPath = verbosity === 'minimal'
187
+ ? {
188
+ phase: 'start',
189
+ mode: 'simple_task_skip',
190
+ nextTools: ['smart_read', 'smart_search'],
191
+ next: 'smart_read: Skip smart_turn for this simple task and use lightweight read/search directly.',
192
+ }
193
+ : {
194
+ phase: 'start',
195
+ mode: 'simple_task_skip',
196
+ contextSource: 'direct_prompt',
197
+ continuityState: 'simple_task_skip',
198
+ ensureSessionRecommended: false,
199
+ autoCreated: false,
200
+ isolatedSession: false,
201
+ nextTools: ['smart_read', 'smart_search'],
202
+ nextActions: deriveStartActions({ prompt, mode: 'simple_task_skip', refreshedContext: null, summaryResult: null }),
203
+ next: 'smart_read: Skip smart_turn for this simple task and use lightweight read/search directly.',
204
+ instructions: 'smart_read: Skip smart_turn for this simple task and use lightweight read/search directly. | smart_search: Use only if the task grows beyond a single targeted read.',
205
+ };
206
+
207
+ return {
208
+ phase: 'start',
209
+ skipSmartTurn: true,
210
+ continuity: {
211
+ state: 'simple_task_skip',
212
+ shouldReuseContext: false,
213
+ reason: 'Simple task heuristic skipped persisted continuity setup to avoid overhead.',
214
+ },
215
+ ...(normalizedTokenBudget ? {
216
+ taskBudget: normalizedTokenBudget,
217
+ remainingBudget: peekRemainingBudget({ tokenBudget: normalizedTokenBudget }),
218
+ } : {}),
219
+ recommendedPath,
220
+ message: 'Simple task heuristic skipped smart_turn(start); use lightweight read/search flow unless the task grows.',
221
+ };
222
+ };
223
+
181
224
  const buildAutoCreateUpdate = (prompt) => ({
182
225
  goal: truncate(prompt, 120),
183
226
  status: 'planning',
@@ -253,6 +296,28 @@ const shouldRefreshContext = ({ prompt, ensureSession, summaryResult, continuity
253
296
  || ['possible_shift', 'context_mismatch'].includes(continuity?.state)
254
297
  );
255
298
 
299
+ const shouldIncludeSummaryInMinimal = ({ continuity, summaryResult, mutationSafety, autoCreated }) =>
300
+ Boolean(mutationSafety?.blocked)
301
+ || Boolean(summaryResult?.ambiguous)
302
+ || Boolean(autoCreated)
303
+ || ['possible_shift', 'context_mismatch'].includes(continuity?.state);
304
+
305
+ const shouldIncludeRefreshedContextInMinimal = ({ continuity, summaryResult, mutationSafety, refreshedContext }) =>
306
+ Boolean(mutationSafety?.blocked)
307
+ || Boolean(summaryResult?.ambiguous)
308
+ || ['possible_shift', 'context_mismatch'].includes(continuity?.state)
309
+ || Number(refreshedContext?.topFiles?.length ?? 0) > 0;
310
+
311
+ const compactSummaryForMinimal = (summary) => {
312
+ if (!summary) return null;
313
+ return {
314
+ ...(summary.status ? { status: summary.status } : {}),
315
+ ...(summary.goal ? { goal: summary.goal } : {}),
316
+ ...(summary.currentFocus ? { currentFocus: summary.currentFocus } : {}),
317
+ ...(summary.nextStep ? { nextStep: summary.nextStep } : {}),
318
+ };
319
+ };
320
+
256
321
  const buildStartRecommendedPath = ({
257
322
  prompt,
258
323
  ensureSession,
@@ -444,6 +509,7 @@ const startTurn = async ({
444
509
  taskId,
445
510
  prompt,
446
511
  maxTokens = DEFAULT_START_MAX_TOKENS,
512
+ tokenBudget,
447
513
  ensureSession = false,
448
514
  includeMetrics = false,
449
515
  metricsWindow = '7d',
@@ -451,6 +517,11 @@ const startTurn = async ({
451
517
  verbosity = DEFAULT_VERBOSITY,
452
518
  } = {}) => {
453
519
  const startTime = Date.now();
520
+ const normalizedTokenBudget = normalizeTokenBudget(tokenBudget);
521
+
522
+ if (!sessionId && !taskId && isSimpleTask(prompt) && normalizeWhitespace(prompt).length <= SIMPLE_TASK_SKIP_MAX_LENGTH) {
523
+ return buildSimpleTaskStartResult({ prompt, tokenBudget: normalizedTokenBudget, verbosity });
524
+ }
454
525
 
455
526
  if (process.env.DEVCTX_DISABLE_BACKGROUND_TASKS !== 'true') {
456
527
  triggerBackgroundIndexBuild({ root: projectRoot }).catch(() => {});
@@ -602,6 +673,8 @@ const startTurn = async ({
602
673
  const compactTask = summaryResult.task && minimal
603
674
  ? { taskId: summaryResult.task.taskId, status: summaryResult.task.status }
604
675
  : summaryResult.task ?? null;
676
+ const includeSummary = !minimal || shouldIncludeSummaryInMinimal({ continuity, summaryResult, mutationSafety, autoCreated });
677
+ const includeRefreshedContext = !minimal || shouldIncludeRefreshedContextInMinimal({ continuity, summaryResult, mutationSafety, refreshedContext });
605
678
  const includeMessage = !minimal || mutationSafety?.blocked;
606
679
 
607
680
  return attachSafetyMetadata({
@@ -613,8 +686,8 @@ const startTurn = async ({
613
686
  ...(minimal ? {} : { promptPreview: truncate(prompt, MAX_PROMPT_PREVIEW) }),
614
687
  ...(previousSessionId ? { previousSessionId } : {}),
615
688
  continuity: compactContinuity,
616
- ...(summaryResult.summary ? { summary: summaryResult.summary } : {}),
617
- ...(refreshedContext ? { refreshedContext } : {}),
689
+ ...(includeSummary && summaryResult.summary ? { summary: minimal ? compactSummaryForMinimal(summaryResult.summary) : summaryResult.summary } : {}),
690
+ ...(includeRefreshedContext && refreshedContext ? { refreshedContext } : {}),
618
691
  ...(workflow ? { workflow } : {}),
619
692
  ...(!minimal && summaryResult.candidates ? { candidates: summaryResult.candidates } : {}),
620
693
  ...(summaryResult.ambiguous && summaryResult.recommendedSessionId ? { recommendedSessionId: summaryResult.recommendedSessionId } : {}),
@@ -632,6 +705,10 @@ const startTurn = async ({
632
705
  ? 'Created a new persisted session for this task prompt.'
633
706
  : continuity.reason,
634
707
  } : {}),
708
+ ...(normalizedTokenBudget ? {
709
+ taskBudget: normalizedTokenBudget,
710
+ remainingBudget: peekRemainingBudget({ tokenBudget: normalizedTokenBudget }),
711
+ } : {}),
635
712
  }, {
636
713
  repoSafety: summaryResult.repoSafety ?? metrics?.repoSafety ?? null,
637
714
  sideEffectsSuppressed: Boolean(summaryResult.sideEffectsSuppressed ?? metrics?.sideEffectsSuppressed),
@@ -649,12 +726,14 @@ const endTurn = async ({
649
726
  update = {},
650
727
  force = false,
651
728
  maxTokens = DEFAULT_END_MAX_TOKENS,
729
+ tokenBudget,
652
730
  includeMetrics = false,
653
731
  metricsWindow = '7d',
654
732
  latestMetrics = 5,
655
733
  verbosity = DEFAULT_VERBOSITY,
656
734
  } = {}) => {
657
735
  const startTime = Date.now();
736
+ const normalizedTokenBudget = normalizeTokenBudget(tokenBudget);
658
737
  const checkpoint = await smartSummary({
659
738
  action: 'checkpoint',
660
739
  sessionId,
@@ -741,8 +820,6 @@ const endTurn = async ({
741
820
  ? {
742
821
  skipped: Boolean(checkpoint.skipped),
743
822
  ...(checkpoint.blocked !== undefined ? { blocked: checkpoint.blocked } : {}),
744
- ...(checkpoint.summary ? { summary: checkpoint.summary } : {}),
745
- ...(checkpoint.tokens !== undefined ? { tokens: checkpoint.tokens } : {}),
746
823
  ...(checkpoint.checkpoint ? {
747
824
  checkpoint: {
748
825
  event: checkpoint.checkpoint.event,
@@ -762,6 +839,10 @@ const endTurn = async ({
762
839
  ...(workflow ? { workflow } : {}),
763
840
  ...(metrics ? { metrics: summarizeMetrics(metrics) } : {}),
764
841
  ...(isStorageUnhealthy(checkpoint.storageHealth ?? metrics?.storageHealth) ? { storageHealth: checkpoint.storageHealth ?? metrics?.storageHealth } : {}),
842
+ ...(normalizedTokenBudget ? {
843
+ taskBudget: normalizedTokenBudget,
844
+ remainingBudget: peekRemainingBudget({ tokenBudget: normalizedTokenBudget }),
845
+ } : {}),
765
846
  recommendedPath,
766
847
  ...(includeMessage ? { message: mutationSafety?.blocked ? mutationSafety.message : checkpoint.message } : {}),
767
848
  }, {
@@ -783,6 +864,7 @@ export const smartTurn = async ({
783
864
  event,
784
865
  force,
785
866
  maxTokens,
867
+ tokenBudget,
786
868
  ensureSession = false,
787
869
  includeMetrics = false,
788
870
  metricsWindow = '7d',
@@ -797,6 +879,7 @@ export const smartTurn = async ({
797
879
  taskId,
798
880
  prompt,
799
881
  maxTokens,
882
+ tokenBudget,
800
883
  ensureSession,
801
884
  includeMetrics,
802
885
  metricsWindow,
@@ -813,6 +896,7 @@ export const smartTurn = async ({
813
896
  update,
814
897
  force,
815
898
  maxTokens,
899
+ tokenBudget,
816
900
  includeMetrics,
817
901
  metricsWindow,
818
902
  latestMetrics,
@@ -0,0 +1,116 @@
1
+ export const normalizeTokenBudget = (value) => {
2
+ if (Number.isFinite(value) && value >= 1) {
3
+ return { maxTokens: Math.floor(value), shared: false };
4
+ }
5
+
6
+ if (!value || typeof value !== 'object') {
7
+ return null;
8
+ }
9
+
10
+ const maxTokens = Number.isFinite(value.maxTokens) && value.maxTokens >= 1
11
+ ? Math.floor(value.maxTokens)
12
+ : null;
13
+
14
+ if (!maxTokens) {
15
+ return null;
16
+ }
17
+
18
+ const normalized = {
19
+ maxTokens,
20
+ shared: Boolean(value.shared || value.id),
21
+ };
22
+
23
+ if (typeof value.id === 'string' && value.id.trim()) {
24
+ normalized.id = value.id.trim();
25
+ }
26
+
27
+ return normalized;
28
+ };
29
+
30
+ export const resolveBudgetMaxTokens = (maxTokens, tokenBudget) => {
31
+ if (Number.isFinite(maxTokens) && maxTokens >= 1) {
32
+ return maxTokens;
33
+ }
34
+
35
+ return normalizeTokenBudget(tokenBudget)?.maxTokens;
36
+ };
37
+
38
+ const sharedTaskBudgets = new Map();
39
+
40
+ const getSharedBudgetKey = (tokenBudget, fallbackKey = null) => {
41
+ const normalized = normalizeTokenBudget(tokenBudget);
42
+ if (!normalized?.shared) return null;
43
+ if (normalized.id) return normalized.id;
44
+ if (typeof fallbackKey === 'string' && fallbackKey.trim()) return fallbackKey.trim();
45
+ return null;
46
+ };
47
+
48
+ const ensureSharedBudgetState = (normalizedTokenBudget, fallbackKey = null) => {
49
+ const sharedKey = getSharedBudgetKey(normalizedTokenBudget, fallbackKey);
50
+ if (!sharedKey) {
51
+ return { sharedKey: null, state: null };
52
+ }
53
+
54
+ const existing = sharedTaskBudgets.get(sharedKey);
55
+ if (!existing || existing.maxTokens !== normalizedTokenBudget.maxTokens) {
56
+ const next = {
57
+ maxTokens: normalizedTokenBudget.maxTokens,
58
+ remainingBudget: normalizedTokenBudget.maxTokens,
59
+ };
60
+ sharedTaskBudgets.set(sharedKey, next);
61
+ return { sharedKey, state: next };
62
+ }
63
+
64
+ return { sharedKey, state: existing };
65
+ };
66
+
67
+ export const resolveTokenBudgetWindow = ({ maxTokens, tokenBudget, fallbackKey = null } = {}) => {
68
+ const normalized = normalizeTokenBudget(tokenBudget);
69
+ const explicitMaxTokens = Number.isFinite(maxTokens) && maxTokens >= 1 ? Math.floor(maxTokens) : null;
70
+ const baseMaxTokens = explicitMaxTokens ?? normalized?.maxTokens ?? null;
71
+
72
+ if (!normalized) {
73
+ return {
74
+ normalized: null,
75
+ sharedKey: null,
76
+ remainingBudget: null,
77
+ effectiveMaxTokens: baseMaxTokens,
78
+ };
79
+ }
80
+
81
+ const { sharedKey, state } = ensureSharedBudgetState(normalized, fallbackKey);
82
+ const remainingBudget = sharedKey ? state.remainingBudget : normalized.maxTokens;
83
+ const effectiveMaxTokens = baseMaxTokens == null
84
+ ? remainingBudget
85
+ : Math.min(baseMaxTokens, remainingBudget);
86
+
87
+ return {
88
+ normalized,
89
+ sharedKey,
90
+ remainingBudget,
91
+ effectiveMaxTokens,
92
+ };
93
+ };
94
+
95
+ export const peekRemainingBudget = ({ tokenBudget, fallbackKey = null } = {}) =>
96
+ resolveTokenBudgetWindow({ tokenBudget, fallbackKey }).remainingBudget;
97
+
98
+ export const consumeTokenBudget = ({ tokenBudget, usedTokens = 0, fallbackKey = null } = {}) => {
99
+ const normalized = normalizeTokenBudget(tokenBudget);
100
+ if (!normalized) {
101
+ return null;
102
+ }
103
+
104
+ const consumed = Math.max(0, Math.floor(usedTokens));
105
+ const { sharedKey, state } = ensureSharedBudgetState(normalized, fallbackKey);
106
+
107
+ if (!sharedKey) {
108
+ return Math.max(0, normalized.maxTokens - consumed);
109
+ }
110
+
111
+ state.remainingBudget = Math.max(0, state.remainingBudget - consumed);
112
+ sharedTaskBudgets.set(sharedKey, state);
113
+ return state.remainingBudget;
114
+ };
115
+
116
+ export const clearTaskBudgets = () => sharedTaskBudgets.clear();