- sleeper 帶 --as-sleeper 就能在沒有 exclusive 鎖時做收尾 - sleeper 被 pin 在自己的人格上,讀寫其他人格(含主人格)一律攔下 - sleeper 只能走 CLI,不得用 Write 或 shell 重導向改人格檔案 - 主人格載入別的人格時,sleeper 仍以自己的 pin 為準 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2091 lines
80 KiB
JavaScript
2091 lines
80 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";
|
||
import zlib from "node:zlib";
|
||
|
||
// --------------------------------------------------------------------------- //
|
||
// 路徑
|
||
// --------------------------------------------------------------------------- //
|
||
|
||
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)租約
|
||
export const SLEEPER_LEASE_SECONDS = 300; // sleeper(睡眠 sub agent)租約:只夠做完收尾
|
||
export const SLEEP_DECAY_MINUTES = 480; // 睡一次=套用一次 8 小時的情緒衰減
|
||
|
||
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);
|
||
|
||
// 人格目錄名。新建的人格一律是**人格編號**(`ASUNA-01`:英文名全大寫+兩位索引),
|
||
// 但舊的小寫 slug(`asuna-sao`)仍然合法,才不會把既有人格鎖在門外。
|
||
const SLUG_RE = /^[A-Za-z0-9][A-Za-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"));
|
||
}
|
||
|
||
// --------------------------------------------------------------------------- //
|
||
// 全域設定(跨人格、跨 session)
|
||
// --------------------------------------------------------------------------- //
|
||
//
|
||
// 目前只有一個鍵:`default_persona`——開新 session 時要不要自動載入某個人格。
|
||
// 預設是**沒有設定**(等使用者指定),這是刻意的:不該替使用者挑一個人格附身。
|
||
|
||
export const homeSettingsPath = () => path.join(runtimeDir(), "settings.json");
|
||
|
||
export function loadHomeSettings() {
|
||
return readJson(homeSettingsPath(), {}) ?? {};
|
||
}
|
||
|
||
export function saveHomeSettings(patch) {
|
||
const data = { ...loadHomeSettings(), ...patch, updated_at: nowIso() };
|
||
writeJson(homeSettingsPath(), data);
|
||
return data;
|
||
}
|
||
|
||
/**
|
||
* 要自動載入的人格;沒設定就回 null(呼叫端應該「什麼都不做」)。
|
||
* 環境變數 `PERSONA_DEFAULT` 優先於設定檔,`off`/`none`/空字串代表關閉。
|
||
*/
|
||
export function defaultPersona() {
|
||
const env = String(process.env.PERSONA_DEFAULT ?? "").trim();
|
||
const raw = env || String(loadHomeSettings().default_persona ?? "").trim();
|
||
if (!raw || ["off", "none", "false", "0"].includes(raw.toLowerCase())) return null;
|
||
return raw;
|
||
}
|
||
|
||
export function setDefaultPersona(slug) {
|
||
return saveHomeSettings({ default_persona: slug || null });
|
||
}
|
||
|
||
// --------------------------------------------------------------------------- //
|
||
// 時間與檔案 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;
|
||
}
|
||
|
||
/**
|
||
* 明確套用「經過 N 分鐘」的衰減(睡眠用)。
|
||
*
|
||
* 跟 `decayEmotion` 不同:那個看的是距離上次更新過了多久(真實時間),
|
||
* 這個是「就算你才剛聊完,也讓情緒像過了一夜」。強度大的負向情緒半衰期本來就長,
|
||
* 所以睡一覺不會把羞愧與悲傷抹平——這是刻意的。
|
||
*/
|
||
export function decayEmotionBy(state, minutes = SLEEP_DECAY_MINUTES) {
|
||
const mins = Math.max(0, Number(minutes) || 0);
|
||
if (!mins) 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, mins / half) : 0;
|
||
state.levels[key] = Math.round((base + (level - base) * factor) * 100) / 100;
|
||
}
|
||
state.updated_at = nowIso();
|
||
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);
|
||
}
|
||
|
||
// --------------------------------------------------------------------------- //
|
||
// sleeper 租約(睡眠用的短期寫入權)
|
||
// --------------------------------------------------------------------------- //
|
||
//
|
||
// 為什麼不用 exclusive 鎖:主人格叫別人去睡的時候,自己並沒有 release,
|
||
// 而「一個 session 只能載入一個 host 人格」——所以睡眠需要另一種、更短的寫入權。
|
||
//
|
||
// 為什麼還是要驗鎖:睡眠會 prune/reindex/push。如果目標人格此刻正被另一個程序
|
||
// 載入著對話,兩邊會同時改同一批檔案,記憶會互相覆蓋。所以:
|
||
// * 沒有活著的鎖 → 直接取得 sleeper 租約(最常見)
|
||
// * 鎖屬於同一個 session → 可以(自己睡自己)
|
||
// * 鎖是死的(心跳過期)→ 可以接手(睡眠本來就是收尾動作)
|
||
// * 鎖活著且屬於別人 → 拒絕,回報 owner 讓使用者決定
|
||
|
||
export const sleepersPath = (slug) => path.join(personaDir(slug), "state", "sleepers.json");
|
||
|
||
export function liveSleepers(slug) {
|
||
const data = readJson(sleepersPath(slug), {}) ?? {};
|
||
return (data.sleepers || []).filter((s) => ageSeconds(s.heartbeat_at) <= SLEEPER_LEASE_SECONDS);
|
||
}
|
||
|
||
/** 取得睡眠寫入權;拿不到就丟 LockError(附 owner 資訊)。 */
|
||
export function acquireSleepLease(slug, sessionId, { agentId = null } = {}) {
|
||
ensurePersonaDirs(slug);
|
||
const lock = readJson(lockPath(slug)) ?? {};
|
||
const held = Boolean(Object.keys(lock).length);
|
||
if (held && lock.session_id !== sessionId && !lockIsDead(lock)) {
|
||
throw new LockError(
|
||
`人格 \`${slug}\` 正被另一個程序載入(session ${String(lock.session_id).slice(0, 8)}…,` +
|
||
`cwd ${lock.cwd},最後心跳 ${lock.heartbeat_at})。它可能正在對話中,` +
|
||
"現在睡眠會與它的寫入互相覆蓋,所以不做。請等它結束或請使用者決定。",
|
||
{ session_id: lock.session_id, cwd: lock.cwd, heartbeat_at: lock.heartbeat_at },
|
||
);
|
||
}
|
||
const others = liveSleepers(slug).filter((s) => !(s.session_id === sessionId && s.agent_id === agentId));
|
||
if (others.length) {
|
||
throw new LockError(`人格 \`${slug}\` 已經有另一個 sleeper 在收尾(session ${String(others[0].session_id).slice(0, 8)}…)。`, others[0]);
|
||
}
|
||
const lease = {
|
||
session_id: sessionId,
|
||
agent_id: agentId,
|
||
started_at: nowIso(),
|
||
heartbeat_at: nowIso(),
|
||
lease_seconds: SLEEPER_LEASE_SECONDS,
|
||
took_over_dead_lock: held && lockIsDead(lock),
|
||
};
|
||
writeJson(sleepersPath(slug), { sleepers: [lease] });
|
||
return lease;
|
||
}
|
||
|
||
/** 續租:收尾要跑好幾個指令(固化、日記、心智圖…),別讓租約在中途過期。 */
|
||
export function heartbeatSleepLease(slug, sessionId, agentId = null) {
|
||
const data = readJson(sleepersPath(slug), {}) ?? {};
|
||
let touched = false;
|
||
for (const lease of data.sleepers || []) {
|
||
if (lease.session_id !== sessionId) continue;
|
||
if (agentId !== null && lease.agent_id && lease.agent_id !== agentId) continue;
|
||
lease.heartbeat_at = nowIso();
|
||
touched = true;
|
||
}
|
||
if (touched) writeJson(sleepersPath(slug), data);
|
||
return touched;
|
||
}
|
||
|
||
export function dropSleepLease(slug, sessionId, agentId = null) {
|
||
const data = readJson(sleepersPath(slug), {}) ?? {};
|
||
data.sleepers = (data.sleepers || []).filter(
|
||
(s) => !(s.session_id === sessionId && (agentId === null || s.agent_id === agentId)),
|
||
);
|
||
writeJson(sleepersPath(slug), data);
|
||
}
|
||
|
||
// --------------------------------------------------------------------------- //
|
||
// 睡眠會做的機械性收尾
|
||
// --------------------------------------------------------------------------- //
|
||
|
||
export const THREAD_STALE_DAYS = 7;
|
||
export const SAID_KEEP_HOURS = 24;
|
||
export const SAID_KEEP_LINES = 300;
|
||
|
||
/** 太久沒動的思維導圖收進 `mindmap/threads/archive/`(不刪,只是收起來)。 */
|
||
export function archiveStaleThreads(slug, days = THREAD_STALE_DAYS) {
|
||
const dir = path.join(personaDir(slug), "mindmap", "threads");
|
||
let names = [];
|
||
try {
|
||
names = fs.readdirSync(dir).filter((f) => f.endsWith(".mmd"));
|
||
} catch {
|
||
return 0;
|
||
}
|
||
const cutoff = Date.now() - days * 86_400_000;
|
||
const archive = path.join(dir, "archive");
|
||
let moved = 0;
|
||
for (const name of names) {
|
||
const file = path.join(dir, name);
|
||
let stat;
|
||
try {
|
||
stat = fs.statSync(file);
|
||
} catch {
|
||
continue;
|
||
}
|
||
if (stat.mtimeMs >= cutoff) continue;
|
||
fs.mkdirSync(archive, { recursive: true });
|
||
fs.renameSync(file, path.join(archive, name));
|
||
moved += 1;
|
||
}
|
||
return moved;
|
||
}
|
||
|
||
/** `said.jsonl` 只服務「不要重講」判定,留最近 24 小時/300 行就夠。 */
|
||
export function trimSaid(slug, { hours = SAID_KEEP_HOURS, lines = SAID_KEEP_LINES } = {}) {
|
||
const file = saidPath(slug);
|
||
const rows = readJsonl(file);
|
||
if (!rows.length) return 0;
|
||
const cutoff = Date.now() - hours * 3_600_000;
|
||
let kept = rows.filter((r) => (parseIso(r.ts)?.getTime() ?? Date.now()) >= cutoff);
|
||
kept = kept.slice(-lines);
|
||
if (kept.length !== rows.length) {
|
||
writeText(file, kept.map((r) => JSON.stringify(r)).join("\n") + (kept.length ? "\n" : ""));
|
||
}
|
||
return kept.length;
|
||
}
|
||
|
||
/** 把「上個月以前」的 journal 壓成 .jsonl.gz(同步時省流量,也不再被讀)。 */
|
||
export function archiveJournals(slug) {
|
||
const dir = path.join(personaDir(slug), "journal");
|
||
let names = [];
|
||
try {
|
||
names = fs.readdirSync(dir).filter((f) => /^\d{4}-\d{2}\.jsonl$/.test(f));
|
||
} catch {
|
||
return 0;
|
||
}
|
||
const current = path.basename(journalPath(slug));
|
||
let gzipped = 0;
|
||
for (const name of names) {
|
||
if (name === current) continue;
|
||
const file = path.join(dir, name);
|
||
const target = `${file}.gz`;
|
||
if (fs.existsSync(target)) continue;
|
||
fs.writeFileSync(target, zlib.gzipSync(fs.readFileSync(file)));
|
||
fs.rmSync(file);
|
||
gzipped += 1;
|
||
}
|
||
return gzipped;
|
||
}
|
||
|
||
// --------------------------------------------------------------------------- //
|
||
// 睡眠狀態
|
||
// --------------------------------------------------------------------------- //
|
||
|
||
export const sleepStatePath = (slug) => path.join(personaDir(slug), "state", "sleep.json");
|
||
|
||
export function loadSleepState(slug) {
|
||
const data = readJson(sleepStatePath(slug), {}) ?? {};
|
||
data.last_slept_at ??= null;
|
||
data.count ??= 0;
|
||
return data;
|
||
}
|
||
|
||
export function saveSleepState(slug, patch) {
|
||
const data = { ...loadSleepState(slug), ...patch };
|
||
writeJson(sleepStatePath(slug), data);
|
||
return data;
|
||
}
|
||
|
||
/** 距離上次睡眠幾小時(沒睡過回 null)。 */
|
||
export function hoursAwake(slug) {
|
||
const at = loadSleepState(slug).last_slept_at;
|
||
if (!at) return null;
|
||
return Math.round((ageSeconds(at) / 3600) * 10) / 10;
|
||
}
|
||
|
||
// --------------------------------------------------------------------------- //
|
||
// 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);
|
||
}
|
||
|
||
/** 在 entries(need `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;
|
||
}
|
||
|
||
/** 蓋上「最後一次接觸」的時間戳;找不到那個人就回 false(不會憑空建節點)。 */
|
||
export function stampContact(slug, nameOrId, at = nowIso()) {
|
||
const data = loadRelations(slug);
|
||
const key = slugify(String(nameOrId || ""));
|
||
const node = data.nodes.find((n) => n.id === key || slugify(n.name || "") === key);
|
||
if (!node) return false;
|
||
node.last_contact_at = at;
|
||
node.updated_at = nowIso();
|
||
writeJson(relationsJson(slug), data);
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* 很久沒聯絡、但關係很近的人——這是「主動想起某個人」的客觀依據。
|
||
* 沒有 `last_contact_at` 的節點用 `created_at` 當起點;分數 = 親近度 × 沉默天數。
|
||
*/
|
||
export function staleContacts(slug, { days = 3, minCloseness = 60, limit = 3 } = {}) {
|
||
const data = loadRelations(slug);
|
||
const out = [];
|
||
for (const node of data.nodes) {
|
||
const closeness = Number(node.closeness ?? 0);
|
||
if (closeness < minCloseness) continue;
|
||
const since = node.last_contact_at || node.created_at || null;
|
||
const silentDays = since ? Math.floor(ageSeconds(since) / 86_400) : null;
|
||
if (silentDays === null || silentDays < days) continue;
|
||
out.push({
|
||
id: node.id,
|
||
name: node.name || node.id,
|
||
kind: node.kind || "human",
|
||
closeness,
|
||
silent_days: silentDays,
|
||
score: closeness * silentDays,
|
||
});
|
||
}
|
||
return out.sort((a, b) => b.score - a.score).slice(0, limit);
|
||
}
|
||
|
||
export function upsertRelationNode(slug, node) {
|
||
const data = loadRelations(slug);
|
||
const nodeId = node.id || slugify(node.name || "");
|
||
node.id = nodeId;
|
||
let idx = data.nodes.findIndex((n) => n.id === nodeId);
|
||
// 同一個人不該因為換了 id(例如原本用名字當 id,後來改用人格編號 ASUNA-01)就多長一個節點:
|
||
// 找不到 id 但找得到同名節點時,就地換 id,並把指向舊 id 的連線一起改過去。
|
||
if (idx < 0 && node.name) {
|
||
const byName = data.nodes.findIndex((n) => n.name === node.name);
|
||
if (byName >= 0) {
|
||
const oldId = data.nodes[byName].id;
|
||
idx = byName;
|
||
if (oldId !== nodeId) {
|
||
for (const edge of data.edges) {
|
||
if (edge.from === oldId) edge.from = nodeId;
|
||
if (edge.to === oldId) edge.to = 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", "default", "sleep",
|
||
]);
|
||
// sleeper(睡眠 sub agent)只准做收尾:整理自己的記憶與圖、衰減情緒、同步、寫睡眠狀態。
|
||
// 不准 load/release(它用的是 sleeper 租約)、不准 invite/room(它不是去聊天的)、
|
||
// 不准 export/import(那是把記憶搬出去)、不准 remember(收尾階段不再新增短期記憶)。
|
||
export const SLEEPER_SAFE_SUBCOMMANDS = new Set([
|
||
"sleep", "candidates", "consolidate", "prune", "reindex", "mindmap", "relation",
|
||
"emotion", "recall", "show", "brief", "status", "said", "think", "sync",
|
||
]);
|
||
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),
|
||
asSleeper: /--as-sleeper\b/.test(command),
|
||
};
|
||
const sub = command.match(/persona\.(?:mjs|js|py)['"]?\s+([a-z][a-z0-9-]*)/);
|
||
if (sub) info.subcommand = sub[1];
|
||
// 人格名可能是大寫的編號(ASUNA-01),漏掉大寫等於漏掉整個跨人格檢查
|
||
info.personas = [...command.matchAll(/--(?:persona|guest|host|as)[= ]+['"]?([A-Za-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),之後不得換人。
|
||
* * persona-sleeper 型 sub agent → **就是那個人格自己在睡**:對目標人格可寫,
|
||
* 但被 pin 在它身上(連 host 都不能碰),而且只准跑收尾用的子指令。
|
||
* 它回傳給主人格的只有「睡完了沒、哪一步出錯」,不含任何記憶內容。
|
||
*/
|
||
export function resolveScope(sessionId, agentId, agentType) {
|
||
const data = loadSession(sessionId);
|
||
const host = data.host;
|
||
const guests = Object.keys(data.guests || {});
|
||
const type = String(agentType || "");
|
||
const isGuestAgent = Boolean(agentType) && type.includes("persona-guest");
|
||
const isSleeperAgent = Boolean(agentType) && type.includes("persona-sleeper");
|
||
if (isSleeperAgent) {
|
||
const pinned = (data.pins || {})[agentId || ""];
|
||
return {
|
||
role: "sleeper",
|
||
// 還沒 pin:允許 first-touch(碰到誰就定誰);pin 之後不得換人
|
||
allowed: pinned ? [pinned] : null,
|
||
readonly: false,
|
||
host,
|
||
guests,
|
||
pinned,
|
||
rooms: [],
|
||
session: data,
|
||
};
|
||
}
|
||
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 === "sleeper") {
|
||
if (!SLEEPER_SAFE_SUBCOMMANDS.has(sub)) {
|
||
return deny(
|
||
`睡眠 sub agent 只能執行 ${[...SLEEPER_SAFE_SUBCOMMANDS].sort().join("/")},不得執行 \`${sub}\`。` +
|
||
"它的任務是把自己的一天收尾,不是聊天、載入或搬移記憶。",
|
||
);
|
||
}
|
||
for (const slug of info.personas) {
|
||
if (scope.allowed && !scope.allowed.includes(slug)) {
|
||
return deny(
|
||
`這個睡眠 sub agent 已經被綁在 \`${scope.allowed[0]}\`,不得再碰 \`${slug}\`(一次只睡一個人格)。`,
|
||
);
|
||
}
|
||
}
|
||
// first-touch pinning:第一個提到的人格就是它要睡的那個,之後不得換人
|
||
if (!scope.allowed && agentId && info.personas.length) pinAgent(sessionId, agentId, info.personas[0]);
|
||
// sleeper 的範圍已經由它自己的 pin 決定,不能再套用 host 的判定
|
||
// (否則主人格 load 著別人時,sleeper 連自己的收尾指令都會被擋)。
|
||
} else if (scope.role === "guest") {
|
||
if (!GUEST_SAFE_SUBCOMMANDS.has(sub)) {
|
||
return deny(
|
||
`guest 人格(sub agent)僅能執行 ${[...GUEST_SAFE_SUBCOMMANDS].sort().join("/")},不得執行 \`${sub}\`。`,
|
||
);
|
||
}
|
||
if (info.asSleeper) {
|
||
return deny("`--as-sleeper` 只有 persona-sleeper 型的 sub agent 能用;guest 是來聊天的,不是來收尾的。");
|
||
}
|
||
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 能用;主程序不得以受邀人格的身分存取它的資料。",
|
||
);
|
||
}
|
||
if (info.asSleeper) {
|
||
return deny(
|
||
"`--as-sleeper` 只有 persona-sleeper 型的 sub agent 能用;" +
|
||
"要請別的人格收尾請走 /jsc-persona:persona-sleep(它會替那個人格開一個 sleeper)。",
|
||
);
|
||
}
|
||
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" || scope.role === "sleeper")) {
|
||
for (const target of extractPaths(tool, toolInput, cwd)) {
|
||
if (personaSlugOf(target)) {
|
||
return deny(
|
||
scope.role === "sleeper"
|
||
? "睡眠 sub agent 不得用 shell 改人格倉庫的檔案,收尾請走 persona CLI 的子指令。"
|
||
: "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.role === "sleeper") {
|
||
// 睡眠 sub agent:被 pin 在目標人格(連 host 都不能碰),而且只能讀;
|
||
// 所有寫入都要走 CLI 的白名單子指令,不准自己動手改檔案。
|
||
if (scope.allowed && !scope.allowed.includes(slug)) {
|
||
return deny(
|
||
`這個睡眠 sub agent 只能碰 \`${scope.allowed[0]}\`,不得讀寫 \`${slug}\` 的資料(跨人格資料隔離)。`,
|
||
);
|
||
}
|
||
if (!scope.allowed && agentId) pinAgent(sessionId, agentId, slug);
|
||
if (MUTATING_TOOLS.has(tool)) {
|
||
return deny(
|
||
`睡眠 sub agent 不得直接改檔案(\`${slug}\`)。收尾的每一步都要走 persona CLI,` +
|
||
"這樣才會經過鎖、索引與同步的處理。",
|
||
);
|
||
}
|
||
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}`);
|
||
|
||
// 很久沒聯絡但很親近的人 → 這是「主動提議去關心某人」的依據(不是每輪都要提)
|
||
const stale = staleContacts(slug);
|
||
if (stale.length) {
|
||
lines.push(
|
||
`很久沒接觸的人:${stale.map((s) => `${s.name}(親近 ${s.closeness}/沉默 ${s.silent_days} 天)`).join("、")}`,
|
||
" 想去看看誰是可以的:對人格用 /jsc-persona:persona-invite(`invite --theater off` 只換一輪、不進劇場)," +
|
||
"但**先問使用者一句**再邀,不要自己把畫面切走。",
|
||
);
|
||
}
|
||
const awake = hoursAwake(slug);
|
||
if (awake !== null && awake >= 16) {
|
||
lines.push(`距離上次睡眠已經 ${awake} 小時(短期記憶會越積越多)→ 可以提議 /jsc-persona:persona-sleep。`);
|
||
}
|
||
|
||
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");
|
||
}
|