From ad4e9b7bef069ab8c0280bb60db8b7299468e7ee Mon Sep 17 00:00:00 2001 From: xiaowei Date: Fri, 4 Sep 2026 01:35:31 +0800 Subject: [PATCH] =?UTF-8?q?fix(hermes-plugin):=20=E5=9C=A8=E6=AD=A3?= =?UTF-8?q?=E6=9C=AC=E5=9F=BA=E7=A1=80=E4=B8=8A=E5=8A=A0=20conflicts=20?= =?UTF-8?q?=E8=A7=A3=E6=9E=90=EF=BC=88=E4=BF=9D=E7=95=99=20prefetch/social?= =?UTF-8?q?=20=E5=8A=9F=E8=83=BD=EF=BC=8C=E5=8B=BF=E7=94=A8=E9=83=A8?= =?UTF-8?q?=E7=BD=B2=E5=89=AF=E6=9C=AC=E6=95=B4=E4=BD=93=E8=A6=86=E7=9B=96?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 前一版 fcdaf54 误用旧部署副本覆盖正本(41469B→35801B),丢了 _is_social_close/_prefetch_cache/prefetch()。已恢复正本(checkout HEAD~1)后重新应用: - ZhiYiClient.__init__ 加 _last_conflicts - commit() 成功时捕获 conflicts - _tool_memory_write 检出冲突返回 warning(不静默) --- plugins/hermes-zhiyi/__init__.py | 243 +++++++++++++++++++++++++------ 1 file changed, 199 insertions(+), 44 deletions(-) diff --git a/plugins/hermes-zhiyi/__init__.py b/plugins/hermes-zhiyi/__init__.py index 3dd1fbf..4dda873 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 = 8 # 少于8字的过滤掉 +MEMORY_MIN_LENGTH = 20 # 少于20字的过滤掉 _FORBIDDEN_PATTERNS = [ "Review the conversation above", "[System note:", @@ -67,6 +67,22 @@ _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: """过滤系统注入内容和测试垃圾,防止污染记忆存储。""" @@ -75,10 +91,6 @@ 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 @@ -101,7 +113,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._last_conflicts: list = [] # 最近一次 commit 的冲突检测结果(2026-09-04 记忆治理) self._session = requests.Session() self._session.headers.update({ "Content-Type": "application/json", @@ -137,8 +149,7 @@ 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 记忆治理修复) + # 2026-09-04 记忆治理:检出冲突不静默(写入端看到 conflicts 才能处理) self._last_conflicts = data.get("conflicts") or [] cid = ( data.get("commit_id") @@ -278,7 +289,7 @@ class HermesZhiYiMemoryProvider(MemoryProvider): self._turn_counter: int = 0 self._write_queue: List[Dict] = [] self._queue_lock = threading.Lock() - self._prefetch_cache: str = "" # last prefetch result + 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 @@ -351,7 +362,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 = prefetch_text + self._prefetch_cache["ws_push"] = prefetch_text except json.JSONDecodeError: pass @@ -365,6 +376,7 @@ 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, @@ -377,6 +389,9 @@ 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: @@ -440,27 +455,56 @@ class HermesZhiYiMemoryProvider(MemoryProvider): # ── Read path ──────────────────────────────────────────────────────────── - def prefetch(self, query: str, *, session_id: str = "") -> str: + 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 "" - blocks = ["[ZhiYi Memory — relevant past context]"] + # 社交关闭消息不触发预取 + if _is_social_close(query): + return "" - # 语义搜索 - results = self._client.recall(query.strip(), top_k=3, - agent_id=os.environ.get("ZHIYI_AGENT_ID", "hermes-a06")) + # 优先从 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", "") + 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 笔记(从 query 提取关键词作为实体) - notes = self._client.search_notes(query.strip(), max_hops=2, max_notes=3) + # 图谱导航 + Obsidian 笔记(如果缓存没有命中) + if notes is None: + notes = self._client.search_notes(query.strip(), max_hops=2, max_notes=3) if notes: - blocks.append("\n[ZhiYi Graph — related Obsidian notes]") + blocks.append("\n[织忆 Graph — related Obsidian notes]") for n in notes: title = n.get("title", "无标题") path = n.get("path", "") @@ -468,7 +512,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}") @@ -476,16 +520,116 @@ class HermesZhiYiMemoryProvider(MemoryProvider): # 合并 WebSocket prefetch.push 推送事件 with self._prefetch_lock: - if self._prefetch_cache and "[ZhiYi Prefetch" in self._prefetch_cache: - text += "\n" + self._prefetch_cache + ws_push = self._prefetch_cache.pop("ws_push", None) + if ws_push: + text += "\n" + ws_push with self._prefetch_lock: - self._prefetch_cache = text + 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 已是同步的,不需要额外的异步队列。""" - pass + """异步预取:本轮对话结束后立即查询织忆,下一轮 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 ──────────────────────────────────────────────────────── @@ -627,7 +771,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", "") + content = r.get("content", "") or r.get("text", "") or r.get("snippet", "") formatted.append({ "content": content[:1000], "score": round(r.get("score", 0), 4), @@ -642,21 +786,20 @@ 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: - result = {"success": True, "commit_id": commit_id} conflicts = getattr(self._client, "_last_conflicts", []) + result = {"success": True, "commit_id": commit_id} if conflicts: - # zhiyid 检出与现有记忆矛盾:返回给 agent 感知,不静默 - # (2026-09-03 记忆治理:冲突记忆会进 episodes→蒸馏→污染长期记忆) + # 2026-09-04 记忆治理:检出冲突 → 提示写方处理(不静默) result["conflicts"] = conflicts result["warning"] = ( - "⚠️ 此内容与现有记忆冲突,仍已写入待蒸馏。" - "若是修正请更新旧条目;若是推断请标注 [推断];若写错了请用 memory_feedback 标记 not_useful" + f"⚠️ 检出 {len(conflicts)} 条潜在冲突记忆,已跳过自动蒸馏" + "(distill_skipped=conflict)。若这是修正→更新旧记忆;" + "若写错→用 memory_feedback 标记 not_useful。" ) return json.dumps(result, ensure_ascii=False) return json.dumps({"success": False, "error": "Commit failed — check ZhiYi server logs"}) @@ -680,7 +823,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"}) + "message": "Feedback recorded — VProp + Dashboard updated"}) return json.dumps({"success": False, "error": "ZhiYi feedback API failed"}) def _tool_memory_metrics(self) -> str: @@ -755,17 +898,29 @@ 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[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" + "\\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}" ) @@ -774,4 +929,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()) \ No newline at end of file