tokentrace 0.7.0 → 0.8.1

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 (81) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/README.md +12 -30
  3. package/app/api/prices/refresh/route.ts +6 -1
  4. package/app/api/prices/route.ts +36 -13
  5. package/app/api/repair-items/route.ts +86 -0
  6. package/app/api/scan/route.ts +6 -1
  7. package/app/api/settings/route.ts +6 -1
  8. package/app/diagnostics/page.tsx +140 -1
  9. package/app/evidence/page.tsx +170 -0
  10. package/app/globals.css +9 -3
  11. package/app/layout.tsx +3 -2
  12. package/app/page.tsx +50 -39
  13. package/app/repair/page.tsx +199 -0
  14. package/bin/tokentrace.js +28 -4
  15. package/components/period-filter.tsx +29 -10
  16. package/components/repair-state-control.tsx +109 -0
  17. package/components/ui/typography.tsx +3 -3
  18. package/dist/runtime/db-migrate.mjs +74 -1
  19. package/dist/runtime/db-seed.mjs +74 -1
  20. package/dist/runtime/digest.mjs +552 -285
  21. package/dist/runtime/doctor.mjs +2489 -384
  22. package/dist/runtime/evidence.mjs +781 -0
  23. package/dist/runtime/insights.mjs +826 -263
  24. package/dist/runtime/pricing-refresh.mjs +1162 -967
  25. package/dist/runtime/repair.mjs +863 -0
  26. package/dist/runtime/reset.mjs +74 -1
  27. package/dist/runtime/scan.mjs +1261 -702
  28. package/dist/runtime/status.mjs +842 -542
  29. package/docs/assets/doctor-parser-trust-0.8.0.png +0 -0
  30. package/docs/assets/evidence-0.8.0.png +0 -0
  31. package/docs/assets/overview-0.8.0.png +0 -0
  32. package/docs/assets/repair-0.8.0.png +0 -0
  33. package/package.json +2 -2
  34. package/scripts/build-cli-runtime.mjs +2 -0
  35. package/scripts/digest.ts +21 -3
  36. package/scripts/doctor.ts +25 -5
  37. package/scripts/evidence.ts +92 -0
  38. package/scripts/insights.ts +18 -2
  39. package/scripts/pricing-refresh.ts +25 -9
  40. package/scripts/repair.ts +57 -0
  41. package/scripts/scan.ts +21 -8
  42. package/scripts/smoke-cli.mjs +60 -0
  43. package/scripts/status.ts +37 -43
  44. package/src/db/migrate-core.ts +76 -0
  45. package/src/db/schema.ts +16 -0
  46. package/src/ingestion/adapters/claude-code.ts +51 -20
  47. package/src/ingestion/adapters/codex-cli.ts +203 -17
  48. package/src/ingestion/adapters/generic-json.ts +1 -1
  49. package/src/ingestion/adapters/generic-jsonl.ts +1 -1
  50. package/src/ingestion/adapters/generic-log.ts +60 -10
  51. package/src/ingestion/adapters/helpers.ts +107 -42
  52. package/src/ingestion/discovery.ts +11 -2
  53. package/src/ingestion/persist.ts +43 -4
  54. package/src/ingestion/scan.ts +45 -18
  55. package/src/lib/analytics.ts +38 -22
  56. package/src/lib/api-json.ts +21 -0
  57. package/src/lib/claude-statusline.ts +202 -0
  58. package/src/lib/doctor.ts +19 -3
  59. package/src/lib/evidence-trail.ts +309 -0
  60. package/src/lib/live-status.ts +7 -205
  61. package/src/lib/parser-trust.ts +188 -0
  62. package/src/lib/pricing-refresh-cli.ts +57 -0
  63. package/src/lib/report-cli.ts +36 -0
  64. package/src/lib/scan-cli.ts +50 -0
  65. package/src/lib/scan-diff.ts +204 -0
  66. package/src/lib/status-cli.ts +150 -0
  67. package/src/lib/unknown-cost-repair.ts +532 -0
  68. package/docs/assets/diagnostics.png +0 -0
  69. package/docs/assets/discovery.png +0 -0
  70. package/docs/assets/doctor-0.6.0.png +0 -0
  71. package/docs/assets/mobile-overview.png +0 -0
  72. package/docs/assets/overview-0.6.0.png +0 -0
  73. package/docs/assets/overview-0.7.0.png +0 -0
  74. package/docs/assets/overview.png +0 -0
  75. package/docs/assets/pricing.png +0 -0
  76. package/docs/assets/projects-0.7.0.png +0 -0
  77. package/docs/assets/session-explorer.png +0 -0
  78. package/docs/assets/sessions-0.7.0.png +0 -0
  79. package/docs/assets/settings-guardrails-0.7.0.png +0 -0
  80. package/docs/assets/settings-package-trust-0.6.0.png +0 -0
  81. package/docs/assets/usage-intelligence-0.7.0.png +0 -0
package/CHANGELOG.md CHANGED
@@ -2,6 +2,42 @@
2
2
 
3
3
  All notable changes to TokenTrace are documented here.
4
4
 
5
+ ## [0.8.1] - 2026-05-13
6
+
7
+ ### Fixed
8
+
9
+ - Codex CLI imports now read exact `token_count` totals, including cached input and reasoning output tokens, so cleared Codex sessions are no longer undercounted.
10
+ - Claude Code and Codex session artifacts can exceed the generic file-size cap without being skipped during discovery.
11
+ - OpenAI-style, Claude-style, and generic usage parsers now normalize cached, cache-write, and reasoning token fields without double-counting them.
12
+ - Parser-version-aware rescans now reprocess stale imports, and source-file replacement is atomic so a failed replacement cannot delete prior trusted sessions.
13
+ - Unknown-cost cause summaries now assign each interaction to one primary cause instead of overlapping buckets.
14
+ - Pricing, scan, settings, repair, and pricing-refresh APIs now reject malformed JSON with clean 400 responses.
15
+ - Manual pricing saves now reject blank model/provider names and invalid numeric prices instead of silently storing unknown prices.
16
+ - CLI commands now reject unknown flags across scan, pricing refresh, status, doctor, digest, and insights commands.
17
+ - Local release checks now run ProjScan through `projscan@latest`, matching the GitHub security workflow guardrail.
18
+
19
+ ## [0.8.0] - 2026-05-12
20
+
21
+ ### Added
22
+
23
+ - Evidence detail pages and `tokentrace evidence --json` for tracing metric totals back to sessions, source files, parser status, and pricing context.
24
+ - Unknown Cost Repair workbench and `tokentrace repair --json` for grouped local repair state, alias hints, parser review links, and pricing follow-up.
25
+ - Parser Trust Report and Scan History Diff panels in Diagnostics for latest scan parser coverage and scan-to-scan import changes.
26
+
27
+ ### Changed
28
+
29
+ - Overview metric cards now link major totals to evidence trails and route unknown cost work to Unknown Cost Repair.
30
+ - Overview now places Token Trend and Cost Trend directly after Usage Pulse and the metric cards, with Monthly Guardrails and Recommended Next Actions below the charts.
31
+ - Dense evidence, repair, parser trust, and scan diff tables preserve horizontal scrolling and stable source-path truncation.
32
+ - Evidence and repair copy now states local-first behavior, support-file ignores, and parser-review requirements for unsupported files.
33
+ - README screenshots now use public-safe synthetic Evidence + Repair views, and obsolete screenshot assets were removed from the package payload.
34
+
35
+ ### Fixed
36
+
37
+ - Overview custom period date fields now use an intentionally inset calendar icon while preserving native `type="date"` submission fields and the single-line desktop toolbar.
38
+ - The app shell now constrains page width on small screens so wide toolbars scroll internally instead of widening the whole page.
39
+ - `tokentrace statusline setup claude` and piped Claude status-line input no longer touch the TokenTrace app database or start the dashboard.
40
+
5
41
  ## [0.7.0] - 2026-05-12
6
42
 
7
43
  ### Added
package/README.md CHANGED
@@ -8,7 +8,7 @@ Local-first analytics for AI CLI usage. TokenTrace scans local CLI logs, normali
8
8
 
9
9
  TokenTrace is designed for local development machines first, with macOS-oriented defaults. It does not require a cloud account and does not send telemetry or logs anywhere.
10
10
 
11
- ![TokenTrace 0.7.0 overview dashboard](docs/assets/overview-0.7.0.png)
11
+ ![TokenTrace overview dashboard](docs/assets/overview-0.8.0.png)
12
12
 
13
13
  ## Start In Seconds
14
14
 
@@ -37,6 +37,10 @@ tokentrace serve --port 3210 --no-open
37
37
  tokentrace scan # Scan local AI CLI usage logs
38
38
  tokentrace doctor --json
39
39
  # Inspect scan health and repair recommendations
40
+ tokentrace evidence --json
41
+ # Print metric evidence trails as JSON
42
+ tokentrace repair --json
43
+ # Print unknown-cost repair groups as JSON
40
44
  tokentrace digest --json
41
45
  # Print current-month local usage digest
42
46
  tokentrace insights --json
@@ -184,6 +188,8 @@ tokentrace statusline claude
184
188
 
185
189
  Claude Code sends session JSON to the command on stdin. TokenTrace reads the transcript path, model, context usage, and session cost, then prints one compact local line:
186
190
 
191
+ Do not set the Claude Code `statusLine.command` to plain `tokentrace`. Plain `tokentrace` starts the dashboard, while `tokentrace statusline claude` prints exactly one status-line response.
192
+
187
193
  ![TokenTrace Claude Code status line](docs/assets/claude-statusline.svg)
188
194
 
189
195
  You can also inspect the same local status outside Claude Code:
@@ -197,21 +203,15 @@ Codex CLI status-line integration is intentionally deferred until its status-lin
197
203
 
198
204
  ## Screenshots
199
205
 
200
- Usage Intelligence views from `0.7.0`:
201
-
202
- ![TokenTrace 0.7.0 overview dashboard](docs/assets/overview-0.7.0.png)
203
-
204
- ![TokenTrace 0.7.0 Usage Intelligence review queue](docs/assets/usage-intelligence-0.7.0.png)
205
-
206
- ![TokenTrace 0.7.0 session comparison flags](docs/assets/sessions-0.7.0.png)
206
+ Evidence + Repair views:
207
207
 
208
- ![TokenTrace 0.7.0 project signals](docs/assets/projects-0.7.0.png)
208
+ ![TokenTrace overview dashboard](docs/assets/overview-0.8.0.png)
209
209
 
210
- ![TokenTrace 0.7.0 local guardrail settings](docs/assets/settings-guardrails-0.7.0.png)
210
+ ![TokenTrace processed tokens evidence trail](docs/assets/evidence-0.8.0.png)
211
211
 
212
- Stable Daily Tool views from `0.6.0`:
212
+ ![TokenTrace unknown cost repair queue](docs/assets/repair-0.8.0.png)
213
213
 
214
- ![TokenTrace 0.6.0 Scan Doctor](docs/assets/doctor-0.6.0.png)
214
+ ![TokenTrace Scan Doctor parser trust report](docs/assets/doctor-parser-trust-0.8.0.png)
215
215
 
216
216
  CLI startup and help:
217
217
 
@@ -225,24 +225,6 @@ Optional wrapper diagnostics:
225
225
 
226
226
  ![TokenTrace wrapper command](docs/assets/cli-wrapper.gif)
227
227
 
228
- Session exploration:
229
-
230
- ![TokenTrace session explorer](docs/assets/session-explorer.png)
231
-
232
- Scan Doctor and file discovery:
233
-
234
- ![TokenTrace ingestion diagnostics](docs/assets/diagnostics.png)
235
-
236
- ![TokenTrace file discovery](docs/assets/discovery.png)
237
-
238
- Editable model pricing:
239
-
240
- ![TokenTrace pricing configuration](docs/assets/pricing.png)
241
-
242
- Mobile overview:
243
-
244
- ![TokenTrace mobile overview](docs/assets/mobile-overview.png)
245
-
246
228
  ## Privacy Model
247
229
 
248
230
  - All processing runs locally on your machine.
@@ -1,10 +1,15 @@
1
1
  import { NextResponse } from "next/server";
2
+ import { readJsonObject } from "@/src/lib/api-json";
2
3
  import { refreshPricing } from "@/src/lib/pricing-refresh";
3
4
 
4
5
  export const dynamic = "force-dynamic";
5
6
 
6
7
  export async function POST(request: Request) {
7
- const body = await request.json().catch(() => ({}));
8
+ const parsed = await readJsonObject(request);
9
+ if (!parsed.ok) {
10
+ return NextResponse.json({ error: parsed.error }, { status: 400 });
11
+ }
12
+ const body = parsed.body;
8
13
  const result = await refreshPricing({
9
14
  source: body?.source === "bundled" ? "bundled" : "remote",
10
15
  force: Boolean(body?.force)
@@ -1,12 +1,21 @@
1
1
  import { NextResponse } from "next/server";
2
+ import { readJsonObject } from "@/src/lib/api-json";
2
3
  import { getPricingRows, upsertPricing } from "@/src/lib/pricing";
3
4
 
4
5
  export const dynamic = "force-dynamic";
5
6
 
6
- function nullableNumber(value: unknown) {
7
- if (value === "" || value == null) return null;
7
+ function requiredText(value: unknown) {
8
+ return typeof value === "string" ? value.trim() : "";
9
+ }
10
+
11
+ function nullablePrice(value: unknown, field: string) {
12
+ if (value == null) return { ok: true as const, value: null };
13
+ if (typeof value === "string" && value.trim() === "") return { ok: true as const, value: null };
8
14
  const number = Number(value);
9
- return Number.isFinite(number) ? number : null;
15
+ if (!Number.isFinite(number) || number < 0) {
16
+ return { ok: false as const, error: `${field} must be a non-negative number or empty` };
17
+ }
18
+ return { ok: true as const, value: number };
10
19
  }
11
20
 
12
21
  export async function GET() {
@@ -14,20 +23,34 @@ export async function GET() {
14
23
  }
15
24
 
16
25
  export async function POST(request: Request) {
17
- const body = await request.json();
18
- if (!body.providerId || !body.model) {
26
+ const parsed = await readJsonObject(request);
27
+ if (!parsed.ok) {
28
+ return NextResponse.json({ error: parsed.error }, { status: 400 });
29
+ }
30
+ const body = parsed.body;
31
+ const providerId = requiredText(body.providerId);
32
+ const model = requiredText(body.model);
33
+ if (!providerId || !model) {
19
34
  return NextResponse.json({ error: "providerId and model are required" }, { status: 400 });
20
35
  }
36
+ const inputTokenPrice = nullablePrice(body.inputTokenPrice, "inputTokenPrice");
37
+ const outputTokenPrice = nullablePrice(body.outputTokenPrice, "outputTokenPrice");
38
+ const cachedInputTokenPrice = nullablePrice(body.cachedInputTokenPrice, "cachedInputTokenPrice");
39
+ const cacheWriteTokenPrice = nullablePrice(body.cacheWriteTokenPrice, "cacheWriteTokenPrice");
40
+ if (!inputTokenPrice.ok) return NextResponse.json({ error: inputTokenPrice.error }, { status: 400 });
41
+ if (!outputTokenPrice.ok) return NextResponse.json({ error: outputTokenPrice.error }, { status: 400 });
42
+ if (!cachedInputTokenPrice.ok) return NextResponse.json({ error: cachedInputTokenPrice.error }, { status: 400 });
43
+ if (!cacheWriteTokenPrice.ok) return NextResponse.json({ error: cacheWriteTokenPrice.error }, { status: 400 });
21
44
 
22
45
  const id = upsertPricing({
23
- providerId: String(body.providerId),
24
- providerName: body.providerName ? String(body.providerName) : undefined,
25
- model: String(body.model),
26
- inputTokenPrice: nullableNumber(body.inputTokenPrice),
27
- outputTokenPrice: nullableNumber(body.outputTokenPrice),
28
- cachedInputTokenPrice: nullableNumber(body.cachedInputTokenPrice),
29
- cacheWriteTokenPrice: nullableNumber(body.cacheWriteTokenPrice),
30
- currency: body.currency ? String(body.currency) : "USD"
46
+ providerId,
47
+ providerName: requiredText(body.providerName) || undefined,
48
+ model,
49
+ inputTokenPrice: inputTokenPrice.value,
50
+ outputTokenPrice: outputTokenPrice.value,
51
+ cachedInputTokenPrice: cachedInputTokenPrice.value,
52
+ cacheWriteTokenPrice: cacheWriteTokenPrice.value,
53
+ currency: requiredText(body.currency) || "USD"
31
54
  });
32
55
 
33
56
  return NextResponse.json({ id, costsRecalculated: true });
@@ -0,0 +1,86 @@
1
+ import { NextResponse } from "next/server";
2
+ import {
3
+ buildUnknownCostRepairWorkbench,
4
+ getUnknownCostReview,
5
+ saveUnknownCostReview,
6
+ type UnknownCostRepairStatus
7
+ } from "@/src/lib/unknown-cost-repair";
8
+ import { readJsonObject } from "@/src/lib/api-json";
9
+
10
+ export const dynamic = "force-dynamic";
11
+
12
+ const reviewStates = new Set<UnknownCostRepairStatus>([
13
+ "unresolved",
14
+ "ignored",
15
+ "resolved",
16
+ "needs-parser-review"
17
+ ]);
18
+
19
+ function text(value: unknown, maxLength: number) {
20
+ return typeof value === "string" ? value.trim().slice(0, maxLength) : "";
21
+ }
22
+
23
+ function reviewState(value: unknown): UnknownCostRepairStatus | null {
24
+ return typeof value === "string" && reviewStates.has(value as UnknownCostRepairStatus)
25
+ ? (value as UnknownCostRepairStatus)
26
+ : null;
27
+ }
28
+
29
+ function workbenchGroupForKey(key: string) {
30
+ return buildUnknownCostRepairWorkbench().groups.find((group) => group.key === key) ?? null;
31
+ }
32
+
33
+ export async function GET() {
34
+ return NextResponse.json(buildUnknownCostRepairWorkbench());
35
+ }
36
+
37
+ export async function PUT(request: Request) {
38
+ const parsed = await readJsonObject(request);
39
+ if (!parsed.ok) {
40
+ return NextResponse.json({ error: parsed.error }, { status: 400 });
41
+ }
42
+ const body = parsed.body;
43
+ const key = text(body.key, 1000);
44
+ const status = reviewState(body.status ?? body.state);
45
+
46
+ if (!key) {
47
+ return NextResponse.json({ error: "key is required" }, { status: 400 });
48
+ }
49
+
50
+ if (!status) {
51
+ return NextResponse.json({ error: "status must be unresolved, ignored, resolved, or needs-parser-review" }, { status: 400 });
52
+ }
53
+
54
+ const group = workbenchGroupForKey(key);
55
+ const existing = group ? null : getUnknownCostReview(key);
56
+ if (!group && (!existing || existing.updatedAt == null)) {
57
+ return NextResponse.json({ error: "repair key was not found in current workbench evidence" }, { status: 404 });
58
+ }
59
+
60
+ const metadata = {
61
+ sourceFile: "",
62
+ model: "",
63
+ cause: ""
64
+ };
65
+ if (group) {
66
+ metadata.sourceFile = group.sourceFile;
67
+ metadata.model = group.model;
68
+ metadata.cause = group.cause;
69
+ } else if (existing) {
70
+ metadata.sourceFile = existing.sourceFile;
71
+ metadata.model = existing.model;
72
+ metadata.cause = existing.cause;
73
+ }
74
+ const review = saveUnknownCostReview({
75
+ key,
76
+ status,
77
+ notes: text(body.notes ?? body.note, 500),
78
+ sourceFile: metadata.sourceFile,
79
+ model: metadata.model,
80
+ cause: metadata.cause
81
+ });
82
+
83
+ return NextResponse.json({ review });
84
+ }
85
+
86
+ export const PATCH = PUT;
@@ -1,10 +1,15 @@
1
1
  import { NextResponse } from "next/server";
2
2
  import { runScan } from "@/src/ingestion/scan";
3
+ import { readJsonObject } from "@/src/lib/api-json";
3
4
 
4
5
  export const dynamic = "force-dynamic";
5
6
 
6
7
  export async function POST(request: Request) {
7
- const body = await request.json().catch(() => ({}));
8
+ const parsed = await readJsonObject(request);
9
+ if (!parsed.ok) {
10
+ return NextResponse.json({ error: parsed.error }, { status: 400 });
11
+ }
12
+ const body = parsed.body;
8
13
  const result = await runScan({
9
14
  folders: Array.isArray(body.folders)
10
15
  ? body.folders.filter((folder: unknown): folder is string => typeof folder === "string")
@@ -1,6 +1,7 @@
1
1
  import { NextResponse } from "next/server";
2
2
  import { getDatabasePath } from "@/src/db/client";
3
3
  import { getAppSettings, normalizeUsageGuardrails, saveAppSettings } from "@/src/db/settings";
4
+ import { readJsonObject } from "@/src/lib/api-json";
4
5
 
5
6
  export const dynamic = "force-dynamic";
6
7
 
@@ -12,7 +13,11 @@ export async function GET() {
12
13
  }
13
14
 
14
15
  export async function PUT(request: Request) {
15
- const body = await request.json();
16
+ const parsed = await readJsonObject(request);
17
+ if (!parsed.ok) {
18
+ return NextResponse.json({ error: parsed.error }, { status: 400 });
19
+ }
20
+ const body = parsed.body;
16
21
  const customFolders = Array.isArray(body.customFolders)
17
22
  ? body.customFolders.filter((folder: unknown): folder is string => typeof folder === "string")
18
23
  : [];
@@ -2,6 +2,7 @@ import Link from "next/link";
2
2
  import { AlertTriangle, ArrowRight, CheckCircle2, CircleDashed } from "lucide-react";
3
3
  import { Badge } from "@/components/ui/badge";
4
4
  import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
5
+ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
5
6
  import { DataValue, FieldLabel, MonoText, PageHeader } from "@/components/ui/typography";
6
7
  import { ScanHealthSummary } from "@/components/scan-health-summary";
7
8
  import { getAnalyticsData, getScanTrustData, type DebugScanRun } from "@/src/lib/analytics";
@@ -136,6 +137,7 @@ function TrustChecklist({
136
137
  function DoctorReportPanel({ report }: { report: DoctorReport }) {
137
138
  const statusRows = [
138
139
  ["Imported", report.fileStatus.imported],
140
+ ["With errors", report.fileStatus.importedWithErrors],
139
141
  ["Duplicates", report.fileStatus.duplicates],
140
142
  ["Ignored", report.fileStatus.ignored],
141
143
  ["Unsupported", report.fileStatus.unsupported],
@@ -192,7 +194,7 @@ function DoctorReportPanel({ report }: { report: DoctorReport }) {
192
194
  <div className="grid gap-6 xl:grid-cols-[1fr_1fr]">
193
195
  <div className="space-y-3">
194
196
  <div className="mb-3 text-sm font-semibold">File handling</div>
195
- <div className="grid border-y sm:grid-cols-5 sm:divide-x xl:grid-cols-2">
197
+ <div className="grid border-y sm:grid-cols-6 sm:divide-x xl:grid-cols-2">
196
198
  {statusRows.map(([label, value]) => (
197
199
  <div key={label} className="p-2">
198
200
  <FieldLabel>{label}</FieldLabel>
@@ -261,6 +263,139 @@ function DoctorReportPanel({ report }: { report: DoctorReport }) {
261
263
  );
262
264
  }
263
265
 
266
+ function ParserTrustPanel({ report }: { report: DoctorReport["parserTrust"] }) {
267
+ return (
268
+ <Card>
269
+ <CardHeader>
270
+ <CardTitle>Parser Trust Report</CardTitle>
271
+ <CardDescription>
272
+ Latest scan files grouped by parser, source family, version, status, and import yield. Ignored files are known support files, not usage transcripts. Unsupported files need parser review before they become usage.
273
+ </CardDescription>
274
+ </CardHeader>
275
+ <CardContent className="table-scroll">
276
+ {report.parsers.length ? (
277
+ <Table className="min-w-[72rem]">
278
+ <TableHeader>
279
+ <TableRow>
280
+ <TableHead>Parser</TableHead>
281
+ <TableHead>Version</TableHead>
282
+ <TableHead>Source</TableHead>
283
+ <TableHead className="text-right">Imported</TableHead>
284
+ <TableHead className="text-right">With errors</TableHead>
285
+ <TableHead className="text-right">Ignored</TableHead>
286
+ <TableHead className="text-right">Unsupported</TableHead>
287
+ <TableHead className="text-right">Failed</TableHead>
288
+ <TableHead className="text-right">Duplicate</TableHead>
289
+ <TableHead className="text-right">Records</TableHead>
290
+ <TableHead className="min-w-56">Latest reason</TableHead>
291
+ </TableRow>
292
+ </TableHeader>
293
+ <TableBody>
294
+ {report.parsers.map((row) => (
295
+ <TableRow key={`${row.parser}:${row.version}:${row.sourceFamily}`}>
296
+ <TableCell className="font-medium">{row.parser}</TableCell>
297
+ <TableCell>
298
+ <Badge variant="secondary">{row.version}</Badge>
299
+ </TableCell>
300
+ <TableCell>{row.sourceFamily}</TableCell>
301
+ <TableCell className="text-right">{row.imported.toLocaleString()}</TableCell>
302
+ <TableCell className="text-right">{row.importedWithErrors.toLocaleString()}</TableCell>
303
+ <TableCell className="text-right">{row.ignored.toLocaleString()}</TableCell>
304
+ <TableCell className="text-right">{row.unsupported.toLocaleString()}</TableCell>
305
+ <TableCell className="text-right">{row.failed.toLocaleString()}</TableCell>
306
+ <TableCell className="text-right">{row.duplicate.toLocaleString()}</TableCell>
307
+ <TableCell className="text-right">{row.recordsImported.toLocaleString()}</TableCell>
308
+ <TableCell className="max-w-md text-xs text-muted-foreground">
309
+ {row.latestReason || "No parser reason recorded."}
310
+ </TableCell>
311
+ </TableRow>
312
+ ))}
313
+ </TableBody>
314
+ </Table>
315
+ ) : (
316
+ <div className="px-4 py-6 text-sm text-muted-foreground">
317
+ No parser trust data yet. Run `tokentrace scan` to populate the latest scan report.
318
+ </div>
319
+ )}
320
+ </CardContent>
321
+ </Card>
322
+ );
323
+ }
324
+
325
+ function formatDelta(value: number) {
326
+ if (value > 0) return `+${value.toLocaleString()}`;
327
+ return value.toLocaleString();
328
+ }
329
+
330
+ function ScanDiffPanel({ report }: { report: DoctorReport["scanDiff"] }) {
331
+ const rows: Array<[string, keyof DoctorReport["scanDiff"]["current"]]> = [
332
+ ["Files scanned", "filesScanned"],
333
+ ["Records imported", "recordsImported"],
334
+ ["Imported", "imported"],
335
+ ["With errors", "importedWithErrors"],
336
+ ["Duplicates", "duplicates"],
337
+ ["Ignored", "ignored"],
338
+ ["Unsupported", "unsupported"],
339
+ ["Failed", "failed"]
340
+ ];
341
+
342
+ return (
343
+ <Card>
344
+ <CardHeader>
345
+ <CardTitle>Scan History Diff</CardTitle>
346
+ <CardDescription>
347
+ Latest scan compared with the previous scan using deterministic scan ordering. Ignored files are known support files, not usage transcripts.
348
+ </CardDescription>
349
+ </CardHeader>
350
+ <CardContent className="table-scroll space-y-4">
351
+ <div className="grid border-y md:grid-cols-2 md:divide-x">
352
+ <div className="min-w-0 p-3">
353
+ <FieldLabel>Latest scan</FieldLabel>
354
+ <div className="mt-1 text-sm font-semibold">{formatDate(report.latestCompletedAt ?? report.latestStartedAt)}</div>
355
+ <MonoText className="mt-1 block truncate text-xs text-muted-foreground">
356
+ {report.latestScanId ?? "No scan"}
357
+ </MonoText>
358
+ </div>
359
+ <div className="min-w-0 p-3">
360
+ <FieldLabel>Previous scan</FieldLabel>
361
+ <div className="mt-1 text-sm font-semibold">{formatDate(report.previousCompletedAt ?? report.previousStartedAt)}</div>
362
+ <MonoText className="mt-1 block truncate text-xs text-muted-foreground">
363
+ {report.previousScanId ?? "No previous scan"}
364
+ </MonoText>
365
+ </div>
366
+ </div>
367
+
368
+ <Table>
369
+ <TableHeader>
370
+ <TableRow>
371
+ <TableHead>Count</TableHead>
372
+ <TableHead className="text-right">Current</TableHead>
373
+ <TableHead className="text-right">Previous</TableHead>
374
+ <TableHead className="text-right">Delta</TableHead>
375
+ </TableRow>
376
+ </TableHeader>
377
+ <TableBody>
378
+ {rows.map(([label, key]) => (
379
+ <TableRow key={key}>
380
+ <TableCell className="font-medium">{label}</TableCell>
381
+ <TableCell className="text-right">{report.current[key].toLocaleString()}</TableCell>
382
+ <TableCell className="text-right">{report.previous[key].toLocaleString()}</TableCell>
383
+ <TableCell className="text-right">{formatDelta(report.delta[key])}</TableCell>
384
+ </TableRow>
385
+ ))}
386
+ </TableBody>
387
+ </Table>
388
+
389
+ {report.explanation ? (
390
+ <div className="rounded-md border bg-muted/30 p-3 text-sm leading-relaxed text-muted-foreground">
391
+ {report.explanation}
392
+ </div>
393
+ ) : null}
394
+ </CardContent>
395
+ </Card>
396
+ );
397
+ }
398
+
264
399
  function scanRunVariant(scanRun: DebugScanRun) {
265
400
  if (scanRun.errors.length > 0) return "destructive";
266
401
  if (scanRun.warnings.length > 0) return "warning";
@@ -366,6 +501,10 @@ export default async function DiagnosticsPage() {
366
501
 
367
502
  <DoctorReportPanel report={doctorReport} />
368
503
 
504
+ <ParserTrustPanel report={doctorReport.parserTrust} />
505
+
506
+ <ScanDiffPanel report={doctorReport.scanDiff} />
507
+
369
508
  <ScanHistoryPanel scanRuns={data.scanRuns} />
370
509
 
371
510
  <ScanHealthSummary health={data.health} />