#!/usr/bin/env python3 """ simp-skill · Memory System 心上人档案记忆系统 — 事件追加、状态读写、快照管理 用法: python3 tools/memory.py append '' python3 tools/memory.py events [--last 5] [--type signal_recorded] python3 tools/memory.py context [--with-strategy] python3 tools/memory.py snapshot python3 tools/memory.py timeline python3 tools/memory.py rebuild """ import argparse import json import logging from datetime import date, datetime from pathlib import Path from typing import Any logging.basicConfig(level=logging.INFO, format="%(message)s") logger = logging.getLogger(__name__) DEFAULT_BASE_DIR = Path("crushes") VALID_EVENT_TYPES = frozenset({ "profile_created", "profile_updated", "stage_changed", "signal_recorded", "analysis_done", "progress_evaluated", "strategy_updated", "crisis_handled", "quit_evaluated", "confess_prepared", }) _SECTION_ORDER = [ "## 当前状态(一句话)", "## 最近信号(最新3条)", "## 当前策略方向", "## 下一步建议", ] _TIMELINE_LABELS: dict[str, str] = { "profile_created": "🌱 档案创建", "profile_updated": "✏️ 档案更新", "stage_changed": "📍 阶段变化", "signal_recorded": "📡 信号记录", "analysis_done": "🔍 完成分析", "progress_evaluated": "📊 进度评估", "strategy_updated": "🗺️ 策略更新", "crisis_handled": "🆘 危机处理", "quit_evaluated": "🍃 放弃判断", "confess_prepared": "💌 表白准备", } # Frontmatter 中应解析为数字的键白名单。其余键(尤其作为档案主键的 slug)保持字符串, # 避免 "007" 在 parse->render 往返中被静默转成 7 而损坏目录查找。 _NUMERIC_FRONTMATTER_KEYS = frozenset({ "age", "score", "signal_score", "last_signal_score", "milestones_done", "consecutive_days", }) def _parse_frontmatter(content: str) -> tuple[dict[str, Any], str]: """解析 Markdown YAML frontmatter,返回 (字段dict, 正文)""" if not content.startswith("---"): return {}, content parts = content.split("---", 2) if len(parts) < 3: return {}, content fields: dict[str, Any] = {} for line in parts[1].strip().splitlines(): if ": " not in line: continue key, _, raw = line.partition(": ") k = key.strip() value: Any = raw.strip() if (value.startswith('"') and value.endswith('"')) or ( value.startswith("'") and value.endswith("'") ): value = value[1:-1] elif value == "null": value = None elif k in _NUMERIC_FRONTMATTER_KEYS: if value.lstrip("-").isdigit(): value = int(value) else: try: value = float(value) except ValueError: pass fields[k] = value return fields, parts[2] def _render_frontmatter(fields: dict[str, Any], body: str) -> str: """将字段dict和正文渲染回 frontmatter 格式""" _NEEDS_QUOTE = set(':#{[}],&*?|<>=!%@`') lines = ["---"] for key, value in fields.items(): if value is None: lines.append(f"{key}: null") elif isinstance(value, bool): lines.append(f"{key}: {str(value).lower()}") elif isinstance(value, (int, float)): lines.append(f"{key}: {value}") else: s = str(value) if any(c in s for c in _NEEDS_QUOTE) or not s: lines.append(f'{key}: "{s}"') else: lines.append(f"{key}: {s}") lines.append("---") return "\n".join(lines) + body def _parse_body_sections(body: str) -> dict[str, str]: """将 Markdown 正文按 ## 标题拆分为 {标题: 内容} 字典""" sections: dict[str, str] = {} current_header: str | None = None current_lines: list[str] = [] for line in body.splitlines(): if line.startswith("## "): if current_header is not None: sections[current_header] = "\n".join(current_lines).strip() current_header = line current_lines = [] elif current_header is not None: current_lines.append(line) if current_header is not None: sections[current_header] = "\n".join(current_lines).strip() return sections def _needs_leading_newline(path: Path) -> bool: """文件已存在、非空且不以换行结尾(说明上次写入被截断)时返回 True。 据此在追加前补一个前导换行,避免把新记录拼接到损坏的半行末尾后一起丢失。""" if not path.exists() or path.stat().st_size == 0: return False with path.open("rb") as f: f.seek(-1, 2) return f.read(1) != b"\n" def append_event( slug: str, event_type: str, data: dict[str, Any], base_dir: Path = DEFAULT_BASE_DIR, ) -> None: """追加一条事件到 events.jsonl,同时更新 meta.json 的 event_count""" if event_type not in VALID_EVENT_TYPES: raise ValueError(f"未知事件类型: {event_type}") crush_dir = base_dir / slug if not crush_dir.exists(): raise FileNotFoundError(f"档案不存在: {slug}") event = { "ts": datetime.now().isoformat(), "v": 1, "type": event_type, "slug": slug, "data": data, } events_path = crush_dir / "events.jsonl" prefix = "\n" if _needs_leading_newline(events_path) else "" with events_path.open("a", encoding="utf-8") as f: f.write(prefix + json.dumps(event, ensure_ascii=False) + "\n") meta_path = crush_dir / "meta.json" if meta_path.exists(): meta = json.loads(meta_path.read_text(encoding="utf-8")) updated_meta = { **meta, "event_count": len(get_recent_events(slug, n=10**9, base_dir=base_dir)), "updated_at": datetime.now().isoformat(), } meta_path.write_text(json.dumps(updated_meta, ensure_ascii=False, indent=2), encoding="utf-8") def get_recent_events( slug: str, n: int = 5, event_types: list[str] | None = None, base_dir: Path = DEFAULT_BASE_DIR, ) -> list[dict[str, Any]]: """返回最近 N 条事件,可按 type 列表过滤""" events_path = base_dir / slug / "events.jsonl" if not events_path.exists(): return [] events: list[dict[str, Any]] = [] for idx, line in enumerate(events_path.read_text(encoding="utf-8").splitlines(), start=1): stripped = line.strip() if not stripped: continue try: event = json.loads(stripped) except json.JSONDecodeError: logger.warning("⚠️ events.jsonl 第 %d 行损坏,已跳过:%.80s", idx, stripped) continue if event_types is None or event.get("type") in event_types: events.append(event) return events[-n:] def load_context( slug: str, include_strategy: bool = False, base_dir: Path = DEFAULT_BASE_DIR, ) -> str: """拼装 profile.md + state.md,供 Claude 注入 context""" crush_dir = base_dir / slug parts: list[str] = [] for filename, label in [("profile.md", "[档案]"), ("state.md", "[状态]")]: path = crush_dir / filename if path.exists(): parts.append(f"# {label} {filename}\n\n") parts.append(path.read_text(encoding="utf-8")) parts.append("\n\n") if include_strategy: strategy_path = crush_dir / "strategy.md" if strategy_path.exists(): parts.append("# [策略] strategy.md\n\n") parts.append(strategy_path.read_text(encoding="utf-8")) return "".join(parts) def update_state( slug: str, frontmatter_updates: dict[str, Any], sections: dict[str, str], base_dir: Path = DEFAULT_BASE_DIR, ) -> None: """全量覆盖 state.md;frontmatter_updates 合并 YAML 字段,sections 合并正文章节""" crush_dir = base_dir / slug if not crush_dir.exists(): raise FileNotFoundError(f"档案不存在: {slug}") state_path = crush_dir / "state.md" existing_fm: dict[str, Any] = {} existing_sections: dict[str, str] = {} if state_path.exists(): existing_fm, body = _parse_frontmatter(state_path.read_text(encoding="utf-8")) existing_sections = _parse_body_sections(body) new_fm = {**existing_fm, **frontmatter_updates, "last_updated": datetime.now().isoformat()} merged_sections = {**existing_sections, **sections} body_parts: list[str] = ["\n"] for header in _SECTION_ORDER: content = merged_sections.get(header, "[待生成]") body_parts.append(f"\n{header}\n\n{content}\n") state_path.write_text(_render_frontmatter(new_fm, "".join(body_parts)), encoding="utf-8") meta_path = crush_dir / "meta.json" if meta_path.exists(): sync_keys = {"current_stage", "signal_score"} sync_updates = {k: v for k, v in frontmatter_updates.items() if k in sync_keys} if sync_updates: meta = json.loads(meta_path.read_text(encoding="utf-8")) updated_meta = {**meta, **sync_updates, "updated_at": datetime.now().isoformat()} meta_path.write_text(json.dumps(updated_meta, ensure_ascii=False, indent=2), encoding="utf-8") def take_snapshot( slug: str, base_dir: Path = DEFAULT_BASE_DIR, ) -> Path: """将 meta.json + state.md frontmatter 合并写入 snapshots/YYYY-MM-DD.json""" crush_dir = base_dir / slug snapshots_dir = crush_dir / "snapshots" snapshots_dir.mkdir(exist_ok=True) snapshot: dict[str, Any] = { "slug": slug, "snapshot_date": date.today().isoformat(), } meta_path = crush_dir / "meta.json" if meta_path.exists(): snapshot["meta"] = json.loads(meta_path.read_text(encoding="utf-8")) state_path = crush_dir / "state.md" if state_path.exists(): state_fm, _ = _parse_frontmatter(state_path.read_text(encoding="utf-8")) snapshot["state"] = state_fm snapshot_path = snapshots_dir / f"{date.today().isoformat()}.json" snapshot_path.write_text(json.dumps(snapshot, ensure_ascii=False, indent=2), encoding="utf-8") if meta_path.exists(): meta = json.loads(meta_path.read_text(encoding="utf-8")) updated_meta = {**meta, "last_snapshot": date.today().isoformat()} meta_path.write_text(json.dumps(updated_meta, ensure_ascii=False, indent=2), encoding="utf-8") logger.info("✅ 快照已保存:%s", snapshot_path) return snapshot_path def rebuild_state_from_events( slug: str, base_dir: Path = DEFAULT_BASE_DIR, ) -> dict[str, Any]: """从 events.jsonl 重放所有事件,返回推断的当前状态""" events = get_recent_events(slug, n=100_000, base_dir=base_dir) state: dict[str, Any] = { "current_stage": "未知", "signal_score": None, "last_signal_score": None, "score_trend": "stable", "recommended_mode": "hybrid", "milestones_done": 0, } for event in events: etype = event.get("type") data = event.get("data", {}) if etype == "stage_changed": state = {**state, "current_stage": data.get("to", state["current_stage"])} elif etype in ("analysis_done", "progress_evaluated"): new_score = data.get("score") prev_score = state.get("signal_score") trend = "stable" if new_score is not None and prev_score is not None: diff = new_score - prev_score trend = "up" if diff >= 2 else ("down" if diff <= -2 else "stable") state = { **state, "last_signal_score": prev_score, "signal_score": new_score if new_score is not None else state["signal_score"], "score_trend": trend, "current_stage": data.get("stage", state["current_stage"]), } if etype == "progress_evaluated" and "milestones_done" in data: state = {**state, "milestones_done": data["milestones_done"]} return state def _format_timeline(events: list[dict[str, Any]]) -> str: """格式化事件时间线为可读字符串""" if not events: return "暂无事件记录" _DIRECTION_ICON = {"green": "🟢", "red": "🔴", "neutral": "🟡"} lines = [f"📅 事件时间线(共 {len(events)} 条)", ""] for event in events: ts = event.get("ts", "")[:16].replace("T", " ") label = _TIMELINE_LABELS.get(event.get("type", ""), event.get("type", "")) data = event.get("data", {}) etype = event.get("type") if etype == "stage_changed": desc = f"{data.get('from', '?')} → {data.get('to', '?')}" elif etype in ("analysis_done", "progress_evaluated"): desc = f"评分 {data.get('score', '?')}/25 阶段:{data.get('stage', '?')}" elif etype == "signal_recorded": icon = _DIRECTION_ICON.get(data.get("direction", ""), "") desc = f"{icon} {data.get('content', '')}" elif etype == "crisis_handled": desc = data.get("crisis_type", "") else: desc = "" lines.append(f" {ts} {label} {desc}".rstrip()) return "\n".join(lines) def main() -> None: parser = argparse.ArgumentParser(description="simp-skill · 记忆系统") sub = parser.add_subparsers(dest="cmd", required=True) p = sub.add_parser("append", help="追加事件") p.add_argument("slug") p.add_argument("event_type") p.add_argument("data", help="JSON 字符串") p.add_argument("--base-dir", default="crushes") p = sub.add_parser("events", help="查看最近事件") p.add_argument("slug") p.add_argument("--last", type=int, default=5) p.add_argument("--type", dest="event_type", help="按事件类型过滤") p.add_argument("--base-dir", default="crushes") p = sub.add_parser("context", help="读取档案上下文") p.add_argument("slug") p.add_argument("--with-strategy", action="store_true") p.add_argument("--base-dir", default="crushes") p = sub.add_parser("snapshot", help="生成日快照") p.add_argument("slug") p.add_argument("--base-dir", default="crushes") p = sub.add_parser("timeline", help="查看事件时间线") p.add_argument("slug") p.add_argument("--base-dir", default="crushes") p = sub.add_parser("rebuild", help="从事件流重建状态") p.add_argument("slug") p.add_argument("--base-dir", default="crushes") args = parser.parse_args() base_dir = Path(args.base_dir) if args.cmd == "append": try: data = json.loads(args.data) except json.JSONDecodeError as e: logger.error("❌ data 必须是合法 JSON:%s", e) return append_event(args.slug, args.event_type, data, base_dir) logger.info("✅ 事件已记录:%s", args.event_type) elif args.cmd == "events": types = [args.event_type] if args.event_type else None events = get_recent_events(args.slug, args.last, types, base_dir) for event in events: print(json.dumps(event, ensure_ascii=False)) if not events: logger.info("暂无事件记录") elif args.cmd == "context": print(load_context(args.slug, args.with_strategy, base_dir)) elif args.cmd == "snapshot": take_snapshot(args.slug, base_dir) elif args.cmd == "timeline": all_events = get_recent_events(args.slug, n=100_000, base_dir=base_dir) print(_format_timeline(all_events)) elif args.cmd == "rebuild": state = rebuild_state_from_events(args.slug, base_dir) print(json.dumps(state, ensure_ascii=False, indent=2)) if __name__ == "__main__": main()