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

1260 lines
46 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_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 # guestsub 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:
"""單行 appendO_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)