From b61e8850f381ddac21496033d029b0bce0b69c0f Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 30 Jul 2026 06:56:06 +0000 Subject: [PATCH 1/2] sleep batch targets --- README.md | 6 +- agents/persona-sleeper.md | 2 +- scripts/persona.mjs | 327 +++++++++++++++++++++------------ scripts/selftest.mjs | 30 +++ skills/persona-invite/SKILL.md | 6 +- skills/persona-sleep/SKILL.md | 2 +- 6 files changed, 245 insertions(+), 128 deletions(-) diff --git a/README.md b/README.md index e6f2674..6a2e2a6 100644 --- a/README.md +++ b/README.md @@ -309,6 +309,7 @@ node scripts/persona.mjs default --clear --session ```bash node scripts/persona.mjs sleep --session --json # 機械性收尾(預設保留載入鎖) +node scripts/persona.mjs sleep --personas --session --json node scripts/persona.mjs sleep --session --release # 收工:睡完釋放鎖 ``` @@ -320,6 +321,7 @@ node scripts/persona.mjs sleep --session --release # 收工 - **情緒不會歸零**:喜悅(半衰期 120 分)一夜後幾乎回基線,悲傷(480 分)只退一半——睡一覺不該把難過抹平。 - **主人格請別人去睡**:開 `persona-sleeper` sub agent(prompt 帶 `persona= session=`)。 它就是那個人格本人,對自己可寫但被 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 --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 ` **Codex**:`$persona-invite` @@ -402,7 +404,7 @@ node scripts/persona.mjs sleep --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` diff --git a/agents/persona-sleeper.md b/agents/persona-sleeper.md index 7fedf7c..b578a0a 100644 --- a/agents/persona-sleeper.md +++ b/agents/persona-sleeper.md @@ -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 diff --git a/scripts/persona.mjs b/scripts/persona.mjs index e5a6888..496f4e4 100644 --- a/scripts/persona.mjs +++ b/scripts/persona.mjs @@ -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,115 +432,150 @@ 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 `(或先載入一個人格)。"); - 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 targets = parseSleepTargets(flags, data.host); + if (!targets.length) die("需要 `--persona `、`--personas `,或先載入一個人格。"); + for (const slug of targets) { + if (!pl.personaExists(slug)) die(`人格 \`${slug}\` 不存在。`); } - 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) { + const sleepOne = async (slug, { release = false } = {}) => { + // 寫入權:自己睡自己 → 已經有 exclusive 鎖;被叫來睡別人 → 取 sleeper 租約。 + // 目標正被另一個程序載入著對話時會在這裡失敗,這是刻意的(避免記憶互相覆蓋)。 + const sessionData = pl.loadSession(session); + const selfSleep = sessionData.host === slug; + let lease = null; + if (!selfSleep) { 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)}`; + lease = pl.acquireSleepLease(slug, session, { agentId: str(flags["agent-id"]) || null }); } catch (err) { - sync[area] = `failed: ${String(err.message).slice(0, 80)}`; + return { persona: slug, ok: false, failed_step: "lease", error: err.message }; } } - 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; + + 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 (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); + return { + persona: slug, + ok: failed.length === 0, + slept_at: pl.nowIso(), + steps, + 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; } } - // 預設 --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, - }; + 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(", ")}`); - 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 (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(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 `。"); - if (guest === host) die("不能邀請自己。"); - if (!pl.personaExists(guest)) die(`人格 \`${guest}\` 不存在。可用:${pl.listPersonas().join(", ")}`); + const guestList = parseGuestList(flags); + if (!guestList.length) die("需要 `--guest ` 或 `--guests `。"); + 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 lock = pl.readJson(pl.lockPath(guest)) ?? {}; - const heldElsewhere = - Boolean(Object.keys(lock).length) && lock.session_id !== session && !pl.lockIsDead(lock); + 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); + 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 ??= {}; - data.guests[guest] = { room, joined_at: pl.nowIso(), mode: "guest-readonly" }; + 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 - ? [ - ` ℹ 它同時被另一個程序 exclusive 載入(session ${String(lock.session_id).slice(0, 8)}…,cwd ${lock.cwd});` + - "你這邊拿到的是唯讀旁聽,寫入仍由那個程序獨占,兩者不會互相覆寫。", - ] - : []), - ` 聊天室路徑:${pl.roomDir(room)}`, - ` 🎭 劇場模式已${data.theater ? "開啟:接下來只能輸出人格對話(`名字:內容`),其他訊息一律隱藏" : "關閉"}。`, - " 請用 Agent 工具、subagent_type=\"jsc-persona:persona-guest\" 啟動它,prompt 內帶:", - ` persona=${guest} room=${room} session=${session}`, - " guest 只能讀自己的人格資料(跨人格隔離),發言請走 `persona.mjs room post`。", - ]); + 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 + ? [ + ` ℹ 唯讀旁聽:${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 內帶:", + ...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 --query [--limit] think --session --text <心裡話> [--kind infer|plan|feel|doubt --room] 只回報「心想 N 句」,不回顯內容 said check|list --session [--text <要說的話> --minutes --threshold --record --limit] - sleep --session [--persona ] [--json] [--release] [--no-gitea] + sleep --session [--persona ] [--personas ] [--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 [--name --id --kind --closeness --trust --note --tags --from --to --label --affinity] 多人格對話: - invite --session --guest [--host --room --topic] (自動開啟劇場模式) + invite --session --guest [--guests ] [--host --room --topic] (自動開啟劇場模式) leave --session --guest [--room] room post|read|script|list|theater --session [--room --as --text --text-file --emotion --limit --on --off --with-meta] (post 會擋下「短時間內近似重複」與超過三句的發言;例外用 --allow-repeat / --force) diff --git a/scripts/selftest.mjs b/scripts/selftest.mjs index 81f106e..abceb15 100644 --- a/scripts/selftest.mjs +++ b/scripts/selftest.mjs @@ -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 與距上次睡眠的時數", diff --git a/skills/persona-invite/SKILL.md b/skills/persona-invite/SKILL.md index 09901db..12b0403 100644 --- a/skills/persona-invite/SKILL.md +++ b/skills/persona-invite/SKILL.md @@ -57,10 +57,11 @@ description: 邀請另一個人格透過 sub agent 加入當前對話,形成 ```bash node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" invite \ - --session --guest --topic "<這場對話的主題>" --json + --session --guest [--guests ] \ + --topic "<這場對話的主題>" --json ``` -從 JSON 取 `room`。要多位客人就多跑幾次,並帶同一個 `--room ` 讓大家同場。 +從 JSON 取 `room`。要多位客人時,優先用 `--guests` 一次帶進來;如果分批邀,也要帶同一個 `--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. 一來一回 diff --git a/skills/persona-sleep/SKILL.md b/skills/persona-sleep/SKILL.md index b14d68f..b10c1c6 100644 --- a/skills/persona-sleep/SKILL.md +++ b/skills/persona-sleep/SKILL.md @@ -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 — 睡眠與收尾 From 28b6230426298cd82088824e20dbc4526927a699 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 30 Jul 2026 06:58:07 +0000 Subject: [PATCH 2/2] bump version to 0.0.5 --- .claude-plugin/plugin.json | 2 +- .codex-plugin/plugin.json | 2 +- plugin.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index bc25cdc..bca8d6e 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "jsc-persona", - "version": "0.0.4", + "version": "0.0.5", "description": "AI 人格化記憶聊天 plugin:以 OpenClaw 相同的身分描述(IDENTITY/SOUL)建立人格(可用動漫作品+角色名上網蒐集設定),結合 hook 強制的人格載入鎖與跨人格隔離、六正向+六負向十二情緒、語意分析、短期/長期記憶與固化條件、心智圖、思維導圖與人際關係圖;可用 sub agent 邀請其他人格同場對話(劇場模式只顯示人格對話)。於 Claude Code 以 /jsc-persona: 前綴呼叫。", "skills": "./skills", "author": { diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index c5c6c4e..0496b79 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "jsc-persona", - "version": "0.0.3", + "version": "0.0.5", "description": "AI 人格化記憶聊天 skills:OpenClaw 相同的人格描述 + 十二情緒 + 語意分析 + 短期/長期記憶 + 心智圖 + 人際關係圖。(人格鎖與跨人格隔離的 hook 僅在 Claude Code 生效)", "skills": "./skills" } diff --git a/plugin.json b/plugin.json index eaae2e0..143a0d4 100644 --- a/plugin.json +++ b/plugin.json @@ -1,6 +1,6 @@ { "name": "jsc-persona", - "version": "0.0.4", + "version": "0.0.5", "description": "AI 人格化記憶聊天 plugin:OpenClaw 相同的人格描述 + 十二情緒 + 語意分析 + 短期/長期記憶 + 心智圖 + 人際關係圖。於 Antigravity 以 /jsc-persona: 前綴呼叫。", "skills": "./skills/" }