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
takopi/runners/pi.py
ADDED
|
@@ -0,0 +1,523 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
from collections.abc import AsyncIterator
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from datetime import datetime, UTC
|
|
8
|
+
from pathlib import Path, PurePath
|
|
9
|
+
from typing import Any
|
|
10
|
+
from uuid import uuid4
|
|
11
|
+
|
|
12
|
+
import msgspec
|
|
13
|
+
|
|
14
|
+
from ..backends import EngineBackend, EngineConfig
|
|
15
|
+
from ..config import ConfigError
|
|
16
|
+
from ..logging import get_logger
|
|
17
|
+
from ..model import (
|
|
18
|
+
Action,
|
|
19
|
+
ActionEvent,
|
|
20
|
+
ActionKind,
|
|
21
|
+
ActionLevel,
|
|
22
|
+
ActionPhase,
|
|
23
|
+
CompletedEvent,
|
|
24
|
+
EngineId,
|
|
25
|
+
ResumeToken,
|
|
26
|
+
StartedEvent,
|
|
27
|
+
TakopiEvent,
|
|
28
|
+
)
|
|
29
|
+
from ..runner import JsonlSubprocessRunner, ResumeTokenMixin, Runner
|
|
30
|
+
from .run_options import get_run_options
|
|
31
|
+
from ..schemas import pi as pi_schema
|
|
32
|
+
from ..utils.paths import get_run_base_dir
|
|
33
|
+
from .tool_actions import tool_kind_and_title
|
|
34
|
+
|
|
35
|
+
logger = get_logger(__name__)
|
|
36
|
+
|
|
37
|
+
ENGINE: EngineId = "pi"
|
|
38
|
+
|
|
39
|
+
_RESUME_RE = re.compile(r"(?im)^\s*`?pi\s+--session\s+(?P<token>.+?)`?\s*$")
|
|
40
|
+
|
|
41
|
+
_SESSION_ID_PREFIX_LEN = 8
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(slots=True)
|
|
45
|
+
class PiStreamState:
|
|
46
|
+
resume: ResumeToken
|
|
47
|
+
allow_id_promotion: bool = False
|
|
48
|
+
pending_actions: dict[str, Action] = field(default_factory=dict)
|
|
49
|
+
last_assistant_text: str | None = None
|
|
50
|
+
last_assistant_error: str | None = None
|
|
51
|
+
last_usage: dict[str, Any] | None = None
|
|
52
|
+
started: bool = False
|
|
53
|
+
note_seq: int = 0
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _looks_like_session_path(token: str) -> bool:
|
|
57
|
+
if not token:
|
|
58
|
+
return False
|
|
59
|
+
if token.endswith(".jsonl"):
|
|
60
|
+
return True
|
|
61
|
+
if "/" in token or "\\" in token:
|
|
62
|
+
return True
|
|
63
|
+
return token.startswith("~")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _short_session_id(session_id: str) -> str:
|
|
67
|
+
if not session_id:
|
|
68
|
+
return session_id
|
|
69
|
+
if "-" in session_id:
|
|
70
|
+
return session_id.split("-", 1)[0]
|
|
71
|
+
if len(session_id) > _SESSION_ID_PREFIX_LEN:
|
|
72
|
+
return session_id[:_SESSION_ID_PREFIX_LEN]
|
|
73
|
+
return session_id
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _maybe_promote_session_id(state: PiStreamState, session_id: str | None) -> None:
|
|
77
|
+
if not session_id:
|
|
78
|
+
return
|
|
79
|
+
if state.started:
|
|
80
|
+
return
|
|
81
|
+
if not state.allow_id_promotion:
|
|
82
|
+
return
|
|
83
|
+
if not _looks_like_session_path(state.resume.value):
|
|
84
|
+
return
|
|
85
|
+
state.resume = ResumeToken(engine=ENGINE, value=_short_session_id(session_id))
|
|
86
|
+
state.allow_id_promotion = False
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _action_event(
|
|
90
|
+
*,
|
|
91
|
+
phase: ActionPhase,
|
|
92
|
+
action: Action,
|
|
93
|
+
ok: bool | None = None,
|
|
94
|
+
message: str | None = None,
|
|
95
|
+
level: ActionLevel | None = None,
|
|
96
|
+
) -> ActionEvent:
|
|
97
|
+
return ActionEvent(
|
|
98
|
+
engine=ENGINE,
|
|
99
|
+
action=action,
|
|
100
|
+
phase=phase,
|
|
101
|
+
ok=ok,
|
|
102
|
+
message=message,
|
|
103
|
+
level=level,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _extract_text_blocks(content: Any) -> str | None:
|
|
108
|
+
if not isinstance(content, list):
|
|
109
|
+
return None
|
|
110
|
+
parts: list[str] = []
|
|
111
|
+
for item in content:
|
|
112
|
+
if not isinstance(item, dict):
|
|
113
|
+
continue
|
|
114
|
+
if item.get("type") != "text":
|
|
115
|
+
continue
|
|
116
|
+
text = item.get("text")
|
|
117
|
+
if isinstance(text, str) and text:
|
|
118
|
+
parts.append(text)
|
|
119
|
+
if not parts:
|
|
120
|
+
return None
|
|
121
|
+
return "".join(parts).strip() or None
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _assistant_error(message: dict[str, Any]) -> str | None:
|
|
125
|
+
stop_reason = message.get("stopReason")
|
|
126
|
+
if stop_reason in {"error", "aborted"}:
|
|
127
|
+
error = message.get("errorMessage")
|
|
128
|
+
if isinstance(error, str) and error:
|
|
129
|
+
return error
|
|
130
|
+
return f"pi run {stop_reason}"
|
|
131
|
+
return None
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _tool_kind_and_title(
|
|
135
|
+
name: str,
|
|
136
|
+
args: dict[str, Any],
|
|
137
|
+
) -> tuple[ActionKind, str]:
|
|
138
|
+
return tool_kind_and_title(name, args, path_keys=("path",))
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _last_assistant_message(messages: Any) -> dict[str, Any] | None:
|
|
142
|
+
if not isinstance(messages, list):
|
|
143
|
+
return None
|
|
144
|
+
for item in reversed(messages):
|
|
145
|
+
if isinstance(item, dict) and item.get("role") == "assistant":
|
|
146
|
+
return item
|
|
147
|
+
return None
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def translate_pi_event(
|
|
151
|
+
event: pi_schema.PiEvent,
|
|
152
|
+
*,
|
|
153
|
+
title: str,
|
|
154
|
+
meta: dict[str, Any] | None,
|
|
155
|
+
state: PiStreamState,
|
|
156
|
+
) -> list[TakopiEvent]:
|
|
157
|
+
out: list[TakopiEvent] = []
|
|
158
|
+
if isinstance(event, pi_schema.SessionHeader):
|
|
159
|
+
_maybe_promote_session_id(state, event.id)
|
|
160
|
+
if not state.started:
|
|
161
|
+
out.append(
|
|
162
|
+
StartedEvent(
|
|
163
|
+
engine=ENGINE,
|
|
164
|
+
resume=state.resume,
|
|
165
|
+
title=title,
|
|
166
|
+
meta=meta or None,
|
|
167
|
+
)
|
|
168
|
+
)
|
|
169
|
+
state.started = True
|
|
170
|
+
return out
|
|
171
|
+
|
|
172
|
+
if not state.started:
|
|
173
|
+
out.append(
|
|
174
|
+
StartedEvent(
|
|
175
|
+
engine=ENGINE,
|
|
176
|
+
resume=state.resume,
|
|
177
|
+
title=title,
|
|
178
|
+
meta=meta or None,
|
|
179
|
+
)
|
|
180
|
+
)
|
|
181
|
+
state.started = True
|
|
182
|
+
|
|
183
|
+
match event:
|
|
184
|
+
case pi_schema.ToolExecutionStart(
|
|
185
|
+
toolCallId=tool_id, toolName=tool_name, args=args
|
|
186
|
+
):
|
|
187
|
+
if not isinstance(args, dict):
|
|
188
|
+
args = {}
|
|
189
|
+
if isinstance(tool_id, str) and tool_id:
|
|
190
|
+
name = str(tool_name or "tool")
|
|
191
|
+
kind, title_str = _tool_kind_and_title(name, args)
|
|
192
|
+
detail: dict[str, Any] = {"tool_name": name, "args": args}
|
|
193
|
+
if kind == "file_change":
|
|
194
|
+
path = args.get("path")
|
|
195
|
+
if path:
|
|
196
|
+
detail["changes"] = [{"path": str(path), "kind": "update"}]
|
|
197
|
+
action = Action(id=tool_id, kind=kind, title=title_str, detail=detail)
|
|
198
|
+
state.pending_actions[action.id] = action
|
|
199
|
+
out.append(_action_event(phase="started", action=action))
|
|
200
|
+
return out
|
|
201
|
+
|
|
202
|
+
case pi_schema.ToolExecutionEnd(
|
|
203
|
+
toolCallId=tool_id, toolName=tool_name, result=result, isError=is_error
|
|
204
|
+
):
|
|
205
|
+
if isinstance(tool_id, str) and tool_id:
|
|
206
|
+
action = state.pending_actions.pop(tool_id, None)
|
|
207
|
+
name = str(tool_name or "tool")
|
|
208
|
+
if action is None:
|
|
209
|
+
action = Action(id=tool_id, kind="tool", title=name, detail={})
|
|
210
|
+
detail = dict(action.detail)
|
|
211
|
+
detail["result"] = result
|
|
212
|
+
detail["is_error"] = is_error
|
|
213
|
+
out.append(
|
|
214
|
+
_action_event(
|
|
215
|
+
phase="completed",
|
|
216
|
+
action=Action(
|
|
217
|
+
id=action.id,
|
|
218
|
+
kind=action.kind,
|
|
219
|
+
title=action.title,
|
|
220
|
+
detail=detail,
|
|
221
|
+
),
|
|
222
|
+
ok=not is_error,
|
|
223
|
+
)
|
|
224
|
+
)
|
|
225
|
+
return out
|
|
226
|
+
|
|
227
|
+
case pi_schema.MessageEnd(message=message):
|
|
228
|
+
if isinstance(message, dict) and message.get("role") == "assistant":
|
|
229
|
+
text = _extract_text_blocks(message.get("content"))
|
|
230
|
+
if text:
|
|
231
|
+
state.last_assistant_text = text
|
|
232
|
+
usage = message.get("usage")
|
|
233
|
+
if isinstance(usage, dict):
|
|
234
|
+
state.last_usage = usage
|
|
235
|
+
error = _assistant_error(message)
|
|
236
|
+
if error:
|
|
237
|
+
state.last_assistant_error = error
|
|
238
|
+
return out
|
|
239
|
+
|
|
240
|
+
case pi_schema.AgentEnd(messages=messages):
|
|
241
|
+
assistant = _last_assistant_message(messages)
|
|
242
|
+
if assistant:
|
|
243
|
+
text = _extract_text_blocks(assistant.get("content"))
|
|
244
|
+
if text:
|
|
245
|
+
state.last_assistant_text = text
|
|
246
|
+
usage = assistant.get("usage")
|
|
247
|
+
if isinstance(usage, dict):
|
|
248
|
+
state.last_usage = usage
|
|
249
|
+
error = _assistant_error(assistant)
|
|
250
|
+
if error:
|
|
251
|
+
state.last_assistant_error = error
|
|
252
|
+
|
|
253
|
+
ok = state.last_assistant_error is None
|
|
254
|
+
error = state.last_assistant_error
|
|
255
|
+
answer = state.last_assistant_text or ""
|
|
256
|
+
|
|
257
|
+
out.append(
|
|
258
|
+
CompletedEvent(
|
|
259
|
+
engine=ENGINE,
|
|
260
|
+
ok=ok,
|
|
261
|
+
answer=answer,
|
|
262
|
+
resume=state.resume,
|
|
263
|
+
error=error,
|
|
264
|
+
usage=state.last_usage,
|
|
265
|
+
)
|
|
266
|
+
)
|
|
267
|
+
return out
|
|
268
|
+
|
|
269
|
+
case _:
|
|
270
|
+
return out
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
class PiRunner(ResumeTokenMixin, JsonlSubprocessRunner):
|
|
274
|
+
engine: EngineId = ENGINE
|
|
275
|
+
resume_re: re.Pattern[str] = _RESUME_RE
|
|
276
|
+
session_title: str = "pi"
|
|
277
|
+
logger = logger
|
|
278
|
+
|
|
279
|
+
def __init__(
|
|
280
|
+
self,
|
|
281
|
+
*,
|
|
282
|
+
extra_args: list[str],
|
|
283
|
+
model: str | None,
|
|
284
|
+
provider: str | None,
|
|
285
|
+
) -> None:
|
|
286
|
+
self.extra_args = extra_args
|
|
287
|
+
self.model = model
|
|
288
|
+
self.provider = provider
|
|
289
|
+
|
|
290
|
+
def format_resume(self, token: ResumeToken) -> str:
|
|
291
|
+
if token.engine != ENGINE:
|
|
292
|
+
raise RuntimeError(f"resume token is for engine {token.engine!r}")
|
|
293
|
+
return f"`pi --session {self._quote_token(token.value)}`"
|
|
294
|
+
|
|
295
|
+
def run(
|
|
296
|
+
self, prompt: str, resume: ResumeToken | None
|
|
297
|
+
) -> AsyncIterator[TakopiEvent]:
|
|
298
|
+
return super().run(prompt, resume)
|
|
299
|
+
|
|
300
|
+
def extract_resume(self, text: str | None) -> ResumeToken | None:
|
|
301
|
+
if not text:
|
|
302
|
+
return None
|
|
303
|
+
found: str | None = None
|
|
304
|
+
for match in self.resume_re.finditer(text):
|
|
305
|
+
token = match.group("token")
|
|
306
|
+
if not token:
|
|
307
|
+
continue
|
|
308
|
+
token = token.strip()
|
|
309
|
+
if len(token) >= 2 and token[0] == token[-1] and token[0] in {'"', "'"}:
|
|
310
|
+
token = token[1:-1]
|
|
311
|
+
found = token
|
|
312
|
+
if not found:
|
|
313
|
+
return None
|
|
314
|
+
return ResumeToken(engine=self.engine, value=found)
|
|
315
|
+
|
|
316
|
+
def command(self) -> str:
|
|
317
|
+
return "pi"
|
|
318
|
+
|
|
319
|
+
def build_args(
|
|
320
|
+
self,
|
|
321
|
+
prompt: str,
|
|
322
|
+
resume: ResumeToken | None,
|
|
323
|
+
*,
|
|
324
|
+
state: PiStreamState,
|
|
325
|
+
) -> list[str]:
|
|
326
|
+
run_options = get_run_options()
|
|
327
|
+
args: list[str] = [*self.extra_args, "--print", "--mode", "json"]
|
|
328
|
+
if self.provider:
|
|
329
|
+
args.extend(["--provider", self.provider])
|
|
330
|
+
model = self.model
|
|
331
|
+
if run_options is not None and run_options.model:
|
|
332
|
+
model = run_options.model
|
|
333
|
+
if model:
|
|
334
|
+
args.extend(["--model", model])
|
|
335
|
+
args.extend(["--session", state.resume.value])
|
|
336
|
+
args.append(self._sanitize_prompt(prompt))
|
|
337
|
+
return args
|
|
338
|
+
|
|
339
|
+
def stdin_payload(
|
|
340
|
+
self,
|
|
341
|
+
prompt: str,
|
|
342
|
+
resume: ResumeToken | None,
|
|
343
|
+
*,
|
|
344
|
+
state: PiStreamState,
|
|
345
|
+
) -> bytes | None:
|
|
346
|
+
return None
|
|
347
|
+
|
|
348
|
+
def env(self, *, state: PiStreamState) -> dict[str, str] | None:
|
|
349
|
+
env = dict(os.environ)
|
|
350
|
+
env.setdefault("NO_COLOR", "1")
|
|
351
|
+
env.setdefault("CI", "1")
|
|
352
|
+
return env
|
|
353
|
+
|
|
354
|
+
def new_state(self, prompt: str, resume: ResumeToken | None) -> PiStreamState:
|
|
355
|
+
if resume is None:
|
|
356
|
+
session_path = self._new_session_path()
|
|
357
|
+
token = ResumeToken(engine=ENGINE, value=session_path)
|
|
358
|
+
return PiStreamState(
|
|
359
|
+
resume=token,
|
|
360
|
+
allow_id_promotion=True,
|
|
361
|
+
)
|
|
362
|
+
return PiStreamState(resume=resume)
|
|
363
|
+
|
|
364
|
+
def translate(
|
|
365
|
+
self,
|
|
366
|
+
data: pi_schema.PiEvent,
|
|
367
|
+
*,
|
|
368
|
+
state: PiStreamState,
|
|
369
|
+
resume: ResumeToken | None,
|
|
370
|
+
found_session: ResumeToken | None,
|
|
371
|
+
) -> list[TakopiEvent]:
|
|
372
|
+
meta: dict[str, Any] = {"cwd": os.getcwd()}
|
|
373
|
+
if self.model:
|
|
374
|
+
meta["model"] = self.model
|
|
375
|
+
if self.provider:
|
|
376
|
+
meta["provider"] = self.provider
|
|
377
|
+
return translate_pi_event(
|
|
378
|
+
data,
|
|
379
|
+
title=self.session_title,
|
|
380
|
+
meta=meta or None,
|
|
381
|
+
state=state,
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
def decode_jsonl(
|
|
385
|
+
self,
|
|
386
|
+
*,
|
|
387
|
+
line: bytes,
|
|
388
|
+
) -> pi_schema.PiEvent:
|
|
389
|
+
return pi_schema.decode_event(line)
|
|
390
|
+
|
|
391
|
+
def decode_error_events(
|
|
392
|
+
self,
|
|
393
|
+
*,
|
|
394
|
+
raw: str,
|
|
395
|
+
line: str,
|
|
396
|
+
error: Exception,
|
|
397
|
+
state: PiStreamState,
|
|
398
|
+
) -> list[TakopiEvent]:
|
|
399
|
+
if isinstance(error, msgspec.DecodeError):
|
|
400
|
+
self.get_logger().warning(
|
|
401
|
+
"jsonl.msgspec.invalid",
|
|
402
|
+
tag=self.tag(),
|
|
403
|
+
error=str(error),
|
|
404
|
+
error_type=error.__class__.__name__,
|
|
405
|
+
)
|
|
406
|
+
return []
|
|
407
|
+
return super().decode_error_events(
|
|
408
|
+
raw=raw,
|
|
409
|
+
line=line,
|
|
410
|
+
error=error,
|
|
411
|
+
state=state,
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
def process_error_events(
|
|
415
|
+
self,
|
|
416
|
+
rc: int,
|
|
417
|
+
*,
|
|
418
|
+
resume: ResumeToken | None,
|
|
419
|
+
found_session: ResumeToken | None,
|
|
420
|
+
state: PiStreamState,
|
|
421
|
+
) -> list[TakopiEvent]:
|
|
422
|
+
message = f"pi failed (rc={rc})."
|
|
423
|
+
resume_for_completed = found_session or resume or state.resume
|
|
424
|
+
return [
|
|
425
|
+
self.note_event(message, state=state),
|
|
426
|
+
CompletedEvent(
|
|
427
|
+
engine=ENGINE,
|
|
428
|
+
ok=False,
|
|
429
|
+
answer=state.last_assistant_text or "",
|
|
430
|
+
resume=resume_for_completed,
|
|
431
|
+
error=message,
|
|
432
|
+
usage=state.last_usage,
|
|
433
|
+
),
|
|
434
|
+
]
|
|
435
|
+
|
|
436
|
+
def stream_end_events(
|
|
437
|
+
self,
|
|
438
|
+
*,
|
|
439
|
+
resume: ResumeToken | None,
|
|
440
|
+
found_session: ResumeToken | None,
|
|
441
|
+
state: PiStreamState,
|
|
442
|
+
) -> list[TakopiEvent]:
|
|
443
|
+
resume_for_completed = found_session or resume or state.resume
|
|
444
|
+
message = "pi finished without an agent_end event"
|
|
445
|
+
return [
|
|
446
|
+
CompletedEvent(
|
|
447
|
+
engine=ENGINE,
|
|
448
|
+
ok=False,
|
|
449
|
+
answer=state.last_assistant_text or "",
|
|
450
|
+
resume=resume_for_completed,
|
|
451
|
+
error=message,
|
|
452
|
+
usage=state.last_usage,
|
|
453
|
+
)
|
|
454
|
+
]
|
|
455
|
+
|
|
456
|
+
def _new_session_path(self) -> str:
|
|
457
|
+
cwd = get_run_base_dir() or Path.cwd()
|
|
458
|
+
session_dir = _default_session_dir(cwd)
|
|
459
|
+
session_dir.mkdir(parents=True, exist_ok=True)
|
|
460
|
+
timestamp = datetime.now(UTC).isoformat()
|
|
461
|
+
safe_timestamp = timestamp.replace(":", "-").replace(".", "-")
|
|
462
|
+
token = uuid4().hex
|
|
463
|
+
filename = f"{safe_timestamp}_{token}.jsonl"
|
|
464
|
+
return str(session_dir / filename)
|
|
465
|
+
|
|
466
|
+
def _sanitize_prompt(self, prompt: str) -> str:
|
|
467
|
+
if prompt.startswith("-"):
|
|
468
|
+
return f" {prompt}"
|
|
469
|
+
return prompt
|
|
470
|
+
|
|
471
|
+
def _quote_token(self, token: str) -> str:
|
|
472
|
+
if not token:
|
|
473
|
+
return token
|
|
474
|
+
needs_quotes = any(ch.isspace() for ch in token)
|
|
475
|
+
if not needs_quotes and '"' not in token:
|
|
476
|
+
return token
|
|
477
|
+
escaped = token.replace('"', '\\"')
|
|
478
|
+
return f'"{escaped}"'
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def _default_session_dir(cwd: PurePath) -> Path:
|
|
482
|
+
agent_dir = os.environ.get("PI_CODING_AGENT_DIR")
|
|
483
|
+
base = Path(agent_dir).expanduser() if agent_dir else Path.home() / ".pi" / "agent"
|
|
484
|
+
cwd_str = str(cwd).lstrip("/\\")
|
|
485
|
+
safe_path_part = cwd_str.translate(str.maketrans({"/": "-", "\\": "-", ":": "-"}))
|
|
486
|
+
safe_path = f"--{safe_path_part}--"
|
|
487
|
+
return base / "sessions" / safe_path
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def build_runner(config: EngineConfig, config_path: Path) -> Runner:
|
|
491
|
+
extra_args_value = config.get("extra_args")
|
|
492
|
+
if extra_args_value is None:
|
|
493
|
+
extra_args = []
|
|
494
|
+
elif isinstance(extra_args_value, list) and all(
|
|
495
|
+
isinstance(x, str) for x in extra_args_value
|
|
496
|
+
):
|
|
497
|
+
extra_args = list(extra_args_value)
|
|
498
|
+
else:
|
|
499
|
+
raise ConfigError(
|
|
500
|
+
f"Invalid `pi.extra_args` in {config_path}; expected a list of strings."
|
|
501
|
+
)
|
|
502
|
+
|
|
503
|
+
model = config.get("model")
|
|
504
|
+
if model is not None and not isinstance(model, str):
|
|
505
|
+
raise ConfigError(f"Invalid `pi.model` in {config_path}; expected a string.")
|
|
506
|
+
|
|
507
|
+
provider = config.get("provider")
|
|
508
|
+
if provider is not None and not isinstance(provider, str):
|
|
509
|
+
raise ConfigError(f"Invalid `pi.provider` in {config_path}; expected a string.")
|
|
510
|
+
|
|
511
|
+
return PiRunner(
|
|
512
|
+
extra_args=extra_args,
|
|
513
|
+
model=model,
|
|
514
|
+
provider=provider,
|
|
515
|
+
)
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
BACKEND = EngineBackend(
|
|
519
|
+
id="pi",
|
|
520
|
+
build_runner=build_runner,
|
|
521
|
+
cli_cmd="pi",
|
|
522
|
+
install_cmd="npm install -g @mariozechner/pi-coding-agent",
|
|
523
|
+
)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterator
|
|
4
|
+
from contextlib import contextmanager
|
|
5
|
+
from contextvars import ContextVar, Token
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True, slots=True)
|
|
10
|
+
class EngineRunOptions:
|
|
11
|
+
model: str | None = None
|
|
12
|
+
reasoning: str | None = None
|
|
13
|
+
system: str | None = None
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
_RUN_OPTIONS: ContextVar[EngineRunOptions | None] = ContextVar(
|
|
17
|
+
"takopi.engine_run_options", default=None
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def get_run_options() -> EngineRunOptions | None:
|
|
22
|
+
return _RUN_OPTIONS.get()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def set_run_options(options: EngineRunOptions | None) -> Token:
|
|
26
|
+
return _RUN_OPTIONS.set(options)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def reset_run_options(token: Token) -> None:
|
|
30
|
+
_RUN_OPTIONS.reset(token)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@contextmanager
|
|
34
|
+
def apply_run_options(options: EngineRunOptions | None) -> Iterator[None]:
|
|
35
|
+
token = set_run_options(options)
|
|
36
|
+
try:
|
|
37
|
+
yield
|
|
38
|
+
finally:
|
|
39
|
+
reset_run_options(token)
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping, Sequence
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from ..model import ActionKind
|
|
7
|
+
from ..utils.paths import relativize_command, relativize_path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def tool_input_path(
|
|
11
|
+
tool_input: Mapping[str, Any],
|
|
12
|
+
*,
|
|
13
|
+
path_keys: Sequence[str],
|
|
14
|
+
) -> str | None:
|
|
15
|
+
for key in path_keys:
|
|
16
|
+
value = tool_input.get(key)
|
|
17
|
+
if isinstance(value, str) and value:
|
|
18
|
+
return value
|
|
19
|
+
return None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def tool_kind_and_title(
|
|
23
|
+
tool_name: str,
|
|
24
|
+
tool_input: Mapping[str, Any],
|
|
25
|
+
*,
|
|
26
|
+
path_keys: Sequence[str],
|
|
27
|
+
task_kind: ActionKind = "subagent",
|
|
28
|
+
) -> tuple[ActionKind, str]:
|
|
29
|
+
name_lower = tool_name.lower()
|
|
30
|
+
|
|
31
|
+
if name_lower in {"bash", "shell", "killshell"}:
|
|
32
|
+
command = tool_input.get("command")
|
|
33
|
+
display = relativize_command(str(command or tool_name))
|
|
34
|
+
return "command", display
|
|
35
|
+
|
|
36
|
+
if name_lower in {"edit", "write", "notebookedit", "multiedit"}:
|
|
37
|
+
path = tool_input_path(tool_input, path_keys=path_keys)
|
|
38
|
+
if path:
|
|
39
|
+
return "file_change", relativize_path(str(path))
|
|
40
|
+
return "file_change", str(tool_name)
|
|
41
|
+
|
|
42
|
+
if name_lower == "read":
|
|
43
|
+
path = tool_input_path(tool_input, path_keys=path_keys)
|
|
44
|
+
if path:
|
|
45
|
+
return "tool", f"read: `{relativize_path(str(path))}`"
|
|
46
|
+
return "tool", "read"
|
|
47
|
+
|
|
48
|
+
if name_lower == "glob":
|
|
49
|
+
pattern = tool_input.get("pattern")
|
|
50
|
+
if pattern:
|
|
51
|
+
return "tool", f"glob: `{pattern}`"
|
|
52
|
+
return "tool", "glob"
|
|
53
|
+
|
|
54
|
+
if name_lower == "grep":
|
|
55
|
+
pattern = tool_input.get("pattern")
|
|
56
|
+
if pattern:
|
|
57
|
+
return "tool", f"grep: {pattern}"
|
|
58
|
+
return "tool", "grep"
|
|
59
|
+
|
|
60
|
+
if name_lower == "find":
|
|
61
|
+
pattern = tool_input.get("pattern")
|
|
62
|
+
if pattern:
|
|
63
|
+
return "tool", f"find: {pattern}"
|
|
64
|
+
return "tool", "find"
|
|
65
|
+
|
|
66
|
+
if name_lower == "ls":
|
|
67
|
+
path = tool_input_path(tool_input, path_keys=path_keys)
|
|
68
|
+
if path:
|
|
69
|
+
return "tool", f"ls: `{relativize_path(str(path))}`"
|
|
70
|
+
return "tool", "ls"
|
|
71
|
+
|
|
72
|
+
if name_lower in {"websearch", "web_search"}:
|
|
73
|
+
query = tool_input.get("query")
|
|
74
|
+
return "web_search", str(query or "search")
|
|
75
|
+
|
|
76
|
+
if name_lower in {"webfetch", "web_fetch"}:
|
|
77
|
+
url = tool_input.get("url")
|
|
78
|
+
return "web_search", str(url or "fetch")
|
|
79
|
+
|
|
80
|
+
if name_lower in {"todowrite", "todoread"}:
|
|
81
|
+
return "note", "update todos" if "write" in name_lower else "read todos"
|
|
82
|
+
|
|
83
|
+
if name_lower == "askuserquestion":
|
|
84
|
+
return "note", "ask user"
|
|
85
|
+
|
|
86
|
+
if name_lower in {"task", "agent"}:
|
|
87
|
+
desc = tool_input.get("description") or tool_input.get("prompt")
|
|
88
|
+
return task_kind, str(desc or tool_name)
|
|
89
|
+
|
|
90
|
+
return "tool", tool_name
|