@agentunion/kite 1.0.6 → 1.2.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 (112) hide show
  1. package/cli.js +127 -25
  2. package/core/event_hub/entry.py +384 -61
  3. package/core/event_hub/hub.py +8 -0
  4. package/core/event_hub/module.md +0 -1
  5. package/core/event_hub/server.py +169 -38
  6. package/core/kite_log.py +241 -0
  7. package/core/launcher/entry.py +1306 -425
  8. package/core/launcher/module_scanner.py +10 -9
  9. package/core/launcher/process_manager.py +555 -121
  10. package/core/registry/entry.py +335 -30
  11. package/core/registry/server.py +339 -256
  12. package/core/registry/store.py +13 -2
  13. package/extensions/agents/__init__.py +1 -0
  14. package/extensions/agents/assistant/__init__.py +1 -0
  15. package/extensions/agents/assistant/entry.py +380 -0
  16. package/extensions/agents/assistant/module.md +22 -0
  17. package/extensions/agents/assistant/server.py +236 -0
  18. package/extensions/channels/__init__.py +1 -0
  19. package/extensions/channels/acp_channel/__init__.py +1 -0
  20. package/extensions/channels/acp_channel/entry.py +380 -0
  21. package/extensions/channels/acp_channel/module.md +22 -0
  22. package/extensions/channels/acp_channel/server.py +236 -0
  23. package/{core → extensions}/event_hub_bench/entry.py +664 -371
  24. package/{core → extensions}/event_hub_bench/module.md +4 -2
  25. package/extensions/services/backup/__init__.py +1 -0
  26. package/extensions/services/backup/entry.py +380 -0
  27. package/extensions/services/backup/module.md +22 -0
  28. package/extensions/services/backup/server.py +244 -0
  29. package/extensions/services/model_service/__init__.py +1 -0
  30. package/extensions/services/model_service/entry.py +380 -0
  31. package/extensions/services/model_service/module.md +22 -0
  32. package/extensions/services/model_service/server.py +236 -0
  33. package/extensions/services/watchdog/entry.py +460 -143
  34. package/extensions/services/watchdog/module.md +3 -0
  35. package/extensions/services/watchdog/monitor.py +128 -13
  36. package/extensions/services/watchdog/server.py +75 -13
  37. package/extensions/services/web/__init__.py +1 -0
  38. package/extensions/services/web/config.yaml +149 -0
  39. package/extensions/services/web/entry.py +487 -0
  40. package/extensions/services/web/module.md +24 -0
  41. package/extensions/services/web/routes/__init__.py +1 -0
  42. package/extensions/services/web/routes/routes_call.py +189 -0
  43. package/extensions/services/web/routes/routes_config.py +512 -0
  44. package/extensions/services/web/routes/routes_contacts.py +98 -0
  45. package/extensions/services/web/routes/routes_devlog.py +99 -0
  46. package/extensions/services/web/routes/routes_phone.py +81 -0
  47. package/extensions/services/web/routes/routes_sms.py +48 -0
  48. package/extensions/services/web/routes/routes_stats.py +17 -0
  49. package/extensions/services/web/routes/routes_voicechat.py +554 -0
  50. package/extensions/services/web/routes/schemas.py +216 -0
  51. package/extensions/services/web/server.py +332 -0
  52. package/extensions/services/web/static/css/style.css +1064 -0
  53. package/extensions/services/web/static/index.html +1445 -0
  54. package/extensions/services/web/static/js/app.js +4671 -0
  55. package/extensions/services/web/vendor/__init__.py +1 -0
  56. package/extensions/services/web/vendor/bluetooth/audio.py +348 -0
  57. package/extensions/services/web/vendor/bluetooth/contacts.py +251 -0
  58. package/extensions/services/web/vendor/bluetooth/manager.py +395 -0
  59. package/extensions/services/web/vendor/bluetooth/sms.py +290 -0
  60. package/extensions/services/web/vendor/bluetooth/telephony.py +274 -0
  61. package/extensions/services/web/vendor/config.py +139 -0
  62. package/extensions/services/web/vendor/conversation/__init__.py +0 -0
  63. package/extensions/services/web/vendor/conversation/asr.py +936 -0
  64. package/extensions/services/web/vendor/conversation/engine.py +548 -0
  65. package/extensions/services/web/vendor/conversation/llm.py +534 -0
  66. package/extensions/services/web/vendor/conversation/mcp_tools.py +190 -0
  67. package/extensions/services/web/vendor/conversation/tts.py +322 -0
  68. package/extensions/services/web/vendor/conversation/vad.py +138 -0
  69. package/extensions/services/web/vendor/storage/__init__.py +1 -0
  70. package/extensions/services/web/vendor/storage/identity.py +312 -0
  71. package/extensions/services/web/vendor/storage/store.py +507 -0
  72. package/extensions/services/web/vendor/task/__init__.py +0 -0
  73. package/extensions/services/web/vendor/task/manager.py +864 -0
  74. package/extensions/services/web/vendor/task/models.py +45 -0
  75. package/extensions/services/web/vendor/task/webhook.py +263 -0
  76. package/extensions/services/web/vendor/tools/__init__.py +0 -0
  77. package/extensions/services/web/vendor/tools/registry.py +321 -0
  78. package/main.py +344 -4
  79. package/package.json +11 -2
  80. package/core/__pycache__/__init__.cpython-313.pyc +0 -0
  81. package/core/__pycache__/data_dir.cpython-313.pyc +0 -0
  82. package/core/data_dir.py +0 -62
  83. package/core/event_hub/__pycache__/__init__.cpython-313.pyc +0 -0
  84. package/core/event_hub/__pycache__/bench.cpython-313.pyc +0 -0
  85. package/core/event_hub/__pycache__/bench_perf.cpython-313.pyc +0 -0
  86. package/core/event_hub/__pycache__/dedup.cpython-313.pyc +0 -0
  87. package/core/event_hub/__pycache__/entry.cpython-313.pyc +0 -0
  88. package/core/event_hub/__pycache__/hub.cpython-313.pyc +0 -0
  89. package/core/event_hub/__pycache__/router.cpython-313.pyc +0 -0
  90. package/core/event_hub/__pycache__/server.cpython-313.pyc +0 -0
  91. package/core/event_hub/bench_results/2026-02-28_13-26-48.json +0 -51
  92. package/core/event_hub/bench_results/2026-02-28_13-44-45.json +0 -51
  93. package/core/event_hub/bench_results/2026-02-28_13-45-39.json +0 -51
  94. package/core/launcher/__pycache__/__init__.cpython-313.pyc +0 -0
  95. package/core/launcher/__pycache__/entry.cpython-313.pyc +0 -0
  96. package/core/launcher/__pycache__/module_scanner.cpython-313.pyc +0 -0
  97. package/core/launcher/__pycache__/process_manager.cpython-313.pyc +0 -0
  98. package/core/launcher/data/log/lifecycle.jsonl +0 -1158
  99. package/core/launcher/data/token.txt +0 -1
  100. package/core/registry/__pycache__/__init__.cpython-313.pyc +0 -0
  101. package/core/registry/__pycache__/entry.cpython-313.pyc +0 -0
  102. package/core/registry/__pycache__/server.cpython-313.pyc +0 -0
  103. package/core/registry/__pycache__/store.cpython-313.pyc +0 -0
  104. package/core/registry/data/port.txt +0 -1
  105. package/core/registry/data/port_484.txt +0 -1
  106. package/extensions/__pycache__/__init__.cpython-313.pyc +0 -0
  107. package/extensions/services/__pycache__/__init__.cpython-313.pyc +0 -0
  108. package/extensions/services/watchdog/__pycache__/__init__.cpython-313.pyc +0 -0
  109. package/extensions/services/watchdog/__pycache__/entry.cpython-313.pyc +0 -0
  110. package/extensions/services/watchdog/__pycache__/monitor.cpython-313.pyc +0 -0
  111. package/extensions/services/watchdog/__pycache__/server.cpython-313.pyc +0 -0
  112. /package/{core/event_hub/bench_results/.gitkeep → extensions/services/web/vendor/bluetooth/__init__.py} +0 -0
@@ -2,9 +2,17 @@
2
2
  Event Hub HTTP + WebSocket server.
3
3
  FastAPI app: /ws (WebSocket), /health, /stats.
4
4
  30s timer for heartbeat renewal + dedup cleanup.
5
+
6
+ Launcher bootstrap sequence:
7
+ Launcher connects with launcher_ws_token → Event Hub verifies locally →
8
+ registers to Registry → sends module.ready.
5
9
  """
6
10
 
7
11
  import asyncio
12
+ import json
13
+ import os
14
+ import uuid
15
+ from datetime import datetime, timezone
8
16
 
9
17
  import httpx
10
18
  from fastapi import FastAPI, WebSocket, WebSocketDisconnect
@@ -14,35 +22,159 @@ from .hub import EventHub
14
22
 
15
23
  class EventHubServer:
16
24
 
17
- def __init__(self, hub: EventHub, own_token: str, registry_url: str, test_mode: bool = False):
25
+ def __init__(self, hub: EventHub, own_token: str, registry_url: str,
26
+ launcher_ws_token: str = "",
27
+ advertise_ip: str = "127.0.0.1", port: int = 0):
18
28
  self.hub = hub
19
29
  self.own_token = own_token
20
30
  self.registry_url = registry_url
21
- self.test_mode = test_mode
31
+ self.is_debug = os.environ.get("KITE_DEBUG") == "1"
32
+ self.launcher_ws_token = launcher_ws_token
33
+ self.advertise_ip = advertise_ip
34
+ self.port = port
22
35
  self._timer_task: asyncio.Task | None = None
36
+ self._launcher_connected = False
37
+ self._registered_to_registry = False
38
+ self._uvicorn_server = None # set by entry.py for graceful shutdown
39
+ self._launcher_ws: WebSocket | None = None # reference to Launcher's WS
40
+ self._http_client: httpx.AsyncClient | None = None # reused for Registry calls
23
41
  self.app = self._create_app()
42
+ # Register shutdown callback on hub
43
+ self.hub._shutdown_cb = self._handle_shutdown
24
44
 
25
- # ── Token verification via Registry ──
45
+ # ── Token verification ──
26
46
 
27
47
  async def _verify_token(self, token: str, module_id_hint: str = "") -> str | None:
28
- """Call Registry POST /verify to validate a module token. Returns module_id or None."""
29
- if self.test_mode:
30
- return module_id_hint or token[:32] or "test_module"
48
+ """Verify a token. Launcher's launcher_ws_token is verified locally.
49
+ Other tokens are verified via Registry POST /verify.
50
+ In debug mode (KITE_DEBUG=1), any non-empty token is accepted."""
51
+ if self.is_debug and token:
52
+ return module_id_hint or "debug"
53
+ # Local verification for Launcher bootstrap (before Registry registration)
54
+ if self.launcher_ws_token and token == self.launcher_ws_token:
55
+ return "launcher"
56
+ # Normal verification via Registry
57
+ import time as _time
58
+ _t0 = _time.monotonic()
59
+ try:
60
+ resp = await self._http_client.post(
61
+ f"{self.registry_url}/verify",
62
+ json={"token": token},
63
+ headers={"Authorization": f"Bearer {self.own_token}"},
64
+ timeout=5,
65
+ )
66
+ _t1 = _time.monotonic()
67
+ if resp.status_code == 200:
68
+ body = resp.json()
69
+ if body.get("ok"):
70
+ mid = body.get("module_id")
71
+ print(f"[event_hub] _verify_token({mid}): {(_t1-_t0)*1000:.0f}ms")
72
+ return mid
73
+ except Exception as e:
74
+ print(f"[event_hub] Token verification failed: {e}")
75
+ return None
76
+
77
+ # ── Launcher bootstrap ──
78
+
79
+ async def _on_launcher_connected(self, ws: WebSocket):
80
+ """Called on first Launcher WS connect. Registers to Registry first, then sends module.ready.
81
+ This order ensures all startup-phase prints are done before Launcher calls close_stdio."""
82
+ self._launcher_connected = True
83
+ self._launcher_ws = ws
84
+ # Step 1: Register to Registry (may print log lines)
85
+ await self._register_to_registry()
86
+ # Step 2: Send module.ready (Launcher will close_stdio after receiving this)
87
+ msg = {
88
+ "type": "event",
89
+ "event_id": str(uuid.uuid4()),
90
+ "event": "module.ready",
91
+ "source": "event_hub",
92
+ "timestamp": datetime.now(timezone.utc).isoformat(),
93
+ "data": {
94
+ "module_id": "event_hub",
95
+ "ws_endpoint": f"ws://{self.advertise_ip}:{self.port}/ws",
96
+ "graceful_shutdown": True,
97
+ },
98
+ }
99
+ try:
100
+ await ws.send_text(json.dumps(msg))
101
+ print("[event_hub] Sent module.ready to Launcher")
102
+ except Exception as e:
103
+ print(f"[event_hub] Failed to send module.ready: {e}")
104
+
105
+ async def _handle_shutdown(self, data: dict):
106
+ """Handle module.shutdown event targeting event_hub."""
107
+ print("[event_hub] Received shutdown request")
108
+ # Send ack and ready via Launcher's WS connection
109
+ ws = self._launcher_ws
110
+ if ws:
111
+ try:
112
+ ack_msg = {
113
+ "type": "event",
114
+ "event_id": str(uuid.uuid4()),
115
+ "event": "module.shutdown.ack",
116
+ "source": "event_hub",
117
+ "timestamp": datetime.now(timezone.utc).isoformat(),
118
+ "data": {"module_id": "event_hub", "estimated_cleanup": 2},
119
+ }
120
+ await ws.send_text(json.dumps(ack_msg))
121
+ except Exception as e:
122
+ print(f"[event_hub] Failed to send shutdown ack: {e}")
123
+ # Cleanup
124
+ if self._timer_task:
125
+ self._timer_task.cancel()
126
+ if self._http_client:
127
+ await self._http_client.aclose()
128
+ # Send ready
129
+ if ws:
130
+ try:
131
+ ready_msg = {
132
+ "type": "event",
133
+ "event_id": str(uuid.uuid4()),
134
+ "event": "module.shutdown.ready",
135
+ "source": "event_hub",
136
+ "timestamp": datetime.now(timezone.utc).isoformat(),
137
+ "data": {"module_id": "event_hub"},
138
+ }
139
+ await ws.send_text(json.dumps(ready_msg))
140
+ except Exception as e:
141
+ print(f"[event_hub] Failed to send shutdown ready: {e}")
142
+ print("[event_hub] Shutdown ready, exiting")
143
+ # Trigger uvicorn exit
144
+ if self._uvicorn_server:
145
+ self._uvicorn_server.should_exit = True
146
+
147
+ async def _register_to_registry(self):
148
+ """Register to Registry. Triggered after Launcher connects."""
149
+ if self._registered_to_registry:
150
+ return
151
+ payload = {
152
+ "action": "register",
153
+ "module_id": "event_hub",
154
+ "module_type": "infrastructure",
155
+ "name": "Event Hub",
156
+ "api_endpoint": f"http://{self.advertise_ip}:{self.port}",
157
+ "health_endpoint": "/health",
158
+ "metadata": {
159
+ "ws_endpoint": f"ws://{self.advertise_ip}:{self.port}/ws",
160
+ },
161
+ }
162
+ headers = {"Authorization": f"Bearer {self.own_token}"}
31
163
  try:
32
164
  async with httpx.AsyncClient() as client:
33
165
  resp = await client.post(
34
- f"{self.registry_url}/verify",
35
- json={"token": token},
36
- headers={"Authorization": f"Bearer {self.own_token}"},
166
+ f"{self.registry_url}/modules",
167
+ json=payload,
168
+ headers=headers,
37
169
  timeout=5,
38
170
  )
39
171
  if resp.status_code == 200:
40
- body = resp.json()
41
- if body.get("ok"):
42
- return body.get("module_id")
172
+ self._registered_to_registry = True
173
+ print("[event_hub] Registered to Registry")
174
+ else:
175
+ print(f"[event_hub] WARNING: Registry returned {resp.status_code}")
43
176
  except Exception as e:
44
- print(f"[event_hub] Token verification failed: {e}")
45
- return None
177
+ print(f"[event_hub] WARNING: Failed to register to Registry: {e}")
46
178
 
47
179
  # ── App factory ──
48
180
 
@@ -52,15 +184,14 @@ class EventHubServer:
52
184
 
53
185
  @app.on_event("startup")
54
186
  async def _startup():
187
+ # Initialize HTTP client early to avoid blocking first token verification
188
+ server._http_client = httpx.AsyncClient()
55
189
  server._timer_task = asyncio.create_task(server._timer_loop())
56
- server._test_task = asyncio.create_task(server._test_event_loop())
57
190
 
58
191
  @app.on_event("shutdown")
59
192
  async def _shutdown():
60
193
  if server._timer_task:
61
194
  server._timer_task.cancel()
62
- if hasattr(server, '_test_task') and server._test_task:
63
- server._test_task.cancel()
64
195
 
65
196
  # ── WebSocket endpoint ──
66
197
 
@@ -70,12 +201,26 @@ class EventHubServer:
70
201
  mid_hint = ws.query_params.get("id", "")
71
202
  module_id = await server._verify_token(token, mid_hint)
72
203
  if not module_id:
73
- await ws.close(code=4001, reason="Authentication failed")
204
+ # Must accept before close Starlette drops TCP without close frame otherwise,
205
+ # causing websockets 15.x clients to get "no close frame received or sent" errors.
206
+ await ws.accept()
207
+ print(f"[event_hub] 认证失败: token={token[:8]}... hint={mid_hint}")
208
+ try:
209
+ await ws.close(code=4001, reason="Authentication failed")
210
+ except Exception:
211
+ pass
74
212
  return
75
213
 
76
214
  await ws.accept()
77
215
  server.hub.add_connection(module_id, ws)
78
216
 
217
+ # Launcher bootstrap: first connection triggers module.ready + Registry registration
218
+ if (module_id == "launcher"
219
+ and not server._launcher_connected
220
+ and server.launcher_ws_token
221
+ and token == server.launcher_ws_token):
222
+ await server._on_launcher_connected(ws)
223
+
79
224
  try:
80
225
  while True:
81
226
  raw = await ws.receive_text()
@@ -83,7 +228,10 @@ class EventHubServer:
83
228
  except WebSocketDisconnect:
84
229
  pass
85
230
  except Exception as e:
86
- print(f"[event_hub] WebSocket error for {module_id}: {e}")
231
+ # Connection-state errors are normal during shutdown
232
+ err = str(e).lower()
233
+ if "not connected" not in err and "closed" not in err:
234
+ print(f"[event_hub] WebSocket error for {module_id}: {e}")
87
235
  finally:
88
236
  server.hub.remove_connection(module_id)
89
237
 
@@ -104,10 +252,9 @@ class EventHubServer:
104
252
  async def _timer_loop(self):
105
253
  while True:
106
254
  await asyncio.sleep(30)
107
- # Dedup cleanup (offload to thread to avoid blocking event loop)
108
255
  await asyncio.get_event_loop().run_in_executor(None, self.hub.dedup.cleanup)
109
- # Heartbeat to Registry
110
- await self._heartbeat()
256
+ if self._registered_to_registry:
257
+ await self._heartbeat()
111
258
 
112
259
  async def _heartbeat(self):
113
260
  try:
@@ -120,19 +267,3 @@ class EventHubServer:
120
267
  )
121
268
  except Exception:
122
269
  pass
123
-
124
- async def _test_event_loop(self):
125
- """Publish a test event every 5 seconds via internal routing."""
126
- import uuid
127
- from datetime import datetime, timezone
128
- while True:
129
- await asyncio.sleep(5)
130
- msg = {
131
- "type": "event",
132
- "event_id": str(uuid.uuid4()),
133
- "event": "event_hub.test",
134
- "source": "event_hub",
135
- "timestamp": datetime.now(timezone.utc).isoformat(),
136
- "data": {"message": "heartbeat from event_hub"},
137
- }
138
- await self.hub._route_event("event_hub", msg)
@@ -0,0 +1,241 @@
1
+ """
2
+ Kite module logging utilities.
3
+ Provides latest.log + crashes.jsonl infrastructure per the 异常处理规范.
4
+
5
+ Each module calls init_module_log() and setup_exception_hooks() at startup.
6
+ All paths derived from KITE_MODULE_DATA environment variable.
7
+
8
+ Note: This is a convenience library for Python modules. Node.js/binary modules
9
+ implement the same spec independently (paths + formats are language-agnostic).
10
+ """
11
+
12
+ import json
13
+ import os
14
+ import re
15
+ import sys
16
+ import threading
17
+ import traceback
18
+ from datetime import datetime, timezone
19
+
20
+
21
+ # ── Module-level state (initialized by init_module_log) ──
22
+
23
+ _module_name = ""
24
+ _log_dir = None
25
+ _log_latest_path = None
26
+ _log_daily_path = None
27
+ _log_daily_date = ""
28
+ _crash_log_path = None
29
+ _log_lock = threading.Lock()
30
+
31
+ # Strip ANSI escape sequences for plain-text log files
32
+ _ANSI_RE = re.compile(r"\033\[[0-9;]*m")
33
+
34
+
35
+ def _strip_ansi(s: str) -> str:
36
+ return _ANSI_RE.sub("", s)
37
+
38
+
39
+ def _resolve_daily_log_path():
40
+ """Resolve daily log path based on current date."""
41
+ global _log_daily_path, _log_daily_date
42
+ if not _log_dir:
43
+ return
44
+ today = datetime.now().strftime("%Y-%m-%d")
45
+ if today == _log_daily_date and _log_daily_path:
46
+ return
47
+ month_dir = os.path.join(_log_dir, today[:7])
48
+ os.makedirs(month_dir, exist_ok=True)
49
+ _log_daily_path = os.path.join(month_dir, f"{today}.log")
50
+ _log_daily_date = today
51
+
52
+
53
+ def init_module_log(module_name: str):
54
+ """Initialize log files for a module. Call once at startup.
55
+
56
+ Requires KITE_MODULE_DATA environment variable to be set.
57
+ Creates: {KITE_MODULE_DATA}/log/latest.log (truncated)
58
+ {KITE_MODULE_DATA}/log/crashes.jsonl (truncated)
59
+ {KITE_MODULE_DATA}/log/{YYYY-MM}/ (daily archive directory)
60
+ """
61
+ global _module_name, _log_dir, _log_latest_path, _crash_log_path
62
+
63
+ _module_name = module_name
64
+ module_data = os.environ.get("KITE_MODULE_DATA")
65
+ if not module_data:
66
+ return
67
+
68
+ _log_dir = os.path.join(module_data, "log")
69
+ os.makedirs(_log_dir, exist_ok=True)
70
+
71
+ suffix = os.environ.get("KITE_INSTANCE_SUFFIX", "")
72
+
73
+ # latest.log — truncate on each startup
74
+ _log_latest_path = os.path.join(_log_dir, f"latest{suffix}.log")
75
+ try:
76
+ with open(_log_latest_path, "w", encoding="utf-8") as f:
77
+ pass
78
+ except Exception:
79
+ _log_latest_path = None
80
+
81
+ # crashes.jsonl — truncate on each startup
82
+ _crash_log_path = os.path.join(_log_dir, f"crashes{suffix}.jsonl")
83
+ try:
84
+ with open(_crash_log_path, "w", encoding="utf-8") as f:
85
+ pass
86
+ except Exception:
87
+ _crash_log_path = None
88
+
89
+ # Ensure daily archive directory exists
90
+ _resolve_daily_log_path()
91
+
92
+
93
+ def write_log(plain_line: str):
94
+ """Write a plain-text line to both latest.log and daily log (open-write-close)."""
95
+ with _log_lock:
96
+ if _log_latest_path:
97
+ try:
98
+ with open(_log_latest_path, "a", encoding="utf-8") as f:
99
+ f.write(plain_line)
100
+ except Exception:
101
+ pass
102
+ _resolve_daily_log_path()
103
+ if _log_daily_path:
104
+ try:
105
+ with open(_log_daily_path, "a", encoding="utf-8") as f:
106
+ f.write(plain_line)
107
+ except Exception:
108
+ pass
109
+
110
+
111
+ def write_crash(exc_type, exc_value, exc_tb,
112
+ thread_name=None, severity="critical", handled=False):
113
+ """Write crash record to crashes.jsonl + daily crash archive."""
114
+ record = {
115
+ "timestamp": datetime.now(timezone.utc).isoformat(),
116
+ "module": _module_name,
117
+ "thread": thread_name or threading.current_thread().name,
118
+ "exception_type": exc_type.__name__ if exc_type else "Unknown",
119
+ "exception_message": str(exc_value),
120
+ "traceback": "".join(traceback.format_exception(exc_type, exc_value, exc_tb)),
121
+ "severity": severity,
122
+ "handled": handled,
123
+ "process_id": os.getpid(),
124
+ "platform": sys.platform,
125
+ "runtime_version": f"Python {sys.version.split()[0]}",
126
+ }
127
+
128
+ if exc_tb:
129
+ tb_entries = traceback.extract_tb(exc_tb)
130
+ if tb_entries:
131
+ last = tb_entries[-1]
132
+ record["context"] = {
133
+ "function": last.name,
134
+ "file": os.path.basename(last.filename),
135
+ "line": last.lineno,
136
+ }
137
+
138
+ line = json.dumps(record, ensure_ascii=False) + "\n"
139
+
140
+ # 1. Write to crashes.jsonl (current run)
141
+ if _crash_log_path:
142
+ try:
143
+ with open(_crash_log_path, "a", encoding="utf-8") as f:
144
+ f.write(line)
145
+ except Exception:
146
+ pass
147
+
148
+ # 2. Write to daily crash archive
149
+ if _log_dir:
150
+ try:
151
+ today = datetime.now().strftime("%Y-%m-%d")
152
+ archive_dir = os.path.join(_log_dir, "crashes", today[:7])
153
+ os.makedirs(archive_dir, exist_ok=True)
154
+ archive_path = os.path.join(archive_dir, f"{today}.jsonl")
155
+ with open(archive_path, "a", encoding="utf-8") as f:
156
+ f.write(line)
157
+ except Exception:
158
+ pass
159
+
160
+
161
+ def print_crash_summary(exc_type, exc_tb, thread_name=None):
162
+ """Print crash summary to console (red highlight)."""
163
+ RED = "\033[91m"
164
+ RESET = "\033[0m"
165
+
166
+ if exc_tb:
167
+ tb_entries = traceback.extract_tb(exc_tb)
168
+ if tb_entries:
169
+ last = tb_entries[-1]
170
+ location = f"{os.path.basename(last.filename)}:{last.lineno}"
171
+ else:
172
+ location = "unknown"
173
+ else:
174
+ location = "unknown"
175
+
176
+ prefix = f"[{_module_name}]"
177
+ if thread_name:
178
+ print(f"{prefix} {RED}线程 {thread_name} 崩溃: "
179
+ f"{exc_type.__name__} in {location}{RESET}")
180
+ else:
181
+ print(f"{prefix} {RED}崩溃: {exc_type.__name__} in {location}{RESET}")
182
+ if _crash_log_path:
183
+ print(f"{prefix} 崩溃日志: {_crash_log_path}")
184
+
185
+
186
+ def setup_exception_hooks(module_name: str = ""):
187
+ """Set up global exception hooks (sys.excepthook + threading.excepthook).
188
+ Call once at startup after init_module_log().
189
+ """
190
+ if module_name:
191
+ global _module_name
192
+ _module_name = module_name
193
+
194
+ _orig_excepthook = sys.excepthook
195
+
196
+ def _excepthook(exc_type, exc_value, exc_tb):
197
+ write_crash(exc_type, exc_value, exc_tb, severity="critical", handled=False)
198
+ print_crash_summary(exc_type, exc_tb)
199
+ _orig_excepthook(exc_type, exc_value, exc_tb)
200
+
201
+ sys.excepthook = _excepthook
202
+
203
+ if hasattr(threading, "excepthook"):
204
+ def _thread_excepthook(args):
205
+ write_crash(args.exc_type, args.exc_value, args.exc_traceback,
206
+ thread_name=args.thread.name if args.thread else "unknown",
207
+ severity="error", handled=False)
208
+ print_crash_summary(args.exc_type, args.exc_traceback,
209
+ thread_name=args.thread.name if args.thread else None)
210
+
211
+ threading.excepthook = _thread_excepthook
212
+
213
+
214
+ def install_log_hook():
215
+ """Install a hook on the _SafeWriter or stdout to capture all print output
216
+ to latest.log + daily log. Call after init_module_log().
217
+
218
+ Wraps the current sys.stdout so that every write() also goes to log files.
219
+ """
220
+ if not _log_latest_path:
221
+ return
222
+
223
+ original_stdout = sys.stdout
224
+
225
+ class _LoggingWriter:
226
+ """Wraps stdout to tee all output to log files."""
227
+ def __init__(self, stream):
228
+ self._stream = stream
229
+
230
+ def write(self, s):
231
+ self._stream.write(s)
232
+ if s and s.strip():
233
+ write_log(_strip_ansi(s) + ("\n" if not s.endswith("\n") else ""))
234
+
235
+ def flush(self):
236
+ self._stream.flush()
237
+
238
+ def __getattr__(self, name):
239
+ return getattr(self._stream, name)
240
+
241
+ sys.stdout = _LoggingWriter(original_stdout)