From a4a904f66ba31042bcc2593d058c7e753956e29b Mon Sep 17 00:00:00 2001 From: root Date: Fri, 24 Apr 2026 13:56:14 +0800 Subject: [PATCH] =?UTF-8?q?P1:=20=E5=AE=8C=E6=95=B4=E5=8F=82=E6=95=B0?= =?UTF-8?q?=E9=AA=8C=E8=AF=81=20+=20=E9=94=99=E8=AF=AF=E5=A4=84=E7=90=86/?= =?UTF-8?q?=E6=97=A5=E5=BF=97=20+=20=E6=B5=8B=E8=AF=95=E8=A6=86=E7=9B=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 为所有 action 实现参数验证函数 - 添加 GraphMemoryLogger 日志系统(info/warn/error/action) - 敏感数据脱敏:attributes 只记录属性名,triplets 只记录数量 - 顶层 action 参数空值检查 - 参数验证错误返回 validation_error 类型 - 添加 11 个参数验证测试用例 - 全部 118 个测试通过 --- README.md | 7 + TODO.md | 121 +++++ ts/openclaw.plugin.json | 6 +- .../core/tools/builtin/graph_memory_tool.ts | 300 ++++++++++- .../tools/builtin/graph_memory_tool.test.ts | 504 ++++++++++-------- 5 files changed, 712 insertions(+), 226 deletions(-) create mode 100644 TODO.md diff --git a/README.md b/README.md index 21fe105..d59b471 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,13 @@ AI 应调用 `recall` action 并返回之前写入的信息。 本项目是 OpenClaw 的图记忆插件,基于 SQLite 实现持久化图数据库。 +**设计理念:增强而非替换** + +本插件作为 **增强工具** 提供,不会替换 OpenClaw 的内置 memory-core 系统: +- **保留 message 数组**:对话历史仍由 memory-core 管理 +- **图记忆作为工具**:通过 `graph_memory` 工具为 LLM 提供结构化记忆能力 +- **两者并存**:memory-core 管理会话历史,graph-memory 提供知识图谱 + **核心功能:** - **recall**: 检索记忆(支持关键词、种子实体、多跳遍历、时间过滤) - **commit**: 写入记忆(三元组批量写入) diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..d72d81d --- /dev/null +++ b/TODO.md @@ -0,0 +1,121 @@ +# TrulyMEM OpenClaw分支 TODO + +## 设计决策:作为增强工具而非主记忆核心 + +### 背景分析 + +TrulyMEM main分支的设计理念: +- **图数据库作为唯一持久化记忆载体** +- **摒弃传统messages数组上下文** +- **工作记忆链(TaskNode链)代替对话历史** +- **每轮强制执行流程**:查询人设图 → 查询工作记忆链 → 处理对话 → 更新工作记忆链 + +### 与OpenClaw memory-core对比 + +| 方面 | TrulyMEM (main分支) | OpenClaw memory-core | +|------|---------------------|---------------------| +| 核心理念 | 图数据库作为唯一持久化记忆载体 | session transcripts + memory search | +| 对话历史 | 工作记忆链(TaskNode链)代替传统messages | messages数组持久化存储 | +| 上下文管理 | 每轮从图数据库重建 + context_rewrite压缩 | compaction机制压缩历史 | +| 记忆写入 | 通过memory_commit工具写入三元组 | 自动记录对话历史 | +| 记忆检索 | memory_recall工具主动查询 | memory search索引检索 | +| 人设管理 | PersonaNode + 强制查询机制 | 无内置人设系统 | + +### 作为主记忆核心的挑战 + +1. **架构差异**: + - OpenClaw memory-core是完整基础设施,管理session transcripts、health monitor等 + - TrulyMEM是独立应用设计,需要重新适配OpenClaw架构 + +2. **强制执行流程**: + - TrulyMEM要求每轮必须:查询人设图 → 查询工作记忆链 → 处理对话 → 更新工作记忆链 + - OpenClaw没有这种强制流程,需要修改核心逻辑 + +3. **依赖问题**: + - main分支是Python实现(TUI应用) + - openclaw分支是TypeScript插件(不完整移植) + - 需要完整移植Python版本的核心逻辑 + +4. **功能缺失**: + - openclaw分支缺少:context_rewrite压缩工具、强制执行流程、人设强制查询 + - 当前只是普通tool,不是完整记忆系统 + +### 设计决策 + +**短期目标**:作为增强工具 +- 提供图记忆能力作为额外工具 +- 不替换memory-core +- LLM可选调用 +- 移除 `"kind": "memory"` 配置,避免独占memory插槽 + +**长期目标**:如果要替代memory-core +- 需要深度架构重构 +- 需要完整移植main分支的核心逻辑(Python → TypeScript) +- 需要实现强制执行流程(修改OpenClaw核心) +- 需要实现context_rewrite工具 +- 需要实现工作记忆链机制 +- 需要先在独立项目中验证可行性 + +--- + +## 当前状态 + +### 已完成 +- [x] plugin-entry.ts 改为OpenClaw SDK规范格式 +- [x] 移除 `"kind": "memory"` 配置 +- [x] README.md 更新安装文档 +- [x] 插件成功加载到OpenClaw +- [x] 基本recall/commit功能测试通过 + +### 待完成(增强工具设计) + +#### 优先级 P0 - 核心功能修复 +- [ ] 确认移除memory插槽后的插件加载状态 +- [ ] 测试与memory-core并存运行 +- [ ] 验证工具schema正确传递给Kimi + +#### 优先级 P1 - 功能完善 +- [ ] 实现完整的工具参数验证 +- [ ] 添加错误处理和日志 +- [ ] 完善skill文档(说明增强而非替换) + +#### 优先级 P2 - 可选高级功能 +- [ ] 实现context_rewrite工具(压缩上下文) +- [ ] 实现工作记忆链机制 +- [ ] 实现人设强制查询(作为skill而非核心) +- [ ] 添加时间过滤和多跳遍历优化 + +--- + +## 技术细节 + +### 当前实现状态 + +**工具列表**: +- `graph_memory` - 综合工具,支持多种action + - recall: 检索记忆 + - commit: 写入记忆 + - purge: 删除记忆 + - introspect: 查看状态 + - archive: 归档记忆 + - cleanup: 清理数据 + - persona_update/clear: 人设管理 + - task_create/set_state/delete/link_info: 任务管理 + +**数据存储**: +- SQLite数据库:`~/.trulymem/graph_memory.db` +- 表结构:entities, relations + +**与main分支差异**: +- 无context_rewrite工具 +- 无强制执行流程 +- 无Python TUI界面 +- 纯TypeScript插件实现 + +--- + +## 参考 + +- main分支文档:`docs/zh/memory.md`, `docs/zh/working_memory.md` +- OpenClaw文档:https://docs.openclaw.ai/zh-CN/tools/plugin +- kimi-proxy:`/home/program/kimi-proxy/server.js` \ No newline at end of file diff --git a/ts/openclaw.plugin.json b/ts/openclaw.plugin.json index 2b13abc..47beeec 100644 --- a/ts/openclaw.plugin.json +++ b/ts/openclaw.plugin.json @@ -1,13 +1,15 @@ { "id": "graph-memory", "name": "Graph Memory", - "kind": "memory", - "description": "让 AI 拥有真正的长期记忆能力 - 基于图数据库的记忆系统", + "description": "让 AI 拥有真正的长期记忆能力 - 基于图数据库的记忆系统(作为增强工具,不替换memory-core)", "skills": [ "bundled-skills/graph-memory", "bundled-skills/graph-memory-persona", "bundled-skills/graph-memory-task" ], + "contracts": { + "tools": ["graph_memory"] + }, "configSchema": { "type": "object", "additionalProperties": false, 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 aa838f8..f795c6b 100644 --- a/ts/src/runtime/core/tools/builtin/graph_memory_tool.ts +++ b/ts/src/runtime/core/tools/builtin/graph_memory_tool.ts @@ -63,30 +63,274 @@ export const GraphMemoryToolSchema = Type.Object({ export type GraphMemoryToolParams = Static; -const GRAPH_MEMORY_TOOL_DESCRIPTION = `图记忆工具 - 让 AI 拥有真正的长期记忆能力 +const GRAPH_MEMORY_TOOL_DESCRIPTION = `图记忆工具 - 让 AI 拥有真正的长期记忆能力。作为 OpenClaw memory-core 的增强补充,不替代其核心功能。 操作: -- recall: 检索记忆 -- commit: 写入记忆 -- purge: 删除记忆 -- introspect: 查看状态 -- archive: 归档旧记忆 -- cleanup: 清理无效数据 -- persona_update/clear: 人设管理 -- task_create/set_state/delete/link_info: 任务管理`; +- recall: 检索记忆(可选参数: queryIntent, seedEntities, depth, sessionFilter) +- commit: 写入记忆(必需参数: triplets) +- purge: 删除记忆(可选参数: criteria, mode, newRelation) +- introspect: 查看状态(无参数) +- archive: 归档旧记忆(可选参数: days, 默认30天) +- cleanup: 清理无效数据(可选参数: dry_run, 默认true预览模式) +- persona_update: 更新人设(必需参数: attributes; 可选: mode=merge/replace) +- persona_clear: 清除人设(必需参数: confirm=true) +- task_create: 创建任务(必需参数: task_id, description; 可选: info_nodes) +- task_set_state: 设置任务状态(必需参数: task_id, state) +- task_delete: 删除任务(必需参数: task_id) +- task_link_info: 关联信息(必需参数: task_id, info_node)`; + +// ==================== 参数验证 ==================== + +interface ValidationError { + field: string; + message: string; +} + +function validateRecallParams(params: Record): ValidationError[] { + const errors: ValidationError[] = []; + const queryIntent = params.queryIntent; + const seedEntities = params.seedEntities; + + if ((!queryIntent || (typeof queryIntent === 'string' && queryIntent.trim() === '')) && + (!seedEntities || !Array.isArray(seedEntities) || seedEntities.length === 0)) { + errors.push({ field: 'queryIntent/seedEntities', message: 'recall 操作需要提供 queryIntent 或 seedEntities 之一' }); + } + + if (params.depth !== undefined) { + const depth = Number(params.depth); + if (isNaN(depth) || depth < 1 || depth > 5) { + errors.push({ field: 'depth', message: 'depth 必须在 1-5 之间' }); + } + } + + return errors; +} + +function validateCommitParams(params: Record): ValidationError[] { + const errors: ValidationError[] = []; + const triplets = params.triplets; + + if (!triplets || !Array.isArray(triplets) || triplets.length === 0) { + errors.push({ field: 'triplets', message: 'commit 操作必需提供 triplets 数组' }); + return errors; + } + + for (let i = 0; i < triplets.length; i++) { + const t = triplets[i] as Record; + if (!t.subject || typeof t.subject !== 'string' || t.subject.trim() === '') { + errors.push({ field: `triplets[${i}].subject`, message: '三元组主体不能为空字符串' }); + } + if (!t.relation || typeof t.relation !== 'string' || t.relation.trim() === '') { + errors.push({ field: `triplets[${i}].relation`, message: '三元组关系不能为空字符串' }); + } + if (!t.object || typeof t.object !== 'string' || t.object.trim() === '') { + errors.push({ field: `triplets[${i}].object`, message: '三元组客体不能为空字符串' }); + } + if (t.confidence !== undefined) { + const c = Number(t.confidence); + if (isNaN(c) || c < 0 || c > 1) { + errors.push({ field: `triplets[${i}].confidence`, message: '置信度必须在 0-1 之间' }); + } + } + } + + return errors; +} + +function validatePurgeParams(params: Record): ValidationError[] { + const errors: ValidationError[] = []; + + if (params.mode === 'supersede' && (!params.newRelation || typeof params.newRelation !== 'object')) { + errors.push({ field: 'newRelation', message: 'supersede 模式必需提供 newRelation' }); + } + + if (params.newRelation) { + const nr = params.newRelation as Record; + if (!nr.relation || typeof nr.relation !== 'string') { + errors.push({ field: 'newRelation.relation', message: 'newRelation.relation 必须是字符串' }); + } + if (!nr.target || typeof nr.target !== 'string') { + errors.push({ field: 'newRelation.target', message: 'newRelation.target 必须是字符串' }); + } + } + + return errors; +} + +function validatePersonaUpdateParams(params: Record): ValidationError[] { + const errors: ValidationError[] = []; + const attributes = params.attributes; + + if (!attributes || !Array.isArray(attributes) || attributes.length === 0) { + errors.push({ field: 'attributes', message: 'persona_update 操作必需提供 attributes 数组' }); + return errors; + } + + for (let i = 0; i < attributes.length; i++) { + const attr = attributes[i] as Record; + if (!attr.attribute || typeof attr.attribute !== 'string' || attr.attribute.trim() === '') { + errors.push({ field: `attributes[${i}].attribute`, message: '属性名不能为空字符串' }); + } + if (attr.value === undefined || typeof attr.value !== 'string') { + errors.push({ field: `attributes[${i}].value`, message: '属性值必须是字符串' }); + } + } + + return errors; +} + +function validateTaskCreateParams(params: Record): ValidationError[] { + const errors: ValidationError[] = []; + + if (!params.task_id || typeof params.task_id !== 'string' || params.task_id.trim() === '') { + errors.push({ field: 'task_id', message: 'task_create 操作必需提供 task_id 字符串' }); + } + if (!params.description || typeof params.description !== 'string') { + errors.push({ field: 'description', message: 'task_create 操作必需提供 description 字符串' }); + } + + return errors; +} + +function validateTaskSetStateParams(params: Record): ValidationError[] { + const errors: ValidationError[] = []; + + if (!params.task_id || typeof params.task_id !== 'string' || params.task_id.trim() === '') { + errors.push({ field: 'task_id', message: 'task_set_state 操作必需提供 task_id 字符串' }); + } + if (!params.state || typeof params.state !== 'string' || params.state.trim() === '') { + errors.push({ field: 'state', message: 'task_set_state 操作必需提供 state 字符串' }); + } + + return errors; +} + +function validateTaskDeleteParams(params: Record): ValidationError[] { + const errors: ValidationError[] = []; + + if (!params.task_id || typeof params.task_id !== 'string' || params.task_id.trim() === '') { + errors.push({ field: 'task_id', message: 'task_delete 操作必需提供 task_id 字符串' }); + } + + return errors; +} + +function validateTaskLinkInfoParams(params: Record): ValidationError[] { + const errors: ValidationError[] = []; + + if (!params.task_id || typeof params.task_id !== 'string' || params.task_id.trim() === '') { + errors.push({ field: 'task_id', message: 'task_link_info 操作必需提供 task_id 字符串' }); + } + if (!params.info_node || typeof params.info_node !== 'string' || params.info_node.trim() === '') { + errors.push({ field: 'info_node', message: 'task_link_info 操作必需提供 info_node 字符串' }); + } + + return errors; +} + +function validatePersonaClearParams(params: Record): ValidationError[] { + const errors: ValidationError[] = []; + + if (params.confirm === undefined) { + errors.push({ field: 'confirm', message: 'persona_clear 操作必需设置 confirm: true 才能执行清除' }); + } + + return errors; +} + +function validateParams(action: string, params: Record): ValidationError[] { + switch (action) { + case 'recall': return validateRecallParams(params); + case 'commit': return validateCommitParams(params); + case 'purge': return validatePurgeParams(params); + case 'persona_update': return validatePersonaUpdateParams(params); + case 'persona_clear': return validatePersonaClearParams(params); + case 'task_create': return validateTaskCreateParams(params); + case 'task_set_state': return validateTaskSetStateParams(params); + case 'task_delete': return validateTaskDeleteParams(params); + case 'task_link_info': return validateTaskLinkInfoParams(params); + default: return []; + } +} + +// ==================== 日志 ==================== + +class GraphMemoryLogger { + private prefix = '[TrulyMEM]'; + + info(message: string, meta?: Record): void { + console.log(`${this.prefix} [INFO] ${message}`, meta ? JSON.stringify(meta) : ''); + } + + warn(message: string, meta?: Record): void { + console.warn(`${this.prefix} [WARN] ${message}`, meta ? JSON.stringify(meta) : ''); + } + + error(message: string, meta?: Record): void { + console.error(`${this.prefix} [ERROR] ${message}`, meta ? JSON.stringify(meta) : ''); + } + + action(action: string, params: Record, result?: unknown): void { + const meta: Record = { action }; + if (params && Object.keys(params).length > 0) { + // 敏感信息脱敏:不记录具体属性值 + const sanitized = this.sanitizeParams(params); + meta.params = sanitized; + } + if (result !== undefined) { + meta.result = typeof result === 'object' && result !== null + ? (result as Record).success ?? 'ok' + : 'ok'; + } + this.info(`action=${action}`, meta); + } + + private sanitizeParams(params: Record): Record { + const sanitized: Record = {}; + for (const [key, value] of Object.entries(params)) { + if (key === 'attributes') { + sanitized[key] = Array.isArray(value) + ? (value as Array>).map(a => a.attribute) + : value; + } else if (key === 'triplets') { + sanitized[key] = Array.isArray(value) + ? `${(value as unknown[]).length} triplets` + : value; + } else { + sanitized[key] = value; + } + } + return sanitized; + } +} + +// ==================== 主逻辑 ==================== export function createGraphMemoryTool(dbPath?: string, sessionId?: string) { const db = new GraphDatabase(dbPath, sessionId); const service = new MemoryService(db); const limiter = new ToolLimiter(); + const logger = new GraphMemoryLogger(); async function executeAction(action: string, params: Record): Promise { + // 1. 限流检查 const [allowed, reason] = limiter.canCall(action); if (!allowed) { + logger.warn(`Rate limit blocked: ${action}`, { reason }); throw new Error(reason); } limiter.recordCall(action); + // 2. 参数验证 + const validationErrors = validateParams(action, params); + if (validationErrors.length > 0) { + const errorMsg = validationErrors.map(e => `${e.field}: ${e.message}`).join('; '); + logger.warn(`Validation failed for ${action}`, { errors: validationErrors }); + throw new Error(`参数验证失败: ${errorMsg}`); + } + + logger.action(action, params); + switch (action) { case 'recall': return service.recall({ @@ -120,6 +364,10 @@ export function createGraphMemoryTool(dbPath?: string, sessionId?: string) { }); case 'persona_clear': + // confirm=false 时也要允许执行(返回 cancelled),验证只拦截 confirm 不是 boolean 的情况 + if (params.confirm === undefined) { + throw new Error('persona_clear 操作必需设置 confirm: true 才能执行清除'); + } return service.clearPersona({ confirm: params.confirm as boolean }); @@ -168,14 +416,34 @@ export function createGraphMemoryTool(dbPath?: string, sessionId?: string) { content: Array<{ type: 'text'; text: string }>; }> { const action = params.action as string; - const actionParams = params.params as Record; + const actionParams = (params.params as Record) || {}; + + // 顶层参数验证 + if (!action || typeof action !== 'string') { + logger.error('Missing or invalid action parameter', { params }); + return { + content: [{ + type: 'text', + text: JSON.stringify({ + success: false, + error: { + type: 'validation_error', + message: '必需提供 action 参数(字符串类型)' + } + }) + }] + }; + } try { const result = await executeAction(action, actionParams); + logger.action(action, actionParams, result); return { content: [{ type: 'text', text: JSON.stringify({ success: true, data: result }) }] }; } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + logger.error(`Execution failed: ${action}`, { error: errorMessage }); return { content: [{ type: 'text', @@ -183,12 +451,22 @@ export function createGraphMemoryTool(dbPath?: string, sessionId?: string) { success: false, error: { type: 'execution_error', - message: error instanceof Error ? error.message : String(error) + message: errorMessage } }) }] }; } + }, + + // 供测试使用的内部方法 + getLimiterSummary(): string { + return limiter.getSummary(); + }, + + resetLimiter(): void { + limiter.reset(); + logger.info('Tool limiter reset'); } }; } 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 index 6d5cfb6..0f275e5 100644 --- a/ts/tests/runtime/core/tools/builtin/graph_memory_tool.test.ts +++ b/ts/tests/runtime/core/tools/builtin/graph_memory_tool.test.ts @@ -1,18 +1,17 @@ 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 { createGraphMemoryTool, ToolLimiter } from '../../../../../dist/runtime/core/tools/builtin/graph_memory_tool.js'; import * as fs from 'fs'; const TEST_DB_PATH = '/tmp/test_graph_memory_tool.db'; describe('GraphMemoryTool', () => { - let tool: GraphMemoryTool; + let tool: ReturnType; beforeEach(() => { if (fs.existsSync(TEST_DB_PATH)) { fs.unlinkSync(TEST_DB_PATH); } - tool = new GraphMemoryTool(TEST_DB_PATH, 'test-session'); + tool = createGraphMemoryTool(TEST_DB_PATH, 'test-session'); }); afterEach(() => { @@ -22,24 +21,17 @@ describe('GraphMemoryTool', () => { }); 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'); + expect(tool.name).toBe('graph_memory'); }); - it('should have analysis category', () => { - expect(tool.category).toBe('analysis'); - }); - - it('should have safe permission level', () => { - expect(tool.permissionLevel).toBe('safe'); + it('should have description', () => { + expect(tool.description).toBeTruthy(); + expect(tool.description).toContain('图记忆'); }); it('should have input schema with all actions', () => { - const actions = tool.inputSchema.properties?.action?.enum as string[]; + const actions = tool.parameters.properties?.action?.enum as string[]; expect(actions).toContain('recall'); expect(actions).toContain('commit'); expect(actions).toContain('purge'); @@ -55,64 +47,66 @@ describe('GraphMemoryTool', () => { }); }); - describe('recall action', () => { - it('should recall memories by query intent', async () => { - await tool.handler({ + describe('execute wrapper', () => { + it('should execute recall action', async () => { + await tool.execute('call-1', { action: 'commit', params: { triplets: [ { subject: '用户', relation: '喜欢', object: '编程' } ] } - }, {} as any); + }); - const result = await tool.handler({ + const result = await tool.execute('call-2', { action: 'recall', params: { queryIntent: '用户 编程' } - }, {} as any); + }); - const parsed = JSON.parse(result); + expect(result).toHaveProperty('content'); + expect(Array.isArray(result.content)).toBe(true); + expect(result.content[0]).toHaveProperty('type', 'text'); + + const parsed = JSON.parse(result.content[0].text); 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({ + const result = await tool.execute('call-3', { action: 'recall', params: { queryIntent: '不存在的关键词xyz123' } - }, {} as any); + }); - const parsed = JSON.parse(result); + const parsed = JSON.parse(result.content[0].text); 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({ + const result = await tool.execute('call-4', { action: 'commit', params: { triplets: [ { subject: '测试', relation: '是', object: '示例' } ] } - }, {} as any); + }); - const parsed = JSON.parse(result); + const parsed = JSON.parse(result.content[0].text); 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({ + const result = await tool.execute('call-5', { action: 'commit', params: { triplets: [ @@ -121,79 +115,73 @@ describe('GraphMemoryTool', () => { { subject: '实体C', relation: '关联', object: '实体A' } ] } - }, {} as any); + }); - const parsed = JSON.parse(result); + const parsed = JSON.parse(result.content[0].text); 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({ + await tool.execute('call-6', { action: 'commit', params: { triplets: [ { subject: '待删除', relation: '是', object: '测试' } ] } - }, {} as any); + }); - const result = await tool.handler({ + const result = await tool.execute('call-7', { action: 'purge', params: { criteria: { subject: '待删除' }, mode: 'soft' } - }, {} as any); + }); - const parsed = JSON.parse(result); + const parsed = JSON.parse(result.content[0].text); 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({ + const result = await tool.execute('call-8', { action: 'purge', params: {} - }, {} as any); + }); - const parsed = JSON.parse(result); + const parsed = JSON.parse(result.content[0].text); expect(parsed.success).toBe(true); expect(parsed.data.deleted).toBe(0); }); - }); - describe('introspect action', () => { it('should return memory statistics', async () => { - await tool.handler({ + await tool.execute('call-9', { action: 'commit', params: { triplets: [ { subject: '实体1', relation: '关系', object: '实体2' } ] } - }, {} as any); + }); - const result = await tool.handler({ + const result = await tool.execute('call-10', { action: 'introspect', params: {} - }, {} as any); + }); - const parsed = JSON.parse(result); + const parsed = JSON.parse(result.content[0].text); 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({ + const result = await tool.execute('call-11', { action: 'persona_update', params: { attributes: [ @@ -202,310 +190,400 @@ describe('GraphMemoryTool', () => { ], mode: 'merge' } - }, {} as any); + }); - const parsed = JSON.parse(result); + const parsed = JSON.parse(result.content[0].text); 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({ + const result = await tool.execute('call-12', { action: 'persona_clear', params: { confirm: true } - }, {} as any); + }); - const parsed = JSON.parse(result); + const parsed = JSON.parse(result.content[0].text); 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({ + const result = await tool.execute('call-13', { action: 'persona_clear', params: { confirm: false } - }, {} as any); + }); - const parsed = JSON.parse(result); + const parsed = JSON.parse(result.content[0].text); 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({ + const result = await tool.execute('call-14', { action: 'task_create', params: { task_id: 'Task_Test123', description: '测试任务描述' } - }, {} as any); + }); - const parsed = JSON.parse(result); + const parsed = JSON.parse(result.content[0].text); 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({ + const result = await tool.execute('call-15', { action: 'task_create', params: { task_id: 'Task_WithInfo', description: '带信息的任务', info_nodes: ['信息节点1', '信息节点2'] } - }, {} as any); + }); - const parsed = JSON.parse(result); + const parsed = JSON.parse(result.content[0].text); 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({ + await tool.execute('call-16', { action: 'task_create', params: { task_id: 'Task_StateTest', description: '状态测试任务' } - }, {} as any); + }); - const result = await tool.handler({ + const result = await tool.execute('call-17', { action: 'task_set_state', params: { task_id: 'Task_StateTest', state: '已完成' } - }, {} as any); + }); - const parsed = JSON.parse(result); + const parsed = JSON.parse(result.content[0].text); 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({ + await tool.execute('call-18', { action: 'task_create', params: { task_id: 'Task_DeleteMe', description: '待删除任务' } - }, {} as any); + }); - const result = await tool.handler({ + const result = await tool.execute('call-19', { action: 'task_delete', params: { task_id: 'Task_DeleteMe' } - }, {} as any); + }); - const parsed = JSON.parse(result); + const parsed = JSON.parse(result.content[0].text); 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({ + await tool.execute('call-20', { action: 'task_create', params: { task_id: 'Task_Link', description: '链接测试任务' } - }, {} as any); + }); - const result = await tool.handler({ + const result = await tool.execute('call-21', { action: 'task_link_info', params: { task_id: 'Task_Link', info_node: '新的信息节点' } - }, {} as any); + }); - const parsed = JSON.parse(result); + const parsed = JSON.parse(result.content[0].text); 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({ + const result = await tool.execute('call-22', { 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', { + it('should cleanup in dry_run mode', async () => { + const result = await tool.execute('call-23', { + action: 'cleanup', + params: { + dry_run: true + } + }); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.success).toBe(true); + }); + + it('should cleanup without dry_run', async () => { + const result = await tool.execute('call-24', { + action: 'cleanup', + params: {} + }); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.success).toBe(true); + }); + + it('should return error for unknown action', async () => { + const result = await tool.execute('call-25', { action: 'invalid_action', params: {} }); const parsed = JSON.parse(result.content[0].text); expect(parsed.success).toBe(false); + expect(parsed.error).toBeDefined(); }); - }); - describe('ToolLimiter integration', () => { - it('should block calls when rate limit exceeded', async () => { - tool.resetLimiter(); + it('should handle errors in execute format', async () => { + const result = await tool.execute('call-26', { + action: '', + params: {} + }); - const limiter = (tool as any).limiter; + const parsed = JSON.parse(result.content[0].text); + expect(parsed.success).toBe(false); + }); + + it('should handle limiter blocking', async () => { + const newTool = createGraphMemoryTool(TEST_DB_PATH + '.limiter', 'limiter-session'); + + // Fill up memory_query limit for (let i = 0; i < 20; i++) { - limiter.recordCall('recall'); + await newTool.execute(`limiter-call-${i}`, { + action: 'introspect', + params: {} + }); } - const result = await tool.handler({ + // Next recall should be blocked + const result = await newTool.execute('limiter-blocked', { action: 'recall', params: { queryIntent: '测试' } - }, {} as any); + }); - const parsed = JSON.parse(result); + const parsed = JSON.parse(result.content[0].text); expect(parsed.success).toBe(false); expect(parsed.error.message).toContain('已达上限'); }); + }); - it('should get limiter summary', () => { - tool.resetLimiter(); - const summary = tool.getLimiterSummary(); + describe('ToolLimiter standalone', () => { + it('should limit memory query calls', () => { + const limiter = new ToolLimiter(); + + for (let i = 0; i < 20; i++) { + const [allowed] = limiter.canCall('recall'); + if (allowed) limiter.recordCall('recall'); + } + + const [allowed] = limiter.canCall('recall'); + expect(allowed).toBe(false); + }); + + it('should limit memory update calls', () => { + const limiter = new ToolLimiter(); + + for (let i = 0; i < 10; i++) { + const [allowed] = limiter.canCall('commit'); + if (allowed) limiter.recordCall('commit'); + } + + const [allowed] = limiter.canCall('commit'); + expect(allowed).toBe(false); + }); + + it('should get summary', () => { + const limiter = new ToolLimiter(); + const summary = limiter.getSummary(); expect(summary).toContain('人设图'); expect(summary).toContain('工作记忆链'); expect(summary).toContain('一般记忆'); }); - it('should reset limiter and allow calls again', async () => { - const limiter = (tool as any).limiter; + it('should reset and allow calls again', () => { + const limiter = new ToolLimiter(); + for (let i = 0; i < 20; i++) { - limiter.recordCall('recall'); + const [allowed] = limiter.canCall('recall'); + if (allowed) limiter.recordCall('recall'); } - tool.resetLimiter(); + expect(limiter.canCall('recall')[0]).toBe(false); + limiter.reset(); + expect(limiter.canCall('recall')[0]).toBe(true); + }); + }); - const result = await tool.handler({ - action: 'recall', - params: { queryIntent: '测试' } - }, {} as any); + describe('Parameter validation', () => { + it('should reject commit without triplets', async () => { + const result = await tool.execute('call-val-1', { + action: 'commit', + params: {} + }); - const parsed = JSON.parse(result); + const parsed = JSON.parse(result.content[0].text); + expect(parsed.success).toBe(false); + expect(parsed.error.message).toContain('triplets'); + }); + + it('should reject commit with empty triplet fields', async () => { + const result = await tool.execute('call-val-2', { + action: 'commit', + params: { + triplets: [{ subject: '', relation: '是', object: '测试' }] + } + }); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.success).toBe(false); + expect(parsed.error.message).toContain('subject'); + }); + + it('should reject persona_update without attributes', async () => { + const result = await tool.execute('call-val-3', { + action: 'persona_update', + params: {} + }); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.success).toBe(false); + expect(parsed.error.message).toContain('attributes'); + }); + + it('should reject persona_clear without confirm', async () => { + const result = await tool.execute('call-val-4', { + action: 'persona_clear', + params: {} + }); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.success).toBe(false); + expect(parsed.error.message).toContain('confirm'); + }); + + it('should allow persona_clear with confirm=false and return cancelled', async () => { + const result = await tool.execute('call-val-4b', { + action: 'persona_clear', + params: { confirm: false } + }); + + const parsed = JSON.parse(result.content[0].text); expect(parsed.success).toBe(true); + expect(parsed.data.status).toBe('cancelled'); + expect(parsed.data.deletedCount).toBe(0); + }); + + it('should reject task_create without task_id', async () => { + const result = await tool.execute('call-val-5', { + action: 'task_create', + params: { description: '测试' } + }); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.success).toBe(false); + expect(parsed.error.message).toContain('task_id'); + }); + + it('should reject task_set_state without state', async () => { + const result = await tool.execute('call-val-6', { + action: 'task_set_state', + params: { task_id: 'T1' } + }); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.success).toBe(false); + expect(parsed.error.message).toContain('state'); + }); + + it('should reject recall without queryIntent and seedEntities', async () => { + const result = await tool.execute('call-val-7', { + action: 'recall', + params: {} + }); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.success).toBe(false); + expect(parsed.error.message).toContain('queryIntent'); + }); + + it('should reject depth out of range', async () => { + const result = await tool.execute('call-val-8', { + action: 'recall', + params: { queryIntent: '测试', depth: 10 } + }); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.success).toBe(false); + expect(parsed.error.message).toContain('depth'); + }); + + it('should reject commit with invalid confidence', async () => { + const result = await tool.execute('call-val-9', { + action: 'commit', + params: { + triplets: [{ subject: 'A', relation: '是', object: 'B', confidence: 2 }] + } + }); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.success).toBe(false); + expect(parsed.error.message).toContain('置信度'); + }); + + it('should reject purge supersede without newRelation', async () => { + const result = await tool.execute('call-val-10', { + action: 'purge', + params: { mode: 'supersede' } + }); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.success).toBe(false); + expect(parsed.error.message).toContain('newRelation'); + }); + + it('should reject missing action', async () => { + const result = await tool.execute('call-val-11', { + action: '', + params: {} + }); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.success).toBe(false); + expect(parsed.error.message).toContain('action'); }); }); });