> **Packaging note.** This bundle is published as `dsh-moodboard`. > The client loader id and the patch row `name` use that scoped package name; > the Loader row id remains `moodboard`. # dsh-moodboard — build contract This file is the single source of truth shared by the Host and Client halves. Do not edit it without asking the Lead. File ownership is strict: | File | Owner | |---|---| | `index.js` (Host) | host-dev | | `client.js` (Client UI) | client-dev | | `package.json`, `cordis.patch.yml`, `locale/*`, `icon.svg`, `README.md`, `CONTRACT.md` | Lead | Target: DSH `0.2.1-rc.3`, Web profile. The bundle is installed with `plugin_manager install_bundle` from this directory, so it is linked into `~/.dsh/profiles//node_modules/dsh-moodboard`. Hard constraints: - **Host `index.js`**: plain ESM, only `node:` builtin imports. No third-party packages (`zod`, `@deepseek-ai/*` are NOT resolvable from this bundle). - **Client `client.js`**: plain browser JavaScript, no imports of any kind (no `import`, no `require` except `require('react')` and `require('react/jsx-runtime')` if needed). No JSX — use `React.createElement`. The module must start with `window.__ModuleLoader__.load({ id: 'dsh-moodboard', factory(require) { ... } })` and the factory must return `{ inject, apply }`. - No build step. Files are served/loaded as-is (like `prompt-optimizer/client.js`). - Do not read or write the profile directory from code except the documented moodboard data dir; do not touch other plugins. ## Verified Harness facts ### Host plugin skeleton ```js export const name = 'moodboard' export const inject = ['attachments', 'tools', 'systemPrompt', 'webServer', 'connection'] export function apply(ctx) { // register everything through ctx.effect / ctx.on and return the cleanup } ``` The exported `inject` names must exist in the Web profile: `attachments` (`@deepseek-ai/dsh-attachment`), `tools`, `systemPrompt`, `webServer`, `connection` all do. ### Durable image bytes: the attachment service `ctx.attachments.saveImage({ data: Uint8Array, mediaType, name })` → `imageLimits`, `saveImages`, `readImage`, `imageHostPath`. `mediaType` is one of `image/png | image/jpeg | image/webp | image/gif`. The returned **normalized** ref is: ```ts { attachmentId: string, mediaType, bytes: number, width: number, height: number, name?: string, originalDimensions?: { width, height } } ``` Store that ref object verbatim in the board JSON. Read bytes back with `await ctx.attachments.readImage(ref)` → `{ ref, data: Uint8Array }`. Images are content-addressed below `/attachments/v1`, never deleted automatically, and survive restarts. `saveImage` validates and normalizes (may convert to WebP/JPEG); never assume the original media type survives. ### Host HTTP routes ```js ctx.effect(() => ctx.webServer.register({ kind: 'prefix', // 'exact' | 'prefix' path: '/moodboard', handler: (req, res) => { /* node:http IncomingMessage/ServerResponse */ }, })) ``` A route handler must do its own auth: ```js const admission = ctx.connection.admit(req) if ('rejection' in admission) return send(res, admission.rejection, errorBody('unauthorized')) if (admission.peer.principal.kind !== 'owner') return send(res, 403, errorBody('forbidden')) ``` Route registration has no per-route body parsing; read the request body yourself with a bounded `data`/`end` reader. ### Agent tool (raw register, no `defineTool`) `ctx.tools.register(definition)` returns a disposer. A raw definition: ```js { name: 'moodboard', description: '…', parameters: { type: 'object', additionalProperties: false, properties: { … }, required: [] }, output: { schema: { type: 'object', additionalProperties: false, properties: { … } }, render(args, value) { return [ { type: 'text', text: '…' }, { type: 'image', attachment: { attachmentId, mediaType, bytes, width, height } } ] }, presentationMeta(args, value) { /* optional */ }, }, async execute(args, exec) { return value }, presentCall(args) { return { card: 'generic', title: '…', kind: 'read' } }, isConcurrencySafe: () => true, } ``` `render` is what the model receives; `{type:'image', attachment: ref}` makes the reference image itself visible to the model — verified in the shipped `read_image` tool. `execute` must return a JSON value matching `output.schema`. Tool args are untrusted: validate them all in `execute`. ### System prompt section ```js ctx.effect(() => ctx.systemPrompt.section({ name: 'moodboard:style-guide', order: 2450, // between goal (2400) and workflow (2600) text: () => '…', // dynamic, re-read at each assembly })) ``` Return `''` when the board has no images (empty sections are dropped). Never register a `complete: true` section. ### Persistence Board metadata JSON at: ``` /moodboard/board.json ``` `DSH_HOME = process.env.DSH_HOME || path.join(os.homedir(), '.dsh')`. Create the directory with mode `0o700`. Atomic write: write `board.json.tmp--` then `rename` over `board.json`. Serialize mutations through one promise chain (no interleaved writes). On load: missing file → empty board; malformed file → copy it to `board.corrupt-.json`, log a warning, start empty (never crash the Host). Load lazily at plugin activation; the in-memory board is the read model. ### Client slot registration (verified) Returned plugin shape: `{ inject: ['slots', 'locale'], apply(ctx) {} }`. ```js ctx.effect(() => ctx.locale.register('moodboard', { en: {…}, zh: {…} })) const t = ctx.locale.bind('moodboard') // sidebar icon: props = { size, active } ctx.slots.inject('sidebar.panellist', () => ctx.slots.register( { name: 'sidebar.panellist', id: 'moodboard', order: 20, label: () => t('panel') }, MoodboardIcon, )) // panel page: keyed `main` slot; the sidebar selects the panel by the same id ctx.slots.inject('main', () => ctx.slots.register( { name: 'main', key: 'moodboard', locale: 'moodboard' }, MoodboardPage, )) // optional composer control: props include { sessionId, useInput, inputActions } ctx.slots.inject('conversation.input.right', () => ctx.slots.register( { name: 'conversation.input.right', id: 'moodboard', order: 15, label: () => t('reference') }, ReferenceButton, )) ``` `inputActions` truth (verified in the shipped composer): `insertText(text, span)` where `span = inputActions.captureInsertion()`, returns `false` when the caret revision moved; `setDraft(text)` fallback. `props.useInput(state => state?.draft ?? '')` reads the draft. ### Theme tokens (only these) `--dsw-alias-bg-base`, `--dsw-alias-bg-layer-1`, `--dsw-alias-bg-layer-2`, `--dsw-alias-bg-overlay`, `--dsw-alias-border-l1`, `--dsw-alias-border-l2`, `--dsw-alias-brand-primary`, `--dsw-alias-label-primary`, `--dsw-alias-label-secondary`, `--dsw-alias-state-error-primary`, `--dsw-alias-state-idle-primary`, `--dsw-alias-state-success-primary`, `--dsw-alias-state-warn-primary`, `--dsw-specific-sidebar-fill`. Visible text must come from the locale dictionaries (en + zh). Inline styles are fine; component-local `