@agentunion/kite 1.0.7 → 1.3.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 (100) hide show
  1. package/CHANGELOG.md +208 -0
  2. package/README.md +48 -0
  3. package/cli.js +1 -1
  4. package/extensions/agents/__init__.py +1 -0
  5. package/extensions/agents/assistant/__init__.py +1 -0
  6. package/extensions/agents/assistant/entry.py +329 -0
  7. package/extensions/agents/assistant/module.md +22 -0
  8. package/extensions/agents/assistant/server.py +197 -0
  9. package/extensions/channels/__init__.py +1 -0
  10. package/extensions/channels/acp_channel/__init__.py +1 -0
  11. package/extensions/channels/acp_channel/entry.py +329 -0
  12. package/extensions/channels/acp_channel/module.md +22 -0
  13. package/extensions/channels/acp_channel/server.py +197 -0
  14. package/extensions/event_hub_bench/entry.py +624 -379
  15. package/extensions/event_hub_bench/module.md +2 -1
  16. package/extensions/services/backup/__init__.py +1 -0
  17. package/extensions/services/backup/entry.py +508 -0
  18. package/extensions/services/backup/module.md +22 -0
  19. package/extensions/services/model_service/__init__.py +1 -0
  20. package/extensions/services/model_service/entry.py +508 -0
  21. package/extensions/services/model_service/module.md +22 -0
  22. package/extensions/services/watchdog/entry.py +468 -102
  23. package/extensions/services/watchdog/module.md +3 -0
  24. package/extensions/services/watchdog/monitor.py +170 -69
  25. package/extensions/services/web/__init__.py +1 -0
  26. package/extensions/services/web/config.yaml +149 -0
  27. package/extensions/services/web/entry.py +390 -0
  28. package/extensions/services/web/module.md +24 -0
  29. package/extensions/services/web/routes/__init__.py +1 -0
  30. package/extensions/services/web/routes/routes_call.py +189 -0
  31. package/extensions/services/web/routes/routes_config.py +512 -0
  32. package/extensions/services/web/routes/routes_contacts.py +98 -0
  33. package/extensions/services/web/routes/routes_devlog.py +99 -0
  34. package/extensions/services/web/routes/routes_phone.py +81 -0
  35. package/extensions/services/web/routes/routes_sms.py +48 -0
  36. package/extensions/services/web/routes/routes_stats.py +17 -0
  37. package/extensions/services/web/routes/routes_voicechat.py +554 -0
  38. package/extensions/services/web/routes/schemas.py +216 -0
  39. package/extensions/services/web/server.py +375 -0
  40. package/extensions/services/web/static/css/style.css +1064 -0
  41. package/extensions/services/web/static/index.html +1445 -0
  42. package/extensions/services/web/static/js/app.js +4671 -0
  43. package/extensions/services/web/vendor/__init__.py +1 -0
  44. package/extensions/services/web/vendor/bluetooth/audio.py +348 -0
  45. package/extensions/services/web/vendor/bluetooth/contacts.py +251 -0
  46. package/extensions/services/web/vendor/bluetooth/manager.py +395 -0
  47. package/extensions/services/web/vendor/bluetooth/sms.py +290 -0
  48. package/extensions/services/web/vendor/bluetooth/telephony.py +274 -0
  49. package/extensions/services/web/vendor/config.py +139 -0
  50. package/extensions/services/web/vendor/conversation/asr.py +936 -0
  51. package/extensions/services/web/vendor/conversation/engine.py +548 -0
  52. package/extensions/services/web/vendor/conversation/llm.py +534 -0
  53. package/extensions/services/web/vendor/conversation/mcp_tools.py +190 -0
  54. package/extensions/services/web/vendor/conversation/tts.py +322 -0
  55. package/extensions/services/web/vendor/conversation/vad.py +138 -0
  56. package/extensions/services/web/vendor/storage/__init__.py +1 -0
  57. package/extensions/services/web/vendor/storage/identity.py +312 -0
  58. package/extensions/services/web/vendor/storage/store.py +507 -0
  59. package/extensions/services/web/vendor/task/manager.py +864 -0
  60. package/extensions/services/web/vendor/task/models.py +45 -0
  61. package/extensions/services/web/vendor/task/webhook.py +263 -0
  62. package/extensions/services/web/vendor/tools/registry.py +321 -0
  63. package/kernel/__init__.py +0 -0
  64. package/kernel/entry.py +407 -0
  65. package/{core/event_hub/hub.py → kernel/event_hub.py} +62 -74
  66. package/kernel/module.md +33 -0
  67. package/{core/registry/store.py → kernel/registry_store.py} +23 -8
  68. package/kernel/rpc_router.py +388 -0
  69. package/kernel/server.py +267 -0
  70. package/launcher/__init__.py +10 -0
  71. package/launcher/__main__.py +6 -0
  72. package/launcher/count_lines.py +258 -0
  73. package/launcher/entry.py +1778 -0
  74. package/launcher/logging_setup.py +289 -0
  75. package/{core/launcher → launcher}/module_scanner.py +11 -6
  76. package/launcher/process_manager.py +880 -0
  77. package/main.py +11 -210
  78. package/package.json +6 -9
  79. package/__init__.py +0 -1
  80. package/__main__.py +0 -15
  81. package/core/event_hub/BENCHMARK.md +0 -94
  82. package/core/event_hub/bench.py +0 -459
  83. package/core/event_hub/bench_extreme.py +0 -308
  84. package/core/event_hub/bench_perf.py +0 -350
  85. package/core/event_hub/entry.py +0 -157
  86. package/core/event_hub/module.md +0 -20
  87. package/core/event_hub/server.py +0 -206
  88. package/core/launcher/entry.py +0 -1158
  89. package/core/launcher/process_manager.py +0 -470
  90. package/core/registry/entry.py +0 -110
  91. package/core/registry/module.md +0 -30
  92. package/core/registry/server.py +0 -289
  93. package/extensions/services/watchdog/server.py +0 -167
  94. /package/{core → extensions/services/web/vendor/bluetooth}/__init__.py +0 -0
  95. /package/{core/event_hub → extensions/services/web/vendor/conversation}/__init__.py +0 -0
  96. /package/{core/launcher → extensions/services/web/vendor/task}/__init__.py +0 -0
  97. /package/{core/registry → extensions/services/web/vendor/tools}/__init__.py +0 -0
  98. /package/{core/event_hub → kernel}/dedup.py +0 -0
  99. /package/{core/event_hub → kernel}/router.py +0 -0
  100. /package/{core/launcher → launcher}/module.md +0 -0
@@ -0,0 +1,216 @@
1
+ """Pydantic models for all API request / response types."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Optional
6
+
7
+ from pydantic import BaseModel, Field
8
+
9
+
10
+ # ---------------------------------------------------------------------------
11
+ # Call
12
+ # ---------------------------------------------------------------------------
13
+
14
+ class CallRequest(BaseModel):
15
+ phone_number: Optional[str] = None
16
+ purpose: str
17
+ system_prompt: Optional[str] = None
18
+ webhook_url: Optional[str] = None
19
+ max_duration_seconds: int = 300
20
+ language: str = "zh"
21
+ play_text: Optional[str] = None
22
+ require_confirmation: Optional[bool] = None
23
+
24
+
25
+ class CallResponse(BaseModel):
26
+ task_id: str
27
+ status: str
28
+
29
+
30
+ class CallStatus(BaseModel):
31
+ task_id: str
32
+ status: str
33
+ phone_number: Optional[str] = None
34
+ contact_name: Optional[str] = None
35
+ direction: Optional[str] = None
36
+ duration_seconds: Optional[float] = None
37
+ result: Optional[str] = None
38
+ summary: Optional[str] = None
39
+ started_at: Optional[str] = None
40
+ ended_at: Optional[str] = None
41
+ has_recording: Optional[bool] = None
42
+
43
+
44
+ class CallConfirmRequest(BaseModel):
45
+ action: str
46
+ phone_number: Optional[str] = None
47
+ system_prompt: Optional[str] = None
48
+ purpose: Optional[str] = None
49
+
50
+
51
+ class CallMessageRequest(BaseModel):
52
+ message: str
53
+
54
+
55
+ class HangupResponse(BaseModel):
56
+ task_id: str
57
+ status: str
58
+
59
+
60
+ # ---------------------------------------------------------------------------
61
+ # Phone / Bluetooth
62
+ # ---------------------------------------------------------------------------
63
+
64
+ class PhoneStatus(BaseModel):
65
+ bluetooth_connected: bool = False
66
+ device_name: Optional[str] = None
67
+ device_address: Optional[str] = None
68
+ battery_level: Optional[int] = None
69
+ signal_strength: Optional[int] = None
70
+ operator: Optional[str] = None
71
+ in_call: bool = False
72
+
73
+
74
+ # ---------------------------------------------------------------------------
75
+ # SMS
76
+ # ---------------------------------------------------------------------------
77
+
78
+ class SMSRequest(BaseModel):
79
+ phone_number: str
80
+ content: str
81
+
82
+
83
+ class SMSRecord(BaseModel):
84
+ id: str
85
+ phone_number: str
86
+ contact_name: Optional[str] = None
87
+ direction: str
88
+ content: str
89
+ status: Optional[str] = None
90
+ timestamp: Optional[str] = None
91
+
92
+
93
+ # ---------------------------------------------------------------------------
94
+ # Contacts
95
+ # ---------------------------------------------------------------------------
96
+
97
+ class ContactCreate(BaseModel):
98
+ name: str
99
+ phone: str
100
+ company: Optional[str] = None
101
+ title: Optional[str] = None
102
+ notes: Optional[str] = None
103
+ tags: Optional[list[str]] = None
104
+
105
+
106
+ class ContactUpdate(BaseModel):
107
+ name: Optional[str] = None
108
+ phone: Optional[str] = None
109
+ company: Optional[str] = None
110
+ title: Optional[str] = None
111
+ notes: Optional[str] = None
112
+ tags: Optional[list[str]] = None
113
+
114
+
115
+ class ContactRecord(BaseModel):
116
+ id: str
117
+ name: str
118
+ phone: str
119
+ company: Optional[str] = None
120
+ title: Optional[str] = None
121
+ notes: Optional[str] = None
122
+ tags: Optional[list[str]] = None
123
+ source: Optional[str] = None
124
+ created_at: Optional[str] = None
125
+ updated_at: Optional[str] = None
126
+
127
+
128
+ # ---------------------------------------------------------------------------
129
+ # Paginated wrapper
130
+ # ---------------------------------------------------------------------------
131
+
132
+ class PaginatedResponse(BaseModel):
133
+ items: list[Any] = Field(default_factory=list)
134
+ total: int = 0
135
+ page: int = 1
136
+ page_size: int = 20
137
+
138
+
139
+ # ---------------------------------------------------------------------------
140
+ # Stats
141
+ # ---------------------------------------------------------------------------
142
+
143
+ class StatsResponse(BaseModel):
144
+ total_calls: int = 0
145
+ total_duration_seconds: float = 0
146
+ avg_duration_seconds: float = 0
147
+ calls_today: int = 0
148
+ calls_this_week: int = 0
149
+ calls_by_result: dict[str, int] = Field(default_factory=dict)
150
+ calls_by_direction: dict[str, int] = Field(default_factory=dict)
151
+ total_sms_sent: int = 0
152
+ total_sms_received: int = 0
153
+ total_contacts: int = 0
154
+
155
+
156
+ # ---------------------------------------------------------------------------
157
+ # Webhook
158
+ # ---------------------------------------------------------------------------
159
+
160
+ class WebhookPayload(BaseModel):
161
+ task_id: str
162
+ type: str
163
+ status: str
164
+ phone_number: Optional[str] = None
165
+ contact_name: Optional[str] = None
166
+ direction: Optional[str] = None
167
+ duration_seconds: Optional[float] = None
168
+ transcript: Optional[list[dict[str, Any]]] = None
169
+ summary: Optional[str] = None
170
+ result: Optional[str] = None
171
+ ended_reason: Optional[str] = None
172
+ recording_url: Optional[str] = None
173
+ timestamp: Optional[str] = None
174
+ matched_contact: Optional[dict[str, Any]] = None
175
+ contact_info: Optional[dict[str, Any]] = None
176
+
177
+
178
+ # ---------------------------------------------------------------------------
179
+ # Dev Log
180
+ # ---------------------------------------------------------------------------
181
+
182
+ class DevLogCreate(BaseModel):
183
+ content: str
184
+ important: bool = False
185
+ urgent: bool = False
186
+ type: str = "需求" # 需求 | BUG | 优化 | 重构 | 澄清 | 文档
187
+
188
+
189
+ class DevLogUpdate(BaseModel):
190
+ content: Optional[str] = None
191
+ important: Optional[bool] = None
192
+ urgent: Optional[bool] = None
193
+ status: Optional[str] = None
194
+ type: Optional[str] = None
195
+
196
+
197
+ class DevLogRecord(BaseModel):
198
+ id: str
199
+ content: str
200
+ important: bool = False
201
+ urgent: bool = False
202
+ type: str = "需求"
203
+ status: str = "pending"
204
+ created_at: Optional[str] = None
205
+ updated_at: Optional[str] = None
206
+ completed_at: Optional[str] = None
207
+
208
+
209
+ # ---------------------------------------------------------------------------
210
+ # Config
211
+ # ---------------------------------------------------------------------------
212
+
213
+ class ConfigUpdate(BaseModel):
214
+ """Arbitrary config update payload — accepts any key/value pairs."""
215
+
216
+ model_config = {"extra": "allow"}
@@ -0,0 +1,375 @@
1
+ """
2
+ Web Management HTTP server.
3
+ Full web UI with all AI Phone Agent API endpoints.
4
+ Exposes /health, /status, static frontend, and all /api/* routes.
5
+ Connects to Kernel via WebSocket JSON-RPC 2.0 for event publishing and subscription.
6
+ """
7
+
8
+ import asyncio
9
+ import json
10
+ import logging
11
+ import time
12
+ import uuid
13
+ from datetime import datetime, timezone
14
+ from pathlib import Path
15
+
16
+ import websockets
17
+ from fastapi import FastAPI
18
+ from fastapi.staticfiles import StaticFiles
19
+
20
+ from vendor import config as cfg
21
+ from vendor.bluetooth.manager import BluetoothManager
22
+ from vendor.task.manager import TaskManager
23
+ from vendor.tools.registry import init_registry
24
+
25
+ from routes.routes_call import router as call_router
26
+ from routes.routes_phone import router as phone_router
27
+ from routes.routes_config import router as config_router
28
+ from routes.routes_sms import router as sms_router
29
+ from routes.routes_contacts import router as contacts_router
30
+ from routes.routes_stats import router as stats_router
31
+ from routes.routes_voicechat import router as voicechat_router
32
+ from routes.routes_devlog import router as devlog_router
33
+
34
+ logger = logging.getLogger(__name__)
35
+
36
+
37
+ class WebServer:
38
+
39
+ def __init__(self, token: str = "", kernel_port: int = 0,
40
+ host: str = "0.0.0.0", port: int = 0, boot_t0: float = 0):
41
+ self.token = token
42
+ self.kernel_port = kernel_port
43
+ self.host = host
44
+ self.port = port
45
+ self.boot_t0 = boot_t0
46
+ self._ws_task: asyncio.Task | None = None
47
+ self._test_task: asyncio.Task | None = None
48
+ self._ws: object | None = None
49
+ self._ready_sent = False
50
+ self._shutting_down = False
51
+ self._uvicorn_server = None # set by entry.py for graceful shutdown
52
+ self._start_time = time.time()
53
+ self.bt_manager: BluetoothManager | None = None
54
+ self.task_manager: TaskManager | None = None
55
+ self.app = self._create_app()
56
+
57
+ def _create_app(self) -> FastAPI:
58
+ app = FastAPI(title="Kite Web Management", docs_url="/docs", redoc_url=None)
59
+ server = self
60
+
61
+ @app.on_event("startup")
62
+ async def _startup():
63
+ # Load configuration
64
+ cfg.load_config()
65
+ load_err = cfg.get_load_error()
66
+ if load_err:
67
+ print(f"[web] 配置加载失败,无法启动:\n {load_err}")
68
+ # Schedule graceful exit instead of crashing
69
+ if server._uvicorn_server:
70
+ server._uvicorn_server.should_exit = True
71
+ return
72
+
73
+ # Ensure data directories exist
74
+ base = cfg.data_dir()
75
+ for sub in ("sms", "contacts", "tasks", "config", "devlog", "users", "tools"):
76
+ (base / sub).mkdir(parents=True, exist_ok=True)
77
+
78
+ logging.basicConfig(
79
+ level=logging.INFO,
80
+ format="%(asctime)s [%(name)s] %(levelname)s: %(message)s",
81
+ )
82
+
83
+ # Initialize tool registry
84
+ registry = init_registry()
85
+ app.state.tool_registry = registry
86
+
87
+ # Initialize managers
88
+ server.bt_manager = BluetoothManager()
89
+ server.task_manager = TaskManager(server.bt_manager)
90
+
91
+ app.state.bt_manager = server.bt_manager
92
+ app.state.task_manager = server.task_manager
93
+
94
+ # Start bluetooth auto-connect in background
95
+ asyncio.create_task(server.bt_manager.start())
96
+
97
+ logger.info("Web Management: managers initialized")
98
+
99
+ # Start background tasks directly
100
+ if server.kernel_port:
101
+ server._ws_task = asyncio.create_task(server._ws_loop())
102
+ server._test_task = asyncio.create_task(server._test_event_loop())
103
+
104
+ @app.on_event("shutdown")
105
+ async def _shutdown():
106
+ if server._ws_task:
107
+ server._ws_task.cancel()
108
+ if server._test_task:
109
+ server._test_task.cancel()
110
+ if server._ws:
111
+ await server._ws.close()
112
+ if server.bt_manager:
113
+ await server.bt_manager.stop()
114
+ print("[web] Shutdown complete")
115
+
116
+ # Health and status endpoints
117
+ @app.get("/health")
118
+ async def health():
119
+ return {
120
+ "status": "healthy",
121
+ "details": {
122
+ "kernel_connected": server._ws is not None,
123
+ "uptime_seconds": round(time.time() - server._start_time),
124
+ },
125
+ }
126
+
127
+ @app.get("/status")
128
+ async def status():
129
+ return {
130
+ "module": "web",
131
+ "status": "running",
132
+ "event_hub_connected": server._ws is not None,
133
+ "uptime_seconds": round(time.time() - server._start_time),
134
+ }
135
+
136
+ # Mount all API routes
137
+ app.include_router(call_router, prefix="/api")
138
+ app.include_router(phone_router, prefix="/api")
139
+ app.include_router(config_router, prefix="/api")
140
+ app.include_router(sms_router, prefix="/api")
141
+ app.include_router(contacts_router, prefix="/api")
142
+ app.include_router(stats_router, prefix="/api")
143
+ app.include_router(voicechat_router) # no prefix (has own /ws/ and /api/ paths)
144
+ app.include_router(devlog_router, prefix="/api")
145
+
146
+ # Serve frontend static files
147
+ static_dir = Path(__file__).parent / "static"
148
+ if static_dir.exists():
149
+ app.mount("/", StaticFiles(directory=str(static_dir), html=True), name="web")
150
+
151
+ return app
152
+
153
+ # ── Kernel WebSocket client ──
154
+
155
+ async def _ws_loop(self):
156
+ """Connect to Kernel, subscribe, register, and listen. Reconnect on failure."""
157
+ retry_delay = 0.5 # start with 0.5s
158
+ max_delay = 30 # cap at 30s
159
+ while not self._shutting_down:
160
+ try:
161
+ await self._ws_connect()
162
+ retry_delay = 0.5 # reset on successful connection
163
+ except asyncio.CancelledError:
164
+ return
165
+ except Exception as e:
166
+ print(f"[web] Kernel connection error: {e}, retrying in {retry_delay:.1f}s")
167
+ self._ws = None
168
+ if self._shutting_down:
169
+ return
170
+ await asyncio.sleep(retry_delay)
171
+ retry_delay = min(retry_delay * 2, max_delay) # exponential backoff
172
+
173
+ async def _ws_connect(self):
174
+ """Single WebSocket session: connect, register, subscribe, receive loop."""
175
+ url = f"ws://127.0.0.1:{self.kernel_port}/ws?token={self.token}&id=web"
176
+ print(f"[web] WS connecting to Kernel")
177
+ async with websockets.connect(url, open_timeout=5, ping_interval=None, ping_timeout=None, close_timeout=10) as ws:
178
+ self._ws = ws
179
+ elapsed = time.monotonic() - self.boot_t0 if self.boot_t0 else 0
180
+ elapsed_str = f" ({elapsed:.1f}s)" if elapsed else ""
181
+ print(f"[web] Connected to Kernel{elapsed_str}")
182
+
183
+ # Subscribe to events
184
+ await self._rpc_call(ws, "event.subscribe", {
185
+ "events": [
186
+ "module.started",
187
+ "module.stopped",
188
+ "module.shutdown",
189
+ ],
190
+ })
191
+
192
+ # Register to Kernel Registry via RPC
193
+ await self._rpc_call(ws, "registry.register", {
194
+ "module_id": "web",
195
+ "module_type": "service",
196
+ "api_endpoint": f"http://127.0.0.1:{self.port}",
197
+ "health_endpoint": "/health",
198
+ "events_publish": {
199
+ "web.test": {"description": "Test event from web module"},
200
+ "web.started": {"description": "Web UI started with access URL"},
201
+ },
202
+ "events_subscribe": [
203
+ "module.started",
204
+ "module.stopped",
205
+ "module.shutdown",
206
+ ],
207
+ })
208
+ print(f"[web] Registered to Kernel{elapsed_str}")
209
+
210
+ # Send module.ready (once) so Launcher knows we're up
211
+ if not self._ready_sent:
212
+ await self._rpc_call(ws, "event.publish", {
213
+ "event_id": str(uuid.uuid4()),
214
+ "event": "module.ready",
215
+ "data": {
216
+ "module_id": "web",
217
+ "graceful_shutdown": True,
218
+ },
219
+ })
220
+ self._ready_sent = True
221
+ elapsed = time.monotonic() - self.boot_t0 if self.boot_t0 else 0
222
+ elapsed_str = f" ({elapsed:.1f}s)" if elapsed else ""
223
+ print(f"[web] module.ready sent{elapsed_str}")
224
+
225
+ # Publish web.started event with access URL
226
+ display_host = "localhost" if self.host == "0.0.0.0" else self.host
227
+ access_url = f"http://{display_host}:{self.port}"
228
+ await self._publish_event({
229
+ "event": "web.started",
230
+ "data": {
231
+ "module_id": "web",
232
+ "url": access_url,
233
+ "host": self.host,
234
+ "port": self.port,
235
+ },
236
+ })
237
+
238
+ # Receive loop
239
+ async for raw in ws:
240
+ try:
241
+ msg = json.loads(raw)
242
+ except (json.JSONDecodeError, TypeError):
243
+ continue
244
+
245
+ try:
246
+ has_method = "method" in msg
247
+ has_id = "id" in msg
248
+
249
+ if has_method and not has_id:
250
+ # Event Notification
251
+ await self._handle_event_notification(msg)
252
+ elif has_method and has_id:
253
+ # Incoming RPC request
254
+ await self._handle_rpc_request(ws, msg)
255
+ # Ignore RPC responses (we don't await them in this simple impl)
256
+ except Exception as e:
257
+ print(f"[web] 消息处理异常(已忽略): {e}")
258
+
259
+ async def _rpc_call(self, ws, method: str, params: dict = None):
260
+ """Send a JSON-RPC 2.0 request (fire-and-forget, no response awaited)."""
261
+ msg = {"jsonrpc": "2.0", "id": str(uuid.uuid4()), "method": method}
262
+ if params:
263
+ msg["params"] = params
264
+ await ws.send(json.dumps(msg))
265
+
266
+ async def _handle_event_notification(self, msg: dict):
267
+ """Handle an event notification (JSON-RPC 2.0 Notification with method='event')."""
268
+ params = msg.get("params", {})
269
+ event_type = params.get("event", "")
270
+ data = params.get("data", {})
271
+
272
+ # Special handling for module.shutdown targeting web
273
+ if event_type == "module.shutdown" and data.get("module_id") == "web":
274
+ await self._handle_shutdown()
275
+ return
276
+
277
+ # Log other events
278
+ print(f"[web] Event received: {event_type}")
279
+
280
+ async def _handle_rpc_request(self, ws, msg: dict):
281
+ """Handle an incoming RPC request (web.* methods)."""
282
+ rpc_id = msg.get("id", "")
283
+ method = msg.get("method", "")
284
+ params = msg.get("params", {})
285
+
286
+ handlers = {
287
+ "health": lambda p: self._rpc_health(),
288
+ "status": lambda p: self._rpc_status(),
289
+ }
290
+ handler = handlers.get(method)
291
+ if handler:
292
+ try:
293
+ result = await handler(params)
294
+ await ws.send(json.dumps({"jsonrpc": "2.0", "id": rpc_id, "result": result}))
295
+ except Exception as e:
296
+ await ws.send(json.dumps({
297
+ "jsonrpc": "2.0", "id": rpc_id,
298
+ "error": {"code": -32603, "message": str(e)},
299
+ }))
300
+ else:
301
+ await ws.send(json.dumps({
302
+ "jsonrpc": "2.0", "id": rpc_id,
303
+ "error": {"code": -32601, "message": f"Method not found: {method}"},
304
+ }))
305
+
306
+ async def _rpc_health(self) -> dict:
307
+ """RPC handler for web.health."""
308
+ return {
309
+ "status": "healthy",
310
+ "details": {
311
+ "uptime_seconds": round(time.time() - self._start_time),
312
+ },
313
+ }
314
+
315
+ async def _rpc_status(self) -> dict:
316
+ """RPC handler for web.status."""
317
+ return {
318
+ "module": "web",
319
+ "status": "running",
320
+ "uptime_seconds": round(time.time() - self._start_time),
321
+ }
322
+
323
+ async def _handle_shutdown(self):
324
+ """Handle module.shutdown: ack → cleanup → ready → exit."""
325
+ print("[web] Received module.shutdown")
326
+ self._shutting_down = True
327
+
328
+ # Step 1: Send ack
329
+ await self._publish_event({
330
+ "event": "module.shutdown.ack",
331
+ "data": {"module_id": "web", "estimated_cleanup": 2},
332
+ })
333
+ print("[web] shutdown ack sent")
334
+
335
+ # Step 2: Cleanup (cancel background tasks)
336
+ if self._test_task:
337
+ self._test_task.cancel()
338
+ if self.bt_manager:
339
+ await self.bt_manager.stop()
340
+
341
+ # Step 3: Send ready (before closing WS!)
342
+ await self._publish_event({
343
+ "event": "module.shutdown.ready",
344
+ "data": {"module_id": "web"},
345
+ })
346
+ print("[web] Shutdown complete")
347
+
348
+ # Step 4: Trigger uvicorn exit (WS will close when uvicorn shuts down)
349
+ if self._uvicorn_server:
350
+ self._uvicorn_server.should_exit = True
351
+
352
+ async def _publish_event(self, event: dict):
353
+ """Publish an event via RPC event.publish."""
354
+ if not self._ws:
355
+ return
356
+ await self._rpc_call(self._ws, "event.publish", {
357
+ "event_id": str(uuid.uuid4()),
358
+ "event": event.get("event", ""),
359
+ "data": event.get("data", {}),
360
+ })
361
+
362
+ # ── Test event loop ──
363
+
364
+ async def _test_event_loop(self):
365
+ """Publish a test event every 10 seconds."""
366
+ while True:
367
+ await asyncio.sleep(10)
368
+ await self._publish_event({
369
+ "event": "web.test",
370
+ "data": {
371
+ "message": "test event from web",
372
+ "timestamp": datetime.now(timezone.utc).isoformat(),
373
+ },
374
+ })
375
+ print("[web] test event published")