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>
This commit is contained in:
parent
0259f0ce92
commit
c870b10368
|
|
@ -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. 模型路由
|
||||||
|
|
|
||||||
12
PROGRESS.md
12
PROGRESS.md
|
|
@ -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(进入公测期:CLAUDE.md「开发阶段心智」翻新为"对外契约必须向后兼容,仅内部实现仍可最优重写")
|
最后更新:2026-06-12(双层记忆升级为 agent 自管 + 前端只读记忆面板)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -21,6 +21,16 @@
|
||||||
|
|
||||||
## 已完成关键能力
|
## 已完成关键能力
|
||||||
|
|
||||||
|
### 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 / 进入公测期:对外兼容策略
|
### 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。
|
- 项目进入公测(对外真实用户在用)。`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。
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
|
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
|
||||||
# 改版本只动这一行。
|
# 改版本只动这一行。
|
||||||
__version__ = "0.11.1"
|
__version__ = "0.12.0"
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
182
core/memory.py
182
core/memory.py
|
|
@ -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_agent。SaaS 化时 `<storage_root>`
|
user_id 由 web auth 入口(JWT `sub`)透传到 build_agent。SaaS 化时 `<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:` 行的值。
|
||||||
line = raw.strip()
|
刻意不引 yaml 依赖 —— 记忆文件 frontmatter 就这一个字段够用,手解析最省。
|
||||||
if line.startswith("#"):
|
"""
|
||||||
return line.lstrip("#").strip()
|
lines = text.splitlines()
|
||||||
if line:
|
if not lines or lines[0].strip() != "---":
|
||||||
return line[:60]
|
return None
|
||||||
except (OSError, UnicodeDecodeError):
|
for raw in lines[1:]:
|
||||||
pass
|
stripped = raw.strip()
|
||||||
return p.stem
|
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()
|
||||||
|
if line == "---":
|
||||||
|
continue
|
||||||
|
if line.startswith("#"):
|
||||||
|
return line.lstrip("#").strip()
|
||||||
|
if line:
|
||||||
|
return line[:60]
|
||||||
|
return 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
|
||||||
|
|
|
||||||
28
web/app.py
28
web/app.py
|
|
@ -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)。
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue