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,483 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import re
5
+ import shutil
6
+ from dataclasses import dataclass, field
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ import msgspec
11
+
12
+ from ..backends import EngineBackend, EngineConfig
13
+ from ..events import EventFactory
14
+ from ..logging import get_logger
15
+ from ..model import Action, ActionKind, EngineId, ResumeToken, TakopiEvent
16
+ from ..runner import JsonlSubprocessRunner, ResumeTokenMixin, Runner
17
+ from .run_options import get_run_options
18
+ from ..schemas import claude as claude_schema
19
+ from .tool_actions import tool_input_path, tool_kind_and_title
20
+
21
+ logger = get_logger(__name__)
22
+
23
+ ENGINE: EngineId = "claude"
24
+ DEFAULT_ALLOWED_TOOLS = ["Bash", "Read", "Edit", "Write"]
25
+
26
+ _RESUME_RE = re.compile(
27
+ r"(?im)^\s*`?claude\s+(?:--resume|-r)\s+(?P<token>[^`\s]+)`?\s*$"
28
+ )
29
+
30
+
31
+ @dataclass(slots=True)
32
+ class ClaudeStreamState:
33
+ factory: EventFactory = field(default_factory=lambda: EventFactory(ENGINE))
34
+ pending_actions: dict[str, Action] = field(default_factory=dict)
35
+ last_assistant_text: str | None = None
36
+ note_seq: int = 0
37
+
38
+
39
+ def _normalize_tool_result(content: Any) -> str:
40
+ if content is None:
41
+ return ""
42
+ if isinstance(content, str):
43
+ return content
44
+ if isinstance(content, list):
45
+ parts: list[str] = []
46
+ for item in content:
47
+ if isinstance(item, dict):
48
+ text = item.get("text")
49
+ if isinstance(text, str) and text:
50
+ parts.append(text)
51
+ elif isinstance(item, str):
52
+ parts.append(item)
53
+ return "\n".join(part for part in parts if part)
54
+ if isinstance(content, dict):
55
+ text = content.get("text")
56
+ if isinstance(text, str):
57
+ return text
58
+ return str(content)
59
+
60
+
61
+ def _coerce_comma_list(value: Any) -> str | None:
62
+ if value is None:
63
+ return None
64
+ if isinstance(value, (list, tuple, set)):
65
+ parts = [str(item) for item in value if item is not None]
66
+ joined = ",".join(part for part in parts if part)
67
+ return joined or None
68
+ text = str(value)
69
+ return text or None
70
+
71
+
72
+ def _tool_kind_and_title(
73
+ name: str, tool_input: dict[str, Any]
74
+ ) -> tuple[ActionKind, str]:
75
+ return tool_kind_and_title(name, tool_input, path_keys=("file_path", "path"))
76
+
77
+
78
+ def _tool_action(
79
+ content: claude_schema.StreamToolUseBlock,
80
+ *,
81
+ parent_tool_use_id: str | None,
82
+ ) -> Action:
83
+ tool_id = content.id
84
+ tool_name = str(content.name or "tool")
85
+ tool_input = content.input
86
+
87
+ kind, title = _tool_kind_and_title(tool_name, tool_input)
88
+
89
+ detail: dict[str, Any] = {
90
+ "name": tool_name,
91
+ "input": tool_input,
92
+ }
93
+ if parent_tool_use_id:
94
+ detail["parent_tool_use_id"] = parent_tool_use_id
95
+
96
+ if kind == "file_change":
97
+ path = tool_input_path(tool_input, path_keys=("file_path", "path"))
98
+ if path:
99
+ detail["changes"] = [{"path": path, "kind": "update"}]
100
+
101
+ return Action(id=tool_id, kind=kind, title=title, detail=detail)
102
+
103
+
104
+ def _tool_result_event(
105
+ content: claude_schema.StreamToolResultBlock,
106
+ *,
107
+ action: Action,
108
+ factory: EventFactory,
109
+ ) -> TakopiEvent:
110
+ is_error = content.is_error is True
111
+ raw_result = content.content
112
+ normalized = _normalize_tool_result(raw_result)
113
+ preview = normalized
114
+
115
+ detail = action.detail | {
116
+ "tool_use_id": content.tool_use_id,
117
+ "result_preview": preview,
118
+ "result_len": len(normalized),
119
+ "is_error": is_error,
120
+ }
121
+ return factory.action_completed(
122
+ action_id=action.id,
123
+ kind=action.kind,
124
+ title=action.title,
125
+ ok=not is_error,
126
+ detail=detail,
127
+ )
128
+
129
+
130
+ def _extract_error(event: claude_schema.StreamResultMessage) -> str | None:
131
+ if event.is_error:
132
+ if isinstance(event.result, str) and event.result:
133
+ return event.result
134
+ subtype = event.subtype
135
+ if subtype:
136
+ return f"claude run failed ({subtype})"
137
+ return "claude run failed"
138
+ return None
139
+
140
+
141
+ def _usage_payload(event: claude_schema.StreamResultMessage) -> dict[str, Any]:
142
+ usage: dict[str, Any] = {}
143
+ for key in (
144
+ "total_cost_usd",
145
+ "duration_ms",
146
+ "duration_api_ms",
147
+ "num_turns",
148
+ ):
149
+ value = getattr(event, key, None)
150
+ if value is not None:
151
+ usage[key] = value
152
+ if event.usage is not None:
153
+ usage["usage"] = event.usage
154
+ return usage
155
+
156
+
157
+ def translate_claude_event(
158
+ event: claude_schema.StreamJsonMessage,
159
+ *,
160
+ title: str,
161
+ state: ClaudeStreamState,
162
+ factory: EventFactory,
163
+ ) -> list[TakopiEvent]:
164
+ match event:
165
+ case claude_schema.StreamSystemMessage(subtype=subtype):
166
+ if subtype != "init":
167
+ return []
168
+ session_id = event.session_id
169
+ if not session_id:
170
+ return []
171
+ meta: dict[str, Any] = {}
172
+ for key in (
173
+ "cwd",
174
+ "tools",
175
+ "permissionMode",
176
+ "output_style",
177
+ "apiKeySource",
178
+ "mcp_servers",
179
+ ):
180
+ value = getattr(event, key, None)
181
+ if value is not None:
182
+ meta[key] = value
183
+ model = event.model
184
+ token = ResumeToken(engine=ENGINE, value=session_id)
185
+ event_title = str(model) if isinstance(model, str) and model else title
186
+ return [factory.started(token, title=event_title, meta=meta or None)]
187
+ case claude_schema.StreamAssistantMessage(
188
+ message=message, parent_tool_use_id=parent_tool_use_id
189
+ ):
190
+ out: list[TakopiEvent] = []
191
+ for content in message.content:
192
+ match content:
193
+ case claude_schema.StreamToolUseBlock():
194
+ action = _tool_action(
195
+ content,
196
+ parent_tool_use_id=parent_tool_use_id,
197
+ )
198
+ state.pending_actions[action.id] = action
199
+ out.append(
200
+ factory.action_started(
201
+ action_id=action.id,
202
+ kind=action.kind,
203
+ title=action.title,
204
+ detail=action.detail,
205
+ )
206
+ )
207
+ case claude_schema.StreamThinkingBlock(
208
+ thinking=thinking, signature=signature
209
+ ):
210
+ if not thinking:
211
+ continue
212
+ state.note_seq += 1
213
+ action_id = f"claude.thinking.{state.note_seq}"
214
+ detail: dict[str, Any] = {}
215
+ if parent_tool_use_id:
216
+ detail["parent_tool_use_id"] = parent_tool_use_id
217
+ if signature:
218
+ detail["signature"] = signature
219
+ out.append(
220
+ factory.action_completed(
221
+ action_id=action_id,
222
+ kind="note",
223
+ title=thinking,
224
+ ok=True,
225
+ detail=detail,
226
+ )
227
+ )
228
+ case claude_schema.StreamTextBlock(text=text):
229
+ if text:
230
+ state.last_assistant_text = text
231
+ case _:
232
+ continue
233
+ return out
234
+ case claude_schema.StreamUserMessage(message=message):
235
+ if not isinstance(message.content, list):
236
+ return []
237
+ out: list[TakopiEvent] = []
238
+ for content in message.content:
239
+ if not isinstance(content, claude_schema.StreamToolResultBlock):
240
+ continue
241
+ tool_use_id = content.tool_use_id
242
+ action = state.pending_actions.pop(tool_use_id, None)
243
+ if action is None:
244
+ action = Action(
245
+ id=tool_use_id,
246
+ kind="tool",
247
+ title="tool result",
248
+ detail={},
249
+ )
250
+ out.append(
251
+ _tool_result_event(
252
+ content,
253
+ action=action,
254
+ factory=factory,
255
+ )
256
+ )
257
+ return out
258
+ case claude_schema.StreamResultMessage():
259
+ ok = not event.is_error
260
+ result_text = event.result or ""
261
+ if ok and not result_text and state.last_assistant_text:
262
+ result_text = state.last_assistant_text
263
+
264
+ resume = ResumeToken(engine=ENGINE, value=event.session_id)
265
+ error = None if ok else _extract_error(event)
266
+ usage = _usage_payload(event)
267
+
268
+ return [
269
+ factory.completed(
270
+ ok=ok,
271
+ answer=result_text,
272
+ resume=resume,
273
+ error=error,
274
+ usage=usage or None,
275
+ )
276
+ ]
277
+ case _:
278
+ return []
279
+
280
+
281
+ @dataclass(slots=True)
282
+ class ClaudeRunner(ResumeTokenMixin, JsonlSubprocessRunner):
283
+ engine: EngineId = ENGINE
284
+ resume_re: re.Pattern[str] = _RESUME_RE
285
+
286
+ claude_cmd: str = "claude"
287
+ model: str | None = None
288
+ allowed_tools: list[str] | None = None
289
+ dangerously_skip_permissions: bool = False
290
+ use_api_billing: bool = False
291
+ session_title: str = "claude"
292
+ logger = logger
293
+
294
+ def format_resume(self, token: ResumeToken) -> str:
295
+ if token.engine != ENGINE:
296
+ raise RuntimeError(f"resume token is for engine {token.engine!r}")
297
+ return f"`claude --resume {token.value}`"
298
+
299
+ def _build_args(self, prompt: str, resume: ResumeToken | None) -> list[str]:
300
+ run_options = get_run_options()
301
+ args: list[str] = ["-p", "--output-format", "stream-json", "--verbose"]
302
+ if resume is not None:
303
+ args.extend(["--resume", resume.value])
304
+ model = self.model
305
+ if run_options is not None and run_options.model:
306
+ model = run_options.model
307
+ if model is not None:
308
+ args.extend(["--model", str(model)])
309
+ allowed_tools = _coerce_comma_list(self.allowed_tools)
310
+ if allowed_tools is not None:
311
+ args.extend(["--allowedTools", allowed_tools])
312
+ if self.dangerously_skip_permissions is True:
313
+ args.append("--dangerously-skip-permissions")
314
+ args.append("--")
315
+ args.append(prompt)
316
+ return args
317
+
318
+ def command(self) -> str:
319
+ return self.claude_cmd
320
+
321
+ def build_args(
322
+ self,
323
+ prompt: str,
324
+ resume: ResumeToken | None,
325
+ *,
326
+ state: Any,
327
+ ) -> list[str]:
328
+ return self._build_args(prompt, resume)
329
+
330
+ def stdin_payload(
331
+ self,
332
+ prompt: str,
333
+ resume: ResumeToken | None,
334
+ *,
335
+ state: Any,
336
+ ) -> bytes | None:
337
+ return None
338
+
339
+ def env(self, *, state: Any) -> dict[str, str] | None:
340
+ if self.use_api_billing is not True:
341
+ env = dict(os.environ)
342
+ env.pop("ANTHROPIC_API_KEY", None)
343
+ return env
344
+ return None
345
+
346
+ def new_state(self, prompt: str, resume: ResumeToken | None) -> ClaudeStreamState:
347
+ return ClaudeStreamState()
348
+
349
+ def start_run(
350
+ self,
351
+ prompt: str,
352
+ resume: ResumeToken | None,
353
+ *,
354
+ state: ClaudeStreamState,
355
+ ) -> None:
356
+ pass
357
+
358
+ def decode_jsonl(
359
+ self,
360
+ *,
361
+ line: bytes,
362
+ ) -> claude_schema.StreamJsonMessage:
363
+ return claude_schema.decode_stream_json_line(line)
364
+
365
+ def decode_error_events(
366
+ self,
367
+ *,
368
+ raw: str,
369
+ line: str,
370
+ error: Exception,
371
+ state: ClaudeStreamState,
372
+ ) -> list[TakopiEvent]:
373
+ if isinstance(error, msgspec.DecodeError):
374
+ self.get_logger().warning(
375
+ "jsonl.msgspec.invalid",
376
+ tag=self.tag(),
377
+ error=str(error),
378
+ error_type=error.__class__.__name__,
379
+ )
380
+ return []
381
+ return super().decode_error_events(
382
+ raw=raw,
383
+ line=line,
384
+ error=error,
385
+ state=state,
386
+ )
387
+
388
+ def invalid_json_events(
389
+ self,
390
+ *,
391
+ raw: str,
392
+ line: str,
393
+ state: ClaudeStreamState,
394
+ ) -> list[TakopiEvent]:
395
+ return []
396
+
397
+ def translate(
398
+ self,
399
+ data: claude_schema.StreamJsonMessage,
400
+ *,
401
+ state: ClaudeStreamState,
402
+ resume: ResumeToken | None,
403
+ found_session: ResumeToken | None,
404
+ ) -> list[TakopiEvent]:
405
+ return translate_claude_event(
406
+ data,
407
+ title=self.session_title,
408
+ state=state,
409
+ factory=state.factory,
410
+ )
411
+
412
+ def process_error_events(
413
+ self,
414
+ rc: int,
415
+ *,
416
+ resume: ResumeToken | None,
417
+ found_session: ResumeToken | None,
418
+ state: ClaudeStreamState,
419
+ ) -> list[TakopiEvent]:
420
+ message = f"claude failed (rc={rc})."
421
+ resume_for_completed = found_session or resume
422
+ return [
423
+ self.note_event(message, state=state, ok=False),
424
+ state.factory.completed_error(
425
+ error=message,
426
+ resume=resume_for_completed,
427
+ ),
428
+ ]
429
+
430
+ def stream_end_events(
431
+ self,
432
+ *,
433
+ resume: ResumeToken | None,
434
+ found_session: ResumeToken | None,
435
+ state: ClaudeStreamState,
436
+ ) -> list[TakopiEvent]:
437
+ if not found_session:
438
+ message = "claude finished but no session_id was captured"
439
+ resume_for_completed = resume
440
+ return [
441
+ state.factory.completed_error(
442
+ error=message,
443
+ resume=resume_for_completed,
444
+ )
445
+ ]
446
+
447
+ message = "claude finished without a result event"
448
+ return [
449
+ state.factory.completed_error(
450
+ error=message,
451
+ answer=state.last_assistant_text or "",
452
+ resume=found_session,
453
+ )
454
+ ]
455
+
456
+
457
+ def build_runner(config: EngineConfig, _config_path: Path) -> Runner:
458
+ claude_cmd = shutil.which("claude") or "claude"
459
+
460
+ model = config.get("model")
461
+ if "allowed_tools" in config:
462
+ allowed_tools = config.get("allowed_tools")
463
+ else:
464
+ allowed_tools = DEFAULT_ALLOWED_TOOLS
465
+ dangerously_skip_permissions = config.get("dangerously_skip_permissions") is True
466
+ use_api_billing = config.get("use_api_billing") is True
467
+ title = str(model) if model is not None else "claude"
468
+
469
+ return ClaudeRunner(
470
+ claude_cmd=claude_cmd,
471
+ model=model,
472
+ allowed_tools=allowed_tools,
473
+ dangerously_skip_permissions=dangerously_skip_permissions,
474
+ use_api_billing=use_api_billing,
475
+ session_title=title,
476
+ )
477
+
478
+
479
+ BACKEND = EngineBackend(
480
+ id="claude",
481
+ build_runner=build_runner,
482
+ install_cmd="npm install -g @anthropic-ai/claude-code",
483
+ )