@oh-my-pi/omp-stats 18.14.0 → 18.14.2

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.
@@ -10,6 +10,10 @@ export declare function getCostDashboardStats(range?: TimeRange, signal?: AbortS
10
10
  export declare function getRecentRequests(limit?: number, signal?: AbortSignal): Promise<MessageStats[]>;
11
11
  export declare function getRecentErrors(range?: TimeRange, limit?: number, signal?: AbortSignal): Promise<MessageStats[]>;
12
12
  export declare function getRequestDetails(id: number, signal?: AbortSignal): Promise<RequestDetails>;
13
+ /** Whether the page may close the dashboard (`omp stats` shown in place of a Tern block). */
14
+ export declare function getClosable(signal?: AbortSignal): Promise<boolean>;
15
+ /** Close the dashboard: its host takes the page away and stops serving it. */
16
+ export declare function closeDashboard(): Promise<void>;
13
17
  /** Ask the server to start a background sync; progress arrives on the `/api/events` stream. */
14
18
  export declare function requestSync(): Promise<LiveStatus>;
15
19
  export declare function getFrustrationDashboardStats(range?: TimeRange, signal?: AbortSignal): Promise<FrustrationDashboardStats>;
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Close, while the host shows the dashboard in place of its terminal
3
+ * (`omp stats` in Tern), where the page is the only way out: the button, Esc,
4
+ * or Ctrl+C with nothing selected, as in the terminal it covers. Renders
5
+ * nothing anywhere else.
6
+ */
7
+ export declare function CloseButton(): import("react").JSX.Element | null;
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Guards for the dashboard's page-wide keys (`Shell`'s shortcuts,
3
+ * `CloseButton`'s Esc and Ctrl+C): they stay out of fields being typed
4
+ * in and out of open dialogs, which own their keys.
5
+ */
6
+ /** Whether `target` takes typing (a field, a select, editable content). */
7
+ export declare function isTypingTarget(target: EventTarget | null): boolean;
8
+ /** Whether a modal dialog or drawer is open. */
9
+ export declare function isDialogOpen(): boolean;
@@ -4,7 +4,7 @@ export { closeDb } from "./db.js";
4
4
  export { refreshRollups } from "./rollup.js";
5
5
  export type { StatsJudge, StatsJudgeProvider } from "./frustration.js";
6
6
  export { getGainDashboardStats } from "./gain-aggregator.js";
7
- export { formatStatsDashboardUrl, type StartServerOptions, startServer } from "./server.js";
7
+ export { formatStatsDashboardUrl, type StartServerOptions, setStatsCloseHandler, startServer } from "./server.js";
8
8
  export type { GainDashboardStats, GainSource, GainSourceTotals, GainTimeSeriesPoint } from "./shared-types.js";
9
9
  export type { AggregatedStats, DashboardStats, FolderStats, MessageStats, ModelPerformancePoint, ModelStats, ModelTimeSeriesPoint, TimeSeriesPoint, ToolDashboardStats, ToolModelStats, ToolTimeSeriesPoint, ToolUsageStats, } from "./types.js";
10
10
  /**
@@ -1,4 +1,11 @@
1
1
  import { type StatsJudgeProvider } from "./frustration.js";
2
+ /**
3
+ * Let the page close the dashboard: while `close` is set, it shows a Close
4
+ * button (and takes Esc and Ctrl+C) that calls it. `omp stats` sets it while
5
+ * Tern shows the page in place of the block it runs in, where the page has no
6
+ * way out of its own; `undefined` takes Close away again.
7
+ */
8
+ export declare function setStatsCloseHandler(close: (() => void) | undefined): void;
2
9
  /**
3
10
  * Handle API requests.
4
11
  */
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/omp-stats",
4
- "version": "18.14.0",
4
+ "version": "18.14.2",
5
5
  "description": "Local observability dashboard for omp AI usage statistics",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": {
@@ -42,9 +42,9 @@
42
42
  "fmt": "oxfmt --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts'"
43
43
  },
44
44
  "dependencies": {
45
- "@oh-my-pi/pi-ai": "18.14.0",
46
- "@oh-my-pi/pi-catalog": "18.14.0",
47
- "@oh-my-pi/pi-utils": "18.14.0",
45
+ "@oh-my-pi/pi-ai": "18.14.2",
46
+ "@oh-my-pi/pi-catalog": "18.14.2",
47
+ "@oh-my-pi/pi-utils": "18.14.2",
48
48
  "lucide-react": "^1.24.0",
49
49
  "react": "19.2.7",
50
50
  "react-dom": "19.2.7"
package/src/client/api.ts CHANGED
@@ -121,6 +121,19 @@ export async function getRequestDetails(id: number, signal?: AbortSignal): Promi
121
121
  return fetchJson<RequestDetails>(`${API_BASE}/request/${id}`, { signal });
122
122
  }
123
123
 
124
+ /** Whether the page may close the dashboard (`omp stats` shown in place of a Tern block). */
125
+ export async function getClosable(signal?: AbortSignal): Promise<boolean> {
126
+ const { closable } = await fetchJson<{ closable: boolean }>(`${API_BASE}/close`, { signal });
127
+ return closable;
128
+ }
129
+
130
+ /** Close the dashboard: its host takes the page away and stops serving it. */
131
+ export async function closeDashboard(): Promise<void> {
132
+ const endpoint = `${API_BASE}/close`;
133
+ const res = await fetch(endpoint, { method: "POST", headers: ACTION_HEADERS });
134
+ if (!res.ok) throw new ApiError(res.status, endpoint, await readErrorMessage(res, endpoint));
135
+ }
136
+
124
137
  /** Ask the server to start a background sync; progress arrives on the `/api/events` stream. */
125
138
  export async function requestSync(): Promise<LiveStatus> {
126
139
  return fetchJson<LiveStatus>(`${API_BASE}/sync`, { method: "POST" });
@@ -0,0 +1,56 @@
1
+ import { X } from "lucide-react";
2
+ import { useEffect, useState } from "react";
3
+ import { closeDashboard, getClosable } from "../api";
4
+ import { isDialogOpen, isTypingTarget } from "./keys";
5
+
6
+ /**
7
+ * Close, while the host shows the dashboard in place of its terminal
8
+ * (`omp stats` in Tern), where the page is the only way out: the button, Esc,
9
+ * or Ctrl+C with nothing selected, as in the terminal it covers. Renders
10
+ * nothing anywhere else.
11
+ */
12
+ export function CloseButton() {
13
+ const closable = useClosable();
14
+
15
+ useEffect(() => {
16
+ if (!closable) return;
17
+ const onKey = (e: KeyboardEvent) => {
18
+ if (!isCloseKey(e)) return;
19
+ e.preventDefault();
20
+ close();
21
+ };
22
+ window.addEventListener("keydown", onKey);
23
+ return () => window.removeEventListener("keydown", onKey);
24
+ }, [closable]);
25
+
26
+ if (!closable) return null;
27
+ return (
28
+ <button type="button" className="btn" data-variant="ghost" onClick={close} title="Close the dashboard (Esc)">
29
+ <X size={15} />
30
+ Close
31
+ <span className="kbd">Esc</span>
32
+ </button>
33
+ );
34
+ }
35
+
36
+ function close(): void {
37
+ closeDashboard().catch(error => console.error("Closing the dashboard failed", error));
38
+ }
39
+
40
+ function useClosable(): boolean {
41
+ const [closable, setClosable] = useState(false);
42
+ useEffect(() => {
43
+ const abort = new AbortController();
44
+ getClosable(abort.signal).then(setClosable, () => {});
45
+ return () => abort.abort();
46
+ }, []);
47
+ return closable;
48
+ }
49
+
50
+ /** Esc, or Ctrl+C with no text selected (with a selection it copies), outside fields and dialogs. */
51
+ function isCloseKey(e: KeyboardEvent): boolean {
52
+ if (e.defaultPrevented || e.altKey || e.metaKey || e.shiftKey) return false;
53
+ if (isTypingTarget(e.target) || isDialogOpen()) return false;
54
+ if (e.key === "Escape") return !e.ctrlKey;
55
+ return e.ctrlKey && e.key.toLowerCase() === "c" && !window.getSelection()?.toString();
56
+ }
@@ -4,6 +4,8 @@ import { rangeMeta, TIME_RANGES } from "../data/range";
4
4
  import { useLiveStatus } from "../data/live";
5
5
  import type { TimeRange } from "../types";
6
6
  import { Segmented } from "../ui";
7
+ import { CloseButton } from "./CloseButton";
8
+ import { isDialogOpen, isTypingTarget } from "./keys";
7
9
  import { LiveChip } from "./LiveChip";
8
10
  import { type DashboardSection, NAV } from "./nav";
9
11
  import { ThemeToggle } from "./ThemeToggle";
@@ -77,6 +79,7 @@ export function Shell({ section, onSectionChange, range, onRangeChange, children
77
79
  </span>
78
80
  <Segmented options={RANGE_OPTIONS} value={range} onChange={onRangeChange} aria-label="Time range" />
79
81
  <ThemeToggle />
82
+ <CloseButton />
80
83
  </div>
81
84
  {progress !== undefined && (
82
85
  <div className="topbar-progress">
@@ -143,17 +146,7 @@ function useShortcuts(onSection: (section: DashboardSection) => void, onRange: (
143
146
  let pendingG = 0;
144
147
  const onKey = (e: KeyboardEvent) => {
145
148
  if (e.metaKey || e.ctrlKey || e.altKey || e.defaultPrevented) return;
146
- const target = e.target;
147
- if (
148
- target instanceof HTMLElement &&
149
- (target.isContentEditable ||
150
- target.tagName === "INPUT" ||
151
- target.tagName === "TEXTAREA" ||
152
- target.tagName === "SELECT")
153
- ) {
154
- return;
155
- }
156
- if (document.querySelector('[aria-modal="true"]')) return;
149
+ if (isTypingTarget(e.target) || isDialogOpen()) return;
157
150
  const key = e.key.toLowerCase();
158
151
  if (pendingG && Date.now() - pendingG < 1200) {
159
152
  pendingG = 0;
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Guards for the dashboard's page-wide keys (`Shell`'s shortcuts,
3
+ * `CloseButton`'s Esc and Ctrl+C): they stay out of fields being typed
4
+ * in and out of open dialogs, which own their keys.
5
+ */
6
+
7
+ /** Whether `target` takes typing (a field, a select, editable content). */
8
+ export function isTypingTarget(target: EventTarget | null): boolean {
9
+ return (
10
+ target instanceof HTMLElement &&
11
+ (target.isContentEditable ||
12
+ target.tagName === "INPUT" ||
13
+ target.tagName === "TEXTAREA" ||
14
+ target.tagName === "SELECT")
15
+ );
16
+ }
17
+
18
+ /** Whether a modal dialog or drawer is open. */
19
+ export function isDialogOpen(): boolean {
20
+ return document.querySelector('[aria-modal="true"]') !== null;
21
+ }
@@ -0,0 +1,8 @@
1
+ import "react";
2
+
3
+ declare module "react" {
4
+ /** Custom properties (`--arm`, `--i` …) in `style` objects, which React passes through to the element. */
5
+ interface CSSProperties {
6
+ [property: `--${string}`]: string | number | undefined;
7
+ }
8
+ }
@@ -103,7 +103,7 @@ export const TranscriptList = memo(function TranscriptList({
103
103
  const chipText = row.span?.kind ?? row.marker?.kind ?? "";
104
104
  const rowContent = (
105
105
  <>
106
- <span className="traces-kind" style={{ "--traces-kind-color": color } as React.CSSProperties}>
106
+ <span className="traces-kind" style={{ "--traces-kind-color": color }}>
107
107
  {chipText}
108
108
  </span>
109
109
  {row.depth > 0 && (
@@ -1,4 +1,4 @@
1
- import type { CSSProperties, ReactNode } from "react";
1
+ import type { ReactNode } from "react";
2
2
 
3
3
  export interface CardProps {
4
4
  title?: ReactNode;
@@ -22,7 +22,7 @@ export function Card({ title, description, actions, flush, footer, className, in
22
22
  return (
23
23
  <section
24
24
  className={className ? `card rise ${className}` : "card rise"}
25
- style={index !== undefined ? ({ "--i": index } as CSSProperties) : undefined}
25
+ style={index !== undefined ? { "--i": index } : undefined}
26
26
  >
27
27
  {hasHeader && (
28
28
  <header className="card-header">
@@ -1,5 +1,5 @@
1
1
  import { X } from "lucide-react";
2
- import { type CSSProperties, type ReactNode, useEffect, useRef } from "react";
2
+ import { type ReactNode, useEffect, useRef } from "react";
3
3
  import { createPortal } from "react-dom";
4
4
 
5
5
  export interface DrawerProps {
@@ -50,7 +50,7 @@ export function Drawer({ open, onClose, title, subtitle, actions, width, childre
50
50
  role="dialog"
51
51
  aria-modal="true"
52
52
  tabIndex={-1}
53
- style={width !== undefined ? ({ "--drawer-w": `${width}px` } as CSSProperties) : undefined}
53
+ style={width !== undefined ? { "--drawer-w": `${width}px` } : undefined}
54
54
  >
55
55
  <header className="drawer-header">
56
56
  <div className="drawer-titles">
@@ -1,4 +1,4 @@
1
- import { type CSSProperties, type ReactNode, useEffect, useRef, useState } from "react";
1
+ import { type ReactNode, useEffect, useRef, useState } from "react";
2
2
  import { Sparkline } from "../charts/Sparkline";
3
3
 
4
4
  export interface StatDelta {
@@ -58,10 +58,7 @@ export interface StatGridProps {
58
58
  /** Tiles sharing one card, separated by hairlines. */
59
59
  export function StatGrid({ min, children }: StatGridProps) {
60
60
  return (
61
- <div
62
- className="stat-grid rise"
63
- style={min !== undefined ? ({ "--stat-min": `${min}px` } as CSSProperties) : undefined}
64
- >
61
+ <div className="stat-grid rise" style={min !== undefined ? { "--stat-min": `${min}px` } : undefined}>
65
62
  {children}
66
63
  </div>
67
64
  );
package/src/db.ts CHANGED
@@ -100,7 +100,8 @@ const FORK_DEDUPE_KEY = "fork_dedupe_v1";
100
100
  // v2: tool-name sanitization at ingest (see `sanitizeToolName` in parser.ts)
101
101
  // collapses provider-side garbage names; a full re-parse replaces the polluted
102
102
  // rows already stored in existing databases.
103
- const TOOL_CALLS_BACKFILL_KEY = "tool_calls_v2";
103
+ // v3: reclassify synthetic non-executions so historical tool error rates are corrected.
104
+ const TOOL_CALLS_BACKFILL_KEY = "tool_calls_v3";
104
105
  // Older ingests dropped `Usage.orchestration` (never a stored column) when
105
106
  // pricing, so subscription models billed on orchestration tokens — multi-agent
106
107
  // Grok most notably — were priced from conversation buckets alone and could not
package/src/index.ts CHANGED
@@ -22,7 +22,7 @@ export { closeDb } from "./db";
22
22
  export { refreshRollups } from "./rollup";
23
23
  export type { StatsJudge, StatsJudgeProvider } from "./frustration";
24
24
  export { getGainDashboardStats } from "./gain-aggregator";
25
- export { formatStatsDashboardUrl, type StartServerOptions, startServer } from "./server";
25
+ export { formatStatsDashboardUrl, type StartServerOptions, setStatsCloseHandler, startServer } from "./server";
26
26
  export type { GainDashboardStats, GainSource, GainSourceTotals, GainTimeSeriesPoint } from "./shared-types";
27
27
  export type {
28
28
  AggregatedStats,
package/src/parser.ts CHANGED
@@ -416,6 +416,13 @@ function sanitizeToolName(name: string): string | null {
416
416
  return candidate.length > 0 ? candidate : null;
417
417
  }
418
418
 
419
+ /** A call the agent loop skipped carries a synthetic `executed: false` result: it never ran, so it is no tool error. */
420
+ function isToolExecutionError(message: { isError?: unknown; details?: unknown }): boolean {
421
+ if (message.isError !== true) return false;
422
+ const details = message.details as { __synthetic?: unknown; executed?: unknown } | null | undefined;
423
+ return !(details?.__synthetic === true && details.executed === false);
424
+ }
425
+
419
426
  /**
420
427
  * Build the result linkage for a `toolResult` entry: text characters fed back
421
428
  * into context plus the error flag, keyed to the originating call.
@@ -435,7 +442,7 @@ function extractToolResultLink(sessionFile: string, entry: SessionMessageEntry):
435
442
  sessionFile,
436
443
  toolCallId: msg.toolCallId,
437
444
  resultChars,
438
- isError: msg.isError === true,
445
+ isError: isToolExecutionError(msg),
439
446
  };
440
447
  }
441
448
 
package/src/server.ts CHANGED
@@ -163,6 +163,19 @@ const ensureClientBuild = async () => {
163
163
  */
164
164
  const STATS_ACTION_HEADER = "X-Omp-Stats-Action";
165
165
 
166
+ /** What the page's Close calls; unset, the page offers none. */
167
+ let closeHandler: (() => void) | undefined;
168
+
169
+ /**
170
+ * Let the page close the dashboard: while `close` is set, it shows a Close
171
+ * button (and takes Esc and Ctrl+C) that calls it. `omp stats` sets it while
172
+ * Tern shows the page in place of the block it runs in, where the page has no
173
+ * way out of its own; `undefined` takes Close away again.
174
+ */
175
+ export function setStatsCloseHandler(close: (() => void) | undefined): void {
176
+ closeHandler = close;
177
+ }
178
+
166
179
  /**
167
180
  * Handle API requests.
168
181
  */
@@ -268,6 +281,17 @@ export async function handleApi(req: Request): Promise<Response> {
268
281
  return Response.json(details);
269
282
  }
270
283
 
284
+ if (path === "/api/close") {
285
+ if (req.method === "GET") return Response.json({ closable: closeHandler !== undefined });
286
+ if (req.method !== "POST") return Response.json({ error: "GET or POST required" }, { status: 405 });
287
+ if (req.headers.get(STATS_ACTION_HEADER) !== "1") {
288
+ return Response.json({ error: `${STATS_ACTION_HEADER}: 1 header required` }, { status: 403 });
289
+ }
290
+ if (!closeHandler) return Response.json({ error: "this dashboard is not closed from its page" }, { status: 404 });
291
+ closeHandler();
292
+ return new Response(null, { status: 202 });
293
+ }
294
+
271
295
  if (path === "/api/sync") {
272
296
  if (req.method !== "POST") return Response.json({ error: "POST required" }, { status: 405 });
273
297
  statsLive().requestSync();