trickle-cli 0.1.194 → 0.1.196

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,12 @@
1
+ /**
2
+ * trickle cleanup — Smart data management for .trickle/ files.
3
+ *
4
+ * Prunes old data, compacts JSONL files, and manages retention.
5
+ * Essential for heavy workloads where agents produce 10-100x more data.
6
+ */
7
+ export declare function cleanupCommand(opts: {
8
+ retainDays?: string;
9
+ retainLines?: string;
10
+ dryRun?: boolean;
11
+ json?: boolean;
12
+ }): void;
@@ -0,0 +1,173 @@
1
+ "use strict";
2
+ /**
3
+ * trickle cleanup — Smart data management for .trickle/ files.
4
+ *
5
+ * Prunes old data, compacts JSONL files, and manages retention.
6
+ * Essential for heavy workloads where agents produce 10-100x more data.
7
+ */
8
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
9
+ if (k2 === undefined) k2 = k;
10
+ var desc = Object.getOwnPropertyDescriptor(m, k);
11
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
12
+ desc = { enumerable: true, get: function() { return m[k]; } };
13
+ }
14
+ Object.defineProperty(o, k2, desc);
15
+ }) : (function(o, m, k, k2) {
16
+ if (k2 === undefined) k2 = k;
17
+ o[k2] = m[k];
18
+ }));
19
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
20
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
21
+ }) : function(o, v) {
22
+ o["default"] = v;
23
+ });
24
+ var __importStar = (this && this.__importStar) || (function () {
25
+ var ownKeys = function(o) {
26
+ ownKeys = Object.getOwnPropertyNames || function (o) {
27
+ var ar = [];
28
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
29
+ return ar;
30
+ };
31
+ return ownKeys(o);
32
+ };
33
+ return function (mod) {
34
+ if (mod && mod.__esModule) return mod;
35
+ var result = {};
36
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
37
+ __setModuleDefault(result, mod);
38
+ return result;
39
+ };
40
+ })();
41
+ var __importDefault = (this && this.__importDefault) || function (mod) {
42
+ return (mod && mod.__esModule) ? mod : { "default": mod };
43
+ };
44
+ Object.defineProperty(exports, "__esModule", { value: true });
45
+ exports.cleanupCommand = cleanupCommand;
46
+ const fs = __importStar(require("fs"));
47
+ const path = __importStar(require("path"));
48
+ const chalk_1 = __importDefault(require("chalk"));
49
+ function cleanupCommand(opts) {
50
+ const dir = process.env.TRICKLE_LOCAL_DIR || path.join(process.cwd(), '.trickle');
51
+ if (!fs.existsSync(dir)) {
52
+ console.log(chalk_1.default.yellow(' No .trickle/ directory found.'));
53
+ return;
54
+ }
55
+ const retainDays = opts.retainDays ? parseInt(opts.retainDays, 10) : 7;
56
+ const retainLines = opts.retainLines ? parseInt(opts.retainLines, 10) : 0;
57
+ const cutoffMs = Date.now() - retainDays * 24 * 60 * 60 * 1000;
58
+ const dryRun = opts.dryRun || false;
59
+ const jsonlFiles = [
60
+ 'observations.jsonl', 'variables.jsonl', 'calltrace.jsonl',
61
+ 'queries.jsonl', 'errors.jsonl', 'llm.jsonl', 'agents.jsonl',
62
+ 'mcp.jsonl', 'logs.jsonl', 'console.jsonl', 'traces.jsonl',
63
+ 'alerts.jsonl', 'profile.jsonl',
64
+ ];
65
+ let totalBefore = 0;
66
+ let totalAfter = 0;
67
+ let totalLinesRemoved = 0;
68
+ let filesProcessed = 0;
69
+ const details = [];
70
+ for (const file of jsonlFiles) {
71
+ const filePath = path.join(dir, file);
72
+ if (!fs.existsSync(filePath))
73
+ continue;
74
+ const content = fs.readFileSync(filePath, 'utf-8');
75
+ const beforeSize = Buffer.byteLength(content);
76
+ totalBefore += beforeSize;
77
+ const lines = content.split('\n').filter(Boolean);
78
+ let kept;
79
+ if (retainLines > 0) {
80
+ // Keep only the last N lines
81
+ kept = lines.slice(-retainLines);
82
+ }
83
+ else {
84
+ // Keep lines with timestamp newer than cutoff
85
+ kept = lines.filter(line => {
86
+ try {
87
+ const obj = JSON.parse(line);
88
+ const ts = obj.timestamp || 0;
89
+ // If timestamp is in seconds (< 2000000000), convert to ms
90
+ const tsMs = ts < 2_000_000_000 ? ts * 1000 : ts;
91
+ // Keep if no timestamp (can't determine age) or if newer than cutoff
92
+ return !ts || tsMs > cutoffMs;
93
+ }
94
+ catch {
95
+ return true; // Keep unparseable lines
96
+ }
97
+ });
98
+ }
99
+ const removed = lines.length - kept.length;
100
+ totalLinesRemoved += removed;
101
+ const newContent = kept.length > 0 ? kept.join('\n') + '\n' : '';
102
+ const afterSize = Buffer.byteLength(newContent);
103
+ totalAfter += afterSize;
104
+ filesProcessed++;
105
+ details.push({ file, before: beforeSize, after: afterSize, removed });
106
+ if (!dryRun && removed > 0) {
107
+ fs.writeFileSync(filePath, newContent, 'utf-8');
108
+ }
109
+ }
110
+ // Also clean up snapshot directory if it exists and is old
111
+ const snapshotDir = path.join(dir, 'snapshot');
112
+ if (fs.existsSync(snapshotDir)) {
113
+ try {
114
+ const stat = fs.statSync(snapshotDir);
115
+ if (stat.mtimeMs < cutoffMs && !dryRun) {
116
+ fs.rmSync(snapshotDir, { recursive: true });
117
+ details.push({ file: 'snapshot/', before: 0, after: 0, removed: -1 });
118
+ }
119
+ }
120
+ catch { }
121
+ }
122
+ // Clean up CSV export directory
123
+ const csvDir = path.join(dir, 'csv');
124
+ if (fs.existsSync(csvDir) && !dryRun) {
125
+ try {
126
+ const stat = fs.statSync(csvDir);
127
+ if (stat.mtimeMs < cutoffMs) {
128
+ fs.rmSync(csvDir, { recursive: true });
129
+ }
130
+ }
131
+ catch { }
132
+ }
133
+ const result = {
134
+ filesProcessed,
135
+ linesRemoved: totalLinesRemoved,
136
+ bytesBefore: totalBefore,
137
+ bytesAfter: totalAfter,
138
+ bytesSaved: totalBefore - totalAfter,
139
+ };
140
+ if (opts.json) {
141
+ console.log(JSON.stringify({ ...result, details, dryRun, retainDays }, null, 2));
142
+ return;
143
+ }
144
+ console.log('');
145
+ console.log(chalk_1.default.bold(` trickle cleanup${dryRun ? ' (dry run)' : ''}`));
146
+ console.log(chalk_1.default.gray(' ' + '─'.repeat(50)));
147
+ console.log(` Retention: ${retainLines > 0 ? `last ${retainLines} lines per file` : `${retainDays} days`}`);
148
+ console.log(` Files scanned: ${filesProcessed}`);
149
+ if (totalLinesRemoved === 0) {
150
+ console.log(chalk_1.default.green(' No data to prune — all data is within retention window.'));
151
+ }
152
+ else {
153
+ console.log(` Lines removed: ${chalk_1.default.yellow(String(totalLinesRemoved))}`);
154
+ console.log(` Space: ${formatBytes(totalBefore)} → ${formatBytes(totalAfter)} (${chalk_1.default.green('saved ' + formatBytes(result.bytesSaved))})`);
155
+ if (dryRun) {
156
+ console.log(chalk_1.default.yellow('\n Dry run — no files modified. Remove --dry-run to apply.'));
157
+ }
158
+ else {
159
+ for (const d of details.filter(d => d.removed > 0)) {
160
+ console.log(chalk_1.default.gray(` ${d.file}: ${d.removed} lines removed`));
161
+ }
162
+ }
163
+ }
164
+ console.log(chalk_1.default.gray(' ' + '─'.repeat(50)));
165
+ console.log('');
166
+ }
167
+ function formatBytes(bytes) {
168
+ if (bytes >= 1_000_000)
169
+ return (bytes / 1_000_000).toFixed(1) + 'MB';
170
+ if (bytes >= 1_000)
171
+ return (bytes / 1_000).toFixed(1) + 'KB';
172
+ return bytes + 'B';
173
+ }
@@ -147,10 +147,46 @@ function costReportCommand(opts) {
147
147
  }
148
148
  }
149
149
  }
150
+ // Model tier analysis — classify models into frontier/standard/mini tiers
151
+ // Ordered longest-first to avoid substring matches (gpt-4o-mini before gpt-4o)
152
+ const TIER_RULES = [
153
+ ['gpt-4o-mini', 'mini'], ['gpt-4-turbo', 'frontier'], ['gpt-4o', 'standard'], ['gpt-4', 'frontier'],
154
+ ['gpt-3.5-turbo', 'mini'], ['o1-mini', 'standard'], ['o1-pro', 'frontier'], ['o1', 'frontier'],
155
+ ['o3-mini', 'standard'], ['o3', 'frontier'], ['o4-mini', 'standard'],
156
+ ['claude-opus', 'frontier'], ['claude-sonnet', 'standard'], ['claude-haiku', 'mini'],
157
+ ['gemini-2.5-flash-lite', 'mini'], ['gemini-2.5-flash', 'standard'], ['gemini-2.5-pro', 'frontier'],
158
+ ['gemini-2.0-flash', 'mini'], ['gemini-1.5-pro', 'frontier'], ['gemini-1.5-flash', 'mini'],
159
+ ];
160
+ function classifyTier(model) {
161
+ for (const [pattern, tier] of TIER_RULES) {
162
+ if (model.includes(pattern))
163
+ return tier;
164
+ }
165
+ if (model.includes('mini') || model.includes('lite') || model.includes('haiku') || model.includes('flash'))
166
+ return 'mini';
167
+ if (model.includes('pro') || model.includes('opus') || model.includes('turbo'))
168
+ return 'frontier';
169
+ return 'standard';
170
+ }
171
+ const byTier = {};
172
+ for (const c of calls) {
173
+ const tier = classifyTier(c.model || '');
174
+ if (!byTier[tier])
175
+ byTier[tier] = { calls: 0, tokens: 0, cost: 0, avgLatency: 0, errors: 0 };
176
+ byTier[tier].calls++;
177
+ byTier[tier].tokens += c.totalTokens || 0;
178
+ byTier[tier].cost += c.estimatedCostUsd || 0;
179
+ byTier[tier].avgLatency += c.durationMs || 0;
180
+ if (c.error)
181
+ byTier[tier].errors++;
182
+ }
183
+ for (const t of Object.values(byTier)) {
184
+ t.avgLatency = t.calls > 0 ? t.avgLatency / t.calls : 0;
185
+ }
150
186
  if (opts.json) {
151
187
  console.log(JSON.stringify({
152
188
  summary: { totalCost, totalTokens, totalInputTokens, totalOutputTokens, totalCalls: calls.length, totalDurationMs: totalDuration, errors: errorCount, monthlyProjection },
153
- byProvider, byModel,
189
+ byProvider, byModel, byTier,
154
190
  ...(Object.keys(byAgent).length > 0 ? { byAgent } : {}),
155
191
  }, null, 2));
156
192
  return;
@@ -195,6 +231,27 @@ function costReportCommand(opts) {
195
231
  }
196
232
  // Top costly calls
197
233
  const costlyCalls = calls.filter(c => c.estimatedCostUsd > 0).sort((a, b) => b.estimatedCostUsd - a.estimatedCostUsd).slice(0, 5);
234
+ // By tier
235
+ if (Object.keys(byTier).length > 1) {
236
+ console.log(chalk_1.default.gray('\n ' + '─'.repeat(60)));
237
+ console.log(chalk_1.default.bold(' Model Tier Analysis'));
238
+ const tierOrder = ['frontier', 'standard', 'mini'];
239
+ const tierLabels = { frontier: '🔴 Frontier', standard: '🟡 Standard', mini: '🟢 Mini' };
240
+ for (const tier of tierOrder) {
241
+ const data = byTier[tier];
242
+ if (!data)
243
+ continue;
244
+ const pct = totalCost > 0 ? ((data.cost / totalCost) * 100).toFixed(0) : '0';
245
+ const callPct = calls.length > 0 ? ((data.calls / calls.length) * 100).toFixed(0) : '0';
246
+ const errRate = data.calls > 0 ? ((data.errors / data.calls) * 100).toFixed(0) : '0';
247
+ console.log(` ${(tierLabels[tier] || tier).padEnd(16)} $${data.cost.toFixed(4).padEnd(10)} ${chalk_1.default.gray(pct + '% cost')} ${data.calls} calls (${callPct}%) avg ${data.avgLatency.toFixed(0)}ms ${data.errors > 0 ? chalk_1.default.red(errRate + '% err') : chalk_1.default.green('0% err')}`);
248
+ }
249
+ // Tier optimization suggestion
250
+ const frontierPct = byTier.frontier ? (byTier.frontier.calls / calls.length) * 100 : 0;
251
+ if (frontierPct > 50) {
252
+ console.log(chalk_1.default.yellow(` 💡 ${frontierPct.toFixed(0)}% of calls use frontier models. Consider routing simple tasks to mini tier for ~75% savings.`));
253
+ }
254
+ }
198
255
  // By agent (if agent data exists)
199
256
  if (Object.keys(byAgent).length > 0) {
200
257
  console.log(chalk_1.default.gray('\n ' + '─'.repeat(60)));
package/dist/index.js CHANGED
@@ -920,6 +920,18 @@ program
920
920
  const { whyCommand } = await Promise.resolve().then(() => __importStar(require("./commands/why")));
921
921
  whyCommand(query, opts);
922
922
  });
923
+ // trickle cleanup
924
+ program
925
+ .command("cleanup")
926
+ .description("Prune old .trickle/ data — manage retention for heavy workloads")
927
+ .option("--retain-days <days>", "Keep data from last N days (default: 7)")
928
+ .option("--retain-lines <lines>", "Keep only last N lines per file (overrides --retain-days)")
929
+ .option("--dry-run", "Show what would be removed without modifying files")
930
+ .option("--json", "Output structured JSON")
931
+ .action(async (opts) => {
932
+ const { cleanupCommand } = await Promise.resolve().then(() => __importStar(require("./commands/cleanup")));
933
+ cleanupCommand(opts);
934
+ });
923
935
  // trickle eval
924
936
  program
925
937
  .command("eval")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trickle-cli",
3
- "version": "0.1.194",
3
+ "version": "0.1.196",
4
4
  "description": "CLI for trickle runtime type observability",
5
5
  "bin": {
6
6
  "trickle": "dist/index.js"
@@ -0,0 +1,162 @@
1
+ /**
2
+ * trickle cleanup — Smart data management for .trickle/ files.
3
+ *
4
+ * Prunes old data, compacts JSONL files, and manages retention.
5
+ * Essential for heavy workloads where agents produce 10-100x more data.
6
+ */
7
+
8
+ import * as fs from 'fs';
9
+ import * as path from 'path';
10
+ import chalk from 'chalk';
11
+
12
+ interface CleanupResult {
13
+ filesProcessed: number;
14
+ linesRemoved: number;
15
+ bytesBefore: number;
16
+ bytesAfter: number;
17
+ bytesSaved: number;
18
+ }
19
+
20
+ export function cleanupCommand(opts: {
21
+ retainDays?: string;
22
+ retainLines?: string;
23
+ dryRun?: boolean;
24
+ json?: boolean;
25
+ }): void {
26
+ const dir = process.env.TRICKLE_LOCAL_DIR || path.join(process.cwd(), '.trickle');
27
+
28
+ if (!fs.existsSync(dir)) {
29
+ console.log(chalk.yellow(' No .trickle/ directory found.'));
30
+ return;
31
+ }
32
+
33
+ const retainDays = opts.retainDays ? parseInt(opts.retainDays, 10) : 7;
34
+ const retainLines = opts.retainLines ? parseInt(opts.retainLines, 10) : 0;
35
+ const cutoffMs = Date.now() - retainDays * 24 * 60 * 60 * 1000;
36
+ const dryRun = opts.dryRun || false;
37
+
38
+ const jsonlFiles = [
39
+ 'observations.jsonl', 'variables.jsonl', 'calltrace.jsonl',
40
+ 'queries.jsonl', 'errors.jsonl', 'llm.jsonl', 'agents.jsonl',
41
+ 'mcp.jsonl', 'logs.jsonl', 'console.jsonl', 'traces.jsonl',
42
+ 'alerts.jsonl', 'profile.jsonl',
43
+ ];
44
+
45
+ let totalBefore = 0;
46
+ let totalAfter = 0;
47
+ let totalLinesRemoved = 0;
48
+ let filesProcessed = 0;
49
+
50
+ const details: Array<{ file: string; before: number; after: number; removed: number }> = [];
51
+
52
+ for (const file of jsonlFiles) {
53
+ const filePath = path.join(dir, file);
54
+ if (!fs.existsSync(filePath)) continue;
55
+
56
+ const content = fs.readFileSync(filePath, 'utf-8');
57
+ const beforeSize = Buffer.byteLength(content);
58
+ totalBefore += beforeSize;
59
+
60
+ const lines = content.split('\n').filter(Boolean);
61
+ let kept: string[];
62
+
63
+ if (retainLines > 0) {
64
+ // Keep only the last N lines
65
+ kept = lines.slice(-retainLines);
66
+ } else {
67
+ // Keep lines with timestamp newer than cutoff
68
+ kept = lines.filter(line => {
69
+ try {
70
+ const obj = JSON.parse(line);
71
+ const ts = obj.timestamp || 0;
72
+ // If timestamp is in seconds (< 2000000000), convert to ms
73
+ const tsMs = ts < 2_000_000_000 ? ts * 1000 : ts;
74
+ // Keep if no timestamp (can't determine age) or if newer than cutoff
75
+ return !ts || tsMs > cutoffMs;
76
+ } catch {
77
+ return true; // Keep unparseable lines
78
+ }
79
+ });
80
+ }
81
+
82
+ const removed = lines.length - kept.length;
83
+ totalLinesRemoved += removed;
84
+
85
+ const newContent = kept.length > 0 ? kept.join('\n') + '\n' : '';
86
+ const afterSize = Buffer.byteLength(newContent);
87
+ totalAfter += afterSize;
88
+ filesProcessed++;
89
+
90
+ details.push({ file, before: beforeSize, after: afterSize, removed });
91
+
92
+ if (!dryRun && removed > 0) {
93
+ fs.writeFileSync(filePath, newContent, 'utf-8');
94
+ }
95
+ }
96
+
97
+ // Also clean up snapshot directory if it exists and is old
98
+ const snapshotDir = path.join(dir, 'snapshot');
99
+ if (fs.existsSync(snapshotDir)) {
100
+ try {
101
+ const stat = fs.statSync(snapshotDir);
102
+ if (stat.mtimeMs < cutoffMs && !dryRun) {
103
+ fs.rmSync(snapshotDir, { recursive: true });
104
+ details.push({ file: 'snapshot/', before: 0, after: 0, removed: -1 });
105
+ }
106
+ } catch {}
107
+ }
108
+
109
+ // Clean up CSV export directory
110
+ const csvDir = path.join(dir, 'csv');
111
+ if (fs.existsSync(csvDir) && !dryRun) {
112
+ try {
113
+ const stat = fs.statSync(csvDir);
114
+ if (stat.mtimeMs < cutoffMs) {
115
+ fs.rmSync(csvDir, { recursive: true });
116
+ }
117
+ } catch {}
118
+ }
119
+
120
+ const result: CleanupResult = {
121
+ filesProcessed,
122
+ linesRemoved: totalLinesRemoved,
123
+ bytesBefore: totalBefore,
124
+ bytesAfter: totalAfter,
125
+ bytesSaved: totalBefore - totalAfter,
126
+ };
127
+
128
+ if (opts.json) {
129
+ console.log(JSON.stringify({ ...result, details, dryRun, retainDays }, null, 2));
130
+ return;
131
+ }
132
+
133
+ console.log('');
134
+ console.log(chalk.bold(` trickle cleanup${dryRun ? ' (dry run)' : ''}`));
135
+ console.log(chalk.gray(' ' + '─'.repeat(50)));
136
+ console.log(` Retention: ${retainLines > 0 ? `last ${retainLines} lines per file` : `${retainDays} days`}`);
137
+ console.log(` Files scanned: ${filesProcessed}`);
138
+
139
+ if (totalLinesRemoved === 0) {
140
+ console.log(chalk.green(' No data to prune — all data is within retention window.'));
141
+ } else {
142
+ console.log(` Lines removed: ${chalk.yellow(String(totalLinesRemoved))}`);
143
+ console.log(` Space: ${formatBytes(totalBefore)} → ${formatBytes(totalAfter)} (${chalk.green('saved ' + formatBytes(result.bytesSaved))})`);
144
+
145
+ if (dryRun) {
146
+ console.log(chalk.yellow('\n Dry run — no files modified. Remove --dry-run to apply.'));
147
+ } else {
148
+ for (const d of details.filter(d => d.removed > 0)) {
149
+ console.log(chalk.gray(` ${d.file}: ${d.removed} lines removed`));
150
+ }
151
+ }
152
+ }
153
+
154
+ console.log(chalk.gray(' ' + '─'.repeat(50)));
155
+ console.log('');
156
+ }
157
+
158
+ function formatBytes(bytes: number): string {
159
+ if (bytes >= 1_000_000) return (bytes / 1_000_000).toFixed(1) + 'MB';
160
+ if (bytes >= 1_000) return (bytes / 1_000).toFixed(1) + 'KB';
161
+ return bytes + 'B';
162
+ }
@@ -122,10 +122,44 @@ export function costReportCommand(opts: { json?: boolean; budget?: string }): vo
122
122
  }
123
123
  }
124
124
 
125
+ // Model tier analysis — classify models into frontier/standard/mini tiers
126
+ // Ordered longest-first to avoid substring matches (gpt-4o-mini before gpt-4o)
127
+ const TIER_RULES: Array<[string, string]> = [
128
+ ['gpt-4o-mini', 'mini'], ['gpt-4-turbo', 'frontier'], ['gpt-4o', 'standard'], ['gpt-4', 'frontier'],
129
+ ['gpt-3.5-turbo', 'mini'], ['o1-mini', 'standard'], ['o1-pro', 'frontier'], ['o1', 'frontier'],
130
+ ['o3-mini', 'standard'], ['o3', 'frontier'], ['o4-mini', 'standard'],
131
+ ['claude-opus', 'frontier'], ['claude-sonnet', 'standard'], ['claude-haiku', 'mini'],
132
+ ['gemini-2.5-flash-lite', 'mini'], ['gemini-2.5-flash', 'standard'], ['gemini-2.5-pro', 'frontier'],
133
+ ['gemini-2.0-flash', 'mini'], ['gemini-1.5-pro', 'frontier'], ['gemini-1.5-flash', 'mini'],
134
+ ];
135
+
136
+ function classifyTier(model: string): string {
137
+ for (const [pattern, tier] of TIER_RULES) {
138
+ if (model.includes(pattern)) return tier;
139
+ }
140
+ if (model.includes('mini') || model.includes('lite') || model.includes('haiku') || model.includes('flash')) return 'mini';
141
+ if (model.includes('pro') || model.includes('opus') || model.includes('turbo')) return 'frontier';
142
+ return 'standard';
143
+ }
144
+
145
+ const byTier: Record<string, { calls: number; tokens: number; cost: number; avgLatency: number; errors: number }> = {};
146
+ for (const c of calls) {
147
+ const tier = classifyTier(c.model || '');
148
+ if (!byTier[tier]) byTier[tier] = { calls: 0, tokens: 0, cost: 0, avgLatency: 0, errors: 0 };
149
+ byTier[tier].calls++;
150
+ byTier[tier].tokens += c.totalTokens || 0;
151
+ byTier[tier].cost += c.estimatedCostUsd || 0;
152
+ byTier[tier].avgLatency += c.durationMs || 0;
153
+ if (c.error) byTier[tier].errors++;
154
+ }
155
+ for (const t of Object.values(byTier)) {
156
+ t.avgLatency = t.calls > 0 ? t.avgLatency / t.calls : 0;
157
+ }
158
+
125
159
  if (opts.json) {
126
160
  console.log(JSON.stringify({
127
161
  summary: { totalCost, totalTokens, totalInputTokens, totalOutputTokens, totalCalls: calls.length, totalDurationMs: totalDuration, errors: errorCount, monthlyProjection },
128
- byProvider, byModel,
162
+ byProvider, byModel, byTier,
129
163
  ...(Object.keys(byAgent).length > 0 ? { byAgent } : {}),
130
164
  }, null, 2));
131
165
  return;
@@ -175,6 +209,27 @@ export function costReportCommand(opts: { json?: boolean; budget?: string }): vo
175
209
 
176
210
  // Top costly calls
177
211
  const costlyCalls = calls.filter(c => c.estimatedCostUsd > 0).sort((a, b) => b.estimatedCostUsd - a.estimatedCostUsd).slice(0, 5);
212
+ // By tier
213
+ if (Object.keys(byTier).length > 1) {
214
+ console.log(chalk.gray('\n ' + '─'.repeat(60)));
215
+ console.log(chalk.bold(' Model Tier Analysis'));
216
+ const tierOrder = ['frontier', 'standard', 'mini'];
217
+ const tierLabels: Record<string, string> = { frontier: '🔴 Frontier', standard: '🟡 Standard', mini: '🟢 Mini' };
218
+ for (const tier of tierOrder) {
219
+ const data = byTier[tier];
220
+ if (!data) continue;
221
+ const pct = totalCost > 0 ? ((data.cost / totalCost) * 100).toFixed(0) : '0';
222
+ const callPct = calls.length > 0 ? ((data.calls / calls.length) * 100).toFixed(0) : '0';
223
+ const errRate = data.calls > 0 ? ((data.errors / data.calls) * 100).toFixed(0) : '0';
224
+ console.log(` ${(tierLabels[tier] || tier).padEnd(16)} $${data.cost.toFixed(4).padEnd(10)} ${chalk.gray(pct + '% cost')} ${data.calls} calls (${callPct}%) avg ${data.avgLatency.toFixed(0)}ms ${data.errors > 0 ? chalk.red(errRate + '% err') : chalk.green('0% err')}`);
225
+ }
226
+ // Tier optimization suggestion
227
+ const frontierPct = byTier.frontier ? (byTier.frontier.calls / calls.length) * 100 : 0;
228
+ if (frontierPct > 50) {
229
+ console.log(chalk.yellow(` 💡 ${frontierPct.toFixed(0)}% of calls use frontier models. Consider routing simple tasks to mini tier for ~75% savings.`));
230
+ }
231
+ }
232
+
178
233
  // By agent (if agent data exists)
179
234
  if (Object.keys(byAgent).length > 0) {
180
235
  console.log(chalk.gray('\n ' + '─'.repeat(60)));
package/src/index.ts CHANGED
@@ -953,6 +953,19 @@ program
953
953
  whyCommand(query, opts);
954
954
  });
955
955
 
956
+ // trickle cleanup
957
+ program
958
+ .command("cleanup")
959
+ .description("Prune old .trickle/ data — manage retention for heavy workloads")
960
+ .option("--retain-days <days>", "Keep data from last N days (default: 7)")
961
+ .option("--retain-lines <lines>", "Keep only last N lines per file (overrides --retain-days)")
962
+ .option("--dry-run", "Show what would be removed without modifying files")
963
+ .option("--json", "Output structured JSON")
964
+ .action(async (opts) => {
965
+ const { cleanupCommand } = await import("./commands/cleanup");
966
+ cleanupCommand(opts);
967
+ });
968
+
956
969
  // trickle eval
957
970
  program
958
971
  .command("eval")