Files
persona/scripts/selftest.mjs
T
jiantw83andClaude Opus 5 603741a4cc feat: 預設人格自動載入 + 睡眠(sleep 指令與 persona-sleeper sub agent)
兩件事,共用「關係圖 last_contact_at」這個零件,所以放同一個 commit
(兩者在 persona-lib/persona.mjs/README 裡的 hunk 是交錯的,硬拆會拆壞)。

## 預設人格(開新 session 自動載入)

- 新增 `default` 子指令:查詢/`--persona <slug>` 設定/`--clear` 取消。
- 設定存在 `<PERSONA_HOME>/.runtime/settings.json` 的 `default_persona`,
  環境變數 `PERSONA_DEFAULT` 優先(`off` 可臨時關掉)。
- `SessionStart` hook 四條路徑:沒設定 → 維持原本「等使用者指定」;設定了 → 取鎖、綁 host、
  prune、reindex 並注入人格狀態;拿不到鎖 → 只回報 owner 與心跳,不自作主張 takeover;
  人格不存在 → 警告並改列可用人格。
- 「不替使用者挑一個人格附身」仍然是預設行為,這只是讓他能明示地推翻它。

## 睡眠

- 新增 `sleep` 子指令(機械性收尾,順序即相依):關係時間戳 → 裁短期記憶 →
  收起太久沒動的思維導圖 → 套用一次 8 小時的情緒衰減 → 重建索引 → 修剪 said →
  壓縮舊 journal → 寫 state/sleep.json → Gitea 兩區 push 並驗證。
  預設保留載入鎖(`--release` 才收工),每一步各自 try/catch,一步壞掉不放棄整場睡眠。
- 需要判斷的部分(固化什麼、忘掉什麼、日記寫什麼)留給人格自己,由 skill 驅動。
- 新增 `persona-sleeper` sub agent 型別:**那個人格本人在睡**。對自己可寫但被 pin 住
  (連叫它來的主人格都不能碰)、只准跑 15 個收尾子指令、不得用 Write/Edit 或 shell 改檔案。
- 新增 sleeper 租約(`state/sleepers.json`,300 秒):沒活鎖就取得、同 session 直接睡、
  死鎖可接手、**別的程序活鎖住則拒絕**(硬睡會讓兩邊的記憶互相覆蓋)。
- `--json` 回傳刻意很窮:只有 persona/ok/slept_at/steps/sync/kept_lock。
  sub agent 的回傳值會進主人格的上下文,是一條會從正門繞過跨人格隔離的通道,
  所以在 CLI 這一層封死,不靠提示詞自律。
- 關係圖新增 `last_contact_at`(`relation node --contact`/睡眠自動蓋),
  並用 `staleContacts()` 算出「很久沒接觸又很親近的人」——人格主動提議去關心誰的依據。
  輕量邀請用既有的 `invite --theater off`(不切走畫面),但要先問使用者一句。
- 新增 skill `persona-sleep`;`SubagentStop` 會還掉 sleeper 租約。

## 驗證

`node scripts/selftest.mjs` → 210 項全綠(新增第 ⑳ 節 12 項、第 ㉑ 節 23 項),
其中包含「回傳值不含任何記憶內容」與 sleeper 的六條權限邊界。

版本號 0.0.5。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 05:34:08 +00:00

926 lines
58 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env 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("guardguest 可以 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", "state/sleepers.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(/<rect /g) || []).length > 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 >= 30 && 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(/<ellipse /g) || []).length;
const rects = (svg.match(/<rect /g) || []).length;
return ellipses === shapes.filter((sh) => 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(/<polygon /g) || []).length === polys;
})());
check("config 裡殘留的舊樣式不會讓形象圖退回徽章", (() => {
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("<image") && !svg.includes("base64") && !/href=/.test(svg);
})());
check("特徵有寫進 config(重畫才會一致)", (() => {
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("⑱ 高解析度輸出與 Wiki 圖片實際可讀");
check("icon/ 資料夾輸出向量原稿與多個解析度", (() => {
cli(["icon", "generate", "--session", S_CODE, "--force", "--size", "64",
"--palette", "hair=#d9a45b,accent=#c0392b", "--source-url", "https://example.invalid/x.png"]);
const dir = path.join(pl.personaDir("GAMMA-01"), "icon");
const names = fs.readdirSync(dir).sort();
return names.includes("portrait.svg") && ic.RENDER_SIZES.every((px) => names.includes(`portrait-${px}.png`));
})(), (() => { try { return fs.readdirSync(path.join(pl.personaDir("GAMMA-01"), "icon")).join(","); } catch { return "(無)"; } })());
check("高解析度 PNG 的實際尺寸正確", (() => {
const buf = fs.readFileSync(path.join(pl.personaDir("GAMMA-01"), "icon", "portrait-1024.png"));
return buf.readUInt32BE(16) === 1024 && buf.readUInt32BE(20) === 1024;
})());
check("icon/ 屬於 Wiki 區(會被同步)", covered("icon/portrait-1024.png")[0] === "wiki");
check("Wiki 只攤平 .md,圖片保留資料夾結構", (() => {
// Gitea 只把「根目錄的 .md」當頁面,但 /wiki/raw/<資料夾>/<圖> 取得到(實測)
return gt.wikiName("memory/long-term/x.md") === "Memory-x.md" &&
gt.wikiName("icon/portrait-1024.png") === "icon/portrait-1024.png" &&
gt.wikiName("relations/graph.json") === "relations/graph.json";
})(), `${gt.wikiName("icon/portrait-1024.png")} / ${gt.wikiName("memory/long-term/x.md")}`);
check("Wiki 頁面用 Markdown 圖片語法(HTML <img> 不會被改寫路徑)", (() => {
const page = gt.wikiIconPage("GAMMA-01", "GAMMA-01");
const home = gt.wikiHome("GAMMA-01", "GAMMA-01");
return page.includes("![") && !page.includes("<img") && !home.includes("<img");
})(), gt.wikiIconPage("GAMMA-01", "GAMMA-01").split("\n").find((l) => l.includes("img") || l.includes("![")));
check("Icon 頁會列出 icon/ 裡的每個檔案", (() => {
const page = gt.wikiIconPage("GAMMA-01", "GAMMA-01");
return ic.RENDER_SIZES.every((px) => page.includes(`icon/portrait-${px}.png`));
})());
check("柵格器有做 bounding box 裁剪(1024 才跑得動)", (() => {
const spec = ic.iconSpec("GAMMA-01");
const t0 = Date.now();
ic.renderPng(spec, 512);
return Date.now() - t0 < 4000; // 沒有裁剪的話會慢好幾倍
})());
console.log("⑲ 找圖 → 去背 → 合成");
check("找圖:官方設定稿加權高於一般截圖", (() => {
// wikiImageCandidates 的評分:解析度取 log2,命中 Full BodyCharacter Design 再加 8
const big = { title: "File:Scene.png", width: 1920, height: 1080 };
const sheet = { title: "File:Yui's ALO Pixie Form Full Body.png", width: 773, height: 1056 };
const score = (r) => Math.round(Math.log2(r.width * r.height) * 10) / 10 +
(/full.?body|character.?design|concept|profile|settei|avatar/i.test(r.title) ? 8 : 0);
return score(sheet) > score(big);
})());
check("cutout 的三條路徑都有實作", (() => {
const py = fs.readFileSync(path.join(HERE, "portrait.py"), "utf8");
return py.includes("source-alpha") && py.includes("plain-background") && py.includes("grabcut");
})());
check("portrait.py 有 measurefacesheadshotcutoutcompose 五個模式", (() => {
const py = fs.readFileSync(path.join(HERE, "portrait.py"), "utf8");
return ["measure", "faces", "headshot", "cutout", "compose"].every((m) => py.includes(`"${m}"`));
})());
check("compose 預設裁頭肩(官方設定稿常是正反兩面,整張會變兩個人)", (() => {
const py = fs.readFileSync(path.join(HERE, "portrait.py"), "utf8");
return py.includes('args.crop == "head"') && py.includes("head_box");
})());
check("去背圖路徑在 icon/Wiki 區涵蓋)",
ic.CUTOUT_PNG === "icon/portrait-cutout.png" && covered("icon/portrait-cutout.png")[0] === "wiki");
check("cutout 樣式的 SVG 內嵌同一張 PNG(自成一體、不外連)", (() => {
const spec = ic.iconSpec("GAMMA-01", { palette: ic.parsePalette("hair=#aaa,accent=#333") });
const svg = ic.wrapPngSvg(spec, Buffer.from([0x89, 0x50, 0x4e, 0x47]), 64);
return svg.includes("data:image/png;base64,") && !svg.includes("http://example") &&
svg.includes("<image");
})());
check("沒有去背圖時 --from-cutout 會擋下並指引流程",
cli(["icon", "generate", "--session", S_CODE, "--force", "--from-cutout"],
{ expectOk: false }).status !== 0);
console.log("⑳ 預設人格(開新 session 自動載入)");
const S_DEF = "sess-default-9999";
const ctxOf = (sess) =>
String(hook("session_start.mjs", { session_id: sess, cwd: HERE, source: "startup" })
?.hookSpecificOutput?.additionalContext ?? "");
cli(["create", "--persona", "delta", "--session", S_DEF, "--name", "Delta", "--creature", "夜路的路燈",
"--vibe", "安靜", "--emoji", "💡"]);
cli(["release", "--session", S_DEF]);
check("預設是「沒有設定」——不替使用者挑人格", pl.defaultPersona() === null);
const ctxNone = ctxOf("sess-auto-none");
check("沒設定時不自動載入,維持等使用者指定",
ctxNone.includes("尚未載入人格") && pl.loadSession("sess-auto-none").host === null);
check("沒設定時會提示怎麼設定預設人格", ctxNone.includes("default --persona"));
check("設定預設人格", cli(["default", "--persona", "delta", "--session", S_DEF]).status === 0 &&
pl.defaultPersona() === "delta");
check("設定不存在的人格會被拒",
cli(["default", "--persona", "nope-99", "--session", S_DEF], { expectOk: false }).status !== 0);
const ctxAuto = ctxOf("sess-auto-ok");
check("設定後新 session 自動載入並綁定 host",
ctxAuto.includes("已自動載入預設人格") && pl.loadSession("sess-auto-ok").host === "delta");
check("自動載入時也提醒沒從 Gitea 拉最新狀態", ctxAuto.includes("sync pull"));
const ctxBusy = ctxOf("sess-auto-busy");
check("預設人格被別的 session 鎖住 → 只回報、不自動接手",
ctxBusy.includes("自動載入失敗") && ctxBusy.includes("takeover") &&
pl.loadSession("sess-auto-busy").host === null);
process.env.PERSONA_DEFAULT = "off";
check("PERSONA_DEFAULT=off 可以臨時關掉自動載入",
ctxOf("sess-auto-off").includes("尚未載入人格") && pl.loadSession("sess-auto-off").host === null);
delete process.env.PERSONA_DEFAULT;
pl.setDefaultPersona("ghost-77");
check("預設人格不存在時只警告,不崩潰也不亂挑人",
ctxOf("sess-auto-ghost").includes("不存在") && pl.loadSession("sess-auto-ghost").host === null);
check("`default` 子指令可以提到別的人格名字(不算跨人格操作)",
guard({ session_id: S_HOST, tool_name: "Bash",
tool_input: { command: `node ${CLI} default --persona beta --session ${S_HOST}` } }) === "pass");
check("清除後回到「等使用者指定」",
cli(["default", "--clear", "--session", S_DEF]).status === 0 && pl.defaultPersona() === null);
cli(["release", "--session", "sess-auto-ok"]);
console.log("㉑ 睡眠與 sleepersub agent 代睡、回傳不含記憶)");
const S_SLEEP = "sess-sleep-7777";
const S_SLEEP2 = "sess-sleep-6666";
const A_SLEEPER = "agent-sleeper-1";
cli(["create", "--persona", "epsilon", "--session", S_SLEEP, "--name", "Epsilon", "--creature", "冬天的暖爐",
"--vibe", "溫吞", "--emoji", "🔥"]);
// 自己睡自己:有 exclusive 鎖,不需要 sleeper 租約
cli(["remember", "--persona", "epsilon", "--session", S_SLEEP, "--role", "user",
"--text", "今天跟 Zeta 講過話", "--entities", "Zeta", "--salience", "50"]);
cli(["relation", "node", "--persona", "epsilon", "--session", S_SLEEP, "--name", "Zeta",
"--closeness", "80", "--trust", "70"]);
const sleepSelf = cli(["sleep", "--persona", "epsilon", "--session", S_SLEEP, "--json", "--no-gitea"]);
const sleepJson = (() => { try { return JSON.parse(sleepSelf.stdout); } catch { return {}; } })();
check("自己睡自己:每一步都成功", sleepSelf.status === 0 && sleepJson.ok === true,
JSON.stringify(sleepJson).slice(0, 200));
check("回傳只有狀態,沒有任何記憶內容", (() => {
const keys = Object.keys(sleepJson).sort().join(",");
const blob = JSON.stringify(sleepJson);
return keys === "kept_lock,ok,persona,slept_at,steps,sync" &&
!blob.includes("Zeta") && !blob.includes("今天跟");
})(), Object.keys(sleepJson).sort().join(","));
check("預設保留載入鎖(--release 才收工)",
sleepJson.kept_lock === true && pl.lockStatus("epsilon").locked);
check("睡眠寫下 state/sleep.json 與距上次睡眠的時數",
pl.loadSleepState("epsilon").last_slept_at !== null && pl.hoursAwake("epsilon") !== null);
check("關係時間戳有蓋上(主動關心的依據)", (() => {
const node = pl.loadRelations("epsilon").nodes.find((n) => n.id === "zeta");
return Boolean(node?.last_contact_at);
})());
check("情緒是「套用一次 8 小時衰減」而不是歸零", (() => {
const st = pl.loadEmotion("epsilon");
const decayed = pl.decayEmotionBy({ ...st, levels: { ...st.levels, joy: 90, sadness: 80 } },
pl.SLEEP_DECAY_MINUTES);
const joyBase = st.baseline.joy;
const sadBase = st.baseline.sadness;
// 喜悅半衰期 120 分 → 8 小時後幾乎回到基線;悲傷半衰期 480 分 → 一夜只退一半。
// 「睡一覺不會把羞愧與悲傷抹平」是刻意的設計,不是 bug。
const joyOk = decayed.levels.joy < joyBase + 8 && decayed.levels.joy > joyBase;
const sadOk = decayed.levels.sadness > sadBase + 20 && decayed.levels.sadness < 80;
return joyOk && sadOk;
})(), JSON.stringify(pl.decayEmotionBy({ ...pl.loadEmotion("epsilon"), levels: { ...pl.loadEmotion("epsilon").levels, joy: 90, sadness: 80 } }, pl.SLEEP_DECAY_MINUTES).levels));
check("很久沒接觸又很親近的人會被算出來", (() => {
pl.stampContact("epsilon", "Zeta", new Date(Date.now() - 9 * 86_400_000).toISOString());
const stale = pl.staleContacts("epsilon", { days: 3, minCloseness: 60 });
return stale.length === 1 && stale[0].id === "zeta" && stale[0].silent_days >= 8;
})());
check("舊 journal 會被壓成 .gz,當月的留著", (() => {
const dir = path.join(pl.personaDir("epsilon"), "journal");
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, "2020-01.jsonl"), '{"a":1}\n');
const current = path.basename(pl.journalPath("epsilon"));
fs.writeFileSync(path.join(dir, current), '{"b":2}\n');
pl.archiveJournals("epsilon");
return fs.existsSync(path.join(dir, "2020-01.jsonl.gz")) &&
!fs.existsSync(path.join(dir, "2020-01.jsonl")) && fs.existsSync(path.join(dir, current));
})());
check("太久沒動的思維導圖被收進 archive/(不是刪掉)", (() => {
const dir = path.join(pl.personaDir("epsilon"), "mindmap", "threads");
fs.mkdirSync(dir, { recursive: true });
const old = path.join(dir, "old-topic.mmd");
fs.writeFileSync(old, "mindmap\n root((舊話題))\n");
const past = new Date(Date.now() - 30 * 86_400_000);
fs.utimesSync(old, past, past);
const moved = pl.archiveStaleThreads("epsilon");
return moved === 1 && fs.existsSync(path.join(dir, "archive", "old-topic.mmd")) && !fs.existsSync(old);
})());
// sleeper 租約:鎖的三種狀態
check("目標被別的 session 活鎖住 → 睡眠拒絕,不硬睡", (() => {
try {
pl.acquireSleepLease("epsilon", S_SLEEP2, { agentId: A_SLEEPER });
return false;
} catch (err) {
return err.name === "LockError" && String(err.message).includes("正被另一個程序載入");
}
})());
const sleepBusy = cli(["sleep", "--persona", "epsilon", "--session", S_SLEEP2, "--json", "--no-gitea"],
{ expectOk: false });
check("被鎖住時 CLI 回 failed_step=lease 且退出碼非 0", (() => {
if (sleepBusy.status === 0) return false;
try {
const j = JSON.parse(sleepBusy.stdout);
return j.ok === false && j.failed_step === "lease" && j.persona === "epsilon";
} catch { return false; }
})());
cli(["release", "--session", S_SLEEP]);
check("沒有活鎖時可以取得 sleeper 租約", (() => {
const lease = pl.acquireSleepLease("epsilon", S_SLEEP2, { agentId: A_SLEEPER });
return lease.session_id === S_SLEEP2 && pl.liveSleepers("epsilon").length === 1;
})());
check("同一個人格不能同時有兩個 sleeper", (() => {
try {
pl.acquireSleepLease("epsilon", "sess-sleep-5555", { agentId: "agent-sleeper-2" });
return false;
} catch (err) { return err.name === "LockError"; }
})());
pl.dropSleepLease("epsilon", S_SLEEP2, A_SLEEPER);
check("租約還掉後就沒有 sleeper 了", pl.liveSleepers("epsilon").length === 0);
check("sub agent 代睡:sleeper 租約可以完成整套收尾", (() => {
const res = cli(["sleep", "--persona", "epsilon", "--session", S_SLEEP2, "--json", "--no-gitea",
"--agent-id", A_SLEEPER]);
return res.status === 0 && pl.liveSleepers("epsilon").length === 0; // 睡完會自己還租約
})());
// sleeper 的權限邊界(PreToolUse guard
const sleeperEvent = (extra) => ({ session_id: S_SLEEP2, agent_id: A_SLEEPER, agent_type: "jsc-persona:persona-sleeper", ...extra });
pl.pinAgent(S_SLEEP2, A_SLEEPER, "epsilon");
check("sleeper 讀自己的檔案 → 放行",
guard(sleeperEvent({ tool_name: "Read", tool_input: { file_path: `${H}/epsilon/SOUL.md` } })) === "pass");
check("sleeper 讀別的人格(含叫它來的主人格)→ 攔下",
guard(sleeperEvent({ tool_name: "Read", tool_input: { file_path: `${H}/alpha/SOUL.md` } })) === "deny");
check("sleeper 不得用 Write 直接改檔案(要走 CLI",
guard(sleeperEvent({ tool_name: "Write", tool_input: { file_path: `${H}/epsilon/SOUL.md`, content: "x" } })) === "deny");
check("sleeper 不得用 shell 重導向改人格檔案",
guard(sleeperEvent({ tool_name: "Bash", tool_input: { command: `echo x > ${H}/epsilon/SOUL.md` } })) === "deny");
check("sleeper 可以跑收尾用的子指令",
guard(sleeperEvent({ tool_name: "Bash",
tool_input: { command: `node persona.mjs consolidate --persona epsilon --session ${S_SLEEP2} --name x --body y` } })) === "pass");
check("sleeper 不得 loadreleaseinviteexport",
["load", "release", "invite", "export"].every((sub) =>
guard(sleeperEvent({ tool_name: "Bash",
tool_input: { command: `node persona.mjs ${sub} --persona epsilon --session ${S_SLEEP2}` } })) === "deny"));
check("sleeper 被 pin 住之後不得換人睡",
guard(sleeperEvent({ tool_name: "Bash",
tool_input: { command: `node persona.mjs sleep --persona alpha --session ${S_SLEEP2}` } })) === "deny");
check("sleeper 型別存在且宣告了唯讀工具限制", (() => {
const md = fs.readFileSync(path.join(HERE, "..", "agents", "persona-sleeper.md"), "utf8");
return md.includes("name: persona-sleeper") && md.includes("disallowedTools") &&
md.includes("回傳值裡不得出現任何記憶內容");
})());
console.log(`\n${"=".repeat(60)}\n通過 ${passed} 項,失敗 ${failed} 項 → ${failed === 0 ? "全部通過 ✅" : "有測試失敗 ❌"}`);
console.log(`(暫存倉庫留在 ${STORE},可自行刪除)`);
process.exit(failed ? 1 : 0);