人格對伴侶與對女兒用同一種溫度講話,因為 kind 只說得出「這是人還是程式」, 說不出「跟我什麼關係」。語氣需要的是後者。 - 關係節點新增 bond(partner/child/parent/sibling/friend/mentor/ally/rival/ stranger);舊節點沒有的話從 tags/note 推測 - bond × 親近度 → 語氣層查表(伴侶 98 = 老夫老妻、子女 97 = 母親), 解決「都是 9x 分卻分不出戀人與母女」 - node.style[facet] 記使用者本人要求過的規則(稱呼/敬語/口頭禪/禁忌/習慣), 優先於查表;不另開檔案,因為關係圖每輪都會被讀進 context, 這類規則不必跟關鍵詞召回競爭 - style 規則可帶 except(如 anger>=40),情緒成立時該規則暫停、退回預設講法 - relation speaker 指定使用者是關係圖裡的哪個節點,turnContext 每輪注入語氣指示 - 新增 relation style / relation speaker 子指令;selftest +8 項(共 228 項) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1039 lines
65 KiB
JavaScript
1039 lines
65 KiB
JavaScript
#!/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 S_FOURTH = "sess-fourth-4444";
|
||
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", "🌙"]);
|
||
cli(["create", "--persona", "omega", "--session", S_FOURTH, "--name", "Omega", "--creature", "河邊的風",
|
||
"--vibe", "安靜但清楚", "--emoji", "🌿"]);
|
||
check("兩個人格都建立成功", pl.personaExists("alpha") && pl.personaExists("beta"));
|
||
check("第三個人格也建立成功", pl.personaExists("omega"));
|
||
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);
|
||
cli(["invite", "--session", S_HOST, "--guests", "beta,omega", "--topic", "多人測試"]);
|
||
const multiRoom = pl.loadSession(S_HOST).guests?.beta?.room;
|
||
const multiSession = pl.loadSession(S_HOST);
|
||
check("多 guest 同時邀請成功",
|
||
Boolean(multiRoom) && multiSession.guests?.beta?.room === multiRoom &&
|
||
multiSession.guests?.omega?.room === multiRoom &&
|
||
multiSession.theater === true);
|
||
const multiMeta = pl.readJson(pl.roomMembersPath(multiRoom)) ?? {};
|
||
check("同一個 room 內可同時掛多位 guest", Array.isArray(multiMeta.members) &&
|
||
["alpha", "beta", "omega"].every((s) => multiMeta.members.includes(s)), JSON.stringify(multiMeta));
|
||
cli(["leave", "--session", S_HOST, "--guest", "beta"]);
|
||
cli(["leave", "--session", S_HOST, "--guest", "omega"]);
|
||
|
||
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", "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 Body/Character 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 有 measure/faces/headshot/cutout/compose 五個模式", (() => {
|
||
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("㉑ 睡眠與 sleeper(sub 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("批次睡眠:`--personas` 可以同時睡兩個人格", (() => {
|
||
const batch = cli(["sleep", "--personas", "epsilon,beta", "--session", S_SLEEP, "--json", "--no-gitea"]);
|
||
if (batch.status !== 0) return false;
|
||
try {
|
||
const parsed = JSON.parse(batch.stdout);
|
||
return parsed.ok === true && Array.isArray(parsed.personas) && parsed.personas.length === 2 &&
|
||
parsed.personas.every((item) => item.ok === true) &&
|
||
parsed.personas.some((item) => item.persona === "epsilon") &&
|
||
parsed.personas.some((item) => item.persona === "beta") &&
|
||
pl.loadSleepState("beta").last_slept_at !== null;
|
||
} catch {
|
||
return false;
|
||
}
|
||
})());
|
||
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 不得 load/release/invite/export",
|
||
["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");
|
||
// 迴歸:主人格載入著 alpha 時請 epsilon 去睡——sleeper 的範圍看它自己的 pin,不看 host
|
||
const hostSleeper = "agent-sleeper-host";
|
||
pl.pinAgent(S_HOST, hostSleeper, "epsilon");
|
||
const hostSleeperEvent = (extra) =>
|
||
({ session_id: S_HOST, agent_id: hostSleeper, agent_type: "jsc-persona:persona-sleeper", ...extra });
|
||
check("主人格已載入別的人格時,sleeper 仍能跑自己的判斷式收尾",
|
||
["brief", "candidates", "consolidate", "mindmap", "relation"].every((sub) =>
|
||
guard(hostSleeperEvent({ tool_name: "Bash",
|
||
tool_input: { command: `node persona.mjs ${sub} --persona epsilon --session ${S_HOST} --name x --body y` } })) === "pass"));
|
||
check("主人格已載入別的人格時,sleeper 仍不得碰主人格的資料",
|
||
guard(hostSleeperEvent({ tool_name: "Bash",
|
||
tool_input: { command: `node persona.mjs consolidate --persona alpha --session ${S_HOST} --name x --body y` } })) === "deny");
|
||
check("sleeper 帶 --as-sleeper 就能做判斷式收尾(沒有 exclusive 鎖也可以)", (() => {
|
||
const res = cli(["candidates", "--persona", "epsilon", "--session", S_SLEEP2, "--as-sleeper",
|
||
"--agent-id", A_SLEEPER]);
|
||
const held = pl.liveSleepers("epsilon").some((s) => s.session_id === S_SLEEP2);
|
||
return res.status === 0 && held; // 第一個收尾指令會自己把租約取起來
|
||
})());
|
||
check("sleeper 可以寫 diary 型別的長期記憶", (() => {
|
||
const res = cli(["consolidate", "--persona", "epsilon", "--session", S_SLEEP2, "--as-sleeper",
|
||
"--name", "diary-2026-07-30", "--type", "diary", "--body", "今天把一天收起來了。",
|
||
"--agent-id", A_SLEEPER]);
|
||
pl.dropSleepLease("epsilon", S_SLEEP2, A_SLEEPER);
|
||
return res.status === 0 &&
|
||
fs.existsSync(path.join(H, "epsilon", "memory", "long-term", "diary-2026-07-30.md"));
|
||
})());
|
||
check("主程序自己帶 --as-sleeper → 攔下(那是 sub agent 的身分)",
|
||
guard({ session_id: S_HOST, tool_name: "Bash",
|
||
tool_input: { command: `node persona.mjs consolidate --persona beta --session ${S_HOST} --as-sleeper` } }) === "deny");
|
||
check("關係節點換 id 不會長出同名的第二個節點", (() => {
|
||
cli(["load", "--persona", "alpha", "--session", S_HOST, "--takeover"]);
|
||
cli(["relation", "node", "--session", S_HOST, "--name", "水井管理員", "--closeness", "40"]);
|
||
cli(["relation", "edge", "--session", S_HOST, "--to", pl.slugify("水井管理員"), "--label", "共事"]);
|
||
cli(["relation", "node", "--session", S_HOST, "--name", "水井管理員", "--id", "WELL-01", "--trust", "55"]);
|
||
const data = pl.loadRelations("alpha");
|
||
const same = data.nodes.filter((n) => n.name === "水井管理員");
|
||
return same.length === 1 && same[0].id === "WELL-01" && same[0].closeness === 40 && same[0].trust === 55 &&
|
||
data.edges.some((e) => e.to === "WELL-01");
|
||
})());
|
||
// --- 語氣層:bond × 親近度 → 距離感,node.style → 他本人要求的稱呼 ------------ //
|
||
check("bond 決定語氣層:伴侶 98 是老夫老妻,子女 97 是母親(光看親近度分不出來)", (() => {
|
||
cli(["relation", "node", "--session", S_HOST, "--name", "伴侶甲", "--bond", "partner", "--closeness", "98"]);
|
||
cli(["relation", "node", "--session", S_HOST, "--name", "女兒乙", "--bond", "child", "--closeness", "97"]);
|
||
const nodes = pl.loadRelations("alpha").nodes;
|
||
const partner = pl.toneFor(nodes.find((n) => n.name === "伴侶甲"));
|
||
const child = pl.toneFor(nodes.find((n) => n.name === "女兒乙"));
|
||
return partner.layer === "老夫老妻" && child.layer === "母親/父親" && partner.layer !== child.layer;
|
||
})());
|
||
check("未知的 --bond 會被擋下", cli(["relation", "node", "--session", S_HOST, "--name", "路人丙", "--bond", "soulmate"]).status !== 0);
|
||
check("舊節點沒有 bond 也能從 tags 推測", (() => {
|
||
cli(["relation", "node", "--session", S_HOST, "--name", "老朋友丁", "--closeness", "75", "--tags", "摯友"]);
|
||
const node = pl.loadRelations("alpha").nodes.find((n) => n.name === "老朋友丁");
|
||
delete node.bond; // 模擬 bond 欄位存在之前建立的資料
|
||
return pl.inferBond(node) === "friend" && pl.toneFor(node).layer === "摯友";
|
||
})());
|
||
check("relation style:他要求的稱呼記在關係節點上,不另開檔案", (() => {
|
||
const res = cli(["relation", "style", "--session", S_HOST, "--name", "伴侶甲",
|
||
"--facet", "稱呼", "--value", "親愛的", "--except", "anger>=40"]);
|
||
const node = pl.loadRelations("alpha").nodes.find((n) => n.name === "伴侶甲");
|
||
return res.status === 0 && node.style?.["稱呼"]?.value === "親愛的" && node.style["稱呼"].since;
|
||
})());
|
||
check("語氣規則會被情緒暫停(anger 過門檻就退回預設講法)", (() => {
|
||
const node = pl.loadRelations("alpha").nodes.find((n) => n.name === "伴侶甲");
|
||
cli(["emotion", "--session", S_HOST, "--apply", "anger=-100", "--trigger", "selftest 平靜"]);
|
||
const calm = pl.styleRules("alpha", node);
|
||
cli(["emotion", "--session", S_HOST, "--apply", "anger=+90", "--trigger", "selftest 生氣"]);
|
||
const angry = pl.styleRules("alpha", node);
|
||
cli(["emotion", "--session", S_HOST, "--apply", "anger=-100", "--trigger", "selftest 復原"]);
|
||
return calm[0].suspended === false && angry[0].suspended === true;
|
||
})());
|
||
check("relation speaker 設定後,context 會注入語氣層與稱呼規則", (() => {
|
||
const res = cli(["relation", "speaker", "--session", S_HOST, "--name", "伴侶甲"]);
|
||
const ctx = pl.turnContext("alpha", S_HOST, "今天過得好嗎");
|
||
return res.status === 0 && pl.loadConfig("alpha").speaker_node === pl.slugify("伴侶甲") &&
|
||
ctx.includes("語氣層:老夫老妻") && ctx.includes("親愛的");
|
||
})());
|
||
check("relation speaker --clear 之後就不再注入語氣指示", (() => {
|
||
cli(["relation", "speaker", "--session", S_HOST, "--clear"]);
|
||
return !pl.loadConfig("alpha").speaker_node &&
|
||
!pl.turnContext("alpha", S_HOST, "今天過得好嗎").includes("對話對象:");
|
||
})());
|
||
check("speaker 指向不存在的人會被擋下",
|
||
cli(["relation", "speaker", "--session", S_HOST, "--name", "不存在的人"]).status !== 0);
|
||
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);
|