tokentrace 0.16.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.
- package/CHANGELOG.md +110 -0
- package/TOKENTRACE_AGENT.md +29 -0
- package/app/models/page.tsx +1 -1
- package/app/page.tsx +88 -47
- package/app/projects/page.tsx +1 -1
- package/app/query/page.tsx +292 -0
- package/app/tools/page.tsx +1 -1
- package/components/charts/rank-bar-chart-lazy.tsx +9 -0
- package/components/charts/skeleton.tsx +16 -0
- package/components/charts/trend-chart.tsx +34 -2
- package/components/charts/trend-section-lazy.tsx +9 -0
- package/components/charts/trend-section.tsx +14 -2
- package/components/overview/anomalies-panel.tsx +116 -0
- package/components/overview/current-mix-panel.tsx +1 -1
- package/components/overview/section-skeletons.tsx +35 -0
- package/components/repair/repair-items-table.tsx +52 -1
- package/components/sidebar.tsx +2 -0
- package/dist/runtime/agent.mjs +238 -9
- package/dist/runtime/anomalies.mjs +1063 -0
- package/dist/runtime/db-migrate.mjs +21 -0
- package/dist/runtime/db-seed.mjs +61 -12
- package/dist/runtime/digest.mjs +41 -2
- package/dist/runtime/doctor.mjs +97 -19
- package/dist/runtime/evidence.mjs +21 -0
- package/dist/runtime/insights.mjs +41 -2
- package/dist/runtime/mcp.mjs +173 -0
- package/dist/runtime/pricing-refresh.mjs +228 -19
- package/dist/runtime/query.mjs +1104 -0
- package/dist/runtime/repair.mjs +1122 -382
- package/dist/runtime/report.mjs +46 -6
- package/dist/runtime/reset.mjs +61 -12
- package/dist/runtime/review.mjs +41 -2
- package/dist/runtime/scan.mjs +353 -117
- package/dist/runtime/status.mjs +21 -0
- package/llms.txt +3 -0
- package/next.config.mjs +28 -5
- package/package.json +1 -1
- package/scripts/anomalies.ts +95 -0
- package/scripts/build-cli-runtime.mjs +2 -0
- package/scripts/doctor.ts +38 -11
- package/scripts/query.ts +47 -0
- package/scripts/repair.ts +128 -0
- package/server.json +2 -2
- package/src/cli/commands.js +20 -0
- package/src/db/client.ts +5 -0
- package/src/db/migrate-core.ts +16 -0
- package/src/db/prepared.ts +17 -0
- package/src/ingestion/discovery.ts +2 -5
- package/src/ingestion/persist.ts +12 -3
- package/src/ingestion/scan-adapters.ts +11 -0
- package/src/ingestion/scan-files.ts +35 -24
- package/src/ingestion/scan.ts +6 -8
- package/src/lib/agent-discovery.ts +48 -0
- package/src/lib/analytics-query-helpers.ts +2 -2
- package/src/lib/anomalies-cli.ts +73 -0
- package/src/lib/anomaly-detection.ts +162 -0
- package/src/lib/cost-recalculation.ts +62 -14
- package/src/lib/doctor-cli.ts +45 -0
- package/src/lib/mcp/tools.ts +78 -0
- package/src/lib/mcp-server.ts +102 -0
- package/src/lib/model-aliases/backfill.ts +130 -0
- package/src/lib/model-aliases/store.ts +156 -0
- package/src/lib/overview-data.ts +140 -46
- package/src/lib/scheduled-scan.ts +4 -4
- package/src/lib/source-catalog.ts +5 -3
- package/src/lib/structured-query-cli.ts +172 -0
- package/src/lib/structured-query.ts +278 -0
- package/src/lib/unknown-cost-repair/auto-classify-cli.ts +238 -0
- package/src/lib/unknown-cost-repair/auto-classify.ts +175 -0
- package/src/lib/unknown-cost-repair/suggestions.ts +2 -2
- package/src/lib/unknown-cost-repair/types.ts +2 -0
- package/src/lib/unknown-cost-repair/workbench.ts +23 -5
- package/tsconfig.json +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,116 @@ 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
|
+
|
|
59
|
+
## [0.17.0] - 2026-05-23
|
|
60
|
+
|
|
61
|
+
### Performance
|
|
62
|
+
|
|
63
|
+
- **Runtime SQLite pragmas tuned for analytics.** `src/db/client.ts` now
|
|
64
|
+
re-applies `journal_mode=WAL` on the live connection and sets
|
|
65
|
+
`synchronous=NORMAL`, `temp_store=MEMORY`, `cache_size=64MB`, and
|
|
66
|
+
`mmap_size=256MB`. Previously only `busy_timeout` and `foreign_keys`
|
|
67
|
+
were set on the runtime connection.
|
|
68
|
+
- **Prepared-statement cache.** `src/db/prepared.ts` adds a tiny
|
|
69
|
+
`prepareCached(sql)` helper keyed by SQL string. The hot analytics,
|
|
70
|
+
unknown-cost-repair, scheduled-scan, and ingestion helpers now skip
|
|
71
|
+
the parse-and-plan cost on repeat queries.
|
|
72
|
+
- **Overview page parallelization.** The independent sub-queries in
|
|
73
|
+
`getOverviewData` (analytics, accounting invariants, scan diff,
|
|
74
|
+
default search roots, repair workbench) now run through `Promise.all`,
|
|
75
|
+
so the async filesystem walk overlaps with the serialized SQLite
|
|
76
|
+
reads.
|
|
77
|
+
- **Render-scoped overview memo.** `getOverviewPageData` is wrapped in
|
|
78
|
+
`React.cache` so any future composition that calls it twice within a
|
|
79
|
+
single server render tree pays the cost once.
|
|
80
|
+
- **Lazy-loaded Recharts.** `TrendSection` and `RankBarChart` are now
|
|
81
|
+
loaded via `next/dynamic({ ssr: false })` on the overview, projects,
|
|
82
|
+
tools, and models routes, splitting the ~80KB Recharts bundle out of
|
|
83
|
+
the initial JS payload. `ChartSkeleton` keeps the chart slot from
|
|
84
|
+
collapsing during client hydration.
|
|
85
|
+
- **Streaming overview with two `<Suspense>` boundaries.** The overview
|
|
86
|
+
page now splits into two cache-wrapped fetchers — `getOverviewPrimaryData`
|
|
87
|
+
(analytics-driven: pulse, summary, trend, trust, guardrails,
|
|
88
|
+
recommendations, mix) and `getOverviewRepairData` (workbench-driven:
|
|
89
|
+
review status, repair items). Each renders inside its own `<Suspense>`
|
|
90
|
+
so the page shell and period filter paint immediately, the primary
|
|
91
|
+
analytics block streams in next, and the repair lane streams in
|
|
92
|
+
independently when the workbench query resolves.
|
|
93
|
+
- **`tokentrace doctor --timings`.** New flag force-enables analytics
|
|
94
|
+
timing capture and emits the analytics timing report (slow queries,
|
|
95
|
+
threshold, sample list). Combine with `--json` for machine-readable
|
|
96
|
+
output. Useful for before/after measurement of performance changes
|
|
97
|
+
since `TOKENTRACE_ANALYTICS_TIMING` is off by default in production.
|
|
98
|
+
- **Scan ingestion throughput.** Adds a (path, size, mtime)-keyed
|
|
99
|
+
file-hash cache so rescans of unchanged files skip the `fs.readFile`
|
|
100
|
+
+ SHA-256 step entirely. The hot scan-side `INSERT INTO scan_files`,
|
|
101
|
+
`INSERT INTO scan_runs`, and `hasImportedFile` lookups also route
|
|
102
|
+
through `prepareCached`.
|
|
103
|
+
- **Next bundle optimizations.** Enables `optimizePackageImports` for
|
|
104
|
+
`lucide-react` (~37 import sites) and `recharts` so Next 16 transforms
|
|
105
|
+
named imports into per-symbol imports and prunes unused-symbol weight
|
|
106
|
+
from the client bundle. Adds an opt-in `@next/bundle-analyzer`
|
|
107
|
+
integration gated on `ANALYZE=true` for follow-up audits.
|
|
108
|
+
- **Hot-path bug-hunt fixes.** `source-catalog.summarizeSourceCoverage`
|
|
109
|
+
was O(rows × entries); now uses a pre-built Map for O(1) parser tier
|
|
110
|
+
lookups. `ingestion/discovery.getDefaultSearchRoots` parallelizes its
|
|
111
|
+
`fs.access` checks via `Promise.all` instead of awaiting them
|
|
112
|
+
sequentially. `ingestion/persist.findProjectRoot` memoizes the
|
|
113
|
+
resolved project root by start directory, eliminating duplicate
|
|
114
|
+
filesystem walks when many imported sessions share the same source
|
|
115
|
+
directory.
|
|
116
|
+
|
|
7
117
|
## [0.16.0] - 2026-05-23
|
|
8
118
|
|
|
9
119
|
### Added
|
package/TOKENTRACE_AGENT.md
CHANGED
|
@@ -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/models/page.tsx
CHANGED
|
@@ -3,7 +3,7 @@ import { EmptyState } from "@/components/empty-state";
|
|
|
3
3
|
import { ScanNowButton } from "@/components/scan-now-button";
|
|
4
4
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
5
5
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
|
6
|
-
import { RankBarChart } from "@/components/charts/rank-bar-chart";
|
|
6
|
+
import { RankBarChart } from "@/components/charts/rank-bar-chart-lazy";
|
|
7
7
|
import { PageHeader } from "@/components/ui/typography";
|
|
8
8
|
import { getAnalyticsData } from "@/src/lib/analytics";
|
|
9
9
|
import { formatCurrency, formatTokens } from "@/src/lib/format";
|
package/app/page.tsx
CHANGED
|
@@ -1,12 +1,20 @@
|
|
|
1
|
+
import { Suspense } from "react";
|
|
1
2
|
import Link from "next/link";
|
|
2
3
|
import { ArrowRight } from "lucide-react";
|
|
3
|
-
import { TrendSection } from "@/components/charts/trend-section";
|
|
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";
|
|
4
8
|
import { OverviewCurrentMixPanel } from "@/components/overview/current-mix-panel";
|
|
5
9
|
import { DataConfidenceStrip } from "@/components/overview/data-confidence-strip";
|
|
6
10
|
import { FirstRunPanel } from "@/components/overview/first-run-panel";
|
|
7
11
|
import { UsageGuardrailsPanel } from "@/components/overview/guardrails-panel";
|
|
8
12
|
import { OverviewRecommendationsCard } from "@/components/overview/recommendations-card";
|
|
9
13
|
import { OverviewReviewStatusStrip } from "@/components/overview/review-status-strip";
|
|
14
|
+
import {
|
|
15
|
+
OverviewPrimarySkeleton,
|
|
16
|
+
OverviewRepairSkeleton
|
|
17
|
+
} from "@/components/overview/section-skeletons";
|
|
10
18
|
import { CostSessionsCard, TokenAccountingCard } from "@/components/overview/summary-cards";
|
|
11
19
|
import { TopRepairItemsStrip } from "@/components/overview/top-repair-items-strip";
|
|
12
20
|
import { OverviewTrustFooter } from "@/components/overview/trust-footer";
|
|
@@ -14,9 +22,9 @@ import { UsagePulsePanel } from "@/components/overview/usage-pulse-panel";
|
|
|
14
22
|
import { PeriodFilter } from "@/components/period-filter";
|
|
15
23
|
import { Button } from "@/components/ui/button";
|
|
16
24
|
import { PageHeader } from "@/components/ui/typography";
|
|
17
|
-
import { resolveDateRange } from "@/src/lib/date-range";
|
|
25
|
+
import { mergeHrefParams, type ResolvedDateRange, resolveDateRange } from "@/src/lib/date-range";
|
|
18
26
|
import { runDueScheduledScan } from "@/src/lib/scheduled-scan";
|
|
19
|
-
import {
|
|
27
|
+
import { getOverviewPrimaryData, getOverviewRepairData } from "@/src/lib/overview-data";
|
|
20
28
|
|
|
21
29
|
export const dynamic = "force-dynamic";
|
|
22
30
|
|
|
@@ -24,51 +32,31 @@ type OverviewPageProps = {
|
|
|
24
32
|
searchParams?: Promise<Record<string, string | string[] | undefined>>;
|
|
25
33
|
};
|
|
26
34
|
|
|
27
|
-
|
|
28
|
-
void runDueScheduledScan().catch(() => undefined);
|
|
29
|
-
const params = (await searchParams) ?? {};
|
|
30
|
-
const range = resolveDateRange(params);
|
|
31
|
-
const overview = await getOverviewPageData(range);
|
|
35
|
+
async function OverviewPrimarySection({ range }: { range: ResolvedDateRange }) {
|
|
32
36
|
const {
|
|
33
37
|
data,
|
|
34
38
|
trust,
|
|
35
|
-
accountingReport,
|
|
36
|
-
postSessionReview,
|
|
37
|
-
rangeLinkParams,
|
|
38
39
|
evidenceLinks,
|
|
39
|
-
doctorReport,
|
|
40
|
-
repairWorkbench,
|
|
41
|
-
repairFocusHref,
|
|
42
|
-
unknownCostEvidenceHref,
|
|
43
40
|
firstRunStatus,
|
|
44
41
|
summary,
|
|
45
|
-
trendDefaultWindow
|
|
46
|
-
|
|
42
|
+
trendDefaultWindow,
|
|
43
|
+
unknownCostEvidenceHref,
|
|
44
|
+
rangeLinkParams
|
|
45
|
+
} = await getOverviewPrimaryData(range);
|
|
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 }));
|
|
47
54
|
|
|
48
55
|
return (
|
|
49
56
|
<div className="space-y-8">
|
|
50
|
-
<
|
|
51
|
-
title="Overview"
|
|
52
|
-
description="Local token, cost, model, and session analytics across AI CLI tools."
|
|
53
|
-
actions={
|
|
54
|
-
<Button asChild>
|
|
55
|
-
<Link href="/settings#scan-controls">
|
|
56
|
-
Configure scan <ArrowRight className="h-4 w-4" />
|
|
57
|
-
</Link>
|
|
58
|
-
</Button>
|
|
59
|
-
}
|
|
60
|
-
/>
|
|
61
|
-
|
|
62
|
-
<PeriodFilter range={range} />
|
|
63
|
-
|
|
64
|
-
{summary.interactions === 0 ? (
|
|
65
|
-
<FirstRunPanel status={firstRunStatus} />
|
|
66
|
-
) : null}
|
|
67
|
-
|
|
57
|
+
{summary.interactions === 0 ? <FirstRunPanel status={firstRunStatus} /> : null}
|
|
68
58
|
<UsagePulsePanel comparison={data.comparison} />
|
|
69
|
-
|
|
70
59
|
<DataConfidenceStrip confidence={data.dataConfidence} />
|
|
71
|
-
|
|
72
60
|
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-6">
|
|
73
61
|
<TokenAccountingCard
|
|
74
62
|
summary={summary}
|
|
@@ -84,11 +72,44 @@ export default async function OverviewPage({ searchParams }: OverviewPageProps)
|
|
|
84
72
|
sessionsHref={evidenceLinks.sessions}
|
|
85
73
|
/>
|
|
86
74
|
</div>
|
|
87
|
-
|
|
88
75
|
<OverviewTrustFooter health={trust.health} pricedModelCount={trust.pricedModelCount} />
|
|
76
|
+
<TrendSection
|
|
77
|
+
data={data.trends}
|
|
78
|
+
defaultWindow={trendDefaultWindow}
|
|
79
|
+
tokenMarkers={tokenMarkers}
|
|
80
|
+
costMarkers={costMarkers}
|
|
81
|
+
/>
|
|
82
|
+
<OverviewAnomaliesPanel trends={data.trends} />
|
|
83
|
+
<UsageGuardrailsPanel progress={data.usageGuardrails} />
|
|
84
|
+
<OverviewRecommendationsCard recommendations={data.recommendations} />
|
|
85
|
+
<OverviewCurrentMixPanel
|
|
86
|
+
tools={data.tools}
|
|
87
|
+
mostUsedTool={summary.mostUsedTool}
|
|
88
|
+
mostUsedModel={summary.mostUsedModel}
|
|
89
|
+
/>
|
|
90
|
+
</div>
|
|
91
|
+
);
|
|
92
|
+
}
|
|
89
93
|
|
|
90
|
-
|
|
94
|
+
async function OverviewRepairSection({ range }: { range: ResolvedDateRange }) {
|
|
95
|
+
const {
|
|
96
|
+
accountingReport,
|
|
97
|
+
postSessionReview,
|
|
98
|
+
doctorReport,
|
|
99
|
+
repairWorkbench,
|
|
100
|
+
repairFocusHref,
|
|
101
|
+
evidenceLinks,
|
|
102
|
+
summary,
|
|
103
|
+
trust,
|
|
104
|
+
rangeLinkParams
|
|
105
|
+
} = await getOverviewRepairData(range);
|
|
106
|
+
|
|
107
|
+
if (summary.interactions === 0 && repairWorkbench.groups.length === 0) {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
91
110
|
|
|
111
|
+
return (
|
|
112
|
+
<div className="space-y-8">
|
|
92
113
|
{summary.interactions > 0 ? (
|
|
93
114
|
<OverviewReviewStatusStrip
|
|
94
115
|
report={doctorReport}
|
|
@@ -102,7 +123,6 @@ export default async function OverviewPage({ searchParams }: OverviewPageProps)
|
|
|
102
123
|
cachedEvidenceHref={evidenceLinks["cached-tokens"]}
|
|
103
124
|
/>
|
|
104
125
|
) : null}
|
|
105
|
-
|
|
106
126
|
{repairWorkbench.groups.length ? (
|
|
107
127
|
<TopRepairItemsStrip
|
|
108
128
|
groups={repairWorkbench.groups}
|
|
@@ -110,17 +130,38 @@ export default async function OverviewPage({ searchParams }: OverviewPageProps)
|
|
|
110
130
|
rangeLinkParams={rangeLinkParams}
|
|
111
131
|
/>
|
|
112
132
|
) : null}
|
|
133
|
+
</div>
|
|
134
|
+
);
|
|
135
|
+
}
|
|
113
136
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
137
|
+
export default async function OverviewPage({ searchParams }: OverviewPageProps) {
|
|
138
|
+
void runDueScheduledScan().catch(() => undefined);
|
|
139
|
+
const params = (await searchParams) ?? {};
|
|
140
|
+
const range = resolveDateRange(params);
|
|
117
141
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
142
|
+
return (
|
|
143
|
+
<div className="space-y-8">
|
|
144
|
+
<PageHeader
|
|
145
|
+
title="Overview"
|
|
146
|
+
description="Local token, cost, model, and session analytics across AI CLI tools."
|
|
147
|
+
actions={
|
|
148
|
+
<Button asChild>
|
|
149
|
+
<Link href="/settings#scan-controls">
|
|
150
|
+
Configure scan <ArrowRight className="h-4 w-4" />
|
|
151
|
+
</Link>
|
|
152
|
+
</Button>
|
|
153
|
+
}
|
|
122
154
|
/>
|
|
123
155
|
|
|
156
|
+
<PeriodFilter range={range} />
|
|
157
|
+
|
|
158
|
+
<Suspense fallback={<OverviewPrimarySkeleton />}>
|
|
159
|
+
<OverviewPrimarySection range={range} />
|
|
160
|
+
</Suspense>
|
|
161
|
+
|
|
162
|
+
<Suspense fallback={<OverviewRepairSkeleton />}>
|
|
163
|
+
<OverviewRepairSection range={range} />
|
|
164
|
+
</Suspense>
|
|
124
165
|
</div>
|
|
125
166
|
);
|
|
126
167
|
}
|
package/app/projects/page.tsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import Link from "next/link";
|
|
2
|
-
import { RankBarChart } from "@/components/charts/rank-bar-chart";
|
|
2
|
+
import { RankBarChart } from "@/components/charts/rank-bar-chart-lazy";
|
|
3
3
|
import { TrendChart } from "@/components/charts/trend-chart";
|
|
4
4
|
import { EmptyState } from "@/components/empty-state";
|
|
5
5
|
import { ScanNowButton } from "@/components/scan-now-button";
|
|
@@ -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
|
+
}
|
package/app/tools/page.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { RankBarChart } from "@/components/charts/rank-bar-chart";
|
|
1
|
+
import { RankBarChart } from "@/components/charts/rank-bar-chart-lazy";
|
|
2
2
|
import { EmptyState } from "@/components/empty-state";
|
|
3
3
|
import { ScanNowButton } from "@/components/scan-now-button";
|
|
4
4
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|