讓人格講話更像正常人,並讓人格可以搬家。
1. 心裡話(inner voice)
- 新增 `think` 子指令:推導/盤算寫進 state/inner.jsonl,
只印「💭 心想 N 句」,內容永不回顯給使用者。
- <persona-context> 每輪帶回最近三句,推論因此有連續性。
2. 短時間內不重複
- 說出口的話由 Stop hook 自動記進 state/said.jsonl。
- 新增 `said check|list`:事前確認這句是不是又要說一次。
- 相似度 = 字元 bigram Jaccard(0.4) + 字集合 Jaccard(0.6),
門檻 0.72;字集合權重較高才分得開「重排語序」(要擋)
與「換掉關鍵詞」(是新資訊,放行)。
3. 回話一到三句 + 劇場模式同樣適用
- 規則注入 turnContext 與 UserPromptSubmit hint。
- 劇場模式的 CLI 都帶 --quiet,警告會被丟掉,所以 `room post`
對近似重複與超過三句的發言直接拒收(--allow-repeat / --force 例外),
host 與 guest 走同一支 CLI,一視同仁。
4. 人格匯出匯入(新 skill persona-transfer)
- `export` / `import`:單一 JSON bundle(可 gzip)+ sha256 checksum,
無外部依賴。不帶載入鎖與 guest 租約,journal/ 需明確 --with-journal。
- 匯出只能匯出本 session 已載入的人格,否則等於跨人格讀取的後門;
bundle 內的 ../ 逃逸路徑一律拒收。
順帶修正:agents/persona-guest.md 的 CLI 範例都少了 --as-guest,
照著跑會被 requireMember 擋下,guest 根本無法認識自己或發言。
selftest 102 項全綠(新增 ⑪ 說話節制、⑫ 匯出匯入,含重複判定校準對照表)。
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1044 lines
44 KiB
JavaScript
1044 lines
44 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";
|
||
|
||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||
const TEMPLATE_DIR = path.join(HERE, "..", "skills", "persona-create", "templates");
|
||
|
||
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",
|
||
]);
|
||
|
||
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;
|
||
|
||
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 = ({ flags }) => {
|
||
const session = requireSession(flags);
|
||
const slug = str(flags.persona);
|
||
if (!pl.validSlug(slug)) die("slug 只能是小寫英數與連字號(最長 48 字),例如 `lumi`、`shen-yu`。");
|
||
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,
|
||
display_name: mapping.NAME,
|
||
created_at: pl.nowIso(),
|
||
created_by_session: session,
|
||
origin: str(flags.origin) || "custom",
|
||
source_work: str(flags.work),
|
||
schema: 1,
|
||
});
|
||
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}\` 建立於 ${root},已取得載入鎖並綁定本 session。`);
|
||
say(` 下一步:補完 ${root}/IDENTITY.md 與 SOUL.md,再用 /jsc-persona:persona-chat 開始對話。`);
|
||
};
|
||
|
||
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,
|
||
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}\` ${state}` +
|
||
(r.locked ? `(session ${r.owner_session}…, cwd ${r.owner_cwd})` : "") +
|
||
`|guest ${r.guests}|長期記憶 ${r.long_term}|短期 ${r.short_term}` +
|
||
(r.identity ? `|${r.identity}` : ""),
|
||
);
|
||
}
|
||
emit({ home: pl.personaHome(), personas: rows }, flags.json, lines);
|
||
};
|
||
|
||
commands.load = ({ 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 });
|
||
pl.pruneShortTerm(slug);
|
||
pl.rebuildIndex(slug);
|
||
const lines = [`✔ 已載入人格 \`${slug}\`(exclusive,session ${session.slice(0, 8)}…,租約 ${lock.lease_seconds}s)`];
|
||
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 = ({ flags }) => {
|
||
const session = requireSession(flags);
|
||
const data = pl.loadSession(session);
|
||
const slug = str(flags.persona) || data.host;
|
||
if (!slug) die("本 session 沒有載入任何人格。");
|
||
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 已重建。`);
|
||
};
|
||
|
||
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)} 則長期記憶)。`);
|
||
};
|
||
|
||
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}\` 已更新。`);
|
||
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} 已更新。`);
|
||
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);
|
||
};
|
||
|
||
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 --persona <slug> --session <id> [--name --creature --vibe --emoji --avatar --baseline --origin --work]
|
||
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)
|
||
|
||
搬家:
|
||
export --session <id> [--out <檔案> --with-journal --gzip --force] 匯出目前載入的人格
|
||
import --session <id> --file <檔案> [--persona <新 slug> --force --load]
|
||
|
||
維護:
|
||
gc 清理死鎖與過期租約
|
||
guard (內部)從 stdin 讀 hook event 測試隔離判斷
|
||
|
||
全域旗標:--json(機器可讀)、--quiet(成功時不輸出;劇場模式必用)
|
||
`;
|
||
|
||
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 {
|
||
command({ flags: parsed.flags, positional: parsed._ });
|
||
} catch (err) {
|
||
if (err instanceof pl.LockError) die(err.message);
|
||
throw err;
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
process.exit(main(process.argv.slice(2)));
|