Skip to content
Open
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
89 changes: 89 additions & 0 deletions docs/statistics-contract.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
# Gentle Shell statistics schema (`gentle-shell.statistics/v1`)

A non-TUI host — a client that runs `pi --mode rpc` itself — receives the session statistics aggregates as one bounded JSON document per coalescing window, so it can render totals, breakdowns and the timeline without polling. A human billing hours gets the same numbers as a file through the exporters.

Source map: [publisher](../lib/session-statistics-rpc.ts), [aggregate](../lib/session-aggregate.ts), [timeline](../lib/session-timeline.ts), [exporters](../lib/session-export.ts).

## Transport

Pi's `setWidget` is the only fire-and-forget RPC push structured enough to carry this: in RPC mode it accepts a `string[]` (sent as `extension_ui_request`) and silently ignores a component-factory function. The statistics publisher uses its own widget key, `gentle-statistics`, so it can never collide with `gentle-agents.activity/v1`.

```json
{
"type": "extension_ui_request",
"method": "setWidget",
"widgetKey": "gentle-statistics",
"widgetLines": ["{\"schema\":\"gentle-shell.statistics/v1\", ...}"]
}
```

`widgetLines` is always exactly one line: one JSON document, `JSON.stringify`'d, never pretty-printed. Parse it as `gentle-shell.statistics/v1`. The publisher is a module with no wiring yet; I8 wires it to an overlay.

## Payload shape

```jsonc
{
"schema": "gentle-shell.statistics/v1",
"asOf": 1732000000000,
"totals": {
"turns": 5,
"cost": { "nanoUsd": 6000000, "provenance": "partial", "absent": 2 },
"tokens": { "input": 650, "output": 210, "cacheRead": 80, "cacheWrite": 15, "reasoning": 58, "total": 955, "provenance": "measured" },
"ratios": { "cacheReadShare": 0.0837, "cacheWriteShare": 0.0157, "reasoningShareOfOutput": 0.2761, "outputToTotal": 0.2198, "costPerTurn": 0.0012, "tokensPerTurn": 191 }
},
"counts": {
"sessions": { "value": 1, "provenance": "measured" },
"subagents": { "value": 2, "provenance": "measured" },
"toolCalls": { "value": 10, "provenance": "partial" }
},
"perModel": [{ "key": "p1/m1", "bucket": { "turns": 2, "cost": {}, "tokens": {}, "ratios": {} } }],
"perAgentClass": [{ "key": "orchestrator", "bucket": {} }],
"perSubagent": [{ "key": "S1", "label": "build", "bucket": {} }],
"perProject": [{ "key": "projA", "bucket": {} }],
"timeline": {
"segments": 827,
"modelMs": 5844898,
"toolMs": 13508144,
"idleMs": 6779869,
"wallClockMs": 26132924,
"modelLatency": [{ "model": "m1", "count": 2, "medianMs": 2000, "p90Ms": 2000 }],
"toolDurations": [{ "command": "pnpm test", "count": 1, "medianMs": 3000, "p90Ms": 3000, "parallelCount": 1 }]
}
}
```

`totals` is a `UsageBucket` from `lib/session-aggregate.ts`: `turns`, the six token counters, and the derived ratios. `timeline` is `null` when no timeline is supplied; when present, `idleMs` is an estimate (the wall clock the model and tool segments do not cover), never a measured time.

## Provenance and unavailable markers

Every monetary and token figure carries `provenance`: `measured` while every component was reported, `partial` as soon as one component reported no cost, forever. An unavailable ratio is `null`, never a blank or a zero. Counts carry provenance too: the orchestrator's own tool calls are not in a usage record, so an aggregate that includes a parent record reports its `toolCalls` count as `partial` rather than a silently smaller number.

## Bounds

Every bound fails closed. A value that cannot fit is dropped or shrunk; the encoder never throws.

| Field | Bound |
|---|---|
| breakdown key (`perModel`/`perAgentClass`/`perSubagent`/`perProject`) | 120 characters, trailing `…` |
| `perSubagent.label` | 120 characters, trailing `…` |
| entries per breakdown (`perModel`, `perAgentClass`, `perSubagent`, `perProject`) | 20 |
| `timeline.modelLatency`, `timeline.toolDurations` | 20 entries each |
| whole payload | 64 KiB |

## Oversize is a discard, not a silent truncation

When the payload exceeds the bound, `encodeStatisticsLines` shrinks it in order: halve every breakdown's entries (repeatedly, down to zero), then drop the timeline. If even that does not fit, it returns **no lines** and the publisher sends nothing — a partial payload is never presented as complete. Malformed input fails closed to no lines.

## Coalescing and the attempt slot

The publisher coalesces a burst of requests into one `setWidget` call per window (150 ms default) and publishes a final frame on `stop`. Publishing holds a single attempt slot: a request while a frame is in flight is discarded, never queued. A `setWidget` failure is reported to `onError` and never thrown into the session.

## Exporters

`lib/session-export.ts` renders I4's `UsageAggregate` and nothing else, so the export and any UI cannot diverge. All three mark provenance and write `n/a` for an unavailable ratio.

- **Markdown** (`exportStatisticsMarkdown`): a session summary plus one row per scope, with a provenance column.
- **CSV** (`exportStatisticsCsv`): one row per scope, with `cost_provenance`, `tokens_provenance` and `ratios_provenance` columns.
- **JSON** (`exportStatisticsJson`): the versioned envelope `{ "schema", "generatedAt"?, "session", "perModel", "perAgentClass", "perSubagent", "perProject" }`, with `ratios.provenance: "derived"`.

The output is deterministic for a given aggregate; `generatedAt` is injected and omitted by default, so golden files are stable across runs. Only allowlisted aggregate fields are read: prompt text, response text and filesystem paths cannot reach any artifact.
178 changes: 178 additions & 0 deletions lib/session-export.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
// Statistics exporters (epic #1, I7).
//
// A human billing hours needs a file, and tooling needs a stable shape. The
// renderers take I4's `UsageAggregate` and nothing else, so the export and any
// UI cannot diverge: there is one source of numbers. Every figure states its
// provenance — `measured`, `partial` for a cost with an unreported component,
// `derived` for a ratio — and an unavailable ratio is written as `n/a`, never a
// blank that reads as zero.
//
// Only allowlisted aggregate fields are read; prompt text, response text and
// filesystem paths cannot reach any artifact. The output is deterministic for a
// given aggregate (the clock is injected and omitted by default).

import { NANO_USD_SCALE } from "./session-usage.ts";
import type { CountFigure, FigureProvenance, MoneyFigure, TokenFigure, UsageAggregate, UsageBucket } from "./session-aggregate.ts";

export const STATISTICS_EXPORT_SCHEMA = "gentle-shell.statistics/v1";

export interface ExportOptions {
/** Injected clock. Omitted by default so the output is stable across runs. */
readonly generatedAt?: number;
}

interface ExportRow {
readonly scope: "session" | "model" | "agent_class" | "subagent" | "project";
readonly key: string;
readonly bucket: UsageBucket;
}

function rowsOf(aggregate: UsageAggregate): ExportRow[] {
return [
{ scope: "session", key: "", bucket: aggregate },
...aggregate.perModel.map((entry): ExportRow => ({ scope: "model", key: `${entry.provider}/${entry.model}`, bucket: entry })),
...aggregate.perAgentClass.map((entry): ExportRow => ({ scope: "agent_class", key: entry.agentClass, bucket: entry })),
...aggregate.perSubagent.map((entry): ExportRow => ({ scope: "subagent", key: entry.taskId, bucket: entry })),
...aggregate.perProject.map((entry): ExportRow => ({ scope: "project", key: entry.project, bucket: entry })),
];
}

function usd(nanoUsd: number): string {
return (nanoUsd / NANO_USD_SCALE).toFixed(6);
}

/** A ratio is `derived`; an unavailable ratio is explicit, never a blank. */
function ratio(value: number | null): string {
return value === null ? "n/a" : value.toFixed(6);
}

function countProvenance(count: CountFigure): FigureProvenance {
return count.provenance;
}

/** The versioned JSON envelope, with `derived` marked on the ratios object. */
export function exportStatisticsJson(aggregate: UsageAggregate, options: ExportOptions = {}): string {
const payload = {
schema: STATISTICS_EXPORT_SCHEMA,
...(options.generatedAt !== undefined ? { generatedAt: options.generatedAt } : {}),
session: {
asOf: aggregate.asOf,
turns: aggregate.turns,
cost: aggregate.cost,
tokens: aggregate.tokens,
ratios: { ...aggregate.ratios, provenance: "derived" as const },
counts: { sessions: aggregate.sessions, subagents: aggregate.subagents, toolCalls: aggregate.toolCalls },
},
perModel: aggregate.perModel,
perAgentClass: aggregate.perAgentClass,
perSubagent: aggregate.perSubagent,
perProject: aggregate.perProject,
};
return `${JSON.stringify(payload, null, 2)}\n`;
}

const CSV_COLUMNS = [
"scope",
"key",
"turns",
"cost_usd",
"cost_provenance",
"cost_absent_components",
"tokens_input",
"tokens_output",
"tokens_cache_read",
"tokens_cache_write",
"tokens_reasoning",
"tokens_total",
"tokens_provenance",
"cache_read_share",
"cache_write_share",
"reasoning_share_of_output",
"output_to_total",
"cost_per_turn",
"tokens_per_turn",
"ratios_provenance",
] as const;

function csvField(value: string): string {
return /[",\n]/.test(value) ? `"${value.replace(/"/g, '""')}"` : value;
}

/** One row per scope, with an explicit provenance column and `n/a` for unavailable ratios. */
export function exportStatisticsCsv(aggregate: UsageAggregate): string {
const lines = [CSV_COLUMNS.join(",")];
for (const row of rowsOf(aggregate)) {
const { bucket } = row;
lines.push(
[
row.scope,
csvField(row.key),
String(bucket.turns),
usd(bucket.cost.nanoUsd),
bucket.cost.provenance,
String(bucket.cost.absent),
String(bucket.tokens.input),
String(bucket.tokens.output),
String(bucket.tokens.cacheRead),
String(bucket.tokens.cacheWrite),
String(bucket.tokens.reasoning),
String(bucket.tokens.total),
bucket.tokens.provenance,
ratio(bucket.ratios.cacheReadShare),
ratio(bucket.ratios.cacheWriteShare),
ratio(bucket.ratios.reasoningShareOfOutput),
ratio(bucket.ratios.outputToTotal),
ratio(bucket.ratios.costPerTurn),
ratio(bucket.ratios.tokensPerTurn),
"derived",
].join(","),
);
}
return `${lines.join("\n")}\n`;
}

function costCell(cost: MoneyFigure): string {
return cost.provenance === "measured" ? `$${usd(cost.nanoUsd)}` : `$${usd(cost.nanoUsd)} + (${cost.absent} unreported)`;
}

function tokenCell(tokens: TokenFigure): string {
return `${tokens.total} (in ${tokens.input}, out ${tokens.output}, cache r/w ${tokens.cacheRead}/${tokens.cacheWrite}, reasoning ${tokens.reasoning})`;
}

function breakdownTable(rows: readonly ExportRow[]): string[] {
const lines = ["| Scope | Key | Turns | Cost | Cost provenance | Tokens | Cache-read share | Reasoning/output | Cost/turn |", "| --- | --- | ---: | ---: | --- | ---: | ---: | ---: | ---: |"];
for (const row of rows) {
lines.push(
`| ${row.scope} | ${row.key === "" ? "session" : row.key} | ${row.bucket.turns} | ${costCell(row.bucket.cost)} | ${row.bucket.cost.provenance} | ${row.bucket.tokens.total} | ${ratio(row.bucket.ratios.cacheReadShare)} | ${ratio(row.bucket.ratios.reasoningShareOfOutput)} | ${ratio(row.bucket.ratios.costPerTurn)} |`,
);
}
return lines;
}

/** A human report: a session summary, then one row per scope, provenance included. */
export function exportStatisticsMarkdown(aggregate: UsageAggregate, options: ExportOptions = {}): string {
const lines: string[] = ["# Session statistics", ""];
if (options.generatedAt !== undefined) {
lines.push(`Generated: ${new Date(options.generatedAt).toISOString()}`, "");
}
lines.push(
"## Session",
"",
"| Metric | Value | Provenance |",
"| --- | ---: | --- |",
`| Turns | ${aggregate.turns} | measured |`,
`| Cost | ${costCell(aggregate.cost)} | ${aggregate.cost.provenance} |`,
`| Tokens | ${tokenCell(aggregate.tokens)} | ${aggregate.tokens.provenance} |`,
`| Sessions | ${aggregate.sessions.value} | ${countProvenance(aggregate.sessions)} |`,
`| Subagents | ${aggregate.subagents.value} | ${countProvenance(aggregate.subagents)} |`,
`| Tool calls | ${aggregate.toolCalls.value} | ${countProvenance(aggregate.toolCalls)} |`,
"",
"Ratios are derived; `n/a` means the denominator is zero (unavailable), never zero.",
"",
"## Breakdown",
"",
...breakdownTable(rowsOf(aggregate)),
"",
);
return lines.join("\n");
}
Loading