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:
+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>
|
||||
|
||||
Reference in New Issue
Block a user