feat: complete OpenClaw plugin migration with SQLite persistence

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

View File

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

View File

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

View File

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

View File

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