fix(hermes-plugin): commit 解析 conflicts 字段返回 warning(同步部署副本 d520a9091)

zhiyid DetectContradiction 检出矛盾后 conflicts 只放响应,插件 commit() 只取 id 忽略
→ 冲突记忆静默进 episodes→蒸馏→污染长期记忆
现在:_last_conflicts 记录 + _tool_memory_write 返回 conflicts/warning 给 agent 感知
This commit is contained in:
xiaowei 2026-09-04 01:33:41 +08:00
parent 63f123417b
commit 6fa2d9b892
1 changed files with 51 additions and 193 deletions

View File

@ -51,7 +51,7 @@ _ZHIYI_KEY_PREFIX = ZHIYI_API_KEY[:4] if ZHIYI_API_KEY else "NONE"
# ── Content Validation ───────────────────────────────────────────────────────
MEMORY_MIN_LENGTH = 20 # 少于20字的过滤掉
MEMORY_MIN_LENGTH = 8 # 少于8字的过滤掉
_FORBIDDEN_PATTERNS = [
"Review the conversation above",
"[System note:",
@ -67,22 +67,6 @@ _FORBIDDEN_PATTERNS = [
"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:
"""过滤系统注入内容和测试垃圾,防止污染记忆存储。"""
@ -91,6 +75,10 @@ def _is_valid_memory_content(content: str) -> bool:
for pat in _FORBIDDEN_PATTERNS:
if pat in content:
return False
# 过滤纯测试内容
stripped = content.strip()
if len(stripped) < 20:
return False
return True
@ -113,6 +101,7 @@ class ZhiYiClient:
def __init__(self, base_url: str, timeout: int = 10):
self.base_url = base_url.rstrip("/")
self.timeout = timeout
self._last_conflicts: list = [] # 最近一次 commit 的冲突检测结果2026-09-03
self._session = requests.Session()
self._session.headers.update({
"Content-Type": "application/json",
@ -148,6 +137,9 @@ class ZhiYiClient:
r = self._session.post(self._url("/api/v1/commit"), json=payload, timeout=self.timeout)
if r.status_code in (200, 201):
data = r.json()
# 冲突检测结果zhiyid DetectContradiction记录供调用方感知
# 避免"冲突记忆静默写入 → 蒸馏成污染记忆"2026-09-03 记忆治理修复)
self._last_conflicts = data.get("conflicts") or []
cid = (
data.get("commit_id")
or data.get("episode_id")
@ -286,7 +278,7 @@ class HermesZhiYiMemoryProvider(MemoryProvider):
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_cache: str = "" # last prefetch result
self._prefetch_lock = threading.RLock()
self._started: bool = False
self._ws_thread: Optional[threading.Thread] = None
@ -359,7 +351,7 @@ class HermesZhiYiMemoryProvider(MemoryProvider):
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
self._prefetch_cache = prefetch_text
except json.JSONDecodeError:
pass
@ -373,7 +365,6 @@ class HermesZhiYiMemoryProvider(MemoryProvider):
logger.info("[ZhiYi] WS connected to %s", ws_url)
while self._ws_running:
ws_app = None
try:
ws_app = websocket.WebSocketApp(
ws_url,
@ -386,9 +377,6 @@ class HermesZhiYiMemoryProvider(MemoryProvider):
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:
@ -452,56 +440,27 @@ class HermesZhiYiMemoryProvider(MemoryProvider):
# ── Read path ────────────────────────────────────────────────────────────
def prefetch(self, query: str, *, session_id: str = "", depth: str = "fast") -> str:
def prefetch(self, query: str, *, session_id: str = "") -> str:
"""每次 API 调用前触发:执行语义搜索 + 图谱导航 Obsidian 笔记,返回最相关记忆。"""
if not self._client or not query or len(query.strip()) < 2:
return ""
# 社交关闭消息不触发预取
if _is_social_close(query):
return ""
blocks = ["[ZhiYi Memory — relevant past context]"]
# 优先从 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"))
# 语义搜索
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", "")
content = r.get("content", "") or r.get("text", "")
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)
# 图谱导航 + Obsidian 笔记(从 query 提取关键词作为实体)
notes = self._client.search_notes(query.strip(), max_hops=2, max_notes=3)
if notes:
blocks.append("\n[织忆 Graph — related Obsidian notes]")
blocks.append("\n[ZhiYi Graph — related Obsidian notes]")
for n in notes:
title = n.get("title", "无标题")
path = n.get("path", "")
@ -509,7 +468,7 @@ class HermesZhiYiMemoryProvider(MemoryProvider):
score = n.get("score", 0)
entities = ", ".join(n.get("entities", []))
blocks.append(f" [{score:.0f}] {title} ({path})")
blocks.append(f" \"{snippet}\"")
blocks.append(f' "{snippet}"')
if entities:
blocks.append(f" via entities: {entities}")
@ -517,116 +476,16 @@ class HermesZhiYiMemoryProvider(MemoryProvider):
# 合并 WebSocket prefetch.push 推送事件
with self._prefetch_lock:
ws_push = self._prefetch_cache.pop("ws_push", None)
if ws_push:
text += "\n" + ws_push
if self._prefetch_cache and "[ZhiYi Prefetch" in self._prefetch_cache:
text += "\n" + self._prefetch_cache
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()
self._prefetch_cache = text
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)
"""空实现 — prefetch 已是同步的,不需要额外的异步队列。"""
pass
# ── Tool interface ────────────────────────────────────────────────────────
@ -768,7 +627,7 @@ class HermesZhiYiMemoryProvider(MemoryProvider):
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", "")
content = r.get("content", "") or r.get("text", "")
formatted.append({
"content": content[:1000],
"score": round(r.get("score", 0), 4),
@ -783,12 +642,23 @@ class HermesZhiYiMemoryProvider(MemoryProvider):
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"})
self._client._last_conflicts = [] # reset before commit
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})
result = {"success": True, "commit_id": commit_id}
conflicts = getattr(self._client, "_last_conflicts", [])
if conflicts:
# zhiyid 检出与现有记忆矛盾:返回给 agent 感知,不静默
# 2026-09-03 记忆治理:冲突记忆会进 episodes→蒸馏→污染长期记忆
result["conflicts"] = conflicts
result["warning"] = (
"⚠️ 此内容与现有记忆冲突,仍已写入待蒸馏。"
"若是修正请更新旧条目;若是推断请标注 [推断];若写错了请用 memory_feedback 标记 not_useful"
)
return json.dumps(result, ensure_ascii=False)
return json.dumps({"success": False, "error": "Commit failed — check ZhiYi server logs"})
def _tool_memory_stats(self) -> str:
@ -810,7 +680,7 @@ class HermesZhiYiMemoryProvider(MemoryProvider):
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"})
"message": "Feedback recorded"})
return json.dumps({"success": False, "error": "ZhiYi feedback API failed"})
def _tool_memory_metrics(self) -> str:
@ -885,29 +755,17 @@ class HermesZhiYiMemoryProvider(MemoryProvider):
# ── 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}"
"\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"
)
@ -916,4 +774,4 @@ class HermesZhiYiMemoryProvider(MemoryProvider):
def register(ctx) -> None:
"""Called by Hermes plugin system to register this memory provider."""
ctx.register_memory_provider(HermesZhiYiMemoryProvider())
ctx.register_memory_provider(HermesZhiYiMemoryProvider())