Files
persona/scripts/persona.mjs
T
jiantw83andClaude Opus 5 15e4eea8ac feat: 圖示改為人物形象圖(有臉),並讓 Wiki 保存 SVG + PNG
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>
2026-07-30 02:39:58 +00:00

1445 lines
65 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
// persona.mjs — jsc-persona 的人格 / 記憶 / 情緒 / 關係圖 CLI(Node.js,只用內建模組)
//
// 所有子指令都需要 `--session <session_id>`(除了 list / status / gc)。
// session_id 由 SessionStart hook 注入到上下文(PERSONA_SESSION=...),
// hook 會驗證 CLI 帶的 --session 與真實 session 相符,藉此讓「人格鎖」與
// 「跨人格隔離」無法被繞過。
//
// 全域旗標:--json(機器可讀輸出)、--quiet(成功時不輸出,劇場模式用)
import fs from "node:fs";
import path from "node:path";
import process from "node:process";
import zlib from "node:zlib";
import { fileURLToPath } from "node:url";
import * as pl from "./persona-lib.mjs";
import * as gt from "./persona-gitea.mjs";
import * as ic from "./persona-icon.mjs";
const HERE = path.dirname(fileURLToPath(import.meta.url));
const TEMPLATE_DIR = path.join(HERE, "..", "skills", "persona-create", "templates");
const SELF = path.join(HERE, "persona.mjs");
let QUIET = false;
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;
}
/**
* 呼叫者是 hostowner)或以 `--as-guest` 自稱的受邀人格。
* 受邀人格的資料只有它自己(persona-guest sub agent)能讀;主程序即使邀請了它,
* 也只能看它在聊天室說出口的話。`--as-guest` 由 PreToolUse hook 把關。
*/
function requireMember(slug, sessionId, asGuest = false) {
if (!slug) die("未指定人格,且本 session 沒有載入人格。");
if (!pl.personaExists(slug)) die(`人格 \`${slug}\` 不存在。`);
const data = pl.loadSession(sessionId);
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}\`exclusivesession ${session.slice(0, 8)}…,租約 ${lock.lease_seconds}s`];
for (const res of pulled) {
if (res.conflicts?.length) {
lines.push(
`⚠ ${gt.AREAS[res.area].label}有衝突,**沒有覆蓋本機**${res.conflicts.slice(0, 5).join(", ")}` +
"。請告訴使用者:本機與 Gitea 都改過同一份資料,要保留哪一邊(`sync pull --force` 會以遠端為準)。",
);
} else if (res.ok && res.written?.length) {
lines.push(`↓ ${gt.AREAS[res.area].label}從 Gitea 拉回 ${res.written.length} 個檔案。`);
} else if (!res.ok && !res.skipped) {
lines.push(`⚠ ${gt.AREAS[res.area]?.label || res.area}同步失敗(不影響本機):${String(res.reason).slice(0, 120)}`);
}
}
if (lock.took_over_from) {
const prev = lock.took_over_from;
lines.push(
`⚠ 這把鎖是接手來的:原持有者 session ${String(prev.session_id || "").slice(0, 8)}…(cwd ${prev.cwd}` +
`已失聯 ${prev.stale_minutes} 分鐘。請向使用者說明,若那個程序其實還活著,兩邊的記憶可能會互相覆蓋。`,
);
}
const context = pl.turnContext(slug, session);
lines.push(context);
emit({ persona: slug, lock, context }, flags.json, lines);
};
commands.release = async ({ flags }) => {
const session = requireSession(flags);
const data = pl.loadSession(session);
const slug = str(flags.persona) || data.host;
if (!slug) die("本 session 沒有載入任何人格。");
// 收工前把兩區都推上去(失敗不阻斷釋放,人格不能被鎖在網路問題裡)
if (!flags["no-gitea"] && !gt.giteaProblem() && pl.personaExists(slug) && gt.personaCode(slug)) {
for (const area of gt.AREA_KEYS) {
try {
const res = await gt.pushArea(slug, area, { message: `release: 對話結束 ${pl.nowIso()}` });
if (res.ok && res.changed) say(` ↑ ${gt.AREAS[area].label}已推上 Gitea。`);
else if (!res.ok && !res.skipped) say(` ⚠ ${gt.AREAS[area].label}推送失敗:${String(res.reason).slice(0, 120)}`);
} catch (err) {
say(` ⚠ ${gt.AREAS[area].label}推送失敗:${err.message.slice(0, 120)}`);
}
}
}
const released = pl.unbindSession(session);
ok(`已釋放人格 \`${slug}\` 的載入鎖${released.guests.length ? `,並退出 guest${released.guests.join(", ")}` : "。"}`);
};
commands.status = ({ flags }) => {
const slug = str(flags.persona);
if (slug) {
const status = pl.lockStatus(slug);
const lines = [
`人格 \`${slug}\`${status.locked ? "🔒 已載入" : status.stale ? "⚠ 死鎖可接手" : "🔓 空閒"}`,
` owner: ${JSON.stringify(status.owner)}`,
` guests: ${JSON.stringify(status.guests)}`,
];
if (pl.personaExists(slug)) lines.push(` ${pl.emotionBrief(slug)}`);
emit(status, flags.json, lines);
return;
}
const session = str(flags.session);
const data = session ? pl.loadSession(session) : {};
emit(data, flags.json, [
`session ${(session || "-").slice(0, 12)}…`,
` host 人格:${data.host || "(未載入)"}`,
` guest 人格:${Object.keys(data.guests || {}).join(", ") || "(無)"}`,
` 聊天室:${(data.rooms || []).join(", ") || "(無)"}`,
` 劇場模式:${data.theater ? "🎭 開啟(只輸出人格對話)" : "關閉"}`,
]);
};
commands.heartbeat = ({ flags }) => {
const session = requireSession(flags);
const data = pl.loadSession(session);
if (data.host) pl.heartbeatLock(data.host, session);
for (const [guest, info] of Object.entries(data.guests || {})) {
pl.addGuestLease(guest, session, info.room || "", data.host || "");
}
ok(`heartbeathost=${data.host}guests=${Object.keys(data.guests || {}).join(", ") || "(無)"}`);
};
commands.show = ({ flags }) => {
const session = requireSession(flags);
const slug = hostOf(flags, session);
requireMember(slug, session, Boolean(flags["as-guest"]));
const files = { identity: "IDENTITY.md", soul: "SOUL.md", agents: "AGENTS.md", user: "USER.md" };
const what = str(flags.what) || "all";
const chosen = what === "all" ? Object.values(files) : [files[what]].filter(Boolean);
if (!chosen.length) die(`--what 只能是 ${Object.keys(files).join("/")}/all。`);
const out = [];
for (const filename of chosen) {
const file = path.join(pl.personaDir(slug), filename);
if (!fs.existsSync(file)) continue;
// 只給實際內容,模板註解(<!-- ... -->)對人格認知沒幫助
const body = fs.readFileSync(file, "utf8").replace(/<!--[\s\S]*?-->\n?/g, "").trimEnd();
out.push(`===== ${filename} =====\n${body}`);
}
out.push(`===== 狀態 =====\n${pl.emotionBrief(slug)}`);
process.stdout.write(`${out.join("\n\n")}\n`);
};
commands.brief = ({ flags }) => {
const session = requireSession(flags);
const slug = hostOf(flags, session);
requireMember(slug, session, Boolean(flags["as-guest"]));
process.stdout.write(`${pl.turnContext(slug, session, str(flags.query))}\n`);
};
commands.remember = ({ flags }) => {
const session = requireSession(flags);
const slug = hostOf(flags, session);
const [, role] = requireMember(slug, session, Boolean(flags["as-guest"]));
const scope = str(flags.scope) || "short";
if (role === "guest" && scope !== "inbox") {
die("guestsub agent)只能寫入 inbox`--scope inbox --room <room>`。");
}
const text = str(flags.text);
if (!text) die("需要 `--text`。");
const entry = {
ts: pl.nowIso(),
role: str(flags.role) || "user",
text,
topics: csv(flags.topics),
entities: csv(flags.entities),
intent: str(flags.intent),
salience: num(flags.salience, 40),
emotion_deltas: parseDeltas(flags.emotion),
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}\` 的 inboxroom ${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("guestsub agent)的心裡話要帶 `--room`,會寫進自己的 inbox。");
pl.appendJsonl(pl.inboxPath(slug, room), {
ts: pl.nowIso(), role: "inner", kind, text, room, salience: num(flags.salience, 35),
});
count = pl.readJsonl(pl.inboxPath(slug, room)).filter((r) => r.role === "inner").length;
} else {
pl.recordInner(slug, text, { kind, room });
count = pl.innerCount(slug);
}
emit({ persona: slug, kind, count }, flags.json, [`💭 心想 ${count} 句`]);
};
/** 說過的話:查最近說了什麼、以及「這句是不是又要再說一次」。 */
commands.said = ({ flags, positional }) => {
const session = requireSession(flags);
const slug = hostOf(flags, session);
requireMember(slug, session, Boolean(flags["as-guest"]));
const action = positional[0] || "check";
if (action === "list") {
const rows = pl.recentSaid(slug, num(flags.limit, 8));
emit({ persona: slug, said: rows }, flags.json, [
`\`${slug}\` 最近說過的話(${rows.length} 則):`,
...rows.map((r) => ` - [${r.ts}] ${String(r.text || "").replace(/\n/g, " ").slice(0, 100)}`),
]);
return;
}
if (action === "check") {
const text = str(flags.text);
if (!text) die("需要 `--text`(你打算說的話)。");
const opts = {
minutes: num(flags.minutes, pl.REPEAT_WINDOW_MINUTES),
threshold: num(flags.threshold, pl.REPEAT_THRESHOLD),
};
const repeat = pl.saidRepeat(slug, text, opts);
const sentences = pl.sentenceCount(text);
const tooLong = sentences > pl.MAX_SENTENCES;
const lines = [];
if (repeat) {
lines.push(
`⚠ 這句跟 ${repeat.minutes_ago} 分鐘前說過的話相似 ${repeat.similarity}:「${repeat.text.slice(0, 60)}」`,
" → 換個角度、補新資訊,或直接推進話題;不要再說一次。",
);
}
if (tooLong) lines.push(`⚠ 這段有 ${sentences} 句,超過 ${pl.MAX_SENTENCES} 句上限 → 砍到重點。`);
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("guestsub 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)。**建立人格並補齊 IDENTITYSOUL 之後再跑**
* 這樣配色與字母才會對得上最終的身分。同一個人格永遠得到同一張圖。
*/
commands.icon = async ({ flags, positional }) => {
const session = requireSession(flags);
const slug = hostOf(flags, session);
const action = positional[0] || "generate";
if (action === "show") {
requireMember(slug, session, Boolean(flags["as-guest"]));
const spec = ic.iconSpec(slug);
const src = spec.source || {};
emit({ persona: slug, spec, exists: ic.hasIcon(slug) }, flags.json, [
`人格 \`${slug}\` 圖示:${ic.hasIcon(slug) ? "✔ 已產生" : "✘ 尚未產生(跑 `icon generate`"}`,
` 字母 ${spec.letters}|配色 ${spec.palette ? "取自參考照片" : "由編號雜湊"}` +
`${JSON.stringify(spec.c1)}${JSON.stringify(spec.c2)}seed ${spec.seed}`,
...(spec.palette ? [` 調色盤:${ic.paletteToString(spec.palette)}`] : []),
...(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 只能是 162048。");
let palette = null;
if (flags.palette) {
palette = ic.parsePalette(str(flags.palette));
if (!palette) {
die(
"`--palette` 格式錯誤。要 `hair=#rrggbb,eye=#rrggbb,accent=#rrggbb,secondary=#rrggbb,light=#rrggbb`" +
"其中 hair 與 accent 必填(顏色請取自你實際看過的參考照片)。",
);
}
if (!str(flags["source-url"])) {
die("用 `--palette` 就必須帶 `--source-url`:配色是從哪張圖取的要留得下來(可查證)。");
}
}
const source = str(flags["source-url"])
? {
url: str(flags["source-url"]),
note: str(flags["source-note"]) || null,
date: str(flags["source-date"]) || pl.nowIso().slice(0, 10),
}
: null;
const style = str(flags.style) || null;
if (style && !ic.STYLES.includes(style)) die(`--style 只能是 ${ic.STYLES.join("/")}。`);
// 參考照片:給了就試著裁臉;工具不足會退回向量人物形象並印出安裝指令
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)));