Files
persona/scripts/persona.py
T
2026-07-29 14:37:20 +00:00

774 lines
33 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
# -*- 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())