# Architecture: How This Codebase Is Structured

English | [中文](architecture.zh.md)

This complements [`plugin-development.md`](plugin-development.md), which explains the DSH/Cordis mechanics this plugin uses. This document explains the *internal* design: why the code is split the way it is, how data flows between modules, and what boundary each layer is responsible for. It assumes you have already skimmed the [main README](../README.md) for *what* the plugin does.

## The two-process shape

A DSH plugin with a browser half is really two programs that happen to ship together and never share memory:

```
┌─────────────────────────────┐        HTTP (JSON only)        ┌──────────────────────────────┐
│  src/host/   (Node process) │ ◄─────────────────────────────► │  src/client/  (browser)      │
│  compiled 1:1 by tsc        │                                  │  bundled by esbuild into one  │
│  → dist/host/**             │                                  │  classic script → dist/client.js │
└─────────────────────────────┘                                  └──────────────────────────────┘
              │                                                                 │
              └──────────────────────┬──────────────────────────────────────────┘
                                      │ type-only (erased at build time)
                              ┌───────▼────────┐
                              │  src/shared/    │
                              │  record-shape.ts│
                              └────────────────┘
```

`src/shared/` is the only thing both halves reference, and it contributes **zero runtime bytes** to the client bundle — it is `import type` only (`WireRecord`, `WireRecordSummary`, …), so both sides agree on the field names of a captured record without either side depending on the other's code.

## Host half: capture → store → serve

The host side has one job — turn raw `fetch` traffic into queryable records — done in three layers, each independently testable:

```
fetch-patch.ts ──records──► store.ts ──serves──► routes.ts
     ▲                    │  │                      ▲
     │                    │  ▼                      │
call-context.ts           │  persistence/ (archive.ts, codec.ts, naming.ts)   config.ts
step-tracker.ts           ▼
     ▲              coverage.ts
     │                    ▲
index.ts (apply) wires everything above into ctx.effect()/ctx.on(), and config.ts's schema into Cordis activation
```

- **`fetch-patch.ts`** is the capture layer: it knows how to wrap a `fetch` function and produce a `WireRecord`, and nothing else. It takes its `push`/`finalize` callbacks as parameters rather than importing the store directly — so it can be unit-tested against a fake `fetch` with no store at all. Its admission test takes either of two signals: the harness `deepseek-harness/` user-agent, or a bound `call-context.ts` identity (a request issued inside a model call). The second exists because a provider plugin may rewrite the user-agent at the fetch layer; admission is deliberately not the place where a request can get lost.
- **`call-context.ts`** / **`step-tracker.ts`** are the *attribution* layer: they answer "which turn/session/purpose does this call belong to", entirely independent of HTTP or storage. `call-context.ts` also carries each call's mutable receipt — how many requests the patch captured under it — and reports one `SettledCall` when the stream settles, which is what lets the store count a model call that produced no capture at all. `step-tracker.ts` in particular has zero Cordis or Node dependencies — it is a plain `Map`-based state machine, tested the same way any pure TypeScript module would be.
- **`config.ts`** owns `PluginConfig` (the user-facing shape), its runtime `Config` schema (`@deepseek-ai/schemastery`, no Cordis dependency), and `resolveEffectiveConfig` — the one place every field's default is applied, so `index.ts`'s `apply`, the `config` route's `GET`, and this file's own tests all agree on what "the config currently in effect" means. It exists separately from `index.ts` for the same reason `persistence/constants.ts` does: a default-value change and a Cordis-wiring change are different concerns, and keeping them in one file means either edit's tests can accidentally exercise the other's code path.
- **`store.ts`** is the orchestration layer: an in-memory ring merged live with an optional durable `archive`. It depends on `fetch-patch.ts` (to build the wrapped `fetch`) and `persistence/` (to persist/restore), but knows nothing about HTTP. It also feeds **`coverage.ts`** from both sides: every pushed record's provider/host, and every settled model call (`recordModelCall`, from the glue's `llm/stream` observer) with whether that call captured anything. `coverage.ts` is a small, dependency-free counter pair that answers "how many calls has each provider produced since this process started" AND "how many model calls produced nothing" — a health signal, not part of the record itself, exposed read-only through `stats()`. It exists because the fetch patch's coverage is only as good as `globalThis.fetch` staying the active reference, and as the traffic actually entering it: a provider whose slot got swapped, or whose SDK holds a pre-patch snapshot of the slot, is invisible from the capture side alone — but a non-zero untraced-model-call count is something no such provider can hide (see the README's "Known limitation" section). `store.ts`'s own `clear()` takes an optional `sessionId`, narrowing both the ring and the archive clear to one session — the Wire Trace tab's own "Clear" button passes one, the plugin management page's "Clear all history" button omits it. `clear()` deliberately does not reset the coverage counters.
- **`persistence/`** is its own three-way split, because "store a record" and "name a file" and "reshape a record for disk" are three different concerns that used to live in one 689-line file before this refactor:
  - `naming.ts` — turns a timestamp into a globally-unique, chronologically-sortable filename (no I/O).
  - `codec.ts` — converts between the live record shape and the on-disk JSON shape (no I/O).
  - `archive.ts` — the actual `readdir`/`readFile`/`writeFile` orchestration, built on the two above. Its `clear(options?)` mirrors `store.ts`'s session scoping: an unscoped clear is a pure name operation (no file is opened), while a session-scoped one must open every file to read its `sessionId` first — done in the same concurrent-batches-with-a-yield discipline `list()` already uses, so a large scoped clear cannot stall the event loop the web UI shares.
- **`routes.ts`** is the HTTP-facing layer: it knows about `IncomingMessage`/`ServerResponse` and query strings, and calls `store`/`curl.ts`/`config.ts` — but the store has no idea an HTTP server exists. This is what lets `createWireTraceStore` be exercised directly (see the smoke tests in this repository's development history) without a running web server. Its `config` route (`GET`/`POST`) is the one place this plugin writes its OWN persisted configuration: `POST` merges a patch over the current config, validates the result with `config.ts`'s `Config` schema, and persists it through a Cordis `configEditor` service (when the host exposes one) — see [`plugin-development.md`](plugin-development.md) for why this is safe and what it degrades to when that service is absent.
- **`curl.ts`** is a self-contained feature (render a record as a `curl` command) that only `routes.ts` calls; it does not participate in capture or storage at all.
- **`index.ts`** is the only file that imports Cordis concepts (`ctx.effect`, `ctx.on`, `ctx.get`, `ctx.inject`, `ctx.fiber`) — every other host module is plain TypeScript that happens to be *useful to* a Cordis plugin, not *coupled to* one. That separation is what let every layer below be verified with plain Node scripts while developing this plugin, with no running DSH process required. Its wiring is also deliberately shaped so the fetch patch goes in the moment `apply` runs: this plugin declares no top-level `inject` (which would keep `apply` inactive until the web server exists) and reaches each service through `ctx.inject([...])` where it is actually needed — because a module that loads before the patch is installed is invisible to it forever (see the README's "Known limitation").

## Client half: data model → adapters → view

The browser side follows the same "keep the pure computation away from the framework glue" principle, split three ways:

```
sse.ts (parse) ──frames──► sse-merge/ (reassemble) ──merged JSON──► wire-trace-view.ts ──renders──► json-view.ts
                                                                            │
                              view-model.ts (list/detail logic), json-model.ts (flatten),
                              format.ts (labels), strings.ts (text), api-client.ts (fetch)

plugin-manage-view.ts ──renders (cache stats, clear-all, every Config field)──► the plugins.bundle.config Slot
  (shares ViewContext/WireTraceReact with wire-trace-view.ts; uses the same api-client.ts and strings.ts)
```

- **`sse.ts`** only knows the SSE wire format (`event:`/`data:`/blank-line framing). It has never heard of OpenAI, Anthropic, or Cordis.
- **`sse-merge/`** is a small adapter registry — see [`plugin-development.md` §5](plugin-development.md#5-what-is-not-ordinary-cordis-the-fetch-patch) for why this exists at all, and the main README's ["Response: merged SSE"](../README.md#response-merged-sse-and-the-raw-wire-text-underneath) section for what it produces. Structurally, each file is a self-contained state machine implementing the same `SseMergeAdapter<TResult>` interface (`shared.ts`):

  | file | recognizes | reassembles into |
  |---|---|---|
  | `anthropic.ts` | `event: message_start` / `content_block_delta` / … | the shape of a non-streamed `POST /v1/messages` response |
  | `openai-responses.ts` | `event: response.output_item.added` / `response.output_text.delta` / … | the shape of a non-streamed `POST /v1/responses` response |
  | `openai-chat-completions.ts` | an object with a top-level `choices` array | the shape of a non-streamed `POST /v1/chat/completions` response |
  | `index.ts` | — | offers every frame to each adapter above in turn; a frame no adapter claims goes to `unrecognized` |

  Adding a fourth provider shape means adding one more file next to these three and registering its factory in `index.ts`'s `ADAPTER_FACTORIES` array — nothing else in the module changes. This is why the split exists: the previous single-file version mixed "how do I recognize this frame" with "how do I render it", which made adding a provider mean editing one growing function instead of adding one new file.
- **`json-model.ts`** is the JSON-tree flattening algorithm (`flattenJq`, collapse/expand bookkeeping) with no DOM and no React — it is what makes the request pane, the merged response pane, and the raw-frame pane all render through the exact same collapsible tree.
- **`format.ts`** is presentation-only string formatting (turn badges, session labels, timestamps) with no state and no side effects. Every function that produces user-visible text takes the active-locale `t(key, params?)` as an explicit parameter, rather than importing a dictionary directly — that is what keeps it framework-free and directly unit-testable (a test passes a small fake `t`) while still being locale-aware.
- **`strings.ts`** holds two flat, bilingual (`zh`/`en`) locale dictionaries — every user-visible string in the tab, plus every interpolated message, addressed by one key (`toolbar.clear`, `notice.truncatedBody`, …) with `{name}`-style placeholders. This is exactly the shape DSH's own `@deepseek-ai/dsh-client-locale` service expects: `entry.ts` registers both dictionaries under this plugin's own namespace via `ctx.locale.register`, and every other client module receives the bound `t` as a parameter instead of importing this file's dictionaries directly — so the tab's language follows DSH's own language setting rather than always rendering one fixed locale.
- **`view-model.ts`** is the pure list/detail logic behind the tab: merging the two racing list reads (`mergeSummaryPages`), deciding when the session filter must be abandoned (`shouldFallBackToAllSessions`), grouping rows by turn (`groupRows`), and choosing which body each tab renders (`selectRequestBody`/`selectResponseBody`/`describeBodyNotice`). None of it touches React, `window`, or `document`. It exists because this logic is the subtlest in the client half — the merge is what stops a late poll from erasing already-loaded history — and while it lived inside the component it was covered by nothing at all.
- **`api-client.ts`** is the thin `fetch` wrapper around this plugin's own `/llm-wire-trace/*` routes (mirrors `routes.ts` on the host side) plus two unrelated browser utilities (`download`, `copy`).
- **`json-view.ts`** is the one presentational React component, parameterized over a minimal `ReactLike` interface rather than importing `react` — because, per the classic-script constraint explained in `plugin-development.md`, `react` only exists at runtime through the factory's `require`, never as a static import.
- **`wire-trace-view.ts`** is the only file that owns state (`React.useState`/`useEffect`) and orchestrates all of the above into the tab's actual behavior — polling, filtering, the merged/raw toggle. It is intentionally the largest file in `src/client/`, because state orchestration does not decompose as cleanly as pure computation does; everything that *could* be extracted as pure logic already has been (into `view-model.ts`/`json-model.ts`/`format.ts`/`strings.ts`/`sse-merge/`), so this file is left holding only the parts that are genuinely about "what should this component do right now." What remains is overwhelmingly `React.createElement` trees — which is why extracting the logic shrank the file far less than it grew the tested surface. Its `Clear` button is scoped to the current session (`apiPost('clear', { sessionId })`).
- **`plugin-manage-view.ts`** is this plugin's content inside DSH's own Plugins page (the `plugins.bundle.config` Slot, keyed by package name in `entry.ts`) — local cache stats, a page-wide "clear all history" button (`apiPost('clear', {})`, no `sessionId`), and a form over every `PluginConfig` field, read from and written through the `config` route. It reuses `wire-trace-view.ts`'s `ViewContext`/`WireTraceReact` types and `api-client.ts`/`strings.ts`, rather than duplicating them, but is its own component with its own state — the two Slots (`conversation.view`, `plugins.bundle.config`) are unrelated except for sharing this plugin's identity. Its `CONFIG_FIELDS` list duplicates `config.ts`'s field set (same cross-boundary-duplication reason as `ROUTE`/`PACKAGE_NAME` in `constants.ts`), pinned by a test that imports both sides. It also renders the coverage counters' capture-gap warning (`manage.captureWarning`) whenever a provider reports untraced model calls — the one place `coverage.ts`'s health signal reaches a human instead of a log line. When the `config` route cannot be read at all (most commonly a host still running an older build of this plugin, from before a plugin update without a `dsh web` restart), it falls back to `CONFIG_FIELD_DEFAULTS` — each field's documented default, also pinned against `config.ts`'s `resolveEffectiveConfig` by the same test — rather than surfacing the fetch failure as an error with nothing the reader can do about it.
- **`entry.ts`** is the only file that touches `window.__ModuleLoader__`, Cordis Slot registration, or the `ctx.locale` service — the same "keep the framework glue in exactly one file" pattern as the host's `index.ts`. It is where `strings.ts`'s `zh`/`en` dictionaries get registered and bound to the `t` function every other client module receives as a plain parameter.

## Why this shape, generally

Two rules run through every split above, and are worth carrying into a new plugin regardless of what that plugin does:

1. **Framework glue lives in exactly one file per half** (`src/host/index.ts`, `src/client/entry.ts`). Every other module is plain TypeScript, testable by importing it and calling its functions — no Cordis context, no DOM, no running DSH process required. This is what made it possible to verify this plugin's entire rewrite (see the development history in this repository) by piping real captured records through the compiled modules directly in `node -e "..."`, rather than only being able to check it by clicking through a live UI. `test/` makes this concrete rather than theoretical: it mirrors the same boundary one file at a time (`test/host/**` against `src/host/**`, `test/client/**` against the DOM-free `src/client/*.ts`), and every module on that side of the line has a real `node:test` file exercising it — see [`AGENTS.md`](../AGENTS.md) for the exact rule and which modules are deliberately excluded.
2. **A concern gets its own file the moment it would otherwise force two unrelated things to change together.** `persistence/naming.ts` versus `archive.ts` is the clearest example: a filename-format change and a retention-sweep change used to require touching the same function; splitting them means either can change without the other's tests even re-running the wrong assertions.

## Where to go next

- [`plugin-development.md`](plugin-development.md) — the DSH/Cordis side of this same codebase (services, events, Slots, why the `fetch` patch requires an installed package).
- [`AGENTS.md`](../AGENTS.md) — the self-verification flow a change must complete, and the module boundaries/hard constraints it enforces.
- [Main README, "Repository layout"](../README.md#repository-layout) — the flat file-by-file listing this document's diagrams summarize.
- [Main README, "How it works"](../README.md#how-it-works) — the runtime behavior these modules implement, independent of how they're split.
