@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,407 @@
1
+ """
2
+ Kernel entry point.
3
+ Reads boot_info token + tokens dict from stdin, starts unified WebSocket server,
4
+ outputs port via stdout, self-registers, publishes module.ready.
5
+
6
+ Stdin protocol:
7
+ Line 1: {"token": "launcher_kite_token"} (boot_info)
8
+ Line 2: {"kite": "tokens", "tokens": {"mod1":"tok1", ...}} (token mapping)
9
+
10
+ Stdout protocol:
11
+ {"kite": "port", "port": N} (Kernel port)
12
+ """
13
+
14
+ import builtins
15
+ import json
16
+ import os
17
+ import re
18
+ import sys
19
+ import socket
20
+ import threading
21
+ import time
22
+ import traceback
23
+ from datetime import datetime, timezone
24
+
25
+ import uvicorn
26
+
27
+
28
+ # ── Module configuration ──
29
+ MODULE_NAME = "kernel"
30
+
31
+
32
+ def _fmt_elapsed(t0: float) -> str:
33
+ """Format elapsed time since t0: <1s -> 'NNNms', >=1s -> 'N.Ns', >=10s -> 'NNs'."""
34
+ d = time.monotonic() - t0
35
+ if d < 1:
36
+ return f"{d * 1000:.0f}ms"
37
+ if d < 10:
38
+ return f"{d:.1f}s"
39
+ return f"{d:.0f}s"
40
+
41
+
42
+ # ── Safe stdout/stderr: ignore BrokenPipeError after Launcher closes stdio ──
43
+
44
+ class _SafeWriter:
45
+ """Wraps a stream to silently swallow BrokenPipeError on write/flush."""
46
+ def __init__(self, stream):
47
+ self._stream = stream
48
+
49
+ def write(self, s):
50
+ try:
51
+ self._stream.write(s)
52
+ except (BrokenPipeError, OSError):
53
+ pass
54
+
55
+ def flush(self):
56
+ try:
57
+ self._stream.flush()
58
+ except (BrokenPipeError, OSError):
59
+ pass
60
+
61
+ def __getattr__(self, name):
62
+ return getattr(self._stream, name)
63
+
64
+ sys.stdout = _SafeWriter(sys.stdout)
65
+ sys.stderr = _SafeWriter(sys.stderr)
66
+
67
+
68
+ # ── Timestamped print + log file writer ──
69
+ # Independent implementation per module (no shared code dependency)
70
+
71
+ _builtin_print = builtins.print
72
+ _start_ts = time.monotonic()
73
+ _last_ts = time.monotonic()
74
+ _ANSI_RE = re.compile(r"\033\[[0-9;]*m")
75
+ _log_lock = threading.Lock()
76
+ _log_latest_path = None
77
+ _log_daily_path = None
78
+ _log_daily_date = ""
79
+ _log_dir = None
80
+ _crash_log_path = None
81
+
82
+ def _strip_ansi(s: str) -> str:
83
+ return _ANSI_RE.sub("", s)
84
+
85
+ def _resolve_daily_log_path():
86
+ """Resolve daily log path based on current date."""
87
+ global _log_daily_path, _log_daily_date
88
+ if not _log_dir:
89
+ return
90
+ today = datetime.now().strftime("%Y-%m-%d")
91
+ if today == _log_daily_date and _log_daily_path:
92
+ return
93
+ month_dir = os.path.join(_log_dir, today[:7])
94
+ os.makedirs(month_dir, exist_ok=True)
95
+ _log_daily_path = os.path.join(month_dir, f"{today}.log")
96
+ _log_daily_date = today
97
+
98
+ def _write_log(plain_line: str):
99
+ """Write a plain-text line to both latest.log and daily log."""
100
+ with _log_lock:
101
+ if _log_latest_path:
102
+ try:
103
+ with open(_log_latest_path, "a", encoding="utf-8") as f:
104
+ f.write(plain_line)
105
+ except Exception:
106
+ pass
107
+ _resolve_daily_log_path()
108
+ if _log_daily_path:
109
+ try:
110
+ with open(_log_daily_path, "a", encoding="utf-8") as f:
111
+ f.write(plain_line)
112
+ except Exception:
113
+ pass
114
+
115
+ def _write_crash(exc_type, exc_value, exc_tb, thread_name=None, severity="critical", handled=False):
116
+ """Write crash record to crashes.jsonl + daily crash archive."""
117
+ record = {
118
+ "timestamp": datetime.now(timezone.utc).isoformat(),
119
+ "module": MODULE_NAME,
120
+ "thread": thread_name or threading.current_thread().name,
121
+ "exception_type": exc_type.__name__ if exc_type else "Unknown",
122
+ "exception_message": str(exc_value),
123
+ "traceback": "".join(traceback.format_exception(exc_type, exc_value, exc_tb)),
124
+ "severity": severity,
125
+ "handled": handled,
126
+ "process_id": os.getpid(),
127
+ "platform": sys.platform,
128
+ "runtime_version": f"Python {sys.version.split()[0]}",
129
+ }
130
+
131
+ if exc_tb:
132
+ tb_entries = traceback.extract_tb(exc_tb)
133
+ if tb_entries:
134
+ last = tb_entries[-1]
135
+ record["context"] = {
136
+ "function": last.name,
137
+ "file": os.path.basename(last.filename),
138
+ "line": last.lineno,
139
+ }
140
+
141
+ line = json.dumps(record, ensure_ascii=False) + "\n"
142
+
143
+ # 1. Write to crashes.jsonl (current run)
144
+ if _crash_log_path:
145
+ try:
146
+ with open(_crash_log_path, "a", encoding="utf-8") as f:
147
+ f.write(line)
148
+ except Exception:
149
+ pass
150
+
151
+ # 2. Write to daily crash archive
152
+ if _log_dir:
153
+ try:
154
+ today = datetime.now().strftime("%Y-%m-%d")
155
+ archive_dir = os.path.join(_log_dir, "crashes", today[:7])
156
+ os.makedirs(archive_dir, exist_ok=True)
157
+ archive_path = os.path.join(archive_dir, f"{today}.jsonl")
158
+ with open(archive_path, "a", encoding="utf-8") as f:
159
+ f.write(line)
160
+ except Exception:
161
+ pass
162
+
163
+ def _print_crash_summary(exc_type, exc_tb, thread_name=None):
164
+ """Print crash summary to console (red highlight)."""
165
+ RED = "\033[91m"
166
+ RESET = "\033[0m"
167
+
168
+ if exc_tb:
169
+ tb_entries = traceback.extract_tb(exc_tb)
170
+ if tb_entries:
171
+ last = tb_entries[-1]
172
+ location = f"{os.path.basename(last.filename)}:{last.lineno}"
173
+ else:
174
+ location = "unknown"
175
+ else:
176
+ location = "unknown"
177
+
178
+ prefix = f"[{MODULE_NAME}]"
179
+ if thread_name:
180
+ _builtin_print(f"{prefix} {RED}线程 {thread_name} 崩溃: "
181
+ f"{exc_type.__name__} in {location}{RESET}")
182
+ else:
183
+ _builtin_print(f"{prefix} {RED}崩溃: {exc_type.__name__} in {location}{RESET}")
184
+ if _crash_log_path:
185
+ _builtin_print(f"{prefix} 崩溃日志: {_crash_log_path}")
186
+
187
+ def _setup_exception_hooks():
188
+ """Set up global exception hooks (sys.excepthook + threading.excepthook)."""
189
+ _orig_excepthook = sys.excepthook
190
+
191
+ def _excepthook(exc_type, exc_value, exc_tb):
192
+ _write_crash(exc_type, exc_value, exc_tb, severity="critical", handled=False)
193
+ _print_crash_summary(exc_type, exc_tb)
194
+ _orig_excepthook(exc_type, exc_value, exc_tb)
195
+
196
+ sys.excepthook = _excepthook
197
+
198
+ if hasattr(threading, "excepthook"):
199
+ def _thread_excepthook(args):
200
+ _write_crash(args.exc_type, args.exc_value, args.exc_traceback,
201
+ thread_name=args.thread.name if args.thread else "unknown",
202
+ severity="error", handled=False)
203
+ _print_crash_summary(args.exc_type, args.exc_traceback,
204
+ thread_name=args.thread.name if args.thread else None)
205
+
206
+ threading.excepthook = _thread_excepthook
207
+
208
+ def _tprint(*args, **kwargs):
209
+ """Timestamped print that adds [timestamp] HH:MM:SS.mmm +delta prefix."""
210
+ global _last_ts
211
+ now = time.monotonic()
212
+ elapsed = now - _start_ts
213
+ delta = now - _last_ts
214
+ _last_ts = now
215
+
216
+ # Format elapsed (cumulative time since start)
217
+ if elapsed < 1:
218
+ elapsed_str = f"{elapsed * 1000:.0f}ms"
219
+ elif elapsed < 100:
220
+ elapsed_str = f"{elapsed:.1f}s"
221
+ else:
222
+ elapsed_str = f"{elapsed:.0f}s"
223
+
224
+ # Format delta (time since last print)
225
+ if delta < 0.001:
226
+ delta_str = ""
227
+ elif delta < 1:
228
+ delta_str = f"+{delta * 1000:.0f}ms"
229
+ elif delta < 100:
230
+ delta_str = f"+{delta:.1f}s"
231
+ else:
232
+ delta_str = f"+{delta:.0f}s"
233
+
234
+ # Current time
235
+ ts = datetime.now().strftime("%H:%M:%S.%f")[:-3]
236
+
237
+ # Print to console (original behavior)
238
+ _builtin_print(*args, **kwargs)
239
+
240
+ # Write to log files with timestamp prefix
241
+ if _log_latest_path or _log_daily_path:
242
+ sep = kwargs.get("sep", " ")
243
+ end = kwargs.get("end", "\n")
244
+ text = sep.join(str(a) for a in args)
245
+ prefix = f"[{elapsed_str:>6}] {ts} {delta_str:>8} "
246
+ _write_log(prefix + _strip_ansi(text) + end)
247
+
248
+ builtins.print = _tprint
249
+
250
+ # Ensure project root is on sys.path (set by main.py or cli.js)
251
+ _this_dir = os.path.dirname(os.path.abspath(__file__))
252
+ _project_root = os.environ.get("KITE_PROJECT") or os.path.dirname(os.path.dirname(_this_dir))
253
+ if _project_root not in sys.path:
254
+ sys.path.insert(0, _project_root)
255
+
256
+ from kernel.server import KernelServer
257
+
258
+
259
+ def _read_module_md() -> dict:
260
+ """Read preferred_port and advertise_ip from own module.md."""
261
+ md_path = os.path.join(_this_dir, "module.md")
262
+ result = {"preferred_port": 0, "advertise_ip": "127.0.0.1"}
263
+ try:
264
+ with open(md_path, "r", encoding="utf-8") as f:
265
+ text = f.read()
266
+ m = re.match(r'^---\s*\n(.*?)\n---', text, re.DOTALL)
267
+ if m:
268
+ try:
269
+ import yaml
270
+ fm = yaml.safe_load(m.group(1)) or {}
271
+ except ImportError:
272
+ fm = {}
273
+ result["preferred_port"] = int(fm.get("preferred_port", 0))
274
+ result["advertise_ip"] = fm.get("advertise_ip", "127.0.0.1")
275
+ except Exception:
276
+ pass
277
+ return result
278
+
279
+
280
+ def _bind_port(preferred: int, host: str) -> int:
281
+ """Try preferred port first, fall back to OS-assigned."""
282
+ if preferred:
283
+ try:
284
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
285
+ s.bind((host, preferred))
286
+ return preferred
287
+ except OSError:
288
+ pass
289
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
290
+ s.bind((host, 0))
291
+ return s.getsockname()[1]
292
+
293
+
294
+ def _read_stdin_kite_messages(expected: set[str], timeout: float = 10) -> dict[str, dict]:
295
+ """Read multiple structured kite messages from stdin until all expected types received.
296
+
297
+ Args:
298
+ expected: set of kite message types to wait for (e.g. {"tokens"})
299
+ timeout: total timeout in seconds
300
+
301
+ Returns:
302
+ dict mapping kite type -> message dict. Missing types are absent from the result.
303
+ """
304
+ collected: dict[str, dict] = {}
305
+ remaining = set(expected)
306
+
307
+ def _read():
308
+ while remaining:
309
+ try:
310
+ line = sys.stdin.readline().strip()
311
+ if not line:
312
+ return # stdin closed
313
+ msg = json.loads(line)
314
+ if isinstance(msg, dict) and "kite" in msg:
315
+ kite_type = msg["kite"]
316
+ collected[kite_type] = msg
317
+ remaining.discard(kite_type)
318
+ except Exception:
319
+ return # parse error or stdin closed
320
+
321
+ t = threading.Thread(target=_read, daemon=True)
322
+ t.start()
323
+ t.join(timeout=timeout)
324
+ return collected
325
+
326
+
327
+ def main():
328
+ # Initialize log file paths
329
+ global _log_dir, _log_latest_path, _crash_log_path
330
+ module_data = os.environ.get("KITE_MODULE_DATA")
331
+ if module_data:
332
+ _log_dir = os.path.join(module_data, "log")
333
+ os.makedirs(_log_dir, exist_ok=True)
334
+ suffix = os.environ.get("KITE_INSTANCE_SUFFIX", "")
335
+
336
+ # latest.log — truncate on each startup
337
+ _log_latest_path = os.path.join(_log_dir, f"latest{suffix}.log")
338
+ try:
339
+ with open(_log_latest_path, "w", encoding="utf-8") as f:
340
+ pass # truncate
341
+ except Exception:
342
+ _log_latest_path = None
343
+
344
+ # crashes.jsonl — truncate on each startup
345
+ _crash_log_path = os.path.join(_log_dir, f"crashes{suffix}.jsonl")
346
+ try:
347
+ with open(_crash_log_path, "w", encoding="utf-8") as f:
348
+ pass # truncate
349
+ except Exception:
350
+ _crash_log_path = None
351
+
352
+ _resolve_daily_log_path()
353
+
354
+ # Setup exception hooks
355
+ _setup_exception_hooks()
356
+
357
+ _t0 = time.monotonic()
358
+
359
+ # Kite environment
360
+ is_debug = os.environ.get("KITE_DEBUG") == "1"
361
+
362
+ if is_debug:
363
+ print("[kernel] 调试模式已启用 (KITE_DEBUG=1),接受所有令牌")
364
+
365
+ # Step 1: Read config from own module.md
366
+ md_config = _read_module_md()
367
+ advertise_ip = md_config["advertise_ip"]
368
+ preferred_port = md_config["preferred_port"]
369
+
370
+ # Step 2: Generate launcher token
371
+ import secrets
372
+ launcher_token = secrets.token_urlsafe(32)
373
+
374
+ # Step 3: Create KernelServer with launcher_token
375
+ server = KernelServer(launcher_token=launcher_token, advertise_ip=advertise_ip)
376
+
377
+ # Step 4: Bind port
378
+ bind_host = advertise_ip
379
+ port = _bind_port(preferred_port, bind_host)
380
+ server.port = port
381
+
382
+ # Step 5: Output port + launcher_token via stdout
383
+ print(json.dumps({"kite": "port", "port": port, "token": launcher_token}), flush=True)
384
+
385
+ print(f"[kernel] 启动中 {bind_host}:{port} ({_fmt_elapsed(_t0)})")
386
+
387
+ # Step 6: Self-register in registry (direct memory write)
388
+ server.self_register()
389
+
390
+ # Step 7: Start uvicorn (module.ready will be published when Launcher subscribes)
391
+ try:
392
+ config = uvicorn.Config(server.app, host=bind_host, port=port, log_level="warning")
393
+ uvi_server = uvicorn.Server(config)
394
+ server._uvicorn_server = uvi_server
395
+
396
+ # module.ready is now published when Launcher subscribes (not at startup)
397
+ # This ensures Launcher is connected and subscribed before receiving the event
398
+
399
+ uvi_server.run()
400
+ except Exception as e:
401
+ _write_crash(type(e), e, e.__traceback__, severity="critical", handled=True)
402
+ _print_crash_summary(type(e), e.__traceback__)
403
+ sys.exit(1)
404
+
405
+
406
+ if __name__ == "__main__":
407
+ main()
@@ -1,7 +1,13 @@
1
1
  """
2
- Core Event Hub logic.
3
- WebSocket handling, event processing (validate dedup route → ack),
4
- connection and subscription management.
2
+ Kernel Event Hub logic.
3
+ Connection management, subscription matching, event routing (validate -> dedup -> route),
4
+ per-subscriber delivery queues.
5
+
6
+ Adapted from core/event_hub/hub.py for the merged Kernel module:
7
+ - Removed handle_message, _handle_event, _send_ack, _send_error (moved to RpcRouter)
8
+ - Added publish_event() for RPC-driven event publishing
9
+ - Added publish_internal() for Kernel-originated events (e.g. module.offline)
10
+ - Event delivery uses JSON-RPC 2.0 Notification format
5
11
  """
6
12
 
7
13
  import asyncio
@@ -14,7 +20,7 @@ try:
14
20
  except ImportError:
15
21
  orjson = None
16
22
 
17
- from fastapi import WebSocket, WebSocketDisconnect
23
+ from starlette.websockets import WebSocket
18
24
 
19
25
  from .dedup import EventDedup
20
26
  from .router import match_parts
@@ -31,6 +37,7 @@ def _loads(raw: str):
31
37
  return orjson.loads(raw)
32
38
  return json.loads(raw)
33
39
 
40
+
34
41
  QUEUE_MAXSIZE = 10000
35
42
 
36
43
 
@@ -75,14 +82,14 @@ class EventHub:
75
82
  self._senders[module_id] = asyncio.create_task(
76
83
  self._sender_loop(module_id, ws, q)
77
84
  )
78
- print(f"[event_hub] {module_id} connected")
85
+ print(f"[kernel] {module_id} connected")
79
86
 
80
87
  async def _close_old(self, module_id: str, ws: WebSocket):
81
88
  try:
82
89
  await ws.close(code=4000, reason="replaced by new connection")
83
90
  except Exception:
84
91
  pass
85
- print(f"[event_hub] Closed old connection for {module_id}")
92
+ print(f"[kernel] Closed old connection for {module_id}")
86
93
 
87
94
  def remove_connection(self, module_id: str):
88
95
  """Clean up on disconnect."""
@@ -93,7 +100,7 @@ class EventHub:
93
100
  task = self._senders.pop(module_id, None)
94
101
  if task:
95
102
  task.cancel()
96
- print(f"[event_hub] {module_id} disconnected")
103
+ print(f"[kernel] {module_id} disconnected")
97
104
 
98
105
  # ── Sender loop (per-subscriber) ──
99
106
 
@@ -109,7 +116,7 @@ class EventHub:
109
116
  await ws.send_text(raw)
110
117
  self._cnt_routed += 1
111
118
  except Exception:
112
- print(f"[event_hub] Send failed to {mid}, closing sender")
119
+ print(f"[kernel] Send failed to {mid}, closing sender")
113
120
  break
114
121
  except asyncio.CancelledError:
115
122
  pass
@@ -122,77 +129,72 @@ class EventHub:
122
129
  self.subscriptions[module_id].update(
123
130
  (p, tuple(p.split("."))) for p in events
124
131
  )
125
- print(f"[event_hub] {module_id} subscribed: {events}")
132
+ print(f"[kernel] {module_id} subscribed: {events}")
126
133
 
127
134
  def handle_unsubscribe(self, module_id: str, events: list[str]):
128
135
  subs = self.subscriptions.get(module_id)
129
136
  if subs:
130
137
  to_remove = {item for item in subs if item[0] in events}
131
138
  subs.difference_update(to_remove)
132
- print(f"[event_hub] {module_id} unsubscribed: {events}")
133
-
134
- # ── Main message handler ──
135
-
136
- async def handle_message(self, module_id: str, ws: WebSocket, raw: str):
137
- try:
138
- msg = _loads(raw)
139
- except Exception:
140
- await self._send_error(ws, "Invalid JSON")
141
- return
142
-
143
- msg_type = msg.get("type", "")
144
-
145
- if msg_type == "subscribe":
146
- events = msg.get("events", [])
147
- if isinstance(events, list) and events:
148
- self.handle_subscribe(module_id, events)
149
- return
139
+ print(f"[kernel] {module_id} unsubscribed: {events}")
150
140
 
151
- if msg_type == "unsubscribe":
152
- events = msg.get("events", [])
153
- if isinstance(events, list) and events:
154
- self.handle_unsubscribe(module_id, events)
155
- return
141
+ # ── Event publishing (called by RpcRouter) ──
156
142
 
157
- if msg_type == "event":
158
- await self._handle_event(module_id, ws, msg)
159
- return
160
-
161
- await self._send_error(ws, f"Unknown message type: {msg_type}")
162
-
163
- # ── Event processing ──
164
-
165
- async def _handle_event(self, module_id: str, ws: WebSocket, msg: dict):
166
- """Validate → dedup → auto-fill → route → ack."""
143
+ def publish_event(self, sender_id: str, event_id: str, event_type: str,
144
+ data: dict = None, echo: bool = False) -> dict:
145
+ """Publish an event from a module. Called by RpcRouter for event.publish RPC.
146
+ Returns {ok: True} on success, or error dict if validation fails."""
167
147
  self._cnt_received += 1
168
148
 
169
- event_id = msg.get("event_id")
170
- event_type = msg.get("event")
171
149
  if not event_id or not event_type:
172
150
  self._cnt_errors += 1
173
- await self._send_error(ws, "Missing required field: event_id or event")
174
- return
151
+ return {"ok": False, "error": "Missing required field: event_id or event"}
175
152
 
176
153
  if self.dedup.is_duplicate(event_id):
177
154
  self._cnt_dedup += 1
178
- await self._send_ack(ws, event_id)
179
- return
155
+ return {"ok": True} # silently accept duplicates
156
+
157
+ msg = {
158
+ "jsonrpc": "2.0",
159
+ "method": "event",
160
+ "params": {
161
+ "event_id": event_id,
162
+ "event": event_type,
163
+ "source": sender_id,
164
+ "timestamp": datetime.now(timezone.utc).isoformat(),
165
+ "data": data or {},
166
+ },
167
+ }
168
+
169
+ self._route_event(sender_id, msg, event_type, echo)
170
+ return {"ok": True}
180
171
 
181
- if not msg.get("source"):
182
- msg["source"] = module_id
183
- if not msg.get("timestamp"):
184
- msg["timestamp"] = datetime.now(timezone.utc).isoformat()
172
+ def publish_internal(self, event_type: str, data: dict):
173
+ """Publish a Kernel-originated event (e.g. module.offline, module.registered).
174
+ No dedup check — internal events are unique by nature."""
175
+ import uuid
176
+ event_id = str(uuid.uuid4())
177
+ self._cnt_received += 1
178
+
179
+ msg = {
180
+ "jsonrpc": "2.0",
181
+ "method": "event",
182
+ "params": {
183
+ "event_id": event_id,
184
+ "event": event_type,
185
+ "source": "kernel",
186
+ "timestamp": datetime.now(timezone.utc).isoformat(),
187
+ "data": data,
188
+ },
189
+ }
185
190
 
186
- await self._route_event(module_id, msg)
187
- await self._send_ack(ws, event_id)
191
+ self._route_event("kernel", msg, event_type, echo=False)
188
192
 
189
193
  # ── Routing ──
190
194
 
191
- async def _route_event(self, sender_id: str, msg: dict):
195
+ def _route_event(self, sender_id: str, msg: dict, event_type: str, echo: bool):
192
196
  """Enqueue event to all matching subscribers' delivery queues."""
193
- event_type = msg["event"]
194
197
  e_parts = tuple(event_type.split("."))
195
- echo = msg.get("echo", False)
196
198
  raw = None # lazy serialization
197
199
 
198
200
  for mid, patterns in self.subscriptions.items():
@@ -207,26 +209,12 @@ class EventHub:
207
209
  try:
208
210
  queue.put_nowait(raw)
209
211
  except asyncio.QueueFull:
210
- await queue.put(raw)
212
+ # Best-effort: drop if queue is full and we can't await
213
+ # (we're not in an async context in _route_event)
214
+ pass
211
215
  self._cnt_queued += 1
212
216
  break
213
217
 
214
- # ── Helpers ──
215
-
216
- @staticmethod
217
- async def _send_ack(ws: WebSocket, event_id: str):
218
- try:
219
- await ws.send_text('{"type":"ack","event_id":"' + event_id + '"}')
220
- except Exception:
221
- pass
222
-
223
- @staticmethod
224
- async def _send_error(ws: WebSocket, message: str):
225
- try:
226
- await ws.send_json({"type": "error", "message": message})
227
- except Exception:
228
- pass
229
-
230
218
  # ── Stats ──
231
219
 
232
220
  def _counters_dict(self) -> dict:
@@ -0,0 +1,33 @@
1
+ ---
2
+ name: kernel
3
+ display_name: Kernel
4
+ type: infrastructure
5
+ state: enabled
6
+ runtime: python
7
+ entry: entry.py
8
+ ---
9
+
10
+ # Kernel
11
+
12
+ Unified infrastructure module that merges Registry (service discovery) and Event Hub (event routing) into a single process with a WebSocket JSON-RPC 2.0 interface.
13
+
14
+ ## Responsibilities
15
+
16
+ - **Service Registry**: Module registration, heartbeat TTL, glob lookup, dot-path queries
17
+ - **Event Routing**: NATS-style wildcard subscriptions, per-subscriber queues, 1h dedup
18
+ - **RPC Routing**: JSON-RPC 2.0 dispatch for builtin methods + cross-module forwarding
19
+ - **Token Verification**: In-memory token→module_id resolution (no cross-process HTTP)
20
+
21
+ ## Protocol
22
+
23
+ Single WebSocket endpoint: `ws://127.0.0.1:{port}/ws?token={TOKEN}&id={MODULE_ID}`
24
+
25
+ Three frame types on the wire:
26
+ - **RPC Request**: `{jsonrpc:"2.0", id, method, params}` — client→Kernel or Kernel→client (forwarded)
27
+ - **RPC Response**: `{jsonrpc:"2.0", id, result}` or `{jsonrpc:"2.0", id, error}` — response to request
28
+ - **Event Notification**: `{jsonrpc:"2.0", method:"event", params:{event_id, event, source, timestamp, data}}` — no id, no response
29
+
30
+ ## HTTP Endpoints (debug only)
31
+
32
+ - `GET /health` — combined registry + event hub health
33
+ - `GET /stats` — connections, subscriptions, counters