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:
parent
63f123417b
commit
6fa2d9b892
|
|
@ -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())
|
||||
|
|
|
|||
Loading…
Reference in New Issue