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
+1
View File
@@ -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
View File
@@ -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; // guestsub 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)只准做收尾:整理自己的記憶與圖、衰減情緒、同步、寫睡眠狀態。
// 不准 loadrelease(它用的是 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
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>
+174 -1
View File
@@ -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("㉑ 睡眠與 sleepersub 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 不得 loadreleaseinviteexport",
["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);