Files
persona/scripts/persona.mjs
jiantw83andClaude Opus 5 35c8665db0 feat(情緒表達): emoji 表程度、名字後括號的動作格、鬧彆扭的階梯
只有文字這一個通道,所以情緒表達是排版問題。這批補的是三個沒被用到的位置。

emoji = 強度計(Q9)
- EMOTION_EMOJI 十二情緒各一個,EMOJI_INTENSITY 兩階(💦 / 💦)。
  40-59 本體、60-79 加 💦、80+ 加 💦,低於 40 不顯示(跟破口同一條線)。
- emotionEmojiNow() 讀當下主導情緒算出來,不是自己挑;IDENTITY 的 ## Emoji
  區塊可逐人格覆寫(跟 ## Tells 同一層)。
- 刻意不用重複本體表示程度(不是 😳😳😳)——疊字看起來像洗頁。
- room post 沒帶 --emotion 時自動帶入 emoji,強度不到才退回文字標註。

emoji 開放(Q5/Q10)
- speechLint 不再擋 emoji,超過六個才給 hint。用 linter 偷偷關回去等於撤銷
  「開放」這個決定。
- 唯一的煞車是數字:emojiAudit() 掛在 emotion --audit,算幾則帶符號、平均幾個、
  以及**只有符號沒有句子**那幾則(那才是真的退化)。

名字後面的括號有兩格(Q6)
- 括號原本就有(放情緒標註),動作是加進去的第二格:名字(情緒・動作):內容。
  roomTag() 負責組,兩格都可省略。
- actionLint() 擋三種:超過 12 字、一格塞兩個動作、寫情緒名稱
  ((害羞)是旁白換了個位置)。生理反應算動作:臉紅、手在抖、聲音變小。
- room post --action、remember --behavior(記做了什麼,不是感覺到什麼)。

一輪兩個動作(Q7)
- TELLS_PER_TURN = 2,而且兩個必須來自同一種情緒且有遞進關係。
  鬧彆扭天生是否認+轉移兩個動作,只准一個等於永遠只做得到一半。
  被擋下的形狀是「兩種情緒各演一個」。

鬧彆扭的階梯與女性預設(G3)
- modestyDirective 分三級:41-59 一句帶過、60-74 嘴硬(否認 → 反駁 → 音量掉下來)、
  75+ 惱羞(否認 → 反駁 → 翻臉)。惱羞是換情緒,所以仍然是兩個動作。
- GENDER_MODESTY_DEFAULT.female 62 → 70;MODESTY_SIGNALS 補傲嬌那一路
  (傲嬌、嘴硬、不坦率、口是心非、死不承認、愛面子、鬧彆扭、逞強)。
  界線不變:描述永遠蓋過預設,可以推到 0。

文件:README 加一節「文字通道的另外兩格」與鬧彆扭階梯表、emotions.md 加 emoji 章節、
persona-chat SKILL 與 anti-ai-voice 跟上。selftest 616 → 641 全綠。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 07:46:34 +00:00

2725 lines
134 KiB
JavaScript
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
// persona.mjs — jsc-persona 的人格 / 記憶 / 情緒 / 關係圖 CLI(Node.js,只用內建模組)
//
// 所有子指令都需要 `--session <session_id>`(除了 list / status / gc)。
// session_id 由 SessionStart hook 注入到上下文(PERSONA_SESSION=...),
// hook 會驗證 CLI 帶的 --session 與真實 session 相符,藉此讓「人格鎖」與
// 「跨人格隔離」無法被繞過。
//
// 全域旗標:--json(機器可讀輸出)、--quiet(成功時不輸出,劇場模式用)
import fs from "node:fs";
import path from "node:path";
import process from "node:process";
import 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 <session_id>`(值取自上下文的 `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,正在替 <slug> 收尾」。
* 誰是 sub agent、是哪一型,只有 PreToolUse hook 看得到(`event.agent_type`),
* 而 hook 認得出這支 CLI 靠的是檔名正則——把 `persona.mjs` 複製出去改個名字,
* hook 就完全不表態,`--as-sleeper` 等於對任意人格的完整讀寫權。
*
* 所以唯一可信的證據是 hook 在 session 檔(`.runtime/sessions/<id>.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;
}
/**
* 呼叫者是 hostowner)或以 `--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 <id>\` 再載入 \`${slug}\`` +
"(若只是想讓兩個人格對話,請用 /jsc-persona:persona-invite)。",
);
}
let lock;
try {
lock = pl.acquireLock(slug, session, { cwd: str(flags.cwd) || null, takeover: Boolean(flags.takeover) });
} catch (err) {
die(`${err.message}\n 若確定那個程序已結束,可加 --takeover 接手。`);
}
pl.bindHost(session, slug, { cwd: str(flags.cwd) || null });
// 載入時先把遠端拉回來(別台機器可能動過),衝突就停下來讓使用者決定
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}\`exclusivesession ${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 <slug>`、`--personas <slug,slug>`,或先載入一個人格。");
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 <slug>` 停用:`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(`heartbeathost=${data.host}guests=${Object.keys(data.guests || {}).join(", ") || "(無)"}`);
};
commands.show = ({ flags }) => {
const session = requireSession(flags);
const slug = hostOf(flags, session);
requireMember(slug, session, Boolean(flags["as-guest"]));
const files = { identity: "IDENTITY.md", soul: "SOUL.md", agents: "AGENTS.md", user: "USER.md" };
const what = str(flags.what) || "all";
const chosen = what === "all" ? Object.values(files) : [files[what]].filter(Boolean);
if (!chosen.length) die(`--what 只能是 ${Object.keys(files).join("/")}/all。`);
const out = [];
for (const filename of chosen) {
const file = path.join(pl.personaDir(slug), filename);
if (!fs.existsSync(file)) continue;
// 只給實際內容,模板註解(<!-- ... -->)對人格認知沒幫助
const body = fs.readFileSync(file, "utf8").replace(/<!--[\s\S]*?-->\n?/g, "").trimEnd();
out.push(`===== ${filename} =====\n${body}`);
}
out.push(`===== 狀態 =====\n${pl.emotionBrief(slug)}`);
process.stdout.write(`${out.join("\n\n")}\n`);
};
commands.brief = ({ flags }) => {
const session = requireSession(flags);
const slug = hostOf(flags, session);
requireMember(slug, session, Boolean(flags["as-guest"]));
process.stdout.write(`${pl.turnContext(slug, session, str(flags.query))}\n`);
};
commands.remember = ({ flags }) => {
const session = requireSession(flags);
const slug = hostOf(flags, session);
const [, role] = requireMember(slug, session, Boolean(flags["as-guest"]));
const scope = str(flags.scope) || "short";
if (role === "guest" && scope !== "inbox") {
die("guestsub agent)只能寫入 inbox`--scope inbox --room <room>`。");
}
const text = str(flags.text);
if (!text) die("需要 `--text`。");
const entry = {
ts: pl.nowIso(),
role: str(flags.role) || "user",
text,
topics: csv(flags.topics),
entities: csv(flags.entities),
intent: str(flags.intent),
salience: num(flags.salience, 40),
emotion_deltas: parseDeltas(flags.emotion),
// 行為備註:那一刻**做了什麼**(別過頭、把杯子推過來),不是感覺到什麼。
// 跟聊天室括號裡的動作格同一種東西,所以套同一條檢查。
...(str(flags.behavior) ? { behavior: str(flags.behavior) } : {}),
room: str(flags.room) || null,
session: session.slice(0, 8),
};
if (entry.behavior) {
const issues = pl.actionLint(entry.behavior);
if (issues.length && !flags.force) {
die(`${issues.map(pl.actionLintMessage).join("")}。(真的需要才加 \`--force\`。)`);
}
}
if (scope === "inbox") {
if (!entry.room) die("`--scope inbox` 必須指定 `--room`。");
pl.appendJsonl(pl.inboxPath(slug, entry.room), entry);
ok(`已寫入 \`${slug}\` 的 inboxroom ${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("guestsub 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_seenrecall_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-sleeppersona-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);
// emoji 沒有上限(使用者拍板),所以唯一的煞車是把數字擺出來
const emoji = pl.emojiAudit(slug);
const now = pl.emotionEmojiNow(slug);
emit({ persona: slug, audit, trend, emoji, emoji_now: now }, 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} 輪)` : " 還沒有足夠的輪次算走向。",
` emoji:最近 ${emoji.rows} 則裡 ${emoji.with_emoji} 則帶符號,平均 ${emoji.average} 個` +
(emoji.worst ? `,最多的那則 ${emoji.worst.count} 個` : ""),
emoji.symbol_only
? ` ⚠ 有 ${emoji.symbol_only} 則幾乎只有符號沒有句子——符號是加上去的,不是用來代替講話`
: ` 現在的情緒符號:${now ? `${now.emoji}${now.zh} ${now.level}` : "強度不到 40,不顯示"}`,
]);
return;
}
if (flags.apply && role === "guest") die("guestsub 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;
}
// 使用者在關係圖裡是誰 → 每輪 `<persona-context>` 就能算出該用哪一層語氣說話。
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_idsentity_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 matterjsonl 的值不保證是陣列(`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_idsentity_ids${ambiguousRows.length}):`);
lines.push(...(ambiguousRows.length
? ambiguousRows.map(([n, ids]) => ` - ${n}${ids.join("")}(改掉其中一個的名字或 id`)
: [" (無)"]));
lines.push(`about_idsentity_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 <slug>` 或 `--guests <slug,slug>`。");
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 <slug>`。");
const info = (data.guests || {})[guest];
if (!info) die(`\`${guest}\` 不在本 session 的 guest 名單。`);
delete data.guests[guest];
const room = str(flags.room) || info.room;
pl.dropGuestLease(guest, session, room);
for (const agentId 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 <persona>`。");
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)) {
// 情緒格自動帶入:強度夠就用 emoji(種類表情緒、數量表程度),
// 不到門檻才退回文字標註——低強度的情緒本來就不該在括號裡宣告。
const now = pl.emotionEmojiNow(speaker);
emotion = now
? now.emoji
: pl.dominant(pl.decayEmotion(pl.loadEmotion(speaker)), 2)
.map(({ key, level }) => `${pl.EMOTIONS[key].zh}${Math.round(level)}`)
.join("/");
}
// 動作格:括號的第二格,只放看得見或聽得見的動作(臉紅、別過頭、手在抖)
const actionText = str(flags.action);
const actionIssues = pl.actionLint(actionText);
if (actionIssues.length && !flags.force) {
die(`${actionIssues.map(pl.actionLintMessage).join("")}。(真的需要才加 \`--force\`。)`);
}
const entry = pl.roomPost(room, speaker, text, { emotion, action: actionText, 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}` +
`${actionText ? `/動作 ${actionText}` : ""})。` +
(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 <id>`",
]);
};
/** 從 bundle 匯入人格。可用 `--persona <新 slug>` 換名匯入(同一個人格要並存兩份時很有用)。 */
commands.import = ({ flags }) => {
const session = requireSession(flags);
const file = str(flags.file);
if (!file) die("需要 `--file <bundle.json|.gz>`。");
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 <id>`(本機還沒有的那些)",
]);
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)。**建立人格並補齊 IDENTITYSOUL 之後再跑**
* 這樣配色與字母才會對得上最終的身分。同一個人格永遠得到同一張圖。
*/
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 <fandom 子網域>` 與 `--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 BodyCharacter 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 只能是 162048。");
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 <subcommand> [options]
人格與鎖:
create --romaji <英文名> --session <id> [--persona <目錄名> --code <ASUNA-01> --name --creature --gender
--vibe --emoji --avatar --tells --baseline --origin --work --no-gitea --public]
--tells "anger=不講話;句子只剩動詞|羞愧=摸後頸" 情緒破口,寫進 IDENTITY.md 的
## Tells 區塊(蓋掉全域預設;沒帶就沿用預設)
人格編號 = 英文名全大寫 + 兩位索引(同名才遞增),也是 Gitea 存取庫的名稱;
不指定 --persona 就用編號當目錄名。
load --persona <slug> --session <id> [--takeover]
release --session <id> [--persona <slug>]
list 列出人格與鎖狀態
status [--persona <slug>] [--session <id>]
heartbeat --session <id> 續租
show --session <id> [--persona] [--what identity|soul|agents|user|all]
brief --session <id> [--query <text>] 輸出人格上下文
記憶:
remember --session <id> --text <t> [--role --topics --entities --intent --salience --emotion --behavior
--scope short|inbox --room]
--behavior 那一刻**做了什麼**(別過頭、把手縮回來),不是感覺到什麼。
寫情緒名稱會被擋——那一格是動作。
recall --session <id> --query <q> [--limit]
think --session <id> --text <心裡話> [--kind infer|plan|feel|doubt --room] 只回報「心想 N 句」,不回顯內容
said check|list --session <id> [--text <要說的話> --minutes --threshold --record --limit]
sleep --session <id> [--persona <slug>] [--personas <slug,slug>] [--json] [--release] [--no-gitea]
睡眠收尾(機械性):關係時間戳→裁短期→收思維導圖→情緒衰減 8 小時→重建索引
→修剪 said→壓縮舊 journal→寫 sleep.json→Gitea 兩區 push+驗證。
預設保留載入鎖(--release 才收工)。--json 只回報「睡完了沒、哪一步出錯」。
candidates --session <id> [--reviewed all|<#N,#N> [--until <ISO>]]
列出達到「短期→長期」條件的候選與依據(每筆前面的 #N 就是它的編號)。
--reviewed 把判斷過的短期記憶標掉(看過決定不記的用這個)——
候選數字只有這裡跟 consolidate --from-short 會往下扣,
判斷完不標,同一批下一輪又會被算成候選。
consolidate --session <id> --name <n> --body <b> [--type --about --topics --salience --strength --emotion
--when --where --mood --gist --detail --rules --source --forget --from-short <#N,#N>]
內文會切成「主旨/細節」兩層:衰減先吃細節,主旨最後才掉。
--from-short 標記這則是從哪幾筆短期記憶長出來的(順便標成判斷過)。
prune / reindex --session <id>
migrate --session <id> [--all] [--dry-run] [--json]
長期記憶升格式(補 strength、切主旨/細節)。--all 掃所有人格,只回報數量不印內容。
loop add|done|drop|touch|sweep|list --session <id> [--text --kind question|promise|topic|mine --id --note]
懸著的事(同時最多 5 條):他沒回答的問題/他答應要做的事/被打斷的話題/我想問但沒問的。
7 天沒進展自動收掉並留一則「沒下文」。mine 那種同時是自我議程的來源。
probe add|confirm|deny|audit --session <id> [--text --memory --note --id --limit]
模糊記憶的試探紀錄:可以說不確定、可以問,**不可以斷言**(add 會擋掉沒問號的句子)。
audit 看「試探幾次、被否認幾次」——這是開放這條界線唯一的煞車。
情緒與圖:
emotion --session <id> [--apply joy=+10,...] [--baseline ...] [--trigger <why>]
[--from <對象>] 這件事是誰引起的:親近度會放大/縮小 delta
[--read "<對方說的話>"] 只讀情緒訊號與該怎麼接,不改狀態
[--audit [--limit 20]] 最近幾輪的 delta 是不是只往一邊倒+對方的走向
套用時會飽和、受單輪預算限制(|delta| 總和 ≤ 60),再經交互抑制、慣性、
當日底色與疲勞調整——實際生效的值看 last_trigger.scaled
mindmap show|thread|list --session <id> [--topic <t>] [--force]
relation node|edge|render|show --session <id> [--name --id --kind --bond --closeness --trust --note --tags --from --to --label --affinity]
relation style --session <id> --name <who> [--facet 稱呼 --value 親愛的 --except anger>=40 --since --clear]
relation speaker --session <id> --name <who> | --clear (使用者在關係圖裡是誰 → 決定語氣層)
relation doctor --session <id> [--json] 記憶裡的人名對不對得上關係圖節點:對不到的人名、
同名歧義、指到不存在節點的 id、沒人提到的節點。
**唯讀**(一個檔案都不改);graph.json 壞掉時以非零結束
多人格對話:
invite --session <id> --guest <slug> [--guests <slug,slug>] [--host --room --topic] (自動開啟劇場模式)
leave --session <id> --guest <slug> [--room]
room post|read|script|floor|list|theater --session <id> [--room --as --to --barge-in --text --text-file
--emotion --action --limit --on --off --with-meta]
(post 會擋下「短時間內近似重複」與超過三句的發言;例外用 --allow-repeat / --force
名字後面的括號有兩格:名字(情緒・動作):內容
--emotion 情緒標註(沒帶就用當下情緒算出來的 emoji,強度不到 40 就退回文字)
--action 看得見或聽得見的動作(臉紅、別過頭、手在抖);一個動作、12 字內,
寫情緒名稱會被擋(那是前面那一格)。括號裡的動作**算進一輪兩個動作的額度**。
--to <slug|名字|all> 這句話對誰說。指名 → 進入一對一,旁人不該接話(要插話得帶
--barge-in "<理由>");--to all → 把話題開回全場。
floor 現在誰對誰在講、該誰接話、誰先安靜、誰很久沒開口
圖示(建立人格並補齊資料後跑):
icon search|measure|faces|headshot|cutout|generate|show --session <id>
search --wiki <fandom 子網域> --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 <YYYY-MM-DD>]
[--from-cutout] [--zoom 2.15] [--pick <索引>]
帶 --from-cutout → 用去背好的官方原圖合成(頭肩構圖,最忠於原作)。
否則依人格資料重新繪製;沒帶 --palette → 徽章樣式。
show 看目前的樣式、配色、特徵與來源
產出 icon.svg + icon.png,設為 Gitea 存取庫頭像並同步到 Wiki 區。
編號與 Gitea(存取庫名稱 = 人格編號):
code show|assign|next --session <id> [--romaji <英文名> --code <ASUNA-01> --rename --force --public --owner]
發新編號前會先問遠端有哪些編號(避免換機器撞號);Gitea 連不上就只看本機並明講
clone --session <id> [--code <ASUNA-01>|<ASUNA-01>] [--persona <目錄名> --owner --force --load --json]
把一個**本機還沒有**的人格從 Gitea 整個拉回來(換機器接續同一個人格的第一步)。
不帶 --code 就列出遠端有哪些人格、哪些本機還沒有。
唯一不用先載入該人格的同步入口(本機沒有它就 load 不了它)。
本機已有同名人格時預設不覆蓋:要蓋加 --force,或用 --persona 拉成第二份。
sync status|init|push|pull|verify --session <id> [--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 <id> [--out <檔案> --with-journal --gzip --force] 匯出目前載入的人格
import --session <id> --file <檔案> [--persona <新 slug> --force --load] 從 bundle 檔匯入
clone --session <id> [--code <ASUNA-01>] 從 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)));