@oh-my-pi/omp-stats 18.16.0 → 18.17.0

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.
Files changed (77) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/dist/client/index.css +1 -1
  3. package/dist/client/index.js +102 -99
  4. package/dist/types/client/api.d.ts +4 -1
  5. package/dist/types/client/app/nav.d.ts +1 -1
  6. package/dist/types/client/charts/Chart.d.ts +16 -8
  7. package/dist/types/client/charts/DotWhisker.d.ts +50 -0
  8. package/dist/types/client/charts/Sparkline.d.ts +1 -1
  9. package/dist/types/client/charts/Waterfall.d.ts +26 -0
  10. package/dist/types/client/charts/index.d.ts +2 -0
  11. package/dist/types/client/charts/types.d.ts +8 -2
  12. package/dist/types/client/routes/SpotlightRoute.d.ts +7 -0
  13. package/dist/types/client/routes/index.d.ts +1 -0
  14. package/dist/types/client/spotlight/Compounding.d.ts +8 -0
  15. package/dist/types/client/spotlight/Coverage.d.ts +6 -0
  16. package/dist/types/client/spotlight/Economics.d.ts +10 -0
  17. package/dist/types/client/spotlight/FetchBack.d.ts +9 -0
  18. package/dist/types/client/spotlight/Hero.d.ts +12 -0
  19. package/dist/types/client/spotlight/Overhead.d.ts +6 -0
  20. package/dist/types/client/spotlight/Ranking.d.ts +6 -0
  21. package/dist/types/client/spotlight/ResultsFeed.d.ts +6 -0
  22. package/dist/types/client/spotlight/Savings.d.ts +10 -0
  23. package/dist/types/client/spotlight/ShareTrend.d.ts +8 -0
  24. package/dist/types/client/spotlight/Steps.d.ts +6 -0
  25. package/dist/types/client/spotlight/TimeCost.d.ts +6 -0
  26. package/dist/types/client/spotlight/chips.d.ts +16 -0
  27. package/dist/types/client/spotlight/meta.d.ts +64 -0
  28. package/dist/types/db.d.ts +26 -0
  29. package/dist/types/index.d.ts +2 -1
  30. package/dist/types/parser.d.ts +4 -0
  31. package/dist/types/rollup.d.ts +22 -1
  32. package/dist/types/shared-types.d.ts +419 -0
  33. package/dist/types/spotlight-aggregator.d.ts +189 -0
  34. package/dist/types/spotlight-bootstrap.d.ts +32 -0
  35. package/dist/types/spotlight-ingest.d.ts +258 -0
  36. package/dist/types/spotlight-measures.d.ts +51 -0
  37. package/package.json +5 -5
  38. package/src/aggregator.ts +3 -1
  39. package/src/client/App.tsx +3 -0
  40. package/src/client/api.ts +21 -0
  41. package/src/client/app/nav.ts +4 -1
  42. package/src/client/charts/Chart.tsx +105 -22
  43. package/src/client/charts/DotWhisker.tsx +149 -0
  44. package/src/client/charts/Sparkline.tsx +3 -2
  45. package/src/client/charts/Waterfall.tsx +86 -0
  46. package/src/client/charts/index.ts +2 -0
  47. package/src/client/charts/types.ts +9 -2
  48. package/src/client/react-css.d.ts +1 -1
  49. package/src/client/routes/SpotlightRoute.tsx +138 -0
  50. package/src/client/routes/index.ts +1 -0
  51. package/src/client/spotlight/Compounding.tsx +222 -0
  52. package/src/client/spotlight/Coverage.tsx +96 -0
  53. package/src/client/spotlight/Economics.tsx +167 -0
  54. package/src/client/spotlight/FetchBack.tsx +222 -0
  55. package/src/client/spotlight/Hero.tsx +177 -0
  56. package/src/client/spotlight/Overhead.tsx +135 -0
  57. package/src/client/spotlight/Ranking.tsx +248 -0
  58. package/src/client/spotlight/ResultsFeed.tsx +354 -0
  59. package/src/client/spotlight/Savings.tsx +355 -0
  60. package/src/client/spotlight/ShareTrend.tsx +103 -0
  61. package/src/client/spotlight/Steps.tsx +225 -0
  62. package/src/client/spotlight/TimeCost.tsx +64 -0
  63. package/src/client/spotlight/chips.tsx +63 -0
  64. package/src/client/spotlight/meta.ts +206 -0
  65. package/src/client/spotlight/spotlight.css +1387 -0
  66. package/src/client/styles.css +289 -1
  67. package/src/db.ts +480 -2
  68. package/src/index.ts +10 -1
  69. package/src/live.ts +21 -9
  70. package/src/parser.ts +44 -8
  71. package/src/rollup.ts +203 -10
  72. package/src/server.ts +42 -0
  73. package/src/shared-types.ts +453 -0
  74. package/src/spotlight-aggregator.ts +1738 -0
  75. package/src/spotlight-bootstrap.ts +110 -0
  76. package/src/spotlight-ingest.ts +981 -0
  77. package/src/spotlight-measures.ts +667 -0
@@ -0,0 +1,149 @@
1
+ import type { ReactNode } from "react";
2
+ import { niceScale } from "./Chart";
3
+
4
+ /** One estimate on a row: a point and its interval. */
5
+ export interface DotWhiskerMark {
6
+ key: string;
7
+ value: number | null;
8
+ lo: number | null;
9
+ hi: number | null;
10
+ color: string;
11
+ /** `dot` (default) filled; `ring` hollow (a counterfactual); `diamond` a reference level; `square` a variant. */
12
+ shape?: "dot" | "ring" | "diamond" | "square";
13
+ /** Vertical offset from the row's center in px, to keep several marks of one row apart. */
14
+ offset?: number;
15
+ /** Hover text naming the mark. */
16
+ title?: string;
17
+ }
18
+
19
+ export interface DotWhiskerRow {
20
+ key: string;
21
+ label: ReactNode;
22
+ marks: readonly DotWhiskerMark[];
23
+ /** Right-column figure; defaults to the first mark's value. */
24
+ figure?: ReactNode;
25
+ /** Small print under the figure; defaults to the first mark's interval. */
26
+ detail?: ReactNode;
27
+ }
28
+
29
+ export interface DotWhiskerGuide {
30
+ key: string;
31
+ value: number;
32
+ color: string;
33
+ }
34
+
35
+ /** A shaded span through every row (e.g. a reference's interval). */
36
+ export interface DotWhiskerBand {
37
+ lo: number;
38
+ hi: number;
39
+ color: string;
40
+ }
41
+
42
+ export interface DotWhiskerProps {
43
+ rows: readonly DotWhiskerRow[];
44
+ /** Vertical guides through every row (e.g. chance levels). */
45
+ guides?: readonly DotWhiskerGuide[];
46
+ band?: DotWhiskerBand;
47
+ format: (value: number) => string;
48
+ /** Axis tick formatter; defaults to `format`. */
49
+ formatTick?: (value: number) => string;
50
+ }
51
+
52
+ /**
53
+ * Point estimates with their intervals on one shared axis that always spans
54
+ * zero (and extends below it for signed values), one row per group. Layout is
55
+ * percentage-based, so it never measures the DOM.
56
+ */
57
+ export function DotWhisker({ rows, guides, band, format, formatTick }: DotWhiskerProps) {
58
+ let max = 0;
59
+ let min = 0;
60
+ const extend = (v: number | null) => {
61
+ if (v === null) return;
62
+ max = Math.max(max, v);
63
+ min = Math.min(min, v);
64
+ };
65
+ for (const row of rows) {
66
+ for (const mark of row.marks) {
67
+ extend(mark.value);
68
+ extend(mark.lo);
69
+ extend(mark.hi);
70
+ }
71
+ }
72
+ for (const guide of guides ?? []) extend(guide.value);
73
+ if (band) {
74
+ extend(band.lo);
75
+ extend(band.hi);
76
+ }
77
+ const scale = niceScale(max, min);
78
+ const span = scale.max - scale.min;
79
+ const at = (v: number) => ((Math.min(scale.max, Math.max(scale.min, v)) - scale.min) / span) * 100;
80
+ const pct = (v: number) => `${at(v)}%`;
81
+ const underlay = (
82
+ <>
83
+ {band && (
84
+ <span
85
+ className="dw-band"
86
+ style={{ left: pct(band.lo), width: `${at(band.hi) - at(band.lo)}%`, "--band": band.color }}
87
+ />
88
+ )}
89
+ {guides?.map(guide => (
90
+ <span key={guide.key} className="dw-guide" style={{ left: pct(guide.value), "--guide": guide.color }} />
91
+ ))}
92
+ </>
93
+ );
94
+
95
+ return (
96
+ <div className="dw">
97
+ {rows.map(row => {
98
+ const first = row.marks[0];
99
+ const interval =
100
+ first !== undefined && first.lo !== null && first.hi !== null
101
+ ? `${format(first.lo)}–${format(first.hi)}`
102
+ : "";
103
+ return (
104
+ <div key={row.key} className="dw-row">
105
+ <div className="dw-label">{row.label}</div>
106
+ <div className="dw-track">
107
+ {underlay}
108
+ {row.marks.map(mark => (
109
+ <span
110
+ key={mark.key}
111
+ className="dw-mark"
112
+ data-shape={mark.shape ?? "dot"}
113
+ style={{ "--dot": mark.color, "--dy": `${mark.offset ?? 0}px` }}
114
+ >
115
+ {mark.lo !== null && mark.hi !== null && (
116
+ <span
117
+ className="dw-whisker"
118
+ style={{ left: pct(mark.lo), width: `${at(mark.hi) - at(mark.lo)}%` }}
119
+ />
120
+ )}
121
+ {mark.value !== null && (
122
+ <span className="dw-dot" style={{ left: pct(mark.value) }} title={mark.title} />
123
+ )}
124
+ </span>
125
+ ))}
126
+ </div>
127
+ <div className="dw-value">
128
+ <span className="num">
129
+ {row.figure ?? (first?.value === null || first === undefined ? "–" : format(first.value))}
130
+ </span>
131
+ <span className="dw-detail">{row.detail ?? interval}</span>
132
+ </div>
133
+ </div>
134
+ );
135
+ })}
136
+ <div className="dw-row dw-axis" aria-hidden="true">
137
+ <div />
138
+ <div className="dw-track">
139
+ {scale.ticks.map(t => (
140
+ <span key={t} className="dw-tick" style={{ left: pct(t) }}>
141
+ {(formatTick ?? format)(t)}
142
+ </span>
143
+ ))}
144
+ </div>
145
+ <div />
146
+ </div>
147
+ </div>
148
+ );
149
+ }
@@ -10,18 +10,19 @@ export interface SparklineProps {
10
10
  area?: boolean;
11
11
  }
12
12
 
13
- /** Axis-free trend line for stat tiles and table cells. */
13
+ /** Axis-free trend line for stat tiles and table cells; negative values dip below the zero line. */
14
14
  export function Sparkline({ values, color = "var(--chart-primary)", height = 28, width, area = true }: SparklineProps) {
15
15
  const [ref, measured] = useWidth<HTMLDivElement>();
16
16
  const w = width ?? measured;
17
17
  const max = Math.max(0, ...values);
18
+ const min = Math.min(0, ...values);
18
19
  const n = values.length;
19
20
  let line = "";
20
21
  if (w > 0 && n > 0) {
21
22
  const step = n > 1 ? w / (n - 1) : 0;
22
23
  for (let i = 0; i < n; i++) {
23
24
  const x = n > 1 ? i * step : w / 2;
24
- const y = max > 0 ? height - 2 - (values[i] / max) * (height - 4) : height - 2;
25
+ const y = max > min ? height - 2 - ((values[i] - min) / (max - min)) * (height - 4) : height - 2;
25
26
  line += `${i === 0 ? "M" : "L"}${x.toFixed(1)},${y.toFixed(1)}`;
26
27
  }
27
28
  }
@@ -0,0 +1,86 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ export interface WaterfallStep {
4
+ key: string;
5
+ label: ReactNode;
6
+ /** `total` bars start at zero and reset the running total; `delta` bars float from it. */
7
+ kind: "total" | "delta";
8
+ /** A total's level, or a delta's signed change. */
9
+ value: number;
10
+ /** Any CSS background (solid color or gradient). */
11
+ color: string;
12
+ /** Dim figure after the value (e.g. share of the first total). */
13
+ note?: ReactNode;
14
+ title?: string;
15
+ }
16
+
17
+ export interface WaterfallProps {
18
+ steps: readonly WaterfallStep[];
19
+ /** Formats magnitudes; deltas get their sign from the component. */
20
+ format: (value: number) => string;
21
+ }
22
+
23
+ interface PlacedStep extends WaterfallStep {
24
+ from: number;
25
+ to: number;
26
+ /** Running total before this step (the connector's position). */
27
+ before: number;
28
+ }
29
+
30
+ /**
31
+ * Horizontal bridge chart: a total, the changes that eat into it, and the
32
+ * total left at the end. Rows read top to bottom; dashed connectors carry the
33
+ * running total from one bar to the next. Pure layout (percentages), so it
34
+ * never measures the DOM and reflows with its column.
35
+ */
36
+ export function Waterfall({ steps, format }: WaterfallProps) {
37
+ let running = 0;
38
+ const placed: PlacedStep[] = steps.map(step => {
39
+ const before = running;
40
+ if (step.kind === "total") {
41
+ running = step.value;
42
+ return { ...step, from: 0, to: step.value, before };
43
+ }
44
+ running += step.value;
45
+ return { ...step, from: before, to: running, before };
46
+ });
47
+ let lo = 0;
48
+ let hi = 0;
49
+ for (const step of placed) {
50
+ lo = Math.min(lo, step.from, step.to);
51
+ hi = Math.max(hi, step.from, step.to);
52
+ }
53
+ const span = hi - lo || 1;
54
+ const pct = (v: number) => ((v - lo) / span) * 100;
55
+
56
+ return (
57
+ <ol className="waterfall">
58
+ {placed.map((step, i) => {
59
+ const left = pct(Math.min(step.from, step.to));
60
+ const width = Math.abs(pct(step.to) - pct(step.from));
61
+ const sign = step.kind === "delta" ? (step.value < 0 ? "−" : "+") : step.value < 0 ? "−" : "";
62
+ return (
63
+ <li key={step.key} className="waterfall-row" data-kind={step.kind} title={step.title}>
64
+ <span className="waterfall-label">{step.label}</span>
65
+ <span className="waterfall-track">
66
+ {lo < 0 && <span className="waterfall-zero" style={{ left: `${pct(0)}%` }} />}
67
+ {i > 0 && <span className="waterfall-link" style={{ left: `${pct(step.before)}%` }} />}
68
+ <span
69
+ className="waterfall-bar"
70
+ data-empty={step.value === 0}
71
+ style={{ left: `${left}%`, width: `${width}%`, "--bar": step.color }}
72
+ />
73
+ </span>
74
+ <span className="waterfall-value">
75
+ <span className="num">
76
+ {sign}
77
+ {format(Math.abs(step.value))}
78
+ </span>
79
+ {step.note !== undefined && <span className="waterfall-note">{step.note}</span>}
80
+ </span>
81
+ </li>
82
+ );
83
+ })}
84
+ </ol>
85
+ );
86
+ }
@@ -1,7 +1,9 @@
1
1
  export * from "./BarList";
2
2
  export * from "./Chart";
3
+ export * from "./DotWhisker";
3
4
  export * from "./Legend";
4
5
  export * from "./ShareBar";
5
6
  export * from "./Sparkline";
6
7
  export * from "./TimeChart";
7
8
  export * from "./types";
9
+ export * from "./Waterfall";
@@ -11,13 +11,20 @@ export interface ChartSeries {
11
11
  axis?: "left" | "right";
12
12
  /** Diagonal hatching instead of a solid fill (bars/areas). */
13
13
  pattern?: "hatch";
14
- /** Dashed stroke (lines). */
14
+ /** Dashed stroke (lines, area outlines). */
15
15
  dashed?: boolean;
16
16
  /** Leave this series out of the tooltip (e.g. a trend line that repeats a bar). */
17
17
  tooltip?: false;
18
18
  }
19
19
 
20
- export type ChartKind = "bars" | "area" | "line";
20
+ /** `dots` marks each value with a point and no connecting segment (raw samples under a trend line). */
21
+ export type ChartKind = "bars" | "area" | "line" | "dots";
22
+
23
+ /** Vertical guide at an x slot (e.g. an experiment start or a compaction). */
24
+ export interface SlotMarker {
25
+ index: number;
26
+ label?: string;
27
+ }
21
28
 
22
29
  /** Horizontal guide drawn across the plot (e.g. a 100% capacity line). */
23
30
  export interface ReferenceLine {
@@ -1,7 +1,7 @@
1
1
  import "react";
2
2
 
3
3
  declare module "react" {
4
- /** Custom properties (`--arm`, `--i` …) in `style` objects, which React passes through to the element. */
4
+ /** Custom properties (`--kind`, `--i` …) in `style` objects, which React passes through to the element. */
5
5
  interface CSSProperties {
6
6
  [property: `--${string}`]: string | number | undefined;
7
7
  }
@@ -0,0 +1,138 @@
1
+ import { ScanSearch } from "lucide-react";
2
+ import { useState } from "react";
3
+ import { getSpotlightDashboardStats } from "../api";
4
+ import { useQuery } from "../data/query";
5
+ import { rangeMeta } from "../data/range";
6
+ import { Compounding } from "../spotlight/Compounding";
7
+ import { Coverage } from "../spotlight/Coverage";
8
+ import { Economics } from "../spotlight/Economics";
9
+ import { FetchBack, FetchBackGaps } from "../spotlight/FetchBack";
10
+ import { SpotlightHero } from "../spotlight/Hero";
11
+ import { Overhead } from "../spotlight/Overhead";
12
+ import { Ranking } from "../spotlight/Ranking";
13
+ import { ResultsFeed } from "../spotlight/ResultsFeed";
14
+ import { SavingsOverTime, WhereSavingsWent } from "../spotlight/Savings";
15
+ import { ShareTrend } from "../spotlight/ShareTrend";
16
+ import { Steps } from "../spotlight/Steps";
17
+ import { TimeCost } from "../spotlight/TimeCost";
18
+ import type { SpotlightAgentScope, TimeRange } from "../types";
19
+ import { Card, ChartSkeleton, PageHeader, QueryView, Segmented } from "../ui";
20
+ import "../spotlight/spotlight.css";
21
+
22
+ export interface SpotlightRouteProps {
23
+ active: boolean;
24
+ range: TimeRange;
25
+ }
26
+
27
+ const AGENT_OPTIONS = [
28
+ { value: "all", label: "All", title: "Every experiment session, subagents and advisors included" },
29
+ { value: "main", label: "Main", title: "Sessions you drove: the main agent only" },
30
+ { value: "subagent", label: "Subagents", title: "Sessions the task tool spawned" },
31
+ ] as const;
32
+
33
+ /** Prose for the scope in empty states ("main-agent "). */
34
+ const AGENT_SCOPE_PROSE: Record<SpotlightAgentScope, string> = { all: "", main: "main-agent ", subagent: "subagent " };
35
+
36
+ /** The page reads as four questions, in order; each heading opens the cards that answer one. */
37
+ function SectionHeading({ id, title, children }: { id: string; title: string; children: string }) {
38
+ return (
39
+ <header className="spot-section rise" id={id}>
40
+ <h2 className="spot-section-title">{title}</h2>
41
+ <p className="spot-section-copy">{children}</p>
42
+ </header>
43
+ );
44
+ }
45
+
46
+ export function SpotlightRoute({ active, range }: SpotlightRouteProps) {
47
+ // Every experiment session shapes, subagents included; scoping keeps their very different workloads apart.
48
+ const [agent, setAgent] = useState<SpotlightAgentScope>("all");
49
+ const stats = useQuery(
50
+ ["spotlight", range, agent],
51
+ ({ signal }) => getSpotlightDashboardStats(range, agent, signal),
52
+ { enabled: active },
53
+ );
54
+ const meta = rangeMeta(range);
55
+ const experimentStart = stats.data?.experimentStart ?? null;
56
+
57
+ return (
58
+ <div className="page spot-page">
59
+ <PageHeader
60
+ title="Spotlight"
61
+ description={`What shaping tool output saved in ${meta.windowLabel} against what pi would have shown unshaped, after taking back what the agent fetched again because of a cut.`}
62
+ actions={<Segmented aria-label="Sessions" options={AGENT_OPTIONS} value={agent} onChange={setAgent} />}
63
+ />
64
+
65
+ <QueryView
66
+ query={stats}
67
+ skeleton={<ChartSkeleton height={220} />}
68
+ isEmpty={data => data.sessions === 0 && data.recent.length === 0}
69
+ empty={
70
+ <Card>
71
+ <div className="spot-empty">
72
+ <span className="spot-empty-icon" aria-hidden="true">
73
+ <ScanSearch size={20} />
74
+ </span>
75
+ <h2 className="spot-empty-title">
76
+ {experimentStart === null
77
+ ? "The Spotlight experiment hasn't run yet"
78
+ : `No ${AGENT_SCOPE_PROSE[agent]}experiment sessions in ${meta.windowLabel}`}
79
+ </h2>
80
+ <p className="spot-empty-hint">
81
+ {experimentStart === null ? (
82
+ <>
83
+ Turn it on with <code>spotlight.experiment.enabled</code> in your settings or{" "}
84
+ <code>PI_SPOTLIGHT_EXPERIMENT=1</code>. Every session then shapes tool output, a random
85
+ share of results is held out unshaped as the counterfactual, and this page tracks what the
86
+ cuts save as you work.
87
+ </>
88
+ ) : agent === "all" ? (
89
+ "Try a longer range, or start a session with the experiment enabled."
90
+ ) : (
91
+ "Try a longer range, or switch to All sessions."
92
+ )}
93
+ </p>
94
+ </div>
95
+ </Card>
96
+ }
97
+ >
98
+ {data => (
99
+ <>
100
+ <SpotlightHero stats={data} windowLabel={meta.windowLabel} stale={stats.stale} />
101
+
102
+ <SectionHeading id="spot-saved" title="What it saved">
103
+ Where the saving came from, what fetch-backs took back, and what later requests no longer carry.
104
+ </SectionHeading>
105
+ <div className="grid grid-main-side">
106
+ <SavingsOverTime stats={data} range={range} stale={stats.stale} />
107
+ <WhereSavingsWent stats={data} stale={stats.stale} />
108
+ </div>
109
+ <Compounding stats={data} active={active} stale={stats.stale} />
110
+ <ShareTrend stats={data} range={range} stale={stats.stale} />
111
+
112
+ <SectionHeading id="spot-cost" title="What it cost">
113
+ Agent time, Spotlight's latency and failures, and whether shaped sessions got worse.
114
+ </SectionHeading>
115
+ <TimeCost stats={data} stale={stats.stale} />
116
+ <Overhead stats={data} stale={stats.stale} />
117
+
118
+ <SectionHeading id="spot-work" title="Is Spotlight doing the work?">
119
+ Whether its heat finds the lines the agent needs, and whether its cuts come back less than cuts
120
+ made without it.
121
+ </SectionHeading>
122
+ <Ranking stats={data} stale={stats.stale} />
123
+ <FetchBack stats={data} stale={stats.stale} />
124
+ <FetchBackGaps stats={data} stale={stats.stale} />
125
+
126
+ <SectionHeading id="spot-tune" title="Where to tune">
127
+ Which steps, sizes and tools pay, and which tools Spotlight doesn't shape yet.
128
+ </SectionHeading>
129
+ <Steps stats={data} stale={stats.stale} />
130
+ <Economics stats={data} stale={stats.stale} />
131
+ <Coverage stats={data} stale={stats.stale} />
132
+ <ResultsFeed rows={data.recent} stale={stats.stale} />
133
+ </>
134
+ )}
135
+ </QueryView>
136
+ </div>
137
+ );
138
+ }
@@ -7,5 +7,6 @@ export * from "./OverviewRoute";
7
7
  export * from "./ProjectsRoute";
8
8
  export * from "./ProvidersRoute";
9
9
  export * from "./RequestsRoute";
10
+ export * from "./SpotlightRoute";
10
11
  export * from "./ToolsRoute";
11
12
  export * from "./TracesRoute";
@@ -0,0 +1,222 @@
1
+ import { format } from "@oh-my-pi/pi-utils/dates";
2
+ import { useMemo, useState } from "react";
3
+ import { getSpotlightSessionContext } from "../api";
4
+ import { Chart, type ChartSeries, Legend, type SlotMarker } from "../charts";
5
+ import { formatCompact, formatFolder, formatInteger } from "../data/formatters";
6
+ import { useQuery } from "../data/query";
7
+ import type { SpotlightDashboardStats, SpotlightSessionContext, SpotlightSessionSummary } from "../types";
8
+ import { Badge, Card, ChartSkeleton, EmptyState, ErrorState } from "../ui";
9
+ import { formatShare, formatSignedCompact, formatSignedCost } from "./meta";
10
+
11
+ export interface CompoundingProps {
12
+ stats: SpotlightDashboardStats;
13
+ active: boolean;
14
+ stale: boolean;
15
+ }
16
+
17
+ /**
18
+ * Title, else a subagent's file name (`Consumers.jsonl`), else the project
19
+ * folder. Subagent and advisor sessions are indented under an arrow and tagged,
20
+ * since they sit in the newest-first list beside the session that spawned them.
21
+ */
22
+ function sessionLabel(session: SpotlightSessionSummary): string {
23
+ const main = session.agentType === "main";
24
+ const base = (session.sessionFile.split("/").pop() ?? "").replace(/\.jsonl$/, "");
25
+ const name = session.title?.trim() || (main ? formatFolder(session.folder) : base);
26
+ const head = main ? name : `↳ ${name} (${session.agentType})`;
27
+ return `${head} · ${formatCompact(session.savedTokens)} saved`;
28
+ }
29
+
30
+ /** A cut keeps paying: every later request would have re-read it. One session's context with and without shaping. */
31
+ export function Compounding({ stats, active, stale }: CompoundingProps) {
32
+ const { compounding } = stats;
33
+ const [picked, setPicked] = useState<string | null>(null);
34
+ const sessions = stats.sessionList;
35
+ // Default: the newest session that saved anything (the list is newest first).
36
+ const fallback = sessions.find(s => s.savedTokens > 0) ?? sessions[0];
37
+ const file = picked !== null && sessions.some(s => s.sessionFile === picked) ? picked : fallback?.sessionFile;
38
+
39
+ const context = useQuery(
40
+ ["spotlight-session", file ?? null],
41
+ ({ signal }) => getSpotlightSessionContext(file ?? "", signal),
42
+ { enabled: active && file !== undefined },
43
+ );
44
+ const avoided = compounding.avoidedTokens - compounding.recoveredCarriedTokens;
45
+ // Saving-weighted mean of the requests each cut stayed out of.
46
+ const carry = stats.gross.savedTokens > 0 ? compounding.avoidedTokens / stats.gross.savedTokens : null;
47
+ const points = context.data?.points;
48
+ // The selected session's own share: what its requests would have carried without shaping, and didn't.
49
+ const sessionShare = useMemo(() => {
50
+ if (!points) return null;
51
+ let gap = 0;
52
+ let without = 0;
53
+ for (const p of points) {
54
+ gap += p.withoutTokens - p.contextTokens;
55
+ without += p.withoutTokens;
56
+ }
57
+ return without > 0 ? { share: gap / without, tokens: gap } : null;
58
+ }, [points]);
59
+
60
+ return (
61
+ <Card
62
+ index={3}
63
+ title="Compounding"
64
+ description="A cut is never re-read: each later request of the session carries a smaller context"
65
+ actions={
66
+ sessions.length > 0 && (
67
+ <select
68
+ className="input spot-session-select"
69
+ aria-label="Session"
70
+ value={file ?? ""}
71
+ onChange={e => setPicked(e.target.value)}
72
+ >
73
+ {sessions.map(s => (
74
+ <option key={s.sessionFile} value={s.sessionFile}>
75
+ {sessionLabel(s)}
76
+ </option>
77
+ ))}
78
+ </select>
79
+ )
80
+ }
81
+ stale={stale}
82
+ footer="Context = input + cache read + cache write of each request. A compaction is a request whose context is under 60% of the previous one's; it ends the carry."
83
+ >
84
+ <div className="spot-compounding">
85
+ <div className="spot-compounding-chart">
86
+ {file === undefined ? (
87
+ <EmptyState title="No experiment sessions in this range" />
88
+ ) : context.data === null ? (
89
+ context.error ? (
90
+ <ErrorState error={context.error} onRetry={context.refetch} />
91
+ ) : (
92
+ <ChartSkeleton height={280} />
93
+ )
94
+ ) : (
95
+ <ContextChart context={context.data} stale={context.stale} />
96
+ )}
97
+ </div>
98
+ <aside className="spot-compounding-side">
99
+ <div className="spot-eyebrow">Context avoided in this session</div>
100
+ <div className="spot-side-value">{formatShare(sessionShare?.share ?? null)}</div>
101
+ {points !== undefined && (
102
+ <p className="spot-side-copy">
103
+ {sessionShare === null
104
+ ? "This session has no recorded requests."
105
+ : `of what its requests would have carried without Spotlight: ${formatSignedCompact(sessionShare.tokens)} prompt tokens, net of recoveries.`}
106
+ </p>
107
+ )}
108
+ {context.data && <SessionFacts session={context.data.session} />}
109
+ <p className="spot-side-range">
110
+ <span className="spot-side-range-value">{formatShare(compounding.avoidedShare)}</span> across all the
111
+ sessions shown: cuts kept {formatCompact(Math.max(0, avoided))} tokens out of later requests
112
+ {carry !== null && <>, each saved token re-sent {carry.toFixed(1)} times on average</>}.
113
+ </p>
114
+ </aside>
115
+ </div>
116
+ </Card>
117
+ );
118
+ }
119
+
120
+ function SessionFacts({ session }: { session: SpotlightSessionSummary }) {
121
+ return (
122
+ <dl className="spot-facts">
123
+ <div>
124
+ <dt>Agent</dt>
125
+ <dd className="row">
126
+ <Badge>{session.agentType}</Badge>
127
+ </dd>
128
+ </div>
129
+ <div>
130
+ <dt>Requests</dt>
131
+ <dd className="num">{formatInteger(session.requests)}</dd>
132
+ </div>
133
+ <div>
134
+ <dt>Saved</dt>
135
+ <dd className="num">{formatSignedCompact(session.savedTokens)}</dd>
136
+ </div>
137
+ <div>
138
+ <dt>Net $</dt>
139
+ <dd className="num">{formatSignedCost(session.netUsd)}</dd>
140
+ </div>
141
+ </dl>
142
+ );
143
+ }
144
+
145
+ const CONTEXT_LEGEND = [
146
+ { key: "actual", label: "Context", color: "var(--spot-gross)" },
147
+ { key: "ghost", label: "Without Spotlight", color: "var(--spot-ghost)" },
148
+ { key: "compaction", label: "Compaction", color: "var(--line-4)" },
149
+ ];
150
+
151
+ /** One session's context per request. The x axis is the request number (several can share a minute); the tooltip carries the time. */
152
+ function ContextChart({ context, stale }: { context: SpotlightSessionContext; stale: boolean }) {
153
+ const { points } = context;
154
+ const chart = useMemo(() => {
155
+ const series: ChartSeries[] = [
156
+ {
157
+ key: "actual",
158
+ label: "Context",
159
+ color: "var(--spot-gross)",
160
+ kind: "area",
161
+ values: points.map(p => p.contextTokens),
162
+ },
163
+ {
164
+ key: "ghost",
165
+ label: "Avoided",
166
+ color: "var(--spot-ghost)",
167
+ kind: "area",
168
+ pattern: "hatch",
169
+ dashed: true,
170
+ values: points.map(p => Math.max(0, p.withoutTokens - p.contextTokens)),
171
+ },
172
+ ];
173
+ const markers: SlotMarker[] = [];
174
+ points.forEach((p, i) => {
175
+ if (p.compacted) markers.push({ index: i });
176
+ });
177
+ return { series, markers };
178
+ }, [points]);
179
+
180
+ if (points.length === 0) {
181
+ return <EmptyState title="No requests recorded for this session" />;
182
+ }
183
+ return (
184
+ <div className="spot-context" data-stale={stale}>
185
+ <Legend items={CONTEXT_LEGEND} />
186
+ <Chart
187
+ slots={points.length}
188
+ series={chart.series}
189
+ markers={chart.markers}
190
+ height={280}
191
+ tickLabel={i => `#${i + 1}`}
192
+ tooltipTitle={i => `Request ${i + 1} · ${format(new Date(points[i].timestamp), "MMM d HH:mm:ss")}`}
193
+ formatTooltip={formatInteger}
194
+ showTotal={false}
195
+ tooltipExtra={i => {
196
+ const p = points[i];
197
+ return (
198
+ <div className="spot-tooltip-extra">
199
+ <div className="chart-tooltip-row chart-tooltip-total">
200
+ <span className="chart-tooltip-label">Without Spotlight</span>
201
+ <span className="chart-tooltip-value">{formatInteger(p.withoutTokens)}</span>
202
+ </div>
203
+ {p.saved > 0 && (
204
+ <div className="chart-tooltip-row">
205
+ <span className="chart-tooltip-label">Cut since last request</span>
206
+ <span className="chart-tooltip-value">{formatInteger(p.saved)}</span>
207
+ </div>
208
+ )}
209
+ {p.recovered > 0 && (
210
+ <div className="chart-tooltip-row">
211
+ <span className="chart-tooltip-label">Fetched back</span>
212
+ <span className="chart-tooltip-value">{formatInteger(p.recovered)}</span>
213
+ </div>
214
+ )}
215
+ {p.compacted && <div className="chart-tooltip-row dim">Compaction before this request</div>}
216
+ </div>
217
+ );
218
+ }}
219
+ />
220
+ </div>
221
+ );
222
+ }