@hasna/terminal 3.3.9 → 3.4.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.
package/dist/cli.js CHANGED
@@ -78,6 +78,14 @@ if (args[0] === "uninstall") {
78
78
  handleInstall(["uninstall"]);
79
79
  process.exit(0);
80
80
  }
81
+ // ── Prune ────────────────────────────────────────────────────────────────────
82
+ if (args[0] === "prune") {
83
+ const days = parseInt(args.find(a => a.startsWith("--older-than="))?.split("=")[1] ?? "90");
84
+ const { pruneSessions } = await import("./sessions-db.js");
85
+ const result = pruneSessions(days);
86
+ console.log(` Pruned ${result.sessionsDeleted} sessions, ${result.interactionsDeleted} interactions older than ${days}d`);
87
+ process.exit(0);
88
+ }
81
89
  // ── MCP commands ─────────────────────────────────────────────────────────────
82
90
  if (args[0] === "mcp") {
83
91
  if (args[1] === "serve" || args.length === 1) {
@@ -12,7 +12,7 @@ import { listRecipes, listCollections, getRecipe, createRecipe } from "../recipe
12
12
  import { substituteVariables } from "../recipes/model.js";
13
13
  import { bgStart, bgStatus, bgStop, bgLogs, bgWaitPort } from "../supervisor.js";
14
14
  import { diffOutput } from "../diff-cache.js";
15
- import { listSessions, getSessionInteractions, getSessionStats } from "../sessions-db.js";
15
+ import { createSession, logInteraction, listSessions, getSessionInteractions, getSessionStats, getSessionEconomy } from "../sessions-db.js";
16
16
  import { cachedRead } from "../file-cache.js";
17
17
  import { getBootContext, invalidateBootCache } from "../session-boot.js";
18
18
  import { storeOutput, expandOutput } from "../expand-store.js";
@@ -57,8 +57,26 @@ function exec(command, cwd, timeout, allowRewrite = false) {
57
57
  export function createServer() {
58
58
  const server = new McpServer({
59
59
  name: "terminal",
60
- version: "0.2.0",
60
+ version: "3.4.0",
61
61
  });
62
+ // Create a session for this MCP server instance
63
+ const sessionId = createSession(process.cwd(), "mcp");
64
+ /** Log a tool call to sessions.db for economy tracking */
65
+ function logCall(tool, data) {
66
+ try {
67
+ logInteraction(sessionId, {
68
+ nl: `[mcp:${tool}]${data.command ? ` ${data.command.slice(0, 200)}` : ""}`,
69
+ command: data.command?.slice(0, 500),
70
+ exitCode: data.exitCode,
71
+ tokensUsed: data.aiProcessed ? (data.outputTokens ?? 0) : 0,
72
+ tokensSaved: data.tokensSaved ?? 0,
73
+ durationMs: data.durationMs,
74
+ model: data.model,
75
+ cached: false,
76
+ });
77
+ }
78
+ catch { } // never let logging break tool execution
79
+ }
62
80
  // ── execute: run a command, return structured result ──────────────────────
63
81
  server.tool("execute", "Run a shell command. Format guide: no format/raw for git commit/push (<50 tokens). format=compressed for long build output (CPU-only, no AI). format=json or format=summary for AI-summarized output (234ms, saves 80% tokens). Prefer execute_smart for most tasks.", {
64
82
  command: z.string().describe("Shell command to execute"),
@@ -67,15 +85,16 @@ export function createServer() {
67
85
  format: z.enum(["raw", "json", "compressed", "summary"]).optional().describe("Output format"),
68
86
  maxTokens: z.number().optional().describe("Token budget for compressed/summary format"),
69
87
  }, async ({ command, cwd, timeout, format, maxTokens }) => {
88
+ const start = Date.now();
70
89
  const result = await exec(command, cwd, timeout ?? 30000);
71
90
  const output = (result.stdout + result.stderr).trim();
72
91
  // Raw mode — with lazy execution for large results
73
92
  if (!format || format === "raw") {
74
93
  const clean = stripAnsi(output);
75
- // Lazy mode: if >100 lines, return count + sample instead of full output
76
94
  if (shouldBeLazy(clean, command)) {
77
95
  const lazy = toLazy(clean, command);
78
96
  const detailKey = storeOutput(command, clean);
97
+ logCall("execute", { command, outputTokens: estimateTokens(clean), tokensSaved: 0, durationMs: Date.now() - start, exitCode: result.exitCode });
79
98
  return {
80
99
  content: [{ type: "text", text: JSON.stringify({
81
100
  exitCode: result.exitCode, ...lazy, detailKey, duration: result.duration,
@@ -83,6 +102,7 @@ export function createServer() {
83
102
  }) }],
84
103
  };
85
104
  }
105
+ logCall("execute", { command, outputTokens: estimateTokens(clean), tokensSaved: 0, durationMs: Date.now() - start, exitCode: result.exitCode });
86
106
  return {
87
107
  content: [{ type: "text", text: JSON.stringify({
88
108
  exitCode: result.exitCode, output: clean, duration: result.duration, tokens: estimateTokens(clean),
@@ -95,6 +115,7 @@ export function createServer() {
95
115
  try {
96
116
  const processed = await processOutput(command, output);
97
117
  const detailKey = output.split("\n").length > 15 ? storeOutput(command, output) : undefined;
118
+ logCall("execute", { command, outputTokens: estimateTokens(output), tokensSaved: processed.tokensSaved, durationMs: Date.now() - start, exitCode: result.exitCode, aiProcessed: processed.aiProcessed });
98
119
  return {
99
120
  content: [{ type: "text", text: JSON.stringify({
100
121
  exitCode: result.exitCode,
@@ -109,6 +130,7 @@ export function createServer() {
109
130
  }
110
131
  catch {
111
132
  const compressed = compress(command, output, { maxTokens });
133
+ logCall("execute", { command, outputTokens: estimateTokens(output), tokensSaved: compressed.tokensSaved, durationMs: Date.now() - start, exitCode: result.exitCode });
112
134
  return {
113
135
  content: [{ type: "text", text: JSON.stringify({
114
136
  exitCode: result.exitCode, output: compressed.content, duration: result.duration,
@@ -135,11 +157,12 @@ export function createServer() {
135
157
  cwd: z.string().optional().describe("Working directory"),
136
158
  timeout: z.number().optional().describe("Timeout in ms (default: 30000)"),
137
159
  }, async ({ command, cwd, timeout }) => {
138
- const result = await exec(command, cwd, timeout ?? 30000, true); // allow rewrite for smart mode
160
+ const start = Date.now();
161
+ const result = await exec(command, cwd, timeout ?? 30000, true);
139
162
  const output = (result.stdout + result.stderr).trim();
140
163
  const processed = await processOutput(command, output);
141
- // Progressive disclosure: store full output, return summary + expand key
142
164
  const detailKey = output.split("\n").length > 15 ? storeOutput(command, output) : undefined;
165
+ logCall("execute_smart", { command, outputTokens: estimateTokens(output), tokensSaved: processed.tokensSaved, durationMs: Date.now() - start, exitCode: result.exitCode, aiProcessed: processed.aiProcessed });
143
166
  return {
144
167
  content: [{ type: "text", text: JSON.stringify({
145
168
  exitCode: result.exitCode,
@@ -217,7 +240,9 @@ export function createServer() {
217
240
  includeNodeModules: z.boolean().optional().describe("Include node_modules (default: false)"),
218
241
  maxResults: z.number().optional().describe("Max results per category (default: 50)"),
219
242
  }, async ({ pattern, path, includeNodeModules, maxResults }) => {
243
+ const start = Date.now();
220
244
  const result = await searchFiles(pattern, path ?? process.cwd(), { includeNodeModules, maxResults });
245
+ logCall("search_files", { command: `search_files ${pattern}`, tokensSaved: result.tokensSaved ?? 0, durationMs: Date.now() - start });
221
246
  return { content: [{ type: "text", text: JSON.stringify(result) }] };
222
247
  });
223
248
  // ── search_content: smart grep with grouping ──────────────────────────────
@@ -228,7 +253,9 @@ export function createServer() {
228
253
  maxResults: z.number().optional().describe("Max files to return (default: 30)"),
229
254
  contextLines: z.number().optional().describe("Context lines around matches (default: 0)"),
230
255
  }, async ({ pattern, path, fileType, maxResults, contextLines }) => {
256
+ const start = Date.now();
231
257
  const result = await searchContent(pattern, path ?? process.cwd(), { fileType, maxResults, contextLines });
258
+ logCall("search_content", { command: `grep ${pattern}`, tokensSaved: result.tokensSaved ?? 0, durationMs: Date.now() - start });
232
259
  return { content: [{ type: "text", text: JSON.stringify(result) }] };
233
260
  });
234
261
  // ── search_semantic: AST-powered code search ───────────────────────────────
@@ -340,6 +367,7 @@ export function createServer() {
340
367
  cwd: z.string().optional().describe("Working directory"),
341
368
  timeout: z.number().optional().describe("Timeout in ms"),
342
369
  }, async ({ command, cwd, timeout }) => {
370
+ const start = Date.now();
343
371
  const workDir = cwd ?? process.cwd();
344
372
  const result = await exec(command, workDir, timeout ?? 30000);
345
373
  const output = (result.stdout + result.stderr).trim();
@@ -347,6 +375,7 @@ export function createServer() {
347
375
  if (diff.tokensSaved > 0) {
348
376
  recordSaving("diff", diff.tokensSaved);
349
377
  }
378
+ logCall("execute_diff", { command, outputTokens: estimateTokens(output), tokensSaved: diff.tokensSaved, durationMs: Date.now() - start, exitCode: result.exitCode });
350
379
  if (diff.unchanged) {
351
380
  return { content: [{ type: "text", text: JSON.stringify({
352
381
  exitCode: result.exitCode, unchanged: true, diffSummary: diff.diffSummary,
@@ -388,7 +417,8 @@ export function createServer() {
388
417
  }
389
418
  if (action === "detail" && sessionId) {
390
419
  const interactions = getSessionInteractions(sessionId);
391
- return { content: [{ type: "text", text: JSON.stringify(interactions) }] };
420
+ const economy = getSessionEconomy(sessionId);
421
+ return { content: [{ type: "text", text: JSON.stringify({ interactions, economy }) }] };
392
422
  }
393
423
  const sessions = listSessions(limit ?? 20);
394
424
  return { content: [{ type: "text", text: JSON.stringify(sessions) }] };
@@ -453,9 +483,11 @@ export function createServer() {
453
483
  limit: z.number().optional().describe("Max lines to return"),
454
484
  summarize: z.boolean().optional().describe("Return AI summary instead of full content (saves ~90% tokens)"),
455
485
  }, async ({ path, offset, limit, summarize }) => {
486
+ const start = Date.now();
456
487
  const result = cachedRead(path, { offset, limit });
457
488
  if (summarize && result.content.length > 500) {
458
489
  const processed = await processOutput(`cat ${path}`, result.content);
490
+ logCall("read_file", { command: path, outputTokens: estimateTokens(result.content), tokensSaved: processed.tokensSaved, durationMs: Date.now() - start, aiProcessed: true });
459
491
  return {
460
492
  content: [{ type: "text", text: JSON.stringify({
461
493
  summary: processed.summary,
@@ -465,6 +497,7 @@ export function createServer() {
465
497
  }) }],
466
498
  };
467
499
  }
500
+ logCall("read_file", { command: path, outputTokens: estimateTokens(result.content), tokensSaved: 0, durationMs: Date.now() - start });
468
501
  return {
469
502
  content: [{ type: "text", text: JSON.stringify({
470
503
  content: result.content,
@@ -137,6 +137,45 @@ export function getSessionStats() {
137
137
  errorRate: totalInteractions > 0 ? (errors.c ?? 0) / totalInteractions : 0,
138
138
  };
139
139
  }
140
+ /** Get economy stats for a specific session */
141
+ export function getSessionEconomy(sessionId) {
142
+ const d = getDb();
143
+ const rows = d.prepare("SELECT nl, tokens_saved, tokens_used, duration_ms FROM interactions WHERE session_id = ?").all(sessionId);
144
+ const tools = {};
145
+ let totalSaved = 0, totalUsed = 0, aiCalls = 0, totalLatency = 0, latencyCount = 0;
146
+ for (const r of rows) {
147
+ totalSaved += r.tokens_saved ?? 0;
148
+ totalUsed += r.tokens_used ?? 0;
149
+ if (r.tokens_used > 0)
150
+ aiCalls++;
151
+ if (r.duration_ms) {
152
+ totalLatency += r.duration_ms;
153
+ latencyCount++;
154
+ }
155
+ // Extract tool name from nl field: [mcp:toolname] command
156
+ const toolMatch = r.nl.match(/^\[mcp:(\w+)\]/);
157
+ const tool = toolMatch?.[1] ?? "cli";
158
+ if (!tools[tool])
159
+ tools[tool] = { calls: 0, tokensSaved: 0 };
160
+ tools[tool].calls++;
161
+ tools[tool].tokensSaved += r.tokens_saved ?? 0;
162
+ }
163
+ // Savings at consumer model rates (×5 turns before compaction)
164
+ const multiplied = totalSaved * 5;
165
+ return {
166
+ totalCalls: rows.length,
167
+ tokensSaved: totalSaved,
168
+ tokensUsed: totalUsed,
169
+ aiCalls,
170
+ avgLatencyMs: latencyCount > 0 ? Math.round(totalLatency / latencyCount) : 0,
171
+ savingsUsd: {
172
+ opus: (multiplied * 15) / 1_000_000,
173
+ sonnet: (multiplied * 3) / 1_000_000,
174
+ haiku: (multiplied * 0.8) / 1_000_000,
175
+ },
176
+ tools,
177
+ };
178
+ }
140
179
  // ── Corrections ─────────────────────────────────────────────────────────────
141
180
  /** Record a correction: command failed, then AI retried with a better one */
142
181
  export function recordCorrection(prompt, failedCommand, errorOutput, correctedCommand, worked, errorType) {
@@ -164,6 +203,25 @@ export function findSimilarCorrections(prompt, limit = 5) {
164
203
  export function recordOutput(command, rawOutputPath, compressedSummary, tokensRaw, tokensCompressed, provider, model, sessionId) {
165
204
  getDb().prepare("INSERT INTO outputs (session_id, command, raw_output_path, compressed_summary, tokens_raw, tokens_compressed, provider, model, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)").run(sessionId ?? null, command, rawOutputPath ?? null, compressedSummary?.slice(0, 5000) ?? "", tokensRaw, tokensCompressed, provider ?? null, model ?? null, Date.now());
166
205
  }
206
+ /** Prune sessions and interactions older than N days */
207
+ export function pruneSessions(olderThanDays = 90) {
208
+ const d = getDb();
209
+ const cutoff = Date.now() - (olderThanDays * 24 * 60 * 60 * 1000);
210
+ const oldSessions = d.prepare("SELECT id FROM sessions WHERE started_at < ?").all(cutoff);
211
+ if (oldSessions.length === 0)
212
+ return { sessionsDeleted: 0, interactionsDeleted: 0 };
213
+ const ids = oldSessions.map(s => s.id);
214
+ const placeholders = ids.map(() => "?").join(",");
215
+ const intResult = d.prepare(`DELETE FROM interactions WHERE session_id IN (${placeholders})`).run(...ids);
216
+ const sesResult = d.prepare(`DELETE FROM sessions WHERE id IN (${placeholders})`).run(...ids);
217
+ // Also prune old corrections and outputs
218
+ d.prepare("DELETE FROM corrections WHERE created_at < ?").run(cutoff);
219
+ d.prepare("DELETE FROM outputs WHERE created_at < ?").run(cutoff);
220
+ return {
221
+ sessionsDeleted: oldSessions.length,
222
+ interactionsDeleted: intResult.changes ?? 0,
223
+ };
224
+ }
167
225
  /** Close the database connection */
168
226
  export function closeDb() {
169
227
  if (db) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hasna/terminal",
3
- "version": "3.3.9",
3
+ "version": "3.4.0",
4
4
  "description": "Smart terminal wrapper for AI agents and humans — structured output, token compression, MCP server, natural language",
5
5
  "type": "module",
6
6
  "files": [
package/src/cli.tsx CHANGED
@@ -83,6 +83,16 @@ if (args[0] === "uninstall") {
83
83
  process.exit(0);
84
84
  }
85
85
 
86
+ // ── Prune ────────────────────────────────────────────────────────────────────
87
+
88
+ if (args[0] === "prune") {
89
+ const days = parseInt(args.find(a => a.startsWith("--older-than="))?.split("=")[1] ?? "90");
90
+ const { pruneSessions } = await import("./sessions-db.js");
91
+ const result = pruneSessions(days);
92
+ console.log(` Pruned ${result.sessionsDeleted} sessions, ${result.interactionsDeleted} interactions older than ${days}d`);
93
+ process.exit(0);
94
+ }
95
+
86
96
  // ── MCP commands ─────────────────────────────────────────────────────────────
87
97
 
88
98
  if (args[0] === "mcp") {
package/src/mcp/server.ts CHANGED
@@ -13,7 +13,7 @@ import { listRecipes, listCollections, getRecipe, createRecipe } from "../recipe
13
13
  import { substituteVariables } from "../recipes/model.js";
14
14
  import { bgStart, bgStatus, bgStop, bgLogs, bgWaitPort } from "../supervisor.js";
15
15
  import { diffOutput } from "../diff-cache.js";
16
- import { listSessions, getSessionInteractions, getSessionStats } from "../sessions-db.js";
16
+ import { createSession, logInteraction, listSessions, getSessionInteractions, getSessionStats, getSessionEconomy } from "../sessions-db.js";
17
17
  import { cachedRead, cacheStats } from "../file-cache.js";
18
18
  import { getBootContext, invalidateBootCache } from "../session-boot.js";
19
19
  import { storeOutput, expandOutput } from "../expand-store.js";
@@ -62,9 +62,36 @@ function exec(command: string, cwd?: string, timeout?: number, allowRewrite: boo
62
62
  export function createServer(): McpServer {
63
63
  const server = new McpServer({
64
64
  name: "terminal",
65
- version: "0.2.0",
65
+ version: "3.4.0",
66
66
  });
67
67
 
68
+ // Create a session for this MCP server instance
69
+ const sessionId = createSession(process.cwd(), "mcp");
70
+
71
+ /** Log a tool call to sessions.db for economy tracking */
72
+ function logCall(tool: string, data: {
73
+ command?: string;
74
+ outputTokens?: number;
75
+ tokensSaved?: number;
76
+ durationMs?: number;
77
+ exitCode?: number;
78
+ aiProcessed?: boolean;
79
+ model?: string;
80
+ }) {
81
+ try {
82
+ logInteraction(sessionId, {
83
+ nl: `[mcp:${tool}]${data.command ? ` ${data.command.slice(0, 200)}` : ""}`,
84
+ command: data.command?.slice(0, 500),
85
+ exitCode: data.exitCode,
86
+ tokensUsed: data.aiProcessed ? (data.outputTokens ?? 0) : 0,
87
+ tokensSaved: data.tokensSaved ?? 0,
88
+ durationMs: data.durationMs,
89
+ model: data.model,
90
+ cached: false,
91
+ });
92
+ } catch {} // never let logging break tool execution
93
+ }
94
+
68
95
  // ── execute: run a command, return structured result ──────────────────────
69
96
 
70
97
  server.tool(
@@ -78,16 +105,17 @@ export function createServer(): McpServer {
78
105
  maxTokens: z.number().optional().describe("Token budget for compressed/summary format"),
79
106
  },
80
107
  async ({ command, cwd, timeout, format, maxTokens }) => {
108
+ const start = Date.now();
81
109
  const result = await exec(command, cwd, timeout ?? 30000);
82
110
  const output = (result.stdout + result.stderr).trim();
83
111
 
84
112
  // Raw mode — with lazy execution for large results
85
113
  if (!format || format === "raw") {
86
114
  const clean = stripAnsi(output);
87
- // Lazy mode: if >100 lines, return count + sample instead of full output
88
115
  if (shouldBeLazy(clean, command)) {
89
116
  const lazy = toLazy(clean, command);
90
117
  const detailKey = storeOutput(command, clean);
118
+ logCall("execute", { command, outputTokens: estimateTokens(clean), tokensSaved: 0, durationMs: Date.now() - start, exitCode: result.exitCode });
91
119
  return {
92
120
  content: [{ type: "text" as const, text: JSON.stringify({
93
121
  exitCode: result.exitCode, ...lazy, detailKey, duration: result.duration,
@@ -95,6 +123,7 @@ export function createServer(): McpServer {
95
123
  }) }],
96
124
  };
97
125
  }
126
+ logCall("execute", { command, outputTokens: estimateTokens(clean), tokensSaved: 0, durationMs: Date.now() - start, exitCode: result.exitCode });
98
127
  return {
99
128
  content: [{ type: "text" as const, text: JSON.stringify({
100
129
  exitCode: result.exitCode, output: clean, duration: result.duration, tokens: estimateTokens(clean),
@@ -108,6 +137,7 @@ export function createServer(): McpServer {
108
137
  try {
109
138
  const processed = await processOutput(command, output);
110
139
  const detailKey = output.split("\n").length > 15 ? storeOutput(command, output) : undefined;
140
+ logCall("execute", { command, outputTokens: estimateTokens(output), tokensSaved: processed.tokensSaved, durationMs: Date.now() - start, exitCode: result.exitCode, aiProcessed: processed.aiProcessed });
111
141
  return {
112
142
  content: [{ type: "text" as const, text: JSON.stringify({
113
143
  exitCode: result.exitCode,
@@ -121,6 +151,7 @@ export function createServer(): McpServer {
121
151
  };
122
152
  } catch {
123
153
  const compressed = compress(command, output, { maxTokens });
154
+ logCall("execute", { command, outputTokens: estimateTokens(output), tokensSaved: compressed.tokensSaved, durationMs: Date.now() - start, exitCode: result.exitCode });
124
155
  return {
125
156
  content: [{ type: "text" as const, text: JSON.stringify({
126
157
  exitCode: result.exitCode, output: compressed.content, duration: result.duration,
@@ -156,12 +187,13 @@ export function createServer(): McpServer {
156
187
  timeout: z.number().optional().describe("Timeout in ms (default: 30000)"),
157
188
  },
158
189
  async ({ command, cwd, timeout }) => {
159
- const result = await exec(command, cwd, timeout ?? 30000, true); // allow rewrite for smart mode
190
+ const start = Date.now();
191
+ const result = await exec(command, cwd, timeout ?? 30000, true);
160
192
  const output = (result.stdout + result.stderr).trim();
161
193
  const processed = await processOutput(command, output);
162
194
 
163
- // Progressive disclosure: store full output, return summary + expand key
164
195
  const detailKey = output.split("\n").length > 15 ? storeOutput(command, output) : undefined;
196
+ logCall("execute_smart", { command, outputTokens: estimateTokens(output), tokensSaved: processed.tokensSaved, durationMs: Date.now() - start, exitCode: result.exitCode, aiProcessed: processed.aiProcessed });
165
197
 
166
198
  return {
167
199
  content: [{ type: "text" as const, text: JSON.stringify({
@@ -274,7 +306,9 @@ export function createServer(): McpServer {
274
306
  maxResults: z.number().optional().describe("Max results per category (default: 50)"),
275
307
  },
276
308
  async ({ pattern, path, includeNodeModules, maxResults }) => {
309
+ const start = Date.now();
277
310
  const result = await searchFiles(pattern, path ?? process.cwd(), { includeNodeModules, maxResults });
311
+ logCall("search_files", { command: `search_files ${pattern}`, tokensSaved: (result as any).tokensSaved ?? 0, durationMs: Date.now() - start });
278
312
  return { content: [{ type: "text" as const, text: JSON.stringify(result) }] };
279
313
  }
280
314
  );
@@ -292,7 +326,9 @@ export function createServer(): McpServer {
292
326
  contextLines: z.number().optional().describe("Context lines around matches (default: 0)"),
293
327
  },
294
328
  async ({ pattern, path, fileType, maxResults, contextLines }) => {
329
+ const start = Date.now();
295
330
  const result = await searchContent(pattern, path ?? process.cwd(), { fileType, maxResults, contextLines });
331
+ logCall("search_content", { command: `grep ${pattern}`, tokensSaved: result.tokensSaved ?? 0, durationMs: Date.now() - start });
296
332
  return { content: [{ type: "text" as const, text: JSON.stringify(result) }] };
297
333
  }
298
334
  );
@@ -482,6 +518,7 @@ export function createServer(): McpServer {
482
518
  timeout: z.number().optional().describe("Timeout in ms"),
483
519
  },
484
520
  async ({ command, cwd, timeout }) => {
521
+ const start = Date.now();
485
522
  const workDir = cwd ?? process.cwd();
486
523
  const result = await exec(command, workDir, timeout ?? 30000);
487
524
  const output = (result.stdout + result.stderr).trim();
@@ -490,6 +527,7 @@ export function createServer(): McpServer {
490
527
  if (diff.tokensSaved > 0) {
491
528
  recordSaving("diff", diff.tokensSaved);
492
529
  }
530
+ logCall("execute_diff", { command, outputTokens: estimateTokens(output), tokensSaved: diff.tokensSaved, durationMs: Date.now() - start, exitCode: result.exitCode });
493
531
 
494
532
  if (diff.unchanged) {
495
533
  return { content: [{ type: "text" as const, text: JSON.stringify({
@@ -553,7 +591,8 @@ export function createServer(): McpServer {
553
591
  }
554
592
  if (action === "detail" && sessionId) {
555
593
  const interactions = getSessionInteractions(sessionId);
556
- return { content: [{ type: "text" as const, text: JSON.stringify(interactions) }] };
594
+ const economy = getSessionEconomy(sessionId);
595
+ return { content: [{ type: "text" as const, text: JSON.stringify({ interactions, economy }) }] };
557
596
  }
558
597
  const sessions = listSessions(limit ?? 20);
559
598
  return { content: [{ type: "text" as const, text: JSON.stringify(sessions) }] };
@@ -646,10 +685,12 @@ export function createServer(): McpServer {
646
685
  summarize: z.boolean().optional().describe("Return AI summary instead of full content (saves ~90% tokens)"),
647
686
  },
648
687
  async ({ path, offset, limit, summarize }) => {
688
+ const start = Date.now();
649
689
  const result = cachedRead(path, { offset, limit });
650
690
 
651
691
  if (summarize && result.content.length > 500) {
652
692
  const processed = await processOutput(`cat ${path}`, result.content);
693
+ logCall("read_file", { command: path, outputTokens: estimateTokens(result.content), tokensSaved: processed.tokensSaved, durationMs: Date.now() - start, aiProcessed: true });
653
694
  return {
654
695
  content: [{ type: "text" as const, text: JSON.stringify({
655
696
  summary: processed.summary,
@@ -660,6 +701,7 @@ export function createServer(): McpServer {
660
701
  };
661
702
  }
662
703
 
704
+ logCall("read_file", { command: path, outputTokens: estimateTokens(result.content), tokensSaved: 0, durationMs: Date.now() - start });
663
705
  return {
664
706
  content: [{ type: "text" as const, text: JSON.stringify({
665
707
  content: result.content,
@@ -212,6 +212,55 @@ export function getSessionStats(): SessionStats {
212
212
  };
213
213
  }
214
214
 
215
+ /** Get economy stats for a specific session */
216
+ export function getSessionEconomy(sessionId: string): {
217
+ totalCalls: number;
218
+ tokensSaved: number;
219
+ tokensUsed: number;
220
+ aiCalls: number;
221
+ avgLatencyMs: number;
222
+ savingsUsd: { opus: number; sonnet: number; haiku: number };
223
+ tools: Record<string, { calls: number; tokensSaved: number }>;
224
+ } {
225
+ const d = getDb();
226
+ const rows = d.prepare(
227
+ "SELECT nl, tokens_saved, tokens_used, duration_ms FROM interactions WHERE session_id = ?"
228
+ ).all(sessionId) as { nl: string; tokens_saved: number; tokens_used: number; duration_ms: number | null }[];
229
+
230
+ const tools: Record<string, { calls: number; tokensSaved: number }> = {};
231
+ let totalSaved = 0, totalUsed = 0, aiCalls = 0, totalLatency = 0, latencyCount = 0;
232
+
233
+ for (const r of rows) {
234
+ totalSaved += r.tokens_saved ?? 0;
235
+ totalUsed += r.tokens_used ?? 0;
236
+ if (r.tokens_used > 0) aiCalls++;
237
+ if (r.duration_ms) { totalLatency += r.duration_ms; latencyCount++; }
238
+
239
+ // Extract tool name from nl field: [mcp:toolname] command
240
+ const toolMatch = r.nl.match(/^\[mcp:(\w+)\]/);
241
+ const tool = toolMatch?.[1] ?? "cli";
242
+ if (!tools[tool]) tools[tool] = { calls: 0, tokensSaved: 0 };
243
+ tools[tool].calls++;
244
+ tools[tool].tokensSaved += r.tokens_saved ?? 0;
245
+ }
246
+
247
+ // Savings at consumer model rates (×5 turns before compaction)
248
+ const multiplied = totalSaved * 5;
249
+ return {
250
+ totalCalls: rows.length,
251
+ tokensSaved: totalSaved,
252
+ tokensUsed: totalUsed,
253
+ aiCalls,
254
+ avgLatencyMs: latencyCount > 0 ? Math.round(totalLatency / latencyCount) : 0,
255
+ savingsUsd: {
256
+ opus: (multiplied * 15) / 1_000_000,
257
+ sonnet: (multiplied * 3) / 1_000_000,
258
+ haiku: (multiplied * 0.8) / 1_000_000,
259
+ },
260
+ tools,
261
+ };
262
+ }
263
+
215
264
  // ── Corrections ─────────────────────────────────────────────────────────────
216
265
 
217
266
  /** Record a correction: command failed, then AI retried with a better one */
@@ -267,6 +316,30 @@ export function recordOutput(
267
316
  ).run(sessionId ?? null, command, rawOutputPath ?? null, compressedSummary?.slice(0, 5000) ?? "", tokensRaw, tokensCompressed, provider ?? null, model ?? null, Date.now());
268
317
  }
269
318
 
319
+ /** Prune sessions and interactions older than N days */
320
+ export function pruneSessions(olderThanDays: number = 90): { sessionsDeleted: number; interactionsDeleted: number } {
321
+ const d = getDb();
322
+ const cutoff = Date.now() - (olderThanDays * 24 * 60 * 60 * 1000);
323
+ const oldSessions = d.prepare("SELECT id FROM sessions WHERE started_at < ?").all(cutoff) as { id: string }[];
324
+
325
+ if (oldSessions.length === 0) return { sessionsDeleted: 0, interactionsDeleted: 0 };
326
+
327
+ const ids = oldSessions.map(s => s.id);
328
+ const placeholders = ids.map(() => "?").join(",");
329
+
330
+ const intResult = d.prepare(`DELETE FROM interactions WHERE session_id IN (${placeholders})`).run(...ids);
331
+ const sesResult = d.prepare(`DELETE FROM sessions WHERE id IN (${placeholders})`).run(...ids);
332
+
333
+ // Also prune old corrections and outputs
334
+ d.prepare("DELETE FROM corrections WHERE created_at < ?").run(cutoff);
335
+ d.prepare("DELETE FROM outputs WHERE created_at < ?").run(cutoff);
336
+
337
+ return {
338
+ sessionsDeleted: oldSessions.length,
339
+ interactionsDeleted: (intResult as any).changes ?? 0,
340
+ };
341
+ }
342
+
270
343
  /** Close the database connection */
271
344
  export function closeDb(): void {
272
345
  if (db) { db.close(); db = null; }