From ae93016442e718846285945d085249f70f29000f Mon Sep 17 00:00:00 2001 From: caoqianming Date: Fri, 8 May 2026 09:55:56 +0800 Subject: [PATCH] =?UTF-8?q?cli:=20REPL=20/resume=20=E5=88=87=20task=20+=20?= =?UTF-8?q?=E6=87=92=E5=88=9B=E5=BB=BA=20task=5Fdir=20+=20=E5=88=87?= =?UTF-8?q?=E8=B5=B0=E5=89=8D=E7=A9=BA=E6=B8=85=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 加 /resume [last|] REPL 命令,无参数列最近 10 个表格让用户挑; 和 /new 对称,都在 REPL 内重建五元组。tasks 命令复用 _list_task_rows - main.py 新建分支不再 session.save() / task_state.save() 占位 —— 推迟到首条 user 消息触发的 Session.append → save() 才物化 task_dir。 启动 REPL 立刻 /exit 磁盘无痕,跨进程也安全 - _cleanup_if_empty 在 /exit /quit /new /resume + Ctrl-C/EOF 守门: 无 user 消息 + 目录在磁盘上 + 文件集 ⊆ {messages.json} 才删, state.json 存在(/done /abandon /desc 留下的显式痕迹)就保 Co-Authored-By: Claude Opus 4.7 (1M context) --- DESIGN.md | 8 ++- PROGRESS.md | 15 ++++-- cli.py | 148 ++++++++++++++++++++++++++++++++++++++++++---------- main.py | 4 +- 4 files changed, 139 insertions(+), 36 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 15ecea3..2863aef 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -76,7 +76,7 @@ zcbot/ 4. 解析 task_dir(新建 or resume) 5. 拼 system prompt:`prompts/system/general_v1.md` + `SkillRegistry.discovery_block()`(skill 列表)+ cwd + **task_dir 绝对路径**(产物根) 6. 装配工具集(fs / shell / load_skill / run_python) -7. 写初始 `state.json` + `messages.json`,启动 REPL +7. 启动 REPL —— **新建路径不预占文件**(懒创建,见 §3.6) --- @@ -136,7 +136,11 @@ yaml 是手填的,可能错。`probe` 用真实 LLM 调用对账: 存储:`workspace/tasks//{state.json, messages.json}`。每轮 `agent.run` 后调 `sync_task_tokens` 把 LLM 累计 tokens 写回。 -CLI:`chat --mode coding --desc "..."`;REPL `/status /done /abandon /desc`;`tasks [--status active|completed|abandoned]` 列任务。 +**懒创建** —— `build_agent` 新建分支不立刻 save,task_dir 在第一条 user 消息触发 `Session.append → save()` 时才物化(`Session.save` / `TaskState.save` 都 `mkdir(parents=True)`)。启动 REPL 后立刻 `/exit` 磁盘无痕,跨进程也安全(没有"另一个 REPL 刚 build_agent 还没说话就被这个进程当空 task 删掉"的窗口)。 + +**REPL 内 task 切换** —— `/new` 开新 task,`/resume [last|]` 切到已有 task(无参数列最近 10 个表格让用户选),`/done /abandon` 改状态,`/desc` 改描述。切走前 `_cleanup_if_empty` 守门:三条都满足才删 task_dir —— ① session 没 user 消息 ② 目录在磁盘上 ③ 目录里只剩 `messages.json`(state.json 存在 = `/done /abandon /desc` 留下的显式痕迹,要保)。 + +CLI:`chat --mode coding --desc "..." [--resume last|]`;`tasks [--status active|completed|abandoned]` 列任务。 --- diff --git a/PROGRESS.md b/PROGRESS.md index c13e537..c2f7d68 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -2,7 +2,7 @@ > 配合 `DESIGN.md` 阅读。本文件只记录 phase 状态、决策偏差、文件量、下一步。 -最后更新:2026-05-07(TUI 打磨 + task_dir 概念真正落地) +最后更新:2026-05-08(REPL `/resume` + 懒创建 task_dir + 切换前空清理) --- @@ -40,6 +40,11 @@ - system prompt 显式注入 `task_dir` 绝对路径,SKILL.md 里 `` 占位符**真正落地**;`spec_lock.md` / `sections/` / `slides/` / 最终 docx/pptx 全收敛到 `workspace/tasks//` - `.gitignore` 删 `sections/` `slides/` `spec_lock.md` 三条无锚 bandaid —— 现在写错位置 git status 立刻报红,不再靠 ignore 兜底 +**REPL 内 task 切换 + 懒创建**(2026-05-08): +- `/resume [last|]` REPL 命令,无参数列最近 10 个 task 表格让用户挑序号或 task_id;和 `/new` 对称,都在 REPL 内重建 (agent, session, sid, task_state, task_dir) 五元组。`tasks` 命令和 `/resume` 共用 `_list_task_rows` helper +- **懒创建 task_dir**:`build_agent` 新建分支不再 `session.save()` / `task_state.save()` 占位,推迟到首条 user 消息触发的 `Session.append → save()`。启动 REPL 立刻 `/exit` 磁盘无痕,跨进程安全(没有"另一个 REPL 刚 build_agent 没说话就被本进程当空 task 删"的窗口) +- `_cleanup_if_empty` 在切走前(`/exit /quit /new /resume` + Ctrl-C/EOF)守门。三条都满足才删 task_dir:① 无 user 消息 ② 目录在磁盘上 ③ 文件集 ⊆ `{messages.json}`(state.json 存在 = 用户跑过 `/done /abandon /desc` 留下显式痕迹,要保) + --- ## 关键决策与偏差 @@ -59,7 +64,7 @@ ``` core/capabilities.py 71 core/llm.py 89 -core/loop.py 157 ← +markdown 渲染 / spinner 显时长+token +core/loop.py 158 ← +markdown 渲染 / spinner 显时长+token core/probe.py 243 ← Phase 4 core/session.py 77 core/skills.py 81 @@ -69,10 +74,10 @@ tools/fs.py 182 tools/shell.py 94 tools/run_python.py 84 tools/skill_tool.py 45 -main.py 185 ← Phase 6 task 装配 / +task_dir 注入 -cli.py 264 ← +probe / +tasks 子命令 +main.py 185 ← Phase 6 task 装配 / +task_dir 注入 / -占位 save (懒创建) +cli.py 358 ← +probe / +tasks / +/resume / +空 task 清理 ───────────────────────────────── -Python 合计 ~1669 行 +Python 合计 ~1764 行 ``` 加上 skills/ppt 下的脚本(~600 行)、SKILL.md / references / config / prompts,总仓库约 2500 行可读源码。 diff --git a/cli.py b/cli.py index 5afb9d6..1ba6cfb 100644 --- a/cli.py +++ b/cli.py @@ -12,6 +12,7 @@ from __future__ import annotations import json +import shutil import sys import click @@ -35,6 +36,56 @@ def cli() -> None: """zcbot - 个人任务 agent""" +def _cleanup_if_empty(task_dir, session, console=None) -> bool: + """切走前清理 task_dir。三条都满足才删: + 1) session 没有 user 消息 + 2) task_dir 在磁盘上(懒创建后,没说话就没目录,直接 no-op) + 3) 目录里只剩 messages.json(state.json 存在 = `/done /abandon /desc` 留下的显式痕迹,要保) + """ + if any(m.get("role") == "user" for m in session.messages): + return False + try: + entries = list(task_dir.iterdir()) + except FileNotFoundError: + return False + if any(p.is_dir() for p in entries): + return False + if {p.name for p in entries if p.is_file()} - {"messages.json"}: + return False + shutil.rmtree(task_dir, ignore_errors=True) + if console is not None: + console.print(f"[muted]清理空 task {task_dir.name}[/muted]") + return True + + +def _list_task_rows(workspace_dir, limit=20, status=None): + """返回 [(mtime, task_id, status, mode, model, tokens, n_msgs, desc), ...] mtime 降序。""" + tdir = tasks_dir(workspace_dir) + rows = [] + for d in tdir.iterdir(): + if not d.is_dir(): + continue + msg_path = d / "messages.json" + if not msg_path.exists(): + continue + st = TaskState.load(d) + if st is None: + continue + if status and st.status != status: + continue + try: + data = json.loads(msg_path.read_text(encoding="utf-8")) + n = len(data.get("messages", [])) + except Exception: + n = -1 + rows.append(( + msg_path.stat().st_mtime, st.task_id, st.status, st.mode, + st.model_profile or st.model, st.tokens_total, n, st.description, + )) + rows.sort(reverse=True) + return rows[:limit] + + @cli.command() @click.option("--model", default=None, help="模型档案,如 deepseek_v4.flash 或 deepseek_v4.pro") @click.option("--workspace", default=None, help="工作目录(存 tasks/ 和 sessions/)") @@ -72,7 +123,8 @@ def chat(model: str, workspace: str, resume: str, mode: str, desc: str) -> None: f"model: [accent]{agent.caps.model_id}[/accent]{meta_tail}" ) console.print( - "[info]/exit 退出 /reset 清空对话(保留 task) /new 开新 task /id /status 查看 " + "[info]/exit 退出 /reset 清空对话(保留 task) /new 开新 task " + "/resume [last|] 切到已有 task /id /status 查看 " "/done /abandon 改状态 /desc <文本> 设描述[/info]\n" ) @@ -81,16 +133,19 @@ def chat(model: str, workspace: str, resume: str, mode: str, desc: str) -> None: user_input = Prompt.ask("[user]you[/user]", console=console) except (EOFError, KeyboardInterrupt): console.print("\n[muted]bye[/muted]") + _cleanup_if_empty(task_dir, session, console) break cmd = user_input.strip() if cmd in ("/exit", "/quit"): + _cleanup_if_empty(task_dir, session, console) break if cmd == "/reset": session.reset(keep_system=True) console.print("[info]当前 task 对话已重置(保留 system 和 state)[/info]") continue if cmd == "/new": + _cleanup_if_empty(task_dir, session, console) try: agent, session, sid, task_state, task_dir = build_agent( model_name=model, workspace=workspace, console=console, @@ -101,6 +156,69 @@ def chat(model: str, workspace: str, resume: str, mode: str, desc: str) -> None: continue console.print(f"[ok]新 task[/ok] [bold]{sid}[/bold]") continue + if cmd.startswith("/resume"): + arg = cmd[len("/resume"):].strip() + ws_dir = resolve_workspace(workspace) + target_id = None + if arg == "last": + rs = _list_task_rows(ws_dir, limit=1) + if not rs: + console.print("[warn]没有可恢复的 task[/warn]") + continue + target_id = rs[0][1] + elif arg: + target_id = arg + else: + rs = _list_task_rows(ws_dir, limit=10) + if not rs: + console.print("[warn]没有可恢复的 task[/warn]") + continue + tbl = Table(show_lines=False) + tbl.add_column("#", style="bold") + tbl.add_column("task id") + tbl.add_column("status") + tbl.add_column("mode") + tbl.add_column("msgs", justify="right") + tbl.add_column("desc") + sc = {"active": "status.active", "completed": "status.completed", "abandoned": "status.abandoned"} + for i, (_, tid, st, md, _mdl, _tok, n, dsc) in enumerate(rs, 1): + c = sc.get(st, "info") + d_show = dsc if len(dsc) <= 50 else dsc[:47] + "..." + tbl.add_row(str(i), tid, f"[{c}]{st}[/{c}]", md, str(n), d_show) + console.print(tbl) + try: + sel = Prompt.ask("[user]选编号或输入 task_id (回车取消)[/user]", console=console, default="") + except (EOFError, KeyboardInterrupt): + continue + sel = sel.strip() + if not sel: + continue + if sel.isdigit(): + idx = int(sel) - 1 + if 0 <= idx < len(rs): + target_id = rs[idx][1] + else: + console.print(f"[err]编号超界: {sel}[/err]") + continue + else: + target_id = sel + if target_id == sid: + console.print(f"[info]已是当前 task: {sid}[/info]") + continue + _cleanup_if_empty(task_dir, session, console) + try: + agent, session, sid, task_state, task_dir = build_agent( + model_name=model, workspace=workspace, console=console, + session_id=target_id, resume=True, + ) + except Exception as e: + console.print(f"[err]恢复失败:[/err] {type(e).__name__}: {e}") + continue + console.print( + f"[ok]切到 task[/ok] [bold]{sid}[/bold] ({len(session.messages)} 条消息) " + f"model: [accent]{agent.caps.model_id}[/accent]" + ) + continue if cmd == "/id": cwd_disp = session.meta.get("cwd", "?") model_disp = session.meta.get("model", agent.caps.model_id) @@ -152,34 +270,10 @@ def tasks(workspace: str, limit: int, status: str) -> None: """列出已有 task(新格式,workspace/tasks//state.json)。""" cfg = load_config() ws = resolve_workspace(workspace, cfg) - tdir = tasks_dir(ws) - - rows = [] # (mtime, task_id, status, mode, model, tokens, n_msgs, desc) - for d in tdir.iterdir(): - if not d.is_dir(): - continue - msg_path = d / "messages.json" - if not msg_path.exists(): - continue - st = TaskState.load(d) - if st is None: - continue - if status and st.status != status: - continue - try: - data = json.loads(msg_path.read_text(encoding="utf-8")) - n = len(data.get("messages", [])) - except Exception: - n = -1 - rows.append(( - msg_path.stat().st_mtime, st.task_id, st.status, st.mode, - st.model_profile or st.model, st.tokens_total, n, st.description, - )) - rows.sort(reverse=True) - rows = rows[:limit] + rows = _list_task_rows(ws, limit=limit, status=status) if not rows: - click.echo(f"(no tasks in {tdir})") + click.echo(f"(no tasks in {tasks_dir(ws)})") return tbl = Table(show_lines=False) tbl.add_column("task id", style="bold") diff --git a/main.py b/main.py index fb34ba3..7baca74 100644 --- a/main.py +++ b/main.py @@ -146,7 +146,8 @@ def build_agent( "model_profile": model, } session = Session(system_prompt=system_prompt, path=session_path, meta=meta) - session.save() # 占住文件名 + # 懒创建:不预占文件。首条 user 消息触发 Session.append → save() 才会 mkdir + 落盘。 + # task_state 同步推迟到首轮 sync_task_tokens。直到那一刻为止,task_dir 在磁盘上不存在。 task_state = TaskState( task_id=sid, mode=mode, @@ -158,7 +159,6 @@ def build_agent( cwd=str(tool_base), created_at=now_iso, ) - task_state.save(task_dir) tools = {} for cls in (ReadTool, WriteTool, EditTool, GlobTool, GrepTool, ShellTool):