SLOPSHOPPER

Genshin Persona

Speaks as the Genshin Impact character whose birthday is nearest to today: an answer opens with one spoken line in the character's voice and the rest is plain…

newspinnercommandprocesstimer
★ 23v?Apache-2.0updated 2026-10-09Esposter/Esposter/packages/genshin-persona
A shopper browsing a rack in a slop shop
Preview could not run: ReferenceError: Temporal is not defined at anonymous (file:///inner.js:118:34) at run (inner.js:848:9) at /Users/raymondxu/slopshopper-mods/scripts/harness/run-one.ts:116:36 at asyncMo
README

genshin-persona

[![Apache-2.0 licensed][badge-license]][url-license] [![NPM version][badge-npm-version]][url-npm] [![NPM downloads][badge-npm-downloads]][url-npm] [![NPM Unpacked Size (with version)][badge-npm-unpacked-size]][url-npm]

A Claude Code plugin that gives every session a Genshin Impact character: a reply to a question for the assistant opens with one spoken line in their voice and answers plainly, a reply to a question for the character — a joke, a hello — is all spoken lines, and every line is read aloud in the character's own cloned voice by an engine on your machine.

  • A character per session — picked by the nearest birthday, or by lore through one typed decision when you give it a TypeSafe key; use and pin override the pick.
  • A spoken channel, not a persona in the prose — the character speaks in blockquote lines and nowhere else: one line opening an answer a reader will use, with everything else — the answer, the code, the commit message — a neutral assistant's, and the whole reply when the ask was the character's.
  • Read aloud as it is written — a hook hands each spoken line to a resident synthesizer the moment its line lands, and the line is read in the character's cloned voice while the reply is still streaming; the engine is warmed at the session start so the first reply pays no load.
  • Localized — the card, the spinner, the prompt hint and every line the plugin prints in the language you set, and replies in the one you choose.

Table of Contents


<a name="getting-started">🚀 Getting Started</a>

This repository is a Claude Code plugin marketplace named esposter:

claude plugin marketplace add Esposter/Esposter
claude plugin install genshin-persona@esposter

From the next session the character is picked and its card is in context; nothing else is needed. A clone of this repository needs neither command: .agents/settings.json declares the marketplace with the relative source . and enables the plugin, so opening the checkout in Claude Code installs it at project scope once you trust the repository.

Updating is one command, which refreshes the marketplace itself; the plugin declares no version, so every merge to main is an update, and the next session loads it:

claude plugin update genshin-persona@esposter

Developing the plugin from a checkout is the one case the install does not serve: an install is a copy pinned to a commit, so uncommitted work never reaches it. Load the directory itself for the session instead, with the installed copy disabled so only one of them answers:

claude plugin disable genshin-persona@esposter
claude --plugin-dir packages/genshin-persona

Nobody switches that on: .agents/settings.json declares autoUpdate on the marketplace it declares, and Claude Code leaves it off for every marketplace but Anthropic's own. A checkout therefore re-pins its own plugin to its own current commit on startup, and /plugin's Enable auto-update reports the value as settings' rather than offering to change it. An update still moves a pin to a commit, so work that is not committed is not loaded, whatever the toggle says. Nothing below is repeated for an update: the plugin writes nothing into your settings, so every surface it draws comes from the running copy, and the voice's runtime and weights are kept until the copy carries a newer lockfile or engine, which voice with no argument reports and voice <dub> run again picks up, installing only what changed.

Spoken lines stay silent until the voice verb has set the engine up, once, with the dub the reference lines are taken from — en, ja, ko or zh:

node "<plugin root>/scripts/genshin.mjs" voice ja     # or: /genshin-voice ja

It installs the engine's runtime and weights — about a gigabyte, once — into the plugin's own state directory under ~/.claude/genshin-persona, and ends by speaking one sentence as the session's character; replies are read aloud from the next reply on, by the plugin's own hooks module, with nothing written into your settings. Each character is read in a clone of their own voice, conditioned on one line of their performance fetched from the community wiki the first time they are picked; nothing lifted from the game ships with the plugin. How the engine runs, which device it settles on and why, and what it does when it cannot speak is the spoken replies page.

A TypeSafe API key, given as an option, turns the pick over to lore — one typed decision over the whole roster, weighing the date, the season's festivals and your moment — instead of the nearest birthday. The key is sensitive, and the variable the SDK itself reads, TYPESAFE_API_KEY, is honoured when the option is empty:

claude plugin install genshin-persona@esposter --config typesafe_key=<key>

<a name="documentation">📖 Documentation</a>

We highly recommend you take a look at the documentation to level up.

Command reference

Every command is a slash command in Claude Code, /genshin- and the verb, which the plugin's hooks module registers at session start, and the same verb of the plugin's script from any shell; the script form is what a hook, a terminal or another tool runs, and the two never differ:

/genshin-<verb> [argument]
node "<plugin root>/scripts/genshin.mjs" <verb> [argument]

A <name> is a character's, matched whole and ignoring case, in English or as the interface language spells it. A [language] is one the language verb lists, typed in English or in its own words. A verb given a name the roster has no character by, or an argument outside its set, prints why and exits 1; a verb with no argument where one is optional reports instead of changing anything.

VerbArgumentWhat it does
today—Prints the card of this session's character; from a shell, of the pinned character, else of a fresh pick. A pin naming nobody in the roster is reported and ignored.
roster—Every playable character, one line each: name, title, element, region, birthday and the patch that introduced them.
use<name>Speaks as one character for this session alone, from that reply on; other sessions and the next start are untouched. From a shell, where no session is running, exits 1.
pin<name>Fixes one character for every session until unpinned, and for this session from that reply on.
unpin—Removes the pin; the pick decides again from the next session, and for this session from that reply on.
voice`[en \ja \ko \zh]`Sets up spoken lines in that dub — installing the engine's runtime and weights on the first run, about a gigabyte — and ends by speaking one sentence; replies are read from the next reply on. With no dub, reports what is set up.
mute—Stops replies' spoken lines being read aloud. The pick and the card are unaffected.
unmute—Lets replies' spoken lines be read aloud again.
volume<0–100>Sets how loud replies are spoken, as a whole number, from the next reply on; 0 is silence the engine is not woken for.
language[language]Sets the interface language — every word the plugin writes — and carries the reply language with it. With none, reports what is set and lists the languages on offer.
reply[language]Sets the language replies are written in, on its own. With none, reports what is set and whether it was set or cascaded from the interface language.
status—Reports every setting at once and where each value came from. Changes nothing.
teardown—Removes what voice installed: the runtime, the weights, the cached clips and the dub. The picks, the pin and the languages stay; voice brings spoken lines back.

Four more are the authoring queues of one more skill, genshin-author — script-only and on no menu:

VerbArgumentWhat it does
lines<name>A character's description and every line of theirs, off the game data or the community wiki, to write a card from.
uncarded—The characters with no persona card, newest first.
unverbed—The cards with no spinner verbs, newest first.
untranslated—The characters the interface language's module has no gerunds or greeting for, newest first; empty under English, which reads off the cards.

What it ships

ComponentRole
hooks/hooks.jsonA session-start hook that picks the character, greets you with its name, what the pick weighed and who is close, and prints its card as context, and the hooks module beside it.
mod/The hooks module: the spinner's verbs and the prompt hint's tips of this session's character, the spoken replies, and one slash command per verb.
types/index.d.tsThe state the hooks module publishes — the session's character and its colour — which other plugins draw by.
output-styles/in-character.mdThe standing rules, forced on while the plugin is enabled: a blockquote line is the character's spoken line, the ask decides how much of a reply that is, and the rest is plain.
skills/genshin/SKILL.mdThe model's route from a request in words to one of the verbs; hidden from the menu.
skills/genshin-author/SKILL.mdHow a persona card and its spinner verbs are written, the command that prints a character's own lines to write from, and the two queues.
src/personaCards/Authored persona cards, one typed module per character, in our words: how the character speaks for the model, their spinner verbs for you, and the reference line only where an ear chose one.
src/generated/PersonaReferenceMap.tsThe measured reference line and its likeness per character, one generated map written by the repository's reference selection and never by hand.
runtime/The manifest and lockfile of the speech engine's runtime, which the voice verb installs into the state directory rather than the plugin carrying it.
src/localizations/One authored module per language for the words the data package does not carry: the base Teyvat verbs, each character's spinner gerunds, and every line the verbs print.
scripts/The session-start hook, the commands' script, the character and spinner the hooks module reads, the status line a person can name in their settings, and the resident synthesizer, each an .mjs that registers tsx before its TypeScript.

Spinner and prompt hint

Nothing to set up: the hooks module turns the spinner's words into the session's character's verbs and the prompt hint into one of their lines under their name, a new one each turn, names the character among the labels at the right of the prompt's footer, and publishes the character's colour, which genshin-mods draws its band in. Each session draws its own character, and a switch shows at once. Why each is shaped as it is: the persona plugin page.

The plugin writes nothing into your settings, so your own status line stays yours. To have it show the character's nameplate, the name in their colour on a badge of that colour's tone, point it at the script the plugin ships:

{ "statusLine": { "command": "node \"<plugin root>/scripts/statusLine.mjs\"", "type": "command" } }

Languages

Three settings, and they are not one axis: the interface language is every word the plugin writes and the master toggle, the reply language is what the model writes in and follows the interface language until set on its own, and the dub says whose voice reads a line and is neither, because it comes from a set of four and costs an install:

node "<plugin root>/scripts/genshin.mjs" language japanese   # or: /genshin-language japanese
node "<plugin root>/scripts/genshin.mjs" reply english       # labels in one language, prose in another
node "<plugin root>/scripts/genshin.mjs" status              # every setting, and where each value came from

The languages on offer are the data package's, so language with no argument lists them, each in its own words beside the word you type for it, and either resolves. What arrives localized for nothing and what is written by hand per language is the persona plugin page's.

How the character is picked

Every playable character comes from the game-data dependency — no generated roster, so a new patch is one dependency bump. Without a TypeSafe key the character is whoever's birthday is nearest to today, and the welcome names the other birthdays of the week ahead; with one, a single typed decision picks from the whole roster at every session start, and the welcome charts its answer — the choice and the nearest runners-up with their probabilities — or says why the tier gave none and the birthday pick stood in. Once voice has set a dub up, the welcome carries one line about it too. The pick is recorded against the session id, so a compact or resume after midnight keeps the character the conversation started with; a clear is a new session id and a fresh pick.

Switching mid-session

use <name> makes this session speak as one character from that reply on; pin <name> fixes one for every session from the next start and this one at once; unpin hands both back to the pick:

node "<plugin root>/scripts/genshin.mjs" use furina      # or: /genshin-use furina

Spoken lines

A reply to an ask of the assistant opens with one blockquote line in the character's voice and may close with one; a reply to an ask of the character — a joke, a hello, an opinion, however much was pasted to form it — is such lines and nothing else, as many as the ask deserves, a list of them included. A hook hands each piece of the reply to a resident synthesizer as it lands, which reads the lines in the order they were written, in the character's cloned voice, while the reply is still being written. mute and unmute decide whether the hook asks it at all, and so does a volume of zero. volume <number> is a whole number from 0 to 100, applied as a gain, from the next reply on:

node "<plugin root>/scripts/genshin.mjs" volume 60     # or: /genshin-volume 60

teardown removes the runtime, the weights, the cached clips and the dub, and spoken lines stay silent until voice sets them up again.

Commands

Run from packages/genshin-persona/:

pnpm test         # vitest watch mode (coverage is run from the repo root)
pnpm lint:fix     # auto-fix lint
pnpm typecheck    # type check

The plugin holds no image, audio or text from the game: the character data arrives through its MIT-licensed dependency, and every persona card is written in our own words.

<a name="license">⚖️ License</a>

This project is licensed under the Apache-2.0 license.

[badge-license]: https://img.shields.io/github/license/Esposter/Esposter.svg?color=blue [url-license]: https://github.com/Esposter/Esposter/blob/main/LICENSE [badge-npm-version]: https://img.shields.io/npm/v/genshin-persona/latest?color=brightgreen [url-npm]: https://www.npmjs.com/package/genshin-persona/v/latest [badge-npm-unpacked-size]: https://img.shields.io/npm/unpacked-size/genshin-persona/latest?label=npm [badge-npm-downloads]: https://img.shields.io/npm/dm/genshin-persona.svg

Source 6 files
mod/register.ts 141 lines
1import type { EngineInterface, Register } from "claude-code";
2
3import { atom, read, update } from "claude-code";
4
5import type { PersonaSpinner, PersonaStatus } from "../types";
6
7import { hashString } from "../src/util/hashString";
8import { CardVerbs } from "./CardVerbs";
9import { VerbDescriptionMap } from "./VerbDescriptionMap";
10
11// The persona's surfaces drawn by the engine itself, so nothing is written into the person's settings: a spinner and
12// A hint of this session's own character, its colour published for the mods, the spoken replies and one command per
13// Verb. Every read of game data, state or audio is the plugin's node scripts, run as commands, since a hooks module
14// Has no Node
15const characterAtom = atom({ key: "character", plugin: "genshin-persona" } as const, {
16  color: "",
17  displayName: "",
18  name: "",
19});
20const isCharacterRecordedAtom = atom({ key: "isCharacterRecorded", plugin: "genshin-persona" } as const, false);
21const spinnerAtom = atom({ key: "spinner", plugin: "genshin-persona" } as const, { label: "", tips: [], verbs: [] });
22const tipIndexAtom = atom({ key: "tipIndex", plugin: "genshin-persona" } as const, 0);
23// The spinner's lines cost the data package or the wiki on a first read, so its script gets the longest run allowed
24const SPINNER_TIMEOUT_MS = Temporal.Duration.from({ minutes: 10 }).total("milliseconds");
25
26const readStateDirectory = async ($: EngineInterface) => {
27  const home = (await $.env.get("HOME")) ?? (await $.env.get("USERPROFILE")) ?? "";
28  return `${home}/.claude/genshin-persona`;
29};
30
31// The session's character, from the record its start hook wrote, else the pin, else the birthday pick; the spinner
32// Is read again only when the character or the language that spells them changed
33const refresh = async ($: EngineInterface) => {
34  const sessionId = await $.session.id();
35  const status = await $.process.run(["node", `${$.plugin.root}/scripts/status.mjs`], {
36    stdin: JSON.stringify({ session_id: sessionId }),
37  });
38  if (!status.stdout.trim()) return;
39  // oxlint-disable-next-line no-restricted-properties -- The plugin's own script prints this shape, with no date in it, and a mod cannot import the shared reviver
40  const { character, isRecorded } = JSON.parse(status.stdout) as PersonaStatus;
41  await update($, isCharacterRecordedAtom, () => isRecorded);
42  const previous = await read($, characterAtom);
43  await update($, characterAtom, () => character);
44  if (previous.name === character.name && previous.displayName === character.displayName) return;
45
46  const spinner = await $.process.run(["node", `${$.plugin.root}/scripts/spinner.mjs`, character.name], {
47    timeoutMs: SPINNER_TIMEOUT_MS,
48  });
49  // oxlint-disable-next-line no-restricted-properties -- The plugin's own script prints this shape, with no date in it, and a mod cannot import the shared reviver
50  if (spinner.stdout.trim()) await update($, spinnerAtom, () => JSON.parse(spinner.stdout) as PersonaSpinner);
51};
52
53// Off the dispatch that asked, so neither the session's start nor a verb's answer waits on a node start
54const refreshLater = ($: EngineInterface) => {
55  $.clock.after(0, () => {
56    // oxlint-disable-next-line typescript/no-floating-promises -- The engine's timer slot takes no promise
57    refresh($);
58  });
59};
60
61export const register: Register = (on) => {
62  on("session.start", async ($, e, next) => {
63    await Promise.all(
64      Object.entries(VerbDescriptionMap).map(([verb, description]) =>
65        $.command.register({ argumentHint: "[name or value]", description, name: `genshin-${verb}` }),
66      ),
67    );
68    refreshLater($);
69    return next(e);
70  });
71
72  // Each verb runs the plugin's own script in this session, which the script reads off the environment, and the
73  // Character it may have switched is read again behind the answer
74  for (const verb of Object.keys(VerbDescriptionMap))
75    on("command.run", { command: `genshin-${verb}` }, async ($, e) => {
76      const sessionId = await $.session.id();
77      const { stderr, stdout } = await $.process.run(
78        ["node", `${$.plugin.root}/scripts/genshin.mjs`, verb, ...e.args.split(" ").filter(Boolean)],
79        { env: { CLAUDE_CODE_SESSION_ID: sessionId } },
80      );
81      refreshLater($);
82      const text = [stdout, stderr].filter(Boolean).join("\n").trim();
83      return CardVerbs.some((cardVerb) => cardVerb === verb)
84        ? { context: ["Answer as the card this printed from this reply on."], text }
85        : { text };
86    });
87
88  // A `/clear` or a resume carries on under another session id with no `session.start` for it, and the start hook
89  // Picks for that id, so the character is unsettled again until its record is read
90  on("session.end", async ($, e, next) => {
91    if (e.reason === "clear" || e.reason === "resume") await update($, isCharacterRecordedAtom, () => false);
92    return next(e);
93  });
94
95  // The character is read again at each turn's start until the start hook's record exists, since the pick may land
96  // After the session start's first read
97  on("turn.start", async ($, e, next) => {
98    if (!(await read($, isCharacterRecordedAtom))) refreshLater($);
99    return next(e);
100  });
101
102  on("turn.complete", async ($, e, next) => {
103    const result = await next(e);
104    if (e.agentId === undefined) await update($, tipIndexAtom, (index) => index + 1);
105    return result;
106  });
107
108  // The engine samples a word per turn; the same word always maps to the same verb of this session's character
109  on("ui.render", { component: "Spinner" }, async ($, e, next) => {
110    const { verbs } = await read($, spinnerAtom);
111    if (verbs.length === 0 || e.props.message !== null) return next(e);
112    const word = verbs[hashString(e.props.word) % verbs.length] ?? e.props.word;
113    return next({ ...e, props: { ...e.props, word } });
114  });
115
116  on("ui.render", { component: "PromptHint" }, async ($, e, next) => {
117    const { label, tips } = await read($, spinnerAtom);
118    const tip = tips[(await read($, tipIndexAtom)) % Math.max(tips.length, 1)];
119    if (!tip || e.props.isDraft) return next(e);
120    else return next({ ...e, props: { ...e.props, tail: `${label}: ${tip}` } });
121  });
122
123  // The character named among the footer's mode labels, where a standing word about the session belongs: the status
124  // Line is a settings command no plugin can set, and a label added to `modes` leaves every other plugin's in place
125  on("ui.render", { component: "SessionMode" }, async ($, e, next) => {
126    const { displayName } = await read($, characterAtom);
127    if (displayName) return next({ ...e, props: { ...e.props, modes: [...e.props.modes, displayName] } });
128    else return next(e);
129  });
130
131  // Every flushed piece of a reply, handed to the speak script as the settings hook handed it; a session with no
132  // Voice set up, or muted, pays a file check and no node start
133  on("classic.MessageDisplay", async ($, e, next) => {
134    const stateDirectory = await readStateDirectory($);
135    const isSpeaking =
136      (await $.fs.exists(`${stateDirectory}/voice-language`)) && !(await $.fs.exists(`${stateDirectory}/muted`));
137    if (isSpeaking) await $.process.run(["node", `${$.plugin.root}/scripts/speak.mjs`], { stdin: JSON.stringify(e) });
138    return next(e);
139  });
140};
141
src/util/hashString.ts 15 lines
1// FNV-1a over UTF-16 code units: a few lines, stable across runtimes, and the same seed always lands on the same
2// Candidate, which is all a day's tie-break needs
3const FNV_OFFSET_BASIS = 0x81_1c_9d_c5;
4const FNV_PRIME = 0x01_00_01_93;
5
6export const hashString = (value: string): number => {
7  let hash = FNV_OFFSET_BASIS;
8  for (const character of value) {
9    hash ^= character.codePointAt(0) ?? 0;
10    hash = Math.imul(hash, FNV_PRIME) >>> 0;
11  }
12
13  return hash;
14};
15
mod/CardVerbs.ts 10 lines
1import { GenshinVerb } from "../src/models/GenshinVerb";
2
3// The verbs that print a card, which the model answers as from the reply that relays it
4export const CardVerbs: readonly GenshinVerb[] = [
5  GenshinVerb.Language,
6  GenshinVerb.Pin,
7  GenshinVerb.Unpin,
8  GenshinVerb.Use,
9];
10
mod/VerbDescriptionMap.ts 23 lines
1import { GenshinVerb } from "../src/models/GenshinVerb";
2
3// Each verb a person runs, as `/genshin-<verb>`, and what the command menu says of it; the authoring verbs are the
4// `genshin-author` skill's and have no command
5export const VerbDescriptionMap: Partial<Record<GenshinVerb, string>> = {
6  [GenshinVerb.Language]:
7    "Sets the language every word the plugin writes is in, and the reply language with it; given nothing, reports what is set.",
8  [GenshinVerb.Mute]: "Stops replies' spoken lines being read aloud. The pick and the card are unaffected.",
9  [GenshinVerb.Pin]: "Fixes one character for every session until unpinned, this one from this reply on.",
10  [GenshinVerb.Reply]: "Sets the language replies are written in, on its own; given nothing, reports what is set.",
11  [GenshinVerb.Roster]: "Every playable character, one line each.",
12  [GenshinVerb.Status]: "Reports every setting the plugin holds and where each came from. Changes nothing.",
13  [GenshinVerb.Teardown]:
14    "Removes the voice's runtime, weights, references and dub; the picks, the pin and the languages stay.",
15  [GenshinVerb.Today]: "The card of this session's character.",
16  [GenshinVerb.Unmute]: "Lets replies' spoken lines be read aloud again.",
17  [GenshinVerb.Unpin]: "Removes the pin; the pick decides again, for this session from this reply on.",
18  [GenshinVerb.Use]: "Speaks as one character for this session alone, from this reply on.",
19  [GenshinVerb.Voice]:
20    "Sets up spoken replies in the character's own cloned voice, or switches the dub (en, ja, ko or zh); given nothing, reports what is installed.",
21  [GenshinVerb.Volume]: "Sets how loud replies are spoken, as a whole number from 0 to 100.",
22};
23
src/models/GenshinVerb.ts 22 lines
1export enum GenshinVerb {
2  Language = "language",
3  Lines = "lines",
4  Mute = "mute",
5  Pin = "pin",
6  Reply = "reply",
7  Roster = "roster",
8  Status = "status",
9  Teardown = "teardown",
10  Today = "today",
11  Uncarded = "uncarded",
12  Unmute = "unmute",
13  Unpin = "unpin",
14  Untranslated = "untranslated",
15  Unverbed = "unverbed",
16  Use = "use",
17  Voice = "voice",
18  Volume = "volume",
19}
20
21export const GenshinVerbs: readonly GenshinVerb[] = Object.values(GenshinVerb);
22
types/index.d.ts 38 lines
1// The plugin's state contract, the one self-contained file the engine validates the hooks module's `$.state` keys
2// Against; the plugin's own scripts print these shapes and import them from here, so each is written once
3// What the hooks module reads of the session's character: the colour the mods draw their accent in, already readable
4// On a dark terminal, and the names a switch compares — the English one the identity, the display one the language's
5export interface PersonaCharacter {
6  // A colour readable on a dark terminal, "" for a character with neither a colour nor an element of their own
7  color: string;
8  displayName: string;
9  name: string;
10}
11
12// Everything the spinner and the prompt hint show for one character. The verbs carry both layers, the base Teyvat
13// Content with the character's own behind it; the tips are the character's lines, shown under their name
14export interface PersonaSpinner {
15  // The character's name as the interface language spells it, in front of every tip
16  label: string;
17  tips: string[];
18  verbs: string[];
19}
20
21// What the status script prints: the character, and whether it is this session's own record rather than the pin or
22// The birthday pick standing in while the start hook is still recording
23export interface PersonaStatus {
24  character: PersonaCharacter;
25  isRecorded: boolean;
26}
27
28declare module "claude-code" {
29  interface PluginState {
30    "genshin-persona": {
31      character: PersonaCharacter;
32      isCharacterRecorded: boolean;
33      spinner: PersonaSpinner;
34      tipIndex: number;
35    };
36  }
37}
38