genshin-persona 4.0.0 → 4.1.0
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/.mcp.json +1 -0
- package/package.json +5 -3
- package/scripts/channel.mjs +5 -0
- package/scripts/channel.ts +98 -0
- package/scripts/pick.ts +2 -4
- package/scripts/status.ts +2 -4
- package/scripts/statusLine.ts +2 -4
- package/scripts/voice.ts +22 -16
- package/src/generated/CharacterLinesLoaderMap.ts +2 -3
- package/src/generated/genshinText/generated/GameTextLoaderMap.ts +2 -3
- package/src/generated/genshinText/generated/text/ChineseSimplified.json +397 -0
- package/src/generated/genshinText/generated/text/ChineseTraditional.json +397 -0
- package/src/generated/genshinText/generated/text/English.json +397 -0
- package/src/generated/genshinText/generated/text/French.json +397 -0
- package/src/generated/genshinText/generated/text/German.json +397 -0
- package/src/generated/genshinText/generated/text/Indonesian.json +397 -0
- package/src/generated/genshinText/generated/text/Italian.json +397 -0
- package/src/generated/genshinText/generated/text/Japanese.json +397 -0
- package/src/generated/genshinText/generated/text/Korean.json +397 -0
- package/src/generated/genshinText/generated/text/Portuguese.json +397 -0
- package/src/generated/genshinText/generated/text/Russian.json +397 -0
- package/src/generated/genshinText/generated/text/Spanish.json +397 -0
- package/src/generated/genshinText/generated/text/Thai.json +397 -0
- package/src/generated/genshinText/generated/text/Turkish.json +397 -0
- package/src/generated/genshinText/generated/text/Vietnamese.json +397 -0
- package/src/generated/genshinText/models/GameTextKey.ts +482 -5
- package/src/models/VoiceDeviceLadder.ts +4 -0
- package/src/models/VoiceModel.ts +5 -1
- package/src/models/VoiceRuntime.ts +1 -1
- package/src/models/VoiceSynthesizer.ts +11 -1
- package/src/models/VoiceTensor.ts +3 -4
- package/src/services/checkIsChannelRequestAllowed.ts +17 -0
- package/src/services/constants.ts +52 -8
- package/src/services/createChannelPage.ts +58 -0
- package/src/services/createChunkSplicer.ts +47 -0
- package/src/services/createVoiceSynthesizer.ts +152 -4
- package/src/services/getLorePickRequest.ts +5 -1
- package/src/services/getVoiceDeviceLadder.ts +7 -0
- package/src/services/installVoiceRuntime.ts +1 -0
- package/src/services/pickCharacterByLore.ts +4 -1
- package/src/services/readChannelLine.ts +19 -0
- package/src/services/readPersonaModule.ts +1 -0
- package/src/services/readStdinSessionId.ts +7 -0
- package/src/services/readVoiceDevice.ts +3 -2
- package/src/services/vocodeSpeechTokens.ts +26 -0
package/.mcp.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{ "mcpServers": { "genshin-persona": { "command": "node", "args": ["${CLAUDE_PLUGIN_ROOT}/scripts/channel.mjs"] } } }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "genshin-persona",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.1.0",
|
|
4
4
|
"description": "A Claude Code plugin that speaks as the Genshin Impact character whose birthday is nearest: an answer opens with one spoken line in the character's voice and the rest is plain, a joke or a hello is spoken whole, and every line is read aloud in the character's own cloned voice as the reply is written.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude-code",
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
},
|
|
25
25
|
"files": [
|
|
26
26
|
".claude-plugin",
|
|
27
|
+
".mcp.json",
|
|
27
28
|
"hooks",
|
|
28
29
|
"output-styles",
|
|
29
30
|
"runtime",
|
|
@@ -54,6 +55,7 @@
|
|
|
54
55
|
"typecheck": "tsc"
|
|
55
56
|
},
|
|
56
57
|
"dependencies": {
|
|
58
|
+
"@modelcontextprotocol/sdk": "^1.32.1",
|
|
57
59
|
"@typesafe-ai/sdk": "^0.6.0",
|
|
58
60
|
"@wasm-audio-decoders/ogg-vorbis": "^0.1.20",
|
|
59
61
|
"citty": "^0.2.2",
|
|
@@ -61,7 +63,7 @@
|
|
|
61
63
|
"tsx": "^4.23.15"
|
|
62
64
|
},
|
|
63
65
|
"engines": {
|
|
64
|
-
"node": ">=26.
|
|
66
|
+
"node": ">=26.11.1"
|
|
65
67
|
},
|
|
66
|
-
"gitHead": "
|
|
68
|
+
"gitHead": "68de4c22fa7591bf6f6cc0fab2c9a906d8c4d71a"
|
|
67
69
|
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
import type { AddressInfo } from "node:net";
|
|
3
|
+
|
|
4
|
+
import { checkIsChannelRequestAllowed } from "#src/services/checkIsChannelRequestAllowed";
|
|
5
|
+
import { createChannelPage } from "#src/services/createChannelPage";
|
|
6
|
+
import { readChannelLine } from "#src/services/readChannelLine";
|
|
7
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
8
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
9
|
+
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
10
|
+
import { randomBytes } from "node:crypto";
|
|
11
|
+
import { createServer } from "node:http";
|
|
12
|
+
|
|
13
|
+
// The chat window's MCP server, started with the session over stdio. It serves the page on the loopback under a token
|
|
14
|
+
// Printed once to the terminal, takes typed lines into the session as channel events, and offers the reply tool that
|
|
15
|
+
// Sends the plain half of an answer back to the page. The character's spoken lines need no forwarding: the message
|
|
16
|
+
// Display hook already reads them to the viewer
|
|
17
|
+
const token = randomBytes(16).toString("hex");
|
|
18
|
+
const replyStreams = new Set<ServerResponse>();
|
|
19
|
+
|
|
20
|
+
// The low-level Server keeps the raw request handlers and the channel notification, which McpServer's tool API does not expose
|
|
21
|
+
// oxlint-disable-next-line typescript/no-deprecated -- see the comment above
|
|
22
|
+
const mcp = new Server(
|
|
23
|
+
{ name: "genshin-persona", version: "4.0.0" },
|
|
24
|
+
{
|
|
25
|
+
capabilities: { experimental: { "claude/channel": {} }, tools: {} },
|
|
26
|
+
instructions:
|
|
27
|
+
'Lines typed at the desk arrive as <channel source="genshin-persona" sender="desk">. The character\'s spoken lines already reach the viewer, so do not forward them. When a desk line asks something, also call the reply tool with the plain half of the answer; the desk reads it on the chat page.',
|
|
28
|
+
},
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
mcp.setRequestHandler(ListToolsRequestSchema, () => ({
|
|
32
|
+
tools: [
|
|
33
|
+
{
|
|
34
|
+
description: "Show the plain half of an answer to a line typed at the desk on the chat page",
|
|
35
|
+
inputSchema: { properties: { text: { type: "string" } }, required: ["text"], type: "object" },
|
|
36
|
+
name: "reply",
|
|
37
|
+
},
|
|
38
|
+
],
|
|
39
|
+
}));
|
|
40
|
+
|
|
41
|
+
mcp.setRequestHandler(CallToolRequestSchema, (request) => {
|
|
42
|
+
// oxlint-disable-next-line error-handling/no-bare-error -- an unlisted tool name is the SDK's own error to report to the caller
|
|
43
|
+
if (request.params.name !== "reply") throw new Error(`Unknown tool ${request.params.name}`);
|
|
44
|
+
|
|
45
|
+
// oxlint-disable-next-line typescript/no-base-to-string -- the arguments are the caller's own JSON, and its text is the reply as sent
|
|
46
|
+
const text = String(request.params.arguments?.text ?? "");
|
|
47
|
+
for (const stream of replyStreams) stream.write(`event: reply\ndata: ${JSON.stringify({ text })}\n\n`);
|
|
48
|
+
return { content: [{ text: "sent", type: "text" }] };
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const writeEnded = (response: ServerResponse, statusCode: number) => {
|
|
52
|
+
response.writeHead(statusCode).end();
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const handleSay = (request: IncomingMessage, response: ServerResponse) => {
|
|
56
|
+
// Decoded as the stream arrives, so a character split across two chunks is joined rather than read as two bad bytes
|
|
57
|
+
request.setEncoding("utf8");
|
|
58
|
+
let body = "";
|
|
59
|
+
request.on("data", (chunk: string) => {
|
|
60
|
+
body += chunk;
|
|
61
|
+
});
|
|
62
|
+
request.on("end", () => {
|
|
63
|
+
const line = readChannelLine(body);
|
|
64
|
+
if (line === undefined) return writeEnded(response, 400);
|
|
65
|
+
|
|
66
|
+
mcp.notification({ method: "notifications/claude/channel", params: { content: line, meta: { sender: "desk" } } });
|
|
67
|
+
writeEnded(response, 204);
|
|
68
|
+
});
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const handleEvents = (request: IncomingMessage, response: ServerResponse) => {
|
|
72
|
+
response.writeHead(200, { "cache-control": "no-cache", "content-type": "text/event-stream" });
|
|
73
|
+
response.write(": open\n\n");
|
|
74
|
+
replyStreams.add(response);
|
|
75
|
+
request.on("close", () => replyStreams.delete(response));
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const http = createServer((request, response) => {
|
|
79
|
+
const url = new URL(request.url ?? "/", "http://127.0.0.1");
|
|
80
|
+
const [, presentedToken = "", route = ""] = url.pathname.split("/");
|
|
81
|
+
if (!checkIsChannelRequestAllowed(request.socket.remoteAddress, presentedToken, token))
|
|
82
|
+
return writeEnded(response, 404);
|
|
83
|
+
|
|
84
|
+
if (route === "" && request.method === "GET") {
|
|
85
|
+
response.writeHead(200, { "content-type": "text/html; charset=utf-8" }).end(createChannelPage());
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
if (route === "events" && request.method === "GET") return handleEvents(request, response);
|
|
89
|
+
if (route === "say" && request.method === "POST") return handleSay(request, response);
|
|
90
|
+
writeEnded(response, 404);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
http.listen(0, "127.0.0.1", () => {
|
|
94
|
+
const { port } = http.address() as AddressInfo;
|
|
95
|
+
console.error(`Chat into the session: http://127.0.0.1:${port}/${token}/`);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
await mcp.connect(new StdioServerTransport());
|
package/scripts/pick.ts
CHANGED
|
@@ -3,13 +3,12 @@ import { formatLoreChart } from "#src/services/formatLoreChart";
|
|
|
3
3
|
import { formatUpcomingBirthdays } from "#src/services/formatUpcomingBirthdays";
|
|
4
4
|
import { getCard } from "#src/services/getCard";
|
|
5
5
|
import { getSessionStartOutput } from "#src/services/getSessionStartOutput";
|
|
6
|
-
import { parseHookInput } from "#src/services/parseHookInput";
|
|
7
6
|
import { readInterfaceLanguage } from "#src/services/readInterfaceLanguage";
|
|
8
7
|
import { readLocalization } from "#src/services/readLocalization";
|
|
9
8
|
import { readPersonaCard } from "#src/services/readPersonaCard";
|
|
10
9
|
import { readReplyLanguage } from "#src/services/readReplyLanguage";
|
|
11
10
|
import { readRoster } from "#src/services/readRoster";
|
|
12
|
-
import {
|
|
11
|
+
import { readStdinSessionId } from "#src/services/readStdinSessionId";
|
|
13
12
|
import { readVoiceRemark } from "#src/services/readVoiceRemark";
|
|
14
13
|
import { registerQuietExit } from "#src/services/registerQuietExit";
|
|
15
14
|
import { resolveSessionCharacter } from "#src/services/resolveSessionCharacter";
|
|
@@ -18,8 +17,7 @@ import { resolveSessionCharacter } from "#src/services/resolveSessionCharacter";
|
|
|
18
17
|
// No card, and the output style answers plainly
|
|
19
18
|
registerQuietExit();
|
|
20
19
|
const today = Temporal.Now.plainDateISO();
|
|
21
|
-
const
|
|
22
|
-
const { session_id: sessionId = "" } = parseHookInput(input);
|
|
20
|
+
const sessionId = await readStdinSessionId();
|
|
23
21
|
// Both are a file read on this path: what was written to either was checked against the data package then, and the
|
|
24
22
|
// Roster cache is keyed by the interface language, so a language changed since is a cache miss and not a mismatch
|
|
25
23
|
const language = readInterfaceLanguage();
|
package/scripts/status.ts
CHANGED
|
@@ -1,16 +1,14 @@
|
|
|
1
1
|
import type { PersonaStatus } from "#types";
|
|
2
2
|
|
|
3
3
|
import { getPersonaCharacter } from "#src/services/getPersonaCharacter";
|
|
4
|
-
import { parseHookInput } from "#src/services/parseHookInput";
|
|
5
4
|
import { readSessionNameplate } from "#src/services/readSessionNameplate";
|
|
6
|
-
import {
|
|
5
|
+
import { readStdinSessionId } from "#src/services/readStdinSessionId";
|
|
7
6
|
import { registerQuietExit } from "#src/services/registerQuietExit";
|
|
8
7
|
|
|
9
8
|
// The session's character for the hooks module, as JSON: run at session start, after every verb and after each turn
|
|
10
9
|
// Until the session's own record exists
|
|
11
10
|
registerQuietExit();
|
|
12
|
-
const
|
|
13
|
-
const { session_id: sessionId = "" } = parseHookInput(input);
|
|
11
|
+
const sessionId = await readStdinSessionId();
|
|
14
12
|
const sessionNameplate = readSessionNameplate(sessionId);
|
|
15
13
|
if (sessionNameplate) {
|
|
16
14
|
const { isRecorded, nameplate } = sessionNameplate;
|
package/scripts/statusLine.ts
CHANGED
|
@@ -1,13 +1,11 @@
|
|
|
1
1
|
import { formatNameplate } from "#src/services/formatNameplate";
|
|
2
|
-
import { parseHookInput } from "#src/services/parseHookInput";
|
|
3
2
|
import { readSessionNameplate } from "#src/services/readSessionNameplate";
|
|
4
|
-
import {
|
|
3
|
+
import { readStdinSessionId } from "#src/services/readStdinSessionId";
|
|
5
4
|
import { registerQuietExit } from "#src/services/registerQuietExit";
|
|
6
5
|
|
|
7
6
|
// A status line command a person names in their own settings, since no plugin can set one: the session's nameplate.
|
|
8
7
|
// Claude Code hands it the session's id on stdin as it does a hook
|
|
9
8
|
registerQuietExit();
|
|
10
|
-
const
|
|
11
|
-
const { session_id: sessionId = "" } = parseHookInput(input);
|
|
9
|
+
const sessionId = await readStdinSessionId();
|
|
12
10
|
const sessionNameplate = readSessionNameplate(sessionId);
|
|
13
11
|
if (sessionNameplate) console.log(formatNameplate(sessionNameplate.nameplate));
|
package/scripts/voice.ts
CHANGED
|
@@ -90,27 +90,33 @@ const speak = async (request: SpeechRequest, player: AudioPlayer | undefined, ch
|
|
|
90
90
|
if (playerFailure) writeVoiceLog(`the player did not play: ${playerFailure}`);
|
|
91
91
|
};
|
|
92
92
|
let playback: Promise<void> = Promise.resolve();
|
|
93
|
-
for (const line of request.lines) {
|
|
93
|
+
for (const [lineIndex, line] of request.lines.entries()) {
|
|
94
|
+
let isSpoken = false;
|
|
95
|
+
// Each chunk plays as it is vocoded, and a line that stops short is cut where it stood; leaving this loop early
|
|
96
|
+
// Ends the generation behind it
|
|
97
|
+
const label = `reply ${request.messageId}, line ${lineIndex + 1}`;
|
|
94
98
|
// oxlint-disable-next-line no-await-in-loop -- One device: lines synthesize and play back one after another
|
|
95
|
-
const clip
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
99
|
+
for await (const clip of synthesizer.streamSpeech(line, speaker, label, checkIsSuperseded)) {
|
|
100
|
+
isSpoken = true;
|
|
101
|
+
if (synthesizer.device !== settledDevice) {
|
|
102
|
+
settledDevice = synthesizer.device;
|
|
103
|
+
writeVoiceDevice(settledDevice);
|
|
104
|
+
}
|
|
101
105
|
|
|
102
|
-
|
|
103
|
-
settledDevice = synthesizer.device;
|
|
104
|
-
writeVoiceDevice(settledDevice);
|
|
105
|
-
}
|
|
106
|
+
if (!player) continue;
|
|
106
107
|
|
|
107
|
-
|
|
108
|
+
// oxlint-disable-next-line no-await-in-loop -- One device: chunks play back one after another
|
|
109
|
+
await playback;
|
|
110
|
+
if (checkIsSuperseded()) return VoiceStatus.Superseded;
|
|
108
111
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
if (checkIsSuperseded()) return VoiceStatus.Superseded;
|
|
112
|
+
playback = play(player, clip);
|
|
113
|
+
}
|
|
112
114
|
|
|
113
|
-
|
|
115
|
+
if (!isSpoken) {
|
|
116
|
+
// oxlint-disable-next-line no-await-in-loop -- One device: lines synthesize and play back one after another
|
|
117
|
+
await playback;
|
|
118
|
+
return VoiceStatus.Error;
|
|
119
|
+
}
|
|
114
120
|
}
|
|
115
121
|
|
|
116
122
|
await playback;
|
|
@@ -1,10 +1,9 @@
|
|
|
1
|
+
/* eslint-disable no-restricted-syntax -- each language is its own chunk: the map is the entry every spinner read loads, so the other fourteen languages' lines, hundreds of kilobytes each, stay out of the eager graph */
|
|
1
2
|
import type { VoiceLine } from "#src/models/VoiceLine";
|
|
2
3
|
|
|
3
4
|
import { GameLanguage } from "#src/generated/genshinText/models/GameLanguage";
|
|
4
5
|
|
|
5
|
-
// Written by `pnpm -C scripts genshin:text write`, never by hand.
|
|
6
|
-
// Map is the entry every spinner read loads, so the import is the split that keeps the other fourteen languages'
|
|
7
|
-
// Lines, hundreds of kilobytes each, out of it
|
|
6
|
+
// Written by `pnpm -C scripts genshin:text write`, never by hand.
|
|
8
7
|
export const CharacterLinesLoaderMap: Record<GameLanguage, () => Promise<Record<string, VoiceLine[]>>> = {
|
|
9
8
|
[GameLanguage.ChineseSimplified]: async () =>
|
|
10
9
|
(await import("#src/generated/characterLines/ChineseSimplified.json", { with: { type: "json" } })).default,
|
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
// Copied from `genshin-text` by `pnpm -C scripts genshin:text write`, never edited by hand
|
|
2
|
+
/* eslint-disable no-restricted-syntax -- every language but English is its own chunk: the map is the entry every reader loads, so its dynamic imports keep the fourteen languages a page does not show out of the eager graph */
|
|
2
3
|
import type { GameText } from "#src/generated/genshinText/models/GameText";
|
|
3
4
|
|
|
4
5
|
import English from "#src/generated/genshinText/generated/text/English.json" with { type: "json" };
|
|
5
6
|
import { GameLanguage } from "#src/generated/genshinText/models/GameLanguage";
|
|
6
7
|
|
|
7
|
-
// Written by `pnpm -C scripts genshin:text write`, never by hand.
|
|
8
|
-
// On purpose: the map is the entry every reader loads, so the import is the split that keeps the fourteen languages
|
|
9
|
-
// A page does not show out of its eager graph
|
|
8
|
+
// Written by `pnpm -C scripts genshin:text write`, never by hand.
|
|
10
9
|
export const ENGLISH_GAME_TEXT: GameText = English;
|
|
11
10
|
export const GameTextLoaderMap: Record<GameLanguage, () => Promise<GameText>> = {
|
|
12
11
|
[GameLanguage.ChineseSimplified]: async () =>
|