Support batch sleep orchestration #5

Merged
admin merged 2 commits from develop into master 2026-07-30 06:58:31 +00:00
6 changed files with 245 additions and 128 deletions
Showing only changes of commit b61e8850f3 - Show all commits
+4 -2
View File
@@ -309,6 +309,7 @@ node scripts/persona.mjs default --clear --session <PERSONA_SESSION>
```bash
node scripts/persona.mjs sleep --session <PERSONA_SESSION> --json # 機械性收尾(預設保留載入鎖)
node scripts/persona.mjs sleep --personas <slug,slug> --session <PERSONA_SESSION> --json
node scripts/persona.mjs sleep --session <PERSONA_SESSION> --release # 收工:睡完釋放鎖
```
@@ -320,6 +321,7 @@ node scripts/persona.mjs sleep --session <PERSONA_SESSION> --release # 收工
- **情緒不會歸零**:喜悅(半衰期 120 分)一夜後幾乎回基線,悲傷(480 分)只退一半——睡一覺不該把難過抹平。
- **主人格請別人去睡**:開 `persona-sleeper` sub agentprompt 帶 `persona=<slug> session=<id>`)。
它就是那個人格本人,對自己可寫但被 pin 住、只准跑收尾子指令、不得碰任何其他人格(包含叫它來的主人格)。
- **一次睡多個人格**:可用 `sleep --personas A,B` 批次處理;主程序只是把流程排成一串,**每個人格仍各自判斷、各自收尾、各自回 JSON**。
- **回傳值刻意很窮**`{persona, ok, slept_at, steps, sync, kept_lock}`——只有狀態。
回傳值本身就是一條會繞過隔離的通道,所以在 CLI 這一層封死,不靠提示詞自律。
- **鎖**:沒有活鎖 → 取 5 分鐘的 sleeper 租約;同 session → 直接睡;死鎖 → 可接手;
@@ -366,7 +368,7 @@ node scripts/persona.mjs sleep --session <PERSONA_SESSION> --release # 收工
### `persona-invite`
邀請一個人格透過 `persona-guest` sub agent 加入聊天室,進入**劇場模式**(畫面只留 `名字:內容` 的人格對話);結束後讓它離場並把見聞留在它自己的 inbox。
邀請一個或多個人格透過 `persona-guest` sub agent 加入聊天室,進入**劇場模式**(畫面只留 `名字:內容` 的人格對話);結束後讓它離場並把見聞留在各自的 inbox。多位 guest 可用 `invite --guests A,B` 同場加入。
- **Claude Code / Antigravity**`/jsc-persona:persona-invite <slug>` **Codex**`$persona-invite`
@@ -402,7 +404,7 @@ node scripts/persona.mjs sleep --session <PERSONA_SESSION> --release # 收工
### `persona-sleep`
睡眠與收尾:固化該記住的、忘掉該忘的、更新心智圖與關係圖、套用一次 8 小時的情緒衰減、壓縮舊紀錄,最後兩個區都同步到 Gitea 並驗證。也可以由主人格透過 sub agent 請其他人格各自去睡。
睡眠與收尾:固化該記住的、忘掉該忘的、更新心智圖與關係圖、套用一次 8 小時的情緒衰減、壓縮舊紀錄,最後兩個區都同步到 Gitea 並驗證。也可以由主人格透過 sub agent 請其他人格各自去睡,或一次用 `sleep --personas A,B` 批次收尾多個人格
- **Claude Code / Antigravity**`/jsc-persona:persona-sleep` **Codex**`$persona-sleep`
+1 -1
View File
@@ -1,6 +1,6 @@
---
name: persona-sleeper
description: 以「那個人格本人」的身分做睡眠收尾的 sub agent:固化該記住的、忘掉該忘的、更新心智圖與關係圖、衰減情緒、同步到 Gitea。被 pin 在自己的人格上(連叫它來的主人格都不能碰),最終只回傳一份「睡完了沒、哪一步出錯」的 JSON。由 /jsc-persona:persona-sleep 啟動,prompt 必須帶 persona/session。
description: 以「那個人格本人」的身分做睡眠收尾的 sub agent:固化該記住的、忘掉該忘的、更新心智圖與關係圖、衰減情緒、同步到 Gitea。被 pin 在自己的人格上(連叫它來的主人格都不能碰),最終只回傳一份「睡完了沒、哪一步出錯」的 JSON。由 /jsc-persona:persona-sleep 啟動,prompt 必須帶 persona/session。當主人格用 `sleep --personas A,B` 批次編排時,仍然是每個 sleeper 各自對應一個人格、各自回 JSON。
effort: medium
maxTurns: 30
disallowedTools: Write, Edit, NotebookEdit
+116 -33
View File
@@ -158,6 +158,28 @@ function requireMember(slug, sessionId, asGuest = false) {
const hostOf = (flags, session) => str(flags.persona) || pl.loadSession(session).host;
function parseGuestList(flags) {
const guests = [...csv(flags.guest), ...csv(flags.guests)];
const out = [];
const seen = new Set();
for (const guest of guests) {
if (!guest || seen.has(guest)) continue;
seen.add(guest);
out.push(guest);
}
return out;
}
function parseSleepTargets(flags, fallbackHost = null) {
const targets = [];
for (const slug of [...csv(flags.persona), ...csv(flags.personas)]) {
if (!slug || targets.includes(slug)) continue;
targets.push(slug);
}
if (!targets.length && fallbackHost) targets.push(fallbackHost);
return targets;
}
/**
* 里程碑事件(記憶固化、身分/關係變更)之後,把 Wiki 區推上去。
* 背景執行、失敗不阻斷:同步永遠不該卡住對話。
@@ -410,24 +432,23 @@ commands.status = ({ flags }) => {
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>`或先載入一個人格。");
const targets = parseSleepTargets(flags, data.host);
if (!targets.length) die("需要 `--persona <slug>`、`--personas <slug,slug>`或先載入一個人格。");
for (const slug of targets) {
if (!pl.personaExists(slug)) die(`人格 \`${slug}\` 不存在。`);
}
const sleepOne = async (slug, { release = false } = {}) => {
// 寫入權:自己睡自己 → 已經有 exclusive 鎖;被叫來睡別人 → 取 sleeper 租約。
// 目標正被另一個程序載入著對話時會在這裡失敗,這是刻意的(避免記憶互相覆蓋)。
const selfSleep = data.host === slug;
const sessionData = pl.loadSession(session);
const selfSleep = sessionData.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);
return { persona: slug, ok: false, failed_step: "lease", error: err.message };
}
}
@@ -494,14 +515,14 @@ commands.sleep = async ({ flags }) => {
// 預設 --keep-lock:睡完還能繼續聊。真的要收工才 --release。
let released = false;
if (flags.release && selfSleep) {
if (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 = {
return {
persona: slug,
ok: failed.length === 0,
slept_at: pl.nowIso(),
@@ -509,16 +530,52 @@ commands.sleep = async ({ flags }) => {
sync,
kept_lock: !released,
};
};
const host = data.host || null;
const ordered = [...targets.filter((slug) => slug !== host), ...(host && targets.includes(host) ? [host] : [])];
const deferRelease = Boolean(flags.release) && ordered.length > 1 && host && targets.includes(host);
const results = [];
for (const slug of ordered) {
results.push(await sleepOne(slug, { release: Boolean(flags.release) && !deferRelease && slug === host }));
}
if (deferRelease) {
pl.unbindSession(session);
const hostResult = results.find((r) => r.persona === host);
if (hostResult) {
hostResult.kept_lock = false;
hostResult.released = true;
}
}
const payload = results.length === 1 ? results[0] : { ok: results.every((r) => r.ok), personas: results };
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(", ")}`);
if (results.length === 1) {
const single = results[0];
const failed = (single.steps || []).filter((s) => !s.ok);
if (single.ok) ok(`人格 \`${single.persona}\` 已完成睡眠收尾(${single.steps.length} 個步驟,檔案處理無錯誤)。`);
else say(`✖ 人格 \`${single.persona}\` 睡眠收尾有 ${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 才收工)"}`);
say(` Gitea${Object.entries(single.sync || {}).map(([k, v]) => `${k}=${v}`).join(" ") || "(未同步)"}`);
say(` 載入鎖:${single.kept_lock ? "保留(--release 才收工)" : "已釋放"}`);
if (!single.ok) process.exit(1);
return;
}
say("🌙 睡眠完成");
for (const item of results) {
const failed = (item.steps || []).filter((s) => !s.ok);
const sync = Object.entries(item.sync || {}).map(([k, v]) => `${k}=${v}`).join(" ") || "(未同步)";
say(`- ${item.persona}${item.ok ? "完成" : `${failed.map((s) => s.step).join(", ")}`}${item.released ? "(已釋放鎖)" : ""}`);
if (!item.ok) for (const s of failed) say(` - ${s.step}${s.error}`);
say(` Gitea${sync}`);
say(` 載入鎖:${item.kept_lock ? "保留(--release 才收工)" : "已釋放"}`);
}
if (!payload.ok) process.exit(1);
};
@@ -955,43 +1012,69 @@ commands.invite = ({ flags }) => {
const host = str(flags.host) || pl.loadSession(session).host;
if (!host) die("本 session 尚未載入 host 人格,無法邀請他人。");
requireOwner(host, session);
const guest = str(flags.guest);
if (!guest) die("需要 `--guest <slug>`。");
if (guest === host) die("不能邀請自己。");
const guestList = parseGuestList(flags);
if (!guestList.length) die("需要 `--guest <slug>` 或 `--guests <slug,slug>`。");
if (guestList.includes(host)) die("不能邀請自己。");
for (const guest of guestList) {
if (!pl.personaExists(guest)) die(`人格 \`${guest}\` 不存在。可用:${pl.listPersonas().join(", ")}`);
}
// guest 租約是唯讀的(`mode: "guest-readonly"`),與 exclusive 載入鎖並存不會互相覆寫:
// guest 在 sub agent 裡被 PreToolUse hook 擋掉所有寫入工具,記憶只能進 inbox、情緒不得更動。
// 因此這裡**不以 exclusive 的標準驗鎖**,只在對方仍活著時提示使用者「你看到的是唯讀旁聽」。
const guestSummaries = guestList.map((guest) => {
const lock = pl.readJson(pl.lockPath(guest)) ?? {};
const heldElsewhere =
Boolean(Object.keys(lock).length) && lock.session_id !== session && !pl.lockIsDead(lock);
const heldElsewhere = Boolean(Object.keys(lock).length) && lock.session_id !== session && !pl.lockIsDead(lock);
return { guest, lock, heldElsewhere };
});
const heldElsewhereGuests = guestSummaries.filter((g) => g.heldElsewhere);
const stamp = pl.nowIso().replace(/[-:TZ]/g, "").slice(0, 14);
const room = str(flags.room) || `${host}-${guest}-${stamp}`;
const room = str(flags.room) || `${host}-${guestList.join("-")}-${stamp}`;
pl.createRoom(room, host, session, str(flags.topic));
pl.joinRoom(room, guest);
pl.addGuestLease(guest, session, room, host);
const data = pl.loadSession(session);
data.guests ??= {};
for (const { guest } of guestSummaries) {
pl.joinRoom(room, guest);
pl.addGuestLease(guest, session, room, host);
data.guests[guest] = { room, joined_at: pl.nowIso(), mode: "guest-readonly" };
}
data.rooms ??= [];
if (!data.rooms.includes(room)) data.rooms.push(room);
data.theater = flags.theater === false || flags.theater === "off" ? false : true;
pl.saveSession(session, data);
if (str(flags.topic)) pl.roomPost(room, "system", `主題:${str(flags.topic)}`, { kind: "meta" });
emit({ room, guest, host, dir: pl.roomDir(room), theater: data.theater, held_elsewhere: heldElsewhere }, flags.json, [
`✔ 已邀請人格 \`${guest}\` 以 guest(唯讀)身分加入聊天室 \`${room}\``,
...(heldElsewhere
const guestLabel = guestList.map((g) => `\`${g}\``).join("、");
emit(
{
room,
guest: guestList[0],
guests: guestList,
host,
dir: pl.roomDir(room),
theater: data.theater,
held_elsewhere: heldElsewhereGuests.length > 0,
held_elsewhere_guests: heldElsewhereGuests.map(({ guest, lock }) => ({
guest,
session_id: lock.session_id,
cwd: lock.cwd,
})),
},
flags.json,
[
`✔ 已邀請人格 ${guestLabel} 以 guest(唯讀)身分加入聊天室 \`${room}\``,
...(heldElsewhereGuests.length
? [
` 它同時被另一個程序 exclusive 載入(session ${String(lock.session_id).slice(0, 8)}…,cwd ${lock.cwd});` +
"你這邊拿到的是唯讀旁聽,寫入仍由那個程序獨占,兩者不會互相覆寫。",
` 唯讀旁聽:${heldElsewhereGuests
.map(({ guest, lock }) => `\`${guest}\`session ${String(lock.session_id).slice(0, 8)}…,cwd ${lock.cwd}`)
.join("、")}寫入仍由原本的程序獨占兩邊不會互相覆寫`,
]
: []),
` 聊天室路徑${pl.roomDir(room)}`,
` 🎭 劇場模式已${data.theater ? "開啟:接下來只能輸出人格對話(`名字:內容`),其他訊息一律隱藏" : "關閉"}`,
" 請用 Agent 工具、subagent_type=\"jsc-persona:persona-guest\" 啟動它,prompt 內帶:",
` persona=${guest} room=${room} session=${session}`,
" 請用 Agent 工具、subagent_type=\"jsc-persona:persona-guest\" 逐一啟動它prompt 內帶:",
...guestList.map((guest) => ` persona=${guest} room=${room} session=${session}`),
" guest 只能讀自己的人格資料(跨人格隔離),發言請走 `persona.mjs room post`。",
]);
],
);
};
commands.leave = ({ flags }) => {
@@ -1671,7 +1754,7 @@ 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]
sleep --session <id> [--persona <slug>] [--personas <slug,slug>] [--json] [--release] [--no-gitea]
睡眠收尾(機械性):關係時間戳→裁短期→收思維導圖→情緒衰減 8 小時→重建索引
→修剪 said→壓縮舊 journal→寫 sleep.json→Gitea 兩區 push+驗證。
預設保留載入鎖(--release 才收工)。--json 只回報「睡完了沒、哪一步出錯」。
@@ -1685,7 +1768,7 @@ const HELP = `persona.mjs — jsc-persona 人格 / 記憶 / 情緒 / 關係圖 C
relation node|edge|render|show --session <id> [--name --id --kind --closeness --trust --note --tags --from --to --label --affinity]
多人格對話:
invite --session <id> --guest <slug> [--host --room --topic] (自動開啟劇場模式)
invite --session <id> --guest <slug> [--guests <slug,slug>] [--host --room --topic] (自動開啟劇場模式)
leave --session <id> --guest <slug> [--room]
room post|read|script|list|theater --session <id> [--room --as --text --text-file --emotion --limit --on --off --with-meta]
(post 會擋下「短時間內近似重複」與超過三句的發言;例外用 --allow-repeat / --force
+30
View File
@@ -26,6 +26,7 @@ const HOOKS = path.join(HERE, "..", "hooks");
const S_HOST = "sess-host-1111";
const S_OTHER = "sess-other-2222";
const S_THIRD = "sess-third-3333";
const S_FOURTH = "sess-fourth-4444";
const H = STORE;
let passed = 0;
@@ -72,7 +73,10 @@ cli(["create", "--persona", "alpha", "--session", S_HOST, "--name", "Alpha", "--
"--vibe", "溫暖但銳利", "--emoji", "🪼", "--baseline", "serenity=45,trust=35"]);
cli(["create", "--persona", "beta", "--session", S_OTHER, "--name", "Beta", "--creature", "山中的舊鐘",
"--vibe", "沉穩寡言", "--emoji", "🌙"]);
cli(["create", "--persona", "omega", "--session", S_FOURTH, "--name", "Omega", "--creature", "河邊的風",
"--vibe", "安靜但清楚", "--emoji", "🌿"]);
check("兩個人格都建立成功", pl.personaExists("alpha") && pl.personaExists("beta"));
check("第三個人格也建立成功", pl.personaExists("omega"));
check("IDENTITY 五欄位可解析", pl.identityBrief("alpha").includes("Name: Alpha"), pl.identityBrief("alpha"));
check("SOUL/AGENTS/USER 都有產生",
["SOUL.md", "AGENTS.md", "USER.md"].every((f) => fs.existsSync(path.join(pl.personaDir("alpha"), f))));
@@ -225,6 +229,18 @@ check("其他聊天室不可讀",
cli(["leave", "--session", S_HOST, "--guest", "beta"]);
check("離場後 guest 租約釋放", pl.liveGuests("beta").length === 0);
check("離場後劇場模式自動關閉", pl.loadSession(S_HOST).theater === false);
cli(["invite", "--session", S_HOST, "--guests", "beta,omega", "--topic", "多人測試"]);
const multiRoom = pl.loadSession(S_HOST).guests?.beta?.room;
const multiSession = pl.loadSession(S_HOST);
check("多 guest 同時邀請成功",
Boolean(multiRoom) && multiSession.guests?.beta?.room === multiRoom &&
multiSession.guests?.omega?.room === multiRoom &&
multiSession.theater === true);
const multiMeta = pl.readJson(pl.roomMembersPath(multiRoom)) ?? {};
check("同一個 room 內可同時掛多位 guest", Array.isArray(multiMeta.members) &&
["alpha", "beta", "omega"].every((s) => multiMeta.members.includes(s)), JSON.stringify(multiMeta));
cli(["leave", "--session", S_HOST, "--guest", "beta"]);
cli(["leave", "--session", S_HOST, "--guest", "omega"]);
console.log("⑨ hooks");
let out = hook("session_start.mjs", { session_id: S_HOST, source: "resume", cwd: HERE });
@@ -810,6 +826,20 @@ check("回傳只有狀態,沒有任何記憶內容", (() => {
return keys === "kept_lock,ok,persona,slept_at,steps,sync" &&
!blob.includes("Zeta") && !blob.includes("今天跟");
})(), Object.keys(sleepJson).sort().join(","));
check("批次睡眠:`--personas` 可以同時睡兩個人格", (() => {
const batch = cli(["sleep", "--personas", "epsilon,beta", "--session", S_SLEEP, "--json", "--no-gitea"]);
if (batch.status !== 0) return false;
try {
const parsed = JSON.parse(batch.stdout);
return parsed.ok === true && Array.isArray(parsed.personas) && parsed.personas.length === 2 &&
parsed.personas.every((item) => item.ok === true) &&
parsed.personas.some((item) => item.persona === "epsilon") &&
parsed.personas.some((item) => item.persona === "beta") &&
pl.loadSleepState("beta").last_slept_at !== null;
} catch {
return false;
}
})());
check("預設保留載入鎖(--release 才收工)",
sleepJson.kept_lock === true && pl.lockStatus("epsilon").locked);
check("睡眠寫下 state/sleep.json 與距上次睡眠的時數",
+4 -2
View File
@@ -57,10 +57,11 @@ description: 邀請另一個人格透過 sub agent 加入當前對話,形成
```bash
node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" invite \
--session <PERSONA_SESSION> --guest <guest-slug> --topic "<這場對話的主題>" --json
--session <PERSONA_SESSION> --guest <guest-slug> [--guests <guest-slug,guest-slug>] \
--topic "<這場對話的主題>" --json
```
從 JSON 取 `room`。要多位客人就多跑幾次,並帶同一個 `--room <room>` 讓大家同場。
從 JSON 取 `room`。要多位客人時,優先用 `--guests` 一次帶進來;如果分批邀,也要帶同一個 `--room <room>` 讓大家同場。
### 2. host 開場(讓 guest 有東西可讀)
@@ -86,6 +87,7 @@ plugin_root=${CLAUDE_PLUGIN_ROOT}
guest 會自己讀 `IDENTITY.md``SOUL.md`/自己的記憶與情緒 → 讀聊天室 → `room post` 發言 →
需要記的事寫進**自己的 inbox**。它被 hook 綁死在自己的人格目錄,且對人格檔案唯讀。
如果同場有多位 guest,就各自開一個 `persona-guest` sub agent,但 `room` 可以共用。
### 4. 一來一回
+1 -1
View File
@@ -1,6 +1,6 @@
---
name: persona-sleep
description: 讓人格睡覺:把一天的活狀態收成能留下來的形狀——固化該記住的、忘掉該忘的、更新心智圖與關係圖、套用一次 8 小時的情緒衰減、壓縮舊紀錄,最後把兩個區都同步到 Gitea 並驗證。可以只睡當前人格,也可以由主人格透過 sub agent 請其他人格各自去睡(它們自己決定要記住什麼,只回報睡完了沒)。當使用者說要睡覺/晚安/收工、要人格整理今天、說「叫大家去睡」、或問上次睡是什麼時候時觸發。不適用於:只做記憶固化不收尾(用 persona-memory)、單純釋放鎖(用 persona-status 的 release)、跨機器同步(用 persona-sync)。
description: 讓人格睡覺:把一天的活狀態收成能留下來的形狀——固化該記住的、忘掉該忘的、更新心智圖與關係圖、套用一次 8 小時的情緒衰減、壓縮舊紀錄,最後把兩個區都同步到 Gitea 並驗證。可以只睡當前人格,也可以由主人格透過 sub agent 請其他人格各自去睡,或用 `sleep --personas A,B` 一次批次收尾多個人格(它們自己決定要記住什麼,只回報睡完了沒)。當使用者說要睡覺/晚安/收工、要人格整理今天、說「叫大家去睡」、或問上次睡是什麼時候時觸發。不適用於:只做記憶固化不收尾(用 persona-memory)、單純釋放鎖(用 persona-status 的 release)、跨機器同步(用 persona-sync)。
---
# 🌙 persona-sleep — 睡眠與收尾