clementine-agent 1.1.9 → 1.1.10

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.
@@ -16,6 +16,8 @@ export interface ToolFamilyStats {
16
16
  /** Family label — collapses mcp__ subnames into one bucket per server. */
17
17
  family: string;
18
18
  totalCalls: number;
19
+ /** Estimated cost attributed to this family (USD). Heuristic — see attributeCostsToToolUses. */
20
+ estimatedCostUsd: number;
19
21
  /** Per-tool breakdown within the family, sorted by count desc. */
20
22
  byTool: Array<{
21
23
  tool: string;
@@ -35,6 +37,9 @@ export interface ToolUsageReport {
35
37
  families: ToolFamilyStats[];
36
38
  /** Total cost (sum of query_complete events) over the window — context for tool counts. */
37
39
  totalCostUsd: number;
40
+ /** Sum of cost attributed to tool calls (≤ totalCostUsd). The gap is the cost of
41
+ * query_completes whose tool calls fell outside the window or weren't logged. */
42
+ attributedCostUsd: number;
38
43
  }
39
44
  /**
40
45
  * Family normalization. Built-in SDK tools keep their name; MCP tools are
@@ -42,6 +42,45 @@ export function classifyToolFamily(toolName) {
42
42
  };
43
43
  return BUILTIN_FAMILIES[toolName] ?? toolName;
44
44
  }
45
+ /**
46
+ * Time-proximity cost attribution. Audit events don't carry an explicit
47
+ * query_id linking tool_use to query_complete, so we group by a sliding
48
+ * window: tool_use events that occur AFTER the previous query_complete
49
+ * (or the window start) and AT-OR-BEFORE the next query_complete are
50
+ * attributed to that query. The query's cost is then divided evenly
51
+ * across the tool calls in its window.
52
+ *
53
+ * Caveats:
54
+ * - Concurrent queries (e.g. cron + chat in the same window) will mix.
55
+ * Best-effort heuristic, not exact accounting.
56
+ * - Tool calls without a closing query_complete in the window get
57
+ * attributed nothing — captured in the gap between totalCostUsd
58
+ * and attributedCostUsd in the report.
59
+ * - The even-distribution assumption ignores per-call cost variance
60
+ * (a single Bash that consumed 50k tokens vs a Read that consumed
61
+ * 200). For our purposes (aggregate "where is my budget going?")
62
+ * this is good enough — actionable to within ~15% per family.
63
+ */
64
+ function attributeCostsToToolUses(events) {
65
+ const perToolCost = new Map();
66
+ let pendingToolIndices = [];
67
+ for (const e of events) {
68
+ if (!e.isQueryComplete) {
69
+ if (e.toolEntryIndex !== undefined)
70
+ pendingToolIndices.push(e.toolEntryIndex);
71
+ continue;
72
+ }
73
+ // Query closed — distribute cost.
74
+ if (pendingToolIndices.length > 0 && typeof e.cost_usd === 'number' && Number.isFinite(e.cost_usd)) {
75
+ const perCall = e.cost_usd / pendingToolIndices.length;
76
+ for (const idx of pendingToolIndices) {
77
+ perToolCost.set(idx, (perToolCost.get(idx) ?? 0) + perCall);
78
+ }
79
+ }
80
+ pendingToolIndices = [];
81
+ }
82
+ return perToolCost;
83
+ }
45
84
  /**
46
85
  * Aggregate tool_use + query_complete events from audit.jsonl over the
47
86
  * given window. Window bounds are ISO strings; entries outside are ignored.
@@ -53,17 +92,20 @@ export function classifyToolFamily(toolName) {
53
92
  export function buildToolUsageReport(auditLogPath, windowStart, windowEnd) {
54
93
  const startMs = Date.parse(windowStart);
55
94
  const endMs = Date.parse(windowEnd);
56
- // family → { totalCalls, perTool: Map<string,count>, perSource: Map<string,count> }
95
+ // family → { totalCalls, totalCost, perTool, perSource }
57
96
  const families = new Map();
58
97
  let totalToolCalls = 0;
59
98
  let totalQueries = 0;
60
99
  let totalCost = 0;
61
100
  if (!existsSync(auditLogPath)) {
62
- return { windowStart, windowEnd, totalToolCalls: 0, totalQueries: 0, families: [], totalCostUsd: 0 };
101
+ return {
102
+ windowStart, windowEnd, totalToolCalls: 0, totalQueries: 0,
103
+ families: [], totalCostUsd: 0, attributedCostUsd: 0,
104
+ };
63
105
  }
64
- // Stream-friendly read — each line is independent JSON. Audit logs are
65
- // typically a few MB; readFileSync is fine at that scale.
66
106
  const raw = readFileSync(auditLogPath, 'utf-8');
107
+ const toolEntries = [];
108
+ const sequence = [];
67
109
  for (const line of raw.split('\n')) {
68
110
  if (!line)
69
111
  continue;
@@ -84,27 +126,40 @@ export function buildToolUsageReport(auditLogPath, windowStart, windowEnd) {
84
126
  if (entry.event_type === 'tool_use' && entry.tool_name) {
85
127
  const family = classifyToolFamily(entry.tool_name);
86
128
  const source = entry.job || entry.source || 'unknown';
87
- let bucket = families.get(family);
88
- if (!bucket) {
89
- bucket = { totalCalls: 0, perTool: new Map(), perSource: new Map() };
90
- families.set(family, bucket);
91
- }
92
- bucket.totalCalls++;
93
- bucket.perTool.set(entry.tool_name, (bucket.perTool.get(entry.tool_name) ?? 0) + 1);
94
- bucket.perSource.set(source, (bucket.perSource.get(source) ?? 0) + 1);
129
+ toolEntries.push({ family, source, toolName: entry.tool_name });
130
+ sequence.push({ ts: tsMs, isQueryComplete: false, toolEntryIndex: toolEntries.length - 1 });
95
131
  totalToolCalls++;
96
132
  }
97
133
  else if (entry.event_type === 'query_complete') {
98
134
  totalQueries++;
99
- if (typeof entry.cost_usd === 'number' && Number.isFinite(entry.cost_usd)) {
100
- totalCost += entry.cost_usd;
101
- }
135
+ const cost = typeof entry.cost_usd === 'number' && Number.isFinite(entry.cost_usd) ? entry.cost_usd : 0;
136
+ totalCost += cost;
137
+ sequence.push({ ts: tsMs, isQueryComplete: true, cost_usd: cost });
138
+ }
139
+ }
140
+ // Second pass: attribute each query's cost across its preceding tool_use events.
141
+ const perToolCost = attributeCostsToToolUses(sequence);
142
+ let attributedCost = 0;
143
+ // Third pass: bucket toolEntries into family stats, summing attributed cost.
144
+ for (let i = 0; i < toolEntries.length; i++) {
145
+ const t = toolEntries[i];
146
+ const cost = perToolCost.get(i) ?? 0;
147
+ attributedCost += cost;
148
+ let bucket = families.get(t.family);
149
+ if (!bucket) {
150
+ bucket = { totalCalls: 0, totalCost: 0, perTool: new Map(), perSource: new Map() };
151
+ families.set(t.family, bucket);
102
152
  }
153
+ bucket.totalCalls++;
154
+ bucket.totalCost += cost;
155
+ bucket.perTool.set(t.toolName, (bucket.perTool.get(t.toolName) ?? 0) + 1);
156
+ bucket.perSource.set(t.source, (bucket.perSource.get(t.source) ?? 0) + 1);
103
157
  }
104
158
  const familyStats = [...families.entries()]
105
159
  .map(([family, b]) => ({
106
160
  family,
107
161
  totalCalls: b.totalCalls,
162
+ estimatedCostUsd: Number(b.totalCost.toFixed(4)),
108
163
  byTool: [...b.perTool.entries()]
109
164
  .map(([tool, count]) => ({ tool, count }))
110
165
  .sort((a, c) => c.count - a.count),
@@ -112,7 +167,8 @@ export function buildToolUsageReport(auditLogPath, windowStart, windowEnd) {
112
167
  .map(([source, count]) => ({ source, count }))
113
168
  .sort((a, c) => c.count - a.count),
114
169
  }))
115
- .sort((a, b) => b.totalCalls - a.totalCalls);
170
+ // Sort by cost first (the actionable signal); fall back to call count.
171
+ .sort((a, b) => b.estimatedCostUsd - a.estimatedCostUsd || b.totalCalls - a.totalCalls);
116
172
  return {
117
173
  windowStart,
118
174
  windowEnd,
@@ -120,6 +176,7 @@ export function buildToolUsageReport(auditLogPath, windowStart, windowEnd) {
120
176
  totalQueries,
121
177
  families: familyStats,
122
178
  totalCostUsd: Number(totalCost.toFixed(4)),
179
+ attributedCostUsd: Number(attributedCost.toFixed(4)),
123
180
  };
124
181
  }
125
182
  /** Default audit log path — passed-through for CLI default + tests. */
package/dist/cli/index.js CHANGED
@@ -1292,7 +1292,7 @@ async function cmdAnalyticsToolUsage(opts) {
1292
1292
  console.log(` ${BOLD}Window:${RESET} last ${hours}h ${DIM}(${start.toISOString()} → ${end.toISOString()})${RESET}`);
1293
1293
  console.log(` ${BOLD}Total tool calls:${RESET} ${report.totalToolCalls.toLocaleString()}`);
1294
1294
  console.log(` ${BOLD}Total queries:${RESET} ${report.totalQueries.toLocaleString()}`);
1295
- console.log(` ${BOLD}Total cost:${RESET} ${GREEN}$${report.totalCostUsd.toFixed(4)}${RESET}`);
1295
+ console.log(` ${BOLD}Total cost:${RESET} ${GREEN}$${report.totalCostUsd.toFixed(4)}${RESET} ${DIM}(attributed to tools: $${report.attributedCostUsd.toFixed(4)})${RESET}`);
1296
1296
  console.log();
1297
1297
  if (report.families.length === 0) {
1298
1298
  console.log(` ${DIM}No tool_use events in window.${RESET}`);
@@ -1300,23 +1300,24 @@ async function cmdAnalyticsToolUsage(opts) {
1300
1300
  return;
1301
1301
  }
1302
1302
  const top = report.families.slice(0, limit);
1303
- const maxCalls = Math.max(...top.map(f => f.totalCalls));
1303
+ const maxCost = Math.max(...top.map(f => f.estimatedCostUsd), 0.0001);
1304
1304
  const familyWidth = Math.max(...top.map(f => f.family.length), 12);
1305
- console.log(` ${BOLD}Top ${top.length} tool families${RESET}`);
1305
+ console.log(` ${BOLD}Top ${top.length} tool families ${DIM}(ranked by attributed cost)${RESET}`);
1306
1306
  for (const f of top) {
1307
- const pct = report.totalToolCalls > 0
1308
- ? ((f.totalCalls / report.totalToolCalls) * 100).toFixed(1)
1307
+ const pct = report.attributedCostUsd > 0
1308
+ ? ((f.estimatedCostUsd / report.attributedCostUsd) * 100).toFixed(1)
1309
1309
  : '0.0';
1310
- const barLen = Math.round((f.totalCalls / maxCalls) * 28);
1311
- const bar = '█'.repeat(barLen).padEnd(28);
1310
+ const barLen = Math.round((f.estimatedCostUsd / maxCost) * 24);
1311
+ const bar = '█'.repeat(barLen).padEnd(24);
1312
1312
  console.log(` ${CYAN}${f.family.padEnd(familyWidth)}${RESET} ` +
1313
- `${String(f.totalCalls).padStart(5)} ${DIM}calls${RESET} ` +
1314
- `${pct.padStart(5)}% ${YELLOW}${bar}${RESET}`);
1315
- // Top 2 individual tools within each family + top source
1313
+ `${GREEN}$${f.estimatedCostUsd.toFixed(2).padStart(7)}${RESET} ` +
1314
+ `${pct.padStart(5)}% ` +
1315
+ `${DIM}${String(f.totalCalls).padStart(5)} calls${RESET} ` +
1316
+ `${YELLOW}${bar}${RESET}`);
1316
1317
  const topTools = f.byTool.slice(0, 2).map(t => `${t.tool}×${t.count}`).join(', ');
1317
1318
  const topSource = f.bySource[0];
1318
1319
  console.log(` ${DIM}top tools: ${topTools}${RESET}`);
1319
- if (topSource) {
1320
+ if (topSource && topSource.source !== 'unknown') {
1320
1321
  console.log(` ${DIM}driven by: ${topSource.source} (${topSource.count} calls)${RESET}`);
1321
1322
  }
1322
1323
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clementine-agent",
3
- "version": "1.1.9",
3
+ "version": "1.1.10",
4
4
  "description": "Clementine — Personal AI Assistant (TypeScript)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",