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
|
@@ -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
|
takopi/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
|
takopi/context.py
ADDED
takopi/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}`"
|
takopi/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
|
+
)
|
takopi/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
|
+
)
|
takopi/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))
|