1260 lines
46 KiB
Python
1260 lines
46 KiB
Python
#!/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"<!-- 由 persona.py 自動產生,最後更新 {iso()};一則記憶一行 -->",
|
||
"",
|
||
]
|
||
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'])}<br/>親近 {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 <slug> --session <session_id>`(或 /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 = [
|
||
"<persona-context>",
|
||
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("</persona-context>")
|
||
return "\n".join(lines)
|