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