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:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user