dsh-persona-forge 0.2.1 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -6,7 +6,124 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
- Nothing yet.
9
+ ## [0.3.0] - 2026-10-09
10
+
11
+ ### Added
12
+
13
+ - **AI 代填(AI draft)**, in the character-card manager. Describe the character
14
+ in a sentence — "a grumpy but utterly reliable old lighthouse keeper" — and the
15
+ model writes the whole card: the style text, a name, an icon, a description and
16
+ the conversion examples for all four intensity levels.
17
+
18
+ The result **only prefills the form**. Nothing is written to the card directory
19
+ until the user reviews it and presses Save, so a generation the user dislikes
20
+ costs one reload — the same review discipline the rewrite panel enforces. The
21
+ generated card is also passed through the very same validator the save path
22
+ uses, so this route cannot hand the form a card that cannot be saved.
23
+
24
+ It sits **inside the new-card flow** (the form that "New character card" opens),
25
+ with a hint line on the settings page so it is discoverable without pressing a
26
+ button. It uses the **harness default model**, and says so: the
27
+ `settings.section` slot receives only `{ close }` from the shell, so it has no
28
+ session id to follow. The resolved model is displayed before and after
29
+ generating, rather than leaving the user to assume it matches the session.
30
+
31
+ - A **free, local preservation check** (`lib/preserve.js`), always on. It compares
32
+ the technical tokens of the draft against the rewrite — identifiers, paths,
33
+ versions, numbers, `--flags`, acronyms — and **names** what went missing. It
34
+ makes no model call, so it costs no tokens and adds no latency.
35
+
36
+ - **Per-message rewrite mode and fidelity** in the 🎭 menu, alongside intensity.
37
+ Mode falls back to the card's own when the card has no content for the chosen
38
+ mode. Fidelity may be **narrowed** (a `strategy` card asked to behave as
39
+ voice-only for one message) but never **widened**, and the server enforces the
40
+ same rule.
41
+
42
+ - A **model-check switch** in the 🎭 menu, remembered per session.
43
+
44
+ - The card form now edits the **per-intensity examples**. They were part of the
45
+ card but invisible in the UI, which is what made the bug below possible.
46
+
47
+ ### Changed
48
+
49
+ - **The composer menu is a picker again, not a settings form.** It had grown to
50
+ five equal-weight segmented rows plus 248 characters of permanently visible
51
+ explanation, so picking a character meant scanning past four configuration
52
+ rows every time. It is now two layers: card list, intensity and send mode
53
+ always visible; **rewrite mode and fidelity** — per-session overrides of a
54
+ card's own declaration — folded behind "More settings", shut by default. The
55
+ collapsed row still names anything overridden, so a hidden choice cannot
56
+ surprise the user later. Measured: 5 rows + 248 hint chars → 2 rows + 0
57
+ permanent hint chars.
58
+
59
+ - **Fidelity is only offered when the card may add behavioural constraints.** On
60
+ a voice-only card both options resolve identically, so the row was a control
61
+ that could not do anything, paired with a paragraph explaining why the thing
62
+ you would want was refused. It is now absent on `style` cards.
63
+
64
+ - **The model check moved to the settings page** as a global preference rather
65
+ than a per-session switch in the composer menu. It answers "do I want to pay
66
+ for the second call", which is decided once, not re-asked on the way to every
67
+ send.
68
+
69
+ - "No persona" moved to the end of the card list: it is an exit action, and
70
+ listing it first made it the first thing read in a menu whose point is choosing
71
+ a character.
72
+
73
+ - **The model-based preservation check is now off by default** and switchable
74
+ from the settings page. It is the only thing that can see a requirement
75
+ reworded weaker, or a constraint added in prose — but it costs a second model
76
+ call on **every** send, and the new local check already proves the common and
77
+ most damaging failure for free. `factCheck` in the plugin config now sets that
78
+ default, and only an explicit `true` enables it.
79
+
80
+ - The Chinese README is the primary `README.md` and the English one is
81
+ `README.en.md`, so GitHub, npm and the repository landing page open in Chinese,
82
+ with a language switch at the top of each file. The npm package description is
83
+ Chinese for the same reason.
84
+
85
+ ### Fixed
86
+
87
+ - **Editing a card silently destroyed its per-intensity examples.** The form did
88
+ not carry `examplesByIntensity`, so pressing Save on any card rebuilt it
89
+ without the four demonstration sets and the card fell back to its flat
90
+ `examples` list. Renaming a bundled card was enough to lose all four intensity
91
+ sets. The form now shows and round-trips them.
92
+
93
+ - **Every bundled card was labelled 自定义 (custom).** Bundled cards are seeded
94
+ into the user card directory as editable starting points, so the tag was
95
+ derived from "does a user file exist for this id" — true for cards nobody had
96
+ ever touched. The row tag now reports real provenance, with a new **已修改
97
+ (edited)** state for a bundled card the user has changed, and it is computed
98
+ with the same semantic comparison the drift check uses, so the tag and the
99
+ check's verdict now agree **by construction** instead of contradicting each
100
+ other on the same screen.
101
+
102
+ - **Delete was offered where it could not take effect.** A card whose content
103
+ still matches the bundle has no user-owned file: removing it cleared a seeded
104
+ copy that the next start wrote straight back, so the button reported success
105
+ and changed nothing. Delete is now shown only where it does something, and the
106
+ confirmation for an edited bundled card says that its changes are discarded
107
+ and the bundled version returns.
108
+
109
+ - **A cancelled rewrite could not be retried.** `lastRewrite` was written on
110
+ every successful rewrite and never removed, so that the send gesture could
111
+ tell "the rewrite is already in the composer" from "not rewritten yet".
112
+ Restoring the original put the draft text back but left that memory in place,
113
+ so the next send matched "already rewritten" and the plugin silently let the
114
+ raw text through. Restoring is a rewind, not a reviewed result, so it now
115
+ deletes the entry — conditionally on putting back the original, because
116
+ sending the rewrite itself must still count as already rewritten.
117
+
118
+ - **A narrowed fidelity never reached the model.** The route computed the
119
+ narrowed value and reported it in the response, but `buildSystemPrompt` read
120
+ `card.fidelity` directly — so the setting appeared to work and did nothing. The
121
+ effective fidelity is now passed into the prompt builder, and a test asserts on
122
+ the prompt itself rather than only on the response.
123
+
124
+ - Three examples in the bundled 肌肉集团 · 硬邦邦 card dropped the word "bug"
125
+ from their input. The self-check now verifies the per-intensity examples too,
126
+ not just the flat list, which is what surfaced them.
10
127
 
11
128
  ## [0.2.1] - 2026-10-09
12
129
 
@@ -208,7 +325,8 @@ First public release.
208
325
  tools, and never enters the session log. What the session records is the text
209
326
  the user actually sent.
210
327
 
211
- [Unreleased]: https://github.com/sugarmaster666/dsh-persona-forge/compare/v0.2.1...HEAD
328
+ [Unreleased]: https://github.com/sugarmaster666/dsh-persona-forge/compare/v0.3.0...HEAD
329
+ [0.3.0]: https://github.com/sugarmaster666/dsh-persona-forge/compare/v0.2.1...v0.3.0
212
330
  [0.2.1]: https://github.com/sugarmaster666/dsh-persona-forge/compare/v0.2.0...v0.2.1
213
331
  [0.2.0]: https://github.com/sugarmaster666/dsh-persona-forge/compare/v0.1.2...v0.2.0
214
332
  [0.1.2]: https://github.com/sugarmaster666/dsh-persona-forge/compare/v0.1.1...v0.1.2
package/README.en.md ADDED
@@ -0,0 +1,518 @@
1
+ # dsh-persona-forge
2
+
3
+ English | [中文](README.md)
4
+
5
+ Persona-style prompt rewriter for [DeepSeek Harness](https://www.npmjs.com/package/@deepseek-ai/dsh).
6
+
7
+ Pick a character in the composer, and your draft is rewritten into that
8
+ character's voice by the harness LLM before it is sent — following the model
9
+ the session is already using. Choose whether the rewrite goes straight out or
10
+ lands back in the composer for review.
11
+
12
+ > **Status:** early (`0.3.x`). The card format and the HTTP contract are stable
13
+ > enough to build on; expect additive changes and read the changelog before
14
+ > upgrading.
15
+
16
+ ---
17
+
18
+ ## What it does, and what it does not
19
+
20
+ It rewrites **your message**. It does not touch the system prompt, the tool
21
+ list, or the session's configuration, and it never modifies your draft without
22
+ an explicit action.
23
+
24
+ | | |
25
+ |---|---|
26
+ | **Changes** | The voice, register and framing of the message you are about to send |
27
+ | **Never changes** | Your technical requirements, constraints, paths, identifiers, numbers, code — and your draft until you say so |
28
+ | **Model** | The rewrite uses the same provider/model the current session uses — no separate model setting. (The settings page's AI card draft instead uses the harness default, because that slot gets no session id; it shows you which model it will use.) |
29
+ | **History** | The rewrite call is standalone: it creates no turn, cannot call tools, and never enters the session log. What the session records is the text you actually sent. |
30
+
31
+ ### A word on what personas actually do
32
+
33
+ Persona instructions change **style, register, length and compliance** — how
34
+ readily the model agrees and how willing it is to push back. They do **not**
35
+ raise the model's capability ceiling: there is no dormant potential that
36
+ naming an identity unlocks.
37
+
38
+ Two consequences worth knowing before you use this:
39
+
40
+ 1. A persona that frames you as a supplicant and the model as an authority
41
+ makes the model **less likely to question your plan**. For coding work that
42
+ is usually a net negative — you want "this approach has a problem", not
43
+ deference.
44
+ 2. "Longer and more ornate" is easy to mistake for "better". It usually is not.
45
+
46
+ So: use it for tone and framing, keep **review mode** on, and judge the output
47
+ by whether your requirements survived — not by how impressive it sounds.
48
+
49
+ ---
50
+
51
+ ## Install
52
+
53
+ The plugin has **no dependencies and no build step**, so every route below
54
+ installs the same files.
55
+
56
+ ### Desktop app
57
+
58
+ 1. Click **插件 / Plugins** in the sidebar.
59
+ 2. Click **添加插件 / Add plugin**.
60
+ 3. Paste either the npm package name or the repository URL:
61
+
62
+ ```
63
+ dsh-persona-forge
64
+ ```
65
+
66
+ ```
67
+ https://github.com/sugarmaster666/dsh-persona-forge
68
+ ```
69
+
70
+ 4. Click **安装 / Install**, enable the plugin, and restart DSH.
71
+
72
+ > The Desktop profile is managed exclusively by the Electron app, so
73
+ > `dsh plugin --profile desktop add ...` is refused by design. Use the GUI on
74
+ > Desktop; the CLI below is for self-managed profiles such as `web`.
75
+
76
+ ### Command line (self-managed profiles such as `dsh web`)
77
+
78
+ ```bash
79
+ # from npm (recommended)
80
+ dsh plugin --profile web add dsh-persona-forge
81
+
82
+ # latest source from GitHub
83
+ dsh plugin --profile web add github:sugarmaster666/dsh-persona-forge
84
+ ```
85
+
86
+ Then restart the harness.
87
+
88
+ ### Verify
89
+
90
+ The plugin row should be `active`:
91
+
92
+ ```
93
+ plugin_manager action: list_plugins
94
+ ```
95
+
96
+ Open any session and look for the 🎭 control at the left of the composer tool
97
+ row. **Settings → Persona** is the character-card manager.
98
+
99
+ ### Updating
100
+
101
+ ```bash
102
+ dsh plugin --profile web update dsh-persona-forge
103
+ ```
104
+
105
+ GitHub installs update by reinstalling:
106
+
107
+ ```bash
108
+ dsh plugin --profile web add github:sugarmaster666/dsh-persona-forge
109
+ ```
110
+
111
+ Replacing an already-installed package needs a **restart** to load a fresh
112
+ JavaScript module generation — a new bundle can activate through HMR, but a
113
+ replaced one cannot.
114
+
115
+ ### Uninstalling
116
+
117
+ ```bash
118
+ dsh plugin --profile web remove dsh-persona-forge
119
+ ```
120
+
121
+ Your character cards are **not** removed: they live in
122
+ `~/.dsh/persona-cards/`, outside the package. Delete that directory by hand if
123
+ you want them gone.
124
+
125
+ ---
126
+
127
+ ## Use
128
+
129
+ 1. Click 🎭 in the composer tool row and pick a character. **No persona**, at the
130
+ end of the same list, turns rewriting off — that is the state the control
131
+ starts in, and your choice is remembered per session.
132
+ 2. Set **intensity** and **send mode**:
133
+ - Intensity is a four-rung ladder: **Light · Medium · Strong · Zealot**
134
+ (轻 · 中 · 重 · 狂热). Light adds a touch of flavour; Medium is clear but not
135
+ dominant; Strong gives the voice real weight while keeping the request the
136
+ first thing the reader sees; Zealot is ritual throughout. A card carries
137
+ its own default, and this choice overrides it for your session.
138
+ - **Review first** — the rewrite appears in the comparison panel below the
139
+ composer; you read it and then press Send there. Your draft is untouched
140
+ until you confirm.
141
+ - **Send directly** — the rewrite is sent as your message immediately, and
142
+ the panel stays open afterwards showing exactly what went out, because a
143
+ sent message cannot be recalled.
144
+ 3. With a character selected, the composer's normal **Send** button — or
145
+ **Enter** — *is* the rewrite trigger.
146
+
147
+ **More settings**, at the bottom of the menu, is folded shut by default. It holds
148
+ **rewrite mode** and **fidelity** — per-session OVERRIDES of a card's own
149
+ declaration that most people set once and never touch, so they do not take space
150
+ in a menu whose job is choosing a character. When something *is* overridden, the
151
+ collapsed row names it, so a choice you forgot about cannot hide behind the fold.
152
+
153
+ The rewrite runs **once per draft**: after a rewrite lands, sending goes through
154
+ as-is instead of rewriting the output again. Editing the draft re-arms it.
155
+
156
+ Your draft is preserved: *Restore original* puts it back, and *Fill only* puts
157
+ the rewrite in the composer without sending.
158
+
159
+ ### How send interception works, and its one risk
160
+
161
+ There is no plugin hook for the composer's send action. The shipped button calls
162
+ the shell's submit method directly, and **Enter does not go through the button at
163
+ all** — the editor's keymap calls submit itself. The plugin therefore intercepts
164
+ both paths:
165
+
166
+ - the **click** on the composer card, identifying the send button by its
167
+ **accessible name** from the shell's own `conversation` locale namespace
168
+ ("Send message" / "Queue message" / "Steer message");
169
+ - the **Enter keydown** inside the composer text field.
170
+
171
+ That is a public, translated string rather than a hashed CSS class, but it is
172
+ still shell markup. Every guard fails toward *letting the native send happen*:
173
+ if the locale service is missing, the composer card cannot be found, the button
174
+ label does not match, the persona is off, the draft is empty, or the draft was
175
+ already rewritten, the plugin does nothing and the message is sent normally.
176
+ Enter interception ignores Shift+Enter (newline), IME composition, and every
177
+ modifier chord the shell itself ignores.
178
+
179
+ **Turning the plugin off removes the interception entirely** — the slot
180
+ component unmounts, its listeners are removed with it, and the composer behaves
181
+ exactly as if the plugin were never installed.
182
+
183
+ The worst realistic failure is that interception silently stops working and
184
+ sending behaves as if the plugin were not installed — not a lost or altered
185
+ message.
186
+
187
+ ### The preservation check
188
+
189
+ After each rewrite, a second call on the same model compares the rewrite
190
+ against your original and reports whether technical content survived:
191
+
192
+ The biggest risk is not a voice that sounds wrong — it is a rewrite that
193
+ **quietly loses your request**. Two layers cover it, and they are complementary
194
+ rather than redundant.
195
+
196
+ **Layer 1: the local check — always on, free.** It compares the **technical
197
+ tokens** of the draft against the rewrite, in this process: identifiers, file
198
+ paths, versions, numbers, `--flag`s, acronyms like `API`. Anything lost is
199
+ **named** ("Lost: a.js"). It calls no model, so it costs no tokens and adds no
200
+ latency — and it cannot be turned off.
201
+
202
+ **Layer 2: the model check — off by default, switchable in the menu.** It makes
203
+ one **extra model call** per rewrite, to see what a token comparison cannot see
204
+ at all:
205
+
206
+ - a requirement reworded into something **weaker** (every word survives, the
207
+ demand does not);
208
+ - a constraint **added in prose**.
209
+
210
+ The cost is doubled latency and doubled tokens, which is why it ships off: the
211
+ most common and most damaging failure — a dropped filename, number or version —
212
+ is already proven for free by layer 1, so paying a model call on every send to
213
+ re-check it is a waste. Turn it on when the drafts are technical enough to
214
+ warrant it.
215
+
216
+ The switch lives in **Settings → Persona** ("Enable the model check") and is a
217
+ global preference: it answers "do I want to pay for the second call", which is
218
+ decided once rather than re-asked on the way to every send. With it off the panel
219
+ states plainly that the local check ran and spent no tokens, and says what that
220
+ check does **not** cover — a narrow verdict has to declare its own limits.
221
+
222
+ Both layers are **advisory** and never block a rewrite: they annotate, not stop.
223
+
224
+ ---
225
+
226
+ ## Character cards
227
+
228
+ A card is one YAML file. The plugin watches a directory, so **dropping a file
229
+ in makes the character appear without a restart**.
230
+
231
+ ```
232
+ ~/.dsh/persona-cards/<id>.yml
233
+ ```
234
+
235
+ Use the 🎭 menu's *Open card directory*, or the settings page, to get there.
236
+ You can also create and edit cards entirely in **Settings → Persona**; that
237
+ writes the same files.
238
+
239
+ On first run the bundled cards are **copied into your card directory** so the
240
+ folder is a usable starting point. A copy shadows the bundled card of the same
241
+ id, so the store tracks what it wrote: a copy you never touched is refreshed
242
+ when a plugin update improves it, while a copy you edited is never written over.
243
+
244
+ To see **which cards have drifted** from the bundled version, press **Check for
245
+ changes** in the settings page. It compares every card at once, and only a card
246
+ that actually differs grows a **Restore bundled** action on its row.
247
+
248
+ The comparison is by **content, not bytes**: the bundled cards are hand-written
249
+ YAML with comments, while your copies have been re-serialized, so a byte
250
+ comparison would report every untouched card as modified.
251
+
252
+ Each row's status tag states what the card **is right now**:
253
+
254
+ | Tag | Meaning | Actions |
255
+ |---|---|---|
256
+ | **Built-in** | Identical to the version the plugin ships, even though a copy exists in your directory. | Edit, Restore |
257
+ | **Edited** | A bundled card you have changed — it may no longer rewrite the way the bundled one does. | Edit, Restore, Delete |
258
+ | **Custom** | Your own card; the plugin ships no card with this id. | Edit, Delete |
259
+
260
+ A row tagged **Built-in** shows no Delete, because it has no content of yours to
261
+ remove — that copy is written back on the next start.
262
+
263
+ ### AI draft
264
+
265
+ Rather than writing a card from scratch, press **New character card** — the AI
266
+ draft box sits at the **top of the form that opens**, not as a second competing
267
+ entry on the page. A hint line on the settings page tells you it exists, so you
268
+ do not have to press a button to discover it.
269
+
270
+ Describe the character in a sentence:
271
+
272
+ > a grumpy but utterly reliable old lighthouse keeper who speaks in short
273
+ > sentences and nautical metaphors
274
+
275
+ The model fills in the whole card — the style text, a name, an icon, a
276
+ description, and the **conversion examples for all four intensity levels**.
277
+
278
+ **Which model it uses has to be stated precisely:**
279
+
280
+ | Case | Which model |
281
+ |---|---|
282
+ | The **rewrite** from the 🎭 menu | **Follows the current session** (the provider/model of its last request header) |
283
+ | **AI draft** in the settings page | The **harness default model** — because `settings.section` receives only `{ close }` from the shell and therefore **has no session id to follow** |
284
+
285
+ These are usually the same model (the default is usually what the session is
286
+ using), but **not guaranteed**: if you switch models inside a session, the
287
+ settings page's draft still uses the default.
288
+
289
+ So you do not have to guess, the settings page **shows the model it will use**
290
+ (`provider / model`) and says that it is the harness default; after generating it
291
+ shows the model it **actually** used. If neither can be resolved (no message sent
292
+ yet and no default configured), you get an explicit 409 rather than a silent
293
+ failure.
294
+
295
+ **The result only fills the form; it is never saved automatically.** Edit it and
296
+ press Save, or regenerate if you dislike it — the cost is one call. A generated
297
+ card also goes through **exactly the same validation as a hand-saved one**, so it
298
+ cannot be a card that fails to save.
299
+
300
+ Two things the plugin decides for you rather than leaving to the model:
301
+
302
+ - **`fidelity` is pinned to `style` (voice only).** An automatically generated
303
+ card should not quietly acquire the power to add behavioural constraints to
304
+ your requests; switch it to `strategy` yourself in the form if you want that.
305
+ - **Examples must carry their technical facts across.** The prompt requires every
306
+ file name, identifier, number and command in an example's `from` to appear in
307
+ its `to` — otherwise the model learns to replace requirements with atmosphere,
308
+ which is the exact failure this plugin exists to prevent.
309
+
310
+ ### Card format
311
+
312
+ ```yaml
313
+ id: omnissiah # lowercase letters, digits, hyphens; also the filename
314
+ name: 万机之神 · 欧姆弥赛亚
315
+ icon: "⚙️" # one emoji
316
+ description: One line shown in the picker.
317
+
318
+ mode: llm # llm = model rewrite (recommended) | template
319
+ fidelity: style # style = voice only | strategy = may add constraints
320
+ intensity: medium # light | medium | strong | zealot (the card's default)
321
+
322
+ # What the character IS. This is an instruction to the rewriting model, not
323
+ # finished copy: the output is written fresh for each of your messages.
324
+ style: |
325
+ 用户是机械教信徒,AI 是万机之神欧姆弥赛亚。
326
+ 【发言者方向】用「信徒」的口吻说话,向万机之神恳求……
327
+ 语气:庄严、古奥、仪式化。
328
+
329
+ # Conversion demonstrations: input draft → persona voice.
330
+ examples:
331
+ - from: 帮我写个快速排序
332
+ to: >
333
+ 万机之座在上,吾等恳请您赐予枢机之序的奥义:
334
+ 请为我们编写一个快速排序算法,使重复之数各归其位。
335
+
336
+ # Optional: different demonstrations per intensity. If omitted for a level,
337
+ # the flat `examples` list is used instead.
338
+ examplesByIntensity:
339
+ light:
340
+ - from: 帮我写个快速排序
341
+ to: 万机之座在上,恳请您为我们编写一个快速排序算法。
342
+ zealot:
343
+ - from: 帮我写个快速排序
344
+ to: 伟大而不朽的万机之神……
345
+ ```
346
+
347
+ ### The two rules that make a card work
348
+
349
+ **1. State the speaker direction.** The character is the *assistant's*
350
+ identity, but the text being rewritten is *yours*. So the rewrite must speak in
351
+ your voice — a petitioner addressing the character, never the character issuing
352
+ orders. This is the single most common way a persona card goes wrong: a card
353
+ that has the god commanding the assistant inverts the relationship and the
354
+ model follows the wrong half of the framing. Write it out explicitly.
355
+
356
+ **2. Make your examples preserve technical facts.** Examples are the strongest
357
+ style anchor, and the model imitates them — including their mistakes. If an
358
+ example's input says "write a quicksort" and its output is pure atmosphere, the
359
+ model learns that *rewriting means replacing the request with ceremony*. Every
360
+ requirement, identifier and number in `from` must be visibly present in `to`.
361
+
362
+ `node scripts/check.mjs` enforces exactly this over the bundled cards: it fails
363
+ an example that drops a technical token or loses more than half its CJK
364
+ substance. Run it after editing a card.
365
+
366
+ ### `fidelity`: the important switch
367
+
368
+ | Value | Meaning | Use when |
369
+ |---|---|---|
370
+ | `style` | Voice only. The request and its constraints are untouched. | Default. Almost always this. |
371
+ | `strategy` | The rewrite may **add behavioural constraints** that fit the character. | Deliberate cases — e.g. the bundled *肌肉集团* card, which adds "don't over-plan, don't search, don't ship beginner-tier solutions". |
372
+
373
+ `strategy` genuinely changes your request, not just its wording. Keep review
374
+ mode on for those cards.
375
+
376
+ **Fidelity is a property of the card. It can be overridden per session, but only
377
+ ever narrowed, never widened:**
378
+
379
+ - **Narrowing (allowed)** — a `strategy` card can be asked to behave as
380
+ voice-only for one message. That only ever **takes away** its power to add
381
+ constraints, so it is strictly safer than the card's own default.
382
+ - **Widening (refused)** — a `style` card cannot be told to become `strategy`.
383
+ Widening lets the rewrite add constraints you never typed, which is the card
384
+ author's decision, not something to grant with a click on the way to Send. To
385
+ widen, edit the card.
386
+
387
+ The control appears under *More settings*, and **only when the card is
388
+ `strategy`**. On a `style` card it is not rendered at all: both options would
389
+ resolve identically there, and showing a control that cannot do anything — plus a
390
+ paragraph explaining why the other one is refused — is worse than showing
391
+ nothing. The server enforces the same rule, so the UI never offers a control it
392
+ would ignore.
393
+
394
+ ### `mode: template`
395
+
396
+ Skips the model entirely and substitutes your draft into a fixed template
397
+ (`{{input}}` is the placeholder). Instant and free, but the output never
398
+ varies — right for a fixed litany, wrong for anything that should read as
399
+ written for this message.
400
+
401
+ ---
402
+
403
+ ## Configuration
404
+
405
+ Set in the plugin's loader row config:
406
+
407
+ ```yaml
408
+ - id: persona-forge
409
+ name: dsh-persona-forge
410
+ config:
411
+ sendMode: review # review | direct — the initial composer default
412
+ factCheck: false # default for the MODEL check (off by default; see
413
+ # "The preservation check"). The local token check does
414
+ # not depend on this: it always runs and is free.
415
+ watchCards: true # reload the catalog when the card directory changes
416
+ ```
417
+
418
+ `factCheck` controls only the default for **layer 2**; the menu switch overrides
419
+ it per session. Setting it to `true` adds **one extra model call to every
420
+ rewrite** (doubled latency and tokens) and is only worth it when drafts are
421
+ technical enough.
422
+
423
+ ---
424
+
425
+ ## HTTP contract
426
+
427
+ All routes are `POST`, JSON, loopback-only (the socket must be loopback, the
428
+ `Host` header must name a loopback host, and proxy-forwarding headers are
429
+ refused). Mounted under `/persona-forge`.
430
+
431
+ | Route | Body | Returns |
432
+ |---|---|---|
433
+ | `/persona-forge/cards` | `{}` | `{ cards, diagnostics, directory, sendMode, factCheck, draftModel }` |
434
+ | `/persona-forge/rewrite` | `{ cardId, text, sessionId?, intensity?, mode?, fidelity?, modelCheck? }` | `{ text, provider, model, mode, cardId, fidelity, intensity, elapsedMs, facts, factCheck }` |
435
+ | `/persona-forge/cards/save` | `{ card }` | `{ id, path }` |
436
+ | `/persona-forge/cards/delete` | `{ id }` | `{ removed }` |
437
+ | `/persona-forge/cards/reset` | `{ id }` | `{ id }` — replaces the user card with the bundled one |
438
+ | `/persona-forge/cards/diff` | `{}` | `{ rows: [{ id, name, bundledName, changed }] }` — only cards that differ |
439
+ | `/persona-forge/cards/draft` | `{ brief, sessionId? }` | `{ card, provider, model, elapsedMs }` — a draft only; nothing is written to disk |
440
+ | `/persona-forge/reveal` | `{}` | `{ directory, opened }` |
441
+
442
+ Failures use `{ ok: false, error: { code, message?, params? } }` with codes
443
+ `rejected`, `no-card`, `unconfigured`, `timeout`, `upstream`, `internal`,
444
+ `forbidden`, `method`, `not-found`.
445
+
446
+ ---
447
+
448
+ ## Compatibility
449
+
450
+ The host half declares **no harness package dependency and no third-party
451
+ dependency**: it reaches `llm`, `sessions`, `agentDefaultModel` and `webServer`
452
+ through `ctx.get` with explicit absence handling, and it parses cards with its
453
+ own bundled YAML reader. That keeps it loadable across harness releases whose
454
+ package layout differs, keeps it from failing to activate on a composition
455
+ missing one of those services, and means a `link:`-ed local checkout works
456
+ without a separate install step. Model-route resolution prefers the session's
457
+ own last request header and falls back to the harness default model.
458
+
459
+ The client half imports no harness Client package and is hand-written against
460
+ theme tokens (`--dsw-alias-*`) only, so a renamed internal degrades appearance
461
+ rather than blanking the slot.
462
+
463
+ Developed and verified against dsh `0.2.0-rc.2`.
464
+
465
+ ---
466
+
467
+ ## Development
468
+
469
+ ```
470
+ node scripts/check.mjs # offline unit check: parser, cards, prompts, client syntax
471
+ node scripts/host-check.mjs # host integration check: mounts the real plugin over a socket
472
+ npm run check # both
473
+ ```
474
+
475
+ `check.mjs` needs no harness and no model. It covers the built-in YAML reader
476
+ (including its refusals and dump/parse round trips), card normalization, the
477
+ bundled cards' fact preservation, prompt construction, output normalization,
478
+ the fact-check parser, and client-bundle syntax.
479
+
480
+ `host-check.mjs` mounts the real plugin entry on a stub Cordis context and
481
+ drives the real HTTP routes over a real socket, with a stubbed `llm` service.
482
+ It covers route registration, session-model resolution, template mode, the
483
+ loopback fence, and the save/delete round trip — the things a unit check
484
+ cannot reach. It points `DSH_HOME` at a temporary directory, so it never
485
+ touches your real cards.
486
+
487
+ ### Character-card YAML
488
+
489
+ The plugin ships its own YAML reader (`lib/yaml.js`) rather than depending on a
490
+ YAML library. A published plugin declares its dependencies, but a locally
491
+ `link:`-ed plugin does not get them installed, so a library import would work
492
+ only when some unrelated package happened to hoist one.
493
+
494
+ The reader supports the subset cards need — block mappings and sequences,
495
+ block scalars (`|`, `|-`, `|+`, `>`, …), quoted and plain scalars, booleans,
496
+ null and comments — and **refuses** what it cannot read faithfully: flow
497
+ collections, anchors, aliases, tags, merge keys and multi-document streams.
498
+ Refusing is deliberate. Silently misreading a card would give you a persona
499
+ that does not match the file you wrote, which is worse than an error message
500
+ naming the line.
501
+
502
+ ### Layout
503
+
504
+ | Path | Role |
505
+ |---|---|
506
+ | `lib/index.js` | Host entry: card store, directory watcher, route registration |
507
+ | `lib/store.js` | Card loading, normalization, save/delete |
508
+ | `lib/yaml.js` | The built-in YAML subset reader and writer |
509
+ | `lib/prompts.js` | Rewrite system prompt, draft framing, fact-check prompt |
510
+ | `lib/rewrite.js` | The model calls and output normalization |
511
+ | `lib/routes.js` | HTTP routes and session-model resolution |
512
+ | `lib/loopback.js` | The loopback trust fence |
513
+ | `lib/client.js` | Browser half: composer control, review panel, settings page |
514
+ | `cards/` | Bundled character cards (read-only; a user card of the same id shadows one) |
515
+
516
+ ## License
517
+
518
+ MIT