#!/usr/bin/env python3 """ 统一记忆入口 (memory_recall.py) 同时查询织忆、TencentDB、Soulful 三套记忆系统,合并去重后输出统一上下文。 """ import argparse import json import os import subprocess import threading import time import urllib.error import urllib.request from pathlib import Path # ── 常量 ────────────────────────────────────────────────────────────────────── ZHIYI_URL = "http://localhost:7821/api/v1/recall" ZHIYI_HEADERS = { "X-API-Key": os.environ.get("ZHIYI_API_KEY", "zhiyi-dev-key-2026"), "Content-Type": "application/json", } TENCENTDB_URL = "http://localhost:8420" SOULFUL_DIR = Path.home() / ".hermes" / "soulful" DEFAULT_TOP_K = 3 TIMEOUT_SEC = 15 # ── 工具函数 ────────────────────────────────────────────────────────────────── def http_post(url: str, payload: dict, headers: dict | None = None, timeout: int = TIMEOUT_SEC) -> dict | None: """POST JSON,返回解析后的 dict;失败自动重试最多3次,返回 None 并打印 warning。""" delays = [1, 2, 4] for attempt in range(4): # 首次 + 3次重试 try: req = urllib.request.Request( url, data=json.dumps(payload).encode("utf-8"), headers=headers or {}, method="POST", ) with urllib.request.urlopen(req, timeout=timeout) as resp: return json.loads(resp.read().decode("utf-8")) except Exception as e: if attempt < 3: print(f" [warning] {url} -> {e} (重试 {attempt+1}/3, 等待 {delays[attempt]}s)", flush=True) time.sleep(delays[attempt]) else: print(f" [warning] {url} -> {e} (已重试3次,放弃)", flush=True) return None return None def read_json_file(path: Path) -> dict | list | None: """读取 JSON 文件,失败返回 None 并打印 warning。""" try: return json.loads(path.read_text(encoding="utf-8")) except Exception as e: print(f" [warning] read {path} -> {e}", flush=True) return None def read_jsonl(path: Path): """逐行读取 JSONL,yield dict;失败打印 warning 并跳过。""" try: for line in path.read_text(encoding="utf-8").splitlines(): line = line.strip() if not line: continue try: yield json.loads(line) except json.JSONDecodeError: pass except Exception as e: print(f" [warning] read {path} -> {e}", flush=True) # ── bge-embed keepalive ──────────────────────────────────────────────────────── BGE_HEALTH_URL = "http://localhost:8000/health" _KEEPALIVE_RUNNING = True def add_keepalive(): """后台线程:每10秒ping bge-embed /health,防止服务因空闲超时断开。""" global _KEEPALIVE_RUNNING while _KEEPALIVE_RUNNING: try: req = urllib.request.Request(BGE_HEALTH_URL, method="GET") with urllib.request.urlopen(req, timeout=5) as resp: resp.read() # 消费响应,无需解析 except Exception: pass # bge-embed 暂时不可用也静默,ping 下次继续 time.sleep(10) def start_keepalive(): """启动 keepalive 守护线程(在 main 入口调用)。""" t = threading.Thread(target=add_keepalive, daemon=True) t.start() def stop_keepalive(): """停止 keepalive 线程。""" global _KEEPALIVE_RUNNING _KEEPALIVE_RUNNING = False # ── 查询函数(供线程调用) ────────────────────────────────────────────────────── def query_zhiyi(query: str, top_k: int) -> dict: result = http_post(ZHIYI_URL, {"query": query, "top_k": top_k}, ZHIYI_HEADERS) if result is None: # 降级:织忆不可用时从 llm_context.json 读取缓存上下文 ctx = load_llm_context() if ctx is not None: print(f" [degrade] 织忆不可用,从 llm_context.json 读取缓存上下文", flush=True) return { "error": False, "degraded": True, "results": [], "llm_context": ctx, } return {"error": True, "results": []} return {"error": False, "results": result.get("results", [])} def query_tencentdb_l1(query: str, top_k: int) -> dict: result = http_post( f"{TENCENTDB_URL}/search/memories", {"query": query, "top_k": top_k}, ) if result is None: return {"error": True, "results": []} return {"error": False, "results": result.get("results", [])} def query_tencentdb_l0(query: str, top_k: int) -> dict: result = http_post( f"{TENCENTDB_URL}/search/conversations", {"query": query, "top_k": top_k}, ) if result is None: return {"error": True, "results": []} return {"error": False, "results": result.get("results", [])} # ── Soulful 读取 ─────────────────────────────────────────────────────────────── def load_soulful() -> dict: """加载 Soulful 三件套,返回聚合 dict;各文件缺失不报错。""" profile = read_json_file(SOULFUL_DIR / "user-profile.json") or {} cares_data = read_json_file(SOULFUL_DIR / "cares-queue.json") or {} heart_traces = list(read_jsonl(SOULFUL_DIR / "heart-traces.jsonl")) return { "error": False, "profile": profile, "cares": cares_data.get("cares", []) if isinstance(cares_data, dict) else [], "heart_traces": heart_traces, } # ── 统一读取入口(优先 llm_context.json)──────────────────────────────────────── LLM_CONTEXT_FILE = Path.home() / ".hermes" / "llm_context.json" def load_llm_context() -> dict | None: """读取 llm_context.json(daemon 统一写入的合并上下文)。失败返回 None。""" try: if not LLM_CONTEXT_FILE.exists(): return None return json.loads(LLM_CONTEXT_FILE.read_text(encoding="utf-8")) except Exception: return None def query_llm_context() -> dict: """主查询:优先 llm_context.json,再补充织忆/L1/L0/soulful 详细数据。 返回格式同 parallel_query:(zhiyi, l1, l0, soulful, unified_context) unified_context 包含 user_profile / active_scenes / short_term / cares / recent_moments """ ctx = load_llm_context() soulful_data = load_soulful() unified = { "error": False, "ctx": ctx, # llm_context v2 dict or None "user_profile": (ctx or {}).get("user_profile", {}), "active_scenes": (ctx or {}).get("active_scenes", []), "short_term": (ctx or {}).get("short_term", {}), "cares": soulful_data.get("cares", []), # 优先 soulful 原始 "heart_traces": soulful_data.get("heart_traces", []), "distill_status": (ctx or {}).get("distill_status", "no_context"), } # 如果 ctx 有完整数据,织忆语义检索由主查询补充 # (此处不重复检索,llm_context.json 已含 daemon 蒸馏结果) zhiyi = {"error": False, "results": []} tddb_l1 = {"error": False, "results": []} tddb_l0 = {"error": False, "results": []} return zhiyi, tddb_l1, tddb_l0, soulful_data, unified # ── 并行查询(fallback 模式)──────────────────────────────────────────────────── def parallel_query(query: str, top_k: int): """用三个线程同时查询三个后端 + Soulful 文件,返回 (zhiyi, l1, l0, soulful)。""" def safe_update(result_dict, fn, *args, **kwargs): """安全执行查询,异常时标记 error""" try: result_dict.update(fn(*args, **kwargs)) except Exception as e: result_dict["error"] = True result_dict["results"] = [] threads = [] zhiyi_result = {} l1_result = {} l0_result = {} soulful_result = {} t1 = threading.Thread(target=lambda: safe_update(zhiyi_result, query_zhiyi, query, top_k)) t2 = threading.Thread(target=lambda: safe_update(l1_result, query_tencentdb_l1, query, top_k)) t3 = threading.Thread(target=lambda: safe_update(l0_result, query_tencentdb_l0, query, top_k)) t4 = threading.Thread(target=lambda: safe_update(soulful_result, load_soulful)) for t in (t1, t2, t3, t4): t.start() for t in (t1, t2, t3, t4): t.join() return zhiyi_result, l1_result, l0_result, soulful_result # ── 格式化输出 ──────────────────────────────────────────────────────────────── import re def _fmt_val(v): """格式化 profile 中的 value:dict/list 走 JSON.dumps,其余直接转 str。""" if isinstance(v, (dict, list)): return json.dumps(v, ensure_ascii=False) return str(v) def format_pretty(zhiyi, l1, l0, soulful): """pretty 模式:分区块显示。""" lines = [] # ── 语义记忆(织忆) ── if zhiyi.get("error"): lines.append("## 语义记忆(织忆)") lines.append("暂不可用") else: lines.append("## 语义记忆(织忆)") for item in zhiyi.get("results", []): if isinstance(item, dict): content = item.get("content", "") score = item.get("score", 0.0) cat = item.get("category", "") ts = item.get("timestamp", "") else: content = str(item) score, cat, ts = 0.0, "", "" cat_str = f" [{cat}]" if cat else "" ts_str = f" ({ts})" if ts else "" lines.append(f"[{content}]{cat_str}{ts_str} (score: {score:.2f})") lines.append("") # ── 人格记忆(TencentDB L1) ── if l1.get("error"): lines.append("## 人格记忆(TencentDB)") lines.append("暂不可用\n") else: lines.append("## 人格记忆(TencentDB)") results = l1.get("results", []) # TencentDB 返回的 results 是 markdown 字符串,不是 JSON 数组 if isinstance(results, str): # 整段输出,不拆分 lines.append(results) else: for item in results: if isinstance(item, dict): content = item.get("content", "") score = item.get("score", 0.0) ptype = item.get("type", "instruction") scene = item.get("scene", "") else: content = str(item) score, ptype, scene = 0.0, "instruction", "" scene_str = f" [{scene}]" if scene else "" lines.append(f"[type: {ptype}]{scene_str}") lines.append(f"[{content}] (score: {score:.2f})") lines.append("") # ── 对话记忆(TencentDB L0) ── if l0.get("error"): lines.append("## 对话记忆(TencentDB L0)") lines.append("暂不可用\n") else: lines.append("## 对话记忆(TencentDB L0)") results = l0.get("results", []) # TencentDB 返回的 results 是 markdown 字符串,不是 JSON 数组 if isinstance(results, str): lines.append(results) else: for item in results: if isinstance(item, dict): msg = item.get("message", "") score = item.get("score", 0.0) ts = item.get("timestamp", "") session = item.get("session", "") else: msg = str(item) score, ts, session = 0.0, "", "" session_str = f"[{session}]" if session else "" ts_str = f" {ts}" if ts else "" lines.append(f"{session_str}{ts_str}") lines.append(f"[{msg}] (score: {score:.2f})") lines.append("") # ── 关系感知(Soulful) ── lines.append("## 关系感知(Soulful)") if soulful.get("error"): lines.append("暂不可用\n") else: # 画像 lines.append("### 画像") profile = soulful.get("profile", {}) if profile: for key, value in profile.items(): lines.append(f"{key}: {_fmt_val(value)}") else: lines.append("(无)") # 牵挂 lines.append("\n### 待关注(牵挂)") cares = soulful.get("cares", []) if cares: for item in cares: content = item.get("content", "") due = item.get("follow_up_date") or item.get("due") or "null" lines.append(f"- [{content}] (due: {due})") else: lines.append("(无)") # 心迹 lines.append("\n### 心迹") heart_traces = soulful.get("heart_traces", []) if heart_traces: for item in heart_traces: ts = item.get("timestamp", "") content = item.get("content", "") lines.append(f"[{ts}] [{content}]") else: lines.append("(无)") return "\n".join(lines) def format_compact(zhiyi, l1, l0, soulful): """compact 模式:单行动态摘要。""" parts = [] # 织忆 if not zhiyi.get("error"): results = zhiyi.get("results", []) if results: top = results[0] parts.append(f"织忆/{top.get('content', '')[:40]}(s={top.get('score',0):.2f})") # L1 - TencentDB returns string for markdown, list for parsed if not l1.get("error"): results = l1.get("results", []) if isinstance(results, str): # Markdown string - extract content from first meaningful line # L1 format: "- **[persona/instruction]** ..." then blank then " content" # or " content" appears on 2nd or 3rd non-empty line lines = [l for l in results.split("\n") if l.strip() and not l.startswith("Found") and not l.startswith("---")] if len(lines) >= 2: # Second line has the actual content text (after blank line) content = lines[1].strip().lstrip("-* []").strip() parts.append(f"人格:{content[:35]}...") elif lines: content = lines[0].strip().lstrip("-* []").strip() parts.append(f"人格:{content[:35]}") elif results: top = results[0] ptype = top.get("type", "?") parts.append(f"人格/{ptype}:{top.get('content', '')[:30]}(s={top.get('score',0):.2f})") # L0 - TencentDB returns string for markdown, list for parsed if not l0.get("error"): results = l0.get("results", []) if isinstance(results, str): # L0 format: "**[user]** Session: ... (score: X)\n\nMESSAGE CONTENT\n\n---..." # Match header line + blank line + first non-empty content line m = re.search(r'\*\*\[(user|assistant)\]\*\*.*?\n\n(.+?)(?:\n\n|---\Z)', results, re.DOTALL) if m: msg = m.group(2).strip()[:40] parts.append(f"对话:{msg}") elif "**[user]**" in results: for line in results.split("\n"): if "**[user]**" in line or "**[assistant]**" in line: parts.append(f"对话:{line[:50]}") break elif results: top = results[0] msg = top.get("message", "")[:40] parts.append(f"对话:{msg}(s={top.get('score',0):.2f})") # Soulful 牵挂 if not soulful.get("error"): cares = soulful.get("cares", []) if cares: parts.append(f"牵挂:{cares[0].get('content', '')[:30]}") if not parts: return "[memory] no results" return "[memory] " + " | ".join(parts) # ── 统一上下文格式化 ────────────────────────────────────────────────────────── def format_unified(ctx: dict): """格式化 llm_context.json v2 统一格式(方案A核心输出)。""" lines = [] # user_profile up = ctx.get("user_profile") or {} if up: lines.append("## 用户画像(L4-L5)") if isinstance(up, dict): for k, v in up.items(): if isinstance(v, dict): lines.append(f" {k}:") for k2, v2 in v.items(): lines.append(f" {k2}: {v2}") elif isinstance(v, list): lines.append(f" {k}: {', '.join(str(x) for x in v)}") else: lines.append(f" {k}: {v}") else: lines.append(f" {up}") lines.append("") # active_scenes scenes = ctx.get("active_scenes") or [] if scenes: lines.append(f"## 活跃场景({len(scenes)} 个)") for s in scenes[:5]: name = s.get("name", s) if isinstance(s, dict) else str(s) layer = s.get("layer", "?") if isinstance(s, dict) else "?" lines.append(f" [{layer}] {name}") lines.append("") # short_term st = ctx.get("short_term") or {} if st: lines.append("## 短期记忆(蒸馏计数)") for k, v in st.items(): lines.append(f" {k}: {v}") lines.append("") # cares cares = ctx.get("cares") or [] if cares: lines.append(f"## 牵挂({len(cares)} 条待关怀)") for c in cares[:5]: content = c.get("content", "")[:50] due = c.get("due", c.get("follow_up_date", "?")) lines.append(f" [{due}] {content}") lines.append("") # recent_moments moments = ctx.get("recent_moments") or [] if moments: lines.append(f"## 心迹({len(moments)} 条)") for m in moments[-3:]: content = m.get("content", "")[:60] ts = m.get("timestamp", "")[:10] lines.append(f" [{ts}] {content}") lines.append("") # distill_status ds = ctx.get("distill_status", "ok") lines.append(f"蒸馏状态: {ds}") return "\n".join(lines) # ── 入口 ────────────────────────────────────────────────────────────────────── def main(): start_keepalive() # 后台保持 bge-embed 连接活跃 parser = argparse.ArgumentParser(description="统一记忆入口") parser.add_argument("query", help="查询文本") parser.add_argument("--top-k", type=int, default=DEFAULT_TOP_K, help=f"每系统返回条数 (default: {DEFAULT_TOP_K})") parser.add_argument("--format", choices=["pretty", "compact", "unified"], default="pretty", help="输出格式 (default: pretty)") args = parser.parse_args() if args.format == "unified": # 新统一模式:读 llm_context.json(方案A) _, _, _, _, unified = query_llm_context() if unified.get("error"): print("[memory] llm_context 不可用") raise SystemExit(1) ctx = unified.get("ctx") if ctx is None: print("[memory] llm_context.json 尚无数据(daemon 还未写入)") raise SystemExit(1) print(format_unified(ctx)) return # legacy 模式:三系统并行查询 zhiyi, l1, l0, soulful = parallel_query(args.query, args.top_k) if args.format == "compact": print(format_compact(zhiyi, l1, l0, soulful)) else: print(format_pretty(zhiyi, l1, l0, soulful)) # 三系统全挂 → 退出码 1 if zhiyi.get("error") and l1.get("error") and l0.get("error") and soulful.get("error"): raise SystemExit(1) if __name__ == "__main__": main()