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/
│ └── deepseek_v4.yaml # flash + pro 两档
├── workspace/
│ ├── memory/ # 双层记忆 (workspace 级,跨 task 共享)
│ │ ├── core.md # 注 system prompt,常驻
│ │ └── extended/ # 索引(标题+绝对路径)注 prompt,内容靠 read 工具按需拉
│ │ └── *.md
│ └── tasks/<task_id>/
│ ├── state.json # TaskState
│ ├── 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 删掉"的窗口)。
**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]` 列任务。
### 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. 模型路由

View File

@ -2,7 +2,7 @@
> 配合 `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 过滤 |
| 4 | 演化性能力 | 🟡 | Model Profile + Capability Probing ✅;版本化 prompts 未做 |
| 5 | Eval Suite | ⏸ 不做 | 个人工具用 dogfooding 替代,probe 覆盖健康检查 |
| 6 | 长任务工程化 | 🟡 | task + state.json + 中断恢复 ✅;context 压缩、双层记忆未做 |
| 6 | 长任务工程化 | 🟡 | task + state.json + 中断恢复 ✅;双层记忆 ✅;context 压缩未做 |
| 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 删"的窗口)
- `_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 |
| 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 替代 |
| 版本化 prompt | 直接 `general_v1.md`,无 active.md 软链接 | Windows 软链接麻烦,真要切版本时再做 |
| run_python 沙盒 | subprocess + env 过滤 | 阶段 1 设计如此;Docker 待 Phase 7 |
@ -64,31 +80,33 @@
```
core/capabilities.py 71
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/session.py 77
core/session.py 93 ← +atomic_write_text
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/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 注入 / -占位 save (懒创建)
cli.py 358 ← +probe / +tasks / +/resume / +空 task 清理
main.py 210 ← +memory 注入 (_build_system_prompt)
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 长上下文一般用不到
3. **小修打磨**(~半小时)—— `Session.save()` 改原子写(tmp + rename),防 surrogate 等异常 truncate
4. **Phase 7 Docker 沙盒**(~1 天)—— 替换 subprocess,run_python 安全升级
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 透传明显不够用再做
3. **Phase 7 更多 skill / 模型档案**(持续)
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 透传明显不够用再做

7
cli.py
View File

@ -42,6 +42,7 @@ def _cleanup_if_empty(task_dir, session, console=None) -> bool:
1) session 没有 user 消息
2) task_dir 在磁盘上(懒创建后,没说话就没目录,直接 no-op)
3) 目录里只剩 messages.json(state.json 存在 = `/done /abandon /desc` 留下的显式痕迹,要保)
原子写留下的 `*.tmp` 孤儿不算痕迹,放过
"""
if any(m.get("role") == "user" for m in session.messages):
return False
@ -51,7 +52,11 @@ def _cleanup_if_empty(task_dir, session, console=None) -> bool:
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"}:
meaningful = {
p.name for p in entries
if p.is_file() and not p.name.endswith(".tmp")
}
if meaningful - {"messages.json"}:
return False
shutil.rmtree(task_dir, ignore_errors=True)
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
import json
import os
from pathlib import Path
from typing import Any, Dict, List, Optional
@ -25,6 +26,22 @@ def _to_dict(msg: Any) -> Any:
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:
def __init__(
self,
@ -54,11 +71,10 @@ class Session:
def save(self) -> None:
if self.path is None:
return
self.path.parent.mkdir(parents=True, exist_ok=True)
payload = {"meta": self.meta, "messages": self.messages}
self.path.write_text(
atomic_write_text(
self.path,
json.dumps(payload, ensure_ascii=False, indent=2),
encoding="utf-8",
)
@classmethod

View File

@ -15,6 +15,8 @@ from datetime import datetime
from pathlib import Path
from typing import Optional
from .session import atomic_write_text
@dataclass
class TaskState:
@ -37,11 +39,10 @@ class TaskState:
return self.tokens_prompt + self.tokens_completion
def save(self, task_dir: Path) -> None:
task_dir.mkdir(parents=True, exist_ok=True)
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),
encoding="utf-8",
)
@classmethod

51
main.py
View File

@ -16,6 +16,7 @@ from rich.console import Console
from core.capabilities import ModelCapabilities
from core.llm import LLM
from core.loop import AgentLoop
from core.memory import memory_block
from core.session import Session
from core.sinks import ConsoleEventSink
from core.skills import SkillRegistry
@ -73,6 +74,35 @@ def resolve_task_messages_path(
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(
model_name: Optional[str] = None,
workspace: Optional[str] = None,
@ -99,8 +129,15 @@ def build_agent(
task_dir = session_path.parent
system_prompt = _build_system_prompt(cfg, skills, workspace_dir, tool_base, task_dir)
if resume:
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")
if saved_cwd and console is not None and saved_cwd != str(tool_base):
console.print(
@ -124,20 +161,6 @@ def build_agent(
)
task_state.save(task_dir)
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")
meta = {
"id": sid,