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
54 changes: 54 additions & 0 deletions docs/adr/0032-a-usage-ledger-beside-the-state-file.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# 32. A usage ledger beside the state file, for the Codex work this product starts

Date: 2026-09-17 · Status: Accepted · Source: [#377](https://github.com/Simon-AI-coding/GPT-VoiceCoding/issues/377)

On 2026-09-17 the user's weekly Codex allowance fell from 25% to 23% remaining on a morning they did
not remember using Codex. Finding what this product had spent took a manual search of
`~/.codex/sessions` and `engine.log`, and it only half worked. The Call Agent's token usage was held
in memory for the status reply and nowhere else. The engine logged the name of the Voice's
`session.usage.updated` event once per call and dropped what the event said. Nothing read the
account's rate limits.

`core/persistence.py` keeps switch state and nothing else, and it says a durable history would be
"a different store and a different decision". This ADR is that decision.

## Decision

**The engine appends one JSON line for each usage fact about Codex work it started**, when the fact
arrives, to `usage-YYYY-MM.jsonl` in the directory that holds `state.json`.

- **Content.**
- Each line has `at` (ISO 8601 with offset) and `kind`, plus the ids known at that moment
(`call_id`, `realtime_session_id`, `thread_id`, `turn_id`, and `role` for a thread).
- `payload` is what Codex sent.
- The line kinds are:
- `codex_thread`: `thread/tokenUsage/updated` for the Call Agent's thread, or for a Delegated
Turn's thread while that turn is running.
- `realtime`: the realtime channel's `session.usage.updated` event. A real event from codex's
own log carries no transcript: `usage.audio_duration_ms`, `usage.backend_model_usage` and
`usage_limit`.
- `rate_limits`: `account/rateLimits/updated`. Codex's log shows that notification alongside
every token reading on a server, and only this product's own threads run on the engine's
server.
- **Exclusions.** The user's own Sessions are left out; they run on the shared daemon, and Codex
already records them.
- **Files.** One file per local calendar month. Files are never pruned and there is no switch. The
composition root builds the ledger and passes it to the Call adapter, just as it passes
`[delegate] model`.
- **Writing.** Every write opens, appends and closes the file.
- **Failures.** A failed write produces one warning. Later failures stay silent until a write
succeeds again. The ledger never raises.
- **Transport.** `CallTransport` gains `on_event`, because the Voice's usage arrives on the realtime
events channel and in no app-server notification.

**This is not the durable subset ADR 0001 describes, and does not amend it.** That subset is Bridge
Core's truth: written by one storage component and read back on restart. The ledger is bookkeeping
the Call adapter writes and nothing in the engine reads back, so no decision is ever made from it.

## Consequences

- `jq` over one month's file shows what this product spent and when, for the Call Agent, Delegated
Turns and the Voice, next to the account's rate-limit readings.
- The ledger is a second durable file, and it is write-only for the engine. Nothing in the engine
reads it, so it cannot become a second source of truth for anything.
- No UI or summary command reads it yet. Either would be a separate decision.
1 change: 1 addition & 0 deletions docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
| [0029](0029-the-control-window-owns-its-height.md) | The Control Window owns its height; nothing outside it measures content |
| [0030](0030-each-live-call-audio-crossing-has-one-owner.md) | Each Live Call audio crossing has one owner: the speaker keeps listening continuous, the microphone keeps speaking fresh — **no bare buffer, no fixed prebuffer** |
| [0031](0031-one-version-and-releases-cut-from-it.md) | One version, written in `pyproject.toml`; a workflow cuts the Release when it changes, and the installer builds the latest Release |
| [0032](0032-a-usage-ledger-beside-the-state-file.md) | The engine appends the usage of the Codex work it starts (Call Agent, Delegated Turns, the Voice, rate limits) to a monthly `usage-YYYY-MM.jsonl` beside `state.json`; **never pruned, never read by the engine** |

0008 (headless direct-child launcher) and 0009 (a launch carries its Opening Instruction) were removed with the launcher when v1.0 became a bridge over Sessions the user starts ([#67](https://github.com/Simon-AI-coding/GPT-VoiceCoding/issues/67), [#68](https://github.com/Simon-AI-coding/GPT-VoiceCoding/issues/68)); they return with the launch map, from git history.

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "gpt-voicecoding"
version = "1.0.3"
version = "1.0.4"
description = "Voice-controlling terminal coding agents through a realtime voice call."
readme = "README.md"
license = { file = "LICENSE" }
Expand Down
6 changes: 6 additions & 0 deletions src/gpt_voicecoding/adapters/call/realtime/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
TransportError,
TransportFactory,
)
from gpt_voicecoding.usage_ledger import UsageLedger

__all__ = [
"APPROVAL_POLICY",
Expand Down Expand Up @@ -70,6 +71,7 @@ def realtime_call(
sink: Any = None,
settings: dict[str, Any] | None = None,
transport_factory: TransportFactory | None = None,
usage_ledger: UsageLedger | None = None,
) -> RealtimeCallAdapter:
"""Build the adapter from an opaque settings table, refusing keys it lacks.

Expand All @@ -79,6 +81,9 @@ def realtime_call(
is given. A key in `[adapters.settings.call]` would be that one value stated
twice, free to drift; it has no default here for the same reason it has none
in `config.py`.

`usage_ledger` arrives the same way (#377): where it lives is the engine's
own directory, which the root knows and this table does not.
"""
read = RealtimeCallSettings.of(settings)
audio, cues = (transport_factory, None) if transport_factory else _audio_from(read)
Expand All @@ -89,6 +94,7 @@ def realtime_call(
settings=read,
transport_factory=audio,
cue_player=cues,
usage_ledger=usage_ledger,
)


Expand Down
81 changes: 81 additions & 0 deletions src/gpt_voicecoding/adapters/call/realtime/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@
from gpt_voicecoding.seams.events import EventSink
from gpt_voicecoding.seams.identity import RequestId, SessionName
from gpt_voicecoding.seams.verify import VerifyOutcome, VerifyResult
from gpt_voicecoding.usage_ledger import UsageKind, UsageLedger, UsageRole

_log = logging.getLogger(__name__)

Expand Down Expand Up @@ -221,6 +222,12 @@
#: wake-ups a pause rather than forty.
USER_QUIET_POLL_FRACTION = 0.25

#: The realtime events-channel event that carries the Voice's own usage (#377).
#: Its shape was read off codex's own log of a real call on 2026-09-17 —
#: `usage.audio_duration_ms`, `usage.backend_model_usage`, `usage_limit` — and it
#: carries no transcript, so it is recorded as it arrives.
REALTIME_USAGE_EVENT = "session.usage.updated"


class _Abandoned(Exception):
"""This call attempt was hung up or dropped while the handshake was running."""
Expand All @@ -242,6 +249,10 @@ class _LiveCall:
started: asyncio.Future[None]
#: What codex called the thread, once it has said. None until then.
thread_id: str | None = None
#: What codex called this call's realtime session, if it said. The thread
#: outlives a call (#270), so this is what tells two calls on it apart in
#: the usage ledger (#377).
realtime_session_id: str | None = None
#: Set when *this side* asked for the end, so the far side going quiet
#: afterwards is not reported as a loss.
ending: bool = False
Expand Down Expand Up @@ -316,8 +327,13 @@ def __init__(
settings: RealtimeCallSettings | None = None,
transport_factory: TransportFactory,
cue_player: CueOutput | None = None,
usage_ledger: UsageLedger | None = None,
) -> None:
self._sink = sink
#: Where the usage of the Codex work this adapter starts is written down
#: (#377). The composition root always hands one in; None is for tests
#: that are about something else.
self._usage = usage_ledger
self._settings = settings or RealtimeCallSettings()
#: The model the Call Agent runs on, and the same value every Delegated
#: Turn is given (#270). Constructor-shaped and not a settings key,
Expand Down Expand Up @@ -349,6 +365,10 @@ def __init__(
#: The current Call Agent's thread, retained between system calls and
#: for late completions (#270). User dials, forget, and restart clear it.
self._last_call_thread: str | None = None
#: The realtime session of the last call that came up on that thread,
#: so a Call Agent reading that lands after the hang-up still names the
#: call it belongs to in the usage ledger (#377). Cleared with the thread.
self._last_realtime_session: str | None = None
self._state = CallState.DOWN
self._delegating: dict[str, _DelegatedTurn] = {}
#: Work started from a notification callback, so none of it outlives
Expand Down Expand Up @@ -471,6 +491,7 @@ def call_agent(self) -> CallAgent | None:

def forget_call_agent(self) -> None:
self._last_call_thread = None
self._last_realtime_session = None
self._call_agent = None

async def models(self) -> tuple[ModelChoice, ...]:
Expand Down Expand Up @@ -793,6 +814,7 @@ def _registered(self) -> _LiveCall:
transport=self._new_transport(), sdp=loop.create_future(), started=loop.create_future()
)
live.transport.on_lost(lambda reason, held=live: self._lost(held, reason))
live.transport.on_event(lambda event, held=live: self._channel_event(held, event))
self._call = live
self._state = CallState.CONNECTING
return live
Expand Down Expand Up @@ -999,6 +1021,7 @@ def _heard(self, message: Message) -> None:
if not isinstance(params, dict):
return
thread_id = params.get("threadId")
self._record_usage(str(method), thread_id, params)

if method == "thread/tokenUsage/updated" and thread_id == self._last_call_thread:
agent = self._call_agent
Expand Down Expand Up @@ -1036,13 +1059,71 @@ def _heard(self, message: Message) -> None:
# line is a separate question and not this one's to answer.
_log.info("a Call Agent turn ended after its call was over: %s", thread_id)

def _record_usage(self, method: str, thread_id: object, params: Message) -> None:
"""Write down what Codex says this product's own work cost (#377).

**Only threads this adapter is running.** The Call Agent's thread — for
as long as it is the current one, so a turn that outlives its call still
counts — and a Delegated Turn's thread while that turn is in flight. The
user's own Sessions ride the shared daemon, not this server; the filter
is what keeps that true if they ever do not, and it keeps no list of
conversations beyond the turn that is running (ADR 0021 §7).

**Every rate-limit reading.** It names no thread, and everything on this
server is this product's own work — codex sends one beside each token
reading.
"""
if self._usage is None:
return
if method == "account/rateLimits/updated":
self._usage.record(UsageKind.RATE_LIMITS, payload=params)
return
if method != "thread/tokenUsage/updated" or not isinstance(thread_id, str):
return
usage = params.get("tokenUsage")
turn_id = params.get("turnId")
if thread_id == self._last_call_thread:
self._usage.record(
UsageKind.CODEX_THREAD,
role=UsageRole.CALL_AGENT.value,
# A Call Agent's thread is the call id this system uses (`CallStarted`).
call_id=thread_id,
realtime_session_id=self._last_realtime_session,
thread_id=thread_id,
turn_id=turn_id,
payload=usage,
)
elif thread_id in self._delegating:
self._usage.record(
UsageKind.CODEX_THREAD,
role=UsageRole.DELEGATED_TURN.value,
thread_id=thread_id,
turn_id=turn_id,
payload=usage,
)

def _channel_event(self, live: _LiveCall, event: dict[str, Any]) -> None:
"""One server event off this call's realtime channel. Only usage is kept."""
if self._usage is None or event.get("type") != REALTIME_USAGE_EVENT:
return
self._usage.record(
UsageKind.REALTIME,
call_id=live.thread_id,
realtime_session_id=live.realtime_session_id,
thread_id=live.thread_id,
payload=event,
)

def _call_heard(self, live: _LiveCall, method: str, params: Message) -> None:
match method:
case "thread/realtime/sdp":
sdp = params.get("sdp")
if isinstance(sdp, str) and not live.sdp.done():
live.sdp.set_result(sdp)
case "thread/realtime/started":
session = params.get("realtimeSessionId")
live.realtime_session_id = session if isinstance(session, str) else None
self._last_realtime_session = live.realtime_session_id
if not live.started.done():
live.started.set_result(None)
case "thread/realtime/transcript/delta":
Expand Down
21 changes: 17 additions & 4 deletions src/gpt_voicecoding/adapters/call/realtime/transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@

The interface is the minimum the signalling conversation actually needs, in the
order it needs it: an offer to send, an answer to apply, a connection to wait
for, a state to grade a `speak` against, one fact about playout, and a way to be
told the far side went away. Anything larger would be inventing a lifecycle
framework for one caller.
for, a state to grade a `speak` against, one fact about playout, a way to be
told the far side went away, and the server events only this side can see.
Anything larger would be inventing a lifecycle framework for one caller.

**Why playout is here and not above the Call seam.** `VoiceSpeech(speaking=False)`
means the Voice's audio has *finished playing* (#195). Only this side knows when
Expand All @@ -26,7 +26,7 @@
from __future__ import annotations

from collections.abc import Callable
from typing import Protocol, runtime_checkable
from typing import Any, Protocol, runtime_checkable


class TransportError(Exception):
Expand All @@ -37,6 +37,10 @@ class TransportError(Exception):
#: for a close this side asked for: the adapter already knows about those.
LostHandler = Callable[[str], None]

#: Called with each server event the realtime events channel carries, already
#: parsed: a JSON object with a string `type`. On the event loop, and must not block.
EventHandler = Callable[[dict[str, Any]], None]


@runtime_checkable
class CallTransport(Protocol):
Expand Down Expand Up @@ -89,6 +93,15 @@ def on_lost(self, handler: LostHandler) -> None:
"""Be told, once, if the connection ends without being asked to."""
...

def on_event(self, handler: EventHandler) -> None:
"""Be handed every server event the realtime events channel carries (#377).

The Voice's own usage (`session.usage.updated`) arrives there and on no
app-server notification, so the adapter that records it has to be told.
A handler that raises is logged and does not stop the channel.
"""
...

async def aclose(self) -> None:
"""Close the connection and release the audio devices. Idempotent."""
...
Expand Down
18 changes: 17 additions & 1 deletion src/gpt_voicecoding/adapters/call/realtime/webrtc.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
from gpt_voicecoding.adapters.call.realtime.transport import (
CallTransport,
CueOutput,
EventHandler,
LostHandler,
TransportError,
TransportFactory,
Expand Down Expand Up @@ -312,6 +313,8 @@ def __init__(
# all. Event types seen here are named in the log once each, so a run
# shows what this backend actually sends.
self._events_seen: set[str] = set()
#: Who is handed each of those events (#377). None until the adapter asks.
self._on_event: EventHandler | None = None
channel = self._pc.createDataChannel(EVENTS_CHANNEL)

@channel.on("message")
Expand Down Expand Up @@ -434,8 +437,11 @@ def _read_channel_event(self, message: Any) -> None:
census stays: it is how the next codex protocol change gets noticed
rather than silently absorbed.

Since #377 each event is also handed to whoever asked with `on_event`:
the Voice's own usage arrives on this channel and nowhere else.

Anything that is not a JSON object with a string `type` is not a server
event and is not named.
event, and is neither named nor handed up.
"""
if isinstance(message, bytes | bytearray):
message = message.decode("utf-8", errors="replace")
Expand All @@ -453,10 +459,20 @@ def _read_channel_event(self, message: Any) -> None:
if kind not in self._events_seen:
self._events_seen.add(kind)
_log.info("the realtime events channel carried %s", kind)
handler = self._on_event
if handler is None:
return
try:
handler(event)
except Exception:
_log.exception("a realtime events channel event handler raised")

def on_lost(self, handler: LostHandler) -> None:
self._on_lost = handler

def on_event(self, handler: EventHandler) -> None:
self._on_event = handler

async def aclose(self) -> None:
if self._closing:
return
Expand Down
2 changes: 1 addition & 1 deletion src/gpt_voicecoding/core/persistence.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
comes from writing a temporary file and renaming it over the target, so a reader
sees either the whole previous state or the whole new one, never half of either.
If a durable *history* is ever needed, that is a different store and a different
decision.
decision — the usage ledger is one such (ADR 0032), and it is not this file.

**Location.** `~/Library/Application Support/GPT-VoiceCoding/engine/state.json` by
default, and the base directory is a parameter so tests point it somewhere else
Expand Down
5 changes: 5 additions & 0 deletions src/gpt_voicecoding/engine/composition.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@
from gpt_voicecoding.seams.control_plane import Action
from gpt_voicecoding.seams.events import EventSink
from gpt_voicecoding.seams.identity import AgentKind, new_request_id
from gpt_voicecoding.usage_ledger import UsageLedger

_log = logging.getLogger(__name__)

Expand Down Expand Up @@ -506,6 +507,9 @@ def built(seam: str, reference: str) -> Any:
configuration — so it travels like `progress_capture` does: known before
construction, and therefore an argument to it. A second key under
`[adapters.settings.call]` would be the same number written twice.

The usage ledger (#377) is handed over the same way: it is written beside
the state file, in the engine's own directory, and always on.
"""
factory = factory_of(reference)
settings = config.adapters.settings_for(seam)
Expand All @@ -515,6 +519,7 @@ def built(seam: str, reference: str) -> Any:
if seam == "call":
arguments["delegated_turn_model"] = config.delegated_turn_model
arguments["delegated_turn_effort"] = config.delegated_turn_effort
arguments["usage_ledger"] = UsageLedger(config.state_path.parent)
if settings is not None:
arguments["settings"] = settings
try:
Expand Down
Loading
Loading