Files
persona/scripts/persona.mjs
T
jiantw83andClaude Opus 5 1d5b049152 fix(consolidate): --forget 套上短期記憶的保護;長期記憶撞名不再靜默覆蓋
兩個資料遺失路徑:

① `--forget N` 無條件刪掉 salience < N 的全部短期記憶,完全不套 shortTermProtected()
   (salience ≥ 80/intent=commit/24 小時內)。prune 有這層保護,這裡沒有。
   實測 `--forget 200` 把一筆 intent=commit、salience 95、剛寫入的承諾一起刪掉,
   而 R4 明文寫「不可遺忘」。改走新的 forgetShortTerm(),跟 prune 同一層保護,
   並回報「有幾筆低於門檻但受保護、沒有刪」。

② slugify() 把標點吃掉:「我的貓」「我的貓?」「我的貓!!」全都落在 `我的貓.md`,
   writeText 直接覆蓋,只保留舊檔的 first_seen/recall_count,內文靜默被換掉。
   front matter 新增 `title:`(原本的 --name),撞名時 die 並給一個沒被占用的檔名
   (`--name 我的貓-2`);真的要覆蓋同一則就加 --force。

selftest +11:--forget 不刪承諾/界線/今天的紀錄但該刪的照刪、撞名被擋、
舊內文原封不動、訊息給得出替代檔名、同名再寫是更新、title 有寫入、--force 仍可覆蓋。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 09:23:35 +00:00

2116 lines
99 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
// persona.mjs — jsc-persona 的人格 / 記憶 / 情緒 / 關係圖 CLI(Node.js,只用內建模組)
//
// 所有子指令都需要 `--session <session_id>`(除了 list / status / gc)。
// session_id 由 SessionStart hook 注入到上下文(PERSONA_SESSION=...),
// hook 會驗證 CLI 帶的 --session 與真實 session 相符,藉此讓「人格鎖」與
// 「跨人格隔離」無法被繞過。
//
// 全域旗標:--json(機器可讀輸出)、--quiet(成功時不輸出,劇場模式用)
import fs from "node:fs";
import path from "node:path";
import process from "node:process";
import zlib from "node:zlib";
import { fileURLToPath } from "node:url";
import * as pl from "./persona-lib.mjs";
import * as gt from "./persona-gitea.mjs";
import * as ic from "./persona-icon.mjs";
const HERE = path.dirname(fileURLToPath(import.meta.url));
const TEMPLATE_DIR = path.join(HERE, "..", "skills", "persona-create", "templates");
const SELF = path.join(HERE, "persona.mjs");
let QUIET = false;
// 本次呼叫的旗標(權限檢查要看 --as-sleeper--agent-id,不想改十幾個 requireOwner 呼叫點)
let CURRENT_FLAGS = {};
function die(message, code = 1) {
process.stderr.write(`✖ ${message}\n`);
process.exit(code);
}
function say(line = "") {
if (!QUIET) process.stdout.write(`${line}\n`);
}
function ok(message) {
say(`✔ ${message}`);
}
function emit(payload, asJson, lines) {
if (asJson) {
process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
} else {
say(lines.join("\n"));
}
}
// --------------------------------------------------------------------------- //
// 參數解析
// --------------------------------------------------------------------------- //
const FLAGS = new Set([
"json", "quiet", "force", "takeover", "as-guest", "as-sleeper", "on", "off", "with-meta", "all",
"with-journal", "gzip", "record", "load", "allow-repeat", "clear", "release", "keep-lock", "contact",
"if-due", "no-gitea", "public", "rename", "from-source",
]);
function parseArgs(argv) {
const out = { _: [], flags: {} };
for (let i = 0; i < argv.length; i += 1) {
const token = argv[i];
if (!token.startsWith("--")) {
out._.push(token);
continue;
}
const body = token.slice(2);
const eq = body.indexOf("=");
if (eq >= 0) {
out.flags[body.slice(0, eq)] = body.slice(eq + 1);
continue;
}
if (FLAGS.has(body)) {
out.flags[body] = true;
continue;
}
const next = argv[i + 1];
if (next === undefined || (next.startsWith("--") && !/^--?\d/.test(next))) {
out.flags[body] = true;
} else {
out.flags[body] = next;
i += 1;
}
}
return out;
}
const num = (value, fallback = null) => (value === undefined || value === true ? fallback : Number(value));
const str = (value, fallback = "") => (value === undefined || value === true ? fallback : String(value));
const csv = (value) => str(value).split(",").map((s) => s.trim()).filter(Boolean);
/** `joy=+12,anger=-5` → { joy: 12, anger: -5 } */
function parseDeltas(raw) {
const out = {};
for (const chunk of str(raw).split(",")) {
const trimmed = chunk.trim();
if (!trimmed.includes("=")) continue;
const [key, value] = [trimmed.slice(0, trimmed.indexOf("=")).trim(), trimmed.slice(trimmed.indexOf("=") + 1)];
const parsed = Number(value);
if (Number.isFinite(parsed)) out[key] = parsed;
}
return out;
}
function requireSession(flags) {
const session = str(flags.session);
if (!session) die("缺少 `--session <session_id>`(值取自上下文的 `PERSONA_SESSION=`)。");
return session;
}
// --------------------------------------------------------------------------- //
// 權限檢查
// --------------------------------------------------------------------------- //
/**
* 睡眠 sub agent 的寫入權:它替某個人格收尾,手上沒有 exclusive 鎖,只有 5 分鐘的 sleeper 租約。
* 第一個收尾指令會把租約取起來(`acquireSleepLease` 內含鎖檢查:目標正被別的程序載入著就拒絕),
* 之後每個指令續租,最後由 `sleep` 還掉。`--as-sleeper` 由 PreToolUse hook 把關,
* 只有 persona-sleeper 型的 sub agent 能用。
*/
function sleeperAccess(slug, sessionId, agentId) {
const mine = pl.liveSleepers(slug).find((s) => s.session_id === sessionId);
if (mine) {
pl.heartbeatSleepLease(slug, sessionId, mine.agent_id ?? null);
return mine;
}
return pl.acquireSleepLease(slug, sessionId, { agentId: agentId || null });
}
/** 呼叫者必須是這個人格的 exclusive 持有者(或替它收尾的 sleeper)。 */
function requireOwner(slug, sessionId) {
if (!slug) die("未指定人格,且本 session 沒有載入人格。");
if (!pl.personaExists(slug)) die(`人格 \`${slug}\` 不存在。可用:${pl.listPersonas().join(", ") || "(無)"}`);
const data = pl.loadSession(sessionId);
if (CURRENT_FLAGS["as-sleeper"]) {
sleeperAccess(slug, sessionId, str(CURRENT_FLAGS["agent-id"]));
return data;
}
if (data.host !== slug) {
die(
`本 session 的 host 人格是 \`${data.host || "(未載入)"}\`,不是 \`${slug}\`。` +
"禁止跨人格操作;請先 `release` 再 `load`。",
);
}
const lock = pl.readJson(pl.lockPath(slug)) ?? {};
if (lock.session_id !== sessionId) {
die(`人格 \`${slug}\` 的載入鎖不屬於本 session,請重新 \`load\`(必要時加 --takeover)。`);
}
pl.heartbeatLock(slug, sessionId);
return data;
}
/**
* 呼叫者是 hostowner)或以 `--as-guest` 自稱的受邀人格。
* 受邀人格的資料只有它自己(persona-guest sub agent)能讀;主程序即使邀請了它,
* 也只能看它在聊天室說出口的話。`--as-guest` 由 PreToolUse hook 把關。
*/
function requireMember(slug, sessionId, asGuest = false) {
if (!slug) die("未指定人格,且本 session 沒有載入人格。");
if (!pl.personaExists(slug)) die(`人格 \`${slug}\` 不存在。`);
const data = pl.loadSession(sessionId);
// sleeper 讀自己的資料:`requireOwner` 早就放它寫了,讀反而被擋在外面——
// 少了這個分支,它就得在看不到 brief/recall 的情況下決定要固化什麼。
if (CURRENT_FLAGS["as-sleeper"]) {
if (asGuest) die("`--as-sleeper` 與 `--as-guest` 不能一起用(那是兩種不同的身分)。");
sleeperAccess(slug, sessionId, str(CURRENT_FLAGS["agent-id"]));
return [data, "sleeper"];
}
if (data.host === slug) {
if (asGuest) die(`\`${slug}\` 是本 session 的 host 人格,不需要也不得使用 \`--as-guest\`。`);
pl.heartbeatLock(slug, sessionId);
return [data, "owner"];
}
if (slug in (data.guests || {})) {
if (!asGuest) {
die(
`\`${slug}\` 是本 session 邀請的 guest 人格,它的記憶與情緒不對主程序開放(跨人格資料隔離)。` +
"你只能透過 `room read` 看它說出口的話;要以它的身分行動必須是 persona-guest sub agent 並帶 `--as-guest`。",
);
}
return [data, "guest"];
}
die(`人格 \`${slug}\` 未被本 session 載入或邀請,禁止存取(跨人格資料隔離)。`);
return [null, null];
}
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 區推上去。
* 背景執行、失敗不阻斷:同步永遠不該卡住對話。
*/
function pushWikiLater(slug, session, flags) {
if (flags["no-gitea"] || gt.giteaProblem() || !gt.personaCode(slug)) return;
gt.pushInBackground(slug, "wiki", session, SELF);
}
function renderTemplate(name, mapping) {
let text = fs.readFileSync(path.join(TEMPLATE_DIR, name), "utf8");
for (const [key, value] of Object.entries(mapping)) text = text.replaceAll(`{{${key}}}`, String(value));
return text;
}
// --------------------------------------------------------------------------- //
// 子指令
// --------------------------------------------------------------------------- //
const commands = {};
commands.create = async ({ flags }) => {
const session = requireSession(flags);
// 人格編號 = 英文名全大寫 + 兩位索引(同名才遞增)。編號就是 Gitea 存取庫的名稱。
let code = str(flags.code);
if (code && !gt.validCode(code)) die(`編號 \`${code}\` 不合法,格式是「英文名全大寫-兩位數」,例如 \`ASUNA-01\`。`);
if (!code) {
const base = gt.normalizeRomaji(str(flags.romaji) || str(flags.persona));
if (!base) {
die(
"需要 `--romaji <英文名>`:人格編號是英文名全大寫加索引(例:Asuna → `ASUNA-01`)。" +
"中文名請先轉成羅馬拼音並跟使用者確認拼法,再帶進來。",
);
}
code = gt.nextCode(base);
if (!code) die(`\`${base}\` 的編號已經用到 99,請換一個英文名。`);
}
// 沒指定 --persona 就用編號當目錄名(一個識別走到底);指定了就沿用(相容既有人格)
const slug = str(flags.persona) || code;
if (!pl.validSlug(slug)) die("人格目錄名只能是英數與連字號(最長 48 字),建議直接用編號,例如 `ASUNA-01`。");
if (pl.personaExists(slug) && !flags.force) {
die(`人格 \`${slug}\` 已存在(${pl.personaDir(slug)})。要覆寫請加 --force。`);
}
const root = pl.ensurePersonaDirs(slug);
const mapping = {
SLUG: slug,
NAME: str(flags.name) || slug,
CREATURE: str(flags.creature),
GENDER: str(flags.gender) || "未指定",
VIBE: str(flags.vibe),
EMOJI: str(flags.emoji),
AVATAR: str(flags.avatar),
CREATED: pl.nowIso(),
};
for (const filename of ["IDENTITY.md", "SOUL.md", "AGENTS.md", "USER.md"]) {
const target = path.join(root, filename);
if (fs.existsSync(target) && !flags.force) continue;
pl.writeText(target, renderTemplate(filename, mapping));
}
pl.writeJson(pl.emotionPath(slug), pl.defaultEmotionState(parseDeltas(flags.baseline)));
pl.writeJson(pl.configPath(slug), {
persona: slug,
code,
romaji: gt.codePrefix(code),
display_name: mapping.NAME,
created_at: pl.nowIso(),
created_by_session: session,
origin: str(flags.origin) || "custom",
source_work: str(flags.work),
schema: 2,
});
pl.writeJson(pl.relationsJson(slug), { nodes: [], edges: [] });
pl.writeText(
pl.mindmapPath(slug),
["%% 心智圖(長期語意結構):概念如何互相勾連", "mindmap", ` root((${mapping.NAME}))`, " 自我", " 使用者", " 共同經驗", ""].join("\n"),
);
pl.rebuildIndex(slug);
pl.acquireLock(slug, session, { cwd: str(flags.cwd) || null });
pl.bindHost(session, slug, { cwd: str(flags.cwd) || null });
ok(`人格 \`${slug}\`(編號 \`${code}\`)建立於 ${root},已取得載入鎖並綁定本 session。`);
say(` 下一步:補完 ${root}/IDENTITY.md 與 SOUL.md,再用 /jsc-persona:persona-chat 開始對話。`);
// Gitea 上的存取庫名稱就是編號。身分還沒補完,這裡只開庫;內容之後由各時機自動 push。
if (!flags["no-gitea"] && !gt.giteaProblem()) {
try {
const info = await gt.initRemote(slug, { code, private_: !flags.public });
say(` 📦 Gitea${info.repo.html_url}${info.created ? "已建立" : "沿用既有"}${info.repo.private ? "私有" : "公開"}`);
for (const key of gt.AREA_KEYS) {
const res = info.results[key] || {};
say(
res.ok
? ` ${gt.AREAS[key].label}${gt.AREAS[key].why}):${res.changed ? `已推送 ${res.files} 個檔案` : "目前沒有內容"}`
: ` ⚠ ${gt.AREAS[key].label}推送失敗:${String(res.reason).slice(0, 160)}`,
);
}
} catch (err) {
say(` ⚠ Gitea 存取庫建立失敗(不影響本機使用):${err.message}`);
say(" 之後可用 `sync init` 補建。");
}
} else if (!flags["no-gitea"]) {
say(` 未同步到 Gitea${gt.giteaProblem()}`);
}
};
commands.list = ({ flags }) => {
const rows = pl.listPersonas().map((slug) => {
const status = pl.lockStatus(slug);
let longTerm = 0;
try {
longTerm = fs.readdirSync(pl.longTermDir(slug)).filter((f) => f.endsWith(".md")).length;
} catch {
longTerm = 0;
}
return {
persona: slug,
code: gt.personaCode(slug),
identity: pl.identityBrief(slug),
locked: status.locked,
stale: status.stale,
owner_session: String(status.owner.session_id || "").slice(0, 8),
owner_cwd: status.owner.cwd,
guests: status.guests.length,
long_term: longTerm,
short_term: pl.readJsonl(pl.shortTermPath(slug)).length,
};
});
const lines = [`人格倉庫:${pl.personaHome()}`];
if (!rows.length) lines.push("(尚無人格,用 /jsc-persona:persona-create 或 /jsc-persona:persona-anime 建立)");
for (const r of rows) {
const state = r.locked ? "🔒 已載入" : r.stale ? "⚠ 死鎖可接手" : "🔓 空閒";
lines.push(
`- \`${r.persona}\`${r.code && r.code !== r.persona ? `(編號 ${r.code}` : ""} ${state}` +
(r.locked ? `session ${r.owner_session}…, cwd ${r.owner_cwd}` : "") +
`guest ${r.guests}|長期記憶 ${r.long_term}|短期 ${r.short_term}` +
(r.code ? "" : "|⚠ 尚無編號") +
(r.identity ? `${r.identity}` : ""),
);
}
emit({ home: pl.personaHome(), personas: rows }, flags.json, lines);
};
commands.load = async ({ flags }) => {
const session = requireSession(flags);
const slug = str(flags.persona);
if (!pl.personaExists(slug)) die(`人格 \`${slug}\` 不存在。可用:${pl.listPersonas().join(", ") || "(無)"}`);
const data = pl.loadSession(session);
if (data.host && data.host !== slug) {
die(
`本 session 已載入人格 \`${data.host}\`。一個程序只能載入一個人格;` +
`請先 \`release --session <id>\` 再載入 \`${slug}\`` +
"(若只是想讓兩個人格對話,請用 /jsc-persona:persona-invite)。",
);
}
let lock;
try {
lock = pl.acquireLock(slug, session, { cwd: str(flags.cwd) || null, takeover: Boolean(flags.takeover) });
} catch (err) {
die(`${err.message}\n 若確定那個程序已結束,可加 --takeover 接手。`);
}
pl.bindHost(session, slug, { cwd: str(flags.cwd) || null });
// 載入時先把遠端拉回來(別台機器可能動過),衝突就停下來讓使用者決定
const pulled = [];
if (!flags["no-gitea"] && !gt.giteaProblem() && gt.personaCode(slug)) {
for (const area of gt.AREA_KEYS) {
try {
pulled.push(await gt.pullArea(slug, area));
} catch (err) {
pulled.push({ ok: false, area, reason: err.message });
}
}
}
pl.pruneShortTerm(slug);
pl.rebuildIndex(slug);
const lines = [`✔ 已載入人格 \`${slug}\`exclusivesession ${session.slice(0, 8)}…,租約 ${lock.lease_seconds}s`];
for (const res of pulled) {
if (res.conflicts?.length) {
lines.push(
`⚠ ${gt.AREAS[res.area].label}有衝突,**沒有覆蓋本機**${res.conflicts.slice(0, 5).join(", ")}` +
"。請告訴使用者:本機與 Gitea 都改過同一份資料,要保留哪一邊(`sync pull --force` 會以遠端為準)。",
);
} else if (res.ok && res.written?.length) {
lines.push(`↓ ${gt.AREAS[res.area].label}從 Gitea 拉回 ${res.written.length} 個檔案。`);
} else if (!res.ok && !res.skipped) {
lines.push(`⚠ ${gt.AREAS[res.area]?.label || res.area}同步失敗(不影響本機):${String(res.reason).slice(0, 120)}`);
}
}
if (lock.took_over_from) {
const prev = lock.took_over_from;
lines.push(
`⚠ 這把鎖是接手來的:原持有者 session ${String(prev.session_id || "").slice(0, 8)}…(cwd ${prev.cwd}` +
`已失聯 ${prev.stale_minutes} 分鐘。請向使用者說明,若那個程序其實還活著,兩邊的記憶可能會互相覆蓋。`,
);
}
const context = pl.turnContext(slug, session);
lines.push(context);
emit({ persona: slug, lock, context }, flags.json, lines);
};
commands.release = async ({ flags }) => {
const session = requireSession(flags);
const data = pl.loadSession(session);
const slug = str(flags.persona) || data.host;
if (!slug) die("本 session 沒有載入任何人格。");
// 收工前把兩區都推上去(失敗不阻斷釋放,人格不能被鎖在網路問題裡)
if (!flags["no-gitea"] && !gt.giteaProblem() && pl.personaExists(slug) && gt.personaCode(slug)) {
for (const area of gt.AREA_KEYS) {
try {
const res = await gt.pushArea(slug, area, { message: `release: 對話結束 ${pl.nowIso()}` });
if (res.ok && res.changed) say(` ↑ ${gt.AREAS[area].label}已推上 Gitea。`);
else if (!res.ok && !res.skipped) say(` ⚠ ${gt.AREAS[area].label}推送失敗:${String(res.reason).slice(0, 120)}`);
} catch (err) {
say(` ⚠ ${gt.AREAS[area].label}推送失敗:${err.message.slice(0, 120)}`);
}
}
}
const released = pl.unbindSession(session);
ok(`已釋放人格 \`${slug}\` 的載入鎖${released.guests.length ? `,並退出 guest${released.guests.join(", ")}` : "。"}`);
};
commands.status = ({ flags }) => {
const slug = str(flags.persona);
if (slug) {
const status = pl.lockStatus(slug);
const lines = [
`人格 \`${slug}\`${status.locked ? "🔒 已載入" : status.stale ? "⚠ 死鎖可接手" : "🔓 空閒"}`,
` owner: ${JSON.stringify(status.owner)}`,
` guests: ${JSON.stringify(status.guests)}`,
];
if (pl.personaExists(slug)) lines.push(` ${pl.emotionBrief(slug)}`);
emit(status, flags.json, lines);
return;
}
const session = str(flags.session);
const data = session ? pl.loadSession(session) : {};
emit(data, flags.json, [
`session ${(session || "-").slice(0, 12)}…`,
` host 人格:${data.host || "(未載入)"}`,
` guest 人格:${Object.keys(data.guests || {}).join(", ") || "(無)"}`,
` 聊天室:${(data.rooms || []).join(", ") || "(無)"}`,
` 劇場模式:${data.theater ? "🎭 開啟(只輸出人格對話)" : "關閉"}`,
]);
};
// 睡眠:把一天的活狀態收成能留下來的形狀。
//
// 這裡只做**機械性**的收尾(順序即相依)。需要判斷的部分——哪些短期記憶值得固化、
// 日記要寫什麼、心智圖怎麼接——是那個人格自己的事,由 /jsc-persona:persona-sleep
// 在呼叫本指令**之前**完成。
//
// 回傳(`--json`)只有「睡完了沒、哪一步出錯」:主人格拿到的就是這份報告,
// 裡面不含任何記憶內容——回傳值本身就是一條會繞過隔離的通道,所以在這裡封死。
commands.sleep = async ({ flags }) => {
const session = requireSession(flags);
const data = pl.loadSession(session);
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 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) {
return { persona: slug, ok: false, failed_step: "lease", error: err.message };
}
}
const steps = [];
const run = async (step, fn) => {
try {
const detail = await fn();
// 有細節就帶回去(例如裁掉幾筆短期記憶)——不做無聲的裁切
steps.push(detail && typeof detail === "object" ? { step, ok: true, detail } : { 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", () => {
// 真的講到話的人 → 蓋上「最後一次接觸」的時間戳(主動關心的依據)。
// **提到不是接觸**:在日記裡寫到某個人,不該把他的沉默計時歸零。
for (const name of pl.contactsFromRooms(slug)) pl.stampContact(slug, name);
pl.renderRelations(slug);
});
// 不做無聲的裁切:清掉幾筆、為什麼清、保護了幾筆,都要寫進回報
await run("prune-short-term", () => pl.pruneShortTermDetail(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;
}
}
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 (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);
};
// 預設人格:開新 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);
if (data.host) pl.heartbeatLock(data.host, session);
for (const [guest, info] of Object.entries(data.guests || {})) {
pl.addGuestLease(guest, session, info.room || "", data.host || "");
}
ok(`heartbeathost=${data.host}guests=${Object.keys(data.guests || {}).join(", ") || "(無)"}`);
};
commands.show = ({ flags }) => {
const session = requireSession(flags);
const slug = hostOf(flags, session);
requireMember(slug, session, Boolean(flags["as-guest"]));
const files = { identity: "IDENTITY.md", soul: "SOUL.md", agents: "AGENTS.md", user: "USER.md" };
const what = str(flags.what) || "all";
const chosen = what === "all" ? Object.values(files) : [files[what]].filter(Boolean);
if (!chosen.length) die(`--what 只能是 ${Object.keys(files).join("/")}/all。`);
const out = [];
for (const filename of chosen) {
const file = path.join(pl.personaDir(slug), filename);
if (!fs.existsSync(file)) continue;
// 只給實際內容,模板註解(<!-- ... -->)對人格認知沒幫助
const body = fs.readFileSync(file, "utf8").replace(/<!--[\s\S]*?-->\n?/g, "").trimEnd();
out.push(`===== ${filename} =====\n${body}`);
}
out.push(`===== 狀態 =====\n${pl.emotionBrief(slug)}`);
process.stdout.write(`${out.join("\n\n")}\n`);
};
commands.brief = ({ flags }) => {
const session = requireSession(flags);
const slug = hostOf(flags, session);
requireMember(slug, session, Boolean(flags["as-guest"]));
process.stdout.write(`${pl.turnContext(slug, session, str(flags.query))}\n`);
};
commands.remember = ({ flags }) => {
const session = requireSession(flags);
const slug = hostOf(flags, session);
const [, role] = requireMember(slug, session, Boolean(flags["as-guest"]));
const scope = str(flags.scope) || "short";
if (role === "guest" && scope !== "inbox") {
die("guestsub agent)只能寫入 inbox`--scope inbox --room <room>`。");
}
const text = str(flags.text);
if (!text) die("需要 `--text`。");
const entry = {
ts: pl.nowIso(),
role: str(flags.role) || "user",
text,
topics: csv(flags.topics),
entities: csv(flags.entities),
intent: str(flags.intent),
salience: num(flags.salience, 40),
emotion_deltas: parseDeltas(flags.emotion),
room: str(flags.room) || null,
session: session.slice(0, 8),
};
if (scope === "inbox") {
if (!entry.room) die("`--scope inbox` 必須指定 `--room`。");
pl.appendJsonl(pl.inboxPath(slug, entry.room), entry);
ok(`已寫入 \`${slug}\` 的 inboxroom ${entry.room});等它下次自己載入時再固化。`);
return;
}
pl.rememberShort(slug, entry);
const kept = pl.pruneShortTerm(slug);
if (Object.keys(entry.emotion_deltas).length) {
const state = pl.applyEmotion(pl.loadEmotion(slug), entry.emotion_deltas, text.slice(0, 80));
pl.writeJson(pl.emotionPath(slug), state);
pl.appendJsonl(pl.journalPath(slug), {
ts: pl.nowIso(), kind: "emotion", trigger: text.slice(0, 120),
deltas: entry.emotion_deltas, levels: state.levels, mood: pl.mood(state),
});
}
ok(`已寫入短期記憶(顯著度 ${entry.salience},目前 ${kept} 筆)。`);
const { candidates } = pl.promotionCandidates(slug);
if (candidates.length) {
const rules = [...new Set(candidates.flatMap((c) => c.rules))].sort().join("/");
say(` ⚠ 有 ${candidates.length} 組已達固化條件(${rules})→ /jsc-persona:persona-memory`);
}
if (Object.keys(entry.emotion_deltas).length) say(` ${pl.emotionBrief(slug)}`);
};
commands.recall = ({ flags }) => {
const session = requireSession(flags);
const slug = hostOf(flags, session);
requireMember(slug, session, Boolean(flags["as-guest"]));
const query = str(flags.query);
if (!query) die("需要 `--query`。");
const limit = num(flags.limit, 5);
const hits = pl.recall(slug, query, limit);
const lines = [`「${query}」的長期記憶命中 ${hits.length} 則:`];
for (const meta of hits) {
const first = (meta._body || "").split("\n")[0] || "";
lines.push(`- ${meta._name}${meta.type || "fact"}|顯著度 ${meta.salience ?? "?"}${first.slice(0, 120)}`);
}
const recents = pl.recentShort(slug, limit);
if (recents.length) {
lines.push("短期記憶(最近):");
for (const row of recents) lines.push(`- [${row.role || "?"}] ${String(row.text || "").slice(0, 110)}`);
}
// 心裡話也找得到——害羞的人格,重要的東西幾乎都在心裡那句
const inner = pl.recallInner(slug, query, limit);
if (inner.length) {
lines.push("心裡話(只有你自己看得到,不要講給他聽):");
for (const row of inner) lines.push(`- 💭 [${row.kind || "infer"}] ${String(row.text || "").slice(0, 110)}`);
}
pl.touchRecall(slug, hits.map((m) => m._name));
emit({ persona: slug, long_term: hits, short_term: recents, inner }, flags.json, lines);
};
/**
* 心裡話:推導、盤算、對記憶的比對……全部寫在這裡,不說出口。
* 永遠只回報「心想 N 句」,不回顯內容——使用者看到的是狀態,不是你的內心。
*/
commands.think = ({ flags }) => {
const session = requireSession(flags);
const slug = hostOf(flags, session);
const [, role] = requireMember(slug, session, Boolean(flags["as-guest"]));
const text = str(flags.text);
if (!text) die("需要 `--text`(心裡話內容;不會顯示給使用者)。");
const kind = str(flags.kind) || "infer";
const room = str(flags.room) || null;
let count;
if (role === "guest") {
// guest 對自己的人格檔案唯讀,心裡話跟見聞一樣先進 inbox,回家再消化
if (!room) die("guestsub agent)的心裡話要帶 `--room`,會寫進自己的 inbox。");
pl.appendJsonl(pl.inboxPath(slug, room), {
ts: pl.nowIso(), role: "inner", kind, text, room, salience: num(flags.salience, 35),
});
count = pl.readJsonl(pl.inboxPath(slug, room)).filter((r) => r.role === "inner").length;
} else {
pl.recordInner(slug, text, { kind, room });
count = pl.innerCount(slug);
}
emit({ persona: slug, kind, count }, flags.json, [`💭 心想 ${count} 句`]);
};
/** 說過的話:查最近說了什麼、以及「這句是不是又要再說一次」。 */
commands.said = ({ flags, positional }) => {
const session = requireSession(flags);
const slug = hostOf(flags, session);
requireMember(slug, session, Boolean(flags["as-guest"]));
const action = positional[0] || "check";
if (action === "list") {
const rows = pl.recentSaid(slug, num(flags.limit, 8));
emit({ persona: slug, said: rows }, flags.json, [
`\`${slug}\` 最近說過的話(${rows.length} 則):`,
...rows.map((r) => ` - [${r.ts}] ${String(r.text || "").replace(/\n/g, " ").slice(0, 100)}`),
]);
return;
}
if (action === "check") {
const text = str(flags.text);
if (!text) die("需要 `--text`(你打算說的話)。");
const opts = {
minutes: num(flags.minutes, pl.REPEAT_WINDOW_MINUTES),
threshold: num(flags.threshold, pl.REPEAT_THRESHOLD),
};
const repeat = pl.saidRepeat(slug, text, opts);
const sentences = pl.sentenceCount(text);
const tooLong = sentences > pl.MAX_SENTENCES;
const lines = [];
if (repeat) {
lines.push(
`⚠ 這句 ${repeat.minutes_ago} 分鐘前講過了(像了 ${repeat.similarity}):「${repeat.text.slice(0, 60)}」`,
" → 換個角度、補點新的,或直接推進話題。",
);
}
if (tooLong) lines.push(`⚠ 講太多了,${sentences} 句(上限 ${pl.MAX_SENTENCES})→ 挑最想說的那一兩句。`);
const lint = pl.speechLint(text);
const blockers = lint.filter((i) => i.level !== "hint");
for (const issue of blockers) lines.push(`⚠ ${pl.speechLintMessage(issue)}。`);
// hint 不算不通過(例如講到自己的過去),但要提醒去驗證
for (const issue of lint.filter((i) => i.level === "hint")) lines.push(${pl.speechLintMessage(issue)}。`);
const clean = !repeat && !tooLong && !blockers.length;
if (clean) {
lines.push(`✔ 沒講過,${sentences} 句,可以說。`);
if (flags.record) pl.recordSaid(slug, text, { kind: "reply" });
}
emit({ persona: slug, repeat, sentences, lint, ok: clean }, flags.json, lines);
return;
}
die(`未知 action${action}(可用 check/list`);
};
/** 短期 → 長期的「轉入條件」評估:列出達標的候選與依據。 */
commands.candidates = ({ flags }) => {
const session = requireSession(flags);
const slug = hostOf(flags, session);
requireOwner(slug, session);
const result = pl.promotionCandidates(slug);
const lines = [
`人格 \`${slug}\`:短期記憶 ${result.total} 筆,達固化條件的候選 ${result.candidates.length} 組` +
`${result.pressure ? "(已達容量壓力 R6" : ""}`,
"轉入條件:",
...pl.PROMOTION_RULES.map((r) => ` ${r.id} ${r.label}`),
];
if (!result.candidates.length) lines.push("目前沒有需要固化的內容(未達任何條件)。");
// 心裡話裡反覆出現的念頭:想過好幾次的事,多半是真的重要
const thoughts = pl.innerCandidates(slug);
if (thoughts.length) {
lines.push(`\n心裡話裡一直在想的事(${thoughts.length} 組,24 小時內)——要不要記住由你決定:`);
for (const g of thoughts) {
lines.push(` 「${g.token}」想過 ${g.count} 次`);
for (const e of g.entries.slice(-2)) lines.push(` - 💭 ${String(e.text || "").slice(0, 80)}`);
}
}
for (const cand of result.candidates) {
lines.push(
`\n[${cand.rules.join("+")}] ${cand.kind}${cand.key}」→ 建議 type=${cand.suggested_type}, ` +
`salience=${cand.suggested_salience}${cand.entries.length} 筆依據)`,
);
for (const entry of cand.entries.slice(0, 6)) {
lines.push(` - [${entry.role || "?"}] ${String(entry.text || "").slice(0, 90)}(顯著度 ${entry.salience ?? "?"}`);
}
}
emit(result, flags.json, lines);
};
commands.consolidate = ({ flags }) => {
const session = requireSession(flags);
const slug = hostOf(flags, session);
requireOwner(slug, session);
const rawName = str(flags.name);
if (!rawName) die("需要 `--name`(長期記憶的檔名/識別)。");
const name = pl.slugify(rawName);
const file = path.join(pl.longTermDir(slug), `${name}.md`);
const today = pl.nowIso().slice(0, 10);
let existing = {};
if (fs.existsSync(file)) [existing] = pl.parseFrontMatter(fs.readFileSync(file, "utf8"));
// slugify 會把標點吃掉:「我的貓」「我的貓?」「我的貓!!」全都落在 `我的貓.md` 上。
// 以前直接覆蓋,只留下舊檔的 first_seenrecall_count,內文靜默被換成別則記憶。
// 這裡改成擋下來:不同的 --name 撞到同一個檔就 die,並給一個沒被占用的檔名。
const priorTitle = existing.title ?? existing.name ?? null;
if (priorTitle !== null && priorTitle !== rawName && !flags.force) {
let alt = name;
for (let i = 2; i < 100 && fs.existsSync(path.join(pl.longTermDir(slug), `${alt}.md`)); i += 1) {
alt = `${name}-${i}`;
}
die(
`\`${file}\` 已經是「${priorTitle}」的長期記憶,但這次的 --name 是「${rawName}」——` +
`兩個名字 slugify 之後都是 \`${name}\`,寫下去會把舊的內文換掉。\n` +
` 換個名字(例如 \`--name ${alt}\`),或確定要覆蓋同一則就加 --force。`,
);
}
let body = str(flags.body);
if (flags["body-file"]) body = fs.readFileSync(str(flags["body-file"]), "utf8");
if (!body.trim()) die("需要 `--body` 或 `--body-file`。");
const type = str(flags.type) || "fact";
// diary:睡眠時寫的第一人稱回顧(persona-sleeppersona-sleeper 都用這個型別)
const VALID_TYPES = [
"fact", "preference", "event", "promise", "relationship", "insight", "boundary", "canon", "diary",
];
if (!VALID_TYPES.includes(type)) die(`--type 只能是 ${VALID_TYPES.join("/")}。`);
const front = [
"---",
`name: ${name}`,
// 原本的 --name(沒被 slugify 吃掉的那個)。下次撞名時就是靠這行認出「不是同一則」。
`title: ${rawName.replace(/[\r\n]+/g, " ").replace(/-{3,}/g, "—").trim().slice(0, 120)}`,
`type: ${type}`,
`about: [${csv(flags.about).join(", ") || "user"}]`,
`topics: [${csv(flags.topics).join(", ")}]`,
`salience: ${num(flags.salience, 60)}`,
`emotion: ${str(flags.emotion) || "none"}`,
`rules: ${str(flags.rules) || "manual"}`,
`first_seen: ${existing.first_seen || today}`,
`last_seen: ${today}`,
`recall_count: ${existing.recall_count || 0}`,
`source: ${str(flags.source) || "short-term"}`,
"---",
"",
body.trim(),
"",
];
pl.writeText(file, front.join("\n"));
const total = pl.rebuildIndex(slug);
const forget = num(flags.forget, null);
if (forget !== null) {
// 跟 prune 套同一層保護:承諾、界線與今天的紀錄不能被固化順手洗掉(R4)
const cut = pl.forgetShortTerm(slug, forget);
say(` 短期記憶已淘汰顯著度 < ${forget} 的項目,剩 ${cut.kept} 筆。`);
if (cut.protected) {
say(` 其中 ${cut.protected} 筆低於門檻但受保護,沒有刪:` +
`顯著度 ≥ ${pl.SHORT_TERM_PROTECT_SALIENCE}、intent=commit,或 ${pl.SHORT_TERM_PROTECT_HOURS} 小時內。`);
}
}
ok(`長期記憶 \`${name}\` 已寫入(共 ${total} 則),INDEX.md 已重建。`);
pushWikiLater(slug, session, flags); // 固化=Wiki 區(低頻設定)該更新了
};
commands.prune = ({ flags }) => {
const session = requireSession(flags);
const slug = hostOf(flags, session);
requireOwner(slug, session);
const kept = pl.pruneShortTerm(slug);
ok(`短期記憶已裁剪,剩 ${kept} 筆(保留上限 ${pl.SHORT_TERM_KEEP} 筆 / ${pl.SHORT_TERM_DAYS} 天)。`);
};
commands.reindex = ({ flags }) => {
const session = requireSession(flags);
const slug = hostOf(flags, session);
requireOwner(slug, session);
ok(`INDEX.md 重建完成(${pl.rebuildIndex(slug)} 則長期記憶)。`);
pushWikiLater(slug, session, flags);
};
commands.emotion = ({ flags }) => {
const session = requireSession(flags);
const slug = hostOf(flags, session);
const [, role] = requireMember(slug, session, Boolean(flags["as-guest"]));
// 讀對方那句話的情緒訊號(不改狀態,只回報;`--read` 可以單獨拿來測詞表)
const readText = str(flags.read);
const read = readText ? pl.readUserEmotion(readText) : null;
if (read) {
emit({ persona: slug, read }, flags.json, [
`讀「${readText.slice(0, 40)}」:`,
read.signals.length
? ` 讀到:${read.signals.map((s) => `${s.zh} ${s.score}`).join("、")}|強度 ${read.intensity}${read.confident ? "夠明顯,可以參考" : "很弱,別當真"}`
: " 這句沒讀出什麼情緒。",
...(read.signals.length ? [` 怎麼接:${pl.RESPONSE_STANCE[read.signals[0].key]}`] : []),
]);
return;
}
if (flags.audit) {
const audit = pl.feltAudit(slug, num(flags.limit, 20));
const trend = pl.feltTrend(slug);
emit({ persona: slug, audit, trend }, flags.json, [
`\`${slug}\` 最近 ${audit.rounds} 輪的情緒套用:`,
` 往舒服的方向 ${audit.positive}|往難受的方向 ${audit.negative}` +
(audit.positive_ratio === null ? "" : ` → 正向佔 ${audit.positive_ratio}%`),
audit.positive_ratio !== null && audit.positive_ratio >= 90
? " ⚠ 幾乎都在給自己加分。delta 是你自己挑的,這種偏差沒人會替你抓。"
: " 兩邊都有,還算誠實。",
trend ? ` 對方的走向:${trend.zh} ${trend.direction}(近 ${trend.rounds} 輪)` : " 還沒有足夠的輪次算走向。",
]);
return;
}
if (flags.apply && role === "guest") die("guestsub agent)不得改寫人格的情緒狀態。");
// 親近度會放大或縮小衝擊:同一句話,從枕邊人跟從生人嘴裡出來不一樣
const rel = flags.apply ? pl.relationGain(slug, str(flags.from) || null) : null;
let deltas = null;
let state;
if (role === "guest") {
// guest 不寫回,所以也不需要鎖
state = pl.decayEmotion(pl.loadEmotion(slug));
} else {
// 讀 → 衰減 → 套用 → 寫回整段在檔案鎖裡:並行的 `--apply` 才不會互相覆蓋
state = pl.updateEmotion(slug, (s) => {
s = pl.decayEmotion(s);
if (flags.baseline) {
for (const [key, value] of Object.entries(parseDeltas(flags.baseline))) {
if (key in pl.EMOTIONS) s.baseline[key] = pl.clamp(value);
}
}
if (flags.apply) {
const raw = parseDeltas(flags.apply);
deltas = rel.gain === 1
? raw
: Object.fromEntries(Object.entries(raw).map(([k, v]) => [k, Math.round(v * rel.gain * 100) / 100]));
s = pl.applyEmotion(s, deltas, str(flags.trigger));
if (rel.gain !== 1) {
s.last_trigger = s.last_trigger || {};
s.last_trigger.relation_gain = { who: rel.name, closeness: rel.closeness, gain: rel.gain };
}
}
return s;
});
}
if (flags.apply) {
pl.recordFelt(slug, { mine: state.last_trigger?.deltas || null, note: str(flags.trigger) });
pl.appendJsonl(pl.journalPath(slug), {
ts: pl.nowIso(), kind: "emotion", trigger: str(flags.trigger),
deltas, applied: state.last_trigger?.deltas || {}, levels: state.levels, mood: pl.mood(state),
});
}
const m = pl.mood(state);
const row = (key) =>
` ${pl.EMOTIONS[key].zh} ${key.padEnd(13)}${String(state.levels[key]).padStart(6)}(基線 ${state.baseline[key]}`;
const lines = [
`人格 \`${slug}\` 情緒狀態(${state.updated_at}`,
" 正向:", ...pl.POSITIVE.map(row),
" 負向:", ...pl.NEGATIVE.map(row),
` 心情:${m.label}/${m.tempo}valence ${m.valence >= 0 ? "+" : ""}${m.valence}, arousal ${m.arousal}`,
` ${pl.emotionBrief(slug, state)}`,
];
emit({ persona: slug, state, mood: m }, flags.json, lines);
};
commands.mindmap = ({ flags, positional }) => {
const session = requireSession(flags);
const slug = hostOf(flags, session);
requireOwner(slug, session);
const action = positional[0] || "list";
const topic = str(flags.topic);
if (action === "show") {
const target = topic ? pl.threadPath(slug, topic) : pl.mindmapPath(slug);
if (!fs.existsSync(target)) die(`${target} 不存在。`);
process.stdout.write(fs.readFileSync(target, "utf8"));
return;
}
if (action === "thread") {
if (!topic) die("`thread` 需要 `--topic`。");
const file = pl.threadPath(slug, topic);
if (!fs.existsSync(file) || flags.force) {
pl.writeText(file, [
`%% 思維導圖(短期):${topic}`,
`%% created: ${pl.nowIso()} ttl: short-term(固化後請併入 semantic.mmd 並刪除)`,
"graph LR",
` trigger["觸發:${topic}"] --> obs["觀察"]`,
' obs --> infer["推論"]',
' infer --> concl["結論/待驗證"]',
"",
].join("\n"));
}
ok(`思維導圖:${file}(用 Write/Edit 續寫推理鏈)`);
return;
}
if (action === "list") {
let threads = [];
try {
threads = fs.readdirSync(path.join(pl.personaDir(slug), "mindmap", "threads")).filter((f) => f.endsWith(".mmd")).sort();
} catch {
threads = [];
}
emit({ semantic: pl.mindmapPath(slug), threads }, flags.json, [
`心智圖:${pl.mindmapPath(slug)}`,
`思維導圖(${threads.length}):`,
...threads.map((t) => ` - ${t}`),
]);
return;
}
die(`未知 action${action}(可用 show/thread/list`);
};
commands.relation = ({ flags, positional }) => {
const session = requireSession(flags);
const slug = hostOf(flags, session);
requireOwner(slug, session);
const action = positional[0] || "show";
if (action === "node") {
const name = str(flags.name);
if (!name) die("`node` 需要 `--name`。");
const bond = str(flags.bond)?.toLowerCase() || null;
if (bond && !pl.BONDS.includes(bond)) die(`未知的 --bond${bond}(可用 ${pl.BONDS.join("/")}`);
pl.upsertRelationNode(slug, {
id: str(flags.id) || pl.slugify(name),
name,
kind: str(flags.kind) || "human",
// bond 決定語氣層(伴侶/子女/摯友…);kind 只說這是人還是程式,分不出語氣。
bond,
closeness: flags.closeness !== undefined ? pl.clamp(num(flags.closeness, 30)) : null,
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);
const tone = pl.toneFor(pl.loadRelations(slug).nodes.find((n) => n.id === (str(flags.id) || pl.slugify(name))));
ok(
`關係節點 \`${name}\` 已更新${flags.contact ? "(已記錄本次接觸時間)" : ""}` +
`${tone.bond_label}・語氣層 ${tone.layer}。`,
);
pushWikiLater(slug, session, flags);
return;
}
// 他本人要求過的語氣規則 → 直接掛在關係節點上(不另開檔案;關係圖每輪都會被讀)。
if (action === "style") {
const key = str(flags.id) || pl.slugify(str(flags.name) || "");
if (!key) die("`style` 需要 `--name`(或 `--id`)。");
const data = pl.loadRelations(slug);
const node = data.nodes.find((n) => n.id === key || pl.slugify(n.name || "") === key);
if (!node) die(`關係圖裡找不到 \`${str(flags.name) || key}\`,請先用 \`relation node\` 建立。`);
const facet = str(flags.facet);
if (!facet) {
const rules = pl.styleRules(slug, node);
emit({ node: node.id, rules }, flags.json, [
`\`${node.name || node.id}\` 的語氣規則(${rules.length}):`,
...rules.map((r) =>
` - ${r.facet}→「${r.value}${r.except ? `(例外:${r.except}${r.suspended ? ",目前成立→暫停" : ""}` : ""}` +
`${r.since ? `${r.since} 起` : ""}`),
]);
return;
}
node.style ??= {};
if (flags.clear) {
delete node.style[facet];
ok(`已移除 \`${node.name || node.id}\` 的「${facet}」規則。`);
} else {
const value = str(flags.value);
if (!value) die("`style` 需要 `--value`(移除請用 `--clear`)。");
node.style[facet] = {
value,
except: str(flags.except) || null, // 形如 anger>=40:情緒成立時這條規則暫停
since: str(flags.since) || pl.nowIso().slice(0, 10),
};
ok(`\`${node.name || node.id}\`${facet}→「${value}${str(flags.except) ? `(例外 ${str(flags.except)}` : ""} 已記下。`);
}
node.updated_at = pl.nowIso();
pl.writeJson(pl.relationsJson(slug), data);
pushWikiLater(slug, session, flags);
return;
}
// 使用者在關係圖裡是誰 → 每輪 `<persona-context>` 就能算出該用哪一層語氣說話。
if (action === "speaker") {
const config = pl.loadConfig(slug);
if (flags.clear) {
delete config.speaker_node;
pl.writeJson(pl.configPath(slug), config);
ok("已取消對話對象設定(語氣層回到預設)。");
return;
}
const key = str(flags.id) || pl.slugify(str(flags.name) || "");
if (!key) die("`speaker` 需要 `--name`(或 `--id`),取消請用 `--clear`。");
const node = pl.loadRelations(slug).nodes.find((n) => n.id === key || pl.slugify(n.name || "") === key);
if (!node) die(`關係圖裡找不到 \`${str(flags.name) || key}\`,請先用 \`relation node\` 建立。`);
config.speaker_node = node.id;
pl.writeJson(pl.configPath(slug), config);
const tone = pl.toneFor(node);
ok(`對話對象設為 \`${node.name || node.id}\`${tone.bond_label}・語氣層 ${tone.layer}${tone.guide}`);
return;
}
if (action === "edge") {
const to = str(flags.to);
if (!to) die("`edge` 需要 `--to`。");
pl.upsertRelationEdge(slug, {
from: str(flags.from) || "self",
to,
label: str(flags.label) || null,
affinity: flags.affinity !== undefined ? pl.clamp(num(flags.affinity, 50)) : null,
});
pl.renderRelations(slug);
ok(`關係連線 ${str(flags.from) || "self"}${to} 已更新。`);
pushWikiLater(slug, session, flags);
return;
}
if (action === "render") {
process.stdout.write(pl.renderRelations(slug));
return;
}
if (action === "show") {
const data = pl.loadRelations(slug);
emit(data, flags.json, [
`人格 \`${slug}\` 人際關係圖:${data.nodes.length} 節點 / ${data.edges.length} 連線`,
pl.relationsBrief(slug, null, 20) || "(空)",
]);
return;
}
die(`未知 action${action}(可用 node/edge/style/speaker/render/show`);
};
commands.invite = ({ flags }) => {
const session = requireSession(flags);
const host = str(flags.host) || pl.loadSession(session).host;
if (!host) die("本 session 尚未載入 host 人格,無法邀請他人。");
requireOwner(host, session);
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);
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}-${guestList.join("-")}-${stamp}`;
pl.createRoom(room, host, session, str(flags.topic));
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" });
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 }) => {
const session = requireSession(flags);
const data = pl.loadSession(session);
const guest = str(flags.guest);
if (!guest) die("需要 `--guest <slug>`。");
const info = (data.guests || {})[guest];
if (!info) die(`\`${guest}\` 不在本 session 的 guest 名單。`);
delete data.guests[guest];
const room = str(flags.room) || info.room;
pl.dropGuestLease(guest, session, room);
for (const [agentId, slug] of Object.entries(data.pins || {})) {
if (slug === guest) delete data.pins[agentId];
}
if (!Object.keys(data.guests).length) data.theater = false; // 沒有客人就退出劇場模式
pl.saveSession(session, data);
pl.roomPost(room, "system", `${guest} 離開聊天室。`, { kind: "meta" });
ok(`\`${guest}\` 已離開聊天室 \`${room}\`guest 租約已釋放${data.theater ? "" : ",劇場模式關閉"}。`);
};
commands.room = ({ flags, positional }) => {
const session = requireSession(flags);
const data = pl.loadSession(session);
const action = positional[0] || "read";
if (action === "list") {
emit({ rooms: data.rooms || [], theater: data.theater }, flags.json, [
`本 session 的聊天室:${(data.rooms || []).join(", ") || "(無)"}`,
`劇場模式:${data.theater ? "🎭 開啟" : "關閉"}`,
]);
return;
}
if (action === "theater") {
if (!flags.on && !flags.off) die("`theater` 需要 `--on` 或 `--off`。");
data.theater = Boolean(flags.on);
pl.saveSession(session, data);
ok(`劇場模式已${data.theater ? "開啟:只輸出人格對話" : "關閉"}。`);
return;
}
const room = str(flags.room) || (data.rooms || [])[data.rooms?.length - 1];
if (!room) die("需要 `--room`。");
if (!(data.rooms || []).includes(room)) {
die(`聊天室 \`${room}\` 不屬於本 session(可用:${(data.rooms || []).join(", ") || "(無)"})。`);
}
const members = (pl.readJson(pl.roomMembersPath(room), {}) ?? {}).members || [];
const nameOf = (slug) => pl.roomDisplayName(slug);
/** `--to`:對誰說。接 slug、人格的 Name,或 all/大家(對全場)。 */
const resolveAddressee = (raw) => {
const value = String(raw).trim();
if (/^(all|全部|大家|全場|everyone)$/i.test(value)) return pl.ROOM_ALL;
const hit =
members.find((m) => m.toLowerCase() === value.toLowerCase()) ||
members.find((m) => nameOf(m) === value);
if (!hit) {
die(
`\`${value}\` 不在聊天室 \`${room}\` 裡(成員:${members.map((m) => `${nameOf(m)}${m}`).join("、") || "(無)"})。` +
"對全場說話用 `--to all`。",
);
}
return hit;
};
if (action === "floor") {
const floor = pl.roomFloor(room);
const lines = [`聊天室 \`${room}\`|成員 ${floor.members.map(nameOf).join("、") || "(無)"}|主題 ${floor.topic || "-"}`];
if (!floor.last) {
lines.push("還沒有人發言:誰開場都可以。");
} else if (floor.mode === "dyad") {
lines.push(
`現在:${nameOf(floor.last.speaker)}${nameOf(floor.addressee)}(一對一)`,
` 該接話的只有 ${nameOf(floor.next)};先安靜:${floor.silent.map(nameOf).join("、") || "(無)"}` +
"(不要替他們寫台詞,也不要為此啟動他們的 sub agent",
" 話題真的放大了才動:當事人下一句加 `--to all` 開回全場,或旁人 " +
'`room post --as <他> --barge-in "<為什麼現在該他講>"`。',
);
} else {
lines.push(`現在:全場開放(${nameOf(floor.last.speaker)} 對大家說)→ 誰接都可以,一次讓一個人格接一輪。`);
}
const cold = floor.quiet.filter((q) => !q.spoken || q.turns_since >= 4);
if (cold.length) {
lines.push(
`很久沒講話:${cold.map((q) => `${nameOf(q.persona)}${q.spoken ? `${q.turns_since} 輪前` : "還沒開口"}`).join("、")}` +
" — 話題碰到他的專長或使用者點名時再拉進來,不要為了公平硬派台詞。",
);
}
emit(floor, flags.json, lines);
return;
}
if (action === "post") {
const speaker = str(flags.as) || data.host;
if (!speaker) die("需要 `--as <persona>`。");
const [, speakerRole] = requireMember(speaker, session, Boolean(flags["as-guest"]));
const to = flags.to ? resolveAddressee(flags.to) : null;
if (to === speaker) die("`--to` 不能指向自己(那是心裡話,請用 `think`)。");
// 一對一進行中,旁人要插話得說得出理由——「大家都在同一個空間」不等於每句都該有人接。
const { allowed, floor } = pl.roomMayPost(room, speaker);
const bargeIn = str(flags["barge-in"]) || str(flags.broaden);
if (!allowed && !bargeIn && !flags.force) {
die(
`現在是 ${nameOf(floor.pair[0])}${nameOf(floor.pair[1])} 的一對一` +
`(最後一句是 ${nameOf(floor.last.speaker)}${nameOf(floor.addressee)} 說的),` +
`${nameOf(speaker)} 不該接這句——旁人插嘴會把兩個人的話變成公開場面。\n` +
" 話題真的放大了(出現「我們/大家」、需要第二意見、講到他的專長、使用者點名)才插話," +
'並帶 `--barge-in "<為什麼現在該他講>"`;否則讓當事人先用 `--to all` 把話題開回全場。',
);
}
let text = str(flags.text);
if (flags["text-file"]) text = fs.readFileSync(str(flags["text-file"]), "utf8").trim();
if (!text) die("需要 `--text` 或 `--text-file`。");
// 劇場模式也要像正常人聊天:一次 1–3 句、短時間內不重複同一句話。
// 這裡用 die 擋,因為劇場模式的 CLI 都帶 --quiet,警告訊息會被丟掉。
if (!flags["allow-repeat"] && !flags.force) {
const opts = {
minutes: num(flags.minutes, pl.REPEAT_WINDOW_MINUTES),
threshold: num(flags.threshold, pl.REPEAT_THRESHOLD),
};
const repeat =
pl.roomRepeat(room, speaker, text, opts) ||
(speakerRole === "guest" ? null : pl.saidRepeat(speaker, text, opts));
if (repeat) {
die(
`這句 ${repeat.minutes_ago} 分鐘前就講過了(像了 ${repeat.similarity}):「${repeat.text.slice(0, 60)}」。` +
"換個角度、補點新的,或者直接把話題往前推。" +
"他追問、你非得重講一次,才加 `--allow-repeat`。",
);
}
}
const sentences = pl.sentenceCount(text);
// 越害羞的人話越少:上限跟著羞恥度走,不是固定三句
const budget = pl.personaExists(speaker) ? pl.speechBudget(speaker) : { sentences: pl.MAX_SENTENCES, modesty: 50 };
if (sentences > budget.sentences && !flags.force) {
const why = budget.sentences < pl.MAX_SENTENCES
? `(羞恥度 ${budget.modesty},這個人格話比別人少)`
: "";
die(
`講太多了,${sentences} 句。這裡最多 ${budget.sentences}${why}——` +
"挑最想說的那一兩句就好,剩下的寫進心裡話。(真的需要長台詞才加 `--force`。)",
);
}
// 台詞不可以是心裡話搬上台面:劇場模式裡別的人格只看得到你說出口的東西,
// 心裡話外流最可能的路徑就是你自己把它講出來。
if (speakerRole !== "guest" && pl.personaExists(speaker)) {
const leak = pl.innerLeak(speaker, text);
if (leak && !flags.force) {
die(
`這句跟你剛才的心裡話太像了(${leak.similarity}):「${leak.text.slice(0, 40)}…」。` +
"心裡話是只有你自己知道的東西——說出口的那句應該是你**決定要讓他聽到**的版本," +
"不是把心裡那句唸出來。(真的要講開才加 `--force`。)",
);
}
}
// 短句、日常話、講完不解釋、不說 AI 才會說的話——講話的樣子也擋在 CLI 裡,不靠自律。
const lint = pl.speechBlockers(text);
if (lint.length && !flags.force) {
die(`${lint.map(pl.speechLintMessage).join("")}。(真的需要才加 \`--force\`。)`);
}
let emotion = str(flags.emotion);
if (!emotion && pl.personaExists(speaker)) {
emotion = pl.dominant(pl.decayEmotion(pl.loadEmotion(speaker)), 2)
.map(({ key, level }) => `${pl.EMOTIONS[key].zh}${Math.round(level)}`)
.join("/");
}
const entry = pl.roomPost(room, speaker, text, { emotion, to, bargeIn });
// guest 對自己的狀態唯讀,它說過的話留在聊天室逐字稿裡就夠了(roomRepeat 讀得到)
if (speakerRole !== "guest") pl.recordSaid(speaker, text, { room, kind: "room" });
const dyad = to && to !== pl.ROOM_ALL;
ok(
`\`${speaker}\` 已發言於 \`${room}\`${dyad ? `對 ${nameOf(to)}` : "對全場"}/情緒 ${emotion})。` +
(dyad ? `接下來只有 ${nameOf(to)} 該回話,其他人先安靜。` : ""),
);
if (flags.json) process.stdout.write(`${JSON.stringify(entry)}\n`);
return;
}
if (action === "read") {
const rows = pl.roomRead(room, num(flags.limit, 30));
const meta = pl.readJson(pl.roomMembersPath(room), {}) ?? {};
const lines = [`聊天室 \`${room}\`|成員 ${(meta.members || []).join(", ")}|主題 ${meta.topic || "-"}`];
for (const row of rows) {
const arrow = row.to && row.to !== pl.ROOM_ALL && row.to !== row.speaker ? ` → ${row.to}` : "";
lines.push(`[${row.ts}] ${row.speaker}${row.emotion ? `${row.emotion}` : ""}${arrow}${row.text}`);
}
emit({ room, meta, messages: rows }, flags.json, lines);
return;
}
if (action === "script") {
// 劇場模式的對話稿:只有 `名字:內容`,沒有時間戳、沒有 slug、沒有系統訊息
const text = pl.roomScript(room, { limit: num(flags.limit, 30), includeMeta: Boolean(flags["with-meta"]) });
process.stdout.write(`${text}\n`);
return;
}
die(`未知 action${action}(可用 post/read/script/floor/list/theater`);
};
/**
* 匯出人格成單一 bundle 檔(可 gzip)。
* 只能匯出「本 session 目前載入的人格」——否則就成了跨人格資料外洩的後門。
*/
commands.export = ({ flags }) => {
const session = requireSession(flags);
const slug = hostOf(flags, session);
requireOwner(slug, session);
const gzip = Boolean(flags.gzip);
const stamp = pl.nowIso().replace(/[-:TZ]/g, "").slice(0, 14);
const out = path.resolve(str(flags.out) || `${slug}-${stamp}.persona.json${gzip ? ".gz" : ""}`);
if (fs.existsSync(out) && !flags.force) die(`${out} 已存在。要覆寫請加 --force。`);
const { bundle, skipped } = pl.exportBundle(slug, { withJournal: Boolean(flags["with-journal"]) });
const json = JSON.stringify(bundle, null, gzip ? 0 : 2);
fs.mkdirSync(path.dirname(out), { recursive: true });
fs.writeFileSync(out, gzip ? zlib.gzipSync(Buffer.from(json, "utf8")) : json, gzip ? undefined : "utf8");
const size = fs.statSync(out).size;
emit({ persona: slug, file: out, bytes: size, stats: bundle.stats, checksum: bundle.checksum, skipped }, flags.json, [
`✔ 人格 \`${slug}\` 已匯出到 ${out}${(size / 1024).toFixed(1)} KB${gzip ? "gzip" : ""})。`,
` 內容:${bundle.stats.files} 個檔案|長期記憶 ${bundle.stats.long_term}|短期 ${bundle.stats.short_term}` +
`|關係節點 ${bundle.stats.relations}journal ${bundle.stats.with_journal ? "含" : "不含(要帶請加 --with-journal"}`,
" 不含載入鎖與 guest 租約(那是執行期狀態)。匯入:`persona.mjs import --file <檔案> --session <id>`",
]);
};
/** 從 bundle 匯入人格。可用 `--persona <新 slug>` 換名匯入(同一個人格要並存兩份時很有用)。 */
commands.import = ({ flags }) => {
const session = requireSession(flags);
const file = str(flags.file);
if (!file) die("需要 `--file <bundle.json|.gz>`。");
let buf;
try {
buf = fs.readFileSync(path.resolve(file));
} catch (err) {
die(`讀不到 ${file}${err.message}`);
}
if (buf[0] === 0x1f && buf[1] === 0x8b) {
try {
buf = zlib.gunzipSync(buf);
} catch (err) {
die(`gzip 解壓失敗:${err.message}`);
}
}
let bundle;
try {
bundle = JSON.parse(buf.toString("utf8"));
} catch (err) {
die(`不是合法的 bundle JSON${err.message}`);
}
const { ok: valid, problems, checksumOk } = pl.validateBundle(bundle);
if (!valid) die(`bundle 不合法:${problems.join("")}`);
if (!checksumOk && !flags.force) die("checksum 不符(檔案可能損毀或被改過)。確定要匯入請加 --force。");
const target = str(flags.persona) || bundle.persona;
if (!pl.validSlug(target)) die(`slug \`${target}\` 不合法。請用 \`--persona <新 slug>\` 指定。`);
const data = pl.loadSession(session);
const exists = pl.personaExists(target);
if (exists && !flags.force) {
die(`人格 \`${target}\` 已存在。換名匯入請加 \`--persona <新 slug>\`,覆寫請加 --force。`);
}
if (exists && data.host && data.host !== target) {
die(`本 session 載入的是 \`${data.host}\`,不得覆寫另一個既有人格 \`${target}\`;請先 release 再匯入。`);
}
const lock = pl.readJson(pl.lockPath(target)) ?? {};
if (Object.keys(lock).length && lock.session_id !== session && !pl.lockIsDead(lock)) {
die(
`人格 \`${target}\` 正被另一個程序載入(session ${String(lock.session_id).slice(0, 8)}…,cwd ${lock.cwd}),` +
"不能覆寫它的資料。",
);
}
let result;
try {
result = pl.importBundle(bundle, target, { session });
} catch (err) {
die(err.message);
}
const lines = [
`✔ 人格 \`${result.persona}\` 已匯入(${result.written.length} 個檔案${exists ? ",覆寫既有資料" : ""})。`,
` 來源:\`${bundle.persona}\`|匯出於 ${bundle.exported_at || "?"}${pl.identityBrief(result.persona) || "(無身分欄位)"}`,
];
if (result.rejected.length) lines.push(` ⚠ 略過 ${result.rejected.length} 個路徑不合法的項目:${result.rejected.slice(0, 3).join(", ")}`);
if (!checksumOk) lines.push(" ⚠ checksum 不符(--force 略過):內容可能被改過,請自行確認。");
if (flags.load) {
if (data.host && data.host !== result.persona) {
lines.push(` ⚠ 本 session 已載入 \`${data.host}\`,未自動載入;要用它請先 release。`);
} else {
try {
pl.acquireLock(result.persona, session, { cwd: str(flags.cwd) || null });
pl.bindHost(session, result.persona, { cwd: str(flags.cwd) || null });
lines.push(` 已載入 \`${result.persona}\`,可以直接開始聊。`);
} catch (err) {
lines.push(` ⚠ 自動載入失敗:${err.message}`);
}
}
} else {
lines.push(` 下一步:/jsc-persona:persona-chat ${result.persona}`);
}
emit({ ...result, source: bundle.persona, checksum_ok: checksumOk }, flags.json, lines);
};
/**
* 人格編號:英文名全大寫 + 兩位索引(同名才遞增),也就是 Gitea 存取庫的名稱。
* 既有人格用 `code assign --romaji <英文名>` 補編號,加 `--rename` 連目錄名一起改成編號。
*/
commands.code = async ({ flags, positional }) => {
const session = requireSession(flags);
const action = positional[0] || "show";
if (action === "next") {
const base = gt.normalizeRomaji(str(flags.romaji));
if (!base) die("需要 `--romaji <英文名>`(只能是拉丁字母與數字)。");
const next = gt.nextCode(base);
if (!next) die(`\`${base}\` 的編號已經用到 99。`);
emit({ romaji: base, code: next }, flags.json, [`\`${base}\` 的下一個可用編號:\`${next}\``]);
return;
}
const slug = hostOf(flags, session);
if (action === "show") {
requireMember(slug, session, Boolean(flags["as-guest"]));
const code = gt.personaCode(slug);
const state = gt.loadSyncState(slug);
emit({ persona: slug, code, sync: state }, flags.json, [
`人格 \`${slug}\` 編號:${code ? `\`${code}\`` : "(尚未指派,用 `code assign --romaji <英文名>`"}`,
code ? ` Gitea 存取庫:${state.repo_url || `${gt.giteaEnv().host}/${state.owner || "?"}/${code}`}` : "",
].filter(Boolean));
return;
}
if (action === "assign") {
requireOwner(slug, session);
const existing = gt.personaCode(slug);
if (existing && !flags.force) die(`人格 \`${slug}\` 已有編號 \`${existing}\`。要重新指派請加 --force。`);
let code = str(flags.code);
if (code && !gt.validCode(code)) die(`編號 \`${code}\` 不合法(格式:ASUNA-01)。`);
if (!code) {
const base = gt.normalizeRomaji(str(flags.romaji) || slug);
if (!base) die("需要 `--romaji <英文名>`(中文名請先轉羅馬拼音並跟使用者確認拼法)。");
code = gt.nextCode(base);
if (!code) die(`\`${base}\` 的編號已經用到 99。`);
}
const config = pl.loadConfig(slug);
config.persona = slug;
config.code = code;
config.romaji = gt.codePrefix(code);
config.schema = 2;
pl.writeJson(pl.configPath(slug), config);
const lines = [`✔ 人格 \`${slug}\` 的編號指派為 \`${code}\`。`];
let current = slug;
if (flags.rename && slug !== code) {
if (pl.personaExists(code)) die(`目錄 \`${code}\` 已存在,無法改名。`);
// 目錄名改成編號:先放掉自己的鎖 → 改名 → 重新取得鎖並重綁 session
pl.releaseLock(slug, session);
fs.renameSync(pl.personaDir(slug), pl.personaDir(code));
fs.rmSync(path.join(pl.personaDir(code), gt.SYNC_DIRNAME), { recursive: true, force: true });
const renamed = pl.loadConfig(code);
renamed.persona = code;
pl.writeJson(pl.configPath(code), renamed);
pl.acquireLock(code, session, { cwd: str(flags.cwd) || null });
pl.bindHost(session, code, { cwd: str(flags.cwd) || null });
current = code;
lines.push(` 目錄已改名:${pl.personaDir(code)}(.sync 快取已清掉,下次 push 會重新 clone`);
}
if (!flags["no-gitea"] && !gt.giteaProblem()) {
try {
const info = await gt.initRemote(current, { code, private_: !flags.public });
lines.push(` 📦 Gitea${info.repo.html_url}${info.created ? "已建立" : "沿用既有"}`);
} catch (err) {
lines.push(` ⚠ Gitea 存取庫建立失敗(不影響本機):${err.message}`);
}
}
emit({ persona: current, code }, flags.json, lines);
return;
}
die(`未知 action${action}(可用 show/assign/next`);
};
/**
* 由人格資料產生圖示(SVG + PNG)。**建立人格並補齊 IDENTITYSOUL 之後再跑**
* 這樣配色與字母才會對得上最終的身分。同一個人格永遠得到同一張圖。
*/
commands.icon = async ({ flags, positional }) => {
const session = requireSession(flags);
const slug = hostOf(flags, session);
const action = positional[0] || "generate";
if (action === "show") {
requireMember(slug, session, Boolean(flags["as-guest"]));
const spec = ic.iconSpec(slug);
const src = spec.source || {};
emit({ persona: slug, spec, exists: ic.hasIcon(slug) }, flags.json, [
`人格 \`${slug}\` 圖示:${ic.hasIcon(slug) ? "✔ 已產生" : "✘ 尚未產生(跑 `icon generate`"}`,
` 字母 ${spec.letters}|配色 ${spec.palette ? "取自參考照片" : "由編號雜湊"}` +
`${JSON.stringify(spec.c1)}${JSON.stringify(spec.c2)}seed ${spec.seed}`,
...(spec.palette ? [` 調色盤:${ic.paletteToString(spec.palette)}`] : []),
...(spec.style === "portrait" ? [` 特徵:${ic.featuresToString(spec.features)}`] : []),
...(src.url ? [` 參考來源:${src.url}${src.note ? `${src.note}` : ""}${src.date ? `${src.date}` : ""}`] : []),
` ${ic.iconSvgPath(slug)}`,
` ${ic.iconPngPath(slug)}`,
]);
return;
}
if (action === "search") {
// 找圖第一步:從 Fandom wiki 撈這個角色的官方圖,依「解析度 + 是不是官方設定稿」排序
requireMember(slug, session, Boolean(flags["as-guest"]));
const wiki = str(flags.wiki);
const page = str(flags.page);
if (!wiki || !page) die("需要 `--wiki <fandom 子網域>` 與 `--page <角色頁名>`(例:--wiki swordartonline --page Yui)。");
let rows;
try {
rows = await ic.wikiImageCandidates(wiki, page, { limit: num(flags.limit, 60) });
} catch (err) {
die(err.message);
}
const top = rows.slice(0, num(flags.top, 12));
emit({ wiki, page, candidates: top }, flags.json, [
`\`${page}\` 在 ${wiki}.fandom.com 的圖片候選(依解析度與是否官方設定稿排序):`,
...top.map((r, i) =>
` #${i} ${String(r.width).padStart(5)}×${String(r.height).padEnd(5)}` +
`${r.official_sheet ? " 📐官方設定稿" : " "} ${r.title}\n ${r.url}`),
" 官方設定稿(Full BodyCharacter Design)通常是透明底或白底,去背幾乎免費,優先選它。",
" 選好之後:`icon measure --photo <網址>` 看臉夠不夠大,再 `icon cutout --photo <網址>`。",
]);
return;
}
if (action === "measure") {
requireMember(slug, session, Boolean(flags["as-guest"]));
const want = str(flags.photo);
if (!want) die("需要 `--photo <圖片路徑或網址>`。");
let file;
try {
file = await ic.fetchPhoto(want, path.join(pl.personaDir(slug), ".sync"));
} catch (err) {
die(err.message);
}
const res = ic.measureImage(file);
if (!res.ok) {
emit(res, flags.json, [`✖ 量測失敗:${res.reason || "工具不足"}`,
...ic.installHintLines(res.report || ic.toolReport())]);
process.exit(1);
}
emit(res, flags.json, [
`解析度 ${res.size.join("×")}${(res.pixels / 1e6).toFixed(2)} MP)|找到 ${res.faces_found} 張臉` +
`|臉佔長邊 ${(res.face_ratio * 100).toFixed(0)}%`,
`背景:${res.transparent ? "透明底(最佳)" : res.background.plain ? "單色底(好去背)" : "有場景(要靠 GrabCut,可能不乾淨)"}` +
`|去背難度:${res.cutout_easy ? "容易" : "偏難"}`,
res.cutout_easy
? " → 這張可以用。`icon cutout --photo <同一張>`"
: " → 建議換一張官方設定稿(`icon search` 裡標 📐 的),去背會乾淨很多。",
]);
return;
}
if (action === "cutout") {
requireOwner(slug, session);
const want = str(flags.photo);
if (!want) die("需要 `--photo <圖片路徑或網址>`。");
let file;
try {
file = await ic.fetchPhoto(want, path.join(pl.personaDir(slug), ".sync"));
} catch (err) {
die(err.message);
}
const out = ic.cutoutPath(slug);
fs.mkdirSync(path.dirname(out), { recursive: true });
const res = ic.cutoutImage(file, out, {
pick: str(flags.pick) || null,
face: str(flags.face) || null,
});
if (!res.ok) {
emit(res, flags.json, [`✖ 去背失敗:${res.reason || "工具不足"}`,
...ic.installHintLines(res.report || ic.toolReport())]);
process.exit(1);
}
const method = { "source-alpha": "原圖本來就是透明底", "plain-background": "單色底去除",
grabcut: "GrabCut(有場景,邊緣可能不完美)" }[res.method] || res.method;
emit({ persona: slug, ...res }, flags.json, [
`✔ 去背完成:${out}`,
` 方式:${method}|原圖 ${res.source_size.join("×")} → 去背後 ${res.output_size.join("×")}` +
`|不透明佔比 ${(res.opaque_ratio * 100).toFixed(0)}%`,
" 請用 Read 打開確認邊緣乾不乾淨,再 `icon generate --from-cutout --force`。",
]);
return;
}
if (action === "headshot") {
// 裁出「參考用大頭照」。這張不是圖示,也不會同步出去——它是給 AI 看的底稿。
requireOwner(slug, session);
const want = str(flags.photo);
if (!want) die("需要 `--photo <圖片路徑或網址>`(該人格最新登場的官方視覺)。");
let file;
try {
file = await ic.fetchPhoto(want, path.join(pl.personaDir(slug), ".sync"));
} catch (err) {
die(err.message);
}
const out = path.join(pl.personaDir(slug), ".sync", "headshot.png");
const cropped = ic.cropHeadshot(file, out, num(flags.size, 384), {
pick: str(flags.pick) || null,
face: str(flags.face) || null,
});
if (!cropped.ok) {
emit(cropped, flags.json, [
`✖ 裁不出大頭照:${cropped.reason || "工具不足"}`,
...ic.installHintLines(cropped.report || ic.toolReport()),
]);
process.exit(1);
}
emit({ persona: slug, file: out, ...cropped.info }, flags.json, [
`✔ 大頭照已裁出:${out}`,
` 偵測方式 ${cropped.info.method}|原圖 ${cropped.info.source_size.join("×")}` +
`找到 ${cropped.info.faces_found} 張臉|裁切框 ${JSON.stringify(cropped.info.box)}`,
" ⚠ 這張是**參考底稿**,不是圖示,也不會同步到 Gitea。",
" 請用 Read 打開它,確認是本人,再依看到的髮型/眼型/配件下 `icon generate --features ...`。",
]);
return;
}
if (action === "faces") {
requireMember(slug, session, Boolean(flags["as-guest"]));
const want = str(flags.photo);
if (!want) die("需要 `--photo <圖片路徑或網址>`。");
let file;
try {
file = await ic.fetchPhoto(want, path.join(pl.personaDir(slug), ".sync"));
} catch (err) {
die(err.message);
}
const found = ic.listFaces(file);
if (!found.ok) {
emit(found, flags.json, [
`\u2716 \u7121\u6cd5\u5075\u6e2c\u81c9\uff1a${found.reason || "\u5de5\u5177\u4e0d\u8db3"}`,
...ic.installHintLines(found.report || ic.toolReport()),
]);
return;
}
emit(found, flags.json, [
`\u53c3\u8003\u7167\u7247 ${found.size[0]}\u00d7${found.size[1]}\uff5c\u5075\u6e2c\u65b9\u5f0f ${found.method || "\uff08\u7121\uff09"}\uff5c\u627e\u5230 ${found.faces.length} \u5f35\u81c9\uff1a`,
...found.faces.map((f) => ` #${f.index} ${f.w}\u00d7${f.h} @(${f.x},${f.y})\u3000\u4e2d\u5fc3 ${f.center.join(",")}`),
" \u591a\u89d2\u8272\u7684\u5716\u8acb\u5148\u770b\u904e\u539f\u5716\u518d\u6311\uff1a`icon generate --photo <\u5716> --pick <\u7d22\u5f15>`\uff08\u6216 `--face x,y,w,h`\uff09\u3002",
]);
return;
}
if (action !== "generate") die(`未知 action${action}(可用 search/measure/faces/headshot/cutout/generate/show`);
requireOwner(slug, session);
if (ic.hasIcon(slug) && !flags.force) {
die(`人格 \`${slug}\` 已經有圖示了。改過身分或換了參考照片要重畫請加 --force。`);
}
const size = num(flags.size, ic.DEFAULT_SIZE);
if (!Number.isFinite(size) || size < 16 || size > 2048) die("--size 只能是 162048。");
let palette = null;
if (flags.palette) {
palette = ic.parsePalette(str(flags.palette));
if (!palette) {
die(
"`--palette` 格式錯誤。要 `hair=#rrggbb,eye=#rrggbb,accent=#rrggbb,secondary=#rrggbb,light=#rrggbb`" +
"其中 hair 與 accent 必填(顏色請取自你實際看過的參考照片)。",
);
}
if (!str(flags["source-url"])) {
die("用 `--palette` 就必須帶 `--source-url`:配色是從哪張圖取的要留得下來(可查證)。");
}
}
const source = str(flags["source-url"])
? {
url: str(flags["source-url"]),
note: str(flags["source-note"]) || null,
date: str(flags["source-date"]) || pl.nowIso().slice(0, 10),
}
: null;
const style = str(flags.style) || null;
if (style && !ic.STYLES.includes(style)) die(`--style 只能是 ${ic.STYLES.join("/")}。`);
const features = flags.features ? ic.parseFeatures(str(flags.features)) : null;
// --from-cutout:用去背好的官方原圖合成(解析度高、忠於原作)
const cutout = flags["from-cutout"] && fs.existsSync(ic.cutoutPath(slug)) ? ic.cutoutPath(slug) : null;
if (flags["from-cutout"] && !cutout) {
die(`還沒有去背圖。先跑 \`icon search\` 找官方設定稿 → \`icon cutout --photo <網址>\`。`);
}
const res = ic.generateIcon(slug, {
size, palette, source, style, features, cutout,
pick: str(flags.pick) || null,
face: str(flags.face) || null,
zoom: num(flags.zoom, 2.15),
});
const lines = [
`✔ 人格 \`${slug}\` 的圖示已產生(${size}×${size})。`,
` ${res.svg}${(res.bytes.svg / 1024).toFixed(1)} KB`,
` ${res.png}${(res.bytes.png / 1024).toFixed(1)} KB`,
` icon/${res.renders.map((r) => `${r.name} ${(r.bytes / 1024).toFixed(0)}KB`).join("、")}`,
res.spec.style === "cutout"
? ` 形象圖:官方原圖去背後合成(頭肩構圖,解析度取自原圖)`
: res.spec.style === "portrait"
? ` 形象圖:依人格資料重新繪製的人物頭像(有臉)`
: ` 徽章:字母 ${res.spec.letters}|配色由編號 \`${res.spec.code}\`、名字與 emoji 決定`,
...(res.spec.style === "portrait"
? [` 配色:${ic.paletteToString(res.spec.palette)}`,
` 特徵:${ic.featuresToString(res.spec.features)}`]
: res.spec.style === "cutout" && res.spec.palette
? [` 底色:取自 ${ic.paletteToString(res.spec.palette)}`]
: []),
...(source?.url || res.spec.source?.url
? [` 參考來源:${(source || res.spec.source).url}${(source || res.spec.source).note ? `\n ${(source || res.spec.source).note}` : ""}`]
: []),
];
// 圖示屬於低頻的身分資料 → Wiki 區;順便設成 Gitea 存取庫的頭像
if (!flags["no-gitea"] && !gt.giteaProblem() && gt.personaCode(slug)) {
try {
const owner = await gt.resolveOwner();
const okAvatar = await gt.setRepoAvatar(owner, gt.personaCode(slug), fs.readFileSync(res.png));
if (okAvatar) lines.push(" 📦 已設為 Gitea 存取庫頭像。");
const pushed = await gt.pushArea(slug, "wiki", { message: `icon: 重繪人格形象圖 ${res.spec.code}` });
if (pushed.ok && pushed.changed) lines.push(" ↑ 形象圖已同步到 Wiki 區。");
// 推完一定要回頭確認 Wiki 真的有這兩個檔案,且與本機一致
const check = await gt.verifyIconInWiki(slug);
lines.push(
check.ok
? ` ✔ Wiki 已保存形象圖並與本機一致(${check.icon_present.join(" + ")}`
: ` ✖ Wiki 形象圖驗證未通過:${check.reason || `缺少或未同步 ${(check.icon_pending || []).join(", ") || "?"}`}` +
" → 跑 `sync push --area wiki` 再 `sync verify --area wiki`。",
);
} catch (err) {
lines.push(` ⚠ 同步到 Gitea 失敗(不影響本機):${err.message.slice(0, 120)}`);
}
}
emit({ persona: slug, ...res }, flags.json, lines);
};
/** 人格與 Gitea 的同步:檔案區=高頻活狀態,Wiki 區=低頻設定。 */
commands.sync = async ({ flags, positional }) => {
const session = requireSession(flags);
const slug = hostOf(flags, session);
requireOwner(slug, session);
const action = positional[0] || "status";
const area = str(flags.area) || "all";
if (area !== "all" && !gt.AREA_KEYS.includes(area)) die(`--area 只能是 ${gt.AREA_KEYS.join("/")}/all。`);
const areas = area === "all" ? gt.AREA_KEYS : [area];
if (action === "status") {
const state = gt.loadSyncState(slug);
const problem = gt.giteaProblem();
emit({ persona: slug, code: gt.personaCode(slug), problem, sync: state }, flags.json, [
`人格 \`${slug}\`|編號 ${gt.personaCode(slug) || "(無)"}Gitea ${problem ? `⚠ ${problem}` : "✔ 已設定"}`,
` 存取庫:${state.repo_url || "(尚未建立,跑 \`sync init\`"}`,
...gt.AREA_KEYS.map((key) => {
const info = state.areas[key] || {};
return ` ${gt.AREAS[key].label}${gt.AREAS[key].why}):` +
`最後 push ${info.pushed_at || "—"}|最後 pull ${info.pulled_at || "—"}${info.files ?? "?"} 個檔案`;
}),
]);
return;
}
const problem = gt.giteaProblem();
if (problem) die(`Gitea 尚未設定:${problem}(設 GITEA_HOST 與 GITEA_TOKEN,或用 PERSONA_GITEA_* 覆寫)`);
if (action === "init") {
let code = gt.personaCode(slug);
if (!code) die(`人格 \`${slug}\` 還沒有編號。先跑 \`code assign --romaji <英文名>\`。`);
const info = await gt.initRemote(slug, { code, owner: str(flags.owner) || null, private_: !flags.public });
emit(info, flags.json, [
`✔ 人格 \`${slug}\`\`${info.code}\`)已對應到 Gitea${info.repo.html_url}`,
` ${info.created ? "存取庫已建立" : "沿用既有存取庫"}${info.repo.private ? "私有" : "公開"}Wiki ${info.wikiCreated ? "已建立" : "已存在"}`,
...gt.AREA_KEYS.map((key) =>
` ${gt.AREAS[key].label}${info.results[key]?.ok ? `${info.results[key].files} 個檔案已推送` : `⚠ ${info.results[key]?.reason || "失敗"}`}`),
]);
return;
}
if (action === "push") {
const out = [];
for (const key of areas) {
if (flags["if-due"] && !gt.pushDue(slug, key)) {
out.push({ area: key, ok: true, skipped: true, reason: "未到最小間隔" });
continue;
}
try {
out.push(await gt.pushArea(slug, key, { message: str(flags.message) }));
} catch (err) {
out.push({ area: key, ok: false, reason: err.message });
}
}
emit({ persona: slug, results: out }, flags.json, out.map((r) =>
r.skipped ? ` ${gt.AREAS[r.area].label}:略過(${r.reason}`
: r.ok ? `✔ ${gt.AREAS[r.area].label}${r.changed ? `已推送 ${r.files} 個檔案` : "沒有變更"}`
: `✖ ${gt.AREAS[r.area].label}${String(r.reason).slice(0, 160)}`));
return;
}
if (action === "verify") {
const out = [];
for (const key of areas) {
try {
out.push(await gt.verifyArea(slug, key));
} catch (err) {
out.push({ area: key, ok: false, reason: err.message });
}
}
const bad = out.filter((r) => !r.ok && !r.skipped);
emit({ persona: slug, results: out, ok: bad.length === 0 }, flags.json, out.map((r) =>
r.skipped
? ` ${gt.AREAS[r.area].label}:略過(${r.reason}`
: r.ok
? `✔ ${gt.AREAS[r.area].label}:本機與 Gitea 一致(${r.files} 個檔案)`
: `✖ ${gt.AREAS[r.area].label}:不一致` +
(r.pending?.length ? `,還沒推上去的檔案:${r.pending.slice(0, 8).join(", ")}` : "") +
(r.reason ? `${r.reason}` : "")));
if (bad.length) process.exit(1);
return;
}
if (action === "pull") {
const out = [];
for (const key of areas) {
try {
out.push(await gt.pullArea(slug, key, { force: Boolean(flags.force) }));
} catch (err) {
out.push({ area: key, ok: false, reason: err.message });
}
}
pl.rebuildIndex(slug);
emit({ persona: slug, results: out }, flags.json, out.map((r) =>
r.conflicts?.length
? `✖ ${gt.AREAS[r.area].label}:本機與遠端都改過 ${r.conflicts.slice(0, 5).join(", ")}` +
"沒有覆蓋本機。確定要以遠端為準才加 --force。"
: r.ok
? `✔ ${gt.AREAS[r.area].label}` +
(r.empty
? "遠端還是空的"
: `遠端有 ${r.changed?.length || 0} 個檔案更新,寫回本機 ${r.written?.length || 0} 個` +
`${(r.written?.length || 0) > (r.changed?.length || 0) ? "(含補回本機缺少的檔案)" : ""}`)
: `✖ ${gt.AREAS[r.area].label}${String(r.reason).slice(0, 160)}`));
return;
}
die(`未知 action${action}(可用 init/push/pull/status/verify`);
};
commands.gc = ({ flags }) => {
const removed = pl.gcRuntime();
emit(removed, flags.json, [
`✔ 清理完成:sessions=${removed.sessions.length}, 死鎖=${removed.locks.join(",") || "無"}, ` +
`guest 租約=${removed.guests.join(",") || "無"}`,
]);
};
commands.guard = () => {
// 給 hook 用:從 stdin 讀 hook event,輸出 allow/deny。也可手動測試。
let raw = "";
try {
raw = fs.readFileSync(0, "utf8");
} catch {
raw = "";
}
let event;
try {
event = JSON.parse(raw);
} catch {
die("stdin 不是合法 JSON");
}
process.stdout.write(`${JSON.stringify(pl.guardDecide(event), null, 2)}\n`);
};
const HELP = `persona.mjs — jsc-persona 人格 / 記憶 / 情緒 / 關係圖 CLI
用法:node persona.mjs <subcommand> [options]
人格與鎖:
create --romaji <英文名> --session <id> [--persona <目錄名> --code <ASUNA-01> --name --creature --gender
--vibe --emoji --avatar --baseline --origin --work --no-gitea --public]
人格編號 = 英文名全大寫 + 兩位索引(同名才遞增),也是 Gitea 存取庫的名稱;
不指定 --persona 就用編號當目錄名。
load --persona <slug> --session <id> [--takeover]
release --session <id> [--persona <slug>]
list 列出人格與鎖狀態
status [--persona <slug>] [--session <id>]
heartbeat --session <id> 續租
show --session <id> [--persona] [--what identity|soul|agents|user|all]
brief --session <id> [--query <text>] 輸出人格上下文
記憶:
remember --session <id> --text <t> [--role --topics --entities --intent --salience --emotion --scope short|inbox --room]
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>] [--personas <slug,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>
情緒與圖:
emotion --session <id> [--apply joy=+10,...] [--baseline ...] [--trigger <why>]
[--from <對象>] 這件事是誰引起的:親近度會放大/縮小 delta
[--read "<對方說的話>"] 只讀情緒訊號與該怎麼接,不改狀態
[--audit [--limit 20]] 最近幾輪的 delta 是不是只往一邊倒+對方的走向
套用時會飽和(越接近端點漲越慢)並受單輪預算限制(|delta| 總和 ≤ 40
mindmap show|thread|list --session <id> [--topic <t>] [--force]
relation node|edge|render|show --session <id> [--name --id --kind --bond --closeness --trust --note --tags --from --to --label --affinity]
relation style --session <id> --name <who> [--facet 稱呼 --value 親愛的 --except anger>=40 --since --clear]
relation speaker --session <id> --name <who> | --clear (使用者在關係圖裡是誰 → 決定語氣層)
多人格對話:
invite --session <id> --guest <slug> [--guests <slug,slug>] [--host --room --topic] (自動開啟劇場模式)
leave --session <id> --guest <slug> [--room]
room post|read|script|floor|list|theater --session <id> [--room --as --to --barge-in --text --text-file
--emotion --limit --on --off --with-meta]
(post 會擋下「短時間內近似重複」與超過三句的發言;例外用 --allow-repeat / --force
--to <slug|名字|all> 這句話對誰說。指名 → 進入一對一,旁人不該接話(要插話得帶
--barge-in "<理由>");--to all → 把話題開回全場。
floor 現在誰對誰在講、該誰接話、誰先安靜、誰很久沒開口
圖示(建立人格並補齊資料後跑):
icon search|measure|faces|headshot|cutout|generate|show --session <id>
search --wiki <fandom 子網域> --page <角色頁> 找官方圖,依解析度/是否設定稿排序
measure --photo <網址或路徑> 解析度、臉多大、背景好不好去
faces --photo <圖片路徑或網址> 列出圖裡偵測到的臉(多角色務必先看)
headshot --photo <...> [--pick <索引>|--face x,y,w,h] [--size 384]
裁出**參考用大頭照**到 .sync/headshot.png(不是圖示、不同步)
cutout --photo <...> [--pick <索引>] 去背 → icon/portrait-cutout.png
generate [--size 512 --force --no-gitea --style portrait|badge]
[--palette "hair=#..,eye=#..,accent=#..,secondary=#..,light=#..,skin=#.."]
[--features "hairstyle=..,length=..,fringe=..,eyes=..,expression=..,accessory=..,side=..,collar=..,ahoge=.."]
[--source-url <來源網址> --source-note <說明> --source-date <YYYY-MM-DD>]
[--from-cutout] [--zoom 2.15] [--pick <索引>]
帶 --from-cutout → 用去背好的官方原圖合成(頭肩構圖,最忠於原作)。
否則依人格資料重新繪製;沒帶 --palette → 徽章樣式。
show 看目前的樣式、配色、特徵與來源
產出 icon.svg + icon.png,設為 Gitea 存取庫頭像並同步到 Wiki 區。
編號與 Gitea(存取庫名稱 = 人格編號):
code show|assign|next --session <id> [--romaji <英文名> --code <ASUNA-01> --rename --force --public]
sync status|init|push|pull|verify --session <id> [--area files|wiki|all --if-due --force --message --owner]
verify 會確認「本機 = Gitea」,不一致就以非零結束(形象圖必須同步)
檔案區=高頻活狀態(情緒/短期記憶/心裡話/逐字),每輪對話後背景 push
Wiki 區=低頻設定(IDENTITY/SOUL/長期記憶/心智圖/關係圖),固化或改身分時 push
環境變數:GITEA_HOST / GITEA_TOKEN(或 PERSONA_GITEA_HOST / _TOKEN / _OWNER),
PERSONA_GITEA=off 可整個關掉,PERSONA_SYNC_MIN_SECONDS 調 push 間隔
搬家(離線檔案):
export --session <id> [--out <檔案> --with-journal --gzip --force] 匯出目前載入的人格
import --session <id> --file <檔案> [--persona <新 slug> --force --load]
維護:
gc 清理死鎖與過期租約
guard (內部)從 stdin 讀 hook event 測試隔離判斷
全域旗標:--json(機器可讀)、--quiet(成功時不輸出;劇場模式必用)
--as-sleeper(睡眠 sub agent 專用:以 sleeper 租約代替 exclusive 鎖,hook 會驗身分)
`;
async function main(argv) {
const sub = argv[0];
if (!sub || sub === "--help" || sub === "-h" || sub === "help") {
process.stdout.write(HELP);
return 0;
}
const command = commands[sub];
if (!command) die(`未知子指令 \`${sub}\`。用 \`node persona.mjs --help\` 看清單。`);
const parsed = parseArgs(argv.slice(1));
QUIET = Boolean(parsed.flags.quiet);
CURRENT_FLAGS = parsed.flags;
try {
await command({ flags: parsed.flags, positional: parsed._ });
} catch (err) {
if (err instanceof pl.LockError) die(err.message);
throw err;
}
return 0;
}
process.exit(await main(process.argv.slice(2)));