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