Compare commits

..

No commits in common. "b4df60062e494c0d6c4dc91b4083b3d467310e7f" and "263cdb974a0fe374c551e5b468d6a53f1d699b2e" have entirely different histories.

10 changed files with 145 additions and 658 deletions

View File

@ -1,7 +0,0 @@
# zcbot 开发笔记 (给 Claude Code)
## 环境
- **Python 虚拟环境**: `.venv/`(项目根目录下),所有依赖装在里面
- 跑脚本 / 测试一律用 `.venv/Scripts/python.exe ...`,**不要用全局 `python`**(没装 litellm/python-pptx 等会报 ModuleNotFoundError)
- requirements 见 `requirements.txt`

320
DESIGN.md
View File

@ -13,14 +13,13 @@
- **编码**:文件编辑、shell 执行、迭代验证
### 不做什么
- 子 agent / IM 渠道 / 自定义 RAG / 锁定 Anthropic(注:多用户 / Web UI 是 §7 SaaS 化路线,personal-tool 阶段不做)
- 子 agent / IM 渠道 / 多用户 / Web UI(初期 CLI 即可)/ 自定义 RAG / 锁定 Anthropic
- **Eval Suite**:个人工具用 dogfooding 判断模型升级,造作 case 没区分度
### 关键约束
- 模型自由:LiteLLM 接 OpenAI-compatible 任意 provider(默认 DeepSeek V4)
- 任务持久化:任意时刻关机,下次能恢复
- 演化性:模型升级时 agent 跟着升级,不需要大改架构
- **形态兼容**:本地 CLI 与 SaaS 共享同一份 core 和同一种 storage(PG,无 SQLite / JSON 分支);CLI 长期保留(本地直跑 + `--remote` API client 双模式),不会被 HTTP API 取代(详 §7.0)
---
@ -55,11 +54,9 @@ zcbot/
│ └── models/
│ └── deepseek_v4.yaml # flash + pro 两档
├── workspace/
│ ├── memory/ # 双层记忆 (workspace 级,跨 task 共享)
│ │ ├── core.md # 注 system prompt,常驻
│ │ └── extended/ # 索引(标题+绝对路径)注 prompt,内容靠 read 工具按需拉
│ │ └── *.md
│ └── tasks/<task_id>/ # task_dir:仅 skill 产物,state/messages 在 PG
│ └── tasks/<task_id>/
│ ├── state.json # TaskState
│ ├── messages.json # Session
│ ├── spec_lock.md # skill 阶段一产物 (proposal/ppt)
│ ├── source/ # proposal 用户素材 (PDF / 团队介绍)
│ ├── source.md # ppt 转过的素材
@ -73,7 +70,7 @@ zcbot/
**task_dir = `workspace/tasks/<task_id>/`,所有 skill 产物都写到这里**。task_dir 绝对路径在 system prompt 里显式给 agent,SKILL.md 的 `<task_dir>` 占位符指向它。如果 agent 写错位置(写到 cwd / `skills/` / repo 根),git status 会立刻报红 —— `.gitignore` 不再用无锚通配规则盖住污染。
### 启动时拼装顺序
1. 读 `config/agent.yaml` 拿 default_model;`ZCBOT_DB_URL` 环境变量指向 PG(本地 dev 连远端测试 PG 或 docker compose 起的本地 PG;两形态同一种 schema)
1. 读 `config/agent.yaml` 拿 default_model
2. `ModelCapabilities.load("deepseek_v4.flash", config/models/)` 拿能力档案
3. `LLM(caps)` 构造,从 env 读 API key
4. 解析 task_dir(新建 or resume)
@ -133,38 +130,17 @@ yaml 是手填的,可能错。`probe` 用真实 LLM 调用对账:
### 3.6 Session 与 Task
**Session**(`core/session.py`)= 消息列表 + meta,**直接 ORM 写 PG `messages` 表**(append-only,`jsonb` 存 LiteLLM 原样 payload)
**Session**(`core/session.py`)= 消息列表 + meta + 落 `messages.json`
**Task**(`core/task.py`)= Session 的上层概念,含 mode / description / status (active/completed/abandoned) / model / reasoning_effort / task_dir / created_at / updated_at / tokens_prompt / tokens_completion。**直接 ORM 写 PG `tasks` 表**
**Task**(`core/task.py`)= Session 的上层概念,含 mode / description / status (active/completed/abandoned) / model / reasoning_effort / cwd / created_at / updated_at / tokens_prompt / tokens_completion。落 `state.json`
存储:Session / Task → PG;task_dir FS 目录只存 skill 产物(spec_lock / sections / *.docx / *.pptx 等),不再有 `state.json` / `messages.json`。每轮 `agent.run``sync_task_tokens` UPDATE 累计 tokens。**本地 + SaaS 同一份 schema 和 ORM 实现,无 adapter 抽象层**,差别只在 `ZCBOT_DB_URL`(本地连 docker compose 起的 PG / 远端 dev PG,SaaS 连生产 PG)
存储:`workspace/tasks/<task_id>/{state.json, messages.json}`。每轮 `agent.run` 后调 `sync_task_tokens` 把 LLM 累计 tokens 写回
**懒创建** —— `build_agent` 新建分支不立刻 INSERT,Task / Session 在第一条 user 消息触发 `Session.append` 时才 INSERT;task_dir FS 目录在 skill 第一次落产物时 `mkdir(parents=True)`。启动 REPL 后立刻 `/exit` 不留 DB 行 + 不留 FS 目录,跨进程安全
**懒创建** —— `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` 守门:DB 里该 task 没 messages 行 **且** FS task_dir 没产物 → DELETE tasks 行 + rmdir task_dir;任一痕迹存在则保留
**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` 留下的显式痕迹,要保)
**原子性** —— PG INSERT 天然原子,messages / tasks 写入无 0 字节风险。skill 产物(spec_lock.md / sections/*.md 等)仍走 `core.session.atomic_write_text`(tmp + fsync + replace),避免大文件写一半留半文件。
CLI:`chat --mode coding --desc "..." [--resume last|<id>] [--remote <url>]`;`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 自动总结**(后者噪音和误判风险高)。
**形态兼容** —— memory **永远在 FS,不入 DB**:
- 本地形态:`workspace/memory/{core.md, extended/}`
- SaaS 形态:`<storage_root>/users/<user_id>/memory/{core.md, extended/}`(bind mount 进容器)
理由:① memory 本质是"用户笔记",FS 读写 + 编辑器手编是产品语义的一部分,DB 化反而要造一层 UI 让用户改 md;② 跨 task 共享靠"同一 user 看同一份目录"语义自动达成,不需要 schema 设计;③ 不参与 §7.4 表结构,task 删/folder 删都不连带 memory。memory 不分 folder,是 per-user 单一命名空间。
CLI:`chat --mode coding --desc "..." [--resume last|<id>]`;`tasks [--status active|completed|abandoned]` 列任务。
---
@ -224,11 +200,11 @@ default_model: deepseek_v4.flash
### 已知风险
| 风险 | 缓解 |
|-----|------|
| run_python subprocess 沙盒不够强(本地形态非真隔离) | 限制工作目录 + 敏感 env 过滤;SaaS 形态走 docker exec(§7.6 #6),本地依赖用户对模型生成代码的最终审阅 |
| run_python subprocess 沙盒不够强 | 限制工作目录 + 敏感 env 过滤;后续可升级 Docker |
| V4 在某些复杂任务不如 Claude | dogfooding 判断,fallback 手动切 |
| Skill description 不够好 → 触发不准 | 用 Pro 优化 description,实战观察 |
| Long context 退化 | `probe --long-context` 探测可靠 ceiling,不依赖宣称值 |
| 本地 PG 连接不稳定 / 离线 dogfood | `docker compose up -d` 一行起本地 PG 兜底;也可连远端 dev / staging PG;CI 用 ephemeral PG container |
| `Session.save()` 不原子,异常会留 0 字节文件 | 后续改 tmp + rename(已记 PROGRESS) |
### 取舍说明
**为什么用 Hybrid 范式而不是纯 CodeAgent**:V4 JSON tool call 已稳定;沙盒成本只在需要时付;兼容 thinking 模式。
@ -241,276 +217,6 @@ default_model: deepseek_v4.flash
---
## 7. SaaS 化(草案,status=design,2026-05-12)
> §1-§6 是 **本地 dogfood 形态**;本节是 **SaaS 形态**,把 core 包成多用户在线服务。
> 不引入 platform/core 切分 —— core 就是后端,直接对用户做 auth(原"平台签 JWT、core 验签"多租户方案废弃)。两条形态共享同一份 core,差别只在 CLI 入口 vs HTTP 入口。本节落地前 §1-§6 路线照走,不阻塞 dogfood。
### 7.0 与本地形态的兼容性
SaaS 化不是"重写"也不是"取代 CLI",而是**给同一份 core 加一个 HTTP 入口**。落地过程中本地 CLI 必须始终可用。
**两条形态共享**:
- 同一份 `core/`(loop / capabilities / skills / memory / storage 接口)
- 同一份 `tools/`(底层 executor 从 subprocess 换 docker exec,接口不变)
- 同一份 SKILL.md 和 prompts
**两条形态差别**:
| 维度 | 本地形态 | SaaS 形态 |
|---|---|---|
| 入口 | `cli.py chat ...` 直调 core | HTTP `/v1/...` + SSE |
| Storage | **PG**(`ZCBOT_DB_URL` 指 docker compose / 远端 dev PG) | **PG**(`ZCBOT_DB_URL` 指生产 PG) |
| task_dir 根 | `workspace/tasks/<task_id>/`(派生,task 私有) | `<storage_root>/users/<user_id>/<task_dir>/`(用户给,可共享) |
| Memory | `workspace/memory/`(FS) | `<storage_root>/users/<user_id>/memory/`(仍是 FS) |
| Sandbox | subprocess + env 过滤(非真隔离) | per-task docker exec |
| Auth | 无(单用户 `user_id='local'`) | OIDC + JWT(user_id) |
**CLI 长期双模式**:
- **本地直跑**:`cli.py chat`(默认),直接调 core in-process,直连 PG。适合 dogfood / 调 core 内部状态
- **API client**:`cli.py chat --remote https://...`,走 HTTP /v1,跟前端用户路径一致
两模式共用 `cli.py` 入口,差别只在 transport 层(in-process call vs HTTP)。dogfood ≡ 真实用户路径只在 `--remote` 模式下成立;**本地直跑模式永久保留**(调试 core 内部状态比 HTTP roundtrip 顺手)。
**本地 PG 连接** —— `ZCBOT_DB_URL` 指向 docker compose 起的本地 PG(`docker compose up -d` 一行起,repo 自带 `docker-compose.yml`)或远端 dev / staging PG。**离线场景靠本地 docker compose 兜底**,不靠"零依赖"幻觉。
`workspace/` 目录:仅存 skill 产物(spec_lock / sections / *.docx / *.pptx),state / messages 全在 PG。本地 vs SaaS 差别只在 task_dir 根路径,不在 storage 形态。
### 7.1 心智模型:Folder-centric,task-as-DB-record
参考 Claude Code(cwd 是 anchor,状态存别处)+ OpenAI Assistants(stateful agent service)。
- **Folder** = 用户的"硬盘",路径 `users/<user_id>/<user-defined>/...`。能浏览、新建、改名、上传、下载,**和本地文件管理器体感一致**。folder 没 ID,**path 就是标识**;改名走 prefix cascade。
- **Task** = DB 一行,带 `task_dir` 指向 folder(相对 user root)。同 folder 允许多 task,但 task 之间**不允许嵌套**(no-subtask)。
- **Messages** = DB 表,append-only,`jsonb` 存 LiteLLM 原样 payload。
- **Skill 运行产物** 全落 cwd,不引入 artifacts 表;终稿后 SKILL.md 指示 agent 清中间件。
- **Skill 定义** 是项目代码,跟部署走,所有用户共享,不入用户 folder。
**task_dir 在两形态的对应**(§7.0 总览的展开):
- 本地形态:`task_dir = workspace/tasks/<task_id>/`(派生,task 私有,无并发写冲突)
- SaaS 形态:`task_dir = <storage_root>/users/<user_id>/<user-given-path>/`(用户给,可被同 user 多 task 共享)
state / messages **两形态都在 PG**,FS 只承担 skill 产物(sections / *.docx / 中间件)。多 task 共享同 folder 时由 §7.8 文件级悲观锁兜底(并发写同名文件冲突早失败,推到模型自纠)。
### 7.2 资源模型与接口(/v1)
```
POST /v1/folders 创建
GET /v1/folders 列树
GET /v1/folders/{path} 详情(task 列表 + 文件列表)
PATCH /v1/folders/{path} 改名/移动(prefix cascade)
DELETE /v1/folders/{path} hard cascade(连带 task+messages,前端二确认)
POST /v1/folders/{path}/files 上传(multipart)
GET /v1/folders/{path}/files[/{name}] 列 / 下载
DELETE /v1/folders/{path}/files/{name}
POST /v1/tasks 创建({task_dir, mode, desc, model})
GET /v1/tasks 列(?task_dir= ?status= 过滤)
GET /v1/tasks/{id} 详情
PATCH /v1/tasks/{id} 改 mode/desc/status
DELETE /v1/tasks/{id} 删 task(messages 一起删,不动 cwd 文件)
POST /v1/tasks/{id}/messages 发消息,返回 {run_id}
GET /v1/tasks/{id}/messages 历史(?search= 走 jsonb GIN / tsvector)
GET /v1/tasks/{id}/runs/{run_id}/events SSE 事件流
POST /v1/tasks/{id}/runs/{run_id}/cancel
GET /v1/skills | /v1/models | /v1/usage
POST /v1/probe (admin) 跑 capability probe
```
**SSE 事件**:`tool_call` / `tool_result` / `text` (delta) / `usage` / `done`,带 `run_id`
**版本化**:`/v1` minor 半年内向后兼容,major 6 个月 deprecation。
### 7.3 认证模型
OIDC / Clerk / 自建邮箱登录,JWT 只带 `user_id` claim:
```
Authorization: Bearer <user_jwt>
X-Request-Id: <uuid>
```
所有 storage/executor 调用 scoped by `user_id`。**无 tenant 层** —— 个人 SaaS 用不上,日后做企业版加 `org_id` claim 等价隔离。
### 7.4 存储:Postgres + 本地文件系统
```sql
users(user_id uuid pk, email null, password_hash | oidc_subject null, plan null, created_at)
-- 本地形态固定 INSERT 一行 sentinel: user_id = '00000000-0000-0000-0000-000000000000',
-- email / auth / plan 全 NULL;CLI 启动时若不存在则建,tasks 全部 FK 到它
tasks(
task_id uuid pk,
user_id uuid fk,
task_dir text not null, -- 相对 user root,如 "project_a/sub"
mode text, -- coding / proposal / ppt / chat
description text,
status text, -- pending / running / paused / done
model_profile text,
tokens_prompt int default 0,
tokens_completion int default 0,
cost_usd numeric default 0,
created_at timestamptz,
updated_at timestamptz
);
create index on tasks (user_id, task_dir);
messages(
message_id uuid pk,
task_id uuid fk,
idx int not null,
payload jsonb not null, -- LiteLLM dict 原样
tokens_in int, tokens_out int,
created_at timestamptz,
unique (task_id, idx)
);
create index on messages using gin (payload jsonb_path_ops);
-- 对话全文搜按需加 tsvector + GIN(中文起步 simple + pg_trgm)
runs(run_id uuid pk, task_id fk, status, started_at, finished_at, error, tokens_p, tokens_c)
usage_events(id, user_id, task_id uuid, run_id uuid, kind, value, ts)
-- append-only。task_id/run_id 不 FK,task 硬删后审计记录仍存活
```
**No-subtask 校验**(`create_task` 入口):
```sql
SELECT 1 FROM tasks
WHERE user_id = ?
AND ( ? LIKE task_dir || '/%' -- new 在已有之下 → 拒
OR task_dir LIKE ? || '/%' ); -- 已有在 new 之下 → 拒
-- 同 task_dir 允许(同 folder 多 task)
```
**Folder rename**(改名 `old → new`,FS rename 成功后跑):
```sql
UPDATE tasks
SET task_dir = ? || substring(task_dir from char_length(?) + 1) -- new, old
WHERE user_id = ? AND (task_dir = ? OR task_dir LIKE ? || '/%'); -- old, old
```
LIKE 用 `old/%` 而非 `old%`,避免 `project_a` 误中 `project_a_other`。**running task 引用该 folder 时禁 rename / delete**(后端校验 + UI 禁按钮)。
**Folder delete**:hard cascade,前端 modal 列影响面("将删 N 个对话、M 条消息、K 个文件")+ 输入 folder 名二确认。
```sql
-- 先 DB 后 FS;DB 失败 FS 不动一致;DB 成功 FS 失败由后台 GC 兜底清孤儿目录
DELETE FROM messages
WHERE task_id IN (SELECT task_id FROM tasks
WHERE user_id=? AND (task_dir=? OR task_dir LIKE ?||'/%'));
DELETE FROM tasks
WHERE user_id=? AND (task_dir=? OR task_dir LIKE ?||'/%');
-- 然后 FS 递归删 folder
```
`usage_events` 不参与 cascade(审计 append-only)。
**文件系统**:
```
<storage_root>/users/<user_id>/
memory/{core.md, extended/} # 跨 task 的 per-user 记忆,不入 DB
project_a/source/ sections/ proposal.docx
project_b/...
```
本地优先 S3(简化部署 / 低延迟),storage 抽象层留好后续可换 backend。
**Storage 实现:单一 PG ORM**(本地 + SaaS 共用):
- 一份 schema、一份 ORM(SQLAlchemy)、一份查询代码,无 adapter 抽象层,无 SQL 方言适配,无契约测试
- 本地 dev 连接:`ZCBOT_DB_URL=postgresql://...` 环境变量;repo 自带 `docker-compose.yml` 起本地 PG(零配置)或连远端 dev / staging PG
- Schema 演化:alembic 管理 migration,`db/migrations/*.py` 与代码一同版本化;CLI 启动校验当前 schema 版本,落后报错让用户跑 `cli db upgrade`(本地)或部署管线自动 `alembic upgrade head`(SaaS)
- 旧 workspace JSON 一次性迁移:`cli migrate-from-fs --workspace ./workspace` 把 `state.json` / `messages.json` 导入 PG,完成后 workspace 进只读 archive 模式
- 本地单用户 sentinel:DB init 时若 users 表无 sentinel 行则 INSERT;本地 CLI 所有 tasks 全 FK 到这一行,无 auth 流程,但 schema 与 SaaS 完全一致
- memory 不参与:per-user FS,两形态都不入 DB
### 7.5 沙盒:Per-task 容器 + Per-run exec
| 选择 | 理由 |
|---|---|
| 每 task 长驻容器 | 起容器 ~300ms 太慢;多轮 tool call 共享划算 |
| 每 run 一次 `docker exec` | exec 级 timeout/资源限制 |
| 空闲 N 分钟回收 | 不浪费,resume 时拉起 |
| **bind mount = user root** | `<storage_root>/users/<user_id>/` → 容器 `/workspace`;同用户多 task 不互隔(协作方便),跨用户由独立容器实例隔离 |
**资源限制**:cgroup CPU/mem、磁盘配额、egress allowlist(只放 LLM + PyPI 镜像)、root fs read-only、no-new-privileges、drop ALL caps。
**选型**:起步 Docker(运维门槛低);流量起来后视情况换 gVisor / Firecracker / e2b。Executor Protocol 抽象后切换成本低。
### 7.6 Core 代码改造(按依赖顺序)
| # | 项 | 影响文件 | 估时 |
|---|---|---|---|
| 1 | ~~事件流化 `loop.py`~~ | 已完成(commit `375bb29`) | — |
| 2 | **Storage 落 PG**:`Session` / `TaskState` 改 SQLAlchemy ORM 写 PG `messages` / `tasks` 表(单一实现,无 adapter 抽象);alembic 管 schema migration;`cli migrate-from-fs` 一次性把现有 workspace JSON 导入;repo 加 `docker-compose.yml` 起本地 PG 用于 dev | `core/session.py` `core/task.py` 新增 `core/storage/` `db/migrations/` `cli.py::migrate_from_fs` `cli.py::db_upgrade` `docker-compose.yml` `requirements.txt` | 3 天 |
| 3 | **task_dir 双形态共存**:`TaskState.task_dir` 可显式指定(本地默认 `workspace/tasks/<task_id>/`,SaaS = 用户给路径);`tools/fs.py::_resolve` 接受 task_dir 注入;system prompt 注入逻辑两形态共用 | `core/task.py` `tools/fs.py` `main.py` `prompts/system/general_v1.md` | 1 天 |
| 4 | **Folder API**:list / create / rename(cascade + 锁 running task) / delete(hard cascade,前端二确认强校验) / upload / download | 新增 `core/folders/` | 2 天 |
| 5 | **No-subtask 校验**:`create_task` 入口跑 §7.4 的 SQL | `core/task.py` | 0.5 天 |
| 6 | **Executor + 沙箱**:`run_python`/`shell` → `Executor.run(...)`,`docker exec` 到 per-user/per-task 容器;`api_key_env` → `KeyProvider`(运行时注入);**本地形态保留 subprocess executor**,SaaS 形态走 docker executor | `tools/run_python.py` `tools/shell.py` `core/capabilities.py` `core/llm.py` 新增 `core/executor/` | 2-3 天 |
| 7 | **HTTP /v1**:FastAPI + SSE + OIDC | 新增 `core/api/` `core/auth/` | 4 天 |
| 8 | **CLI 双模式**:加 transport 层抽象 —— 无 `--remote` 时走 in-process 直调 core(本地形态);`--remote <url>` 走 HTTP API client(dogfood ≡ 真实用户路径);**不删除本地直跑** | `cli.py``core/transport/` | 1.5 天 |
代码量增量:**+1000~1500 行**(单一 PG 实现比双 adapter 方案省 500-800 行;无契约测试集 / 无方言适配层)。
### 7.7 分阶段落地
| 阶段 | 范围 | 工作量 | 验收 |
|---|---|---|---|
| A | §7.6 #1 | done | ✅ |
| B | §7.6 #2 #3 #4 #5(Storage 落 PG + task_dir 双形态 + Folder API + no-subtask) | ~1 周 | 本地 CLI 走 PG,messages 进 DB 可全文搜;多 task + folder rename 单测过;`migrate-from-fs` 跑通 |
| C | §7.6 #6(Executor + sandbox) | 3 天 | 两本地账号互不可见对方 folder,本地 subprocess executor 仍可用 |
| D | §7.6 #7(HTTP /v1 + auth) | 4 天 | curl/Postman 跑通主流程 |
| E | §7.6 #8(CLI transport 双模式) | 1.5 天 | CLI 默认本地直跑保留,`--remote` 走 HTTP 也跑通 |
| F | 上线打磨(限流 / 监控 / 告警 / HA) | 持续 | SLO 99.5% |
**B 阶段一次性切换** —— 切到 PG 后本地与 SaaS 走相同代码路径,无回退、无双轨。**dogfood 即生效**(messages 进 DB → 全文搜、jsonb 查询立刻可用)。前置:repo 提供 `docker-compose.yml`,作者本机 `docker compose up -d postgres` 一行准备好 dev DB。
### 7.8 已知风险
| 风险 | 缓解 |
|---|---|
| 过早抽象违背 §5 哲学 | B 阶段单一 PG 实现无 adapter 抽象层;C-E 各阶段独立 dogfood 价值,"先有场景再加" |
| 本地 PG 连接 / 离线 dogfood | `docker compose up -d` 本地起 PG 兜底;也支持连远端 dev / staging PG;CI 用 ephemeral PG container |
| CLI 双模式分叉、本地直跑被忽略 | transport 层抽象统一接口;CI 跑 in-process 和 HTTP 两路径同一组用例 |
| `/v1` 冻死后演化慢 | minor 半年兼容,major 6 个月 deprecation;`/v1internal` 实验 |
| Rename 误命中前缀 / 漏改子 task | cascade SQL + 单测覆盖 `project_a` 不中 `project_a_other` |
| 运行中 task 被 rename / delete | 后端校验 + UI 禁按钮 |
| 误删 folder 丢对话 | 前端二确认 + 输入 folder 名;真要再加 trash bin(延迟 cascade) |
| DB-then-FS 中断留孤儿目录 | 后台 GC 周期扫 "FS 有但 DB 无引用" 的目录 |
| 同 folder 多 task 并发写同名文件 | 文件级悲观锁,冲突早失败 |
| Sandbox 出站越权 | egress allowlist 起步只放 LLM + PyPI 镜像 |
| 资源滥用(LLM / 存储) | BYO key 默认;月度 token & 存储配额;cold task LRU 清 |
### 7.9 取舍说明
**path-as-identity 而非 folder_id**:folder 真实存在于 FS,folder_id 等于造两份 source of truth(易不一致)。rename 是 UI 主动动作,cascade 单事务搞定。
**user auth 而非 tenant 层**:个人 SaaS 用不上。日后做企业版加 `org_id` claim,数据隔离规则等价。提前抽象 MVP 多 NULL 一层。
**skill 中间件全落 cwd 不引入 artifacts 表**:中间件是用户花 token 生成的资产,可下载可替换;artifacts 表 + 分类是为不确定的 UX 收益预付架构成本。真嫌乱 UI 加折叠视图。
**hard cascade 而非 soft orphan**:`orphaned` 让 list/resume/UI 都多一种特殊 case,代码长尾;"删 folder = 删项目" 比 "留对话残骸" 自然。`usage_events` append-only 不 FK,task 硬删后月账仍存活。
**Docker + Postgres 起步**:运维门槛最低,Executor 抽象层留好,切 microVM / S3 都是 backend 替换不动接口。
**本地也用 PG,不用 SQLite / JSON**:
1. **dogfood ≡ 真实用户路径** —— 本地与 SaaS 走相同 SQL 方言、相同事务语义、相同 ORM,bug 在 dogfood 阶段就能复现,不会等到生产
2. **Docker 已经是必然依赖** —— §7.6 #6 沙盒走 docker exec;装 Docker 是前提,顺手 `docker compose up postgres` 是零增量门槛
3. **双 adapter 维护税远高于 PG 一次性配置成本** —— 一份 schema、一份 ORM、一份查询;SaaS 起步即终态,切换成本归零
4. **本地 dev 也能连测试服** —— 不强迫本机起 PG,作者可直接连远端 dev / staging PG 跑 dogfood,体感跟连 SaaS 几乎一致
**CLI 不被 API 取代,而是双模式共存**:本地直跑模式调 core 内部状态比 HTTP roundtrip 顺手;前端用户路径靠 `--remote` 模式打通。transport 层抽象代价小、长期价值高 —— 删本地直跑省不下多少代码,反而失去最便利的调试入口。**离线**靠本地 docker compose PG 兜底,不靠"全栈零依赖"幻觉。
**Memory 不入 DB**:跨 task 共享靠"同一 user 看同一份 FS 目录"的语义自动达成,不需要 schema。md 文件用户直接编辑器改,DB 化反而要造 UI、违反 §3.7 "事实由用户判断" 原则。两形态 memory 行为一致(只是根目录不同),迁移零成本。
**为什么 Tasks/Messages 在 PG 但 skill 产物在 FS**:tasks / messages 是元数据 + 对话流,需要查询、过滤、全文搜、跨 task 统计 —— 都是 DB 强项,jsonb GIN / pg_trgm 让查询代码不爆炸。skill 产物(`*.pptx` / `*.docx` / `sections/*.md`)是终用户拿走的文件,期望直接在文件管理器看到、用 Office 打开、邮件附件发出去 —— 进 DB 就要做"导出"这一步多余操作,且二进制 BLOB 在 PG 里没 GIN 索引价值。**FS 是产物的天然存储,DB 是元数据 / 状态 / 查询索引的天然存储,各司其职**。同理 §7.5 沙盒 bind mount = user root,容器里看到的就是用户在 Web UI 里看到的目录,无中间层翻译。
---
## 附录:DeepSeek V4 关键事实(2026-04-24)
- **V4-Pro**:1.6T 总 / 49B 激活,1M context,SWE-Bench 80.6 / Terminal-Bench 67.9 / MCPAtlas 73.6

View File

@ -2,7 +2,7 @@
> 配合 `DESIGN.md` 阅读。本文件只记录 phase 状态、决策偏差、文件量、下一步。
最后更新:2026-05-12(§7 改写为 user-direct SaaS 草案)
最后更新:2026-05-08(REPL `/resume` + 懒创建 task_dir + 切换前空清理)
---
@ -10,42 +10,52 @@
| Phase | 标题 | 状态 | 备注 |
|------|-----|-----|------|
| 1-3 | 骨架 + Skill + run_python | ✅ | 三个 skill;CoreCoder 唯一匹配 edit;敏感 env 过滤 |
| 4 | 演化性能力 | 🟡 | Model Profile + Probing ✅;版本化 prompt 未做 |
| 5 | Eval Suite | ⏸ 不做 | dogfooding 替代,probe 覆盖健康检查 |
| 6 | 长任务工程化 | 🟡 | task + state.json + 恢复 ✅;双层记忆 ✅;context 压缩未做 |
| 7 | 打磨 | ❌ | Docker 沙盒 / 更多 skill |
| §7 SaaS 化 | DESIGN §7 路线 | 🟡 | A 事件流化 ✅;B (Storage 落 PG + task_dir 双形态 + Folder API + no-subtask) 可立刻开,本地与 SaaS 共用同一种 storage |
| 1 | 最小可用骨架 | ✅ | 全部验收点过 |
| 2 | Skill 系统 + 三个 skill | ✅ | Anthropic 格式;coding/ppt/proposal |
| 3 | Hybrid 范式 (run_python) | ✅ | subprocess + 敏感 env 过滤 |
| 4 | 演化性能力 | 🟡 | Model Profile + Capability Probing ✅;版本化 prompts 未做 |
| 5 | Eval Suite | ⏸ 不做 | 个人工具用 dogfooding 替代,probe 覆盖健康检查 |
| 6 | 长任务工程化 | 🟡 | task + state.json + 中断恢复 ✅;context 压缩、双层记忆未做 |
| 7 | 打磨 | ❌ | Docker 沙盒 / 更多 skill / Web UI |
---
## 已完成关键能力
**2026-Q1 ~ 05-06:Phase 1-4** —— 骨架 / 三个 skill(coding/ppt/proposal)/ run_python 范式 / Model Profile + Capability Probing。`ppt` v3:商务红约束 + apply_brand + Iconify + render_icon/quality_check;素材摄取改 markitdown CLI
**Phase 1-3**(2026 早期):骨架 + skill 系统 + run_python。所有工具基目录是用户当前 cwd(不是 zcbot 仓库本身),agent 操作的是用户项目。`tools/fs.py` 的 `edit` 用 CoreCoder 风格唯一匹配。`tools/run_python.py` 过滤 `*API_KEY *TOKEN *SECRET *PASSWORD *PRIVATE_KEY` 环境变量。三个 skill 中 `ppt/` 最完整(v3:商务红硬约束 + apply_brand 品牌条 + Iconify 图标库 + scripts:fetch_icon / quality_check / render_icon;素材摄取改用 markitdown CLI)
**2026-05-06:Phase 6 部分** —— task + state.json + tokens 累计;CLI `tasks` + REPL `/status /done /abandon /desc`;移除 legacy `workspace/sessions/`
**Phase 4**(2026-05-06):
- `core/probe.py` + `cli.py probe` —— basic_chat / parallel_tools / thinking_mode / long_context 四项探测
- 真实 probe 跑通,**flash mismatch 发现**:yaml `parallel_tools: false` 但实测能并发(暂不自动改 yaml,需更多场景观察)
- pro 全 ok
**2026-05-07:TUI 打磨 + task_dir 落地** —— rich Markdown 渲染;thinking spinner 显实时耗时+累计 token;system prompt 注入 task_dir 绝对路径,skill 产物全收敛 `workspace/tasks/<id>/`;`.gitignore` 删 bandaid 行。
**Phase 6 部分**(2026-05-06):
- `core/task.py` + `workspace/tasks/<id>/{state.json, messages.json}` —— TaskState 跟 mode/desc/status/tokens/timestamps;`build_agent` 返 5 元组;`sync_task_tokens` 每轮后写回
- CLI 新增 `tasks` 子命令 + REPL `/status /done /abandon /desc`;`chat` 加 `--mode --desc` 选项
- 移除 legacy `workspace/sessions/` 兼容(单一布局)
**2026-05-08:REPL task 切换 + 懒创建** —— `/resume [last|<id>]`;`build_agent` 不预占文件,首条 user 消息触发 save;`_cleanup_if_empty` 三条件守门防误删。
**TUI 打磨 + task_dir 落地**(2026-05-07):
- assistant 文字走 `rich.markdown.Markdown`,粗体/列表/表格/代码块正常渲染(非流式)
- thinking spinner 由 daemon 线程每 100ms 刷文案,显示实时耗时 + 累计 token;每轮 LLM 返回追加 dim 一行 `[in N out N t Xs]` 留痕
- system prompt 显式注入 `task_dir` 绝对路径,SKILL.md 里 `<task_dir>` 占位符**真正落地**;`spec_lock.md` / `sections/` / `slides/` / 最终 docx/pptx 全收敛到 `workspace/tasks/<id>/`
- `.gitignore``sections/` `slides/` `spec_lock.md` 三条无锚 bandaid —— 现在写错位置 git status 立刻报红,不再靠 ignore 兜底
**2026-05-09 → 05-10:§7 草案 + 对话导出** —— DESIGN §7 初版 SaaS 草案(后于 05-12 重写);`cli.py export <task_id>` + `core/export_docx.py` 导对话成 docx。
**2026-05-11:原子写 + 双层记忆 + §7 A** —— `atomic_write_text` 接管 save;`core/memory.py` 双层记忆(core.md 入 prompt,extended/* 走索引);loop 事件流化(`sink.emit`)铺 SSE 路。
**2026-05-12:§7 改写** —— 原 platform/core 多租户方案废弃,改 user-direct(folder-centric,task/messages 入 PG,no-subtask 约束,hard cascade delete)。
**REPL 内 task 切换 + 懒创建**(2026-05-08):
- `/resume [last|<id>]` 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` 留下显式痕迹,要保)
---
## 关键决策与偏差
| 项 | 决策 | 备注 |
|---|------|------|
| 工具基目录 | cwd(读)+ task_dir(写) | system prompt 同时注入两者绝对路径 |
| Workspace 布局 | `tasks/<id>/` + `memory/{core.md, extended/}` | memory 跨 task 共享 |
| Eval Suite | 不做 | 个人工具 dogfooding |
| 版本化 prompt | 直接 `general_v1.md` | Windows 软链接麻烦,真要切再做 |
| run_python 沙盒 | subprocess + env 过滤 | Docker 在 §7 C 阶段 |
| 项 | 决策 | 与设计差异 |
|---|------|-----------|
| 工具基目录 | 用户当前 cwd(读)+ task_dir(写) | system prompt 同时给 cwd 与 task_dir 绝对路径,SKILL.md `<task_dir>` 占位符指向 task_dir |
| Workspace 用途 | `tasks/<id>/{state.json, messages.json}` | memory/ 待 Phase 6 双层记忆 |
| Eval Suite | 不做 | 设计为团队场景;个人工具 dogfooding 替代 |
| 版本化 prompt | 直接 `general_v1.md`,无 active.md 软链接 | Windows 软链接麻烦,真要切版本时再做 |
| run_python 沙盒 | subprocess + env 过滤 | 阶段 1 设计如此;Docker 待 Phase 7 |
---
@ -54,35 +64,31 @@
```
core/capabilities.py 71
core/llm.py 89
core/loop.py 152 ← §7 A: sink.emit
core/sinks.py 101 ← §7 A
core/ui.py 38
core/probe.py 243
core/session.py 93 ← +atomic_write_text
core/loop.py 158 ← +markdown 渲染 / spinner 显时长+token
core/probe.py 243 ← Phase 4
core/session.py 77
core/skills.py 81
core/task.py 64
core/memory.py 76
core/export_docx.py 372
core/task.py 63 ← Phase 6
tools/base.py 34
tools/fs.py 182
tools/shell.py 94
tools/run_python.py 84
tools/skill_tool.py 45
main.py 210
cli.py 439
main.py 185 ← Phase 6 task 装配 / +task_dir 注入 / -占位 save (懒创建)
cli.py 358 ← +probe / +tasks / +/resume / +空 task 清理
─────────────────────────────────
Python 合计 ~2429
Python 合计 ~1764
```
skills/ppt 脚本 ~600 行 + SKILL.md / references / config / prompts,总仓库约 3000 行
上 skills/ppt 下的脚本(~600 行)、SKILL.md / references / config / prompts,总仓库约 2500 行可读源码
---
## 下一步候选(性价比排序)
1. **§7 B 阶段**(~1 周)—— Storage 落 PG(单一实现,无 adapter 抽象)+ task_dir 双形态 + Folder API + No-subtask。**dogfood 即生效**(messages 进 DB → 全文搜立刻可用)。
- 前置:repo 加 `docker-compose.yml`(`docker compose up -d postgres` 起本地 dev PG)或 `ZCBOT_DB_URL` 指向远端测试 PG
- 里程碑:① schema + alembic 初版迁移 ② SQLAlchemy ORM 接入 `Session` / `TaskState` ③ CLI 适配(去 `.json` 读写,加 `_cleanup_if_empty` 新逻辑)④ `cli migrate-from-fs` 工具(把现有 `workspace/tasks/*/` 导入 PG)⑤ Folder API + no-subtask SQL 校验 ⑥ 本地单用户 sentinel(`user_id='00000000-...'`)init 流程
2. **Phase 6 context 三层压缩**(~1 天)—— 兜底,V4 长上下文一般用不到
3. **Phase 7 更多 skill / 模型档案**(持续)
4. **Proposal mermaid 流程图预渲染**(~半天)—— ASCII 透传不够用时再上 `mmdc`(Node.js 依赖)
1. **Phase 6 双层记忆**(~半天)—— `workspace/memory/core.md` 注 prompt,`extended/<topic>.md` 按需读
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 透传明显不够用再做

7
cli.py
View File

@ -42,7 +42,6 @@ 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
@ -52,11 +51,7 @@ def _cleanup_if_empty(task_dir, session, console=None) -> bool:
return False
if any(p.is_dir() for p in entries):
return False
meaningful = {
p.name for p in entries
if p.is_file() and not p.name.endswith(".tmp")
}
if meaningful - {"messages.json"}:
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:

View File

@ -1,17 +1,19 @@
"""主 agent loop: ReAct 风格,LLM ↔ Tool 反复直到无 tool_call。
loop 不直接 print 进度通过 sink.emit(event) 上抛Sink 决定怎么呈现
(本地 console / SSE / 日志)事件类型见 core/sinks.py 头部说明
"""
"""主 agent loop: ReAct 风格,LLM ↔ Tool 反复直到无 tool_call。"""
from __future__ import annotations
import json
import threading
import time
from contextlib import contextmanager
from typing import Any, Dict, Optional, Tuple
from rich.console import Console
from rich.markdown import Markdown
from .capabilities import ModelCapabilities
from .llm import LLM
from .session import Session
from .ui import make_console
def _extract_usage(usage: Any) -> Tuple[int, int]:
@ -34,7 +36,7 @@ class AgentLoop:
tools: Dict[str, Any],
session: Session,
capabilities: ModelCapabilities,
sink: Optional[Any] = None,
console: Optional[Console] = None,
max_iterations: Optional[int] = None,
) -> None:
self.llm = llm
@ -42,42 +44,68 @@ class AgentLoop:
self.session = session
self.caps = capabilities
self.max_iterations = max_iterations or capabilities.max_iterations
self.sink = sink
self.console = console or make_console()
def _emit(self, event: dict) -> None:
if self.sink is not None:
self.sink.emit(event)
@contextmanager
def _thinking(self):
"""spinner 实时刷耗时 + 上下文 token 数。yield 出的 ctx 退出后填 elapsed。"""
start = time.monotonic()
stop = threading.Event()
def fmt() -> str:
elapsed = time.monotonic() - start
total = self.llm.token_counter.total
tail = f" ctx {total:,} tok" if total else ""
return f"[muted]thinking... {elapsed:.1f}s{tail}[/muted]"
class Ctx:
elapsed: float = 0.0
ctx = Ctx()
status = self.console.status(fmt(), spinner="dots")
def tick() -> None:
while not stop.wait(0.1):
try:
status.update(fmt())
except Exception:
return
with status:
th = threading.Thread(target=tick, daemon=True)
th.start()
try:
yield ctx
finally:
stop.set()
th.join(timeout=0.5)
ctx.elapsed = time.monotonic() - start
def run(self, user_message: str) -> str:
self.session.append({"role": "user", "content": user_message})
for _ in range(self.max_iterations):
self._emit({"type": "llm_start"})
start = time.monotonic()
response = self.llm.chat(
messages=self.session.messages,
tools=[t.schema for t in self.tools.values()],
reasoning_effort=self.caps.default_reasoning_effort or None,
)
elapsed = time.monotonic() - start
with self._thinking() as t:
response = self.llm.chat(
messages=self.session.messages,
tools=[t.schema for t in self.tools.values()],
reasoning_effort=self.caps.default_reasoning_effort or None,
)
msg = response.choices[0].message
self.session.append(msg)
pt, ct = _extract_usage(getattr(response, "usage", None))
self._emit({
"type": "llm_end",
"prompt_tokens": pt,
"completion_tokens": ct,
"elapsed": elapsed,
})
self.console.print(
f"[info][in {pt:,} out {ct:,} t {t.elapsed:.1f}s][/info]"
)
tool_calls = getattr(msg, "tool_calls", None) or []
content = getattr(msg, "content", None)
if content:
self._emit({"type": "text", "content": content})
self.console.print("[assistant]assistant>[/assistant]")
self.console.print(Markdown(content))
if not tool_calls:
self._emit({"type": "done"})
return content or ""
for tc in tool_calls:
@ -90,7 +118,6 @@ class AgentLoop:
}
)
self._emit({"type": "done"})
return "[reached max iterations]"
def _execute_tool_call(self, tc: Any) -> str:
@ -101,52 +128,31 @@ class AgentLoop:
except json.JSONDecodeError as e:
return f"[Error] invalid JSON arguments for {name}: {e}"
args_preview = json.dumps(args, ensure_ascii=False)
if len(args_preview) > 200:
args_preview = args_preview[:200] + "..."
self._emit({
"type": "tool_call",
"name": name,
"args": args,
"args_preview": args_preview,
})
preview = json.dumps(args, ensure_ascii=False)
if len(preview) > 200:
preview = preview[:200] + "..."
self.console.print(f"[tool]tool>[/tool] {name}({preview})")
tool = self.tools.get(name)
if tool is None:
err = f"[Error] unknown tool: {name}"
self._emit({"type": "tool_result", "name": name, "result": err,
"preview": err, "truncated": False})
return err
return f"[Error] unknown tool: {name}"
try:
result = tool.execute(**args)
except TypeError as e:
err = f"[Error] bad arguments to {name}: {e}"
self._emit({"type": "tool_result", "name": name, "result": err,
"preview": err, "truncated": False})
return err
return f"[Error] bad arguments to {name}: {e}"
except Exception as e:
err = f"[Error executing {name}] {type(e).__name__}: {e}"
self._emit({"type": "tool_result", "name": name, "result": err,
"preview": err, "truncated": False})
return err
return f"[Error executing {name}] {type(e).__name__}: {e}"
if not isinstance(result, str):
result = str(result)
# 控制返回给模型的 tool 结果体量,避免炸 context
MAX_LEN = 16_000
truncated = False
if len(result) > MAX_LEN:
result = result[:MAX_LEN] + f"\n[... truncated, {len(result) - MAX_LEN} chars ...]"
truncated = True
# 给用户预览(截短)
preview = result if len(result) < 400 else result[:400] + "..."
self._emit({
"type": "tool_result",
"name": name,
"result": result,
"preview": preview,
"truncated": truncated,
})
self.console.print(f"[muted]{preview}[/muted]")
return result

View File

@ -1,76 +0,0 @@
"""双层记忆: `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,7 +11,6 @@
from __future__ import annotations
import json
import os
from pathlib import Path
from typing import Any, Dict, List, Optional
@ -26,22 +25,6 @@ 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,
@ -71,10 +54,11 @@ 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}
atomic_write_text(
self.path,
self.path.write_text(
json.dumps(payload, ensure_ascii=False, indent=2),
encoding="utf-8",
)
@classmethod

View File

@ -1,101 +0,0 @@
"""EventSink: 把 loop 产生的事件画到目标(本地 console / SSE / 日志)。
Loop 不直接 print, emit({type, ...})Sink 决定怎么呈现
事件类型(loop 当前会发的):
llm_start {type} 一轮 LLM 调用开始
llm_end {type, prompt_tokens, completion_tokens, elapsed}
text {type, content} assistant 文字段(整段,非流式)
tool_call {type, name, args, args_preview}
tool_result {type, name, result, preview, truncated}
done {type} 一次 run 全部结束
后续接 SSE ,sink 实现里把 emit yield 即可,loop 一行不用改
"""
from __future__ import annotations
import threading
import time
from typing import Callable, Optional
from rich.console import Console
from rich.markdown import Markdown
class ConsoleEventSink:
"""把事件画到 rich console。spinner 在 llm_start..llm_end 之间显示,
后台 daemon 线程每 100ms 刷耗时 + 累计 token"""
def __init__(
self,
console: Console,
token_counter: Optional[Callable[[], int]] = None,
) -> None:
self.console = console
# 把 LLM 累计 token 数取出来(spinner 文案要用),可选;无则不显示 ctx
self._tokens = token_counter or (lambda: 0)
self._status = None
self._stop: Optional[threading.Event] = None
self._thread: Optional[threading.Thread] = None
self._start = 0.0
def emit(self, event: dict) -> None:
t = event.get("type")
if t == "llm_start":
self._spinner_start()
elif t == "llm_end":
self._spinner_stop()
pt = event.get("prompt_tokens", 0)
ct = event.get("completion_tokens", 0)
el = event.get("elapsed", 0.0)
self.console.print(f"[info][in {pt:,} out {ct:,} t {el:.1f}s][/info]")
elif t == "text":
content = event.get("content") or ""
if content:
self.console.print("[assistant]assistant>[/assistant]")
self.console.print(Markdown(content))
elif t == "tool_call":
name = event.get("name", "")
preview = event.get("args_preview", "")
self.console.print(f"[tool]tool>[/tool] {name}({preview})")
elif t == "tool_result":
preview = event.get("preview", "")
self.console.print(f"[muted]{preview}[/muted]")
# done: 无需输出
def _spinner_start(self) -> None:
self._start = time.monotonic()
self._stop = threading.Event()
def fmt() -> str:
elapsed = time.monotonic() - self._start
total = self._tokens()
tail = f" ctx {total:,} tok" if total else ""
return f"[muted]thinking... {elapsed:.1f}s{tail}[/muted]"
self._status = self.console.status(fmt(), spinner="dots")
self._status.__enter__()
def tick() -> None:
while not self._stop.wait(0.1):
try:
self._status.update(fmt())
except Exception:
return
self._thread = threading.Thread(target=tick, daemon=True)
self._thread.start()
def _spinner_stop(self) -> None:
if self._stop is not None:
self._stop.set()
if self._thread is not None:
self._thread.join(timeout=0.5)
if self._status is not None:
try:
self._status.__exit__(None, None, None)
except Exception:
pass
self._status = None
self._stop = None
self._thread = None

View File

@ -15,8 +15,6 @@ from datetime import datetime
from pathlib import Path
from typing import Optional
from .session import atomic_write_text
@dataclass
class TaskState:
@ -39,10 +37,11 @@ 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")
atomic_write_text(
task_dir / "state.json",
(task_dir / "state.json").write_text(
json.dumps(asdict(self), ensure_ascii=False, indent=2),
encoding="utf-8",
)
@classmethod

55
main.py
View File

@ -16,9 +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
from core.task import TaskState
from tools.fs import EditTool, GlobTool, GrepTool, ReadTool, WriteTool
@ -74,35 +72,6 @@ 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,
@ -129,15 +98,8 @@ 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(
@ -161,6 +123,20 @@ 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,
@ -197,8 +173,7 @@ def build_agent(
rp = RunPythonTool(base_dir=tool_base)
tools[rp.name] = rp
sink = ConsoleEventSink(console, token_counter=lambda: llm.token_counter.total) if console else None
agent = AgentLoop(llm, tools, session, caps, sink=sink)
agent = AgentLoop(llm, tools, session, caps, console=console)
return agent, session, sid, task_state, task_dir