#!/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)