參考兩篇 ithelp 文章(Day25 情緒辨識模組、Day26 整合與情緒記憶池)的**架構**, 實作全部重寫——它們的六類關鍵字 first-match、罐頭哲學回應與模式切換都比現有的 十二情緒模型差,照抄是降級,而且它的罐頭回應正是上一個 commit 剛封殺的罐頭同理心。 一、輸入端:`readUserEmotion(text)` 在這之前情緒**全部是人格自己填的** `--apply`,而人不會主動給自己扣分——正向一路貼頂、 負向整天不動。現在每輪先讀對方那句話:十二類加權詞表(強3/一般2/弱1)、程度副詞乘係數、 **否定會擋掉那次命中**(「我不害怕」不算 fear)、引號與 `code` 內不比對(提及不算使用)、 標點只放大既有訊號不憑空長出新情緒(「居然修好了!!」不會被讀成憤怒)。 強度用飽和曲線不是線性乘。回的是**訊號不是判定**,人格可以推翻。`emotion --read` 可單測。 二、怎麼接:`RESPONSE_STANCE` 十二種各一條**動作**(悲傷→先接住不要急著給解法;憤怒→不辯解,先認可能認的那一小塊), 不是罐頭句——罐頭句由 `SPEECH_BLACKLIST` 擋著。 三、走向:`state/felt.jsonl` + `feltTrend()` 每輪的偵測結果與套用的 delta 都記下來,近重遠輕的加權算最近 5 輪的趨勢 (不用多數決:會被離群值主導又丟掉強度),注入「他最近 3 輪的走向:悲傷 ↘ 在退」。 新檔案已歸到 Gitea 檔案區(高頻)。 四、情緒調節:飽和與單輪預算 `applyEmotion` 不再是加完直接 clamp。飽和=越接近端點同方向漲越慢(headroom^K,K=1, 往 baseline 回的方向不壓);單輪預算=所有 |delta| 總和上限 60,超過等比例縮小。 `emotion --audit` 印出最近幾輪往舒服/往難受的比例,正向 ≥90% 會被點名。 依據:今天 85 次套用讓 joy/serenity/gratitude/anticipation 都到過 100、負向均值整天 14–17。 測試:297 項全過(新增 24 項,含讀取的 SF6/SNF4、飽和、預算、回歸方向不壓、走向、audit、 --read 不改狀態)。既有的「情緒有被施加」改成符合飽和後的語意。 版本:與 PR #10 同一批未合併的改動,維持 0.0.7。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2042 lines
94 KiB
JavaScript
2042 lines
94 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",
|
||
]);
|
||
|
||
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` 由 PreToolUse hook 把關,
|
||
* 只有 persona-sleeper 型的 sub agent 能用。
|
||
*/
|
||
function sleeperAccess(slug, sessionId, agentId) {
|
||
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;
|
||
}
|
||
|
||
// --------------------------------------------------------------------------- //
|
||
// 子指令
|
||
// --------------------------------------------------------------------------- //
|
||
|
||
const commands = {};
|
||
|
||
commands.create = async ({ flags }) => {
|
||
const session = requireSession(flags);
|
||
// 人格編號 = 英文名全大寫 + 兩位索引(同名才遞增)。編號就是 Gitea 存取庫的名稱。
|
||
let code = str(flags.code);
|
||
if (code && !gt.validCode(code)) die(`編號 \`${code}\` 不合法,格式是「英文名全大寫-兩位數」,例如 \`ASUNA-01\`。`);
|
||
if (!code) {
|
||
const base = gt.normalizeRomaji(str(flags.romaji) || str(flags.persona));
|
||
if (!base) {
|
||
die(
|
||
"需要 `--romaji <英文名>`:人格編號是英文名全大寫加索引(例:Asuna → `ASUNA-01`)。" +
|
||
"中文名請先轉成羅馬拼音並跟使用者確認拼法,再帶進來。",
|
||
);
|
||
}
|
||
code = gt.nextCode(base);
|
||
if (!code) die(`\`${base}\` 的編號已經用到 99,請換一個英文名。`);
|
||
}
|
||
// 沒指定 --persona 就用編號當目錄名(一個識別走到底);指定了就沿用(相容既有人格)
|
||
const slug = str(flags.persona) || code;
|
||
if (!pl.validSlug(slug)) die("人格目錄名只能是英數與連字號(最長 48 字),建議直接用編號,例如 `ASUNA-01`。");
|
||
if (pl.personaExists(slug) && !flags.force) {
|
||
die(`人格 \`${slug}\` 已存在(${pl.personaDir(slug)})。要覆寫請加 --force。`);
|
||
}
|
||
const root = pl.ensurePersonaDirs(slug);
|
||
const mapping = {
|
||
SLUG: slug,
|
||
NAME: str(flags.name) || slug,
|
||
CREATURE: str(flags.creature),
|
||
VIBE: str(flags.vibe),
|
||
EMOJI: str(flags.emoji),
|
||
AVATAR: str(flags.avatar),
|
||
CREATED: pl.nowIso(),
|
||
};
|
||
for (const filename of ["IDENTITY.md", "SOUL.md", "AGENTS.md", "USER.md"]) {
|
||
const target = path.join(root, filename);
|
||
if (fs.existsSync(target) && !flags.force) continue;
|
||
pl.writeText(target, renderTemplate(filename, mapping));
|
||
}
|
||
pl.writeJson(pl.emotionPath(slug), pl.defaultEmotionState(parseDeltas(flags.baseline)));
|
||
pl.writeJson(pl.configPath(slug), {
|
||
persona: slug,
|
||
code,
|
||
romaji: gt.codePrefix(code),
|
||
display_name: mapping.NAME,
|
||
created_at: pl.nowIso(),
|
||
created_by_session: session,
|
||
origin: str(flags.origin) || "custom",
|
||
source_work: str(flags.work),
|
||
schema: 2,
|
||
});
|
||
pl.writeJson(pl.relationsJson(slug), { nodes: [], edges: [] });
|
||
pl.writeText(
|
||
pl.mindmapPath(slug),
|
||
["%% 心智圖(長期語意結構):概念如何互相勾連", "mindmap", ` root((${mapping.NAME}))`, " 自我", " 使用者", " 共同經驗", ""].join("\n"),
|
||
);
|
||
pl.rebuildIndex(slug);
|
||
pl.acquireLock(slug, session, { cwd: str(flags.cwd) || null });
|
||
pl.bindHost(session, slug, { cwd: str(flags.cwd) || null });
|
||
ok(`人格 \`${slug}\`(編號 \`${code}\`)建立於 ${root},已取得載入鎖並綁定本 session。`);
|
||
say(` 下一步:補完 ${root}/IDENTITY.md 與 SOUL.md,再用 /jsc-persona:persona-chat 開始對話。`);
|
||
// Gitea 上的存取庫名稱就是編號。身分還沒補完,這裡只開庫;內容之後由各時機自動 push。
|
||
if (!flags["no-gitea"] && !gt.giteaProblem()) {
|
||
try {
|
||
const info = await gt.initRemote(slug, { code, private_: !flags.public });
|
||
say(` 📦 Gitea:${info.repo.html_url}(${info.created ? "已建立" : "沿用既有"},${info.repo.private ? "私有" : "公開"})`);
|
||
for (const key of gt.AREA_KEYS) {
|
||
const res = info.results[key] || {};
|
||
say(
|
||
res.ok
|
||
? ` ${gt.AREAS[key].label}(${gt.AREAS[key].why}):${res.changed ? `已推送 ${res.files} 個檔案` : "目前沒有內容"}`
|
||
: ` ⚠ ${gt.AREAS[key].label}推送失敗:${String(res.reason).slice(0, 160)}`,
|
||
);
|
||
}
|
||
} catch (err) {
|
||
say(` ⚠ Gitea 存取庫建立失敗(不影響本機使用):${err.message}`);
|
||
say(" 之後可用 `sync init` 補建。");
|
||
}
|
||
} else if (!flags["no-gitea"]) {
|
||
say(` ℹ 未同步到 Gitea:${gt.giteaProblem()}`);
|
||
}
|
||
};
|
||
|
||
commands.list = ({ flags }) => {
|
||
const rows = pl.listPersonas().map((slug) => {
|
||
const status = pl.lockStatus(slug);
|
||
let longTerm = 0;
|
||
try {
|
||
longTerm = fs.readdirSync(pl.longTermDir(slug)).filter((f) => f.endsWith(".md")).length;
|
||
} catch {
|
||
longTerm = 0;
|
||
}
|
||
return {
|
||
persona: slug,
|
||
code: gt.personaCode(slug),
|
||
identity: pl.identityBrief(slug),
|
||
locked: status.locked,
|
||
stale: status.stale,
|
||
owner_session: String(status.owner.session_id || "").slice(0, 8),
|
||
owner_cwd: status.owner.cwd,
|
||
guests: status.guests.length,
|
||
long_term: longTerm,
|
||
short_term: pl.readJsonl(pl.shortTermPath(slug)).length,
|
||
};
|
||
});
|
||
const lines = [`人格倉庫:${pl.personaHome()}`];
|
||
if (!rows.length) lines.push("(尚無人格,用 /jsc-persona:persona-create 或 /jsc-persona:persona-anime 建立)");
|
||
for (const r of rows) {
|
||
const state = r.locked ? "🔒 已載入" : r.stale ? "⚠ 死鎖可接手" : "🔓 空閒";
|
||
lines.push(
|
||
`- \`${r.persona}\`${r.code && r.code !== r.persona ? `(編號 ${r.code})` : ""} ${state}` +
|
||
(r.locked ? `(session ${r.owner_session}…, cwd ${r.owner_cwd})` : "") +
|
||
`|guest ${r.guests}|長期記憶 ${r.long_term}|短期 ${r.short_term}` +
|
||
(r.code ? "" : "|⚠ 尚無編號") +
|
||
(r.identity ? `|${r.identity}` : ""),
|
||
);
|
||
}
|
||
emit({ home: pl.personaHome(), personas: rows }, flags.json, lines);
|
||
};
|
||
|
||
commands.load = async ({ flags }) => {
|
||
const session = requireSession(flags);
|
||
const slug = str(flags.persona);
|
||
if (!pl.personaExists(slug)) die(`人格 \`${slug}\` 不存在。可用:${pl.listPersonas().join(", ") || "(無)"}`);
|
||
const data = pl.loadSession(session);
|
||
if (data.host && data.host !== slug) {
|
||
die(
|
||
`本 session 已載入人格 \`${data.host}\`。一個程序只能載入一個人格;` +
|
||
`請先 \`release --session <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 {
|
||
await fn();
|
||
steps.push({ 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", () => {
|
||
// 今天提到過的人 → 蓋上「最後一次接觸」的時間戳(主動關心的依據)
|
||
const seen = new Set();
|
||
for (const row of pl.readJsonl(pl.shortTermPath(slug), 200)) {
|
||
for (const name of row.entities || []) seen.add(name);
|
||
}
|
||
for (const name of seen) pl.stampContact(slug, name);
|
||
pl.renderRelations(slug);
|
||
});
|
||
await run("prune-short-term", () => pl.pruneShortTerm(slug));
|
||
await run("archive-threads", () => pl.archiveStaleThreads(slug));
|
||
await run("emotion-decay", () => {
|
||
const state = pl.decayEmotionBy(pl.loadEmotion(slug), pl.SLEEP_DECAY_MINUTES);
|
||
pl.writeJson(pl.emotionPath(slug), state);
|
||
});
|
||
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),
|
||
room: str(flags.room) || null,
|
||
session: session.slice(0, 8),
|
||
};
|
||
if (scope === "inbox") {
|
||
if (!entry.room) die("`--scope inbox` 必須指定 `--room`。");
|
||
pl.appendJsonl(pl.inboxPath(slug, entry.room), entry);
|
||
ok(`已寫入 \`${slug}\` 的 inbox(room ${entry.room});等它下次自己載入時再固化。`);
|
||
return;
|
||
}
|
||
pl.rememberShort(slug, entry);
|
||
const kept = pl.pruneShortTerm(slug);
|
||
if (Object.keys(entry.emotion_deltas).length) {
|
||
const state = pl.applyEmotion(pl.loadEmotion(slug), entry.emotion_deltas, text.slice(0, 80));
|
||
pl.writeJson(pl.emotionPath(slug), state);
|
||
pl.appendJsonl(pl.journalPath(slug), {
|
||
ts: pl.nowIso(), kind: "emotion", trigger: text.slice(0, 120),
|
||
deltas: entry.emotion_deltas, levels: state.levels, mood: pl.mood(state),
|
||
});
|
||
}
|
||
ok(`已寫入短期記憶(顯著度 ${entry.salience},目前 ${kept} 筆)。`);
|
||
const { candidates } = pl.promotionCandidates(slug);
|
||
if (candidates.length) {
|
||
const rules = [...new Set(candidates.flatMap((c) => c.rules))].sort().join("/");
|
||
say(` ⚠ 有 ${candidates.length} 組已達固化條件(${rules})→ /jsc-persona:persona-memory`);
|
||
}
|
||
if (Object.keys(entry.emotion_deltas).length) say(` ${pl.emotionBrief(slug)}`);
|
||
};
|
||
|
||
commands.recall = ({ flags }) => {
|
||
const session = requireSession(flags);
|
||
const slug = hostOf(flags, session);
|
||
requireMember(slug, session, Boolean(flags["as-guest"]));
|
||
const query = str(flags.query);
|
||
if (!query) die("需要 `--query`。");
|
||
const limit = num(flags.limit, 5);
|
||
const hits = pl.recall(slug, query, limit);
|
||
const lines = [`「${query}」的長期記憶命中 ${hits.length} 則:`];
|
||
for (const meta of hits) {
|
||
const first = (meta._body || "").split("\n")[0] || "";
|
||
lines.push(`- ${meta._name}|${meta.type || "fact"}|顯著度 ${meta.salience ?? "?"}|${first.slice(0, 120)}`);
|
||
}
|
||
const recents = pl.recentShort(slug, limit);
|
||
if (recents.length) {
|
||
lines.push("短期記憶(最近):");
|
||
for (const row of recents) lines.push(`- [${row.role || "?"}] ${String(row.text || "").slice(0, 110)}`);
|
||
}
|
||
pl.touchRecall(slug, hits.map((m) => m._name));
|
||
emit({ persona: slug, long_term: hits, short_term: recents }, flags.json, lines);
|
||
};
|
||
|
||
/**
|
||
* 心裡話:推導、盤算、對記憶的比對……全部寫在這裡,不說出口。
|
||
* 永遠只回報「心想 N 句」,不回顯內容——使用者看到的是狀態,不是你的內心。
|
||
*/
|
||
commands.think = ({ flags }) => {
|
||
const session = requireSession(flags);
|
||
const slug = hostOf(flags, session);
|
||
const [, role] = requireMember(slug, session, Boolean(flags["as-guest"]));
|
||
const text = str(flags.text);
|
||
if (!text) die("需要 `--text`(心裡話內容;不會顯示給使用者)。");
|
||
const kind = str(flags.kind) || "infer";
|
||
const room = str(flags.room) || null;
|
||
let count;
|
||
if (role === "guest") {
|
||
// guest 對自己的人格檔案唯讀,心裡話跟見聞一樣先進 inbox,回家再消化
|
||
if (!room) die("guest(sub agent)的心裡話要帶 `--room`,會寫進自己的 inbox。");
|
||
pl.appendJsonl(pl.inboxPath(slug, room), {
|
||
ts: pl.nowIso(), role: "inner", kind, text, room, salience: num(flags.salience, 35),
|
||
});
|
||
count = pl.readJsonl(pl.inboxPath(slug, room)).filter((r) => r.role === "inner").length;
|
||
} else {
|
||
pl.recordInner(slug, text, { kind, room });
|
||
count = pl.innerCount(slug);
|
||
}
|
||
emit({ persona: slug, kind, count }, flags.json, [`💭 心想 ${count} 句`]);
|
||
};
|
||
|
||
/** 說過的話:查最近說了什麼、以及「這句是不是又要再說一次」。 */
|
||
commands.said = ({ flags, positional }) => {
|
||
const session = requireSession(flags);
|
||
const slug = hostOf(flags, session);
|
||
requireMember(slug, session, Boolean(flags["as-guest"]));
|
||
const action = positional[0] || "check";
|
||
if (action === "list") {
|
||
const rows = pl.recentSaid(slug, num(flags.limit, 8));
|
||
emit({ persona: slug, said: rows }, flags.json, [
|
||
`\`${slug}\` 最近說過的話(${rows.length} 則):`,
|
||
...rows.map((r) => ` - [${r.ts}] ${String(r.text || "").replace(/\n/g, " ").slice(0, 100)}`),
|
||
]);
|
||
return;
|
||
}
|
||
if (action === "check") {
|
||
const text = str(flags.text);
|
||
if (!text) die("需要 `--text`(你打算說的話)。");
|
||
const opts = {
|
||
minutes: num(flags.minutes, pl.REPEAT_WINDOW_MINUTES),
|
||
threshold: num(flags.threshold, pl.REPEAT_THRESHOLD),
|
||
};
|
||
const repeat = pl.saidRepeat(slug, text, opts);
|
||
const sentences = pl.sentenceCount(text);
|
||
const tooLong = sentences > pl.MAX_SENTENCES;
|
||
const lines = [];
|
||
if (repeat) {
|
||
lines.push(
|
||
`⚠ 這句跟 ${repeat.minutes_ago} 分鐘前說過的話相似 ${repeat.similarity}:「${repeat.text.slice(0, 60)}」`,
|
||
" → 換個角度、補新資訊,或直接推進話題;不要再說一次。",
|
||
);
|
||
}
|
||
if (tooLong) lines.push(`⚠ 這段有 ${sentences} 句,超過 ${pl.MAX_SENTENCES} 句上限 → 砍到重點。`);
|
||
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 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, 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);
|
||
const result = pl.promotionCandidates(slug);
|
||
const lines = [
|
||
`人格 \`${slug}\`:短期記憶 ${result.total} 筆,達固化條件的候選 ${result.candidates.length} 組` +
|
||
`${result.pressure ? "(已達容量壓力 R6)" : ""}`,
|
||
"轉入條件:",
|
||
...pl.PROMOTION_RULES.map((r) => ` ${r.id} ${r.label}`),
|
||
];
|
||
if (!result.candidates.length) lines.push("目前沒有需要固化的內容(未達任何條件)。");
|
||
for (const cand of result.candidates) {
|
||
lines.push(
|
||
`\n[${cand.rules.join("+")}] ${cand.kind}「${cand.key}」→ 建議 type=${cand.suggested_type}, ` +
|
||
`salience=${cand.suggested_salience}(${cand.entries.length} 筆依據)`,
|
||
);
|
||
for (const entry of cand.entries.slice(0, 6)) {
|
||
lines.push(` - [${entry.role || "?"}] ${String(entry.text || "").slice(0, 90)}(顯著度 ${entry.salience ?? "?"})`);
|
||
}
|
||
}
|
||
emit(result, flags.json, lines);
|
||
};
|
||
|
||
commands.consolidate = ({ flags }) => {
|
||
const session = requireSession(flags);
|
||
const slug = hostOf(flags, session);
|
||
requireOwner(slug, session);
|
||
const rawName = str(flags.name);
|
||
if (!rawName) die("需要 `--name`(長期記憶的檔名/識別)。");
|
||
const name = pl.slugify(rawName);
|
||
const file = path.join(pl.longTermDir(slug), `${name}.md`);
|
||
const today = pl.nowIso().slice(0, 10);
|
||
let existing = {};
|
||
if (fs.existsSync(file)) [existing] = pl.parseFrontMatter(fs.readFileSync(file, "utf8"));
|
||
let body = str(flags.body);
|
||
if (flags["body-file"]) body = fs.readFileSync(str(flags["body-file"]), "utf8");
|
||
if (!body.trim()) die("需要 `--body` 或 `--body-file`。");
|
||
const type = str(flags.type) || "fact";
|
||
// 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 front = [
|
||
"---",
|
||
`name: ${name}`,
|
||
`type: ${type}`,
|
||
`about: [${csv(flags.about).join(", ") || "user"}]`,
|
||
`topics: [${csv(flags.topics).join(", ")}]`,
|
||
`salience: ${num(flags.salience, 60)}`,
|
||
`emotion: ${str(flags.emotion) || "none"}`,
|
||
`rules: ${str(flags.rules) || "manual"}`,
|
||
`first_seen: ${existing.first_seen || today}`,
|
||
`last_seen: ${today}`,
|
||
`recall_count: ${existing.recall_count || 0}`,
|
||
`source: ${str(flags.source) || "short-term"}`,
|
||
"---",
|
||
"",
|
||
body.trim(),
|
||
"",
|
||
];
|
||
pl.writeText(file, front.join("\n"));
|
||
const total = pl.rebuildIndex(slug);
|
||
const forget = num(flags.forget, null);
|
||
if (forget !== null) {
|
||
const rows = pl.readJsonl(pl.shortTermPath(slug));
|
||
const keep = rows.filter((r) => Number(r.salience || 0) >= forget);
|
||
pl.writeText(pl.shortTermPath(slug), keep.map((r) => JSON.stringify(r)).join("\n") + (keep.length ? "\n" : ""));
|
||
say(` 短期記憶已淘汰顯著度 < ${forget} 的項目,剩 ${keep.length} 筆。`);
|
||
}
|
||
ok(`長期記憶 \`${name}\` 已寫入(共 ${total} 則),INDEX.md 已重建。`);
|
||
pushWikiLater(slug, session, flags); // 固化=Wiki 區(低頻設定)該更新了
|
||
};
|
||
|
||
commands.prune = ({ flags }) => {
|
||
const session = requireSession(flags);
|
||
const slug = hostOf(flags, session);
|
||
requireOwner(slug, session);
|
||
const kept = pl.pruneShortTerm(slug);
|
||
ok(`短期記憶已裁剪,剩 ${kept} 筆(保留上限 ${pl.SHORT_TERM_KEEP} 筆 / ${pl.SHORT_TERM_DAYS} 天)。`);
|
||
};
|
||
|
||
commands.reindex = ({ flags }) => {
|
||
const session = requireSession(flags);
|
||
const slug = hostOf(flags, session);
|
||
requireOwner(slug, session);
|
||
ok(`INDEX.md 重建完成(${pl.rebuildIndex(slug)} 則長期記憶)。`);
|
||
pushWikiLater(slug, session, flags);
|
||
};
|
||
|
||
commands.emotion = ({ flags }) => {
|
||
const session = requireSession(flags);
|
||
const slug = hostOf(flags, session);
|
||
const [, role] = requireMember(slug, session, Boolean(flags["as-guest"]));
|
||
let state = pl.decayEmotion(pl.loadEmotion(slug));
|
||
if (flags.baseline) {
|
||
for (const [key, value] of Object.entries(parseDeltas(flags.baseline))) {
|
||
if (key in pl.EMOTIONS) state.baseline[key] = pl.clamp(value);
|
||
}
|
||
}
|
||
// 讀對方那句話的情緒訊號(不改狀態,只回報;`--read` 可以單獨拿來測詞表)
|
||
const readText = str(flags.read);
|
||
const read = readText ? pl.readUserEmotion(readText) : null;
|
||
if (read) {
|
||
emit({ persona: slug, read }, flags.json, [
|
||
`讀「${readText.slice(0, 40)}」:`,
|
||
read.signals.length
|
||
? ` 訊號:${read.signals.map((s) => `${s.zh} ${s.score}`).join("、")}|強度 ${read.intensity}|${read.confident ? "夠強,值得參考" : "偏弱,別當判定"}`
|
||
: " 沒有讀到明確的情緒訊號。",
|
||
...(read.signals.length ? [` 怎麼接:${pl.RESPONSE_STANCE[read.signals[0].key]}`] : []),
|
||
]);
|
||
return;
|
||
}
|
||
if (flags.audit) {
|
||
const audit = pl.feltAudit(slug, num(flags.limit, 20));
|
||
const trend = pl.feltTrend(slug);
|
||
emit({ persona: slug, audit, trend }, flags.json, [
|
||
`\`${slug}\` 最近 ${audit.rounds} 輪的情緒套用:`,
|
||
` 往舒服的方向 ${audit.positive}|往難受的方向 ${audit.negative}` +
|
||
(audit.positive_ratio === null ? "" : ` → 正向佔 ${audit.positive_ratio}%`),
|
||
audit.positive_ratio !== null && audit.positive_ratio >= 90
|
||
? " ⚠ 幾乎只給自己加分——delta 是你自己挑的,這種偏差要自己看得見。"
|
||
: " 比例還算兩邊都有。",
|
||
trend ? ` 對方的走向:${trend.zh} ${trend.direction}(近 ${trend.rounds} 輪)` : " 還沒有足夠的輪次算走向。",
|
||
]);
|
||
return;
|
||
}
|
||
if (flags.apply) {
|
||
if (role === "guest") die("guest(sub agent)不得改寫人格的情緒狀態。");
|
||
const deltas = parseDeltas(flags.apply);
|
||
state = pl.applyEmotion(state, deltas, str(flags.trigger));
|
||
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.mood(state),
|
||
});
|
||
}
|
||
if (role !== "guest") pl.writeJson(pl.emotionPath(slug), state);
|
||
const m = pl.mood(state);
|
||
const row = (key) =>
|
||
` ${pl.EMOTIONS[key].zh} ${key.padEnd(13)}${String(state.levels[key]).padStart(6)}(基線 ${state.baseline[key]})`;
|
||
const lines = [
|
||
`人格 \`${slug}\` 情緒狀態(${state.updated_at})`,
|
||
" 正向:", ...pl.POSITIVE.map(row),
|
||
" 負向:", ...pl.NEGATIVE.map(row),
|
||
` 心情:${m.label}/${m.tempo}(valence ${m.valence >= 0 ? "+" : ""}${m.valence}, arousal ${m.arousal})`,
|
||
` ${pl.emotionBrief(slug, state)}`,
|
||
];
|
||
emit({ persona: slug, state, mood: m }, flags.json, lines);
|
||
};
|
||
|
||
commands.mindmap = ({ flags, positional }) => {
|
||
const session = requireSession(flags);
|
||
const slug = hostOf(flags, session);
|
||
requireOwner(slug, session);
|
||
const action = positional[0] || "list";
|
||
const topic = str(flags.topic);
|
||
if (action === "show") {
|
||
const target = topic ? pl.threadPath(slug, topic) : pl.mindmapPath(slug);
|
||
if (!fs.existsSync(target)) die(`${target} 不存在。`);
|
||
process.stdout.write(fs.readFileSync(target, "utf8"));
|
||
return;
|
||
}
|
||
if (action === "thread") {
|
||
if (!topic) die("`thread` 需要 `--topic`。");
|
||
const file = pl.threadPath(slug, topic);
|
||
if (!fs.existsSync(file) || flags.force) {
|
||
pl.writeText(file, [
|
||
`%% 思維導圖(短期):${topic}`,
|
||
`%% created: ${pl.nowIso()} ttl: short-term(固化後請併入 semantic.mmd 並刪除)`,
|
||
"graph LR",
|
||
` trigger["觸發:${topic}"] --> obs["觀察"]`,
|
||
' obs --> infer["推論"]',
|
||
' infer --> concl["結論/待驗證"]',
|
||
"",
|
||
].join("\n"));
|
||
}
|
||
ok(`思維導圖:${file}(用 Write/Edit 續寫推理鏈)`);
|
||
return;
|
||
}
|
||
if (action === "list") {
|
||
let threads = [];
|
||
try {
|
||
threads = fs.readdirSync(path.join(pl.personaDir(slug), "mindmap", "threads")).filter((f) => f.endsWith(".mmd")).sort();
|
||
} catch {
|
||
threads = [];
|
||
}
|
||
emit({ semantic: pl.mindmapPath(slug), threads }, flags.json, [
|
||
`心智圖:${pl.mindmapPath(slug)}`,
|
||
`思維導圖(${threads.length}):`,
|
||
...threads.map((t) => ` - ${t}`),
|
||
]);
|
||
return;
|
||
}
|
||
die(`未知 action:${action}(可用 show/thread/list)`);
|
||
};
|
||
|
||
commands.relation = ({ flags, positional }) => {
|
||
const session = requireSession(flags);
|
||
const slug = hostOf(flags, session);
|
||
requireOwner(slug, session);
|
||
const action = positional[0] || "show";
|
||
if (action === "node") {
|
||
const name = str(flags.name);
|
||
if (!name) die("`node` 需要 `--name`。");
|
||
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 = pl.loadRelations(slug);
|
||
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 = pl.loadRelations(slug).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);
|
||
emit(data, flags.json, [
|
||
`人格 \`${slug}\` 人際關係圖:${data.nodes.length} 節點 / ${data.edges.length} 連線`,
|
||
pl.relationsBrief(slug, null, 20) || "(空)",
|
||
]);
|
||
return;
|
||
}
|
||
die(`未知 action:${action}(可用 node/edge/style/speaker/render/show)`);
|
||
};
|
||
|
||
commands.invite = ({ flags }) => {
|
||
const session = requireSession(flags);
|
||
const host = str(flags.host) || pl.loadSession(session).host;
|
||
if (!host) die("本 session 尚未載入 host 人格,無法邀請他人。");
|
||
requireOwner(host, session);
|
||
const 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, slug] of Object.entries(data.pins || {})) {
|
||
if (slug === guest) delete data.pins[agentId];
|
||
}
|
||
if (!Object.keys(data.guests).length) data.theater = false; // 沒有客人就退出劇場模式
|
||
pl.saveSession(session, data);
|
||
pl.roomPost(room, "system", `${guest} 離開聊天室。`, { kind: "meta" });
|
||
ok(`\`${guest}\` 已離開聊天室 \`${room}\`,guest 租約已釋放${data.theater ? "" : ",劇場模式關閉"}。`);
|
||
};
|
||
|
||
commands.room = ({ flags, positional }) => {
|
||
const session = requireSession(flags);
|
||
const data = pl.loadSession(session);
|
||
const action = positional[0] || "read";
|
||
if (action === "list") {
|
||
emit({ rooms: data.rooms || [], theater: data.theater }, flags.json, [
|
||
`本 session 的聊天室:${(data.rooms || []).join(", ") || "(無)"}`,
|
||
`劇場模式:${data.theater ? "🎭 開啟" : "關閉"}`,
|
||
]);
|
||
return;
|
||
}
|
||
if (action === "theater") {
|
||
if (!flags.on && !flags.off) die("`theater` 需要 `--on` 或 `--off`。");
|
||
data.theater = Boolean(flags.on);
|
||
pl.saveSession(session, data);
|
||
ok(`劇場模式已${data.theater ? "開啟:只輸出人格對話" : "關閉"}。`);
|
||
return;
|
||
}
|
||
const room = str(flags.room) || (data.rooms || [])[data.rooms?.length - 1];
|
||
if (!room) die("需要 `--room`。");
|
||
if (!(data.rooms || []).includes(room)) {
|
||
die(`聊天室 \`${room}\` 不屬於本 session(可用:${(data.rooms || []).join(", ") || "(無)"})。`);
|
||
}
|
||
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(
|
||
`\`${speaker}\` 在 ${repeat.minutes_ago} 分鐘前說過幾乎一樣的話(相似度 ${repeat.similarity}):` +
|
||
`「${repeat.text.slice(0, 60)}」。換個說法、補新東西或推進話題;` +
|
||
"真的需要重複(例如被追問)才加 `--allow-repeat`。",
|
||
);
|
||
}
|
||
}
|
||
const sentences = pl.sentenceCount(text);
|
||
if (sentences > pl.MAX_SENTENCES && !flags.force) {
|
||
die(
|
||
`這句有 ${sentences} 句,超過 ${pl.MAX_SENTENCES} 句上限——聊天不是報告,砍到重點再發。` +
|
||
"(真的需要長段落才加 `--force`。)",
|
||
);
|
||
}
|
||
// 短句、日常話、講完不解釋、不說 AI 才會說的話——講話的樣子也擋在 CLI 裡,不靠自律。
|
||
const lint = pl.speechBlockers(text);
|
||
if (lint.length && !flags.force) {
|
||
die(`講話規則:${lint.map(pl.speechLintMessage).join(";")}。(真的需要才加 \`--force\`。)`);
|
||
}
|
||
let emotion = str(flags.emotion);
|
||
if (!emotion && pl.personaExists(speaker)) {
|
||
emotion = pl.dominant(pl.decayEmotion(pl.loadEmotion(speaker)), 2)
|
||
.map(({ key, level }) => `${pl.EMOTIONS[key].zh}${Math.round(level)}`)
|
||
.join("/");
|
||
}
|
||
const entry = pl.roomPost(room, speaker, text, { emotion, to, bargeIn });
|
||
// guest 對自己的狀態唯讀,它說過的話留在聊天室逐字稿裡就夠了(roomRepeat 讀得到)
|
||
if (speakerRole !== "guest") pl.recordSaid(speaker, text, { room, kind: "room" });
|
||
const dyad = to && to !== pl.ROOM_ALL;
|
||
ok(
|
||
`\`${speaker}\` 已發言於 \`${room}\`(${dyad ? `對 ${nameOf(to)}` : "對全場"}/情緒 ${emotion})。` +
|
||
(dyad ? `接下來只有 ${nameOf(to)} 該回話,其他人先安靜。` : ""),
|
||
);
|
||
if (flags.json) process.stdout.write(`${JSON.stringify(entry)}\n`);
|
||
return;
|
||
}
|
||
if (action === "read") {
|
||
const rows = pl.roomRead(room, num(flags.limit, 30));
|
||
const meta = pl.readJson(pl.roomMembersPath(room), {}) ?? {};
|
||
const lines = [`聊天室 \`${room}\`|成員 ${(meta.members || []).join(", ")}|主題 ${meta.topic || "-"}`];
|
||
for (const row of rows) {
|
||
const arrow = row.to && row.to !== pl.ROOM_ALL && row.to !== row.speaker ? ` → ${row.to}` : "";
|
||
lines.push(`[${row.ts}] ${row.speaker}${row.emotion ? `(${row.emotion})` : ""}${arrow}:${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 略過):內容可能被改過,請自行確認。");
|
||
if (flags.load) {
|
||
if (data.host && data.host !== result.persona) {
|
||
lines.push(` ⚠ 本 session 已載入 \`${data.host}\`,未自動載入;要用它請先 release。`);
|
||
} else {
|
||
try {
|
||
pl.acquireLock(result.persona, session, { cwd: str(flags.cwd) || null });
|
||
pl.bindHost(session, result.persona, { cwd: str(flags.cwd) || null });
|
||
lines.push(` 已載入 \`${result.persona}\`,可以直接開始聊。`);
|
||
} catch (err) {
|
||
lines.push(` ⚠ 自動載入失敗:${err.message}`);
|
||
}
|
||
}
|
||
} else {
|
||
lines.push(` 下一步:/jsc-persona:persona-chat ${result.persona}`);
|
||
}
|
||
emit({ ...result, source: bundle.persona, checksum_ok: checksumOk }, flags.json, lines);
|
||
};
|
||
|
||
/**
|
||
* 人格編號:英文名全大寫 + 兩位索引(同名才遞增),也就是 Gitea 存取庫的名稱。
|
||
* 既有人格用 `code assign --romaji <英文名>` 補編號,加 `--rename` 連目錄名一起改成編號。
|
||
*/
|
||
commands.code = async ({ flags, positional }) => {
|
||
const session = requireSession(flags);
|
||
const action = positional[0] || "show";
|
||
if (action === "next") {
|
||
const base = gt.normalizeRomaji(str(flags.romaji));
|
||
if (!base) die("需要 `--romaji <英文名>`(只能是拉丁字母與數字)。");
|
||
const next = gt.nextCode(base);
|
||
if (!next) die(`\`${base}\` 的編號已經用到 99。`);
|
||
emit({ romaji: base, code: next }, flags.json, [`\`${base}\` 的下一個可用編號:\`${next}\``]);
|
||
return;
|
||
}
|
||
const slug = hostOf(flags, session);
|
||
if (action === "show") {
|
||
requireMember(slug, session, Boolean(flags["as-guest"]));
|
||
const code = gt.personaCode(slug);
|
||
const state = gt.loadSyncState(slug);
|
||
emit({ persona: slug, code, sync: state }, flags.json, [
|
||
`人格 \`${slug}\` 編號:${code ? `\`${code}\`` : "(尚未指派,用 `code assign --romaji <英文名>`)"}`,
|
||
code ? ` Gitea 存取庫:${state.repo_url || `${gt.giteaEnv().host}/${state.owner || "?"}/${code}`}` : "",
|
||
].filter(Boolean));
|
||
return;
|
||
}
|
||
if (action === "assign") {
|
||
requireOwner(slug, session);
|
||
const existing = gt.personaCode(slug);
|
||
if (existing && !flags.force) die(`人格 \`${slug}\` 已有編號 \`${existing}\`。要重新指派請加 --force。`);
|
||
let code = str(flags.code);
|
||
if (code && !gt.validCode(code)) die(`編號 \`${code}\` 不合法(格式:ASUNA-01)。`);
|
||
if (!code) {
|
||
const base = gt.normalizeRomaji(str(flags.romaji) || slug);
|
||
if (!base) die("需要 `--romaji <英文名>`(中文名請先轉羅馬拼音並跟使用者確認拼法)。");
|
||
code = gt.nextCode(base);
|
||
if (!code) die(`\`${base}\` 的編號已經用到 99。`);
|
||
}
|
||
const config = pl.loadConfig(slug);
|
||
config.persona = slug;
|
||
config.code = code;
|
||
config.romaji = gt.codePrefix(code);
|
||
config.schema = 2;
|
||
pl.writeJson(pl.configPath(slug), config);
|
||
const lines = [`✔ 人格 \`${slug}\` 的編號指派為 \`${code}\`。`];
|
||
let current = slug;
|
||
if (flags.rename && slug !== code) {
|
||
if (pl.personaExists(code)) die(`目錄 \`${code}\` 已存在,無法改名。`);
|
||
// 目錄名改成編號:先放掉自己的鎖 → 改名 → 重新取得鎖並重綁 session
|
||
pl.releaseLock(slug, session);
|
||
fs.renameSync(pl.personaDir(slug), pl.personaDir(code));
|
||
fs.rmSync(path.join(pl.personaDir(code), gt.SYNC_DIRNAME), { recursive: true, force: true });
|
||
const renamed = pl.loadConfig(code);
|
||
renamed.persona = code;
|
||
pl.writeJson(pl.configPath(code), renamed);
|
||
pl.acquireLock(code, session, { cwd: str(flags.cwd) || null });
|
||
pl.bindHost(session, code, { cwd: str(flags.cwd) || null });
|
||
current = code;
|
||
lines.push(` 目錄已改名:${pl.personaDir(code)}(.sync 快取已清掉,下次 push 會重新 clone)`);
|
||
}
|
||
if (!flags["no-gitea"] && !gt.giteaProblem()) {
|
||
try {
|
||
const info = await gt.initRemote(current, { code, private_: !flags.public });
|
||
lines.push(` 📦 Gitea:${info.repo.html_url}(${info.created ? "已建立" : "沿用既有"})`);
|
||
} catch (err) {
|
||
lines.push(` ⚠ Gitea 存取庫建立失敗(不影響本機):${err.message}`);
|
||
}
|
||
}
|
||
emit({ persona: current, code }, flags.json, lines);
|
||
return;
|
||
}
|
||
die(`未知 action:${action}(可用 show/assign/next)`);
|
||
};
|
||
|
||
/**
|
||
* 由人格資料產生圖示(SVG + PNG)。**建立人格並補齊 IDENTITY/SOUL 之後再跑**,
|
||
* 這樣配色與字母才會對得上最終的身分。同一個人格永遠得到同一張圖。
|
||
*/
|
||
commands.icon = async ({ flags, positional }) => {
|
||
const session = requireSession(flags);
|
||
const slug = hostOf(flags, session);
|
||
const action = positional[0] || "generate";
|
||
if (action === "show") {
|
||
requireMember(slug, session, Boolean(flags["as-guest"]));
|
||
const spec = ic.iconSpec(slug);
|
||
const src = spec.source || {};
|
||
emit({ persona: slug, spec, exists: ic.hasIcon(slug) }, flags.json, [
|
||
`人格 \`${slug}\` 圖示:${ic.hasIcon(slug) ? "✔ 已產生" : "✘ 尚未產生(跑 `icon generate`)"}`,
|
||
` 字母 ${spec.letters}|配色 ${spec.palette ? "取自參考照片" : "由編號雜湊"}` +
|
||
`:${JSON.stringify(spec.c1)} → ${JSON.stringify(spec.c2)}|seed ${spec.seed}`,
|
||
...(spec.palette ? [` 調色盤:${ic.paletteToString(spec.palette)}`] : []),
|
||
...(spec.style === "portrait" ? [` 特徵:${ic.featuresToString(spec.features)}`] : []),
|
||
...(src.url ? [` 參考來源:${src.url}${src.note ? `(${src.note})` : ""}${src.date ? `|${src.date}` : ""}`] : []),
|
||
` ${ic.iconSvgPath(slug)}`,
|
||
` ${ic.iconPngPath(slug)}`,
|
||
]);
|
||
return;
|
||
}
|
||
if (action === "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();
|
||
emit({ persona: slug, code: gt.personaCode(slug), problem, sync: state }, flags.json, [
|
||
`人格 \`${slug}\`|編號 ${gt.personaCode(slug) || "(無)"}|Gitea ${problem ? `⚠ ${problem}` : "✔ 已設定"}`,
|
||
` 存取庫:${state.repo_url || "(尚未建立,跑 \`sync init\`)"}`,
|
||
...gt.AREA_KEYS.map((key) => {
|
||
const info = state.areas[key] || {};
|
||
return ` ${gt.AREAS[key].label}(${gt.AREAS[key].why}):` +
|
||
`最後 push ${info.pushed_at || "—"}|最後 pull ${info.pulled_at || "—"}|${info.files ?? "?"} 個檔案`;
|
||
}),
|
||
]);
|
||
return;
|
||
}
|
||
const problem = gt.giteaProblem();
|
||
if (problem) die(`Gitea 尚未設定:${problem}(設 GITEA_HOST 與 GITEA_TOKEN,或用 PERSONA_GITEA_* 覆寫)`);
|
||
|
||
if (action === "init") {
|
||
let code = gt.personaCode(slug);
|
||
if (!code) die(`人格 \`${slug}\` 還沒有編號。先跑 \`code assign --romaji <英文名>\`。`);
|
||
const info = await gt.initRemote(slug, { code, owner: str(flags.owner) || null, private_: !flags.public });
|
||
emit(info, flags.json, [
|
||
`✔ 人格 \`${slug}\`(\`${info.code}\`)已對應到 Gitea:${info.repo.html_url}`,
|
||
` ${info.created ? "存取庫已建立" : "沿用既有存取庫"}|${info.repo.private ? "私有" : "公開"}|Wiki ${info.wikiCreated ? "已建立" : "已存在"}`,
|
||
...gt.AREA_KEYS.map((key) =>
|
||
` ${gt.AREAS[key].label}:${info.results[key]?.ok ? `${info.results[key].files} 個檔案已推送` : `⚠ ${info.results[key]?.reason || "失敗"}`}`),
|
||
]);
|
||
return;
|
||
}
|
||
if (action === "push") {
|
||
const out = [];
|
||
for (const key of areas) {
|
||
if (flags["if-due"] && !gt.pushDue(slug, key)) {
|
||
out.push({ area: key, ok: true, skipped: true, reason: "未到最小間隔" });
|
||
continue;
|
||
}
|
||
try {
|
||
out.push(await gt.pushArea(slug, key, { message: str(flags.message) }));
|
||
} catch (err) {
|
||
out.push({ area: key, ok: false, reason: err.message });
|
||
}
|
||
}
|
||
emit({ persona: slug, results: out }, flags.json, out.map((r) =>
|
||
r.skipped ? ` ${gt.AREAS[r.area].label}:略過(${r.reason})`
|
||
: r.ok ? `✔ ${gt.AREAS[r.area].label}:${r.changed ? `已推送 ${r.files} 個檔案` : "沒有變更"}`
|
||
: `✖ ${gt.AREAS[r.area].label}:${String(r.reason).slice(0, 160)}`));
|
||
return;
|
||
}
|
||
if (action === "verify") {
|
||
const out = [];
|
||
for (const key of areas) {
|
||
try {
|
||
out.push(await gt.verifyArea(slug, key));
|
||
} catch (err) {
|
||
out.push({ area: key, ok: false, reason: err.message });
|
||
}
|
||
}
|
||
const bad = out.filter((r) => !r.ok && !r.skipped);
|
||
emit({ persona: slug, results: out, ok: bad.length === 0 }, flags.json, out.map((r) =>
|
||
r.skipped
|
||
? ` ${gt.AREAS[r.area].label}:略過(${r.reason})`
|
||
: r.ok
|
||
? `✔ ${gt.AREAS[r.area].label}:本機與 Gitea 一致(${r.files} 個檔案)`
|
||
: `✖ ${gt.AREAS[r.area].label}:不一致` +
|
||
(r.pending?.length ? `,還沒推上去的檔案:${r.pending.slice(0, 8).join(", ")}` : "") +
|
||
(r.reason ? `(${r.reason})` : "")));
|
||
if (bad.length) process.exit(1);
|
||
return;
|
||
}
|
||
if (action === "pull") {
|
||
const out = [];
|
||
for (const key of areas) {
|
||
try {
|
||
out.push(await gt.pullArea(slug, key, { force: Boolean(flags.force) }));
|
||
} catch (err) {
|
||
out.push({ area: key, ok: false, reason: err.message });
|
||
}
|
||
}
|
||
pl.rebuildIndex(slug);
|
||
emit({ persona: slug, results: out }, flags.json, out.map((r) =>
|
||
r.conflicts?.length
|
||
? `✖ ${gt.AREAS[r.area].label}:本機與遠端都改過 ${r.conflicts.slice(0, 5).join(", ")};` +
|
||
"沒有覆蓋本機。確定要以遠端為準才加 --force。"
|
||
: r.ok
|
||
? `✔ ${gt.AREAS[r.area].label}:` +
|
||
(r.empty
|
||
? "遠端還是空的"
|
||
: `遠端有 ${r.changed?.length || 0} 個檔案更新,寫回本機 ${r.written?.length || 0} 個` +
|
||
`${(r.written?.length || 0) > (r.changed?.length || 0) ? "(含補回本機缺少的檔案)" : ""}`)
|
||
: `✖ ${gt.AREAS[r.area].label}:${String(r.reason).slice(0, 160)}`));
|
||
return;
|
||
}
|
||
die(`未知 action:${action}(可用 init/push/pull/status/verify)`);
|
||
};
|
||
|
||
commands.gc = ({ flags }) => {
|
||
const removed = pl.gcRuntime();
|
||
emit(removed, flags.json, [
|
||
`✔ 清理完成:sessions=${removed.sessions.length}, 死鎖=${removed.locks.join(",") || "無"}, ` +
|
||
`guest 租約=${removed.guests.join(",") || "無"}`,
|
||
]);
|
||
};
|
||
|
||
commands.guard = () => {
|
||
// 給 hook 用:從 stdin 讀 hook event,輸出 allow/deny。也可手動測試。
|
||
let raw = "";
|
||
try {
|
||
raw = fs.readFileSync(0, "utf8");
|
||
} catch {
|
||
raw = "";
|
||
}
|
||
let event;
|
||
try {
|
||
event = JSON.parse(raw);
|
||
} catch {
|
||
die("stdin 不是合法 JSON");
|
||
}
|
||
process.stdout.write(`${JSON.stringify(pl.guardDecide(event), null, 2)}\n`);
|
||
};
|
||
|
||
const HELP = `persona.mjs — jsc-persona 人格 / 記憶 / 情緒 / 關係圖 CLI
|
||
|
||
用法:node persona.mjs <subcommand> [options]
|
||
|
||
人格與鎖:
|
||
create --romaji <英文名> --session <id> [--persona <目錄名> --code <ASUNA-01> --name --creature
|
||
--vibe --emoji --avatar --baseline --origin --work --no-gitea --public]
|
||
人格編號 = 英文名全大寫 + 兩位索引(同名才遞增),也是 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 --scope short|inbox --room]
|
||
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> 列出達到「短期→長期」條件的候選與依據
|
||
consolidate --session <id> --name <n> --body <b> [--type --about --topics --salience --emotion --rules --source --forget]
|
||
prune / reindex --session <id>
|
||
|
||
情緒與圖:
|
||
emotion --session <id> [--apply joy=+10,...] [--baseline ...] [--trigger <why>]
|
||
[--read "<對方說的話>"] 只讀情緒訊號與該怎麼接,不改狀態
|
||
[--audit [--limit 20]] 最近幾輪的 delta 是不是只往一邊倒+對方的走向
|
||
套用時會飽和(越接近端點漲越慢)並受單輪預算限制(|delta| 總和 ≤ 40)
|
||
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 (使用者在關係圖裡是誰 → 決定語氣層)
|
||
|
||
多人格對話:
|
||
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 --limit --on --off --with-meta]
|
||
(post 會擋下「短時間內近似重複」與超過三句的發言;例外用 --allow-repeat / --force)
|
||
--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]
|
||
sync status|init|push|pull|verify --session <id> [--area files|wiki|all --if-due --force --message --owner]
|
||
verify 會確認「本機 = Gitea」,不一致就以非零結束(形象圖必須同步)
|
||
檔案區=高頻活狀態(情緒/短期記憶/心裡話/逐字),每輪對話後背景 push
|
||
Wiki 區=低頻設定(IDENTITY/SOUL/長期記憶/心智圖/關係圖),固化或改身分時 push
|
||
環境變數:GITEA_HOST / GITEA_TOKEN(或 PERSONA_GITEA_HOST / _TOKEN / _OWNER),
|
||
PERSONA_GITEA=off 可整個關掉,PERSONA_SYNC_MIN_SECONDS 調 push 間隔
|
||
|
||
搬家(離線檔案):
|
||
export --session <id> [--out <檔案> --with-journal --gzip --force] 匯出目前載入的人格
|
||
import --session <id> --file <檔案> [--persona <新 slug> --force --load]
|
||
|
||
維護:
|
||
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);
|
||
throw err;
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
process.exit(await main(process.argv.slice(2)));
|