@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.
@@ -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[]>;
@@ -0,0 +1,5 @@
1
+ import type { AgentTypeStats } from "../types";
2
+ export interface AgentTokenShareProps {
3
+ stats: AgentTypeStats[];
4
+ }
5
+ export declare function AgentTokenShare({ stats }: AgentTokenShareProps): import("react").JSX.Element;
@@ -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 {
@@ -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
  */
@@ -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
  */
@@ -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.13",
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.13",
41
- "@oh-my-pi/pi-catalog": "16.1.13",
42
- "@oh-my-pi/pi-utils": "16.1.13",
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(range?: string | null): Promise<Pick<DashboardStats, "overall" | "timeSeries">> {
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">
@@ -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