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"
|
|||
|
|
```
|