"""客机记忆文件接线(对齐 Muse P7,叶子模块)。 Muse 官方要求(security.muse.ai / 设计文 2026-09-08,见 docs/chv-muse-p7-gap.md §1):记忆跨对话持续存在;记忆文件可 直接阅读与编辑;用户可随时让 Muse“忘记”指定内容;VM 数据持续 备份以便恢复;VM 是用户放入 Muse 的一切内容的系统记录。 本模块只做接线,不重造记忆引擎: - 记忆引擎 = 记忆系统 v13(~/workspace/memory-system),经真实 CLI 调用(`node dist/cli.js search|get|explain|forget-plan| forget-confirm|backup|restore|export`)。CLI 不可用时降级为本地 Markdown 模式,bank 忘记明确记为待办,不谎称已忘。 - 记忆文件布局与记忆系统同款:家目录下 MEMORY.md + memory/*.md; 宿主侧镜像在 `state_dir//memory-home/`,客内家目录在 `/home/chv/memory`(workspace 之外,不同步到 Gitea)。 - “忘记”是尽力删除 + 验证查不到,不是密码学擦除:已备份的快照 里仍有旧内容(与记忆系统 CLI 的 backup 语义一致),差距表 §5 写清。 """ import json import os import subprocess import threading from collections.abc import Mapping from cloud.chv.app_state import assert_no_secrets GUEST_MEMORY_HOME = "/home/chv/memory" _MEMORY_MD = "MEMORY.md" def memory_home_path(state_dir: str, vm_name: str) -> str: """某 VM 记忆家目录镜像路径:宿主 state_dir 下,不在客机磁盘里。""" return os.path.join(state_dir, vm_name, "memory-home") _MEMORY_SUBDIR = "memory" _AUDIT_FILENAME = "audit.jsonl" _CLI_TIMEOUT_SECONDS = 120 class MemoryError(RuntimeError): """记忆文件操作失败(路径穿越、CLI 失败等)。""" def _is_within(base: str, path: str) -> bool: """path 是否在家目录内(防路径穿越)。""" base_abs = os.path.abspath(base) path_abs = os.path.abspath(path) return path_abs == base_abs or path_abs.startswith(base_abs + os.sep) def _remove_lines_containing(path: str, needle: str) -> int: """删掉文件里含 needle 的行(大小写不敏感),返回删掉的行数。""" if not os.path.isfile(path): return 0 with open(path, "r", encoding="utf-8") as handle: lines = handle.readlines() kept = [line for line in lines if needle.lower() not in line.lower()] removed = len(lines) - len(kept) if removed: with open(path, "w", encoding="utf-8") as handle: handle.writelines(kept) return removed def _audit_line(actor: str, action: str, target: str, detail: Mapping[str, object]) -> str: """一条审计 JSON 行(detail 过无凭证守卫)。""" assert_no_secrets(detail) record = {"actor": actor, "action": action, "target": target, "detail": dict(detail)} return json.dumps(record, sort_keys=True) + "\n" class MemoryFiles: """宿主侧记忆家目录镜像:读/列/写/追加,写操作记审计。""" def __init__(self, home: str) -> None: self._home = home self._lock = threading.RLock() @property def home(self) -> str: """镜像家目录路径。""" return self._home def ensure(self) -> None: """建家目录结构(MEMORY.md + memory/,0700)。""" os.makedirs(os.path.join(self._home, _MEMORY_SUBDIR), mode=0o700, exist_ok=True) memory_md = os.path.join(self._home, _MEMORY_MD) if not os.path.exists(memory_md): with open(memory_md, "w", encoding="utf-8") as handle: handle.write("# MEMORY.md\n") os.chmod(memory_md, 0o600) os.chmod(self._home, 0o700) def resolve(self, rel: str) -> str: """相对路径 → 家目录内绝对路径,穿越即拒。""" candidate = os.path.abspath(os.path.join(self._home, rel)) if not _is_within(self._home, candidate): raise MemoryError("path escapes memory home: %s" % rel) return candidate def list_files(self) -> list[str]: """列家目录下全部文件(相对路径,排序)。""" found: list[str] = [] for root, _dirs, names in os.walk(self._home): for name in names: full = os.path.join(root, name) found.append(os.path.relpath(full, self._home)) return sorted(found) def read_file(self, rel: str) -> str: """读一个记忆文件(相对路径),不存在即报错。""" path = self.resolve(rel) if not os.path.isfile(path): raise MemoryError("memory file missing: %s" % rel) with open(path, "r", encoding="utf-8") as handle: return handle.read() def write_file(self, rel: str, text: str, actor: str = "host") -> None: """写一个记忆文件(相对路径,0600),记审计。""" assert_no_secrets({"text": text}) path = self.resolve(rel) parent = os.path.dirname(path) if parent: os.makedirs(parent, mode=0o700, exist_ok=True) with self._lock: with open(path, "w", encoding="utf-8") as handle: handle.write(text) os.chmod(path, 0o600) self._append_audit(actor, "write", rel, {"bytes": len(text)}) def append_lines(self, rel: str, lines: list[str], actor: str = "host") -> None: """向一个记忆文件追加行(0600),记审计。""" assert_no_secrets({"lines": lines}) path = self.resolve(rel) parent = os.path.dirname(path) if parent: os.makedirs(parent, mode=0o700, exist_ok=True) with self._lock: with open(path, "a", encoding="utf-8") as handle: for line in lines: handle.write(line if line.endswith("\n") else line + "\n") os.chmod(path, 0o600) self._append_audit(actor, "append", rel, {"lines": len(lines)}) def remove_lines(self, rel: str, needle: str, actor: str = "host") -> int: """删掉一个记忆文件里含 needle 的行,返回行数,记审计。""" path = self.resolve(rel) with self._lock: removed = _remove_lines_containing(path, needle) if removed: self._append_audit(actor, "forget-lines", rel, {"needle": needle, "removed": removed}) return removed def _append_audit(self, actor: str, action: str, target: str, detail: Mapping[str, object]) -> None: """审计追加到家目录 memory/audit.jsonl(O_APPEND)。""" audit_path = os.path.join(self._home, _MEMORY_SUBDIR, _AUDIT_FILENAME) with open(audit_path, "a", encoding="utf-8") as handle: handle.write(_audit_line(actor, action, target, detail)) class MemorySystemClient: """记忆系统 CLI 客户端(subprocess 调真实 CLI,不重造引擎)。 cli_path 指向 `memory-system/dist/cli.js`,node_path 指向 node 可执行文件。所有调用都有超时;非零退出即 MemoryError(stderr 截断 500 字符,绝不含凭证值——CLI 输出本就不含凭证)。 """ def __init__(self, cli_path: str, node_path: str = "node") -> None: self._cli_path = cli_path self._node_path = node_path @property def cli_path(self) -> str: """CLI 脚本路径。""" return self._cli_path def available(self) -> bool: """CLI 脚本与 node 是否都存在。""" import shutil if not os.path.isfile(self._cli_path): return False return shutil.which(self._node_path) is not None def _run(self, args: list[str], timeout: int = _CLI_TIMEOUT_SECONDS) -> dict[str, object]: """跑一次 CLI,stdout 解析为 JSON dict。""" cmd = [self._node_path, self._cli_path] + args try: proc = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) except subprocess.TimeoutExpired as exc: raise MemoryError("memory cli timeout: %s" % " ".join(args[:2])) if proc.returncode != 0: raise MemoryError("memory cli failed (%d): %s" % ( proc.returncode, proc.stderr.strip()[:500])) try: parsed = json.loads(proc.stdout) except json.JSONDecodeError as exc: raise MemoryError("memory cli bad json: %s" % exc) if not isinstance(parsed, dict): raise MemoryError("memory cli bad json: not an object") return parsed def search(self, home: str, *queries: str) -> dict[str, object]: """词汇检索(CLI search),返回 {"hits": [...]}。""" return self._run(["search", home] + list(queries)) def search_semantic(self, home: str, query: str) -> dict[str, object]: """混合语义 + 词汇检索(无 embedder 时退化为纯词汇)。""" return self._run(["search-semantic", home, query]) def get(self, home: str, citation: str) -> dict[str, object]: """按引用取一条记忆(MEMORY.md#L7 或 memory/...#L7-L9)。""" return self._run(["get", home, citation]) def explain(self, home: str, claim: str) -> dict[str, object]: """解释一条 claim 的来源与替代关系。""" return self._run(["explain", home, claim]) def forget_plan(self, home: str, query: str) -> dict[str, object]: """忘记计划:列出 query 命中的 claims,不删任何东西。""" return self._run(["forget-plan", home, query]) def forget_confirm(self, home: str, query: str, actor: str = "cli") -> dict[str, object]: """确认忘记:删命中的 claims,记审计,返回结果。""" return self._run(["forget-confirm", home, query, "--actor", actor]) def backup(self, home: str, out: str | None = None) -> dict[str, object]: """整 home 快照备份(文件级 + manifest sha256)。""" args = ["backup", home] if out is not None: args += ["--out", out] return self._run(args) def restore(self, home: str, snapshot: str, actor: str = "cli") -> dict[str, object]: """从快照恢复(先验 manifest,失败则 home 不动)。""" return self._run(["restore", home, snapshot, "--actor", actor]) def export(self, home: str, out_dir: str) -> dict[str, object]: """导出全部记忆为 Markdown 目录。""" return self._run(["export", home, out_dir]) def build_pull_command(ssh_cmd: list[str], remote_home: str) -> list[str]: """构造拉取命令:ssh … tar -czf - -C <父目录> <家目录名>。""" parent = os.path.dirname(remote_home.rstrip("/")) base = os.path.basename(remote_home.rstrip("/")) return list(ssh_cmd) + ["tar -czf - -C %s %s" % (parent, base)] def pull_memory_home(ssh_cmd: list[str], remote_home: str, local_home: str, timeout: int = _CLI_TIMEOUT_SECONDS) -> dict[str, int]: """经 SSH + tar 把客机记忆家目录拉到宿主镜像(覆盖式)。 ssh_cmd 是已构造好的 ssh 基础命令(端口/密钥/用户已定,见 cloud.chv.ssh_transport.build_ssh_command)。返回拉取到的 文件数;ssh 或 tar 失败即 MemoryError。 """ os.makedirs(local_home, mode=0o700, exist_ok=True) pull_cmd = build_pull_command(ssh_cmd, remote_home) fetch = subprocess.Popen(pull_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) try: extract = subprocess.run( ["tar", "-xzf", "-", "-C", local_home], stdin=fetch.stdout, capture_output=True, text=True, timeout=timeout) finally: if fetch.stdout is not None: fetch.stdout.close() fetch_rc = fetch.wait() err = "" if fetch.stderr is not None: err = fetch.stderr.read().decode("utf-8", "replace")[:500] fetch.stderr.close() if fetch_rc != 0: raise MemoryError("memory pull ssh failed (%d): %s" % (fetch_rc, err)) if extract.returncode != 0: raise MemoryError("memory pull tar failed: %s" % extract.stderr.strip()[:500]) count = 0 for _root, _dirs, names in os.walk(local_home): count += len(names) return {"files": count} def forget(memory_files: MemoryFiles, client: MemorySystemClient | None, query: str, actor: str = "host") -> dict[str, object]: """忘记指定内容:bank 忘记 + 本地 Markdown 清行 + 验证查不到。 client 在位时走真实 CLI(plan → confirm → 验证 search 为空); client 缺位时只做本地清行,bank 忘记记为待办(不谎称已忘)。 返回 {"mode", "claims", "lines_removed", "verified"}。 """ assert_no_secrets({"query": query}) if client is not None and client.available(): plan = client.forget_plan(memory_files.home, query) claims = plan.get("claims") claim_list = claims if isinstance(claims, list) else [] client.forget_confirm(memory_files.home, query, actor=actor) removed = _forget_local_lines(memory_files, query, actor) result = client.search(memory_files.home, query) hits = result.get("hits") hit_list = hits if isinstance(hits, list) else [] if hit_list: raise MemoryError( "forget verify failed: %d hits remain" % len(hit_list)) return {"mode": "cli", "claims": len(claim_list), "lines_removed": removed, "verified": True} removed = _forget_local_lines(memory_files, query, actor) memory_files._append_audit(actor, "forget-pending", query, {"reason": "cli-unavailable", "lines_removed": removed}) return {"mode": "local-only", "claims": 0, "lines_removed": removed, "verified": False} def _forget_local_lines(memory_files: MemoryFiles, query: str, actor: str) -> int: """从镜像家目录全部 Markdown 里清掉含 query 的行。""" removed = 0 for rel in memory_files.list_files(): if rel.endswith(".md"): removed += memory_files.remove_lines(rel, query, actor=actor) return removed