diff --git a/.gitignore b/.gitignore index c0f65b2..8c33bb8 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,3 @@ coverage/ # Agent workspace: session notes and scratch data, not source. .workbuddy-ai/ - -# Agent instructions: private notes, never published. -AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..72f9a1d --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,885 @@ +--- +tags: + - dsh + - plugin + - opencode + - cordis +status: note +aliases: + - dsh-opencode + - dsh-opencode-patch +--- + +# `dsh-opencode-patch` — OpenCode on DeepSeek Harness + +> [!info] Summary DSH host plugin (`dsh-opencode-patch` on npm, published with the `@viztor/dsh-opencode-patch` and `@viztor/dsh-opencode` scoped aliases from the same tree; repo `viztor/dsh-opencode-patch`) that keeps OpenCode Zen free-tier models working inside DeepSeek Harness: deterministic `ses_…` session affinity, gateway origin-header restoration, `read`/`bash` tool-schema fallback, a models.dev-backed model catalog with SWR refresh, and a composer meter showing Go quota, Zen overflow, session spend and the active model's rate. Standards reference: [[OBSIDIAN]] (`~/dev/OBSIDIAN.md`). + +## HARD RULE: a local path never leaves the machine + +An absolute path from a development machine (`/Users//…`, `/home//…`, `C:\Users\…`) must never appear anywhere a third party can read: documentation, code comments, examples, test fixtures, commit messages, issue text, or a published artifact. It leaks a username, an operating system and a directory layout, and it is worthless to the reader anyway. + +- **In prose and examples, use a neutral placeholder** — `/home/you/projects/my-app`, `~/dev/my-app`, or just the folder name. +- **Before publishing, grep the artifact for the home prefix** (`/Users/`, `/home/`, `C:\Users\`), not just the source tree. +- **Generated outputs count.** Coverage reports, source maps and logs carry absolute paths; they must be gitignored, never committed. +- **The check belongs before the commit.** Once it ships, removing it means rewriting content AND the history that carried it, plus force-pushing over every tag and invalidating published provenance links. + +## How it works + +1. **Turn scope** — `apply()` hooks `llm/stream` for configured providers, derives a stable `ses_<12hex><14base62>` ID per DSH session (`openCodeSessionIdFor`, SHA-256), and carries it in `AsyncLocalStorage` across the streamed turn (`withStore`). +2. **Fetch patch** — `patchFetch()` intercepts only OpenCode traffic (`isOpenCodeRequest`: `opencode.ai/zen` URL or matching provider in turn state). It always sets `x-opencode-session`, optionally restores `User-Agent` / `x-opencode-client` / `x-opencode-project`, and injects fallback `read`+`bash` schemas into free-tier `/responses` bodies. Non-OpenCode requests return via the original fetch untouched. +3. **Settings UI** — `src/settings-page.tsx` builds `lib/client.js`, contributing the OpenCode Patch card under DSH Settings → Plugins: **8 fields in 3 sections** (Gateway Requests / Models & Free Tier / Quota Meter) — the behaviour toggles plus the `keySource` credential policy. The other 8 schema knobs are **config-only** (see `CONFIG_ONLY_FIELDS`), and the row config (`cordis.patch.yml`) is their reference. + +## Package vs component (do not conflate) + +- **npm package** `dsh-opencode-patch`: the installable unit (host `main` + `lib/client.js`); the `@viztor/dsh-opencode-patch` and `@viztor/dsh-opencode` scoped aliases are published from the same tree. The host resolves a row to `node_modules/`, so the row's `name` must equal `dsh-opencode-patch` exactly. +- **cordis row**: one _instance_ of the package. `id` (`dsh-opencode-patch`) is the instance id and doubles as the settings namespace the client card binds (with a fallback to the legacy namespace `dsh-opencode`). One package can back N rows with different ids/configs — the card binds the default `dsh-opencode-patch` row (single-row assumption; a second row would need its own NS binding). +- **plugin `name` export** (`src/index.ts`): the component identity (log lines, service scoping). Matches the default row id by convention only. +- **client slot key** (`PKG` in `src/settings-page.tsx`): bundle-level page key, always the npm package name. + +## Why the gateway markers and the free-tier marker exist + +Two knobs look redundant until you know what they cover — both are config-only for that reason. A third, `usageProviderMarkers`, _was_ redundant and has been removed: see below. + +**`gatewayUrls`** is consulted in exactly one place (`isOpenCodeRequest`) and is checked _before_ the turn state. Two kinds of request need it: one that arrives with **no active turn state** (the patch runs deep in the adapter path, and not every gateway call happens inside a turn) and one whose **provider id we do not list** (a custom relay, mirror, or self-hosted gateway). The provider list cannot cover either, because it is matched against turn state that may not exist. + +`isModelsListingUrl` used to be the same gap and no longer is: it now takes the configured markers exactly like `isOpenCodeRequest` does (`gatewayUrls` with a default), and `patchFetch` — which already holds the config — passes them. The reason that gap was worth closing is that it was **invisible**: a relay matched every other OpenCode rule, so it got the headers, the session id and the key injection, and then no catalog — and a shorter model list reads as "fewer models", not as a broken rule. The same class of bug as the missing `Tooltip` (works, does nothing) and the stale bundle (works, says the old thing). `isGoModelsListingUrl` forwards the markers too, because the Go plane is a property of the PATH (`/go/v1`), never of the host — which is exactly why the host had to come out of the predicate. + +**`freeModelMarker`** exists because **the gateway really does treat free tier differently**. `tool-fallback.ts`: "The OpenCode Zen gateway rejects free-tier `/responses` bodies that lack `read` and `bash` in `tools`, while DSH deliberately does not send them." The plugin rewrites the body to carry both schemas — and only when they are genuinely missing, so a body that already declares them passes through byte-identical. The marker (default `"free"`, `"*"` for every model) is how we detect "free tier", because a model row carries **no capability flag** — the only signal is the model id. It is config-only because `"free"` tracks the vendor's ids and `"*"` is the escape hatch if the rule ever widens to paid models. + +### The meter's registration: declare services with `ctx.inject` + +The composer-dock meter depends on two cross-plugin services: `slots` (to register the entry) and `modelDirectories` (to know the active provider). **They must be declared with `ctx.inject([...], scope => …)`**, which is the host's own idiom — `ui-model-selection` does exactly this for the same pair: + +```ts +ctx.inject(['slots', 'modelDirectories'], (scope: ClientContext) => { … }) +``` + +Reading `ctx.modelDirectories` straight off the **root** context is not guaranteed, and because every access in that path is optional (`?.`) the failure is **silent**: `directoryFor` never resolved, the injector returned `null`, and the meter simply never mounted — with no error anywhere. That is how it shipped broken. `settings-page.tsx` now registers through `ctx.inject` and falls back to the root context only when the assembly has no `inject` at all. A regression test pins it: a root context carrying _neither_ service must still produce a working injector, because both arrive on the injected scope. + +General rule: when reaching for another plugin's service, declare it. An optional read off the root context turns a wiring mistake into a missing feature. + +It gated _whether the meter renders_ for the active provider, and its default was `["opencode-go", "opencode"]` — the same list as `providers`, reversed. They are the same set by construction: a route we do not claim carries no OpenCode headers, so it has no quota to report either. The meter now reads **`providers`** (client-side, from the served settings snapshot, falling back to `DEFAULT_PROVIDERS`), and the pill's prop is named `meterProviders` so the source is obvious. + +`DEFAULT_PROVIDERS` therefore moved to `config-values.ts` — the client bundle needs the default and may not import `config.ts` (schemastery). This is the same pattern as `usageKeyEnv`: a row setting that asked the user to restate a decision the composition already owns. Both are gone; prefer removing such a knob over documenting it. + +### The meter's trigger: the `Tooltip` wraps the BUTTON + +Hovering explains, clicking opens — and the tooltip is what "explains" now, because `3313094` removed hover-to-open when the popover was aligned with the host's own language. The wiring looks correct either way, which is the problem: **the host `Tooltip` clones its child and hands the clone the hover handlers and the anchor ref**, so wrapping our _component_ attaches them to a component that ignores unknown props and the tooltip never appears. Hovering the pill did nothing at all for one release, and nothing in the tree said why. + +It sits inside `UsageTrigger`, around the `