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.
Files changed (103) hide show
  1. takopi/__init__.py +1 -0
  2. takopi/api.py +116 -0
  3. takopi/backends.py +25 -0
  4. takopi/backends_helpers.py +14 -0
  5. takopi/cli/__init__.py +228 -0
  6. takopi/cli/config.py +320 -0
  7. takopi/cli/doctor.py +173 -0
  8. takopi/cli/init.py +113 -0
  9. takopi/cli/onboarding_cmd.py +126 -0
  10. takopi/cli/plugins.py +196 -0
  11. takopi/cli/run.py +419 -0
  12. takopi/cli/topic.py +355 -0
  13. takopi/commands.py +134 -0
  14. takopi/config.py +142 -0
  15. takopi/config_migrations.py +124 -0
  16. takopi/config_watch.py +146 -0
  17. takopi/context.py +9 -0
  18. takopi/directives.py +146 -0
  19. takopi/engines.py +53 -0
  20. takopi/events.py +170 -0
  21. takopi/ids.py +17 -0
  22. takopi/lockfile.py +158 -0
  23. takopi/logging.py +283 -0
  24. takopi/markdown.py +298 -0
  25. takopi/model.py +77 -0
  26. takopi/plugins.py +312 -0
  27. takopi/presenter.py +25 -0
  28. takopi/progress.py +99 -0
  29. takopi/router.py +113 -0
  30. takopi/runner.py +712 -0
  31. takopi/runner_bridge.py +619 -0
  32. takopi/runners/__init__.py +1 -0
  33. takopi/runners/claude.py +483 -0
  34. takopi/runners/codex.py +656 -0
  35. takopi/runners/mock.py +221 -0
  36. takopi/runners/opencode.py +505 -0
  37. takopi/runners/pi.py +523 -0
  38. takopi/runners/run_options.py +39 -0
  39. takopi/runners/tool_actions.py +90 -0
  40. takopi/runtime_loader.py +207 -0
  41. takopi/scheduler.py +159 -0
  42. takopi/schemas/__init__.py +1 -0
  43. takopi/schemas/claude.py +238 -0
  44. takopi/schemas/codex.py +169 -0
  45. takopi/schemas/opencode.py +51 -0
  46. takopi/schemas/pi.py +117 -0
  47. takopi/settings.py +360 -0
  48. takopi/telegram/__init__.py +20 -0
  49. takopi/telegram/api_models.py +37 -0
  50. takopi/telegram/api_schemas.py +152 -0
  51. takopi/telegram/backend.py +163 -0
  52. takopi/telegram/bridge.py +425 -0
  53. takopi/telegram/chat_prefs.py +242 -0
  54. takopi/telegram/chat_sessions.py +112 -0
  55. takopi/telegram/client.py +409 -0
  56. takopi/telegram/client_api.py +539 -0
  57. takopi/telegram/commands/__init__.py +12 -0
  58. takopi/telegram/commands/agent.py +196 -0
  59. takopi/telegram/commands/cancel.py +116 -0
  60. takopi/telegram/commands/dispatch.py +111 -0
  61. takopi/telegram/commands/executor.py +449 -0
  62. takopi/telegram/commands/file_transfer.py +586 -0
  63. takopi/telegram/commands/handlers.py +45 -0
  64. takopi/telegram/commands/media.py +143 -0
  65. takopi/telegram/commands/menu.py +139 -0
  66. takopi/telegram/commands/model.py +215 -0
  67. takopi/telegram/commands/overrides.py +159 -0
  68. takopi/telegram/commands/parse.py +30 -0
  69. takopi/telegram/commands/plan.py +16 -0
  70. takopi/telegram/commands/reasoning.py +234 -0
  71. takopi/telegram/commands/reply.py +23 -0
  72. takopi/telegram/commands/topics.py +332 -0
  73. takopi/telegram/commands/trigger.py +143 -0
  74. takopi/telegram/context.py +140 -0
  75. takopi/telegram/engine_defaults.py +86 -0
  76. takopi/telegram/engine_overrides.py +105 -0
  77. takopi/telegram/files.py +178 -0
  78. takopi/telegram/loop.py +1822 -0
  79. takopi/telegram/onboarding.py +1088 -0
  80. takopi/telegram/outbox.py +177 -0
  81. takopi/telegram/parsing.py +239 -0
  82. takopi/telegram/render.py +198 -0
  83. takopi/telegram/state_store.py +88 -0
  84. takopi/telegram/topic_state.py +334 -0
  85. takopi/telegram/topics.py +256 -0
  86. takopi/telegram/trigger_mode.py +68 -0
  87. takopi/telegram/types.py +63 -0
  88. takopi/telegram/voice.py +110 -0
  89. takopi/transport.py +53 -0
  90. takopi/transport_runtime.py +323 -0
  91. takopi/transports.py +76 -0
  92. takopi/utils/__init__.py +1 -0
  93. takopi/utils/git.py +87 -0
  94. takopi/utils/json_state.py +21 -0
  95. takopi/utils/paths.py +47 -0
  96. takopi/utils/streams.py +44 -0
  97. takopi/utils/subprocess.py +86 -0
  98. takopi/worktrees.py +135 -0
  99. yee88-0.1.0.dist-info/METADATA +116 -0
  100. yee88-0.1.0.dist-info/RECORD +103 -0
  101. yee88-0.1.0.dist-info/WHEEL +4 -0
  102. yee88-0.1.0.dist-info/entry_points.txt +11 -0
  103. 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
+ )