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

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

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

View File

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

View File

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

View File

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

View File

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