@oh-my-pi/omp-stats 15.0.0 → 15.0.2

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.
@@ -1,8 +1,28 @@
1
1
  /**
2
2
  * Client-side type definitions.
3
- * Duplicated from ../types.ts to avoid pulling in server dependencies.
3
+ *
4
+ * Shared shapes (aggregations, time-series, dashboard payloads) live in
5
+ * `../shared-types` and are re-exported here. The types declared inline below
6
+ * are deliberately client-only because:
7
+ * - `Usage` is redeclared locally so the client bundle avoids importing
8
+ * `@oh-my-pi/pi-ai` (the server-side AI types package).
9
+ * - `MessageStats.stopReason` is widened from the server's `StopReason`
10
+ * enum to `string`, again to keep the client free of pi-ai types.
11
+ * - `TimeRange`, `OverviewStats`, `ModelDashboardStats`,
12
+ * `CostDashboardStats` are UI-only view shapes the server never produces.
4
13
  */
5
14
 
15
+ import type {
16
+ AggregatedStats,
17
+ CostTimeSeriesPoint,
18
+ ModelPerformancePoint,
19
+ ModelStats,
20
+ ModelTimeSeriesPoint,
21
+ TimeSeriesPoint,
22
+ } from "../shared-types";
23
+
24
+ export * from "../shared-types";
25
+
6
26
  export interface Usage {
7
27
  input: number;
8
28
  output: number;
@@ -40,80 +60,7 @@ export interface RequestDetails extends MessageStats {
40
60
  output: unknown;
41
61
  }
42
62
 
43
- export interface AggregatedStats {
44
- totalRequests: number;
45
- successfulRequests: number;
46
- failedRequests: number;
47
- errorRate: number;
48
- totalInputTokens: number;
49
- totalOutputTokens: number;
50
- totalCacheReadTokens: number;
51
- totalCacheWriteTokens: number;
52
- cacheRate: number;
53
- totalCost: number;
54
- totalPremiumRequests: number;
55
- avgDuration: number | null;
56
- avgTtft: number | null;
57
- avgTokensPerSecond: number | null;
58
- firstTimestamp: number;
59
- lastTimestamp: number;
60
- }
61
-
62
63
  export type TimeRange = "1h" | "24h" | "7d" | "30d" | "90d" | "all";
63
- export interface ModelStats extends AggregatedStats {
64
- model: string;
65
- provider: string;
66
- }
67
-
68
- export interface FolderStats extends AggregatedStats {
69
- folder: string;
70
- }
71
-
72
- export interface TimeSeriesPoint {
73
- timestamp: number;
74
- requests: number;
75
- errors: number;
76
- tokens: number;
77
- cost: number;
78
- }
79
-
80
- export interface ModelTimeSeriesPoint {
81
- timestamp: number;
82
- model: string;
83
- provider: string;
84
- requests: number;
85
- }
86
-
87
- export interface ModelPerformancePoint {
88
- timestamp: number;
89
- model: string;
90
- provider: string;
91
- requests: number;
92
- avgTtft: number | null;
93
- avgTokensPerSecond: number | null;
94
- }
95
-
96
- export interface CostTimeSeriesPoint {
97
- timestamp: number;
98
- model: string;
99
- provider: string;
100
- cost: number;
101
- costInput: number;
102
- costOutput: number;
103
- costCacheRead: number;
104
- costCacheWrite: number;
105
- requests: number;
106
- }
107
-
108
- export interface DashboardStats {
109
- overall: AggregatedStats;
110
- byModel: ModelStats[];
111
- byFolder: FolderStats[];
112
- timeSeries: TimeSeriesPoint[];
113
- modelSeries: ModelTimeSeriesPoint[];
114
- modelPerformanceSeries: ModelPerformancePoint[];
115
- costSeries: CostTimeSeriesPoint[];
116
- }
117
64
 
118
65
  export interface OverviewStats {
119
66
  overall: AggregatedStats;
@@ -129,50 +76,3 @@ export interface ModelDashboardStats {
129
76
  export interface CostDashboardStats {
130
77
  costSeries: CostTimeSeriesPoint[];
131
78
  }
132
-
133
- export interface BehaviorTimeSeriesPoint {
134
- timestamp: number;
135
- model: string;
136
- provider: string;
137
- messages: number;
138
- yelling: number;
139
- profanity: number;
140
- anguish: number;
141
- negation: number;
142
- repetition: number;
143
- blame: number;
144
- chars: number;
145
- }
146
-
147
- export interface BehaviorOverallStats {
148
- totalMessages: number;
149
- totalYelling: number;
150
- totalProfanity: number;
151
- totalAnguish: number;
152
- totalNegation: number;
153
- totalRepetition: number;
154
- totalBlame: number;
155
- totalChars: number;
156
- firstTimestamp: number;
157
- lastTimestamp: number;
158
- }
159
-
160
- export interface BehaviorModelStats {
161
- model: string;
162
- provider: string;
163
- totalMessages: number;
164
- totalYelling: number;
165
- totalProfanity: number;
166
- totalAnguish: number;
167
- totalNegation: number;
168
- totalRepetition: number;
169
- totalBlame: number;
170
- totalChars: number;
171
- lastTimestamp: number;
172
- }
173
-
174
- export interface BehaviorDashboardStats {
175
- overall: BehaviorOverallStats;
176
- byModel: BehaviorModelStats[];
177
- behaviorSeries: BehaviorTimeSeriesPoint[];
178
- }
package/src/db.ts CHANGED
@@ -38,6 +38,7 @@ const BACKFILL_COMPLETE = "complete";
38
38
  const BACKFILL_PENDING = "pending";
39
39
  const USER_MESSAGES_BACKFILL_KEY = "user_messages_v5";
40
40
  const USER_MESSAGE_LINKS_REPAIR_KEY = "user_message_links_v1";
41
+ const PRIORITY_PREMIUM_REQUESTS_BACKFILL_KEY = "premium_requests_priority_v1";
41
42
  function shouldResetBackfill(value: string | undefined): boolean {
42
43
  return value !== BACKFILL_COMPLETE && value !== BACKFILL_PENDING;
43
44
  }
@@ -179,6 +180,7 @@ export async function initDb(): Promise<Database> {
179
180
  }
180
181
  backfillUserMessages(db);
181
182
  repairUserMessageLinks(db);
183
+ backfillPriorityPremiumRequests(db);
182
184
  backfillMissingCatalogCosts(db);
183
185
  return db;
184
186
  }
@@ -300,13 +302,21 @@ export function setFileOffset(sessionFile: string, offset: number, lastModified:
300
302
  export function insertMessageStats(stats: MessageStats[]): number {
301
303
  if (!db || stats.length === 0) return 0;
302
304
 
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).
303
310
  const stmt = db.prepare(`
304
- INSERT OR IGNORE INTO messages (
311
+ INSERT INTO messages (
305
312
  session_file, entry_id, folder, model, provider, api, timestamp,
306
313
  duration, ttft, stop_reason, error_message,
307
314
  input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, total_tokens, premium_requests,
308
315
  cost_input, cost_output, cost_cache_read, cost_cache_write, cost_total
309
316
  ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
317
+ ON CONFLICT(session_file, entry_id) DO UPDATE SET
318
+ premium_requests = excluded.premium_requests
319
+ WHERE messages.premium_requests < excluded.premium_requests
310
320
  `);
311
321
 
312
322
  let inserted = 0;
@@ -780,6 +790,36 @@ function repairUserMessageLinks(database: Database): void {
780
790
  .run(USER_MESSAGE_LINKS_REPAIR_KEY, BACKFILL_PENDING);
781
791
  }
782
792
 
793
+ /**
794
+ * One-shot wipe of `file_offsets` so the next sync re-parses every session
795
+ * and re-derives `premium_requests` from recorded `service_tier_change`
796
+ * entries. Earlier ingestions captured priority OpenAI traffic with
797
+ * `premium_requests = 0` because the AI layer only set the field for GitHub
798
+ * Copilot traffic. The parser now folds priority requests into the same
799
+ * counter; combined with the UPSERT in `insertMessageStats`, a single sync
800
+ * pass brings the messages table up to date without touching any other
801
+ * column. Idempotent: gated by a sentinel row in `meta`.
802
+ */
803
+ function backfillPriorityPremiumRequests(database: Database): void {
804
+ const row = database.prepare("SELECT value FROM meta WHERE key = ?").get(PRIORITY_PREMIUM_REQUESTS_BACKFILL_KEY) as
805
+ | { value: string }
806
+ | undefined;
807
+ if (!shouldResetBackfill(row?.value)) return;
808
+
809
+ database.exec("DELETE FROM file_offsets");
810
+ database
811
+ .prepare("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)")
812
+ .run(PRIORITY_PREMIUM_REQUESTS_BACKFILL_KEY, BACKFILL_PENDING);
813
+ }
814
+
815
+ export function markPriorityPremiumRequestsBackfillComplete(): void {
816
+ if (!db) return;
817
+ db.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)").run(
818
+ PRIORITY_PREMIUM_REQUESTS_BACKFILL_KEY,
819
+ BACKFILL_COMPLETE,
820
+ );
821
+ }
822
+
783
823
  export function markUserMessagesBackfillComplete(): void {
784
824
  if (!db) return;
785
825
  db.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)").run(
package/src/parser.ts CHANGED
@@ -1,8 +1,15 @@
1
1
  import * as fs from "node:fs/promises";
2
2
  import * as path from "node:path";
3
- import type { AssistantMessage } from "@oh-my-pi/pi-ai";
3
+ import { type AssistantMessage, getPriorityPremiumRequests, type ServiceTier } from "@oh-my-pi/pi-ai";
4
4
  import { getSessionsDir, isEnoent } from "@oh-my-pi/pi-utils";
5
- import type { MessageStats, SessionEntry, SessionMessageEntry, UserMessageLink, UserMessageStats } from "./types";
5
+ import type {
6
+ MessageStats,
7
+ SessionEntry,
8
+ SessionMessageEntry,
9
+ SessionServiceTierChangeEntry,
10
+ UserMessageLink,
11
+ UserMessageStats,
12
+ } from "./types";
6
13
  import { computeUserMessageMetrics } from "./user-metrics";
7
14
 
8
15
  /**
@@ -24,6 +31,10 @@ function extractFolderFromPath(sessionPath: string): string {
24
31
  function isAssistantMessage(entry: SessionEntry): entry is SessionMessageEntry {
25
32
  if (entry.type !== "message") return false;
26
33
  const msgEntry = entry as SessionMessageEntry;
34
+ // Legacy sessions (pre-id tracking) recorded message entries without an `id`.
35
+ // They're not linkable and would violate the messages.entry_id NOT NULL
36
+ // constraint, so skip them at the parser boundary.
37
+ if (typeof msgEntry.id !== "string" || msgEntry.id.length === 0) return false;
27
38
  return msgEntry.message?.role === "assistant";
28
39
  }
29
40
 
@@ -33,9 +44,17 @@ function isAssistantMessage(entry: SessionEntry): entry is SessionMessageEntry {
33
44
  function isUserMessage(entry: SessionEntry): entry is SessionMessageEntry {
34
45
  if (entry.type !== "message") return false;
35
46
  const msgEntry = entry as SessionMessageEntry;
47
+ if (typeof msgEntry.id !== "string" || msgEntry.id.length === 0) return false;
36
48
  return msgEntry.message?.role === "user";
37
49
  }
38
50
 
51
+ /**
52
+ * Check if an entry is a service-tier change.
53
+ */
54
+ function isServiceTierChange(entry: SessionEntry): entry is SessionServiceTierChangeEntry {
55
+ return entry.type === "service_tier_change";
56
+ }
57
+
39
58
  /**
40
59
  * Extract plain text from a user message content payload.
41
60
  */
@@ -83,10 +102,25 @@ function extractUserStats(sessionFile: string, folder: string, entry: SessionMes
83
102
  /**
84
103
  * Extract stats from an assistant message entry.
85
104
  */
86
- function extractStats(sessionFile: string, folder: string, entry: SessionMessageEntry): MessageStats | null {
105
+ function extractStats(
106
+ sessionFile: string,
107
+ folder: string,
108
+ entry: SessionMessageEntry,
109
+ currentServiceTier: ServiceTier | undefined,
110
+ ): MessageStats | null {
87
111
  const msg = entry.message as AssistantMessage;
88
112
  if (!msg || msg.role !== "assistant") return null;
89
113
 
114
+ // Backfill: when the session recorded `priority` as the active service tier
115
+ // at this point but the AI usage payload was captured before priority
116
+ // requests were folded into `premiumRequests`, derive the count here so the
117
+ // "Premium Reqs" stat aggregates priority traffic on re-sync. Trust any
118
+ // non-zero value already in `usage.premiumRequests` (Copilot multipliers or
119
+ // the new AI code path) and only synthesise when the field is missing/zero.
120
+ const recorded = msg.usage.premiumRequests ?? 0;
121
+ const derived = recorded > 0 ? recorded : getPriorityPremiumRequests(currentServiceTier, msg.provider);
122
+ const usage = derived === recorded ? msg.usage : { ...msg.usage, premiumRequests: derived };
123
+
90
124
  return {
91
125
  sessionFile,
92
126
  entryId: entry.id,
@@ -99,7 +133,7 @@ function extractStats(sessionFile: string, folder: string, entry: SessionMessage
99
133
  ttft: msg.ttft ?? null,
100
134
  stopReason: msg.stopReason,
101
135
  errorMessage: msg.errorMessage ?? null,
102
- usage: msg.usage,
136
+ usage,
103
137
  };
104
138
  }
105
139
 
@@ -158,7 +192,12 @@ export async function parseSessionFile(sessionPath: string, fromOffset = 0): Pro
158
192
  const start = Math.max(0, Math.min(fromOffset, bytes.length));
159
193
  const unprocessed = bytes.subarray(start);
160
194
  const { entries, read } = parseSessionEntriesLenient(unprocessed);
195
+ let currentServiceTier: ServiceTier | undefined;
161
196
  for (const entry of entries) {
197
+ if (isServiceTierChange(entry)) {
198
+ currentServiceTier = entry.serviceTier ?? undefined;
199
+ continue;
200
+ }
162
201
  if (isUserMessage(entry)) {
163
202
  const userMsg = extractUserStats(sessionPath, folder, entry);
164
203
  if (userMsg) {
@@ -168,7 +207,7 @@ export async function parseSessionFile(sessionPath: string, fromOffset = 0): Pro
168
207
  continue;
169
208
  }
170
209
  if (isAssistantMessage(entry)) {
171
- const msgStats = extractStats(sessionPath, folder, entry);
210
+ const msgStats = extractStats(sessionPath, folder, entry, currentServiceTier);
172
211
  if (msgStats) stats.push(msgStats);
173
212
  // Link assistant's responding model back to the user message it answered.
174
213
  const parentId = (entry as SessionMessageEntry).parentId;
@@ -0,0 +1,204 @@
1
+ /**
2
+ * Shared type definitions consumed by both the server-side stats code and the
3
+ * standalone client bundle. Keep this file free of any imports from server-only
4
+ * packages (e.g. `@oh-my-pi/pi-ai`, `bun:sqlite`) so the client can import it
5
+ * without dragging server dependencies into its bundle.
6
+ */
7
+
8
+ /**
9
+ * Aggregated stats for a model or folder.
10
+ */
11
+ export interface AggregatedStats {
12
+ /** Total number of requests */
13
+ totalRequests: number;
14
+ /** Number of successful requests */
15
+ successfulRequests: number;
16
+ /** Number of failed requests */
17
+ failedRequests: number;
18
+ /** Error rate (0-1) */
19
+ errorRate: number;
20
+ /** Total input tokens */
21
+ totalInputTokens: number;
22
+ /** Total output tokens */
23
+ totalOutputTokens: number;
24
+ /** Total cache read tokens */
25
+ totalCacheReadTokens: number;
26
+ /** Total cache write tokens */
27
+ totalCacheWriteTokens: number;
28
+ /** Cache hit rate (0-1) */
29
+ cacheRate: number;
30
+ /** Total cost */
31
+ totalCost: number;
32
+ /** Total premium requests */
33
+ totalPremiumRequests: number;
34
+ /** Average duration in ms */
35
+ avgDuration: number | null;
36
+ /** Average TTFT in ms */
37
+ avgTtft: number | null;
38
+ /** Average tokens per second (output tokens / duration) */
39
+ avgTokensPerSecond: number | null;
40
+ /** Time range */
41
+ firstTimestamp: number;
42
+ lastTimestamp: number;
43
+ }
44
+
45
+ /**
46
+ * Stats grouped by model.
47
+ */
48
+ export interface ModelStats extends AggregatedStats {
49
+ model: string;
50
+ provider: string;
51
+ }
52
+
53
+ /**
54
+ * Stats grouped by folder.
55
+ */
56
+ export interface FolderStats extends AggregatedStats {
57
+ folder: string;
58
+ }
59
+
60
+ /**
61
+ * Time series data point.
62
+ */
63
+ export interface TimeSeriesPoint {
64
+ /** Bucket timestamp (start of hour/day) */
65
+ timestamp: number;
66
+ /** Request count */
67
+ requests: number;
68
+ /** Error count */
69
+ errors: number;
70
+ /** Total tokens */
71
+ tokens: number;
72
+ /** Total cost */
73
+ cost: number;
74
+ }
75
+
76
+ /**
77
+ * Model usage time series data point (daily buckets).
78
+ */
79
+ export interface ModelTimeSeriesPoint {
80
+ /** Bucket timestamp (start of day) */
81
+ timestamp: number;
82
+ /** Model name */
83
+ model: string;
84
+ /** Provider name */
85
+ provider: string;
86
+ /** Request count */
87
+ requests: number;
88
+ }
89
+
90
+ /**
91
+ * Model performance time series data point (daily buckets).
92
+ */
93
+ export interface ModelPerformancePoint {
94
+ /** Bucket timestamp (start of day) */
95
+ timestamp: number;
96
+ /** Model name */
97
+ model: string;
98
+ /** Provider name */
99
+ provider: string;
100
+ /** Request count */
101
+ requests: number;
102
+ /** Average TTFT in ms */
103
+ avgTtft: number | null;
104
+ /** Average tokens per second */
105
+ avgTokensPerSecond: number | null;
106
+ }
107
+
108
+ /**
109
+ * Cost time series data point (daily buckets).
110
+ */
111
+ export interface CostTimeSeriesPoint {
112
+ /** Bucket timestamp (start of day) */
113
+ timestamp: number;
114
+ /** Model name */
115
+ model: string;
116
+ /** Provider name */
117
+ provider: string;
118
+ /** Total cost for this bucket */
119
+ cost: number;
120
+ /** Cost breakdown */
121
+ costInput: number;
122
+ costOutput: number;
123
+ costCacheRead: number;
124
+ costCacheWrite: number;
125
+ /** Request count */
126
+ requests: number;
127
+ }
128
+
129
+ /**
130
+ * Overall dashboard stats.
131
+ */
132
+ export interface DashboardStats {
133
+ overall: AggregatedStats;
134
+ byModel: ModelStats[];
135
+ byFolder: FolderStats[];
136
+ timeSeries: TimeSeriesPoint[];
137
+ modelSeries: ModelTimeSeriesPoint[];
138
+ modelPerformanceSeries: ModelPerformancePoint[];
139
+ costSeries: CostTimeSeriesPoint[];
140
+ }
141
+
142
+ /**
143
+ * Behavior time-series point (daily bucket, per responding model).
144
+ */
145
+ export interface BehaviorTimeSeriesPoint {
146
+ /** Bucket timestamp (start of day) */
147
+ timestamp: number;
148
+ /** Responding model ("unknown" if user msg never got a reply) */
149
+ model: string;
150
+ /** Responding provider */
151
+ provider: string;
152
+ /** Number of user messages in bucket */
153
+ messages: number;
154
+ /** Total yelling sentences in bucket */
155
+ yelling: number;
156
+ /** Total profanity hits in bucket */
157
+ profanity: number;
158
+ /** Total anguish signal in bucket */
159
+ anguish: number;
160
+ /** Total corrective-negation hits in bucket */
161
+ negation: number;
162
+ /** Total user-repeating-themselves hits in bucket */
163
+ repetition: number;
164
+ /** Total second-person blame hits in bucket */
165
+ blame: number;
166
+ /** Total characters in bucket */
167
+ chars: number;
168
+ }
169
+
170
+ export interface BehaviorOverallStats {
171
+ totalMessages: number;
172
+ totalYelling: number;
173
+ totalProfanity: number;
174
+ totalAnguish: number;
175
+ totalNegation: number;
176
+ totalRepetition: number;
177
+ totalBlame: number;
178
+ totalChars: number;
179
+ firstTimestamp: number;
180
+ lastTimestamp: number;
181
+ }
182
+
183
+ /**
184
+ * Per-model behavioral aggregate over the active range.
185
+ */
186
+ export interface BehaviorModelStats {
187
+ model: string;
188
+ provider: string;
189
+ totalMessages: number;
190
+ totalYelling: number;
191
+ totalProfanity: number;
192
+ totalAnguish: number;
193
+ totalNegation: number;
194
+ totalRepetition: number;
195
+ totalBlame: number;
196
+ totalChars: number;
197
+ lastTimestamp: number;
198
+ }
199
+
200
+ export interface BehaviorDashboardStats {
201
+ overall: BehaviorOverallStats;
202
+ byModel: BehaviorModelStats[];
203
+ behaviorSeries: BehaviorTimeSeriesPoint[];
204
+ }