tokentrace 0.8.1 → 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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,18 @@
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
+
5
17
  ## [0.8.1] - 2026-05-13
6
18
 
7
19
  ### Fixed
@@ -1,18 +1,28 @@
1
1
  import { NextResponse } from "next/server";
2
- import { readJsonObject } from "@/src/lib/api-json";
2
+ import { jsonBooleanFlag, readOptionalJsonObject } from "@/src/lib/api-json";
3
3
  import { refreshPricing } from "@/src/lib/pricing-refresh";
4
4
 
5
5
  export const dynamic = "force-dynamic";
6
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
+
7
13
  export async function POST(request: Request) {
8
- const parsed = await readJsonObject(request);
14
+ const parsed = await readOptionalJsonObject(request);
9
15
  if (!parsed.ok) {
10
16
  return NextResponse.json({ error: parsed.error }, { status: 400 });
11
17
  }
12
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
+ }
13
23
  const result = await refreshPricing({
14
- source: body?.source === "bundled" ? "bundled" : "remote",
15
- force: Boolean(body?.force)
24
+ source,
25
+ force: jsonBooleanFlag(body.force)
16
26
  });
17
27
  return NextResponse.json(result);
18
28
  }
@@ -11,6 +11,9 @@ function requiredText(value: unknown) {
11
11
  function nullablePrice(value: unknown, field: string) {
12
12
  if (value == null) return { ok: true as const, value: null };
13
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
+ }
14
17
  const number = Number(value);
15
18
  if (!Number.isFinite(number) || number < 0) {
16
19
  return { ok: false as const, error: `${field} must be a non-negative number or empty` };
@@ -1,20 +1,27 @@
1
1
  import { NextResponse } from "next/server";
2
2
  import { runScan } from "@/src/ingestion/scan";
3
- import { readJsonObject } from "@/src/lib/api-json";
3
+ import { jsonBooleanFlag, readOptionalJsonObject } from "@/src/lib/api-json";
4
4
 
5
5
  export const dynamic = "force-dynamic";
6
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
+
7
16
  export async function POST(request: Request) {
8
- const parsed = await readJsonObject(request);
17
+ const parsed = await readOptionalJsonObject(request);
9
18
  if (!parsed.ok) {
10
19
  return NextResponse.json({ error: parsed.error }, { status: 400 });
11
20
  }
12
21
  const body = parsed.body;
13
22
  const result = await runScan({
14
- folders: Array.isArray(body.folders)
15
- ? body.folders.filter((folder: unknown): folder is string => typeof folder === "string")
16
- : undefined,
17
- force: Boolean(body.force)
23
+ folders: stringList(body.folders),
24
+ force: jsonBooleanFlag(body.force)
18
25
  });
19
26
  return NextResponse.json(result);
20
27
  }
@@ -1,7 +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
+ import { jsonBooleanFlag, readJsonObject } from "@/src/lib/api-json";
5
5
 
6
6
  export const dynamic = "force-dynamic";
7
7
 
@@ -19,11 +19,14 @@ export async function PUT(request: Request) {
19
19
  }
20
20
  const body = parsed.body;
21
21
  const customFolders = Array.isArray(body.customFolders)
22
- ? 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)
23
26
  : [];
24
27
  const saved = saveAppSettings({
25
28
  customFolders,
26
- storeRawMessageContent: Boolean(body.storeRawMessageContent),
29
+ storeRawMessageContent: jsonBooleanFlag(body.storeRawMessageContent),
27
30
  usageGuardrails: normalizeUsageGuardrails(body.usageGuardrails)
28
31
  });
29
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;
@@ -775,7 +775,8 @@ var init_model_aliases = __esm({
775
775
  // src/lib/format.ts
776
776
  function formatTokens(value) {
777
777
  const number3 = value ?? 0;
778
- if (number3 >= 1e6) return `${(number3 / 1e6).toFixed(2)}M`;
778
+ if (number3 >= 999995e3) return `${(number3 / 1e9).toFixed(2)}B`;
779
+ if (number3 >= 999950) return `${(number3 / 1e6).toFixed(2)}M`;
779
780
  if (number3 >= 1e3) return `${(number3 / 1e3).toFixed(1)}K`;
780
781
  return number3.toLocaleString();
781
782
  }
@@ -964,8 +965,8 @@ function normalizeSettings(value) {
964
965
  if (!value || typeof value !== "object") return defaultSettings;
965
966
  const candidate = value;
966
967
  return {
967
- customFolders: Array.isArray(candidate.customFolders) ? candidate.customFolders.filter((item) => typeof item === "string") : [],
968
- storeRawMessageContent: Boolean(candidate.storeRawMessageContent),
968
+ customFolders: Array.isArray(candidate.customFolders) ? candidate.customFolders.filter((item) => typeof item === "string").map((item) => item.trim()).filter(Boolean) : [],
969
+ storeRawMessageContent: candidate.storeRawMessageContent === true,
969
970
  usageGuardrails: normalizeUsageGuardrails(candidate.usageGuardrails)
970
971
  };
971
972
  }
@@ -775,7 +775,8 @@ var init_model_aliases = __esm({
775
775
  // src/lib/format.ts
776
776
  function formatTokens(value) {
777
777
  const number4 = value ?? 0;
778
- if (number4 >= 1e6) return `${(number4 / 1e6).toFixed(2)}M`;
778
+ if (number4 >= 999995e3) return `${(number4 / 1e9).toFixed(2)}B`;
779
+ if (number4 >= 999950) return `${(number4 / 1e6).toFixed(2)}M`;
779
780
  if (number4 >= 1e3) return `${(number4 / 1e3).toFixed(1)}K`;
780
781
  return number4.toLocaleString();
781
782
  }
@@ -949,8 +950,8 @@ function normalizeSettings(value) {
949
950
  if (!value || typeof value !== "object") return defaultSettings;
950
951
  const candidate = value;
951
952
  return {
952
- customFolders: Array.isArray(candidate.customFolders) ? candidate.customFolders.filter((item) => typeof item === "string") : [],
953
- storeRawMessageContent: Boolean(candidate.storeRawMessageContent),
953
+ customFolders: Array.isArray(candidate.customFolders) ? candidate.customFolders.filter((item) => typeof item === "string").map((item) => item.trim()).filter(Boolean) : [],
954
+ storeRawMessageContent: candidate.storeRawMessageContent === true,
954
955
  usageGuardrails: normalizeUsageGuardrails(candidate.usageGuardrails)
955
956
  };
956
957
  }
@@ -775,7 +775,8 @@ var init_model_aliases = __esm({
775
775
  // src/lib/format.ts
776
776
  function formatTokens(value) {
777
777
  const number3 = value ?? 0;
778
- if (number3 >= 1e6) return `${(number3 / 1e6).toFixed(2)}M`;
778
+ if (number3 >= 999995e3) return `${(number3 / 1e9).toFixed(2)}B`;
779
+ if (number3 >= 999950) return `${(number3 / 1e6).toFixed(2)}M`;
779
780
  if (number3 >= 1e3) return `${(number3 / 1e3).toFixed(1)}K`;
780
781
  return number3.toLocaleString();
781
782
  }
@@ -949,8 +950,8 @@ function normalizeSettings(value) {
949
950
  if (!value || typeof value !== "object") return defaultSettings;
950
951
  const candidate = value;
951
952
  return {
952
- customFolders: Array.isArray(candidate.customFolders) ? candidate.customFolders.filter((item) => typeof item === "string") : [],
953
- storeRawMessageContent: Boolean(candidate.storeRawMessageContent),
953
+ customFolders: Array.isArray(candidate.customFolders) ? candidate.customFolders.filter((item) => typeof item === "string").map((item) => item.trim()).filter(Boolean) : [],
954
+ storeRawMessageContent: candidate.storeRawMessageContent === true,
954
955
  usageGuardrails: normalizeUsageGuardrails(candidate.usageGuardrails)
955
956
  };
956
957
  }
@@ -1470,8 +1470,16 @@ var init_default_model_prices = __esm({
1470
1470
  // src/lib/pricing-manifest.ts
1471
1471
  function nullableNumber(value) {
1472
1472
  if (value == null) return null;
1473
- const number = Number(value);
1474
- return Number.isFinite(number) ? number : null;
1473
+ if (typeof value === "number") {
1474
+ return Number.isFinite(value) && value >= 0 ? value : null;
1475
+ }
1476
+ if (typeof value === "string") {
1477
+ const trimmed = value.trim();
1478
+ if (!trimmed) return null;
1479
+ const number = Number(trimmed);
1480
+ return Number.isFinite(number) && number >= 0 ? number : null;
1481
+ }
1482
+ return null;
1475
1483
  }
1476
1484
  function validModel(value) {
1477
1485
  if (!value || typeof value !== "object") return null;
@@ -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;
@@ -532,8 +532,8 @@ function normalizeSettings(value) {
532
532
  if (!value || typeof value !== "object") return defaultSettings;
533
533
  const candidate = value;
534
534
  return {
535
- customFolders: Array.isArray(candidate.customFolders) ? candidate.customFolders.filter((item) => typeof item === "string") : [],
536
- storeRawMessageContent: Boolean(candidate.storeRawMessageContent),
535
+ customFolders: Array.isArray(candidate.customFolders) ? candidate.customFolders.filter((item) => typeof item === "string").map((item) => item.trim()).filter(Boolean) : [],
536
+ storeRawMessageContent: candidate.storeRawMessageContent === true,
537
537
  usageGuardrails: normalizeUsageGuardrails(candidate.usageGuardrails)
538
538
  };
539
539
  }
@@ -1566,15 +1566,16 @@ function deltaFromUsage(current, previous) {
1566
1566
  return delta.inputTokens + delta.cachedInputTokens + delta.outputTokens + delta.reasoningOutputTokens + delta.totalTokens > 0 ? delta : null;
1567
1567
  }
1568
1568
  function normalizedUsageFromDelta(delta) {
1569
- const inputTokens = Math.max(0, delta.inputTokens - delta.cachedInputTokens);
1569
+ const inputTokens = delta.inputTokens;
1570
1570
  const outputTokens = Math.max(0, delta.outputTokens - delta.reasoningOutputTokens);
1571
+ const displayedTotalWithCache = delta.totalTokens + delta.cachedInputTokens;
1571
1572
  const computedTotal = inputTokens + delta.cachedInputTokens + outputTokens + delta.reasoningOutputTokens;
1572
1573
  return {
1573
1574
  input_tokens: inputTokens,
1574
1575
  output_tokens: outputTokens,
1575
1576
  cache_read_input_tokens: delta.cachedInputTokens,
1576
1577
  reasoning_tokens: delta.reasoningOutputTokens,
1577
- total_tokens: Math.max(delta.totalTokens, computedTotal)
1578
+ total_tokens: Math.max(displayedTotalWithCache, computedTotal)
1578
1579
  };
1579
1580
  }
1580
1581
  function exactTokenCountRecords(records, fallbackSessionId) {
@@ -1617,7 +1618,7 @@ var init_codex_cli = __esm({
1617
1618
  codexCliAdapter = {
1618
1619
  id: "codex-cli",
1619
1620
  displayName: "Codex CLI",
1620
- version: 2,
1621
+ version: 3,
1621
1622
  async detect(file) {
1622
1623
  const extension = path5.extname(file.path).toLowerCase();
1623
1624
  if (isCodexCliUsagePath(file.path)) {
@@ -1879,23 +1880,31 @@ var init_generic_log = __esm({
1879
1880
  const session = textAfter(line, [/session(?:_id)?\s*[:=]\s*([^\s,]+)/i]);
1880
1881
  if (session) currentSession = session;
1881
1882
  projectPath = projectPath ?? textAfter(line, [/(?:cwd|project|path)\s*[:=]\s*(.+)$/i]);
1883
+ const codexSummaryLine = /\bToken usage:/i.test(line) || /\(\s*\+\s*[0-9,]+\s+cached\s*\)/i.test(line);
1882
1884
  const model = textAfter(line, [/model\s*[:=]\s*([A-Za-z0-9_.:/-]+)/i]);
1883
1885
  const inputTokens = numberAfter(line, [
1886
+ /\binput\s*[:=]\s*([0-9,]+)/i,
1884
1887
  /(?:input_tokens|prompt_tokens|input tokens|prompt tokens)\s*[:=]\s*([0-9,]+)/i
1885
1888
  ]);
1886
1889
  const outputTokens = numberAfter(line, [
1890
+ /\boutput\s*[:=]\s*([0-9,]+)/i,
1887
1891
  /(?:output_tokens|completion_tokens|output tokens|completion tokens)\s*[:=]\s*([0-9,]+)/i
1888
1892
  ]);
1889
1893
  const cacheReadTokens = numberAfter(line, [
1894
+ /\(\s*\+\s*([0-9,]+)\s+cached\s*\)/i,
1895
+ /\bcached\s*[:=]\s*([0-9,]+)/i,
1890
1896
  /(?:cache_read_input_tokens|cached_input_tokens|cache read tokens|cached tokens)\s*[:=]\s*([0-9,]+)/i
1891
1897
  ]);
1892
1898
  const cacheWriteTokens = numberAfter(line, [
1893
1899
  /(?:cache_creation_input_tokens|cache_write_input_tokens|cache write tokens|cache creation tokens)\s*[:=]\s*([0-9,]+)/i
1894
1900
  ]);
1895
1901
  const reasoningTokens = numberAfter(line, [
1902
+ /\(\s*reasoning\s+([0-9,]+)\s*\)/i,
1903
+ /\breasoning\s*[:=]\s*([0-9,]+)/i,
1896
1904
  /(?:reasoning_output_tokens|reasoning_tokens|reasoning output tokens|reasoning tokens)\s*[:=]\s*([0-9,]+)/i
1897
1905
  ]);
1898
1906
  const explicitTotalTokens = numberAfter(line, [
1907
+ /\btotal\s*[:=]\s*([0-9,]+)/i,
1899
1908
  /(?:total_tokens|total tokens)\s*[:=]\s*([0-9,]+)/i
1900
1909
  ]);
1901
1910
  const fallbackTotalTokens = inputTokens == null && outputTokens == null && cacheReadTokens == null && cacheWriteTokens == null && reasoningTokens == null ? numberAfter(line, [/\btokens\s*[:=]\s*([0-9,]+)/i]) : null;
@@ -1911,7 +1920,7 @@ var init_generic_log = __esm({
1911
1920
  const normalizedCacheWriteTokens = cacheWriteTokens ?? 0;
1912
1921
  const normalizedReasoningTokens = reasoningTokens ?? 0;
1913
1922
  const partSum = () => normalizedInputTokens + normalizedOutputTokens + normalizedCacheReadTokens + normalizedCacheWriteTokens + normalizedReasoningTokens;
1914
- if (inputTokens != null && totalTokens != null && normalizedCacheReadTokens + normalizedCacheWriteTokens > 0 && partSum() > totalTokens) {
1923
+ if (!codexSummaryLine && inputTokens != null && totalTokens != null && normalizedCacheReadTokens + normalizedCacheWriteTokens > 0 && partSum() > totalTokens) {
1915
1924
  normalizedInputTokens = Math.max(
1916
1925
  0,
1917
1926
  normalizedInputTokens - normalizedCacheReadTokens - normalizedCacheWriteTokens
@@ -1920,7 +1929,7 @@ var init_generic_log = __esm({
1920
1929
  if (outputTokens != null && totalTokens != null && normalizedReasoningTokens > 0 && partSum() > totalTokens) {
1921
1930
  normalizedOutputTokens = Math.max(0, normalizedOutputTokens - normalizedReasoningTokens);
1922
1931
  }
1923
- const structuredTotal = totalTokens ?? partSum();
1932
+ const structuredTotal = codexSummaryLine && totalTokens != null ? Math.max(totalTokens + normalizedCacheReadTokens + normalizedCacheWriteTokens, partSum()) : totalTokens ?? partSum();
1924
1933
  interactions2.push({
1925
1934
  externalId: `${currentSession}-${index2}`,
1926
1935
  timestamp,
@@ -170,7 +170,8 @@ var init_helpers = __esm({
170
170
  // src/lib/format.ts
171
171
  function formatTokens(value) {
172
172
  const number2 = value ?? 0;
173
- if (number2 >= 1e6) return `${(number2 / 1e6).toFixed(2)}M`;
173
+ if (number2 >= 999995e3) return `${(number2 / 1e9).toFixed(2)}B`;
174
+ if (number2 >= 999950) return `${(number2 / 1e6).toFixed(2)}M`;
174
175
  if (number2 >= 1e3) return `${(number2 / 1e3).toFixed(1)}K`;
175
176
  return number2.toLocaleString();
176
177
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tokentrace",
3
- "version": "0.8.1",
3
+ "version": "0.8.2",
4
4
  "description": "Local-first dashboard for AI CLI token, cost, and session analytics.",
5
5
  "author": {
6
6
  "name": "Abhi Yoheswaran",
@@ -45,9 +45,12 @@ function normalizeSettings(value: unknown): AppSettings {
45
45
  const candidate = value as Partial<AppSettings>;
46
46
  return {
47
47
  customFolders: Array.isArray(candidate.customFolders)
48
- ? candidate.customFolders.filter((item): item is string => typeof item === "string")
48
+ ? candidate.customFolders
49
+ .filter((item): item is string => typeof item === "string")
50
+ .map((item) => item.trim())
51
+ .filter(Boolean)
49
52
  : [],
50
- storeRawMessageContent: Boolean(candidate.storeRawMessageContent),
53
+ storeRawMessageContent: candidate.storeRawMessageContent === true,
51
54
  usageGuardrails: normalizeUsageGuardrails(candidate.usageGuardrails)
52
55
  };
53
56
  }
@@ -155,8 +155,9 @@ function deltaFromUsage(current: CodexUsageTotal, previous: CodexUsageTotal | nu
155
155
  }
156
156
 
157
157
  function normalizedUsageFromDelta(delta: CodexUsageTotal) {
158
- const inputTokens = Math.max(0, delta.inputTokens - delta.cachedInputTokens);
158
+ const inputTokens = delta.inputTokens;
159
159
  const outputTokens = Math.max(0, delta.outputTokens - delta.reasoningOutputTokens);
160
+ const displayedTotalWithCache = delta.totalTokens + delta.cachedInputTokens;
160
161
  const computedTotal =
161
162
  inputTokens + delta.cachedInputTokens + outputTokens + delta.reasoningOutputTokens;
162
163
 
@@ -165,7 +166,7 @@ function normalizedUsageFromDelta(delta: CodexUsageTotal) {
165
166
  output_tokens: outputTokens,
166
167
  cache_read_input_tokens: delta.cachedInputTokens,
167
168
  reasoning_tokens: delta.reasoningOutputTokens,
168
- total_tokens: Math.max(delta.totalTokens, computedTotal)
169
+ total_tokens: Math.max(displayedTotalWithCache, computedTotal)
169
170
  };
170
171
  }
171
172
 
@@ -207,7 +208,7 @@ function exactTokenCountRecords(records: Record<string, unknown>[], fallbackSess
207
208
  export const codexCliAdapter: IngestionAdapter = {
208
209
  id: "codex-cli",
209
210
  displayName: "Codex CLI",
210
- version: 2,
211
+ version: 3,
211
212
 
212
213
  async detect(file) {
213
214
  const extension = path.extname(file.path).toLowerCase();
@@ -57,23 +57,32 @@ export const genericLogAdapter: IngestionAdapter = {
57
57
  projectPath =
58
58
  projectPath ?? textAfter(line, [/(?:cwd|project|path)\s*[:=]\s*(.+)$/i]);
59
59
 
60
+ const codexSummaryLine =
61
+ /\bToken usage:/i.test(line) || /\(\s*\+\s*[0-9,]+\s+cached\s*\)/i.test(line);
60
62
  const model = textAfter(line, [/model\s*[:=]\s*([A-Za-z0-9_.:/-]+)/i]);
61
63
  const inputTokens = numberAfter(line, [
64
+ /\binput\s*[:=]\s*([0-9,]+)/i,
62
65
  /(?:input_tokens|prompt_tokens|input tokens|prompt tokens)\s*[:=]\s*([0-9,]+)/i
63
66
  ]);
64
67
  const outputTokens = numberAfter(line, [
68
+ /\boutput\s*[:=]\s*([0-9,]+)/i,
65
69
  /(?:output_tokens|completion_tokens|output tokens|completion tokens)\s*[:=]\s*([0-9,]+)/i
66
70
  ]);
67
71
  const cacheReadTokens = numberAfter(line, [
72
+ /\(\s*\+\s*([0-9,]+)\s+cached\s*\)/i,
73
+ /\bcached\s*[:=]\s*([0-9,]+)/i,
68
74
  /(?:cache_read_input_tokens|cached_input_tokens|cache read tokens|cached tokens)\s*[:=]\s*([0-9,]+)/i
69
75
  ]);
70
76
  const cacheWriteTokens = numberAfter(line, [
71
77
  /(?:cache_creation_input_tokens|cache_write_input_tokens|cache write tokens|cache creation tokens)\s*[:=]\s*([0-9,]+)/i
72
78
  ]);
73
79
  const reasoningTokens = numberAfter(line, [
80
+ /\(\s*reasoning\s+([0-9,]+)\s*\)/i,
81
+ /\breasoning\s*[:=]\s*([0-9,]+)/i,
74
82
  /(?:reasoning_output_tokens|reasoning_tokens|reasoning output tokens|reasoning tokens)\s*[:=]\s*([0-9,]+)/i
75
83
  ]);
76
84
  const explicitTotalTokens = numberAfter(line, [
85
+ /\btotal\s*[:=]\s*([0-9,]+)/i,
77
86
  /(?:total_tokens|total tokens)\s*[:=]\s*([0-9,]+)/i
78
87
  ]);
79
88
  const fallbackTotalTokens =
@@ -109,6 +118,7 @@ export const genericLogAdapter: IngestionAdapter = {
109
118
  normalizedCacheWriteTokens +
110
119
  normalizedReasoningTokens;
111
120
  if (
121
+ !codexSummaryLine &&
112
122
  inputTokens != null &&
113
123
  totalTokens != null &&
114
124
  normalizedCacheReadTokens + normalizedCacheWriteTokens > 0 &&
@@ -128,7 +138,9 @@ export const genericLogAdapter: IngestionAdapter = {
128
138
  normalizedOutputTokens = Math.max(0, normalizedOutputTokens - normalizedReasoningTokens);
129
139
  }
130
140
  const structuredTotal =
131
- totalTokens ?? partSum();
141
+ codexSummaryLine && totalTokens != null
142
+ ? Math.max(totalTokens + normalizedCacheReadTokens + normalizedCacheWriteTokens, partSum())
143
+ : totalTokens ?? partSum();
132
144
  interactions.push({
133
145
  externalId: `${currentSession}-${index}`,
134
146
  timestamp,
@@ -4,11 +4,24 @@ export type JsonObjectResult =
4
4
  | { ok: true; body: JsonObject }
5
5
  | { ok: false; error: string };
6
6
 
7
- export async function readJsonObject(request: Request): Promise<JsonObjectResult> {
8
- let body: unknown;
7
+ async function readJsonObjectText(request: Request, allowEmpty: boolean): Promise<JsonObjectResult> {
8
+ let text: string;
9
9
 
10
10
  try {
11
- body = await request.json();
11
+ text = await request.text();
12
+ } catch {
13
+ return { ok: false, error: "request body must be valid JSON" };
14
+ }
15
+
16
+ if (!text.trim()) {
17
+ return allowEmpty
18
+ ? { ok: true, body: {} }
19
+ : { ok: false, error: "request body must be valid JSON" };
20
+ }
21
+
22
+ let body: unknown;
23
+ try {
24
+ body = JSON.parse(text);
12
25
  } catch {
13
26
  return { ok: false, error: "request body must be valid JSON" };
14
27
  }
@@ -19,3 +32,15 @@ export async function readJsonObject(request: Request): Promise<JsonObjectResult
19
32
 
20
33
  return { ok: true, body: body as JsonObject };
21
34
  }
35
+
36
+ export function readJsonObject(request: Request): Promise<JsonObjectResult> {
37
+ return readJsonObjectText(request, false);
38
+ }
39
+
40
+ export function readOptionalJsonObject(request: Request): Promise<JsonObjectResult> {
41
+ return readJsonObjectText(request, true);
42
+ }
43
+
44
+ export function jsonBooleanFlag(value: unknown) {
45
+ return value === true;
46
+ }
package/src/lib/format.ts CHANGED
@@ -1,10 +1,21 @@
1
1
  export function formatTokens(value: number | null | undefined) {
2
2
  const number = value ?? 0;
3
- if (number >= 1_000_000) return `${(number / 1_000_000).toFixed(2)}M`;
3
+ if (number >= 999_995_000) return `${(number / 1_000_000_000).toFixed(2)}B`;
4
+ if (number >= 999_950) return `${(number / 1_000_000).toFixed(2)}M`;
4
5
  if (number >= 1_000) return `${(number / 1_000).toFixed(1)}K`;
5
6
  return number.toLocaleString();
6
7
  }
7
8
 
9
+ export function formatSignedTokens(value: number | null | undefined) {
10
+ const number = value ?? 0;
11
+ if (number === 0) return "0";
12
+ return `${number > 0 ? "+" : "-"}${formatTokens(Math.abs(number))}`;
13
+ }
14
+
15
+ export function formatExactTokens(value: number | null | undefined) {
16
+ return (value ?? 0).toLocaleString();
17
+ }
18
+
8
19
  export function formatCurrency(value: number | null | undefined, currency = "USD") {
9
20
  if (value == null) return "Unknown";
10
21
  return new Intl.NumberFormat("en-US", {
@@ -31,8 +31,16 @@ export type PricingManifest = {
31
31
 
32
32
  function nullableNumber(value: unknown) {
33
33
  if (value == null) return null;
34
- const number = Number(value);
35
- return Number.isFinite(number) ? number : null;
34
+ if (typeof value === "number") {
35
+ return Number.isFinite(value) && value >= 0 ? value : null;
36
+ }
37
+ if (typeof value === "string") {
38
+ const trimmed = value.trim();
39
+ if (!trimmed) return null;
40
+ const number = Number(trimmed);
41
+ return Number.isFinite(number) && number >= 0 ? number : null;
42
+ }
43
+ return null;
36
44
  }
37
45
 
38
46
  function validModel(value: unknown): PricingManifestModel | null {