Files
oh-my-pi/packages/stats/src/parser.ts
T
2026-05-30 18:08:51 +02:00

335 lines
11 KiB
TypeScript

import * as fs from "node:fs/promises";
import * as path from "node:path";
import { type AssistantMessage, getPriorityPremiumRequests, type ServiceTier } from "@oh-my-pi/pi-ai";
import { getSessionsDir, isEnoent } from "@oh-my-pi/pi-utils";
import type {
MessageStats,
SessionEntry,
SessionMessageEntry,
SessionServiceTierChangeEntry,
UserMessageLink,
UserMessageStats,
} from "./types";
import { computeUserMessageMetrics } from "./user-metrics";
/**
* Extract folder name from session filename.
* Session files are named like: --work--pi--/timestamp_uuid.jsonl
* The folder part uses -- as path separator.
*/
function extractFolderFromPath(sessionPath: string): string {
const sessionsDir = getSessionsDir();
const rel = path.relative(sessionsDir, sessionPath);
const projectDir = rel.split(path.sep)[0];
// Convert --work--pi-- to /work/pi
return projectDir.replace(/^--/, "/").replace(/--/g, "/");
}
/**
* Check if an entry is an assistant message.
*/
function isAssistantMessage(entry: SessionEntry): entry is SessionMessageEntry {
if (entry.type !== "message") return false;
const msgEntry = entry as SessionMessageEntry;
// Legacy sessions (pre-id tracking) recorded message entries without an `id`.
// They're not linkable and would violate the messages.entry_id NOT NULL
// constraint, so skip them at the parser boundary.
if (typeof msgEntry.id !== "string" || msgEntry.id.length === 0) return false;
return msgEntry.message?.role === "assistant";
}
/**
* Check if an entry is a user message (non-toolResult).
*/
function isUserMessage(entry: SessionEntry): entry is SessionMessageEntry {
if (entry.type !== "message") return false;
const msgEntry = entry as SessionMessageEntry;
if (typeof msgEntry.id !== "string" || msgEntry.id.length === 0) return false;
return msgEntry.message?.role === "user";
}
/**
* Check if an entry is a service-tier change.
*/
function isServiceTierChange(entry: SessionEntry): entry is SessionServiceTierChangeEntry {
return entry.type === "service_tier_change";
}
/**
* Extract plain text from a user message content payload.
*/
function extractUserText(content: unknown): string {
if (typeof content === "string") return content;
if (!Array.isArray(content)) return "";
const parts: string[] = [];
for (const block of content) {
if (block && typeof block === "object" && (block as { type?: unknown }).type === "text") {
const text = (block as { text?: unknown }).text;
if (typeof text === "string") parts.push(text);
}
}
return parts.join("");
}
/**
* Build user-message stats from an entry. Returns null for empty/synthetic content.
*/
function extractUserStats(sessionFile: string, folder: string, entry: SessionMessageEntry): UserMessageStats | null {
const msg = entry.message as { role: "user"; content?: unknown; synthetic?: boolean };
if (msg.role !== "user" || msg.synthetic) return null;
const text = extractUserText(msg.content);
if (!text.trim()) return null;
const metrics = computeUserMessageMetrics(text);
const ts = Date.parse(entry.timestamp);
return {
sessionFile,
entryId: entry.id,
folder,
timestamp: Number.isFinite(ts) ? ts : 0,
model: null,
provider: null,
chars: metrics.chars,
words: metrics.words,
yelling: metrics.yelling,
profanity: metrics.profanity,
anguish: metrics.anguish,
negation: metrics.negation,
repetition: metrics.repetition,
blame: metrics.blame,
};
}
/**
* Extract stats from an assistant message entry.
*/
function extractStats(
sessionFile: string,
folder: string,
entry: SessionMessageEntry,
currentServiceTier: ServiceTier | undefined,
): MessageStats | null {
const msg = entry.message as AssistantMessage;
if (msg?.role !== "assistant") return null;
// Backfill: when the session recorded `priority` as the active service tier
// at this point but the AI usage payload was captured before priority
// requests were folded into `premiumRequests`, derive the count here so the
// "Premium Reqs" stat aggregates priority traffic on re-sync. Trust any
// non-zero value already in `usage.premiumRequests` (Copilot multipliers or
// the new AI code path) and only synthesise when the field is missing/zero.
const recorded = msg.usage.premiumRequests ?? 0;
const derived = recorded > 0 ? recorded : getPriorityPremiumRequests(currentServiceTier, msg.provider);
const usage = derived === recorded ? msg.usage : { ...msg.usage, premiumRequests: derived };
return {
sessionFile,
entryId: entry.id,
folder,
model: msg.model,
provider: msg.provider,
api: msg.api,
timestamp: msg.timestamp,
duration: msg.duration ?? null,
ttft: msg.ttft ?? null,
stopReason: msg.stopReason,
errorMessage: msg.errorMessage ?? null,
usage,
};
}
const LF = 0x0a;
function parseSessionEntriesLenient(bytes: Uint8Array): { entries: SessionEntry[]; read: number } {
const entries: SessionEntry[] = [];
let cursor = 0;
while (cursor < bytes.length) {
const { values, error, read, done } = Bun.JSONL.parseChunk(bytes, cursor, bytes.length);
if (values.length > 0) {
entries.push(...(values as SessionEntry[]));
}
if (error) {
const nextNewline = bytes.indexOf(LF, Math.max(read, cursor));
if (nextNewline === -1) break;
cursor = nextNewline + 1;
continue;
}
if (read <= cursor) break;
cursor = read;
if (done) break;
}
return { entries, read: cursor };
}
function scanLastServiceTier(bytes: Uint8Array): ServiceTier | undefined {
let cursor = 0;
let currentServiceTier: ServiceTier | undefined;
while (cursor < bytes.length) {
const { values, error, read, done } = Bun.JSONL.parseChunk(bytes, cursor, bytes.length);
for (const value of values as SessionEntry[]) {
if (isServiceTierChange(value)) currentServiceTier = value.serviceTier ?? undefined;
}
if (error) {
const nextNewline = bytes.indexOf(LF, Math.max(read, cursor));
if (nextNewline === -1) break;
cursor = nextNewline + 1;
continue;
}
if (read <= cursor) break;
cursor = read;
if (done) break;
}
return currentServiceTier;
}
/**
* Parse a session file and extract all assistant message stats.
* Uses incremental reading with offset tracking.
*
* Service-tier carry-over: `currentServiceTier` is a session-scoped piece of
* state derived from `service_tier_change` entries that affects whether
* subsequent OpenAI assistant replies count as premium requests. Incremental
* syncs that resume past the most-recent tier change would otherwise lose
* that state and silently record `premiumRequests = 0` for priority traffic
* (the coding-agent stopped folding the tier into `usage.premiumRequests`
* after 13f59162e — the parser is now the sole source of truth). When
* `fromOffset > 0` we therefore scan the bytes preceding `fromOffset`
* for the latest service-tier value before parsing the unprocessed tail.
* The scan only keeps the current tier and does not materialize prefix
* entries, preserving offset-based memory behavior for large sessions.
*/
export interface ParseSessionResult {
stats: MessageStats[];
userStats: UserMessageStats[];
userLinks: UserMessageLink[];
newOffset: number;
}
export async function parseSessionFile(sessionPath: string, fromOffset = 0): Promise<ParseSessionResult> {
let bytes: Uint8Array;
try {
bytes = await Bun.file(sessionPath).bytes();
} catch (err) {
if (isEnoent(err)) return { stats: [], userStats: [], userLinks: [], newOffset: fromOffset };
throw err;
}
const folder = extractFolderFromPath(sessionPath);
const stats: MessageStats[] = [];
const userStats: UserMessageStats[] = [];
const userLinks: UserMessageLink[] = [];
const userByEntryId = new Map<string, UserMessageStats>();
const start = Math.max(0, Math.min(fromOffset, bytes.length));
const unprocessed = bytes.subarray(start);
const { entries, read } = parseSessionEntriesLenient(unprocessed);
let currentServiceTier: ServiceTier | undefined;
if (start > 0) {
currentServiceTier = scanLastServiceTier(bytes.subarray(0, start));
}
for (const entry of entries) {
if (isServiceTierChange(entry)) {
currentServiceTier = entry.serviceTier ?? undefined;
continue;
}
if (isUserMessage(entry)) {
const userMsg = extractUserStats(sessionPath, folder, entry);
if (userMsg) {
userStats.push(userMsg);
userByEntryId.set(entry.id, userMsg);
}
continue;
}
if (isAssistantMessage(entry)) {
const msgStats = extractStats(sessionPath, folder, entry, currentServiceTier);
if (msgStats) stats.push(msgStats);
// Link assistant's responding model back to the user message it answered.
const parentId = (entry as SessionMessageEntry).parentId;
if (parentId) {
const msg = entry.message as AssistantMessage;
if (msg.model && msg.provider) {
// Emit unconditionally. The aggregator's UPDATE is guarded by
// `model IS NULL` so this is idempotent: a no-op for already
// linked rows, a fix-up for fresh inserts (which start NULL
// because the user row is recorded before its reply lands) and
// for cross-pass orphans whose parent was committed by an
// earlier incremental sync.
userLinks.push({
sessionFile: sessionPath,
entryId: parentId,
model: msg.model,
provider: msg.provider,
});
}
}
}
}
return { stats, userStats, userLinks, newOffset: start + read };
}
/**
* List all session directories (folders).
*/
export async function listSessionFolders(): Promise<string[]> {
try {
const sessionsDir = getSessionsDir();
const entries = await fs.readdir(sessionsDir, { withFileTypes: true });
return entries.filter(e => e.isDirectory()).map(e => path.join(sessionsDir, e.name));
} catch {
return [];
}
}
/**
* List all session files in a folder.
*/
export async function listSessionFiles(folderPath: string): Promise<string[]> {
try {
const entries = await fs.readdir(folderPath, { recursive: true, withFileTypes: true });
return entries.filter(e => e.isFile() && e.name.endsWith(".jsonl")).map(e => path.join(e.parentPath, e.name));
} catch {
return [];
}
}
/**
* List all session files across all folders.
*/
export async function listAllSessionFiles(): Promise<string[]> {
const folders = await listSessionFolders();
const allFiles: string[] = [];
for (const folder of folders) {
const files = await listSessionFiles(folder);
allFiles.push(...files);
}
return allFiles;
}
/**
* Find a specific entry in a session file.
*/
export async function getSessionEntry(sessionPath: string, entryId: string): Promise<SessionEntry | null> {
let bytes: Uint8Array;
try {
bytes = await Bun.file(sessionPath).bytes();
} catch (err) {
if (isEnoent(err)) return null;
throw err;
}
const { entries } = parseSessionEntriesLenient(bytes);
for (const entry of entries) {
if ("id" in entry && entry.id === entryId) {
return entry;
}
}
return null;
}