@oh-my-pi/omp-stats 6.9.69

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 ADDED
@@ -0,0 +1,166 @@
1
+ import { readdir } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { basename, join } from "node:path";
4
+ import type { AssistantMessage } from "@oh-my-pi/pi-ai";
5
+ import type { MessageStats, SessionEntry, SessionMessageEntry } from "./types";
6
+
7
+ const SESSIONS_DIR = join(homedir(), ".omp", "agent", "sessions");
8
+
9
+ /**
10
+ * Extract folder name from session filename.
11
+ * Session files are named like: --work--pi--/timestamp_uuid.jsonl
12
+ * The folder part uses -- as path separator.
13
+ */
14
+ function extractFolderFromPath(sessionPath: string): string {
15
+ const dir = basename(sessionPath.replace(/\/[^/]+\.jsonl$/, ""));
16
+ // Convert --work--pi-- to /work/pi
17
+ return dir.replace(/^--/, "/").replace(/--/g, "/");
18
+ }
19
+
20
+ /**
21
+ * Parse a single JSONL line into a session entry.
22
+ */
23
+ function parseLine(line: string): SessionEntry | null {
24
+ const trimmed = line.trim();
25
+ if (!trimmed) return null;
26
+
27
+ try {
28
+ return JSON.parse(trimmed) as SessionEntry;
29
+ } catch {
30
+ return null;
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Check if an entry is an assistant message.
36
+ */
37
+ function isAssistantMessage(entry: SessionEntry): entry is SessionMessageEntry {
38
+ if (entry.type !== "message") return false;
39
+ const msgEntry = entry as SessionMessageEntry;
40
+ return msgEntry.message?.role === "assistant";
41
+ }
42
+
43
+ /**
44
+ * Extract stats from an assistant message entry.
45
+ */
46
+ function extractStats(sessionFile: string, folder: string, entry: SessionMessageEntry): MessageStats | null {
47
+ const msg = entry.message as AssistantMessage;
48
+ if (!msg || msg.role !== "assistant") return null;
49
+
50
+ return {
51
+ sessionFile,
52
+ entryId: entry.id,
53
+ folder,
54
+ model: msg.model,
55
+ provider: msg.provider,
56
+ api: msg.api,
57
+ timestamp: msg.timestamp,
58
+ duration: msg.duration ?? null,
59
+ ttft: msg.ttft ?? null,
60
+ stopReason: msg.stopReason,
61
+ errorMessage: msg.errorMessage ?? null,
62
+ usage: msg.usage,
63
+ };
64
+ }
65
+
66
+ /**
67
+ * Parse a session file and extract all assistant message stats.
68
+ * Uses incremental reading with offset tracking.
69
+ */
70
+ export async function parseSessionFile(
71
+ sessionPath: string,
72
+ fromOffset = 0,
73
+ ): Promise<{ stats: MessageStats[]; newOffset: number }> {
74
+ const file = Bun.file(sessionPath);
75
+ const exists = await file.exists();
76
+ if (!exists) {
77
+ return { stats: [], newOffset: fromOffset };
78
+ }
79
+
80
+ const text = await file.text();
81
+ const lines = text.split("\n");
82
+ const folder = extractFolderFromPath(sessionPath);
83
+ const stats: MessageStats[] = [];
84
+
85
+ let currentOffset = 0;
86
+ for (const line of lines) {
87
+ const lineLength = line.length + 1; // +1 for newline
88
+ if (currentOffset >= fromOffset && line.trim()) {
89
+ const entry = parseLine(line);
90
+ if (entry && isAssistantMessage(entry)) {
91
+ const msgStats = extractStats(sessionPath, folder, entry);
92
+ if (msgStats) {
93
+ stats.push(msgStats);
94
+ }
95
+ }
96
+ }
97
+ currentOffset += lineLength;
98
+ }
99
+
100
+ return { stats, newOffset: currentOffset };
101
+ }
102
+
103
+ /**
104
+ * List all session directories (folders).
105
+ */
106
+ export async function listSessionFolders(): Promise<string[]> {
107
+ try {
108
+ const entries = await readdir(SESSIONS_DIR, { withFileTypes: true });
109
+ return entries.filter((e) => e.isDirectory()).map((e) => join(SESSIONS_DIR, e.name));
110
+ } catch {
111
+ return [];
112
+ }
113
+ }
114
+
115
+ /**
116
+ * List all session files in a folder.
117
+ */
118
+ export async function listSessionFiles(folderPath: string): Promise<string[]> {
119
+ try {
120
+ const entries = await readdir(folderPath, { withFileTypes: true });
121
+ return entries.filter((e) => e.isFile() && e.name.endsWith(".jsonl")).map((e) => join(folderPath, e.name));
122
+ } catch {
123
+ return [];
124
+ }
125
+ }
126
+
127
+ /**
128
+ * List all session files across all folders.
129
+ */
130
+ export async function listAllSessionFiles(): Promise<string[]> {
131
+ const folders = await listSessionFolders();
132
+ const allFiles: string[] = [];
133
+
134
+ for (const folder of folders) {
135
+ const files = await listSessionFiles(folder);
136
+ allFiles.push(...files);
137
+ }
138
+
139
+ return allFiles;
140
+ }
141
+
142
+ /**
143
+ * Get session directory path.
144
+ */
145
+ export function getSessionsDir(): string {
146
+ return SESSIONS_DIR;
147
+ }
148
+
149
+ /**
150
+ * Find a specific entry in a session file.
151
+ */
152
+ export async function getSessionEntry(sessionPath: string, entryId: string): Promise<SessionEntry | null> {
153
+ const file = Bun.file(sessionPath);
154
+ if (!(await file.exists())) return null;
155
+
156
+ const text = await file.text();
157
+ const lines = text.split("\n");
158
+
159
+ for (const line of lines) {
160
+ const entry = parseLine(line);
161
+ if (entry && "id" in entry && entry.id === entryId) {
162
+ return entry;
163
+ }
164
+ }
165
+ return null;
166
+ }
package/src/server.ts ADDED
@@ -0,0 +1,147 @@
1
+ import { join } from "node:path";
2
+ import {
3
+ getDashboardStats,
4
+ getRecentErrors,
5
+ getRecentRequests,
6
+ getRequestDetails,
7
+ getTotalMessageCount,
8
+ syncAllSessions,
9
+ } from "./aggregator";
10
+
11
+ const STATIC_DIR = join(import.meta.dir, "..", "dist", "client");
12
+
13
+ /**
14
+ * Handle API requests.
15
+ */
16
+ async function handleApi(req: Request): Promise<Response> {
17
+ const url = new URL(req.url);
18
+ const path = url.pathname;
19
+
20
+ // Sync sessions before returning stats
21
+ await syncAllSessions();
22
+
23
+ if (path === "/api/stats") {
24
+ const stats = await getDashboardStats();
25
+ return Response.json(stats);
26
+ }
27
+
28
+ if (path === "/api/stats/recent") {
29
+ const limit = url.searchParams.get("limit");
30
+ const stats = await getRecentRequests(limit ? parseInt(limit, 10) : undefined);
31
+ return Response.json(stats);
32
+ }
33
+
34
+ if (path === "/api/stats/errors") {
35
+ const limit = url.searchParams.get("limit");
36
+ const stats = await getRecentErrors(limit ? parseInt(limit, 10) : undefined);
37
+ return Response.json(stats);
38
+ }
39
+
40
+ if (path === "/api/stats/models") {
41
+ const stats = await getDashboardStats();
42
+ return Response.json(stats.byModel);
43
+ }
44
+
45
+ if (path === "/api/stats/folders") {
46
+ const stats = await getDashboardStats();
47
+ return Response.json(stats.byFolder);
48
+ }
49
+
50
+ if (path === "/api/stats/timeseries") {
51
+ const stats = await getDashboardStats();
52
+ return Response.json(stats.timeSeries);
53
+ }
54
+
55
+ if (path.startsWith("/api/request/")) {
56
+ const id = path.split("/").pop();
57
+ if (!id) return new Response("Bad Request", { status: 400 });
58
+ const details = await getRequestDetails(parseInt(id, 10));
59
+ if (!details) return new Response("Not Found", { status: 404 });
60
+ return Response.json(details);
61
+ }
62
+
63
+ if (path === "/api/sync") {
64
+ const result = await syncAllSessions();
65
+ const count = await getTotalMessageCount();
66
+ return Response.json({ ...result, totalMessages: count });
67
+ }
68
+
69
+ return new Response("Not Found", { status: 404 });
70
+ }
71
+
72
+ /**
73
+ * Handle static file requests.
74
+ */
75
+ async function handleStatic(path: string): Promise<Response> {
76
+ const filePath = path === "/" ? "/index.html" : path;
77
+ const fullPath = join(STATIC_DIR, filePath);
78
+
79
+ const file = Bun.file(fullPath);
80
+ if (await file.exists()) {
81
+ return new Response(file);
82
+ }
83
+
84
+ // SPA fallback
85
+ const index = Bun.file(join(STATIC_DIR, "index.html"));
86
+ if (await index.exists()) {
87
+ return new Response(index);
88
+ }
89
+
90
+ return new Response("Not Found", { status: 404 });
91
+ }
92
+
93
+ /**
94
+ * Start the HTTP server.
95
+ */
96
+ export function startServer(port = 3847): { port: number; stop: () => void } {
97
+ const server = Bun.serve({
98
+ port,
99
+ async fetch(req) {
100
+ const url = new URL(req.url);
101
+ const path = url.pathname;
102
+
103
+ // CORS headers for local development
104
+ const corsHeaders = {
105
+ "Access-Control-Allow-Origin": "*",
106
+ "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
107
+ "Access-Control-Allow-Headers": "Content-Type",
108
+ };
109
+
110
+ if (req.method === "OPTIONS") {
111
+ return new Response(null, { headers: corsHeaders });
112
+ }
113
+
114
+ try {
115
+ let response: Response;
116
+
117
+ if (path.startsWith("/api/")) {
118
+ response = await handleApi(req);
119
+ } else {
120
+ response = await handleStatic(path);
121
+ }
122
+
123
+ // Add CORS headers to all responses
124
+ const headers = new Headers(response.headers);
125
+ for (const [key, value] of Object.entries(corsHeaders)) {
126
+ headers.set(key, value);
127
+ }
128
+
129
+ return new Response(response.body, {
130
+ status: response.status,
131
+ headers,
132
+ });
133
+ } catch (error) {
134
+ console.error("Server error:", error);
135
+ return Response.json(
136
+ { error: error instanceof Error ? error.message : "Unknown error" },
137
+ { status: 500, headers: corsHeaders },
138
+ );
139
+ }
140
+ },
141
+ });
142
+
143
+ return {
144
+ port: server.port ?? port,
145
+ stop: () => server.stop(),
146
+ };
147
+ }
package/src/types.ts ADDED
@@ -0,0 +1,139 @@
1
+ import type { AssistantMessage, StopReason, Usage } from "@oh-my-pi/pi-ai";
2
+
3
+ /**
4
+ * Extracted stats from an assistant message.
5
+ */
6
+ export interface MessageStats {
7
+ /** Database ID */
8
+ id?: number;
9
+ /** Session file path */
10
+ sessionFile: string;
11
+ /** Entry ID within the session */
12
+ entryId: string;
13
+ /** Folder/project path (extracted from session filename) */
14
+ folder: string;
15
+ /** Model ID */
16
+ model: string;
17
+ /** Provider name */
18
+ provider: string;
19
+ /** API type */
20
+ api: string;
21
+ /** Unix timestamp in milliseconds */
22
+ timestamp: number;
23
+ /** Request duration in milliseconds */
24
+ duration: number | null;
25
+ /** Time to first token in milliseconds */
26
+ ttft: number | null;
27
+ /** Stop reason */
28
+ stopReason: StopReason;
29
+ /** Error message if stopReason is error */
30
+ errorMessage: string | null;
31
+ /** Token usage */
32
+ usage: Usage;
33
+ }
34
+
35
+ /**
36
+ * Full details of a request, including content.
37
+ */
38
+ export interface RequestDetails extends MessageStats {
39
+ messages: any[]; // The full conversation history or just the last turn
40
+ output: any; // The model's response
41
+ }
42
+
43
+ /**
44
+ * Aggregated stats for a model or folder.
45
+ */
46
+ export interface AggregatedStats {
47
+ /** Total number of requests */
48
+ totalRequests: number;
49
+ /** Number of successful requests */
50
+ successfulRequests: number;
51
+ /** Number of failed requests */
52
+ failedRequests: number;
53
+ /** Error rate (0-1) */
54
+ errorRate: number;
55
+ /** Total input tokens */
56
+ totalInputTokens: number;
57
+ /** Total output tokens */
58
+ totalOutputTokens: number;
59
+ /** Total cache read tokens */
60
+ totalCacheReadTokens: number;
61
+ /** Total cache write tokens */
62
+ totalCacheWriteTokens: number;
63
+ /** Cache hit rate (0-1) */
64
+ cacheRate: number;
65
+ /** Total cost */
66
+ totalCost: number;
67
+ /** Average duration in ms */
68
+ avgDuration: number | null;
69
+ /** Average TTFT in ms */
70
+ avgTtft: number | null;
71
+ /** Average tokens per second (output tokens / duration) */
72
+ avgTokensPerSecond: number | null;
73
+ /** Time range */
74
+ firstTimestamp: number;
75
+ lastTimestamp: number;
76
+ }
77
+
78
+ /**
79
+ * Stats grouped by model.
80
+ */
81
+ export interface ModelStats extends AggregatedStats {
82
+ model: string;
83
+ provider: string;
84
+ }
85
+
86
+ /**
87
+ * Stats grouped by folder.
88
+ */
89
+ export interface FolderStats extends AggregatedStats {
90
+ folder: string;
91
+ }
92
+
93
+ /**
94
+ * Time series data point.
95
+ */
96
+ export interface TimeSeriesPoint {
97
+ /** Bucket timestamp (start of hour/day) */
98
+ timestamp: number;
99
+ /** Request count */
100
+ requests: number;
101
+ /** Error count */
102
+ errors: number;
103
+ /** Total tokens */
104
+ tokens: number;
105
+ /** Total cost */
106
+ cost: number;
107
+ }
108
+
109
+ /**
110
+ * Overall dashboard stats.
111
+ */
112
+ export interface DashboardStats {
113
+ overall: AggregatedStats;
114
+ byModel: ModelStats[];
115
+ byFolder: FolderStats[];
116
+ timeSeries: TimeSeriesPoint[];
117
+ }
118
+
119
+ /**
120
+ * Session log entry types.
121
+ */
122
+ export interface SessionHeader {
123
+ type: "session";
124
+ version: number;
125
+ id: string;
126
+ timestamp: string;
127
+ cwd: string;
128
+ title?: string;
129
+ }
130
+
131
+ export interface SessionMessageEntry {
132
+ type: "message";
133
+ id: string;
134
+ parentId: string | null;
135
+ timestamp: string;
136
+ message: AssistantMessage | { role: "user" | "toolResult" };
137
+ }
138
+
139
+ export type SessionEntry = SessionHeader | SessionMessageEntry | { type: string };