@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 +2 -0
- package/dist/types/client/types.d.ts +2 -1
- package/dist/types/db.d.ts +6 -2
- package/dist/types/index.d.ts +1 -1
- package/dist/types/parser.d.ts +9 -13
- package/dist/types/shared-types.d.ts +8 -1
- package/dist/types/types.d.ts +15 -2
- package/package.json +3 -3
- package/src/aggregator.ts +2 -0
- package/src/client/types.ts +2 -0
- package/src/db.ts +112 -16
- package/src/index.ts +12 -1
- package/src/parser.ts +101 -36
- package/src/shared-types.ts +10 -1
- package/src/types.ts +13 -2
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, 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:
|
|
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
|
*/
|
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
|
@@ -1,22 +1,18 @@
|
|
|
1
|
-
import type {
|
|
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
|
|
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
|
-
stats:
|
|
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
|
|
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 */
|
|
@@ -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:
|
|
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.
|
|
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.
|
|
42
|
-
"@gajae-code/utils": "0.17.
|
|
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),
|
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,
|
|
@@ -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:
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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:
|
|
350
|
+
export function insertMessageStats(stats: ParsedMessageStats[]): number {
|
|
303
351
|
if (!db || stats.length === 0) return 0;
|
|
304
352
|
|
|
305
|
-
//
|
|
306
|
-
//
|
|
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
|
-
|
|
319
|
-
|
|
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 {
|
|
3
|
+
import { getPriorityPremiumRequests, type ServiceTier } from "@gajae-code/ai";
|
|
4
4
|
import { getSessionsDir, isEnoent } from "@gajae-code/utils";
|
|
5
5
|
import type {
|
|
6
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
111
|
-
|
|
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
|
|
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 =
|
|
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
|
|
133
|
-
ttft: msg.ttft
|
|
134
|
-
stopReason
|
|
135
|
-
|
|
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
|
|
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
|
|
196
|
-
*
|
|
197
|
-
*
|
|
198
|
-
*
|
|
199
|
-
*
|
|
200
|
-
*
|
|
201
|
-
*
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
}
|
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 */
|
|
@@ -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:
|
|
75
|
+
message: SessionAssistantMessage | { role: "user" | "toolResult" };
|
|
65
76
|
}
|
|
66
77
|
|
|
67
78
|
export interface SessionServiceTierChangeEntry {
|