Files
persona/scripts/persona.mjs
T
jiantw83andClaude Opus 5 8af0d5c387 feat(關係): 未修復的裂痕,親近度高不等於沒事
closeness/trust 是純量:吵完架把親近度調低,看起來像「本來就沒那麼近」,
而不是「很近,但上次那件事還沒和好」。後者才是真的關係——兩個人可以很親近,
同時之間卡著一件事,那兩件事各自獨立,壓不進同一個數字裡。

節點多一個 rifts 陣列(一個人可以同時卡著幾件事),relation rift 管它。

語氣上:riftBrief() 在 toneDirective 裡明講「親近度高不等於沒事,這件事解決之前
語氣裡要留一點沒散掉的東西」,並要求不必一直提它、但不能像沒發生過。

回想上:裂痕帶著那則長期記憶的名字(--memory),人格要細節自己 recall,
不必等使用者提起那件事——沒和好的事本來就會自己浮上來。

--heal 是寫上 healed_at,不是刪掉那一筆:和好過跟沒發生過不一樣,之後回頭看
要分得出來。列出來時和好過的用 ✔ 標。

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

3371 lines
169 KiB
JavaScript
Raw 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",
// 故事匯入:`--stdin` 吃管線進來的候選 JSON、`--accept-exact` 整批收下對得上節點的人名候選。
// `--apply`novel merge**故意不列**`emotion --apply joy=+10` 也叫這個名字,
// 列進來會讓那個旗標變成布林,情緒就再也套不進去(踩過一次)。
"stdin", "accept-exact",
]);
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);
// 行為備註與 `voice/reactions.md` 是**同一批資料**(事件 → 他做了什麼),不是各存一份:
// 記憶留時間軸(那一天發生過這件事),語氣層供每輪注入(他遇到這種事會怎麼反應)。
// 只有**人格自己的**行為才進語氣層——對方做了什麼是記憶,不是他的反應方式。
if (entry.behavior && entry.role === "persona") {
const [top] = Object.entries(entry.emotion_deltas)
.filter(([, d]) => Number(d) > 0)
.sort((a, b) => Number(b[1]) - Number(a[1]));
pl.addVoiceReaction(slug, {
event: text,
action: entry.behavior,
emotion: top ? pl.EMOTIONS[top[0]]?.zh || "" : "",
});
}
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`);
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 都用這個型別)
// habit:**我們之間的慣例**(「他習慣先講結論」「我們之間不說再見」)。
// 其餘型別都是宣告性記憶(發生過什麼、他偏好什麼),慣例是程序性的——
// 它不是「記得的事」,是「每次都這樣做的事」,所以另立一格並且每輪注入。
const VALID_TYPES = [
"fact", "preference", "event", "promise", "relationship", "insight", "boundary", "canon", "diary", "habit",
];
if (!VALID_TYPES.includes(type)) die(`--type 只能是 ${VALID_TYPES.join("/")}。`);
const about = csv(flags.about).length ? csv(flags.about) : ["user"];
// front matter 的組裝在 `writeLongTermMemory``novel write` 也走同一套,
// 欄位順序與預設值只能有一份(見那個函式的說明)。
pl.writeLongTermMemory(slug, {
name,
// 原本的 --name(沒被 slugify 吃掉的那個)。下次撞名時就是靠 title 認出「不是同一則」。
title: rawName,
type,
body,
// `--gist` 明寫時,`--detail` 沒給就是**沒有細節**(見 writeLongTermMemory
gist: str(flags.gist) || null,
detail: str(flags.detail),
about,
topics: csv(flags.topics),
salience: num(flags.salience, 60),
// 沒帶 --strength 就交給既有檔案裡長出來的強度(null = 沿用)
strength: num(flags.strength, null),
emotion: str(flags.emotion),
when: str(flags.when),
where: str(flags.where),
mood: str(flags.mood),
rules: str(flags.rules),
source: str(flags.source),
// 自傳章節(`memory/chapters/`)的段名,**不是** `novel skip --chapter` 那個書的章節。
// 省略就用當下進行中的那一段(見 writeLongTermMemory)。
chapter: str(flags.chapter),
});
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`。");
};
// --------------------------------------------------------------------------- //
// chapter:自傳章節(`memory/chapters/`)——長期記憶與心智圖之間的時期層
//
// 人講自己的過去是按段講的(「在 SAO 那兩年」),不是按事件清單、也不是按概念圖。
// --------------------------------------------------------------------------- //
commands.chapter = ({ flags, positional }) => {
const session = requireSession(flags);
const slug = hostOf(flags, session);
const action = (positional[0] || "list").toLowerCase();
if (action === "list") {
requireMember(slug, session, Boolean(flags["as-guest"]));
const rows = pl.loadChapters(slug);
const now = pl.currentChapter(slug);
emit({ persona: slug, chapters: rows, current: now?.name || null }, flags.json, [
`\`${slug}\` 的自傳章節(${rows.length}${now ? `,現在在「${now.title}」` : ",目前沒有進行中的段"}):`,
...rows.map((c) => ` - ${c.title}${c.from}${c.to || "現在"}` +
`|記憶 ${pl.chapterMemories(slug, c.name).length}${c.open ? "**進行中**" : ""}`),
...(rows.length ? [] : [" (還沒有。用 `chapter add --name <段名> --body <這段是什麼>` 開一段)"]),
]);
return;
}
if (action === "show") {
requireMember(slug, session, Boolean(flags["as-guest"]));
const name = str(flags.name) || positional[1];
if (!name) die("`chapter show` 需要 `--name`。");
const chapter = pl.loadChapters(slug).find((c) => c.name === pl.slugify(name));
if (!chapter) die(`找不到章節 \`${name}\`(用 \`chapter list\` 看有哪些)。`);
const mems = pl.chapterMemories(slug, chapter.name);
emit({ chapter, memories: mems.map((m) => m._name) }, flags.json, [
`**${chapter.title}**${chapter.from}${chapter.to || "現在"}`,
chapter.gist || "(這一段還沒寫說明)",
`這一段裡的長期記憶 ${mems.length} 則:`,
...mems.map((m) => ` - ${m.title || m._name}${m.type}`),
]);
return;
}
requireOwner(slug, session);
if (action === "add") {
const name = str(flags.name);
if (!name) die("`chapter add` 需要 `--name`(例如「在 SAO 那兩年」)。");
const res = pl.writeChapter(slug, {
name,
title: str(flags.title) || name,
from: str(flags.from),
to: str(flags.to),
body: str(flags.body),
});
if (!res) die("`--name` 取不出可用的段名(全是符號?)。");
ok(`章節「${str(flags.title) || name}」寫好了:${res.file}`);
pushWikiLater(slug, session, flags); // memory/chapters/ 屬於 Wiki 區(低頻)
return;
}
if (action === "close") {
const name = str(flags.name) || positional[1];
if (!name) die("`chapter close` 需要 `--name`。");
const res = pl.closeChapter(slug, name, str(flags.at) || undefined);
if (!res) die(`找不到章節 \`${name}\`。`);
ok(`「${name}」這一段收掉了。開新的一段用 \`chapter add\`。`);
pushWikiLater(slug, session, flags);
return;
}
die("用法:`chapter add|list|show|close`。");
};
// --------------------------------------------------------------------------- //
// voice:語氣樣本與情緒反應(`voice/`)
//
// 這一層匯入流程可以直接寫,個性(SOUL.md)不行——權限界線靠檔案分開,不靠自律。
// --------------------------------------------------------------------------- //
commands.voice = ({ flags, positional }) => {
const session = requireSession(flags);
const slug = hostOf(flags, session);
const action = (positional[0] || "show").toLowerCase();
const kind = str(flags.kind) || "sample";
if (!(kind in pl.VOICE_KINDS)) {
die(`--kind 只能是 ${Object.keys(pl.VOICE_KINDS).join("/")}` +
"sample=他自己講過的原句,reaction=事件對上他做了什麼,idiolect=語域的四格)。");
}
const render = (entry) => {
if (kind === "idiolect") return `${entry.facet}${entry.value}`;
if (kind === "reaction") {
return `${entry.event}${entry.action || "(沒記)"}${entry.emotion ? `${entry.emotion}` : ""}`;
}
const at = [entry.to ? `對 ${entry.to}` : "", entry.scene].filter(Boolean).join("");
return `「${entry.text}${at ? `${at}` : ""}`;
};
if (action === "show" || action === "list") {
requireMember(slug, session, Boolean(flags["as-guest"]));
const voice = pl.loadVoice(slug);
if (action === "show") {
const brief = pl.voiceBrief(slug, {
limit: num(flags.limit, pl.VOICE_INJECT_MAX),
to: str(flags.to) || null,
hint: str(flags.scene) || str(flags.text),
});
emit({ persona: slug, brief, samples: voice.samples.length, reactions: voice.reactions.length }, flags.json, [
`\`${slug}\` 這一輪會注入的語氣(最多 ${pl.VOICE_INJECT_MAX} 條——全注入會變成照抄舊台詞):`,
brief || " (語氣檔還是空的:先用 `voice add` 累積他自己講過的原句)",
]);
return;
}
const rows = kind === "sample" ? voice.samples : kind === "reaction" ? voice.reactions : voice.idiolect;
const limit = num(flags.limit, 20);
const shown = Number.isFinite(limit) && limit > 0 ? rows.slice(-limit) : rows;
emit({ persona: slug, kind, total: rows.length, entries: shown }, flags.json, [
`\`${slug}\` 的${pl.VOICE_LABELS[kind]} ${rows.length} 條(${pl.voicePath(slug, kind)}):`,
...shown.map((entry) => ` - ${render(entry)}`),
...(rows.length ? [] : [" (還沒有。手改那個檔也可以:一行一筆,行首 `- `)"]),
]);
return;
}
requireOwner(slug, session);
if (action === "add") {
const res = kind === "sample"
? pl.addVoiceSample(slug, { text: str(flags.text), to: str(flags.to), scene: str(flags.scene) })
: kind === "reaction"
? pl.addVoiceReaction(slug, { event: str(flags.event), action: str(flags.action), emotion: str(flags.emotion) })
: pl.addVoiceIdiolect(slug, { facet: str(flags.facet), value: str(flags.value) });
if (!res) {
die(kind === "sample"
? "需要 `--text`(他自己講過的原句,照抄不要改寫)。"
: kind === "reaction"
? "需要 `--event`(發生了什麼事);`--action` 寫他做了什麼,不要寫他感覺到什麼。"
: `需要 \`--facet\`(只能是 ${pl.IDIOLECT_FACETS.join("")})與 \`--value\`。` +
"「不說」寫的是**用詞**(這個人不會用的字),不是話題禁忌。");
}
if (!res.added) {
ok(`已經有一模一樣的一條了,沒有重複寫入:${res.line}`);
return;
}
ok(`${pl.VOICE_LABELS[kind]}加一條:${res.line}`);
pushWikiLater(slug, session, flags); // voice/ 屬於 Wiki 區(低頻設定)
return;
}
die("用法:`voice add|list|show`。");
};
// --------------------------------------------------------------------------- //
// novel:故事匯入的機械那半
//
// 判斷的那半留在 skill(在場與知情、切場景、第一人稱摘要、個性校正提案),
// 這裡只做機械的:抽人名候選、正名、跳過紀錄、欄位驗證、去重合併、配額重定標、
// 批次寫入。一律不改 SOUL.md,也不套情緒 delta——情緒只固化進欄位(TODO 2.4)。
// --------------------------------------------------------------------------- //
commands.novel = ({ flags, positional }) => {
const session = requireSession(flags);
const slug = hostOf(flags, session);
const action = (positional[0] || "report").toLowerCase();
const sub = (positional[1] || "").toLowerCase();
// `init` 的 --work 是書名,其他子指令的 --work 是 init 定下來的 work-slug
const openWork = () => {
const key = str(flags.work);
if (!key) die("需要 `--work <work-slug>``novel init` 建立時回報的那個 slug)。");
const work = pl.loadNovelWork(slug, key);
if (!work) {
const have = pl.listNovelWorks(slug).map((w) => w.slug);
die(`找不到作品工作區 \`${key}\`` +
`${have.length ? `(現有:${have.join("、")}` : "(還沒有任何工作區)"}。` +
"\n 先 `novel init --work \"<書名>\"`。");
}
return work;
};
const candidatesOf = (work) => pl.readJsonl(pl.novelCandidatesPath(slug, work.slug));
if (action === "report") {
requireMember(slug, session, Boolean(flags["as-guest"]));
const work = openWork();
const rows = candidatesOf(work);
const skipped = pl.readJsonl(pl.novelSkippedPath(slug, work.slug));
const book = pl.loadNovelNameBook(slug, work.slug);
const proposals = pl.loadNovelProposals(slug, work.slug);
const usage = pl.novelQuotaUsage(rows, work.quota);
const unwritten = rows.filter((r) => !r.written_at);
const duplicates = rows.length - pl.mergeNovelCandidates(rows).rows.length;
emit({
persona: slug,
work: work.work,
slug: work.slug,
candidates: rows.length,
unwritten: unwritten.length,
duplicates,
skipped: skipped.length,
names: Object.keys(book.map).length,
ignored: book.ignore.length,
pending_names: proposals.length,
quota: usage,
baseline: work.baseline ?? null,
}, flags.json, [
`《${work.work}》(\`${work.slug}\`)匯入現況:`,
` 記憶候選 ${rows.length} 則|同名還沒合併 ${duplicates} 則|還沒寫入長期記憶 ${unwritten.length} 則`,
` 跳過 ${skipped.length} 章|正名表 ${Object.keys(book.map).length} 條|` +
`標成不是人名 ${book.ignore.length} 個|還沒確認的人名候選 ${proposals.length} 個`,
...usage.map((u) => ` 配額 ${u.label}${u.used}/${u.limit}` +
`${u.over ? ` ⚠ 超出 ${u.over} 則 → 跑 \`novel merge\`` : ""}`),
work.baseline
? ` 情緒基線上次調整:${work.baseline.at}${work.baseline.forced ? "--force 蓋過門檻)" : ""}`
: " 情緒基線還沒動過。",
]);
return;
}
requireOwner(slug, session);
if (action === "init") {
const title = str(flags.work);
if (!title) die("需要 `--work \"<書名>\"`。");
const quota = flags.quota ? pl.parseNovelQuota(str(flags.quota)) : null;
if (flags.quota && !quota) die("`--quota` 看不懂:格式是 `90=5,80=20`(顯著度門檻=最多幾則)。");
const key = pl.slugify(str(flags.slug) || title);
const before = pl.loadNovelWork(slug, key);
if (before && !flags.force) {
die(`工作區 \`${key}\` 已經存在(《${before.work}》,${before.created_at})。` +
"\n 要改配額就重跑一次並加 --force(候選與跳過紀錄不會動)。");
}
const work = pl.initNovelWork(slug, { work: title, workSlug: key, quota });
emit({ persona: slug, work }, flags.json, [
`✔ 工作區建好了:${pl.novelWorkDir(slug, key)}`,
` 作品《${work.work}》 slug \`${work.slug}\`(之後的 --work 都用這個)`,
` 顯著度配額:${pl.novelQuotaUsage([], work.quota).map((u) => `${u.label} 最多 ${u.limit} 則`).join("、")}`,
" 下一步:`novel scan --work <slug> --file <章節檔>` 抽人名候選,再 `novel name review` 確認。",
]);
return;
}
if (action === "scan") {
const work = openWork();
const files = [...csv(flags.file), ...positional.slice(1)];
if (str(flags.dir)) {
let entries = [];
try {
entries = fs.readdirSync(str(flags.dir), { withFileTypes: true });
} catch (err) {
die(`讀不到 --dir \`${str(flags.dir)}\`${String(err.message).slice(0, 120)}`);
}
for (const entry of entries.filter((e) => e.isFile()).sort((a, b) => a.name.localeCompare(b.name))) {
files.push(path.join(str(flags.dir), entry.name));
}
}
if (!files.length) die("需要 `--file <章節檔[,第二檔]>` 或 `--dir <目錄>`。");
const texts = [];
for (const file of files) {
try {
texts.push(fs.readFileSync(file, "utf8"));
} catch (err) {
die(`讀不到 \`${file}\`${String(err.message).slice(0, 120)}`);
}
}
const res = pl.scanNovelNames(slug, work.slug, texts);
emit({ persona: slug, work: work.slug, files: files.length, fresh: res.fresh.length, candidates: res.candidates }, flags.json, [
`掃了 ${files.length} 個章節檔:新增 ${res.fresh.length} 個人名候選,` +
`待確認共 ${res.candidates.length} 個(${pl.novelProposedPath(slug, work.slug)})。`,
...res.candidates.slice(0, 30).map((c) => ` [${c.confidence}] ${c.token}${c.count} 次)` +
`${c.guess ? ` → 猜是「${c.guess}」` : ""}`),
...(res.candidates.length > 30 ? [` (還有 ${res.candidates.length - 30} 個,用 \`novel name review\` 看全部)`] : []),
" 已經在正名表或 ignore 裡的不再列出——第二次掃只會看到新出現的。",
" 下一步:`novel name review`(確認)/`novel name confirm --accept-exact`(整批收下對得上的)。",
]);
return;
}
if (action === "name") {
const work = openWork();
if (sub === "add" || sub === "confirm") {
if (flags["accept-exact"]) {
const done = pl.acceptExactNovelNames(slug, work.slug);
emit({ persona: slug, work: work.slug, confirmed: done }, flags.json, [
`✔ 收下 ${done.length} 條 exact 候選(等於關係節點的名字,沒有判斷空間):`,
...done.map((d) => ` ${d.from}${d.to}`),
...(done.length ? [] : [" (沒有 exact 候選;fuzzy 與 unknown 要一條一條看)"]),
]);
return;
}
const from = str(flags.from);
const to = str(flags.to);
if (!from || !to) die("需要 `--from \"<書裡的名字>\"` 與 `--to \"<關係節點 name>\"`(或 `--accept-exact`)。");
const res = sub === "confirm"
? pl.confirmNovelName(slug, work.slug, { from, to })
: pl.addNovelName(slug, work.slug, { from, to });
if (!res) {
die(`關係圖裡找不到 \`${to}\`——記憶的 \`about\` 對不上節點就等於沒記到人身上。\n` +
` 先建節點:\`relation node --name "${to}" --kind human --session <id>\`` +
"(親近度查 persona-relation/reference/closeness.md),再回來正名。");
}
ok(`正名表:${res.from}${res.to}(節點 \`${res.node_id}\``);
return;
}
if (sub === "ignore" || sub === "reject") {
const token = str(flags.token) || str(flags.from);
if (!token) die("需要 `--token \"<候選詞>\"`。");
if (sub === "ignore") {
if (!pl.ignoreNovelName(slug, work.slug, token)) die("`--token` 是空的。");
ok(`\`${token}\` 標成不是人名了(進 ignore,下次掃不會再列)。`);
return;
}
const removed = pl.rejectNovelName(slug, work.slug, token);
ok(removed
? `\`${token}\` 從候選移除了(沒進 ignore:下次掃還會再出現)。`
: `候選裡沒有 \`${token}\`,什麼都沒動。`);
return;
}
if (sub === "review") {
const rows = pl.loadNovelProposals(slug, work.slug);
const groups = ["exact", "alias", "fuzzy", "unknown"];
const lines = [`《${work.work}》還沒確認的人名候選 ${rows.length} 個:`];
for (const level of groups) {
const group = rows.filter((r) => r.confidence === level);
if (!group.length) continue;
lines.push(` ${level}${group.length} 個):`);
for (const row of group) {
lines.push(` ${row.token}${row.count} 次)${row.guess ? ` → 猜是「${row.guess}」` : ""}`);
for (const sample of (row.samples || []).slice(0, 2)) lines.push(` ⋯${sample}⋯`);
}
if (level === "exact") lines.push(" → 這一組可以 `novel name confirm --accept-exact` 整批收下。");
if (level === "unknown") {
lines.push(" → 對不上任何節點。這是新人物就先 `relation node --name \"<會被說出口的完整稱呼>\"`" +
"再回來 confirm;不是人名就 `novel name ignore --token \"<詞>\"`。");
}
}
if (!rows.length) lines.push(" (沒有待確認的。要抽新的就 `novel scan --file <章節檔>`。)");
emit({ persona: slug, work: work.slug, pending: rows }, flags.json, lines);
return;
}
if (sub === "list" || !sub) {
const book = pl.loadNovelNameBook(slug, work.slug);
const rows = Object.entries(book.map);
emit({ persona: slug, work: work.slug, map: book.map, ignore: book.ignore }, flags.json, [
`《${work.work}》的正名表 ${rows.length} 條(${pl.novelNamesPath(slug, work.slug)}):`,
...rows.map(([from, to]) => ` ${from}${to}`),
...(rows.length ? [] : [" (還是空的:`novel scan` 抽候選,`novel name review` 確認)"]),
...(book.ignore.length ? [` 標成不是人名的:${book.ignore.join("、")}`] : []),
]);
return;
}
die("用法:`novel name add|list|review|confirm|ignore|reject`。");
}
if (action === "skip") {
const work = openWork();
if (sub === "list") {
const rows = pl.readJsonl(pl.novelSkippedPath(slug, work.slug));
emit({ persona: slug, work: work.slug, skipped: rows }, flags.json, [
`《${work.work}》跳過的章節 ${rows.length} 章:`,
...rows.map((r) => ` ${r.chapter}——${r.reason}${String(r.at).slice(0, 10)}`),
...(rows.length ? [] : [" (還沒有跳過紀錄)"]),
]);
return;
}
const entry = pl.addNovelSkip(slug, work.slug, { chapter: str(flags.chapter), reason: str(flags.reason) });
if (!entry) die("需要 `--chapter \"<章節>\"` 與 `--reason \"<理由>\"`——靜默跳過會漏章,事後查不出來。");
ok(`記下跳過:${entry.chapter}——${entry.reason}`);
return;
}
if (action === "candidate") {
const work = openWork();
if (sub !== "add") die("用法:`novel candidate add --work <slug> --file <候選 JSON>`(或 `--stdin`)。");
let raw = "";
if (flags.stdin) {
try {
raw = fs.readFileSync(0, "utf8");
} catch {
raw = "";
}
} else if (str(flags.file)) {
try {
raw = fs.readFileSync(str(flags.file), "utf8");
} catch (err) {
die(`讀不到 \`${str(flags.file)}\`${String(err.message).slice(0, 120)}`);
}
} else {
die("需要 `--file <候選 JSON 檔>` 或 `--stdin`。");
}
let parsed;
try {
parsed = JSON.parse(raw);
} catch (err) {
die(`候選 JSON 解析不了:${String(err.message).slice(0, 160)}`);
}
const rows = Array.isArray(parsed) ? parsed : [parsed];
const res = pl.addNovelCandidates(slug, work.slug, rows);
const lines = [`《${work.work}》收下 ${res.added.length}/${rows.length} 則記憶候選。`];
for (const fail of res.failed) {
lines.push(`✖ 第 ${fail.index + 1} 筆(${fail.name})沒過:`);
for (const err of fail.errors) lines.push(` ${err.field}${err.reason}`);
}
if (res.failed.length) lines.push(" 沒過的**沒有寫進去**:修好那幾筆再送一次(其餘已經收下,不會重複)。");
emit({ persona: slug, work: work.slug, added: res.added.length, failed: res.failed }, flags.json, lines);
// 驗不過的不能只印一行就當成功——呼叫端(skill)要靠結束碼知道這一章沒收完
if (res.failed.length) process.exit(1);
return;
}
if (action === "merge") {
const work = openWork();
const file = pl.novelCandidatesPath(slug, work.slug);
const rows = pl.readJsonl(file);
if (!rows.length) die("這個工作區還沒有記憶候選(先 `novel candidate add`)。");
const { rows: dedup, merged } = pl.mergeNovelCandidates(rows);
const { rows: final, demoted } = pl.requotaNovelCandidates(dedup, work.quota);
const usage = pl.novelQuotaUsage(final, work.quota);
const apply = Boolean(flags.apply);
// 合併會少列、重定標只改欄位——長度沒變就不寫檔,所以一律帶 force
if (apply) pl.rewriteJsonl(file, () => final, { force: true });
emit({
persona: slug, work: work.slug, applied: apply, before: rows.length, after: final.length, merged, demoted, quota: usage,
}, flags.json, [
apply ? `《${work.work}》已收斂並寫回:` : `《${work.work}》收斂試跑(**沒有寫檔**,要落地加 --apply):`,
` ${rows.length} 則 → ${final.length} 則(同名合併掉 ${merged} 則)`,
` 依配額壓下來 ${demoted.length} 則:`,
...demoted.slice(0, 12).map((d) => ` ${d.name}${d.from}${d.to}${d.tier} 那一級滿了)`),
...(demoted.length > 12 ? [` (還有 ${demoted.length - 12} 則)`] : []),
...usage.map((u) => ` 配額 ${u.label}${u.used}/${u.limit}`),
" 合併規則:salience 取高、first_seen 取最早、last_seen 取最晚、topics/about 取聯集、quote 留最長的。",
]);
return;
}
if (action === "write") {
const work = openWork();
const file = pl.novelCandidatesPath(slug, work.slug);
const rows = pl.readJsonl(file);
const pending = rows.filter((r) => !r.written_at);
const limit = num(flags.limit, null);
const batch = Number.isFinite(limit) ? pending.slice(0, Math.max(0, limit)) : pending;
const dryRun = Boolean(flags["dry-run"]);
const written = [];
const skipped = [];
for (const row of batch) {
const name = pl.slugify(row.name);
const target = path.join(pl.longTermDir(slug), `${name}.md`);
const title = row.title || row.name;
// 兩則不同的候選 slugify 成同一個檔名時**不覆蓋**:那會靜默換掉別則記憶的內文
let prior = null;
if (fs.existsSync(target)) [prior] = pl.parseFrontMatter(fs.readFileSync(target, "utf8"));
const priorTitle = prior ? (prior.title ?? prior.name ?? null) : null;
if (priorTitle !== null && priorTitle !== title && !flags.force) {
skipped.push({ name, reason: `\`${name}.md\` 已經是「${priorTitle}」的記憶(要蓋過加 --force` });
continue;
}
if (!dryRun) {
pl.writeLongTermMemory(slug, {
name,
title,
type: row.type,
// 他自己說過的原句接在內文後面,不另開 front matter 欄位——這樣它會跟著
// 「細節」一起衰減(記得吵過但忘了他原話是對的),而不是永遠清晰地掛在標頭上。
// 語氣那一份另外抄進 `voice/samples.md`,兩邊用途不同。
body: row.quote ? `${row.body}\n\n他當時說:「${row.quote}」` : row.body,
about: row.about,
topics: row.topics,
salience: row.salience,
// 情緒只固化進欄位:這裡一次都不呼叫 applyEmotion。逐則重放 delta
// 等於讓最後一章決定他的性格(TODO 2.4/3B.2),基線要走 `novel baseline`。
emotion: row.emotion,
when: row.when,
where: row.where,
mood: row.mood,
rules: "novel-import",
source: row.source || [work.work, row.chapter].filter(Boolean).join(""),
// **劇情內時間不可以寫進 first_seenlast_seen。** 那兩欄是記憶的
// 新鮮度時鐘(`memoryStrength()` 拿 last_seen 算衰減),劇情日期塞進去
// 等於宣告「這則記憶上次被想起是兩年前」——2024 年的劇情匯進來的那一秒
// 就是模糊 0%,而且掉到 0% 之後 touchRecall 不再更新它,永遠回不來。
// 實測 40 到 79 分的 event 全部出生即死,只有 canon 與 80 分以上靠保護清單活著。
//
// 所以兩種語意分成兩組欄位:
// first_seenlast_seen 這則記憶什麼時候形成、上次什麼時候想起(真實時間)
// happened_at_until 故事裡這件事什麼時候發生(劇情時間,配 date_source
// 匯入當下就是這則記憶形成的時刻,所以 first/last 都是今天(給預設值)。
extra: {
date_source: row.date_source,
know_level: row.know_level,
happened_at: row.first_seen,
...(row.last_seen && row.last_seen !== row.first_seen ? { happened_until: row.last_seen } : {}),
},
});
}
written.push({ name, file: target });
}
let total = null;
if (!dryRun && written.length) {
const stamp = pl.nowIso();
const done = new Set(written.map((w) => w.name));
// 就地改欄位:長度沒變的話 `rewriteJsonl` 一個位元組都不會寫出去,所以要 force
pl.rewriteJsonl(file, (all) => all.map(
(r) => (done.has(pl.slugify(r.name)) && !r.written_at ? { ...r, written_at: stamp } : r),
), { force: true });
total = pl.rebuildIndex(slug);
}
emit({
persona: slug, work: work.slug, dry_run: dryRun, written: written.map((w) => w.name), skipped, long_term_total: total,
}, flags.json, [
dryRun
? `《${work.work}》批次寫入試跑:${written.length} 則會寫進長期記憶(**沒有寫檔**)。`
: `✔ 《${work.work}》寫進長期記憶 ${written.length} 則(共 ${total ?? "?"} 則,INDEX.md 已重建)。`,
...written.slice(0, 20).map((w) => ` ${w.name}`),
...(written.length > 20 ? [` (還有 ${written.length - 20} 則)`] : []),
...skipped.map((s) => ` ⚠ 跳過 ${s.name}${s.reason}`),
` 還沒寫入的還有 ${pending.length - written.length} 則。`,
" 情緒只固化進欄位,沒有套進基線——基線要另外走 `novel baseline --propose`。",
]);
if (!dryRun && written.length) pushWikiLater(slug, session, flags);
return;
}
if (action === "baseline") {
const work = openWork();
const proposed = parseDeltas(flags.propose);
if (!Object.keys(proposed).length) {
die("需要 `--propose \"joy=30,anger=12\"`(全書統計出來的**基線值**,不是 delta)。");
}
const diff = pl.novelBaselineDiff(slug, proposed);
if (diff.unknown.length) die(`不認得這幾種情緒:${diff.unknown.join("、")}(可用 ${pl.EMOTION_KEYS.join("/")})。`);
const table = diff.rows.map((r) => ` ${r.zh}${r.key}):現在 ${r.now} → 提案 ${r.next}` +
`|差 ${r.diff > 0 ? "+" : ""}${r.diff}${Math.abs(r.diff) >= diff.gap ? " ⚠" : ""}`);
const blocked = diff.over.length > 0 && !flags.force;
const payload = {
persona: slug, work: work.slug, gap: diff.gap, rows: diff.rows,
over: diff.over.map((r) => r.key), blocked, applied: false,
};
if (blocked) {
// 基線是氣質,改了等於換一個人。差太多就停在這裡(非零結束),讓人看一眼
if (flags.json) {
process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
process.exit(1);
}
die([
`情緒基線提案有 ${diff.over.length} 格差 ${diff.gap} 以上,**沒有寫入**`,
...table,
` 基線是氣質,改了等於換一個人。看過還是要改就加 --force;` +
"只想改差得少的那幾格就把超標的從 --propose 拿掉。",
].join("\n"));
}
pl.applyNovelBaseline(slug, proposed);
// 提案留在 work.json:下一本書要接續時看得出這次是照哪一批統計調的
pl.updateJson(pl.novelWorkPath(slug, work.slug), (prev) => ({
...(prev && typeof prev === "object" ? prev : {}),
baseline: { at: pl.nowIso(), values: proposed, forced: Boolean(flags.force) },
updated_at: pl.nowIso(),
}), {});
payload.applied = true;
emit(payload, flags.json, [
`✔ 情緒基線已更新(${diff.rows.length}${flags.force && diff.over.length ? "--force 蓋過門檻" : ""}):`,
...table,
]);
return;
}
die("用法:`novel init|scan|name|skip|candidate|merge|write|report|baseline`。");
};
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;
}
// 未修復的裂痕:純量(closeness/trust)調不出「很近,但上次那件事還沒和好」。
if (action === "rift") {
const key = str(flags.id) || pl.slugify(str(flags.name) || "");
if (!key) die("`rift` 需要 `--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\` 建立。`);
node.rifts ??= [];
const about = str(flags.about);
if (flags.heal) {
// 和好不是刪掉那一筆:「和好過」跟「沒發生過」不一樣,之後回頭看要分得出來。
const open = node.rifts.filter((r) => r && r.about && !r.healed_at);
if (!open.length) die(`跟 \`${node.name || key}\` 之間沒有還沒和好的事。`);
const target = about ? open.filter((r) => String(r.about).includes(about)) : open;
if (!target.length) die(`找不到「${about}」這一件(還沒和好的有:${open.map((r) => r.about).join("")})。`);
for (const r of target) r.healed_at = pl.nowIso().slice(0, 10);
node.updated_at = pl.nowIso();
pl.writeJson(pl.relationsJson(slug), data);
pl.renderRelations(slug);
ok(`收掉 ${target.length} 件:${target.map((r) => r.about).join("")}。`);
pushWikiLater(slug, session, flags);
return;
}
if (!about) {
const open = pl.openRifts(slug, node);
const healed = (node.rifts || []).filter((r) => r?.healed_at);
emit({ node: node.id, open, healed }, flags.json, [
`\`${node.name || node.id}\`:還沒和好 ${open.length} 件、和好過 ${healed.length} 件。`,
...open.map((r) => ` - ${r.about}${r.at ? `${r.at} 起)` : ""}${r.memory ? `|記憶 ${r.memory}` : ""}`),
...healed.map((r) => ` - ✔ ${pl.injectSafeLine(r.about)}${r.healed_at} 和好)`),
]);
return;
}
node.rifts.push({
at: str(flags.at) || pl.nowIso().slice(0, 10),
about,
// 帶上長期記憶的名字,人格要細節自己 recall——不必等使用者提起那件事。
...(str(flags.memory) ? { memory: str(flags.memory) } : {}),
});
node.updated_at = pl.nowIso();
pl.writeJson(pl.relationsJson(slug), data);
pl.renderRelations(slug);
ok(`記下來了:跟 \`${node.name || key}\` 之間「${about}」還沒和好。` +
"談開了再用 `relation rift --heal` 收掉。");
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}` : "";
// 括號用 `roomTag()` 組,跟 `roomScript()` 同一支——自己拼 `(情緒)` 會把動作格吐掉。
lines.push(`[${row.ts}] ${row.speaker}${pl.roomTag(row)}${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 那種同時是自我議程的來源。
voice add|list|show --session <id> --kind sample|reaction|idiolect
[--text --to --scene] sample:他自己講過的原句(照抄不改寫)
[--event --action --emotion] reaction:事件 → 他做了什麼(不記「他感覺到什麼」)
[--facet 自稱|句尾|口癖|不說 --value] idiolect:語域(把名字遮掉還認得出是誰的那幾格)
show 看這一輪會注入什麼(每輪最多 3 條——全注入會變成照抄舊台詞)。
寫在 voice/ 這一層:匯入流程可以直接寫,個性(SOUL.md)只有你能改。
chapter add|list|show|close --session <id> [--name --title --from --to --at --body]
自傳章節(memory/chapters/):長期記憶與心智圖之間的**時期層**——
「在 SAO 那兩年」。to 空著=還在這一段裡,close 才寫上結束日期。
consolidate 的 --chapter 把一則記憶掛到某一段(省略就掛在當下那一段)。
probe add|confirm|deny|audit --session <id> [--text --memory --note --id --limit]
模糊記憶的試探紀錄:可以說不確定、可以問,**不可以斷言**(add 會擋掉沒問號的句子)。
audit 看「試探幾次、被否認幾次」——這是開放這條界線唯一的煞車。
故事匯入(機械的那半;判斷的那半在 skill:在場與知情、切場景、第一人稱摘要、個性提案):
novel init --session <id> --work "<書名>" [--slug <work-slug> --quota "90=5,80=20" --force]
建 memory/import/<work-slug>/work.json / names.json / skipped.jsonl / candidates.jsonl
novel scan --session <id> --work <slug> [--file <章節檔[,第二檔]>] [--dir <目錄>]
從章節裡抽人名候選(對話歸屬/敬稱/片假名/高頻詞四條規則)→ names-proposed.json
已經在正名表或 ignore 裡的不再列:第二次掃只看到新出現的
novel name review|confirm|ignore|reject|add|list --session <id> --work <slug>
review 待確認的候選,按 exact / alias / fuzzy / unknown 分組
confirm --from --to 收下一條(--to 對不上關係節點就擋下來)
confirm --accept-exact 把等於節點名字的整批收下
ignore --token 這不是人名(下次掃不再列)
reject --token 先跳過(不進 ignore,下次掃還會出現)
add --from --to 手動補一條(掃不到的漏網名字)
novel skip [list] --session <id> --work <slug> [--chapter "<章節>" --reason "<理由>"]
跳過紀錄。靜默跳過會漏章,事後查不出來,所以理由是必填
novel candidate add --session <id> --work <slug> --file <候選 JSON> | --stdin
欄位驗證(name/type/body/first_seen 必填、first_seen 要 YYYY-MM-DD、
date_source 只能 canon/derived/guess)+依正名表正名 about。
know_level 除了 canon 之外都必填(did/saw/told/later/none);
none 是「他不在場也沒人告訴他」,只能配 type canon——他不知道的事不能變成他的經歷
about 裡有還沒確認的名字就擋下來;沒過的逐筆回報並以非零結束
novel merge --session <id> --work <slug> [--apply]
跨章去重(salience 取高/first_seen 最早/last_seen 最晚/topics 與 about 聯集/
quote 留最長)+依配額重定標(超額的從低分往下壓一級)。不帶 --apply 只印報告
novel write --session <id> --work <slug> [--dry-run --limit N --force]
批次寫進長期記憶(一則一檔,跟 consolidate 同一套 front matter
多寫 date_sourceknow_levelhappened_at)。情緒只固化進欄位,不套進基線。
劇情日期落在 happened_atfirst_seenlast_seen 是記憶的新鮮度時鐘,
一律是匯入當天——劇情日期塞進去的話舊劇情匯進來就已經想不起來了
novel report --session <id> --work <slug> [--json] 候選/跳過/正名/配額/還沒寫入的
novel baseline --session <id> --work <slug> --propose "joy=30,anger=12,..." [--force]
情緒基線提案跟現況比差值:任一格差 10 以上就停下來給你看(非零結束)
情緒與圖:
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 rift --session <id> --name <who> [--about "<那件事>" --at <日期> --memory <記憶名> | --heal]
沒和好的事。純量(closeness/trust)調不出「很近,但上次那件事還沒和好」——
把親近度調低看起來像本來就沒那麼近。不帶 --about 就是列出來看。
--heal 是寫上 healed_at,不是刪掉那一筆:和好過跟沒發生過不一樣。
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)));