@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 +2 -0
- package/dist/types/client/types.d.ts +2 -1
- package/dist/types/db.d.ts +5 -1
- package/dist/types/index.d.ts +1 -1
- package/dist/types/parser.d.ts +7 -11
- package/dist/types/shared-types.d.ts +8 -1
- package/dist/types/types.d.ts +3 -0
- package/package.json +3 -3
- package/src/aggregator.ts +2 -0
- package/src/client/types.ts +2 -0
- package/src/db.ts +83 -9
- package/src/index.ts +12 -1
- package/src/parser.ts +53 -15
- package/src/shared-types.ts +10 -1
- package/src/types.ts +3 -0
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;
|
package/dist/types/db.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Database } from "bun:sqlite";
|
|
2
|
-
import type { AggregatedStats, BehaviorModelStats, BehaviorOverallStats, BehaviorTimeSeriesPoint, CostTimeSeriesPoint, FolderStats, MessageStats, ModelPerformancePoint, ModelStats, ModelTimeSeriesPoint, 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
|
*/
|
package/dist/types/index.d.ts
CHANGED
|
@@ -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";
|
package/dist/types/parser.d.ts
CHANGED
|
@@ -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
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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
|
|
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[];
|
package/dist/types/types.d.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
|
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
|
+
"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.
|
|
42
|
-
"@gajae-code/utils": "0.17.
|
|
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),
|
package/src/client/types.ts
CHANGED
|
@@ -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
|
-
//
|
|
328
|
-
//
|
|
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
|
-
|
|
341
|
-
|
|
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
|
|
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
|
|
223
|
-
*
|
|
224
|
-
*
|
|
225
|
-
*
|
|
226
|
-
*
|
|
227
|
-
*
|
|
228
|
-
*
|
|
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
|
-
|
|
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;
|
package/src/shared-types.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
|
-
* Aggregated stats for a model or
|
|
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 */
|