tokentrace 0.17.0 → 0.18.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 (50) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/TOKENTRACE_AGENT.md +29 -0
  3. package/app/page.tsx +17 -1
  4. package/app/query/page.tsx +292 -0
  5. package/components/charts/trend-chart.tsx +34 -2
  6. package/components/charts/trend-section.tsx +14 -2
  7. package/components/overview/anomalies-panel.tsx +116 -0
  8. package/components/repair/repair-items-table.tsx +52 -1
  9. package/components/sidebar.tsx +2 -0
  10. package/dist/runtime/agent.mjs +209 -3
  11. package/dist/runtime/anomalies.mjs +1063 -0
  12. package/dist/runtime/db-migrate.mjs +16 -0
  13. package/dist/runtime/db-seed.mjs +56 -12
  14. package/dist/runtime/digest.mjs +16 -0
  15. package/dist/runtime/doctor.mjs +16 -0
  16. package/dist/runtime/evidence.mjs +16 -0
  17. package/dist/runtime/insights.mjs +16 -0
  18. package/dist/runtime/mcp.mjs +173 -0
  19. package/dist/runtime/pricing-refresh.mjs +201 -15
  20. package/dist/runtime/query.mjs +1104 -0
  21. package/dist/runtime/repair.mjs +1095 -378
  22. package/dist/runtime/report.mjs +16 -0
  23. package/dist/runtime/reset.mjs +56 -12
  24. package/dist/runtime/review.mjs +16 -0
  25. package/dist/runtime/scan.mjs +201 -15
  26. package/dist/runtime/status.mjs +16 -0
  27. package/llms.txt +3 -0
  28. package/package.json +1 -1
  29. package/scripts/anomalies.ts +95 -0
  30. package/scripts/build-cli-runtime.mjs +2 -0
  31. package/scripts/query.ts +47 -0
  32. package/scripts/repair.ts +128 -0
  33. package/server.json +2 -2
  34. package/src/cli/commands.js +20 -0
  35. package/src/db/migrate-core.ts +16 -0
  36. package/src/lib/agent-discovery.ts +48 -0
  37. package/src/lib/anomalies-cli.ts +73 -0
  38. package/src/lib/anomaly-detection.ts +162 -0
  39. package/src/lib/cost-recalculation.ts +62 -14
  40. package/src/lib/mcp/tools.ts +78 -0
  41. package/src/lib/mcp-server.ts +102 -0
  42. package/src/lib/model-aliases/backfill.ts +130 -0
  43. package/src/lib/model-aliases/store.ts +156 -0
  44. package/src/lib/structured-query-cli.ts +172 -0
  45. package/src/lib/structured-query.ts +278 -0
  46. package/src/lib/unknown-cost-repair/auto-classify-cli.ts +238 -0
  47. package/src/lib/unknown-cost-repair/auto-classify.ts +175 -0
  48. package/src/lib/unknown-cost-repair/types.ts +2 -0
  49. package/src/lib/unknown-cost-repair/workbench.ts +21 -3
  50. package/tsconfig.json +2 -1
package/CHANGELOG.md CHANGED
@@ -4,6 +4,58 @@ All notable changes to TokenTrace are documented here.
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## [0.18.0] - 2026-05-28
8
+
9
+ ### Local intelligence
10
+
11
+ - **Anomaly detection (`tokentrace anomalies`).** A new modified-z-score (MAD)
12
+ detector scores the daily token and cost trend against a trailing window
13
+ (default 14 days) and surfaces deviations as `notable`, `high`, or `severe`.
14
+ Pure-stats; spends zero AI tokens. New CLI flags: `--window=N` (3..60),
15
+ `--metric=tokens|cost|all`, `--json`.
16
+ - **Structured query (`tokentrace query`).** A new deterministic
17
+ parameterized aggregation that lets agents ask precise local questions
18
+ without TokenTrace performing any NL parsing. Group by
19
+ `model|project|tool|session|day`, aggregate
20
+ `cost|totalTokens|interactions`, with optional preset/from/to range,
21
+ exact-match `model|project|tool` filters, and `--top` clamped to 200.
22
+ Routes through `prepareCached` for the warm-statement path.
23
+ - **Auto-classifier (`tokentrace repair auto-classify`).** Each unknown-cost
24
+ workbench group now carries a deterministic `classification` field
25
+ produced by three rules in confidence order: `exact-model` (0.95),
26
+ `family-fragment` (0.70, normalized via `modelNameCandidates`), and
27
+ `parser-source` (0.45, same source file as priced examples). The CLI
28
+ supports `--apply --min-confidence=N` (floor 0.85) which writes each
29
+ qualifying exact-model or family-fragment suggestion to a new
30
+ `model_aliases` table and backfills cost for the matching unknown-cost
31
+ interactions; `--dry-run` previews without writing. parser-source
32
+ matches are skipped from `--apply` because they have no
33
+ (provider, observed-model) pair to persist.
34
+ - **Persistent model aliases.** A new `model_aliases` table maps
35
+ `(provider_id, observed_model)` to a priced model. The cost
36
+ recalculation pass now LEFT JOINs the alias table so aliased costs
37
+ survive every re-seed, with metadata noting the rule and confidence.
38
+ - **Interactive Query page (`/query`).** A new dashboard route exposes the
39
+ structured-query surface with a server-rendered form (group-by, metric,
40
+ range preset / from / to, model/project/tool filters, top N). Results
41
+ render as a deterministic table. Same SQL path as the CLI and MCP tool.
42
+ - **Anomaly drill-down + chart markers.** Each anomaly date in the
43
+ overview Anomalies panel is now a link to `/?range=custom&from=DATE&to=DATE`
44
+ so clicking filters the entire overview to that day. The Trend chart
45
+ also renders colored Recharts `ReferenceDot` markers on flagged days
46
+ (red severe, amber high, gray notable) when the bucket is daily.
47
+ - **"Auto-classify" column in Repair Items.** The repair workbench table
48
+ now shows the suggested priced model, classification rule, and
49
+ confidence percentage alongside the existing legacy suggestion.
50
+ - **New MCP tools.** `get_anomalies`, `query_usage`, and
51
+ `get_classifications` are wired into `src/lib/mcp/tools.ts` with full
52
+ input schemas and `src/lib/mcp-server.ts` handlers that translate
53
+ structured arguments into CLI flags. Each handler is read-only and
54
+ spends zero AI tokens.
55
+ - **Agent discovery catalog.** `tokentrace agent --json` now exposes
56
+ `anomalies`, `query`, and `auto-classify` commands so MCP-capable
57
+ agents can discover the new local-intelligence surface.
58
+
7
59
  ## [0.17.0] - 2026-05-23
8
60
 
9
61
  ### Performance
@@ -92,6 +92,35 @@ curl http://127.0.0.1:3030/api/roadmap
92
92
  tokentrace evidence --json
93
93
  ```
94
94
 
95
+ ## Local Intelligence (zero AI tokens)
96
+
97
+ TokenTrace ships deterministic, local-only analytical surfaces that spend
98
+ no AI tokens. The agent on the calling side handles any natural-language
99
+ parsing; TokenTrace executes pure SQL and statistics.
100
+
101
+ Spot unusual local usage days via a modified-z-score (MAD) detector:
102
+
103
+ ```bash
104
+ tokentrace anomalies --json [--window=N] [--metric=tokens|cost|all]
105
+ ```
106
+
107
+ Run a structured aggregation by model, project, tool, session, or day:
108
+
109
+ ```bash
110
+ tokentrace query --group-by model --metric cost --range 7d --json
111
+ tokentrace query --group-by day --metric totalTokens --from 2026-05-01 --to 2026-05-15 --json
112
+ ```
113
+
114
+ Get deterministic classification suggestions for the unknown-cost queue:
115
+
116
+ ```bash
117
+ tokentrace repair auto-classify --json [--min-confidence=N]
118
+ ```
119
+
120
+ The matching MCP tools are `get_anomalies`, `query_usage`, and
121
+ `get_classifications`. All three are read-only and require no
122
+ `confirmLocalScan` acknowledgement.
123
+
95
124
  ## Guardrails
96
125
 
97
126
  - Do not run `tokentrace reset` unless the human explicitly asks to clear imported local data.
package/app/page.tsx CHANGED
@@ -2,6 +2,9 @@ import { Suspense } from "react";
2
2
  import Link from "next/link";
3
3
  import { ArrowRight } from "lucide-react";
4
4
  import { TrendSection } from "@/components/charts/trend-section-lazy";
5
+ import type { TrendAnomalyMarker } from "@/components/charts/trend-chart";
6
+ import { OverviewAnomaliesPanel } from "@/components/overview/anomalies-panel";
7
+ import { detectAnomalies } from "@/src/lib/anomaly-detection";
5
8
  import { OverviewCurrentMixPanel } from "@/components/overview/current-mix-panel";
6
9
  import { DataConfidenceStrip } from "@/components/overview/data-confidence-strip";
7
10
  import { FirstRunPanel } from "@/components/overview/first-run-panel";
@@ -41,6 +44,13 @@ async function OverviewPrimarySection({ range }: { range: ResolvedDateRange }) {
41
44
  rangeLinkParams
42
45
  } = await getOverviewPrimaryData(range);
43
46
  const repairFocusHref = mergeHrefParams("/repair", rangeLinkParams);
47
+ const anomalyReport = detectAnomalies(data.trends);
48
+ const tokenMarkers: TrendAnomalyMarker[] = anomalyReport.anomalies
49
+ .filter((entry) => entry.metric === "tokens")
50
+ .map((entry) => ({ date: entry.date, value: entry.value, severity: entry.severity }));
51
+ const costMarkers: TrendAnomalyMarker[] = anomalyReport.anomalies
52
+ .filter((entry) => entry.metric === "cost")
53
+ .map((entry) => ({ date: entry.date, value: entry.value, severity: entry.severity }));
44
54
 
45
55
  return (
46
56
  <div className="space-y-8">
@@ -63,7 +73,13 @@ async function OverviewPrimarySection({ range }: { range: ResolvedDateRange }) {
63
73
  />
64
74
  </div>
65
75
  <OverviewTrustFooter health={trust.health} pricedModelCount={trust.pricedModelCount} />
66
- <TrendSection data={data.trends} defaultWindow={trendDefaultWindow} />
76
+ <TrendSection
77
+ data={data.trends}
78
+ defaultWindow={trendDefaultWindow}
79
+ tokenMarkers={tokenMarkers}
80
+ costMarkers={costMarkers}
81
+ />
82
+ <OverviewAnomaliesPanel trends={data.trends} />
67
83
  <UsageGuardrailsPanel progress={data.usageGuardrails} />
68
84
  <OverviewRecommendationsCard recommendations={data.recommendations} />
69
85
  <OverviewCurrentMixPanel
@@ -0,0 +1,292 @@
1
+ import { PageHeader } from "@/components/ui/typography";
2
+ import { Badge } from "@/components/ui/badge";
3
+ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
4
+ import {
5
+ Table,
6
+ TableBody,
7
+ TableCell,
8
+ TableHead,
9
+ TableHeader,
10
+ TableRow
11
+ } from "@/components/ui/table";
12
+ import {
13
+ runStructuredQuery,
14
+ type StructuredQueryArgs,
15
+ type StructuredQueryGroupBy,
16
+ type StructuredQueryMetric,
17
+ type StructuredQueryRangePreset,
18
+ type StructuredQuerySort
19
+ } from "@/src/lib/structured-query";
20
+ import { formatTokens } from "@/src/lib/format";
21
+
22
+ export const dynamic = "force-dynamic";
23
+
24
+ type QueryPageProps = {
25
+ searchParams?: Promise<Record<string, string | string[] | undefined>>;
26
+ };
27
+
28
+ function readString(value: string | string[] | undefined): string | undefined {
29
+ if (Array.isArray(value)) return value[0];
30
+ return typeof value === "string" && value.trim() ? value : undefined;
31
+ }
32
+
33
+ const GROUP_BY: StructuredQueryGroupBy[] = ["model", "project", "tool", "session", "day"];
34
+ const METRIC: StructuredQueryMetric[] = ["cost", "totalTokens", "interactions"];
35
+ const PRESETS: StructuredQueryRangePreset[] = ["today", "7d", "30d", "60d", "90d", "all"];
36
+ const SORTS: StructuredQuerySort[] = ["desc", "asc"];
37
+
38
+ function formatValue(value: number, metric: StructuredQueryMetric) {
39
+ if (metric === "cost") return `$${value.toFixed(2)}`;
40
+ if (metric === "totalTokens") return formatTokens(value);
41
+ return value.toLocaleString();
42
+ }
43
+
44
+ function selectClass() {
45
+ return "w-full rounded-md border bg-background px-3 py-2 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-ring";
46
+ }
47
+
48
+ function inputClass() {
49
+ return "w-full rounded-md border bg-background px-3 py-2 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-ring";
50
+ }
51
+
52
+ function buttonClass() {
53
+ return "inline-flex items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground shadow-sm hover:bg-primary/90";
54
+ }
55
+
56
+ export default async function QueryPage({ searchParams }: QueryPageProps) {
57
+ const params = (await searchParams) ?? {};
58
+ const groupBy = (readString(params.groupBy) as StructuredQueryGroupBy | undefined) ?? "model";
59
+ const metric = (readString(params.metric) as StructuredQueryMetric | undefined) ?? "cost";
60
+ const preset = readString(params.range) as StructuredQueryRangePreset | undefined;
61
+ const from = readString(params.from);
62
+ const to = readString(params.to);
63
+ const model = readString(params.model);
64
+ const project = readString(params.project);
65
+ const tool = readString(params.tool);
66
+ const topRaw = readString(params.topN);
67
+ const sort = (readString(params.sort) as StructuredQuerySort | undefined) ?? "desc";
68
+
69
+ const submitted = Boolean(params.submit);
70
+
71
+ let error: string | null = null;
72
+ let result: ReturnType<typeof runStructuredQuery> | null = null;
73
+
74
+ if (submitted) {
75
+ const args: StructuredQueryArgs = {
76
+ groupBy: GROUP_BY.includes(groupBy) ? groupBy : "model",
77
+ metric: METRIC.includes(metric) ? metric : "cost",
78
+ sort,
79
+ filters: { model, project, tool },
80
+ range: preset || from || to ? { preset, from, to } : undefined,
81
+ topN: topRaw ? Number(topRaw) : undefined
82
+ };
83
+ try {
84
+ result = runStructuredQuery(args);
85
+ } catch (caught) {
86
+ error = caught instanceof Error ? caught.message : "Query failed.";
87
+ }
88
+ }
89
+
90
+ return (
91
+ <div className="space-y-6">
92
+ <PageHeader
93
+ title="Query"
94
+ description="Deterministic local SQL aggregation. The form below runs entirely on your machine — no AI tokens are spent."
95
+ />
96
+
97
+ <Card>
98
+ <CardHeader>
99
+ <CardTitle>Run a structured query</CardTitle>
100
+ <CardDescription>
101
+ Group local interactions by model, project, tool, session, or day and aggregate cost,
102
+ total tokens, or interaction count.
103
+ </CardDescription>
104
+ </CardHeader>
105
+ <CardContent>
106
+ <form method="get" className="grid gap-4 md:grid-cols-3">
107
+ <label className="space-y-1 text-sm">
108
+ <span className="font-medium">Group by</span>
109
+ <select name="groupBy" defaultValue={groupBy} className={selectClass()}>
110
+ {GROUP_BY.map((value) => (
111
+ <option key={value} value={value}>
112
+ {value}
113
+ </option>
114
+ ))}
115
+ </select>
116
+ </label>
117
+ <label className="space-y-1 text-sm">
118
+ <span className="font-medium">Metric</span>
119
+ <select name="metric" defaultValue={metric} className={selectClass()}>
120
+ {METRIC.map((value) => (
121
+ <option key={value} value={value}>
122
+ {value}
123
+ </option>
124
+ ))}
125
+ </select>
126
+ </label>
127
+ <label className="space-y-1 text-sm">
128
+ <span className="font-medium">Sort</span>
129
+ <select name="sort" defaultValue={sort} className={selectClass()}>
130
+ {SORTS.map((value) => (
131
+ <option key={value} value={value}>
132
+ {value}
133
+ </option>
134
+ ))}
135
+ </select>
136
+ </label>
137
+ <label className="space-y-1 text-sm">
138
+ <span className="font-medium">Preset range</span>
139
+ <select name="range" defaultValue={preset ?? ""} className={selectClass()}>
140
+ <option value="">(none)</option>
141
+ {PRESETS.map((value) => (
142
+ <option key={value} value={value}>
143
+ {value}
144
+ </option>
145
+ ))}
146
+ </select>
147
+ </label>
148
+ <label className="space-y-1 text-sm">
149
+ <span className="font-medium">From (YYYY-MM-DD)</span>
150
+ <input
151
+ type="text"
152
+ name="from"
153
+ defaultValue={from ?? ""}
154
+ placeholder="2026-05-01"
155
+ className={inputClass()}
156
+ />
157
+ </label>
158
+ <label className="space-y-1 text-sm">
159
+ <span className="font-medium">To (YYYY-MM-DD, exclusive)</span>
160
+ <input
161
+ type="text"
162
+ name="to"
163
+ defaultValue={to ?? ""}
164
+ placeholder="2026-05-15"
165
+ className={inputClass()}
166
+ />
167
+ </label>
168
+ <label className="space-y-1 text-sm">
169
+ <span className="font-medium">Model filter</span>
170
+ <input type="text" name="model" defaultValue={model ?? ""} className={inputClass()} />
171
+ </label>
172
+ <label className="space-y-1 text-sm">
173
+ <span className="font-medium">Project filter</span>
174
+ <input
175
+ type="text"
176
+ name="project"
177
+ defaultValue={project ?? ""}
178
+ className={inputClass()}
179
+ />
180
+ </label>
181
+ <label className="space-y-1 text-sm">
182
+ <span className="font-medium">Tool filter</span>
183
+ <input type="text" name="tool" defaultValue={tool ?? ""} className={inputClass()} />
184
+ </label>
185
+ <label className="space-y-1 text-sm">
186
+ <span className="font-medium">Top N (1..200)</span>
187
+ <input
188
+ type="number"
189
+ name="topN"
190
+ min={1}
191
+ max={200}
192
+ defaultValue={topRaw ?? "20"}
193
+ className={inputClass()}
194
+ />
195
+ </label>
196
+ <div className="md:col-span-3">
197
+ <button type="submit" name="submit" value="1" className={buttonClass()}>
198
+ Run query
199
+ </button>
200
+ </div>
201
+ </form>
202
+ </CardContent>
203
+ </Card>
204
+
205
+ {error ? (
206
+ <Card>
207
+ <CardHeader>
208
+ <CardTitle>Query error</CardTitle>
209
+ <CardDescription>The structured-query validator rejected the arguments.</CardDescription>
210
+ </CardHeader>
211
+ <CardContent>
212
+ <p className="text-sm text-destructive">{error}</p>
213
+ </CardContent>
214
+ </Card>
215
+ ) : null}
216
+
217
+ {result ? (
218
+ <Card>
219
+ <CardHeader className="flex flex-col gap-3 pb-3 lg:flex-row lg:items-start lg:justify-between">
220
+ <div>
221
+ <CardTitle>
222
+ {result.rows.length === 0 ? "No rows" : `${result.rows.length} rows`}
223
+ </CardTitle>
224
+ <CardDescription>
225
+ Group by <code>{result.groupBy}</code>, metric <code>{result.metric}</code>, sort{" "}
226
+ <code>{result.sort}</code>. {result.range.preset ? `Range: ${result.range.preset}. ` : ""}
227
+ {result.range.from || result.range.to
228
+ ? `Window: ${result.range.from ?? "—"} → ${result.range.to ?? "—"}. `
229
+ : ""}
230
+ Showing {result.rows.length} of {result.totalGroups} groups
231
+ {result.truncated ? " (truncated by topN)" : ""}.
232
+ </CardDescription>
233
+ </div>
234
+ <div className="flex gap-2">
235
+ {result.filters.model ? <Badge variant="secondary">model={result.filters.model}</Badge> : null}
236
+ {result.filters.project ? <Badge variant="secondary">project={result.filters.project}</Badge> : null}
237
+ {result.filters.tool ? <Badge variant="secondary">tool={result.filters.tool}</Badge> : null}
238
+ </div>
239
+ </CardHeader>
240
+ <CardContent>
241
+ {result.rows.length === 0 ? (
242
+ <p className="text-sm text-muted-foreground">
243
+ No groups matched. Try a different range or remove filters.
244
+ </p>
245
+ ) : (
246
+ <div className="overflow-x-auto">
247
+ <Table>
248
+ <TableHeader>
249
+ <TableRow>
250
+ <TableHead>{result.groupBy}</TableHead>
251
+ <TableHead className="text-right">{result.metric}</TableHead>
252
+ <TableHead className="text-right">interactions</TableHead>
253
+ <TableHead className="text-right">totalTokens</TableHead>
254
+ <TableHead className="text-right">cost</TableHead>
255
+ </TableRow>
256
+ </TableHeader>
257
+ <TableBody>
258
+ {result.rows.map((row) => (
259
+ <TableRow key={row.group}>
260
+ <TableCell className="font-medium">{row.group}</TableCell>
261
+ <TableCell className="text-right">
262
+ {formatValue(row.value, result.metric)}
263
+ </TableCell>
264
+ <TableCell className="text-right">{row.interactions.toLocaleString()}</TableCell>
265
+ <TableCell className="text-right">{formatTokens(row.totalTokens)}</TableCell>
266
+ <TableCell className="text-right">${row.cost.toFixed(2)}</TableCell>
267
+ </TableRow>
268
+ ))}
269
+ </TableBody>
270
+ </Table>
271
+ </div>
272
+ )}
273
+ </CardContent>
274
+ </Card>
275
+ ) : null}
276
+
277
+ {!submitted && !error ? (
278
+ <Card>
279
+ <CardHeader>
280
+ <CardTitle>About this page</CardTitle>
281
+ <CardDescription>
282
+ The form submits a GET request with structured arguments. The same query is available
283
+ from the CLI as <code>tokentrace query --group-by ... --metric ... --json</code> and
284
+ through the MCP <code>query_usage</code> tool. All three paths execute the same
285
+ deterministic SQL — zero AI tokens are spent.
286
+ </CardDescription>
287
+ </CardHeader>
288
+ </Card>
289
+ ) : null}
290
+ </div>
291
+ );
292
+ }
@@ -2,7 +2,7 @@
2
2
 
3
3
  import * as React from "react";
4
4
  import { useMemo, useState } from "react";
5
- import { Area, AreaChart, CartesianGrid, Tooltip, XAxis, YAxis } from "recharts";
5
+ import { Area, AreaChart, CartesianGrid, ReferenceDot, Tooltip, XAxis, YAxis } from "recharts";
6
6
  import type { TrendPoint } from "@/src/lib/analytics";
7
7
  import { formatCurrency, formatShortDate, formatTokens } from "@/src/lib/format";
8
8
  import { Button } from "@/components/ui/button";
@@ -118,6 +118,18 @@ function bucketFor(date: string, period: TrendBucket) {
118
118
  return date;
119
119
  }
120
120
 
121
+ export type TrendAnomalyMarker = {
122
+ date: string;
123
+ value: number;
124
+ severity: "notable" | "high" | "severe";
125
+ };
126
+
127
+ function severityColor(severity: TrendAnomalyMarker["severity"]) {
128
+ if (severity === "severe") return "#dc2626";
129
+ if (severity === "high") return "#f59e0b";
130
+ return "#a3a3a3";
131
+ }
132
+
121
133
  export function TrendChart({
122
134
  data,
123
135
  metric,
@@ -125,7 +137,8 @@ export function TrendChart({
125
137
  defaultWindow = "60d",
126
138
  period: controlledPeriod,
127
139
  trendWindow: controlledTrendWindow,
128
- showControls = true
140
+ showControls = true,
141
+ markers
129
142
  }: {
130
143
  data: TrendPoint[];
131
144
  metric: "totalTokens" | "cost";
@@ -134,6 +147,7 @@ export function TrendChart({
134
147
  period?: TrendBucket;
135
148
  trendWindow?: TrendWindow;
136
149
  showControls?: boolean;
150
+ markers?: TrendAnomalyMarker[];
137
151
  }) {
138
152
  const [internalPeriod, setInternalPeriod] = useState<TrendBucket>("daily");
139
153
  const [internalTrendWindow, setInternalTrendWindow] = useState<TrendWindow>(defaultWindow);
@@ -228,6 +242,24 @@ export function TrendChart({
228
242
  fill={`url(#trend-${metric})`}
229
243
  strokeWidth={2}
230
244
  />
245
+ {period === "daily" && markers
246
+ ? markers
247
+ .filter((marker) =>
248
+ chartData.some((point) => point.date === marker.date)
249
+ )
250
+ .map((marker) => (
251
+ <ReferenceDot
252
+ key={`${marker.date}:${metric}`}
253
+ x={marker.date}
254
+ y={marker.value}
255
+ r={5}
256
+ fill={severityColor(marker.severity)}
257
+ stroke="#ffffff"
258
+ strokeWidth={2}
259
+ ifOverflow="extendDomain"
260
+ />
261
+ ))
262
+ : null}
231
263
  </AreaChart>
232
264
  ) : (
233
265
  <ChartSkeleton />
@@ -5,7 +5,13 @@ import { useState } from "react";
5
5
  import type { TrendPoint } from "@/src/lib/analytics";
6
6
  import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
7
7
  import { Badge } from "@/components/ui/badge";
8
- import { TrendChart, TrendControls, type TrendBucket, type TrendWindow } from "@/components/charts/trend-chart";
8
+ import {
9
+ TrendChart,
10
+ TrendControls,
11
+ type TrendAnomalyMarker,
12
+ type TrendBucket,
13
+ type TrendWindow
14
+ } from "@/components/charts/trend-chart";
9
15
 
10
16
  function trendWindowLabel(window: TrendWindow) {
11
17
  if (window === "30d") return "Showing latest 30 days";
@@ -16,10 +22,14 @@ function trendWindowLabel(window: TrendWindow) {
16
22
 
17
23
  export function TrendSection({
18
24
  data,
19
- defaultWindow
25
+ defaultWindow,
26
+ tokenMarkers,
27
+ costMarkers
20
28
  }: {
21
29
  data: TrendPoint[];
22
30
  defaultWindow: TrendWindow;
31
+ tokenMarkers?: TrendAnomalyMarker[];
32
+ costMarkers?: TrendAnomalyMarker[];
23
33
  }) {
24
34
  const [period, setPeriod] = useState<TrendBucket>("daily");
25
35
  const [trendWindow, setTrendWindow] = useState<TrendWindow>(defaultWindow);
@@ -65,6 +75,7 @@ export function TrendSection({
65
75
  period={period}
66
76
  trendWindow={trendWindow}
67
77
  showControls={false}
78
+ markers={tokenMarkers}
68
79
  />
69
80
  </CardContent>
70
81
  </Card>
@@ -81,6 +92,7 @@ export function TrendSection({
81
92
  period={period}
82
93
  trendWindow={trendWindow}
83
94
  showControls={false}
95
+ markers={costMarkers}
84
96
  />
85
97
  </CardContent>
86
98
  </Card>
@@ -0,0 +1,116 @@
1
+ import Link from "next/link";
2
+ import { AlertTriangle, ArrowUpRight, CheckCircle2 } from "lucide-react";
3
+ import { detectAnomalies, type Anomaly, type AnomalySeverity } from "@/src/lib/anomaly-detection";
4
+ import type { TrendPoint } from "@/src/lib/analytics";
5
+ import { Badge } from "@/components/ui/badge";
6
+ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
7
+ import { formatTokens } from "@/src/lib/format";
8
+
9
+ function dayDrillHref(date: string) {
10
+ // Filter the overview to a single day using the existing custom-range params.
11
+ const params = new URLSearchParams({ range: "custom", from: date, to: date });
12
+ return `/?${params.toString()}`;
13
+ }
14
+
15
+ function severityVariant(severity: AnomalySeverity): "destructive" | "warning" | "secondary" {
16
+ if (severity === "severe") return "destructive";
17
+ if (severity === "high") return "warning";
18
+ return "secondary";
19
+ }
20
+
21
+ function formatValue(anomaly: Anomaly) {
22
+ if (anomaly.metric === "tokens") return `${formatTokens(anomaly.value)} tokens`;
23
+ return `$${anomaly.value.toFixed(2)}`;
24
+ }
25
+
26
+ function formatBaseline(anomaly: Anomaly) {
27
+ if (anomaly.metric === "tokens") return `${formatTokens(anomaly.baseline)} median`;
28
+ return `$${anomaly.baseline.toFixed(2)} median`;
29
+ }
30
+
31
+ function formatRatio(anomaly: Anomaly) {
32
+ if (anomaly.ratio == null || !Number.isFinite(anomaly.ratio)) return "—";
33
+ return `${anomaly.ratio.toFixed(1)}× baseline`;
34
+ }
35
+
36
+ export function OverviewAnomaliesPanel({ trends }: { trends: TrendPoint[] }) {
37
+ const report = detectAnomalies(trends);
38
+ const recent = report.anomalies.slice(-6).reverse();
39
+
40
+ return (
41
+ <Card>
42
+ <CardHeader className="flex flex-col gap-3 pb-3 lg:flex-row lg:items-start lg:justify-between">
43
+ <div>
44
+ <CardTitle>Anomalies</CardTitle>
45
+ <CardDescription>
46
+ Local modified-z-score (MAD) detector over the last {report.windowSize}-day window. Zero AI tokens spent.
47
+ </CardDescription>
48
+ </div>
49
+ <div className="flex flex-wrap gap-2">
50
+ {report.summary.bySeverity.severe > 0 ? (
51
+ <Badge variant="destructive">{report.summary.bySeverity.severe} severe</Badge>
52
+ ) : null}
53
+ {report.summary.bySeverity.high > 0 ? (
54
+ <Badge variant="warning">{report.summary.bySeverity.high} high</Badge>
55
+ ) : null}
56
+ {report.summary.bySeverity.notable > 0 ? (
57
+ <Badge variant="secondary">{report.summary.bySeverity.notable} notable</Badge>
58
+ ) : null}
59
+ {report.summary.total === 0 ? <Badge variant="success">clear</Badge> : null}
60
+ </div>
61
+ </CardHeader>
62
+ <CardContent>
63
+ {report.summary.total === 0 ? (
64
+ <div className="flex items-center gap-2 text-sm text-muted-foreground">
65
+ <CheckCircle2 className="h-4 w-4 text-emerald-500" />
66
+ No unusual days detected in the visible trend. The detector needs at least 5 prior days of data.
67
+ </div>
68
+ ) : (
69
+ <ul className="space-y-2 text-sm">
70
+ {recent.map((anomaly) => (
71
+ <li
72
+ key={`${anomaly.date}:${anomaly.metric}`}
73
+ className="flex items-start gap-3 rounded-md border bg-muted/20 px-3 py-2"
74
+ >
75
+ <AlertTriangle
76
+ className={
77
+ anomaly.severity === "severe"
78
+ ? "h-4 w-4 mt-0.5 text-destructive"
79
+ : anomaly.severity === "high"
80
+ ? "h-4 w-4 mt-0.5 text-amber-500"
81
+ : "h-4 w-4 mt-0.5 text-muted-foreground"
82
+ }
83
+ />
84
+ <div className="flex-1 min-w-0">
85
+ <div className="flex flex-wrap items-center gap-2">
86
+ <Badge variant={severityVariant(anomaly.severity)}>{anomaly.severity}</Badge>
87
+ <Link
88
+ href={dayDrillHref(anomaly.date)}
89
+ className="inline-flex items-center gap-1 font-medium underline-offset-4 hover:underline"
90
+ title={`Filter the overview to ${anomaly.date}`}
91
+ >
92
+ {anomaly.date}
93
+ <ArrowUpRight className="h-3 w-3" />
94
+ </Link>
95
+ <span className="text-xs text-muted-foreground uppercase tracking-wide">
96
+ {anomaly.metric}
97
+ </span>
98
+ </div>
99
+ <div className="mt-1 text-xs text-muted-foreground">
100
+ {formatValue(anomaly)} — {formatRatio(anomaly)} ({formatBaseline(anomaly)})
101
+ </div>
102
+ </div>
103
+ </li>
104
+ ))}
105
+ {report.summary.total > recent.length ? (
106
+ <li className="text-xs text-muted-foreground">
107
+ Showing {recent.length} most recent of {report.summary.total} anomalies. Run{" "}
108
+ <code>tokentrace anomalies --json</code> for the full report.
109
+ </li>
110
+ ) : null}
111
+ </ul>
112
+ )}
113
+ </CardContent>
114
+ </Card>
115
+ );
116
+ }