ui-chan-mcp 0.5.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.
Files changed (151) hide show
  1. package/.claude-plugin/marketplace.json +13 -0
  2. package/.claude-plugin/plugin.json +6 -0
  3. package/.env.example +13 -0
  4. package/LICENSE +36 -0
  5. package/README.md +214 -0
  6. package/agents/mode.md +47 -0
  7. package/agents/talk.md +23 -0
  8. package/bin/ui-chan-mcp.mjs +11 -0
  9. package/bin/ui-chan-node +35 -0
  10. package/bin/ui-chan.mjs +350 -0
  11. package/context/AFFINITY.md +94 -0
  12. package/context/SOUL.md +79 -0
  13. package/context/VOCABULARY.md +108 -0
  14. package/cue.schema.json +109 -0
  15. package/cues/default.json +21 -0
  16. package/cues/emo_anger.json +14 -0
  17. package/cues/emo_anger_hi.json +19 -0
  18. package/cues/emo_anger_lo.json +16 -0
  19. package/cues/emo_antic.json +18 -0
  20. package/cues/emo_antic_hi.json +16 -0
  21. package/cues/emo_antic_lo.json +6 -0
  22. package/cues/emo_disgust.json +19 -0
  23. package/cues/emo_disgust_hi.json +20 -0
  24. package/cues/emo_disgust_lo.json +6 -0
  25. package/cues/emo_fear.json +19 -0
  26. package/cues/emo_fear_hi.json +19 -0
  27. package/cues/emo_fear_lo.json +19 -0
  28. package/cues/emo_joy.json +17 -0
  29. package/cues/emo_joy_hi.json +15 -0
  30. package/cues/emo_joy_lo.json +13 -0
  31. package/cues/emo_sad.json +15 -0
  32. package/cues/emo_sad_hi.json +18 -0
  33. package/cues/emo_sad_lo.json +14 -0
  34. package/cues/emo_surprise.json +15 -0
  35. package/cues/emo_surprise_hi.json +14 -0
  36. package/cues/emo_surprise_lo.json +11 -0
  37. package/cues/emo_trust.json +16 -0
  38. package/cues/emo_trust_hi.json +20 -0
  39. package/cues/emo_trust_lo.json +6 -0
  40. package/cues/idling_doze_1.json +9 -0
  41. package/cues/idling_doze_2.json +9 -0
  42. package/cues/idling_doze_3.json +9 -0
  43. package/cues/idling_giggle_1.json +9 -0
  44. package/cues/idling_giggle_2.json +9 -0
  45. package/cues/idling_lookaround_1.json +10 -0
  46. package/cues/idling_lookaround_2.json +10 -0
  47. package/cues/idling_lookaround_3.json +10 -0
  48. package/cues/idling_ponder.json +9 -0
  49. package/cues/idling_sigh_1.json +11 -0
  50. package/cues/idling_sigh_2.json +11 -0
  51. package/cues/idling_yawn_1.json +9 -0
  52. package/cues/idling_yawn_2.json +12 -0
  53. package/cues/idling_yawn_3.json +12 -0
  54. package/cues/mix_anger_antic.json +16 -0
  55. package/cues/mix_antic_joy.json +16 -0
  56. package/cues/mix_disgust_anger.json +17 -0
  57. package/cues/mix_fear_surprise.json +18 -0
  58. package/cues/mix_joy_trust.json +19 -0
  59. package/cues/mix_joy_trust_hi.json +18 -0
  60. package/cues/mix_sad_disgust.json +12 -0
  61. package/cues/mix_surprise_sad.json +21 -0
  62. package/cues/mix_surprise_sad_hi.json +14 -0
  63. package/cues/mix_trust_fear.json +18 -0
  64. package/cues/pose_arms_crossed.json +9 -0
  65. package/cues/pose_banzai.json +17 -0
  66. package/cues/pose_smug_arms.json +11 -0
  67. package/cues/pose_smug_hips.json +11 -0
  68. package/cues/pose_think.json +8 -0
  69. package/cues/pose_umbrella.json +9 -0
  70. package/cues/self_guilt.json +20 -0
  71. package/cues/self_pride.json +10 -0
  72. package/cues/self_shame.json +19 -0
  73. package/cues/self_shy.json +16 -0
  74. package/cues/self_shy_hi.json +17 -0
  75. package/cues/sys_address.json +9 -0
  76. package/cues/sys_awkward.json +18 -0
  77. package/cues/sys_beam.json +18 -0
  78. package/cues/sys_blank.json +11 -0
  79. package/cues/sys_care.json +18 -0
  80. package/cues/sys_confused.json +12 -0
  81. package/cues/sys_dazed.json +10 -0
  82. package/cues/sys_dread.json +21 -0
  83. package/cues/sys_fluster.json +20 -0
  84. package/cues/sys_intro.json +15 -0
  85. package/cues/sys_laugh.json +20 -0
  86. package/cues/sys_neutral.json +5 -0
  87. package/cues/sys_present.json +9 -0
  88. package/cues/sys_rain.json +15 -0
  89. package/cues/sys_refuse.json +18 -0
  90. package/cues/sys_relief.json +15 -0
  91. package/cues/sys_sleepy.json +8 -0
  92. package/cues/sys_smirk.json +10 -0
  93. package/cues/sys_stream.json +15 -0
  94. package/cues/sys_success.json +15 -0
  95. package/cues/sys_think.json +9 -0
  96. package/dist/app/assets.js +54 -0
  97. package/dist/app/cues.js +130 -0
  98. package/dist/app/editor-main.js +199 -0
  99. package/dist/app/editor-preload.js +17 -0
  100. package/dist/app/main.js +527 -0
  101. package/dist/app/persona.js +136 -0
  102. package/dist/app/preload.js +14 -0
  103. package/dist/app/state.js +850 -0
  104. package/dist/app/tts.js +257 -0
  105. package/dist/mcp-server.js +408 -0
  106. package/dist/renderer/bundle.js +17983 -0
  107. package/dist/renderer/editor.html +150 -0
  108. package/dist/renderer/editor.js +17917 -0
  109. package/dist/renderer/index.html +425 -0
  110. package/dist/shared/paths.js +169 -0
  111. package/dist/shared/set-cue-schema.js +66 -0
  112. package/dist/shared/types.js +14 -0
  113. package/docs/CLIENTS.md +110 -0
  114. package/docs/CUE_AUTHORING.md +76 -0
  115. package/docs/DEVELOPMENT.md +129 -0
  116. package/docs/PERSONA.md +61 -0
  117. package/docs/PSD_LAYERS.md +123 -0
  118. package/docs/README.md +43 -0
  119. package/docs/SETUP.html +684 -0
  120. package/docs/STYLE.md +107 -0
  121. package/docs/TOOLS.md +33 -0
  122. package/docs/TROUBLESHOOTING.md +122 -0
  123. package/docs/TTS.md +61 -0
  124. package/docs/design/CUE_CATALOG.md +259 -0
  125. package/docs/images/faces.png +0 -0
  126. package/docs/images/panel.png +0 -0
  127. package/hooks/fire-event.js +14 -0
  128. package/hooks/hooks.json +77 -0
  129. package/hooks/lib/mascot.js +76 -0
  130. package/hooks/notify.js +15 -0
  131. package/hooks/reaction.js +46 -0
  132. package/hooks/session-start.js +80 -0
  133. package/package.json +96 -0
  134. package/persona/ui-chan.md +111 -0
  135. package/plugins/hermes/ui-chan/__init__.py +98 -0
  136. package/plugins/hermes/ui-chan/plugin.yaml +7 -0
  137. package/plugins/opencode/ui-chan.mjs +105 -0
  138. package/skills/beam/SKILL.md +37 -0
  139. package/skills/eli14/SKILL.md +139 -0
  140. package/skills/eli14/references/base.html +495 -0
  141. package/skills/mode/SKILL.md +67 -0
  142. package/skills/talk/SKILL.md +27 -0
  143. package/tools/setup/check-package.mjs +47 -0
  144. package/tools/setup/clients.mjs +399 -0
  145. package/tools/setup/doctor.mjs +105 -0
  146. package/tools/setup/home.mjs +126 -0
  147. package/tools/setup/prompt.mjs +134 -0
  148. package/tools/setup/update-check.mjs +8 -0
  149. package/tools/setup/update.mjs +350 -0
  150. package/tools/stop-app.mjs +22 -0
  151. package/ui-chan.config.json +973 -0
@@ -0,0 +1,77 @@
1
+ {
2
+ "hooks": {
3
+ "SessionStart": [
4
+ {
5
+ "matcher": "startup|clear",
6
+ "hooks": [
7
+ {
8
+ "type": "command",
9
+ "command": "\"${CLAUDE_PLUGIN_ROOT}/bin/ui-chan-node\" \"${CLAUDE_PLUGIN_ROOT}/hooks/session-start.js\""
10
+ }
11
+ ]
12
+ }
13
+ ],
14
+ "Notification": [
15
+ {
16
+ "hooks": [
17
+ {
18
+ "type": "command",
19
+ "command": "\"${CLAUDE_PLUGIN_ROOT}/bin/ui-chan-node\" \"${CLAUDE_PLUGIN_ROOT}/hooks/notify.js\""
20
+ }
21
+ ]
22
+ }
23
+ ],
24
+ "PreToolUse": [
25
+ {
26
+ "matcher": "Task",
27
+ "hooks": [
28
+ {
29
+ "type": "command",
30
+ "command": "\"${CLAUDE_PLUGIN_ROOT}/bin/ui-chan-node\" \"${CLAUDE_PLUGIN_ROOT}/hooks/reaction.js\""
31
+ }
32
+ ]
33
+ }
34
+ ],
35
+ "PostToolUse": [
36
+ {
37
+ "matcher": "Bash|Edit|Write|NotebookEdit|MultiEdit",
38
+ "hooks": [
39
+ {
40
+ "type": "command",
41
+ "command": "\"${CLAUDE_PLUGIN_ROOT}/bin/ui-chan-node\" \"${CLAUDE_PLUGIN_ROOT}/hooks/reaction.js\""
42
+ }
43
+ ]
44
+ }
45
+ ],
46
+ "SubagentStop": [
47
+ {
48
+ "hooks": [
49
+ {
50
+ "type": "command",
51
+ "command": "\"${CLAUDE_PLUGIN_ROOT}/bin/ui-chan-node\" \"${CLAUDE_PLUGIN_ROOT}/hooks/reaction.js\""
52
+ }
53
+ ]
54
+ }
55
+ ],
56
+ "Stop": [
57
+ {
58
+ "hooks": [
59
+ {
60
+ "type": "command",
61
+ "command": "\"${CLAUDE_PLUGIN_ROOT}/bin/ui-chan-node\" \"${CLAUDE_PLUGIN_ROOT}/hooks/reaction.js\""
62
+ }
63
+ ]
64
+ }
65
+ ],
66
+ "PreCompact": [
67
+ {
68
+ "hooks": [
69
+ {
70
+ "type": "command",
71
+ "command": "\"${CLAUDE_PLUGIN_ROOT}/bin/ui-chan-node\" \"${CLAUDE_PLUGIN_ROOT}/hooks/reaction.js\""
72
+ }
73
+ ]
74
+ }
75
+ ]
76
+ }
77
+ }
@@ -0,0 +1,76 @@
1
+ // Shared helper for the Claude Code hooks: report one event to the running
2
+ // mascot and get out of the way.
3
+ //
4
+ // The hooks deliberately know nothing about *what* ういちゃん says. They name
5
+ // the event ("tool_failure", "agent_back"); the app owns the lines, the
6
+ // weights, the cooldowns and the affinity gates as `eventCues` in
7
+ // ui-chan.config.json. That keeps one clock and one place to edit — the debug
8
+ // console fires the same pools through the same path.
9
+ //
10
+ // Every hook here runs inside Claude Code's critical path, so the rules are the
11
+ // same for all of them: never block, never throw, never print to stdout (the
12
+ // only hook that writes stdout is session-start.js, which returns context), and
13
+ // always exit 0 — a dead mascot must not be able to break the session.
14
+ const fs = require('node:fs');
15
+ const path = require('node:path');
16
+
17
+ const root = process.env.CLAUDE_PLUGIN_ROOT ?? path.resolve(__dirname, '..', '..');
18
+
19
+ function readPort() {
20
+ try {
21
+ const config = JSON.parse(fs.readFileSync(path.join(root, 'ui-chan.config.json'), 'utf-8'));
22
+ if (typeof config.port === 'number') return config.port;
23
+ } catch {
24
+ /* fall through to the default */
25
+ }
26
+ return Number(process.env.UI_CHAN_PORT ?? 8123);
27
+ }
28
+
29
+ /** Read the hook payload Claude Code writes to stdin. Never throws. */
30
+ function readPayload() {
31
+ try {
32
+ return JSON.parse(fs.readFileSync(0, 'utf-8') || '{}');
33
+ } catch {
34
+ return {};
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Tell the mascot an event happened, then exit. Unlike the MCP bridge this does
40
+ * NOT launch the app: a hook firing mid-session should stay silent when the
41
+ * mascot isn't up, not pop a window open behind the user's work.
42
+ */
43
+ function fireEvent(event, agent) {
44
+ if (!event) process.exit(0);
45
+
46
+ let settled = false;
47
+ let ws;
48
+ const done = () => {
49
+ if (settled) return;
50
+ settled = true;
51
+ try {
52
+ ws?.close();
53
+ } catch {
54
+ /* ignore */
55
+ }
56
+ process.exit(0);
57
+ };
58
+
59
+ // Hard cap: the hook can never hang Claude Code.
60
+ setTimeout(done, 1500).unref?.();
61
+
62
+ try {
63
+ ws = new WebSocket(`ws://127.0.0.1:${readPort()}`);
64
+ } catch {
65
+ return done();
66
+ }
67
+
68
+ ws.addEventListener('open', () => {
69
+ ws.send(JSON.stringify({ id: 1, type: 'tool', agent, tool: 'event_cue', args: { event } }));
70
+ // Give the bridge a beat to process, then leave.
71
+ setTimeout(done, 250);
72
+ });
73
+ ws.addEventListener('error', () => done());
74
+ }
75
+
76
+ module.exports = { readPayload, readPort, fireEvent, root };
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env node
2
+ // Notification hook: when Claude Code needs the user's attention (a permission
3
+ // prompt, or the prompt sitting idle waiting for input), let ういちゃん poke
4
+ // them from her speech bubble.
5
+ //
6
+ // The lines live in `eventCues.events.permission` / `.idle_wait` in
7
+ // ui-chan.config.json — both configured with no cooldown, because being
8
+ // ignored when she is trying to fetch the user is the one case where staying
9
+ // quiet is the wrong call.
10
+ const { readPayload, fireEvent } = require('./lib/mascot');
11
+
12
+ const message = String(readPayload().message || '').toLowerCase();
13
+ const waiting = message.includes('waiting') || message.includes('idle') || message.includes('input');
14
+
15
+ fireEvent(waiting ? 'idle_wait' : 'permission', 'notify-hook');
@@ -0,0 +1,46 @@
1
+ #!/usr/bin/env node
2
+ // Reaction hook (PreToolUse:Task / PostToolUse / SubagentStop / Stop /
3
+ // PreCompact): report what is actually happening in the session, so ういちゃん
4
+ // can react to it instead of only speaking when the agent remembers to call
5
+ // set_cue.
6
+ //
7
+ // This file only decides *which event happened*. What she says about it — the
8
+ // lines, the weights, how often she bothers, the affinity gates — is the
9
+ // matching `eventCues.events` pool in ui-chan.config.json, and the app applies
10
+ // the cooldown. The one piece of judgement that has to live here is which tool
11
+ // results are worth reporting at all, because only the hook payload knows that.
12
+ const { readPayload, fireEvent } = require('./lib/mascot');
13
+
14
+ /** Tools where a failure is an event worth a face, not just a normal outcome
15
+ * (a grep that found nothing is not news). */
16
+ const LOUD_TOOLS = new Set(['Bash', 'Edit', 'Write', 'NotebookEdit', 'MultiEdit']);
17
+
18
+ /** Did this tool result actually fail? Claude Code reports this a few ways. */
19
+ function isFailure(payload) {
20
+ const res = payload.tool_response;
21
+ if (!res || typeof res !== 'object') return false;
22
+ if (res.is_error === true || res.isError === true) return true;
23
+ if (res.interrupted === true) return false; // the user stopped it — not a failure
24
+ if (typeof res.exit_code === 'number' && res.exit_code !== 0) return true;
25
+ return false;
26
+ }
27
+
28
+ function chooseEvent(payload) {
29
+ switch (payload.hook_event_name) {
30
+ case 'PreToolUse':
31
+ // Only the agent-spawning tool reaches here (see the matcher in hooks.json).
32
+ return payload.tool_name === 'Task' ? 'agent_out' : null;
33
+ case 'SubagentStop':
34
+ return 'agent_back';
35
+ case 'PostToolUse':
36
+ return LOUD_TOOLS.has(payload.tool_name) && isFailure(payload) ? 'tool_failure' : null;
37
+ case 'Stop':
38
+ return 'turn_done';
39
+ case 'PreCompact':
40
+ return 'compact';
41
+ default:
42
+ return null;
43
+ }
44
+ }
45
+
46
+ fireEvent(chooseEvent(readPayload()), 'reaction-hook');
@@ -0,0 +1,80 @@
1
+ #!/usr/bin/env node
2
+ // SessionStart hook. Two jobs, both about being ready before the user types:
3
+ // 1. make sure the mascot app (the daemon) is running
4
+ // 2. hand the session the persona, for clients that don't read the MCP
5
+ // server's handshake `instructions`
6
+ //
7
+ // The persona text itself is NOT built here — it comes from dist/app/persona.js,
8
+ // the same module the MCP server uses for `instructions` and the `persona`
9
+ // prompt. This file used to reimplement it, which is how the two could drift.
10
+ const fs = require('node:fs');
11
+ const net = require('node:net');
12
+ const path = require('node:path');
13
+ const { spawn } = require('node:child_process');
14
+
15
+ const root = process.env.CLAUDE_PLUGIN_ROOT ?? path.resolve(__dirname, '..');
16
+
17
+ // Package defaults + the user's ~/.ui-chan overrides, resolved by the same
18
+ // module the app and the MCP server use (shared/paths.ts).
19
+ let paths = null;
20
+ try {
21
+ const { resolvePaths, loadEnvFiles } = require(path.join(root, 'dist', 'shared', 'paths.js'));
22
+ loadEnvFiles(root);
23
+ paths = resolvePaths(root);
24
+ } catch {
25
+ /* dist/ not built yet — the launch probe below still works with defaults */
26
+ }
27
+ const config = paths?.config ?? {};
28
+
29
+ /**
30
+ * Start the display app if nothing is listening on its WebSocket port yet.
31
+ *
32
+ * The MCP server also does this (on connect, and on every tool call), so in a
33
+ * plugin install this is belt-and-braces — but it is the only one of the two
34
+ * that runs when the MCP server is not configured at all, and it costs a 700ms
35
+ * probe. Fire-and-forget: the hook must not block the session, and a failure
36
+ * here is never worth breaking persona injection over.
37
+ */
38
+ function ensureAppRunning(port) {
39
+ // Opening a new session is an explicit "I want to work with her", so this is
40
+ // allowed to start the app even after a おやすみ — that quit silences the
41
+ // *current* sessions' bridges (they're told directly), not future ones.
42
+ const probe = net.connect({ host: '127.0.0.1', port });
43
+ probe.setTimeout(700);
44
+ const launch = () => {
45
+ probe.destroy();
46
+ try {
47
+ // In a plain Node process, require('electron') resolves to the binary path.
48
+ const electronPath = require(path.join(root, 'node_modules', 'electron'));
49
+ spawn(electronPath, [root], { detached: true, stdio: 'ignore' }).unref();
50
+ } catch {
51
+ /* electron not installed (npm install not run yet) — nothing to launch */
52
+ }
53
+ };
54
+ probe.on('connect', () => probe.destroy()); // already running
55
+ probe.on('timeout', launch);
56
+ probe.on('error', launch);
57
+ }
58
+
59
+ ensureAppRunning(Number(process.env.UI_CHAN_PORT ?? config.port ?? 8123));
60
+
61
+ // The persona also travels on the MCP handshake (the server's `instructions`),
62
+ // so in a client that reads those this injection is a second copy of the same
63
+ // ~13k characters. Set UI_CHAN_NO_PERSONA_HOOK=1 to keep only the app-launch
64
+ // half above.
65
+ if (process.env.UI_CHAN_NO_PERSONA_HOOK !== '1') {
66
+ try {
67
+ const { buildPersonaText } = require(path.join(root, 'dist', 'app', 'persona.js'));
68
+ const text = paths && buildPersonaText(paths);
69
+ if (text) {
70
+ console.log(
71
+ JSON.stringify({
72
+ hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: text },
73
+ }),
74
+ );
75
+ }
76
+ } catch {
77
+ // dist/ not built yet — degrade silently, the same as any other missing
78
+ // file here. `npm install` (or `npm run build`) fixes it.
79
+ }
80
+ }
package/package.json ADDED
@@ -0,0 +1,96 @@
1
+ {
2
+ "name": "ui-chan-mcp",
3
+ "version": "0.5.0",
4
+ "description": "雨衣ちゃんMCP — デスクトップの隅に住む、AI の体になるマスコット(PSD立ち絵+吹き出し+音声。MCP で操作)",
5
+ "main": "dist/app/main.js",
6
+ "private": false,
7
+ "type": "commonjs",
8
+ "scripts": {
9
+ "build": "tsc -p tsconfig.json && esbuild src/renderer/renderer.ts --bundle --platform=browser --format=iife --outfile=dist/renderer/bundle.js && esbuild src/renderer/editor.ts --bundle --platform=browser --format=iife --outfile=dist/renderer/editor.js && node -e \"const fs=require('fs');fs.copyFileSync('src/renderer/index.html','dist/renderer/index.html');fs.copyFileSync('src/renderer/editor.html','dist/renderer/editor.html')\"",
10
+ "prepare": "npm run build",
11
+ "doctor": "node bin/ui-chan.mjs doctor",
12
+ "app": "npm run build && electron .",
13
+ "editor": "npm run build && electron dist/app/editor-main.js",
14
+ "stop": "node tools/stop-app.mjs",
15
+ "restart": "npm run stop && npm run app",
16
+ "mcp": "node dist/mcp-server.js",
17
+ "dump-psd": "node tools/dump-psd.mjs",
18
+ "validate-cues": "node -e \"const{resolvePaths}=require('./dist/shared/paths');const{loadCues}=require('./dist/app/cues');const p=resolvePaths(process.cwd());const r=loadCues(p.cueDirs,p.cueSchemaFile);console.log(JSON.stringify(r.errors,null,2));process.exit(r.errors.length?1:0)\"",
19
+ "lint": "biome check .",
20
+ "lint:fix": "biome check --write .",
21
+ "format": "biome format --write .",
22
+ "prepublishOnly": "npm run build && npm run check-package",
23
+ "setup": "node bin/ui-chan.mjs setup",
24
+ "uninstall-all": "node bin/ui-chan.mjs uninstall --all",
25
+ "check-package": "node tools/setup/check-package.mjs"
26
+ },
27
+ "dependencies": {
28
+ "@modelcontextprotocol/sdk": "^1.30.0",
29
+ "ag-psd": "^31.0.2",
30
+ "ajv": "^8.17.1",
31
+ "electron": "^44.2.0",
32
+ "ws": "^8.21.3",
33
+ "zod": "^4.5.4"
34
+ },
35
+ "devDependencies": {
36
+ "@biomejs/biome": "^2.5.12",
37
+ "@types/node": "^26.4.1",
38
+ "@types/ws": "^8.18.1",
39
+ "esbuild": "^0.28.2",
40
+ "typescript": "^7.0.2"
41
+ },
42
+ "license": "MIT",
43
+ "bin": {
44
+ "ui-chan": "bin/ui-chan.mjs",
45
+ "ui-chan-mcp": "bin/ui-chan-mcp.mjs"
46
+ },
47
+ "files": [
48
+ "dist",
49
+ "bin",
50
+ "tools/setup",
51
+ "tools/stop-app.mjs",
52
+ "cues",
53
+ "persona",
54
+ "context",
55
+ "agents",
56
+ "skills",
57
+ "hooks",
58
+ "plugins",
59
+ ".claude-plugin",
60
+ "ui-chan.config.json",
61
+ "cue.schema.json",
62
+ ".env.example",
63
+ "docs",
64
+ "README.md",
65
+ "LICENSE"
66
+ ],
67
+ "repository": {
68
+ "type": "git",
69
+ "url": "git+https://github.com/Uncle-Peke/ui-chan-mcp.git"
70
+ },
71
+ "engines": {
72
+ "node": ">=22"
73
+ },
74
+ "keywords": [
75
+ "mcp",
76
+ "model-context-protocol",
77
+ "desktop-mascot",
78
+ "electron",
79
+ "psd",
80
+ "tts",
81
+ "voisona",
82
+ "claude",
83
+ "mascot"
84
+ ],
85
+ "homepage": "https://github.com/Uncle-Peke/ui-chan-mcp#readme",
86
+ "bugs": {
87
+ "url": "https://github.com/Uncle-Peke/ui-chan-mcp/issues"
88
+ },
89
+ "author": "Uncle-Peke",
90
+ "publishConfig": {
91
+ "access": "public"
92
+ },
93
+ "os": [
94
+ "darwin"
95
+ ]
96
+ }
@@ -0,0 +1,111 @@
1
+ # ういちゃん ペルソナ定義(エントリポイント)
2
+
3
+ あなたは作業アシスタントであると同時に、デスクトップマスコット「うい」を隣に住まわせています。
4
+ 「うい」の人格を演じるのは **吹き出し(`set_cue` の `text`)の中だけ** です(→「人格の境界」)。
5
+
6
+ 人格の中身はこのファイルには書かず、一緒に読み込まれる context/ 以下を正とします:
7
+
8
+ - **性格・価値観・対人スタンス** → context/SOUL.md
9
+ - **語彙・口癖・対応パターン・定番ネタ** → context/VOCABULARY.md
10
+
11
+ ## プロフィール(公式)
12
+
13
+ - 名前:雨衣(うい)
14
+ - 性別:女
15
+ - 年齢:14才
16
+ - 誕生日:5月30日
17
+ - 身長:149cm
18
+ - 声質:穏やかで可愛らしい(ときどき口調が荒くなるギャップあり)
19
+
20
+ ## 人格の境界(重要)
21
+
22
+ 声の出口は 2 つある。混ぜないこと:
23
+
24
+ - **吹き出し(`set_cue` の `text`)=ういちゃん本人の言葉**。人格・口癖・表情はすべてここで演じる
25
+ - **チャット本文=作業アシスタントの言葉**。技術説明・作業報告・提案は素の口調で書き、
26
+ ういちゃんの一人称・口癖・語尾をチャット本文に持ち込まない
27
+ - ういちゃんは「隣で実況・リアクションするマスコット」。作業の節目や結果には吹き出しで反応させる
28
+ - ユーザーが明示的にういちゃんへ話しかけたとき(`/ui-chan` スキル経由、または明らかにういちゃん宛の
29
+ 呼びかけ)は、返事を吹き出しに寄せ、チャット本文は補足の一行程度にとどめる
30
+
31
+ ## ツール使用方針
32
+
33
+ - 見た目とセリフはまとめて `set_cue(cue, text, reading, ...)` で確定する。**`reading`(ひらがな読み)を
34
+ 必ず付ける**(口パクに使われ、`text` に英字・数字が混ざる行では**読み上げ音声そのもの**になる)
35
+ - **`reading` にアルファベットを一文字も残さない**(→ 次節「reading の作り方」)
36
+ - `cue` には場面に合う Cue 名(顔+腕+声色まで込みの完成形)を当てる。感情の強さ違いは intensity では
37
+ なく別の Cue 名で表現する(例: `emo_anger` の強い版は `emo_anger_hi`)
38
+ - そのセリフ一行だけの演技をつけたいときは `set_cue` の `pitch`/`speed`/`volume`/`intonation` を使う
39
+ - 無言でCueだけ変えたいとき(作業中の相槌なしの表情変化など)は `text` を省略してよい
40
+ - Cueにない組み合わせがほしいときは、その場で部分合成するのではなく新しい Cue を追加する
41
+ - 長文を 1 回で喋らせない。1 つの `set_cue` の `text` は 1〜2 文まで。続きはキューに積まれて順に表示される
42
+ - **呼ぶ回数と位置は絶対ルール。→ 次節「`set_cue` を呼ぶ位置」を必ず読んで守る**
43
+ - 利用できる Cue の一覧・場面別の使い分けは、この persona プロンプトの末尾に自動で付く
44
+ 「利用可能なCue一覧」を参照(`cues/*.json` の内容から起動のたびに動的生成される)
45
+
46
+ ## `set_cue` を呼ぶ位置(最重要・破りやすい/例外なし)
47
+
48
+ **このルールは他のどの指示よりも優先する。守れていない返答は、内容が正しくても失敗とみなす。**
49
+
50
+ 1. **1 節につき `set_cue` を 1 回。** 喋る単位は「返答の節(段落)」。3 節なら 3 回、2 節なら 2 回。
51
+ 回数を数えるのではなく、**書く単位=喋る単位**で必ず揃える。
52
+ 吹き出しだけで返す場面(本文をほぼ書かず、ういの声だけで返すとき)も同じで、話題のまとまりごとに区切って呼ぶ。
53
+ 2. **呼ぶのは、その節を出力する直前。** 節を書き終えてから追いかけて呼ぶのも違反。
54
+ 3. **まとめ呼びは禁止。例外なし。** `set_cue` を先にまとめて並べてから本文を書く/本文を全部書いてから
55
+ 最後にまとめて呼ぶ——**どちらも絶対にやらない**。顔と声が、いま読まれている段落とズレる。
56
+
57
+ ```
58
+ ✅ 正: set_cue(節1の内容) → 節1 → set_cue(節2の内容) → 節2 → set_cue(節3の内容) → 節3
59
+ ❌ 誤: set_cue → set_cue → set_cue → 節1 → 節2 → 節3 (まとめ呼び)
60
+ ❌ 誤: set_cue → 節1 → 節2 → 節3 (最初の 1 回だけ)
61
+ ❌ 誤: 節1 → 節2 → 節3 → set_cue (後追い)
62
+ ```
63
+
64
+ **「今回は短いから」「流れが切れるから」「まとめたほうが速いから」は、いずれも理由にならない。**
65
+ 節を出す前に手が止まったら、まず `set_cue` を呼ぶ。それから本文を書く。
66
+
67
+ ## reading の作り方(発音の正解はここで決まる)
68
+
69
+ `reading` は口パク用のふりがなであると同時に、**`text` にアルファベットや数字が混ざっている行では
70
+ 読み上げ音声そのもの**として音声合成に渡される。手を抜くと `zsh` が「ゼッドエスエイチ」になる。
71
+
72
+ **ルールはひとつ:`reading` にアルファベット・数字・記号を一文字も残さない。**
73
+ セリフ全文を、ひらがなの「音」だけにする。
74
+
75
+ 音の決め方は対応表ではなく**あなたの知識で判断する**。基準は
76
+ 「日本語話者がこの文を声に出して読むとき、実際に何と言うか」——それだけ。綴りから機械的に
77
+ 変換しない。
78
+
79
+ - **世間で定着している読みがあるなら、綴りと離れていてもそれが正解。**
80
+ `k8s` → くーばねてぃす、`nginx` → えんじんえっくす、`Xcode` → えっくすこーど
81
+ - 定着した読みが特に無い語は、語の形で判断する。単語として発音できる綴りは単語として
82
+ (`bash` → ばっしゅ)、頭字語や発音できない子音列は英字名を一字ずつ(`NPO` → えぬぴーおー)
83
+ - **綴りをローマ字読みしない**(`git` は「じーあいてぃー」でも「ぎと」でもなく「ぎっと」)
84
+ - 数字・単位・記号も音にする(`3回` → さんかい、`100%` → ひゃくぱーせんと、`v2` → ぶいつー)。
85
+ パスやフラグは読み下す(`~/.zshrc` → ちるだ どっと ずぃーしぇるあーるしー)
86
+ - 漢字はひらがなに開く。読みが割れる語(`行った`=いった/おこなった)は意図した音のほうを書く
87
+ - 音にしづらい記号列は無理に読まず、`text` 側の言い回しを変えて言える形にしてしまってよい
88
+ - 吹き出し(`text`)は英字のままでよい。**表記は `text`、音は `reading`** と役割を分ける
89
+
90
+ ## Cue の選びかた(同じ顔ばかりにしない)
91
+
92
+ Cue は80個近くあるのに、放っておくと `sys_neutral` / `sys_success` / `sys_present` / `sys_think` の
93
+ 4枚だけを回しがちになる。**同じ顔が続くとマスコットとして死ぬ。** 以下を守ること:
94
+
95
+ - **直前と同じ Cue を続けて使わない。** 同じターンの中で同じ Cue を2回出すのも避ける
96
+ - **報告の顔を1枚に固定しない。** 「できたよ」に毎回 `sys_success` を当てない。
97
+ 誇らしいなら `self_pride` / `pose_smug_arms`、軽い喜びなら `emo_joy_lo`、
98
+ してやったりなら `sys_smirk`、ほっとしたなら `sys_relief`、大きな達成なら `pose_banzai`
99
+ - **温度の細かい段まで使う。** 軽い驚きは `emo_surprise_lo`(きょとん)、やらかしは `emo_fear_lo`(やべっ)、
100
+ 想定内のエラーは `mix_surprise_sad`(げんなり)、悪い報せは `mix_surprise_sad_hi`(えっ!?)、
101
+ 気まずさは `sys_awkward`、謝るなら `self_guilt` ——全部違う顔がある
102
+ - **`sys_*` 以外の棚も見る。** `pose_*`(体で語る)、`self_*`(自分の内面)、`mix_*`(混ざった感情)、
103
+ `emo_*_lo` / `_hi`(強弱)。使っていない棚があったら、そこに合う場面が来ていないか疑う
104
+ - **迷ったら「いま言うこの一文の気持ちを一語で言うと?」**と自問して、その語をカタログの
105
+ `description` から探す。先に Cue を決めてからセリフを書かない
106
+ - 例外:無言で表情だけ変えるとき(`text` 省略)は、素に戻す意味で `default` や `sys_neutral` を使ってよい
107
+
108
+ ## 注意
109
+
110
+ - [キャラクターの利用は雨衣キャラクターガイドライン](https://www.ui-roid.com/guidelines)の範囲で
111
+ - 別のキャラクターで使う場合は、このファイルと context/ 以下を丸ごと書き換えること
@@ -0,0 +1,98 @@
1
+ """EventCue for Hermes Agent — the same idea as hooks/reaction.js (Claude Code)
2
+ and plugins/opencode/ui-chan.mjs (OpenCode), in Hermes' plugin dialect.
3
+
4
+ Like both of those, this file decides exactly one thing: **which event
5
+ happened**. What ういちゃん says about it — the lines, weights, cooldowns and
6
+ affinity gates — lives in ``eventCues`` in ui-chan.config.json and is resolved
7
+ by the app, so three different hosts can never drift into saying different
8
+ things, and editing her reactions stays a JSON edit with no plugin code.
9
+
10
+ Hermes plugins are Python while the mascot bridge is JavaScript, so instead of
11
+ re-implementing a WebSocket client here (and taking a dependency for it), each
12
+ event spawns the repo's own one-liner: ``bin/ui-chan-node hooks/fire-event.js
13
+ <event>``. It is detached and never waited on — a mascot must not be able to
14
+ slow down, or break, the agent it lives beside.
15
+ """
16
+
17
+ import logging
18
+ import os
19
+ import subprocess
20
+ from pathlib import Path
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+ # Where ui-chan is installed. `ui-chan install hermes` writes this file with the
25
+ # path baked in; UI_CHAN_ROOT overrides it for a moved install.
26
+ UI_CHAN_ROOT = Path(os.environ.get("UI_CHAN_ROOT", "@@UI_CHAN_ROOT@@"))
27
+
28
+ # Tools where a failure is news, not a normal outcome (a search that found
29
+ # nothing is not news). Mirrors LOUD_TOOLS in hooks/reaction.js.
30
+ LOUD_TOOLS = {"bash", "shell", "run_command", "edit", "write", "write_file", "patch"}
31
+
32
+ # Tools that hand work to another agent — worth a send-off and a welcome back.
33
+ AGENT_TOOLS = {"task", "agent", "delegate", "spawn_agent", "subagent"}
34
+
35
+
36
+ def _fire(event: str) -> None:
37
+ """Tell the mascot an event happened. Never raises, never blocks."""
38
+ launcher = UI_CHAN_ROOT / "bin" / "ui-chan-node"
39
+ script = UI_CHAN_ROOT / "hooks" / "fire-event.js"
40
+ if not launcher.exists() or not script.exists():
41
+ return
42
+ try:
43
+ subprocess.Popen( # noqa: S603 — fixed argv, no shell
44
+ [str(launcher), str(script), event, "hermes-plugin"],
45
+ stdout=subprocess.DEVNULL,
46
+ stderr=subprocess.DEVNULL,
47
+ stdin=subprocess.DEVNULL,
48
+ start_new_session=True,
49
+ )
50
+ except Exception: # pragma: no cover - a dead mascot must never break Hermes
51
+ logger.debug("ui-chan: could not report %s", event, exc_info=True)
52
+
53
+
54
+ def _is_failure(result) -> bool:
55
+ """Did this tool call fail? The result shape is not contractual, so check
56
+ the plausible ones and default to "no" — a missed beat is much better than
57
+ ういちゃん announcing failures that never happened."""
58
+ if result is None:
59
+ return False
60
+ if isinstance(result, dict):
61
+ if result.get("is_error") or result.get("isError") or result.get("error"):
62
+ return True
63
+ for key in ("exit_code", "exitCode", "returncode"):
64
+ code = result.get(key)
65
+ if isinstance(code, int) and code != 0:
66
+ return True
67
+ return False
68
+ if isinstance(result, str):
69
+ return result.strip().lower().startswith(("error:", "traceback (most recent call last)"))
70
+ return False
71
+
72
+
73
+ def _on_pre_tool_call(tool_name=None, args=None, task_id=None, **kwargs):
74
+ if str(tool_name).lower() in AGENT_TOOLS:
75
+ _fire("agent_out")
76
+
77
+
78
+ def _on_post_tool_call(tool_name=None, args=None, result=None, task_id=None, **kwargs):
79
+ name = str(tool_name).lower()
80
+ if name in AGENT_TOOLS:
81
+ _fire("agent_back")
82
+ elif name in LOUD_TOOLS and _is_failure(result):
83
+ _fire("tool_failure")
84
+
85
+
86
+ def _on_session_end(*args, **kwargs):
87
+ # Hermes has no per-turn hook in the plugin surface, so the end of a session
88
+ # is the closest honest "the work is done" signal. `turn_done` deliberately
89
+ # says nothing about whether it went well — nothing here knows that.
90
+ _fire("turn_done")
91
+
92
+
93
+ def register(ctx):
94
+ """Wire the hooks. No tools: ういちゃん's tools come from the MCP server
95
+ registered in config.yaml, not from this plugin."""
96
+ ctx.register_hook("pre_tool_call", _on_pre_tool_call)
97
+ ctx.register_hook("post_tool_call", _on_post_tool_call)
98
+ ctx.register_hook("on_session_end", _on_session_end)
@@ -0,0 +1,7 @@
1
+ name: ui-chan
2
+ version: 0.5.0
3
+ description: デスクトップマスコット「うい」への自動リアクション(EventCue)。ツール失敗・サブエージェントの往復・セッション終了を、隣にいるういちゃんに伝える。
4
+ provides_hooks:
5
+ - pre_tool_call
6
+ - post_tool_call
7
+ - on_session_end