#!/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` 先過 `requireSleeperPin()`: * 只有本 session 真的有一個 pin 在目標人格上的 persona-sleeper sub agent 才拿得到租約。 */ /** * `--as-sleeper` 的身分驗證——**CLI 自己驗,不外包給 hook**。 * * 這個旗標的意思是「我是 persona-sleeper 型的 sub agent,正在替 收尾」。 * 誰是 sub agent、是哪一型,只有 PreToolUse hook 看得到(`event.agent_type`), * 而 hook 認得出這支 CLI 靠的是檔名正則——把 `persona.mjs` 複製出去改個名字, * hook 就完全不表態,`--as-sleeper` 等於對任意人格的完整讀寫權。 * * 所以唯一可信的證據是 hook 在 session 檔(`.runtime/sessions/.json` 的 `pins`) * 裡留下的 sleeper pin:它由 hook 依 `agent_type` 寫入,沒經過 hook 的程序拿不到。 * 這裡照那份資料驗:本 session 要有一個 pin 在目標人格上的 sleeper,驗不過就 die。 */ function requireSleeperPin(slug, sessionId) { const pins = pl.sleeperPins(sessionId, slug); if (!pins.length) { const others = pl.sleeperPins(sessionId).map((p) => p.persona); die( `\`--as-sleeper\` 不成立:本 session 沒有 pin 在 \`${slug}\` 上的 persona-sleeper sub agent` + `${others.length ? `(目前有 pin 的是 ${JSON.stringify([...new Set(others)])})` : ""}。` + "pin 由 PreToolUse hook 依 sub agent 的型別寫入,冒不得;" + "要請人格收尾請走 /jsc-persona:persona-sleep,它會替那個人格開一個 sleeper。", ); } const agentId = str(CURRENT_FLAGS["agent-id"]); if (agentId && !pins.some((p) => p.agent_id === agentId)) { die(`\`--agent-id ${agentId}\` 不是本 session 中 pin 在 \`${slug}\` 上的睡眠 sub agent。`); } return pins[0]; } function sleeperAccess(slug, sessionId, agentId) { requireSleeperPin(slug, sessionId); 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; } /** * `--tells "anger=不講話;句子只剩動詞|羞愧=摸後頸"` → IDENTITY.md 的 `## Tells` 條目。 * * 情緒之間用 `|` 分,同一種情緒的多條破口用 `;` 或 `;` 分。 * 沒帶就回空字串:空的區塊等於「這個人格沿用全域預設」,樣板裡的註解會教下一個人怎麼補。 */ function renderTells(raw) { const src = String(raw || "").trim(); if (!src) return ""; const lines = []; for (const group of src.split("|")) { const idx = group.search(/[=::]/); if (idx < 0) continue; const key = group.slice(0, idx).trim(); const tells = group.slice(idx + 1).split(/[;;、,,]/).map((s) => s.trim()).filter(Boolean).slice(0, 5); if (!key || !tells.length) continue; lines.push(`- ${pl.injectSafeLine(key, 12)}: ${tells.map((t) => pl.injectSafeLine(t, 40)).join(";")}`); } return lines.join("\n"); } // --------------------------------------------------------------------------- // // 子指令 // --------------------------------------------------------------------------- // const commands = {}; commands.create = async ({ flags }) => { const session = requireSession(flags); // 人格編號 = 英文名全大寫 + 兩位索引(同名才遞增)。編號就是 Gitea 存取庫的名稱。 let code = str(flags.code); let codeNote = ""; 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`)。" + "中文名請先轉成羅馬拼音並跟使用者確認拼法,再帶進來。", ); } // 發號前先問遠端:`nextCode` 只掃本機,換一台機器就會把同一個號再發一次 const next = flags["no-gitea"] ? { code: gt.nextCode(base), checked_remote: false, reason: "--no-gitea" } : await gt.nextCodeAcrossMachines(base, { owner: str(flags.owner) || null }); code = next.code; if (!code) die(`\`${base}\` 的編號已經用到 99,請換一個英文名。`); if (!next.checked_remote) codeNote = ` ⚠ 編號只對過本機,沒對過 Gitea(${next.reason})——別台機器可能已經用掉這個號。`; } // 沒指定 --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), GENDER: str(flags.gender) || "未指定", VIBE: str(flags.vibe), EMOJI: str(flags.emoji), AVATAR: str(flags.avatar), // 情緒破口:`--tells "anger=不講話;把事情做完再說|羞愧=摸後頸"`。 // 沒帶就留空——空的區塊會退回全域預設,而樣板裡的註解會告訴下一個人怎麼補。 TELLS: renderTells(str(flags.tells)), 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。`); if (codeNote) say(codeNote); 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 { const detail = await fn(); // 有細節就帶回去(例如裁掉幾筆短期記憶)——不做無聲的裁切 steps.push(detail && typeof detail === "object" ? { step, ok: true, detail } : { 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", () => { // 真的講到話的人 → 蓋上「最後一次接觸」的時間戳(主動關心的依據)。 // **提到不是接觸**:在日記裡寫到某個人,不該把他的沉默計時歸零。 for (const name of pl.contactsFromRooms(slug)) pl.stampContact(slug, name); pl.renderRelations(slug); }); // 不做無聲的裁切:清掉幾筆、為什麼清、保護了幾筆,都要寫進回報 await run("prune-short-term", () => pl.pruneShortTermDetail(slug)); await run("archive-threads", () => pl.archiveStaleThreads(slug)); await run("emotion-decay", () => { pl.updateEmotion(slug, (s) => pl.decayEmotionBy(s, pl.SLEEP_DECAY_MINUTES)); // 當日底色不歸零,只帶一部分過去——睡一覺不會把昨天的低氣壓抹掉 pl.sleepDayMood(slug); }); // 懸太久沒下文的事在這裡收(睡覺是它自然的收尾點),各留一則短期記憶 await run("sweep-loops", () => ({ cold: pl.sweepLoops(slug).length })); 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.updateEmotion(slug, (s) => pl.applyEmotion(s, entry.emotion_deltas, text.slice(0, 80), { slug })); 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 r = pl.memoryRecalled(meta); const head = `- ${meta._name}|${meta.type || "fact"}|顯著度 ${meta.salience ?? "?"}`; if (r.state === "clear") { const first = (meta._gist || meta._body || "").split("\n")[0] || ""; lines.push(`${head}|${first.slice(0, 120)}`); } else if (r.state === "faded") { lines.push(`${head}|⚠ 半模糊 ${Math.round(r.retrievability * 100)}%|主旨:${r.gist.split("\n")[0].slice(0, 100)}`); lines.push(` ${r.hint}`); } else { lines.push(`${head}|⚠ 模糊 ${Math.round(r.retrievability * 100)}%|只剩線索:${(r.topics || []).join("/") || "-"}`); lines.push(` ${r.hint}`); } } 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)}`); } // 心裡話也找得到——害羞的人格,重要的東西幾乎都在心裡那句 const inner = pl.recallInner(slug, query, limit); if (inner.length) { lines.push("心裡話(只有你自己看得到,不要講給他聽):"); for (const row of inner) lines.push(`- 💭 [${row.kind || "infer"}] ${String(row.text || "").slice(0, 110)}`); } // 模糊到只剩線索的那幾則**不算被想起來**:真的想不起來就不該重設它的衰減 pl.touchRecall(slug, hits.filter((m) => (m._recall?.retrievability ?? 1) >= pl.MEMORY_FUZZY_AT).map((m) => m._name)); emit({ persona: slug, long_term: hits, short_term: recents, inner }, 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})→ 挑最想說的那一兩句。`); const lint = pl.speechLint(text); const blockers = lint.filter((i) => i.level !== "hint"); for (const issue of blockers) lines.push(`⚠ ${pl.speechLintMessage(issue)}。`); // hint 不算不通過(例如講到自己的過去),但要提醒去驗證 for (const issue of lint.filter((i) => i.level === "hint")) lines.push(`· ${pl.speechLintMessage(issue)}。`); // 模糊態的兩種句型:試探是**提醒**(記一筆),假裝記得是**提醒去查**。 // 這裡刻意都不擋——擋下去等於把「開放試探」那條界線收回來,稽核才是煞車。 const probing = pl.looksLikeProbe(text); const asserting = pl.looksLikeFabrication(text); if (probing) { lines.push("· 這句是試探:說出口之後記一筆 `persona.mjs probe add --text \"...\"`," + "對方回了再 `probe confirm` 或 `probe deny`。"); } else if (asserting) { lines.push("· 這句在斷言自己的過去,而且沒有在問:先 `recall` 確認那則記憶還清晰。" + "只要它是模糊態,就改成帶問號的試探句,不可以把細節補出來。"); } const clean = !repeat && !tooLong && !blockers.length; if (clean) { lines.push(`✔ 沒講過,${sentences} 句,可以說。`); if (flags.record) pl.recordSaid(slug, text, { kind: "reply" }); } emit({ persona: slug, repeat, sentences, lint, probing, asserting, ok: clean }, flags.json, lines); return; } die(`未知 action:${action}(可用 check/list)`); }; /** 短期 → 長期的「轉入條件」評估:列出達標的候選與依據。 */ commands.candidates = ({ flags }) => { const session = requireSession(flags); const slug = hostOf(flags, session); requireOwner(slug, session); // `--reviewed`:把判斷過的短期記憶標掉。**這是候選數字唯一會往下扣的地方**—— // 固化完不標,同一批下一輪照樣被算成候選,提醒永遠亮著(看起來像睡眠沒做事)。 if (flags.reviewed !== undefined) { const spec = flags.reviewed === true ? "all" : str(flags.reviewed) || "all"; const indexes = spec === "all" ? null : csv(spec).map(Number); if (indexes && (!indexes.length || indexes.some((n) => !Number.isInteger(n)))) { die("`--reviewed` 只能是 `all` 或以逗號分隔的編號(候選輸出裡每筆前面的 `#N`)。"); } const until = str(flags.until) || null; const res = pl.markShortTermReviewed(slug, { indexes, until }); const scope = indexes ? `${indexes.length} 筆指名的` : until ? `${until} 之前的` : "全部"; ok(`已標記${scope}短期記憶為判斷過(新標 ${res.marked} 筆,本來就標過 ${res.already} 筆)。`); const after = pl.promotionCandidates(slug); say(` 現在還有 ${after.candidates.length} 組候選(短期記憶 ${after.total} 筆,判斷過 ${after.reviewed} 筆)。`); return; } const result = pl.promotionCandidates(slug); const lines = [ `人格 \`${slug}\`:短期記憶 ${result.total} 筆(判斷過 ${result.reviewed} 筆),` + `達固化條件的候選 ${result.candidates.length} 組${result.pressure ? "(已達容量壓力 R6)" : ""}`, "轉入條件:", ...pl.PROMOTION_RULES.map((r) => ` ${r.id} ${r.label}`), ]; if (!result.candidates.length) lines.push("目前沒有需要固化的內容(未達任何條件)。"); // 心裡話裡反覆出現的念頭:想過好幾次的事,多半是真的重要 const thoughts = pl.innerCandidates(slug); if (thoughts.length) { lines.push(`\n心裡話裡一直在想的事(${thoughts.length} 組,24 小時內)——要不要記住由你決定:`); for (const g of thoughts) { lines.push(` 「${g.token}」想過 ${g.count} 次`); for (const e of g.entries.slice(-2)) lines.push(` - 💭 ${String(e.text || "").slice(0, 80)}`); } } 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} 筆依據)`, ); // `#N` 就是 `--from-short` 與 `--reviewed` 要填的編號 for (const entry of cand.entries.slice(0, 6)) { lines.push(` - #${entry._index} [${entry.role || "?"}] ${String(entry.text || "").slice(0, 90)}(顯著度 ${entry.salience ?? "?"})`); } } if (result.candidates.length) { lines.push( "", "判斷完之後要把來源標掉,不然下一輪同一批又會被算成候選:", " 固化的  `consolidate --from-short <#N,#N> ...`(寫長期記憶時一起標)", " 看過不記的`candidates --reviewed <#N,#N>`", ); } 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")); // slugify 會把標點吃掉:「我的貓」「我的貓?」「我的貓!!」全都落在 `我的貓.md` 上。 // 以前直接覆蓋,只留下舊檔的 first_seen/recall_count,內文靜默被換成別則記憶。 // 這裡改成擋下來:不同的 --name 撞到同一個檔就 die,並給一個沒被占用的檔名。 const priorTitle = existing.title ?? existing.name ?? null; if (priorTitle !== null && priorTitle !== rawName && !flags.force) { let alt = name; for (let i = 2; i < 100 && fs.existsSync(path.join(pl.longTermDir(slug), `${alt}.md`)); i += 1) { alt = `${name}-${i}`; } die( `\`${file}\` 已經是「${priorTitle}」的長期記憶,但這次的 --name 是「${rawName}」——` + `兩個名字 slugify 之後都是 \`${name}\`,寫下去會把舊的內文換掉。\n` + ` 換個名字(例如 \`--name ${alt}\`),或確定要覆蓋同一則就加 --force。`, ); } 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 stamp = pl.contextStamp(slug); // `--gist` 明寫時,`--detail` 沒給就是**沒有細節**——退回整份 body 會讓主旨之外 // 再抄一次全文,那是把「衰減先吃細節」這件事整個抵銷掉。 const splitBody = str(flags.gist) ? { gist: str(flags.gist), detail: str(flags.detail) } : pl.splitGistDetail(body); const about = csv(flags.about).length ? csv(flags.about) : ["user"]; // `about` 是自由字串(原樣保留);對得上關係圖節點的才多寫一行 id,之後 recall 與 // 「人際關係」那段才知道講的是同一個人。一個都對不上就不輸出這一行。 // `resolveRelationRefs` 已經把不能寫進 front matter 的 id 濾掉了(帶換行的節點 id // 可以在這裡多插一行、覆寫下面的 `type`,把一則 fact 變成不該被遺忘的 canon)。 const aboutIds = pl.resolveRelationRefs(slug, about); // front matter 的每個值都只能是一行:帶換行的旗標值同樣能偽造出別的欄位, // 而 `parseFrontMatter` 取後出現的值 → 後面宣告的 type/salience 會被前面偽造的蓋掉。 const fm = (value) => pl.injectSafeLine(value); const front = [ "---", `name: ${name}`, // 原本的 --name(沒被 slugify 吃掉的那個)。下次撞名時就是靠這行認出「不是同一則」。 `title: ${rawName.replace(/[\r\n]+/g, " ").replace(/-{3,}/g, "—").trim().slice(0, 120)}`, `type: ${type}`, `about: [${about.map(fm).filter(Boolean).join(", ")}]`, ...(aboutIds.length ? [`about_ids: [${aboutIds.join(", ")}]`] : []), `topics: [${csv(flags.topics).map(fm).filter(Boolean).join(", ")}]`, `salience: ${num(flags.salience, 60)}`, // 回想強度:連續衰減的起點。已經存在的記憶保留自己長出來的強度, // 不要因為重寫一次就把「被想起過很多次」的歷史抹平。 `strength: ${num(flags.strength, Number(existing.strength ?? pl.MEMORY_STRENGTH_DEFAULT))}`, `emotion: ${fm(str(flags.emotion) || "none")}`, // 情境索引:什麼時候、在哪裡、什麼心情記下來的(回想時的非語意線索)。 // 推不出來的欄位就不寫——全專案一致:沒有值不留空欄位。 ...["when", "where", "mood"] .map((k) => [k, fm(str(flags[k]) || existing[k] || stamp[k] || "")]) .filter(([, v]) => v) .map(([k, v]) => `${k}: ${v}`), `rules: ${fm(str(flags.rules) || "manual")}`, `first_seen: ${existing.first_seen || today}`, `last_seen: ${today}`, `recall_count: ${existing.recall_count || 0}`, `source: ${fm(str(flags.source) || "short-term")}`, "---", "", // 主旨/細節兩層:衰減先吃細節,主旨最後才掉。沒有明寫就用第一段當主旨。 pl.joinGistDetail(splitBody.gist, splitBody.detail), "", ]; pl.writeText(file, front.join("\n")); const total = pl.rebuildIndex(slug); // 來源短期記憶標成判斷過:不標的話這幾筆下一輪又會被算成固化候選。 // 編號看 `candidates` 輸出裡每筆前面的 `#N`。 if (flags["from-short"] !== undefined) { const indexes = csv(flags["from-short"]).map((s) => Number(String(s).replace(/^#/, ""))); if (!indexes.length || indexes.some((n) => !Number.isInteger(n))) { die("`--from-short` 要的是以逗號分隔的編號(候選輸出裡每筆前面的 `#N`)。"); } const res = pl.markShortTermReviewed(slug, { indexes, promotedTo: name }); say(` 來源的 ${res.marked} 筆短期記憶已標成判斷過(promoted_to: ${name})。`); } const forget = num(flags.forget, null); if (forget !== null) { // 跟 prune 套同一層保護:承諾、界線與今天的紀錄不能被固化順手洗掉(R4) const cut = pl.forgetShortTerm(slug, forget); say(` 短期記憶已淘汰顯著度 < ${forget} 的項目,剩 ${cut.kept} 筆。`); if (cut.protected) { say(` 其中 ${cut.protected} 筆低於門檻但受保護,沒有刪:` + `顯著度 ≥ ${pl.SHORT_TERM_PROTECT_SALIENCE}、intent=commit,或 ${pl.SHORT_TERM_PROTECT_HOURS} 小時內。`); } } 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} 天)。`); }; /** * 把長期記憶檔升到新格式(`strength` 與主旨/細節兩層)。 * * `--all` 會掃過**所有**人格——這是唯一一個跨人格的維護指令, * 所以它只回報「動了幾個檔」,一個字的內容都不印出來(跨人格隔離的界線在這裡不能鬆)。 */ commands.migrate = ({ flags }) => { const session = requireSession(flags); const dryRun = Boolean(flags["dry-run"]); const targets = flags.all ? pl.listPersonas() : [hostOf(flags, session)]; if (!flags.all) requireOwner(targets[0], session); const rows = []; for (const slug of targets) { const stats = pl.migrateLongTerm(slug, { dryRun }); if (!dryRun && (stats.strength || stats.split)) pl.rebuildIndex(slug); rows.push({ persona: slug, ...stats }); } const lines = [dryRun ? "長期記憶格式遷移(試跑,沒有寫入):" : "長期記憶格式遷移完成:"]; for (const r of rows) { lines.push(`- ${r.persona}|共 ${r.total} 則|補 strength ${r.strength}|切主旨/細節 ${r.split}` + (r.skipped ? `|讀寫失敗 ${r.skipped}` : "")); } lines.push(" (只回報數量:跨人格維護不印任何記憶內容。)"); emit({ dry_run: dryRun, personas: rows }, flags.json, lines); }; // --------------------------------------------------------------------------- // // loop:懸著的事(未完事項) // --------------------------------------------------------------------------- // commands.loop = ({ flags, positional }) => { const session = requireSession(flags); const slug = hostOf(flags, session); const action = (positional[0] || "list").toLowerCase(); const show = () => { const live = pl.openLoops(slug); const lines = [`\`${slug}\` 現在懸著 ${live.length}/${pl.LOOP_MAX} 件事:`]; for (const l of live) { const days = Math.floor((Date.now() - (pl.parseIso(l.opened_at)?.getTime() ?? Date.now())) / 86_400_000); lines.push(`- ${l.id}|${pl.LOOP_KINDS[l.kind] || l.kind}|${l.text}(開了 ${days} 天)`); } if (!live.length) lines.push("- (沒有懸著的事)"); emit({ persona: slug, loops: live }, flags.json, lines); }; if (action === "list") { requireMember(slug, session, Boolean(flags["as-guest"])); show(); return; } requireOwner(slug, session); if (action === "add") { const res = pl.addLoop(slug, { text: str(flags.text), kind: str(flags.kind) || "topic", about: str(flags.about) }); if (!res.ok) die(`開不了:${res.reason}。`); ok(`懸著了:${res.loop.id}|${pl.LOOP_KINDS[res.loop.kind]}|${res.loop.text}(${res.open}/${pl.LOOP_MAX})`); return; } if (action === "done" || action === "drop" || action === "cold") { const id = str(flags.id); if (!id) die("需要 `--id`(用 `loop list` 查)。"); const res = pl.closeLoop(slug, id, action === "done" ? "done" : action, str(flags.note)); if (!res.ok) die(res.reason); ok(`收掉了 ${res.loop.id}:${res.loop.text}`); return; } if (action === "touch") { const id = str(flags.id); if (!id) die("需要 `--id`。"); const res = pl.touchLoop(slug, id); if (!res.ok) die(res.reason); ok(`${res.loop.id} 有進展,逾期重算。`); return; } if (action === "sweep") { const cold = pl.sweepLoops(slug); ok(cold.length ? `收掉 ${cold.length} 件沒下文的(各留了一則短期記憶):${cold.map((l) => l.text).join("、")}` : `沒有超過 ${pl.LOOP_STALE_DAYS} 天沒進展的。`); return; } die("用法:`loop add|done|drop|touch|sweep|list`。"); }; // --------------------------------------------------------------------------- // // probe:模糊記憶的試探與稽核(開放界線唯一的煞車) // --------------------------------------------------------------------------- // commands.probe = ({ flags, positional }) => { const session = requireSession(flags); const slug = hostOf(flags, session); const action = (positional[0] || "audit").toLowerCase(); if (action === "audit") { requireMember(slug, session, Boolean(flags["as-guest"])); const a = pl.probeAudit(slug, num(flags.limit, 50)); const lines = [ `\`${slug}\` 的試探稽核(最近 ${a.total} 次):`, ` 確認 ${a.confirmed}/否認 ${a.denied}/還沒結案 ${a.pending}`, a.denied_ratio === null ? " 還沒有結案的試探,算不出錯誤率。" : ` 結案的裡面有 ${a.denied_ratio}% 被否認` + (a.denied_ratio >= 40 ? " ⚠ 太高了:模糊態正在被拿來編內容,先收緊到「只能說記不清」。" : "。"), ]; for (const row of a.recent) lines.push(` - [${row.outcome}] ${row.text}${row.memory ? `(${row.memory})` : ""}`); emit({ persona: slug, audit: a }, flags.json, lines); return; } requireOwner(slug, session); if (action === "add") { const text = str(flags.text); if (!text) die("需要 `--text`(你實際問出口的那句試探)。"); if (!pl.looksLikeProbe(text)) { die("這句不像試探:試探一定要在**問**(帶問號,例如「是不是上個月那次?」)。\n" + " 可以說不確定,不可以斷言——斷言就是幻覺,不是模糊記憶。"); } const entry = pl.recordProbe(slug, { text, memory: str(flags.memory) }); ok(`記下這次試探了:${entry.text} → 對方回了之後用 \`probe confirm\` 或 \`probe deny\`。`); return; } if (action === "confirm" || action === "deny") { const hit = pl.resolveProbe(slug, action === "confirm" ? "confirmed" : "denied", { at: str(flags.id) || null, // 不指定就結最近一筆還沒結案的 note: str(flags.note), }); if (!hit) die("沒有還沒結案的試探。"); if (action === "deny") { ok(`記成「被否認」了:${hit.text}\n 現在就寫一則更正記憶(\`remember\` 或 \`consolidate\`),不要放著。`); } else { ok(`記成「對方確認」了:${hit.text}`); } return; } die("用法:`probe add|confirm|deny|audit`。"); }; 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"])); // 讀對方那句話的情緒訊號(不改狀態,只回報;`--read` 可以單獨拿來測詞表) const readText = str(flags.read); const read = readText ? pl.readUserEmotion(readText) : null; if (read) { emit({ persona: slug, read }, flags.json, [ `讀「${readText.slice(0, 40)}」:`, read.signals.length ? ` 讀到:${read.signals.map((s) => `${s.zh} ${s.score}`).join("、")}|強度 ${read.intensity}|${read.confident ? "夠明顯,可以參考" : "很弱,別當真"}` : " 這句沒讀出什麼情緒。", ...(read.signals.length ? [` 怎麼接:${pl.RESPONSE_STANCE[read.signals[0].key]}`] : []), ]); return; } if (flags.audit) { const audit = pl.feltAudit(slug, num(flags.limit, 20)); const trend = pl.feltTrend(slug); emit({ persona: slug, audit, trend }, flags.json, [ `\`${slug}\` 最近 ${audit.rounds} 輪的情緒套用:`, ` 往舒服的方向 ${audit.positive}|往難受的方向 ${audit.negative}` + (audit.positive_ratio === null ? "" : ` → 正向佔 ${audit.positive_ratio}%`), audit.positive_ratio !== null && audit.positive_ratio >= 90 ? " ⚠ 幾乎都在給自己加分。delta 是你自己挑的,這種偏差沒人會替你抓。" : " 兩邊都有,還算誠實。", trend ? ` 對方的走向:${trend.zh} ${trend.direction}(近 ${trend.rounds} 輪)` : " 還沒有足夠的輪次算走向。", ]); return; } if (flags.apply && role === "guest") die("guest(sub agent)不得改寫人格的情緒狀態。"); // 親近度會放大或縮小衝擊:同一句話,從枕邊人跟從生人嘴裡出來不一樣 const rel = flags.apply ? pl.relationGain(slug, str(flags.from) || null) : null; let deltas = null; let state; if (role === "guest") { // guest 不寫回,所以也不需要鎖 state = pl.decayEmotion(pl.loadEmotion(slug)); } else { // 讀 → 衰減 → 套用 → 寫回整段在檔案鎖裡:並行的 `--apply` 才不會互相覆蓋 state = pl.updateEmotion(slug, (s) => { s = pl.decayEmotion(s); if (flags.baseline) { for (const [key, value] of Object.entries(parseDeltas(flags.baseline))) { if (key in pl.EMOTIONS) s.baseline[key] = pl.clamp(value); } } if (flags.apply) { const raw = parseDeltas(flags.apply); deltas = rel.gain === 1 ? raw : Object.fromEntries(Object.entries(raw).map(([k, v]) => [k, Math.round(v * rel.gain * 100) / 100])); s = pl.applyEmotion(s, deltas, str(flags.trigger), { slug }); if (rel.gain !== 1) { s.last_trigger = s.last_trigger || {}; s.last_trigger.relation_gain = { who: rel.name, closeness: rel.closeness, gain: rel.gain }; } } return s; }); } if (flags.apply) { pl.recordFelt(slug, { mine: state.last_trigger?.deltas || null, note: str(flags.trigger) }); pl.appendJsonl(pl.journalPath(slug), { ts: pl.nowIso(), kind: "emotion", trigger: str(flags.trigger), deltas, applied: state.last_trigger?.deltas || {}, levels: state.levels, mood: pl.moodOf(slug, state), }); } const m = pl.moodOf(slug, 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"; // graph.json 壞掉時「空圖」與「壞檔」不能長得一樣:要做決定的 action 直接擋下來 // (放它過去就是拿一張空圖去覆蓋原檔)。doctor 自己會報告,不走這裡。 const graphOrDie = () => { const data = pl.loadRelations(slug); if (data._error) { die(`${data._error}\n 先修好這個檔案(或把它移開讓人格從空的關係圖重新開始),再用 \`relation doctor\` 確認。`); } return data; }; 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 = graphOrDie(); 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 = graphOrDie().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); // 壞檔不能印成「0 節點」了事(那跟空圖逐字相同,看不出東西還在不在) emit(data, flags.json, [ `人格 \`${slug}\` 人際關係圖:${data.nodes.length} 節點 / ${data.edges.length} 連線`, ...(data._error ? [`⚠ ${data._error}(節點可能還在檔案裡,只是讀不出來 → \`relation doctor\`)`] : []), pl.relationsBrief(slug, null, 20) || "(空)", ]); return; } // 健檢:記憶裡的人名與關係圖節點對不對得上。**唯讀**——只印報告,一個檔案都不改。 if (action === "doctor") { const graph = pl.loadRelations(slug); // 壞檔不能報「節點 0 個、0 問題」(那跟空圖逐字相同,exit 也一樣是 0)。 if (graph._error) { die( `${graph._error}\n 健檢無法進行:這不是「沒有關係圖」,是「讀不出來」。` + `\n 所有關係圖的寫入都會被拒絕(免得覆蓋掉原檔),修好之後再跑一次。`, ); } const nodes = graph.nodes; const mentioned = new Set(); // 有記憶提到、且對得上節點的 id const missingAbout = new Map(); const missingEntities = new Map(); const dangling = new Map(); // about_ids/entity_ids 指到不存在的節點 const ambiguous = new Map(); // 一個名字對到多個節點 → 兩邊都不寫 // `user`/`self` 是 CLI 自己填的佔位字(`consolidate` 沒帶 `--about` 就是 `about: [user]`), // 不是人名。不跳過的話它會是筆數最高那一行,把真正的問題壓到看不見。 const PLACEHOLDERS = new Set(["user", "self"]); const bump = (map, key) => map.set(key, (map.get(key) || 0) + 1); const sortByCount = (map) => [...map.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])); // 用**完全相等**比對,跟 `resolveRelationRefs`(寫入路徑)同一套規則: // 拿 `findRelationNode` 的子字串 fallback 來健檢,會把「猜對的」當成「對上的」, // 於是「先生 → 小林先生」這種誤配在報告上是 0 問題。 // front matter/jsonl 的值不保證是陣列(`about: 王經理` 會給一個字串)→ 一律先攤成清單, // 不然 `for..of` 會逐字元跑,一個人名變成一堆單字的假人名。 const asList = (v) => (Array.isArray(v) ? v : v === undefined || v === null || v === "" ? [] : [v]); const tally = (names, missing) => { for (const raw of asList(names)) { const name = String(raw).trim(); if (!name) continue; const matched = pl.matchRelationNodes(nodes, name); if (matched.length === 1) mentioned.add(matched[0].id); else if (matched.length > 1) ambiguous.set(name, matched.map((n) => String(n.id))); else if (!PLACEHOLDERS.has(name.toLowerCase())) bump(missing, name); } }; const tallyIds = (ids) => { for (const raw of asList(ids)) { const id = String(raw).trim(); if (!id) continue; // id 不是人名:對不上就是「死指標」(節點被改 id 或被刪掉),單獨列一類 if (nodes.some((n) => String(n.id).trim() === id)) mentioned.add(id); else bump(dangling, id); } }; for (const meta of pl.longTermEntries(slug)) { tally(meta.about, missingAbout); tallyIds(meta.about_ids); } for (const row of pl.readJsonl(pl.shortTermPath(slug))) { tally(row.entities, missingEntities); tallyIds(row.entity_ids); } const orphans = nodes .filter((n) => !mentioned.has(n.id)) .map((n) => ({ id: n.id, name: n.name || n.id, closeness: Number(n.closeness ?? 0) })) .sort((a, b) => b.closeness - a.closeness); const aboutRows = sortByCount(missingAbout); const entityRows = sortByCount(missingEntities); const danglingRows = sortByCount(dangling); const ambiguousRows = [...ambiguous.entries()].sort((a, b) => a[0].localeCompare(b[0])); const lines = [`人格 \`${slug}\` 關係圖健檢(只讀,不改記憶與關係圖):節點 ${nodes.length} 個`]; lines.push(`長期記憶 about 對不到節點的人名(${aboutRows.length}):`); lines.push(...(aboutRows.length ? aboutRows.map(([n, c]) => ` - ${n}(${c} 則)`) : [" (無)"])); lines.push(`短期記憶 entities 對不到節點的人名(${entityRows.length}):`); lines.push(...(entityRows.length ? entityRows.map(([n, c]) => ` - ${n}(${c} 筆)`) : [" (無)"])); lines.push(`同名歧義:一個名字對到多個節點,兩邊都不會寫進 about_ids/entity_ids(${ambiguousRows.length}):`); lines.push(...(ambiguousRows.length ? ambiguousRows.map(([n, ids]) => ` - ${n} → ${ids.join("/")}(改掉其中一個的名字或 id)`) : [" (無)"])); lines.push(`about_ids/entity_ids 指到不存在的節點(${danglingRows.length}):`); lines.push(...(danglingRows.length ? danglingRows.map(([id, c]) => ` - ${id}(${c} 處;節點被改 id 或被刪掉了)`) : [" (無)"])); lines.push(`關係圖裡有節點、但沒有任何記憶提到(${orphans.length}):`); lines.push(...(orphans.length ? orphans.map((n) => ` - ${n.name}(${n.id}/親近 ${n.closeness})`) : [" (無)"])); lines.push( `總計:節點 ${nodes.length}/被記憶提到 ${mentioned.size}/沒人提到 ${orphans.length}` + `|對不到節點的人名:長期 ${aboutRows.length} 種、短期 ${entityRows.length} 種` + `|同名歧義 ${ambiguousRows.length} 個|死指標 ${danglingRows.length} 個。`, ); emit( { persona: slug, nodes: nodes.length, mentioned: [...mentioned], unresolved_about: aboutRows.map(([name, count]) => ({ name, count })), unresolved_entities: entityRows.map(([name, count]) => ({ name, count })), ambiguous_names: ambiguousRows.map(([name, ids]) => ({ name, ids })), dangling_ids: danglingRows.map(([id, count]) => ({ id, count })), unmentioned_nodes: orphans, }, flags.json, lines, ); return; } die(`未知 action:${action}(可用 node/edge/style/speaker/render/show/doctor)`); }; 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 of Object.keys(data.pins || {})) { if (pl.pinOf(data, agentId)?.persona === 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(", ") || "(無)"})。`); } const members = (pl.readJson(pl.roomMembersPath(room), {}) ?? {}).members || []; const nameOf = (slug) => pl.roomDisplayName(slug); /** `--to`:對誰說。接 slug、人格的 Name,或 all/大家(對全場)。 */ const resolveAddressee = (raw) => { const value = String(raw).trim(); if (/^(all|全部|大家|全場|everyone)$/i.test(value)) return pl.ROOM_ALL; const hit = members.find((m) => m.toLowerCase() === value.toLowerCase()) || members.find((m) => nameOf(m) === value); if (!hit) { die( `\`${value}\` 不在聊天室 \`${room}\` 裡(成員:${members.map((m) => `${nameOf(m)}(${m})`).join("、") || "(無)"})。` + "對全場說話用 `--to all`。", ); } return hit; }; if (action === "floor") { const floor = pl.roomFloor(room); const lines = [`聊天室 \`${room}\`|成員 ${floor.members.map(nameOf).join("、") || "(無)"}|主題 ${floor.topic || "-"}`]; if (!floor.last) { lines.push("還沒有人發言:誰開場都可以。"); } else if (floor.mode === "dyad") { lines.push( `現在:${nameOf(floor.last.speaker)} → ${nameOf(floor.addressee)}(一對一)`, ` 該接話的只有 ${nameOf(floor.next)};先安靜:${floor.silent.map(nameOf).join("、") || "(無)"}` + "(不要替他們寫台詞,也不要為此啟動他們的 sub agent)", " 話題真的放大了才動:當事人下一句加 `--to all` 開回全場,或旁人 " + '`room post --as <他> --barge-in "<為什麼現在該他講>"`。', ); } else { lines.push(`現在:全場開放(${nameOf(floor.last.speaker)} 對大家說)→ 誰接都可以,一次讓一個人格接一輪。`); } const cold = floor.quiet.filter((q) => !q.spoken || q.turns_since >= 4); if (cold.length) { lines.push( `很久沒講話:${cold.map((q) => `${nameOf(q.persona)}(${q.spoken ? `${q.turns_since} 輪前` : "還沒開口"})`).join("、")}` + " — 話題碰到他的專長或使用者點名時再拉進來,不要為了公平硬派台詞。", ); } emit(floor, flags.json, lines); return; } if (action === "post") { const speaker = str(flags.as) || data.host; if (!speaker) die("需要 `--as `。"); const [, speakerRole] = requireMember(speaker, session, Boolean(flags["as-guest"])); const to = flags.to ? resolveAddressee(flags.to) : null; if (to === speaker) die("`--to` 不能指向自己(那是心裡話,請用 `think`)。"); // 一對一進行中,旁人要插話得說得出理由——「大家都在同一個空間」不等於每句都該有人接。 const { allowed, floor } = pl.roomMayPost(room, speaker); const bargeIn = str(flags["barge-in"]) || str(flags.broaden); if (!allowed && !bargeIn && !flags.force) { die( `現在是 ${nameOf(floor.pair[0])} 跟 ${nameOf(floor.pair[1])} 的一對一` + `(最後一句是 ${nameOf(floor.last.speaker)} 對 ${nameOf(floor.addressee)} 說的),` + `${nameOf(speaker)} 不該接這句——旁人插嘴會把兩個人的話變成公開場面。\n` + " 話題真的放大了(出現「我們/大家」、需要第二意見、講到他的專長、使用者點名)才插話," + '並帶 `--barge-in "<為什麼現在該他講>"`;否則讓當事人先用 `--to all` 把話題開回全場。', ); } 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( `這句 ${repeat.minutes_ago} 分鐘前就講過了(像了 ${repeat.similarity}):「${repeat.text.slice(0, 60)}」。` + "換個角度、補點新的,或者直接把話題往前推。" + "他追問、你非得重講一次,才加 `--allow-repeat`。", ); } } const sentences = pl.sentenceCount(text); // 越害羞的人話越少:上限跟著羞恥度走,不是固定三句 const budget = pl.personaExists(speaker) ? pl.speechBudget(speaker) : { sentences: pl.MAX_SENTENCES, modesty: 50 }; if (sentences > budget.sentences && !flags.force) { const why = budget.sentences < pl.MAX_SENTENCES ? `(羞恥度 ${budget.modesty},這個人格話比別人少)` : ""; die( `講太多了,${sentences} 句。這裡最多 ${budget.sentences} 句${why}——` + "挑最想說的那一兩句就好,剩下的寫進心裡話。(真的需要長台詞才加 `--force`。)", ); } // 台詞不可以是心裡話搬上台面:劇場模式裡別的人格只看得到你說出口的東西, // 心裡話外流最可能的路徑就是你自己把它講出來。 if (speakerRole !== "guest" && pl.personaExists(speaker)) { const leak = pl.innerLeak(speaker, text); if (leak && !flags.force) { die( `這句跟你剛才的心裡話太像了(${leak.similarity}):「${leak.text.slice(0, 40)}…」。` + "心裡話是只有你自己知道的東西——說出口的那句應該是你**決定要讓他聽到**的版本," + "不是把心裡那句唸出來。(真的要講開才加 `--force`。)", ); } } // 短句、日常話、講完不解釋、不說 AI 才會說的話——講話的樣子也擋在 CLI 裡,不靠自律。 const lint = pl.speechBlockers(text); if (lint.length && !flags.force) { die(`${lint.map(pl.speechLintMessage).join(";")}。(真的需要才加 \`--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, to, bargeIn }); // guest 對自己的狀態唯讀,它說過的話留在聊天室逐字稿裡就夠了(roomRepeat 讀得到) if (speakerRole !== "guest") pl.recordSaid(speaker, text, { room, kind: "room" }); const dyad = to && to !== pl.ROOM_ALL; ok( `\`${speaker}\` 已發言於 \`${room}\`(${dyad ? `對 ${nameOf(to)}` : "對全場"}/情緒 ${emotion})。` + (dyad ? `接下來只有 ${nameOf(to)} 該回話,其他人先安靜。` : ""), ); 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 || "-"}`]; // 一行一句的排版:台詞是別的人格寫的,換行與注入標記在這裡也要壓掉 // (`roomPost` 已在寫入端處理,這是給舊逐字稿的第二道)。 for (const row of rows) { const arrow = row.to && row.to !== pl.ROOM_ALL && row.to !== row.speaker ? ` → ${row.to}` : ""; const emotion = pl.injectSafeLine(row.emotion); lines.push(`[${row.ts}] ${row.speaker}${emotion ? `(${emotion})` : ""}${arrow}:${pl.injectSafeLine(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/floor/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 略過):內容可能被改過,請自行確認。"); // 舊 bundle(v1)匯進來會自動升格式。這件事以前只出現在 --json 裡, // 等於人看的輸出完全沒提——升成功要講,升失敗更要講。 const inVersion = Number(bundle.version) || 1; if (inVersion < pl.BUNDLE_VERSION) { if (result.migrated) { lines.push( ` 這是 v${inVersion} 的舊 bundle,已就地升到 v${pl.BUNDLE_VERSION}:` + `${result.migrated.total} 則長期記憶,補 strength ${result.migrated.strength}、切主旨/細節 ${result.migrated.split}` + (result.migrated.skipped ? `,${result.migrated.skipped} 則看不懂格式沒動` : "") + "。", ); } else { lines.push( ` ⚠ 這是 v${inVersion} 的舊 bundle,但自動升格式失敗了。` + "請手動跑 `persona.mjs migrate --dry-run` 看情況,再決定要不要 `migrate`。", ); } } 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 匯入一個**本機還沒有**的人格(換一台機器時的第一步)。 * * 這是唯一不需要「先載入該人格」的同步入口,而且非如此不可:`sync` 的每個動作都要 * `requireOwner`,而 `requireOwner` 的第一件事是「本機沒有這個人格就 die」——本機沒有它, * 就 load 不了它,就永遠拉不回來。所以走 `import` 那條路:只驗 session,不驗 host。 */ commands.clone = async ({ flags, positional }) => { const session = requireSession(flags); const problem = gt.giteaProblem(); if (problem) die(`Gitea 尚未設定:${problem}(設 GITEA_HOST 與 GITEA_TOKEN,或用 PERSONA_GITEA_* 覆寫)`); const owner = str(flags.owner) || null; const code = str(flags.code) || positional[0] || ""; // 不指定編號 → 列出遠端有哪些人格讓使用者挑 if (!code) { let listed; try { listed = await gt.listRemotePersonas({ owner }); } catch (err) { die(`列不出 Gitea 上的人格:${err.message}`); } const rows = listed.personas; const missing = rows.filter((r) => !r.local); emit({ owner: listed.owner, personas: rows }, flags.json, [ `Gitea \`${listed.owner}\` 底下的人格:${rows.length} 個,其中 ${missing.length} 個本機還沒有。`, ...(rows.length ? rows.map((r) => `${r.local ? " ✔" : " ⬇"} \`${r.code}\`` + (r.local ? ` 本機已有${r.local === r.code ? "" : `(目錄 ${r.local})`}` : " 本機還沒有") + `|${r.private ? "私有" : "公開"}|最後更新 ${String(r.updated_at || "").slice(0, 10) || "—"}` + (r.description ? `|${r.description}` : "")) : ["(一個都沒有——存取庫名稱要是人格編號才算得上人格,例如 `ASUNA-01`)"]), "", "匯入:`persona.mjs clone --code <編號> --session `(本機還沒有的那些)", ]); return; } if (!gt.validCode(code)) die(`編號 \`${code}\` 不合法(格式:ASUNA-01)。`); const target = str(flags.persona) || code; const data = pl.loadSession(session); const exists = pl.personaExists(target); if (exists && !flags.force) { die( `人格 \`${target}\` 本機已經有了。要以遠端覆蓋本機請加 --force(本機還沒推上去的改動會不見),` + "或用 `--persona <另一個目錄名>` 拉成第二份;只是想更新的話請 `load` 之後跑 `sync pull`。", ); } if (exists) { // 覆寫既有人格:跟 `import` 一樣的兩道保護 if (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 = await gt.importFromRemote(code, { owner, slug: target, force: Boolean(flags.force) }); } catch (err) { die(err.message); } const lines = [ `✔ 人格 \`${result.persona}\`(\`${result.code}\`)已從 Gitea 匯入${exists ? ",覆寫本機既有資料" : ""}。`, ` 來源:${result.owner}/${result.code}|${result.written.length} 個檔案|${pl.identityBrief(result.persona) || "(無身分欄位)"}`, ...gt.AREA_KEYS.map((key) => { const res = result.results[key] || {}; return ` ${gt.AREAS[key].label}:` + (res.ok ? `${res.written?.length ?? 0} 個檔案${res.empty ? "(遠端是空的)" : ""}` : `⚠ ${String(res.reason || "失敗").slice(0, 120)}`); }), ` 長期記憶 ${pl.longTermEntries(result.persona).length} 則|短期 ${pl.readJsonl(pl.shortTermPath(result.persona)).length} 筆` + `|關係人 ${pl.loadRelations(result.persona).nodes.length} 位`, ]; 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, 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 = await gt.nextCodeAcrossMachines(base, { owner: str(flags.owner) || null }); if (!next.code) die(`\`${base}\` 的編號已經用到 99。`); emit({ romaji: base, ...next }, flags.json, [ `\`${base}\` 的下一個可用編號:\`${next.code}\``, next.checked_remote ? ` (已對過 Gitea 上的 ${next.taken.length} 個編號)` : ` ⚠ 只看了本機,沒對過 Gitea(${next.reason})——換機器可能會撞號。`, ]); 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); let codeNote = ""; if (code && !gt.validCode(code)) die(`編號 \`${code}\` 不合法(格式:ASUNA-01)。`); if (!code) { const base = gt.normalizeRomaji(str(flags.romaji) || slug); if (!base) die("需要 `--romaji <英文名>`(中文名請先轉羅馬拼音並跟使用者確認拼法)。"); // 發號前先問遠端:本機看不到別台機器已經發出去的編號 const next = await gt.nextCodeAcrossMachines(base, { owner: str(flags.owner) || null }); code = next.code; if (!code) die(`\`${base}\` 的編號已經用到 99。`); codeNote = next.checked_remote ? ` (已對過 Gitea 上的 ${next.taken.length} 個編號,不會跟別台機器撞號)` : ` ⚠ 編號只對過本機,沒對過 Gitea(${next.reason})——別台機器可能已經用掉這個號。`; } 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}\`。`, ...(codeNote ? [codeNote] : [])]; 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(); const overwrites = state.overwrites || []; 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 ?? "?"} 個檔案`; }), // 「本機是真相來源」的代價:push 撞到別台機器時是本機贏。贏了要記帳。 ...(overwrites.length ? [" ⚠ 本機曾覆蓋遠端(別台機器同時在用同一個人格):"] .concat(overwrites.slice(-5).map((o) => ` ${o.at} ${gt.AREAS[o.area]?.label || o.area} ${o.files.length} 個檔案` + `(${o.files.slice(0, 4).join(", ")}${o.files.length > 4 ? "…" : ""})` + ` 上一版 ${String(o.previous || "").slice(0, 8)}`)) .concat([` 要看回被蓋掉的內容:\`git -C ${gt.syncDir(slug, overwrites.at(-1).area)} show <上一版>:<檔案>\``]) : []), ]); 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 }); } } // 覆蓋遠端這件事在 --quiet(背景 push)下也要留下痕跡:stderr 一定寫,sync.json 也記著 for (const r of out) { if (!r.overwrote) continue; process.stderr.write( `⚠ ${gt.AREAS[r.area].label}:以本機為準覆蓋了遠端 ${r.overwrote.files.length} 個檔案` + `(${r.overwrote.files.slice(0, 6).join(", ")}),上一版是 ${String(r.overwrote.previous).slice(0, 8)}。\n` + ` 別台機器可能正在用同一個人格。要看回被蓋掉的內容:` + `git -C ${gt.syncDir(slug, r.area)} show ${String(r.overwrote.previous).slice(0, 8)}:<檔案>\n`, ); } 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} 個檔案` : "沒有變更"}` + (r.overwrote ? ` ⚠ 其中覆蓋了遠端 ${r.overwrote.files.length} 個檔案(上一版 ${String(r.overwrote.previous).slice(0, 8)})` : "") : `✖ ${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 --gender --vibe --emoji --avatar --tells --baseline --origin --work --no-gitea --public] --tells "anger=不講話;句子只剩動詞|羞愧=摸後頸" 情緒破口,寫進 IDENTITY.md 的 ## Tells 區塊(蓋掉全域預設;沒帶就沿用預設) 人格編號 = 英文名全大寫 + 兩位索引(同名才遞增),也是 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 [--reviewed all|<#N,#N> [--until ]] 列出達到「短期→長期」條件的候選與依據(每筆前面的 #N 就是它的編號)。 --reviewed 把判斷過的短期記憶標掉(看過決定不記的用這個)—— 候選數字只有這裡跟 consolidate --from-short 會往下扣, 判斷完不標,同一批下一輪又會被算成候選。 consolidate --session --name --body [--type --about --topics --salience --strength --emotion --when --where --mood --gist --detail --rules --source --forget --from-short <#N,#N>] 內文會切成「主旨/細節」兩層:衰減先吃細節,主旨最後才掉。 --from-short 標記這則是從哪幾筆短期記憶長出來的(順便標成判斷過)。 prune / reindex --session migrate --session [--all] [--dry-run] [--json] 長期記憶升格式(補 strength、切主旨/細節)。--all 掃所有人格,只回報數量不印內容。 loop add|done|drop|touch|sweep|list --session [--text --kind question|promise|topic|mine --id --note] 懸著的事(同時最多 5 條):他沒回答的問題/他答應要做的事/被打斷的話題/我想問但沒問的。 7 天沒進展自動收掉並留一則「沒下文」。mine 那種同時是自我議程的來源。 probe add|confirm|deny|audit --session [--text --memory --note --id --limit] 模糊記憶的試探紀錄:可以說不確定、可以問,**不可以斷言**(add 會擋掉沒問號的句子)。 audit 看「試探幾次、被否認幾次」——這是開放這條界線唯一的煞車。 情緒與圖: emotion --session [--apply joy=+10,...] [--baseline ...] [--trigger ] [--from <對象>] 這件事是誰引起的:親近度會放大/縮小 delta [--read "<對方說的話>"] 只讀情緒訊號與該怎麼接,不改狀態 [--audit [--limit 20]] 最近幾輪的 delta 是不是只往一邊倒+對方的走向 套用時會飽和、受單輪預算限制(|delta| 總和 ≤ 60),再經交互抑制、慣性、 當日底色與疲勞調整——實際生效的值看 last_trigger.scaled 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 (使用者在關係圖裡是誰 → 決定語氣層) relation doctor --session [--json] 記憶裡的人名對不對得上關係圖節點:對不到的人名、 同名歧義、指到不存在節點的 id、沒人提到的節點。 **唯讀**(一個檔案都不改);graph.json 壞掉時以非零結束 多人格對話: invite --session --guest [--guests ] [--host --room --topic] (自動開啟劇場模式) leave --session --guest [--room] room post|read|script|floor|list|theater --session [--room --as --to --barge-in --text --text-file --emotion --limit --on --off --with-meta] (post 會擋下「短時間內近似重複」與超過三句的發言;例外用 --allow-repeat / --force) --to 這句話對誰說。指名 → 進入一對一,旁人不該接話(要插話得帶 --barge-in "<理由>");--to all → 把話題開回全場。 floor 現在誰對誰在講、該誰接話、誰先安靜、誰很久沒開口 圖示(建立人格並補齊資料後跑): 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 --owner] 發新編號前會先問遠端有哪些編號(避免換機器撞號);Gitea 連不上就只看本機並明講 clone --session [--code |] [--persona <目錄名> --owner --force --load --json] 把一個**本機還沒有**的人格從 Gitea 整個拉回來(換機器接續同一個人格的第一步)。 不帶 --code 就列出遠端有哪些人格、哪些本機還沒有。 唯一不用先載入該人格的同步入口(本機沒有它就 load 不了它)。 本機已有同名人格時預設不覆蓋:要蓋加 --force,或用 --persona 拉成第二份。 sync status|init|push|pull|verify --session [--area files|wiki|all --if-due --force --message --owner] verify 會確認「本機 = Gitea」,不一致就以非零結束(形象圖必須同步) 檔案區=高頻活狀態(情緒/短期記憶/心裡話/逐字),每輪對話後背景 push Wiki 區=低頻設定(IDENTITY/SOUL/長期記憶/心智圖/關係圖),固化或改身分時 push push 撞到別台機器時以**本機為準**覆蓋遠端,並回報蓋掉哪些檔案與上一版 sha (sync.json 記帳、sync status 列得出來、下一輪的 Stop hook 會提醒一次) 環境變數: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] 從 bundle 檔匯入 clone --session [--code ] 從 Gitea 匯入(見上) 維護: 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); // 關係圖壞掉/id 不合法:使用者要看得懂的一行,不是 stack trace if (err instanceof pl.RelationsError) die(err.message); throw err; } return 0; } process.exit(await main(process.argv.slice(2)));