roomkit-ui 0.3.0__py3-none-any.whl

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 (214) hide show
  1. roomkit_ui/__init__.py +1 -0
  2. roomkit_ui/__main__.py +3 -0
  3. roomkit_ui/aec_dump.py +197 -0
  4. roomkit_ui/agent_hooks.py +117 -0
  5. roomkit_ui/app.py +415 -0
  6. roomkit_ui/assets/icon-macos.png +0 -0
  7. roomkit_ui/assets/icon.icns +0 -0
  8. roomkit_ui/assets/icon.ico +0 -0
  9. roomkit_ui/assets/icon.png +0 -0
  10. roomkit_ui/assets/logo.svg +7 -0
  11. roomkit_ui/audio_tags.py +64 -0
  12. roomkit_ui/builtin_tools.py +156 -0
  13. roomkit_ui/call_mode.py +217 -0
  14. roomkit_ui/camera_capture.py +73 -0
  15. roomkit_ui/cleanup.py +281 -0
  16. roomkit_ui/cli_exec.py +239 -0
  17. roomkit_ui/cli_help.py +239 -0
  18. roomkit_ui/cli_tools.py +224 -0
  19. roomkit_ui/cli_tools_config.py +86 -0
  20. roomkit_ui/component_installer.py +152 -0
  21. roomkit_ui/components.py +196 -0
  22. roomkit_ui/constants.py +70 -0
  23. roomkit_ui/conversation_titles.py +84 -0
  24. roomkit_ui/conversations.py +337 -0
  25. roomkit_ui/dictation_stream.py +80 -0
  26. roomkit_ui/download_flight.py +88 -0
  27. roomkit_ui/engine.py +950 -0
  28. roomkit_ui/engine_audio.py +492 -0
  29. roomkit_ui/engine_callbacks.py +114 -0
  30. roomkit_ui/engine_realtime.py +526 -0
  31. roomkit_ui/engine_state.py +20 -0
  32. roomkit_ui/engine_text.py +237 -0
  33. roomkit_ui/engine_tools.py +422 -0
  34. roomkit_ui/engine_vc.py +777 -0
  35. roomkit_ui/engine_vc_helpers.py +310 -0
  36. roomkit_ui/enrollment.py +176 -0
  37. roomkit_ui/env_config.py +46 -0
  38. roomkit_ui/features.py +323 -0
  39. roomkit_ui/gguf_models.py +730 -0
  40. roomkit_ui/hooks.py +400 -0
  41. roomkit_ui/hotkey.py +445 -0
  42. roomkit_ui/icons.py +186 -0
  43. roomkit_ui/idle_hangup.py +162 -0
  44. roomkit_ui/image_generation.py +537 -0
  45. roomkit_ui/image_messages.py +62 -0
  46. roomkit_ui/images.py +112 -0
  47. roomkit_ui/macos_app.py +135 -0
  48. roomkit_ui/mcp_app_bridge.py +260 -0
  49. roomkit_ui/mcp_app_state.py +53 -0
  50. roomkit_ui/mcp_auth.py +384 -0
  51. roomkit_ui/mcp_config.py +135 -0
  52. roomkit_ui/mcp_http_auth.py +96 -0
  53. roomkit_ui/mcp_manager.py +631 -0
  54. roomkit_ui/mcp_skills.py +173 -0
  55. roomkit_ui/memory.py +321 -0
  56. roomkit_ui/model_catalog.py +417 -0
  57. roomkit_ui/model_download.py +430 -0
  58. roomkit_ui/model_manager.py +268 -0
  59. roomkit_ui/paste.py +273 -0
  60. roomkit_ui/paste_windows.py +167 -0
  61. roomkit_ui/paths.py +78 -0
  62. roomkit_ui/providers/__init__.py +91 -0
  63. roomkit_ui/providers/anthropic.py +21 -0
  64. roomkit_ui/providers/gemini.py +21 -0
  65. roomkit_ui/providers/llamacpp.py +192 -0
  66. roomkit_ui/providers/local.py +58 -0
  67. roomkit_ui/providers/openai.py +21 -0
  68. roomkit_ui/realtime_providers.py +458 -0
  69. roomkit_ui/secret_store.py +178 -0
  70. roomkit_ui/session_models.py +218 -0
  71. roomkit_ui/settings.py +469 -0
  72. roomkit_ui/settings_secrets.py +456 -0
  73. roomkit_ui/skill_approval.py +107 -0
  74. roomkit_ui/skill_git.py +136 -0
  75. roomkit_ui/skill_manager.py +225 -0
  76. roomkit_ui/skill_well_known.py +347 -0
  77. roomkit_ui/skills_sh_client.py +154 -0
  78. roomkit_ui/sounds.py +101 -0
  79. roomkit_ui/speaker_manager.py +100 -0
  80. roomkit_ui/speaker_naming.py +274 -0
  81. roomkit_ui/speaker_roster.py +188 -0
  82. roomkit_ui/stt_engine.py +519 -0
  83. roomkit_ui/stt_sessions.py +384 -0
  84. roomkit_ui/text_chat.py +75 -0
  85. roomkit_ui/theme.py +349 -0
  86. roomkit_ui/theme_live.py +71 -0
  87. roomkit_ui/tool_events.py +20 -0
  88. roomkit_ui/toolset.py +209 -0
  89. roomkit_ui/tray.py +168 -0
  90. roomkit_ui/tts/__init__.py +55 -0
  91. roomkit_ui/tts/_util.py +12 -0
  92. roomkit_ui/tts/azure.py +35 -0
  93. roomkit_ui/tts/elevenlabs.py +40 -0
  94. roomkit_ui/tts/gemini.py +31 -0
  95. roomkit_ui/tts/gradium.py +52 -0
  96. roomkit_ui/tts/neutts.py +25 -0
  97. roomkit_ui/tts/piper.py +22 -0
  98. roomkit_ui/tts/pocket.py +95 -0
  99. roomkit_ui/tts/qwen3.py +25 -0
  100. roomkit_ui/url_policy.py +221 -0
  101. roomkit_ui/vc_agent.py +381 -0
  102. roomkit_ui/vc_stt.py +275 -0
  103. roomkit_ui/wakeword/__init__.py +31 -0
  104. roomkit_ui/wakeword/capture.py +312 -0
  105. roomkit_ui/wakeword/controller.py +268 -0
  106. roomkit_ui/wakeword/detector.py +120 -0
  107. roomkit_ui/wakeword/dtw.py +55 -0
  108. roomkit_ui/wakeword/encoder_download.py +55 -0
  109. roomkit_ui/wakeword/enroller.py +263 -0
  110. roomkit_ui/wakeword/features.py +145 -0
  111. roomkit_ui/wakeword/fingerprint.py +195 -0
  112. roomkit_ui/wakeword/jobs.py +95 -0
  113. roomkit_ui/wakeword/listener.py +138 -0
  114. roomkit_ui/wakeword/page_sensitivity.py +34 -0
  115. roomkit_ui/wakeword/resample.py +88 -0
  116. roomkit_ui/watchdog.py +194 -0
  117. roomkit_ui/widgets/__init__.py +0 -0
  118. roomkit_ui/widgets/buttons.py +28 -0
  119. roomkit_ui/widgets/camera_dialog.py +232 -0
  120. roomkit_ui/widgets/camera_process.py +165 -0
  121. roomkit_ui/widgets/chat_bubble.py +482 -0
  122. roomkit_ui/widgets/chat_view.py +552 -0
  123. roomkit_ui/widgets/composer_dictation.py +94 -0
  124. roomkit_ui/widgets/conversation_composer.py +551 -0
  125. roomkit_ui/widgets/conversation_drawer.py +300 -0
  126. roomkit_ui/widgets/conversation_history.py +359 -0
  127. roomkit_ui/widgets/conversation_home.py +176 -0
  128. roomkit_ui/widgets/dictation_log.py +78 -0
  129. roomkit_ui/widgets/hotkey_button.py +347 -0
  130. roomkit_ui/widgets/image_attachments.py +271 -0
  131. roomkit_ui/widgets/install_offer.py +124 -0
  132. roomkit_ui/widgets/main_window.py +652 -0
  133. roomkit_ui/widgets/mcp_app_server.py +91 -0
  134. roomkit_ui/widgets/mcp_app_web.py +128 -0
  135. roomkit_ui/widgets/mcp_app_widget.py +341 -0
  136. roomkit_ui/widgets/mcp_app_wrapper.py +80 -0
  137. roomkit_ui/widgets/menus.py +79 -0
  138. roomkit_ui/widgets/message_input.py +77 -0
  139. roomkit_ui/widgets/navigation_brand.py +24 -0
  140. roomkit_ui/widgets/session_drawer.py +357 -0
  141. roomkit_ui/widgets/session_info.py +76 -0
  142. roomkit_ui/widgets/session_loading.py +78 -0
  143. roomkit_ui/widgets/settings/__init__.py +5 -0
  144. roomkit_ui/widgets/settings/about_page.py +95 -0
  145. roomkit_ui/widgets/settings/appearance.py +95 -0
  146. roomkit_ui/widgets/settings/attitudes_page.py +218 -0
  147. roomkit_ui/widgets/settings/audio_capture_page.py +143 -0
  148. roomkit_ui/widgets/settings/audio_page.py +40 -0
  149. roomkit_ui/widgets/settings/audio_processing.py +338 -0
  150. roomkit_ui/widgets/settings/catalog_controls.py +153 -0
  151. roomkit_ui/widgets/settings/choice_cards.py +116 -0
  152. roomkit_ui/widgets/settings/cli_tools_page.py +323 -0
  153. roomkit_ui/widgets/settings/components_page.py +260 -0
  154. roomkit_ui/widgets/settings/controls.py +247 -0
  155. roomkit_ui/widgets/settings/conversation_page.py +133 -0
  156. roomkit_ui/widgets/settings/diagnostics_page.py +57 -0
  157. roomkit_ui/widgets/settings/dictation_page.py +192 -0
  158. roomkit_ui/widgets/settings/feature_hints.py +89 -0
  159. roomkit_ui/widgets/settings/general_page.py +125 -0
  160. roomkit_ui/widgets/settings/images_setup.py +153 -0
  161. roomkit_ui/widgets/settings/integration_controls.py +281 -0
  162. roomkit_ui/widgets/settings/integrations_page.py +59 -0
  163. roomkit_ui/widgets/settings/library_page.py +38 -0
  164. roomkit_ui/widgets/settings/llm_fields.py +601 -0
  165. roomkit_ui/widgets/settings/llm_library_page.py +526 -0
  166. roomkit_ui/widgets/settings/mcp_page.py +657 -0
  167. roomkit_ui/widgets/settings/model_combos.py +49 -0
  168. roomkit_ui/widgets/settings/model_jobs.py +97 -0
  169. roomkit_ui/widgets/settings/model_library.py +161 -0
  170. roomkit_ui/widgets/settings/models_page.py +149 -0
  171. roomkit_ui/widgets/settings/models_widgets.py +92 -0
  172. roomkit_ui/widgets/settings/navigation.py +61 -0
  173. roomkit_ui/widgets/settings/page_registry.py +548 -0
  174. roomkit_ui/widgets/settings/panel.py +345 -0
  175. roomkit_ui/widgets/settings/pipeline_summary.py +36 -0
  176. roomkit_ui/widgets/settings/privacy_page.py +149 -0
  177. roomkit_ui/widgets/settings/providers_page.py +281 -0
  178. roomkit_ui/widgets/settings/realtime_catalogs.py +199 -0
  179. roomkit_ui/widgets/settings/realtime_gemini_live.py +116 -0
  180. roomkit_ui/widgets/settings/realtime_gpt_live.py +111 -0
  181. roomkit_ui/widgets/settings/realtime_section.py +641 -0
  182. roomkit_ui/widgets/settings/recordings_page.py +409 -0
  183. roomkit_ui/widgets/settings/search.py +113 -0
  184. roomkit_ui/widgets/settings/skills/__init__.py +3 -0
  185. roomkit_ui/widgets/settings/skills/marketplace.py +209 -0
  186. roomkit_ui/widgets/settings/skills/my_skills.py +155 -0
  187. roomkit_ui/widgets/settings/skills/page.py +483 -0
  188. roomkit_ui/widgets/settings/skills/widgets.py +99 -0
  189. roomkit_ui/widgets/settings/speakers_dialogs.py +200 -0
  190. roomkit_ui/widgets/settings/speakers_muse.py +55 -0
  191. roomkit_ui/widgets/settings/speakers_page.py +367 -0
  192. roomkit_ui/widgets/settings/style.py +327 -0
  193. roomkit_ui/widgets/settings/telemetry_page.py +107 -0
  194. roomkit_ui/widgets/settings/text_chat_setup.py +52 -0
  195. roomkit_ui/widgets/settings/tool_search_section.py +96 -0
  196. roomkit_ui/widgets/settings/vc_agent_fields.py +210 -0
  197. roomkit_ui/widgets/settings/vc_azure_fields.py +67 -0
  198. roomkit_ui/widgets/settings/vc_cloud_fields.py +331 -0
  199. roomkit_ui/widgets/settings/vc_google_fields.py +106 -0
  200. roomkit_ui/widgets/settings/vc_local_fields.py +226 -0
  201. roomkit_ui/widgets/settings/vc_meta_fields.py +32 -0
  202. roomkit_ui/widgets/settings/voice_setup.py +330 -0
  203. roomkit_ui/widgets/settings/wakeword_page.py +252 -0
  204. roomkit_ui/widgets/skill_approval_dialog.py +40 -0
  205. roomkit_ui/widgets/sliding_panel.py +146 -0
  206. roomkit_ui/widgets/tool_activity.py +290 -0
  207. roomkit_ui/widgets/voice_name_dialog.py +28 -0
  208. roomkit_ui/widgets/vu_meter.py +103 -0
  209. roomkit_ui/widgets/wakeword_wizard.py +315 -0
  210. roomkit_ui-0.3.0.dist-info/METADATA +115 -0
  211. roomkit_ui-0.3.0.dist-info/RECORD +214 -0
  212. roomkit_ui-0.3.0.dist-info/WHEEL +4 -0
  213. roomkit_ui-0.3.0.dist-info/entry_points.txt +2 -0
  214. roomkit_ui-0.3.0.dist-info/licenses/LICENSE +21 -0
roomkit_ui/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """RoomKit UI — a desktop voice assistant built on roomkit."""
roomkit_ui/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from roomkit_ui.app import main
2
+
3
+ main()
roomkit_ui/aec_dump.py ADDED
@@ -0,0 +1,197 @@
1
+ """AEC dump recorder — captures what the echo canceller actually sees.
2
+
3
+ Wraps any roomkit ``AECProvider`` and records, in arrival order, every
4
+ reference frame fed from the playback path and every capture frame with
5
+ its processed result. The dump is the raw material for the offline AEC
6
+ bench (``scripts/aec_bench.py`` in the roomkit repo): replaying it through
7
+ a fresh provider with different settings measures a fix instead of
8
+ guessing at it.
9
+
10
+ Enabled by the ``ROOMKIT_AEC_DUMP`` environment variable (a directory);
11
+ wired on the realtime path only — the VC path hands the same AEC instance
12
+ to the backend, whose transport-level integration this wrapper does not
13
+ mimic.
14
+
15
+ Format (``aec-dump/1``): one JSONL file per stream. Line 1 is a header
16
+ ``{"format": "aec-dump/1", "sample_rate": …, "channels": …,
17
+ "sample_width": …, "stream": …, "provider": …}``; every following line is
18
+ an event — ``{"t": "ref", "ns": …, "d": <b64>}`` for a reference frame,
19
+ ``{"t": "cap", "ns": …, "i": <b64>, "o": <b64>}`` for a capture frame and
20
+ its processed output, ``{"t": "act", "ns": …, "a": bool}`` for a
21
+ per-stream activation toggle — the live pipeline pairs activation with
22
+ playback (the interrupt cycle), and a replay that ignored those toggles
23
+ would not reproduce the run. Three WAVs (``ref/mic_in/mic_out``) are written
24
+ alongside for listening; the JSONL, which preserves interleaving, is what
25
+ the bench replays.
26
+
27
+ ``feed_reference`` runs on the PortAudio callback thread, so events are
28
+ buffered in memory and flushed at ``reset()`` — never any I/O on the
29
+ audio thread. A safety cap stops recording after ~10 minutes.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import base64
35
+ import contextlib
36
+ import json
37
+ import logging
38
+ import threading
39
+ import time
40
+ import wave
41
+ from pathlib import Path
42
+ from typing import Any
43
+
44
+ logger = logging.getLogger(__name__)
45
+
46
+ # ~10 min of 10 ms events at 24 kHz on both paths — bench sessions are
47
+ # minutes long; an overnight session must not eat the machine's RAM.
48
+ _MAX_EVENTS = 120_000
49
+
50
+
51
+ class AECDumpRecorder:
52
+ """Pass-through ``AECProvider`` wrapper that records its traffic."""
53
+
54
+ def __init__(self, inner: Any, directory: Path) -> None:
55
+ self._inner = inner
56
+ self._dir = directory
57
+ self._lock = threading.Lock()
58
+ # stream -> list of event tuples; ("ref", ns, bytes) or
59
+ # ("cap", ns, in_bytes, out_bytes)
60
+ self._events: dict[str, list[tuple]] = {}
61
+ self._formats: dict[str, tuple[int, int, int]] = {}
62
+ self._capped: set[str] = set()
63
+
64
+ # -- AECProvider surface (0.43 ABC) -----------------------------------
65
+
66
+ @property
67
+ def name(self) -> str:
68
+ return f"{self._inner.name}+dump"
69
+
70
+ def process(self, frame: Any, stream: str) -> Any:
71
+ out = self._inner.process(frame, stream)
72
+ self._record(
73
+ stream,
74
+ ("cap", time.monotonic_ns(), bytes(frame.data), bytes(out.data)),
75
+ frame,
76
+ )
77
+ return out
78
+
79
+ def feed_reference(self, frame: Any, stream: str) -> None:
80
+ self._inner.feed_reference(frame, stream)
81
+ self._record(stream, ("ref", time.monotonic_ns(), bytes(frame.data)), frame)
82
+
83
+ def set_active(self, active: bool) -> None:
84
+ self._inner.set_active(active)
85
+
86
+ def set_stream_active(self, stream: str, active: bool) -> None:
87
+ self._inner.set_stream_active(stream, active)
88
+ with self._lock:
89
+ if stream not in self._capped:
90
+ events = self._events.setdefault(stream, [])
91
+ if len(events) < _MAX_EVENTS:
92
+ events.append(("act", time.monotonic_ns(), active))
93
+
94
+ def reset(self, stream: str) -> None:
95
+ self._inner.reset(stream)
96
+ self._flush(stream)
97
+
98
+ def __getattr__(self, item: str) -> Any:
99
+ # Anything beyond the ABC (stats hooks, provider-specific knobs)
100
+ # reaches the wrapped provider untouched.
101
+ return getattr(self._inner, item)
102
+
103
+ # -- recording ---------------------------------------------------------
104
+
105
+ def _record(self, stream: str, event: tuple, frame: Any) -> None:
106
+ with self._lock:
107
+ if stream in self._capped:
108
+ return
109
+ events = self._events.setdefault(stream, [])
110
+ if len(events) >= _MAX_EVENTS:
111
+ self._capped.add(stream)
112
+ logger.warning(
113
+ "AEC dump for stream %s hit the event cap — recording stopped", stream
114
+ )
115
+ return
116
+ events.append(event)
117
+ self._formats.setdefault(
118
+ stream,
119
+ (
120
+ getattr(frame, "sample_rate", 0),
121
+ getattr(frame, "channels", 1),
122
+ getattr(frame, "sample_width", 2),
123
+ ),
124
+ )
125
+
126
+ def _flush(self, stream: str) -> None:
127
+ with self._lock:
128
+ events = self._events.pop(stream, [])
129
+ fmt = self._formats.pop(stream, (0, 1, 2))
130
+ self._capped.discard(stream)
131
+ if not events:
132
+ return
133
+ try:
134
+ session_dir = self._dir / time.strftime("%Y%m%d-%H%M%S")
135
+ session_dir.mkdir(parents=True, exist_ok=True)
136
+ self._write_jsonl(session_dir, stream, fmt, events)
137
+ self._write_wavs(session_dir, fmt, events)
138
+ logger.info(
139
+ "AEC dump written: %s (%d events, stream %s)",
140
+ session_dir,
141
+ len(events),
142
+ stream,
143
+ )
144
+ except Exception:
145
+ logger.exception("Failed to write AEC dump for stream %s", stream)
146
+
147
+ def _write_jsonl(
148
+ self,
149
+ session_dir: Path,
150
+ stream: str,
151
+ fmt: tuple[int, int, int],
152
+ events: list[tuple],
153
+ ) -> None:
154
+ sample_rate, channels, sample_width = fmt
155
+ header = {
156
+ "format": "aec-dump/1",
157
+ "sample_rate": sample_rate,
158
+ "channels": channels,
159
+ "sample_width": sample_width,
160
+ "stream": stream,
161
+ "provider": getattr(self._inner, "name", "unknown"),
162
+ }
163
+ with (session_dir / "events.jsonl").open("w", encoding="utf-8") as f:
164
+ f.write(json.dumps(header) + "\n")
165
+ for event in events:
166
+ if event[0] == "ref":
167
+ _, ns, data = event
168
+ row: dict = {"t": "ref", "ns": ns, "d": _b64(data)}
169
+ elif event[0] == "act":
170
+ _, ns, active = event
171
+ row = {"t": "act", "ns": ns, "a": bool(active)}
172
+ else:
173
+ _, ns, in_data, out_data = event
174
+ row = {"t": "cap", "ns": ns, "i": _b64(in_data), "o": _b64(out_data)}
175
+ f.write(json.dumps(row) + "\n")
176
+
177
+ def _write_wavs(
178
+ self, session_dir: Path, fmt: tuple[int, int, int], events: list[tuple]
179
+ ) -> None:
180
+ sample_rate, channels, sample_width = fmt
181
+ tracks = {
182
+ "ref.wav": b"".join(e[2] for e in events if e[0] == "ref"),
183
+ "mic_in.wav": b"".join(e[2] for e in events if e[0] == "cap"),
184
+ "mic_out.wav": b"".join(e[3] for e in events if e[0] == "cap"),
185
+ }
186
+ for filename, pcm in tracks.items():
187
+ if not pcm:
188
+ continue
189
+ with contextlib.closing(wave.open(str(session_dir / filename), "wb")) as w:
190
+ w.setnchannels(channels)
191
+ w.setsampwidth(sample_width)
192
+ w.setframerate(sample_rate or 24000)
193
+ w.writeframes(pcm)
194
+
195
+
196
+ def _b64(data: bytes) -> str:
197
+ return base64.b64encode(data).decode("ascii")
@@ -0,0 +1,117 @@
1
+ """Log turn-taking diagnostics; show only background-task progress in the chat.
2
+
3
+ Speaking decisions and thoughts are internal diagnostics. Background work
4
+ still announces when it starts and ends, and updates the engine's task count
5
+ so idle hang-up can wait for it.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ from typing import Any
12
+
13
+ from roomkit_ui.engine_state import EngineState
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+ _TASK_LABEL_MAX = 120
18
+
19
+
20
+ def _task_label(meta: dict) -> str:
21
+ """What a task was asked, on one line and shortened."""
22
+ task = " ".join(str(meta.get("task_input") or "").split())
23
+ if len(task) > _TASK_LABEL_MAX:
24
+ task = task[: _TASK_LABEL_MAX - 1] + "…"
25
+ return task
26
+
27
+
28
+ def task_started_notice(event: Any) -> str:
29
+ """The chat line for a task the agent started: what it was asked."""
30
+ task = _task_label(getattr(event, "metadata", None) or {})
31
+ return f"Working in the background: {task}" if task else "Working in the background."
32
+
33
+
34
+ def task_ended_notice(event: Any) -> str:
35
+ """The chat line for a task that ended. A failure's error stays in the
36
+ log: it is the framework's, not words for the user."""
37
+ meta = getattr(event, "metadata", None) or {}
38
+ status = str(meta.get("task_status") or "")
39
+ if status == "completed":
40
+ took = meta.get("duration_ms")
41
+ return (
42
+ f"Background work done in {round(took / 1000)} s." if took else "Background work done."
43
+ )
44
+ if status == "cancelled":
45
+ return "Background work cancelled."
46
+ return "Background work failed."
47
+
48
+
49
+ def register_agent_hooks(kit: Any, engine: Any) -> None:
50
+ """Log policy decisions and thoughts; report background-task progress."""
51
+ _register_speaking_hooks(kit)
52
+ _register_task_hooks(kit, engine)
53
+
54
+
55
+ def _register_speaking_hooks(kit: Any) -> None:
56
+ from roomkit.models.enums import HookExecution, HookTrigger
57
+
58
+ @kit.hook(HookTrigger.ON_SPEAK_DECISION, HookExecution.ASYNC)
59
+ async def _on_speak_decision(event, context):
60
+ decision = event.decision
61
+ log = logger.warning if decision.reason == "fallback" else logger.info
62
+ log(
63
+ "Speak decision: %s (%s, %d ms%s)",
64
+ decision.mode,
65
+ decision.reason,
66
+ event.duration_ms,
67
+ ", asked again" if event.asked_again else "",
68
+ )
69
+
70
+ @kit.hook(HookTrigger.ON_THOUGHT, HookExecution.ASYNC)
71
+ async def _on_thought(event, context):
72
+ thought = event.thought
73
+ took = f"{event.duration_ms} ms" if event.duration_ms is not None else "emptied"
74
+ logger.info(
75
+ "Thought (%s): %s | wants to say: %s", took, thought.text, list(thought.want_to_say)
76
+ )
77
+
78
+
79
+ def _register_task_hooks(kit: Any, engine: Any) -> None:
80
+ from roomkit.models.enums import HookExecution, HookTrigger
81
+
82
+ @kit.hook(HookTrigger.ON_TASK_DELEGATED, HookExecution.ASYNC)
83
+ async def _on_task_started(event, context):
84
+ engine._background_tasks += 1
85
+ meta = event.metadata or {}
86
+ # agent_id: the channel doing it, "background" on a model of its own.
87
+ logger.info(
88
+ "Background task %s started on %s: %s",
89
+ meta.get("task_id"),
90
+ meta.get("agent_id"),
91
+ _task_label(meta),
92
+ )
93
+ _notify(engine, task_started_notice(event))
94
+
95
+ @kit.hook(HookTrigger.ON_TASK_COMPLETED, HookExecution.ASYNC)
96
+ async def _on_task_ended(event, context):
97
+ engine._background_tasks = max(0, engine._background_tasks - 1)
98
+ meta = event.metadata or {}
99
+ if meta.get("error"):
100
+ logger.warning("Background task %s failed: %s", meta.get("task_id"), meta["error"])
101
+ else:
102
+ logger.info(
103
+ "Background task %s %s in %.1f s",
104
+ meta.get("task_id"),
105
+ meta.get("task_status"),
106
+ (meta.get("duration_ms") or 0) / 1000,
107
+ )
108
+ _notify(engine, task_ended_notice(event))
109
+
110
+
111
+ def _notify(engine: Any, notice: str) -> None:
112
+ if engine._state != EngineState.ACTIVE:
113
+ return
114
+ try:
115
+ engine.session_notice.emit(notice)
116
+ except Exception:
117
+ pass