@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/package.json +6 -6
- package/src/aggregator.ts +279 -42
- package/src/client/App.tsx +125 -30
- package/src/client/api.ts +35 -3
- package/src/client/components/BehaviorChart.tsx +374 -0
- package/src/client/components/BehaviorModelsTable.tsx +465 -0
- package/src/client/components/BehaviorSummary.tsx +95 -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 +63 -0
- package/src/db.ts +420 -26
- package/src/index.ts +29 -3
- package/src/parser.ts +95 -7
- package/src/server.ts +30 -6
- package/src/sync-worker.ts +31 -0
- package/src/types.ts +113 -0
- package/src/user-metrics.ts +686 -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, 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
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
//
|
|
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
|
|
|
@@ -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
|
+
}
|