chibi-mcp 0.1.1__tar.gz
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.
- chibi_mcp-0.1.1/.gitignore +17 -0
- chibi_mcp-0.1.1/LICENSE +21 -0
- chibi_mcp-0.1.1/PKG-INFO +117 -0
- chibi_mcp-0.1.1/README.md +87 -0
- chibi_mcp-0.1.1/chibi_mcp/__init__.py +3 -0
- chibi_mcp-0.1.1/chibi_mcp/__main__.py +91 -0
- chibi_mcp-0.1.1/chibi_mcp/server.py +134 -0
- chibi_mcp-0.1.1/chibi_mcp/state.py +154 -0
- chibi_mcp-0.1.1/chibi_mcp/system_info.py +51 -0
- chibi_mcp-0.1.1/chibi_mcp/ws_server.py +124 -0
- chibi_mcp-0.1.1/pyproject.toml +77 -0
- chibi_mcp-0.1.1/tests/__init__.py +0 -0
- chibi_mcp-0.1.1/tests/test_server.py +41 -0
- chibi_mcp-0.1.1/tests/test_state.py +149 -0
- chibi_mcp-0.1.1/tests/test_system_info.py +34 -0
- chibi_mcp-0.1.1/tests/test_ws_integration.py +128 -0
chibi_mcp-0.1.1/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 soccz
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
chibi_mcp-0.1.1/PKG-INFO
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: chibi-mcp
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: MCP server for tteoki — a Korean rice cake desktop character that visualizes Claude Code session state
|
|
5
|
+
Project-URL: Homepage, https://github.com/soccz/chibi-mcp
|
|
6
|
+
Project-URL: Repository, https://github.com/soccz/chibi-mcp
|
|
7
|
+
Project-URL: Issues, https://github.com/soccz/chibi-mcp/issues
|
|
8
|
+
Project-URL: Release Notes, https://github.com/soccz/chibi-mcp/releases
|
|
9
|
+
Author: soccz
|
|
10
|
+
License: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: claude-code,desktop-pet,garaetteok,korean,mcp,tteoki
|
|
13
|
+
Classifier: Development Status :: 3 - Alpha
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
20
|
+
Classifier: Topic :: System :: Monitoring
|
|
21
|
+
Requires-Python: >=3.12
|
|
22
|
+
Requires-Dist: fastmcp>=3.2.0
|
|
23
|
+
Requires-Dist: psutil>=5.9.0
|
|
24
|
+
Requires-Dist: websockets>=14.0
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
27
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
28
|
+
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
29
|
+
Description-Content-Type: text/markdown
|
|
30
|
+
|
|
31
|
+
# chibi-mcp (server)
|
|
32
|
+
|
|
33
|
+
MCP server for **tteoki** — a Korean rice cake (가래떡) desktop character that visualizes Claude Code session state.
|
|
34
|
+
|
|
35
|
+
The server has two jobs running together in one process:
|
|
36
|
+
1. **MCP server (stdio)** — Claude Code / Codex calls these tools to interact with tteoki.
|
|
37
|
+
2. **WebSocket server (`ws://127.0.0.1:9876`)** — pushes state snapshots and events to the desktop app.
|
|
38
|
+
|
|
39
|
+
## Install
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
pip install chibi-mcp
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
(For local development from this repo, see "Develop" below.)
|
|
46
|
+
|
|
47
|
+
## Register with Claude Code
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
claude mcp add chibi -- chibi-mcp
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Then launch the chibi-desktop app — it connects to the WebSocket and renders tteoki.
|
|
54
|
+
|
|
55
|
+
## MCP tools
|
|
56
|
+
|
|
57
|
+
| Tool | Description |
|
|
58
|
+
|---|---|
|
|
59
|
+
| `get_pet_state` | Returns mood, system metrics (CPU/RAM/battery), counters, timing. Counts as a Claude interaction (may trigger a slice every N calls). |
|
|
60
|
+
| `pet_say(text)` | Make tteoki say something in a speech bubble. |
|
|
61
|
+
| `slice_now` | Force a slice (resets the lengthen cycle, fires a slice event). |
|
|
62
|
+
| `set_slice_interval(n)` | Change how often (every N Claude tool calls) the auto-slice fires. Default: 10. |
|
|
63
|
+
|
|
64
|
+
## WebSocket protocol (server → desktop)
|
|
65
|
+
|
|
66
|
+
JSON messages broadcast to all connected desktop clients:
|
|
67
|
+
|
|
68
|
+
```jsonc
|
|
69
|
+
{ "type": "state",
|
|
70
|
+
"payload": {
|
|
71
|
+
"mood": "calm | panting | drowsy | lonely | happy | surprised | joyful",
|
|
72
|
+
"system": { "cpu_percent": 12.3, "ram_percent": 51.2, "battery_percent": 84.0, "battery_plugged": false },
|
|
73
|
+
"counters": { "calls_total": 23, "calls_since_slice": 3, "slice_interval": 10, "slices_today": 2 },
|
|
74
|
+
"timing": { "session_seconds": 1842, "idle_seconds": 7 }
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
{ "type": "say", "text": "오늘도 같이 코딩!" }
|
|
79
|
+
|
|
80
|
+
{ "type": "slice" }
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
State is pushed every 2 seconds. `say` and `slice` are pushed on demand.
|
|
84
|
+
|
|
85
|
+
## Environment variables
|
|
86
|
+
|
|
87
|
+
| Var | Default | Purpose |
|
|
88
|
+
|---|---|---|
|
|
89
|
+
| `CHIBI_WS_HOST` | `127.0.0.1` | WebSocket bind host |
|
|
90
|
+
| `CHIBI_WS_PORT` | `9876` | WebSocket bind port |
|
|
91
|
+
| `CHIBI_LOG_LEVEL` | `INFO` | Logging level |
|
|
92
|
+
|
|
93
|
+
## Develop
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
cd server
|
|
97
|
+
python3.12 -m venv venv
|
|
98
|
+
source venv/bin/activate
|
|
99
|
+
pip install -e ".[dev]"
|
|
100
|
+
pytest
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Run directly (without Claude Code) for the WebSocket side only:
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
CHIBI_LOG_LEVEL=DEBUG python -m chibi_mcp
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
The stdio MCP side will wait for a client on stdin/stdout, so to test the WebSocket alone, connect a simple ws client to `ws://localhost:9876` while the process runs.
|
|
110
|
+
|
|
111
|
+
## Design notes
|
|
112
|
+
|
|
113
|
+
- **Counter resets only when the process restarts** — intentional "today's-work" scope.
|
|
114
|
+
- **All metrics are local** — no network calls, no telemetry, no accounts.
|
|
115
|
+
- **Slice trigger is Claude-call-based** (not wall-clock) — measures actual work, not just time sitting.
|
|
116
|
+
|
|
117
|
+
See `../SPEC.md` and `../CHARACTER_DESIGN.md` for the broader project context.
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# chibi-mcp (server)
|
|
2
|
+
|
|
3
|
+
MCP server for **tteoki** — a Korean rice cake (가래떡) desktop character that visualizes Claude Code session state.
|
|
4
|
+
|
|
5
|
+
The server has two jobs running together in one process:
|
|
6
|
+
1. **MCP server (stdio)** — Claude Code / Codex calls these tools to interact with tteoki.
|
|
7
|
+
2. **WebSocket server (`ws://127.0.0.1:9876`)** — pushes state snapshots and events to the desktop app.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pip install chibi-mcp
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
(For local development from this repo, see "Develop" below.)
|
|
16
|
+
|
|
17
|
+
## Register with Claude Code
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
claude mcp add chibi -- chibi-mcp
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Then launch the chibi-desktop app — it connects to the WebSocket and renders tteoki.
|
|
24
|
+
|
|
25
|
+
## MCP tools
|
|
26
|
+
|
|
27
|
+
| Tool | Description |
|
|
28
|
+
|---|---|
|
|
29
|
+
| `get_pet_state` | Returns mood, system metrics (CPU/RAM/battery), counters, timing. Counts as a Claude interaction (may trigger a slice every N calls). |
|
|
30
|
+
| `pet_say(text)` | Make tteoki say something in a speech bubble. |
|
|
31
|
+
| `slice_now` | Force a slice (resets the lengthen cycle, fires a slice event). |
|
|
32
|
+
| `set_slice_interval(n)` | Change how often (every N Claude tool calls) the auto-slice fires. Default: 10. |
|
|
33
|
+
|
|
34
|
+
## WebSocket protocol (server → desktop)
|
|
35
|
+
|
|
36
|
+
JSON messages broadcast to all connected desktop clients:
|
|
37
|
+
|
|
38
|
+
```jsonc
|
|
39
|
+
{ "type": "state",
|
|
40
|
+
"payload": {
|
|
41
|
+
"mood": "calm | panting | drowsy | lonely | happy | surprised | joyful",
|
|
42
|
+
"system": { "cpu_percent": 12.3, "ram_percent": 51.2, "battery_percent": 84.0, "battery_plugged": false },
|
|
43
|
+
"counters": { "calls_total": 23, "calls_since_slice": 3, "slice_interval": 10, "slices_today": 2 },
|
|
44
|
+
"timing": { "session_seconds": 1842, "idle_seconds": 7 }
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
{ "type": "say", "text": "오늘도 같이 코딩!" }
|
|
49
|
+
|
|
50
|
+
{ "type": "slice" }
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
State is pushed every 2 seconds. `say` and `slice` are pushed on demand.
|
|
54
|
+
|
|
55
|
+
## Environment variables
|
|
56
|
+
|
|
57
|
+
| Var | Default | Purpose |
|
|
58
|
+
|---|---|---|
|
|
59
|
+
| `CHIBI_WS_HOST` | `127.0.0.1` | WebSocket bind host |
|
|
60
|
+
| `CHIBI_WS_PORT` | `9876` | WebSocket bind port |
|
|
61
|
+
| `CHIBI_LOG_LEVEL` | `INFO` | Logging level |
|
|
62
|
+
|
|
63
|
+
## Develop
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
cd server
|
|
67
|
+
python3.12 -m venv venv
|
|
68
|
+
source venv/bin/activate
|
|
69
|
+
pip install -e ".[dev]"
|
|
70
|
+
pytest
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Run directly (without Claude Code) for the WebSocket side only:
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
CHIBI_LOG_LEVEL=DEBUG python -m chibi_mcp
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
The stdio MCP side will wait for a client on stdin/stdout, so to test the WebSocket alone, connect a simple ws client to `ws://localhost:9876` while the process runs.
|
|
80
|
+
|
|
81
|
+
## Design notes
|
|
82
|
+
|
|
83
|
+
- **Counter resets only when the process restarts** — intentional "today's-work" scope.
|
|
84
|
+
- **All metrics are local** — no network calls, no telemetry, no accounts.
|
|
85
|
+
- **Slice trigger is Claude-call-based** (not wall-clock) — measures actual work, not just time sitting.
|
|
86
|
+
|
|
87
|
+
See `../SPEC.md` and `../CHARACTER_DESIGN.md` for the broader project context.
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""chibi-mcp entry point.
|
|
2
|
+
|
|
3
|
+
Runs the FastMCP server (stdio transport for Claude Code) AND a localhost
|
|
4
|
+
WebSocket server (for the desktop app) in the same asyncio loop.
|
|
5
|
+
|
|
6
|
+
Install:
|
|
7
|
+
pip install chibi-mcp
|
|
8
|
+
|
|
9
|
+
Register with Claude Code:
|
|
10
|
+
claude mcp add chibi -- chibi-mcp
|
|
11
|
+
|
|
12
|
+
Then launch the chibi-desktop app, which will connect to ws://localhost:9876.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import asyncio
|
|
18
|
+
import logging
|
|
19
|
+
import os
|
|
20
|
+
import signal
|
|
21
|
+
import sys
|
|
22
|
+
from contextlib import suppress
|
|
23
|
+
|
|
24
|
+
from .server import mcp
|
|
25
|
+
from .ws_server import DEFAULT_WS_HOST, DEFAULT_WS_PORT, run_ws_server
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _setup_logging() -> None:
|
|
29
|
+
level_name = os.environ.get("CHIBI_LOG_LEVEL", "INFO").upper()
|
|
30
|
+
level = getattr(logging, level_name, logging.INFO)
|
|
31
|
+
# Log to stderr — stdout is reserved for MCP stdio transport
|
|
32
|
+
logging.basicConfig(
|
|
33
|
+
level=level,
|
|
34
|
+
stream=sys.stderr,
|
|
35
|
+
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
async def _run_concurrent() -> None:
|
|
40
|
+
"""Run MCP (stdio) and WebSocket server concurrently."""
|
|
41
|
+
host = os.environ.get("CHIBI_WS_HOST", DEFAULT_WS_HOST)
|
|
42
|
+
port = int(os.environ.get("CHIBI_WS_PORT", DEFAULT_WS_PORT))
|
|
43
|
+
|
|
44
|
+
ws_task = asyncio.create_task(run_ws_server(host=host, port=port))
|
|
45
|
+
# FastMCP's run_stdio_async is the asyncio variant of mcp.run()
|
|
46
|
+
mcp_task = asyncio.create_task(mcp.run_stdio_async())
|
|
47
|
+
|
|
48
|
+
# Graceful shutdown on SIGTERM/SIGINT
|
|
49
|
+
loop = asyncio.get_running_loop()
|
|
50
|
+
stop = loop.create_future()
|
|
51
|
+
|
|
52
|
+
def _signal_stop() -> None:
|
|
53
|
+
if not stop.done():
|
|
54
|
+
stop.set_result(None)
|
|
55
|
+
|
|
56
|
+
for sig in (signal.SIGINT, signal.SIGTERM):
|
|
57
|
+
with suppress(NotImplementedError):
|
|
58
|
+
loop.add_signal_handler(sig, _signal_stop)
|
|
59
|
+
|
|
60
|
+
try:
|
|
61
|
+
# Wait for any of: MCP exit, WS exit, signal
|
|
62
|
+
done, _pending = await asyncio.wait(
|
|
63
|
+
{mcp_task, ws_task, stop},
|
|
64
|
+
return_when=asyncio.FIRST_COMPLETED,
|
|
65
|
+
)
|
|
66
|
+
for t in done:
|
|
67
|
+
exc = t.exception() if not t.cancelled() else None
|
|
68
|
+
if exc:
|
|
69
|
+
logging.getLogger(__name__).error("task failed: %r", exc)
|
|
70
|
+
finally:
|
|
71
|
+
for t in (mcp_task, ws_task):
|
|
72
|
+
if not t.done():
|
|
73
|
+
t.cancel()
|
|
74
|
+
with suppress(asyncio.CancelledError):
|
|
75
|
+
await asyncio.gather(mcp_task, ws_task, return_exceptions=True)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def main() -> int:
|
|
79
|
+
_setup_logging()
|
|
80
|
+
log = logging.getLogger("chibi_mcp")
|
|
81
|
+
log.info("chibi-mcp starting (stdio MCP + ws://%s:%d)",
|
|
82
|
+
os.environ.get("CHIBI_WS_HOST", DEFAULT_WS_HOST),
|
|
83
|
+
int(os.environ.get("CHIBI_WS_PORT", DEFAULT_WS_PORT)))
|
|
84
|
+
with suppress(KeyboardInterrupt):
|
|
85
|
+
asyncio.run(_run_concurrent())
|
|
86
|
+
log.info("chibi-mcp stopped")
|
|
87
|
+
return 0
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
if __name__ == "__main__":
|
|
91
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""FastMCP server — tools Claude Code calls to interact with tteoki."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
|
|
8
|
+
from fastmcp import FastMCP
|
|
9
|
+
|
|
10
|
+
from .state import get_state
|
|
11
|
+
from .ws_server import get_broadcaster
|
|
12
|
+
|
|
13
|
+
log = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
mcp = FastMCP("chibi-mcp")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# Limits for incoming MCP tool inputs. Claude may emit large strings — we
|
|
19
|
+
# defend the desktop UI from rendering pathological payloads.
|
|
20
|
+
MAX_SAY_LEN = 200
|
|
21
|
+
SAY_CONTROL_CHARS = "".join(chr(c) for c in range(32) if c not in (9, 10)) # keep tab+LF
|
|
22
|
+
|
|
23
|
+
# Holds references to fire-and-forget asyncio tasks so they aren't garbage
|
|
24
|
+
# collected mid-flight. (RUF006 — Python may drop tasks that have no strong
|
|
25
|
+
# reference, silently dropping the WebSocket broadcast.)
|
|
26
|
+
_PENDING_TASKS: set[asyncio.Task] = set()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _fire_and_forget(coro) -> bool:
|
|
30
|
+
"""Schedule `coro` on the running loop, keep a hard reference until done.
|
|
31
|
+
|
|
32
|
+
Returns True if scheduled, False if there's no running loop in the current
|
|
33
|
+
thread (which means MCP is being called synchronously without an event loop).
|
|
34
|
+
"""
|
|
35
|
+
try:
|
|
36
|
+
loop = asyncio.get_running_loop()
|
|
37
|
+
except RuntimeError:
|
|
38
|
+
return False
|
|
39
|
+
task = loop.create_task(coro)
|
|
40
|
+
_PENDING_TASKS.add(task)
|
|
41
|
+
task.add_done_callback(_PENDING_TASKS.discard)
|
|
42
|
+
return True
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _sanitize_say(text: str) -> str:
|
|
46
|
+
if not isinstance(text, str):
|
|
47
|
+
text = str(text)
|
|
48
|
+
# Drop control characters (except tab and newline)
|
|
49
|
+
cleaned = text.translate({ord(c): None for c in SAY_CONTROL_CHARS})
|
|
50
|
+
# Collapse to single line for speech bubble
|
|
51
|
+
cleaned = cleaned.replace("\r", " ").replace("\n", " ").strip()
|
|
52
|
+
if len(cleaned) > MAX_SAY_LEN:
|
|
53
|
+
cleaned = cleaned[: MAX_SAY_LEN - 1] + "…"
|
|
54
|
+
return cleaned
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _record_call_and_maybe_slice(force_slice: bool = False) -> dict:
|
|
58
|
+
"""Increment counter; if slice milestone hit, broadcast a slice event."""
|
|
59
|
+
state = get_state()
|
|
60
|
+
result = state.record_call(force_slice=force_slice)
|
|
61
|
+
if result["sliced"]:
|
|
62
|
+
broadcaster = get_broadcaster()
|
|
63
|
+
scheduled = _fire_and_forget(broadcaster.broadcast({"type": "slice"}))
|
|
64
|
+
if not scheduled:
|
|
65
|
+
log.debug("slice event skipped (no running loop)")
|
|
66
|
+
return result
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@mcp.tool()
|
|
70
|
+
def get_pet_state() -> dict:
|
|
71
|
+
"""Return tteoki's current state: mood, system metrics, counters, timing.
|
|
72
|
+
|
|
73
|
+
The desktop app reads this to render the character. Calling this tool
|
|
74
|
+
counts as a Claude interaction and may trigger a slice every N calls.
|
|
75
|
+
"""
|
|
76
|
+
counter = _record_call_and_maybe_slice()
|
|
77
|
+
state = get_state()
|
|
78
|
+
snapshot = state.snapshot()
|
|
79
|
+
snapshot["last_call_result"] = counter
|
|
80
|
+
return snapshot
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@mcp.tool()
|
|
84
|
+
def pet_say(text: str) -> dict:
|
|
85
|
+
"""Make tteoki say something via a speech bubble in the desktop app.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
text: short message (≤ 200 chars; longer text is truncated with "…").
|
|
89
|
+
Control characters and newlines are stripped to keep the bubble
|
|
90
|
+
renderable.
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
Confirmation dict with the sanitized text and broadcast status.
|
|
94
|
+
"""
|
|
95
|
+
safe = _sanitize_say(text)
|
|
96
|
+
counter = _record_call_and_maybe_slice()
|
|
97
|
+
|
|
98
|
+
broadcaster = get_broadcaster()
|
|
99
|
+
broadcasted = _fire_and_forget(broadcaster.broadcast({"type": "say", "text": safe}))
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
"spoken": safe,
|
|
103
|
+
"broadcasted": broadcasted,
|
|
104
|
+
"counter": counter,
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@mcp.tool()
|
|
109
|
+
def slice_now() -> dict:
|
|
110
|
+
"""Manually trigger a slice (force the lengthen-cycle to reset).
|
|
111
|
+
|
|
112
|
+
Useful when the user wants to mark a milestone without waiting for the
|
|
113
|
+
N-call automatic trigger.
|
|
114
|
+
"""
|
|
115
|
+
counter = _record_call_and_maybe_slice(force_slice=True)
|
|
116
|
+
return {
|
|
117
|
+
"forced": True,
|
|
118
|
+
"counter": counter,
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@mcp.tool()
|
|
123
|
+
def set_slice_interval(n: int) -> dict:
|
|
124
|
+
"""Change how often (every N Claude tool calls) tteoki gets sliced.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
n: positive integer. Default is 10. Suggested values: 5, 10, 25, 50, 100.
|
|
128
|
+
"""
|
|
129
|
+
if n < 1:
|
|
130
|
+
raise ValueError("slice interval must be ≥ 1")
|
|
131
|
+
state = get_state()
|
|
132
|
+
old = state.slice_interval
|
|
133
|
+
state.slice_interval = n
|
|
134
|
+
return {"previous": old, "current": n}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""Character state — derives tteoki's mood from system metrics and call counter."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from enum import StrEnum
|
|
8
|
+
from threading import Lock
|
|
9
|
+
|
|
10
|
+
from .system_info import SystemSnapshot, read_snapshot
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Mood(StrEnum):
|
|
14
|
+
CALM = "calm" # 평온 — default
|
|
15
|
+
PANTING = "panting" # 헐떡 — CPU 80%+
|
|
16
|
+
DROWSY = "drowsy" # 졸림 — battery < 20% unplugged
|
|
17
|
+
LONELY = "lonely" # 시무룩 — long idle (no Claude calls)
|
|
18
|
+
HAPPY = "happy" # 기쁨 — recent Claude call
|
|
19
|
+
SURPRISED = "surprised" # 놀람 — CPU sudden spike
|
|
20
|
+
JOYFUL = "joyful" # 행복 — milestone (slice triggered)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# Slice trigger: every N Claude tool calls (user-decided default = 10)
|
|
24
|
+
DEFAULT_SLICE_INTERVAL = 10
|
|
25
|
+
LONELY_IDLE_SECONDS = 30 * 60 # 30 minutes
|
|
26
|
+
HAPPY_WINDOW_SECONDS = 30
|
|
27
|
+
SURPRISE_DELTA = 30.0 # CPU jump of 30%+ within one tick = surprise
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class TteokiState:
|
|
32
|
+
"""In-memory state of the tteoki character.
|
|
33
|
+
|
|
34
|
+
Thread-safe via a single lock. Counters reset only when the server process restarts
|
|
35
|
+
(today's-work scope, intentional).
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
slice_interval: int = DEFAULT_SLICE_INTERVAL
|
|
39
|
+
_lock: Lock = field(default_factory=Lock, repr=False)
|
|
40
|
+
|
|
41
|
+
# Counters
|
|
42
|
+
call_count: int = 0 # cumulative Claude tool calls since server start
|
|
43
|
+
calls_since_slice: int = 0 # resets on each slice
|
|
44
|
+
slices_today: int = 0 # cumulative slice events since server start
|
|
45
|
+
|
|
46
|
+
# Timing
|
|
47
|
+
started_at: float = field(default_factory=time.time)
|
|
48
|
+
last_call_at: float | None = None
|
|
49
|
+
|
|
50
|
+
# CPU spike detection
|
|
51
|
+
last_cpu: float = 0.0
|
|
52
|
+
|
|
53
|
+
def record_call(self, force_slice: bool = False) -> dict:
|
|
54
|
+
"""Increment call counters. Returns a dict signaling if a slice fired.
|
|
55
|
+
|
|
56
|
+
When `force_slice` is True, the slice milestone is triggered regardless
|
|
57
|
+
of how many calls have accumulated. Used by the manual `slice_now` tool.
|
|
58
|
+
"""
|
|
59
|
+
with self._lock:
|
|
60
|
+
self.call_count += 1
|
|
61
|
+
self.calls_since_slice += 1
|
|
62
|
+
self.last_call_at = time.time()
|
|
63
|
+
sliced = False
|
|
64
|
+
if force_slice or self.calls_since_slice >= self.slice_interval:
|
|
65
|
+
self.calls_since_slice = 0
|
|
66
|
+
self.slices_today += 1
|
|
67
|
+
sliced = True
|
|
68
|
+
return {
|
|
69
|
+
"call_count": self.call_count,
|
|
70
|
+
"calls_since_slice": self.calls_since_slice,
|
|
71
|
+
"slices_today": self.slices_today,
|
|
72
|
+
"sliced": sliced,
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
def compute_mood(self, snap: SystemSnapshot) -> Mood:
|
|
76
|
+
"""Derive mood from current snapshot and timing history."""
|
|
77
|
+
now = time.time()
|
|
78
|
+
|
|
79
|
+
with self._lock:
|
|
80
|
+
last_call = self.last_call_at
|
|
81
|
+
last_cpu = self.last_cpu
|
|
82
|
+
self.last_cpu = snap.cpu_percent
|
|
83
|
+
|
|
84
|
+
# Battery drowsiness wins if unplugged and low
|
|
85
|
+
if (
|
|
86
|
+
snap.battery_percent is not None
|
|
87
|
+
and snap.battery_percent < 20
|
|
88
|
+
and snap.battery_plugged is False
|
|
89
|
+
):
|
|
90
|
+
return Mood.DROWSY
|
|
91
|
+
|
|
92
|
+
# CPU sudden spike (≥30% jump)
|
|
93
|
+
if snap.cpu_percent - last_cpu >= SURPRISE_DELTA and snap.cpu_percent > 50:
|
|
94
|
+
return Mood.SURPRISED
|
|
95
|
+
|
|
96
|
+
# Sustained high CPU
|
|
97
|
+
if snap.cpu_percent >= 80:
|
|
98
|
+
return Mood.PANTING
|
|
99
|
+
|
|
100
|
+
# Recent Claude call → happy
|
|
101
|
+
if last_call is not None and (now - last_call) <= HAPPY_WINDOW_SECONDS:
|
|
102
|
+
return Mood.HAPPY
|
|
103
|
+
|
|
104
|
+
# Long idle → lonely
|
|
105
|
+
if last_call is None:
|
|
106
|
+
session_age = now - self.started_at
|
|
107
|
+
if session_age > LONELY_IDLE_SECONDS:
|
|
108
|
+
return Mood.LONELY
|
|
109
|
+
elif (now - last_call) > LONELY_IDLE_SECONDS:
|
|
110
|
+
return Mood.LONELY
|
|
111
|
+
|
|
112
|
+
return Mood.CALM
|
|
113
|
+
|
|
114
|
+
def snapshot(self) -> dict:
|
|
115
|
+
"""Return a JSON-serializable snapshot of full state (for MCP/WebSocket)."""
|
|
116
|
+
snap = read_snapshot(interval=0.0)
|
|
117
|
+
mood = self.compute_mood(snap)
|
|
118
|
+
|
|
119
|
+
with self._lock:
|
|
120
|
+
now = time.time()
|
|
121
|
+
session_seconds = int(now - self.started_at)
|
|
122
|
+
idle_seconds = int(now - self.last_call_at) if self.last_call_at else None
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
"mood": mood.value,
|
|
126
|
+
"system": snap.to_dict(),
|
|
127
|
+
"counters": {
|
|
128
|
+
"calls_total": self.call_count,
|
|
129
|
+
"calls_since_slice": self.calls_since_slice,
|
|
130
|
+
"slice_interval": self.slice_interval,
|
|
131
|
+
"slices_today": self.slices_today,
|
|
132
|
+
},
|
|
133
|
+
"timing": {
|
|
134
|
+
"session_seconds": session_seconds,
|
|
135
|
+
"idle_seconds": idle_seconds,
|
|
136
|
+
},
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# Module-level singleton — one tteoki per server process
|
|
141
|
+
_STATE: TteokiState | None = None
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def get_state() -> TteokiState:
|
|
145
|
+
global _STATE
|
|
146
|
+
if _STATE is None:
|
|
147
|
+
_STATE = TteokiState()
|
|
148
|
+
return _STATE
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def reset_state_for_tests() -> None:
|
|
152
|
+
"""Test helper — DO NOT call from runtime code."""
|
|
153
|
+
global _STATE
|
|
154
|
+
_STATE = None
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""System metrics (CPU, RAM, battery) via psutil."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
import psutil
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class SystemSnapshot:
|
|
12
|
+
cpu_percent: float
|
|
13
|
+
ram_percent: float
|
|
14
|
+
battery_percent: float | None # None when no battery (desktop)
|
|
15
|
+
battery_plugged: bool | None
|
|
16
|
+
|
|
17
|
+
def to_dict(self) -> dict:
|
|
18
|
+
return {
|
|
19
|
+
"cpu_percent": round(self.cpu_percent, 1),
|
|
20
|
+
"ram_percent": round(self.ram_percent, 1),
|
|
21
|
+
"battery_percent": (
|
|
22
|
+
round(self.battery_percent, 1) if self.battery_percent is not None else None
|
|
23
|
+
),
|
|
24
|
+
"battery_plugged": self.battery_plugged,
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def read_snapshot(interval: float = 0.0) -> SystemSnapshot:
|
|
29
|
+
"""Read current CPU/RAM/battery state.
|
|
30
|
+
|
|
31
|
+
interval=0.0 returns the cached value since last call (non-blocking).
|
|
32
|
+
interval>0 measures CPU over that window (blocking).
|
|
33
|
+
|
|
34
|
+
Battery is best-effort: psutil.sensors_battery() can raise
|
|
35
|
+
NotImplementedError on some Linux/VM/server environments. Treat any
|
|
36
|
+
failure as "no battery" rather than crashing the server.
|
|
37
|
+
"""
|
|
38
|
+
cpu = psutil.cpu_percent(interval=interval)
|
|
39
|
+
ram = psutil.virtual_memory().percent
|
|
40
|
+
try:
|
|
41
|
+
bat = psutil.sensors_battery()
|
|
42
|
+
except (NotImplementedError, AttributeError, OSError):
|
|
43
|
+
bat = None
|
|
44
|
+
if bat is None:
|
|
45
|
+
return SystemSnapshot(cpu_percent=cpu, ram_percent=ram, battery_percent=None, battery_plugged=None)
|
|
46
|
+
return SystemSnapshot(
|
|
47
|
+
cpu_percent=cpu,
|
|
48
|
+
ram_percent=ram,
|
|
49
|
+
battery_percent=bat.percent,
|
|
50
|
+
battery_plugged=bat.power_plugged,
|
|
51
|
+
)
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""WebSocket server — pushes tteoki state to the desktop app.
|
|
2
|
+
|
|
3
|
+
Protocol (JSON messages, server → client):
|
|
4
|
+
{"type": "state", "payload": {...full state snapshot...}}
|
|
5
|
+
{"type": "say", "text": "..."}
|
|
6
|
+
{"type": "slice"} # fires when N-call milestone hits
|
|
7
|
+
|
|
8
|
+
The desktop app connects to ws://localhost:9876 and listens for events.
|
|
9
|
+
|
|
10
|
+
Uses the websockets >= 14 asyncio API (`websockets.asyncio.server`).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import asyncio
|
|
16
|
+
import contextlib
|
|
17
|
+
import json
|
|
18
|
+
import logging
|
|
19
|
+
|
|
20
|
+
from websockets.asyncio.server import ServerConnection, serve
|
|
21
|
+
from websockets.exceptions import ConnectionClosed
|
|
22
|
+
|
|
23
|
+
from .state import get_state
|
|
24
|
+
|
|
25
|
+
log = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
DEFAULT_WS_HOST = "127.0.0.1"
|
|
28
|
+
DEFAULT_WS_PORT = 9876
|
|
29
|
+
STATE_PUSH_INTERVAL_SECONDS = 2.0 # snapshot push cadence
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class TteokiBroadcaster:
|
|
33
|
+
"""Holds connected desktop clients and broadcasts events."""
|
|
34
|
+
|
|
35
|
+
def __init__(self) -> None:
|
|
36
|
+
self._clients: set[ServerConnection] = set()
|
|
37
|
+
self._lock = asyncio.Lock()
|
|
38
|
+
|
|
39
|
+
async def register(self, ws: ServerConnection) -> None:
|
|
40
|
+
async with self._lock:
|
|
41
|
+
self._clients.add(ws)
|
|
42
|
+
log.info("ws client connected (%d total)", len(self._clients))
|
|
43
|
+
|
|
44
|
+
async def unregister(self, ws: ServerConnection) -> None:
|
|
45
|
+
async with self._lock:
|
|
46
|
+
self._clients.discard(ws)
|
|
47
|
+
log.info("ws client disconnected (%d total)", len(self._clients))
|
|
48
|
+
|
|
49
|
+
async def broadcast(self, message: dict) -> None:
|
|
50
|
+
payload = json.dumps(message)
|
|
51
|
+
async with self._lock:
|
|
52
|
+
clients = list(self._clients)
|
|
53
|
+
if not clients:
|
|
54
|
+
return
|
|
55
|
+
results = await asyncio.gather(
|
|
56
|
+
*(self._send_safe(c, payload) for c in clients), return_exceptions=True
|
|
57
|
+
)
|
|
58
|
+
for r in results:
|
|
59
|
+
if isinstance(r, Exception):
|
|
60
|
+
log.debug("broadcast send failed: %s", r)
|
|
61
|
+
|
|
62
|
+
@staticmethod
|
|
63
|
+
async def _send_safe(ws: ServerConnection, payload: str) -> None:
|
|
64
|
+
with contextlib.suppress(ConnectionClosed):
|
|
65
|
+
await ws.send(payload)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
_BROADCASTER: TteokiBroadcaster | None = None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def get_broadcaster() -> TteokiBroadcaster:
|
|
72
|
+
global _BROADCASTER
|
|
73
|
+
if _BROADCASTER is None:
|
|
74
|
+
_BROADCASTER = TteokiBroadcaster()
|
|
75
|
+
return _BROADCASTER
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def reset_broadcaster_for_tests() -> None:
|
|
79
|
+
"""Test helper — DO NOT call from runtime code."""
|
|
80
|
+
global _BROADCASTER
|
|
81
|
+
_BROADCASTER = None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
async def _handle_client(ws: ServerConnection) -> None:
|
|
85
|
+
broadcaster = get_broadcaster()
|
|
86
|
+
await broadcaster.register(ws)
|
|
87
|
+
|
|
88
|
+
# Send current state immediately on connect
|
|
89
|
+
state = get_state()
|
|
90
|
+
await ws.send(json.dumps({"type": "state", "payload": state.snapshot()}))
|
|
91
|
+
|
|
92
|
+
try:
|
|
93
|
+
async for _raw in ws:
|
|
94
|
+
# Desktop app may send pings or settings updates later; ignore for v0.1
|
|
95
|
+
pass
|
|
96
|
+
except ConnectionClosed:
|
|
97
|
+
pass
|
|
98
|
+
finally:
|
|
99
|
+
await broadcaster.unregister(ws)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
async def _state_push_loop() -> None:
|
|
103
|
+
"""Periodically push state to all connected clients."""
|
|
104
|
+
broadcaster = get_broadcaster()
|
|
105
|
+
state = get_state()
|
|
106
|
+
while True:
|
|
107
|
+
try:
|
|
108
|
+
await broadcaster.broadcast({"type": "state", "payload": state.snapshot()})
|
|
109
|
+
except Exception:
|
|
110
|
+
log.exception("state push failed")
|
|
111
|
+
await asyncio.sleep(STATE_PUSH_INTERVAL_SECONDS)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
async def run_ws_server(host: str = DEFAULT_WS_HOST, port: int = DEFAULT_WS_PORT) -> None:
|
|
115
|
+
"""Run WebSocket server + periodic state push concurrently."""
|
|
116
|
+
push_task = asyncio.create_task(_state_push_loop())
|
|
117
|
+
async with serve(_handle_client, host, port):
|
|
118
|
+
log.info("ws server listening on ws://%s:%d", host, port)
|
|
119
|
+
try:
|
|
120
|
+
await asyncio.Future() # run forever
|
|
121
|
+
finally:
|
|
122
|
+
push_task.cancel()
|
|
123
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
124
|
+
await push_task
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "chibi-mcp"
|
|
3
|
+
version = "0.1.1"
|
|
4
|
+
description = "MCP server for tteoki — a Korean rice cake desktop character that visualizes Claude Code session state"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.12"
|
|
7
|
+
license = { text = "MIT" }
|
|
8
|
+
authors = [{ name = "soccz" }]
|
|
9
|
+
keywords = ["mcp", "claude-code", "desktop-pet", "korean", "tteoki", "garaetteok"]
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Development Status :: 3 - Alpha",
|
|
12
|
+
"Intended Audience :: Developers",
|
|
13
|
+
"License :: OSI Approved :: MIT License",
|
|
14
|
+
"Operating System :: OS Independent",
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"Programming Language :: Python :: 3.12",
|
|
17
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
18
|
+
"Topic :: System :: Monitoring",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
dependencies = [
|
|
22
|
+
"fastmcp>=3.2.0",
|
|
23
|
+
"psutil>=5.9.0",
|
|
24
|
+
"websockets>=14.0",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[project.urls]
|
|
28
|
+
Homepage = "https://github.com/soccz/chibi-mcp"
|
|
29
|
+
Repository = "https://github.com/soccz/chibi-mcp"
|
|
30
|
+
Issues = "https://github.com/soccz/chibi-mcp/issues"
|
|
31
|
+
"Release Notes" = "https://github.com/soccz/chibi-mcp/releases"
|
|
32
|
+
|
|
33
|
+
[project.optional-dependencies]
|
|
34
|
+
dev = [
|
|
35
|
+
"pytest>=8.0",
|
|
36
|
+
"pytest-asyncio>=0.23",
|
|
37
|
+
"ruff>=0.6",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
[project.scripts]
|
|
41
|
+
chibi-mcp = "chibi_mcp.__main__:main"
|
|
42
|
+
|
|
43
|
+
[build-system]
|
|
44
|
+
requires = ["hatchling"]
|
|
45
|
+
build-backend = "hatchling.build"
|
|
46
|
+
|
|
47
|
+
[tool.hatch.build.targets.wheel]
|
|
48
|
+
packages = ["chibi_mcp"]
|
|
49
|
+
|
|
50
|
+
[tool.pytest.ini_options]
|
|
51
|
+
asyncio_mode = "auto"
|
|
52
|
+
testpaths = ["tests"]
|
|
53
|
+
|
|
54
|
+
[tool.ruff]
|
|
55
|
+
line-length = 100
|
|
56
|
+
target-version = "py312"
|
|
57
|
+
extend-exclude = ["venv", "build", "dist"]
|
|
58
|
+
|
|
59
|
+
[tool.ruff.lint]
|
|
60
|
+
select = [
|
|
61
|
+
"E", # pycodestyle errors
|
|
62
|
+
"F", # pyflakes
|
|
63
|
+
"W", # pycodestyle warnings
|
|
64
|
+
"I", # isort
|
|
65
|
+
"UP", # pyupgrade
|
|
66
|
+
"B", # bugbear
|
|
67
|
+
"C4", # comprehensions
|
|
68
|
+
"SIM", # simplify
|
|
69
|
+
"RUF", # ruff-specific
|
|
70
|
+
]
|
|
71
|
+
ignore = [
|
|
72
|
+
"E501", # line too long — handled by formatter context
|
|
73
|
+
"SIM117", # combining nested with statements isn't always clearer
|
|
74
|
+
]
|
|
75
|
+
|
|
76
|
+
[tool.ruff.lint.per-file-ignores]
|
|
77
|
+
"tests/**" = ["B011"] # allow `assert` patterns in tests
|
|
File without changes
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Tests for server.py — input sanitization for MCP tool args."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from chibi_mcp.server import MAX_SAY_LEN, _sanitize_say
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_sanitize_short_text_passthrough():
|
|
9
|
+
assert _sanitize_say("hi") == "hi"
|
|
10
|
+
assert _sanitize_say("오늘도 같이 코딩!") == "오늘도 같이 코딩!"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_sanitize_truncates_long_text():
|
|
14
|
+
long = "a" * 1000
|
|
15
|
+
result = _sanitize_say(long)
|
|
16
|
+
assert len(result) == MAX_SAY_LEN
|
|
17
|
+
assert result.endswith("…")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_sanitize_strips_control_chars():
|
|
21
|
+
# \x00 NULL, \x07 BEL — should be stripped; tab and newline are collapsed to space
|
|
22
|
+
result = _sanitize_say("hi\x00there\x07world\nnewline\ttab")
|
|
23
|
+
assert "\x00" not in result
|
|
24
|
+
assert "\x07" not in result
|
|
25
|
+
assert "\n" not in result
|
|
26
|
+
# tab is preserved in SAY_CONTROL_CHARS exclusion but newline collapses to space
|
|
27
|
+
assert "hi" in result and "world" in result
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_sanitize_collapses_newlines_for_single_line_bubble():
|
|
31
|
+
assert "\n" not in _sanitize_say("line1\nline2")
|
|
32
|
+
assert "\r" not in _sanitize_say("line1\r\nline2")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_sanitize_handles_non_string_input():
|
|
36
|
+
assert _sanitize_say(42) == "42" # type: ignore[arg-type]
|
|
37
|
+
assert _sanitize_say(None) == "None" # type: ignore[arg-type]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_sanitize_strips_surrounding_whitespace():
|
|
41
|
+
assert _sanitize_say(" hello ") == "hello"
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Tests for state.py — mood derivation and slice counter."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from unittest.mock import patch
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from chibi_mcp.state import (
|
|
11
|
+
DEFAULT_SLICE_INTERVAL,
|
|
12
|
+
HAPPY_WINDOW_SECONDS,
|
|
13
|
+
LONELY_IDLE_SECONDS,
|
|
14
|
+
Mood,
|
|
15
|
+
TteokiState,
|
|
16
|
+
reset_state_for_tests,
|
|
17
|
+
)
|
|
18
|
+
from chibi_mcp.system_info import SystemSnapshot
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@pytest.fixture(autouse=True)
|
|
22
|
+
def _reset():
|
|
23
|
+
reset_state_for_tests()
|
|
24
|
+
yield
|
|
25
|
+
reset_state_for_tests()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _snap(cpu=10.0, ram=40.0, bat=None, plugged=None) -> SystemSnapshot:
|
|
29
|
+
return SystemSnapshot(cpu_percent=cpu, ram_percent=ram, battery_percent=bat, battery_plugged=plugged)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# ---- slice counter ----
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_slice_fires_every_default_interval():
|
|
36
|
+
state = TteokiState()
|
|
37
|
+
fired = []
|
|
38
|
+
for _ in range(DEFAULT_SLICE_INTERVAL):
|
|
39
|
+
r = state.record_call()
|
|
40
|
+
fired.append(r["sliced"])
|
|
41
|
+
# First N-1 calls don't slice; N-th does
|
|
42
|
+
assert fired == [False] * (DEFAULT_SLICE_INTERVAL - 1) + [True]
|
|
43
|
+
assert state.slices_today == 1
|
|
44
|
+
assert state.calls_since_slice == 0
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_slice_interval_is_configurable():
|
|
48
|
+
state = TteokiState(slice_interval=3)
|
|
49
|
+
sliced = [state.record_call()["sliced"] for _ in range(6)]
|
|
50
|
+
assert sliced == [False, False, True, False, False, True]
|
|
51
|
+
assert state.slices_today == 2
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_call_count_keeps_growing_across_slices():
|
|
55
|
+
state = TteokiState(slice_interval=2)
|
|
56
|
+
for _ in range(5):
|
|
57
|
+
state.record_call()
|
|
58
|
+
assert state.call_count == 5
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# ---- force_slice (manual slice_now path) ----
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_force_slice_fires_immediately():
|
|
65
|
+
state = TteokiState(slice_interval=100)
|
|
66
|
+
r = state.record_call(force_slice=True)
|
|
67
|
+
assert r["sliced"] is True
|
|
68
|
+
assert state.slices_today == 1
|
|
69
|
+
assert state.calls_since_slice == 0
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_force_slice_does_not_affect_natural_counter():
|
|
73
|
+
state = TteokiState(slice_interval=3)
|
|
74
|
+
state.record_call()
|
|
75
|
+
state.record_call() # calls_since_slice = 2
|
|
76
|
+
state.record_call(force_slice=True) # forced — resets, +1 slice
|
|
77
|
+
assert state.slices_today == 1
|
|
78
|
+
# next 3 normal calls should slice again
|
|
79
|
+
sliced = [state.record_call()["sliced"] for _ in range(3)]
|
|
80
|
+
assert sliced == [False, False, True]
|
|
81
|
+
assert state.slices_today == 2
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# ---- mood derivation ----
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_default_mood_is_calm():
|
|
88
|
+
state = TteokiState()
|
|
89
|
+
assert state.compute_mood(_snap()) == Mood.CALM
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_high_cpu_triggers_panting():
|
|
93
|
+
state = TteokiState()
|
|
94
|
+
state.last_cpu = 75.0 # so jump isn't enough for SURPRISED
|
|
95
|
+
assert state.compute_mood(_snap(cpu=85.0)) == Mood.PANTING
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def test_sudden_cpu_spike_triggers_surprised():
|
|
99
|
+
state = TteokiState()
|
|
100
|
+
state.last_cpu = 5.0
|
|
101
|
+
assert state.compute_mood(_snap(cpu=60.0)) == Mood.SURPRISED
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def test_low_battery_unplugged_triggers_drowsy():
|
|
105
|
+
state = TteokiState()
|
|
106
|
+
assert state.compute_mood(_snap(bat=15.0, plugged=False)) == Mood.DROWSY
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_low_battery_but_plugged_is_not_drowsy():
|
|
110
|
+
state = TteokiState()
|
|
111
|
+
assert state.compute_mood(_snap(bat=15.0, plugged=True)) != Mood.DROWSY
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def test_recent_call_triggers_happy():
|
|
115
|
+
state = TteokiState()
|
|
116
|
+
state.last_call_at = time.time() - 5
|
|
117
|
+
assert state.compute_mood(_snap()) == Mood.HAPPY
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def test_long_idle_after_calls_triggers_lonely():
|
|
121
|
+
state = TteokiState()
|
|
122
|
+
state.last_call_at = time.time() - (LONELY_IDLE_SECONDS + 60)
|
|
123
|
+
assert state.compute_mood(_snap()) == Mood.LONELY
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def test_long_session_without_any_call_triggers_lonely():
|
|
127
|
+
state = TteokiState()
|
|
128
|
+
state.started_at = time.time() - (LONELY_IDLE_SECONDS + 60)
|
|
129
|
+
state.last_call_at = None
|
|
130
|
+
assert state.compute_mood(_snap()) == Mood.LONELY
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def test_happy_window_boundary():
|
|
134
|
+
state = TteokiState()
|
|
135
|
+
state.last_call_at = time.time() - (HAPPY_WINDOW_SECONDS - 1)
|
|
136
|
+
assert state.compute_mood(_snap()) == Mood.HAPPY
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
# ---- snapshot ----
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def test_snapshot_contains_expected_keys():
|
|
143
|
+
state = TteokiState()
|
|
144
|
+
state.record_call()
|
|
145
|
+
with patch("chibi_mcp.state.read_snapshot", return_value=_snap()):
|
|
146
|
+
snap = state.snapshot()
|
|
147
|
+
assert set(snap.keys()) >= {"mood", "system", "counters", "timing"}
|
|
148
|
+
assert snap["counters"]["calls_total"] == 1
|
|
149
|
+
assert snap["counters"]["slice_interval"] == DEFAULT_SLICE_INTERVAL
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Tests for system_info.py — psutil wrapper."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from chibi_mcp.system_info import SystemSnapshot, read_snapshot
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_read_snapshot_returns_floats():
|
|
9
|
+
snap = read_snapshot(interval=0.0)
|
|
10
|
+
assert isinstance(snap, SystemSnapshot)
|
|
11
|
+
assert isinstance(snap.cpu_percent, float)
|
|
12
|
+
assert 0.0 <= snap.cpu_percent <= 100.0
|
|
13
|
+
assert 0.0 <= snap.ram_percent <= 100.0
|
|
14
|
+
# battery may be None on desktops
|
|
15
|
+
if snap.battery_percent is not None:
|
|
16
|
+
assert 0.0 <= snap.battery_percent <= 100.0
|
|
17
|
+
assert isinstance(snap.battery_plugged, bool)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_to_dict_rounds_and_handles_no_battery():
|
|
21
|
+
snap = SystemSnapshot(cpu_percent=12.345, ram_percent=67.891, battery_percent=None, battery_plugged=None)
|
|
22
|
+
d = snap.to_dict()
|
|
23
|
+
assert d == {
|
|
24
|
+
"cpu_percent": 12.3,
|
|
25
|
+
"ram_percent": 67.9,
|
|
26
|
+
"battery_percent": None,
|
|
27
|
+
"battery_plugged": None,
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_to_dict_with_battery():
|
|
32
|
+
snap = SystemSnapshot(cpu_percent=10.0, ram_percent=20.0, battery_percent=55.555, battery_plugged=True)
|
|
33
|
+
assert snap.to_dict()["battery_percent"] == 55.6
|
|
34
|
+
assert snap.to_dict()["battery_plugged"] is True
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""WebSocket E2E integration tests.
|
|
2
|
+
|
|
3
|
+
Spin up the real ws_server on an ephemeral port and assert that:
|
|
4
|
+
- connecting clients receive an initial `state` message,
|
|
5
|
+
- the periodic state push fires,
|
|
6
|
+
- manual `pet_say` and `slice` broadcasts reach the client.
|
|
7
|
+
|
|
8
|
+
These tests run in CI alongside the unit tests.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
import contextlib
|
|
15
|
+
import json
|
|
16
|
+
import socket
|
|
17
|
+
|
|
18
|
+
import pytest
|
|
19
|
+
from websockets.asyncio.client import connect
|
|
20
|
+
|
|
21
|
+
from chibi_mcp.state import reset_state_for_tests
|
|
22
|
+
from chibi_mcp.ws_server import (
|
|
23
|
+
get_broadcaster,
|
|
24
|
+
reset_broadcaster_for_tests,
|
|
25
|
+
run_ws_server,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _free_port() -> int:
|
|
30
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
31
|
+
s.bind(("127.0.0.1", 0))
|
|
32
|
+
return s.getsockname()[1]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@pytest.fixture
|
|
36
|
+
async def ws_url():
|
|
37
|
+
"""Start a fresh ws_server on a free port for each test."""
|
|
38
|
+
reset_state_for_tests()
|
|
39
|
+
reset_broadcaster_for_tests()
|
|
40
|
+
port = _free_port()
|
|
41
|
+
server_task = asyncio.create_task(run_ws_server(host="127.0.0.1", port=port))
|
|
42
|
+
# Allow the server a tick to start listening
|
|
43
|
+
for _ in range(20):
|
|
44
|
+
await asyncio.sleep(0.05)
|
|
45
|
+
try:
|
|
46
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
47
|
+
s.settimeout(0.1)
|
|
48
|
+
s.connect(("127.0.0.1", port))
|
|
49
|
+
break
|
|
50
|
+
except OSError:
|
|
51
|
+
continue
|
|
52
|
+
yield f"ws://127.0.0.1:{port}"
|
|
53
|
+
server_task.cancel()
|
|
54
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
55
|
+
await server_task
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
async def test_initial_state_pushed_on_connect(ws_url):
|
|
59
|
+
async with connect(ws_url) as ws:
|
|
60
|
+
raw = await asyncio.wait_for(ws.recv(), timeout=2.0)
|
|
61
|
+
msg = json.loads(raw)
|
|
62
|
+
assert msg["type"] == "state"
|
|
63
|
+
p = msg["payload"]
|
|
64
|
+
assert "mood" in p
|
|
65
|
+
assert "system" in p
|
|
66
|
+
assert "counters" in p
|
|
67
|
+
assert "timing" in p
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
async def test_pet_say_broadcasts_to_client(ws_url):
|
|
71
|
+
async with connect(ws_url) as ws:
|
|
72
|
+
# First message: state. Skip it.
|
|
73
|
+
await asyncio.wait_for(ws.recv(), timeout=2.0)
|
|
74
|
+
|
|
75
|
+
# Trigger a say broadcast
|
|
76
|
+
await get_broadcaster().broadcast({"type": "say", "text": "hello!"})
|
|
77
|
+
raw = await asyncio.wait_for(ws.recv(), timeout=2.0)
|
|
78
|
+
msg = json.loads(raw)
|
|
79
|
+
assert msg["type"] == "say"
|
|
80
|
+
assert msg["text"] == "hello!"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
async def test_slice_event_broadcasts_to_client(ws_url):
|
|
84
|
+
async with connect(ws_url) as ws:
|
|
85
|
+
await asyncio.wait_for(ws.recv(), timeout=2.0)
|
|
86
|
+
|
|
87
|
+
await get_broadcaster().broadcast({"type": "slice"})
|
|
88
|
+
raw = await asyncio.wait_for(ws.recv(), timeout=2.0)
|
|
89
|
+
msg = json.loads(raw)
|
|
90
|
+
assert msg["type"] == "slice"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
async def test_multiple_clients_all_receive_broadcast(ws_url):
|
|
94
|
+
async with connect(ws_url) as ws1, connect(ws_url) as ws2:
|
|
95
|
+
# Drain initial state on both
|
|
96
|
+
await asyncio.wait_for(ws1.recv(), timeout=2.0)
|
|
97
|
+
await asyncio.wait_for(ws2.recv(), timeout=2.0)
|
|
98
|
+
|
|
99
|
+
await get_broadcaster().broadcast({"type": "say", "text": "broadcast"})
|
|
100
|
+
|
|
101
|
+
m1 = json.loads(await asyncio.wait_for(ws1.recv(), timeout=2.0))
|
|
102
|
+
m2 = json.loads(await asyncio.wait_for(ws2.recv(), timeout=2.0))
|
|
103
|
+
assert m1["type"] == "say" and m2["type"] == "say"
|
|
104
|
+
assert m1["text"] == "broadcast" and m2["text"] == "broadcast"
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
async def test_state_pushes_periodically(ws_url):
|
|
108
|
+
"""Verify the 2s push loop fires at least once during the test window."""
|
|
109
|
+
async with connect(ws_url) as ws:
|
|
110
|
+
# initial connect message
|
|
111
|
+
await asyncio.wait_for(ws.recv(), timeout=2.0)
|
|
112
|
+
# next message should be the periodic state (within ~3s)
|
|
113
|
+
raw = await asyncio.wait_for(ws.recv(), timeout=3.5)
|
|
114
|
+
msg = json.loads(raw)
|
|
115
|
+
assert msg["type"] == "state"
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
async def test_disconnect_unregisters_client(ws_url):
|
|
119
|
+
broadcaster = get_broadcaster()
|
|
120
|
+
async with connect(ws_url) as ws:
|
|
121
|
+
await asyncio.wait_for(ws.recv(), timeout=2.0)
|
|
122
|
+
# one client should be registered after connect+initial recv
|
|
123
|
+
# give the server a tick to register
|
|
124
|
+
await asyncio.sleep(0.05)
|
|
125
|
+
assert len(broadcaster._clients) == 1
|
|
126
|
+
# After context exit, ws is closed. Give server a tick to unregister.
|
|
127
|
+
await asyncio.sleep(0.2)
|
|
128
|
+
assert len(broadcaster._clients) == 0
|