Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,55 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),\
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [1.9.2] - 2026-07-15

### Fixed

- **Deep Rescan can no longer shrink sealed history.** The windowed rebuild
used to overwrite sealed relay days unconditionally; when the raw JSONL
behind a day had aged out (or a file read truncated mid-stream), the rebuilt
day was smaller and the loss was permanent — this shrank a sealed
2026-06-12 on 2026-07-12, and by 2026-07-15 four more days (2.4B tokens)
would have gone the same way. Sealed days are now only replaced when the
rebuild carries at least as much data, per provider (`shouldKeepSealedDay`);
`/api/rescan?force=true` remains the explicit override for parser-fix
rescans where shrinking is the point.
- Per-file fail-soft read errors in the codex and claude-code parsers (and
the shared `readJsonlFile`/`readJsonlFileFromOffset` helpers) now surface
as provider warnings tagged `partial` instead of silently truncating a
scan. Partial faults deliberately do NOT abort the trailing gap-fill (one
permanently unreadable file must not block sealing forever); a forced deep
rescan refuses to shrink a sealed day for a provider that hit a truncated
read during that rebuild.
- The sealed-day guard also protects frozen cost: a rebuild that re-derives
identical tokens but lower cost (kosha unreadable/offline during the
rescan, a model since pruned from the registry) keeps the sealed day.
- Provider warnings no longer accumulate without bound in the warm daemon:
each refresh tick and each deep rescan replaces the previous cycle's
provider warnings instead of appending duplicates forever.
- Summary cache schema bumped to 3 so cached summaries holding the old
even-split model rows rebuild instead of being served post-upgrade.
- The trailing gap-fill no longer seals days BEFORE the gap from the partial
slice a fresh-mtime multi-day file happens to carry — interior holes stay
open for an explicit rescan instead of freezing a partial day forever.
- Model summaries now use the exact per-project (provider, model) cross-cut
instead of even-splitting a day-level bucket across providers — one model
used by codex and codex-desktop in the same day no longer shows two
identical phantom rows with fractional half-tokens.
- The web dashboard's "Today" is now actually today: the last daily entry is
only presented as today when its date matches the local calendar date,
instead of relabeling the most recent active day.
- `ensure-summary` no longer bakes an arbitrarily stale cached summary into
the static web build: it prefers the live daemon, accepts the cache only if
it was scanned today, and rescans otherwise.

### Added

- **Today, hour by hour** panel on the web dashboard: today's cost per local
hour as a 24-bar chart with a custom hover tooltip (cost, tokens, records,
busiest model per hour), current-hour marker, and an honest whole-series
switch to token bars when the day has no priced usage.

## [1.9.1] - 2026-07-10

### Added
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
<p align="center"><strong>Token Usage Tracker for AI Coding Agents</strong></p>

<p align="center">
<a href="https://github.com/sriinnu/tokmeter/releases/latest"><img src="https://img.shields.io/badge/release-v1.9.2-39d353?style=flat-square&logo=github" alt="release" /></a>
<a href="https://www.npmjs.com/package/@sriinnu/tokmeter"><img src="https://img.shields.io/badge/npm-@sriinnu/tokmeter-39d353?style=flat-square&logo=npm" alt="npm" /></a>
<img src="https://img.shields.io/badge/node-%3E%3D18-0e4429?style=flat-square&logo=node.js" alt="node" />
<img src="https://img.shields.io/badge/license-AGPL--3.0-26a641?style=flat-square" alt="license" />
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@sriinnu/tokmeter-cli",
"version": "1.9.1",
"version": "1.9.2",
"private": true,
"description": "Token usage tracking CLI and automation helpers",
"type": "module",
Expand Down
2 changes: 1 addition & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@sriinnu/tokmeter-core",
"version": "1.9.1",
"version": "1.9.2",
"private": true,
"description": "Token usage tracking core — session parsers, aggregation, and pricing",
"type": "module",
Expand Down
24 changes: 22 additions & 2 deletions packages/core/src/aggregate-consumers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -389,11 +389,31 @@ export function computeModelCostsFromState(
return modelTotalsToSummaries(totals.values(), grandTotalCost);
}

// Unfiltered fast path: day-level model buckets. Multi-provider single-model
// days even-split (best we can do at this granularity).
// Unfiltered path. Exact (provider, model) pairing lives in the per-project
// cross-cut buckets — use them whenever the day carries them. Only
// pre-enrichment day files (modelBuckets backfilled empty by
// forwardMigrateAggregate) fall back to the day-level even-split
// approximation, which fabricates identical half-token rows when one model
// ran under two providers in a day (codex + codex-desktop sharing
// gpt-5.6-sol showed 190,725,623.5 tokens EACH).
for (const day of iterateAllDays(aggregates, todayAccumulator)) {
if (!dayInWindow(day, opts, todayKey)) continue;
grandTotalCost += day.cost;
// The cross-cut must be COMPLETE for the day, not merely present: a
// partially corrupt/hand-edited file with one bucket-less project would
// otherwise silently drop that project's models from the rows. Every
// record folds into exactly one (project, model) bucket, so a complete
// cross-cut's record count equals the day's.
let bucketRecords = 0;
for (const project of Object.values(day.projects)) {
for (const mb of Object.values(project.modelBuckets)) bucketRecords += mb.recordCount;
}
if (bucketRecords >= day.recordCount && day.recordCount > 0) {
for (const project of Object.values(day.projects)) {
for (const mb of Object.values(project.modelBuckets)) foldCrossCut(totals, mb);
}
continue;
}
for (const model of Object.values(day.models)) {
const n = model.providers.length;
for (const provider of model.providers) {
Expand Down
41 changes: 41 additions & 0 deletions packages/core/src/aggregate-migration-parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -492,3 +492,44 @@ describe("Phase 3 parity — provider filter on aggregate getters", () => {
expect(fresh).toEqual(legacy);
});
});

describe("getModelCosts — one model under two providers stays exactly attributed", () => {
// codex + codex-desktop both ran gpt-5.6-sol on the same day with very
// different volumes. The old day-level even-split fabricated two identical
// half-token rows (…623.5 each); the per-project cross-cut is exact.
const now = Date.now();
const records = [
r({
timestamp: now,
provider: "codex" as TokenRecord["provider"],
model: "gpt-5.6-sol",
project: "alpha",
inputTokens: 1000,
outputTokens: 500,
cost: 2,
}),
r({
timestamp: now + 1,
provider: "codex-desktop" as TokenRecord["provider"],
model: "gpt-5.6-sol",
project: "alpha",
inputTokens: 9000,
outputTokens: 100,
cost: 0,
}),
];
const { aggregates, todayAcc } = projectToState(records, now);

test("matches legacy exact pairing — no even-split, no fractional tokens", () => {
const legacy = aggregateByModel(records);
const fresh = computeModelCostsFromState(aggregates, todayAcc);
expect(fresh).toEqual(legacy);
const codexRow = fresh.find((m) => m.provider === "codex");
const desktopRow = fresh.find((m) => m.provider === "codex-desktop");
expect(codexRow?.inputTokens).toBe(1000);
expect(desktopRow?.inputTokens).toBe(9000);
for (const row of fresh) {
expect(Number.isInteger(row.totalTokens)).toBe(true);
}
});
});
Binary file modified packages/core/src/aggregates.ts
Binary file not shown.
10 changes: 9 additions & 1 deletion packages/core/src/config-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ export type DefaultRange = "all" | "today" | "week" | "month" | "year";
export type DefaultSort = "cost" | "tokens" | "activeDays";
export type MenubarColorSource = "off" | "context" | "block" | "budget";
const MENUBAR_COLOR_SOURCES: readonly MenubarColorSource[] = ["off", "context", "block", "budget"];
export type WeekChartStyle = "line" | "bars" | "area";
const WEEK_CHART_STYLES: readonly WeekChartStyle[] = ["line", "bars", "area"];

export interface UserConfig {
version: 1;
Expand All @@ -55,6 +57,9 @@ export interface UserConfig {
* - "off": no coloring.
*/
menubarColorSource: MenubarColorSource;
/** Rendering style for the popover's LAST 7 DAYS chart (Swift mirrors
* this in HubConfigStore.WeekChartStyle — keep raw values identical). */
weekChartStyle: WeekChartStyle;
};
daemon: {
/** Advisory: seconds between full rescans inside the daemon. */
Expand Down Expand Up @@ -107,7 +112,7 @@ export interface UserConfig {
*/
export const DEFAULT_CONFIG: UserConfig = {
version: 1,
bar: { refreshSeconds: 30, menubarColorSource: "context" },
bar: { refreshSeconds: 30, menubarColorSource: "context", weekChartStyle: "line" },
daemon: { scanIntervalSeconds: 60, antigravityLivePolling: false },
cli: { defaultRange: "all", defaultSort: "cost" },
alerts: { dailyCostThreshold: null },
Expand Down Expand Up @@ -188,6 +193,9 @@ function normalizeConfig(raw: Partial<UserConfig>): UserConfig {
)
? (raw.bar?.menubarColorSource as MenubarColorSource)
: d.bar.menubarColorSource,
weekChartStyle: WEEK_CHART_STYLES.includes(raw.bar?.weekChartStyle as WeekChartStyle)
? (raw.bar?.weekChartStyle as WeekChartStyle)
: d.bar.weekChartStyle,
},
daemon: {
scanIntervalSeconds: clampPositiveInt(
Expand Down
11 changes: 8 additions & 3 deletions packages/core/src/parsers/claude-code.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,11 +116,16 @@ export class ClaudeCodeParser implements SessionParser {
const cwd = decodeClaudeSlugDir(file);
const isSubagent = file.includes("/subagents/");

// Append mode: only parse new bytes from where we left off
// Append mode: only parse new bytes from where we left off. Read faults
// fail soft but surface via onWarning so a rebuild knows it's partial.
const readFault = (error: unknown) =>
opts?.onWarning?.(
`failed read of ${file}: ${error instanceof Error ? error.message : error}`
);
const lines =
cacheResult.appendOffset > 0
? await readJsonlFileFromOffset<ClaudeMessage>(file, cacheResult.appendOffset)
: await readJsonlFile<ClaudeMessage>(file);
? await readJsonlFileFromOffset<ClaudeMessage>(file, cacheResult.appendOffset, readFault)
: await readJsonlFile<ClaudeMessage>(file, readFault);

const newRecords: TokenRecord[] = [];
// Dedup: using both usage values and a stable per-message discriminator so
Expand Down
25 changes: 17 additions & 8 deletions packages/core/src/parsers/codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -449,7 +449,11 @@ function foldCodexEvent(
* read. Either way the caller gets just this file's records — to fold and drop
* — so peak memory is bounded to a single file, not the whole corpus.
*/
export async function parseCodexFile(file: string, sizeBytes: number): Promise<TokenRecord[]> {
export async function parseCodexFile(
file: string,
sizeBytes: number,
onWarning?: (message: string) => void
): Promise<TokenRecord[]> {
const out: TokenRecord[] = [];
const state = defaultState();
if (sizeBytes >= CODEX_LARGE_FILE_BYTES) {
Expand All @@ -469,19 +473,24 @@ export async function parseCodexFile(file: string, sizeBytes: number): Promise<T
}
foldCodexEvent(evt, state, file, out);
}
} catch {
} catch (error) {
// FAIL SOFT, per file — mirror readJsonlFile's contract. A mid-stream read
// error (the file rotated/deleted by an active session between stat() and
// read, EACCES/EIO, an evicted iCloud file) must NOT propagate: without
// this catch it aborts the ENTIRE codex provider, and a windowed rescan
// would then overwrite good sealed days with an empty result. Return what
// we parsed before the fault instead — one bad file costs only that file.
// this catch it aborts the ENTIRE codex provider. Return what we parsed
// before the fault instead — one bad file costs only that file. But the
// truncation must not be SILENT: a windowed rescan that doesn't know the
// result is partial overwrites a good sealed day with the truncated one
// (this exact failure shrank sealed history on 2026-07-12).
onWarning?.(`truncated read of ${file}: ${error instanceof Error ? error.message : error}`);
} finally {
rl.close();
stream.destroy(); // rl.close() alone doesn't release the underlying fd
}
} else {
const events = await readJsonlFile<CodexEvent>(file);
const events = await readJsonlFile<CodexEvent>(file, (error) => {
onWarning?.(`failed read of ${file}: ${error instanceof Error ? error.message : error}`);
});
for (const evt of events) foldCodexEvent(evt, state, file, out);
}
return out;
Expand Down Expand Up @@ -588,12 +597,12 @@ export class CodexParser implements SessionParser {

// Big ones: strictly one at a time.
for (const f of large) {
await onFile(await parseCodexFile(f.file, f.size));
await onFile(await parseCodexFile(f.file, f.size, opts?.onWarning));
}
// Small ones: a bounded concurrent batch. onFile's fold is synchronous, so
// interleaved awaits here can't corrupt the caller's accumulators.
await mapWithConcurrency(small, CODEX_SCAN_CONCURRENCY, async (f) => {
await onFile(await parseCodexFile(f.file, f.size));
await onFile(await parseCodexFile(f.file, f.size, opts?.onWarning));
});
}
}
25 changes: 19 additions & 6 deletions packages/core/src/parsers/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -341,8 +341,14 @@ export function clearRecordCache(): void {
} catch {}
}

/** Read only the tail of a file from a byte offset (for append-only parsing). */
export async function readJsonlFileFromOffset<T>(path: string, offsetBytes: number): Promise<T[]> {
/** Read only the tail of a file from a byte offset (for append-only parsing).
* A read failure returns [] (fail-soft) — pass `onError` to distinguish that
* from an empty tail when the caller seals history. */
export async function readJsonlFileFromOffset<T>(
path: string,
offsetBytes: number,
onError?: (error: unknown) => void
): Promise<T[]> {
const { open } = await import("node:fs/promises");
let fd: Awaited<ReturnType<typeof open>> | null = null;
try {
Expand Down Expand Up @@ -379,7 +385,8 @@ export async function readJsonlFileFromOffset<T>(path: string, offsetBytes: numb
} catch {}
}
return results;
} catch {
} catch (error) {
onError?.(error);
return [];
} finally {
// Guarantee the fd is released even if read() threw mid-buffer. Without
Expand Down Expand Up @@ -576,8 +583,13 @@ export async function readJsonFile<T>(path: string): Promise<T | null> {
}
}

/** Read JSONL file and parse each line. */
export async function readJsonlFile<T>(path: string): Promise<T[]> {
/** Read JSONL file and parse each line. Malformed lines are skipped; a read
* failure returns [] (fail-soft) — pass `onError` to distinguish that from a
* genuinely empty file when the caller seals history. */
export async function readJsonlFile<T>(
path: string,
onError?: (error: unknown) => void
): Promise<T[]> {
try {
const raw = await readFile(path, "utf-8");
const lines = raw.split("\n").filter((l: string) => l.trim());
Expand All @@ -590,7 +602,8 @@ export async function readJsonlFile<T>(path: string): Promise<T[]> {
}
}
return results;
} catch {
} catch (error) {
onError?.(error);
return [];
}
}
Expand Down
Loading
Loading