smart-context-mcp 1.6.1 → 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 +1 -1
- package/scripts/task-runner.js +8 -0
- package/src/server.js +8 -0
- package/src/storage/sqlite.js +21 -0
- package/src/task-runner.js +50 -2
- package/src/tools/smart-doctor.js +119 -4
- package/src/tools/smart-shell.js +43 -5
- package/src/utils/runtime-check.js +52 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "smart-context-mcp",
|
|
3
|
-
"version": "1.6.
|
|
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",
|
package/scripts/task-runner.js
CHANGED
|
@@ -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,
|
package/src/storage/sqlite.js
CHANGED
|
@@ -1103,6 +1103,7 @@ const buildSessionCleanupCandidate = (db, sessionsDir, fileName) => {
|
|
|
1103
1103
|
const payload = readJsonFile(filePath);
|
|
1104
1104
|
|
|
1105
1105
|
if (!payload || typeof payload !== 'object') {
|
|
1106
|
+
const sizeBytes = fs.existsSync(filePath) ? fs.statSync(filePath).size : 0;
|
|
1106
1107
|
return {
|
|
1107
1108
|
type: 'session',
|
|
1108
1109
|
path: filePath,
|
|
@@ -1110,6 +1111,7 @@ const buildSessionCleanupCandidate = (db, sessionsDir, fileName) => {
|
|
|
1110
1111
|
eligible: false,
|
|
1111
1112
|
reason: 'invalid_json',
|
|
1112
1113
|
sessionId: null,
|
|
1114
|
+
sizeBytes,
|
|
1113
1115
|
};
|
|
1114
1116
|
}
|
|
1115
1117
|
|
|
@@ -1120,6 +1122,7 @@ const buildSessionCleanupCandidate = (db, sessionsDir, fileName) => {
|
|
|
1120
1122
|
WHERE session_id = ?
|
|
1121
1123
|
`).get(sessionId);
|
|
1122
1124
|
if (!sessionRow) {
|
|
1125
|
+
const sizeBytes = fs.existsSync(filePath) ? fs.statSync(filePath).size : 0;
|
|
1123
1126
|
return {
|
|
1124
1127
|
type: 'session',
|
|
1125
1128
|
path: filePath,
|
|
@@ -1127,6 +1130,7 @@ const buildSessionCleanupCandidate = (db, sessionsDir, fileName) => {
|
|
|
1127
1130
|
eligible: false,
|
|
1128
1131
|
reason: 'missing_in_sqlite',
|
|
1129
1132
|
sessionId,
|
|
1133
|
+
sizeBytes,
|
|
1130
1134
|
};
|
|
1131
1135
|
}
|
|
1132
1136
|
|
|
@@ -1134,6 +1138,8 @@ const buildSessionCleanupCandidate = (db, sessionsDir, fileName) => {
|
|
|
1134
1138
|
const sqliteUpdatedAt = toIsoString(sessionRow.updated_at);
|
|
1135
1139
|
const eligible = getTimestamp(sqliteUpdatedAt) >= getTimestamp(fileUpdatedAt);
|
|
1136
1140
|
|
|
1141
|
+
const sizeBytes = fs.existsSync(filePath) ? fs.statSync(filePath).size : 0;
|
|
1142
|
+
|
|
1137
1143
|
return {
|
|
1138
1144
|
type: 'session',
|
|
1139
1145
|
path: filePath,
|
|
@@ -1141,6 +1147,7 @@ const buildSessionCleanupCandidate = (db, sessionsDir, fileName) => {
|
|
|
1141
1147
|
eligible,
|
|
1142
1148
|
reason: eligible ? 'imported_and_not_newer_than_sqlite' : 'legacy_file_newer_than_sqlite',
|
|
1143
1149
|
sessionId,
|
|
1150
|
+
sizeBytes,
|
|
1144
1151
|
fileUpdatedAt,
|
|
1145
1152
|
sqliteUpdatedAt,
|
|
1146
1153
|
};
|
|
@@ -1153,34 +1160,40 @@ const buildActiveCleanupCandidate = (db, activeSessionFile) => {
|
|
|
1153
1160
|
|
|
1154
1161
|
const payload = readJsonFile(activeSessionFile);
|
|
1155
1162
|
if (!payload || typeof payload !== 'object') {
|
|
1163
|
+
const sizeBytes = fs.statSync(activeSessionFile).size;
|
|
1156
1164
|
return {
|
|
1157
1165
|
type: 'active_session',
|
|
1158
1166
|
path: activeSessionFile,
|
|
1159
1167
|
eligible: false,
|
|
1160
1168
|
reason: 'invalid_json',
|
|
1161
1169
|
sessionId: null,
|
|
1170
|
+
sizeBytes,
|
|
1162
1171
|
};
|
|
1163
1172
|
}
|
|
1164
1173
|
|
|
1165
1174
|
const legacySessionId = typeof payload.sessionId === 'string' ? payload.sessionId : null;
|
|
1166
1175
|
const activeRow = getActiveSessionRow(db);
|
|
1167
1176
|
if (!legacySessionId) {
|
|
1177
|
+
const sizeBytes = fs.statSync(activeSessionFile).size;
|
|
1168
1178
|
return {
|
|
1169
1179
|
type: 'active_session',
|
|
1170
1180
|
path: activeSessionFile,
|
|
1171
1181
|
eligible: true,
|
|
1172
1182
|
reason: 'orphaned_legacy_file',
|
|
1173
1183
|
sessionId: null,
|
|
1184
|
+
sizeBytes,
|
|
1174
1185
|
};
|
|
1175
1186
|
}
|
|
1176
1187
|
|
|
1177
1188
|
if (!activeRow) {
|
|
1189
|
+
const sizeBytes = fs.statSync(activeSessionFile).size;
|
|
1178
1190
|
return {
|
|
1179
1191
|
type: 'active_session',
|
|
1180
1192
|
path: activeSessionFile,
|
|
1181
1193
|
eligible: true,
|
|
1182
1194
|
reason: 'sqlite_has_no_active_session',
|
|
1183
1195
|
sessionId: legacySessionId,
|
|
1196
|
+
sizeBytes,
|
|
1184
1197
|
};
|
|
1185
1198
|
}
|
|
1186
1199
|
|
|
@@ -1189,12 +1202,15 @@ const buildActiveCleanupCandidate = (db, activeSessionFile) => {
|
|
|
1189
1202
|
const eligible = activeRow.session_id === legacySessionId
|
|
1190
1203
|
&& getTimestamp(sqliteUpdatedAt) >= getTimestamp(fileUpdatedAt);
|
|
1191
1204
|
|
|
1205
|
+
const sizeBytes = fs.existsSync(activeSessionFile) ? fs.statSync(activeSessionFile).size : 0;
|
|
1206
|
+
|
|
1192
1207
|
return {
|
|
1193
1208
|
type: 'active_session',
|
|
1194
1209
|
path: activeSessionFile,
|
|
1195
1210
|
eligible,
|
|
1196
1211
|
reason: eligible ? 'sqlite_active_session_matches' : 'sqlite_active_session_differs',
|
|
1197
1212
|
sessionId: legacySessionId,
|
|
1213
|
+
sizeBytes,
|
|
1198
1214
|
fileUpdatedAt,
|
|
1199
1215
|
sqliteUpdatedAt,
|
|
1200
1216
|
};
|
|
@@ -1207,12 +1223,14 @@ const buildMetricsCleanupCandidate = (db, metricsFile) => {
|
|
|
1207
1223
|
|
|
1208
1224
|
const { entries, invalidLines } = readMetricsEntries(metricsFile);
|
|
1209
1225
|
if (invalidLines.length > 0) {
|
|
1226
|
+
const sizeBytes = fs.statSync(metricsFile).size;
|
|
1210
1227
|
return {
|
|
1211
1228
|
type: 'metrics',
|
|
1212
1229
|
path: metricsFile,
|
|
1213
1230
|
eligible: false,
|
|
1214
1231
|
reason: 'invalid_jsonl',
|
|
1215
1232
|
entryCount: entries.length,
|
|
1233
|
+
sizeBytes,
|
|
1216
1234
|
invalidLines,
|
|
1217
1235
|
missingEntries: [],
|
|
1218
1236
|
};
|
|
@@ -1231,12 +1249,15 @@ const buildMetricsCleanupCandidate = (db, metricsFile) => {
|
|
|
1231
1249
|
}
|
|
1232
1250
|
}
|
|
1233
1251
|
|
|
1252
|
+
const sizeBytes = fs.existsSync(metricsFile) ? fs.statSync(metricsFile).size : 0;
|
|
1253
|
+
|
|
1234
1254
|
return {
|
|
1235
1255
|
type: 'metrics',
|
|
1236
1256
|
path: metricsFile,
|
|
1237
1257
|
eligible: missingEntries.length === 0,
|
|
1238
1258
|
reason: missingEntries.length === 0 ? 'all_entries_imported' : 'sqlite_missing_entries',
|
|
1239
1259
|
entryCount: entries.length,
|
|
1260
|
+
sizeBytes,
|
|
1240
1261
|
invalidLines,
|
|
1241
1262
|
missingEntries,
|
|
1242
1263
|
};
|
package/src/task-runner.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { setTimeout as delay } from 'node:timers/promises';
|
|
2
2
|
import { persistMetrics } from './metrics.js';
|
|
3
3
|
import { countTokens } from './tokenCounter.js';
|
|
4
|
+
import { projectRoot } from './utils/paths.js';
|
|
4
5
|
import { TASK_RUNNER_QUALITY_ANALYTICS_KIND } from './analytics/product-quality.js';
|
|
5
6
|
import { runHeadlessWrapper } from './orchestration/headless-wrapper.js';
|
|
6
7
|
import { smartContext } from './tools/smart-context.js';
|
|
@@ -513,10 +514,57 @@ const runCleanupCommand = async ({
|
|
|
513
514
|
});
|
|
514
515
|
|
|
515
516
|
if (cleanupMode === 'legacy') {
|
|
516
|
-
const
|
|
517
|
+
const dryRun = await smartSummary({
|
|
517
518
|
action: 'cleanup_legacy',
|
|
518
|
-
apply,
|
|
519
|
+
apply: false,
|
|
519
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;
|
|
520
568
|
const payload = {
|
|
521
569
|
command: 'cleanup',
|
|
522
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
|
|
151
|
+
activeSessionId,
|
|
82
152
|
schemaVersion: Number(getMeta(db, 'schema_version') ?? 0),
|
|
83
153
|
lastCompactedAt: getMeta(db, 'state_compacted_at'),
|
|
84
|
-
retentionDays
|
|
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
|
-
|
|
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,
|
package/src/tools/smart-shell.js
CHANGED
|
@@ -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 (
|
|
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
|
+
};
|