From 85c3431b529a320557190d29603f7984ef59be70 Mon Sep 17 00:00:00 2001 From: root Date: Thu, 16 Apr 2026 18:26:19 +0800 Subject: [PATCH] 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 --- .sisyphus/plans/plan.md | 572 ++++++++++++++++++ .sisyphus/plans/progress.md | 95 +++ README.md | 286 ++++----- README_EN.md | 297 ++++----- .../{graph_memory => graph-memory}/SKILL.md | 97 ++- skills/graph-memory/persona/SKILL.md | 44 ++ skills/graph-memory/task/SKILL.md | 52 ++ skills/graph_memory/persona/SKILL.md | 98 --- skills/graph_memory/task/SKILL.md | 134 ---- .../{graph_memory => graph-memory}/SKILL.md | 69 +-- .../graph-memory/persona/SKILL.md | 27 + ts/bundled-skills/graph-memory/task/SKILL.md | 44 ++ .../graph_memory/persona/SKILL.md | 66 -- ts/bundled-skills/graph_memory/task/SKILL.md | 98 --- ts/openclaw.plugin.json | 15 + ts/package-lock.json | 431 +++++++++++++ ts/package.json | 16 +- ts/src/plugin-entry.ts | 114 ++++ .../core/graph_memory/graph_database.ts | 403 ++++++++---- .../core/graph_memory/memory_service.ts | 8 + .../core/tools/builtin/graph_memory_tool.ts | 71 ++- ts/src/runtime/core/tools/tool_limiter.ts | 139 +++++ .../core/graph_memory/graph_database.test.ts | 222 +++++++ .../core/graph_memory/memory_service.test.ts | 242 ++++++++ .../tools/builtin/graph_memory_tool.test.ts | 511 ++++++++++++++++ .../runtime/core/tools/tool_limiter.test.ts | 433 +++++++++++++ ts/tsconfig.json | 7 +- 27 files changed, 3574 insertions(+), 1017 deletions(-) create mode 100644 .sisyphus/plans/plan.md create mode 100644 .sisyphus/plans/progress.md rename skills/{graph_memory => graph-memory}/SKILL.md (51%) create mode 100644 skills/graph-memory/persona/SKILL.md create mode 100644 skills/graph-memory/task/SKILL.md delete mode 100644 skills/graph_memory/persona/SKILL.md delete mode 100644 skills/graph_memory/task/SKILL.md rename ts/bundled-skills/{graph_memory => graph-memory}/SKILL.md (53%) create mode 100644 ts/bundled-skills/graph-memory/persona/SKILL.md create mode 100644 ts/bundled-skills/graph-memory/task/SKILL.md delete mode 100644 ts/bundled-skills/graph_memory/persona/SKILL.md delete mode 100644 ts/bundled-skills/graph_memory/task/SKILL.md create mode 100644 ts/openclaw.plugin.json create mode 100644 ts/src/plugin-entry.ts create mode 100644 ts/src/runtime/core/tools/tool_limiter.ts create mode 100644 ts/tests/runtime/core/graph_memory/graph_database.test.ts create mode 100644 ts/tests/runtime/core/graph_memory/memory_service.test.ts create mode 100644 ts/tests/runtime/core/tools/builtin/graph_memory_tool.test.ts create mode 100644 ts/tests/runtime/core/tools/tool_limiter.test.ts diff --git a/.sisyphus/plans/plan.md b/.sisyphus/plans/plan.md new file mode 100644 index 0000000..c53a803 --- /dev/null +++ b/.sisyphus/plans/plan.md @@ -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` 验证 diff --git a/.sisyphus/plans/progress.md b/.sisyphus/plans/progress.md new file mode 100644 index 0000000..b7cdac3 --- /dev/null +++ b/.sisyphus/plans/progress.md @@ -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 分支 diff --git a/README.md b/README.md index 9d9f8ae..ef3cdb9 100644 --- a/README.md +++ b/README.md @@ -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) \ No newline at end of file +[GNU General Public License v3.0 (GPLv3)](LICENSE) diff --git a/README_EN.md b/README_EN.md index 8da198e..cb6b946 100644 --- a/README_EN.md +++ b/README_EN.md @@ -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) \ No newline at end of file +[GNU General Public License v3.0 (GPLv3)](LICENSE) diff --git a/skills/graph_memory/SKILL.md b/skills/graph-memory/SKILL.md similarity index 51% rename from skills/graph_memory/SKILL.md rename to skills/graph-memory/SKILL.md index 58df58e..1712016 100644 --- a/skills/graph_memory/SKILL.md +++ b/skills/graph-memory/SKILL.md @@ -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 信息应该谨慎修改 -- 任务可以跨会话追踪 diff --git a/skills/graph-memory/persona/SKILL.md b/skills/graph-memory/persona/SKILL.md new file mode 100644 index 0000000..5bd2bfd --- /dev/null +++ b/skills/graph-memory/persona/SKILL.md @@ -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 +``` diff --git a/skills/graph-memory/task/SKILL.md b/skills/graph-memory/task/SKILL.md new file mode 100644 index 0000000..85f2203 --- /dev/null +++ b/skills/graph-memory/task/SKILL.md @@ -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`: 信息节点 diff --git a/skills/graph_memory/persona/SKILL.md b/skills/graph_memory/persona/SKILL.md deleted file mode 100644 index 576bd3b..0000000 --- a/skills/graph_memory/persona/SKILL.md +++ /dev/null @@ -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 的角色 | "猫娘"、"助手"、"专家" | -| 性格 | 性格特征 | "活泼"、"严肃"、"幽默" | -| 职业 | 专业领域 | "技术作家"、"程序员" | -| 语言风格 | 说话方式 | "简洁"、"详细" | diff --git a/skills/graph_memory/task/SKILL.md b/skills/graph_memory/task/SKILL.md deleted file mode 100644 index 4eb4872..0000000 --- a/skills/graph_memory/task/SKILL.md +++ /dev/null @@ -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. **清理完成**:已完成的任务及时删除或归档 diff --git a/ts/bundled-skills/graph_memory/SKILL.md b/ts/bundled-skills/graph-memory/SKILL.md similarity index 53% rename from ts/bundled-skills/graph_memory/SKILL.md rename to ts/bundled-skills/graph-memory/SKILL.md index a86a862..e3140b3 100644 --- a/ts/bundled-skills/graph_memory/SKILL.md +++ b/ts/bundled-skills/graph-memory/SKILL.md @@ -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) ## 使用原则 diff --git a/ts/bundled-skills/graph-memory/persona/SKILL.md b/ts/bundled-skills/graph-memory/persona/SKILL.md new file mode 100644 index 0000000..188153f --- /dev/null +++ b/ts/bundled-skills/graph-memory/persona/SKILL.md @@ -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 才能执行清除 diff --git a/ts/bundled-skills/graph-memory/task/SKILL.md b/ts/bundled-skills/graph-memory/task/SKILL.md new file mode 100644 index 0000000..a4a09bd --- /dev/null +++ b/ts/bundled-skills/graph-memory/task/SKILL.md @@ -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`: 信息节点 diff --git a/ts/bundled-skills/graph_memory/persona/SKILL.md b/ts/bundled-skills/graph_memory/persona/SKILL.md deleted file mode 100644 index b3f87c5..0000000 --- a/ts/bundled-skills/graph_memory/persona/SKILL.md +++ /dev/null @@ -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 -``` diff --git a/ts/bundled-skills/graph_memory/task/SKILL.md b/ts/bundled-skills/graph_memory/task/SKILL.md deleted file mode 100644 index 2b1b7b3..0000000 --- a/ts/bundled-skills/graph_memory/task/SKILL.md +++ /dev/null @@ -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: "新教程链接" -``` diff --git a/ts/openclaw.plugin.json b/ts/openclaw.plugin.json new file mode 100644 index 0000000..7a33994 --- /dev/null +++ b/ts/openclaw.plugin.json @@ -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" + } + } + } +} diff --git a/ts/package-lock.json b/ts/package-lock.json index 696d480..a1905f6 100644 --- a/ts/package-lock.json +++ b/ts/package-lock.json @@ -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", diff --git a/ts/package.json b/ts/package.json index 48812a3..ccbf526 100644 --- a/ts/package.json +++ b/ts/package.json @@ -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" diff --git a/ts/src/plugin-entry.ts b/ts/src/plugin-entry.ts new file mode 100644 index 0000000..f293bea --- /dev/null +++ b/ts/src/plugin-entry.ts @@ -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) => 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): 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) + }] + }; + } + }); +} diff --git a/ts/src/runtime/core/graph_memory/graph_database.ts b/ts/src/runtime/core/graph_memory/graph_database.ts index 7a7cadc..4bc6d79 100644 --- a/ts/src/runtime/core/graph_memory/graph_database.ts +++ b/ts/src/runtime/core/graph_memory/graph_database.ts @@ -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 = new Map(); - private relations: Map = 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 { - 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(); const entities: Entity[] = []; const relations: Relation[] = []; - const entityIds = new Set(); - 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>; + + 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>; + + 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(entityIds); + let currentLevel = new Set(entityIds); + + for (let d = 1; d < depth; d++) { + const nextLevel = new Set(); + 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>; + + 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>; + + 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 { @@ -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 { - 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 { - 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): 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): 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(); } } diff --git a/ts/src/runtime/core/graph_memory/memory_service.ts b/ts/src/runtime/core/graph_memory/memory_service.ts index 6ebc13e..cf84349 100644 --- a/ts/src/runtime/core/graph_memory/memory_service.ts +++ b/ts/src/runtime/core/graph_memory/memory_service.ts @@ -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; diff --git a/ts/src/runtime/core/tools/builtin/graph_memory_tool.ts b/ts/src/runtime/core/tools/builtin/graph_memory_tool.ts index da30525..b2393ba 100644 --- a/ts/src/runtime/core/tools/builtin/graph_memory_tool.ts +++ b/ts/src/runtime/core/tools/builtin/graph_memory_tool.ts @@ -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, _context: ToolExecutionContext): Promise { @@ -118,7 +135,30 @@ export class GraphMemoryTool implements Tool { } } + async execute(_toolCallId: string, params: Record): Promise { + 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): Promise { + 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'; diff --git a/ts/src/runtime/core/tools/tool_limiter.ts b/ts/src/runtime/core/tools/tool_limiter.ts new file mode 100644 index 0000000..d6e5425 --- /dev/null +++ b/ts/src/runtime/core/tools/tool_limiter.ts @@ -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) { + 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, + }; + } +} diff --git a/ts/tests/runtime/core/graph_memory/graph_database.test.ts b/ts/tests/runtime/core/graph_memory/graph_database.test.ts new file mode 100644 index 0000000..cfe9a34 --- /dev/null +++ b/ts/tests/runtime/core/graph_memory/graph_database.test.ts @@ -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'); + }); + }); +}); \ No newline at end of file diff --git a/ts/tests/runtime/core/graph_memory/memory_service.test.ts b/ts/tests/runtime/core/graph_memory/memory_service.test.ts new file mode 100644 index 0000000..5be2223 --- /dev/null +++ b/ts/tests/runtime/core/graph_memory/memory_service.test.ts @@ -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); + }); + + }); +}); \ No newline at end of file diff --git a/ts/tests/runtime/core/tools/builtin/graph_memory_tool.test.ts b/ts/tests/runtime/core/tools/builtin/graph_memory_tool.test.ts new file mode 100644 index 0000000..6d5cfb6 --- /dev/null +++ b/ts/tests/runtime/core/tools/builtin/graph_memory_tool.test.ts @@ -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); + }); + }); +}); diff --git a/ts/tests/runtime/core/tools/tool_limiter.test.ts b/ts/tests/runtime/core/tools/tool_limiter.test.ts new file mode 100644 index 0000000..a815bd0 --- /dev/null +++ b/ts/tests/runtime/core/tools/tool_limiter.test.ts @@ -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); + }); + }); +}); diff --git a/ts/tsconfig.json b/ts/tsconfig.json index b5689e7..5afc143 100644 --- a/ts/tsconfig.json +++ b/ts/tsconfig.json @@ -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"]