tokentrace 0.18.0 → 0.18.1

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
@@ -4,6 +4,41 @@ All notable changes to TokenTrace are documented here.
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## [0.18.1] - 2026-05-28
8
+
9
+ ### Fixed
10
+
11
+ - **Scheduled scans now coalesce concurrent runs.** `runDueScheduledScan`
12
+ fires on every overview page load; two overlapping loads (a second tab, a
13
+ quick refresh, a prefetch) previously both passed the due-check and both
14
+ called `runScan`, producing duplicate scan runs. A module-scoped in-flight
15
+ promise guard now coalesces concurrent callers into a single scan within
16
+ the server process.
17
+ - **`tokentrace repair auto-classify --apply` is now atomic.** The alias
18
+ writes and cost backfills for a batch run inside a single SQLite
19
+ transaction, and `backfillAlias` wraps its per-row updates in a transaction
20
+ too, so a failure partway can no longer leave the local database
21
+ half-applied with inaccurate reported counts.
22
+ - **`tokentrace query` surfaces range errors cleanly.** Range validation
23
+ (e.g. `--from` after `--to`, or a preset combined with explicit dates) now
24
+ prints the validation message plus usage and exits non-zero, instead of
25
+ letting a raw stack trace escape.
26
+ - **Anomaly z-scores survive JSON.** The flat-window (`mad === 0`) severe
27
+ branch reported `Infinity`, which `JSON.stringify` turned into `null` and
28
+ silently dropped the signal. It now reports a finite sentinel z-score.
29
+ - **Stricter query CLI parsing.** Empty `--flag=` values are rejected with a
30
+ clear "Missing value" message rather than flowing through as empty strings.
31
+
32
+ ### Internal
33
+
34
+ - **Test suite no longer flakes under parallel load.** CLI/MCP integration
35
+ tests spawn `bin/tokentrace.js`, which itself spawns `db-migrate` and
36
+ `db-seed`, fanning out to ~3x the core count in node processes. The Vitest
37
+ fork pool is now capped at roughly half the available cores with one
38
+ generous global timeout, removing the scattered per-test timeout overrides.
39
+ - Removed the dead `tailwind.config.ts` left over from the Tailwind 4
40
+ (CSS-first) migration.
41
+
7
42
  ## [0.18.0] - 2026-05-28
8
43
 
9
44
  ### Local intelligence
@@ -846,7 +846,7 @@ function scoreValue(value, window, thresholds) {
846
846
  }
847
847
  const flatRunLength = window.filter((entry) => entry === baseline).length;
848
848
  if (value > 2 * baseline && flatRunLength >= 3) {
849
- return { severity: "severe", baseline, zScore: Number.POSITIVE_INFINITY };
849
+ return { severity: "severe", baseline, zScore: FLAT_RUN_Z };
850
850
  }
851
851
  return { severity: null, baseline, zScore: 0 };
852
852
  }
@@ -907,7 +907,7 @@ function detectAnomalies(points, options2 = {}) {
907
907
  summary
908
908
  };
909
909
  }
910
- var DEFAULT_WINDOW, DEFAULT_MIN_WINDOW, DEFAULT_THRESHOLDS, MAD_TO_STDDEV, METRICS;
910
+ var DEFAULT_WINDOW, DEFAULT_MIN_WINDOW, DEFAULT_THRESHOLDS, MAD_TO_STDDEV, FLAT_RUN_Z, METRICS;
911
911
  var init_anomaly_detection = __esm({
912
912
  "src/lib/anomaly-detection.ts"() {
913
913
  "use strict";
@@ -915,6 +915,7 @@ var init_anomaly_detection = __esm({
915
915
  DEFAULT_MIN_WINDOW = 5;
916
916
  DEFAULT_THRESHOLDS = { notable: 3, high: 4.5, severe: 6 };
917
917
  MAD_TO_STDDEV = 0.6745;
918
+ FLAT_RUN_Z = 999;
918
919
  METRICS = [
919
920
  { metric: "tokens", valueOf: (point) => point.totalTokens },
920
921
  { metric: "cost", valueOf: (point) => point.cost }
@@ -1659,7 +1659,6 @@ function recalculateInteractionCosts() {
1659
1659
  currency: interaction.alias_currency ?? "USD"
1660
1660
  } : null;
1661
1661
  const resolvedPrice = aliasPrice ?? directPrice;
1662
- const resolvedViaAlias = aliasPrice != null;
1663
1662
  const cost = calculateInteractionCost(
1664
1663
  {
1665
1664
  inputTokens: interaction.input_tokens,
@@ -1671,6 +1670,7 @@ function recalculateInteractionCosts() {
1671
1670
  },
1672
1671
  resolvedPrice
1673
1672
  );
1673
+ const resolvedViaAlias = aliasPrice != null && cost.amount != null;
1674
1674
  if (cost.status === "unknown") unknownCostInteractions += 1;
1675
1675
  const metadata = {
1676
1676
  ...existingMetadata,
@@ -940,7 +940,6 @@ function recalculateInteractionCosts() {
940
940
  currency: interaction.alias_currency ?? "USD"
941
941
  } : null;
942
942
  const resolvedPrice = aliasPrice ?? directPrice;
943
- const resolvedViaAlias = aliasPrice != null;
944
943
  const cost = calculateInteractionCost(
945
944
  {
946
945
  inputTokens: interaction.input_tokens,
@@ -952,6 +951,7 @@ function recalculateInteractionCosts() {
952
951
  },
953
952
  resolvedPrice
954
953
  );
954
+ const resolvedViaAlias = aliasPrice != null && cost.amount != null;
955
955
  if (cost.status === "unknown") unknownCostInteractions += 1;
956
956
  const metadata = {
957
957
  ...existingMetadata,
@@ -942,7 +942,11 @@ var SORT = ["asc", "desc"];
942
942
  var PRESET = ["today", "7d", "30d", "60d", "90d", "all"];
943
943
  function valueOf(arg, next) {
944
944
  const eq = arg.indexOf("=");
945
- if (eq >= 0) return { value: arg.slice(eq + 1), consumedNext: false };
945
+ if (eq >= 0) {
946
+ const value = arg.slice(eq + 1);
947
+ if (!value) throw new Error(`Missing value for ${arg.slice(0, eq)}`);
948
+ return { value, consumedNext: false };
949
+ }
946
950
  if (next == null || next.startsWith("-")) {
947
951
  throw new Error(`Missing value for ${arg}`);
948
952
  }
@@ -1075,7 +1079,14 @@ if (parsed.help) {
1075
1079
  process.exit(0);
1076
1080
  }
1077
1081
  var { runStructuredQuery: runStructuredQuery2 } = await Promise.resolve().then(() => (init_structured_query(), structured_query_exports));
1078
- var result = runStructuredQuery2(parsed.args);
1082
+ var result;
1083
+ try {
1084
+ result = runStructuredQuery2(parsed.args);
1085
+ } catch (error) {
1086
+ console.error(error instanceof Error ? error.message : "Invalid query.");
1087
+ console.error(structuredQueryUsage());
1088
+ process.exit(1);
1089
+ }
1079
1090
  if (parsed.json) {
1080
1091
  console.log(JSON.stringify(result, null, 2));
1081
1092
  } else {
@@ -897,6 +897,12 @@ var init_schema = __esm({
897
897
  });
898
898
 
899
899
  // src/db/client.ts
900
+ var client_exports = {};
901
+ __export(client_exports, {
902
+ db: () => db,
903
+ getDatabasePath: () => getDatabasePath,
904
+ sqlite: () => sqlite
905
+ });
900
906
  import fs from "node:fs";
901
907
  import path from "node:path";
902
908
  import { fileURLToPath } from "node:url";
@@ -910,6 +916,9 @@ function databaseUrlPath(value) {
910
916
  return value.slice("file:".length);
911
917
  }
912
918
  }
919
+ function getDatabasePath() {
920
+ return dbPath;
921
+ }
913
922
  var defaultDbPath, dbPath, sqlite, db;
914
923
  var init_client = __esm({
915
924
  "src/db/client.ts"() {
@@ -2001,9 +2010,12 @@ function backfillAlias(alias, options2 = {}) {
2001
2010
  const stmt = prepareCached(
2002
2011
  "UPDATE interactions SET cost = ?, cost_estimated = 1 WHERE id = ?"
2003
2012
  );
2004
- for (const update of updates) {
2005
- stmt.run(update.cost, update.id);
2006
- }
2013
+ const writeAll = sqlite.transaction((rows4) => {
2014
+ for (const update of rows4) {
2015
+ stmt.run(update.cost, update.id);
2016
+ }
2017
+ });
2018
+ writeAll(updates);
2007
2019
  }
2008
2020
  return {
2009
2021
  alias,
@@ -2016,6 +2028,7 @@ function backfillAlias(alias, options2 = {}) {
2016
2028
  var init_backfill = __esm({
2017
2029
  "src/lib/model-aliases/backfill.ts"() {
2018
2030
  "use strict";
2031
+ init_client();
2019
2032
  init_prepared();
2020
2033
  init_cost();
2021
2034
  }
@@ -2248,10 +2261,11 @@ if (args[0] === "auto-classify") {
2248
2261
  const workbench2 = buildUnknownCostRepairWorkbench3();
2249
2262
  const result = buildAutoClassifyResult2(workbench2, { minConfidence: options2.minConfidence });
2250
2263
  if (options2.apply) {
2251
- const [{ upsertAlias: upsertAlias2 }, { backfillAlias: backfillAlias2 }, { prepareCached: prepareCached2 }] = await Promise.all([
2264
+ const [{ upsertAlias: upsertAlias2 }, { backfillAlias: backfillAlias2 }, { prepareCached: prepareCached2 }, { sqlite: sqlite2 }] = await Promise.all([
2252
2265
  Promise.resolve().then(() => (init_store(), store_exports)),
2253
2266
  Promise.resolve().then(() => (init_backfill(), backfill_exports)),
2254
- Promise.resolve().then(() => (init_prepared(), prepared_exports))
2267
+ Promise.resolve().then(() => (init_prepared(), prepared_exports)),
2268
+ Promise.resolve().then(() => (init_client(), client_exports))
2255
2269
  ]);
2256
2270
  const outcome = {
2257
2271
  dryRun: options2.dryRun,
@@ -2271,18 +2285,18 @@ if (args[0] === "auto-classify") {
2271
2285
  ).get(modelName, providerName, providerName);
2272
2286
  return row2 ?? null;
2273
2287
  };
2274
- for (const suggestion of result.suggestions) {
2288
+ const processSuggestion = (suggestion) => {
2275
2289
  const c = suggestion.classification;
2276
2290
  if (c.rule === "none" || !c.suggestedModel) {
2277
2291
  outcome.skipped.push({ key: suggestion.key, reason: "No suggested model." });
2278
- continue;
2292
+ return;
2279
2293
  }
2280
2294
  if (c.rule === "parser-source") {
2281
2295
  outcome.skipped.push({
2282
2296
  key: suggestion.key,
2283
2297
  reason: "parser-source suggestions have no (provider, observed-model) pair; fix the parser instead."
2284
2298
  });
2285
- continue;
2299
+ return;
2286
2300
  }
2287
2301
  const resolved = resolvePricedModelId(
2288
2302
  c.suggestedProvider ?? suggestion.provider,
@@ -2293,7 +2307,7 @@ if (args[0] === "auto-classify") {
2293
2307
  key: suggestion.key,
2294
2308
  reason: `Could not resolve priced model "${c.suggestedModel}" under provider "${suggestion.provider}".`
2295
2309
  });
2296
- continue;
2310
+ return;
2297
2311
  }
2298
2312
  if (!options2.dryRun) {
2299
2313
  upsertAlias2({
@@ -2325,6 +2339,14 @@ if (args[0] === "auto-classify") {
2325
2339
  affectedInteractions: backfill.affectedInteractions,
2326
2340
  addedCost: backfill.totalCost
2327
2341
  });
2342
+ };
2343
+ if (options2.dryRun) {
2344
+ for (const suggestion of result.suggestions) processSuggestion(suggestion);
2345
+ } else {
2346
+ const applyBatch = sqlite2.transaction(() => {
2347
+ for (const suggestion of result.suggestions) processSuggestion(suggestion);
2348
+ });
2349
+ applyBatch();
2328
2350
  }
2329
2351
  result.applied = outcome;
2330
2352
  }
@@ -1659,7 +1659,6 @@ function recalculateInteractionCosts() {
1659
1659
  currency: interaction.alias_currency ?? "USD"
1660
1660
  } : null;
1661
1661
  const resolvedPrice = aliasPrice ?? directPrice;
1662
- const resolvedViaAlias = aliasPrice != null;
1663
1662
  const cost = calculateInteractionCost(
1664
1663
  {
1665
1664
  inputTokens: interaction.input_tokens,
@@ -1671,6 +1670,7 @@ function recalculateInteractionCosts() {
1671
1670
  },
1672
1671
  resolvedPrice
1673
1672
  );
1673
+ const resolvedViaAlias = aliasPrice != null && cost.amount != null;
1674
1674
  if (cost.status === "unknown") unknownCostInteractions += 1;
1675
1675
  const metadata = {
1676
1676
  ...existingMetadata,
@@ -4337,7 +4337,6 @@ function recalculateInteractionCosts() {
4337
4337
  currency: interaction.alias_currency ?? "USD"
4338
4338
  } : null;
4339
4339
  const resolvedPrice = aliasPrice ?? directPrice;
4340
- const resolvedViaAlias = aliasPrice != null;
4341
4340
  const cost = calculateInteractionCost(
4342
4341
  {
4343
4342
  inputTokens: interaction.input_tokens,
@@ -4349,6 +4348,7 @@ function recalculateInteractionCosts() {
4349
4348
  },
4350
4349
  resolvedPrice
4351
4350
  );
4351
+ const resolvedViaAlias = aliasPrice != null && cost.amount != null;
4352
4352
  if (cost.status === "unknown") unknownCostInteractions += 1;
4353
4353
  const metadata = {
4354
4354
  ...existingMetadata,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tokentrace",
3
- "version": "0.18.0",
3
+ "version": "0.18.1",
4
4
  "mcpName": "io.github.abhiyoheswaran1/tokentrace",
5
5
  "description": "Local-first dashboard for AI CLI token, cost, and session analytics.",
6
6
  "author": {
package/scripts/query.ts CHANGED
@@ -17,7 +17,18 @@ if (parsed.help) {
17
17
  }
18
18
 
19
19
  const { runStructuredQuery } = await import("@/src/lib/structured-query");
20
- const result = runStructuredQuery(parsed.args);
20
+
21
+ let result: ReturnType<typeof runStructuredQuery>;
22
+ try {
23
+ // Range/argument validation (e.g. from >= to, preset + from/to together)
24
+ // lives in runStructuredQuery, so surface those the same clean way as parse
25
+ // errors instead of letting a raw stack trace escape.
26
+ result = runStructuredQuery(parsed.args);
27
+ } catch (error) {
28
+ console.error(error instanceof Error ? error.message : "Invalid query.");
29
+ console.error(structuredQueryUsage());
30
+ process.exit(1);
31
+ }
21
32
 
22
33
  if (parsed.json) {
23
34
  console.log(JSON.stringify(result, null, 2));
package/scripts/repair.ts CHANGED
@@ -49,10 +49,11 @@ if (args[0] === "auto-classify") {
49
49
  const result = buildAutoClassifyResult(workbench, { minConfidence: options.minConfidence });
50
50
 
51
51
  if (options.apply) {
52
- const [{ upsertAlias }, { backfillAlias }, { prepareCached }] = await Promise.all([
52
+ const [{ upsertAlias }, { backfillAlias }, { prepareCached }, { sqlite }] = await Promise.all([
53
53
  import("@/src/lib/model-aliases/store"),
54
54
  import("@/src/lib/model-aliases/backfill"),
55
- import("@/src/db/prepared")
55
+ import("@/src/db/prepared"),
56
+ import("@/src/db/client")
56
57
  ]);
57
58
 
58
59
  const outcome: NonNullable<typeof result.applied> = {
@@ -79,18 +80,18 @@ if (args[0] === "auto-classify") {
79
80
  return row ?? null;
80
81
  };
81
82
 
82
- for (const suggestion of result.suggestions) {
83
+ const processSuggestion = (suggestion: (typeof result.suggestions)[number]) => {
83
84
  const c = suggestion.classification;
84
85
  if (c.rule === "none" || !c.suggestedModel) {
85
86
  outcome.skipped.push({ key: suggestion.key, reason: "No suggested model." });
86
- continue;
87
+ return;
87
88
  }
88
89
  if (c.rule === "parser-source") {
89
90
  outcome.skipped.push({
90
91
  key: suggestion.key,
91
92
  reason: "parser-source suggestions have no (provider, observed-model) pair; fix the parser instead."
92
93
  });
93
- continue;
94
+ return;
94
95
  }
95
96
  const resolved = resolvePricedModelId(
96
97
  c.suggestedProvider ?? suggestion.provider,
@@ -101,7 +102,7 @@ if (args[0] === "auto-classify") {
101
102
  key: suggestion.key,
102
103
  reason: `Could not resolve priced model "${c.suggestedModel}" under provider "${suggestion.provider}".`
103
104
  });
104
- continue;
105
+ return;
105
106
  }
106
107
 
107
108
  if (!options.dryRun) {
@@ -136,6 +137,18 @@ if (args[0] === "auto-classify") {
136
137
  affectedInteractions: backfill.affectedInteractions,
137
138
  addedCost: backfill.totalCost
138
139
  });
140
+ };
141
+
142
+ if (options.dryRun) {
143
+ for (const suggestion of result.suggestions) processSuggestion(suggestion);
144
+ } else {
145
+ // Apply the whole batch atomically: every alias + cost backfill commits
146
+ // together, or none does, so a failure partway can't leave the local
147
+ // database half-applied with inaccurate reported counts.
148
+ const applyBatch = sqlite.transaction(() => {
149
+ for (const suggestion of result.suggestions) processSuggestion(suggestion);
150
+ });
151
+ applyBatch();
139
152
  }
140
153
 
141
154
  result.applied = outcome;
package/server.json CHANGED
@@ -8,12 +8,12 @@
8
8
  "url": "https://github.com/abhiyoheswaran1/tokentrace",
9
9
  "source": "github"
10
10
  },
11
- "version": "0.18.0",
11
+ "version": "0.18.1",
12
12
  "packages": [
13
13
  {
14
14
  "registryType": "npm",
15
15
  "identifier": "tokentrace",
16
- "version": "0.18.0",
16
+ "version": "0.18.1",
17
17
  "runtimeHint": "npx",
18
18
  "packageArguments": [
19
19
  {
@@ -42,6 +42,12 @@ const DEFAULT_THRESHOLDS: AnomalyThresholds = { notable: 3, high: 4.5, severe: 6
42
42
  // Makes MAD a consistent estimator of stddev under normality.
43
43
  const MAD_TO_STDDEV = 0.6745;
44
44
 
45
+ // When the trailing window has zero spread (mad === 0) a true modified z-score
46
+ // is undefined/infinite. We still flag a large jump as severe, but report a
47
+ // finite sentinel z-score so the value round-trips through JSON (JSON.stringify
48
+ // serializes Infinity as null, which would silently drop the signal).
49
+ const FLAT_RUN_Z = 999;
50
+
45
51
  function median(values: number[]): number {
46
52
  if (values.length === 0) return 0;
47
53
  const sorted = values.slice().sort((a, b) => a - b);
@@ -81,7 +87,7 @@ function scoreValue(value: number, window: number[], thresholds: AnomalyThreshol
81
87
  }
82
88
  const flatRunLength = window.filter((entry) => entry === baseline).length;
83
89
  if (value > 2 * baseline && flatRunLength >= 3) {
84
- return { severity: "severe", baseline, zScore: Number.POSITIVE_INFINITY };
90
+ return { severity: "severe", baseline, zScore: FLAT_RUN_Z };
85
91
  }
86
92
  return { severity: null, baseline, zScore: 0 };
87
93
  }
@@ -238,7 +238,6 @@ export function recalculateInteractionCosts(): CostRecalculationResult {
238
238
  }
239
239
  : null;
240
240
  const resolvedPrice = aliasPrice ?? directPrice;
241
- const resolvedViaAlias = aliasPrice != null;
242
241
 
243
242
  const cost = calculateInteractionCost(
244
243
  {
@@ -252,6 +251,11 @@ export function recalculateInteractionCosts(): CostRecalculationResult {
252
251
  resolvedPrice
253
252
  );
254
253
 
254
+ // Only treat the row as alias-resolved if the alias actually produced a
255
+ // cost. An alias pointing at a model that itself lacks complete pricing
256
+ // must not be labelled as estimated/alias-sourced with a null amount.
257
+ const resolvedViaAlias = aliasPrice != null && cost.amount != null;
258
+
255
259
  if (cost.status === "unknown") unknownCostInteractions += 1;
256
260
  const metadata = {
257
261
  ...existingMetadata,
@@ -1,3 +1,4 @@
1
+ import { sqlite } from "@/src/db/client";
1
2
  import { prepareCached } from "@/src/db/prepared";
2
3
  import { calculateInteractionCost, type PriceConfig } from "@/src/lib/cost";
3
4
 
@@ -115,9 +116,14 @@ export function backfillAlias(
115
116
  const stmt = prepareCached(
116
117
  "UPDATE interactions SET cost = ?, cost_estimated = 1 WHERE id = ?"
117
118
  );
118
- for (const update of updates) {
119
- stmt.run(update.cost, update.id);
120
- }
119
+ // Atomic: either every matched interaction is backfilled or none are, so a
120
+ // mid-loop failure can't leave the cost column half-updated.
121
+ const writeAll = sqlite.transaction((rows: Array<{ id: string; cost: number }>) => {
122
+ for (const update of rows) {
123
+ stmt.run(update.cost, update.id);
124
+ }
125
+ });
126
+ writeAll(updates);
121
127
  }
122
128
 
123
129
  return {
@@ -10,7 +10,24 @@ function latestScanDate() {
10
10
  return row?.scanAt ? new Date(row.scanAt) : null;
11
11
  }
12
12
 
13
- export async function runDueScheduledScan(now = new Date()) {
13
+ type ScheduledScanOutcome = Awaited<ReturnType<typeof runScheduledScan>>;
14
+
15
+ // The dashboard fires runDueScheduledScan() on every overview page load. Two
16
+ // overlapping loads (a second tab, a quick refresh, prefetch) would otherwise
17
+ // both pass the due-check and both call runScan(), producing duplicate scan
18
+ // runs and wasted work. The Next.js server is a single process, so a
19
+ // module-scoped promise guard coalesces concurrent callers into one scan.
20
+ let inFlightScheduledScan: Promise<ScheduledScanOutcome> | null = null;
21
+
22
+ export function runDueScheduledScan(now = new Date()): Promise<ScheduledScanOutcome> {
23
+ if (inFlightScheduledScan) return inFlightScheduledScan;
24
+ inFlightScheduledScan = runScheduledScan(now).finally(() => {
25
+ inFlightScheduledScan = null;
26
+ });
27
+ return inFlightScheduledScan;
28
+ }
29
+
30
+ async function runScheduledScan(now: Date) {
14
31
  const settings = getAppSettings();
15
32
  if (!isScanDue(settings.scanSchedule, latestScanDate(), now)) {
16
33
  return {
@@ -47,7 +47,11 @@ const PRESET: readonly StructuredQueryRangePreset[] = ["today", "7d", "30d", "60
47
47
 
48
48
  function valueOf(arg: string, next: string | undefined): { value: string; consumedNext: boolean } {
49
49
  const eq = arg.indexOf("=");
50
- if (eq >= 0) return { value: arg.slice(eq + 1), consumedNext: false };
50
+ if (eq >= 0) {
51
+ const value = arg.slice(eq + 1);
52
+ if (!value) throw new Error(`Missing value for ${arg.slice(0, eq)}`);
53
+ return { value, consumedNext: false };
54
+ }
51
55
  if (next == null || next.startsWith("-")) {
52
56
  throw new Error(`Missing value for ${arg}`);
53
57
  }
package/tsconfig.json CHANGED
@@ -38,7 +38,6 @@
38
38
  ".next/dev/types/**/*.ts"
39
39
  ],
40
40
  "exclude": [
41
- "node_modules",
42
- "tailwind.config.ts"
41
+ "node_modules"
43
42
  ]
44
43
  }