Skip to content
dsh.fish
Bundle

@dsh-user/narrative-voice

A DSH (DeepSeek Harness) bundle plugin that rewrites the ask_user_question tool description at prompt-assembly time with a fixed narrative-voice rule (default: the answerer narrates), toggled live via the /voice command.

Source
TellToday
stars
1 stars
License
MIT
Updated
Updated 5 days ago

Readme

# 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 <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 <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

| 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/<profile>/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).

Install

dsh plugin --profile web add github:TellToday/dsh-narrative-voice

Profile: web

  • This source has no pinned commit, so a later push upstream changes what installs. Prefer pinning a commit.
Source