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.
- voice_copilot/__init__.py +3 -0
- voice_copilot/__main__.py +4 -0
- voice_copilot/adapters/__init__.py +5 -0
- voice_copilot/adapters/base.py +91 -0
- voice_copilot/adapters/claude_code.py +232 -0
- voice_copilot/adapters/codex.py +288 -0
- voice_copilot/audio/__init__.py +5 -0
- voice_copilot/audio/hub.py +63 -0
- voice_copilot/audio/mic.py +98 -0
- voice_copilot/audio/tts_driver.py +295 -0
- voice_copilot/cli.py +423 -0
- voice_copilot/commentator/__init__.py +3 -0
- voice_copilot/commentator/format.py +212 -0
- voice_copilot/commentator/importance.py +90 -0
- voice_copilot/commentator/pipeline.py +548 -0
- voice_copilot/commentator/prompts/__init__.py +55 -0
- voice_copilot/commentator/prompts/en.cli.md +25 -0
- voice_copilot/commentator/prompts/en.cli.summary.md +14 -0
- voice_copilot/commentator/prompts/en.md +23 -0
- voice_copilot/commentator/prompts/en.summary.md +14 -0
- voice_copilot/commentator/prompts/es.md +24 -0
- voice_copilot/commentator/prompts/fr.md +24 -0
- voice_copilot/commentator/prompts/ru.cli.md +17 -0
- voice_copilot/commentator/prompts/ru.cli.summary.md +17 -0
- voice_copilot/commentator/prompts/ru.md +23 -0
- voice_copilot/commentator/prompts/ru.summary.md +23 -0
- voice_copilot/commentator/prompts/uk.md +23 -0
- voice_copilot/core/__init__.py +4 -0
- voice_copilot/core/bus.py +41 -0
- voice_copilot/core/config.py +176 -0
- voice_copilot/core/events.py +61 -0
- voice_copilot/core/secrets.py +80 -0
- voice_copilot/dialog/__init__.py +3 -0
- voice_copilot/dialog/manager.py +148 -0
- voice_copilot/hotkeys.py +194 -0
- voice_copilot/providers/__init__.py +5 -0
- voice_copilot/providers/llm/__init__.py +11 -0
- voice_copilot/providers/llm/anthropic.py +53 -0
- voice_copilot/providers/llm/base.py +37 -0
- voice_copilot/providers/llm/copilot_cli.py +145 -0
- voice_copilot/providers/llm/github_copilot.py +134 -0
- voice_copilot/providers/llm/openai.py +76 -0
- voice_copilot/providers/llm/openai_compat.py +27 -0
- voice_copilot/providers/registry.py +43 -0
- voice_copilot/providers/stt/__init__.py +6 -0
- voice_copilot/providers/stt/base.py +55 -0
- voice_copilot/providers/stt/deepgram.py +12 -0
- voice_copilot/providers/stt/faster_whisper.py +75 -0
- voice_copilot/providers/stt/whisper_api.py +49 -0
- voice_copilot/providers/tts/__init__.py +8 -0
- voice_copilot/providers/tts/base.py +53 -0
- voice_copilot/providers/tts/edge.py +47 -0
- voice_copilot/providers/tts/elevenlabs.py +58 -0
- voice_copilot/providers/tts/openai.py +50 -0
- voice_copilot/providers/tts/piper.py +12 -0
- voice_copilot/providers/tts/silero.py +16 -0
- voice_copilot/proxy/__init__.py +3 -0
- voice_copilot/proxy/anthropic.py +152 -0
- voice_copilot/proxy/body_sniffer.py +96 -0
- voice_copilot/proxy/cli_catalog.py +43 -0
- voice_copilot/proxy/cli_catalog.yaml +47 -0
- voice_copilot/proxy/cli_shims.py +615 -0
- voice_copilot/proxy/ollama_native.py +126 -0
- voice_copilot/proxy/openai.py +169 -0
- voice_copilot/proxy/server.py +248 -0
- voice_copilot/proxy/session.py +202 -0
- voice_copilot/tray.py +66 -0
- voice_copilot/web/__init__.py +3 -0
- voice_copilot/web/demo.py +34 -0
- voice_copilot/web/server.py +384 -0
- voice_copilot/web/static/app.js +1585 -0
- voice_copilot/web/static/index.html +374 -0
- voice_copilot/web/static/style.css +783 -0
- voice_copilot/web/ws.py +193 -0
- voice_copilot-0.0.1.dist-info/METADATA +325 -0
- voice_copilot-0.0.1.dist-info/RECORD +79 -0
- voice_copilot-0.0.1.dist-info/WHEEL +4 -0
- voice_copilot-0.0.1.dist-info/entry_points.txt +2 -0
- voice_copilot-0.0.1.dist-info/licenses/LICENSE +21 -0
|
@@ -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))
|