voice 語氣層、novel 機械指令與 persona-story 技能併進 develop,跟 G2/G3 那批合流。 四個共同修改的檔(README、persona-lib、persona.mjs、selftest)全部自動合併,無衝突。 驗證:selftest 646 + 63 = 709 項全綠,零重疊也零回歸。 併進來同時解掉 TARGET.md 兩條卡在跨分支的項目:5.7 的記憶行為欄位要跟 persona-story 的 1.12 對齊、5.6 的語域要吃 3A 的語氣統計——兩邊現在同一棵樹了。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
3222 lines
160 KiB
JavaScript
3222 lines
160 KiB
JavaScript
#!/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;
|
||
}
|
||
|
||
/**
|
||
* 呼叫者是 host(owner)或以 `--as-guest` 自稱的受邀人格。
|
||
* 受邀人格的資料只有它自己(persona-guest sub agent)能讀;主程序即使邀請了它,
|
||
* 也只能看它在聊天室說出口的話。`--as-guest` 由 PreToolUse hook 把關。
|
||
*/
|
||
function requireMember(slug, sessionId, asGuest = false) {
|
||
if (!slug) die("未指定人格,且本 session 沒有載入人格。");
|
||
if (!pl.personaExists(slug)) die(`人格 \`${slug}\` 不存在。`);
|
||
const data = pl.loadSession(sessionId);
|
||
// sleeper 讀自己的資料:`requireOwner` 早就放它寫了,讀反而被擋在外面——
|
||
// 少了這個分支,它就得在看不到 brief/recall 的情況下決定要固化什麼。
|
||
if (CURRENT_FLAGS["as-sleeper"]) {
|
||
if (asGuest) die("`--as-sleeper` 與 `--as-guest` 不能一起用(那是兩種不同的身分)。");
|
||
sleeperAccess(slug, sessionId, str(CURRENT_FLAGS["agent-id"]));
|
||
return [data, "sleeper"];
|
||
}
|
||
if (data.host === slug) {
|
||
if (asGuest) die(`\`${slug}\` 是本 session 的 host 人格,不需要也不得使用 \`--as-guest\`。`);
|
||
pl.heartbeatLock(slug, sessionId);
|
||
return [data, "owner"];
|
||
}
|
||
if (slug in (data.guests || {})) {
|
||
if (!asGuest) {
|
||
die(
|
||
`\`${slug}\` 是本 session 邀請的 guest 人格,它的記憶與情緒不對主程序開放(跨人格資料隔離)。` +
|
||
"你只能透過 `room read` 看它說出口的話;要以它的身分行動必須是 persona-guest sub agent 並帶 `--as-guest`。",
|
||
);
|
||
}
|
||
return [data, "guest"];
|
||
}
|
||
die(`人格 \`${slug}\` 未被本 session 載入或邀請,禁止存取(跨人格資料隔離)。`);
|
||
return [null, null];
|
||
}
|
||
|
||
const hostOf = (flags, session) => str(flags.persona) || pl.loadSession(session).host;
|
||
|
||
function parseGuestList(flags) {
|
||
const guests = [...csv(flags.guest), ...csv(flags.guests)];
|
||
const out = [];
|
||
const seen = new Set();
|
||
for (const guest of guests) {
|
||
if (!guest || seen.has(guest)) continue;
|
||
seen.add(guest);
|
||
out.push(guest);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function parseSleepTargets(flags, fallbackHost = null) {
|
||
const targets = [];
|
||
for (const slug of [...csv(flags.persona), ...csv(flags.personas)]) {
|
||
if (!slug || targets.includes(slug)) continue;
|
||
targets.push(slug);
|
||
}
|
||
if (!targets.length && fallbackHost) targets.push(fallbackHost);
|
||
return targets;
|
||
}
|
||
|
||
/**
|
||
* 里程碑事件(記憶固化、身分/關係變更)之後,把 Wiki 區推上去。
|
||
* 背景執行、失敗不阻斷:同步永遠不該卡住對話。
|
||
*/
|
||
function pushWikiLater(slug, session, flags) {
|
||
if (flags["no-gitea"] || gt.giteaProblem() || !gt.personaCode(slug)) return;
|
||
gt.pushInBackground(slug, "wiki", session, SELF);
|
||
}
|
||
|
||
function renderTemplate(name, mapping) {
|
||
let text = fs.readFileSync(path.join(TEMPLATE_DIR, name), "utf8");
|
||
for (const [key, value] of Object.entries(mapping)) text = text.replaceAll(`{{${key}}}`, String(value));
|
||
return text;
|
||
}
|
||
|
||
/**
|
||
* `--tells "anger=不講話;句子只剩動詞|羞愧=摸後頸"` → IDENTITY.md 的 `## Tells` 條目。
|
||
*
|
||
* 情緒之間用 `|` 分,同一種情緒的多條破口用 `;` 或 `;` 分。
|
||
* 沒帶就回空字串:空的區塊等於「這個人格沿用全域預設」,樣板裡的註解會教下一個人怎麼補。
|
||
*/
|
||
function renderTells(raw) {
|
||
const src = String(raw || "").trim();
|
||
if (!src) return "";
|
||
const lines = [];
|
||
for (const group of src.split("|")) {
|
||
const idx = group.search(/[=::]/);
|
||
if (idx < 0) continue;
|
||
const key = group.slice(0, idx).trim();
|
||
const tells = group.slice(idx + 1).split(/[;;、,,]/).map((s) => s.trim()).filter(Boolean).slice(0, 5);
|
||
if (!key || !tells.length) continue;
|
||
lines.push(`- ${pl.injectSafeLine(key, 12)}: ${tells.map((t) => pl.injectSafeLine(t, 40)).join(";")}`);
|
||
}
|
||
return lines.join("\n");
|
||
}
|
||
|
||
// --------------------------------------------------------------------------- //
|
||
// 子指令
|
||
// --------------------------------------------------------------------------- //
|
||
|
||
const commands = {};
|
||
|
||
commands.create = async ({ flags }) => {
|
||
const session = requireSession(flags);
|
||
// 人格編號 = 英文名全大寫 + 兩位索引(同名才遞增)。編號就是 Gitea 存取庫的名稱。
|
||
let code = str(flags.code);
|
||
let codeNote = "";
|
||
if (code && !gt.validCode(code)) die(`編號 \`${code}\` 不合法,格式是「英文名全大寫-兩位數」,例如 \`ASUNA-01\`。`);
|
||
if (!code) {
|
||
const base = gt.normalizeRomaji(str(flags.romaji) || str(flags.persona));
|
||
if (!base) {
|
||
die(
|
||
"需要 `--romaji <英文名>`:人格編號是英文名全大寫加索引(例:Asuna → `ASUNA-01`)。" +
|
||
"中文名請先轉成羅馬拼音並跟使用者確認拼法,再帶進來。",
|
||
);
|
||
}
|
||
// 發號前先問遠端:`nextCode` 只掃本機,換一台機器就會把同一個號再發一次
|
||
const next = flags["no-gitea"] ? { code: gt.nextCode(base), checked_remote: false, reason: "--no-gitea" }
|
||
: await gt.nextCodeAcrossMachines(base, { owner: str(flags.owner) || null });
|
||
code = next.code;
|
||
if (!code) die(`\`${base}\` 的編號已經用到 99,請換一個英文名。`);
|
||
if (!next.checked_remote) codeNote = ` ⚠ 編號只對過本機,沒對過 Gitea(${next.reason})——別台機器可能已經用掉這個號。`;
|
||
}
|
||
// 沒指定 --persona 就用編號當目錄名(一個識別走到底);指定了就沿用(相容既有人格)
|
||
const slug = str(flags.persona) || code;
|
||
if (!pl.validSlug(slug)) die("人格目錄名只能是英數與連字號(最長 48 字),建議直接用編號,例如 `ASUNA-01`。");
|
||
if (pl.personaExists(slug) && !flags.force) {
|
||
die(`人格 \`${slug}\` 已存在(${pl.personaDir(slug)})。要覆寫請加 --force。`);
|
||
}
|
||
const root = pl.ensurePersonaDirs(slug);
|
||
const mapping = {
|
||
SLUG: slug,
|
||
NAME: str(flags.name) || slug,
|
||
CREATURE: str(flags.creature),
|
||
GENDER: str(flags.gender) || "未指定",
|
||
VIBE: str(flags.vibe),
|
||
EMOJI: str(flags.emoji),
|
||
AVATAR: str(flags.avatar),
|
||
// 情緒破口:`--tells "anger=不講話;把事情做完再說|羞愧=摸後頸"`。
|
||
// 沒帶就留空——空的區塊會退回全域預設,而樣板裡的註解會告訴下一個人怎麼補。
|
||
TELLS: renderTells(str(flags.tells)),
|
||
CREATED: pl.nowIso(),
|
||
};
|
||
for (const filename of ["IDENTITY.md", "SOUL.md", "AGENTS.md", "USER.md"]) {
|
||
const target = path.join(root, filename);
|
||
if (fs.existsSync(target) && !flags.force) continue;
|
||
pl.writeText(target, renderTemplate(filename, mapping));
|
||
}
|
||
pl.writeJson(pl.emotionPath(slug), pl.defaultEmotionState(parseDeltas(flags.baseline)));
|
||
pl.writeJson(pl.configPath(slug), {
|
||
persona: slug,
|
||
code,
|
||
romaji: gt.codePrefix(code),
|
||
display_name: mapping.NAME,
|
||
created_at: pl.nowIso(),
|
||
created_by_session: session,
|
||
origin: str(flags.origin) || "custom",
|
||
source_work: str(flags.work),
|
||
schema: 2,
|
||
});
|
||
pl.writeJson(pl.relationsJson(slug), { nodes: [], edges: [] });
|
||
pl.writeText(
|
||
pl.mindmapPath(slug),
|
||
["%% 心智圖(長期語意結構):概念如何互相勾連", "mindmap", ` root((${mapping.NAME}))`, " 自我", " 使用者", " 共同經驗", ""].join("\n"),
|
||
);
|
||
pl.rebuildIndex(slug);
|
||
pl.acquireLock(slug, session, { cwd: str(flags.cwd) || null });
|
||
pl.bindHost(session, slug, { cwd: str(flags.cwd) || null });
|
||
ok(`人格 \`${slug}\`(編號 \`${code}\`)建立於 ${root},已取得載入鎖並綁定本 session。`);
|
||
if (codeNote) say(codeNote);
|
||
say(` 下一步:補完 ${root}/IDENTITY.md 與 SOUL.md,再用 /jsc-persona:persona-chat 開始對話。`);
|
||
// Gitea 上的存取庫名稱就是編號。身分還沒補完,這裡只開庫;內容之後由各時機自動 push。
|
||
if (!flags["no-gitea"] && !gt.giteaProblem()) {
|
||
try {
|
||
const info = await gt.initRemote(slug, { code, private_: !flags.public });
|
||
say(` 📦 Gitea:${info.repo.html_url}(${info.created ? "已建立" : "沿用既有"},${info.repo.private ? "私有" : "公開"})`);
|
||
for (const key of gt.AREA_KEYS) {
|
||
const res = info.results[key] || {};
|
||
say(
|
||
res.ok
|
||
? ` ${gt.AREAS[key].label}(${gt.AREAS[key].why}):${res.changed ? `已推送 ${res.files} 個檔案` : "目前沒有內容"}`
|
||
: ` ⚠ ${gt.AREAS[key].label}推送失敗:${String(res.reason).slice(0, 160)}`,
|
||
);
|
||
}
|
||
} catch (err) {
|
||
say(` ⚠ Gitea 存取庫建立失敗(不影響本機使用):${err.message}`);
|
||
say(" 之後可用 `sync init` 補建。");
|
||
}
|
||
} else if (!flags["no-gitea"]) {
|
||
say(` ℹ 未同步到 Gitea:${gt.giteaProblem()}`);
|
||
}
|
||
};
|
||
|
||
commands.list = ({ flags }) => {
|
||
const rows = pl.listPersonas().map((slug) => {
|
||
const status = pl.lockStatus(slug);
|
||
let longTerm = 0;
|
||
try {
|
||
longTerm = fs.readdirSync(pl.longTermDir(slug)).filter((f) => f.endsWith(".md")).length;
|
||
} catch {
|
||
longTerm = 0;
|
||
}
|
||
return {
|
||
persona: slug,
|
||
code: gt.personaCode(slug),
|
||
identity: pl.identityBrief(slug),
|
||
locked: status.locked,
|
||
stale: status.stale,
|
||
owner_session: String(status.owner.session_id || "").slice(0, 8),
|
||
owner_cwd: status.owner.cwd,
|
||
guests: status.guests.length,
|
||
long_term: longTerm,
|
||
short_term: pl.readJsonl(pl.shortTermPath(slug)).length,
|
||
};
|
||
});
|
||
const lines = [`人格倉庫:${pl.personaHome()}`];
|
||
if (!rows.length) lines.push("(尚無人格,用 /jsc-persona:persona-create 或 /jsc-persona:persona-anime 建立)");
|
||
for (const r of rows) {
|
||
const state = r.locked ? "🔒 已載入" : r.stale ? "⚠ 死鎖可接手" : "🔓 空閒";
|
||
lines.push(
|
||
`- \`${r.persona}\`${r.code && r.code !== r.persona ? `(編號 ${r.code})` : ""} ${state}` +
|
||
(r.locked ? `(session ${r.owner_session}…, cwd ${r.owner_cwd})` : "") +
|
||
`|guest ${r.guests}|長期記憶 ${r.long_term}|短期 ${r.short_term}` +
|
||
(r.code ? "" : "|⚠ 尚無編號") +
|
||
(r.identity ? `|${r.identity}` : ""),
|
||
);
|
||
}
|
||
emit({ home: pl.personaHome(), personas: rows }, flags.json, lines);
|
||
};
|
||
|
||
commands.load = async ({ flags }) => {
|
||
const session = requireSession(flags);
|
||
const slug = str(flags.persona);
|
||
if (!pl.personaExists(slug)) die(`人格 \`${slug}\` 不存在。可用:${pl.listPersonas().join(", ") || "(無)"}`);
|
||
const data = pl.loadSession(session);
|
||
if (data.host && data.host !== slug) {
|
||
die(
|
||
`本 session 已載入人格 \`${data.host}\`。一個程序只能載入一個人格;` +
|
||
`請先 \`release --session <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}\`(exclusive,session ${session.slice(0, 8)}…,租約 ${lock.lease_seconds}s)`];
|
||
for (const res of pulled) {
|
||
if (res.conflicts?.length) {
|
||
lines.push(
|
||
`⚠ ${gt.AREAS[res.area].label}有衝突,**沒有覆蓋本機**:${res.conflicts.slice(0, 5).join(", ")}` +
|
||
"。請告訴使用者:本機與 Gitea 都改過同一份資料,要保留哪一邊(`sync pull --force` 會以遠端為準)。",
|
||
);
|
||
} else if (res.ok && res.written?.length) {
|
||
lines.push(`↓ ${gt.AREAS[res.area].label}從 Gitea 拉回 ${res.written.length} 個檔案。`);
|
||
} else if (!res.ok && !res.skipped) {
|
||
lines.push(`⚠ ${gt.AREAS[res.area]?.label || res.area}同步失敗(不影響本機):${String(res.reason).slice(0, 120)}`);
|
||
}
|
||
}
|
||
if (lock.took_over_from) {
|
||
const prev = lock.took_over_from;
|
||
lines.push(
|
||
`⚠ 這把鎖是接手來的:原持有者 session ${String(prev.session_id || "").slice(0, 8)}…(cwd ${prev.cwd})` +
|
||
`已失聯 ${prev.stale_minutes} 分鐘。請向使用者說明,若那個程序其實還活著,兩邊的記憶可能會互相覆蓋。`,
|
||
);
|
||
}
|
||
const context = pl.turnContext(slug, session);
|
||
lines.push(context);
|
||
emit({ persona: slug, lock, context }, flags.json, lines);
|
||
};
|
||
|
||
commands.release = async ({ flags }) => {
|
||
const session = requireSession(flags);
|
||
const data = pl.loadSession(session);
|
||
const slug = str(flags.persona) || data.host;
|
||
if (!slug) die("本 session 沒有載入任何人格。");
|
||
// 收工前把兩區都推上去(失敗不阻斷釋放,人格不能被鎖在網路問題裡)
|
||
if (!flags["no-gitea"] && !gt.giteaProblem() && pl.personaExists(slug) && gt.personaCode(slug)) {
|
||
for (const area of gt.AREA_KEYS) {
|
||
try {
|
||
const res = await gt.pushArea(slug, area, { message: `release: 對話結束 ${pl.nowIso()}` });
|
||
if (res.ok && res.changed) say(` ↑ ${gt.AREAS[area].label}已推上 Gitea。`);
|
||
else if (!res.ok && !res.skipped) say(` ⚠ ${gt.AREAS[area].label}推送失敗:${String(res.reason).slice(0, 120)}`);
|
||
} catch (err) {
|
||
say(` ⚠ ${gt.AREAS[area].label}推送失敗:${err.message.slice(0, 120)}`);
|
||
}
|
||
}
|
||
}
|
||
const released = pl.unbindSession(session);
|
||
ok(`已釋放人格 \`${slug}\` 的載入鎖${released.guests.length ? `,並退出 guest:${released.guests.join(", ")}` : "。"}`);
|
||
};
|
||
|
||
commands.status = ({ flags }) => {
|
||
const slug = str(flags.persona);
|
||
if (slug) {
|
||
const status = pl.lockStatus(slug);
|
||
const lines = [
|
||
`人格 \`${slug}\`:${status.locked ? "🔒 已載入" : status.stale ? "⚠ 死鎖可接手" : "🔓 空閒"}`,
|
||
` owner: ${JSON.stringify(status.owner)}`,
|
||
` guests: ${JSON.stringify(status.guests)}`,
|
||
];
|
||
if (pl.personaExists(slug)) lines.push(` ${pl.emotionBrief(slug)}`);
|
||
emit(status, flags.json, lines);
|
||
return;
|
||
}
|
||
const session = str(flags.session);
|
||
const data = session ? pl.loadSession(session) : {};
|
||
emit(data, flags.json, [
|
||
`session ${(session || "-").slice(0, 12)}…`,
|
||
` host 人格:${data.host || "(未載入)"}`,
|
||
` guest 人格:${Object.keys(data.guests || {}).join(", ") || "(無)"}`,
|
||
` 聊天室:${(data.rooms || []).join(", ") || "(無)"}`,
|
||
` 劇場模式:${data.theater ? "🎭 開啟(只輸出人格對話)" : "關閉"}`,
|
||
]);
|
||
};
|
||
|
||
// 睡眠:把一天的活狀態收成能留下來的形狀。
|
||
//
|
||
// 這裡只做**機械性**的收尾(順序即相依)。需要判斷的部分——哪些短期記憶值得固化、
|
||
// 日記要寫什麼、心智圖怎麼接——是那個人格自己的事,由 /jsc-persona:persona-sleep
|
||
// 在呼叫本指令**之前**完成。
|
||
//
|
||
// 回傳(`--json`)只有「睡完了沒、哪一步出錯」:主人格拿到的就是這份報告,
|
||
// 裡面不含任何記憶內容——回傳值本身就是一條會繞過隔離的通道,所以在這裡封死。
|
||
commands.sleep = async ({ flags }) => {
|
||
const session = requireSession(flags);
|
||
const data = pl.loadSession(session);
|
||
const targets = parseSleepTargets(flags, data.host);
|
||
if (!targets.length) die("需要 `--persona <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(`heartbeat:host=${data.host},guests=${Object.keys(data.guests || {}).join(", ") || "(無)"}`);
|
||
};
|
||
|
||
commands.show = ({ flags }) => {
|
||
const session = requireSession(flags);
|
||
const slug = hostOf(flags, session);
|
||
requireMember(slug, session, Boolean(flags["as-guest"]));
|
||
const files = { identity: "IDENTITY.md", soul: "SOUL.md", agents: "AGENTS.md", user: "USER.md" };
|
||
const what = str(flags.what) || "all";
|
||
const chosen = what === "all" ? Object.values(files) : [files[what]].filter(Boolean);
|
||
if (!chosen.length) die(`--what 只能是 ${Object.keys(files).join("/")}/all。`);
|
||
const out = [];
|
||
for (const filename of chosen) {
|
||
const file = path.join(pl.personaDir(slug), filename);
|
||
if (!fs.existsSync(file)) continue;
|
||
// 只給實際內容,模板註解(<!-- ... -->)對人格認知沒幫助
|
||
const body = fs.readFileSync(file, "utf8").replace(/<!--[\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("guest(sub 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}\` 的 inbox(room ${entry.room});等它下次自己載入時再固化。`);
|
||
return;
|
||
}
|
||
pl.rememberShort(slug, entry);
|
||
const kept = pl.pruneShortTerm(slug);
|
||
if (Object.keys(entry.emotion_deltas).length) {
|
||
const state = pl.updateEmotion(slug, (s) => pl.applyEmotion(s, entry.emotion_deltas, text.slice(0, 80), { slug }));
|
||
pl.appendJsonl(pl.journalPath(slug), {
|
||
ts: pl.nowIso(), kind: "emotion", trigger: text.slice(0, 120),
|
||
deltas: entry.emotion_deltas, levels: state.levels, mood: pl.mood(state),
|
||
});
|
||
}
|
||
ok(`已寫入短期記憶(顯著度 ${entry.salience},目前 ${kept} 筆)。`);
|
||
const { candidates } = pl.promotionCandidates(slug);
|
||
if (candidates.length) {
|
||
const rules = [...new Set(candidates.flatMap((c) => c.rules))].sort().join("/");
|
||
say(` ⚠ 有 ${candidates.length} 組已達固化條件(${rules})→ /jsc-persona:persona-memory`);
|
||
}
|
||
if (Object.keys(entry.emotion_deltas).length) say(` ${pl.emotionBrief(slug)}`);
|
||
};
|
||
|
||
commands.recall = ({ flags }) => {
|
||
const session = requireSession(flags);
|
||
const slug = hostOf(flags, session);
|
||
requireMember(slug, session, Boolean(flags["as-guest"]));
|
||
const query = str(flags.query);
|
||
if (!query) die("需要 `--query`。");
|
||
const limit = num(flags.limit, 5);
|
||
const hits = pl.recall(slug, query, limit);
|
||
const lines = [`「${query}」的長期記憶命中 ${hits.length} 則:`];
|
||
for (const meta of hits) {
|
||
const r = pl.memoryRecalled(meta);
|
||
const head = `- ${meta._name}|${meta.type || "fact"}|顯著度 ${meta.salience ?? "?"}`;
|
||
if (r.state === "clear") {
|
||
const first = (meta._gist || meta._body || "").split("\n")[0] || "";
|
||
lines.push(`${head}|${first.slice(0, 120)}`);
|
||
} else if (r.state === "faded") {
|
||
lines.push(`${head}|⚠ 半模糊 ${Math.round(r.retrievability * 100)}%|主旨:${r.gist.split("\n")[0].slice(0, 100)}`);
|
||
lines.push(` ${r.hint}`);
|
||
} else {
|
||
lines.push(`${head}|⚠ 模糊 ${Math.round(r.retrievability * 100)}%|只剩線索:${(r.topics || []).join("/") || "-"}`);
|
||
lines.push(` ${r.hint}`);
|
||
}
|
||
}
|
||
const recents = pl.recentShort(slug, limit);
|
||
if (recents.length) {
|
||
lines.push("短期記憶(最近):");
|
||
for (const row of recents) lines.push(`- [${row.role || "?"}] ${String(row.text || "").slice(0, 110)}`);
|
||
}
|
||
// 心裡話也找得到——害羞的人格,重要的東西幾乎都在心裡那句
|
||
const inner = pl.recallInner(slug, query, limit);
|
||
if (inner.length) {
|
||
lines.push("心裡話(只有你自己看得到,不要講給他聽):");
|
||
for (const row of inner) lines.push(`- 💭 [${row.kind || "infer"}] ${String(row.text || "").slice(0, 110)}`);
|
||
}
|
||
// 模糊到只剩線索的那幾則**不算被想起來**:真的想不起來就不該重設它的衰減
|
||
pl.touchRecall(slug, hits.filter((m) => (m._recall?.retrievability ?? 1) >= pl.MEMORY_FUZZY_AT).map((m) => m._name));
|
||
emit({ persona: slug, long_term: hits, short_term: recents, inner }, flags.json, lines);
|
||
};
|
||
|
||
/**
|
||
* 心裡話:推導、盤算、對記憶的比對……全部寫在這裡,不說出口。
|
||
* 永遠只回報「心想 N 句」,不回顯內容——使用者看到的是狀態,不是你的內心。
|
||
*/
|
||
commands.think = ({ flags }) => {
|
||
const session = requireSession(flags);
|
||
const slug = hostOf(flags, session);
|
||
const [, role] = requireMember(slug, session, Boolean(flags["as-guest"]));
|
||
const text = str(flags.text);
|
||
if (!text) die("需要 `--text`(心裡話內容;不會顯示給使用者)。");
|
||
const kind = str(flags.kind) || "infer";
|
||
const room = str(flags.room) || null;
|
||
let count;
|
||
if (role === "guest") {
|
||
// guest 對自己的人格檔案唯讀,心裡話跟見聞一樣先進 inbox,回家再消化
|
||
if (!room) die("guest(sub agent)的心裡話要帶 `--room`,會寫進自己的 inbox。");
|
||
pl.appendJsonl(pl.inboxPath(slug, room), {
|
||
ts: pl.nowIso(), role: "inner", kind, text, room, salience: num(flags.salience, 35),
|
||
});
|
||
count = pl.readJsonl(pl.inboxPath(slug, room)).filter((r) => r.role === "inner").length;
|
||
} else {
|
||
pl.recordInner(slug, text, { kind, room });
|
||
count = pl.innerCount(slug);
|
||
}
|
||
emit({ persona: slug, kind, count }, flags.json, [`💭 心想 ${count} 句`]);
|
||
};
|
||
|
||
/** 說過的話:查最近說了什麼、以及「這句是不是又要再說一次」。 */
|
||
commands.said = ({ flags, positional }) => {
|
||
const session = requireSession(flags);
|
||
const slug = hostOf(flags, session);
|
||
requireMember(slug, session, Boolean(flags["as-guest"]));
|
||
const action = positional[0] || "check";
|
||
if (action === "list") {
|
||
const rows = pl.recentSaid(slug, num(flags.limit, 8));
|
||
emit({ persona: slug, said: rows }, flags.json, [
|
||
`\`${slug}\` 最近說過的話(${rows.length} 則):`,
|
||
...rows.map((r) => ` - [${r.ts}] ${String(r.text || "").replace(/\n/g, " ").slice(0, 100)}`),
|
||
]);
|
||
return;
|
||
}
|
||
if (action === "check") {
|
||
const text = str(flags.text);
|
||
if (!text) die("需要 `--text`(你打算說的話)。");
|
||
const opts = {
|
||
minutes: num(flags.minutes, pl.REPEAT_WINDOW_MINUTES),
|
||
threshold: num(flags.threshold, pl.REPEAT_THRESHOLD),
|
||
};
|
||
const repeat = pl.saidRepeat(slug, text, opts);
|
||
const sentences = pl.sentenceCount(text);
|
||
const tooLong = sentences > pl.MAX_SENTENCES;
|
||
const lines = [];
|
||
if (repeat) {
|
||
lines.push(
|
||
`⚠ 這句 ${repeat.minutes_ago} 分鐘前講過了(像了 ${repeat.similarity}):「${repeat.text.slice(0, 60)}」`,
|
||
" → 換個角度、補點新的,或直接推進話題。",
|
||
);
|
||
}
|
||
if (tooLong) lines.push(`⚠ 講太多了,${sentences} 句(上限 ${pl.MAX_SENTENCES})→ 挑最想說的那一兩句。`);
|
||
const lint = pl.speechLint(text);
|
||
const blockers = lint.filter((i) => i.level !== "hint");
|
||
for (const issue of blockers) lines.push(`⚠ ${pl.speechLintMessage(issue)}。`);
|
||
// hint 不算不通過(例如講到自己的過去),但要提醒去驗證
|
||
for (const issue of lint.filter((i) => i.level === "hint")) lines.push(`· ${pl.speechLintMessage(issue)}。`);
|
||
// 模糊態的兩種句型:試探是**提醒**(記一筆),假裝記得是**提醒去查**。
|
||
// 這裡刻意都不擋——擋下去等於把「開放試探」那條界線收回來,稽核才是煞車。
|
||
const probing = pl.looksLikeProbe(text);
|
||
const asserting = pl.looksLikeFabrication(text);
|
||
if (probing) {
|
||
lines.push("· 這句是試探:說出口之後記一筆 `persona.mjs probe add --text \"...\"`," +
|
||
"對方回了再 `probe confirm` 或 `probe deny`。");
|
||
} else if (asserting) {
|
||
lines.push("· 這句在斷言自己的過去,而且沒有在問:先 `recall` 確認那則記憶還清晰。" +
|
||
"只要它是模糊態,就改成帶問號的試探句,不可以把細節補出來。");
|
||
}
|
||
const clean = !repeat && !tooLong && !blockers.length;
|
||
if (clean) {
|
||
lines.push(`✔ 沒講過,${sentences} 句,可以說。`);
|
||
if (flags.record) pl.recordSaid(slug, text, { kind: "reply" });
|
||
}
|
||
emit({ persona: slug, repeat, sentences, lint, probing, asserting, ok: clean }, flags.json, lines);
|
||
return;
|
||
}
|
||
die(`未知 action:${action}(可用 check/list)`);
|
||
};
|
||
|
||
/** 短期 → 長期的「轉入條件」評估:列出達標的候選與依據。 */
|
||
commands.candidates = ({ flags }) => {
|
||
const session = requireSession(flags);
|
||
const slug = hostOf(flags, session);
|
||
requireOwner(slug, session);
|
||
// `--reviewed`:把判斷過的短期記憶標掉。**這是候選數字唯一會往下扣的地方**——
|
||
// 固化完不標,同一批下一輪照樣被算成候選,提醒永遠亮著(看起來像睡眠沒做事)。
|
||
if (flags.reviewed !== undefined) {
|
||
const spec = flags.reviewed === true ? "all" : str(flags.reviewed) || "all";
|
||
const indexes = spec === "all" ? null : csv(spec).map(Number);
|
||
if (indexes && (!indexes.length || indexes.some((n) => !Number.isInteger(n)))) {
|
||
die("`--reviewed` 只能是 `all` 或以逗號分隔的編號(候選輸出裡每筆前面的 `#N`)。");
|
||
}
|
||
const until = str(flags.until) || null;
|
||
const res = pl.markShortTermReviewed(slug, { indexes, until });
|
||
const scope = indexes ? `${indexes.length} 筆指名的` : until ? `${until} 之前的` : "全部";
|
||
ok(`已標記${scope}短期記憶為判斷過(新標 ${res.marked} 筆,本來就標過 ${res.already} 筆)。`);
|
||
const after = pl.promotionCandidates(slug);
|
||
say(` 現在還有 ${after.candidates.length} 組候選(短期記憶 ${after.total} 筆,判斷過 ${after.reviewed} 筆)。`);
|
||
return;
|
||
}
|
||
const result = pl.promotionCandidates(slug);
|
||
const lines = [
|
||
`人格 \`${slug}\`:短期記憶 ${result.total} 筆(判斷過 ${result.reviewed} 筆),` +
|
||
`達固化條件的候選 ${result.candidates.length} 組${result.pressure ? "(已達容量壓力 R6)" : ""}`,
|
||
"轉入條件:",
|
||
...pl.PROMOTION_RULES.map((r) => ` ${r.id} ${r.label}`),
|
||
];
|
||
if (!result.candidates.length) lines.push("目前沒有需要固化的內容(未達任何條件)。");
|
||
// 心裡話裡反覆出現的念頭:想過好幾次的事,多半是真的重要
|
||
const thoughts = pl.innerCandidates(slug);
|
||
if (thoughts.length) {
|
||
lines.push(`\n心裡話裡一直在想的事(${thoughts.length} 組,24 小時內)——要不要記住由你決定:`);
|
||
for (const g of thoughts) {
|
||
lines.push(` 「${g.token}」想過 ${g.count} 次`);
|
||
for (const e of g.entries.slice(-2)) lines.push(` - 💭 ${String(e.text || "").slice(0, 80)}`);
|
||
}
|
||
}
|
||
for (const cand of result.candidates) {
|
||
lines.push(
|
||
`\n[${cand.rules.join("+")}] ${cand.kind}「${cand.key}」→ 建議 type=${cand.suggested_type}, ` +
|
||
`salience=${cand.suggested_salience}(${cand.entries.length} 筆依據)`,
|
||
);
|
||
// `#N` 就是 `--from-short` 與 `--reviewed` 要填的編號
|
||
for (const entry of cand.entries.slice(0, 6)) {
|
||
lines.push(` - #${entry._index} [${entry.role || "?"}] ${String(entry.text || "").slice(0, 90)}(顯著度 ${entry.salience ?? "?"})`);
|
||
}
|
||
}
|
||
if (result.candidates.length) {
|
||
lines.push(
|
||
"",
|
||
"判斷完之後要把來源標掉,不然下一輪同一批又會被算成候選:",
|
||
" 固化的 `consolidate --from-short <#N,#N> ...`(寫長期記憶時一起標)",
|
||
" 看過不記的`candidates --reviewed <#N,#N>`",
|
||
);
|
||
}
|
||
emit(result, flags.json, lines);
|
||
};
|
||
|
||
commands.consolidate = ({ flags }) => {
|
||
const session = requireSession(flags);
|
||
const slug = hostOf(flags, session);
|
||
requireOwner(slug, session);
|
||
const rawName = str(flags.name);
|
||
if (!rawName) die("需要 `--name`(長期記憶的檔名/識別)。");
|
||
const name = pl.slugify(rawName);
|
||
const file = path.join(pl.longTermDir(slug), `${name}.md`);
|
||
let existing = {};
|
||
if (fs.existsSync(file)) [existing] = pl.parseFrontMatter(fs.readFileSync(file, "utf8"));
|
||
// slugify 會把標點吃掉:「我的貓」「我的貓?」「我的貓!!」全都落在 `我的貓.md` 上。
|
||
// 以前直接覆蓋,只留下舊檔的 first_seen/recall_count,內文靜默被換成別則記憶。
|
||
// 這裡改成擋下來:不同的 --name 撞到同一個檔就 die,並給一個沒被占用的檔名。
|
||
const priorTitle = existing.title ?? existing.name ?? null;
|
||
if (priorTitle !== null && priorTitle !== rawName && !flags.force) {
|
||
let alt = name;
|
||
for (let i = 2; i < 100 && fs.existsSync(path.join(pl.longTermDir(slug), `${alt}.md`)); i += 1) {
|
||
alt = `${name}-${i}`;
|
||
}
|
||
die(
|
||
`\`${file}\` 已經是「${priorTitle}」的長期記憶,但這次的 --name 是「${rawName}」——` +
|
||
`兩個名字 slugify 之後都是 \`${name}\`,寫下去會把舊的內文換掉。\n` +
|
||
` 換個名字(例如 \`--name ${alt}\`),或確定要覆蓋同一則就加 --force。`,
|
||
);
|
||
}
|
||
let body = str(flags.body);
|
||
if (flags["body-file"]) body = fs.readFileSync(str(flags["body-file"]), "utf8");
|
||
if (!body.trim()) die("需要 `--body` 或 `--body-file`。");
|
||
const type = str(flags.type) || "fact";
|
||
// diary:睡眠時寫的第一人稱回顧(persona-sleep/persona-sleeper 都用這個型別)
|
||
const VALID_TYPES = [
|
||
"fact", "preference", "event", "promise", "relationship", "insight", "boundary", "canon", "diary",
|
||
];
|
||
if (!VALID_TYPES.includes(type)) die(`--type 只能是 ${VALID_TYPES.join("/")}。`);
|
||
const 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),
|
||
});
|
||
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`。");
|
||
};
|
||
|
||
// --------------------------------------------------------------------------- //
|
||
// 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=事件對上他做了什麼)。");
|
||
}
|
||
const render = (entry) => (kind === "sample"
|
||
? `「${entry.text}」${[entry.to ? `對 ${entry.to}` : "", entry.scene].filter(Boolean).join("/")
|
||
? `(${[entry.to ? `對 ${entry.to}` : "", entry.scene].filter(Boolean).join("/")})` : ""}`
|
||
: `${entry.event} → ${entry.action || "(沒記)"}${entry.emotion ? `(${entry.emotion})` : ""}`);
|
||
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 : voice.reactions;
|
||
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) })
|
||
: pl.addVoiceReaction(slug, { event: str(flags.event), action: str(flags.action), emotion: str(flags.emotion) });
|
||
if (!res) {
|
||
die(kind === "sample"
|
||
? "需要 `--text`(他自己講過的原句,照抄不要改寫)。"
|
||
: "需要 `--event`(發生了什麼事);`--action` 寫他做了什麼,不要寫他感覺到什麼。");
|
||
}
|
||
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_seen/last_seen。** 那兩欄是記憶的
|
||
// 新鮮度時鐘(`memoryStrength()` 拿 last_seen 算衰減),劇情日期塞進去
|
||
// 等於宣告「這則記憶上次被想起是兩年前」——2024 年的劇情匯進來的那一秒
|
||
// 就是模糊 0%,而且掉到 0% 之後 touchRecall 不再更新它,永遠回不來。
|
||
// 實測 40 到 79 分的 event 全部出生即死,只有 canon 與 80 分以上靠保護清單活著。
|
||
//
|
||
// 所以兩種語意分成兩組欄位:
|
||
// first_seen/last_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("guest(sub agent)不得改寫人格的情緒狀態。");
|
||
// 親近度會放大或縮小衝擊:同一句話,從枕邊人跟從生人嘴裡出來不一樣
|
||
const rel = flags.apply ? pl.relationGain(slug, str(flags.from) || null) : null;
|
||
let deltas = null;
|
||
let state;
|
||
if (role === "guest") {
|
||
// guest 不寫回,所以也不需要鎖
|
||
state = pl.decayEmotion(pl.loadEmotion(slug));
|
||
} else {
|
||
// 讀 → 衰減 → 套用 → 寫回整段在檔案鎖裡:並行的 `--apply` 才不會互相覆蓋
|
||
state = pl.updateEmotion(slug, (s) => {
|
||
s = pl.decayEmotion(s);
|
||
if (flags.baseline) {
|
||
for (const [key, value] of Object.entries(parseDeltas(flags.baseline))) {
|
||
if (key in pl.EMOTIONS) s.baseline[key] = pl.clamp(value);
|
||
}
|
||
}
|
||
if (flags.apply) {
|
||
const raw = parseDeltas(flags.apply);
|
||
deltas = rel.gain === 1
|
||
? raw
|
||
: Object.fromEntries(Object.entries(raw).map(([k, v]) => [k, Math.round(v * rel.gain * 100) / 100]));
|
||
s = pl.applyEmotion(s, deltas, str(flags.trigger), { slug });
|
||
if (rel.gain !== 1) {
|
||
s.last_trigger = s.last_trigger || {};
|
||
s.last_trigger.relation_gain = { who: rel.name, closeness: rel.closeness, gain: rel.gain };
|
||
}
|
||
}
|
||
return s;
|
||
});
|
||
}
|
||
if (flags.apply) {
|
||
pl.recordFelt(slug, { mine: state.last_trigger?.deltas || null, note: str(flags.trigger) });
|
||
pl.appendJsonl(pl.journalPath(slug), {
|
||
ts: pl.nowIso(), kind: "emotion", trigger: str(flags.trigger),
|
||
deltas, applied: state.last_trigger?.deltas || {}, levels: state.levels, mood: pl.moodOf(slug, state),
|
||
});
|
||
}
|
||
const m = pl.moodOf(slug, state);
|
||
const row = (key) =>
|
||
` ${pl.EMOTIONS[key].zh} ${key.padEnd(13)}${String(state.levels[key]).padStart(6)}(基線 ${state.baseline[key]})`;
|
||
const lines = [
|
||
`人格 \`${slug}\` 情緒狀態(${state.updated_at})`,
|
||
" 正向:", ...pl.POSITIVE.map(row),
|
||
" 負向:", ...pl.NEGATIVE.map(row),
|
||
` 心情:${m.label}/${m.tempo}(valence ${m.valence >= 0 ? "+" : ""}${m.valence}, arousal ${m.arousal})`,
|
||
` ${pl.emotionBrief(slug, state)}`,
|
||
];
|
||
emit({ persona: slug, state, mood: m }, flags.json, lines);
|
||
};
|
||
|
||
commands.mindmap = ({ flags, positional }) => {
|
||
const session = requireSession(flags);
|
||
const slug = hostOf(flags, session);
|
||
requireOwner(slug, session);
|
||
const action = positional[0] || "list";
|
||
const topic = str(flags.topic);
|
||
if (action === "show") {
|
||
const target = topic ? pl.threadPath(slug, topic) : pl.mindmapPath(slug);
|
||
if (!fs.existsSync(target)) die(`${target} 不存在。`);
|
||
process.stdout.write(fs.readFileSync(target, "utf8"));
|
||
return;
|
||
}
|
||
if (action === "thread") {
|
||
if (!topic) die("`thread` 需要 `--topic`。");
|
||
const file = pl.threadPath(slug, topic);
|
||
if (!fs.existsSync(file) || flags.force) {
|
||
pl.writeText(file, [
|
||
`%% 思維導圖(短期):${topic}`,
|
||
`%% created: ${pl.nowIso()} ttl: short-term(固化後請併入 semantic.mmd 並刪除)`,
|
||
"graph LR",
|
||
` trigger["觸發:${topic}"] --> obs["觀察"]`,
|
||
' obs --> infer["推論"]',
|
||
' infer --> concl["結論/待驗證"]',
|
||
"",
|
||
].join("\n"));
|
||
}
|
||
ok(`思維導圖:${file}(用 Write/Edit 續寫推理鏈)`);
|
||
return;
|
||
}
|
||
if (action === "list") {
|
||
let threads = [];
|
||
try {
|
||
threads = fs.readdirSync(path.join(pl.personaDir(slug), "mindmap", "threads")).filter((f) => f.endsWith(".mmd")).sort();
|
||
} catch {
|
||
threads = [];
|
||
}
|
||
emit({ semantic: pl.mindmapPath(slug), threads }, flags.json, [
|
||
`心智圖:${pl.mindmapPath(slug)}`,
|
||
`思維導圖(${threads.length}):`,
|
||
...threads.map((t) => ` - ${t}`),
|
||
]);
|
||
return;
|
||
}
|
||
die(`未知 action:${action}(可用 show/thread/list)`);
|
||
};
|
||
|
||
commands.relation = ({ flags, positional }) => {
|
||
const session = requireSession(flags);
|
||
const slug = hostOf(flags, session);
|
||
requireOwner(slug, session);
|
||
const action = positional[0] || "show";
|
||
// graph.json 壞掉時「空圖」與「壞檔」不能長得一樣:要做決定的 action 直接擋下來
|
||
// (放它過去就是拿一張空圖去覆蓋原檔)。doctor 自己會報告,不走這裡。
|
||
const graphOrDie = () => {
|
||
const data = pl.loadRelations(slug);
|
||
if (data._error) {
|
||
die(`${data._error}\n 先修好這個檔案(或把它移開讓人格從空的關係圖重新開始),再用 \`relation doctor\` 確認。`);
|
||
}
|
||
return data;
|
||
};
|
||
if (action === "node") {
|
||
const name = str(flags.name);
|
||
if (!name) die("`node` 需要 `--name`。");
|
||
const bond = str(flags.bond)?.toLowerCase() || null;
|
||
if (bond && !pl.BONDS.includes(bond)) die(`未知的 --bond:${bond}(可用 ${pl.BONDS.join("/")})`);
|
||
pl.upsertRelationNode(slug, {
|
||
id: str(flags.id) || pl.slugify(name),
|
||
name,
|
||
kind: str(flags.kind) || "human",
|
||
// bond 決定語氣層(伴侶/子女/摯友…);kind 只說這是人還是程式,分不出語氣。
|
||
bond,
|
||
closeness: flags.closeness !== undefined ? pl.clamp(num(flags.closeness, 30)) : null,
|
||
trust: flags.trust !== undefined ? pl.clamp(num(flags.trust, 30)) : null,
|
||
note: str(flags.note) || null,
|
||
tags: csv(flags.tags).length ? csv(flags.tags) : null,
|
||
// --contact:這輪真的有接觸到這個人 → 蓋時間戳(睡眠與「主動關心」都靠它判斷沉默多久)
|
||
last_contact_at: flags.contact ? pl.nowIso() : null,
|
||
});
|
||
pl.renderRelations(slug);
|
||
const tone = pl.toneFor(pl.loadRelations(slug).nodes.find((n) => n.id === (str(flags.id) || pl.slugify(name))));
|
||
ok(
|
||
`關係節點 \`${name}\` 已更新${flags.contact ? "(已記錄本次接觸時間)" : ""}` +
|
||
`|${tone.bond_label}・語氣層 ${tone.layer}。`,
|
||
);
|
||
pushWikiLater(slug, session, flags);
|
||
return;
|
||
}
|
||
// 他本人要求過的語氣規則 → 直接掛在關係節點上(不另開檔案;關係圖每輪都會被讀)。
|
||
if (action === "style") {
|
||
const key = str(flags.id) || pl.slugify(str(flags.name) || "");
|
||
if (!key) die("`style` 需要 `--name`(或 `--id`)。");
|
||
const data = graphOrDie();
|
||
const node = data.nodes.find((n) => n.id === key || pl.slugify(n.name || "") === key);
|
||
if (!node) die(`關係圖裡找不到 \`${str(flags.name) || key}\`,請先用 \`relation node\` 建立。`);
|
||
const facet = str(flags.facet);
|
||
if (!facet) {
|
||
const rules = pl.styleRules(slug, node);
|
||
emit({ node: node.id, rules }, flags.json, [
|
||
`\`${node.name || node.id}\` 的語氣規則(${rules.length}):`,
|
||
...rules.map((r) =>
|
||
` - ${r.facet}→「${r.value}」${r.except ? `(例外:${r.except}${r.suspended ? ",目前成立→暫停" : ""})` : ""}` +
|
||
`${r.since ? `|${r.since} 起` : ""}`),
|
||
]);
|
||
return;
|
||
}
|
||
node.style ??= {};
|
||
if (flags.clear) {
|
||
delete node.style[facet];
|
||
ok(`已移除 \`${node.name || node.id}\` 的「${facet}」規則。`);
|
||
} else {
|
||
const value = str(flags.value);
|
||
if (!value) die("`style` 需要 `--value`(移除請用 `--clear`)。");
|
||
node.style[facet] = {
|
||
value,
|
||
except: str(flags.except) || null, // 形如 anger>=40:情緒成立時這條規則暫停
|
||
since: str(flags.since) || pl.nowIso().slice(0, 10),
|
||
};
|
||
ok(`\`${node.name || node.id}\`:${facet}→「${value}」${str(flags.except) ? `(例外 ${str(flags.except)})` : ""} 已記下。`);
|
||
}
|
||
node.updated_at = pl.nowIso();
|
||
pl.writeJson(pl.relationsJson(slug), data);
|
||
pushWikiLater(slug, session, flags);
|
||
return;
|
||
}
|
||
// 使用者在關係圖裡是誰 → 每輪 `<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_ids/entity_ids 指到不存在的節點
|
||
const ambiguous = new Map(); // 一個名字對到多個節點 → 兩邊都不寫
|
||
// `user`/`self` 是 CLI 自己填的佔位字(`consolidate` 沒帶 `--about` 就是 `about: [user]`),
|
||
// 不是人名。不跳過的話它會是筆數最高那一行,把真正的問題壓到看不見。
|
||
const PLACEHOLDERS = new Set(["user", "self"]);
|
||
const bump = (map, key) => map.set(key, (map.get(key) || 0) + 1);
|
||
const sortByCount = (map) => [...map.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
|
||
// 用**完全相等**比對,跟 `resolveRelationRefs`(寫入路徑)同一套規則:
|
||
// 拿 `findRelationNode` 的子字串 fallback 來健檢,會把「猜對的」當成「對上的」,
|
||
// 於是「先生 → 小林先生」這種誤配在報告上是 0 問題。
|
||
// front matter/jsonl 的值不保證是陣列(`about: 王經理` 會給一個字串)→ 一律先攤成清單,
|
||
// 不然 `for..of` 會逐字元跑,一個人名變成一堆單字的假人名。
|
||
const asList = (v) => (Array.isArray(v) ? v : v === undefined || v === null || v === "" ? [] : [v]);
|
||
const tally = (names, missing) => {
|
||
for (const raw of asList(names)) {
|
||
const name = String(raw).trim();
|
||
if (!name) continue;
|
||
const matched = pl.matchRelationNodes(nodes, name);
|
||
if (matched.length === 1) mentioned.add(matched[0].id);
|
||
else if (matched.length > 1) ambiguous.set(name, matched.map((n) => String(n.id)));
|
||
else if (!PLACEHOLDERS.has(name.toLowerCase())) bump(missing, name);
|
||
}
|
||
};
|
||
const tallyIds = (ids) => {
|
||
for (const raw of asList(ids)) {
|
||
const id = String(raw).trim();
|
||
if (!id) continue;
|
||
// id 不是人名:對不上就是「死指標」(節點被改 id 或被刪掉),單獨列一類
|
||
if (nodes.some((n) => String(n.id).trim() === id)) mentioned.add(id);
|
||
else bump(dangling, id);
|
||
}
|
||
};
|
||
for (const meta of pl.longTermEntries(slug)) {
|
||
tally(meta.about, missingAbout);
|
||
tallyIds(meta.about_ids);
|
||
}
|
||
for (const row of pl.readJsonl(pl.shortTermPath(slug))) {
|
||
tally(row.entities, missingEntities);
|
||
tallyIds(row.entity_ids);
|
||
}
|
||
const orphans = nodes
|
||
.filter((n) => !mentioned.has(n.id))
|
||
.map((n) => ({ id: n.id, name: n.name || n.id, closeness: Number(n.closeness ?? 0) }))
|
||
.sort((a, b) => b.closeness - a.closeness);
|
||
const aboutRows = sortByCount(missingAbout);
|
||
const entityRows = sortByCount(missingEntities);
|
||
const danglingRows = sortByCount(dangling);
|
||
const ambiguousRows = [...ambiguous.entries()].sort((a, b) => a[0].localeCompare(b[0]));
|
||
const lines = [`人格 \`${slug}\` 關係圖健檢(只讀,不改記憶與關係圖):節點 ${nodes.length} 個`];
|
||
lines.push(`長期記憶 about 對不到節點的人名(${aboutRows.length}):`);
|
||
lines.push(...(aboutRows.length ? aboutRows.map(([n, c]) => ` - ${n}(${c} 則)`) : [" (無)"]));
|
||
lines.push(`短期記憶 entities 對不到節點的人名(${entityRows.length}):`);
|
||
lines.push(...(entityRows.length ? entityRows.map(([n, c]) => ` - ${n}(${c} 筆)`) : [" (無)"]));
|
||
lines.push(`同名歧義:一個名字對到多個節點,兩邊都不會寫進 about_ids/entity_ids(${ambiguousRows.length}):`);
|
||
lines.push(...(ambiguousRows.length
|
||
? ambiguousRows.map(([n, ids]) => ` - ${n} → ${ids.join("/")}(改掉其中一個的名字或 id)`)
|
||
: [" (無)"]));
|
||
lines.push(`about_ids/entity_ids 指到不存在的節點(${danglingRows.length}):`);
|
||
lines.push(...(danglingRows.length
|
||
? danglingRows.map(([id, c]) => ` - ${id}(${c} 處;節點被改 id 或被刪掉了)`)
|
||
: [" (無)"]));
|
||
lines.push(`關係圖裡有節點、但沒有任何記憶提到(${orphans.length}):`);
|
||
lines.push(...(orphans.length
|
||
? orphans.map((n) => ` - ${n.name}(${n.id}/親近 ${n.closeness})`)
|
||
: [" (無)"]));
|
||
lines.push(
|
||
`總計:節點 ${nodes.length}/被記憶提到 ${mentioned.size}/沒人提到 ${orphans.length}` +
|
||
`|對不到節點的人名:長期 ${aboutRows.length} 種、短期 ${entityRows.length} 種` +
|
||
`|同名歧義 ${ambiguousRows.length} 個|死指標 ${danglingRows.length} 個。`,
|
||
);
|
||
emit(
|
||
{
|
||
persona: slug,
|
||
nodes: nodes.length,
|
||
mentioned: [...mentioned],
|
||
unresolved_about: aboutRows.map(([name, count]) => ({ name, count })),
|
||
unresolved_entities: entityRows.map(([name, count]) => ({ name, count })),
|
||
ambiguous_names: ambiguousRows.map(([name, ids]) => ({ name, ids })),
|
||
dangling_ids: danglingRows.map(([id, count]) => ({ id, count })),
|
||
unmentioned_nodes: orphans,
|
||
},
|
||
flags.json,
|
||
lines,
|
||
);
|
||
return;
|
||
}
|
||
die(`未知 action:${action}(可用 node/edge/style/speaker/render/show/doctor)`);
|
||
};
|
||
|
||
commands.invite = ({ flags }) => {
|
||
const session = requireSession(flags);
|
||
const host = str(flags.host) || pl.loadSession(session).host;
|
||
if (!host) die("本 session 尚未載入 host 人格,無法邀請他人。");
|
||
requireOwner(host, session);
|
||
const guestList = parseGuestList(flags);
|
||
if (!guestList.length) die("需要 `--guest <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)。**建立人格並補齊 IDENTITY/SOUL 之後再跑**,
|
||
* 這樣配色與字母才會對得上最終的身分。同一個人格永遠得到同一張圖。
|
||
*/
|
||
commands.icon = async ({ flags, positional }) => {
|
||
const session = requireSession(flags);
|
||
const slug = hostOf(flags, session);
|
||
const action = positional[0] || "generate";
|
||
if (action === "show") {
|
||
requireMember(slug, session, Boolean(flags["as-guest"]));
|
||
const spec = ic.iconSpec(slug);
|
||
const src = spec.source || {};
|
||
emit({ persona: slug, spec, exists: ic.hasIcon(slug) }, flags.json, [
|
||
`人格 \`${slug}\` 圖示:${ic.hasIcon(slug) ? "✔ 已產生" : "✘ 尚未產生(跑 `icon generate`)"}`,
|
||
` 字母 ${spec.letters}|配色 ${spec.palette ? "取自參考照片" : "由編號雜湊"}` +
|
||
`:${JSON.stringify(spec.c1)} → ${JSON.stringify(spec.c2)}|seed ${spec.seed}`,
|
||
...(spec.palette ? [` 調色盤:${ic.paletteToString(spec.palette)}`] : []),
|
||
...(spec.style === "portrait" ? [` 特徵:${ic.featuresToString(spec.features)}`] : []),
|
||
...(src.url ? [` 參考來源:${src.url}${src.note ? `(${src.note})` : ""}${src.date ? `|${src.date}` : ""}`] : []),
|
||
` ${ic.iconSvgPath(slug)}`,
|
||
` ${ic.iconPngPath(slug)}`,
|
||
]);
|
||
return;
|
||
}
|
||
if (action === "search") {
|
||
// 找圖第一步:從 Fandom wiki 撈這個角色的官方圖,依「解析度 + 是不是官方設定稿」排序
|
||
requireMember(slug, session, Boolean(flags["as-guest"]));
|
||
const wiki = str(flags.wiki);
|
||
const page = str(flags.page);
|
||
if (!wiki || !page) die("需要 `--wiki <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 Body/Character Design)通常是透明底或白底,去背幾乎免費,優先選它。",
|
||
" 選好之後:`icon measure --photo <網址>` 看臉夠不夠大,再 `icon cutout --photo <網址>`。",
|
||
]);
|
||
return;
|
||
}
|
||
if (action === "measure") {
|
||
requireMember(slug, session, Boolean(flags["as-guest"]));
|
||
const want = str(flags.photo);
|
||
if (!want) die("需要 `--photo <圖片路徑或網址>`。");
|
||
let file;
|
||
try {
|
||
file = await ic.fetchPhoto(want, path.join(pl.personaDir(slug), ".sync"));
|
||
} catch (err) {
|
||
die(err.message);
|
||
}
|
||
const res = ic.measureImage(file);
|
||
if (!res.ok) {
|
||
emit(res, flags.json, [`✖ 量測失敗:${res.reason || "工具不足"}`,
|
||
...ic.installHintLines(res.report || ic.toolReport())]);
|
||
process.exit(1);
|
||
}
|
||
emit(res, flags.json, [
|
||
`解析度 ${res.size.join("×")}(${(res.pixels / 1e6).toFixed(2)} MP)|找到 ${res.faces_found} 張臉` +
|
||
`|臉佔長邊 ${(res.face_ratio * 100).toFixed(0)}%`,
|
||
`背景:${res.transparent ? "透明底(最佳)" : res.background.plain ? "單色底(好去背)" : "有場景(要靠 GrabCut,可能不乾淨)"}` +
|
||
`|去背難度:${res.cutout_easy ? "容易" : "偏難"}`,
|
||
res.cutout_easy
|
||
? " → 這張可以用。`icon cutout --photo <同一張>`"
|
||
: " → 建議換一張官方設定稿(`icon search` 裡標 📐 的),去背會乾淨很多。",
|
||
]);
|
||
return;
|
||
}
|
||
if (action === "cutout") {
|
||
requireOwner(slug, session);
|
||
const want = str(flags.photo);
|
||
if (!want) die("需要 `--photo <圖片路徑或網址>`。");
|
||
let file;
|
||
try {
|
||
file = await ic.fetchPhoto(want, path.join(pl.personaDir(slug), ".sync"));
|
||
} catch (err) {
|
||
die(err.message);
|
||
}
|
||
const out = ic.cutoutPath(slug);
|
||
fs.mkdirSync(path.dirname(out), { recursive: true });
|
||
const res = ic.cutoutImage(file, out, {
|
||
pick: str(flags.pick) || null,
|
||
face: str(flags.face) || null,
|
||
});
|
||
if (!res.ok) {
|
||
emit(res, flags.json, [`✖ 去背失敗:${res.reason || "工具不足"}`,
|
||
...ic.installHintLines(res.report || ic.toolReport())]);
|
||
process.exit(1);
|
||
}
|
||
const method = { "source-alpha": "原圖本來就是透明底", "plain-background": "單色底去除",
|
||
grabcut: "GrabCut(有場景,邊緣可能不完美)" }[res.method] || res.method;
|
||
emit({ persona: slug, ...res }, flags.json, [
|
||
`✔ 去背完成:${out}`,
|
||
` 方式:${method}|原圖 ${res.source_size.join("×")} → 去背後 ${res.output_size.join("×")}` +
|
||
`|不透明佔比 ${(res.opaque_ratio * 100).toFixed(0)}%`,
|
||
" 請用 Read 打開確認邊緣乾不乾淨,再 `icon generate --from-cutout --force`。",
|
||
]);
|
||
return;
|
||
}
|
||
if (action === "headshot") {
|
||
// 裁出「參考用大頭照」。這張不是圖示,也不會同步出去——它是給 AI 看的底稿。
|
||
requireOwner(slug, session);
|
||
const want = str(flags.photo);
|
||
if (!want) die("需要 `--photo <圖片路徑或網址>`(該人格最新登場的官方視覺)。");
|
||
let file;
|
||
try {
|
||
file = await ic.fetchPhoto(want, path.join(pl.personaDir(slug), ".sync"));
|
||
} catch (err) {
|
||
die(err.message);
|
||
}
|
||
const out = path.join(pl.personaDir(slug), ".sync", "headshot.png");
|
||
const cropped = ic.cropHeadshot(file, out, num(flags.size, 384), {
|
||
pick: str(flags.pick) || null,
|
||
face: str(flags.face) || null,
|
||
});
|
||
if (!cropped.ok) {
|
||
emit(cropped, flags.json, [
|
||
`✖ 裁不出大頭照:${cropped.reason || "工具不足"}`,
|
||
...ic.installHintLines(cropped.report || ic.toolReport()),
|
||
]);
|
||
process.exit(1);
|
||
}
|
||
emit({ persona: slug, file: out, ...cropped.info }, flags.json, [
|
||
`✔ 大頭照已裁出:${out}`,
|
||
` 偵測方式 ${cropped.info.method}|原圖 ${cropped.info.source_size.join("×")}|` +
|
||
`找到 ${cropped.info.faces_found} 張臉|裁切框 ${JSON.stringify(cropped.info.box)}`,
|
||
" ⚠ 這張是**參考底稿**,不是圖示,也不會同步到 Gitea。",
|
||
" 請用 Read 打開它,確認是本人,再依看到的髮型/眼型/配件下 `icon generate --features ...`。",
|
||
]);
|
||
return;
|
||
}
|
||
if (action === "faces") {
|
||
requireMember(slug, session, Boolean(flags["as-guest"]));
|
||
const want = str(flags.photo);
|
||
if (!want) die("需要 `--photo <圖片路徑或網址>`。");
|
||
let file;
|
||
try {
|
||
file = await ic.fetchPhoto(want, path.join(pl.personaDir(slug), ".sync"));
|
||
} catch (err) {
|
||
die(err.message);
|
||
}
|
||
const found = ic.listFaces(file);
|
||
if (!found.ok) {
|
||
emit(found, flags.json, [
|
||
`\u2716 \u7121\u6cd5\u5075\u6e2c\u81c9\uff1a${found.reason || "\u5de5\u5177\u4e0d\u8db3"}`,
|
||
...ic.installHintLines(found.report || ic.toolReport()),
|
||
]);
|
||
return;
|
||
}
|
||
emit(found, flags.json, [
|
||
`\u53c3\u8003\u7167\u7247 ${found.size[0]}\u00d7${found.size[1]}\uff5c\u5075\u6e2c\u65b9\u5f0f ${found.method || "\uff08\u7121\uff09"}\uff5c\u627e\u5230 ${found.faces.length} \u5f35\u81c9\uff1a`,
|
||
...found.faces.map((f) => ` #${f.index} ${f.w}\u00d7${f.h} @(${f.x},${f.y})\u3000\u4e2d\u5fc3 ${f.center.join(",")}`),
|
||
" \u591a\u89d2\u8272\u7684\u5716\u8acb\u5148\u770b\u904e\u539f\u5716\u518d\u6311\uff1a`icon generate --photo <\u5716> --pick <\u7d22\u5f15>`\uff08\u6216 `--face x,y,w,h`\uff09\u3002",
|
||
]);
|
||
return;
|
||
}
|
||
if (action !== "generate") die(`未知 action:${action}(可用 search/measure/faces/headshot/cutout/generate/show)`);
|
||
requireOwner(slug, session);
|
||
if (ic.hasIcon(slug) && !flags.force) {
|
||
die(`人格 \`${slug}\` 已經有圖示了。改過身分或換了參考照片要重畫請加 --force。`);
|
||
}
|
||
const size = num(flags.size, ic.DEFAULT_SIZE);
|
||
if (!Number.isFinite(size) || size < 16 || size > 2048) die("--size 只能是 16–2048。");
|
||
let palette = null;
|
||
if (flags.palette) {
|
||
palette = ic.parsePalette(str(flags.palette));
|
||
if (!palette) {
|
||
die(
|
||
"`--palette` 格式錯誤。要 `hair=#rrggbb,eye=#rrggbb,accent=#rrggbb,secondary=#rrggbb,light=#rrggbb`," +
|
||
"其中 hair 與 accent 必填(顏色請取自你實際看過的參考照片)。",
|
||
);
|
||
}
|
||
if (!str(flags["source-url"])) {
|
||
die("用 `--palette` 就必須帶 `--source-url`:配色是從哪張圖取的要留得下來(可查證)。");
|
||
}
|
||
}
|
||
const source = str(flags["source-url"])
|
||
? {
|
||
url: str(flags["source-url"]),
|
||
note: str(flags["source-note"]) || null,
|
||
date: str(flags["source-date"]) || pl.nowIso().slice(0, 10),
|
||
}
|
||
: null;
|
||
const style = str(flags.style) || null;
|
||
if (style && !ic.STYLES.includes(style)) die(`--style 只能是 ${ic.STYLES.join("/")}。`);
|
||
const features = flags.features ? ic.parseFeatures(str(flags.features)) : null;
|
||
// --from-cutout:用去背好的官方原圖合成(解析度高、忠於原作)
|
||
const cutout = flags["from-cutout"] && fs.existsSync(ic.cutoutPath(slug)) ? ic.cutoutPath(slug) : null;
|
||
if (flags["from-cutout"] && !cutout) {
|
||
die(`還沒有去背圖。先跑 \`icon search\` 找官方設定稿 → \`icon cutout --photo <網址>\`。`);
|
||
}
|
||
const res = ic.generateIcon(slug, {
|
||
size, palette, source, style, features, cutout,
|
||
pick: str(flags.pick) || null,
|
||
face: str(flags.face) || null,
|
||
zoom: num(flags.zoom, 2.15),
|
||
});
|
||
const lines = [
|
||
`✔ 人格 \`${slug}\` 的圖示已產生(${size}×${size})。`,
|
||
` ${res.svg}(${(res.bytes.svg / 1024).toFixed(1)} KB)`,
|
||
` ${res.png}(${(res.bytes.png / 1024).toFixed(1)} KB)`,
|
||
` icon/:${res.renders.map((r) => `${r.name} ${(r.bytes / 1024).toFixed(0)}KB`).join("、")}`,
|
||
res.spec.style === "cutout"
|
||
? ` 形象圖:官方原圖去背後合成(頭肩構圖,解析度取自原圖)`
|
||
: res.spec.style === "portrait"
|
||
? ` 形象圖:依人格資料重新繪製的人物頭像(有臉)`
|
||
: ` 徽章:字母 ${res.spec.letters}|配色由編號 \`${res.spec.code}\`、名字與 emoji 決定`,
|
||
...(res.spec.style === "portrait"
|
||
? [` 配色:${ic.paletteToString(res.spec.palette)}`,
|
||
` 特徵:${ic.featuresToString(res.spec.features)}`]
|
||
: res.spec.style === "cutout" && res.spec.palette
|
||
? [` 底色:取自 ${ic.paletteToString(res.spec.palette)}`]
|
||
: []),
|
||
...(source?.url || res.spec.source?.url
|
||
? [` 參考來源:${(source || res.spec.source).url}${(source || res.spec.source).note ? `\n ${(source || res.spec.source).note}` : ""}`]
|
||
: []),
|
||
];
|
||
// 圖示屬於低頻的身分資料 → Wiki 區;順便設成 Gitea 存取庫的頭像
|
||
if (!flags["no-gitea"] && !gt.giteaProblem() && gt.personaCode(slug)) {
|
||
try {
|
||
const owner = await gt.resolveOwner();
|
||
const okAvatar = await gt.setRepoAvatar(owner, gt.personaCode(slug), fs.readFileSync(res.png));
|
||
if (okAvatar) lines.push(" 📦 已設為 Gitea 存取庫頭像。");
|
||
const pushed = await gt.pushArea(slug, "wiki", { message: `icon: 重繪人格形象圖 ${res.spec.code}` });
|
||
if (pushed.ok && pushed.changed) lines.push(" ↑ 形象圖已同步到 Wiki 區。");
|
||
// 推完一定要回頭確認 Wiki 真的有這兩個檔案,且與本機一致
|
||
const check = await gt.verifyIconInWiki(slug);
|
||
lines.push(
|
||
check.ok
|
||
? ` ✔ Wiki 已保存形象圖並與本機一致(${check.icon_present.join(" + ")})`
|
||
: ` ✖ Wiki 形象圖驗證未通過:${check.reason || `缺少或未同步 ${(check.icon_pending || []).join(", ") || "?"}`}` +
|
||
" → 跑 `sync push --area wiki` 再 `sync verify --area wiki`。",
|
||
);
|
||
} catch (err) {
|
||
lines.push(` ⚠ 同步到 Gitea 失敗(不影響本機):${err.message.slice(0, 120)}`);
|
||
}
|
||
}
|
||
emit({ persona: slug, ...res }, flags.json, lines);
|
||
};
|
||
|
||
/** 人格與 Gitea 的同步:檔案區=高頻活狀態,Wiki 區=低頻設定。 */
|
||
commands.sync = async ({ flags, positional }) => {
|
||
const session = requireSession(flags);
|
||
const slug = hostOf(flags, session);
|
||
requireOwner(slug, session);
|
||
const action = positional[0] || "status";
|
||
const area = str(flags.area) || "all";
|
||
if (area !== "all" && !gt.AREA_KEYS.includes(area)) die(`--area 只能是 ${gt.AREA_KEYS.join("/")}/all。`);
|
||
const areas = area === "all" ? gt.AREA_KEYS : [area];
|
||
|
||
if (action === "status") {
|
||
const state = gt.loadSyncState(slug);
|
||
const problem = gt.giteaProblem();
|
||
const overwrites = state.overwrites || [];
|
||
emit({ persona: slug, code: gt.personaCode(slug), problem, sync: state }, flags.json, [
|
||
`人格 \`${slug}\`|編號 ${gt.personaCode(slug) || "(無)"}|Gitea ${problem ? `⚠ ${problem}` : "✔ 已設定"}`,
|
||
` 存取庫:${state.repo_url || "(尚未建立,跑 \`sync init\`)"}`,
|
||
...gt.AREA_KEYS.map((key) => {
|
||
const info = state.areas[key] || {};
|
||
return ` ${gt.AREAS[key].label}(${gt.AREAS[key].why}):` +
|
||
`最後 push ${info.pushed_at || "—"}|最後 pull ${info.pulled_at || "—"}|${info.files ?? "?"} 個檔案`;
|
||
}),
|
||
// 「本機是真相來源」的代價:push 撞到別台機器時是本機贏。贏了要記帳。
|
||
...(overwrites.length
|
||
? [" ⚠ 本機曾覆蓋遠端(別台機器同時在用同一個人格):"]
|
||
.concat(overwrites.slice(-5).map((o) =>
|
||
` ${o.at} ${gt.AREAS[o.area]?.label || o.area} ${o.files.length} 個檔案` +
|
||
`(${o.files.slice(0, 4).join(", ")}${o.files.length > 4 ? "…" : ""})` +
|
||
` 上一版 ${String(o.previous || "").slice(0, 8)}`))
|
||
.concat([` 要看回被蓋掉的內容:\`git -C ${gt.syncDir(slug, overwrites.at(-1).area)} show <上一版>:<檔案>\``])
|
||
: []),
|
||
]);
|
||
return;
|
||
}
|
||
const problem = gt.giteaProblem();
|
||
if (problem) die(`Gitea 尚未設定:${problem}(設 GITEA_HOST 與 GITEA_TOKEN,或用 PERSONA_GITEA_* 覆寫)`);
|
||
|
||
if (action === "init") {
|
||
let code = gt.personaCode(slug);
|
||
if (!code) die(`人格 \`${slug}\` 還沒有編號。先跑 \`code assign --romaji <英文名>\`。`);
|
||
const info = await gt.initRemote(slug, { code, owner: str(flags.owner) || null, private_: !flags.public });
|
||
emit(info, flags.json, [
|
||
`✔ 人格 \`${slug}\`(\`${info.code}\`)已對應到 Gitea:${info.repo.html_url}`,
|
||
` ${info.created ? "存取庫已建立" : "沿用既有存取庫"}|${info.repo.private ? "私有" : "公開"}|Wiki ${info.wikiCreated ? "已建立" : "已存在"}`,
|
||
...gt.AREA_KEYS.map((key) =>
|
||
` ${gt.AREAS[key].label}:${info.results[key]?.ok ? `${info.results[key].files} 個檔案已推送` : `⚠ ${info.results[key]?.reason || "失敗"}`}`),
|
||
]);
|
||
return;
|
||
}
|
||
if (action === "push") {
|
||
const out = [];
|
||
for (const key of areas) {
|
||
if (flags["if-due"] && !gt.pushDue(slug, key)) {
|
||
out.push({ area: key, ok: true, skipped: true, reason: "未到最小間隔" });
|
||
continue;
|
||
}
|
||
try {
|
||
out.push(await gt.pushArea(slug, key, { message: str(flags.message) }));
|
||
} catch (err) {
|
||
out.push({ area: key, ok: false, reason: err.message });
|
||
}
|
||
}
|
||
// 覆蓋遠端這件事在 --quiet(背景 push)下也要留下痕跡:stderr 一定寫,sync.json 也記著
|
||
for (const r of out) {
|
||
if (!r.overwrote) continue;
|
||
process.stderr.write(
|
||
`⚠ ${gt.AREAS[r.area].label}:以本機為準覆蓋了遠端 ${r.overwrote.files.length} 個檔案` +
|
||
`(${r.overwrote.files.slice(0, 6).join(", ")}),上一版是 ${String(r.overwrote.previous).slice(0, 8)}。\n` +
|
||
` 別台機器可能正在用同一個人格。要看回被蓋掉的內容:` +
|
||
`git -C ${gt.syncDir(slug, r.area)} show ${String(r.overwrote.previous).slice(0, 8)}:<檔案>\n`,
|
||
);
|
||
}
|
||
emit({ persona: slug, results: out }, flags.json, out.map((r) =>
|
||
r.skipped ? ` ${gt.AREAS[r.area].label}:略過(${r.reason})`
|
||
: r.ok ? `✔ ${gt.AREAS[r.area].label}:${r.changed ? `已推送 ${r.files} 個檔案` : "沒有變更"}` +
|
||
(r.overwrote ? ` ⚠ 其中覆蓋了遠端 ${r.overwrote.files.length} 個檔案(上一版 ${String(r.overwrote.previous).slice(0, 8)})` : "")
|
||
: `✖ ${gt.AREAS[r.area].label}:${String(r.reason).slice(0, 160)}`));
|
||
return;
|
||
}
|
||
if (action === "verify") {
|
||
const out = [];
|
||
for (const key of areas) {
|
||
try {
|
||
out.push(await gt.verifyArea(slug, key));
|
||
} catch (err) {
|
||
out.push({ area: key, ok: false, reason: err.message });
|
||
}
|
||
}
|
||
const bad = out.filter((r) => !r.ok && !r.skipped);
|
||
emit({ persona: slug, results: out, ok: bad.length === 0 }, flags.json, out.map((r) =>
|
||
r.skipped
|
||
? ` ${gt.AREAS[r.area].label}:略過(${r.reason})`
|
||
: r.ok
|
||
? `✔ ${gt.AREAS[r.area].label}:本機與 Gitea 一致(${r.files} 個檔案)`
|
||
: `✖ ${gt.AREAS[r.area].label}:不一致` +
|
||
(r.pending?.length ? `,還沒推上去的檔案:${r.pending.slice(0, 8).join(", ")}` : "") +
|
||
(r.reason ? `(${r.reason})` : "")));
|
||
if (bad.length) process.exit(1);
|
||
return;
|
||
}
|
||
if (action === "pull") {
|
||
const out = [];
|
||
for (const key of areas) {
|
||
try {
|
||
out.push(await gt.pullArea(slug, key, { force: Boolean(flags.force) }));
|
||
} catch (err) {
|
||
out.push({ area: key, ok: false, reason: err.message });
|
||
}
|
||
}
|
||
pl.rebuildIndex(slug);
|
||
emit({ persona: slug, results: out }, flags.json, out.map((r) =>
|
||
r.conflicts?.length
|
||
? `✖ ${gt.AREAS[r.area].label}:本機與遠端都改過 ${r.conflicts.slice(0, 5).join(", ")};` +
|
||
"沒有覆蓋本機。確定要以遠端為準才加 --force。"
|
||
: r.ok
|
||
? `✔ ${gt.AREAS[r.area].label}:` +
|
||
(r.empty
|
||
? "遠端還是空的"
|
||
: `遠端有 ${r.changed?.length || 0} 個檔案更新,寫回本機 ${r.written?.length || 0} 個` +
|
||
`${(r.written?.length || 0) > (r.changed?.length || 0) ? "(含補回本機缺少的檔案)" : ""}`)
|
||
: `✖ ${gt.AREAS[r.area].label}:${String(r.reason).slice(0, 160)}`));
|
||
return;
|
||
}
|
||
die(`未知 action:${action}(可用 init/push/pull/status/verify)`);
|
||
};
|
||
|
||
commands.gc = ({ flags }) => {
|
||
const removed = pl.gcRuntime();
|
||
emit(removed, flags.json, [
|
||
`✔ 清理完成:sessions=${removed.sessions.length}, 死鎖=${removed.locks.join(",") || "無"}, ` +
|
||
`guest 租約=${removed.guests.join(",") || "無"}`,
|
||
]);
|
||
};
|
||
|
||
commands.guard = () => {
|
||
// 給 hook 用:從 stdin 讀 hook event,輸出 allow/deny。也可手動測試。
|
||
let raw = "";
|
||
try {
|
||
raw = fs.readFileSync(0, "utf8");
|
||
} catch {
|
||
raw = "";
|
||
}
|
||
let event;
|
||
try {
|
||
event = JSON.parse(raw);
|
||
} catch {
|
||
die("stdin 不是合法 JSON");
|
||
}
|
||
process.stdout.write(`${JSON.stringify(pl.guardDecide(event), null, 2)}\n`);
|
||
};
|
||
|
||
const HELP = `persona.mjs — jsc-persona 人格 / 記憶 / 情緒 / 關係圖 CLI
|
||
|
||
用法:node persona.mjs <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
|
||
[--text --to --scene] sample:他自己講過的原句(照抄不改寫)
|
||
[--event --action --emotion] reaction:事件 → 他做了什麼(不記「他感覺到什麼」)
|
||
show 看這一輪會注入什麼(每輪最多 3 條——全注入會變成照抄舊台詞)。
|
||
寫在 voice/ 這一層:匯入流程可以直接寫,個性(SOUL.md)只有你能改。
|
||
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_source/know_level/happened_at)。情緒只固化進欄位,不套進基線。
|
||
劇情日期落在 happened_at;first_seen/last_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 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)));
|