Files
persona/scripts/persona-lib.mjs
T
jiantw83andClaude Opus 5 52a73e81c8 feat: 心裡話與不重複發言、劇場模式同規則、人格匯出匯入
讓人格講話更像正常人,並讓人格可以搬家。

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>
2026-07-30 00:59:16 +00:00

1718 lines
64 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.
// 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; // guestsub 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);
}
/** 單行 appendO_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 = [
"# 長期記憶索引",
"",
`<!-- 由 persona.mjs 自動產生,最後更新 ${nowIso()};一則記憶一行 -->`,
"",
];
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 */
}
}
}
// --------------------------------------------------------------------------- //
// 說話節制:心裡話(inner voice)、說過的話(said)、句數上限
// --------------------------------------------------------------------------- //
//
// 三條「像正常人聊天」的規則,都靠這一段支撐:
// 1. 推導過程進 inner.jsonl(心裡話),永遠不回顯內容,只回報「心想 N 句」。
// 2. 說出口的話進 said.jsonl,短時間內近似重複會被擋下(room post)或警告(said check)。
// 3. 一次講 1–3 句;超過就是在寫報告,不是在聊天。
export const MAX_SENTENCES = 3;
export const SAID_KEEP = 150;
export const INNER_KEEP = 150;
export const REPEAT_WINDOW_MINUTES = 120; // 「短時間內」的定義
export const REPEAT_THRESHOLD = 0.72; // 字元 bigram Jaccard,超過視為同一句話
export const REPEAT_MIN_CHARS = 8; // 太短的附和(「嗯」「好啊」)不算重複
export const INNER_WINDOW_MINUTES = 240;
export const saidPath = (slug) => path.join(personaDir(slug), "state", "said.jsonl");
export const innerPath = (slug) => path.join(personaDir(slug), "state", "inner.jsonl");
/** 去掉劇場模式的 `emoji 名字(情緒):` 前綴,只留真正說出口的內容。 */
export function stripSpeakerPrefix(line) {
const m = String(line ?? "").match(
/^\s*(?:\S{1,3}\s+)?[^:,。!?!?\n]{1,16}(?:[^\n]{0,32})?\s*[:]\s*(\S.*)$/u,
);
return m ? m[1] : String(line ?? "");
}
/** 比對用的正規化:拿掉前綴、空白與標點,只留語意骨架。 */
export function normalizeSpeech(text) {
return stripSpeakerPrefix(text)
.normalize("NFKC")
.replace(/\s+/g, "")
.replace(/[,。!?、;:,.!?;:~~…「」『』"'()()【】[\]—-]+/g, "")
.toLowerCase();
}
function charBigrams(text) {
const set = new Set();
if (text.length === 1) set.add(text);
for (let i = 0; i + 2 <= text.length; i += 1) set.add(text.slice(i, i + 2));
return set;
}
function jaccard(A, B) {
if (!A.size || !B.size) return 0;
let inter = 0;
for (const item of A) if (B.has(item)) inter += 1;
return inter / (A.size + B.size - inter);
}
/**
* 兩句話的相似度(0–1)。中文沒有空白可切,所以用字元層級的兩個訊號:
* * bigram Jaccard(權重 0.4):看「詞序與搭配」——整句改寫會掉下來。
* * 字集合 Jaccard(權重 0.6):看「用了哪些字」——把同一句話重排也躲不掉。
* 字集合權重較高,是因為要分開的正是這兩種情況:
* 「我等一下把報告寄給你」vs「等一下我會把報告寄給你」→ 0.78 擋(同一件事換句話說:用字幾乎相同)
* 「你今天看起來很累」  vs「你今天看起來很開心」  → 0.69 放行(換了關鍵詞=新資訊)
*/
export function similarity(a, b) {
const normA = normalizeSpeech(a);
const normB = normalizeSpeech(b);
if (!normA || !normB) return 0;
const score =
0.4 * jaccard(charBigrams(normA), charBigrams(normB)) +
0.6 * jaccard(new Set(normA), new Set(normB));
return Math.round(score * 1000) / 1000;
}
/** 句數:以句末標點或換行切;沒有標點的一整串算 1 句。 */
export function sentenceCount(text) {
return String(text ?? "")
.split(/[。!?!?…]+|\n+/)
.map((s) => s.trim())
.filter(Boolean).length;
}
/** 把一則回覆拆成「說出口的句子」:劇場模式一行一句,一般模式整段算一句。 */
export function spokenLines(text, { theater = false } = {}) {
const raw = String(text ?? "").trim();
if (!raw) return [];
if (!theater) return [raw.slice(0, 2000)];
return raw
.split("\n")
.map((line) => stripSpeakerPrefix(line).trim())
.filter(Boolean)
.slice(0, 8);
}
/** 在 entriesneed `ts` / `text`)裡找出與 text 近似的一則;沒有就回 null。 */
export function findRepeat(entries, text, {
minutes = REPEAT_WINDOW_MINUTES,
threshold = REPEAT_THRESHOLD,
minChars = REPEAT_MIN_CHARS,
} = {}) {
if (normalizeSpeech(text).length < minChars) return null;
let best = null;
for (const row of entries || []) {
if (minutes !== null && ageSeconds(row.ts) > minutes * 60) continue;
const score = similarity(text, row.text || "");
if (score < threshold) continue;
if (!best || score > best.similarity) {
best = {
similarity: score,
at: row.ts,
text: String(row.text || "").slice(0, 200),
minutes_ago: Math.round(ageSeconds(row.ts) / 60),
};
}
}
return best;
}
function trimJsonl(file, keep) {
const rows = readJsonl(file);
if (rows.length <= keep * 1.5) return rows.length;
const kept = rows.slice(-keep);
writeText(file, kept.map((r) => JSON.stringify(r)).join("\n") + "\n");
return kept.length;
}
/** 記下「說出口的話」。同一句話 10 分鐘內只記一次(避免 room post 與 Stop hook 重複記)。 */
export function recordSaid(slug, text, { room = null, kind = "reply" } = {}) {
const norm = normalizeSpeech(text);
if (!norm) return null;
for (const row of readJsonl(saidPath(slug), 12)) {
if (row.norm === norm.slice(0, 400) && ageSeconds(row.ts) <= 600) return null;
}
const entry = { ts: nowIso(), kind, room, text: String(text).slice(0, 2000), norm: norm.slice(0, 400) };
appendJsonl(saidPath(slug), entry);
trimJsonl(saidPath(slug), SAID_KEEP);
return entry;
}
export const recentSaid = (slug, limit = 5) => readJsonl(saidPath(slug), limit);
export function saidRepeat(slug, text, opts = {}) {
return findRepeat(readJsonl(saidPath(slug), SAID_KEEP), text, opts);
}
/** 同一個發言者在同一個聊天室裡有沒有講過幾乎一樣的話。 */
export function roomRepeat(room, speaker, text, opts = {}) {
const rows = roomRead(room, 80).filter((m) => m.speaker === speaker && m.kind !== "meta");
return findRepeat(rows, text, opts);
}
/** 心裡話:只進自己的 inner.jsonl,永遠不回顯給使用者。 */
export function recordInner(slug, text, { kind = "infer", room = null } = {}) {
const entry = { ts: nowIso(), kind, room, text: String(text ?? "").slice(0, 1200) };
appendJsonl(innerPath(slug), entry);
trimJsonl(innerPath(slug), INNER_KEEP);
return entry;
}
export const recentInner = (slug, limit = 3) => readJsonl(innerPath(slug), limit);
/** 「心想 N 句」的 N:預設算最近 4 小時。 */
export function innerCount(slug, minutes = INNER_WINDOW_MINUTES) {
return readJsonl(innerPath(slug), INNER_KEEP).filter(
(row) => minutes === null || ageSeconds(row.ts) <= minutes * 60,
).length;
}
// --------------------------------------------------------------------------- //
// 心智圖 / 思維導圖 / 人際關係圖
// --------------------------------------------------------------------------- //
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}<br/>親近 ${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");
}
// --------------------------------------------------------------------------- //
// 匯出 / 匯入:把一個人格打包成單一檔案(可搬到另一台機器或另一個 AI 助理)
// --------------------------------------------------------------------------- //
//
// bundle 是純 JSON(可再 gzip),不含執行期狀態:
// * 帶走:IDENTITY/SOUL/AGENTS/USER、state/config.json、state/emotion.json、
// state/inner.jsonl、state/said.jsonl、記憶(短期/長期/inbox/INDEX)、
// 心智圖、思維導圖、人際關係圖。
// * 不帶:state/lock.json、state/guests.json(鎖與租約屬於「那台機器的那個程序」),
// journal/(逐字稿很大且屬隱私,要帶請加 --with-journal)。
export const BUNDLE_FORMAT = "jsc-persona/bundle";
export const BUNDLE_VERSION = 1;
export const BUNDLE_SKIP = new Set(["state/lock.json", "state/guests.json"]);
const MAX_BUNDLE_FILE = 5 * 1024 * 1024;
function walkFiles(root, rel = "") {
const out = [];
let entries = [];
try {
entries = fs.readdirSync(path.join(root, rel), { withFileTypes: true });
} catch {
return out;
}
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
const next = rel ? `${rel}/${entry.name}` : entry.name;
if (entry.isDirectory()) out.push(...walkFiles(root, next));
else if (entry.isFile()) out.push(next);
}
return out;
}
export const bundleChecksum = (files) =>
"sha256:" + crypto.createHash("sha256").update(JSON.stringify(files)).digest("hex");
export function exportBundle(slug, { withJournal = false } = {}) {
if (!personaExists(slug)) throw new Error(`人格 \`${slug}\` 不存在。`);
const root = personaDir(slug);
const files = {};
const skipped = [];
for (const rel of walkFiles(root)) {
if (BUNDLE_SKIP.has(rel) || /(^|\/)\.|\.tmp\d*$/.test(rel)) {
skipped.push(rel);
continue;
}
if (!withJournal && rel.startsWith("journal/")) {
skipped.push(rel);
continue;
}
let buf;
try {
buf = fs.readFileSync(path.join(root, rel));
} catch {
skipped.push(rel);
continue;
}
if (buf.length > MAX_BUNDLE_FILE) {
skipped.push(rel);
continue;
}
const text = buf.toString("utf8");
const isText = Buffer.compare(Buffer.from(text, "utf8"), buf) === 0;
files[rel] = isText ? { encoding: "utf8", content: text } : { encoding: "base64", content: buf.toString("base64") };
}
const bundle = {
format: BUNDLE_FORMAT,
version: BUNDLE_VERSION,
persona: slug,
exported_at: nowIso(),
identity: identityFields(slug),
stats: {
files: Object.keys(files).length,
long_term: longTermEntries(slug).length,
short_term: readJsonl(shortTermPath(slug)).length,
relations: loadRelations(slug).nodes.length,
said: readJsonl(saidPath(slug)).length,
inner: readJsonl(innerPath(slug)).length,
with_journal: Boolean(withJournal),
},
files,
};
bundle.checksum = bundleChecksum(files);
return { bundle, skipped };
}
/** bundle 內的相對路徑必須乖乖待在人格目錄裡(防 `../` 逃逸與絕對路徑)。 */
export function safeBundlePath(rel) {
const value = String(rel ?? "");
if (!value || path.isAbsolute(value) || value.includes("\\")) return null;
const parts = value.split("/");
if (parts.some((p) => !p || p === "." || p === "..")) return null;
return parts.join(path.sep);
}
export function validateBundle(bundle) {
const problems = [];
if (!bundle || typeof bundle !== "object") problems.push("不是合法的 JSON 物件");
else {
if (bundle.format !== BUNDLE_FORMAT) problems.push(`format 必須是 ${BUNDLE_FORMAT}(實際:${bundle.format}`);
if (Number(bundle.version) > BUNDLE_VERSION) problems.push(`bundle 版本 ${bundle.version} 比本版 (${BUNDLE_VERSION}) 新`);
if (!bundle.files || typeof bundle.files !== "object") problems.push("缺少 files");
else if (!bundle.files["IDENTITY.md"]) problems.push("缺少 IDENTITY.md(人格的最低要件)");
}
const checksumOk = !bundle?.checksum || bundle.checksum === bundleChecksum(bundle.files || {});
return { ok: problems.length === 0, problems, checksumOk };
}
export function importBundle(bundle, targetSlug, { session = null } = {}) {
const slug = targetSlug || bundle.persona;
if (!validSlug(slug)) throw new Error(`slug \`${slug}\` 不合法(小寫英數與連字號,最長 48 字)。`);
const { ok, problems } = validateBundle(bundle);
if (!ok) throw new Error(`bundle 不合法:${problems.join("")}`);
const root = ensurePersonaDirs(slug);
const written = [];
const rejected = [];
for (const [rel, entry] of Object.entries(bundle.files)) {
if (BUNDLE_SKIP.has(rel)) continue;
const safe = safeBundlePath(rel);
if (!safe) {
rejected.push(rel);
continue;
}
const target = path.join(root, safe);
const buf =
entry?.encoding === "base64"
? Buffer.from(String(entry.content || ""), "base64")
: Buffer.from(String(entry?.content ?? ""), "utf8");
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.writeFileSync(target, buf);
written.push(safe);
}
// 換名匯入時,config 要跟著改名,並留下來歷
const config = readJson(configPath(slug), {}) ?? {};
config.persona = slug;
config.imported_at = nowIso();
config.imported_from = { persona: bundle.persona, exported_at: bundle.exported_at || null };
if (session) config.imported_by_session = session;
writeJson(configPath(slug), config);
rebuildIndex(slug);
try {
renderRelations(slug);
} catch {
/* 沒有關係圖就算了 */
}
return { persona: slug, written, rejected };
}
// --------------------------------------------------------------------------- //
// 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", "think", "said",
]);
// owner 這些子指令本來就要提到別的人格名字(邀請/離場/查詢/匯入新人格),不算跨人格讀取
export const OWNER_EXEMPT_SUBCOMMANDS = new Set([
"create", "list", "status", "gc", "invite", "load", "leave", "import",
]);
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 <slug> --session <session_id>`(或 /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-context>",
`PERSONA_SESSION=${sessionId}`,
`人格:\`${slug}\` ${identityBrief(slug)}`,
`人格倉庫:${personaDir(slug)}(唯一可讀寫的人格資料範圍)`,
emotionBrief(slug, state),
`說話規則:回使用者 ${MAX_SENTENCES} 句以內(劇場模式每人每輪也一樣);` +
"推導、比對、盤算一律走心裡話(`persona.mjs think`),不要說給使用者聽——" +
"要讓他知道你在想,就只報「心想 N 句」,不報內容。",
];
const inner = recentInner(slug, 3);
if (inner.length) {
lines.push(`心裡話(只有自己知道;近 ${INNER_WINDOW_MINUTES / 60} 小時心想 ${innerCount(slug)} 句):`);
for (const row of inner) {
lines.push(` - 💭 ${String(row.text || "").replace(/\n/g, " ").slice(0, 90)}`);
}
}
const said = recentSaid(slug, 5);
if (said.length) {
lines.push(`最近說過的話(${REPEAT_WINDOW_MINUTES} 分鐘內不要再說一次,要嘛換角度、要嘛推進話題):`);
for (const row of said) {
lines.push(` - ${String(row.text || "").replace(/\n/g, " ").slice(0, 80)}`);
}
}
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` 並把輸出丟掉。",
` 每個人格每輪 ${MAX_SENTENCES} 句以內;推導走 \`think\`(心裡話);`,
" 近似重複的台詞 `room post` 會直接擋下,換個說法或推進話題,不要硬講同一句。",
" 想結束請等使用者說,或由使用者說「結束對話」後才做收尾與摘要。",
);
} 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("</persona-context>");
return lines.join("\n");
}