项目文件夹

文件
wehub-resource-sync 1d6fd29cda
tests / test (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:36:27 +08:00

463 行
16 KiB
Python

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
#!/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.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()