@oh-my-pi/omp-stats 14.9.3 → 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/package.json +6 -6
- package/src/aggregator.ts +138 -11
- package/src/client/App.tsx +125 -30
- package/src/client/api.ts +35 -3
- package/src/client/components/BehaviorChart.tsx +367 -0
- package/src/client/components/BehaviorModelsTable.tsx +422 -0
- package/src/client/components/BehaviorSummary.tsx +75 -0
- package/src/client/components/CostChart.tsx +5 -38
- package/src/client/components/CostSummary.tsx +8 -47
- package/src/client/components/Header.tsx +28 -4
- package/src/client/components/StatsGrid.tsx +10 -1
- package/src/client/types.ts +54 -0
- package/src/db.ts +307 -26
- package/src/parser.ts +75 -4
- package/src/server.ts +30 -6
- package/src/types.ts +81 -0
- package/src/user-metrics.ts +486 -0
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
|
-
//
|
|
171
|
-
|
|
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
|
+
}
|