feat: openclaw memory-zhiyi plugin v0.2.0 — 修复agent_id, 增加feedback/prefetch/forget/stats, 增强错误处理
This commit is contained in:
parent
dbb9a032aa
commit
da917f7eb8
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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; },
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
"axios": "^1.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.0.0",
|
||||
"@types/node": "^20.19.41",
|
||||
"typescript": "^5.3.0"
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue