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.
- package/.claude-plugin/marketplace.json +13 -0
- package/.claude-plugin/plugin.json +6 -0
- package/.env.example +13 -0
- package/LICENSE +36 -0
- package/README.md +214 -0
- package/agents/mode.md +47 -0
- package/agents/talk.md +23 -0
- package/bin/ui-chan-mcp.mjs +11 -0
- package/bin/ui-chan-node +35 -0
- package/bin/ui-chan.mjs +350 -0
- package/context/AFFINITY.md +94 -0
- package/context/SOUL.md +79 -0
- package/context/VOCABULARY.md +108 -0
- package/cue.schema.json +109 -0
- package/cues/default.json +21 -0
- package/cues/emo_anger.json +14 -0
- package/cues/emo_anger_hi.json +19 -0
- package/cues/emo_anger_lo.json +16 -0
- package/cues/emo_antic.json +18 -0
- package/cues/emo_antic_hi.json +16 -0
- package/cues/emo_antic_lo.json +6 -0
- package/cues/emo_disgust.json +19 -0
- package/cues/emo_disgust_hi.json +20 -0
- package/cues/emo_disgust_lo.json +6 -0
- package/cues/emo_fear.json +19 -0
- package/cues/emo_fear_hi.json +19 -0
- package/cues/emo_fear_lo.json +19 -0
- package/cues/emo_joy.json +17 -0
- package/cues/emo_joy_hi.json +15 -0
- package/cues/emo_joy_lo.json +13 -0
- package/cues/emo_sad.json +15 -0
- package/cues/emo_sad_hi.json +18 -0
- package/cues/emo_sad_lo.json +14 -0
- package/cues/emo_surprise.json +15 -0
- package/cues/emo_surprise_hi.json +14 -0
- package/cues/emo_surprise_lo.json +11 -0
- package/cues/emo_trust.json +16 -0
- package/cues/emo_trust_hi.json +20 -0
- package/cues/emo_trust_lo.json +6 -0
- package/cues/idling_doze_1.json +9 -0
- package/cues/idling_doze_2.json +9 -0
- package/cues/idling_doze_3.json +9 -0
- package/cues/idling_giggle_1.json +9 -0
- package/cues/idling_giggle_2.json +9 -0
- package/cues/idling_lookaround_1.json +10 -0
- package/cues/idling_lookaround_2.json +10 -0
- package/cues/idling_lookaround_3.json +10 -0
- package/cues/idling_ponder.json +9 -0
- package/cues/idling_sigh_1.json +11 -0
- package/cues/idling_sigh_2.json +11 -0
- package/cues/idling_yawn_1.json +9 -0
- package/cues/idling_yawn_2.json +12 -0
- package/cues/idling_yawn_3.json +12 -0
- package/cues/mix_anger_antic.json +16 -0
- package/cues/mix_antic_joy.json +16 -0
- package/cues/mix_disgust_anger.json +17 -0
- package/cues/mix_fear_surprise.json +18 -0
- package/cues/mix_joy_trust.json +19 -0
- package/cues/mix_joy_trust_hi.json +18 -0
- package/cues/mix_sad_disgust.json +12 -0
- package/cues/mix_surprise_sad.json +21 -0
- package/cues/mix_surprise_sad_hi.json +14 -0
- package/cues/mix_trust_fear.json +18 -0
- package/cues/pose_arms_crossed.json +9 -0
- package/cues/pose_banzai.json +17 -0
- package/cues/pose_smug_arms.json +11 -0
- package/cues/pose_smug_hips.json +11 -0
- package/cues/pose_think.json +8 -0
- package/cues/pose_umbrella.json +9 -0
- package/cues/self_guilt.json +20 -0
- package/cues/self_pride.json +10 -0
- package/cues/self_shame.json +19 -0
- package/cues/self_shy.json +16 -0
- package/cues/self_shy_hi.json +17 -0
- package/cues/sys_address.json +9 -0
- package/cues/sys_awkward.json +18 -0
- package/cues/sys_beam.json +18 -0
- package/cues/sys_blank.json +11 -0
- package/cues/sys_care.json +18 -0
- package/cues/sys_confused.json +12 -0
- package/cues/sys_dazed.json +10 -0
- package/cues/sys_dread.json +21 -0
- package/cues/sys_fluster.json +20 -0
- package/cues/sys_intro.json +15 -0
- package/cues/sys_laugh.json +20 -0
- package/cues/sys_neutral.json +5 -0
- package/cues/sys_present.json +9 -0
- package/cues/sys_rain.json +15 -0
- package/cues/sys_refuse.json +18 -0
- package/cues/sys_relief.json +15 -0
- package/cues/sys_sleepy.json +8 -0
- package/cues/sys_smirk.json +10 -0
- package/cues/sys_stream.json +15 -0
- package/cues/sys_success.json +15 -0
- package/cues/sys_think.json +9 -0
- package/dist/app/assets.js +54 -0
- package/dist/app/cues.js +130 -0
- package/dist/app/editor-main.js +199 -0
- package/dist/app/editor-preload.js +17 -0
- package/dist/app/main.js +527 -0
- package/dist/app/persona.js +136 -0
- package/dist/app/preload.js +14 -0
- package/dist/app/state.js +850 -0
- package/dist/app/tts.js +257 -0
- package/dist/mcp-server.js +408 -0
- package/dist/renderer/bundle.js +17983 -0
- package/dist/renderer/editor.html +150 -0
- package/dist/renderer/editor.js +17917 -0
- package/dist/renderer/index.html +425 -0
- package/dist/shared/paths.js +169 -0
- package/dist/shared/set-cue-schema.js +66 -0
- package/dist/shared/types.js +14 -0
- package/docs/CLIENTS.md +110 -0
- package/docs/CUE_AUTHORING.md +76 -0
- package/docs/DEVELOPMENT.md +129 -0
- package/docs/PERSONA.md +61 -0
- package/docs/PSD_LAYERS.md +123 -0
- package/docs/README.md +43 -0
- package/docs/SETUP.html +684 -0
- package/docs/STYLE.md +107 -0
- package/docs/TOOLS.md +33 -0
- package/docs/TROUBLESHOOTING.md +122 -0
- package/docs/TTS.md +61 -0
- package/docs/design/CUE_CATALOG.md +259 -0
- package/docs/images/faces.png +0 -0
- package/docs/images/panel.png +0 -0
- package/hooks/fire-event.js +14 -0
- package/hooks/hooks.json +77 -0
- package/hooks/lib/mascot.js +76 -0
- package/hooks/notify.js +15 -0
- package/hooks/reaction.js +46 -0
- package/hooks/session-start.js +80 -0
- package/package.json +96 -0
- package/persona/ui-chan.md +111 -0
- package/plugins/hermes/ui-chan/__init__.py +98 -0
- package/plugins/hermes/ui-chan/plugin.yaml +7 -0
- package/plugins/opencode/ui-chan.mjs +105 -0
- package/skills/beam/SKILL.md +37 -0
- package/skills/eli14/SKILL.md +139 -0
- package/skills/eli14/references/base.html +495 -0
- package/skills/mode/SKILL.md +67 -0
- package/skills/talk/SKILL.md +27 -0
- package/tools/setup/check-package.mjs +47 -0
- package/tools/setup/clients.mjs +399 -0
- package/tools/setup/doctor.mjs +105 -0
- package/tools/setup/home.mjs +126 -0
- package/tools/setup/prompt.mjs +134 -0
- package/tools/setup/update-check.mjs +8 -0
- package/tools/setup/update.mjs +350 -0
- package/tools/stop-app.mjs +22 -0
- package/ui-chan.config.json +973 -0
package/hooks/hooks.json
ADDED
|
@@ -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 };
|
package/hooks/notify.js
ADDED
|
@@ -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)
|