#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""persona_lib — jsc-persona 的共用核心。
只用標準庫。負責:
* 人格倉庫路徑與 slug 規則
* 單一程序載入鎖(exclusive lock)與 guest lease
* session 綁定(host / guests / rooms / agent pins)
* 十二情緒模型(六正向 + 六負向)與衰減
* 短期記憶 / 長期記憶 / 心智圖 / 思維導圖 / 人際關係圖 的讀寫
* 跨人格隔離的判斷核心(guard)
"""
from __future__ import annotations
import hashlib
import json
import os
import re
import unicodedata
from datetime import datetime, timedelta, timezone
from pathlib import Path
# --------------------------------------------------------------------------- #
# 路徑
# --------------------------------------------------------------------------- #
def persona_home() -> Path:
raw = os.environ.get("PERSONA_HOME")
if raw:
return Path(os.path.expanduser(raw)).resolve()
return (Path.home() / ".claude" / "personas").resolve()
HOME = persona_home
RUNTIME_DIRNAME = ".runtime"
ROOMS_DIRNAME = ".rooms"
LEASE_SECONDS = 900 # 15 分鐘沒有 heartbeat 視為死鎖,可被接手
GUEST_LEASE_SECONDS = 1800 # guest(sub agent)租約
def runtime_dir() -> Path:
return persona_home() / RUNTIME_DIRNAME
def sessions_dir() -> Path:
return runtime_dir() / "sessions"
def rooms_dir() -> Path:
return persona_home() / ROOMS_DIRNAME
def persona_dir(slug: str) -> Path:
return persona_home() / slug
SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,47}$")
RESERVED_SLUGS = {RUNTIME_DIRNAME, ROOMS_DIRNAME, "", ".", ".."}
def valid_slug(slug: str) -> bool:
return bool(slug) and bool(SLUG_RE.match(slug)) and slug not in RESERVED_SLUGS
def slugify(text: str) -> str:
"""檔名/節點 id 用。保留中日韓字(檔名可讀),其餘壓成連字號;全空則用雜湊。"""
norm = unicodedata.normalize("NFKC", text or "")
norm = re.sub(r"[^A-Za-z0-9-ヿ一-鿿]+", "-", norm).strip("-")
norm = re.sub(r"(?a)[A-Z]", lambda m: m.group(0).lower(), norm)
if not norm:
digest = hashlib.md5((text or "").encode("utf-8")).hexdigest()[:8]
return f"n-{digest}"
return norm[:48]
def mermaid_id(node_id: str) -> str:
"""Mermaid 節點別名:只能是英數與底線;非 ASCII 名稱改用穩定雜湊。"""
alias = re.sub(r"[^A-Za-z0-9_]", "_", node_id or "")
if not re.search(r"[A-Za-z0-9]", alias):
alias = "n_" + hashlib.md5((node_id or "").encode("utf-8")).hexdigest()[:8]
return alias
def list_personas() -> list[str]:
home = persona_home()
if not home.is_dir():
return []
out = []
for child in sorted(home.iterdir()):
if child.is_dir() and valid_slug(child.name) and (child / "IDENTITY.md").exists():
out.append(child.name)
return out
def persona_exists(slug: str) -> bool:
return valid_slug(slug) and (persona_dir(slug) / "IDENTITY.md").exists()
# --------------------------------------------------------------------------- #
# 時間與檔案 IO
# --------------------------------------------------------------------------- #
def utcnow() -> datetime:
return datetime.now(timezone.utc)
def iso(dt: datetime | None = None) -> str:
return (dt or utcnow()).replace(microsecond=0).isoformat().replace("+00:00", "Z")
def parse_iso(value: str | None) -> datetime | None:
if not value:
return None
try:
return datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError:
return None
def age_seconds(value: str | None) -> float:
dt = parse_iso(value)
if dt is None:
return float("inf")
return (utcnow() - dt).total_seconds()
def read_json(path: Path, default=None):
try:
with open(path, "r", encoding="utf-8") as fh:
return json.load(fh)
except (FileNotFoundError, json.JSONDecodeError, OSError):
return default
def write_json(path: Path, obj) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(path.suffix + f".tmp{os.getpid()}")
with open(tmp, "w", encoding="utf-8") as fh:
json.dump(obj, fh, ensure_ascii=False, indent=2)
fh.write("\n")
os.replace(tmp, path)
def write_text(path: Path, text: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(path.suffix + f".tmp{os.getpid()}")
with open(tmp, "w", encoding="utf-8") as fh:
fh.write(text)
os.replace(tmp, path)
def append_jsonl(path: Path, obj) -> None:
"""單行 append(O_APPEND 對單行寫入是原子的),guest 也能安全使用。"""
path.parent.mkdir(parents=True, exist_ok=True)
line = json.dumps(obj, ensure_ascii=False) + "\n"
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
try:
os.write(fd, line.encode("utf-8"))
finally:
os.close(fd)
def read_jsonl(path: Path, limit: int | None = None) -> list[dict]:
try:
with open(path, "r", encoding="utf-8") as fh:
lines = fh.readlines()
except (FileNotFoundError, OSError):
return []
if limit is not None:
lines = lines[-limit:]
out = []
for line in lines:
line = line.strip()
if not line:
continue
try:
out.append(json.loads(line))
except json.JSONDecodeError:
continue
return out
# --------------------------------------------------------------------------- #
# 十二情緒模型(六正向 + 六負向)
# --------------------------------------------------------------------------- #
# key -> (中文, 極性, arousal 權重, 預設半衰期分鐘)
EMOTIONS: dict[str, tuple[str, int, float, int]] = {
# 六正向
"joy": ("喜悅", +1, 0.6, 120),
"trust": ("信任", +1, 0.3, 720),
"anticipation": ("期待", +1, 0.6, 240),
"gratitude": ("感激", +1, 0.4, 480),
"serenity": ("平靜", +1, 0.1, 180),
"delight": ("驚喜", +1, 0.9, 60),
# 六負向
"anger": ("憤怒", -1, 0.9, 90),
"sadness": ("悲傷", -1, 0.3, 480),
"fear": ("恐懼", -1, 0.9, 120),
"disgust": ("厭惡", -1, 0.5, 360),
"shame": ("羞愧", -1, 0.5, 240),
"anxiety": ("焦慮", -1, 0.8, 150),
}
POSITIVE = [k for k, v in EMOTIONS.items() if v[1] > 0]
NEGATIVE = [k for k, v in EMOTIONS.items() if v[1] < 0]
DEFAULT_BASELINE = {
"joy": 25, "trust": 30, "anticipation": 20, "gratitude": 15, "serenity": 40, "delight": 5,
"anger": 3, "sadness": 5, "fear": 3, "disgust": 3, "shame": 3, "anxiety": 8,
}
def emotion_path(slug: str) -> Path:
return persona_dir(slug) / "state" / "emotion.json"
def default_emotion_state(baseline: dict | None = None) -> dict:
base = dict(DEFAULT_BASELINE)
for k, v in (baseline or {}).items():
if k in EMOTIONS:
base[k] = clamp(v)
return {
"updated_at": iso(),
"baseline": base,
"levels": dict(base),
"half_life_minutes": {k: v[3] for k, v in EMOTIONS.items()},
"history_len": 0,
"last_trigger": None,
}
def clamp(value, lo=0, hi=100) -> float:
try:
value = float(value)
except (TypeError, ValueError):
return lo
return max(lo, min(hi, value))
def load_emotion(slug: str) -> dict:
state = read_json(emotion_path(slug))
if not isinstance(state, dict) or "levels" not in state:
state = default_emotion_state()
for key in EMOTIONS:
state.setdefault("baseline", {}).setdefault(key, DEFAULT_BASELINE[key])
state.setdefault("levels", {}).setdefault(key, state["baseline"][key])
state.setdefault("half_life_minutes", {}).setdefault(key, EMOTIONS[key][3])
return state
def decay_emotion(state: dict, now: datetime | None = None) -> dict:
"""情緒朝 baseline 指數衰減;半衰期依情緒種類不同。"""
now = now or utcnow()
last = parse_iso(state.get("updated_at")) or now
minutes = max(0.0, (now - last).total_seconds() / 60.0)
if minutes <= 0:
return state
for key in EMOTIONS:
half = float(state["half_life_minutes"].get(key) or EMOTIONS[key][3])
base = float(state["baseline"].get(key, DEFAULT_BASELINE[key]))
level = float(state["levels"].get(key, base))
factor = 0.5 ** (minutes / half) if half > 0 else 0.0
state["levels"][key] = round(base + (level - base) * factor, 2)
state["updated_at"] = iso(now)
return state
def apply_emotion(state: dict, deltas: dict, trigger: str | None = None) -> dict:
state = decay_emotion(state)
applied = {}
for key, delta in (deltas or {}).items():
if key not in EMOTIONS:
continue
try:
delta = float(delta)
except (TypeError, ValueError):
continue
before = float(state["levels"].get(key, 0))
state["levels"][key] = round(clamp(before + delta), 2)
applied[key] = round(state["levels"][key] - before, 2)
state["updated_at"] = iso()
state["history_len"] = int(state.get("history_len") or 0) + 1
if applied:
state["last_trigger"] = {"at": iso(), "summary": trigger or "", "deltas": applied}
return state
def mood(state: dict) -> dict:
levels = state.get("levels", {})
valence = arousal = 0.0
for key, (_zh, polarity, arousal_w, _hl) in EMOTIONS.items():
level = float(levels.get(key, 0))
valence += polarity * level
arousal += arousal_w * level
valence = round(max(-100.0, min(100.0, valence / 3.0)), 1)
arousal = round(min(100.0, arousal / 3.0), 1)
if valence >= 30:
label = "正向"
elif valence <= -30:
label = "負向"
else:
label = "中性"
tempo = "高張" if arousal >= 55 else ("平穩" if arousal >= 25 else "低張")
return {"valence": valence, "arousal": arousal, "label": label, "tempo": tempo}
def dominant(state: dict, top: int = 3) -> list[tuple[str, float]]:
levels = state.get("levels", {})
base = state.get("baseline", DEFAULT_BASELINE)
# 以「超出 baseline 的幅度」排序,才看得出「此刻被觸動什麼」
ranked = sorted(
((k, float(levels.get(k, 0))) for k in EMOTIONS),
key=lambda kv: (kv[1] - float(base.get(kv[0], 0)), kv[1]),
reverse=True,
)
return [(k, round(v, 1)) for k, v in ranked[:top]]
def emotion_brief(slug: str, state: dict | None = None) -> str:
state = state or load_emotion(slug)
state = decay_emotion(dict(state))
m = mood(state)
top = ", ".join(f"{EMOTIONS[k][0]}({k}) {v:.0f}" for k, v in dominant(state))
pos = sum(float(state["levels"].get(k, 0)) for k in POSITIVE) / len(POSITIVE)
neg = sum(float(state["levels"].get(k, 0)) for k in NEGATIVE) / len(NEGATIVE)
return (
f"情緒:{top}|心情 {m['label']}/{m['tempo']}"
f"(valence {m['valence']:+.0f}, arousal {m['arousal']:.0f})"
f"|正向均值 {pos:.0f} / 負向均值 {neg:.0f}"
)
# --------------------------------------------------------------------------- #
# 人格目錄骨架
# --------------------------------------------------------------------------- #
PERSONA_SUBDIRS = [
"state",
"memory/long-term",
"memory/inbox",
"mindmap/threads",
"relations",
"journal",
]
def ensure_persona_dirs(slug: str) -> Path:
root = persona_dir(slug)
for sub in PERSONA_SUBDIRS:
(root / sub).mkdir(parents=True, exist_ok=True)
return root
def config_path(slug: str) -> Path:
return persona_dir(slug) / "state" / "config.json"
def load_config(slug: str) -> dict:
return read_json(config_path(slug), {}) or {}
# --------------------------------------------------------------------------- #
# 鎖:同一人格只能被一個程序載入(sub agent 共用同一 session 的鎖)
# --------------------------------------------------------------------------- #
def lock_path(slug: str) -> Path:
return persona_dir(slug) / "state" / "lock.json"
def guests_path(slug: str) -> Path:
return persona_dir(slug) / "state" / "guests.json"
class LockError(RuntimeError):
def __init__(self, message: str, owner: dict | None = None):
super().__init__(message)
self.owner = owner or {}
def lock_is_dead(lock: dict) -> bool:
"""只看心跳租約。
鎖的擁有者是「那個 AI 程序的 session」,不是短命的 CLI process,
所以不能用 pid 存活判斷(CLI 跑完就結束了)。session 還活著時,
每輪對話的 hook 會續租;程序異常結束就會在租約到期後被視為死鎖。
"""
if not lock:
return True
return age_seconds(lock.get("heartbeat_at")) > float(lock.get("lease_seconds") or LEASE_SECONDS)
def live_guests(slug: str, exclude_session: str | None = None) -> list[dict]:
data = read_json(guests_path(slug), {}) or {}
out = []
for entry in (data.get("guests") or []):
if age_seconds(entry.get("heartbeat_at")) > GUEST_LEASE_SECONDS:
continue
if exclude_session and entry.get("session_id") == exclude_session:
continue
out.append(entry)
return out
def acquire_lock(slug: str, session_id: str, *, tool: str = "claude-code",
cwd: str | None = None, takeover: bool = False) -> dict:
"""取得 exclusive 鎖。同 session 重入 = 續租;他 session 存活 = 失敗。"""
ensure_persona_dirs(slug)
path = lock_path(slug)
now = iso()
payload = {
"persona": slug,
"session_id": session_id,
"writer_pid": os.getpid(), # 只作為紀錄:CLI process 會馬上結束
"host": os.uname().nodename,
"tool": tool,
"cwd": cwd or os.getcwd(),
"acquired_at": now,
"heartbeat_at": now,
"lease_seconds": LEASE_SECONDS,
"mode": "exclusive",
}
existing = read_json(path)
if isinstance(existing, dict) and existing.get("session_id"):
if existing["session_id"] != session_id:
# 租約已過期(程序異常結束)→ 允許接手,但要留下痕跡讓使用者知道
payload["took_over_from"] = {
"session_id": existing.get("session_id"),
"cwd": existing.get("cwd"),
"heartbeat_at": existing.get("heartbeat_at"),
"stale_minutes": round(age_seconds(existing.get("heartbeat_at")) / 60, 1),
}
if existing["session_id"] == session_id:
existing["heartbeat_at"] = now
existing["writer_pid"] = os.getpid()
write_json(path, existing)
return existing
if not (lock_is_dead(existing) or takeover):
raise LockError(
f"人格 `{slug}` 已被另一個程序載入"
f"(session {existing['session_id'][:8]}…, cwd {existing.get('cwd')},"
f"最後心跳 {existing.get('heartbeat_at')},"
f"{age_seconds(existing.get('heartbeat_at')) / 60:.0f} 分鐘前)。",
existing,
)
# 有其他 session 的 guest 租約時,不得 exclusive 載入
others = live_guests(slug, exclude_session=session_id)
if others and not takeover:
who = others[0]
raise LockError(
f"人格 `{slug}` 正以 guest 身分參與另一個 session "
f"({who.get('session_id', '')[:8]}… / room {who.get('room')})的對話,"
"請先結束該對話再載入。",
who,
)
write_json(path, payload)
return payload
def heartbeat_lock(slug: str, session_id: str) -> bool:
path = lock_path(slug)
lock = read_json(path)
if not isinstance(lock, dict) or lock.get("session_id") != session_id:
return False
lock["heartbeat_at"] = iso()
write_json(path, lock)
return True
def release_lock(slug: str, session_id: str, *, force: bool = False) -> bool:
path = lock_path(slug)
lock = read_json(path)
if not isinstance(lock, dict):
return False
if lock.get("session_id") != session_id and not force:
return False
try:
path.unlink()
except OSError:
return False
return True
def lock_status(slug: str) -> dict:
lock = read_json(lock_path(slug)) or {}
return {
"persona": slug,
"locked": bool(lock) and not lock_is_dead(lock),
"stale": bool(lock) and lock_is_dead(lock),
"owner": lock,
"guests": live_guests(slug),
}
def add_guest_lease(slug: str, session_id: str, room: str, host_persona: str) -> None:
path = guests_path(slug)
data = read_json(path, {}) or {}
guests = [g for g in (data.get("guests") or [])
if not (g.get("session_id") == session_id and g.get("room") == room)
and age_seconds(g.get("heartbeat_at")) <= GUEST_LEASE_SECONDS]
guests.append({
"session_id": session_id,
"room": room,
"host_persona": host_persona,
"joined_at": iso(),
"heartbeat_at": iso(),
"mode": "guest-readonly",
})
data["guests"] = guests
write_json(path, data)
def drop_guest_lease(slug: str, session_id: str, room: str | None = None) -> None:
path = guests_path(slug)
data = read_json(path, {}) or {}
data["guests"] = [
g for g in (data.get("guests") or [])
if not (g.get("session_id") == session_id and (room is None or g.get("room") == room))
]
write_json(path, data)
# --------------------------------------------------------------------------- #
# session 綁定:誰是 host、邀了哪些 guest、sub agent pin 到哪個人格
# --------------------------------------------------------------------------- #
def session_path(session_id: str) -> Path:
safe = re.sub(r"[^A-Za-z0-9_.-]", "-", session_id or "unknown")[:120]
return sessions_dir() / f"{safe}.json"
def load_session(session_id: str) -> dict:
data = read_json(session_path(session_id), {}) or {}
data.setdefault("session_id", session_id)
data.setdefault("host", None)
data.setdefault("guests", {})
data.setdefault("rooms", [])
data.setdefault("pins", {})
return data
def save_session(session_id: str, data: dict) -> None:
data["updated_at"] = iso()
write_json(session_path(session_id), data)
def bind_host(session_id: str, slug: str, *, cwd: str | None = None) -> dict:
data = load_session(session_id)
data["host"] = slug
data["host_bound_at"] = iso()
data["cwd"] = cwd or os.getcwd()
save_session(session_id, data)
return data
def unbind_session(session_id: str) -> dict:
"""釋放這個 session 的所有鎖與租約,回傳被釋放的內容。"""
data = load_session(session_id)
released = {"host": None, "guests": []}
host = data.get("host")
if host and persona_exists(host):
if release_lock(host, session_id):
released["host"] = host
for slug, info in (data.get("guests") or {}).items():
if persona_exists(slug):
drop_guest_lease(slug, session_id, info.get("room"))
released["guests"].append(slug)
try:
session_path(session_id).unlink()
except OSError:
pass
return released
def gc_runtime() -> dict:
"""清掉死掉的 session 綁定、過期 guest 租約與死鎖。"""
removed = {"sessions": [], "locks": [], "guests": []}
sdir = sessions_dir()
if sdir.is_dir():
for path in sdir.glob("*.json"):
data = read_json(path, {}) or {}
host = data.get("host")
alive = False
if host and persona_exists(host):
lock = read_json(lock_path(host)) or {}
alive = lock.get("session_id") == data.get("session_id") and not lock_is_dead(lock)
if not alive and age_seconds(data.get("updated_at")) > LEASE_SECONDS:
removed["sessions"].append(data.get("session_id"))
try:
path.unlink()
except OSError:
pass
for slug in list_personas():
lock = read_json(lock_path(slug))
if isinstance(lock, dict) and lock_is_dead(lock):
try:
lock_path(slug).unlink()
removed["locks"].append(slug)
except OSError:
pass
data = read_json(guests_path(slug), {}) or {}
guests = data.get("guests") or []
keep = [g for g in guests if age_seconds(g.get("heartbeat_at")) <= GUEST_LEASE_SECONDS]
if len(keep) != len(guests):
data["guests"] = keep
write_json(guests_path(slug), data)
removed["guests"].append(slug)
return removed
# --------------------------------------------------------------------------- #
# 記憶:短期(滾動)/ 長期(一則一檔)
# --------------------------------------------------------------------------- #
SHORT_TERM_KEEP = 240 # 短期記憶保留筆數
SHORT_TERM_DAYS = 14 # 短期記憶保留天數
CONSOLIDATE_THRESHOLD = 40 # 超過這個筆數就提示固化
def short_term_path(slug: str) -> Path:
return persona_dir(slug) / "memory" / "short-term.jsonl"
def inbox_path(slug: str, room: str) -> Path:
safe = re.sub(r"[^A-Za-z0-9_.-]", "-", room)[:64]
return persona_dir(slug) / "memory" / "inbox" / f"room-{safe}.jsonl"
def long_term_dir(slug: str) -> Path:
return persona_dir(slug) / "memory" / "long-term"
def index_path(slug: str) -> Path:
return persona_dir(slug) / "memory" / "INDEX.md"
def journal_path(slug: str) -> Path:
return persona_dir(slug) / "journal" / f"{utcnow():%Y-%m}.jsonl"
def remember_short(slug: str, entry: dict) -> dict:
entry.setdefault("ts", iso())
append_jsonl(short_term_path(slug), entry)
return entry
def prune_short_term(slug: str) -> int:
"""裁掉過舊/過多的短期記憶,回傳剩餘筆數。"""
path = short_term_path(slug)
rows = read_jsonl(path)
if not rows:
return 0
cutoff = utcnow() - timedelta(days=SHORT_TERM_DAYS)
kept = [r for r in rows if (parse_iso(r.get("ts")) or utcnow()) >= cutoff]
kept = kept[-SHORT_TERM_KEEP:]
if len(kept) != len(rows):
write_text(path, "".join(json.dumps(r, ensure_ascii=False) + "\n" for r in kept))
return len(kept)
def recent_short(slug: str, limit: int = 8) -> list[dict]:
return read_jsonl(short_term_path(slug), limit=limit)
def parse_front_matter(text: str) -> tuple[dict, str]:
if not text.startswith("---"):
return {}, text
parts = text.split("---", 2)
if len(parts) < 3:
return {}, text
meta: dict = {}
for line in parts[1].splitlines():
if not line.strip() or line.strip().startswith("#") or ":" not in line:
continue
key, _, value = line.partition(":")
value = value.strip()
if value.startswith("[") and value.endswith("]"):
meta[key.strip()] = [v.strip() for v in value[1:-1].split(",") if v.strip()]
else:
meta[key.strip()] = value
return meta, parts[2].lstrip("\n")
def long_term_entries(slug: str) -> list[dict]:
out = []
for path in sorted(long_term_dir(slug).glob("*.md")):
try:
meta, body = parse_front_matter(path.read_text(encoding="utf-8"))
except OSError:
continue
meta["_path"] = str(path)
meta["_name"] = meta.get("name") or path.stem
meta["_body"] = body.strip()
out.append(meta)
return out
def rebuild_index(slug: str) -> int:
entries = long_term_entries(slug)
lines = [
"# 長期記憶索引",
"",
f"",
"",
]
for meta in sorted(entries, key=lambda m: -float(m.get("salience") or 0)):
topics = meta.get("topics") or []
topics = topics if isinstance(topics, list) else [str(topics)]
summary = (meta["_body"].splitlines() or [""])[0][:110]
lines.append(
f"- [{meta['_name']}](long-term/{Path(meta['_path']).name}) "
f"|{meta.get('type', 'fact')}|顯著度 {meta.get('salience', '?')}"
f"|主題 {'/'.join(topics) if topics else '-'}|{summary}"
)
if len(lines) == 4:
lines.append("- (尚無長期記憶)")
write_text(index_path(slug), "\n".join(lines) + "\n")
return len(entries)
STOPWORDS = {"的", "了", "是", "我", "你", "他", "她", "們", "在", "和", "與", "也", "就",
"都", "很", "有", "沒", "不", "要", "會", "把", "被", "而", "但", "嗎", "呢",
"the", "a", "an", "and", "or", "to", "of", "is", "it", "for", "on", "in"}
CJK_RUN = re.compile(r"[-ヿ一-鿿]{2,}")
def keywords(text: str, limit: int = 12) -> list[str]:
"""抽關鍵詞。中文沒有空白可切,所以用 3-gram + 2-gram 滑窗(長的優先)。"""
text = text or ""
tokens = re.findall(r"[A-Za-z][A-Za-z0-9_+-]{1,}", text)
trigrams, bigrams = [], []
for run in CJK_RUN.findall(text):
for size, bucket in ((3, trigrams), (2, bigrams)):
for i in range(len(run) - size + 1):
bucket.append(run[i:i + size])
tokens += trigrams + bigrams
out, seen = [], set()
for tok in tokens:
low = tok.lower()
if low in STOPWORDS or len(low) < 2 or low in seen:
continue
seen.add(low)
out.append(tok)
if len(out) >= limit:
break
return out
def recall(slug: str, query: str, limit: int = 5) -> list[dict]:
"""以關鍵詞比對長期記憶(name/topics/body),回傳最相關的幾則。"""
keys = [k.lower() for k in keywords(query, 16)]
scored = []
for meta in long_term_entries(slug):
haystack = " ".join([
str(meta.get("_name", "")),
" ".join(meta.get("topics", []) if isinstance(meta.get("topics"), list) else []),
" ".join(meta.get("about", []) if isinstance(meta.get("about"), list) else []),
meta.get("_body", ""),
]).lower()
hits = sum(1 for k in keys if k in haystack)
if hits:
score = hits * 10 + float(meta.get("salience") or 0) / 10
scored.append((score, meta))
scored.sort(key=lambda pair: -pair[0])
return [meta for _score, meta in scored[:limit]]
def touch_recall(slug: str, names: list[str]) -> None:
"""被回想到就更新 last_seen / recall_count(記憶越常用越不易被淘汰)。"""
for meta in long_term_entries(slug):
if meta["_name"] not in names:
continue
path = Path(meta["_path"])
try:
text = path.read_text(encoding="utf-8")
except OSError:
continue
count = int(float(meta.get("recall_count") or 0)) + 1
text = re.sub(r"(?m)^recall_count:.*$", f"recall_count: {count}", text)
text = re.sub(r"(?m)^last_seen:.*$", f"last_seen: {utcnow():%Y-%m-%d}", text)
try:
path.write_text(text, encoding="utf-8")
except OSError:
pass
# --------------------------------------------------------------------------- #
# 心智圖 / 思維導圖 / 人際關係圖
# --------------------------------------------------------------------------- #
def mindmap_path(slug: str) -> Path:
return persona_dir(slug) / "mindmap" / "semantic.mmd"
def thread_path(slug: str, topic: str) -> Path:
return persona_dir(slug) / "mindmap" / "threads" / f"{slugify(topic)}.mmd"
def relations_json(slug: str) -> Path:
return persona_dir(slug) / "relations" / "graph.json"
def relations_mmd(slug: str) -> Path:
return persona_dir(slug) / "relations" / "graph.mmd"
def load_relations(slug: str) -> dict:
data = read_json(relations_json(slug), {}) or {}
data.setdefault("nodes", [])
data.setdefault("edges", [])
return data
def upsert_relation_node(slug: str, node: dict) -> dict:
data = load_relations(slug)
node_id = node.get("id") or slugify(node.get("name", ""))
node["id"] = node_id
for idx, existing in enumerate(data["nodes"]):
if existing.get("id") == node_id:
existing.update({k: v for k, v in node.items() if v is not None})
existing["updated_at"] = iso()
data["nodes"][idx] = existing
break
else:
node.setdefault("kind", "human")
node.setdefault("closeness", 30)
node.setdefault("trust", 30)
node["created_at"] = iso()
node["updated_at"] = iso()
data["nodes"].append(node)
write_json(relations_json(slug), data)
return data
def upsert_relation_edge(slug: str, edge: dict) -> dict:
data = load_relations(slug)
key = (edge.get("from"), edge.get("to"))
for idx, existing in enumerate(data["edges"]):
if (existing.get("from"), existing.get("to")) == key:
existing.update({k: v for k, v in edge.items() if v is not None})
existing["updated_at"] = iso()
data["edges"][idx] = existing
break
else:
edge.setdefault("affinity", 50)
edge["created_at"] = iso()
edge["updated_at"] = iso()
data["edges"].append(edge)
write_json(relations_json(slug), data)
return data
def render_relations(slug: str) -> str:
data = load_relations(slug)
lines = ["%% 由 persona.py 產生:人際關係圖", "flowchart LR"]
lines.append(' self(("我"))')
for node in data["nodes"]:
nid = mermaid_id(node["id"])
label = f"{node.get('name', node['id'])}
親近 {node.get('closeness', '?')}/信任 {node.get('trust', '?')}"
shape = f'{nid}["{label}"]' if node.get("kind") != "persona" else f'{nid}(["{label}"])'
lines.append(f" {shape}")
for edge in data["edges"]:
src = "self" if edge.get("from") in (None, "self") else mermaid_id(edge["from"])
dst = mermaid_id(edge.get("to", "unknown"))
affinity = float(edge.get("affinity") or 50)
arrow = "-->" if affinity >= 50 else "-.->"
label = edge.get("label") or ""
lines.append(f' {src} {arrow}|"{label} {affinity:.0f}"| {dst}')
text = "\n".join(lines) + "\n"
write_text(relations_mmd(slug), text)
return text
def relations_brief(slug: str, names: list[str] | None = None, limit: int = 5) -> str:
data = load_relations(slug)
nodes = data["nodes"]
if names:
low = [n.lower() for n in names]
nodes = [n for n in nodes
if any(k in (str(n.get("name", "")) + n.get("id", "")).lower() for k in low)] or data["nodes"]
nodes = sorted(nodes, key=lambda n: -float(n.get("closeness") or 0))[:limit]
if not nodes:
return ""
return ";".join(
f"{n.get('name', n['id'])}({n.get('kind', 'human')}/親近 {n.get('closeness', '?')}"
f"/信任 {n.get('trust', '?')}{'/' + n['note'] if n.get('note') else ''})"
for n in nodes
)
# --------------------------------------------------------------------------- #
# 聊天室(跨人格唯一合法的資料交換介面)
# --------------------------------------------------------------------------- #
def room_dir(room: str) -> Path:
safe = re.sub(r"[^A-Za-z0-9_.-]", "-", room)[:64]
return rooms_dir() / safe
def room_transcript(room: str) -> Path:
return room_dir(room) / "transcript.jsonl"
def room_members(room: str) -> Path:
return room_dir(room) / "members.json"
def create_room(room: str, host_persona: str, session_id: str, topic: str = "") -> dict:
rdir = room_dir(room)
rdir.mkdir(parents=True, exist_ok=True)
meta = read_json(room_members(room), {}) or {}
meta.update({
"room": room,
"host_persona": host_persona,
"session_id": session_id,
"topic": topic or meta.get("topic", ""),
"created_at": meta.get("created_at") or iso(),
"updated_at": iso(),
})
meta.setdefault("members", [host_persona])
write_json(room_members(room), meta)
return meta
def join_room(room: str, persona: str) -> dict:
meta = read_json(room_members(room), {}) or {"room": room, "members": []}
members = meta.setdefault("members", [])
if persona not in members:
members.append(persona)
meta["updated_at"] = iso()
write_json(room_members(room), meta)
return meta
def room_post(room: str, speaker: str, text: str, *, emotion: str = "", kind: str = "say") -> dict:
entry = {"ts": iso(), "speaker": speaker, "kind": kind, "text": text, "emotion": emotion}
append_jsonl(room_transcript(room), entry)
return entry
def room_read(room: str, limit: int = 30) -> list[dict]:
return read_jsonl(room_transcript(room), limit=limit)
# --------------------------------------------------------------------------- #
# guard:跨人格隔離 + 鎖驗證的判斷核心
# --------------------------------------------------------------------------- #
MUTATING_TOOLS = {"Write", "Edit", "NotebookEdit", "MultiEdit"}
PATH_TOOL_FIELDS = {
"Read": ("file_path",),
"Write": ("file_path",),
"Edit": ("file_path",),
"MultiEdit": ("file_path",),
"NotebookEdit": ("notebook_path", "file_path"),
"Glob": ("path",),
"Grep": ("path",),
"LS": ("path",),
}
GUEST_SAFE_SUBCOMMANDS = {"show", "status", "list", "recall", "room", "remember", "leave", "brief"}
# owner 這些子指令本來就要提到別的人格名字(邀請/離場/查詢),不算跨人格讀取
OWNER_EXEMPT_SUBCOMMANDS = {"create", "list", "status", "gc", "invite", "load", "leave"}
MUTATING_SHELL = re.compile(
r"(>>?|\|\s*tee\b|\brm\b|\bmv\b|\bcp\b|\btruncate\b|\bdd\b|\bchmod\b|\bchown\b|"
r"\bsed\b[^|;]*-i|\btouch\b|\bmkdir\b|\bln\b)"
)
def _expand(token: str) -> str:
token = token.strip().strip("'\"")
token = token.replace("${PERSONA_HOME}", str(persona_home()))
token = token.replace("$PERSONA_HOME", str(persona_home()))
return os.path.expanduser(os.path.expandvars(token))
def _resolve(token: str, cwd: str | None) -> Path | None:
try:
raw = _expand(token)
if not raw:
return None
path = Path(raw)
if not path.is_absolute():
path = Path(cwd or os.getcwd()) / path
# 不用 strict=True:目標可能還不存在(Write);但要吃掉 symlink 與 ..
return Path(os.path.normpath(str(path.resolve(strict=False))))
except (OSError, ValueError, RuntimeError):
return None
def _under(path: Path, base: Path) -> bool:
try:
path.relative_to(base)
return True
except ValueError:
return False
def persona_slug_of(path: Path) -> str | None:
home = persona_home()
if not _under(path, home) or path == home:
return None
rel = path.relative_to(home).parts
return rel[0] if rel else None
def extract_paths(tool_name: str, tool_input: dict, cwd: str | None) -> list[Path]:
out: list[Path] = []
for field in PATH_TOOL_FIELDS.get(tool_name, ()):
value = tool_input.get(field)
if isinstance(value, str) and value:
resolved = _resolve(value, cwd)
if resolved:
out.append(resolved)
if tool_name == "Bash":
command = tool_input.get("command") or ""
home_str = str(persona_home())
candidates = re.findall(r"[^\s'\";|&<>()]+", command)
for token in candidates:
if ("/" not in token) and ("PERSONA_HOME" not in token):
continue
expanded = _expand(token)
if home_str in expanded or "personas" in expanded or expanded.startswith(home_str):
resolved = _resolve(token, cwd)
if resolved and _under(resolved, persona_home()):
out.append(resolved)
return out
def cli_invocation(command: str) -> dict | None:
"""辨識 Bash 是否在呼叫 persona.py,並取出 subcommand / --persona / --session。"""
if "persona.py" not in command:
return None
info: dict = {"subcommand": None, "personas": [], "session": None,
"as_guest": bool(re.search(r"--as-guest\b", command))}
match = re.search(r"persona\.py['\"]?\s+([a-z][a-z0-9-]*)", command)
if match:
info["subcommand"] = match.group(1)
info["personas"] = [m for m in re.findall(r"--(?:persona|guest|host|as)[= ]+['\"]?([a-z0-9-]+)", command)]
sess = re.search(r"--session[= ]+['\"]?([^\s'\"]+)", command)
if sess:
info["session"] = sess.group(1)
return info
def resolve_scope(session_id: str, agent_id: str | None, agent_type: str | None) -> dict:
"""算出這個呼叫者能碰哪個人格。
* 主程序(無 agent_id)與一般 sub agent → host 人格,可讀寫。
* persona-guest 型 sub agent → 只能碰被邀請的 guest 人格,且唯讀;
第一次觸碰哪個 guest 就 pin 住(first-touch pinning),之後不得換人。
"""
data = load_session(session_id)
host = data.get("host")
guests = list((data.get("guests") or {}).keys())
is_guest_agent = bool(agent_type) and "persona-guest" in str(agent_type)
if not is_guest_agent:
return {
"role": "owner",
"allowed": [host] if host else [],
"readonly": False,
"host": host,
"guests": guests,
"rooms": data.get("rooms") or [],
"session": data,
}
pinned = (data.get("pins") or {}).get(agent_id or "")
allowed = [pinned] if pinned else guests
return {
"role": "guest",
"allowed": allowed,
"readonly": True,
"host": host,
"guests": guests,
"pinned": pinned,
"rooms": data.get("rooms") or [],
"session": data,
}
def pin_agent(session_id: str, agent_id: str, slug: str) -> None:
data = load_session(session_id)
pins = data.setdefault("pins", {})
if pins.get(agent_id) != slug:
pins[agent_id] = slug
save_session(session_id, data)
def guard_decide(event: dict) -> tuple[str, str]:
"""回傳 ("allow"|"deny"|"pass", reason)。"pass" = 不表態,交回原本流程。"""
tool = event.get("tool_name") or ""
tool_input = event.get("tool_input") or {}
session_id = event.get("session_id") or "unknown"
agent_id = event.get("agent_id")
agent_type = event.get("agent_type")
cwd = event.get("cwd")
scope = resolve_scope(session_id, agent_id, agent_type)
# 1) persona.py 呼叫:先驗 session 身分,再驗人格範圍
if tool == "Bash":
info = cli_invocation(tool_input.get("command") or "")
if info:
if info["session"] and info["session"] != session_id:
return ("deny", (
f"CLI 的 --session `{info['session'][:12]}…` 與本 session 不符,"
"不得冒用其他程序的身分(人格鎖與隔離都靠 session 判定)。"
))
sub = info["subcommand"] or ""
if scope["role"] == "guest":
if sub not in GUEST_SAFE_SUBCOMMANDS:
return ("deny", (
f"guest 人格(sub agent)僅能執行 {sorted(GUEST_SAFE_SUBCOMMANDS)},"
f"不得執行 `{sub}`。"
))
for slug in info["personas"]:
if scope["allowed"] and slug not in scope["allowed"]:
return ("deny", f"guest 只能操作被邀請的人格 {scope['allowed']},不得碰 `{slug}`。")
else:
if info["as_guest"]:
return ("deny", (
"`--as-guest` 只有 persona-guest 型的 sub agent 能用;"
"主程序不得以受邀人格的身分存取它的資料。"
))
for slug in info["personas"]:
if sub in OWNER_EXEMPT_SUBCOMMANDS:
continue
if scope["host"] and slug != scope["host"]:
extra = (
"(它是本 session 邀請的 guest:你只能讀它在聊天室說出口的話,"
"不能碰它的記憶或情緒。)" if slug in scope["guests"] else
"請先 release 再 load,或改用 invite + 聊天室。"
)
return ("deny", f"本 session 已載入人格 `{scope['host']}`,禁止跨人格操作 `{slug}`。{extra}")
if MUTATING_SHELL.search(tool_input.get("command") or "") and scope["role"] == "guest":
for path in extract_paths(tool, tool_input, cwd):
if persona_slug_of(path):
return ("deny", "guest 人格對人格倉庫唯讀,寫入請透過 `persona.py room post` 或 `remember --scope inbox`。")
# 2) 路徑隔離
for path in extract_paths(tool, tool_input, cwd):
home = persona_home()
if not _under(path, home):
continue
if path == home:
return ("deny", "禁止直接遍歷人格倉庫根目錄(會看到其他人格)。請用 `persona.py list`。")
slug = persona_slug_of(path)
if slug == ROOMS_DIRNAME:
parts = path.relative_to(home).parts
room = parts[1] if len(parts) > 1 else None
if room and scope["rooms"] and room not in scope["rooms"]:
return ("deny", f"聊天室 `{room}` 不屬於本 session(可用的:{scope['rooms']})。")
continue
if slug == RUNTIME_DIRNAME:
return ("deny", "`.runtime/` 是鎖與綁定的內部狀態,只能由 persona.py 維護。")
if slug is None:
continue
if not scope["allowed"]:
return ("deny", (
"尚未載入任何人格。請先執行 "
"`persona.py load --session `(或 /jsc-persona:persona-chat)。"
))
if slug not in scope["allowed"]:
if scope["role"] == "guest":
return ("deny", (
f"guest 人格被 pin 在 {scope['allowed']},禁止讀取 `{slug}` 的任何資料"
"(跨人格資料隔離)。"
))
return ("deny", (
f"本 session 的人格是 `{scope['allowed'][0]}`,禁止讀寫 `{slug}` 的資料"
"(跨人格資料隔離)。要與它對話請用 /jsc-persona:persona-invite。"
))
# 3) guest 唯讀 + first-touch pinning
if scope["role"] == "guest":
if not scope.get("pinned") and agent_id:
pin_agent(session_id, agent_id, slug)
if tool in MUTATING_TOOLS:
return ("deny", (
f"guest 人格 `{slug}` 在 sub agent 中為唯讀;"
"要留下記憶請 `persona.py remember --scope inbox`(下次它自己載入時再固化)。"
))
# 4) 鎖驗證:owner 必須真的持有鎖
if scope["role"] == "owner":
lock = read_json(lock_path(slug)) or {}
if lock and lock.get("session_id") != session_id and not lock_is_dead(lock):
return ("deny", (
f"人格 `{slug}` 的鎖屬於另一個程序(session {lock.get('session_id', '')[:8]}…,"
f"cwd {lock.get('cwd')})。同一人格同時只能被一個程序載入。"
))
if not lock and tool in MUTATING_TOOLS:
return ("deny", (
f"人格 `{slug}` 目前沒有有效的載入鎖,禁止寫入。"
"請先 `persona.py load` 取得鎖。"
))
return ("pass", "")
# --------------------------------------------------------------------------- #
# 給 hook 用的上下文組裝
# --------------------------------------------------------------------------- #
def identity_brief(slug: str) -> str:
path = persona_dir(slug) / "IDENTITY.md"
fields = {}
try:
for line in path.read_text(encoding="utf-8").splitlines():
m = re.match(r"\s*[-*]?\s*(Name|Creature|Vibe|Emoji|Avatar)\s*:\s*(.+)$", line, re.I)
if m:
value = m.group(2).strip()
if value.startswith("(") or value.startswith("_("):
continue
fields[m.group(1).capitalize()] = value
except OSError:
return ""
if not fields:
return ""
order = ["Emoji", "Name", "Creature", "Vibe"]
return "|".join(f"{k}: {fields[k]}" for k in order if k in fields)
def turn_context(slug: str, session_id: str, prompt: str = "") -> str:
"""UserPromptSubmit 注入的人格上下文:身分 + 情緒 + 短期記憶 + 相關長期記憶 + 關係。"""
state = decay_emotion(load_emotion(slug))
write_json(emotion_path(slug), state)
lines = [
"",
f"PERSONA_SESSION={session_id}",
f"人格:`{slug}` {identity_brief(slug)}",
f"人格倉庫:{persona_dir(slug)}(唯一可讀寫的人格資料範圍)",
emotion_brief(slug, state),
]
recents = recent_short(slug, 6)
if recents:
lines.append("短期記憶(最近):")
for row in recents:
who = row.get("role") or row.get("speaker") or "?"
text = (row.get("text") or "").replace("\n", " ")[:90]
sal = row.get("salience")
lines.append(f" - [{who}] {text}" + (f"(顯著度 {sal})" if sal else ""))
hits = recall(slug, prompt, 4) if prompt else []
if hits:
lines.append("相關長期記憶:")
for meta in hits:
body = (meta.get("_body") or "").splitlines()
lines.append(f" - {meta['_name']}|{meta.get('type', 'fact')}|{(body[0] if body else '')[:100]}")
touch_recall(slug, [m["_name"] for m in hits])
rel = relations_brief(slug, keywords(prompt, 6) if prompt else None)
if rel:
lines.append(f"人際關係:{rel}")
pending = len(read_jsonl(short_term_path(slug)))
if pending >= CONSOLIDATE_THRESHOLD:
lines.append(f"⚠ 短期記憶已累積 {pending} 筆,建議執行 /jsc-persona:persona-memory 固化為長期記憶。")
inbox = list((persona_dir(slug) / "memory" / "inbox").glob("room-*.jsonl"))
if inbox:
lines.append(f"⚠ 有 {len(inbox)} 個聊天室 inbox 待消化(guest 期間留下的見聞)。")
lines.append("")
return "\n".join(lines)