feat: P1 auto-injection hook with social close detection

- queue_prefetch now caches next-turn recall results asynchronously
- Social closer detection skips trivial messages (ok, thanks, emoji)
- prefetch uses cached queue results when available (TTL 30s)
- Output header changed to [织忆 Memory] for source clarity
This commit is contained in:
小唯 2026-07-02 00:22:43 +08:00
parent 72cbf73583
commit 5e24646600
1 changed files with 86 additions and 21 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 = 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
@ -274,7 +286,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
@ -347,7 +359,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
@ -361,6 +373,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,
@ -373,6 +386,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:
@ -441,22 +457,51 @@ class HermesZhiYiMemoryProvider(MemoryProvider):
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", "")
@ -472,16 +517,36 @@ 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})
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()
# ── Tool interface ────────────────────────────────────────────────────────
@ -623,7 +688,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),