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:
@@ -75,6 +75,7 @@ export const AREAS = {
|
||||
"state/emotion.json",
|
||||
"state/inner.jsonl",
|
||||
"state/said.jsonl",
|
||||
"state/sleep.json",
|
||||
"memory/short-term.jsonl",
|
||||
"memory/inbox/",
|
||||
"mindmap/threads/",
|
||||
|
||||
+335
-5
@@ -12,6 +12,7 @@ import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import crypto from "node:crypto";
|
||||
import zlib from "node:zlib";
|
||||
|
||||
// --------------------------------------------------------------------------- //
|
||||
// 路徑
|
||||
@@ -21,6 +22,8 @@ export const RUNTIME_DIRNAME = ".runtime";
|
||||
export const ROOMS_DIRNAME = ".rooms";
|
||||
export const LEASE_SECONDS = 900; // 15 分鐘沒有 heartbeat 視為死鎖,可被接手
|
||||
export const GUEST_LEASE_SECONDS = 1800; // guest(sub agent)租約
|
||||
export const SLEEPER_LEASE_SECONDS = 300; // sleeper(睡眠 sub agent)租約:只夠做完收尾
|
||||
export const SLEEP_DECAY_MINUTES = 480; // 睡一次=套用一次 8 小時的情緒衰減
|
||||
|
||||
function expandUser(p) {
|
||||
if (p === "~") return os.homedir();
|
||||
@@ -88,6 +91,40 @@ export function personaExists(slug) {
|
||||
return validSlug(slug) && fs.existsSync(path.join(personaDir(slug), "IDENTITY.md"));
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------- //
|
||||
// 全域設定(跨人格、跨 session)
|
||||
// --------------------------------------------------------------------------- //
|
||||
//
|
||||
// 目前只有一個鍵:`default_persona`——開新 session 時要不要自動載入某個人格。
|
||||
// 預設是**沒有設定**(等使用者指定),這是刻意的:不該替使用者挑一個人格附身。
|
||||
|
||||
export const homeSettingsPath = () => path.join(runtimeDir(), "settings.json");
|
||||
|
||||
export function loadHomeSettings() {
|
||||
return readJson(homeSettingsPath(), {}) ?? {};
|
||||
}
|
||||
|
||||
export function saveHomeSettings(patch) {
|
||||
const data = { ...loadHomeSettings(), ...patch, updated_at: nowIso() };
|
||||
writeJson(homeSettingsPath(), data);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 要自動載入的人格;沒設定就回 null(呼叫端應該「什麼都不做」)。
|
||||
* 環境變數 `PERSONA_DEFAULT` 優先於設定檔,`off`/`none`/空字串代表關閉。
|
||||
*/
|
||||
export function defaultPersona() {
|
||||
const env = String(process.env.PERSONA_DEFAULT ?? "").trim();
|
||||
const raw = env || String(loadHomeSettings().default_persona ?? "").trim();
|
||||
if (!raw || ["off", "none", "false", "0"].includes(raw.toLowerCase())) return null;
|
||||
return raw;
|
||||
}
|
||||
|
||||
export function setDefaultPersona(slug) {
|
||||
return saveHomeSettings({ default_persona: slug || null });
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------- //
|
||||
// 時間與檔案 IO
|
||||
// --------------------------------------------------------------------------- //
|
||||
@@ -245,6 +282,27 @@ export function decayEmotion(state, now = new Date()) {
|
||||
return state;
|
||||
}
|
||||
|
||||
/**
|
||||
* 明確套用「經過 N 分鐘」的衰減(睡眠用)。
|
||||
*
|
||||
* 跟 `decayEmotion` 不同:那個看的是距離上次更新過了多久(真實時間),
|
||||
* 這個是「就算你才剛聊完,也讓情緒像過了一夜」。強度大的負向情緒半衰期本來就長,
|
||||
* 所以睡一覺不會把羞愧與悲傷抹平——這是刻意的。
|
||||
*/
|
||||
export function decayEmotionBy(state, minutes = SLEEP_DECAY_MINUTES) {
|
||||
const mins = Math.max(0, Number(minutes) || 0);
|
||||
if (!mins) return state;
|
||||
for (const key of EMOTION_KEYS) {
|
||||
const half = Number(state.half_life_minutes[key] ?? EMOTIONS[key].halfLife);
|
||||
const base = Number(state.baseline[key] ?? DEFAULT_BASELINE[key]);
|
||||
const level = Number(state.levels[key] ?? base);
|
||||
const factor = half > 0 ? Math.pow(0.5, mins / half) : 0;
|
||||
state.levels[key] = Math.round((base + (level - base) * factor) * 100) / 100;
|
||||
}
|
||||
state.updated_at = nowIso();
|
||||
return state;
|
||||
}
|
||||
|
||||
export function applyEmotion(state, deltas, trigger = "") {
|
||||
state = decayEmotion(state);
|
||||
const applied = {};
|
||||
@@ -473,6 +531,163 @@ export function dropGuestLease(slug, sessionId, room = null) {
|
||||
writeJson(file, data);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------- //
|
||||
// sleeper 租約(睡眠用的短期寫入權)
|
||||
// --------------------------------------------------------------------------- //
|
||||
//
|
||||
// 為什麼不用 exclusive 鎖:主人格叫別人去睡的時候,自己並沒有 release,
|
||||
// 而「一個 session 只能載入一個 host 人格」——所以睡眠需要另一種、更短的寫入權。
|
||||
//
|
||||
// 為什麼還是要驗鎖:睡眠會 prune/reindex/push。如果目標人格此刻正被另一個程序
|
||||
// 載入著對話,兩邊會同時改同一批檔案,記憶會互相覆蓋。所以:
|
||||
// * 沒有活著的鎖 → 直接取得 sleeper 租約(最常見)
|
||||
// * 鎖屬於同一個 session → 可以(自己睡自己)
|
||||
// * 鎖是死的(心跳過期)→ 可以接手(睡眠本來就是收尾動作)
|
||||
// * 鎖活著且屬於別人 → 拒絕,回報 owner 讓使用者決定
|
||||
|
||||
export const sleepersPath = (slug) => path.join(personaDir(slug), "state", "sleepers.json");
|
||||
|
||||
export function liveSleepers(slug) {
|
||||
const data = readJson(sleepersPath(slug), {}) ?? {};
|
||||
return (data.sleepers || []).filter((s) => ageSeconds(s.heartbeat_at) <= SLEEPER_LEASE_SECONDS);
|
||||
}
|
||||
|
||||
/** 取得睡眠寫入權;拿不到就丟 LockError(附 owner 資訊)。 */
|
||||
export function acquireSleepLease(slug, sessionId, { agentId = null } = {}) {
|
||||
ensurePersonaDirs(slug);
|
||||
const lock = readJson(lockPath(slug)) ?? {};
|
||||
const held = Boolean(Object.keys(lock).length);
|
||||
if (held && lock.session_id !== sessionId && !lockIsDead(lock)) {
|
||||
throw new LockError(
|
||||
`人格 \`${slug}\` 正被另一個程序載入(session ${String(lock.session_id).slice(0, 8)}…,` +
|
||||
`cwd ${lock.cwd},最後心跳 ${lock.heartbeat_at})。它可能正在對話中,` +
|
||||
"現在睡眠會與它的寫入互相覆蓋,所以不做。請等它結束或請使用者決定。",
|
||||
{ session_id: lock.session_id, cwd: lock.cwd, heartbeat_at: lock.heartbeat_at },
|
||||
);
|
||||
}
|
||||
const others = liveSleepers(slug).filter((s) => !(s.session_id === sessionId && s.agent_id === agentId));
|
||||
if (others.length) {
|
||||
throw new LockError(`人格 \`${slug}\` 已經有另一個 sleeper 在收尾(session ${String(others[0].session_id).slice(0, 8)}…)。`, others[0]);
|
||||
}
|
||||
const lease = {
|
||||
session_id: sessionId,
|
||||
agent_id: agentId,
|
||||
started_at: nowIso(),
|
||||
heartbeat_at: nowIso(),
|
||||
lease_seconds: SLEEPER_LEASE_SECONDS,
|
||||
took_over_dead_lock: held && lockIsDead(lock),
|
||||
};
|
||||
writeJson(sleepersPath(slug), { sleepers: [lease] });
|
||||
return lease;
|
||||
}
|
||||
|
||||
export function dropSleepLease(slug, sessionId, agentId = null) {
|
||||
const data = readJson(sleepersPath(slug), {}) ?? {};
|
||||
data.sleepers = (data.sleepers || []).filter(
|
||||
(s) => !(s.session_id === sessionId && (agentId === null || s.agent_id === agentId)),
|
||||
);
|
||||
writeJson(sleepersPath(slug), data);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------- //
|
||||
// 睡眠會做的機械性收尾
|
||||
// --------------------------------------------------------------------------- //
|
||||
|
||||
export const THREAD_STALE_DAYS = 7;
|
||||
export const SAID_KEEP_HOURS = 24;
|
||||
export const SAID_KEEP_LINES = 300;
|
||||
|
||||
/** 太久沒動的思維導圖收進 `mindmap/threads/archive/`(不刪,只是收起來)。 */
|
||||
export function archiveStaleThreads(slug, days = THREAD_STALE_DAYS) {
|
||||
const dir = path.join(personaDir(slug), "mindmap", "threads");
|
||||
let names = [];
|
||||
try {
|
||||
names = fs.readdirSync(dir).filter((f) => f.endsWith(".mmd"));
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
const cutoff = Date.now() - days * 86_400_000;
|
||||
const archive = path.join(dir, "archive");
|
||||
let moved = 0;
|
||||
for (const name of names) {
|
||||
const file = path.join(dir, name);
|
||||
let stat;
|
||||
try {
|
||||
stat = fs.statSync(file);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (stat.mtimeMs >= cutoff) continue;
|
||||
fs.mkdirSync(archive, { recursive: true });
|
||||
fs.renameSync(file, path.join(archive, name));
|
||||
moved += 1;
|
||||
}
|
||||
return moved;
|
||||
}
|
||||
|
||||
/** `said.jsonl` 只服務「不要重講」判定,留最近 24 小時/300 行就夠。 */
|
||||
export function trimSaid(slug, { hours = SAID_KEEP_HOURS, lines = SAID_KEEP_LINES } = {}) {
|
||||
const file = saidPath(slug);
|
||||
const rows = readJsonl(file);
|
||||
if (!rows.length) return 0;
|
||||
const cutoff = Date.now() - hours * 3_600_000;
|
||||
let kept = rows.filter((r) => (parseIso(r.ts)?.getTime() ?? Date.now()) >= cutoff);
|
||||
kept = kept.slice(-lines);
|
||||
if (kept.length !== rows.length) {
|
||||
writeText(file, kept.map((r) => JSON.stringify(r)).join("\n") + (kept.length ? "\n" : ""));
|
||||
}
|
||||
return kept.length;
|
||||
}
|
||||
|
||||
/** 把「上個月以前」的 journal 壓成 .jsonl.gz(同步時省流量,也不再被讀)。 */
|
||||
export function archiveJournals(slug) {
|
||||
const dir = path.join(personaDir(slug), "journal");
|
||||
let names = [];
|
||||
try {
|
||||
names = fs.readdirSync(dir).filter((f) => /^\d{4}-\d{2}\.jsonl$/.test(f));
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
const current = path.basename(journalPath(slug));
|
||||
let gzipped = 0;
|
||||
for (const name of names) {
|
||||
if (name === current) continue;
|
||||
const file = path.join(dir, name);
|
||||
const target = `${file}.gz`;
|
||||
if (fs.existsSync(target)) continue;
|
||||
fs.writeFileSync(target, zlib.gzipSync(fs.readFileSync(file)));
|
||||
fs.rmSync(file);
|
||||
gzipped += 1;
|
||||
}
|
||||
return gzipped;
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------- //
|
||||
// 睡眠狀態
|
||||
// --------------------------------------------------------------------------- //
|
||||
|
||||
export const sleepStatePath = (slug) => path.join(personaDir(slug), "state", "sleep.json");
|
||||
|
||||
export function loadSleepState(slug) {
|
||||
const data = readJson(sleepStatePath(slug), {}) ?? {};
|
||||
data.last_slept_at ??= null;
|
||||
data.count ??= 0;
|
||||
return data;
|
||||
}
|
||||
|
||||
export function saveSleepState(slug, patch) {
|
||||
const data = { ...loadSleepState(slug), ...patch };
|
||||
writeJson(sleepStatePath(slug), data);
|
||||
return data;
|
||||
}
|
||||
|
||||
/** 距離上次睡眠幾小時(沒睡過回 null)。 */
|
||||
export function hoursAwake(slug) {
|
||||
const at = loadSleepState(slug).last_slept_at;
|
||||
if (!at) return null;
|
||||
return Math.round((ageSeconds(at) / 3600) * 10) / 10;
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------- //
|
||||
// session 綁定:誰是 host、邀了哪些 guest、sub agent pin、劇場模式
|
||||
// --------------------------------------------------------------------------- //
|
||||
@@ -1055,6 +1270,43 @@ export function loadRelations(slug) {
|
||||
return data;
|
||||
}
|
||||
|
||||
/** 蓋上「最後一次接觸」的時間戳;找不到那個人就回 false(不會憑空建節點)。 */
|
||||
export function stampContact(slug, nameOrId, at = nowIso()) {
|
||||
const data = loadRelations(slug);
|
||||
const key = slugify(String(nameOrId || ""));
|
||||
const node = data.nodes.find((n) => n.id === key || slugify(n.name || "") === key);
|
||||
if (!node) return false;
|
||||
node.last_contact_at = at;
|
||||
node.updated_at = nowIso();
|
||||
writeJson(relationsJson(slug), data);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 很久沒聯絡、但關係很近的人——這是「主動想起某個人」的客觀依據。
|
||||
* 沒有 `last_contact_at` 的節點用 `created_at` 當起點;分數 = 親近度 × 沉默天數。
|
||||
*/
|
||||
export function staleContacts(slug, { days = 3, minCloseness = 60, limit = 3 } = {}) {
|
||||
const data = loadRelations(slug);
|
||||
const out = [];
|
||||
for (const node of data.nodes) {
|
||||
const closeness = Number(node.closeness ?? 0);
|
||||
if (closeness < minCloseness) continue;
|
||||
const since = node.last_contact_at || node.created_at || null;
|
||||
const silentDays = since ? Math.floor(ageSeconds(since) / 86_400) : null;
|
||||
if (silentDays === null || silentDays < days) continue;
|
||||
out.push({
|
||||
id: node.id,
|
||||
name: node.name || node.id,
|
||||
kind: node.kind || "human",
|
||||
closeness,
|
||||
silent_days: silentDays,
|
||||
score: closeness * silentDays,
|
||||
});
|
||||
}
|
||||
return out.sort((a, b) => b.score - a.score).slice(0, limit);
|
||||
}
|
||||
|
||||
export function upsertRelationNode(slug, node) {
|
||||
const data = loadRelations(slug);
|
||||
const nodeId = node.id || slugify(node.name || "");
|
||||
@@ -1351,9 +1603,16 @@ const PATH_TOOL_FIELDS = {
|
||||
export const GUEST_SAFE_SUBCOMMANDS = new Set([
|
||||
"show", "status", "list", "recall", "room", "remember", "leave", "brief", "think", "said",
|
||||
]);
|
||||
// owner 這些子指令本來就要提到別的人格名字(邀請/離場/查詢/匯入新人格),不算跨人格讀取
|
||||
// owner 這些子指令本來就要提到別的人格名字(邀請/離場/查詢/匯入新人格/設定預設人格),不算跨人格讀取
|
||||
export const OWNER_EXEMPT_SUBCOMMANDS = new Set([
|
||||
"create", "list", "status", "gc", "invite", "load", "leave", "import",
|
||||
"create", "list", "status", "gc", "invite", "load", "leave", "import", "default", "sleep",
|
||||
]);
|
||||
// sleeper(睡眠 sub agent)只准做收尾:整理自己的記憶與圖、衰減情緒、同步、寫睡眠狀態。
|
||||
// 不准 load/release(它用的是 sleeper 租約)、不准 invite/room(它不是去聊天的)、
|
||||
// 不准 export/import(那是把記憶搬出去)、不准 remember(收尾階段不再新增短期記憶)。
|
||||
export const SLEEPER_SAFE_SUBCOMMANDS = new Set([
|
||||
"sleep", "candidates", "consolidate", "prune", "reindex", "mindmap", "relation",
|
||||
"emotion", "recall", "show", "brief", "status", "said", "think", "sync",
|
||||
]);
|
||||
const MUTATING_SHELL =
|
||||
/(>>?|\|\s*tee\b|\brm\b|\bmv\b|\bcp\b|\btruncate\b|\bdd\b|\bchmod\b|\bchown\b|\bsed\b[^|;]*-i|\btouch\b|\bmkdir\b|\bln\b)/;
|
||||
@@ -1448,12 +1707,31 @@ export function cliInvocation(command) {
|
||||
* * 主程序(無 agent_id)與一般 sub agent → host 人格,可讀寫。
|
||||
* * persona-guest 型 sub agent → 只能碰被邀請的 guest 人格,且唯讀;
|
||||
* 第一次觸碰哪個 guest 就 pin 住(first-touch pinning),之後不得換人。
|
||||
* * persona-sleeper 型 sub agent → **就是那個人格自己在睡**:對目標人格可寫,
|
||||
* 但被 pin 在它身上(連 host 都不能碰),而且只准跑收尾用的子指令。
|
||||
* 它回傳給主人格的只有「睡完了沒、哪一步出錯」,不含任何記憶內容。
|
||||
*/
|
||||
export function resolveScope(sessionId, agentId, agentType) {
|
||||
const data = loadSession(sessionId);
|
||||
const host = data.host;
|
||||
const guests = Object.keys(data.guests || {});
|
||||
const isGuestAgent = Boolean(agentType) && String(agentType).includes("persona-guest");
|
||||
const type = String(agentType || "");
|
||||
const isGuestAgent = Boolean(agentType) && type.includes("persona-guest");
|
||||
const isSleeperAgent = Boolean(agentType) && type.includes("persona-sleeper");
|
||||
if (isSleeperAgent) {
|
||||
const pinned = (data.pins || {})[agentId || ""];
|
||||
return {
|
||||
role: "sleeper",
|
||||
// 還沒 pin:允許 first-touch(碰到誰就定誰);pin 之後不得換人
|
||||
allowed: pinned ? [pinned] : null,
|
||||
readonly: false,
|
||||
host,
|
||||
guests,
|
||||
pinned,
|
||||
rooms: [],
|
||||
session: data,
|
||||
};
|
||||
}
|
||||
if (!isGuestAgent) {
|
||||
return {
|
||||
role: "owner",
|
||||
@@ -1510,6 +1788,23 @@ export function guardDecide(event) {
|
||||
);
|
||||
}
|
||||
const sub = info.subcommand || "";
|
||||
if (scope.role === "sleeper") {
|
||||
if (!SLEEPER_SAFE_SUBCOMMANDS.has(sub)) {
|
||||
return deny(
|
||||
`睡眠 sub agent 只能執行 ${[...SLEEPER_SAFE_SUBCOMMANDS].sort().join("/")},不得執行 \`${sub}\`。` +
|
||||
"它的任務是把自己的一天收尾,不是聊天、載入或搬移記憶。",
|
||||
);
|
||||
}
|
||||
for (const slug of info.personas) {
|
||||
if (scope.allowed && !scope.allowed.includes(slug)) {
|
||||
return deny(
|
||||
`這個睡眠 sub agent 已經被綁在 \`${scope.allowed[0]}\`,不得再碰 \`${slug}\`(一次只睡一個人格)。`,
|
||||
);
|
||||
}
|
||||
}
|
||||
// first-touch pinning:第一個提到的人格就是它要睡的那個,之後不得換人
|
||||
if (!scope.allowed && agentId && info.personas.length) pinAgent(sessionId, agentId, info.personas[0]);
|
||||
}
|
||||
if (scope.role === "guest") {
|
||||
if (!GUEST_SAFE_SUBCOMMANDS.has(sub)) {
|
||||
return deny(
|
||||
@@ -1538,10 +1833,14 @@ export function guardDecide(event) {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (MUTATING_SHELL.test(command) && scope.role === "guest") {
|
||||
if (MUTATING_SHELL.test(command) && (scope.role === "guest" || scope.role === "sleeper")) {
|
||||
for (const target of extractPaths(tool, toolInput, cwd)) {
|
||||
if (personaSlugOf(target)) {
|
||||
return deny("guest 人格對人格倉庫唯讀,寫入請透過 `persona.mjs room post` 或 `remember --scope inbox`。");
|
||||
return deny(
|
||||
scope.role === "sleeper"
|
||||
? "睡眠 sub agent 不得用 shell 改人格倉庫的檔案,收尾請走 persona CLI 的子指令。"
|
||||
: "guest 人格對人格倉庫唯讀,寫入請透過 `persona.mjs room post` 或 `remember --scope inbox`。",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1567,6 +1866,23 @@ export function guardDecide(event) {
|
||||
return deny("`.runtime/` 是鎖與綁定的內部狀態,只能由 persona CLI 維護。");
|
||||
}
|
||||
if (!slug) continue;
|
||||
if (scope.role === "sleeper") {
|
||||
// 睡眠 sub agent:被 pin 在目標人格(連 host 都不能碰),而且只能讀;
|
||||
// 所有寫入都要走 CLI 的白名單子指令,不准自己動手改檔案。
|
||||
if (scope.allowed && !scope.allowed.includes(slug)) {
|
||||
return deny(
|
||||
`這個睡眠 sub agent 只能碰 \`${scope.allowed[0]}\`,不得讀寫 \`${slug}\` 的資料(跨人格資料隔離)。`,
|
||||
);
|
||||
}
|
||||
if (!scope.allowed && agentId) pinAgent(sessionId, agentId, slug);
|
||||
if (MUTATING_TOOLS.has(tool)) {
|
||||
return deny(
|
||||
`睡眠 sub agent 不得直接改檔案(\`${slug}\`)。收尾的每一步都要走 persona CLI,` +
|
||||
"這樣才會經過鎖、索引與同步的處理。",
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!scope.allowed.length) {
|
||||
return deny(
|
||||
"尚未載入任何人格。請先執行 `persona.mjs load --persona <slug> --session <session_id>`(或 /jsc-persona:persona-chat)。",
|
||||
@@ -1689,6 +2005,20 @@ export function turnContext(slug, sessionId, prompt = "") {
|
||||
const rel = relationsBrief(slug, prompt ? keywords(prompt, 6) : null);
|
||||
if (rel) lines.push(`人際關係:${rel}`);
|
||||
|
||||
// 很久沒聯絡但很親近的人 → 這是「主動提議去關心某人」的依據(不是每輪都要提)
|
||||
const stale = staleContacts(slug);
|
||||
if (stale.length) {
|
||||
lines.push(
|
||||
`很久沒接觸的人:${stale.map((s) => `${s.name}(親近 ${s.closeness}/沉默 ${s.silent_days} 天)`).join("、")}`,
|
||||
" 想去看看誰是可以的:對人格用 /jsc-persona:persona-invite(`invite --theater off` 只換一輪、不進劇場)," +
|
||||
"但**先問使用者一句**再邀,不要自己把畫面切走。",
|
||||
);
|
||||
}
|
||||
const awake = hoursAwake(slug);
|
||||
if (awake !== null && awake >= 16) {
|
||||
lines.push(`距離上次睡眠已經 ${awake} 小時(短期記憶會越積越多)→ 可以提議 /jsc-persona:persona-sleep。`);
|
||||
}
|
||||
|
||||
if (session.theater && (session.rooms || []).length) {
|
||||
const rooms = session.rooms;
|
||||
lines.push(
|
||||
|
||||
+163
-2
@@ -50,7 +50,7 @@ function emit(payload, asJson, lines) {
|
||||
|
||||
const FLAGS = new Set([
|
||||
"json", "quiet", "force", "takeover", "as-guest", "on", "off", "with-meta", "all",
|
||||
"with-journal", "gzip", "record", "load", "allow-repeat",
|
||||
"with-journal", "gzip", "record", "load", "allow-repeat", "clear", "release", "keep-lock", "contact",
|
||||
"if-due", "no-gitea", "public", "rename", "from-source",
|
||||
]);
|
||||
|
||||
@@ -399,6 +399,161 @@ commands.status = ({ flags }) => {
|
||||
]);
|
||||
};
|
||||
|
||||
// 睡眠:把一天的活狀態收成能留下來的形狀。
|
||||
//
|
||||
// 這裡只做**機械性**的收尾(順序即相依)。需要判斷的部分——哪些短期記憶值得固化、
|
||||
// 日記要寫什麼、心智圖怎麼接——是那個人格自己的事,由 /jsc-persona:persona-sleep
|
||||
// 在呼叫本指令**之前**完成。
|
||||
//
|
||||
// 回傳(`--json`)只有「睡完了沒、哪一步出錯」:主人格拿到的就是這份報告,
|
||||
// 裡面不含任何記憶內容——回傳值本身就是一條會繞過隔離的通道,所以在這裡封死。
|
||||
commands.sleep = async ({ flags }) => {
|
||||
const session = requireSession(flags);
|
||||
const data = pl.loadSession(session);
|
||||
const slug = str(flags.persona) || data.host;
|
||||
if (!slug) die("需要 `--persona <slug>`(或先載入一個人格)。");
|
||||
if (!pl.personaExists(slug)) die(`人格 \`${slug}\` 不存在。`);
|
||||
|
||||
// 寫入權:自己睡自己 → 已經有 exclusive 鎖;被叫來睡別人 → 取 sleeper 租約。
|
||||
// 目標正被另一個程序載入著對話時會在這裡失敗,這是刻意的(避免記憶互相覆蓋)。
|
||||
const selfSleep = data.host === slug;
|
||||
let lease = null;
|
||||
if (!selfSleep) {
|
||||
try {
|
||||
lease = pl.acquireSleepLease(slug, session, { agentId: str(flags["agent-id"]) || null });
|
||||
} catch (err) {
|
||||
const payload = { persona: slug, ok: false, failed_step: "lease", error: err.message };
|
||||
if (flags.json) {
|
||||
process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
die(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
const steps = [];
|
||||
const run = async (step, fn) => {
|
||||
try {
|
||||
await fn();
|
||||
steps.push({ step, ok: true });
|
||||
} catch (err) {
|
||||
// 一步壞掉不放棄整個睡眠:記下來繼續走,最後在報告裡說明。
|
||||
steps.push({ step, ok: false, error: String(err.message || err).slice(0, 300) });
|
||||
}
|
||||
};
|
||||
|
||||
const hours = pl.hoursAwake(slug);
|
||||
await run("relation-contact", () => {
|
||||
// 今天提到過的人 → 蓋上「最後一次接觸」的時間戳(主動關心的依據)
|
||||
const seen = new Set();
|
||||
for (const row of pl.readJsonl(pl.shortTermPath(slug), 200)) {
|
||||
for (const name of row.entities || []) seen.add(name);
|
||||
}
|
||||
for (const name of seen) pl.stampContact(slug, name);
|
||||
pl.renderRelations(slug);
|
||||
});
|
||||
await run("prune-short-term", () => pl.pruneShortTerm(slug));
|
||||
await run("archive-threads", () => pl.archiveStaleThreads(slug));
|
||||
await run("emotion-decay", () => {
|
||||
const state = pl.decayEmotionBy(pl.loadEmotion(slug), pl.SLEEP_DECAY_MINUTES);
|
||||
pl.writeJson(pl.emotionPath(slug), state);
|
||||
});
|
||||
await run("reindex", () => pl.rebuildIndex(slug));
|
||||
await run("trim-said", () => pl.trimSaid(slug));
|
||||
await run("archive-journal", () => pl.archiveJournals(slug));
|
||||
await run("sleep-state", () => {
|
||||
const prev = pl.loadSleepState(slug);
|
||||
pl.saveSleepState(slug, {
|
||||
last_slept_at: pl.nowIso(),
|
||||
count: Number(prev.count || 0) + 1,
|
||||
hours_awake: hours,
|
||||
});
|
||||
});
|
||||
|
||||
// Gitea:兩區都推,推完驗證。沒設定或失敗都不算睡眠失敗(同步永遠不阻斷)。
|
||||
const sync = {};
|
||||
if (flags["no-gitea"] || gt.giteaProblem() || !gt.personaCode(slug)) {
|
||||
sync.skipped = gt.giteaProblem() || (gt.personaCode(slug) ? "使用者要求略過" : "尚無人格編號");
|
||||
} else {
|
||||
for (const area of gt.AREA_KEYS) {
|
||||
try {
|
||||
const res = await gt.pushArea(slug, area, { message: `sleep: 收尾 ${pl.nowIso()}` });
|
||||
sync[area] = res.ok ? (res.changed ? "pushed" : "no-change") : `failed: ${String(res.reason).slice(0, 80)}`;
|
||||
} catch (err) {
|
||||
sync[area] = `failed: ${String(err.message).slice(0, 80)}`;
|
||||
}
|
||||
}
|
||||
try {
|
||||
const verdicts = [];
|
||||
for (const area of gt.AREA_KEYS) verdicts.push(await gt.verifyArea(slug, area));
|
||||
sync.verified = verdicts.every((v) => v.ok !== false);
|
||||
} catch {
|
||||
sync.verified = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 預設 --keep-lock:睡完還能繼續聊。真的要收工才 --release。
|
||||
let released = false;
|
||||
if (flags.release && selfSleep) {
|
||||
pl.unbindSession(session);
|
||||
released = true;
|
||||
}
|
||||
if (lease) pl.dropSleepLease(slug, session, str(flags["agent-id"]) || null);
|
||||
|
||||
const failed = steps.filter((s) => !s.ok);
|
||||
const payload = {
|
||||
persona: slug,
|
||||
ok: failed.length === 0,
|
||||
slept_at: pl.nowIso(),
|
||||
steps,
|
||||
sync,
|
||||
kept_lock: !released,
|
||||
};
|
||||
if (flags.json) {
|
||||
process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
|
||||
if (!payload.ok) process.exit(1);
|
||||
return;
|
||||
}
|
||||
if (payload.ok) ok(`人格 \`${slug}\` 已完成睡眠收尾(${steps.length} 個步驟,檔案處理無錯誤)。`);
|
||||
else say(`✖ 人格 \`${slug}\` 睡眠收尾有 ${failed.length} 步失敗:${failed.map((s) => s.step).join(", ")}`);
|
||||
for (const s of failed) say(` - ${s.step}:${s.error}`);
|
||||
say(` Gitea:${Object.entries(sync).map(([k, v]) => `${k}=${v}`).join(" ") || "(未同步)"}`);
|
||||
say(` 載入鎖:${released ? "已釋放" : "保留(--release 才收工)"}`);
|
||||
if (!payload.ok) process.exit(1);
|
||||
};
|
||||
|
||||
// 預設人格:開新 session 時由 SessionStart hook 自動載入。沒設定就什麼都不做——
|
||||
// 「不自己挑一個人格附身」仍然是預設行為,這裡只是讓使用者能明示地推翻它。
|
||||
commands.default = ({ flags }) => {
|
||||
const clear = Boolean(flags.clear || flags.off);
|
||||
const slug = str(flags.persona);
|
||||
if (clear) {
|
||||
pl.setDefaultPersona(null);
|
||||
ok("已清除預設人格:之後開新 session 不會自動載入任何人格(等使用者指定)。");
|
||||
return;
|
||||
}
|
||||
if (slug) {
|
||||
if (!pl.personaExists(slug)) {
|
||||
die(`人格 \`${slug}\` 不存在。可用:${pl.listPersonas().join(", ") || "(無)"}`);
|
||||
}
|
||||
pl.setDefaultPersona(slug);
|
||||
ok(`已設定預設人格 \`${slug}\`:之後每個新 session 都會自動載入它(載入不到時只回報,不會強制接手)。`);
|
||||
say(` 設定檔:${pl.homeSettingsPath()} 停用:\`default --clear\` 或環境變數 \`PERSONA_DEFAULT=off\``);
|
||||
return;
|
||||
}
|
||||
const current = pl.defaultPersona();
|
||||
const env = String(process.env.PERSONA_DEFAULT ?? "").trim();
|
||||
emit(
|
||||
{ default_persona: current, from_env: Boolean(env), settings: pl.homeSettingsPath() },
|
||||
flags.json,
|
||||
[
|
||||
`預設人格:${current ? `\`${current}\`${pl.personaExists(current) ? "" : "(⚠ 這個人格不存在)"}` : "(未設定,開新 session 不自動載入)"}`,
|
||||
` 來源:${env ? "環境變數 PERSONA_DEFAULT" : "設定檔"} ${pl.homeSettingsPath()}`,
|
||||
" 設定:`default --persona <slug>` 停用:`default --clear`",
|
||||
],
|
||||
);
|
||||
};
|
||||
|
||||
commands.heartbeat = ({ flags }) => {
|
||||
const session = requireSession(flags);
|
||||
const data = pl.loadSession(session);
|
||||
@@ -758,9 +913,11 @@ commands.relation = ({ flags, positional }) => {
|
||||
trust: flags.trust !== undefined ? pl.clamp(num(flags.trust, 30)) : null,
|
||||
note: str(flags.note) || null,
|
||||
tags: csv(flags.tags).length ? csv(flags.tags) : null,
|
||||
// --contact:這輪真的有接觸到這個人 → 蓋時間戳(睡眠與「主動關心」都靠它判斷沉默多久)
|
||||
last_contact_at: flags.contact ? pl.nowIso() : null,
|
||||
});
|
||||
pl.renderRelations(slug);
|
||||
ok(`關係節點 \`${name}\` 已更新。`);
|
||||
ok(`關係節點 \`${name}\` 已更新${flags.contact ? "(已記錄本次接觸時間)" : ""}。`);
|
||||
pushWikiLater(slug, session, flags);
|
||||
return;
|
||||
}
|
||||
@@ -1514,6 +1671,10 @@ const HELP = `persona.mjs — jsc-persona 人格 / 記憶 / 情緒 / 關係圖 C
|
||||
recall --session <id> --query <q> [--limit]
|
||||
think --session <id> --text <心裡話> [--kind infer|plan|feel|doubt --room] 只回報「心想 N 句」,不回顯內容
|
||||
said check|list --session <id> [--text <要說的話> --minutes --threshold --record --limit]
|
||||
sleep --session <id> [--persona <slug>] [--json] [--release] [--no-gitea]
|
||||
睡眠收尾(機械性):關係時間戳→裁短期→收思維導圖→情緒衰減 8 小時→重建索引
|
||||
→修剪 said→壓縮舊 journal→寫 sleep.json→Gitea 兩區 push+驗證。
|
||||
預設保留載入鎖(--release 才收工)。--json 只回報「睡完了沒、哪一步出錯」。
|
||||
candidates --session <id> 列出達到「短期→長期」條件的候選與依據
|
||||
consolidate --session <id> --name <n> --body <b> [--type --about --topics --salience --emotion --rules --source --forget]
|
||||
prune / reindex --session <id>
|
||||
|
||||
+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