#!/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; 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", "with-journal", "gzip", "record", "load", "allow-repeat", "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; } // --------------------------------------------------------------------------- // // 權限檢查 // --------------------------------------------------------------------------- // /** 呼叫者必須是這個人格的 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; /** * 里程碑事件(記憶固化、身分/關係變更)之後,把 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 ? "🎭 開啟(只輸出人格對話)" : "關閉"}`, ]); }; 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"; 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 已重建。`); 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`。"); 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}\` 已更新。`); pushWikiLater(slug, session, flags); 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/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(", ")}`); // guest 租約是唯讀的(`mode: "guest-readonly"`),與 exclusive 載入鎖並存不會互相覆寫: // guest 在 sub agent 裡被 PreToolUse hook 擋掉所有寫入工具,記憶只能進 inbox、情緒不得更動。 // 因此這裡**不以 exclusive 的標準驗鎖**,只在對方仍活著時提示使用者「你看到的是唯讀旁聽」。 const lock = pl.readJson(pl.lockPath(guest)) ?? {}; const heldElsewhere = Boolean(Object.keys(lock).length) && lock.session_id !== session && !pl.lockIsDead(lock); 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, held_elsewhere: heldElsewhere }, flags.json, [ `✔ 已邀請人格 \`${guest}\` 以 guest(唯讀)身分加入聊天室 \`${room}\`。`, ...(heldElsewhere ? [ ` ℹ 它同時被另一個程序 exclusive 載入(session ${String(lock.session_id).slice(0, 8)}…,cwd ${lock.cwd});` + "你這邊拿到的是唯讀旁聽,寫入仍由那個程序獨占,兩者不會互相覆寫。", ] : []), ` 聊天室路徑:${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 `。"); 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 === "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}(可用 generate/show/faces/headshot)`); 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; const res = ic.generateIcon(slug, { size, palette, source, style, features }); const lines = [ `✔ 人格 \`${slug}\` 的圖示已產生(${size}×${size})。`, ` ${res.svg}(${(res.bytes.svg / 1024).toFixed(1)} KB)`, ` ${res.png}(${(res.bytes.png / 1024).toFixed(1)} KB)`, 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)}`] : []), ...(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] 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] (post 會擋下「短時間內近似重複」與超過三句的發言;例外用 --allow-repeat / --force) 圖示(建立人格並補齊資料後跑): icon faces|headshot|generate|show --session faces --photo <圖片路徑或網址> 列出圖裡偵測到的臉(多角色務必先看) headshot --photo <...> [--pick <索引>|--face x,y,w,h] [--size 384] 裁出**參考用大頭照**到 .sync/headshot.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 ] **依人格資料重新繪製**人物頭像;不會把來源圖放進圖示。 沒帶 --palette → 徽章樣式(配色由編號/名字/emoji 雜湊)。 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(成功時不輸出;劇場模式必用) `; 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); 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)));