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
71 changes: 71 additions & 0 deletions .claude/commands/audit-docs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
---
description: Audit the prose docs against the actual code and fix any drift, treating every doc claim as untrusted until verified against source
---

Audit xray's prose documentation against the current code and bring it back in
sync. The governing rule is [`.claude/rules/docs-freshness.md`](../rules/docs-freshness.md)
— read it first for the source → doc map.

**Premise: every doc claim is untrusted until verified against source.** A
markdown table, a docstring, a code example — all are claims about the code, not
the code itself. Do not trust a doc because it reads confidently. Open the
source and confirm.

## Scope

`$ARGUMENTS` may narrow the audit to one area (e.g. `sdk`, `wire-contract`,
`schema`). If empty, audit the full set:

- `docs/` — `index.md`, `architecture.md`, `integrate.md`, `sdk-python.md`, `wire-contract.md`
- `README.md`, `CONTRIBUTING.md`, `CLAUDE.md`
- `sdk/python/README.md`, `sdk/python/.claude/rules/typed-boundaries.md`

If a commit range is given (e.g. `since main`), use `git diff` to find which
source files moved and audit only the docs the map couples them to — a targeted
pre-PR pass.

## Method

1. **Walk the source → doc map** in the freshness rule. For each doc in scope,
list its concrete claims: exported names, signatures, enum values, table
counts, env var names + defaults, API routes, limits, status codes, file
paths, code examples.
2. **Verify each claim against source** — Read the file, Grep the symbol. Pick
the highest-signal ground truth:
- SDK public surface → `sdk/python/src/xray/__init__.py` (`__all__`) + the
dataclass / function defs in `conversation.py`, `config.py`, `errors.py`,
`instrument.py`, `orchestrator.py`, `runtime/*`.
- Storage / enums → `src/server/store/schema.ts` (`lifecycle_state`,
`analysis_step`, `failure_reason`, table list).
- OTLP vocabularies / extraction → `src/server/otlp/vocabularies/*`.
- Limits + error shapes → `src/server/otlp/otlp.types.ts`, `otlp.router.ts`.
- Env vars → `src/server/env/env.ts`.
3. **Stale-name sweep.** Grep the doc set for names the code no longer exports
(the highest-confidence drift). Judge each hit — a legitimate "renamed from
X" migration note is fine; a live reference to a dead symbol is not.
4. **Check cross-links.** Every relative markdown link and `github.com/.../blob`
link must resolve. Remember `docs/` is served as a GitHub Pages site under
`baseurl: /xray` — links that escape the `docs/` root must use the full blob
URL, not `../`.
5. **Honesty pass** ([`honesty.md`](../rules/honesty.md)). Flag any claim that
says *more* than the code guarantees, not just outright-wrong ones. Over-claims
are the subtle drift.

## Fix

Apply the corrections directly. For each fix, the doc must now match a specific
line of source you can point to. If a claim can't be verified, remove or soften
it rather than leaving it authoritative.

## Report

End with a short summary grouped by severity:

- **Wrong** — a flat-out false claim a reader would act on (fixed).
- **Over-claim** — says more than the code guarantees (fixed/softened).
- **Stale link / name** — dead reference (fixed).
- **Checked, fine** — a claim you verified that turned out correct (list briefly,
so the audit's coverage is legible — per [`honesty.md`](../rules/honesty.md) §3,
don't claim you audited what you didn't).

Do not commit or push unless asked — leave the working tree for review.
39 changes: 39 additions & 0 deletions .claude/rules/docs-freshness.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# Docs track the code — audit them when the surface moves

The prose docs in this repo (`docs/`, the root `README.md`, `CONTRIBUTING.md`, `CLAUDE.md`, `sdk/python/README.md`) are **untrusted by default**: a comment, a docstring, or a markdown table is a claim about the code, not the code itself, and it drifts silently the moment the thing it describes changes. There is **no CI gate** for this on purpose — AI-in-CI is too expensive for a self-hosted single-image project, and a deterministic denylist of stale strings rots faster than the docs it guards. So freshness is a *process* obligation, enforced by this rule plus the [`/audit-docs`](../commands/audit-docs.md) command, not by a check.

The failure mode this prevents: someone renames `LiveKitDriver` → `LiveKitRuntime` in the SDK, the rename compiles and the tests pass, but six markdown files still say `LiveKitDriver` — and a reader who copies the example gets an `ImportError`. That was a real, whole-PR cleanup (the docs rewrite that introduced this rule). The fix is cheap *at the point of change* (you're already in the code) and expensive later (a full re-audit).

---

## 1 · The obligation

When you change a **public surface** — anything a doc describes — re-check the docs it maps to **in the same change**, the same "fix in passing" reflex as [`pattern-matching.md`](./pattern-matching.md) §4 and [`comments.md`](./comments.md) §4. You do not need to rewrite a doc you didn't invalidate; you need to confirm the ones you *did* touch still tell the truth.

Before opening a PR that touches any row's left column below, run [`/audit-docs`](../commands/audit-docs.md) (or do the equivalent by hand). It's advisory, not gated — but the audit is cheap and the drift is embarrassing in a public repo.

## 2 · The source → doc map

Coarse on purpose (directory / file → doc), so it survives refactors that a line-level map wouldn't. If you add a doc or a new public surface, add the edge here.

| If you change… | Re-check… |
|-------------------------------------------------------------|---------------------------------------------------------------------------|
| `sdk/python/src/xray/__init__.py` (`__all__`), `conversation.py`, `config.py`, `errors.py`, `instrument.py`, `orchestrator.py` (public signatures) | `docs/sdk-python.md` (API table + signatures), `sdk/python/README.md` (quickstart) |
| `sdk/python/src/xray/runtime/*` (runtime classes / ABC / protocols) | `docs/sdk-python.md` (Runtimes), `docs/integrate.md` (example imports) |
| `src/server/store/schema.ts` (tables, `lifecycle_state` / `analysis_step` / `failure_reason` enums) | `docs/architecture.md` (storage ERD + table list), `docs/integrate.md`, `CLAUDE.md` (Storage ¶) |
| `src/server/otlp/vocabularies/*` | `docs/wire-contract.md` (vocabularies), `docs/integrate.md` (vocab section) |
| `src/server/otlp/otlp.types.ts` (size / span caps, error shapes) | `docs/wire-contract.md` (Limits + status codes) |
| `src/server/env/env.ts` (`XRAY_*` env vars + defaults) | `docs/sdk-python.md`, `docs/architecture.md`, `README.md`, `CONTRIBUTING.md` |
| Control-plane routes (`src/server/**/<slice>.router.ts`) | `docs/architecture.md` (control-plane list). The OpenAPI at `/docs` self-syncs from `describeRoute` — only the *narrative* needs a human. |

## 3 · Honesty bar (cross-link)

A doc must not claim more than the code guarantees — see [`honesty.md`](./honesty.md). "The SDK synthesizes TTS" when TTS moved server-side, or "X is supported" when the code does less, is an honesty failure, not a typo. The `/audit-docs` method is built on this: every doc claim is verified *against source*, and an unverifiable claim is removed or softened, never left to look authoritative.

---

## What's NOT a rule here

- **"Every code change needs a doc change."** No — most don't (internal refactors, test additions, bug fixes that already match the docs). The obligation fires only when you move a surface a doc *describes*.
- **"Add a CI check / pre-commit grep for stale strings."** Deliberately rejected: maintenance cost + an incomplete denylist gives false confidence. Stale-name grepping lives *inside* `/audit-docs`, where a human judges each hit, not as a standalone gate.
- **"Keep docs exhaustive."** Out of scope. This rule is about *accuracy* of what's documented, not *coverage*.
13 changes: 7 additions & 6 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,23 +6,23 @@

**Voice is the primary investment.** Per-turn audio playback, barge-in indicators, per-stage STT/TTS latency, full-replay mixdown — first-class, not afterthoughts.

**Storage.** Conversations, Replays, server-derived `replay_turns` + `speech_segments` (from VAD), per-turn Whisper transcripts (`turn_transcripts`), per-turn timing metrics (`replay_metrics`), per-assertion + per-judge outcomes (`assertion_results`, `judge_results`), the per-replay verdict row (`replay_evaluations`), recognized OTLP spans, and tool-call / model-usage rows live in a single SQLite file at `/data/xray.db` (mounted volume on the container). bunqueue (the embedded job queue) owns a separate `/data/bunqueue.db` file in the same volume — acknowledged tradeoff vs the strict "one file" reading of the single-image rule (single volume, two files, no second process). Both use single-writer `bun:sqlite`, no network driver. Why SQLite is the right choice here is the topic of [`.claude/rules/single-image-distribution.md`](./.claude/rules/single-image-distribution.md).
**Storage.** Conversations, Replays, server-derived `replay_turns` + `speech_segments` (from VAD), per-turn transcripts (`turn_transcripts`), per-turn timing metrics (`replay_metrics`), per-assertion + per-judge outcomes (`assertion_results`, `judge_results`), the per-replay verdict row (`replay_evaluations`), recognized OTLP spans, tool-call / model-usage rows, and the server-side TTS cache (`tts_synth_cache`) live in a single SQLite file at `/data/xray.db` (mounted volume on the container). bunqueue (the embedded job queue) owns a separate `/data/bunqueue.db` file in the same volume — acknowledged tradeoff vs the strict "one file" reading of the single-image rule (single volume, two files, no second process). Both use single-writer `bun:sqlite`, no network driver. Why SQLite is the right choice here is the topic of [`.claude/rules/single-image-distribution.md`](./.claude/rules/single-image-distribution.md).

## The two paths data takes into xray

xray has exactly two write surfaces; both are documented and Valibot-validated at the boundary.

1. **Control plane (the SDK calls these directly).**
- `POST /v1/conversations` — idempotent upsert of the Conversation spec (turns + per-turn assertions + conversation-level judges) keyed by its content hash. The server computes the hash; the SDK never hashes anything. Same canonical JSON in → same hash → row upsert (last-write-wins on `name`).
- `POST /v1/replays` — eager Replay-row creation (`lifecycle_state='pending'`). Returns `replay_id` so the SDK can propagate it (LiveKit room metadata → OTEL baggage) BEFORE the dev's agent emits its first span.
- `POST /v1/conversations` — idempotent upsert of the Conversation spec, sent as **multipart/form-data**: a JSON `spec` part (`{name, turns, judges?, live?}`) plus one file part per `RecordedAudio` turn. Keyed by content hash; the server computes the hash (including the sha256 of any audio bytes), the SDK never hashes anything. Same canonical spec in → same hash → row upsert (last-write-wins on `name`).
- `POST /v1/replays` — eager Replay-row creation (`lifecycle_state='pending'`). Returns the Replay row (`ReplayDetailResponse`; its `id` is the replay id) so the SDK can propagate the replay context (the joining participant's JWT `xray` attribute → OTEL baggage; no room/participant metadata) BEFORE the dev's agent emits its first span.
- `POST /v1/replays/:id/audio` — driver uploads the 48kHz int16 **stereo WAV** (L = user, R = agent, wall-clock-aligned), plus the `X-Recording-Started-At` header: the wall-clock of audio sample 0, persisted as `replays.recording_started_at` — the sole origin for mapping span timestamps onto the audio timeline. Flips `lifecycle_state` to `recording_uploaded`.
- `POST /v1/replays/:id/analyze` — enqueues the bunqueue `analyze-replay` job. The server transitions to `lifecycle_state='analyzing'` with `analysis_step='vad'`. **Three-stage chain**: `analyze-replay` (VAD per channel + Whisper transcription per turn) → `calculate-metrics` (audio-frame metrics: agent_response_ms, interrupted) → `evaluate-replay` (runs declared assertions + judges, writes `assertion_results` / `judge_results` / `replay_evaluations`, flips lifecycle to `completed`). Each stage enqueues the next on success; any stage's failure stamps `lifecycle_state='failed'` with a stage-specific `failure_reason`. Tool/model spans are attributed to turns at eval/read time by mapping their wall-clock `started_at` onto the audio timeline (origin = `replays.recording_started_at`, see `docs/specs/0001-timeline-clock-alignment.md`) — there is no stored `turn_idx` on those rows and no backfill stage.
- `POST /v1/replays/:id/analyze` — enqueues the bunqueue `analyze-replay` job. The server transitions to `lifecycle_state='analyzing'` with `analysis_step='vad'`. **Three-stage chain**: `analyze-replay` (VAD per channel + per-turn transcription; provider-selectable — OpenAI Whisper / Google Gemini / Mistral Voxtral) → `calculate-metrics` (audio-frame metrics: agent_response_ms, interrupted) → `evaluate-replay` (runs declared assertions + judges, writes `assertion_results` / `judge_results` / `replay_evaluations`, flips lifecycle to `completed`). Each stage enqueues the next on success; any stage's failure stamps `lifecycle_state='failed'` with a stage-specific `failure_reason`. Tool/model spans are attributed to turns at eval/read time by mapping their wall-clock `started_at` onto the audio timeline (origin = `replays.recording_started_at`) — there is no stored `turn_idx` on those rows and no backfill stage.
- `GET /v1/replays/:id/events` — SSE stream of `state` / `progress` / `evaluation_complete` / `failed` events. The `evaluation_complete` event carries the full `ReplayResult` payload (verdict + per-assertion + per-judge + per-turn metrics) — the SDK returns immediately without a follow-up GET.
- `GET /v1/replays/:id/result` — same `ReplayResult` payload outside the SSE stream for late subscribers / inspector hydration.
- `PATCH /v1/replays/:id` — driver-side failures only (`failure_reason='driver_aborted'` / `audio_missing` / `agent_not_joined`). Lifecycle transitions during the analyze chain are server-owned.

2. **OTLP/HTTP receiver (the dev's agent emits spans).**
- `POST /v1/otlp/v1/traces` — OpenTelemetry traces (JSON + protobuf). **Filters, not gates**: routes spans by the `xray.replay.id` resource attribute and runs each through a vocabulary registry (`src/server/otlp/vocabularies/`: `xray.*`, OTel GenAI semconv `gen_ai.*`, Langfuse). Unknown vocabularies are dropped silently; unknown replay ids are dropped silently. Extracted fields land in `tool_calls` and `model_usage` with no stored turn association — turn membership is derived at eval/read time from each row's wall-clock `started_at` against the audio timeline (origin = `replays.recording_started_at`, sent by the driver as the `X-Recording-Started-At` header on the audio upload). Model TTFT, when the agent's instrumentation emits `gen_ai.response.time_to_first_chunk`, lands on `model_usage.ttft_ms`. Every accepted span lands in `spans`. `xray.turn` / `xray.stage.*` are accepted as raw spans only. `xray.assertion` and `xray.judge` are no longer recognized — evaluation runs from the declared `Assertion` / `Judge` catalog.
- `POST /v1/otlp/v1/traces` — OpenTelemetry traces (JSON + protobuf). **Filters, not gates**: routes spans by the `xray.replay.id` attribute (a span-level value overrides the resource-level value) and runs each through a vocabulary registry (`src/server/otlp/vocabularies/`: `xray.*`, OTel GenAI semconv `gen_ai.*`, Langfuse). Unknown vocabularies are dropped silently; unknown replay ids are dropped silently. Extracted fields land in `tool_calls` and `model_usage` with no stored turn association — turn membership is derived at eval/read time from each row's wall-clock `started_at` against the audio timeline (origin = `replays.recording_started_at`, sent by the driver as the `X-Recording-Started-At` header on the audio upload). Model TTFT, when the agent's instrumentation emits `gen_ai.response.time_to_first_chunk`, lands on `model_usage.ttft_ms`. Every accepted span lands in `spans`. `xray.turn` / `xray.stage.*` are accepted as raw spans only. `xray.assertion` and `xray.judge` are no longer recognized — evaluation runs from the declared `Assertion` / `Judge` catalog.

The two paths are coupled by trust: the OTLP receiver doesn't create Conversation or Replay rows, ever. The trust boundary is the SDK's POST. The analyze chain adds three "internal" write paths — the embedded bunqueue workers writing `replay_turns` + `speech_segments` + `turn_transcripts` + `replay_metrics` + `assertion_results` + `judge_results` + `replay_evaluations` after reading the uploaded WAV + the declared spec.

Expand All @@ -42,7 +42,7 @@ The shared `OpenAPIV3.SchemaObject` helper lives in `src/server/core/types.ts` a
The Python SDK lives at [`sdk/python/`](./sdk/python). Public surface:

- `xray.conversation` — test definitions: `Conversation`, `Turn.user(...)`, `Turn.agent(...)`, declarative `Assertion.contains(...)` / `Assertion.tool_called(...)` / `Assertion.max_latency_ms(...)` / etc., and conversation-level `Judge.text_match(...)`. All assertion / judge variants ship on the wire and run **server-side**.
- `xray.runtime` — pluggable driver ABC; `xray.runtime.livekit.LiveKitDriver` is the v1 implementation. Pipecat / OpenAI Realtime / Gemini Live / raw WebSocket are on the roadmap as new sub-modules.
- `xray.runtime` — pluggable `Runtime` ABC (`xray.runtime.base.Runtime`); `xray.runtime.livekit.LiveKitRuntime` is the v1 implementation, and `xray.runtime.livekit_live.LiveKitLiveRuntime` powers `xray.run_live` (OS-mic sessions). Pipecat / OpenAI Realtime / Gemini Live / raw WebSocket are on the roadmap as new sub-modules.
- `xray.attach(ctx)` — async context manager for LiveKit Agents worker entrypoints; reads the JWT's `xray` attribute, installs the OTLP exporter, force-flushes spans on exit.
- `xray.run(...)` — orchestrator: POST conversation + replay, drive the driver, upload the stereo WAV, POST `/analyze`, wait for the server's `evaluation_complete` SSE, return `xray.ReplayResult`. Per-assertion / per-judge failures don't raise — `assert result.passed` is the pytest idiom. Driver-side or server-chain failures raise typed `XrayError` / `ReplayEvaluationError`.

Expand All @@ -69,6 +69,7 @@ Every CI step must be runnable on a developer machine with one command — image
@.claude/rules/supply-chain.md
@.claude/rules/public-repo.md
@.claude/rules/single-image-distribution.md
@.claude/rules/docs-freshness.md

### Python SDK rules (load when touching `sdk/python/`)

Expand Down
Loading
Loading