smart-context-mcp 1.1.0 → 1.2.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,197 @@
1
+ /**
2
+ * Adoption analytics - measures how often agents use devctx tools in practice
3
+ *
4
+ * Limitations:
5
+ * - Complexity is inferred from operation count, not actual task complexity
6
+ * - Can't detect feedback shown (requires agent cooperation)
7
+ * - Can't detect forcing prompts (requires prompt analysis)
8
+ * - Can only measure when devctx IS used, not when it's ignored
9
+ */
10
+
11
+ const DEVCTX_TOOLS = new Set([
12
+ 'smart_read',
13
+ 'smart_search',
14
+ 'smart_context',
15
+ 'smart_shell',
16
+ 'smart_summary',
17
+ 'smart_turn',
18
+ 'smart_read_batch',
19
+ 'smart_metrics',
20
+ 'build_index',
21
+ 'warm_cache',
22
+ 'git_blame',
23
+ 'cross_project',
24
+ ]);
25
+
26
+ const inferComplexity = (opCount, fileCount) => {
27
+ if (opCount <= 2 && fileCount <= 1) return 'trivial';
28
+ if (opCount <= 5 && fileCount <= 3) return 'simple';
29
+ if (opCount <= 15 && fileCount <= 10) return 'moderate';
30
+ return 'complex';
31
+ };
32
+
33
+ const groupBySession = (entries) => {
34
+ const sessions = new Map();
35
+
36
+ for (const entry of entries) {
37
+ const sessionId = entry.sessionId || entry.session_id || 'unknown';
38
+
39
+ if (!sessions.has(sessionId)) {
40
+ sessions.set(sessionId, {
41
+ sessionId,
42
+ operations: [],
43
+ devctxTools: new Set(),
44
+ nativeTools: new Set(),
45
+ filesAccessed: new Set(),
46
+ totalRawTokens: 0,
47
+ totalCompressedTokens: 0,
48
+ totalSavedTokens: 0,
49
+ });
50
+ }
51
+
52
+ const session = sessions.get(sessionId);
53
+ session.operations.push(entry);
54
+
55
+ const tool = entry.tool;
56
+ if (DEVCTX_TOOLS.has(tool)) {
57
+ session.devctxTools.add(tool);
58
+ } else if (tool) {
59
+ session.nativeTools.add(tool);
60
+ }
61
+
62
+ if (entry.target) {
63
+ session.filesAccessed.add(entry.target);
64
+ }
65
+
66
+ session.totalRawTokens += Number(entry.rawTokens || entry.raw_tokens || 0);
67
+ session.totalCompressedTokens += Number(entry.compressedTokens || entry.compressed_tokens || 0);
68
+ session.totalSavedTokens += Number(entry.savedTokens || entry.saved_tokens || 0);
69
+ }
70
+
71
+ return Array.from(sessions.values());
72
+ };
73
+
74
+ export const analyzeAdoption = (entries) => {
75
+ const sessions = groupBySession(entries);
76
+
77
+ const sessionsWithDevctx = sessions.filter(s => s.devctxTools.size > 0);
78
+ const sessionsWithoutDevctx = sessions.filter(s => s.devctxTools.size === 0 && s.operations.length > 0);
79
+
80
+ const byComplexity = {
81
+ trivial: { total: 0, withDevctx: 0 },
82
+ simple: { total: 0, withDevctx: 0 },
83
+ moderate: { total: 0, withDevctx: 0 },
84
+ complex: { total: 0, withDevctx: 0 },
85
+ };
86
+
87
+ for (const session of sessions) {
88
+ const complexity = inferComplexity(session.operations.length, session.filesAccessed.size);
89
+ byComplexity[complexity].total += 1;
90
+ if (session.devctxTools.size > 0) {
91
+ byComplexity[complexity].withDevctx += 1;
92
+ }
93
+ }
94
+
95
+ const nonTrivialSessions = sessions.filter(s => {
96
+ const complexity = inferComplexity(s.operations.length, s.filesAccessed.size);
97
+ return complexity !== 'trivial';
98
+ });
99
+
100
+ const nonTrivialWithDevctx = nonTrivialSessions.filter(s => s.devctxTools.size > 0);
101
+
102
+ const toolUsageCount = {};
103
+ for (const session of sessionsWithDevctx) {
104
+ for (const tool of session.devctxTools) {
105
+ toolUsageCount[tool] = (toolUsageCount[tool] || 0) + 1;
106
+ }
107
+ }
108
+
109
+ return {
110
+ totalSessions: sessions.length,
111
+ sessionsWithDevctx: sessionsWithDevctx.length,
112
+ sessionsWithoutDevctx: sessionsWithoutDevctx.length,
113
+ adoptionRate: sessions.length > 0
114
+ ? Number(((sessionsWithDevctx.length / sessions.length) * 100).toFixed(1))
115
+ : 0,
116
+
117
+ nonTrivial: {
118
+ total: nonTrivialSessions.length,
119
+ withDevctx: nonTrivialWithDevctx.length,
120
+ adoptionRate: nonTrivialSessions.length > 0
121
+ ? Number(((nonTrivialWithDevctx.length / nonTrivialSessions.length) * 100).toFixed(1))
122
+ : 0,
123
+ },
124
+
125
+ byComplexity: Object.fromEntries(
126
+ Object.entries(byComplexity).map(([level, stats]) => [
127
+ level,
128
+ {
129
+ ...stats,
130
+ adoptionRate: stats.total > 0
131
+ ? Number(((stats.withDevctx / stats.total) * 100).toFixed(1))
132
+ : 0,
133
+ },
134
+ ])
135
+ ),
136
+
137
+ toolUsageCount,
138
+
139
+ avgToolsPerSession: sessionsWithDevctx.length > 0
140
+ ? Number((sessionsWithDevctx.reduce((sum, s) => sum + s.devctxTools.size, 0) / sessionsWithDevctx.length).toFixed(1))
141
+ : 0,
142
+
143
+ avgTokenSavingsWhenUsed: sessionsWithDevctx.length > 0
144
+ ? Number((sessionsWithDevctx.reduce((sum, s) => sum + s.totalSavedTokens, 0) / sessionsWithDevctx.length).toFixed(0))
145
+ : 0,
146
+ };
147
+ };
148
+
149
+ export const formatAdoptionReport = (stats) => {
150
+ const lines = [];
151
+
152
+ lines.push('');
153
+ lines.push('Adoption Analysis (Inferred from Tool Usage)');
154
+ lines.push('');
155
+ lines.push(`Total sessions: ${stats.totalSessions}`);
156
+ lines.push(`Sessions with devctx: ${stats.sessionsWithDevctx} (${stats.adoptionRate}%)`);
157
+ lines.push(`Sessions without: ${stats.sessionsWithoutDevctx} (${100 - stats.adoptionRate}%)`);
158
+ lines.push('');
159
+
160
+ lines.push('Non-Trivial Tasks Only:');
161
+ lines.push(`Total: ${stats.nonTrivial.total}`);
162
+ lines.push(`With devctx: ${stats.nonTrivial.withDevctx} (${stats.nonTrivial.adoptionRate}%)`);
163
+ lines.push(`Without devctx: ${stats.nonTrivial.total - stats.nonTrivial.withDevctx} (${100 - stats.nonTrivial.adoptionRate}%)`);
164
+ lines.push('');
165
+
166
+ lines.push('By Inferred Complexity:');
167
+ for (const [level, data] of Object.entries(stats.byComplexity)) {
168
+ if (data.total === 0) continue;
169
+ lines.push(`- ${level.padEnd(10)} ${data.withDevctx}/${data.total} (${data.adoptionRate}%)`);
170
+ }
171
+ lines.push('');
172
+
173
+ if (stats.sessionsWithDevctx > 0) {
174
+ lines.push('When devctx IS used:');
175
+ lines.push(`Avg tools/session: ${stats.avgToolsPerSession}`);
176
+ lines.push(`Avg token savings: ${stats.avgTokenSavingsWhenUsed.toLocaleString()} tokens`);
177
+ lines.push('');
178
+ }
179
+
180
+ lines.push('Top Tools Used:');
181
+ const sortedTools = Object.entries(stats.toolUsageCount)
182
+ .sort((a, b) => b[1] - a[1])
183
+ .slice(0, 5);
184
+ for (const [tool, count] of sortedTools) {
185
+ lines.push(`- ${tool.padEnd(20)} ${count} sessions`);
186
+ }
187
+ lines.push('');
188
+
189
+ lines.push('Limitations:');
190
+ lines.push('- Complexity inferred from operation count (not actual task complexity)');
191
+ lines.push('- Can only measure when devctx IS used (tool calls visible)');
192
+ lines.push('- Cannot measure feedback shown or forcing prompts (requires agent cooperation)');
193
+ lines.push('- Sessions without devctx may be simple tasks (not adoption failures)');
194
+ lines.push('');
195
+
196
+ return lines.join('\n');
197
+ };
@@ -5,7 +5,7 @@ import path from 'node:path';
5
5
  import { projectRoot } from '../utils/runtime-config.js';
6
6
 
7
7
  export const STATE_DB_FILENAME = 'state.sqlite';
8
- export const SQLITE_SCHEMA_VERSION = 4;
8
+ export const SQLITE_SCHEMA_VERSION = 5;
9
9
  export const ACTIVE_SESSION_SCOPE = 'project';
10
10
  export const EXPECTED_TABLES = [
11
11
  'active_session',
@@ -16,6 +16,7 @@ export const EXPECTED_TABLES = [
16
16
  'session_events',
17
17
  'sessions',
18
18
  'summary_cache',
19
+ 'workflow_metrics',
19
20
  ];
20
21
 
21
22
  const MIGRATIONS = [
@@ -145,6 +146,34 @@ const MIGRATIONS = [
145
146
  ON context_access(session_id, timestamp DESC)`,
146
147
  ],
147
148
  },
149
+ {
150
+ version: 5,
151
+ statements: [
152
+ `CREATE TABLE IF NOT EXISTS workflow_metrics (
153
+ workflow_id INTEGER PRIMARY KEY AUTOINCREMENT,
154
+ workflow_type TEXT NOT NULL,
155
+ session_id TEXT,
156
+ start_time TEXT NOT NULL,
157
+ end_time TEXT,
158
+ duration_ms INTEGER,
159
+ tools_used_json TEXT NOT NULL DEFAULT '[]',
160
+ steps_count INTEGER NOT NULL DEFAULT 0,
161
+ raw_tokens INTEGER NOT NULL DEFAULT 0,
162
+ compressed_tokens INTEGER NOT NULL DEFAULT 0,
163
+ saved_tokens INTEGER NOT NULL DEFAULT 0,
164
+ savings_pct REAL NOT NULL DEFAULT 0,
165
+ baseline_tokens INTEGER NOT NULL DEFAULT 0,
166
+ vs_baseline_pct REAL NOT NULL DEFAULT 0,
167
+ metadata_json TEXT NOT NULL DEFAULT '{}',
168
+ created_at TEXT NOT NULL,
169
+ FOREIGN KEY(session_id) REFERENCES sessions(session_id) ON DELETE SET NULL
170
+ )`,
171
+ `CREATE INDEX IF NOT EXISTS idx_workflow_metrics_type_created
172
+ ON workflow_metrics(workflow_type, created_at DESC)`,
173
+ `CREATE INDEX IF NOT EXISTS idx_workflow_metrics_session
174
+ ON workflow_metrics(session_id, created_at DESC)`,
175
+ ],
176
+ },
148
177
  ];
149
178
 
150
179
  let sqliteModulePromise = null;
@@ -14,6 +14,7 @@ import {
14
14
  readMetricsEntries,
15
15
  resolveMetricsInput,
16
16
  } from '../metrics.js';
17
+ import { analyzeAdoption } from '../analytics/adoption.js';
17
18
 
18
19
  const WINDOW_MS = {
19
20
  '24h': 24 * 60 * 60 * 1000,
@@ -197,6 +198,8 @@ export const smartMetrics = async ({
197
198
  .filter((entry) => (tool ? entry.tool === tool : true))
198
199
  .filter((entry) => (resolvedSessionId ? entry.sessionId === resolvedSessionId : true));
199
200
 
201
+ const adoption = analyzeAdoption(filteredEntries);
202
+
200
203
  return {
201
204
  filePath: resolved.storagePath,
202
205
  storagePath: resolved.storagePath,
@@ -212,6 +215,7 @@ export const smartMetrics = async ({
212
215
  },
213
216
  invalidLines,
214
217
  summary: aggregateMetrics(filteredEntries),
218
+ adoption,
215
219
  latestEntries: buildLatestEntries(filteredEntries, latest),
216
220
  };
217
221
  }
@@ -229,6 +233,8 @@ export const smartMetrics = async ({
229
233
  window,
230
234
  });
231
235
 
236
+ const adoption = analyzeAdoption(entries);
237
+
232
238
  return {
233
239
  filePath: resolved.storagePath,
234
240
  storagePath: resolved.storagePath,
@@ -244,6 +250,7 @@ export const smartMetrics = async ({
244
250
  },
245
251
  invalidLines,
246
252
  summary: aggregateMetrics(entries),
253
+ adoption,
247
254
  latestEntries: buildLatestEntries(entries, latest),
248
255
  };
249
256
  };
@@ -18,6 +18,15 @@ export const smartReadBatch = async ({ files, maxTokens }) => {
18
18
  maxTokens: item.maxTokens,
19
19
  });
20
20
 
21
+ if (readResult.error) {
22
+ results.push({
23
+ filePath: item.path,
24
+ mode: item.mode ?? 'outline',
25
+ error: readResult.error,
26
+ });
27
+ continue;
28
+ }
29
+
21
30
  const itemTokens = countTokens(readResult.content);
22
31
 
23
32
  if (maxTokens && totalTokens + itemTokens > maxTokens && results.length > 0) {
@@ -363,7 +363,27 @@ const formatContextSections = (sections) => {
363
363
  };
364
364
 
365
365
  export const smartRead = async ({ filePath, mode = 'outline', startLine, endLine, symbol, maxTokens, context: includeContext }) => {
366
- const { fullPath, content } = readTextFile(filePath);
366
+ let fullPath, content;
367
+
368
+ try {
369
+ const result = readTextFile(filePath);
370
+ fullPath = result.fullPath;
371
+ content = result.content;
372
+ } catch (error) {
373
+ const errorMessage = error.message || String(error);
374
+ return {
375
+ error: errorMessage,
376
+ filePath,
377
+ mode,
378
+ metrics: buildMetrics({
379
+ tool: 'smart_read',
380
+ target: filePath,
381
+ rawText: '',
382
+ compressedText: errorMessage,
383
+ }),
384
+ };
385
+ }
386
+
367
387
  const extension = path.extname(fullPath).toLowerCase();
368
388
  const mtime = getFileMtime(fullPath);
369
389
 
@@ -6,11 +6,21 @@ import { projectRoot } from '../utils/paths.js';
6
6
  import { pickRelevantLines, truncate, uniqueLines } from '../utils/text.js';
7
7
 
8
8
  const execFile = promisify(execFileCallback);
9
- const blockedPattern = /[|&;<>`\n\r]/;
9
+ const isShellDisabled = () => process.env.DEVCTX_SHELL_DISABLED === 'true';
10
+ const blockedPattern = /[|&;<>`\n\r$()]/;
10
11
  const allowedCommands = new Set(['pwd', 'ls', 'find', 'rg', 'git', 'npm', 'pnpm', 'yarn', 'bun']);
11
- const allowedGitSubcommands = new Set(['status', 'diff', 'show', 'log', 'branch', 'rev-parse']);
12
+ const allowedGitSubcommands = new Set(['status', 'diff', 'show', 'log', 'branch', 'rev-parse', 'blame']);
12
13
  const allowedPackageManagerSubcommands = new Set(['test', 'run', 'lint', 'build', 'typecheck', 'check']);
13
- const safeRunScriptPattern = /^(test|lint|build|typecheck|check|smoke|verify)(:|$)/;
14
+ const safeRunScriptPattern = /^(test|lint|build|typecheck|check|smoke|verify|eval)(:|$)/;
15
+ const dangerousPatterns = [
16
+ /rm\s+-rf/i,
17
+ /sudo/i,
18
+ /curl.*\|/i,
19
+ /wget.*\|/i,
20
+ /eval/i,
21
+ /exec/i,
22
+ ];
23
+ const MAX_COMMAND_LENGTH = 500;
14
24
 
15
25
  const tokenize = (command) => {
16
26
  const tokens = [];
@@ -67,12 +77,26 @@ const tokenize = (command) => {
67
77
  };
68
78
 
69
79
  const validateCommand = (command, tokens) => {
80
+ if (isShellDisabled()) {
81
+ return 'Shell execution is disabled (DEVCTX_SHELL_DISABLED=true)';
82
+ }
83
+
70
84
  if (!command.trim()) {
71
85
  return 'Command is empty';
72
86
  }
73
87
 
74
- if (blockedPattern.test(command) || command.includes('$(')) {
75
- return 'Shell operators are not allowed';
88
+ if (command.length > MAX_COMMAND_LENGTH) {
89
+ return `Command too long (max ${MAX_COMMAND_LENGTH} chars)`;
90
+ }
91
+
92
+ if (blockedPattern.test(command)) {
93
+ return 'Shell operators are not allowed (|, &, ;, <, >, `, $, (, ))';
94
+ }
95
+
96
+ for (const pattern of dangerousPatterns) {
97
+ if (pattern.test(command)) {
98
+ return `Dangerous pattern detected: ${pattern.source}`;
99
+ }
76
100
  }
77
101
 
78
102
  if (tokens.length === 0) {
@@ -82,11 +106,11 @@ const validateCommand = (command, tokens) => {
82
106
  const [baseCommand, subcommand, thirdToken] = tokens;
83
107
 
84
108
  if (!allowedCommands.has(baseCommand)) {
85
- return `Command not allowed: ${baseCommand}`;
109
+ return `Command not allowed: ${baseCommand}. Allowed: ${[...allowedCommands].join(', ')}`;
86
110
  }
87
111
 
88
112
  if (baseCommand === 'git' && !allowedGitSubcommands.has(subcommand)) {
89
- return `Git subcommand not allowed: ${subcommand ?? '(missing)'}`;
113
+ return `Git subcommand not allowed: ${subcommand ?? '(missing)'}. Allowed: ${[...allowedGitSubcommands].join(', ')}`;
90
114
  }
91
115
 
92
116
  if (baseCommand === 'find') {
@@ -99,11 +123,11 @@ const validateCommand = (command, tokens) => {
99
123
 
100
124
  if (['npm', 'pnpm', 'yarn', 'bun'].includes(baseCommand)) {
101
125
  if (!subcommand || !allowedPackageManagerSubcommands.has(subcommand)) {
102
- return `Package manager subcommand not allowed: ${subcommand ?? '(missing)'}`;
126
+ return `Package manager subcommand not allowed: ${subcommand ?? '(missing)'}. Allowed: ${[...allowedPackageManagerSubcommands].join(', ')}`;
103
127
  }
104
128
 
105
129
  if (subcommand === 'run' && (!thirdToken || !safeRunScriptPattern.test(thirdToken))) {
106
- return `Package manager script not allowed: ${thirdToken ?? '(missing)'}`;
130
+ return `Package manager script not allowed: ${thirdToken ?? '(missing)'}. Allowed pattern: ${safeRunScriptPattern.source}`;
107
131
  }
108
132
  }
109
133
 
@@ -205,6 +205,7 @@ const startTurn = async ({
205
205
  })
206
206
  : null;
207
207
 
208
+
208
209
  return {
209
210
  phase: 'start',
210
211
  promptPreview: truncate(prompt, MAX_PROMPT_PREVIEW),
@@ -0,0 +1,53 @@
1
+ // Stub for workflow tracking (to be implemented in future version)
2
+ // This avoids SQLite issues in tests while keeping the API available
3
+
4
+ export const detectWorkflowType = () => null;
5
+ export const getWorkflowBaseline = () => 0;
6
+ export const startWorkflow = () => null;
7
+ export const endWorkflow = () => null;
8
+ export const getWorkflowMetrics = () => [];
9
+ export const getWorkflowSummaryByType = () => [];
10
+ export const autoTrackWorkflow = () => null;
11
+
12
+ export const WORKFLOW_DEFINITIONS = {
13
+ debugging: {
14
+ name: 'Debugging',
15
+ description: 'Error-first, symbol-focused debugging workflow',
16
+ typicalTools: ['smart_turn', 'smart_search', 'smart_read', 'smart_shell'],
17
+ minTools: 3,
18
+ baselineTokens: 150000,
19
+ pattern: /debug|error|bug|fix|fail/i,
20
+ },
21
+ 'code-review': {
22
+ name: 'Code Review',
23
+ description: 'Diff-aware, API-focused code review workflow',
24
+ typicalTools: ['smart_turn', 'smart_context', 'smart_read', 'git_blame', 'smart_shell'],
25
+ minTools: 3,
26
+ baselineTokens: 200000,
27
+ pattern: /review|pr|pull.?request|approve/i,
28
+ },
29
+ refactoring: {
30
+ name: 'Refactoring',
31
+ description: 'Graph-aware, test-verified refactoring workflow',
32
+ typicalTools: ['smart_turn', 'smart_context', 'smart_read', 'git_blame', 'smart_shell'],
33
+ minTools: 3,
34
+ baselineTokens: 180000,
35
+ pattern: /refactor|extract|rename|move|restructure/i,
36
+ },
37
+ testing: {
38
+ name: 'Testing',
39
+ description: 'Coverage-aware, TDD-friendly testing workflow',
40
+ typicalTools: ['smart_turn', 'smart_search', 'smart_read', 'smart_context', 'smart_shell'],
41
+ minTools: 3,
42
+ baselineTokens: 120000,
43
+ pattern: /test|spec|coverage|tdd/i,
44
+ },
45
+ architecture: {
46
+ name: 'Architecture Exploration',
47
+ description: 'Index-first, minimal-detail architecture exploration',
48
+ typicalTools: ['smart_turn', 'smart_context', 'smart_search', 'smart_read', 'cross_project'],
49
+ minTools: 3,
50
+ baselineTokens: 300000,
51
+ pattern: /architect|explore|understand|structure|design/i,
52
+ },
53
+ };