Files
codex-app-server/cloud/chv/memory_files.py
T
host 29fa0c8813 feat(chv): 对齐 Muse P7——状态与记忆分离
- 每 VM 一个 SQLite 状态库(cloud/chv/app_state.py):
  state_dir/<vm>/app-state.sqlite(0600,WAL),表 tasks/goals/
  sessions/audit(只追加)/meta;写操作过无凭证守卫(键名 +
  值模式);备份用 sqlite3 在线备份 + manifest(sha256),恢复
  先验 manifest、先留 pre-restore 副本;DDL 用 SQLite 与
  Postgres 共通受限子集,换 Postgres = 换连接工厂。
- 记忆文件接线(cloud/chv/memory_files.py):宿主镜像读/列/
  写/追加(路径穿越守卫、写审计);MemorySystemClient 调真实
  记忆系统 CLI(search/get/explain/forget-plan/forget-confirm/
  backup/restore/export),不重造引擎;forget = plan → confirm
  → 本地清行 → search 验证查不到;pull_memory_home 经 SSH +
  tar 拉取客机记忆家目录。
- 客内脚本 chv-guest/chv-memory.py(纯标准库,list/read/
  append/forget/export),家目录 /home/chv/memory(workspace
  之外,不同步到 Gitea);build.sh 拷入 + buildinfo 登记。
- 新测试 cloud/tests/test_chv_muse_p7.py 46 项全过(含真 CLI
  write/search/forget/backup/restore 实测、假 ssh 拉取、否定
  测试);全量 cloud 套件 1913 项 OK(skipped=8)。
- 存储选型:SQLite 不是 Postgres(见差距表 §4;轻量 VM 跑
  Postgres server 太重,宿主共享 Postgres 破坏每用户 VM
  隔离);忘记是尽力删除 + 验证,不是密码学擦除。
- 真机待验证(未做,等测试机空闲):见
  docs/chv-muse-p7-gap.md §6 清单
2026-11-06 22:39:04 +00:00

344 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""客机记忆文件接线(对齐 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/<vm>/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