907 lines
27 KiB
Markdown
907 lines
27 KiB
Markdown
# Agent 长期记忆系统 Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** 为 NetaClaw Agent 添加跨会话长期记忆,支持 MySQL FULLTEXT 和 SQLite FTS5 双后端,Agent 粒度可配。
|
||
|
||
**Architecture:** Provider 抽象层 + 双后端实现。MemoryProvider 接口统一 save/search/delete 操作,MysqlMemoryProvider 用 FULLTEXT ngram,SqliteMemoryProvider 用 FTS5 trigram。工厂函数根据 Agent 配置选择后端。memory_save / memory_recall 两个工具注入 Agent 工具列表,prefetch 在每轮对话前检索相关记忆注入 system prompt。
|
||
|
||
**Tech Stack:** TypeScript, TypeORM (MySQL), better-sqlite3 (SQLite FTS5), @sinclair/typebox (tool schema)
|
||
|
||
**Spec:** `docs/superpowers/specs/2026-04-12-agent-memory-system-design.md`
|
||
|
||
---
|
||
|
||
## File Structure
|
||
|
||
```
|
||
src/modules/netaclaw/
|
||
├── memory/
|
||
│ ├── provider.ts # MemoryProvider 接口 + MemoryEntry 类型 + AgentMemoryConfig
|
||
│ ├── factory.ts # createMemoryProvider 工厂函数
|
||
│ ├── mysql_provider.ts # MysqlMemoryProvider
|
||
│ ├── sqlite_provider.ts # SqliteMemoryProvider
|
||
│ └── prefetch.ts # prefetchMemory() + formatMemoryContext()
|
||
├── entity/
|
||
│ └── memory.ts # NetaClawMemoryEntity (新增)
|
||
├── tools/builtin/
|
||
│ └── memory.ts # memory_save + memory_recall 工具
|
||
```
|
||
|
||
Modified files:
|
||
- `src/modules/netaclaw/runtime/agent.ts` — AgentRunParams 新增 memoryContext,消息注入
|
||
- `src/modules/netaclaw/controller/chat.ts` — prefetch 调用 + memory 工具注入
|
||
- `src/entities.ts` — 注册 NetaClawMemoryEntity
|
||
|
||
---
|
||
|
||
### Task 1: Install dependencies + Create MemoryEntry types and MemoryProvider interface
|
||
|
||
**Files:**
|
||
- Create: `src/modules/netaclaw/memory/provider.ts`
|
||
|
||
- [ ] **Step 1: Install better-sqlite3**
|
||
|
||
```bash
|
||
cd packages/backend && pnpm add better-sqlite3 && pnpm add -D @types/better-sqlite3
|
||
```
|
||
|
||
- [ ] **Step 2: Create provider.ts with types and interface**
|
||
|
||
Create `src/modules/netaclaw/memory/provider.ts`:
|
||
|
||
```typescript
|
||
/**
|
||
* 长期记忆 Provider 抽象层
|
||
*/
|
||
|
||
export type MemoryType = 'user' | 'project' | 'feedback' | 'reference';
|
||
|
||
export interface MemoryEntry {
|
||
id: number;
|
||
agentName: string;
|
||
userId: string;
|
||
type: MemoryType;
|
||
name: string;
|
||
content: string;
|
||
description: string;
|
||
metadata?: Record<string, unknown>;
|
||
createdAt: Date;
|
||
updatedAt: Date;
|
||
}
|
||
|
||
export interface MemorySearchOpts {
|
||
agentName: string;
|
||
userId: string;
|
||
type?: MemoryType;
|
||
limit?: number;
|
||
}
|
||
|
||
export interface AgentMemoryConfig {
|
||
enabled: boolean;
|
||
backend: 'mysql' | 'sqlite';
|
||
sqlitePath?: string;
|
||
prefetchLimit?: 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>;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Verify no TypeScript errors**
|
||
|
||
```bash
|
||
cd packages/backend && npx tsc --noEmit src/modules/netaclaw/memory/provider.ts
|
||
```
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add src/modules/netaclaw/memory/provider.ts package.json pnpm-lock.yaml
|
||
git commit -m "feat(memory): add MemoryProvider interface and types + install better-sqlite3"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 2: Create NetaClawMemoryEntity for MySQL
|
||
|
||
**Files:**
|
||
- Create: `src/modules/netaclaw/entity/memory.ts`
|
||
- Modify: `src/entities.ts`
|
||
|
||
- [ ] **Step 1: Create the entity file**
|
||
|
||
Create `src/modules/netaclaw/entity/memory.ts`:
|
||
|
||
```typescript
|
||
import { BaseEntity } from '../../base/entity/base.js';
|
||
import { Column, Entity, Index } from 'typeorm';
|
||
|
||
/**
|
||
* NetaClaw 长期记忆
|
||
*/
|
||
@Entity('netaclaw_memory')
|
||
export class NetaClawMemoryEntity extends BaseEntity {
|
||
@Index()
|
||
@Column({ comment: 'Agent名称', length: 100 })
|
||
agentName: string;
|
||
|
||
@Index()
|
||
@Column({ comment: '用户ID', length: 100 })
|
||
userId: string;
|
||
|
||
@Index()
|
||
@Column({ comment: '记忆类型: user/project/feedback/reference', length: 20 })
|
||
type: string;
|
||
|
||
@Column({ comment: '记忆标题', length: 255 })
|
||
name: string;
|
||
|
||
@Column({ type: 'text', comment: '记忆正文' })
|
||
content: string;
|
||
|
||
@Column({ comment: '一行描述', length: 500, default: '' })
|
||
description: string;
|
||
|
||
@Column({ type: 'json', comment: '元数据', nullable: true })
|
||
metadata: Record<string, unknown>;
|
||
}
|
||
```
|
||
|
||
Note: FULLTEXT 索引需要通过 migration 或手动 SQL 添加,TypeORM 不直接支持 `WITH PARSER ngram`。在 Step 3 中处理。
|
||
|
||
- [ ] **Step 2: Register entity in src/entities.ts**
|
||
|
||
在 `src/entities.ts` 中,在 `import * as entity29 from './modules/netaclaw/entity/model_channel';` 之后添加:
|
||
|
||
```typescript
|
||
import * as entity30 from './modules/netaclaw/entity/memory';
|
||
```
|
||
|
||
在 `entities` 数组末尾(`]` 之前)添加:
|
||
|
||
```typescript
|
||
...Object.values(entity30),
|
||
```
|
||
|
||
- [ ] **Step 3: Create SQL migration for FULLTEXT index**
|
||
|
||
Create `src/modules/netaclaw/memory/migration.sql` (reference file, run manually or via init):
|
||
|
||
```sql
|
||
-- 在 TypeORM 自动建表后执行,添加 ngram FULLTEXT 索引
|
||
ALTER TABLE netaclaw_memory
|
||
ADD FULLTEXT INDEX ft_content (name, content, description) WITH PARSER ngram;
|
||
```
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add src/modules/netaclaw/entity/memory.ts src/entities.ts src/modules/netaclaw/memory/migration.sql
|
||
git commit -m "feat(memory): add NetaClawMemoryEntity with FULLTEXT index migration"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 3: Implement MysqlMemoryProvider
|
||
|
||
**Files:**
|
||
- Create: `src/modules/netaclaw/memory/mysql_provider.ts`
|
||
|
||
- [ ] **Step 1: Implement MysqlMemoryProvider**
|
||
|
||
Create `src/modules/netaclaw/memory/mysql_provider.ts`:
|
||
|
||
```typescript
|
||
import { Repository } from 'typeorm';
|
||
import { NetaClawMemoryEntity } from '../entity/memory.js';
|
||
import { MemoryProvider, MemoryEntry, MemorySearchOpts } from './provider.js';
|
||
|
||
function toEntry(e: NetaClawMemoryEntity): MemoryEntry {
|
||
return {
|
||
id: e.id,
|
||
agentName: e.agentName,
|
||
userId: e.userId,
|
||
type: e.type as MemoryEntry['type'],
|
||
name: e.name,
|
||
content: e.content,
|
||
description: e.description,
|
||
metadata: e.metadata,
|
||
createdAt: e.createTime,
|
||
updatedAt: e.updateTime,
|
||
};
|
||
}
|
||
|
||
export class MysqlMemoryProvider implements MemoryProvider {
|
||
constructor(private repo: Repository<NetaClawMemoryEntity>) {}
|
||
|
||
async save(entry: Omit<MemoryEntry, 'id' | 'createdAt' | 'updatedAt'>): Promise<MemoryEntry> {
|
||
const saved = await this.repo.save({
|
||
agentName: entry.agentName,
|
||
userId: entry.userId,
|
||
type: entry.type,
|
||
name: entry.name,
|
||
content: entry.content,
|
||
description: entry.description ?? '',
|
||
metadata: entry.metadata,
|
||
});
|
||
return toEntry(saved);
|
||
}
|
||
|
||
async update(id: number, partial: Partial<Pick<MemoryEntry, 'name' | 'content' | 'description' | 'type' | 'metadata'>>): Promise<MemoryEntry> {
|
||
await this.repo.update(id, partial);
|
||
const updated = await this.repo.findOneByOrFail({ id });
|
||
return toEntry(updated);
|
||
}
|
||
|
||
async delete(id: number): Promise<void> {
|
||
await this.repo.delete(id);
|
||
}
|
||
|
||
async search(query: string, opts: MemorySearchOpts): Promise<MemoryEntry[]> {
|
||
const limit = opts.limit ?? 10;
|
||
let qb = this.repo.createQueryBuilder('m')
|
||
.where('m.agentName = :agentName', { agentName: opts.agentName })
|
||
.andWhere('m.userId = :userId', { userId: opts.userId })
|
||
.andWhere(`MATCH(m.name, m.content, m.description) AGAINST(:query IN BOOLEAN MODE)`, { query })
|
||
.orderBy(`MATCH(m.name, m.content, m.description) AGAINST(:query IN BOOLEAN MODE)`, 'DESC')
|
||
.limit(limit);
|
||
|
||
if (opts.type) {
|
||
qb = qb.andWhere('m.type = :type', { type: opts.type });
|
||
}
|
||
|
||
const results = await qb.getMany();
|
||
return results.map(toEntry);
|
||
}
|
||
|
||
async list(opts: MemorySearchOpts): Promise<MemoryEntry[]> {
|
||
const where: any = { agentName: opts.agentName, userId: opts.userId };
|
||
if (opts.type) where.type = opts.type;
|
||
const results = await this.repo.find({
|
||
where,
|
||
order: { updateTime: 'DESC' },
|
||
take: opts.limit ?? 10,
|
||
});
|
||
return results.map(toEntry);
|
||
}
|
||
|
||
async getById(id: number): Promise<MemoryEntry | null> {
|
||
const e = await this.repo.findOneBy({ id });
|
||
return e ? toEntry(e) : null;
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Verify no TypeScript errors**
|
||
|
||
```bash
|
||
cd packages/backend && npx tsc --noEmit
|
||
```
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add src/modules/netaclaw/memory/mysql_provider.ts
|
||
git commit -m "feat(memory): implement MysqlMemoryProvider with FULLTEXT search"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 4: Implement SqliteMemoryProvider
|
||
|
||
**Files:**
|
||
- Create: `src/modules/netaclaw/memory/sqlite_provider.ts`
|
||
|
||
- [ ] **Step 1: Implement SqliteMemoryProvider**
|
||
|
||
Create `src/modules/netaclaw/memory/sqlite_provider.ts`:
|
||
|
||
```typescript
|
||
import Database from 'better-sqlite3';
|
||
import * as fs from 'fs';
|
||
import * as path from 'path';
|
||
import { MemoryProvider, MemoryEntry, MemorySearchOpts } from './provider.js';
|
||
|
||
const INIT_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(type IN ('user', 'project', 'feedback', 'reference')),
|
||
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'))
|
||
);
|
||
|
||
CREATE INDEX IF NOT EXISTS idx_agent_user ON memory(agent_name, user_id);
|
||
`;
|
||
|
||
const FTS_SQL = `
|
||
CREATE VIRTUAL TABLE IF NOT EXISTS memory_fts USING fts5(
|
||
name, content, description,
|
||
content='memory', content_rowid='id',
|
||
tokenize='trigram'
|
||
);
|
||
`;
|
||
|
||
const TRIGGER_SQL = `
|
||
CREATE TRIGGER IF NOT EXISTS memory_ai AFTER INSERT ON memory BEGIN
|
||
INSERT INTO memory_fts(rowid, name, content, description)
|
||
VALUES (new.id, new.name, new.content, new.description);
|
||
END;
|
||
|
||
CREATE TRIGGER IF NOT EXISTS memory_ad AFTER DELETE ON memory BEGIN
|
||
INSERT INTO memory_fts(memory_fts, rowid, name, content, description)
|
||
VALUES ('delete', old.id, old.name, old.content, old.description);
|
||
END;
|
||
|
||
CREATE TRIGGER IF NOT EXISTS memory_au AFTER UPDATE ON memory BEGIN
|
||
INSERT INTO memory_fts(memory_fts, rowid, name, content, description)
|
||
VALUES ('delete', old.id, old.name, old.content, old.description);
|
||
INSERT INTO memory_fts(rowid, name, content, description)
|
||
VALUES (new.id, new.name, new.content, new.description);
|
||
END;
|
||
`;
|
||
|
||
function toEntry(row: any): MemoryEntry {
|
||
return {
|
||
id: row.id,
|
||
agentName: row.agent_name,
|
||
userId: row.user_id,
|
||
type: row.type,
|
||
name: row.name,
|
||
content: row.content,
|
||
description: row.description,
|
||
metadata: row.metadata ? JSON.parse(row.metadata) : undefined,
|
||
createdAt: new Date(row.created_at),
|
||
updatedAt: new Date(row.updated_at),
|
||
};
|
||
}
|
||
|
||
export class SqliteMemoryProvider implements MemoryProvider {
|
||
private db: Database.Database;
|
||
|
||
constructor(dbPath?: string) {
|
||
const resolvedPath = dbPath ?? path.resolve(process.cwd(), 'data/memory/memory.db');
|
||
fs.mkdirSync(path.dirname(resolvedPath), { recursive: true });
|
||
this.db = new Database(resolvedPath);
|
||
this.db.pragma('journal_mode = WAL');
|
||
this.db.exec(INIT_SQL);
|
||
this.db.exec(FTS_SQL);
|
||
this.db.exec(TRIGGER_SQL);
|
||
}
|
||
|
||
async save(entry: Omit<MemoryEntry, 'id' | 'createdAt' | 'updatedAt'>): Promise<MemoryEntry> {
|
||
const stmt = this.db.prepare(`
|
||
INSERT INTO memory (agent_name, user_id, type, name, content, description, metadata)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||
`);
|
||
const info = stmt.run(
|
||
entry.agentName, entry.userId, entry.type,
|
||
entry.name, entry.content, entry.description ?? '',
|
||
entry.metadata ? JSON.stringify(entry.metadata) : null,
|
||
);
|
||
return (await this.getById(info.lastInsertRowid as number))!;
|
||
}
|
||
|
||
async update(id: number, partial: Partial<Pick<MemoryEntry, 'name' | 'content' | 'description' | 'type' | 'metadata'>>): Promise<MemoryEntry> {
|
||
const sets: string[] = [];
|
||
const values: any[] = [];
|
||
if (partial.name !== undefined) { sets.push('name = ?'); values.push(partial.name); }
|
||
if (partial.content !== undefined) { sets.push('content = ?'); values.push(partial.content); }
|
||
if (partial.description !== undefined) { sets.push('description = ?'); values.push(partial.description); }
|
||
if (partial.type !== undefined) { sets.push('type = ?'); values.push(partial.type); }
|
||
if (partial.metadata !== undefined) { sets.push('metadata = ?'); values.push(JSON.stringify(partial.metadata)); }
|
||
sets.push("updated_at = datetime('now')");
|
||
values.push(id);
|
||
this.db.prepare(`UPDATE memory SET ${sets.join(', ')} WHERE id = ?`).run(...values);
|
||
return (await this.getById(id))!;
|
||
}
|
||
|
||
async delete(id: number): Promise<void> {
|
||
this.db.prepare('DELETE FROM memory WHERE id = ?').run(id);
|
||
}
|
||
|
||
async search(query: string, opts: MemorySearchOpts): Promise<MemoryEntry[]> {
|
||
const limit = opts.limit ?? 10;
|
||
// FTS5 trigram tokenizer: wrap query for substring match
|
||
const ftsQuery = `"${query.replace(/"/g, '""')}"`;
|
||
let sql = `
|
||
SELECT m.* FROM memory m
|
||
JOIN memory_fts f ON m.id = f.rowid
|
||
WHERE memory_fts MATCH ?
|
||
AND m.agent_name = ?
|
||
AND m.user_id = ?
|
||
`;
|
||
const params: any[] = [ftsQuery, opts.agentName, opts.userId];
|
||
if (opts.type) { sql += ' AND m.type = ?'; params.push(opts.type); }
|
||
sql += ' ORDER BY rank LIMIT ?';
|
||
params.push(limit);
|
||
const rows = this.db.prepare(sql).all(...params);
|
||
return rows.map(toEntry);
|
||
}
|
||
|
||
async list(opts: MemorySearchOpts): Promise<MemoryEntry[]> {
|
||
let sql = 'SELECT * FROM memory WHERE agent_name = ? AND user_id = ?';
|
||
const params: any[] = [opts.agentName, opts.userId];
|
||
if (opts.type) { sql += ' AND type = ?'; params.push(opts.type); }
|
||
sql += ' ORDER BY updated_at DESC LIMIT ?';
|
||
params.push(opts.limit ?? 10);
|
||
const rows = this.db.prepare(sql).all(...params);
|
||
return rows.map(toEntry);
|
||
}
|
||
|
||
async getById(id: number): Promise<MemoryEntry | null> {
|
||
const row = this.db.prepare('SELECT * FROM memory WHERE id = ?').get(id);
|
||
return row ? toEntry(row) : null;
|
||
}
|
||
|
||
async close(): Promise<void> {
|
||
this.db.close();
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Verify no TypeScript errors**
|
||
|
||
```bash
|
||
cd packages/backend && npx tsc --noEmit
|
||
```
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add src/modules/netaclaw/memory/sqlite_provider.ts
|
||
git commit -m "feat(memory): implement SqliteMemoryProvider with FTS5 trigram search"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 5: Create factory function
|
||
|
||
**Files:**
|
||
- Create: `src/modules/netaclaw/memory/factory.ts`
|
||
|
||
- [ ] **Step 1: Implement createMemoryProvider**
|
||
|
||
Create `src/modules/netaclaw/memory/factory.ts`:
|
||
|
||
```typescript
|
||
import { Repository } from 'typeorm';
|
||
import { NetaClawMemoryEntity } from '../entity/memory.js';
|
||
import { MemoryProvider, AgentMemoryConfig } from './provider.js';
|
||
import { MysqlMemoryProvider } from './mysql_provider.js';
|
||
import { SqliteMemoryProvider } from './sqlite_provider.js';
|
||
|
||
export function createMemoryProvider(
|
||
config: AgentMemoryConfig,
|
||
mysqlRepo?: Repository<NetaClawMemoryEntity>,
|
||
): MemoryProvider {
|
||
if (config.backend === 'sqlite') {
|
||
return new SqliteMemoryProvider(config.sqlitePath);
|
||
}
|
||
if (!mysqlRepo) {
|
||
throw new Error('MysqlMemoryProvider requires a TypeORM repository');
|
||
}
|
||
return new MysqlMemoryProvider(mysqlRepo);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Commit**
|
||
|
||
```bash
|
||
git add src/modules/netaclaw/memory/factory.ts
|
||
git commit -m "feat(memory): add createMemoryProvider factory"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 6: Create prefetch module
|
||
|
||
**Files:**
|
||
- Create: `src/modules/netaclaw/memory/prefetch.ts`
|
||
|
||
- [ ] **Step 1: Implement prefetchMemory and formatMemoryContext**
|
||
|
||
Create `src/modules/netaclaw/memory/prefetch.ts`:
|
||
|
||
```typescript
|
||
import { MemoryProvider, MemoryEntry, MemorySearchOpts } from './provider.js';
|
||
|
||
export function formatMemoryContext(entries: MemoryEntry[]): string {
|
||
if (entries.length === 0) return '';
|
||
const lines = entries.map(e => `[${e.type}] ${e.name}\n${e.content}`);
|
||
return `以下是与当前对话可能相关的长期记忆:\n\n${lines.join('\n\n')}`;
|
||
}
|
||
|
||
export async function prefetchMemory(
|
||
provider: MemoryProvider,
|
||
userMessage: string,
|
||
agentName: string,
|
||
userId: string,
|
||
limit = 5,
|
||
): Promise<string> {
|
||
const opts: MemorySearchOpts = { agentName, userId, limit };
|
||
let entries: MemoryEntry[];
|
||
try {
|
||
entries = await provider.search(userMessage, opts);
|
||
} catch {
|
||
// search 失败时 fallback 到 list(例如 query 为空或 FTS 语法错误)
|
||
entries = await provider.list(opts);
|
||
}
|
||
return formatMemoryContext(entries);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Commit**
|
||
|
||
```bash
|
||
git add src/modules/netaclaw/memory/prefetch.ts
|
||
git commit -m "feat(memory): add prefetchMemory with formatted context output"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 7: Create memory tools (memory_save + memory_recall)
|
||
|
||
**Files:**
|
||
- Create: `src/modules/netaclaw/tools/builtin/memory.ts`
|
||
|
||
- [ ] **Step 1: Implement memory tools**
|
||
|
||
Create `src/modules/netaclaw/tools/builtin/memory.ts`:
|
||
|
||
```typescript
|
||
import { Type, Static } from '@sinclair/typebox';
|
||
import { AnyAgentTool } from '../common.js';
|
||
import { MemoryProvider, MemoryType } from '../../memory/provider.js';
|
||
|
||
const MemoryTypeSchema = Type.Union([
|
||
Type.Literal('user'),
|
||
Type.Literal('project'),
|
||
Type.Literal('feedback'),
|
||
Type.Literal('reference'),
|
||
]);
|
||
|
||
// --- memory_save ---
|
||
|
||
const memorySaveParams = Type.Object({
|
||
action: Type.Union([Type.Literal('create'), Type.Literal('update'), Type.Literal('delete')]),
|
||
name: Type.String({ description: '记忆标题' }),
|
||
type: MemoryTypeSchema,
|
||
content: Type.Optional(Type.String({ description: '记忆正文' })),
|
||
description: Type.Optional(Type.String({ description: '一行描述' })),
|
||
id: Type.Optional(Type.Number({ description: '更新/删除时的记忆 ID' })),
|
||
});
|
||
|
||
type MemorySaveParams = Static<typeof memorySaveParams>;
|
||
|
||
export function createMemorySaveTool(
|
||
provider: MemoryProvider,
|
||
agentName: string,
|
||
userId: string,
|
||
): AnyAgentTool {
|
||
return {
|
||
name: 'memory_save',
|
||
label: '保存记忆',
|
||
description: '存储、更新或删除长期记忆。记忆会在未来对话中自动召回。',
|
||
parameters: memorySaveParams,
|
||
async execute(_id: string, params: MemorySaveParams): Promise<string> {
|
||
if (params.action === 'create') {
|
||
const entry = await provider.save({
|
||
agentName,
|
||
userId,
|
||
type: params.type as MemoryType,
|
||
name: params.name,
|
||
content: params.content ?? '',
|
||
description: params.description ?? '',
|
||
});
|
||
return `记忆已保存 (id=${entry.id}): ${entry.name}`;
|
||
}
|
||
if (params.action === 'update') {
|
||
if (!params.id) return '错误: 更新操作需要提供 id';
|
||
const entry = await provider.update(params.id, {
|
||
name: params.name,
|
||
type: params.type as MemoryType,
|
||
content: params.content,
|
||
description: params.description,
|
||
});
|
||
return `记忆已更新 (id=${entry.id}): ${entry.name}`;
|
||
}
|
||
if (params.action === 'delete') {
|
||
if (!params.id) return '错误: 删除操作需要提供 id';
|
||
await provider.delete(params.id);
|
||
return `记忆已删除 (id=${params.id})`;
|
||
}
|
||
return '错误: 未知操作';
|
||
},
|
||
};
|
||
}
|
||
|
||
// --- memory_recall ---
|
||
|
||
const memoryRecallParams = Type.Object({
|
||
query: Type.String({ description: '搜索关键词' }),
|
||
type: Type.Optional(MemoryTypeSchema),
|
||
limit: Type.Optional(Type.Number({ description: '返回条数,默认 5', default: 5 })),
|
||
});
|
||
|
||
type MemoryRecallParams = Static<typeof memoryRecallParams>;
|
||
|
||
export function createMemoryRecallTool(
|
||
provider: MemoryProvider,
|
||
agentName: string,
|
||
userId: string,
|
||
): AnyAgentTool {
|
||
return {
|
||
name: 'memory_recall',
|
||
label: '检索记忆',
|
||
description: '搜索长期记忆中的相关信息。',
|
||
parameters: memoryRecallParams,
|
||
async execute(_id: string, params: MemoryRecallParams): Promise<string> {
|
||
const entries = await provider.search(params.query, {
|
||
agentName,
|
||
userId,
|
||
type: params.type as MemoryType | undefined,
|
||
limit: params.limit ?? 5,
|
||
});
|
||
if (entries.length === 0) return '未找到相关记忆。';
|
||
return entries.map(e => `[${e.type}] (id=${e.id}) ${e.name}\n${e.content}`).join('\n\n');
|
||
},
|
||
};
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Verify no TypeScript errors**
|
||
|
||
```bash
|
||
cd packages/backend && npx tsc --noEmit
|
||
```
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add src/modules/netaclaw/tools/builtin/memory.ts
|
||
git commit -m "feat(memory): add memory_save and memory_recall agent tools"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 8: Modify runtime/agent.ts to support memoryContext injection
|
||
|
||
**Files:**
|
||
- Modify: `src/modules/netaclaw/runtime/agent.ts`
|
||
|
||
- [ ] **Step 1: Add memoryContext to AgentRunParams and inject into messages**
|
||
|
||
In `src/modules/netaclaw/runtime/agent.ts`:
|
||
|
||
Add `memoryContext?: string;` to the `AgentRunParams` interface (after `history?: LLMMessage[];`):
|
||
|
||
```typescript
|
||
export interface AgentRunParams {
|
||
agentConfig: AgentConfig;
|
||
tools: AnyAgentTool[];
|
||
userMessage: string;
|
||
history?: LLMMessage[];
|
||
memoryContext?: string;
|
||
onToken?: (text: string) => void;
|
||
onThinking?: (text: string) => void;
|
||
onToolCall?: (name: string, args: Record<string, unknown>) => void;
|
||
onToolResult?: (name: string, result: string) => void;
|
||
}
|
||
```
|
||
|
||
Update the `runAgent` function destructuring to include `memoryContext`:
|
||
|
||
```typescript
|
||
const { agentConfig, tools, userMessage, history = [], memoryContext,
|
||
onToken, onThinking, onToolCall, onToolResult } = params;
|
||
```
|
||
|
||
Update the messages array — append memoryContext into the single system prompt (Anthropic provider only reads the first system message, so a second system message would be silently dropped):
|
||
|
||
```typescript
|
||
const systemContent = memoryContext
|
||
? `${agentConfig.systemPrompt}\n\n<memory-context>\n${memoryContext}\n</memory-context>`
|
||
: agentConfig.systemPrompt;
|
||
|
||
const messages: LLMMessage[] = [
|
||
{ role: 'system', content: systemContent },
|
||
...history,
|
||
{ role: 'user', content: userMessage },
|
||
];
|
||
```
|
||
|
||
- [ ] **Step 2: Verify no TypeScript errors**
|
||
|
||
```bash
|
||
cd packages/backend && npx tsc --noEmit
|
||
```
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add src/modules/netaclaw/runtime/agent.ts
|
||
git commit -m "feat(memory): inject memoryContext into agent message assembly"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 9: Integrate memory into controller/chat.ts
|
||
|
||
**Files:**
|
||
- Modify: `src/modules/netaclaw/controller/chat.ts`
|
||
|
||
- [ ] **Step 1: Add imports, inject repo, wire up prefetch + tools**
|
||
|
||
In `src/modules/netaclaw/controller/chat.ts`, add imports at the top:
|
||
|
||
```typescript
|
||
import { InjectEntityModel } from '@midwayjs/typeorm';
|
||
import { Repository } from 'typeorm';
|
||
import { NetaClawMemoryEntity } from '../entity/memory.js';
|
||
import { NetaClawAgentService } from '../service/agent.js';
|
||
import { AnyAgentTool } from '../tools/common.js';
|
||
import { AgentMemoryConfig } from '../memory/provider.js';
|
||
import { createMemoryProvider } from '../memory/factory.js';
|
||
import { prefetchMemory } from '../memory/prefetch.js';
|
||
import { createMemorySaveTool, createMemoryRecallTool } from '../tools/builtin/memory.js';
|
||
```
|
||
|
||
Add the repository and service injections inside the class (after `skillLoader`):
|
||
|
||
```typescript
|
||
@InjectEntityModel(NetaClawMemoryEntity)
|
||
memoryRepo: Repository<NetaClawMemoryEntity>;
|
||
|
||
@Inject()
|
||
agentService: NetaClawAgentService;
|
||
```
|
||
|
||
Update the `chat` method. First, add `userId` to the body type:
|
||
|
||
```typescript
|
||
async chat(@Body() body: { sessionId?: string; message: string; agentName?: string; userId?: string }) {
|
||
```
|
||
|
||
After `const agentName = ...` (line 30), load the agent entity from DB:
|
||
|
||
```typescript
|
||
const agentEntity = await this.agentService.agentRepo.findOneBy({ name: agentName });
|
||
```
|
||
|
||
Then after `const systemPrompt = ...` and before `const agentConfig = ...`, add memory logic:
|
||
|
||
```typescript
|
||
// --- 记忆系统 ---
|
||
const memoryConfig: AgentMemoryConfig | undefined =
|
||
(agentEntity?.config as any)?.memory as AgentMemoryConfig | undefined;
|
||
|
||
let memoryContext: string | undefined;
|
||
let memoryTools: AnyAgentTool[] = [];
|
||
|
||
if (memoryConfig?.enabled) {
|
||
const provider = createMemoryProvider(memoryConfig, this.memoryRepo);
|
||
const userId = body.userId ?? 'anonymous';
|
||
|
||
memoryContext = await prefetchMemory(provider, body.message, agentName, userId, memoryConfig.prefetchLimit);
|
||
memoryTools = [
|
||
createMemorySaveTool(provider, agentName, userId),
|
||
createMemoryRecallTool(provider, agentName, userId),
|
||
];
|
||
}
|
||
```
|
||
|
||
Update the systemPrompt to append memory instructions when enabled:
|
||
|
||
```typescript
|
||
let finalSystemPrompt = systemPrompt;
|
||
if (memoryConfig?.enabled) {
|
||
finalSystemPrompt += MEMORY_SYSTEM_PROMPT;
|
||
}
|
||
```
|
||
|
||
Add the constant at the top of the file (after imports):
|
||
|
||
```typescript
|
||
const MEMORY_SYSTEM_PROMPT = `
|
||
|
||
## 记忆系统
|
||
你拥有长期记忆能力。使用 memory_save 工具存储重要信息,使用 memory_recall 工具检索过往记忆。
|
||
|
||
记忆类型:
|
||
- user: 用户画像(偏好、角色、习惯)
|
||
- project: 项目知识(进展、决策、约束)
|
||
- feedback: 行为反馈(用户对你行为的纠正或确认)
|
||
- reference: 引用(外部资源链接、文档地址)
|
||
|
||
存储原则:
|
||
- 当用户透露个人偏好、角色、习惯时,存为 user 类型
|
||
- 当了解到项目进展、决策、约束时,存为 project 类型
|
||
- 当用户纠正或确认你的行为时,存为 feedback 类型
|
||
- 当提到外部资源链接时,存为 reference 类型
|
||
- 更新已有记忆而非创建重复条目
|
||
- 只存储对未来对话有价值的信息`;
|
||
```
|
||
|
||
Update the agentConfig to use `finalSystemPrompt`:
|
||
|
||
```typescript
|
||
const agentConfig: AgentConfig = {
|
||
name: agentName,
|
||
systemPrompt: finalSystemPrompt,
|
||
// ... rest unchanged
|
||
};
|
||
```
|
||
|
||
Update the tools array to include memory tools:
|
||
|
||
```typescript
|
||
const tools = [bashTool, readFileTool, writeFileTool, listDirTool, ...memoryTools];
|
||
```
|
||
|
||
Update the runAgent call to pass memoryContext:
|
||
|
||
```typescript
|
||
const result = await runAgent({
|
||
agentConfig,
|
||
tools,
|
||
userMessage: body.message,
|
||
history: history.slice(0, -1),
|
||
memoryContext,
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Verify no TypeScript errors**
|
||
|
||
```bash
|
||
cd packages/backend && npx tsc --noEmit
|
||
```
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add src/modules/netaclaw/controller/chat.ts
|
||
git commit -m "feat(memory): integrate memory prefetch + tools into chat controller"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 10: Final verification
|
||
|
||
- [ ] **Step 1: Verify full TypeScript compilation**
|
||
|
||
```bash
|
||
cd packages/backend && npx tsc --noEmit
|
||
```
|
||
Expected: No errors.
|
||
|
||
- [ ] **Step 2: Verify all new files exist**
|
||
|
||
```bash
|
||
ls -la src/modules/netaclaw/memory/
|
||
ls -la src/modules/netaclaw/tools/builtin/memory.ts
|
||
ls -la src/modules/netaclaw/entity/memory.ts
|
||
```
|
||
|
||
Expected: provider.ts, factory.ts, mysql_provider.ts, sqlite_provider.ts, prefetch.ts, migration.sql in memory/; memory.ts in tools/builtin/; memory.ts in entity/.
|
||
|
||
- [ ] **Step 3: Commit all remaining changes**
|
||
|
||
```bash
|
||
git add -A && git status
|
||
git commit -m "feat(memory): complete long-term memory system implementation"
|
||
```
|