tokentrace 0.8.4 → 0.9.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 (39) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +26 -0
  3. package/app/api/saved-views/[id]/route.ts +16 -0
  4. package/app/api/saved-views/route.ts +35 -0
  5. package/app/page.tsx +105 -1
  6. package/app/sessions/[id]/page.tsx +202 -0
  7. package/app/sessions/page.tsx +13 -0
  8. package/bin/tokentrace.js +22 -0
  9. package/components/session-explorer.tsx +139 -7
  10. package/dist/runtime/db-migrate.mjs +23 -0
  11. package/dist/runtime/db-seed.mjs +23 -0
  12. package/dist/runtime/digest.mjs +109 -14
  13. package/dist/runtime/doctor.mjs +24 -1
  14. package/dist/runtime/evidence.mjs +23 -0
  15. package/dist/runtime/insights.mjs +24 -1
  16. package/dist/runtime/pricing-refresh.mjs +24 -1
  17. package/dist/runtime/repair.mjs +23 -0
  18. package/dist/runtime/report.mjs +3067 -0
  19. package/dist/runtime/reset.mjs +23 -0
  20. package/dist/runtime/review.mjs +2746 -0
  21. package/dist/runtime/scan.mjs +24 -1
  22. package/dist/runtime/status.mjs +37 -8
  23. package/package.json +1 -1
  24. package/scripts/build-cli-runtime.mjs +2 -0
  25. package/scripts/digest.ts +15 -8
  26. package/scripts/report.ts +102 -0
  27. package/scripts/review.ts +46 -0
  28. package/scripts/smoke-cli.mjs +9 -0
  29. package/src/db/migrate-core.ts +9 -0
  30. package/src/db/schema.ts +19 -0
  31. package/src/lib/accounting-invariants.ts +171 -0
  32. package/src/lib/claude-statusline.ts +13 -7
  33. package/src/lib/daily-digest.ts +4 -0
  34. package/src/lib/markdown-report.ts +71 -0
  35. package/src/lib/post-session-review.ts +146 -0
  36. package/src/lib/report-cli.ts +100 -0
  37. package/src/lib/saved-views.ts +218 -0
  38. package/src/lib/session-timeline.ts +322 -0
  39. package/src/lib/since-filter.ts +60 -0
package/CHANGELOG.md CHANGED
@@ -2,6 +2,22 @@
2
2
 
3
3
  All notable changes to TokenTrace are documented here.
4
4
 
5
+ ## [0.9.0] - 2026-05-13
6
+
7
+ ### Added
8
+
9
+ - Session Timeline pages for inspecting one imported CLI session as ordered interactions, model changes, token spikes, cache activity, tool calls, parser confidence, and unknown-cost events without exposing raw message bodies.
10
+ - Saved Local Views on Sessions, with built-in local filters for unknown cost, high-cost sessions, Claude/Codex this month, estimated tokens, guardrail review, and parser review, plus user-created SQLite-backed views.
11
+ - `tokentrace digest --since <last-scan|yesterday|YYYY-MM-DD>` for scoped local usage digests.
12
+ - `tokentrace report --markdown` and `tokentrace review --json` for deterministic local summaries covering digest data, post-session movement, accounting state, parser warnings, and expensive sessions.
13
+ - Overview Post-Session Review panel showing latest scan movement, unknown costs, parser follow-up, guardrail state, and expensive sessions.
14
+ - Accounting invariant checks that verify processed tokens balance against fresh input, output, reasoning, and cache buckets.
15
+
16
+ ### Changed
17
+
18
+ - Claude status line output now includes context-window usage when Claude provides it and distinguishes priced sessions from pricing-repair states.
19
+ - Overview Data Readiness now includes token-bucket balance and keeps readiness tiles readable at normal desktop widths.
20
+
5
21
  ## [0.8.4] - 2026-05-13
6
22
 
7
23
  ### Changed
package/README.md CHANGED
@@ -43,6 +43,12 @@ tokentrace repair --json
43
43
  # Print unknown-cost repair groups as JSON
44
44
  tokentrace digest --json
45
45
  # Print current-month local usage digest
46
+ tokentrace digest --since yesterday
47
+ # Print a scoped local usage digest
48
+ tokentrace report --markdown
49
+ # Print a deterministic Markdown report
50
+ tokentrace review --json
51
+ # Print post-session scan and review movement
46
52
  tokentrace insights --json
47
53
  # Print local recommendations as JSON
48
54
  tokentrace status --json
@@ -139,6 +145,13 @@ Settings also supports optional local monthly usage guardrails. Set a cost
139
145
  limit, token limit, or both, and Overview will show month-to-date progress from
140
146
  imported local CLI usage.
141
147
 
148
+ Sessions includes built-in and local saved views for recurring review paths:
149
+ unknown cost, high-cost sessions, Claude/Codex this month, estimated tokens,
150
+ guardrail review, and parser review. Open a session's **Timeline** link to see
151
+ ordered interactions, model changes, token spikes, cache activity, tool calls,
152
+ parser confidence, and unknown-cost events. Raw prompts and message bodies stay
153
+ hidden by default.
154
+
142
155
  ## Ingestion Architecture
143
156
 
144
157
  TokenTrace's primary ingestion architecture is direct local filesystem ingestion:
@@ -188,6 +201,10 @@ tokentrace statusline claude
188
201
 
189
202
  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:
190
203
 
204
+ ```text
205
+ TokenTrace | Opus | session 3.3K tokens | cache 2.0K | cost $0.1235 | ctx 7% | priced
206
+ ```
207
+
191
208
  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
209
 
193
210
  ![TokenTrace Claude Code status line](docs/assets/claude-statusline.svg)
@@ -199,6 +216,15 @@ tokentrace status --json
199
216
  tokentrace watch --session
200
217
  ```
201
218
 
219
+ Daily reporting commands stay deterministic and local:
220
+
221
+ ```bash
222
+ tokentrace digest --since last-scan
223
+ tokentrace digest --since 2026-05-01 --json
224
+ tokentrace report --markdown --since yesterday
225
+ tokentrace review --json
226
+ ```
227
+
202
228
  Codex CLI status-line integration is intentionally deferred until its status-line and hook contracts are stable enough to support without fragile terminal output parsing. Use `tokentrace watch --session --compact` in a terminal split or tmux pane as the current fallback. See [docs/CODEX_INTEGRATION_SPIKE.md](docs/CODEX_INTEGRATION_SPIKE.md) for the current decision.
203
229
 
204
230
  ## Screenshots
@@ -0,0 +1,16 @@
1
+ import { NextResponse } from "next/server";
2
+ import { deleteSavedView } from "@/src/lib/saved-views";
3
+
4
+ export const dynamic = "force-dynamic";
5
+
6
+ export async function DELETE(
7
+ _request: Request,
8
+ {
9
+ params
10
+ }: {
11
+ params: Promise<{ id: string }>;
12
+ }
13
+ ) {
14
+ const { id } = await params;
15
+ return NextResponse.json({ deleted: deleteSavedView(decodeURIComponent(id)) });
16
+ }
@@ -0,0 +1,35 @@
1
+ import { NextResponse } from "next/server";
2
+ import { readJsonObject } from "@/src/lib/api-json";
3
+ import { getSavedViews, saveSavedView } from "@/src/lib/saved-views";
4
+
5
+ export const dynamic = "force-dynamic";
6
+
7
+ function object(value: unknown): Record<string, unknown> {
8
+ return value && typeof value === "object" && !Array.isArray(value)
9
+ ? (value as Record<string, unknown>)
10
+ : {};
11
+ }
12
+
13
+ export async function GET() {
14
+ return NextResponse.json(getSavedViews());
15
+ }
16
+
17
+ export async function POST(request: Request) {
18
+ const parsed = await readJsonObject(request);
19
+ if (!parsed.ok) {
20
+ return NextResponse.json({ error: parsed.error }, { status: 400 });
21
+ }
22
+
23
+ try {
24
+ const view = saveSavedView({
25
+ name: typeof parsed.body.name === "string" ? parsed.body.name : "",
26
+ filters: object(parsed.body.filters)
27
+ });
28
+ return NextResponse.json({ view });
29
+ } catch (error) {
30
+ return NextResponse.json(
31
+ { error: error instanceof Error ? error.message : "could not save view" },
32
+ { status: 400 }
33
+ );
34
+ }
35
+ }
package/app/page.tsx CHANGED
@@ -10,6 +10,7 @@ import { HelpTooltip } from "@/components/ui/help-tooltip";
10
10
  import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
11
11
  import { DataValue, FieldLabel, MonoText, PageHeader } from "@/components/ui/typography";
12
12
  import { getAnalyticsData, getScanTrustData } from "@/src/lib/analytics";
13
+ import { buildAccountingInvariants, type AccountingInvariantReport } from "@/src/lib/accounting-invariants";
13
14
  import { buildDoctorReport, type DoctorReport } from "@/src/lib/doctor";
14
15
  import { getDefaultSearchRoots } from "@/src/ingestion/discovery";
15
16
  import { buildFirstRunStatus, type FirstRunStatus } from "@/src/lib/first-run-status";
@@ -18,6 +19,8 @@ import { dateRangeQueryParams, mergeHrefParams, resolveDateRange } from "@/src/l
18
19
  import { formatCurrency, formatSignedTokens, formatTokens, percent } from "@/src/lib/format";
19
20
  import { cn } from "@/src/lib/utils";
20
21
  import type { UsageGuardrailMetric } from "@/src/lib/usage-guardrails";
22
+ import { buildScanDiff } from "@/src/lib/scan-diff";
23
+ import { buildPostSessionReview, type PostSessionReview } from "@/src/lib/post-session-review";
21
24
 
22
25
  export const dynamic = "force-dynamic";
23
26
 
@@ -358,12 +361,14 @@ function readinessVariant(state: "ready" | "review" | "blocked") {
358
361
 
359
362
  function DataReadinessPanel({
360
363
  report,
364
+ accountingReport,
361
365
  selectedInteractions,
362
366
  selectedCachedTokens,
363
367
  repairHref,
364
368
  cachedEvidenceHref
365
369
  }: {
366
370
  report: DoctorReport;
371
+ accountingReport: AccountingInvariantReport;
367
372
  selectedInteractions: number;
368
373
  selectedCachedTokens: number;
369
374
  repairHref: string;
@@ -401,6 +406,17 @@ function DataReadinessPanel({
401
406
  state: selectedCachedTokens > 0 ? "ready" as const : "review" as const,
402
407
  href: cachedEvidenceHref
403
408
  },
409
+ {
410
+ label: "Token buckets",
411
+ value: accountingReport.balanceDeltaTokens === 0
412
+ ? "Balanced"
413
+ : `${formatTokens(Math.abs(accountingReport.balanceDeltaTokens))} delta`,
414
+ detail: accountingReport.balanceDeltaTokens === 0
415
+ ? "Processed tokens equal fresh input, output, reasoning, and cache buckets."
416
+ : "Some processed tokens are outside the visible buckets and need parser review.",
417
+ state: accountingReport.status === "ready" ? "ready" as const : "review" as const,
418
+ href: "/evidence?metric=processed-tokens"
419
+ },
404
420
  {
405
421
  label: "Boundaries",
406
422
  value: `${report.supportMatrix.summary.stable} stable, ${report.supportMatrix.summary.bestEffort} best effort`,
@@ -417,7 +433,7 @@ function DataReadinessPanel({
417
433
  <CardDescription>Current trust checks before acting on cost, token, parser, or cache numbers.</CardDescription>
418
434
  </CardHeader>
419
435
  <CardContent className="p-0">
420
- <div className="grid border-t md:grid-cols-2 xl:grid-cols-5">
436
+ <div className="grid border-t md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-6">
421
437
  {items.map((item, index) => (
422
438
  <Link
423
439
  key={item.label}
@@ -441,6 +457,84 @@ function DataReadinessPanel({
441
457
  );
442
458
  }
443
459
 
460
+ function PostSessionReviewPanel({ review }: { review: PostSessionReview }) {
461
+ return (
462
+ <Card>
463
+ <CardHeader className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
464
+ <div>
465
+ <CardTitle>Post-Session Review</CardTitle>
466
+ <CardDescription>Latest scan movement, guardrail state, pricing gaps, parser warnings, and expensive sessions.</CardDescription>
467
+ </div>
468
+ <Badge variant={review.newlyImportedRecords > 0 ? "success" : "secondary"}>{review.headline}</Badge>
469
+ </CardHeader>
470
+ <CardContent className="space-y-4">
471
+ <div className="grid border-y md:grid-cols-4 md:divide-x">
472
+ <div className="p-3">
473
+ <FieldLabel>Imported records</FieldLabel>
474
+ <DataValue className="mt-1">{review.newlyImportedRecords.toLocaleString()}</DataValue>
475
+ </div>
476
+ <div className="p-3">
477
+ <FieldLabel>Unknown cost</FieldLabel>
478
+ <DataValue className="mt-1">{review.unknownCostInteractions.toLocaleString()}</DataValue>
479
+ </div>
480
+ <div className="p-3">
481
+ <FieldLabel>Parser warnings</FieldLabel>
482
+ <DataValue className="mt-1">{review.parserWarnings.toLocaleString()}</DataValue>
483
+ </div>
484
+ <div className="p-3">
485
+ <FieldLabel>Token guardrail</FieldLabel>
486
+ <DataValue className="mt-1">{percent(review.guardrails.tokens.percent)}</DataValue>
487
+ </div>
488
+ </div>
489
+ <div className="grid gap-3 lg:grid-cols-2">
490
+ <div>
491
+ <h3 className="text-sm font-semibold">Expensive Sessions</h3>
492
+ <div className="mt-2 divide-y rounded-md border">
493
+ {review.expensiveSessions.slice(0, 3).map((session) => (
494
+ <Link
495
+ key={session.id}
496
+ href={session.href}
497
+ className="flex min-w-0 items-center justify-between gap-3 p-3 text-sm transition-colors hover:bg-muted/40"
498
+ >
499
+ <span className="min-w-0">
500
+ <span className="block truncate font-medium">{session.title}</span>
501
+ <span className="text-xs text-muted-foreground">{session.tool} / {session.models}</span>
502
+ </span>
503
+ <span className="shrink-0 text-right tabular-nums">
504
+ <span className="block">{formatCurrency(session.cost)}</span>
505
+ <span className="text-xs text-muted-foreground">{formatTokens(session.totalTokens)}</span>
506
+ </span>
507
+ </Link>
508
+ ))}
509
+ {!review.expensiveSessions.length ? (
510
+ <div className="p-3 text-sm text-muted-foreground">No priced or token-heavy sessions yet.</div>
511
+ ) : null}
512
+ </div>
513
+ </div>
514
+ <div>
515
+ <h3 className="text-sm font-semibold">Parser Follow-Up</h3>
516
+ <div className="mt-2 divide-y rounded-md border">
517
+ {review.parserWarningSources.slice(0, 3).map((source) => (
518
+ <Link
519
+ key={source.sourceFile}
520
+ href={source.href}
521
+ className="block min-w-0 p-3 text-sm transition-colors hover:bg-muted/40"
522
+ >
523
+ <span className="font-medium">{source.parserStatus}</span>
524
+ <MonoText className="mt-1 block truncate">{source.sourceFile}</MonoText>
525
+ </Link>
526
+ ))}
527
+ {!review.parserWarningSources.length ? (
528
+ <div className="p-3 text-sm text-muted-foreground">No parser follow-up from the current session set.</div>
529
+ ) : null}
530
+ </div>
531
+ </div>
532
+ </div>
533
+ </CardContent>
534
+ </Card>
535
+ );
536
+ }
537
+
444
538
  type OverviewPageProps = {
445
539
  searchParams?: Promise<Record<string, string | string[] | undefined>>;
446
540
  };
@@ -449,6 +543,13 @@ export default async function OverviewPage({ searchParams }: OverviewPageProps)
449
543
  const params = (await searchParams) ?? {};
450
544
  const range = resolveDateRange(params);
451
545
  const data = getAnalyticsData(range.filters);
546
+ const accountingReport = buildAccountingInvariants(range.filters);
547
+ const postSessionReview = buildPostSessionReview({
548
+ scanDiff: buildScanDiff(),
549
+ usageGuardrails: data.usageGuardrails,
550
+ summary: data.summary,
551
+ sessions: data.sessions
552
+ });
452
553
  const rangeLinkParams = dateRangeQueryParams(range);
453
554
  const evidenceLinks = Object.fromEntries(
454
555
  Object.entries(data.evidenceLinks).map(([key, href]) => [key, mergeHrefParams(href, rangeLinkParams)])
@@ -646,6 +747,7 @@ export default async function OverviewPage({ searchParams }: OverviewPageProps)
646
747
  {summary.interactions > 0 ? (
647
748
  <DataReadinessPanel
648
749
  report={doctorReport}
750
+ accountingReport={accountingReport}
649
751
  selectedInteractions={summary.interactions}
650
752
  selectedCachedTokens={summary.cachedTokens}
651
753
  repairHref={repairFocusHref}
@@ -653,6 +755,8 @@ export default async function OverviewPage({ searchParams }: OverviewPageProps)
653
755
  />
654
756
  ) : null}
655
757
 
758
+ {summary.interactions > 0 ? <PostSessionReviewPanel review={postSessionReview} /> : null}
759
+
656
760
  <UsageGuardrailsPanel progress={data.usageGuardrails} />
657
761
 
658
762
  <Card>
@@ -0,0 +1,202 @@
1
+ import Link from "next/link";
2
+ import { notFound } from "next/navigation";
3
+ import { ArrowLeft, Database, Layers, MessageSquare, ShieldCheck, Terminal } from "lucide-react";
4
+ import { Badge } from "@/components/ui/badge";
5
+ import { Button } from "@/components/ui/button";
6
+ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
7
+ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
8
+ import { DataValue, FieldLabel, MonoText, PageHeader } from "@/components/ui/typography";
9
+ import { buildSessionTimeline, type SessionTimelineEventKind } from "@/src/lib/session-timeline";
10
+ import { formatCurrency, formatDate, formatDuration, formatTokens } from "@/src/lib/format";
11
+
12
+ export const dynamic = "force-dynamic";
13
+
14
+ function eventVariant(kind: SessionTimelineEventKind) {
15
+ if (kind === "unknown-cost") return "destructive";
16
+ if (kind === "token-spike" || kind === "model-change") return "warning";
17
+ if (kind === "cache") return "success";
18
+ return "secondary";
19
+ }
20
+
21
+ function eventLabel(kind: SessionTimelineEventKind) {
22
+ return kind.replace(/-/g, " ");
23
+ }
24
+
25
+ function SummaryTile({
26
+ label,
27
+ value,
28
+ detail,
29
+ icon: Icon
30
+ }: {
31
+ label: string;
32
+ value: string;
33
+ detail: string;
34
+ icon: typeof Database;
35
+ }) {
36
+ return (
37
+ <Card>
38
+ <CardContent className="flex min-w-0 items-start gap-3 p-4">
39
+ <span className="mt-0.5 inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground">
40
+ <Icon className="h-4 w-4" aria-hidden="true" />
41
+ </span>
42
+ <div className="min-w-0">
43
+ <FieldLabel>{label}</FieldLabel>
44
+ <DataValue className="mt-1 text-2xl">{value}</DataValue>
45
+ <div className="mt-1 text-xs text-muted-foreground">{detail}</div>
46
+ </div>
47
+ </CardContent>
48
+ </Card>
49
+ );
50
+ }
51
+
52
+ export default async function SessionTimelinePage({
53
+ params
54
+ }: {
55
+ params: Promise<{ id: string }>;
56
+ }) {
57
+ const { id } = await params;
58
+ const timeline = buildSessionTimeline(decodeURIComponent(id));
59
+ if (!timeline) notFound();
60
+
61
+ const sessionHref = `/sessions?source=${encodeURIComponent(timeline.session.sourceFile)}`;
62
+ const durationMs =
63
+ timeline.session.startedAt != null && timeline.session.endedAt != null
64
+ ? timeline.session.endedAt - timeline.session.startedAt
65
+ : null;
66
+
67
+ return (
68
+ <div className="space-y-6">
69
+ <PageHeader
70
+ title={timeline.session.title ?? "Session timeline"}
71
+ description={`${timeline.session.tool} usage events for ${timeline.session.project}. Raw prompts and message bodies stay hidden in this view.`}
72
+ actions={
73
+ <Button asChild variant="outline">
74
+ <Link href={sessionHref}>
75
+ <ArrowLeft className="h-4 w-4" />
76
+ Back to sessions
77
+ </Link>
78
+ </Button>
79
+ }
80
+ />
81
+
82
+ <Card>
83
+ <CardHeader>
84
+ <CardTitle>Session Context</CardTitle>
85
+ <CardDescription>
86
+ Ordered local usage evidence from imported interaction records, parser metadata, pricing state, cache buckets, and tool calls.
87
+ </CardDescription>
88
+ </CardHeader>
89
+ <CardContent className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
90
+ <div className="min-w-0">
91
+ <FieldLabel>Source file</FieldLabel>
92
+ <MonoText className="mt-1 block truncate" title={timeline.session.sourceFile}>
93
+ {timeline.session.sourceFile}
94
+ </MonoText>
95
+ </div>
96
+ <div>
97
+ <FieldLabel>Started</FieldLabel>
98
+ <div className="mt-1 text-sm font-medium">{formatDate(timeline.session.startedAt)}</div>
99
+ </div>
100
+ <div>
101
+ <FieldLabel>Duration</FieldLabel>
102
+ <div className="mt-1 text-sm font-medium">{formatDuration(durationMs)}</div>
103
+ </div>
104
+ <div>
105
+ <FieldLabel>Parser</FieldLabel>
106
+ <div className="mt-1 flex flex-wrap items-center gap-2 text-sm font-medium">
107
+ {timeline.summary.parser ?? "unknown"}
108
+ {timeline.summary.parserConfidence != null ? (
109
+ <Badge variant="success">{Math.round(timeline.summary.parserConfidence * 100)}%</Badge>
110
+ ) : null}
111
+ </div>
112
+ </div>
113
+ </CardContent>
114
+ </Card>
115
+
116
+ <div className="grid gap-3 md:grid-cols-2 xl:grid-cols-5">
117
+ <SummaryTile
118
+ label="Processed"
119
+ value={formatTokens(timeline.summary.totalTokens)}
120
+ detail={`${timeline.summary.interactions.toLocaleString()} interactions`}
121
+ icon={Database}
122
+ />
123
+ <SummaryTile
124
+ label="Cache"
125
+ value={formatTokens(timeline.summary.cachedTokens)}
126
+ detail="Read and write cache tokens"
127
+ icon={Layers}
128
+ />
129
+ <SummaryTile
130
+ label="Cost"
131
+ value={formatCurrency(timeline.summary.cost)}
132
+ detail={`${timeline.summary.unknownCostInteractions.toLocaleString()} unknown interactions`}
133
+ icon={ShieldCheck}
134
+ />
135
+ <SummaryTile
136
+ label="Models"
137
+ value={timeline.summary.models.length.toLocaleString()}
138
+ detail={timeline.summary.models.slice(0, 2).join(", ") || "No model rows"}
139
+ icon={MessageSquare}
140
+ />
141
+ <SummaryTile
142
+ label="Tool calls"
143
+ value={timeline.summary.toolCalls.toLocaleString()}
144
+ detail="Imported tool-call rows"
145
+ icon={Terminal}
146
+ />
147
+ </div>
148
+
149
+ <Card>
150
+ <CardHeader>
151
+ <CardTitle>Timeline</CardTitle>
152
+ <CardDescription>Interaction rows are expanded with model changes, spikes, cache activity, unknown cost, and tool calls.</CardDescription>
153
+ </CardHeader>
154
+ <CardContent className="table-scroll">
155
+ <Table className="min-w-[78rem]">
156
+ <TableHeader>
157
+ <TableRow>
158
+ <TableHead className="w-36">Time</TableHead>
159
+ <TableHead className="w-32">Event</TableHead>
160
+ <TableHead className="w-44">Detail</TableHead>
161
+ <TableHead className="w-36">Model</TableHead>
162
+ <TableHead className="w-24">Role</TableHead>
163
+ <TableHead className="w-24">Tokens</TableHead>
164
+ <TableHead className="w-24">Cache</TableHead>
165
+ <TableHead className="w-24">Cost</TableHead>
166
+ <TableHead className="w-36">Confidence</TableHead>
167
+ <TableHead className="w-28">Raw</TableHead>
168
+ </TableRow>
169
+ </TableHeader>
170
+ <TableBody>
171
+ {timeline.events.map((event) => (
172
+ <TableRow key={event.id}>
173
+ <TableCell>{formatDate(event.timestamp)}</TableCell>
174
+ <TableCell>
175
+ <Badge variant={eventVariant(event.kind)}>{eventLabel(event.kind)}</Badge>
176
+ </TableCell>
177
+ <TableCell>
178
+ <div className="font-medium">{event.title}</div>
179
+ <div className="mt-1 text-xs text-muted-foreground">{event.detail}</div>
180
+ </TableCell>
181
+ <TableCell className="max-w-36 truncate" title={event.model ?? undefined}>{event.model ?? "unknown"}</TableCell>
182
+ <TableCell>{event.role ?? "unknown"}</TableCell>
183
+ <TableCell className="tabular-nums">{formatTokens(event.totalTokens)}</TableCell>
184
+ <TableCell className="tabular-nums">{formatTokens(event.cachedTokens)}</TableCell>
185
+ <TableCell className="tabular-nums">{formatCurrency(event.cost)}</TableCell>
186
+ <TableCell>
187
+ <Badge variant={event.tokenConfidence === "exact" ? "success" : event.tokenConfidence === "unknown" ? "destructive" : "warning"}>
188
+ {event.tokenConfidence ?? "unknown"}
189
+ </Badge>
190
+ </TableCell>
191
+ <TableCell className="text-xs text-muted-foreground">
192
+ {event.rawTextHidden ? "Hidden" : "Visible"}
193
+ </TableCell>
194
+ </TableRow>
195
+ ))}
196
+ </TableBody>
197
+ </Table>
198
+ </CardContent>
199
+ </Card>
200
+ </div>
201
+ );
202
+ }
@@ -6,6 +6,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
6
6
  import { PageHeader } from "@/components/ui/typography";
7
7
  import { getAnalyticsData } from "@/src/lib/analytics";
8
8
  import { formatCurrency, formatTokens } from "@/src/lib/format";
9
+ import { getSavedViews } from "@/src/lib/saved-views";
9
10
 
10
11
  export const dynamic = "force-dynamic";
11
12
 
@@ -16,13 +17,19 @@ export default async function SessionsPage({
16
17
  project?: string;
17
18
  tool?: string;
18
19
  model?: string;
20
+ query?: string;
19
21
  source?: string;
22
+ exact?: "all" | "exact" | "estimated";
20
23
  cost?: "all" | "priced" | "unknown";
24
+ from?: string;
25
+ to?: string;
26
+ highCost?: string;
21
27
  cache?: string;
22
28
  }>;
23
29
  }) {
24
30
  const params = await searchParams;
25
31
  const data = getAnalyticsData();
32
+ const savedViews = getSavedViews();
26
33
  return (
27
34
  <div className="space-y-6">
28
35
  <PageHeader
@@ -84,9 +91,15 @@ export default async function SessionsPage({
84
91
  initialProject={params?.project}
85
92
  initialTool={params?.tool}
86
93
  initialModel={params?.model}
94
+ initialQuery={params?.query}
87
95
  initialSource={params?.source}
96
+ initialExact={params?.exact}
88
97
  initialCost={params?.cost}
98
+ initialFrom={params?.from}
99
+ initialTo={params?.to}
100
+ initialHighCost={params?.highCost === "1"}
89
101
  initialCache={params?.cache === "1"}
102
+ savedViews={savedViews}
90
103
  />
91
104
  </div>
92
105
  );
package/bin/tokentrace.js CHANGED
@@ -32,6 +32,10 @@ Usage:
32
32
  Print metric evidence trail as JSON
33
33
  tokentrace digest --json
34
34
  Print current-month local usage digest
35
+ tokentrace report --markdown
36
+ Print a deterministic local Markdown report
37
+ tokentrace review --json
38
+ Print post-session review movement as JSON
35
39
  tokentrace insights --json
36
40
  Print local recommendations as JSON
37
41
  tokentrace repair --json
@@ -381,6 +385,16 @@ async function digest(args) {
381
385
  await runNodeScript("digest", args);
382
386
  }
383
387
 
388
+ async function report(args) {
389
+ await initializeDatabase({ quiet: true, refreshPrices: false });
390
+ await runNodeScript("report", args);
391
+ }
392
+
393
+ async function review(args) {
394
+ await initializeDatabase({ quiet: true, refreshPrices: false });
395
+ await runNodeScript("review", args);
396
+ }
397
+
384
398
  async function insights(args) {
385
399
  await initializeDatabase({ quiet: true, refreshPrices: false });
386
400
  await runNodeScript("insights", args);
@@ -558,6 +572,14 @@ async function main() {
558
572
  await digest(args);
559
573
  return;
560
574
  }
575
+ if (command === "report") {
576
+ await report(args);
577
+ return;
578
+ }
579
+ if (command === "review") {
580
+ await review(args);
581
+ return;
582
+ }
561
583
  if (command === "insights") {
562
584
  await insights(args);
563
585
  return;