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.
- package/CHANGELOG.md +26 -0
- package/app/api/prices/refresh/route.ts +18 -3
- package/app/api/prices/route.ts +39 -13
- package/app/api/repair-items/route.ts +6 -1
- package/app/api/scan/route.ts +17 -5
- package/app/api/settings/route.ts +11 -3
- package/app/evidence/page.tsx +3 -3
- package/app/page.tsx +7 -7
- package/dist/runtime/db-seed.mjs +10 -2
- package/dist/runtime/digest.mjs +507 -336
- package/dist/runtime/doctor.mjs +2181 -396
- package/dist/runtime/insights.mjs +783 -316
- package/dist/runtime/pricing-refresh.mjs +1159 -1029
- package/dist/runtime/reset.mjs +10 -2
- package/dist/runtime/scan.mjs +1259 -764
- package/dist/runtime/status.mjs +242 -86
- package/package.json +2 -2
- package/scripts/digest.ts +21 -3
- package/scripts/doctor.ts +22 -4
- package/scripts/insights.ts +18 -2
- package/scripts/pricing-refresh.ts +25 -9
- package/scripts/scan.ts +21 -8
- package/scripts/status.ts +33 -38
- package/src/db/settings.ts +5 -2
- package/src/ingestion/adapters/claude-code.ts +51 -20
- package/src/ingestion/adapters/codex-cli.ts +204 -17
- package/src/ingestion/adapters/generic-json.ts +1 -1
- package/src/ingestion/adapters/generic-jsonl.ts +1 -1
- package/src/ingestion/adapters/generic-log.ts +72 -10
- package/src/ingestion/adapters/helpers.ts +107 -42
- package/src/ingestion/discovery.ts +11 -2
- package/src/ingestion/persist.ts +43 -4
- package/src/ingestion/scan.ts +45 -18
- package/src/lib/analytics.ts +20 -19
- package/src/lib/api-json.ts +46 -0
- package/src/lib/format.ts +12 -1
- package/src/lib/pricing-manifest.ts +10 -2
- package/src/lib/pricing-refresh-cli.ts +57 -0
- package/src/lib/report-cli.ts +36 -0
- package/src/lib/scan-cli.ts +50 -0
- 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
|
|
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
|
|
10
|
-
force:
|
|
24
|
+
source,
|
|
25
|
+
force: jsonBooleanFlag(body.force)
|
|
11
26
|
});
|
|
12
27
|
return NextResponse.json(result);
|
|
13
28
|
}
|
package/app/api/prices/route.ts
CHANGED
|
@@ -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
|
|
7
|
-
|
|
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
|
-
|
|
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
|
|
18
|
-
if (!
|
|
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
|
|
24
|
-
providerName:
|
|
25
|
-
model
|
|
26
|
-
inputTokenPrice:
|
|
27
|
-
outputTokenPrice:
|
|
28
|
-
cachedInputTokenPrice:
|
|
29
|
-
cacheWriteTokenPrice:
|
|
30
|
-
currency:
|
|
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
|
|
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
|
|
package/app/api/scan/route.ts
CHANGED
|
@@ -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
|
|
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:
|
|
10
|
-
|
|
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
|
|
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
|
|
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:
|
|
29
|
+
storeRawMessageContent: jsonBooleanFlag(body.storeRawMessageContent),
|
|
22
30
|
usageGuardrails: normalizeUsageGuardrails(body.usageGuardrails)
|
|
23
31
|
});
|
|
24
32
|
|
package/app/evidence/page.tsx
CHANGED
|
@@ -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,
|
|
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">{
|
|
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>{
|
|
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={
|
|
322
|
+
delta={formatSignedTokens(data.comparison.delta.totalTokens)}
|
|
323
323
|
percentValue={data.comparison.delta.totalTokensPercent}
|
|
324
324
|
previous={formatTokens(data.comparison.previous.totalTokens)}
|
|
325
325
|
/>
|
package/dist/runtime/db-seed.mjs
CHANGED
|
@@ -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
|
-
|
|
1197
|
-
|
|
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;
|