@oh-my-pi/omp-stats 16.1.13 → 16.1.15
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 +96 -96
- package/dist/client/styles.css +51 -0
- package/dist/types/aggregator.d.ts +1 -1
- package/dist/types/client/components/AgentTokenShare.d.ts +5 -0
- package/dist/types/client/data/view-models.d.ts +23 -1
- package/dist/types/client/types.d.ts +2 -1
- package/dist/types/db.d.ts +7 -1
- package/dist/types/parser.d.ts +11 -1
- package/dist/types/shared-types.d.ts +28 -0
- package/dist/types/types.d.ts +3 -0
- package/package.json +4 -4
- package/src/aggregator.ts +6 -1
- package/src/client/components/AgentTokenShare.tsx +68 -0
- package/src/client/data/view-models.ts +55 -0
- package/src/client/routes/OverviewRoute.tsx +10 -0
- package/src/client/types.ts +2 -0
- package/src/db.ts +101 -2
- package/src/parser.ts +24 -1
- package/src/shared-types.ts +30 -0
- package/src/types.ts +3 -0
package/dist/client/styles.css
CHANGED
|
@@ -208,6 +208,12 @@
|
|
|
208
208
|
.h-1 {
|
|
209
209
|
height: var(--spacing);
|
|
210
210
|
}
|
|
211
|
+
.h-2\.5 {
|
|
212
|
+
height: calc(var(--spacing) * 2.5);
|
|
213
|
+
}
|
|
214
|
+
.h-3 {
|
|
215
|
+
height: calc(var(--spacing) * 3);
|
|
216
|
+
}
|
|
211
217
|
.h-10 {
|
|
212
218
|
height: calc(var(--spacing) * 10);
|
|
213
219
|
}
|
|
@@ -229,6 +235,9 @@
|
|
|
229
235
|
.max-h-\[calc\(100vh-300px\)\] {
|
|
230
236
|
max-height: calc(100vh - 300px);
|
|
231
237
|
}
|
|
238
|
+
.w-2\.5 {
|
|
239
|
+
width: calc(var(--spacing) * 2.5);
|
|
240
|
+
}
|
|
232
241
|
.w-24 {
|
|
233
242
|
width: calc(var(--spacing) * 24);
|
|
234
243
|
}
|
|
@@ -244,6 +253,9 @@
|
|
|
244
253
|
.flex-1 {
|
|
245
254
|
flex: 1;
|
|
246
255
|
}
|
|
256
|
+
.flex-shrink-0 {
|
|
257
|
+
flex-shrink: 0;
|
|
258
|
+
}
|
|
247
259
|
.cursor-pointer {
|
|
248
260
|
cursor: pointer;
|
|
249
261
|
}
|
|
@@ -297,6 +309,13 @@
|
|
|
297
309
|
margin-block-end: calc(var(--spacing) * calc(1 - var(--tw-space-y-reverse)));
|
|
298
310
|
}
|
|
299
311
|
}
|
|
312
|
+
.space-y-2 {
|
|
313
|
+
:where(& > :not(:last-child)) {
|
|
314
|
+
--tw-space-y-reverse: 0;
|
|
315
|
+
margin-block-start: calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));
|
|
316
|
+
margin-block-end: calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)));
|
|
317
|
+
}
|
|
318
|
+
}
|
|
300
319
|
.space-y-4 {
|
|
301
320
|
:where(& > :not(:last-child)) {
|
|
302
321
|
--tw-space-y-reverse: 0;
|
|
@@ -328,6 +347,9 @@
|
|
|
328
347
|
.rounded {
|
|
329
348
|
border-radius: 0.25rem;
|
|
330
349
|
}
|
|
350
|
+
.rounded-full {
|
|
351
|
+
border-radius: calc(infinity * 1px);
|
|
352
|
+
}
|
|
331
353
|
.border-t {
|
|
332
354
|
border-top-style: var(--tw-border-style);
|
|
333
355
|
border-top-width: 1px;
|
|
@@ -432,6 +454,10 @@
|
|
|
432
454
|
.uppercase {
|
|
433
455
|
text-transform: uppercase;
|
|
434
456
|
}
|
|
457
|
+
.tabular-nums {
|
|
458
|
+
--tw-numeric-spacing: tabular-nums;
|
|
459
|
+
font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);
|
|
460
|
+
}
|
|
435
461
|
.transition-colors {
|
|
436
462
|
transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to;
|
|
437
463
|
transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
|
|
@@ -1585,6 +1611,26 @@
|
|
|
1585
1611
|
syntax: "*";
|
|
1586
1612
|
inherits: false;
|
|
1587
1613
|
}
|
|
1614
|
+
@property --tw-ordinal {
|
|
1615
|
+
syntax: "*";
|
|
1616
|
+
inherits: false;
|
|
1617
|
+
}
|
|
1618
|
+
@property --tw-slashed-zero {
|
|
1619
|
+
syntax: "*";
|
|
1620
|
+
inherits: false;
|
|
1621
|
+
}
|
|
1622
|
+
@property --tw-numeric-figure {
|
|
1623
|
+
syntax: "*";
|
|
1624
|
+
inherits: false;
|
|
1625
|
+
}
|
|
1626
|
+
@property --tw-numeric-spacing {
|
|
1627
|
+
syntax: "*";
|
|
1628
|
+
inherits: false;
|
|
1629
|
+
}
|
|
1630
|
+
@property --tw-numeric-fraction {
|
|
1631
|
+
syntax: "*";
|
|
1632
|
+
inherits: false;
|
|
1633
|
+
}
|
|
1588
1634
|
@keyframes spin {
|
|
1589
1635
|
to {
|
|
1590
1636
|
transform: rotate(360deg);
|
|
@@ -1597,6 +1643,11 @@
|
|
|
1597
1643
|
--tw-border-style: solid;
|
|
1598
1644
|
--tw-font-weight: initial;
|
|
1599
1645
|
--tw-tracking: initial;
|
|
1646
|
+
--tw-ordinal: initial;
|
|
1647
|
+
--tw-slashed-zero: initial;
|
|
1648
|
+
--tw-numeric-figure: initial;
|
|
1649
|
+
--tw-numeric-spacing: initial;
|
|
1650
|
+
--tw-numeric-fraction: initial;
|
|
1600
1651
|
}
|
|
1601
1652
|
}
|
|
1602
1653
|
}
|
|
@@ -52,7 +52,7 @@ export declare function syncAllSessions(opts?: SyncOptions): Promise<{
|
|
|
52
52
|
* Get all dashboard stats.
|
|
53
53
|
*/
|
|
54
54
|
export declare function getDashboardStats(range?: string | null): Promise<DashboardStats>;
|
|
55
|
-
export declare function getOverviewStats(range?: string | null): Promise<Pick<DashboardStats, "overall" | "timeSeries">>;
|
|
55
|
+
export declare function getOverviewStats(range?: string | null): Promise<Pick<DashboardStats, "overall" | "byAgentType" | "timeSeries">>;
|
|
56
56
|
export declare function getModelDashboardStats(range?: string | null): Promise<Pick<DashboardStats, "byModel" | "modelSeries" | "modelPerformanceSeries">>;
|
|
57
57
|
export declare function getCostDashboardStats(range?: string | null): Promise<Pick<DashboardStats, "costSeries">>;
|
|
58
58
|
export declare function getRecentRequests(limit?: number): Promise<MessageStats[]>;
|
|
@@ -1,4 +1,26 @@
|
|
|
1
|
-
import type { BehaviorOverallStats, BehaviorTimeSeriesPoint, CostTimeSeriesPoint, FolderStats, ModelPerformancePoint, TimeRange } from "../types";
|
|
1
|
+
import type { AgentType, AgentTypeStats, BehaviorOverallStats, BehaviorTimeSeriesPoint, CostTimeSeriesPoint, FolderStats, ModelPerformancePoint, TimeRange } from "../types";
|
|
2
|
+
export interface AgentTokenSegment {
|
|
3
|
+
agentType: AgentType;
|
|
4
|
+
/** input + output + cache read + cache write — the displayed denominator. */
|
|
5
|
+
tokens: number;
|
|
6
|
+
requests: number;
|
|
7
|
+
cost: number;
|
|
8
|
+
/** Fraction (0-1) of total tokens across all present agent types. */
|
|
9
|
+
share: number;
|
|
10
|
+
}
|
|
11
|
+
export interface AgentTokenShareView {
|
|
12
|
+
totalTokens: number;
|
|
13
|
+
totalCost: number;
|
|
14
|
+
segments: AgentTokenSegment[];
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Build the "token usage by agent" breakdown: one segment per agent type that
|
|
18
|
+
* appears in the data, ordered main -> subagents -> advisor, each carrying its
|
|
19
|
+
* token total and share of the grand total. Token counts sum the same four
|
|
20
|
+
* columns the overview renders (input + output + cache read + cache write) so a
|
|
21
|
+
* segment's share never disagrees with the count beside it.
|
|
22
|
+
*/
|
|
23
|
+
export declare function buildAgentTokenShare(stats: AgentTypeStats[]): AgentTokenShareView;
|
|
2
24
|
export interface CostSummaryView {
|
|
3
25
|
totalCost: number;
|
|
4
26
|
avgDailyCost: number;
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* - `TimeRange`, `OverviewStats`, `ModelDashboardStats`,
|
|
12
12
|
* `CostDashboardStats` are UI-only view shapes the server never produces.
|
|
13
13
|
*/
|
|
14
|
-
import type { AggregatedStats, CostTimeSeriesPoint, ModelPerformancePoint, ModelStats, ModelTimeSeriesPoint, TimeSeriesPoint } from "../shared-types";
|
|
14
|
+
import type { AgentTypeStats, AggregatedStats, CostTimeSeriesPoint, ModelPerformancePoint, ModelStats, ModelTimeSeriesPoint, TimeSeriesPoint } from "../shared-types";
|
|
15
15
|
export * from "../shared-types";
|
|
16
16
|
export interface Usage {
|
|
17
17
|
input: number;
|
|
@@ -50,6 +50,7 @@ export interface RequestDetails extends MessageStats {
|
|
|
50
50
|
export type TimeRange = "1h" | "24h" | "7d" | "30d" | "90d" | "all";
|
|
51
51
|
export interface OverviewStats {
|
|
52
52
|
overall: AggregatedStats;
|
|
53
|
+
byAgentType: AgentTypeStats[];
|
|
53
54
|
timeSeries: TimeSeriesPoint[];
|
|
54
55
|
}
|
|
55
56
|
export interface ModelDashboardStats {
|
package/dist/types/db.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Database } from "bun:sqlite";
|
|
2
|
-
import type { AggregatedStats, BehaviorModelStats, BehaviorOverallStats, BehaviorTimeSeriesPoint, CostTimeSeriesPoint, FolderStats, MessageStats, ModelPerformancePoint, ModelStats, ModelTimeSeriesPoint, TimeSeriesPoint, UserMessageLink, UserMessageStats } from "./types";
|
|
2
|
+
import type { AgentTypeStats, AggregatedStats, BehaviorModelStats, BehaviorOverallStats, BehaviorTimeSeriesPoint, CostTimeSeriesPoint, FolderStats, MessageStats, ModelPerformancePoint, ModelStats, ModelTimeSeriesPoint, TimeSeriesPoint, UserMessageLink, UserMessageStats } from "./types";
|
|
3
3
|
/**
|
|
4
4
|
* Initialize the database and create tables.
|
|
5
5
|
*/
|
|
@@ -31,6 +31,12 @@ export declare function getStatsByModel(cutoff?: number): ModelStats[];
|
|
|
31
31
|
* Get stats grouped by folder.
|
|
32
32
|
*/
|
|
33
33
|
export declare function getStatsByFolder(cutoff?: number): FolderStats[];
|
|
34
|
+
/**
|
|
35
|
+
* Get token usage grouped by agent type (main agent, task subagents, advisor).
|
|
36
|
+
* Token columns are explicit so the dashboard's share denominator matches the
|
|
37
|
+
* counts it renders. Rows missing `agent_type` (defensive) fall back to "main".
|
|
38
|
+
*/
|
|
39
|
+
export declare function getStatsByAgentType(cutoff?: number): AgentTypeStats[];
|
|
34
40
|
/**
|
|
35
41
|
* Get time series data.
|
|
36
42
|
*/
|
package/dist/types/parser.d.ts
CHANGED
|
@@ -1,4 +1,14 @@
|
|
|
1
|
-
import type { MessageStats, SessionEntry, UserMessageLink, UserMessageStats } from "./types";
|
|
1
|
+
import type { AgentType, MessageStats, SessionEntry, UserMessageLink, UserMessageStats } from "./types";
|
|
2
|
+
/**
|
|
3
|
+
* Classify which agent produced a transcript from its path within the sessions
|
|
4
|
+
* directory. Layout: `<sessionsDir>/<project>/<file>.jsonl` is the `main`
|
|
5
|
+
* agent; subagent and advisor transcripts live nested one level deeper inside
|
|
6
|
+
* the session's artifacts dir (`<project>/<session>/<id>.jsonl`,
|
|
7
|
+
* `<project>/<session>/__advisor.jsonl`). Any `__advisor.jsonl` — at any depth,
|
|
8
|
+
* including a subagent's own advisor — counts as `advisor`; every other nested
|
|
9
|
+
* transcript is a task `subagent`.
|
|
10
|
+
*/
|
|
11
|
+
export declare function classifyAgentType(sessionPath: string): AgentType;
|
|
2
12
|
/**
|
|
3
13
|
* Parse a session file and extract all assistant message stats.
|
|
4
14
|
* Uses incremental reading with offset tracking.
|
|
@@ -125,11 +125,39 @@ export interface DashboardStats {
|
|
|
125
125
|
overall: AggregatedStats;
|
|
126
126
|
byModel: ModelStats[];
|
|
127
127
|
byFolder: FolderStats[];
|
|
128
|
+
byAgentType: AgentTypeStats[];
|
|
128
129
|
timeSeries: TimeSeriesPoint[];
|
|
129
130
|
modelSeries: ModelTimeSeriesPoint[];
|
|
130
131
|
modelPerformanceSeries: ModelPerformancePoint[];
|
|
131
132
|
costSeries: CostTimeSeriesPoint[];
|
|
132
133
|
}
|
|
134
|
+
/**
|
|
135
|
+
* Which agent produced a message, derived from its transcript file location
|
|
136
|
+
* inside the session directory: the top-level `<project>/<file>.jsonl` is the
|
|
137
|
+
* `main` agent, an `__advisor.jsonl` is the passive `advisor`, and any other
|
|
138
|
+
* nested transcript is a task `subagent`.
|
|
139
|
+
*/
|
|
140
|
+
export type AgentType = "main" | "subagent" | "advisor";
|
|
141
|
+
/**
|
|
142
|
+
* Token usage aggregated by {@link AgentType} over the active range. Token
|
|
143
|
+
* columns are explicit so the dashboard's share denominator matches the
|
|
144
|
+
* counts it renders (input + output + cache read + cache write).
|
|
145
|
+
*/
|
|
146
|
+
export interface AgentTypeStats {
|
|
147
|
+
agentType: AgentType;
|
|
148
|
+
/** Total number of requests */
|
|
149
|
+
totalRequests: number;
|
|
150
|
+
/** Total input tokens */
|
|
151
|
+
totalInputTokens: number;
|
|
152
|
+
/** Total output tokens */
|
|
153
|
+
totalOutputTokens: number;
|
|
154
|
+
/** Total cache read tokens */
|
|
155
|
+
totalCacheReadTokens: number;
|
|
156
|
+
/** Total cache write tokens */
|
|
157
|
+
totalCacheWriteTokens: number;
|
|
158
|
+
/** Total cost */
|
|
159
|
+
totalCost: number;
|
|
160
|
+
}
|
|
133
161
|
/**
|
|
134
162
|
* Behavior time-series point (daily bucket, per responding model).
|
|
135
163
|
*/
|
package/dist/types/types.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { AssistantMessage, ServiceTier, StopReason, Usage } from "@oh-my-pi/pi-ai";
|
|
2
|
+
import type { AgentType } from "./shared-types";
|
|
2
3
|
export * from "./shared-types";
|
|
3
4
|
/**
|
|
4
5
|
* Extracted stats from an assistant message.
|
|
@@ -30,6 +31,8 @@ export interface MessageStats {
|
|
|
30
31
|
errorMessage: string | null;
|
|
31
32
|
/** Token usage */
|
|
32
33
|
usage: Usage;
|
|
34
|
+
/** Which agent produced this message (main agent, task subagent, advisor) */
|
|
35
|
+
agentType: AgentType;
|
|
33
36
|
}
|
|
34
37
|
/**
|
|
35
38
|
* Full details of a request, including content.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/omp-stats",
|
|
4
|
-
"version": "16.1.
|
|
4
|
+
"version": "16.1.15",
|
|
5
5
|
"description": "Local observability dashboard for pi AI usage statistics",
|
|
6
6
|
"homepage": "https://omp.sh",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -37,9 +37,9 @@
|
|
|
37
37
|
"fmt": "biome format --write ."
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"@oh-my-pi/pi-ai": "16.1.
|
|
41
|
-
"@oh-my-pi/pi-catalog": "16.1.
|
|
42
|
-
"@oh-my-pi/pi-utils": "16.1.
|
|
40
|
+
"@oh-my-pi/pi-ai": "16.1.15",
|
|
41
|
+
"@oh-my-pi/pi-catalog": "16.1.15",
|
|
42
|
+
"@oh-my-pi/pi-utils": "16.1.15",
|
|
43
43
|
"@tailwindcss/node": "^4.3.0",
|
|
44
44
|
"chart.js": "^4.5.1",
|
|
45
45
|
"date-fns": "^4.4.0",
|
package/src/aggregator.ts
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
getModelPerformanceSeries,
|
|
14
14
|
getModelTimeSeries,
|
|
15
15
|
getOverallStats,
|
|
16
|
+
getStatsByAgentType,
|
|
16
17
|
getStatsByFolder,
|
|
17
18
|
getStatsByModel,
|
|
18
19
|
getTimeSeries,
|
|
@@ -364,6 +365,7 @@ export async function getDashboardStats(range?: string | null): Promise<Dashboar
|
|
|
364
365
|
overall: getOverallStats(cutoff ?? undefined),
|
|
365
366
|
byModel: getStatsByModel(cutoff ?? undefined),
|
|
366
367
|
byFolder: getStatsByFolder(cutoff ?? undefined),
|
|
368
|
+
byAgentType: getStatsByAgentType(cutoff ?? undefined),
|
|
367
369
|
timeSeries: getTimeSeries(timeSeriesHours, cutoff, timeSeriesBucketMs),
|
|
368
370
|
modelSeries: getModelTimeSeries(modelSeriesDays, cutoff, modelSeriesBucketMs),
|
|
369
371
|
modelPerformanceSeries: getModelPerformanceSeries(modelPerformanceDays, cutoff, modelPerformanceBucketMs),
|
|
@@ -371,12 +373,15 @@ export async function getDashboardStats(range?: string | null): Promise<Dashboar
|
|
|
371
373
|
};
|
|
372
374
|
}
|
|
373
375
|
|
|
374
|
-
export async function getOverviewStats(
|
|
376
|
+
export async function getOverviewStats(
|
|
377
|
+
range?: string | null,
|
|
378
|
+
): Promise<Pick<DashboardStats, "overall" | "byAgentType" | "timeSeries">> {
|
|
375
379
|
await initDb();
|
|
376
380
|
const { timeSeriesHours, timeSeriesBucketMs, cutoff } = getTimeRangeConfig(range);
|
|
377
381
|
|
|
378
382
|
return {
|
|
379
383
|
overall: getOverallStats(cutoff ?? undefined),
|
|
384
|
+
byAgentType: getStatsByAgentType(cutoff ?? undefined),
|
|
380
385
|
timeSeries: getTimeSeries(timeSeriesHours, cutoff, timeSeriesBucketMs),
|
|
381
386
|
};
|
|
382
387
|
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { useMemo } from "react";
|
|
2
|
+
import { formatCompact, formatInteger, formatPercent } from "../data/formatters";
|
|
3
|
+
import { buildAgentTokenShare } from "../data/view-models";
|
|
4
|
+
import type { AgentType, AgentTypeStats } from "../types";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Per-agent-type display chrome. Colors follow the OMP brand palette
|
|
8
|
+
* (pink -> violet -> cyan) used by the dashboard charts so the bar reads on
|
|
9
|
+
* both themes without per-theme overrides.
|
|
10
|
+
*/
|
|
11
|
+
const AGENT_META: Record<AgentType, { label: string; color: string }> = {
|
|
12
|
+
main: { label: "Main agent", color: "#ed4abf" },
|
|
13
|
+
subagent: { label: "Subagents", color: "#9b4dff" },
|
|
14
|
+
advisor: { label: "Advisor", color: "#5ad8e6" },
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export interface AgentTokenShareProps {
|
|
18
|
+
stats: AgentTypeStats[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function AgentTokenShare({ stats }: AgentTokenShareProps) {
|
|
22
|
+
const view = useMemo(() => buildAgentTokenShare(stats), [stats]);
|
|
23
|
+
|
|
24
|
+
if (view.totalTokens === 0) {
|
|
25
|
+
return <div className="py-8 text-center stats-text-muted text-sm">No token usage in this range</div>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<div className="space-y-4">
|
|
30
|
+
<div className="flex h-3 w-full overflow-hidden rounded-full" style={{ background: "var(--surface-2)" }}>
|
|
31
|
+
{view.segments.map(
|
|
32
|
+
seg =>
|
|
33
|
+
seg.share > 0 && (
|
|
34
|
+
<div
|
|
35
|
+
key={seg.agentType}
|
|
36
|
+
className="h-full"
|
|
37
|
+
style={{ width: `${seg.share * 100}%`, background: AGENT_META[seg.agentType].color }}
|
|
38
|
+
title={`${AGENT_META[seg.agentType].label}: ${formatPercent(seg.share)}`}
|
|
39
|
+
/>
|
|
40
|
+
),
|
|
41
|
+
)}
|
|
42
|
+
</div>
|
|
43
|
+
|
|
44
|
+
<div className="space-y-2">
|
|
45
|
+
{view.segments.map(seg => (
|
|
46
|
+
<div key={seg.agentType} className="flex items-center justify-between gap-3 text-sm">
|
|
47
|
+
<div className="flex items-center gap-2 min-w-0">
|
|
48
|
+
<span
|
|
49
|
+
className="w-2.5 h-2.5 rounded-full flex-shrink-0"
|
|
50
|
+
style={{ background: AGENT_META[seg.agentType].color }}
|
|
51
|
+
/>
|
|
52
|
+
<span className="stats-text-primary truncate">{AGENT_META[seg.agentType].label}</span>
|
|
53
|
+
<span className="stats-text-muted stats-text-xs whitespace-nowrap">
|
|
54
|
+
{formatInteger(seg.requests)} req
|
|
55
|
+
</span>
|
|
56
|
+
</div>
|
|
57
|
+
<div className="flex items-center gap-3 whitespace-nowrap">
|
|
58
|
+
<span className="stats-text-secondary">{formatCompact(seg.tokens)} tok</span>
|
|
59
|
+
<span className="stats-font-semibold stats-text-primary tabular-nums">
|
|
60
|
+
{formatPercent(seg.share)}
|
|
61
|
+
</span>
|
|
62
|
+
</div>
|
|
63
|
+
</div>
|
|
64
|
+
))}
|
|
65
|
+
</div>
|
|
66
|
+
</div>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { rangeMeta } from "../components/range-meta";
|
|
2
2
|
import type {
|
|
3
|
+
AgentType,
|
|
4
|
+
AgentTypeStats,
|
|
3
5
|
BehaviorOverallStats,
|
|
4
6
|
BehaviorTimeSeriesPoint,
|
|
5
7
|
CostTimeSeriesPoint,
|
|
@@ -8,6 +10,59 @@ import type {
|
|
|
8
10
|
TimeRange,
|
|
9
11
|
} from "../types";
|
|
10
12
|
|
|
13
|
+
/** Fixed display order for the agent-token-share breakdown. */
|
|
14
|
+
const AGENT_TYPE_ORDER: AgentType[] = ["main", "subagent", "advisor"];
|
|
15
|
+
|
|
16
|
+
export interface AgentTokenSegment {
|
|
17
|
+
agentType: AgentType;
|
|
18
|
+
/** input + output + cache read + cache write — the displayed denominator. */
|
|
19
|
+
tokens: number;
|
|
20
|
+
requests: number;
|
|
21
|
+
cost: number;
|
|
22
|
+
/** Fraction (0-1) of total tokens across all present agent types. */
|
|
23
|
+
share: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface AgentTokenShareView {
|
|
27
|
+
totalTokens: number;
|
|
28
|
+
totalCost: number;
|
|
29
|
+
segments: AgentTokenSegment[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Build the "token usage by agent" breakdown: one segment per agent type that
|
|
34
|
+
* appears in the data, ordered main -> subagents -> advisor, each carrying its
|
|
35
|
+
* token total and share of the grand total. Token counts sum the same four
|
|
36
|
+
* columns the overview renders (input + output + cache read + cache write) so a
|
|
37
|
+
* segment's share never disagrees with the count beside it.
|
|
38
|
+
*/
|
|
39
|
+
export function buildAgentTokenShare(stats: AgentTypeStats[]): AgentTokenShareView {
|
|
40
|
+
const byType = new Map<AgentType, AgentTypeStats>();
|
|
41
|
+
for (const stat of stats) byType.set(stat.agentType, stat);
|
|
42
|
+
|
|
43
|
+
const tokensOf = (stat: AgentTypeStats) =>
|
|
44
|
+
stat.totalInputTokens + stat.totalOutputTokens + stat.totalCacheReadTokens + stat.totalCacheWriteTokens;
|
|
45
|
+
|
|
46
|
+
const present = AGENT_TYPE_ORDER.map(type => byType.get(type)).filter(
|
|
47
|
+
(stat): stat is AgentTypeStats => stat !== undefined,
|
|
48
|
+
);
|
|
49
|
+
const totalTokens = present.reduce((sum, stat) => sum + tokensOf(stat), 0);
|
|
50
|
+
const totalCost = present.reduce((sum, stat) => sum + stat.totalCost, 0);
|
|
51
|
+
|
|
52
|
+
const segments = present.map(stat => {
|
|
53
|
+
const tokens = tokensOf(stat);
|
|
54
|
+
return {
|
|
55
|
+
agentType: stat.agentType,
|
|
56
|
+
tokens,
|
|
57
|
+
requests: stat.totalRequests,
|
|
58
|
+
cost: stat.totalCost,
|
|
59
|
+
share: totalTokens > 0 ? tokens / totalTokens : 0,
|
|
60
|
+
};
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
return { totalTokens, totalCost, segments };
|
|
64
|
+
}
|
|
65
|
+
|
|
11
66
|
export interface CostSummaryView {
|
|
12
67
|
totalCost: number;
|
|
13
68
|
avgDailyCost: number;
|
|
@@ -2,6 +2,7 @@ import { format } from "date-fns";
|
|
|
2
2
|
import { useMemo } from "react";
|
|
3
3
|
import { Line } from "react-chartjs-2";
|
|
4
4
|
import { getOverviewStats, getRecentRequests } from "../api";
|
|
5
|
+
import { AgentTokenShare } from "../components/AgentTokenShare";
|
|
5
6
|
import { CHART_THEMES } from "../components/chart-shared";
|
|
6
7
|
import { formatCost, formatDurationMs, formatInteger, formatRelativeTime } from "../data/formatters";
|
|
7
8
|
import { useResource } from "../data/useResource";
|
|
@@ -224,6 +225,15 @@ export function OverviewRoute({ active, range, refreshTrigger, onRequestClick }:
|
|
|
224
225
|
{overview && <MetricCluster stats={overview.overall} />}
|
|
225
226
|
</AsyncBoundary>
|
|
226
227
|
|
|
228
|
+
<Panel
|
|
229
|
+
title="Token Usage by Agent"
|
|
230
|
+
subtitle="Share of tokens across the main agent, task subagents, and the advisor"
|
|
231
|
+
>
|
|
232
|
+
<AsyncBoundary loading={overviewLoading} error={overviewError} data={overview}>
|
|
233
|
+
{overview && <AgentTokenShare stats={overview.byAgentType} />}
|
|
234
|
+
</AsyncBoundary>
|
|
235
|
+
</Panel>
|
|
236
|
+
|
|
227
237
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
|
228
238
|
<div className="lg:col-span-2">
|
|
229
239
|
<Panel title="System Throughput" subtitle="Request volume and errors over time">
|
package/src/client/types.ts
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import type {
|
|
16
|
+
AgentTypeStats,
|
|
16
17
|
AggregatedStats,
|
|
17
18
|
CostTimeSeriesPoint,
|
|
18
19
|
ModelPerformancePoint,
|
|
@@ -64,6 +65,7 @@ export type TimeRange = "1h" | "24h" | "7d" | "30d" | "90d" | "all";
|
|
|
64
65
|
|
|
65
66
|
export interface OverviewStats {
|
|
66
67
|
overall: AggregatedStats;
|
|
68
|
+
byAgentType: AgentTypeStats[];
|
|
67
69
|
timeSeries: TimeSeriesPoint[];
|
|
68
70
|
}
|
|
69
71
|
|
package/src/db.ts
CHANGED
|
@@ -4,7 +4,10 @@ import type { Usage } from "@oh-my-pi/pi-ai";
|
|
|
4
4
|
import type { GeneratedProvider } from "@oh-my-pi/pi-catalog/models";
|
|
5
5
|
import { getBundledModel } from "@oh-my-pi/pi-catalog/models";
|
|
6
6
|
import { getConfigRootDir, getStatsDbPath } from "@oh-my-pi/pi-utils";
|
|
7
|
+
import { classifyAgentType } from "./parser";
|
|
7
8
|
import type {
|
|
9
|
+
AgentType,
|
|
10
|
+
AgentTypeStats,
|
|
8
11
|
AggregatedStats,
|
|
9
12
|
BehaviorModelStats,
|
|
10
13
|
BehaviorOverallStats,
|
|
@@ -41,6 +44,7 @@ const BACKFILL_PENDING = "pending";
|
|
|
41
44
|
const USER_MESSAGES_BACKFILL_KEY = "user_messages_v6";
|
|
42
45
|
const USER_MESSAGE_LINKS_REPAIR_KEY = "user_message_links_v1";
|
|
43
46
|
const PRIORITY_PREMIUM_REQUESTS_BACKFILL_KEY = "premium_requests_priority_v1";
|
|
47
|
+
const AGENT_TYPE_BACKFILL_KEY = "agent_type_v1";
|
|
44
48
|
function shouldResetBackfill(value: string | undefined): boolean {
|
|
45
49
|
return value !== BACKFILL_COMPLETE && value !== BACKFILL_PENDING;
|
|
46
50
|
}
|
|
@@ -59,6 +63,11 @@ export async function initDb(): Promise<Database> {
|
|
|
59
63
|
db.run("PRAGMA busy_timeout = 5000");
|
|
60
64
|
db.run("PRAGMA journal_mode = WAL");
|
|
61
65
|
|
|
66
|
+
// Whether `messages` predates this init — drives the one-time agent_type
|
|
67
|
+
// backfill below, so it must be sampled before CREATE TABLE adds the table.
|
|
68
|
+
const messagesTableExisted =
|
|
69
|
+
db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'messages'").get() !== undefined;
|
|
70
|
+
|
|
62
71
|
// Create tables
|
|
63
72
|
db.run(`
|
|
64
73
|
CREATE TABLE IF NOT EXISTS messages (
|
|
@@ -85,6 +94,7 @@ export async function initDb(): Promise<Database> {
|
|
|
85
94
|
cost_cache_read REAL NOT NULL,
|
|
86
95
|
cost_cache_write REAL NOT NULL,
|
|
87
96
|
cost_total REAL NOT NULL,
|
|
97
|
+
agent_type TEXT NOT NULL DEFAULT 'main',
|
|
88
98
|
UNIQUE(session_file, entry_id)
|
|
89
99
|
);
|
|
90
100
|
|
|
@@ -135,6 +145,26 @@ export async function initDb(): Promise<Database> {
|
|
|
135
145
|
db.run("ALTER TABLE messages ADD COLUMN premium_requests REAL NOT NULL DEFAULT 0");
|
|
136
146
|
}
|
|
137
147
|
db.run("UPDATE messages SET premium_requests = 0 WHERE premium_requests IS NULL");
|
|
148
|
+
// Token-usage-by-agent: each message is classified main / subagent / advisor
|
|
149
|
+
// from its transcript path. A brand-new table gets the column from CREATE
|
|
150
|
+
// TABLE and the parser labels rows at insert time; a pre-existing table gets
|
|
151
|
+
// the column here (defaulting every prior row to 'main') and enrolls the
|
|
152
|
+
// one-time path-based reclassification, gated by a meta sentinel.
|
|
153
|
+
const hasAgentTypeColumn = messageColumns.some(column => column.name === "agent_type");
|
|
154
|
+
if (!hasAgentTypeColumn) {
|
|
155
|
+
db.run("ALTER TABLE messages ADD COLUMN agent_type TEXT NOT NULL DEFAULT 'main'");
|
|
156
|
+
}
|
|
157
|
+
// For any pre-existing table, enroll the backfill PENDING unless a prior run
|
|
158
|
+
// already settled the sentinel — `OR IGNORE` leaves an existing
|
|
159
|
+
// COMPLETE/PENDING value intact, so an ALTER that committed before its
|
|
160
|
+
// sentinel write (process killed in between) still reclassifies on the next
|
|
161
|
+
// init instead of silently leaving every row as the 'main' default. A
|
|
162
|
+
// brand-new empty table has nothing to reclassify, so it settles COMPLETE.
|
|
163
|
+
db.prepare("INSERT OR IGNORE INTO meta (key, value) VALUES (?, ?)").run(
|
|
164
|
+
AGENT_TYPE_BACKFILL_KEY,
|
|
165
|
+
messagesTableExisted ? BACKFILL_PENDING : BACKFILL_COMPLETE,
|
|
166
|
+
);
|
|
167
|
+
db.run("CREATE INDEX IF NOT EXISTS idx_messages_timestamp_agent_type ON messages(timestamp, agent_type)");
|
|
138
168
|
// Each behavior-metric bump invalidates previously-ingested rows. We detect
|
|
139
169
|
// the stale schema by column name and drop the table; `IF NOT EXISTS` above
|
|
140
170
|
// already produced the new schema, but we want a clean wipe + re-ingest.
|
|
@@ -186,6 +216,7 @@ export async function initDb(): Promise<Database> {
|
|
|
186
216
|
backfillUserMessages(db);
|
|
187
217
|
repairUserMessageLinks(db);
|
|
188
218
|
backfillPriorityPremiumRequests(db);
|
|
219
|
+
backfillAgentType(db);
|
|
189
220
|
backfillMissingCatalogCosts(db);
|
|
190
221
|
return db;
|
|
191
222
|
}
|
|
@@ -317,8 +348,8 @@ export function insertMessageStats(stats: MessageStats[]): number {
|
|
|
317
348
|
session_file, entry_id, folder, model, provider, api, timestamp,
|
|
318
349
|
duration, ttft, stop_reason, error_message,
|
|
319
350
|
input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, total_tokens, premium_requests,
|
|
320
|
-
cost_input, cost_output, cost_cache_read, cost_cache_write, cost_total
|
|
321
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
351
|
+
cost_input, cost_output, cost_cache_read, cost_cache_write, cost_total, agent_type
|
|
352
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
322
353
|
ON CONFLICT(session_file, entry_id) DO UPDATE SET
|
|
323
354
|
premium_requests = excluded.premium_requests
|
|
324
355
|
WHERE messages.premium_requests < excluded.premium_requests
|
|
@@ -351,6 +382,7 @@ export function insertMessageStats(stats: MessageStats[]): number {
|
|
|
351
382
|
cost.cacheRead,
|
|
352
383
|
cost.cacheWrite,
|
|
353
384
|
cost.total,
|
|
385
|
+
s.agentType,
|
|
354
386
|
);
|
|
355
387
|
if (result.changes > 0) inserted++;
|
|
356
388
|
}
|
|
@@ -519,6 +551,41 @@ export function getStatsByFolder(cutoff?: number): FolderStats[] {
|
|
|
519
551
|
}));
|
|
520
552
|
}
|
|
521
553
|
|
|
554
|
+
/**
|
|
555
|
+
* Get token usage grouped by agent type (main agent, task subagents, advisor).
|
|
556
|
+
* Token columns are explicit so the dashboard's share denominator matches the
|
|
557
|
+
* counts it renders. Rows missing `agent_type` (defensive) fall back to "main".
|
|
558
|
+
*/
|
|
559
|
+
export function getStatsByAgentType(cutoff?: number): AgentTypeStats[] {
|
|
560
|
+
if (!db) return [];
|
|
561
|
+
|
|
562
|
+
const hasCutoff = cutoff !== undefined && cutoff > 0;
|
|
563
|
+
const stmt = db.prepare(`
|
|
564
|
+
SELECT
|
|
565
|
+
agent_type,
|
|
566
|
+
COUNT(*) as total_requests,
|
|
567
|
+
SUM(input_tokens) as total_input_tokens,
|
|
568
|
+
SUM(output_tokens) as total_output_tokens,
|
|
569
|
+
SUM(cache_read_tokens) as total_cache_read_tokens,
|
|
570
|
+
SUM(cache_write_tokens) as total_cache_write_tokens,
|
|
571
|
+
SUM(cost_total) as total_cost
|
|
572
|
+
FROM messages
|
|
573
|
+
${hasCutoff ? "WHERE timestamp >= ?" : ""}
|
|
574
|
+
GROUP BY agent_type
|
|
575
|
+
`);
|
|
576
|
+
|
|
577
|
+
const rows = (hasCutoff ? stmt.all(cutoff) : stmt.all()) as any[];
|
|
578
|
+
return rows.map(row => ({
|
|
579
|
+
agentType: (row.agent_type as AgentType) ?? "main",
|
|
580
|
+
totalRequests: row.total_requests || 0,
|
|
581
|
+
totalInputTokens: row.total_input_tokens || 0,
|
|
582
|
+
totalOutputTokens: row.total_output_tokens || 0,
|
|
583
|
+
totalCacheReadTokens: row.total_cache_read_tokens || 0,
|
|
584
|
+
totalCacheWriteTokens: row.total_cache_write_tokens || 0,
|
|
585
|
+
totalCost: row.total_cost || 0,
|
|
586
|
+
}));
|
|
587
|
+
}
|
|
588
|
+
|
|
522
589
|
/**
|
|
523
590
|
* Get time series data.
|
|
524
591
|
*/
|
|
@@ -686,6 +753,7 @@ function rowToMessageStats(row: any): MessageStats {
|
|
|
686
753
|
total: row.cost_total,
|
|
687
754
|
},
|
|
688
755
|
},
|
|
756
|
+
agentType: (row.agent_type as AgentType) ?? "main",
|
|
689
757
|
};
|
|
690
758
|
}
|
|
691
759
|
|
|
@@ -794,6 +862,37 @@ function backfillUserMessages(database: Database): void {
|
|
|
794
862
|
.run(USER_MESSAGES_BACKFILL_KEY, BACKFILL_PENDING);
|
|
795
863
|
}
|
|
796
864
|
|
|
865
|
+
/**
|
|
866
|
+
* Reclassify pre-existing `messages` rows by agent type once, after the
|
|
867
|
+
* `agent_type` column is added to an older database (every prior row defaulted
|
|
868
|
+
* to 'main' on the ALTER). Classification is purely path-based — derived from
|
|
869
|
+
* the stored `session_file` — so no session re-parse is needed. Idempotent and
|
|
870
|
+
* crash-safe: enrolled (PENDING) only at migration time in {@link initDb} and
|
|
871
|
+
* marked COMPLETE inside the same transaction that applies the updates, so an
|
|
872
|
+
* interrupted run rolls back and retries on the next init.
|
|
873
|
+
*/
|
|
874
|
+
function backfillAgentType(database: Database): void {
|
|
875
|
+
const row = database.prepare("SELECT value FROM meta WHERE key = ?").get(AGENT_TYPE_BACKFILL_KEY) as
|
|
876
|
+
| { value: string }
|
|
877
|
+
| undefined;
|
|
878
|
+
if (row?.value !== BACKFILL_PENDING) return;
|
|
879
|
+
|
|
880
|
+
const sessionFiles = database.prepare("SELECT DISTINCT session_file FROM messages").all() as {
|
|
881
|
+
session_file: string;
|
|
882
|
+
}[];
|
|
883
|
+
const update = database.prepare("UPDATE messages SET agent_type = ? WHERE session_file = ?");
|
|
884
|
+
const markComplete = database.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)");
|
|
885
|
+
const apply = database.transaction(() => {
|
|
886
|
+
for (const { session_file } of sessionFiles) {
|
|
887
|
+
const agentType = classifyAgentType(session_file);
|
|
888
|
+
// Rows already default to 'main'; only the nested transcripts move.
|
|
889
|
+
if (agentType !== "main") update.run(agentType, session_file);
|
|
890
|
+
}
|
|
891
|
+
markComplete.run(AGENT_TYPE_BACKFILL_KEY, BACKFILL_COMPLETE);
|
|
892
|
+
});
|
|
893
|
+
apply();
|
|
894
|
+
}
|
|
895
|
+
|
|
797
896
|
/**
|
|
798
897
|
* One-shot wipe of `file_offsets` to force `parseSessionFile` to re-parse
|
|
799
898
|
* every session from byte zero. We don't touch `user_messages`; the parser
|