@gajae-code/stats 0.17.2 → 0.17.5

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, 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
  */
@@ -18,7 +18,7 @@ export declare function setFileOffset(sessionFile: string, offset: number, lastM
18
18
  /**
19
19
  * Insert message stats into the database.
20
20
  */
21
- export declare function insertMessageStats(stats: MessageStats[]): number;
21
+ export declare function insertMessageStats(stats: ParsedMessageStats[]): number;
22
22
  /**
23
23
  * Get overall aggregated stats.
24
24
  */
@@ -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";
@@ -1,22 +1,18 @@
1
- import type { MessageStats, SessionEntry, UserMessageLink, UserMessageStats } from "./types";
1
+ import type { ParsedMessageStats, SessionEntry, UserMessageLink, UserMessageStats } from "./types";
2
2
  /**
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
- stats: MessageStats[];
15
+ stats: ParsedMessageStats[];
20
16
  userStats: UserMessageStats[];
21
17
  userLinks: UserMessageLink[];
22
18
  newOffset: number;
@@ -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 */
@@ -25,7 +28,7 @@ export interface MessageStats {
25
28
  /** Time to first token in milliseconds */
26
29
  ttft: number | null;
27
30
  /** Stop reason */
28
- stopReason: StopReason;
31
+ stopReason: StopReason | "unknown";
29
32
  /** Error message if stopReason is error */
30
33
  errorMessage: string | null;
31
34
  /** Token usage */
@@ -51,12 +54,22 @@ export interface SessionHeader {
51
54
  cwd: string;
52
55
  title?: string;
53
56
  }
57
+ /** Historical JSONL may contain missing, partial, or malformed cost payloads. */
58
+ export type SessionAssistantMessage = Omit<AssistantMessage, "usage"> & {
59
+ usage: Omit<Usage, "cost"> & {
60
+ cost?: unknown;
61
+ };
62
+ };
63
+ /** Parser output retains untrusted costs until the database insertion boundary. */
64
+ export type ParsedMessageStats = Omit<MessageStats, "usage"> & {
65
+ usage: SessionAssistantMessage["usage"];
66
+ };
54
67
  export interface SessionMessageEntry {
55
68
  type: "message";
56
69
  id: string;
57
70
  parentId: string | null;
58
71
  timestamp: string;
59
- message: AssistantMessage | {
72
+ message: SessionAssistantMessage | {
60
73
  role: "user" | "toolResult";
61
74
  };
62
75
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@gajae-code/stats",
4
- "version": "0.17.2",
4
+ "version": "0.17.5",
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.2",
42
- "@gajae-code/utils": "0.17.2",
41
+ "@gajae-code/ai": "0.17.5",
42
+ "@gajae-code/utils": "0.17.5",
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,
@@ -13,6 +15,7 @@ import type {
13
15
  ModelPerformancePoint,
14
16
  ModelStats,
15
17
  ModelTimeSeriesPoint,
18
+ ParsedMessageStats,
16
19
  TimeSeriesPoint,
17
20
  UserMessageLink,
18
21
  UserMessageStats,
@@ -32,10 +35,28 @@ interface CostBackfillRow {
32
35
  cache_write_tokens: number;
33
36
  }
34
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
+
35
55
  let db: Database | null = null;
36
56
 
37
57
  const BACKFILL_COMPLETE = "complete";
38
58
  const BACKFILL_PENDING = "pending";
59
+ const AGENT_ROLE_BACKFILL_KEY = "agent_role_attribution_v1";
39
60
  const USER_MESSAGES_BACKFILL_KEY = "user_messages_v5";
40
61
  const USER_MESSAGE_LINKS_REPAIR_KEY = "user_message_links_v1";
41
62
  const PRIORITY_PREMIUM_REQUESTS_BACKFILL_KEY = "premium_requests_priority_v1";
@@ -60,6 +81,7 @@ export async function initDb(): Promise<Database> {
60
81
  id INTEGER PRIMARY KEY AUTOINCREMENT,
61
82
  session_file TEXT NOT NULL,
62
83
  entry_id TEXT NOT NULL,
84
+ agent TEXT NOT NULL DEFAULT 'unknown',
63
85
  folder TEXT NOT NULL,
64
86
  model TEXT NOT NULL,
65
87
  provider TEXT NOT NULL,
@@ -129,6 +151,10 @@ export async function initDb(): Promise<Database> {
129
151
  if (!messageColumns.some(column => column.name === "premium_requests")) {
130
152
  db.exec("ALTER TABLE messages ADD COLUMN premium_requests REAL NOT NULL DEFAULT 0");
131
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)");
132
158
  db.exec("UPDATE messages SET premium_requests = 0 WHERE premium_requests IS NULL");
133
159
  // Each behavior-metric bump invalidates previously-ingested rows. We detect
134
160
  // the stale schema by column name and drop the table; `IF NOT EXISTS` above
@@ -178,6 +204,7 @@ export async function initDb(): Promise<Database> {
178
204
  CREATE INDEX IF NOT EXISTS idx_user_messages_timestamp_model ON user_messages(timestamp, model, provider);
179
205
  `);
180
206
  }
207
+ backfillAgentRoleAttribution(db);
181
208
  backfillUserMessages(db);
182
209
  repairUserMessageLinks(db);
183
210
  backfillPriorityPremiumRequests(db);
@@ -228,12 +255,32 @@ function calculateCatalogCost(provider: string, modelId: string, tokens: CostTok
228
255
  };
229
256
  }
230
257
 
231
- function resolveStoredCost(stats: MessageStats): UsageCost {
232
- if (stats.usage.cost.total !== 0) {
233
- return stats.usage.cost;
234
- }
235
-
236
- return calculateCatalogCost(stats.provider, stats.model, stats.usage) ?? stats.usage.cost;
258
+ function resolveStoredCost(stats: ParsedMessageStats): UsageCost {
259
+ const raw = stats.usage.cost;
260
+ const recorded = raw && typeof raw === "object" ? (raw as Record<string, unknown>) : {};
261
+ const finite = (value: unknown): value is number => typeof value === "number" && Number.isFinite(value);
262
+ const keys = ["input", "output", "cacheRead", "cacheWrite", "total"] as const;
263
+ // Complete zero-cost payloads historically request catalog pricing. A partial
264
+ // payload is different: preserve every valid recorded value, including zero.
265
+ const allZero = keys.every(key => recorded[key] === 0);
266
+ const catalog = calculateCatalogCost(stats.provider, stats.model, stats.usage);
267
+ const component = (key: keyof ModelCost): number => {
268
+ if (!allZero && finite(recorded[key])) return recorded[key];
269
+ const fallback = catalog?.[key];
270
+ return finite(fallback) ? fallback : 0;
271
+ };
272
+ const input = component("input");
273
+ const output = component("output");
274
+ const cacheRead = component("cacheRead");
275
+ const cacheWrite = component("cacheWrite");
276
+ const sum = input + output + cacheRead + cacheWrite;
277
+ return {
278
+ input,
279
+ output,
280
+ cacheRead,
281
+ cacheWrite,
282
+ total: !allZero && finite(recorded.total) ? recorded.total : finite(sum) ? sum : 0,
283
+ };
237
284
  }
238
285
 
239
286
  function backfillMissingCatalogCosts(database: Database): void {
@@ -242,6 +289,7 @@ function backfillMissingCatalogCosts(database: Database): void {
242
289
  SELECT id, provider, model, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens
243
290
  FROM messages
244
291
  WHERE cost_total = 0 AND total_tokens > 0
292
+ AND cost_input = 0 AND cost_output = 0 AND cost_cache_read = 0 AND cost_cache_write = 0
245
293
  `)
246
294
  .all() as CostBackfillRow[];
247
295
 
@@ -299,24 +347,22 @@ export function setFileOffset(sessionFile: string, offset: number, lastModified:
299
347
  /**
300
348
  * Insert message stats into the database.
301
349
  */
302
- export function insertMessageStats(stats: MessageStats[]): number {
350
+ export function insertMessageStats(stats: ParsedMessageStats[]): number {
303
351
  if (!db || stats.length === 0) return 0;
304
352
 
305
- // Use UPSERT so a re-sync can fix up `premium_requests` for rows persisted
306
- // before priority service-tier traffic was counted as premium. The guard
307
- // `WHERE messages.premium_requests < excluded.premium_requests` keeps every
308
- // other column immutable and never demotes an existing count (e.g. when a
309
- // 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.
310
355
  const stmt = db.prepare(`
311
356
  INSERT INTO messages (
312
- session_file, entry_id, folder, model, provider, api, timestamp,
357
+ session_file, entry_id, agent, folder, model, provider, api, timestamp,
313
358
  duration, ttft, stop_reason, error_message,
314
359
  input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, total_tokens, premium_requests,
315
360
  cost_input, cost_output, cost_cache_read, cost_cache_write, cost_total
316
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
361
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
317
362
  ON CONFLICT(session_file, entry_id) DO UPDATE SET
318
- premium_requests = excluded.premium_requests
319
- 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
320
366
  `);
321
367
 
322
368
  let inserted = 0;
@@ -326,6 +372,7 @@ export function insertMessageStats(stats: MessageStats[]): number {
326
372
  const result = stmt.run(
327
373
  s.sessionFile,
328
374
  s.entryId,
375
+ s.agent,
329
376
  s.folder,
330
377
  s.model,
331
378
  s.provider,
@@ -514,6 +561,41 @@ export function getStatsByFolder(cutoff?: number): FolderStats[] {
514
561
  }));
515
562
  }
516
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
+
517
599
  /**
518
600
  * Get time series data.
519
601
  */
@@ -658,6 +740,7 @@ function rowToMessageStats(row: any): MessageStats {
658
740
  sessionFile: row.session_file,
659
741
  entryId: row.entry_id,
660
742
  folder: row.folder,
743
+ agent: row.agent as AgentRole,
661
744
  model: row.model,
662
745
  provider: row.provider,
663
746
  api: row.api,
@@ -807,6 +890,19 @@ function repairUserMessageLinks(database: Database): void {
807
890
  .run(USER_MESSAGE_LINKS_REPAIR_KEY, BACKFILL_PENDING);
808
891
  }
809
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
+
810
906
  /**
811
907
  * One-shot wipe of `file_offsets` so the next sync re-parses every session
812
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
@@ -1,9 +1,11 @@
1
1
  import * as fs from "node:fs/promises";
2
2
  import * as path from "node:path";
3
- import { type AssistantMessage, getPriorityPremiumRequests, type ServiceTier } from "@gajae-code/ai";
3
+ import { getPriorityPremiumRequests, type ServiceTier } from "@gajae-code/ai";
4
4
  import { getSessionsDir, isEnoent } from "@gajae-code/utils";
5
5
  import type {
6
- MessageStats,
6
+ AgentRole,
7
+ ParsedMessageStats,
8
+ SessionAssistantMessage,
7
9
  SessionEntry,
8
10
  SessionMessageEntry,
9
11
  SessionServiceTierChangeEntry,
@@ -25,34 +27,70 @@ function extractFolderFromPath(sessionPath: string): string {
25
27
  return projectDir.replace(/^--/, "/").replace(/--/g, "/");
26
28
  }
27
29
 
30
+ function isObject(value: unknown): value is Record<string, unknown> {
31
+ return value !== null && typeof value === "object" && !Array.isArray(value);
32
+ }
33
+
34
+ function isNonemptyString(value: unknown): value is string {
35
+ return typeof value === "string" && value.trim().length > 0;
36
+ }
37
+
38
+ function isNonnegativeNumber(value: unknown): value is number {
39
+ return typeof value === "number" && Number.isFinite(value) && value >= 0;
40
+ }
41
+
28
42
  /**
29
43
  * Check if an entry is an assistant message.
30
44
  */
31
45
  function isAssistantMessage(entry: SessionEntry): entry is SessionMessageEntry {
32
- if (entry.type !== "message") return false;
46
+ if (!isObject(entry) || entry.type !== "message") return false;
33
47
  const msgEntry = entry as SessionMessageEntry;
34
48
  // Legacy sessions (pre-id tracking) recorded message entries without an `id`.
35
49
  // They're not linkable and would violate the messages.entry_id NOT NULL
36
50
  // constraint, so skip them at the parser boundary.
37
51
  if (typeof msgEntry.id !== "string" || msgEntry.id.length === 0) return false;
38
- return msgEntry.message?.role === "assistant";
52
+ return isObject(msgEntry.message) && msgEntry.message.role === "assistant";
39
53
  }
40
54
 
41
55
  /**
42
56
  * Check if an entry is a user message (non-toolResult).
43
57
  */
44
58
  function isUserMessage(entry: SessionEntry): entry is SessionMessageEntry {
45
- if (entry.type !== "message") return false;
59
+ if (!isObject(entry) || entry.type !== "message") return false;
46
60
  const msgEntry = entry as SessionMessageEntry;
47
61
  if (typeof msgEntry.id !== "string" || msgEntry.id.length === 0) return false;
48
- return msgEntry.message?.role === "user";
62
+ return isObject(msgEntry.message) && msgEntry.message.role === "user";
49
63
  }
50
64
 
51
65
  /**
52
66
  * Check if an entry is a service-tier change.
53
67
  */
54
68
  function isServiceTierChange(entry: SessionEntry): entry is SessionServiceTierChangeEntry {
55
- return entry.type === "service_tier_change";
69
+ return isObject(entry) && entry.type === "service_tier_change";
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
+ }
56
94
  }
57
95
 
58
96
  /**
@@ -107,9 +145,22 @@ function extractStats(
107
145
  folder: string,
108
146
  entry: SessionMessageEntry,
109
147
  currentServiceTier: ServiceTier | undefined,
110
- ): MessageStats | null {
111
- const msg = entry.message as AssistantMessage;
148
+ agentRole: AgentRole,
149
+ ): ParsedMessageStats | null {
150
+ const msg = entry.message as SessionAssistantMessage;
112
151
  if (msg?.role !== "assistant") return null;
152
+ // Incomplete historical metadata cannot be safely attributed or counted.
153
+ // Skip malformed rows, as with legacy entries lacking an ID; do not invent usage.
154
+ if (!isNonemptyString(msg.model) || !isNonemptyString(msg.provider) || !isNonemptyString(msg.api)) return null;
155
+ if (!isNonnegativeNumber(msg.timestamp) || !isObject(msg.usage)) return null;
156
+ if (
157
+ !isNonnegativeNumber(msg.usage.input) ||
158
+ !isNonnegativeNumber(msg.usage.output) ||
159
+ !isNonnegativeNumber(msg.usage.cacheRead) ||
160
+ !isNonnegativeNumber(msg.usage.cacheWrite) ||
161
+ !isNonnegativeNumber(msg.usage.totalTokens)
162
+ )
163
+ return null;
113
164
 
114
165
  // Backfill: when the session recorded `priority` as the active service tier
115
166
  // at this point but the AI usage payload was captured before priority
@@ -117,22 +168,25 @@ function extractStats(
117
168
  // "Premium Reqs" stat aggregates priority traffic on re-sync. Trust any
118
169
  // non-zero value already in `usage.premiumRequests` (Copilot multipliers or
119
170
  // the new AI code path) and only synthesise when the field is missing/zero.
120
- const recorded = msg.usage.premiumRequests ?? 0;
171
+ const recorded = isNonnegativeNumber(msg.usage.premiumRequests) ? msg.usage.premiumRequests : 0;
121
172
  const derived = recorded > 0 ? recorded : getPriorityPremiumRequests(currentServiceTier, msg.provider);
122
- const usage = derived === recorded ? msg.usage : { ...msg.usage, premiumRequests: derived };
173
+ const usage = { ...msg.usage, premiumRequests: derived };
123
174
 
124
175
  return {
125
176
  sessionFile,
126
177
  entryId: entry.id,
127
178
  folder,
179
+ agent: agentRole,
128
180
  model: msg.model,
129
181
  provider: msg.provider,
130
182
  api: msg.api,
131
183
  timestamp: msg.timestamp,
132
- duration: msg.duration ?? null,
133
- ttft: msg.ttft ?? null,
134
- stopReason: msg.stopReason,
135
- errorMessage: msg.errorMessage ?? null,
184
+ duration: isNonnegativeNumber(msg.duration) ? msg.duration : null,
185
+ ttft: isNonnegativeNumber(msg.ttft) ? msg.ttft : null,
186
+ // Historical session entries may omit stopReason. Keep the stats schema
187
+ // non-nullable while preserving those requests instead of aborting sync.
188
+ stopReason: isNonemptyString(msg.stopReason) ? msg.stopReason : "unknown",
189
+ errorMessage: typeof msg.errorMessage === "string" ? msg.errorMessage : null,
136
190
  usage,
137
191
  };
138
192
  }
@@ -164,14 +218,20 @@ function parseSessionEntriesLenient(bytes: Uint8Array): { entries: SessionEntry[
164
218
  return { entries, read: cursor };
165
219
  }
166
220
 
167
- function scanLastServiceTier(bytes: Uint8Array): ServiceTier | undefined {
221
+ function scanSessionMetadata(
222
+ bytes: Uint8Array,
223
+ initialAgentRole: AgentRole,
224
+ ): { serviceTier: ServiceTier | undefined; agentRole: AgentRole } {
168
225
  let cursor = 0;
169
226
  let currentServiceTier: ServiceTier | undefined;
227
+ let currentAgentRole = initialAgentRole;
170
228
 
171
229
  while (cursor < bytes.length) {
172
230
  const { values, error, read, done } = Bun.JSONL.parseChunk(bytes, cursor, bytes.length);
173
231
  for (const value of values as SessionEntry[]) {
174
232
  if (isServiceTierChange(value)) currentServiceTier = value.serviceTier ?? undefined;
233
+ const agentRole = agentRoleFromSessionEntry(value);
234
+ if (agentRole !== undefined) currentAgentRole = agentRole;
175
235
  }
176
236
 
177
237
  if (error) {
@@ -186,26 +246,22 @@ function scanLastServiceTier(bytes: Uint8Array): ServiceTier | undefined {
186
246
  if (done) break;
187
247
  }
188
248
 
189
- return currentServiceTier;
249
+ return { serviceTier: currentServiceTier, agentRole: currentAgentRole };
190
250
  }
191
251
  /**
192
252
  * Parse a session file and extract all assistant message stats.
193
253
  * Uses incremental reading with offset tracking.
194
254
  *
195
- * Service-tier carry-over: `currentServiceTier` is a session-scoped piece of
196
- * state derived from `service_tier_change` entries that affects whether
197
- * subsequent OpenAI assistant replies count as premium requests. Incremental
198
- * syncs that resume past the most-recent tier change would otherwise lose
199
- * that state and silently record `premiumRequests = 0` for priority traffic
200
- * (the coding-agent stopped folding the tier into `usage.premiumRequests`
201
- * after 13f59162e — the parser is now the sole source of truth). When
202
- * `fromOffset > 0` we therefore scan the bytes preceding `fromOffset`
203
- * for the latest service-tier value before parsing the unprocessed tail.
204
- * The scan only keeps the current tier and does not materialize prefix
205
- * 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.
206
262
  */
207
263
  export interface ParseSessionResult {
208
- stats: MessageStats[];
264
+ stats: ParsedMessageStats[];
209
265
  userStats: UserMessageStats[];
210
266
  userLinks: UserMessageLink[];
211
267
  newOffset: number;
@@ -220,22 +276,31 @@ export async function parseSessionFile(sessionPath: string, fromOffset = 0): Pro
220
276
  }
221
277
 
222
278
  const folder = extractFolderFromPath(sessionPath);
223
- const stats: MessageStats[] = [];
279
+ const stats: ParsedMessageStats[] = [];
224
280
  const userStats: UserMessageStats[] = [];
225
281
  const userLinks: UserMessageLink[] = [];
226
282
  const userByEntryId = new Map<string, UserMessageStats>();
227
283
  const start = Math.max(0, Math.min(fromOffset, bytes.length));
284
+ const initialAgentRole = inferAgentRoleFromPath(sessionPath);
228
285
  const unprocessed = bytes.subarray(start);
229
286
  const { entries, read } = parseSessionEntriesLenient(unprocessed);
230
287
  let currentServiceTier: ServiceTier | undefined;
288
+ let currentAgentRole = initialAgentRole;
231
289
  if (start > 0) {
232
- currentServiceTier = scanLastServiceTier(bytes.subarray(0, start));
290
+ const metadata = scanSessionMetadata(bytes.subarray(0, start), initialAgentRole);
291
+ currentServiceTier = metadata.serviceTier;
292
+ currentAgentRole = metadata.agentRole;
233
293
  }
234
294
  for (const entry of entries) {
235
295
  if (isServiceTierChange(entry)) {
236
296
  currentServiceTier = entry.serviceTier ?? undefined;
237
297
  continue;
238
298
  }
299
+ const agentRole = agentRoleFromSessionEntry(entry);
300
+ if (agentRole !== undefined) {
301
+ currentAgentRole = agentRole;
302
+ continue;
303
+ }
239
304
  if (isUserMessage(entry)) {
240
305
  const userMsg = extractUserStats(sessionPath, folder, entry);
241
306
  if (userMsg) {
@@ -245,13 +310,13 @@ export async function parseSessionFile(sessionPath: string, fromOffset = 0): Pro
245
310
  continue;
246
311
  }
247
312
  if (isAssistantMessage(entry)) {
248
- const msgStats = extractStats(sessionPath, folder, entry, currentServiceTier);
313
+ const msgStats = extractStats(sessionPath, folder, entry, currentServiceTier, currentAgentRole);
249
314
  if (msgStats) stats.push(msgStats);
250
315
  // Link assistant's responding model back to the user message it answered.
251
316
  const parentId = (entry as SessionMessageEntry).parentId;
252
- if (parentId) {
253
- const msg = entry.message as AssistantMessage;
254
- if (msg.model && msg.provider) {
317
+ if (isNonemptyString(parentId)) {
318
+ const msg = entry.message as SessionAssistantMessage;
319
+ if (isNonemptyString(msg.model) && isNonemptyString(msg.provider)) {
255
320
  // Emit unconditionally. The aggregator's UPDATE is guarded by
256
321
  // `model IS NULL` so this is idempotent: a no-op for already
257
322
  // linked rows, a fix-up for fresh inserts (which start NULL
@@ -326,7 +391,7 @@ export async function getSessionEntry(sessionPath: string, entryId: string): Pro
326
391
 
327
392
  const { entries } = parseSessionEntriesLenient(bytes);
328
393
  for (const entry of entries) {
329
- if ("id" in entry && entry.id === entryId) {
394
+ if (isObject(entry) && "id" in entry && entry.id === entryId) {
330
395
  return entry;
331
396
  }
332
397
  }
@@ -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 */
@@ -27,7 +30,7 @@ export interface MessageStats {
27
30
  /** Time to first token in milliseconds */
28
31
  ttft: number | null;
29
32
  /** Stop reason */
30
- stopReason: StopReason;
33
+ stopReason: StopReason | "unknown";
31
34
  /** Error message if stopReason is error */
32
35
  errorMessage: string | null;
33
36
  /** Token usage */
@@ -56,12 +59,20 @@ export interface SessionHeader {
56
59
  title?: string;
57
60
  }
58
61
 
62
+ /** Historical JSONL may contain missing, partial, or malformed cost payloads. */
63
+ export type SessionAssistantMessage = Omit<AssistantMessage, "usage"> & {
64
+ usage: Omit<Usage, "cost"> & { cost?: unknown };
65
+ };
66
+
67
+ /** Parser output retains untrusted costs until the database insertion boundary. */
68
+ export type ParsedMessageStats = Omit<MessageStats, "usage"> & { usage: SessionAssistantMessage["usage"] };
69
+
59
70
  export interface SessionMessageEntry {
60
71
  type: "message";
61
72
  id: string;
62
73
  parentId: string | null;
63
74
  timestamp: string;
64
- message: AssistantMessage | { role: "user" | "toolResult" };
75
+ message: SessionAssistantMessage | { role: "user" | "toolResult" };
65
76
  }
66
77
 
67
78
  export interface SessionServiceTierChangeEntry {