feat: 改寫為 Node.js,新增動漫角色建人格、劇場模式與記憶固化條件

腳本全面從 Python 改寫為 Node.js(ESM,只用內建模組,無 npm 依賴):
scripts/persona-lib.mjs(核心)、scripts/persona.mjs(CLI)、hooks/*.mjs(六個
hook)、scripts/selftest.mjs(68 項自我測試,全綠)。

新增:
- persona-anime skill:用「動漫作品+角色名」建立人格,先上網蒐集至少三個獨立
  來源的公開設定,映射成 OpenClaw 的 IDENTITY 五欄位與 SOUL 四段落,再固化成
  canon 基礎記憶(每則帶來源 URL)+原作人際關係圖+依角色型別的情緒基線;
  必寫 roleplay-frame 界線記憶(非官方、非本人)。
- 劇場模式:invite 後只顯示人格對話(`名字:內容`)。UserPromptSubmit hook 每輪
  注入強制規則、Stop hook 完全靜音,CLI 新增 --quiet 與 room script(乾淨對話稿)。
  leave 後沒客人自動關閉,也可用 room theater --on/--off 手動切換。
- 短期→長期記憶的成文轉入條件 R1–R6(promotionCandidates)與 candidates 子指令,
  hook 在達標時提醒固化;長期記憶新增 canon 型別與 rules 欄位。
- 人格改為「由使用者呼叫才載入」:SessionStart hook 只列出可用人格,不自動附身。

其他:版本號改回 0.0.1;README/AGENTS.md 同步更新。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-29 16:03:27 +00:00
co-authored by Claude Opus 5
parent 4426ede979
commit 2005f26c94
39 changed files with 1878 additions and 2695 deletions
+834
View File
@@ -0,0 +1,834 @@
#!/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 { fileURLToPath } from "node:url";
import * as pl from "./persona-lib.mjs";
const HERE = path.dirname(fileURLToPath(import.meta.url));
const TEMPLATE_DIR = path.join(HERE, "..", "skills", "persona-create", "templates");
let QUIET = false;
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", "on", "off", "with-meta", "all",
]);
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;
}
// --------------------------------------------------------------------------- //
// 權限檢查
// --------------------------------------------------------------------------- //
/** 呼叫者必須是這個人格的 exclusive 持有者。 */
function requireOwner(slug, sessionId) {
if (!slug) die("未指定人格,且本 session 沒有載入人格。");
if (!pl.personaExists(slug)) die(`人格 \`${slug}\` 不存在。可用:${pl.listPersonas().join(", ") || "(無)"}`);
const data = pl.loadSession(sessionId);
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);
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 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 = ({ flags }) => {
const session = requireSession(flags);
const slug = str(flags.persona);
if (!pl.validSlug(slug)) die("slug 只能是小寫英數與連字號(最長 48 字),例如 `lumi`、`shen-yu`。");
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),
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,
display_name: mapping.NAME,
created_at: pl.nowIso(),
created_by_session: session,
origin: str(flags.origin) || "custom",
source_work: str(flags.work),
schema: 1,
});
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}\` 建立於 ${root},已取得載入鎖並綁定本 session。`);
say(` 下一步:補完 ${root}/IDENTITY.md 與 SOUL.md,再用 /jsc-persona:persona-chat 開始對話。`);
};
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,
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}\` ${state}` +
(r.locked ? `session ${r.owner_session}…, cwd ${r.owner_cwd}` : "") +
`guest ${r.guests}|長期記憶 ${r.long_term}|短期 ${r.short_term}` +
(r.identity ? `${r.identity}` : ""),
);
}
emit({ home: pl.personaHome(), personas: rows }, flags.json, lines);
};
commands.load = ({ 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 });
pl.pruneShortTerm(slug);
pl.rebuildIndex(slug);
const lines = [`✔ 已載入人格 \`${slug}\`exclusivesession ${session.slice(0, 8)}…,租約 ${lock.lease_seconds}s`];
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 = ({ flags }) => {
const session = requireSession(flags);
const data = pl.loadSession(session);
const slug = str(flags.persona) || data.host;
if (!slug) die("本 session 沒有載入任何人格。");
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 ? "🎭 開啟(只輸出人格對話)" : "關閉"}`,
]);
};
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)}`);
}
pl.touchRecall(slug, hits.map((m) => m._name));
emit({ persona: slug, long_term: hits, short_term: recents }, flags.json, lines);
};
/** 短期 → 長期的「轉入條件」評估:列出達標的候選與依據。 */
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("目前沒有需要固化的內容(未達任何條件)。");
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"));
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";
const VALID_TYPES = ["fact", "preference", "event", "promise", "relationship", "insight", "boundary", "canon"];
if (!VALID_TYPES.includes(type)) die(`--type 只能是 ${VALID_TYPES.join("/")}`);
const front = [
"---",
`name: ${name}`,
`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) {
const rows = pl.readJsonl(pl.shortTermPath(slug));
const keep = rows.filter((r) => Number(r.salience || 0) >= forget);
pl.writeText(pl.shortTermPath(slug), keep.map((r) => JSON.stringify(r)).join("\n") + (keep.length ? "\n" : ""));
say(` 短期記憶已淘汰顯著度 < ${forget} 的項目,剩 ${keep.length} 筆。`);
}
ok(`長期記憶 \`${name}\` 已寫入(共 ${total} 則),INDEX.md 已重建。`);
};
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)} 則長期記憶)。`);
};
commands.emotion = ({ flags }) => {
const session = requireSession(flags);
const slug = hostOf(flags, session);
const [, role] = requireMember(slug, session, Boolean(flags["as-guest"]));
let state = pl.decayEmotion(pl.loadEmotion(slug));
if (flags.baseline) {
for (const [key, value] of Object.entries(parseDeltas(flags.baseline))) {
if (key in pl.EMOTIONS) state.baseline[key] = pl.clamp(value);
}
}
if (flags.apply) {
if (role === "guest") die("guestsub agent)不得改寫人格的情緒狀態。");
const deltas = parseDeltas(flags.apply);
state = pl.applyEmotion(state, deltas, str(flags.trigger));
pl.appendJsonl(pl.journalPath(slug), {
ts: pl.nowIso(), kind: "emotion", trigger: str(flags.trigger),
deltas, levels: state.levels, mood: pl.mood(state),
});
}
if (role !== "guest") pl.writeJson(pl.emotionPath(slug), 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`。");
pl.upsertRelationNode(slug, {
id: str(flags.id) || pl.slugify(name),
name,
kind: str(flags.kind) || "human",
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,
});
pl.renderRelations(slug);
ok(`關係節點 \`${name}\` 已更新。`);
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} 已更新。`);
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/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 guest = str(flags.guest);
if (!guest) die("需要 `--guest <slug>`。");
if (guest === host) die("不能邀請自己。");
if (!pl.personaExists(guest)) die(`人格 \`${guest}\` 不存在。可用:${pl.listPersonas().join(", ")}`);
const lock = pl.readJson(pl.lockPath(guest)) ?? {};
if (Object.keys(lock).length && lock.session_id !== session && !pl.lockIsDead(lock)) {
die(
`人格 \`${guest}\` 正被另一個程序載入(session ${String(lock.session_id).slice(0, 8)}…,cwd ${lock.cwd})。` +
"同一人格同時只能被一個程序載入,無法邀請。",
);
}
const stamp = pl.nowIso().replace(/[-:TZ]/g, "").slice(0, 14);
const room = str(flags.room) || `${host}-${guest}-${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" };
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 }, flags.json, [
`✔ 已邀請人格 \`${guest}\` 以 guest(唯讀)身分加入聊天室 \`${room}\``,
` 聊天室路徑:${pl.roomDir(room)}`,
` 🎭 劇場模式已${data.theater ? "開啟:接下來只能輸出人格對話(`名字:內容`),其他訊息一律隱藏" : "關閉"}`,
" 請用 Agent 工具、subagent_type=\"jsc-persona:persona-guest\" 啟動它,prompt 內帶:",
` 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(", ") || "(無)"})。`);
}
if (action === "post") {
const speaker = str(flags.as) || data.host;
if (!speaker) die("需要 `--as <persona>`。");
requireMember(speaker, session, Boolean(flags["as-guest"]));
let text = str(flags.text);
if (flags["text-file"]) text = fs.readFileSync(str(flags["text-file"]), "utf8").trim();
if (!text) die("需要 `--text` 或 `--text-file`。");
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 });
ok(`\`${speaker}\` 已發言於 \`${room}\`(情緒 ${emotion})。`);
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) {
lines.push(`[${row.ts}] ${row.speaker}${row.emotion ? `${row.emotion}` : ""}${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/list/theater`);
};
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 --persona <slug> --session <id> [--name --creature --vibe --emoji --avatar --baseline --origin --work]
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]
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>]
mindmap show|thread|list --session <id> [--topic <t>] [--force]
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] (自動開啟劇場模式)
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]
維護:
gc 清理死鎖與過期租約
guard (內部)從 stdin 讀 hook event 測試隔離判斷
全域旗標:--json(機器可讀)、--quiet(成功時不輸出;劇場模式必用)
`;
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);
try {
command({ flags: parsed.flags, positional: parsed._ });
} catch (err) {
if (err instanceof pl.LockError) die(err.message);
throw err;
}
return 0;
}
process.exit(main(process.argv.slice(2)));