From 6fa2d9b892d0919e6619b83e964be207a9583d53 Mon Sep 17 00:00:00 2001 From: xiaowei Date: Fri, 4 Sep 2026 01:33:41 +0800 Subject: [PATCH] =?UTF-8?q?fix(hermes-plugin):=20commit=20=E8=A7=A3?= =?UTF-8?q?=E6=9E=90=20conflicts=20=E5=AD=97=E6=AE=B5=E8=BF=94=E5=9B=9E=20?= =?UTF-8?q?warning=EF=BC=88=E5=90=8C=E6=AD=A5=E9=83=A8=E7=BD=B2=E5=89=AF?= =?UTF-8?q?=E6=9C=AC=20d520a9091=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit zhiyid DetectContradiction 检出矛盾后 conflicts 只放响应,插件 commit() 只取 id 忽略 → 冲突记忆静默进 episodes→蒸馏→污染长期记忆 现在:_last_conflicts 记录 + _tool_memory_write 返回 conflicts/warning 给 agent 感知 --- plugins/hermes-zhiyi/__init__.py | 244 +++++++------------------------ 1 file changed, 51 insertions(+), 193 deletions(-) diff --git a/plugins/hermes-zhiyi/__init__.py b/plugins/hermes-zhiyi/__init__.py index bdc3ba8..3dd1fbf 100644 --- a/plugins/hermes-zhiyi/__init__.py +++ b/plugins/hermes-zhiyi/__init__.py @@ -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()) \ No newline at end of file + ctx.register_memory_provider(HermesZhiYiMemoryProvider())