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:
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user