Compare commits

...

2 Commits

Author SHA1 Message Date
caoqianming c870b10368 feat(memory): 双层记忆升级为 agent 自管 + 前端只读记忆面板 + bump 0.12.0
写入路径从纯手工改为 agent 自管(prompt 契约,非后台蒸馏):memory_block
注入可写路径锚点 + 「记忆维护契约」,契约/锚点常驻(记忆为空也注,解新用户
冷启动)。extended 索引从首行标题升为优先 frontmatter description(缺则退回
首行,平滑兼容存量)。修旧 bug:extended 路径在 docker 下注的是宿主路径指不到,
改按 backend 给 host 绝对路径 / /workspace/.memory。

前端记忆面板取舍 = GUI 当眼睛、模型当手:左栏「记忆」按钮开只读 modal 看全貌
(GET /v1/memory + GET /v1/memory/extended/{filename},零写/删 API,路径穿越
校验收口在 core/memory.py)。"看全貌"是读不是 operation,走 LLM 又贵又只拿
转述;"改"全走对话(agent 自管),单一写入口 + 自然语言 + 不会写坏 frontmatter。
对照业界:Claude(同文件式)给全套 view+edit,ChatGPT/Gemini 黑箱只给看/删。

单测覆盖:frontmatter 解析 / legacy 兜底 / 空记忆常驻契约 / host·docker 路径 /
只读视图 / 单篇读 / 文件名安全 / 越界拦截。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 12:20:08 +08:00
caoqianming 0259f0ce92 docs(compat): 进入公测期,开发心智翻新为保证对外兼容 + bump 0.11.1
- CLAUDE.md「开发阶段心智」从"开发期可随意 break、不写兼容层"改为:
  对外契约(用户数据/DB schema/对外 API/CLI·env·文件布局)必须向后兼容,
  仅纯内部实现仍以最优为准放手重构;拿不准 → 当对外契约处理
- 版本号段:公测保持 0.x,1.0 留给对外冻结行为 / 正式 GA
- PROGRESS 加一条;bump 0.11.0 → 0.11.1

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 11:23:03 +08:00
10 changed files with 384 additions and 47 deletions

View File

@ -24,16 +24,24 @@ PowerShell here-string `@'...'@` **只在 PowerShell 工具里有效**;用 Bash
理由:开发期需求漂移快,写到一半被推翻代价高 —— 口头对齐方案是最低成本的纠偏机会。 理由:开发期需求漂移快,写到一半被推翻代价高 —— 口头对齐方案是最低成本的纠偏机会。
## 开发阶段心智 ## 开发阶段心智(公测期:保证对外兼容)
当前处于**开发测试期**(开发自用 + 内部测试,DB 已有真实测试数据)。改需求 / 重构时,**以最优实现为准,不为旧数据 / 旧字段 / 旧 API 留兼容层**,但**不删现有数据**: **已进入公测期**(对外真实用户在用,DB 里是真实用户数据 + 线上正在跑的会话)。心智从"开发期可随意 break"切换到**对外面必须向后兼容、对内部实现仍以最优为准**。判断一处改动能不能随意改,先问:**它是不是外部用户能感知 / 依赖的契约?**
- DB schema 变 → 直接改 model + 写一条干净的 migration:加列 / 改列结构 OK;**不要 truncate / DELETE FROM 现有表 —— 测试数据要保留**
- 删字段(DROP COLUMN)前:若该列是当前唯一持有该信息(如累计型 tokens 列),先 backfill 到新位置再删;若纯冗余(从其他列能推出)直接删 OK
- 字段语义变 → 全量替换 + migration 把旧值映射到新值(不留 `legacy_xxx` / `*_v2` 并存)
- CLI / REPL 选项变 → 直接改,不留 deprecated 别名
- 只有当用户明确说"这条要保留兼容"时才写兼容代码
理由:兼容层是技术债;但测试数据是观察新代码行为的依据 —— 一次 truncate 后再回去查"上周那 task 烧了多少 token / 哪条消息触发的 bug",就只能瞎猜。 **对外契约 —— 必须保证兼容,break 前先有迁移路径**:
- **用户数据**:绝不 truncate / DELETE FROM / 重置现有表 —— 这是用户的东西,丢了无法恢复
- **DB schema**:加列 / 改列 OK,但要写干净 migration 且**平滑兼容线上存量数据**;删字段(DROP COLUMN)前先 backfill 到新位置,确认无引用再删
- **字段语义变**:全量替换 + migration 把旧值映射到新值,且要考虑**线上正在跑的旧请求**读到该字段时不崩
- **对外 API(HTTP 接口 / 请求·响应 schema)**:不改既有字段语义、不删字段、不改 URL;要变先加新字段 / 新端点,旧的留一个废弃窗口
- **CLI / REPL 选项、env 变量、文件布局**:改名 / 删除前保留 deprecated 别名一个版本,并在 RUN.md 标注废弃;直接 break 会打断正在用的人
**对内部实现 —— 仍以最优为准,放手重构**:
- 纯内部模块 / 函数 / 私有数据流(外部不可见、无人依赖)→ 该重写重写,不留 `legacy_xxx` / `*_v2` 并存
- 内部重构只要**对外行为不变**(同样的输入 → 同样的输出 / 同样的 schema),不算破坏兼容
**拿不准是"对外契约"还是"内部实现"时 → 当成对外契约处理(先对方案,见上一节)。** 只有用户明确说"这条可以 break / 不用兼容"才走破坏式改法。
理由:公测后"随意 break"的前提(只有自己的测试数据、坏了重来)已不成立 —— 现在每次破坏式改动都可能弄丢真实用户数据或打断线上请求。兼容层确实是技术债,但比起搞坏用户数据,这点债值得背;等正式打 1.0、对外冻结行为后再统一清理废弃面。
## 文档维护 ## 文档维护
@ -46,7 +54,7 @@ PowerShell here-string `@'...'@` **只在 PowerShell 工具里有效**;用 Bash
- patch(`0.8.x`):bug 修复 / 重构 / 调参 / 新加 skill / 样式 - patch(`0.8.x`):bug 修复 / 重构 / 调参 / 新加 skill / 样式
- minor(`0.x.0`):成批新功能 / 明显的对外行为变化 - minor(`0.x.0`):成批新功能 / 明显的对外行为变化
- major(`x.0.0`):1.0 正式发版 / 不兼容大重构 - major(`x.0.0`):1.0 正式发版 / 不兼容大重构
- 当前 `0.x` 开发期,未正式发版前不进 1.0 - 当前 `0.x` **公测期**,1.0 留给"对外冻结行为 / 正式 GA"那一刻;公测中保持 `0.x` 迭代,minor 走新功能、patch 走修复
**只有以下情况才动 `DESIGN.md`**(避免把工程笔记沉淀成设计): **只有以下情况才动 `DESIGN.md`**(避免把工程笔记沉淀成设计):
- 架构 / 心智模型变化(如 §7.1 task-primary 重写) - 架构 / 心智模型变化(如 §7.1 task-primary 重写)

View File

@ -108,12 +108,16 @@ yaml 是手填的,probe 用真实调用对账:`basic_chat` / `parallel_tools` /
| 层 | 文件 | 加载 | 适合 | | 层 | 文件 | 加载 | 适合 |
|---|---|---|---| |---|---|---|---|
| Core | `core.md` | 每次 build_agent 进 system prompt | 跨任务高频精炼事实(几百 token) | | Core | `core.md` | 每次 build_agent 进 system prompt | 跨任务高频精炼事实(几百 token) |
| Extended | `extended/*.md` | 索引(标题+绝对路径)进 prompt,内容靠 `read` 工具按需拉 | 大量低频专题 | | Extended | `extended/*.md` | 索引(frontmatter `description`,缺则退回首行标题 — legacy 兼容)+ 可写绝对路径进 prompt,内容靠 `read` 工具按需拉 | 大量低频专题 |
**system prompt 每次 build_agent 重建**(resume 也是),memory 演化即时生效。memory 由人填(也允许 agent 用 `write` 写),系统不自动维护 — **事实由用户判断,不由 LLM 自动总结** **system prompt 每次 build_agent 重建**(resume 也是),memory 演化即时生效。
**写入路径 = agent 自管(prompt 契约,非后台蒸馏)**:`memory_block` 把 `.memory/` 的**可写绝对路径锚点** + 一段「记忆维护契约」一起注进 prompt(契约 + 锚点常驻,即使记忆为空,否则新用户冷启动不知道自己能记)。契约规定:学到跨 task 复用的稳定事实就当场用已有 `write`/`edit` 存,写前 `grep`/`read` 查重(更新而非堆重复),extended 一事一文件 + frontmatter `description`(这行进索引决定召回)。**不引专用 `remember` 工具**(复用 fs 工具,改动最小);**不做后台自动蒸馏**(不烧额外 token,人仍可审核/手编)。路径锚点按 backend 给 host 绝对路径 / docker `/workspace/.memory`(同 working_dir 的容器路径转译)。
**memory 永远在 FS,不入 DB**:本地 `workspace/users/<user_id>/.memory/`,SaaS `<storage_root>/users/<user_id>/.memory/`(bind mount 进容器)。**dotfile `.memory/` 命名**避免项目名取 `memory` 时撞;`validate_task_name` 拒 `.` 起头双向防呆。理由:用户笔记语义,FS 读写 + 编辑器手编是产品的一部分;跨 task 共享靠"同一 user 同一目录"自动达成,无需 schema。 **memory 永远在 FS,不入 DB**:本地 `workspace/users/<user_id>/.memory/`,SaaS `<storage_root>/users/<user_id>/.memory/`(bind mount 进容器)。**dotfile `.memory/` 命名**避免项目名取 `memory` 时撞;`validate_task_name` 拒 `.` 起头双向防呆。理由:用户笔记语义,FS 读写 + 编辑器手编是产品的一部分;跨 task 共享靠"同一 user 同一目录"自动达成,无需 schema。
**前端记忆面板 = 只读窗口,"改"全走对话(取舍)**:web 左栏「记忆」按钮开只读 modal,直接读 FS 渲染全貌(`GET /v1/memory` 全貌 + `GET /v1/memory/extended/{filename}` 单篇),**故意不提供写/删 API**。理由:① "看全貌"是读、不是 operation —— 走 LLM 反而又贵又只能拿到转述,看地面真相必须直读 FS;② "改"走对话(agent 自管,上文契约)= 单一写入口、自然语言、能合并改写,且用户不会写坏 frontmatter。对照业界:Claude(同为文件式记忆)给全套 view+edit;ChatGPT/Gemini 黑箱式只给看/删、长期不支持内联编辑。我们取"GUI 当眼睛、模型当手":既守住文件式记忆的透明卖点,又不引第二套写代码。后续若"删一条 / prune 臃肿 core.md"这类确定性精确操作摩擦明显,再单加直接的 delete(delete 是唯一廉价且确定性强、值得直连的 mutation,同 ChatGPT 做法)。路径穿越校验收口在 `core/memory.py`(只许 `.memory/extended/` 下扁平 `.md` + resolve 子树兜底)。
--- ---
## 4. 模型路由 ## 4. 模型路由

View File

@ -2,7 +2,7 @@
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9` > 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`
最后更新:2026-06-12(admin 管理后台 + 目录/筛选排序/分页/导出 PDF:users.role + require_admin + /v1/admin/* + 独立 admin.html) 最后更新:2026-06-12(双层记忆升级为 agent 自管 + 前端只读记忆面板)
--- ---
@ -21,6 +21,20 @@
## 已完成关键能力 ## 已完成关键能力
### 2026-06-12 / 双层记忆升级为 agent 自管(写入路径)
- **背景**:`.memory/`(core.md + extended/)存储原语已在,但纯手工维护 —— 系统不往里写,用户也不会主动整理 → 记忆形同虚设。**这轮补「写入」与「召回」两条路,不碰存储/DB,不破坏存量 `.memory/` 数据。**
- **写入 = agent 自管(选型:不引专用工具、不做后台蒸馏)**:`memory_block` 把 `.memory/` 可写绝对路径锚点 + 一段「记忆维护契约」注进 prompt,**契约+锚点常驻(即使记忆为空,解新用户冷启动不知道能记)**。agent 学到跨 task 稳定事实就用已有 `write`/`edit`/`grep` 维护,写前查重、extended 一事一文件 + frontmatter `description`。复用 fs 工具改动最小,人仍可审核手编。
- **召回升级**:extended 索引从「读首行当标题」升成**优先解析 frontmatter `description`**(召回依据更准),无 frontmatter 的存量文件退回首行标题(**公测期平滑兼容**)。
- **docker 路径转译**:发现旧 extended 索引注的是宿主绝对路径,docker 下 agent 看到的是 `/workspace/...` → 指不到。`mem_dir_display` 按 backend 给 host 绝对路径 / `/workspace/.memory`,与 working_dir 同套转译。
- 改动文件:`core/memory.py`(frontmatter 解析 + 契约 + 路径锚点)、`core/agent_builder.py`(算 `mem_dir_display` 传入)、`DESIGN.md` §3.7 同步心智+语义。单测覆盖 frontmatter 解析 / legacy 兜底 / 空记忆常驻契约 / host·docker 路径。明确不做:向量/RAG、全文搜索端点(正交,要做单开)。
- **前端只读记忆面板(GUI 当眼睛、模型当手)**:左栏「记忆」按钮(技能旁)开只读 modal 看全貌。**取舍**:查完业界(Claude 文件式给全套 view+edit;ChatGPT/Gemini 黑箱只给看/删)后定为 **GUI 只读 + "改"全走对话**(agent 自管已建好)—— "看全貌"是读不是 operation,走 LLM 又贵又只拿转述;"改"走对话 = 单一写入口 + 自然语言 + 不会写坏 frontmatter。后端只加 2 个只读端点 `GET /v1/memory`、`GET /v1/memory/extended/{filename}`(路径穿越校验收口在 `core/memory.py::read_extended_file`),**零写/删 API**。前端新增 `web/static/js/memory.js` + modal/CSS,复用 skills-modal 同构。契约里补明「用户说记住/改/忘掉是直接指令」。单测覆盖只读视图 / 单篇读 / 文件名安全 / 越界拦截。bump 0.11.1 → 0.12.0(本批含 agent 自管 + 记忆面板,同一 minor)。
### 2026-06-12 / 进入公测期:对外兼容策略
- 项目进入公测(对外真实用户在用)。`CLAUDE.md`「开发阶段心智」从"开发期可随意 break、不写兼容层"翻新为**对外契约(用户数据 / DB schema / 对外 API / CLI·env·文件布局)必须向后兼容,仅纯内部实现仍以最优为准放手重构**;拿不准 → 当对外契约处理。版本号段同步:公测保持 `0.x`,1.0 留给"对外冻结行为 / 正式 GA"。同条记忆 `feedback_dev_phase_no_compat` 一并翻新。bump 0.11.0 → 0.11.1。
### 2026-06-12(傍晚)修上下文压缩投毒 → run_python 空转报错 ### 2026-06-12(傍晚)修上下文压缩投毒 → run_python 空转报错
- **根因(DB 实测,60 个 task 命中 83 次 `[Error] bad arguments to run_python: code or script_path must be provided`)**:`core/context.py` 把旧 assistant `tool_call.arguments`(>800 字符)压成 `{"_compacted":true,"original_chars":N,"note":...}` marker 发给 LLM。模型在长 doc/ppt 任务里看到几十次"过去的 run_python 长这样",就**照葫芦画瓢把 marker 当真实参数原样吐出来** → executor 拿不到 code/script_path → 报错空转。83 次里 **61 次是模型仿写 marker**(铁证:抓到 `{"_compacted":true,"original_chars":85}`——85<800 压缩器根本不会出手且缺 `note` 字段,压缩器必带 只能是模型伪造),22 次是真· `{}`这正是代码里早已为 `task_progress` 单独豁免注释明写"会毒化模型"的同一个坑,只是 run_python 没豁免 - **根因(DB 实测,60 个 task 命中 83 次 `[Error] bad arguments to run_python: code or script_path must be provided`)**:`core/context.py` 把旧 assistant `tool_call.arguments`(>800 字符)压成 `{"_compacted":true,"original_chars":N,"note":...}` marker 发给 LLM。模型在长 doc/ppt 任务里看到几十次"过去的 run_python 长这样",就**照葫芦画瓢把 marker 当真实参数原样吐出来** → executor 拿不到 code/script_path → 报错空转。83 次里 **61 次是模型仿写 marker**(铁证:抓到 `{"_compacted":true,"original_chars":85}`——85<800 压缩器根本不会出手且缺 `note` 字段,压缩器必带 只能是模型伪造),22 次是真· `{}`这正是代码里早已为 `task_progress` 单独豁免注释明写"会毒化模型"的同一个坑,只是 run_python 没豁免

View File

@ -1,3 +1,3 @@
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。 # zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
# 改版本只动这一行。 # 改版本只动这一行。
__version__ = "0.11.0" __version__ = "0.12.0"

View File

@ -252,16 +252,20 @@ def _build_system_prompt(
prompt = (ROOT / cfg["system_prompt"]).read_text(encoding="utf-8") prompt = (ROOT / cfg["system_prompt"]).read_text(encoding="utf-8")
if skills.skills: if skills.skills:
prompt += f"\n\n## 可用 skill (用 load_skill 加载完整指引)\n{skills.discovery_block()}" prompt += f"\n\n## 可用 skill (用 load_skill 加载完整指引)\n{skills.discovery_block()}"
prompt += memory_block(workspace_dir, user_id)
if media_enabled:
prompt += "\n\n" + _MEDIA_TOOLS_BLOCK
# docker backend 下 shell/run_python/fs 工具全在容器里跑,容器把 # docker backend 下 shell/run_python/fs 工具全在容器里跑,容器把
# `<workspace>/users/<uid>` bind 到 `/workspace`、`--workdir /workspace/<wd>` # `<workspace>/users/<uid>` bind 到 `/workspace`、`--workdir /workspace/<wd>`
# (executor_docker.py:99-100)。此时 prompt 必须给**容器路径**,否则 LLM # (executor_docker.py:99-100)。此时 prompt 给 agent 的所有可写/可读绝对路径
# 拿着宿主绝对路径在沙盒里 find 不到任何东西(host 路径容器内根本不存在)。 # (含 .memory/ 写入锚点)都必须是**容器路径**,否则 LLM 拿着宿主绝对路径在沙盒里
# host backend 不变,直接用宿主绝对路径。 # find 不到任何东西。host backend 不变,直接用宿主绝对路径。
wd_abs = working_dir.resolve()
is_docker = os.getenv("ZCBOT_SANDBOX_BACKEND", "host").lower() == "docker" is_docker = os.getenv("ZCBOT_SANDBOX_BACKEND", "host").lower() == "docker"
# .memory/ 在 agent 视角下的可写路径:docker 给容器路径,host 给宿主绝对路径。
mem_dir_display = "/workspace/.memory" if is_docker else str(
user_root(workspace_dir, user_id) / ".memory"
)
prompt += memory_block(workspace_dir, user_id, mem_dir_display)
if media_enabled:
prompt += "\n\n" + _MEDIA_TOOLS_BLOCK
wd_abs = working_dir.resolve()
if is_docker: if is_docker:
try: try:
wd_rel = wd_abs.relative_to(user_root(workspace_dir, user_id)) wd_rel = wd_abs.relative_to(user_root(workspace_dir, user_id))

View File

@ -2,8 +2,9 @@
core.md system prompt,每次都看到装稳定事实 core.md system prompt,每次都看到装稳定事实
(用户偏好 / 常用命令 / 项目约定 / 模型 quirk 备忘等) (用户偏好 / 常用命令 / 项目约定 / 模型 quirk 备忘等)
extended/<x>.md 索引(标题+路径) prompt,内容 agent `read` 按需拉 extended/<x>.md 索引(frontmatter `description` + 路径) prompt,内容 agent
装少数任务才用的专题资料( API 速查 / 某历史事件等) `read` 按需拉装少数任务才用的专题资料( API 速查 / 某历史
事件等)description 是召回依据:写得准,模型才知道何时该拉
为什么这样切: 为什么这样切:
core 一直挂在上下文里,token 成本固定 只放跨任务高频用的精炼内容 core 一直挂在上下文里,token 成本固定 只放跨任务高频用的精炼内容
@ -14,11 +15,17 @@ memory 是 per-user(同一 workspace 内按 user_id 隔离),同 user 的所有 t
项目名取 `memory` 时撞名;`.` 起头也被 `validate_task_name` ,双向防呆 项目名取 `memory` 时撞名;`.` 起头也被 `validate_task_name` ,双向防呆
user_id web auth 入口(JWT `sub`)透传到 build_agentSaaS 化时 `<storage_root>` user_id web auth 入口(JWT `sub`)透传到 build_agentSaaS 化时 `<storage_root>`
替换 `workspace`,布局不变(§7.0) 替换 `workspace`,布局不变(§7.0)
写入路径(agent 自管):memory_block `.memory/` **可写绝对路径**(host 绝对路径 /
docker `/workspace/.memory`)连同记忆维护契约一起注进 prompt,agent 用已有
`write`/`edit`/`grep` 直接维护 不引专用工具契约 + 锚点即使记忆为空也常驻,
否则新用户冷启动永远不知道自己能记
""" """
from __future__ import annotations from __future__ import annotations
import re
from pathlib import Path from pathlib import Path
from typing import List, Tuple from typing import Any, Dict, List, Optional, Tuple
from uuid import UUID from uuid import UUID
@ -26,18 +33,42 @@ def _memory_dir(workspace_dir: Path, user_id: UUID) -> Path:
return workspace_dir / "users" / str(user_id) / ".memory" return workspace_dir / "users" / str(user_id) / ".memory"
def _read_first_title(p: Path) -> str: def _parse_frontmatter_description(text: str) -> Optional[str]:
"""取文件第一个非空 h1/h2 行作为标题;没有就用文件名 stem。""" """取 YAML frontmatter 里的 `description:` 一行;没有 frontmatter 返回 None。
try:
for raw in p.read_text(encoding="utf-8").splitlines(): 只认文件最开头的 `---` ... `---` ,块内首个 `description:` 行的值
刻意不引 yaml 依赖 记忆文件 frontmatter 就这一个字段够用,手解析最省
"""
lines = text.splitlines()
if not lines or lines[0].strip() != "---":
return None
for raw in lines[1:]:
stripped = raw.strip()
if stripped == "---":
break
if stripped.startswith("description:"):
val = stripped[len("description:"):].strip()
# 去掉可能的引号
if len(val) >= 2 and val[0] in "'\"" and val[-1] == val[0]:
val = val[1:-1]
return val.strip() or None
return None
def _read_first_title(text: str, stem: str) -> str:
"""取文件第一个非空 h1/h2 行作为标题;没有就用文件名 stem。
legacy 兜底:存量 extended 文件没 frontmatter,退回首行当标题(平滑兼容)
"""
for raw in text.splitlines():
line = raw.strip() line = raw.strip()
if line == "---":
continue
if line.startswith("#"): if line.startswith("#"):
return line.lstrip("#").strip() return line.lstrip("#").strip()
if line: if line:
return line[:60] return line[:60]
except (OSError, UnicodeDecodeError): return stem
pass
return p.stem
def _load_core(workspace_dir: Path, user_id: UUID) -> str: def _load_core(workspace_dir: Path, user_id: UUID) -> str:
@ -50,31 +81,128 @@ def _load_core(workspace_dir: Path, user_id: UUID) -> str:
return "" return ""
def _extended_index(workspace_dir: Path, user_id: UUID) -> List[Tuple[str, Path]]: def _extended_index(workspace_dir: Path, user_id: UUID) -> List[Tuple[str, str]]:
"""返回 [(title, abs_path), ...],按文件名排序。""" """返回 [(description_or_title, filename), ...],按文件名排序。
优先 frontmatter `description`;没有则退回首行标题(legacy 兼容)
返回 filename(非绝对路径) 路径由 memory_block backend display 前缀,
docker 下要给容器路径而非宿主路径
"""
ext_dir = _memory_dir(workspace_dir, user_id) / "extended" ext_dir = _memory_dir(workspace_dir, user_id) / "extended"
if not ext_dir.is_dir(): if not ext_dir.is_dir():
return [] return []
items: List[Tuple[str, Path]] = [] items: List[Tuple[str, str]] = []
for p in sorted(ext_dir.glob("*.md")): for p in sorted(ext_dir.glob("*.md")):
if p.is_file(): if not p.is_file():
items.append((_read_first_title(p), p.resolve())) continue
try:
text = p.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError):
text = ""
label = _parse_frontmatter_description(text) or _read_first_title(text, p.stem)
items.append((label, p.name))
return items return items
def memory_block(workspace_dir: Path, user_id: UUID) -> str: _CONTRACT = """\
"""构造注入 system prompt 的记忆段;两块都空就返回空串。""" 你可以**主动维护**这份记忆(用已有 `write`/`edit`/`grep`),把跨 task 复用的事实存下来,
下次别的 task 一开场就能用规矩:
- **什么值得记**:用户稳定偏好项目长期约定反复用到的事实踩过的坑(及为什么)
**不要记**:只跟当前 task 有关的一次性信息能从产物/代码里直接看到的东西
- **core.md(常驻,)**:只放跨 task 高频精炼的稳定事实 它每轮都占 token
- **extended/<slug>.md(按需,便宜)**:低频专题资料,一事一文件文件开头写
frontmatter `description:` 一行(这行进上面索引,决定何时被召回),正文是事实本身:
```
---
description: <一句话说清这份资料是什么何时该拉>
---
<内容>
```
- **写前先查重**:`grep`/`read` 看现有记忆有没有,有就 `edit` 更新别堆重复;发现记错了就删
- **用户让你"记住 / 改 / 忘掉"某事时,这是直接指令**:照办 "记住"就写"改成" `edit`
"忘掉 / 删掉"就把对应条目从 core.md 删掉或删掉那个 extended 文件改完回一句确认即可
- 记忆即时生效(每个新 task 重读),不用通知用户"""
def memory_block(
workspace_dir: Path,
user_id: UUID,
mem_dir_display: Optional[str] = None,
) -> str:
"""构造注入 system prompt 的记忆段。
mem_dir_display: `.memory/` agent 视角下的可写绝对路径前缀host backend
None 用宿主绝对路径;docker backend `/workspace/.memory`(容器内路径)
与旧版不同:契约 + 写入锚点常驻(即使记忆空), agent 知道自己能记;core /
extended 两段仍按有无内容才出现
"""
core = _load_core(workspace_dir, user_id) core = _load_core(workspace_dir, user_id)
ext = _extended_index(workspace_dir, user_id) ext = _extended_index(workspace_dir, user_id)
if not core and not ext:
return ""
parts = ["\n\n## 记忆 (user 级,跨 task 共享)"] real_dir = _memory_dir(workspace_dir, user_id)
base = mem_dir_display if mem_dir_display is not None else str(real_dir)
base = base.rstrip("/")
parts = ["\n\n## 记忆 (user 级,跨 task 共享)\n"]
parts.append(_CONTRACT)
parts.append(
f"\n\n**写到这里**:core → `{base}/core.md`;"
f"专题 → `{base}/extended/<slug>.md`\n"
)
if core: if core:
parts.append("\n### Core (常驻 prompt)\n") parts.append("\n### Core (常驻 prompt)\n")
parts.append(core) parts.append(core)
if ext: if ext:
parts.append("\n\n### Extended (按需用 `read` 加载)\n") parts.append("\n\n### Extended (按需用 `read` 加载)\n")
for title, path in ext: for label, name in ext:
parts.append(f"- `{path}` — {title}\n") parts.append(f"- `{base}/extended/{name}` — {label}\n")
return "".join(parts) return "".join(parts)
# ── 只读视图(web GUI 用) ─────────────────────────────────────────────
# 前端「记忆」弹框只读展示用。**故意不提供写/删 API** —— 改记忆全走对话(agent
# 自管,见 _CONTRACT),GUI 当"眼睛"不当"手":看全貌靠直接读 FS(便宜、是地面真相),
# 改靠模型(统一写入口、自然语言、能合并改写)。详见 DESIGN §3.7。
_EXTENDED_NAME_RE = re.compile(r"^[\w\-.]+\.md$")
def _is_safe_extended_name(name: str) -> bool:
"""防穿越:只许 `.memory/extended/` 下的扁平 `.md` 文件名。
拒斜杠 / `..` / dotfile / .md配合调用处 resolve 再兜一层子树校验
"""
if not name or "/" in name or "\\" in name or name.startswith("."):
return False
return bool(_EXTENDED_NAME_RE.match(name))
def memory_view(workspace_dir: Path, user_id: UUID) -> Dict[str, Any]:
"""记忆全貌(只读):core 原文 + extended 列表(filename + description)。
一次填满前端弹框core 给原文( strip 前的注入版)让用户看到真实落盘内容
"""
return {
"core": _load_core(workspace_dir, user_id),
"extended": [
{"filename": name, "description": label}
for label, name in _extended_index(workspace_dir, user_id)
],
}
def read_extended_file(
workspace_dir: Path, user_id: UUID, filename: str
) -> Optional[str]:
"""读单篇 extended 原文;文件名非法 / 越界 / 不存在 → None(调用方转 404)。"""
if not _is_safe_extended_name(filename):
return None
ext_dir = (_memory_dir(workspace_dir, user_id) / "extended").resolve()
target = (ext_dir / filename).resolve()
if ext_dir not in target.parents or not target.is_file():
return None
try:
return target.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError):
return None

View File

@ -1286,6 +1286,34 @@ def create_app() -> FastAPI:
raise HTTPException(400, "拒绝删除 .skills 之外的路径") raise HTTPException(400, "拒绝删除 .skills 之外的路径")
shutil.rmtree(target) shutil.rmtree(target)
@app.get("/v1/memory", tags=["memory"])
def get_memory(user_id: UUID = Depends(require_user)):
"""记忆全貌(只读):core.md 原文 + extended 列表(filename + description)。
前端记忆弹框一次拉满**只读** 改记忆全走对话(agent 自管,
`core/memory.py::_CONTRACT`),GUI "眼睛"不当""(DESIGN §3.7)
每次现读 FS( skills 端点),agent 刚写的即时可见
"""
from core.agent_builder import resolve_workspace
from core.memory import memory_view
ws = resolve_workspace(None)
return memory_view(ws, user_id)
@app.get("/v1/memory/extended/{filename}", tags=["memory"])
def get_memory_extended(filename: str, user_id: UUID = Depends(require_user)):
"""读单篇 extended 原文(点开列表项时拉)。文件名非法 / 不存在 → 404。
路径穿越校验收口在 `core/memory.py::read_extended_file`(只许 `.memory/extended/`
下扁平 `.md`, resolve 兜一层子树)
"""
from core.agent_builder import resolve_workspace
from core.memory import read_extended_file
ws = resolve_workspace(None)
content = read_extended_file(ws, user_id, filename)
if content is None:
raise HTTPException(404, f"memory file not found: {filename!r}")
return {"filename": filename, "content": content}
@app.delete("/v1/tasks/{task_id}", status_code=204, tags=["tasks"]) @app.delete("/v1/tasks/{task_id}", status_code=204, tags=["tasks"])
def delete_task(task_id: str, user_id: UUID = Depends(require_user)): def delete_task(task_id: str, user_id: UUID = Depends(require_user)):
"""硬删除:DELETE DB 行(messages / usage_events CASCADE)。 """硬删除:DELETE DB 行(messages / usage_events CASCADE)。

View File

@ -292,6 +292,34 @@
.sk-pane { width: auto; max-height: 26vh; border-right: none; border-bottom: 1px solid var(--border); } .sk-pane { width: auto; max-height: 26vh; border-right: none; border-bottom: 1px solid var(--border); }
} }
/* ───── 记忆查看 modal(只读两栏;改走对话)───── */
#memory-modal { z-index: 112; }
#memory-modal .card {
width: 880px; max-width: 94vw; height: 80vh; max-height: 80vh;
display: flex; flex-direction: column;
}
#memory-modal h3 {
margin: 0; padding: 12px 16px; font-size: 16px;
border-bottom: 1px solid var(--border);
display: flex; align-items: center; gap: 8px;
}
#memory-modal h3 .spacer { flex: 1; }
#memory-modal h3 svg { opacity: .85; }
#memory-modal .sk-x {
border: none; background: transparent; font-size: 16px;
cursor: pointer; color: var(--muted); padding: 2px 6px;
}
#mem-hint {
padding: 8px 16px; font-size: 12px; color: var(--muted);
border-bottom: 1px solid var(--border); background: #fafafa;
}
#mem-cols { flex: 1; display: flex; min-height: 0; }
#mem-detail { flex: 1; min-width: 0; overflow: auto; padding: 16px 20px; }
@media (max-width: 760px) {
#memory-modal .card { width: 96vw; height: 88vh; max-height: 88vh; }
#mem-cols { flex-direction: column; }
}
/* ───── 3-pane layout ───── */ /* ───── 3-pane layout ───── */
#app { display: none; height: 100vh; } #app { display: none; height: 100vh; }
#app.ready { #app.ready {
@ -1109,6 +1137,23 @@
</div> </div>
</div> </div>
<!-- ───── 记忆查看 modal(只读;改记忆走对话)───── -->
<div id="memory-modal" class="modal">
<div class="card">
<h3>
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"></path><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"></path></svg>
<span>记忆</span>
<span class="spacer"></span>
<button id="mem-close" class="sk-x" title="关闭"></button>
</h3>
<div id="mem-hint">跨任务共享的长期记忆,只读查看。<b>想改?直接在对话里跟我说</b>「记住…」「改成…」「忘掉…」,我会帮你维护。</div>
<div id="mem-cols">
<div id="mem-list" class="sk-pane"><div class="muted" style="padding:8px;">加载中…</div></div>
<div id="mem-detail"><div class="sk-empty">← 选 Core 或某条专题查看</div></div>
</div>
</div>
</div>
<!-- ───── embed-mode waiting overlay (token 握手中) ───── --> <!-- ───── embed-mode waiting overlay (token 握手中) ───── -->
<div id="embed-waiting"> <div id="embed-waiting">
<div class="spinner"></div> <div class="spinner"></div>
@ -1182,6 +1227,10 @@
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="7" height="7" rx="1.5"></rect><rect x="14" y="3" width="7" height="7" rx="1.5"></rect><rect x="3" y="14" width="7" height="7" rx="1.5"></rect><rect x="14" y="14" width="7" height="7" rx="1.5"></rect></svg> <svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="7" height="7" rx="1.5"></rect><rect x="14" y="3" width="7" height="7" rx="1.5"></rect><rect x="3" y="14" width="7" height="7" rx="1.5"></rect><rect x="14" y="14" width="7" height="7" rx="1.5"></rect></svg>
<span>技能</span> <span>技能</span>
</button> </button>
<button id="hd-memory" title="查看跨任务长期记忆(改记忆请在对话里说)">
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"></path><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"></path></svg>
<span>记忆</span>
</button>
</div> </div>
</div> </div>
<div id="split-left" class="splitter" role="separator" aria-orientation="vertical" title="拖拽调整任务栏宽度"></div> <div id="split-left" class="splitter" role="separator" aria-orientation="vertical" title="拖拽调整任务栏宽度"></div>

View File

@ -7,6 +7,7 @@ import { $ } from "./dom.js";
import { api } from "./api.js"; import { api } from "./api.js";
import { closeChpwModal } from "./auth.js"; import { closeChpwModal } from "./auth.js";
import { closeSkillsModal } from "./skills.js"; import { closeSkillsModal } from "./skills.js";
import { closeMemoryModal } from "./memory.js";
import { closeFilePreview, closeMiniPreview } from "./preview.js"; import { closeFilePreview, closeMiniPreview } from "./preview.js";
import { closeSrcPicker, loadFiles } from "./files.js"; import { closeSrcPicker, loadFiles } from "./files.js";
import { loadFolderSuggestions } from "./newtask.js"; import { loadFolderSuggestions } from "./newtask.js";
@ -85,6 +86,7 @@ document.addEventListener("keydown", (e) => {
// 多模态共存:优先关靠前栈顶 — 小预览(z 96)→ 选入(z 95)→ 文件预览(z 90)→ 新任务(z 80) // 多模态共存:优先关靠前栈顶 — 小预览(z 96)→ 选入(z 95)→ 文件预览(z 90)→ 新任务(z 80)
if ($("chpw-modal").classList.contains("show")) { closeChpwModal(); return; } if ($("chpw-modal").classList.contains("show")) { closeChpwModal(); return; }
if ($("skills-modal").classList.contains("show")) { closeSkillsModal(); return; } if ($("skills-modal").classList.contains("show")) { closeSkillsModal(); return; }
if ($("memory-modal").classList.contains("show")) { closeMemoryModal(); return; }
if ($("mini-preview-modal").classList.contains("show")) { closeMiniPreview(); return; } if ($("mini-preview-modal").classList.contains("show")) { closeMiniPreview(); return; }
if ($("src-picker-modal").classList.contains("show")) { closeSrcPicker(); return; } if ($("src-picker-modal").classList.contains("show")) { closeSrcPicker(); return; }
if ($("file-preview-modal").classList.contains("show")) { closeFilePreview(); return; } if ($("file-preview-modal").classList.contains("show")) { closeFilePreview(); return; }

100
web/static/js/memory.js Normal file
View File

@ -0,0 +1,100 @@
// 记忆 modal:只读两栏 master-detail。左栏列 Core(常驻)+ Extended 各条(带 description),
// 右栏渲染选中项原文(markdown)。左侧 rail 底部「记忆」按钮触发。
// **只读** —— 改记忆全走对话(agent 自管,见 core/memory.py 的 _CONTRACT)。
// GUI 当"眼睛"不当"手":看全貌靠直接读 FS(便宜、是地面真相),改靠模型(DESIGN §3.7)。
// 后端:GET /v1/memory(全貌)、GET /v1/memory/extended/{filename}(单篇原文)。
import { $ } from "./dom.js";
import { api } from "./api.js";
import { escapeHtml } from "./format.js";
import { renderMd, highlightIn } from "./markdown.js";
const PLACEHOLDER = '<div class="sk-empty">← 选 Core 或某条专题查看</div>';
let _cache = null; // 本次打开的 {core, extended} 快照;渲染右栏 Core 复用,免二次请求
function openMemoryModal() {
$("memory-modal").classList.add("show");
$("mem-detail").innerHTML = PLACEHOLDER;
renderList();
}
export function closeMemoryModal() {
$("memory-modal").classList.remove("show");
}
async function renderList() {
const list = $("mem-list");
list.innerHTML = '<div class="muted" style="padding:8px;">加载中…</div>';
let data;
try {
data = await api("GET", "/v1/memory");
} catch (e) {
list.innerHTML = `<div class="err" style="padding:8px;">加载失败: ${escapeHtml(e.message)}</div>`;
return;
}
_cache = data;
const ext = data.extended || [];
const coreEmpty = !data.core || !data.core.trim();
let html = '<div class="sk-group-title">常驻 (Core)</div>';
html += `<div class="sk-item" data-kind="core">
<div class="sk-name">core.md${coreEmpty ? ' <span class="sk-badge">空</span>' : ""}</div>
<div class="sk-desc">每轮注入,跨任务高频事实</div>
</div>`;
html += `<div class="sk-group-title" style="margin-top:12px;">专题 (Extended ${ext.length})</div>`;
html += ext.length
? ext
.map(
(e) => `<div class="sk-item" data-kind="ext" data-file="${escapeHtml(e.filename)}">
<div class="sk-name">${escapeHtml(e.filename)}</div>
<div class="sk-desc">${escapeHtml(e.description || "")}</div>
</div>`
)
.join("")
: '<div class="muted" style="padding:4px 8px;font-size:12px;">还没有。在对话里让我「记住某专题」即可。</div>';
list.innerHTML = html;
}
function highlightSel(itemEl) {
$("mem-list").querySelectorAll(".sk-item.active").forEach((el) => el.classList.remove("active"));
if (itemEl) itemEl.classList.add("active");
}
function showCore(itemEl) {
highlightSel(itemEl);
const detail = $("mem-detail");
const core = (_cache && _cache.core) || "";
detail.innerHTML = core.trim()
? '<div class="sk-d-head"><span class="sk-d-name">core.md</span><span class="sk-badge">常驻</span></div>' +
`<div class="sk-detail-md">${renderMd(core)}</div>`
: '<div class="sk-empty">core.md 还是空的。在对话里跟我说你的偏好 / 项目约定,我会记进来。</div>';
highlightIn(detail);
}
async function showExt(filename, itemEl) {
highlightSel(itemEl);
const detail = $("mem-detail");
detail.innerHTML = '<div class="muted" style="padding:8px;">加载中…</div>';
let data;
try {
data = await api("GET", "/v1/memory/extended/" + encodeURIComponent(filename));
} catch (e) {
detail.innerHTML = `<div class="err" style="padding:8px;">加载失败: ${escapeHtml(e.message)}</div>`;
return;
}
detail.innerHTML =
`<div class="sk-d-head"><span class="sk-d-name">${escapeHtml(filename)}</span><span class="sk-badge">按需</span></div>` +
`<div class="sk-detail-md">${renderMd(data.content)}</div>`;
highlightIn(detail);
}
// ───── 顶层绑定 ─────
$("hd-memory").onclick = openMemoryModal;
$("mem-close").onclick = closeMemoryModal;
$("memory-modal").addEventListener("click", (e) => {
if (e.target.id === "memory-modal") closeMemoryModal(); // 点遮罩关闭
});
$("mem-list").addEventListener("click", (e) => {
const item = e.target.closest(".sk-item");
if (!item) return;
if (item.getAttribute("data-kind") === "core") showCore(item);
else showExt(item.getAttribute("data-file"), item);
});