@gajae-code/stats 0.17.4 → 0.17.6

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/README.md CHANGED
@@ -8,6 +8,7 @@ Local observability dashboard for AI usage statistics.
8
8
  - **SQLite aggregation**: Efficient stats storage and querying using `bun:sqlite`
9
9
  - **Web dashboard**: Real-time metrics visualization with Chart.js
10
10
  - **Incremental sync**: Only processes new/modified log entries
11
+ - **Role usage breakdown**: `gjc stats --summary` and JSON report usage by default, executor, planner, architect, and critic; custom roles and legacy sessions without identity metadata are grouped as `other` or `unknown`
11
12
 
12
13
  ## Metrics Tracked
13
14
 
@@ -50,6 +51,7 @@ const { processed, files } = await syncAllSessions();
50
51
  const stats = await getDashboardStats();
51
52
  console.log(stats.overall.totalCost);
52
53
  console.log(stats.byModel[0].avgTokensPerSecond);
54
+ console.log(stats.byAgent.find(agent => agent.agent === "executor")?.totalCost);
53
55
  ```
54
56
 
55
57
  ## API Endpoints
@@ -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 { AgentRole, AggregatedStats, CostTimeSeriesPoint, ModelPerformancePoint, ModelStats, ModelTimeSeriesPoint, TimeSeriesPoint } from "../shared-types";
15
15
  export * from "../shared-types";
16
16
  export interface Usage {
17
17
  input: number;
@@ -33,6 +33,7 @@ export interface MessageStats {
33
33
  sessionFile: string;
34
34
  entryId: string;
35
35
  folder: string;
36
+ agent: AgentRole;
36
37
  model: string;
37
38
  provider: string;
38
39
  api: string;
@@ -1,5 +1,5 @@
1
1
  import { Database } from "bun:sqlite";
2
- import type { AggregatedStats, BehaviorModelStats, BehaviorOverallStats, BehaviorTimeSeriesPoint, CostTimeSeriesPoint, FolderStats, MessageStats, ModelPerformancePoint, ModelStats, ModelTimeSeriesPoint, ParsedMessageStats, TimeSeriesPoint, UserMessageLink, UserMessageStats } from "./types";
2
+ import type { AgentStats, AggregatedStats, BehaviorModelStats, BehaviorOverallStats, BehaviorTimeSeriesPoint, CostTimeSeriesPoint, FolderStats, MessageStats, ModelPerformancePoint, ModelStats, ModelTimeSeriesPoint, ParsedMessageStats, TimeSeriesPoint, UserMessageLink, UserMessageStats } from "./types";
3
3
  /**
4
4
  * Initialize the database and create tables.
5
5
  */
@@ -31,6 +31,10 @@ 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 stats grouped by the persisted agent role.
36
+ */
37
+ export declare function getStatsByAgent(cutoff?: number): AgentStats[];
34
38
  /**
35
39
  * Get time series data.
36
40
  */
@@ -2,4 +2,4 @@
2
2
  export { getDashboardStats, getTotalMessageCount, type SyncOptions, type SyncProgress, smokeTestSyncWorker, syncAllSessions, } from "./aggregator";
3
3
  export { closeDb } from "./db";
4
4
  export { startServer } from "./server";
5
- export type { AggregatedStats, DashboardStats, FolderStats, MessageStats, ModelPerformancePoint, ModelStats, ModelTimeSeriesPoint, TimeSeriesPoint, } from "./types";
5
+ export type { AgentRole, AgentStats, AggregatedStats, DashboardStats, FolderStats, MessageStats, ModelPerformancePoint, ModelStats, ModelTimeSeriesPoint, TimeSeriesPoint, } from "./types";
@@ -3,17 +3,13 @@ import type { ParsedMessageStats, SessionEntry, UserMessageLink, UserMessageStat
3
3
  * Parse a session file and extract all assistant message stats.
4
4
  * Uses incremental reading with offset tracking.
5
5
  *
6
- * Service-tier carry-over: `currentServiceTier` is a session-scoped piece of
7
- * state derived from `service_tier_change` entries that affects whether
8
- * subsequent OpenAI assistant replies count as premium requests. Incremental
9
- * syncs that resume past the most-recent tier change would otherwise lose
10
- * that state and silently record `premiumRequests = 0` for priority traffic
11
- * (the coding-agent stopped folding the tier into `usage.premiumRequests`
12
- * after 13f59162e — the parser is now the sole source of truth). When
13
- * `fromOffset > 0` we therefore scan the bytes preceding `fromOffset`
14
- * for the latest service-tier value before parsing the unprocessed tail.
15
- * The scan only keeps the current tier and does not materialize prefix
16
- * entries, preserving offset-based memory behavior for large sessions.
6
+ * Service-tier and agent-role carry-over are session-scoped state. Incremental
7
+ * syncs that resume after either metadata entry would otherwise lose the
8
+ * context needed to attribute priority requests and assistant usage. When
9
+ * `fromOffset > 0`, scan the prefix for the current values before parsing the
10
+ * unprocessed tail. The scan keeps only current metadata and does not
11
+ * materialize prefix entries, preserving offset-based memory behavior for
12
+ * large sessions.
17
13
  */
18
14
  export interface ParseSessionResult {
19
15
  stats: ParsedMessageStats[];
@@ -5,7 +5,7 @@
5
5
  * without dragging server dependencies into its bundle.
6
6
  */
7
7
  /**
8
- * Aggregated stats for a model or folder.
8
+ * Aggregated stats for a model, folder, or agent role.
9
9
  */
10
10
  export interface AggregatedStats {
11
11
  /** Total number of requests */
@@ -53,6 +53,12 @@ export interface ModelStats extends AggregatedStats {
53
53
  export interface FolderStats extends AggregatedStats {
54
54
  folder: string;
55
55
  }
56
+ /** Agent role attributed from the session transcript. */
57
+ export type AgentRole = "default" | "executor" | "planner" | "architect" | "critic" | "other" | "unknown";
58
+ /** Stats grouped by the agent role that produced the assistant request. */
59
+ export interface AgentStats extends AggregatedStats {
60
+ agent: AgentRole;
61
+ }
56
62
  /**
57
63
  * Time series data point.
58
64
  */
@@ -125,6 +131,7 @@ export interface DashboardStats {
125
131
  overall: AggregatedStats;
126
132
  byModel: ModelStats[];
127
133
  byFolder: FolderStats[];
134
+ byAgent: AgentStats[];
128
135
  timeSeries: TimeSeriesPoint[];
129
136
  modelSeries: ModelTimeSeriesPoint[];
130
137
  modelPerformanceSeries: ModelPerformancePoint[];
@@ -1,4 +1,5 @@
1
1
  import type { AssistantMessage, ServiceTier, StopReason, Usage } from "@gajae-code/ai";
2
+ import type { AgentRole } from "./shared-types";
2
3
  export * from "./shared-types";
3
4
  /**
4
5
  * Extracted stats from an assistant message.
@@ -12,6 +13,8 @@ export interface MessageStats {
12
13
  entryId: string;
13
14
  /** Folder/project path (extracted from session filename) */
14
15
  folder: string;
16
+ /** Agent role inferred from the persisted session identity. */
17
+ agent: AgentRole;
15
18
  /** Model ID */
16
19
  model: string;
17
20
  /** Provider name */
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@gajae-code/stats",
4
- "version": "0.17.4",
4
+ "version": "0.17.6",
5
5
  "description": "Local observability dashboard for pi AI usage statistics",
6
6
  "homepage": "https://gajae-code.com",
7
7
  "author": "Yeachan-Heo",
@@ -38,8 +38,8 @@
38
38
  "fmt": "biome format --write ."
39
39
  },
40
40
  "dependencies": {
41
- "@gajae-code/ai": "0.17.4",
42
- "@gajae-code/utils": "0.17.4",
41
+ "@gajae-code/ai": "0.17.6",
42
+ "@gajae-code/utils": "0.17.6",
43
43
  "@tailwindcss/node": "^4.2.4",
44
44
  "chart.js": "^4.5.1",
45
45
  "date-fns": "^4.1.0",
package/src/aggregator.ts CHANGED
@@ -13,6 +13,7 @@ import {
13
13
  getModelPerformanceSeries,
14
14
  getModelTimeSeries,
15
15
  getOverallStats,
16
+ getStatsByAgent,
16
17
  getStatsByFolder,
17
18
  getStatsByModel,
18
19
  getTimeSeries,
@@ -367,6 +368,7 @@ export async function getDashboardStats(range?: string | null): Promise<Dashboar
367
368
  overall: getOverallStats(cutoff ?? undefined),
368
369
  byModel: getStatsByModel(cutoff ?? undefined),
369
370
  byFolder: getStatsByFolder(cutoff ?? undefined),
371
+ byAgent: getStatsByAgent(cutoff ?? undefined),
370
372
  timeSeries: getTimeSeries(timeSeriesHours, cutoff, timeSeriesBucketMs),
371
373
  modelSeries: getModelTimeSeries(modelSeriesDays, cutoff, modelSeriesBucketMs),
372
374
  modelPerformanceSeries: getModelPerformanceSeries(modelPerformanceDays, cutoff, modelPerformanceBucketMs),
@@ -13,6 +13,7 @@
13
13
  */
14
14
 
15
15
  import type {
16
+ AgentRole,
16
17
  AggregatedStats,
17
18
  CostTimeSeriesPoint,
18
19
  ModelPerformancePoint,
@@ -44,6 +45,7 @@ export interface MessageStats {
44
45
  sessionFile: string;
45
46
  entryId: string;
46
47
  folder: string;
48
+ agent: AgentRole;
47
49
  model: string;
48
50
  provider: string;
49
51
  api: string;
package/src/db.ts CHANGED
@@ -3,6 +3,8 @@ import * as fs from "node:fs/promises";
3
3
  import { type GeneratedProvider, getBundledModel, type Usage } from "@gajae-code/ai";
4
4
  import { getConfigRootDir, getStatsDbPath } from "@gajae-code/utils";
5
5
  import type {
6
+ AgentRole,
7
+ AgentStats,
6
8
  AggregatedStats,
7
9
  BehaviorModelStats,
8
10
  BehaviorOverallStats,
@@ -33,10 +35,28 @@ interface CostBackfillRow {
33
35
  cache_write_tokens: number;
34
36
  }
35
37
 
38
+ interface AgentStatsRow {
39
+ agent: AgentRole;
40
+ total_requests: number;
41
+ failed_requests: number;
42
+ total_input_tokens: number | null;
43
+ total_output_tokens: number | null;
44
+ total_cache_read_tokens: number | null;
45
+ total_cache_write_tokens: number | null;
46
+ total_premium_requests: number | null;
47
+ total_cost: number | null;
48
+ avg_duration: number | null;
49
+ avg_ttft: number | null;
50
+ avg_tokens_per_second: number | null;
51
+ first_timestamp: number | null;
52
+ last_timestamp: number | null;
53
+ }
54
+
36
55
  let db: Database | null = null;
37
56
 
38
57
  const BACKFILL_COMPLETE = "complete";
39
58
  const BACKFILL_PENDING = "pending";
59
+ const AGENT_ROLE_BACKFILL_KEY = "agent_role_attribution_v1";
40
60
  const USER_MESSAGES_BACKFILL_KEY = "user_messages_v5";
41
61
  const USER_MESSAGE_LINKS_REPAIR_KEY = "user_message_links_v1";
42
62
  const PRIORITY_PREMIUM_REQUESTS_BACKFILL_KEY = "premium_requests_priority_v1";
@@ -61,6 +81,7 @@ export async function initDb(): Promise<Database> {
61
81
  id INTEGER PRIMARY KEY AUTOINCREMENT,
62
82
  session_file TEXT NOT NULL,
63
83
  entry_id TEXT NOT NULL,
84
+ agent TEXT NOT NULL DEFAULT 'unknown',
64
85
  folder TEXT NOT NULL,
65
86
  model TEXT NOT NULL,
66
87
  provider TEXT NOT NULL,
@@ -130,6 +151,10 @@ export async function initDb(): Promise<Database> {
130
151
  if (!messageColumns.some(column => column.name === "premium_requests")) {
131
152
  db.exec("ALTER TABLE messages ADD COLUMN premium_requests REAL NOT NULL DEFAULT 0");
132
153
  }
154
+ if (!messageColumns.some(column => column.name === "agent")) {
155
+ db.exec("ALTER TABLE messages ADD COLUMN agent TEXT NOT NULL DEFAULT 'unknown'");
156
+ }
157
+ db.exec("CREATE INDEX IF NOT EXISTS idx_messages_agent ON messages(agent)");
133
158
  db.exec("UPDATE messages SET premium_requests = 0 WHERE premium_requests IS NULL");
134
159
  // Each behavior-metric bump invalidates previously-ingested rows. We detect
135
160
  // the stale schema by column name and drop the table; `IF NOT EXISTS` above
@@ -179,6 +204,7 @@ export async function initDb(): Promise<Database> {
179
204
  CREATE INDEX IF NOT EXISTS idx_user_messages_timestamp_model ON user_messages(timestamp, model, provider);
180
205
  `);
181
206
  }
207
+ backfillAgentRoleAttribution(db);
182
208
  backfillUserMessages(db);
183
209
  repairUserMessageLinks(db);
184
210
  backfillPriorityPremiumRequests(db);
@@ -324,21 +350,19 @@ export function setFileOffset(sessionFile: string, offset: number, lastModified:
324
350
  export function insertMessageStats(stats: ParsedMessageStats[]): number {
325
351
  if (!db || stats.length === 0) return 0;
326
352
 
327
- // Use UPSERT so a re-sync can fix up `premium_requests` for rows persisted
328
- // before priority service-tier traffic was counted as premium. The guard
329
- // `WHERE messages.premium_requests < excluded.premium_requests` keeps every
330
- // other column immutable and never demotes an existing count (e.g. when a
331
- // later parse drops back to 0 for the same row).
353
+ // Re-syncs repair role attribution for existing rows and only promote
354
+ // `premium_requests`; every other message field stays immutable.
332
355
  const stmt = db.prepare(`
333
356
  INSERT INTO messages (
334
- session_file, entry_id, folder, model, provider, api, timestamp,
357
+ session_file, entry_id, agent, folder, model, provider, api, timestamp,
335
358
  duration, ttft, stop_reason, error_message,
336
359
  input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, total_tokens, premium_requests,
337
360
  cost_input, cost_output, cost_cache_read, cost_cache_write, cost_total
338
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
361
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
339
362
  ON CONFLICT(session_file, entry_id) DO UPDATE SET
340
- premium_requests = excluded.premium_requests
341
- WHERE messages.premium_requests < excluded.premium_requests
363
+ agent = excluded.agent,
364
+ premium_requests = MAX(messages.premium_requests, excluded.premium_requests)
365
+ WHERE messages.agent <> excluded.agent OR messages.premium_requests < excluded.premium_requests
342
366
  `);
343
367
 
344
368
  let inserted = 0;
@@ -348,6 +372,7 @@ export function insertMessageStats(stats: ParsedMessageStats[]): number {
348
372
  const result = stmt.run(
349
373
  s.sessionFile,
350
374
  s.entryId,
375
+ s.agent,
351
376
  s.folder,
352
377
  s.model,
353
378
  s.provider,
@@ -536,6 +561,41 @@ export function getStatsByFolder(cutoff?: number): FolderStats[] {
536
561
  }));
537
562
  }
538
563
 
564
+ /**
565
+ * Get stats grouped by the persisted agent role.
566
+ */
567
+ export function getStatsByAgent(cutoff?: number): AgentStats[] {
568
+ if (!db) return [];
569
+
570
+ const hasCutoff = cutoff !== undefined && cutoff > 0;
571
+ const stmt = db.prepare(`
572
+ SELECT
573
+ agent,
574
+ COUNT(*) as total_requests,
575
+ SUM(CASE WHEN stop_reason = 'error' THEN 1 ELSE 0 END) as failed_requests,
576
+ SUM(input_tokens) as total_input_tokens,
577
+ SUM(output_tokens) as total_output_tokens,
578
+ SUM(cache_read_tokens) as total_cache_read_tokens,
579
+ SUM(cache_write_tokens) as total_cache_write_tokens,
580
+ SUM(premium_requests) as total_premium_requests,
581
+ SUM(cost_total) as total_cost,
582
+ AVG(duration) as avg_duration,
583
+ AVG(ttft) as avg_ttft,
584
+ AVG(CASE WHEN duration > 0 THEN output_tokens * 1000.0 / duration ELSE NULL END) as avg_tokens_per_second,
585
+ MIN(timestamp) as first_timestamp,
586
+ MAX(timestamp) as last_timestamp
587
+ FROM messages
588
+ ${hasCutoff ? "WHERE timestamp >= ?" : ""}
589
+ GROUP BY agent
590
+ ORDER BY total_requests DESC, agent ASC
591
+ `);
592
+ const rows = (hasCutoff ? stmt.all(cutoff) : stmt.all()) as AgentStatsRow[];
593
+ return rows.map(row => ({
594
+ agent: row.agent,
595
+ ...buildAggregatedStats([row]),
596
+ }));
597
+ }
598
+
539
599
  /**
540
600
  * Get time series data.
541
601
  */
@@ -680,6 +740,7 @@ function rowToMessageStats(row: any): MessageStats {
680
740
  sessionFile: row.session_file,
681
741
  entryId: row.entry_id,
682
742
  folder: row.folder,
743
+ agent: row.agent as AgentRole,
683
744
  model: row.model,
684
745
  provider: row.provider,
685
746
  api: row.api,
@@ -829,6 +890,19 @@ function repairUserMessageLinks(database: Database): void {
829
890
  .run(USER_MESSAGE_LINKS_REPAIR_KEY, BACKFILL_PENDING);
830
891
  }
831
892
 
893
+ /** Reparse existing sessions so stored assistant rows gain role attribution. */
894
+ function backfillAgentRoleAttribution(database: Database): void {
895
+ const row = database.prepare("SELECT value FROM meta WHERE key = ?").get(AGENT_ROLE_BACKFILL_KEY) as
896
+ | { value: string }
897
+ | undefined;
898
+ if (!shouldResetBackfill(row?.value)) return;
899
+
900
+ database.exec("DELETE FROM file_offsets");
901
+ database
902
+ .prepare("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)")
903
+ .run(AGENT_ROLE_BACKFILL_KEY, BACKFILL_PENDING);
904
+ }
905
+
832
906
  /**
833
907
  * One-shot wipe of `file_offsets` so the next sync re-parses every session
834
908
  * and re-derives `premium_requests` from recorded `service_tier_change`
package/src/index.ts CHANGED
@@ -17,6 +17,8 @@ export {
17
17
  export { closeDb } from "./db";
18
18
  export { startServer } from "./server";
19
19
  export type {
20
+ AgentRole,
21
+ AgentStats,
20
22
  AggregatedStats,
21
23
  DashboardStats,
22
24
  FolderStats,
@@ -45,7 +47,7 @@ function normalizePremiumRequests(n: number): number {
45
47
  */
46
48
  async function printStats(): Promise<void> {
47
49
  const stats = await getDashboardStats();
48
- const { overall, byModel, byFolder } = stats;
50
+ const { overall, byModel, byFolder, byAgent } = stats;
49
51
 
50
52
  console.log("\n=== AI Usage Statistics ===\n");
51
53
 
@@ -73,6 +75,15 @@ async function printStats(): Promise<void> {
73
75
  }
74
76
  }
75
77
 
78
+ if (byAgent.length > 0) {
79
+ console.log("\nBy Agent Role:");
80
+ for (const agent of byAgent) {
81
+ console.log(
82
+ ` ${agent.agent}: ${formatNumber(agent.totalRequests)} reqs, ${formatNumber(agent.totalInputTokens)} input / ${formatNumber(agent.totalOutputTokens)} output, ${formatPercent(agent.cacheRate)} cache, ${formatCost(agent.totalCost)}`,
83
+ );
84
+ }
85
+ }
86
+
76
87
  if (byFolder.length > 0) {
77
88
  console.log("\nBy Folder:");
78
89
  for (const f of byFolder.slice(0, 10)) {
package/src/parser.ts CHANGED
@@ -3,6 +3,7 @@ import * as path from "node:path";
3
3
  import { getPriorityPremiumRequests, type ServiceTier } from "@gajae-code/ai";
4
4
  import { getSessionsDir, isEnoent } from "@gajae-code/utils";
5
5
  import type {
6
+ AgentRole,
6
7
  ParsedMessageStats,
7
8
  SessionAssistantMessage,
8
9
  SessionEntry,
@@ -68,6 +69,30 @@ function isServiceTierChange(entry: SessionEntry): entry is SessionServiceTierCh
68
69
  return isObject(entry) && entry.type === "service_tier_change";
69
70
  }
70
71
 
72
+ function inferAgentRoleFromPath(sessionPath: string): AgentRole {
73
+ const relativePath = path.relative(getSessionsDir(), sessionPath);
74
+ const pathDepth = relativePath.split(path.sep).filter(Boolean).length;
75
+ // Root transcripts live directly under a project directory. Task transcripts
76
+ // live in a directory nested beneath their parent transcript, so legacy child
77
+ // sessions without a persisted identity must not be mistaken for `default`.
78
+ return pathDepth <= 2 ? "default" : "unknown";
79
+ }
80
+
81
+ function agentRoleFromSessionEntry(entry: unknown): AgentRole | undefined {
82
+ if (!isObject(entry) || entry.type !== "configured_model_chain") return undefined;
83
+ if (entry.role !== "default" || entry.origin !== "subagent") return undefined;
84
+ if (!isNonemptyString(entry.identity)) return "unknown";
85
+ switch (entry.identity) {
86
+ case "executor":
87
+ case "planner":
88
+ case "architect":
89
+ case "critic":
90
+ return entry.identity;
91
+ default:
92
+ return "other";
93
+ }
94
+ }
95
+
71
96
  /**
72
97
  * Extract plain text from a user message content payload.
73
98
  */
@@ -120,6 +145,7 @@ function extractStats(
120
145
  folder: string,
121
146
  entry: SessionMessageEntry,
122
147
  currentServiceTier: ServiceTier | undefined,
148
+ agentRole: AgentRole,
123
149
  ): ParsedMessageStats | null {
124
150
  const msg = entry.message as SessionAssistantMessage;
125
151
  if (msg?.role !== "assistant") return null;
@@ -150,6 +176,7 @@ function extractStats(
150
176
  sessionFile,
151
177
  entryId: entry.id,
152
178
  folder,
179
+ agent: agentRole,
153
180
  model: msg.model,
154
181
  provider: msg.provider,
155
182
  api: msg.api,
@@ -191,14 +218,20 @@ function parseSessionEntriesLenient(bytes: Uint8Array): { entries: SessionEntry[
191
218
  return { entries, read: cursor };
192
219
  }
193
220
 
194
- function scanLastServiceTier(bytes: Uint8Array): ServiceTier | undefined {
221
+ function scanSessionMetadata(
222
+ bytes: Uint8Array,
223
+ initialAgentRole: AgentRole,
224
+ ): { serviceTier: ServiceTier | undefined; agentRole: AgentRole } {
195
225
  let cursor = 0;
196
226
  let currentServiceTier: ServiceTier | undefined;
227
+ let currentAgentRole = initialAgentRole;
197
228
 
198
229
  while (cursor < bytes.length) {
199
230
  const { values, error, read, done } = Bun.JSONL.parseChunk(bytes, cursor, bytes.length);
200
231
  for (const value of values as SessionEntry[]) {
201
232
  if (isServiceTierChange(value)) currentServiceTier = value.serviceTier ?? undefined;
233
+ const agentRole = agentRoleFromSessionEntry(value);
234
+ if (agentRole !== undefined) currentAgentRole = agentRole;
202
235
  }
203
236
 
204
237
  if (error) {
@@ -213,23 +246,19 @@ function scanLastServiceTier(bytes: Uint8Array): ServiceTier | undefined {
213
246
  if (done) break;
214
247
  }
215
248
 
216
- return currentServiceTier;
249
+ return { serviceTier: currentServiceTier, agentRole: currentAgentRole };
217
250
  }
218
251
  /**
219
252
  * Parse a session file and extract all assistant message stats.
220
253
  * Uses incremental reading with offset tracking.
221
254
  *
222
- * Service-tier carry-over: `currentServiceTier` is a session-scoped piece of
223
- * state derived from `service_tier_change` entries that affects whether
224
- * subsequent OpenAI assistant replies count as premium requests. Incremental
225
- * syncs that resume past the most-recent tier change would otherwise lose
226
- * that state and silently record `premiumRequests = 0` for priority traffic
227
- * (the coding-agent stopped folding the tier into `usage.premiumRequests`
228
- * after 13f59162e — the parser is now the sole source of truth). When
229
- * `fromOffset > 0` we therefore scan the bytes preceding `fromOffset`
230
- * for the latest service-tier value before parsing the unprocessed tail.
231
- * The scan only keeps the current tier and does not materialize prefix
232
- * entries, preserving offset-based memory behavior for large sessions.
255
+ * Service-tier and agent-role carry-over are session-scoped state. Incremental
256
+ * syncs that resume after either metadata entry would otherwise lose the
257
+ * context needed to attribute priority requests and assistant usage. When
258
+ * `fromOffset > 0`, scan the prefix for the current values before parsing the
259
+ * unprocessed tail. The scan keeps only current metadata and does not
260
+ * materialize prefix entries, preserving offset-based memory behavior for
261
+ * large sessions.
233
262
  */
234
263
  export interface ParseSessionResult {
235
264
  stats: ParsedMessageStats[];
@@ -252,17 +281,26 @@ export async function parseSessionFile(sessionPath: string, fromOffset = 0): Pro
252
281
  const userLinks: UserMessageLink[] = [];
253
282
  const userByEntryId = new Map<string, UserMessageStats>();
254
283
  const start = Math.max(0, Math.min(fromOffset, bytes.length));
284
+ const initialAgentRole = inferAgentRoleFromPath(sessionPath);
255
285
  const unprocessed = bytes.subarray(start);
256
286
  const { entries, read } = parseSessionEntriesLenient(unprocessed);
257
287
  let currentServiceTier: ServiceTier | undefined;
288
+ let currentAgentRole = initialAgentRole;
258
289
  if (start > 0) {
259
- currentServiceTier = scanLastServiceTier(bytes.subarray(0, start));
290
+ const metadata = scanSessionMetadata(bytes.subarray(0, start), initialAgentRole);
291
+ currentServiceTier = metadata.serviceTier;
292
+ currentAgentRole = metadata.agentRole;
260
293
  }
261
294
  for (const entry of entries) {
262
295
  if (isServiceTierChange(entry)) {
263
296
  currentServiceTier = entry.serviceTier ?? undefined;
264
297
  continue;
265
298
  }
299
+ const agentRole = agentRoleFromSessionEntry(entry);
300
+ if (agentRole !== undefined) {
301
+ currentAgentRole = agentRole;
302
+ continue;
303
+ }
266
304
  if (isUserMessage(entry)) {
267
305
  const userMsg = extractUserStats(sessionPath, folder, entry);
268
306
  if (userMsg) {
@@ -272,7 +310,7 @@ export async function parseSessionFile(sessionPath: string, fromOffset = 0): Pro
272
310
  continue;
273
311
  }
274
312
  if (isAssistantMessage(entry)) {
275
- const msgStats = extractStats(sessionPath, folder, entry, currentServiceTier);
313
+ const msgStats = extractStats(sessionPath, folder, entry, currentServiceTier, currentAgentRole);
276
314
  if (msgStats) stats.push(msgStats);
277
315
  // Link assistant's responding model back to the user message it answered.
278
316
  const parentId = (entry as SessionMessageEntry).parentId;
@@ -6,7 +6,7 @@
6
6
  */
7
7
 
8
8
  /**
9
- * Aggregated stats for a model or folder.
9
+ * Aggregated stats for a model, folder, or agent role.
10
10
  */
11
11
  export interface AggregatedStats {
12
12
  /** Total number of requests */
@@ -57,6 +57,14 @@ export interface FolderStats extends AggregatedStats {
57
57
  folder: string;
58
58
  }
59
59
 
60
+ /** Agent role attributed from the session transcript. */
61
+ export type AgentRole = "default" | "executor" | "planner" | "architect" | "critic" | "other" | "unknown";
62
+
63
+ /** Stats grouped by the agent role that produced the assistant request. */
64
+ export interface AgentStats extends AggregatedStats {
65
+ agent: AgentRole;
66
+ }
67
+
60
68
  /**
61
69
  * Time series data point.
62
70
  */
@@ -133,6 +141,7 @@ export interface DashboardStats {
133
141
  overall: AggregatedStats;
134
142
  byModel: ModelStats[];
135
143
  byFolder: FolderStats[];
144
+ byAgent: AgentStats[];
136
145
  timeSeries: TimeSeriesPoint[];
137
146
  modelSeries: ModelTimeSeriesPoint[];
138
147
  modelPerformanceSeries: ModelPerformancePoint[];
package/src/types.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { AssistantMessage, ServiceTier, StopReason, Usage } from "@gajae-code/ai";
2
+ import type { AgentRole } from "./shared-types";
2
3
 
3
4
  export * from "./shared-types";
4
5
 
@@ -14,6 +15,8 @@ export interface MessageStats {
14
15
  entryId: string;
15
16
  /** Folder/project path (extracted from session filename) */
16
17
  folder: string;
18
+ /** Agent role inferred from the persisted session identity. */
19
+ agent: AgentRole;
17
20
  /** Model ID */
18
21
  model: string;
19
22
  /** Provider name */