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>
This commit is contained in:
+174
-1
@@ -445,7 +445,10 @@ check("guard 認得大寫編號的 --persona(不會漏掉跨人格檢查)",
|
||||
tool_input: { command: `node persona.mjs recall --persona ALPHA-01 --session ${S_CODE} --query x` } }) === "deny");
|
||||
cli(["icon", "generate", "--session", S_SPEAK, "--size", "64"]); // 讓分區檢查也涵蓋圖示
|
||||
// 分區必須「不重不漏」:人格產生的每個檔案都要恰好屬於一區,否則同步會默默漏資料
|
||||
const AREA_EXEMPT = new Set(["state/lock.json", "state/guests.json", "state/sync.json"]);
|
||||
// 鎖與租約是「這台機器此刻的狀態」,不同步(同步了只會在別台機器造成假的佔用)
|
||||
const 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)));
|
||||
@@ -747,6 +750,176 @@ 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("預設保留載入鎖(--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");
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user