From da917f7eb8de1127eb0ec7df92dba27ae0eb31b3 Mon Sep 17 00:00:00 2001 From: xiaowei Date: Sat, 30 May 2026 04:20:24 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20openclaw=20memory-zhiyi=20plugin=20v0.2?= =?UTF-8?q?.0=20=E2=80=94=20=E4=BF=AE=E5=A4=8Dagent=5Fid,=20=E5=A2=9E?= =?UTF-8?q?=E5=8A=A0feedback/prefetch/forget/stats,=20=E5=A2=9E=E5=BC=BA?= =?UTF-8?q?=E9=94=99=E8=AF=AF=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- openclaw-zhiyi-plugin/dist/client.d.ts | 23 ++- openclaw-zhiyi-plugin/dist/client.js | 168 ++++++++++++++++--- openclaw-zhiyi-plugin/dist/index.d.ts | 9 + openclaw-zhiyi-plugin/dist/index.js | 141 +++++++++++++++- openclaw-zhiyi-plugin/dist/types.d.ts | 18 ++ openclaw-zhiyi-plugin/package-lock.json | 2 +- openclaw-zhiyi-plugin/package.json | 6 +- openclaw-zhiyi-plugin/src/client.ts | 213 +++++++++++++++++++++--- openclaw-zhiyi-plugin/src/index.ts | 154 ++++++++++++++++- openclaw-zhiyi-plugin/src/types.ts | 23 ++- 10 files changed, 682 insertions(+), 75 deletions(-) diff --git a/openclaw-zhiyi-plugin/dist/client.d.ts b/openclaw-zhiyi-plugin/dist/client.d.ts index 69f3492..ce611d3 100644 --- a/openclaw-zhiyi-plugin/dist/client.d.ts +++ b/openclaw-zhiyi-plugin/dist/client.d.ts @@ -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; - commit(content: string, category?: string, metadata?: Record): Promise; + commit(content: string, category?: string, metadata?: Record): Promise; batchCommit(items: Array<{ content: string; category?: string; metadata?: Record; - }>): Promise; + }>, concurrency?: number): Promise<{ + ok: number; + fail: number; + errors: string[]; + }>; recall(query: string, topK?: number): Promise; + prefetch(context?: string): Promise; + clearCache(): void; + markUseful(memoryId: string): Promise; + markNotUseful(memoryId: string, reason?: string): Promise; + forget(memoryId: string): Promise; searchNotes(entity: string, maxHops?: number, maxNotes?: number): Promise; navigate(entity: string, maxHops?: number): Promise; - stats(): Promise>; + stats(): Promise; + private _log; } diff --git a/openclaw-zhiyi-plugin/dist/client.js b/openclaw-zhiyi-plugin/dist/client.js index c6cf723..173e180 100644 --- a/openclaw-zhiyi-plugin/dist/client.js +++ b/openclaw-zhiyi-plugin/dist/client.js @@ -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; diff --git a/openclaw-zhiyi-plugin/dist/index.d.ts b/openclaw-zhiyi-plugin/dist/index.d.ts index 5cc4244..6e947c5 100644 --- a/openclaw-zhiyi-plugin/dist/index.d.ts +++ b/openclaw-zhiyi-plugin/dist/index.d.ts @@ -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'; diff --git a/openclaw-zhiyi-plugin/dist/index.js b/openclaw-zhiyi-plugin/dist/index.js index 7e8c8a4..cb3130f 100644 --- a/openclaw-zhiyi-plugin/dist/index.js +++ b/openclaw-zhiyi-plugin/dist/index.js @@ -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; }, diff --git a/openclaw-zhiyi-plugin/dist/types.d.ts b/openclaw-zhiyi-plugin/dist/types.d.ts index 0367dfe..79d52cc 100644 --- a/openclaw-zhiyi-plugin/dist/types.d.ts +++ b/openclaw-zhiyi-plugin/dist/types.d.ts @@ -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; +} diff --git a/openclaw-zhiyi-plugin/package-lock.json b/openclaw-zhiyi-plugin/package-lock.json index f7fd476..7aa808b 100644 --- a/openclaw-zhiyi-plugin/package-lock.json +++ b/openclaw-zhiyi-plugin/package-lock.json @@ -11,7 +11,7 @@ "axios": "^1.6.0" }, "devDependencies": { - "@types/node": "^20.0.0", + "@types/node": "^20.19.41", "typescript": "^5.3.0" } }, diff --git a/openclaw-zhiyi-plugin/package.json b/openclaw-zhiyi-plugin/package.json index d1c28e2..819c72e 100644 --- a/openclaw-zhiyi-plugin/package.json +++ b/openclaw-zhiyi-plugin/package.json @@ -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" } -} \ No newline at end of file +} diff --git a/openclaw-zhiyi-plugin/src/client.ts b/openclaw-zhiyi-plugin/src/client.ts index e6975e2..ea0109a 100644 --- a/openclaw-zhiyi-plugin/src/client.ts +++ b/openclaw-zhiyi-plugin/src/client.ts @@ -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 = 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 { 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): Promise { + // ─── Commit ─────────────────────────────────────────────── + + async commit( + content: string, + category = 'episodes', + metadata?: Record, + ): Promise { 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 = { + 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 }>): Promise { - 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 }>, + 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 { + 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 { + if (!context || context.length < 5) return []; + return await this.recall(context, 3); + } + + clearCache(): void { + this.cache.clear(); + } + + // ─── Feedback ───────────────────────────────────────────── + + async markUseful(memoryId: string): Promise { + 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 { + 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 { + 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 { 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 { 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> { - try { const r = await this.client.get('/api/v1/stats'); return r.data; } - catch { return {}; } + // ─── Stats ──────────────────────────────────────────────── + + async stats(): Promise { + try { + const r = await this.client.get('/api/v1/stats'); + return r.data as StatsResult; + } catch { return {}; } } -} \ No newline at end of file + + // ─── Internal ───────────────────────────────────────────── + + private _log(op: string, e: any): void { + const msg = e?.response?.data?.error || e?.message || String(e); + console.error(`[ZhiYi] ${op}: ${msg}`); + } +} diff --git a/openclaw-zhiyi-plugin/src/index.ts b/openclaw-zhiyi-plugin/src/index.ts index fcb42f2..12effa7 100644 --- a/openclaw-zhiyi-plugin/src/index.ts +++ b/openclaw-zhiyi-plugin/src/index.ts @@ -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 }) => { + // memory:commit — store new memory + api.on('memory:commit', async (params: { + content: string; + category?: string; + metadata?: Record; + }) => { 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 }; \ No newline at end of file +export { ZhiYiClient }; diff --git a/openclaw-zhiyi-plugin/src/types.ts b/openclaw-zhiyi-plugin/src/types.ts index bec1f01..850e3ff 100644 --- a/openclaw-zhiyi-plugin/src/types.ts +++ b/openclaw-zhiyi-plugin/src/types.ts @@ -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; -} \ No newline at end of file +} + +export interface StatsResult { + total_memories?: number; + total_episodes?: number; + backend?: string; + [key: string]: unknown; +} + +export interface FeedbackResult { + success: boolean; + error?: string; +}