symphony-code 0.0.1__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 (169) hide show
  1. coding_agent/__init__.py +20 -0
  2. coding_agent/_lazy.py +17 -0
  3. coding_agent/addons/__init__.py +35 -0
  4. coding_agent/addons/approvals.py +224 -0
  5. coding_agent/addons/compaction/__init__.py +36 -0
  6. coding_agent/addons/compaction/addon.py +88 -0
  7. coding_agent/addons/compaction/compactor.py +138 -0
  8. coding_agent/addons/compaction/prompts.py +20 -0
  9. coding_agent/addons/compaction/transcript.py +118 -0
  10. coding_agent/addons/evaluation/__init__.py +68 -0
  11. coding_agent/addons/evaluation/addon.py +199 -0
  12. coding_agent/addons/evaluation/evaluators.py +245 -0
  13. coding_agent/addons/evaluation/handlers.py +66 -0
  14. coding_agent/addons/evaluation/policy.py +277 -0
  15. coding_agent/addons/evaluation/protocol.py +192 -0
  16. coding_agent/addons/evaluation/state.py +284 -0
  17. coding_agent/addons/langfuse/__init__.py +24 -0
  18. coding_agent/addons/langfuse/addon.py +465 -0
  19. coding_agent/addons/langfuse/install.py +79 -0
  20. coding_agent/addons/langfuse/serialize.py +158 -0
  21. coding_agent/addons/learning/__init__.py +33 -0
  22. coding_agent/addons/learning/addon.py +181 -0
  23. coding_agent/addons/learning/loop.py +247 -0
  24. coding_agent/addons/learning/prompts.py +26 -0
  25. coding_agent/addons/learning/sanitize.py +64 -0
  26. coding_agent/addons/learning/store.py +522 -0
  27. coding_agent/addons/manager/__init__.py +18 -0
  28. coding_agent/addons/manager/bridge.py +74 -0
  29. coding_agent/addons/manager/discovery.py +34 -0
  30. coding_agent/addons/manager/report.py +33 -0
  31. coding_agent/addons/manager/runtime.py +76 -0
  32. coding_agent/addons/manager/tool_changes.py +16 -0
  33. coding_agent/addons/persistence/__init__.py +41 -0
  34. coding_agent/addons/persistence/active.py +132 -0
  35. coding_agent/addons/persistence/artifacts.py +110 -0
  36. coding_agent/addons/persistence/collection.py +166 -0
  37. coding_agent/addons/persistence/compatibility.py +30 -0
  38. coding_agent/addons/persistence/images.py +133 -0
  39. coding_agent/addons/persistence/jsonl.py +1195 -0
  40. coding_agent/addons/persistence/presentation.py +104 -0
  41. coding_agent/addons/plugins/__init__.py +19 -0
  42. coding_agent/addons/plugins/install.py +37 -0
  43. coding_agent/addons/plugins/manager.py +140 -0
  44. coding_agent/addons/plugins/models.py +50 -0
  45. coding_agent/addons/skills/__init__.py +28 -0
  46. coding_agent/addons/skills/addon.py +26 -0
  47. coding_agent/addons/skills/args.py +78 -0
  48. coding_agent/addons/skills/bundled_skills/addon-authoring/SKILL.md +77 -0
  49. coding_agent/addons/skills/bundled_skills/context-budget/SKILL.md +12 -0
  50. coding_agent/addons/skills/bundled_skills/orient-first/SKILL.md +16 -0
  51. coding_agent/addons/skills/bundled_skills/security-boundary/SKILL.md +16 -0
  52. coding_agent/addons/skills/bundled_skills/skill-discipline/SKILL.md +13 -0
  53. coding_agent/addons/skills/bundled_skills/surgical-edit/SKILL.md +11 -0
  54. coding_agent/addons/skills/bundled_skills/systematic-debug/SKILL.md +18 -0
  55. coding_agent/addons/skills/bundled_skills/verify-before-done/SKILL.md +11 -0
  56. coding_agent/addons/skills/frontmatter.py +57 -0
  57. coding_agent/addons/skills/models.py +57 -0
  58. coding_agent/addons/skills/registry.py +109 -0
  59. coding_agent/addons/skills/tool.py +54 -0
  60. coding_agent/addons/voice/__init__.py +5 -0
  61. coding_agent/addons/voice/addon.py +106 -0
  62. coding_agent/addons/voice/credentials.py +42 -0
  63. coding_agent/addons/voice/playback.py +42 -0
  64. coding_agent/addons/voice/speak.py +287 -0
  65. coding_agent/addons/voice/stream.py +651 -0
  66. coding_agent/addons/voice/summary.py +126 -0
  67. coding_agent/agent.py +523 -0
  68. coding_agent/bench/__init__.py +1 -0
  69. coding_agent/bench/cli.py +200 -0
  70. coding_agent/bench/patch.py +78 -0
  71. coding_agent/commands/__init__.py +5 -0
  72. coding_agent/commands/catalog.py +179 -0
  73. coding_agent/commands/jev_argument.py +40 -0
  74. coding_agent/config/__init__.py +51 -0
  75. coding_agent/config/config.py +376 -0
  76. coding_agent/config/credentials.py +153 -0
  77. coding_agent/config/personalities.json +34 -0
  78. coding_agent/config/personalities.py +170 -0
  79. coding_agent/config/prompts.py +67 -0
  80. coding_agent/protocols/__init__.py +5 -0
  81. coding_agent/protocols/acp/README.md +36 -0
  82. coding_agent/protocols/acp/__init__.py +27 -0
  83. coding_agent/protocols/acp/mapping.py +164 -0
  84. coding_agent/protocols/acp/server.py +261 -0
  85. coding_agent/protocols/acp/transport.py +72 -0
  86. coding_agent/protocols/stdio/README.md +36 -0
  87. coding_agent/protocols/stdio/__init__.py +37 -0
  88. coding_agent/protocols/stdio/catalog.py +49 -0
  89. coding_agent/protocols/stdio/commands.py +218 -0
  90. coding_agent/protocols/stdio/server.py +181 -0
  91. coding_agent/protocols/stdio/transport.py +71 -0
  92. coding_agent/py.typed +0 -0
  93. coding_agent/resources/__init__.py +5 -0
  94. coding_agent/resources/usage.py +264 -0
  95. coding_agent/run/__init__.py +1 -0
  96. coding_agent/run/cli.py +99 -0
  97. coding_agent/run/detach.py +37 -0
  98. coding_agent/run/interrupted.py +67 -0
  99. coding_agent/run/log_sink.py +46 -0
  100. coding_agent/tools/__init__.py +73 -0
  101. coding_agent/tools/ask_user.py +61 -0
  102. coding_agent/tools/base.py +136 -0
  103. coding_agent/tools/bash.py +288 -0
  104. coding_agent/tools/bash_jobs.py +116 -0
  105. coding_agent/tools/generate_image.py +123 -0
  106. coding_agent/tools/memory.py +100 -0
  107. coding_agent/tools/patch.py +218 -0
  108. coding_agent/tools/read_file.py +167 -0
  109. coding_agent/tools/search.py +433 -0
  110. coding_agent/tools/web_search.py +151 -0
  111. coding_agent/tools/write_file.py +40 -0
  112. coding_agent/tui/EFFICIENCY_PLAN.md +25 -0
  113. coding_agent/tui/__init__.py +22 -0
  114. coding_agent/tui/__main__.py +122 -0
  115. coding_agent/tui/app.py +456 -0
  116. coding_agent/tui/chrome/__init__.py +33 -0
  117. coding_agent/tui/chrome/footer.py +274 -0
  118. coding_agent/tui/chrome/topbar.py +259 -0
  119. coding_agent/tui/commands/__init__.py +45 -0
  120. coding_agent/tui/commands/catalog.py +35 -0
  121. coding_agent/tui/commands/manager.py +557 -0
  122. coding_agent/tui/composer/__init__.py +18 -0
  123. coding_agent/tui/composer/input.py +219 -0
  124. coding_agent/tui/composer/slash_menu.py +341 -0
  125. coding_agent/tui/composer/surface.py +330 -0
  126. coding_agent/tui/composer/voice.py +267 -0
  127. coding_agent/tui/composer/voice_mode.py +491 -0
  128. coding_agent/tui/runtime/__init__.py +23 -0
  129. coding_agent/tui/runtime/events.py +728 -0
  130. coding_agent/tui/runtime/sink.py +115 -0
  131. coding_agent/tui/runtime/state.py +145 -0
  132. coding_agent/tui/runtime/subagent.py +531 -0
  133. coding_agent/tui/runtime/turn.py +206 -0
  134. coding_agent/tui/screens/__init__.py +54 -0
  135. coding_agent/tui/screens/ask.py +69 -0
  136. coding_agent/tui/screens/context.py +195 -0
  137. coding_agent/tui/screens/dashboard.py +91 -0
  138. coding_agent/tui/screens/diff.py +302 -0
  139. coding_agent/tui/screens/extensions.py +68 -0
  140. coding_agent/tui/screens/file_selector.py +167 -0
  141. coding_agent/tui/screens/history.py +458 -0
  142. coding_agent/tui/screens/langfuse.py +142 -0
  143. coding_agent/tui/screens/learning.py +113 -0
  144. coding_agent/tui/screens/modal.py +131 -0
  145. coding_agent/tui/screens/onboard.py +455 -0
  146. coding_agent/tui/screens/resume.py +163 -0
  147. coding_agent/tui/theme/__init__.py +49 -0
  148. coding_agent/tui/theme/load.py +325 -0
  149. coding_agent/tui/theme/style.py +95 -0
  150. coding_agent/tui/theme/theme.toml +1687 -0
  151. coding_agent/tui/tools/__init__.py +53 -0
  152. coding_agent/tui/tools/activity.py +184 -0
  153. coding_agent/tui/tools/calls.py +560 -0
  154. coding_agent/tui/tools/code_view.py +52 -0
  155. coding_agent/tui/tools/images.py +250 -0
  156. coding_agent/tui/tools/labels.py +135 -0
  157. coding_agent/tui/tools/modal.py +137 -0
  158. coding_agent/tui/tools/partial_args.py +19 -0
  159. coding_agent/tui/tools/snapshots.py +382 -0
  160. coding_agent/tui/transcript/__init__.py +36 -0
  161. coding_agent/tui/transcript/messages.py +408 -0
  162. coding_agent/tui/transcript/process.py +504 -0
  163. coding_agent/tui/transcript/surface.py +345 -0
  164. coding_agent/tui/transcript/thinking_markdown.py +314 -0
  165. symphony_code-0.0.1.dist-info/METADATA +66 -0
  166. symphony_code-0.0.1.dist-info/RECORD +169 -0
  167. symphony_code-0.0.1.dist-info/WHEEL +4 -0
  168. symphony_code-0.0.1.dist-info/entry_points.txt +5 -0
  169. symphony_code-0.0.1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,330 @@
1
+ """Slash-menu and composer-hint flow mixed into CodingAgentApp."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from textual import events
6
+ from textual.widgets import Button, OptionList, TextArea
7
+ from textual.worker import WorkerState, get_current_worker
8
+
9
+ from coding_agent.config.credentials import OFFLINE_HINT
10
+ from coding_agent.tui.chrome.footer import DEFAULT_HINT, ComposerOverlay
11
+ from coding_agent.tui.commands import (
12
+ command_matches,
13
+ effort_matches,
14
+ effort_options_for_model,
15
+ model_matches,
16
+ model_supports_effort,
17
+ personality_matches,
18
+ )
19
+ from coding_agent.tui.composer.input import PromptInput
20
+ from coding_agent.tui.composer.input import QueuedTurn
21
+ from coding_agent.tui.composer.slash_menu import SlashMenu
22
+ from coding_agent.tui.screens.file_selector import (
23
+ active_file_mention,
24
+ complete_file_mention,
25
+ )
26
+ from coding_agent.tui.tools.images import build_user_content
27
+ from coding_agent.tui.transcript import UserMessage
28
+
29
+
30
+ class ComposerSurface:
31
+ """Composer submit, slash-menu navigation, and hint updates."""
32
+
33
+ async def on_prompt_input_submitted(self, event: PromptInput.Submitted) -> None:
34
+ text = event.input.expanded_value(event.input.text).strip()
35
+ pasted_chunks = event.input.take_pasted_chunks()
36
+ event.input.load_text("")
37
+ if self._pending_question_id is not None:
38
+ images = event.input.take_images()
39
+ approval_menu = self.query_one("#approval-menu", SlashMenu)
40
+ if approval_menu.display and approval_menu.selected_value:
41
+ await self._answer_question(approval_menu.selected_value)
42
+ if text:
43
+ user_content = build_user_content(text, images)
44
+ turn = QueuedTurn(user_content, text, pasted_chunks, images)
45
+ if self._busy:
46
+ self.queue_turn(turn)
47
+ else:
48
+ self._start_turn(turn)
49
+ return
50
+ await self._answer_question(self._submitted_question_answer(text))
51
+ return
52
+ if not text:
53
+ event.input.take_images()
54
+ return
55
+ self.query_one("#slash-menu", SlashMenu).set_commands(())
56
+ if text.startswith("/"):
57
+ event.input.take_images()
58
+ await self._run_slash_command(text)
59
+ return
60
+ images = event.input.take_images()
61
+ if self._agent is None:
62
+ self.add_notice(OFFLINE_HINT, "error")
63
+ return
64
+ user_content = build_user_content(text, images)
65
+ if self._busy:
66
+ # Do not disable the editor while the harness is working: a follow-up
67
+ # can be prepared and submitted, then is dispatched FIFO afterward.
68
+ self.queue_turn(QueuedTurn(user_content, text, pasted_chunks, images))
69
+ return
70
+
71
+ self._start_turn(QueuedTurn(user_content, text, pasted_chunks, images))
72
+
73
+ def _start_turn(self, turn: QueuedTurn) -> None:
74
+ self._run_generation += 1
75
+ self.sink.reset_cancel()
76
+ self._assistant = None
77
+ self._thinking = None
78
+ self._reasoning = None
79
+ self._process = None
80
+ self._tools = {}
81
+ self._tool_groups = {}
82
+ self._open_tool_group = None
83
+ self._mount_transcript(
84
+ UserMessage(turn.text, pasted_chunks=turn.pasted_chunks, images=turn.images)
85
+ )
86
+ self.set_thinking("Thinking…")
87
+ self._busy = True
88
+ self._set_status("")
89
+ # The harness reads this once, on the run that is about to start.
90
+ agent = getattr(self, "_agent", None)
91
+ harness = getattr(agent, "harness", None)
92
+ if harness is not None:
93
+ harness.reply_aloud = bool(turn.voice)
94
+ # Let the submitted message paint before checkpoint and prompt setup.
95
+ self.call_after_refresh(self.run_agent, turn.content)
96
+
97
+ def on_button_pressed(self, event: Button.Pressed) -> None:
98
+ if event.button.id not in {"queued-send-now", "queued-edit"} or not self._queued_turns:
99
+ return
100
+ prompt = self.query_one("#prompt", PromptInput)
101
+ if event.button.id == "queued-edit":
102
+ prompt.load_text(self.pop_queued_turn().text)
103
+ prompt.submit_on_enter = True
104
+ prompt.disabled = False
105
+ prompt.focus()
106
+ return
107
+ if not self._busy:
108
+ self._start_turn(self.pop_queued_turn())
109
+ return
110
+ # Interrupt the in-flight run and leave the prompt queued. The
111
+ # cancelled worker starts it from ``finally`` only after the previous
112
+ # harness call has actually returned, so two runs cannot overlap.
113
+ self._pending_question_id = None
114
+ self._pending_question_default = ""
115
+ self._pending_question_agent_id = ""
116
+ self._cancel_active_run("queued_send_now")
117
+
118
+ def _composer_menu(self) -> SlashMenu:
119
+ approval_menu = self.query_one("#approval-menu", SlashMenu)
120
+ return (
121
+ approval_menu
122
+ if approval_menu.display
123
+ else self.query_one("#slash-menu", SlashMenu)
124
+ )
125
+
126
+ def _show_file_matches(self, menu: SlashMenu, query: str) -> None:
127
+ """Filter the last finished walk now; walk again in a worker if stale.
128
+
129
+ The first ``@`` of a session has nothing to filter yet, so the menu
130
+ fills when that walk lands (``_file_index_ready``).
131
+ """
132
+ cached = self._file_index.cached_matches(query)
133
+ if cached is not None:
134
+ menu.set_files(cached)
135
+ if self._file_index.fresh:
136
+ return
137
+ walking = any(
138
+ worker.group == "file-index"
139
+ and worker.state in {WorkerState.PENDING, WorkerState.RUNNING}
140
+ for worker in self.workers
141
+ )
142
+ if not walking:
143
+ self.run_worker(
144
+ self._walk_workspace, thread=True, group="file-index", exit_on_error=False
145
+ )
146
+
147
+ def _walk_workspace(self) -> None:
148
+ """Worker thread: rebuild the ``@`` index, then refresh the menu."""
149
+ self._file_index.rebuild()
150
+ if not get_current_worker().is_cancelled:
151
+ self.call_from_thread(self._file_index_ready)
152
+
153
+ def _file_index_ready(self) -> None:
154
+ if self._pending_question_id is not None:
155
+ return
156
+ prompt = self.query_one("#prompt", PromptInput)
157
+ mention = active_file_mention(prompt.text)
158
+ if mention is not None:
159
+ _start, query = mention
160
+ self._show_file_matches(self._composer_menu(), query)
161
+
162
+ def on_text_area_changed(self, event: TextArea.Changed) -> None:
163
+ if event.text_area.id != "prompt":
164
+ return
165
+ if self._pending_question_id is not None:
166
+ return
167
+ text = event.text_area.text
168
+ if text and not text.startswith("/") and "@" not in text:
169
+ return
170
+ menu = self._composer_menu()
171
+ mention = active_file_mention(event.text_area.text)
172
+ if mention is not None:
173
+ _start, query = mention
174
+ self._show_file_matches(menu, query)
175
+ elif event.text_area.text.startswith("/model "):
176
+ current = self._agent.harness.model_id if self._agent is not None else ""
177
+ menu.set_models(
178
+ model_matches(
179
+ event.text_area.text.removeprefix("/model "),
180
+ self._model_options,
181
+ ),
182
+ current,
183
+ )
184
+ elif event.text_area.text.startswith("/personality "):
185
+ current = getattr(getattr(self, "config", None), "personality", None) or ""
186
+ menu.set_personalities(
187
+ personality_matches(event.text_area.text.removeprefix("/personality ")),
188
+ current,
189
+ )
190
+ elif event.text_area.text.startswith("/effort "):
191
+ current = "default"
192
+ efforts = ()
193
+ if self._agent is not None:
194
+ current = self._agent.harness.reasoning_effort or "default"
195
+ efforts = effort_options_for_model(self._agent.harness.model_id)
196
+ menu.set_efforts(
197
+ effort_matches(
198
+ event.text_area.text.removeprefix("/effort "),
199
+ efforts,
200
+ ),
201
+ current,
202
+ )
203
+ else:
204
+ model_id = getattr(getattr(self._agent, "harness", None), "model_id", "")
205
+ try:
206
+ addon_commands = self._agent.user_addons.surface.slash_commands
207
+ except AttributeError: # no agent yet, or a host agent without user addons
208
+ addon_commands = ()
209
+ menu.set_commands(
210
+ command_matches(
211
+ event.text_area.text,
212
+ include_effort=model_supports_effort(model_id),
213
+ extra=addon_commands,
214
+ )
215
+ )
216
+
217
+ def on_key(self, event: events.Key) -> None:
218
+ """Navigate, choose, or complete the visible slash menu."""
219
+ prompt = self.query_one("#prompt", PromptInput)
220
+ if not prompt.has_focus:
221
+ return
222
+ if event.key in {"ctrl+enter", "control+enter"}:
223
+ prompt.action_submit()
224
+ event.prevent_default()
225
+ event.stop()
226
+ return
227
+ approval_menu = self.query_one("#approval-menu", SlashMenu)
228
+ menu = (
229
+ approval_menu
230
+ if approval_menu.display
231
+ else self.query_one("#slash-menu", SlashMenu)
232
+ )
233
+ if not menu.display:
234
+ return
235
+
236
+ if event.key in {"up", "down"}:
237
+ menu.move_selection(-1 if event.key == "up" else 1)
238
+ event.prevent_default()
239
+ event.stop()
240
+ return
241
+ if event.key in {"tab", "enter"} and menu.selected_value:
242
+ self._choose_menu_option(menu, submit=event.key == "enter")
243
+ event.prevent_default()
244
+ event.stop()
245
+ elif event.key == "enter" and self._pending_question_id is not None:
246
+ self.call_later(prompt.action_submit)
247
+ event.prevent_default()
248
+ event.stop()
249
+
250
+ def on_option_list_option_selected(
251
+ self, event: OptionList.OptionSelected
252
+ ) -> None:
253
+ """Apply menu choices selected with the pointer."""
254
+ if event.option_list.id not in {"slash-menu", "approval-menu"}:
255
+ return
256
+ menu = event.option_list
257
+ assert isinstance(menu, SlashMenu)
258
+ if menu.select_option_index(event.option_index):
259
+ self._choose_menu_option(menu, submit=True)
260
+ event.stop()
261
+
262
+ def _submitted_question_answer(self, text: str) -> str:
263
+ """Prefer the visible approval highlight over empty prompt text / default."""
264
+ approval_menu = self.query_one("#approval-menu", SlashMenu)
265
+ if approval_menu.display and approval_menu.selected_value:
266
+ return approval_menu.selected_value
267
+ return text or self._pending_question_default
268
+
269
+ def _choose_menu_option(self, menu: SlashMenu, *, submit: bool) -> None:
270
+ prompt = self.query_one("#prompt", PromptInput)
271
+ if self._pending_question_id is not None and submit:
272
+ answer = menu.selected_value
273
+ menu.set_commands(())
274
+ self.call_later(self._answer_question, answer)
275
+ prompt.focus()
276
+ return
277
+ if menu.is_file_selector:
278
+ prompt.value, cursor = complete_file_mention(
279
+ prompt.value,
280
+ menu.selected_value.removeprefix("@"),
281
+ )
282
+ prompt.cursor_position = cursor
283
+ menu.set_commands(())
284
+ else:
285
+ prompt.value = menu.selected_value
286
+ prompt.cursor_position = len(prompt.value)
287
+ if submit:
288
+ menu.set_commands(())
289
+ self.call_later(prompt.action_submit)
290
+ prompt.focus()
291
+
292
+ async def _run_slash_command(self, value: str) -> None:
293
+ command, _, argument = value[1:].partition(" ")
294
+ if command.lower() == "addon":
295
+ if self._busy:
296
+ self.add_notice("/addon is unavailable while a turn is running.", "warning")
297
+ return
298
+ if self._agent is None:
299
+ self.add_notice(OFFLINE_HINT, "error")
300
+ return
301
+ skill_tool = self._agent.harness.tools.get("skill")
302
+ skill_instructions = (
303
+ await skill_tool.execute(sink=None, args={"skill_id": "bundled/addon-authoring"})
304
+ if skill_tool is not None
305
+ else "Bundled addon-authoring skill is unavailable; inspect core_harness.extensions and "
306
+ "the installed addon documentation before proceeding."
307
+ )
308
+ prompt = (
309
+ "Follow these Symphony addon-authoring instructions, then create or improve a "
310
+ "user addon for this request. Use the supported addon API and user addon "
311
+ "directory documented here. Do not modify Symphony's built-in source unless "
312
+ "I explicitly ask.\n\n"
313
+ f"{skill_instructions}"
314
+ )
315
+ if argument.strip():
316
+ prompt += f"\n\nAddon request: {argument.strip()}"
317
+ self._start_turn(QueuedTurn(prompt, prompt, (), ()))
318
+ return
319
+ await self._command_manager.run(value)
320
+
321
+ def add_update(self, text: str, hint: str = "") -> None:
322
+ """Show a banner above the composer. Only the app has one; composer-less
323
+ views such as the child transcript leave this undefined, so the event
324
+ presenter falls back to a transcript notice."""
325
+ overlay = self.query_one("#composer-overlay", ComposerOverlay)
326
+ overlay.show(text, hint or DEFAULT_HINT)
327
+
328
+ def _update_composer_hint(self) -> None:
329
+ """Refresh the footer keyboard hint for the current state."""
330
+ self._set_status("")
@@ -0,0 +1,267 @@
1
+ """Voice capture and xAI transcription for the TUI composer.
2
+
3
+ Credentials, the spoken-summary split, and MP3 playback belong to the voice
4
+ add-on (``coding_agent.addons.voice``); this module imports them from there.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import threading
10
+ from typing import Any, Protocol
11
+
12
+ from coding_agent.addons.voice.credentials import (
13
+ XAI_AUDIO_BASE_URL,
14
+ UnavailableVoice,
15
+ grok_voice_credentials,
16
+ )
17
+
18
+ # Speech-to-text model. The realtime alias is not accepted by POST /v1/stt.
19
+ # Transcribe 2.0 is the STT model xAI documents as the default. The chat
20
+ # model is never switched.
21
+ GROK_VOICE_MODEL = "grok-voice-transcribe-2.0"
22
+ GROK_VOICE_FULL_ID = f"grok:{GROK_VOICE_MODEL}"
23
+
24
+ # Alternate the composer border every half second.
25
+ VOICE_BLINK_INTERVAL_S = 0.5
26
+ VOICE_LIGHT_PURPLE = "#D7C4FB"
27
+ VOICE_DARK_PURPLE = "#A371F7"
28
+
29
+
30
+ class VoiceTranscriber(Protocol):
31
+ """Turns one captured utterance into text. Tests inject a fake."""
32
+
33
+ def transcribe(self, audio: bytes, *, model: str) -> str: ...
34
+
35
+
36
+ class VoiceCapture(Protocol):
37
+ """Records one utterance. ``None`` means the microphone stopped."""
38
+
39
+ def listen(self) -> bytes | None: ...
40
+
41
+ def close(self) -> None:
42
+ """Release the microphone; a blocked ``listen`` returns ``None`` soon after."""
43
+
44
+
45
+ def transcript_text(body: dict[str, Any]) -> str:
46
+ """Read a transcript from either the audio API or a chat completion."""
47
+ text = body.get("text")
48
+ if isinstance(text, str) and text.strip():
49
+ return text.strip()
50
+ choices = body.get("choices") or []
51
+ if not choices:
52
+ return ""
53
+ message = choices[0].get("message") or {}
54
+ content = message.get("content") or ""
55
+ return content.strip() if isinstance(content, str) else ""
56
+
57
+
58
+ class GrokVoiceTranscriber:
59
+ """Transcribe one utterance with grok-voice-transcribe-2.0.
60
+
61
+ The WAV is posted once to ``/stt``. Nothing stays open between utterances.
62
+ Auth is the Grok OAuth session when there is no API key.
63
+ """
64
+
65
+ def transcribe(self, audio: bytes, *, model: str = GROK_VOICE_MODEL) -> str:
66
+ if not audio:
67
+ return ""
68
+ api_key, extra_headers = grok_voice_credentials()
69
+ try:
70
+ import httpx
71
+ except ImportError as exc: # pragma: no cover - httpx is a core dependency
72
+ raise UnavailableVoice("Voice mode needs httpx.") from exc
73
+
74
+ headers = {"Authorization": f"Bearer {api_key}", **extra_headers}
75
+ # Model fields must precede the file. xAI ignores fields that follow it.
76
+ response = httpx.post(
77
+ f"{XAI_AUDIO_BASE_URL}/stt",
78
+ headers=headers,
79
+ data={"model": model, "language": "en"},
80
+ files={"file": ("utterance.wav", audio, "audio/wav")},
81
+ timeout=30.0,
82
+ )
83
+ try:
84
+ response.raise_for_status()
85
+ except httpx.HTTPStatusError as exc:
86
+ detail = exc.response.text.strip().splitlines()
87
+ suffix = f" · {detail[0][:180]}" if detail else ""
88
+ raise UnavailableVoice(
89
+ f"Voice transcription failed · {exc.response.status_code}{suffix}"
90
+ ) from exc
91
+ return transcript_text(response.json())
92
+
93
+
94
+ def _load_sounddevice() -> Any:
95
+ """Import PortAudio, installing the voice extra once if it is missing."""
96
+ try:
97
+ import sounddevice as sd
98
+ except ImportError:
99
+ _install_voice_extra()
100
+ try:
101
+ import sounddevice as sd
102
+ except ImportError as exc:
103
+ raise UnavailableVoice(
104
+ "Voice mode could not install the microphone package. "
105
+ "Run: uv sync --package symphony-code --extra voice"
106
+ ) from exc
107
+ except OSError as exc:
108
+ raise UnavailableVoice(_NO_PORTAUDIO) from exc
109
+ except OSError as exc:
110
+ # sounddevice imports, then cannot find libportaudio. pip cannot fix that.
111
+ raise UnavailableVoice(_NO_PORTAUDIO) from exc
112
+ return sd
113
+
114
+
115
+ _NO_PORTAUDIO = (
116
+ "Voice mode needs the PortAudio library. "
117
+ "Install it (brew install portaudio, or apt install libportaudio2)."
118
+ )
119
+
120
+
121
+ def _install_voice_extra() -> None:
122
+ """Install ``sounddevice`` into the interpreter running the TUI."""
123
+ import shutil
124
+ import subprocess
125
+ import sys
126
+
127
+ commands: list[list[str]] = []
128
+ uv = shutil.which("uv")
129
+ if uv:
130
+ commands.append([uv, "pip", "install", "sounddevice>=0.5.1", "--python", sys.executable])
131
+ commands.append([sys.executable, "-m", "pip", "install", "sounddevice>=0.5.1"])
132
+ for command in commands:
133
+ try:
134
+ completed = subprocess.run(
135
+ command,
136
+ check=False,
137
+ capture_output=True,
138
+ text=True,
139
+ timeout=120,
140
+ )
141
+ except (OSError, subprocess.TimeoutExpired):
142
+ continue
143
+ if completed.returncode == 0:
144
+ return
145
+
146
+
147
+ # 32 ms per callback (512 frames at 16 kHz): a finer end-of-speech check than
148
+ # the old 50 ms poll, ~31 wakeups a second, and not left to the host's choice.
149
+ CAPTURE_BLOCK_MS = 32
150
+ # int16 RMS. Normal speech sits well above this; a quiet room does not.
151
+ SPEECH_RMS = 500
152
+
153
+
154
+ def record_wav(
155
+ sd: Any,
156
+ *,
157
+ seconds: float = 8,
158
+ sample_rate: int = 16_000,
159
+ silence_seconds: float = 0.7,
160
+ stop: threading.Event | None = None,
161
+ ) -> bytes:
162
+ """Record mono 16-bit PCM until the speaker pauses, then wrap it in a WAV.
163
+
164
+ Recording ends after ``silence_seconds`` of quiet once speech has started;
165
+ ``seconds`` is only the safety cap. The PortAudio callback decides that
166
+ from the frames it has counted and sets an event, so this thread blocks on
167
+ the event instead of waking to poll. Setting ``stop`` ends it early.
168
+
169
+ ``RawInputStream`` keeps the samples in a ``bytearray``. ``sd.rec`` would
170
+ allocate a NumPy array and force that dependency on every user.
171
+ """
172
+ import io
173
+ import warnings
174
+ import wave
175
+
176
+ try:
177
+ with warnings.catch_warnings():
178
+ warnings.simplefilter("ignore", DeprecationWarning)
179
+ from audioop import rms # Removed in Python 3.13.
180
+ except ImportError:
181
+ rms = _pcm16_rms
182
+
183
+ ended = stop if stop is not None else threading.Event()
184
+ captured = bytearray()
185
+ heard_speech = False
186
+ quiet_frames = 0
187
+ silence_frames = int(silence_seconds * sample_rate)
188
+
189
+ def _on_audio(indata: Any, frames: int, time_info: Any, status: Any) -> None:
190
+ nonlocal heard_speech, quiet_frames
191
+ del time_info, status
192
+ if ended.is_set():
193
+ return
194
+ # PortAudio reuses ``indata``; this extend is the only copy.
195
+ captured.extend(indata)
196
+ if rms(indata, 2) >= SPEECH_RMS:
197
+ heard_speech = True
198
+ quiet_frames = 0
199
+ else:
200
+ quiet_frames += frames
201
+ if heard_speech and quiet_frames >= silence_frames:
202
+ ended.set()
203
+
204
+ try:
205
+ stream = sd.RawInputStream(
206
+ samplerate=sample_rate,
207
+ channels=1,
208
+ dtype="int16",
209
+ callback=_on_audio,
210
+ blocksize=sample_rate * CAPTURE_BLOCK_MS // 1000,
211
+ )
212
+ except Exception as exc: # PortAudio errors are not ImportError
213
+ raise UnavailableVoice(f"Microphone unavailable · {exc}") from exc
214
+ with stream:
215
+ ended.wait(seconds)
216
+ buffer = io.BytesIO()
217
+ with wave.open(buffer, "wb") as handle:
218
+ handle.setnchannels(1)
219
+ handle.setsampwidth(2)
220
+ handle.setframerate(sample_rate)
221
+ handle.writeframes(captured)
222
+ return buffer.getvalue()
223
+
224
+
225
+ def _pcm16_rms(pcm: Any, width: int) -> int:
226
+ """``audioop.rms`` for mono int16, for Pythons that no longer ship audioop."""
227
+ del width
228
+ samples = memoryview(pcm).cast("B").cast("h")
229
+ if not samples:
230
+ return 0
231
+ return int((sum(sample * sample for sample in samples) / len(samples)) ** 0.5)
232
+
233
+
234
+ class MicrophoneCapture:
235
+ """Record utterances from the default input device until ``close``.
236
+
237
+ Imported lazily so the TUI still starts on a machine without an audio
238
+ extra installed. The first listen installs ``sounddevice`` into this
239
+ interpreter; if that fails, the user gets the install command instead of
240
+ a crash. Recording itself does not need NumPy.
241
+ """
242
+
243
+ def __init__(self) -> None:
244
+ self._lock = threading.Lock()
245
+ self._closed = False
246
+ self._recording: threading.Event | None = None
247
+
248
+ def listen(self) -> bytes | None:
249
+ sd = _load_sounddevice()
250
+ stop = threading.Event()
251
+ with self._lock:
252
+ if self._closed:
253
+ return None
254
+ self._recording = stop
255
+ try:
256
+ audio = record_wav(sd, stop=stop)
257
+ finally:
258
+ with self._lock:
259
+ self._recording = None
260
+ return None if self._closed else audio
261
+
262
+ def close(self) -> None:
263
+ """Idempotent and non-blocking, so the UI thread may call it."""
264
+ with self._lock:
265
+ self._closed = True
266
+ if self._recording is not None:
267
+ self._recording.set()