core: Session/TaskState 原子写 + Phase 6 双层记忆

- core.session.atomic_write_text (tmp + fsync + os.replace) 接管 Session/
  TaskState 落盘, 中途异常不留 0 字节; _cleanup_if_empty 放过 *.tmp 孤儿
- core/memory.py: workspace/memory/{core.md, extended/} 双层记忆.
  core.md 注 system prompt, extended/*.md 索引(标题+绝对路径)注 prompt,
  内容靠 read 工具按需拉
- _build_system_prompt 从 build_agent 里提出来, new 和 resume 都走同一段,
  resume 时覆盖 messages[0] -> memory 演化即时生效
- PROGRESS/DESIGN 同步: §7 platform track 行 + A 阶段完成 + 双层记忆/原子写
  + 文件清单到 2429 行

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
caoqianming 2026-05-11 15:13:56 +08:00
parent 375bb2999c
commit e4a48fbb53
7 changed files with 195 additions and 37 deletions

View File

@ -54,6 +54,10 @@ zcbot/
│ └── models/ │ └── models/
│ └── deepseek_v4.yaml # flash + pro 两档 │ └── deepseek_v4.yaml # flash + pro 两档
├── workspace/ ├── workspace/
│ ├── memory/ # 双层记忆 (workspace 级,跨 task 共享)
│ │ ├── core.md # 注 system prompt,常驻
│ │ └── extended/ # 索引(标题+绝对路径)注 prompt,内容靠 read 工具按需拉
│ │ └── *.md
│ └── tasks/<task_id>/ │ └── tasks/<task_id>/
│ ├── state.json # TaskState │ ├── state.json # TaskState
│ ├── messages.json # Session │ ├── messages.json # Session
@ -138,10 +142,25 @@ yaml 是手填的,可能错。`probe` 用真实 LLM 调用对账:
**懒创建** —— `build_agent` 新建分支不立刻 save,task_dir 在第一条 user 消息触发 `Session.append → save()` 时才物化(`Session.save` / `TaskState.save``mkdir(parents=True)`)。启动 REPL 后立刻 `/exit` 磁盘无痕,跨进程也安全(没有"另一个 REPL 刚 build_agent 还没说话就被这个进程当空 task 删掉"的窗口)。 **懒创建** —— `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|<id>]` 切到已有 task(无参数列最近 10 个表格让用户选),`/done /abandon` 改状态,`/desc` 改描述。切走前 `_cleanup_if_empty` 守门:三条都满足才删 task_dir —— ① session 没 user 消息 ② 目录在磁盘上 ③ 目录里只剩 `messages.json`(state.json 存在 = `/done /abandon /desc` 留下的显式痕迹,要保)。 **REPL 内 task 切换** —— `/new` 开新 task,`/resume [last|<id>]` 切到已有 task(无参数列最近 10 个表格让用户选),`/done /abandon` 改状态,`/desc` 改描述。切走前 `_cleanup_if_empty` 守门:三条都满足才删 task_dir —— ① session 没 user 消息 ② 目录在磁盘上 ③ 目录里只剩 `messages.json`(state.json 存在 = `/done /abandon /desc` 留下的显式痕迹,要保;原子写的 `*.tmp` 孤儿不算)。
**原子落盘** —— `Session.save``TaskState.save` 都走 `core.session.atomic_write_text`:先写 `path.tmp` + `fsync`,再 `os.replace` 到目标。中途异常(磁盘满 / surrogate 编码错 / 进程被杀)不留 0 字节或半文件,老内容保留。
CLI:`chat --mode coding --desc "..." [--resume last|<id>]`;`tasks [--status active|completed|abandoned]` 列任务。 CLI:`chat --mode coding --desc "..." [--resume last|<id>]`;`tasks [--status active|completed|abandoned]` 列任务。
### 3.7 双层记忆(`core/memory.py`)
跨 task 共享的事实(用户偏好 / 项目约定 / 模型 quirk 备忘)放 `workspace/memory/`,两层切法:
| 层 | 文件 | 加载时机 | 适合内容 |
|---|------|---------|---------|
| Core | `workspace/memory/core.md` | 每次 build_agent 拼进 system prompt | 跨任务高频用的精炼事实(几百 token 内) |
| Extended | `workspace/memory/extended/*.md` | 索引(标题+绝对路径)进 prompt,内容靠 `read` 工具按需拉 | 大量低频专题(API 速查 / 历史事件) |
**system prompt 每次 build_agent 重建**,resume 也走 `_build_system_prompt` 并覆盖 `messages[0]` —— memory 演化即时生效。代价:resume 时上下文里的 system 段可能和上一轮不一样,但跨轮强一致性不是个人 agent 的痛点,memory 时效性更重要。
memory 文件由人填(也允许 agent 用 `write` 写)。系统不自动维护 —— 这是和"auto memory"框架的关键差异:**事实由用户判断,不由 LLM 自动总结**(后者噪音和误判风险高)。
--- ---
## 4. 模型路由 ## 4. 模型路由

View File

@ -2,7 +2,7 @@
> 配合 `DESIGN.md` 阅读。本文件只记录 phase 状态、决策偏差、文件量、下一步。 > 配合 `DESIGN.md` 阅读。本文件只记录 phase 状态、决策偏差、文件量、下一步。
最后更新:2026-05-08(REPL `/resume` + 懒创建 task_dir + 切换前空清理) 最后更新:2026-05-11(原子写 + Phase 6 双层记忆)
--- ---
@ -15,8 +15,9 @@
| 3 | Hybrid 范式 (run_python) | ✅ | subprocess + 敏感 env 过滤 | | 3 | Hybrid 范式 (run_python) | ✅ | subprocess + 敏感 env 过滤 |
| 4 | 演化性能力 | 🟡 | Model Profile + Capability Probing ✅;版本化 prompts 未做 | | 4 | 演化性能力 | 🟡 | Model Profile + Capability Probing ✅;版本化 prompts 未做 |
| 5 | Eval Suite | ⏸ 不做 | 个人工具用 dogfooding 替代,probe 覆盖健康检查 | | 5 | Eval Suite | ⏸ 不做 | 个人工具用 dogfooding 替代,probe 覆盖健康检查 |
| 6 | 长任务工程化 | 🟡 | task + state.json + 中断恢复 ✅;context 压缩、双层记忆未做 | | 6 | 长任务工程化 | 🟡 | task + state.json + 中断恢复 ✅;双层记忆 ✅;context 压缩未做 |
| 7 | 打磨 | ❌ | Docker 沙盒 / 更多 skill / Web UI | | 7 | 打磨 | ❌ | Docker 沙盒 / 更多 skill / Web UI |
| §7 platform track | Core/Platform 切分(SaaS 化) | 🟡 | A 阶段 loop 事件流化 ✅;B-E 待平台 kickoff 后开 |
--- ---
@ -45,6 +46,21 @@
- **懒创建 task_dir**:`build_agent` 新建分支不再 `session.save()` / `task_state.save()` 占位,推迟到首条 user 消息触发的 `Session.append → save()`。启动 REPL 立刻 `/exit` 磁盘无痕,跨进程安全(没有"另一个 REPL 刚 build_agent 没说话就被本进程当空 task 删"的窗口) - **懒创建 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` 留下显式痕迹,要保) - `_cleanup_if_empty` 在切走前(`/exit /quit /new /resume` + Ctrl-C/EOF)守门。三条都满足才删 task_dir:① 无 user 消息 ② 目录在磁盘上 ③ 文件集 ⊆ `{messages.json}`(state.json 存在 = 用户跑过 `/done /abandon /desc` 留下显式痕迹,要保)
**§7 草案 + 对话导出**(2026-05-09 → 05-10):
- DESIGN §7 加 Core/Platform 切分草案(SaaS 化方向):资源模型 `/v1/*` + SSE 事件流、Postgres + 本地盘存储、Per-task Docker 容器 + per-run exec、多租户鉴权与隔离、A-E 五段落地。**§1-§6 personal-tool 路线照走,不阻塞 dogfood**
- `cli.py export <task_id> [--out path]` + `core/export_docx.py` —— 把 task 对话(user/assistant/tool)倒成 `.docx`,assistant 走 markdown→docx 转换;方便归档/外发
**§7 A 阶段:loop 事件流化**(2026-05-11):
- `core/loop.py` 去掉所有 `console.print`,改 `sink.emit({type, ...})`。事件:`llm_start / llm_end / text / tool_call / tool_result / done`
- 新增 `core/sinks.py` + `ConsoleEventSink` 接管渲染:spinner / `[in N out N t Xs]` / assistant Markdown / `tool>(args)` / 结果预览。CLI 行为**零回归**
- 给后续 SSE 铺路:接 HTTP 时换 sink 实现(把 emit 转 yield)即可,loop 一行不用动
**原子写 + 双层记忆**(2026-05-11):
- `core.session.atomic_write_text`(tmp + fsync + `os.replace`)接管 `Session.save` / `TaskState.save`,中途异常不留 0 字节;`_cleanup_if_empty` 放过 `*.tmp` 孤儿
- 新增 `core/memory.py`:`workspace/memory/core.md` 注 system prompt,`extended/*.md` 索引(标题+绝对路径)注 prompt,内容靠 `read` 工具按需拉
- `_build_system_prompt` 提出来,**new 和 resume 都走同一段**,memory 演化即时生效(resume 时覆盖 `messages[0]`,代价是上一轮的 system 段不再同形,memory 时效性更重要)
- DESIGN §3.7 记法,目录树补 `workspace/memory/`
--- ---
## 关键决策与偏差 ## 关键决策与偏差
@ -52,7 +68,7 @@
| 项 | 决策 | 与设计差异 | | 项 | 决策 | 与设计差异 |
|---|------|-----------| |---|------|-----------|
| 工具基目录 | 用户当前 cwd(读)+ task_dir(写) | system prompt 同时给 cwd 与 task_dir 绝对路径,SKILL.md `<task_dir>` 占位符指向 task_dir | | 工具基目录 | 用户当前 cwd(读)+ task_dir(写) | system prompt 同时给 cwd 与 task_dir 绝对路径,SKILL.md `<task_dir>` 占位符指向 task_dir |
| Workspace 用途 | `tasks/<id>/{state.json, messages.json}` | memory/ 待 Phase 6 双层记忆 | | Workspace 用途 | `tasks/<id>/{state.json, messages.json}` + `memory/{core.md, extended/}` | memory 跨 task 共享 |
| Eval Suite | 不做 | 设计为团队场景;个人工具 dogfooding 替代 | | Eval Suite | 不做 | 设计为团队场景;个人工具 dogfooding 替代 |
| 版本化 prompt | 直接 `general_v1.md`,无 active.md 软链接 | Windows 软链接麻烦,真要切版本时再做 | | 版本化 prompt | 直接 `general_v1.md`,无 active.md 软链接 | Windows 软链接麻烦,真要切版本时再做 |
| run_python 沙盒 | subprocess + env 过滤 | 阶段 1 设计如此;Docker 待 Phase 7 | | run_python 沙盒 | subprocess + env 过滤 | 阶段 1 设计如此;Docker 待 Phase 7 |
@ -64,31 +80,33 @@
``` ```
core/capabilities.py 71 core/capabilities.py 71
core/llm.py 89 core/llm.py 89
core/loop.py 158 ← +markdown 渲染 / spinner 显时长+token core/loop.py 152 ← §7 A: 去 console.print,改 sink.emit
core/sinks.py 101 ← §7 A 新增: ConsoleEventSink
core/ui.py 38 ← 语义化 console 主题
core/probe.py 243 ← Phase 4 core/probe.py 243 ← Phase 4
core/session.py 77 core/session.py 93 ← +atomic_write_text
core/skills.py 81 core/skills.py 81
core/task.py 63 ← Phase 6 core/task.py 64 ← Phase 6
core/memory.py 76 ← Phase 6 双层记忆
core/export_docx.py 372 ← task 对话导出 .docx
tools/base.py 34 tools/base.py 34
tools/fs.py 182 tools/fs.py 182
tools/shell.py 94 tools/shell.py 94
tools/run_python.py 84 tools/run_python.py 84
tools/skill_tool.py 45 tools/skill_tool.py 45
main.py 185 ← Phase 6 task 装配 / +task_dir 注入 / -占位 save (懒创建) main.py 210 ← +memory 注入 (_build_system_prompt)
cli.py 358 ← +probe / +tasks / +/resume / +空 task 清理 cli.py 439 ← +export 命令 / cleanup 放过 .tmp
───────────────────────────────── ─────────────────────────────────
Python 合计 ~1764 Python 合计 ~2429
``` ```
加上 skills/ppt 下的脚本(~600 行)、SKILL.md / references / config / prompts,总仓库约 2500 行可读源码。 加上 skills/ppt 下的脚本(~600 行)、SKILL.md / references / config / prompts,总仓库约 3000 行可读源码。
--- ---
## 下一步候选(性价比排序) ## 下一步候选(性价比排序)
1. **Phase 6 双层记忆**(~半天)—— `workspace/memory/core.md` 注 prompt,`extended/<topic>.md` 按需读 1. **§7 B 阶段:Storage / Executor / DI / KeyProvider**(~1 周)—— Session/TaskState 落 Postgres、Executor 抽象走 docker exec、(tenant_id, user_id) 上下文透传、API key 改 KeyProvider。**等平台 kickoff 时间锁定后开**
2. **Phase 6 context 三层压缩**(~1 天)—— 兜底用,V4 长上下文一般用不到 2. **Phase 6 context 三层压缩**(~1 天)—— 兜底用,V4 长上下文一般用不到
3. **小修打磨**(~半小时)—— `Session.save()` 改原子写(tmp + rename),防 surrogate 等异常 truncate 3. **Phase 7 更多 skill / 模型档案**(持续)
4. **Phase 7 Docker 沙盒**(~1 天)—— 替换 subprocess,run_python 安全升级 4. **Proposal mermaid 流程图预渲染**(~半天,看到第二张图再做)—— 现状是 ASCII 框图走 fenced code 透传 (新宋体 + Consolas + xml:space=preserve),中文与 box drawing 字符宽度对不齐时还是有错位。增强方案: ` ```mermaid ` 块在 `render_docx.py` 里调 `mmdc` (mermaid-cli) → PNG → `add_picture` 嵌入。依赖 Node.js + `npm i -g @mermaid-js/mermaid-cli`,首次配置略麻烦,所以等 ASCII 透传明显不够用再做
5. **Phase 7 更多 skill / 模型档案**(持续)
6. **Proposal mermaid 流程图预渲染**(~半天,看到第二张图再做)—— 现状是 ASCII 框图走 fenced code 透传 (新宋体 + Consolas + xml:space=preserve),中文与 box drawing 字符宽度对不齐时还是有错位。增强方案: ` ```mermaid ` 块在 `render_docx.py` 里调 `mmdc` (mermaid-cli) → PNG → `add_picture` 嵌入。依赖 Node.js + `npm i -g @mermaid-js/mermaid-cli`,首次配置略麻烦,所以等 ASCII 透传明显不够用再做

7
cli.py
View File

@ -42,6 +42,7 @@ def _cleanup_if_empty(task_dir, session, console=None) -> bool:
1) session 没有 user 消息 1) session 没有 user 消息
2) task_dir 在磁盘上(懒创建后,没说话就没目录,直接 no-op) 2) task_dir 在磁盘上(懒创建后,没说话就没目录,直接 no-op)
3) 目录里只剩 messages.json(state.json 存在 = `/done /abandon /desc` 留下的显式痕迹,要保) 3) 目录里只剩 messages.json(state.json 存在 = `/done /abandon /desc` 留下的显式痕迹,要保)
原子写留下的 `*.tmp` 孤儿不算痕迹,放过
""" """
if any(m.get("role") == "user" for m in session.messages): if any(m.get("role") == "user" for m in session.messages):
return False return False
@ -51,7 +52,11 @@ def _cleanup_if_empty(task_dir, session, console=None) -> bool:
return False return False
if any(p.is_dir() for p in entries): if any(p.is_dir() for p in entries):
return False return False
if {p.name for p in entries if p.is_file()} - {"messages.json"}: meaningful = {
p.name for p in entries
if p.is_file() and not p.name.endswith(".tmp")
}
if meaningful - {"messages.json"}:
return False return False
shutil.rmtree(task_dir, ignore_errors=True) shutil.rmtree(task_dir, ignore_errors=True)
if console is not None: if console is not None:

76
core/memory.py Normal file
View File

@ -0,0 +1,76 @@
"""双层记忆: `workspace/memory/`。
core.md system prompt,每次都看到装稳定事实
(用户偏好 / 常用命令 / 项目约定 / 模型 quirk 备忘等)
extended/<x>.md 索引(标题+路径) prompt,内容 agent `read` 按需拉
装少数任务才用的专题资料( API 速查 / 某历史事件等)
为什么这样切:
core 一直挂在上下文里,token 成本固定 只放跨任务高频用的精炼内容
extended 索引只占几行,内容按需付费 适合大量低频专题
memory workspace 级别(不是 task 级别)同一 workspace 的所有 task 共享
SaaS (§7)后会按 tenant 隔离 接口不变,只换 storage backend
"""
from __future__ import annotations
from pathlib import Path
from typing import List, Tuple
def _memory_dir(workspace_dir: Path) -> Path:
return workspace_dir / "memory"
def _read_first_title(p: Path) -> str:
"""取文件第一个非空 h1/h2 行作为标题;没有就用文件名 stem。"""
try:
for raw in p.read_text(encoding="utf-8").splitlines():
line = raw.strip()
if line.startswith("#"):
return line.lstrip("#").strip()
if line:
return line[:60]
except (OSError, UnicodeDecodeError):
pass
return p.stem
def _load_core(workspace_dir: Path) -> str:
p = _memory_dir(workspace_dir) / "core.md"
if not p.is_file():
return ""
try:
return p.read_text(encoding="utf-8").strip()
except (OSError, UnicodeDecodeError):
return ""
def _extended_index(workspace_dir: Path) -> List[Tuple[str, Path]]:
"""返回 [(title, abs_path), ...],按文件名排序。"""
ext_dir = _memory_dir(workspace_dir) / "extended"
if not ext_dir.is_dir():
return []
items: List[Tuple[str, Path]] = []
for p in sorted(ext_dir.glob("*.md")):
if p.is_file():
items.append((_read_first_title(p), p.resolve()))
return items
def memory_block(workspace_dir: Path) -> str:
"""构造注入 system prompt 的记忆段;两块都空就返回空串。"""
core = _load_core(workspace_dir)
ext = _extended_index(workspace_dir)
if not core and not ext:
return ""
parts = ["\n\n## 记忆 (workspace 级,跨 task 共享)"]
if core:
parts.append("\n### Core (常驻 prompt)\n")
parts.append(core)
if ext:
parts.append("\n\n### Extended (按需用 `read` 加载)\n")
for title, path in ext:
parts.append(f"- `{path}` — {title}\n")
return "".join(parts)

View File

@ -11,6 +11,7 @@
from __future__ import annotations from __future__ import annotations
import json import json
import os
from pathlib import Path from pathlib import Path
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
@ -25,6 +26,22 @@ def _to_dict(msg: Any) -> Any:
return msg return msg
def atomic_write_text(path: Path, text: str, encoding: str = "utf-8") -> None:
"""原子写: 先写到 path.tmp 再 os.replace 到 path。
防止写中途异常(磁盘满 / surrogate 编码错 / 进程被杀)留下 0 字节或半文件
REPL task 假设下 .tmp 名固定;若上次写崩留下孤儿,本次写会覆盖它
`_cleanup_if_empty` 已配合放过 `*.tmp` 文件
"""
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(path.suffix + ".tmp")
with open(tmp, "w", encoding=encoding, newline="\n") as f:
f.write(text)
f.flush()
os.fsync(f.fileno())
os.replace(tmp, path)
class Session: class Session:
def __init__( def __init__(
self, self,
@ -54,11 +71,10 @@ class Session:
def save(self) -> None: def save(self) -> None:
if self.path is None: if self.path is None:
return return
self.path.parent.mkdir(parents=True, exist_ok=True)
payload = {"meta": self.meta, "messages": self.messages} payload = {"meta": self.meta, "messages": self.messages}
self.path.write_text( atomic_write_text(
self.path,
json.dumps(payload, ensure_ascii=False, indent=2), json.dumps(payload, ensure_ascii=False, indent=2),
encoding="utf-8",
) )
@classmethod @classmethod

View File

@ -15,6 +15,8 @@ from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
from .session import atomic_write_text
@dataclass @dataclass
class TaskState: class TaskState:
@ -37,11 +39,10 @@ class TaskState:
return self.tokens_prompt + self.tokens_completion return self.tokens_prompt + self.tokens_completion
def save(self, task_dir: Path) -> None: def save(self, task_dir: Path) -> None:
task_dir.mkdir(parents=True, exist_ok=True)
self.updated_at = datetime.now().isoformat(timespec="seconds") self.updated_at = datetime.now().isoformat(timespec="seconds")
(task_dir / "state.json").write_text( atomic_write_text(
task_dir / "state.json",
json.dumps(asdict(self), ensure_ascii=False, indent=2), json.dumps(asdict(self), ensure_ascii=False, indent=2),
encoding="utf-8",
) )
@classmethod @classmethod

51
main.py
View File

@ -16,6 +16,7 @@ from rich.console import Console
from core.capabilities import ModelCapabilities from core.capabilities import ModelCapabilities
from core.llm import LLM from core.llm import LLM
from core.loop import AgentLoop from core.loop import AgentLoop
from core.memory import memory_block
from core.session import Session from core.session import Session
from core.sinks import ConsoleEventSink from core.sinks import ConsoleEventSink
from core.skills import SkillRegistry from core.skills import SkillRegistry
@ -73,6 +74,35 @@ def resolve_task_messages_path(
return tdir / sid / "messages.json", sid return tdir / sid / "messages.json", sid
def _build_system_prompt(
cfg: dict,
skills: SkillRegistry,
workspace_dir: Path,
tool_base: Path,
task_dir: Path,
) -> str:
"""拼 system prompt: 模板 + skill 列表 + memory + 工作目录段。
new task resume task 都走这里,memory 演化即时生效
"""
prompt = (ROOT / cfg["system_prompt"]).read_text(encoding="utf-8")
if skills.skills:
prompt += f"\n\n## 可用 skill (用 load_skill 加载完整指引)\n{skills.discovery_block()}"
prompt += memory_block(workspace_dir)
task_dir_abs = task_dir.resolve()
prompt += (
f"\n\n## 工作目录\n"
f"- cwd(用户启动时所在目录,只读用): `{tool_base}`\n"
f"- **task_dir(所有产物写到这里)**: `{task_dir_abs}`\n\n"
f"SKILL 文档里出现的 `<task_dir>` 占位符,一律指上面这个绝对路径。"
f"产物示例: `{task_dir_abs}/spec_lock.md`、"
f"`{task_dir_abs}/sections/01_summary.md`、"
f"`{task_dir_abs}/slides/`、最终 .docx/.pptx。\n"
f"⛔ 不要把产物写到 cwd / `skills/` / repo 根 —— 只写到 task_dir。"
)
return prompt
def build_agent( def build_agent(
model_name: Optional[str] = None, model_name: Optional[str] = None,
workspace: Optional[str] = None, workspace: Optional[str] = None,
@ -99,8 +129,15 @@ def build_agent(
task_dir = session_path.parent task_dir = session_path.parent
system_prompt = _build_system_prompt(cfg, skills, workspace_dir, tool_base, task_dir)
if resume: if resume:
session = Session.load(session_path) session = Session.load(session_path)
# 用最新 memory + skill 列表刷新 system prompt(messages[0]),memory 演化即时生效
if session.messages and session.messages[0].get("role") == "system":
session.messages[0]["content"] = system_prompt
else:
session.messages.insert(0, {"role": "system", "content": system_prompt})
saved_cwd = session.meta.get("cwd") saved_cwd = session.meta.get("cwd")
if saved_cwd and console is not None and saved_cwd != str(tool_base): if saved_cwd and console is not None and saved_cwd != str(tool_base):
console.print( console.print(
@ -124,20 +161,6 @@ def build_agent(
) )
task_state.save(task_dir) task_state.save(task_dir)
else: else:
system_prompt = (ROOT / cfg["system_prompt"]).read_text(encoding="utf-8")
if skills.skills:
system_prompt += f"\n\n## 可用 skill (用 load_skill 加载完整指引)\n{skills.discovery_block()}"
task_dir_abs = task_dir.resolve()
system_prompt += (
f"\n\n## 工作目录\n"
f"- cwd(用户启动时所在目录,只读用): `{tool_base}`\n"
f"- **task_dir(所有产物写到这里)**: `{task_dir_abs}`\n\n"
f"SKILL 文档里出现的 `<task_dir>` 占位符,一律指上面这个绝对路径。"
f"产物示例: `{task_dir_abs}/spec_lock.md`、"
f"`{task_dir_abs}/sections/01_summary.md`、"
f"`{task_dir_abs}/slides/`、最终 .docx/.pptx。\n"
f"⛔ 不要把产物写到 cwd / `skills/` / repo 根 —— 只写到 task_dir。"
)
now_iso = datetime.now().isoformat(timespec="seconds") now_iso = datetime.now().isoformat(timespec="seconds")
meta = { meta = {
"id": sid, "id": sid,