@oh-my-pi/omp-stats 14.9.3 → 14.9.7

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, UserMessageLink, UserMessageStats } from "./types";
6
+ import { computeUserMessageMetrics } from "./user-metrics";
6
7
 
7
8
  /**
8
9
  * Extract folder name from session filename.
@@ -26,6 +27,59 @@ 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
+ yelling: metrics.yelling,
75
+ profanity: metrics.profanity,
76
+ anguish: metrics.anguish,
77
+ negation: metrics.negation,
78
+ repetition: metrics.repetition,
79
+ blame: metrics.blame,
80
+ };
81
+ }
82
+
29
83
  /**
30
84
  * Extract stats from an assistant message entry.
31
85
  */
@@ -80,31 +134,65 @@ function parseSessionEntriesLenient(bytes: Uint8Array): { entries: SessionEntry[
80
134
  * Parse a session file and extract all assistant message stats.
81
135
  * Uses incremental reading with offset tracking.
82
136
  */
83
- export async function parseSessionFile(
84
- sessionPath: string,
85
- fromOffset = 0,
86
- ): Promise<{ stats: MessageStats[]; newOffset: number }> {
137
+ export interface ParseSessionResult {
138
+ stats: MessageStats[];
139
+ userStats: UserMessageStats[];
140
+ userLinks: UserMessageLink[];
141
+ newOffset: number;
142
+ }
143
+
144
+ export async function parseSessionFile(sessionPath: string, fromOffset = 0): Promise<ParseSessionResult> {
87
145
  let bytes: Uint8Array;
88
146
  try {
89
147
  bytes = await Bun.file(sessionPath).bytes();
90
148
  } catch (err) {
91
- if (isEnoent(err)) return { stats: [], newOffset: fromOffset };
149
+ if (isEnoent(err)) return { stats: [], userStats: [], userLinks: [], newOffset: fromOffset };
92
150
  throw err;
93
151
  }
94
152
 
95
153
  const folder = extractFolderFromPath(sessionPath);
96
154
  const stats: MessageStats[] = [];
155
+ const userStats: UserMessageStats[] = [];
156
+ const userLinks: UserMessageLink[] = [];
157
+ const userByEntryId = new Map<string, UserMessageStats>();
97
158
  const start = Math.max(0, Math.min(fromOffset, bytes.length));
98
159
  const unprocessed = bytes.subarray(start);
99
160
  const { entries, read } = parseSessionEntriesLenient(unprocessed);
100
161
  for (const entry of entries) {
162
+ if (isUserMessage(entry)) {
163
+ const userMsg = extractUserStats(sessionPath, folder, entry);
164
+ if (userMsg) {
165
+ userStats.push(userMsg);
166
+ userByEntryId.set(entry.id, userMsg);
167
+ }
168
+ continue;
169
+ }
101
170
  if (isAssistantMessage(entry)) {
102
171
  const msgStats = extractStats(sessionPath, folder, entry);
103
172
  if (msgStats) stats.push(msgStats);
173
+ // Link assistant's responding model back to the user message it answered.
174
+ const parentId = (entry as SessionMessageEntry).parentId;
175
+ if (parentId) {
176
+ const msg = entry.message as AssistantMessage;
177
+ if (msg.model && msg.provider) {
178
+ // Emit unconditionally. The aggregator's UPDATE is guarded by
179
+ // `model IS NULL` so this is idempotent: a no-op for already
180
+ // linked rows, a fix-up for fresh inserts (which start NULL
181
+ // because the user row is recorded before its reply lands) and
182
+ // for cross-pass orphans whose parent was committed by an
183
+ // earlier incremental sync.
184
+ userLinks.push({
185
+ sessionFile: sessionPath,
186
+ entryId: parentId,
187
+ model: msg.model,
188
+ provider: msg.provider,
189
+ });
190
+ }
191
+ }
104
192
  }
105
193
  }
106
194
 
107
- return { stats, newOffset: start + read };
195
+ return { stats, userStats, userLinks, newOffset: start + read };
108
196
  }
109
197
 
110
198
  /**
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
 
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Stateless parse worker for `syncAllSessions`. The main thread owns the
3
+ * SQLite handle; workers receive `{ sessionFile, fromOffset }`, run
4
+ * `parseSessionFile` (which is pure I/O + CPU, no DB), and post the
5
+ * structured-clone-safe result back. One in-flight request per worker so
6
+ * the main thread can fan jobs out 1:1 with the pool size.
7
+ */
8
+
9
+ import { type ParseSessionResult, parseSessionFile } from "./parser";
10
+
11
+ export interface SyncWorkerRequest {
12
+ sessionFile: string;
13
+ fromOffset: number;
14
+ }
15
+
16
+ export type SyncWorkerResponse = { ok: true; result: ParseSessionResult } | { ok: false; error: string };
17
+
18
+ declare const self: Worker & {
19
+ onmessage: ((event: MessageEvent<SyncWorkerRequest>) => void) | null;
20
+ };
21
+
22
+ self.onmessage = async event => {
23
+ const { sessionFile, fromOffset } = event.data;
24
+ try {
25
+ const result = await parseSessionFile(sessionFile, fromOffset);
26
+ self.postMessage({ ok: true, result } satisfies SyncWorkerResponse);
27
+ } catch (err) {
28
+ const error = err instanceof Error ? (err.stack ?? err.message) : String(err);
29
+ self.postMessage({ ok: false, error } satisfies SyncWorkerResponse);
30
+ }
31
+ };
package/src/types.ts CHANGED
@@ -195,3 +195,116 @@ 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
+ yelling: number;
223
+ /** Profanity hits */
224
+ profanity: number;
225
+ /** Catch-all upset signal: drama runs + `noooo`/`ughh`/... + `dude` + `..` */
226
+ anguish: number;
227
+ /** Corrective negation ("no", "nope", "thats not what i meant") */
228
+ negation: number;
229
+ /** User repeating themselves ("i meant", "still doesnt work", "like i said") */
230
+ repetition: number;
231
+ /** Second-person reproach ("you didnt", "you broke", "stop X-ing") */
232
+ blame: number;
233
+ }
234
+
235
+ /**
236
+ * Pair emitted by the parser when it sees an assistant message whose
237
+ * `parentId` points to a user message that wasn't parsed in the same pass
238
+ * (e.g. user prompt landed in an earlier incremental sync). The aggregator
239
+ * applies the link to the persisted `user_messages` row so it stops showing
240
+ * up in the "unknown" model bucket.
241
+ */
242
+ export interface UserMessageLink {
243
+ sessionFile: string;
244
+ entryId: string;
245
+ model: string;
246
+ provider: string;
247
+ }
248
+
249
+ /**
250
+ * Behavior time-series point (daily bucket, per responding model).
251
+ */
252
+ export interface BehaviorTimeSeriesPoint {
253
+ /** Bucket timestamp (start of day) */
254
+ timestamp: number;
255
+ /** Responding model ("unknown" if user msg never got a reply) */
256
+ model: string;
257
+ /** Responding provider */
258
+ provider: string;
259
+ /** Number of user messages in bucket */
260
+ messages: number;
261
+ /** Total yelling sentences in bucket */
262
+ yelling: number;
263
+ /** Total profanity hits in bucket */
264
+ profanity: number;
265
+ /** Total anguish signal in bucket */
266
+ anguish: number;
267
+ /** Total corrective-negation hits in bucket */
268
+ negation: number;
269
+ /** Total user-repeating-themselves hits in bucket */
270
+ repetition: number;
271
+ /** Total second-person blame hits in bucket */
272
+ blame: number;
273
+ /** Total characters in bucket */
274
+ chars: number;
275
+ }
276
+
277
+ export interface BehaviorOverallStats {
278
+ totalMessages: number;
279
+ totalYelling: number;
280
+ totalProfanity: number;
281
+ totalAnguish: number;
282
+ totalNegation: number;
283
+ totalRepetition: number;
284
+ totalBlame: number;
285
+ totalChars: number;
286
+ firstTimestamp: number;
287
+ lastTimestamp: number;
288
+ }
289
+
290
+ /**
291
+ * Per-model behavioral aggregate over the active range.
292
+ */
293
+ export interface BehaviorModelStats {
294
+ model: string;
295
+ provider: string;
296
+ totalMessages: number;
297
+ totalYelling: number;
298
+ totalProfanity: number;
299
+ totalAnguish: number;
300
+ totalNegation: number;
301
+ totalRepetition: number;
302
+ totalBlame: number;
303
+ totalChars: number;
304
+ lastTimestamp: number;
305
+ }
306
+ export interface BehaviorDashboardStats {
307
+ overall: BehaviorOverallStats;
308
+ byModel: BehaviorModelStats[];
309
+ behaviorSeries: BehaviorTimeSeriesPoint[];
310
+ }