smart-context-mcp 1.6.0 → 1.6.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "smart-context-mcp",
3
- "version": "1.6.0",
3
+ "version": "1.6.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,13 @@
1
1
  #!/usr/bin/env node
2
2
  import { runTaskRunner } from '../src/task-runner.js';
3
+ import { checkNodeVersion } from '../src/utils/runtime-check.js';
4
+
5
+ const runtimeCheck = checkNodeVersion();
6
+ if (!runtimeCheck.ok) {
7
+ console.error(`[smart-context-task] Runtime check failed: ${runtimeCheck.message}`);
8
+ console.error(`[smart-context-task] Current: ${runtimeCheck.current}, Required: ${runtimeCheck.minimum}+`);
9
+ process.exit(1);
10
+ }
3
11
 
4
12
  const requireValue = (argv, index, flag) => {
5
13
  const value = argv[index + 1];
package/src/server.js CHANGED
@@ -16,6 +16,7 @@ import { smartMetrics } from './tools/smart-metrics.js';
16
16
  import { smartTurn } from './tools/smart-turn.js';
17
17
  import { projectRoot, projectRootSource } from './utils/paths.js';
18
18
  import { setServerForStreaming } from './streaming.js';
19
+ import { checkNodeVersion } from './utils/runtime-check.js';
19
20
  import {
20
21
  getSymbolBlame,
21
22
  getFileAuthorshipStats,
@@ -44,6 +45,13 @@ export const asTextResult = (result) => ({
44
45
  });
45
46
 
46
47
  export const createDevctxServer = () => {
48
+ const runtimeCheck = checkNodeVersion();
49
+ if (!runtimeCheck.ok) {
50
+ console.error(`[devctx] Runtime check failed: ${runtimeCheck.message}`);
51
+ console.error(`[devctx] Current: ${runtimeCheck.current}, Required: ${runtimeCheck.minimum}+`);
52
+ process.exit(1);
53
+ }
54
+
47
55
  const server = new McpServer({
48
56
  name: 'devctx',
49
57
  version,
@@ -2,12 +2,16 @@ import fs from 'node:fs';
2
2
  import { createHash } from 'node:crypto';
3
3
  import os from 'node:os';
4
4
  import path from 'node:path';
5
+ import { setTimeout as delay } from 'node:timers/promises';
5
6
  import { projectRoot } from '../utils/runtime-config.js';
6
7
 
7
8
  export const STATE_DB_FILENAME = 'state.sqlite';
8
9
  export const SQLITE_SCHEMA_VERSION = 5;
9
10
  export const ACTIVE_SESSION_SCOPE = 'project';
10
11
  export const STATE_DB_SOFT_MAX_BYTES = 32 * 1024 * 1024;
12
+ const STATE_DB_BUSY_TIMEOUT_MS = 1000;
13
+ const STATE_DB_LOCK_RETRY_ATTEMPTS = 3;
14
+ const STATE_DB_LOCK_RETRY_DELAY_MS = 75;
11
15
  export const EXPECTED_TABLES = [
12
16
  'active_session',
13
17
  'context_access',
@@ -444,7 +448,32 @@ export const getMeta = (db, key) => {
444
448
  const getSchemaVersion = (db) => Number(getMeta(db, 'schema_version') ?? 0);
445
449
  const VALID_STATUSES = new Set(['planning', 'in_progress', 'blocked', 'completed']);
446
450
 
447
- const applyPragmas = (db) => {
451
+ const isStateDbLockError = (error) =>
452
+ /database is locked|database table is locked|SQLITE_BUSY|SQLITE_LOCKED/i.test(String(error?.message ?? error ?? ''));
453
+
454
+ const withStateDbLockRetry = async (operation) => {
455
+ for (let attempt = 1; attempt <= STATE_DB_LOCK_RETRY_ATTEMPTS; attempt += 1) {
456
+ try {
457
+ return await operation();
458
+ } catch (error) {
459
+ if (!isStateDbLockError(error) || attempt === STATE_DB_LOCK_RETRY_ATTEMPTS) {
460
+ throw error;
461
+ }
462
+
463
+ await delay(STATE_DB_LOCK_RETRY_DELAY_MS * attempt);
464
+ }
465
+ }
466
+
467
+ throw new Error('SQLite lock retry exhausted unexpectedly.');
468
+ };
469
+
470
+ const applyPragmas = (db, { readOnly = false } = {}) => {
471
+ db.exec(`PRAGMA busy_timeout = ${STATE_DB_BUSY_TIMEOUT_MS}`);
472
+
473
+ if (readOnly) {
474
+ return;
475
+ }
476
+
448
477
  db.exec('PRAGMA foreign_keys = ON');
449
478
  db.exec('PRAGMA journal_mode = WAL');
450
479
  db.exec('PRAGMA synchronous = NORMAL');
@@ -505,17 +534,28 @@ export const listStateTables = (db) =>
505
534
 
506
535
  export const openStateDb = async ({ filePath = getStateDbPath(), readOnly = false } = {}) => {
507
536
  try {
508
- const { DatabaseSync } = await loadSqliteModule();
509
- if (!readOnly) {
510
- ensureStateDir(filePath);
511
- }
537
+ return await withStateDbLockRetry(async () => {
538
+ const { DatabaseSync } = await loadSqliteModule();
539
+ if (!readOnly) {
540
+ ensureStateDir(filePath);
541
+ }
512
542
 
513
- const db = new DatabaseSync(filePath, readOnly ? { readOnly: true } : {});
514
- if (!readOnly) {
515
- applyPragmas(db);
516
- runStateMigrations(db);
517
- }
518
- return db;
543
+ let db = null;
544
+
545
+ try {
546
+ db = new DatabaseSync(filePath, readOnly ? { readOnly: true } : {});
547
+ applyPragmas(db, { readOnly });
548
+ if (!readOnly) {
549
+ runStateMigrations(db);
550
+ }
551
+ return db;
552
+ } catch (error) {
553
+ try {
554
+ db?.close();
555
+ } catch {}
556
+ throw error;
557
+ }
558
+ });
519
559
  } catch (error) {
520
560
  throw enrichStateDbError(error, { filePath, readOnly });
521
561
  }
@@ -1063,6 +1103,7 @@ const buildSessionCleanupCandidate = (db, sessionsDir, fileName) => {
1063
1103
  const payload = readJsonFile(filePath);
1064
1104
 
1065
1105
  if (!payload || typeof payload !== 'object') {
1106
+ const sizeBytes = fs.existsSync(filePath) ? fs.statSync(filePath).size : 0;
1066
1107
  return {
1067
1108
  type: 'session',
1068
1109
  path: filePath,
@@ -1070,6 +1111,7 @@ const buildSessionCleanupCandidate = (db, sessionsDir, fileName) => {
1070
1111
  eligible: false,
1071
1112
  reason: 'invalid_json',
1072
1113
  sessionId: null,
1114
+ sizeBytes,
1073
1115
  };
1074
1116
  }
1075
1117
 
@@ -1080,6 +1122,7 @@ const buildSessionCleanupCandidate = (db, sessionsDir, fileName) => {
1080
1122
  WHERE session_id = ?
1081
1123
  `).get(sessionId);
1082
1124
  if (!sessionRow) {
1125
+ const sizeBytes = fs.existsSync(filePath) ? fs.statSync(filePath).size : 0;
1083
1126
  return {
1084
1127
  type: 'session',
1085
1128
  path: filePath,
@@ -1087,6 +1130,7 @@ const buildSessionCleanupCandidate = (db, sessionsDir, fileName) => {
1087
1130
  eligible: false,
1088
1131
  reason: 'missing_in_sqlite',
1089
1132
  sessionId,
1133
+ sizeBytes,
1090
1134
  };
1091
1135
  }
1092
1136
 
@@ -1094,6 +1138,8 @@ const buildSessionCleanupCandidate = (db, sessionsDir, fileName) => {
1094
1138
  const sqliteUpdatedAt = toIsoString(sessionRow.updated_at);
1095
1139
  const eligible = getTimestamp(sqliteUpdatedAt) >= getTimestamp(fileUpdatedAt);
1096
1140
 
1141
+ const sizeBytes = fs.existsSync(filePath) ? fs.statSync(filePath).size : 0;
1142
+
1097
1143
  return {
1098
1144
  type: 'session',
1099
1145
  path: filePath,
@@ -1101,6 +1147,7 @@ const buildSessionCleanupCandidate = (db, sessionsDir, fileName) => {
1101
1147
  eligible,
1102
1148
  reason: eligible ? 'imported_and_not_newer_than_sqlite' : 'legacy_file_newer_than_sqlite',
1103
1149
  sessionId,
1150
+ sizeBytes,
1104
1151
  fileUpdatedAt,
1105
1152
  sqliteUpdatedAt,
1106
1153
  };
@@ -1113,34 +1160,40 @@ const buildActiveCleanupCandidate = (db, activeSessionFile) => {
1113
1160
 
1114
1161
  const payload = readJsonFile(activeSessionFile);
1115
1162
  if (!payload || typeof payload !== 'object') {
1163
+ const sizeBytes = fs.statSync(activeSessionFile).size;
1116
1164
  return {
1117
1165
  type: 'active_session',
1118
1166
  path: activeSessionFile,
1119
1167
  eligible: false,
1120
1168
  reason: 'invalid_json',
1121
1169
  sessionId: null,
1170
+ sizeBytes,
1122
1171
  };
1123
1172
  }
1124
1173
 
1125
1174
  const legacySessionId = typeof payload.sessionId === 'string' ? payload.sessionId : null;
1126
1175
  const activeRow = getActiveSessionRow(db);
1127
1176
  if (!legacySessionId) {
1177
+ const sizeBytes = fs.statSync(activeSessionFile).size;
1128
1178
  return {
1129
1179
  type: 'active_session',
1130
1180
  path: activeSessionFile,
1131
1181
  eligible: true,
1132
1182
  reason: 'orphaned_legacy_file',
1133
1183
  sessionId: null,
1184
+ sizeBytes,
1134
1185
  };
1135
1186
  }
1136
1187
 
1137
1188
  if (!activeRow) {
1189
+ const sizeBytes = fs.statSync(activeSessionFile).size;
1138
1190
  return {
1139
1191
  type: 'active_session',
1140
1192
  path: activeSessionFile,
1141
1193
  eligible: true,
1142
1194
  reason: 'sqlite_has_no_active_session',
1143
1195
  sessionId: legacySessionId,
1196
+ sizeBytes,
1144
1197
  };
1145
1198
  }
1146
1199
 
@@ -1149,12 +1202,15 @@ const buildActiveCleanupCandidate = (db, activeSessionFile) => {
1149
1202
  const eligible = activeRow.session_id === legacySessionId
1150
1203
  && getTimestamp(sqliteUpdatedAt) >= getTimestamp(fileUpdatedAt);
1151
1204
 
1205
+ const sizeBytes = fs.existsSync(activeSessionFile) ? fs.statSync(activeSessionFile).size : 0;
1206
+
1152
1207
  return {
1153
1208
  type: 'active_session',
1154
1209
  path: activeSessionFile,
1155
1210
  eligible,
1156
1211
  reason: eligible ? 'sqlite_active_session_matches' : 'sqlite_active_session_differs',
1157
1212
  sessionId: legacySessionId,
1213
+ sizeBytes,
1158
1214
  fileUpdatedAt,
1159
1215
  sqliteUpdatedAt,
1160
1216
  };
@@ -1167,12 +1223,14 @@ const buildMetricsCleanupCandidate = (db, metricsFile) => {
1167
1223
 
1168
1224
  const { entries, invalidLines } = readMetricsEntries(metricsFile);
1169
1225
  if (invalidLines.length > 0) {
1226
+ const sizeBytes = fs.statSync(metricsFile).size;
1170
1227
  return {
1171
1228
  type: 'metrics',
1172
1229
  path: metricsFile,
1173
1230
  eligible: false,
1174
1231
  reason: 'invalid_jsonl',
1175
1232
  entryCount: entries.length,
1233
+ sizeBytes,
1176
1234
  invalidLines,
1177
1235
  missingEntries: [],
1178
1236
  };
@@ -1191,12 +1249,15 @@ const buildMetricsCleanupCandidate = (db, metricsFile) => {
1191
1249
  }
1192
1250
  }
1193
1251
 
1252
+ const sizeBytes = fs.existsSync(metricsFile) ? fs.statSync(metricsFile).size : 0;
1253
+
1194
1254
  return {
1195
1255
  type: 'metrics',
1196
1256
  path: metricsFile,
1197
1257
  eligible: missingEntries.length === 0,
1198
1258
  reason: missingEntries.length === 0 ? 'all_entries_imported' : 'sqlite_missing_entries',
1199
1259
  entryCount: entries.length,
1260
+ sizeBytes,
1200
1261
  invalidLines,
1201
1262
  missingEntries,
1202
1263
  };
@@ -1,5 +1,7 @@
1
+ import { setTimeout as delay } from 'node:timers/promises';
1
2
  import { persistMetrics } from './metrics.js';
2
3
  import { countTokens } from './tokenCounter.js';
4
+ import { projectRoot } from './utils/paths.js';
3
5
  import { TASK_RUNNER_QUALITY_ANALYTICS_KIND } from './analytics/product-quality.js';
4
6
  import { runHeadlessWrapper } from './orchestration/headless-wrapper.js';
5
7
  import { smartContext } from './tools/smart-context.js';
@@ -22,6 +24,8 @@ import {
22
24
 
23
25
  const START_MAX_TOKENS = 350;
24
26
  const END_MAX_TOKENS = 350;
27
+ const RUNNER_LOCK_RETRY_ATTEMPTS = 3;
28
+ const RUNNER_LOCK_RETRY_DELAY_MS = 100;
25
29
 
26
30
  const normalizeWhitespace = (value) => String(value ?? '').replace(/\s+/g, ' ').trim();
27
31
  const truncate = (value, maxLength = 160) => {
@@ -39,6 +43,13 @@ const truncate = (value, maxLength = 160) => {
39
43
 
40
44
  const asArray = (value) => Array.isArray(value) ? value : [];
41
45
  const uniqueCompact = (values) => [...new Set(asArray(values).map((value) => normalizeWhitespace(value)).filter(Boolean))];
46
+ const extractContextTopFiles = (topFiles) => uniqueCompact(asArray(topFiles).map((item) => {
47
+ if (typeof item === 'string') {
48
+ return item;
49
+ }
50
+
51
+ return item?.file ?? item?.path ?? '';
52
+ })).slice(0, 3);
42
53
 
43
54
  const extractPreflightTopFiles = (preflightResult) => {
44
55
  if (!preflightResult) {
@@ -50,12 +61,7 @@ const extractPreflightTopFiles = (preflightResult) => {
50
61
  }
51
62
 
52
63
  if (preflightResult.tool === 'smart_search') {
53
- return uniqueCompact(asArray(preflightResult.result?.topFiles).map((item) => {
54
- if (typeof item === 'string') {
55
- return item;
56
- }
57
- return item?.file ?? item?.path ?? '';
58
- })).slice(0, 3);
64
+ return extractContextTopFiles(preflightResult.result?.topFiles);
59
65
  }
60
66
 
61
67
  return [];
@@ -100,7 +106,7 @@ const buildPreflightTask = ({ workflowProfile, prompt, startResult }) => {
100
106
  const normalizedPrompt = normalizeWhitespace(prompt);
101
107
  const persistedNextStep = normalizeWhitespace(startResult?.summary?.nextStep);
102
108
  const currentFocus = normalizeWhitespace(startResult?.summary?.currentFocus);
103
- const refreshedTopFiles = uniqueCompact(startResult?.refreshedContext?.topFiles).slice(0, 3);
109
+ const refreshedTopFiles = extractContextTopFiles(startResult?.refreshedContext?.topFiles);
104
110
 
105
111
  if (workflowProfile.commandName === 'continue' || workflowProfile.commandName === 'resume') {
106
112
  if (persistedNextStep) {
@@ -178,7 +184,7 @@ const buildContinuityGuidance = ({ startResult }) => {
178
184
  ];
179
185
  const nextStep = normalizeWhitespace(startResult?.summary?.nextStep);
180
186
  const currentFocus = normalizeWhitespace(startResult?.summary?.currentFocus);
181
- const refreshedTopFiles = uniqueCompact(startResult?.refreshedContext?.topFiles).slice(0, 3);
187
+ const refreshedTopFiles = extractContextTopFiles(startResult?.refreshedContext?.topFiles);
182
188
  const recommendedNextTools = asArray(startResult?.recommendedPath?.nextTools)
183
189
  .map((tool) => normalizeWhitespace(tool))
184
190
  .filter(Boolean)
@@ -253,6 +259,29 @@ const buildWorkflowPolicyPayload = ({ commandName, workflowProfile, preflightSum
253
259
  preflight: preflightSummary,
254
260
  });
255
261
 
262
+ const isRetriableLockError = (error) => {
263
+ const issue = error?.storageHealth?.issue ?? error?.cause?.storageHealth?.issue ?? null;
264
+ const retriable = error?.storageHealth?.retriable ?? error?.cause?.storageHealth?.retriable ?? false;
265
+ const message = String(error?.message ?? error?.cause?.message ?? error ?? '');
266
+ return retriable || issue === 'locked' || /database is locked|database table is locked|SQLITE_BUSY|SQLITE_LOCKED/i.test(message);
267
+ };
268
+
269
+ const withRunnerLockRetry = async (operation) => {
270
+ for (let attempt = 1; attempt <= RUNNER_LOCK_RETRY_ATTEMPTS; attempt += 1) {
271
+ try {
272
+ return await operation();
273
+ } catch (error) {
274
+ if (!isRetriableLockError(error) || attempt === RUNNER_LOCK_RETRY_ATTEMPTS) {
275
+ throw error;
276
+ }
277
+
278
+ await delay(RUNNER_LOCK_RETRY_DELAY_MS * attempt);
279
+ }
280
+ }
281
+
282
+ throw new Error('Task runner lock retry exhausted unexpectedly.');
283
+ };
284
+
256
285
  const recordRunnerMetrics = async ({
257
286
  commandName,
258
287
  client,
@@ -330,13 +359,13 @@ const runWorkflowCommand = async ({
330
359
  });
331
360
  const workflowProfile = buildWorkflowPolicyProfile({ commandName });
332
361
 
333
- const start = await smartTurn({
362
+ const start = await withRunnerLockRetry(() => smartTurn({
334
363
  phase: 'start',
335
364
  sessionId,
336
365
  prompt: requestedPrompt,
337
366
  ensureSession: true,
338
367
  maxTokens: START_MAX_TOKENS,
339
- });
368
+ }));
340
369
 
341
370
  const gate = evaluateRunnerGate({ startResult: start });
342
371
  let preflightSummary = null;
@@ -363,7 +392,7 @@ const runWorkflowCommand = async ({
363
392
  });
364
393
 
365
394
  if (gate.requiresDoctor && !allowDegraded) {
366
- const doctor = await smartDoctor();
395
+ const doctor = await withRunnerLockRetry(() => smartDoctor());
367
396
  const blockedResult = buildRunnerBlockedResult({
368
397
  commandName,
369
398
  client,
@@ -423,7 +452,7 @@ const runWorkflowCommand = async ({
423
452
  };
424
453
 
425
454
  const runDoctorCommand = async ({ verifyIntegrity = true, client }) => {
426
- const result = await smartDoctor({ verifyIntegrity });
455
+ const result = await withRunnerLockRetry(() => smartDoctor({ verifyIntegrity }));
427
456
  await recordRunnerMetrics({
428
457
  commandName: 'doctor',
429
458
  client,
@@ -436,7 +465,7 @@ const runDoctorCommand = async ({ verifyIntegrity = true, client }) => {
436
465
  };
437
466
 
438
467
  const runStatusCommand = async ({ format = 'compact', maxItems = 10, client }) => {
439
- const result = await smartStatus({ format, maxItems });
468
+ const result = await withRunnerLockRetry(() => smartStatus({ format, maxItems }));
440
469
  await recordRunnerMetrics({
441
470
  commandName: 'status',
442
471
  client,
@@ -451,13 +480,13 @@ const runCheckpointCommand = async ({
451
480
  event = 'milestone',
452
481
  update = {},
453
482
  }) => {
454
- const result = await smartTurn({
483
+ const result = await withRunnerLockRetry(() => smartTurn({
455
484
  phase: 'end',
456
485
  sessionId,
457
486
  event,
458
487
  update,
459
488
  maxTokens: END_MAX_TOKENS,
460
- });
489
+ }));
461
490
  await recordRunnerMetrics({
462
491
  commandName: 'checkpoint',
463
492
  client,
@@ -485,10 +514,57 @@ const runCleanupCommand = async ({
485
514
  });
486
515
 
487
516
  if (cleanupMode === 'legacy') {
488
- const result = await smartSummary({
517
+ const dryRun = await smartSummary({
489
518
  action: 'cleanup_legacy',
490
- apply,
519
+ apply: false,
491
520
  });
521
+
522
+ if (!apply) {
523
+ const eligibleFiles = [];
524
+
525
+ if (dryRun.sessions?.candidates) {
526
+ for (const session of dryRun.sessions.candidates) {
527
+ if (session.deletable) {
528
+ eligibleFiles.push({
529
+ relativePath: session.relativePath || session.path,
530
+ sizeBytes: session.sizeBytes || 0,
531
+ });
532
+ }
533
+ }
534
+ }
535
+
536
+ if (dryRun.metrics?.eligible && dryRun.metrics?.path) {
537
+ eligibleFiles.push({
538
+ relativePath: dryRun.metrics.path.replace(projectRoot + '/', ''),
539
+ sizeBytes: dryRun.metrics.sizeBytes || 0,
540
+ });
541
+ }
542
+
543
+ if (dryRun.activeSession?.eligible && dryRun.activeSession?.path) {
544
+ eligibleFiles.push({
545
+ relativePath: dryRun.activeSession.path.replace(projectRoot + '/', ''),
546
+ sizeBytes: dryRun.activeSession.sizeBytes || 0,
547
+ });
548
+ }
549
+
550
+ if (eligibleFiles.length > 0) {
551
+ console.log('\n📋 Legacy files eligible for cleanup:\n');
552
+ console.log('File Size');
553
+ console.log('─'.repeat(60));
554
+ for (const file of eligibleFiles) {
555
+ const sizeKB = file.sizeBytes ? `${(file.sizeBytes / 1024).toFixed(1)}KB` : 'N/A';
556
+ console.log(`${file.relativePath.padEnd(42)} ${sizeKB}`);
557
+ }
558
+ const totalKB = eligibleFiles.reduce((sum, f) => sum + (f.sizeBytes || 0), 0) / 1024;
559
+ console.log('─'.repeat(60));
560
+ console.log(`Total: ${eligibleFiles.length} files, ${totalKB.toFixed(1)}KB\n`);
561
+ console.log('💡 To apply cleanup, run: smart-context-task cleanup --cleanup-mode legacy --apply\n');
562
+ } else {
563
+ console.log('\n✅ No legacy files eligible for cleanup.\n');
564
+ }
565
+ }
566
+
567
+ const result = apply ? await smartSummary({ action: 'cleanup_legacy', apply: true }) : dryRun;
492
568
  const payload = {
493
569
  command: 'cleanup',
494
570
  cleanupMode,
@@ -68,6 +68,76 @@ const readMaintenanceSnapshot = async ({ filePath, storageHealth }) => {
68
68
  try {
69
69
  return await withStateDbSnapshot((db) => {
70
70
  const count = (tableName) => db.prepare(`SELECT COUNT(*) AS count FROM ${tableName}`).get().count;
71
+ const retentionDays = Number(getMeta(db, 'state_compaction_retention_days') ?? DEFAULT_RETENTION_DAYS);
72
+ const activeSessionId = db.prepare('SELECT session_id FROM active_session WHERE scope = ?').get(ACTIVE_SESSION_SCOPE)?.session_id ?? null;
73
+ const cutoff = new Date(Date.now() - retentionDays * 24 * 60 * 60 * 1000).toISOString();
74
+ const hookTurnCutoff = new Date(Date.now() - 48 * 60 * 60 * 1000).toISOString();
75
+ const staleSessionsQuery = activeSessionId
76
+ ? db.prepare(`
77
+ SELECT COUNT(*) AS count
78
+ FROM sessions
79
+ WHERE datetime(updated_at) < datetime(?)
80
+ AND session_id != ?
81
+ `)
82
+ : db.prepare(`
83
+ SELECT COUNT(*) AS count
84
+ FROM sessions
85
+ WHERE datetime(updated_at) < datetime(?)
86
+ `);
87
+ const staleSessions = activeSessionId
88
+ ? staleSessionsQuery.get(cutoff, activeSessionId).count
89
+ : staleSessionsQuery.get(cutoff).count;
90
+ const staleSessionEventsQuery = db.prepare(`
91
+ SELECT COUNT(*) AS count
92
+ FROM (
93
+ SELECT
94
+ se.event_id,
95
+ se.created_at,
96
+ ROW_NUMBER() OVER (
97
+ PARTITION BY se.session_id
98
+ ORDER BY datetime(se.created_at) DESC, se.event_id DESC
99
+ ) AS row_num
100
+ FROM session_events se
101
+ JOIN sessions s ON s.session_id = se.session_id
102
+ WHERE datetime(s.updated_at) >= datetime(@cutoff)
103
+ OR s.session_id = @activeSessionId
104
+ )
105
+ WHERE row_num > @keepLatest
106
+ AND datetime(created_at) < datetime(@cutoff)
107
+ `);
108
+ const staleMetricsQuery = db.prepare(`
109
+ SELECT COUNT(*) AS count
110
+ FROM (
111
+ SELECT
112
+ metric_id,
113
+ created_at,
114
+ ROW_NUMBER() OVER (
115
+ ORDER BY datetime(created_at) DESC, metric_id DESC
116
+ ) AS row_num
117
+ FROM metrics_events
118
+ )
119
+ WHERE row_num > @keepLatest
120
+ AND datetime(created_at) < datetime(@cutoff)
121
+ `);
122
+ const staleHookTurns = db.prepare(`
123
+ SELECT COUNT(*) AS count
124
+ FROM hook_turn_state
125
+ WHERE datetime(updated_at) < datetime(?)
126
+ `).get(hookTurnCutoff).count;
127
+ const compactionEstimate = {
128
+ sessionsDeleted: staleSessions,
129
+ sessionEventsDeleted: staleSessionEventsQuery.get({
130
+ cutoff,
131
+ activeSessionId,
132
+ keepLatest: DEFAULT_KEEP_LATEST_EVENTS_PER_SESSION,
133
+ }).count,
134
+ metricsEventsDeleted: staleMetricsQuery.get({
135
+ cutoff,
136
+ keepLatest: DEFAULT_KEEP_LATEST_METRICS,
137
+ }).count,
138
+ hookTurnStateDeleted: staleHookTurns,
139
+ };
140
+ compactionEstimate.totalDeletes = Object.values(compactionEstimate).reduce((total, value) => total + value, 0);
71
141
 
72
142
  return {
73
143
  available: true,
@@ -78,14 +148,15 @@ const readMaintenanceSnapshot = async ({ filePath, storageHealth }) => {
78
148
  hookTurnState: count('hook_turn_state'),
79
149
  workflowMetrics: count('workflow_metrics'),
80
150
  },
81
- activeSessionId: db.prepare('SELECT session_id FROM active_session WHERE scope = ?').get(ACTIVE_SESSION_SCOPE)?.session_id ?? null,
151
+ activeSessionId,
82
152
  schemaVersion: Number(getMeta(db, 'schema_version') ?? 0),
83
153
  lastCompactedAt: getMeta(db, 'state_compacted_at'),
84
- retentionDays: Number(getMeta(db, 'state_compaction_retention_days') ?? DEFAULT_RETENTION_DAYS),
154
+ retentionDays,
85
155
  legacyImport: {
86
156
  sessions: Number(getMeta(db, 'legacy_sessions_import_count') ?? 0),
87
157
  metrics: Number(getMeta(db, 'legacy_metrics_import_count') ?? 0),
88
158
  },
159
+ compactionEstimate,
89
160
  };
90
161
  }, { filePath });
91
162
  } catch (error) {
@@ -227,21 +298,30 @@ const buildCompactionCheck = ({ storageHealth, maintenance }) => {
227
298
  retentionDays,
228
299
  schemaVersion,
229
300
  activeSessionId,
301
+ compactionEstimate = {
302
+ sessionsDeleted: 0,
303
+ sessionEventsDeleted: 0,
304
+ metricsEventsDeleted: 0,
305
+ hookTurnStateDeleted: 0,
306
+ totalDeletes: 0,
307
+ },
230
308
  } = maintenance;
231
309
  const daysSinceCompaction = daysSince(lastCompactedAt);
232
310
  const sessionEventThreshold = Math.max(SESSION_EVENTS_WARNING_FLOOR, counts.sessions * DEFAULT_KEEP_LATEST_EVENTS_PER_SESSION * 5);
233
311
  const needsCompaction = [];
312
+ const canReclaimRows = compactionEstimate.totalDeletes > 0;
234
313
 
235
314
  if (storageHealth.issue === 'oversized') {
236
315
  needsCompaction.push('database_size');
237
316
  }
238
317
 
239
- if (!lastCompactedAt && (counts.sessionEvents > sessionEventThreshold || counts.metricsEvents > METRICS_WARNING_FLOOR)) {
318
+ if (!lastCompactedAt && canReclaimRows && (counts.sessionEvents > sessionEventThreshold || counts.metricsEvents > METRICS_WARNING_FLOOR)) {
240
319
  needsCompaction.push('never_compacted');
241
320
  }
242
321
 
243
322
  if (
244
323
  daysSinceCompaction !== null
324
+ && canReclaimRows
245
325
  && daysSinceCompaction > retentionDays
246
326
  && (counts.sessionEvents > sessionEventThreshold || counts.metricsEvents > DEFAULT_KEEP_LATEST_METRICS)
247
327
  ) {
@@ -249,12 +329,19 @@ const buildCompactionCheck = ({ storageHealth, maintenance }) => {
249
329
  }
250
330
 
251
331
  if (needsCompaction.length > 0) {
332
+ const totalRows = compactionEstimate.totalDeletes;
333
+ const estimatedBytes = Math.round(storageHealth.sizeBytes * (totalRows / (counts.sessions + counts.sessionEvents + counts.metricsEvents + counts.hookTurnState)));
334
+ const pctReduction = Math.round((estimatedBytes / storageHealth.sizeBytes) * 100);
335
+ const impactSummary = totalRows > 0
336
+ ? `Estimated impact: ~${totalRows} rows, ~${(estimatedBytes / 1024 / 1024).toFixed(1)}MB (${pctReduction}% reduction)`
337
+ : 'Estimated impact: minimal (no rows to delete with current retention policy)';
338
+
252
339
  return buildCheck({
253
340
  id: 'compaction',
254
341
  status: 'warning',
255
342
  message: 'SQLite retention/compaction hygiene should be refreshed before the local state grows further.',
256
343
  recommendedActions: [
257
- 'Run smart_summary with action="compact" to prune old events and metrics.',
344
+ `Run smart_summary with action="compact" to prune old events and metrics. ${impactSummary}`,
258
345
  'Use vacuum=true if you expect large deletions and want to reclaim file size immediately.',
259
346
  ],
260
347
  details: {
@@ -264,6 +351,33 @@ const buildCompactionCheck = ({ storageHealth, maintenance }) => {
264
351
  schemaVersion,
265
352
  activeSessionId,
266
353
  counts,
354
+ compactionEstimate,
355
+ estimatedImpact: {
356
+ rowsToDelete: totalRows,
357
+ bytesReclaimed: estimatedBytes,
358
+ pctReduction,
359
+ },
360
+ lastCompactedAt,
361
+ daysSinceCompaction,
362
+ retentionDays,
363
+ },
364
+ });
365
+ }
366
+
367
+ if (!lastCompactedAt && !canReclaimRows && (counts.sessionEvents > sessionEventThreshold || counts.metricsEvents > METRICS_WARNING_FLOOR)) {
368
+ return buildCheck({
369
+ id: 'compaction',
370
+ status: 'info',
371
+ message: 'SQLite state has never been compacted, but the current retention policy would not reclaim any rows yet.',
372
+ recommendedActions: [],
373
+ details: {
374
+ available: true,
375
+ recommended: false,
376
+ reason: 'baseline_not_initialized',
377
+ schemaVersion,
378
+ activeSessionId,
379
+ counts,
380
+ compactionEstimate,
267
381
  lastCompactedAt,
268
382
  daysSinceCompaction,
269
383
  retentionDays,
@@ -282,6 +396,7 @@ const buildCompactionCheck = ({ storageHealth, maintenance }) => {
282
396
  schemaVersion,
283
397
  activeSessionId,
284
398
  counts,
399
+ compactionEstimate,
285
400
  lastCompactedAt,
286
401
  daysSinceCompaction,
287
402
  retentionDays,
@@ -10,7 +10,6 @@ import { recordDevctxOperation } from '../missed-opportunities.js';
10
10
 
11
11
  const execFile = promisify(execFileCallback);
12
12
  const isShellDisabled = () => process.env.DEVCTX_SHELL_DISABLED === 'true';
13
- const blockedPattern = /[|&;<>`\n\r$()]/;
14
13
  const allowedCommands = new Set(['pwd', 'ls', 'find', 'rg', 'git', 'npm', 'pnpm', 'yarn', 'bun']);
15
14
  const allowedGitSubcommands = new Set(['status', 'diff', 'show', 'log', 'branch', 'rev-parse', 'blame']);
16
15
  const allowedPackageManagerSubcommands = new Set(['test', 'run', 'lint', 'build', 'typecheck', 'check']);
@@ -20,8 +19,8 @@ const dangerousPatterns = [
20
19
  /sudo/i,
21
20
  /curl.*\|/i,
22
21
  /wget.*\|/i,
23
- /eval/i,
24
- /exec/i,
22
+ /(^|\s)eval(\s|$)/i,
23
+ /(^|\s)exec(\s|$)/i,
25
24
  ];
26
25
  const MAX_COMMAND_LENGTH = 500;
27
26
 
@@ -79,6 +78,45 @@ const tokenize = (command) => {
79
78
  return tokens;
80
79
  };
81
80
 
81
+ const hasUnquotedShellOperators = (command) => {
82
+ let inQuote = null;
83
+ let prevWasEscape = false;
84
+
85
+ for (const char of command) {
86
+ if (prevWasEscape) {
87
+ prevWasEscape = false;
88
+ continue;
89
+ }
90
+
91
+ if (char === '\\') {
92
+ prevWasEscape = true;
93
+ continue;
94
+ }
95
+
96
+ if (inQuote) {
97
+ if (char === inQuote) {
98
+ inQuote = null;
99
+ }
100
+ continue;
101
+ }
102
+
103
+ if (char === '"' || char === "'") {
104
+ inQuote = char;
105
+ continue;
106
+ }
107
+
108
+ if (/[|&;<>`\n\r$]/.test(char)) {
109
+ return true;
110
+ }
111
+
112
+ if (char === '(' || char === ')') {
113
+ return true;
114
+ }
115
+ }
116
+
117
+ return false;
118
+ };
119
+
82
120
  const validateCommand = (command, tokens) => {
83
121
  if (isShellDisabled()) {
84
122
  return 'Shell execution is disabled (DEVCTX_SHELL_DISABLED=true)';
@@ -92,8 +130,8 @@ const validateCommand = (command, tokens) => {
92
130
  return `Command too long (max ${MAX_COMMAND_LENGTH} chars)`;
93
131
  }
94
132
 
95
- if (blockedPattern.test(command)) {
96
- return 'Shell operators are not allowed (|, &, ;, <, >, `, $, (, ))';
133
+ if (hasUnquotedShellOperators(command)) {
134
+ return 'Shell operators are not allowed outside quotes (|, &, ;, <, >, `, $, (, ))';
97
135
  }
98
136
 
99
137
  for (const pattern of dangerousPatterns) {
@@ -0,0 +1,52 @@
1
+ const MINIMUM_NODE_VERSION = 22;
2
+ const MINIMUM_NODE_VERSION_REASON = 'node:sqlite and node:test require Node 22+';
3
+
4
+ const parseNodeVersion = (versionString) => {
5
+ const match = /^v?(\d+)\.(\d+)\.(\d+)/.exec(versionString);
6
+ if (!match) {
7
+ return null;
8
+ }
9
+
10
+ return {
11
+ major: Number(match[1]),
12
+ minor: Number(match[2]),
13
+ patch: Number(match[3]),
14
+ raw: versionString,
15
+ };
16
+ };
17
+
18
+ export const checkNodeVersion = (versionString = process.version) => {
19
+ const current = parseNodeVersion(versionString);
20
+
21
+ if (!current) {
22
+ return {
23
+ ok: false,
24
+ current: versionString,
25
+ minimum: MINIMUM_NODE_VERSION,
26
+ message: `Unable to parse Node version: ${versionString}`,
27
+ reason: MINIMUM_NODE_VERSION_REASON,
28
+ };
29
+ }
30
+
31
+ const ok = current.major >= MINIMUM_NODE_VERSION;
32
+
33
+ return {
34
+ ok,
35
+ current: current.raw,
36
+ minimum: MINIMUM_NODE_VERSION,
37
+ message: ok
38
+ ? `Node ${current.raw} meets minimum requirement (${MINIMUM_NODE_VERSION}+)`
39
+ : `Node ${current.raw} is below minimum requirement (${MINIMUM_NODE_VERSION}+). ${MINIMUM_NODE_VERSION_REASON}`,
40
+ reason: ok ? null : MINIMUM_NODE_VERSION_REASON,
41
+ };
42
+ };
43
+
44
+ export const assertNodeVersion = (versionString = process.version) => {
45
+ const check = checkNodeVersion(versionString);
46
+
47
+ if (!check.ok) {
48
+ throw new Error(check.message);
49
+ }
50
+
51
+ return check;
52
+ };
@@ -27,14 +27,14 @@ const readEnvValue = (...names) => {
27
27
  };
28
28
 
29
29
  const defaultDevctxRoot = path.resolve(currentDir, '..', '..');
30
- const defaultProjectRoot = path.resolve(defaultDevctxRoot, '..', '..');
30
+ const defaultProjectRoot = path.resolve(process.cwd());
31
31
  const projectRootArg = readArgValue('--project-root');
32
32
  const projectRootEnv = readEnvValue('DEVCTX_PROJECT_ROOT', 'MCP_PROJECT_ROOT');
33
33
  const rawProjectRoot = projectRootArg ?? projectRootEnv ?? defaultProjectRoot;
34
34
 
35
35
  export const devctxRoot = defaultDevctxRoot;
36
36
  export let projectRoot = path.resolve(rawProjectRoot);
37
- export const projectRootSource = projectRootArg ? 'argv' : projectRootEnv ? 'env' : 'default';
37
+ export const projectRootSource = projectRootArg ? 'argv' : projectRootEnv ? 'env' : 'cwd';
38
38
 
39
39
  export const setProjectRoot = (newRoot) => {
40
40
  projectRoot = path.resolve(newRoot);