Files
persona/scripts/persona-lib.mjs
T
jiantw83andClaude Opus 5 2005f26c94 feat: 改寫為 Node.js,新增動漫角色建人格、劇場模式與記憶固化條件
腳本全面從 Python 改寫為 Node.js(ESM,只用內建模組,無 npm 依賴):
scripts/persona-lib.mjs(核心)、scripts/persona.mjs(CLI)、hooks/*.mjs(六個
hook)、scripts/selftest.mjs(68 項自我測試,全綠)。

新增:
- persona-anime skill:用「動漫作品+角色名」建立人格,先上網蒐集至少三個獨立
  來源的公開設定,映射成 OpenClaw 的 IDENTITY 五欄位與 SOUL 四段落,再固化成
  canon 基礎記憶(每則帶來源 URL)+原作人際關係圖+依角色型別的情緒基線;
  必寫 roleplay-frame 界線記憶(非官方、非本人)。
- 劇場模式:invite 後只顯示人格對話(`名字:內容`)。UserPromptSubmit hook 每輪
  注入強制規則、Stop hook 完全靜音,CLI 新增 --quiet 與 room script(乾淨對話稿)。
  leave 後沒客人自動關閉,也可用 room theater --on/--off 手動切換。
- 短期→長期記憶的成文轉入條件 R1–R6(promotionCandidates)與 candidates 子指令,
  hook 在達標時提醒固化;長期記憶新增 canon 型別與 rules 欄位。
- 人格改為「由使用者呼叫才載入」:SessionStart hook 只列出可用人格,不自動附身。

其他:版本號改回 0.0.1;README/AGENTS.md 同步更新。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 16:03:27 +00:00

1385 lines
51 KiB
JavaScript
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.
// persona-lib.mjs — jsc-persona 的共用核心(Node.js,只用內建模組)
//
// 負責:
// * 人格倉庫路徑與 slug 規則
// * 單一程序載入鎖(exclusive lock)與 guest lease
// * session 綁定(host / guests / rooms / agent pins / 劇場模式)
// * 十二情緒模型(六正向 + 六負向)與衰減
// * 短期記憶 / 長期記憶 / 心智圖 / 思維導圖 / 人際關係圖 的讀寫
// * 跨人格隔離的判斷核心(guard)
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import crypto from "node:crypto";
// --------------------------------------------------------------------------- //
// 路徑
// --------------------------------------------------------------------------- //
export const RUNTIME_DIRNAME = ".runtime";
export const ROOMS_DIRNAME = ".rooms";
export const LEASE_SECONDS = 900; // 15 分鐘沒有 heartbeat 視為死鎖,可被接手
export const GUEST_LEASE_SECONDS = 1800; // guestsub agent)租約
function expandUser(p) {
if (p === "~") return os.homedir();
if (p.startsWith("~/")) return path.join(os.homedir(), p.slice(2));
return p;
}
export function personaHome() {
const raw = process.env.PERSONA_HOME;
if (raw) return path.resolve(expandUser(raw));
return path.resolve(path.join(os.homedir(), ".claude", "personas"));
}
export const runtimeDir = () => path.join(personaHome(), RUNTIME_DIRNAME);
export const sessionsDir = () => path.join(runtimeDir(), "sessions");
export const roomsDir = () => path.join(personaHome(), ROOMS_DIRNAME);
export const personaDir = (slug) => path.join(personaHome(), slug);
const SLUG_RE = /^[a-z0-9][a-z0-9-]{0,47}$/;
const RESERVED_SLUGS = new Set([RUNTIME_DIRNAME, ROOMS_DIRNAME, "", ".", ".."]);
export function validSlug(slug) {
return typeof slug === "string" && SLUG_RE.test(slug) && !RESERVED_SLUGS.has(slug);
}
const CJK_CLASS = "\\u3040-\\u30ff\\u3400-\\u4dbf\\u4e00-\\u9fff\\uac00-\\ud7af";
/** 檔名/節點 id 用。保留中日韓字(檔名可讀),其餘壓成連字號;全空則用雜湊。 */
export function slugify(text) {
const src = (text ?? "").normalize("NFKC");
let norm = src.replace(new RegExp(`[^A-Za-z0-9${CJK_CLASS}]+`, "g"), "-").replace(/^-+|-+$/g, "");
norm = norm.replace(/[A-Z]/g, (c) => c.toLowerCase());
if (!norm) {
return "n-" + crypto.createHash("md5").update(String(text ?? "")).digest("hex").slice(0, 8);
}
return norm.slice(0, 48);
}
/** Mermaid 節點別名:只能是英數與底線;非 ASCII 名稱改用穩定雜湊。 */
export function mermaidId(nodeId) {
const alias = String(nodeId ?? "").replace(/[^A-Za-z0-9_]/g, "_");
if (!/[A-Za-z0-9]/.test(alias)) {
return "n_" + crypto.createHash("md5").update(String(nodeId ?? "")).digest("hex").slice(0, 8);
}
return alias;
}
export function listPersonas() {
const home = personaHome();
let entries;
try {
entries = fs.readdirSync(home, { withFileTypes: true });
} catch {
return [];
}
return entries
.filter((e) => e.isDirectory() && validSlug(e.name) && fs.existsSync(path.join(home, e.name, "IDENTITY.md")))
.map((e) => e.name)
.sort();
}
export function personaExists(slug) {
return validSlug(slug) && fs.existsSync(path.join(personaDir(slug), "IDENTITY.md"));
}
// --------------------------------------------------------------------------- //
// 時間與檔案 IO
// --------------------------------------------------------------------------- //
export const iso = (date = new Date()) => new Date(Math.floor(date.getTime() / 1000) * 1000).toISOString().replace(".000Z", "Z");
export const nowIso = () => iso(new Date());
export function parseIso(value) {
if (!value) return null;
const dt = new Date(value);
return Number.isNaN(dt.getTime()) ? null : dt;
}
export function ageSeconds(value) {
const dt = parseIso(value);
if (!dt) return Infinity;
return (Date.now() - dt.getTime()) / 1000;
}
export function minutesAgo(minutes) {
return new Date(Date.now() - minutes * 60_000);
}
export function readJson(file, fallback = null) {
try {
return JSON.parse(fs.readFileSync(file, "utf8"));
} catch {
return fallback;
}
}
export function writeJson(file, obj) {
fs.mkdirSync(path.dirname(file), { recursive: true });
const tmp = `${file}.tmp${process.pid}`;
fs.writeFileSync(tmp, JSON.stringify(obj, null, 2) + "\n", "utf8");
fs.renameSync(tmp, file);
}
export function writeText(file, text) {
fs.mkdirSync(path.dirname(file), { recursive: true });
const tmp = `${file}.tmp${process.pid}`;
fs.writeFileSync(tmp, text, "utf8");
fs.renameSync(tmp, file);
}
/** 單行 appendO_APPEND 對單行寫入是原子的),guest 也能安全使用。 */
export function appendJsonl(file, obj) {
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.appendFileSync(file, JSON.stringify(obj) + "\n", { encoding: "utf8", mode: 0o600 });
}
export function readJsonl(file, limit = null) {
let text;
try {
text = fs.readFileSync(file, "utf8");
} catch {
return [];
}
let lines = text.split("\n").filter((l) => l.trim());
if (limit !== null) lines = lines.slice(-limit);
const out = [];
for (const line of lines) {
try {
out.push(JSON.parse(line));
} catch {
/* 壞行跳過 */
}
}
return out;
}
// --------------------------------------------------------------------------- //
// 十二情緒模型(六正向 + 六負向)
// --------------------------------------------------------------------------- //
// key -> { zh, polarity, arousal 權重, 預設半衰期分鐘 }
export const EMOTIONS = {
// 六正向
joy: { zh: "喜悅", polarity: +1, arousal: 0.6, halfLife: 120 },
trust: { zh: "信任", polarity: +1, arousal: 0.3, halfLife: 720 },
anticipation: { zh: "期待", polarity: +1, arousal: 0.6, halfLife: 240 },
gratitude: { zh: "感激", polarity: +1, arousal: 0.4, halfLife: 480 },
serenity: { zh: "平靜", polarity: +1, arousal: 0.1, halfLife: 180 },
delight: { zh: "驚喜", polarity: +1, arousal: 0.9, halfLife: 60 },
// 六負向
anger: { zh: "憤怒", polarity: -1, arousal: 0.9, halfLife: 90 },
sadness: { zh: "悲傷", polarity: -1, arousal: 0.3, halfLife: 480 },
fear: { zh: "恐懼", polarity: -1, arousal: 0.9, halfLife: 120 },
disgust: { zh: "厭惡", polarity: -1, arousal: 0.5, halfLife: 360 },
shame: { zh: "羞愧", polarity: -1, arousal: 0.5, halfLife: 240 },
anxiety: { zh: "焦慮", polarity: -1, arousal: 0.8, halfLife: 150 },
};
export const EMOTION_KEYS = Object.keys(EMOTIONS);
export const POSITIVE = EMOTION_KEYS.filter((k) => EMOTIONS[k].polarity > 0);
export const NEGATIVE = EMOTION_KEYS.filter((k) => EMOTIONS[k].polarity < 0);
export const 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,
};
export const emotionPath = (slug) => path.join(personaDir(slug), "state", "emotion.json");
export function clamp(value, lo = 0, hi = 100) {
const num = Number(value);
if (!Number.isFinite(num)) return lo;
return Math.max(lo, Math.min(hi, num));
}
export function defaultEmotionState(baseline = {}) {
const base = { ...DEFAULT_BASELINE };
for (const [k, v] of Object.entries(baseline || {})) {
if (k in EMOTIONS) base[k] = clamp(v);
}
const halfLives = {};
for (const k of EMOTION_KEYS) halfLives[k] = EMOTIONS[k].halfLife;
return {
updated_at: nowIso(),
baseline: base,
levels: { ...base },
half_life_minutes: halfLives,
history_len: 0,
last_trigger: null,
};
}
export function loadEmotion(slug) {
let state = readJson(emotionPath(slug));
if (!state || typeof state !== "object" || !state.levels) state = defaultEmotionState();
state.baseline ??= {};
state.levels ??= {};
state.half_life_minutes ??= {};
for (const key of EMOTION_KEYS) {
state.baseline[key] ??= DEFAULT_BASELINE[key];
state.levels[key] ??= state.baseline[key];
state.half_life_minutes[key] ??= EMOTIONS[key].halfLife;
}
return state;
}
/** 情緒朝 baseline 指數衰減;半衰期依情緒種類不同。 */
export function decayEmotion(state, now = new Date()) {
const last = parseIso(state.updated_at) ?? now;
const minutes = Math.max(0, (now.getTime() - last.getTime()) / 60_000);
if (minutes <= 0) return state;
for (const key of EMOTION_KEYS) {
const half = Number(state.half_life_minutes[key] ?? EMOTIONS[key].halfLife);
const base = Number(state.baseline[key] ?? DEFAULT_BASELINE[key]);
const level = Number(state.levels[key] ?? base);
const factor = half > 0 ? Math.pow(0.5, minutes / half) : 0;
state.levels[key] = Math.round((base + (level - base) * factor) * 100) / 100;
}
state.updated_at = iso(now);
return state;
}
export function applyEmotion(state, deltas, trigger = "") {
state = decayEmotion(state);
const applied = {};
for (const [key, rawDelta] of Object.entries(deltas || {})) {
if (!(key in EMOTIONS)) continue;
const delta = Number(rawDelta);
if (!Number.isFinite(delta)) continue;
const before = Number(state.levels[key] ?? 0);
state.levels[key] = Math.round(clamp(before + delta) * 100) / 100;
applied[key] = Math.round((state.levels[key] - before) * 100) / 100;
}
state.updated_at = nowIso();
state.history_len = Number(state.history_len || 0) + 1;
if (Object.keys(applied).length) {
state.last_trigger = { at: nowIso(), summary: trigger || "", deltas: applied };
}
return state;
}
export function mood(state) {
const levels = state.levels || {};
let valence = 0;
let arousal = 0;
for (const key of EMOTION_KEYS) {
const level = Number(levels[key] ?? 0);
valence += EMOTIONS[key].polarity * level;
arousal += EMOTIONS[key].arousal * level;
}
valence = Math.round(Math.max(-100, Math.min(100, valence / 3)) * 10) / 10;
arousal = Math.round(Math.min(100, arousal / 3) * 10) / 10;
const label = valence >= 30 ? "正向" : valence <= -30 ? "負向" : "中性";
const tempo = arousal >= 55 ? "高張" : arousal >= 25 ? "平穩" : "低張";
return { valence, arousal, label, tempo };
}
/** 以「超出 baseline 的幅度」排序,才看得出「此刻被觸動什麼」。 */
export function dominant(state, top = 3) {
const levels = state.levels || {};
const base = state.baseline || DEFAULT_BASELINE;
return EMOTION_KEYS
.map((key) => ({ key, level: Number(levels[key] ?? 0), delta: Number(levels[key] ?? 0) - Number(base[key] ?? 0) }))
.sort((a, b) => b.delta - a.delta || b.level - a.level)
.slice(0, top)
.map(({ key, level }) => ({ key, level: Math.round(level * 10) / 10 }));
}
export function emotionBrief(slug, state = null) {
const st = decayEmotion(structuredClone(state ?? loadEmotion(slug)));
const m = mood(st);
const top = dominant(st).map(({ key, level }) => `${EMOTIONS[key].zh}(${key}) ${Math.round(level)}`).join(", ");
const avg = (keys) => keys.reduce((sum, k) => sum + Number(st.levels[k] ?? 0), 0) / keys.length;
return (
`情緒:${top}|心情 ${m.label}/${m.tempo}` +
`valence ${m.valence >= 0 ? "+" : ""}${Math.round(m.valence)}, arousal ${Math.round(m.arousal)}` +
`|正向均值 ${Math.round(avg(POSITIVE))} / 負向均值 ${Math.round(avg(NEGATIVE))}`
);
}
// --------------------------------------------------------------------------- //
// 人格目錄骨架
// --------------------------------------------------------------------------- //
export const PERSONA_SUBDIRS = [
"state",
"memory/long-term",
"memory/inbox",
"mindmap/threads",
"relations",
"journal",
];
export function ensurePersonaDirs(slug) {
const root = personaDir(slug);
for (const sub of PERSONA_SUBDIRS) fs.mkdirSync(path.join(root, sub), { recursive: true });
return root;
}
export const configPath = (slug) => path.join(personaDir(slug), "state", "config.json");
export const loadConfig = (slug) => readJson(configPath(slug), {}) ?? {};
// --------------------------------------------------------------------------- //
// 鎖:同一人格只能被一個程序載入(sub agent 共用同一 session 的鎖)
// --------------------------------------------------------------------------- //
export const lockPath = (slug) => path.join(personaDir(slug), "state", "lock.json");
export const guestsPath = (slug) => path.join(personaDir(slug), "state", "guests.json");
export class LockError extends Error {
constructor(message, owner = {}) {
super(message);
this.name = "LockError";
this.owner = owner;
}
}
/**
* 只看心跳租約。
*
* 鎖的擁有者是「那個 AI 程序的 session」,不是短命的 CLI process
* 所以不能用 pid 存活判斷(CLI 跑完就結束了)。session 還活著時,
* 每輪對話的 hook 會續租;程序異常結束就會在租約到期後被視為死鎖。
*/
export function lockIsDead(lock) {
if (!lock || !Object.keys(lock).length) return true;
return ageSeconds(lock.heartbeat_at) > Number(lock.lease_seconds || LEASE_SECONDS);
}
export function liveGuests(slug, excludeSession = null) {
const data = readJson(guestsPath(slug), {}) ?? {};
return (data.guests || []).filter(
(g) => ageSeconds(g.heartbeat_at) <= GUEST_LEASE_SECONDS && (!excludeSession || g.session_id !== excludeSession),
);
}
/** 取得 exclusive 鎖。同 session 重入 = 續租;他 session 存活 = 失敗。 */
export function acquireLock(slug, sessionId, { tool = "claude-code", cwd = null, takeover = false } = {}) {
ensurePersonaDirs(slug);
const file = lockPath(slug);
const now = nowIso();
const payload = {
persona: slug,
session_id: sessionId,
writer_pid: process.pid, // 只作為紀錄:CLI process 會馬上結束
host: os.hostname(),
tool,
cwd: cwd || process.cwd(),
acquired_at: now,
heartbeat_at: now,
lease_seconds: LEASE_SECONDS,
mode: "exclusive",
};
const existing = readJson(file);
if (existing && typeof existing === "object" && existing.session_id) {
if (existing.session_id === sessionId) {
existing.heartbeat_at = now;
existing.writer_pid = process.pid;
writeJson(file, existing);
return existing;
}
if (!(lockIsDead(existing) || takeover)) {
throw new LockError(
`人格 \`${slug}\` 已被另一個程序載入(session ${String(existing.session_id).slice(0, 8)}…, ` +
`cwd ${existing.cwd},最後心跳 ${existing.heartbeat_at}${Math.round(ageSeconds(existing.heartbeat_at) / 60)} 分鐘前)。`,
existing,
);
}
// 租約已過期(程序異常結束)→ 允許接手,但要留下痕跡讓使用者知道
payload.took_over_from = {
session_id: existing.session_id,
cwd: existing.cwd,
heartbeat_at: existing.heartbeat_at,
stale_minutes: Math.round((ageSeconds(existing.heartbeat_at) / 60) * 10) / 10,
};
}
const others = liveGuests(slug, sessionId);
if (others.length && !takeover) {
const who = others[0];
throw new LockError(
`人格 \`${slug}\` 正以 guest 身分參與另一個 session${String(who.session_id).slice(0, 8)}… / room ${who.room})的對話,` +
"請先結束該對話再載入。",
who,
);
}
writeJson(file, payload);
return payload;
}
export function heartbeatLock(slug, sessionId) {
const file = lockPath(slug);
const lock = readJson(file);
if (!lock || lock.session_id !== sessionId) return false;
lock.heartbeat_at = nowIso();
writeJson(file, lock);
return true;
}
export function releaseLock(slug, sessionId, { force = false } = {}) {
const file = lockPath(slug);
const lock = readJson(file);
if (!lock) return false;
if (lock.session_id !== sessionId && !force) return false;
try {
fs.unlinkSync(file);
return true;
} catch {
return false;
}
}
export function lockStatus(slug) {
const lock = readJson(lockPath(slug)) ?? {};
const has = Boolean(Object.keys(lock).length);
return {
persona: slug,
locked: has && !lockIsDead(lock),
stale: has && lockIsDead(lock),
owner: lock,
guests: liveGuests(slug),
};
}
export function addGuestLease(slug, sessionId, room, hostPersona) {
const file = guestsPath(slug);
const data = readJson(file, {}) ?? {};
const guests = (data.guests || []).filter(
(g) => !(g.session_id === sessionId && g.room === room) && ageSeconds(g.heartbeat_at) <= GUEST_LEASE_SECONDS,
);
guests.push({
session_id: sessionId,
room,
host_persona: hostPersona,
joined_at: nowIso(),
heartbeat_at: nowIso(),
mode: "guest-readonly",
});
data.guests = guests;
writeJson(file, data);
}
export function dropGuestLease(slug, sessionId, room = null) {
const file = guestsPath(slug);
const data = readJson(file, {}) ?? {};
data.guests = (data.guests || []).filter(
(g) => !(g.session_id === sessionId && (room === null || g.room === room)),
);
writeJson(file, data);
}
// --------------------------------------------------------------------------- //
// session 綁定:誰是 host、邀了哪些 guest、sub agent pin、劇場模式
// --------------------------------------------------------------------------- //
export function sessionPath(sessionId) {
const safe = String(sessionId || "unknown").replace(/[^A-Za-z0-9_.-]/g, "-").slice(0, 120);
return path.join(sessionsDir(), `${safe}.json`);
}
export function loadSession(sessionId) {
const data = readJson(sessionPath(sessionId), {}) ?? {};
data.session_id ??= sessionId;
data.host ??= null;
data.guests ??= {};
data.rooms ??= [];
data.pins ??= {};
data.theater ??= false;
return data;
}
export function saveSession(sessionId, data) {
data.updated_at = nowIso();
writeJson(sessionPath(sessionId), data);
}
export function bindHost(sessionId, slug, { cwd = null } = {}) {
const data = loadSession(sessionId);
data.host = slug;
data.host_bound_at = nowIso();
data.cwd = cwd || process.cwd();
saveSession(sessionId, data);
return data;
}
/** 釋放這個 session 的所有鎖與租約,回傳被釋放的內容。 */
export function unbindSession(sessionId) {
const data = loadSession(sessionId);
const released = { host: null, guests: [] };
if (data.host && personaExists(data.host) && releaseLock(data.host, sessionId)) released.host = data.host;
for (const [slug, info] of Object.entries(data.guests || {})) {
if (personaExists(slug)) {
dropGuestLease(slug, sessionId, info.room);
released.guests.push(slug);
}
}
try {
fs.unlinkSync(sessionPath(sessionId));
} catch {
/* 沒有就算了 */
}
return released;
}
/** 清掉死掉的 session 綁定、過期 guest 租約與死鎖。 */
export function gcRuntime() {
const removed = { sessions: [], locks: [], guests: [] };
let files = [];
try {
files = fs.readdirSync(sessionsDir()).filter((f) => f.endsWith(".json"));
} catch {
files = [];
}
for (const name of files) {
const file = path.join(sessionsDir(), name);
const data = readJson(file, {}) ?? {};
let alive = false;
if (data.host && personaExists(data.host)) {
const lock = readJson(lockPath(data.host)) ?? {};
alive = lock.session_id === data.session_id && !lockIsDead(lock);
}
if (!alive && ageSeconds(data.updated_at) > LEASE_SECONDS) {
removed.sessions.push(data.session_id);
try {
fs.unlinkSync(file);
} catch {
/* ignore */
}
}
}
for (const slug of listPersonas()) {
const lock = readJson(lockPath(slug));
if (lock && lockIsDead(lock)) {
try {
fs.unlinkSync(lockPath(slug));
removed.locks.push(slug);
} catch {
/* ignore */
}
}
const data = readJson(guestsPath(slug), {}) ?? {};
const guests = data.guests || [];
const keep = guests.filter((g) => ageSeconds(g.heartbeat_at) <= GUEST_LEASE_SECONDS);
if (keep.length !== guests.length) {
data.guests = keep;
writeJson(guestsPath(slug), data);
removed.guests.push(slug);
}
}
return removed;
}
// --------------------------------------------------------------------------- //
// 記憶:短期(滾動)/ 長期(一則一檔)
// --------------------------------------------------------------------------- //
export const SHORT_TERM_KEEP = 240; // 短期記憶保留筆數
export const SHORT_TERM_DAYS = 14; // 短期記憶保留天數
export const CONSOLIDATE_THRESHOLD = 40; // 超過這個筆數就提示固化
export const shortTermPath = (slug) => path.join(personaDir(slug), "memory", "short-term.jsonl");
export const inboxPath = (slug, room) =>
path.join(personaDir(slug), "memory", "inbox", `room-${String(room).replace(/[^A-Za-z0-9_.-]/g, "-").slice(0, 64)}.jsonl`);
export const longTermDir = (slug) => path.join(personaDir(slug), "memory", "long-term");
export const indexPath = (slug) => path.join(personaDir(slug), "memory", "INDEX.md");
export const journalPath = (slug) => {
const d = new Date();
const month = `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}`;
return path.join(personaDir(slug), "journal", `${month}.jsonl`);
};
export function rememberShort(slug, entry) {
entry.ts ??= nowIso();
appendJsonl(shortTermPath(slug), entry);
return entry;
}
/** 裁掉過舊/過多的短期記憶,回傳剩餘筆數。 */
export function pruneShortTerm(slug) {
const file = shortTermPath(slug);
const rows = readJsonl(file);
if (!rows.length) return 0;
const cutoff = Date.now() - SHORT_TERM_DAYS * 86_400_000;
let kept = rows.filter((r) => (parseIso(r.ts)?.getTime() ?? Date.now()) >= cutoff);
kept = kept.slice(-SHORT_TERM_KEEP);
if (kept.length !== rows.length) {
writeText(file, kept.map((r) => JSON.stringify(r)).join("\n") + (kept.length ? "\n" : ""));
}
return kept.length;
}
export const recentShort = (slug, limit = 8) => readJsonl(shortTermPath(slug), limit);
export function parseFrontMatter(text) {
if (!text.startsWith("---")) return [{}, text];
const parts = text.split("---");
if (parts.length < 3) return [{}, text];
const meta = {};
for (const line of parts[1].split("\n")) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#") || !trimmed.includes(":")) continue;
const idx = trimmed.indexOf(":");
const key = trimmed.slice(0, idx).trim();
const value = trimmed.slice(idx + 1).trim();
if (value.startsWith("[") && value.endsWith("]")) {
meta[key] = value.slice(1, -1).split(",").map((v) => v.trim()).filter(Boolean);
} else {
meta[key] = value;
}
}
const body = parts.slice(2).join("---").replace(/^\n+/, "");
return [meta, body];
}
export function longTermEntries(slug) {
let files = [];
try {
files = fs.readdirSync(longTermDir(slug)).filter((f) => f.endsWith(".md")).sort();
} catch {
return [];
}
const out = [];
for (const name of files) {
const file = path.join(longTermDir(slug), name);
let text;
try {
text = fs.readFileSync(file, "utf8");
} catch {
continue;
}
const [meta, body] = parseFrontMatter(text);
meta._path = file;
meta._name = meta.name || path.basename(name, ".md");
meta._body = body.trim();
out.push(meta);
}
return out;
}
export function rebuildIndex(slug) {
const entries = longTermEntries(slug);
const lines = [
"# 長期記憶索引",
"",
`<!-- 由 persona.mjs 自動產生,最後更新 ${nowIso()};一則記憶一行 -->`,
"",
];
const sorted = [...entries].sort((a, b) => Number(b.salience || 0) - Number(a.salience || 0));
for (const meta of sorted) {
const topics = Array.isArray(meta.topics) ? meta.topics : meta.topics ? [String(meta.topics)] : [];
const summary = (meta._body.split("\n")[0] || "").slice(0, 110);
lines.push(
`- [${meta._name}](long-term/${path.basename(meta._path)})` +
`${meta.type || "fact"}|顯著度 ${meta.salience ?? "?"}` +
`|主題 ${topics.length ? topics.join("/") : "-"}${summary}`,
);
}
if (lines.length === 4) lines.push("- (尚無長期記憶)");
writeText(indexPath(slug), lines.join("\n") + "\n");
return entries.length;
}
const STOPWORDS = new Set([
"的", "了", "是", "我", "你", "他", "她", "們", "在", "和", "與", "也", "就", "都", "很", "有",
"沒", "不", "要", "會", "把", "被", "而", "但", "嗎", "呢",
"the", "a", "an", "and", "or", "to", "of", "is", "it", "for", "on", "in",
]);
const CJK_RUN = new RegExp(`[${CJK_CLASS}]{2,}`, "g");
/** 抽關鍵詞。中文沒有空白可切,所以用 3-gram + 2-gram 滑窗(長的優先)。 */
export function keywords(text, limit = 12) {
const src = text || "";
const tokens = [...(src.match(/[A-Za-z][A-Za-z0-9_+-]+/g) || [])];
const trigrams = [];
const bigrams = [];
for (const run of src.match(CJK_RUN) || []) {
for (const [size, bucket] of [[3, trigrams], [2, bigrams]]) {
for (let i = 0; i + size <= run.length; i += 1) bucket.push(run.slice(i, i + size));
}
}
tokens.push(...trigrams, ...bigrams);
const out = [];
const seen = new Set();
for (const tok of tokens) {
const low = tok.toLowerCase();
if (STOPWORDS.has(low) || low.length < 2 || seen.has(low)) continue;
seen.add(low);
out.push(tok);
if (out.length >= limit) break;
}
return out;
}
/** 以關鍵詞比對長期記憶(name/topics/body),回傳最相關的幾則。 */
export function recall(slug, query, limit = 5) {
const keys = keywords(query, 16).map((k) => k.toLowerCase());
const scored = [];
for (const meta of longTermEntries(slug)) {
const haystack = [
meta._name || "",
Array.isArray(meta.topics) ? meta.topics.join(" ") : "",
Array.isArray(meta.about) ? meta.about.join(" ") : "",
meta._body || "",
].join(" ").toLowerCase();
const hits = keys.filter((k) => haystack.includes(k)).length;
if (hits) scored.push({ score: hits * 10 + Number(meta.salience || 0) / 10, meta });
}
scored.sort((a, b) => b.score - a.score);
return scored.slice(0, limit).map((s) => s.meta);
}
// --------------------------------------------------------------------------- //
// 短期 → 長期的轉入條件(固化門檻)
// --------------------------------------------------------------------------- //
/** 承諾/界線類的關鍵詞:命中就一定要固化。 */
const COMMITMENT_RE =
/(答應|承諾|保證|說好|約定|一定會|絕對不|不要再|以後都|從今天起|拜託你記住|記住這件事|下次記得|deadline|due)/i;
const BOUNDARY_RE = /(不准|不許|禁止|別再|我討厭|我最恨|底線|界線|不能接受|不想聽)/;
export const PROMOTION_RULES = [
{ id: "R1", label: "高顯著度單筆(salience ≥ 60" },
{ id: "R2", label: "主題反覆出現(同 topic ≥ 3 筆,或 ≥ 2 筆且平均 salience ≥ 45" },
{ id: "R3", label: "情緒衝擊大(單筆情緒變動總量 ≥ 25)" },
{ id: "R4", label: "承諾/界線(intent=commit 或命中承諾/界線關鍵詞)" },
{ id: "R5", label: "人物反覆出現(同一 entity ≥ 2 筆)" },
{ id: "R6", label: "容量壓力(短期記憶 ≥ 40 筆,依顯著度排序清出空間)" },
];
function emotionImpact(entry) {
return Object.values(entry.emotion_deltas || {}).reduce((sum, v) => sum + Math.abs(Number(v) || 0), 0);
}
/**
* 掃短期記憶,依 PROMOTION_RULES 算出「該轉入長期記憶」的候選。
* 回傳 { total, pressure, candidates: [{ rules, key, kind, entries, suggested_type, suggested_salience }] }
*/
export function promotionCandidates(slug) {
const rows = readJsonl(shortTermPath(slug));
const total = rows.length;
const byTopic = new Map();
const byEntity = new Map();
const singles = [];
rows.forEach((row, index) => {
const entry = { ...row, _index: index };
const salience = Number(row.salience || 0);
const impact = emotionImpact(row);
const text = String(row.text || "");
const rules = [];
if (salience >= 60) rules.push("R1");
if (impact >= 25) rules.push("R3");
if (row.intent === "commit" || COMMITMENT_RE.test(text)) rules.push("R4");
if (BOUNDARY_RE.test(text)) rules.push("R4");
if (rules.length) {
singles.push({
rules: [...new Set(rules)],
key: text.slice(0, 40),
kind: "entry",
entries: [entry],
suggested_type: rules.includes("R4") ? (BOUNDARY_RE.test(text) ? "boundary" : "promise") : "event",
suggested_salience: Math.max(salience, rules.includes("R4") ? 80 : 60),
});
}
for (const topic of row.topics || []) {
if (!byTopic.has(topic)) byTopic.set(topic, []);
byTopic.get(topic).push(entry);
}
for (const entity of row.entities || []) {
if (!byEntity.has(entity)) byEntity.set(entity, []);
byEntity.get(entity).push(entry);
}
});
const candidates = [...singles];
for (const [topic, entries] of byTopic) {
const avg = entries.reduce((s, e) => s + Number(e.salience || 0), 0) / entries.length;
if (entries.length >= 3 || (entries.length >= 2 && avg >= 45)) {
candidates.push({
rules: ["R2"],
key: topic,
kind: "topic",
entries,
suggested_type: "preference",
suggested_salience: Math.min(95, Math.round(avg + 10)),
});
}
}
for (const [entity, entries] of byEntity) {
if (entries.length >= 2) {
candidates.push({
rules: ["R5"],
key: entity,
kind: "entity",
entries,
suggested_type: "relationship",
suggested_salience: Math.min(90, Math.round(entries.reduce((s, e) => s + Number(e.salience || 0), 0) / entries.length + 5)),
});
}
}
const pressure = total >= CONSOLIDATE_THRESHOLD;
if (pressure) {
const top = [...rows]
.map((r, i) => ({ ...r, _index: i }))
.sort((a, b) => Number(b.salience || 0) - Number(a.salience || 0))
.slice(0, 5);
candidates.push({
rules: ["R6"],
key: `容量壓力(${total} 筆)`,
kind: "pressure",
entries: top,
suggested_type: "event",
suggested_salience: 55,
});
}
// 同一則短期記憶可能觸發多條規則 → 依 key 去重、合併規則
const merged = new Map();
for (const cand of candidates) {
const dedupeKey = `${cand.kind}:${cand.key}`;
if (merged.has(dedupeKey)) {
const prev = merged.get(dedupeKey);
prev.rules = [...new Set([...prev.rules, ...cand.rules])];
prev.suggested_salience = Math.max(prev.suggested_salience, cand.suggested_salience);
} else {
merged.set(dedupeKey, { ...cand });
}
}
return { total, pressure, candidates: [...merged.values()] };
}
/** 被回想到就更新 last_seen / recall_count(記憶越常用越不易被淘汰)。 */
export function touchRecall(slug, names) {
const wanted = new Set(names);
const today = nowIso().slice(0, 10);
for (const meta of longTermEntries(slug)) {
if (!wanted.has(meta._name)) continue;
let text;
try {
text = fs.readFileSync(meta._path, "utf8");
} catch {
continue;
}
const count = Math.floor(Number(meta.recall_count || 0)) + 1;
text = text.replace(/^recall_count:.*$/m, `recall_count: ${count}`);
text = text.replace(/^last_seen:.*$/m, `last_seen: ${today}`);
try {
fs.writeFileSync(meta._path, text, "utf8");
} catch {
/* ignore */
}
}
}
// --------------------------------------------------------------------------- //
// 心智圖 / 思維導圖 / 人際關係圖
// --------------------------------------------------------------------------- //
export const mindmapPath = (slug) => path.join(personaDir(slug), "mindmap", "semantic.mmd");
export const threadPath = (slug, topic) => path.join(personaDir(slug), "mindmap", "threads", `${slugify(topic)}.mmd`);
export const relationsJson = (slug) => path.join(personaDir(slug), "relations", "graph.json");
export const relationsMmd = (slug) => path.join(personaDir(slug), "relations", "graph.mmd");
export function loadRelations(slug) {
const data = readJson(relationsJson(slug), {}) ?? {};
data.nodes ??= [];
data.edges ??= [];
return data;
}
export function upsertRelationNode(slug, node) {
const data = loadRelations(slug);
const nodeId = node.id || slugify(node.name || "");
node.id = nodeId;
const idx = data.nodes.findIndex((n) => n.id === nodeId);
if (idx >= 0) {
for (const [k, v] of Object.entries(node)) if (v !== null && v !== undefined) data.nodes[idx][k] = v;
data.nodes[idx].updated_at = nowIso();
} else {
node.kind ??= "human";
node.closeness ??= 30;
node.trust ??= 30;
node.created_at = nowIso();
node.updated_at = nowIso();
data.nodes.push(node);
}
writeJson(relationsJson(slug), data);
return data;
}
export function upsertRelationEdge(slug, edge) {
const data = loadRelations(slug);
const idx = data.edges.findIndex((e) => e.from === edge.from && e.to === edge.to);
if (idx >= 0) {
for (const [k, v] of Object.entries(edge)) if (v !== null && v !== undefined) data.edges[idx][k] = v;
data.edges[idx].updated_at = nowIso();
} else {
edge.affinity ??= 50;
edge.created_at = nowIso();
edge.updated_at = nowIso();
data.edges.push(edge);
}
writeJson(relationsJson(slug), data);
return data;
}
export function renderRelations(slug) {
const data = loadRelations(slug);
const lines = ["%% 由 persona.mjs 產生:人際關係圖", "flowchart LR", ' self(("我"))'];
for (const node of data.nodes) {
const nid = mermaidId(node.id);
const label = `${node.name || node.id}<br/>親近 ${node.closeness ?? "?"}/信任 ${node.trust ?? "?"}`;
lines.push(` ${node.kind === "persona" ? `${nid}(["${label}"])` : `${nid}["${label}"]`}`);
}
for (const edge of data.edges) {
const src = !edge.from || edge.from === "self" ? "self" : mermaidId(edge.from);
const dst = mermaidId(edge.to || "unknown");
const affinity = Number(edge.affinity ?? 50);
lines.push(` ${src} ${affinity >= 50 ? "-->" : "-.->"}|"${edge.label || ""} ${Math.round(affinity)}"| ${dst}`);
}
const text = lines.join("\n") + "\n";
writeText(relationsMmd(slug), text);
return text;
}
export function relationsBrief(slug, names = null, limit = 5) {
const data = loadRelations(slug);
let nodes = data.nodes;
if (names?.length) {
const low = names.map((n) => n.toLowerCase());
const matched = nodes.filter((n) => low.some((k) => `${n.name || ""}${n.id || ""}`.toLowerCase().includes(k)));
nodes = matched.length ? matched : data.nodes;
}
nodes = [...nodes].sort((a, b) => Number(b.closeness || 0) - Number(a.closeness || 0)).slice(0, limit);
if (!nodes.length) return "";
return nodes
.map((n) =>
`${n.name || n.id}${n.kind || "human"}/親近 ${n.closeness ?? "?"}/信任 ${n.trust ?? "?"}` +
`${n.note ? `${n.note}` : ""}`)
.join("");
}
// --------------------------------------------------------------------------- //
// 聊天室(跨人格唯一合法的資料交換介面)
// --------------------------------------------------------------------------- //
export const roomDir = (room) => path.join(roomsDir(), String(room).replace(/[^A-Za-z0-9_.-]/g, "-").slice(0, 64));
export const roomTranscript = (room) => path.join(roomDir(room), "transcript.jsonl");
export const roomMembersPath = (room) => path.join(roomDir(room), "members.json");
export function createRoom(room, hostPersona, sessionId, topic = "") {
fs.mkdirSync(roomDir(room), { recursive: true });
const meta = readJson(roomMembersPath(room), {}) ?? {};
Object.assign(meta, {
room,
host_persona: hostPersona,
session_id: sessionId,
topic: topic || meta.topic || "",
created_at: meta.created_at || nowIso(),
updated_at: nowIso(),
});
meta.members ??= [hostPersona];
writeJson(roomMembersPath(room), meta);
return meta;
}
export function joinRoom(room, persona) {
const meta = readJson(roomMembersPath(room), { room, members: [] }) ?? { room, members: [] };
meta.members ??= [];
if (!meta.members.includes(persona)) meta.members.push(persona);
meta.updated_at = nowIso();
writeJson(roomMembersPath(room), meta);
return meta;
}
export function roomPost(room, speaker, text, { emotion = "", kind = "say" } = {}) {
const entry = { ts: nowIso(), speaker, kind, text, emotion };
appendJsonl(roomTranscript(room), entry);
return entry;
}
export const roomRead = (room, limit = 30) => readJsonl(roomTranscript(room), limit);
/** 劇場模式的對話呈現:`emoji 名字(情緒):內容`,其餘一律不輸出。 */
export function roomScript(room, { limit = 30, includeMeta = false } = {}) {
const lines = [];
for (const msg of roomRead(room, limit)) {
if (msg.kind === "meta" || msg.speaker === "system") {
if (includeMeta) lines.push(`${msg.text}`);
continue;
}
const slug = msg.speaker;
const ident = personaExists(slug) ? identityFields(slug) : {};
const name = ident.Name || slug;
const emoji = ident.Emoji ? `${ident.Emoji} ` : "";
lines.push(`${emoji}${name}${msg.emotion ? `${msg.emotion}` : ""}${msg.text}`);
}
return lines.join("\n");
}
// --------------------------------------------------------------------------- //
// guard:跨人格隔離 + 鎖驗證的判斷核心
// --------------------------------------------------------------------------- //
export const MUTATING_TOOLS = new Set(["Write", "Edit", "NotebookEdit", "MultiEdit"]);
const 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"],
};
export const GUEST_SAFE_SUBCOMMANDS = new Set(["show", "status", "list", "recall", "room", "remember", "leave", "brief"]);
// owner 這些子指令本來就要提到別的人格名字(邀請/離場/查詢),不算跨人格讀取
export const OWNER_EXEMPT_SUBCOMMANDS = new Set(["create", "list", "status", "gc", "invite", "load", "leave"]);
const MUTATING_SHELL =
/(>>?|\|\s*tee\b|\brm\b|\bmv\b|\bcp\b|\btruncate\b|\bdd\b|\bchmod\b|\bchown\b|\bsed\b[^|;]*-i|\btouch\b|\bmkdir\b|\bln\b)/;
function expandToken(token) {
let t = String(token).trim().replace(/^['"]|['"]$/g, "");
t = t.replaceAll("${PERSONA_HOME}", personaHome()).replaceAll("$PERSONA_HOME", personaHome());
t = t.replace(/\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?/g, (m, name) => process.env[name] ?? m);
return expandUser(t);
}
/** 解析成絕對路徑:吃掉 `..`,並對已存在的祖先解 symlink(目標可能還不存在)。 */
function resolvePath(token, cwd) {
try {
const raw = expandToken(token);
if (!raw) return null;
let abs = path.isAbsolute(raw) ? path.normalize(raw) : path.normalize(path.resolve(cwd || process.cwd(), raw));
const parts = [];
let probe = abs;
for (;;) {
if (fs.existsSync(probe)) {
const real = fs.realpathSync(probe);
return parts.length ? path.join(real, ...parts.reverse()) : real;
}
const parent = path.dirname(probe);
if (parent === probe) return abs;
parts.push(path.basename(probe));
probe = parent;
}
} catch {
return null;
}
}
function isUnder(target, base) {
const rel = path.relative(base, target);
return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
}
export function personaSlugOf(target) {
const home = personaHome();
if (!isUnder(target, home) || target === home) return null;
const rel = path.relative(home, target);
return rel.split(path.sep)[0] || null;
}
export function extractPaths(toolName, toolInput, cwd) {
const out = [];
for (const field of PATH_TOOL_FIELDS[toolName] || []) {
const value = toolInput?.[field];
if (typeof value === "string" && value) {
const resolved = resolvePath(value, cwd);
if (resolved) out.push(resolved);
}
}
if (toolName === "Bash") {
const command = toolInput?.command || "";
const home = personaHome();
for (const token of command.match(/[^\s'";|&<>()]+/g) || []) {
if (!token.includes("/") && !token.includes("PERSONA_HOME")) continue;
const expanded = expandToken(token);
if (expanded.includes(home) || expanded.includes("personas")) {
const resolved = resolvePath(token, cwd);
if (resolved && isUnder(resolved, home)) out.push(resolved);
}
}
}
return out;
}
/** 辨識 Bash 是否在呼叫 persona CLI,並取出 subcommand / --persona / --session。 */
export function cliInvocation(command) {
if (!/persona\.(mjs|js|py)\b/.test(command)) return null;
const info = {
subcommand: null,
personas: [],
session: null,
asGuest: /--as-guest\b/.test(command),
};
const sub = command.match(/persona\.(?:mjs|js|py)['"]?\s+([a-z][a-z0-9-]*)/);
if (sub) info.subcommand = sub[1];
info.personas = [...command.matchAll(/--(?:persona|guest|host|as)[= ]+['"]?([a-z0-9-]+)/g)].map((m) => m[1]);
const sess = command.match(/--session[= ]+['"]?([^\s'"]+)/);
if (sess) info.session = sess[1];
return info;
}
/**
* 算出這個呼叫者能碰哪個人格。
*
* * 主程序(無 agent_id)與一般 sub agent → host 人格,可讀寫。
* * persona-guest 型 sub agent → 只能碰被邀請的 guest 人格,且唯讀;
* 第一次觸碰哪個 guest 就 pin 住(first-touch pinning),之後不得換人。
*/
export function resolveScope(sessionId, agentId, agentType) {
const data = loadSession(sessionId);
const host = data.host;
const guests = Object.keys(data.guests || {});
const isGuestAgent = Boolean(agentType) && String(agentType).includes("persona-guest");
if (!isGuestAgent) {
return {
role: "owner",
allowed: host ? [host] : [],
readonly: false,
host,
guests,
rooms: data.rooms || [],
session: data,
};
}
const pinned = (data.pins || {})[agentId || ""];
return {
role: "guest",
allowed: pinned ? [pinned] : guests,
readonly: true,
host,
guests,
pinned,
rooms: data.rooms || [],
session: data,
};
}
export function pinAgent(sessionId, agentId, slug) {
const data = loadSession(sessionId);
data.pins ??= {};
if (data.pins[agentId] !== slug) {
data.pins[agentId] = slug;
saveSession(sessionId, data);
}
}
/** 回傳 { decision: "allow"|"deny"|"pass", reason }。"pass" = 不表態,交回原本流程。 */
export function guardDecide(event) {
const tool = event.tool_name || "";
const toolInput = event.tool_input || {};
const sessionId = event.session_id || "unknown";
const agentId = event.agent_id;
const agentType = event.agent_type;
const cwd = event.cwd;
const scope = resolveScope(sessionId, agentId, agentType);
const deny = (reason) => ({ decision: "deny", reason });
// 1) persona CLI 呼叫:先驗 session 身分,再驗人格範圍
if (tool === "Bash") {
const command = toolInput.command || "";
const info = cliInvocation(command);
if (info) {
if (info.session && info.session !== sessionId) {
return deny(
`CLI 的 --session \`${info.session.slice(0, 12)}…\` 與本 session 不符,` +
"不得冒用其他程序的身分(人格鎖與隔離都靠 session 判定)。",
);
}
const sub = info.subcommand || "";
if (scope.role === "guest") {
if (!GUEST_SAFE_SUBCOMMANDS.has(sub)) {
return deny(
`guest 人格(sub agent)僅能執行 ${[...GUEST_SAFE_SUBCOMMANDS].sort().join("/")},不得執行 \`${sub}\`。`,
);
}
for (const slug of info.personas) {
if (scope.allowed.length && !scope.allowed.includes(slug)) {
return deny(`guest 只能操作被邀請的人格 ${JSON.stringify(scope.allowed)},不得碰 \`${slug}\`。`);
}
}
} else {
if (info.asGuest) {
return deny(
"`--as-guest` 只有 persona-guest 型的 sub agent 能用;主程序不得以受邀人格的身分存取它的資料。",
);
}
for (const slug of info.personas) {
if (OWNER_EXEMPT_SUBCOMMANDS.has(sub)) continue;
if (scope.host && slug !== scope.host) {
const extra = scope.guests.includes(slug)
? "(它是本 session 邀請的 guest:你只能讀它在聊天室說出口的話,不能碰它的記憶或情緒。)"
: "請先 release 再 load,或改用 invite + 聊天室。";
return deny(`本 session 已載入人格 \`${scope.host}\`,禁止跨人格操作 \`${slug}\`。${extra}`);
}
}
}
}
if (MUTATING_SHELL.test(command) && scope.role === "guest") {
for (const target of extractPaths(tool, toolInput, cwd)) {
if (personaSlugOf(target)) {
return deny("guest 人格對人格倉庫唯讀,寫入請透過 `persona.mjs room post` 或 `remember --scope inbox`。");
}
}
}
}
// 2) 路徑隔離
const home = personaHome();
for (const target of extractPaths(tool, toolInput, cwd)) {
if (!isUnder(target, home)) continue;
if (target === home) {
return deny("禁止直接遍歷人格倉庫根目錄(會看到其他人格)。請用 `persona.mjs list`。");
}
const slug = personaSlugOf(target);
if (slug === ROOMS_DIRNAME) {
const parts = path.relative(home, target).split(path.sep);
const room = parts.length > 1 ? parts[1] : null;
if (room && scope.rooms.length && !scope.rooms.includes(room)) {
return deny(`聊天室 \`${room}\` 不屬於本 session(可用的:${JSON.stringify(scope.rooms)})。`);
}
continue;
}
if (slug === RUNTIME_DIRNAME) {
return deny("`.runtime/` 是鎖與綁定的內部狀態,只能由 persona CLI 維護。");
}
if (!slug) continue;
if (!scope.allowed.length) {
return deny(
"尚未載入任何人格。請先執行 `persona.mjs load --persona <slug> --session <session_id>`(或 /jsc-persona:persona-chat)。",
);
}
if (!scope.allowed.includes(slug)) {
if (scope.role === "guest") {
return deny(
`guest 人格被 pin 在 ${JSON.stringify(scope.allowed)},禁止讀取 \`${slug}\` 的任何資料(跨人格資料隔離)。`,
);
}
return deny(
`本 session 的人格是 \`${scope.allowed[0]}\`,禁止讀寫 \`${slug}\` 的資料(跨人格資料隔離)。` +
"要與它對話請用 /jsc-persona:persona-invite。",
);
}
// 3) guest 唯讀 + first-touch pinning
if (scope.role === "guest") {
if (!scope.pinned && agentId) pinAgent(sessionId, agentId, slug);
if (MUTATING_TOOLS.has(tool)) {
return deny(
`guest 人格 \`${slug}\` 在 sub agent 中為唯讀;要留下記憶請 \`persona.mjs remember --scope inbox\`(下次它自己載入時再固化)。`,
);
}
}
// 4) 鎖驗證:owner 必須真的持有鎖
if (scope.role === "owner") {
const lock = readJson(lockPath(slug)) ?? {};
const hasLock = Boolean(Object.keys(lock).length);
if (hasLock && lock.session_id !== sessionId && !lockIsDead(lock)) {
return deny(
`人格 \`${slug}\` 的鎖屬於另一個程序(session ${String(lock.session_id).slice(0, 8)}…,cwd ${lock.cwd})。` +
"同一人格同時只能被一個程序載入。",
);
}
if (!hasLock && MUTATING_TOOLS.has(tool)) {
return deny(`人格 \`${slug}\` 目前沒有有效的載入鎖,禁止寫入。請先 \`persona.mjs load\` 取得鎖。`);
}
}
}
return { decision: "pass", reason: "" };
}
// --------------------------------------------------------------------------- //
// 給 hook 用的上下文組裝
// --------------------------------------------------------------------------- //
export function identityFields(slug) {
const fields = {};
let text;
try {
text = fs.readFileSync(path.join(personaDir(slug), "IDENTITY.md"), "utf8");
} catch {
return fields;
}
for (const line of text.split("\n")) {
const m = line.match(/^\s*[-*]?\s*(Name|Creature|Vibe|Emoji|Avatar)\s*:\s*(.+)$/i);
if (!m) continue;
const value = m[2].trim();
if (value.startsWith("(") || value.startsWith("_(")) continue;
const key = m[1][0].toUpperCase() + m[1].slice(1).toLowerCase();
fields[key] = value;
}
return fields;
}
export function identityBrief(slug) {
const fields = identityFields(slug);
const order = ["Emoji", "Name", "Creature", "Vibe"];
return order.filter((k) => fields[k]).map((k) => `${k}: ${fields[k]}`).join("");
}
/** UserPromptSubmit 注入的人格上下文:身分 + 情緒 + 短期記憶 + 相關長期記憶 + 關係。 */
export function turnContext(slug, sessionId, prompt = "") {
const state = decayEmotion(loadEmotion(slug));
writeJson(emotionPath(slug), state);
const session = loadSession(sessionId);
const lines = [
"<persona-context>",
`PERSONA_SESSION=${sessionId}`,
`人格:\`${slug}\` ${identityBrief(slug)}`,
`人格倉庫:${personaDir(slug)}(唯一可讀寫的人格資料範圍)`,
emotionBrief(slug, state),
];
const recents = recentShort(slug, 6);
if (recents.length) {
lines.push("短期記憶(最近):");
for (const row of recents) {
const who = row.role || row.speaker || "?";
const text = String(row.text || "").replace(/\n/g, " ").slice(0, 90);
lines.push(` - [${who}] ${text}${row.salience ? `(顯著度 ${row.salience}` : ""}`);
}
}
const hits = prompt ? recall(slug, prompt, 4) : [];
if (hits.length) {
lines.push("相關長期記憶:");
for (const meta of hits) {
const first = (meta._body || "").split("\n")[0] || "";
lines.push(` - ${meta._name}${meta.type || "fact"}${first.slice(0, 100)}`);
}
touchRecall(slug, hits.map((m) => m._name));
}
const rel = relationsBrief(slug, prompt ? keywords(prompt, 6) : null);
if (rel) lines.push(`人際關係:${rel}`);
if (session.theater && (session.rooms || []).length) {
const rooms = session.rooms;
lines.push(
`🎭 多人聊天模式(劇場)進行中:聊天室 ${JSON.stringify(rooms)}。`,
" 對使用者的輸出**只能有人格對話**(每行 `emoji 名字(情緒):內容`):",
" 不得出現指令、指令輸出、狀態說明、進度、摘要、分析或旁白;所有 CLI 一律加 `--quiet` 並把輸出丟掉。",
" 想結束請等使用者說,或由使用者說「結束對話」後才做收尾與摘要。",
);
} else {
const { total, candidates } = promotionCandidates(slug);
if (candidates.length) {
const rules = [...new Set(candidates.flatMap((c) => c.rules))].sort().join("/");
lines.push(
`⚠ 短期記憶 ${total} 筆,其中 ${candidates.length} 組已達固化條件(${rules})→ 執行 /jsc-persona:persona-memory。`,
);
}
let inbox = [];
try {
inbox = fs.readdirSync(path.join(personaDir(slug), "memory", "inbox")).filter((f) => f.startsWith("room-"));
} catch {
inbox = [];
}
if (inbox.length) lines.push(`⚠ 有 ${inbox.length} 個聊天室 inbox 待消化(guest 期間留下的見聞)。`);
}
lines.push("</persona-context>");
return lines.join("\n");
}