"""双层记忆: `workspace/users//.memory/` (§3.7 / §7.4)。 core.md —— 注 system prompt,每次都看到。装稳定事实 (用户偏好 / 常用命令 / 项目约定 / 模型 quirk 备忘等) extended/.md —— 索引(标题+路径)注 prompt,内容 agent 用 `read` 按需拉。 装少数任务才用的专题资料(某 API 速查 / 某历史事件等) 为什么这样切: core 一直挂在上下文里,token 成本固定 ⇒ 只放跨任务高频用的精炼内容 extended 索引只占几行,内容按需付费 ⇒ 适合大量低频专题 memory 是 per-user(同一 workspace 内按 user_id 隔离),同 user 的所有 task 共享。 **dotfile `.memory/` 命名**:跟用户起的项目目录(同样落 `/` 下)区分,避免 项目名取 `memory` 时撞名;`.` 起头也被 `validate_task_name` 拒,双向防呆。 本地 CLI = SENTINEL user;web/JWT 用 sub。SaaS 化时 `` 替换 `workspace`,布局不变(§7.0)。 """ from __future__ import annotations from pathlib import Path from typing import List, Tuple from uuid import UUID def _memory_dir(workspace_dir: Path, user_id: UUID) -> Path: return workspace_dir / "users" / str(user_id) / ".memory" def _read_first_title(p: Path) -> str: """取文件第一个非空 h1/h2 行作为标题;没有就用文件名 stem。""" try: for raw in p.read_text(encoding="utf-8").splitlines(): line = raw.strip() if line.startswith("#"): return line.lstrip("#").strip() if line: return line[:60] except (OSError, UnicodeDecodeError): pass return p.stem def _load_core(workspace_dir: Path, user_id: UUID) -> str: p = _memory_dir(workspace_dir, user_id) / "core.md" if not p.is_file(): return "" try: return p.read_text(encoding="utf-8").strip() except (OSError, UnicodeDecodeError): return "" def _extended_index(workspace_dir: Path, 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, Path]] = [] for p in sorted(ext_dir.glob("*.md")): if p.is_file(): items.append((_read_first_title(p), p.resolve())) return items def memory_block(workspace_dir: Path, 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 "" 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 title, path in ext: parts.append(f"- `{path}` — {title}\n") return "".join(parts)