# dsh-narrative-voice **English** · [Chinese](./README.zh-CN.md) A **DSH (DeepSeek Harness) bundle plugin** that keeps the questions and options generated by the `ask_user_question` tool **pronoun-consistent**. At **prompt-assembly time** it rewrites the tool's `questions.description` in full into a description carrying a fixed narrative-voice rule, and you can turn it **on and off anytime** with the `/voice` command. ## Why this plugin exists The `ask_user_question` tool asks the human a concise question and offers a few clickable options. **Without a fixed rule, the model drifts pronouns inside a single option**: - a **label** in the answerer's voice ("I'll restart it myself" — "I" = the person answering) paired with a **description** that drifts into the AI's voice ("I'll walk you through the restart" — "I" = the AI); - the word **"user"** also leaks in ("…so the user has less to worry about"), referring to the very person reading it in the third person. Two different "I"s in one option, and the user cannot tell them apart: who is providing the action? whose perspective is "I"? This plugin fixes it at the **prompt level**: it writes a fixed narrative-voice rule into the tool's own description, so the model keeps question, header, label, and description **in one consistent perspective throughout**, and **never calls the answerer "the user"**. ## What it does The rule splits the tool's fields into two groups: - **question and header** — **identical in both schemes**, always written from the AI's point of view: "I" = the AI, "You" = the person answering (the user). - **label and description** — follow the selected scheme: - **Scheme B (default, `voice: "user"`)** — the answerer narrates: "I" = the person answering, "You" = the AI. - **Scheme A (`voice: "ai"`)** — the AI narrates: "I" = the AI, "You" = the person answering. **Why this design? Why split the tool's fields into two groups?** **question and header — this is the AI asking the user a question.** The asker naturally speaks in the first person: "I (the AI) would like to ask you (the user) a question". So these two fields are fixed in the AI's point of view: **"I" = the AI, "You" = the user**, identical in both schemes and never changed — otherwise an "I" referring to the user would appear inside a question, and the reader would think the user is asking themselves. **label and description — this is the answer the user is about to give.** An option is essentially a reply that the AI guessed in advance and wrote on the user's behalf. But whose voice should that reply use? There are two equally reasonable views — this is exactly where Scheme A and Scheme B diverge: - **Scheme B (the answerer narrates)**: the user treats the option as a reply typed with their own hand — the first person is in the user's hands, and the AI merely guesses what the user would type and hands it back as an option. So inside an option: "I" = the user ("I'll restart it myself…"), "You" = the AI ("you'll guide me…"). - **Scheme A (the AI narrates)**: the user treats the option as a multiple-choice question handed to them by the AI — like the AI saying "I've run into a problem, here are two options, you pick: A, I'll handle it this way; B, I'll handle it that way". So inside an option: "I" = the AI ("I'll handle it for you…"), "You" = the user. The rule applies **only inside the `ask_user_question` tool** (ordinary replies and other tools are never affected), and **only where pronouns actually appear** — it never forces pronouns into a question or option that does not naturally need them. ## How it works Before every model request, DSH runs `SystemPrompt.assemble()`, puts the assembled prompt into an `assembly` object, and dispatches it through the Cordis **waterfall** event `system-prompt/assemble` — **the waterfall's return value is what is actually sent to the model**. This plugin registers a `global: true` listener on the waterfall: while enabled, it finds `ask_user_question` in `assembly.tools` and rewrites `parameters.properties.questions.description` (the real assembled shape is JSON-Schema) **in place**, then `return next()` lets it through. Only the per-request clone is touched — the registry schema and the parameter-validation closure are unaffected. `assemble()` runs once per message, so the `/voice` toggle takes effect from the next message — the same in any conversation, **including ones already in progress**. ## Install One command, straight from this GitHub repo (**verified in practice**): ```powershell dsh plugin --profile add "github:TellToday/dsh-narrative-voice#main" ``` - `#main` tracks the latest commit; - after installing, **restart the profile's process** (for the web profile, that's `dsh web`). Equivalent alternatives: ```powershell # full git URL dsh plugin --profile add "git+https://github.com/TellToday/dsh-narrative-voice.git" # or a local directory (for development) dsh plugin --profile add "C:\path\to\dsh-narrative-voice" ``` The package declares `dsh.bundle.patch`, so `dsh plugin add` automatically appends it to `dsh.profile.bundles` as a bundle layer. Uninstall: `dsh plugin --profile remove @dsh-user/narrative-voice`. > Prerequisite: `pnpm` on PATH. A git-hosted install clones the repo through > your system git (honoring your git proxy settings). ## Usage | Command | Effect | |---|---| | `/voice on` | enable the rewrite (effective from the next message) | | `/voice off` | disable it (the tool description is restored) | | `/voice user` | switch to Scheme B (the answerer narrates) and enable | | `/voice ai` | switch to Scheme A (the AI narrates) and enable | | `/voice` | show the current state (on/off + the active scheme) | The command is handled by the host's `commands` service (not by the model), so it takes effect instantly — no HMR dependency, no restart. ## Default configuration | Key | Default | Meaning | |---|---|---| | `voice` | `user` (Scheme B) | which narrative scheme to use | | `defaultActive` | `true` | enabled right after install | To change the defaults (instead of switching at runtime with `/voice`), override the row by id in the profile's patch file `$DSH_HOME/profiles//cordis.patch.yml`. The patch replaces the whole config, so list every key: ```yaml - id: narrative-voice config: voice: user # user (Scheme B: the answerer narrates) | ai (Scheme A: the AI narrates) defaultActive: true # false = disabled by default, until /voice on ``` The config is validated by `Config` (a dependency-free Standard Schema implementation): an invalid value fails the plugin load with a clear error. ## Project layout ``` dsh-narrative-voice/ ├── lib/index.js # plugin body: Config, the assemble listener, the /voice command ├── cordis.patch.yml # bundle patch: inserts the plugin row into the host plane ├── test/ │ ├── functional.mjs # isolated functional tests (39 assertions) │ └── run-test.ps1 # runs the tests directly (no install, no junction) ├── package.json # bundle metadata (dsh.bundle.patch; zero deps) ├── LICENSE # MIT └── README.md / README.zh-CN.md ``` ## Development ```powershell pwsh ./test/run-test.ps1 ``` The plugin has **no bare imports**, so the tests run with plain `node` — nothing to install, nothing to clean up. ## License MIT — see [LICENSE](./LICENSE).