feat(stats): added frustration metrics to stats parser and charting

- Added new frustration and revised behavior metrics across stats types, parser mappings, and UI charts.
- Reworked session sync to fan out parsing across a capped worker pool with SyncOptions progress callbacks.
- Added worker-based parse messaging and throttled TTY progress rendering in the stats sync command.
- Updated behavior scoring to strip structured content, ignore noisy prompts, and fix profanity boundary regressions.
- Expanded user message schema and migrations, then repaired assistant model/provider links during sync backfill.
- Updated worker-import guidance in AGENTS.md and recorded sync-progress/metrics updates in package changelogs.
This commit is contained in:
can1357
2026-05-12 07:48:46 +02:00
parent a7209c2b4f
commit f56cbbf4c6
17 changed files with 988 additions and 211 deletions
+2
View File
@@ -1,12 +1,14 @@
# Changelog
## [Unreleased]
### Breaking Changes
- Changed the `timeoutMs` execution option to no longer be enforced during worker-based JS runs, so callers must rely on external cancellation signals for time limits
### Added
- Added a live single-line sync progress display to the stats command showing current/total sessions while syncing
- Added automatic inline JS evaluation fallback when worker creation failed so script execution still works in environments without worker support
### Changed
+56 -2
View File
@@ -8,6 +8,58 @@ import { APP_NAME, formatDuration, formatNumber, formatPercent } from "@oh-my-pi
import chalk from "chalk";
import { openPath } from "../utils/open";
/**
* Single-line TTY progress bar. On a non-TTY stream we just stay quiet -
* the final "Synced ..." summary still prints either way.
*/
function createSyncProgressReporter(): {
onProgress: (event: { current: number; total: number; sessionFile: string }) => void;
finish: () => void;
} {
const stream = process.stderr;
const isTty = stream.isTTY === true;
let lastWidth = 0;
let lastRender = 0;
return {
onProgress(event) {
if (!isTty) return;
const now = Date.now();
// Throttle to ~30 fps and always force a render for the last file.
if (event.current < event.total && now - lastRender < 33) return;
lastRender = now;
const label = chalk.dim(shortenSessionFile(event.sessionFile));
const pct = ((event.current / event.total) * 100).toFixed(0).padStart(3, " ");
const counter = chalk.cyan(`[${event.current}/${event.total}]`);
const line = `${counter} ${pct}% ${label}`;
const columns = stream.columns ?? 120;
const trimmed = truncateToColumns(line, columns - 1);
stream.write(`\r${trimmed.padEnd(lastWidth)}`);
lastWidth = trimmed.length;
},
finish() {
if (!isTty || lastWidth === 0) return;
stream.write(`\r${" ".repeat(lastWidth)}\r`);
lastWidth = 0;
},
};
}
function shortenSessionFile(p: string): string {
const marker = "/sessions/";
const idx = p.indexOf(marker);
return idx >= 0 ? p.slice(idx + marker.length) : p;
}
function truncateToColumns(s: string, max: number): string {
if (max <= 0) return "";
const width = Bun.stringWidth(s, { countAnsiEscapeCodes: false });
if (width <= max) return s;
// Cheap right-trim with an ellipsis - we don't need ANSI-aware slicing
// because the colored prefix is short and the truncated tail is the
// dim filename, where dropping bytes is fine.
return `${s.slice(0, Math.max(0, max - 1))}\u2026`;
}
// =============================================================================
// Types
// =============================================================================
@@ -74,8 +126,10 @@ export async function runStatsCommand(cmd: StatsCommandArgs): Promise<void> {
);
// Sync session files first
console.log("Syncing session files...");
const { processed, files } = await syncAllSessions();
const progress = createSyncProgressReporter();
process.stderr.write("Syncing session files...\n");
const { processed, files } = await syncAllSessions({ onProgress: progress.onProgress });
progress.finish();
const total = await getTotalMessageCount();
console.log(`Synced ${processed} new entries from ${files} files (${total} total)\n`);
@@ -503,6 +503,7 @@ function indirectEval(source: string, filename?: string): unknown {
const withPragma = filename ? `${source}\n//# sourceURL=${filename}` : source;
// Read `eval` via a property access so the call site is *indirect* (global scope),
// not direct (this function's lexical scope). The cast erases the lib.dom return type.
// biome-ignore lint/security/noGlobalEval: indirect eval is intentional for sandbox execution
const geval = globalThis.eval as (src: string) => unknown;
return geval(withPragma);
}