Compare commits
No commits in common. "c870b1036824509283697de099c9e046596c092f" and "f12df1bd827c99cb000bfbd3b92a7da0bcc4232e" have entirely different histories.
c870b10368
...
f12df1bd82
26
CLAUDE.md
26
CLAUDE.md
|
|
@ -24,24 +24,16 @@ PowerShell here-string `@'...'@` **只在 PowerShell 工具里有效**;用 Bash
|
|||
|
||||
理由:开发期需求漂移快,写到一半被推翻代价高 —— 口头对齐方案是最低成本的纠偏机会。
|
||||
|
||||
## 开发阶段心智(公测期:保证对外兼容)
|
||||
## 开发阶段心智
|
||||
|
||||
**已进入公测期**(对外真实用户在用,DB 里是真实用户数据 + 线上正在跑的会话)。心智从"开发期可随意 break"切换到**对外面必须向后兼容、对内部实现仍以最优为准**。判断一处改动能不能随意改,先问:**它是不是外部用户能感知 / 依赖的契约?**
|
||||
当前处于**开发测试期**(开发自用 + 内部测试,DB 已有真实测试数据)。改需求 / 重构时,**以最优实现为准,不为旧数据 / 旧字段 / 旧 API 留兼容层**,但**不删现有数据**:
|
||||
- DB schema 变 → 直接改 model + 写一条干净的 migration:加列 / 改列结构 OK;**不要 truncate / DELETE FROM 现有表 —— 测试数据要保留**
|
||||
- 删字段(DROP COLUMN)前:若该列是当前唯一持有该信息(如累计型 tokens 列),先 backfill 到新位置再删;若纯冗余(从其他列能推出)直接删 OK
|
||||
- 字段语义变 → 全量替换 + migration 把旧值映射到新值(不留 `legacy_xxx` / `*_v2` 并存)
|
||||
- CLI / REPL 选项变 → 直接改,不留 deprecated 别名
|
||||
- 只有当用户明确说"这条要保留兼容"时才写兼容代码
|
||||
|
||||
**对外契约 —— 必须保证兼容,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、对外冻结行为后再统一清理废弃面。
|
||||
理由:兼容层是技术债;但测试数据是观察新代码行为的依据 —— 一次 truncate 后再回去查"上周那 task 烧了多少 token / 哪条消息触发的 bug",就只能瞎猜。
|
||||
|
||||
## 文档维护
|
||||
|
||||
|
|
@ -54,7 +46,7 @@ PowerShell here-string `@'...'@` **只在 PowerShell 工具里有效**;用 Bash
|
|||
- patch(`0.8.x`):bug 修复 / 重构 / 调参 / 新加 skill / 样式
|
||||
- minor(`0.x.0`):成批新功能 / 明显的对外行为变化
|
||||
- major(`x.0.0`):1.0 正式发版 / 不兼容大重构
|
||||
- 当前 `0.x` **公测期**,1.0 留给"对外冻结行为 / 正式 GA"那一刻;公测中保持 `0.x` 迭代,minor 走新功能、patch 走修复
|
||||
- 当前 `0.x` 开发期,未正式发版前不进 1.0
|
||||
|
||||
**只有以下情况才动 `DESIGN.md`**(避免把工程笔记沉淀成设计):
|
||||
- 架构 / 心智模型变化(如 §7.1 task-primary 重写)
|
||||
|
|
|
|||
|
|
@ -108,16 +108,12 @@ yaml 是手填的,probe 用真实调用对账:`basic_chat` / `parallel_tools` /
|
|||
| 层 | 文件 | 加载 | 适合 |
|
||||
|---|---|---|---|
|
||||
| Core | `core.md` | 每次 build_agent 进 system prompt | 跨任务高频精炼事实(几百 token) |
|
||||
| Extended | `extended/*.md` | 索引(frontmatter `description`,缺则退回首行标题 — legacy 兼容)+ 可写绝对路径进 prompt,内容靠 `read` 工具按需拉 | 大量低频专题 |
|
||||
| Extended | `extended/*.md` | 索引(标题+绝对路径)进 prompt,内容靠 `read` 工具按需拉 | 大量低频专题 |
|
||||
|
||||
**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 的容器路径转译)。
|
||||
**system prompt 每次 build_agent 重建**(resume 也是),memory 演化即时生效。memory 由人填(也允许 agent 用 `write` 写),系统不自动维护 — **事实由用户判断,不由 LLM 自动总结**。
|
||||
|
||||
**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. 模型路由
|
||||
|
|
|
|||
16
PROGRESS.md
16
PROGRESS.md
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`。
|
||||
|
||||
最后更新:2026-06-12(双层记忆升级为 agent 自管 + 前端只读记忆面板)
|
||||
最后更新:2026-06-12(admin 管理后台 + 目录/筛选排序/分页/导出 PDF:users.role + require_admin + /v1/admin/* + 独立 admin.html)
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -21,20 +21,6 @@
|
|||
|
||||
## 已完成关键能力
|
||||
|
||||
### 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 空转报错
|
||||
|
||||
- **根因(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 没豁免。
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
|
||||
# 改版本只动这一行。
|
||||
__version__ = "0.12.0"
|
||||
__version__ = "0.11.0"
|
||||
|
|
|
|||
|
|
@ -252,20 +252,16 @@ def _build_system_prompt(
|
|||
prompt = (ROOT / cfg["system_prompt"]).read_text(encoding="utf-8")
|
||||
if skills.skills:
|
||||
prompt += f"\n\n## 可用 skill (用 load_skill 加载完整指引)\n{skills.discovery_block()}"
|
||||
# docker backend 下 shell/run_python/fs 工具全在容器里跑,容器把
|
||||
# `<workspace>/users/<uid>` bind 到 `/workspace`、`--workdir /workspace/<wd>`
|
||||
# (executor_docker.py:99-100)。此时 prompt 给 agent 的所有可写/可读绝对路径
|
||||
# (含 .memory/ 写入锚点)都必须是**容器路径**,否则 LLM 拿着宿主绝对路径在沙盒里
|
||||
# find 不到任何东西。host backend 不变,直接用宿主绝对路径。
|
||||
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)
|
||||
prompt += memory_block(workspace_dir, user_id)
|
||||
if media_enabled:
|
||||
prompt += "\n\n" + _MEDIA_TOOLS_BLOCK
|
||||
# docker backend 下 shell/run_python/fs 工具全在容器里跑,容器把
|
||||
# `<workspace>/users/<uid>` bind 到 `/workspace`、`--workdir /workspace/<wd>`
|
||||
# (executor_docker.py:99-100)。此时 prompt 必须给**容器路径**,否则 LLM
|
||||
# 拿着宿主绝对路径在沙盒里 find 不到任何东西(host 路径容器内根本不存在)。
|
||||
# host backend 不变,直接用宿主绝对路径。
|
||||
wd_abs = working_dir.resolve()
|
||||
is_docker = os.getenv("ZCBOT_SANDBOX_BACKEND", "host").lower() == "docker"
|
||||
if is_docker:
|
||||
try:
|
||||
wd_rel = wd_abs.relative_to(user_root(workspace_dir, user_id))
|
||||
|
|
|
|||
172
core/memory.py
172
core/memory.py
|
|
@ -2,9 +2,8 @@
|
|||
|
||||
core.md —— 注 system prompt,每次都看到。装稳定事实
|
||||
(用户偏好 / 常用命令 / 项目约定 / 模型 quirk 备忘等)
|
||||
extended/<x>.md —— 索引(frontmatter `description` + 路径)注 prompt,内容 agent
|
||||
用 `read` 按需拉。装少数任务才用的专题资料(某 API 速查 / 某历史
|
||||
事件等)。description 是召回依据:写得准,模型才知道何时该拉。
|
||||
extended/<x>.md —— 索引(标题+路径)注 prompt,内容 agent 用 `read` 按需拉。
|
||||
装少数任务才用的专题资料(某 API 速查 / 某历史事件等)
|
||||
|
||||
为什么这样切:
|
||||
core 一直挂在上下文里,token 成本固定 ⇒ 只放跨任务高频用的精炼内容
|
||||
|
|
@ -15,17 +14,11 @@ memory 是 per-user(同一 workspace 内按 user_id 隔离),同 user 的所有 t
|
|||
项目名取 `memory` 时撞名;`.` 起头也被 `validate_task_name` 拒,双向防呆。
|
||||
user_id 由 web auth 入口(JWT `sub`)透传到 build_agent。SaaS 化时 `<storage_root>`
|
||||
替换 `workspace`,布局不变(§7.0)。
|
||||
|
||||
写入路径(agent 自管):memory_block 把 `.memory/` 的**可写绝对路径**(host 绝对路径 /
|
||||
docker `/workspace/.memory`)连同「记忆维护契约」一起注进 prompt,agent 用已有
|
||||
`write`/`edit`/`grep` 直接维护 —— 不引专用工具。契约 + 锚点即使记忆为空也常驻,
|
||||
否则新用户冷启动永远不知道自己能记。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
from typing import List, Tuple
|
||||
from uuid import UUID
|
||||
|
||||
|
||||
|
|
@ -33,42 +26,18 @@ def _memory_dir(workspace_dir: Path, user_id: UUID) -> Path:
|
|||
return workspace_dir / "users" / str(user_id) / ".memory"
|
||||
|
||||
|
||||
def _parse_frontmatter_description(text: str) -> Optional[str]:
|
||||
"""取 YAML frontmatter 里的 `description:` 一行;没有 frontmatter 返回 None。
|
||||
|
||||
只认文件最开头的 `---` ... `---` 块,块内首个 `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():
|
||||
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 == "---":
|
||||
continue
|
||||
if line.startswith("#"):
|
||||
return line.lstrip("#").strip()
|
||||
if line:
|
||||
return line[:60]
|
||||
return stem
|
||||
except (OSError, UnicodeDecodeError):
|
||||
pass
|
||||
return p.stem
|
||||
|
||||
|
||||
def _load_core(workspace_dir: Path, user_id: UUID) -> str:
|
||||
|
|
@ -81,128 +50,31 @@ def _load_core(workspace_dir: Path, user_id: UUID) -> str:
|
|||
return ""
|
||||
|
||||
|
||||
def _extended_index(workspace_dir: Path, user_id: UUID) -> List[Tuple[str, str]]:
|
||||
"""返回 [(description_or_title, filename), ...],按文件名排序。
|
||||
|
||||
优先 frontmatter `description`;没有则退回首行标题(legacy 兼容)。
|
||||
返回 filename(非绝对路径)—— 路径由 memory_block 按 backend 拼 display 前缀,
|
||||
docker 下要给容器路径而非宿主路径。
|
||||
"""
|
||||
def _extended_index(workspace_dir: Path, user_id: UUID) -> List[Tuple[str, Path]]:
|
||||
"""返回 [(title, abs_path), ...],按文件名排序。"""
|
||||
ext_dir = _memory_dir(workspace_dir, user_id) / "extended"
|
||||
if not ext_dir.is_dir():
|
||||
return []
|
||||
items: List[Tuple[str, str]] = []
|
||||
items: List[Tuple[str, Path]] = []
|
||||
for p in sorted(ext_dir.glob("*.md")):
|
||||
if not p.is_file():
|
||||
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))
|
||||
if p.is_file():
|
||||
items.append((_read_first_title(p), p.resolve()))
|
||||
return items
|
||||
|
||||
|
||||
_CONTRACT = """\
|
||||
你可以**主动维护**这份记忆(用已有 `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 两段仍按有无内容才出现。
|
||||
"""
|
||||
def memory_block(workspace_dir: Path, user_id: UUID) -> str:
|
||||
"""构造注入 system prompt 的记忆段;两块都空就返回空串。"""
|
||||
core = _load_core(workspace_dir, user_id)
|
||||
ext = _extended_index(workspace_dir, user_id)
|
||||
if not core and not ext:
|
||||
return ""
|
||||
|
||||
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"
|
||||
)
|
||||
parts = ["\n\n## 记忆 (user 级,跨 task 共享)"]
|
||||
if core:
|
||||
parts.append("\n### Core (常驻 prompt)\n")
|
||||
parts.append(core)
|
||||
if ext:
|
||||
parts.append("\n\n### Extended (按需用 `read` 加载)\n")
|
||||
for label, name in ext:
|
||||
parts.append(f"- `{base}/extended/{name}` — {label}\n")
|
||||
for title, path in ext:
|
||||
parts.append(f"- `{path}` — {title}\n")
|
||||
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
|
||||
|
|
|
|||
28
web/app.py
28
web/app.py
|
|
@ -1286,34 +1286,6 @@ def create_app() -> FastAPI:
|
|||
raise HTTPException(400, "拒绝删除 .skills 之外的路径")
|
||||
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"])
|
||||
def delete_task(task_id: str, user_id: UUID = Depends(require_user)):
|
||||
"""硬删除:DELETE DB 行(messages / usage_events CASCADE)。
|
||||
|
|
|
|||
|
|
@ -292,34 +292,6 @@
|
|||
.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 ───── */
|
||||
#app { display: none; height: 100vh; }
|
||||
#app.ready {
|
||||
|
|
@ -1137,23 +1109,6 @@
|
|||
</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 握手中) ───── -->
|
||||
<div id="embed-waiting">
|
||||
<div class="spinner"></div>
|
||||
|
|
@ -1227,10 +1182,6 @@
|
|||
<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>
|
||||
</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 id="split-left" class="splitter" role="separator" aria-orientation="vertical" title="拖拽调整任务栏宽度"></div>
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ import { $ } from "./dom.js";
|
|||
import { api } from "./api.js";
|
||||
import { closeChpwModal } from "./auth.js";
|
||||
import { closeSkillsModal } from "./skills.js";
|
||||
import { closeMemoryModal } from "./memory.js";
|
||||
import { closeFilePreview, closeMiniPreview } from "./preview.js";
|
||||
import { closeSrcPicker, loadFiles } from "./files.js";
|
||||
import { loadFolderSuggestions } from "./newtask.js";
|
||||
|
|
@ -86,7 +85,6 @@ document.addEventListener("keydown", (e) => {
|
|||
// 多模态共存:优先关靠前栈顶 — 小预览(z 96)→ 选入(z 95)→ 文件预览(z 90)→ 新任务(z 80)
|
||||
if ($("chpw-modal").classList.contains("show")) { closeChpwModal(); 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 ($("src-picker-modal").classList.contains("show")) { closeSrcPicker(); return; }
|
||||
if ($("file-preview-modal").classList.contains("show")) { closeFilePreview(); return; }
|
||||
|
|
|
|||
|
|
@ -1,100 +0,0 @@
|
|||
// 记忆 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);
|
||||
});
|
||||
Loading…
Reference in New Issue