Skip to content

Commit 67aa7b7

Browse files
committed
docs(memory): add design spec and implementation plan for crates/memory
1 parent ac48fd7 commit 67aa7b7

2 files changed

Lines changed: 2918 additions & 0 deletions

File tree

docs/design-crates-memory.md

Lines changed: 394 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,394 @@
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

Comments
 (0)