# dsh-persona-forge English | [中文](README.md) Persona-style prompt rewriter for [DeepSeek Harness](https://www.npmjs.com/package/@deepseek-ai/dsh). Pick a character in the composer, and your draft is rewritten into that character's voice by the harness LLM before it is sent — following the model the session is already using. Choose whether the rewrite goes straight out or lands back in the composer for review. > **Status:** early (`0.8.x`). The card format and the HTTP contract are stable > enough to build on; expect additive changes and read the changelog before > upgrading. --- ## What it does, and what it does not It rewrites **your message**. It does not touch the system prompt, the tool list, or the session's configuration, and it never modifies your draft without an explicit action. | | | |---|---| | **Changes** | The voice, register and framing of the message you are about to send | | **Never changes** | Your technical requirements, constraints, paths, identifiers, numbers, code — and your draft until you say so | | **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.) | | **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. | ### A word on what personas actually do First, a fact: a persona does **not** unlock hidden capability. There is no dormant potential that naming an identity wakes up — the ceiling is the ceiling. But the other half is true too: **a persona really does change how the model thinks.** It changes style, register, length and **compliance** — how readily the model agrees, how willing it is to push back, and how seriously it will commit to an absurd metaphor. This project was born in that second, slightly unreasonable place. The inspiration is a 30-second video on Bilibili: > [**“ds:好的,我现在是万机之神了(”**](https://www.bilibili.com/video/BV1Ata26pE92) — by 身居幻想乡の长乐安生 The comments put it better than any spec sheet: > Another AI is you pretending to be Guan Yu — however well you act, you are not > Guan Yu. But when **Big Fat Fish** pretends to be Guan Yu, she genuinely > believes she is Guan Yu, and really does ride out and cut down Hua Xiong. "Okay, I am the Machine God now" — the model is not *humouring* the bit. It has **accepted the identity**. What this plugin does is make that moment reusable: put a character card in front of your draft, and let DeepSeek speak as that character. **It is tuned for DeepSeek Flash.** Flash is fast, cheap and plentiful — the tier you actually use all day — and letting it answer you once in the voice of the Machine God is the entire point. It runs on other models too, but that "one line and it is in character" feel was tuned against Flash. **So do not treat it as a performance switch.** Two things worth knowing: 1. A persona that frames you as a supplicant and the model as an authority makes the model **less likely to question your plan**. For coding work that is usually a net negative — you want "this approach has a problem", not deference. 2. "Longer and more ornate" is easy to mistake for "better". It usually is not. Use it for tone and framing, keep **review mode** on, and judge the output by whether your requirements survived — not by how impressive it sounds. > The two bundled cards are the two tempers of that video: **万机之神 · 欧姆弥赛亚** > is the solemn Mechanicum litany, and **肌肉集团 · 硬邦邦** is its antidote — a > machine-gun-tempo crude client who says it all in fewer words than the litany. --- ## Install The plugin has **no dependencies and no build step**, so every route below installs the same files. ### Desktop app 1. Click **插件 / Plugins** in the sidebar. 2. Click **添加插件 / Add plugin**. 3. Paste either the npm package name or the repository URL: ``` dsh-persona-forge ``` ``` https://github.com/sugarmaster666/dsh-persona-forge ``` 4. Click **安装 / Install**, enable the plugin, and restart DSH. > The Desktop profile is managed exclusively by the Electron app, so > `dsh plugin --profile desktop add ...` is refused by design. Use the GUI on > Desktop; the CLI below is for self-managed profiles such as `web`. ### Command line (self-managed profiles such as `dsh web`) ```bash # from npm (recommended) dsh plugin --profile web add dsh-persona-forge # latest source from GitHub dsh plugin --profile web add github:sugarmaster666/dsh-persona-forge ``` Then restart the harness. ### Verify The plugin row should be `active`: ``` plugin_manager action: list_plugins ``` Open any session and look for the 🎭 control at the left of the composer tool row. **Settings → Persona** is the character-card manager. ### Updating ```bash dsh plugin --profile web update dsh-persona-forge ``` GitHub installs update by reinstalling: ```bash dsh plugin --profile web add github:sugarmaster666/dsh-persona-forge ``` Replacing an already-installed package needs a **restart** to load a fresh JavaScript module generation — a new bundle can activate through HMR, but a replaced one cannot. ### Uninstalling ```bash dsh plugin --profile web remove dsh-persona-forge ``` Your character cards are **not** removed: they live in `~/.dsh/persona-cards/`, outside the package. Delete that directory by hand if you want them gone. --- ## Use 1. Click 🎭 in the composer tool row and pick a character. **No persona**, at the end of the same list, turns rewriting off — that is the state the control starts in. **Which character you are using is remembered per session.** 2. The selected character grows its own **"Settings for "** row right under it. Open it for **intensity**, **send mode**, **rewrite mode** and **fidelity**: - Intensity is a four-rung ladder: **Light · Medium · Strong · Zealot** (轻 · 中 · 重 · 狂热). Light adds a touch of flavour; Medium is clear but not dominant; Strong gives the voice real weight while keeping the request the first thing the reader sees; Zealot is ritual throughout. A card carries its own default, and this choice overrides it. - **Review first** — the rewrite appears in the comparison panel below the composer; you read it and then press Send there. Your draft is untouched until you confirm. - **Send directly** — the rewrite is sent as your message immediately, and the panel then **closes itself**. There is nothing left to confirm, and a panel left up would block the next message. 3. With a character selected, the composer's normal **Send** button — or **Enter** — *is* the rewrite trigger. The character menu: three cards, No persona, and the selected character's expanded settings (intensity, send mode, rewrite mode, fidelity) **Everything that configures a character lives in its own "Settings for " fold, and follows the CHARACTER rather than the session.** "硬邦邦 always goes out Zealot, un-reviewed" is a fact about that character rather than a temporary choice in one conversation — so configure it once and picking it in another session gives you the same settings. Switching character **re-reads that character's own settings** rather than carrying the previous one's intensity across. That fold holds four things: | | | |---|---| | **Intensity** | Light · Medium · Strong · Zealot | | **Send mode** | Review first / Send directly | | **Rewrite mode** | Card default / Model rewrite / Template | | **Fidelity** | Card default / Voice only / Constraints too | Every row explains itself on **hover** rather than in permanent prose. The folded row itself **summarises the current settings**, so you can see what a character is set to without opening it. **All four follow the character card** — pick it in another session and you get the same set. **The card directory has exactly one entry point: the settings page.** It is not in the rewrite menu — it is occasional housekeeping, and it should not sit in the path of choosing a character. **The rewrite column is editable.** What the model wrote is a starting point, not a verdict: fixing a dropped identifier or a tone that is slightly off by hand is faster than another round trip. Once you edit it the panel says so, and **Send** and **Fill** both carry *your* text. The review panel: the original draft on the left, the editable rewrite on the right, and beneath them the local check verdict, model and elapsed time, plus Send / Restore original / Rewrite **While the panel is open, the composer's send arrow is deliberately inert.** The panel is the decision point; pressing send now would either push the un-reviewed draft out or start a second rewrite of the same draft. Pick an action in the panel, or close it, and the composer comes back. The rewrite runs **once per draft**: after a rewrite lands, sending goes through as-is instead of rewriting the output again. Editing the draft re-arms it. Your draft is preserved: *Restore original* puts it back, and also rewinds the "already rewritten" memory, so a rewrite you cancelled can be run again. ### How send interception works, and its one risk There is no plugin hook for the composer's send action. The shipped button calls the shell's submit method directly, and **Enter does not go through the button at all** — the editor's keymap calls submit itself. The plugin therefore intercepts both paths: - the **click** on the composer card, identifying the send button by its **accessible name** from the shell's own `conversation` locale namespace ("Send message" / "Queue message" / "Steer message"); - the **Enter keydown** inside the composer text field. That is a public, translated string rather than a hashed CSS class, but it is still shell markup. Every guard fails toward *letting the native send happen*: if the locale service is missing, the composer card cannot be found, the button label does not match, the persona is off, the draft is empty, or the draft was already rewritten, the plugin does nothing and the message is sent normally. Enter interception ignores Shift+Enter (newline), IME composition, and every modifier chord the shell itself ignores. **Turning the plugin off removes the interception entirely** — the slot component unmounts, its listeners are removed with it, and the composer behaves exactly as if the plugin were never installed. The worst realistic failure is that interception silently stops working and sending behaves as if the plugin were not installed — not a lost or altered message. ### The preservation check The biggest risk is not a voice that sounds wrong — it is a rewrite that **quietly loses your request**. The plugin compares the **technical tokens** of the draft against the rewrite, in this process: identifiers, file paths, versions, numbers, `--flag`s, acronyms like `API`. Anything lost is **named** ("Lost: a.js"). It calls **no model**, so it costs no tokens and adds no latency — and it cannot be turned off. It always runs. **What it cannot see** (and the panel says so, so you do not assume more): a requirement reworded into something **weaker** while every word survives, a constraint **added in prose**, a quantity written in Chinese words (「两次」), or a bare lowercase English word that is not a path, flag or identifier. > A second layer once existed: one **extra model call** per rewrite aimed > exactly at those cases. It has been **removed** — it doubled the latency and > the token cost of every send, and the most common and most damaging failure (a > dropped filename, number or version) was already proven for free. For the > residue, **the rewrite column is editable**, which is more direct than another > round trip. The check is **advisory** and never blocks a rewrite: it annotates, it does not stop. --- ## Character cards A card is one YAML file. The plugin watches a directory, so **dropping a file in makes the character appear without a restart**. ``` ~/.dsh/persona-cards/.yml ``` Use *Open directory* in **Settings → Persona** to get there (the rewrite menu no longer carries an entry). You can also create and edit cards entirely in **Settings → Persona**; that writes the same files. On first run the bundled cards are **copied into your card directory** so the folder is a usable starting point. A copy shadows the bundled card of the same id, so the store tracks what it wrote: a copy you never touched is refreshed when a plugin update improves it, while a copy you edited is never written over. To see **which cards have drifted** from the bundled version, press **Check for changes** in the settings page. It compares every card at once, and only a card that actually differs grows a **Restore bundled** action on its row. The comparison is by **content, not bytes**: the bundled cards are hand-written YAML with comments, while your copies have been re-serialized, so a byte comparison would report every untouched card as modified. Each row's status tag states what the card **is right now**: | Tag | Meaning | Actions | |---|---|---| | **Built-in** | Identical to the version the plugin ships, even though a copy exists in your directory. | Edit, Restore | | **Edited** | A bundled card you have changed — it may no longer rewrite the way the bundled one does. | Edit, Restore, Delete | | **Custom** | Your own card; the plugin ships no card with this id. | Edit, Delete | A row tagged **Built-in** shows no Delete, because it has no content of yours to remove — that copy is written back on the next start. ### AI draft Rather than writing a card from scratch, press **New character card** — the AI draft box sits at the **top of the form that opens**, not as a second competing entry on the page. A hint line on the settings page tells you it exists, so you do not have to press a button to discover it. The 角色扮演 settings page: New character card and the expanded AI draft's Generate draft are boxed, and the footer names the harness default model this run will use **You** write one sentence: **who the AI plays, who you are, and how they talk.** > the AI plays a grumpy but utterly reliable old lighthouse keeper, and I am his > apprentice; he speaks in short sentences and nautical metaphors **The AI** then fills the form by the character-card fields: a name, an icon, a description, the style text, and the **conversion examples for all four intensity levels**. > **The rewrite is always YOUR message, never the character's line.** If you say > "the AI is my mother", the example must be **the child speaking to her mother** > (`妈,帮我把标题改成首页好不好?`), **not** the mother speaking > (~~`乖,妈帮你把标题改成首页`~~). Get this backwards and the card is useless — > the rewrite comes out as the character answering for you. The result **only fills the form — it is never saved automatically**. Two things are deliberately fixed: **fidelity** is always voice-only, and **rewrite mode** is always model rewrite. A generated card should not quietly acquire the power to add constraints to your request; raise it by hand if you want it. > **All four intensity sets must come back, or the generation counts as failed.** > The examples are the longest part of a card, and a model occasionally runs out > of output budget part-way through them. That used to be accepted as-is, which > left the missing levels with **nothing to demonstrate** — it looked fine and was > quietly worse. It now reports an error; retrying once is usually enough. **Which model it uses has to be stated precisely:** | Case | Which model | |---|---| | The **rewrite** from the 🎭 menu | **Follows the model the current session is using** — preferring the one you have just **picked in the model dropdown** (even before you send with it), then the provider/model of the session's last request | | **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** | These are usually the same model (the default is usually what the session is using), but **not guaranteed**: if you switch models inside a session, the settings page's draft still uses the default. > The rewrite reads the host's own precedence: **pending pick → last request > header → harness default**. So changing the model in the dropdown takes effect > immediately, with no message to send first. Reading the request header alone > sent a rewrite to the **previous** model whenever you had switched and not yet > spoken — that was a bug, and it is fixed. > What is followed is the **provider / model**; the **reasoning depth is never > specified explicitly**. The rewrite sends no `reasoningEffort` field and lets > the harness decide per route. > > That is not laziness, it is the only portable spelling. The harness validates > the field *before* handing the request to any provider: when a route's model > declares **no** reasoning metadata, **any present value throws** — including > the empty string (the host's own "Provider default" sentinel) and including > `off`. `hy4-preview` on `workbuddy-xdpool` is exactly such a route, and > pinning `low` there broke every rewrite outright. > > The cost: on a reasoning model the rewrite may still spend thinking time > before emitting text. The panel's elapsed seconds and the 120s deadline are > the current answer to that. So you do not have to guess, the settings page **shows the model it will use** (`provider / model`) and says that it is the harness default; after generating it shows the model it **actually** used. If neither can be resolved (no message sent yet and no default configured), you get an explicit 409 rather than a silent failure. **The result only fills the form; it is never saved automatically.** Edit it and press Save, or regenerate if you dislike it — the cost is one call. A generated card also goes through **exactly the same validation as a hand-saved one**, so it cannot be a card that fails to save. Two things the plugin decides for you rather than leaving to the model: - **`fidelity` is pinned to `style` (voice only).** An automatically generated card should not quietly acquire the power to add behavioural constraints to your requests; switch it to `strategy` yourself in the form if you want that. - **Examples must carry their technical facts across.** The prompt requires every file name, identifier, number and command in an example's `from` to appear in its `to` — otherwise the model learns to replace requirements with atmosphere, which is the exact failure this plugin exists to prevent. ### Card format ```yaml id: omnissiah # lowercase letters, digits, hyphens; also the filename name: 万机之神 · 欧姆弥赛亚 icon: "⚙️" # one emoji description: One line shown in the picker. mode: llm # llm = model rewrite (recommended) | template fidelity: style # style = voice only | strategy = may add constraints intensity: medium # light | medium | strong | zealot (the card's default) # The fixed wrapper for template mode: no model call, {{input}} is replaced # verbatim with what you typed. Every card should carry one, or "Template" is a # switch that does nothing. template: | 万机之座在上,吾等是您卑微的信徒,谨向欧姆弥赛亚祷告: {{input}} 恳请您垂听吾等的祈求。愿机魂安宁,愿数据各安其位。 # What the character IS. This is an instruction to the rewriting model, not # finished copy: the output is written fresh for each of your messages. style: | 用户是机械教信徒,AI 是万机之神欧姆弥赛亚。 【发言者方向】用「信徒」的口吻说话,向万机之神恳求…… 语气:庄严、古奥、仪式化。 # Conversion demonstrations: input draft → persona voice, one set per level. # # Four sets is all you need. An earlier version also had a flat `examples:` list, # but it was only the fallback for a level with no set of its own — on a card # with all four populated it was never reached, yet still had to be maintained. # Write only this now. (A flat `examples:` in an older file is still READ and # still falls back; it is just no longer something you need to write.) examplesByIntensity: light: - from: 帮我写个快速排序 to: 万机之座在上,恳请您为我们编写一个快速排序算法。 medium: - from: 帮我写个快速排序 to: > 万机之座在上,吾等恳请您赐予枢机之序的奥义: 请为我们编写一个快速排序算法,使重复之数各归其位。 strong: - from: 帮我写个快速排序 to: 伟大而不朽的万机之神,恳请您赐予枢机之序的奥义…… zealot: - from: 帮我写个快速排序 to: 伟大而不朽的万机之神…… # The SECOND set, shown when fidelity is `strategy`. All four levels again. # # It demonstrates "voice + behavioural framing" — framing being an instruction # about HOW to do the work (do not over-plan, do not stop at the first plausible # cause), never a restatement of the request. Without this set a user who raises # fidelity gives the model nothing to imitate, and it pads instead. examplesByIntensityStrategy: light: - from: 帮我写个快速排序 to: 万机之座在上,请为我们编写一个快速排序算法,径直给出可用之器。 medium: - from: 帮我写个快速排序 to: > 万机之座在上,恳请您赐予枢机之序的奥义,使重复之数各归其位。 另有一请:请直取可行之解,不必先为吾等铺设长篇筹谋。 strong: - from: 帮我写个快速排序 to: 伟大而不朽的万机之神,恳请您赐予枢机之序的奥义……勿止步于初见可行之解。 zealot: - from: 帮我写个快速排序 to: 伟大而不朽的万机之神……其一,勿以理论充作交付;其二,勿满足于最初可行之解。 ``` ### The two rules that make a card work **1. State the speaker direction.** The text being rewritten is always *yours*, but **which side the character is on is your decision**: the AI can play the character (and you address it as a petitioner, a client or a subordinate), or **you can be** the character (speaking in its voice to the AI). Both are valid. The only thing that goes wrong is **leaving it unsaid** — an implicit direction leaves the model guessing, and guessing wrong inverts the whole relationship (a petitioner's prayer comes out as the god issuing orders). Write it out explicitly. The two bundled cards take one side each: 欧姆弥赛亚 is "the user is a believer, the AI is the Machine God"; 硬邦邦 is "**the user is** the client". **2. Make your examples preserve technical facts.** Examples are the strongest style anchor, and the model imitates them — including their mistakes. If an example's input says "write a quicksort" and its output is pure atmosphere, the model learns that *rewriting means replacing the request with ceremony*. Every requirement, identifier and number in `from` must be visibly present in `to`. `node scripts/check.mjs` enforces exactly this over the bundled cards: it fails an example that drops a technical token or loses more than half its CJK substance. Run it after editing a card. ### `fidelity`: the important switch | Value | Meaning | Use when | |---|---|---| | `style` | Voice only. | Default. Almost always this. | | `strategy` | The rewrite may **add behavioural framing**. | Deliberate cases — e.g. the bundled *肌肉集团* card, which adds "don't over-plan, don't search, don't ship beginner-tier solutions". | **"Constraint" means two different things in this document — do not conflate them:** | | What it is | May it change? | |---|---|---| | **Technical content** | What you are asking for, which file, which number, which limit | **Never, under any setting** | | **Behavioural framing** | How the assistant should approach the work — how direct, whether to plan first, what counts as good enough | `strategy` may add it | What `strategy` adds is the **second** kind, and it **genuinely changes the request you send** — it puts something in your message that you did not type. So keep **Review first** on for those cards. > The prompt used to conflate the two: the hard rules said "never add, remove, > widen or reinterpret a requirement" while declaring themselves an override of > everything *including the character description*, and the `strategy` permission > was written before them. The model obeyed the absolute prohibition, so > `strategy` was a **no-op** — flipping "Constraints too" in the UI changed > nothing about the output. The hard rules now cover technical content only, and > say explicitly that they do not forbid behavioural framing. **Fidelity is a property of the card, but you can override it per character, in both directions:** | Option | Meaning | |---|---| | **Card default** | Use whatever the card declares on the settings page | | **Voice only** | Wording changes; your request does not | | **Constraints too** | The rewrite may also add the character's working style (e.g. "no long planning") | **"Constraints too" genuinely changes your request**, rather than only rewording it — it adds things you never typed. So pair it with **Review first**. > This rule used to be one-way: a `strategy` card could be narrowed to `style`, > but a `style` card could never be widened. The reasoning was that a click on > the way to Send should not grant the power to add constraints to your request. > > That reasoning does not hold up: **the card is your own file**, and the > settings page has always let you set `fidelity:` directly. Refusing in the menu > did not remove the capability — it only made the choice **disappear from the > place where you actually work**. All three options are now available on every > card. ### `mode: template` Skips the model entirely and substitutes your draft into a fixed template (`{{input}}` is the placeholder). Instant and free, but the output never varies — right for a fixed litany, wrong for anything that should read as written for this message. --- ## Configuration Set in the plugin's loader row config: ```yaml - id: persona-forge name: dsh-persona-forge config: sendMode: review # review | direct — the initial composer default watchCards: true # reload the catalog when the card directory changes ``` The technical-token check depends on no configuration: it always runs and is free. The retired `factCheck` key is ignored, so a profile still carrying it changes nothing. --- ## HTTP contract All routes are `POST`, JSON, loopback-only (the socket must be loopback, the `Host` header must name a loopback host, and proxy-forwarding headers are refused). Mounted under `/persona-forge`. | Route | Body | Returns | |---|---|---| | `/persona-forge/cards` | `{}` | `{ cards, diagnostics, directory, sendMode, draftModel }` | | `/persona-forge/rewrite` | `{ cardId, text, sessionId?, intensity?, mode?, fidelity? }` | `{ text, provider, model, mode, cardId, fidelity, intensity, elapsedMs, facts }` | | `/persona-forge/cards/save` | `{ card }` | `{ id, path }` | | `/persona-forge/cards/delete` | `{ id }` | `{ removed }` | | `/persona-forge/cards/reset` | `{ id }` | `{ id }` — replaces the user card with the bundled one | | `/persona-forge/cards/diff` | `{}` | `{ rows: [{ id, name, bundledName, changed }] }` — only cards that differ | | `/persona-forge/cards/draft` | `{ brief, sessionId? }` | `{ card, provider, model, elapsedMs }` — a draft only; nothing is written to disk | | `/persona-forge/reveal` | `{}` | `{ directory, opened }` | Failures use `{ ok: false, error: { code, message?, params? } }` with codes `rejected`, `no-card`, `unconfigured`, `timeout`, `upstream`, `internal`, `forbidden`, `method`, `not-found`. --- ## Compatibility The host half declares **no harness package dependency and no third-party dependency**: it reaches `llm`, `sessions`, `agentDefaultModel` and `webServer` through `ctx.get` with explicit absence handling, and it parses cards with its own bundled YAML reader. That keeps it loadable across harness releases whose package layout differs, keeps it from failing to activate on a composition missing one of those services, and means a `link:`-ed local checkout works without a separate install step. Model-route resolution prefers the session's own last request header and falls back to the harness default model. The client half imports no harness Client package and is hand-written against theme tokens (`--dsw-alias-*`) only, so a renamed internal degrades appearance rather than blanking the slot. Developed and verified against dsh `0.2.0-rc.2`. --- ## Development ``` node scripts/check.mjs # offline unit check: parser, cards, prompts, client syntax node scripts/host-check.mjs # host integration check: mounts the real plugin over a socket npm run check # both ``` `check.mjs` needs no harness and no model. It covers the built-in YAML reader (including its refusals and dump/parse round trips), card normalization, the bundled cards' fact preservation, prompt construction, output normalization, the local fact comparison, route resolution order, the send-gesture decision, and client-bundle syntax. `host-check.mjs` mounts the real plugin entry on a stub Cordis context and drives the real HTTP routes over a real socket, with a stubbed `llm` service. It covers route registration, session-model resolution, template mode, the loopback fence, and the save/delete round trip — the things a unit check cannot reach. It points `DSH_HOME` at a temporary directory, so it never touches your real cards. ### Character-card YAML The plugin ships its own YAML reader (`lib/yaml.js`) rather than depending on a YAML library. A published plugin declares its dependencies, but a locally `link:`-ed plugin does not get them installed, so a library import would work only when some unrelated package happened to hoist one. The reader supports the subset cards need — block mappings and sequences, block scalars (`|`, `|-`, `|+`, `>`, …), quoted and plain scalars, booleans, null and comments — and **refuses** what it cannot read faithfully: flow collections, anchors, aliases, tags, merge keys and multi-document streams. Refusing is deliberate. Silently misreading a card would give you a persona that does not match the file you wrote, which is worse than an error message naming the line. ### Layout | Path | Role | |---|---| | `lib/index.js` | Host entry: card store, directory watcher, route registration | | `lib/store.js` | Card loading, normalization, save/delete | | `lib/yaml.js` | The built-in YAML subset reader and writer | | `lib/prompts.js` | Rewrite system prompt, draft framing, AI-draft prompt | | `lib/rewrite.js` | The model calls and output normalization | | `lib/routes.js` | HTTP routes and session-model resolution | | `lib/loopback.js` | The loopback trust fence | | `lib/client.js` | Browser half: composer control, review panel, settings page | | `cards/` | Bundled character cards (read-only; a user card of the same id shadows one) | ## License MIT