Compare commits

..

No commits in common. "c870b1036824509283697de099c9e046596c092f" and "f12df1bd827c99cb000bfbd3b92a7da0bcc4232e" have entirely different histories.

10 changed files with 47 additions and 384 deletions

View File

@ -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 重写)

View File

@ -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. 模型路由

View File

@ -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 没豁免

View File

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

View File

@ -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))

View File

@ -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_agentSaaS 化时 `<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

View File

@ -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)。

View File

@ -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>

View File

@ -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; }

View File

@ -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);
});