Support batch sleep orchestration #5
@@ -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 agent(prompt 帶 `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,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
|
||||
|
||||
+205
-122
@@ -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 <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 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 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 <slug>`。");
|
||||
if (guest === host) die("不能邀請自己。");
|
||||
if (!pl.personaExists(guest)) die(`人格 \`${guest}\` 不存在。可用:${pl.listPersonas().join(", ")}`);
|
||||
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 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 <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)
|
||||
|
||||
@@ -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 與距上次睡眠的時數",
|
||||
|
||||
@@ -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,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 — 睡眠與收尾
|
||||
|
||||
Reference in New Issue
Block a user