smart-context-mcp 1.0.3 → 1.0.4

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,314 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { persistMetrics } from '../metrics.js';
3
+ import { countTokens } from '../tokenCounter.js';
4
+ import { smartSummary } from '../tools/smart-summary.js';
5
+ import { smartTurn } from '../tools/smart-turn.js';
6
+
7
+ const DEFAULT_EVENT = 'session_end';
8
+ const START_MAX_TOKENS = 350;
9
+ const END_MAX_TOKENS = 350;
10
+ const SAFE_CONTINUITY_STATES = new Set(['aligned', 'resume']);
11
+
12
+ const normalizeWhitespace = (value) => String(value ?? '').replace(/\s+/g, ' ').trim();
13
+
14
+ const truncate = (value, maxLength = 160) => {
15
+ const normalized = normalizeWhitespace(value);
16
+ if (normalized.length <= maxLength) {
17
+ return normalized;
18
+ }
19
+
20
+ if (maxLength <= 3) {
21
+ return '';
22
+ }
23
+
24
+ return `${normalized.slice(0, maxLength - 3)}...`;
25
+ };
26
+
27
+ const extractNextStep = (value) => {
28
+ const normalized = normalizeWhitespace(value);
29
+ if (!normalized) {
30
+ return '';
31
+ }
32
+
33
+ const explicitMatch = normalized.match(/(?:next step|siguiente paso)\s*[:\-]\s*([^.;\n]{12,180})/i);
34
+ if (explicitMatch?.[1]) {
35
+ return truncate(explicitMatch[1], 150);
36
+ }
37
+
38
+ return '';
39
+ };
40
+
41
+ const buildContextLines = (startResult) => {
42
+ const summary = startResult?.summary ?? {};
43
+ const lines = [];
44
+
45
+ if (startResult?.sessionId) {
46
+ lines.push(`Persisted devctx session: ${startResult.sessionId}`);
47
+ }
48
+
49
+ if (summary.goal) {
50
+ lines.push(`Goal: ${truncate(summary.goal, 120)}`);
51
+ }
52
+
53
+ if (summary.currentFocus) {
54
+ lines.push(`Focus: ${truncate(summary.currentFocus, 120)}`);
55
+ }
56
+
57
+ if (summary.nextStep) {
58
+ lines.push(`Next step: ${truncate(summary.nextStep, 120)}`);
59
+ }
60
+
61
+ if (startResult?.continuity?.reason) {
62
+ lines.push(`Context status: ${truncate(startResult.continuity.reason, 120)}`);
63
+ }
64
+
65
+ return lines.slice(0, 5);
66
+ };
67
+
68
+ export const buildWrappedPrompt = ({ prompt, startResult }) => {
69
+ const lines = buildContextLines(startResult);
70
+ if (lines.length === 0) {
71
+ return prompt;
72
+ }
73
+
74
+ return [
75
+ 'Use the persisted devctx project context below only if it is relevant to the user request.',
76
+ ...lines.map((line) => `- ${line}`),
77
+ '',
78
+ 'User request:',
79
+ prompt,
80
+ ].join('\n');
81
+ };
82
+
83
+ const buildFreshSessionUpdate = (prompt) => {
84
+ const preview = truncate(prompt, 140);
85
+ return {
86
+ goal: truncate(prompt, 120),
87
+ status: 'planning',
88
+ currentFocus: preview,
89
+ pinnedContext: [preview],
90
+ nextStep: 'Inspect the relevant code, validate task boundaries, and checkpoint the first concrete milestone.',
91
+ };
92
+ };
93
+
94
+ const ensureIsolatedSession = async ({ prompt, sessionId, startResult }) => {
95
+ if (sessionId || !startResult?.sessionId) {
96
+ return {
97
+ startResult,
98
+ isolated: false,
99
+ };
100
+ }
101
+
102
+ if (SAFE_CONTINUITY_STATES.has(startResult.continuity?.state ?? '')) {
103
+ return {
104
+ startResult,
105
+ isolated: false,
106
+ };
107
+ }
108
+
109
+ const created = await smartSummary({
110
+ action: 'update',
111
+ update: buildFreshSessionUpdate(prompt),
112
+ maxTokens: START_MAX_TOKENS,
113
+ });
114
+ const isolatedStart = await smartTurn({
115
+ phase: 'start',
116
+ sessionId: created.sessionId,
117
+ prompt,
118
+ ensureSession: false,
119
+ maxTokens: START_MAX_TOKENS,
120
+ });
121
+
122
+ return {
123
+ startResult: isolatedStart,
124
+ isolated: true,
125
+ previousSessionId: startResult.sessionId,
126
+ };
127
+ };
128
+
129
+ const runChildProcess = ({ command, args, env, stdinText, streamOutput }) => new Promise((resolve, reject) => {
130
+ const child = spawn(command, args, {
131
+ env,
132
+ stdio: ['pipe', 'pipe', 'pipe'],
133
+ });
134
+
135
+ let stdout = '';
136
+ let stderr = '';
137
+
138
+ child.stdout.on('data', (chunk) => {
139
+ const text = chunk.toString();
140
+ stdout += text;
141
+ if (streamOutput) {
142
+ process.stdout.write(text);
143
+ }
144
+ });
145
+
146
+ child.stderr.on('data', (chunk) => {
147
+ const text = chunk.toString();
148
+ stderr += text;
149
+ if (streamOutput) {
150
+ process.stderr.write(text);
151
+ }
152
+ });
153
+
154
+ child.on('error', reject);
155
+ child.on('close', (exitCode, signal) => resolve({ exitCode: exitCode ?? 0, signal, stdout, stderr }));
156
+
157
+ if (stdinText) {
158
+ child.stdin.end(stdinText);
159
+ } else {
160
+ child.stdin.end();
161
+ }
162
+ });
163
+
164
+ const buildEndUpdate = ({ prompt, childResult }) => {
165
+ const combinedOutput = [childResult.stdout, childResult.stderr].filter(Boolean).join('\n');
166
+ const nextStep = extractNextStep(combinedOutput);
167
+ const update = {
168
+ currentFocus: truncate(prompt, 140),
169
+ };
170
+
171
+ if (nextStep) {
172
+ update.nextStep = nextStep;
173
+ } else if (childResult.exitCode === 0) {
174
+ update.nextStep = 'Review the latest headless agent output and checkpoint any concrete file changes before continuing.';
175
+ } else {
176
+ update.status = 'blocked';
177
+ update.whyBlocked = `Headless agent command exited with code ${childResult.exitCode}.`;
178
+ update.nextStep = 'Review the headless agent stderr/output and rerun the command once the issue is fixed.';
179
+ }
180
+
181
+ return update;
182
+ };
183
+
184
+ const inferEndEvent = ({ requestedEvent, childResult }) => {
185
+ if (requestedEvent) {
186
+ return requestedEvent;
187
+ }
188
+
189
+ return childResult.exitCode === 0 ? DEFAULT_EVENT : 'blocker';
190
+ };
191
+
192
+ export const runHeadlessWrapper = async ({
193
+ client = 'generic',
194
+ prompt,
195
+ command,
196
+ args = [],
197
+ sessionId,
198
+ event,
199
+ stdinPrompt = false,
200
+ dryRun = false,
201
+ streamOutput = false,
202
+ runCommand = runChildProcess,
203
+ } = {}) => {
204
+ if (!normalizeWhitespace(prompt)) {
205
+ throw new Error('prompt is required');
206
+ }
207
+
208
+ if (!dryRun && !normalizeWhitespace(command)) {
209
+ throw new Error('command is required unless dryRun=true');
210
+ }
211
+
212
+ const start = await smartTurn({
213
+ phase: 'start',
214
+ sessionId,
215
+ prompt,
216
+ ensureSession: true,
217
+ maxTokens: START_MAX_TOKENS,
218
+ });
219
+ const sessionResolution = await ensureIsolatedSession({ prompt, sessionId, startResult: start });
220
+ const effectiveStart = sessionResolution.startResult;
221
+ const wrappedPrompt = buildWrappedPrompt({ prompt, startResult: effectiveStart });
222
+ const overheadTokens = Math.max(0, countTokens(wrappedPrompt) - countTokens(prompt));
223
+
224
+ await persistMetrics({
225
+ tool: 'agent_wrapper',
226
+ action: `${client}:start`,
227
+ sessionId: effectiveStart.sessionId ?? null,
228
+ rawTokens: 0,
229
+ compressedTokens: 0,
230
+ savedTokens: 0,
231
+ savingsPct: 0,
232
+ metadata: {
233
+ isContextOverhead: overheadTokens > 0,
234
+ overheadTokens,
235
+ client,
236
+ dryRun,
237
+ isolatedSession: sessionResolution.isolated,
238
+ previousSessionId: sessionResolution.previousSessionId ?? null,
239
+ },
240
+ timestamp: new Date().toISOString(),
241
+ });
242
+
243
+ const finalArgs = stdinPrompt ? [...args] : [...args, wrappedPrompt];
244
+ if (dryRun) {
245
+ return {
246
+ client,
247
+ dryRun: true,
248
+ command,
249
+ args: finalArgs,
250
+ wrappedPrompt,
251
+ overheadTokens,
252
+ start: effectiveStart,
253
+ sessionId: effectiveStart.sessionId ?? sessionId ?? null,
254
+ isolatedSession: sessionResolution.isolated,
255
+ };
256
+ }
257
+
258
+ const childResult = await runCommand({
259
+ command,
260
+ args: finalArgs,
261
+ env: {
262
+ ...process.env,
263
+ DEVCTX_TURN_SESSION_ID: effectiveStart.sessionId ?? '',
264
+ DEVCTX_TURN_CONTINUITY_STATE: effectiveStart.continuity?.state ?? '',
265
+ DEVCTX_TURN_CONTEXT: wrappedPrompt,
266
+ },
267
+ stdinText: stdinPrompt ? wrappedPrompt : '',
268
+ streamOutput,
269
+ });
270
+
271
+ const resolvedEvent = inferEndEvent({ requestedEvent: event, childResult });
272
+ const end = await smartTurn({
273
+ phase: 'end',
274
+ sessionId: effectiveStart.sessionId ?? sessionId ?? undefined,
275
+ event: resolvedEvent,
276
+ update: buildEndUpdate({ prompt, childResult }),
277
+ maxTokens: END_MAX_TOKENS,
278
+ });
279
+
280
+ await persistMetrics({
281
+ tool: 'agent_wrapper',
282
+ action: `${client}:end`,
283
+ sessionId: effectiveStart.sessionId ?? null,
284
+ rawTokens: 0,
285
+ compressedTokens: 0,
286
+ savedTokens: 0,
287
+ savingsPct: 0,
288
+ metadata: {
289
+ client,
290
+ exitCode: childResult.exitCode,
291
+ event: resolvedEvent,
292
+ isContextOverhead: false,
293
+ overheadTokens: 0,
294
+ isolatedSession: sessionResolution.isolated,
295
+ },
296
+ timestamp: new Date().toISOString(),
297
+ });
298
+
299
+ return {
300
+ client,
301
+ command,
302
+ args: finalArgs,
303
+ wrappedPrompt,
304
+ overheadTokens,
305
+ exitCode: childResult.exitCode,
306
+ signal: childResult.signal,
307
+ stdout: childResult.stdout,
308
+ stderr: childResult.stderr,
309
+ start: effectiveStart,
310
+ end,
311
+ sessionId: effectiveStart.sessionId ?? sessionId ?? null,
312
+ isolatedSession: sessionResolution.isolated,
313
+ };
314
+ };
@@ -0,0 +1,166 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { execFileSync } from 'node:child_process';
4
+ import { getStateDbPath } from './storage/sqlite.js';
5
+ import { projectRoot } from './utils/runtime-config.js';
6
+
7
+ const hasGitignoreEntry = (content, entry) => {
8
+ const target = entry.replace(/\/+$/, '');
9
+ return content
10
+ .split(/\r?\n/)
11
+ .map((line) => line.trim())
12
+ .filter(Boolean)
13
+ .map((line) => line.replace(/\/+$/, ''))
14
+ .includes(target);
15
+ };
16
+
17
+ const runGit = (args, cwd) => {
18
+ try {
19
+ const stdout = execFileSync('git', args, {
20
+ cwd,
21
+ encoding: 'utf8',
22
+ stdio: ['ignore', 'pipe', 'pipe'],
23
+ });
24
+ return {
25
+ ok: true,
26
+ code: 0,
27
+ stdout: stdout.trim(),
28
+ stderr: '',
29
+ };
30
+ } catch (error) {
31
+ return {
32
+ ok: false,
33
+ code: Number.isInteger(error.status) ? error.status : null,
34
+ stdout: typeof error.stdout === 'string' ? error.stdout.trim() : '',
35
+ stderr: typeof error.stderr === 'string' ? error.stderr.trim() : '',
36
+ errorCode: error.code ?? null,
37
+ };
38
+ }
39
+ };
40
+
41
+ const toRelativePath = (basePath, targetPath) => path.relative(basePath, path.resolve(targetPath)).replace(/\\/g, '/');
42
+
43
+ const getStagedFiles = (gitRoot, filePath) => {
44
+ const result = runGit(['diff', '--cached', '--name-only', '--', filePath], gitRoot);
45
+ if (!result.ok) {
46
+ return [];
47
+ }
48
+
49
+ return result.stdout
50
+ .split('\n')
51
+ .map((line) => line.trim())
52
+ .filter(Boolean);
53
+ };
54
+
55
+ export const getRepoSafety = ({
56
+ root = projectRoot,
57
+ stateDbPath = getStateDbPath(),
58
+ } = {}) => {
59
+ const repoRootResult = runGit(['rev-parse', '--show-toplevel'], root);
60
+
61
+ if (!repoRootResult.ok || !repoRootResult.stdout) {
62
+ return {
63
+ available: false,
64
+ isGitRepo: false,
65
+ riskLevel: 'unknown',
66
+ warnings: [],
67
+ recommendedActions: [],
68
+ };
69
+ }
70
+
71
+ const gitRoot = repoRootResult.stdout;
72
+ const relativeStateDbPath = toRelativePath(gitRoot, stateDbPath);
73
+ const projectGitignorePath = path.join(root, '.gitignore');
74
+ const gitignoreContent = fs.existsSync(projectGitignorePath)
75
+ ? fs.readFileSync(projectGitignorePath, 'utf8')
76
+ : '';
77
+ const projectIgnoreEntryPresent = hasGitignoreEntry(gitignoreContent, '.devctx/');
78
+
79
+ const ignoredResult = runGit(['check-ignore', relativeStateDbPath], gitRoot);
80
+ const trackedResult = runGit(['ls-files', '--error-unmatch', relativeStateDbPath], gitRoot);
81
+
82
+ const isIgnored = ignoredResult.code === 0;
83
+ const isTracked = trackedResult.code === 0;
84
+ const stagedPaths = getStagedFiles(gitRoot, relativeStateDbPath);
85
+ const isStaged = stagedPaths.length > 0;
86
+ const warnings = [];
87
+ const recommendedActions = [];
88
+
89
+ if (isTracked) {
90
+ warnings.push(`${relativeStateDbPath} is tracked by git and can be committed accidentally.`);
91
+ recommendedActions.push(`Untrack ${relativeStateDbPath} and keep .devctx/ ignored before committing.`);
92
+ }
93
+
94
+ if (isStaged) {
95
+ warnings.push(`${relativeStateDbPath} is staged for commit.`);
96
+ recommendedActions.push(`Unstage ${relativeStateDbPath} before committing.`);
97
+ } else if (!isIgnored) {
98
+ warnings.push(`${relativeStateDbPath} is not ignored by git.`);
99
+ recommendedActions.push('Add .devctx/ to the project .gitignore before relying on project-local state.');
100
+ }
101
+
102
+ if (!projectIgnoreEntryPresent) {
103
+ recommendedActions.push('Ensure the project .gitignore contains `.devctx/` for explicit local-state hygiene.');
104
+ }
105
+
106
+ return {
107
+ available: true,
108
+ isGitRepo: true,
109
+ gitRoot,
110
+ stateDbPath: relativeStateDbPath,
111
+ projectGitignorePath: path.relative(root, projectGitignorePath).replace(/\\/g, '/'),
112
+ projectIgnoreEntryPresent,
113
+ isIgnored,
114
+ isTracked,
115
+ isStaged,
116
+ stagedPaths,
117
+ riskLevel: warnings.length > 0 ? 'warning' : 'ok',
118
+ warnings,
119
+ recommendedActions,
120
+ };
121
+ };
122
+
123
+ export const enforceRepoSafety = ({
124
+ root = projectRoot,
125
+ stateDbPath = getStateDbPath(),
126
+ } = {}) => {
127
+ const safety = getRepoSafety({ root, stateDbPath });
128
+
129
+ if (!safety.available) {
130
+ return {
131
+ ...safety,
132
+ enforced: false,
133
+ ok: true,
134
+ violations: [],
135
+ message: 'Repository safety checks skipped because no git repository was detected.',
136
+ };
137
+ }
138
+
139
+ const violations = [];
140
+
141
+ if (!safety.projectIgnoreEntryPresent) {
142
+ violations.push('The project .gitignore does not include .devctx/.');
143
+ }
144
+
145
+ if (!safety.isIgnored) {
146
+ violations.push(`${safety.stateDbPath} is not ignored by git.`);
147
+ }
148
+
149
+ if (safety.isTracked) {
150
+ violations.push(`${safety.stateDbPath} is tracked by git.`);
151
+ }
152
+
153
+ if (safety.isStaged) {
154
+ violations.push(`${safety.stateDbPath} is staged for commit.`);
155
+ }
156
+
157
+ return {
158
+ ...safety,
159
+ enforced: true,
160
+ ok: violations.length === 0,
161
+ violations,
162
+ message: violations.length === 0
163
+ ? 'Repository safety checks passed.'
164
+ : 'Repository safety checks failed.',
165
+ };
166
+ };
package/src/server.js CHANGED
@@ -9,6 +9,8 @@ import { smartContext } from './tools/smart-context.js';
9
9
  import { smartReadBatch } from './tools/smart-read-batch.js';
10
10
  import { smartShell } from './tools/smart-shell.js';
11
11
  import { smartSummary } from './tools/smart-summary.js';
12
+ import { smartMetrics } from './tools/smart-metrics.js';
13
+ import { smartTurn } from './tools/smart-turn.js';
12
14
  import { projectRoot, projectRootSource } from './utils/paths.js';
13
15
 
14
16
  const require = createRequire(import.meta.url);
@@ -123,9 +125,9 @@ export const createDevctxServer = () => {
123
125
 
124
126
  server.tool(
125
127
  'smart_summary',
126
- 'Maintain compressed conversation state across turns. Actions: get (retrieve current/last session), update (create or replace a session; omitted fields are cleared), append (add to existing session), reset (clear session), list_sessions (show all sessions). Sessions persist in .devctx/sessions/ with 30-day retention. Auto-generates sessionId from goal if not provided. Returns a resume summary capped at maxTokens (default 500) plus compression metadata (`truncated`, `compressionLevel`, `omitted`) and `schemaVersion`. Tracks: goal, status, pinned context, unresolved questions, current focus, blockers, next step, completed steps, key decisions, and touched files.',
128
+ 'Maintain compressed conversation state across turns. Actions: get (retrieve current/last session), update (create or replace a session; omitted fields are cleared), append (add to existing session), auto_append (append only if something meaningful changed), checkpoint (event-driven orchestration that decides whether to auto-persist), reset (clear session), list_sessions (show all sessions), compact (apply retention/compaction to SQLite events), cleanup_legacy (inspect or remove imported legacy JSON/JSONL files). Sessions persist in project-local SQLite with 30-day retention defaults. Auto-generates sessionId from goal if omitted. `get` auto-resumes the active session when present, otherwise falls back to the best saved session when unambiguous; pass `sessionId: "auto"` to accept the recommended session even when multiple recent candidates exist. Returns a resume summary capped at maxTokens (default 500) plus compression metadata (`truncated`, `compressionLevel`, `omitted`) and `schemaVersion`. Includes `repoSafety` so agents can catch `.devctx/state.sqlite` git hygiene issues early; mutating actions are blocked at runtime when that SQLite file is tracked or staged. Tracks: goal, status, pinned context, unresolved questions, current focus, blockers, next step, completed steps, key decisions, and touched files.',
127
129
  {
128
- action: z.enum(['get', 'update', 'append', 'reset', 'list_sessions']),
130
+ action: z.enum(['get', 'update', 'append', 'auto_append', 'checkpoint', 'reset', 'list_sessions', 'compact', 'cleanup_legacy']),
129
131
  sessionId: z.string().optional(),
130
132
  update: z.object({
131
133
  goal: z.string().optional(),
@@ -140,10 +142,87 @@ export const createDevctxServer = () => {
140
142
  nextStep: z.string().optional(),
141
143
  touchedFiles: z.array(z.string()).optional(),
142
144
  }).optional(),
145
+ event: z.enum(['manual', 'milestone', 'decision', 'blocker', 'status_change', 'file_change', 'task_switch', 'task_complete', 'session_end', 'read_only', 'heartbeat']).optional(),
146
+ force: z.boolean().optional(),
143
147
  maxTokens: z.number().int().min(100).max(2000).optional(),
148
+ retentionDays: z.number().int().min(1).max(3650).optional(),
149
+ keepLatestEventsPerSession: z.number().int().min(0).max(10000).optional(),
150
+ keepLatestMetrics: z.number().int().min(0).max(100000).optional(),
151
+ vacuum: z.boolean().optional(),
152
+ apply: z.boolean().optional(),
144
153
  },
145
- async ({ action, sessionId, update, maxTokens }) =>
146
- asTextResult(await smartSummary({ action, sessionId, update, maxTokens })),
154
+ async ({ action, sessionId, update, event, force, maxTokens, retentionDays, keepLatestEventsPerSession, keepLatestMetrics, vacuum, apply }) =>
155
+ asTextResult(await smartSummary({
156
+ action,
157
+ sessionId,
158
+ update,
159
+ event,
160
+ force,
161
+ maxTokens,
162
+ retentionDays,
163
+ keepLatestEventsPerSession,
164
+ keepLatestMetrics,
165
+ vacuum,
166
+ apply,
167
+ })),
168
+ );
169
+
170
+ server.tool(
171
+ 'smart_turn',
172
+ 'Orchestrate start/end of a meaningful agent turn so context usage becomes almost mandatory with low token overhead. `phase: "start"` rehydrates persisted context, classifies prompt continuity against the saved session, optionally auto-creates a planning session for a new substantial task, and can include compact metrics. `phase: "end"` writes a checkpoint through smart_summary and can optionally include compact metrics. Use this instead of manually chaining `smart_summary(get)` and `smart_summary(checkpoint)` when you want a single context-first turn workflow.',
173
+ {
174
+ phase: z.enum(['start', 'end']),
175
+ sessionId: z.string().optional(),
176
+ prompt: z.string().optional(),
177
+ update: z.object({
178
+ goal: z.string().optional(),
179
+ status: z.enum(['planning', 'in_progress', 'blocked', 'completed']).optional(),
180
+ pinnedContext: z.array(z.string()).optional(),
181
+ unresolvedQuestions: z.array(z.string()).optional(),
182
+ currentFocus: z.string().optional(),
183
+ whyBlocked: z.string().optional(),
184
+ completed: z.array(z.string()).optional(),
185
+ decisions: z.array(z.string()).optional(),
186
+ blockers: z.array(z.string()).optional(),
187
+ nextStep: z.string().optional(),
188
+ touchedFiles: z.array(z.string()).optional(),
189
+ }).optional(),
190
+ event: z.enum(['manual', 'milestone', 'decision', 'blocker', 'status_change', 'file_change', 'task_switch', 'task_complete', 'session_end', 'read_only', 'heartbeat']).optional(),
191
+ force: z.boolean().optional(),
192
+ maxTokens: z.number().int().min(100).max(2000).optional(),
193
+ ensureSession: z.boolean().optional(),
194
+ includeMetrics: z.boolean().optional(),
195
+ metricsWindow: z.enum(['24h', '7d', '30d', 'all']).optional(),
196
+ latestMetrics: z.number().int().min(1).max(20).optional(),
197
+ },
198
+ async ({ phase, sessionId, prompt, update, event, force, maxTokens, ensureSession, includeMetrics, metricsWindow, latestMetrics }) =>
199
+ asTextResult(await smartTurn({
200
+ phase,
201
+ sessionId,
202
+ prompt,
203
+ update,
204
+ event,
205
+ force,
206
+ maxTokens,
207
+ ensureSession,
208
+ includeMetrics,
209
+ metricsWindow,
210
+ latestMetrics,
211
+ })),
212
+ );
213
+
214
+ server.tool(
215
+ 'smart_metrics',
216
+ 'Inspect token metrics recorded in project-local SQLite storage by default. Returns aggregated totals, per-tool savings, and recent entries. Supports time windows (`24h`, `7d`, `30d`, `all`), optional tool filtering, and optional session filtering (`sessionId: "active"` resolves the current active session automatically). Pass `file` to inspect a legacy/custom JSONL file explicitly. When `.devctx/state.sqlite` is tracked or staged, reads fall back to a temporary read-only snapshot and report `sideEffectsSuppressed`; metrics writes from other tools are skipped until git hygiene is fixed.',
217
+ {
218
+ file: z.string().optional(),
219
+ tool: z.string().optional(),
220
+ sessionId: z.string().optional(),
221
+ window: z.enum(['24h', '7d', '30d', 'all']).optional(),
222
+ latest: z.number().int().min(1).max(50).optional(),
223
+ },
224
+ async ({ file, tool, sessionId, window, latest }) =>
225
+ asTextResult(await smartMetrics({ file, tool, sessionId, window, latest })),
147
226
  );
148
227
 
149
228
  return server;