#!/usr/bin/env node // persona.mjs — jsc-persona 的人格 / 記憶 / 情緒 / 關係圖 CLI(Node.js,只用內建模組) // // 所有子指令都需要 `--session `(除了 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 `(值取自上下文的 `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 \` 再載入 \`${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(/\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 `。"); } 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 `。"); 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 `。"); 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 `。"); 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 [options] 人格與鎖: create --persona --session [--name --creature --vibe --emoji --avatar --baseline --origin --work] load --persona --session [--takeover] release --session [--persona ] list 列出人格與鎖狀態 status [--persona ] [--session ] heartbeat --session 續租 show --session [--persona] [--what identity|soul|agents|user|all] brief --session [--query ] 輸出人格上下文 記憶: remember --session --text [--role --topics --entities --intent --salience --emotion --scope short|inbox --room] recall --session --query [--limit] candidates --session 列出達到「短期→長期」條件的候選與依據 consolidate --session --name --body [--type --about --topics --salience --emotion --rules --source --forget] prune / reindex --session 情緒與圖: emotion --session [--apply joy=+10,...] [--baseline ...] [--trigger ] mindmap show|thread|list --session [--topic ] [--force] relation node|edge|render|show --session [--name --id --kind --closeness --trust --note --tags --from --to --label --affinity] 多人格對話: invite --session --guest [--host --room --topic] (自動開啟劇場模式) leave --session --guest [--room] room post|read|script|list|theater --session [--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)));