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.
- yee88/__init__.py +1 -0
- yee88/api.py +116 -0
- yee88/backends.py +25 -0
- yee88/backends_helpers.py +14 -0
- yee88/cli/__init__.py +228 -0
- yee88/cli/config.py +320 -0
- yee88/cli/doctor.py +173 -0
- yee88/cli/init.py +113 -0
- yee88/cli/onboarding_cmd.py +126 -0
- yee88/cli/plugins.py +196 -0
- yee88/cli/run.py +419 -0
- yee88/cli/topic.py +355 -0
- yee88/commands.py +134 -0
- yee88/config.py +142 -0
- yee88/config_migrations.py +124 -0
- yee88/config_watch.py +146 -0
- yee88/context.py +9 -0
- yee88/directives.py +146 -0
- yee88/engines.py +53 -0
- yee88/events.py +170 -0
- yee88/ids.py +17 -0
- yee88/lockfile.py +158 -0
- yee88/logging.py +283 -0
- yee88/markdown.py +298 -0
- yee88/model.py +77 -0
- yee88/plugins.py +312 -0
- yee88/presenter.py +25 -0
- yee88/progress.py +99 -0
- yee88/router.py +113 -0
- yee88/runner.py +712 -0
- yee88/runner_bridge.py +619 -0
- yee88/runners/__init__.py +1 -0
- yee88/runners/claude.py +483 -0
- yee88/runners/codex.py +656 -0
- yee88/runners/mock.py +221 -0
- yee88/runners/opencode.py +505 -0
- yee88/runners/pi.py +523 -0
- yee88/runners/run_options.py +39 -0
- yee88/runners/tool_actions.py +90 -0
- yee88/runtime_loader.py +207 -0
- yee88/scheduler.py +159 -0
- yee88/schemas/__init__.py +1 -0
- yee88/schemas/claude.py +238 -0
- yee88/schemas/codex.py +169 -0
- yee88/schemas/opencode.py +51 -0
- yee88/schemas/pi.py +117 -0
- yee88/settings.py +360 -0
- yee88/telegram/__init__.py +20 -0
- yee88/telegram/api_models.py +37 -0
- yee88/telegram/api_schemas.py +152 -0
- yee88/telegram/backend.py +163 -0
- yee88/telegram/bridge.py +425 -0
- yee88/telegram/chat_prefs.py +242 -0
- yee88/telegram/chat_sessions.py +112 -0
- yee88/telegram/client.py +409 -0
- yee88/telegram/client_api.py +539 -0
- yee88/telegram/commands/__init__.py +12 -0
- yee88/telegram/commands/agent.py +196 -0
- yee88/telegram/commands/cancel.py +116 -0
- yee88/telegram/commands/dispatch.py +111 -0
- yee88/telegram/commands/executor.py +449 -0
- yee88/telegram/commands/file_transfer.py +586 -0
- yee88/telegram/commands/handlers.py +45 -0
- yee88/telegram/commands/media.py +143 -0
- yee88/telegram/commands/menu.py +139 -0
- yee88/telegram/commands/model.py +215 -0
- yee88/telegram/commands/overrides.py +159 -0
- yee88/telegram/commands/parse.py +30 -0
- yee88/telegram/commands/plan.py +16 -0
- yee88/telegram/commands/reasoning.py +234 -0
- yee88/telegram/commands/reply.py +23 -0
- yee88/telegram/commands/topics.py +332 -0
- yee88/telegram/commands/trigger.py +143 -0
- yee88/telegram/context.py +140 -0
- yee88/telegram/engine_defaults.py +86 -0
- yee88/telegram/engine_overrides.py +105 -0
- yee88/telegram/files.py +178 -0
- yee88/telegram/loop.py +1822 -0
- yee88/telegram/onboarding.py +1088 -0
- yee88/telegram/outbox.py +177 -0
- yee88/telegram/parsing.py +239 -0
- yee88/telegram/render.py +198 -0
- yee88/telegram/state_store.py +88 -0
- yee88/telegram/topic_state.py +334 -0
- yee88/telegram/topics.py +256 -0
- yee88/telegram/trigger_mode.py +68 -0
- yee88/telegram/types.py +63 -0
- yee88/telegram/voice.py +110 -0
- yee88/transport.py +53 -0
- yee88/transport_runtime.py +323 -0
- yee88/transports.py +76 -0
- yee88/utils/__init__.py +1 -0
- yee88/utils/git.py +87 -0
- yee88/utils/json_state.py +21 -0
- yee88/utils/paths.py +47 -0
- yee88/utils/streams.py +44 -0
- yee88/utils/subprocess.py +86 -0
- yee88/worktrees.py +135 -0
- yee88-0.3.0.dist-info/METADATA +116 -0
- yee88-0.3.0.dist-info/RECORD +103 -0
- yee88-0.3.0.dist-info/WHEEL +4 -0
- yee88-0.3.0.dist-info/entry_points.txt +11 -0
- yee88-0.3.0.dist-info/licenses/LICENSE +21 -0
yee88/telegram/types.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True, slots=True)
|
|
8
|
+
class TelegramVoice:
|
|
9
|
+
file_id: str
|
|
10
|
+
mime_type: str | None
|
|
11
|
+
file_size: int | None
|
|
12
|
+
duration: int | None
|
|
13
|
+
raw: dict[str, Any]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True, slots=True)
|
|
17
|
+
class TelegramDocument:
|
|
18
|
+
file_id: str
|
|
19
|
+
file_name: str | None
|
|
20
|
+
mime_type: str | None
|
|
21
|
+
file_size: int | None
|
|
22
|
+
raw: dict[str, Any]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True, slots=True)
|
|
26
|
+
class TelegramIncomingMessage:
|
|
27
|
+
transport: str
|
|
28
|
+
chat_id: int
|
|
29
|
+
message_id: int
|
|
30
|
+
text: str
|
|
31
|
+
reply_to_message_id: int | None
|
|
32
|
+
reply_to_text: str | None
|
|
33
|
+
sender_id: int | None
|
|
34
|
+
reply_to_is_bot: bool | None = None
|
|
35
|
+
reply_to_username: str | None = None
|
|
36
|
+
media_group_id: str | None = None
|
|
37
|
+
thread_id: int | None = None
|
|
38
|
+
is_topic_message: bool | None = None
|
|
39
|
+
chat_type: str | None = None
|
|
40
|
+
is_forum: bool | None = None
|
|
41
|
+
voice: TelegramVoice | None = None
|
|
42
|
+
document: TelegramDocument | None = None
|
|
43
|
+
raw: dict[str, Any] | None = None
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def is_private(self) -> bool:
|
|
47
|
+
if self.chat_type is not None:
|
|
48
|
+
return self.chat_type == "private"
|
|
49
|
+
return self.chat_id > 0
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True, slots=True)
|
|
53
|
+
class TelegramCallbackQuery:
|
|
54
|
+
transport: str
|
|
55
|
+
chat_id: int
|
|
56
|
+
message_id: int
|
|
57
|
+
callback_query_id: str
|
|
58
|
+
data: str | None
|
|
59
|
+
sender_id: int | None
|
|
60
|
+
raw: dict[str, Any] | None = None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
TelegramIncomingUpdate = TelegramIncomingMessage | TelegramCallbackQuery
|
yee88/telegram/voice.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import io
|
|
4
|
+
from collections.abc import Awaitable, Callable
|
|
5
|
+
from typing import Protocol
|
|
6
|
+
|
|
7
|
+
from ..logging import get_logger
|
|
8
|
+
from openai import AsyncOpenAI, OpenAIError
|
|
9
|
+
|
|
10
|
+
from .client import BotClient
|
|
11
|
+
from .types import TelegramIncomingMessage
|
|
12
|
+
|
|
13
|
+
logger = get_logger(__name__)
|
|
14
|
+
|
|
15
|
+
__all__ = ["transcribe_voice"]
|
|
16
|
+
|
|
17
|
+
VOICE_TRANSCRIPTION_DISABLED_HINT = (
|
|
18
|
+
"voice transcription is disabled. enable it in config:\n"
|
|
19
|
+
"```toml\n"
|
|
20
|
+
"[transports.telegram]\n"
|
|
21
|
+
"voice_transcription = true\n"
|
|
22
|
+
"```"
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class VoiceTranscriber(Protocol):
|
|
27
|
+
async def transcribe(self, *, model: str, audio_bytes: bytes) -> str: ...
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class OpenAIVoiceTranscriber:
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
*,
|
|
34
|
+
base_url: str | None = None,
|
|
35
|
+
api_key: str | None = None,
|
|
36
|
+
) -> None:
|
|
37
|
+
self._base_url = base_url
|
|
38
|
+
self._api_key = api_key
|
|
39
|
+
|
|
40
|
+
async def transcribe(self, *, model: str, audio_bytes: bytes) -> str:
|
|
41
|
+
audio_file = io.BytesIO(audio_bytes)
|
|
42
|
+
audio_file.name = "voice.ogg"
|
|
43
|
+
async with AsyncOpenAI(
|
|
44
|
+
base_url=self._base_url,
|
|
45
|
+
api_key=self._api_key,
|
|
46
|
+
timeout=120,
|
|
47
|
+
) as client:
|
|
48
|
+
response = await client.audio.transcriptions.create(
|
|
49
|
+
model=model,
|
|
50
|
+
file=audio_file,
|
|
51
|
+
)
|
|
52
|
+
return response.text
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
async def transcribe_voice(
|
|
56
|
+
*,
|
|
57
|
+
bot: BotClient,
|
|
58
|
+
msg: TelegramIncomingMessage,
|
|
59
|
+
enabled: bool,
|
|
60
|
+
model: str,
|
|
61
|
+
max_bytes: int | None = None,
|
|
62
|
+
reply: Callable[..., Awaitable[None]],
|
|
63
|
+
transcriber: VoiceTranscriber | None = None,
|
|
64
|
+
base_url: str | None = None,
|
|
65
|
+
api_key: str | None = None,
|
|
66
|
+
) -> str | None:
|
|
67
|
+
voice = msg.voice
|
|
68
|
+
if voice is None:
|
|
69
|
+
return msg.text
|
|
70
|
+
if not enabled:
|
|
71
|
+
await reply(text=VOICE_TRANSCRIPTION_DISABLED_HINT)
|
|
72
|
+
return None
|
|
73
|
+
if (
|
|
74
|
+
max_bytes is not None
|
|
75
|
+
and voice.file_size is not None
|
|
76
|
+
and voice.file_size > max_bytes
|
|
77
|
+
):
|
|
78
|
+
await reply(text="voice message is too large to transcribe.")
|
|
79
|
+
return None
|
|
80
|
+
file_info = await bot.get_file(voice.file_id)
|
|
81
|
+
if file_info is None:
|
|
82
|
+
await reply(text="failed to fetch voice file.")
|
|
83
|
+
return None
|
|
84
|
+
audio_bytes = await bot.download_file(file_info.file_path)
|
|
85
|
+
if audio_bytes is None:
|
|
86
|
+
await reply(text="failed to download voice file.")
|
|
87
|
+
return None
|
|
88
|
+
if max_bytes is not None and len(audio_bytes) > max_bytes:
|
|
89
|
+
await reply(text="voice message is too large to transcribe.")
|
|
90
|
+
return None
|
|
91
|
+
if transcriber is None:
|
|
92
|
+
transcriber = OpenAIVoiceTranscriber(base_url=base_url, api_key=api_key)
|
|
93
|
+
try:
|
|
94
|
+
return await transcriber.transcribe(model=model, audio_bytes=audio_bytes)
|
|
95
|
+
except OpenAIError as exc:
|
|
96
|
+
logger.error(
|
|
97
|
+
"openai.transcribe.error",
|
|
98
|
+
error=str(exc),
|
|
99
|
+
error_type=exc.__class__.__name__,
|
|
100
|
+
)
|
|
101
|
+
await reply(text=str(exc).strip() or "voice transcription failed")
|
|
102
|
+
return None
|
|
103
|
+
except (RuntimeError, OSError, ValueError) as exc:
|
|
104
|
+
logger.error(
|
|
105
|
+
"voice.transcribe.error",
|
|
106
|
+
error=str(exc),
|
|
107
|
+
error_type=exc.__class__.__name__,
|
|
108
|
+
)
|
|
109
|
+
await reply(text=str(exc).strip() or "voice transcription failed")
|
|
110
|
+
return None
|
yee88/transport.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Any, Protocol
|
|
5
|
+
|
|
6
|
+
type ChannelId = int | str
|
|
7
|
+
type MessageId = int | str
|
|
8
|
+
type ThreadId = int | str
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True, slots=True)
|
|
12
|
+
class MessageRef:
|
|
13
|
+
channel_id: ChannelId
|
|
14
|
+
message_id: MessageId
|
|
15
|
+
raw: Any | None = field(default=None, compare=False, hash=False)
|
|
16
|
+
thread_id: ThreadId | None = field(default=None, compare=False, hash=False)
|
|
17
|
+
sender_id: int | None = field(default=None, compare=False, hash=False)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True, slots=True)
|
|
21
|
+
class RenderedMessage:
|
|
22
|
+
text: str
|
|
23
|
+
extra: dict[str, Any] = field(default_factory=dict)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True, slots=True)
|
|
27
|
+
class SendOptions:
|
|
28
|
+
reply_to: MessageRef | None = None
|
|
29
|
+
notify: bool = True
|
|
30
|
+
replace: MessageRef | None = None
|
|
31
|
+
thread_id: ThreadId | None = None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Transport(Protocol):
|
|
35
|
+
async def close(self) -> None: ...
|
|
36
|
+
|
|
37
|
+
async def send(
|
|
38
|
+
self,
|
|
39
|
+
*,
|
|
40
|
+
channel_id: ChannelId,
|
|
41
|
+
message: RenderedMessage,
|
|
42
|
+
options: SendOptions | None = None,
|
|
43
|
+
) -> MessageRef | None: ...
|
|
44
|
+
|
|
45
|
+
async def edit(
|
|
46
|
+
self,
|
|
47
|
+
*,
|
|
48
|
+
ref: MessageRef,
|
|
49
|
+
message: RenderedMessage,
|
|
50
|
+
wait: bool = True,
|
|
51
|
+
) -> MessageRef | None: ...
|
|
52
|
+
|
|
53
|
+
async def delete(self, *, ref: MessageRef) -> bool: ...
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterable, Mapping
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, Literal
|
|
7
|
+
|
|
8
|
+
from .config import ConfigError, ProjectsConfig
|
|
9
|
+
from .context import RunContext
|
|
10
|
+
from .directives import (
|
|
11
|
+
ParsedDirectives,
|
|
12
|
+
format_context_line,
|
|
13
|
+
parse_context_line,
|
|
14
|
+
parse_directives,
|
|
15
|
+
)
|
|
16
|
+
from .model import EngineId, ResumeToken
|
|
17
|
+
from .plugins import normalize_allowlist
|
|
18
|
+
from .router import AutoRouter, EngineStatus
|
|
19
|
+
from .runner import Runner
|
|
20
|
+
from .worktrees import WorktreeError, resolve_run_cwd
|
|
21
|
+
|
|
22
|
+
type ContextSource = Literal[
|
|
23
|
+
"reply_ctx",
|
|
24
|
+
"directives",
|
|
25
|
+
"ambient",
|
|
26
|
+
"default_project",
|
|
27
|
+
"none",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True, slots=True)
|
|
32
|
+
class ResolvedMessage:
|
|
33
|
+
prompt: str
|
|
34
|
+
resume_token: ResumeToken | None
|
|
35
|
+
engine_override: EngineId | None
|
|
36
|
+
context: RunContext | None
|
|
37
|
+
context_source: ContextSource = "none"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True, slots=True)
|
|
41
|
+
class ResolvedRunner:
|
|
42
|
+
engine: EngineId
|
|
43
|
+
runner: Runner
|
|
44
|
+
available: bool
|
|
45
|
+
issue: str | None = None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class TransportRuntime:
|
|
49
|
+
__slots__ = (
|
|
50
|
+
"_router",
|
|
51
|
+
"_projects",
|
|
52
|
+
"_allowlist",
|
|
53
|
+
"_config_path",
|
|
54
|
+
"_plugin_configs",
|
|
55
|
+
"_watch_config",
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
def __init__(
|
|
59
|
+
self,
|
|
60
|
+
*,
|
|
61
|
+
router: AutoRouter,
|
|
62
|
+
projects: ProjectsConfig,
|
|
63
|
+
allowlist: Iterable[str] | None = None,
|
|
64
|
+
config_path: Path | None = None,
|
|
65
|
+
plugin_configs: Mapping[str, Any] | None = None,
|
|
66
|
+
watch_config: bool = False,
|
|
67
|
+
) -> None:
|
|
68
|
+
self._apply(
|
|
69
|
+
router=router,
|
|
70
|
+
projects=projects,
|
|
71
|
+
allowlist=allowlist,
|
|
72
|
+
config_path=config_path,
|
|
73
|
+
plugin_configs=plugin_configs,
|
|
74
|
+
watch_config=watch_config,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
def update(
|
|
78
|
+
self,
|
|
79
|
+
*,
|
|
80
|
+
router: AutoRouter,
|
|
81
|
+
projects: ProjectsConfig,
|
|
82
|
+
allowlist: Iterable[str] | None = None,
|
|
83
|
+
config_path: Path | None = None,
|
|
84
|
+
plugin_configs: Mapping[str, Any] | None = None,
|
|
85
|
+
watch_config: bool = False,
|
|
86
|
+
) -> None:
|
|
87
|
+
self._apply(
|
|
88
|
+
router=router,
|
|
89
|
+
projects=projects,
|
|
90
|
+
allowlist=allowlist,
|
|
91
|
+
config_path=config_path,
|
|
92
|
+
plugin_configs=plugin_configs,
|
|
93
|
+
watch_config=watch_config,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
def _apply(
|
|
97
|
+
self,
|
|
98
|
+
*,
|
|
99
|
+
router: AutoRouter,
|
|
100
|
+
projects: ProjectsConfig,
|
|
101
|
+
allowlist: Iterable[str] | None,
|
|
102
|
+
config_path: Path | None,
|
|
103
|
+
plugin_configs: Mapping[str, Any] | None,
|
|
104
|
+
watch_config: bool,
|
|
105
|
+
) -> None:
|
|
106
|
+
self._router = router
|
|
107
|
+
self._projects = projects
|
|
108
|
+
self._allowlist = normalize_allowlist(allowlist)
|
|
109
|
+
self._config_path = config_path
|
|
110
|
+
self._plugin_configs = dict(plugin_configs or {})
|
|
111
|
+
self._watch_config = watch_config
|
|
112
|
+
|
|
113
|
+
@property
|
|
114
|
+
def default_engine(self) -> EngineId:
|
|
115
|
+
return self._router.default_engine
|
|
116
|
+
|
|
117
|
+
def resolve_engine(
|
|
118
|
+
self,
|
|
119
|
+
*,
|
|
120
|
+
engine_override: EngineId | None,
|
|
121
|
+
context: RunContext | None,
|
|
122
|
+
) -> EngineId:
|
|
123
|
+
if engine_override is not None:
|
|
124
|
+
return engine_override
|
|
125
|
+
if context is None or context.project is None:
|
|
126
|
+
return self._router.default_engine
|
|
127
|
+
project = self._projects.projects.get(context.project)
|
|
128
|
+
if project is None:
|
|
129
|
+
return self._router.default_engine
|
|
130
|
+
return project.default_engine or self._router.default_engine
|
|
131
|
+
|
|
132
|
+
@property
|
|
133
|
+
def engine_ids(self) -> tuple[EngineId, ...]:
|
|
134
|
+
return self._router.engine_ids
|
|
135
|
+
|
|
136
|
+
def available_engine_ids(self) -> tuple[EngineId, ...]:
|
|
137
|
+
return tuple(entry.engine for entry in self._router.available_entries)
|
|
138
|
+
|
|
139
|
+
def engine_ids_with_status(self, status: EngineStatus) -> tuple[EngineId, ...]:
|
|
140
|
+
return tuple(
|
|
141
|
+
entry.engine for entry in self._router.entries if entry.status == status
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
def missing_engine_ids(self) -> tuple[EngineId, ...]:
|
|
145
|
+
return self.engine_ids_with_status("missing_cli")
|
|
146
|
+
|
|
147
|
+
def project_aliases(self) -> tuple[str, ...]:
|
|
148
|
+
return tuple(project.alias for project in self._projects.projects.values())
|
|
149
|
+
|
|
150
|
+
@property
|
|
151
|
+
def allowlist(self) -> set[str] | None:
|
|
152
|
+
return self._allowlist
|
|
153
|
+
|
|
154
|
+
@property
|
|
155
|
+
def config_path(self) -> Path | None:
|
|
156
|
+
return self._config_path
|
|
157
|
+
|
|
158
|
+
@property
|
|
159
|
+
def watch_config(self) -> bool:
|
|
160
|
+
return self._watch_config
|
|
161
|
+
|
|
162
|
+
def plugin_config(self, plugin_id: str) -> dict[str, Any]:
|
|
163
|
+
if not self._plugin_configs:
|
|
164
|
+
return {}
|
|
165
|
+
raw = self._plugin_configs.get(plugin_id)
|
|
166
|
+
if raw is None:
|
|
167
|
+
return {}
|
|
168
|
+
if not isinstance(raw, dict):
|
|
169
|
+
path = self._config_path or Path("<config>")
|
|
170
|
+
raise ConfigError(
|
|
171
|
+
f"Invalid `plugins.{plugin_id}` in {path}; expected a table."
|
|
172
|
+
)
|
|
173
|
+
return dict(raw)
|
|
174
|
+
|
|
175
|
+
def resolve_message(
|
|
176
|
+
self,
|
|
177
|
+
*,
|
|
178
|
+
text: str,
|
|
179
|
+
reply_text: str | None,
|
|
180
|
+
ambient_context: RunContext | None = None,
|
|
181
|
+
chat_id: int | None = None,
|
|
182
|
+
) -> ResolvedMessage:
|
|
183
|
+
directives = parse_directives(
|
|
184
|
+
text,
|
|
185
|
+
engine_ids=self._router.engine_ids,
|
|
186
|
+
projects=self._projects,
|
|
187
|
+
)
|
|
188
|
+
reply_ctx = parse_context_line(reply_text, projects=self._projects)
|
|
189
|
+
resume_token = self._router.resolve_resume(directives.prompt, reply_text)
|
|
190
|
+
chat_project = self._projects.project_for_chat(chat_id)
|
|
191
|
+
default_project = chat_project or self._projects.default_project
|
|
192
|
+
|
|
193
|
+
context, context_source = self._resolve_context(
|
|
194
|
+
directives=directives,
|
|
195
|
+
reply_ctx=reply_ctx,
|
|
196
|
+
ambient_context=ambient_context,
|
|
197
|
+
default_project=default_project,
|
|
198
|
+
)
|
|
199
|
+
engine_override = self._resolve_engine_override(
|
|
200
|
+
directives_engine=directives.engine,
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
return ResolvedMessage(
|
|
204
|
+
prompt=directives.prompt,
|
|
205
|
+
resume_token=resume_token,
|
|
206
|
+
engine_override=engine_override,
|
|
207
|
+
context=context,
|
|
208
|
+
context_source=context_source,
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
def project_default_engine(self, context: RunContext | None) -> EngineId | None:
|
|
212
|
+
if context is None or context.project is None:
|
|
213
|
+
return None
|
|
214
|
+
project = self._projects.projects.get(context.project)
|
|
215
|
+
if project is None:
|
|
216
|
+
return None
|
|
217
|
+
return project.default_engine
|
|
218
|
+
|
|
219
|
+
def _resolve_context(
|
|
220
|
+
self,
|
|
221
|
+
*,
|
|
222
|
+
directives: ParsedDirectives,
|
|
223
|
+
reply_ctx: RunContext | None,
|
|
224
|
+
ambient_context: RunContext | None,
|
|
225
|
+
default_project: str | None,
|
|
226
|
+
) -> tuple[RunContext | None, ContextSource]:
|
|
227
|
+
if reply_ctx is not None:
|
|
228
|
+
return reply_ctx, "reply_ctx"
|
|
229
|
+
|
|
230
|
+
project_key = directives.project
|
|
231
|
+
branch = directives.branch
|
|
232
|
+
if project_key is None:
|
|
233
|
+
if ambient_context is not None and ambient_context.project is not None:
|
|
234
|
+
project_key = ambient_context.project
|
|
235
|
+
else:
|
|
236
|
+
project_key = default_project
|
|
237
|
+
if (
|
|
238
|
+
branch is None
|
|
239
|
+
and ambient_context is not None
|
|
240
|
+
and ambient_context.branch is not None
|
|
241
|
+
and project_key == ambient_context.project
|
|
242
|
+
):
|
|
243
|
+
branch = ambient_context.branch
|
|
244
|
+
context: RunContext | None = None
|
|
245
|
+
if project_key is not None or branch is not None:
|
|
246
|
+
context = RunContext(project=project_key, branch=branch)
|
|
247
|
+
|
|
248
|
+
if directives.project is not None or directives.branch is not None:
|
|
249
|
+
context_source: ContextSource = "directives"
|
|
250
|
+
elif ambient_context is not None and ambient_context.project is not None:
|
|
251
|
+
context_source = "ambient"
|
|
252
|
+
elif default_project is not None:
|
|
253
|
+
context_source = "default_project"
|
|
254
|
+
else:
|
|
255
|
+
context_source = "none"
|
|
256
|
+
|
|
257
|
+
return context, context_source
|
|
258
|
+
|
|
259
|
+
def _resolve_engine_override(
|
|
260
|
+
self,
|
|
261
|
+
*,
|
|
262
|
+
directives_engine: EngineId | None,
|
|
263
|
+
) -> EngineId | None:
|
|
264
|
+
if directives_engine is not None:
|
|
265
|
+
return directives_engine
|
|
266
|
+
return None
|
|
267
|
+
|
|
268
|
+
@property
|
|
269
|
+
def default_project(self) -> str | None:
|
|
270
|
+
return self._projects.default_project
|
|
271
|
+
|
|
272
|
+
def normalize_project_key(self, value: str) -> str | None:
|
|
273
|
+
key = value.strip().lower()
|
|
274
|
+
if key in self._projects.projects:
|
|
275
|
+
return key
|
|
276
|
+
return None
|
|
277
|
+
|
|
278
|
+
def project_alias_for_key(self, key: str) -> str:
|
|
279
|
+
project = self._projects.projects.get(key)
|
|
280
|
+
return project.alias if project is not None else key
|
|
281
|
+
|
|
282
|
+
def default_context_for_chat(self, chat_id: int | None) -> RunContext | None:
|
|
283
|
+
project_key = self._projects.project_for_chat(chat_id)
|
|
284
|
+
if project_key is None:
|
|
285
|
+
return None
|
|
286
|
+
return RunContext(project=project_key, branch=None)
|
|
287
|
+
|
|
288
|
+
def resolve_system_prompt(self, context: RunContext | None) -> str | None:
|
|
289
|
+
project_key = context.project if context is not None else None
|
|
290
|
+
return self._projects.resolve_system_prompt(project_key)
|
|
291
|
+
|
|
292
|
+
def project_chat_ids(self) -> tuple[int, ...]:
|
|
293
|
+
return self._projects.project_chat_ids()
|
|
294
|
+
|
|
295
|
+
def resolve_runner(
|
|
296
|
+
self,
|
|
297
|
+
*,
|
|
298
|
+
resume_token: ResumeToken | None,
|
|
299
|
+
engine_override: EngineId | None,
|
|
300
|
+
) -> ResolvedRunner:
|
|
301
|
+
entry = (
|
|
302
|
+
self._router.entry_for_engine(engine_override)
|
|
303
|
+
if resume_token is None
|
|
304
|
+
else self._router.entry_for(resume_token)
|
|
305
|
+
)
|
|
306
|
+
return ResolvedRunner(
|
|
307
|
+
engine=entry.engine,
|
|
308
|
+
runner=entry.runner,
|
|
309
|
+
available=entry.available,
|
|
310
|
+
issue=entry.issue,
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
def is_resume_line(self, line: str) -> bool:
|
|
314
|
+
return self._router.is_resume_line(line)
|
|
315
|
+
|
|
316
|
+
def resolve_run_cwd(self, context: RunContext | None) -> Path | None:
|
|
317
|
+
try:
|
|
318
|
+
return resolve_run_cwd(context, projects=self._projects)
|
|
319
|
+
except WorktreeError as exc:
|
|
320
|
+
raise ConfigError(str(exc)) from exc
|
|
321
|
+
|
|
322
|
+
def format_context_line(self, context: RunContext | None) -> str | None:
|
|
323
|
+
return format_context_line(context, projects=self._projects)
|
yee88/transports.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Protocol, runtime_checkable
|
|
6
|
+
from collections.abc import Iterable
|
|
7
|
+
|
|
8
|
+
from .backends import EngineBackend, SetupIssue
|
|
9
|
+
from .plugins import TRANSPORT_GROUP, list_ids, load_plugin_backend
|
|
10
|
+
from .transport_runtime import TransportRuntime
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True, slots=True)
|
|
14
|
+
class SetupResult:
|
|
15
|
+
issues: list[SetupIssue]
|
|
16
|
+
config_path: Path
|
|
17
|
+
|
|
18
|
+
@property
|
|
19
|
+
def ok(self) -> bool:
|
|
20
|
+
return not self.issues
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@runtime_checkable
|
|
24
|
+
class TransportBackend(Protocol):
|
|
25
|
+
id: str
|
|
26
|
+
description: str
|
|
27
|
+
|
|
28
|
+
def check_setup(
|
|
29
|
+
self,
|
|
30
|
+
engine_backend: EngineBackend,
|
|
31
|
+
*,
|
|
32
|
+
transport_override: str | None = None,
|
|
33
|
+
) -> SetupResult: ...
|
|
34
|
+
|
|
35
|
+
async def interactive_setup(self, *, force: bool) -> bool: ...
|
|
36
|
+
|
|
37
|
+
def lock_token(
|
|
38
|
+
self, *, transport_config: object, _config_path: Path
|
|
39
|
+
) -> str | None: ...
|
|
40
|
+
|
|
41
|
+
def build_and_run(
|
|
42
|
+
self,
|
|
43
|
+
*,
|
|
44
|
+
transport_config: object,
|
|
45
|
+
config_path: Path,
|
|
46
|
+
runtime: TransportRuntime,
|
|
47
|
+
final_notify: bool,
|
|
48
|
+
default_engine_override: str | None,
|
|
49
|
+
) -> None: ...
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _validate_transport_backend(backend: object, ep) -> None:
|
|
53
|
+
if not isinstance(backend, TransportBackend):
|
|
54
|
+
raise TypeError(f"{ep.value} is not a TransportBackend")
|
|
55
|
+
if backend.id != ep.name:
|
|
56
|
+
raise ValueError(
|
|
57
|
+
f"{ep.value} transport id {backend.id!r} does not match entrypoint {ep.name!r}"
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def get_transport(
|
|
62
|
+
transport_id: str, *, allowlist: Iterable[str] | None = None
|
|
63
|
+
) -> TransportBackend:
|
|
64
|
+
backend = load_plugin_backend(
|
|
65
|
+
TRANSPORT_GROUP,
|
|
66
|
+
transport_id,
|
|
67
|
+
allowlist=allowlist,
|
|
68
|
+
validator=_validate_transport_backend,
|
|
69
|
+
kind_label="transport",
|
|
70
|
+
)
|
|
71
|
+
assert backend is not None
|
|
72
|
+
return backend
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def list_transports(*, allowlist: Iterable[str] | None = None) -> list[str]:
|
|
76
|
+
return list_ids(TRANSPORT_GROUP, allowlist=allowlist)
|
yee88/utils/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Utility helpers for Takopi."""
|