Files
wehub-resource-sync 1d6fd29cda
tests / test (push) Waiting to run
chore: import upstream snapshot with attribution
2026-07-13 12:36:27 +08:00

463 lines
16 KiB
Python
Raw Permalink 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.
#!/usr/bin/env python3
"""
simp-skill · Memory System
心上人档案记忆系统 — 事件追加、状态读写、快照管理
用法:
python3 tools/memory.py append <slug> <event_type> '<json_data>'
python3 tools/memory.py events <slug> [--last 5] [--type signal_recorded]
python3 tools/memory.py context <slug> [--with-strategy]
python3 tools/memory.py snapshot <slug>
python3 tools/memory.py timeline <slug>
python3 tools/memory.py rebuild <slug>
"""
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.mdfrontmatter_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()