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:
2026-07-30 05:34:08 +00:00
co-authored by Claude Opus 5
parent 042c0e7d4c
commit 603741a4cc
16 changed files with 1019 additions and 43 deletions
+163 -2
View File
@@ -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>