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
@@ -0,0 +1,124 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ from .config import ConfigError, ensure_table, read_config, write_config
7
+ from .logging import get_logger
8
+
9
+ logger = get_logger(__name__)
10
+
11
+
12
+ def _ensure_subtable(
13
+ parent: dict[str, Any],
14
+ key: str,
15
+ *,
16
+ config_path: Path,
17
+ label: str,
18
+ ) -> dict[str, Any] | None:
19
+ value = parent.get(key)
20
+ if value is None:
21
+ return None
22
+ if not isinstance(value, dict):
23
+ raise ConfigError(f"Invalid `{label}` in {config_path}; expected a table.")
24
+ return value
25
+
26
+
27
+ def _migrate_legacy_telegram(config: dict[str, Any], *, config_path: Path) -> bool:
28
+ has_legacy = "bot_token" in config or "chat_id" in config
29
+ if not has_legacy:
30
+ return False
31
+
32
+ transports = ensure_table(config, "transports", config_path=config_path)
33
+ telegram = ensure_table(
34
+ transports,
35
+ "telegram",
36
+ config_path=config_path,
37
+ label="transports.telegram",
38
+ )
39
+
40
+ if "bot_token" in config and "bot_token" not in telegram:
41
+ telegram["bot_token"] = config["bot_token"]
42
+ if "chat_id" in config and "chat_id" not in telegram:
43
+ telegram["chat_id"] = config["chat_id"]
44
+
45
+ config.pop("bot_token", None)
46
+ config.pop("chat_id", None)
47
+ config.setdefault("transport", "telegram")
48
+ return True
49
+
50
+
51
+ def _migrate_topics_scope(config: dict[str, Any], *, config_path: Path) -> bool:
52
+ transports = _ensure_subtable(
53
+ config,
54
+ "transports",
55
+ config_path=config_path,
56
+ label="transports",
57
+ )
58
+ if transports is None:
59
+ return False
60
+
61
+ telegram = _ensure_subtable(
62
+ transports,
63
+ "telegram",
64
+ config_path=config_path,
65
+ label="transports.telegram",
66
+ )
67
+ if telegram is None:
68
+ return False
69
+
70
+ topics = _ensure_subtable(
71
+ telegram,
72
+ "topics",
73
+ config_path=config_path,
74
+ label="transports.telegram.topics",
75
+ )
76
+ if topics is None:
77
+ return False
78
+ if "mode" not in topics:
79
+ return False
80
+
81
+ if "scope" not in topics:
82
+ mode = topics.get("mode")
83
+ if not isinstance(mode, str):
84
+ raise ConfigError(
85
+ f"Invalid `transports.telegram.topics.mode` in {config_path}; "
86
+ "expected a string."
87
+ )
88
+ cleaned = mode.strip()
89
+ mapping = {
90
+ "multi_project_chat": "main",
91
+ "per_project_chat": "projects",
92
+ }
93
+ if cleaned not in mapping:
94
+ raise ConfigError(
95
+ f"Invalid `transports.telegram.topics.mode` in {config_path}; "
96
+ "expected 'multi_project_chat' or 'per_project_chat'."
97
+ )
98
+ topics["scope"] = mapping[cleaned]
99
+
100
+ topics.pop("mode", None)
101
+ return True
102
+
103
+
104
+ def migrate_config(config: dict[str, Any], *, config_path: Path) -> list[str]:
105
+ applied: list[str] = []
106
+ if _migrate_legacy_telegram(config, config_path=config_path):
107
+ applied.append("legacy-telegram")
108
+ if _migrate_topics_scope(config, config_path=config_path):
109
+ applied.append("topics-scope")
110
+ return applied
111
+
112
+
113
+ def migrate_config_file(path: Path) -> list[str]:
114
+ config = read_config(path)
115
+ applied = migrate_config(config, config_path=path)
116
+ if applied:
117
+ write_config(config, path)
118
+ for migration in applied:
119
+ logger.info(
120
+ "config.migrated",
121
+ migration=migration,
122
+ path=str(path),
123
+ )
124
+ return applied
yee88/config_watch.py ADDED
@@ -0,0 +1,146 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+ from collections.abc import Awaitable, Callable, Iterable
7
+
8
+ from watchfiles import awatch
9
+
10
+ from .config import ConfigError
11
+ from .ids import RESERVED_CHAT_COMMANDS
12
+ from .logging import get_logger
13
+ from .runtime_loader import RuntimeSpec, build_runtime_spec
14
+ from .settings import TakopiSettings, load_settings
15
+ from .transport_runtime import TransportRuntime
16
+
17
+ logger = get_logger(__name__)
18
+
19
+ __all__ = [
20
+ "ConfigReload",
21
+ "config_status",
22
+ "watch_config",
23
+ ]
24
+
25
+
26
+ @dataclass(frozen=True, slots=True)
27
+ class ConfigReload:
28
+ settings: TakopiSettings
29
+ runtime_spec: RuntimeSpec
30
+ config_path: Path
31
+
32
+
33
+ def config_status(path: Path) -> tuple[str, tuple[int, int] | None]:
34
+ try:
35
+ stat = path.stat()
36
+ except FileNotFoundError:
37
+ return "missing", None
38
+ except OSError:
39
+ return "missing", None
40
+ if not path.is_file():
41
+ return "invalid", None
42
+ return "ok", (stat.st_mtime_ns, stat.st_size)
43
+
44
+
45
+ def _matches_config_path(candidate: str, config_path: Path) -> bool:
46
+ try:
47
+ return Path(candidate).resolve(strict=False) == config_path
48
+ except OSError:
49
+ return False
50
+
51
+
52
+ def _reload_config(
53
+ config_path: Path,
54
+ default_engine_override: str | None,
55
+ reserved: tuple[str, ...],
56
+ ) -> ConfigReload:
57
+ settings, resolved_path = load_settings(config_path)
58
+ spec = build_runtime_spec(
59
+ settings=settings,
60
+ config_path=resolved_path,
61
+ default_engine_override=default_engine_override,
62
+ reserved=reserved,
63
+ )
64
+ return ConfigReload(
65
+ settings=settings,
66
+ runtime_spec=spec,
67
+ config_path=resolved_path,
68
+ )
69
+
70
+
71
+ async def watch_config(
72
+ *,
73
+ config_path: Path,
74
+ runtime: TransportRuntime,
75
+ default_engine_override: str | None = None,
76
+ reserved: Iterable[str] = RESERVED_CHAT_COMMANDS,
77
+ on_reload: Callable[[ConfigReload], Awaitable[None]] | None = None,
78
+ ) -> None:
79
+ # Mutmut sets MUTANT_UNDER_TEST; disable watchers to avoid native crashes.
80
+ if os.environ.get("MUTANT_UNDER_TEST"):
81
+ return
82
+ reserved_tuple = tuple(reserved)
83
+ config_path = config_path.expanduser().resolve()
84
+ watch_root = config_path.parent
85
+ status, signature = config_status(config_path)
86
+ last_status = status
87
+ if status != "ok":
88
+ logger.warning("config.watch.unavailable", path=str(config_path), status=status)
89
+
90
+ async for changes in awatch(watch_root):
91
+ if not any(_matches_config_path(path, config_path) for _, path in changes):
92
+ continue
93
+
94
+ status, current = config_status(config_path)
95
+ if status != "ok":
96
+ if status != last_status:
97
+ logger.warning(
98
+ "config.watch.unavailable",
99
+ path=str(config_path),
100
+ status=status,
101
+ )
102
+ last_status = status
103
+ signature = None
104
+ continue
105
+
106
+ if last_status != "ok":
107
+ logger.info("config.watch.available", path=str(config_path))
108
+ last_status = status
109
+
110
+ if current == signature:
111
+ continue
112
+
113
+ try:
114
+ reload = _reload_config(
115
+ config_path,
116
+ default_engine_override,
117
+ reserved_tuple,
118
+ )
119
+ except ConfigError as exc:
120
+ logger.warning("config.reload.failed", error=str(exc))
121
+ signature = current
122
+ continue
123
+ except Exception as exc: # pragma: no cover - safety net
124
+ logger.exception(
125
+ "config.reload.crashed",
126
+ error=str(exc),
127
+ error_type=exc.__class__.__name__,
128
+ )
129
+ signature = current
130
+ continue
131
+
132
+ reload.runtime_spec.apply(runtime, config_path=reload.config_path)
133
+ logger.info("config.reload.applied", path=str(reload.config_path))
134
+ if on_reload is not None:
135
+ try:
136
+ await on_reload(reload)
137
+ except Exception as exc: # pragma: no cover - safety net
138
+ logger.exception(
139
+ "config.reload.callback_failed",
140
+ error=str(exc),
141
+ error_type=exc.__class__.__name__,
142
+ )
143
+
144
+ _, signature = config_status(config_path)
145
+ if signature is None:
146
+ signature = current
yee88/context.py ADDED
@@ -0,0 +1,9 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+
6
+ @dataclass(frozen=True, slots=True)
7
+ class RunContext:
8
+ project: str | None = None
9
+ branch: str | None = None
yee88/directives.py ADDED
@@ -0,0 +1,146 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+ from .config import ProjectsConfig
6
+ from .context import RunContext
7
+ from .model import EngineId
8
+
9
+
10
+ @dataclass(frozen=True, slots=True)
11
+ class ParsedDirectives:
12
+ prompt: str
13
+ engine: EngineId | None
14
+ project: str | None
15
+ branch: str | None
16
+
17
+
18
+ class DirectiveError(RuntimeError):
19
+ pass
20
+
21
+
22
+ def parse_directives(
23
+ text: str,
24
+ *,
25
+ engine_ids: tuple[EngineId, ...],
26
+ projects: ProjectsConfig,
27
+ ) -> ParsedDirectives:
28
+ if not text:
29
+ return ParsedDirectives(prompt="", engine=None, project=None, branch=None)
30
+
31
+ lines = text.splitlines()
32
+ idx = next((i for i, line in enumerate(lines) if line.strip()), None)
33
+ if idx is None:
34
+ return ParsedDirectives(prompt=text, engine=None, project=None, branch=None)
35
+
36
+ line = lines[idx].lstrip()
37
+ tokens = line.split()
38
+ if not tokens:
39
+ return ParsedDirectives(prompt=text, engine=None, project=None, branch=None)
40
+
41
+ engine_map = {engine.lower(): engine for engine in engine_ids}
42
+ project_map = {alias.lower(): alias for alias in projects.projects}
43
+
44
+ engine: EngineId | None = None
45
+ project: str | None = None
46
+ branch: str | None = None
47
+ consumed = 0
48
+
49
+ for token in tokens:
50
+ if token.startswith("/"):
51
+ name = token[1:]
52
+ if "@" in name:
53
+ name = name.split("@", 1)[0]
54
+ if not name:
55
+ break
56
+ key = name.lower()
57
+ engine_candidate = engine_map.get(key)
58
+ project_candidate = project_map.get(key)
59
+ if engine_candidate is not None:
60
+ if engine is not None:
61
+ raise DirectiveError("multiple engine directives")
62
+ engine = engine_candidate
63
+ consumed += 1
64
+ continue
65
+ if project_candidate is not None:
66
+ if project is not None:
67
+ raise DirectiveError("multiple project directives")
68
+ project = project_candidate
69
+ consumed += 1
70
+ continue
71
+ break
72
+ if token.startswith("@"):
73
+ value = token[1:]
74
+ if not value:
75
+ break
76
+ if branch is not None:
77
+ raise DirectiveError("multiple @branch directives")
78
+ branch = value
79
+ consumed += 1
80
+ continue
81
+ break
82
+
83
+ if consumed == 0:
84
+ return ParsedDirectives(prompt=text, engine=None, project=None, branch=None)
85
+
86
+ if consumed < len(tokens):
87
+ remainder = " ".join(tokens[consumed:])
88
+ lines[idx] = remainder
89
+ else:
90
+ lines.pop(idx)
91
+
92
+ prompt = "\n".join(lines).strip()
93
+ return ParsedDirectives(
94
+ prompt=prompt, engine=engine, project=project, branch=branch
95
+ )
96
+
97
+
98
+ def parse_context_line(
99
+ text: str | None, *, projects: ProjectsConfig
100
+ ) -> RunContext | None:
101
+ if not text:
102
+ return None
103
+ ctx: RunContext | None = None
104
+ for line in text.splitlines():
105
+ stripped = line.strip()
106
+ if stripped.startswith("`") and stripped.endswith("`") and len(stripped) > 1:
107
+ stripped = stripped[1:-1].strip()
108
+ elif stripped.startswith("`"):
109
+ stripped = stripped[1:].strip()
110
+ elif stripped.endswith("`"):
111
+ stripped = stripped[:-1].strip()
112
+ if not stripped.lower().startswith("ctx:"):
113
+ continue
114
+ content = stripped.split(":", 1)[1].strip()
115
+ if not content:
116
+ continue
117
+ tokens = content.split()
118
+ if not tokens:
119
+ continue
120
+ project = tokens[0]
121
+ branch = None
122
+ if len(tokens) >= 2:
123
+ if tokens[1] == "@" and len(tokens) >= 3:
124
+ branch = tokens[2]
125
+ elif tokens[1].startswith("@"):
126
+ branch = tokens[1][1:]
127
+ project_key = project.lower()
128
+ if project_key not in projects.projects:
129
+ raise DirectiveError(
130
+ f"unknown project {project!r} in ctx line; start a new thread or "
131
+ "add it back to your config"
132
+ )
133
+ ctx = RunContext(project=project_key, branch=branch)
134
+ return ctx
135
+
136
+
137
+ def format_context_line(
138
+ context: RunContext | None, *, projects: ProjectsConfig
139
+ ) -> str | None:
140
+ if context is None or context.project is None:
141
+ return None
142
+ project_cfg = projects.projects.get(context.project)
143
+ alias = project_cfg.alias if project_cfg is not None else context.project
144
+ if context.branch:
145
+ return f"`ctx: {alias} @{context.branch}`"
146
+ return f"`ctx: {alias}`"
yee88/engines.py ADDED
@@ -0,0 +1,53 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Iterable
4
+
5
+ from .backends import EngineBackend
6
+ from .config import ConfigError
7
+ from .plugins import ENGINE_GROUP, list_ids, load_plugin_backend
8
+ from .ids import RESERVED_ENGINE_IDS
9
+
10
+
11
+ def _validate_engine_backend(backend: object, ep) -> None:
12
+ if not isinstance(backend, EngineBackend):
13
+ raise TypeError(f"{ep.value} is not an EngineBackend")
14
+ if backend.id != ep.name:
15
+ raise ValueError(
16
+ f"{ep.value} engine id {backend.id!r} does not match entrypoint {ep.name!r}"
17
+ )
18
+
19
+
20
+ def get_backend(
21
+ engine_id: str, *, allowlist: Iterable[str] | None = None
22
+ ) -> EngineBackend:
23
+ if engine_id.lower() in RESERVED_ENGINE_IDS:
24
+ raise ConfigError(f"Engine id {engine_id!r} is reserved.")
25
+ backend = load_plugin_backend(
26
+ ENGINE_GROUP,
27
+ engine_id,
28
+ allowlist=allowlist,
29
+ validator=_validate_engine_backend,
30
+ kind_label="engine",
31
+ )
32
+ assert backend is not None
33
+ return backend
34
+
35
+
36
+ def list_backends(*, allowlist: Iterable[str] | None = None) -> list[EngineBackend]:
37
+ backends: list[EngineBackend] = []
38
+ for engine_id in list_backend_ids(allowlist=allowlist):
39
+ try:
40
+ backends.append(get_backend(engine_id, allowlist=allowlist))
41
+ except ConfigError:
42
+ continue
43
+ if not backends:
44
+ raise ConfigError("No engine backends are available.")
45
+ return backends
46
+
47
+
48
+ def list_backend_ids(*, allowlist: Iterable[str] | None = None) -> list[str]:
49
+ return list_ids(
50
+ ENGINE_GROUP,
51
+ allowlist=allowlist,
52
+ reserved_ids=RESERVED_ENGINE_IDS,
53
+ )
yee88/events.py ADDED
@@ -0,0 +1,170 @@
1
+ """Event factory helpers for runner implementations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from .model import (
8
+ Action,
9
+ ActionEvent,
10
+ ActionKind,
11
+ ActionLevel,
12
+ ActionPhase,
13
+ CompletedEvent,
14
+ EngineId,
15
+ ResumeToken,
16
+ StartedEvent,
17
+ )
18
+
19
+
20
+ class EventFactory:
21
+ __slots__ = ("engine", "_resume")
22
+
23
+ def __init__(self, engine: EngineId) -> None:
24
+ self.engine = engine
25
+ self._resume: ResumeToken | None = None
26
+
27
+ @property
28
+ def resume(self) -> ResumeToken | None:
29
+ return self._resume
30
+
31
+ def started(
32
+ self,
33
+ token: ResumeToken,
34
+ *,
35
+ title: str | None = None,
36
+ meta: dict[str, Any] | None = None,
37
+ ) -> StartedEvent:
38
+ if token.engine != self.engine:
39
+ raise RuntimeError(f"resume token is for engine {token.engine!r}")
40
+ if self._resume is not None and self._resume != token:
41
+ raise RuntimeError(
42
+ f"resume token mismatch: {self._resume.value} vs {token.value}"
43
+ )
44
+ self._resume = token
45
+ return StartedEvent(engine=self.engine, resume=token, title=title, meta=meta)
46
+
47
+ def action(
48
+ self,
49
+ *,
50
+ phase: ActionPhase,
51
+ action_id: str,
52
+ kind: ActionKind,
53
+ title: str,
54
+ detail: dict[str, Any] | None = None,
55
+ ok: bool | None = None,
56
+ message: str | None = None,
57
+ level: ActionLevel | None = None,
58
+ ) -> ActionEvent:
59
+ action = Action(
60
+ id=action_id,
61
+ kind=kind,
62
+ title=title,
63
+ detail=detail or {},
64
+ )
65
+ return ActionEvent(
66
+ engine=self.engine,
67
+ action=action,
68
+ phase=phase,
69
+ ok=ok,
70
+ message=message,
71
+ level=level,
72
+ )
73
+
74
+ def action_started(
75
+ self,
76
+ *,
77
+ action_id: str,
78
+ kind: ActionKind,
79
+ title: str,
80
+ detail: dict[str, Any] | None = None,
81
+ ) -> ActionEvent:
82
+ return self.action(
83
+ phase="started",
84
+ action_id=action_id,
85
+ kind=kind,
86
+ title=title,
87
+ detail=detail,
88
+ )
89
+
90
+ def action_updated(
91
+ self,
92
+ *,
93
+ action_id: str,
94
+ kind: ActionKind,
95
+ title: str,
96
+ detail: dict[str, Any] | None = None,
97
+ ) -> ActionEvent:
98
+ return self.action(
99
+ phase="updated",
100
+ action_id=action_id,
101
+ kind=kind,
102
+ title=title,
103
+ detail=detail,
104
+ )
105
+
106
+ def action_completed(
107
+ self,
108
+ *,
109
+ action_id: str,
110
+ kind: ActionKind,
111
+ title: str,
112
+ ok: bool,
113
+ detail: dict[str, Any] | None = None,
114
+ message: str | None = None,
115
+ level: ActionLevel | None = None,
116
+ ) -> ActionEvent:
117
+ return self.action(
118
+ phase="completed",
119
+ action_id=action_id,
120
+ kind=kind,
121
+ title=title,
122
+ detail=detail,
123
+ ok=ok,
124
+ message=message,
125
+ level=level,
126
+ )
127
+
128
+ def completed(
129
+ self,
130
+ *,
131
+ ok: bool,
132
+ answer: str,
133
+ resume: ResumeToken | None = None,
134
+ error: str | None = None,
135
+ usage: dict[str, Any] | None = None,
136
+ ) -> CompletedEvent:
137
+ resolved_resume = resume if resume is not None else self._resume
138
+ return CompletedEvent(
139
+ engine=self.engine,
140
+ ok=ok,
141
+ answer=answer,
142
+ resume=resolved_resume,
143
+ error=error,
144
+ usage=usage,
145
+ )
146
+
147
+ def completed_ok(
148
+ self,
149
+ *,
150
+ answer: str,
151
+ resume: ResumeToken | None = None,
152
+ usage: dict[str, Any] | None = None,
153
+ ) -> CompletedEvent:
154
+ return self.completed(ok=True, answer=answer, resume=resume, usage=usage)
155
+
156
+ def completed_error(
157
+ self,
158
+ *,
159
+ error: str,
160
+ answer: str = "",
161
+ resume: ResumeToken | None = None,
162
+ usage: dict[str, Any] | None = None,
163
+ ) -> CompletedEvent:
164
+ return self.completed(
165
+ ok=False,
166
+ answer=answer,
167
+ resume=resume,
168
+ error=error,
169
+ usage=usage,
170
+ )
yee88/ids.py ADDED
@@ -0,0 +1,17 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+
5
+ ID_PATTERN = r"^[a-z0-9_]{1,32}$"
6
+ _ID_RE = re.compile(ID_PATTERN)
7
+
8
+ RESERVED_CLI_COMMANDS = frozenset({"config", "doctor", "init", "plugins"})
9
+ RESERVED_CHAT_COMMANDS = frozenset(
10
+ {"cancel", "file", "new", "agent", "model", "reasoning", "trigger", "topic", "ctx"}
11
+ )
12
+ RESERVED_ENGINE_IDS = RESERVED_CLI_COMMANDS | RESERVED_CHAT_COMMANDS
13
+ RESERVED_COMMAND_IDS = RESERVED_CLI_COMMANDS | RESERVED_CHAT_COMMANDS
14
+
15
+
16
+ def is_valid_id(value: str) -> bool:
17
+ return bool(_ID_RE.fullmatch(value))