yee88 0.1.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.
- takopi/__init__.py +1 -0
- takopi/api.py +116 -0
- takopi/backends.py +25 -0
- takopi/backends_helpers.py +14 -0
- takopi/cli/__init__.py +228 -0
- takopi/cli/config.py +320 -0
- takopi/cli/doctor.py +173 -0
- takopi/cli/init.py +113 -0
- takopi/cli/onboarding_cmd.py +126 -0
- takopi/cli/plugins.py +196 -0
- takopi/cli/run.py +419 -0
- takopi/cli/topic.py +355 -0
- takopi/commands.py +134 -0
- takopi/config.py +142 -0
- takopi/config_migrations.py +124 -0
- takopi/config_watch.py +146 -0
- takopi/context.py +9 -0
- takopi/directives.py +146 -0
- takopi/engines.py +53 -0
- takopi/events.py +170 -0
- takopi/ids.py +17 -0
- takopi/lockfile.py +158 -0
- takopi/logging.py +283 -0
- takopi/markdown.py +298 -0
- takopi/model.py +77 -0
- takopi/plugins.py +312 -0
- takopi/presenter.py +25 -0
- takopi/progress.py +99 -0
- takopi/router.py +113 -0
- takopi/runner.py +712 -0
- takopi/runner_bridge.py +619 -0
- takopi/runners/__init__.py +1 -0
- takopi/runners/claude.py +483 -0
- takopi/runners/codex.py +656 -0
- takopi/runners/mock.py +221 -0
- takopi/runners/opencode.py +505 -0
- takopi/runners/pi.py +523 -0
- takopi/runners/run_options.py +39 -0
- takopi/runners/tool_actions.py +90 -0
- takopi/runtime_loader.py +207 -0
- takopi/scheduler.py +159 -0
- takopi/schemas/__init__.py +1 -0
- takopi/schemas/claude.py +238 -0
- takopi/schemas/codex.py +169 -0
- takopi/schemas/opencode.py +51 -0
- takopi/schemas/pi.py +117 -0
- takopi/settings.py +360 -0
- takopi/telegram/__init__.py +20 -0
- takopi/telegram/api_models.py +37 -0
- takopi/telegram/api_schemas.py +152 -0
- takopi/telegram/backend.py +163 -0
- takopi/telegram/bridge.py +425 -0
- takopi/telegram/chat_prefs.py +242 -0
- takopi/telegram/chat_sessions.py +112 -0
- takopi/telegram/client.py +409 -0
- takopi/telegram/client_api.py +539 -0
- takopi/telegram/commands/__init__.py +12 -0
- takopi/telegram/commands/agent.py +196 -0
- takopi/telegram/commands/cancel.py +116 -0
- takopi/telegram/commands/dispatch.py +111 -0
- takopi/telegram/commands/executor.py +449 -0
- takopi/telegram/commands/file_transfer.py +586 -0
- takopi/telegram/commands/handlers.py +45 -0
- takopi/telegram/commands/media.py +143 -0
- takopi/telegram/commands/menu.py +139 -0
- takopi/telegram/commands/model.py +215 -0
- takopi/telegram/commands/overrides.py +159 -0
- takopi/telegram/commands/parse.py +30 -0
- takopi/telegram/commands/plan.py +16 -0
- takopi/telegram/commands/reasoning.py +234 -0
- takopi/telegram/commands/reply.py +23 -0
- takopi/telegram/commands/topics.py +332 -0
- takopi/telegram/commands/trigger.py +143 -0
- takopi/telegram/context.py +140 -0
- takopi/telegram/engine_defaults.py +86 -0
- takopi/telegram/engine_overrides.py +105 -0
- takopi/telegram/files.py +178 -0
- takopi/telegram/loop.py +1822 -0
- takopi/telegram/onboarding.py +1088 -0
- takopi/telegram/outbox.py +177 -0
- takopi/telegram/parsing.py +239 -0
- takopi/telegram/render.py +198 -0
- takopi/telegram/state_store.py +88 -0
- takopi/telegram/topic_state.py +334 -0
- takopi/telegram/topics.py +256 -0
- takopi/telegram/trigger_mode.py +68 -0
- takopi/telegram/types.py +63 -0
- takopi/telegram/voice.py +110 -0
- takopi/transport.py +53 -0
- takopi/transport_runtime.py +323 -0
- takopi/transports.py +76 -0
- takopi/utils/__init__.py +1 -0
- takopi/utils/git.py +87 -0
- takopi/utils/json_state.py +21 -0
- takopi/utils/paths.py +47 -0
- takopi/utils/streams.py +44 -0
- takopi/utils/subprocess.py +86 -0
- takopi/worktrees.py +135 -0
- yee88-0.1.0.dist-info/METADATA +116 -0
- yee88-0.1.0.dist-info/RECORD +103 -0
- yee88-0.1.0.dist-info/WHEEL +4 -0
- yee88-0.1.0.dist-info/entry_points.txt +11 -0
- yee88-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,505 @@
|
|
|
1
|
+
"""OpenCode CLI runner.
|
|
2
|
+
|
|
3
|
+
This runner integrates with the OpenCode CLI (https://github.com/sst/opencode).
|
|
4
|
+
|
|
5
|
+
OpenCode outputs JSON events in a streaming format with types:
|
|
6
|
+
- step_start: Marks the beginning of a processing step
|
|
7
|
+
- tool_use: Tool invocation with input/output
|
|
8
|
+
- text: Text output from the model
|
|
9
|
+
- step_finish: Marks the end of a step (with reason: "stop" or "tool-calls")
|
|
10
|
+
|
|
11
|
+
Session IDs use the format: ses_XXXX (e.g., ses_494719016ffe85dkDMj0FPRbHK)
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import re
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any, Literal
|
|
20
|
+
|
|
21
|
+
import msgspec
|
|
22
|
+
|
|
23
|
+
from ..backends import EngineBackend, EngineConfig
|
|
24
|
+
from ..config import ConfigError
|
|
25
|
+
from ..logging import get_logger
|
|
26
|
+
from ..model import (
|
|
27
|
+
Action,
|
|
28
|
+
ActionEvent,
|
|
29
|
+
ActionKind,
|
|
30
|
+
CompletedEvent,
|
|
31
|
+
EngineId,
|
|
32
|
+
ResumeToken,
|
|
33
|
+
StartedEvent,
|
|
34
|
+
TakopiEvent,
|
|
35
|
+
)
|
|
36
|
+
from ..runner import JsonlSubprocessRunner, ResumeTokenMixin, Runner
|
|
37
|
+
from .run_options import get_run_options
|
|
38
|
+
from ..schemas import opencode as opencode_schema
|
|
39
|
+
from ..utils.paths import relativize_path
|
|
40
|
+
from .tool_actions import tool_input_path, tool_kind_and_title
|
|
41
|
+
|
|
42
|
+
logger = get_logger(__name__)
|
|
43
|
+
|
|
44
|
+
ENGINE: EngineId = "opencode"
|
|
45
|
+
|
|
46
|
+
_RESUME_RE = re.compile(
|
|
47
|
+
r"(?im)^\s*`?opencode(?:\s+run)?\s+(?:--session|-s)\s+(?P<token>ses_[A-Za-z0-9]+)`?\s*$"
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(slots=True)
|
|
52
|
+
class OpenCodeStreamState:
|
|
53
|
+
"""State tracked during OpenCode JSONL streaming."""
|
|
54
|
+
|
|
55
|
+
pending_actions: dict[str, Action] = field(default_factory=dict)
|
|
56
|
+
last_text: str | None = None
|
|
57
|
+
note_seq: int = 0
|
|
58
|
+
session_id: str | None = None
|
|
59
|
+
emitted_started: bool = False
|
|
60
|
+
saw_step_finish: bool = False
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _action_event(
|
|
64
|
+
*,
|
|
65
|
+
phase: Literal["started", "updated", "completed"],
|
|
66
|
+
action: Action,
|
|
67
|
+
ok: bool | None = None,
|
|
68
|
+
message: str | None = None,
|
|
69
|
+
level: Literal["debug", "info", "warning", "error"] | None = None,
|
|
70
|
+
) -> ActionEvent:
|
|
71
|
+
return ActionEvent(
|
|
72
|
+
engine=ENGINE,
|
|
73
|
+
action=action,
|
|
74
|
+
phase=phase,
|
|
75
|
+
ok=ok,
|
|
76
|
+
message=message,
|
|
77
|
+
level=level,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _tool_kind_and_title(
|
|
82
|
+
tool_name: str, tool_input: dict[str, Any]
|
|
83
|
+
) -> tuple[ActionKind, str]:
|
|
84
|
+
return tool_kind_and_title(
|
|
85
|
+
tool_name,
|
|
86
|
+
tool_input,
|
|
87
|
+
path_keys=("file_path", "filePath"),
|
|
88
|
+
task_kind="tool",
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _normalize_tool_title(
|
|
93
|
+
title: str,
|
|
94
|
+
*,
|
|
95
|
+
tool_input: dict[str, Any],
|
|
96
|
+
) -> str:
|
|
97
|
+
if "`" in title:
|
|
98
|
+
return title
|
|
99
|
+
|
|
100
|
+
path = tool_input_path(tool_input, path_keys=("file_path", "filePath"))
|
|
101
|
+
if isinstance(path, str) and path:
|
|
102
|
+
rel_path = relativize_path(path)
|
|
103
|
+
if title in (path, rel_path):
|
|
104
|
+
return f"`{rel_path}`"
|
|
105
|
+
|
|
106
|
+
return title
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _extract_tool_action(part: dict[str, Any]) -> Action | None:
|
|
110
|
+
"""Extract an Action from an OpenCode tool_use part."""
|
|
111
|
+
state = part.get("state") or {}
|
|
112
|
+
|
|
113
|
+
call_id = part.get("callID")
|
|
114
|
+
if not isinstance(call_id, str) or not call_id:
|
|
115
|
+
call_id = part.get("id")
|
|
116
|
+
if not isinstance(call_id, str) or not call_id:
|
|
117
|
+
return None
|
|
118
|
+
|
|
119
|
+
tool_name = part.get("tool") or "tool"
|
|
120
|
+
tool_input = state.get("input") or {}
|
|
121
|
+
if not isinstance(tool_input, dict):
|
|
122
|
+
tool_input = {}
|
|
123
|
+
|
|
124
|
+
kind, title = _tool_kind_and_title(tool_name, tool_input)
|
|
125
|
+
|
|
126
|
+
state_title = state.get("title")
|
|
127
|
+
if isinstance(state_title, str) and state_title:
|
|
128
|
+
title = _normalize_tool_title(state_title, tool_input=tool_input)
|
|
129
|
+
|
|
130
|
+
detail: dict[str, Any] = {
|
|
131
|
+
"name": tool_name,
|
|
132
|
+
"input": tool_input,
|
|
133
|
+
"callID": call_id,
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if kind == "file_change":
|
|
137
|
+
path = tool_input.get("file_path") or tool_input.get("filePath")
|
|
138
|
+
if path:
|
|
139
|
+
detail["changes"] = [{"path": path, "kind": "update"}]
|
|
140
|
+
|
|
141
|
+
return Action(id=call_id, kind=kind, title=title, detail=detail)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def translate_opencode_event(
|
|
145
|
+
event: opencode_schema.OpenCodeEvent,
|
|
146
|
+
*,
|
|
147
|
+
title: str,
|
|
148
|
+
state: OpenCodeStreamState,
|
|
149
|
+
) -> list[TakopiEvent]:
|
|
150
|
+
"""Translate an OpenCode JSON event into Takopi events."""
|
|
151
|
+
session_id = event.sessionID
|
|
152
|
+
|
|
153
|
+
if isinstance(session_id, str) and session_id and state.session_id is None:
|
|
154
|
+
state.session_id = session_id
|
|
155
|
+
|
|
156
|
+
match event:
|
|
157
|
+
case opencode_schema.StepStart():
|
|
158
|
+
if not state.emitted_started and state.session_id:
|
|
159
|
+
state.emitted_started = True
|
|
160
|
+
return [
|
|
161
|
+
StartedEvent(
|
|
162
|
+
engine=ENGINE,
|
|
163
|
+
resume=ResumeToken(engine=ENGINE, value=state.session_id),
|
|
164
|
+
title=title,
|
|
165
|
+
)
|
|
166
|
+
]
|
|
167
|
+
return []
|
|
168
|
+
|
|
169
|
+
case opencode_schema.ToolUse(part=part):
|
|
170
|
+
part = part or {}
|
|
171
|
+
tool_state = part.get("state") or {}
|
|
172
|
+
status = tool_state.get("status")
|
|
173
|
+
|
|
174
|
+
action = _extract_tool_action(part)
|
|
175
|
+
if action is None:
|
|
176
|
+
return []
|
|
177
|
+
|
|
178
|
+
if status == "completed":
|
|
179
|
+
output = tool_state.get("output")
|
|
180
|
+
metadata = tool_state.get("metadata") or {}
|
|
181
|
+
exit_code = metadata.get("exit")
|
|
182
|
+
|
|
183
|
+
is_error = False
|
|
184
|
+
if isinstance(exit_code, int) and exit_code != 0:
|
|
185
|
+
is_error = True
|
|
186
|
+
|
|
187
|
+
detail = dict(action.detail)
|
|
188
|
+
if output is not None:
|
|
189
|
+
detail["output_preview"] = (
|
|
190
|
+
str(output)[:500] if len(str(output)) > 500 else str(output)
|
|
191
|
+
)
|
|
192
|
+
detail["exit_code"] = exit_code
|
|
193
|
+
|
|
194
|
+
state.pending_actions.pop(action.id, None)
|
|
195
|
+
|
|
196
|
+
return [
|
|
197
|
+
_action_event(
|
|
198
|
+
phase="completed",
|
|
199
|
+
action=Action(
|
|
200
|
+
id=action.id,
|
|
201
|
+
kind=action.kind,
|
|
202
|
+
title=action.title,
|
|
203
|
+
detail=detail,
|
|
204
|
+
),
|
|
205
|
+
ok=not is_error,
|
|
206
|
+
)
|
|
207
|
+
]
|
|
208
|
+
if status == "error":
|
|
209
|
+
error = tool_state.get("error")
|
|
210
|
+
metadata = tool_state.get("metadata") or {}
|
|
211
|
+
exit_code = metadata.get("exit")
|
|
212
|
+
|
|
213
|
+
detail = dict(action.detail)
|
|
214
|
+
if error is not None:
|
|
215
|
+
detail["error"] = error
|
|
216
|
+
detail["exit_code"] = exit_code
|
|
217
|
+
|
|
218
|
+
state.pending_actions.pop(action.id, None)
|
|
219
|
+
|
|
220
|
+
return [
|
|
221
|
+
_action_event(
|
|
222
|
+
phase="completed",
|
|
223
|
+
action=Action(
|
|
224
|
+
id=action.id,
|
|
225
|
+
kind=action.kind,
|
|
226
|
+
title=action.title,
|
|
227
|
+
detail=detail,
|
|
228
|
+
),
|
|
229
|
+
ok=False,
|
|
230
|
+
message=str(error) if error is not None else None,
|
|
231
|
+
)
|
|
232
|
+
]
|
|
233
|
+
else:
|
|
234
|
+
state.pending_actions[action.id] = action
|
|
235
|
+
return [_action_event(phase="started", action=action)]
|
|
236
|
+
|
|
237
|
+
case opencode_schema.Text(part=part):
|
|
238
|
+
part = part or {}
|
|
239
|
+
text = part.get("text")
|
|
240
|
+
if isinstance(text, str) and text:
|
|
241
|
+
if state.last_text is None:
|
|
242
|
+
state.last_text = text
|
|
243
|
+
else:
|
|
244
|
+
state.last_text += text
|
|
245
|
+
return []
|
|
246
|
+
|
|
247
|
+
case opencode_schema.StepFinish(part=part):
|
|
248
|
+
part = part or {}
|
|
249
|
+
reason = part.get("reason")
|
|
250
|
+
state.saw_step_finish = True
|
|
251
|
+
|
|
252
|
+
if reason == "stop":
|
|
253
|
+
resume = None
|
|
254
|
+
if state.session_id:
|
|
255
|
+
resume = ResumeToken(engine=ENGINE, value=state.session_id)
|
|
256
|
+
|
|
257
|
+
return [
|
|
258
|
+
CompletedEvent(
|
|
259
|
+
engine=ENGINE,
|
|
260
|
+
ok=True,
|
|
261
|
+
answer=state.last_text or "",
|
|
262
|
+
resume=resume,
|
|
263
|
+
)
|
|
264
|
+
]
|
|
265
|
+
return []
|
|
266
|
+
|
|
267
|
+
case opencode_schema.Error(error=error_value, message=message_value):
|
|
268
|
+
raw_message = message_value if message_value is not None else error_value
|
|
269
|
+
|
|
270
|
+
message = raw_message
|
|
271
|
+
if isinstance(message, dict):
|
|
272
|
+
data = message.get("data")
|
|
273
|
+
if isinstance(data, dict) and data.get("message"):
|
|
274
|
+
message = data.get("message")
|
|
275
|
+
else:
|
|
276
|
+
message = (
|
|
277
|
+
message.get("message")
|
|
278
|
+
or message.get("name")
|
|
279
|
+
or "opencode error"
|
|
280
|
+
)
|
|
281
|
+
elif message is None:
|
|
282
|
+
message = "opencode error"
|
|
283
|
+
|
|
284
|
+
resume = None
|
|
285
|
+
if state.session_id:
|
|
286
|
+
resume = ResumeToken(engine=ENGINE, value=state.session_id)
|
|
287
|
+
|
|
288
|
+
return [
|
|
289
|
+
CompletedEvent(
|
|
290
|
+
engine=ENGINE,
|
|
291
|
+
ok=False,
|
|
292
|
+
answer=state.last_text or "",
|
|
293
|
+
resume=resume,
|
|
294
|
+
error=str(message),
|
|
295
|
+
)
|
|
296
|
+
]
|
|
297
|
+
|
|
298
|
+
case _:
|
|
299
|
+
return []
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
@dataclass(slots=True)
|
|
303
|
+
class OpenCodeRunner(ResumeTokenMixin, JsonlSubprocessRunner):
|
|
304
|
+
"""Runner for OpenCode CLI."""
|
|
305
|
+
|
|
306
|
+
engine: EngineId = ENGINE
|
|
307
|
+
resume_re: re.Pattern[str] = _RESUME_RE
|
|
308
|
+
|
|
309
|
+
opencode_cmd: str = "opencode"
|
|
310
|
+
model: str | None = None
|
|
311
|
+
session_title: str = "opencode"
|
|
312
|
+
logger = logger
|
|
313
|
+
|
|
314
|
+
def format_resume(self, token: ResumeToken) -> str:
|
|
315
|
+
if token.engine != ENGINE:
|
|
316
|
+
raise RuntimeError(f"resume token is for engine {token.engine!r}")
|
|
317
|
+
return f"`opencode --session {token.value}`"
|
|
318
|
+
|
|
319
|
+
def command(self) -> str:
|
|
320
|
+
return self.opencode_cmd
|
|
321
|
+
|
|
322
|
+
def build_args(
|
|
323
|
+
self,
|
|
324
|
+
prompt: str,
|
|
325
|
+
resume: ResumeToken | None,
|
|
326
|
+
*,
|
|
327
|
+
state: Any,
|
|
328
|
+
) -> list[str]:
|
|
329
|
+
run_options = get_run_options()
|
|
330
|
+
args = ["run", "--format", "json"]
|
|
331
|
+
if resume is not None:
|
|
332
|
+
args.extend(["--session", resume.value])
|
|
333
|
+
model = self.model
|
|
334
|
+
if run_options is not None and run_options.model:
|
|
335
|
+
model = run_options.model
|
|
336
|
+
if model is not None:
|
|
337
|
+
args.extend(["--model", str(model)])
|
|
338
|
+
# Apply system prompt as prefix if provided
|
|
339
|
+
if run_options is not None and run_options.system:
|
|
340
|
+
prompt = f"{run_options.system}\n\n{prompt}"
|
|
341
|
+
args.extend(["--", prompt])
|
|
342
|
+
return args
|
|
343
|
+
|
|
344
|
+
def stdin_payload(
|
|
345
|
+
self,
|
|
346
|
+
prompt: str,
|
|
347
|
+
resume: ResumeToken | None,
|
|
348
|
+
*,
|
|
349
|
+
state: Any,
|
|
350
|
+
) -> bytes | None:
|
|
351
|
+
return None
|
|
352
|
+
|
|
353
|
+
def new_state(self, prompt: str, resume: ResumeToken | None) -> OpenCodeStreamState:
|
|
354
|
+
return OpenCodeStreamState()
|
|
355
|
+
|
|
356
|
+
def start_run(
|
|
357
|
+
self,
|
|
358
|
+
prompt: str,
|
|
359
|
+
resume: ResumeToken | None,
|
|
360
|
+
*,
|
|
361
|
+
state: OpenCodeStreamState,
|
|
362
|
+
) -> None:
|
|
363
|
+
pass
|
|
364
|
+
|
|
365
|
+
def invalid_json_events(
|
|
366
|
+
self,
|
|
367
|
+
*,
|
|
368
|
+
raw: str,
|
|
369
|
+
line: str,
|
|
370
|
+
state: OpenCodeStreamState,
|
|
371
|
+
) -> list[TakopiEvent]:
|
|
372
|
+
message = "invalid JSON from opencode; ignoring line"
|
|
373
|
+
return [self.note_event(message, state=state, detail={"line": raw})]
|
|
374
|
+
|
|
375
|
+
def translate(
|
|
376
|
+
self,
|
|
377
|
+
data: opencode_schema.OpenCodeEvent,
|
|
378
|
+
*,
|
|
379
|
+
state: OpenCodeStreamState,
|
|
380
|
+
resume: ResumeToken | None,
|
|
381
|
+
found_session: ResumeToken | None,
|
|
382
|
+
) -> list[TakopiEvent]:
|
|
383
|
+
return translate_opencode_event(
|
|
384
|
+
data,
|
|
385
|
+
title=self.session_title,
|
|
386
|
+
state=state,
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
def decode_jsonl(self, *, line: bytes) -> opencode_schema.OpenCodeEvent:
|
|
390
|
+
return opencode_schema.decode_event(line)
|
|
391
|
+
|
|
392
|
+
def decode_error_events(
|
|
393
|
+
self,
|
|
394
|
+
*,
|
|
395
|
+
raw: str,
|
|
396
|
+
line: str,
|
|
397
|
+
error: Exception,
|
|
398
|
+
state: OpenCodeStreamState,
|
|
399
|
+
) -> list[TakopiEvent]:
|
|
400
|
+
if isinstance(error, msgspec.DecodeError):
|
|
401
|
+
self.get_logger().warning(
|
|
402
|
+
"jsonl.msgspec.invalid",
|
|
403
|
+
tag=self.tag(),
|
|
404
|
+
error=str(error),
|
|
405
|
+
error_type=error.__class__.__name__,
|
|
406
|
+
)
|
|
407
|
+
return []
|
|
408
|
+
return super().decode_error_events(
|
|
409
|
+
raw=raw,
|
|
410
|
+
line=line,
|
|
411
|
+
error=error,
|
|
412
|
+
state=state,
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
def process_error_events(
|
|
416
|
+
self,
|
|
417
|
+
rc: int,
|
|
418
|
+
*,
|
|
419
|
+
resume: ResumeToken | None,
|
|
420
|
+
found_session: ResumeToken | None,
|
|
421
|
+
state: OpenCodeStreamState,
|
|
422
|
+
) -> list[TakopiEvent]:
|
|
423
|
+
message = f"opencode failed (rc={rc})."
|
|
424
|
+
resume_for_completed = found_session or resume
|
|
425
|
+
return [
|
|
426
|
+
self.note_event(
|
|
427
|
+
message,
|
|
428
|
+
state=state,
|
|
429
|
+
ok=False,
|
|
430
|
+
),
|
|
431
|
+
CompletedEvent(
|
|
432
|
+
engine=ENGINE,
|
|
433
|
+
ok=False,
|
|
434
|
+
answer=state.last_text or "",
|
|
435
|
+
resume=resume_for_completed,
|
|
436
|
+
error=message,
|
|
437
|
+
),
|
|
438
|
+
]
|
|
439
|
+
|
|
440
|
+
def stream_end_events(
|
|
441
|
+
self,
|
|
442
|
+
*,
|
|
443
|
+
resume: ResumeToken | None,
|
|
444
|
+
found_session: ResumeToken | None,
|
|
445
|
+
state: OpenCodeStreamState,
|
|
446
|
+
) -> list[TakopiEvent]:
|
|
447
|
+
if not found_session:
|
|
448
|
+
message = "opencode finished but no session_id was captured"
|
|
449
|
+
resume_for_completed = resume
|
|
450
|
+
return [
|
|
451
|
+
CompletedEvent(
|
|
452
|
+
engine=ENGINE,
|
|
453
|
+
ok=False,
|
|
454
|
+
answer=state.last_text or "",
|
|
455
|
+
resume=resume_for_completed,
|
|
456
|
+
error=message,
|
|
457
|
+
)
|
|
458
|
+
]
|
|
459
|
+
|
|
460
|
+
if state.saw_step_finish:
|
|
461
|
+
return [
|
|
462
|
+
CompletedEvent(
|
|
463
|
+
engine=ENGINE,
|
|
464
|
+
ok=True,
|
|
465
|
+
answer=state.last_text or "",
|
|
466
|
+
resume=found_session,
|
|
467
|
+
)
|
|
468
|
+
]
|
|
469
|
+
|
|
470
|
+
message = "opencode finished without a result event"
|
|
471
|
+
return [
|
|
472
|
+
CompletedEvent(
|
|
473
|
+
engine=ENGINE,
|
|
474
|
+
ok=False,
|
|
475
|
+
answer=state.last_text or "",
|
|
476
|
+
resume=found_session,
|
|
477
|
+
error=message,
|
|
478
|
+
)
|
|
479
|
+
]
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def build_runner(config: EngineConfig, config_path: Path) -> Runner:
|
|
483
|
+
"""Build an OpenCodeRunner from configuration."""
|
|
484
|
+
opencode_cmd = "opencode"
|
|
485
|
+
|
|
486
|
+
model = config.get("model")
|
|
487
|
+
if model is not None and not isinstance(model, str):
|
|
488
|
+
raise ConfigError(
|
|
489
|
+
f"Invalid `opencode.model` in {config_path}; expected a string."
|
|
490
|
+
)
|
|
491
|
+
|
|
492
|
+
title = str(model) if model is not None else "opencode"
|
|
493
|
+
|
|
494
|
+
return OpenCodeRunner(
|
|
495
|
+
opencode_cmd=opencode_cmd,
|
|
496
|
+
model=model,
|
|
497
|
+
session_title=title,
|
|
498
|
+
)
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
BACKEND = EngineBackend(
|
|
502
|
+
id="opencode",
|
|
503
|
+
build_runner=build_runner,
|
|
504
|
+
install_cmd="npm install -g opencode-ai@latest",
|
|
505
|
+
)
|