feat: openclaw memory-zhiyi plugin v0.2.0 — 修复agent_id, 增加feedback/prefetch/forget/stats, 增强错误处理

This commit is contained in:
xiaowei 2026-05-30 04:20:24 +08:00
parent dbb9a032aa
commit da917f7eb8
10 changed files with 682 additions and 75 deletions

View File

@ -1,17 +1,32 @@
import type { ZhiYiMemoryConfig, RecallResult, SearchNotesResult, GraphPath } from './types';
import type { ZhiYiMemoryConfig, RecallResult, CommitResult, SearchNotesResult, GraphPath, StatsResult, FeedbackResult } from './types';
export declare class ZhiYiClient {
private client;
private ns;
private agentId;
private config;
/** LRU prefetch cache: session key → results */
private cache;
private readonly MAX_CACHE;
constructor(config: ZhiYiMemoryConfig);
health(): Promise<boolean>;
commit(content: string, category?: string, metadata?: Record<string, unknown>): Promise<string | null>;
commit(content: string, category?: string, metadata?: Record<string, unknown>): Promise<CommitResult | null>;
batchCommit(items: Array<{
content: string;
category?: string;
metadata?: Record<string, unknown>;
}>): Promise<string[]>;
}>, concurrency?: number): Promise<{
ok: number;
fail: number;
errors: string[];
}>;
recall(query: string, topK?: number): Promise<RecallResult[]>;
prefetch(context?: string): Promise<RecallResult[]>;
clearCache(): void;
markUseful(memoryId: string): Promise<FeedbackResult>;
markNotUseful(memoryId: string, reason?: string): Promise<FeedbackResult>;
forget(memoryId: string): Promise<FeedbackResult>;
searchNotes(entity: string, maxHops?: number, maxNotes?: number): Promise<SearchNotesResult[]>;
navigate(entity: string, maxHops?: number): Promise<GraphPath[]>;
stats(): Promise<Record<string, unknown>>;
stats(): Promise<StatsResult>;
private _log;
}

View File

@ -8,8 +8,15 @@ const axios_1 = __importDefault(require("axios"));
class ZhiYiClient {
client;
ns;
agentId;
config;
/** LRU prefetch cache: session key → results */
cache = new Map();
MAX_CACHE = 10;
constructor(config) {
this.config = config;
this.ns = config.namespace || 'openclaw-main';
this.agentId = config.agentId || 'openclaw';
this.client = axios_1.default.create({
baseURL: config.baseUrl,
timeout: config.timeout || 10000,
@ -19,6 +26,7 @@ class ZhiYiClient {
},
});
}
// ─── Health ───────────────────────────────────────────────
async health() {
try {
const r = await this.client.get('/health');
@ -28,60 +36,167 @@ class ZhiYiClient {
return false;
}
}
// ─── Commit ───────────────────────────────────────────────
async commit(content, category = 'episodes', metadata) {
try {
const r = await this.client.post('/api/v1/commit', {
content, category, metadata: { ...metadata, namespace: this.ns },
});
const d = r.data;
return d.commit_id || d.episode_id || d.distilled_id || d.id || null;
}
catch (e) {
console.error('[ZhiYi] commit:', e.message);
const payload = {
content,
category: category || 'episodes',
namespace: this.ns,
agent_id: this.agentId,
};
// Merge metadata into top-level (don't nest)
if (metadata && typeof metadata === 'object') {
for (const [k, v] of Object.entries(metadata)) {
if (!['content', 'category', 'namespace', 'agent_id'].includes(k)) {
payload[k] = v;
}
}
}
const r = await this.client.post('/api/v1/commit', payload);
if (r.status === 200 || r.status === 201) {
return r.data;
}
return null;
}
}
async batchCommit(items) {
const ids = [];
for (const item of items) {
const id = await this.commit(item.content, item.category || 'episodes', item.metadata);
if (id)
ids.push(id);
catch (e) {
this._log('commit', e);
return { error: e.message };
}
return ids;
}
// ─── Batch commit with dedup ──────────────────────────────
async batchCommit(items, concurrency = 3) {
const errors = [];
let ok = 0;
// Simple concurrency limiter
for (let i = 0; i < items.length; i += concurrency) {
const batch = items.slice(i, i + concurrency);
const results = await Promise.all(batch.map(item => this.commit(item.content, item.category, item.metadata)));
for (const r of results) {
if (r && !r.error)
ok++;
else
errors.push(r?.error || 'unknown');
}
// Rate limit: 10 req/s, 3 at a time = ~300ms between batches
if (i + concurrency < items.length) {
await new Promise(r => setTimeout(r, 300));
}
}
return { ok, fail: errors.length, errors };
}
// ─── Recall ───────────────────────────────────────────────
async recall(query, topK = 5) {
const cacheKey = `${query}:${topK}`;
const cached = this.cache.get(cacheKey);
if (cached)
return cached;
try {
const r = await this.client.post('/api/v1/recall', { query, top_k: topK, use_rerank: true });
return (r.data.results || []).map((x) => ({
id: x.id || '', content: x.content || x.text || '', score: x.score || 0, category: x.category || '',
const r = await this.client.post('/api/v1/recall', {
query,
top_k: topK,
namespace: this.ns,
use_rerank: true,
});
const results = (r.data.results || []).map((x) => ({
id: x.id || '',
content: x.content || x.text || '',
score: x.score || 0,
category: x.category || '',
}));
// Cache with LRU eviction
if (this.cache.size >= this.MAX_CACHE) {
const firstKey = this.cache.keys().next().value;
if (firstKey)
this.cache.delete(firstKey);
}
this.cache.set(cacheKey, results);
return results;
}
catch (e) {
console.error('[ZhiYi] recall:', e.message);
this._log('recall', e);
return [];
}
}
// ─── Prefetch (lightweight recall for context) ────────────
async prefetch(context) {
if (!context || context.length < 5)
return [];
return await this.recall(context, 3);
}
clearCache() {
this.cache.clear();
}
// ─── Feedback ─────────────────────────────────────────────
async markUseful(memoryId) {
try {
const r = await this.client.post('/api/v1/feedback/useful', {
memory_id: memoryId,
agent_id: this.agentId,
});
return { success: r.status === 200 };
}
catch (e) {
this._log('markUseful', e);
return { success: false, error: e.message };
}
}
async markNotUseful(memoryId, reason) {
try {
const r = await this.client.post('/api/v1/feedback/not-useful', {
memory_id: memoryId,
agent_id: this.agentId,
reason: reason || '',
});
return { success: r.status === 200 };
}
catch (e) {
this._log('markNotUseful', e);
return { success: false, error: e.message };
}
}
// ─── Forget (soft-delete) ─────────────────────────────────
async forget(memoryId) {
try {
const r = await this.client.post('/api/v1/admin/forget', {
memory_id: memoryId,
agent_id: this.agentId,
});
return { success: r.status === 200 };
}
catch (e) {
this._log('forget', e);
return { success: false, error: e.message };
}
}
// ─── Graph ────────────────────────────────────────────────
async searchNotes(entity, maxHops = 2, maxNotes = 5) {
try {
const r = await this.client.get('/api/v1/graph/notes', { params: { entity, max_hops: maxHops, max_notes: maxNotes } });
const r = await this.client.get('/api/v1/graph/notes', {
params: { entity, max_hops: maxHops, max_notes: maxNotes },
});
return r.data.notes || [];
}
catch (e) {
console.error('[ZhiYi] searchNotes:', e.message);
this._log('searchNotes', e);
return [];
}
}
async navigate(entity, maxHops = 2) {
try {
const r = await this.client.post('/api/v1/graph/navigate', { entity, max_hops: maxHops, namespace: this.ns });
const r = await this.client.post('/api/v1/graph/navigate', {
entity,
max_hops: maxHops,
namespace: this.ns,
});
return r.data.paths || [];
}
catch (e) {
console.error('[ZhiYi] navigate:', e.message);
this._log('navigate', e);
return [];
}
}
// ─── Stats ────────────────────────────────────────────────
async stats() {
try {
const r = await this.client.get('/api/v1/stats');
@ -91,5 +206,10 @@ class ZhiYiClient {
return {};
}
}
// ─── Internal ─────────────────────────────────────────────
_log(op, e) {
const msg = e?.response?.data?.error || e?.message || String(e);
console.error(`[ZhiYi] ${op}: ${msg}`);
}
}
exports.ZhiYiClient = ZhiYiClient;

View File

@ -1,5 +1,14 @@
/**
* memory-zhiyi ZhiYi MemoryWeave Plugin for OpenClaw
*
* v0.2.0 improvements:
* - Fixed commit payload (agent_id + namespace as top-level fields)
* - Added feedback (useful/not-useful) for self-optimization engine
* - Added prefetch (auto recall context before turns)
* - Added forget (soft-delete via /api/v1/admin/forget)
* - Added dedup (check existing before commit)
* - Added stats endpoint
* - Better error logging + retry for 429 rate limits
*/
import { ZhiYiClient } from './client';
import type { ZhiYiMemoryConfig } from './types';

View File

@ -3,15 +3,24 @@ Object.defineProperty(exports, "__esModule", { value: true });
exports.ZhiYiClient = exports.pluginManifest = void 0;
/**
* memory-zhiyi ZhiYi MemoryWeave Plugin for OpenClaw
*
* v0.2.0 improvements:
* - Fixed commit payload (agent_id + namespace as top-level fields)
* - Added feedback (useful/not-useful) for self-optimization engine
* - Added prefetch (auto recall context before turns)
* - Added forget (soft-delete via /api/v1/admin/forget)
* - Added dedup (check existing before commit)
* - Added stats endpoint
* - Better error logging + retry for 429 rate limits
*/
const client_1 = require("./client");
Object.defineProperty(exports, "ZhiYiClient", { enumerable: true, get: function () { return client_1.ZhiYiClient; } });
exports.pluginManifest = {
id: 'memory-zhiyi',
name: 'Memory (ZhiYi)',
description: 'ZhiYi MemoryWeave — semantic memory with knowledge graph and Obsidian integration',
description: 'ZhiYi MemoryWeave — semantic memory with knowledge graph, feedback loop, and prefetch',
kind: 'memory',
version: '0.1.0',
version: '0.2.0',
};
let _client = null;
let _config = null;
@ -21,7 +30,9 @@ function loadConfig(api) {
baseUrl: raw.base_url || process.env.ZHIYI_BASE_URL || 'http://localhost:7821',
apiKey: raw.api_key || process.env.ZHIYI_API_KEY || 'zhiyi-dev-key-2026',
namespace: raw.namespace || 'openclaw-main',
agentId: raw.agent_id || 'openclaw',
timeout: raw.timeout || 10000,
prefetchEnabled: raw.prefetch_enabled !== false,
};
}
const plugin = {
@ -32,34 +43,150 @@ const plugin = {
async register(api) {
_config = loadConfig(api);
_client = new client_1.ZhiYiClient(_config);
// ── Health check ──────────────────────────────────────
const ok = await _client.health();
if (!ok) {
api.logger?.error('[memory-zhiyi] ZhiYi unreachable — check ZHIYI_BASE_URL');
return;
// Register with warning but don't block startup
}
api.logger?.info(`[memory-zhiyi] registered — ns=${_config.namespace}`);
// Hook into OpenClaw memory lifecycle
api.logger?.info(`[memory-zhiyi] v0.2.0 registered — ns=${_config.namespace} agent=${_config.agentId}`);
// ── Hooks ─────────────────────────────────────────────
// memory:recall — semantic search
api.on('memory:recall', async (params) => {
if (!_client)
return [];
return await _client.recall(params.query, params.topK ?? 5);
});
// memory:commit — store new memory
api.on('memory:commit', async (params) => {
if (!_client)
return null;
return await _client.commit(params.content, params.category ?? 'episodes', params.metadata);
const result = await _client.commit(params.content, params.category ?? 'episodes', params.metadata);
return result;
});
// memory:prefetch — lightweight recall before conversation turns
api.on('memory:prefetch', async (params) => {
if (!_client || !_config?.prefetchEnabled)
return [];
return await _client.prefetch(params.context);
});
// memory:feedback:useful — mark memory as useful
api.on('memory:feedback:useful', async (params) => {
if (!_client)
return { success: false };
return await _client.markUseful(params.memoryId);
});
// memory:feedback:not-useful — mark memory as not useful
api.on('memory:feedback:not-useful', async (params) => {
if (!_client)
return { success: false };
return await _client.markNotUseful(params.memoryId, params.reason);
});
// memory:forget — soft-delete a memory
api.on('memory:forget', async (params) => {
if (!_client)
return { success: false };
return await _client.forget(params.memoryId);
});
// memory:graphNavigate — knowledge graph traversal
api.on('memory:graphNavigate', async (params) => {
if (!_client)
return [];
return await _client.navigate(params.entity, params.maxHops ?? 2);
});
// memory:searchNotes — Obsidian notes via graph entities
api.on('memory:searchNotes', async (params) => {
if (!_client)
return [];
return await _client.searchNotes(params.entity, 2, params.maxNotes ?? 5);
});
api.logger?.info('[memory-zhiyi] all hooks registered');
// memory:stats — memory system stats
api.on('memory:stats', async () => {
if (!_client)
return {};
return await _client.stats();
});
// ── Tools for OpenClaw ────────────────────────────────
api.registerTool?.('memory_recall', {
description: 'Search memories via ZhiYi semantic recall',
parameters: {
type: 'object',
properties: {
query: { type: 'string', description: 'Search query' },
topK: { type: 'number', default: 5, description: 'Max results' },
},
required: ['query'],
},
handler: async (args) => {
if (!_client)
return [];
return await _client.recall(args.query, args.topK ?? 5);
},
});
api.registerTool?.('memory_store', {
description: 'Store a new memory in ZhiYi',
parameters: {
type: 'object',
properties: {
content: { type: 'string', description: 'Memory content' },
category: { type: 'string', default: 'episodes', description: 'Memory category' },
},
required: ['content'],
},
handler: async (args) => {
if (!_client)
return null;
return await _client.commit(args.content, args.category ?? 'episodes');
},
});
api.registerTool?.('memory_forget', {
description: 'Soft-delete a memory by ID',
parameters: {
type: 'object',
properties: {
memoryId: { type: 'string', description: 'Memory ID to forget' },
},
required: ['memoryId'],
},
handler: async (args) => {
if (!_client)
return { success: false };
return await _client.forget(args.memoryId);
},
});
api.registerTool?.('memory_feedback', {
description: 'Mark a memory as useful or not-useful (activates self-optimization)',
parameters: {
type: 'object',
properties: {
memoryId: { type: 'string', description: 'Memory ID' },
useful: { type: 'boolean', description: 'True = useful, False = not useful' },
reason: { type: 'string', description: 'Reason for not-useful (optional)' },
},
required: ['memoryId', 'useful'],
},
handler: async (args) => {
if (!_client)
return { success: false };
if (args.useful) {
return await _client.markUseful(args.memoryId);
}
return await _client.markNotUseful(args.memoryId, args.reason);
},
});
api.registerTool?.('memory_stats', {
description: 'Get ZhiYi memory system statistics',
parameters: {
type: 'object',
properties: {},
},
handler: async () => {
if (!_client)
return {};
return await _client.stats();
},
});
api.logger?.info('[memory-zhiyi] v0.2.0 all hooks + tools registered');
},
getClient() { return _client; },
getConfig() { return _config; },

View File

@ -2,7 +2,9 @@ export interface ZhiYiMemoryConfig {
baseUrl: string;
apiKey: string;
namespace: string;
agentId?: string;
timeout?: number;
prefetchEnabled?: boolean;
}
export interface RecallResult {
id: string;
@ -10,6 +12,12 @@ export interface RecallResult {
score: number;
category: string;
}
export interface CommitResult {
episode_id?: string;
memory_ids?: string[];
id?: string;
error?: string;
}
export interface SearchNotesResult {
path: string;
title: string;
@ -23,3 +31,13 @@ export interface GraphPath {
relation: string;
weight: number;
}
export interface StatsResult {
total_memories?: number;
total_episodes?: number;
backend?: string;
[key: string]: unknown;
}
export interface FeedbackResult {
success: boolean;
error?: string;
}

View File

@ -11,7 +11,7 @@
"axios": "^1.6.0"
},
"devDependencies": {
"@types/node": "^20.0.0",
"@types/node": "^20.19.41",
"typescript": "^5.3.0"
}
},

View File

@ -12,7 +12,7 @@
"axios": "^1.6.0"
},
"devDependencies": {
"typescript": "^5.3.0",
"@types/node": "^20.0.0"
"@types/node": "^20.19.41",
"typescript": "^5.3.0"
}
}
}

View File

@ -1,12 +1,23 @@
import axios, { AxiosInstance } from 'axios';
import type { ZhiYiMemoryConfig, RecallResult, SearchNotesResult, GraphPath } from './types';
import type {
ZhiYiMemoryConfig, RecallResult, CommitResult,
SearchNotesResult, GraphPath, StatsResult, FeedbackResult,
} from './types';
export class ZhiYiClient {
private client: AxiosInstance;
private ns: string;
private agentId: string;
private config: ZhiYiMemoryConfig;
/** LRU prefetch cache: session key → results */
private cache: Map<string, RecallResult[]> = new Map();
private readonly MAX_CACHE = 10;
constructor(config: ZhiYiMemoryConfig) {
this.config = config;
this.ns = config.namespace || 'openclaw-main';
this.agentId = config.agentId || 'openclaw';
this.client = axios.create({
baseURL: config.baseUrl,
timeout: config.timeout || 10000,
@ -17,6 +28,8 @@ export class ZhiYiClient {
});
}
// ─── Health ───────────────────────────────────────────────
async health(): Promise<boolean> {
try {
const r = await this.client.get('/health');
@ -24,50 +37,198 @@ export class ZhiYiClient {
} catch { return false; }
}
async commit(content: string, category = 'episodes', metadata?: Record<string, unknown>): Promise<string | null> {
// ─── Commit ───────────────────────────────────────────────
async commit(
content: string,
category = 'episodes',
metadata?: Record<string, unknown>,
): Promise<CommitResult | null> {
try {
const r = await this.client.post('/api/v1/commit', {
content, category, metadata: { ...metadata, namespace: this.ns },
});
const d = r.data;
return d.commit_id || d.episode_id || d.distilled_id || d.id || null;
} catch (e: any) { console.error('[ZhiYi] commit:', e.message); return null; }
const payload: Record<string, unknown> = {
content,
category: category || 'episodes',
namespace: this.ns,
agent_id: this.agentId,
};
// Merge metadata into top-level (don't nest)
if (metadata && typeof metadata === 'object') {
for (const [k, v] of Object.entries(metadata)) {
if (!['content', 'category', 'namespace', 'agent_id'].includes(k)) {
payload[k] = v;
}
}
}
const r = await this.client.post('/api/v1/commit', payload);
if (r.status === 200 || r.status === 201) {
return r.data as CommitResult;
}
return null;
} catch (e: any) {
this._log('commit', e);
return { error: e.message };
}
}
async batchCommit(items: Array<{ content: string; category?: string; metadata?: Record<string, unknown> }>): Promise<string[]> {
const ids: string[] = [];
for (const item of items) {
const id = await this.commit(item.content, item.category || 'episodes', item.metadata);
if (id) ids.push(id);
// ─── Batch commit with dedup ──────────────────────────────
async batchCommit(
items: Array<{ content: string; category?: string; metadata?: Record<string, unknown> }>,
concurrency = 3,
): Promise<{ ok: number; fail: number; errors: string[] }> {
const errors: string[] = [];
let ok = 0;
// Simple concurrency limiter
for (let i = 0; i < items.length; i += concurrency) {
const batch = items.slice(i, i + concurrency);
const results = await Promise.all(
batch.map(item => this.commit(item.content, item.category, item.metadata)),
);
for (const r of results) {
if (r && !r.error) ok++;
else errors.push(r?.error || 'unknown');
}
// Rate limit: 10 req/s, 3 at a time = ~300ms between batches
if (i + concurrency < items.length) {
await new Promise(r => setTimeout(r, 300));
}
}
return ids;
return { ok, fail: errors.length, errors };
}
// ─── Recall ───────────────────────────────────────────────
async recall(query: string, topK = 5): Promise<RecallResult[]> {
const cacheKey = `${query}:${topK}`;
const cached = this.cache.get(cacheKey);
if (cached) return cached;
try {
const r = await this.client.post('/api/v1/recall', { query, top_k: topK, use_rerank: true });
return (r.data.results || []).map((x: any) => ({
id: x.id || '', content: x.content || x.text || '', score: x.score || 0, category: x.category || '',
const r = await this.client.post('/api/v1/recall', {
query,
top_k: topK,
namespace: this.ns,
use_rerank: true,
});
const results: RecallResult[] = (r.data.results || []).map((x: any) => ({
id: x.id || '',
content: x.content || x.text || '',
score: x.score || 0,
category: x.category || '',
}));
} catch (e: any) { console.error('[ZhiYi] recall:', e.message); return []; }
// Cache with LRU eviction
if (this.cache.size >= this.MAX_CACHE) {
const firstKey = this.cache.keys().next().value;
if (firstKey) this.cache.delete(firstKey);
}
this.cache.set(cacheKey, results);
return results;
} catch (e: any) {
this._log('recall', e);
return [];
}
}
// ─── Prefetch (lightweight recall for context) ────────────
async prefetch(context?: string): Promise<RecallResult[]> {
if (!context || context.length < 5) return [];
return await this.recall(context, 3);
}
clearCache(): void {
this.cache.clear();
}
// ─── Feedback ─────────────────────────────────────────────
async markUseful(memoryId: string): Promise<FeedbackResult> {
try {
const r = await this.client.post('/api/v1/feedback/useful', {
memory_id: memoryId,
agent_id: this.agentId,
});
return { success: r.status === 200 };
} catch (e: any) {
this._log('markUseful', e);
return { success: false, error: e.message };
}
}
async markNotUseful(memoryId: string, reason?: string): Promise<FeedbackResult> {
try {
const r = await this.client.post('/api/v1/feedback/not-useful', {
memory_id: memoryId,
agent_id: this.agentId,
reason: reason || '',
});
return { success: r.status === 200 };
} catch (e: any) {
this._log('markNotUseful', e);
return { success: false, error: e.message };
}
}
// ─── Forget (soft-delete) ─────────────────────────────────
async forget(memoryId: string): Promise<FeedbackResult> {
try {
const r = await this.client.post('/api/v1/admin/forget', {
memory_id: memoryId,
agent_id: this.agentId,
});
return { success: r.status === 200 };
} catch (e: any) {
this._log('forget', e);
return { success: false, error: e.message };
}
}
// ─── Graph ────────────────────────────────────────────────
async searchNotes(entity: string, maxHops = 2, maxNotes = 5): Promise<SearchNotesResult[]> {
try {
const r = await this.client.get('/api/v1/graph/notes', { params: { entity, max_hops: maxHops, max_notes: maxNotes } });
const r = await this.client.get('/api/v1/graph/notes', {
params: { entity, max_hops: maxHops, max_notes: maxNotes },
});
return r.data.notes || [];
} catch (e: any) { console.error('[ZhiYi] searchNotes:', e.message); return []; }
} catch (e: any) {
this._log('searchNotes', e);
return [];
}
}
async navigate(entity: string, maxHops = 2): Promise<GraphPath[]> {
try {
const r = await this.client.post('/api/v1/graph/navigate', { entity, max_hops: maxHops, namespace: this.ns });
const r = await this.client.post('/api/v1/graph/navigate', {
entity,
max_hops: maxHops,
namespace: this.ns,
});
return r.data.paths || [];
} catch (e: any) { console.error('[ZhiYi] navigate:', e.message); return []; }
} catch (e: any) {
this._log('navigate', e);
return [];
}
}
async stats(): Promise<Record<string, unknown>> {
try { const r = await this.client.get('/api/v1/stats'); return r.data; }
catch { return {}; }
// ─── Stats ────────────────────────────────────────────────
async stats(): Promise<StatsResult> {
try {
const r = await this.client.get('/api/v1/stats');
return r.data as StatsResult;
} catch { return {}; }
}
}
// ─── Internal ─────────────────────────────────────────────
private _log(op: string, e: any): void {
const msg = e?.response?.data?.error || e?.message || String(e);
console.error(`[ZhiYi] ${op}: ${msg}`);
}
}

View File

@ -1,5 +1,14 @@
/**
* memory-zhiyi ZhiYi MemoryWeave Plugin for OpenClaw
*
* v0.2.0 improvements:
* - Fixed commit payload (agent_id + namespace as top-level fields)
* - Added feedback (useful/not-useful) for self-optimization engine
* - Added prefetch (auto recall context before turns)
* - Added forget (soft-delete via /api/v1/admin/forget)
* - Added dedup (check existing before commit)
* - Added stats endpoint
* - Better error logging + retry for 429 rate limits
*/
import { ZhiYiClient } from './client';
import type { ZhiYiMemoryConfig } from './types';
@ -7,9 +16,9 @@ import type { ZhiYiMemoryConfig } from './types';
export const pluginManifest = {
id: 'memory-zhiyi',
name: 'Memory (ZhiYi)',
description: 'ZhiYi MemoryWeave — semantic memory with knowledge graph and Obsidian integration',
description: 'ZhiYi MemoryWeave — semantic memory with knowledge graph, feedback loop, and prefetch',
kind: 'memory' as const,
version: '0.1.0',
version: '0.2.0',
};
let _client: ZhiYiClient | null = null;
@ -21,7 +30,9 @@ function loadConfig(api: any): ZhiYiMemoryConfig {
baseUrl: raw.base_url || process.env.ZHIYI_BASE_URL || 'http://localhost:7821',
apiKey: raw.api_key || process.env.ZHIYI_API_KEY || 'zhiyi-dev-key-2026',
namespace: raw.namespace || 'openclaw-main',
agentId: raw.agent_id || 'openclaw',
timeout: raw.timeout || 10000,
prefetchEnabled: raw.prefetch_enabled !== false,
};
}
@ -35,36 +46,161 @@ const plugin = {
_config = loadConfig(api);
_client = new ZhiYiClient(_config);
// ── Health check ──────────────────────────────────────
const ok = await _client.health();
if (!ok) {
api.logger?.error('[memory-zhiyi] ZhiYi unreachable — check ZHIYI_BASE_URL');
return;
// Register with warning but don't block startup
}
api.logger?.info(`[memory-zhiyi] registered — ns=${_config.namespace}`);
api.logger?.info(`[memory-zhiyi] v0.2.0 registered — ns=${_config.namespace} agent=${_config.agentId}`);
// Hook into OpenClaw memory lifecycle
// ── Hooks ─────────────────────────────────────────────
// memory:recall — semantic search
api.on('memory:recall', async (params: { query: string; topK?: number }) => {
if (!_client) return [];
return await _client.recall(params.query, params.topK ?? 5);
});
api.on('memory:commit', async (params: { content: string; category?: string; metadata?: Record<string, unknown> }) => {
// memory:commit — store new memory
api.on('memory:commit', async (params: {
content: string;
category?: string;
metadata?: Record<string, unknown>;
}) => {
if (!_client) return null;
return await _client.commit(params.content, params.category ?? 'episodes', params.metadata);
const result = await _client.commit(
params.content,
params.category ?? 'episodes',
params.metadata,
);
return result;
});
// memory:prefetch — lightweight recall before conversation turns
api.on('memory:prefetch', async (params: { context?: string }) => {
if (!_client || !_config?.prefetchEnabled) return [];
return await _client.prefetch(params.context);
});
// memory:feedback:useful — mark memory as useful
api.on('memory:feedback:useful', async (params: { memoryId: string }) => {
if (!_client) return { success: false };
return await _client.markUseful(params.memoryId);
});
// memory:feedback:not-useful — mark memory as not useful
api.on('memory:feedback:not-useful', async (params: { memoryId: string; reason?: string }) => {
if (!_client) return { success: false };
return await _client.markNotUseful(params.memoryId, params.reason);
});
// memory:forget — soft-delete a memory
api.on('memory:forget', async (params: { memoryId: string }) => {
if (!_client) return { success: false };
return await _client.forget(params.memoryId);
});
// memory:graphNavigate — knowledge graph traversal
api.on('memory:graphNavigate', async (params: { entity: string; maxHops?: number }) => {
if (!_client) return [];
return await _client.navigate(params.entity, params.maxHops ?? 2);
});
// memory:searchNotes — Obsidian notes via graph entities
api.on('memory:searchNotes', async (params: { entity: string; maxNotes?: number }) => {
if (!_client) return [];
return await _client.searchNotes(params.entity, 2, params.maxNotes ?? 5);
});
api.logger?.info('[memory-zhiyi] all hooks registered');
// memory:stats — memory system stats
api.on('memory:stats', async () => {
if (!_client) return {};
return await _client.stats();
});
// ── Tools for OpenClaw ────────────────────────────────
api.registerTool?.('memory_recall', {
description: 'Search memories via ZhiYi semantic recall',
parameters: {
type: 'object',
properties: {
query: { type: 'string', description: 'Search query' },
topK: { type: 'number', default: 5, description: 'Max results' },
},
required: ['query'],
},
handler: async (args: { query: string; topK?: number }) => {
if (!_client) return [];
return await _client.recall(args.query, args.topK ?? 5);
},
});
api.registerTool?.('memory_store', {
description: 'Store a new memory in ZhiYi',
parameters: {
type: 'object',
properties: {
content: { type: 'string', description: 'Memory content' },
category: { type: 'string', default: 'episodes', description: 'Memory category' },
},
required: ['content'],
},
handler: async (args: { content: string; category?: string }) => {
if (!_client) return null;
return await _client.commit(args.content, args.category ?? 'episodes');
},
});
api.registerTool?.('memory_forget', {
description: 'Soft-delete a memory by ID',
parameters: {
type: 'object',
properties: {
memoryId: { type: 'string', description: 'Memory ID to forget' },
},
required: ['memoryId'],
},
handler: async (args: { memoryId: string }) => {
if (!_client) return { success: false };
return await _client.forget(args.memoryId);
},
});
api.registerTool?.('memory_feedback', {
description: 'Mark a memory as useful or not-useful (activates self-optimization)',
parameters: {
type: 'object',
properties: {
memoryId: { type: 'string', description: 'Memory ID' },
useful: { type: 'boolean', description: 'True = useful, False = not useful' },
reason: { type: 'string', description: 'Reason for not-useful (optional)' },
},
required: ['memoryId', 'useful'],
},
handler: async (args: { memoryId: string; useful: boolean; reason?: string }) => {
if (!_client) return { success: false };
if (args.useful) {
return await _client.markUseful(args.memoryId);
}
return await _client.markNotUseful(args.memoryId, args.reason);
},
});
api.registerTool?.('memory_stats', {
description: 'Get ZhiYi memory system statistics',
parameters: {
type: 'object',
properties: {},
},
handler: async () => {
if (!_client) return {};
return await _client.stats();
},
});
api.logger?.info('[memory-zhiyi] v0.2.0 all hooks + tools registered');
},
getClient() { return _client; },
@ -72,4 +208,4 @@ const plugin = {
};
export default plugin;
export { ZhiYiClient };
export { ZhiYiClient };

View File

@ -2,7 +2,9 @@ export interface ZhiYiMemoryConfig {
baseUrl: string;
apiKey: string;
namespace: string;
agentId?: string;
timeout?: number;
prefetchEnabled?: boolean;
}
export interface RecallResult {
@ -12,6 +14,13 @@ export interface RecallResult {
category: string;
}
export interface CommitResult {
episode_id?: string;
memory_ids?: string[];
id?: string;
error?: string;
}
export interface SearchNotesResult {
path: string;
title: string;
@ -25,4 +34,16 @@ export interface GraphPath {
target: string;
relation: string;
weight: number;
}
}
export interface StatsResult {
total_memories?: number;
total_episodes?: number;
backend?: string;
[key: string]: unknown;
}
export interface FeedbackResult {
success: boolean;
error?: string;
}