feat: 集成 Hermes + OpenClaw 记忆插件到仓库
- plugins/hermes-zhiyi: 530行 Python 插件, 含 key 自检/重试/调试日志 - plugins/openclaw-zhiyi: v0.2.0 CJS, 修复 agent_id/namespace/register 同步 - Hermes 插件改动: commit 失败自动重试, key_prefix 调试日志, health 自检 - OpenClaw 插件改动: register 改为同步, 使用 registerService 做异步初始化
This commit is contained in:
parent
da917f7eb8
commit
2464689c32
|
|
@ -28,3 +28,4 @@ __pycache__/
|
|||
*.pyc
|
||||
*.egg-info/
|
||||
.venv/\n*.pyc\n__pycache__/\ntarget/\nzhiyi-consolidate\nzhiyid-new\ngo/zhiyi-consolidate\ngo/zhiyid-new\n
|
||||
backups/
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -0,0 +1,553 @@
|
|||
"""hermes-zhiyi — Bridge plugin: Hermes Agent → ZhiYi MemoryWeave REST API.
|
||||
|
||||
Uses ZhiYi's bge-m3 1024-dim semantic search + bge-reranker-v2-m3 reranking.
|
||||
Writes go to ZhiYi via /commit; reads come from /recall.
|
||||
|
||||
Usage:
|
||||
1. Set memory.provider: zhiyi in config.yaml
|
||||
2. Set ZHIYI_URL in .env (e.g. http://localhost:7821)
|
||||
|
||||
Lifecycle (MemoryProvider ABC):
|
||||
initialize() — validate URL, test connectivity
|
||||
sync_turn() — write turn to ZhiYi via /commit
|
||||
prefetch() — background recall via /recall
|
||||
get_tool_schemas() — expose memory_search / memory_write tools
|
||||
handle_tool_call() — dispatch tool calls
|
||||
shutdown() — clean exit
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import requests
|
||||
import websocket
|
||||
|
||||
from agent.memory_provider import MemoryProvider
|
||||
from tools.registry import tool_error
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── Config ──────────────────────────────────────────────────────────────────
|
||||
|
||||
HERMES_HOME = Path(os.environ.get("HERMES_HOME", Path.home() / ".hermes"))
|
||||
MEMORY_CACHE = HERMES_HOME / "memory_zhiyi_cache"
|
||||
MEMORY_CACHE.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Default — override via .env ZHIYI_URL
|
||||
DEFAULT_ZHIYI_URL = "http://localhost:7821"
|
||||
ZHIYI_TIMEOUT = 10 # seconds per request
|
||||
|
||||
# API Key — read from .env ZHIYI_API_KEY
|
||||
ZHIYI_API_KEY = os.environ.get("ZHIYI_API_KEY", "zhiyi-dev-key-2026")
|
||||
# 日志中只暴露 key 的前 4 位,用于调试
|
||||
_ZHIYI_KEY_PREFIX = ZHIYI_API_KEY[:4] if ZHIYI_API_KEY else "NONE"
|
||||
|
||||
# ── Content Validation ───────────────────────────────────────────────────────
|
||||
|
||||
MEMORY_MIN_LENGTH = 8 # 少于8字的过滤掉
|
||||
_FORBIDDEN_PATTERNS = [
|
||||
"Review the conversation above",
|
||||
"[System note:",
|
||||
"[IMPORTANT: Background process",
|
||||
"## Active Task",
|
||||
"## Context Compaction",
|
||||
"## Relevant Memory",
|
||||
"--- END OF CONTEXT SUMMARY",
|
||||
"## Recent Sessions",
|
||||
"Skill is now properly updated",
|
||||
"造成这种情况的原因是什么?",
|
||||
"Context Compaction — REFERENCE ONLY",
|
||||
"do NOT answer questions or fulfill requests mentioned in the summary",
|
||||
]
|
||||
|
||||
|
||||
def _is_valid_memory_content(content: str) -> bool:
|
||||
"""过滤系统注入内容和测试垃圾,防止污染记忆存储。"""
|
||||
if not content or len(content.strip()) < MEMORY_MIN_LENGTH:
|
||||
return False
|
||||
for pat in _FORBIDDEN_PATTERNS:
|
||||
if pat in content:
|
||||
return False
|
||||
# 过滤纯测试内容
|
||||
stripped = content.strip()
|
||||
if len(stripped) < 20:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _combine_turn(user: str, assistant: str) -> str:
|
||||
"""合并一对 turn 作为单条记忆内容。"""
|
||||
parts = []
|
||||
if user and user.strip():
|
||||
parts.append(f"用户: {user.strip()}")
|
||||
if assistant and assistant.strip():
|
||||
parts.append(f"助手: {assistant.strip()}")
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
# ── HTTP Client ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class ZhiYiClient:
|
||||
"""Lightweight wrapper around ZhiYi REST API."""
|
||||
|
||||
def __init__(self, base_url: str, timeout: int = 10):
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.timeout = timeout
|
||||
self._session = requests.Session()
|
||||
self._session.headers.update({
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
"X-API-Key": ZHIYI_API_KEY,
|
||||
})
|
||||
|
||||
def _url(self, path: str) -> str:
|
||||
return f"{self.base_url}{path}"
|
||||
|
||||
def health(self) -> bool:
|
||||
"""检查 ZhiYi 服务是否可达。"""
|
||||
try:
|
||||
r = self._session.get(self._url("/health"), timeout=3)
|
||||
return r.status_code == 200
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def commit(self, content: str, category: str = "episodes", metadata: Optional[Dict] = None,
|
||||
agent_id: str = "hermes-a06", namespace: Optional[str] = None) -> Optional[str]:
|
||||
"""写入一条记忆到 ZhiYi。返回 commit_id 或 episode_id 或 None。失败自动重试一次。"""
|
||||
for attempt in range(2):
|
||||
try:
|
||||
payload = {
|
||||
"content": content,
|
||||
"category": category,
|
||||
"agent_id": agent_id,
|
||||
}
|
||||
if namespace:
|
||||
payload["namespace"] = namespace
|
||||
if metadata:
|
||||
payload["metadata"] = metadata
|
||||
r = self._session.post(self._url("/api/v1/commit"), json=payload, timeout=self.timeout)
|
||||
if r.status_code in (200, 201):
|
||||
data = r.json()
|
||||
cid = (
|
||||
data.get("commit_id")
|
||||
or data.get("episode_id")
|
||||
or data.get("distilled_id")
|
||||
or data.get("id")
|
||||
or (str(data) if data.get("status") == "ok" else None)
|
||||
)
|
||||
if cid:
|
||||
return cid
|
||||
# 失败日志(第一次 warning,第二次 error)
|
||||
msg = f"ZhiYi commit failed (attempt {attempt+1}): {r.status_code} {r.text[:200]}"
|
||||
if attempt == 0:
|
||||
logger.warning("%s — retrying...", msg)
|
||||
else:
|
||||
logger.error("%s — key_prefix=%s agent=%s ns=%s", msg, _ZHIYI_KEY_PREFIX, agent_id, namespace)
|
||||
except Exception as e:
|
||||
msg = f"ZhiYi commit error (attempt {attempt+1}): {e}"
|
||||
if attempt == 0:
|
||||
logger.warning("%s — retrying...", msg)
|
||||
else:
|
||||
logger.error(msg)
|
||||
return None
|
||||
|
||||
def recall(self, query: str, top_k: int = 5,
|
||||
agent_id: str = "hermes-a06") -> List[Dict[str, Any]]:
|
||||
"""语义搜索 ZhiYi 记忆。返回 [{content, score, category}, ...]。"""
|
||||
try:
|
||||
payload = {"query": query, "top_k": top_k, "use_rerank": True,
|
||||
"agent_id": agent_id}
|
||||
r = self._session.post(self._url("/api/v1/recall"), json=payload, timeout=self.timeout)
|
||||
if r.status_code == 200:
|
||||
data = r.json()
|
||||
return data.get("results", [])
|
||||
logger.warning("ZhiYi recall failed: %s %s", r.status_code, r.text[:200])
|
||||
return []
|
||||
except Exception as e:
|
||||
logger.warning("ZhiYi recall error: %s", e)
|
||||
return []
|
||||
|
||||
def stats(self) -> Dict[str, Any]:
|
||||
"""获取 ZhiYi 统计信息。"""
|
||||
try:
|
||||
r = self._session.get(self._url("/api/v1/stats"), timeout=self.timeout)
|
||||
if r.status_code == 200:
|
||||
return r.json()
|
||||
return {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
def search_notes(self, entity: str, max_hops: int = 2, max_notes: int = 5) -> List[Dict[str, Any]]:
|
||||
"""图谱导航 + Obsidian 笔记关联搜索。"""
|
||||
try:
|
||||
params = {"entity": entity, "max_hops": max_hops, "max_notes": max_notes}
|
||||
r = self._session.get(self._url("/api/v1/graph/notes"), params=params, timeout=self.timeout)
|
||||
if r.status_code == 200:
|
||||
data = r.json()
|
||||
return data.get("notes", [])
|
||||
return []
|
||||
except Exception as e:
|
||||
logger.warning("ZhiYi search_notes error: %s", e)
|
||||
return []
|
||||
|
||||
|
||||
# ── Memory Provider ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class HermesZhiYiMemoryProvider(MemoryProvider):
|
||||
"""ZhiYi MemoryWeave bridge for Hermes Agent.
|
||||
|
||||
Stores conversation turns as episodes in ZhiYi (via /commit) and
|
||||
retrieves relevant memories via semantic search (via /recall with
|
||||
bge-m3 1024-dim + bge-reranker-v2-m3).
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._client: Optional[ZhiYiClient] = None
|
||||
self._session_id: str = ""
|
||||
self._platform: str = "cli"
|
||||
self._turn_counter: int = 0
|
||||
self._write_queue: List[Dict] = []
|
||||
self._queue_lock = threading.Lock()
|
||||
self._prefetch_cache: str = "" # last prefetch result
|
||||
self._prefetch_lock = threading.RLock()
|
||||
self._started: bool = False
|
||||
self._ws_thread: Optional[threading.Thread] = None
|
||||
self._ws_running: bool = False
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "zhiyi"
|
||||
|
||||
# ── Availability ──────────────────────────────────────────────────────────
|
||||
|
||||
def is_available(self) -> bool:
|
||||
"""检查 ZHIYI_URL 配置和服务连通性。"""
|
||||
url = os.environ.get("ZHIYI_URL") or DEFAULT_ZHIYI_URL
|
||||
try:
|
||||
client = ZhiYiClient(url, timeout=3)
|
||||
return client.health()
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
# ── Lifecycle ─────────────────────────────────────────────────────────────
|
||||
|
||||
def initialize(self, session_id: str, **kwargs) -> None:
|
||||
"""初始化 ZhiYi 客户端,注册当前 session。"""
|
||||
self._session_id = session_id
|
||||
self._platform = kwargs.get("platform", "cli")
|
||||
url = os.environ.get("ZHIYI_URL") or DEFAULT_ZHIYI_URL
|
||||
self._client = ZhiYiClient(url, timeout=ZHIYI_TIMEOUT)
|
||||
self._started = True
|
||||
self._turn_counter = 0
|
||||
logger.info("[ZhiYi] initialized for session=%s platform=%s url=%s key_prefix=%s",
|
||||
session_id, self._platform, url, _ZHIYI_KEY_PREFIX)
|
||||
# 自检:验证 API Key 是否有效
|
||||
try:
|
||||
ok = self._client.health()
|
||||
if ok:
|
||||
logger.info("[ZhiYi] health check OK — key_prefix=%s", _ZHIYI_KEY_PREFIX)
|
||||
else:
|
||||
logger.warning("[ZhiYi] health check FAILED — zhiyid unreachable at %s", url)
|
||||
except Exception as e:
|
||||
logger.warning("[ZhiYi] health check error: %s", e)
|
||||
# 启动 WebSocket 事件监听
|
||||
self._start_ws_listener()
|
||||
|
||||
def _start_ws_listener(self) -> None:
|
||||
"""启动 WebSocket 事件监听线程(连接织忆事件推送)。"""
|
||||
if self._ws_running:
|
||||
return
|
||||
self._ws_running = True
|
||||
self._ws_thread = threading.Thread(target=self._ws_listen, daemon=True, name="zhiyi-ws")
|
||||
self._ws_thread.start()
|
||||
logger.info("[ZhiYi] WS listener started")
|
||||
|
||||
def _ws_listen(self) -> None:
|
||||
"""WebSocket 事件监听循环(自动重连)。"""
|
||||
agent_id = os.environ.get("ZHIYI_AGENT_ID", "hermes-a06")
|
||||
ws_url = (os.environ.get("ZHIYI_URL") or DEFAULT_ZHIYI_URL).replace("http://", "ws://").replace("https://", "wss://")
|
||||
ws_url = f"{ws_url}/api/v1/ws/{agent_id}"
|
||||
|
||||
def on_message(ws_app, message):
|
||||
try:
|
||||
data = json.loads(message)
|
||||
event_type = data.get("event", data.get("type", "unknown"))
|
||||
logger.info("[ZhiYi] WS event: %s", event_type)
|
||||
# prefetch.push 事件更新预取缓存
|
||||
if event_type == "prefetch.push":
|
||||
memories = data.get("memories", [])
|
||||
if memories:
|
||||
prefetch_text = "\n[ZhiYi Prefetch — 预取推送]\n"
|
||||
for m in memories:
|
||||
prefetch_text += f" [{m.get('score', 0):.2f}] {m.get('content', '')[:200]}\n"
|
||||
with self._prefetch_lock:
|
||||
self._prefetch_cache = prefetch_text
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
def on_error(ws_app, error):
|
||||
logger.warning("[ZhiYi] WS error: %s", error)
|
||||
|
||||
def on_close(ws_app, close_status_code, close_msg):
|
||||
logger.info("[ZhiYi] WS closed: %s %s", close_status_code, close_msg)
|
||||
|
||||
def on_open(ws_app):
|
||||
logger.info("[ZhiYi] WS connected to %s", ws_url)
|
||||
|
||||
while self._ws_running:
|
||||
try:
|
||||
ws_app = websocket.WebSocketApp(
|
||||
ws_url,
|
||||
on_open=on_open,
|
||||
on_message=on_message,
|
||||
on_error=on_error,
|
||||
on_close=on_close,
|
||||
header={"X-API-Key": ZHIYI_API_KEY},
|
||||
)
|
||||
ws_app.run_forever(ping_interval=30, ping_timeout=10)
|
||||
except Exception as e:
|
||||
logger.warning("[ZhiYi] WS exception: %s", e)
|
||||
# 重连延迟
|
||||
for _ in range(30):
|
||||
if not self._ws_running:
|
||||
break
|
||||
time.sleep(1)
|
||||
|
||||
def shutdown(self) -> None:
|
||||
"""flush pending writes before exit."""
|
||||
self._flush_queue()
|
||||
self._ws_running = False
|
||||
self._started = False
|
||||
logger.info("[ZhiYi] shutdown")
|
||||
|
||||
# ── Write path ───────────────────────────────────────────────────────────
|
||||
|
||||
def sync_turn(self, user_content: str, assistant_content: str, *, session_id: str = "") -> None:
|
||||
"""每轮对话结束后调用:将 turn 写入队列,异步 commit 到 ZhiYi。"""
|
||||
if not self._started or not self._client:
|
||||
return
|
||||
|
||||
combined = _combine_turn(user_content, assistant_content)
|
||||
if not _is_valid_memory_content(combined):
|
||||
return
|
||||
|
||||
self._turn_counter += 1
|
||||
entry = {
|
||||
"content": combined,
|
||||
"category": "episodes",
|
||||
"metadata": {
|
||||
"session_id": session_id or self._session_id,
|
||||
"turn": self._turn_counter,
|
||||
"platform": self._platform,
|
||||
},
|
||||
}
|
||||
|
||||
with self._queue_lock:
|
||||
self._write_queue.append(entry)
|
||||
|
||||
# 队列超过一定量就 flush,避免积压
|
||||
if len(self._write_queue) >= 3:
|
||||
self._flush_queue()
|
||||
|
||||
def _flush_queue(self) -> None:
|
||||
"""将队列中的记忆批量写入 ZhiYi。"""
|
||||
with self._queue_lock:
|
||||
if not self._write_queue:
|
||||
return
|
||||
entries = self._write_queue[:]
|
||||
self._write_queue.clear()
|
||||
|
||||
# 从 metadata 中读取 agent_id(sync_turn 写入时携带),否则用默认值
|
||||
for entry in entries:
|
||||
meta = entry.get("metadata", {}) or {}
|
||||
self._client.commit(
|
||||
content=entry["content"],
|
||||
category=entry.get("category", "episodes"),
|
||||
metadata=meta,
|
||||
agent_id=meta.get("agent_id", "hermes-a06"),
|
||||
namespace=meta.get("namespace"),
|
||||
)
|
||||
|
||||
# ── Read path ────────────────────────────────────────────────────────────
|
||||
|
||||
def prefetch(self, query: str, *, session_id: str = "") -> str:
|
||||
"""每次 API 调用前触发:执行语义搜索 + 图谱导航 Obsidian 笔记,返回最相关记忆。"""
|
||||
if not self._client or not query or len(query.strip()) < 2:
|
||||
return ""
|
||||
|
||||
blocks = ["[ZhiYi Memory — relevant past context]"]
|
||||
|
||||
# 语义搜索
|
||||
results = self._client.recall(query.strip(), top_k=3,
|
||||
agent_id=os.environ.get("ZHIYI_AGENT_ID", "hermes-a06"))
|
||||
for r in results:
|
||||
score = r.get("score", 0)
|
||||
content = r.get("content", "") or r.get("text", "")
|
||||
cat = r.get("category", "")
|
||||
if content:
|
||||
blocks.append(f" [{score:.2f}][{cat}] {content[:500]}")
|
||||
|
||||
# 图谱导航 + Obsidian 笔记(从 query 提取关键词作为实体)
|
||||
notes = self._client.search_notes(query.strip(), max_hops=2, max_notes=3)
|
||||
if notes:
|
||||
blocks.append("\n[ZhiYi Graph — related Obsidian notes]")
|
||||
for n in notes:
|
||||
title = n.get("title", "无标题")
|
||||
path = n.get("path", "")
|
||||
snippet = n.get("snippet", "")[:200]
|
||||
score = n.get("score", 0)
|
||||
entities = ", ".join(n.get("entities", []))
|
||||
blocks.append(f" [{score:.0f}] {title} ({path})")
|
||||
blocks.append(f" \"{snippet}\"")
|
||||
if entities:
|
||||
blocks.append(f" via entities: {entities}")
|
||||
|
||||
text = "\n".join(blocks)
|
||||
with self._prefetch_lock:
|
||||
self._prefetch_cache = text
|
||||
return text
|
||||
|
||||
def queue_prefetch(self, query: str, *, session_id: str = "") -> None:
|
||||
"""空实现 — prefetch 已是同步的,不需要额外的异步队列。"""
|
||||
pass
|
||||
|
||||
# ── Tool interface ────────────────────────────────────────────────────────
|
||||
|
||||
def get_tool_schemas(self) -> List[Dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"name": "memory_search",
|
||||
"description": "Search ZhiYi semantic memory for relevant past information. Use for: user preferences, earlier decisions, established facts, project context.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "Semantic search query describing what to find. Be specific — the query is encoded with bge-m3 1024-dim and matched against all memories.",
|
||||
},
|
||||
"top_k": {
|
||||
"type": "integer",
|
||||
"description": "Maximum number of results to return (default: 5, max: 20).",
|
||||
"default": 5,
|
||||
},
|
||||
},
|
||||
"required": ["query"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "memory_write",
|
||||
"description": "Explicitly save an important fact or preference to ZhiYi memory. Use when: user tells you something important, you discover a key fact, user confirms a decision.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"content": {
|
||||
"type": "string",
|
||||
"description": "The memory content to save. Should be a complete, meaningful statement. Be specific — include context.",
|
||||
},
|
||||
"category": {
|
||||
"type": "string",
|
||||
"description": "Category for this memory: 'distilled' (important facts), 'episodes' (conversation turns). Default: 'distilled'.",
|
||||
"default": "distilled",
|
||||
},
|
||||
},
|
||||
"required": ["content"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "memory_stats",
|
||||
"description": "Get ZhiYi memory statistics: total documents, vector dimensions, index status.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
def handle_tool_call(self, tool_name: str, args: Dict[str, Any], **kwargs) -> str:
|
||||
if tool_name == "memory_search":
|
||||
return self._tool_memory_search(args.get("query", ""), args.get("top_k", 5))
|
||||
elif tool_name == "memory_write":
|
||||
return self._tool_memory_write(args.get("content", ""), args.get("category", "distilled"))
|
||||
elif tool_name == "memory_stats":
|
||||
return self._tool_memory_stats()
|
||||
return tool_error(tool_name, "Unknown tool")
|
||||
|
||||
# ── Tool handlers ─────────────────────────────────────────────────────────
|
||||
|
||||
def _tool_memory_search(self, query: str, top_k: int) -> str:
|
||||
if not self._client:
|
||||
return json.dumps({"success": False, "error": "ZhiYi client not initialized"})
|
||||
if top_k > 20:
|
||||
top_k = 20
|
||||
results = self._client.recall(query, top_k=top_k)
|
||||
if not results:
|
||||
return json.dumps({"success": True, "results": [], "message": "No relevant memories found"})
|
||||
formatted = []
|
||||
for r in results:
|
||||
content = r.get("content", "") or r.get("text", "")
|
||||
formatted.append({
|
||||
"content": content[:1000], # 截断超长内容
|
||||
"score": round(r.get("score", 0), 4),
|
||||
"category": r.get("category", ""),
|
||||
})
|
||||
return json.dumps({"success": True, "count": len(formatted), "results": formatted})
|
||||
|
||||
def _tool_memory_write(self, content: str, category: str) -> str:
|
||||
if not self._client:
|
||||
return json.dumps({"success": False, "error": "ZhiYi client not initialized"})
|
||||
if not _is_valid_memory_content(content):
|
||||
return json.dumps({"success": False, "error": "Content too short or invalid"})
|
||||
commit_id = self._client.commit(
|
||||
content=content, category=category,
|
||||
agent_id=os.environ.get("ZHIYI_AGENT_ID", "hermes-a06"),
|
||||
)
|
||||
if commit_id:
|
||||
return json.dumps({"success": True, "commit_id": commit_id})
|
||||
return json.dumps({"success": False, "error": "Commit failed — check ZhiYi server logs"})
|
||||
|
||||
def _tool_memory_stats(self) -> str:
|
||||
if not self._client:
|
||||
return json.dumps({"success": False, "error": "ZhiYi client not initialized"})
|
||||
stats = self._client.stats()
|
||||
if not stats:
|
||||
return json.dumps({"success": False, "error": "Could not reach ZhiYi /stats endpoint"})
|
||||
return json.dumps({"success": True, "stats": stats})
|
||||
|
||||
# ── Session management ────────────────────────────────────────────────────
|
||||
|
||||
def on_session_switch(self, new_session_id: str, *, parent_session_id: str = "", reset: bool = False, **kwargs) -> None:
|
||||
self._session_id = new_session_id
|
||||
self._turn_counter = 0
|
||||
if reset:
|
||||
self._flush_queue()
|
||||
|
||||
def on_turn_start(self, turn_number: int, message: str, **kwargs) -> None:
|
||||
"""每轮开始时 flush 待写入的记忆,保证顺序。"""
|
||||
self._flush_queue()
|
||||
|
||||
# ── System prompt ────────────────────────────────────────────────────────
|
||||
|
||||
def system_prompt_block(self) -> str:
|
||||
return (
|
||||
"\n[ZhiYi Memory] You have access to ZhiYi MemoryWeave — a semantic memory system with knowledge graph.\n"
|
||||
"Your prefetch automatically retrieves: (1) relevant semantic memories, (2) Obsidian notes related via graph navigation.\n"
|
||||
"Use memory_search to recall past facts, decisions, and context before answering.\n"
|
||||
"Use memory_write to save important information the user tells you.\n"
|
||||
)
|
||||
|
||||
|
||||
# ── Plugin entry point ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def register(ctx) -> None:
|
||||
"""Called by Hermes plugin system to register this memory provider."""
|
||||
ctx.register_memory_provider(HermesZhiYiMemoryProvider())
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
name: hermes-zhiyi
|
||||
version: 1.0.0
|
||||
description: Bridge to ZhiYi MemoryWeave — bge-m3 1024-dim semantic memory with FAISS + bge-reranker-v2-m3. Replaces local lanceDB with shared ZhiYi server.
|
||||
provider: zhiyi
|
||||
entry: __init__.register
|
||||
memory_provider: true
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
# memory-zhiyi — OpenClaw ZhiYi Memory Plugin
|
||||
|
||||
ZhiYi MemoryWeave plugin for OpenClaw. Replaces `memory-lancedb-pro` with semantic memory + knowledge graph + Obsidian integration.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Install
|
||||
cd ~/.openclaw/workspace/plugins/
|
||||
git clone <this-repo> memory-zhiyi
|
||||
cd memory-zhiyi
|
||||
npm install
|
||||
npm run build
|
||||
|
||||
# Configure in ~/.openclaw/config.yaml:
|
||||
memory:
|
||||
provider: zhiyi
|
||||
|
||||
zhiyi:
|
||||
base_url: http://localhost:7821
|
||||
api_key: your-key
|
||||
namespace: openclaw-main
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
- `src/client.ts` — ZhiYi REST API wrapper
|
||||
- `src/index.ts` — OpenClaw plugin entry (register hooks)
|
||||
- `src/cli.ts` — LanceDB → ZhiYi migration CLI
|
||||
- `src/types.ts` — TypeScript interfaces
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* CLI: migrate-lancedb-to-zhiyi
|
||||
*
|
||||
* Reads OpenClaw LanceDB data and commits to ZhiYi openclaw-main namespace.
|
||||
*
|
||||
* Usage:
|
||||
* npx ts-node src/cli.ts
|
||||
* ZHIYI_BASE_URL=http://localhost:7821 ZHIYI_API_KEY=xxx npx ts-node src/cli.ts
|
||||
*/
|
||||
export {};
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
/**
|
||||
* CLI: migrate-lancedb-to-zhiyi
|
||||
*
|
||||
* Reads OpenClaw LanceDB data and commits to ZhiYi openclaw-main namespace.
|
||||
*
|
||||
* Usage:
|
||||
* npx ts-node src/cli.ts
|
||||
* ZHIYI_BASE_URL=http://localhost:7821 ZHIYI_API_KEY=xxx npx ts-node src/cli.ts
|
||||
*/
|
||||
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
var desc = Object.getOwnPropertyDescriptor(m, k);
|
||||
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
||||
desc = { enumerable: true, get: function() { return m[k]; } };
|
||||
}
|
||||
Object.defineProperty(o, k2, desc);
|
||||
}) : (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
o[k2] = m[k];
|
||||
}));
|
||||
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
||||
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
||||
}) : function(o, v) {
|
||||
o["default"] = v;
|
||||
});
|
||||
var __importStar = (this && this.__importStar) || (function () {
|
||||
var ownKeys = function(o) {
|
||||
ownKeys = Object.getOwnPropertyNames || function (o) {
|
||||
var ar = [];
|
||||
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
||||
return ar;
|
||||
};
|
||||
return ownKeys(o);
|
||||
};
|
||||
return function (mod) {
|
||||
if (mod && mod.__esModule) return mod;
|
||||
var result = {};
|
||||
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
||||
__setModuleDefault(result, mod);
|
||||
return result;
|
||||
};
|
||||
})();
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
const client_1 = require("./client");
|
||||
const path = __importStar(require("path"));
|
||||
const BASE_URL = process.env.ZHIYI_BASE_URL || 'http://localhost:7821';
|
||||
const API_KEY = process.env.ZHIYI_API_KEY || 'zhiyi-dev-key-2026';
|
||||
const NAMESPACE = process.env.OC_NAMESPACE || 'openclaw-main';
|
||||
const LANCEDB_PATH = path.join(process.env.HOME || '/home/muc', '.openclaw/memory-lancedb');
|
||||
async function main() {
|
||||
console.log(`[migrate] ZhiYi: ${BASE_URL}`);
|
||||
console.log(`[migrate] Namespace: ${NAMESPACE}`);
|
||||
console.log(`[migrate] LanceDB path: ${LANCEDB_PATH}`);
|
||||
const client = new client_1.ZhiYiClient({ baseUrl: BASE_URL, apiKey: API_KEY, namespace: NAMESPACE });
|
||||
const ok = await client.health();
|
||||
if (!ok) {
|
||||
console.error('[migrate] ERROR: ZhiYi unreachable');
|
||||
process.exit(1);
|
||||
}
|
||||
// TODO: OpenClaw LanceDB is a separate store
|
||||
// For now, guide the user:
|
||||
console.log('[migrate]');
|
||||
console.log('[migrate] LanceDB must be read from OpenClaw workspace.');
|
||||
console.log('[migrate] The LanceDB data lives at: ~/.openclaw/memory-lancedb/');
|
||||
console.log('[migrate] This migration requires OpenClaw to be running.');
|
||||
console.log('[migrate]');
|
||||
console.log('[migrate] Manual migration steps:');
|
||||
console.log('[migrate] 1. OpenClaw LanceDB stores episodes in ~/.openclaw/memory-lancedb/');
|
||||
console.log('[migrate] 2. Export: use OpenClaw CLI or read LanceDB directly');
|
||||
console.log('[migrate] 3. Import: call client.batchCommit() with exported data');
|
||||
console.log('[migrate] 4. Switch provider: set memory.provider: zhiyi in OpenClaw config');
|
||||
console.log('[migrate]');
|
||||
console.log('[migrate] TODO: implement LanceDB reader once OpenClaw config is available');
|
||||
}
|
||||
main().catch(console.error);
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
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<CommitResult | null>;
|
||||
batchCommit(items: Array<{
|
||||
content: string;
|
||||
category?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}>, 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<StatsResult>;
|
||||
private _log;
|
||||
}
|
||||
|
|
@ -0,0 +1,215 @@
|
|||
"use strict";
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.ZhiYiClient = void 0;
|
||||
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,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-API-Key': config.apiKey,
|
||||
},
|
||||
});
|
||||
}
|
||||
// ─── Health ───────────────────────────────────────────────
|
||||
async health() {
|
||||
try {
|
||||
const r = await this.client.get('/health');
|
||||
return r.status === 200;
|
||||
}
|
||||
catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// ─── Commit ───────────────────────────────────────────────
|
||||
async commit(content, category = 'episodes', metadata) {
|
||||
try {
|
||||
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;
|
||||
}
|
||||
catch (e) {
|
||||
this._log('commit', e);
|
||||
return { error: e.message };
|
||||
}
|
||||
}
|
||||
// ─── 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,
|
||||
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) {
|
||||
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 },
|
||||
});
|
||||
return r.data.notes || [];
|
||||
}
|
||||
catch (e) {
|
||||
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,
|
||||
});
|
||||
return r.data.paths || [];
|
||||
}
|
||||
catch (e) {
|
||||
this._log('navigate', e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
// ─── Stats ────────────────────────────────────────────────
|
||||
async stats() {
|
||||
try {
|
||||
const r = await this.client.get('/api/v1/stats');
|
||||
return r.data;
|
||||
}
|
||||
catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
// ─── Internal ─────────────────────────────────────────────
|
||||
_log(op, e) {
|
||||
const msg = e?.response?.data?.error || e?.message || String(e);
|
||||
console.error(`[ZhiYi] ${op}: ${msg}`);
|
||||
}
|
||||
}
|
||||
exports.ZhiYiClient = ZhiYiClient;
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
/**
|
||||
* 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';
|
||||
export declare const pluginManifest: {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
kind: "memory";
|
||||
version: string;
|
||||
};
|
||||
declare const plugin: {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
kind: "memory";
|
||||
register(api: any): void;
|
||||
getClient(): ZhiYiClient | null;
|
||||
getConfig(): ZhiYiMemoryConfig | null;
|
||||
};
|
||||
export default plugin;
|
||||
export { ZhiYiClient };
|
||||
|
|
@ -0,0 +1,205 @@
|
|||
"use strict";
|
||||
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, feedback loop, and prefetch',
|
||||
kind: 'memory',
|
||||
version: '0.2.0',
|
||||
};
|
||||
let _client = null;
|
||||
let _config = null;
|
||||
function loadConfig(api) {
|
||||
const raw = api.config?.zhiyi || api.config?.memory_zhiyi || {};
|
||||
return {
|
||||
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 = {
|
||||
id: exports.pluginManifest.id,
|
||||
name: exports.pluginManifest.name,
|
||||
description: exports.pluginManifest.description,
|
||||
kind: exports.pluginManifest.kind,
|
||||
register(api) {
|
||||
_config = loadConfig(api);
|
||||
_client = new client_1.ZhiYiClient(_config);
|
||||
// ── Health check via service start (async, doesn't block register) ─
|
||||
api.registerService({
|
||||
id: 'memory-zhiyi',
|
||||
start: async () => {
|
||||
const ok = await _client.health();
|
||||
if (!ok) {
|
||||
api.logger?.error('[memory-zhiyi] ZhiYi unreachable — check ZHIYI_BASE_URL');
|
||||
}
|
||||
else {
|
||||
api.logger?.info(`[memory-zhiyi] v0.2.0 connected — ns=${_config.namespace}`);
|
||||
}
|
||||
},
|
||||
stop: () => {
|
||||
_client?.clearCache();
|
||||
api.logger?.info('[memory-zhiyi] stopped');
|
||||
},
|
||||
});
|
||||
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;
|
||||
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);
|
||||
});
|
||||
// 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; },
|
||||
};
|
||||
exports.default = plugin;
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
export interface ZhiYiMemoryConfig {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
namespace: string;
|
||||
agentId?: string;
|
||||
timeout?: number;
|
||||
prefetchEnabled?: boolean;
|
||||
}
|
||||
export interface RecallResult {
|
||||
id: string;
|
||||
content: string;
|
||||
score: number;
|
||||
category: string;
|
||||
}
|
||||
export interface CommitResult {
|
||||
episode_id?: string;
|
||||
memory_ids?: string[];
|
||||
id?: string;
|
||||
error?: string;
|
||||
}
|
||||
export interface SearchNotesResult {
|
||||
path: string;
|
||||
title: string;
|
||||
snippet: string;
|
||||
entities: string[];
|
||||
score: number;
|
||||
}
|
||||
export interface GraphPath {
|
||||
source: string;
|
||||
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;
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
{
|
||||
"id": "memory-zhiyi",
|
||||
"name": "Memory ZhiYi",
|
||||
"description": "ZhiYi MemoryWeave plugin for OpenClaw \u2014 semantic memory with knowledge graph",
|
||||
"activation": {
|
||||
"onStartup": false,
|
||||
"onCommands": [
|
||||
"zhiyi-mem"
|
||||
]
|
||||
},
|
||||
"kind": "memory",
|
||||
"contracts": {
|
||||
"tools": [
|
||||
"memory_forget",
|
||||
"memory_recall",
|
||||
"memory_store"
|
||||
]
|
||||
},
|
||||
"uiHints": {
|
||||
"base_url": {
|
||||
"label": "ZhiYi API Base URL",
|
||||
"placeholder": "http://localhost:7821",
|
||||
"help": "ZhiYi MemoryWeave API endpoint"
|
||||
},
|
||||
"api_key": {
|
||||
"label": "ZhiYi API Key",
|
||||
"sensitive": true,
|
||||
"placeholder": "your-api-key",
|
||||
"help": "API key for ZhiYi MemoryWeave"
|
||||
},
|
||||
"namespace": {
|
||||
"label": "Namespace",
|
||||
"placeholder": "openclaw-main",
|
||||
"help": "Agent namespace in ZhiYi"
|
||||
},
|
||||
"agent_id": {
|
||||
"label": "Agent ID",
|
||||
"placeholder": "openclaw",
|
||||
"help": "Agent identifier in ZhiYi"
|
||||
}
|
||||
},
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"base_url": {
|
||||
"type": "string"
|
||||
},
|
||||
"api_key": {
|
||||
"type": "string"
|
||||
},
|
||||
"namespace": {
|
||||
"type": "string"
|
||||
},
|
||||
"agent_id": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"version": "0.2.0"
|
||||
}
|
||||
|
|
@ -0,0 +1,381 @@
|
|||
{
|
||||
"name": "memory-zhiyi",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "memory-zhiyi",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"axios": "^1.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.0.0",
|
||||
"typescript": "^5.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "20.19.41",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.41.tgz",
|
||||
"integrity": "sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/agent-base": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
|
||||
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/asynckit": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.16.1",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.16.1.tgz",
|
||||
"integrity": "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.16.0",
|
||||
"form-data": "^4.0.5",
|
||||
"https-proxy-agent": "^5.0.1",
|
||||
"proxy-from-env": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bind-apply-helpers": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"function-bind": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/combined-stream": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"delayed-stream": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/delayed-stream": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
"gopd": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-define-property": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-errors": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-object-atoms": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
|
||||
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-set-tostringtag": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
||||
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"get-intrinsic": "^1.2.6",
|
||||
"has-tostringtag": "^1.0.2",
|
||||
"hasown": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.16.0",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
|
||||
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/RubenVerborgh"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"debug": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/form-data": {
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
||||
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"es-set-tostringtag": "^2.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"mime-types": "^2.1.12"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/function-bind": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-intrinsic": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
"es-define-property": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
"es-object-atoms": "^1.1.1",
|
||||
"function-bind": "^1.1.2",
|
||||
"get-proto": "^1.0.1",
|
||||
"gopd": "^1.2.0",
|
||||
"has-symbols": "^1.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"math-intrinsics": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dunder-proto": "^1.0.1",
|
||||
"es-object-atoms": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/gopd": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has-symbols": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has-tostringtag": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
||||
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-symbols": "^1.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/hasown": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
|
||||
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"function-bind": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/https-proxy-agent": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
|
||||
"integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"agent-base": "6",
|
||||
"debug": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-db": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-types": {
|
||||
"version": "2.1.35",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-db": "1.52.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
|
||||
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
{
|
||||
"name": "memory-zhiyi",
|
||||
"version": "0.1.0",
|
||||
"description": "ZhiYi MemoryWeave plugin for OpenClaw — semantic memory with knowledge graph",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"test": "echo no tests yet"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.3.0",
|
||||
"@types/node": "^20.0.0"
|
||||
},
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./dist/index.js"
|
||||
],
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.5.27"
|
||||
}
|
||||
},
|
||||
"peerDependencies": {
|
||||
"openclaw": ">=2026.5.27"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* CLI: migrate-lancedb-to-zhiyi
|
||||
*
|
||||
* Reads OpenClaw LanceDB data and commits to ZhiYi openclaw-main namespace.
|
||||
*
|
||||
* Usage:
|
||||
* npx ts-node src/cli.ts
|
||||
* ZHIYI_BASE_URL=http://localhost:7821 ZHIYI_API_KEY=xxx npx ts-node src/cli.ts
|
||||
*/
|
||||
|
||||
import { ZhiYiClient } from './client';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
|
||||
const BASE_URL = process.env.ZHIYI_BASE_URL || 'http://localhost:7821';
|
||||
const API_KEY = process.env.ZHIYI_API_KEY || 'zhiyi-dev-key-2026';
|
||||
const NAMESPACE = process.env.OC_NAMESPACE || 'openclaw-main';
|
||||
const LANCEDB_PATH = path.join(process.env.HOME || '/home/muc', '.openclaw/memory-lancedb');
|
||||
|
||||
async function main() {
|
||||
console.log(`[migrate] ZhiYi: ${BASE_URL}`);
|
||||
console.log(`[migrate] Namespace: ${NAMESPACE}`);
|
||||
console.log(`[migrate] LanceDB path: ${LANCEDB_PATH}`);
|
||||
|
||||
const client = new ZhiYiClient({ baseUrl: BASE_URL, apiKey: API_KEY, namespace: NAMESPACE });
|
||||
|
||||
const ok = await client.health();
|
||||
if (!ok) { console.error('[migrate] ERROR: ZhiYi unreachable'); process.exit(1); }
|
||||
|
||||
// TODO: OpenClaw LanceDB is a separate store
|
||||
// For now, guide the user:
|
||||
console.log('[migrate]');
|
||||
console.log('[migrate] LanceDB must be read from OpenClaw workspace.');
|
||||
console.log('[migrate] The LanceDB data lives at: ~/.openclaw/memory-lancedb/');
|
||||
console.log('[migrate] This migration requires OpenClaw to be running.');
|
||||
console.log('[migrate]');
|
||||
console.log('[migrate] Manual migration steps:');
|
||||
console.log('[migrate] 1. OpenClaw LanceDB stores episodes in ~/.openclaw/memory-lancedb/');
|
||||
console.log('[migrate] 2. Export: use OpenClaw CLI or read LanceDB directly');
|
||||
console.log('[migrate] 3. Import: call client.batchCommit() with exported data');
|
||||
console.log('[migrate] 4. Switch provider: set memory.provider: zhiyi in OpenClaw config');
|
||||
console.log('[migrate]');
|
||||
console.log('[migrate] TODO: implement LanceDB reader once OpenClaw config is available');
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
import axios, { AxiosInstance } from 'axios';
|
||||
import type { ZhiYiMemoryConfig, RecallResult, SearchNotesResult, GraphPath } from './types';
|
||||
|
||||
export class ZhiYiClient {
|
||||
private client: AxiosInstance;
|
||||
private ns: string;
|
||||
|
||||
constructor(config: ZhiYiMemoryConfig) {
|
||||
this.ns = config.namespace || 'openclaw-main';
|
||||
this.client = axios.create({
|
||||
baseURL: config.baseUrl,
|
||||
timeout: config.timeout || 10000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-API-Key': config.apiKey,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async health(): Promise<boolean> {
|
||||
try {
|
||||
const r = await this.client.get('/health');
|
||||
return r.status === 200;
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
async commit(content: string, category = 'episodes', metadata?: Record<string, unknown>): Promise<string | 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; }
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
async recall(query: string, topK = 5): Promise<RecallResult[]> {
|
||||
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 || '',
|
||||
}));
|
||||
} catch (e: any) { console.error('[ZhiYi] recall:', e.message); return []; }
|
||||
}
|
||||
|
||||
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 } });
|
||||
return r.data.notes || [];
|
||||
} catch (e: any) { console.error('[ZhiYi] searchNotes:', e.message); 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 });
|
||||
return r.data.paths || [];
|
||||
} catch (e: any) { console.error('[ZhiYi] navigate:', e.message); return []; }
|
||||
}
|
||||
|
||||
async stats(): Promise<Record<string, unknown>> {
|
||||
try { const r = await this.client.get('/api/v1/stats'); return r.data; }
|
||||
catch { return {}; }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
/**
|
||||
* memory-zhiyi — ZhiYi MemoryWeave Plugin for OpenClaw
|
||||
*/
|
||||
import { ZhiYiClient } from './client';
|
||||
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',
|
||||
kind: 'memory' as const,
|
||||
version: '0.1.0',
|
||||
};
|
||||
|
||||
let _client: ZhiYiClient | null = null;
|
||||
let _config: ZhiYiMemoryConfig | null = null;
|
||||
|
||||
function loadConfig(api: any): ZhiYiMemoryConfig {
|
||||
const raw = api.config?.zhiyi || api.config?.memory_zhiyi || {};
|
||||
return {
|
||||
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',
|
||||
timeout: raw.timeout || 10000,
|
||||
};
|
||||
}
|
||||
|
||||
const plugin = {
|
||||
id: pluginManifest.id,
|
||||
name: pluginManifest.name,
|
||||
description: pluginManifest.description,
|
||||
kind: pluginManifest.kind,
|
||||
|
||||
async register(api: any) {
|
||||
_config = loadConfig(api);
|
||||
_client = new ZhiYiClient(_config);
|
||||
|
||||
const ok = await _client.health();
|
||||
if (!ok) {
|
||||
api.logger?.error('[memory-zhiyi] ZhiYi unreachable — check ZHIYI_BASE_URL');
|
||||
return;
|
||||
}
|
||||
|
||||
api.logger?.info(`[memory-zhiyi] registered — ns=${_config.namespace}`);
|
||||
|
||||
// Hook into OpenClaw memory lifecycle
|
||||
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> }) => {
|
||||
if (!_client) return null;
|
||||
return await _client.commit(params.content, params.category ?? 'episodes', params.metadata);
|
||||
});
|
||||
|
||||
api.on('memory:graphNavigate', async (params: { entity: string; maxHops?: number }) => {
|
||||
if (!_client) return [];
|
||||
return await _client.navigate(params.entity, params.maxHops ?? 2);
|
||||
});
|
||||
|
||||
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');
|
||||
},
|
||||
|
||||
getClient() { return _client; },
|
||||
getConfig() { return _config; },
|
||||
};
|
||||
|
||||
export default plugin;
|
||||
export { ZhiYiClient };
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
export interface ZhiYiMemoryConfig {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
namespace: string;
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
export interface RecallResult {
|
||||
id: string;
|
||||
content: string;
|
||||
score: number;
|
||||
category: string;
|
||||
}
|
||||
|
||||
export interface SearchNotesResult {
|
||||
path: string;
|
||||
title: string;
|
||||
snippet: string;
|
||||
entities: string[];
|
||||
score: number;
|
||||
}
|
||||
|
||||
export interface GraphPath {
|
||||
source: string;
|
||||
target: string;
|
||||
relation: string;
|
||||
weight: number;
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "commonjs",
|
||||
"moduleResolution": "node",
|
||||
"ignoreDeprecations": "6.0",
|
||||
"outDir": "dist-cjs",
|
||||
"rootDir": "src",
|
||||
"declaration": true,
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "commonjs",
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"declaration": true,
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
Loading…
Reference in New Issue