@oh-my-pi/omp-stats 14.9.2 → 14.9.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/src/parser.ts CHANGED
@@ -2,7 +2,8 @@ import * as fs from "node:fs/promises";
2
2
  import * as path from "node:path";
3
3
  import type { AssistantMessage } from "@oh-my-pi/pi-ai";
4
4
  import { getSessionsDir, isEnoent } from "@oh-my-pi/pi-utils";
5
- import type { MessageStats, SessionEntry, SessionMessageEntry } from "./types";
5
+ import type { MessageStats, SessionEntry, SessionMessageEntry, UserMessageStats } from "./types";
6
+ import { computeUserMessageMetrics } from "./user-metrics";
6
7
 
7
8
  /**
8
9
  * Extract folder name from session filename.
@@ -26,6 +27,56 @@ function isAssistantMessage(entry: SessionEntry): entry is SessionMessageEntry {
26
27
  return msgEntry.message?.role === "assistant";
27
28
  }
28
29
 
30
+ /**
31
+ * Check if an entry is a user message (non-toolResult).
32
+ */
33
+ function isUserMessage(entry: SessionEntry): entry is SessionMessageEntry {
34
+ if (entry.type !== "message") return false;
35
+ const msgEntry = entry as SessionMessageEntry;
36
+ return msgEntry.message?.role === "user";
37
+ }
38
+
39
+ /**
40
+ * Extract plain text from a user message content payload.
41
+ */
42
+ function extractUserText(content: unknown): string {
43
+ if (typeof content === "string") return content;
44
+ if (!Array.isArray(content)) return "";
45
+ const parts: string[] = [];
46
+ for (const block of content) {
47
+ if (block && typeof block === "object" && (block as { type?: unknown }).type === "text") {
48
+ const text = (block as { text?: unknown }).text;
49
+ if (typeof text === "string") parts.push(text);
50
+ }
51
+ }
52
+ return parts.join("");
53
+ }
54
+
55
+ /**
56
+ * Build user-message stats from an entry. Returns null for empty/synthetic content.
57
+ */
58
+ function extractUserStats(sessionFile: string, folder: string, entry: SessionMessageEntry): UserMessageStats | null {
59
+ const msg = entry.message as { role: "user"; content?: unknown; synthetic?: boolean };
60
+ if (msg.role !== "user" || msg.synthetic) return null;
61
+ const text = extractUserText(msg.content);
62
+ if (!text.trim()) return null;
63
+ const metrics = computeUserMessageMetrics(text);
64
+ const ts = Date.parse(entry.timestamp);
65
+ return {
66
+ sessionFile,
67
+ entryId: entry.id,
68
+ folder,
69
+ timestamp: Number.isFinite(ts) ? ts : 0,
70
+ model: null,
71
+ provider: null,
72
+ chars: metrics.chars,
73
+ words: metrics.words,
74
+ yellingSentences: metrics.yellingSentences,
75
+ profanity: metrics.profanity,
76
+ dramaRuns: metrics.dramaRuns,
77
+ };
78
+ }
79
+
29
80
  /**
30
81
  * Extract stats from an assistant message entry.
31
82
  */
@@ -83,28 +134,48 @@ function parseSessionEntriesLenient(bytes: Uint8Array): { entries: SessionEntry[
83
134
  export async function parseSessionFile(
84
135
  sessionPath: string,
85
136
  fromOffset = 0,
86
- ): Promise<{ stats: MessageStats[]; newOffset: number }> {
137
+ ): Promise<{ stats: MessageStats[]; userStats: UserMessageStats[]; newOffset: number }> {
87
138
  let bytes: Uint8Array;
88
139
  try {
89
140
  bytes = await Bun.file(sessionPath).bytes();
90
141
  } catch (err) {
91
- if (isEnoent(err)) return { stats: [], newOffset: fromOffset };
142
+ if (isEnoent(err)) return { stats: [], userStats: [], newOffset: fromOffset };
92
143
  throw err;
93
144
  }
94
145
 
95
146
  const folder = extractFolderFromPath(sessionPath);
96
147
  const stats: MessageStats[] = [];
148
+ const userStats: UserMessageStats[] = [];
149
+ const userByEntryId = new Map<string, UserMessageStats>();
97
150
  const start = Math.max(0, Math.min(fromOffset, bytes.length));
98
151
  const unprocessed = bytes.subarray(start);
99
152
  const { entries, read } = parseSessionEntriesLenient(unprocessed);
100
153
  for (const entry of entries) {
154
+ if (isUserMessage(entry)) {
155
+ const userMsg = extractUserStats(sessionPath, folder, entry);
156
+ if (userMsg) {
157
+ userStats.push(userMsg);
158
+ userByEntryId.set(entry.id, userMsg);
159
+ }
160
+ continue;
161
+ }
101
162
  if (isAssistantMessage(entry)) {
102
163
  const msgStats = extractStats(sessionPath, folder, entry);
103
164
  if (msgStats) stats.push(msgStats);
165
+ // Link assistant's responding model back to the user message it answered.
166
+ const parentId = (entry as SessionMessageEntry).parentId;
167
+ if (parentId) {
168
+ const parentUser = userByEntryId.get(parentId);
169
+ if (parentUser && parentUser.model === null) {
170
+ const msg = entry.message as AssistantMessage;
171
+ parentUser.model = msg.model;
172
+ parentUser.provider = msg.provider;
173
+ }
174
+ }
104
175
  }
105
176
  }
106
177
 
107
- return { stats, newOffset: start + read };
178
+ return { stats, userStats, newOffset: start + read };
108
179
  }
109
180
 
110
181
  /**
package/src/server.ts CHANGED
@@ -3,7 +3,11 @@ import * as os from "node:os";
3
3
  import * as path from "node:path";
4
4
  import { $ } from "bun";
5
5
  import {
6
+ getBehaviorDashboardStats,
7
+ getCostDashboardStats,
6
8
  getDashboardStats,
9
+ getModelDashboardStats,
10
+ getOverviewStats,
7
11
  getRecentErrors,
8
12
  getRecentRequests,
9
13
  getRequestDetails,
@@ -167,11 +171,31 @@ async function handleApi(req: Request): Promise<Response> {
167
171
  const url = new URL(req.url);
168
172
  const path = url.pathname;
169
173
 
170
- // Sync sessions before returning stats
171
- await syncAllSessions();
174
+ // Stats reads are DB-only; explicit /api/sync does the expensive session scan.
175
+ const range = url.searchParams.get("range");
172
176
 
173
177
  if (path === "/api/stats") {
174
- const stats = await getDashboardStats();
178
+ const stats = await getDashboardStats(range);
179
+ return Response.json(stats);
180
+ }
181
+
182
+ if (path === "/api/stats/overview") {
183
+ const stats = await getOverviewStats(range);
184
+ return Response.json(stats);
185
+ }
186
+
187
+ if (path === "/api/stats/model-dashboard") {
188
+ const stats = await getModelDashboardStats(range);
189
+ return Response.json(stats);
190
+ }
191
+
192
+ if (path === "/api/stats/costs") {
193
+ const stats = await getCostDashboardStats(range);
194
+ return Response.json(stats);
195
+ }
196
+
197
+ if (path === "/api/stats/behavior") {
198
+ const stats = await getBehaviorDashboardStats(range);
175
199
  return Response.json(stats);
176
200
  }
177
201
 
@@ -188,17 +212,17 @@ async function handleApi(req: Request): Promise<Response> {
188
212
  }
189
213
 
190
214
  if (path === "/api/stats/models") {
191
- const stats = await getDashboardStats();
215
+ const stats = await getDashboardStats(range);
192
216
  return Response.json(stats.byModel);
193
217
  }
194
218
 
195
219
  if (path === "/api/stats/folders") {
196
- const stats = await getDashboardStats();
220
+ const stats = await getDashboardStats(range);
197
221
  return Response.json(stats.byFolder);
198
222
  }
199
223
 
200
224
  if (path === "/api/stats/timeseries") {
201
- const stats = await getDashboardStats();
225
+ const stats = await getDashboardStats(range);
202
226
  return Response.json(stats.timeSeries);
203
227
  }
204
228
 
package/src/types.ts CHANGED
@@ -195,3 +195,84 @@ export interface SessionMessageEntry {
195
195
  }
196
196
 
197
197
  export type SessionEntry = SessionHeader | SessionMessageEntry | { type: string };
198
+
199
+ /**
200
+ * Behavioral stats extracted from a single user message.
201
+ */
202
+ export interface UserMessageStats {
203
+ /** Database ID */
204
+ id?: number;
205
+ /** Session file path */
206
+ sessionFile: string;
207
+ /** Entry ID within the session */
208
+ entryId: string;
209
+ /** Folder/project path */
210
+ folder: string;
211
+ /** Unix timestamp in ms */
212
+ timestamp: number;
213
+ /** Model that responded to this user message, if linked */
214
+ model: string | null;
215
+ /** Provider that responded to this user message, if linked */
216
+ provider: string | null;
217
+ /** Total characters of message text */
218
+ chars: number;
219
+ /** Whitespace-delimited word count */
220
+ words: number;
221
+ /** Yelling sentences (> 50% uppercase letters) */
222
+ yellingSentences: number;
223
+ /** Profanity hits */
224
+ profanity: number;
225
+ /** Runs of 3+ consecutive `!` / `?` */
226
+ dramaRuns: number;
227
+ }
228
+
229
+ /**
230
+ * Behavior time-series point (daily bucket, per responding model).
231
+ */
232
+ export interface BehaviorTimeSeriesPoint {
233
+ /** Bucket timestamp (start of day) */
234
+ timestamp: number;
235
+ /** Responding model ("unknown" if user msg never got a reply) */
236
+ model: string;
237
+ /** Responding provider */
238
+ provider: string;
239
+ /** Number of user messages in bucket */
240
+ messages: number;
241
+ /** Total yelling sentences in bucket */
242
+ yellingSentences: number;
243
+ /** Total profanity hits in bucket */
244
+ profanity: number;
245
+ /** Total drama runs in bucket */
246
+ dramaRuns: number;
247
+ /** Total characters in bucket */
248
+ chars: number;
249
+ }
250
+
251
+ export interface BehaviorOverallStats {
252
+ totalMessages: number;
253
+ totalYellingSentences: number;
254
+ totalProfanity: number;
255
+ totalDramaRuns: number;
256
+ totalChars: number;
257
+ firstTimestamp: number;
258
+ lastTimestamp: number;
259
+ }
260
+
261
+ /**
262
+ * Per-model behavioral aggregate over the active range.
263
+ */
264
+ export interface BehaviorModelStats {
265
+ model: string;
266
+ provider: string;
267
+ totalMessages: number;
268
+ totalYellingSentences: number;
269
+ totalProfanity: number;
270
+ totalDramaRuns: number;
271
+ totalChars: number;
272
+ lastTimestamp: number;
273
+ }
274
+ export interface BehaviorDashboardStats {
275
+ overall: BehaviorOverallStats;
276
+ byModel: BehaviorModelStats[];
277
+ behaviorSeries: BehaviorTimeSeriesPoint[];
278
+ }