// persona-lib.mjs — jsc-persona 的共用核心(Node.js,只用內建模組)
//
// 負責:
// * 人格倉庫路徑與 slug 規則
// * 單一程序載入鎖(exclusive lock)與 guest lease
// * session 綁定(host / guests / rooms / agent pins / 劇場模式)
// * 十二情緒模型(六正向 + 六負向)與衰減
// * 短期記憶 / 長期記憶 / 心智圖 / 思維導圖 / 人際關係圖 的讀寫
// * 跨人格隔離的判斷核心(guard)
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import crypto from "node:crypto";
// --------------------------------------------------------------------------- //
// 路徑
// --------------------------------------------------------------------------- //
export const RUNTIME_DIRNAME = ".runtime";
export const ROOMS_DIRNAME = ".rooms";
export const LEASE_SECONDS = 900; // 15 分鐘沒有 heartbeat 視為死鎖,可被接手
export const GUEST_LEASE_SECONDS = 1800; // guest(sub agent)租約
function expandUser(p) {
if (p === "~") return os.homedir();
if (p.startsWith("~/")) return path.join(os.homedir(), p.slice(2));
return p;
}
export function personaHome() {
const raw = process.env.PERSONA_HOME;
if (raw) return path.resolve(expandUser(raw));
return path.resolve(path.join(os.homedir(), ".claude", "personas"));
}
export const runtimeDir = () => path.join(personaHome(), RUNTIME_DIRNAME);
export const sessionsDir = () => path.join(runtimeDir(), "sessions");
export const roomsDir = () => path.join(personaHome(), ROOMS_DIRNAME);
export const personaDir = (slug) => path.join(personaHome(), slug);
const SLUG_RE = /^[a-z0-9][a-z0-9-]{0,47}$/;
const RESERVED_SLUGS = new Set([RUNTIME_DIRNAME, ROOMS_DIRNAME, "", ".", ".."]);
export function validSlug(slug) {
return typeof slug === "string" && SLUG_RE.test(slug) && !RESERVED_SLUGS.has(slug);
}
const CJK_CLASS = "\\u3040-\\u30ff\\u3400-\\u4dbf\\u4e00-\\u9fff\\uac00-\\ud7af";
/** 檔名/節點 id 用。保留中日韓字(檔名可讀),其餘壓成連字號;全空則用雜湊。 */
export function slugify(text) {
const src = (text ?? "").normalize("NFKC");
let norm = src.replace(new RegExp(`[^A-Za-z0-9${CJK_CLASS}]+`, "g"), "-").replace(/^-+|-+$/g, "");
norm = norm.replace(/[A-Z]/g, (c) => c.toLowerCase());
if (!norm) {
return "n-" + crypto.createHash("md5").update(String(text ?? "")).digest("hex").slice(0, 8);
}
return norm.slice(0, 48);
}
/** Mermaid 節點別名:只能是英數與底線;非 ASCII 名稱改用穩定雜湊。 */
export function mermaidId(nodeId) {
const alias = String(nodeId ?? "").replace(/[^A-Za-z0-9_]/g, "_");
if (!/[A-Za-z0-9]/.test(alias)) {
return "n_" + crypto.createHash("md5").update(String(nodeId ?? "")).digest("hex").slice(0, 8);
}
return alias;
}
export function listPersonas() {
const home = personaHome();
let entries;
try {
entries = fs.readdirSync(home, { withFileTypes: true });
} catch {
return [];
}
return entries
.filter((e) => e.isDirectory() && validSlug(e.name) && fs.existsSync(path.join(home, e.name, "IDENTITY.md")))
.map((e) => e.name)
.sort();
}
export function personaExists(slug) {
return validSlug(slug) && fs.existsSync(path.join(personaDir(slug), "IDENTITY.md"));
}
// --------------------------------------------------------------------------- //
// 時間與檔案 IO
// --------------------------------------------------------------------------- //
export const iso = (date = new Date()) => new Date(Math.floor(date.getTime() / 1000) * 1000).toISOString().replace(".000Z", "Z");
export const nowIso = () => iso(new Date());
export function parseIso(value) {
if (!value) return null;
const dt = new Date(value);
return Number.isNaN(dt.getTime()) ? null : dt;
}
export function ageSeconds(value) {
const dt = parseIso(value);
if (!dt) return Infinity;
return (Date.now() - dt.getTime()) / 1000;
}
export function minutesAgo(minutes) {
return new Date(Date.now() - minutes * 60_000);
}
export function readJson(file, fallback = null) {
try {
return JSON.parse(fs.readFileSync(file, "utf8"));
} catch {
return fallback;
}
}
export function writeJson(file, obj) {
fs.mkdirSync(path.dirname(file), { recursive: true });
const tmp = `${file}.tmp${process.pid}`;
fs.writeFileSync(tmp, JSON.stringify(obj, null, 2) + "\n", "utf8");
fs.renameSync(tmp, file);
}
export function writeText(file, text) {
fs.mkdirSync(path.dirname(file), { recursive: true });
const tmp = `${file}.tmp${process.pid}`;
fs.writeFileSync(tmp, text, "utf8");
fs.renameSync(tmp, file);
}
/** 單行 append(O_APPEND 對單行寫入是原子的),guest 也能安全使用。 */
export function appendJsonl(file, obj) {
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.appendFileSync(file, JSON.stringify(obj) + "\n", { encoding: "utf8", mode: 0o600 });
}
export function readJsonl(file, limit = null) {
let text;
try {
text = fs.readFileSync(file, "utf8");
} catch {
return [];
}
let lines = text.split("\n").filter((l) => l.trim());
if (limit !== null) lines = lines.slice(-limit);
const out = [];
for (const line of lines) {
try {
out.push(JSON.parse(line));
} catch {
/* 壞行跳過 */
}
}
return out;
}
// --------------------------------------------------------------------------- //
// 十二情緒模型(六正向 + 六負向)
// --------------------------------------------------------------------------- //
// key -> { zh, polarity, arousal 權重, 預設半衰期分鐘 }
export const EMOTIONS = {
// 六正向
joy: { zh: "喜悅", polarity: +1, arousal: 0.6, halfLife: 120 },
trust: { zh: "信任", polarity: +1, arousal: 0.3, halfLife: 720 },
anticipation: { zh: "期待", polarity: +1, arousal: 0.6, halfLife: 240 },
gratitude: { zh: "感激", polarity: +1, arousal: 0.4, halfLife: 480 },
serenity: { zh: "平靜", polarity: +1, arousal: 0.1, halfLife: 180 },
delight: { zh: "驚喜", polarity: +1, arousal: 0.9, halfLife: 60 },
// 六負向
anger: { zh: "憤怒", polarity: -1, arousal: 0.9, halfLife: 90 },
sadness: { zh: "悲傷", polarity: -1, arousal: 0.3, halfLife: 480 },
fear: { zh: "恐懼", polarity: -1, arousal: 0.9, halfLife: 120 },
disgust: { zh: "厭惡", polarity: -1, arousal: 0.5, halfLife: 360 },
shame: { zh: "羞愧", polarity: -1, arousal: 0.5, halfLife: 240 },
anxiety: { zh: "焦慮", polarity: -1, arousal: 0.8, halfLife: 150 },
};
export const EMOTION_KEYS = Object.keys(EMOTIONS);
export const POSITIVE = EMOTION_KEYS.filter((k) => EMOTIONS[k].polarity > 0);
export const NEGATIVE = EMOTION_KEYS.filter((k) => EMOTIONS[k].polarity < 0);
export const DEFAULT_BASELINE = {
joy: 25, trust: 30, anticipation: 20, gratitude: 15, serenity: 40, delight: 5,
anger: 3, sadness: 5, fear: 3, disgust: 3, shame: 3, anxiety: 8,
};
export const emotionPath = (slug) => path.join(personaDir(slug), "state", "emotion.json");
export function clamp(value, lo = 0, hi = 100) {
const num = Number(value);
if (!Number.isFinite(num)) return lo;
return Math.max(lo, Math.min(hi, num));
}
export function defaultEmotionState(baseline = {}) {
const base = { ...DEFAULT_BASELINE };
for (const [k, v] of Object.entries(baseline || {})) {
if (k in EMOTIONS) base[k] = clamp(v);
}
const halfLives = {};
for (const k of EMOTION_KEYS) halfLives[k] = EMOTIONS[k].halfLife;
return {
updated_at: nowIso(),
baseline: base,
levels: { ...base },
half_life_minutes: halfLives,
history_len: 0,
last_trigger: null,
};
}
export function loadEmotion(slug) {
let state = readJson(emotionPath(slug));
if (!state || typeof state !== "object" || !state.levels) state = defaultEmotionState();
state.baseline ??= {};
state.levels ??= {};
state.half_life_minutes ??= {};
for (const key of EMOTION_KEYS) {
state.baseline[key] ??= DEFAULT_BASELINE[key];
state.levels[key] ??= state.baseline[key];
state.half_life_minutes[key] ??= EMOTIONS[key].halfLife;
}
return state;
}
/** 情緒朝 baseline 指數衰減;半衰期依情緒種類不同。 */
export function decayEmotion(state, now = new Date()) {
const last = parseIso(state.updated_at) ?? now;
const minutes = Math.max(0, (now.getTime() - last.getTime()) / 60_000);
if (minutes <= 0) return state;
for (const key of EMOTION_KEYS) {
const half = Number(state.half_life_minutes[key] ?? EMOTIONS[key].halfLife);
const base = Number(state.baseline[key] ?? DEFAULT_BASELINE[key]);
const level = Number(state.levels[key] ?? base);
const factor = half > 0 ? Math.pow(0.5, minutes / half) : 0;
state.levels[key] = Math.round((base + (level - base) * factor) * 100) / 100;
}
state.updated_at = iso(now);
return state;
}
export function applyEmotion(state, deltas, trigger = "") {
state = decayEmotion(state);
const applied = {};
for (const [key, rawDelta] of Object.entries(deltas || {})) {
if (!(key in EMOTIONS)) continue;
const delta = Number(rawDelta);
if (!Number.isFinite(delta)) continue;
const before = Number(state.levels[key] ?? 0);
state.levels[key] = Math.round(clamp(before + delta) * 100) / 100;
applied[key] = Math.round((state.levels[key] - before) * 100) / 100;
}
state.updated_at = nowIso();
state.history_len = Number(state.history_len || 0) + 1;
if (Object.keys(applied).length) {
state.last_trigger = { at: nowIso(), summary: trigger || "", deltas: applied };
}
return state;
}
export function mood(state) {
const levels = state.levels || {};
let valence = 0;
let arousal = 0;
for (const key of EMOTION_KEYS) {
const level = Number(levels[key] ?? 0);
valence += EMOTIONS[key].polarity * level;
arousal += EMOTIONS[key].arousal * level;
}
valence = Math.round(Math.max(-100, Math.min(100, valence / 3)) * 10) / 10;
arousal = Math.round(Math.min(100, arousal / 3) * 10) / 10;
const label = valence >= 30 ? "正向" : valence <= -30 ? "負向" : "中性";
const tempo = arousal >= 55 ? "高張" : arousal >= 25 ? "平穩" : "低張";
return { valence, arousal, label, tempo };
}
/** 以「超出 baseline 的幅度」排序,才看得出「此刻被觸動什麼」。 */
export function dominant(state, top = 3) {
const levels = state.levels || {};
const base = state.baseline || DEFAULT_BASELINE;
return EMOTION_KEYS
.map((key) => ({ key, level: Number(levels[key] ?? 0), delta: Number(levels[key] ?? 0) - Number(base[key] ?? 0) }))
.sort((a, b) => b.delta - a.delta || b.level - a.level)
.slice(0, top)
.map(({ key, level }) => ({ key, level: Math.round(level * 10) / 10 }));
}
export function emotionBrief(slug, state = null) {
const st = decayEmotion(structuredClone(state ?? loadEmotion(slug)));
const m = mood(st);
const top = dominant(st).map(({ key, level }) => `${EMOTIONS[key].zh}(${key}) ${Math.round(level)}`).join(", ");
const avg = (keys) => keys.reduce((sum, k) => sum + Number(st.levels[k] ?? 0), 0) / keys.length;
return (
`情緒:${top}|心情 ${m.label}/${m.tempo}` +
`(valence ${m.valence >= 0 ? "+" : ""}${Math.round(m.valence)}, arousal ${Math.round(m.arousal)})` +
`|正向均值 ${Math.round(avg(POSITIVE))} / 負向均值 ${Math.round(avg(NEGATIVE))}`
);
}
// --------------------------------------------------------------------------- //
// 人格目錄骨架
// --------------------------------------------------------------------------- //
export const PERSONA_SUBDIRS = [
"state",
"memory/long-term",
"memory/inbox",
"mindmap/threads",
"relations",
"journal",
];
export function ensurePersonaDirs(slug) {
const root = personaDir(slug);
for (const sub of PERSONA_SUBDIRS) fs.mkdirSync(path.join(root, sub), { recursive: true });
return root;
}
export const configPath = (slug) => path.join(personaDir(slug), "state", "config.json");
export const loadConfig = (slug) => readJson(configPath(slug), {}) ?? {};
// --------------------------------------------------------------------------- //
// 鎖:同一人格只能被一個程序載入(sub agent 共用同一 session 的鎖)
// --------------------------------------------------------------------------- //
export const lockPath = (slug) => path.join(personaDir(slug), "state", "lock.json");
export const guestsPath = (slug) => path.join(personaDir(slug), "state", "guests.json");
export class LockError extends Error {
constructor(message, owner = {}) {
super(message);
this.name = "LockError";
this.owner = owner;
}
}
/**
* 只看心跳租約。
*
* 鎖的擁有者是「那個 AI 程序的 session」,不是短命的 CLI process,
* 所以不能用 pid 存活判斷(CLI 跑完就結束了)。session 還活著時,
* 每輪對話的 hook 會續租;程序異常結束就會在租約到期後被視為死鎖。
*/
export function lockIsDead(lock) {
if (!lock || !Object.keys(lock).length) return true;
return ageSeconds(lock.heartbeat_at) > Number(lock.lease_seconds || LEASE_SECONDS);
}
export function liveGuests(slug, excludeSession = null) {
const data = readJson(guestsPath(slug), {}) ?? {};
return (data.guests || []).filter(
(g) => ageSeconds(g.heartbeat_at) <= GUEST_LEASE_SECONDS && (!excludeSession || g.session_id !== excludeSession),
);
}
/** 取得 exclusive 鎖。同 session 重入 = 續租;他 session 存活 = 失敗。 */
export function acquireLock(slug, sessionId, { tool = "claude-code", cwd = null, takeover = false } = {}) {
ensurePersonaDirs(slug);
const file = lockPath(slug);
const now = nowIso();
const payload = {
persona: slug,
session_id: sessionId,
writer_pid: process.pid, // 只作為紀錄:CLI process 會馬上結束
host: os.hostname(),
tool,
cwd: cwd || process.cwd(),
acquired_at: now,
heartbeat_at: now,
lease_seconds: LEASE_SECONDS,
mode: "exclusive",
};
const existing = readJson(file);
if (existing && typeof existing === "object" && existing.session_id) {
if (existing.session_id === sessionId) {
existing.heartbeat_at = now;
existing.writer_pid = process.pid;
writeJson(file, existing);
return existing;
}
if (!(lockIsDead(existing) || takeover)) {
throw new LockError(
`人格 \`${slug}\` 已被另一個程序載入(session ${String(existing.session_id).slice(0, 8)}…, ` +
`cwd ${existing.cwd},最後心跳 ${existing.heartbeat_at},${Math.round(ageSeconds(existing.heartbeat_at) / 60)} 分鐘前)。`,
existing,
);
}
// 租約已過期(程序異常結束)→ 允許接手,但要留下痕跡讓使用者知道
payload.took_over_from = {
session_id: existing.session_id,
cwd: existing.cwd,
heartbeat_at: existing.heartbeat_at,
stale_minutes: Math.round((ageSeconds(existing.heartbeat_at) / 60) * 10) / 10,
};
}
const others = liveGuests(slug, sessionId);
if (others.length && !takeover) {
const who = others[0];
throw new LockError(
`人格 \`${slug}\` 正以 guest 身分參與另一個 session(${String(who.session_id).slice(0, 8)}… / room ${who.room})的對話,` +
"請先結束該對話再載入。",
who,
);
}
writeJson(file, payload);
return payload;
}
export function heartbeatLock(slug, sessionId) {
const file = lockPath(slug);
const lock = readJson(file);
if (!lock || lock.session_id !== sessionId) return false;
lock.heartbeat_at = nowIso();
writeJson(file, lock);
return true;
}
export function releaseLock(slug, sessionId, { force = false } = {}) {
const file = lockPath(slug);
const lock = readJson(file);
if (!lock) return false;
if (lock.session_id !== sessionId && !force) return false;
try {
fs.unlinkSync(file);
return true;
} catch {
return false;
}
}
export function lockStatus(slug) {
const lock = readJson(lockPath(slug)) ?? {};
const has = Boolean(Object.keys(lock).length);
return {
persona: slug,
locked: has && !lockIsDead(lock),
stale: has && lockIsDead(lock),
owner: lock,
guests: liveGuests(slug),
};
}
export function addGuestLease(slug, sessionId, room, hostPersona) {
const file = guestsPath(slug);
const data = readJson(file, {}) ?? {};
const guests = (data.guests || []).filter(
(g) => !(g.session_id === sessionId && g.room === room) && ageSeconds(g.heartbeat_at) <= GUEST_LEASE_SECONDS,
);
guests.push({
session_id: sessionId,
room,
host_persona: hostPersona,
joined_at: nowIso(),
heartbeat_at: nowIso(),
mode: "guest-readonly",
});
data.guests = guests;
writeJson(file, data);
}
export function dropGuestLease(slug, sessionId, room = null) {
const file = guestsPath(slug);
const data = readJson(file, {}) ?? {};
data.guests = (data.guests || []).filter(
(g) => !(g.session_id === sessionId && (room === null || g.room === room)),
);
writeJson(file, data);
}
// --------------------------------------------------------------------------- //
// session 綁定:誰是 host、邀了哪些 guest、sub agent pin、劇場模式
// --------------------------------------------------------------------------- //
export function sessionPath(sessionId) {
const safe = String(sessionId || "unknown").replace(/[^A-Za-z0-9_.-]/g, "-").slice(0, 120);
return path.join(sessionsDir(), `${safe}.json`);
}
export function loadSession(sessionId) {
const data = readJson(sessionPath(sessionId), {}) ?? {};
data.session_id ??= sessionId;
data.host ??= null;
data.guests ??= {};
data.rooms ??= [];
data.pins ??= {};
data.theater ??= false;
return data;
}
export function saveSession(sessionId, data) {
data.updated_at = nowIso();
writeJson(sessionPath(sessionId), data);
}
export function bindHost(sessionId, slug, { cwd = null } = {}) {
const data = loadSession(sessionId);
data.host = slug;
data.host_bound_at = nowIso();
data.cwd = cwd || process.cwd();
saveSession(sessionId, data);
return data;
}
/** 釋放這個 session 的所有鎖與租約,回傳被釋放的內容。 */
export function unbindSession(sessionId) {
const data = loadSession(sessionId);
const released = { host: null, guests: [] };
if (data.host && personaExists(data.host) && releaseLock(data.host, sessionId)) released.host = data.host;
for (const [slug, info] of Object.entries(data.guests || {})) {
if (personaExists(slug)) {
dropGuestLease(slug, sessionId, info.room);
released.guests.push(slug);
}
}
try {
fs.unlinkSync(sessionPath(sessionId));
} catch {
/* 沒有就算了 */
}
return released;
}
/** 清掉死掉的 session 綁定、過期 guest 租約與死鎖。 */
export function gcRuntime() {
const removed = { sessions: [], locks: [], guests: [] };
let files = [];
try {
files = fs.readdirSync(sessionsDir()).filter((f) => f.endsWith(".json"));
} catch {
files = [];
}
for (const name of files) {
const file = path.join(sessionsDir(), name);
const data = readJson(file, {}) ?? {};
let alive = false;
if (data.host && personaExists(data.host)) {
const lock = readJson(lockPath(data.host)) ?? {};
alive = lock.session_id === data.session_id && !lockIsDead(lock);
}
if (!alive && ageSeconds(data.updated_at) > LEASE_SECONDS) {
removed.sessions.push(data.session_id);
try {
fs.unlinkSync(file);
} catch {
/* ignore */
}
}
}
for (const slug of listPersonas()) {
const lock = readJson(lockPath(slug));
if (lock && lockIsDead(lock)) {
try {
fs.unlinkSync(lockPath(slug));
removed.locks.push(slug);
} catch {
/* ignore */
}
}
const data = readJson(guestsPath(slug), {}) ?? {};
const guests = data.guests || [];
const keep = guests.filter((g) => ageSeconds(g.heartbeat_at) <= GUEST_LEASE_SECONDS);
if (keep.length !== guests.length) {
data.guests = keep;
writeJson(guestsPath(slug), data);
removed.guests.push(slug);
}
}
return removed;
}
// --------------------------------------------------------------------------- //
// 記憶:短期(滾動)/ 長期(一則一檔)
// --------------------------------------------------------------------------- //
export const SHORT_TERM_KEEP = 240; // 短期記憶保留筆數
export const SHORT_TERM_DAYS = 14; // 短期記憶保留天數
export const CONSOLIDATE_THRESHOLD = 40; // 超過這個筆數就提示固化
export const shortTermPath = (slug) => path.join(personaDir(slug), "memory", "short-term.jsonl");
export const inboxPath = (slug, room) =>
path.join(personaDir(slug), "memory", "inbox", `room-${String(room).replace(/[^A-Za-z0-9_.-]/g, "-").slice(0, 64)}.jsonl`);
export const longTermDir = (slug) => path.join(personaDir(slug), "memory", "long-term");
export const indexPath = (slug) => path.join(personaDir(slug), "memory", "INDEX.md");
export const journalPath = (slug) => {
const d = new Date();
const month = `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}`;
return path.join(personaDir(slug), "journal", `${month}.jsonl`);
};
export function rememberShort(slug, entry) {
entry.ts ??= nowIso();
appendJsonl(shortTermPath(slug), entry);
return entry;
}
/** 裁掉過舊/過多的短期記憶,回傳剩餘筆數。 */
export function pruneShortTerm(slug) {
const file = shortTermPath(slug);
const rows = readJsonl(file);
if (!rows.length) return 0;
const cutoff = Date.now() - SHORT_TERM_DAYS * 86_400_000;
let kept = rows.filter((r) => (parseIso(r.ts)?.getTime() ?? Date.now()) >= cutoff);
kept = kept.slice(-SHORT_TERM_KEEP);
if (kept.length !== rows.length) {
writeText(file, kept.map((r) => JSON.stringify(r)).join("\n") + (kept.length ? "\n" : ""));
}
return kept.length;
}
export const recentShort = (slug, limit = 8) => readJsonl(shortTermPath(slug), limit);
export function parseFrontMatter(text) {
if (!text.startsWith("---")) return [{}, text];
const parts = text.split("---");
if (parts.length < 3) return [{}, text];
const meta = {};
for (const line of parts[1].split("\n")) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#") || !trimmed.includes(":")) continue;
const idx = trimmed.indexOf(":");
const key = trimmed.slice(0, idx).trim();
const value = trimmed.slice(idx + 1).trim();
if (value.startsWith("[") && value.endsWith("]")) {
meta[key] = value.slice(1, -1).split(",").map((v) => v.trim()).filter(Boolean);
} else {
meta[key] = value;
}
}
const body = parts.slice(2).join("---").replace(/^\n+/, "");
return [meta, body];
}
export function longTermEntries(slug) {
let files = [];
try {
files = fs.readdirSync(longTermDir(slug)).filter((f) => f.endsWith(".md")).sort();
} catch {
return [];
}
const out = [];
for (const name of files) {
const file = path.join(longTermDir(slug), name);
let text;
try {
text = fs.readFileSync(file, "utf8");
} catch {
continue;
}
const [meta, body] = parseFrontMatter(text);
meta._path = file;
meta._name = meta.name || path.basename(name, ".md");
meta._body = body.trim();
out.push(meta);
}
return out;
}
export function rebuildIndex(slug) {
const entries = longTermEntries(slug);
const lines = [
"# 長期記憶索引",
"",
``,
"",
];
const sorted = [...entries].sort((a, b) => Number(b.salience || 0) - Number(a.salience || 0));
for (const meta of sorted) {
const topics = Array.isArray(meta.topics) ? meta.topics : meta.topics ? [String(meta.topics)] : [];
const summary = (meta._body.split("\n")[0] || "").slice(0, 110);
lines.push(
`- [${meta._name}](long-term/${path.basename(meta._path)})` +
`|${meta.type || "fact"}|顯著度 ${meta.salience ?? "?"}` +
`|主題 ${topics.length ? topics.join("/") : "-"}|${summary}`,
);
}
if (lines.length === 4) lines.push("- (尚無長期記憶)");
writeText(indexPath(slug), lines.join("\n") + "\n");
return entries.length;
}
const STOPWORDS = new Set([
"的", "了", "是", "我", "你", "他", "她", "們", "在", "和", "與", "也", "就", "都", "很", "有",
"沒", "不", "要", "會", "把", "被", "而", "但", "嗎", "呢",
"the", "a", "an", "and", "or", "to", "of", "is", "it", "for", "on", "in",
]);
const CJK_RUN = new RegExp(`[${CJK_CLASS}]{2,}`, "g");
/** 抽關鍵詞。中文沒有空白可切,所以用 3-gram + 2-gram 滑窗(長的優先)。 */
export function keywords(text, limit = 12) {
const src = text || "";
const tokens = [...(src.match(/[A-Za-z][A-Za-z0-9_+-]+/g) || [])];
const trigrams = [];
const bigrams = [];
for (const run of src.match(CJK_RUN) || []) {
for (const [size, bucket] of [[3, trigrams], [2, bigrams]]) {
for (let i = 0; i + size <= run.length; i += 1) bucket.push(run.slice(i, i + size));
}
}
tokens.push(...trigrams, ...bigrams);
const out = [];
const seen = new Set();
for (const tok of tokens) {
const low = tok.toLowerCase();
if (STOPWORDS.has(low) || low.length < 2 || seen.has(low)) continue;
seen.add(low);
out.push(tok);
if (out.length >= limit) break;
}
return out;
}
/** 以關鍵詞比對長期記憶(name/topics/body),回傳最相關的幾則。 */
export function recall(slug, query, limit = 5) {
const keys = keywords(query, 16).map((k) => k.toLowerCase());
const scored = [];
for (const meta of longTermEntries(slug)) {
const haystack = [
meta._name || "",
Array.isArray(meta.topics) ? meta.topics.join(" ") : "",
Array.isArray(meta.about) ? meta.about.join(" ") : "",
meta._body || "",
].join(" ").toLowerCase();
const hits = keys.filter((k) => haystack.includes(k)).length;
if (hits) scored.push({ score: hits * 10 + Number(meta.salience || 0) / 10, meta });
}
scored.sort((a, b) => b.score - a.score);
return scored.slice(0, limit).map((s) => s.meta);
}
// --------------------------------------------------------------------------- //
// 短期 → 長期的轉入條件(固化門檻)
// --------------------------------------------------------------------------- //
/** 承諾/界線類的關鍵詞:命中就一定要固化。 */
const COMMITMENT_RE =
/(答應|承諾|保證|說好|約定|一定會|絕對不|不要再|以後都|從今天起|拜託你記住|記住這件事|下次記得|deadline|due)/i;
const BOUNDARY_RE = /(不准|不許|禁止|別再|我討厭|我最恨|底線|界線|不能接受|不想聽)/;
export const PROMOTION_RULES = [
{ id: "R1", label: "高顯著度單筆(salience ≥ 60)" },
{ id: "R2", label: "主題反覆出現(同 topic ≥ 3 筆,或 ≥ 2 筆且平均 salience ≥ 45)" },
{ id: "R3", label: "情緒衝擊大(單筆情緒變動總量 ≥ 25)" },
{ id: "R4", label: "承諾/界線(intent=commit 或命中承諾/界線關鍵詞)" },
{ id: "R5", label: "人物反覆出現(同一 entity ≥ 2 筆)" },
{ id: "R6", label: "容量壓力(短期記憶 ≥ 40 筆,依顯著度排序清出空間)" },
];
function emotionImpact(entry) {
return Object.values(entry.emotion_deltas || {}).reduce((sum, v) => sum + Math.abs(Number(v) || 0), 0);
}
/**
* 掃短期記憶,依 PROMOTION_RULES 算出「該轉入長期記憶」的候選。
* 回傳 { total, pressure, candidates: [{ rules, key, kind, entries, suggested_type, suggested_salience }] }
*/
export function promotionCandidates(slug) {
const rows = readJsonl(shortTermPath(slug));
const total = rows.length;
const byTopic = new Map();
const byEntity = new Map();
const singles = [];
rows.forEach((row, index) => {
const entry = { ...row, _index: index };
const salience = Number(row.salience || 0);
const impact = emotionImpact(row);
const text = String(row.text || "");
const rules = [];
if (salience >= 60) rules.push("R1");
if (impact >= 25) rules.push("R3");
if (row.intent === "commit" || COMMITMENT_RE.test(text)) rules.push("R4");
if (BOUNDARY_RE.test(text)) rules.push("R4");
if (rules.length) {
singles.push({
rules: [...new Set(rules)],
key: text.slice(0, 40),
kind: "entry",
entries: [entry],
suggested_type: rules.includes("R4") ? (BOUNDARY_RE.test(text) ? "boundary" : "promise") : "event",
suggested_salience: Math.max(salience, rules.includes("R4") ? 80 : 60),
});
}
for (const topic of row.topics || []) {
if (!byTopic.has(topic)) byTopic.set(topic, []);
byTopic.get(topic).push(entry);
}
for (const entity of row.entities || []) {
if (!byEntity.has(entity)) byEntity.set(entity, []);
byEntity.get(entity).push(entry);
}
});
const candidates = [...singles];
for (const [topic, entries] of byTopic) {
const avg = entries.reduce((s, e) => s + Number(e.salience || 0), 0) / entries.length;
if (entries.length >= 3 || (entries.length >= 2 && avg >= 45)) {
candidates.push({
rules: ["R2"],
key: topic,
kind: "topic",
entries,
suggested_type: "preference",
suggested_salience: Math.min(95, Math.round(avg + 10)),
});
}
}
for (const [entity, entries] of byEntity) {
if (entries.length >= 2) {
candidates.push({
rules: ["R5"],
key: entity,
kind: "entity",
entries,
suggested_type: "relationship",
suggested_salience: Math.min(90, Math.round(entries.reduce((s, e) => s + Number(e.salience || 0), 0) / entries.length + 5)),
});
}
}
const pressure = total >= CONSOLIDATE_THRESHOLD;
if (pressure) {
const top = [...rows]
.map((r, i) => ({ ...r, _index: i }))
.sort((a, b) => Number(b.salience || 0) - Number(a.salience || 0))
.slice(0, 5);
candidates.push({
rules: ["R6"],
key: `容量壓力(${total} 筆)`,
kind: "pressure",
entries: top,
suggested_type: "event",
suggested_salience: 55,
});
}
// 同一則短期記憶可能觸發多條規則 → 依 key 去重、合併規則
const merged = new Map();
for (const cand of candidates) {
const dedupeKey = `${cand.kind}:${cand.key}`;
if (merged.has(dedupeKey)) {
const prev = merged.get(dedupeKey);
prev.rules = [...new Set([...prev.rules, ...cand.rules])];
prev.suggested_salience = Math.max(prev.suggested_salience, cand.suggested_salience);
} else {
merged.set(dedupeKey, { ...cand });
}
}
return { total, pressure, candidates: [...merged.values()] };
}
/** 被回想到就更新 last_seen / recall_count(記憶越常用越不易被淘汰)。 */
export function touchRecall(slug, names) {
const wanted = new Set(names);
const today = nowIso().slice(0, 10);
for (const meta of longTermEntries(slug)) {
if (!wanted.has(meta._name)) continue;
let text;
try {
text = fs.readFileSync(meta._path, "utf8");
} catch {
continue;
}
const count = Math.floor(Number(meta.recall_count || 0)) + 1;
text = text.replace(/^recall_count:.*$/m, `recall_count: ${count}`);
text = text.replace(/^last_seen:.*$/m, `last_seen: ${today}`);
try {
fs.writeFileSync(meta._path, text, "utf8");
} catch {
/* ignore */
}
}
}
// --------------------------------------------------------------------------- //
// 心智圖 / 思維導圖 / 人際關係圖
// --------------------------------------------------------------------------- //
export const mindmapPath = (slug) => path.join(personaDir(slug), "mindmap", "semantic.mmd");
export const threadPath = (slug, topic) => path.join(personaDir(slug), "mindmap", "threads", `${slugify(topic)}.mmd`);
export const relationsJson = (slug) => path.join(personaDir(slug), "relations", "graph.json");
export const relationsMmd = (slug) => path.join(personaDir(slug), "relations", "graph.mmd");
export function loadRelations(slug) {
const data = readJson(relationsJson(slug), {}) ?? {};
data.nodes ??= [];
data.edges ??= [];
return data;
}
export function upsertRelationNode(slug, node) {
const data = loadRelations(slug);
const nodeId = node.id || slugify(node.name || "");
node.id = nodeId;
const idx = data.nodes.findIndex((n) => n.id === nodeId);
if (idx >= 0) {
for (const [k, v] of Object.entries(node)) if (v !== null && v !== undefined) data.nodes[idx][k] = v;
data.nodes[idx].updated_at = nowIso();
} else {
node.kind ??= "human";
node.closeness ??= 30;
node.trust ??= 30;
node.created_at = nowIso();
node.updated_at = nowIso();
data.nodes.push(node);
}
writeJson(relationsJson(slug), data);
return data;
}
export function upsertRelationEdge(slug, edge) {
const data = loadRelations(slug);
const idx = data.edges.findIndex((e) => e.from === edge.from && e.to === edge.to);
if (idx >= 0) {
for (const [k, v] of Object.entries(edge)) if (v !== null && v !== undefined) data.edges[idx][k] = v;
data.edges[idx].updated_at = nowIso();
} else {
edge.affinity ??= 50;
edge.created_at = nowIso();
edge.updated_at = nowIso();
data.edges.push(edge);
}
writeJson(relationsJson(slug), data);
return data;
}
export function renderRelations(slug) {
const data = loadRelations(slug);
const lines = ["%% 由 persona.mjs 產生:人際關係圖", "flowchart LR", ' self(("我"))'];
for (const node of data.nodes) {
const nid = mermaidId(node.id);
const label = `${node.name || node.id}
親近 ${node.closeness ?? "?"}/信任 ${node.trust ?? "?"}`;
lines.push(` ${node.kind === "persona" ? `${nid}(["${label}"])` : `${nid}["${label}"]`}`);
}
for (const edge of data.edges) {
const src = !edge.from || edge.from === "self" ? "self" : mermaidId(edge.from);
const dst = mermaidId(edge.to || "unknown");
const affinity = Number(edge.affinity ?? 50);
lines.push(` ${src} ${affinity >= 50 ? "-->" : "-.->"}|"${edge.label || ""} ${Math.round(affinity)}"| ${dst}`);
}
const text = lines.join("\n") + "\n";
writeText(relationsMmd(slug), text);
return text;
}
export function relationsBrief(slug, names = null, limit = 5) {
const data = loadRelations(slug);
let nodes = data.nodes;
if (names?.length) {
const low = names.map((n) => n.toLowerCase());
const matched = nodes.filter((n) => low.some((k) => `${n.name || ""}${n.id || ""}`.toLowerCase().includes(k)));
nodes = matched.length ? matched : data.nodes;
}
nodes = [...nodes].sort((a, b) => Number(b.closeness || 0) - Number(a.closeness || 0)).slice(0, limit);
if (!nodes.length) return "";
return nodes
.map((n) =>
`${n.name || n.id}(${n.kind || "human"}/親近 ${n.closeness ?? "?"}/信任 ${n.trust ?? "?"}` +
`${n.note ? `/${n.note}` : ""})`)
.join(";");
}
// --------------------------------------------------------------------------- //
// 聊天室(跨人格唯一合法的資料交換介面)
// --------------------------------------------------------------------------- //
export const roomDir = (room) => path.join(roomsDir(), String(room).replace(/[^A-Za-z0-9_.-]/g, "-").slice(0, 64));
export const roomTranscript = (room) => path.join(roomDir(room), "transcript.jsonl");
export const roomMembersPath = (room) => path.join(roomDir(room), "members.json");
export function createRoom(room, hostPersona, sessionId, topic = "") {
fs.mkdirSync(roomDir(room), { recursive: true });
const meta = readJson(roomMembersPath(room), {}) ?? {};
Object.assign(meta, {
room,
host_persona: hostPersona,
session_id: sessionId,
topic: topic || meta.topic || "",
created_at: meta.created_at || nowIso(),
updated_at: nowIso(),
});
meta.members ??= [hostPersona];
writeJson(roomMembersPath(room), meta);
return meta;
}
export function joinRoom(room, persona) {
const meta = readJson(roomMembersPath(room), { room, members: [] }) ?? { room, members: [] };
meta.members ??= [];
if (!meta.members.includes(persona)) meta.members.push(persona);
meta.updated_at = nowIso();
writeJson(roomMembersPath(room), meta);
return meta;
}
export function roomPost(room, speaker, text, { emotion = "", kind = "say" } = {}) {
const entry = { ts: nowIso(), speaker, kind, text, emotion };
appendJsonl(roomTranscript(room), entry);
return entry;
}
export const roomRead = (room, limit = 30) => readJsonl(roomTranscript(room), limit);
/** 劇場模式的對話呈現:`emoji 名字(情緒):內容`,其餘一律不輸出。 */
export function roomScript(room, { limit = 30, includeMeta = false } = {}) {
const lines = [];
for (const msg of roomRead(room, limit)) {
if (msg.kind === "meta" || msg.speaker === "system") {
if (includeMeta) lines.push(`(${msg.text})`);
continue;
}
const slug = msg.speaker;
const ident = personaExists(slug) ? identityFields(slug) : {};
const name = ident.Name || slug;
const emoji = ident.Emoji ? `${ident.Emoji} ` : "";
lines.push(`${emoji}${name}${msg.emotion ? `(${msg.emotion})` : ""}:${msg.text}`);
}
return lines.join("\n");
}
// --------------------------------------------------------------------------- //
// guard:跨人格隔離 + 鎖驗證的判斷核心
// --------------------------------------------------------------------------- //
export const MUTATING_TOOLS = new Set(["Write", "Edit", "NotebookEdit", "MultiEdit"]);
const PATH_TOOL_FIELDS = {
Read: ["file_path"],
Write: ["file_path"],
Edit: ["file_path"],
MultiEdit: ["file_path"],
NotebookEdit: ["notebook_path", "file_path"],
Glob: ["path"],
Grep: ["path"],
LS: ["path"],
};
export const GUEST_SAFE_SUBCOMMANDS = new Set(["show", "status", "list", "recall", "room", "remember", "leave", "brief"]);
// owner 這些子指令本來就要提到別的人格名字(邀請/離場/查詢),不算跨人格讀取
export const OWNER_EXEMPT_SUBCOMMANDS = new Set(["create", "list", "status", "gc", "invite", "load", "leave"]);
const MUTATING_SHELL =
/(>>?|\|\s*tee\b|\brm\b|\bmv\b|\bcp\b|\btruncate\b|\bdd\b|\bchmod\b|\bchown\b|\bsed\b[^|;]*-i|\btouch\b|\bmkdir\b|\bln\b)/;
function expandToken(token) {
let t = String(token).trim().replace(/^['"]|['"]$/g, "");
t = t.replaceAll("${PERSONA_HOME}", personaHome()).replaceAll("$PERSONA_HOME", personaHome());
t = t.replace(/\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?/g, (m, name) => process.env[name] ?? m);
return expandUser(t);
}
/** 解析成絕對路徑:吃掉 `..`,並對已存在的祖先解 symlink(目標可能還不存在)。 */
function resolvePath(token, cwd) {
try {
const raw = expandToken(token);
if (!raw) return null;
let abs = path.isAbsolute(raw) ? path.normalize(raw) : path.normalize(path.resolve(cwd || process.cwd(), raw));
const parts = [];
let probe = abs;
for (;;) {
if (fs.existsSync(probe)) {
const real = fs.realpathSync(probe);
return parts.length ? path.join(real, ...parts.reverse()) : real;
}
const parent = path.dirname(probe);
if (parent === probe) return abs;
parts.push(path.basename(probe));
probe = parent;
}
} catch {
return null;
}
}
function isUnder(target, base) {
const rel = path.relative(base, target);
return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
}
export function personaSlugOf(target) {
const home = personaHome();
if (!isUnder(target, home) || target === home) return null;
const rel = path.relative(home, target);
return rel.split(path.sep)[0] || null;
}
export function extractPaths(toolName, toolInput, cwd) {
const out = [];
for (const field of PATH_TOOL_FIELDS[toolName] || []) {
const value = toolInput?.[field];
if (typeof value === "string" && value) {
const resolved = resolvePath(value, cwd);
if (resolved) out.push(resolved);
}
}
if (toolName === "Bash") {
const command = toolInput?.command || "";
const home = personaHome();
for (const token of command.match(/[^\s'";|&<>()]+/g) || []) {
if (!token.includes("/") && !token.includes("PERSONA_HOME")) continue;
const expanded = expandToken(token);
if (expanded.includes(home) || expanded.includes("personas")) {
const resolved = resolvePath(token, cwd);
if (resolved && isUnder(resolved, home)) out.push(resolved);
}
}
}
return out;
}
/** 辨識 Bash 是否在呼叫 persona CLI,並取出 subcommand / --persona / --session。 */
export function cliInvocation(command) {
if (!/persona\.(mjs|js|py)\b/.test(command)) return null;
const info = {
subcommand: null,
personas: [],
session: null,
asGuest: /--as-guest\b/.test(command),
};
const sub = command.match(/persona\.(?:mjs|js|py)['"]?\s+([a-z][a-z0-9-]*)/);
if (sub) info.subcommand = sub[1];
info.personas = [...command.matchAll(/--(?:persona|guest|host|as)[= ]+['"]?([a-z0-9-]+)/g)].map((m) => m[1]);
const sess = command.match(/--session[= ]+['"]?([^\s'"]+)/);
if (sess) info.session = sess[1];
return info;
}
/**
* 算出這個呼叫者能碰哪個人格。
*
* * 主程序(無 agent_id)與一般 sub agent → host 人格,可讀寫。
* * persona-guest 型 sub agent → 只能碰被邀請的 guest 人格,且唯讀;
* 第一次觸碰哪個 guest 就 pin 住(first-touch pinning),之後不得換人。
*/
export function resolveScope(sessionId, agentId, agentType) {
const data = loadSession(sessionId);
const host = data.host;
const guests = Object.keys(data.guests || {});
const isGuestAgent = Boolean(agentType) && String(agentType).includes("persona-guest");
if (!isGuestAgent) {
return {
role: "owner",
allowed: host ? [host] : [],
readonly: false,
host,
guests,
rooms: data.rooms || [],
session: data,
};
}
const pinned = (data.pins || {})[agentId || ""];
return {
role: "guest",
allowed: pinned ? [pinned] : guests,
readonly: true,
host,
guests,
pinned,
rooms: data.rooms || [],
session: data,
};
}
export function pinAgent(sessionId, agentId, slug) {
const data = loadSession(sessionId);
data.pins ??= {};
if (data.pins[agentId] !== slug) {
data.pins[agentId] = slug;
saveSession(sessionId, data);
}
}
/** 回傳 { decision: "allow"|"deny"|"pass", reason }。"pass" = 不表態,交回原本流程。 */
export function guardDecide(event) {
const tool = event.tool_name || "";
const toolInput = event.tool_input || {};
const sessionId = event.session_id || "unknown";
const agentId = event.agent_id;
const agentType = event.agent_type;
const cwd = event.cwd;
const scope = resolveScope(sessionId, agentId, agentType);
const deny = (reason) => ({ decision: "deny", reason });
// 1) persona CLI 呼叫:先驗 session 身分,再驗人格範圍
if (tool === "Bash") {
const command = toolInput.command || "";
const info = cliInvocation(command);
if (info) {
if (info.session && info.session !== sessionId) {
return deny(
`CLI 的 --session \`${info.session.slice(0, 12)}…\` 與本 session 不符,` +
"不得冒用其他程序的身分(人格鎖與隔離都靠 session 判定)。",
);
}
const sub = info.subcommand || "";
if (scope.role === "guest") {
if (!GUEST_SAFE_SUBCOMMANDS.has(sub)) {
return deny(
`guest 人格(sub agent)僅能執行 ${[...GUEST_SAFE_SUBCOMMANDS].sort().join("/")},不得執行 \`${sub}\`。`,
);
}
for (const slug of info.personas) {
if (scope.allowed.length && !scope.allowed.includes(slug)) {
return deny(`guest 只能操作被邀請的人格 ${JSON.stringify(scope.allowed)},不得碰 \`${slug}\`。`);
}
}
} else {
if (info.asGuest) {
return deny(
"`--as-guest` 只有 persona-guest 型的 sub agent 能用;主程序不得以受邀人格的身分存取它的資料。",
);
}
for (const slug of info.personas) {
if (OWNER_EXEMPT_SUBCOMMANDS.has(sub)) continue;
if (scope.host && slug !== scope.host) {
const extra = scope.guests.includes(slug)
? "(它是本 session 邀請的 guest:你只能讀它在聊天室說出口的話,不能碰它的記憶或情緒。)"
: "請先 release 再 load,或改用 invite + 聊天室。";
return deny(`本 session 已載入人格 \`${scope.host}\`,禁止跨人格操作 \`${slug}\`。${extra}`);
}
}
}
}
if (MUTATING_SHELL.test(command) && scope.role === "guest") {
for (const target of extractPaths(tool, toolInput, cwd)) {
if (personaSlugOf(target)) {
return deny("guest 人格對人格倉庫唯讀,寫入請透過 `persona.mjs room post` 或 `remember --scope inbox`。");
}
}
}
}
// 2) 路徑隔離
const home = personaHome();
for (const target of extractPaths(tool, toolInput, cwd)) {
if (!isUnder(target, home)) continue;
if (target === home) {
return deny("禁止直接遍歷人格倉庫根目錄(會看到其他人格)。請用 `persona.mjs list`。");
}
const slug = personaSlugOf(target);
if (slug === ROOMS_DIRNAME) {
const parts = path.relative(home, target).split(path.sep);
const room = parts.length > 1 ? parts[1] : null;
if (room && scope.rooms.length && !scope.rooms.includes(room)) {
return deny(`聊天室 \`${room}\` 不屬於本 session(可用的:${JSON.stringify(scope.rooms)})。`);
}
continue;
}
if (slug === RUNTIME_DIRNAME) {
return deny("`.runtime/` 是鎖與綁定的內部狀態,只能由 persona CLI 維護。");
}
if (!slug) continue;
if (!scope.allowed.length) {
return deny(
"尚未載入任何人格。請先執行 `persona.mjs load --persona --session `(或 /jsc-persona:persona-chat)。",
);
}
if (!scope.allowed.includes(slug)) {
if (scope.role === "guest") {
return deny(
`guest 人格被 pin 在 ${JSON.stringify(scope.allowed)},禁止讀取 \`${slug}\` 的任何資料(跨人格資料隔離)。`,
);
}
return deny(
`本 session 的人格是 \`${scope.allowed[0]}\`,禁止讀寫 \`${slug}\` 的資料(跨人格資料隔離)。` +
"要與它對話請用 /jsc-persona:persona-invite。",
);
}
// 3) guest 唯讀 + first-touch pinning
if (scope.role === "guest") {
if (!scope.pinned && agentId) pinAgent(sessionId, agentId, slug);
if (MUTATING_TOOLS.has(tool)) {
return deny(
`guest 人格 \`${slug}\` 在 sub agent 中為唯讀;要留下記憶請 \`persona.mjs remember --scope inbox\`(下次它自己載入時再固化)。`,
);
}
}
// 4) 鎖驗證:owner 必須真的持有鎖
if (scope.role === "owner") {
const lock = readJson(lockPath(slug)) ?? {};
const hasLock = Boolean(Object.keys(lock).length);
if (hasLock && lock.session_id !== sessionId && !lockIsDead(lock)) {
return deny(
`人格 \`${slug}\` 的鎖屬於另一個程序(session ${String(lock.session_id).slice(0, 8)}…,cwd ${lock.cwd})。` +
"同一人格同時只能被一個程序載入。",
);
}
if (!hasLock && MUTATING_TOOLS.has(tool)) {
return deny(`人格 \`${slug}\` 目前沒有有效的載入鎖,禁止寫入。請先 \`persona.mjs load\` 取得鎖。`);
}
}
}
return { decision: "pass", reason: "" };
}
// --------------------------------------------------------------------------- //
// 給 hook 用的上下文組裝
// --------------------------------------------------------------------------- //
export function identityFields(slug) {
const fields = {};
let text;
try {
text = fs.readFileSync(path.join(personaDir(slug), "IDENTITY.md"), "utf8");
} catch {
return fields;
}
for (const line of text.split("\n")) {
const m = line.match(/^\s*[-*]?\s*(Name|Creature|Vibe|Emoji|Avatar)\s*:\s*(.+)$/i);
if (!m) continue;
const value = m[2].trim();
if (value.startsWith("(") || value.startsWith("_(")) continue;
const key = m[1][0].toUpperCase() + m[1].slice(1).toLowerCase();
fields[key] = value;
}
return fields;
}
export function identityBrief(slug) {
const fields = identityFields(slug);
const order = ["Emoji", "Name", "Creature", "Vibe"];
return order.filter((k) => fields[k]).map((k) => `${k}: ${fields[k]}`).join("|");
}
/** UserPromptSubmit 注入的人格上下文:身分 + 情緒 + 短期記憶 + 相關長期記憶 + 關係。 */
export function turnContext(slug, sessionId, prompt = "") {
const state = decayEmotion(loadEmotion(slug));
writeJson(emotionPath(slug), state);
const session = loadSession(sessionId);
const lines = [
"",
`PERSONA_SESSION=${sessionId}`,
`人格:\`${slug}\` ${identityBrief(slug)}`,
`人格倉庫:${personaDir(slug)}(唯一可讀寫的人格資料範圍)`,
emotionBrief(slug, state),
];
const recents = recentShort(slug, 6);
if (recents.length) {
lines.push("短期記憶(最近):");
for (const row of recents) {
const who = row.role || row.speaker || "?";
const text = String(row.text || "").replace(/\n/g, " ").slice(0, 90);
lines.push(` - [${who}] ${text}${row.salience ? `(顯著度 ${row.salience})` : ""}`);
}
}
const hits = prompt ? recall(slug, prompt, 4) : [];
if (hits.length) {
lines.push("相關長期記憶:");
for (const meta of hits) {
const first = (meta._body || "").split("\n")[0] || "";
lines.push(` - ${meta._name}|${meta.type || "fact"}|${first.slice(0, 100)}`);
}
touchRecall(slug, hits.map((m) => m._name));
}
const rel = relationsBrief(slug, prompt ? keywords(prompt, 6) : null);
if (rel) lines.push(`人際關係:${rel}`);
if (session.theater && (session.rooms || []).length) {
const rooms = session.rooms;
lines.push(
`🎭 多人聊天模式(劇場)進行中:聊天室 ${JSON.stringify(rooms)}。`,
" 對使用者的輸出**只能有人格對話**(每行 `emoji 名字(情緒):內容`):",
" 不得出現指令、指令輸出、狀態說明、進度、摘要、分析或旁白;所有 CLI 一律加 `--quiet` 並把輸出丟掉。",
" 想結束請等使用者說,或由使用者說「結束對話」後才做收尾與摘要。",
);
} else {
const { total, candidates } = promotionCandidates(slug);
if (candidates.length) {
const rules = [...new Set(candidates.flatMap((c) => c.rules))].sort().join("/");
lines.push(
`⚠ 短期記憶 ${total} 筆,其中 ${candidates.length} 組已達固化條件(${rules})→ 執行 /jsc-persona:persona-memory。`,
);
}
let inbox = [];
try {
inbox = fs.readdirSync(path.join(personaDir(slug), "memory", "inbox")).filter((f) => f.startsWith("room-"));
} catch {
inbox = [];
}
if (inbox.length) lines.push(`⚠ 有 ${inbox.length} 個聊天室 inbox 待消化(guest 期間留下的見聞)。`);
}
lines.push("");
return lines.join("\n");
}