smart-context-mcp 1.7.1 → 1.7.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "smart-context-mcp",
3
- "version": "1.7.1",
3
+ "version": "1.7.3",
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",
@@ -26,6 +26,7 @@
26
26
  "./server": "./src/server.js"
27
27
  },
28
28
  "files": [
29
+ "README.md",
29
30
  "src/",
30
31
  "scripts/claude-hook.js",
31
32
  "scripts/check-repo-safety.js",
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
 
@@ -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
  };
@@ -62,6 +62,7 @@ const recordRunnerMetrics = async ({
62
62
  usedWrapper = false,
63
63
  blocked = false,
64
64
  doctorIssued = false,
65
+ fastPath = false,
65
66
  }) => {
66
67
  const startResult = result?.start ?? result?.startResult ?? null;
67
68
  const endResult = result?.end ?? (result?.phase === 'end' ? result : null);
@@ -83,6 +84,7 @@ const recordRunnerMetrics = async ({
83
84
  usedWrapper,
84
85
  overheadTokens: Number(result?.overheadTokens ?? 0),
85
86
  managedByBaseOrchestrator: WORKFLOW_COMMANDS.has(commandName),
87
+ fastPath,
86
88
  });
87
89
 
88
90
  await persistMetrics({
@@ -148,8 +150,10 @@ const runWorkflowCommand = async ({
148
150
  ensureSession: true,
149
151
  allowIsolation: false,
150
152
  startMaxTokens: DEFAULT_START_MAX_TOKENS,
153
+ enableFastPath: true,
151
154
  }));
152
155
  const start = startResolution.startResult;
156
+ const fastPath = startResolution.fastPath ?? false;
153
157
 
154
158
  const gate = evaluateRunnerGate({ startResult: start });
155
159
  let preflightSummary = null;
@@ -159,6 +163,7 @@ const runWorkflowCommand = async ({
159
163
  workflowProfile,
160
164
  prompt: requestedPrompt,
161
165
  startResult: start,
166
+ skipPreflight: fastPath,
162
167
  });
163
168
  preflightSummary = buildPreflightSummary(preflightResult);
164
169
  }
@@ -194,6 +199,7 @@ const runWorkflowCommand = async ({
194
199
  usedWrapper: false,
195
200
  blocked: true,
196
201
  doctorIssued: true,
202
+ fastPath,
197
203
  });
198
204
  return blockedResult;
199
205
  }
@@ -231,6 +237,7 @@ const runWorkflowCommand = async ({
231
237
  usedWrapper: true,
232
238
  blocked: false,
233
239
  doctorIssued: false,
240
+ fastPath,
234
241
  });
235
242
  return result;
236
243
  };
@@ -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,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
+ };