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.
- package/CHANGELOG.md +16 -0
- package/README.md +26 -0
- package/app/api/saved-views/[id]/route.ts +16 -0
- package/app/api/saved-views/route.ts +35 -0
- package/app/page.tsx +105 -1
- package/app/sessions/[id]/page.tsx +202 -0
- package/app/sessions/page.tsx +13 -0
- package/bin/tokentrace.js +22 -0
- package/components/session-explorer.tsx +139 -7
- package/dist/runtime/db-migrate.mjs +23 -0
- package/dist/runtime/db-seed.mjs +23 -0
- package/dist/runtime/digest.mjs +109 -14
- package/dist/runtime/doctor.mjs +24 -1
- package/dist/runtime/evidence.mjs +23 -0
- package/dist/runtime/insights.mjs +24 -1
- package/dist/runtime/pricing-refresh.mjs +24 -1
- package/dist/runtime/repair.mjs +23 -0
- package/dist/runtime/report.mjs +3067 -0
- package/dist/runtime/reset.mjs +23 -0
- package/dist/runtime/review.mjs +2746 -0
- package/dist/runtime/scan.mjs +24 -1
- package/dist/runtime/status.mjs +37 -8
- package/package.json +1 -1
- package/scripts/build-cli-runtime.mjs +2 -0
- package/scripts/digest.ts +15 -8
- package/scripts/report.ts +102 -0
- package/scripts/review.ts +46 -0
- package/scripts/smoke-cli.mjs +9 -0
- package/src/db/migrate-core.ts +9 -0
- package/src/db/schema.ts +19 -0
- package/src/lib/accounting-invariants.ts +171 -0
- package/src/lib/claude-statusline.ts +13 -7
- package/src/lib/daily-digest.ts +4 -0
- package/src/lib/markdown-report.ts +71 -0
- package/src/lib/post-session-review.ts +146 -0
- package/src/lib/report-cli.ts +100 -0
- package/src/lib/saved-views.ts +218 -0
- package/src/lib/session-timeline.ts +322 -0
- 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
|

|
|
@@ -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-
|
|
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
|
+
}
|
package/app/sessions/page.tsx
CHANGED
|
@@ -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;
|