#!/usr/bin/env python3 # -*- coding: utf-8 -*- """persona.py — jsc-persona 的人格 / 記憶 / 情緒 / 關係圖 CLI。 所有子指令都需要 `--session `(除了 list / status / gc)。 session_id 由 SessionStart hook 注入到上下文(PERSONA_SESSION=...), hook 會驗證 CLI 帶的 --session 與真實 session 相符,藉此讓「人格鎖」與 「跨人格隔離」無法被繞過。 用法(皆為 `python3 persona.py ...`): 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]: """呼叫者是 host(owner)或以 `--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 ` 再載入 `{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}`(exclusive,session {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"heartbeat:host={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("guest(sub agent)只能寫入 inbox:`--scope inbox --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}` 的 inbox(room {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("guest(sub 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 。") 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())