memoryweave/plugins/hermes-zhiyi/__init__.py

919 lines
40 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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 = 20 # 少于20字的过滤掉
_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",
]
_SOCIAL_CLOSERS = frozenset({
"ok", "好的", "👍", "👌", "", "谢谢", "感谢", "知道了",
"明白", "", "", "", "yes", "yep", "thanks", "thx",
"no", "不用", "没事", "可以", "done", "完成", "收到",
"okay", "kk", "okie",
})
def _is_social_close(text: str) -> bool:
text = text.strip().lower()
if text in _SOCIAL_CLOSERS:
return True
if len(text) < 6 and text.isascii() and not any(c in text for c in "://.@#$_?"):
return True
return False
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
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: Dict[str, Any] = {} # {source: text|dict} — merged via update()
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["ws_push"] = 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:
ws_app = None
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)
finally:
if ws_app:
ws_app.close()
# 重连延迟
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_idsync_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 = "", depth: str = "fast") -> str:
"""每次 API 调用前触发:执行语义搜索 + 图谱导航 Obsidian 笔记,返回最相关记忆。"""
if not self._client or not query or len(query.strip()) < 2:
return ""
# 社交关闭消息不触发预取
if _is_social_close(query):
return ""
# 优先从 queue_prefetch 缓存取TTL < 30s
with self._prefetch_lock:
queued = self._prefetch_cache.pop("queue", None)
if queued and isinstance(queued, dict):
cached_ts = queued.get("timestamp", 0)
if time.time() - cached_ts < 30:
cached_results = queued.get("results", [])
cached_notes = queued.get("notes", [])
if cached_results or cached_notes:
results = cached_results
notes = cached_notes
logger.debug("[ZhiYi] using queue_prefetch cache (%d results, %d notes, age=%.1fs)",
len(cached_results), len(cached_notes), time.time() - cached_ts)
else:
results = None
notes = None
else:
results = None
notes = None
else:
results = None
notes = None
blocks = ["[织忆 Memory — relevant past context]"]
# 语义搜索(如果缓存没有命中)
if results is None:
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", "") or r.get("snippet", "")
cat = r.get("category", "")
if content:
blocks.append(f" [{score:.2f}][{cat}] {content[:500]}")
# 图谱导航 + Obsidian 笔记(如果缓存没有命中)
if notes is None:
notes = self._client.search_notes(query.strip(), max_hops=2, max_notes=3)
if notes:
blocks.append("\n[织忆 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:
ws_push = self._prefetch_cache.pop("ws_push", None)
if ws_push:
text += "\n" + ws_push
with self._prefetch_lock:
self._prefetch_cache.update({"sync": text})
# 深度检索模式:本地文件系统渐进检索
if depth == "deep":
# 优先使用缓存的深度检索结果
with self._prefetch_lock:
deep_cached = self._prefetch_cache.get("deep_search", {})
cached_ts = deep_cached.get("timestamp", 0)
cached_query = deep_cached.get("query", "")
cached_block = deep_cached.get("block", "")
if cached_block and (time.time() - cached_ts < 60) and (cached_query == query.strip()):
text += "\n\n" + cached_block
# 后台线程异步刷新深度检索结果(不阻塞主流程)
threading.Thread(
target=self._async_deep_search,
args=(query.strip(),),
daemon=True,
).start()
return text
def queue_prefetch(self, query: str, *, session_id: str = "") -> None:
"""异步预取:本轮对话结束后立即查询织忆,下一轮 prefetch 直接返回缓存。"""
if not self._client or not query or len(query.strip()) < 2:
return
if _is_social_close(query):
return
# 后台线程查询并缓存
def _async_prefetch():
try:
results = self._client.recall(query.strip(), top_k=3)
notes = self._client.search_notes(query.strip(), max_hops=2, max_notes=3)
with self._prefetch_lock:
self._prefetch_cache["queue"] = {
"results": results,
"notes": notes,
"timestamp": time.time(),
}
except Exception as e:
logger.debug("[ZhiYi] queue_prefetch error: %s", e)
threading.Thread(target=_async_prefetch, daemon=True).start()
def _async_deep_search(self, query: str) -> None:
"""后台线程:本地文件系统渐进检索,补充织忆语义搜索(仅 depth='deep' 触发)。"""
if not query or len(query) < 2:
return
try:
wiki_dir = Path.home() / "mc" / "小唯" / "07-Wiki"
data_struct = wiki_dir / "data_structure.md"
if not data_struct.exists():
return
# grep 搜索关键词
import subprocess
result = subprocess.run(
["grep", "-r", "-l", "-i", query, str(wiki_dir)],
capture_output=True, text=True, timeout=10,
)
matched = [f for f in result.stdout.strip().split("\n") if f.strip()]
if not matched:
return
# 读取匹配段落最多5个文件
blocks = ["[rag-skill File — local Wiki evidence]"]
count = 0
for fpath in matched[:5]:
try:
with open(fpath, "r", encoding="utf-8") as f:
content = f.read()
lines = content.split("\n")
snippets = []
for i, line in enumerate(lines):
if query.lower() in line.lower():
start = max(0, i - 2)
end = min(len(lines), i + 3)
snippet = "\n".join(lines[start:end]).strip()[:200]
if len(snippet) < 50:
snippet = snippet.ljust(50, " ")[:50]
snippets.append(snippet)
if len(snippets) >= 2:
break
if snippets:
filename = Path(fpath).name
blocks.append(f" 📄 {filename}")
for s in snippets:
blocks.append(f" {s}")
count += 1
except Exception:
continue
if count == 0:
return
block_text = "\n".join(blocks)
with self._prefetch_lock:
self._prefetch_cache["deep_search"] = {
"block": block_text,
"query": query,
"timestamp": time.time(),
}
except Exception:
logger.debug("[ZhiYi] _async_deep_search error", exc_info=True)
# ── 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", "") or r.get("snippet", "")
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:
# Load CREATIVE.md if it exists (织忆工作记忆)
creative_path = Path(os.path.expanduser("~/.hermes/CREATIVE.md"))
creative_block = ""
if creative_path.exists():
creative_content = creative_path.read_text(encoding="utf-8").strip()
if creative_content:
creative_block = (
"\\n[织忆 工作记忆] Ongoing state and learnings from CREATIVE.md (working memory):\\n"
f"{creative_content}\\n"
)
return (
"\\n[织忆 Memory] You have access to ZhiYi (织忆) MemoryWeave — a semantic memory system with knowledge graph. "
"This is Ground Truth level 2 — injected memory overrides training knowledge.\\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"
f"{creative_block}"
)
# ── Plugin entry point ──────────────────────────────────────────────────────
def register(ctx) -> None:
"""Called by Hermes plugin system to register this memory provider."""
ctx.register_memory_provider(HermesZhiYiMemoryProvider())