@agentunion/kite 1.0.7 → 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 (77) hide show
  1. package/core/event_hub/entry.py +305 -26
  2. package/core/event_hub/hub.py +8 -0
  3. package/core/event_hub/server.py +80 -17
  4. package/core/kite_log.py +241 -0
  5. package/core/launcher/entry.py +978 -284
  6. package/core/launcher/process_manager.py +456 -46
  7. package/core/registry/entry.py +272 -3
  8. package/core/registry/server.py +339 -289
  9. package/core/registry/store.py +10 -4
  10. package/extensions/agents/__init__.py +1 -0
  11. package/extensions/agents/assistant/__init__.py +1 -0
  12. package/extensions/agents/assistant/entry.py +380 -0
  13. package/extensions/agents/assistant/module.md +22 -0
  14. package/extensions/agents/assistant/server.py +236 -0
  15. package/extensions/channels/__init__.py +1 -0
  16. package/extensions/channels/acp_channel/__init__.py +1 -0
  17. package/extensions/channels/acp_channel/entry.py +380 -0
  18. package/extensions/channels/acp_channel/module.md +22 -0
  19. package/extensions/channels/acp_channel/server.py +236 -0
  20. package/extensions/event_hub_bench/entry.py +664 -379
  21. package/extensions/event_hub_bench/module.md +2 -1
  22. package/extensions/services/backup/__init__.py +1 -0
  23. package/extensions/services/backup/entry.py +380 -0
  24. package/extensions/services/backup/module.md +22 -0
  25. package/extensions/services/backup/server.py +244 -0
  26. package/extensions/services/model_service/__init__.py +1 -0
  27. package/extensions/services/model_service/entry.py +380 -0
  28. package/extensions/services/model_service/module.md +22 -0
  29. package/extensions/services/model_service/server.py +236 -0
  30. package/extensions/services/watchdog/entry.py +460 -147
  31. package/extensions/services/watchdog/module.md +3 -0
  32. package/extensions/services/watchdog/monitor.py +128 -13
  33. package/extensions/services/watchdog/server.py +75 -13
  34. package/extensions/services/web/__init__.py +1 -0
  35. package/extensions/services/web/config.yaml +149 -0
  36. package/extensions/services/web/entry.py +487 -0
  37. package/extensions/services/web/module.md +24 -0
  38. package/extensions/services/web/routes/__init__.py +1 -0
  39. package/extensions/services/web/routes/routes_call.py +189 -0
  40. package/extensions/services/web/routes/routes_config.py +512 -0
  41. package/extensions/services/web/routes/routes_contacts.py +98 -0
  42. package/extensions/services/web/routes/routes_devlog.py +99 -0
  43. package/extensions/services/web/routes/routes_phone.py +81 -0
  44. package/extensions/services/web/routes/routes_sms.py +48 -0
  45. package/extensions/services/web/routes/routes_stats.py +17 -0
  46. package/extensions/services/web/routes/routes_voicechat.py +554 -0
  47. package/extensions/services/web/routes/schemas.py +216 -0
  48. package/extensions/services/web/server.py +332 -0
  49. package/extensions/services/web/static/css/style.css +1064 -0
  50. package/extensions/services/web/static/index.html +1445 -0
  51. package/extensions/services/web/static/js/app.js +4671 -0
  52. package/extensions/services/web/vendor/__init__.py +1 -0
  53. package/extensions/services/web/vendor/bluetooth/__init__.py +0 -0
  54. package/extensions/services/web/vendor/bluetooth/audio.py +348 -0
  55. package/extensions/services/web/vendor/bluetooth/contacts.py +251 -0
  56. package/extensions/services/web/vendor/bluetooth/manager.py +395 -0
  57. package/extensions/services/web/vendor/bluetooth/sms.py +290 -0
  58. package/extensions/services/web/vendor/bluetooth/telephony.py +274 -0
  59. package/extensions/services/web/vendor/config.py +139 -0
  60. package/extensions/services/web/vendor/conversation/__init__.py +0 -0
  61. package/extensions/services/web/vendor/conversation/asr.py +936 -0
  62. package/extensions/services/web/vendor/conversation/engine.py +548 -0
  63. package/extensions/services/web/vendor/conversation/llm.py +534 -0
  64. package/extensions/services/web/vendor/conversation/mcp_tools.py +190 -0
  65. package/extensions/services/web/vendor/conversation/tts.py +322 -0
  66. package/extensions/services/web/vendor/conversation/vad.py +138 -0
  67. package/extensions/services/web/vendor/storage/__init__.py +1 -0
  68. package/extensions/services/web/vendor/storage/identity.py +312 -0
  69. package/extensions/services/web/vendor/storage/store.py +507 -0
  70. package/extensions/services/web/vendor/task/__init__.py +0 -0
  71. package/extensions/services/web/vendor/task/manager.py +864 -0
  72. package/extensions/services/web/vendor/task/models.py +45 -0
  73. package/extensions/services/web/vendor/task/webhook.py +263 -0
  74. package/extensions/services/web/vendor/tools/__init__.py +0 -0
  75. package/extensions/services/web/vendor/tools/registry.py +321 -0
  76. package/main.py +230 -90
  77. package/package.json +1 -1
@@ -0,0 +1,274 @@
1
+ """Telephony control via PipeWire / BlueZ HFP D-Bus interface.
2
+
3
+ Provides call dialling, answering, hanging-up and call-state monitoring.
4
+ Falls back to a mock implementation when D-Bus (dasbus) is not available
5
+ (e.g. development on Windows / macOS).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import logging
12
+ from enum import Enum
13
+ from typing import Any, Callable
14
+
15
+ logger = logging.getLogger("bluetooth.telephony")
16
+
17
+ # ---------------------------------------------------------------------------
18
+ # Conditional D-Bus imports
19
+ # ---------------------------------------------------------------------------
20
+
21
+ _DBUS_AVAILABLE = False
22
+
23
+ try:
24
+ from dasbus.connection import SessionMessageBus
25
+ from dasbus.loop import EventLoop
26
+
27
+ _DBUS_AVAILABLE = True
28
+ except ImportError:
29
+ logger.warning(
30
+ "dasbus not available — using mock TelephonyController for development"
31
+ )
32
+
33
+ # D-Bus constants (placeholder interface — replace with actual BlueZ HFP
34
+ # or oFono / ModemManager interface when integrating on real hardware).
35
+ _TELEPHONY_SERVICE = "org.pipewire.Telephony"
36
+ _TELEPHONY_PATH = "/org/pipewire/Telephony"
37
+ _TELEPHONY_IFACE = "org.pipewire.Telephony"
38
+
39
+
40
+ class CallState(str, Enum):
41
+ """Possible states for a phone call."""
42
+
43
+ IDLE = "idle"
44
+ DIALING = "dialing"
45
+ ALERTING = "alerting"
46
+ ACTIVE = "active"
47
+ HELD = "held"
48
+ INCOMING = "incoming"
49
+ DISCONNECTED = "disconnected"
50
+
51
+
52
+ class TelephonyController:
53
+ """Control phone calls over Bluetooth HFP via D-Bus."""
54
+
55
+ def __init__(self) -> None:
56
+ self._bus: Any | None = None
57
+ self._proxy: Any | None = None
58
+
59
+ self._call_state: CallState = CallState.IDLE
60
+ self._current_number: str = ""
61
+ self._signal_task: asyncio.Task | None = None
62
+ self._running: bool = False
63
+
64
+ # Callbacks
65
+ self.on_call_state_changed: Callable[[CallState, str], Any] | None = None
66
+ self.on_incoming_call: Callable[[str], Any] | None = None
67
+
68
+ # -- Properties ----------------------------------------------------------
69
+
70
+ @property
71
+ def call_state(self) -> CallState:
72
+ return self._call_state
73
+
74
+ @property
75
+ def current_number(self) -> str:
76
+ return self._current_number
77
+
78
+ # -- Lifecycle -----------------------------------------------------------
79
+
80
+ async def start(self) -> None:
81
+ """Connect to the telephony D-Bus service and start listening for
82
+ call-state signals.
83
+ """
84
+ self._running = True
85
+
86
+ if _DBUS_AVAILABLE:
87
+ try:
88
+ self._bus = SessionMessageBus()
89
+ self._proxy = self._bus.get_proxy(
90
+ _TELEPHONY_SERVICE, _TELEPHONY_PATH
91
+ )
92
+ logger.info("Telephony D-Bus proxy acquired")
93
+ self._signal_task = asyncio.create_task(
94
+ self._listen_signals()
95
+ )
96
+ except Exception as exc:
97
+ logger.error(
98
+ "Failed to connect to Telephony D-Bus service: %s", exc
99
+ )
100
+ self._bus = None
101
+ self._proxy = None
102
+ else:
103
+ logger.info("D-Bus not available — telephony running in mock mode")
104
+
105
+ async def stop(self) -> None:
106
+ """Stop listening and clean up."""
107
+ self._running = False
108
+ if self._signal_task and not self._signal_task.done():
109
+ self._signal_task.cancel()
110
+ try:
111
+ await self._signal_task
112
+ except asyncio.CancelledError:
113
+ pass
114
+ logger.info("TelephonyController stopped")
115
+
116
+ # -- Call control --------------------------------------------------------
117
+
118
+ async def dial(self, number: str) -> bool:
119
+ """Initiate an outgoing call to *number*.
120
+
121
+ Returns True if the call was successfully initiated.
122
+ """
123
+ logger.info("Dialling %s ...", number)
124
+ self._current_number = number
125
+
126
+ if not _DBUS_AVAILABLE or self._proxy is None:
127
+ return await self._mock_dial(number)
128
+
129
+ try:
130
+ self._proxy.Dial(number)
131
+ self._set_state(CallState.DIALING, number)
132
+ return True
133
+ except Exception as exc:
134
+ logger.error("Dial failed: %s", exc)
135
+ return False
136
+
137
+ async def answer(self) -> bool:
138
+ """Answer an incoming call.
139
+
140
+ Returns True on success.
141
+ """
142
+ logger.info("Answering incoming call ...")
143
+
144
+ if not _DBUS_AVAILABLE or self._proxy is None:
145
+ return await self._mock_answer()
146
+
147
+ try:
148
+ self._proxy.Answer()
149
+ self._set_state(CallState.ACTIVE, self._current_number)
150
+ return True
151
+ except Exception as exc:
152
+ logger.error("Answer failed: %s", exc)
153
+ return False
154
+
155
+ async def hangup(self) -> bool:
156
+ """Hang up the current or incoming call.
157
+
158
+ Returns True on success.
159
+ """
160
+ logger.info("Hanging up ...")
161
+
162
+ if not _DBUS_AVAILABLE or self._proxy is None:
163
+ return await self._mock_hangup()
164
+
165
+ try:
166
+ self._proxy.Hangup()
167
+ self._set_state(CallState.DISCONNECTED, self._current_number)
168
+ # Reset to idle after a short delay
169
+ await asyncio.sleep(0.5)
170
+ self._set_state(CallState.IDLE, "")
171
+ return True
172
+ except Exception as exc:
173
+ logger.error("Hangup failed: %s", exc)
174
+ return False
175
+
176
+ async def get_call_state(self) -> str:
177
+ """Return the current call state as a string."""
178
+ if _DBUS_AVAILABLE and self._proxy is not None:
179
+ try:
180
+ raw = self._proxy.GetCallState()
181
+ self._call_state = CallState(raw.lower())
182
+ except Exception:
183
+ pass # use cached state
184
+ return self._call_state.value
185
+
186
+ # -- D-Bus signal listener -----------------------------------------------
187
+
188
+ async def _listen_signals(self) -> None:
189
+ """Poll D-Bus for call state changes.
190
+
191
+ A real implementation would subscribe to D-Bus signals instead of
192
+ polling. This loop acts as a placeholder.
193
+ """
194
+ while self._running:
195
+ try:
196
+ if self._proxy is not None:
197
+ raw = self._proxy.GetCallState()
198
+ new_state = CallState(raw.lower())
199
+ if new_state != self._call_state:
200
+ old = self._call_state
201
+ self._call_state = new_state
202
+
203
+ # Try to read the number associated with the call
204
+ try:
205
+ self._current_number = str(
206
+ self._proxy.GetCallNumber()
207
+ )
208
+ except Exception:
209
+ pass
210
+
211
+ logger.info(
212
+ "Call state: %s -> %s (number=%s)",
213
+ old.value,
214
+ new_state.value,
215
+ self._current_number,
216
+ )
217
+
218
+ if new_state == CallState.INCOMING and self.on_incoming_call:
219
+ self.on_incoming_call(self._current_number)
220
+ if self.on_call_state_changed:
221
+ self.on_call_state_changed(
222
+ new_state, self._current_number
223
+ )
224
+ except Exception as exc:
225
+ logger.debug("Signal poll error: %s", exc)
226
+
227
+ await asyncio.sleep(1.0)
228
+
229
+ # -- Internal helpers ----------------------------------------------------
230
+
231
+ def _set_state(self, state: CallState, number: str) -> None:
232
+ old = self._call_state
233
+ self._call_state = state
234
+ self._current_number = number
235
+
236
+ if old != state:
237
+ logger.info(
238
+ "Call state: %s -> %s (number=%s)",
239
+ old.value, state.value, number,
240
+ )
241
+ if self.on_call_state_changed:
242
+ self.on_call_state_changed(state, number)
243
+
244
+ # -- Mock helpers (development) ------------------------------------------
245
+
246
+ async def _mock_dial(self, number: str) -> bool:
247
+ logger.info("[mock] Dialling %s", number)
248
+ self._set_state(CallState.DIALING, number)
249
+ # Simulate ringing then active
250
+ await asyncio.sleep(0.3)
251
+ self._set_state(CallState.ALERTING, number)
252
+ await asyncio.sleep(0.5)
253
+ self._set_state(CallState.ACTIVE, number)
254
+ return True
255
+
256
+ async def _mock_answer(self) -> bool:
257
+ logger.info("[mock] Answering call from %s", self._current_number)
258
+ self._set_state(CallState.ACTIVE, self._current_number)
259
+ return True
260
+
261
+ async def _mock_hangup(self) -> bool:
262
+ logger.info("[mock] Hanging up call with %s", self._current_number)
263
+ self._set_state(CallState.DISCONNECTED, self._current_number)
264
+ await asyncio.sleep(0.3)
265
+ self._set_state(CallState.IDLE, "")
266
+ return True
267
+
268
+ async def mock_incoming_call(self, number: str) -> None:
269
+ """Simulate an incoming call — useful for testing without hardware."""
270
+ logger.info("[mock] Simulating incoming call from %s", number)
271
+ self._current_number = number
272
+ self._set_state(CallState.INCOMING, number)
273
+ if self.on_incoming_call:
274
+ self.on_incoming_call(number)
@@ -0,0 +1,139 @@
1
+ """Configuration management — YAML file with runtime override support."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import copy
6
+ import os
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ import yaml
11
+
12
+ _VENDOR_DIR = Path(__file__).resolve().parent
13
+ _MODULE_DIR = _VENDOR_DIR.parent # extensions/services/web/
14
+ _ROOT = _MODULE_DIR
15
+ _DEFAULT_CONFIG_PATH = _MODULE_DIR / "config.yaml"
16
+ _RUNTIME_CONFIG_PATH: Path | None = None
17
+ _load_error: str | None = None # set when config loading fails
18
+
19
+ _config: dict[str, Any] = {}
20
+
21
+
22
+ def _deep_merge(base: dict, override: dict) -> dict:
23
+ """Recursively merge *override* into *base* (mutates *base*)."""
24
+ for key, value in override.items():
25
+ if key in base and isinstance(base[key], dict) and isinstance(value, dict):
26
+ _deep_merge(base[key], value)
27
+ else:
28
+ base[key] = value
29
+ return base
30
+
31
+
32
+ def _resolve_project_root() -> Path:
33
+ """Resolve ai-phone-agent project root for relative path resolution."""
34
+ kite_project = os.environ.get("KITE_PROJECT")
35
+ if kite_project:
36
+ return Path(kite_project).parent # Kite/ -> ai-phone-agent/
37
+ # Fallback: walk up from module dir
38
+ return _MODULE_DIR
39
+
40
+
41
+ def load_config() -> dict[str, Any]:
42
+ """Load default config, then overlay runtime config if it exists.
43
+
44
+ Returns the loaded config dict. If the default config file is missing,
45
+ sets ``_load_error`` with a human-readable message and returns an empty
46
+ dict so the caller can decide how to handle it (e.g. log and exit
47
+ gracefully instead of crashing).
48
+ """
49
+ global _config, _RUNTIME_CONFIG_PATH, _load_error
50
+
51
+ if not _DEFAULT_CONFIG_PATH.exists():
52
+ _load_error = (
53
+ f"默认配置文件不存在: {_DEFAULT_CONFIG_PATH}\n"
54
+ f"请将 config.yaml 放到 Web 模块目录: {_MODULE_DIR}"
55
+ )
56
+ _config = {}
57
+ return _config
58
+
59
+ try:
60
+ with open(_DEFAULT_CONFIG_PATH, "r", encoding="utf-8") as f:
61
+ _config = yaml.safe_load(f) or {}
62
+ except Exception as exc:
63
+ _load_error = f"配置文件加载失败: {_DEFAULT_CONFIG_PATH}\n原因: {exc}"
64
+ _config = {}
65
+ return _config
66
+
67
+ # Determine data dir — resolve relative paths against project root
68
+ project_root = _resolve_project_root()
69
+ data_dir = Path(_config.get("data", {}).get("base_dir", "./data"))
70
+ if not data_dir.is_absolute():
71
+ data_dir = project_root / data_dir
72
+ _config.setdefault("data", {})["base_dir"] = str(data_dir)
73
+
74
+ _RUNTIME_CONFIG_PATH = data_dir / "config" / "runtime.yaml"
75
+ if _RUNTIME_CONFIG_PATH.exists():
76
+ try:
77
+ with open(_RUNTIME_CONFIG_PATH, "r", encoding="utf-8") as f:
78
+ runtime = yaml.safe_load(f) or {}
79
+ _deep_merge(_config, runtime)
80
+ except Exception as exc:
81
+ print(f"[web] WARNING: 运行时配置加载失败 ({_RUNTIME_CONFIG_PATH}): {exc}")
82
+
83
+ _load_error = None
84
+ return _config
85
+
86
+
87
+ def get_load_error() -> str | None:
88
+ """Return the error message from the last load_config() call, or None."""
89
+ return _load_error
90
+
91
+
92
+ def get_config() -> dict[str, Any]:
93
+ if not _config:
94
+ load_config()
95
+ return _config
96
+
97
+
98
+ def get(path: str, default: Any = None) -> Any:
99
+ """Get a nested config value using dot-notation, e.g. ``get('llm.active_provider')``."""
100
+ cfg = get_config()
101
+ keys = path.split(".")
102
+ for k in keys:
103
+ if isinstance(cfg, dict):
104
+ cfg = cfg.get(k)
105
+ else:
106
+ return default
107
+ if cfg is None:
108
+ return default
109
+ return cfg
110
+
111
+
112
+ def update_config(updates: dict[str, Any]) -> dict[str, Any]:
113
+ """Merge *updates* into runtime config and persist to disk."""
114
+ global _config
115
+ _deep_merge(_config, updates)
116
+ _save_runtime(updates)
117
+ return _config
118
+
119
+
120
+ def _save_runtime(updates: dict[str, Any]) -> None:
121
+ if _RUNTIME_CONFIG_PATH is None:
122
+ return
123
+ existing: dict[str, Any] = {}
124
+ if _RUNTIME_CONFIG_PATH.exists():
125
+ with open(_RUNTIME_CONFIG_PATH, "r", encoding="utf-8") as f:
126
+ existing = yaml.safe_load(f) or {}
127
+ _deep_merge(existing, updates)
128
+ _RUNTIME_CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
129
+ with open(_RUNTIME_CONFIG_PATH, "w", encoding="utf-8") as f:
130
+ yaml.dump(existing, f, allow_unicode=True, default_flow_style=False)
131
+
132
+
133
+ def data_dir() -> Path:
134
+ return Path(get("data.base_dir", "./data"))
135
+
136
+
137
+ def root_dir() -> Path:
138
+ """Return the project root directory."""
139
+ return _ROOT