1. 圖示三種樣式,優先看得到臉 photo 有參考圖且工具齊全 → 從官方視覺自動偵測並**裁出臉**,圓角+瞳色外框 portrait 有參考圖但缺工具 → **有五官的向量人物**(髮型/瞳色/服裝色都取自那張圖) badge 完全沒有參考圖 → 舊的雙色漸層 + 編號字母 portrait 與 badge 共用同一份「圖形清單」,SVG 與自寫柵格器從同一份資料畫, 兩邊不可能長得不一樣;photo 的 SVG 以 base64 內嵌裁好的 PNG,一樣不外連。 2. 缺工具會「提示安裝」,不靜默降級 新增 scripts/portrait.py(選用):Pillow 解碼裁切、OpenCV + lbpcascade_animeface 偵測動漫臉。toolReport() 會列出缺什麼、為什麼要、怎麼裝(venv 免 sudo), CLI 在退回向量形象時把這些印出來。 注意:**OpenCV 5 拿掉了 CascadeClassifier,必須裝 4.x**。 plugin 本體仍然零依賴——沒有這些工具照樣產得出有臉的形象圖。 新增 `icon faces`:列出參考圖裡偵測到的所有臉。多角色的主視覺一定要先看再挑 (--pick <索引>|largest|leftmost|rightmost 或 --face x,y,w,h),挑錯就是別人的臉。 3. Wiki 保存形象圖 icon.svg 與 icon.png 都在 Wiki 區,另外自動產生一頁 Icon:同時展示兩種格式, 並列出樣式、調色盤、來源網址、造型說明與裁切框。PNG 同時設為存取庫頭像。 已套用到兩個真實人格(皆為真實裁臉): ASUNA-01 《Unanswered//butterfly》(2026) 官方主視覺,臉 #1(anime-cascade) YUI-01 AniList 官方角色圖(frontal-cascade) selftest 148 項全綠(新增第 ⑯ 節:形象圖真的畫了五官、SVG/PNG 出自同一份清單、 工具偵測與安裝提示、Wiki 形象圖專頁與來源)。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1445 lines
65 KiB
JavaScript
1445 lines
65 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;
|
||
|
||
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", "on", "off", "with-meta", "all",
|
||
"with-journal", "gzip", "record", "load", "allow-repeat",
|
||
"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;
|
||
}
|
||
|
||
// --------------------------------------------------------------------------- //
|
||
// 權限檢查
|
||
// --------------------------------------------------------------------------- //
|
||
|
||
/** 呼叫者必須是這個人格的 exclusive 持有者。 */
|
||
function requireOwner(slug, sessionId) {
|
||
if (!slug) die("未指定人格,且本 session 沒有載入人格。");
|
||
if (!pl.personaExists(slug)) die(`人格 \`${slug}\` 不存在。可用:${pl.listPersonas().join(", ") || "(無)"}`);
|
||
const data = pl.loadSession(sessionId);
|
||
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);
|
||
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;
|
||
|
||
/**
|
||
* 里程碑事件(記憶固化、身分/關係變更)之後,把 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 ? "🎭 開啟(只輸出人格對話)" : "關閉"}`,
|
||
]);
|
||
};
|
||
|
||
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} 句上限 → 砍到重點。`);
|
||
if (!repeat && !tooLong) {
|
||
lines.push(`✔ 沒說過,${sentences} 句,可以說。`);
|
||
if (flags.record) pl.recordSaid(slug, text, { kind: "reply" });
|
||
}
|
||
emit({ persona: slug, repeat, sentences, ok: !repeat && !tooLong }, 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";
|
||
const VALID_TYPES = ["fact", "preference", "event", "promise", "relationship", "insight", "boundary", "canon"];
|
||
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);
|
||
}
|
||
}
|
||
if (flags.apply) {
|
||
if (role === "guest") die("guest(sub agent)不得改寫人格的情緒狀態。");
|
||
const deltas = parseDeltas(flags.apply);
|
||
state = pl.applyEmotion(state, deltas, str(flags.trigger));
|
||
pl.appendJsonl(pl.journalPath(slug), {
|
||
ts: pl.nowIso(), kind: "emotion", trigger: str(flags.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`。");
|
||
pl.upsertRelationNode(slug, {
|
||
id: str(flags.id) || pl.slugify(name),
|
||
name,
|
||
kind: str(flags.kind) || "human",
|
||
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,
|
||
});
|
||
pl.renderRelations(slug);
|
||
ok(`關係節點 \`${name}\` 已更新。`);
|
||
pushWikiLater(slug, session, flags);
|
||
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/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 guest = str(flags.guest);
|
||
if (!guest) die("需要 `--guest <slug>`。");
|
||
if (guest === host) die("不能邀請自己。");
|
||
if (!pl.personaExists(guest)) die(`人格 \`${guest}\` 不存在。可用:${pl.listPersonas().join(", ")}`);
|
||
// guest 租約是唯讀的(`mode: "guest-readonly"`),與 exclusive 載入鎖並存不會互相覆寫:
|
||
// guest 在 sub agent 裡被 PreToolUse hook 擋掉所有寫入工具,記憶只能進 inbox、情緒不得更動。
|
||
// 因此這裡**不以 exclusive 的標準驗鎖**,只在對方仍活著時提示使用者「你看到的是唯讀旁聽」。
|
||
const lock = pl.readJson(pl.lockPath(guest)) ?? {};
|
||
const heldElsewhere =
|
||
Boolean(Object.keys(lock).length) && lock.session_id !== session && !pl.lockIsDead(lock);
|
||
const stamp = pl.nowIso().replace(/[-:TZ]/g, "").slice(0, 14);
|
||
const room = str(flags.room) || `${host}-${guest}-${stamp}`;
|
||
pl.createRoom(room, host, session, str(flags.topic));
|
||
pl.joinRoom(room, guest);
|
||
pl.addGuestLease(guest, session, room, host);
|
||
const data = pl.loadSession(session);
|
||
data.guests ??= {};
|
||
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" });
|
||
emit({ room, guest, host, dir: pl.roomDir(room), theater: data.theater, held_elsewhere: heldElsewhere }, flags.json, [
|
||
`✔ 已邀請人格 \`${guest}\` 以 guest(唯讀)身分加入聊天室 \`${room}\`。`,
|
||
...(heldElsewhere
|
||
? [
|
||
` ℹ 它同時被另一個程序 exclusive 載入(session ${String(lock.session_id).slice(0, 8)}…,cwd ${lock.cwd});` +
|
||
"你這邊拿到的是唯讀旁聽,寫入仍由那個程序獨占,兩者不會互相覆寫。",
|
||
]
|
||
: []),
|
||
` 聊天室路徑:${pl.roomDir(room)}`,
|
||
` 🎭 劇場模式已${data.theater ? "開啟:接下來只能輸出人格對話(`名字:內容`),其他訊息一律隱藏" : "關閉"}。`,
|
||
" 請用 Agent 工具、subagent_type=\"jsc-persona:persona-guest\" 啟動它,prompt 內帶:",
|
||
` 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(", ") || "(無)"})。`);
|
||
}
|
||
if (action === "post") {
|
||
const speaker = str(flags.as) || data.host;
|
||
if (!speaker) die("需要 `--as <persona>`。");
|
||
const [, speakerRole] = requireMember(speaker, session, Boolean(flags["as-guest"]));
|
||
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`。)",
|
||
);
|
||
}
|
||
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 });
|
||
// guest 對自己的狀態唯讀,它說過的話留在聊天室逐字稿裡就夠了(roomRepeat 讀得到)
|
||
if (speakerRole !== "guest") pl.recordSaid(speaker, text, { room, kind: "room" });
|
||
ok(`\`${speaker}\` 已發言於 \`${room}\`(情緒 ${emotion})。`);
|
||
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) {
|
||
lines.push(`[${row.ts}] ${row.speaker}${row.emotion ? `(${row.emotion})` : ""}:${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/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)}`] : []),
|
||
...(src.url ? [` 參考來源:${src.url}${src.note ? `(${src.note})` : ""}${src.date ? `|${src.date}` : ""}`] : []),
|
||
` ${ic.iconSvgPath(slug)}`,
|
||
` ${ic.iconPngPath(slug)}`,
|
||
]);
|
||
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(`\u672a\u77e5 action\uff1a${action}\uff08\u53ef\u7528 generate/show/faces\uff09`);
|
||
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("/")}。`);
|
||
// 參考照片:給了就試著裁臉;工具不足會退回向量人物形象並印出安裝指令
|
||
let photo = null;
|
||
if (flags.photo || (flags["from-source"] && source?.url)) {
|
||
const want = str(flags.photo) || source.url;
|
||
try {
|
||
photo = await ic.fetchPhoto(want, path.join(pl.personaDir(slug), ".sync"));
|
||
} catch (err) {
|
||
die(`${err.message}`);
|
||
}
|
||
}
|
||
const res = ic.generateIcon(slug, {
|
||
size, palette, source, style, photo,
|
||
pick: str(flags.pick) || null,
|
||
face: str(flags.face) || null,
|
||
});
|
||
const lines = [
|
||
`✔ 人格 \`${slug}\` 的圖示已產生(${size}×${size})。`,
|
||
` ${res.svg}(${(res.bytes.svg / 1024).toFixed(1)} KB)`,
|
||
` ${res.png}(${(res.bytes.png / 1024).toFixed(1)} KB)`,
|
||
res.spec.style === "photo"
|
||
? ` 形象圖:真實照片裁臉(偵測方式 ${res.photo?.info?.method},裁切框 ${JSON.stringify(res.photo?.info?.box)})`
|
||
: res.spec.style === "portrait"
|
||
? ` 形象圖:向量人物(有臉)|配色取自參考照片:${ic.paletteToString(res.spec.palette)}`
|
||
: ` 徽章:字母 ${res.spec.letters}|配色由編號 \`${res.spec.code}\`、名字與 emoji 決定`,
|
||
...(source?.url || res.spec.source?.url
|
||
? [` 參考來源:${(source || res.spec.source).url}${(source || res.spec.source).note ? `\n ${(source || res.spec.source).note}` : ""}`]
|
||
: []),
|
||
// 缺工具就把安裝方式講清楚,而不是默默降級
|
||
...(photo && res.photo && !res.photo.ok
|
||
? [
|
||
...(res.photo.reason ? [` ⚠ 照片裁臉失敗:${res.photo.reason}`] : []),
|
||
...ic.installHintLines(res.photo.report || ic.toolReport()),
|
||
]
|
||
: []),
|
||
];
|
||
// 圖示屬於低頻的身分資料 → 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.letters}` });
|
||
if (pushed.ok && pushed.changed) lines.push(" ↑ 圖示已同步到 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 === "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)`);
|
||
};
|
||
|
||
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]
|
||
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>]
|
||
mindmap show|thread|list --session <id> [--topic <t>] [--force]
|
||
relation node|edge|render|show --session <id> [--name --id --kind --closeness --trust --note --tags --from --to --label --affinity]
|
||
|
||
多人格對話:
|
||
invite --session <id> --guest <slug> [--host --room --topic] (自動開啟劇場模式)
|
||
leave --session <id> --guest <slug> [--room]
|
||
room post|read|script|list|theater --session <id> [--room --as --text --text-file --emotion --limit --on --off --with-meta]
|
||
(post 會擋下「短時間內近似重複」與超過三句的發言;例外用 --allow-repeat / --force)
|
||
|
||
圖示(建立人格並補齊資料後跑):
|
||
icon generate|show|faces --session <id> [--size 512 --force --no-gitea]
|
||
[--palette "hair=#..,eye=#..,accent=#..,secondary=#..,light=#.."]
|
||
[--source-url <參考照片網址> --source-note <說明> --source-date <YYYY-MM-DD>]
|
||
[--photo <圖片路徑或網址> | --from-source] [--style portrait|badge]
|
||
[--pick largest|leftmost|rightmost|<索引> | --face x,y,w,h]
|
||
faces 會列出參考照片裡偵測到的臉——多角色的圖務必先看過再用 --pick 指定。
|
||
產出 icon.svg + icon.png,設為 Gitea 存取庫頭像並同步到 Wiki 區。
|
||
沒帶 --palette → 徽章樣式,配色由編號/名字/emoji 雜湊而來。
|
||
帶了 --palette → 向量人物形象(有臉),配色取自「你實際看過的參考照片」
|
||
(必須同時帶 --source-url 存證)。
|
||
帶了 --photo → 直接用那張照片裁出臉當形象圖;工具不足時會退回向量人物形象
|
||
並印出安裝指令(Pillow / OpenCV / 動漫臉模型)。
|
||
|
||
編號與 Gitea(存取庫名稱 = 人格編號):
|
||
code show|assign|next --session <id> [--romaji <英文名> --code <ASUNA-01> --rename --force --public]
|
||
sync status|init|push|pull --session <id> [--area files|wiki|all --if-due --force --message --owner]
|
||
檔案區=高頻活狀態(情緒/短期記憶/心裡話/逐字),每輪對話後背景 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(成功時不輸出;劇場模式必用)
|
||
`;
|
||
|
||
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);
|
||
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)));
|