762 lines
34 KiB
Python
762 lines
34 KiB
Python
"""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 mark_useful(self, memory_id: str) -> bool:
|
||
"""标记一条记忆为有用。"""
|
||
try:
|
||
r = self._session.post(self._url("/api/v1/feedback/useful"),
|
||
json={"memory_id": memory_id, "agent_id": "hermes-a06"},
|
||
timeout=self.timeout)
|
||
return r.status_code == 200
|
||
except Exception:
|
||
return False
|
||
|
||
def mark_not_useful(self, memory_id: str, reason: str = "") -> bool:
|
||
"""标记一条记忆为无用。"""
|
||
try:
|
||
r = self._session.post(self._url("/api/v1/feedback/not-useful"),
|
||
json={"memory_id": memory_id, "agent_id": "hermes-a06", "reason": reason},
|
||
timeout=self.timeout)
|
||
return r.status_code == 200
|
||
except Exception:
|
||
return False
|
||
|
||
def metrics(self) -> Dict[str, Any]:
|
||
"""获取自优化 7 项核心指标。"""
|
||
try:
|
||
r = self._session.get(self._url("/api/v1/metrics/self"), 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 []
|
||
|
||
def graph_navigate(self, entity: str, max_hops: int = 2,
|
||
namespace: Optional[str] = None) -> Dict[str, Any]:
|
||
"""图谱导航:查询实体的 N 跳关系网络。返回 {entity, count, paths}。"""
|
||
try:
|
||
payload = {"entity": entity, "max_hops": max_hops}
|
||
if namespace:
|
||
payload["namespace"] = namespace
|
||
r = self._session.post(self._url("/api/v1/graph/navigate"), json=payload, timeout=self.timeout)
|
||
if r.status_code == 200:
|
||
return r.json()
|
||
logger.warning("ZhiYi graph_navigate failed: %s %s", r.status_code, r.text[:200])
|
||
return {}
|
||
except Exception as e:
|
||
logger.warning("ZhiYi graph_navigate error: %s", e)
|
||
return {}
|
||
|
||
def graph_stats(self) -> Dict[str, Any]:
|
||
"""获取图谱统计:节点数、边数、密度。"""
|
||
try:
|
||
r = self._session.get(self._url("/api/v1/graph/stats"), timeout=self.timeout)
|
||
if r.status_code == 200:
|
||
return r.json()
|
||
return {}
|
||
except Exception as e:
|
||
logger.warning("ZhiYi graph_stats 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)
|
||
|
||
# 合并 WebSocket prefetch.push 推送事件
|
||
with self._prefetch_lock:
|
||
if self._prefetch_cache and "[ZhiYi Prefetch" in self._prefetch_cache:
|
||
text += "\n" + self._prefetch_cache
|
||
|
||
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": {},
|
||
},
|
||
},
|
||
{
|
||
"name": "memory_feedback",
|
||
"description": "Mark a memory as useful or not-useful. Feedback activates ZhiYi's self-optimization — useful memories get higher priority, not-useful memories trigger quality monitoring and potential deprecation.",
|
||
"parameters": {
|
||
"type": "object",
|
||
"properties": {
|
||
"memory_id": {
|
||
"type": "string",
|
||
"description": "The memory ID to provide feedback on (from memory_search results).",
|
||
},
|
||
"useful": {
|
||
"type": "boolean",
|
||
"description": "True = this memory was helpful, False = this memory was not helpful.",
|
||
},
|
||
"reason": {
|
||
"type": "string",
|
||
"description": "Why this memory was not useful (optional, only used when useful=false).",
|
||
},
|
||
},
|
||
"required": ["memory_id", "useful"],
|
||
},
|
||
},
|
||
{
|
||
"name": "memory_metrics",
|
||
"description": "Get ZhiYi self-optimization metrics: recall hit rate, usefulness rate, gap closure rate, distillation loss, and more. Use this to check memory system health.",
|
||
"parameters": {
|
||
"type": "object",
|
||
"properties": {},
|
||
},
|
||
},
|
||
{
|
||
"name": "memory_graph_navigate",
|
||
"description": "Navigate the ZhiYi knowledge graph to understand relationships around an entity. Shows N-hop relationship network — what concepts, people, or projects are connected, and how. Use when: analyzing project structure, understanding who/what is related to a topic, exploring context dependencies.",
|
||
"parameters": {
|
||
"type": "object",
|
||
"properties": {
|
||
"entity": {
|
||
"type": "string",
|
||
"description": "The central entity to navigate from (e.g. '牧尘', '织忆', '小唯', 'openclaw'). Chinese and English both work.",
|
||
},
|
||
"max_hops": {
|
||
"type": "integer",
|
||
"description": "Maximum path depth (default: 2). 1-hop = direct neighbors only; 2-hop = friends-of-friends. Larger values return more paths but are slower.",
|
||
"default": 2,
|
||
},
|
||
},
|
||
"required": ["entity"],
|
||
},
|
||
},
|
||
{
|
||
"name": "memory_graph_stats",
|
||
"description": "Get ZhiYi knowledge graph statistics: total nodes, edges, and graph density. Use to check graph size and health.",
|
||
"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_feedback":
|
||
return self._tool_memory_feedback(
|
||
args.get("memory_id", ""), args.get("useful", True), args.get("reason", ""))
|
||
elif tool_name == "memory_metrics":
|
||
return self._tool_memory_metrics()
|
||
elif tool_name == "memory_stats":
|
||
return self._tool_memory_stats()
|
||
elif tool_name == "memory_graph_navigate":
|
||
return self._tool_memory_graph_navigate(
|
||
args.get("entity", ""), args.get("max_hops", 2))
|
||
elif tool_name == "memory_graph_stats":
|
||
return self._tool_memory_graph_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", ""),
|
||
"quality_score": round(r.get("quality_score", 0), 4),
|
||
"v_value": round(r.get("v_value", 0), 4),
|
||
})
|
||
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})
|
||
|
||
def _tool_memory_feedback(self, memory_id: str, useful: bool, reason: str = "") -> str:
|
||
if not self._client:
|
||
return json.dumps({"success": False, "error": "ZhiYi client not initialized"})
|
||
if not memory_id:
|
||
return json.dumps({"success": False, "error": "memory_id required"})
|
||
if useful:
|
||
ok = self._client.mark_useful(memory_id)
|
||
else:
|
||
ok = self._client.mark_not_useful(memory_id, reason)
|
||
if ok:
|
||
return json.dumps({"success": True, "memory_id": memory_id, "useful": useful,
|
||
"message": "Feedback recorded — VProp + Dashboard updated"})
|
||
return json.dumps({"success": False, "error": "ZhiYi feedback API failed"})
|
||
|
||
def _tool_memory_metrics(self) -> str:
|
||
if not self._client:
|
||
return json.dumps({"success": False, "error": "ZhiYi client not initialized"})
|
||
metrics = self._client.metrics()
|
||
if not metrics:
|
||
return json.dumps({"success": False, "error": "Could not reach ZhiYi /metrics/self"})
|
||
return json.dumps({"success": True, "metrics": metrics})
|
||
|
||
def _tool_memory_graph_navigate(self, entity: str, max_hops: int) -> str:
|
||
"""图谱导航:查询实体的 N 跳关系网络。"""
|
||
if not self._client:
|
||
return json.dumps({"success": False, "error": "ZhiYi client not initialized"})
|
||
if not entity or len(entity.strip()) < 1:
|
||
return json.dumps({"success": False, "error": "entity is required"})
|
||
if max_hops < 1:
|
||
max_hops = 1
|
||
if max_hops > 5:
|
||
max_hops = 5
|
||
|
||
result = self._client.graph_navigate(entity.strip(), max_hops=max_hops)
|
||
if not result:
|
||
return json.dumps({"success": False, "error": "Graph navigate failed — check ZhiYi server logs"})
|
||
|
||
# 格式化路径输出,保留关键信息
|
||
paths = result.get("paths", [])
|
||
formatted_paths = []
|
||
for p in paths[:50]: # 最多返回50条路径,避免太长
|
||
formatted_paths.append({
|
||
"from": p.get("from", ""),
|
||
"relation": p.get("relation", ""),
|
||
"to": p.get("to", ""),
|
||
"hop": p.get("hop", 1),
|
||
"weight": round(p.get("weight", 0), 3),
|
||
})
|
||
|
||
return json.dumps({
|
||
"success": True,
|
||
"entity": result.get("entity", entity),
|
||
"bidirectional": result.get("bidirectional", False),
|
||
"count": result.get("count", len(formatted_paths)),
|
||
"paths": formatted_paths,
|
||
"message": f"Found {result.get('count', 0)} paths within {max_hops}-hop network",
|
||
})
|
||
|
||
def _tool_memory_graph_stats(self) -> str:
|
||
"""获取图谱统计:节点数、边数、密度。"""
|
||
if not self._client:
|
||
return json.dumps({"success": False, "error": "ZhiYi client not initialized"})
|
||
stats = self._client.graph_stats()
|
||
if not stats:
|
||
return json.dumps({"success": False, "error": "Could not reach ZhiYi /graph/stats"})
|
||
return json.dumps({
|
||
"success": True,
|
||
"stats": stats,
|
||
"message": f"Graph has {stats.get('node_count', 0)} nodes and {stats.get('edge_count', 0)} edges (density: {stats.get('density', 0):.4f})",
|
||
})
|
||
|
||
# ── 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"
|
||
"Tools available:\\n"
|
||
" memory_search — semantic search for relevant past information\\n"
|
||
" memory_write — save an important fact or preference\\n"
|
||
" memory_feedback — mark memories as useful/not-useful (drives self-optimization)\\n"
|
||
" memory_metrics — check memory system health (recall hit rate, gap closures, etc.)\\n"
|
||
" memory_stats — get memory statistics (total docs, vector dimensions)\\n"
|
||
" memory_graph_navigate — navigate the knowledge graph to understand entity relationships (N-hop network)\\n"
|
||
" memory_graph_stats — get knowledge graph statistics (nodes, edges, density)\\n"
|
||
)
|
||
|
||
|
||
# ── Plugin entry point ──────────────────────────────────────────────────────
|
||
|
||
|
||
def register(ctx) -> None:
|
||
"""Called by Hermes plugin system to register this memory provider."""
|
||
ctx.register_memory_provider(HermesZhiYiMemoryProvider()) |