Skip to content

plastic-labs/rotom

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

24 Commits
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Rotom (Context Trace Viewer)

A small web app that lets you page through Hermes Agent conversation sessions turn by turn and inspect exactly what goes into the model's context window — the full assembled system prompt, the user message, and every tool call paired with its result, all in true execution order.

It's a single stdlib Python server plus one HTML file. No dependencies, no build step, no framework.


Quick start

Heads up — Rotom requires a Hermes plugin that is NOT in stock Hermes. Rotom is a reader over Hermes' state.db, and the data it renders is persisted by a plugin (rotom-capture) that captures per-turn prompts. A vanilla Hermes install does not write the per-turn Honcho recall snapshots, and the trace view depends on them. The session list may populate without the plugin; the part that makes Rotom worth running does not. Read Hermes-side requirements — you need the plugin installed and enabled, not just the same Hermes version.

Prerequisite: Hermes Agent installed and used at least once, so that ~/.hermes/state.db exists with some sessions in it — plus the rotom-capture plugin described below. The viewer reads that database; it does not generate data of its own.

git clone https://github.com/plastic-labs/rotom.git
cd rotom
python3 server.py

Then open http://localhost:32850/ in any browser. That's it — no other process is required.

You should see your recent sessions in the left sidebar. Pick one and use / to page through its turns.


Hermes-side requirements

Rotom is a reader over Hermes' SQLite store at ~/.hermes/state.db. It cannot show data Hermes never wrote, and the data it depends on comes from a Hermes plugin (rotom-capture) that lives in ~/.hermes/plugins/ — outside the Hermes repo, so it survives hermes update (see HERMES-PATCHES.md in this repo for the full setup guide). This is not upstream stock behavior. To run Rotom on your machine you need the plugin installed and enabled.

What Rotom reads, and where each piece comes from:

  1. sessions.system_prompt — the system prompt captured once at session start (Persona, MEMORY, USER PROFILE, and the Honcho tooling preamble — the "use honcho_profile/..." text). This is a column on the sessions table that stock Hermes populates; on the build Rotom targets it's filled for 424 of 428 sessions. Note this is the static prompt, not the per-turn recalled Honcho payload.

  2. prompt_snapshots table — the keystone, and the reason the trace view is worth anything. The per-turn Honcho recall block (<memory-context>: Session Summary, User/AI Representation, Peer Card, Deductive/Inductive observations) is assembled at request time, sent to the model, and discarded by stock Hermes — never persisted. The rotom-capture plugin adds:

    • a prompt_snapshots table (self-creates via CREATE TABLE IF NOT EXISTS on plugin load),
    • a pre_api_request hook that stashes the assembled prompt + extracted Honcho block,
    • a post_api_request hook that writes the row with token usage, gated behind a config flag and crash-isolated.

    It is off by default. To enable:

    # config.yaml
    trace:
      capture_honcho: true
    plugins:
      enabled:
        - rotom-capture

    Then restart the Hermes process — plugins are discovered at startup, and snapshots only accrue going forward (there is no backfill of past sessions). Without this, Rotom's Honcho lane is empty.

    The capture also stores the full assembled prompt (assembled_prompt, the whole api_messages array as sent). Rotom surfaces it as a "Full assembled prompt (exact payload sent)" lane at the top of each turn — the literal message array the model received, one collapsible entry per message with role badges and per-message token estimates. It's the ground-truth view every other lane is derived from.

  3. messages.tool_calls / tool_call_id — needed to render tool calls with their arguments in execution order. Stock Hermes does store these; the risk is only if a different Hermes version stores them differently.

Per-message true token counts are NOT available even on the patched build: messages.token_count is NULL for every row. Rotom shows chars/4 estimates labeled ~est — never mistake those for provider truth. Populating real per-message counts was scoped but never implemented.

Bottom line: you need (a) the rotom-capture plugin installed in ~/.hermes/plugins/ and added to plugins.enabled, and (b) trace.capture_honcho: true set with a process restart. See HERMES-PATCHES.md in this repo; it has the schema, the plugin structure, the four safety properties (config flag not .env, crash-isolated, out-of-band so prompt caching survives, capture-only-when-present), and the enable steps. Without the plugin, Rotom runs and lists sessions but the per-turn context view won't have the data it's designed to show.


Configuration (all optional)

The defaults work out of the box. Override with environment variables if you need to:

Var Default What it does
PORT 32850 Listen port
HOST 127.0.0.1 Bind address (set 0.0.0.0 for remote)
STATE_DB ~/.hermes/state.db Path to your Hermes state DB
TV_BASE http://127.0.0.1:32848 Television base URL, for theming only
TV_TOKEN ~/.television/state/token TV bearer token file, for share links

Example — a different port and an explicit DB path:

PORT=8080 STATE_DB=/home/me/.hermes/state.db python3 server.py
# open http://localhost:8080/

What it shows

For any session you pick from the sidebar, each turn is shown as a single call-stepped round-trip. Within a turn Hermes makes one API call per model round-trip (each tool result triggers another call with the grown transcript), so an API-call stepper pages through every call (call 1 of N). For the selected call you see the full round-trip, top to bottom:

  • INPUT (→ sent to the model) — the exact api_messages payload for this call, banded into cached (the byte-stable system prefix), replayed (transcript messages identical to the previous call — collapsed by default, diffed via a per-message signature), and new (what was appended this call — expanded by default: the delta you're actually reading). Nothing is omitted — the collapsed bands are present and one click away, so a surprising response is always fully reconstructable. The captured Honcho <memory-context> recall rides inside the user message on call 1 and is split into its sub-sections here. Context compaction is surfaced as a first-class event: on the call where the compressor rewrites the prefix, INPUT carries a badge and its replayed band force-expands (calling it "unchanged" would be a lie); re-compaction mid-turn shows as distinct badges.
  • OUTPUT (← model response) — the text + reasoning + finish_reason the model returned on this call. This is NOT in the input payload (that's input-only); it's derived from the assistant frame this call produced.
  • TOOLS (⇄ args in → result back) — each tool call this round-trip made, with its arguments pretty-printed per-key (the literal input to the tool) paired with its result. Heavy fields (e.g. a write_file content field) are token-weighted and flagged as replaying every subsequent call — the compounding cost made visible.

The provider's input / cache-read token counts for the selected call are shown inline.

Sessions can be pinned (the pin icon on each row) to float them to the top of the list. Pins are a local UI preference stored in localStorage — the backend is read-only and never writes to your DB.

Keyboard: / page between turns.


Architecture

  browser ──loads──> http://localhost:32850/   (this app)
                              │
                server.py (stdlib HTTPServer, threaded)
                              │
              ┌───────────────┴────────────────┐
        reads state.db                  (optional) proxies a
        ~/.hermes/state.db              Television theme stylesheet
        sessions + messages             + fonts from TV_BASE
  • server.py — stdlib http.server (no deps), threaded, binds HOST:PORT.
  • public/index.html — single-file vanilla-JS frontend, all paths relative.

Data source

The viewer reads the live SQLite store at ~/.hermes/state.db (sessions + messages tables), opened read-only so it never blocks Hermes' writer. It does not read the legacy ~/.hermes/sessions/*.jsonl files — Hermes stopped writing those in May 2026; everything since lives in state.db (the same store session_search uses), including the stored system_prompt column that powers the context view.

Endpoints

Route Returns
GET / the viewer HTML
GET /sessions 150 most recent sessions (id, title, model, tokens, times)
GET /trace/<id> { meta, system_prompt, turn_count, turns[] }
GET /canonical.css proxies the TV active-theme stylesheet (optional)
GET /themes/* proxies TV theme font files (optional)
GET /token TV bearer token (used only for share links)
GET /health { "status": "ok", "port": <PORT> }

Security note: the /token endpoint serves the contents of the Television bearer token file. Do not expose it to untrusted networks — relevant if you set HOST=0.0.0.0 to allow remote access.

Turn parsing

parse_turns() groups messages into turns (one user message + everything until the next user message). build_trace() walks each turn's messages in order and emits a steps[] array, pairing every tool result to its originating call via tool_call_id. That's what makes tool calls render in real execution order with the command inline, instead of collapsing a turn to its final text and a flat bucket of orphaned results.


Running it as a long-lived service

server.py is just a foreground process. For a clean clone, python3 server.py (or a process manager of your choice) is all you need.

If you want it to survive reboots and self-heal, a tiny watchdog cron works well. The reference one used on the original deployment lives at ~/.hermes/scripts/ctx_trace_watchdog.sh and runs every 5 minutes:

  • Healthy (/health → 200): exits silently.
  • Down: kills any stale process by listening port (ss -tlnp → pid — the process argv is just python3 server.py, so pkill -f on the path does NOT match it), relaunches detached, polls health, prints one line only if it had to restart.

Force a fresh start (e.g. after a code change):

PORT=32850
PIDS=$(ss -tlnpH "sport = :$PORT" | grep -oE 'pid=[0-9]+' | cut -d= -f2 | sort -u)
[ -n "$PIDS" ] && kill -9 $PIDS
cd /path/to/rotom && nohup python3 server.py >/tmp/ctx-trace.log 2>&1 & disown

Verify:

curl -s http://127.0.0.1:32850/health           # {"status":"ok","port":32850}
curl -s -o /dev/null -w "%{http_code}\n" \
     http://127.0.0.1:32850/                     # 200

Appendix: Television Theming (Optional)

Rotom runs perfectly without Television (TV). TV is an optional theming dependency — if it's not running, the page falls back to its own built-in dark theme and everything else works exactly the same. There's also a local light/dark toggle (top right) that persists in localStorage.

If you do have a Television instance and want Rotom to match its theme, configure the two TV-related environment variables:

Var Default What it does
TV_BASE http://127.0.0.1:32848 Television base URL
TV_TOKEN ~/.television/state/token Path to the TV bearer token file

When TV_BASE is reachable, Rotom proxies theme assets through two optional endpoints:

  • GET /canonical.css — proxies the TV active-theme stylesheet so the viewer picks up the same colors and fonts as the rest of the dashboard.
  • GET /themes/* — proxies TV theme font files referenced by the stylesheet.

The GET /token endpoint serves the contents of the TV bearer token file (used to generate share links with TV). As noted in the endpoints table above, this endpoint should not be exposed to untrusted networks.

If nothing is running at TV_BASE, these endpoints return empty or error responses and the viewer uses its built-in theme — no crash, no missing content.

About

No description, website, or topics provided.

Resources

License

Stars

1 star

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors