mirror of
https://gitcode.com/JianFeeeee/TrulyMEM-TrueHumanMEM.git
synced 2026-10-15 12:15:32 +00:00
feat: complete OpenClaw plugin migration with SQLite persistence
- Migrate to OpenClaw Plugin SDK with definePluginEntry and api.registerTool - Rewrite GraphDatabase with better-sqlite3 for persistent storage - Implement recall depth traversal, timeRange filtering, supersede mode - Add archive and cleanup data management features - Fix SKILL.md frontmatter to OpenClaw single-line format (kebab-case names) - Add ToolLimiter for per-turn rate limiting - Add TypeBox parameter schemas for OpenClaw tool registration - Add 110 tests across 4 test files (GraphDatabase, MemoryService, Tool, Limiter) - Update README for OpenClaw plugin installation and usage
This commit is contained in:
@ -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)
|
||||
|
||||
## 使用原则
|
||||
|
||||
27
ts/bundled-skills/graph-memory/persona/SKILL.md
Normal file
27
ts/bundled-skills/graph-memory/persona/SKILL.md
Normal file
@ -0,0 +1,27 @@
|
||||
---
|
||||
name: graph-memory-persona
|
||||
description: "管理 AI 人设 - 更新或清除 AI 角色特征。使用 persona_update 更新、persona_clear 清除"
|
||||
metadata: {"openclaw": {"requires": {"bins": ["node"]}}}
|
||||
user-invocable: true
|
||||
---
|
||||
|
||||
# GraphMemory Persona 人设管理
|
||||
|
||||
管理 AI 的人设/角色特征。
|
||||
|
||||
## 操作
|
||||
|
||||
### 1. persona_update - 更新人设
|
||||
|
||||
更新 AI 的角色特征。
|
||||
|
||||
**参数**:
|
||||
- `attributes`: 属性数组,每个包含 attribute 和 value
|
||||
- `mode`: 更新模式(merge 合并或 replace 替换)
|
||||
|
||||
### 2. persona_clear - 清除人设
|
||||
|
||||
清除 AI 的所有角色特征。
|
||||
|
||||
**参数**:
|
||||
- `confirm`: 确认为 true 才能执行清除
|
||||
44
ts/bundled-skills/graph-memory/task/SKILL.md
Normal file
44
ts/bundled-skills/graph-memory/task/SKILL.md
Normal file
@ -0,0 +1,44 @@
|
||||
---
|
||||
name: graph-memory-task
|
||||
description: "管理连续性任务 - 创建、更新、删除任务节点。使用 task_create 创建、task_set_state 更新状态"
|
||||
metadata: {"openclaw": {"requires": {"bins": ["node"]}}}
|
||||
user-invocable: true
|
||||
---
|
||||
|
||||
# GraphMemory Task 任务管理
|
||||
|
||||
管理长期/连续性任务。
|
||||
|
||||
## 操作
|
||||
|
||||
### 1. task_create - 创建任务
|
||||
|
||||
创建新的任务节点。
|
||||
|
||||
**参数**:
|
||||
- `task_id`: 唯一任务标识
|
||||
- `description`: 任务描述
|
||||
- `info_nodes`: 可选的相关信息节点
|
||||
|
||||
### 2. task_set_state - 设置状态
|
||||
|
||||
更新任务状态。
|
||||
|
||||
**参数**:
|
||||
- `task_id`: 任务ID
|
||||
- `state`: 新状态 (进行中/已完成/已暂停/已取消)
|
||||
|
||||
### 3. task_delete - 删除任务
|
||||
|
||||
删除任务节点。
|
||||
|
||||
**参数**:
|
||||
- `task_id`: 任务ID
|
||||
|
||||
### 4. task_link_info - 关联信息
|
||||
|
||||
将信息节点关联到任务。
|
||||
|
||||
**参数**:
|
||||
- `task_id`: 任务ID
|
||||
- `info_node`: 信息节点
|
||||
@ -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
|
||||
```
|
||||
@ -1,98 +0,0 @@
|
||||
---
|
||||
name: graph_memory_task
|
||||
description: 管理连续性任务 - 创建、更新、删除任务节点
|
||||
when_to_use: 需要创建或管理长期任务时
|
||||
context: inline
|
||||
allowed_tools:
|
||||
- builtin:graph_memory
|
||||
arguments:
|
||||
- name: action
|
||||
type: string
|
||||
required: true
|
||||
enum: [task_create, task_set_state, task_delete, task_link_info]
|
||||
description: 操作类型
|
||||
- name: task_id
|
||||
type: string
|
||||
required: true
|
||||
description: 任务ID
|
||||
- name: description
|
||||
type: string
|
||||
description: 任务描述 (用于 create)
|
||||
- name: state
|
||||
type: string
|
||||
enum: [进行中, 已完成, 已暂停, 已取消]
|
||||
description: 任务状态 (用于 set_state)
|
||||
- name: info_nodes
|
||||
type: array
|
||||
description: 信息节点数组 (用于 create)
|
||||
- name: info_node
|
||||
type: string
|
||||
description: 信息节点 (用于 link_info)
|
||||
user_invocable: true
|
||||
---
|
||||
|
||||
# GraphMemory Task 任务管理
|
||||
|
||||
管理长期/连续性任务。
|
||||
|
||||
## 操作
|
||||
|
||||
### 1. task_create - 创建任务
|
||||
|
||||
创建新的任务节点。
|
||||
|
||||
**参数**:
|
||||
- `task_id`: 唯一任务标识
|
||||
- `description`: 任务描述
|
||||
- `info_nodes`: 可选的相关信息节点
|
||||
|
||||
**示例**:
|
||||
```
|
||||
action: task_create
|
||||
task_id: "Task_学习TypeScript"
|
||||
description: "学习 TypeScript 并完成项目"
|
||||
info_nodes: ["TypeScript文档", "教程链接"]
|
||||
```
|
||||
|
||||
### 2. task_set_state - 设置状态
|
||||
|
||||
更新任务状态。
|
||||
|
||||
**参数**:
|
||||
- `task_id`: 任务ID
|
||||
- `state`: 新状态 (进行中/已完成/已暂停/已取消)
|
||||
|
||||
**示例**:
|
||||
```
|
||||
action: task_set_state
|
||||
task_id: "Task_学习TypeScript"
|
||||
state: "已完成"
|
||||
```
|
||||
|
||||
### 3. task_delete - 删除任务
|
||||
|
||||
删除任务节点。
|
||||
|
||||
**参数**:
|
||||
- `task_id`: 任务ID
|
||||
|
||||
**示例**:
|
||||
```
|
||||
action: task_delete
|
||||
task_id: "Task_学习TypeScript"
|
||||
```
|
||||
|
||||
### 4. task_link_info - 关联信息
|
||||
|
||||
将信息节点关联到任务。
|
||||
|
||||
**参数**:
|
||||
- `task_id`: 任务ID
|
||||
- `info_node`: 信息节点
|
||||
|
||||
**示例**:
|
||||
```
|
||||
action: task_link_info
|
||||
task_id: "Task_学习TypeScript"
|
||||
info_node: "新教程链接"
|
||||
```
|
||||
15
ts/openclaw.plugin.json
Normal file
15
ts/openclaw.plugin.json
Normal file
@ -0,0 +1,15 @@
|
||||
{
|
||||
"id": "graph-memory",
|
||||
"name": "Graph Memory",
|
||||
"description": "让 AI 拥有真正的长期记忆能力 - 基于图数据库的记忆系统",
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"dbPath": {
|
||||
"type": "string",
|
||||
"description": "SQLite 数据库文件路径,默认 graph_memory.db"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
431
ts/package-lock.json
generated
431
ts/package-lock.json
generated
@ -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",
|
||||
|
||||
@ -1,15 +1,29 @@
|
||||
{
|
||||
"name": "trulymem-waterflow",
|
||||
"name": "@trulymem/openclaw-graph-memory",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"openclaw": {
|
||||
"extensions": ["./dist/plugin-entry.js"],
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.3.24-beta.2",
|
||||
"minGatewayVersion": "2026.3.24-beta.2"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.3.24-beta.2",
|
||||
"pluginSdkVersion": "2026.3.24-beta.2"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"test": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@sinclair/typebox": "^0.34.49",
|
||||
"better-sqlite3": "^12.9.0",
|
||||
"yaml": "^2.8.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/node": "^25.5.2",
|
||||
"typescript": "^5.0.0",
|
||||
"vitest": "^2.0.0"
|
||||
|
||||
114
ts/src/plugin-entry.ts
Normal file
114
ts/src/plugin-entry.ts
Normal file
@ -0,0 +1,114 @@
|
||||
/**
|
||||
* OpenClaw Plugin Entry Point
|
||||
*
|
||||
* Registers the GraphMemory tool with OpenClaw's plugin system.
|
||||
* Uses @sinclair/typebox for parameter schema definition.
|
||||
*/
|
||||
import { Type } from '@sinclair/typebox';
|
||||
import { GraphMemoryTool } from './runtime/core/tools/builtin/graph_memory_tool.js';
|
||||
|
||||
// OpenClaw Tool Schema using TypeBox
|
||||
const GraphMemoryToolSchema = Type.Object({
|
||||
action: Type.String({
|
||||
description: '记忆操作类型',
|
||||
enum: [
|
||||
'recall', 'commit', 'purge', 'introspect',
|
||||
'persona_update', 'persona_clear',
|
||||
'task_create', 'task_set_state', 'task_delete', 'task_link_info'
|
||||
]
|
||||
}),
|
||||
params: Type.Object({
|
||||
queryIntent: Type.Optional(Type.String({ description: '搜索意图' })),
|
||||
seedEntities: Type.Optional(Type.Array(Type.String({ description: '实体' }), { description: '种子实体' })),
|
||||
depth: Type.Optional(Type.Number({ description: '检索深度' })),
|
||||
sessionFilter: Type.Optional(Type.String({ description: '会话ID过滤' })),
|
||||
triplets: Type.Optional(Type.Array(
|
||||
Type.Object({
|
||||
subject: Type.String({ description: '主体' }),
|
||||
relation: Type.String({ description: '关系' }),
|
||||
object: Type.String({ description: '客体' }),
|
||||
confidence: Type.Optional(Type.Number({ description: '置信度' }))
|
||||
}, { description: '三元组' }),
|
||||
{ description: '三元组数组' }
|
||||
)),
|
||||
sessionId: Type.Optional(Type.String({ description: '会话ID' })),
|
||||
turnId: Type.Optional(Type.Number({ description: '轮次ID' })),
|
||||
criteria: Type.Optional(Type.Object({
|
||||
subject: Type.Optional(Type.String({ description: '主体' })),
|
||||
target: Type.Optional(Type.String({ description: '客体' })),
|
||||
relation: Type.Optional(Type.String({ description: '关系' })),
|
||||
sessionId: Type.Optional(Type.String({ description: '会话ID' }))
|
||||
}, { description: '删除条件' })),
|
||||
mode: Type.Optional(Type.String({
|
||||
enum: ['soft', 'hard', 'supersede'],
|
||||
description: '删除模式'
|
||||
})),
|
||||
newRelation: Type.Optional(Type.Object({
|
||||
relation: Type.String({ description: '关系' }),
|
||||
target: Type.String({ description: '客体' })
|
||||
}, { description: '新关系(supersede模式)' })),
|
||||
attributes: Type.Optional(Type.Array(
|
||||
Type.Object({
|
||||
attribute: Type.String({ description: '属性名' }),
|
||||
value: Type.String({ description: '属性值' })
|
||||
}, { description: '属性' }),
|
||||
{ description: '属性数组' }
|
||||
)),
|
||||
confirm: Type.Optional(Type.Boolean({ description: '确认清除' })),
|
||||
task_id: Type.Optional(Type.String({ description: '任务ID' })),
|
||||
description: Type.Optional(Type.String({ description: '任务描述' })),
|
||||
state: Type.Optional(Type.String({ description: '任务状态' })),
|
||||
info_nodes: Type.Optional(Type.Array(Type.String({ description: '节点' }), { description: '信息节点' })),
|
||||
info_node: Type.Optional(Type.String({ description: '信息节点' })),
|
||||
days: Type.Optional(Type.Number({ description: '归档天数' })),
|
||||
dry_run: Type.Optional(Type.Boolean({ description: '仅预览不删除' }))
|
||||
}, { description: '操作参数' })
|
||||
});
|
||||
|
||||
/**
|
||||
* Plugin entry point for OpenClaw.
|
||||
*
|
||||
* When loaded as an OpenClaw plugin, this function registers the GraphMemory tool.
|
||||
* For standalone usage, import GraphMemoryTool directly.
|
||||
*/
|
||||
export default function registerGraphMemoryPlugin(api: {
|
||||
registerTool: (tool: {
|
||||
name: string;
|
||||
description: string;
|
||||
parameters: typeof GraphMemoryToolSchema;
|
||||
execute: (id: string, params: Record<string, unknown>) => Promise<{
|
||||
content: Array<{ type: 'text'; text: string }>;
|
||||
}>;
|
||||
}) => void;
|
||||
}): void {
|
||||
const tool = new GraphMemoryTool();
|
||||
|
||||
api.registerTool({
|
||||
name: 'graph_memory',
|
||||
description: '图记忆工具 - 让 AI 拥有真正的长期记忆能力。支持 recall(检索)、commit(写入)、purge(删除)、introspect(状态)、人设管理、任务管理',
|
||||
parameters: GraphMemoryToolSchema,
|
||||
async execute(_id: string, params: Record<string, unknown>): Promise<{
|
||||
content: Array<{ type: 'text'; text: string }>;
|
||||
}> {
|
||||
const result = await tool.handler(params, {
|
||||
toolCallId: _id,
|
||||
workingDirectory: process.cwd(),
|
||||
abortController: { signal: new AbortController().signal },
|
||||
config: {},
|
||||
logger: {
|
||||
info: () => {},
|
||||
warn: () => {},
|
||||
error: () => {},
|
||||
debug: () => {}
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: typeof result === 'string' ? result : JSON.stringify(result)
|
||||
}]
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
@ -1,61 +1,163 @@
|
||||
import type { Entity, Relation, RecallParams, CommitParams, PurgeParams, RecallResult, CommitResult, PurgeResult, MemoryStats } from './types';
|
||||
import Database from 'better-sqlite3';
|
||||
import type {
|
||||
Entity, Relation, RecallParams, CommitParams, PurgeParams,
|
||||
RecallResult, CommitResult, PurgeResult, MemoryStats
|
||||
} from './types';
|
||||
|
||||
export class GraphDatabase {
|
||||
private entities: Map<string, Entity> = new Map();
|
||||
private relations: Map<string, Relation> = new Map();
|
||||
private db: Database.Database;
|
||||
private sessionId: string;
|
||||
|
||||
constructor(sessionId?: string) {
|
||||
constructor(dbPath?: string, sessionId?: string) {
|
||||
this.db = new Database(dbPath || 'graph_memory.db');
|
||||
this.sessionId = sessionId || `session-${Date.now()}`;
|
||||
this.db.pragma('journal_mode = WAL');
|
||||
this.initialize();
|
||||
}
|
||||
|
||||
private initialize(): void {
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS entities (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT UNIQUE NOT NULL,
|
||||
type TEXT DEFAULT 'unknown',
|
||||
mention_count INTEGER DEFAULT 1,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now'))
|
||||
)
|
||||
`);
|
||||
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS relations (
|
||||
id TEXT PRIMARY KEY,
|
||||
source_id TEXT NOT NULL,
|
||||
target_id TEXT NOT NULL,
|
||||
relation_type TEXT NOT NULL,
|
||||
confidence REAL DEFAULT 1.0,
|
||||
status TEXT DEFAULT 'active',
|
||||
session_id TEXT,
|
||||
turn_id INTEGER DEFAULT 0,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now')),
|
||||
date_bucket TEXT,
|
||||
superseded_by TEXT,
|
||||
FOREIGN KEY (source_id) REFERENCES entities(id),
|
||||
FOREIGN KEY (target_id) REFERENCES entities(id)
|
||||
)
|
||||
`);
|
||||
|
||||
this.db.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_entity_name ON entities(name);
|
||||
CREATE INDEX IF NOT EXISTS idx_entity_type ON entities(type);
|
||||
CREATE INDEX IF NOT EXISTS idx_relation_source ON relations(source_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_relation_target ON relations(target_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_relation_type ON relations(relation_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_relation_status ON relations(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_relation_session ON relations(session_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_relation_date ON relations(date_bucket);
|
||||
CREATE INDEX IF NOT EXISTS idx_relation_superseded ON relations(superseded_by);
|
||||
`);
|
||||
}
|
||||
|
||||
async recall(params: RecallParams): Promise<RecallResult> {
|
||||
const { queryIntent, seedEntities, sessionFilter } = params;
|
||||
const { queryIntent, seedEntities, depth = 2, timeRange, sessionFilter } = params;
|
||||
const keywords = queryIntent.split(/[,\s]+/).filter(k => k.length > 0);
|
||||
const entityIds = new Set<string>();
|
||||
const entities: Entity[] = [];
|
||||
const relations: Relation[] = [];
|
||||
const entityIds = new Set<string>();
|
||||
|
||||
for (const keyword of keywords) {
|
||||
const lowerKeyword = keyword.toLowerCase();
|
||||
for (const [_, entity] of this.entities) {
|
||||
if (entity.name.toLowerCase().includes(lowerKeyword)) {
|
||||
if (!entityIds.has(entity.id)) {
|
||||
entityIds.add(entity.id);
|
||||
entities.push(entity);
|
||||
}
|
||||
if (keywords.length === 0 && (!seedEntities || seedEntities.length === 0)) {
|
||||
return { entities: [], relations: [], message: 'No query keywords' };
|
||||
}
|
||||
|
||||
const timeCondition = timeRange?.days
|
||||
? `AND r.created_at >= datetime('now', '-${timeRange.days} days')`
|
||||
: '';
|
||||
const sessionCondition = sessionFilter ? `AND r.session_id = ?` : '';
|
||||
|
||||
if (keywords.length > 0) {
|
||||
const likeConditions = keywords.map(() => `LOWER(e.name) LIKE ?`).join(' OR ');
|
||||
const likeParams = keywords.map(k => `%${k.toLowerCase()}%`);
|
||||
const seedRows = this.db.prepare(
|
||||
`SELECT DISTINCT e.* FROM entities e WHERE ${likeConditions}`
|
||||
).all(...likeParams) as Array<Record<string, unknown>>;
|
||||
|
||||
for (const row of seedRows) {
|
||||
const id = row.id as string;
|
||||
if (!entityIds.has(id)) {
|
||||
entityIds.add(id);
|
||||
entities.push(this.rowToEntity(row));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (seedEntities && seedEntities.length > 0) {
|
||||
for (const seedName of seedEntities) {
|
||||
for (const [_, entity] of this.entities) {
|
||||
if (entity.name.toLowerCase() === seedName.toLowerCase()) {
|
||||
if (!entityIds.has(entity.id)) {
|
||||
entityIds.add(entity.id);
|
||||
entities.push(entity);
|
||||
}
|
||||
}
|
||||
const placeholders = seedEntities.map(() => '?').join(',');
|
||||
const exactRows = this.db.prepare(
|
||||
`SELECT * FROM entities WHERE LOWER(name) IN (${placeholders})`
|
||||
).all(...seedEntities.map(s => s.toLowerCase())) as Array<Record<string, unknown>>;
|
||||
|
||||
for (const row of exactRows) {
|
||||
const id = row.id as string;
|
||||
if (!entityIds.has(id)) {
|
||||
entityIds.add(id);
|
||||
entities.push(this.rowToEntity(row));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const [_, relation] of this.relations) {
|
||||
if (entityIds.has(relation.sourceId) || entityIds.has(relation.targetId)) {
|
||||
if (relation.status === 'active') {
|
||||
if (!sessionFilter || relation.sessionId === sessionFilter) {
|
||||
relations.push(relation);
|
||||
}
|
||||
const visited = new Set<string>(entityIds);
|
||||
let currentLevel = new Set<string>(entityIds);
|
||||
|
||||
for (let d = 1; d < depth; d++) {
|
||||
const nextLevel = new Set<string>();
|
||||
const idsArray = Array.from(currentLevel);
|
||||
if (idsArray.length === 0) break;
|
||||
|
||||
const placeholders = idsArray.map(() => '?').join(',');
|
||||
const neighborRows = this.db.prepare(`
|
||||
SELECT DISTINCT e.* FROM entities e
|
||||
JOIN relations r ON (r.source_id = e.id OR r.target_id = e.id)
|
||||
WHERE (r.source_id IN (${placeholders}) OR r.target_id IN (${placeholders}))
|
||||
AND r.status = 'active'
|
||||
${timeCondition}
|
||||
${sessionCondition}
|
||||
LIMIT 200
|
||||
`).all(...idsArray, ...idsArray, ...(sessionFilter ? [sessionFilter] : [])) as Array<Record<string, unknown>>;
|
||||
|
||||
for (const row of neighborRows) {
|
||||
const id = row.id as string;
|
||||
if (!visited.has(id)) {
|
||||
visited.add(id);
|
||||
nextLevel.add(id);
|
||||
entities.push(this.rowToEntity(row));
|
||||
}
|
||||
}
|
||||
currentLevel = nextLevel;
|
||||
}
|
||||
|
||||
if (entityIds.size > 0) {
|
||||
const allIds = Array.from(entityIds);
|
||||
const placeholders = allIds.map(() => '?').join(',');
|
||||
const relationRows = this.db.prepare(`
|
||||
SELECT r.*, e1.name as source_name, e2.name as target_name
|
||||
FROM relations r
|
||||
JOIN entities e1 ON r.source_id = e1.id
|
||||
JOIN entities e2 ON r.target_id = e2.id
|
||||
WHERE (r.source_id IN (${placeholders}) OR r.target_id IN (${placeholders}))
|
||||
AND r.status = 'active'
|
||||
${timeCondition}
|
||||
${sessionCondition}
|
||||
ORDER BY r.created_at DESC
|
||||
LIMIT 100
|
||||
`).all(...allIds, ...allIds, ...(sessionFilter ? [sessionFilter] : [])) as Array<Record<string, unknown>>;
|
||||
|
||||
for (const row of relationRows) {
|
||||
relations.push(this.rowToRelation(row));
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
entities,
|
||||
relations,
|
||||
message: `找到 ${entities.length} 个实体, ${relations.length} 条关系`
|
||||
};
|
||||
return { entities, relations, message: `Found ${entities.length} entities, ${relations.length} relations` };
|
||||
}
|
||||
|
||||
async commit(params: CommitParams): Promise<CommitResult> {
|
||||
@ -63,28 +165,47 @@ export class GraphDatabase {
|
||||
let createdEntities = 0;
|
||||
let createdRelations = 0;
|
||||
|
||||
const insertRelation = this.db.prepare(`
|
||||
INSERT INTO relations (id, source_id, target_id, relation_type, confidence, session_id, turn_id, status, date_bucket)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, 'active', ?)
|
||||
`);
|
||||
|
||||
const insertEntity = this.db.prepare(`
|
||||
INSERT OR IGNORE INTO entities (id, name, type) VALUES (?, ?, 'unknown')
|
||||
`);
|
||||
|
||||
const updateEntity = this.db.prepare(`
|
||||
UPDATE entities SET mention_count = mention_count + 1, updated_at = datetime('now') WHERE name = ?
|
||||
`);
|
||||
|
||||
const getEntity = this.db.prepare(`SELECT id FROM entities WHERE name = ?`);
|
||||
|
||||
for (const triplet of triplets) {
|
||||
const sourceId = this.upsertEntity(triplet.subject);
|
||||
const targetId = this.upsertEntity(triplet.object);
|
||||
const dateBucket = new Date().toISOString().split('T')[0] ?? '';
|
||||
|
||||
const relationId = this.generateId();
|
||||
const now = new Date();
|
||||
const relation: Relation = {
|
||||
id: relationId,
|
||||
sourceId,
|
||||
targetId,
|
||||
relationType: triplet.relation,
|
||||
confidence: triplet.confidence || 1.0,
|
||||
status: 'active',
|
||||
sessionId: sessionId || this.sessionId,
|
||||
turnId: turnId || 0,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
dateBucket: this.getDateBucket(now)
|
||||
};
|
||||
let sourceId = (getEntity.get(triplet.subject) as { id: string } | undefined)?.id;
|
||||
if (sourceId) {
|
||||
updateEntity.run(triplet.subject);
|
||||
} else {
|
||||
sourceId = this.generateId();
|
||||
insertEntity.run(sourceId, triplet.subject);
|
||||
}
|
||||
createdEntities++;
|
||||
|
||||
this.relations.set(relationId, relation);
|
||||
createdEntities += 2;
|
||||
let targetId = (getEntity.get(triplet.object) as { id: string } | undefined)?.id;
|
||||
if (targetId) {
|
||||
updateEntity.run(triplet.object);
|
||||
} else {
|
||||
targetId = this.generateId();
|
||||
insertEntity.run(targetId, triplet.object);
|
||||
}
|
||||
createdEntities++;
|
||||
|
||||
insertRelation.run(
|
||||
this.generateId(), sourceId, targetId, triplet.relation,
|
||||
triplet.confidence || 1.0, sessionId || this.sessionId,
|
||||
turnId || 0, dateBucket
|
||||
);
|
||||
createdRelations++;
|
||||
}
|
||||
|
||||
@ -92,105 +213,129 @@ export class GraphDatabase {
|
||||
}
|
||||
|
||||
async purge(params: PurgeParams): Promise<PurgeResult> {
|
||||
const { criteria, mode = 'soft' } = params;
|
||||
const { criteria, mode = 'soft', newRelation } = params;
|
||||
let deleted = 0;
|
||||
if (!criteria) return { deleted: 0, mode };
|
||||
|
||||
for (const [id, relation] of this.relations) {
|
||||
if (relation.status !== 'active') continue;
|
||||
const conditions: string[] = ["status = 'active'"];
|
||||
const values: unknown[] = [];
|
||||
|
||||
if (!criteria) {
|
||||
continue;
|
||||
}
|
||||
if (criteria.subject) {
|
||||
conditions.push(`source_id IN (SELECT id FROM entities WHERE LOWER(name) = ?)`);
|
||||
values.push(criteria.subject.toLowerCase());
|
||||
}
|
||||
if (criteria.target) {
|
||||
conditions.push(`target_id IN (SELECT id FROM entities WHERE LOWER(name) = ?)`);
|
||||
values.push(criteria.target.toLowerCase());
|
||||
}
|
||||
if (criteria.relation) {
|
||||
conditions.push(`LOWER(relation_type) = ?`);
|
||||
values.push(criteria.relation.toLowerCase());
|
||||
}
|
||||
if (criteria.sessionId) {
|
||||
conditions.push(`session_id = ?`);
|
||||
values.push(criteria.sessionId);
|
||||
}
|
||||
|
||||
let matches = true;
|
||||
if (criteria.subject) {
|
||||
const sourceEntity = this.entities.get(relation.sourceId);
|
||||
matches = sourceEntity?.name.toLowerCase() === criteria.subject.toLowerCase();
|
||||
}
|
||||
if (matches && criteria.target) {
|
||||
const targetEntity = this.entities.get(relation.targetId);
|
||||
matches = targetEntity?.name.toLowerCase() === criteria.target.toLowerCase();
|
||||
}
|
||||
if (matches && criteria.relation) {
|
||||
matches = relation.relationType.toLowerCase() === criteria.relation.toLowerCase();
|
||||
}
|
||||
if (matches && criteria.sessionId) {
|
||||
matches = relation.sessionId === criteria.sessionId;
|
||||
}
|
||||
const whereClause = conditions.join(' AND ');
|
||||
|
||||
if (matches) {
|
||||
if (mode === 'hard') {
|
||||
this.relations.delete(id);
|
||||
} else {
|
||||
relation.status = 'deleted';
|
||||
relation.updatedAt = new Date();
|
||||
}
|
||||
if (mode === 'supersede' && newRelation) {
|
||||
const ids = this.db.prepare(
|
||||
`SELECT id, source_id, target_id FROM relations WHERE ${whereClause}`
|
||||
).all(...values) as Array<{ id: string; source_id: string; target_id: string }>;
|
||||
|
||||
for (const row of ids) {
|
||||
const newId = this.generateId();
|
||||
this.db.prepare(`
|
||||
INSERT INTO relations (id, source_id, target_id, relation_type, confidence, session_id, turn_id, status, date_bucket, superseded_by)
|
||||
VALUES (?, ?, ?, ?, 1.0, ?, 0, 'active', ?, ?)
|
||||
`).run(newId, row.source_id, row.target_id, newRelation.relation, this.sessionId, new Date().toISOString().split('T')[0], row.id);
|
||||
|
||||
this.db.prepare(`
|
||||
UPDATE relations SET status = 'superseded', superseded_by = ?, updated_at = datetime('now') WHERE id = ?
|
||||
`).run(newId, row.id);
|
||||
deleted++;
|
||||
}
|
||||
} else if (mode === 'hard') {
|
||||
const result = this.db.prepare(`DELETE FROM relations WHERE ${whereClause}`).run(...values);
|
||||
deleted = result.changes;
|
||||
} else {
|
||||
const result = this.db.prepare(`UPDATE relations SET status = 'deleted', updated_at = datetime('now') WHERE ${whereClause}`).run(...values);
|
||||
deleted = result.changes;
|
||||
}
|
||||
|
||||
return { deleted, mode };
|
||||
}
|
||||
|
||||
async introspect(): Promise<MemoryStats> {
|
||||
let entityCount = 0;
|
||||
for (const [_, entity] of this.entities) {
|
||||
if (!this.isEntityDeleted(entity.id)) entityCount++;
|
||||
}
|
||||
|
||||
let relationCount = 0;
|
||||
for (const [_, relation] of this.relations) {
|
||||
if (relation.status === 'active') relationCount++;
|
||||
}
|
||||
|
||||
const entityCount = (this.db.prepare(`SELECT COUNT(*) as c FROM entities`).get() as { c: number }).c;
|
||||
const relationCount = (this.db.prepare(`SELECT COUNT(*) as c FROM relations WHERE status = 'active'`).get() as { c: number }).c;
|
||||
return { entityCount, relationCount, sessionId: this.sessionId };
|
||||
}
|
||||
|
||||
private upsertEntity(name: string): string {
|
||||
for (const [id, entity] of this.entities) {
|
||||
if (entity.name === name && !this.isEntityDeleted(id)) {
|
||||
entity.mentionCount++;
|
||||
entity.updatedAt = new Date();
|
||||
return id;
|
||||
}
|
||||
}
|
||||
|
||||
const id = this.generateId();
|
||||
const now = new Date();
|
||||
const entity: Entity = {
|
||||
id,
|
||||
name,
|
||||
type: 'unknown',
|
||||
mentionCount: 1,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
};
|
||||
this.entities.set(id, entity);
|
||||
return id;
|
||||
async archive(days: number = 30): Promise<{ archived: number }> {
|
||||
const result = this.db.prepare(`
|
||||
UPDATE relations SET status = 'archived', updated_at = datetime('now')
|
||||
WHERE status = 'active' AND created_at < datetime('now', '-' || ? || ' days')
|
||||
`).run(days);
|
||||
return { archived: result.changes };
|
||||
}
|
||||
|
||||
private isEntityDeleted(entityId: string): boolean {
|
||||
for (const [_, relation] of this.relations) {
|
||||
if ((relation.sourceId === entityId || relation.targetId === entityId) && relation.status === 'deleted') {
|
||||
return true;
|
||||
}
|
||||
async cleanup(dryRun: boolean = true): Promise<{ deleted_relations: number; deleted_entities: number; details?: string[] }> {
|
||||
const details: string[] = [];
|
||||
const oldRelations = this.db.prepare(`
|
||||
SELECT id FROM relations WHERE status = 'deleted' AND updated_at < datetime('now', '-90 days')
|
||||
`).all() as Array<{ id: string }>;
|
||||
|
||||
if (dryRun) {
|
||||
details.push(`Will delete ${oldRelations.length} old deleted relations`);
|
||||
const orphaned = this.db.prepare(`
|
||||
SELECT e.id, e.name FROM entities e
|
||||
WHERE e.id NOT IN (SELECT DISTINCT source_id FROM relations WHERE status != 'deleted')
|
||||
AND e.id NOT IN (SELECT DISTINCT target_id FROM relations WHERE status != 'deleted')
|
||||
`).all() as Array<{ id: string; name: string }>;
|
||||
details.push(`Will delete ${orphaned.length} orphaned entities`);
|
||||
return { deleted_relations: oldRelations.length, deleted_entities: orphaned.length, details };
|
||||
}
|
||||
return false;
|
||||
|
||||
const deleteResult = this.db.prepare(`
|
||||
DELETE FROM relations WHERE status = 'deleted' AND updated_at < datetime('now', '-90 days')
|
||||
`).run();
|
||||
|
||||
const deleteOrphans = this.db.prepare(`
|
||||
DELETE FROM entities
|
||||
WHERE id NOT IN (SELECT DISTINCT source_id FROM relations)
|
||||
AND id NOT IN (SELECT DISTINCT target_id FROM relations)
|
||||
`).run();
|
||||
|
||||
return { deleted_relations: deleteResult.changes, deleted_entities: deleteOrphans.changes };
|
||||
}
|
||||
|
||||
private rowToEntity(row: Record<string, unknown>): Entity {
|
||||
return {
|
||||
id: row.id as string, name: row.name as string,
|
||||
type: (row.type as string) || 'unknown', mentionCount: (row.mention_count as number) || 1,
|
||||
createdAt: new Date(row.created_at as string), updatedAt: new Date(row.updated_at as string)
|
||||
};
|
||||
}
|
||||
|
||||
private rowToRelation(row: Record<string, unknown>): Relation {
|
||||
return {
|
||||
id: row.id as string, sourceId: row.source_id as string, targetId: row.target_id as string,
|
||||
relationType: row.relation_type as string, confidence: (row.confidence as number) || 1.0,
|
||||
status: (row.status as Relation['status']) || 'active',
|
||||
sessionId: (row.session_id as string) || this.sessionId,
|
||||
turnId: (row.turn_id as number) || 0,
|
||||
createdAt: new Date(row.created_at as string), updatedAt: new Date(row.updated_at as string),
|
||||
dateBucket: (row.date_bucket as string) || ''
|
||||
};
|
||||
}
|
||||
|
||||
private generateId(): string {
|
||||
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
return `${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
|
||||
}
|
||||
|
||||
private getDateBucket(date: Date): string {
|
||||
return date.toISOString().split('T')[0] ?? '';
|
||||
}
|
||||
|
||||
setSessionId(sessionId: string): void {
|
||||
this.sessionId = sessionId;
|
||||
}
|
||||
|
||||
getSessionId(): string {
|
||||
return this.sessionId;
|
||||
}
|
||||
setSessionId(sessionId: string): void { this.sessionId = sessionId; }
|
||||
getSessionId(): string { return this.sessionId; }
|
||||
close(): void { this.db.close(); }
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import type { Tool, ToolCategory, PermissionLevel, ToolInputSchema, ToolExecutionContext, ToolOutput } from '../tool_interface';
|
||||
import { GraphDatabase } from '../../graph_memory/graph_database';
|
||||
import { MemoryService } from '../../graph_memory/memory_service';
|
||||
import { ToolLimiter } from '../tool_limiter';
|
||||
|
||||
const GRAPH_MEMORY_TOOL_ID = 'builtin:graph_memory';
|
||||
|
||||
@ -14,6 +15,10 @@ const GRAPH_MEMORY_TOOL_DESCRIPTION = `图记忆工具 - 让 AI 拥有真正的
|
||||
- persona_update/clear: 人设管理
|
||||
- task_create/set_state/delete: 任务管理`;
|
||||
|
||||
export interface OpenClawToolResult {
|
||||
content: Array<{ type: 'text'; text: string }>;
|
||||
}
|
||||
|
||||
export class GraphMemoryTool implements Tool {
|
||||
readonly id = GRAPH_MEMORY_TOOL_ID;
|
||||
readonly name = 'GraphMemory';
|
||||
@ -27,7 +32,7 @@ export class GraphMemoryTool implements Tool {
|
||||
action: {
|
||||
type: 'string',
|
||||
enum: [
|
||||
'recall', 'commit', 'purge', 'introspect',
|
||||
'recall', 'commit', 'purge', 'introspect', 'archive', 'cleanup',
|
||||
'persona_update', 'persona_clear',
|
||||
'task_create', 'task_set_state', 'task_delete', 'task_link_info'
|
||||
],
|
||||
@ -68,6 +73,14 @@ export class GraphMemoryTool implements Tool {
|
||||
description: '删除条件'
|
||||
},
|
||||
mode: { type: 'string', enum: ['soft', 'hard', 'supersede'], description: '删除模式' },
|
||||
newRelation: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
relation: { type: 'string', description: '关系' },
|
||||
target: { type: 'string', description: '客体' }
|
||||
},
|
||||
description: '新关系(supersede模式)'
|
||||
},
|
||||
attributes: {
|
||||
type: 'array',
|
||||
items: {
|
||||
@ -85,7 +98,9 @@ export class GraphMemoryTool implements Tool {
|
||||
description: { type: 'string', description: '任务描述' },
|
||||
state: { type: 'string', description: '任务状态' },
|
||||
info_nodes: { type: 'array', items: { type: 'string', description: '节点' }, description: '信息节点' },
|
||||
info_node: { type: 'string', description: '信息节点' }
|
||||
info_node: { type: 'string', description: '信息节点' },
|
||||
days: { type: 'number', description: '归档天数' },
|
||||
dry_run: { type: 'boolean', description: '仅预览不删除' }
|
||||
}
|
||||
}
|
||||
},
|
||||
@ -94,10 +109,12 @@ export class GraphMemoryTool implements Tool {
|
||||
|
||||
private db: GraphDatabase;
|
||||
private service: MemoryService;
|
||||
private limiter: ToolLimiter;
|
||||
|
||||
constructor(sessionId?: string) {
|
||||
this.db = new GraphDatabase(sessionId);
|
||||
constructor(dbPath?: string, sessionId?: string) {
|
||||
this.db = new GraphDatabase(dbPath, sessionId);
|
||||
this.service = new MemoryService(this.db);
|
||||
this.limiter = new ToolLimiter();
|
||||
}
|
||||
|
||||
async handler(params: Record<string, unknown>, _context: ToolExecutionContext): Promise<ToolOutput> {
|
||||
@ -118,7 +135,30 @@ export class GraphMemoryTool implements Tool {
|
||||
}
|
||||
}
|
||||
|
||||
async execute(_toolCallId: string, params: Record<string, unknown>): Promise<OpenClawToolResult> {
|
||||
const result = await this.handler(params, {
|
||||
toolCallId: _toolCallId,
|
||||
workingDirectory: process.cwd(),
|
||||
abortController: { signal: new AbortController().signal },
|
||||
config: {},
|
||||
logger: { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} }
|
||||
});
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: typeof result === 'string' ? result : JSON.stringify(result)
|
||||
}]
|
||||
};
|
||||
}
|
||||
|
||||
private async executeAction(action: string, params: Record<string, unknown>): Promise<unknown> {
|
||||
const [allowed, reason] = this.limiter.canCall(action);
|
||||
if (!allowed) {
|
||||
throw new Error(reason);
|
||||
}
|
||||
this.limiter.recordCall(action);
|
||||
|
||||
switch (action) {
|
||||
case 'recall':
|
||||
return this.service.recall({
|
||||
@ -138,7 +178,8 @@ export class GraphMemoryTool implements Tool {
|
||||
case 'purge':
|
||||
return this.service.purge({
|
||||
criteria: params.criteria as { subject?: string | undefined; target?: string | undefined; relation?: string | undefined; sessionId?: string | undefined } | undefined,
|
||||
mode: params.mode as 'soft' | 'hard' | 'supersede' | undefined
|
||||
mode: params.mode as 'soft' | 'hard' | 'supersede' | undefined,
|
||||
newRelation: params.newRelation as { relation: string; target: string } | undefined
|
||||
});
|
||||
|
||||
case 'introspect':
|
||||
@ -179,12 +220,28 @@ export class GraphMemoryTool implements Tool {
|
||||
info_node: params.info_node as string
|
||||
});
|
||||
|
||||
case 'archive':
|
||||
return this.service.archive(params.days as number | undefined);
|
||||
|
||||
case 'cleanup':
|
||||
return this.service.cleanup(params.dry_run as boolean | undefined);
|
||||
|
||||
default:
|
||||
throw new Error(`Unknown action: ${action}`);
|
||||
}
|
||||
}
|
||||
|
||||
resetLimiter(): void {
|
||||
this.limiter.reset();
|
||||
}
|
||||
|
||||
getLimiterSummary(): string {
|
||||
return this.limiter.getSummary();
|
||||
}
|
||||
}
|
||||
|
||||
export function createGraphMemoryTool(sessionId?: string): GraphMemoryTool {
|
||||
return new GraphMemoryTool(sessionId);
|
||||
export function createGraphMemoryTool(dbPath?: string, sessionId?: string): GraphMemoryTool {
|
||||
return new GraphMemoryTool(dbPath, sessionId);
|
||||
}
|
||||
|
||||
export { ToolLimiter } from '../tool_limiter';
|
||||
|
||||
139
ts/src/runtime/core/tools/tool_limiter.ts
Normal file
139
ts/src/runtime/core/tools/tool_limiter.ts
Normal file
@ -0,0 +1,139 @@
|
||||
export interface ToolLimits {
|
||||
persona_query_max: number;
|
||||
persona_update_max: number;
|
||||
task_query_max: number;
|
||||
task_update_max: number;
|
||||
memory_query_max: number;
|
||||
memory_update_max: number;
|
||||
}
|
||||
|
||||
export interface ToolCallCount {
|
||||
persona_query: number;
|
||||
persona_update: number;
|
||||
task_query: number;
|
||||
task_update: number;
|
||||
memory_query: number;
|
||||
memory_update: number;
|
||||
}
|
||||
|
||||
const DEFAULT_LIMITS: ToolLimits = {
|
||||
persona_query_max: 1,
|
||||
persona_update_max: 1,
|
||||
task_query_max: 4,
|
||||
task_update_max: 5,
|
||||
memory_query_max: 20,
|
||||
memory_update_max: 10,
|
||||
};
|
||||
|
||||
export class ToolLimiter {
|
||||
limits: ToolLimits;
|
||||
counts: ToolCallCount;
|
||||
|
||||
constructor(limits?: Partial<ToolLimits>) {
|
||||
this.limits = { ...DEFAULT_LIMITS, ...limits };
|
||||
this.counts = {
|
||||
persona_query: 0,
|
||||
persona_update: 0,
|
||||
task_query: 0,
|
||||
task_update: 0,
|
||||
memory_query: 0,
|
||||
memory_update: 0,
|
||||
};
|
||||
}
|
||||
|
||||
private classifyTool(action: string): [string, string] {
|
||||
switch (action) {
|
||||
case 'persona_update':
|
||||
case 'persona_clear':
|
||||
return ['persona', 'update'];
|
||||
case 'task_create':
|
||||
case 'task_set_state':
|
||||
case 'task_delete':
|
||||
case 'task_link_info':
|
||||
return ['task', 'update'];
|
||||
case 'recall':
|
||||
case 'introspect':
|
||||
return ['memory', 'query'];
|
||||
case 'commit':
|
||||
case 'purge':
|
||||
case 'archive':
|
||||
case 'cleanup':
|
||||
return ['memory', 'update'];
|
||||
default:
|
||||
return ['memory', 'update'];
|
||||
}
|
||||
}
|
||||
|
||||
canCall(action: string): [boolean, string] {
|
||||
const [category, operation] = this.classifyTool(action);
|
||||
|
||||
if (category === 'persona') {
|
||||
if (operation === 'query') {
|
||||
if (this.counts.persona_query >= this.limits.persona_query_max) {
|
||||
return [false, `人设图查询次数已达上限(${this.limits.persona_query_max}次)`];
|
||||
}
|
||||
} else {
|
||||
if (this.counts.persona_update >= this.limits.persona_update_max) {
|
||||
return [false, `人设图修改次数已达上限(${this.limits.persona_update_max}次)`];
|
||||
}
|
||||
}
|
||||
} else if (category === 'task') {
|
||||
if (operation === 'query') {
|
||||
if (this.counts.task_query >= this.limits.task_query_max) {
|
||||
return [false, `工作记忆链查询次数已达上限(${this.limits.task_query_max}次)`];
|
||||
}
|
||||
} else {
|
||||
if (this.counts.task_update >= this.limits.task_update_max) {
|
||||
return [false, `工作记忆链修改次数已达上限(${this.limits.task_update_max}次)`];
|
||||
}
|
||||
}
|
||||
} else if (category === 'memory') {
|
||||
if (operation === 'query') {
|
||||
if (this.counts.memory_query >= this.limits.memory_query_max) {
|
||||
return [false, `一般记忆查询次数已达上限(${this.limits.memory_query_max}次)`];
|
||||
}
|
||||
} else {
|
||||
if (this.counts.memory_update >= this.limits.memory_update_max) {
|
||||
return [false, `一般记忆修改次数已达上限(${this.limits.memory_update_max}次)`];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [true, '允许调用'];
|
||||
}
|
||||
|
||||
recordCall(action: string): void {
|
||||
const [category, operation] = this.classifyTool(action);
|
||||
|
||||
if (category === 'persona') {
|
||||
if (operation === 'query') this.counts.persona_query++;
|
||||
else this.counts.persona_update++;
|
||||
} else if (category === 'task') {
|
||||
if (operation === 'query') this.counts.task_query++;
|
||||
else this.counts.task_update++;
|
||||
} else if (category === 'memory') {
|
||||
if (operation === 'query') this.counts.memory_query++;
|
||||
else this.counts.memory_update++;
|
||||
}
|
||||
}
|
||||
|
||||
getSummary(): string {
|
||||
const lines = [
|
||||
`人设图: 查询${this.counts.persona_query}/${this.limits.persona_query_max}次, 修改${this.counts.persona_update}/${this.limits.persona_update_max}次`,
|
||||
`工作记忆链: 查询${this.counts.task_query}/${this.limits.task_query_max}次, 修改${this.counts.task_update}/${this.limits.task_update_max}次`,
|
||||
`一般记忆: 查询${this.counts.memory_query}/${this.limits.memory_query_max}次, 修改${this.counts.memory_update}/${this.limits.memory_update_max}次`,
|
||||
];
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.counts = {
|
||||
persona_query: 0,
|
||||
persona_update: 0,
|
||||
task_query: 0,
|
||||
task_update: 0,
|
||||
memory_query: 0,
|
||||
memory_update: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
222
ts/tests/runtime/core/graph_memory/graph_database.test.ts
Normal file
222
ts/tests/runtime/core/graph_memory/graph_database.test.ts
Normal file
@ -0,0 +1,222 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { GraphDatabase } from '/home/program/TrulyMEM-TrueHumanMEM/ts/dist/runtime/core/graph_memory/graph_database.js';
|
||||
|
||||
const TEST_DB_PATH = '/tmp/test_graph_memory.db';
|
||||
|
||||
describe('GraphDatabase', () => {
|
||||
let db: GraphDatabase;
|
||||
|
||||
beforeEach(async () => {
|
||||
try {
|
||||
const fs = await import('fs');
|
||||
if (fs.existsSync(TEST_DB_PATH)) {
|
||||
fs.unlinkSync(TEST_DB_PATH);
|
||||
}
|
||||
if (fs.existsSync(`${TEST_DB_PATH}-wal`)) {
|
||||
fs.unlinkSync(`${TEST_DB_PATH}-wal`);
|
||||
}
|
||||
if (fs.existsSync(`${TEST_DB_PATH}-shm`)) {
|
||||
fs.unlinkSync(`${TEST_DB_PATH}-shm`);
|
||||
}
|
||||
} catch {}
|
||||
db = new GraphDatabase(TEST_DB_PATH);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (db && typeof db.close === 'function') {
|
||||
db.close();
|
||||
}
|
||||
});
|
||||
|
||||
describe('commit', () => {
|
||||
it('creates entities and relations', async () => {
|
||||
const result = await db.commit({
|
||||
triplets: [
|
||||
{ subject: 'Alice', relation: 'knows', object: 'Bob' }
|
||||
]
|
||||
});
|
||||
|
||||
expect(result.createdEntities).toBe(2);
|
||||
expect(result.createdRelations).toBe(1);
|
||||
});
|
||||
|
||||
it('handles batch multiple triplets', async () => {
|
||||
const result = await db.commit({
|
||||
triplets: [
|
||||
{ subject: '用户', relation: '喜欢', object: '编程' },
|
||||
{ subject: '用户', relation: '正在学习', object: 'TypeScript' },
|
||||
{ subject: 'TypeScript', relation: '是', object: '语言' }
|
||||
]
|
||||
});
|
||||
|
||||
expect(result.createdEntities).toBe(6);
|
||||
expect(result.createdRelations).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('recall', () => {
|
||||
beforeEach(async () => {
|
||||
await db.commit({
|
||||
triplets: [
|
||||
{ subject: '用户', relation: '喜欢', object: '编程' },
|
||||
{ subject: '用户', relation: '正在学习', object: 'TypeScript' },
|
||||
{ subject: 'TypeScript', relation: '是', object: '语言' },
|
||||
{ subject: 'Alice', relation: 'knows', object: 'Bob' }
|
||||
]
|
||||
});
|
||||
});
|
||||
|
||||
it('keyword search finds matching entities', async () => {
|
||||
const result = await db.recall({ queryIntent: '用户' });
|
||||
|
||||
expect(result.entities.length).toBeGreaterThan(0);
|
||||
expect(result.entities.some(e => e.name === '用户')).toBe(true);
|
||||
});
|
||||
|
||||
it('seedEntities finds exact matches', async () => {
|
||||
const result = await db.recall({
|
||||
queryIntent: 'test',
|
||||
seedEntities: ['用户']
|
||||
});
|
||||
|
||||
expect(result.entities.length).toBeGreaterThan(0);
|
||||
expect(result.entities.some(e => e.name === '用户')).toBe(true);
|
||||
});
|
||||
|
||||
it('sessionFilter filters by session', async () => {
|
||||
await db.commit({
|
||||
triplets: [
|
||||
{ subject: 'Test', relation: 'is', object: 'Temp' }
|
||||
],
|
||||
sessionId: 'test-session'
|
||||
});
|
||||
|
||||
const result = await db.recall({
|
||||
queryIntent: 'Test',
|
||||
sessionFilter: 'test-session'
|
||||
});
|
||||
|
||||
expect(result.entities.some(e => e.name === 'Test')).toBe(true);
|
||||
});
|
||||
|
||||
it('finds related entities through relations', async () => {
|
||||
const result = await db.recall({
|
||||
queryIntent: '编程'
|
||||
});
|
||||
|
||||
expect(result.relations.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('purge', () => {
|
||||
beforeEach(async () => {
|
||||
await db.commit({
|
||||
triplets: [
|
||||
{ subject: 'ToDelete', relation: 'is', object: 'Test' }
|
||||
]
|
||||
});
|
||||
});
|
||||
|
||||
it('soft delete marks relations as deleted', async () => {
|
||||
const result = await db.purge({
|
||||
criteria: { subject: 'ToDelete' },
|
||||
mode: 'soft'
|
||||
});
|
||||
|
||||
expect(result.deleted).toBe(1);
|
||||
expect(result.mode).toBe('soft');
|
||||
});
|
||||
|
||||
it('hard delete removes relations permanently', async () => {
|
||||
const result = await db.purge({
|
||||
criteria: { subject: 'ToDelete' },
|
||||
mode: 'hard'
|
||||
});
|
||||
|
||||
expect(result.deleted).toBe(1);
|
||||
expect(result.mode).toBe('hard');
|
||||
});
|
||||
|
||||
it('purge filters by target criteria', async () => {
|
||||
const result = await db.purge({
|
||||
criteria: { target: 'Test' },
|
||||
mode: 'soft'
|
||||
});
|
||||
|
||||
expect(result.deleted).toBe(1);
|
||||
});
|
||||
|
||||
it('purge filters by relation criteria', async () => {
|
||||
const result = await db.purge({
|
||||
criteria: { relation: 'is' },
|
||||
mode: 'soft'
|
||||
});
|
||||
|
||||
expect(result.deleted).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('introspect', () => {
|
||||
it('returns entity and relation counts', async () => {
|
||||
await db.commit({
|
||||
triplets: [
|
||||
{ subject: 'Entity1', relation: 'relates', object: 'Entity2' }
|
||||
]
|
||||
});
|
||||
|
||||
const stats = await db.introspect();
|
||||
|
||||
expect(stats.entityCount).toBe(2);
|
||||
expect(stats.relationCount).toBe(1);
|
||||
expect(stats.sessionId).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('empty query handling', () => {
|
||||
it('returns empty result for query with no matches', async () => {
|
||||
const result = await db.recall({ queryIntent: 'xyznonexistent123' });
|
||||
|
||||
expect(result.entities).toEqual([]);
|
||||
expect(result.relations).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('duplicate entity handling', () => {
|
||||
it('upserts existing entities (increments mention count)', async () => {
|
||||
await db.commit({
|
||||
triplets: [
|
||||
{ subject: 'Duplicate', relation: 'is', object: 'First' }
|
||||
]
|
||||
});
|
||||
|
||||
const stats1 = await db.introspect();
|
||||
const entityBefore = stats1.entityCount;
|
||||
|
||||
await db.commit({
|
||||
triplets: [
|
||||
{ subject: 'Duplicate', relation: 'is', object: 'Second' }
|
||||
]
|
||||
});
|
||||
|
||||
const stats2 = await db.introspect();
|
||||
const entityAfter = stats2.entityCount;
|
||||
|
||||
expect(entityAfter).toBeLessThan(entityBefore + 2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setSessionId and getSessionId', () => {
|
||||
it('setSessionId updates session id', async () => {
|
||||
db.setSessionId('new-session-id');
|
||||
|
||||
expect(db.getSessionId()).toBe('new-session-id');
|
||||
});
|
||||
|
||||
it('getSessionId returns current session id', async () => {
|
||||
const sessionId = db.getSessionId();
|
||||
|
||||
expect(sessionId).toBeDefined();
|
||||
expect(typeof sessionId).toBe('string');
|
||||
});
|
||||
});
|
||||
});
|
||||
242
ts/tests/runtime/core/graph_memory/memory_service.test.ts
Normal file
242
ts/tests/runtime/core/graph_memory/memory_service.test.ts
Normal file
@ -0,0 +1,242 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { MemoryService } from '../../../../dist/runtime/core/graph_memory/memory_service.js';
|
||||
import { GraphDatabase } from '../../../../dist/runtime/core/graph_memory/graph_database.js';
|
||||
import * as fs from 'fs';
|
||||
|
||||
const TEST_DB_PATH = '/tmp/test_memory_service.db';
|
||||
|
||||
describe('MemoryService', () => {
|
||||
let memoryService: MemoryService;
|
||||
let db: GraphDatabase;
|
||||
|
||||
beforeEach(async () => {
|
||||
if (fs.existsSync(TEST_DB_PATH)) {
|
||||
fs.unlinkSync(TEST_DB_PATH);
|
||||
}
|
||||
const walPath = TEST_DB_PATH + '-wal';
|
||||
const shmPath = TEST_DB_PATH + '-shm';
|
||||
if (fs.existsSync(walPath)) fs.unlinkSync(walPath);
|
||||
if (fs.existsSync(shmPath)) fs.unlinkSync(shmPath);
|
||||
db = new GraphDatabase(TEST_DB_PATH, 'test-session');
|
||||
memoryService = new MemoryService(db);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (typeof db.close === 'function') db.close();
|
||||
if (fs.existsSync(TEST_DB_PATH)) {
|
||||
fs.unlinkSync(TEST_DB_PATH);
|
||||
}
|
||||
const walPath = TEST_DB_PATH + '-wal';
|
||||
const shmPath = TEST_DB_PATH + '-shm';
|
||||
if (fs.existsSync(walPath)) fs.unlinkSync(walPath);
|
||||
if (fs.existsSync(shmPath)) fs.unlinkSync(shmPath);
|
||||
});
|
||||
|
||||
describe('updatePersona', () => {
|
||||
it('should merge attributes in merge mode', async () => {
|
||||
const result = await memoryService.updatePersona({
|
||||
attributes: [
|
||||
{ attribute: 'name', value: 'AI Assistant' },
|
||||
{ attribute: 'personality', value: 'helpful' }
|
||||
],
|
||||
mode: 'merge'
|
||||
});
|
||||
|
||||
expect(result.status).toBe('success');
|
||||
expect(result.updatedAttributes).toBe(2);
|
||||
});
|
||||
|
||||
it('should replace attributes in replace mode', async () => {
|
||||
await memoryService.updatePersona({
|
||||
attributes: [
|
||||
{ attribute: 'name', value: 'Old Name' }
|
||||
],
|
||||
mode: 'merge'
|
||||
});
|
||||
|
||||
const result = await memoryService.updatePersona({
|
||||
attributes: [
|
||||
{ attribute: 'name', value: 'New Name' }
|
||||
],
|
||||
mode: 'replace'
|
||||
});
|
||||
|
||||
expect(result.status).toBe('success');
|
||||
expect(result.updatedAttributes).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearPersona', () => {
|
||||
it('should delete persona when confirm is true', async () => {
|
||||
await memoryService.updatePersona({
|
||||
attributes: [
|
||||
{ attribute: 'name', value: 'Test AI' }
|
||||
],
|
||||
mode: 'merge'
|
||||
});
|
||||
|
||||
const result = await memoryService.clearPersona({ confirm: true });
|
||||
expect(result.status).toBe('success');
|
||||
expect(result.deletedCount).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should cancel when confirm is false', async () => {
|
||||
const result = await memoryService.clearPersona({ confirm: false });
|
||||
expect(result.status).toBe('cancelled');
|
||||
expect(result.deletedCount).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createTask', () => {
|
||||
it('should create task with info_nodes', async () => {
|
||||
const result = await memoryService.createTask({
|
||||
task_id: 'Task_001',
|
||||
description: 'Test task with info',
|
||||
info_nodes: ['Node_A', 'Node_B']
|
||||
});
|
||||
|
||||
expect(result.status).toBe('success');
|
||||
expect(result.taskId).toBe('Task_001');
|
||||
});
|
||||
|
||||
it('should create task without info_nodes', async () => {
|
||||
const result = await memoryService.createTask({
|
||||
task_id: 'Task_002',
|
||||
description: 'Test task without info'
|
||||
});
|
||||
|
||||
expect(result.status).toBe('success');
|
||||
expect(result.taskId).toBe('Task_002');
|
||||
});
|
||||
});
|
||||
|
||||
describe('setTaskState', () => {
|
||||
it('should set task state', async () => {
|
||||
await memoryService.createTask({
|
||||
task_id: 'Task_StateTest',
|
||||
description: 'Task for state test'
|
||||
});
|
||||
|
||||
const result = await memoryService.setTaskState({
|
||||
task_id: 'Task_StateTest',
|
||||
state: '已暂停'
|
||||
});
|
||||
|
||||
expect(result.status).toBe('success');
|
||||
expect(result.newState).toBe('已暂停');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteTask', () => {
|
||||
it('should delete task', async () => {
|
||||
await memoryService.createTask({
|
||||
task_id: 'Task_Delete',
|
||||
description: 'Task to delete'
|
||||
});
|
||||
|
||||
const result = await memoryService.deleteTask({ task_id: 'Task_Delete' });
|
||||
expect(result.status).toBe('success');
|
||||
expect(result.taskId).toBe('Task_Delete');
|
||||
});
|
||||
});
|
||||
|
||||
describe('linkInfoToTask', () => {
|
||||
it('should link info node to task', async () => {
|
||||
await memoryService.createTask({
|
||||
task_id: 'Task_Link',
|
||||
description: 'Task for linking'
|
||||
});
|
||||
|
||||
const result = await memoryService.linkInfoToTask({
|
||||
task_id: 'Task_Link',
|
||||
info_node: 'Info_Node_X'
|
||||
});
|
||||
|
||||
expect(result.status).toBe('success');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sessionId', () => {
|
||||
it('should set and get sessionId', () => {
|
||||
memoryService.setSessionId('custom-session-123');
|
||||
const sessionId = memoryService.getSessionId();
|
||||
expect(sessionId).toBe('custom-session-123');
|
||||
});
|
||||
});
|
||||
|
||||
describe('task state transitions', () => {
|
||||
it('should transition: 进行中 → 已暂停 → 进行中 → 已完成', async () => {
|
||||
await memoryService.createTask({
|
||||
task_id: 'Task_Transition',
|
||||
description: 'State transition task'
|
||||
});
|
||||
|
||||
const state1 = await memoryService.setTaskState({
|
||||
task_id: 'Task_Transition',
|
||||
state: '进行中'
|
||||
});
|
||||
expect(state1.newState).toBe('进行中');
|
||||
|
||||
const state2 = await memoryService.setTaskState({
|
||||
task_id: 'Task_Transition',
|
||||
state: '已暂停'
|
||||
});
|
||||
expect(state2.newState).toBe('已暂停');
|
||||
|
||||
const state3 = await memoryService.setTaskState({
|
||||
task_id: 'Task_Transition',
|
||||
state: '进行中'
|
||||
});
|
||||
expect(state3.newState).toBe('进行中');
|
||||
|
||||
const state4 = await memoryService.setTaskState({
|
||||
task_id: 'Task_Transition',
|
||||
state: '已完成'
|
||||
});
|
||||
expect(state4.newState).toBe('已完成');
|
||||
});
|
||||
});
|
||||
|
||||
describe('delegate methods', () => {
|
||||
it('recall should delegate to db', async () => {
|
||||
await memoryService.commit({
|
||||
triplets: [
|
||||
{ subject: '测试', relation: '是', object: '示例' }
|
||||
]
|
||||
});
|
||||
|
||||
const result = await memoryService.recall({
|
||||
queryIntent: '测试'
|
||||
});
|
||||
|
||||
expect(result.entities).toBeDefined();
|
||||
expect(result.relations).toBeDefined();
|
||||
});
|
||||
|
||||
it('commit should delegate to db', async () => {
|
||||
const result = await memoryService.commit({
|
||||
triplets: [
|
||||
{ subject: '用户', relation: '喜欢', object: '编程' }
|
||||
]
|
||||
});
|
||||
|
||||
expect(result.createdEntities).toBeGreaterThan(0);
|
||||
expect(result.createdRelations).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('purge should delegate to db', async () => {
|
||||
await memoryService.commit({
|
||||
triplets: [
|
||||
{ subject: '待删除', relation: '是', object: '测试' }
|
||||
]
|
||||
});
|
||||
|
||||
const result = await memoryService.purge({
|
||||
criteria: { subject: '待删除' }
|
||||
});
|
||||
|
||||
expect(result.deleted).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
});
|
||||
});
|
||||
511
ts/tests/runtime/core/tools/builtin/graph_memory_tool.test.ts
Normal file
511
ts/tests/runtime/core/tools/builtin/graph_memory_tool.test.ts
Normal file
@ -0,0 +1,511 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { GraphMemoryTool } from '../../../../../dist/runtime/core/tools/builtin/graph_memory_tool.js';
|
||||
import { ToolLimiter } from '../../../../../dist/runtime/core/tools/tool_limiter.js';
|
||||
import * as fs from 'fs';
|
||||
|
||||
const TEST_DB_PATH = '/tmp/test_graph_memory_tool.db';
|
||||
|
||||
describe('GraphMemoryTool', () => {
|
||||
let tool: GraphMemoryTool;
|
||||
|
||||
beforeEach(() => {
|
||||
if (fs.existsSync(TEST_DB_PATH)) {
|
||||
fs.unlinkSync(TEST_DB_PATH);
|
||||
}
|
||||
tool = new GraphMemoryTool(TEST_DB_PATH, 'test-session');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (fs.existsSync(TEST_DB_PATH)) {
|
||||
fs.unlinkSync(TEST_DB_PATH);
|
||||
}
|
||||
});
|
||||
|
||||
describe('Tool metadata', () => {
|
||||
it('should have correct id', () => {
|
||||
expect(tool.id).toBe('builtin:graph_memory');
|
||||
});
|
||||
|
||||
it('should have correct name', () => {
|
||||
expect(tool.name).toBe('GraphMemory');
|
||||
});
|
||||
|
||||
it('should have analysis category', () => {
|
||||
expect(tool.category).toBe('analysis');
|
||||
});
|
||||
|
||||
it('should have safe permission level', () => {
|
||||
expect(tool.permissionLevel).toBe('safe');
|
||||
});
|
||||
|
||||
it('should have input schema with all actions', () => {
|
||||
const actions = tool.inputSchema.properties?.action?.enum as string[];
|
||||
expect(actions).toContain('recall');
|
||||
expect(actions).toContain('commit');
|
||||
expect(actions).toContain('purge');
|
||||
expect(actions).toContain('introspect');
|
||||
expect(actions).toContain('persona_update');
|
||||
expect(actions).toContain('persona_clear');
|
||||
expect(actions).toContain('task_create');
|
||||
expect(actions).toContain('task_set_state');
|
||||
expect(actions).toContain('task_delete');
|
||||
expect(actions).toContain('task_link_info');
|
||||
expect(actions).toContain('archive');
|
||||
expect(actions).toContain('cleanup');
|
||||
});
|
||||
});
|
||||
|
||||
describe('recall action', () => {
|
||||
it('should recall memories by query intent', async () => {
|
||||
await tool.handler({
|
||||
action: 'commit',
|
||||
params: {
|
||||
triplets: [
|
||||
{ subject: '用户', relation: '喜欢', object: '编程' }
|
||||
]
|
||||
}
|
||||
}, {} as any);
|
||||
|
||||
const result = await tool.handler({
|
||||
action: 'recall',
|
||||
params: {
|
||||
queryIntent: '用户 编程'
|
||||
}
|
||||
}, {} as any);
|
||||
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed.success).toBe(true);
|
||||
expect(parsed.data.entities).toBeDefined();
|
||||
expect(parsed.data.relations).toBeDefined();
|
||||
});
|
||||
|
||||
it('should return empty results for non-matching query', async () => {
|
||||
const result = await tool.handler({
|
||||
action: 'recall',
|
||||
params: {
|
||||
queryIntent: '不存在的关键词xyz123'
|
||||
}
|
||||
}, {} as any);
|
||||
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed.success).toBe(true);
|
||||
expect(parsed.data.entities).toEqual([]);
|
||||
expect(parsed.data.relations).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('commit action', () => {
|
||||
it('should commit triplets successfully', async () => {
|
||||
const result = await tool.handler({
|
||||
action: 'commit',
|
||||
params: {
|
||||
triplets: [
|
||||
{ subject: '测试', relation: '是', object: '示例' }
|
||||
]
|
||||
}
|
||||
}, {} as any);
|
||||
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed.success).toBe(true);
|
||||
expect(parsed.data.createdEntities).toBeGreaterThan(0);
|
||||
expect(parsed.data.createdRelations).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should handle multiple triplets', async () => {
|
||||
const result = await tool.handler({
|
||||
action: 'commit',
|
||||
params: {
|
||||
triplets: [
|
||||
{ subject: '实体A', relation: '关联', object: '实体B' },
|
||||
{ subject: '实体B', relation: '关联', object: '实体C' },
|
||||
{ subject: '实体C', relation: '关联', object: '实体A' }
|
||||
]
|
||||
}
|
||||
}, {} as any);
|
||||
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed.success).toBe(true);
|
||||
expect(parsed.data.createdEntities).toBe(6);
|
||||
expect(parsed.data.createdRelations).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('purge action', () => {
|
||||
it('should purge relations by criteria', async () => {
|
||||
await tool.handler({
|
||||
action: 'commit',
|
||||
params: {
|
||||
triplets: [
|
||||
{ subject: '待删除', relation: '是', object: '测试' }
|
||||
]
|
||||
}
|
||||
}, {} as any);
|
||||
|
||||
const result = await tool.handler({
|
||||
action: 'purge',
|
||||
params: {
|
||||
criteria: { subject: '待删除' },
|
||||
mode: 'soft'
|
||||
}
|
||||
}, {} as any);
|
||||
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed.success).toBe(true);
|
||||
expect(parsed.data.deleted).toBeGreaterThanOrEqual(1);
|
||||
expect(parsed.data.mode).toBe('soft');
|
||||
});
|
||||
|
||||
it('should return 0 when no criteria provided', async () => {
|
||||
const result = await tool.handler({
|
||||
action: 'purge',
|
||||
params: {}
|
||||
}, {} as any);
|
||||
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed.success).toBe(true);
|
||||
expect(parsed.data.deleted).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('introspect action', () => {
|
||||
it('should return memory statistics', async () => {
|
||||
await tool.handler({
|
||||
action: 'commit',
|
||||
params: {
|
||||
triplets: [
|
||||
{ subject: '实体1', relation: '关系', object: '实体2' }
|
||||
]
|
||||
}
|
||||
}, {} as any);
|
||||
|
||||
const result = await tool.handler({
|
||||
action: 'introspect',
|
||||
params: {}
|
||||
}, {} as any);
|
||||
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed.success).toBe(true);
|
||||
expect(parsed.data.entityCount).toBeGreaterThan(0);
|
||||
expect(parsed.data.relationCount).toBeGreaterThan(0);
|
||||
expect(parsed.data.sessionId).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('persona_update action', () => {
|
||||
it('should update persona attributes', async () => {
|
||||
const result = await tool.handler({
|
||||
action: 'persona_update',
|
||||
params: {
|
||||
attributes: [
|
||||
{ attribute: 'name', value: 'AI助手' },
|
||||
{ attribute: '性格', value: '友善' }
|
||||
],
|
||||
mode: 'merge'
|
||||
}
|
||||
}, {} as any);
|
||||
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed.success).toBe(true);
|
||||
expect(parsed.data.status).toBe('success');
|
||||
expect(parsed.data.updatedAttributes).toBe(2);
|
||||
});
|
||||
|
||||
it('should replace persona in replace mode', async () => {
|
||||
tool.resetLimiter();
|
||||
const result = await tool.handler({
|
||||
action: 'persona_update',
|
||||
params: {
|
||||
attributes: [{ attribute: 'name', value: 'TestAI' }],
|
||||
mode: 'merge'
|
||||
}
|
||||
}, {} as any);
|
||||
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed.success).toBe(true);
|
||||
expect(parsed.data.updatedAttributes).toBe(1);
|
||||
expect(parsed.data.status).toBe('success');
|
||||
});
|
||||
});
|
||||
|
||||
describe('persona_clear action', () => {
|
||||
it('should clear persona when confirm is true', async () => {
|
||||
const result = await tool.handler({
|
||||
action: 'persona_clear',
|
||||
params: {
|
||||
confirm: true
|
||||
}
|
||||
}, {} as any);
|
||||
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed.success).toBe(true);
|
||||
expect(parsed.data.status).toBe('success');
|
||||
expect(parsed.data.deletedCount).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it('should cancel when confirm is false', async () => {
|
||||
const result = await tool.handler({
|
||||
action: 'persona_clear',
|
||||
params: {
|
||||
confirm: false
|
||||
}
|
||||
}, {} as any);
|
||||
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed.success).toBe(true);
|
||||
expect(parsed.data.status).toBe('cancelled');
|
||||
expect(parsed.data.deletedCount).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('task_create action', () => {
|
||||
it('should create task successfully', async () => {
|
||||
const result = await tool.handler({
|
||||
action: 'task_create',
|
||||
params: {
|
||||
task_id: 'Task_Test123',
|
||||
description: '测试任务描述'
|
||||
}
|
||||
}, {} as any);
|
||||
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed.success).toBe(true);
|
||||
expect(parsed.data.status).toBe('success');
|
||||
expect(parsed.data.taskId).toBe('Task_Test123');
|
||||
});
|
||||
|
||||
it('should create task with info_nodes', async () => {
|
||||
const result = await tool.handler({
|
||||
action: 'task_create',
|
||||
params: {
|
||||
task_id: 'Task_WithInfo',
|
||||
description: '带信息的任务',
|
||||
info_nodes: ['信息节点1', '信息节点2']
|
||||
}
|
||||
}, {} as any);
|
||||
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed.success).toBe(true);
|
||||
expect(parsed.data.taskId).toBe('Task_WithInfo');
|
||||
});
|
||||
});
|
||||
|
||||
describe('task_set_state action', () => {
|
||||
it('should set task state', async () => {
|
||||
await tool.handler({
|
||||
action: 'task_create',
|
||||
params: {
|
||||
task_id: 'Task_StateTest',
|
||||
description: '状态测试任务'
|
||||
}
|
||||
}, {} as any);
|
||||
|
||||
const result = await tool.handler({
|
||||
action: 'task_set_state',
|
||||
params: {
|
||||
task_id: 'Task_StateTest',
|
||||
state: '已完成'
|
||||
}
|
||||
}, {} as any);
|
||||
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed.success).toBe(true);
|
||||
expect(parsed.data.status).toBe('success');
|
||||
expect(parsed.data.newState).toBe('已完成');
|
||||
});
|
||||
});
|
||||
|
||||
describe('task_delete action', () => {
|
||||
it('should delete task', async () => {
|
||||
await tool.handler({
|
||||
action: 'task_create',
|
||||
params: {
|
||||
task_id: 'Task_DeleteMe',
|
||||
description: '待删除任务'
|
||||
}
|
||||
}, {} as any);
|
||||
|
||||
const result = await tool.handler({
|
||||
action: 'task_delete',
|
||||
params: {
|
||||
task_id: 'Task_DeleteMe'
|
||||
}
|
||||
}, {} as any);
|
||||
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed.success).toBe(true);
|
||||
expect(parsed.data.status).toBe('success');
|
||||
expect(parsed.data.taskId).toBe('Task_DeleteMe');
|
||||
});
|
||||
});
|
||||
|
||||
describe('task_link_info action', () => {
|
||||
it('should link info node to task', async () => {
|
||||
await tool.handler({
|
||||
action: 'task_create',
|
||||
params: {
|
||||
task_id: 'Task_Link',
|
||||
description: '链接测试任务'
|
||||
}
|
||||
}, {} as any);
|
||||
|
||||
const result = await tool.handler({
|
||||
action: 'task_link_info',
|
||||
params: {
|
||||
task_id: 'Task_Link',
|
||||
info_node: '新的信息节点'
|
||||
}
|
||||
}, {} as any);
|
||||
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed.success).toBe(true);
|
||||
expect(parsed.data.status).toBe('success');
|
||||
});
|
||||
});
|
||||
|
||||
describe('archive action', () => {
|
||||
it('should archive old relations', async () => {
|
||||
const result = await tool.handler({
|
||||
action: 'archive',
|
||||
params: {
|
||||
days: 30
|
||||
}
|
||||
}, {} as any);
|
||||
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cleanup action', () => {
|
||||
it('should cleanup in dry_run mode', async () => {
|
||||
const result = await tool.handler({
|
||||
action: 'cleanup',
|
||||
params: {
|
||||
dry_run: true
|
||||
}
|
||||
}, {} as any);
|
||||
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should cleanup without dry_run', async () => {
|
||||
const result = await tool.handler({
|
||||
action: 'cleanup',
|
||||
params: {}
|
||||
}, {} as any);
|
||||
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Unknown action error handling', () => {
|
||||
it('should return error for unknown action', async () => {
|
||||
const result = await tool.handler({
|
||||
action: 'unknown_action',
|
||||
params: {}
|
||||
}, {} as any);
|
||||
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed.success).toBe(false);
|
||||
expect(parsed.error).toBeDefined();
|
||||
expect(parsed.error.type).toBe('execution_error');
|
||||
expect(parsed.error.message).toContain('Unknown action');
|
||||
});
|
||||
|
||||
it('should handle empty action gracefully', async () => {
|
||||
const result = await tool.handler({
|
||||
action: '',
|
||||
params: {}
|
||||
}, {} as any);
|
||||
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('OpenClaw execute method', () => {
|
||||
it('should return correct format with content array', async () => {
|
||||
const result = await tool.execute('call-123', {
|
||||
action: 'introspect',
|
||||
params: {}
|
||||
});
|
||||
|
||||
expect(result).toHaveProperty('content');
|
||||
expect(Array.isArray(result.content)).toBe(true);
|
||||
expect(result.content[0]).toHaveProperty('type', 'text');
|
||||
expect(result.content[0]).toHaveProperty('text');
|
||||
expect(typeof result.content[0].text).toBe('string');
|
||||
});
|
||||
|
||||
it('should parse JSON in text content', async () => {
|
||||
const result = await tool.execute('call-456', {
|
||||
action: 'commit',
|
||||
params: {
|
||||
triplets: [
|
||||
{ subject: '测试', relation: '是', object: '执行' }
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(result.content[0].text);
|
||||
expect(parsed.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle errors in execute format', async () => {
|
||||
const result = await tool.execute('call-789', {
|
||||
action: 'invalid_action',
|
||||
params: {}
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(result.content[0].text);
|
||||
expect(parsed.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ToolLimiter integration', () => {
|
||||
it('should block calls when rate limit exceeded', async () => {
|
||||
tool.resetLimiter();
|
||||
|
||||
const limiter = (tool as any).limiter;
|
||||
for (let i = 0; i < 20; i++) {
|
||||
limiter.recordCall('recall');
|
||||
}
|
||||
|
||||
const result = await tool.handler({
|
||||
action: 'recall',
|
||||
params: { queryIntent: '测试' }
|
||||
}, {} as any);
|
||||
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed.success).toBe(false);
|
||||
expect(parsed.error.message).toContain('已达上限');
|
||||
});
|
||||
|
||||
it('should get limiter summary', () => {
|
||||
tool.resetLimiter();
|
||||
const summary = tool.getLimiterSummary();
|
||||
expect(summary).toContain('人设图');
|
||||
expect(summary).toContain('工作记忆链');
|
||||
expect(summary).toContain('一般记忆');
|
||||
});
|
||||
|
||||
it('should reset limiter and allow calls again', async () => {
|
||||
const limiter = (tool as any).limiter;
|
||||
for (let i = 0; i < 20; i++) {
|
||||
limiter.recordCall('recall');
|
||||
}
|
||||
|
||||
tool.resetLimiter();
|
||||
|
||||
const result = await tool.handler({
|
||||
action: 'recall',
|
||||
params: { queryIntent: '测试' }
|
||||
}, {} as any);
|
||||
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed.success).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
433
ts/tests/runtime/core/tools/tool_limiter.test.ts
Normal file
433
ts/tests/runtime/core/tools/tool_limiter.test.ts
Normal file
@ -0,0 +1,433 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { ToolLimiter } from '../../../../dist/runtime/core/tools/tool_limiter.js';
|
||||
|
||||
describe('ToolLimiter', () => {
|
||||
describe('Default limits', () => {
|
||||
it('should have correct default limits', () => {
|
||||
const limiter = new ToolLimiter();
|
||||
|
||||
expect(limiter.limits.persona_query_max).toBe(1);
|
||||
expect(limiter.limits.persona_update_max).toBe(1);
|
||||
expect(limiter.limits.task_query_max).toBe(4);
|
||||
expect(limiter.limits.task_update_max).toBe(5);
|
||||
expect(limiter.limits.memory_query_max).toBe(20);
|
||||
expect(limiter.limits.memory_update_max).toBe(10);
|
||||
});
|
||||
|
||||
it('should initialize counts to zero', () => {
|
||||
const limiter = new ToolLimiter();
|
||||
|
||||
expect(limiter.counts.persona_query).toBe(0);
|
||||
expect(limiter.counts.persona_update).toBe(0);
|
||||
expect(limiter.counts.task_query).toBe(0);
|
||||
expect(limiter.counts.task_update).toBe(0);
|
||||
expect(limiter.counts.memory_query).toBe(0);
|
||||
expect(limiter.counts.memory_update).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('canCall allows within limits', () => {
|
||||
let limiter: ToolLimiter;
|
||||
|
||||
beforeEach(() => {
|
||||
limiter = new ToolLimiter();
|
||||
});
|
||||
|
||||
it('should allow recall (memory query) initially', () => {
|
||||
const [allowed, reason] = limiter.canCall('recall');
|
||||
expect(allowed).toBe(true);
|
||||
expect(reason).toBe('允许调用');
|
||||
});
|
||||
|
||||
it('should allow introspect (memory query) initially', () => {
|
||||
const [allowed, reason] = limiter.canCall('introspect');
|
||||
expect(allowed).toBe(true);
|
||||
expect(reason).toBe('允许调用');
|
||||
});
|
||||
|
||||
it('should allow commit (memory update) initially', () => {
|
||||
const [allowed, reason] = limiter.canCall('commit');
|
||||
expect(allowed).toBe(true);
|
||||
expect(reason).toBe('允许调用');
|
||||
});
|
||||
|
||||
it('should allow task operations initially', () => {
|
||||
expect(limiter.canCall('task_create')[0]).toBe(true);
|
||||
expect(limiter.canCall('task_set_state')[0]).toBe(true);
|
||||
expect(limiter.canCall('task_delete')[0]).toBe(true);
|
||||
expect(limiter.canCall('task_link_info')[0]).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow persona operations initially', () => {
|
||||
expect(limiter.canCall('persona_update')[0]).toBe(true);
|
||||
expect(limiter.canCall('persona_clear')[0]).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow purge initially', () => {
|
||||
const [allowed] = limiter.canCall('purge');
|
||||
expect(allowed).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow archive initially', () => {
|
||||
const [allowed] = limiter.canCall('archive');
|
||||
expect(allowed).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow cleanup initially', () => {
|
||||
const [allowed] = limiter.canCall('cleanup');
|
||||
expect(allowed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('canCall blocks when exceeded', () => {
|
||||
let limiter: ToolLimiter;
|
||||
|
||||
beforeEach(() => {
|
||||
limiter = new ToolLimiter();
|
||||
});
|
||||
|
||||
it('should block recall after 20 calls', () => {
|
||||
for (let i = 0; i < 20; i++) {
|
||||
limiter.recordCall('recall');
|
||||
}
|
||||
|
||||
const [allowed, reason] = limiter.canCall('recall');
|
||||
expect(allowed).toBe(false);
|
||||
expect(reason).toContain('一般记忆查询次数已达上限');
|
||||
expect(reason).toContain('20');
|
||||
});
|
||||
|
||||
it('should block commit after 10 calls', () => {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
limiter.recordCall('commit');
|
||||
}
|
||||
|
||||
const [allowed, reason] = limiter.canCall('commit');
|
||||
expect(allowed).toBe(false);
|
||||
expect(reason).toContain('一般记忆修改次数已达上限');
|
||||
});
|
||||
|
||||
it('should block purge after 10 calls', () => {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
limiter.recordCall('purge');
|
||||
}
|
||||
|
||||
const [allowed] = limiter.canCall('purge');
|
||||
expect(allowed).toBe(false);
|
||||
});
|
||||
|
||||
it('should block task operations after 5 updates', () => {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
limiter.recordCall('task_create');
|
||||
}
|
||||
|
||||
const [allowed] = limiter.canCall('task_create');
|
||||
expect(allowed).toBe(false);
|
||||
});
|
||||
|
||||
it('should block task_set_state after 5 updates', () => {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
limiter.recordCall('task_set_state');
|
||||
}
|
||||
|
||||
const [allowed] = limiter.canCall('task_set_state');
|
||||
expect(allowed).toBe(false);
|
||||
});
|
||||
|
||||
it('should block persona_update after 1 call', () => {
|
||||
limiter.recordCall('persona_update');
|
||||
|
||||
const [allowed] = limiter.canCall('persona_update');
|
||||
expect(allowed).toBe(false);
|
||||
});
|
||||
|
||||
it('should block persona_clear after 1 call', () => {
|
||||
limiter.recordCall('persona_clear');
|
||||
|
||||
const [allowed] = limiter.canCall('persona_clear');
|
||||
expect(allowed).toBe(false);
|
||||
});
|
||||
|
||||
it('should block unknown actions as memory update', () => {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
limiter.recordCall('unknown_action');
|
||||
}
|
||||
|
||||
const [allowed] = limiter.canCall('unknown_action');
|
||||
expect(allowed).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('recordCall increments counters', () => {
|
||||
let limiter: ToolLimiter;
|
||||
|
||||
beforeEach(() => {
|
||||
limiter = new ToolLimiter();
|
||||
});
|
||||
|
||||
it('should increment memory_query for recall', () => {
|
||||
limiter.recordCall('recall');
|
||||
expect(limiter.counts.memory_query).toBe(1);
|
||||
|
||||
limiter.recordCall('recall');
|
||||
expect(limiter.counts.memory_query).toBe(2);
|
||||
});
|
||||
|
||||
it('should increment memory_query for introspect', () => {
|
||||
limiter.recordCall('introspect');
|
||||
expect(limiter.counts.memory_query).toBe(1);
|
||||
});
|
||||
|
||||
it('should increment memory_update for commit', () => {
|
||||
limiter.recordCall('commit');
|
||||
expect(limiter.counts.memory_update).toBe(1);
|
||||
});
|
||||
|
||||
it('should increment memory_update for purge', () => {
|
||||
limiter.recordCall('purge');
|
||||
expect(limiter.counts.memory_update).toBe(1);
|
||||
});
|
||||
|
||||
it('should increment memory_update for archive', () => {
|
||||
limiter.recordCall('archive');
|
||||
expect(limiter.counts.memory_update).toBe(1);
|
||||
});
|
||||
|
||||
it('should increment memory_update for cleanup', () => {
|
||||
limiter.recordCall('cleanup');
|
||||
expect(limiter.counts.memory_update).toBe(1);
|
||||
});
|
||||
|
||||
it('should increment task_update for task operations', () => {
|
||||
limiter.recordCall('task_create');
|
||||
limiter.recordCall('task_set_state');
|
||||
limiter.recordCall('task_delete');
|
||||
limiter.recordCall('task_link_info');
|
||||
|
||||
expect(limiter.counts.task_update).toBe(4);
|
||||
});
|
||||
|
||||
it('should increment persona_update for persona operations', () => {
|
||||
limiter.recordCall('persona_update');
|
||||
limiter.recordCall('persona_clear');
|
||||
|
||||
expect(limiter.counts.persona_update).toBe(2);
|
||||
});
|
||||
|
||||
it('should handle unknown actions as memory update', () => {
|
||||
limiter.recordCall('some_unknown_action');
|
||||
|
||||
expect(limiter.counts.memory_update).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reset clears counters', () => {
|
||||
it('should reset all counts to zero', () => {
|
||||
const limiter = new ToolLimiter();
|
||||
|
||||
limiter.recordCall('recall');
|
||||
limiter.recordCall('recall');
|
||||
limiter.recordCall('commit');
|
||||
limiter.recordCall('task_create');
|
||||
limiter.recordCall('persona_update');
|
||||
|
||||
limiter.reset();
|
||||
|
||||
expect(limiter.counts.persona_query).toBe(0);
|
||||
expect(limiter.counts.persona_update).toBe(0);
|
||||
expect(limiter.counts.task_query).toBe(0);
|
||||
expect(limiter.counts.task_update).toBe(0);
|
||||
expect(limiter.counts.memory_query).toBe(0);
|
||||
expect(limiter.counts.memory_update).toBe(0);
|
||||
});
|
||||
|
||||
it('should allow calls after reset', () => {
|
||||
const limiter = new ToolLimiter();
|
||||
|
||||
for (let i = 0; i < 20; i++) {
|
||||
limiter.recordCall('recall');
|
||||
}
|
||||
|
||||
limiter.reset();
|
||||
|
||||
const [allowed] = limiter.canCall('recall');
|
||||
expect(allowed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSummary returns formatted string', () => {
|
||||
it('should return summary with correct format', () => {
|
||||
const limiter = new ToolLimiter();
|
||||
const summary = limiter.getSummary();
|
||||
|
||||
expect(summary).toContain('人设图');
|
||||
expect(summary).toContain('工作记忆链');
|
||||
expect(summary).toContain('一般记忆');
|
||||
expect(summary).toContain('查询');
|
||||
expect(summary).toContain('修改');
|
||||
expect(summary).toContain('/');
|
||||
});
|
||||
|
||||
it('should show updated counts in summary', () => {
|
||||
const limiter = new ToolLimiter();
|
||||
|
||||
limiter.recordCall('recall');
|
||||
limiter.recordCall('recall');
|
||||
limiter.recordCall('commit');
|
||||
|
||||
const summary = limiter.getSummary();
|
||||
expect(summary).toContain('2/20');
|
||||
expect(summary).toContain('1/10');
|
||||
});
|
||||
});
|
||||
|
||||
describe('classifyTool for all action types', () => {
|
||||
let limiter: ToolLimiter;
|
||||
|
||||
beforeEach(() => {
|
||||
limiter = new ToolLimiter();
|
||||
});
|
||||
|
||||
it('should classify recall as memory query', () => {
|
||||
for (let i = 0; i < 20; i++) limiter.recordCall('recall');
|
||||
const [, reason] = limiter.canCall('recall');
|
||||
expect(reason).toContain('一般记忆查询');
|
||||
});
|
||||
|
||||
it('should classify introspect as memory query', () => {
|
||||
for (let i = 0; i < 20; i++) limiter.recordCall('introspect');
|
||||
const [, reason] = limiter.canCall('introspect');
|
||||
expect(reason).toContain('一般记忆查询');
|
||||
});
|
||||
|
||||
it('should classify commit as memory update', () => {
|
||||
for (let i = 0; i < 10; i++) limiter.recordCall('commit');
|
||||
const [, reason] = limiter.canCall('commit');
|
||||
expect(reason).toContain('一般记忆修改');
|
||||
});
|
||||
|
||||
it('should classify purge as memory update', () => {
|
||||
for (let i = 0; i < 10; i++) limiter.recordCall('purge');
|
||||
const [, reason] = limiter.canCall('purge');
|
||||
expect(reason).toContain('一般记忆修改');
|
||||
});
|
||||
|
||||
it('should classify archive as memory update', () => {
|
||||
for (let i = 0; i < 10; i++) limiter.recordCall('archive');
|
||||
const [, reason] = limiter.canCall('archive');
|
||||
expect(reason).toContain('一般记忆修改');
|
||||
});
|
||||
|
||||
it('should classify cleanup as memory update', () => {
|
||||
for (let i = 0; i < 10; i++) limiter.recordCall('cleanup');
|
||||
const [, reason] = limiter.canCall('cleanup');
|
||||
expect(reason).toContain('一般记忆修改');
|
||||
});
|
||||
|
||||
it('should classify task_create as task update', () => {
|
||||
for (let i = 0; i < 5; i++) limiter.recordCall('task_create');
|
||||
const [, reason] = limiter.canCall('task_create');
|
||||
expect(reason).toContain('工作记忆链修改');
|
||||
});
|
||||
|
||||
it('should classify task_set_state as task update', () => {
|
||||
for (let i = 0; i < 5; i++) limiter.recordCall('task_set_state');
|
||||
const [, reason] = limiter.canCall('task_set_state');
|
||||
expect(reason).toContain('工作记忆链修改');
|
||||
});
|
||||
|
||||
it('should classify task_delete as task update', () => {
|
||||
for (let i = 0; i < 5; i++) limiter.recordCall('task_delete');
|
||||
const [, reason] = limiter.canCall('task_delete');
|
||||
expect(reason).toContain('工作记忆链修改');
|
||||
});
|
||||
|
||||
it('should classify task_link_info as task update', () => {
|
||||
for (let i = 0; i < 5; i++) limiter.recordCall('task_link_info');
|
||||
const [, reason] = limiter.canCall('task_link_info');
|
||||
expect(reason).toContain('工作记忆链修改');
|
||||
});
|
||||
|
||||
it('should classify persona_update as persona update', () => {
|
||||
limiter.recordCall('persona_update');
|
||||
const [, reason] = limiter.canCall('persona_update');
|
||||
expect(reason).toContain('人设图修改');
|
||||
});
|
||||
|
||||
it('should classify persona_clear as persona update', () => {
|
||||
limiter.recordCall('persona_clear');
|
||||
const [, reason] = limiter.canCall('persona_clear');
|
||||
expect(reason).toContain('人设图修改');
|
||||
});
|
||||
|
||||
it('should classify unknown action as memory update', () => {
|
||||
for (let i = 0; i < 10; i++) limiter.recordCall('totally_unknown');
|
||||
const [, reason] = limiter.canCall('totally_unknown');
|
||||
expect(reason).toContain('一般记忆修改');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Custom limits override', () => {
|
||||
it('should accept custom limits', () => {
|
||||
const limiter = new ToolLimiter({
|
||||
memory_query_max: 100,
|
||||
memory_update_max: 50
|
||||
});
|
||||
|
||||
expect(limiter.limits.memory_query_max).toBe(100);
|
||||
expect(limiter.limits.memory_update_max).toBe(50);
|
||||
expect(limiter.limits.task_query_max).toBe(4);
|
||||
});
|
||||
|
||||
it('should merge custom limits with defaults', () => {
|
||||
const limiter = new ToolLimiter({
|
||||
persona_update_max: 5
|
||||
});
|
||||
|
||||
expect(limiter.limits.persona_update_max).toBe(5);
|
||||
expect(limiter.limits.persona_query_max).toBe(1);
|
||||
});
|
||||
|
||||
it('should allow unlimited calls with high custom limits', () => {
|
||||
const limiter = new ToolLimiter({
|
||||
memory_query_max: 10000,
|
||||
memory_update_max: 10000
|
||||
});
|
||||
|
||||
for (let i = 0; i < 100; i++) {
|
||||
limiter.recordCall('recall');
|
||||
limiter.recordCall('commit');
|
||||
}
|
||||
|
||||
expect(limiter.canCall('recall')[0]).toBe(true);
|
||||
expect(limiter.canCall('commit')[0]).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow zero limits to block immediately', () => {
|
||||
const limiter = new ToolLimiter({
|
||||
task_update_max: 0
|
||||
});
|
||||
|
||||
const [allowed] = limiter.canCall('task_create');
|
||||
expect(allowed).toBe(false);
|
||||
});
|
||||
|
||||
it('should preserve custom limits after reset', () => {
|
||||
const limiter = new ToolLimiter({
|
||||
memory_query_max: 500
|
||||
});
|
||||
|
||||
for (let i = 0; i < 100; i++) {
|
||||
limiter.recordCall('recall');
|
||||
}
|
||||
|
||||
limiter.reset();
|
||||
|
||||
expect(limiter.limits.memory_query_max).toBe(500);
|
||||
expect(limiter.counts.memory_query).toBe(0);
|
||||
|
||||
const [allowed] = limiter.canCall('recall');
|
||||
expect(allowed).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user