@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.
- package/core/event_hub/entry.py +305 -26
- package/core/event_hub/hub.py +8 -0
- package/core/event_hub/server.py +80 -17
- package/core/kite_log.py +241 -0
- package/core/launcher/entry.py +978 -284
- package/core/launcher/process_manager.py +456 -46
- package/core/registry/entry.py +272 -3
- package/core/registry/server.py +339 -289
- package/core/registry/store.py +10 -4
- package/extensions/agents/__init__.py +1 -0
- package/extensions/agents/assistant/__init__.py +1 -0
- package/extensions/agents/assistant/entry.py +380 -0
- package/extensions/agents/assistant/module.md +22 -0
- package/extensions/agents/assistant/server.py +236 -0
- package/extensions/channels/__init__.py +1 -0
- package/extensions/channels/acp_channel/__init__.py +1 -0
- package/extensions/channels/acp_channel/entry.py +380 -0
- package/extensions/channels/acp_channel/module.md +22 -0
- package/extensions/channels/acp_channel/server.py +236 -0
- package/extensions/event_hub_bench/entry.py +664 -379
- package/extensions/event_hub_bench/module.md +2 -1
- package/extensions/services/backup/__init__.py +1 -0
- package/extensions/services/backup/entry.py +380 -0
- package/extensions/services/backup/module.md +22 -0
- package/extensions/services/backup/server.py +244 -0
- package/extensions/services/model_service/__init__.py +1 -0
- package/extensions/services/model_service/entry.py +380 -0
- package/extensions/services/model_service/module.md +22 -0
- package/extensions/services/model_service/server.py +236 -0
- package/extensions/services/watchdog/entry.py +460 -147
- package/extensions/services/watchdog/module.md +3 -0
- package/extensions/services/watchdog/monitor.py +128 -13
- package/extensions/services/watchdog/server.py +75 -13
- package/extensions/services/web/__init__.py +1 -0
- package/extensions/services/web/config.yaml +149 -0
- package/extensions/services/web/entry.py +487 -0
- package/extensions/services/web/module.md +24 -0
- package/extensions/services/web/routes/__init__.py +1 -0
- package/extensions/services/web/routes/routes_call.py +189 -0
- package/extensions/services/web/routes/routes_config.py +512 -0
- package/extensions/services/web/routes/routes_contacts.py +98 -0
- package/extensions/services/web/routes/routes_devlog.py +99 -0
- package/extensions/services/web/routes/routes_phone.py +81 -0
- package/extensions/services/web/routes/routes_sms.py +48 -0
- package/extensions/services/web/routes/routes_stats.py +17 -0
- package/extensions/services/web/routes/routes_voicechat.py +554 -0
- package/extensions/services/web/routes/schemas.py +216 -0
- package/extensions/services/web/server.py +332 -0
- package/extensions/services/web/static/css/style.css +1064 -0
- package/extensions/services/web/static/index.html +1445 -0
- package/extensions/services/web/static/js/app.js +4671 -0
- package/extensions/services/web/vendor/__init__.py +1 -0
- package/extensions/services/web/vendor/bluetooth/__init__.py +0 -0
- package/extensions/services/web/vendor/bluetooth/audio.py +348 -0
- package/extensions/services/web/vendor/bluetooth/contacts.py +251 -0
- package/extensions/services/web/vendor/bluetooth/manager.py +395 -0
- package/extensions/services/web/vendor/bluetooth/sms.py +290 -0
- package/extensions/services/web/vendor/bluetooth/telephony.py +274 -0
- package/extensions/services/web/vendor/config.py +139 -0
- package/extensions/services/web/vendor/conversation/__init__.py +0 -0
- package/extensions/services/web/vendor/conversation/asr.py +936 -0
- package/extensions/services/web/vendor/conversation/engine.py +548 -0
- package/extensions/services/web/vendor/conversation/llm.py +534 -0
- package/extensions/services/web/vendor/conversation/mcp_tools.py +190 -0
- package/extensions/services/web/vendor/conversation/tts.py +322 -0
- package/extensions/services/web/vendor/conversation/vad.py +138 -0
- package/extensions/services/web/vendor/storage/__init__.py +1 -0
- package/extensions/services/web/vendor/storage/identity.py +312 -0
- package/extensions/services/web/vendor/storage/store.py +507 -0
- package/extensions/services/web/vendor/task/__init__.py +0 -0
- package/extensions/services/web/vendor/task/manager.py +864 -0
- package/extensions/services/web/vendor/task/models.py +45 -0
- package/extensions/services/web/vendor/task/webhook.py +263 -0
- package/extensions/services/web/vendor/tools/__init__.py +0 -0
- package/extensions/services/web/vendor/tools/registry.py +321 -0
- package/main.py +230 -90
- 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
|
|
File without changes
|