@oh-my-pi/omp-stats 16.3.0 → 16.3.2

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/src/db.ts CHANGED
@@ -19,6 +19,11 @@ import type {
19
19
  ModelStats,
20
20
  ModelTimeSeriesPoint,
21
21
  TimeSeriesPoint,
22
+ ToolCallStats,
23
+ ToolModelStats,
24
+ ToolResultLink,
25
+ ToolTimeSeriesPoint,
26
+ ToolUsageStats,
22
27
  UserMessageLink,
23
28
  UserMessageStats,
24
29
  } from "./types";
@@ -46,6 +51,7 @@ const USER_MESSAGE_LINKS_REPAIR_KEY = "user_message_links_v1";
46
51
  const PRIORITY_PREMIUM_REQUESTS_BACKFILL_KEY = "premium_requests_priority_v1";
47
52
  const AGENT_TYPE_BACKFILL_KEY = "agent_type_v1";
48
53
  const FORK_DEDUPE_KEY = "fork_dedupe_v1";
54
+ const TOOL_CALLS_BACKFILL_KEY = "tool_calls_v1";
49
55
  function shouldResetBackfill(value: string | undefined): boolean {
50
56
  return value !== BACKFILL_COMPLETE && value !== BACKFILL_PENDING;
51
57
  }
@@ -135,6 +141,27 @@ export async function initDb(): Promise<Database> {
135
141
  CREATE INDEX IF NOT EXISTS idx_user_messages_timestamp ON user_messages(timestamp);
136
142
  CREATE INDEX IF NOT EXISTS idx_user_messages_timestamp_model ON user_messages(timestamp, model, provider);
137
143
 
144
+ CREATE TABLE IF NOT EXISTS tool_calls (
145
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
146
+ session_file TEXT NOT NULL,
147
+ entry_id TEXT NOT NULL,
148
+ tool_call_id TEXT NOT NULL,
149
+ folder TEXT NOT NULL,
150
+ tool_name TEXT NOT NULL,
151
+ model TEXT NOT NULL,
152
+ provider TEXT NOT NULL,
153
+ timestamp INTEGER NOT NULL,
154
+ agent_type TEXT NOT NULL DEFAULT 'main',
155
+ calls_in_turn INTEGER NOT NULL DEFAULT 1,
156
+ args_chars INTEGER NOT NULL DEFAULT 0,
157
+ result_chars INTEGER,
158
+ is_error INTEGER,
159
+ UNIQUE(session_file, tool_call_id)
160
+ );
161
+
162
+ CREATE INDEX IF NOT EXISTS idx_tool_calls_timestamp ON tool_calls(timestamp);
163
+ CREATE INDEX IF NOT EXISTS idx_tool_calls_tool_timestamp ON tool_calls(tool_name, timestamp);
164
+
138
165
  CREATE TABLE IF NOT EXISTS meta (
139
166
  key TEXT PRIMARY KEY,
140
167
  value TEXT NOT NULL
@@ -215,6 +242,7 @@ export async function initDb(): Promise<Database> {
215
242
  `);
216
243
  }
217
244
  backfillUserMessages(db);
245
+ backfillToolCalls(db);
218
246
  repairUserMessageLinks(db);
219
247
  backfillPriorityPremiumRequests(db);
220
248
  backfillAgentType(db);
@@ -881,6 +909,27 @@ function backfillUserMessages(database: Database): void {
881
909
  .run(USER_MESSAGES_BACKFILL_KEY, BACKFILL_PENDING);
882
910
  }
883
911
 
912
+ /**
913
+ * One-shot wipe of `tool_calls` + `file_offsets` when the `tool_calls` table
914
+ * is introduced (or its schema version bumps), so the next sync re-parses
915
+ * every session and ingests historical tool calls. `messages` and
916
+ * `user_messages` re-inserts are idempotent, so the offset reset is safe.
917
+ * Same sentinel protocol as {@link backfillUserMessages}: the PENDING value
918
+ * written here prevents re-wiping on subsequent inits.
919
+ */
920
+ function backfillToolCalls(database: Database): void {
921
+ const row = database.prepare("SELECT value FROM meta WHERE key = ?").get(TOOL_CALLS_BACKFILL_KEY) as
922
+ | { value: string }
923
+ | undefined;
924
+ if (!shouldResetBackfill(row?.value)) return;
925
+
926
+ database.run("DELETE FROM tool_calls");
927
+ database.run("DELETE FROM file_offsets");
928
+ database
929
+ .prepare("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)")
930
+ .run(TOOL_CALLS_BACKFILL_KEY, BACKFILL_PENDING);
931
+ }
932
+
884
933
  /**
885
934
  * Reclassify pre-existing `messages` rows by agent type once, after the
886
935
  * `agent_type` column is added to an older database (every prior row defaulted
@@ -1276,3 +1325,206 @@ export function getBehaviorByModel(cutoff?: number | null): BehaviorModelStats[]
1276
1325
  lastTimestamp: row.last_timestamp ?? 0,
1277
1326
  }));
1278
1327
  }
1328
+
1329
+ /**
1330
+ * Insert tool-call rows. Idempotent via UNIQUE(session_file, tool_call_id);
1331
+ * the `WHERE NOT EXISTS` guard mirrors {@link insertMessageStats}: forked
1332
+ * sessions deep-copy assistant entries (same `entry_id`, `timestamp`, and
1333
+ * tool-call ids under a new file), so first-write-wins across the lineage
1334
+ * keeps aggregates from double counting. Keyed on the assistant entry
1335
+ * identity, not the call id alone — provider call ids are not a global
1336
+ * namespace across unrelated sessions.
1337
+ */
1338
+ export function insertToolCalls(calls: ToolCallStats[]): number {
1339
+ if (!db || calls.length === 0) return 0;
1340
+
1341
+ const stmt = db.prepare(`
1342
+ INSERT OR IGNORE INTO tool_calls (
1343
+ session_file, entry_id, tool_call_id, folder, tool_name,
1344
+ model, provider, timestamp, agent_type, calls_in_turn, args_chars
1345
+ )
1346
+ SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
1347
+ WHERE NOT EXISTS (
1348
+ SELECT 1 FROM tool_calls
1349
+ WHERE entry_id = ? AND timestamp = ? AND tool_call_id = ? AND session_file <> ?
1350
+ )
1351
+ `);
1352
+
1353
+ let inserted = 0;
1354
+ const insert = db.transaction(() => {
1355
+ for (const c of calls) {
1356
+ const result = stmt.run(
1357
+ c.sessionFile,
1358
+ c.entryId,
1359
+ c.toolCallId,
1360
+ c.folder,
1361
+ c.toolName,
1362
+ c.model,
1363
+ c.provider,
1364
+ c.timestamp,
1365
+ c.agentType,
1366
+ c.callsInTurn,
1367
+ c.argsChars,
1368
+ // `WHERE NOT EXISTS` binds: skip when a different session_file
1369
+ // already holds this (entry_id, timestamp, tool_call_id).
1370
+ c.entryId,
1371
+ c.timestamp,
1372
+ c.toolCallId,
1373
+ c.sessionFile,
1374
+ );
1375
+ if (result.changes > 0) inserted++;
1376
+ }
1377
+ });
1378
+ insert();
1379
+ return inserted;
1380
+ }
1381
+
1382
+ /**
1383
+ * Attach result size / error flag to persisted tool-call rows. Results can
1384
+ * land in a later incremental sync pass than the call that produced them, so
1385
+ * this is an UPDATE keyed by (session_file, tool_call_id). The `IS NULL`
1386
+ * guard makes re-syncs idempotent; rows skipped by the fork guard simply
1387
+ * never match.
1388
+ */
1389
+ export function updateToolResults(links: ToolResultLink[]): number {
1390
+ if (!db || links.length === 0) return 0;
1391
+
1392
+ const stmt = db.prepare(`
1393
+ UPDATE tool_calls
1394
+ SET result_chars = ?, is_error = ?
1395
+ WHERE session_file = ? AND tool_call_id = ? AND result_chars IS NULL
1396
+ `);
1397
+
1398
+ let updated = 0;
1399
+ const apply = db.transaction(() => {
1400
+ for (const link of links) {
1401
+ const result = stmt.run(link.resultChars, link.isError ? 1 : 0, link.sessionFile, link.toolCallId);
1402
+ updated += result.changes;
1403
+ }
1404
+ });
1405
+ apply();
1406
+ return updated;
1407
+ }
1408
+
1409
+ /**
1410
+ * Shared SELECT list for tool aggregates. Real provider usage comes from the
1411
+ * invoking assistant turn (`messages` join) divided by `calls_in_turn`, so
1412
+ * per-tool token/cost shares stay additive across tools.
1413
+ */
1414
+ const TOOL_AGGREGATE_COLUMNS = `
1415
+ COUNT(*) as calls,
1416
+ SUM(CASE WHEN t.is_error = 1 THEN 1 ELSE 0 END) as errors,
1417
+ SUM(t.args_chars) as args_chars,
1418
+ SUM(COALESCE(t.result_chars, 0)) as result_chars,
1419
+ SUM(COALESCE(m.total_tokens, 0) * 1.0 / t.calls_in_turn) as total_tokens_share,
1420
+ SUM(COALESCE(m.output_tokens, 0) * 1.0 / t.calls_in_turn) as output_tokens_share,
1421
+ SUM(COALESCE(m.cost_total, 0) / t.calls_in_turn) as cost_share,
1422
+ MAX(t.timestamp) as last_used
1423
+ `;
1424
+
1425
+ interface ToolAggregateRow {
1426
+ tool_name: string;
1427
+ model?: string;
1428
+ provider?: string;
1429
+ calls: number;
1430
+ errors: number;
1431
+ args_chars: number | null;
1432
+ result_chars: number | null;
1433
+ total_tokens_share: number | null;
1434
+ output_tokens_share: number | null;
1435
+ cost_share: number | null;
1436
+ last_used: number;
1437
+ }
1438
+
1439
+ function rowToToolUsage(row: ToolAggregateRow): ToolUsageStats {
1440
+ return {
1441
+ tool: row.tool_name,
1442
+ calls: row.calls,
1443
+ errors: row.errors,
1444
+ argsChars: row.args_chars ?? 0,
1445
+ resultChars: row.result_chars ?? 0,
1446
+ totalTokensShare: row.total_tokens_share ?? 0,
1447
+ outputTokensShare: row.output_tokens_share ?? 0,
1448
+ costShare: row.cost_share ?? 0,
1449
+ lastUsed: row.last_used,
1450
+ };
1451
+ }
1452
+
1453
+ /**
1454
+ * Get tool usage aggregated by tool name.
1455
+ */
1456
+ export function getToolStats(cutoff?: number): ToolUsageStats[] {
1457
+ if (!db) return [];
1458
+
1459
+ const hasCutoff = cutoff !== undefined && cutoff > 0;
1460
+ const stmt = db.prepare(`
1461
+ SELECT t.tool_name, ${TOOL_AGGREGATE_COLUMNS}
1462
+ FROM tool_calls t
1463
+ LEFT JOIN messages m ON m.session_file = t.session_file AND m.entry_id = t.entry_id
1464
+ ${hasCutoff ? "WHERE t.timestamp >= ?" : ""}
1465
+ GROUP BY t.tool_name
1466
+ ORDER BY calls DESC
1467
+ `);
1468
+
1469
+ const rows = (hasCutoff ? stmt.all(cutoff) : stmt.all()) as ToolAggregateRow[];
1470
+ return rows.map(rowToToolUsage);
1471
+ }
1472
+
1473
+ /**
1474
+ * Get tool usage aggregated by (tool, model, provider).
1475
+ */
1476
+ export function getToolStatsByModel(cutoff?: number): ToolModelStats[] {
1477
+ if (!db) return [];
1478
+
1479
+ const hasCutoff = cutoff !== undefined && cutoff > 0;
1480
+ const stmt = db.prepare(`
1481
+ SELECT t.tool_name, t.model, t.provider, ${TOOL_AGGREGATE_COLUMNS}
1482
+ FROM tool_calls t
1483
+ LEFT JOIN messages m ON m.session_file = t.session_file AND m.entry_id = t.entry_id
1484
+ ${hasCutoff ? "WHERE t.timestamp >= ?" : ""}
1485
+ GROUP BY t.tool_name, t.model, t.provider
1486
+ ORDER BY calls DESC
1487
+ `);
1488
+
1489
+ const rows = (hasCutoff ? stmt.all(cutoff) : stmt.all()) as ToolAggregateRow[];
1490
+ return rows.map(row => ({
1491
+ ...rowToToolUsage(row),
1492
+ model: row.model ?? "",
1493
+ provider: row.provider ?? "",
1494
+ }));
1495
+ }
1496
+
1497
+ /**
1498
+ * Get tool-call time series (one point per bucket per tool).
1499
+ */
1500
+ export function getToolTimeSeries(
1501
+ days = 14,
1502
+ cutoff?: number | null,
1503
+ bucketMs = 24 * 60 * 60 * 1000,
1504
+ ): ToolTimeSeriesPoint[] {
1505
+ if (!db) return [];
1506
+
1507
+ const hasCutoff = cutoff !== null;
1508
+ const seriesCutoff = hasCutoff ? (cutoff ?? Date.now() - days * 24 * 60 * 60 * 1000) : 0;
1509
+
1510
+ const stmt = db.prepare(`
1511
+ SELECT
1512
+ (timestamp / ?) * ? as bucket,
1513
+ tool_name,
1514
+ COUNT(*) as calls,
1515
+ SUM(CASE WHEN is_error = 1 THEN 1 ELSE 0 END) as errors
1516
+ FROM tool_calls
1517
+ ${hasCutoff ? "WHERE timestamp >= ?" : ""}
1518
+ GROUP BY bucket, tool_name
1519
+ ORDER BY bucket ASC
1520
+ `);
1521
+
1522
+ const rowsRaw = hasCutoff ? stmt.all(bucketMs, bucketMs, seriesCutoff) : stmt.all(bucketMs, bucketMs);
1523
+ const rows = rowsRaw as Array<{ bucket: number; tool_name: string; calls: number; errors: number }>;
1524
+ return rows.map(row => ({
1525
+ timestamp: row.bucket,
1526
+ tool: row.tool_name,
1527
+ calls: row.calls,
1528
+ errors: row.errors,
1529
+ }));
1530
+ }
package/src/index.ts CHANGED
@@ -8,6 +8,7 @@ import { startServer } from "./server";
8
8
 
9
9
  export {
10
10
  getDashboardStats,
11
+ getToolDashboardStats,
11
12
  getTotalMessageCount,
12
13
  type SyncOptions,
13
14
  type SyncProgress,
@@ -32,6 +33,10 @@ export type {
32
33
  ModelStats,
33
34
  ModelTimeSeriesPoint,
34
35
  TimeSeriesPoint,
36
+ ToolDashboardStats,
37
+ ToolModelStats,
38
+ ToolTimeSeriesPoint,
39
+ ToolUsageStats,
35
40
  } from "./types";
36
41
 
37
42
  /**
package/src/parser.ts CHANGED
@@ -6,6 +6,7 @@ import {
6
6
  getPriorityPremiumRequests,
7
7
  resolveModelServiceTier,
8
8
  type ServiceTierByFamily,
9
+ type ToolResultMessage,
9
10
  } from "@oh-my-pi/pi-ai";
10
11
  import { getSessionsDir, isEnoent } from "@oh-my-pi/pi-utils";
11
12
  import type {
@@ -14,6 +15,8 @@ import type {
14
15
  SessionEntry,
15
16
  SessionMessageEntry,
16
17
  SessionServiceTierChangeEntry,
18
+ ToolCallStats,
19
+ ToolResultLink,
17
20
  UserMessageLink,
18
21
  UserMessageStats,
19
22
  } from "./types";
@@ -85,6 +88,14 @@ function isServiceTierChange(entry: SessionEntry): entry is SessionServiceTierCh
85
88
  return entry.type === "service_tier_change";
86
89
  }
87
90
 
91
+ /**
92
+ * Check if an entry is a tool-result message.
93
+ */
94
+ function isToolResultMessage(entry: SessionEntry): entry is SessionMessageEntry {
95
+ if (entry.type !== "message") return false;
96
+ return (entry as SessionMessageEntry).message?.role === "toolResult";
97
+ }
98
+
88
99
  /**
89
100
  * Extract plain text from a user message content payload.
90
101
  */
@@ -171,6 +182,66 @@ function extractStats(
171
182
  };
172
183
  }
173
184
 
185
+ /**
186
+ * Extract one {@link ToolCallStats} per `toolCall` content block of an
187
+ * assistant message. Returns an empty array for turns without tool calls.
188
+ */
189
+ function extractToolCalls(
190
+ sessionFile: string,
191
+ folder: string,
192
+ entry: SessionMessageEntry,
193
+ agentType: AgentType,
194
+ ): ToolCallStats[] {
195
+ const msg = entry.message as AssistantMessage;
196
+ if (msg?.role !== "assistant" || !Array.isArray(msg.content)) return [];
197
+
198
+ const blocks = msg.content.filter(block => block.type === "toolCall");
199
+ if (blocks.length === 0) return [];
200
+
201
+ return blocks.map(block => {
202
+ let argsChars = 0;
203
+ try {
204
+ argsChars = JSON.stringify(block.arguments ?? {}).length;
205
+ } catch {
206
+ // Non-serializable arguments (shouldn't happen in persisted JSONL); size unknown.
207
+ }
208
+ return {
209
+ sessionFile,
210
+ entryId: entry.id,
211
+ toolCallId: block.id,
212
+ folder,
213
+ toolName: block.name,
214
+ model: msg.model,
215
+ provider: msg.provider,
216
+ timestamp: msg.timestamp,
217
+ agentType,
218
+ callsInTurn: blocks.length,
219
+ argsChars,
220
+ };
221
+ });
222
+ }
223
+
224
+ /**
225
+ * Build the result linkage for a `toolResult` entry: text characters fed back
226
+ * into context plus the error flag, keyed to the originating call.
227
+ */
228
+ function extractToolResultLink(sessionFile: string, entry: SessionMessageEntry): ToolResultLink | null {
229
+ const msg = entry.message as ToolResultMessage;
230
+ if (msg.role !== "toolResult" || typeof msg.toolCallId !== "string" || msg.toolCallId.length === 0) return null;
231
+ let resultChars = 0;
232
+ if (Array.isArray(msg.content)) {
233
+ for (const block of msg.content) {
234
+ if (block.type === "text" && typeof block.text === "string") resultChars += block.text.length;
235
+ }
236
+ }
237
+ return {
238
+ sessionFile,
239
+ toolCallId: msg.toolCallId,
240
+ resultChars,
241
+ isError: msg.isError === true,
242
+ };
243
+ }
244
+
174
245
  const LF = 0x0a;
175
246
  const CR = 0x0d;
176
247
  const jsonLineDecoder = new TextDecoder();
@@ -241,6 +312,8 @@ export interface ParseSessionResult {
241
312
  stats: MessageStats[];
242
313
  userStats: UserMessageStats[];
243
314
  userLinks: UserMessageLink[];
315
+ toolCalls: ToolCallStats[];
316
+ toolResults: ToolResultLink[];
244
317
  newOffset: number;
245
318
  }
246
319
  export async function parseSessionFile(sessionPath: string, fromOffset = 0): Promise<ParseSessionResult> {
@@ -248,7 +321,8 @@ export async function parseSessionFile(sessionPath: string, fromOffset = 0): Pro
248
321
  try {
249
322
  bytes = await Bun.file(sessionPath).bytes();
250
323
  } catch (err) {
251
- if (isEnoent(err)) return { stats: [], userStats: [], userLinks: [], newOffset: fromOffset };
324
+ if (isEnoent(err))
325
+ return { stats: [], userStats: [], userLinks: [], toolCalls: [], toolResults: [], newOffset: fromOffset };
252
326
  throw err;
253
327
  }
254
328
 
@@ -257,6 +331,8 @@ export async function parseSessionFile(sessionPath: string, fromOffset = 0): Pro
257
331
  const stats: MessageStats[] = [];
258
332
  const userStats: UserMessageStats[] = [];
259
333
  const userLinks: UserMessageLink[] = [];
334
+ const toolCalls: ToolCallStats[] = [];
335
+ const toolResults: ToolResultLink[] = [];
260
336
  const userByEntryId = new Map<string, UserMessageStats>();
261
337
  const start = Math.max(0, Math.min(fromOffset, bytes.length));
262
338
  const unprocessed = bytes.subarray(start);
@@ -278,9 +354,15 @@ export async function parseSessionFile(sessionPath: string, fromOffset = 0): Pro
278
354
  }
279
355
  continue;
280
356
  }
357
+ if (isToolResultMessage(entry)) {
358
+ const link = extractToolResultLink(sessionPath, entry);
359
+ if (link) toolResults.push(link);
360
+ continue;
361
+ }
281
362
  if (isAssistantMessage(entry)) {
282
363
  const msgStats = extractStats(sessionPath, folder, entry, currentServiceTier, agentType);
283
364
  if (msgStats) stats.push(msgStats);
365
+ toolCalls.push(...extractToolCalls(sessionPath, folder, entry, agentType));
284
366
  // Link assistant's responding model back to the user message it answered.
285
367
  const parentId = (entry as SessionMessageEntry).parentId;
286
368
  if (parentId) {
@@ -303,7 +385,7 @@ export async function parseSessionFile(sessionPath: string, fromOffset = 0): Pro
303
385
  }
304
386
  }
305
387
 
306
- return { stats, userStats, userLinks, newOffset: start + read };
388
+ return { stats, userStats, userLinks, toolCalls, toolResults, newOffset: start + read };
307
389
  }
308
390
 
309
391
  /**
package/src/server.ts CHANGED
@@ -13,6 +13,7 @@ import {
13
13
  getRecentErrors,
14
14
  getRecentRequests,
15
15
  getRequestDetails,
16
+ getToolDashboardStats,
16
17
  getTotalMessageCount,
17
18
  syncAllSessions,
18
19
  } from "./aggregator";
@@ -215,6 +216,11 @@ async function handleApi(req: Request): Promise<Response> {
215
216
  return Response.json(stats);
216
217
  }
217
218
 
219
+ if (path === "/api/stats/tools") {
220
+ const stats = await getToolDashboardStats(range);
221
+ return Response.json(stats);
222
+ }
223
+
218
224
  if (path === "/api/stats/recent") {
219
225
  const limit = url.searchParams.get("limit");
220
226
  const stats = await getRecentRequests(limit ? parseInt(limit, 10) : undefined);
@@ -269,3 +269,55 @@ export interface GainDashboardStats {
269
269
  /** All distinct projects seen in the data, for the selector. */
270
270
  projects: string[];
271
271
  }
272
+
273
+ /**
274
+ * Aggregated usage for a single tool over the active range.
275
+ *
276
+ * Token/cost fields are the *real* provider usage of the assistant turns that
277
+ * invoked the tool, split evenly across that turn's tool calls so the numbers
278
+ * stay additive (a turn with 3 calls contributes a third of its usage to each
279
+ * tool). Payload fields (`argsChars`/`resultChars`) are raw character counts
280
+ * of the serialized arguments and the text fed back into context — a size
281
+ * proxy, not provider-counted tokens.
282
+ */
283
+ export interface ToolUsageStats {
284
+ /** Tool name as recorded on the tool call. */
285
+ tool: string;
286
+ /** Number of tool calls. */
287
+ calls: number;
288
+ /** Calls whose result came back with `isError`. */
289
+ errors: number;
290
+ /** Serialized tool-call argument characters. */
291
+ argsChars: number;
292
+ /** Text characters of tool results fed back into context. */
293
+ resultChars: number;
294
+ /** Total provider tokens of invoking turns, attributed per call share. */
295
+ totalTokensShare: number;
296
+ /** Output tokens of invoking turns, attributed per call share. */
297
+ outputTokensShare: number;
298
+ /** Cost (USD) of invoking turns, attributed per call share. */
299
+ costShare: number;
300
+ /** Unix ms of the most recent call in range. */
301
+ lastUsed: number;
302
+ }
303
+
304
+ /** Per-(tool, model) breakdown with the same attribution as {@link ToolUsageStats}. */
305
+ export interface ToolModelStats extends ToolUsageStats {
306
+ model: string;
307
+ provider: string;
308
+ }
309
+
310
+ /** Tool-call time-series point (one bucket per tool). */
311
+ export interface ToolTimeSeriesPoint {
312
+ timestamp: number;
313
+ tool: string;
314
+ calls: number;
315
+ errors: number;
316
+ }
317
+
318
+ /** Complete tools dashboard payload. */
319
+ export interface ToolDashboardStats {
320
+ byTool: ToolUsageStats[];
321
+ byToolModel: ToolModelStats[];
322
+ series: ToolTimeSeriesPoint[];
323
+ }
package/src/types.ts CHANGED
@@ -126,3 +126,46 @@ export interface UserMessageLink {
126
126
  model: string;
127
127
  provider: string;
128
128
  }
129
+
130
+ /**
131
+ * One tool call extracted from an assistant message's `toolCall` content
132
+ * blocks. `callsInTurn` records how many calls that assistant turn contained
133
+ * so aggregation can split the turn's real provider usage evenly per call.
134
+ */
135
+ export interface ToolCallStats {
136
+ /** Session file path */
137
+ sessionFile: string;
138
+ /** Assistant-message entry ID that emitted the call */
139
+ entryId: string;
140
+ /** Provider-assigned tool call ID (unique within a session) */
141
+ toolCallId: string;
142
+ /** Folder/project path (extracted from session filename) */
143
+ folder: string;
144
+ /** Tool name */
145
+ toolName: string;
146
+ /** Model that emitted the call */
147
+ model: string;
148
+ /** Provider name */
149
+ provider: string;
150
+ /** Assistant-message timestamp (Unix ms) */
151
+ timestamp: number;
152
+ /** Which agent produced the call */
153
+ agentType: AgentType;
154
+ /** Total tool calls in the same assistant turn (>= 1) */
155
+ callsInTurn: number;
156
+ /** Serialized argument characters */
157
+ argsChars: number;
158
+ }
159
+
160
+ /**
161
+ * Result linkage emitted when the parser sees a `toolResult` message entry.
162
+ * Applied as an UPDATE on the persisted tool-call row — results can land in a
163
+ * later incremental sync pass than the call that produced them.
164
+ */
165
+ export interface ToolResultLink {
166
+ sessionFile: string;
167
+ toolCallId: string;
168
+ /** Text characters fed back into context */
169
+ resultChars: number;
170
+ isError: boolean;
171
+ }