Back to home@TellToday

dsh-narrative-voice

No description

Stars
1
Language
JavaScript
Created
Aug 20, 2026
Updated
Aug 20, 2026
GitHub repo

Introduction

dsh-narrative-voice

English · Chinese

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 headeridentical 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/assemblethe 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):

dsh plugin --profile <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:

# full git URL
dsh plugin --profile <profile> add "git+https://github.com/TellToday/dsh-narrative-voice.git"
# or a local directory (for development)
dsh plugin --profile <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 <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

CommandEffect
/voice onenable the rewrite (effective from the next message)
/voice offdisable it (the tool description is restored)
/voice userswitch to Scheme B (the answerer narrates) and enable
/voice aiswitch to Scheme A (the AI narrates) and enable
/voiceshow 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

KeyDefaultMeaning
voiceuser (Scheme B)which narrative scheme to use
defaultActivetrueenabled 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/<profile>/cordis.patch.yml. The patch replaces the whole config, so list every key:

- 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

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.