From 86ac89cff1e64cb2a6ae74f26b6334539f53be76 Mon Sep 17 00:00:00 2001 From: lizhixuan Date: Mon, 28 Sep 2026 22:53:55 +0800 Subject: [PATCH 01/12] docs: design spec for the dsh 0.1.7 port (0.2.0) --- .../specs/2026-09-28-dsh-017-port-design.md | 145 ++++++++++++++++++ 1 file changed, 145 insertions(+) create mode 100644 docs/superpowers/specs/2026-09-28-dsh-017-port-design.md diff --git a/docs/superpowers/specs/2026-09-28-dsh-017-port-design.md b/docs/superpowers/specs/2026-09-28-dsh-017-port-design.md new file mode 100644 index 0000000..83d3b60 --- /dev/null +++ b/docs/superpowers/specs/2026-09-28-dsh-017-port-design.md @@ -0,0 +1,145 @@ +# @refkit/dsh-plugin 0.2.0 — port to DeepSeek Harness 0.1.7 (design spec) + +Status: approved in conversation 2026-09-28. Amends `2026-09-20-dsh-plugin-design.md`; sections not +mentioned here stand unchanged. Evidence for every API claim: the research brief +`.superpowers/research/2026-09-28-dsh-017-migration.md` (git-ignored; its sections are cited as §n). + +## Why + +dsh 0.1.7-rc.2 (npm `latest`) removed `ctx.settings.installSection` and the +`@deepseek-ai/dsh-client-runtime` package. The 0.1.0 plugin loads and its tools work, but its +settings never register (the inject child throws), so users cannot enter API keys in the UI. +Live acceptance on 0.1.7-rc.2 also showed 1280 px thumbnails in 150 px tiles and a stale "19 +sources" description. + +## Goals + +1. Full function on dsh 0.1.7-rc.2: tools, card, and a settings page where users enter the 10 API + keys and the tuning fields, applied live without restart. +2. Declare compatibility honestly: dsh `^0.1.7-rc.2` only. +3. Remove the 0.1.5-era scaffolding that 0.1.7 makes unnecessary (the 18 `pnpm.overrides`). + +## Non-goals + +- dsh 0.2.0-rc.x support (published 2026-09-28; identical typings per §5, widen after a live smoke). +- `role('credential-ref')` indirection for keys (follow-up hardening; §Open risks). +- Any change to tool contracts, render text, presentation metadata, or the card's visual design + beyond the phase-aware running state. + +## Decisions + +### P1 — Version and peers (amends D1, D10) + +- Package version `0.2.0`; `PLUGIN_VERSION` follows. +- `peerDependencies`: `@deepseek-ai/dsh-tools` `^0.1.7-rc.2`, `@deepseek-ai/dsh-system-prompt` + `^0.1.7-rc.2`, `@deepseek-ai/dsh-settings` `^0.1.7-rc.2` (optional, used only for `configure`), + `@deepseek-ai/cordis` `^4.0.4`, `@deepseek-ai/schemastery` `^3.18.4`, `react` `^18.2.0` + (optional). dsh checks only `@deepseek-ai/dsh` / `@deepseek-ai/dsh-*` peers, with + `includePrerelease` (§3), so `^0.1.7-rc.2` admits 0.1.7-rc.2 … 0.1.x and refuses 0.1.5/0.1.6 + (where `.volatile()` does not exist) and 0.2.0-rc.x. +- `devDependencies` pinned exactly to the 0.1.7-rc.2 family (§6 list): `dsh-tools`, + `dsh-system-prompt`, `dsh-settings`, `dsh-client-ui-tool`, `dsh-client-ui-conversation`, + `dsh-client-ui-renderer`, `dsh-client-ui-chat`, `dsh-client-ui-slots`, + `dsh-client-ui-plugin-manager`, `dsh-client-ui-settings`, `dsh-client-ui-primitives`, + `dsh-api-remotes` at `0.1.7-rc.2`; `cordis` `~4.0.4`; `cordis-plugin-loader` `~1.0.5` + (type-only, for the `loader/volatile-update` event); `schemastery` `~3.18.4`. + `@deepseek-ai/dsh-client-runtime` is removed. The whole `pnpm.overrides` block is deleted — a + 0.1.7 dev install resolves without it (§6). +- `dsh.client.inject` becomes `['@deepseek-ai/dsh-client-ui-tool', + '@deepseek-ai/dsh-client-ui-conversation', '@deepseek-ai/dsh-client-ui-plugin-manager', + '@deepseek-ai/dsh-client-ui-settings']` (informational in 0.1.7, §4). +- `description`: "… across 23 sources from 19 provider packages and 4 modalities …". +- README "tested against" becomes `@deepseek-ai/dsh` 0.1.7-rc.2. + +### P2 — Live configuration (replaces D4's settings mechanism) + +- Every one of the 18 `Config` fields is declared `.volatile()`; secrets keep `role('secret')`. +- `sources` becomes `z.array(z.union(PROVIDER_IDS)).default([]).volatile()`; the schema rejects an + unknown id with a message that lists the valid ids. `validateConfig` is deleted. Retired ids + must stay in the union as no-ops in future releases (§2). +- Types: `ConfigValues` is the plain value interface (what the old `Config` interface was); + `Config` (type) maps each field to `Volatile<…>` from `@deepseek-ai/cordis`; `Config` (value) is + the schemastery schema. `readConfig(config): ConfigValues` reads every reference with `.get()`. + `resolveConfig(values, env)` is unchanged in behaviour and now takes `ConfigValues`. +- `apply(ctx, config)`: resolve once; register `ctx.on('loader/volatile-update', …)` on the + plugin's own context (the event is delivered to the owning fiber only) to re-resolve and drop + the cached client; the client is still built lazily. The environment is still re-read at every + resolve, so the `REFKIT_*` variables keep working as a fallback under empty settings. +- `ctx.inject(['settings'], child => child.effect(() => child.settings.configure({ auto: false }, + ctx.fiber)))` opts out of any future auto-generated page, because the plugin ships its own (P3). +- An edit to a volatile field never re-applies the plugin; a stored invalid config fails the + fiber at startup (the user fixes `cordis.patch.yml`) — documented in the README. +- User-facing copy that names the settings location reads: "Plugins (sidebar) → + @refkit/dsh-plugin → refkit → Configure" (EN) / 「插件(侧边栏)→ @refkit/dsh-plugin → refkit → + 配置」 (ZH), in tool errors, `cordis.patch.yml` comments and READMEs. + +### P3 — Settings page (new; replaces D4's settings card) + +- The browser half registers keyed slot `plugins.row.config`, key `'@refkit/dsh-plugin#refkit'`, + registrant `'@refkit/dsh-plugin'`, inside the existing `ctx.inject(['slots'], …)` wiring with the + same try/catch guards as the toolview. Component: `RefkitSettingsPage(props: + PluginConfigViewProps)`. +- `view === 'summary'` renders one line: "License-aware reference search · N keys set". +- `view === 'page'` with `form?.state.status === 'ready'` renders two sections: + - **API keys**: one row per `KEY_FIELDS` entry — label (provider names it enables), a + set/not-set marker, a password input (never pre-filled; secrets are redacted on the wire), + **Save** (writes `{ op: 'set', path: [field], value }`) and **Clear** (`{ op: 'unset', path: + [field] }`). Markers come from `ctx.configForms.describe().getSnapshot().view?.namespaces + .find(n => n.ns === 'refkit')?.secrets`, reached only through an optional + `ctx.inject(['configForms'], …)` child; without it markers read "unknown". + - **Search**: `sources` as a checkbox list of the 23 provider ids (empty = all enabled), `limit`, + `poolFactor`, `deadlineMs`, `timeoutMs` as numeric inputs with the schema bounds, `rerank` and + `sourceConfidence` as switches, `userAgent` as a text input; one **Save** that writes only the + changed paths with `form.state.revision`; **Reset** unsets them. + - `form.mutate` resolving `false` (refused by the Host) shows an inline error; success shows a + transient "Saved". +- Otherwise (no form, loading, failed fiber) the page shows a short explanatory line and never + throws. +- UI primitives: `Button`, `Input`, `Switch` from `@deepseek-ai/dsh-client-ui-primitives` (a + platform seed module, value import allowed); everything else type-only. +- Pure logic lives in `src/client/settings-model.ts` (DOM-free, unit-tested): building the draft + from `form.state`, diffing a draft into `SettingsPathOp[]`, validating numeric bounds, the + summary text. + +### P4 — Client types and bundle (amends D5, D9) + +- `ClientContext` → `Context` from `@deepseek-ai/cordis`; `ctx.slots` typing from + `@deepseek-ai/dsh-client-ui-renderer/client`; `ToolCallBlock` from + `@deepseek-ai/dsh-client-ui-conversation/client`. +- `RefkitCard` narrows on `props.phase`: `'preparing'` renders a one-line "Preparing refkit + search…", `'start'` renders the existing skeleton grid, `'result'` the existing logic. +- `CLIENT_EXTERNALS` (and the bundle check's allow-list) become exactly the dsh web shell's seed + table: `react`, `react/jsx-runtime`, `react-dom`, `react-dom/client`, `@deepseek-ai/cordis`, + `@deepseek-ai/dsh-client-store`, `@deepseek-ai/dsh-client-ui-slots`, + `@deepseek-ai/dsh-client-ui-primitives`, `@deepseek-ai/dsh-client-ui-dockkit` (§4). + +### P5 — Thumbnail size + +- The registry builds Wikimedia Commons with `thumbWidth: 500` (a standard Wikimedia thumbnail + step), so tiles load a ~500 px image instead of 1280 px. The implementer confirms live that the + API returns a 500-wide `thumburl`. + +### P6 — refkit patch dependency + +- `@refkit/provider-openverse` `^0.5.1` and `@refkit/provider-artic` `^0.4.1` (anonymous Openverse + `page_size` ≤ 20; ARTIC `limit` ≤ 100 — refkitjs/refkit#29). Applied only after both are on npm. + +## Testing + +- Unit: `Config({})` yields references with the `DEFAULTS` values; `Config({ sources: ['unsplsh'] + })` throws naming the valid ids; `readConfig` snapshots; the index test drives a fake context + with `on('loader/volatile-update')` and a `liveConfig` helper whose references read a mutable + object, proving a settings edit swaps the client without re-apply; `configure({ auto: false })` + is registered when a settings service exists; settings-model diff/validation/summary. +- Build: bundle check with the corrected allow-list; `lib/` fresh. +- Live acceptance on dsh 0.1.7-rc.2 (controller, in the Browser pane): the row shows **配置**, + the page renders populated, saving a key flips its marker to "set" without restart and enables + the source on the next search, clearing it flips back, an invalid bound is refused with the + inline error, the card renders across phases, Openverse returns results. + +## Open risks + +- The `plugins.row.config` key format and the row id are documented, not yet observed live (§Open + risks); acceptance verifies them. +- Keys are stored in plaintext in the profile's `cordis.patch.yml` (mode 0600), as with every + volatile secret in 0.1.7; README says so. From e2fa1472b8d3529559a408900ee276cdfe37dce5 Mon Sep 17 00:00:00 2001 From: lizhixuan Date: Mon, 28 Sep 2026 22:55:35 +0800 Subject: [PATCH 02/12] docs(spec): settings page mirrors dsh's own SettingsFormModel pages --- .../specs/2026-09-28-dsh-017-port-design.md | 55 ++++++++++--------- 1 file changed, 30 insertions(+), 25 deletions(-) diff --git a/docs/superpowers/specs/2026-09-28-dsh-017-port-design.md b/docs/superpowers/specs/2026-09-28-dsh-017-port-design.md index 83d3b60..414905d 100644 --- a/docs/superpowers/specs/2026-09-28-dsh-017-port-design.md +++ b/docs/superpowers/specs/2026-09-28-dsh-017-port-design.md @@ -75,31 +75,36 @@ sources" description. ### P3 — Settings page (new; replaces D4's settings card) -- The browser half registers keyed slot `plugins.row.config`, key `'@refkit/dsh-plugin#refkit'`, - registrant `'@refkit/dsh-plugin'`, inside the existing `ctx.inject(['slots'], …)` wiring with the - same try/catch guards as the toolview. Component: `RefkitSettingsPage(props: - PluginConfigViewProps)`. -- `view === 'summary'` renders one line: "License-aware reference search · N keys set". -- `view === 'page'` with `form?.state.status === 'ready'` renders two sections: - - **API keys**: one row per `KEY_FIELDS` entry — label (provider names it enables), a - set/not-set marker, a password input (never pre-filled; secrets are redacted on the wire), - **Save** (writes `{ op: 'set', path: [field], value }`) and **Clear** (`{ op: 'unset', path: - [field] }`). Markers come from `ctx.configForms.describe().getSnapshot().view?.namespaces - .find(n => n.ns === 'refkit')?.secrets`, reached only through an optional - `ctx.inject(['configForms'], …)` child; without it markers read "unknown". - - **Search**: `sources` as a checkbox list of the 23 provider ids (empty = all enabled), `limit`, - `poolFactor`, `deadlineMs`, `timeoutMs` as numeric inputs with the schema bounds, `rerank` and - `sourceConfidence` as switches, `userAgent` as a text input; one **Save** that writes only the - changed paths with `form.state.revision`; **Reset** unsets them. - - `form.mutate` resolving `false` (refused by the Host) shows an inline error; success shows a - transient "Saved". -- Otherwise (no form, loading, failed fiber) the page shows a short explanatory line and never - throws. -- UI primitives: `Button`, `Input`, `Switch` from `@deepseek-ai/dsh-client-ui-primitives` (a - platform seed module, value import allowed); everything else type-only. -- Pure logic lives in `src/client/settings-model.ts` (DOM-free, unit-tested): building the draft - from `form.state`, diffing a draft into `SettingsPathOp[]`, validating numeric bounds, the - summary text. +The page follows the pattern of dsh's own settings pages (`@deepseek-ai/dsh-client-ui-settings-web-search`, +`-shell`, `-agent-loop`, `-subagent`), so it looks and behaves like the rest of the Plugins page. + +- **Where**: keyed slot `plugins.row.config`, key `'@refkit/dsh-plugin#refkit'`, registrant + `'@refkit/dsh-plugin'` — the `refkit` row on the `@refkit/dsh-plugin` bundle page gains a + configure control. Registered from the browser half inside the existing guarded + `ctx.inject(['slots'], …)` wiring, and only while the Host serves namespace `refkit` + (`ctx.configForms.whileServed` when available). +- **State**: a controller built in an optional `ctx.inject(['configForms'], …)` child binds + `configForms.get('refkit')` and stages edits with the shared `SettingsFormModel` exported by + `@deepseek-ai/dsh-client-ui-primitives` (a platform seed module; value imports allowed). Field + specs: `settingsNumberField` for `limit`, `poolFactor`, `deadlineMs`, `timeoutMs`; + `settingsTextField` for `userAgent`; refkit-owned specs (pure, in `src/client/settings-model.ts`) + for the booleans `rerank` / `sourceConfidence` and for `sources` (comma-separated ids, validated + against the 23 provider ids; empty = all). Secret specs for the 10 keys write + `{ op: 'set', path: [field], value: text }` through the same form's `mutate`. +- **Rendering**: `SettingsForm` frame (its own Save, saving, failed and read-only states) with two + groups: **API keys** — one `SettingsSecretField` per key, labelled with the providers it enables, + blank on load, a blank draft keeps the stored key, `configured` from the namespace's secret + presence markers (`configForms.describe()` → `namespaces.find(ns === 'refkit').secrets`), plus a + small **Remove** button beside a configured key that immediately writes `{ op: 'unset', path: + [field] }`; **Search** — `SettingsValueField`s for the numbers, `sources` and `userAgent` (each + with the Overridden badge and Reset that the component provides) and two `Switch`es. +- **Summary view** (`view: 'summary'`): "License-aware reference search · N of 10 keys set". +- **Copy**: all strings in `src/client/copy.ts` (English); schema bounds shown as hints. +- **Degradation**: no configForms service, namespace not served, or fiber failed → the page shows + the form frame's unavailable line; nothing throws into the shell. +- **Pure logic** in `src/client/settings-model.ts` (DOM-free, unit-tested): the boolean and + `sources` field specs (format/parse, invalid ids block the save), the secret spec factory, the + key → providers label table, and the summary text. ### P4 — Client types and bundle (amends D5, D9) From 19d14d3a6ca04721cbf18a1da9678518bbb4a3c4 Mon Sep 17 00:00:00 2001 From: lizhixuan Date: Mon, 28 Sep 2026 22:57:23 +0800 Subject: [PATCH 03/12] docs: implementation plan for the dsh 0.1.7 port --- .../plans/2026-09-28-dsh-017-port.md | 222 ++++++++++++++++++ 1 file changed, 222 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-28-dsh-017-port.md diff --git a/docs/superpowers/plans/2026-09-28-dsh-017-port.md b/docs/superpowers/plans/2026-09-28-dsh-017-port.md new file mode 100644 index 0000000..dbe7b42 --- /dev/null +++ b/docs/superpowers/plans/2026-09-28-dsh-017-port.md @@ -0,0 +1,222 @@ +# @refkit/dsh-plugin 0.2.0 — port to dsh 0.1.7 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make `@refkit/dsh-plugin` fully functional on DeepSeek Harness 0.1.7-rc.2 — live settings with a native-looking settings page, corrected client typings and externals, smaller thumbnails — and release it as 0.2.0. + +**Architecture:** The host half reads its `Config` as dsh Loader `Volatile` references and rebuilds the refkit client on `loader/volatile-update`; validation moves into the schema. The browser half adds a `plugins.row.config` page built from dsh's shared `SettingsFormModel` and settings-form components, next to the existing `tool.call.toolview` card, which becomes phase-aware. + +**Tech Stack:** TypeScript 5.7, dsh 0.1.7-rc.2 package family, `@deepseek-ai/cordis` 4.0.4, `@deepseek-ai/schemastery` 3.18.4, React 18, tsdown 0.22, vitest, pnpm 10.32.1 (`npx -y pnpm@10.32.1`). + +**Spec:** `docs/superpowers/specs/2026-09-28-dsh-017-port-design.md` (P1–P6), amending `docs/superpowers/specs/2026-09-20-dsh-plugin-design.md`. API evidence: the research brief `.superpowers/research/2026-09-28-dsh-017-migration.md` (git-ignored; exists in this checkout) — cited below as §n / "Recommended migration step n". + +## Global Constraints + +- Package version `0.2.0`; `PLUGIN_VERSION = '0.2.0'`. +- DSH peers exactly: `@deepseek-ai/dsh-tools` `^0.1.7-rc.2`, `@deepseek-ai/dsh-system-prompt` `^0.1.7-rc.2`, `@deepseek-ai/dsh-settings` `^0.1.7-rc.2` (optional); plus `@deepseek-ai/cordis` `^4.0.4`, `@deepseek-ai/schemastery` `^3.18.4`, `react` `^18.2.0` (optional). +- No `pnpm.overrides` block. No `@deepseek-ai/dsh-client-runtime` anywhere. +- `CLIENT_EXTERNALS` and the bundle check allow-list are exactly: `react`, `react/jsx-runtime`, `react-dom`, `react-dom/client`, `@deepseek-ai/cordis`, `@deepseek-ai/dsh-client-store`, `@deepseek-ai/dsh-client-ui-slots`, `@deepseek-ai/dsh-client-ui-primitives`, `@deepseek-ai/dsh-client-ui-dockkit`. +- All 18 `Config` fields `.volatile()`; `sources` is `z.array(z.union(PROVIDER_IDS)).default([]).volatile()`; secrets keep `role('secret')`. +- Settings page slot: `plugins.row.config`, key `'@refkit/dsh-plugin#refkit'`, registrant `'@refkit/dsh-plugin'`. +- Settings location copy: EN "Plugins (sidebar) → @refkit/dsh-plugin → refkit → Configure"; ZH 「插件(侧边栏)→ @refkit/dsh-plugin → refkit → 配置」. +- Unchanged: tool names, parameters, output schemas, render text, presentation metadata, env fallback names, defaults and bounds, the 65 s tool timeout backstop, the `tool.call.toolview` key. +- Browser half: value imports from `@deepseek-ai/*` only from the externals list; everything else `import type`. Every wiring step guarded with try/catch + `console.warn`; nothing throws into the shell. +- Canonical values carry no `undefined`-valued properties; secrets never reach any output, log or error. +- Gate before every commit: `npx -y pnpm@10.32.1 typecheck && npx -y pnpm@10.32.1 lint && npx -y pnpm@10.32.1 test && npx -y pnpm@10.32.1 build && node scripts/check-client-bundle.mjs && node scripts/smoke-host.mjs`, pristine output, `git status --porcelain -- lib` empty after the build except what the commit includes. Conventional commits, no attribution trailers. Work in `/Users/xuan/Desktop/testSpace/dsh-plugin` on branch `feat/dsh-017-port`; never `git stash`, never push. + +--- + +### Task 1: dsh 0.1.7 toolchain, live configuration, client type port + +**Files:** +- Modify: `package.json`, `pnpm-lock.yaml`, `tsdown.config.ts`, `scripts/check-client-bundle.mjs`, `scripts/smoke-host.mjs` +- Modify: `src/config.ts`, `src/index.ts`, `src/client/index.tsx`, `src/client/Card.tsx` +- Test: `tests/config.test.ts`, `tests/index.test.ts` +- Regenerate: `lib/**` + +**Interfaces:** +- Produces (src/config.ts): `ConfigValues` (plain value interface — the former `Config` interface, with `sources?: readonly string[]`), `type Config` (`{ readonly [K in keyof ConfigValues]-?: Volatile }`), `const Config` (schemastery schema, all fields `.volatile()`), `readConfig(config: Config): ConfigValues`, `resolveConfig(values: ConfigValues, env?)` (behaviour unchanged). `validateConfig` is deleted. +- Produces (src/index.ts): `applyWith(ctx, config: Config, deps: ApplyDeps): PluginHandles` (unchanged signature shape; `config` is now references), `apply(ctx, config: Config)` (no default argument — the Loader always supplies references). +- Consumed later: Task 2 imports nothing from the host; Task 3 changes copy in `src/config.ts` and `src/tools/search.ts`. + +- [ ] **Step 1: package.json** + + - `version` → `0.2.0`; `description` → `DeepSeek Harness plugin for refkit: license-normalized creative reference search across 23 sources from 19 provider packages and 4 modalities, a strict-deny use-gate, and a web card with license and use-verdict badges (refkit_search / refkit_rights).` + - `peerDependencies` / `peerDependenciesMeta` per the Global Constraints (dsh-settings and react optional; drop dsh-system-prompt from optional only if it was — keep it optional as today). + - `devDependencies`: replace every `@deepseek-ai/*` entry with the 0.1.7 set: `@deepseek-ai/cordis` `~4.0.4`, `@deepseek-ai/cordis-plugin-loader` `~1.0.5`, `@deepseek-ai/schemastery` `~3.18.4`, and exactly `0.1.7-rc.2` for `dsh-tools`, `dsh-system-prompt`, `dsh-settings`, `dsh-client-ui-tool`, `dsh-client-ui-conversation`, `dsh-client-ui-renderer`, `dsh-client-ui-chat`, `dsh-client-ui-slots`, `dsh-client-ui-plugin-manager`, `dsh-client-ui-settings`, `dsh-client-ui-primitives`, `dsh-api-remotes`. Remove `@deepseek-ai/dsh-client-runtime`. Keep the non-dsh devDependencies. + - Delete the whole `"pnpm": { "overrides": … }` block. + - `dsh.client.inject` → `["@deepseek-ai/dsh-client-ui-tool", "@deepseek-ai/dsh-client-ui-conversation", "@deepseek-ai/dsh-client-ui-plugin-manager", "@deepseek-ai/dsh-client-ui-settings"]`. + - Run `npx -y pnpm@10.32.1 install`; expect success with no `ERR_PNPM_NO_MATCHING_VERSION` (brief §6 probe `a/`). Paste any peer warnings into the report. + +- [ ] **Step 2: externals** + + `tsdown.config.ts`: set `CLIENT_EXTERNALS` to the Global Constraints list (as a `readonly string[]`). `scripts/check-client-bundle.mjs`: its `allowed` set becomes the `@deepseek-ai/*` subset of that list (`cordis`, `dsh-client-store`, `dsh-client-ui-slots`, `dsh-client-ui-primitives`, `dsh-client-ui-dockkit`). Add `@deepseek-ai/cordis-plugin-loader` and `@deepseek-ai/dsh-settings` to `HOST_EXTERNAL` defensively (type-only today). + +- [ ] **Step 3: failing config tests** + + In `tests/config.test.ts` (keep the existing registry/env/clamp/buildClient tests, adapting any `Config` interface usage to `ConfigValues`): + - Replace the `validateConfig` describe with: `expect(() => Config({ sources: ['unsplsh'] })).toThrow(/unsplsh/)` and the thrown message also matches `/wikimedia-commons/` (the union lists the valid ids); `expect(() => Config({ sources: ['met', 'unsplash'] })).not.toThrow()`. + - Add: `const values = readConfig(Config({}))` → `toMatchObject({ sources: [], limit: 12, poolFactor: 2, deadlineMs: 15000, timeoutMs: 10000, rerank: true, sourceConfidence: true })` and every `KEY_FIELDS` entry is `undefined`. + - Add: every field of `Config({})` exposes a `.get` function (`Object.values(Config({})).every(r => typeof (r as { get?: unknown }).get === 'function')`). + - Keep the secret-role test (every `KEY_FIELDS` field `role: 'secret'`, no other field) — adapt the `toJSON()` walk if volatile changes the node shape; add an assertion that every one of the 18 fields carries `meta.volatile === true` in the same walk. + - Keep the bounds tests (`Config({ limit: 0 })` etc. throw). + Run `npx -y pnpm@10.32.1 vitest run tests/config.test.ts` → expect FAIL (no `readConfig`, no volatile). + +- [ ] **Step 4: src/config.ts** + + Follow brief "Recommended migration step 2": + ```ts + import type { Volatile } from '@deepseek-ai/cordis' + /** Plain settings values: what each reference's `.get()` returns. */ + export interface ConfigValues { /* the 18 fields of today's Config interface, sources?: readonly string[] */ } + /** Config as `apply` receives it: every field a live Loader reference. */ + export type Config = { readonly [K in keyof ConfigValues]-?: Volatile } + const secret = (text: string) => z.string().role('secret').description(text).volatile() + // declared AFTER PROVIDER_REGISTRY / PROVIDER_IDS, because sources needs the id union + export const Config = z.object({ + unsplashAccessKey: secret('…same text as today…'), /* …the other nine… */ + sources: z.array(z.union(PROVIDER_IDS as unknown as [string, ...string[]])).default([]).description('…').volatile(), + limit: z.number().step(1).min(1).max(30).default(DEFAULTS.limit).description('…').volatile(), + /* poolFactor, deadlineMs (max MAX_DEADLINE_MS), timeoutMs, rerank, sourceConfidence: today's specs + .volatile() */ + userAgent: z.string().description('…').volatile(), + }) + const _schemaCheck: Config = {} as ReturnType; void _schemaCheck + /** One consistent snapshot; the Loader commits every reference before it emits the event. */ + export function readConfig(config: Config): ConfigValues { + return Object.fromEntries(Object.entries(config).map(([k, ref]) => [k, (ref as Volatile).get()])) as ConfigValues + } + ``` + `resolveConfig(values: ConfigValues, env = process.env)` keeps today's body (`Array.isArray(values.sources)` still works on a frozen array). Delete `validateConfig` and its export from `src/index.ts`. If `z.union` needs a mutable tuple type, cast as shown; keep `PROVIDER_IDS` itself `readonly string[]`. + Run the config tests → PASS. + +- [ ] **Step 5: failing index tests** + + Rewrite `tests/index.test.ts` around the new model (brief step 8): + - `fakeContext(services)` gains `on(name, fn)` storing listeners in a map and an `emit(name, ...args)` helper; `inject(deps, cb)` as today, with an optional `settings` service shaped `{ configure: (policy, owner) => () => void }` and the scope passed to `cb` having `effect(fn)` that calls `fn()` immediately. + - `liveConfig(get: () => ConfigValues): Config` builds an object whose 18 fields are `{ get: () => get()[field] }`. + - Tests: (1) `name`/`inject` unchanged; (2) both tools register with no optional services; (3) lazy client + live rebuild: `applyWith(ctx, liveConfig(() => current), { createClient: spy, env: {} })`, `getClient()` twice builds once without pexels, then set `current = { pexelsApiKey: 'k', limit: 7 }`, `emit('loader/volatile-update', [['pexelsApiKey'], ['limit']])`, `getConfig().limit === 7`, next `getClient()` builds a second client containing `pexels` and `pexels-video`, and `apply`-style registration happened exactly once (tools registered once); (4) with a `settings` service present, `configure` is called once with `{ auto: false }`; (5) system-prompt section unchanged (name `tool:refkit`, order 115, GUIDANCE); (6) GUIDANCE mentions `refkit_search`, `refkit_rights`, `intent`. + Run → FAIL. + +- [ ] **Step 6: src/index.ts** + + Brief step 3, plus the `configure` opt-out (spec P2): + ```ts + import type {} from '@deepseek-ai/cordis-plugin-loader' // 'loader/volatile-update' event typing + import type {} from '@deepseek-ai/dsh-settings' // ctx.settings.configure typing + export function applyWith(ctx: Context, config: Config, deps: ApplyDeps): PluginHandles { + const env = deps.env ?? process.env + let resolved = resolveConfig(readConfig(config), env) + let client: RefkitClient | null = null + ctx.on('loader/volatile-update', () => { resolved = resolveConfig(readConfig(config), env); client = null }) + const getConfig = (): ResolvedConfig => resolved + const getClient = (): RefkitClient => (client ??= buildClient(resolved, deps.createClient)) + ctx.inject(['settings'], (child) => { child.effect(() => child.settings.configure({ auto: false }, ctx.fiber)) }) + ctx.inject(['systemPrompt'], (scope) => { scope.systemPrompt.section({ name: 'tool:refkit', order: 115, text: GUIDANCE }) }) + ctx.tools.register(createSearchTool({ client: getClient, config: getConfig })) + ctx.tools.register(createRightsTool()) + return { getClient, getConfig } + } + export function apply(ctx: Context, config: Config): void { applyWith(ctx, config, { createClient: createRefkit }) } + ``` + Update the module doc comment (no "settings section" wording). Re-exports: `Config`, `readConfig`, types `Config as RefkitPluginConfig`, `ConfigValues`, `ResolvedConfig`; drop `validateConfig`. If the fake `ctx.fiber` is undefined in tests, `configure` receives undefined — acceptable; assert only the policy argument. + Run the index tests → PASS. + +- [ ] **Step 7: client type port** + + `src/client/index.tsx`: `import type { Context as ClientContext } from '@deepseek-ai/cordis'`; `import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'` (ctx.slots); keep the ui-tool and ui-conversation type imports; wiring unchanged. + `src/client/Card.tsx`: `import type { ToolCallBlock } from '@deepseek-ai/dsh-client-ui-conversation/client'`; `RefkitCard(props: ToolCallOwnerProps)` returns `` when `props.phase !== 'result'`, otherwise `const block = props.block` and the existing settled logic; keep `textOf`/`outcomeOf` working on the result node type (their `'kind' in block` guards may stay or be simplified; typecheck is the arbiter). + +- [ ] **Step 8: smoke script** + + `scripts/smoke-host.mjs` live part: give the fake `ctx` an `on: () => () => {}` stub and call `apply(ctx, Config({}))`. The surface check stays as is. + +- [ ] **Step 9: gate, build, commit** + + Run the full gate from Global Constraints. `lib/` regenerates (types, index.js, client.js). Commit: `feat!: port to dsh 0.1.7 — volatile live config, schema-level validation, client type imports, corrected externals`. + +--- + +### Task 2: settings page on the Plugins page + +**Files:** +- Create: `src/client/settings-model.ts`, `src/client/SettingsPage.tsx` +- Modify: `src/client/index.tsx`, `src/client/copy.ts`, `src/client/styles.ts` (only if a layout class is needed) +- Test: `tests/settings-model.test.ts` +- Regenerate: `lib/**` + +**Interfaces:** +- Consumes: `KEY_FIELDS` and `PROVIDER_REGISTRY` data — NOT by importing `src/config.ts` into the client bundle (it pulls 19 provider packages). Instead `settings-model.ts` holds a small static table `KEY_LABELS: Record` (field → providers it enables, e.g. `pexelsApiKey: 'Pexels (images, video)'`) and `SOURCE_IDS: readonly string[]` (the 23 ids); a test asserts both match `KEY_FIELDS` / `PROVIDER_IDS` from `src/config.ts` so they cannot drift. +- Produces: `settingsBooleanField(field)`, `settingsSourcesField(field, ids)` (both `SettingsFieldSpec`-compatible: `{ field, format(value) → string, parse(text) → SettingsFieldWrite | undefined }`), `secretSpec(field, write)`, `summaryText(configuredCount, total)`, `KEY_LABELS`, `SOURCE_IDS`; `RefkitSettingsPage` component; `createSettingsController(configForms)`. + +Reference implementation to mirror: `@deepseek-ai/dsh-client-ui-settings-web-search` (installed at `$(npm root -g)/@deepseek-ai/dsh/node_modules/@deepseek-ai/dsh-client-ui-settings-web-search/lib/client.js`; source at `https://github.com/deepseek-ai/deepseek-harness/tree/dsh-v0.1.7-rc.2/packages/client/ui-settings-web-search/src`). Read it first. Also read the typings in `node_modules/@deepseek-ai/dsh-client-ui-primitives/lib/types/settings-form/*.d.ts`, `node_modules/@deepseek-ai/dsh-client-ui-settings/lib/types/client/config-form*.d.ts`, and `node_modules/@deepseek-ai/dsh-client-ui-plugin-manager/lib/types/client/slot-contract.d.ts`. + +- [ ] **Step 1: failing settings-model tests** (`tests/settings-model.test.ts`) + - `settingsBooleanField('rerank')`: `format(true) === 'on'`, `format(false) === 'off'`, `format(undefined) === ''`; `parse('on') → { kind: 'set', value: true }`, `parse('off') → { kind: 'set', value: false }`, `parse('') → { kind: 'clear' }`, `parse('maybe') → undefined`. + - `settingsSourcesField('sources', SOURCE_IDS)`: `format(['met','artic']) === 'met, artic'`, `format([]) === ''`; `parse('met, artic') → { kind: 'set', value: ['met','artic'] }`; `parse(' met ,, artic ') → same`; `parse('') → { kind: 'clear' }`; `parse('met, unsplsh') → undefined`; duplicates collapse (`parse('met, met')` → `['met']`). + - `secretSpec('pexelsApiKey', write)`: `.field === 'pexelsApiKey'`; `.write('k')` calls `write` with `[{ op: 'set', path: ['pexelsApiKey'], value: 'k' }]` and resolves to its boolean. + - `summaryText(3, 10) === 'License-aware reference search · 3 of 10 keys set'`. + - `Object.keys(KEY_LABELS).sort()` equals `[...KEY_FIELDS].sort()`; `[...SOURCE_IDS].sort()` equals `[...PROVIDER_IDS].sort()` (import these two from `../src/config.ts` in the test only). + Run → FAIL. + +- [ ] **Step 2: src/client/settings-model.ts** — implement the above (DOM-free, no `@deepseek-ai/*` value imports; import `SettingsFieldSpec`/`SettingsFieldWrite`/`SettingsSecretSpec`/`SettingsFormPathOp` types from `@deepseek-ai/dsh-client-ui-primitives` with `import type`). Run → PASS. + +- [ ] **Step 3: controller + page** + + - Controller (in `SettingsPage.tsx` or a sibling `settings-controller.ts`): given `configForms` (the injected service), `const form = configForms.get('refkit')` (confirm the accessor name from the typings/reference), `new SettingsFormModel(form, [settingsNumberField('limit'), settingsNumberField('poolFactor'), settingsNumberField('deadlineMs'), settingsNumberField('timeoutMs'), settingsTextField('userAgent'), settingsBooleanField('rerank'), settingsBooleanField('sourceConfidence'), settingsSourcesField('sources', SOURCE_IDS)], KEY_FIELDS_CLIENT.map(f => secretSpec(f, ops => form.mutate(ops))))`, where `KEY_FIELDS_CLIENT = Object.keys(KEY_LABELS)`. Secret presence: read `configForms.describe().getSnapshot().view?.namespaces.find(n => n.ns === 'refkit')?.secrets` and expose `configured(field)` (`secrets.find(s => s.path[0] === field)?.set === true`); re-read on the describe store's changes. `remove(field)` performs `form.mutate([{ op: 'unset', path: [field] }])` immediately. + - Page `RefkitSettingsPage({ view }: PluginConfigViewProps)`: `view === 'summary'` → `summaryText(configuredCount, 10)`; `view === 'page'` → `SettingsForm` (labels from `COPY.settings`) containing an "API keys" group of 10 `SettingsSecretField`s (label `KEY_LABELS[field]`, hint the env names from a static table `KEY_ENV_HINT` in settings-model — e.g. `Env: REFKIT_PEXELS_KEY / PEXELS_KEY` — asserted equal to `KEY_ENV` in the test, `configured`, `stateLabel` "Set" / "Not set", and a `Button variant="ghost" size="sm"` "Remove" beside configured keys) and a "Search" group of `SettingsValueField`s (numbers with `numeric`, hints showing bounds; `sources` with placeholder "empty = all enabled sources" and `help` listing the ids; `userAgent`) plus two `Switch`es wired through `actions.edit(field, next ? 'on' : 'off')`. Read form state through the model's `bind(...)` store with `useSyncExternalStore`. + - If the controller cannot be created (no `configForms`), the page renders `COPY.settings.unavailable` inside a plain div. + +- [ ] **Step 4: registration** (`src/client/index.tsx`) + + Inside the existing `ctx.inject(['slots'], scope => …)`, add a second guarded registration: `scope.slots.inject('plugins.row.config', () => { try { return scope.slots.register({ name: 'plugins.row.config', key: '@refkit/dsh-plugin#refkit', registrant: '@refkit/dsh-plugin' }, RefkitSettingsPage) } catch (e) { console.warn('[refkit] settings page registration failed', e); return () => {} } })`. Create the controller in an optional `ctx.inject(['configForms'], child => …)` child (never a required top-level inject — brief §4 "Client inject"), store it where the page reads it (module-level holder set/cleared by the child's effect), and dispose it on unload. Prefer `configForms.whileServed('refkit', …)` for registering the page if the service offers it (as the web-search page does); otherwise register unconditionally and let the page show the unavailable line. + +- [ ] **Step 5: gate, build, commit** + + Full gate. `node scripts/check-client-bundle.mjs` must still pass — the only new runtime import is `@deepseek-ai/dsh-client-ui-primitives` (seed). Commit: `feat(client): settings page on the Plugins page — API keys and search tuning, live`. + +--- + +### Task 3: phase-aware card, smaller thumbnails, copy and docs + +**Files:** +- Modify: `src/client/Card.tsx`, `src/client/copy.ts`, `src/config.ts`, `src/tools/search.ts`, `cordis.patch.yml`, `README.md`, `README.zh.md`, `docs/superpowers/specs/2026-09-20-dsh-plugin-design.md` (a one-line pointer at the top to the port spec) +- Test: `tests/search.test.ts`, `tests/config.test.ts` +- Regenerate: `lib/**` + +- [ ] **Step 1: card phases** — `props.phase === 'preparing'` renders a one-line `
{COPY.preparing}
` with `COPY.preparing = 'Preparing refkit search…'`; `'start'` keeps `RunningGrid`. + +- [ ] **Step 2: thumbnails** — first confirm live: `curl -s "https://commons.wikimedia.org/w/api.php?action=query&format=json&generator=search&gsrsearch=lion&gsrnamespace=6&gsrlimit=2&prop=imageinfo&iiprop=url&iiurlwidth=500"` and check the returned `thumburl` width is 500 (paste the evidence). Then the registry entry becomes `make: () => wikimediaCommons({ thumbWidth: 500 })`; add a config test that the built provider… (if the provider does not expose its config, assert through a `vi.mock` spy on `wikimediaCommons` as the museum-cap tests do) receives `{ thumbWidth: 500 }`. + +- [ ] **Step 3: copy** — replace every "Settings -> Plugins -> refkit" string with the Global Constraints EN location (in `src/config.ts` `buildClient` error, `src/tools/search.ts` description and error hints, `src/client/copy.ts` if present); update the tests that match the old text (`tests/search.test.ts`, `tests/config.test.ts`) to match `/Plugins \(sidebar\) → @refkit\/dsh-plugin → refkit → Configure/`. `cordis.patch.yml` comments point to the same location. + +- [ ] **Step 4: README.md / README.zh.md** + - Install: unchanged commands; "Tested against `@deepseek-ai/dsh` 0.1.7-rc.2. Requires dsh ≥ 0.1.7-rc.2 (dsh refuses to load it on older releases)." + - Configuration: keys and tuning are set on **Plugins (sidebar) → @refkit/dsh-plugin → refkit → Configure**; changes apply on the next call without restart; keys are stored in the profile's `cordis.patch.yml` (file mode 0600, plaintext) — the `REFKIT_*` environment variables remain a fallback for a key left empty; an invalid hand-edited `cordis.patch.yml` stops the plugin at startup until fixed. + - Keep every table; fix the description sentence to "23 sources from 19 provider packages". + - ZH mirrors EN with the ZH location string. + +- [ ] **Step 5: gate, build, commit** — `feat: phase-aware card, 500px Wikimedia thumbnails, settings location copy and docs for dsh 0.1.7`. + +--- + +### Task 4: refkit patch dependencies (execute only after npm publish) + +**Precondition:** `npm view @refkit/provider-openverse version` prints `0.5.1` and `npm view @refkit/provider-artic version` prints `0.4.1`. If not, report `BLOCKED` naming refkitjs/refkit's failed Release run (expired `NPM_TOKEN`); do not use tarballs or `file:` specs. + +- [ ] **Step 1:** `package.json` dependencies `@refkit/provider-openverse` `^0.5.1`, `@refkit/provider-artic` `^0.4.1`; `npx -y pnpm@10.32.1 install`; confirm the lockfile resolves 0.5.1 / 0.4.1. +- [ ] **Step 2:** live check (network): a one-off `node` script in the scratch area (not committed) that runs `runSearch({ query: 'neon street night' }, deps)` with `resolveConfig({ sources: ['openverse'] }, {})` via the built `lib/index.js` and prints the Openverse status — expect `fulfilled` with results (before the bump it was `failed … 401`). Paste the output. +- [ ] **Step 3:** full gate, commit `fix(deps): refkit provider patches — Openverse anonymous page size, ARTIC limit cap`. + +--- + +## Controller acceptance (after Task 4, before the final review) + +Live on dsh 0.1.7-rc.2 in the Browser pane, with the plugin installed from this branch (`dsh plugin --profile web add file:/Users/xuan/Desktop/testSpace/dsh-plugin`, host restarted): + +1. Plugins → @refkit/dsh-plugin → the `refkit` row shows a configure control; the page renders both groups populated with defaults, "Not set" on all keys. +2. A tuning edit (e.g. `limit` 8) saves, survives a page reload, and applies to the next search without restart (the card header shows 8 references when enough exist). +3. An invalid draft (`limit` 99, `sources` `met, bogus`) blocks save or is refused with the form's failure line; nothing is written. +4. Saving a dummy key for a keyed source flips it to "Set" and the source appears in the card's source chips (it will fail upstream with a bogus key — that is expected and shown as a failed source); **Remove** flips it back. +5. A search with `intent: commercial-product` renders the phase states and the result card; Openverse is fulfilled. +6. Summary line on the bundle page reads "… N of 10 keys set". +Record results in the ledger; any failure re-opens the owning task. From 1c0624e574652b9161b637f978f8db416cc5c73d Mon Sep 17 00:00:00 2001 From: lizhixuan Date: Mon, 28 Sep 2026 23:07:09 +0800 Subject: [PATCH 04/12] =?UTF-8?q?feat!:=20port=20to=20dsh=200.1.7=20?= =?UTF-8?q?=E2=80=94=20volatile=20live=20config,=20schema-level=20validati?= =?UTF-8?q?on,=20client=20type=20imports,=20corrected=20externals?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- eslint.config.mjs | 2 +- lib/client.js | 4 +- lib/client.js.map | 2 +- lib/index.js | 97 +- lib/types/client/Card.d.ts | 2 +- lib/types/client/Card.d.ts.map | 2 +- lib/types/client/index.d.ts | 2 +- lib/types/client/index.d.ts.map | 2 +- lib/types/config.d.ts | 76 +- lib/types/config.d.ts.map | 2 +- lib/types/index.d.ts | 18 +- lib/types/index.d.ts.map | 2 +- package.json | 61 +- pnpm-lock.yaml | 1509 ++++++++++++------------------- scripts/check-client-bundle.mjs | 2 +- scripts/smoke-host.mjs | 3 +- src/client/Card.tsx | 8 +- src/client/index.tsx | 3 +- src/config.ts | 94 +- src/index.ts | 40 +- tests/config.test.ts | 46 +- tests/index.test.ts | 89 +- tsdown.config.ts | 6 +- 23 files changed, 888 insertions(+), 1184 deletions(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index 2f88e06..a371ae5 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,7 +1,7 @@ import tseslint from 'typescript-eslint' export default tseslint.config( - { ignores: ['lib/**', 'node_modules/**', 'docs/**'] }, + { ignores: ['lib/**', 'node_modules/**', 'docs/**', '.superpowers/**'] }, ...tseslint.configs.recommended, { rules: { diff --git a/lib/client.js b/lib/client.js index ae98519..f090a64 100644 --- a/lib/client.js +++ b/lib/client.js @@ -438,10 +438,10 @@ window.__ModuleLoader__.load({ })] })] }); } - /** The slot component: dispatch by block lifecycle, degrade safely. */ + /** The slot component: dispatch by call phase, degrade safely. */ function RefkitCard(props) { + if (props.phase !== "result") return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(RunningGrid, {}); const block = props.block; - if (!("kind" in block)) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(RunningGrid, {}); if (block.isError) { const text = textOf(block); return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", { diff --git a/lib/client.js.map b/lib/client.js.map index e491a92..86e85c2 100644 --- a/lib/client.js.map +++ b/lib/client.js.map @@ -1 +1 @@ -{"version":3,"file":"client.js","names":[],"sources":["../src/core/outcome.ts","../src/client/badges.ts","../src/client/copy.ts","../src/client/styles.ts","../src/client/Card.tsx","../src/client/index.tsx"],"sourcesContent":["/**\n * Canonical result vocabulary shared by the host half (tool output, render,\n * presentation metadata) and the browser half (the card). No runtime\n * dependencies: plain data plus soft parsers, so a malformed or\n * version-drifted payload degrades to text instead of crashing a render.\n * @module @refkit/dsh-plugin/core\n */\n\nexport const MODALITIES = ['image', 'video', 'audio', 'text'] as const\nexport type Modality = (typeof MODALITIES)[number]\n\nexport const DECISIONS = ['allowed', 'allowed-with-attribution', 'denied', 'needs-review'] as const\nexport type Decision = (typeof DECISIONS)[number]\n\nexport const SOURCE_STATUSES = ['fulfilled', 'failed', 'skipped'] as const\nexport type SourceStatusKind = (typeof SOURCE_STATUSES)[number]\n\n/** Lossless JSON. */\nexport type Json = string | number | boolean | null | Json[] | { [key: string]: Json }\n\nexport interface VerdictSummary {\n decision: Decision\n reason: string\n confidence: 'high' | 'low'\n}\n\n/** One reference as the tool returns it and the card renders it. */\nexport interface RefTile {\n id: string\n modality: Modality\n provider: string\n canonicalUrl: string\n license: string\n title?: string\n kind?: string\n licenseVersion?: string\n author?: string\n thumbnail?: string\n preview?: string\n width?: number\n height?: number\n description?: string\n excerpt?: string\n tags?: string[]\n useVerdict?: VerdictSummary\n attribution?: string\n}\n\nexport interface SourceStatus {\n id: string\n status: SourceStatusKind\n reason?: string\n returned?: number\n}\n\n/** The canonical value of one refkit_search call. */\nexport interface SearchOutcome {\n query: string\n modalities: Modality[]\n intent?: string\n count: number\n references: RefTile[]\n nextCursor?: string\n sources: SourceStatus[]\n warnings: string[]\n note?: string\n /** Core's full SearchMeta, only when the caller passed `explain`. */\n meta?: Json\n}\n\n/** What the card receives: the outcome minus `meta`, descriptions cut, tags dropped. */\nexport type CardOutcome = Omit\n\nconst HTTP = /^https?:\\/\\//i\n\nfunction str(v: unknown): string | undefined {\n return typeof v === 'string' && v.length > 0 ? v : undefined\n}\nfunction num(v: unknown): number | undefined {\n return typeof v === 'number' && Number.isFinite(v) ? v : undefined\n}\n\n/** Shallow copy of `obj` without its undefined-valued own properties (dsh snapshots values as lossless JSON). */\nexport function defined(obj: T): T {\n return Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined)) as T\n}\n\n/** Cut `text` to `max` code points, appending an ellipsis when anything was removed. */\nexport function trunc(text: string, max: number): string {\n const points = Array.from(text)\n if (points.length <= max) return text\n return points.slice(0, Math.max(0, max - 1)).join('') + '…'\n}\n\nfunction narrowVerdict(v: unknown): VerdictSummary | undefined {\n if (typeof v !== 'object' || v === null) return undefined\n const r = v as Record\n if (!DECISIONS.includes(r.decision as Decision)) return undefined\n if (r.confidence !== 'high' && r.confidence !== 'low') return undefined\n return { decision: r.decision as Decision, reason: str(r.reason) ?? '', confidence: r.confidence }\n}\n\n/** Soft-narrow one tile; null for anything unusable. Unknown keys are dropped. */\nexport function narrowTile(value: unknown): RefTile | null {\n if (typeof value !== 'object' || value === null) return null\n const r = value as Record\n const id = str(r.id)\n const provider = str(r.provider)\n const canonicalUrl = str(r.canonicalUrl)\n const license = str(r.license)\n if (id === undefined || provider === undefined || license === undefined) return null\n if (canonicalUrl === undefined || !HTTP.test(canonicalUrl)) return null\n if (!MODALITIES.includes(r.modality as Modality)) return null\n const tile: RefTile = { id, modality: r.modality as Modality, provider, canonicalUrl, license }\n for (const key of ['title', 'kind', 'licenseVersion', 'author', 'description', 'excerpt', 'attribution'] as const) {\n const s = str(r[key])\n if (s !== undefined) tile[key] = s\n }\n for (const key of ['thumbnail', 'preview'] as const) {\n const s = str(r[key])\n if (s !== undefined && HTTP.test(s)) tile[key] = s\n }\n for (const key of ['width', 'height'] as const) {\n const n = num(r[key])\n if (n !== undefined && n > 0) tile[key] = n\n }\n if (Array.isArray(r.tags)) {\n const tags = r.tags.filter((t): t is string => typeof t === 'string' && t.length > 0)\n if (tags.length > 0) tile.tags = tags\n }\n const verdict = narrowVerdict(r.useVerdict)\n if (verdict !== undefined) tile.useVerdict = verdict\n return tile\n}\n\nfunction narrowSource(v: unknown): SourceStatus | null {\n if (typeof v !== 'object' || v === null) return null\n const r = v as Record\n const id = str(r.id)\n if (id === undefined || !SOURCE_STATUSES.includes(r.status as SourceStatusKind)) return null\n const out: SourceStatus = { id, status: r.status as SourceStatusKind }\n const reason = str(r.reason)\n if (reason !== undefined) out.reason = reason\n const returned = num(r.returned)\n if (returned !== undefined) out.returned = returned\n return out\n}\n\n/** Soft-parse a canonical value or presentation metadata; null for the wrong shape. */\nexport function narrowOutcome(value: unknown): SearchOutcome | null {\n if (typeof value !== 'object' || value === null) return null\n const r = value as Record\n const query = typeof r.query === 'string' ? r.query : undefined\n if (query === undefined || typeof r.count !== 'number') return null\n if (!Array.isArray(r.modalities) || !Array.isArray(r.references) || !Array.isArray(r.sources)) return null\n if (!Array.isArray(r.warnings)) return null\n const modalities = r.modalities.filter((m): m is Modality => MODALITIES.includes(m as Modality))\n const references = r.references.map(narrowTile).filter((t): t is RefTile => t !== null)\n const sources = r.sources.map(narrowSource).filter((s): s is SourceStatus => s !== null)\n const warnings = r.warnings.filter((w): w is string => typeof w === 'string')\n const out: SearchOutcome = { query, modalities, count: r.count, references, sources, warnings }\n const intent = str(r.intent)\n if (intent !== undefined) out.intent = intent\n const nextCursor = str(r.nextCursor)\n if (nextCursor !== undefined) out.nextCursor = nextCursor\n const note = str(r.note)\n if (note !== undefined) out.note = note\n if (r.meta !== undefined) out.meta = r.meta as Json\n return out\n}\n","/**\n * Pure presentation helpers for the card: verdict badge classes and labels,\n * license chip text, source bucketing, tile aspect ratio. No DOM, no React,\n * so they are unit-tested directly.\n * @module @refkit/dsh-plugin/client/badges\n */\n\nimport type { Decision, RefTile, SourceStatus } from '../core/outcome.ts'\n\nconst LICENSE_CHARS = 20\n\nexport function verdictBadge(decision: Decision): { className: string; label: string } {\n switch (decision) {\n case 'allowed': return { className: 'rk-badge rk-badge-allowed', label: 'Allowed' }\n case 'allowed-with-attribution': return { className: 'rk-badge rk-badge-attribution', label: 'Credit required' }\n case 'denied': return { className: 'rk-badge rk-badge-denied', label: 'Not allowed' }\n case 'needs-review': return { className: 'rk-badge rk-badge-review', label: 'Needs review' }\n }\n}\n\nexport function licenseLabel(tile: Pick): string {\n const base = tile.licenseVersion ? `${tile.license} ${tile.licenseVersion}` : tile.license\n const points = Array.from(base)\n return points.length > LICENSE_CHARS ? points.slice(0, LICENSE_CHARS).join('') + '…' : base\n}\n\nexport function summarizeSources(sources: readonly SourceStatus[]): { fulfilled: SourceStatus[]; failed: SourceStatus[]; skipped: SourceStatus[] } {\n return {\n fulfilled: sources.filter(s => s.status === 'fulfilled'),\n failed: sources.filter(s => s.status === 'failed'),\n skipped: sources.filter(s => s.status === 'skipped'),\n }\n}\n\n/** CSS `aspect-ratio` value for a tile. */\nexport function aspectRatio(tile: Pick): string {\n return tile.width && tile.height && tile.width > 0 && tile.height > 0 ? `${tile.width} / ${tile.height}` : '4 / 3'\n}\n","/** UI strings for the card. One object so a locale swap is one file. */\nexport const COPY = {\n searching: 'Searching refkit sources…',\n refsFor: (count: number, query: string) => `${count} reference${count === 1 ? '' : 's'} for “${query}”`,\n intent: (intent: string) => `intent: ${intent}`,\n more: 'more available — ask for the next page',\n failed: (n: number) => `${n} source${n === 1 ? '' : 's'} failed`,\n skipped: (n: number) => `${n} skipped`,\n open: 'Open',\n copyCredit: 'Copy credit',\n copied: 'Copied',\n copyFailed: 'Select and copy:',\n empty: 'No results. Try broader terms, another modality, or fewer controls.',\n legend: 'Verdict:',\n untitled: '(untitled)',\n} as const\n","/**\n * Card stylesheet, injected once as