smart-context-mcp 1.7.0 → 1.7.2

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
@@ -52,9 +52,9 @@ Restart your AI client. Done.
52
52
 
53
53
  ---
54
54
 
55
- ## `1.6.0` Task Runner
55
+ ## Task Runner
56
56
 
57
- `1.6.0` adds `smart-context-task`, a workflow-oriented CLI on top of the raw MCP tools.
57
+ `smart-context-task` is a workflow-oriented CLI on top of the raw MCP tools.
58
58
 
59
59
  Use it when you want a more repeatable path than “agent reads rules and hopefully picks the right flow”.
60
60
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "smart-context-mcp",
3
- "version": "1.7.0",
3
+ "version": "1.7.2",
4
4
  "description": "MCP server that reduces agent token usage by 90% with intelligent context compression, task checkpoint persistence, and workflow-aware agent guidance.",
5
5
  "author": "Francisco Caballero Portero <fcp1978@hotmail.com>",
6
6
  "type": "module",
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { runHeadlessWrapper } from '../src/orchestration/headless-wrapper.js';
3
+ import { detectClient } from '../src/utils/client-detection.js';
3
4
 
4
5
  const requireValue = (argv, index, flag) => {
5
6
  const value = argv[index + 1];
@@ -11,7 +12,7 @@ const requireValue = (argv, index, flag) => {
11
12
 
12
13
  const parseArgs = (argv) => {
13
14
  const options = {
14
- client: 'generic',
15
+ client: null,
15
16
  prompt: '',
16
17
  sessionId: undefined,
17
18
  event: undefined,
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { runTaskRunner } from '../src/task-runner.js';
3
3
  import { checkNodeVersion } from '../src/utils/runtime-check.js';
4
+ import { detectClient } from '../src/utils/client-detection.js';
4
5
 
5
6
  const runtimeCheck = checkNodeVersion();
6
7
  if (!runtimeCheck.ok) {
@@ -28,7 +29,7 @@ const parseArgs = (argv) => {
28
29
  const rest = argv[0] && !argv[0].startsWith('--') ? argv.slice(1) : argv;
29
30
  const options = {
30
31
  commandName: subcommand,
31
- client: 'generic',
32
+ client: null,
32
33
  prompt: '',
33
34
  sessionId: undefined,
34
35
  event: undefined,
package/src/metrics.js CHANGED
@@ -267,6 +267,18 @@ export const aggregateMetrics = (entries) => {
267
267
 
268
268
  const netSavedTokens = getNetSavedTokens(savedTokens, overheadTokens);
269
269
 
270
+ const topTools = tools
271
+ .slice()
272
+ .sort((a, b) => b.netSavedTokens - a.netSavedTokens)
273
+ .slice(0, 3)
274
+ .filter((tool) => tool.netSavedTokens > 0)
275
+ .map((tool) => ({
276
+ tool: tool.tool,
277
+ netSavedTokens: tool.netSavedTokens,
278
+ netSavingsPct: tool.netSavingsPct,
279
+ count: tool.count,
280
+ }));
281
+
270
282
  return {
271
283
  count: entries.length,
272
284
  rawTokens,
@@ -275,6 +287,7 @@ export const aggregateMetrics = (entries) => {
275
287
  savingsPct: rawTokens > 0 ? Number(((savedTokens / rawTokens) * 100).toFixed(2)) : 0,
276
288
  netSavedTokens,
277
289
  netSavingsPct: rawTokens > 0 ? Number(((netSavedTokens / rawTokens) * 100).toFixed(2)) : 0,
290
+ topTools,
278
291
  tools,
279
292
  overheadTokens,
280
293
  overheadPctOfRaw: rawTokens > 0 ? Number(((overheadTokens / rawTokens) * 100).toFixed(2)) : 0,
@@ -8,6 +8,7 @@ import {
8
8
  extractNextStep,
9
9
  normalizeWhitespace,
10
10
  truncate,
11
+ isSimpleTask,
11
12
  MAX_FOCUS_LENGTH,
12
13
  MAX_GOAL_LENGTH,
13
14
  } from './policy/event-policy.js';
@@ -113,21 +114,25 @@ export const resolveManagedStart = async ({
113
114
  startMaxTokens = DEFAULT_START_MAX_TOKENS,
114
115
  startTurn = smartTurn,
115
116
  summaryTool = smartSummary,
117
+ enableFastPath = true,
116
118
  }) => {
119
+ const simpleTask = enableFastPath && isSimpleTask(prompt);
120
+
117
121
  const startResult = preparedStartResult ?? await startTurn({
118
122
  phase: 'start',
119
123
  sessionId,
120
124
  prompt,
121
- ensureSession,
125
+ ensureSession: simpleTask ? false : ensureSession,
122
126
  maxTokens: startMaxTokens,
123
127
  });
124
128
 
125
- if (!allowIsolation) {
129
+ if (!allowIsolation || simpleTask) {
126
130
  return {
127
131
  startResult,
128
132
  isolated: Boolean(startResult?.isolatedSession),
129
133
  previousSessionId: startResult?.previousSessionId ?? null,
130
134
  autoStarted: !preparedStartResult,
135
+ fastPath: simpleTask,
131
136
  };
132
137
  }
133
138
 
@@ -143,6 +148,7 @@ export const resolveManagedStart = async ({
143
148
  return {
144
149
  ...isolatedSession,
145
150
  autoStarted: !preparedStartResult,
151
+ fastPath: false,
146
152
  };
147
153
  };
148
154
 
@@ -7,6 +7,7 @@ import {
7
7
  resolveManagedStart,
8
8
  } from './base-orchestrator.js';
9
9
  import { normalizeWhitespace } from './policy/event-policy.js';
10
+ import { detectClient } from '../utils/client-detection.js';
10
11
 
11
12
  const runChildProcess = ({ command, args, env, stdinText, streamOutput }) => new Promise((resolve, reject) => {
12
13
  const child = spawn(command, args, {
@@ -44,7 +45,7 @@ const runChildProcess = ({ command, args, env, stdinText, streamOutput }) => new
44
45
  });
45
46
 
46
47
  export const runHeadlessWrapper = async ({
47
- client = 'generic',
48
+ client = null,
48
49
  prompt,
49
50
  command,
50
51
  args = [],
@@ -56,6 +57,8 @@ export const runHeadlessWrapper = async ({
56
57
  runCommand = runChildProcess,
57
58
  preparedStartResult = null,
58
59
  } = {}) => {
60
+ const resolvedClient = client ?? detectClient();
61
+
59
62
  if (!normalizeWhitespace(prompt)) {
60
63
  throw new Error('prompt is required');
61
64
  }
@@ -77,7 +80,7 @@ export const runHeadlessWrapper = async ({
77
80
 
78
81
  await recordAgentWrapperMetric({
79
82
  phase: 'start',
80
- client,
83
+ client: resolvedClient,
81
84
  sessionId: effectiveStart.sessionId ?? null,
82
85
  dryRun,
83
86
  overheadTokens,
@@ -89,7 +92,7 @@ export const runHeadlessWrapper = async ({
89
92
  const finalArgs = stdinPrompt ? [...args] : [...args, wrappedPrompt];
90
93
  if (dryRun) {
91
94
  return {
92
- client,
95
+ client: resolvedClient,
93
96
  dryRun: true,
94
97
  command,
95
98
  args: finalArgs,
@@ -123,7 +126,7 @@ export const runHeadlessWrapper = async ({
123
126
 
124
127
  await recordAgentWrapperMetric({
125
128
  phase: 'end',
126
- client,
129
+ client: resolvedClient,
127
130
  sessionId: effectiveStart.sessionId ?? null,
128
131
  isolatedSession: sessionResolution.isolated,
129
132
  exitCode: childResult.exitCode,
@@ -132,7 +135,7 @@ export const runHeadlessWrapper = async ({
132
135
  });
133
136
 
134
137
  return {
135
- client,
138
+ client: resolvedClient,
136
139
  command,
137
140
  args: finalArgs,
138
141
  wrappedPrompt,
@@ -13,6 +13,39 @@ export const MIN_NEXT_STEP_LENGTH = 12;
13
13
  export const MAX_NEXT_STEP_CAPTURE_LENGTH = 180;
14
14
  export const MAX_RECOMMENDED_TOOLS = 3;
15
15
 
16
+ const SIMPLE_TASK_PATTERNS = [
17
+ /^(move|rename|delete|add|remove|fix|update|change|modify|edit)\s+\w+/i,
18
+ /^(create|write|read|show|display|list|find)\s+(a|an|the)?\s*\w+/i,
19
+ /\b(one|single|this|that)\s+(file|function|class|component|method)\b/i,
20
+ /^(add|remove|update|fix)\s+(comment|import|export|type|interface)/i,
21
+ ];
22
+
23
+ const COMPLEX_TASK_INDICATORS = [
24
+ /\b(refactor|migrate|redesign|architect|implement|integrate)\b/i,
25
+ /\b(all|every|entire|whole|across|throughout)\b/i,
26
+ /\b(system|architecture|infrastructure|framework)\b/i,
27
+ /\b(multiple|several|many)\s+(files|components|modules)\b/i,
28
+ ];
29
+
30
+ export const isSimpleTask = (prompt) => {
31
+ if (!prompt || typeof prompt !== 'string') {
32
+ return false;
33
+ }
34
+
35
+ const normalized = normalizeWhitespace(prompt);
36
+
37
+ if (normalized.length > 200) {
38
+ return false;
39
+ }
40
+
41
+ const hasComplexIndicator = COMPLEX_TASK_INDICATORS.some((pattern) => pattern.test(normalized));
42
+ if (hasComplexIndicator) {
43
+ return false;
44
+ }
45
+
46
+ return SIMPLE_TASK_PATTERNS.some((pattern) => pattern.test(normalized));
47
+ };
48
+
16
49
  export const normalizeWhitespace = (value) => String(value ?? '').replace(/\s+/g, ' ').trim();
17
50
 
18
51
  export const truncate = (value, maxLength = DEFAULT_TRUNCATE_LENGTH) => {
@@ -132,9 +165,10 @@ export const runWorkflowPreflight = async ({
132
165
  startResult,
133
166
  contextTool = smartContext,
134
167
  searchTool = smartSearch,
168
+ skipPreflight = false,
135
169
  }) => {
136
170
  const preflight = workflowProfile.preflight;
137
- if (!preflight) {
171
+ if (!preflight || skipPreflight) {
138
172
  return null;
139
173
  }
140
174
 
@@ -281,6 +315,7 @@ export const buildTaskRunnerAutomaticity = ({
281
315
  usedWrapper = false,
282
316
  overheadTokens = 0,
283
317
  managedByBaseOrchestrator = false,
318
+ fastPath = false,
284
319
  }) => {
285
320
  const safeOverheadTokens = Number.isFinite(overheadTokens) ? Math.max(0, overheadTokens) : 0;
286
321
  const checkpointPersisted = Boolean(endResult && !endResult.checkpoint?.skipped && !endResult.checkpoint?.blocked);
@@ -293,5 +328,6 @@ export const buildTaskRunnerAutomaticity = ({
293
328
  autoWrappedPrompt: usedWrapper && safeOverheadTokens > 0,
294
329
  isolatedSession: Boolean(startResult?.isolatedSession),
295
330
  contextOverheadTokens: safeOverheadTokens,
331
+ fastPath,
296
332
  };
297
333
  };
@@ -27,6 +27,7 @@ import {
27
27
  buildWorkflowPrompt,
28
28
  evaluateRunnerGate,
29
29
  } from './task-runner/policy.js';
30
+ import { detectClient } from './utils/client-detection.js';
30
31
 
31
32
  const RUNNER_LOCK_RETRY_ATTEMPTS = 3;
32
33
  const RUNNER_LOCK_RETRY_DELAY_MS = 100;
@@ -61,6 +62,7 @@ const recordRunnerMetrics = async ({
61
62
  usedWrapper = false,
62
63
  blocked = false,
63
64
  doctorIssued = false,
65
+ fastPath = false,
64
66
  }) => {
65
67
  const startResult = result?.start ?? result?.startResult ?? null;
66
68
  const endResult = result?.end ?? (result?.phase === 'end' ? result : null);
@@ -82,6 +84,7 @@ const recordRunnerMetrics = async ({
82
84
  usedWrapper,
83
85
  overheadTokens: Number(result?.overheadTokens ?? 0),
84
86
  managedByBaseOrchestrator: WORKFLOW_COMMANDS.has(commandName),
87
+ fastPath,
85
88
  });
86
89
 
87
90
  await persistMetrics({
@@ -147,8 +150,10 @@ const runWorkflowCommand = async ({
147
150
  ensureSession: true,
148
151
  allowIsolation: false,
149
152
  startMaxTokens: DEFAULT_START_MAX_TOKENS,
153
+ enableFastPath: true,
150
154
  }));
151
155
  const start = startResolution.startResult;
156
+ const fastPath = startResolution.fastPath ?? false;
152
157
 
153
158
  const gate = evaluateRunnerGate({ startResult: start });
154
159
  let preflightSummary = null;
@@ -158,6 +163,7 @@ const runWorkflowCommand = async ({
158
163
  workflowProfile,
159
164
  prompt: requestedPrompt,
160
165
  startResult: start,
166
+ skipPreflight: fastPath,
161
167
  });
162
168
  preflightSummary = buildPreflightSummary(preflightResult);
163
169
  }
@@ -193,6 +199,7 @@ const runWorkflowCommand = async ({
193
199
  usedWrapper: false,
194
200
  blocked: true,
195
201
  doctorIssued: true,
202
+ fastPath,
196
203
  });
197
204
  return blockedResult;
198
205
  }
@@ -230,6 +237,7 @@ const runWorkflowCommand = async ({
230
237
  usedWrapper: true,
231
238
  blocked: false,
232
239
  doctorIssued: false,
240
+ fastPath,
233
241
  });
234
242
  return result;
235
243
  };
@@ -414,7 +422,7 @@ const runCleanupCommand = async ({
414
422
 
415
423
  export const runTaskRunner = async ({
416
424
  commandName = 'task',
417
- client = 'generic',
425
+ client = null,
418
426
  prompt = '',
419
427
  sessionId,
420
428
  event,
@@ -436,6 +444,8 @@ export const runTaskRunner = async ({
436
444
  vacuum = false,
437
445
  update = {},
438
446
  } = {}) => {
447
+ const resolvedClient = client ?? detectClient();
448
+
439
449
  if (!RUNNER_COMMANDS.includes(commandName)) {
440
450
  throw new Error(`Unsupported task-runner command: ${commandName}`);
441
451
  }
@@ -443,7 +453,7 @@ export const runTaskRunner = async ({
443
453
  if (WORKFLOW_COMMANDS.has(commandName)) {
444
454
  return runWorkflowCommand({
445
455
  commandName,
446
- client,
456
+ client: resolvedClient,
447
457
  prompt,
448
458
  sessionId,
449
459
  event,
@@ -458,16 +468,16 @@ export const runTaskRunner = async ({
458
468
  }
459
469
 
460
470
  if (commandName === 'doctor') {
461
- return runDoctorCommand({ verifyIntegrity, client });
471
+ return runDoctorCommand({ verifyIntegrity, client: resolvedClient });
462
472
  }
463
473
 
464
474
  if (commandName === 'status') {
465
- return runStatusCommand({ format, maxItems, client });
475
+ return runStatusCommand({ format, maxItems, client: resolvedClient });
466
476
  }
467
477
 
468
478
  if (commandName === 'checkpoint') {
469
479
  return runCheckpointCommand({
470
- client,
480
+ client: resolvedClient,
471
481
  sessionId,
472
482
  event,
473
483
  update,
@@ -476,7 +486,7 @@ export const runTaskRunner = async ({
476
486
 
477
487
  if (commandName === 'cleanup') {
478
488
  return runCleanupCommand({
479
- client,
489
+ client: resolvedClient,
480
490
  cleanupMode,
481
491
  apply,
482
492
  retentionDays,
@@ -14,6 +14,8 @@ import { predictContextFiles, recordContextAccess } from '../context-patterns.js
14
14
  import { recordToolUsage } from '../usage-feedback.js';
15
15
  import { recordDecision, DECISION_REASONS, EXPECTED_BENEFITS } from '../decision-explainer.js';
16
16
  import { recordDevctxOperation } from '../missed-opportunities.js';
17
+ import { buildMetricsDisplay } from '../utils/metrics-display.js';
18
+ import { createProgressReporter } from '../streaming.js';
17
19
  import {
18
20
  getDetailedDiff,
19
21
  analyzeChangeImpact,
@@ -385,7 +387,15 @@ export const smartContext = async ({
385
387
  detail = 'balanced',
386
388
  include = DEFAULT_INCLUDE,
387
389
  prefetch = false,
390
+ progress: enableProgress = false,
388
391
  }) => {
392
+ const progress = enableProgress ? createProgressReporter('smart_context') : null;
393
+ const startTime = Date.now();
394
+
395
+ if (progress) {
396
+ progress.report({ phase: 'planning', task: task.substring(0, 80) });
397
+ }
398
+
389
399
  const resolvedIntent = (intent && VALID_INTENTS.has(intent)) ? intent : inferIntent(task);
390
400
  const root = projectRoot;
391
401
  const detailMode = VALID_DETAIL_MODES.has(detail) ? detail : 'balanced';
@@ -438,6 +448,14 @@ export const smartContext = async ({
438
448
  return impactB - impactA;
439
449
  });
440
450
 
451
+ if (progress) {
452
+ progress.report({
453
+ phase: 'diff-analysis',
454
+ changedFiles: changed.files.length,
455
+ expandedFiles: expandedFiles.size,
456
+ });
457
+ }
458
+
441
459
  diffSummary = {
442
460
  ref: changed.ref,
443
461
  totalChanged: changed.files.length + changed.skippedDeleted,
@@ -467,6 +485,10 @@ export const smartContext = async ({
467
485
  ...fallbackKeywords,
468
486
  extractFallbackSearchQuery(task),
469
487
  ]).slice(0, 6);
488
+ if (progress) {
489
+ progress.report({ phase: 'searching', queries: queryCandidates.length });
490
+ }
491
+
470
492
  const searchResults = await Promise.all(
471
493
  queryCandidates.map((query) => smartSearch({ query, cwd: '.', intent: resolvedIntent }))
472
494
  );
@@ -602,6 +624,10 @@ export const smartContext = async ({
602
624
  }
603
625
 
604
626
  if (pendingReads.length > 0) {
627
+ if (progress) {
628
+ progress.report({ phase: 'reading', files: pendingReads.length });
629
+ }
630
+
605
631
  const batchResults = await smartReadBatch({
606
632
  files: pendingReads.map(({ item }) => ({ path: item.absPath, mode: item.mode })),
607
633
  });
@@ -796,6 +822,28 @@ export const smartContext = async ({
796
822
  tests: coverageMin(perFile.map((c) => c.tests)),
797
823
  };
798
824
 
825
+ const filesIncluded = new Set(context.map((c) => c.file)).size;
826
+ const metricsDisplay = buildMetricsDisplay({
827
+ tool: 'smart_context',
828
+ target: task,
829
+ metrics: {
830
+ rawTokens: totalRawTokens,
831
+ compressedTokens: totalCompressedTokens,
832
+ savedTokens,
833
+ },
834
+ startTime: enableProgress ? startTime : null,
835
+ filesCount: filesIncluded,
836
+ });
837
+
838
+ if (progress) {
839
+ progress.complete({
840
+ task: task.substring(0, 80),
841
+ files: filesIncluded,
842
+ savedTokens,
843
+ savingsPct: totalRawTokens > 0 ? ((savedTokens / totalRawTokens) * 100).toFixed(1) : null,
844
+ });
845
+ }
846
+
799
847
  const result = {
800
848
  success: true,
801
849
  task,
@@ -807,7 +855,7 @@ export const smartContext = async ({
807
855
  metrics: {
808
856
  contentTokens,
809
857
  totalTokens: 0,
810
- filesIncluded: new Set(context.map((c) => c.file)).size,
858
+ filesIncluded,
811
859
  filesEvaluated: expanded.size,
812
860
  savingsPct,
813
861
  detailMode,
@@ -825,6 +873,7 @@ export const smartContext = async ({
825
873
  }
826
874
  } : {})
827
875
  },
876
+ metricsDisplay,
828
877
  ...(includeSet.has('hints') ? { hints } : {}),
829
878
  };
830
879
 
@@ -12,6 +12,8 @@ import { countTokens } from '../tokenCounter.js';
12
12
  import { recordToolUsage } from '../usage-feedback.js';
13
13
  import { recordDecision, DECISION_REASONS, EXPECTED_BENEFITS } from '../decision-explainer.js';
14
14
  import { recordDevctxOperation } from '../missed-opportunities.js';
15
+ import { buildMetricsDisplay } from '../utils/metrics-display.js';
16
+ import { createProgressReporter } from '../streaming.js';
15
17
 
16
18
  const execFile = promisify(execFileCb);
17
19
  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';
@@ -408,14 +410,26 @@ const formatContextSections = (sections) => {
408
410
  return parts.length > 0 ? '\n' + parts.join('\n') : '';
409
411
  };
410
412
 
411
- export const smartRead = async ({ filePath, mode = 'outline', startLine, endLine, symbol, maxTokens, context: includeContext, cwd }) => {
413
+ export const smartRead = async ({ filePath, mode = 'outline', startLine, endLine, symbol, maxTokens, context: includeContext, cwd, progress: enableProgress = false }) => {
414
+ const progress = enableProgress ? createProgressReporter('smart_read') : null;
415
+ const startTime = Date.now();
416
+
412
417
  let fullPath, content;
413
418
  const effectiveRoot = cwd || projectRoot;
414
419
 
420
+ if (progress) {
421
+ progress.report({ phase: 'reading', file: filePath });
422
+ }
423
+
415
424
  try {
416
425
  const result = readTextFile(filePath, effectiveRoot);
417
426
  fullPath = result.fullPath;
418
427
  content = result.content;
428
+
429
+ if (progress) {
430
+ const rawTokens = countTokens(content);
431
+ progress.report({ phase: 'loaded', file: path.relative(effectiveRoot, fullPath), rawTokens });
432
+ }
419
433
  } catch (error) {
420
434
  const errorMessage = error.message || String(error);
421
435
  return {
@@ -479,6 +493,18 @@ export const smartRead = async ({ filePath, mode = 'outline', startLine, endLine
479
493
  cacheHit = g.cached;
480
494
  }
481
495
 
496
+ if (progress) {
497
+ const compressedTokens = countTokens(compressedText);
498
+ const rawTokens = countTokens(content);
499
+ progress.report({
500
+ phase: 'compressed',
501
+ mode: effectiveMode,
502
+ rawTokens,
503
+ compressedTokens,
504
+ ratio: rawTokens > 0 ? (rawTokens / compressedTokens).toFixed(1) : null,
505
+ });
506
+ }
507
+
482
508
  let contextResult = null;
483
509
 
484
510
  if (mode === 'symbol' && includeContext && symbol) {
@@ -545,6 +571,22 @@ export const smartRead = async ({ filePath, mode = 'outline', startLine, endLine
545
571
  const confidence = { parser, truncated, cached: cacheHit && !contextResult };
546
572
  if (contextResult) confidence.graphCoverage = contextResult.graphCoverage;
547
573
 
574
+ const metricsDisplay = buildMetricsDisplay({
575
+ tool: 'smart_read',
576
+ target: path.relative(effectiveRoot, fullPath),
577
+ metrics,
578
+ startTime: enableProgress ? startTime : null,
579
+ });
580
+
581
+ if (progress) {
582
+ progress.complete({
583
+ file: path.relative(effectiveRoot, fullPath),
584
+ mode: effectiveMode,
585
+ savedTokens: metrics.savedTokens,
586
+ savingsPct: metrics.savingsPct,
587
+ });
588
+ }
589
+
548
590
  const result = {
549
591
  filePath: fullPath,
550
592
  mode,
@@ -553,6 +595,7 @@ export const smartRead = async ({ filePath, mode = 'outline', startLine, endLine
553
595
  content: compressedText,
554
596
  confidence,
555
597
  metrics,
598
+ metricsDisplay,
556
599
  };
557
600
 
558
601
  if (cacheHit && !contextResult) result.cached = true;
@@ -12,6 +12,8 @@ import { recordToolUsage } from '../usage-feedback.js';
12
12
  import { recordDecision, DECISION_REASONS, EXPECTED_BENEFITS } from '../decision-explainer.js';
13
13
  import { recordDevctxOperation } from '../missed-opportunities.js';
14
14
  import { IGNORED_DIRS, IGNORED_FILE_NAMES } from '../config/ignored-paths.js';
15
+ import { buildMetricsDisplay } from '../utils/metrics-display.js';
16
+ import { createProgressReporter } from '../streaming.js';
15
17
 
16
18
  const execFile = promisify(execFileCallback);
17
19
  const supportedGlobs = [
@@ -293,7 +295,14 @@ const buildCompactResult = (groups, totalMatches, query, root) => {
293
295
  return lines.join('\n');
294
296
  };
295
297
 
296
- export const smartSearch = async ({ query, cwd = '.', intent, _testForceWalk = false }) => {
298
+ export const smartSearch = async ({ query, cwd = '.', intent, _testForceWalk = false, progress: enableProgress = false }) => {
299
+ const progress = enableProgress ? createProgressReporter('smart_search') : null;
300
+ const startTime = Date.now();
301
+
302
+ if (progress) {
303
+ progress.report({ phase: 'searching', query });
304
+ }
305
+
297
306
  const root = resolveSafePath(cwd);
298
307
  const rgMatches = _testForceWalk ? null : await searchWithRipgrep(root, query);
299
308
  const usedFallback = rgMatches === null;
@@ -340,6 +349,11 @@ export const smartSearch = async ({ query, cwd = '.', intent, _testForceWalk = f
340
349
  let graphHits = null;
341
350
  let indexFreshness = 'unavailable';
342
351
  let loadedIndex = null;
352
+
353
+ if (progress) {
354
+ progress.report({ phase: 'ranking', rawMatches: rawMatches.length });
355
+ }
356
+
343
357
  try {
344
358
  loadedIndex = loadIndex(indexRoot);
345
359
  if (loadedIndex) {
@@ -422,6 +436,24 @@ export const smartSearch = async ({ query, cwd = '.', intent, _testForceWalk = f
422
436
 
423
437
  const confidence = { level: retrievalConfidence, indexFreshness };
424
438
 
439
+ const metricsDisplay = buildMetricsDisplay({
440
+ tool: 'smart_search',
441
+ target: query,
442
+ metrics,
443
+ startTime: enableProgress ? startTime : null,
444
+ filesCount: groups.length,
445
+ });
446
+
447
+ if (progress) {
448
+ progress.complete({
449
+ query,
450
+ matches: dedupedMatches.length,
451
+ files: groups.length,
452
+ savedTokens: metrics.savedTokens,
453
+ savingsPct: metrics.savingsPct,
454
+ });
455
+ }
456
+
425
457
  const result = {
426
458
  query,
427
459
  root,
@@ -437,6 +469,7 @@ export const smartSearch = async ({ query, cwd = '.', intent, _testForceWalk = f
437
469
  topFiles: groups.slice(0, 10).map((group) => ({ file: group.file, count: group.count, score: group.score })),
438
470
  matches: compressedText,
439
471
  metrics,
472
+ metricsDisplay,
440
473
  };
441
474
 
442
475
  if (provenance) result.provenance = provenance;
@@ -7,6 +7,7 @@ import { pickRelevantLines, truncate, uniqueLines } from '../utils/text.js';
7
7
  import { recordToolUsage } from '../usage-feedback.js';
8
8
  import { recordDecision, DECISION_REASONS, EXPECTED_BENEFITS } from '../decision-explainer.js';
9
9
  import { recordDevctxOperation } from '../missed-opportunities.js';
10
+ import { buildMetricsDisplay } from '../utils/metrics-display.js';
10
11
 
11
12
  const execFile = promisify(execFileCallback);
12
13
  const isShellDisabled = () => process.env.DEVCTX_SHELL_DISABLED === 'true';
@@ -289,6 +290,13 @@ export const smartShell = async ({ command }) => {
289
290
  context: `${outputLines} lines → ${compressedText.split('\n').length} lines (relevant only)`,
290
291
  });
291
292
 
293
+ const metricsDisplay = buildMetricsDisplay({
294
+ tool: 'smart_shell',
295
+ target: command,
296
+ metrics,
297
+ startTime: null,
298
+ });
299
+
292
300
  const result = {
293
301
  command,
294
302
  exitCode: execution.code,
@@ -296,6 +304,7 @@ export const smartShell = async ({ command }) => {
296
304
  output: compressedText,
297
305
  confidence: { blocked: false, timedOut: !!execution.timedOut },
298
306
  metrics,
307
+ metricsDisplay,
299
308
  };
300
309
 
301
310
  if (execution.timedOut) result.timedOut = true;
@@ -0,0 +1,33 @@
1
+ const detectClientFromEnv = () => {
2
+ if (process.env.CURSOR_AGENT === '1') {
3
+ return 'cursor';
4
+ }
5
+
6
+ if (process.env.CLAUDE_AGENT === '1') {
7
+ return 'claude';
8
+ }
9
+
10
+ if (process.env.GEMINI_AGENT === '1') {
11
+ return 'gemini';
12
+ }
13
+
14
+ if (process.env.CODEX_AGENT === '1') {
15
+ return 'codex';
16
+ }
17
+
18
+ return 'generic';
19
+ };
20
+
21
+ let cachedClient = null;
22
+
23
+ export const detectClient = () => {
24
+ if (cachedClient === null) {
25
+ cachedClient = detectClientFromEnv();
26
+ }
27
+
28
+ return cachedClient;
29
+ };
30
+
31
+ export const resetClientDetection = () => {
32
+ cachedClient = null;
33
+ };
@@ -0,0 +1,63 @@
1
+ const formatNumber = (value) => {
2
+ if (value >= 1000000) {
3
+ return `${(value / 1000000).toFixed(1)}M`;
4
+ }
5
+ if (value >= 1000) {
6
+ return `${(value / 1000).toFixed(1)}K`;
7
+ }
8
+ return String(value);
9
+ };
10
+
11
+ const formatRatio = (raw, compressed) => {
12
+ if (raw === 0 || compressed === 0) {
13
+ return null;
14
+ }
15
+ const ratio = raw / compressed;
16
+ if (ratio < 2) {
17
+ return null;
18
+ }
19
+ return `${ratio.toFixed(1)}:1`;
20
+ };
21
+
22
+ const formatDuration = (startTime) => {
23
+ if (!startTime) {
24
+ return null;
25
+ }
26
+ const elapsed = Date.now() - startTime;
27
+ if (elapsed < 1000) {
28
+ return `${elapsed}ms`;
29
+ }
30
+ return `${(elapsed / 1000).toFixed(1)}s`;
31
+ };
32
+
33
+ export const buildMetricsDisplay = ({ tool, target, metrics, startTime, filesCount }) => {
34
+ const parts = [`✓ ${tool}`];
35
+
36
+ if (target) {
37
+ const shortTarget = target.length > 40 ? `${target.slice(0, 37)}...` : target;
38
+ parts.push(shortTarget);
39
+ }
40
+
41
+ if (filesCount && filesCount > 1) {
42
+ parts.push(`${filesCount} files`);
43
+ }
44
+
45
+ if (metrics?.rawTokens > 0 && metrics?.compressedTokens > 0) {
46
+ const raw = formatNumber(metrics.rawTokens);
47
+ const compressed = formatNumber(metrics.compressedTokens);
48
+ const ratio = formatRatio(metrics.rawTokens, metrics.compressedTokens);
49
+
50
+ if (ratio) {
51
+ parts.push(`${raw}→${compressed} tokens (${ratio})`);
52
+ } else {
53
+ parts.push(`${raw}→${compressed} tokens`);
54
+ }
55
+ }
56
+
57
+ const duration = formatDuration(startTime);
58
+ if (duration) {
59
+ parts.push(duration);
60
+ }
61
+
62
+ return parts.join(', ');
63
+ };