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/schemas/codex.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
# Headless JSONL schema derived from tag rust-v0.77.0 (git 112f40e91c12af0f7146d7e03f20283516a8af0b).
|
|
4
|
+
|
|
5
|
+
from typing import Any, Literal
|
|
6
|
+
|
|
7
|
+
import msgspec
|
|
8
|
+
|
|
9
|
+
type CommandExecutionStatus = Literal[
|
|
10
|
+
"in_progress",
|
|
11
|
+
"completed",
|
|
12
|
+
"failed",
|
|
13
|
+
"declined",
|
|
14
|
+
]
|
|
15
|
+
type PatchApplyStatus = Literal[
|
|
16
|
+
"in_progress",
|
|
17
|
+
"completed",
|
|
18
|
+
"failed",
|
|
19
|
+
]
|
|
20
|
+
type PatchChangeKind = Literal[
|
|
21
|
+
"add",
|
|
22
|
+
"delete",
|
|
23
|
+
"update",
|
|
24
|
+
]
|
|
25
|
+
type McpToolCallStatus = Literal[
|
|
26
|
+
"in_progress",
|
|
27
|
+
"completed",
|
|
28
|
+
"failed",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Usage(msgspec.Struct, kw_only=True):
|
|
33
|
+
input_tokens: int
|
|
34
|
+
cached_input_tokens: int
|
|
35
|
+
output_tokens: int
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ThreadError(msgspec.Struct, kw_only=True):
|
|
39
|
+
message: str
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class ThreadStarted(msgspec.Struct, tag="thread.started", kw_only=True):
|
|
43
|
+
thread_id: str
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class TurnStarted(msgspec.Struct, tag="turn.started", kw_only=True):
|
|
47
|
+
pass
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class TurnCompleted(msgspec.Struct, tag="turn.completed", kw_only=True):
|
|
51
|
+
usage: Usage
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class TurnFailed(msgspec.Struct, tag="turn.failed", kw_only=True):
|
|
55
|
+
error: ThreadError
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class StreamError(msgspec.Struct, tag="error", kw_only=True):
|
|
59
|
+
message: str
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class AgentMessageItem(msgspec.Struct, tag="agent_message", kw_only=True):
|
|
63
|
+
id: str
|
|
64
|
+
text: str
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class ReasoningItem(msgspec.Struct, tag="reasoning", kw_only=True):
|
|
68
|
+
id: str
|
|
69
|
+
text: str
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class CommandExecutionItem(msgspec.Struct, tag="command_execution", kw_only=True):
|
|
73
|
+
id: str
|
|
74
|
+
command: str
|
|
75
|
+
aggregated_output: str
|
|
76
|
+
exit_code: int | None
|
|
77
|
+
status: CommandExecutionStatus
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class FileUpdateChange(msgspec.Struct, kw_only=True):
|
|
81
|
+
path: str
|
|
82
|
+
kind: PatchChangeKind
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class FileChangeItem(msgspec.Struct, tag="file_change", kw_only=True):
|
|
86
|
+
id: str
|
|
87
|
+
changes: list[FileUpdateChange]
|
|
88
|
+
status: PatchApplyStatus
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class McpToolCallItemResult(msgspec.Struct, kw_only=True):
|
|
92
|
+
content: list[dict[str, Any]]
|
|
93
|
+
structured_content: Any
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class McpToolCallItemError(msgspec.Struct, kw_only=True):
|
|
97
|
+
message: str
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class McpToolCallItem(msgspec.Struct, tag="mcp_tool_call", kw_only=True):
|
|
101
|
+
id: str
|
|
102
|
+
server: str
|
|
103
|
+
tool: str
|
|
104
|
+
arguments: Any
|
|
105
|
+
result: McpToolCallItemResult | None
|
|
106
|
+
error: McpToolCallItemError | None
|
|
107
|
+
status: McpToolCallStatus
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class WebSearchItem(msgspec.Struct, tag="web_search", kw_only=True):
|
|
111
|
+
id: str
|
|
112
|
+
query: str
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class ErrorItem(msgspec.Struct, tag="error", kw_only=True):
|
|
116
|
+
id: str
|
|
117
|
+
message: str
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class TodoItem(msgspec.Struct, kw_only=True):
|
|
121
|
+
text: str
|
|
122
|
+
completed: bool
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class TodoListItem(msgspec.Struct, tag="todo_list", kw_only=True):
|
|
126
|
+
id: str
|
|
127
|
+
items: list[TodoItem]
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
type ThreadItem = (
|
|
131
|
+
AgentMessageItem
|
|
132
|
+
| ReasoningItem
|
|
133
|
+
| CommandExecutionItem
|
|
134
|
+
| FileChangeItem
|
|
135
|
+
| McpToolCallItem
|
|
136
|
+
| WebSearchItem
|
|
137
|
+
| TodoListItem
|
|
138
|
+
| ErrorItem
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class ItemStarted(msgspec.Struct, tag="item.started", kw_only=True):
|
|
143
|
+
item: ThreadItem
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class ItemUpdated(msgspec.Struct, tag="item.updated", kw_only=True):
|
|
147
|
+
item: ThreadItem
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class ItemCompleted(msgspec.Struct, tag="item.completed", kw_only=True):
|
|
151
|
+
item: ThreadItem
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
type ThreadEvent = (
|
|
155
|
+
ThreadStarted
|
|
156
|
+
| TurnStarted
|
|
157
|
+
| TurnCompleted
|
|
158
|
+
| TurnFailed
|
|
159
|
+
| ItemStarted
|
|
160
|
+
| ItemUpdated
|
|
161
|
+
| ItemCompleted
|
|
162
|
+
| StreamError
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
_DECODER = msgspec.json.Decoder(ThreadEvent)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def decode_event(data: bytes | str) -> ThreadEvent:
|
|
169
|
+
return _DECODER.decode(data)
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Msgspec models and decoder for opencode --format json output."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import msgspec
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class _Event(msgspec.Struct, tag_field="type", forbid_unknown_fields=False):
|
|
11
|
+
pass
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class StepStart(_Event, tag="step_start"):
|
|
15
|
+
timestamp: int | None = None
|
|
16
|
+
sessionID: str | None = None
|
|
17
|
+
part: dict[str, Any] | None = None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class StepFinish(_Event, tag="step_finish"):
|
|
21
|
+
timestamp: int | None = None
|
|
22
|
+
sessionID: str | None = None
|
|
23
|
+
part: dict[str, Any] | None = None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ToolUse(_Event, tag="tool_use"):
|
|
27
|
+
timestamp: int | None = None
|
|
28
|
+
sessionID: str | None = None
|
|
29
|
+
part: dict[str, Any] | None = None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Text(_Event, tag="text"):
|
|
33
|
+
timestamp: int | None = None
|
|
34
|
+
sessionID: str | None = None
|
|
35
|
+
part: dict[str, Any] | None = None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Error(_Event, tag="error"):
|
|
39
|
+
timestamp: int | None = None
|
|
40
|
+
sessionID: str | None = None
|
|
41
|
+
error: Any = None
|
|
42
|
+
message: Any = None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
type OpenCodeEvent = StepStart | StepFinish | ToolUse | Text | Error
|
|
46
|
+
|
|
47
|
+
_DECODER = msgspec.json.Decoder(OpenCodeEvent)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def decode_event(line: str | bytes) -> OpenCodeEvent:
|
|
51
|
+
return _DECODER.decode(line)
|
yee88/schemas/pi.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Msgspec models and decoder for pi --mode json output."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import msgspec
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class _Event(msgspec.Struct, tag_field="type", forbid_unknown_fields=False):
|
|
11
|
+
pass
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class SessionHeader(_Event, tag="session"):
|
|
15
|
+
id: str | None = None
|
|
16
|
+
version: int | None = None
|
|
17
|
+
timestamp: str | None = None
|
|
18
|
+
cwd: str | None = None
|
|
19
|
+
parentSession: str | None = None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class AgentStart(_Event, tag="agent_start"):
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class AgentEnd(_Event, tag="agent_end"):
|
|
27
|
+
messages: list[dict[str, Any]]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class MessageEnd(_Event, tag="message_end"):
|
|
31
|
+
message: dict[str, Any]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class MessageStart(_Event, tag="message_start"):
|
|
35
|
+
message: dict[str, Any] | None = None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class MessageUpdate(_Event, tag="message_update"):
|
|
39
|
+
message: dict[str, Any] | None = None
|
|
40
|
+
assistantMessageEvent: dict[str, Any] | None = None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class TurnStart(_Event, tag="turn_start"):
|
|
44
|
+
pass
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class TurnEnd(_Event, tag="turn_end"):
|
|
48
|
+
message: dict[str, Any] | None = None
|
|
49
|
+
toolResults: list[dict[str, Any]] | None = None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class ToolExecutionStart(_Event, tag="tool_execution_start"):
|
|
53
|
+
toolCallId: str
|
|
54
|
+
toolName: str | None = None
|
|
55
|
+
args: dict[str, Any] = msgspec.field(default_factory=dict)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class ToolExecutionUpdate(_Event, tag="tool_execution_update"):
|
|
59
|
+
toolCallId: str | None = None
|
|
60
|
+
toolName: str | None = None
|
|
61
|
+
args: dict[str, Any] = msgspec.field(default_factory=dict)
|
|
62
|
+
partialResult: Any = None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class ToolExecutionEnd(_Event, tag="tool_execution_end"):
|
|
66
|
+
toolCallId: str
|
|
67
|
+
toolName: str | None = None
|
|
68
|
+
result: Any = None
|
|
69
|
+
isError: bool = False
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class AutoCompactionStart(_Event, tag="auto_compaction_start"):
|
|
73
|
+
reason: str | None = None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class AutoCompactionEnd(_Event, tag="auto_compaction_end"):
|
|
77
|
+
result: dict[str, Any] | None = None
|
|
78
|
+
aborted: bool | None = None
|
|
79
|
+
willRetry: bool | None = None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class AutoRetryStart(_Event, tag="auto_retry_start"):
|
|
83
|
+
attempt: int | None = None
|
|
84
|
+
maxAttempts: int | None = None
|
|
85
|
+
delayMs: int | None = None
|
|
86
|
+
errorMessage: str | None = None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class AutoRetryEnd(_Event, tag="auto_retry_end"):
|
|
90
|
+
success: bool | None = None
|
|
91
|
+
attempt: int | None = None
|
|
92
|
+
finalError: str | None = None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
type PiEvent = (
|
|
96
|
+
SessionHeader
|
|
97
|
+
| AgentStart
|
|
98
|
+
| AgentEnd
|
|
99
|
+
| MessageStart
|
|
100
|
+
| MessageUpdate
|
|
101
|
+
| MessageEnd
|
|
102
|
+
| TurnStart
|
|
103
|
+
| TurnEnd
|
|
104
|
+
| ToolExecutionStart
|
|
105
|
+
| ToolExecutionUpdate
|
|
106
|
+
| ToolExecutionEnd
|
|
107
|
+
| AutoCompactionStart
|
|
108
|
+
| AutoCompactionEnd
|
|
109
|
+
| AutoRetryStart
|
|
110
|
+
| AutoRetryEnd
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
_DECODER = msgspec.json.Decoder(PiEvent)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def decode_event(line: str | bytes) -> PiEvent:
|
|
117
|
+
return _DECODER.decode(line)
|
yee88/settings.py
ADDED
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Annotated, Any, ClassVar, Literal
|
|
5
|
+
from collections.abc import Iterable
|
|
6
|
+
|
|
7
|
+
from pydantic import (
|
|
8
|
+
BaseModel,
|
|
9
|
+
ConfigDict,
|
|
10
|
+
Field,
|
|
11
|
+
ValidationError,
|
|
12
|
+
StringConstraints,
|
|
13
|
+
field_validator,
|
|
14
|
+
model_validator,
|
|
15
|
+
)
|
|
16
|
+
from pydantic.types import StrictInt
|
|
17
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
18
|
+
from pydantic_settings.sources import TomlConfigSettingsSource
|
|
19
|
+
|
|
20
|
+
from .config import (
|
|
21
|
+
ConfigError,
|
|
22
|
+
HOME_CONFIG_PATH,
|
|
23
|
+
ProjectConfig,
|
|
24
|
+
ProjectsConfig,
|
|
25
|
+
)
|
|
26
|
+
from .config_migrations import migrate_config_file
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
NonEmptyStr = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1)]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _normalize_engine_id(
|
|
33
|
+
value: str,
|
|
34
|
+
*,
|
|
35
|
+
engine_ids: Iterable[str],
|
|
36
|
+
config_path: Path,
|
|
37
|
+
label: str,
|
|
38
|
+
) -> str:
|
|
39
|
+
engine_map = {engine.lower(): engine for engine in engine_ids}
|
|
40
|
+
engine = engine_map.get(value.lower())
|
|
41
|
+
if engine is None:
|
|
42
|
+
available = ", ".join(sorted(engine_map.values()))
|
|
43
|
+
raise ConfigError(
|
|
44
|
+
f"Unknown `{label}` {value!r} in {config_path}. Available: {available}."
|
|
45
|
+
)
|
|
46
|
+
return engine
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _normalize_project_path(value: str, *, config_path: Path) -> Path:
|
|
50
|
+
path = Path(value).expanduser()
|
|
51
|
+
if not path.is_absolute():
|
|
52
|
+
path = config_path.parent / path
|
|
53
|
+
return path
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class TelegramTopicsSettings(BaseModel):
|
|
57
|
+
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
|
|
58
|
+
|
|
59
|
+
enabled: bool = False
|
|
60
|
+
scope: Literal["auto", "main", "projects", "all"] = "auto"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class TelegramFilesSettings(BaseModel):
|
|
64
|
+
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
|
|
65
|
+
|
|
66
|
+
max_upload_bytes: ClassVar[int] = 20 * 1024 * 1024
|
|
67
|
+
max_download_bytes: ClassVar[int] = 50 * 1024 * 1024
|
|
68
|
+
|
|
69
|
+
enabled: bool = False
|
|
70
|
+
auto_put: bool = True
|
|
71
|
+
auto_put_mode: Literal["upload", "prompt"] = "upload"
|
|
72
|
+
uploads_dir: NonEmptyStr = "incoming"
|
|
73
|
+
allowed_user_ids: list[StrictInt] = Field(default_factory=list)
|
|
74
|
+
deny_globs: list[NonEmptyStr] = Field(
|
|
75
|
+
default_factory=lambda: [
|
|
76
|
+
".git/**",
|
|
77
|
+
".env",
|
|
78
|
+
".envrc",
|
|
79
|
+
"**/*.pem",
|
|
80
|
+
"**/.ssh/**",
|
|
81
|
+
]
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
@field_validator("uploads_dir")
|
|
85
|
+
@classmethod
|
|
86
|
+
def _validate_uploads_dir(cls, value: str) -> str:
|
|
87
|
+
if Path(value).is_absolute():
|
|
88
|
+
raise ValueError("files.uploads_dir must be a relative path")
|
|
89
|
+
return value
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class TelegramTransportSettings(BaseModel):
|
|
93
|
+
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
|
|
94
|
+
|
|
95
|
+
bot_token: NonEmptyStr
|
|
96
|
+
chat_id: StrictInt
|
|
97
|
+
allowed_user_ids: list[StrictInt] = Field(default_factory=list)
|
|
98
|
+
message_overflow: Literal["trim", "split"] = "trim"
|
|
99
|
+
voice_transcription: bool = False
|
|
100
|
+
voice_max_bytes: StrictInt = 10 * 1024 * 1024
|
|
101
|
+
voice_transcription_model: NonEmptyStr = "gpt-4o-mini-transcribe"
|
|
102
|
+
voice_transcription_base_url: NonEmptyStr | None = None
|
|
103
|
+
voice_transcription_api_key: NonEmptyStr | None = None
|
|
104
|
+
session_mode: Literal["stateless", "chat"] = "stateless"
|
|
105
|
+
show_resume_line: bool = True
|
|
106
|
+
forward_coalesce_s: float = Field(default=1.0, ge=0)
|
|
107
|
+
media_group_debounce_s: float = Field(default=1.0, ge=0)
|
|
108
|
+
topics: TelegramTopicsSettings = Field(default_factory=TelegramTopicsSettings)
|
|
109
|
+
files: TelegramFilesSettings = Field(default_factory=TelegramFilesSettings)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class TransportsSettings(BaseModel):
|
|
113
|
+
telegram: TelegramTransportSettings
|
|
114
|
+
|
|
115
|
+
model_config = ConfigDict(extra="allow")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class PluginsSettings(BaseModel):
|
|
119
|
+
enabled: list[NonEmptyStr] = Field(default_factory=list)
|
|
120
|
+
|
|
121
|
+
model_config = ConfigDict(extra="allow", str_strip_whitespace=True)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class ProjectSettings(BaseModel):
|
|
125
|
+
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
|
|
126
|
+
|
|
127
|
+
path: NonEmptyStr
|
|
128
|
+
worktrees_dir: NonEmptyStr = ".worktrees"
|
|
129
|
+
default_engine: NonEmptyStr | None = None
|
|
130
|
+
worktree_base: NonEmptyStr | None = None
|
|
131
|
+
chat_id: StrictInt | None = None
|
|
132
|
+
system_prompt: str | None = None
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class TakopiSettings(BaseSettings):
|
|
136
|
+
model_config = SettingsConfigDict(
|
|
137
|
+
extra="allow",
|
|
138
|
+
env_prefix="TAKOPI__",
|
|
139
|
+
env_nested_delimiter="__",
|
|
140
|
+
str_strip_whitespace=True,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
watch_config: bool = False
|
|
144
|
+
default_engine: NonEmptyStr = "codex"
|
|
145
|
+
default_project: NonEmptyStr | None = None
|
|
146
|
+
system_prompt: str | None = (
|
|
147
|
+
"你是我的专业秘书,请用中文回复。"
|
|
148
|
+
"每次会话结束以后,都请叫我老板,用简短、调皮的职场回复,告诉我你的任务结果!"
|
|
149
|
+
)
|
|
150
|
+
projects: dict[str, ProjectSettings] = Field(default_factory=dict)
|
|
151
|
+
|
|
152
|
+
transport: NonEmptyStr = "telegram"
|
|
153
|
+
transports: TransportsSettings
|
|
154
|
+
|
|
155
|
+
plugins: PluginsSettings = Field(default_factory=PluginsSettings)
|
|
156
|
+
|
|
157
|
+
@model_validator(mode="before")
|
|
158
|
+
@classmethod
|
|
159
|
+
def _reject_legacy_telegram_keys(cls, data: Any) -> Any:
|
|
160
|
+
if isinstance(data, dict) and ("bot_token" in data or "chat_id" in data):
|
|
161
|
+
raise ValueError(
|
|
162
|
+
"Move bot_token/chat_id under [transports.telegram] "
|
|
163
|
+
'and set transport = "telegram".'
|
|
164
|
+
)
|
|
165
|
+
return data
|
|
166
|
+
|
|
167
|
+
@classmethod
|
|
168
|
+
def settings_customise_sources(
|
|
169
|
+
cls,
|
|
170
|
+
settings_cls,
|
|
171
|
+
init_settings,
|
|
172
|
+
env_settings,
|
|
173
|
+
dotenv_settings,
|
|
174
|
+
file_secret_settings,
|
|
175
|
+
):
|
|
176
|
+
return (
|
|
177
|
+
init_settings,
|
|
178
|
+
env_settings,
|
|
179
|
+
dotenv_settings,
|
|
180
|
+
TomlConfigSettingsSource(settings_cls),
|
|
181
|
+
file_secret_settings,
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
def engine_config(self, engine_id: str, *, config_path: Path) -> dict[str, Any]:
|
|
185
|
+
extra = self.model_extra or {}
|
|
186
|
+
raw = extra.get(engine_id)
|
|
187
|
+
if raw is None:
|
|
188
|
+
return {}
|
|
189
|
+
if not isinstance(raw, dict):
|
|
190
|
+
raise ConfigError(
|
|
191
|
+
f"Invalid `{engine_id}` config in {config_path}; expected a table."
|
|
192
|
+
)
|
|
193
|
+
return raw
|
|
194
|
+
|
|
195
|
+
def transport_config(
|
|
196
|
+
self, transport_id: str, *, config_path: Path
|
|
197
|
+
) -> dict[str, Any]:
|
|
198
|
+
if transport_id == "telegram":
|
|
199
|
+
return self.transports.telegram.model_dump()
|
|
200
|
+
extra = self.transports.model_extra or {}
|
|
201
|
+
raw = extra.get(transport_id)
|
|
202
|
+
if raw is None:
|
|
203
|
+
return {}
|
|
204
|
+
if not isinstance(raw, dict):
|
|
205
|
+
raise ConfigError(
|
|
206
|
+
f"Invalid `transports.{transport_id}` in {config_path}; "
|
|
207
|
+
"expected a table."
|
|
208
|
+
)
|
|
209
|
+
return raw
|
|
210
|
+
|
|
211
|
+
def to_projects_config(
|
|
212
|
+
self,
|
|
213
|
+
*,
|
|
214
|
+
config_path: Path,
|
|
215
|
+
engine_ids: Iterable[str],
|
|
216
|
+
reserved: Iterable[str] = ("cancel",),
|
|
217
|
+
) -> ProjectsConfig:
|
|
218
|
+
default_project = self.default_project
|
|
219
|
+
default_chat_id = self.transports.telegram.chat_id
|
|
220
|
+
|
|
221
|
+
reserved_lower = {value.lower() for value in reserved}
|
|
222
|
+
engine_map = {engine.lower(): engine for engine in engine_ids}
|
|
223
|
+
projects: dict[str, ProjectConfig] = {}
|
|
224
|
+
chat_map: dict[int, str] = {}
|
|
225
|
+
|
|
226
|
+
for raw_alias, entry in self.projects.items():
|
|
227
|
+
alias = raw_alias
|
|
228
|
+
alias_key = alias.lower()
|
|
229
|
+
if alias_key in engine_map or alias_key in reserved_lower:
|
|
230
|
+
raise ConfigError(
|
|
231
|
+
f"Invalid project alias {alias!r} in {config_path}; "
|
|
232
|
+
"aliases must not match engine ids or reserved commands."
|
|
233
|
+
)
|
|
234
|
+
if alias_key in projects:
|
|
235
|
+
raise ConfigError(
|
|
236
|
+
f"Duplicate project alias {alias!r} in {config_path}."
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
path = _normalize_project_path(entry.path, config_path=config_path)
|
|
240
|
+
|
|
241
|
+
worktrees_dir = Path(entry.worktrees_dir).expanduser()
|
|
242
|
+
|
|
243
|
+
default_engine = None
|
|
244
|
+
if entry.default_engine is not None:
|
|
245
|
+
default_engine = _normalize_engine_id(
|
|
246
|
+
entry.default_engine,
|
|
247
|
+
engine_ids=engine_ids,
|
|
248
|
+
config_path=config_path,
|
|
249
|
+
label=f"projects.{alias}.default_engine",
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
worktree_base = entry.worktree_base
|
|
253
|
+
|
|
254
|
+
chat_id = entry.chat_id
|
|
255
|
+
if chat_id is not None:
|
|
256
|
+
if chat_id == default_chat_id:
|
|
257
|
+
raise ConfigError(
|
|
258
|
+
f"Invalid `projects.{alias}.chat_id` in {config_path}; "
|
|
259
|
+
"must not match transports.telegram.chat_id."
|
|
260
|
+
)
|
|
261
|
+
if chat_id in chat_map:
|
|
262
|
+
existing = chat_map[chat_id]
|
|
263
|
+
raise ConfigError(
|
|
264
|
+
f"Duplicate `projects.*.chat_id` {chat_id} in {config_path}; "
|
|
265
|
+
f"already used by {existing!r}."
|
|
266
|
+
)
|
|
267
|
+
chat_map[chat_id] = alias_key
|
|
268
|
+
|
|
269
|
+
projects[alias_key] = ProjectConfig(
|
|
270
|
+
alias=alias,
|
|
271
|
+
path=path,
|
|
272
|
+
worktrees_dir=worktrees_dir,
|
|
273
|
+
default_engine=default_engine,
|
|
274
|
+
worktree_base=worktree_base,
|
|
275
|
+
chat_id=chat_id,
|
|
276
|
+
system_prompt=entry.system_prompt,
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
if default_project is not None:
|
|
280
|
+
default_key = default_project.lower()
|
|
281
|
+
if default_key not in projects:
|
|
282
|
+
raise ConfigError(
|
|
283
|
+
f"Invalid `default_project` {default_project!r} in {config_path}; "
|
|
284
|
+
"no matching project alias found."
|
|
285
|
+
)
|
|
286
|
+
default_project = default_key
|
|
287
|
+
|
|
288
|
+
return ProjectsConfig(
|
|
289
|
+
projects=projects,
|
|
290
|
+
default_project=default_project,
|
|
291
|
+
global_system_prompt=self.system_prompt,
|
|
292
|
+
chat_map=chat_map,
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def load_settings(path: str | Path | None = None) -> tuple[TakopiSettings, Path]:
|
|
297
|
+
cfg_path = _resolve_config_path(path)
|
|
298
|
+
_ensure_config_file(cfg_path)
|
|
299
|
+
migrate_config_file(cfg_path)
|
|
300
|
+
return _load_settings_from_path(cfg_path), cfg_path
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def load_settings_if_exists(
|
|
304
|
+
path: str | Path | None = None,
|
|
305
|
+
) -> tuple[TakopiSettings, Path] | None:
|
|
306
|
+
cfg_path = _resolve_config_path(path)
|
|
307
|
+
if cfg_path.exists():
|
|
308
|
+
if not cfg_path.is_file():
|
|
309
|
+
raise ConfigError(
|
|
310
|
+
f"Config path {cfg_path} exists but is not a file."
|
|
311
|
+
) from None
|
|
312
|
+
migrate_config_file(cfg_path)
|
|
313
|
+
return _load_settings_from_path(cfg_path), cfg_path
|
|
314
|
+
return None
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def validate_settings_data(
|
|
318
|
+
data: dict[str, Any], *, config_path: Path
|
|
319
|
+
) -> TakopiSettings:
|
|
320
|
+
try:
|
|
321
|
+
return TakopiSettings.model_validate(data)
|
|
322
|
+
except ValidationError as exc:
|
|
323
|
+
raise ConfigError(f"Invalid config in {config_path}: {exc}") from exc
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def require_telegram(settings: TakopiSettings, config_path: Path) -> tuple[str, int]:
|
|
327
|
+
if settings.transport != "telegram":
|
|
328
|
+
raise ConfigError(
|
|
329
|
+
f"Unsupported transport {settings.transport!r} in {config_path} "
|
|
330
|
+
"(telegram only for now)."
|
|
331
|
+
)
|
|
332
|
+
tg = settings.transports.telegram
|
|
333
|
+
return tg.bot_token, tg.chat_id
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _resolve_config_path(path: str | Path | None) -> Path:
|
|
337
|
+
return Path(path).expanduser() if path else HOME_CONFIG_PATH
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _ensure_config_file(cfg_path: Path) -> None:
|
|
341
|
+
if cfg_path.exists() and not cfg_path.is_file():
|
|
342
|
+
raise ConfigError(f"Config path {cfg_path} exists but is not a file.") from None
|
|
343
|
+
if not cfg_path.exists():
|
|
344
|
+
raise ConfigError(f"Missing config file {cfg_path}.") from None
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _load_settings_from_path(cfg_path: Path) -> TakopiSettings:
|
|
348
|
+
cfg = dict(TakopiSettings.model_config)
|
|
349
|
+
cfg["toml_file"] = cfg_path
|
|
350
|
+
Bound = type(
|
|
351
|
+
"TakopiSettingsBound",
|
|
352
|
+
(TakopiSettings,),
|
|
353
|
+
{"model_config": SettingsConfigDict(**cfg)},
|
|
354
|
+
)
|
|
355
|
+
try:
|
|
356
|
+
return Bound()
|
|
357
|
+
except ValidationError as exc:
|
|
358
|
+
raise ConfigError(f"Invalid config in {cfg_path}: {exc}") from exc
|
|
359
|
+
except Exception as exc: # pragma: no cover - safety net
|
|
360
|
+
raise ConfigError(f"Failed to load config {cfg_path}: {exc}") from exc
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Telegram-specific clients and adapters."""
|
|
2
|
+
|
|
3
|
+
from .client import parse_incoming_update, poll_incoming
|
|
4
|
+
from .types import (
|
|
5
|
+
TelegramCallbackQuery,
|
|
6
|
+
TelegramDocument,
|
|
7
|
+
TelegramIncomingMessage,
|
|
8
|
+
TelegramIncomingUpdate,
|
|
9
|
+
TelegramVoice,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"TelegramCallbackQuery",
|
|
14
|
+
"TelegramDocument",
|
|
15
|
+
"TelegramIncomingMessage",
|
|
16
|
+
"TelegramIncomingUpdate",
|
|
17
|
+
"TelegramVoice",
|
|
18
|
+
"parse_incoming_update",
|
|
19
|
+
"poll_incoming",
|
|
20
|
+
]
|