voice-copilot 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 (79) hide show
  1. voice_copilot/__init__.py +3 -0
  2. voice_copilot/__main__.py +4 -0
  3. voice_copilot/adapters/__init__.py +5 -0
  4. voice_copilot/adapters/base.py +91 -0
  5. voice_copilot/adapters/claude_code.py +232 -0
  6. voice_copilot/adapters/codex.py +288 -0
  7. voice_copilot/audio/__init__.py +5 -0
  8. voice_copilot/audio/hub.py +63 -0
  9. voice_copilot/audio/mic.py +98 -0
  10. voice_copilot/audio/tts_driver.py +295 -0
  11. voice_copilot/cli.py +423 -0
  12. voice_copilot/commentator/__init__.py +3 -0
  13. voice_copilot/commentator/format.py +212 -0
  14. voice_copilot/commentator/importance.py +90 -0
  15. voice_copilot/commentator/pipeline.py +548 -0
  16. voice_copilot/commentator/prompts/__init__.py +55 -0
  17. voice_copilot/commentator/prompts/en.cli.md +25 -0
  18. voice_copilot/commentator/prompts/en.cli.summary.md +14 -0
  19. voice_copilot/commentator/prompts/en.md +23 -0
  20. voice_copilot/commentator/prompts/en.summary.md +14 -0
  21. voice_copilot/commentator/prompts/es.md +24 -0
  22. voice_copilot/commentator/prompts/fr.md +24 -0
  23. voice_copilot/commentator/prompts/ru.cli.md +17 -0
  24. voice_copilot/commentator/prompts/ru.cli.summary.md +17 -0
  25. voice_copilot/commentator/prompts/ru.md +23 -0
  26. voice_copilot/commentator/prompts/ru.summary.md +23 -0
  27. voice_copilot/commentator/prompts/uk.md +23 -0
  28. voice_copilot/core/__init__.py +4 -0
  29. voice_copilot/core/bus.py +41 -0
  30. voice_copilot/core/config.py +176 -0
  31. voice_copilot/core/events.py +61 -0
  32. voice_copilot/core/secrets.py +80 -0
  33. voice_copilot/dialog/__init__.py +3 -0
  34. voice_copilot/dialog/manager.py +148 -0
  35. voice_copilot/hotkeys.py +194 -0
  36. voice_copilot/providers/__init__.py +5 -0
  37. voice_copilot/providers/llm/__init__.py +11 -0
  38. voice_copilot/providers/llm/anthropic.py +53 -0
  39. voice_copilot/providers/llm/base.py +37 -0
  40. voice_copilot/providers/llm/copilot_cli.py +145 -0
  41. voice_copilot/providers/llm/github_copilot.py +134 -0
  42. voice_copilot/providers/llm/openai.py +76 -0
  43. voice_copilot/providers/llm/openai_compat.py +27 -0
  44. voice_copilot/providers/registry.py +43 -0
  45. voice_copilot/providers/stt/__init__.py +6 -0
  46. voice_copilot/providers/stt/base.py +55 -0
  47. voice_copilot/providers/stt/deepgram.py +12 -0
  48. voice_copilot/providers/stt/faster_whisper.py +75 -0
  49. voice_copilot/providers/stt/whisper_api.py +49 -0
  50. voice_copilot/providers/tts/__init__.py +8 -0
  51. voice_copilot/providers/tts/base.py +53 -0
  52. voice_copilot/providers/tts/edge.py +47 -0
  53. voice_copilot/providers/tts/elevenlabs.py +58 -0
  54. voice_copilot/providers/tts/openai.py +50 -0
  55. voice_copilot/providers/tts/piper.py +12 -0
  56. voice_copilot/providers/tts/silero.py +16 -0
  57. voice_copilot/proxy/__init__.py +3 -0
  58. voice_copilot/proxy/anthropic.py +152 -0
  59. voice_copilot/proxy/body_sniffer.py +96 -0
  60. voice_copilot/proxy/cli_catalog.py +43 -0
  61. voice_copilot/proxy/cli_catalog.yaml +47 -0
  62. voice_copilot/proxy/cli_shims.py +615 -0
  63. voice_copilot/proxy/ollama_native.py +126 -0
  64. voice_copilot/proxy/openai.py +169 -0
  65. voice_copilot/proxy/server.py +248 -0
  66. voice_copilot/proxy/session.py +202 -0
  67. voice_copilot/tray.py +66 -0
  68. voice_copilot/web/__init__.py +3 -0
  69. voice_copilot/web/demo.py +34 -0
  70. voice_copilot/web/server.py +384 -0
  71. voice_copilot/web/static/app.js +1585 -0
  72. voice_copilot/web/static/index.html +374 -0
  73. voice_copilot/web/static/style.css +783 -0
  74. voice_copilot/web/ws.py +193 -0
  75. voice_copilot-0.0.1.dist-info/METADATA +325 -0
  76. voice_copilot-0.0.1.dist-info/RECORD +79 -0
  77. voice_copilot-0.0.1.dist-info/WHEEL +4 -0
  78. voice_copilot-0.0.1.dist-info/entry_points.txt +2 -0
  79. voice_copilot-0.0.1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,3 @@
1
+ """voice-copilot — voice pair-programmer for LLM coding CLIs."""
2
+
3
+ __version__ = "0.0.1"
@@ -0,0 +1,4 @@
1
+ from voice_copilot.cli import app
2
+
3
+ if __name__ == "__main__":
4
+ app()
@@ -0,0 +1,5 @@
1
+ from voice_copilot.adapters.base import CLIAdapter, QuickAsideCapability
2
+ from voice_copilot.adapters.claude_code import ClaudeCodeAdapter
3
+ from voice_copilot.adapters.codex import CodexAdapter
4
+
5
+ __all__ = ["CLIAdapter", "ClaudeCodeAdapter", "CodexAdapter", "QuickAsideCapability"]
@@ -0,0 +1,91 @@
1
+ """CLI adapter interface.
2
+
3
+ An adapter spawns and talks to a target LLM coding CLI. Its job:
4
+ * normalise vendor events into `EventKind` and push them on the bus
5
+ * accept user messages (text) from the dialog manager and inject them
6
+ * support suspend/resume so the user can speak without the agent racing ahead
7
+
8
+ Adapters declare one of three `QuickAsideCapability` values so the dialog
9
+ manager knows how urgently a user message can be delivered.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import asyncio
15
+ import logging
16
+ from abc import ABC, abstractmethod
17
+ from enum import StrEnum
18
+
19
+ log = logging.getLogger(__name__)
20
+
21
+
22
+ class QuickAsideCapability(StrEnum):
23
+ NATIVE = "native"
24
+ """CLI has a built-in side-question channel (inject immediately mid-turn)."""
25
+ QUEUE = "queue"
26
+ """No side channel, but stdin stays open — message lands on next turn boundary."""
27
+ MANUAL = "manual"
28
+ """External process — fall back to clipboard + popup text."""
29
+
30
+
31
+ class CLIAdapter(ABC):
32
+ name: str = "unknown"
33
+ quick_aside: QuickAsideCapability = QuickAsideCapability.MANUAL
34
+
35
+ _proc: asyncio.subprocess.Process | None = None
36
+ _paused: bool = False
37
+
38
+ @abstractmethod
39
+ async def start(self, initial_prompt: str | None = None) -> None:
40
+ """Spawn the CLI subprocess and start reading its events into the bus."""
41
+ raise NotImplementedError
42
+
43
+ @abstractmethod
44
+ async def send_user_message(self, text: str, *, urgent: bool = False) -> None:
45
+ """Deliver a user message into the running session."""
46
+ raise NotImplementedError
47
+
48
+ @abstractmethod
49
+ async def stop(self) -> None:
50
+ """Close stdin and terminate the subprocess."""
51
+ raise NotImplementedError
52
+
53
+ async def pause(self) -> bool:
54
+ """Freeze the child process so the user can talk uninterrupted.
55
+
56
+ Returns True if the process was actually paused (False if already
57
+ paused, not started, or pause unsupported on this platform). Uses
58
+ psutil.suspend — works on Win/macOS/Linux without sending signals.
59
+ """
60
+ if self._proc is None or self._proc.returncode is not None:
61
+ return False
62
+ if self._paused:
63
+ return False
64
+ try:
65
+ import psutil
66
+
67
+ psutil.Process(self._proc.pid).suspend()
68
+ except Exception as e:
69
+ log.warning("pause failed: %s", e)
70
+ return False
71
+ self._paused = True
72
+ return True
73
+
74
+ async def resume(self) -> bool:
75
+ if self._proc is None or self._proc.returncode is not None:
76
+ return False
77
+ if not self._paused:
78
+ return False
79
+ try:
80
+ import psutil
81
+
82
+ psutil.Process(self._proc.pid).resume()
83
+ except Exception as e:
84
+ log.warning("resume failed: %s", e)
85
+ return False
86
+ self._paused = False
87
+ return True
88
+
89
+ @property
90
+ def is_paused(self) -> bool:
91
+ return self._paused
@@ -0,0 +1,232 @@
1
+ """Claude Code adapter.
2
+
3
+ Spawns `claude --output-format stream-json --input-format stream-json --verbose`
4
+ and bridges its NDJSON event stream onto the bus. User messages are written to
5
+ stdin as `{"type":"user","message":{"role":"user","content":"..."}}` — the CLI
6
+ picks them up at the next turn boundary (queue semantics).
7
+
8
+ Reference: https://code.claude.com/docs/en/agent-sdk/streaming-vs-single-mode
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import asyncio
14
+ import json
15
+ import logging
16
+ import os
17
+ import shutil
18
+ from typing import Any
19
+
20
+ from voice_copilot.adapters.base import CLIAdapter, QuickAsideCapability
21
+ from voice_copilot.core.bus import EventBus
22
+ from voice_copilot.core.events import Event, EventKind
23
+
24
+ log = logging.getLogger(__name__)
25
+
26
+ _EDIT_TOOLS = {"Edit", "Write", "MultiEdit", "NotebookEdit"}
27
+
28
+
29
+ class ClaudeCodeAdapter(CLIAdapter):
30
+ name = "claude"
31
+ quick_aside = QuickAsideCapability.QUEUE
32
+
33
+ def __init__(
34
+ self,
35
+ bus: EventBus,
36
+ binary: str = "claude",
37
+ extra_args: list[str] | None = None,
38
+ env: dict[str, str] | None = None,
39
+ suppress_llm_events: bool = False,
40
+ ) -> None:
41
+ self._bus = bus
42
+ self._binary = binary
43
+ self._extra_args = extra_args or []
44
+ self._env = env or {}
45
+ self._suppress_llm = suppress_llm_events
46
+ self._proc: asyncio.subprocess.Process | None = None
47
+ self._reader_task: asyncio.Task[None] | None = None
48
+ self._stderr_task: asyncio.Task[None] | None = None
49
+ self._tool_use_to_path: dict[str, str] = {}
50
+ self._session_id: str | None = None
51
+ self._stdin_lock = asyncio.Lock()
52
+
53
+ # ------------------------------------------------------------------ lifecycle
54
+
55
+ async def start(self, initial_prompt: str | None = None) -> None:
56
+ if shutil.which(self._binary) is None:
57
+ raise RuntimeError(
58
+ f"`{self._binary}` not found in PATH. "
59
+ f"Install Claude Code: https://claude.com/product/claude-code"
60
+ )
61
+ argv = [
62
+ self._binary,
63
+ "--output-format",
64
+ "stream-json",
65
+ "--input-format",
66
+ "stream-json",
67
+ "--verbose",
68
+ *self._extra_args,
69
+ ]
70
+ log.info("spawning %s", " ".join(argv))
71
+ merged_env = {**os.environ, **self._env} if self._env else None
72
+ self._proc = await asyncio.create_subprocess_exec(
73
+ *argv,
74
+ stdin=asyncio.subprocess.PIPE,
75
+ stdout=asyncio.subprocess.PIPE,
76
+ stderr=asyncio.subprocess.PIPE,
77
+ env=merged_env,
78
+ )
79
+ self._reader_task = asyncio.create_task(self._read_stdout(), name="claude.stdout")
80
+ self._stderr_task = asyncio.create_task(self._read_stderr(), name="claude.stderr")
81
+
82
+ if initial_prompt:
83
+ await self.send_user_message(initial_prompt)
84
+
85
+ async def stop(self) -> None:
86
+ for t in (self._reader_task, self._stderr_task):
87
+ if t is not None:
88
+ t.cancel()
89
+ if self._proc is not None:
90
+ try:
91
+ if self._proc.stdin is not None and not self._proc.stdin.is_closing():
92
+ self._proc.stdin.close()
93
+ except Exception:
94
+ pass
95
+ try:
96
+ await asyncio.wait_for(self._proc.wait(), timeout=5.0)
97
+ except TimeoutError:
98
+ self._proc.kill()
99
+ await self._proc.wait()
100
+ await self._emit(EventKind.SESSION_ENDED, {"session_id": self._session_id})
101
+
102
+ # ------------------------------------------------------------------ input
103
+
104
+ async def send_user_message(self, text: str, *, urgent: bool = False) -> None:
105
+ if self._proc is None or self._proc.stdin is None:
106
+ raise RuntimeError("Claude adapter not started")
107
+ frame = {"type": "user", "message": {"role": "user", "content": text}}
108
+ payload = (json.dumps(frame) + "\n").encode("utf-8")
109
+ async with self._stdin_lock:
110
+ self._proc.stdin.write(payload)
111
+ await self._proc.stdin.drain()
112
+ await self._emit(
113
+ EventKind.USER_MESSAGE,
114
+ {"text": text, "urgent": urgent, "delivery": "queued"},
115
+ )
116
+
117
+ # ------------------------------------------------------------------ readers
118
+
119
+ async def _read_stdout(self) -> None:
120
+ assert self._proc is not None and self._proc.stdout is not None
121
+ while True:
122
+ line = await self._proc.stdout.readline()
123
+ if not line:
124
+ break
125
+ try:
126
+ msg = json.loads(line.decode("utf-8"))
127
+ except json.JSONDecodeError:
128
+ log.warning("non-json stdout line: %r", line[:200])
129
+ continue
130
+ try:
131
+ await self._handle_message(msg)
132
+ except Exception:
133
+ log.exception("failed to handle claude event")
134
+
135
+ async def _read_stderr(self) -> None:
136
+ assert self._proc is not None and self._proc.stderr is not None
137
+ while True:
138
+ line = await self._proc.stderr.readline()
139
+ if not line:
140
+ break
141
+ log.info("claude stderr: %s", line.decode("utf-8", errors="replace").rstrip())
142
+
143
+ # ------------------------------------------------------------------ dispatch
144
+
145
+ async def _handle_message(self, msg: dict[str, Any]) -> None:
146
+ mtype = msg.get("type")
147
+
148
+ if mtype == "system":
149
+ if msg.get("subtype") == "init":
150
+ self._session_id = msg.get("session_id")
151
+ await self._emit(
152
+ EventKind.SESSION_STARTED,
153
+ {
154
+ "target": "claude",
155
+ "session_id": self._session_id,
156
+ "model": msg.get("model"),
157
+ "cwd": msg.get("cwd"),
158
+ "tools": msg.get("tools"),
159
+ },
160
+ )
161
+ return
162
+
163
+ if mtype == "assistant":
164
+ content = ((msg.get("message") or {}).get("content")) or []
165
+ if not isinstance(content, list):
166
+ return
167
+ for block in content:
168
+ if not isinstance(block, dict):
169
+ continue
170
+ btype = block.get("type")
171
+ if btype == "text":
172
+ if not self._suppress_llm:
173
+ await self._emit(EventKind.AGENT_TEXT, {"text": block.get("text", "")})
174
+ elif btype == "thinking":
175
+ if not self._suppress_llm:
176
+ await self._emit(
177
+ EventKind.AGENT_THINKING,
178
+ {"text": block.get("thinking", "")},
179
+ )
180
+ elif btype == "tool_use":
181
+ tid = block.get("id", "")
182
+ name = block.get("name", "")
183
+ inp = block.get("input") or {}
184
+ # Still record file paths locally so TOOL_CALL_FINISHED can
185
+ # emit FILE_EDITED — the proxy never sees tool results.
186
+ if name in _EDIT_TOOLS and isinstance(inp, dict) and "file_path" in inp:
187
+ self._tool_use_to_path[tid] = str(inp["file_path"])
188
+ if not self._suppress_llm:
189
+ await self._emit(
190
+ EventKind.TOOL_CALL_STARTED,
191
+ {"tool_use_id": tid, "tool": name, "input": inp},
192
+ )
193
+ return
194
+
195
+ if mtype == "user":
196
+ content = ((msg.get("message") or {}).get("content")) or []
197
+ if not isinstance(content, list):
198
+ return
199
+ for block in content:
200
+ if not isinstance(block, dict) or block.get("type") != "tool_result":
201
+ continue
202
+ tid = block.get("tool_use_id", "")
203
+ is_error = bool(block.get("is_error"))
204
+ body = block.get("content", "")
205
+ preview = body if isinstance(body, str) else json.dumps(body)
206
+ await self._emit(
207
+ EventKind.TOOL_CALL_FINISHED,
208
+ {"tool_use_id": tid, "is_error": is_error, "preview": preview[:400]},
209
+ )
210
+ if not is_error and tid in self._tool_use_to_path:
211
+ await self._emit(
212
+ EventKind.FILE_EDITED,
213
+ {"path": self._tool_use_to_path.pop(tid)},
214
+ )
215
+ return
216
+
217
+ if mtype == "result":
218
+ await self._emit(
219
+ EventKind.TURN_ENDED,
220
+ {
221
+ "subtype": msg.get("subtype"),
222
+ "duration_ms": msg.get("duration_ms"),
223
+ "total_cost_usd": msg.get("total_cost_usd"),
224
+ "usage": msg.get("usage"),
225
+ "is_error": bool(msg.get("is_error")),
226
+ "result": msg.get("result"),
227
+ },
228
+ )
229
+ return
230
+
231
+ async def _emit(self, kind: EventKind, payload: dict[str, Any]) -> None:
232
+ await self._bus.publish(Event(kind=kind, source="claude.adapter", payload=payload))
@@ -0,0 +1,288 @@
1
+ """Codex CLI adapter (`codex exec --json`).
2
+
3
+ Codex uses a `ThreadEvent` JSON stream:
4
+
5
+ * `thread.started` → SESSION_STARTED
6
+ * `turn.started` → TURN_STARTED
7
+ * `turn.completed` → TURN_ENDED
8
+ * `turn.failed` → TURN_ENDED (is_error)
9
+ * `item.started` → TOOL_CALL_STARTED (for command_execution / mcp_tool_call / web_search)
10
+ * `item.completed` → AGENT_TEXT / AGENT_THINKING / TOOL_CALL_FINISHED / FILE_EDITED
11
+ * `error` → ERROR
12
+
13
+ Reference: https://developers.openai.com/codex/noninteractive
14
+
15
+ Caveat — `codex exec` is one-shot: a single turn per process. True multi-turn
16
+ with queued user messages needs `codex proto` (proto stream JSON-RPC). Here
17
+ we wire the single-turn case correctly; the dialog manager (Э10) will
18
+ orchestrate follow-up turns by re-spawning with thread resumption.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import asyncio
24
+ import json
25
+ import logging
26
+ import os
27
+ import shutil
28
+ from typing import Any
29
+
30
+ from voice_copilot.adapters.base import CLIAdapter, QuickAsideCapability
31
+ from voice_copilot.core.bus import EventBus
32
+ from voice_copilot.core.events import Event, EventKind
33
+
34
+ log = logging.getLogger(__name__)
35
+
36
+
37
+ class CodexAdapter(CLIAdapter):
38
+ name = "codex"
39
+ quick_aside = QuickAsideCapability.QUEUE
40
+
41
+ #: Flags that make `codex exec` behave sensibly under a non-interactive wrapper.
42
+ #: The user can override or extend via `extra_args`.
43
+ DEFAULT_ARGS: tuple[str, ...] = ("--skip-git-repo-check",)
44
+
45
+ def __init__(
46
+ self,
47
+ bus: EventBus,
48
+ binary: str = "codex",
49
+ extra_args: list[str] | None = None,
50
+ sandbox: str | None = "workspace-write",
51
+ env: dict[str, str] | None = None,
52
+ suppress_llm_events: bool = False,
53
+ ) -> None:
54
+ self._bus = bus
55
+ self._binary = binary
56
+ extras = list(self.DEFAULT_ARGS)
57
+ if sandbox:
58
+ extras += ["--sandbox", sandbox]
59
+ if extra_args:
60
+ extras += extra_args
61
+ self._extra_args = extras
62
+ self._env = env or {}
63
+ self._suppress_llm = suppress_llm_events
64
+ self._proc: asyncio.subprocess.Process | None = None
65
+ self._reader_task: asyncio.Task[None] | None = None
66
+ self._stderr_task: asyncio.Task[None] | None = None
67
+ self._thread_id: str | None = None
68
+ self._pending: list[str] = []
69
+
70
+ async def start(self, initial_prompt: str | None = None) -> None:
71
+ if shutil.which(self._binary) is None:
72
+ raise RuntimeError(
73
+ f"`{self._binary}` not found in PATH. "
74
+ f"Install Codex CLI: https://github.com/openai/codex"
75
+ )
76
+ if not initial_prompt:
77
+ raise RuntimeError("codex exec needs an initial prompt; pass `-p '…'`.")
78
+
79
+ argv = [self._binary, "exec", "--json", *self._extra_args, initial_prompt]
80
+ log.info("spawning %s", " ".join(argv))
81
+ merged_env = {**os.environ, **self._env} if self._env else None
82
+ self._proc = await asyncio.create_subprocess_exec(
83
+ *argv,
84
+ stdin=asyncio.subprocess.PIPE,
85
+ stdout=asyncio.subprocess.PIPE,
86
+ stderr=asyncio.subprocess.PIPE,
87
+ env=merged_env,
88
+ )
89
+ self._reader_task = asyncio.create_task(self._read_stdout(), name="codex.stdout")
90
+ self._stderr_task = asyncio.create_task(self._read_stderr(), name="codex.stderr")
91
+
92
+ async def send_user_message(self, text: str, *, urgent: bool = False) -> None:
93
+ # `codex exec` doesn't accept follow-up messages. Queue for Э10 to pick up
94
+ # and spawn a new turn with thread resumption.
95
+ self._pending.append(text)
96
+ await self._emit(
97
+ EventKind.USER_MESSAGE,
98
+ {"text": text, "urgent": urgent, "delivery": "pending-next-turn"},
99
+ )
100
+ log.info("codex user message queued (%d pending)", len(self._pending))
101
+
102
+ async def stop(self) -> None:
103
+ for t in (self._reader_task, self._stderr_task):
104
+ if t is not None:
105
+ t.cancel()
106
+ if self._proc is not None:
107
+ try:
108
+ if self._proc.stdin is not None and not self._proc.stdin.is_closing():
109
+ self._proc.stdin.close()
110
+ except Exception:
111
+ pass
112
+ try:
113
+ await asyncio.wait_for(self._proc.wait(), timeout=5.0)
114
+ except TimeoutError:
115
+ self._proc.kill()
116
+ await self._proc.wait()
117
+ await self._emit(EventKind.SESSION_ENDED, {"thread_id": self._thread_id})
118
+
119
+ # ------------------------------------------------------------------ readers
120
+
121
+ async def _read_stdout(self) -> None:
122
+ assert self._proc is not None and self._proc.stdout is not None
123
+ while True:
124
+ line = await self._proc.stdout.readline()
125
+ if not line:
126
+ break
127
+ try:
128
+ msg = json.loads(line.decode("utf-8"))
129
+ except json.JSONDecodeError:
130
+ log.warning("non-json codex stdout: %r", line[:200])
131
+ continue
132
+ try:
133
+ await self._handle_event(msg)
134
+ except Exception:
135
+ log.exception("failed to handle codex event")
136
+
137
+ async def _read_stderr(self) -> None:
138
+ assert self._proc is not None and self._proc.stderr is not None
139
+ while True:
140
+ line = await self._proc.stderr.readline()
141
+ if not line:
142
+ break
143
+ log.info("codex stderr: %s", line.decode("utf-8", errors="replace").rstrip())
144
+
145
+ # ------------------------------------------------------------------ dispatch
146
+
147
+ async def _handle_event(self, msg: dict[str, Any]) -> None:
148
+ etype = msg.get("type")
149
+
150
+ if etype == "thread.started":
151
+ self._thread_id = msg.get("thread_id")
152
+ await self._emit(
153
+ EventKind.SESSION_STARTED,
154
+ {"target": "codex", "thread_id": self._thread_id},
155
+ )
156
+ return
157
+
158
+ if etype == "turn.started":
159
+ await self._emit(EventKind.TURN_STARTED, {"thread_id": self._thread_id})
160
+ return
161
+
162
+ if etype in ("turn.completed", "turn.failed"):
163
+ usage = msg.get("usage") or {}
164
+ await self._emit(
165
+ EventKind.TURN_ENDED,
166
+ {
167
+ "is_error": etype == "turn.failed",
168
+ "input_tokens": usage.get("input_tokens"),
169
+ "output_tokens": usage.get("output_tokens"),
170
+ "cached_input_tokens": usage.get("cached_input_tokens"),
171
+ },
172
+ )
173
+ return
174
+
175
+ if etype == "item.started":
176
+ await self._on_item(msg, phase="started")
177
+ return
178
+ if etype == "item.updated":
179
+ # Most items only get a single completion event; skip updates to avoid
180
+ # spamming the commentator. Dialog manager can opt-in later if needed.
181
+ return
182
+ if etype == "item.completed":
183
+ await self._on_item(msg, phase="completed")
184
+ return
185
+
186
+ if etype == "error":
187
+ await self._emit(EventKind.ERROR, {"message": msg.get("message"), "raw": msg})
188
+ return
189
+
190
+ async def _on_item(self, msg: dict[str, Any], *, phase: str) -> None:
191
+ item = msg.get("item") or {}
192
+ itype = item.get("item_type") or item.get("type")
193
+ item_id = item.get("id") or item.get("item_id")
194
+
195
+ if itype == "agent_message" and phase == "completed":
196
+ if not self._suppress_llm:
197
+ await self._emit(EventKind.AGENT_TEXT, {"text": item.get("text", "")})
198
+ return
199
+
200
+ if itype == "reasoning" and phase == "completed":
201
+ if not self._suppress_llm:
202
+ await self._emit(
203
+ EventKind.AGENT_THINKING,
204
+ {"text": item.get("summary") or item.get("text", "")},
205
+ )
206
+ return
207
+
208
+ if itype == "command_execution":
209
+ if phase == "started":
210
+ if not self._suppress_llm:
211
+ await self._emit(
212
+ EventKind.TOOL_CALL_STARTED,
213
+ {
214
+ "tool_use_id": item_id,
215
+ "tool": "Bash",
216
+ "input": {"cmd": item.get("command")},
217
+ },
218
+ )
219
+ else:
220
+ await self._emit(
221
+ EventKind.TOOL_CALL_FINISHED,
222
+ {
223
+ "tool_use_id": item_id,
224
+ "tool": "Bash",
225
+ "is_error": (item.get("exit_code") or 0) != 0,
226
+ "exit_code": item.get("exit_code"),
227
+ "preview": (item.get("aggregated_output") or "")[:400],
228
+ },
229
+ )
230
+ return
231
+
232
+ if itype == "file_change" and phase == "completed":
233
+ for change in item.get("changes") or []:
234
+ path = change.get("path") or change.get("file_path")
235
+ if path:
236
+ await self._emit(EventKind.FILE_EDITED, {"path": path})
237
+ return
238
+
239
+ if itype in ("mcp_tool_call", "collab_tool_call"):
240
+ if phase == "started":
241
+ if not self._suppress_llm:
242
+ await self._emit(
243
+ EventKind.TOOL_CALL_STARTED,
244
+ {
245
+ "tool_use_id": item_id,
246
+ "tool": item.get("tool") or itype,
247
+ "server": item.get("server"),
248
+ "input": item.get("arguments"),
249
+ },
250
+ )
251
+ else:
252
+ await self._emit(
253
+ EventKind.TOOL_CALL_FINISHED,
254
+ {
255
+ "tool_use_id": item_id,
256
+ "tool": item.get("tool") or itype,
257
+ "is_error": bool(item.get("error")),
258
+ "preview": (str(item.get("result") or "")[:400]),
259
+ },
260
+ )
261
+ return
262
+
263
+ if itype == "web_search":
264
+ if phase == "started":
265
+ if not self._suppress_llm:
266
+ await self._emit(
267
+ EventKind.TOOL_CALL_STARTED,
268
+ {
269
+ "tool_use_id": item_id,
270
+ "tool": "WebSearch",
271
+ "input": {"query": item.get("query")},
272
+ },
273
+ )
274
+ else:
275
+ await self._emit(
276
+ EventKind.TOOL_CALL_FINISHED,
277
+ {"tool_use_id": item_id, "tool": "WebSearch"},
278
+ )
279
+ return
280
+
281
+ if itype == "error":
282
+ await self._emit(EventKind.ERROR, {"message": item.get("message"), "raw": item})
283
+ return
284
+
285
+ # todo_list and unknown items — fall through silently.
286
+
287
+ async def _emit(self, kind: EventKind, payload: dict[str, Any]) -> None:
288
+ await self._bus.publish(Event(kind=kind, source="codex.adapter", payload=payload))
@@ -0,0 +1,5 @@
1
+ from voice_copilot.audio.hub import AudioHub
2
+ from voice_copilot.audio.mic import MicSession, transcribe_and_publish
3
+ from voice_copilot.audio.tts_driver import TTSDriver
4
+
5
+ __all__ = ["AudioHub", "MicSession", "TTSDriver", "transcribe_and_publish"]