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.
- roomkit_ui/__init__.py +1 -0
- roomkit_ui/__main__.py +3 -0
- roomkit_ui/aec_dump.py +197 -0
- roomkit_ui/agent_hooks.py +117 -0
- roomkit_ui/app.py +415 -0
- roomkit_ui/assets/icon-macos.png +0 -0
- roomkit_ui/assets/icon.icns +0 -0
- roomkit_ui/assets/icon.ico +0 -0
- roomkit_ui/assets/icon.png +0 -0
- roomkit_ui/assets/logo.svg +7 -0
- roomkit_ui/audio_tags.py +64 -0
- roomkit_ui/builtin_tools.py +156 -0
- roomkit_ui/call_mode.py +217 -0
- roomkit_ui/camera_capture.py +73 -0
- roomkit_ui/cleanup.py +281 -0
- roomkit_ui/cli_exec.py +239 -0
- roomkit_ui/cli_help.py +239 -0
- roomkit_ui/cli_tools.py +224 -0
- roomkit_ui/cli_tools_config.py +86 -0
- roomkit_ui/component_installer.py +152 -0
- roomkit_ui/components.py +196 -0
- roomkit_ui/constants.py +70 -0
- roomkit_ui/conversation_titles.py +84 -0
- roomkit_ui/conversations.py +337 -0
- roomkit_ui/dictation_stream.py +80 -0
- roomkit_ui/download_flight.py +88 -0
- roomkit_ui/engine.py +950 -0
- roomkit_ui/engine_audio.py +492 -0
- roomkit_ui/engine_callbacks.py +114 -0
- roomkit_ui/engine_realtime.py +526 -0
- roomkit_ui/engine_state.py +20 -0
- roomkit_ui/engine_text.py +237 -0
- roomkit_ui/engine_tools.py +422 -0
- roomkit_ui/engine_vc.py +777 -0
- roomkit_ui/engine_vc_helpers.py +310 -0
- roomkit_ui/enrollment.py +176 -0
- roomkit_ui/env_config.py +46 -0
- roomkit_ui/features.py +323 -0
- roomkit_ui/gguf_models.py +730 -0
- roomkit_ui/hooks.py +400 -0
- roomkit_ui/hotkey.py +445 -0
- roomkit_ui/icons.py +186 -0
- roomkit_ui/idle_hangup.py +162 -0
- roomkit_ui/image_generation.py +537 -0
- roomkit_ui/image_messages.py +62 -0
- roomkit_ui/images.py +112 -0
- roomkit_ui/macos_app.py +135 -0
- roomkit_ui/mcp_app_bridge.py +260 -0
- roomkit_ui/mcp_app_state.py +53 -0
- roomkit_ui/mcp_auth.py +384 -0
- roomkit_ui/mcp_config.py +135 -0
- roomkit_ui/mcp_http_auth.py +96 -0
- roomkit_ui/mcp_manager.py +631 -0
- roomkit_ui/mcp_skills.py +173 -0
- roomkit_ui/memory.py +321 -0
- roomkit_ui/model_catalog.py +417 -0
- roomkit_ui/model_download.py +430 -0
- roomkit_ui/model_manager.py +268 -0
- roomkit_ui/paste.py +273 -0
- roomkit_ui/paste_windows.py +167 -0
- roomkit_ui/paths.py +78 -0
- roomkit_ui/providers/__init__.py +91 -0
- roomkit_ui/providers/anthropic.py +21 -0
- roomkit_ui/providers/gemini.py +21 -0
- roomkit_ui/providers/llamacpp.py +192 -0
- roomkit_ui/providers/local.py +58 -0
- roomkit_ui/providers/openai.py +21 -0
- roomkit_ui/realtime_providers.py +458 -0
- roomkit_ui/secret_store.py +178 -0
- roomkit_ui/session_models.py +218 -0
- roomkit_ui/settings.py +469 -0
- roomkit_ui/settings_secrets.py +456 -0
- roomkit_ui/skill_approval.py +107 -0
- roomkit_ui/skill_git.py +136 -0
- roomkit_ui/skill_manager.py +225 -0
- roomkit_ui/skill_well_known.py +347 -0
- roomkit_ui/skills_sh_client.py +154 -0
- roomkit_ui/sounds.py +101 -0
- roomkit_ui/speaker_manager.py +100 -0
- roomkit_ui/speaker_naming.py +274 -0
- roomkit_ui/speaker_roster.py +188 -0
- roomkit_ui/stt_engine.py +519 -0
- roomkit_ui/stt_sessions.py +384 -0
- roomkit_ui/text_chat.py +75 -0
- roomkit_ui/theme.py +349 -0
- roomkit_ui/theme_live.py +71 -0
- roomkit_ui/tool_events.py +20 -0
- roomkit_ui/toolset.py +209 -0
- roomkit_ui/tray.py +168 -0
- roomkit_ui/tts/__init__.py +55 -0
- roomkit_ui/tts/_util.py +12 -0
- roomkit_ui/tts/azure.py +35 -0
- roomkit_ui/tts/elevenlabs.py +40 -0
- roomkit_ui/tts/gemini.py +31 -0
- roomkit_ui/tts/gradium.py +52 -0
- roomkit_ui/tts/neutts.py +25 -0
- roomkit_ui/tts/piper.py +22 -0
- roomkit_ui/tts/pocket.py +95 -0
- roomkit_ui/tts/qwen3.py +25 -0
- roomkit_ui/url_policy.py +221 -0
- roomkit_ui/vc_agent.py +381 -0
- roomkit_ui/vc_stt.py +275 -0
- roomkit_ui/wakeword/__init__.py +31 -0
- roomkit_ui/wakeword/capture.py +312 -0
- roomkit_ui/wakeword/controller.py +268 -0
- roomkit_ui/wakeword/detector.py +120 -0
- roomkit_ui/wakeword/dtw.py +55 -0
- roomkit_ui/wakeword/encoder_download.py +55 -0
- roomkit_ui/wakeword/enroller.py +263 -0
- roomkit_ui/wakeword/features.py +145 -0
- roomkit_ui/wakeword/fingerprint.py +195 -0
- roomkit_ui/wakeword/jobs.py +95 -0
- roomkit_ui/wakeword/listener.py +138 -0
- roomkit_ui/wakeword/page_sensitivity.py +34 -0
- roomkit_ui/wakeword/resample.py +88 -0
- roomkit_ui/watchdog.py +194 -0
- roomkit_ui/widgets/__init__.py +0 -0
- roomkit_ui/widgets/buttons.py +28 -0
- roomkit_ui/widgets/camera_dialog.py +232 -0
- roomkit_ui/widgets/camera_process.py +165 -0
- roomkit_ui/widgets/chat_bubble.py +482 -0
- roomkit_ui/widgets/chat_view.py +552 -0
- roomkit_ui/widgets/composer_dictation.py +94 -0
- roomkit_ui/widgets/conversation_composer.py +551 -0
- roomkit_ui/widgets/conversation_drawer.py +300 -0
- roomkit_ui/widgets/conversation_history.py +359 -0
- roomkit_ui/widgets/conversation_home.py +176 -0
- roomkit_ui/widgets/dictation_log.py +78 -0
- roomkit_ui/widgets/hotkey_button.py +347 -0
- roomkit_ui/widgets/image_attachments.py +271 -0
- roomkit_ui/widgets/install_offer.py +124 -0
- roomkit_ui/widgets/main_window.py +652 -0
- roomkit_ui/widgets/mcp_app_server.py +91 -0
- roomkit_ui/widgets/mcp_app_web.py +128 -0
- roomkit_ui/widgets/mcp_app_widget.py +341 -0
- roomkit_ui/widgets/mcp_app_wrapper.py +80 -0
- roomkit_ui/widgets/menus.py +79 -0
- roomkit_ui/widgets/message_input.py +77 -0
- roomkit_ui/widgets/navigation_brand.py +24 -0
- roomkit_ui/widgets/session_drawer.py +357 -0
- roomkit_ui/widgets/session_info.py +76 -0
- roomkit_ui/widgets/session_loading.py +78 -0
- roomkit_ui/widgets/settings/__init__.py +5 -0
- roomkit_ui/widgets/settings/about_page.py +95 -0
- roomkit_ui/widgets/settings/appearance.py +95 -0
- roomkit_ui/widgets/settings/attitudes_page.py +218 -0
- roomkit_ui/widgets/settings/audio_capture_page.py +143 -0
- roomkit_ui/widgets/settings/audio_page.py +40 -0
- roomkit_ui/widgets/settings/audio_processing.py +338 -0
- roomkit_ui/widgets/settings/catalog_controls.py +153 -0
- roomkit_ui/widgets/settings/choice_cards.py +116 -0
- roomkit_ui/widgets/settings/cli_tools_page.py +323 -0
- roomkit_ui/widgets/settings/components_page.py +260 -0
- roomkit_ui/widgets/settings/controls.py +247 -0
- roomkit_ui/widgets/settings/conversation_page.py +133 -0
- roomkit_ui/widgets/settings/diagnostics_page.py +57 -0
- roomkit_ui/widgets/settings/dictation_page.py +192 -0
- roomkit_ui/widgets/settings/feature_hints.py +89 -0
- roomkit_ui/widgets/settings/general_page.py +125 -0
- roomkit_ui/widgets/settings/images_setup.py +153 -0
- roomkit_ui/widgets/settings/integration_controls.py +281 -0
- roomkit_ui/widgets/settings/integrations_page.py +59 -0
- roomkit_ui/widgets/settings/library_page.py +38 -0
- roomkit_ui/widgets/settings/llm_fields.py +601 -0
- roomkit_ui/widgets/settings/llm_library_page.py +526 -0
- roomkit_ui/widgets/settings/mcp_page.py +657 -0
- roomkit_ui/widgets/settings/model_combos.py +49 -0
- roomkit_ui/widgets/settings/model_jobs.py +97 -0
- roomkit_ui/widgets/settings/model_library.py +161 -0
- roomkit_ui/widgets/settings/models_page.py +149 -0
- roomkit_ui/widgets/settings/models_widgets.py +92 -0
- roomkit_ui/widgets/settings/navigation.py +61 -0
- roomkit_ui/widgets/settings/page_registry.py +548 -0
- roomkit_ui/widgets/settings/panel.py +345 -0
- roomkit_ui/widgets/settings/pipeline_summary.py +36 -0
- roomkit_ui/widgets/settings/privacy_page.py +149 -0
- roomkit_ui/widgets/settings/providers_page.py +281 -0
- roomkit_ui/widgets/settings/realtime_catalogs.py +199 -0
- roomkit_ui/widgets/settings/realtime_gemini_live.py +116 -0
- roomkit_ui/widgets/settings/realtime_gpt_live.py +111 -0
- roomkit_ui/widgets/settings/realtime_section.py +641 -0
- roomkit_ui/widgets/settings/recordings_page.py +409 -0
- roomkit_ui/widgets/settings/search.py +113 -0
- roomkit_ui/widgets/settings/skills/__init__.py +3 -0
- roomkit_ui/widgets/settings/skills/marketplace.py +209 -0
- roomkit_ui/widgets/settings/skills/my_skills.py +155 -0
- roomkit_ui/widgets/settings/skills/page.py +483 -0
- roomkit_ui/widgets/settings/skills/widgets.py +99 -0
- roomkit_ui/widgets/settings/speakers_dialogs.py +200 -0
- roomkit_ui/widgets/settings/speakers_muse.py +55 -0
- roomkit_ui/widgets/settings/speakers_page.py +367 -0
- roomkit_ui/widgets/settings/style.py +327 -0
- roomkit_ui/widgets/settings/telemetry_page.py +107 -0
- roomkit_ui/widgets/settings/text_chat_setup.py +52 -0
- roomkit_ui/widgets/settings/tool_search_section.py +96 -0
- roomkit_ui/widgets/settings/vc_agent_fields.py +210 -0
- roomkit_ui/widgets/settings/vc_azure_fields.py +67 -0
- roomkit_ui/widgets/settings/vc_cloud_fields.py +331 -0
- roomkit_ui/widgets/settings/vc_google_fields.py +106 -0
- roomkit_ui/widgets/settings/vc_local_fields.py +226 -0
- roomkit_ui/widgets/settings/vc_meta_fields.py +32 -0
- roomkit_ui/widgets/settings/voice_setup.py +330 -0
- roomkit_ui/widgets/settings/wakeword_page.py +252 -0
- roomkit_ui/widgets/skill_approval_dialog.py +40 -0
- roomkit_ui/widgets/sliding_panel.py +146 -0
- roomkit_ui/widgets/tool_activity.py +290 -0
- roomkit_ui/widgets/voice_name_dialog.py +28 -0
- roomkit_ui/widgets/vu_meter.py +103 -0
- roomkit_ui/widgets/wakeword_wizard.py +315 -0
- roomkit_ui-0.3.0.dist-info/METADATA +115 -0
- roomkit_ui-0.3.0.dist-info/RECORD +214 -0
- roomkit_ui-0.3.0.dist-info/WHEEL +4 -0
- roomkit_ui-0.3.0.dist-info/entry_points.txt +2 -0
- 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
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
|