@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/CHANGELOG.md +6 -0
- package/dist/client/index.js +83 -83
- package/dist/client/styles.css +4 -1
- package/dist/types/aggregator.d.ts +6 -1
- package/dist/types/client/api.d.ts +2 -1
- package/dist/types/client/app/routes.d.ts +1 -1
- package/dist/types/client/data/view-models.d.ts +9 -1
- package/dist/types/client/routes/ToolsRoute.d.ts +7 -0
- package/dist/types/client/routes/index.d.ts +1 -0
- package/dist/types/db.d.ts +31 -1
- package/dist/types/index.d.ts +2 -2
- package/dist/types/parser.d.ts +3 -1
- package/dist/types/shared-types.d.ts +48 -0
- package/dist/types/types.d.ts +41 -0
- package/package.json +4 -4
- package/src/aggregator.ts +22 -1
- package/src/client/App.tsx +3 -0
- package/src/client/api.ts +8 -0
- package/src/client/app/routes.ts +7 -1
- package/src/client/data/useHashRoute.ts +1 -0
- package/src/client/data/view-models.ts +18 -0
- package/src/client/routes/ToolsRoute.tsx +463 -0
- package/src/client/routes/index.ts +1 -0
- package/src/db.ts +252 -0
- package/src/index.ts +5 -0
- package/src/parser.ts +84 -2
- package/src/server.ts +6 -0
- package/src/shared-types.ts +52 -0
- package/src/types.ts +43 -0
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))
|
|
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);
|
package/src/shared-types.ts
CHANGED
|
@@ -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
|
+
}
|