#!/usr/bin/env node // jsc-persona 自我測試:在暫存倉庫裡驗證人格鎖、跨人格隔離、情緒、記憶固化條件、 // 劇場模式與 hooks。 // // 用法:`node scripts/selftest.mjs`(會用自己的暫時 PERSONA_HOME,不動到你的人格資料) import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { spawnSync } from "node:child_process"; import { fileURLToPath } from "node:url"; const HERE = path.dirname(fileURLToPath(import.meta.url)); const STORE = fs.mkdtempSync(path.join(os.tmpdir(), "persona-selftest-")); process.env.PERSONA_HOME = STORE; const pl = await import("./persona-lib.mjs"); const CLI = path.join(HERE, "persona.mjs"); const HOOKS = path.join(HERE, "..", "hooks"); const S_HOST = "sess-host-1111"; const S_OTHER = "sess-other-2222"; const S_THIRD = "sess-third-3333"; const H = STORE; let passed = 0; let failed = 0; function check(label, condition, detail = "") { if (condition) { passed += 1; console.log(` ✔ ${label}`); } else { failed += 1; console.log(` ✘ ${label}${detail ? ` — ${detail}` : ""}`); } } function cli(args, { expectOk = true } = {}) { const proc = spawnSync(process.execPath, [CLI, ...args], { encoding: "utf8" }); if (expectOk && proc.status !== 0) { console.log(` (CLI 失敗:${args.join(" ")}\n ${String(proc.stderr).trim()})`); } return proc; } function hook(name, event) { const proc = spawnSync(process.execPath, [path.join(HOOKS, name)], { input: JSON.stringify(event), encoding: "utf8", }); if (!String(proc.stdout).trim()) return {}; try { return JSON.parse(proc.stdout); } catch { return { _raw: proc.stdout, _err: proc.stderr }; } } const guard = (event) => pl.guardDecide({ cwd: HERE, ...event }).decision; console.log(`暫存人格倉庫:${STORE}\n`); // --------------------------------------------------------------------------- // console.log("① 建立人格(OpenClaw 五欄位)"); cli(["create", "--persona", "alpha", "--session", S_HOST, "--name", "Alpha", "--creature", "深海燈籠魚", "--vibe", "溫暖但銳利", "--emoji", "🪼", "--baseline", "serenity=45,trust=35"]); cli(["create", "--persona", "beta", "--session", S_OTHER, "--name", "Beta", "--creature", "山中的舊鐘", "--vibe", "沉穩寡言", "--emoji", "🌙"]); check("兩個人格都建立成功", pl.personaExists("alpha") && pl.personaExists("beta")); check("IDENTITY 五欄位可解析", pl.identityBrief("alpha").includes("Name: Alpha"), pl.identityBrief("alpha")); check("SOUL/AGENTS/USER 都有產生", ["SOUL.md", "AGENTS.md", "USER.md"].every((f) => fs.existsSync(path.join(pl.personaDir("alpha"), f)))); console.log("② 人格鎖:一個人格只能被一個程序載入"); check("建立時即取得鎖", pl.lockStatus("alpha").locked); check("同 session 重入成功", cli(["load", "--persona", "alpha", "--session", S_HOST]).status === 0); check("同 session 載入第二個人格被拒", cli(["load", "--persona", "beta", "--session", S_HOST], { expectOk: false }).status !== 0); cli(["release", "--session", S_OTHER]); // 讓 beta 空出來 check("其他 session 搶佔已鎖人格被拒", cli(["load", "--persona", "alpha", "--session", S_THIRD], { expectOk: false }).status !== 0); console.log("③ 跨人格資料隔離(PreToolUse guard)"); check("host 讀自己的檔案 → 放行", guard({ session_id: S_HOST, tool_name: "Read", tool_input: { file_path: `${H}/alpha/SOUL.md` } }) === "pass"); check("host 讀別的人格 → 攔下", guard({ session_id: S_HOST, tool_name: "Read", tool_input: { file_path: `${H}/beta/memory/short-term.jsonl` } }) === "deny"); check("用 ../ 繞路 → 攔下", guard({ session_id: S_HOST, tool_name: "Read", tool_input: { file_path: `${H}/alpha/../beta/SOUL.md` } }) === "deny"); check("Bash grep 掃別人格 → 攔下", guard({ session_id: S_HOST, tool_name: "Bash", tool_input: { command: `grep -r . ${H}/beta/` } }) === "deny"); check("$PERSONA_HOME 變數繞路 → 攔下", guard({ session_id: S_HOST, tool_name: "Bash", tool_input: { command: "cat $PERSONA_HOME/beta/SOUL.md" } }) === "deny"); check("遍歷倉庫根目錄 → 攔下", guard({ session_id: S_HOST, tool_name: "Glob", tool_input: { path: H } }) === "deny"); check("讀 .runtime 內部狀態 → 攔下", guard({ session_id: S_HOST, tool_name: "Read", tool_input: { file_path: `${H}/.runtime/sessions/${S_HOST}.json` } }) === "deny"); check("CLI 冒用其他 session → 攔下", guard({ session_id: S_HOST, tool_name: "Bash", tool_input: { command: `node persona.mjs remember --session ${S_OTHER} --text x` } }) === "deny"); check("未載入人格的 session 讀人格 → 攔下", guard({ session_id: "sess-nobody", tool_name: "Read", tool_input: { file_path: `${H}/alpha/SOUL.md` } }) === "deny"); check("專案內普通檔案不受干涉", guard({ session_id: S_HOST, tool_name: "Read", tool_input: { file_path: CLI } }) === "pass"); check("一般 sub agent 沿用 host 範圍(sub agent 不限)", guard({ session_id: S_HOST, agent_id: "ag-1", agent_type: "Explore", tool_name: "Read", tool_input: { file_path: `${H}/alpha/memory/INDEX.md` } }) === "pass"); console.log("④ 情緒(六正向 + 六負向)"); check("十二種情緒", pl.EMOTION_KEYS.length === 12 && pl.POSITIVE.length === 6 && pl.NEGATIVE.length === 6); cli(["emotion", "--persona", "alpha", "--session", S_HOST, "--apply", "joy=+60,anger=+40", "--trigger", "selftest"]); let state = pl.loadEmotion("alpha"); check("情緒有被施加", state.levels.joy >= 70, JSON.stringify(state.levels)); state.updated_at = pl.iso(pl.minutesAgo(120)); const decayed = pl.decayEmotion(structuredClone(state)); const expected = state.baseline.joy + (state.levels.joy - state.baseline.joy) / 2; check("一個半衰期後衰減到中點", Math.abs(decayed.levels.joy - expected) < 0.5, `${decayed.levels.joy} vs ${expected}`); check("心情推導出 valence/arousal", ["valence", "arousal", "label", "tempo"].every((k) => k in pl.mood(decayed))); console.log("⑤ 記憶:短期 → 長期 → 檢索"); cli(["remember", "--persona", "alpha", "--session", S_HOST, "--role", "user", "--text", "討厭早上的會議", "--topics", "work,schedule", "--salience", "70", "--emotion", "anxiety=+10"]); check("短期記憶有寫入", pl.readJsonl(pl.shortTermPath("alpha")).length === 1); cli(["consolidate", "--persona", "alpha", "--session", S_HOST, "--name", "hates-morning-meetings", "--type", "preference", "--about", "user", "--topics", "work,schedule", "--salience", "72", "--body", "使用者討厭早上的會議。"]); check("長期記憶一則一檔", fs.existsSync(path.join(pl.longTermDir("alpha"), "hates-morning-meetings.md"))); check("INDEX.md 有索引", fs.readFileSync(pl.indexPath("alpha"), "utf8").includes("hates-morning-meetings")); check("關鍵詞可檢索到", pl.recall("alpha", "早上 會議").map((m) => m._name).join() === "hates-morning-meetings"); check("情緒事件寫進 journal", pl.readJsonl(pl.journalPath("alpha")).some((r) => r.kind === "emotion")); console.log("⑥ 短期 → 長期的轉入條件"); check("有六條成文條件", pl.PROMOTION_RULES.length === 6); check("R1 高顯著度會成為候選", pl.promotionCandidates("alpha").candidates.some((c) => c.rules.includes("R1"))); cli(["remember", "--session", S_HOST, "--role", "user", "--text", "我答應下週一定會把報告寄給你", "--topics", "work", "--intent", "commit", "--salience", "30"]); const promiseCand = pl.promotionCandidates("alpha").candidates.find((c) => c.rules.includes("R4")); check("R4 承諾必固化(type=promise、salience 拉到 80)", Boolean(promiseCand) && promiseCand.suggested_type === "promise" && promiseCand.suggested_salience >= 80); cli(["remember", "--session", S_HOST, "--role", "user", "--text", "又被排早會,超煩", "--topics", "work,schedule", "--entities", "小林", "--salience", "50", "--emotion", "anger=+20,anxiety=+15"]); cli(["remember", "--session", S_HOST, "--role", "user", "--text", "小林說他會改時間", "--topics", "work", "--entities", "小林", "--salience", "45"]); const cands = pl.promotionCandidates("alpha").candidates; check("R2 主題反覆出現會成為候選", cands.some((c) => c.rules.includes("R2") && c.kind === "topic")); check("R3 情緒衝擊大會成為候選", cands.some((c) => c.rules.includes("R3"))); check("R5 人物反覆出現會成為候選(建議 relationship)", cands.some((c) => c.rules.includes("R5") && c.suggested_type === "relationship")); const candOut = cli(["candidates", "--session", S_HOST, "--json"]); check("candidates 子指令可輸出 JSON", (() => { try { const parsed = JSON.parse(candOut.stdout); return parsed.candidates.length > 0 && typeof parsed.total === "number"; } catch { return false; } })(), candOut.stdout.slice(0, 120)); console.log("⑦ 心智圖 / 思維導圖 / 人際關係圖"); cli(["mindmap", "thread", "--persona", "alpha", "--session", S_HOST, "--topic", "壓力來源"]); check("思維導圖建立(Mermaid graph)", fs.readFileSync(pl.threadPath("alpha", "壓力來源"), "utf8").includes("graph LR")); check("心智圖存在(Mermaid mindmap)", fs.readFileSync(pl.mindmapPath("alpha"), "utf8").includes("mindmap")); cli(["relation", "node", "--persona", "alpha", "--session", S_HOST, "--name", "小林", "--kind", "human", "--closeness", "35", "--trust", "40", "--note", "同事"]); cli(["relation", "edge", "--persona", "alpha", "--session", S_HOST, "--to", "小林", "--label", "透過使用者認識", "--affinity", "45"]); const mmd = fs.readFileSync(pl.relationsMmd("alpha"), "utf8"); const alias = pl.mermaidId("小林"); check("關係圖節點與連線用同一個 Mermaid 別名", mmd.split(alias).length - 1 === 2, mmd); console.log("⑧ 邀請其他人格(sub agent + 聊天室 + 劇場模式)"); cli(["invite", "--session", S_HOST, "--guest", "beta", "--topic", "測試對話"]); const room = pl.loadSession(S_HOST).guests?.beta?.room; check("guest 租約建立", Boolean(room) && pl.liveGuests("beta").some((g) => g.session_id === S_HOST)); check("邀請時自動開啟劇場模式", pl.loadSession(S_HOST).theater === true); check("guest 不佔 exclusive 鎖", !pl.lockStatus("beta").locked); check("有 guest 租約時其他 session 不得 exclusive 載入", cli(["load", "--persona", "beta", "--session", S_THIRD], { expectOk: false }).status !== 0); check("guest sub agent 讀自己 → 放行(first-touch pin)", guard({ session_id: S_HOST, agent_id: "guest-1", agent_type: "jsc-persona:persona-guest", tool_name: "Read", tool_input: { file_path: `${H}/beta/SOUL.md` } }) === "pass"); check("guest 讀主人格 → 攔下", guard({ session_id: S_HOST, agent_id: "guest-1", agent_type: "jsc-persona:persona-guest", tool_name: "Read", tool_input: { file_path: `${H}/alpha/SOUL.md` } }) === "deny"); check("guest 寫人格檔 → 攔下(唯讀)", guard({ session_id: S_HOST, agent_id: "guest-1", agent_type: "jsc-persona:persona-guest", tool_name: "Write", tool_input: { file_path: `${H}/beta/memory/long-term/x.md` } }) === "deny"); check("guest 跑非白名單子指令 → 攔下", guard({ session_id: S_HOST, agent_id: "guest-1", agent_type: "jsc-persona:persona-guest", tool_name: "Bash", tool_input: { command: `node persona.mjs consolidate --persona beta --session ${S_HOST} --name x` } }) === "deny"); check("主程序不得偷讀 guest 的記憶", cli(["recall", "--persona", "beta", "--session", S_HOST, "--query", "x"], { expectOk: false }).status !== 0); check("主程序不得冒用 --as-guest", guard({ session_id: S_HOST, tool_name: "Bash", tool_input: { command: `node persona.mjs recall --persona beta --session ${S_HOST} --as-guest --query x` } }) === "deny"); cli(["room", "post", "--session", S_HOST, "--room", room, "--as", "beta", "--as-guest", "--text", "我是 Beta。"]); cli(["room", "post", "--session", S_HOST, "--room", room, "--as", "alpha", "--text", "我是 Alpha。"]); const msgs = pl.roomRead(room); check("兩個人格都能在聊天室發言(含情緒標記)", ["alpha", "beta"].every((s) => msgs.some((m) => m.speaker === s)) && msgs.some((m) => m.emotion)); const script = cli(["room", "script", "--session", S_HOST, "--room", room]).stdout; check("room script 只輸出 `名字:內容`(無時間戳、無 slug、無系統訊息)", script.includes("Beta(") && script.includes(":我是 Beta。") && !script.includes("[20") && !script.includes("system"), JSON.stringify(script)); check("劇場模式的對話有 emoji 前綴", script.includes("🌙") && script.includes("🪼"), script); const quiet = cli(["room", "post", "--session", S_HOST, "--room", room, "--as", "alpha", "--text", "安靜發言", "--quiet"]); check("--quiet 成功時不輸出任何字", quiet.status === 0 && quiet.stdout === "", JSON.stringify(quiet.stdout)); cli(["remember", "--persona", "beta", "--session", S_HOST, "--as-guest", "--scope", "inbox", "--room", room, "--role", "guest", "--text", "跟 alpha 聊過", "--salience", "50"]); check("guest 只能把見聞留在自己的 inbox", fs.existsSync(pl.inboxPath("beta", room))); check("其他聊天室不可讀", guard({ session_id: S_HOST, tool_name: "Read", tool_input: { file_path: `${H}/.rooms/someone-elses-room/transcript.jsonl` } }) === "deny"); cli(["leave", "--session", S_HOST, "--guest", "beta"]); check("離場後 guest 租約釋放", pl.liveGuests("beta").length === 0); check("離場後劇場模式自動關閉", pl.loadSession(S_HOST).theater === false); console.log("⑨ hooks"); let out = hook("session_start.mjs", { session_id: S_HOST, source: "resume", cwd: HERE }); let ctx = out.hookSpecificOutput?.additionalContext || ""; check("SessionStart 注入 PERSONA_SESSION 與人格狀態", ctx.includes(`PERSONA_SESSION=${S_HOST}`) && ctx.includes("alpha")); check("SessionStart 明示「由使用者呼叫才載入」", ctx.includes("使用者叫你載入")); out = hook("prompt_submit.mjs", { session_id: S_HOST, prompt: "早上的會議又來了", cwd: HERE }); ctx = out.hookSpecificOutput?.additionalContext || ""; check("UserPromptSubmit 注入情緒 + 命中的長期記憶", ctx.includes("情緒:") && ctx.includes("hates-morning-meetings"), ctx.slice(0, 200)); check("UserPromptSubmit 提醒已達固化條件", ctx.includes("固化條件"), ctx.slice(0, 400)); out = hook("guard.mjs", { session_id: S_HOST, tool_name: "Read", cwd: HERE, tool_input: { file_path: `${H}/beta/SOUL.md` } }); check("PreToolUse hook 輸出 deny", out.hookSpecificOutput?.permissionDecision === "deny", JSON.stringify(out)); out = hook("turn_end.mjs", { session_id: S_HOST, last_assistant_message: "好,我幫你挪。" }); check("Stop 記錄人格發言", pl.readJsonl(pl.journalPath("alpha")).some((r) => r.role === "persona")); check("Stop 在非劇場模式提醒固化", String(out.systemMessage || "").includes("固化條件"), JSON.stringify(out)); cli(["room", "theater", "--session", S_HOST, "--on"]); const theaterSession = pl.loadSession(S_HOST); theaterSession.rooms = [room]; pl.saveSession(S_HOST, theaterSession); out = hook("turn_end.mjs", { session_id: S_HOST, last_assistant_message: "🪼 Alpha:嗯。" }); check("劇場模式時 Stop 不發任何提醒", out.systemMessage === undefined, JSON.stringify(out)); out = hook("prompt_submit.mjs", { session_id: S_HOST, prompt: "你們繼續", cwd: HERE }); ctx = out.hookSpecificOutput?.additionalContext || ""; check("劇場模式時 UserPromptSubmit 強制只輸出對話", ctx.includes("只能") && ctx.includes("名字:內容"), ctx.slice(-300)); cli(["room", "theater", "--session", S_HOST, "--off"]); out = hook("session_end.mjs", { session_id: S_HOST, reason: "exit" }); check("SessionEnd 釋放鎖", !pl.lockStatus("alpha").locked); check("釋放後其他 session 可載入", cli(["load", "--persona", "alpha", "--session", S_THIRD]).status === 0); console.log("⑩ 死鎖接手"); const lock = pl.readJson(pl.lockPath("alpha")); lock.heartbeat_at = pl.iso(pl.minutesAgo(20)); pl.writeJson(pl.lockPath("alpha"), lock); check("租約過期會被標記為死鎖", pl.lockStatus("alpha").stale); const takeover = cli(["load", "--persona", "alpha", "--session", "sess-fresh-9999"]); check("死鎖可自動接手並回報", takeover.stdout.includes("接手"), takeover.stdout.slice(0, 200)); pl.gcRuntime(); console.log(`\n${"=".repeat(60)}\n通過 ${passed} 項,失敗 ${failed} 項 → ${failed === 0 ? "全部通過 ✅" : "有測試失敗 ❌"}`); console.log(`(暫存倉庫留在 ${STORE},可自行刪除)`); process.exit(failed ? 1 : 0);