|
| 1 | +# Design: `crates/memory/` — 独立记忆系统 crate |
| 2 | + |
| 3 | +> Status: Approved |
| 4 | +> Date: 2026-04-14 |
| 5 | +
|
| 6 | +--- |
| 7 | + |
| 8 | +## 1. 动机 |
| 9 | + |
| 10 | +当前记忆系统的 6 个模块散落在 `crates/session/` 中: |
| 11 | + |
| 12 | +| 文件 | 行数 | 职责 | |
| 13 | +|------|------|------| |
| 14 | +| `memory.rs` | 452 | MemoryStore CRUD | |
| 15 | +| `memory_types.rs` | 305 | MemoryType 枚举 + frontmatter 解析 | |
| 16 | +| `memory_relevance.rs` | 305 | 关键词评分 + 预算选择 | |
| 17 | +| `memory_age.rs` | 250 | 指数衰减 + 修剪建议 | |
| 18 | +| `memory_extract.rs` | 287 | 对话模式提取(依赖 `crab_core::Message`) | |
| 19 | +| `team_memory.rs` | 179 | 团队共享记忆 | |
| 20 | + |
| 21 | +**问题**: |
| 22 | + |
| 23 | +1. **职责不匹配** — `session` crate 的核心是会话管理 + 上下文压缩,记忆是正交关注点 |
| 24 | +2. **依赖反向渗透** — `memory_extract.rs` 为了 `Message` 拉入了 `crab-core`,使 session → core 依赖因为记忆而变重 |
| 25 | +3. **复用受限** — 如果 daemon、IDE 插件也想访问记忆,必须依赖整个 `crab-session` |
| 26 | +4. **缺失 CCB 关键能力** — 路径安全校验、MEMORY.md 截断、LLM 驱动的语义选择、prompt 构建器 |
| 27 | + |
| 28 | +## 2. 目标 |
| 29 | + |
| 30 | +- 将记忆系统提取为 `crates/memory/`(Layer 2 Service Layer) |
| 31 | +- 最小依赖:`crab-common` + `serde` — 不依赖 `crab-core` |
| 32 | +- 对齐 CCB `memdir/` 的全部能力 |
| 33 | +- 保持 `memory_extract`(对话提取)在 `crates/agent/` 中作为桥接层 |
| 34 | + |
| 35 | +## 3. 架构层级 |
| 36 | + |
| 37 | +``` |
| 38 | +Layer 3: agent ──────┐ |
| 39 | + │ uses MemoryStore, MemorySelector |
| 40 | +Layer 2: memory ◄────┘ |
| 41 | + │ |
| 42 | + └──► crab-common (errors, path utils) |
| 43 | + serde / serde_json |
| 44 | +``` |
| 45 | + |
| 46 | +`memory` 放在 Layer 2,与 `tools`、`fs`、`skill` 同级。 |
| 47 | +`agent` 和 `session` 都可以依赖它,`memory_extract`(对话 → 记忆桥接)留在 `agent` 中。 |
| 48 | + |
| 49 | +## 4. 模块设计 |
| 50 | + |
| 51 | +``` |
| 52 | +crates/memory/ |
| 53 | +├── Cargo.toml |
| 54 | +└── src/ |
| 55 | + ├── lib.rs # 公开 API re-exports |
| 56 | + ├── types.rs # MemoryType, MemoryMetadata, MemoryFile |
| 57 | + ├── store.rs # MemoryStore — 文件 CRUD + frontmatter 解析 |
| 58 | + ├── index.rs # MEMORY.md 索引文件读写 + 截断 |
| 59 | + ├── relevance.rs # MemorySelector — 评分 + 预算选择 |
| 60 | + ├── age.rs # 指数衰减 + 修剪建议 |
| 61 | + ├── prompt.rs # MemoryPromptBuilder — 构建注入 system prompt 的完整记忆段 |
| 62 | + ├── paths.rs # 记忆目录路径解析(per-project + global) |
| 63 | + ├── team.rs # 团队记忆 — 路径、读写、同步 |
| 64 | + └── security.rs # 路径安全校验(穿越、symlink、Unicode 正规化) |
| 65 | +``` |
| 66 | + |
| 67 | +### 4.1 `types.rs` — 核心类型 |
| 68 | + |
| 69 | +```rust |
| 70 | +/// 四类记忆分类(封闭枚举) |
| 71 | +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] |
| 72 | +#[serde(rename_all = "snake_case")] |
| 73 | +pub enum MemoryType { |
| 74 | + User, |
| 75 | + Feedback, |
| 76 | + Project, |
| 77 | + Reference, |
| 78 | +} |
| 79 | + |
| 80 | +/// 从 frontmatter 解析出的元数据 |
| 81 | +#[derive(Debug, Clone, Serialize, Deserialize)] |
| 82 | +pub struct MemoryMetadata { |
| 83 | + pub name: String, |
| 84 | + pub description: String, |
| 85 | + pub memory_type: MemoryType, |
| 86 | + pub created_at: Option<String>, |
| 87 | + pub updated_at: Option<String>, |
| 88 | +} |
| 89 | + |
| 90 | +/// 完整的记忆文件(元数据 + body) |
| 91 | +#[derive(Debug, Clone)] |
| 92 | +pub struct MemoryFile { |
| 93 | + pub filename: String, |
| 94 | + pub path: PathBuf, |
| 95 | + pub metadata: MemoryMetadata, |
| 96 | + pub body: String, |
| 97 | + /// 文件修改时间(用于排序和新鲜度) |
| 98 | + pub mtime: Option<SystemTime>, |
| 99 | +} |
| 100 | +``` |
| 101 | + |
| 102 | +**vs 现状改进**: |
| 103 | +- `MemoryFile.memory_type` 从 `String` → `MemoryType` 枚举(类型安全) |
| 104 | +- 新增 `mtime` 字段(CCB 用 `mtimeMs` 排序) |
| 105 | +- `MemoryFile` 统一了现在分散在 `memory.rs` 和 `memory_relevance.rs` 中的两个不同结构体 |
| 106 | + |
| 107 | +### 4.2 `store.rs` — 记忆存储 |
| 108 | + |
| 109 | +```rust |
| 110 | +pub struct MemoryStore { |
| 111 | + dir: PathBuf, |
| 112 | +} |
| 113 | + |
| 114 | +impl MemoryStore { |
| 115 | + pub fn new(dir: impl Into<PathBuf>) -> Self; |
| 116 | + |
| 117 | + // ── CRUD ── |
| 118 | + pub fn save(&self, filename: &str, content: &str) -> Result<()>; |
| 119 | + pub fn load(&self, filename: &str) -> Result<Option<String>>; |
| 120 | + pub fn delete(&self, filename: &str) -> Result<()>; |
| 121 | + |
| 122 | + // ── 批量操作 ── |
| 123 | + /// 扫描目录,解析 frontmatter,按 mtime 降序排列,上限 200 个 |
| 124 | + pub fn scan(&self) -> Result<Vec<MemoryFile>>; |
| 125 | + |
| 126 | +} |
| 127 | + |
| 128 | +// ── Frontmatter 解析(自由函数,定义在 types.rs)── |
| 129 | +pub fn parse_frontmatter(content: &str) -> Option<MemoryMetadata>; |
| 130 | +pub fn extract_body(content: &str) -> &str; |
| 131 | +pub fn format_frontmatter(metadata: &MemoryMetadata) -> String; |
| 132 | +``` |
| 133 | + |
| 134 | +**vs 现状改进**: |
| 135 | +- `scan()` 替代 `load_all()`,返回 `MemoryFile`(包含 mtime),按 mtime 排序(CCB 行为) |
| 136 | +- `format_frontmatter()` 新增 — 当前 `team_memory.rs` 手动拼接 YAML,应统一 |
| 137 | + |
| 138 | +### 4.3 `index.rs` — MEMORY.md 索引 |
| 139 | + |
| 140 | +```rust |
| 141 | +pub struct MemoryIndex { |
| 142 | + pub entries: Vec<IndexEntry>, |
| 143 | + pub truncation: Option<Truncation>, |
| 144 | +} |
| 145 | + |
| 146 | +pub struct IndexEntry { |
| 147 | + pub title: String, |
| 148 | + pub filename: String, |
| 149 | + pub hook: String, // 一行描述 |
| 150 | +} |
| 151 | + |
| 152 | +pub struct Truncation { |
| 153 | + pub original_lines: usize, |
| 154 | + pub original_bytes: usize, |
| 155 | + pub was_line_truncated: bool, |
| 156 | + pub was_byte_truncated: bool, |
| 157 | +} |
| 158 | + |
| 159 | +/// 读取 MEMORY.md,应用截断限制 |
| 160 | +pub fn load_index(dir: &Path) -> Result<MemoryIndex>; |
| 161 | + |
| 162 | +/// 保存 MEMORY.md(entries → markdown) |
| 163 | +pub fn save_index(dir: &Path, entries: &[IndexEntry]) -> Result<()>; |
| 164 | + |
| 165 | +/// 截断 MEMORY.md 内容(200 行 / 25KB 上限) |
| 166 | +pub fn truncate_index(content: &str) -> (String, Truncation); |
| 167 | +``` |
| 168 | + |
| 169 | +**新增能力**(对齐 CCB `truncateEntrypointContent`): |
| 170 | +- MEMORY.md 的 200 行 / 25KB 截断保护 |
| 171 | +- 截断信息返回,caller 可选择是否提示用户 |
| 172 | + |
| 173 | +### 4.4 `relevance.rs` — 记忆选择 |
| 174 | + |
| 175 | +```rust |
| 176 | +pub struct MemorySelector { |
| 177 | + pub max_memories: usize, |
| 178 | + pub max_total_bytes: usize, |
| 179 | +} |
| 180 | + |
| 181 | +/// 评分后的记忆条目 |
| 182 | +pub struct ScoredMemory { |
| 183 | + pub file: MemoryFile, |
| 184 | + pub score: f64, |
| 185 | +} |
| 186 | + |
| 187 | +impl MemorySelector { |
| 188 | + /// 关键词评分选择(本地,无 LLM 调用) |
| 189 | + pub fn select_by_keywords( |
| 190 | + &self, |
| 191 | + memories: &[MemoryFile], |
| 192 | + context: &str, |
| 193 | + ) -> Vec<ScoredMemory>; |
| 194 | +} |
| 195 | + |
| 196 | +/// 为未来 LLM 驱动的选择预留的 trait |
| 197 | +pub trait MemoryRanker: Send + Sync { |
| 198 | + /// 从 manifest 中选择最相关的记忆文件名(最多 max_count 个) |
| 199 | + fn rank( |
| 200 | + &self, |
| 201 | + query: &str, |
| 202 | + manifest: &str, |
| 203 | + max_count: usize, |
| 204 | + ) -> Pin<Box<dyn Future<Output = crab_common::Result<Vec<String>>> + Send + '_>>; |
| 205 | +} |
| 206 | +``` |
| 207 | + |
| 208 | +**vs 现状改进**: |
| 209 | +- 将 `MemoryEntry` 统一为 `ScoredMemory`(组合而非重复定义) |
| 210 | +- 新增 `MemoryRanker` trait — CCB 用 Sonnet 做语义选择,我们先用关键词评分,trait 为 LLM 选择留接口 |
| 211 | +- `agent` 层注入 `MemoryRanker` 实现(调 LLM),`memory` crate 本身不依赖 `crab-api` |
| 212 | + |
| 213 | +### 4.5 `age.rs` — 记忆老化 |
| 214 | + |
| 215 | +基本保持现有实现,改进: |
| 216 | + |
| 217 | +```rust |
| 218 | +/// 用 mtime(SystemTime)而非字符串日期计算年龄 |
| 219 | +pub fn age_days(mtime: SystemTime) -> u64; |
| 220 | + |
| 221 | +/// 指数衰减评分(30 天半衰期) |
| 222 | +pub fn decay_score(age_days: u64) -> f64; |
| 223 | + |
| 224 | +/// 人类可读的年龄文本 |
| 225 | +pub fn age_text(age_days: u64) -> String; |
| 226 | + |
| 227 | +/// 新鲜度警告(>1 天的记忆附加校验提示) |
| 228 | +pub fn freshness_caveat(age_days: u64) -> Option<String>; |
| 229 | + |
| 230 | +/// 修剪建议 |
| 231 | +pub fn suggest_pruning(memories: &[MemoryFile], threshold: f64) -> Vec<&MemoryFile>; |
| 232 | +``` |
| 233 | + |
| 234 | +**vs 现状改进**: |
| 235 | +- 用 `SystemTime` 替代手写的日期字符串解析 + Hinnant 算法 |
| 236 | +- 删除 `parse_date_to_days()` 和 `days_to_civil()` — 这些应该用 `SystemTime` 直接计算 |
| 237 | + |
| 238 | +### 4.6 `prompt.rs` — 提示词构建器(新增) |
| 239 | + |
| 240 | +CCB 的 `memdir.ts` 核心能力:构建完整的记忆 system prompt 段。 |
| 241 | + |
| 242 | +```rust |
| 243 | +pub struct MemoryPromptBuilder { |
| 244 | + pub display_name: Option<String>, |
| 245 | + pub include_guidelines: bool, |
| 246 | +} |
| 247 | + |
| 248 | +impl MemoryPromptBuilder { |
| 249 | + /// 构建完整的记忆 prompt 段(索引 + 类型说明 + 行为指南) |
| 250 | + pub fn build( |
| 251 | + &self, |
| 252 | + index: &MemoryIndex, |
| 253 | + selected: &[MemoryFile], |
| 254 | + ) -> String; |
| 255 | + |
| 256 | + /// 仅构建行为指南(不含记忆内容,用于 agent 子任务) |
| 257 | + pub fn build_guidelines_only(&self) -> String; |
| 258 | +} |
| 259 | +``` |
| 260 | + |
| 261 | +### 4.7 `paths.rs` — 路径解析(新增) |
| 262 | + |
| 263 | +对齐 CCB `memdir/paths.ts`: |
| 264 | + |
| 265 | +```rust |
| 266 | +/// 自动记忆目录:~/.crab/projects/<sanitized-git-root>/memory/ |
| 267 | +pub fn auto_memory_dir(git_root: Option<&Path>) -> PathBuf; |
| 268 | + |
| 269 | +/// 全局记忆目录:~/.crab/memory/ |
| 270 | +pub fn global_memory_dir() -> PathBuf; |
| 271 | + |
| 272 | +/// 团队记忆目录:~/.crab/teams/<name>/memory/ |
| 273 | +pub fn team_memory_dir(team_name: &str) -> PathBuf; |
| 274 | + |
| 275 | +/// 路径是否在记忆目录内(用于权限豁免判断) |
| 276 | +pub fn is_memory_path(path: &Path) -> bool; |
| 277 | + |
| 278 | +/// 将 git root 路径清理为安全的目录名 |
| 279 | +fn sanitize_path_component(path: &Path) -> String; |
| 280 | +``` |
| 281 | + |
| 282 | +### 4.8 `security.rs` — 路径安全(新增) |
| 283 | + |
| 284 | +对齐 CCB `teamMemPaths.ts` 的安全加固: |
| 285 | + |
| 286 | +```rust |
| 287 | +/// 校验团队记忆路径安全性 |
| 288 | +pub fn validate_team_mem_path(path: &Path, team_dir: &Path) -> Result<PathBuf>; |
| 289 | + |
| 290 | +/// 校验文件名(key)安全性 |
| 291 | +pub fn validate_memory_key(key: &str) -> Result<String>; |
| 292 | + |
| 293 | +// 内部检查: |
| 294 | +// - null byte 检测 |
| 295 | +// - 路径穿越检测(.., %2e%2e, Unicode fullwidth) |
| 296 | +// - symlink 解析校验(dunce::canonicalize) |
| 297 | +// - 前缀攻击防护(team-evil vs team) |
| 298 | +``` |
| 299 | + |
| 300 | +### 4.9 `team.rs` — 团队记忆 |
| 301 | + |
| 302 | +```rust |
| 303 | +pub struct TeamMemoryStore { |
| 304 | + store: MemoryStore, // 复用基础 MemoryStore |
| 305 | + team_name: String, |
| 306 | +} |
| 307 | + |
| 308 | +impl TeamMemoryStore { |
| 309 | + pub fn new(team_name: &str) -> Self; |
| 310 | + pub fn save(&self, metadata: &MemoryMetadata, body: &str) -> Result<()>; |
| 311 | + pub fn load_all(&self) -> Vec<MemoryFile>; |
| 312 | + // 安全校验委托给 security 模块 |
| 313 | +} |
| 314 | +``` |
| 315 | + |
| 316 | +## 5. 依赖关系 |
| 317 | + |
| 318 | +```toml |
| 319 | +[dependencies] |
| 320 | +crab-common.workspace = true # Error types, path::home_dir() |
| 321 | +serde.workspace = true |
| 322 | +serde_json.workspace = true |
| 323 | +dunce.workspace = true # Windows 路径正规化(已在 workspace) |
| 324 | + |
| 325 | +[dev-dependencies] |
| 326 | +tempfile.workspace = true |
| 327 | +``` |
| 328 | + |
| 329 | +**关键决策**:不依赖 `crab-core`。 |
| 330 | + |
| 331 | +- `memory_extract.rs`(依赖 `Message`)**不迁入** `crates/memory/` |
| 332 | +- 它留在 `crates/agent/` 作为"对话 → 记忆"桥接层 |
| 333 | +- `MemoryRanker` trait 定义在 `memory` crate,实现在 `agent`(注入 LLM 调用) |
| 334 | + |
| 335 | +## 6. 迁移计划 |
| 336 | + |
| 337 | +### Phase 1: 创建 crate + 迁移纯数据类型 |
| 338 | + |
| 339 | +1. `cargo new crates/memory --lib` |
| 340 | +2. 迁移 `types.rs`(MemoryType, MemoryMetadata)+ `store.rs`(MemoryStore) |
| 341 | +3. `crab-session` 的 `memory.rs` + `memory_types.rs` 改为 `pub use crab_memory::*` 的 re-export |
| 342 | +4. 确保 `cargo test --workspace` 通过 |
| 343 | + |
| 344 | +### Phase 2: 迁移评分 + 老化 |
| 345 | + |
| 346 | +1. 迁移 `relevance.rs` 和 `age.rs` |
| 347 | +2. 统一 `MemoryEntry` 和 `MemoryFile` 为单一类型 |
| 348 | +3. `session` 中删除原模块,更新 re-export |
| 349 | + |
| 350 | +### Phase 3: 新增能力 |
| 351 | + |
| 352 | +1. 实现 `index.rs`(MEMORY.md 截断) |
| 353 | +2. 实现 `paths.rs`(per-project 路径解析) |
| 354 | +3. 实现 `security.rs`(路径安全校验) |
| 355 | +4. 实现 `prompt.rs`(prompt 构建器) |
| 356 | + |
| 357 | +### Phase 4: 团队记忆 + 桥接层 |
| 358 | + |
| 359 | +1. 迁移 `team_memory.rs` → `team.rs`,集成 `security.rs` |
| 360 | +2. 将 `memory_extract.rs` 移到 `crates/agent/src/memory_extract.rs` |
| 361 | +3. 清理 `crab-session` 中的 memory 相关 re-export |
| 362 | + |
| 363 | +## 7. 与 CCB 的功能对照 |
| 364 | + |
| 365 | +| CCB memdir 文件 | 功能 | crab-memory 模块 | 状态 | |
| 366 | +|-----------------|------|------------------|------| |
| 367 | +| `memoryTypes.ts` | 四类型分类 | `types.rs` | ✅ 已有 | |
| 368 | +| `memdir.ts` (MemoryStore) | 文件 CRUD | `store.rs` | ✅ 已有 | |
| 369 | +| `memdir.ts` (truncate) | MEMORY.md 截断 | `index.rs` | 🆕 新增 | |
| 370 | +| `memdir.ts` (buildPrompt) | prompt 构建 | `prompt.rs` | 🆕 新增 | |
| 371 | +| `memoryScan.ts` | 文件扫描 + header 解析 | `store.rs` scan() | ✅ 已有(需加 mtime) | |
| 372 | +| `findRelevantMemories.ts` | LLM 语义选择 | `relevance.rs` + `MemoryRanker` trait | 🔧 增强 | |
| 373 | +| `memoryAge.ts` | 新鲜度计算 | `age.rs` | ✅ 已有 | |
| 374 | +| `paths.ts` | per-project 路径 | `paths.rs` | 🆕 新增 | |
| 375 | +| `teamMemPaths.ts` | 团队路径 + 安全 | `team.rs` + `security.rs` | 🔧 增强 | |
| 376 | +| `teamMemPrompts.ts` | 团队记忆 prompt | `prompt.rs` | 🆕 新增 | |
| 377 | +| `extractMemories.ts` | 对话提取 | 留在 `agent/` | ⏸️ 不迁移 | |
| 378 | +| `memoryShapeTelemetry.ts` | 遥测 | 不实现(项目不远程遥测) | ❌ 跳过 | |
| 379 | + |
| 380 | +## 8. Rust 生态 crate 选型 |
| 381 | + |
| 382 | +| 需求 | 选项 | 决策 | |
| 383 | +|------|------|------| |
| 384 | +| 路径正规化 | `dunce` | ✅ 已在 workspace(Windows UNC 路径处理) | |
| 385 | +| 目录遍历 | `walkdir` vs `std::fs::read_dir` | `read_dir` 够用(记忆目录单层扁平) | |
| 386 | +| YAML 解析 | `serde_yml` vs 手写 | 手写简单 K-V 解析够用(CCB 也是手写) | |
| 387 | +| 日期计算 | `chrono` vs `SystemTime` | `SystemTime`(避免重依赖,用 mtime 而非字符串日期) | |
| 388 | +| Unicode 正规化 | `unicode-normalization` | 需新增到 workspace(安全校验用) | |
| 389 | + |
| 390 | +## 9. 设计决策记录 |
| 391 | + |
| 392 | +1. **MEMORY.md 截断警告用英文** — 注入 system prompt 的指令文本固定英文(LLM 读的,不是用户看的),用户侧 TUI 提示将来可国际化 |
| 393 | +2. **MemoryRanker LLM 实现推到 Phase 4 之后** — trait 在 Phase 3 定义好 API surface,关键词评分 MVP 覆盖 80%;LLM ranking 在 `crates/agent/` 中实现 `struct LlmMemoryRanker`,注入 `crab-api` client |
| 394 | +3. **git root 由 caller 传入** — `auto_memory_dir(git_root: Option<&Path>)` 不做 git 发现,调用方(cli/agent)已知 git root 直接传入,`None` 时 fallback 到全局 `~/.crab/memory/` |
0 commit comments