腳本全面從 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>
835 lines
34 KiB
JavaScript
835 lines
34 KiB
JavaScript
#!/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;
|
||
}
|
||
|
||
/**
|
||
* 呼叫者是 host(owner)或以 `--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}\`(exclusive,session ${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(`heartbeat:host=${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("guest(sub 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}\` 的 inbox(room ${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("guest(sub 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)));
|