@gajae-code/stats 0.17.2 → 0.17.4

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,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 { AggregatedStats, BehaviorModelStats, BehaviorOverallStats, BehaviorTimeSeriesPoint, CostTimeSeriesPoint, FolderStats, MessageStats, ModelPerformancePoint, ModelStats, ModelTimeSeriesPoint, ParsedMessageStats, TimeSeriesPoint, UserMessageLink, UserMessageStats } from "./types";
3
3
  /**
4
4
  * Initialize the database and create tables.
5
5
  */
@@ -18,7 +18,7 @@ export declare function setFileOffset(sessionFile: string, offset: number, lastM
18
18
  /**
19
19
  * Insert message stats into the database.
20
20
  */
21
- export declare function insertMessageStats(stats: MessageStats[]): number;
21
+ export declare function insertMessageStats(stats: ParsedMessageStats[]): number;
22
22
  /**
23
23
  * Get overall aggregated stats.
24
24
  */
@@ -1,4 +1,4 @@
1
- import type { MessageStats, SessionEntry, UserMessageLink, UserMessageStats } from "./types";
1
+ import type { ParsedMessageStats, SessionEntry, UserMessageLink, UserMessageStats } from "./types";
2
2
  /**
3
3
  * Parse a session file and extract all assistant message stats.
4
4
  * Uses incremental reading with offset tracking.
@@ -16,7 +16,7 @@ import type { MessageStats, SessionEntry, UserMessageLink, UserMessageStats } fr
16
16
  * entries, preserving offset-based memory behavior for large sessions.
17
17
  */
18
18
  export interface ParseSessionResult {
19
- stats: MessageStats[];
19
+ stats: ParsedMessageStats[];
20
20
  userStats: UserMessageStats[];
21
21
  userLinks: UserMessageLink[];
22
22
  newOffset: number;
@@ -25,7 +25,7 @@ export interface MessageStats {
25
25
  /** Time to first token in milliseconds */
26
26
  ttft: number | null;
27
27
  /** Stop reason */
28
- stopReason: StopReason;
28
+ stopReason: StopReason | "unknown";
29
29
  /** Error message if stopReason is error */
30
30
  errorMessage: string | null;
31
31
  /** Token usage */
@@ -51,12 +51,22 @@ export interface SessionHeader {
51
51
  cwd: string;
52
52
  title?: string;
53
53
  }
54
+ /** Historical JSONL may contain missing, partial, or malformed cost payloads. */
55
+ export type SessionAssistantMessage = Omit<AssistantMessage, "usage"> & {
56
+ usage: Omit<Usage, "cost"> & {
57
+ cost?: unknown;
58
+ };
59
+ };
60
+ /** Parser output retains untrusted costs until the database insertion boundary. */
61
+ export type ParsedMessageStats = Omit<MessageStats, "usage"> & {
62
+ usage: SessionAssistantMessage["usage"];
63
+ };
54
64
  export interface SessionMessageEntry {
55
65
  type: "message";
56
66
  id: string;
57
67
  parentId: string | null;
58
68
  timestamp: string;
59
- message: AssistantMessage | {
69
+ message: SessionAssistantMessage | {
60
70
  role: "user" | "toolResult";
61
71
  };
62
72
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@gajae-code/stats",
4
- "version": "0.17.2",
4
+ "version": "0.17.4",
5
5
  "description": "Local observability dashboard for pi AI usage statistics",
6
6
  "homepage": "https://gajae-code.com",
7
7
  "author": "Yeachan-Heo",
@@ -38,8 +38,8 @@
38
38
  "fmt": "biome format --write ."
39
39
  },
40
40
  "dependencies": {
41
- "@gajae-code/ai": "0.17.2",
42
- "@gajae-code/utils": "0.17.2",
41
+ "@gajae-code/ai": "0.17.4",
42
+ "@gajae-code/utils": "0.17.4",
43
43
  "@tailwindcss/node": "^4.2.4",
44
44
  "chart.js": "^4.5.1",
45
45
  "date-fns": "^4.1.0",
package/src/db.ts CHANGED
@@ -13,6 +13,7 @@ import type {
13
13
  ModelPerformancePoint,
14
14
  ModelStats,
15
15
  ModelTimeSeriesPoint,
16
+ ParsedMessageStats,
16
17
  TimeSeriesPoint,
17
18
  UserMessageLink,
18
19
  UserMessageStats,
@@ -228,12 +229,32 @@ function calculateCatalogCost(provider: string, modelId: string, tokens: CostTok
228
229
  };
229
230
  }
230
231
 
231
- function resolveStoredCost(stats: MessageStats): UsageCost {
232
- if (stats.usage.cost.total !== 0) {
233
- return stats.usage.cost;
234
- }
235
-
236
- return calculateCatalogCost(stats.provider, stats.model, stats.usage) ?? stats.usage.cost;
232
+ function resolveStoredCost(stats: ParsedMessageStats): UsageCost {
233
+ const raw = stats.usage.cost;
234
+ const recorded = raw && typeof raw === "object" ? (raw as Record<string, unknown>) : {};
235
+ const finite = (value: unknown): value is number => typeof value === "number" && Number.isFinite(value);
236
+ const keys = ["input", "output", "cacheRead", "cacheWrite", "total"] as const;
237
+ // Complete zero-cost payloads historically request catalog pricing. A partial
238
+ // payload is different: preserve every valid recorded value, including zero.
239
+ const allZero = keys.every(key => recorded[key] === 0);
240
+ const catalog = calculateCatalogCost(stats.provider, stats.model, stats.usage);
241
+ const component = (key: keyof ModelCost): number => {
242
+ if (!allZero && finite(recorded[key])) return recorded[key];
243
+ const fallback = catalog?.[key];
244
+ return finite(fallback) ? fallback : 0;
245
+ };
246
+ const input = component("input");
247
+ const output = component("output");
248
+ const cacheRead = component("cacheRead");
249
+ const cacheWrite = component("cacheWrite");
250
+ const sum = input + output + cacheRead + cacheWrite;
251
+ return {
252
+ input,
253
+ output,
254
+ cacheRead,
255
+ cacheWrite,
256
+ total: !allZero && finite(recorded.total) ? recorded.total : finite(sum) ? sum : 0,
257
+ };
237
258
  }
238
259
 
239
260
  function backfillMissingCatalogCosts(database: Database): void {
@@ -242,6 +263,7 @@ function backfillMissingCatalogCosts(database: Database): void {
242
263
  SELECT id, provider, model, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens
243
264
  FROM messages
244
265
  WHERE cost_total = 0 AND total_tokens > 0
266
+ AND cost_input = 0 AND cost_output = 0 AND cost_cache_read = 0 AND cost_cache_write = 0
245
267
  `)
246
268
  .all() as CostBackfillRow[];
247
269
 
@@ -299,7 +321,7 @@ export function setFileOffset(sessionFile: string, offset: number, lastModified:
299
321
  /**
300
322
  * Insert message stats into the database.
301
323
  */
302
- export function insertMessageStats(stats: MessageStats[]): number {
324
+ export function insertMessageStats(stats: ParsedMessageStats[]): number {
303
325
  if (!db || stats.length === 0) return 0;
304
326
 
305
327
  // Use UPSERT so a re-sync can fix up `premium_requests` for rows persisted
package/src/parser.ts CHANGED
@@ -1,9 +1,10 @@
1
1
  import * as fs from "node:fs/promises";
2
2
  import * as path from "node:path";
3
- import { type AssistantMessage, getPriorityPremiumRequests, type ServiceTier } from "@gajae-code/ai";
3
+ import { getPriorityPremiumRequests, type ServiceTier } from "@gajae-code/ai";
4
4
  import { getSessionsDir, isEnoent } from "@gajae-code/utils";
5
5
  import type {
6
- MessageStats,
6
+ ParsedMessageStats,
7
+ SessionAssistantMessage,
7
8
  SessionEntry,
8
9
  SessionMessageEntry,
9
10
  SessionServiceTierChangeEntry,
@@ -25,34 +26,46 @@ function extractFolderFromPath(sessionPath: string): string {
25
26
  return projectDir.replace(/^--/, "/").replace(/--/g, "/");
26
27
  }
27
28
 
29
+ function isObject(value: unknown): value is Record<string, unknown> {
30
+ return value !== null && typeof value === "object" && !Array.isArray(value);
31
+ }
32
+
33
+ function isNonemptyString(value: unknown): value is string {
34
+ return typeof value === "string" && value.trim().length > 0;
35
+ }
36
+
37
+ function isNonnegativeNumber(value: unknown): value is number {
38
+ return typeof value === "number" && Number.isFinite(value) && value >= 0;
39
+ }
40
+
28
41
  /**
29
42
  * Check if an entry is an assistant message.
30
43
  */
31
44
  function isAssistantMessage(entry: SessionEntry): entry is SessionMessageEntry {
32
- if (entry.type !== "message") return false;
45
+ if (!isObject(entry) || entry.type !== "message") return false;
33
46
  const msgEntry = entry as SessionMessageEntry;
34
47
  // Legacy sessions (pre-id tracking) recorded message entries without an `id`.
35
48
  // They're not linkable and would violate the messages.entry_id NOT NULL
36
49
  // constraint, so skip them at the parser boundary.
37
50
  if (typeof msgEntry.id !== "string" || msgEntry.id.length === 0) return false;
38
- return msgEntry.message?.role === "assistant";
51
+ return isObject(msgEntry.message) && msgEntry.message.role === "assistant";
39
52
  }
40
53
 
41
54
  /**
42
55
  * Check if an entry is a user message (non-toolResult).
43
56
  */
44
57
  function isUserMessage(entry: SessionEntry): entry is SessionMessageEntry {
45
- if (entry.type !== "message") return false;
58
+ if (!isObject(entry) || entry.type !== "message") return false;
46
59
  const msgEntry = entry as SessionMessageEntry;
47
60
  if (typeof msgEntry.id !== "string" || msgEntry.id.length === 0) return false;
48
- return msgEntry.message?.role === "user";
61
+ return isObject(msgEntry.message) && msgEntry.message.role === "user";
49
62
  }
50
63
 
51
64
  /**
52
65
  * Check if an entry is a service-tier change.
53
66
  */
54
67
  function isServiceTierChange(entry: SessionEntry): entry is SessionServiceTierChangeEntry {
55
- return entry.type === "service_tier_change";
68
+ return isObject(entry) && entry.type === "service_tier_change";
56
69
  }
57
70
 
58
71
  /**
@@ -107,9 +120,21 @@ function extractStats(
107
120
  folder: string,
108
121
  entry: SessionMessageEntry,
109
122
  currentServiceTier: ServiceTier | undefined,
110
- ): MessageStats | null {
111
- const msg = entry.message as AssistantMessage;
123
+ ): ParsedMessageStats | null {
124
+ const msg = entry.message as SessionAssistantMessage;
112
125
  if (msg?.role !== "assistant") return null;
126
+ // Incomplete historical metadata cannot be safely attributed or counted.
127
+ // Skip malformed rows, as with legacy entries lacking an ID; do not invent usage.
128
+ if (!isNonemptyString(msg.model) || !isNonemptyString(msg.provider) || !isNonemptyString(msg.api)) return null;
129
+ if (!isNonnegativeNumber(msg.timestamp) || !isObject(msg.usage)) return null;
130
+ if (
131
+ !isNonnegativeNumber(msg.usage.input) ||
132
+ !isNonnegativeNumber(msg.usage.output) ||
133
+ !isNonnegativeNumber(msg.usage.cacheRead) ||
134
+ !isNonnegativeNumber(msg.usage.cacheWrite) ||
135
+ !isNonnegativeNumber(msg.usage.totalTokens)
136
+ )
137
+ return null;
113
138
 
114
139
  // Backfill: when the session recorded `priority` as the active service tier
115
140
  // at this point but the AI usage payload was captured before priority
@@ -117,9 +142,9 @@ function extractStats(
117
142
  // "Premium Reqs" stat aggregates priority traffic on re-sync. Trust any
118
143
  // non-zero value already in `usage.premiumRequests` (Copilot multipliers or
119
144
  // the new AI code path) and only synthesise when the field is missing/zero.
120
- const recorded = msg.usage.premiumRequests ?? 0;
145
+ const recorded = isNonnegativeNumber(msg.usage.premiumRequests) ? msg.usage.premiumRequests : 0;
121
146
  const derived = recorded > 0 ? recorded : getPriorityPremiumRequests(currentServiceTier, msg.provider);
122
- const usage = derived === recorded ? msg.usage : { ...msg.usage, premiumRequests: derived };
147
+ const usage = { ...msg.usage, premiumRequests: derived };
123
148
 
124
149
  return {
125
150
  sessionFile,
@@ -129,10 +154,12 @@ function extractStats(
129
154
  provider: msg.provider,
130
155
  api: msg.api,
131
156
  timestamp: msg.timestamp,
132
- duration: msg.duration ?? null,
133
- ttft: msg.ttft ?? null,
134
- stopReason: msg.stopReason,
135
- errorMessage: msg.errorMessage ?? null,
157
+ duration: isNonnegativeNumber(msg.duration) ? msg.duration : null,
158
+ ttft: isNonnegativeNumber(msg.ttft) ? msg.ttft : null,
159
+ // Historical session entries may omit stopReason. Keep the stats schema
160
+ // non-nullable while preserving those requests instead of aborting sync.
161
+ stopReason: isNonemptyString(msg.stopReason) ? msg.stopReason : "unknown",
162
+ errorMessage: typeof msg.errorMessage === "string" ? msg.errorMessage : null,
136
163
  usage,
137
164
  };
138
165
  }
@@ -205,7 +232,7 @@ function scanLastServiceTier(bytes: Uint8Array): ServiceTier | undefined {
205
232
  * entries, preserving offset-based memory behavior for large sessions.
206
233
  */
207
234
  export interface ParseSessionResult {
208
- stats: MessageStats[];
235
+ stats: ParsedMessageStats[];
209
236
  userStats: UserMessageStats[];
210
237
  userLinks: UserMessageLink[];
211
238
  newOffset: number;
@@ -220,7 +247,7 @@ export async function parseSessionFile(sessionPath: string, fromOffset = 0): Pro
220
247
  }
221
248
 
222
249
  const folder = extractFolderFromPath(sessionPath);
223
- const stats: MessageStats[] = [];
250
+ const stats: ParsedMessageStats[] = [];
224
251
  const userStats: UserMessageStats[] = [];
225
252
  const userLinks: UserMessageLink[] = [];
226
253
  const userByEntryId = new Map<string, UserMessageStats>();
@@ -249,9 +276,9 @@ export async function parseSessionFile(sessionPath: string, fromOffset = 0): Pro
249
276
  if (msgStats) stats.push(msgStats);
250
277
  // Link assistant's responding model back to the user message it answered.
251
278
  const parentId = (entry as SessionMessageEntry).parentId;
252
- if (parentId) {
253
- const msg = entry.message as AssistantMessage;
254
- if (msg.model && msg.provider) {
279
+ if (isNonemptyString(parentId)) {
280
+ const msg = entry.message as SessionAssistantMessage;
281
+ if (isNonemptyString(msg.model) && isNonemptyString(msg.provider)) {
255
282
  // Emit unconditionally. The aggregator's UPDATE is guarded by
256
283
  // `model IS NULL` so this is idempotent: a no-op for already
257
284
  // linked rows, a fix-up for fresh inserts (which start NULL
@@ -326,7 +353,7 @@ export async function getSessionEntry(sessionPath: string, entryId: string): Pro
326
353
 
327
354
  const { entries } = parseSessionEntriesLenient(bytes);
328
355
  for (const entry of entries) {
329
- if ("id" in entry && entry.id === entryId) {
356
+ if (isObject(entry) && "id" in entry && entry.id === entryId) {
330
357
  return entry;
331
358
  }
332
359
  }
package/src/types.ts CHANGED
@@ -27,7 +27,7 @@ export interface MessageStats {
27
27
  /** Time to first token in milliseconds */
28
28
  ttft: number | null;
29
29
  /** Stop reason */
30
- stopReason: StopReason;
30
+ stopReason: StopReason | "unknown";
31
31
  /** Error message if stopReason is error */
32
32
  errorMessage: string | null;
33
33
  /** Token usage */
@@ -56,12 +56,20 @@ export interface SessionHeader {
56
56
  title?: string;
57
57
  }
58
58
 
59
+ /** Historical JSONL may contain missing, partial, or malformed cost payloads. */
60
+ export type SessionAssistantMessage = Omit<AssistantMessage, "usage"> & {
61
+ usage: Omit<Usage, "cost"> & { cost?: unknown };
62
+ };
63
+
64
+ /** Parser output retains untrusted costs until the database insertion boundary. */
65
+ export type ParsedMessageStats = Omit<MessageStats, "usage"> & { usage: SessionAssistantMessage["usage"] };
66
+
59
67
  export interface SessionMessageEntry {
60
68
  type: "message";
61
69
  id: string;
62
70
  parentId: string | null;
63
71
  timestamp: string;
64
- message: AssistantMessage | { role: "user" | "toolResult" };
72
+ message: SessionAssistantMessage | { role: "user" | "toolResult" };
65
73
  }
66
74
 
67
75
  export interface SessionServiceTierChangeEntry {