@oh-my-pi/omp-stats 16.4.6 → 16.4.8

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/omp-stats",
4
- "version": "16.4.6",
4
+ "version": "16.4.8",
5
5
  "description": "Local observability dashboard for pi AI usage statistics",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -39,9 +39,9 @@
39
39
  "fmt": "biome format --write ."
40
40
  },
41
41
  "dependencies": {
42
- "@oh-my-pi/pi-ai": "16.4.6",
43
- "@oh-my-pi/pi-catalog": "16.4.6",
44
- "@oh-my-pi/pi-utils": "16.4.6",
42
+ "@oh-my-pi/pi-ai": "16.4.8",
43
+ "@oh-my-pi/pi-catalog": "16.4.8",
44
+ "@oh-my-pi/pi-utils": "16.4.8",
45
45
  "@tailwindcss/node": "^4.3.0",
46
46
  "chart.js": "^4.5.1",
47
47
  "date-fns": "^4.4.0",
package/src/parser.ts CHANGED
@@ -6,7 +6,9 @@ import {
6
6
  getPriorityPremiumRequests,
7
7
  resolveModelServiceTier,
8
8
  type ServiceTierByFamily,
9
+ type ToolCall,
9
10
  type ToolResultMessage,
11
+ type Usage,
10
12
  } from "@oh-my-pi/pi-ai";
11
13
  import { getSessionsDir, isEnoent, readLines } from "@oh-my-pi/pi-utils";
12
14
  import type {
@@ -142,6 +144,14 @@ function extractUserStats(sessionFile: string, folder: string, entry: SessionMes
142
144
 
143
145
  /**
144
146
  * Extract stats from an assistant message entry.
147
+ *
148
+ * Session JSONL on disk is not guaranteed to match the current
149
+ * `AssistantMessage` shape: crash-truncated turns, sessions written by older
150
+ * versions, and foreign producers all flow through this parser. Every field
151
+ * returned here feeds a NOT NULL column in stats.db, so malformed entries are
152
+ * coerced (missing `stopReason`, token counts, `timestamp`) or skipped
153
+ * (missing `model`/`provider`/`api`/`usage`) instead of crashing the whole
154
+ * sync with a constraint violation.
145
155
  */
146
156
  function extractStats(
147
157
  sessionFile: string,
@@ -152,6 +162,9 @@ function extractStats(
152
162
  ): MessageStats | null {
153
163
  const msg = entry.message as AssistantMessage;
154
164
  if (msg?.role !== "assistant") return null;
165
+ if (typeof msg.model !== "string" || typeof msg.provider !== "string" || typeof msg.api !== "string") return null;
166
+ const rawUsage = msg.usage as Partial<Usage> | undefined;
167
+ if (!rawUsage || typeof rawUsage !== "object") return null;
155
168
 
156
169
  // Backfill: when the session recorded `priority` as the active service tier
157
170
  // at this point but the AI usage payload was captured before priority
@@ -159,11 +172,29 @@ function extractStats(
159
172
  // "Premium Reqs" stat aggregates priority traffic on re-sync. Trust any
160
173
  // non-zero value already in `usage.premiumRequests` (Copilot multipliers or
161
174
  // the new AI code path) and only synthesise when the field is missing/zero.
162
- const recorded = msg.usage.premiumRequests ?? 0;
175
+ const recorded = rawUsage.premiumRequests ?? 0;
163
176
  const model = { provider: msg.provider, api: msg.api, id: msg.model };
164
177
  const tier = resolveModelServiceTier(currentServiceTier, model);
165
178
  const derived = recorded > 0 ? recorded : getPriorityPremiumRequests(tier, model);
166
- const usage = derived === recorded ? msg.usage : { ...msg.usage, premiumRequests: derived };
179
+ const wellFormed =
180
+ typeof rawUsage.input === "number" &&
181
+ typeof rawUsage.output === "number" &&
182
+ typeof rawUsage.cacheRead === "number" &&
183
+ typeof rawUsage.cacheWrite === "number" &&
184
+ typeof rawUsage.totalTokens === "number";
185
+ const usage: Usage =
186
+ wellFormed && derived === recorded
187
+ ? (rawUsage as Usage)
188
+ : {
189
+ ...rawUsage,
190
+ input: rawUsage.input ?? 0,
191
+ output: rawUsage.output ?? 0,
192
+ cacheRead: rawUsage.cacheRead ?? 0,
193
+ cacheWrite: rawUsage.cacheWrite ?? 0,
194
+ totalTokens: rawUsage.totalTokens ?? 0,
195
+ cost: rawUsage.cost ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
196
+ premiumRequests: derived,
197
+ };
167
198
 
168
199
  return {
169
200
  sessionFile,
@@ -172,16 +203,25 @@ function extractStats(
172
203
  model: msg.model,
173
204
  provider: msg.provider,
174
205
  api: msg.api,
175
- timestamp: msg.timestamp,
206
+ timestamp: coerceEntryTimestamp(msg.timestamp, entry),
176
207
  duration: msg.duration ?? null,
177
208
  ttft: msg.ttft ?? null,
178
- stopReason: msg.stopReason,
209
+ // A message persisted without a terminal stop reason never completed
210
+ // normally: classify by whether it carried an error.
211
+ stopReason: msg.stopReason ?? (msg.errorMessage ? "error" : "aborted"),
179
212
  errorMessage: msg.errorMessage ?? null,
180
213
  usage,
181
214
  agentType,
182
215
  };
183
216
  }
184
217
 
218
+ /** Message timestamp, falling back to the entry's ISO timestamp, then 0. */
219
+ function coerceEntryTimestamp(timestamp: number | undefined, entry: SessionMessageEntry): number {
220
+ if (typeof timestamp === "number" && Number.isFinite(timestamp)) return timestamp;
221
+ const ts = Date.parse(entry.timestamp);
222
+ return Number.isFinite(ts) ? ts : 0;
223
+ }
224
+
185
225
  /**
186
226
  * Extract one {@link ToolCallStats} per `toolCall` content block of an
187
227
  * assistant message. Returns an empty array for turns without tool calls.
@@ -194,8 +234,14 @@ function extractToolCalls(
194
234
  ): ToolCallStats[] {
195
235
  const msg = entry.message as AssistantMessage;
196
236
  if (msg?.role !== "assistant" || !Array.isArray(msg.content)) return [];
237
+ // `tool_calls` columns are NOT NULL: skip turns that can't be attributed
238
+ // (malformed persisted entries — see extractStats) and blocks missing ids.
239
+ if (typeof msg.model !== "string" || typeof msg.provider !== "string") return [];
197
240
 
198
- const blocks = msg.content.filter(block => block.type === "toolCall");
241
+ const blocks = msg.content.filter(
242
+ (block): block is ToolCall =>
243
+ block.type === "toolCall" && typeof block.id === "string" && typeof block.name === "string",
244
+ );
199
245
  if (blocks.length === 0) return [];
200
246
 
201
247
  return blocks.map(block => {
@@ -213,7 +259,7 @@ function extractToolCalls(
213
259
  toolName: block.name,
214
260
  model: msg.model,
215
261
  provider: msg.provider,
216
- timestamp: msg.timestamp,
262
+ timestamp: coerceEntryTimestamp(msg.timestamp, entry),
217
263
  agentType,
218
264
  callsInTurn: blocks.length,
219
265
  argsChars,