sleep batch targets
This commit is contained in:
+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)
|
||||
|
||||
Reference in New Issue
Block a user