smart-context-mcp 1.4.0 → 1.6.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.
@@ -0,0 +1,85 @@
1
+ import { HARD_BLOCK_REPO_SAFETY_REASONS } from '../repo-safety.js';
2
+
3
+ export const buildMutationSafety = (
4
+ repoSafety,
5
+ {
6
+ subject = 'Project-local context writes',
7
+ } = {},
8
+ ) => {
9
+ if (!repoSafety || repoSafety.available === false) {
10
+ return null;
11
+ }
12
+
13
+ const blockedBy = HARD_BLOCK_REPO_SAFETY_REASONS
14
+ .filter(([, field]) => repoSafety[field])
15
+ .map(([reason]) => reason);
16
+ const blocked = blockedBy.length > 0;
17
+ const stateDbPath = repoSafety.stateDbPath ?? '.devctx/state.sqlite';
18
+
19
+ return {
20
+ blocked,
21
+ blockedBy,
22
+ stateDbPath,
23
+ recommendedActions: repoSafety.recommendedActions ?? [],
24
+ message: blocked
25
+ ? `${subject} are blocked until git hygiene is fixed for ${stateDbPath}.`
26
+ : `${subject} are allowed for ${stateDbPath}.`,
27
+ };
28
+ };
29
+
30
+ export const buildDegradedMode = (
31
+ {
32
+ sideEffectsSuppressed = false,
33
+ repoSafety,
34
+ reason = 'repo_safety_blocked',
35
+ mode = 'read_only_snapshot',
36
+ impact = 'Write-side effects are paused while git hygiene is blocked.',
37
+ } = {},
38
+ ) => {
39
+ if (!sideEffectsSuppressed) {
40
+ return null;
41
+ }
42
+
43
+ const mutationSafety = buildMutationSafety(repoSafety);
44
+
45
+ return {
46
+ active: true,
47
+ reason,
48
+ mode,
49
+ impact,
50
+ blockedBy: mutationSafety?.blockedBy ?? [],
51
+ recommendedActions: mutationSafety?.recommendedActions ?? repoSafety?.recommendedActions ?? [],
52
+ message: mutationSafety?.blocked
53
+ ? `${impact} Reads continue in degraded mode until repo safety is fixed.`
54
+ : `${impact} Reads continue in degraded mode.`,
55
+ };
56
+ };
57
+
58
+ export const attachSafetyMetadata = (
59
+ result,
60
+ {
61
+ repoSafety = null,
62
+ sideEffectsSuppressed = false,
63
+ subject,
64
+ degradedReason,
65
+ degradedMode,
66
+ degradedImpact,
67
+ } = {},
68
+ ) => {
69
+ const mutationSafety = buildMutationSafety(repoSafety, { subject });
70
+ const degraded = buildDegradedMode({
71
+ sideEffectsSuppressed,
72
+ repoSafety,
73
+ reason: degradedReason,
74
+ mode: degradedMode,
75
+ impact: degradedImpact,
76
+ });
77
+
78
+ return {
79
+ ...result,
80
+ ...(mutationSafety ? { mutationSafety } : {}),
81
+ repoSafety,
82
+ sideEffectsSuppressed: Boolean(sideEffectsSuppressed),
83
+ ...(degraded ? { degradedMode: degraded } : {}),
84
+ };
85
+ };
@@ -1,8 +1,47 @@
1
- import { withStateDb } from './storage/sqlite.js';
1
+ import { getRepoMutationSafety } from './repo-safety.js';
2
+ import { withStateDb, withStateDbSnapshot } from './storage/sqlite.js';
3
+ import { getNetSavedTokens } from './metrics.js';
4
+
5
+ const WORKFLOW_TRACKING_ENABLED_RE = /^(1|true|yes|on)$/i;
6
+ const EMPTY_OBJECT = Object.freeze({});
7
+
8
+ const parseJson = (value, fallback = EMPTY_OBJECT) => {
9
+ try {
10
+ return JSON.parse(value || JSON.stringify(fallback));
11
+ } catch {
12
+ return fallback;
13
+ }
14
+ };
15
+
16
+ const toRoundedPct = (covered, total) => (total > 0 ? Number(((covered / total) * 100).toFixed(2)) : 0);
17
+
18
+ const buildWorkflowNetMetricsCoverage = ({ source, hasNetMetrics }) => ({
19
+ available: hasNetMetrics,
20
+ source: hasNetMetrics ? source : 'none',
21
+ });
22
+
23
+ const buildSummaryNetMetricsCoverage = ({ coveredWorkflows, totalWorkflows }) => ({
24
+ coveredWorkflows,
25
+ totalWorkflows,
26
+ uncoveredWorkflows: Math.max(0, totalWorkflows - coveredWorkflows),
27
+ coveragePct: toRoundedPct(coveredWorkflows, totalWorkflows),
28
+ complete: totalWorkflows > 0 && coveredWorkflows === totalWorkflows,
29
+ });
30
+
31
+ const getWorkflowMutationSafety = () => getRepoMutationSafety();
32
+
33
+ const withWorkflowReadDb = (callback) => {
34
+ const safety = getWorkflowMutationSafety();
35
+ const reader = safety.shouldBlock ? withStateDbSnapshot : withStateDb;
36
+ return reader(callback);
37
+ };
38
+
39
+ export const isWorkflowTrackingEnabled = () =>
40
+ WORKFLOW_TRACKING_ENABLED_RE.test(process.env.DEVCTX_WORKFLOW_TRACKING ?? '');
2
41
 
3
42
  const isWorkflowTrackingAvailable = () => {
4
43
  try {
5
- return withStateDb((db) => workflowTableExists(db));
44
+ return withWorkflowReadDb((db) => workflowTableExists(db));
6
45
  } catch {
7
46
  return false;
8
47
  }
@@ -93,6 +132,10 @@ export const getWorkflowBaseline = (workflowType) => {
93
132
  */
94
133
  export const startWorkflow = (workflowType, sessionId, metadata = {}) => {
95
134
  try {
135
+ if (getWorkflowMutationSafety().shouldBlock) {
136
+ return null;
137
+ }
138
+
96
139
  return withStateDb((db) => {
97
140
  if (!workflowTableExists(db)) {
98
141
  return null;
@@ -131,6 +174,10 @@ export const startWorkflow = (workflowType, sessionId, metadata = {}) => {
131
174
  */
132
175
  export const endWorkflow = (workflowId) => {
133
176
  try {
177
+ if (getWorkflowMutationSafety().shouldBlock) {
178
+ return null;
179
+ }
180
+
134
181
  return withStateDb((db) => {
135
182
  if (!workflowTableExists(db)) {
136
183
  return null;
@@ -140,7 +187,7 @@ export const endWorkflow = (workflowId) => {
140
187
  const workflow = db
141
188
  .prepare(
142
189
  `
143
- SELECT workflow_type, session_id, start_time, baseline_tokens
190
+ SELECT workflow_type, session_id, start_time, baseline_tokens, metadata_json
144
191
  FROM workflow_metrics
145
192
  WHERE workflow_id = ?
146
193
  `,
@@ -160,7 +207,7 @@ export const endWorkflow = (workflowId) => {
160
207
  const metrics = db
161
208
  .prepare(
162
209
  `
163
- SELECT tool, raw_tokens, compressed_tokens, saved_tokens
210
+ SELECT tool, raw_tokens, compressed_tokens, saved_tokens, metadata_json
164
211
  FROM metrics_events
165
212
  WHERE session_id = ? AND created_at >= ?
166
213
  ORDER BY created_at ASC
@@ -172,11 +219,33 @@ export const endWorkflow = (workflowId) => {
172
219
  const rawTokens = metrics.reduce((sum, m) => sum + (m.raw_tokens || 0), 0);
173
220
  const compressedTokens = metrics.reduce((sum, m) => sum + (m.compressed_tokens || 0), 0);
174
221
  const savedTokens = metrics.reduce((sum, m) => sum + (m.saved_tokens || 0), 0);
222
+ const overheadTokens = metrics.reduce((sum, metric) => {
223
+ try {
224
+ const metadata = JSON.parse(metric.metadata_json || '{}');
225
+ return sum + Math.max(0, Number(metadata.overheadTokens ?? 0));
226
+ } catch {
227
+ return sum;
228
+ }
229
+ }, 0);
230
+ const netSavedTokens = getNetSavedTokens(savedTokens, overheadTokens);
175
231
  const savingsPct = rawTokens > 0 ? ((savedTokens / rawTokens) * 100).toFixed(2) : 0;
232
+ const netSavingsPct = rawTokens > 0 ? ((netSavedTokens / rawTokens) * 100).toFixed(2) : 0;
176
233
 
177
234
  // Calculate vs baseline
178
235
  const baselineTokens = workflow.baseline_tokens || 0;
179
236
  const vsBaselinePct = baselineTokens > 0 ? (((baselineTokens - compressedTokens) / baselineTokens) * 100).toFixed(2) : 0;
237
+ const vsBaselineNetPct = baselineTokens > 0 ? (((baselineTokens - (compressedTokens + overheadTokens)) / baselineTokens) * 100).toFixed(2) : 0;
238
+ const persistedMetadata = parseJson(workflow.metadata_json);
239
+ const metadata = {
240
+ ...persistedMetadata,
241
+ summary: {
242
+ ...(persistedMetadata.summary ?? {}),
243
+ overheadTokens,
244
+ netSavedTokens,
245
+ netSavingsPct: Number(netSavingsPct),
246
+ vsBaselineNetPct: Number(vsBaselineNetPct),
247
+ },
248
+ };
180
249
 
181
250
  // Get unique tools used
182
251
  const toolsUsed = [...new Set(metrics.map((m) => m.tool))];
@@ -192,11 +261,24 @@ export const endWorkflow = (workflowId) => {
192
261
  compressed_tokens = ?,
193
262
  saved_tokens = ?,
194
263
  savings_pct = ?,
195
- vs_baseline_pct = ?
264
+ vs_baseline_pct = ?,
265
+ metadata_json = ?
196
266
  WHERE workflow_id = ?
197
267
  `);
198
268
 
199
- stmt.run(now, durationMs, JSON.stringify(toolsUsed), metrics.length, rawTokens, compressedTokens, savedTokens, savingsPct, vsBaselinePct, workflowId);
269
+ stmt.run(
270
+ now,
271
+ durationMs,
272
+ JSON.stringify(toolsUsed),
273
+ metrics.length,
274
+ rawTokens,
275
+ compressedTokens,
276
+ savedTokens,
277
+ savingsPct,
278
+ vsBaselinePct,
279
+ JSON.stringify(metadata),
280
+ workflowId,
281
+ );
200
282
 
201
283
  return {
202
284
  workflowId,
@@ -207,9 +289,13 @@ export const endWorkflow = (workflowId) => {
207
289
  rawTokens,
208
290
  compressedTokens,
209
291
  savedTokens,
292
+ overheadTokens,
293
+ netSavedTokens,
210
294
  savingsPct: Number(savingsPct),
295
+ netSavingsPct: Number(netSavingsPct),
211
296
  baselineTokens,
212
297
  vsBaselinePct: Number(vsBaselinePct),
298
+ vsBaselineNetPct: Number(vsBaselineNetPct),
213
299
  };
214
300
  });
215
301
  } catch {
@@ -222,7 +308,7 @@ export const endWorkflow = (workflowId) => {
222
308
  */
223
309
  export const getWorkflowMetrics = (options = {}) => {
224
310
  try {
225
- return withStateDb((db) => {
311
+ return withWorkflowReadDb((db) => {
226
312
  if (!workflowTableExists(db)) {
227
313
  return [];
228
314
  }
@@ -279,8 +365,30 @@ export const getWorkflowMetrics = (options = {}) => {
279
365
 
280
366
  return workflows.map((w) => ({
281
367
  ...w,
282
- toolsUsed: JSON.parse(w.tools_used_json || '[]'),
283
- metadata: JSON.parse(w.metadata_json || '{}'),
368
+ ...(() => {
369
+ const metadata = parseJson(w.metadata_json);
370
+ const summary = metadata.summary ?? EMPTY_OBJECT;
371
+ const overheadTokens = Number(summary.overheadTokens ?? metadata.overheadTokens);
372
+ const hasOverhead = Number.isFinite(overheadTokens);
373
+ const hasPersistedNetMetrics = Number.isFinite(Number(summary.netSavedTokens));
374
+ const derivedNetSavedTokens = hasOverhead
375
+ ? getNetSavedTokens(w.saved_tokens, overheadTokens)
376
+ : undefined;
377
+ const hasNetMetrics = hasPersistedNetMetrics || hasOverhead;
378
+
379
+ return {
380
+ toolsUsed: parseJson(w.tools_used_json, []),
381
+ metadata,
382
+ overheadTokens: hasOverhead ? overheadTokens : undefined,
383
+ netSavedTokens: summary.netSavedTokens ?? derivedNetSavedTokens,
384
+ netSavingsPct: summary.netSavingsPct,
385
+ vsBaselineNetPct: summary.vsBaselineNetPct,
386
+ netMetricsCoverage: buildWorkflowNetMetricsCoverage({
387
+ source: hasPersistedNetMetrics ? 'persisted' : 'derived',
388
+ hasNetMetrics,
389
+ }),
390
+ };
391
+ })(),
284
392
  }));
285
393
  });
286
394
  } catch {
@@ -293,39 +401,95 @@ export const getWorkflowMetrics = (options = {}) => {
293
401
  */
294
402
  export const getWorkflowSummaryByType = () => {
295
403
  try {
296
- return withStateDb((db) => {
404
+ return withWorkflowReadDb((db) => {
297
405
  if (!workflowTableExists(db)) {
298
406
  return [];
299
407
  }
300
- const summary = db
301
- .prepare(
302
- `
303
- SELECT
304
- workflow_type,
305
- COUNT(*) as count,
306
- SUM(raw_tokens) as total_raw_tokens,
307
- SUM(compressed_tokens) as total_compressed_tokens,
308
- SUM(saved_tokens) as total_saved_tokens,
309
- AVG(savings_pct) as avg_savings_pct,
310
- SUM(baseline_tokens) as total_baseline_tokens,
311
- AVG(vs_baseline_pct) as avg_vs_baseline_pct,
312
- AVG(duration_ms) as avg_duration_ms,
313
- AVG(steps_count) as avg_steps_count
314
- FROM workflow_metrics
315
- WHERE end_time IS NOT NULL
316
- GROUP BY workflow_type
317
- ORDER BY count DESC
318
- `,
319
- )
320
- .all();
321
-
322
- return summary.map((s) => ({
323
- ...s,
324
- avgSavingsPct: Number(s.avg_savings_pct?.toFixed(2) || 0),
325
- avgVsBaselinePct: Number(s.avg_vs_baseline_pct?.toFixed(2) || 0),
326
- avgDurationMs: Math.round(s.avg_duration_ms || 0),
327
- avgStepsCount: Math.round(s.avg_steps_count || 0),
328
- }));
408
+ const workflows = db
409
+ .prepare(
410
+ `
411
+ SELECT
412
+ workflow_type,
413
+ raw_tokens,
414
+ compressed_tokens,
415
+ saved_tokens,
416
+ savings_pct,
417
+ baseline_tokens,
418
+ vs_baseline_pct,
419
+ duration_ms,
420
+ steps_count,
421
+ metadata_json
422
+ FROM workflow_metrics
423
+ WHERE end_time IS NOT NULL
424
+ `,
425
+ )
426
+ .all();
427
+
428
+ const grouped = new Map();
429
+
430
+ for (const workflow of workflows) {
431
+ const existing = grouped.get(workflow.workflow_type) ?? {
432
+ workflow_type: workflow.workflow_type,
433
+ count: 0,
434
+ total_raw_tokens: 0,
435
+ total_compressed_tokens: 0,
436
+ total_saved_tokens: 0,
437
+ total_overhead_tokens: 0,
438
+ total_net_saved_tokens: 0,
439
+ net_metrics_count: 0,
440
+ total_baseline_tokens: 0,
441
+ savingsPctSum: 0,
442
+ vsBaselinePctSum: 0,
443
+ durationMsSum: 0,
444
+ stepsCountSum: 0,
445
+ };
446
+
447
+ const metadata = parseJson(workflow.metadata_json);
448
+ const summary = metadata.summary ?? EMPTY_OBJECT;
449
+ const overheadTokens = Number(summary.overheadTokens);
450
+ const netSavedTokens = Number(summary.netSavedTokens);
451
+ const hasNetMetrics = Number.isFinite(overheadTokens) && Number.isFinite(netSavedTokens);
452
+
453
+ existing.count += 1;
454
+ existing.total_raw_tokens += workflow.raw_tokens || 0;
455
+ existing.total_compressed_tokens += workflow.compressed_tokens || 0;
456
+ existing.total_saved_tokens += workflow.saved_tokens || 0;
457
+ existing.total_baseline_tokens += workflow.baseline_tokens || 0;
458
+ existing.savingsPctSum += workflow.savings_pct || 0;
459
+ existing.vsBaselinePctSum += workflow.vs_baseline_pct || 0;
460
+ existing.durationMsSum += workflow.duration_ms || 0;
461
+ existing.stepsCountSum += workflow.steps_count || 0;
462
+
463
+ if (hasNetMetrics) {
464
+ existing.total_overhead_tokens += overheadTokens;
465
+ existing.total_net_saved_tokens += netSavedTokens;
466
+ existing.net_metrics_count += 1;
467
+ }
468
+
469
+ grouped.set(workflow.workflow_type, existing);
470
+ }
471
+
472
+ return [...grouped.values()]
473
+ .sort((left, right) => right.count - left.count)
474
+ .map((item) => ({
475
+ workflow_type: item.workflow_type,
476
+ count: item.count,
477
+ total_raw_tokens: item.total_raw_tokens,
478
+ total_compressed_tokens: item.total_compressed_tokens,
479
+ total_saved_tokens: item.total_saved_tokens,
480
+ total_overhead_tokens: item.total_overhead_tokens,
481
+ total_net_saved_tokens: item.total_net_saved_tokens,
482
+ net_metrics_count: item.net_metrics_count,
483
+ netMetricsCoverage: buildSummaryNetMetricsCoverage({
484
+ coveredWorkflows: item.net_metrics_count,
485
+ totalWorkflows: item.count,
486
+ }),
487
+ total_baseline_tokens: item.total_baseline_tokens,
488
+ avgSavingsPct: Number((item.savingsPctSum / item.count || 0).toFixed(2)),
489
+ avgVsBaselinePct: Number((item.vsBaselinePctSum / item.count || 0).toFixed(2)),
490
+ avgDurationMs: Math.round(item.durationMsSum / item.count || 0),
491
+ avgStepsCount: Math.round(item.stepsCountSum / item.count || 0),
492
+ }));
329
493
  });
330
494
  } catch {
331
495
  return [];
@@ -351,10 +515,68 @@ const workflowTableExists = (db) => {
351
515
  }
352
516
  };
353
517
 
518
+ export const getActiveWorkflowForSession = (sessionId) => {
519
+ try {
520
+ return withWorkflowReadDb((db) => {
521
+ if (!workflowTableExists(db)) {
522
+ return null;
523
+ }
524
+
525
+ const workflow = db
526
+ .prepare(
527
+ `
528
+ SELECT
529
+ workflow_id,
530
+ workflow_type,
531
+ session_id,
532
+ start_time,
533
+ end_time,
534
+ duration_ms,
535
+ tools_used_json,
536
+ steps_count,
537
+ raw_tokens,
538
+ compressed_tokens,
539
+ saved_tokens,
540
+ savings_pct,
541
+ baseline_tokens,
542
+ vs_baseline_pct,
543
+ metadata_json,
544
+ created_at
545
+ FROM workflow_metrics
546
+ WHERE session_id = ? AND end_time IS NULL
547
+ ORDER BY created_at DESC
548
+ LIMIT 1
549
+ `,
550
+ )
551
+ .get(sessionId);
552
+
553
+ if (!workflow) {
554
+ return null;
555
+ }
556
+
557
+ return {
558
+ ...workflow,
559
+ toolsUsed: parseJson(workflow.tools_used_json, []),
560
+ metadata: parseJson(workflow.metadata_json),
561
+ };
562
+ });
563
+ } catch {
564
+ return null;
565
+ }
566
+ };
567
+
354
568
  /**
355
569
  * Auto-detect and track workflow from session
356
570
  */
357
571
  export const autoTrackWorkflow = (sessionId, sessionGoal) => {
572
+ if (!sessionId || !isWorkflowTrackingEnabled()) {
573
+ return null;
574
+ }
575
+
576
+ if (getWorkflowMutationSafety().shouldBlock) {
577
+ return null;
578
+ }
579
+
358
580
  try {
359
581
  return withStateDb((db) => {
360
582
  // Check if table exists (migration v5)