1263 lines
46 KiB
JavaScript
1263 lines
46 KiB
JavaScript
// 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 = [
|
||
"# 長期記憶索引",
|
||
"",
|
||
`<!-- 由 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);
|
||
}
|
||
|
||
/** 被回想到就更新 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}<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");
|
||
}
|
||
|
||
// --------------------------------------------------------------------------- //
|
||
// 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 <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),
|
||
];
|
||
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 pending = readJsonl(shortTermPath(slug)).length;
|
||
if (pending >= CONSOLIDATE_THRESHOLD) {
|
||
lines.push(`⚠ 短期記憶已累積 ${pending} 筆,建議執行 /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");
|
||
}
|