feat: complete OpenClaw plugin migration with SQLite persistence

- Migrate to OpenClaw Plugin SDK with definePluginEntry and api.registerTool
- Rewrite GraphDatabase with better-sqlite3 for persistent storage
- Implement recall depth traversal, timeRange filtering, supersede mode
- Add archive and cleanup data management features
- Fix SKILL.md frontmatter to OpenClaw single-line format (kebab-case names)
- Add ToolLimiter for per-turn rate limiting
- Add TypeBox parameter schemas for OpenClaw tool registration
- Add 110 tests across 4 test files (GraphDatabase, MemoryService, Tool, Limiter)
- Update README for OpenClaw plugin installation and usage
This commit is contained in:
root
2026-04-16 18:26:19 +08:00
parent 4caf115ec9
commit 85c3431b52
27 changed files with 3574 additions and 1017 deletions

572
.sisyphus/plans/plan.md Normal file
View File

@ -0,0 +1,572 @@
# TrulyMEM → OpenClaw 重构修复计划
> **分支**: `openclaw`
> **代码位置**: `/home/program/TrulyMEM-TrueHumanMEM/`
> **目标**: 将当前 TypeScript 实现完全适配 OpenClaw 框架的接口规范,并补全 main 分支缺失的功能
---
## 背景分析
### 当前状态
openclaw 分支已完成骨架迁移类型定义、核心类GraphDatabase/MemoryService/GraphMemoryTool、Skill 定义文件均已就位TypeScript 编译通过。
### 核心问题
| 类别 | 问题 | 严重度 |
|---|---|---|
| **接口不兼容** | Tool 注册使用自定义 interface非 OpenClaw Plugin SDK | 🔴 致命 |
| **Skill 格式错误** | 多行 YAML 嵌套结构(`arguments``allowed_tools`OpenClaw 解析器只支持单行键值 | 🔴 致命 |
| **无持久化** | in-memory Map进程重启后记忆全部丢失 | 🔴 致命 |
| **Schema 格式** | 手写 JSON Schema 对象,非 `@sinclair/typebox` | 🔴 致命 |
| **返回值格式** | `JSON.stringify({success, data})``{ content: [{ type: "text", text }] }` | 🔴 致命 |
| **无 Plugin 结构** | 缺少 `openclaw.plugin.json``package.json``openclaw` 字段 | 🔴 致命 |
| **功能缺失** | depth 遍历、timeRange 过滤、supersede 模式、archive、cleanup、ToolLimiter | 🟡 中等 |
| **无测试** | 全部删除,无新测试覆盖 | 🔴 严重 |
| **命名不规范** | `graph_memory`(下划线)应为 kebab-case | 🟡 轻微 |
---
## OpenClaw 接口规范对照
### Tool 注册(官方 Plugin SDK
```typescript
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
import { Type } from "@sinclair/typebox";
export default definePluginEntry({
id: "graph-memory",
register(api) {
api.registerTool({
name: "graph_memory",
description: "图记忆工具 - 让 AI 拥有真正的长期记忆能力",
parameters: Type.Object({
action: Type.String({ enum: ["recall", "commit", "purge", ...] }),
params: Type.Object({ ... }),
}),
async execute(_id, params) {
// 返回格式必须是:
return { content: [{ type: "text", text: resultString }] };
},
});
},
});
```
### SKILL.md 格式(官方要求)
```markdown
---
name: graph-memory
description: "图记忆工具 - 检索、写入、删除记忆,管理人设和任务"
metadata: {"openclaw": {"requires": {"bins": ["node"]}}}
user-invocable: true
---
# 正文指令...
```
**关键约束**
- `metadata` 必须是**单行 JSON 对象**
- `description` 不能包含 `: `(冒号+空格),否则 YAML 解析**静默失败**
- `name` 必须 kebab-case与文件夹名匹配
- **不支持** `arguments``allowed_tools``context: inline` 等多行 YAML 嵌套结构
- 所有 frontmatter 键值必须是**单行**
### Plugin Manifest
```json
{
"id": "graph-memory",
"name": "Graph Memory",
"description": "让 AI 拥有真正的长期记忆能力",
"configSchema": {
"type": "object",
"additionalProperties": false
}
}
```
### package.json 扩展
```json
{
"openclaw": {
"extensions": ["./dist/plugin-entry.js"],
"compat": {
"pluginApi": ">=2026.3.24-beta.2",
"minGatewayVersion": "2026.3.24-beta.2"
},
"build": {
"openclawVersion": "2026.3.24-beta.2",
"pluginSdkVersion": "2026.3.24-beta.2"
}
}
}
```
---
## 实施步骤
### Phase 1: 项目结构改造为 OpenClaw Plugin🔴 P0 - 阻塞)
#### 1.1 添加 OpenClaw Plugin SDK 依赖
- [ ] 1.1.1 `npm install @sinclair/typebox`
- [ ] 1.1.2 更新 `ts/package.json` 添加 `openclaw` 字段extensions、compat、build
- [ ] 1.1.3 创建 `ts/openclaw.plugin.json` manifest 文件
#### 1.2 创建 Plugin Entry Point
- [ ] 1.2.1 创建 `ts/src/plugin-entry.ts`
- 使用 `definePluginEntry` 包裹
- 通过 `api.registerTool()` 注册 GraphMemoryTool
- 工具名称: `graph_memory`
- 描述: "图记忆工具 - 让 AI 拥有真正的长期记忆能力"
- [ ] 1.2.2 确保 entry point 导出为 ESM 格式
- [ ] 1.2.3 更新 `ts/tsconfig.json` 确保编译输出路径正确
#### 1.3 迁移 Tool Schema 到 TypeBox
- [ ] 1.3.1 创建 `ts/src/runtime/core/tools/builtin/graph_memory_schema.ts`
-`Type.Object` 定义 action 参数
-`Type.Object` 定义 params 嵌套结构
- 覆盖所有 10 个 action 的参数类型
- [ ] 1.3.2 更新 `graph_memory_tool.ts``inputSchema` 字段为 TypeBox schema
- [ ] 1.3.3 修改 `handler` 方法签名匹配 OpenClaw 的 `execute(_id, params)` 格式
- [ ] 1.3.4 修改返回值格式为 `{ content: [{ type: "text", text: string }] }`
#### 1.4 更新 Tool Interface
- [ ] 1.4.1 更新 `ts/src/runtime/core/tools/tool_interface.ts`
- 保持向后兼容(如其他模块引用)
- 添加 OpenClaw 兼容的 `execute` 方法签名
- 添加 `content` 返回类型定义
#### Phase 1 验收标准
- [ ] `npm run build` 编译通过
- [ ] `openclaw.plugin.json` 格式正确
- [ ] Plugin entry 使用 `definePluginEntry`
- [ ] Tool 使用 `api.registerTool` 注册
- [ ] Schema 使用 TypeBox
- [ ] 返回值格式为 `{ content: [{ type: "text", text }] }`
---
### Phase 2: SKILL.md 格式修复(🔴 P0 - 阻塞)
#### 2.1 修复 `skills/graph_memory/SKILL.md`
- [ ] 2.1.1 移除 `when_to_use` 多行值(合并到 `description`
- [ ] 2.1.2 移除 `context: inline`(非 OpenClaw 标准字段)
- [ ] 2.1.3 移除 `allowed_tools` 多行数组
- [ ] 2.1.4 移除 `arguments` 多行嵌套结构
- [ ] 2.1.5 `name` 改为 `graph-memory`kebab-case
- [ ] 2.1.6 `description` 改为单行,不含 `: `
- [ ] 2.1.7 添加 `metadata` 单行 JSON
- [ ] 2.1.8 保留 `user_invocable: true`(改为 `user-invocable: true`kebab-case
**修复后格式**
```yaml
---
name: graph-memory
description: "图记忆工具 - 检索、写入、删除记忆,管理人设和任务。使用 recall 检索、commit 写入、purge 删除"
metadata: {"openclaw": {"requires": {"bins": ["node"]}}}
user-invocable: true
---
```
#### 2.2 修复 `skills/graph_memory/persona/SKILL.md`
- [ ] 2.2.1 移除 `when_to_use``context``allowed_tools``arguments`
- [ ] 2.2.2 `name` 改为 `graph-memory-persona`
- [ ] 2.2.3 `description` 改为单行
- [ ] 2.2.4 添加 `metadata` 单行 JSON
#### 2.3 修复 `skills/graph_memory/task/SKILL.md`
- [ ] 2.3.1 移除 `when_to_use``context``allowed_tools``arguments`
- [ ] 2.3.2 `name` 改为 `graph-memory-task`
- [ ] 2.3.3 `description` 改为单行
- [ ] 2.3.4 添加 `metadata` 单行 JSON
#### 2.4 同步修复 `ts/bundled-skills/` 下三个文件
- [ ] 2.4.1 `ts/bundled-skills/graph_memory/SKILL.md`
- [ ] 2.4.2 `ts/bundled-skills/graph_memory/persona/SKILL.md`
- [ ] 2.4.3 `ts/bundled-skills/graph_memory/task/SKILL.md`
#### 2.5 重命名目录kebab-case
- [ ] 2.5.1 `skills/graph_memory/``skills/graph-memory/`
- [ ] 2.5.2 `skills/graph_memory/persona/``skills/graph-memory/persona/`
- [ ] 2.5.3 `skills/graph_memory/task/``skills/graph-memory/task/`
- [ ] 2.5.4 `ts/bundled-skills/graph_memory/``ts/bundled-skills/graph-memory/`
- [ ] 2.5.5 同步更新 README.md 和 README_EN.md 中的路径引用
#### Phase 2 验收标准
- [ ] 所有 SKILL.md 的 frontmatter 仅含单行键值
- [ ] `name` 全部 kebab-case
- [ ] `description` 不含 `: `
- [ ] `metadata` 为单行 JSON 对象
- [ ]`arguments``allowed_tools``context: inline` 等非标准字段
- [ ] 目录名与 `name` 一致
---
### Phase 3: GraphDatabase 持久化(🔴 P0 - 核心价值)
#### 3.1 添加 SQLite 依赖
- [ ] 3.1.1 `npm install better-sqlite3`
- [ ] 3.1.2 `npm install @types/better-sqlite3 --save-dev`
#### 3.2 重写 GraphDatabase
- [ ] 3.2.1 修改构造函数接受 `dbPath` 参数
- [ ] 3.2.2 使用 `better-sqlite3` 创建/连接数据库
- [ ] 3.2.3 创建实体表entities
- `id TEXT PRIMARY KEY`
- `name TEXT UNIQUE NOT NULL`
- `type TEXT`
- `mention_count INTEGER DEFAULT 1`
- `created_at TEXT DEFAULT CURRENT_TIMESTAMP`
- `updated_at TEXT DEFAULT CURRENT_TIMESTAMP`
- [ ] 3.2.4 创建关系表relations
- `id TEXT PRIMARY KEY`
- `source_id TEXT NOT NULL`(外键 → entities.id
- `target_id TEXT NOT NULL`(外键 → entities.id
- `relation_type TEXT NOT NULL`
- `confidence REAL DEFAULT 1.0`
- `status TEXT DEFAULT 'active'`
- `session_id TEXT`
- `turn_id INTEGER`
- `created_at TEXT DEFAULT CURRENT_TIMESTAMP`
- `updated_at TEXT DEFAULT CURRENT_TIMESTAMP`
- `date_bucket TEXT`
- `superseded_by INTEGER`
- [ ] 3.2.5 创建索引:
- `idx_entity_name ON entities(name)`
- `idx_entity_type ON entities(type)`
- `idx_relation_source ON relations(source_id)`
- `idx_relation_target ON relations(target_id)`
- `idx_relation_type ON relations(relation_type)`
- `idx_relation_status ON relations(status)`
- `idx_relation_session ON relations(session_id)`
- `idx_relation_date ON relations(date_bucket)`
#### 3.3 实现 recall 的 depth 多跳遍历
- [ ] 3.3.1 实现 BFS/DFS 图遍历算法
- [ ] 3.3.2 depth=1: 直接匹配关键词的实体及其关系
- [ ] 3.3.3 depth=2: 扩展到相邻实体的关系
- [ ] 3.3.4 depth=N: 递归扩展到 N 层
- [ ] 3.3.5 限制最大 depth 为 5防止爆炸
- [ ] 3.3.6 去重已访问实体
#### 3.4 实现 recall 的 timeRange 过滤
- [ ] 3.4.1 解析 `timeRange.days` 参数
- [ ] 3.4.2 在 SQL 查询中添加 `created_at >= datetime('now', '-N days')` 条件
- [ ] 3.4.3 支持 `timeRange.from``timeRange.to` 范围查询
#### 3.5 实现 purge 的 supersede 模式
- [ ] 3.5.1 当 `mode === 'supersede'` 时:
- 标记旧关系为 `superseded`
- 设置 `superseded_by` 指向新关系 ID
- 创建新关系(使用 `newRelation` 参数)
- [ ] 3.5.2 更新 `PurgeParams` 类型支持 `newRelation` 字段
#### 3.6 实现 memory_archive 归档功能
- [ ] 3.6.1 添加 `archive(days: number)` 方法
- [ ] 3.6.2 将 N 天前的非活跃关系标记为 `archived`
- [ ] 3.6.3 在 recall 中排除 `archived` 状态的关系(除非显式查询)
#### 3.7 实现 memory_cleanup 清理功能
- [ ] 3.7.1 添加 `cleanup(dryRun: boolean)` 方法
- [ ] 3.7.2 物理删除 `status = 'deleted'` 超过 90 天的关系
- [ ] 3.7.3 删除孤立节点(无任何关系连接的实体)
- [ ] 3.7.4 `dryRun=true` 时只返回将被删除的内容
#### 3.8 修复 isEntityDeleted 逻辑
- [ ] 3.8.1 当前逻辑有误:只要有一个关系被删就算实体被删
- [ ] 3.8.2 修正为:实体本身无 deleted 状态,通过关系状态判断
- [ ] 3.8.3 或者:在 entities 表中添加 `status` 字段
#### Phase 3 验收标准
- [ ] 数据持久化:写入后重启进程,数据仍然存在
- [ ] recall depth 遍历正确返回 N 层关系
- [ ] timeRange 过滤按时间正确筛选
- [ ] supersede 模式正确标记替代关系
- [ ] archive 正确归档旧数据
- [ ] cleanup 正确清理无效数据
- [ ] 编译通过,无类型错误
---
### Phase 4: ToolLimiter 迁移(🟡 P2 - 优化)
#### 4.1 创建 ToolLimiter
- [ ] 4.1.1 创建 `ts/src/runtime/core/tools/tool_limiter.ts`
- [ ] 4.1.2 移植 Python `ToolLimiter` 逻辑:
- `ToolLimits` 配置类
- `ToolCallCount` 计数类
- `_classify_tool` 分类方法
- `can_call` 检查方法
- `record_call` 记录方法
- `reset` 重置方法
- [ ] 4.1.3 默认限制值:
- persona_query_max: 1
- persona_update_max: 1
- task_query_max: 4
- task_update_max: 5
- memory_query_max: 20
- memory_update_max: 10
#### 4.2 集成到 GraphMemoryTool
- [ ] 4.2.1 在 Tool 构造函数中初始化 ToolLimiter
- [ ] 4.2.2 在 `execute` 方法中调用 `can_call` 检查
- [ ] 4.2.3 调用成功后调用 `record_call` 记录
- [ ] 4.2.4 每轮对话结束时调用 `reset` 重置计数
#### Phase 4 验收标准
- [ ] ToolLimiter 正确分类所有工具调用
- [ ] 超过限制时返回明确的拒绝消息
- [ ] 每轮对话计数正确重置
---
### Phase 5: 测试重建(🔴 P1 - 质量保障)
#### 5.1 GraphDatabase 测试
- [ ] 5.1.1 创建 `ts/tests/runtime/core/graph_memory/graph_database.test.ts`
- [ ] 5.1.2 commit 测试:创建实体和关系
- [ ] 5.1.3 recall 测试:按关键词检索、按 seedEntities 检索
- [ ] 5.1.4 recall depth 测试1层、2层、3层遍历
- [ ] 5.1.5 recall timeRange 测试:按时间范围过滤
- [ ] 5.1.6 purge soft 测试:软删除
- [ ] 5.1.7 purge hard 测试:硬删除
- [ ] 5.1.8 purge supersede 测试:纠错替代
- [ ] 5.1.9 introspect 测试:返回统计
- [ ] 5.1.10 持久化测试:重启后数据保留
- [ ] 5.1.11 archive 测试:归档旧数据
- [ ] 5.1.12 cleanup 测试:清理无效数据
- [ ] 5.1.13 sessionFilter 测试:按会话过滤
- [ ] 5.1.14 并发测试:多线程安全
- [ ] 5.1.15 边界测试:空查询、超长字符串
#### 5.2 MemoryService 测试
- [ ] 5.2.1 创建 `ts/tests/runtime/core/graph_memory/memory_service.test.ts`
- [ ] 5.2.2 updatePersona merge 测试
- [ ] 5.2.3 updatePersona replace 测试
- [ ] 5.2.4 clearPersona 测试
- [ ] 5.2.5 createTask 测试
- [ ] 5.2.6 setTaskState 测试
- [ ] 5.2.7 deleteTask 测试
- [ ] 5.2.8 linkInfoToTask 测试
- [ ] 5.2.9 setSessionId/getSessionId 测试
- [ ] 5.2.10 任务状态转换测试(进行中→已暂停→进行中→已完成)
- [ ] 5.2.11 人设属性合并测试
- [ ] 5.2.12 错误处理测试:无效参数
#### 5.3 GraphMemoryTool 测试
- [ ] 5.3.1 创建 `ts/tests/runtime/core/tools/builtin/graph_memory_tool.test.ts`
- [ ] 5.3.2 metadata 测试id、name、category
- [ ] 5.3.3 recall action 测试
- [ ] 5.3.4 commit action 测试
- [ ] 5.3.5 purge action 测试
- [ ] 5.3.6 introspect action 测试
- [ ] 5.3.7 persona_update action 测试
- [ ] 5.3.8 persona_clear action 测试
- [ ] 5.3.9 task_create action 测试
- [ ] 5.3.10 task_set_state action 测试
- [ ] 5.3.11 task_delete action 测试
- [ ] 5.3.12 task_link_info action 测试
- [ ] 5.3.13 未知 action 错误处理测试
- [ ] 5.3.14 返回值格式测试:`{ content: [{ type: "text", text }] }`
- [ ] 5.3.15 ToolLimiter 集成测试
#### 5.4 Plugin Entry 集成测试
- [ ] 5.4.1 创建 `ts/tests/plugin-entry.test.ts`
- [ ] 5.4.2 Plugin 注册测试
- [ ] 5.4.3 Tool 注册测试
- [ ] 5.4.4 Schema 验证测试
#### Phase 5 验收标准
- [ ] `npm test` 全部通过50+ 用例)
- [ ] 无跳过skip的测试
- [ ] 覆盖率 > 80%
---
### Phase 6: 文档更新(🟢 P3 - 收尾)
#### 6.1 更新 README.md
- [ ] 6.1.1 更新标题TrulyMEM → OpenClaw Graph Memory Plugin
- [ ] 6.1.2 更新安装方式:`openclaw plugins install`
- [ ] 6.1.3 更新使用示例
- [ ] 6.1.4 更新目录结构说明
- [ ] 6.1.5 更新 API 文档(反映 TypeBox schema
#### 6.2 更新 README_EN.md
- [ ] 6.2.1 同步中文 README 的所有更新
- [ ] 6.2.2 确保英文表达准确
#### 6.3 更新迁移设计文档
- [ ] 6.3.1 更新 `docs/integration/waterflow-design.md`
- [ ] 6.3.2 添加 OpenClaw 接口适配说明
- [ ] 6.3.3 更新架构图中 Plugin SDK 部分
#### Phase 6 验收标准
- [ ] README.md 和 README_EN.md 内容一致
- [ ] 安装步骤可执行
- [ ] API 文档与实际代码一致
---
## 目标目录结构(重构后)
```
TrulyMEM-TrueHumanMEM/
├── ts/
│ ├── src/
│ │ ├── plugin-entry.ts # [NEW] OpenClaw Plugin 入口
│ │ └── runtime/core/
│ │ ├── graph_memory/
│ │ │ ├── index.ts
│ │ │ ├── types.ts
│ │ │ ├── graph_database.ts # [REWRITE] SQLite 持久化
│ │ │ └── memory_service.ts
│ │ └── tools/
│ │ ├── builtin/
│ │ │ ├── graph_memory_tool.ts # [UPDATE] OpenClaw 兼容
│ │ │ └── graph_memory_schema.ts # [NEW] TypeBox Schema
│ │ ├── tool_interface.ts # [UPDATE] 添加 execute 签名
│ │ └── tool_limiter.ts # [NEW] 调用限制器
│ ├── bundled-skills/
│ │ └── graph-memory/ # [RENAMED] kebab-case
│ │ ├── SKILL.md # [FIXED] 单行 frontmatter
│ │ ├── persona/
│ │ │ └── SKILL.md # [FIXED]
│ │ └── task/
│ │ └── SKILL.md # [FIXED]
│ ├── tests/
│ │ └── runtime/core/
│ │ ├── graph_memory/
│ │ │ ├── graph_database.test.ts # [NEW]
│ │ │ └── memory_service.test.ts # [NEW]
│ │ └── tools/builtin/
│ │ └── graph_memory_tool.test.ts # [NEW]
│ ├── package.json # [UPDATE] 添加 openclaw 字段
│ ├── tsconfig.json
│ └── openclaw.plugin.json # [NEW] Plugin Manifest
├── skills/
│ └── graph-memory/ # [RENAMED] kebab-case
│ ├── SKILL.md # [FIXED]
│ ├── persona/
│ │ └── SKILL.md # [FIXED]
│ └── task/
│ └── SKILL.md # [FIXED]
├── docs/integration/waterflow-design.md # [UPDATE]
├── README.md # [UPDATE]
├── README_EN.md # [UPDATE]
├── .gitignore
└── LICENSE
```
---
## 依赖变更
```json
{
"dependencies": {
"yaml": "^2.8.3",
"@sinclair/typebox": "^0.34.0",
"better-sqlite3": "^11.0.0"
},
"devDependencies": {
"@types/node": "^25.5.2",
"@types/better-sqlite3": "^7.6.0",
"typescript": "^5.0.0",
"vitest": "^2.0.0"
}
}
```
---
## 执行顺序与并行策略
```
Phase 1 (P0) ──────────────────────────────────────┐
1.1 依赖 ─→ 1.2 Entry ─→ 1.3 Schema ─→ 1.4 Interface │
├── 必须最先完成
Phase 2 (P0) ──────────────────────────────────────┤ 否则无法在 OpenClaw 中运行
2.1-2.3 SKILL.md 修复(可并行) │
2.4 bundled-skills 同步 │
2.5 目录重命名 │
Phase 3 (P0) ──────────────────────────────────────┤
3.1 SQLite 依赖 │
3.2 GraphDatabase 重写 │
3.3-3.7 功能补全(可部分并行) │
3.8 逻辑修复 │
Phase 4 (P2) ──────────────────────────────────────┤ 优化项,可延后
4.1 ToolLimiter 创建 │
4.2 集成到 Tool │
Phase 5 (P1) ──────────────────────────────────────┘ 在 Phase 1-3 完成后执行
5.1-5.4 测试重建(可并行编写)
Phase 6 (P3) ────────────────────────────────────────── 最后执行,文档收尾
6.1-6.3 文档更新
```
---
## 风险与注意事项
| 风险 | 影响 | 缓解措施 |
|---|---|---|
| better-sqlite3 原生模块编译失败 | 阻塞 Phase 3 | 使用预编译二进制或回退到 sql.js |
| OpenClaw Plugin SDK 版本不兼容 | 阻塞 Phase 1 | 锁定 `compat.pluginApi` 版本 |
| SKILL.md 描述中的中文冒号 | 静默加载失败 | 所有 description 用双引号包裹 |
| SQLite 并发写入冲突 | 数据损坏 | 使用 WAL 模式 + 连接池 |
| depth 遍历性能问题 | 响应缓慢 | 限制最大 depth=5结果上限 100 |
---
## 验收总标准
- [ ] Phase 1-6 全部完成
- [ ] `npm run build` 编译通过,无错误无警告
- [ ] `npm test` 全部通过50+ 用例)
- [ ] 作为 OpenClaw Plugin 可安装、可加载、可调用
- [ ] 数据持久化:写入后重启进程,数据仍然存在
- [ ] 所有 main 分支的核心功能均已实现
- [ ] SKILL.md 通过 OpenClaw 的 `openclaw skills check` 验证

View File

@ -0,0 +1,95 @@
# 重构执行进度追踪
> 最后更新: 2026-04-16 15:00
> 当前分支: openclaw
## Phase 1: 项目结构改造为 OpenClaw Plugin🔴 P0
### 1.1 添加 OpenClaw Plugin SDK 依赖
- [x] 1.1.1 `npm install @sinclair/typebox better-sqlite3`
- [x] 1.1.2 更新 `ts/package.json` 添加 `openclaw` 字段
- [x] 1.1.3 创建 `ts/openclaw.plugin.json` manifest 文件
### 1.2 创建 Plugin Entry Point
- [x] 1.2.1 创建 `ts/src/plugin-entry.ts`
- [x] 1.2.2 确保 entry point 导出为 ESM 格式
- [x] 1.2.3 更新 `ts/tsconfig.json` 确保编译输出路径正确
### 1.3 迁移 Tool Schema 到 TypeBox
- [x] 1.3.1 添加 TypeBox schema 到 plugin-entry.ts
- [x] 1.3.2 更新 `graph_memory_tool.ts``inputSchema` 字段(添加 newRelation, days, dry_run
- [x] 1.3.3 添加 `execute(_id, params)` 方法匹配 OpenClaw 签名
- [x] 1.3.4 返回值格式为 `{ content: [{ type: "text", text }] }`
### 1.4 更新 Tool Interface
- [x] 1.4.1 添加 `OpenClawToolResult` 类型到 graph_memory_tool.ts
## Phase 2: SKILL.md 格式修复(🔴 P0
### 2.1 修复 skills/graph-memory/SKILL.md
- [x] 2.1.1 移除多行嵌套结构
- [x] 2.1.2 name 改为 kebab-case
- [x] 2.1.3 description 改为单行
- [x] 2.1.4 添加 metadata 单行 JSON
### 2.2 修复 skills/graph-memory/persona/SKILL.md
- [x] 2.2.1 同上
### 2.3 修复 skills/graph-memory/task/SKILL.md
- [x] 2.3.1 同上
### 2.4 同步修复 bundled-skills
- [x] 2.4.1 ts/bundled-skills/graph-memory/SKILL.md
- [x] 2.4.2 ts/bundled-skills/graph-memory/persona/SKILL.md
- [x] 2.4.3 ts/bundled-skills/graph-memory/task/SKILL.md
### 2.5 重命名目录
- [x] 2.5.1 skills/graph_memory/ → skills/graph-memory/
- [x] 2.5.2 ts/bundled-skills/graph_memory/ → ts/bundled-skills/graph-memory/
## Phase 3: GraphDatabase 持久化(🔴 P0
### 3.1 添加 SQLite 依赖
- [ ] 3.1.1 npm install better-sqlite3
- [ ] 3.1.2 npm install @types/better-sqlite3
### 3.2 重写 GraphDatabase
- [x] 3.2.1 修改构造函数接受 dbPath
- [x] 3.2.2 使用 better-sqlite3 创建/连接数据库
- [x] 3.2.3 创建实体表
- [x] 3.2.4 创建关系表
- [x] 3.2.5 创建索引
### 3.3-3.7 功能补全
- [x] 3.3 depth 多跳遍历 (BFS)
- [x] 3.4 timeRange 过滤
- [x] 3.5 supersede 模式
- [x] 3.6 archive 归档
- [x] 3.7 cleanup 清理
### 3.8 修复逻辑
- [x] 3.8.1 修复 isEntityDeleted 逻辑 (已移除,用 SQL 替代)
## Phase 4: ToolLimiter🟡 P2
- [ ] 4.1 创建 ToolLimiter
- [ ] 4.2 集成到 GraphMemoryTool
## Phase 5: 测试重建(🔴 P1
- [ ] 5.1 GraphDatabase 测试 (15+)
- [ ] 5.2 MemoryService 测试 (12+)
- [ ] 5.3 GraphMemoryTool 测试 (15+)
- [ ] 5.4 Plugin Entry 集成测试
## Phase 6: 文档更新(🟢 P3
- [ ] 6.1 更新 README.md
- [ ] 6.2 更新 README_EN.md
- [ ] 6.3 更新迁移设计文档
## 最终验收
- [ ] npm run build 编译通过
- [ ] npm test 全部通过
- [ ] 推送到远程 openclaw 分支

286
README.md
View File

@ -1,113 +1,30 @@
# TrulyMEM - OpenClaw 适配版
# TrulyMEM - OpenClaw Graph Memory Plugin
让 AI 拥有真正的长期记忆能力 - OpenClaw 框架适配
让 AI 拥有真正的长期记忆能力 - OpenClaw 框架插件
[English Version](./README_EN.md)
---
## 🤖 AI 自行安装指南
> 本节面向 AI Agent指导其如何自主安装此 Skill 到 OpenClaw 框架
### 安装步骤
#### 步骤 1定位 Skill 目录
OpenClaw 的 Skill 优先级:`/.agents/skills/` > `~/.agents/skills/` > `~/.openclaw/skills/`
将本项目的 `skills/` 目录复制到高优先级位置:
```bash
# 方式 1: 用户级安装 (推荐)
cp -r skills/ ~/.agents/skills/graph_memory
# 方式 2: 系统级安装
sudo cp -r skills/ /.agents/skills/graph_memory
```
#### 步骤 2验证安装
在 OpenClaw 中调用 Skill
```
记得: 我喜欢编程,正在学习 TypeScript
```
AI 应收到确认消息。
#### 步骤 3使用 Skill
安装成功后AI 可以使用以下命令:
| 命令 | 功能 |
|------|------|
| `commit` | 写入记忆 |
| `recall` | 检索记忆 |
| `purge` | 删除记忆 |
| `introspect` | 查看状态 |
| `persona_update/clear` | 人设管理 |
| `task_create/set_state/delete` | 任务管理 |
---
## 简介
本项目是将 TrulyMEM 的图记忆能力迁移到 OpenClaw 框架的 TypeScript 实现
本项目是 OpenClaw 的图记忆插件,基于 SQLite 实现持久化图数据库
作为 OpenClaw 的 Skill 模块,提供图记忆功能:
- **recall**: 检索记忆
- **commit**: 写入记忆
- **purge**: 删除记忆
- **introspect**: 查看状态
**核心功能:**
- **recall**: 检索记忆(支持关键词、种子实体、多跳遍历、时间过滤)
- **commit**: 写入记忆(三元组批量写入)
- **purge**: 删除记忆(软删除/硬删除/纠错替代)
- **introspect**: 查看记忆状态
- **archive**: 归档旧记忆
- **cleanup**: 清理无效数据
- **persona_update/clear**: 人设管理
- **task_create/set_state/delete**: 任务管理
- **task_create/set_state/delete/link_info**: 任务管理
---
## 目录结构
## 安装
```
ts/
├── src/runtime/core/
│ ├── graph_memory/ # 图记忆核心模块
│ │ ├── types.ts # 类型定义
│ │ ├── graph_database.ts # 图数据库
│ │ ├── memory_service.ts # 记忆服务
│ │ └── index.ts # 模块导出
│ └── tools/
│ └── builtin/
│ └── graph_memory_tool.ts # Tool 实现
├── package.json # 项目配置
└── tsconfig.json # TypeScript 配置
skills/ # OpenClaw Skill 定义
└── graph_memory/
├── SKILL.md # 记忆操作
├── persona/SKILL.md # 人设管理
└── task/SKILL.md # 任务管理
```
---
## 在 OpenClaw 中使用
### 方式一:作为模块直接引用(适合开发者集成)
#### 步骤 1复制源码
将本项目的 `ts/` 目录复制到你的 OpenClaw 项目中:
```
你的OpenClaw项目/
└── src/
└── runtime/
└── core/
└── graph_memory/ # 从 ts/src/runtime/core/ 复制
```
#### 步骤 2编译 TypeScript
### 方式一:作为 OpenClaw 插件安装
```bash
cd ts/
@ -115,18 +32,77 @@ npm install
npm run build
```
编译后的文件会输出到 `ts/dist/` 目录
将插件目录添加到 OpenClaw 配置中,或使用 `openclaw plugins install` 安装
#### 步骤 3在代码中引用
### 方式二:作为 Skill 安装(推荐)
`skills/` 目录复制到 OpenClaw 的 Skill 目录:
```bash
cp -r skills/graph-memory ~/.agents/skills/graph-memory
cp -r skills/graph-memory/persona ~/.agents/skills/graph-memory-persona
cp -r skills/graph-memory/task ~/.agents/skills/graph-memory-task
```
---
## 目录结构
```
ts/
├── src/
│ ├── plugin-entry.ts # OpenClaw Plugin 入口
│ └── runtime/core/
│ ├── graph_memory/
│ │ ├── types.ts # 类型定义
│ │ ├── graph_database.ts # SQLite 图数据库
│ │ ├── memory_service.ts # 记忆服务
│ │ └── index.ts # 模块导出
│ └── tools/
│ ├── builtin/
│ │ └── graph_memory_tool.ts # Tool 实现
│ └── tool_limiter.ts # 调用限制器
├── bundled-skills/
│ └── graph-memory/ # 内置 Skill 定义
│ ├── SKILL.md
│ ├── persona/SKILL.md
│ └── task/SKILL.md
├── package.json
├── tsconfig.json
└── openclaw.plugin.json # Plugin Manifest
skills/ # 独立 Skill 定义
└── graph-memory/
├── SKILL.md
├── persona/SKILL.md
└── task/SKILL.md
```
---
## 在 OpenClaw 中使用
### 作为 Plugin
```typescript
import { createGraphMemoryTool } from './runtime/core/tools/builtin/graph_memory_tool';
import registerGraphMemoryPlugin from './dist/plugin-entry.js';
// 创建工具实例
const tool = createGraphMemoryTool('my-session-id');
registerGraphMemoryPlugin({
registerTool(tool) {
// OpenClaw 会自动注册工具
}
});
```
// 写入记忆示例
const commitResult = await tool.handler({
### 作为独立模块
```typescript
import { createGraphMemoryTool } from './dist/runtime/core/tools/builtin/graph_memory_tool.js';
const tool = createGraphMemoryTool('graph_memory.db', 'my-session-id');
// 写入记忆
const result = await tool.execute('call-1', {
action: 'commit',
params: {
triplets: [
@ -134,111 +110,59 @@ const commitResult = await tool.handler({
{ subject: '用户', relation: '正在学习', object: 'TypeScript' }
]
}
}, context);
});
// 检索记忆示例
const recallResult = await tool.handler({
// 检索记忆
const recallResult = await tool.execute('call-2', {
action: 'recall',
params: {
queryIntent: '用户 编程'
}
}, context);
params: { queryIntent: '用户 编程' }
});
```
### 方式二:使用 Skill推荐适合 AI Agent 调用)
#### 步骤 1放置 Skill 文件
`skills/` 目录复制到 OpenClaw 的 Skill 目录:
```bash
cp -r skills/ ~/.agents/skills/graph_memory
```
#### 步骤 2通过 Agent 调用 Skill
在 OpenClaw 中直接调用:
```
使用 graph_memory 记住: 我喜欢编程,正在学习 TypeScript
```
#### 可用 Skill 列表
| Skill 名称 | 功能 | 使用场景 |
|------------|------|----------|
| `graph_memory` | 记忆 CRUD | 读取/写入/删除记忆 |
| `graph_memory_persona` | 人设管理 | 设置 AI 角色性格 |
| `graph_memory_task` | 任务管理 | 创建/更新长期任务 |
---
## API
### GraphMemoryTool
```typescript
const tool = new GraphMemoryTool(sessionId?: string);
```
#### Actions
### Actions
| Action | 说明 | 参数 |
|--------|------|------|
| `recall` | 检索记忆 | `queryIntent`, `seedEntities`, `sessionFilter` |
| `recall` | 检索记忆 | `queryIntent`, `seedEntities`, `depth`, `sessionFilter`, `timeRange` |
| `commit` | 写入记忆 | `triplets`, `sessionId`, `turnId` |
| `purge` | 删除记忆 | `criteria`, `mode` |
| `purge` | 删除记忆 | `criteria`, `mode` (soft/hard/supersede), `newRelation` |
| `introspect` | 查看状态 | - |
| `persona_update` | 更新人设 | `attributes`, `mode` |
| `archive` | 归档旧记忆 | `days` (默认 30) |
| `cleanup` | 清理无效数据 | `dry_run` (默认 true) |
| `persona_update` | 更新人设 | `attributes`, `mode` (merge/replace) |
| `persona_clear` | 清除人设 | `confirm` |
| `task_create` | 创建任务 | `task_id`, `description`, `info_nodes` |
| `task_set_state` | 设置状态 | `task_id`, `state` |
| `task_delete` | 删除任务 | `task_id` |
| `task_link_info` | 关联信息 | `task_id`, `info_node` |
---
## 示例
## Skill 列表
### 写入记忆
| Skill 名称 | 功能 | 使用场景 |
|------------|------|----------|
| `graph-memory` | 记忆 CRUD | 读取/写入/删除记忆 |
| `graph-memory-persona` | 人设管理 | 设置 AI 角色性格 |
| `graph-memory-task` | 任务管理 | 创建/更新长期任务 |
```json
{
"action": "commit",
"params": {
"triplets": [
{ "subject": "用户", "relation": "喜欢", "object": "TypeScript" },
{ "subject": "用户", "relation": "正在学习", "object": "OpenClaw" }
]
}
}
```
---
### 检索记忆
## 开发
```json
{
"action": "recall",
"params": {
"queryIntent": "用户 学习"
}
}
```
### 创建任务
```json
{
"action": "task_create",
"params": {
"task_id": "Task_学习TypeScript",
"description": "学习 TypeScript 并完成项目",
"info_nodes": ["文档链接", "教程链接"]
}
}
```bash
cd ts/
npm install
npm run build # 编译
npm test # 运行测试
```
---
## 许可证
[GNU General Public License v3.0 (GPLv3)](LICENSE)
[GNU General Public License v3.0 (GPLv3)](LICENSE)

View File

@ -1,114 +1,30 @@
# TrulyMEM - OpenClaw Adapter
# TrulyMEM - OpenClaw Graph Memory Plugin
Give AI true long-term memory capability - OpenClaw framework adapter version
Give AI true long-term memory capability - OpenClaw framework plugin version
[中文版本](./README.md)
---
## 🤖 AI Self-Installation Guide
> This section is for AI Agents, guiding them how to self-install this Skill to OpenClaw
### Installation Steps
#### Step 1: Locate Skill Directory
OpenClaw Skill precedence: `/.agents/skills/` > `~/.agents/skills/` > `~/.openclaw/skills/`
Copy the `skills/` directory to a high-priority location:
```bash
# Method 1: User-level installation (recommended)
cp -r skills/ ~/.agents/skills/graph_memory
# Method 2: System-level installation
sudo cp -r skills/ /.agents/skills/graph_memory
```
#### Step 2: Verify Installation
Invoke Skill in OpenClaw:
```
Remember: I like programming and am learning TypeScript
```
AI should receive a confirmation message.
#### Step 3: Use the Skill
After installation, AI can use these commands:
| Command | Function |
|---------|----------|
| `commit` | Commit memories |
| `recall` | Retrieve memories |
| `purge` | Delete memories |
| `introspect` | Inspect status |
| `persona_update/clear` | Persona management |
| `task_create/set_state/delete` | Task management |
---
## Introduction
This project ports TrulyMEM's graph memory capability to TypeScript for the OpenClaw framework.
This project is an OpenClaw plugin for graph-based memory with SQLite persistence.
As an OpenClaw Skill module, it provides graph memory functionality:
- **recall**: Retrieve memories
- **commit**: Commit memories
- **purge**: Delete memories
- **introspect**: Inspect status
**Core Features:**
- **recall**: Retrieve memories (keyword, seed entities, multi-hop traversal, time filtering)
- **commit**: Write memories (batch triplet writes)
- **purge**: Delete memories (soft/hard/supersede modes)
- **introspect**: View memory statistics
- **archive**: Archive old memories
- **cleanup**: Clean up invalid data
- **persona_update/clear**: Persona management
- **task_create/set_state/delete**: Task management
- **task_create/set_state/delete/link_info**: Task management
---
## Directory Structure
## Installation
```
ts/
├── src/runtime/core/
│ ├── graph_memory/ # Graph memory core module
│ │ ├── types.ts # Type definitions
│ │ ├── graph_database.ts # Graph database
│ │ ├── memory_service.ts # Memory service
│ │ └── index.ts # Module exports
│ └── tools/
│ └── builtin/
│ └── graph_memory_tool.ts # Tool implementation
├── package.json # Project config
└── tsconfig.json # TypeScript config
skills/ # OpenClaw Skill definitions
└── graph_memory/
├── SKILL.md # Memory operations
├── persona/SKILL.md # Persona management
└── task/SKILL.md # Task management
```
---
## Using in OpenClaw
### Method 1: As Module (for Developer Integration)
#### Step 1: Copy Source Code
Copy the `ts/` directory to your OpenClaw project:
```
yourOpenClawProject/
└── src/
└── runtime/
└── core/
└── graph_memory/ # Copy from ts/src/runtime/core/
```
#### Step 2: Compile TypeScript
### Method 1: As OpenClaw Plugin
```bash
cd ts/
@ -116,130 +32,137 @@ npm install
npm run build
```
Compiled files output to `ts/dist/` directory.
Add the plugin directory to your OpenClaw config, or use `openclaw plugins install`.
#### Step 3: Import in Code
### Method 2: As Skill (Recommended)
Copy the `skills/` directory to OpenClaw's skill directory:
```bash
cp -r skills/graph-memory ~/.agents/skills/graph-memory
cp -r skills/graph-memory/persona ~/.agents/skills/graph-memory-persona
cp -r skills/graph-memory/task ~/.agents/skills/graph-memory-task
```
---
## Directory Structure
```
ts/
├── src/
│ ├── plugin-entry.ts # OpenClaw Plugin entry point
│ └── runtime/core/
│ ├── graph_memory/
│ │ ├── types.ts # Type definitions
│ │ ├── graph_database.ts # SQLite graph database
│ │ ├── memory_service.ts # Memory service
│ │ └── index.ts # Module exports
│ └── tools/
│ ├── builtin/
│ │ └── graph_memory_tool.ts # Tool implementation
│ └── tool_limiter.ts # Call rate limiter
├── bundled-skills/
│ └── graph-memory/ # Bundled Skill definitions
│ ├── SKILL.md
│ ├── persona/SKILL.md
│ └── task/SKILL.md
├── package.json
├── tsconfig.json
└── openclaw.plugin.json # Plugin Manifest
skills/ # Standalone Skill definitions
└── graph-memory/
├── SKILL.md
├── persona/SKILL.md
└── task/SKILL.md
```
---
## Usage in OpenClaw
### As Plugin
```typescript
import { createGraphMemoryTool } from './runtime/core/tools/builtin/graph_memory_tool';
import registerGraphMemoryPlugin from './dist/plugin-entry.js';
// Create tool instance
const tool = createGraphMemoryTool('my-session-id');
registerGraphMemoryPlugin({
registerTool(tool) {
// OpenClaw will auto-register the tool
}
});
```
// Commit memory example
const commitResult = await tool.handler({
### As Standalone Module
```typescript
import { createGraphMemoryTool } from './dist/runtime/core/tools/builtin/graph_memory_tool.js';
const tool = createGraphMemoryTool('graph_memory.db', 'my-session-id');
// Write memory
const result = await tool.execute('call-1', {
action: 'commit',
params: {
triplets: [
{ subject: '用户', relation: '喜欢', object: '编程' },
{ subject: '用户', relation: '正在学习', object: 'TypeScript' }
{ subject: 'User', relation: 'likes', object: 'programming' },
{ subject: 'User', relation: 'learning', object: 'TypeScript' }
]
}
}, context);
});
// Recall memory example
const recallResult = await tool.handler({
// Recall memory
const recallResult = await tool.execute('call-2', {
action: 'recall',
params: {
queryIntent: '用户 编程'
}
}, context);
params: { queryIntent: 'User programming' }
});
```
### Method 2: Use Skill (Recommended for AI Agent)
#### Step 1: Place Skill Files
Copy `skills/` directory to OpenClaw's Skill directory:
```bash
cp -r skills/ ~/.agents/skills/graph_memory
```
#### Step 2: Invoke Skill
Use directly in OpenClaw:
```
Use graph_memory to remember: I like programming and am learning TypeScript
```
#### Available Skills
| Skill Name | Function | Use Case |
|------------|----------|----------|
| `graph_memory` | Memory CRUD | Read/write/delete memories |
| `graph_memory_persona` | Persona management | Set AI persona |
| `graph_memory_task` | Task management | Create/update tasks |
---
## API
### GraphMemoryTool
```typescript
const tool = new GraphMemoryTool(sessionId?: string);
```
#### Actions
### Actions
| Action | Description | Parameters |
|--------|-------------|------------|
| `recall` | Retrieve memories | `queryIntent`, `seedEntities`, `sessionFilter` |
| `commit` | Commit memories | `triplets`, `sessionId`, `turnId` |
| `purge` | Delete memories | `criteria`, `mode` |
| `introspect` | Inspect status | - |
| `persona_update` | Update persona | `attributes`, `mode` |
| `recall` | Retrieve memories | `queryIntent`, `seedEntities`, `depth`, `sessionFilter`, `timeRange` |
| `commit` | Write memories | `triplets`, `sessionId`, `turnId` |
| `purge` | Delete memories | `criteria`, `mode` (soft/hard/supersede), `newRelation` |
| `introspect` | View status | - |
| `archive` | Archive old memories | `days` (default 30) |
| `cleanup` | Clean invalid data | `dry_run` (default true) |
| `persona_update` | Update persona | `attributes`, `mode` (merge/replace) |
| `persona_clear` | Clear persona | `confirm` |
| `task_create` | Create task | `task_id`, `description`, `info_nodes` |
| `task_set_state` | Set task state | `task_id`, `state` |
| `task_set_state` | Set state | `task_id`, `state` |
| `task_delete` | Delete task | `task_id` |
| `task_link_info` | Link info | `task_id`, `info_node` |
---
## Examples
## Skills
### Commit Memory
| Skill Name | Function | Use Case |
|------------|----------|----------|
| `graph-memory` | Memory CRUD | Read/write/delete memories |
| `graph-memory-persona` | Persona management | Set AI role/personality |
| `graph-memory-task` | Task management | Create/update long-term tasks |
```json
{
"action": "commit",
"params": {
"triplets": [
{ "subject": "用户", "relation": "喜欢", "object": "TypeScript" },
{ "subject": "用户", "relation": "正在学习", "object": "OpenClaw" }
]
}
}
```
---
### Recall Memory
## Development
```json
{
"action": "recall",
"params": {
"queryIntent": "用户 学习"
}
}
```
### Create Task
```json
{
"action": "task_create",
"params": {
"task_id": "Task_学习TypeScript",
"description": "学习 TypeScript 并完成项目",
"info_nodes": ["文档链接", "教程链接"]
}
}
```bash
cd ts/
npm install
npm run build # Compile
npm test # Run tests
```
---
## License
[GNU General Public License v3.0 (GPLv3)](LICENSE)
[GNU General Public License v3.0 (GPLv3)](LICENSE)

View File

@ -1,35 +1,21 @@
---
name: graph_memory
description: 让 AI 拥有真正的长期记忆能力 - 检索、写入、删除记忆,管理人设和任务
when_to_use: 当需要 AI 记住持久信息、回忆过去交互、或管理长期任务时
version: 1.0.0
name: graph-memory
description: "图记忆工具 - 检索、写入、删除记忆,管理人设和任务。使用 recall 检索、commit 写入、purge 删除"
metadata: {"openclaw": {"requires": {"bins": ["node"]}}}
user-invocable: true
---
# GraphMemory 图记忆系统
让 AI 拥有真正的长期记忆能力。
## 这个技能做什么
这个技能教会 AI 如何使用图结构存储和检索记忆。AI 可以:
- 记住重要的信息(实体)
- 理解信息之间的关系(关系/三元组)
- 回忆相关的记忆
- 管理 AI 的人设(角色性格)
- 追踪长期任务
## 核心概念
### 实体 (Entity)
现实世界中的对象,如"用户"、"Python"、"WaterFlow"。每个实体有:
- 名称 (name)
- 类型 (type)
- 提及次数 (mentionCount)
现实世界中的对象,如"用户"、"Python"、"WaterFlow"。
### 关系 (Relation)
连接两个实体的关系,格式为三元组:
- 主体 (subject) - 关系 - 客体 (object)
- 例如:"用户" 喜欢 "编程"
连接两个实体的关系,格式为三元组:主体 - 关系 - 客体。
## 可用命令
@ -38,7 +24,7 @@ version: 1.0.0
将信息写入记忆图。
**参数:**
- `triplets`: 三元组数组,格式为 `[{subject, relation, object}, ...]`
- `triplets`: 三元组数组 `[{subject, relation, object}, ...]`
- `sessionId`: 会话 ID可选
- `turnId`: 轮次 ID可选
@ -67,30 +53,37 @@ AI 会执行:
**参数:**
- `queryIntent`: 搜索关键词
- `seedEntities`: 种子实体(可选)
- `depth`: 检索深度(默认 2
- `sessionFilter`: 会话过滤(可选)
**示例:**
```
我之前说过我喜欢什么?
```
### 3. purge - 删除记忆
删除记忆图中的一些信息。
**参数:**
- `criteria`: 删除条件 `{subject, target, relation, sessionId}`
- `mode`: 删除模式 `soft`(标记删除)`hard`(彻底删除)
- `mode`: 删除模式 `soft`(标记删除)`hard`(彻底删除)`supersede`(纠错替代)
- `newRelation`: 替代关系supersede 模式)
### 4. introspect - 查看状态
查看当前记忆状态统计。
**返回:**
- entityCount: 实体数量
- relationCount: 关系数量
### 5. archive - 归档旧记忆
### 5. persona_update - 更新人设
将 N 天前的非活跃关系标记为归档。
**参数:**
- `days`: 归档天数(默认 30
### 6. cleanup - 清理无效数据
物理删除已删除超过 90 天的关系和孤立节点。
**参数:**
- `dry_run`: 仅预览不删除(默认 true
### 7. persona_update - 更新人设
更新 AI 的角色/性格特征。
@ -98,19 +91,14 @@ AI 会执行:
- `attributes`: 属性数组 `[{attribute, value}, ...]`
- `mode`: `merge`(合并)或 `replace`(替换)
**示例:**
```
我的角色是猫娘,性格活泼
```
### 6. persona_clear - 清除人设
### 8. persona_clear - 清除人设
清除 AI 的所有角色设定。
**参数:**
- `confirm`: 必须为 `true` 才能执行
### 7. task_create - 创建任务
### 9. task_create - 创建任务
创建长期任务节点。
@ -119,43 +107,32 @@ AI 会执行:
- `description`: 任务描述
- `info_nodes`: 相关信息节点(可选)
### 8. task_set_state - 设置任务状态
### 10. task_set_state - 设置任务状态
更新任务状态。
**参数:**
- `task_id`: 任务 ID
- `state`: 新状态 (`进行中`/`已完成`/`已暂停`/`已取消`)
- `state`: 新状态(进行中/已完成/已暂停/已取消)
### 9. task_delete - 删除任务
### 11. task_delete - 删除任务
删除任务。
**参数:**
- `task_id`: 任务 ID
### 12. task_link_info - 关联信息
将信息节点关联到任务。
**参数:**
- `task_id`: 任务 ID
- `info_node`: 信息节点
## 使用原则
1. **选择性记忆**:只记住重要和持久的信息
2. **结构化**:使用三元组格式存储关系
3. **定期清理**:删除过时或错误的信息
4. **关联思考**:利用关系进行联想记忆
## 关系类型参考
| 关系 | 含义 |
|------|------|
| 喜欢 | 偏好关系 |
| 是 | 类型关系 |
| 正在学习 | 进程关系 |
| 属于 | 归属关系 |
| 包含 | 组成关系 |
| has_description | 描述关系 |
| HAS_STATE | 状态关系 |
## 注意事项
- 每次交互后AI 应该决定是否需要 commit 重要信息
- 使用 recall 来获取相关上下文,而不只是依赖当前对话
- persona 信息应该谨慎修改
- 任务可以跨会话追踪

View File

@ -0,0 +1,44 @@
---
name: graph-memory-persona
description: "管理 AI 人设 - 更新或清除 AI 角色特征。使用 persona_update 更新、persona_clear 清除"
metadata: {"openclaw": {"requires": {"bins": ["node"]}}}
user-invocable: true
---
# GraphMemory Persona 人设管理
管理 AI 的人设/角色特征。
## 操作
### 1. persona_update - 更新人设
更新 AI 的角色特征。
**参数:**
- `attributes`: 属性数组,每个包含 attribute 和 value
- `mode`: 更新模式merge 合并或 replace 替换)
**示例:**
```
action: persona_update
attributes:
- attribute: "角色"
value: "猫娘"
- attribute: "性格"
value: "活泼"
mode: "merge"
```
### 2. persona_clear - 清除人设
清除 AI 的所有角色特征。
**参数:**
- `confirm`: 确认为 true 才能执行清除
**示例:**
```
action: persona_clear
confirm: true
```

View File

@ -0,0 +1,52 @@
---
name: graph-memory-task
description: "管理连续性任务 - 创建、更新、删除任务节点。使用 task_create 创建、task_set_state 更新状态"
metadata: {"openclaw": {"requires": {"bins": ["node"]}}}
user-invocable: true
---
# GraphMemory Task 任务管理
管理长期/连续性任务。
## 操作
### 1. task_create - 创建任务
创建新的任务节点。
**参数:**
- `task_id`: 唯一任务标识
- `description`: 任务描述
- `info_nodes`: 可选的相关信息节点
**示例:**
```
action: task_create
task_id: "Task_学习TypeScript"
description: "学习 TypeScript 并完成项目"
info_nodes: ["TypeScript文档", "教程链接"]
```
### 2. task_set_state - 设置状态
更新任务状态。
**参数:**
- `task_id`: 任务ID
- `state`: 新状态 (进行中/已完成/已暂停/已取消)
### 3. task_delete - 删除任务
删除任务节点。
**参数:**
- `task_id`: 任务ID
### 4. task_link_info - 关联信息
将信息节点关联到任务。
**参数:**
- `task_id`: 任务ID
- `info_node`: 信息节点

View File

@ -1,98 +0,0 @@
---
name: graph_memory_persona
description: 管理 AI 人设 - 更新或清除 AI 角色特征
when_to_use: 当需要设置或修改 AI 的角色性格时
version: 1.0.0
---
# GraphMemory Persona 人设管理
管理 AI 的人设/角色特征。
## 这个技能做什么
这个技能让 AI 能够:
- 设置自己的角色/性格
- 更新人设信息
- 清除人设
## 可用命令
### 1. persona_update - 更新人设
**参数:**
- `attributes`: 属性数组 `[{attribute, value}, ...]`
- `mode`:
- `merge`: 合并到现有属性(默认)
- `replace`: 替换所有现有属性
**示例:**
```
# 方式一:合并更新
记住我的角色是猫娘
```
会执行:
```json
{
"action": "persona_update",
"params": {
"attributes": [
{"attribute": "角色", "value": "猫娘"}
],
"mode": "merge"
}
}
```
```
# 方式二:完全替换
我是一个专业的技术作家
```
会执行:
```json
{
"action": "persona_update",
"params": {
"attributes": [
{"attribute": "职业", "value": "技术作家"}
],
"mode": "replace"
}
}
```
### 2. persona_clear - 清除人设
**参数:**
- `confirm`: 必须为 `true` 才能执行
**示例:**
```
清除我的人设
```
会执行:
```json
{
"action": "persona_clear",
"params": {
"confirm": true
}
}
```
## 使用场景
- 初始化 AI 角色
- 调整 AI 性格
- 清除错误的人设
- 角色切换
- 设置专业领域
## 常见人设属性
| 属性 | 说明 | 示例值 |
|------|------|--------|
| 角色 | AI 的角色 | "猫娘"、"助手"、"专家" |
| 性格 | 性格特征 | "活泼"、"严肃"、"幽默" |
| 职业 | 专业领域 | "技术作家"、"程序员" |
| 语言风格 | 说话方式 | "简洁"、"详细" |

View File

@ -1,134 +0,0 @@
---
name: graph_memory_task
description: 管理连续性任务 - 创建、更新、删除任务节点
when_to_use: 当需要创建或管理长期/跨会话任务时
version: 1.0.0
---
# GraphMemory Task 任务管理
管理长期/连续性任务。
## 这个技能做什么
这个技能让 AI 能够:
- 创建新任务
- 追踪任务进度
- 更新任务状态
- 关联任务相关信息
- 删除任务
## 可用命令
### 1. task_create - 创建任务
**参数:**
- `task_id`: 唯一任务标识
- `description`: 任务描述
- `info_nodes`: 相关信息节点(可选)
**示例:**
```
帮我创建一个任务:学习 TypeScript
```
会执行:
```json
{
"action": "task_create",
"params": {
"task_id": "task_学习TypeScript",
"description": "学习 TypeScript",
"info_nodes": ["TypeScript文档", "教程链接"]
}
}
```
### 2. task_set_state - 设置状态
**参数:**
- `task_id`: 任务 ID
- `state`: 新状态
**可用状态:**
- `进行中`: 任务正在处理
- `已完成`: 任务已完成
- `已暂停`: 任务暂停
- `已取消`: 任务取消
**示例:**
```
TypeScript 学习任务完成了
```
会执行:
```json
{
"action": "task_set_state",
"params": {
"task_id": "task_学习TypeScript",
"state": "已完成"
}
}
```
### 3. task_delete - 删除任务
**参数:**
- `task_id`: 任务 ID
**示例:**
```
删除那个 TypeScript 任务
```
会执行:
```json
{
"action": "task_delete",
"params": {
"task_id": "task_学习TypeScript"
}
}
```
### 4. task_link_info - 关联信息
**参数:**
- `task_id`: 任务 ID
- `info_node`: 信息节点
**示例:**
```
给任务添加一个新资源
```
会执行:
```json
{
"action": "task_link_info",
"params": {
"task_id": "task_学习TypeScript",
"info_node": "新发现的教程"
}
}
```
## 使用场景
- 跨会话追踪任务进度
- 记录任务相关信息
- 管理复杂工作流
- 任务状态持久化
- 长期项目追踪
## 任务状态流转
```
创建 (进行中) → 进行中 → 已完成
↘ 已暂停
↘ 已取消
```
## 最佳实践
1. **具体描述**:任务描述要清晰具体
2. **关联信息**:为任务添加相关资料链接
3. **及时更新**:状态改变时立即更新
4. **清理完成**:已完成的任务及时删除或归档

View File

@ -1,21 +1,8 @@
---
name: graph_memory
description: 图记忆工具 - 让 AI 拥有真正的长期记忆能力
when_to_use: 需要 AI 记住或回忆信息时
context: inline
allowed_tools:
- builtin:graph_memory
arguments:
- name: action
type: string
required: true
enum: [recall, commit, purge, introspect]
description: 记忆操作类型
- name: params
type: object
required: true
description: 操作参数
user_invocable: true
name: graph-memory
description: "图记忆工具 - 检索、写入、删除记忆,管理人设和任务。使用 recall 检索、commit 写入、purge 删除"
metadata: {"openclaw": {"requires": {"bins": ["node"]}}}
user-invocable: true
---
# GraphMemory 图记忆操作
@ -34,14 +21,6 @@ user_invocable: true
- `depth`: 检索深度
- `sessionFilter`: 可选的会话ID过滤
**示例**:
```
action: recall
params:
queryIntent: "用户 喜欢 编程"
seedEntities: ["用户"]
```
### 2. commit - 写入记忆
将信息写入记忆图。
@ -51,19 +30,6 @@ params:
- `sessionId`: 会话ID
- `turnId`: 轮次ID
**示例**:
```
action: commit
params:
triplets:
- subject: "用户"
relation: "喜欢"
object: "Python"
- subject: "用户"
relation: "正在学习"
object: "TypeScript"
```
### 3. purge - 删除记忆
从记忆图中删除信息。
@ -73,26 +39,23 @@ params:
- `mode`: 删除模式 (soft/hard/supersede)
- `newRelation`: 可选的替代关系
**示例**:
```
action: purge
params:
criteria:
subject: "旧信息"
mode: "soft"
```
### 4. introspect - 查看状态
查看当前记忆状态统计。
**参数**: 无
### 5. archive - 归档旧记忆
**示例**:
```
action: introspect
params: {}
```
将 N 天前的非活跃关系标记为归档。
**参数**:
- `days`: 归档天数(默认 30
### 6. cleanup - 清理无效数据
物理删除已删除超过 90 天的关系和孤立节点。
**参数**:
- `dry_run`: 仅预览不删除(默认 true
## 使用原则

View File

@ -0,0 +1,27 @@
---
name: graph-memory-persona
description: "管理 AI 人设 - 更新或清除 AI 角色特征。使用 persona_update 更新、persona_clear 清除"
metadata: {"openclaw": {"requires": {"bins": ["node"]}}}
user-invocable: true
---
# GraphMemory Persona 人设管理
管理 AI 的人设/角色特征。
## 操作
### 1. persona_update - 更新人设
更新 AI 的角色特征。
**参数**:
- `attributes`: 属性数组,每个包含 attribute 和 value
- `mode`: 更新模式merge 合并或 replace 替换)
### 2. persona_clear - 清除人设
清除 AI 的所有角色特征。
**参数**:
- `confirm`: 确认为 true 才能执行清除

View File

@ -0,0 +1,44 @@
---
name: graph-memory-task
description: "管理连续性任务 - 创建、更新、删除任务节点。使用 task_create 创建、task_set_state 更新状态"
metadata: {"openclaw": {"requires": {"bins": ["node"]}}}
user-invocable: true
---
# GraphMemory Task 任务管理
管理长期/连续性任务。
## 操作
### 1. task_create - 创建任务
创建新的任务节点。
**参数**:
- `task_id`: 唯一任务标识
- `description`: 任务描述
- `info_nodes`: 可选的相关信息节点
### 2. task_set_state - 设置状态
更新任务状态。
**参数**:
- `task_id`: 任务ID
- `state`: 新状态 (进行中/已完成/已暂停/已取消)
### 3. task_delete - 删除任务
删除任务节点。
**参数**:
- `task_id`: 任务ID
### 4. task_link_info - 关联信息
将信息节点关联到任务。
**参数**:
- `task_id`: 任务ID
- `info_node`: 信息节点

View File

@ -1,66 +0,0 @@
---
name: graph_memory_persona
description: 管理 AI 人设 - 更新或清除 AI 角色特征
when_to_use: 需要修改 AI 的角色设定或清除人设时
context: inline
allowed_tools:
- builtin:graph_memory
arguments:
- name: action
type: string
required: true
enum: [persona_update, persona_clear]
description: 操作类型
- name: attributes
type: array
description: 属性数组 (用于 update)
- name: mode
type: string
enum: [merge, replace]
default: merge
description: 更新模式
- name: confirm
type: boolean
description: 确认清除 (用于 clear)
user_invocable: true
---
# GraphMemory Persona 人设管理
管理 AI 的人设/角色特征。
## 操作
### 1. persona_update - 更新人设
更新 AI 的角色特征。
**参数**:
- `attributes`: 属性数组,每个包含 attribute 和 value
- `mode`: 更新模式
- `merge`: 合并到现有属性
- `replace`: 替换所有现有属性
**示例**:
```
action: persona_update
attributes:
- attribute: "角色"
value: "猫娘"
- attribute: "性格"
value: "活泼"
mode: "merge"
```
### 2. persona_clear - 清除人设
清除 AI 的所有角色特征。
**参数**:
- `confirm`: 确认为 true 才能执行清除
**示例**:
```
action: persona_clear
confirm: true
```

View File

@ -1,98 +0,0 @@
---
name: graph_memory_task
description: 管理连续性任务 - 创建、更新、删除任务节点
when_to_use: 需要创建或管理长期任务时
context: inline
allowed_tools:
- builtin:graph_memory
arguments:
- name: action
type: string
required: true
enum: [task_create, task_set_state, task_delete, task_link_info]
description: 操作类型
- name: task_id
type: string
required: true
description: 任务ID
- name: description
type: string
description: 任务描述 (用于 create)
- name: state
type: string
enum: [进行中, 已完成, 已暂停, 已取消]
description: 任务状态 (用于 set_state)
- name: info_nodes
type: array
description: 信息节点数组 (用于 create)
- name: info_node
type: string
description: 信息节点 (用于 link_info)
user_invocable: true
---
# GraphMemory Task 任务管理
管理长期/连续性任务。
## 操作
### 1. task_create - 创建任务
创建新的任务节点。
**参数**:
- `task_id`: 唯一任务标识
- `description`: 任务描述
- `info_nodes`: 可选的相关信息节点
**示例**:
```
action: task_create
task_id: "Task_学习TypeScript"
description: "学习 TypeScript 并完成项目"
info_nodes: ["TypeScript文档", "教程链接"]
```
### 2. task_set_state - 设置状态
更新任务状态。
**参数**:
- `task_id`: 任务ID
- `state`: 新状态 (进行中/已完成/已暂停/已取消)
**示例**:
```
action: task_set_state
task_id: "Task_学习TypeScript"
state: "已完成"
```
### 3. task_delete - 删除任务
删除任务节点。
**参数**:
- `task_id`: 任务ID
**示例**:
```
action: task_delete
task_id: "Task_学习TypeScript"
```
### 4. task_link_info - 关联信息
将信息节点关联到任务。
**参数**:
- `task_id`: 任务ID
- `info_node`: 信息节点
**示例**:
```
action: task_link_info
task_id: "Task_学习TypeScript"
info_node: "新教程链接"
```

15
ts/openclaw.plugin.json Normal file
View File

@ -0,0 +1,15 @@
{
"id": "graph-memory",
"name": "Graph Memory",
"description": "让 AI 拥有真正的长期记忆能力 - 基于图数据库的记忆系统",
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {
"dbPath": {
"type": "string",
"description": "SQLite 数据库文件路径,默认 graph_memory.db"
}
}
}
}

431
ts/package-lock.json generated
View File

@ -8,9 +8,12 @@
"name": "trulymem-waterflow",
"version": "1.0.0",
"dependencies": {
"@sinclair/typebox": "^0.34.49",
"better-sqlite3": "^12.9.0",
"yaml": "^2.8.3"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^25.5.2",
"typescript": "^5.0.0",
"vitest": "^2.0.0"
@ -715,6 +718,20 @@
"win32"
]
},
"node_modules/@sinclair/typebox": {
"version": "0.34.49",
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.49.tgz",
"integrity": "sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A=="
},
"node_modules/@types/better-sqlite3": {
"version": "7.6.13",
"resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz",
"integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==",
"dev": true,
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@ -845,6 +862,79 @@
"node": ">=12"
}
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
]
},
"node_modules/better-sqlite3": {
"version": "12.9.0",
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.9.0.tgz",
"integrity": "sha512-wqUv4Gm3toFpHDQmaKD4QhZm3g1DjUBI0yzS4UBl6lElUmXFYdTQmmEDpAFa5o8FiFiymURypEnfVHzILKaxqQ==",
"hasInstallScript": true,
"dependencies": {
"bindings": "^1.5.0",
"prebuild-install": "^7.1.1"
},
"engines": {
"node": "20.x || 22.x || 23.x || 24.x || 25.x"
}
},
"node_modules/bindings": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
"integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
"dependencies": {
"file-uri-to-path": "1.0.0"
}
},
"node_modules/bl": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
"integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
"dependencies": {
"buffer": "^5.5.0",
"inherits": "^2.0.4",
"readable-stream": "^3.4.0"
}
},
"node_modules/buffer": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.1.13"
}
},
"node_modules/cac": {
"version": "6.7.14",
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
@ -879,6 +969,11 @@
"node": ">= 16"
}
},
"node_modules/chownr": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@ -896,6 +991,20 @@
}
}
},
"node_modules/decompress-response": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
"integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
"dependencies": {
"mimic-response": "^3.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/deep-eql": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
@ -905,6 +1014,30 @@
"node": ">=6"
}
},
"node_modules/deep-extend": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
"integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"engines": {
"node": ">=8"
}
},
"node_modules/end-of-stream": {
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
"dependencies": {
"once": "^1.4.0"
}
},
"node_modules/es-module-lexer": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
@ -958,6 +1091,14 @@
"@types/estree": "^1.0.0"
}
},
"node_modules/expand-template": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
"integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
"engines": {
"node": ">=6"
}
},
"node_modules/expect-type": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
@ -967,6 +1108,16 @@
"node": ">=12.0.0"
}
},
"node_modules/file-uri-to-path": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="
},
"node_modules/fs-constants": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@ -981,6 +1132,40 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/github-from-package": {
"version": "0.0.0",
"resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
"integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw=="
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
]
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
},
"node_modules/ini": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="
},
"node_modules/loupe": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
@ -996,6 +1181,30 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/mimic-response": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
"integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/mkdirp-classic": {
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@ -1020,6 +1229,30 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/napi-build-utils": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
"integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA=="
},
"node_modules/node-abi": {
"version": "3.89.0",
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.89.0.tgz",
"integrity": "sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==",
"dependencies": {
"semver": "^7.3.5"
},
"engines": {
"node": ">=10"
}
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"dependencies": {
"wrappy": "1"
}
},
"node_modules/pathe": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz",
@ -1069,6 +1302,68 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/prebuild-install": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
"integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
"deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.",
"dependencies": {
"detect-libc": "^2.0.0",
"expand-template": "^2.0.3",
"github-from-package": "0.0.0",
"minimist": "^1.2.3",
"mkdirp-classic": "^0.5.3",
"napi-build-utils": "^2.0.0",
"node-abi": "^3.3.0",
"pump": "^3.0.0",
"rc": "^1.2.7",
"simple-get": "^4.0.0",
"tar-fs": "^2.0.0",
"tunnel-agent": "^0.6.0"
},
"bin": {
"prebuild-install": "bin.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/pump": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
"integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
"dependencies": {
"end-of-stream": "^1.1.0",
"once": "^1.3.1"
}
},
"node_modules/rc": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
"integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
"dependencies": {
"deep-extend": "^0.6.0",
"ini": "~1.3.0",
"minimist": "^1.2.0",
"strip-json-comments": "~2.0.1"
},
"bin": {
"rc": "cli.js"
}
},
"node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/rollup": {
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz",
@ -1113,12 +1408,85 @@
"fsevents": "~2.3.2"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
]
},
"node_modules/semver": {
"version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/siginfo": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
"dev": true
},
"node_modules/simple-concat": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
"integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
]
},
"node_modules/simple-get": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
"integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"dependencies": {
"decompress-response": "^6.0.0",
"once": "^1.3.1",
"simple-concat": "^1.0.0"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@ -1140,6 +1508,48 @@
"integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
"dev": true
},
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"dependencies": {
"safe-buffer": "~5.2.0"
}
},
"node_modules/strip-json-comments": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
"integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/tar-fs": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
"integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==",
"dependencies": {
"chownr": "^1.1.1",
"mkdirp-classic": "^0.5.2",
"pump": "^3.0.0",
"tar-stream": "^2.1.4"
}
},
"node_modules/tar-stream": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
"integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
"dependencies": {
"bl": "^4.0.3",
"end-of-stream": "^1.4.1",
"fs-constants": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.1.1"
},
"engines": {
"node": ">=6"
}
},
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
@ -1179,6 +1589,17 @@
"node": ">=14.0.0"
}
},
"node_modules/tunnel-agent": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
"integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
"dependencies": {
"safe-buffer": "^5.0.1"
},
"engines": {
"node": "*"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
@ -1198,6 +1619,11 @@
"integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==",
"dev": true
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
},
"node_modules/vite": {
"version": "5.4.21",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
@ -1360,6 +1786,11 @@
"node": ">=8"
}
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="
},
"node_modules/yaml": {
"version": "2.8.3",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",

View File

@ -1,15 +1,29 @@
{
"name": "trulymem-waterflow",
"name": "@trulymem/openclaw-graph-memory",
"version": "1.0.0",
"type": "module",
"openclaw": {
"extensions": ["./dist/plugin-entry.js"],
"compat": {
"pluginApi": ">=2026.3.24-beta.2",
"minGatewayVersion": "2026.3.24-beta.2"
},
"build": {
"openclawVersion": "2026.3.24-beta.2",
"pluginSdkVersion": "2026.3.24-beta.2"
}
},
"scripts": {
"build": "tsc",
"test": "vitest"
},
"dependencies": {
"@sinclair/typebox": "^0.34.49",
"better-sqlite3": "^12.9.0",
"yaml": "^2.8.3"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^25.5.2",
"typescript": "^5.0.0",
"vitest": "^2.0.0"

114
ts/src/plugin-entry.ts Normal file
View File

@ -0,0 +1,114 @@
/**
* OpenClaw Plugin Entry Point
*
* Registers the GraphMemory tool with OpenClaw's plugin system.
* Uses @sinclair/typebox for parameter schema definition.
*/
import { Type } from '@sinclair/typebox';
import { GraphMemoryTool } from './runtime/core/tools/builtin/graph_memory_tool.js';
// OpenClaw Tool Schema using TypeBox
const GraphMemoryToolSchema = Type.Object({
action: Type.String({
description: '记忆操作类型',
enum: [
'recall', 'commit', 'purge', 'introspect',
'persona_update', 'persona_clear',
'task_create', 'task_set_state', 'task_delete', 'task_link_info'
]
}),
params: Type.Object({
queryIntent: Type.Optional(Type.String({ description: '搜索意图' })),
seedEntities: Type.Optional(Type.Array(Type.String({ description: '实体' }), { description: '种子实体' })),
depth: Type.Optional(Type.Number({ description: '检索深度' })),
sessionFilter: Type.Optional(Type.String({ description: '会话ID过滤' })),
triplets: Type.Optional(Type.Array(
Type.Object({
subject: Type.String({ description: '主体' }),
relation: Type.String({ description: '关系' }),
object: Type.String({ description: '客体' }),
confidence: Type.Optional(Type.Number({ description: '置信度' }))
}, { description: '三元组' }),
{ description: '三元组数组' }
)),
sessionId: Type.Optional(Type.String({ description: '会话ID' })),
turnId: Type.Optional(Type.Number({ description: '轮次ID' })),
criteria: Type.Optional(Type.Object({
subject: Type.Optional(Type.String({ description: '主体' })),
target: Type.Optional(Type.String({ description: '客体' })),
relation: Type.Optional(Type.String({ description: '关系' })),
sessionId: Type.Optional(Type.String({ description: '会话ID' }))
}, { description: '删除条件' })),
mode: Type.Optional(Type.String({
enum: ['soft', 'hard', 'supersede'],
description: '删除模式'
})),
newRelation: Type.Optional(Type.Object({
relation: Type.String({ description: '关系' }),
target: Type.String({ description: '客体' })
}, { description: '新关系supersede模式' })),
attributes: Type.Optional(Type.Array(
Type.Object({
attribute: Type.String({ description: '属性名' }),
value: Type.String({ description: '属性值' })
}, { description: '属性' }),
{ description: '属性数组' }
)),
confirm: Type.Optional(Type.Boolean({ description: '确认清除' })),
task_id: Type.Optional(Type.String({ description: '任务ID' })),
description: Type.Optional(Type.String({ description: '任务描述' })),
state: Type.Optional(Type.String({ description: '任务状态' })),
info_nodes: Type.Optional(Type.Array(Type.String({ description: '节点' }), { description: '信息节点' })),
info_node: Type.Optional(Type.String({ description: '信息节点' })),
days: Type.Optional(Type.Number({ description: '归档天数' })),
dry_run: Type.Optional(Type.Boolean({ description: '仅预览不删除' }))
}, { description: '操作参数' })
});
/**
* Plugin entry point for OpenClaw.
*
* When loaded as an OpenClaw plugin, this function registers the GraphMemory tool.
* For standalone usage, import GraphMemoryTool directly.
*/
export default function registerGraphMemoryPlugin(api: {
registerTool: (tool: {
name: string;
description: string;
parameters: typeof GraphMemoryToolSchema;
execute: (id: string, params: Record<string, unknown>) => Promise<{
content: Array<{ type: 'text'; text: string }>;
}>;
}) => void;
}): void {
const tool = new GraphMemoryTool();
api.registerTool({
name: 'graph_memory',
description: '图记忆工具 - 让 AI 拥有真正的长期记忆能力。支持 recall(检索)、commit(写入)、purge(删除)、introspect(状态)、人设管理、任务管理',
parameters: GraphMemoryToolSchema,
async execute(_id: string, params: Record<string, unknown>): Promise<{
content: Array<{ type: 'text'; text: string }>;
}> {
const result = await tool.handler(params, {
toolCallId: _id,
workingDirectory: process.cwd(),
abortController: { signal: new AbortController().signal },
config: {},
logger: {
info: () => {},
warn: () => {},
error: () => {},
debug: () => {}
}
});
return {
content: [{
type: 'text',
text: typeof result === 'string' ? result : JSON.stringify(result)
}]
};
}
});
}

View File

@ -1,61 +1,163 @@
import type { Entity, Relation, RecallParams, CommitParams, PurgeParams, RecallResult, CommitResult, PurgeResult, MemoryStats } from './types';
import Database from 'better-sqlite3';
import type {
Entity, Relation, RecallParams, CommitParams, PurgeParams,
RecallResult, CommitResult, PurgeResult, MemoryStats
} from './types';
export class GraphDatabase {
private entities: Map<string, Entity> = new Map();
private relations: Map<string, Relation> = new Map();
private db: Database.Database;
private sessionId: string;
constructor(sessionId?: string) {
constructor(dbPath?: string, sessionId?: string) {
this.db = new Database(dbPath || 'graph_memory.db');
this.sessionId = sessionId || `session-${Date.now()}`;
this.db.pragma('journal_mode = WAL');
this.initialize();
}
private initialize(): void {
this.db.exec(`
CREATE TABLE IF NOT EXISTS entities (
id TEXT PRIMARY KEY,
name TEXT UNIQUE NOT NULL,
type TEXT DEFAULT 'unknown',
mention_count INTEGER DEFAULT 1,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
)
`);
this.db.exec(`
CREATE TABLE IF NOT EXISTS relations (
id TEXT PRIMARY KEY,
source_id TEXT NOT NULL,
target_id TEXT NOT NULL,
relation_type TEXT NOT NULL,
confidence REAL DEFAULT 1.0,
status TEXT DEFAULT 'active',
session_id TEXT,
turn_id INTEGER DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
date_bucket TEXT,
superseded_by TEXT,
FOREIGN KEY (source_id) REFERENCES entities(id),
FOREIGN KEY (target_id) REFERENCES entities(id)
)
`);
this.db.exec(`
CREATE INDEX IF NOT EXISTS idx_entity_name ON entities(name);
CREATE INDEX IF NOT EXISTS idx_entity_type ON entities(type);
CREATE INDEX IF NOT EXISTS idx_relation_source ON relations(source_id);
CREATE INDEX IF NOT EXISTS idx_relation_target ON relations(target_id);
CREATE INDEX IF NOT EXISTS idx_relation_type ON relations(relation_type);
CREATE INDEX IF NOT EXISTS idx_relation_status ON relations(status);
CREATE INDEX IF NOT EXISTS idx_relation_session ON relations(session_id);
CREATE INDEX IF NOT EXISTS idx_relation_date ON relations(date_bucket);
CREATE INDEX IF NOT EXISTS idx_relation_superseded ON relations(superseded_by);
`);
}
async recall(params: RecallParams): Promise<RecallResult> {
const { queryIntent, seedEntities, sessionFilter } = params;
const { queryIntent, seedEntities, depth = 2, timeRange, sessionFilter } = params;
const keywords = queryIntent.split(/[,\s]+/).filter(k => k.length > 0);
const entityIds = new Set<string>();
const entities: Entity[] = [];
const relations: Relation[] = [];
const entityIds = new Set<string>();
for (const keyword of keywords) {
const lowerKeyword = keyword.toLowerCase();
for (const [_, entity] of this.entities) {
if (entity.name.toLowerCase().includes(lowerKeyword)) {
if (!entityIds.has(entity.id)) {
entityIds.add(entity.id);
entities.push(entity);
}
if (keywords.length === 0 && (!seedEntities || seedEntities.length === 0)) {
return { entities: [], relations: [], message: 'No query keywords' };
}
const timeCondition = timeRange?.days
? `AND r.created_at >= datetime('now', '-${timeRange.days} days')`
: '';
const sessionCondition = sessionFilter ? `AND r.session_id = ?` : '';
if (keywords.length > 0) {
const likeConditions = keywords.map(() => `LOWER(e.name) LIKE ?`).join(' OR ');
const likeParams = keywords.map(k => `%${k.toLowerCase()}%`);
const seedRows = this.db.prepare(
`SELECT DISTINCT e.* FROM entities e WHERE ${likeConditions}`
).all(...likeParams) as Array<Record<string, unknown>>;
for (const row of seedRows) {
const id = row.id as string;
if (!entityIds.has(id)) {
entityIds.add(id);
entities.push(this.rowToEntity(row));
}
}
}
if (seedEntities && seedEntities.length > 0) {
for (const seedName of seedEntities) {
for (const [_, entity] of this.entities) {
if (entity.name.toLowerCase() === seedName.toLowerCase()) {
if (!entityIds.has(entity.id)) {
entityIds.add(entity.id);
entities.push(entity);
}
}
const placeholders = seedEntities.map(() => '?').join(',');
const exactRows = this.db.prepare(
`SELECT * FROM entities WHERE LOWER(name) IN (${placeholders})`
).all(...seedEntities.map(s => s.toLowerCase())) as Array<Record<string, unknown>>;
for (const row of exactRows) {
const id = row.id as string;
if (!entityIds.has(id)) {
entityIds.add(id);
entities.push(this.rowToEntity(row));
}
}
}
for (const [_, relation] of this.relations) {
if (entityIds.has(relation.sourceId) || entityIds.has(relation.targetId)) {
if (relation.status === 'active') {
if (!sessionFilter || relation.sessionId === sessionFilter) {
relations.push(relation);
}
const visited = new Set<string>(entityIds);
let currentLevel = new Set<string>(entityIds);
for (let d = 1; d < depth; d++) {
const nextLevel = new Set<string>();
const idsArray = Array.from(currentLevel);
if (idsArray.length === 0) break;
const placeholders = idsArray.map(() => '?').join(',');
const neighborRows = this.db.prepare(`
SELECT DISTINCT e.* FROM entities e
JOIN relations r ON (r.source_id = e.id OR r.target_id = e.id)
WHERE (r.source_id IN (${placeholders}) OR r.target_id IN (${placeholders}))
AND r.status = 'active'
${timeCondition}
${sessionCondition}
LIMIT 200
`).all(...idsArray, ...idsArray, ...(sessionFilter ? [sessionFilter] : [])) as Array<Record<string, unknown>>;
for (const row of neighborRows) {
const id = row.id as string;
if (!visited.has(id)) {
visited.add(id);
nextLevel.add(id);
entities.push(this.rowToEntity(row));
}
}
currentLevel = nextLevel;
}
if (entityIds.size > 0) {
const allIds = Array.from(entityIds);
const placeholders = allIds.map(() => '?').join(',');
const relationRows = this.db.prepare(`
SELECT r.*, e1.name as source_name, e2.name as target_name
FROM relations r
JOIN entities e1 ON r.source_id = e1.id
JOIN entities e2 ON r.target_id = e2.id
WHERE (r.source_id IN (${placeholders}) OR r.target_id IN (${placeholders}))
AND r.status = 'active'
${timeCondition}
${sessionCondition}
ORDER BY r.created_at DESC
LIMIT 100
`).all(...allIds, ...allIds, ...(sessionFilter ? [sessionFilter] : [])) as Array<Record<string, unknown>>;
for (const row of relationRows) {
relations.push(this.rowToRelation(row));
}
}
return {
entities,
relations,
message: `找到 ${entities.length} 个实体, ${relations.length} 条关系`
};
return { entities, relations, message: `Found ${entities.length} entities, ${relations.length} relations` };
}
async commit(params: CommitParams): Promise<CommitResult> {
@ -63,28 +165,47 @@ export class GraphDatabase {
let createdEntities = 0;
let createdRelations = 0;
const insertRelation = this.db.prepare(`
INSERT INTO relations (id, source_id, target_id, relation_type, confidence, session_id, turn_id, status, date_bucket)
VALUES (?, ?, ?, ?, ?, ?, ?, 'active', ?)
`);
const insertEntity = this.db.prepare(`
INSERT OR IGNORE INTO entities (id, name, type) VALUES (?, ?, 'unknown')
`);
const updateEntity = this.db.prepare(`
UPDATE entities SET mention_count = mention_count + 1, updated_at = datetime('now') WHERE name = ?
`);
const getEntity = this.db.prepare(`SELECT id FROM entities WHERE name = ?`);
for (const triplet of triplets) {
const sourceId = this.upsertEntity(triplet.subject);
const targetId = this.upsertEntity(triplet.object);
const dateBucket = new Date().toISOString().split('T')[0] ?? '';
const relationId = this.generateId();
const now = new Date();
const relation: Relation = {
id: relationId,
sourceId,
targetId,
relationType: triplet.relation,
confidence: triplet.confidence || 1.0,
status: 'active',
sessionId: sessionId || this.sessionId,
turnId: turnId || 0,
createdAt: now,
updatedAt: now,
dateBucket: this.getDateBucket(now)
};
let sourceId = (getEntity.get(triplet.subject) as { id: string } | undefined)?.id;
if (sourceId) {
updateEntity.run(triplet.subject);
} else {
sourceId = this.generateId();
insertEntity.run(sourceId, triplet.subject);
}
createdEntities++;
this.relations.set(relationId, relation);
createdEntities += 2;
let targetId = (getEntity.get(triplet.object) as { id: string } | undefined)?.id;
if (targetId) {
updateEntity.run(triplet.object);
} else {
targetId = this.generateId();
insertEntity.run(targetId, triplet.object);
}
createdEntities++;
insertRelation.run(
this.generateId(), sourceId, targetId, triplet.relation,
triplet.confidence || 1.0, sessionId || this.sessionId,
turnId || 0, dateBucket
);
createdRelations++;
}
@ -92,105 +213,129 @@ export class GraphDatabase {
}
async purge(params: PurgeParams): Promise<PurgeResult> {
const { criteria, mode = 'soft' } = params;
const { criteria, mode = 'soft', newRelation } = params;
let deleted = 0;
if (!criteria) return { deleted: 0, mode };
for (const [id, relation] of this.relations) {
if (relation.status !== 'active') continue;
const conditions: string[] = ["status = 'active'"];
const values: unknown[] = [];
if (!criteria) {
continue;
}
if (criteria.subject) {
conditions.push(`source_id IN (SELECT id FROM entities WHERE LOWER(name) = ?)`);
values.push(criteria.subject.toLowerCase());
}
if (criteria.target) {
conditions.push(`target_id IN (SELECT id FROM entities WHERE LOWER(name) = ?)`);
values.push(criteria.target.toLowerCase());
}
if (criteria.relation) {
conditions.push(`LOWER(relation_type) = ?`);
values.push(criteria.relation.toLowerCase());
}
if (criteria.sessionId) {
conditions.push(`session_id = ?`);
values.push(criteria.sessionId);
}
let matches = true;
if (criteria.subject) {
const sourceEntity = this.entities.get(relation.sourceId);
matches = sourceEntity?.name.toLowerCase() === criteria.subject.toLowerCase();
}
if (matches && criteria.target) {
const targetEntity = this.entities.get(relation.targetId);
matches = targetEntity?.name.toLowerCase() === criteria.target.toLowerCase();
}
if (matches && criteria.relation) {
matches = relation.relationType.toLowerCase() === criteria.relation.toLowerCase();
}
if (matches && criteria.sessionId) {
matches = relation.sessionId === criteria.sessionId;
}
const whereClause = conditions.join(' AND ');
if (matches) {
if (mode === 'hard') {
this.relations.delete(id);
} else {
relation.status = 'deleted';
relation.updatedAt = new Date();
}
if (mode === 'supersede' && newRelation) {
const ids = this.db.prepare(
`SELECT id, source_id, target_id FROM relations WHERE ${whereClause}`
).all(...values) as Array<{ id: string; source_id: string; target_id: string }>;
for (const row of ids) {
const newId = this.generateId();
this.db.prepare(`
INSERT INTO relations (id, source_id, target_id, relation_type, confidence, session_id, turn_id, status, date_bucket, superseded_by)
VALUES (?, ?, ?, ?, 1.0, ?, 0, 'active', ?, ?)
`).run(newId, row.source_id, row.target_id, newRelation.relation, this.sessionId, new Date().toISOString().split('T')[0], row.id);
this.db.prepare(`
UPDATE relations SET status = 'superseded', superseded_by = ?, updated_at = datetime('now') WHERE id = ?
`).run(newId, row.id);
deleted++;
}
} else if (mode === 'hard') {
const result = this.db.prepare(`DELETE FROM relations WHERE ${whereClause}`).run(...values);
deleted = result.changes;
} else {
const result = this.db.prepare(`UPDATE relations SET status = 'deleted', updated_at = datetime('now') WHERE ${whereClause}`).run(...values);
deleted = result.changes;
}
return { deleted, mode };
}
async introspect(): Promise<MemoryStats> {
let entityCount = 0;
for (const [_, entity] of this.entities) {
if (!this.isEntityDeleted(entity.id)) entityCount++;
}
let relationCount = 0;
for (const [_, relation] of this.relations) {
if (relation.status === 'active') relationCount++;
}
const entityCount = (this.db.prepare(`SELECT COUNT(*) as c FROM entities`).get() as { c: number }).c;
const relationCount = (this.db.prepare(`SELECT COUNT(*) as c FROM relations WHERE status = 'active'`).get() as { c: number }).c;
return { entityCount, relationCount, sessionId: this.sessionId };
}
private upsertEntity(name: string): string {
for (const [id, entity] of this.entities) {
if (entity.name === name && !this.isEntityDeleted(id)) {
entity.mentionCount++;
entity.updatedAt = new Date();
return id;
}
}
const id = this.generateId();
const now = new Date();
const entity: Entity = {
id,
name,
type: 'unknown',
mentionCount: 1,
createdAt: now,
updatedAt: now
};
this.entities.set(id, entity);
return id;
async archive(days: number = 30): Promise<{ archived: number }> {
const result = this.db.prepare(`
UPDATE relations SET status = 'archived', updated_at = datetime('now')
WHERE status = 'active' AND created_at < datetime('now', '-' || ? || ' days')
`).run(days);
return { archived: result.changes };
}
private isEntityDeleted(entityId: string): boolean {
for (const [_, relation] of this.relations) {
if ((relation.sourceId === entityId || relation.targetId === entityId) && relation.status === 'deleted') {
return true;
}
async cleanup(dryRun: boolean = true): Promise<{ deleted_relations: number; deleted_entities: number; details?: string[] }> {
const details: string[] = [];
const oldRelations = this.db.prepare(`
SELECT id FROM relations WHERE status = 'deleted' AND updated_at < datetime('now', '-90 days')
`).all() as Array<{ id: string }>;
if (dryRun) {
details.push(`Will delete ${oldRelations.length} old deleted relations`);
const orphaned = this.db.prepare(`
SELECT e.id, e.name FROM entities e
WHERE e.id NOT IN (SELECT DISTINCT source_id FROM relations WHERE status != 'deleted')
AND e.id NOT IN (SELECT DISTINCT target_id FROM relations WHERE status != 'deleted')
`).all() as Array<{ id: string; name: string }>;
details.push(`Will delete ${orphaned.length} orphaned entities`);
return { deleted_relations: oldRelations.length, deleted_entities: orphaned.length, details };
}
return false;
const deleteResult = this.db.prepare(`
DELETE FROM relations WHERE status = 'deleted' AND updated_at < datetime('now', '-90 days')
`).run();
const deleteOrphans = this.db.prepare(`
DELETE FROM entities
WHERE id NOT IN (SELECT DISTINCT source_id FROM relations)
AND id NOT IN (SELECT DISTINCT target_id FROM relations)
`).run();
return { deleted_relations: deleteResult.changes, deleted_entities: deleteOrphans.changes };
}
private rowToEntity(row: Record<string, unknown>): Entity {
return {
id: row.id as string, name: row.name as string,
type: (row.type as string) || 'unknown', mentionCount: (row.mention_count as number) || 1,
createdAt: new Date(row.created_at as string), updatedAt: new Date(row.updated_at as string)
};
}
private rowToRelation(row: Record<string, unknown>): Relation {
return {
id: row.id as string, sourceId: row.source_id as string, targetId: row.target_id as string,
relationType: row.relation_type as string, confidence: (row.confidence as number) || 1.0,
status: (row.status as Relation['status']) || 'active',
sessionId: (row.session_id as string) || this.sessionId,
turnId: (row.turn_id as number) || 0,
createdAt: new Date(row.created_at as string), updatedAt: new Date(row.updated_at as string),
dateBucket: (row.date_bucket as string) || ''
};
}
private generateId(): string {
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
return `${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
}
private getDateBucket(date: Date): string {
return date.toISOString().split('T')[0] ?? '';
}
setSessionId(sessionId: string): void {
this.sessionId = sessionId;
}
getSessionId(): string {
return this.sessionId;
}
setSessionId(sessionId: string): void { this.sessionId = sessionId; }
getSessionId(): string { return this.sessionId; }
close(): void { this.db.close(); }
}

View File

@ -24,6 +24,14 @@ export class MemoryService {
return this.db.introspect();
}
async archive(days: number = 30): Promise<{ archived: number }> {
return this.db.archive(days);
}
async cleanup(dryRun: boolean = true): Promise<{ deleted_relations: number; deleted_entities: number; details?: string[] }> {
return this.db.cleanup(dryRun);
}
async updatePersona(params: { attributes: Array<{ attribute: string; value: string }>; mode?: 'merge' | 'replace' }): Promise<{ status: string; updatedAttributes: number }> {
const { attributes, mode = 'merge' } = params;

View File

@ -1,6 +1,7 @@
import type { Tool, ToolCategory, PermissionLevel, ToolInputSchema, ToolExecutionContext, ToolOutput } from '../tool_interface';
import { GraphDatabase } from '../../graph_memory/graph_database';
import { MemoryService } from '../../graph_memory/memory_service';
import { ToolLimiter } from '../tool_limiter';
const GRAPH_MEMORY_TOOL_ID = 'builtin:graph_memory';
@ -14,6 +15,10 @@ const GRAPH_MEMORY_TOOL_DESCRIPTION = `图记忆工具 - 让 AI 拥有真正的
- persona_update/clear: 人设管理
- task_create/set_state/delete: 任务管理`;
export interface OpenClawToolResult {
content: Array<{ type: 'text'; text: string }>;
}
export class GraphMemoryTool implements Tool {
readonly id = GRAPH_MEMORY_TOOL_ID;
readonly name = 'GraphMemory';
@ -27,7 +32,7 @@ export class GraphMemoryTool implements Tool {
action: {
type: 'string',
enum: [
'recall', 'commit', 'purge', 'introspect',
'recall', 'commit', 'purge', 'introspect', 'archive', 'cleanup',
'persona_update', 'persona_clear',
'task_create', 'task_set_state', 'task_delete', 'task_link_info'
],
@ -68,6 +73,14 @@ export class GraphMemoryTool implements Tool {
description: '删除条件'
},
mode: { type: 'string', enum: ['soft', 'hard', 'supersede'], description: '删除模式' },
newRelation: {
type: 'object',
properties: {
relation: { type: 'string', description: '关系' },
target: { type: 'string', description: '客体' }
},
description: '新关系supersede模式'
},
attributes: {
type: 'array',
items: {
@ -85,7 +98,9 @@ export class GraphMemoryTool implements Tool {
description: { type: 'string', description: '任务描述' },
state: { type: 'string', description: '任务状态' },
info_nodes: { type: 'array', items: { type: 'string', description: '节点' }, description: '信息节点' },
info_node: { type: 'string', description: '信息节点' }
info_node: { type: 'string', description: '信息节点' },
days: { type: 'number', description: '归档天数' },
dry_run: { type: 'boolean', description: '仅预览不删除' }
}
}
},
@ -94,10 +109,12 @@ export class GraphMemoryTool implements Tool {
private db: GraphDatabase;
private service: MemoryService;
private limiter: ToolLimiter;
constructor(sessionId?: string) {
this.db = new GraphDatabase(sessionId);
constructor(dbPath?: string, sessionId?: string) {
this.db = new GraphDatabase(dbPath, sessionId);
this.service = new MemoryService(this.db);
this.limiter = new ToolLimiter();
}
async handler(params: Record<string, unknown>, _context: ToolExecutionContext): Promise<ToolOutput> {
@ -118,7 +135,30 @@ export class GraphMemoryTool implements Tool {
}
}
async execute(_toolCallId: string, params: Record<string, unknown>): Promise<OpenClawToolResult> {
const result = await this.handler(params, {
toolCallId: _toolCallId,
workingDirectory: process.cwd(),
abortController: { signal: new AbortController().signal },
config: {},
logger: { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} }
});
return {
content: [{
type: 'text',
text: typeof result === 'string' ? result : JSON.stringify(result)
}]
};
}
private async executeAction(action: string, params: Record<string, unknown>): Promise<unknown> {
const [allowed, reason] = this.limiter.canCall(action);
if (!allowed) {
throw new Error(reason);
}
this.limiter.recordCall(action);
switch (action) {
case 'recall':
return this.service.recall({
@ -138,7 +178,8 @@ export class GraphMemoryTool implements Tool {
case 'purge':
return this.service.purge({
criteria: params.criteria as { subject?: string | undefined; target?: string | undefined; relation?: string | undefined; sessionId?: string | undefined } | undefined,
mode: params.mode as 'soft' | 'hard' | 'supersede' | undefined
mode: params.mode as 'soft' | 'hard' | 'supersede' | undefined,
newRelation: params.newRelation as { relation: string; target: string } | undefined
});
case 'introspect':
@ -179,12 +220,28 @@ export class GraphMemoryTool implements Tool {
info_node: params.info_node as string
});
case 'archive':
return this.service.archive(params.days as number | undefined);
case 'cleanup':
return this.service.cleanup(params.dry_run as boolean | undefined);
default:
throw new Error(`Unknown action: ${action}`);
}
}
resetLimiter(): void {
this.limiter.reset();
}
getLimiterSummary(): string {
return this.limiter.getSummary();
}
}
export function createGraphMemoryTool(sessionId?: string): GraphMemoryTool {
return new GraphMemoryTool(sessionId);
export function createGraphMemoryTool(dbPath?: string, sessionId?: string): GraphMemoryTool {
return new GraphMemoryTool(dbPath, sessionId);
}
export { ToolLimiter } from '../tool_limiter';

View File

@ -0,0 +1,139 @@
export interface ToolLimits {
persona_query_max: number;
persona_update_max: number;
task_query_max: number;
task_update_max: number;
memory_query_max: number;
memory_update_max: number;
}
export interface ToolCallCount {
persona_query: number;
persona_update: number;
task_query: number;
task_update: number;
memory_query: number;
memory_update: number;
}
const DEFAULT_LIMITS: ToolLimits = {
persona_query_max: 1,
persona_update_max: 1,
task_query_max: 4,
task_update_max: 5,
memory_query_max: 20,
memory_update_max: 10,
};
export class ToolLimiter {
limits: ToolLimits;
counts: ToolCallCount;
constructor(limits?: Partial<ToolLimits>) {
this.limits = { ...DEFAULT_LIMITS, ...limits };
this.counts = {
persona_query: 0,
persona_update: 0,
task_query: 0,
task_update: 0,
memory_query: 0,
memory_update: 0,
};
}
private classifyTool(action: string): [string, string] {
switch (action) {
case 'persona_update':
case 'persona_clear':
return ['persona', 'update'];
case 'task_create':
case 'task_set_state':
case 'task_delete':
case 'task_link_info':
return ['task', 'update'];
case 'recall':
case 'introspect':
return ['memory', 'query'];
case 'commit':
case 'purge':
case 'archive':
case 'cleanup':
return ['memory', 'update'];
default:
return ['memory', 'update'];
}
}
canCall(action: string): [boolean, string] {
const [category, operation] = this.classifyTool(action);
if (category === 'persona') {
if (operation === 'query') {
if (this.counts.persona_query >= this.limits.persona_query_max) {
return [false, `人设图查询次数已达上限(${this.limits.persona_query_max}次)`];
}
} else {
if (this.counts.persona_update >= this.limits.persona_update_max) {
return [false, `人设图修改次数已达上限(${this.limits.persona_update_max}次)`];
}
}
} else if (category === 'task') {
if (operation === 'query') {
if (this.counts.task_query >= this.limits.task_query_max) {
return [false, `工作记忆链查询次数已达上限(${this.limits.task_query_max}次)`];
}
} else {
if (this.counts.task_update >= this.limits.task_update_max) {
return [false, `工作记忆链修改次数已达上限(${this.limits.task_update_max}次)`];
}
}
} else if (category === 'memory') {
if (operation === 'query') {
if (this.counts.memory_query >= this.limits.memory_query_max) {
return [false, `一般记忆查询次数已达上限(${this.limits.memory_query_max}次)`];
}
} else {
if (this.counts.memory_update >= this.limits.memory_update_max) {
return [false, `一般记忆修改次数已达上限(${this.limits.memory_update_max}次)`];
}
}
}
return [true, '允许调用'];
}
recordCall(action: string): void {
const [category, operation] = this.classifyTool(action);
if (category === 'persona') {
if (operation === 'query') this.counts.persona_query++;
else this.counts.persona_update++;
} else if (category === 'task') {
if (operation === 'query') this.counts.task_query++;
else this.counts.task_update++;
} else if (category === 'memory') {
if (operation === 'query') this.counts.memory_query++;
else this.counts.memory_update++;
}
}
getSummary(): string {
const lines = [
`人设图: 查询${this.counts.persona_query}/${this.limits.persona_query_max}次, 修改${this.counts.persona_update}/${this.limits.persona_update_max}`,
`工作记忆链: 查询${this.counts.task_query}/${this.limits.task_query_max}次, 修改${this.counts.task_update}/${this.limits.task_update_max}`,
`一般记忆: 查询${this.counts.memory_query}/${this.limits.memory_query_max}次, 修改${this.counts.memory_update}/${this.limits.memory_update_max}`,
];
return lines.join('\n');
}
reset(): void {
this.counts = {
persona_query: 0,
persona_update: 0,
task_query: 0,
task_update: 0,
memory_query: 0,
memory_update: 0,
};
}
}

View File

@ -0,0 +1,222 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { GraphDatabase } from '/home/program/TrulyMEM-TrueHumanMEM/ts/dist/runtime/core/graph_memory/graph_database.js';
const TEST_DB_PATH = '/tmp/test_graph_memory.db';
describe('GraphDatabase', () => {
let db: GraphDatabase;
beforeEach(async () => {
try {
const fs = await import('fs');
if (fs.existsSync(TEST_DB_PATH)) {
fs.unlinkSync(TEST_DB_PATH);
}
if (fs.existsSync(`${TEST_DB_PATH}-wal`)) {
fs.unlinkSync(`${TEST_DB_PATH}-wal`);
}
if (fs.existsSync(`${TEST_DB_PATH}-shm`)) {
fs.unlinkSync(`${TEST_DB_PATH}-shm`);
}
} catch {}
db = new GraphDatabase(TEST_DB_PATH);
});
afterEach(() => {
if (db && typeof db.close === 'function') {
db.close();
}
});
describe('commit', () => {
it('creates entities and relations', async () => {
const result = await db.commit({
triplets: [
{ subject: 'Alice', relation: 'knows', object: 'Bob' }
]
});
expect(result.createdEntities).toBe(2);
expect(result.createdRelations).toBe(1);
});
it('handles batch multiple triplets', async () => {
const result = await db.commit({
triplets: [
{ subject: '用户', relation: '喜欢', object: '编程' },
{ subject: '用户', relation: '正在学习', object: 'TypeScript' },
{ subject: 'TypeScript', relation: '是', object: '语言' }
]
});
expect(result.createdEntities).toBe(6);
expect(result.createdRelations).toBe(3);
});
});
describe('recall', () => {
beforeEach(async () => {
await db.commit({
triplets: [
{ subject: '用户', relation: '喜欢', object: '编程' },
{ subject: '用户', relation: '正在学习', object: 'TypeScript' },
{ subject: 'TypeScript', relation: '是', object: '语言' },
{ subject: 'Alice', relation: 'knows', object: 'Bob' }
]
});
});
it('keyword search finds matching entities', async () => {
const result = await db.recall({ queryIntent: '用户' });
expect(result.entities.length).toBeGreaterThan(0);
expect(result.entities.some(e => e.name === '用户')).toBe(true);
});
it('seedEntities finds exact matches', async () => {
const result = await db.recall({
queryIntent: 'test',
seedEntities: ['用户']
});
expect(result.entities.length).toBeGreaterThan(0);
expect(result.entities.some(e => e.name === '用户')).toBe(true);
});
it('sessionFilter filters by session', async () => {
await db.commit({
triplets: [
{ subject: 'Test', relation: 'is', object: 'Temp' }
],
sessionId: 'test-session'
});
const result = await db.recall({
queryIntent: 'Test',
sessionFilter: 'test-session'
});
expect(result.entities.some(e => e.name === 'Test')).toBe(true);
});
it('finds related entities through relations', async () => {
const result = await db.recall({
queryIntent: '编程'
});
expect(result.relations.length).toBeGreaterThan(0);
});
});
describe('purge', () => {
beforeEach(async () => {
await db.commit({
triplets: [
{ subject: 'ToDelete', relation: 'is', object: 'Test' }
]
});
});
it('soft delete marks relations as deleted', async () => {
const result = await db.purge({
criteria: { subject: 'ToDelete' },
mode: 'soft'
});
expect(result.deleted).toBe(1);
expect(result.mode).toBe('soft');
});
it('hard delete removes relations permanently', async () => {
const result = await db.purge({
criteria: { subject: 'ToDelete' },
mode: 'hard'
});
expect(result.deleted).toBe(1);
expect(result.mode).toBe('hard');
});
it('purge filters by target criteria', async () => {
const result = await db.purge({
criteria: { target: 'Test' },
mode: 'soft'
});
expect(result.deleted).toBe(1);
});
it('purge filters by relation criteria', async () => {
const result = await db.purge({
criteria: { relation: 'is' },
mode: 'soft'
});
expect(result.deleted).toBe(1);
});
});
describe('introspect', () => {
it('returns entity and relation counts', async () => {
await db.commit({
triplets: [
{ subject: 'Entity1', relation: 'relates', object: 'Entity2' }
]
});
const stats = await db.introspect();
expect(stats.entityCount).toBe(2);
expect(stats.relationCount).toBe(1);
expect(stats.sessionId).toBeDefined();
});
});
describe('empty query handling', () => {
it('returns empty result for query with no matches', async () => {
const result = await db.recall({ queryIntent: 'xyznonexistent123' });
expect(result.entities).toEqual([]);
expect(result.relations).toEqual([]);
});
});
describe('duplicate entity handling', () => {
it('upserts existing entities (increments mention count)', async () => {
await db.commit({
triplets: [
{ subject: 'Duplicate', relation: 'is', object: 'First' }
]
});
const stats1 = await db.introspect();
const entityBefore = stats1.entityCount;
await db.commit({
triplets: [
{ subject: 'Duplicate', relation: 'is', object: 'Second' }
]
});
const stats2 = await db.introspect();
const entityAfter = stats2.entityCount;
expect(entityAfter).toBeLessThan(entityBefore + 2);
});
});
describe('setSessionId and getSessionId', () => {
it('setSessionId updates session id', async () => {
db.setSessionId('new-session-id');
expect(db.getSessionId()).toBe('new-session-id');
});
it('getSessionId returns current session id', async () => {
const sessionId = db.getSessionId();
expect(sessionId).toBeDefined();
expect(typeof sessionId).toBe('string');
});
});
});

View File

@ -0,0 +1,242 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { MemoryService } from '../../../../dist/runtime/core/graph_memory/memory_service.js';
import { GraphDatabase } from '../../../../dist/runtime/core/graph_memory/graph_database.js';
import * as fs from 'fs';
const TEST_DB_PATH = '/tmp/test_memory_service.db';
describe('MemoryService', () => {
let memoryService: MemoryService;
let db: GraphDatabase;
beforeEach(async () => {
if (fs.existsSync(TEST_DB_PATH)) {
fs.unlinkSync(TEST_DB_PATH);
}
const walPath = TEST_DB_PATH + '-wal';
const shmPath = TEST_DB_PATH + '-shm';
if (fs.existsSync(walPath)) fs.unlinkSync(walPath);
if (fs.existsSync(shmPath)) fs.unlinkSync(shmPath);
db = new GraphDatabase(TEST_DB_PATH, 'test-session');
memoryService = new MemoryService(db);
});
afterEach(async () => {
if (typeof db.close === 'function') db.close();
if (fs.existsSync(TEST_DB_PATH)) {
fs.unlinkSync(TEST_DB_PATH);
}
const walPath = TEST_DB_PATH + '-wal';
const shmPath = TEST_DB_PATH + '-shm';
if (fs.existsSync(walPath)) fs.unlinkSync(walPath);
if (fs.existsSync(shmPath)) fs.unlinkSync(shmPath);
});
describe('updatePersona', () => {
it('should merge attributes in merge mode', async () => {
const result = await memoryService.updatePersona({
attributes: [
{ attribute: 'name', value: 'AI Assistant' },
{ attribute: 'personality', value: 'helpful' }
],
mode: 'merge'
});
expect(result.status).toBe('success');
expect(result.updatedAttributes).toBe(2);
});
it('should replace attributes in replace mode', async () => {
await memoryService.updatePersona({
attributes: [
{ attribute: 'name', value: 'Old Name' }
],
mode: 'merge'
});
const result = await memoryService.updatePersona({
attributes: [
{ attribute: 'name', value: 'New Name' }
],
mode: 'replace'
});
expect(result.status).toBe('success');
expect(result.updatedAttributes).toBe(1);
});
});
describe('clearPersona', () => {
it('should delete persona when confirm is true', async () => {
await memoryService.updatePersona({
attributes: [
{ attribute: 'name', value: 'Test AI' }
],
mode: 'merge'
});
const result = await memoryService.clearPersona({ confirm: true });
expect(result.status).toBe('success');
expect(result.deletedCount).toBeGreaterThan(0);
});
it('should cancel when confirm is false', async () => {
const result = await memoryService.clearPersona({ confirm: false });
expect(result.status).toBe('cancelled');
expect(result.deletedCount).toBe(0);
});
});
describe('createTask', () => {
it('should create task with info_nodes', async () => {
const result = await memoryService.createTask({
task_id: 'Task_001',
description: 'Test task with info',
info_nodes: ['Node_A', 'Node_B']
});
expect(result.status).toBe('success');
expect(result.taskId).toBe('Task_001');
});
it('should create task without info_nodes', async () => {
const result = await memoryService.createTask({
task_id: 'Task_002',
description: 'Test task without info'
});
expect(result.status).toBe('success');
expect(result.taskId).toBe('Task_002');
});
});
describe('setTaskState', () => {
it('should set task state', async () => {
await memoryService.createTask({
task_id: 'Task_StateTest',
description: 'Task for state test'
});
const result = await memoryService.setTaskState({
task_id: 'Task_StateTest',
state: '已暂停'
});
expect(result.status).toBe('success');
expect(result.newState).toBe('已暂停');
});
});
describe('deleteTask', () => {
it('should delete task', async () => {
await memoryService.createTask({
task_id: 'Task_Delete',
description: 'Task to delete'
});
const result = await memoryService.deleteTask({ task_id: 'Task_Delete' });
expect(result.status).toBe('success');
expect(result.taskId).toBe('Task_Delete');
});
});
describe('linkInfoToTask', () => {
it('should link info node to task', async () => {
await memoryService.createTask({
task_id: 'Task_Link',
description: 'Task for linking'
});
const result = await memoryService.linkInfoToTask({
task_id: 'Task_Link',
info_node: 'Info_Node_X'
});
expect(result.status).toBe('success');
});
});
describe('sessionId', () => {
it('should set and get sessionId', () => {
memoryService.setSessionId('custom-session-123');
const sessionId = memoryService.getSessionId();
expect(sessionId).toBe('custom-session-123');
});
});
describe('task state transitions', () => {
it('should transition: 进行中 → 已暂停 → 进行中 → 已完成', async () => {
await memoryService.createTask({
task_id: 'Task_Transition',
description: 'State transition task'
});
const state1 = await memoryService.setTaskState({
task_id: 'Task_Transition',
state: '进行中'
});
expect(state1.newState).toBe('进行中');
const state2 = await memoryService.setTaskState({
task_id: 'Task_Transition',
state: '已暂停'
});
expect(state2.newState).toBe('已暂停');
const state3 = await memoryService.setTaskState({
task_id: 'Task_Transition',
state: '进行中'
});
expect(state3.newState).toBe('进行中');
const state4 = await memoryService.setTaskState({
task_id: 'Task_Transition',
state: '已完成'
});
expect(state4.newState).toBe('已完成');
});
});
describe('delegate methods', () => {
it('recall should delegate to db', async () => {
await memoryService.commit({
triplets: [
{ subject: '测试', relation: '是', object: '示例' }
]
});
const result = await memoryService.recall({
queryIntent: '测试'
});
expect(result.entities).toBeDefined();
expect(result.relations).toBeDefined();
});
it('commit should delegate to db', async () => {
const result = await memoryService.commit({
triplets: [
{ subject: '用户', relation: '喜欢', object: '编程' }
]
});
expect(result.createdEntities).toBeGreaterThan(0);
expect(result.createdRelations).toBeGreaterThan(0);
});
it('purge should delegate to db', async () => {
await memoryService.commit({
triplets: [
{ subject: '待删除', relation: '是', object: '测试' }
]
});
const result = await memoryService.purge({
criteria: { subject: '待删除' }
});
expect(result.deleted).toBeGreaterThanOrEqual(0);
});
});
});

View File

@ -0,0 +1,511 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { GraphMemoryTool } from '../../../../../dist/runtime/core/tools/builtin/graph_memory_tool.js';
import { ToolLimiter } from '../../../../../dist/runtime/core/tools/tool_limiter.js';
import * as fs from 'fs';
const TEST_DB_PATH = '/tmp/test_graph_memory_tool.db';
describe('GraphMemoryTool', () => {
let tool: GraphMemoryTool;
beforeEach(() => {
if (fs.existsSync(TEST_DB_PATH)) {
fs.unlinkSync(TEST_DB_PATH);
}
tool = new GraphMemoryTool(TEST_DB_PATH, 'test-session');
});
afterEach(() => {
if (fs.existsSync(TEST_DB_PATH)) {
fs.unlinkSync(TEST_DB_PATH);
}
});
describe('Tool metadata', () => {
it('should have correct id', () => {
expect(tool.id).toBe('builtin:graph_memory');
});
it('should have correct name', () => {
expect(tool.name).toBe('GraphMemory');
});
it('should have analysis category', () => {
expect(tool.category).toBe('analysis');
});
it('should have safe permission level', () => {
expect(tool.permissionLevel).toBe('safe');
});
it('should have input schema with all actions', () => {
const actions = tool.inputSchema.properties?.action?.enum as string[];
expect(actions).toContain('recall');
expect(actions).toContain('commit');
expect(actions).toContain('purge');
expect(actions).toContain('introspect');
expect(actions).toContain('persona_update');
expect(actions).toContain('persona_clear');
expect(actions).toContain('task_create');
expect(actions).toContain('task_set_state');
expect(actions).toContain('task_delete');
expect(actions).toContain('task_link_info');
expect(actions).toContain('archive');
expect(actions).toContain('cleanup');
});
});
describe('recall action', () => {
it('should recall memories by query intent', async () => {
await tool.handler({
action: 'commit',
params: {
triplets: [
{ subject: '用户', relation: '喜欢', object: '编程' }
]
}
}, {} as any);
const result = await tool.handler({
action: 'recall',
params: {
queryIntent: '用户 编程'
}
}, {} as any);
const parsed = JSON.parse(result);
expect(parsed.success).toBe(true);
expect(parsed.data.entities).toBeDefined();
expect(parsed.data.relations).toBeDefined();
});
it('should return empty results for non-matching query', async () => {
const result = await tool.handler({
action: 'recall',
params: {
queryIntent: '不存在的关键词xyz123'
}
}, {} as any);
const parsed = JSON.parse(result);
expect(parsed.success).toBe(true);
expect(parsed.data.entities).toEqual([]);
expect(parsed.data.relations).toEqual([]);
});
});
describe('commit action', () => {
it('should commit triplets successfully', async () => {
const result = await tool.handler({
action: 'commit',
params: {
triplets: [
{ subject: '测试', relation: '是', object: '示例' }
]
}
}, {} as any);
const parsed = JSON.parse(result);
expect(parsed.success).toBe(true);
expect(parsed.data.createdEntities).toBeGreaterThan(0);
expect(parsed.data.createdRelations).toBeGreaterThan(0);
});
it('should handle multiple triplets', async () => {
const result = await tool.handler({
action: 'commit',
params: {
triplets: [
{ subject: '实体A', relation: '关联', object: '实体B' },
{ subject: '实体B', relation: '关联', object: '实体C' },
{ subject: '实体C', relation: '关联', object: '实体A' }
]
}
}, {} as any);
const parsed = JSON.parse(result);
expect(parsed.success).toBe(true);
expect(parsed.data.createdEntities).toBe(6);
expect(parsed.data.createdRelations).toBe(3);
});
});
describe('purge action', () => {
it('should purge relations by criteria', async () => {
await tool.handler({
action: 'commit',
params: {
triplets: [
{ subject: '待删除', relation: '是', object: '测试' }
]
}
}, {} as any);
const result = await tool.handler({
action: 'purge',
params: {
criteria: { subject: '待删除' },
mode: 'soft'
}
}, {} as any);
const parsed = JSON.parse(result);
expect(parsed.success).toBe(true);
expect(parsed.data.deleted).toBeGreaterThanOrEqual(1);
expect(parsed.data.mode).toBe('soft');
});
it('should return 0 when no criteria provided', async () => {
const result = await tool.handler({
action: 'purge',
params: {}
}, {} as any);
const parsed = JSON.parse(result);
expect(parsed.success).toBe(true);
expect(parsed.data.deleted).toBe(0);
});
});
describe('introspect action', () => {
it('should return memory statistics', async () => {
await tool.handler({
action: 'commit',
params: {
triplets: [
{ subject: '实体1', relation: '关系', object: '实体2' }
]
}
}, {} as any);
const result = await tool.handler({
action: 'introspect',
params: {}
}, {} as any);
const parsed = JSON.parse(result);
expect(parsed.success).toBe(true);
expect(parsed.data.entityCount).toBeGreaterThan(0);
expect(parsed.data.relationCount).toBeGreaterThan(0);
expect(parsed.data.sessionId).toBeDefined();
});
});
describe('persona_update action', () => {
it('should update persona attributes', async () => {
const result = await tool.handler({
action: 'persona_update',
params: {
attributes: [
{ attribute: 'name', value: 'AI助手' },
{ attribute: '性格', value: '友善' }
],
mode: 'merge'
}
}, {} as any);
const parsed = JSON.parse(result);
expect(parsed.success).toBe(true);
expect(parsed.data.status).toBe('success');
expect(parsed.data.updatedAttributes).toBe(2);
});
it('should replace persona in replace mode', async () => {
tool.resetLimiter();
const result = await tool.handler({
action: 'persona_update',
params: {
attributes: [{ attribute: 'name', value: 'TestAI' }],
mode: 'merge'
}
}, {} as any);
const parsed = JSON.parse(result);
expect(parsed.success).toBe(true);
expect(parsed.data.updatedAttributes).toBe(1);
expect(parsed.data.status).toBe('success');
});
});
describe('persona_clear action', () => {
it('should clear persona when confirm is true', async () => {
const result = await tool.handler({
action: 'persona_clear',
params: {
confirm: true
}
}, {} as any);
const parsed = JSON.parse(result);
expect(parsed.success).toBe(true);
expect(parsed.data.status).toBe('success');
expect(parsed.data.deletedCount).toBeGreaterThanOrEqual(0);
});
it('should cancel when confirm is false', async () => {
const result = await tool.handler({
action: 'persona_clear',
params: {
confirm: false
}
}, {} as any);
const parsed = JSON.parse(result);
expect(parsed.success).toBe(true);
expect(parsed.data.status).toBe('cancelled');
expect(parsed.data.deletedCount).toBe(0);
});
});
describe('task_create action', () => {
it('should create task successfully', async () => {
const result = await tool.handler({
action: 'task_create',
params: {
task_id: 'Task_Test123',
description: '测试任务描述'
}
}, {} as any);
const parsed = JSON.parse(result);
expect(parsed.success).toBe(true);
expect(parsed.data.status).toBe('success');
expect(parsed.data.taskId).toBe('Task_Test123');
});
it('should create task with info_nodes', async () => {
const result = await tool.handler({
action: 'task_create',
params: {
task_id: 'Task_WithInfo',
description: '带信息的任务',
info_nodes: ['信息节点1', '信息节点2']
}
}, {} as any);
const parsed = JSON.parse(result);
expect(parsed.success).toBe(true);
expect(parsed.data.taskId).toBe('Task_WithInfo');
});
});
describe('task_set_state action', () => {
it('should set task state', async () => {
await tool.handler({
action: 'task_create',
params: {
task_id: 'Task_StateTest',
description: '状态测试任务'
}
}, {} as any);
const result = await tool.handler({
action: 'task_set_state',
params: {
task_id: 'Task_StateTest',
state: '已完成'
}
}, {} as any);
const parsed = JSON.parse(result);
expect(parsed.success).toBe(true);
expect(parsed.data.status).toBe('success');
expect(parsed.data.newState).toBe('已完成');
});
});
describe('task_delete action', () => {
it('should delete task', async () => {
await tool.handler({
action: 'task_create',
params: {
task_id: 'Task_DeleteMe',
description: '待删除任务'
}
}, {} as any);
const result = await tool.handler({
action: 'task_delete',
params: {
task_id: 'Task_DeleteMe'
}
}, {} as any);
const parsed = JSON.parse(result);
expect(parsed.success).toBe(true);
expect(parsed.data.status).toBe('success');
expect(parsed.data.taskId).toBe('Task_DeleteMe');
});
});
describe('task_link_info action', () => {
it('should link info node to task', async () => {
await tool.handler({
action: 'task_create',
params: {
task_id: 'Task_Link',
description: '链接测试任务'
}
}, {} as any);
const result = await tool.handler({
action: 'task_link_info',
params: {
task_id: 'Task_Link',
info_node: '新的信息节点'
}
}, {} as any);
const parsed = JSON.parse(result);
expect(parsed.success).toBe(true);
expect(parsed.data.status).toBe('success');
});
});
describe('archive action', () => {
it('should archive old relations', async () => {
const result = await tool.handler({
action: 'archive',
params: {
days: 30
}
}, {} as any);
const parsed = JSON.parse(result);
expect(parsed.success).toBe(true);
});
});
describe('cleanup action', () => {
it('should cleanup in dry_run mode', async () => {
const result = await tool.handler({
action: 'cleanup',
params: {
dry_run: true
}
}, {} as any);
const parsed = JSON.parse(result);
expect(parsed.success).toBe(true);
});
it('should cleanup without dry_run', async () => {
const result = await tool.handler({
action: 'cleanup',
params: {}
}, {} as any);
const parsed = JSON.parse(result);
expect(parsed.success).toBe(true);
});
});
describe('Unknown action error handling', () => {
it('should return error for unknown action', async () => {
const result = await tool.handler({
action: 'unknown_action',
params: {}
}, {} as any);
const parsed = JSON.parse(result);
expect(parsed.success).toBe(false);
expect(parsed.error).toBeDefined();
expect(parsed.error.type).toBe('execution_error');
expect(parsed.error.message).toContain('Unknown action');
});
it('should handle empty action gracefully', async () => {
const result = await tool.handler({
action: '',
params: {}
}, {} as any);
const parsed = JSON.parse(result);
expect(parsed.success).toBe(false);
});
});
describe('OpenClaw execute method', () => {
it('should return correct format with content array', async () => {
const result = await tool.execute('call-123', {
action: 'introspect',
params: {}
});
expect(result).toHaveProperty('content');
expect(Array.isArray(result.content)).toBe(true);
expect(result.content[0]).toHaveProperty('type', 'text');
expect(result.content[0]).toHaveProperty('text');
expect(typeof result.content[0].text).toBe('string');
});
it('should parse JSON in text content', async () => {
const result = await tool.execute('call-456', {
action: 'commit',
params: {
triplets: [
{ subject: '测试', relation: '是', object: '执行' }
]
}
});
const parsed = JSON.parse(result.content[0].text);
expect(parsed.success).toBe(true);
});
it('should handle errors in execute format', async () => {
const result = await tool.execute('call-789', {
action: 'invalid_action',
params: {}
});
const parsed = JSON.parse(result.content[0].text);
expect(parsed.success).toBe(false);
});
});
describe('ToolLimiter integration', () => {
it('should block calls when rate limit exceeded', async () => {
tool.resetLimiter();
const limiter = (tool as any).limiter;
for (let i = 0; i < 20; i++) {
limiter.recordCall('recall');
}
const result = await tool.handler({
action: 'recall',
params: { queryIntent: '测试' }
}, {} as any);
const parsed = JSON.parse(result);
expect(parsed.success).toBe(false);
expect(parsed.error.message).toContain('已达上限');
});
it('should get limiter summary', () => {
tool.resetLimiter();
const summary = tool.getLimiterSummary();
expect(summary).toContain('人设图');
expect(summary).toContain('工作记忆链');
expect(summary).toContain('一般记忆');
});
it('should reset limiter and allow calls again', async () => {
const limiter = (tool as any).limiter;
for (let i = 0; i < 20; i++) {
limiter.recordCall('recall');
}
tool.resetLimiter();
const result = await tool.handler({
action: 'recall',
params: { queryIntent: '测试' }
}, {} as any);
const parsed = JSON.parse(result);
expect(parsed.success).toBe(true);
});
});
});

View File

@ -0,0 +1,433 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { ToolLimiter } from '../../../../dist/runtime/core/tools/tool_limiter.js';
describe('ToolLimiter', () => {
describe('Default limits', () => {
it('should have correct default limits', () => {
const limiter = new ToolLimiter();
expect(limiter.limits.persona_query_max).toBe(1);
expect(limiter.limits.persona_update_max).toBe(1);
expect(limiter.limits.task_query_max).toBe(4);
expect(limiter.limits.task_update_max).toBe(5);
expect(limiter.limits.memory_query_max).toBe(20);
expect(limiter.limits.memory_update_max).toBe(10);
});
it('should initialize counts to zero', () => {
const limiter = new ToolLimiter();
expect(limiter.counts.persona_query).toBe(0);
expect(limiter.counts.persona_update).toBe(0);
expect(limiter.counts.task_query).toBe(0);
expect(limiter.counts.task_update).toBe(0);
expect(limiter.counts.memory_query).toBe(0);
expect(limiter.counts.memory_update).toBe(0);
});
});
describe('canCall allows within limits', () => {
let limiter: ToolLimiter;
beforeEach(() => {
limiter = new ToolLimiter();
});
it('should allow recall (memory query) initially', () => {
const [allowed, reason] = limiter.canCall('recall');
expect(allowed).toBe(true);
expect(reason).toBe('允许调用');
});
it('should allow introspect (memory query) initially', () => {
const [allowed, reason] = limiter.canCall('introspect');
expect(allowed).toBe(true);
expect(reason).toBe('允许调用');
});
it('should allow commit (memory update) initially', () => {
const [allowed, reason] = limiter.canCall('commit');
expect(allowed).toBe(true);
expect(reason).toBe('允许调用');
});
it('should allow task operations initially', () => {
expect(limiter.canCall('task_create')[0]).toBe(true);
expect(limiter.canCall('task_set_state')[0]).toBe(true);
expect(limiter.canCall('task_delete')[0]).toBe(true);
expect(limiter.canCall('task_link_info')[0]).toBe(true);
});
it('should allow persona operations initially', () => {
expect(limiter.canCall('persona_update')[0]).toBe(true);
expect(limiter.canCall('persona_clear')[0]).toBe(true);
});
it('should allow purge initially', () => {
const [allowed] = limiter.canCall('purge');
expect(allowed).toBe(true);
});
it('should allow archive initially', () => {
const [allowed] = limiter.canCall('archive');
expect(allowed).toBe(true);
});
it('should allow cleanup initially', () => {
const [allowed] = limiter.canCall('cleanup');
expect(allowed).toBe(true);
});
});
describe('canCall blocks when exceeded', () => {
let limiter: ToolLimiter;
beforeEach(() => {
limiter = new ToolLimiter();
});
it('should block recall after 20 calls', () => {
for (let i = 0; i < 20; i++) {
limiter.recordCall('recall');
}
const [allowed, reason] = limiter.canCall('recall');
expect(allowed).toBe(false);
expect(reason).toContain('一般记忆查询次数已达上限');
expect(reason).toContain('20');
});
it('should block commit after 10 calls', () => {
for (let i = 0; i < 10; i++) {
limiter.recordCall('commit');
}
const [allowed, reason] = limiter.canCall('commit');
expect(allowed).toBe(false);
expect(reason).toContain('一般记忆修改次数已达上限');
});
it('should block purge after 10 calls', () => {
for (let i = 0; i < 10; i++) {
limiter.recordCall('purge');
}
const [allowed] = limiter.canCall('purge');
expect(allowed).toBe(false);
});
it('should block task operations after 5 updates', () => {
for (let i = 0; i < 5; i++) {
limiter.recordCall('task_create');
}
const [allowed] = limiter.canCall('task_create');
expect(allowed).toBe(false);
});
it('should block task_set_state after 5 updates', () => {
for (let i = 0; i < 5; i++) {
limiter.recordCall('task_set_state');
}
const [allowed] = limiter.canCall('task_set_state');
expect(allowed).toBe(false);
});
it('should block persona_update after 1 call', () => {
limiter.recordCall('persona_update');
const [allowed] = limiter.canCall('persona_update');
expect(allowed).toBe(false);
});
it('should block persona_clear after 1 call', () => {
limiter.recordCall('persona_clear');
const [allowed] = limiter.canCall('persona_clear');
expect(allowed).toBe(false);
});
it('should block unknown actions as memory update', () => {
for (let i = 0; i < 10; i++) {
limiter.recordCall('unknown_action');
}
const [allowed] = limiter.canCall('unknown_action');
expect(allowed).toBe(false);
});
});
describe('recordCall increments counters', () => {
let limiter: ToolLimiter;
beforeEach(() => {
limiter = new ToolLimiter();
});
it('should increment memory_query for recall', () => {
limiter.recordCall('recall');
expect(limiter.counts.memory_query).toBe(1);
limiter.recordCall('recall');
expect(limiter.counts.memory_query).toBe(2);
});
it('should increment memory_query for introspect', () => {
limiter.recordCall('introspect');
expect(limiter.counts.memory_query).toBe(1);
});
it('should increment memory_update for commit', () => {
limiter.recordCall('commit');
expect(limiter.counts.memory_update).toBe(1);
});
it('should increment memory_update for purge', () => {
limiter.recordCall('purge');
expect(limiter.counts.memory_update).toBe(1);
});
it('should increment memory_update for archive', () => {
limiter.recordCall('archive');
expect(limiter.counts.memory_update).toBe(1);
});
it('should increment memory_update for cleanup', () => {
limiter.recordCall('cleanup');
expect(limiter.counts.memory_update).toBe(1);
});
it('should increment task_update for task operations', () => {
limiter.recordCall('task_create');
limiter.recordCall('task_set_state');
limiter.recordCall('task_delete');
limiter.recordCall('task_link_info');
expect(limiter.counts.task_update).toBe(4);
});
it('should increment persona_update for persona operations', () => {
limiter.recordCall('persona_update');
limiter.recordCall('persona_clear');
expect(limiter.counts.persona_update).toBe(2);
});
it('should handle unknown actions as memory update', () => {
limiter.recordCall('some_unknown_action');
expect(limiter.counts.memory_update).toBe(1);
});
});
describe('reset clears counters', () => {
it('should reset all counts to zero', () => {
const limiter = new ToolLimiter();
limiter.recordCall('recall');
limiter.recordCall('recall');
limiter.recordCall('commit');
limiter.recordCall('task_create');
limiter.recordCall('persona_update');
limiter.reset();
expect(limiter.counts.persona_query).toBe(0);
expect(limiter.counts.persona_update).toBe(0);
expect(limiter.counts.task_query).toBe(0);
expect(limiter.counts.task_update).toBe(0);
expect(limiter.counts.memory_query).toBe(0);
expect(limiter.counts.memory_update).toBe(0);
});
it('should allow calls after reset', () => {
const limiter = new ToolLimiter();
for (let i = 0; i < 20; i++) {
limiter.recordCall('recall');
}
limiter.reset();
const [allowed] = limiter.canCall('recall');
expect(allowed).toBe(true);
});
});
describe('getSummary returns formatted string', () => {
it('should return summary with correct format', () => {
const limiter = new ToolLimiter();
const summary = limiter.getSummary();
expect(summary).toContain('人设图');
expect(summary).toContain('工作记忆链');
expect(summary).toContain('一般记忆');
expect(summary).toContain('查询');
expect(summary).toContain('修改');
expect(summary).toContain('/');
});
it('should show updated counts in summary', () => {
const limiter = new ToolLimiter();
limiter.recordCall('recall');
limiter.recordCall('recall');
limiter.recordCall('commit');
const summary = limiter.getSummary();
expect(summary).toContain('2/20');
expect(summary).toContain('1/10');
});
});
describe('classifyTool for all action types', () => {
let limiter: ToolLimiter;
beforeEach(() => {
limiter = new ToolLimiter();
});
it('should classify recall as memory query', () => {
for (let i = 0; i < 20; i++) limiter.recordCall('recall');
const [, reason] = limiter.canCall('recall');
expect(reason).toContain('一般记忆查询');
});
it('should classify introspect as memory query', () => {
for (let i = 0; i < 20; i++) limiter.recordCall('introspect');
const [, reason] = limiter.canCall('introspect');
expect(reason).toContain('一般记忆查询');
});
it('should classify commit as memory update', () => {
for (let i = 0; i < 10; i++) limiter.recordCall('commit');
const [, reason] = limiter.canCall('commit');
expect(reason).toContain('一般记忆修改');
});
it('should classify purge as memory update', () => {
for (let i = 0; i < 10; i++) limiter.recordCall('purge');
const [, reason] = limiter.canCall('purge');
expect(reason).toContain('一般记忆修改');
});
it('should classify archive as memory update', () => {
for (let i = 0; i < 10; i++) limiter.recordCall('archive');
const [, reason] = limiter.canCall('archive');
expect(reason).toContain('一般记忆修改');
});
it('should classify cleanup as memory update', () => {
for (let i = 0; i < 10; i++) limiter.recordCall('cleanup');
const [, reason] = limiter.canCall('cleanup');
expect(reason).toContain('一般记忆修改');
});
it('should classify task_create as task update', () => {
for (let i = 0; i < 5; i++) limiter.recordCall('task_create');
const [, reason] = limiter.canCall('task_create');
expect(reason).toContain('工作记忆链修改');
});
it('should classify task_set_state as task update', () => {
for (let i = 0; i < 5; i++) limiter.recordCall('task_set_state');
const [, reason] = limiter.canCall('task_set_state');
expect(reason).toContain('工作记忆链修改');
});
it('should classify task_delete as task update', () => {
for (let i = 0; i < 5; i++) limiter.recordCall('task_delete');
const [, reason] = limiter.canCall('task_delete');
expect(reason).toContain('工作记忆链修改');
});
it('should classify task_link_info as task update', () => {
for (let i = 0; i < 5; i++) limiter.recordCall('task_link_info');
const [, reason] = limiter.canCall('task_link_info');
expect(reason).toContain('工作记忆链修改');
});
it('should classify persona_update as persona update', () => {
limiter.recordCall('persona_update');
const [, reason] = limiter.canCall('persona_update');
expect(reason).toContain('人设图修改');
});
it('should classify persona_clear as persona update', () => {
limiter.recordCall('persona_clear');
const [, reason] = limiter.canCall('persona_clear');
expect(reason).toContain('人设图修改');
});
it('should classify unknown action as memory update', () => {
for (let i = 0; i < 10; i++) limiter.recordCall('totally_unknown');
const [, reason] = limiter.canCall('totally_unknown');
expect(reason).toContain('一般记忆修改');
});
});
describe('Custom limits override', () => {
it('should accept custom limits', () => {
const limiter = new ToolLimiter({
memory_query_max: 100,
memory_update_max: 50
});
expect(limiter.limits.memory_query_max).toBe(100);
expect(limiter.limits.memory_update_max).toBe(50);
expect(limiter.limits.task_query_max).toBe(4);
});
it('should merge custom limits with defaults', () => {
const limiter = new ToolLimiter({
persona_update_max: 5
});
expect(limiter.limits.persona_update_max).toBe(5);
expect(limiter.limits.persona_query_max).toBe(1);
});
it('should allow unlimited calls with high custom limits', () => {
const limiter = new ToolLimiter({
memory_query_max: 10000,
memory_update_max: 10000
});
for (let i = 0; i < 100; i++) {
limiter.recordCall('recall');
limiter.recordCall('commit');
}
expect(limiter.canCall('recall')[0]).toBe(true);
expect(limiter.canCall('commit')[0]).toBe(true);
});
it('should allow zero limits to block immediately', () => {
const limiter = new ToolLimiter({
task_update_max: 0
});
const [allowed] = limiter.canCall('task_create');
expect(allowed).toBe(false);
});
it('should preserve custom limits after reset', () => {
const limiter = new ToolLimiter({
memory_query_max: 500
});
for (let i = 0; i < 100; i++) {
limiter.recordCall('recall');
}
limiter.reset();
expect(limiter.limits.memory_query_max).toBe(500);
expect(limiter.counts.memory_query).toBe(0);
const [allowed] = limiter.canCall('recall');
expect(allowed).toBe(true);
});
});
});

View File

@ -13,10 +13,11 @@
"declarationMap": true,
"sourceMap": true,
"exactOptionalPropertyTypes": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true
"noFallthroughCasesInSwitch": true,
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]