# weave-opencode

W&B Weave observability for the **[opencode](https://opencode.ai) CLI**. Every
opencode turn — model calls, token usage, tool calls, and reasoning — becomes a
trace in [Weave](https://wandb.ai/site/weave).

This is a port of [`weave-codex`](https://github.com/wandb/weave-codex) to
opencode. It reuses that project's entire span/emit half verbatim (OpenTelemetry
GenAI conventions → Weave's Agents OTLP endpoint) and swaps the ingest half:
where weave-codex parses Codex rollout files behind a Stop hook, weave-opencode
consumes opencode's **in-process plugin event stream** and can backfill
completed headless runs from opencode's SQLite store.

> **Status: live plugin + SQLite collect path.** The pipeline builds,
> type-checks, is unit-tested end-to-end (events/SQLite → spans), and the live
> plugin has been smoke-tested against a real opencode 1.17.x server/TUI session.
> Tool, MCP, web-search, and subagent payloads still need broader live
> validation; see [Known caveats / open questions](#known-caveats--open-questions).

## How it works

```
opencode session (long-lived server, plugin loaded in-process)
  ├─ message.updated / message.part.updated  → Accumulator buffers the turn
  └─ session.idle
       └─ reconstruct completed turn(s) → OTEL span tree → OTLP → Weave
             invoke_agent opencode        (the turn)
             ├─ chat <model>              (one per assistant message: usage, output)
             └─ execute_tool <name>       (one per tool: args, result)

headless opencode run
  └─ opencode.db
       └─ weave-opencode collect --json → reconstruct completed turn(s) → same exporter
```

- **In-process, not a daemon.** opencode plugins run inside its long-lived
  server, so — unlike weave-codex's fire-and-forget shell shim + detached worker
  - Unix socket + byte-offset cursor — there is no separate process. The
    [`Accumulator`](src/source/accumulator.ts) buffers the structured event stream
    in memory; `takeCompletedTurns()` is the cursor's moral equivalent (emits
    complete, not-yet-seen turns once, then prunes them).
- **One trace per turn.** Turns are stitched into a conversation server-side via
  `gen_ai.conversation.id` (the opencode session id) on every span.
- **Backdated timelines.** Span start/end come from opencode's message/part
  timestamps, so durations reflect what actually happened. (This is why we build
  spans through the raw OTel API rather than the Weave TS SDK, same as
  weave-codex.)
- **Never throws into opencode.** Every hook is wrapped; failures log to
  `~/.weave-opencode/logs/collector.log` and are swallowed.
- **Durable idempotency.** Both the live plugin and `collect` record successful
  emits in `~/.weave-opencode/collect-state.json`, keyed by project, DB path,
  conversation id, and turn id.

## Quick start

```bash
npm install -g weave-opencode      # (or run from a local build; see Development)

wandb login                        # or: export WANDB_API_KEY=...
export WEAVE_PROJECT="entity/project"

weave-opencode install             # writes a plugin shim into ~/.config/opencode/plugins/
weave-opencode doctor              # confirms plugin path + Weave config
```

Restart opencode so it loads the plugin. Run `weave-opencode doctor` to confirm
everything resolves. Each completed live turn appears in Weave shortly after
`session.idle`.

If you do not have an `opencode` binary on your `PATH`, the published CLI package
is `opencode-ai`:

```bash
pnpm dlx opencode-ai .
```

## Configuration

| Setting         | Env var                          | `settings.json` key | Default                            |
| --------------- | -------------------------------- | ------------------- | ---------------------------------- |
| W&B API key     | `WANDB_API_KEY`                  | `wandb_api_key`     | from `wandb login` (netrc)         |
| Weave project   | `WEAVE_PROJECT`                  | `weave_project`     | — (required, `entity/project`)     |
| Base URL        | `WANDB_BASE_URL`                 | `wandb_base_url`    | `https://trace.wandb.ai`           |
| OpenCode DB     | `OPENCODE_DB`                    | —                   | `${OPENCODE_DATA_DIR}/opencode.db` |
| Capture content | `WEAVE_OPENCODE_CAPTURE_CONTENT` | `capture_content`   | `true`                             |
| Debug logging   | `WEAVE_OPENCODE_DEBUG`           | `debug`             | off (errors always log)            |

State lives in `~/.weave-opencode/` (settings, `collect-state.json`, and
`logs/collector.log`).

### Data captured

By default weave-opencode captures span **content**: prompts, the model's
responses and reasoning, tool-call arguments, and tool results (which include
shell commands, command output, and file contents). This is sent to your
W&B/Weave instance. **PII scrubbing and redaction are not implemented** — the
same caveat weave-codex carries. Set `WEAVE_OPENCODE_CAPTURE_CONTENT=0` to send
**structure, token usage, model, and timing only** (no prompts/code/output).

## Manual smoke test

Use a long-lived opencode process (the TUI, mini UI, or server). This gives the
plugin enough time to flush spans after `session.idle`.

```bash
WEAVE_PROJECT="entity/project" WEAVE_OPENCODE_DEBUG=1 pnpm dlx opencode-ai .
```

In another terminal:

```bash
tail -f ~/.weave-opencode/logs/collector.log
```

After a turn completes, expect log entries like:

```text
weave-opencode plugin loaded
session flush ... turns=1
emitted 1 turn(s)
```

For a headless smoke test, run a server first and attach a single `run` request
to that server:

```bash
WEAVE_PROJECT="entity/project" WEAVE_OPENCODE_DEBUG=1 pnpm dlx opencode-ai serve --port 4099
WEAVE_PROJECT="entity/project" pnpm dlx opencode-ai run --attach http://127.0.0.1:4099 "Reply with exactly: weave-opencode smoke ok"
```

Plain one-shot `opencode run ...` starts its own short-lived server and may exit
before the live plugin's async OTLP exporter finishes. For headless and CI
workflows, prefer a normal run followed by collection:

```bash
opencode run "Reply with exactly: weave-opencode smoke ok"
weave-opencode collect --json
```

`collect` reads OpenCode's SQLite store in read-only mode, reconstructs complete
turns, and sends them through the same exporter as the live plugin.

## Headless collection

```bash
weave-opencode collect [--json] [--db <path>] [--session <id>] [--since <iso-or-epoch-ms>] [--cwd <path>] [--all]
```

- DB path resolution: `--db`, then `OPENCODE_DB`, then
  `${OPENCODE_DATA_DIR}/opencode.db`.
- Default scope: sessions for the current working directory updated since the
  last successful default collect. The first run looks back 24 hours.
- Watermark scans include a 60-second overlap; already emitted turns are skipped
  through durable state.
- `--session <id>` collects that session regardless of cwd or time filters.
- `--all` removes cwd and time filters while still skipping already emitted
  turns.
- `--since` accepts ISO timestamps or epoch milliseconds.

`collect --json` prints top-level IDs for turns that are present in Weave,
including newly emitted turns and `already_emitted` skips:

```json
{
  "version": "0.2.1",
  "project": "entity/project",
  "db": "/abs/path/opencode.db",
  "conversation_ids": ["ses_..."],
  "trace_ids": ["..."],
  "emitted_turns": [
    {"turn_id": "...", "conversation_id": "ses_...", "trace_id": "..."}
  ],
  "skipped_turns": [
    {
      "turn_id": "...",
      "conversation_id": "ses_...",
      "trace_id": "...",
      "reason": "already_emitted"
    }
  ],
  "errors": []
}
```

Invalid config, missing/unreadable DBs, missing SQLite tables, malformed selected
session data, and export failures exit nonzero. Locked, incomplete, and already
emitted turns are reported as skips.

## What ports from weave-codex, and what's new

| Layer                               | File(s)                                             | Status                                        |
| ----------------------------------- | --------------------------------------------------- | --------------------------------------------- |
| OTLP tracer → Weave Agents endpoint | `spans/tracer.ts`                                   | reused verbatim                               |
| Span tree + GenAI attributes        | `spans/emit.ts`                                     | reused; multi-provider + subagent attrs added |
| GenAI message payloads              | `spans/messages.ts`                                 | reused verbatim                               |
| Domain model (`ReconstructedTurn`)  | `model.ts`                                          | reused; `provider`/`parentSessionId` added    |
| Config / netrc / settings           | `config.ts`                                         | reused (env vars renamed)                     |
| Per-session lock, logger            | `lock.ts`, `log.ts`                                 | reused (renamed)                              |
| **Ingest**                          | `rollout/*` → **`source/*`**                        | **rewritten for opencode**                    |
| Trigger                             | Codex Stop hook → **opencode plugin** (`plugin.ts`) | **rewritten**                                 |
| Headless backfill                   | `collect/*`                                         | **SQLite-backed for opencode**                |
| CLI install                         | merge `hooks.json` → **plugin shim** (`install.ts`) | **rewritten**                                 |

New ingest half, all in [`src/source/`](src/source):

- **`types.ts`** — the opencode event/message/part wire shape consumed by the
  collector. The basic message/part/session-idle path is live-validated; richer
  part shapes are still the validation seam.
- **`reconstruct.ts`** — pure: a session's buffered messages+parts →
  `ReconstructedTurn[]`, marking each turn complete/incomplete.
- **`accumulator.ts`** — stateful in-memory buffer; `takeCompletedTurns()`.
- **`collector.ts`** — filters already emitted turns, builds the tracer, and
  ships turns (reuses `emitTurn`).

Headless collection lives in [`src/collect/`](src/collect): SQLite hydration,
default-scope watermarking, durable emitted-turn state, and the `collect` runner.

## Validating the event mapping

All the opencode-shape risk is contained in **`src/source/types.ts`** and
**`src/source/reconstruct.ts`**; the emit half downstream is provider-agnostic.
To validate a new opencode release or a richer interaction:

1. Run an opencode TUI/server session with `WEAVE_OPENCODE_DEBUG=1`.
2. Capture a few real `message.updated` / `message.part.updated` / `session.idle`
   payloads when adding support for a new part shape.
3. Diff the field names against `types.ts` and the fixtures in
   `src/test-support/fixtures.ts`; adjust, and the unit tests will confirm the
   reconstruction still holds.

### Known caveats / open questions

- **Chat granularity.** Each _assistant message_ becomes one `chat` span. If
  opencode exposes per-model-call (step) boundaries with per-step usage, split
  `chat` spans per step for parity with weave-codex's per-round-trip spans.
- **MCP / web-search detection** is heuristic (tool name / `state.metadata`);
  confirm against real tool parts.
- **One-shot live plugin flushes.** A run that owns its own short-lived server
  can exit before async live export finishes. Use `weave-opencode collect --json`
  after `opencode run` for reliable headless/CI capture.
- **Subagents.** opencode child sessions carry a `parentID`; this is recorded as
  `opencode.parent_session.id`. Nesting child traces under the spawning tool span
  is not yet done.
- **Aborted turns.** Handled on a best-effort basis via `session.error`.

## Development

```bash
npm install
npm run build        # tsc → dist/ (+ chmod the CLI bin)
npm test             # vitest: reconstruct + accumulator + end-to-end span tree
npm run typecheck
npm run lint         # prettier --check + tsc --noEmit
```

Node `>=24` is required for `node:sqlite`. Tests run against `src/` directly
(no dist drift) and point all state at temp dirs, so they never touch a real
`~/.config/opencode` or `~/.weave-opencode`.

`npm run check` is the same gate CI runs: build, lint, tests, and a repository
secret scan. Install [`gitleaks`](https://github.com/gitleaks/gitleaks) locally
before running the full check, or run `npm run setup-hooks` once to enable the
local pre-commit hook that scans staged changes and rebuilds before each commit.

## Release

`package.json` and `package-lock.json` are the version source of truth. To
publish:

1. Bump the package version in both files, for example with `npm version`.
2. Merge the version bump to `main`.
3. Create and push a matching tag, for example `v0.1.2`.
4. Dispatch the “Publish Package” GitHub workflow with that exact tag.

The workflow publishes to npm via trusted publishing, then creates or updates
the GitHub Release for the same tag and marks it as latest. Rerunning the
workflow is safe when the package version is already npm's latest version.

---

Derived from [`wandb/weave-codex`](https://github.com/wandb/weave-codex) (MIT).
