Files
persona/scripts/persona.mjs
T
jiantw83andClaude Opus 5 2005f26c94 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>
2026-07-29 16:03:27 +00:00

835 lines
34 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 { 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)));