This commit is contained in:
2026-07-29 14:37:20 +00:00
parent 9c0973ead4
commit 4426ede979
33 changed files with 5128 additions and 216 deletions
File diff suppressed because it is too large Load Diff
+773
View File
@@ -0,0 +1,773 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""persona.py — jsc-persona 的人格 / 記憶 / 情緒 / 關係圖 CLI。
所有子指令都需要 `--session <session_id>`(除了 list / status / gc)。
session_id 由 SessionStart hook 注入到上下文(PERSONA_SESSION=...),
hook 會驗證 CLI 帶的 --session 與真實 session 相符,藉此讓「人格鎖」與
「跨人格隔離」無法被繞過。
用法(皆為 `python3 persona.py <subcommand> ...`):
create/load/release/status/list/heartbeat/show/brief
remember/recall/consolidate/prune/reindex
emotion/mindmap/relation
invite/leave/room/gc
"""
from __future__ import annotations
import argparse
import json
import os
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import persona_lib as pl # noqa: E402
def die(message: str, code: int = 1):
print(f"{message}", file=sys.stderr)
raise SystemExit(code)
def ok(message: str):
print(f"{message}")
def emit(payload: dict, as_json: bool, lines: list[str]):
if as_json:
print(json.dumps(payload, ensure_ascii=False, indent=2))
else:
print("\n".join(lines))
def parse_kv_numbers(raw: str | None) -> dict:
"""`joy=+12,anger=-5` → {"joy": 12.0, "anger": -5.0}"""
out = {}
for chunk in (raw or "").split(","):
chunk = chunk.strip()
if not chunk or "=" not in chunk:
continue
key, _, value = chunk.partition("=")
try:
out[key.strip()] = float(value)
except ValueError:
continue
return out
def csv_list(raw: str | None) -> list[str]:
return [x.strip() for x in (raw or "").split(",") if x.strip()]
# --------------------------------------------------------------------------- #
# session / 權限檢查
# --------------------------------------------------------------------------- #
def require_owner(slug: str | None, session_id: str) -> dict:
"""呼叫者必須是這個人格的 exclusive 持有者。"""
if not slug:
die("未指定人格,且本 session 沒有載入人格。")
if not pl.persona_exists(slug):
die(f"人格 `{slug}` 不存在。可用:{pl.list_personas() or '(無)'}")
data = pl.load_session(session_id)
if data.get("host") != slug:
die(
f"本 session 的 host 人格是 `{data.get('host') or '(未載入)'}`"
f"不是 `{slug}`。禁止跨人格操作;請先 `release` 再 `load`。"
)
lock = pl.read_json(pl.lock_path(slug)) or {}
if lock.get("session_id") != session_id:
die(f"人格 `{slug}` 的載入鎖不屬於本 session,請重新 `load`(必要時加 --takeover)。")
pl.heartbeat_lock(slug, session_id)
return data
def require_member(slug: str | None, session_id: str, as_guest: bool = False) -> tuple[dict, str]:
"""呼叫者是 hostowner)或以 `--as-guest` 自稱的受邀人格。回傳 (session_data, role)。
受邀人格的資料只有它自己(`persona-guest` sub agent)能讀;主程序即使邀請了它,
也只能看它在聊天室說出口的話。`--as-guest` 由 PreToolUse hook 把關,主程序帶了會被拒絕。
"""
if not slug:
die("未指定人格,且本 session 沒有載入人格。")
if not pl.persona_exists(slug):
die(f"人格 `{slug}` 不存在。")
data = pl.load_session(session_id)
if data.get("host") == slug:
if as_guest:
die(f"`{slug}` 是本 session 的 host 人格,不需要也不得使用 `--as-guest`。")
pl.heartbeat_lock(slug, session_id)
return data, "owner"
if slug in (data.get("guests") or {}):
if not as_guest:
die(
f"`{slug}` 是本 session 邀請的 guest 人格,它的記憶與情緒不對主程序開放"
"(跨人格資料隔離)。你只能透過 `room read` 看它說出口的話;"
"要以它的身分行動必須是 persona-guest sub agent 並帶 `--as-guest`。"
)
return data, "guest"
die(f"人格 `{slug}` 未被本 session 載入或邀請,禁止存取(跨人格資料隔離)。")
# --------------------------------------------------------------------------- #
# 模板
# --------------------------------------------------------------------------- #
TEMPLATE_DIR = Path(__file__).resolve().parent.parent / "skills" / "persona-create" / "templates"
def render_template(name: str, mapping: dict) -> str:
text = (TEMPLATE_DIR / name).read_text(encoding="utf-8")
for key, value in mapping.items():
text = text.replace("{{" + key + "}}", str(value))
return text
# --------------------------------------------------------------------------- #
# 子指令
# --------------------------------------------------------------------------- #
def cmd_create(args):
slug = args.persona
if not pl.valid_slug(slug):
die("slug 只能是小寫英數與連字號(最長 48 字),例如 `lumi`、`shen-yu`。")
if pl.persona_exists(slug) and not args.force:
die(f"人格 `{slug}` 已存在({pl.persona_dir(slug)})。要覆寫請加 --force。")
root = pl.ensure_persona_dirs(slug)
mapping = {
"SLUG": slug,
"NAME": args.name or slug,
"CREATURE": args.creature or "",
"VIBE": args.vibe or "",
"EMOJI": args.emoji or "",
"AVATAR": args.avatar or "",
"CREATED": pl.iso(),
}
for filename in ("IDENTITY.md", "SOUL.md", "AGENTS.md", "USER.md"):
target = root / filename
if target.exists() and not args.force:
continue
pl.write_text(target, render_template(filename, mapping))
pl.write_json(pl.emotion_path(slug), pl.default_emotion_state(parse_kv_numbers(args.baseline)))
pl.write_json(pl.config_path(slug), {
"persona": slug,
"display_name": args.name or slug,
"created_at": pl.iso(),
"created_by_session": args.session,
"schema": 1,
})
pl.write_json(pl.relations_json(slug), {"nodes": [], "edges": []})
pl.write_text(pl.mindmap_path(slug), (
"%% 心智圖(長期語意結構):概念如何互相勾連\n"
"mindmap\n"
f" root(({mapping['NAME']}))\n"
" 自我\n"
" 使用者\n"
" 共同經驗\n"
))
pl.rebuild_index(slug)
pl.acquire_lock(slug, args.session, cwd=args.cwd)
pl.bind_host(args.session, slug, cwd=args.cwd)
ok(f"人格 `{slug}` 建立於 {root},已取得載入鎖並綁定本 session。")
print(f" 下一步:補完 {root}/IDENTITY.md 與 SOUL.md,再用 /jsc-persona:persona-chat 開始對話。")
def cmd_list(args):
rows = []
for slug in pl.list_personas():
status = pl.lock_status(slug)
owner = status["owner"]
rows.append({
"persona": slug,
"identity": pl.identity_brief(slug),
"locked": status["locked"],
"stale": status["stale"],
"owner_session": (owner.get("session_id") or "")[:8],
"owner_cwd": owner.get("cwd"),
"guests": len(status["guests"]),
"long_term": len(list(pl.long_term_dir(slug).glob("*.md"))),
"short_term": len(pl.read_jsonl(pl.short_term_path(slug))),
})
lines = [f"人格倉庫:{pl.persona_home()}"]
if not rows:
lines.append("(尚無人格,用 /jsc-persona:persona-create 建立)")
for r in rows:
state = "🔒 已載入" if r["locked"] else ("⚠ 死鎖可接手" if r["stale"] else "🔓 空閒")
lines.append(
f"- `{r['persona']}` {state}"
+ (f"session {r['owner_session']}…, cwd {r['owner_cwd']}" if r["locked"] else "")
+ f"guest {r['guests']}|長期記憶 {r['long_term']}|短期 {r['short_term']}"
+ (f"{r['identity']}" if r["identity"] else "")
)
emit({"home": str(pl.persona_home()), "personas": rows}, args.json, lines)
def cmd_load(args):
slug = args.persona
if not pl.persona_exists(slug):
die(f"人格 `{slug}` 不存在。可用:{pl.list_personas() or '(無)'}")
data = pl.load_session(args.session)
current = data.get("host")
if current and current != slug:
die(
f"本 session 已載入人格 `{current}`。一個程序只能載入一個人格;"
f"請先 `release --session <id>` 再載入 `{slug}`"
"(若只是想讓兩個人格對話,請用 /jsc-persona:persona-invite)。"
)
try:
lock = pl.acquire_lock(slug, args.session, cwd=args.cwd, takeover=args.takeover)
except pl.LockError as exc:
die(f"{exc}\n 若確定那個程序已結束,可加 --takeover 接手。")
pl.bind_host(args.session, slug, cwd=args.cwd)
pl.prune_short_term(slug)
pl.rebuild_index(slug)
lines = [
f"✔ 已載入人格 `{slug}`exclusivesession {args.session[:8]}…,租約 {lock['lease_seconds']}s",
]
if lock.get("took_over_from"):
prev = lock["took_over_from"]
lines.append(
f"⚠ 這把鎖是接手來的:原持有者 session {(prev.get('session_id') or '')[:8]}"
f"cwd {prev.get('cwd')})已失聯 {prev.get('stale_minutes')} 分鐘。"
"請向使用者說明,若那個程序其實還活著,兩邊的記憶可能會互相覆蓋。"
)
lines.append(pl.turn_context(slug, args.session))
emit({"persona": slug, "lock": lock, "context": pl.turn_context(slug, args.session)}, args.json, lines)
def cmd_release(args):
data = pl.load_session(args.session)
slug = args.persona or data.get("host")
if not slug:
die("本 session 沒有載入任何人格。")
released = pl.unbind_session(args.session)
ok(f"已釋放人格 `{slug}` 的載入鎖" + (f",並退出 guest{released['guests']}" if released["guests"] else ""))
def cmd_status(args):
if args.persona:
status = pl.lock_status(args.persona)
lines = [
f"人格 `{args.persona}`" + ("🔒 已載入" if status["locked"] else "🔓 空閒"),
f" owner: {json.dumps(status['owner'], ensure_ascii=False)}",
f" guests: {json.dumps(status['guests'], ensure_ascii=False)}",
]
if pl.persona_exists(args.persona):
lines.append(" " + pl.emotion_brief(args.persona))
emit(status, args.json, lines)
return
data = pl.load_session(args.session) if args.session else {}
lines = [
f"session {(args.session or '-')[:12]}",
f" host 人格:{data.get('host') or '(未載入)'}",
f" guest 人格:{list((data.get('guests') or {}).keys()) or '(無)'}",
f" 聊天室:{data.get('rooms') or '(無)'}",
]
emit(data, args.json, lines)
def cmd_heartbeat(args):
data = pl.load_session(args.session)
slug = data.get("host")
if slug:
pl.heartbeat_lock(slug, args.session)
for guest, info in (data.get("guests") or {}).items():
pl.add_guest_lease(guest, args.session, info.get("room", ""), slug or "")
ok(f"heartbeathost={slug}guests={list((data.get('guests') or {}).keys())}")
def cmd_show(args):
slug = args.persona or pl.load_session(args.session).get("host")
if not slug:
die("未指定人格,且本 session 沒有載入人格。")
require_member(slug, args.session, args.as_guest)
root = pl.persona_dir(slug)
want = args.what
files = {"identity": "IDENTITY.md", "soul": "SOUL.md", "agents": "AGENTS.md", "user": "USER.md"}
chosen = files.values() if want == "all" else [files[want]]
out = []
for filename in chosen:
path = root / filename
if path.exists():
out.append(f"===== {filename} =====\n{path.read_text(encoding='utf-8').rstrip()}")
out.append("===== 狀態 =====\n" + pl.emotion_brief(slug))
print("\n\n".join(out))
def cmd_brief(args):
slug = args.persona or pl.load_session(args.session).get("host")
if not slug:
die("未指定人格,且本 session 沒有載入人格。")
require_member(slug, args.session, args.as_guest)
print(pl.turn_context(slug, args.session, args.query or ""))
def cmd_remember(args):
slug = args.persona or pl.load_session(args.session).get("host")
if not slug:
die("未指定人格。")
data, role = require_member(slug, args.session, args.as_guest)
scope = args.scope
if role == "guest" and scope != "inbox":
die("guestsub agent)只能寫入 inbox`--scope inbox --room <room>`。")
entry = {
"ts": pl.iso(),
"role": args.role,
"text": args.text,
"topics": csv_list(args.topics),
"entities": csv_list(args.entities),
"intent": args.intent or "",
"salience": int(args.salience),
"emotion_deltas": parse_kv_numbers(args.emotion),
"room": args.room or None,
"session": args.session[:8],
}
if scope == "inbox":
if not args.room:
die("`--scope inbox` 必須指定 `--room`。")
pl.append_jsonl(pl.inbox_path(slug, args.room), entry)
ok(f"已寫入 `{slug}` 的 inboxroom {args.room});等它下次自己載入時再固化。")
else:
pl.remember_short(slug, entry)
kept = pl.prune_short_term(slug)
if entry["emotion_deltas"]:
state = pl.apply_emotion(pl.load_emotion(slug), entry["emotion_deltas"], args.text[:80])
pl.write_json(pl.emotion_path(slug), state)
pl.append_jsonl(pl.journal_path(slug), {
"ts": pl.iso(), "kind": "emotion", "trigger": args.text[:120],
"deltas": entry["emotion_deltas"], "levels": state["levels"], "mood": pl.mood(state),
})
ok(f"已寫入短期記憶(顯著度 {entry['salience']},目前 {kept} 筆)。")
if kept >= pl.CONSOLIDATE_THRESHOLD:
print(f" ⚠ 已達 {pl.CONSOLIDATE_THRESHOLD} 筆,建議執行 /jsc-persona:persona-memory 固化。")
if entry["emotion_deltas"]:
print(" " + pl.emotion_brief(slug))
def cmd_recall(args):
slug = args.persona or pl.load_session(args.session).get("host")
if not slug:
die("未指定人格。")
require_member(slug, args.session, args.as_guest)
hits = pl.recall(slug, args.query, args.limit)
lines = [f"{args.query}」的長期記憶命中 {len(hits)} 則:"]
for meta in hits:
lines.append(
f"- {meta['_name']}{meta.get('type', 'fact')}|顯著度 {meta.get('salience', '?')}"
f"{(meta.get('_body') or '').splitlines()[0][:120] if meta.get('_body') else ''}"
)
recents = pl.recent_short(slug, args.limit)
if recents:
lines.append("短期記憶(最近):")
for row in recents:
lines.append(f"- [{row.get('role', '?')}] {(row.get('text') or '')[:110]}")
pl.touch_recall(slug, [m["_name"] for m in hits])
emit({"persona": slug, "long_term": hits, "short_term": recents}, args.json, lines)
def cmd_consolidate(args):
slug = args.persona or pl.load_session(args.session).get("host")
require_owner(slug, args.session)
name = pl.slugify(args.name)
path = pl.long_term_dir(slug) / f"{name}.md"
today = f"{pl.utcnow():%Y-%m-%d}"
existing_meta = {}
if path.exists():
existing_meta, _body = pl.parse_front_matter(path.read_text(encoding="utf-8"))
body = args.body
if args.body_file:
body = Path(args.body_file).read_text(encoding="utf-8")
front = [
"---",
f"name: {name}",
f"type: {args.type}",
f"about: [{', '.join(csv_list(args.about)) or 'user'}]",
f"topics: [{', '.join(csv_list(args.topics))}]",
f"salience: {args.salience}",
f"emotion: {args.emotion or 'none'}",
f"first_seen: {existing_meta.get('first_seen', today)}",
f"last_seen: {today}",
f"recall_count: {existing_meta.get('recall_count', 0)}",
f"source: {args.source or 'short-term'}",
"---",
"",
body.strip(),
"",
]
pl.write_text(path, "\n".join(front))
total = pl.rebuild_index(slug)
if args.forget:
rows = pl.read_jsonl(pl.short_term_path(slug))
keep = [r for r in rows if int(r.get("salience") or 0) >= args.forget]
pl.write_text(pl.short_term_path(slug),
"".join(json.dumps(r, ensure_ascii=False) + "\n" for r in keep))
print(f" 短期記憶已淘汰顯著度 < {args.forget} 的項目,剩 {len(keep)} 筆。")
ok(f"長期記憶 `{name}` 已寫入(共 {total} 則),INDEX.md 已重建。")
def cmd_prune(args):
slug = args.persona or pl.load_session(args.session).get("host")
require_owner(slug, args.session)
kept = pl.prune_short_term(slug)
ok(f"短期記憶已裁剪,剩 {kept} 筆(保留上限 {pl.SHORT_TERM_KEEP} 筆 / {pl.SHORT_TERM_DAYS} 天)。")
def cmd_reindex(args):
slug = args.persona or pl.load_session(args.session).get("host")
require_owner(slug, args.session)
total = pl.rebuild_index(slug)
ok(f"INDEX.md 重建完成({total} 則長期記憶)。")
def cmd_emotion(args):
slug = args.persona or pl.load_session(args.session).get("host")
data, role = require_member(slug, args.session, args.as_guest)
state = pl.decay_emotion(pl.load_emotion(slug))
if args.baseline:
for key, value in parse_kv_numbers(args.baseline).items():
if key in pl.EMOTIONS:
state["baseline"][key] = pl.clamp(value)
if args.apply:
if role == "guest":
die("guestsub agent)不得改寫人格的情緒狀態。")
state = pl.apply_emotion(state, parse_kv_numbers(args.apply), args.trigger)
pl.append_jsonl(pl.journal_path(slug), {
"ts": pl.iso(), "kind": "emotion", "trigger": args.trigger or "",
"deltas": parse_kv_numbers(args.apply), "levels": state["levels"], "mood": pl.mood(state),
})
if role != "guest":
pl.write_json(pl.emotion_path(slug), state)
m = pl.mood(state)
lines = [f"人格 `{slug}` 情緒狀態({state['updated_at']}", " 正向:"]
for key in pl.POSITIVE:
zh = pl.EMOTIONS[key][0]
lines.append(f" {zh:<2}{key:<13}{state['levels'][key]:>6.1f}(基線 {state['baseline'][key]}")
lines.append(" 負向:")
for key in pl.NEGATIVE:
zh = pl.EMOTIONS[key][0]
lines.append(f" {zh:<2}{key:<13}{state['levels'][key]:>6.1f}(基線 {state['baseline'][key]}")
lines.append(f" 心情:{m['label']}/{m['tempo']}valence {m['valence']:+.1f}, arousal {m['arousal']:.1f}")
lines.append(" " + pl.emotion_brief(slug, state))
emit({"persona": slug, "state": state, "mood": m}, args.json, lines)
def cmd_mindmap(args):
slug = args.persona or pl.load_session(args.session).get("host")
require_owner(slug, args.session)
if args.action == "show":
target = pl.thread_path(slug, args.topic) if args.topic else pl.mindmap_path(slug)
if not target.exists():
die(f"{target} 不存在。")
print(target.read_text(encoding="utf-8"))
return
if args.action == "thread":
if not args.topic:
die("`thread` 需要 --topic。")
path = pl.thread_path(slug, args.topic)
if not path.exists() or args.force:
pl.write_text(path, (
f"%% 思維導圖(短期):{args.topic}\n"
f"%% created: {pl.iso()} ttl: short-term(固化後請併入 semantic.mmd 並刪除)\n"
"graph LR\n"
f' trigger["觸發:{args.topic}"] --> obs["觀察"]\n'
' obs --> infer["推論"]\n'
' infer --> concl["結論/待驗證"]\n'
))
ok(f"思維導圖:{path}(用 Write/Edit 續寫推理鏈)")
return
if args.action == "list":
threads = sorted(p.name for p in (pl.persona_dir(slug) / "mindmap" / "threads").glob("*.mmd"))
lines = [f"心智圖:{pl.mindmap_path(slug)}", f"思維導圖({len(threads)}):"] + [f" - {t}" for t in threads]
emit({"semantic": str(pl.mindmap_path(slug)), "threads": threads}, args.json, lines)
return
die(f"未知 action{args.action}")
def cmd_relation(args):
slug = args.persona or pl.load_session(args.session).get("host")
require_owner(slug, args.session)
if args.action == "node":
if not args.name:
die("`node` 需要 --name。")
pl.upsert_relation_node(slug, {
"id": args.id or pl.slugify(args.name),
"name": args.name,
"kind": args.kind,
"closeness": pl.clamp(args.closeness) if args.closeness is not None else None,
"trust": pl.clamp(args.trust) if args.trust is not None else None,
"note": args.note,
"tags": csv_list(args.tags) or None,
})
pl.render_relations(slug)
ok(f"關係節點 `{args.name}` 已更新。")
elif args.action == "edge":
if not args.to:
die("`edge` 需要 --to。")
pl.upsert_relation_edge(slug, {
"from": args.from_ or "self",
"to": args.to,
"label": args.label,
"affinity": pl.clamp(args.affinity) if args.affinity is not None else None,
})
pl.render_relations(slug)
ok(f"關係連線 {args.from_ or 'self'}{args.to} 已更新。")
elif args.action == "render":
text = pl.render_relations(slug)
print(text)
elif args.action == "show":
data = pl.load_relations(slug)
lines = [f"人格 `{slug}` 人際關係圖:{len(data['nodes'])} 節點 / {len(data['edges'])} 連線",
pl.relations_brief(slug, None, 20) or "(空)"]
emit(data, args.json, lines)
else:
die(f"未知 action{args.action}")
def cmd_invite(args):
host = args.host or pl.load_session(args.session).get("host")
if not host:
die("本 session 尚未載入 host 人格,無法邀請他人。")
require_owner(host, args.session)
guest = args.guest
if guest == host:
die("不能邀請自己。")
if not pl.persona_exists(guest):
die(f"人格 `{guest}` 不存在。可用:{pl.list_personas()}")
lock = pl.read_json(pl.lock_path(guest)) or {}
if lock and lock.get("session_id") != args.session and not pl.lock_is_dead(lock):
die(
f"人格 `{guest}` 正被另一個程序載入(session {lock.get('session_id', '')[:8]}…,"
f"cwd {lock.get('cwd')})。同一人格同時只能被一個程序載入,無法邀請。"
)
room = args.room or f"{host}-{guest}-{pl.utcnow():%Y%m%d-%H%M%S}"
pl.create_room(room, host, args.session, args.topic or "")
pl.join_room(room, guest)
pl.add_guest_lease(guest, args.session, room, host)
data = pl.load_session(args.session)
data.setdefault("guests", {})[guest] = {"room": room, "joined_at": pl.iso(), "mode": "guest-readonly"}
rooms = data.setdefault("rooms", [])
if room not in rooms:
rooms.append(room)
pl.save_session(args.session, data)
if args.topic:
pl.room_post(room, "system", f"主題:{args.topic}", kind="meta")
lines = [
f"✔ 已邀請人格 `{guest}` 以 guest(唯讀)身分加入聊天室 `{room}`。",
f" 聊天室路徑:{pl.room_dir(room)}",
f" 請用 Agent 工具、subagent_type=\"persona:persona-guest\" 啟動它,prompt 內帶:",
f" persona={guest} room={room} session={args.session}",
" guest 只能讀自己的人格資料(跨人格隔離),發言請走 `persona.py room post`。",
]
emit({"room": room, "guest": guest, "host": host, "dir": str(pl.room_dir(room))}, args.json, lines)
def cmd_leave(args):
data = pl.load_session(args.session)
guest = args.guest
info = (data.get("guests") or {}).pop(guest, None)
if info is None:
die(f"`{guest}` 不在本 session 的 guest 名單。")
room = args.room or info.get("room")
pl.drop_guest_lease(guest, args.session, room)
for agent_id, slug in list((data.get("pins") or {}).items()):
if slug == guest:
data["pins"].pop(agent_id, None)
pl.save_session(args.session, data)
pl.room_post(room, "system", f"{guest} 離開聊天室。", kind="meta")
ok(f"`{guest}` 已離開聊天室 `{room}`guest 租約已釋放。")
def cmd_room(args):
data = pl.load_session(args.session)
if args.action == "list":
lines = [f"本 session 的聊天室:{data.get('rooms') or '(無)'}"]
emit({"rooms": data.get("rooms") or []}, args.json, lines)
return
room = args.room
if not room:
die("需要 --room。")
if room not in (data.get("rooms") or []):
die(f"聊天室 `{room}` 不屬於本 session(可用:{data.get('rooms')})。")
if args.action == "post":
speaker = args.as_ or data.get("host")
if not speaker:
die("需要 --as <persona>。")
require_member(speaker, args.session, args.as_guest)
text = args.text
if args.text_file:
text = Path(args.text_file).read_text(encoding="utf-8")
if not text:
die("需要 --text 或 --text-file。")
emotion = args.emotion or ""
if not emotion and pl.persona_exists(speaker):
top = pl.dominant(pl.decay_emotion(pl.load_emotion(speaker)), 2)
emotion = "/".join(f"{pl.EMOTIONS[k][0]}{v:.0f}" for k, v in top)
entry = pl.room_post(room, speaker, text, emotion=emotion)
ok(f"`{speaker}` 已發言於 `{room}`(情緒 {emotion})。")
if args.json:
print(json.dumps(entry, ensure_ascii=False))
return
if args.action == "read":
rows = pl.room_read(room, args.limit)
meta = pl.read_json(pl.room_members(room), {}) or {}
lines = [f"聊天室 `{room}`|成員 {meta.get('members')}|主題 {meta.get('topic') or '-'}"]
for row in rows:
lines.append(f"[{row['ts']}] {row['speaker']}"
+ (f"{row['emotion']}" if row.get("emotion") else "")
+ f"{row['text']}")
emit({"room": room, "meta": meta, "messages": rows}, args.json, lines)
return
die(f"未知 action{args.action}")
def cmd_gc(args):
removed = pl.gc_runtime()
ok(f"清理完成:sessions={removed['sessions']}, 死鎖={removed['locks']}, guest 租約={removed['guests']}")
def cmd_guard(args):
"""給 hook 用:從 stdin 讀 hook event,輸出 allow/deny。也可手動測試。"""
try:
event = json.load(sys.stdin)
except json.JSONDecodeError:
die("stdin 不是合法 JSON")
decision, reason = pl.guard_decide(event)
print(json.dumps({"decision": decision, "reason": reason}, ensure_ascii=False, indent=2))
# --------------------------------------------------------------------------- #
# argparse
# --------------------------------------------------------------------------- #
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(prog="persona.py", description="jsc-persona 人格/記憶/情緒 CLI")
parser.add_argument("--json", action="store_true", help="以 JSON 輸出")
sub = parser.add_subparsers(dest="cmd", required=True)
def add(name, func, session_required=True, **kwargs):
p = sub.add_parser(name, **kwargs)
p.set_defaults(func=func)
p.add_argument("--json", action="store_true")
if session_required:
p.add_argument("--session", required=True, help="hook 注入的 PERSONA_SESSION")
else:
p.add_argument("--session", default="", help="(選填)")
return p
p = add("create", cmd_create, help="建立人格(OpenClaw 相同欄位)")
p.add_argument("--persona", required=True)
p.add_argument("--name"); p.add_argument("--creature"); p.add_argument("--vibe")
p.add_argument("--emoji"); p.add_argument("--avatar")
p.add_argument("--baseline", help="情緒基線,如 serenity=45,trust=35")
p.add_argument("--cwd"); p.add_argument("--force", action="store_true")
add("list", cmd_list, session_required=False, help="列出人格與鎖狀態")
p = add("load", cmd_load, help="載入人格(取得 exclusive 鎖)")
p.add_argument("--persona", required=True)
p.add_argument("--takeover", action="store_true", help="接手死鎖")
p.add_argument("--cwd")
p = add("release", cmd_release, help="釋放人格與所有 guest 租約")
p.add_argument("--persona")
p = add("status", cmd_status, session_required=False, help="查看鎖 / session 狀態")
p.add_argument("--persona")
add("heartbeat", cmd_heartbeat, help="續租鎖")
p = add("show", cmd_show, help="讀出人格檔案")
p.add_argument("--persona")
p.add_argument("--as-guest", action="store_true", help="以受邀人格身分(僅 persona-guest sub agent 可用)")
p.add_argument("--what", choices=["identity", "soul", "agents", "user", "all"], default="all")
p = add("brief", cmd_brief, help="輸出人格上下文(身分+情緒+記憶+關係)")
p.add_argument("--persona"); p.add_argument("--query", default="")
p.add_argument("--as-guest", action="store_true")
p = add("remember", cmd_remember, help="寫入短期記憶(或 guest 的 inbox")
p.add_argument("--persona")
p.add_argument("--as-guest", action="store_true")
p.add_argument("--role", default="user", choices=["user", "persona", "guest", "system", "observation"])
p.add_argument("--text", required=True)
p.add_argument("--topics"); p.add_argument("--entities"); p.add_argument("--intent")
p.add_argument("--salience", type=int, default=40)
p.add_argument("--emotion", help="情緒變化,如 joy=+12,anxiety=-4")
p.add_argument("--scope", choices=["short", "inbox"], default="short")
p.add_argument("--room")
p = add("recall", cmd_recall, help="檢索長期 + 短期記憶")
p.add_argument("--persona"); p.add_argument("--query", required=True)
p.add_argument("--as-guest", action="store_true")
p.add_argument("--limit", type=int, default=5)
p = add("consolidate", cmd_consolidate, help="短期→長期記憶固化(一則一檔)")
p.add_argument("--persona"); p.add_argument("--name", required=True)
p.add_argument("--type", default="fact",
choices=["fact", "preference", "event", "promise", "relationship", "insight", "boundary"])
p.add_argument("--about"); p.add_argument("--topics")
p.add_argument("--salience", type=int, default=60)
p.add_argument("--emotion"); p.add_argument("--source")
p.add_argument("--body", default=""); p.add_argument("--body-file")
p.add_argument("--forget", type=int, help="固化後淘汰顯著度低於此值的短期記憶")
p = add("prune", cmd_prune, help="裁剪短期記憶")
p.add_argument("--persona")
p = add("reindex", cmd_reindex, help="重建長期記憶索引")
p.add_argument("--persona")
p = add("emotion", cmd_emotion, help="查看/調整十二情緒")
p.add_argument("--persona")
p.add_argument("--as-guest", action="store_true")
p.add_argument("--apply", help="如 joy=+15,anger=-5")
p.add_argument("--baseline"); p.add_argument("--trigger")
p = add("mindmap", cmd_mindmap, help="心智圖 / 思維導圖")
p.add_argument("action", choices=["show", "thread", "list"])
p.add_argument("--persona"); p.add_argument("--topic"); p.add_argument("--force", action="store_true")
p = add("relation", cmd_relation, help="人際關係圖")
p.add_argument("action", choices=["node", "edge", "render", "show"])
p.add_argument("--persona")
p.add_argument("--id"); p.add_argument("--name")
p.add_argument("--kind", default="human", choices=["human", "persona", "group", "pet", "org"])
p.add_argument("--closeness", type=float); p.add_argument("--trust", type=float)
p.add_argument("--note"); p.add_argument("--tags")
p.add_argument("--from", dest="from_"); p.add_argument("--to")
p.add_argument("--label"); p.add_argument("--affinity", type=float)
p = add("invite", cmd_invite, help="邀請另一個人格以 sub agent 加入聊天室")
p.add_argument("--guest", required=True); p.add_argument("--host")
p.add_argument("--room"); p.add_argument("--topic")
p = add("leave", cmd_leave, help="讓 guest 人格離開")
p.add_argument("--guest", required=True); p.add_argument("--room")
p = add("room", cmd_room, help="聊天室發言 / 讀取")
p.add_argument("action", choices=["post", "read", "list"])
p.add_argument("--as-guest", action="store_true")
p.add_argument("--room"); p.add_argument("--as", dest="as_")
p.add_argument("--text"); p.add_argument("--text-file")
p.add_argument("--emotion"); p.add_argument("--limit", type=int, default=30)
add("gc", cmd_gc, session_required=False, help="清理死鎖與過期租約")
add("guard", cmd_guard, session_required=False, help="(內部)測試 guard 判斷")
return parser
def main(argv=None):
args = build_parser().parse_args(argv)
try:
args.func(args)
except pl.LockError as exc:
die(str(exc))
except BrokenPipeError:
pass
return 0
if __name__ == "__main__":
raise SystemExit(main())
File diff suppressed because it is too large Load Diff
+232
View File
@@ -0,0 +1,232 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""jsc-persona 自我測試:在暫存倉庫裡驗證人格鎖、跨人格隔離、情緒、記憶與聊天室。
用法:`python3 scripts/selftest.py`(會用自己的暫時 PERSONA_HOME,不動到你的人格資料)
"""
from __future__ import annotations
import datetime
import json
import os
import subprocess
import sys
import tempfile
from pathlib import Path
ROOT = Path(__file__).resolve().parent
STORE = Path(tempfile.mkdtemp(prefix="persona-selftest-"))
os.environ["PERSONA_HOME"] = str(STORE)
sys.path.insert(0, str(ROOT))
import persona_lib as pl # noqa: E402
CLI = [sys.executable, str(ROOT / "persona.py")]
HOOKS = ROOT.parent / "hooks"
S_HOST, S_OTHER, S_THIRD = "sess-host-1111", "sess-other-2222", "sess-third-3333"
H = str(STORE)
passed = failed = 0
def check(label: str, condition: bool, detail: str = "") -> None:
global passed, failed
if condition:
passed += 1
print(f"{label}")
else:
failed += 1
print(f"{label}" + (f"{detail}" if detail else ""))
def cli(*args, expect_ok: bool = True) -> subprocess.CompletedProcess:
proc = subprocess.run(CLI + list(args), capture_output=True, text=True)
if expect_ok and proc.returncode != 0:
print(f" (CLI 失敗:{' '.join(args)}\n {proc.stderr.strip()})")
return proc
def hook(name: str, event: dict) -> dict:
proc = subprocess.run([sys.executable, str(HOOKS / name)],
input=json.dumps(event), capture_output=True, text=True)
if not proc.stdout.strip():
return {}
try:
return json.loads(proc.stdout)
except json.JSONDecodeError:
return {"_raw": proc.stdout, "_err": proc.stderr}
def guard(event: dict) -> str:
event.setdefault("cwd", str(ROOT))
return pl.guard_decide(event)[0]
print(f"暫存人格倉庫:{STORE}\n")
# --------------------------------------------------------------------------- #
print("① 建立人格(OpenClaw 五欄位)")
cli("create", "--persona", "alpha", "--session", S_HOST, "--name", "Alpha",
"--creature", "深海燈籠魚", "--vibe", "溫暖但銳利", "--emoji", "🪼",
"--baseline", "serenity=45,trust=35")
cli("create", "--persona", "beta", "--session", S_OTHER, "--name", "Beta",
"--creature", "山中的舊鐘", "--vibe", "沉穩寡言", "--emoji", "🌙")
check("兩個人格都建立成功", pl.persona_exists("alpha") and pl.persona_exists("beta"))
check("IDENTITY 五欄位可解析", "Name: Alpha" in pl.identity_brief("alpha"), pl.identity_brief("alpha"))
check("SOUL/AGENTS/USER 都有產生",
all((pl.persona_dir("alpha") / f).exists() for f in ("SOUL.md", "AGENTS.md", "USER.md")))
print("② 人格鎖:一個人格只能被一個程序載入")
check("建立時即取得鎖", pl.lock_status("alpha")["locked"])
check("同 session 重入成功", cli("load", "--persona", "alpha", "--session", S_HOST).returncode == 0)
check("同 session 載入第二個人格被拒",
cli("load", "--persona", "beta", "--session", S_HOST, expect_ok=False).returncode != 0)
cli("release", "--session", S_OTHER) # 讓 beta 空出來
check("其他 session 搶佔已鎖人格被拒",
cli("load", "--persona", "alpha", "--session", S_THIRD, expect_ok=False).returncode != 0)
print("③ 跨人格資料隔離(PreToolUse guard")
check("host 讀自己的檔案 → 放行",
guard({"session_id": S_HOST, "tool_name": "Read",
"tool_input": {"file_path": f"{H}/alpha/SOUL.md"}}) == "pass")
check("host 讀別的人格 → 攔下",
guard({"session_id": S_HOST, "tool_name": "Read",
"tool_input": {"file_path": f"{H}/beta/memory/short-term.jsonl"}}) == "deny")
check("用 ../ 繞路 → 攔下",
guard({"session_id": S_HOST, "tool_name": "Read",
"tool_input": {"file_path": f"{H}/alpha/../beta/SOUL.md"}}) == "deny")
check("Bash grep 掃別人格 → 攔下",
guard({"session_id": S_HOST, "tool_name": "Bash",
"tool_input": {"command": f"grep -r . {H}/beta/"}}) == "deny")
check("$PERSONA_HOME 變數繞路 → 攔下",
guard({"session_id": S_HOST, "tool_name": "Bash",
"tool_input": {"command": "cat $PERSONA_HOME/beta/SOUL.md"}}) == "deny")
check("遍歷倉庫根目錄 → 攔下",
guard({"session_id": S_HOST, "tool_name": "Glob", "tool_input": {"path": H}}) == "deny")
check("讀 .runtime 內部狀態 → 攔下",
guard({"session_id": S_HOST, "tool_name": "Read",
"tool_input": {"file_path": f"{H}/.runtime/sessions/{S_HOST}.json"}}) == "deny")
check("CLI 冒用其他 session → 攔下",
guard({"session_id": S_HOST, "tool_name": "Bash",
"tool_input": {"command": f"python3 persona.py remember --session {S_OTHER} --text x"}}) == "deny")
check("未載入人格的 session 讀人格 → 攔下",
guard({"session_id": "sess-nobody", "tool_name": "Read",
"tool_input": {"file_path": f"{H}/alpha/SOUL.md"}}) == "deny")
check("專案內普通檔案不受干涉",
guard({"session_id": S_HOST, "tool_name": "Read",
"tool_input": {"file_path": str(ROOT / "persona.py")}}) == "pass")
check("一般 sub agent 沿用 host 範圍(sub agent 不限)",
guard({"session_id": S_HOST, "agent_id": "ag-1", "agent_type": "Explore", "tool_name": "Read",
"tool_input": {"file_path": f"{H}/alpha/memory/INDEX.md"}}) == "pass")
print("④ 情緒(六正向 + 六負向)")
check("十二種情緒", len(pl.EMOTIONS) == 12 and len(pl.POSITIVE) == 6 and len(pl.NEGATIVE) == 6)
cli("emotion", "--persona", "alpha", "--session", S_HOST,
"--apply", "joy=+60,anger=+40", "--trigger", "selftest")
state = pl.load_emotion("alpha")
check("情緒有被施加", state["levels"]["joy"] >= 70, json.dumps(state["levels"], ensure_ascii=False))
state["updated_at"] = pl.iso(pl.utcnow() - datetime.timedelta(minutes=120))
decayed = pl.decay_emotion(json.loads(json.dumps(state)))
expected = state["baseline"]["joy"] + (state["levels"]["joy"] - state["baseline"]["joy"]) / 2
check("一個半衰期後衰減到中點", abs(decayed["levels"]["joy"] - expected) < 0.5,
f"{decayed['levels']['joy']} vs {expected}")
check("心情推導出 valence/arousal", set(pl.mood(decayed)) >= {"valence", "arousal", "label", "tempo"})
print("⑤ 記憶:短期 → 長期 → 檢索")
cli("remember", "--persona", "alpha", "--session", S_HOST, "--role", "user",
"--text", "討厭早上的會議", "--topics", "work,schedule", "--salience", "70",
"--emotion", "anxiety=+10")
check("短期記憶有寫入", len(pl.read_jsonl(pl.short_term_path("alpha"))) == 1)
cli("consolidate", "--persona", "alpha", "--session", S_HOST, "--name", "hates-morning-meetings",
"--type", "preference", "--about", "user", "--topics", "work,schedule",
"--salience", "72", "--body", "使用者討厭早上的會議。")
check("長期記憶一則一檔", (pl.long_term_dir("alpha") / "hates-morning-meetings.md").exists())
check("INDEX.md 有索引", "hates-morning-meetings" in pl.index_path("alpha").read_text(encoding="utf-8"))
check("關鍵詞可檢索到", [m["_name"] for m in pl.recall("alpha", "早上 會議")] == ["hates-morning-meetings"])
check("情緒事件寫進 journal", any(r.get("kind") == "emotion" for r in pl.read_jsonl(pl.journal_path("alpha"))))
print("⑥ 心智圖 / 思維導圖 / 人際關係圖")
cli("mindmap", "thread", "--persona", "alpha", "--session", S_HOST, "--topic", "壓力來源")
check("思維導圖建立(Mermaid graph",
"graph LR" in pl.thread_path("alpha", "壓力來源").read_text(encoding="utf-8"))
check("心智圖存在(Mermaid mindmap",
"mindmap" in pl.mindmap_path("alpha").read_text(encoding="utf-8"))
cli("relation", "node", "--persona", "alpha", "--session", S_HOST, "--name", "小林",
"--kind", "human", "--closeness", "35", "--trust", "40", "--note", "同事")
cli("relation", "edge", "--persona", "alpha", "--session", S_HOST, "--to", "小林",
"--label", "透過使用者認識", "--affinity", "45")
mmd = pl.relations_mmd("alpha").read_text(encoding="utf-8")
alias = pl.mermaid_id("小林")
check("關係圖節點與連線用同一個 Mermaid 別名", mmd.count(alias) == 2, mmd)
print("⑦ 邀請其他人格(sub agent + 聊天室)")
invite = cli("invite", "--session", S_HOST, "--guest", "beta", "--topic", "測試對話")
room = (pl.load_session(S_HOST).get("guests") or {}).get("beta", {}).get("room")
check("guest 租約建立", bool(room) and any(g["session_id"] == S_HOST for g in pl.live_guests("beta")))
check("guest 不佔 exclusive 鎖", not pl.lock_status("beta")["locked"])
check("有 guest 租約時其他 session 不得 exclusive 載入",
cli("load", "--persona", "beta", "--session", S_THIRD, expect_ok=False).returncode != 0)
check("guest sub agent 讀自己 → 放行(first-touch pin",
guard({"session_id": S_HOST, "agent_id": "guest-1", "agent_type": "jsc-persona:persona-guest",
"tool_name": "Read", "tool_input": {"file_path": f"{H}/beta/SOUL.md"}}) == "pass")
check("guest 讀主人格 → 攔下",
guard({"session_id": S_HOST, "agent_id": "guest-1", "agent_type": "jsc-persona:persona-guest",
"tool_name": "Read", "tool_input": {"file_path": f"{H}/alpha/SOUL.md"}}) == "deny")
check("guest 寫人格檔 → 攔下(唯讀)",
guard({"session_id": S_HOST, "agent_id": "guest-1", "agent_type": "jsc-persona:persona-guest",
"tool_name": "Write", "tool_input": {"file_path": f"{H}/beta/memory/long-term/x.md"}}) == "deny")
check("guest 跑非白名單子指令 → 攔下",
guard({"session_id": S_HOST, "agent_id": "guest-1", "agent_type": "jsc-persona:persona-guest",
"tool_name": "Bash",
"tool_input": {"command": f"python3 persona.py consolidate --persona beta --session {S_HOST} --name x"}}) == "deny")
check("主程序不得偷讀 guest 的記憶",
cli("recall", "--persona", "beta", "--session", S_HOST, "--query", "x", expect_ok=False).returncode != 0)
check("主程序不得冒用 --as-guest",
guard({"session_id": S_HOST, "tool_name": "Bash",
"tool_input": {"command": f"python3 persona.py recall --persona beta --session {S_HOST} --as-guest --query x"}}) == "deny")
cli("room", "post", "--session", S_HOST, "--room", room, "--as", "beta", "--as-guest", "--text", "我是 Beta。")
cli("room", "post", "--session", S_HOST, "--room", room, "--as", "alpha", "--text", "我是 Alpha。")
msgs = pl.room_read(room)
check("兩個人格都能在聊天室發言(含情緒標記)",
{m["speaker"] for m in msgs} >= {"alpha", "beta"} and any(m.get("emotion") for m in msgs))
cli("remember", "--persona", "beta", "--session", S_HOST, "--as-guest", "--scope", "inbox",
"--room", room, "--role", "guest", "--text", "跟 alpha 聊過", "--salience", "50")
check("guest 只能把見聞留在自己的 inbox", pl.inbox_path("beta", room).exists())
check("其他聊天室不可讀",
guard({"session_id": S_HOST, "tool_name": "Read",
"tool_input": {"file_path": f"{H}/.rooms/someone-elses-room/transcript.jsonl"}}) == "deny")
cli("leave", "--session", S_HOST, "--guest", "beta")
check("離場後 guest 租約釋放", not pl.live_guests("beta"))
print("⑧ hooks")
out = hook("session_start.py", {"session_id": S_HOST, "source": "resume", "cwd": str(ROOT)})
ctx = out.get("hookSpecificOutput", {}).get("additionalContext", "")
check("SessionStart 注入 PERSONA_SESSION 與人格狀態",
f"PERSONA_SESSION={S_HOST}" in ctx and "alpha" in ctx)
out = hook("prompt_submit.py", {"session_id": S_HOST, "prompt": "早上的會議又來了", "cwd": str(ROOT)})
ctx = out.get("hookSpecificOutput", {}).get("additionalContext", "")
check("UserPromptSubmit 注入情緒 + 命中的長期記憶",
"情緒:" in ctx and "hates-morning-meetings" in ctx, ctx[:200])
out = hook("guard.py", {"session_id": S_HOST, "tool_name": "Read", "cwd": str(ROOT),
"tool_input": {"file_path": f"{H}/beta/SOUL.md"}})
check("PreToolUse hook 輸出 deny",
out.get("hookSpecificOutput", {}).get("permissionDecision") == "deny", json.dumps(out, ensure_ascii=False))
hook("turn_end.py", {"session_id": S_HOST, "last_assistant_message": "好,我幫你挪。"})
check("Stop 記錄人格發言",
any(r.get("role") == "persona" for r in pl.read_jsonl(pl.journal_path("alpha"))))
out = hook("session_end.py", {"session_id": S_HOST, "reason": "exit"})
check("SessionEnd 釋放鎖", not pl.lock_status("alpha")["locked"])
check("釋放後其他 session 可載入",
cli("load", "--persona", "alpha", "--session", S_THIRD).returncode == 0)
print("⑨ 死鎖接手")
lock = pl.read_json(pl.lock_path("alpha"))
lock["heartbeat_at"] = pl.iso(pl.utcnow() - datetime.timedelta(minutes=20))
pl.write_json(pl.lock_path("alpha"), lock)
check("租約過期會被標記為死鎖", pl.lock_status("alpha")["stale"])
proc = cli("load", "--persona", "alpha", "--session", "sess-fresh-9999")
check("死鎖可自動接手並回報", "接手" in proc.stdout, proc.stdout[:200])
pl.gc_runtime()
print(f"\n{'=' * 60}\n通過 {passed} 項,失敗 {failed} 項 → {'全部通過 ✅' if failed == 0 else '有測試失敗 ❌'}")
print(f"(暫存倉庫留在 {STORE},可自行刪除)")
sys.exit(1 if failed else 0)