#!/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 zlib from "node:zlib"; import { fileURLToPath } from "node:url"; import * as pl from "./persona-lib.mjs"; import * as gt from "./persona-gitea.mjs"; import * as ic from "./persona-icon.mjs"; const HERE = path.dirname(fileURLToPath(import.meta.url)); const TEMPLATE_DIR = path.join(HERE, "..", "skills", "persona-create", "templates"); const SELF = path.join(HERE, "persona.mjs"); let QUIET = false; // 本次呼叫的旗標(權限檢查要看 --as-sleeper/--agent-id,不想改十幾個 requireOwner 呼叫點) let CURRENT_FLAGS = {}; function die(message, code = 1) { process.stderr.write(`✖ ${message}\n`); process.exit(code); } function say(line = "") { if (!QUIET) process.stdout.write(`${line}\n`); } function ok(message) { say(`✔ ${message}`); } function emit(payload, asJson, lines) { if (asJson) { process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`); } else { say(lines.join("\n")); } } // --------------------------------------------------------------------------- // // 參數解析 // --------------------------------------------------------------------------- // const FLAGS = new Set([ "json", "quiet", "force", "takeover", "as-guest", "as-sleeper", "on", "off", "with-meta", "all", "with-journal", "gzip", "record", "load", "allow-repeat", "clear", "release", "keep-lock", "contact", "if-due", "no-gitea", "public", "rename", "from-source", ]); function parseArgs(argv) { const out = { _: [], flags: {} }; for (let i = 0; i < argv.length; i += 1) { const token = argv[i]; if (!token.startsWith("--")) { out._.push(token); continue; } const body = token.slice(2); const eq = body.indexOf("="); if (eq >= 0) { out.flags[body.slice(0, eq)] = body.slice(eq + 1); continue; } if (FLAGS.has(body)) { out.flags[body] = true; continue; } const next = argv[i + 1]; if (next === undefined || (next.startsWith("--") && !/^--?\d/.test(next))) { out.flags[body] = true; } else { out.flags[body] = next; i += 1; } } return out; } const num = (value, fallback = null) => (value === undefined || value === true ? fallback : Number(value)); const str = (value, fallback = "") => (value === undefined || value === true ? fallback : String(value)); const csv = (value) => str(value).split(",").map((s) => s.trim()).filter(Boolean); /** `joy=+12,anger=-5` → { joy: 12, anger: -5 } */ function parseDeltas(raw) { const out = {}; for (const chunk of str(raw).split(",")) { const trimmed = chunk.trim(); if (!trimmed.includes("=")) continue; const [key, value] = [trimmed.slice(0, trimmed.indexOf("=")).trim(), trimmed.slice(trimmed.indexOf("=") + 1)]; const parsed = Number(value); if (Number.isFinite(parsed)) out[key] = parsed; } return out; } function requireSession(flags) { const session = str(flags.session); if (!session) die("缺少 `--session `(值取自上下文的 `PERSONA_SESSION=`)。"); return session; } // --------------------------------------------------------------------------- // // 權限檢查 // --------------------------------------------------------------------------- // /** * 睡眠 sub agent 的寫入權:它替某個人格收尾,手上沒有 exclusive 鎖,只有 5 分鐘的 sleeper 租約。 * 第一個收尾指令會把租約取起來(`acquireSleepLease` 內含鎖檢查:目標正被別的程序載入著就拒絕), * 之後每個指令續租,最後由 `sleep` 還掉。`--as-sleeper` 由 PreToolUse hook 把關, * 只有 persona-sleeper 型的 sub agent 能用。 */ function sleeperAccess(slug, sessionId, agentId) { const mine = pl.liveSleepers(slug).find((s) => s.session_id === sessionId); if (mine) { pl.heartbeatSleepLease(slug, sessionId, mine.agent_id ?? null); return mine; } return pl.acquireSleepLease(slug, sessionId, { agentId: agentId || null }); } /** 呼叫者必須是這個人格的 exclusive 持有者(或替它收尾的 sleeper)。 */ function requireOwner(slug, sessionId) { if (!slug) die("未指定人格,且本 session 沒有載入人格。"); if (!pl.personaExists(slug)) die(`人格 \`${slug}\` 不存在。可用:${pl.listPersonas().join(", ") || "(無)"}`); const data = pl.loadSession(sessionId); if (CURRENT_FLAGS["as-sleeper"]) { sleeperAccess(slug, sessionId, str(CURRENT_FLAGS["agent-id"])); return data; } if (data.host !== slug) { die( `本 session 的 host 人格是 \`${data.host || "(未載入)"}\`,不是 \`${slug}\`。` + "禁止跨人格操作;請先 `release` 再 `load`。", ); } const lock = pl.readJson(pl.lockPath(slug)) ?? {}; if (lock.session_id !== sessionId) { die(`人格 \`${slug}\` 的載入鎖不屬於本 session,請重新 \`load\`(必要時加 --takeover)。`); } pl.heartbeatLock(slug, sessionId); return data; } /** * 呼叫者是 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); // sleeper 讀自己的資料:`requireOwner` 早就放它寫了,讀反而被擋在外面—— // 少了這個分支,它就得在看不到 brief/recall 的情況下決定要固化什麼。 if (CURRENT_FLAGS["as-sleeper"]) { if (asGuest) die("`--as-sleeper` 與 `--as-guest` 不能一起用(那是兩種不同的身分)。"); sleeperAccess(slug, sessionId, str(CURRENT_FLAGS["agent-id"])); return [data, "sleeper"]; } if (data.host === slug) { if (asGuest) die(`\`${slug}\` 是本 session 的 host 人格,不需要也不得使用 \`--as-guest\`。`); pl.heartbeatLock(slug, sessionId); return [data, "owner"]; } if (slug in (data.guests || {})) { if (!asGuest) { die( `\`${slug}\` 是本 session 邀請的 guest 人格,它的記憶與情緒不對主程序開放(跨人格資料隔離)。` + "你只能透過 `room read` 看它說出口的話;要以它的身分行動必須是 persona-guest sub agent 並帶 `--as-guest`。", ); } return [data, "guest"]; } die(`人格 \`${slug}\` 未被本 session 載入或邀請,禁止存取(跨人格資料隔離)。`); return [null, null]; } const hostOf = (flags, session) => str(flags.persona) || pl.loadSession(session).host; function parseGuestList(flags) { const guests = [...csv(flags.guest), ...csv(flags.guests)]; const out = []; const seen = new Set(); for (const guest of guests) { if (!guest || seen.has(guest)) continue; seen.add(guest); out.push(guest); } return out; } function parseSleepTargets(flags, fallbackHost = null) { const targets = []; for (const slug of [...csv(flags.persona), ...csv(flags.personas)]) { if (!slug || targets.includes(slug)) continue; targets.push(slug); } if (!targets.length && fallbackHost) targets.push(fallbackHost); return targets; } /** * 里程碑事件(記憶固化、身分/關係變更)之後,把 Wiki 區推上去。 * 背景執行、失敗不阻斷:同步永遠不該卡住對話。 */ function pushWikiLater(slug, session, flags) { if (flags["no-gitea"] || gt.giteaProblem() || !gt.personaCode(slug)) return; gt.pushInBackground(slug, "wiki", session, SELF); } function renderTemplate(name, mapping) { let text = fs.readFileSync(path.join(TEMPLATE_DIR, name), "utf8"); for (const [key, value] of Object.entries(mapping)) text = text.replaceAll(`{{${key}}}`, String(value)); return text; } // --------------------------------------------------------------------------- // // 子指令 // --------------------------------------------------------------------------- // const commands = {}; commands.create = async ({ flags }) => { const session = requireSession(flags); // 人格編號 = 英文名全大寫 + 兩位索引(同名才遞增)。編號就是 Gitea 存取庫的名稱。 let code = str(flags.code); if (code && !gt.validCode(code)) die(`編號 \`${code}\` 不合法,格式是「英文名全大寫-兩位數」,例如 \`ASUNA-01\`。`); if (!code) { const base = gt.normalizeRomaji(str(flags.romaji) || str(flags.persona)); if (!base) { die( "需要 `--romaji <英文名>`:人格編號是英文名全大寫加索引(例:Asuna → `ASUNA-01`)。" + "中文名請先轉成羅馬拼音並跟使用者確認拼法,再帶進來。", ); } code = gt.nextCode(base); if (!code) die(`\`${base}\` 的編號已經用到 99,請換一個英文名。`); } // 沒指定 --persona 就用編號當目錄名(一個識別走到底);指定了就沿用(相容既有人格) const slug = str(flags.persona) || code; if (!pl.validSlug(slug)) die("人格目錄名只能是英數與連字號(最長 48 字),建議直接用編號,例如 `ASUNA-01`。"); if (pl.personaExists(slug) && !flags.force) { die(`人格 \`${slug}\` 已存在(${pl.personaDir(slug)})。要覆寫請加 --force。`); } const root = pl.ensurePersonaDirs(slug); const mapping = { SLUG: slug, NAME: str(flags.name) || slug, CREATURE: str(flags.creature), VIBE: str(flags.vibe), EMOJI: str(flags.emoji), AVATAR: str(flags.avatar), CREATED: pl.nowIso(), }; for (const filename of ["IDENTITY.md", "SOUL.md", "AGENTS.md", "USER.md"]) { const target = path.join(root, filename); if (fs.existsSync(target) && !flags.force) continue; pl.writeText(target, renderTemplate(filename, mapping)); } pl.writeJson(pl.emotionPath(slug), pl.defaultEmotionState(parseDeltas(flags.baseline))); pl.writeJson(pl.configPath(slug), { persona: slug, code, romaji: gt.codePrefix(code), display_name: mapping.NAME, created_at: pl.nowIso(), created_by_session: session, origin: str(flags.origin) || "custom", source_work: str(flags.work), schema: 2, }); pl.writeJson(pl.relationsJson(slug), { nodes: [], edges: [] }); pl.writeText( pl.mindmapPath(slug), ["%% 心智圖(長期語意結構):概念如何互相勾連", "mindmap", ` root((${mapping.NAME}))`, " 自我", " 使用者", " 共同經驗", ""].join("\n"), ); pl.rebuildIndex(slug); pl.acquireLock(slug, session, { cwd: str(flags.cwd) || null }); pl.bindHost(session, slug, { cwd: str(flags.cwd) || null }); ok(`人格 \`${slug}\`(編號 \`${code}\`)建立於 ${root},已取得載入鎖並綁定本 session。`); say(` 下一步:補完 ${root}/IDENTITY.md 與 SOUL.md,再用 /jsc-persona:persona-chat 開始對話。`); // Gitea 上的存取庫名稱就是編號。身分還沒補完,這裡只開庫;內容之後由各時機自動 push。 if (!flags["no-gitea"] && !gt.giteaProblem()) { try { const info = await gt.initRemote(slug, { code, private_: !flags.public }); say(` 📦 Gitea:${info.repo.html_url}(${info.created ? "已建立" : "沿用既有"},${info.repo.private ? "私有" : "公開"})`); for (const key of gt.AREA_KEYS) { const res = info.results[key] || {}; say( res.ok ? ` ${gt.AREAS[key].label}(${gt.AREAS[key].why}):${res.changed ? `已推送 ${res.files} 個檔案` : "目前沒有內容"}` : ` ⚠ ${gt.AREAS[key].label}推送失敗:${String(res.reason).slice(0, 160)}`, ); } } catch (err) { say(` ⚠ Gitea 存取庫建立失敗(不影響本機使用):${err.message}`); say(" 之後可用 `sync init` 補建。"); } } else if (!flags["no-gitea"]) { say(` ℹ 未同步到 Gitea:${gt.giteaProblem()}`); } }; commands.list = ({ flags }) => { const rows = pl.listPersonas().map((slug) => { const status = pl.lockStatus(slug); let longTerm = 0; try { longTerm = fs.readdirSync(pl.longTermDir(slug)).filter((f) => f.endsWith(".md")).length; } catch { longTerm = 0; } return { persona: slug, code: gt.personaCode(slug), identity: pl.identityBrief(slug), locked: status.locked, stale: status.stale, owner_session: String(status.owner.session_id || "").slice(0, 8), owner_cwd: status.owner.cwd, guests: status.guests.length, long_term: longTerm, short_term: pl.readJsonl(pl.shortTermPath(slug)).length, }; }); const lines = [`人格倉庫:${pl.personaHome()}`]; if (!rows.length) lines.push("(尚無人格,用 /jsc-persona:persona-create 或 /jsc-persona:persona-anime 建立)"); for (const r of rows) { const state = r.locked ? "🔒 已載入" : r.stale ? "⚠ 死鎖可接手" : "🔓 空閒"; lines.push( `- \`${r.persona}\`${r.code && r.code !== r.persona ? `(編號 ${r.code})` : ""} ${state}` + (r.locked ? `(session ${r.owner_session}…, cwd ${r.owner_cwd})` : "") + `|guest ${r.guests}|長期記憶 ${r.long_term}|短期 ${r.short_term}` + (r.code ? "" : "|⚠ 尚無編號") + (r.identity ? `|${r.identity}` : ""), ); } emit({ home: pl.personaHome(), personas: rows }, flags.json, lines); }; commands.load = async ({ flags }) => { const session = requireSession(flags); const slug = str(flags.persona); if (!pl.personaExists(slug)) die(`人格 \`${slug}\` 不存在。可用:${pl.listPersonas().join(", ") || "(無)"}`); const data = pl.loadSession(session); if (data.host && data.host !== slug) { die( `本 session 已載入人格 \`${data.host}\`。一個程序只能載入一個人格;` + `請先 \`release --session \` 再載入 \`${slug}\`` + "(若只是想讓兩個人格對話,請用 /jsc-persona:persona-invite)。", ); } let lock; try { lock = pl.acquireLock(slug, session, { cwd: str(flags.cwd) || null, takeover: Boolean(flags.takeover) }); } catch (err) { die(`${err.message}\n 若確定那個程序已結束,可加 --takeover 接手。`); } pl.bindHost(session, slug, { cwd: str(flags.cwd) || null }); // 載入時先把遠端拉回來(別台機器可能動過),衝突就停下來讓使用者決定 const pulled = []; if (!flags["no-gitea"] && !gt.giteaProblem() && gt.personaCode(slug)) { for (const area of gt.AREA_KEYS) { try { pulled.push(await gt.pullArea(slug, area)); } catch (err) { pulled.push({ ok: false, area, reason: err.message }); } } } pl.pruneShortTerm(slug); pl.rebuildIndex(slug); const lines = [`✔ 已載入人格 \`${slug}\`(exclusive,session ${session.slice(0, 8)}…,租約 ${lock.lease_seconds}s)`]; for (const res of pulled) { if (res.conflicts?.length) { lines.push( `⚠ ${gt.AREAS[res.area].label}有衝突,**沒有覆蓋本機**:${res.conflicts.slice(0, 5).join(", ")}` + "。請告訴使用者:本機與 Gitea 都改過同一份資料,要保留哪一邊(`sync pull --force` 會以遠端為準)。", ); } else if (res.ok && res.written?.length) { lines.push(`↓ ${gt.AREAS[res.area].label}從 Gitea 拉回 ${res.written.length} 個檔案。`); } else if (!res.ok && !res.skipped) { lines.push(`⚠ ${gt.AREAS[res.area]?.label || res.area}同步失敗(不影響本機):${String(res.reason).slice(0, 120)}`); } } if (lock.took_over_from) { const prev = lock.took_over_from; lines.push( `⚠ 這把鎖是接手來的:原持有者 session ${String(prev.session_id || "").slice(0, 8)}…(cwd ${prev.cwd})` + `已失聯 ${prev.stale_minutes} 分鐘。請向使用者說明,若那個程序其實還活著,兩邊的記憶可能會互相覆蓋。`, ); } const context = pl.turnContext(slug, session); lines.push(context); emit({ persona: slug, lock, context }, flags.json, lines); }; commands.release = async ({ flags }) => { const session = requireSession(flags); const data = pl.loadSession(session); const slug = str(flags.persona) || data.host; if (!slug) die("本 session 沒有載入任何人格。"); // 收工前把兩區都推上去(失敗不阻斷釋放,人格不能被鎖在網路問題裡) if (!flags["no-gitea"] && !gt.giteaProblem() && pl.personaExists(slug) && gt.personaCode(slug)) { for (const area of gt.AREA_KEYS) { try { const res = await gt.pushArea(slug, area, { message: `release: 對話結束 ${pl.nowIso()}` }); if (res.ok && res.changed) say(` ↑ ${gt.AREAS[area].label}已推上 Gitea。`); else if (!res.ok && !res.skipped) say(` ⚠ ${gt.AREAS[area].label}推送失敗:${String(res.reason).slice(0, 120)}`); } catch (err) { say(` ⚠ ${gt.AREAS[area].label}推送失敗:${err.message.slice(0, 120)}`); } } } const released = pl.unbindSession(session); ok(`已釋放人格 \`${slug}\` 的載入鎖${released.guests.length ? `,並退出 guest:${released.guests.join(", ")}` : "。"}`); }; commands.status = ({ flags }) => { const slug = str(flags.persona); if (slug) { const status = pl.lockStatus(slug); const lines = [ `人格 \`${slug}\`:${status.locked ? "🔒 已載入" : status.stale ? "⚠ 死鎖可接手" : "🔓 空閒"}`, ` owner: ${JSON.stringify(status.owner)}`, ` guests: ${JSON.stringify(status.guests)}`, ]; if (pl.personaExists(slug)) lines.push(` ${pl.emotionBrief(slug)}`); emit(status, flags.json, lines); return; } const session = str(flags.session); const data = session ? pl.loadSession(session) : {}; emit(data, flags.json, [ `session ${(session || "-").slice(0, 12)}…`, ` host 人格:${data.host || "(未載入)"}`, ` guest 人格:${Object.keys(data.guests || {}).join(", ") || "(無)"}`, ` 聊天室:${(data.rooms || []).join(", ") || "(無)"}`, ` 劇場模式:${data.theater ? "🎭 開啟(只輸出人格對話)" : "關閉"}`, ]); }; // 睡眠:把一天的活狀態收成能留下來的形狀。 // // 這裡只做**機械性**的收尾(順序即相依)。需要判斷的部分——哪些短期記憶值得固化、 // 日記要寫什麼、心智圖怎麼接——是那個人格自己的事,由 /jsc-persona:persona-sleep // 在呼叫本指令**之前**完成。 // // 回傳(`--json`)只有「睡完了沒、哪一步出錯」:主人格拿到的就是這份報告, // 裡面不含任何記憶內容——回傳值本身就是一條會繞過隔離的通道,所以在這裡封死。 commands.sleep = async ({ flags }) => { const session = requireSession(flags); const data = pl.loadSession(session); const targets = parseSleepTargets(flags, data.host); if (!targets.length) die("需要 `--persona `、`--personas `,或先載入一個人格。"); for (const slug of targets) { if (!pl.personaExists(slug)) die(`人格 \`${slug}\` 不存在。`); } const sleepOne = async (slug, { release = false } = {}) => { // 寫入權:自己睡自己 → 已經有 exclusive 鎖;被叫來睡別人 → 取 sleeper 租約。 // 目標正被另一個程序載入著對話時會在這裡失敗,這是刻意的(避免記憶互相覆蓋)。 const sessionData = pl.loadSession(session); const selfSleep = sessionData.host === slug; let lease = null; if (!selfSleep) { try { lease = pl.acquireSleepLease(slug, session, { agentId: str(flags["agent-id"]) || null }); } catch (err) { return { persona: slug, ok: false, failed_step: "lease", error: err.message }; } } const steps = []; const run = async (step, fn) => { try { await fn(); steps.push({ step, ok: true }); } catch (err) { // 一步壞掉不放棄整個睡眠:記下來繼續走,最後在報告裡說明。 steps.push({ step, ok: false, error: String(err.message || err).slice(0, 300) }); } }; const hours = pl.hoursAwake(slug); await run("relation-contact", () => { // 今天提到過的人 → 蓋上「最後一次接觸」的時間戳(主動關心的依據) const seen = new Set(); for (const row of pl.readJsonl(pl.shortTermPath(slug), 200)) { for (const name of row.entities || []) seen.add(name); } for (const name of seen) pl.stampContact(slug, name); pl.renderRelations(slug); }); await run("prune-short-term", () => pl.pruneShortTerm(slug)); await run("archive-threads", () => pl.archiveStaleThreads(slug)); await run("emotion-decay", () => { const state = pl.decayEmotionBy(pl.loadEmotion(slug), pl.SLEEP_DECAY_MINUTES); pl.writeJson(pl.emotionPath(slug), state); }); await run("reindex", () => pl.rebuildIndex(slug)); await run("trim-said", () => pl.trimSaid(slug)); await run("archive-journal", () => pl.archiveJournals(slug)); await run("sleep-state", () => { const prev = pl.loadSleepState(slug); pl.saveSleepState(slug, { last_slept_at: pl.nowIso(), count: Number(prev.count || 0) + 1, hours_awake: hours, }); }); // Gitea:兩區都推,推完驗證。沒設定或失敗都不算睡眠失敗(同步永遠不阻斷)。 const sync = {}; if (flags["no-gitea"] || gt.giteaProblem() || !gt.personaCode(slug)) { sync.skipped = gt.giteaProblem() || (gt.personaCode(slug) ? "使用者要求略過" : "尚無人格編號"); } else { for (const area of gt.AREA_KEYS) { try { const res = await gt.pushArea(slug, area, { message: `sleep: 收尾 ${pl.nowIso()}` }); sync[area] = res.ok ? (res.changed ? "pushed" : "no-change") : `failed: ${String(res.reason).slice(0, 80)}`; } catch (err) { sync[area] = `failed: ${String(err.message).slice(0, 80)}`; } } try { const verdicts = []; for (const area of gt.AREA_KEYS) verdicts.push(await gt.verifyArea(slug, area)); sync.verified = verdicts.every((v) => v.ok !== false); } catch { sync.verified = false; } } // 預設 --keep-lock:睡完還能繼續聊。真的要收工才 --release。 let released = false; if (release && selfSleep) { pl.unbindSession(session); released = true; } if (lease) pl.dropSleepLease(slug, session, str(flags["agent-id"]) || null); const failed = steps.filter((s) => !s.ok); return { persona: slug, ok: failed.length === 0, slept_at: pl.nowIso(), steps, sync, kept_lock: !released, }; }; const host = data.host || null; const ordered = [...targets.filter((slug) => slug !== host), ...(host && targets.includes(host) ? [host] : [])]; const deferRelease = Boolean(flags.release) && ordered.length > 1 && host && targets.includes(host); const results = []; for (const slug of ordered) { results.push(await sleepOne(slug, { release: Boolean(flags.release) && !deferRelease && slug === host })); } if (deferRelease) { pl.unbindSession(session); const hostResult = results.find((r) => r.persona === host); if (hostResult) { hostResult.kept_lock = false; hostResult.released = true; } } const payload = results.length === 1 ? results[0] : { ok: results.every((r) => r.ok), personas: results }; if (flags.json) { process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`); if (!payload.ok) process.exit(1); return; } if (results.length === 1) { const single = results[0]; const failed = (single.steps || []).filter((s) => !s.ok); if (single.ok) ok(`人格 \`${single.persona}\` 已完成睡眠收尾(${single.steps.length} 個步驟,檔案處理無錯誤)。`); else say(`✖ 人格 \`${single.persona}\` 睡眠收尾有 ${failed.length} 步失敗:${failed.map((s) => s.step).join(", ")}`); for (const s of failed) say(` - ${s.step}:${s.error}`); say(` Gitea:${Object.entries(single.sync || {}).map(([k, v]) => `${k}=${v}`).join(" ") || "(未同步)"}`); say(` 載入鎖:${single.kept_lock ? "保留(--release 才收工)" : "已釋放"}`); if (!single.ok) process.exit(1); return; } say("🌙 睡眠完成"); for (const item of results) { const failed = (item.steps || []).filter((s) => !s.ok); const sync = Object.entries(item.sync || {}).map(([k, v]) => `${k}=${v}`).join(" ") || "(未同步)"; say(`- ${item.persona}:${item.ok ? "完成" : `⚠ ${failed.map((s) => s.step).join(", ")}`}${item.released ? "(已釋放鎖)" : ""}`); if (!item.ok) for (const s of failed) say(` - ${s.step}:${s.error}`); say(` Gitea:${sync}`); say(` 載入鎖:${item.kept_lock ? "保留(--release 才收工)" : "已釋放"}`); } if (!payload.ok) process.exit(1); }; // 預設人格:開新 session 時由 SessionStart hook 自動載入。沒設定就什麼都不做—— // 「不自己挑一個人格附身」仍然是預設行為,這裡只是讓使用者能明示地推翻它。 commands.default = ({ flags }) => { const clear = Boolean(flags.clear || flags.off); const slug = str(flags.persona); if (clear) { pl.setDefaultPersona(null); ok("已清除預設人格:之後開新 session 不會自動載入任何人格(等使用者指定)。"); return; } if (slug) { if (!pl.personaExists(slug)) { die(`人格 \`${slug}\` 不存在。可用:${pl.listPersonas().join(", ") || "(無)"}`); } pl.setDefaultPersona(slug); ok(`已設定預設人格 \`${slug}\`:之後每個新 session 都會自動載入它(載入不到時只回報,不會強制接手)。`); say(` 設定檔:${pl.homeSettingsPath()} 停用:\`default --clear\` 或環境變數 \`PERSONA_DEFAULT=off\``); return; } const current = pl.defaultPersona(); const env = String(process.env.PERSONA_DEFAULT ?? "").trim(); emit( { default_persona: current, from_env: Boolean(env), settings: pl.homeSettingsPath() }, flags.json, [ `預設人格:${current ? `\`${current}\`${pl.personaExists(current) ? "" : "(⚠ 這個人格不存在)"}` : "(未設定,開新 session 不自動載入)"}`, ` 來源:${env ? "環境變數 PERSONA_DEFAULT" : "設定檔"} ${pl.homeSettingsPath()}`, " 設定:`default --persona ` 停用:`default --clear`", ], ); }; commands.heartbeat = ({ flags }) => { const session = requireSession(flags); const data = pl.loadSession(session); if (data.host) pl.heartbeatLock(data.host, session); for (const [guest, info] of Object.entries(data.guests || {})) { pl.addGuestLease(guest, session, info.room || "", data.host || ""); } ok(`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); }; /** * 心裡話:推導、盤算、對記憶的比對……全部寫在這裡,不說出口。 * 永遠只回報「心想 N 句」,不回顯內容——使用者看到的是狀態,不是你的內心。 */ commands.think = ({ flags }) => { const session = requireSession(flags); const slug = hostOf(flags, session); const [, role] = requireMember(slug, session, Boolean(flags["as-guest"])); const text = str(flags.text); if (!text) die("需要 `--text`(心裡話內容;不會顯示給使用者)。"); const kind = str(flags.kind) || "infer"; const room = str(flags.room) || null; let count; if (role === "guest") { // guest 對自己的人格檔案唯讀,心裡話跟見聞一樣先進 inbox,回家再消化 if (!room) die("guest(sub agent)的心裡話要帶 `--room`,會寫進自己的 inbox。"); pl.appendJsonl(pl.inboxPath(slug, room), { ts: pl.nowIso(), role: "inner", kind, text, room, salience: num(flags.salience, 35), }); count = pl.readJsonl(pl.inboxPath(slug, room)).filter((r) => r.role === "inner").length; } else { pl.recordInner(slug, text, { kind, room }); count = pl.innerCount(slug); } emit({ persona: slug, kind, count }, flags.json, [`💭 心想 ${count} 句`]); }; /** 說過的話:查最近說了什麼、以及「這句是不是又要再說一次」。 */ commands.said = ({ flags, positional }) => { const session = requireSession(flags); const slug = hostOf(flags, session); requireMember(slug, session, Boolean(flags["as-guest"])); const action = positional[0] || "check"; if (action === "list") { const rows = pl.recentSaid(slug, num(flags.limit, 8)); emit({ persona: slug, said: rows }, flags.json, [ `\`${slug}\` 最近說過的話(${rows.length} 則):`, ...rows.map((r) => ` - [${r.ts}] ${String(r.text || "").replace(/\n/g, " ").slice(0, 100)}`), ]); return; } if (action === "check") { const text = str(flags.text); if (!text) die("需要 `--text`(你打算說的話)。"); const opts = { minutes: num(flags.minutes, pl.REPEAT_WINDOW_MINUTES), threshold: num(flags.threshold, pl.REPEAT_THRESHOLD), }; const repeat = pl.saidRepeat(slug, text, opts); const sentences = pl.sentenceCount(text); const tooLong = sentences > pl.MAX_SENTENCES; const lines = []; if (repeat) { lines.push( `⚠ 這句跟 ${repeat.minutes_ago} 分鐘前說過的話相似 ${repeat.similarity}:「${repeat.text.slice(0, 60)}」`, " → 換個角度、補新資訊,或直接推進話題;不要再說一次。", ); } if (tooLong) lines.push(`⚠ 這段有 ${sentences} 句,超過 ${pl.MAX_SENTENCES} 句上限 → 砍到重點。`); if (!repeat && !tooLong) { lines.push(`✔ 沒說過,${sentences} 句,可以說。`); if (flags.record) pl.recordSaid(slug, text, { kind: "reply" }); } emit({ persona: slug, repeat, sentences, ok: !repeat && !tooLong }, flags.json, lines); return; } die(`未知 action:${action}(可用 check/list)`); }; /** 短期 → 長期的「轉入條件」評估:列出達標的候選與依據。 */ commands.candidates = ({ flags }) => { const session = requireSession(flags); const slug = hostOf(flags, session); requireOwner(slug, session); const result = pl.promotionCandidates(slug); const lines = [ `人格 \`${slug}\`:短期記憶 ${result.total} 筆,達固化條件的候選 ${result.candidates.length} 組` + `${result.pressure ? "(已達容量壓力 R6)" : ""}`, "轉入條件:", ...pl.PROMOTION_RULES.map((r) => ` ${r.id} ${r.label}`), ]; if (!result.candidates.length) lines.push("目前沒有需要固化的內容(未達任何條件)。"); 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"; // diary:睡眠時寫的第一人稱回顧(persona-sleep/persona-sleeper 都用這個型別) const VALID_TYPES = [ "fact", "preference", "event", "promise", "relationship", "insight", "boundary", "canon", "diary", ]; if (!VALID_TYPES.includes(type)) die(`--type 只能是 ${VALID_TYPES.join("/")}。`); const front = [ "---", `name: ${name}`, `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 已重建。`); pushWikiLater(slug, session, flags); // 固化=Wiki 區(低頻設定)該更新了 }; commands.prune = ({ flags }) => { const session = requireSession(flags); const slug = hostOf(flags, session); requireOwner(slug, session); const kept = pl.pruneShortTerm(slug); ok(`短期記憶已裁剪,剩 ${kept} 筆(保留上限 ${pl.SHORT_TERM_KEEP} 筆 / ${pl.SHORT_TERM_DAYS} 天)。`); }; commands.reindex = ({ flags }) => { const session = requireSession(flags); const slug = hostOf(flags, session); requireOwner(slug, session); ok(`INDEX.md 重建完成(${pl.rebuildIndex(slug)} 則長期記憶)。`); pushWikiLater(slug, session, flags); }; commands.emotion = ({ flags }) => { const session = requireSession(flags); const slug = hostOf(flags, session); const [, role] = requireMember(slug, session, Boolean(flags["as-guest"])); 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`。"); const bond = str(flags.bond)?.toLowerCase() || null; if (bond && !pl.BONDS.includes(bond)) die(`未知的 --bond:${bond}(可用 ${pl.BONDS.join("/")})`); pl.upsertRelationNode(slug, { id: str(flags.id) || pl.slugify(name), name, kind: str(flags.kind) || "human", // bond 決定語氣層(伴侶/子女/摯友…);kind 只說這是人還是程式,分不出語氣。 bond, closeness: flags.closeness !== undefined ? pl.clamp(num(flags.closeness, 30)) : null, trust: flags.trust !== undefined ? pl.clamp(num(flags.trust, 30)) : null, note: str(flags.note) || null, tags: csv(flags.tags).length ? csv(flags.tags) : null, // --contact:這輪真的有接觸到這個人 → 蓋時間戳(睡眠與「主動關心」都靠它判斷沉默多久) last_contact_at: flags.contact ? pl.nowIso() : null, }); pl.renderRelations(slug); const tone = pl.toneFor(pl.loadRelations(slug).nodes.find((n) => n.id === (str(flags.id) || pl.slugify(name)))); ok( `關係節點 \`${name}\` 已更新${flags.contact ? "(已記錄本次接觸時間)" : ""}` + `|${tone.bond_label}・語氣層 ${tone.layer}。`, ); pushWikiLater(slug, session, flags); return; } // 他本人要求過的語氣規則 → 直接掛在關係節點上(不另開檔案;關係圖每輪都會被讀)。 if (action === "style") { const key = str(flags.id) || pl.slugify(str(flags.name) || ""); if (!key) die("`style` 需要 `--name`(或 `--id`)。"); const data = pl.loadRelations(slug); const node = data.nodes.find((n) => n.id === key || pl.slugify(n.name || "") === key); if (!node) die(`關係圖裡找不到 \`${str(flags.name) || key}\`,請先用 \`relation node\` 建立。`); const facet = str(flags.facet); if (!facet) { const rules = pl.styleRules(slug, node); emit({ node: node.id, rules }, flags.json, [ `\`${node.name || node.id}\` 的語氣規則(${rules.length}):`, ...rules.map((r) => ` - ${r.facet}→「${r.value}」${r.except ? `(例外:${r.except}${r.suspended ? ",目前成立→暫停" : ""})` : ""}` + `${r.since ? `|${r.since} 起` : ""}`), ]); return; } node.style ??= {}; if (flags.clear) { delete node.style[facet]; ok(`已移除 \`${node.name || node.id}\` 的「${facet}」規則。`); } else { const value = str(flags.value); if (!value) die("`style` 需要 `--value`(移除請用 `--clear`)。"); node.style[facet] = { value, except: str(flags.except) || null, // 形如 anger>=40:情緒成立時這條規則暫停 since: str(flags.since) || pl.nowIso().slice(0, 10), }; ok(`\`${node.name || node.id}\`:${facet}→「${value}」${str(flags.except) ? `(例外 ${str(flags.except)})` : ""} 已記下。`); } node.updated_at = pl.nowIso(); pl.writeJson(pl.relationsJson(slug), data); pushWikiLater(slug, session, flags); return; } // 使用者在關係圖裡是誰 → 每輪 `` 就能算出該用哪一層語氣說話。 if (action === "speaker") { const config = pl.loadConfig(slug); if (flags.clear) { delete config.speaker_node; pl.writeJson(pl.configPath(slug), config); ok("已取消對話對象設定(語氣層回到預設)。"); return; } const key = str(flags.id) || pl.slugify(str(flags.name) || ""); if (!key) die("`speaker` 需要 `--name`(或 `--id`),取消請用 `--clear`。"); const node = pl.loadRelations(slug).nodes.find((n) => n.id === key || pl.slugify(n.name || "") === key); if (!node) die(`關係圖裡找不到 \`${str(flags.name) || key}\`,請先用 \`relation node\` 建立。`); config.speaker_node = node.id; pl.writeJson(pl.configPath(slug), config); const tone = pl.toneFor(node); ok(`對話對象設為 \`${node.name || node.id}\`|${tone.bond_label}・語氣層 ${tone.layer} — ${tone.guide}`); return; } if (action === "edge") { const to = str(flags.to); if (!to) die("`edge` 需要 `--to`。"); pl.upsertRelationEdge(slug, { from: str(flags.from) || "self", to, label: str(flags.label) || null, affinity: flags.affinity !== undefined ? pl.clamp(num(flags.affinity, 50)) : null, }); pl.renderRelations(slug); ok(`關係連線 ${str(flags.from) || "self"} → ${to} 已更新。`); pushWikiLater(slug, session, flags); return; } if (action === "render") { process.stdout.write(pl.renderRelations(slug)); return; } if (action === "show") { const data = pl.loadRelations(slug); emit(data, flags.json, [ `人格 \`${slug}\` 人際關係圖:${data.nodes.length} 節點 / ${data.edges.length} 連線`, pl.relationsBrief(slug, null, 20) || "(空)", ]); return; } die(`未知 action:${action}(可用 node/edge/style/speaker/render/show)`); }; commands.invite = ({ flags }) => { const session = requireSession(flags); const host = str(flags.host) || pl.loadSession(session).host; if (!host) die("本 session 尚未載入 host 人格,無法邀請他人。"); requireOwner(host, session); const guestList = parseGuestList(flags); if (!guestList.length) die("需要 `--guest ` 或 `--guests `。"); if (guestList.includes(host)) die("不能邀請自己。"); for (const guest of guestList) { if (!pl.personaExists(guest)) die(`人格 \`${guest}\` 不存在。可用:${pl.listPersonas().join(", ")}`); } // guest 租約是唯讀的(`mode: "guest-readonly"`),與 exclusive 載入鎖並存不會互相覆寫: // guest 在 sub agent 裡被 PreToolUse hook 擋掉所有寫入工具,記憶只能進 inbox、情緒不得更動。 // 因此這裡**不以 exclusive 的標準驗鎖**,只在對方仍活著時提示使用者「你看到的是唯讀旁聽」。 const guestSummaries = guestList.map((guest) => { const lock = pl.readJson(pl.lockPath(guest)) ?? {}; const heldElsewhere = Boolean(Object.keys(lock).length) && lock.session_id !== session && !pl.lockIsDead(lock); return { guest, lock, heldElsewhere }; }); const heldElsewhereGuests = guestSummaries.filter((g) => g.heldElsewhere); const stamp = pl.nowIso().replace(/[-:TZ]/g, "").slice(0, 14); const room = str(flags.room) || `${host}-${guestList.join("-")}-${stamp}`; pl.createRoom(room, host, session, str(flags.topic)); const data = pl.loadSession(session); data.guests ??= {}; for (const { guest } of guestSummaries) { pl.joinRoom(room, guest); pl.addGuestLease(guest, session, room, host); data.guests[guest] = { room, joined_at: pl.nowIso(), mode: "guest-readonly" }; } data.rooms ??= []; if (!data.rooms.includes(room)) data.rooms.push(room); data.theater = flags.theater === false || flags.theater === "off" ? false : true; pl.saveSession(session, data); if (str(flags.topic)) pl.roomPost(room, "system", `主題:${str(flags.topic)}`, { kind: "meta" }); const guestLabel = guestList.map((g) => `\`${g}\``).join("、"); emit( { room, guest: guestList[0], guests: guestList, host, dir: pl.roomDir(room), theater: data.theater, held_elsewhere: heldElsewhereGuests.length > 0, held_elsewhere_guests: heldElsewhereGuests.map(({ guest, lock }) => ({ guest, session_id: lock.session_id, cwd: lock.cwd, })), }, flags.json, [ `✔ 已邀請人格 ${guestLabel} 以 guest(唯讀)身分加入聊天室 \`${room}\`。`, ...(heldElsewhereGuests.length ? [ ` ℹ 唯讀旁聽:${heldElsewhereGuests .map(({ guest, lock }) => `\`${guest}\`(session ${String(lock.session_id).slice(0, 8)}…,cwd ${lock.cwd})`) .join("、")};寫入仍由原本的程序獨占,兩邊不會互相覆寫。`, ] : []), ` 聊天室路徑:${pl.roomDir(room)}`, ` 🎭 劇場模式已${data.theater ? "開啟:接下來只能輸出人格對話(`名字:內容`),其他訊息一律隱藏" : "關閉"}。`, " 請用 Agent 工具、subagent_type=\"jsc-persona:persona-guest\" 逐一啟動它們,prompt 內帶:", ...guestList.map((guest) => ` persona=${guest} room=${room} session=${session}`), " guest 只能讀自己的人格資料(跨人格隔離),發言請走 `persona.mjs room post`。", ], ); }; commands.leave = ({ flags }) => { const session = requireSession(flags); const data = pl.loadSession(session); const guest = str(flags.guest); if (!guest) die("需要 `--guest `。"); 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 `。"); const [, speakerRole] = 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`。"); // 劇場模式也要像正常人聊天:一次 1–3 句、短時間內不重複同一句話。 // 這裡用 die 擋,因為劇場模式的 CLI 都帶 --quiet,警告訊息會被丟掉。 if (!flags["allow-repeat"] && !flags.force) { const opts = { minutes: num(flags.minutes, pl.REPEAT_WINDOW_MINUTES), threshold: num(flags.threshold, pl.REPEAT_THRESHOLD), }; const repeat = pl.roomRepeat(room, speaker, text, opts) || (speakerRole === "guest" ? null : pl.saidRepeat(speaker, text, opts)); if (repeat) { die( `\`${speaker}\` 在 ${repeat.minutes_ago} 分鐘前說過幾乎一樣的話(相似度 ${repeat.similarity}):` + `「${repeat.text.slice(0, 60)}」。換個說法、補新東西或推進話題;` + "真的需要重複(例如被追問)才加 `--allow-repeat`。", ); } } const sentences = pl.sentenceCount(text); if (sentences > pl.MAX_SENTENCES && !flags.force) { die( `這句有 ${sentences} 句,超過 ${pl.MAX_SENTENCES} 句上限——聊天不是報告,砍到重點再發。` + "(真的需要長段落才加 `--force`。)", ); } let emotion = str(flags.emotion); if (!emotion && pl.personaExists(speaker)) { emotion = pl.dominant(pl.decayEmotion(pl.loadEmotion(speaker)), 2) .map(({ key, level }) => `${pl.EMOTIONS[key].zh}${Math.round(level)}`) .join("/"); } const entry = pl.roomPost(room, speaker, text, { emotion }); // guest 對自己的狀態唯讀,它說過的話留在聊天室逐字稿裡就夠了(roomRepeat 讀得到) if (speakerRole !== "guest") pl.recordSaid(speaker, text, { room, kind: "room" }); 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)`); }; /** * 匯出人格成單一 bundle 檔(可 gzip)。 * 只能匯出「本 session 目前載入的人格」——否則就成了跨人格資料外洩的後門。 */ commands.export = ({ flags }) => { const session = requireSession(flags); const slug = hostOf(flags, session); requireOwner(slug, session); const gzip = Boolean(flags.gzip); const stamp = pl.nowIso().replace(/[-:TZ]/g, "").slice(0, 14); const out = path.resolve(str(flags.out) || `${slug}-${stamp}.persona.json${gzip ? ".gz" : ""}`); if (fs.existsSync(out) && !flags.force) die(`${out} 已存在。要覆寫請加 --force。`); const { bundle, skipped } = pl.exportBundle(slug, { withJournal: Boolean(flags["with-journal"]) }); const json = JSON.stringify(bundle, null, gzip ? 0 : 2); fs.mkdirSync(path.dirname(out), { recursive: true }); fs.writeFileSync(out, gzip ? zlib.gzipSync(Buffer.from(json, "utf8")) : json, gzip ? undefined : "utf8"); const size = fs.statSync(out).size; emit({ persona: slug, file: out, bytes: size, stats: bundle.stats, checksum: bundle.checksum, skipped }, flags.json, [ `✔ 人格 \`${slug}\` 已匯出到 ${out}(${(size / 1024).toFixed(1)} KB${gzip ? ",gzip" : ""})。`, ` 內容:${bundle.stats.files} 個檔案|長期記憶 ${bundle.stats.long_term}|短期 ${bundle.stats.short_term}` + `|關係節點 ${bundle.stats.relations}|journal ${bundle.stats.with_journal ? "含" : "不含(要帶請加 --with-journal)"}`, " 不含載入鎖與 guest 租約(那是執行期狀態)。匯入:`persona.mjs import --file <檔案> --session `", ]); }; /** 從 bundle 匯入人格。可用 `--persona <新 slug>` 換名匯入(同一個人格要並存兩份時很有用)。 */ commands.import = ({ flags }) => { const session = requireSession(flags); const file = str(flags.file); if (!file) die("需要 `--file `。"); let buf; try { buf = fs.readFileSync(path.resolve(file)); } catch (err) { die(`讀不到 ${file}:${err.message}`); } if (buf[0] === 0x1f && buf[1] === 0x8b) { try { buf = zlib.gunzipSync(buf); } catch (err) { die(`gzip 解壓失敗:${err.message}`); } } let bundle; try { bundle = JSON.parse(buf.toString("utf8")); } catch (err) { die(`不是合法的 bundle JSON:${err.message}`); } const { ok: valid, problems, checksumOk } = pl.validateBundle(bundle); if (!valid) die(`bundle 不合法:${problems.join(";")}`); if (!checksumOk && !flags.force) die("checksum 不符(檔案可能損毀或被改過)。確定要匯入請加 --force。"); const target = str(flags.persona) || bundle.persona; if (!pl.validSlug(target)) die(`slug \`${target}\` 不合法。請用 \`--persona <新 slug>\` 指定。`); const data = pl.loadSession(session); const exists = pl.personaExists(target); if (exists && !flags.force) { die(`人格 \`${target}\` 已存在。換名匯入請加 \`--persona <新 slug>\`,覆寫請加 --force。`); } if (exists && data.host && data.host !== target) { die(`本 session 載入的是 \`${data.host}\`,不得覆寫另一個既有人格 \`${target}\`;請先 release 再匯入。`); } const lock = pl.readJson(pl.lockPath(target)) ?? {}; if (Object.keys(lock).length && lock.session_id !== session && !pl.lockIsDead(lock)) { die( `人格 \`${target}\` 正被另一個程序載入(session ${String(lock.session_id).slice(0, 8)}…,cwd ${lock.cwd}),` + "不能覆寫它的資料。", ); } let result; try { result = pl.importBundle(bundle, target, { session }); } catch (err) { die(err.message); } const lines = [ `✔ 人格 \`${result.persona}\` 已匯入(${result.written.length} 個檔案${exists ? ",覆寫既有資料" : ""})。`, ` 來源:\`${bundle.persona}\`|匯出於 ${bundle.exported_at || "?"}|${pl.identityBrief(result.persona) || "(無身分欄位)"}`, ]; if (result.rejected.length) lines.push(` ⚠ 略過 ${result.rejected.length} 個路徑不合法的項目:${result.rejected.slice(0, 3).join(", ")}`); if (!checksumOk) lines.push(" ⚠ checksum 不符(--force 略過):內容可能被改過,請自行確認。"); if (flags.load) { if (data.host && data.host !== result.persona) { lines.push(` ⚠ 本 session 已載入 \`${data.host}\`,未自動載入;要用它請先 release。`); } else { try { pl.acquireLock(result.persona, session, { cwd: str(flags.cwd) || null }); pl.bindHost(session, result.persona, { cwd: str(flags.cwd) || null }); lines.push(` 已載入 \`${result.persona}\`,可以直接開始聊。`); } catch (err) { lines.push(` ⚠ 自動載入失敗:${err.message}`); } } } else { lines.push(` 下一步:/jsc-persona:persona-chat ${result.persona}`); } emit({ ...result, source: bundle.persona, checksum_ok: checksumOk }, flags.json, lines); }; /** * 人格編號:英文名全大寫 + 兩位索引(同名才遞增),也就是 Gitea 存取庫的名稱。 * 既有人格用 `code assign --romaji <英文名>` 補編號,加 `--rename` 連目錄名一起改成編號。 */ commands.code = async ({ flags, positional }) => { const session = requireSession(flags); const action = positional[0] || "show"; if (action === "next") { const base = gt.normalizeRomaji(str(flags.romaji)); if (!base) die("需要 `--romaji <英文名>`(只能是拉丁字母與數字)。"); const next = gt.nextCode(base); if (!next) die(`\`${base}\` 的編號已經用到 99。`); emit({ romaji: base, code: next }, flags.json, [`\`${base}\` 的下一個可用編號:\`${next}\``]); return; } const slug = hostOf(flags, session); if (action === "show") { requireMember(slug, session, Boolean(flags["as-guest"])); const code = gt.personaCode(slug); const state = gt.loadSyncState(slug); emit({ persona: slug, code, sync: state }, flags.json, [ `人格 \`${slug}\` 編號:${code ? `\`${code}\`` : "(尚未指派,用 `code assign --romaji <英文名>`)"}`, code ? ` Gitea 存取庫:${state.repo_url || `${gt.giteaEnv().host}/${state.owner || "?"}/${code}`}` : "", ].filter(Boolean)); return; } if (action === "assign") { requireOwner(slug, session); const existing = gt.personaCode(slug); if (existing && !flags.force) die(`人格 \`${slug}\` 已有編號 \`${existing}\`。要重新指派請加 --force。`); let code = str(flags.code); if (code && !gt.validCode(code)) die(`編號 \`${code}\` 不合法(格式:ASUNA-01)。`); if (!code) { const base = gt.normalizeRomaji(str(flags.romaji) || slug); if (!base) die("需要 `--romaji <英文名>`(中文名請先轉羅馬拼音並跟使用者確認拼法)。"); code = gt.nextCode(base); if (!code) die(`\`${base}\` 的編號已經用到 99。`); } const config = pl.loadConfig(slug); config.persona = slug; config.code = code; config.romaji = gt.codePrefix(code); config.schema = 2; pl.writeJson(pl.configPath(slug), config); const lines = [`✔ 人格 \`${slug}\` 的編號指派為 \`${code}\`。`]; let current = slug; if (flags.rename && slug !== code) { if (pl.personaExists(code)) die(`目錄 \`${code}\` 已存在,無法改名。`); // 目錄名改成編號:先放掉自己的鎖 → 改名 → 重新取得鎖並重綁 session pl.releaseLock(slug, session); fs.renameSync(pl.personaDir(slug), pl.personaDir(code)); fs.rmSync(path.join(pl.personaDir(code), gt.SYNC_DIRNAME), { recursive: true, force: true }); const renamed = pl.loadConfig(code); renamed.persona = code; pl.writeJson(pl.configPath(code), renamed); pl.acquireLock(code, session, { cwd: str(flags.cwd) || null }); pl.bindHost(session, code, { cwd: str(flags.cwd) || null }); current = code; lines.push(` 目錄已改名:${pl.personaDir(code)}(.sync 快取已清掉,下次 push 會重新 clone)`); } if (!flags["no-gitea"] && !gt.giteaProblem()) { try { const info = await gt.initRemote(current, { code, private_: !flags.public }); lines.push(` 📦 Gitea:${info.repo.html_url}(${info.created ? "已建立" : "沿用既有"})`); } catch (err) { lines.push(` ⚠ Gitea 存取庫建立失敗(不影響本機):${err.message}`); } } emit({ persona: current, code }, flags.json, lines); return; } die(`未知 action:${action}(可用 show/assign/next)`); }; /** * 由人格資料產生圖示(SVG + PNG)。**建立人格並補齊 IDENTITY/SOUL 之後再跑**, * 這樣配色與字母才會對得上最終的身分。同一個人格永遠得到同一張圖。 */ commands.icon = async ({ flags, positional }) => { const session = requireSession(flags); const slug = hostOf(flags, session); const action = positional[0] || "generate"; if (action === "show") { requireMember(slug, session, Boolean(flags["as-guest"])); const spec = ic.iconSpec(slug); const src = spec.source || {}; emit({ persona: slug, spec, exists: ic.hasIcon(slug) }, flags.json, [ `人格 \`${slug}\` 圖示:${ic.hasIcon(slug) ? "✔ 已產生" : "✘ 尚未產生(跑 `icon generate`)"}`, ` 字母 ${spec.letters}|配色 ${spec.palette ? "取自參考照片" : "由編號雜湊"}` + `:${JSON.stringify(spec.c1)} → ${JSON.stringify(spec.c2)}|seed ${spec.seed}`, ...(spec.palette ? [` 調色盤:${ic.paletteToString(spec.palette)}`] : []), ...(spec.style === "portrait" ? [` 特徵:${ic.featuresToString(spec.features)}`] : []), ...(src.url ? [` 參考來源:${src.url}${src.note ? `(${src.note})` : ""}${src.date ? `|${src.date}` : ""}`] : []), ` ${ic.iconSvgPath(slug)}`, ` ${ic.iconPngPath(slug)}`, ]); return; } if (action === "search") { // 找圖第一步:從 Fandom wiki 撈這個角色的官方圖,依「解析度 + 是不是官方設定稿」排序 requireMember(slug, session, Boolean(flags["as-guest"])); const wiki = str(flags.wiki); const page = str(flags.page); if (!wiki || !page) die("需要 `--wiki ` 與 `--page <角色頁名>`(例:--wiki swordartonline --page Yui)。"); let rows; try { rows = await ic.wikiImageCandidates(wiki, page, { limit: num(flags.limit, 60) }); } catch (err) { die(err.message); } const top = rows.slice(0, num(flags.top, 12)); emit({ wiki, page, candidates: top }, flags.json, [ `\`${page}\` 在 ${wiki}.fandom.com 的圖片候選(依解析度與是否官方設定稿排序):`, ...top.map((r, i) => ` #${i} ${String(r.width).padStart(5)}×${String(r.height).padEnd(5)}` + `${r.official_sheet ? " 📐官方設定稿" : " "} ${r.title}\n ${r.url}`), " 官方設定稿(Full Body/Character Design)通常是透明底或白底,去背幾乎免費,優先選它。", " 選好之後:`icon measure --photo <網址>` 看臉夠不夠大,再 `icon cutout --photo <網址>`。", ]); return; } if (action === "measure") { requireMember(slug, session, Boolean(flags["as-guest"])); const want = str(flags.photo); if (!want) die("需要 `--photo <圖片路徑或網址>`。"); let file; try { file = await ic.fetchPhoto(want, path.join(pl.personaDir(slug), ".sync")); } catch (err) { die(err.message); } const res = ic.measureImage(file); if (!res.ok) { emit(res, flags.json, [`✖ 量測失敗:${res.reason || "工具不足"}`, ...ic.installHintLines(res.report || ic.toolReport())]); process.exit(1); } emit(res, flags.json, [ `解析度 ${res.size.join("×")}(${(res.pixels / 1e6).toFixed(2)} MP)|找到 ${res.faces_found} 張臉` + `|臉佔長邊 ${(res.face_ratio * 100).toFixed(0)}%`, `背景:${res.transparent ? "透明底(最佳)" : res.background.plain ? "單色底(好去背)" : "有場景(要靠 GrabCut,可能不乾淨)"}` + `|去背難度:${res.cutout_easy ? "容易" : "偏難"}`, res.cutout_easy ? " → 這張可以用。`icon cutout --photo <同一張>`" : " → 建議換一張官方設定稿(`icon search` 裡標 📐 的),去背會乾淨很多。", ]); return; } if (action === "cutout") { requireOwner(slug, session); const want = str(flags.photo); if (!want) die("需要 `--photo <圖片路徑或網址>`。"); let file; try { file = await ic.fetchPhoto(want, path.join(pl.personaDir(slug), ".sync")); } catch (err) { die(err.message); } const out = ic.cutoutPath(slug); fs.mkdirSync(path.dirname(out), { recursive: true }); const res = ic.cutoutImage(file, out, { pick: str(flags.pick) || null, face: str(flags.face) || null, }); if (!res.ok) { emit(res, flags.json, [`✖ 去背失敗:${res.reason || "工具不足"}`, ...ic.installHintLines(res.report || ic.toolReport())]); process.exit(1); } const method = { "source-alpha": "原圖本來就是透明底", "plain-background": "單色底去除", grabcut: "GrabCut(有場景,邊緣可能不完美)" }[res.method] || res.method; emit({ persona: slug, ...res }, flags.json, [ `✔ 去背完成:${out}`, ` 方式:${method}|原圖 ${res.source_size.join("×")} → 去背後 ${res.output_size.join("×")}` + `|不透明佔比 ${(res.opaque_ratio * 100).toFixed(0)}%`, " 請用 Read 打開確認邊緣乾不乾淨,再 `icon generate --from-cutout --force`。", ]); return; } if (action === "headshot") { // 裁出「參考用大頭照」。這張不是圖示,也不會同步出去——它是給 AI 看的底稿。 requireOwner(slug, session); const want = str(flags.photo); if (!want) die("需要 `--photo <圖片路徑或網址>`(該人格最新登場的官方視覺)。"); let file; try { file = await ic.fetchPhoto(want, path.join(pl.personaDir(slug), ".sync")); } catch (err) { die(err.message); } const out = path.join(pl.personaDir(slug), ".sync", "headshot.png"); const cropped = ic.cropHeadshot(file, out, num(flags.size, 384), { pick: str(flags.pick) || null, face: str(flags.face) || null, }); if (!cropped.ok) { emit(cropped, flags.json, [ `✖ 裁不出大頭照:${cropped.reason || "工具不足"}`, ...ic.installHintLines(cropped.report || ic.toolReport()), ]); process.exit(1); } emit({ persona: slug, file: out, ...cropped.info }, flags.json, [ `✔ 大頭照已裁出:${out}`, ` 偵測方式 ${cropped.info.method}|原圖 ${cropped.info.source_size.join("×")}|` + `找到 ${cropped.info.faces_found} 張臉|裁切框 ${JSON.stringify(cropped.info.box)}`, " ⚠ 這張是**參考底稿**,不是圖示,也不會同步到 Gitea。", " 請用 Read 打開它,確認是本人,再依看到的髮型/眼型/配件下 `icon generate --features ...`。", ]); return; } if (action === "faces") { requireMember(slug, session, Boolean(flags["as-guest"])); const want = str(flags.photo); if (!want) die("需要 `--photo <圖片路徑或網址>`。"); let file; try { file = await ic.fetchPhoto(want, path.join(pl.personaDir(slug), ".sync")); } catch (err) { die(err.message); } const found = ic.listFaces(file); if (!found.ok) { emit(found, flags.json, [ `\u2716 \u7121\u6cd5\u5075\u6e2c\u81c9\uff1a${found.reason || "\u5de5\u5177\u4e0d\u8db3"}`, ...ic.installHintLines(found.report || ic.toolReport()), ]); return; } emit(found, flags.json, [ `\u53c3\u8003\u7167\u7247 ${found.size[0]}\u00d7${found.size[1]}\uff5c\u5075\u6e2c\u65b9\u5f0f ${found.method || "\uff08\u7121\uff09"}\uff5c\u627e\u5230 ${found.faces.length} \u5f35\u81c9\uff1a`, ...found.faces.map((f) => ` #${f.index} ${f.w}\u00d7${f.h} @(${f.x},${f.y})\u3000\u4e2d\u5fc3 ${f.center.join(",")}`), " \u591a\u89d2\u8272\u7684\u5716\u8acb\u5148\u770b\u904e\u539f\u5716\u518d\u6311\uff1a`icon generate --photo <\u5716> --pick <\u7d22\u5f15>`\uff08\u6216 `--face x,y,w,h`\uff09\u3002", ]); return; } if (action !== "generate") die(`未知 action:${action}(可用 search/measure/faces/headshot/cutout/generate/show)`); requireOwner(slug, session); if (ic.hasIcon(slug) && !flags.force) { die(`人格 \`${slug}\` 已經有圖示了。改過身分或換了參考照片要重畫請加 --force。`); } const size = num(flags.size, ic.DEFAULT_SIZE); if (!Number.isFinite(size) || size < 16 || size > 2048) die("--size 只能是 16–2048。"); let palette = null; if (flags.palette) { palette = ic.parsePalette(str(flags.palette)); if (!palette) { die( "`--palette` 格式錯誤。要 `hair=#rrggbb,eye=#rrggbb,accent=#rrggbb,secondary=#rrggbb,light=#rrggbb`," + "其中 hair 與 accent 必填(顏色請取自你實際看過的參考照片)。", ); } if (!str(flags["source-url"])) { die("用 `--palette` 就必須帶 `--source-url`:配色是從哪張圖取的要留得下來(可查證)。"); } } const source = str(flags["source-url"]) ? { url: str(flags["source-url"]), note: str(flags["source-note"]) || null, date: str(flags["source-date"]) || pl.nowIso().slice(0, 10), } : null; const style = str(flags.style) || null; if (style && !ic.STYLES.includes(style)) die(`--style 只能是 ${ic.STYLES.join("/")}。`); const features = flags.features ? ic.parseFeatures(str(flags.features)) : null; // --from-cutout:用去背好的官方原圖合成(解析度高、忠於原作) const cutout = flags["from-cutout"] && fs.existsSync(ic.cutoutPath(slug)) ? ic.cutoutPath(slug) : null; if (flags["from-cutout"] && !cutout) { die(`還沒有去背圖。先跑 \`icon search\` 找官方設定稿 → \`icon cutout --photo <網址>\`。`); } const res = ic.generateIcon(slug, { size, palette, source, style, features, cutout, pick: str(flags.pick) || null, face: str(flags.face) || null, zoom: num(flags.zoom, 2.15), }); const lines = [ `✔ 人格 \`${slug}\` 的圖示已產生(${size}×${size})。`, ` ${res.svg}(${(res.bytes.svg / 1024).toFixed(1)} KB)`, ` ${res.png}(${(res.bytes.png / 1024).toFixed(1)} KB)`, ` icon/:${res.renders.map((r) => `${r.name} ${(r.bytes / 1024).toFixed(0)}KB`).join("、")}`, res.spec.style === "cutout" ? ` 形象圖:官方原圖去背後合成(頭肩構圖,解析度取自原圖)` : res.spec.style === "portrait" ? ` 形象圖:依人格資料重新繪製的人物頭像(有臉)` : ` 徽章:字母 ${res.spec.letters}|配色由編號 \`${res.spec.code}\`、名字與 emoji 決定`, ...(res.spec.style === "portrait" ? [` 配色:${ic.paletteToString(res.spec.palette)}`, ` 特徵:${ic.featuresToString(res.spec.features)}`] : res.spec.style === "cutout" && res.spec.palette ? [` 底色:取自 ${ic.paletteToString(res.spec.palette)}`] : []), ...(source?.url || res.spec.source?.url ? [` 參考來源:${(source || res.spec.source).url}${(source || res.spec.source).note ? `\n ${(source || res.spec.source).note}` : ""}`] : []), ]; // 圖示屬於低頻的身分資料 → Wiki 區;順便設成 Gitea 存取庫的頭像 if (!flags["no-gitea"] && !gt.giteaProblem() && gt.personaCode(slug)) { try { const owner = await gt.resolveOwner(); const okAvatar = await gt.setRepoAvatar(owner, gt.personaCode(slug), fs.readFileSync(res.png)); if (okAvatar) lines.push(" 📦 已設為 Gitea 存取庫頭像。"); const pushed = await gt.pushArea(slug, "wiki", { message: `icon: 重繪人格形象圖 ${res.spec.code}` }); if (pushed.ok && pushed.changed) lines.push(" ↑ 形象圖已同步到 Wiki 區。"); // 推完一定要回頭確認 Wiki 真的有這兩個檔案,且與本機一致 const check = await gt.verifyIconInWiki(slug); lines.push( check.ok ? ` ✔ Wiki 已保存形象圖並與本機一致(${check.icon_present.join(" + ")})` : ` ✖ Wiki 形象圖驗證未通過:${check.reason || `缺少或未同步 ${(check.icon_pending || []).join(", ") || "?"}`}` + " → 跑 `sync push --area wiki` 再 `sync verify --area wiki`。", ); } catch (err) { lines.push(` ⚠ 同步到 Gitea 失敗(不影響本機):${err.message.slice(0, 120)}`); } } emit({ persona: slug, ...res }, flags.json, lines); }; /** 人格與 Gitea 的同步:檔案區=高頻活狀態,Wiki 區=低頻設定。 */ commands.sync = async ({ flags, positional }) => { const session = requireSession(flags); const slug = hostOf(flags, session); requireOwner(slug, session); const action = positional[0] || "status"; const area = str(flags.area) || "all"; if (area !== "all" && !gt.AREA_KEYS.includes(area)) die(`--area 只能是 ${gt.AREA_KEYS.join("/")}/all。`); const areas = area === "all" ? gt.AREA_KEYS : [area]; if (action === "status") { const state = gt.loadSyncState(slug); const problem = gt.giteaProblem(); emit({ persona: slug, code: gt.personaCode(slug), problem, sync: state }, flags.json, [ `人格 \`${slug}\`|編號 ${gt.personaCode(slug) || "(無)"}|Gitea ${problem ? `⚠ ${problem}` : "✔ 已設定"}`, ` 存取庫:${state.repo_url || "(尚未建立,跑 \`sync init\`)"}`, ...gt.AREA_KEYS.map((key) => { const info = state.areas[key] || {}; return ` ${gt.AREAS[key].label}(${gt.AREAS[key].why}):` + `最後 push ${info.pushed_at || "—"}|最後 pull ${info.pulled_at || "—"}|${info.files ?? "?"} 個檔案`; }), ]); return; } const problem = gt.giteaProblem(); if (problem) die(`Gitea 尚未設定:${problem}(設 GITEA_HOST 與 GITEA_TOKEN,或用 PERSONA_GITEA_* 覆寫)`); if (action === "init") { let code = gt.personaCode(slug); if (!code) die(`人格 \`${slug}\` 還沒有編號。先跑 \`code assign --romaji <英文名>\`。`); const info = await gt.initRemote(slug, { code, owner: str(flags.owner) || null, private_: !flags.public }); emit(info, flags.json, [ `✔ 人格 \`${slug}\`(\`${info.code}\`)已對應到 Gitea:${info.repo.html_url}`, ` ${info.created ? "存取庫已建立" : "沿用既有存取庫"}|${info.repo.private ? "私有" : "公開"}|Wiki ${info.wikiCreated ? "已建立" : "已存在"}`, ...gt.AREA_KEYS.map((key) => ` ${gt.AREAS[key].label}:${info.results[key]?.ok ? `${info.results[key].files} 個檔案已推送` : `⚠ ${info.results[key]?.reason || "失敗"}`}`), ]); return; } if (action === "push") { const out = []; for (const key of areas) { if (flags["if-due"] && !gt.pushDue(slug, key)) { out.push({ area: key, ok: true, skipped: true, reason: "未到最小間隔" }); continue; } try { out.push(await gt.pushArea(slug, key, { message: str(flags.message) })); } catch (err) { out.push({ area: key, ok: false, reason: err.message }); } } emit({ persona: slug, results: out }, flags.json, out.map((r) => r.skipped ? ` ${gt.AREAS[r.area].label}:略過(${r.reason})` : r.ok ? `✔ ${gt.AREAS[r.area].label}:${r.changed ? `已推送 ${r.files} 個檔案` : "沒有變更"}` : `✖ ${gt.AREAS[r.area].label}:${String(r.reason).slice(0, 160)}`)); return; } if (action === "verify") { const out = []; for (const key of areas) { try { out.push(await gt.verifyArea(slug, key)); } catch (err) { out.push({ area: key, ok: false, reason: err.message }); } } const bad = out.filter((r) => !r.ok && !r.skipped); emit({ persona: slug, results: out, ok: bad.length === 0 }, flags.json, out.map((r) => r.skipped ? ` ${gt.AREAS[r.area].label}:略過(${r.reason})` : r.ok ? `✔ ${gt.AREAS[r.area].label}:本機與 Gitea 一致(${r.files} 個檔案)` : `✖ ${gt.AREAS[r.area].label}:不一致` + (r.pending?.length ? `,還沒推上去的檔案:${r.pending.slice(0, 8).join(", ")}` : "") + (r.reason ? `(${r.reason})` : ""))); if (bad.length) process.exit(1); return; } if (action === "pull") { const out = []; for (const key of areas) { try { out.push(await gt.pullArea(slug, key, { force: Boolean(flags.force) })); } catch (err) { out.push({ area: key, ok: false, reason: err.message }); } } pl.rebuildIndex(slug); emit({ persona: slug, results: out }, flags.json, out.map((r) => r.conflicts?.length ? `✖ ${gt.AREAS[r.area].label}:本機與遠端都改過 ${r.conflicts.slice(0, 5).join(", ")};` + "沒有覆蓋本機。確定要以遠端為準才加 --force。" : r.ok ? `✔ ${gt.AREAS[r.area].label}:` + (r.empty ? "遠端還是空的" : `遠端有 ${r.changed?.length || 0} 個檔案更新,寫回本機 ${r.written?.length || 0} 個` + `${(r.written?.length || 0) > (r.changed?.length || 0) ? "(含補回本機缺少的檔案)" : ""}`) : `✖ ${gt.AREAS[r.area].label}:${String(r.reason).slice(0, 160)}`)); return; } die(`未知 action:${action}(可用 init/push/pull/status/verify)`); }; commands.gc = ({ flags }) => { const removed = pl.gcRuntime(); emit(removed, flags.json, [ `✔ 清理完成:sessions=${removed.sessions.length}, 死鎖=${removed.locks.join(",") || "無"}, ` + `guest 租約=${removed.guests.join(",") || "無"}`, ]); }; commands.guard = () => { // 給 hook 用:從 stdin 讀 hook event,輸出 allow/deny。也可手動測試。 let raw = ""; try { raw = fs.readFileSync(0, "utf8"); } catch { raw = ""; } let event; try { event = JSON.parse(raw); } catch { die("stdin 不是合法 JSON"); } process.stdout.write(`${JSON.stringify(pl.guardDecide(event), null, 2)}\n`); }; const HELP = `persona.mjs — jsc-persona 人格 / 記憶 / 情緒 / 關係圖 CLI 用法:node persona.mjs [options] 人格與鎖: create --romaji <英文名> --session [--persona <目錄名> --code --name --creature --vibe --emoji --avatar --baseline --origin --work --no-gitea --public] 人格編號 = 英文名全大寫 + 兩位索引(同名才遞增),也是 Gitea 存取庫的名稱; 不指定 --persona 就用編號當目錄名。 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] think --session --text <心裡話> [--kind infer|plan|feel|doubt --room] 只回報「心想 N 句」,不回顯內容 said check|list --session [--text <要說的話> --minutes --threshold --record --limit] sleep --session [--persona ] [--personas ] [--json] [--release] [--no-gitea] 睡眠收尾(機械性):關係時間戳→裁短期→收思維導圖→情緒衰減 8 小時→重建索引 →修剪 said→壓縮舊 journal→寫 sleep.json→Gitea 兩區 push+驗證。 預設保留載入鎖(--release 才收工)。--json 只回報「睡完了沒、哪一步出錯」。 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 --bond --closeness --trust --note --tags --from --to --label --affinity] relation style --session --name [--facet 稱呼 --value 親愛的 --except anger>=40 --since --clear] relation speaker --session --name | --clear (使用者在關係圖裡是誰 → 決定語氣層) 多人格對話: invite --session --guest [--guests ] [--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] (post 會擋下「短時間內近似重複」與超過三句的發言;例外用 --allow-repeat / --force) 圖示(建立人格並補齊資料後跑): icon search|measure|faces|headshot|cutout|generate|show --session search --wiki --page <角色頁> 找官方圖,依解析度/是否設定稿排序 measure --photo <網址或路徑> 解析度、臉多大、背景好不好去 faces --photo <圖片路徑或網址> 列出圖裡偵測到的臉(多角色務必先看) headshot --photo <...> [--pick <索引>|--face x,y,w,h] [--size 384] 裁出**參考用大頭照**到 .sync/headshot.png(不是圖示、不同步) cutout --photo <...> [--pick <索引>] 去背 → icon/portrait-cutout.png generate [--size 512 --force --no-gitea --style portrait|badge] [--palette "hair=#..,eye=#..,accent=#..,secondary=#..,light=#..,skin=#.."] [--features "hairstyle=..,length=..,fringe=..,eyes=..,expression=..,accessory=..,side=..,collar=..,ahoge=.."] [--source-url <來源網址> --source-note <說明> --source-date ] [--from-cutout] [--zoom 2.15] [--pick <索引>] 帶 --from-cutout → 用去背好的官方原圖合成(頭肩構圖,最忠於原作)。 否則依人格資料重新繪製;沒帶 --palette → 徽章樣式。 show 看目前的樣式、配色、特徵與來源 產出 icon.svg + icon.png,設為 Gitea 存取庫頭像並同步到 Wiki 區。 編號與 Gitea(存取庫名稱 = 人格編號): code show|assign|next --session [--romaji <英文名> --code --rename --force --public] sync status|init|push|pull|verify --session [--area files|wiki|all --if-due --force --message --owner] verify 會確認「本機 = Gitea」,不一致就以非零結束(形象圖必須同步) 檔案區=高頻活狀態(情緒/短期記憶/心裡話/逐字),每輪對話後背景 push Wiki 區=低頻設定(IDENTITY/SOUL/長期記憶/心智圖/關係圖),固化或改身分時 push 環境變數:GITEA_HOST / GITEA_TOKEN(或 PERSONA_GITEA_HOST / _TOKEN / _OWNER), PERSONA_GITEA=off 可整個關掉,PERSONA_SYNC_MIN_SECONDS 調 push 間隔 搬家(離線檔案): export --session [--out <檔案> --with-journal --gzip --force] 匯出目前載入的人格 import --session --file <檔案> [--persona <新 slug> --force --load] 維護: gc 清理死鎖與過期租約 guard (內部)從 stdin 讀 hook event 測試隔離判斷 全域旗標:--json(機器可讀)、--quiet(成功時不輸出;劇場模式必用) --as-sleeper(睡眠 sub agent 專用:以 sleeper 租約代替 exclusive 鎖,hook 會驗身分) `; async function main(argv) { const sub = argv[0]; if (!sub || sub === "--help" || sub === "-h" || sub === "help") { process.stdout.write(HELP); return 0; } const command = commands[sub]; if (!command) die(`未知子指令 \`${sub}\`。用 \`node persona.mjs --help\` 看清單。`); const parsed = parseArgs(argv.slice(1)); QUIET = Boolean(parsed.flags.quiet); CURRENT_FLAGS = parsed.flags; try { await command({ flags: parsed.flags, positional: parsed._ }); } catch (err) { if (err instanceof pl.LockError) die(err.message); throw err; } return 0; } process.exit(await main(process.argv.slice(2)));