#!/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 zlib from "node:zlib"; 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; // 自我測試絕對不碰真的 Gitea:不建存取庫、不 push。同步邏輯只測不需要網路的部分。 process.env.PERSONA_GITEA = "off"; const pl = await import("./persona-lib.mjs"); const gt = await import("./persona-gitea.mjs"); const ic = await import("./persona-icon.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("⑪ 說話節制:心裡話 / 不重複 / 一到三句"); const S_SPEAK = "sess-fresh-9999"; // ⑩ 接手 alpha 的那個 session check("句數以句末標點計算", pl.sentenceCount("好。我想想。") === 2 && pl.sentenceCount("嗯") === 1); check("劇場前綴不影響比對", pl.normalizeSpeech("🪼 Alpha(喜悅42/期待31):今天的風很輕") === pl.normalizeSpeech("今天的風很輕。")); // 重複判定的校準對照表:改 similarity() 或 REPEAT_THRESHOLD 時,這張表必須維持全綠 const REPEAT_CASES = [ [true, "我等一下把報告寄給你", "等一下我會把報告寄給你"], // 語序重排=同一件事 [true, "你這次的鐘修得比上次穩", "這次的鐘你修得比上次穩"], [true, "那你先忙,我在這裡等你。", "我在這裡等你,你先忙吧。"], [false, "你今天看起來很累", "你今天看起來很開心"], // 換關鍵詞=新資訊 [false, "報告我明天寄", "報告我後天寄"], [false, "那台舊鐘修好了嗎", "那台舊鐘賣掉了嗎"], [false, "早上的會議又被排進來了", "我幫你把會議挪到下午三點"], [false, "我等一下把報告寄給你", "今天午餐想吃什麼"], [false, "我不同意這個做法", "我不同意,因為它會讓值班的人扛下全部風險"], ]; const misjudged = REPEAT_CASES.filter(([want, a, b]) => (pl.similarity(a, b) >= pl.REPEAT_THRESHOLD) !== want); check("重複判定校準:換句話說同一件事會擋、只換關鍵詞放行", misjudged.length === 0, misjudged.map(([, a, b]) => `${a}/${b}=${pl.similarity(a, b)}`).join(" | ")); const think = cli(["think", "--session", S_SPEAK, "--kind", "infer", "--text", "他今天語氣比昨天急,deadline 可能提前了"]); check("think 只回報「心想 N 句」,不回顯內容", think.stdout.includes("心想") && !think.stdout.includes("deadline"), JSON.stringify(think.stdout)); check("心裡話寫進 inner.jsonl", pl.readJsonl(pl.innerPath("alpha")).length === 1); check("心裡話不會混進短期記憶", !pl.readJsonl(pl.shortTermPath("alpha")).some((r) => String(r.text || "").includes("deadline 可能提前"))); cli(["said", "check", "--session", S_SPEAK, "--text", "我等一下把報告寄給你,別擔心。", "--record"]); const dup = cli(["said", "check", "--session", S_SPEAK, "--json", "--text", "等一下我會把報告寄給你,別擔心。"]); check("said check 抓到短時間內的重複", (() => { try { const parsed = JSON.parse(dup.stdout); return parsed.ok === false && parsed.repeat && parsed.repeat.similarity >= pl.REPEAT_THRESHOLD; } catch { return false; } })(), dup.stdout.slice(0, 160)); const longSay = cli(["said", "check", "--session", S_SPEAK, "--json", "--text", "第一句在這。第二句在這。第三句在這。第四句在這。"]); check("said check 抓到超過三句", (() => { try { const parsed = JSON.parse(longSay.stdout); return parsed.sentences === 4 && parsed.ok === false; } catch { return false; } })(), longSay.stdout.slice(0, 160)); const ctxSpeak = pl.turnContext("alpha", S_SPEAK, "報告"); check("turnContext 注入說話規則 / 心裡話 / 最近說過", ctxSpeak.includes("說話規則") && ctxSpeak.includes("💭") && ctxSpeak.includes("最近說過的話"), ctxSpeak.slice(0, 400)); cli(["invite", "--session", S_SPEAK, "--guest", "beta", "--topic", "節制測試"]); const room2 = pl.loadSession(S_SPEAK).guests?.beta?.room; check("劇場模式的規則也注入了(每輪三句、重複會被擋)", (() => { const ctx = pl.turnContext("alpha", S_SPEAK, "繼續"); return ctx.includes("句以內") && ctx.includes("重複"); })()); cli(["room", "post", "--session", S_SPEAK, "--room", room2, "--as", "alpha", "--text", "你這次的鐘修得比上次穩。"]); const repeatPost = cli(["room", "post", "--session", S_SPEAK, "--room", room2, "--as", "alpha", "--text", "這次的鐘你修得比上次穩。"], { expectOk: false }); check("room post 擋下近似重複的台詞", repeatPost.status !== 0 && repeatPost.stderr.includes("說過"), repeatPost.stderr.slice(0, 160)); check("--allow-repeat 可以放行", cli(["room", "post", "--session", S_SPEAK, "--room", room2, "--as", "alpha", "--text", "這次的鐘你修得比上次穩。", "--allow-repeat"]).status === 0); const longPost = cli(["room", "post", "--session", S_SPEAK, "--room", room2, "--as", "alpha", "--text", "第一句在這。第二句在這。第三句在這。第四句在這。"], { expectOk: false }); check("room post 擋下超過三句的發言", longPost.status !== 0 && longPost.stderr.includes("超過"), longPost.stderr.slice(0, 160)); check("說出口的話有進 said.jsonl", pl.readJsonl(pl.saidPath("alpha")).some((r) => String(r.text).includes("鐘修得比上次穩"))); hook("turn_end.mjs", { session_id: S_SPEAK, last_assistant_message: "🪼 Alpha(平靜50):那就這樣說定了。" }); check("Stop hook 會把說出口的話記進 said(去掉名字前綴)", pl.readJsonl(pl.saidPath("alpha")).some((r) => r.text === "那就這樣說定了。"), JSON.stringify(pl.recentSaid("alpha", 2))); const guestThink = cli(["think", "--persona", "beta", "--session", S_SPEAK, "--as-guest", "--room", room2, "--text", "alpha 好像在試探我,先不要接這句"]); check("guest 的心裡話只回報數量", guestThink.status === 0 && guestThink.stdout.includes("心想") && !guestThink.stdout.includes("試探"), JSON.stringify(guestThink.stdout)); check("guest 的心裡話進自己的 inbox,不動自己的狀態", pl.readJsonl(pl.inboxPath("beta", room2)).some((r) => r.role === "inner") && pl.readJsonl(pl.innerPath("beta")).length === 0); check("guard:guest 可以 think / said(白名單)", ["think", "said"].every((sub) => guard({ session_id: S_SPEAK, agent_id: "guest-2", agent_type: "jsc-persona:persona-guest", tool_name: "Bash", tool_input: { command: `node persona.mjs ${sub} --persona beta --session ${S_SPEAK} --as-guest --text x` } }) === "pass")); cli(["leave", "--session", S_SPEAK, "--guest", "beta"]); console.log("⑫ 匯出 / 匯入"); const OUT = fs.mkdtempSync(path.join(os.tmpdir(), "persona-bundle-")); const bundleFile = path.join(OUT, "alpha.persona.json"); cli(["export", "--session", S_SPEAK, "--out", bundleFile]); check("匯出產生 bundle 檔", fs.existsSync(bundleFile)); const bundle = JSON.parse(fs.readFileSync(bundleFile, "utf8")); check("bundle 格式與 checksum 正確", bundle.format === pl.BUNDLE_FORMAT && pl.validateBundle(bundle).ok && pl.validateBundle(bundle).checksumOk); check("bundle 帶走身分 / 記憶 / 心智圖 / 關係圖", ["IDENTITY.md", "SOUL.md", "state/emotion.json", "memory/long-term/hates-morning-meetings.md", "memory/INDEX.md", "mindmap/semantic.mmd", "relations/graph.json"].every((f) => f in bundle.files)); check("bundle 不含載入鎖與 guest 租約", !("state/lock.json" in bundle.files) && !("state/guests.json" in bundle.files)); check("bundle 預設不含 journal(隱私)", !Object.keys(bundle.files).some((f) => f.startsWith("journal/"))); const gzFile = path.join(OUT, "alpha.persona.json.gz"); cli(["export", "--session", S_SPEAK, "--out", gzFile, "--with-journal", "--gzip"]); const gzBundle = JSON.parse(zlib.gunzipSync(fs.readFileSync(gzFile)).toString("utf8")); check("--gzip 可解回同一份,--with-journal 會帶 journal", gzBundle.persona === "alpha" && gzBundle.stats.with_journal === true && Object.keys(gzBundle.files).some((f) => f.startsWith("journal/"))); check("匯入既有人格未加 --force 會被擋", cli(["import", "--session", S_SPEAK, "--file", bundleFile], { expectOk: false }).status !== 0); cli(["import", "--session", S_SPEAK, "--file", bundleFile, "--persona", "alpha-copy"]); check("換名匯入成功", pl.personaExists("alpha-copy")); check("匯入後長期記憶與 INDEX 都在", fs.existsSync(path.join(pl.longTermDir("alpha-copy"), "hates-morning-meetings.md")) && fs.readFileSync(pl.indexPath("alpha-copy"), "utf8").includes("hates-morning-meetings")); check("匯入後身分與情緒都在", pl.identityBrief("alpha-copy").includes("Alpha") && pl.loadEmotion("alpha-copy").levels.joy > 0); check("匯入的人格不帶鎖(不會綁在原機器的程序上)", !pl.lockStatus("alpha-copy").locked); check("config 留下來歷", (pl.loadConfig("alpha-copy").imported_from || {}).persona === "alpha"); const evil = JSON.parse(JSON.stringify(bundle)); evil.files["../../../etc/persona-pwned"] = { encoding: "utf8", content: "x" }; const evilResult = pl.importBundle(evil, "alpha-evil"); check("bundle 內的 ../ 逃逸路徑會被拒絕", evilResult.rejected.includes("../../../etc/persona-pwned") && !fs.existsSync("/etc/persona-pwned")); const tampered = JSON.parse(JSON.stringify(bundle)); tampered.files["SOUL.md"] = { encoding: "utf8", content: "被改過的靈魂" }; const tamperedFile = path.join(OUT, "tampered.persona.json"); fs.writeFileSync(tamperedFile, JSON.stringify(tampered), "utf8"); check("checksum 不符會被擋(除非 --force)", cli(["import", "--session", S_SPEAK, "--file", tamperedFile, "--persona", "alpha-tampered"], { expectOk: false }).status !== 0); check("guard:不得匯出別的人格(跨人格外洩)", guard({ session_id: S_SPEAK, tool_name: "Bash", tool_input: { command: `node persona.mjs export --persona beta --session ${S_SPEAK} --out /tmp/b.json` } }) === "deny"); check("guard:匯入新人格允許(只寫新目錄,不讀別人)", guard({ session_id: S_SPEAK, tool_name: "Bash", tool_input: { command: `node persona.mjs import --persona gamma --session ${S_SPEAK} --file /tmp/b.json` } }) === "pass"); fs.rmSync(OUT, { recursive: true, force: true }); console.log("⑬ 人格編號與 Gitea 分區"); check("羅馬拼音正規化:只吃拉丁字母", gt.normalizeRomaji("Asuna") === "ASUNA" && gt.normalizeRomaji("shen yu") === "SHENYU" && gt.normalizeRomaji("亞絲娜") === null && gt.normalizeRomaji("") === null); check("編號格式:英文名全大寫 + 兩位索引", gt.validCode("ASUNA-01") && gt.validCode("SHENYU-12") && !gt.validCode("asuna-01") && !gt.validCode("ASUNA-1") && !gt.validCode("ASUNA")); check("建立人格時自動產生編號(alpha → ALPHA-01)", gt.personaCode("alpha") === "ALPHA-01", String(gt.personaCode("alpha"))); check("同名才遞增,不同名各自從 01 開始", gt.nextCode("Alpha") === "ALPHA-02" && gt.nextCode("Beta") === "BETA-02" && gt.nextCode("Lumi") === "LUMI-01", `${gt.nextCode("Alpha")} / ${gt.nextCode("Beta")} / ${gt.nextCode("Lumi")}`); const S_CODE = "sess-code-4444"; cli(["create", "--persona", "gamma", "--romaji", "Gamma", "--session", S_CODE, "--name", "Gamma", "--emoji", "🜂"]); check("編號寫進 config.json", pl.loadConfig("gamma").code === "GAMMA-01" && pl.loadConfig("gamma").romaji === "GAMMA"); const codeShow = cli(["code", "show", "--session", S_CODE, "--json"]); check("code show 回報編號", (() => { try { return JSON.parse(codeShow.stdout).code === "GAMMA-01"; } catch { return false; } })(), codeShow.stdout.slice(0, 120)); cli(["code", "assign", "--session", S_CODE, "--code", "GAMMA-01", "--rename", "--force"]); check("--rename 把目錄名改成編號", pl.personaExists("GAMMA-01") && !pl.personaExists("gamma")); check("改名後鎖與 session 綁定都跟著轉移", pl.lockStatus("GAMMA-01").locked && pl.loadSession(S_CODE).host === "GAMMA-01" && pl.loadConfig("GAMMA-01").persona === "GAMMA-01"); check("大寫編號目錄一樣受跨人格隔離保護", guard({ session_id: S_HOST, tool_name: "Read", tool_input: { file_path: `${H}/GAMMA-01/SOUL.md` } }) === "deny"); check("guard 認得大寫編號的 --persona(不會漏掉跨人格檢查)", guard({ session_id: S_CODE, tool_name: "Bash", tool_input: { command: `node persona.mjs recall --persona ALPHA-01 --session ${S_CODE} --query x` } }) === "deny"); cli(["icon", "generate", "--session", S_SPEAK, "--size", "64"]); // 讓分區檢查也涵蓋圖示 // 分區必須「不重不漏」:人格產生的每個檔案都要恰好屬於一區,否則同步會默默漏資料 const AREA_EXEMPT = new Set(["state/lock.json", "state/guests.json", "state/sync.json"]); const covered = (rel) => gt.AREA_KEYS.filter((key) => gt.AREAS[key].paths.some((p) => (p.endsWith("/") ? rel.startsWith(p) : rel === p))); const allFiles = []; (function walk(dir, base = "") { for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { if (entry.name.startsWith(".")) continue; const rel = base ? `${base}/${entry.name}` : entry.name; if (entry.isDirectory()) walk(path.join(dir, entry.name), rel); else allFiles.push(rel); } })(pl.personaDir("alpha")); const uncovered = allFiles.filter((f) => !AREA_EXEMPT.has(f) && covered(f).length !== 1); check("檔案區/Wiki 區的切分不重不漏(每個檔案恰好屬於一區)", uncovered.length === 0, uncovered.map((f) => `${f}→${covered(f).length}`).join(", ")); check("高頻資料在檔案區、低頻資料在 Wiki 區", covered("state/emotion.json")[0] === "files" && covered("memory/short-term.jsonl")[0] === "files" && covered("journal/2026-01.jsonl")[0] === "files" && covered("state/said.jsonl")[0] === "files" && covered("IDENTITY.md")[0] === "wiki" && covered("memory/long-term/x.md")[0] === "wiki" && covered("relations/graph.json")[0] === "wiki"); check("沒設定 Gitea 時同步只是略過,不會爆炸", (() => { const res = cli(["sync", "status", "--session", S_CODE]); const push = cli(["sync", "push", "--session", S_CODE], { expectOk: false }); return res.status === 0 && res.stdout.includes("Gitea") && push.status !== 0 && push.stderr.includes("尚未設定"); })()); check("匯出不會把 .sync 的 git clone 打包進去", (() => { fs.mkdirSync(path.join(pl.personaDir("GAMMA-01"), ".sync", "files"), { recursive: true }); fs.writeFileSync(path.join(pl.personaDir("GAMMA-01"), ".sync", "files", "junk.txt"), "x"); const { bundle: b } = pl.exportBundle("GAMMA-01"); return !Object.keys(b.files).some((f) => f.startsWith(".sync")); })()); console.log("⑭ 人格圖示(SVG + PNG,零外部依賴)"); const iconSvg = fs.readFileSync(ic.iconSvgPath("alpha"), "utf8"); const iconPng = fs.readFileSync(ic.iconPngPath("alpha")); check("兩種格式都產生了", ic.hasIcon("alpha")); check("PNG 檔頭合法且尺寸正確", (() => { const sig = iconPng.subarray(0, 8).toString("hex") === "89504e470d0a1a0a"; const type = iconPng.subarray(12, 16).toString("ascii") === "IHDR"; return sig && type && iconPng.readUInt32BE(16) === 64 && iconPng.readUInt32BE(20) === 64 && iconPng[24] === 8 && iconPng[25] === 6; // 8-bit RGBA })(), iconPng.subarray(0, 30).toString("hex")); check("PNG 以 IEND 結尾(chunk 完整)", iconPng.subarray(iconPng.length - 8, iconPng.length - 4).toString("ascii") === "IEND"); check("SVG 有 viewBox、漸層與字母方塊", iconSvg.includes(`viewBox="0 0 64 64"`) && iconSvg.includes("linearGradient") && (iconSvg.match(/ 10, iconSvg.slice(0, 80)); check("圖示是決定性的(同一個人格永遠同一張圖)", (() => { const a = ic.renderPng(ic.iconSpec("alpha"), 32); const b = ic.renderPng(ic.iconSpec("alpha"), 32); return Buffer.compare(a, b) === 0 && ic.renderSvg(ic.iconSpec("alpha"), 32) === ic.renderSvg(ic.iconSpec("alpha"), 32); })()); check("不同人格的圖示不一樣", Buffer.compare(ic.renderPng(ic.iconSpec("alpha"), 32), ic.renderPng(ic.iconSpec("GAMMA-01"), 32)) !== 0); check("字母取自編號(ALPHA-01 → AL)", ic.iconSpec("alpha").letters === "AL", ic.iconSpec("alpha").letters); check("已有圖示時不加 --force 會被擋", cli(["icon", "generate", "--session", S_SPEAK], { expectOk: false }).status !== 0); check("--force 可以重畫", cli(["icon", "generate", "--session", S_SPEAK, "--size", "64", "--force"]).status === 0); check("圖示屬於 Wiki 區(低頻的身分資料)", covered("icon.svg")[0] === "wiki" && covered("icon.png")[0] === "wiki"); check("匯出會用 base64 帶走 PNG(二進位不會壞掉)", (() => { const { bundle: b } = pl.exportBundle("alpha"); const entry = b.files["icon.png"]; return entry?.encoding === "base64" && Buffer.compare(Buffer.from(entry.content, "base64"), iconPng) === 0; })()); check("guest 只能看不能重畫圖示", pl.GUEST_SAFE_SUBCOMMANDS.has("icon") === false || guard({ session_id: S_HOST, agent_id: "guest-9", agent_type: "jsc-persona:persona-guest", tool_name: "Bash", tool_input: { command: `node persona.mjs icon generate --persona beta --session ${S_HOST} --as-guest` } }) === "deny"); console.log("⑮ 依參考照片配色的圖示"); const PAL = "hair=#d9a45b,eye=#9e5b3e,accent=#c0392b,secondary=#e77a8e,light=#f2ebe3"; const SRC = "https://example.invalid/key-visual.png"; check("調色盤解析:hair 與 accent 必填、支援 #abc 縮寫", (() => { const ok = ic.parsePalette(PAL); const short = ic.parsePalette("hair=#abc,accent=#123456"); return ok?.hair?.join() === "217,164,91" && ok.eye.join() === "158,91,62" && short?.hair?.join() === "170,187,204" && ic.parsePalette("eye=#ffffff") === null && ic.parsePalette("garbage") === null; })()); check("`--palette` 沒帶 `--source-url` 會被擋(配色要有出處)", cli(["icon", "generate", "--session", S_CODE, "--force", "--palette", PAL], { expectOk: false }).status !== 0); check("格式錯誤的 `--palette` 會被擋", cli(["icon", "generate", "--session", S_CODE, "--force", "--palette", "hair=紅色", "--source-url", SRC], { expectOk: false }).status !== 0); cli(["icon", "generate", "--session", S_CODE, "--force", "--size", "64", "--palette", PAL, "--source-url", SRC, "--source-note", "測試用主視覺", "--source-date", "2026-07-30"]); const palSpec = ic.iconSpec("GAMMA-01"); check("圖示改用照片配色(瞳色當外框、亮色當紋路)", palSpec.palette !== null && palSpec.ring.join() === "158,91,62" && palSpec.dot.join() === "242,235,227"); check("來源網址與說明寫進 config(可查證)", (() => { const icon = pl.loadConfig("GAMMA-01").icon || {}; return icon.palette.startsWith(PAL) && icon.source?.url === SRC && icon.source?.note === "測試用主視覺" && icon.source?.date === "2026-07-30"; })(), JSON.stringify(pl.loadConfig("GAMMA-01").icon)); check("照片配色與雜湊配色畫出來不一樣", (() => { const withPal = ic.renderPng(palSpec, 32); const hashOnly = ic.renderPng({ ...palSpec, palette: null, c1: [40, 120, 184], c2: [200, 102, 214], ring: palSpec.ink, dot: palSpec.ink, ringAlpha: 0.18, dotAlpha: 0.14 }, 32); return Buffer.compare(withPal, hashOnly) !== 0; })()); check("不帶 --palette 重畫會沿用已存的配色(不會變回雜湊色)", (() => { cli(["icon", "generate", "--session", S_CODE, "--force", "--size", "64"]); const again = ic.iconSpec("GAMMA-01"); return again.palette !== null && ic.paletteToString(again.palette).startsWith(PAL); })()); check("一深一淺的極端配色仍保證字讀得到(會收斂色階)", (() => { // 藍黑髮 + 淡粉洋裝:不收斂的話不論黑字白字都會有一端糊掉 const spec = ic.iconSpec("GAMMA-01", { palette: ic.parsePalette("hair=#1b1b22,eye=#6b4a2f,accent=#f2b6cb,secondary=#4a7bc8,light=#fbeff3"), }); const lum = (c) => { const f = c.map((v) => (v / 255 <= 0.03928 ? v / 255 / 12.92 : ((v / 255 + 0.055) / 1.055) ** 2.4)); return 0.2126 * f[0] + 0.7152 * f[1] + 0.0722 * f[2]; }; const ratio = (a, b) => (Math.max(lum(a), lum(b)) + 0.05) / (Math.min(lum(a), lum(b)) + 0.05); return Math.min(ratio(spec.ink, spec.c1), ratio(spec.ink, spec.c2)) >= 3; })()); console.log("⑯ 人物形象圖(有臉)與照片裁臉工具"); const facePal = ic.parsePalette("hair=#1b1b22,eye=#6b4a2f,accent=#f2b6cb,secondary=#4a7bc8,light=#fbeff3"); const faceSpec = ic.iconSpec("GAMMA-01", { palette: facePal, style: "portrait" }); check("有調色盤時預設畫「人物形象」而不是徽章", ic.iconSpec("GAMMA-01", { palette: facePal }).style === "portrait" && ic.iconSpec("alpha").style !== "portrait", ic.iconSpec("alpha").style); check("形象圖真的畫了五官(眼白/虹膜/瞳孔/嘴都在)", (() => { const shapes = ic.iconShapes(faceSpec); const eyeWhite = shapes.filter((sh) => sh.fill.join() === "252,252,255").length; const iris = shapes.filter((sh) => sh.fill.join() === facePal.eye.join()).length; const highlight = shapes.filter((sh) => sh.fill.join() === "255,255,255").length; return shapes.length >= 20 && eyeWhite === 2 && iris === 2 && highlight === 2; })(), `圖形數 ${ic.iconShapes(faceSpec).length}`); check("形象圖用的是照片配色(髮色當底、瞳色當眼睛)", faceSpec.c1.join() === facePal.hair.join() || faceSpec.c2.join() !== faceSpec.c1.join()); check("SVG 與 PNG 出自同一份圖形清單(SVG 有對應數量的 ellipse)", (() => { const svg = ic.renderSvg(faceSpec, 64); const shapes = ic.iconShapes(faceSpec); const ellipses = (svg.match(/ sh.type === "ellipse").length && rects >= shapes.filter((sh) => sh.type === "rect").length; })()); check("--style 可以強制畫回徽章", ic.iconSpec("GAMMA-01", { palette: facePal, style: "badge" }).style === "badge"); const tools = ic.toolReport(); check("工具偵測會回報缺什麼與怎麼裝", typeof tools.ready === "boolean" && Array.isArray(tools.missing) && tools.missing.every((m) => m.what && m.why && m.how)); check("缺工具時的提示含安裝指令", (() => { const fake = { missing: [{ what: "Pillow", why: "解碼照片", how: "pip install pillow" }] }; const lines = ic.installHintLines(fake); return lines.length >= 3 && lines.join("\n").includes("pip install pillow"); })()); check("工具齊全時提示為空", ic.installHintLines({ missing: [] }).length === 0); check("`icon faces` 需要 --photo", cli(["icon", "faces", "--session", S_CODE], { expectOk: false }).status !== 0); check("形象圖同步到 Wiki 區(svg 與 png 都在)", covered("icon.svg")[0] === "wiki" && covered("icon.png")[0] === "wiki"); check("Wiki 有專頁保存形象圖,並寫明來源", (() => { const page = gt.wikiIconPage("GAMMA-01", "GAMMA-01"); return page.includes("形象圖") && page.includes("icon.svg") && page.includes("icon.png") && page.includes("參考來源") && page.includes("https://example.invalid/key-visual.png"); })(), gt.wikiIconPage("GAMMA-01", "GAMMA-01").slice(0, 120)); check("Wiki 的保留檔不會被同步流程刪掉", ["Home.md", "Icon.md"].every((f) => gt.wikiHome && typeof gt.wikiIconPage === "function")); console.log("⑰ 依人格資料重繪(不直接使用網路圖)與 Wiki 同步驗證"); check("特徵解析:只吃認得的值,其餘回退預設", (() => { const f = ic.parseFeatures("hairstyle=twintails,eyes=round,accessory=flower,bogus=x,expression=???"); return f.hairstyle === "twintails" && f.eyes === "round" && f.accessory === "flower" && f.expression === ic.DEFAULT_FEATURES.expression && !("bogus" in f); })(), JSON.stringify(ic.parseFeatures("hairstyle=twintails,bogus=x"))); const basePal = ic.parsePalette("hair=#d9a45b,eye=#9e5b3e,accent=#c0392b"); const draw = (feat) => ic.renderPng(ic.iconSpec("GAMMA-01", { palette: basePal, style: "portrait", features: ic.parseFeatures(feat) }), 32); check("換髮型會畫出不同的圖", Buffer.compare(draw("hairstyle=straight"), draw("hairstyle=twintails")) !== 0); check("換眼型會畫出不同的圖", Buffer.compare(draw("eyes=round"), draw("eyes=sharp")) !== 0); check("換表情會畫出不同的圖", Buffer.compare(draw("expression=calm"), draw("expression=bright")) !== 0); check("加髮飾/呆毛會多出圖形", (() => { const plain = ic.iconShapes(ic.iconSpec("GAMMA-01", { palette: basePal, style: "portrait", features: ic.parseFeatures("accessory=none,ahoge=no") })); const fancy = ic.iconShapes(ic.iconSpec("GAMMA-01", { palette: basePal, style: "portrait", features: ic.parseFeatures("accessory=flower,ahoge=yes") })); return fancy.length > plain.length + 3; })()); check("多邊形在 SVG 與柵格器都畫得出來(呆毛用的是 polygon)", (() => { const spec = ic.iconSpec("GAMMA-01", { palette: basePal, style: "portrait", features: ic.parseFeatures("ahoge=yes") }); const shapes = ic.iconShapes(spec); const polys = shapes.filter((sh) => sh.type === "poly").length; const svg = ic.renderSvg(spec, 64); return polys > 0 && (svg.match(/ { const cfg = pl.loadConfig("GAMMA-01"); cfg.icon = { ...(cfg.icon || {}), style: "photo" }; // 舊版本寫進去、現已移除的樣式 pl.writeJson(pl.configPath("GAMMA-01"), cfg); return ic.iconSpec("GAMMA-01").style === "portrait"; })(), ic.iconSpec("GAMMA-01").style); check("`icon headshot` 需要 --photo", cli(["icon", "headshot", "--session", S_CODE], { expectOk: false }).status !== 0); check("重繪不會把來源圖塞進圖示(SVG 沒有內嵌影像)", (() => { const svg = fs.readFileSync(ic.iconSvgPath("GAMMA-01"), "utf8"); // xmlns 本來就有 http,所以只看「有沒有內嵌影像」 return !svg.includes(" { cli(["icon", "generate", "--session", S_CODE, "--force", "--size", "64", "--features", "hairstyle=twintails,accessory=ribbon"]); const feat = pl.loadConfig("GAMMA-01").icon?.features || ""; return feat.includes("hairstyle=twintails") && feat.includes("accessory=ribbon"); })(), pl.loadConfig("GAMMA-01").icon?.features); check("沒設定 Gitea 時 sync verify 是「略過」不是崩潰", (() => { const res = cli(["sync", "verify", "--session", S_CODE], { expectOk: false }); return res.stderr.includes("尚未設定") || res.stdout.includes("略過"); })()); check("Wiki 形象圖頁同時列出 SVG 與 PNG", (() => { const page = gt.wikiIconPage("GAMMA-01", "GAMMA-01"); return page.includes("icon.svg") && page.includes("icon.png") && page.includes("SVG") && page.includes("PNG"); })()); check("Wiki 首頁不含每次都變的時間戳(否則永遠驗不過)", (() => { const a = gt.wikiHome("GAMMA-01", "GAMMA-01"); const b = gt.wikiHome("GAMMA-01", "GAMMA-01"); return a === b; })()); console.log(`\n${"=".repeat(60)}\n通過 ${passed} 項,失敗 ${failed} 項 → ${failed === 0 ? "全部通過 ✅" : "有測試失敗 ❌"}`); console.log(`(暫存倉庫留在 ${STORE},可自行刪除)`); process.exit(failed ? 1 : 0);