tokentrace 0.8.0 → 0.8.2

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 (41) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/app/api/prices/refresh/route.ts +18 -3
  3. package/app/api/prices/route.ts +39 -13
  4. package/app/api/repair-items/route.ts +6 -1
  5. package/app/api/scan/route.ts +17 -5
  6. package/app/api/settings/route.ts +11 -3
  7. package/app/evidence/page.tsx +3 -3
  8. package/app/page.tsx +7 -7
  9. package/dist/runtime/db-seed.mjs +10 -2
  10. package/dist/runtime/digest.mjs +507 -336
  11. package/dist/runtime/doctor.mjs +2181 -396
  12. package/dist/runtime/insights.mjs +783 -316
  13. package/dist/runtime/pricing-refresh.mjs +1159 -1029
  14. package/dist/runtime/reset.mjs +10 -2
  15. package/dist/runtime/scan.mjs +1259 -764
  16. package/dist/runtime/status.mjs +242 -86
  17. package/package.json +2 -2
  18. package/scripts/digest.ts +21 -3
  19. package/scripts/doctor.ts +22 -4
  20. package/scripts/insights.ts +18 -2
  21. package/scripts/pricing-refresh.ts +25 -9
  22. package/scripts/scan.ts +21 -8
  23. package/scripts/status.ts +33 -38
  24. package/src/db/settings.ts +5 -2
  25. package/src/ingestion/adapters/claude-code.ts +51 -20
  26. package/src/ingestion/adapters/codex-cli.ts +204 -17
  27. package/src/ingestion/adapters/generic-json.ts +1 -1
  28. package/src/ingestion/adapters/generic-jsonl.ts +1 -1
  29. package/src/ingestion/adapters/generic-log.ts +72 -10
  30. package/src/ingestion/adapters/helpers.ts +107 -42
  31. package/src/ingestion/discovery.ts +11 -2
  32. package/src/ingestion/persist.ts +43 -4
  33. package/src/ingestion/scan.ts +45 -18
  34. package/src/lib/analytics.ts +20 -19
  35. package/src/lib/api-json.ts +46 -0
  36. package/src/lib/format.ts +12 -1
  37. package/src/lib/pricing-manifest.ts +10 -2
  38. package/src/lib/pricing-refresh-cli.ts +57 -0
  39. package/src/lib/report-cli.ts +36 -0
  40. package/src/lib/scan-cli.ts +50 -0
  41. package/src/lib/status-cli.ts +150 -0
package/CHANGELOG.md CHANGED
@@ -2,6 +2,32 @@
2
2
 
3
3
  All notable changes to TokenTrace are documented here.
4
4
 
5
+ ## [0.8.2] - 2026-05-13
6
+
7
+ ### Fixed
8
+
9
+ - Codex CLI `token_count` imports now preserve non-cached input tokens and add cached input tokens to processed totals, matching Codex's `input (+ cached)` session summary.
10
+ - Codex parser provenance is bumped to version 3 so previously imported Codex session files are reprocessed on the next scan instead of keeping stale undercounted rows.
11
+ - Generic text-log parsing now recognizes Codex `Token usage: total=... input=... (+ ... cached) output=...` summary lines as structured usage instead of weak text estimates.
12
+ - Overview and evidence token totals now switch to billions at large scales instead of displaying multi-thousand million values.
13
+ - Scan, settings, pricing, and pricing-refresh API writes now reject malformed JSON and avoid JavaScript truthiness coercion for boolean flags.
14
+ - Settings and scan custom folders are trimmed and blank entries are discarded before persistence or scan execution.
15
+ - Pricing manifest imports now ignore invalid, boolean, array, blank, and negative price values instead of coercing them into trusted numeric prices.
16
+
17
+ ## [0.8.1] - 2026-05-13
18
+
19
+ ### Fixed
20
+
21
+ - Codex CLI imports now read exact `token_count` totals, including cached input and reasoning output tokens, so cleared Codex sessions are no longer undercounted.
22
+ - Claude Code and Codex session artifacts can exceed the generic file-size cap without being skipped during discovery.
23
+ - OpenAI-style, Claude-style, and generic usage parsers now normalize cached, cache-write, and reasoning token fields without double-counting them.
24
+ - Parser-version-aware rescans now reprocess stale imports, and source-file replacement is atomic so a failed replacement cannot delete prior trusted sessions.
25
+ - Unknown-cost cause summaries now assign each interaction to one primary cause instead of overlapping buckets.
26
+ - Pricing, scan, settings, repair, and pricing-refresh APIs now reject malformed JSON with clean 400 responses.
27
+ - Manual pricing saves now reject blank model/provider names and invalid numeric prices instead of silently storing unknown prices.
28
+ - CLI commands now reject unknown flags across scan, pricing refresh, status, doctor, digest, and insights commands.
29
+ - Local release checks now run ProjScan through `projscan@latest`, matching the GitHub security workflow guardrail.
30
+
5
31
  ## [0.8.0] - 2026-05-12
6
32
 
7
33
  ### Added
@@ -1,13 +1,28 @@
1
1
  import { NextResponse } from "next/server";
2
+ import { jsonBooleanFlag, readOptionalJsonObject } 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
 
7
+ function refreshSource(value: unknown) {
8
+ if (value == null) return "remote";
9
+ if (value === "remote" || value === "bundled") return value;
10
+ return null;
11
+ }
12
+
6
13
  export async function POST(request: Request) {
7
- const body = await request.json().catch(() => ({}));
14
+ const parsed = await readOptionalJsonObject(request);
15
+ if (!parsed.ok) {
16
+ return NextResponse.json({ error: parsed.error }, { status: 400 });
17
+ }
18
+ const body = parsed.body;
19
+ const source = refreshSource(body.source);
20
+ if (!source) {
21
+ return NextResponse.json({ error: "source must be remote or bundled" }, { status: 400 });
22
+ }
8
23
  const result = await refreshPricing({
9
- source: body?.source === "bundled" ? "bundled" : "remote",
10
- force: Boolean(body?.force)
24
+ source,
25
+ force: jsonBooleanFlag(body.force)
11
26
  });
12
27
  return NextResponse.json(result);
13
28
  }
@@ -1,12 +1,24 @@
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 };
14
+ if (typeof value !== "number" && typeof value !== "string") {
15
+ return { ok: false as const, error: `${field} must be a non-negative number or empty` };
16
+ }
8
17
  const number = Number(value);
9
- return Number.isFinite(number) ? number : null;
18
+ if (!Number.isFinite(number) || number < 0) {
19
+ return { ok: false as const, error: `${field} must be a non-negative number or empty` };
20
+ }
21
+ return { ok: true as const, value: number };
10
22
  }
11
23
 
12
24
  export async function GET() {
@@ -14,20 +26,34 @@ export async function GET() {
14
26
  }
15
27
 
16
28
  export async function POST(request: Request) {
17
- const body = await request.json();
18
- if (!body.providerId || !body.model) {
29
+ const parsed = await readJsonObject(request);
30
+ if (!parsed.ok) {
31
+ return NextResponse.json({ error: parsed.error }, { status: 400 });
32
+ }
33
+ const body = parsed.body;
34
+ const providerId = requiredText(body.providerId);
35
+ const model = requiredText(body.model);
36
+ if (!providerId || !model) {
19
37
  return NextResponse.json({ error: "providerId and model are required" }, { status: 400 });
20
38
  }
39
+ const inputTokenPrice = nullablePrice(body.inputTokenPrice, "inputTokenPrice");
40
+ const outputTokenPrice = nullablePrice(body.outputTokenPrice, "outputTokenPrice");
41
+ const cachedInputTokenPrice = nullablePrice(body.cachedInputTokenPrice, "cachedInputTokenPrice");
42
+ const cacheWriteTokenPrice = nullablePrice(body.cacheWriteTokenPrice, "cacheWriteTokenPrice");
43
+ if (!inputTokenPrice.ok) return NextResponse.json({ error: inputTokenPrice.error }, { status: 400 });
44
+ if (!outputTokenPrice.ok) return NextResponse.json({ error: outputTokenPrice.error }, { status: 400 });
45
+ if (!cachedInputTokenPrice.ok) return NextResponse.json({ error: cachedInputTokenPrice.error }, { status: 400 });
46
+ if (!cacheWriteTokenPrice.ok) return NextResponse.json({ error: cacheWriteTokenPrice.error }, { status: 400 });
21
47
 
22
48
  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"
49
+ providerId,
50
+ providerName: requiredText(body.providerName) || undefined,
51
+ model,
52
+ inputTokenPrice: inputTokenPrice.value,
53
+ outputTokenPrice: outputTokenPrice.value,
54
+ cachedInputTokenPrice: cachedInputTokenPrice.value,
55
+ cacheWriteTokenPrice: cacheWriteTokenPrice.value,
56
+ currency: requiredText(body.currency) || "USD"
31
57
  });
32
58
 
33
59
  return NextResponse.json({ id, costsRecalculated: true });
@@ -5,6 +5,7 @@ import {
5
5
  saveUnknownCostReview,
6
6
  type UnknownCostRepairStatus
7
7
  } from "@/src/lib/unknown-cost-repair";
8
+ import { readJsonObject } from "@/src/lib/api-json";
8
9
 
9
10
  export const dynamic = "force-dynamic";
10
11
 
@@ -34,7 +35,11 @@ export async function GET() {
34
35
  }
35
36
 
36
37
  export async function PUT(request: Request) {
37
- const body = await request.json();
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;
38
43
  const key = text(body.key, 1000);
39
44
  const status = reviewState(body.status ?? body.state);
40
45
 
@@ -1,15 +1,27 @@
1
1
  import { NextResponse } from "next/server";
2
2
  import { runScan } from "@/src/ingestion/scan";
3
+ import { jsonBooleanFlag, readOptionalJsonObject } from "@/src/lib/api-json";
3
4
 
4
5
  export const dynamic = "force-dynamic";
5
6
 
7
+ function stringList(value: unknown) {
8
+ return Array.isArray(value)
9
+ ? value
10
+ .filter((folder): folder is string => typeof folder === "string")
11
+ .map((folder) => folder.trim())
12
+ .filter(Boolean)
13
+ : undefined;
14
+ }
15
+
6
16
  export async function POST(request: Request) {
7
- const body = await request.json().catch(() => ({}));
17
+ const parsed = await readOptionalJsonObject(request);
18
+ if (!parsed.ok) {
19
+ return NextResponse.json({ error: parsed.error }, { status: 400 });
20
+ }
21
+ const body = parsed.body;
8
22
  const result = await runScan({
9
- folders: Array.isArray(body.folders)
10
- ? body.folders.filter((folder: unknown): folder is string => typeof folder === "string")
11
- : undefined,
12
- force: Boolean(body.force)
23
+ folders: stringList(body.folders),
24
+ force: jsonBooleanFlag(body.force)
13
25
  });
14
26
  return NextResponse.json(result);
15
27
  }
@@ -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 { jsonBooleanFlag, readJsonObject } from "@/src/lib/api-json";
4
5
 
5
6
  export const dynamic = "force-dynamic";
6
7
 
@@ -12,13 +13,20 @@ 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
- ? body.customFolders.filter((folder: unknown): folder is string => typeof folder === "string")
22
+ ? body.customFolders
23
+ .filter((folder: unknown): folder is string => typeof folder === "string")
24
+ .map((folder) => folder.trim())
25
+ .filter(Boolean)
18
26
  : [];
19
27
  const saved = saveAppSettings({
20
28
  customFolders,
21
- storeRawMessageContent: Boolean(body.storeRawMessageContent),
29
+ storeRawMessageContent: jsonBooleanFlag(body.storeRawMessageContent),
22
30
  usageGuardrails: normalizeUsageGuardrails(body.usageGuardrails)
23
31
  });
24
32
 
@@ -6,7 +6,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
6
6
  import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
7
7
  import { DataValue, FieldLabel, MonoText, PageHeader } from "@/components/ui/typography";
8
8
  import { buildEvidenceTrail, parseEvidenceMetric } from "@/src/lib/evidence-trail";
9
- import { formatCurrency, formatTokens, percent } from "@/src/lib/format";
9
+ import { formatCurrency, formatExactTokens, percent } from "@/src/lib/format";
10
10
 
11
11
  export const dynamic = "force-dynamic";
12
12
 
@@ -57,7 +57,7 @@ export default async function EvidencePage({ searchParams }: EvidencePageProps)
57
57
  <div className="grid divide-y border-t sm:grid-cols-2 sm:divide-x sm:divide-y-0 lg:grid-cols-5">
58
58
  <div className="p-3">
59
59
  <FieldLabel>Tokens</FieldLabel>
60
- <DataValue className="mt-1" size="md">{formatTokens(trail.totals.tokens)}</DataValue>
60
+ <DataValue className="mt-1" size="md">{formatExactTokens(trail.totals.tokens)}</DataValue>
61
61
  </div>
62
62
  <div className="p-3">
63
63
  <FieldLabel>Cost</FieldLabel>
@@ -111,7 +111,7 @@ export default async function EvidencePage({ searchParams }: EvidencePageProps)
111
111
  {session.tool} / {session.provider} / {session.project}
112
112
  </div>
113
113
  </TableCell>
114
- <TableCell>{formatTokens(session.totalTokens)}</TableCell>
114
+ <TableCell>{formatExactTokens(session.totalTokens)}</TableCell>
115
115
  <TableCell>{formatCurrency(session.cost)}</TableCell>
116
116
  <TableCell className="max-w-80">
117
117
  <Link href={session.sourceHref} title={session.sourceFile}>
package/app/page.tsx CHANGED
@@ -15,7 +15,7 @@ import { getDefaultSearchRoots } from "@/src/ingestion/discovery";
15
15
  import { buildFirstRunStatus, type FirstRunStatus } from "@/src/lib/first-run-status";
16
16
  import { buildUnknownCostRepairWorkbench } from "@/src/lib/unknown-cost-repair";
17
17
  import { resolveDateRange } from "@/src/lib/date-range";
18
- import { formatCurrency, formatTokens, percent } from "@/src/lib/format";
18
+ import { formatCurrency, formatSignedTokens, formatTokens, percent } from "@/src/lib/format";
19
19
  import { cn } from "@/src/lib/utils";
20
20
  import type { UsageGuardrailMetric } from "@/src/lib/usage-guardrails";
21
21
 
@@ -74,16 +74,16 @@ function MetricCard({
74
74
  );
75
75
  }
76
76
 
77
- function formatSignedNumber(value: number) {
78
- if (value === 0) return "0";
79
- return `${value > 0 ? "+" : "-"}${Math.abs(value).toLocaleString()}`;
80
- }
81
-
82
77
  function formatSignedCurrency(value: number) {
83
78
  if (value === 0) return "$0.00";
84
79
  return `${value > 0 ? "+" : "-"}${formatCurrency(Math.abs(value))}`;
85
80
  }
86
81
 
82
+ function formatSignedNumber(value: number) {
83
+ if (value === 0) return "0";
84
+ return `${value > 0 ? "+" : "-"}${Math.abs(value).toLocaleString()}`;
85
+ }
86
+
87
87
  function formatPercentValue(value: number | null) {
88
88
  if (value == null) return "new";
89
89
  if (value === 0) return "flat";
@@ -319,7 +319,7 @@ export default async function OverviewPage({ searchParams }: OverviewPageProps)
319
319
  <DeltaMetric
320
320
  label="Tokens"
321
321
  value={formatTokens(data.comparison.current.totalTokens)}
322
- delta={formatSignedNumber(data.comparison.delta.totalTokens)}
322
+ delta={formatSignedTokens(data.comparison.delta.totalTokens)}
323
323
  percentValue={data.comparison.delta.totalTokensPercent}
324
324
  previous={formatTokens(data.comparison.previous.totalTokens)}
325
325
  />
@@ -1193,8 +1193,16 @@ var default_model_prices_default = {
1193
1193
  // src/lib/pricing-manifest.ts
1194
1194
  function nullableNumber(value) {
1195
1195
  if (value == null) return null;
1196
- const number = Number(value);
1197
- return Number.isFinite(number) ? number : null;
1196
+ if (typeof value === "number") {
1197
+ return Number.isFinite(value) && value >= 0 ? value : null;
1198
+ }
1199
+ if (typeof value === "string") {
1200
+ const trimmed = value.trim();
1201
+ if (!trimmed) return null;
1202
+ const number = Number(trimmed);
1203
+ return Number.isFinite(number) && number >= 0 ? number : null;
1204
+ }
1205
+ return null;
1198
1206
  }
1199
1207
  function validModel(value) {
1200
1208
  if (!value || typeof value !== "object") return null;