# 记忆管理模块设计

> 日期：2026-04-26
> 状态：待实施
> 目标：为 Agent 系统提供可视化的记忆管理能力，让 Agent 越用越好用

## 1. 背景与目标

现有记忆系统已支持 MySQL/SQLite 双后端存储，Agent 可通过 `memory_save` / `memory_recall` 工具自动积累用户信息。但存在以下不足：

- 记忆类型固定为 4 种（user/project/feedback/reference），无法扩展
- 没有管理页面，管理员无法查看、编辑、预设记忆数据
- Agent 工具缺少类型感知和 metadata 支持
- 管理员无法直观了解各 Agent 各存储后端的记忆分布

本次实现一个记忆管理模块，放在 Agent 管理菜单下，供管理员/运营人员手动增删改查记忆数据，并扩展 Agent 工具能力。

## 2. 数据库 & 存储设计

### 2.1 新增 `netaclaw_memory_type` 表（MySQL）

管理可用的记忆类型，两种存储后端共享此配置。

| 字段 | 类型 | 说明 |
|------|------|------|
| id | int | BaseEntity 自增主键 |
| key | varchar(50) | 类型标识，如 `environment` |
| name | varchar(100) | 显示名称，如"环境信息" |
| description | varchar(500) | 类型说明 |
| icon | varchar(50) | 图标，nullable |
| isSystem | tinyint(1) | 1=系统内置不可删除，0=自定义 |

预置系统类型：

| key | name | description |
|-----|------|-------------|
| user | 用户画像 | 用户偏好、角色、习惯 |
| project | 项目知识 | 项目进展、决策、约束 |
| feedback | 行为反馈 | 用户对 Agent 行为的纠正或确认 |
| reference | 引用资源 | 外部资源链接、文档地址 |

### 2.2 SQLite 迁移策略

SQLite 不支持 `ALTER TABLE DROP CONSTRAINT`，已有 `.db` 文件带旧 CHECK 约束。需要在 `SqliteMemoryProvider` 构造函数中加自动迁移：

```typescript
// 迁移逻辑（在构造函数中执行）
// 1. 检测当前表是否有旧 CHECK 约束（查 sqlite_master 的 sql 列）
// 2. 如果有：
//    a. CREATE TABLE memory_new (... type TEXT NOT NULL ...);  -- 无 CHECK
//    b. INSERT INTO memory_new SELECT * FROM memory;
//    c. DROP TABLE memory;
//    d. ALTER TABLE memory_new RENAME TO memory;
//    e. 重建索引和 FTS 触发器
// 3. 如果没有：跳过迁移
```

新建表的 DDL 去掉 CHECK 约束：
```sql
CREATE TABLE IF NOT EXISTS memory (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  agent_name TEXT NOT NULL,
  user_id TEXT NOT NULL,
  type TEXT NOT NULL,  -- 不再有 CHECK 约束
  name TEXT NOT NULL,
  content TEXT NOT NULL,
  description TEXT NOT NULL DEFAULT '',
  metadata TEXT,
  created_at TEXT DEFAULT (datetime('now')),
  updated_at TEXT DEFAULT (datetime('now'))
);
```

### 2.3 `netaclaw_memory` 表（MySQL）

不改动。`type` 字段为 varchar(20)，本身无约束，已支持任意值。

## 3. 后端架构

### 3.1 MemoryProviderRegistry — Provider 实例缓存池

**解决问题**：避免管理页面每次请求都创建新 Provider 实例，防止 SQLite 文件句柄泄漏和 WAL 锁冲突。

```typescript
@Provide()
@Scope(ScopeEnum.Singleton)
export class MemoryProviderRegistry {
  private cache = new Map<string, { provider: MemoryProvider; backend: string }>();

  // 注入 Agent Entity 和 Memory Entity
  @InjectEntityModel(NetaClawAgentEntity)
  agentEntity: Repository<NetaClawAgentEntity>;
  @InjectEntityModel(NetaClawMemoryEntity)
  memoryEntity: Repository<NetaClawMemoryEntity>;

  async getProvider(agentName: string): Promise<{ provider: MemoryProvider; backend: string }> {
    if (this.cache.has(agentName)) return this.cache.get(agentName)!;
    // 查 Agent 配置 → 创建 provider → 缓存
    const agent = await this.agentEntity.findOneBy({ name: agentName });
    const config = agent?.config?.memory ?? { enabled: true, backend: 'mysql' };
    const provider = createMemoryProvider(config, this.memoryEntity);
    const entry = { provider, backend: config.backend };
    this.cache.set(agentName, entry);
    return entry;
  }

  // Agent 配置变更时清除缓存
  invalidate(agentName: string) {
    const cached = this.cache.get(agentName);
    if (cached) { cached.provider.close?.(); this.cache.delete(agentName); }
  }
}
```

SQLite provider 额外配置 `busy_timeout`：
```typescript
this.db.pragma('busy_timeout = 5000');
```

### 3.2 文件结构

```
packages/backend/src/modules/netaclaw/
├── entity/
│   ├── memory.ts              # 已有，不改
│   └── memory_type.ts         # 新增
├── memory/
│   ├── provider.ts            # 扩展：新增 page/count，userId 可选
│   ├── mysql_provider.ts      # 扩展：实现 page/count
│   ├── sqlite_provider.ts     # 扩展：实现 page/count + 迁移 + busy_timeout
│   ├── factory.ts             # 已有，不改
│   └── registry.ts            # 新增：Provider 实例缓存池
├── service/
│   ├── memory_admin.ts        # 新增
│   └── memory_type.ts         # 新增
├── controller/admin/
│   ├── memory.ts              # 新增
│   └── memory_type.ts         # 新增
```

### 3.3 MemoryProvider 接口扩展

```typescript
// provider.ts 修改

export interface MemorySearchOpts {
  agentName: string;
  userId?: string;   // 改为可选 — 管理页面跨用户查询
  type?: MemoryType;
  limit?: number;
}

export interface MemoryPageOpts {
  agentName?: string; // 可选 — 不传则查所有（仅 MySQL 后端有效）
  userId?: string;    // 可选 — 管理页面不限定用户
  type?: string;
  keyWord?: string;
  page: number;
  size: number;
}

export interface MemoryPageResult {
  list: MemoryEntry[];
  pagination: { page: number; size: number; total: number };
}

export interface MemoryProvider {
  // 已有方法
  save(entry: Omit<MemoryEntry, 'id' | 'createdAt' | 'updatedAt'>): Promise<MemoryEntry>;
  update(id: number, partial: Partial<Pick<MemoryEntry, 'name' | 'content' | 'description' | 'type' | 'metadata'>>): Promise<MemoryEntry>;
  delete(id: number): Promise<void>;
  search(query: string, opts: MemorySearchOpts): Promise<MemoryEntry[]>;
  list(opts: MemorySearchOpts): Promise<MemoryEntry[]>;
  getById(id: number): Promise<MemoryEntry | null>;
  close?(): Promise<void>;

  // 新增方法
  page(opts: MemoryPageOpts): Promise<MemoryPageResult>;
  count(opts: Omit<MemoryPageOpts, 'page' | 'size'>): Promise<number>;
}
```

### 3.4 MemoryAdminService — 查询优化策略

```typescript
@Provide()
export class MemoryAdminService extends BaseService {
  @Inject()
  registry: MemoryProviderRegistry;

  @InjectEntityModel(NetaClawMemoryEntity)
  memoryEntity: Repository<NetaClawMemoryEntity>;

  @InjectEntityModel(NetaClawAgentEntity)
  agentEntity: Repository<NetaClawAgentEntity>;

  async page(params: { agentName?: string; type?: string; keyWord?: string; page: number; size: number }) {
    if (params.agentName) {
      // 指定 Agent → 走对应 provider
      const { provider, backend } = await this.registry.getProvider(params.agentName);
      const result = await provider.page(params);
      // 给每条记录附加 backend 标识
      result.list.forEach(e => (e as any)._backend = backend);
      return result;
    }

    // 未指定 Agent → 分两路查询
    // 1. MySQL 后端：直接在 netaclaw_memory 表上查一次（不按 agentName 过滤）
    // 2. SQLite 后端：只遍历使用 SQLite 的 Agent
    // 合并结果，按 updatedAt DESC 排序，做应用层分页
  }

  async stats() {
    // MySQL 后端：一条 SQL GROUP BY agentName
    const mysqlStats = await this.memoryEntity
      .createQueryBuilder('m')
      .select('m.agentName', 'agentName')
      .addSelect('COUNT(*)', 'count')
      .groupBy('m.agentName')
      .getRawMany();

    // SQLite 后端：只遍历使用 SQLite 的 Agent
    const sqliteAgents = await this.agentEntity.find(/* memory.backend = 'sqlite' */);
    const sqliteStats = [];
    for (const agent of sqliteAgents) {
      const { provider } = await this.registry.getProvider(agent.name);
      const count = await provider.count({ agentName: agent.name });
      sqliteStats.push({ agentName: agent.name, backend: 'sqlite', count });
    }

    return [
      ...mysqlStats.map(s => ({ ...s, backend: 'mysql' })),
      ...sqliteStats,
    ];
  }
}
```

### 3.5 API 接口

**记忆管理（自定义 Controller）**：

所有接口严格遵循 Cool Admin 的请求/响应格式，确保前端 `cl-crud` 组件可直接对接。

| 方法 | 路径 | 说明 |
|------|------|------|
| POST | `/admin/netaclaw/memory/page` | 分页查询，支持 agentName/type/keyWord 筛选 |
| GET | `/admin/netaclaw/memory/info` | 详情（query: agentName, id） |
| POST | `/admin/netaclaw/memory/add` | 新增（body 含 agentName） |
| POST | `/admin/netaclaw/memory/update` | 更新（body 含 agentName + id + updatedAt 乐观锁） |
| POST | `/admin/netaclaw/memory/delete` | 删除（body: { agentName, ids }） |
| GET | `/admin/netaclaw/memory/stats` | 各 Agent 各后端记忆统计 |

**复合标识**：所有操作使用 `{agentName, id}` 作为记忆的唯一标识。前端表格行数据必须携带 `agentName`。分页响应中每条记录附加 `_backend` 字段标识存储来源。

**乐观锁**：update 接口要求传入 `updatedAt`，后端比对后决定是否允许更新，防止管理员和 Agent 同时修改同一条记忆。

**类型管理（标准 @CoolController CRUD）**：

| 方法 | 路径 | 说明 |
|------|------|------|
| POST | `/admin/netaclaw/memory_type/page` | 分页 |
| POST | `/admin/netaclaw/memory_type/add` | 新增类型 |
| POST | `/admin/netaclaw/memory_type/update` | 更新类型 |
| POST | `/admin/netaclaw/memory_type/delete` | 删除（isSystem=1 的拒绝删除） |
| POST | `/admin/netaclaw/memory_type/list` | 列表（供下拉选择） |

## 4. 前端页面

### 4.1 路由与菜单

- 路由：`/agent/memory`
- 前端文件：`modules/agent/views/memory.vue`
- 菜单：挂在 Agent 管理(id=112) 下，orderNum=8

### 4.2 页面布局

```
┌──────────────────────────────────────────────────┐
│  记忆管理                                         │
├───────────┬──────────────────────────────────────┤
│ 左侧统计   │  筛选栏：[Agent ▼] [类型 ▼] [关键词]  │
│           │  [+ 新增记忆] [类型管理]               │
│ Agent A   │──────────────────────────────────────│
│ MySQL     │  记忆列表（表格）                      │
│ 15条      │  ID | 标题 | 类型(tag) | Agent        │
│           │  存储后端(tag) | 描述 | 更新时间 | 操作  │
│ Agent B   │──────────────────────────────────────│
│ SQLite    │  分页                                 │
│ 8条       │                                      │
└───────────┴──────────────────────────────────────┘
```

### 4.3 交互说明

- 左侧统计卡片：调 `/stats` 接口，按 Agent 分组展示记忆数量和存储后端标签
- 点击卡片：自动筛选该 Agent 的记忆
- 表格：类型列用彩色 Tag 展示，存储后端用 MySQL/SQLite 标签区分
- 新增/编辑弹窗：
  - 选择 Agent（下拉）
  - 选择类型（下拉，数据来自 memory_type/list）
  - 填写标题、内容（textarea）、描述
  - metadata（JSON 编辑器，可选）
- 类型管理：点击"类型管理"按钮弹出 Dialog，表格展示所有类型，系统内置类型带锁图标不可删除

## 5. Agent 工具扩展

### 5.1 类型校验 Fallback 机制

Agent 工具的类型校验不强依赖 MySQL 的 `memory_type` 表：

```typescript
async function getAvailableTypes(memoryTypeRepo?: Repository<MemoryTypeEntity>): Promise<string[]> {
  const BUILTIN_TYPES = ['user', 'project', 'feedback', 'reference'];
  if (!memoryTypeRepo) return BUILTIN_TYPES;
  try {
    const types = await memoryTypeRepo.find();
    return types.map(t => t.key);
  } catch {
    return BUILTIN_TYPES; // MySQL 不可用时回退内置类型
  }
}
```

这确保纯 SQLite 部署场景下 Agent 工具仍可正常工作。

### 5.2 新增 `memory_list_types` 工具

让 Agent 感知可用的记忆类型，存储时选择正确类型。

```typescript
// 参数：无
// 返回：可用类型列表 [{ key, name, description }]
// 实现：优先查 memory_type 表，fallback 返回内置 4 种
```

### 5.3 扩展 `memory_save`

- type 参数：从固定枚举改为 `Type.String()`，运行时通过 fallback 机制校验
- 新增 metadata 参数：`Type.Optional(Type.Record(Type.String(), Type.Unknown()))`

### 5.4 扩展 `memory_recall`

- 新增 `id` 参数：支持按 ID 精确查询
- 返回格式增加 metadata 信息

### 5.5 新增 `memory_stats` 工具（可选）

让 Agent 了解自己已存储的记忆分布，辅助决策是更新还是新建。

```typescript
// 参数：无
// 返回：{ total: number, byType: { user: 3, project: 5, ... } }
```

## 6. 架构决策记录

| # | 决策 | 原因 |
|---|------|------|
| 1 | Provider 实例缓存池（MemoryProviderRegistry 单例） | 避免每次请求创建新 SQLite 连接，防止文件句柄泄漏和 WAL 锁冲突 |
| 2 | SQLite 自动迁移去 CHECK 约束 | SQLite 不支持 ALTER TABLE DROP CONSTRAINT，需建新表迁移数据 |
| 3 | 类型校验 Fallback 机制 | 保持 SQLite 后端独立性，纯 SQLite 部署不依赖 MySQL |
| 4 | MySQL 后端查询不按 agentName 扇出 | 共享同一张表，一次 SQL 即可，避免 O(N) 查询 |
| 5 | MemorySearchOpts.userId 改为可选 | 管理页面需要跨用户查询，Agent 运行时仍传 userId |
| 6 | 复合标识 {agentName, id} | 跨后端 ID 可能冲突，必须用复合标识定位记录 |
| 7 | update 接口乐观锁（基于 updatedAt） | 防止管理员和 Agent 同时修改同一条记忆导致数据丢失 |
| 8 | SQLite busy_timeout = 5000ms | 缓解管理员和 Agent 并发写入时的 SQLITE_BUSY 错误 |

## 7. 改动清单

| 层 | 文件 | 操作 |
|----|------|------|
| Entity | `entity/memory_type.ts` | 新增 |
| Provider | `memory/provider.ts` | 扩展接口：page/count，userId 可选 |
| Provider | `memory/mysql_provider.ts` | 实现 page/count |
| Provider | `memory/sqlite_provider.ts` | 实现 page/count + CHECK 迁移 + busy_timeout |
| Registry | `memory/registry.ts` | 新增：Provider 实例缓存池 |
| Service | `service/memory_admin.ts` | 新增：管理页面 Service |
| Service | `service/memory_type.ts` | 新增：类型管理 Service |
| Controller | `controller/admin/memory.ts` | 新增：记忆管理 Controller |
| Controller | `controller/admin/memory_type.ts` | 新增：类型管理 Controller |
| Tool | `tools/builtin/memory.ts` | 扩展 save/recall + fallback 类型校验 |
| Tool | `tools/builtin/memory_types.ts` | 新增：memory_list_types + memory_stats |
| 前端 | `views/memory.vue` | 新增：记忆管理页面 |
| 前端 | `config.ts` | 注册路由 |
| 数据库 | `base_sys_menu` | 新增菜单记录 |
| 注册 | `entities.ts` | 注册新 Entity |
