xyavid/dsh-prompt-polish

dsh-prompt-polish

DeepSeek Harness Web 插件:输入框模型名旁的小星星按钮,一键把草稿改写成结构化的可执行提示词并写回

待分类dshdsh-plugin-marketdsh-plugins

dsh-prompt-polish

Prompt polishing for the DeepSeek Harness web GUI — one click turns a rough composer draft into a clearer, more actionable prompt: explicit goal, concrete requirements, constraints, and acceptance criteria. The button sits right next to the model name, calls the model your session already uses, writes the result straight back into the composer, and then turns into an undo arrow.

English | 简体中文


Why

A prompt that a coding agent can act on usually states what to build, where, under what constraints, and how success is judged. Most drafts don't — they are quick notes. This plugin adds a polish affordance to the dsh composer: it sends the draft through a low-temperature rewrite pass over the harness's own LLM service, fills the improved text back, and keeps the original one click away.

Nothing about your intent changes: the strategy is allowed to make the ask clearer, never to invent requirements. Your API key never reaches the browser, and no part of the flow touches the session's model history.

Features

✨ Composer buttonA small sparkle button in the composer tool row, immediately left of the model name — always at hand, and it reflows with the row
🔁 Three states, one buttonIdle (sparkle) → click to polish. Running (spinner) → click to cancel. Done (undo arrow) → click to restore the original draft. No extra panels, bars, or dialogs
🧠 Your session's modelThe call goes through ctx.llm on the route your session already uses, falling back to the harness-wide default model (agent-default-model) — no second model to configure, no separate key
🔑 Zero credential setupCalls ride the harness LLM service; keys come from the harness credential store and are used host-side only
🛡️ Draft safetyEmpty, attachment-only, over-length, and command/reference-chip drafts are refused up front; a failed call never modifies what you typed
↩️ Two kinds of undoNative Ctrl+Z (the write goes through the official editor API) and the button's own undo state
⚙️ Live settingsenabled, strength, temperature, token and length budgets, timeout, and the strategy prompt apply from Settings → 插件配置 with no restart
🌏 Language follows the draftChinese in → Chinese out, English in → English out; code, paths, identifiers, and error text are preserved verbatim
🚫 No fabricationExpression-level rewriting only: no new requirements, no invented facts, no technology choices you did not imply
🩺 Runtime diagnosticsClient state is exposed on window.__dshPromptPolish (stage / attempts / mounted / errors) for one-command troubleshooting

UI copy is Chinese-only in this version (button tooltips such as 优化提示词 / 取消优化 / 撤回优化, and the local-guard hints). Hooking into the harness locale namespace for zh/en dictionaries is on the roadmap; the settings section itself is described in Chinese because it renders the schema descriptions.

Screenshots are in Feature showcase below; docs/compatibility.md records what was field-verified on which build.

Feature showcase

The button, in place. The sparkle button lives in the composer tool row, immediately left of the model name, so polishing never takes you away from the draft you are writing.

The polish button in the composer tool row

Before. A quick note rather than a task brief: the goal, the constraints, and the acceptance criteria are all left implicit.

A rough draft in the composer before polishing

After. One click rewrites it into a structured, actionable prompt and writes the result straight back into the composer; the button then turns into the undo arrow.

The polished prompt written back into the composer

How it works

flowchart LR
    A[Composer draft] --> B{Local guards<br/>empty / attachments / chips / phase}
    B -- reject --> R[Button turns red:<br/>reason shown on hover, draft untouched]
    B -- pass --> C["POST /api/prompt-polish/optimize<br/>(same-origin, cookie-authenticated)"]
    C --> D["ctx.llm.stream<br/>low-temperature rewrite"]
    D --> E[Validate finish:<br/>error / aborted / truncated → refuse]
    E --> F[Normalize:<br/>strip fences and wrapping quotes]
    F --> G["inputActions.setDraft(result)<br/>native Ctrl+Z can still undo"]
    G --> H[Button becomes the undo arrow]
    H -- click --> I[Original draft restored]

The plugin is one npm package with two halves, following the dsh plugin conventions:

  • Host half (exports ".", Node): exports the Config schema (every field marked .volatile(), so dsh renders the settings form for the composition entry prompt-polish) and registers the POST /api/prompt-polish/optimize route on the shared webserver. The model call uses ctx.llm.stream with the auxiliary-call discipline the harness applies to session titles: composed deadline plus caller cancellation rechecked during and after the stream, terminal-finish validation, and refusal of truncated output.
  • Browser half (exports "./client", loaded through dsh.client): registers the button into the conversation.input.right slot, reads the draft with useInput, writes it with inputActions.setDraft, renders the three button states, and mirrors undoWindowMs through ctx.configForms. It consumes exactly two services — slots and configForms — and never renders custom inject-face hooks; see docs/compatibility.md for why that distinction matters. dsh.client.inject is intentionally empty: it lists client-package dependencies for graph ordering, and this bundle has none.

The two halves never share code at runtime: the browser half receives only the wire types and the prompt normalization helper, and the host half does all credential-bearing work.

Requirements

dsh>= 0.1.7-rc.1 (verified on 0.1.7-rc.1)
Profileweb — the desktop app and dsh web share this profile
Node^22.19.0 || >=24.0.0 (only for building from source)
Plugin0.3.x

The host half declares inject: ['llm', 'webServer'] (since 0.1.7 settings are no longer a namespace a plugin registers — the configuration is resolved from the composition entry; webServer must be declared explicitly, or the registering apply runs before that service exists and the route is silently missing); sessions and the settings domain are probed optionally and degrade gracefully when absent.

Install

From npm (recommended). The published tarball already contains the built lib/, and registry installs run no lifecycle scripts:

dsh plugin --profile web add @xyavid/dsh-prompt-polish

From a local checkout. The built lib/ is committed, so a checkout is usable as-is:

git clone https://github.com/xyavid/dsh-prompt-polish.git
cd dsh-prompt-polish
dsh plugin --profile web add .

To develop against a live checkout, build first and link the directory instead:

pnpm install
pnpm run build
dsh plugin --profile web add link:C:\path\to\dsh-prompt-polish

From a tarball. No build environment needed:

pnpm pack                        # produces xyavid-dsh-prompt-polish-<version>.tgz
dsh plugin --profile web add ./xyavid-dsh-prompt-polish-<version>.tgz

From GitHub. Installing from npm is easier — the published tarball already ships lib/, so it never runs prepare and the gate below does not apply.

The package declares a prepare script, and pnpm >= 10 blocks dependency build scripts by default, so a git install has to be allowlisted first; without it pnpm aborts with ERR_PNPM_GIT_DEP_PREPARE_NOT_ALLOWED. Add this to the profile's pnpm-workspace.yaml ($DSH_HOME/profiles/web/):

allowBuilds:
  '@xyavid/dsh-prompt-polish@https://codeload.github.com/xyavid/dsh-prompt-polish/tar.gz/<commit>': true

Copy the key verbatim from what pnpm printed: a git/tarball dependency's key carries the resolved commit, so it changes whenever you change the ref, and a wrong key fails silently instead of erroring. (The older onlyBuiltDependencies key does nothing on pnpm 10.29.)

dsh plugin --profile web add github:xyavid/dsh-prompt-polish
# or pin a commit for reproducibility:
dsh plugin --profile web add github:xyavid/dsh-prompt-polish#<sha>

Uninstall:

dsh plugin --profile web remove @xyavid/dsh-prompt-polish
# then restart dsh web

dsh plugin add appends the bundle to dsh.profile.bundles automatically (the package declares dsh.bundle.patch); remove takes it back out. After either operation the running profile keeps its startup bundle set, so restart before expecting a change.

Usage

Type a draft, then click the sparkle button left of the model name.

The three states

StateIconClick behaviourHover text
Idle✦ sparklestart polishing优化提示词
Running↻ spinner (hover turns red)cancel the in-flight call取消优化
Done↺ undo arrow (accent colour)restore the original draft撤回优化

What happens, step by step

  1. Local guards run first. Empty or whitespace-only drafts, attachment-only drafts (a chip's trailing zero-width space is folded away correctly), drafts over maxInputChars, drafts containing /commands or @references, and a composer in a non-plain phase are refused with a tooltip. Chips are refused because filling text back would destroy them.
  2. The button spins while the host calls the model. Your draft is untouched during the call, so cancelling at any moment leaves exactly what you typed.
  3. On success the result is written back through the official editor API, the native undo stack stays intact (Ctrl+Z works), and the same button becomes the undo arrow.
  4. Undo restores the original draft and returns the button to idle. If you keep typing after the result lands, the undo state retires itself so stale text can never overwrite your newer edits.

Interaction rules worth knowing

  • Pressing send while a polish is running cancels the call and sends your original text.
  • Editing the draft while a polish is running means the result is not written back; the button returns to idle and the hover text explains why.
  • The undo affordance expires after undoWindowMs (default 60 s). The button reads that value through the client settings mirror, and falls back to 60 s when the mirror is unavailable (see Known limitations).
  • Only one polish runs at a time per session, and a late response from a cancelled call is discarded.

Configuration

Everything lives on the prompt-polish composition entry, editable from Settings → 插件配置 or directly in that entry's config block in the profile's cordis.patch.yml. Since 0.1.7 the settings domain writes edits back to the profile patch, and the plugin reads the .volatile() references declared in its schema, so a save takes effect on the next call without a restart or a remount.

Defaults come from two places, in this order: the schema defaults below, overridden by the composition layer's config block in cordis.patch.yml, overridden by your settings section. Out of the box the composition layer pins enabled, temperature, maxOutputTokens, timeoutMs, and maxInputChars, so those five values are the effective defaults even if you never open the settings page.

FieldDefaultAppliesDescription
enabledtrueliveMaster switch; off hides the button and disables the route
strengthbalancednext callconservative — fix and structure only; balanced — also fill in implicit prerequisites; aggressive — rewrite as a full task brief (length ceiling relaxed to ~1500 characters)
temperature0.3next callLower keeps the rewrite more faithful to the original
maxOutputTokens2048next callOutput budget of one call; truncated output is refused, never written back, so raise this when using aggressive with reasoning enabled
maxInputChars12000next callInput cap counted in Unicode code points (an emoji is one character); over-limit drafts are rejected, never truncated
timeoutMs60000next callEnd-to-end deadline of one call; timeout surfaces as a distinct message, not a generic failure
systemPromptemptynext callYour own strategy text; empty uses the built-in strategy
strategyModereplace-defaultnext callreplace-default swaps the built-in strategy out entirely (its hard rules — preserve intent, never fabricate, body-only output, mirror the input language — are not retained automatically); extend-default appends your text after the built-in strategy, keeping those rules in force
undoWindowMs60000liveHow long the undo affordance stays available (0 = forever); the button reads it through the client configForms mirror

"Applies" mirrors the namespace's applies: 'live' registration: every field is re-read on each request, and enabled / undoWindowMs additionally affect what the browser shows without a restart. Only changes to the composition layer itself (a cordis.patch.yml edit) require reloading the profile directory, and patchReload: live profiles pick those up on save too.

The same values can be set from the user patch layer without touching the plugin:

# ~/.dsh/profiles/web/cordis.patch.yml  (hot-reloaded on save)
- id: prompt-polish
  config:
    strength: aggressive
    timeoutMs: 90000

Model routing precedence (host side):

  1. an explicit route in the request body — the wire protocol carries one, and non-browser callers may supply it; the bundled browser half does not send it, by design;
  2. the route recorded in the current session's last request header;
  3. the harness-wide default model (agent-default-model).

The browser half does not carry an explicit route, but it does carry the current session id (the sessionId standard prop every session-scope slot receives): the host uses it for rule 2, and the model adapter forwards it to the provider as a request marker — the x-opencode-session header the OpenCode Go / Zen gateway requires comes from exactly here, and without it the gateway rejects the call with 400 MissingSessionID. When no session id is available the host synthesizes one for the OpenCode family only; every other provider keeps receiving none (there the field means a cache key / affinity header).

If none of the three names a route the plugin answers unconfigured with an actionable message instead of guessing, which is exactly what happens in a brand-new session before any message has been sent and without a default model. The bundled client therefore behaves as "session model, falling back to the harness default".

Error handling & edge cases

CaseBehaviour
A local guard rejects the draft (empty or whitespace-only, attachment-only, /command or @reference chips, over maxInputChars, or a composer outside the plain phase)Refused in the browser before any request is sent; the host route re-checks emptiness and length. The exact hover text per guard is listed below
Draft edited during the callResult discarded, no write-back, button returns to idle
Send pressed during the callThe host route sees the disconnect, aborts the model call, and your original text is sent
TimeoutMapped to a timeout message naming the configured seconds; retryable
Truncated output (maxTokens)Refused with a hint to raise maxOutputTokens or shorten the draft
Model returns nothing usableNormalized output empty → refused (upstream); retryable
Upstream model failureMapped to upstream with the provider's own text as the tooltip detail
Plugin disabled in settingsButton hidden; the route answers 403
Profile has no webServerThe host half logs one line and stops — headless profiles do not fail

Every failure path leaves the composer exactly as you typed it. Error messages surface on the button itself (red icon + hover text) — the plugin never opens a dialog or a toast.

Error codeHTTP statusMeaningHover text
rejected403 / 413 / 422Refused: disabled, body too large, malformed body, or invalid inputthe host's own sentence (请先输入内容 / 输入过长(n/max),请先精简 / 提示词优化已在设置中关闭), else 无法优化这条输入
timeout504The call exceeded timeoutMs优化超过 <timeoutMs> 毫秒未完成
aborted—Caller disconnected or pressed send; no response is written已取消优化 (the button returns to idle first, so this is rarely visible)
unconfigured409No model route could be resolved, or the composition lacks llm无法确定使用哪个模型:请先在会话里选择模型
upstream502The model call failed, was aborted upstream, was truncated, or returned nothing usablethe provider's own message, or 优化结果在 token 上限处被截断… / 模型没有返回任何文本
internal500 / 502Invalid plugin configuration or an unexpected failurethe config error text, else 插件内部错误

Errors raised before a request is even sent come from the browser half and carry no code:

Local guardHover text
Empty draft, or a draft made only of attachment chips请先输入内容
Draft contains /command or @reference chips含 / 命令或 @ 引用的输入暂不支持优化
Composer is not in the plain phase当前输入状态不允许优化
The draft was edited while the call was running优化期间草稿已改动,结果未采用
Anything unexpected in the browser half优化失败

Architecture

dsh-prompt-polish/
├── package.json          dsh.bundle.patch (layer) + dsh.client (browser half) + scripts/exports
├── cordis.patch.yml      the composition layer: one insert row; its config is the settings base layer
├── src/
│   ├── index.ts          host apply(): config refs (.volatile()) + route + route resolution + error mapping
│   ├── enhancer.ts       ctx.llm.stream call: deadline racing, cancellation, finish validation, normalization
│   ├── prompts.ts        built-in strategy (SYSTEM/USER templates) + strength hints + output cleanup
│   ├── config.ts         schemastery schema, defaults, input checks
│   ├── protocol.ts       wire types and constants shared by both halves
│   ├── http.ts           bounded JSON body reader + JSON responder
│   └── client/
│       ├── index.tsx     browser half: three-state button, undo, slot registration, diagnostics
│       └── styles.ts     button stylesheet (single injected <style>)
├── scripts/
│   ├── build.mjs         esbuild: host ESM + browser half inside the module-loader envelope
│   └── verify-lib.mjs    rebuilds in a temp dir and byte-compares against the committed lib/
├── test/
│   ├── harness.mjs       offline regression: loads the client bundle and asserts exactly one button renders
│   ├── undo-window.test.mjs  jsdom regression for the undo window: settings read, expiry, restore
│   └── opencode-session.test.mjs  OpenCode session marker: sessionId forwarding and fallback, both halves
├── types/contract.ts     compile-time assertions against the official d.ts files
├── docs/compatibility.md interface audit, field-tested conclusions, known boundaries
├── examples/             overlay / user patch / settings examples
└── lib/                  committed build output; ships in the tarball and is loaded at startup

Data flow

browser half (React component)                    host half (Node)
  useInput(state => state.draft)                    POST /api/prompt-polish/optimize
  inputActions.setDraft(text)      ──────────▶      resolve route → ctx.llm.stream(...)
  states: sparkle / spinner / undo                  validate finish → normalize → reply

Development

Use pnpm. The repository is developed with pnpm (pnpm-workspace.yaml), and pnpm ≥ 10 refuses to run dependency install scripts until they are allowlisted. esbuild needs its postinstall to fetch the platform binary, so the workspace file ships the one-line allowance:

allowBuilds:
  esbuild: true

If pnpm install prints Ignored build scripts: esbuild, run pnpm approve-builds once (or keep the allowBuilds entry above) and re-run the install.

pnpm install

pnpm run build       # lib/index.js (host ESM) + lib/client.js (browser half)
pnpm run watch       # incremental rebuild
pnpm run typecheck   # interface contract check against the official d.ts files
pnpm test            # offline regression: the client bundle renders exactly one button
pnpm run check       # build + typecheck + test + verify:lib
pnpm run verify:lib  # fails when the committed lib/ differs from a fresh build

After a build: restart dsh web (host-half changes and the client bundle are both read at startup), then reload the page.

Two build constraints worth knowing

  1. The browser half must use the automatic JSX runtime (jsx: 'automatic' in scripts/build.mjs). esbuild's classic runtime emits a reference to a global React that the module loader never provides; the shell then silently swallows the resulting React is not defined, and the symptom is "registration succeeds but no button ever appears".
  2. The build stamps a source content hash — not a timestamp — into both bundles. Identical sources produce byte-identical output, which is what makes verify:lib meaningful; any source change alters the hash, so the served rev changes and a browser cannot keep serving a stale bundle.

Runtime diagnostics

copy(JSON.stringify(globalThis.__dshPromptPolish))

stage walks through module-loaded → styles-ready → slots-injected → registered → inject-face → rendering; errors / attempts / mounted explain any failure.

Troubleshooting

SymptomCause and fix
Button missingThe process was not restarted (the bundle list is fixed at startup); enabled is false; or lib/client.js is missing because pnpm run build never ran
Two buttonsA version before the "try slots one by one, register once" fix; update to 0.3.x
优化期间草稿已改动,结果未采用You edited the composer during the call; the result is never written over newer input — polish again
无法确定使用哪个模型:请先在会话里选择模型The session never selected a model and no default model exists; pick one in the model menu once
优化结果在 token 上限处被截断…Raise maxOutputTokens or shorten the draft
Plugin fails to load at startupThe boot output carries the original stack; typical causes are a missing lib/ build, a composition without an llm service, or a stale schemastery in the plugin's own node_modules (0.1.7 needs 3.18.4+, otherwise .volatile is not a function)
Console shows client-modules: duplicate factory registration for "…" (bundle executed twice without invalidate?)The installed copy's package.json#name differs from the module id stamped into lib/client.js — typically because lib/ was copied into a renamed directory or a local dev copy. Run pnpm run build inside the installed copy, or node scripts/build.mjs --client-id <install name>, then reload the page
Button never appears on dsh 0.2.0-rc.1, with no error in the consoleFrom 0.2.0-rc.1, app-boot gates every bundle on the @deepseek-ai/dsh* ranges i