yee88 0.6.3__py3-none-any.whl → 0.7.1__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/cli/__init__.py CHANGED
@@ -92,6 +92,7 @@ from .config import (
92
92
  config_unset,
93
93
  )
94
94
  from .cron import app as cron_app
95
+ from .handoff import app as handoff_app
95
96
  from .reload import reload_command
96
97
 
97
98
 
@@ -215,6 +216,7 @@ def create_app() -> typer.Typer:
215
216
  app.command(name="plugins")(plugins_cmd)
216
217
  app.add_typer(config_app, name="config")
217
218
  app.add_typer(cron_app, name="cron")
219
+ app.add_typer(handoff_app, name="handoff")
218
220
  app.command(name="reload")(reload_command)
219
221
  app.callback()(app_main)
220
222
  for engine_id in _engine_ids_for_cli():
yee88/cli/handoff.py ADDED
@@ -0,0 +1,304 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import subprocess
5
+ from dataclasses import dataclass
6
+ from datetime import datetime
7
+ from pathlib import Path
8
+
9
+ import anyio
10
+ import typer
11
+
12
+ from ..context import RunContext
13
+ from ..model import ResumeToken
14
+ from ..settings import load_settings_if_exists
15
+ from ..telegram.client import TelegramClient
16
+ from ..telegram.topic_state import TopicStateStore, resolve_state_path
17
+
18
+ app = typer.Typer(help="Handoff session context to Telegram")
19
+
20
+ OPENCODE_STORAGE = Path.home() / ".local" / "share" / "opencode" / "storage"
21
+
22
+
23
+ @dataclass
24
+ class SessionInfo:
25
+ id: str
26
+ directory: str
27
+ updated: float
28
+ title: str
29
+
30
+ @property
31
+ def project_name(self) -> str:
32
+ return Path(self.directory).name if self.directory else "unknown"
33
+
34
+ @property
35
+ def updated_str(self) -> str:
36
+ return datetime.fromtimestamp(self.updated / 1000).strftime("%m-%d %H:%M")
37
+
38
+
39
+ def _get_recent_sessions(limit: int = 10) -> list[SessionInfo]:
40
+ try:
41
+ result = subprocess.run(
42
+ ["opencode", "session", "list", "--format", "json", "-n", str(limit)],
43
+ capture_output=True,
44
+ text=True,
45
+ check=True,
46
+ )
47
+ data = json.loads(result.stdout)
48
+ return [
49
+ SessionInfo(
50
+ id=s.get("id", ""),
51
+ directory=s.get("directory", ""),
52
+ updated=s.get("updated", 0),
53
+ title=s.get("title", ""),
54
+ )
55
+ for s in data
56
+ ]
57
+ except (subprocess.CalledProcessError, json.JSONDecodeError, FileNotFoundError):
58
+ return []
59
+
60
+
61
+ def _get_session_messages(session_id: str, limit: int = 5) -> list[dict]:
62
+ message_dir = OPENCODE_STORAGE / "message" / session_id
63
+ if not message_dir.exists():
64
+ return []
65
+
66
+ messages: list[tuple[int, str, str]] = []
67
+ for msg_file in message_dir.glob("msg_*.json"):
68
+ try:
69
+ data = json.loads(msg_file.read_text())
70
+ created = data.get("time", {}).get("created", 0)
71
+ role = data.get("role", "unknown")
72
+ msg_id = data.get("id", "")
73
+ messages.append((created, role, msg_id))
74
+ except (json.JSONDecodeError, OSError):
75
+ continue
76
+
77
+ messages.sort(key=lambda x: x[0], reverse=True)
78
+ messages = messages[:limit]
79
+ messages.reverse()
80
+
81
+ result = []
82
+ for _, role, msg_id in messages:
83
+ part_dir = OPENCODE_STORAGE / "part" / msg_id
84
+ if not part_dir.exists():
85
+ continue
86
+ for part_file in part_dir.glob("prt_*.json"):
87
+ try:
88
+ part_data = json.loads(part_file.read_text())
89
+ if part_data.get("type") == "text":
90
+ text = part_data.get("text", "")
91
+ if text.startswith('"') and text.endswith('"\n'):
92
+ text = json.loads(text.rstrip('\n'))
93
+ result.append({"role": role, "text": text})
94
+ break
95
+ except (json.JSONDecodeError, OSError):
96
+ continue
97
+
98
+ return result
99
+
100
+
101
+ def _format_handoff_message(
102
+ session_id: str,
103
+ messages: list[dict],
104
+ project: str | None = None,
105
+ ) -> str:
106
+ lines = ["📱 **会话接力**", ""]
107
+
108
+ if project:
109
+ lines.append(f"📁 项目: `{project}`")
110
+ lines.append(f"🔗 Session: `{session_id}`")
111
+ lines.append("")
112
+ lines.append("---")
113
+ lines.append("")
114
+
115
+ for msg in messages:
116
+ role = msg.get("role", "unknown")
117
+ text = msg.get("text", "")
118
+ role_label = "👤" if role == "user" else "🤖"
119
+ if len(text) > 500:
120
+ text = text[:500] + "..."
121
+ lines.append(f"{role_label} **{role}**:")
122
+ lines.append(text)
123
+ lines.append("")
124
+
125
+ total_len = sum(len(line) for line in lines)
126
+ if total_len > 3500:
127
+ lines = lines[:20]
128
+ lines.append("... (truncated)")
129
+
130
+ lines.append("---")
131
+ lines.append("")
132
+ lines.append("💡 直接在此 Topic 发消息即可继续对话")
133
+
134
+ return "\n".join(lines)
135
+
136
+
137
+ async def _create_handoff_topic(
138
+ token: str,
139
+ chat_id: int,
140
+ session_id: str,
141
+ project: str,
142
+ config_path: Path,
143
+ ) -> int | None:
144
+ title = f"📱 {project} handoff"
145
+
146
+ client = TelegramClient(token)
147
+ try:
148
+ result = await client.create_forum_topic(chat_id, title)
149
+ if result is None:
150
+ return None
151
+
152
+ thread_id = result.message_thread_id
153
+
154
+ state_path = resolve_state_path(config_path)
155
+ store = TopicStateStore(state_path)
156
+
157
+ context = RunContext(project=project.lower(), branch=None)
158
+ await store.set_context(chat_id, thread_id, context, topic_title=title)
159
+
160
+ resume_token = ResumeToken(engine="opencode", value=session_id)
161
+ await store.set_session_resume(chat_id, thread_id, resume_token)
162
+
163
+ return thread_id
164
+ finally:
165
+ await client.close()
166
+
167
+
168
+ async def _send_to_telegram(
169
+ token: str,
170
+ chat_id: int,
171
+ message: str,
172
+ thread_id: int | None = None,
173
+ ) -> bool:
174
+ client = TelegramClient(token)
175
+ try:
176
+ result = await client.send_message(
177
+ chat_id=chat_id,
178
+ text=message,
179
+ message_thread_id=thread_id,
180
+ parse_mode="Markdown",
181
+ )
182
+ return result is not None
183
+ finally:
184
+ await client.close()
185
+
186
+
187
+ @app.command()
188
+ def send(
189
+ session: str | None = typer.Option(
190
+ None, "--session", "-s", help="Session ID (defaults to latest)"
191
+ ),
192
+ limit: int = typer.Option(
193
+ 3, "--limit", "-n", help="Number of messages to include"
194
+ ),
195
+ project: str | None = typer.Option(
196
+ None, "--project", "-p", help="Project name for context"
197
+ ),
198
+ ) -> None:
199
+ result = load_settings_if_exists()
200
+ if result is None:
201
+ typer.echo("❌ 未找到 yee88 配置文件", err=True)
202
+ raise typer.Exit(1)
203
+
204
+ settings, config_path = result
205
+ telegram_cfg = settings.transports.telegram
206
+
207
+ token = telegram_cfg.bot_token
208
+ chat_id = telegram_cfg.chat_id
209
+
210
+ if not token or not chat_id:
211
+ typer.echo("❌ Telegram 配置不完整 (需要 bot_token 和 chat_id)", err=True)
212
+ raise typer.Exit(1)
213
+
214
+ if not telegram_cfg.topics.enabled:
215
+ typer.echo("❌ Topics 未启用,请先运行: yee88 config set transports.telegram.topics.enabled true", err=True)
216
+ raise typer.Exit(1)
217
+
218
+ session_id = session
219
+ session_project = project
220
+ if session_id is None:
221
+ sessions = _get_recent_sessions(limit=10)
222
+ if not sessions:
223
+ typer.echo("❌ 未找到 OpenCode 会话", err=True)
224
+ raise typer.Exit(1)
225
+
226
+ typer.echo("\n📲 会话接力 - 将电脑端会话发送到 Telegram 继续对话")
227
+ typer.echo("━" * 50)
228
+ typer.echo("\n📋 最近的会话:\n")
229
+ for i, s in enumerate(sessions[:10], 1):
230
+ title_display = s.title[:40] if s.title else s.project_name
231
+ typer.echo(f" [{i}] {s.updated_str} {title_display}")
232
+ typer.echo("")
233
+
234
+ choice = typer.prompt("选择会话 (1-10)", default="1")
235
+ try:
236
+ idx = int(choice) - 1
237
+ if idx < 0 or idx >= len(sessions):
238
+ typer.echo("❌ 无效选择", err=True)
239
+ raise typer.Exit(1)
240
+ except ValueError:
241
+ typer.echo("❌ 请输入数字", err=True)
242
+ raise typer.Exit(1)
243
+
244
+ selected = sessions[idx]
245
+ session_id = selected.id
246
+ if session_project is None:
247
+ session_project = selected.project_name
248
+
249
+ if not session_id:
250
+ typer.echo("❌ 会话 ID 为空", err=True)
251
+ raise typer.Exit(1)
252
+
253
+ typer.echo(f"📖 读取会话 {session_id[:20]}...")
254
+
255
+ messages = _get_session_messages(session_id, limit=limit)
256
+ if not messages:
257
+ typer.echo("❌ 无法读取会话消息", err=True)
258
+ raise typer.Exit(1)
259
+
260
+ typer.echo("🆕 创建新 Topic...")
261
+
262
+ async def do_handoff() -> tuple[bool, int | None]:
263
+ thread_id = await _create_handoff_topic(
264
+ token=token,
265
+ chat_id=chat_id,
266
+ session_id=session_id,
267
+ project=session_project or "unknown",
268
+ config_path=config_path,
269
+ )
270
+ if thread_id is None:
271
+ return False, None
272
+
273
+ handoff_msg = _format_handoff_message(
274
+ session_id=session_id,
275
+ messages=messages,
276
+ project=session_project,
277
+ )
278
+
279
+ success = await _send_to_telegram(
280
+ token=token,
281
+ chat_id=chat_id,
282
+ message=handoff_msg,
283
+ thread_id=thread_id,
284
+ )
285
+ return success, thread_id
286
+
287
+ success, thread_id = anyio.run(do_handoff)
288
+
289
+ if success:
290
+ typer.echo("✅ 已发送到 Telegram!")
291
+ typer.echo(f" Session: {session_id}")
292
+ typer.echo(f" Project: {session_project}")
293
+ typer.echo(f" Topic ID: {thread_id}")
294
+ typer.echo(f" 消息数: {limit}")
295
+ else:
296
+ typer.echo("❌ 发送失败", err=True)
297
+ raise typer.Exit(1)
298
+
299
+
300
+ @app.callback(invoke_without_command=True)
301
+ def main(ctx: typer.Context) -> None:
302
+ """Handoff session context to Telegram for mobile continuation."""
303
+ if ctx.invoked_subcommand is None:
304
+ ctx.invoke(send, session=None, limit=3, project=None)
yee88/cron/manager.py CHANGED
@@ -2,25 +2,54 @@ import tomllib
2
2
  import tomli_w
3
3
  from pathlib import Path
4
4
  from typing import List, Optional
5
+ from zoneinfo import ZoneInfo
5
6
  from croniter import croniter
6
7
  from datetime import datetime
7
8
  from .models import CronJob
8
9
 
10
+ BEIJING_TZ = ZoneInfo("Asia/Shanghai")
11
+
9
12
 
10
13
  class CronManager:
11
- def __init__(self, config_dir: Path):
14
+ def __init__(self, config_dir: Path, timezone: str = "Asia/Shanghai"):
12
15
  self.file = config_dir / "cron.toml"
13
16
  self.jobs: List[CronJob] = []
17
+ self.timezone = ZoneInfo(timezone)
14
18
 
15
19
  def _validate_project(self, project: str) -> None:
16
20
  if not project:
17
21
  return
22
+
18
23
  path = Path(project).expanduser().resolve()
19
24
  if path.exists() and path.is_dir():
20
- git_dir = path / ".git"
21
- if git_dir.exists():
22
- return
23
- raise ValueError(f"不是 git 仓库: {project}")
25
+ return
26
+
27
+ from ..settings import load_settings_if_exists
28
+ from ..engines import list_backend_ids
29
+
30
+ result = load_settings_if_exists()
31
+ if result is None:
32
+ return
33
+
34
+ settings, config_path = result
35
+ engine_ids = list_backend_ids()
36
+ projects_config = settings.to_projects_config(
37
+ config_path=config_path,
38
+ engine_ids=engine_ids
39
+ )
40
+
41
+ if project.lower() in projects_config.projects:
42
+ return
43
+
44
+ available = list(projects_config.projects.keys())
45
+ if available:
46
+ raise ValueError(
47
+ f"未知项目: {project}。可用项目: {', '.join(available)}"
48
+ )
49
+ else:
50
+ raise ValueError(
51
+ f"未知项目: {project}。请先使用 'yee88 init' 注册项目"
52
+ )
24
53
 
25
54
  def load(self):
26
55
  if not self.file.exists():
@@ -99,7 +128,7 @@ class CronManager:
99
128
  return False
100
129
 
101
130
  def get_due_jobs(self) -> List[CronJob]:
102
- now = datetime.now()
131
+ now = datetime.now(self.timezone)
103
132
  due = []
104
133
  one_time_completed = []
105
134
 
@@ -107,30 +136,38 @@ class CronManager:
107
136
  if not job.enabled:
108
137
  continue
109
138
 
110
- # 一次性任务处理
111
139
  if job.one_time:
112
140
  try:
113
141
  exec_time = datetime.fromisoformat(job.schedule)
142
+ if exec_time.tzinfo is None:
143
+ exec_time = exec_time.replace(tzinfo=self.timezone)
114
144
  if exec_time <= now:
115
145
  due.append(job)
116
146
  one_time_completed.append(job.id)
117
147
  except Exception:
118
148
  continue
119
149
  else:
120
- # 周期性任务处理
121
150
  try:
122
- base = datetime.fromisoformat(job.last_run) if job.last_run else now
123
- itr = croniter(job.schedule, base)
124
- next_run = itr.get_next(datetime)
125
-
126
- if next_run <= now:
127
- due.append(job)
128
- job.last_run = now.isoformat()
129
- job.next_run = itr.get_next(datetime).isoformat()
151
+ if job.last_run:
152
+ base = datetime.fromisoformat(job.last_run)
153
+ if base.tzinfo is None:
154
+ base = base.replace(tzinfo=self.timezone)
155
+ itr = croniter(job.schedule, base)
156
+ next_run = itr.get_next(datetime)
157
+ if next_run <= now:
158
+ due.append(job)
159
+ job.last_run = now.isoformat()
160
+ job.next_run = itr.get_next(datetime).isoformat()
161
+ else:
162
+ itr = croniter(job.schedule, now)
163
+ prev_run = itr.get_prev(datetime)
164
+ if prev_run.date() == now.date():
165
+ due.append(job)
166
+ job.last_run = now.isoformat()
167
+ job.next_run = itr.get_next(datetime).isoformat()
130
168
  except Exception:
131
169
  continue
132
170
 
133
- # 删除已完成的一次性任务
134
171
  if one_time_completed:
135
172
  self.jobs = [j for j in self.jobs if j.id not in one_time_completed]
136
173
 
yee88/ids.py CHANGED
@@ -7,7 +7,7 @@ _ID_RE = re.compile(ID_PATTERN)
7
7
 
8
8
  RESERVED_CLI_COMMANDS = frozenset({"config", "doctor", "init", "plugins"})
9
9
  RESERVED_CHAT_COMMANDS = frozenset(
10
- {"cancel", "file", "new", "agent", "model", "reasoning", "trigger", "topic", "ctx"}
10
+ {"cancel", "file", "new", "fork", "agent", "model", "reasoning", "trigger", "topic", "ctx"}
11
11
  )
12
12
  RESERVED_ENGINE_IDS = RESERVED_CLI_COMMANDS | RESERVED_CHAT_COMMANDS
13
13
  RESERVED_COMMAND_IDS = RESERVED_CLI_COMMANDS | RESERVED_CHAT_COMMANDS
@@ -36,13 +36,38 @@ description: 当用户说"提醒我"、"X分钟/小时后"、"定时"、"每天/
36
36
 
37
37
  ## ⛔ 默认不传 --project!除非用户明确要求
38
38
 
39
- **简单规则:不知道项目别名就不传 `--project`,让 yee88 使用默认上下文。**
39
+ ### 🧠 COT:判断是否需要 --project
40
40
 
41
- | 场景 | 做法 |
42
- |------|------|
43
- | 用户只说"1分钟后提醒我..." | `yee88 cron add reminder "+1m" "..." -o` (不传 --project) |
44
- | 用户说"在 takopi 项目提醒我..." | `yee88 cron add reminder "+1m" "..." --project takopi -o` |
45
- | 不确定项目别名 | **不传 --project** |
41
+ **收到定时任务请求时,先在脑中过一遍这个决策流程:**
42
+
43
+ ```
44
+ 1. 用户有没有提到具体项目名?
45
+ - "在 takopi 项目下..." → 需要 --project
46
+ - "帮我在 work 项目..." → 需要 --project
47
+ - "提醒我喝水" → 不需要 --project
48
+ - "每天9点提醒站会" → 不需要 --project
49
+
50
+ 2. 用户有没有说"在某个项目下运行"?
51
+ - "在 xxx 下执行..." → 需要 --project
52
+ - "切到 xxx 项目..." → 需要 --project
53
+ - 没有提到项目上下文 → 不需要 --project
54
+
55
+ 3. 这个任务是通用提醒还是项目相关?
56
+ - 喝水、休息、开会 → 通用,不需要 --project
57
+ - 代码审查、部署、PR → 可能需要,但除非用户说了项目名,否则不传
58
+ ```
59
+
60
+ **结论:99% 的情况不需要 --project。只有用户明确说了项目名才传。**
61
+
62
+ ### 决策表
63
+
64
+ | 用户说的话 | 需要 --project? | 命令 |
65
+ |-----------|----------------|------|
66
+ | "1分钟后提醒我喝水" | ❌ 不需要 | `yee88 cron add reminder "+1m" "喝水" -o` |
67
+ | "每天9点提醒我站会" | ❌ 不需要 | `yee88 cron add standup "0 9 * * *" "站会时间"` |
68
+ | "30分钟后提醒我休息" | ❌ 不需要 | `yee88 cron add break "+30m" "休息一下" -o` |
69
+ | "在 takopi 项目下每天9点跑测试" | ✅ 需要 | `yee88 cron add test "0 9 * * *" "跑测试" --project takopi` |
70
+ | "帮我在 work 项目设个提醒" | ✅ 需要 | `yee88 cron add reminder "+1h" "..." --project work -o` |
46
71
 
47
72
  **⚠️ --project 只接受项目别名,不是路径!**
48
73
 
@@ -500,12 +525,38 @@ yee88 cron run <task-id>
500
525
  - 执行后自动从列表中删除
501
526
  - 无法对一次性任务使用 enable/disable(执行前自动删除)
502
527
 
528
+ ### 10. 会话接力(Handoff)
529
+
530
+ 将当前 OpenCode 会话上下文发送到 Telegram,方便在手机上继续对话。
531
+
532
+ ```bash
533
+ yee88 handoff
534
+ ```
535
+
536
+ 功能:
537
+ - 自动列出当前项目的最近会话(带话题名称)
538
+ - 选择会话后,创建新的 Telegram Topic
539
+ - 将会话上下文和最近消息发送到 Topic
540
+ - 在 Telegram 中直接继续对话
541
+
542
+ 选项:
543
+ - `--session, -s`: 指定会话 ID(默认交互选择)
544
+ - `--limit, -n`: 包含的消息数量(默认 3)
545
+ - `--project, -p`: 项目名称
546
+
547
+ 示例:
548
+ ```bash
549
+ yee88 handoff
550
+ yee88 handoff -s ses_abc123 -n 5
551
+ ```
552
+
503
553
  ## 完整命令速查表
504
554
 
505
555
  | 命令 | 说明 |
506
556
  |------|------|
507
557
  | `yee88` | 启动 yee88 |
508
558
  | `yee88 init <alias>` | 注册项目 |
559
+ | `yee88 handoff` | 会话接力到 Telegram |
509
560
  | `yee88 config path` | 查看配置路径 |
510
561
  | `yee88 config list` | 列出配置 |
511
562
  | `yee88 config get <key>` | 获取配置项 |
@@ -18,6 +18,7 @@ from .reasoning import _handle_reasoning_command as handle_reasoning_command
18
18
  from .topics import _handle_chat_new_command as handle_chat_new_command
19
19
  from .topics import _handle_chat_ctx_command as handle_chat_ctx_command
20
20
  from .topics import _handle_ctx_command as handle_ctx_command
21
+ from .topics import _handle_fork_command as handle_fork_command
21
22
  from .topics import _handle_new_command as handle_new_command
22
23
  from .topics import _handle_topic_command as handle_topic_command
23
24
  from .trigger import _handle_trigger_command as handle_trigger_command
@@ -31,6 +32,7 @@ __all__ = [
31
32
  "handle_ctx_command",
32
33
  "handle_file_command",
33
34
  "handle_file_put_default",
35
+ "handle_fork_command",
34
36
  "handle_media_group",
35
37
  "handle_model_command",
36
38
  "handle_new_command",
@@ -83,7 +83,10 @@ def build_bot_commands(
83
83
  commands.append({"command": cmd, "description": description})
84
84
  seen.add(cmd)
85
85
  if include_topics:
86
- for cmd, description in [("topic", "create or bind a topic")]:
86
+ for cmd, description in [
87
+ ("topic", "create or bind a topic"),
88
+ ("fork", "fork current topic to new topic"),
89
+ ]:
87
90
  if cmd in seen:
88
91
  continue
89
92
  commands.append({"command": cmd, "description": description})
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import re as _re
3
4
  from typing import TYPE_CHECKING
4
5
 
5
6
  from ...context import RunContext
@@ -340,3 +341,84 @@ async def _handle_topic_command(
340
341
  message=RenderedMessage(text=rendered_text, extra={"entities": entities}),
341
342
  options=SendOptions(thread_id=thread_id),
342
343
  )
344
+
345
+
346
+ _FORK_TITLE_PATTERN = _re.compile(r"^(.+) \(fork #(\d+)\)$")
347
+
348
+
349
+ def _get_forked_title(title: str | None) -> str:
350
+ if title is None:
351
+ return "topic (fork #1)"
352
+ match = _FORK_TITLE_PATTERN.match(title)
353
+ if match:
354
+ base = match.group(1)
355
+ num = int(match.group(2))
356
+ return f"{base} (fork #{num + 1})"
357
+ return f"{title} (fork #1)"
358
+
359
+
360
+ async def _handle_fork_command(
361
+ cfg: TelegramBridgeConfig,
362
+ msg: TelegramIncomingMessage,
363
+ store: TopicStateStore,
364
+ *,
365
+ resolved_scope: str | None = None,
366
+ scope_chat_ids: frozenset[int] | None = None,
367
+ ) -> None:
368
+ from ...model import ResumeToken
369
+
370
+ reply = make_reply(cfg, msg)
371
+ error = _topics_command_error(
372
+ cfg,
373
+ msg.chat_id,
374
+ resolved_scope=resolved_scope,
375
+ scope_chat_ids=scope_chat_ids,
376
+ )
377
+ if error is not None:
378
+ await reply(text=error)
379
+ return
380
+ tkey = _topic_key(msg, cfg, scope_chat_ids=scope_chat_ids)
381
+ if tkey is None:
382
+ await reply(text="this command only works inside a topic.")
383
+ return
384
+
385
+ snapshot = await store.get_thread(*tkey)
386
+ current_title = snapshot.topic_title if snapshot else None
387
+ forked_title = _get_forked_title(current_title)
388
+
389
+ created = await cfg.bot.create_forum_topic(msg.chat_id, forked_title)
390
+ if created is None:
391
+ await reply(text="failed to create forked topic.")
392
+ return
393
+
394
+ new_thread_id = created.message_thread_id
395
+
396
+ if snapshot and snapshot.context:
397
+ await store.set_context(
398
+ msg.chat_id,
399
+ new_thread_id,
400
+ snapshot.context,
401
+ topic_title=forked_title,
402
+ )
403
+
404
+ if snapshot and snapshot.sessions:
405
+ for engine, resume_value in snapshot.sessions.items():
406
+ token = ResumeToken(engine=engine, value=resume_value)
407
+ await store.set_session_resume(msg.chat_id, new_thread_id, token)
408
+
409
+ await reply(text=f"forked to new topic `{forked_title}`.")
410
+
411
+ context_label = _format_context(cfg.runtime, snapshot.context) if snapshot and snapshot.context else "none"
412
+ sessions_label = ", ".join(sorted(snapshot.sessions.keys())) if snapshot and snapshot.sessions else "none"
413
+ welcome_text = (
414
+ f"forked from previous topic\n\n"
415
+ f"context: `{context_label}`\n"
416
+ f"sessions: {sessions_label}\n\n"
417
+ "continue your conversation here!"
418
+ )
419
+ rendered_text, entities = prepare_telegram(MarkdownParts(header=welcome_text))
420
+ await cfg.exec_cfg.transport.send(
421
+ channel_id=msg.chat_id,
422
+ message=RenderedMessage(text=rendered_text, extra={"entities": entities}),
423
+ options=SendOptions(thread_id=new_thread_id),
424
+ )
yee88/telegram/loop.py CHANGED
@@ -44,6 +44,7 @@ from .commands.handlers import (
44
44
  handle_ctx_command,
45
45
  handle_file_command,
46
46
  handle_file_put_default,
47
+ handle_fork_command,
47
48
  handle_media_group,
48
49
  handle_model_command,
49
50
  handle_new_command,
@@ -224,6 +225,15 @@ def _dispatch_builtin_command(
224
225
  resolved_scope=resolved_scope,
225
226
  scope_chat_ids=scope_chat_ids,
226
227
  )
228
+ elif command_id == "fork":
229
+ handler = partial(
230
+ handle_fork_command,
231
+ cfg,
232
+ msg,
233
+ topic_store,
234
+ resolved_scope=resolved_scope,
235
+ scope_chat_ids=scope_chat_ids,
236
+ )
227
237
  elif command_id == "topic":
228
238
  handler = partial(
229
239
  handle_topic_command,
@@ -1077,7 +1087,8 @@ async def run_main_loop(
1077
1087
  tg.start_soon(run_config_watch)
1078
1088
 
1079
1089
  if config_path is not None:
1080
- cron_manager = CronManager(config_path.parent)
1090
+ cron_manager = CronManager(config_path.parent, timezone="Asia/Shanghai")
1091
+ logger.info("cron.manager.initialized", timezone="Asia/Shanghai")
1081
1092
 
1082
1093
  async def _execute_cron_job(job: CronJob) -> None:
1083
1094
  try:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: yee88
3
- Version: 0.6.3
3
+ Version: 0.7.1
4
4
  Summary: Telegram bridge for Codex, Claude Code, and other agent CLIs.
5
5
  Project-URL: Homepage, https://github.com/banteg/yee88
6
6
  Project-URL: Documentation, https://yee88.dev/
@@ -10,7 +10,7 @@ yee88/context.py,sha256=2GXdWY8KpWbEARJNo34unLN8nqGBtiE72Z_mjwrJTUM,187
10
10
  yee88/directives.py,sha256=28XG1f7yQs8GAgvPoy5g0pTe_aaVQeWFSD4-S5mC5oo,4583
11
11
  yee88/engines.py,sha256=uXtGie-XkQDB1LROTjPlCkYKbCu7XgyTr0yGn837gN4,1646
12
12
  yee88/events.py,sha256=1yo6l5D4xFreyPoU1d4L1ckaplPizxpFeJJevP8JDtk,4225
13
- yee88/ids.py,sha256=uU2A7x61Xn3_Pj-YgY5-EruoOtwDSXOp2D-XduTqAEo,534
13
+ yee88/ids.py,sha256=n8ULXEUpeHRCGc571ZH2IAIGPRuf9lB0g1pZRFm8dWY,542
14
14
  yee88/lockfile.py,sha256=xCSsxVQMqnCN2RWFkkDOVg1qm5D-Sb4AMKr6FdP0kbY,4162
15
15
  yee88/logging.py,sha256=aqoIClE5_vlASKXsc9FjrmsmBy-7jLYOomHheSWSadA,8227
16
16
  yee88/markdown.py,sha256=__2qyoBXTBZVVD7DgZvjDaj67xQCJHMfU0yQ8jcY1pQ,9144
@@ -28,10 +28,11 @@ yee88/transport.py,sha256=hzVJJO4mk1CaXgzsxSiMDqYA8xKMPD-SSQKhHDnwlzE,1330
28
28
  yee88/transport_runtime.py,sha256=3NCcnnHAollwTOwpQzA9pYIF2jKNEZx3qKUIvK3d0Zk,10986
29
29
  yee88/transports.py,sha256=8igVsWgvx4d743SE98LyNuKYaci0-X61IqNiO4U-_Ck,2013
30
30
  yee88/worktrees.py,sha256=KDaT1S0-kQH5v0FmiWgG51jgGviyATVEgfoqRrLmh7Y,3907
31
- yee88/cli/__init__.py,sha256=vJjSKBL51hRAXWHK0W1P93AKbKniYc8eR25UVY_MctI,6597
31
+ yee88/cli/__init__.py,sha256=_Ji5MfYqvF2nuC7_sgbtZOOe0pvIuk0RQ47Hw_aCV_k,6684
32
32
  yee88/cli/config.py,sha256=gTDdaTkJGGooeGECgcOXkpF-Y7Kmqi_Iyum0ghQzazg,9560
33
33
  yee88/cli/cron.py,sha256=uvzgkAmMom8sDMjLgdMcB8V7DACmmdoGTiefif1Ol3U,6215
34
34
  yee88/cli/doctor.py,sha256=FPLXk-ZSPZiHtWrncEqborcz4e1dhb3S5sUVcOw-n60,6000
35
+ yee88/cli/handoff.py,sha256=gd_4HE98wooT4n9gM2if5LkXZMoMi47Fd3A5U4ogBzE,9417
35
36
  yee88/cli/init.py,sha256=y1Z08rh9t4RPhzsgJRtopgd6UvjEPYi9Wfv_t2KlrxI,3761
36
37
  yee88/cli/onboarding_cmd.py,sha256=VNJl9cpfz-Bo7LNpGUIH-VtbOFdATTn1ry21cwtyQQM,4170
37
38
  yee88/cli/plugins.py,sha256=Kxr1FgLIjN6c8il83_cfE9nix0jYPUvI6KXtol3Swow,6319
@@ -39,7 +40,7 @@ yee88/cli/reload.py,sha256=ays9R2kJ0IQEPGfxb3BY7R2mxbJBmp325dKrqqv1nw8,3552
39
40
  yee88/cli/run.py,sha256=qKMJCEwoiNN4Qqt59Gmm1bDQHTboGf8JO73gMeACAck,14197
40
41
  yee88/cli/topic.py,sha256=6j_o0wpHMfkyeyj9wcjU5T5PVXw_cOM0qM5fsMDRiIw,11019
41
42
  yee88/cron/__init__.py,sha256=WZN2RcqJD4i6ZPZjI6b_sKI1VxhNGzhDu2Pju5u9J0o,152
42
- yee88/cron/manager.py,sha256=YNIjzQwIS1fMi83LzZe2pxYSu2uPbey0UKtXa6xUmq8,4050
43
+ yee88/cron/manager.py,sha256=MOKYxGMEDH-rMMdd5nZmltp1kimzrO9L8OuV-HIoKnc,5500
43
44
  yee88/cron/models.py,sha256=IZtUvMBr2_wZbgEWR3U9yQW-QEOMzUybDER8B7Ne8Go,224
44
45
  yee88/cron/scheduler.py,sha256=ITNqpF4MXUieuuFBvVHgzOVxxR5bnhGAFYvWTJWy-IU,1922
45
46
  yee88/runners/__init__.py,sha256=McKaMqLXT9dJlgiEwKf6biD0Ns66Fk7SrxwtcP0ZgzI,30
@@ -55,7 +56,7 @@ yee88/schemas/claude.py,sha256=HqOik1O4u3WcMb4RN2cTVJw6VRYn3EaYj8h5Sevs1XY,5702
55
56
  yee88/schemas/codex.py,sha256=bgIsh35LuaqoOYdTl6BWR-mn-mvh3ouwes_Jn9JMVXg,3412
56
57
  yee88/schemas/opencode.py,sha256=ODhnKXTzxZ_8qaQ7AYqXB7J-XoAjQnXbGMBVTUEM2qY,1175
57
58
  yee88/schemas/pi.py,sha256=e5ETawxk8jrdJbEbeBI_pWQKeCFiBBZLEF-Wo5Fx0XY,2680
58
- yee88/skills/yee88/SKILL.md,sha256=Bba3kuF2jrj07EVs7oI9D1TXvxkMKzwDRcOHp5sv1Zk,13320
59
+ yee88/skills/yee88/SKILL.md,sha256=GrZ7a8sm2ArUo5OdeUC3OECavnykx2DcsBI5EdtdvuQ,15113
59
60
  yee88/telegram/__init__.py,sha256=hX_Wvu920dNLTDrKlj0fsZFSewOo-_otN26O1UNPNA4,452
60
61
  yee88/telegram/api_models.py,sha256=d3H4o2sRZuFgfZfxF1Y1z_xzzCoOy3bKhMeKggYCW3w,538
61
62
  yee88/telegram/api_schemas.py,sha256=Xj-i68GFSb9Uk6GhU2Etp-gYhAat9DKYcvQN9av1NPQ,3823
@@ -69,7 +70,7 @@ yee88/telegram/context.py,sha256=Hb8-k-YbAjO0EmZ35hA8yyMvl1kozgYHUL1L-lbCglA,468
69
70
  yee88/telegram/engine_defaults.py,sha256=n6ROkTmP_s-H5AhPz_OdT62oZf0QtZJyFEDjp5gfub4,2594
70
71
  yee88/telegram/engine_overrides.py,sha256=kv2j102VP-Bqzbutd5ApBkjW3LmVwvCYixsFewVXVeY,3122
71
72
  yee88/telegram/files.py,sha256=Cvmw6r_ocSb3jLzJLGVbzr46m8cRU159majJ1-A5lvg,5053
72
- yee88/telegram/loop.py,sha256=TpsDgEQMv6bJFCwd72lIxzhCe8V8g3p7P_q7_XMI_WM,69363
73
+ yee88/telegram/loop.py,sha256=pjSIsvIAg4N-83EjNHkDnoz8LHF0rQkwkdCN8NAcNZY,69778
73
74
  yee88/telegram/onboarding.py,sha256=QWYaJT_s2bujDxzKjsZuLytyxs_XFDRuiBrsZGRjoOw,35633
74
75
  yee88/telegram/outbox.py,sha256=OcoRyQ7zmQCXR8ZXEMK2f_7-UMRVRAbBgmJGS1u_lcU,5939
75
76
  yee88/telegram/parsing.py,sha256=5PvIPns1NnKryt3XNxPCp4BpWX1gI8kjKi4VxcQ0W-Q,7429
@@ -86,16 +87,16 @@ yee88/telegram/commands/cancel.py,sha256=jE93VjztNETlmAgb7FJX0sLR3O-NHy5XcraUbK1
86
87
  yee88/telegram/commands/dispatch.py,sha256=zcvX0V6_klf5jXqJlBwAK25e83WC9z3-KoRcDbeWre0,3572
87
88
  yee88/telegram/commands/executor.py,sha256=eczs7Ds_S8GVX2_OvdmN23y6zE1fhYTpIAuchfH0eRU,14781
88
89
  yee88/telegram/commands/file_transfer.py,sha256=yfopf_If9f7Scaz4dlUsfcrVvg24QmQdajLzemaENy0,17697
89
- yee88/telegram/commands/handlers.py,sha256=2zySjRW49e1iv2QJW6xq2m9j0t-uz9_FCJZpE7NhIDA,1834
90
+ yee88/telegram/commands/handlers.py,sha256=KsmV6U_TKeNBvy5UdjuC4DQ3_xYiw_CFIYNrrQYlUfQ,1925
90
91
  yee88/telegram/commands/media.py,sha256=drSKTf_BbyvXOGhS9UKwey_243Ic5XissoaxCykpd-c,5045
91
- yee88/telegram/commands/menu.py,sha256=AvEgKQUZageBx7TwV2-V_IGhT7AC0qUTtAHjcLIweNY,4466
92
+ yee88/telegram/commands/menu.py,sha256=vkvH0E4-sizhtyLgq5YRcptcWkzsVgMHkADxkQGscFo,4546
92
93
  yee88/telegram/commands/model.py,sha256=GwdEHuebm02aUIqkcvznPh0FSViVlAjxeMks7TjFXIE,12729
93
94
  yee88/telegram/commands/overrides.py,sha256=lLlIuCWkKwbS5WlQOOr_Ftv_EvfHR9DhjBpWaf6FBng,4853
94
95
  yee88/telegram/commands/parse.py,sha256=0QVW1TVdBWadLbpJ9lRY3s7W4Cm62JJa9jfAaFHQmXU,887
95
96
  yee88/telegram/commands/plan.py,sha256=iKsaRBS-qIfvAaxik5ZEA_VzAnFwx7aEED8sKXNq1wE,487
96
97
  yee88/telegram/commands/reasoning.py,sha256=UFEJOHm4d0v2jFh5HC3oQGS41NYKbmJHRTaAmu_LiGo,8188
97
98
  yee88/telegram/commands/reply.py,sha256=a3zkNjKzn3qZXEZFXuflX-tdhQKQyhYDqZskMy1nS3o,580
98
- yee88/telegram/commands/topics.py,sha256=Vs4BsVGRfsUSlZupOiWp7vpESm_0z-4gHOWuHOEtu7U,11207
99
+ yee88/telegram/commands/topics.py,sha256=TBUdhqtTyDv7I6qFlxeENPMfvZGkYUjvHsEI2Q2b2Mc,13899
99
100
  yee88/telegram/commands/trigger.py,sha256=RgB4V7NoFdfDLk83xl3BPTsIIVr5A2zCNstR_5B_mEw,5201
100
101
  yee88/utils/__init__.py,sha256=cV9_7v07Y6XkrUju0lHdO1ia0-Q57NqyFVMaFCg--do,34
101
102
  yee88/utils/git.py,sha256=SVKcPNl2mUrv-cVHZQ5b8QXWKi33e_Jc4_vfCLwagkg,2413
@@ -103,8 +104,8 @@ yee88/utils/json_state.py,sha256=cnSvGbB9zj90GdYSyigId1M0nEx54T3A3CqqhkAm9kQ,524
103
104
  yee88/utils/paths.py,sha256=_Tp-LyFLeyGD0P0agRudLuT1NR_XTIpryxk3OYDJAGQ,1318
104
105
  yee88/utils/streams.py,sha256=TQezA-A5VCNksLOtwsJplfr8vm1xPTXoGxvik8G2NPI,1121
105
106
  yee88/utils/subprocess.py,sha256=2if6IxTZVSB1kDa8SXw3igj3E-zhKB8P4z5MVe-odzY,2169
106
- yee88-0.6.3.dist-info/METADATA,sha256=seX0L9CSMxsL1xBkRAUhjlaX13Luv9Tkdg1GVM8cFQA,4340
107
- yee88-0.6.3.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
108
- yee88-0.6.3.dist-info/entry_points.txt,sha256=P4MVZ_sZfrHaARVMImNJjoGamP8VDukARWMKfDh20V8,282
109
- yee88-0.6.3.dist-info/licenses/LICENSE,sha256=poyQ59wnbmL3Ox3TiiephfHvUpLvJl0DwLFFgqBDdHY,1063
110
- yee88-0.6.3.dist-info/RECORD,,
107
+ yee88-0.7.1.dist-info/METADATA,sha256=CcgQN8xjM0IfbL-tv1FY8HcH4jz-tcPV27VROzOGgOg,4340
108
+ yee88-0.7.1.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
109
+ yee88-0.7.1.dist-info/entry_points.txt,sha256=P4MVZ_sZfrHaARVMImNJjoGamP8VDukARWMKfDh20V8,282
110
+ yee88-0.7.1.dist-info/licenses/LICENSE,sha256=poyQ59wnbmL3Ox3TiiephfHvUpLvJl0DwLFFgqBDdHY,1063
111
+ yee88-0.7.1.dist-info/RECORD,,
File without changes