Skip to content
Closed
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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
## Unreleased

- Add an overview screenshot (demo data, with the sidebar footer row) to the README and as the first manifest media image.
- Show the cache hit rate — cache reads over the whole prompt — in the session/overview summary, in each turn's CACHE column, and in the in-progress turn line. The value was already computed by `cacheRatio` but never rendered outside the priced cost cell, so turns without a known price hid it entirely.
- Show the **average output throughput** (`output ÷` summed turn wall clock) in the session/overview summary, and put it in the composer pill next to the cache hit rate. Paseo reports no per-token timing, so this is a throughput floor that includes thinking and tooling; the label is documented as such.
- Rework the composer pill label: it now carries tokens with the hit rate (`6.5M·95%`) and the throughput, and no longer repeats the context-window fill that the chat surface already shows. The host caps the pill at 160px, so the label is assembled against a measured advance-width table and drops the throughput (then the hit rate) rather than letting the host clip it.

## v0.7.0 (2026-10-09) — Paseo 0.11 screens and sidebar

Expand Down
18 changes: 11 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,26 +13,26 @@ Per-turn LLM token usage and cost for [Paseo](https://paseo.sh) agents — an ag
</p>

<p align="center">
<img src="images/screenshot-panel.png" alt="TokenLedger per-agent panel: session summary (turns, input/cache/output tokens, cost), context-window bar, and a per-turn history table" width="560">
<img src="images/screenshot-panel.png" alt="TokenLedger per-agent panel: session summary (turns, input/cache/output tokens, cache hit rate, average output throughput, cost), context-window bar, and a per-turn history table" width="560">
</p>

## What it shows

**Per-agent panel** (workspace/explorer, bound to one session — the header names the agent and model it is tracking):

- **Session summary** — total turns, input / cache / output tokens, and reported or estimated cost. Estimated totals use `≈`; missing-price turns are counted explicitly.
- **Turn in progress** — elapsed time, running token counts and estimated cost when available, and a context-window bar. When idle, the last known context-window usage is still shown.
- **Turn history** — one row per finished turn: status, time, duration, tokens, cost, and a data-quality mark.
- **Session summary** — total turns, input / cache / output tokens, cache hit rate, average output throughput, and reported or estimated cost. Estimated totals use `≈`; missing-price turns are counted explicitly. The hit rate is cache reads over the whole prompt (fresh input + reads + known writes; see [how turns are measured](#how-turns-are-measured)) and is blank when the provider reports no cache reads.
- **Turn in progress** — elapsed time, running token counts (with cache hit rate) and estimated cost when available, and a context-window bar. When idle, the last known context-window usage is still shown.
- **Turn history** — one row per finished turn: status, time, duration, tokens, cache hit rate, cost, and a data-quality mark.

**Composer pill** — every live agent gets a compact pill next to its composer (`$0.43 · ctx 47%`, with an activity icon while a turn is running). The total includes the current turn's estimated cost when available and uses `≈` whenever estimates contribute. Pressing it opens that agent's panel. With split views, each session carries its own pill, so the session↔usage binding is always visible.
**Composer pill** — every live agent gets a compact pill next to its composer (`6.5M·95%·74 t/s`, or `$0.43·90%` when the model has a price; an activity icon shows while a turn is running). The token count carries the session's cache hit rate in the same segment, followed by the average output throughput. The pill is capped by the host, so it holds only what fits: the throughput is dropped first, then the hit rate, never the count itself. Context-window fill is deliberately not repeated here — the chat surface already shows it next to the pill. The cost total includes the current turn's estimate when available and uses `≈` whenever estimates contribute. Pressing it opens that agent's panel. With split views, each session carries its own pill, so the session↔usage binding is always visible.

<p align="center">
<img src="images/screenshot-pill.jpg" alt="TokenLedger composer pill showing cost and context usage next to the composer; pressing it opens the panel" width="560">
<img src="images/screenshot-pill.jpg" alt="TokenLedger composer pill showing the session token count with its cache hit rate and average output throughput next to the composer; pressing it opens the panel" width="560">
</p>

**Sidebar** — a **TokenLedger** row in the sidebar footer shows today's cost (or tokens when no price is known), with an activity icon while a turn is running. "Today" starts at local midnight on the device that shows it.

**Overview** (sidebar footer → TokenLedger, or the command center) — all sessions in one place: grand totals, then per-agent rows grouped by workspace with live-turn indicator, last activity, cost/tokens, and turn count. Pick **Today**, **Last 7 days**, **Last 30 days**, or **All time**; the range is part of the screen link and its title. A range lists agents with turns that ended in it, plus agents with a turn in flight. Tapping a row jumps to that agent.
**Overview** (sidebar footer → TokenLedger, or the command center) — all sessions in one place: grand totals (including cache hit rate), then per-agent rows grouped by workspace with live-turn indicator, last activity, cost/tokens, and turn count. Pick **Today**, **Last 7 days**, **Last 30 days**, or **All time**; the range is part of the screen link and its title. A range lists agents with turns that ended in it, plus agents with a turn in flight. Tapping a row jumps to that agent.

<p align="center">
<img src="images/screenshot-overview.png" alt="TokenLedger overview on Last 7 days with demo data: range buttons, totals for all sessions, and agent rows grouped by workspace; the TokenLedger row in the sidebar footer shows today's cost" width="720">
Expand Down Expand Up @@ -115,6 +115,10 @@ Estimated costs are marked `≈`. OpenRouter prices are references and may diffe

Paseo 0.9.1 still does not expose cache-write token counts through its public usage API. TokenLedger can retain and price them separately when supplied, but the current Claude and Codex adapters omit them. Missing counts stay unknown; historical writes are not inferred. Claude's reported total can include charges absent from the token counts, producing a positive residual under COST. Codex input includes cache reads (and cache writes when supplied); the UI separates known cache counts while the ledger retains raw counts.

The **average output throughput** (`avg out` in the panel, `74 t/s` in the pill) is output tokens ÷ the summed wall-clock duration of the turns behind it. Paseo's usage API exposes no per-token or first-token timing, so this is derived from each turn's start and end; that span includes thinking, tool calls, and waiting on the provider. Read it as a floor on generation speed and as a way to compare sessions and models — not as the model's token rate.

The **cache hit rate** is cache reads ÷ the whole prompt (`cached / (input + cached + known writes)`), where `input` is the provider-normalized fresh-token count — for Codex and other OpenAI-style adapters that report inclusive input, the cached portion is subtracted first, so the ratio is comparable across providers. It reflects prompt caching only, not output or cost savings, and it is blank when the provider reports no cache reads. The same figure appears in the session and overview summaries (aggregated over turns) and per turn.

Provider-internal subagents are not ordinary Paseo catalog agents. The public plugin API does not expose their usage; the Codex adapter discards child usage notifications. Session and overview totals therefore **may omit descendant usage**. TokenLedger uses only public Paseo APIs; it does not inspect provider logs or gateway billing endpoints. The [upstream API requirements](docs/paseo-usage-api.md) describe the missing fields, lineage, stable request IDs, and accounting scopes needed to close these gaps without double billing.

Identical consecutive usage snapshots cannot be distinguished from separate equal-size model calls, so Codex totals remain `partial`. Open-turn observations, session identity and billing baselines are checkpointed locally, and graceful plugin reloads preserve them. A crash can lose observations since the last checkpoint; events missed while the plugin was offline cannot be reconstructed. Interrupted recovered turns are marked as partial/unavailable rather than claiming an exact offline outcome. `exact` describes the reported turn aggregate, not proof that every descendant or billing category was included.
Expand Down
4 changes: 1 addition & 3 deletions client/panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,6 @@ function CtxBar({ ctx, theme }: { ctx: Ctx; theme: PluginTheme }) {
function InFlightCard({ inFlight, seq, theme }: { inFlight: InFlight; seq: number; theme: PluginTheme }) {
const elapsed = useElapsed(inFlight.startedAt);
const tokens = tokensLine(inFlight.input, inFlight.cached, inFlight.output, inFlight.cacheWrite);
const pct = fmtPct(cacheRatio(inFlight.input, inFlight.cached, inFlight.cacheWrite));
const ctx = inFlight.ctxUsed !== null && inFlight.ctxMax !== null ? { used: inFlight.ctxUsed, max: inFlight.ctxMax } : null;
return (
<View
Expand All @@ -83,7 +82,6 @@ function InFlightCard({ inFlight, seq, theme }: { inFlight: InFlight; seq: numbe
{tokens ? (
<Text style={{ color: theme.colors.foregroundMuted, fontSize: 12, fontVariant: ["tabular-nums"] }}>
{tokens}
{pct ? ` (${pct} cached)` : ""}
{inFlight.modelCalls > 0 ? ` · ${inFlight.modelCalls} call${inFlight.modelCalls > 1 ? "s" : ""}` : ""}
</Text>
) : (
Expand Down Expand Up @@ -187,7 +185,7 @@ function RecordRow({ record, theme, dense }: { record: TurnRow; theme: PluginThe
<View style={{ gap: 3 }}>
<TurnCell
top={fmtTokens(record.cached)}
bottom={pct ? `${cacheCost}·${pct}` : cacheCost}
bottom={split ? (pct ? `${cacheCost} · ${pct}` : cacheCost) : pct ?? "–"}
width={cols.cache}
theme={theme}
/>
Expand Down
30 changes: 20 additions & 10 deletions client/pill.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,23 +5,33 @@ import { useLedger } from "./data.ts";
import { useEffect } from "react";
import { subscribeAgents } from "../shared/agents.ts";
import { ledgerEnsure, type SyncResult } from "../shared/ledger.ts";
import { fmtCost, fmtTokens } from "./ui.tsx";
import { fmtCost } from "./ui.tsx";
import { compactTokens, fmtPct, fmtThroughput, fmtTokens } from "../shared/format.ts";
import { cacheRatio } from "../shared/pricing.ts";
import { pillLabel as assemblePillLabel } from "../shared/pill-label.ts";

import type { PanelPlacement } from "./layout.ts";

function pillLabel({ summary, inFlight, ctx }: SyncResult): string {
function composePillLabel({ summary, inFlight }: SyncResult): string {
const combinedCost = summary.effectiveCostUsd === null && inFlight?.effectiveCostUsd == null
? null
: (summary.effectiveCostUsd ?? 0) + (inFlight?.effectiveCostUsd ?? 0);
const cost = fmtCost(combinedCost);
const estimated = summary.estimatedTurns > 0 || inFlight?.effectiveCostUsd != null;
const parts = [cost
? `${estimated ? "≈" : ""}${cost}`
: `${fmtTokens(summary.input + summary.cached + (summary.cacheWrite ?? 0) + summary.output)} tok`];
const liveCtx = inFlight?.ctxUsed != null && inFlight.ctxMax != null
? { used: inFlight.ctxUsed, max: inFlight.ctxMax } : ctx;
if (liveCtx && liveCtx.max > 0) parts.push(`ctx ${Math.round(liveCtx.used / liveCtx.max * 100)}%`);
return parts.join(" · ");
const hit = fmtPct(cacheRatio(summary.input, summary.cached, summary.cacheWrite));
const total = summary.input + summary.cached + (summary.cacheWrite ?? 0) + summary.output;

// With a known price the pill leads with money; otherwise it leads with the
// token count. Either way the hit rate rides in the same segment as the
// number it qualifies, so no extra separator is spent on it.
const usage = cost
? `${estimated ? "≈" : ""}${cost}${hit ? `·${hit}` : ""}`
: hit ? `${compactTokens(total)}·${hit}` : `${fmtTokens(total)} tok`;

return assemblePillLabel({
usage,
throughput: fmtThroughput(summary.output, summary.durationMs, "t/s"),
});
}

export function contributePills(client: PluginClientContext, placement: PanelPlacement): () => Promise<void> {
Expand All @@ -44,7 +54,7 @@ export function contributePills(client: PluginClientContext, placement: PanelPla
useEffect(() => placement.observe(agent.id, props.layout.compact), [props.layout.compact]);
const { data, error } = useLedger(agent.id);
useEffect(() => {
handle.update({ label: error ? "Usage unavailable" : data ? pillLabel(data) : "…" });
handle.update({ label: error ? "Usage unavailable" : data ? composePillLabel(data) : "…" });
}, [data, error]);
return <Icon name={data?.inFlight ? "Activity" : "Coins"} size={props.size} color={props.color} />;
}
Expand Down
25 changes: 10 additions & 15 deletions client/ui.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,10 @@
import type { PluginTheme } from "@getpaseo/plugin";
import { Text, View } from "react-native";
import type { Summary, TurnRecord } from "../shared/ledger.ts";

export function fmtTokens(value: number | null): string {
if (value === null) return "–";
if (value < 1000) return String(value);
if (value < 1_000_000) return `${(value / 1000).toFixed(1)}k`;
return `${(value / 1_000_000).toFixed(2)}M`;
}
import { cacheRatio } from "../shared/pricing.ts";
// Formatting lives in shared/ so the pill, panel, and overview render identically.
import { fmtPct, fmtThroughput, fmtTokens } from "../shared/format.ts";
export { fmtPct, fmtThroughput, fmtTokens } from "../shared/format.ts";

export function fmtCost(value: number | null): string | null {
if (value === null) return null;
Expand Down Expand Up @@ -40,13 +37,6 @@ export function fmtResidual(value: number | null): string {
return `${value > 0 ? "+" : "−"}${fmtCostSmall(Math.abs(value))}`;
}

export function fmtPct(ratio: number | null): string | null {
if (ratio === null) return null;
const pct = ratio * 100;
if (pct > 0 && pct < 1) return "<1%";
return `${Math.round(pct)}%`;
}

export function fmtDuration(ms: number | null): string {
if (ms === null) return "–";
const seconds = Math.round(ms / 1000);
Expand All @@ -72,7 +62,8 @@ export function statusColor(status: TurnRecord["status"], theme: PluginTheme): s

export function tokensLine(input: number | null, cached: number | null, output: number | null, cacheWrite?: number | null): string | null {
if (input === null && cached === null && output === null && cacheWrite == null) return null;
return `in ${fmtTokens(input)} · cache ${fmtTokens(cached)}${cacheWrite != null ? ` · write ${fmtTokens(cacheWrite)}` : ''} · out ${fmtTokens(output)}`;
const pct = fmtPct(cacheRatio(input, cached, cacheWrite));
return `in ${fmtTokens(input)} · cache ${fmtTokens(cached)}${pct ? ` (${pct})` : ''}${cacheWrite != null ? ` · write ${fmtTokens(cacheWrite)}` : ''} · out ${fmtTokens(output)}`;
}

/** Column widths for the TURNS table; shared by the header and data rows. */
Expand Down Expand Up @@ -122,13 +113,17 @@ export function SummaryRow({ summary, theme, dense }: { summary: Summary; theme:
const cost = fmtCost(summary.effectiveCostUsd);
const costValue = cost ? `${summary.estimatedTurns > 0 ? "≈" : ""}${cost}` : null;
const costLabel = summary.unpricedTurns > 0 ? `cost · ${summary.unpricedTurns} unknown` : summary.estimatedTurns > 0 ? "cost · estimated" : "cost";
const hit = fmtPct(cacheRatio(summary.input, summary.cached, summary.cacheWrite));
const rate = fmtThroughput(summary.output, summary.durationMs);
return (
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: dense ? 10 : 16 }}>
<Stat label="turns" value={String(summary.turns)} theme={theme} dense={dense} />
<Stat label="input" value={fmtTokens(summary.input)} theme={theme} dense={dense} />
<Stat label="cache" value={fmtTokens(summary.cached)} theme={theme} dense={dense} />
{hit ? <Stat label="cache hit" value={hit} theme={theme} dense={dense} /> : null}
{summary.cacheWrite != null ? <Stat label="cache write" value={fmtTokens(summary.cacheWrite)} theme={theme} dense={dense} /> : null}
<Stat label="output" value={fmtTokens(summary.output)} theme={theme} dense={dense} />
{rate ? <Stat label="avg out" value={rate} theme={theme} dense={dense} /> : null}
{costValue ? <Stat label={costLabel} value={costValue} theme={theme} dense={dense} /> : null}
</View>
);
Expand Down
45 changes: 45 additions & 0 deletions server/read-model.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { finalizeTurn } from '../shared/aggregate.ts';
import type { TurnRow } from '../shared/ledger.ts';

function row(startedAt: string | null, endedAt: string, output: number): TurnRow {
const record = finalizeTurn({
agentId: 'a', turnId: 't', provider: 'codex', model: 'gpt-test',
startedAt, endedAt, status: 'completed',
observations: [], finalUsage: { inputTokens: 100, cachedInputTokens: 40, outputTokens: output },
prevSessionCostUsd: null,
});
return { ...record, seq: 1, effectiveCostUsd: null, costSource: null, costBreakdown: null };
}

/**
* `durationMs` exists so the composer pill and the panel can show an average
* throughput. It must be summed like the token counts: a row with no timing
* contributes nothing instead of dropping or double-counting a turn.
*/
test('summary duration sums per-turn wall clock and tolerates missing timings', async () => {
const home = await mkdtemp(join(tmpdir(), 'token-ledger-read-model-test-'));
const previousHome = process.env.PASEO_HOME;
process.env.PASEO_HOME = home;
try {
const { summaryFromRows } = await import('./read-model.ts');
const summary = summaryFromRows([
row('2026-09-14T00:00:00.000Z', '2026-09-14T00:00:01.000Z', 10),
row(null, '2026-09-14T00:00:05.000Z', 20),
row('2026-09-14T00:00:10.000Z', '2026-09-14T00:00:13.000Z', 30),
]);
assert.equal(summary.turns, 3);
assert.equal(summary.output, 60);
assert.equal(summary.durationMs, 4000);

assert.equal(summaryFromRows([]).durationMs, 0);
} finally {
if (previousHome === undefined) delete process.env.PASEO_HOME;
else process.env.PASEO_HOME = previousHome;
await rm(home, { recursive: true, force: true });
}
});
2 changes: 2 additions & 0 deletions server/read-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export const EMPTY_SUMMARY: Summary = {
input: 0,
cached: 0,
output: 0,
durationMs: 0,
costUsd: null,
effectiveCostUsd: null,
estimatedTurns: 0,
Expand All @@ -21,6 +22,7 @@ export function summaryFromRows(rows: readonly TurnRow[]): Summary {
summary.cached += row.cached ?? 0;
if (row.cacheWrite != null) summary.cacheWrite = (summary.cacheWrite ?? 0) + row.cacheWrite;
summary.output += row.output ?? 0;
summary.durationMs += row.durationMs ?? 0;
if (row.costUsd !== null) summary.costUsd = (summary.costUsd ?? 0) + row.costUsd;
if (row.effectiveCostUsd !== null) {
summary.effectiveCostUsd = (summary.effectiveCostUsd ?? 0) + row.effectiveCostUsd;
Expand Down
1 change: 1 addition & 0 deletions server/tracker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,7 @@ export async function handleOverview(input: { since?: string }, context: { paseo
totals.cached += summary.cached;
if (summary.cacheWrite != null) totals.cacheWrite = (totals.cacheWrite ?? 0) + summary.cacheWrite;
totals.output += summary.output;
totals.durationMs += summary.durationMs;
if (summary.costUsd !== null) totals.costUsd = (totals.costUsd ?? 0) + summary.costUsd;
if (summary.effectiveCostUsd !== null) totals.effectiveCostUsd = (totals.effectiveCostUsd ?? 0) + summary.effectiveCostUsd;
totals.estimatedTurns += summary.estimatedTurns;
Expand Down
Loading