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
takopi/lockfile.py ADDED
@@ -0,0 +1,158 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ import os
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+
9
+ from .logging import get_logger
10
+
11
+ logger = get_logger(__name__)
12
+
13
+
14
+ @dataclass(frozen=True, slots=True)
15
+ class LockInfo:
16
+ pid: int | None
17
+ token_fingerprint: str | None
18
+
19
+
20
+ class LockError(RuntimeError):
21
+ def __init__(
22
+ self,
23
+ *,
24
+ path: Path,
25
+ state: str,
26
+ ) -> None:
27
+ self.path = path
28
+ self.state = state
29
+ super().__init__(_format_lock_message(path, state))
30
+
31
+
32
+ @dataclass(slots=True)
33
+ class LockHandle:
34
+ path: Path
35
+
36
+ def release(self) -> None:
37
+ try:
38
+ self.path.unlink(missing_ok=True)
39
+ except OSError as exc:
40
+ logger.warning(
41
+ "lock.release.failed",
42
+ path=str(self.path),
43
+ error=str(exc),
44
+ error_type=exc.__class__.__name__,
45
+ )
46
+
47
+ def __enter__(self) -> LockHandle:
48
+ return self
49
+
50
+ def __exit__(self, exc_type, exc, tb) -> None:
51
+ self.release()
52
+
53
+
54
+ def token_fingerprint(token: str) -> str:
55
+ digest = hashlib.sha256(token.encode("utf-8")).hexdigest()
56
+ return digest[:10]
57
+
58
+
59
+ def lock_path_for_config(config_path: Path) -> Path:
60
+ return config_path.with_suffix(".lock")
61
+
62
+
63
+ def acquire_lock(
64
+ *, config_path: Path, token_fingerprint: str | None = None
65
+ ) -> LockHandle:
66
+ cfg_path = config_path.expanduser().resolve()
67
+ lock_path = lock_path_for_config(cfg_path)
68
+ try:
69
+ lock_path.parent.mkdir(parents=True, exist_ok=True)
70
+ existing = _read_lock_info(lock_path)
71
+ if existing:
72
+ if (
73
+ token_fingerprint
74
+ and existing.token_fingerprint
75
+ and existing.token_fingerprint != token_fingerprint
76
+ ):
77
+ _write_lock_info(
78
+ lock_path,
79
+ pid=os.getpid(),
80
+ token_fingerprint=token_fingerprint,
81
+ )
82
+ return LockHandle(path=lock_path)
83
+ if _pid_running(existing.pid):
84
+ raise LockError(path=lock_path, state="running") from None
85
+ _write_lock_info(
86
+ lock_path,
87
+ pid=os.getpid(),
88
+ token_fingerprint=token_fingerprint,
89
+ )
90
+ except OSError as exc:
91
+ raise LockError(path=lock_path, state=str(exc)) from exc
92
+
93
+ return LockHandle(path=lock_path)
94
+
95
+
96
+ def _read_lock_info(path: Path) -> LockInfo | None:
97
+ try:
98
+ raw = path.read_text(encoding="utf-8")
99
+ except FileNotFoundError:
100
+ return None
101
+ except OSError:
102
+ return None
103
+ try:
104
+ data = json.loads(raw)
105
+ except json.JSONDecodeError:
106
+ return None
107
+ if not isinstance(data, dict):
108
+ return None
109
+ pid = data.get("pid")
110
+ if isinstance(pid, bool) or not isinstance(pid, int):
111
+ pid = None
112
+ token_hint = data.get("token_fingerprint")
113
+ if not isinstance(token_hint, str):
114
+ token_hint = None
115
+ return LockInfo(
116
+ pid=pid,
117
+ token_fingerprint=token_hint,
118
+ )
119
+
120
+
121
+ def _write_lock_info(path: Path, *, pid: int, token_fingerprint: str | None) -> None:
122
+ payload = {"pid": pid, "token_fingerprint": token_fingerprint}
123
+ path.write_text(
124
+ json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8"
125
+ )
126
+
127
+
128
+ def _pid_running(pid: int | None) -> bool:
129
+ if pid is None or pid <= 0:
130
+ return False
131
+ try:
132
+ os.kill(pid, 0)
133
+ except ProcessLookupError:
134
+ return False
135
+ except PermissionError:
136
+ return True
137
+ except OSError:
138
+ return False
139
+ return True
140
+
141
+
142
+ def _format_lock_message(path: Path, state: str) -> str:
143
+ if state != "running":
144
+ return f"error: lock failed: {state}"
145
+ header = "error: already running"
146
+ display_path = _display_lock_path(path)
147
+ lines = [header, f"remove {display_path} if stale"]
148
+ return "\n".join(lines)
149
+
150
+
151
+ def _display_lock_path(path: Path) -> str:
152
+ home = Path.home()
153
+ try:
154
+ resolved = path.expanduser().resolve()
155
+ rel = resolved.relative_to(home)
156
+ return f"~/{rel}"
157
+ except (ValueError, OSError):
158
+ return str(path)
takopi/logging.py ADDED
@@ -0,0 +1,283 @@
1
+ from __future__ import annotations
2
+
3
+ import errno
4
+ import io
5
+ import os
6
+ import re
7
+ import sys
8
+ from contextlib import contextmanager
9
+ from contextvars import ContextVar
10
+ from typing import Any, TextIO, cast
11
+
12
+ import structlog
13
+ from structlog.types import Processor
14
+
15
+ TELEGRAM_TOKEN_RE = re.compile(r"bot\d+:[A-Za-z0-9_-]+")
16
+ TELEGRAM_BARE_TOKEN_RE = re.compile(r"\b\d+:[A-Za-z0-9_-]{10,}\b")
17
+
18
+ _LEVELS: dict[str, int] = {
19
+ "debug": 10,
20
+ "info": 20,
21
+ "warning": 30,
22
+ "error": 40,
23
+ "exception": 40,
24
+ "critical": 50,
25
+ }
26
+
27
+ _MIN_LEVEL = _LEVELS["info"]
28
+ _PIPELINE_LEVEL_NAME = "debug"
29
+
30
+ _suppress_below: ContextVar[int | None] = ContextVar(
31
+ "takopi_suppress_below", default=None
32
+ )
33
+ _log_file_handle: TextIO | None = None
34
+
35
+
36
+ def _truthy(value: str | None) -> bool:
37
+ if value is None:
38
+ return False
39
+ return value.strip().lower() in {"1", "true", "yes", "on"}
40
+
41
+
42
+ def _level_value(value: str | None, *, default: str = "info") -> int:
43
+ if not value:
44
+ return _LEVELS[default]
45
+ level = _LEVELS.get(value.strip().lower())
46
+ return level if level is not None else _LEVELS[default]
47
+
48
+
49
+ def pipeline_log_level() -> str:
50
+ return _PIPELINE_LEVEL_NAME
51
+
52
+
53
+ def log_pipeline(logger: Any, event: str, **fields: Any) -> None:
54
+ if _PIPELINE_LEVEL_NAME == "info":
55
+ logger.info(event, **fields)
56
+ else:
57
+ logger.debug(event, **fields)
58
+
59
+
60
+ def _drop_below_level(
61
+ logger: Any, method_name: str, event_dict: dict[str, Any]
62
+ ) -> dict[str, Any]:
63
+ level_value = _LEVELS.get(method_name, 0)
64
+ if level_value < _MIN_LEVEL:
65
+ raise structlog.DropEvent
66
+ suppress = _suppress_below.get()
67
+ if suppress is not None and level_value < suppress:
68
+ raise structlog.DropEvent
69
+ return event_dict
70
+
71
+
72
+ def _redact_text(value: str) -> str:
73
+ redacted = TELEGRAM_TOKEN_RE.sub("bot[REDACTED]", value)
74
+ return TELEGRAM_BARE_TOKEN_RE.sub("[REDACTED_TOKEN]", redacted)
75
+
76
+
77
+ def _redact_value(value: Any, memo: dict[int, Any]) -> Any:
78
+ if isinstance(value, str):
79
+ return _redact_text(value)
80
+ if isinstance(value, (bytes, bytearray)):
81
+ return _redact_text(value.decode("utf-8", errors="replace"))
82
+ obj_id = id(value)
83
+ if obj_id in memo:
84
+ return memo[obj_id]
85
+ if isinstance(value, dict):
86
+ redacted: dict[Any, Any] = {}
87
+ memo[obj_id] = redacted
88
+ for key, val in value.items():
89
+ redacted[key] = _redact_value(val, memo)
90
+ return redacted
91
+ if isinstance(value, list):
92
+ redacted_list: list[Any] = []
93
+ memo[obj_id] = redacted_list
94
+ redacted_list.extend(_redact_value(item, memo) for item in value)
95
+ return redacted_list
96
+ if isinstance(value, tuple):
97
+ redacted_tuple: list[Any] = []
98
+ memo[obj_id] = redacted_tuple
99
+ redacted_tuple.extend(_redact_value(item, memo) for item in value)
100
+ return tuple(redacted_tuple)
101
+ if isinstance(value, set):
102
+ redacted_set: set[Any] = set()
103
+ memo[obj_id] = redacted_set
104
+ redacted_set.update(_redact_value(item, memo) for item in value)
105
+ return redacted_set
106
+ return value
107
+
108
+
109
+ def _redact_event_dict(
110
+ _logger: Any, _method_name: str, event_dict: dict[str, Any]
111
+ ) -> dict[str, Any]:
112
+ return _redact_value(event_dict, memo={})
113
+
114
+
115
+ def _file_sink(
116
+ logger: Any, method_name: str, event_dict: dict[str, Any]
117
+ ) -> dict[str, Any]:
118
+ if _log_file_handle is None:
119
+ return event_dict
120
+ try:
121
+ payload = structlog.processors.JSONRenderer(default=str)(
122
+ logger, method_name, dict(event_dict)
123
+ )
124
+ if isinstance(payload, bytes):
125
+ payload = payload.decode("utf-8", errors="replace")
126
+ _log_file_handle.write(payload + "\n")
127
+ _log_file_handle.flush()
128
+ except Exception: # noqa: BLE001
129
+ return event_dict
130
+ return event_dict
131
+
132
+
133
+ def _add_logger_name(
134
+ logger: Any, method_name: str, event_dict: dict[str, Any]
135
+ ) -> dict[str, Any]:
136
+ if "logger" in event_dict:
137
+ return event_dict
138
+ name = event_dict.pop("logger_name", None)
139
+ if isinstance(name, str) and name:
140
+ event_dict["logger"] = name
141
+ return event_dict
142
+ fallback = getattr(logger, "name", None)
143
+ if isinstance(fallback, str) and fallback:
144
+ event_dict["logger"] = fallback
145
+ return event_dict
146
+
147
+
148
+ def get_logger(name: str | None = None) -> Any:
149
+ if name:
150
+ return structlog.get_logger(logger_name=name)
151
+ return structlog.get_logger()
152
+
153
+
154
+ def bind_run_context(**fields: Any) -> None:
155
+ structlog.contextvars.bind_contextvars(**fields)
156
+
157
+
158
+ def clear_context() -> None:
159
+ structlog.contextvars.clear_contextvars()
160
+
161
+
162
+ class SafeWriter(io.TextIOBase):
163
+ def __init__(self, stream: Any) -> None:
164
+ self._stream = stream
165
+ self._closed = False
166
+
167
+ def write(self, message: str) -> int:
168
+ if self._closed:
169
+ return 0
170
+ try:
171
+ return self._stream.write(message)
172
+ except (BrokenPipeError, ValueError):
173
+ self._close()
174
+ return 0
175
+ except OSError as exc:
176
+ if exc.errno == errno.EPIPE:
177
+ self._close()
178
+ return 0
179
+ raise
180
+
181
+ def flush(self) -> None:
182
+ if self._closed:
183
+ return
184
+ try:
185
+ self._stream.flush()
186
+ except (BrokenPipeError, ValueError):
187
+ self._close()
188
+ except OSError as exc:
189
+ if exc.errno == errno.EPIPE:
190
+ self._close()
191
+ return
192
+ raise
193
+
194
+ def isatty(self) -> bool:
195
+ isatty = getattr(self._stream, "isatty", None)
196
+ return bool(isatty()) if callable(isatty) else False
197
+
198
+ def _close(self) -> None:
199
+ if self._closed:
200
+ return
201
+ self._closed = True
202
+ try:
203
+ self._stream.close()
204
+ except Exception: # noqa: BLE001
205
+ return
206
+
207
+
208
+ def setup_logging(
209
+ *, debug: bool = False, cache_logger_on_first_use: bool = False
210
+ ) -> None:
211
+ global _MIN_LEVEL, _PIPELINE_LEVEL_NAME
212
+ global _log_file_handle
213
+
214
+ level_name = os.environ.get("TAKOPI_LOG_LEVEL")
215
+ if debug:
216
+ level_name = "debug"
217
+ _MIN_LEVEL = _level_value(level_name, default="info")
218
+
219
+ trace_pipeline = _truthy(os.environ.get("TAKOPI_TRACE_PIPELINE"))
220
+ _PIPELINE_LEVEL_NAME = "info" if trace_pipeline else "debug"
221
+
222
+ format_value = os.environ.get("TAKOPI_LOG_FORMAT", "console").strip().lower()
223
+ color_override = os.environ.get("TAKOPI_LOG_COLOR")
224
+ is_tty = sys.stdout.isatty() if color_override is None else _truthy(color_override)
225
+ if format_value == "json":
226
+ renderer: Any = structlog.processors.JSONRenderer(default=str)
227
+ else:
228
+ renderer = structlog.dev.ConsoleRenderer(colors=is_tty)
229
+
230
+ safe_stream = cast(TextIO, SafeWriter(sys.stdout))
231
+ log_file = os.environ.get("TAKOPI_LOG_FILE")
232
+ if _log_file_handle is not None:
233
+ try:
234
+ _log_file_handle.close()
235
+ except Exception: # noqa: BLE001
236
+ _log_file_handle = None
237
+ else:
238
+ _log_file_handle = None
239
+ if log_file:
240
+ try:
241
+ _log_file_handle = open( # noqa: SIM115
242
+ log_file, "a", encoding="utf-8"
243
+ )
244
+ except OSError:
245
+ _log_file_handle = None
246
+
247
+ processors = cast(
248
+ list[Processor],
249
+ [
250
+ _drop_below_level,
251
+ structlog.contextvars.merge_contextvars,
252
+ structlog.processors.TimeStamper(fmt="iso", utc=True),
253
+ structlog.processors.add_log_level,
254
+ _add_logger_name,
255
+ ],
256
+ )
257
+ if format_value == "json":
258
+ processors.append(structlog.processors.format_exc_info)
259
+ processors.extend(
260
+ cast(
261
+ list[Processor],
262
+ [
263
+ _redact_event_dict,
264
+ _file_sink,
265
+ cast(Processor, renderer),
266
+ ],
267
+ )
268
+ )
269
+
270
+ structlog.configure(
271
+ processors=processors,
272
+ logger_factory=structlog.PrintLoggerFactory(file=safe_stream),
273
+ cache_logger_on_first_use=cache_logger_on_first_use,
274
+ )
275
+
276
+
277
+ @contextmanager
278
+ def suppress_logs(level: str = "warning"):
279
+ token = _suppress_below.set(_level_value(level, default="warning"))
280
+ try:
281
+ yield
282
+ finally:
283
+ _suppress_below.reset(token)