衝突:scripts/selftest.mjs 檔尾兩份都各自 append 一個測試區塊 (guard 的「注入區塊不可被人格檔案逸出(S6)」與 gitea 的「㉒ 從 Gitea 匯入本機還沒有的人格」)。兩塊都保留,各自收好自己的 `}`。
3565 lines
155 KiB
JavaScript
3565 lines
155 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);
|
||
}
|
||
|
||
// --------------------------------------------------------------------------- //
|
||
// 檔案層互斥鎖
|
||
//
|
||
// 人格鎖(`state/lock.json`)擋的是「兩個人格同時被載入」,不是「兩個程序同時寫同一個檔」:
|
||
// 同一個 session 的 sub agent 與主程序**共用同一把人格鎖**(設計如此),所以兩邊真的會
|
||
// 同時寫。而 said / short-term 的裁切是「整檔讀進來 → 過濾 → 覆蓋」,中間任何一筆 append
|
||
// 都會被那次覆蓋吃掉——實測背景 prune 進行中寫入 30 筆 salience 95 的承諾,會掉幾筆。
|
||
//
|
||
// 所以:凡是碰同一個檔案的 append 與 rewrite,一律先拿這把鎖。
|
||
// sentinel 檔用 `O_EXCL` 建(跨程序原子),放在 `.runtime/locks/` 而不是人格目錄裡,
|
||
// 免得殘留的鎖檔被同步到 Gitea 或被 guard 掃到。
|
||
// --------------------------------------------------------------------------- //
|
||
|
||
export const FILE_LOCK_STALE_MS = 15_000; // 這麼久沒放掉就當持有者死了(避免整個系統卡住)
|
||
export const FILE_LOCK_TIMEOUT_MS = 10_000;
|
||
|
||
const SLEEP_BUF = new Int32Array(new SharedArrayBuffer(4));
|
||
/** 同步小睡(這裡不能用 await:呼叫端全是同步 API)。 */
|
||
function sleepSync(ms) {
|
||
if (ms > 0) Atomics.wait(SLEEP_BUF, 0, 0, ms);
|
||
}
|
||
|
||
export function fileLockPath(file) {
|
||
const key = crypto.createHash("md5").update(path.resolve(file)).digest("hex").slice(0, 16);
|
||
return path.join(runtimeDir(), "locks", `${path.basename(file)}.${key}.lock`);
|
||
}
|
||
|
||
/**
|
||
* 拿著 `file` 的互斥鎖跑 `fn()`,回傳 `fn` 的結果。
|
||
*
|
||
* 拿不到就短暫重試(含亂數退讓,避免兩邊同步互踩);超過 `FILE_LOCK_STALE_MS`
|
||
* 沒被放掉的鎖視為死鎖並搶走。真的等不到就直接做——寧可冒一次競態,
|
||
* 也不要因為一個殘留的鎖檔讓人格從此寫不進東西。
|
||
*/
|
||
export function withFileLock(file, fn, { timeoutMs = FILE_LOCK_TIMEOUT_MS } = {}) {
|
||
const lock = fileLockPath(file);
|
||
fs.mkdirSync(path.dirname(lock), { recursive: true });
|
||
const deadline = Date.now() + timeoutMs;
|
||
let fd = null;
|
||
while (fd === null) {
|
||
try {
|
||
fd = fs.openSync(lock, "wx", 0o600);
|
||
} catch (err) {
|
||
if (err.code !== "EEXIST") throw err;
|
||
let stat = null;
|
||
try {
|
||
stat = fs.statSync(lock);
|
||
} catch {
|
||
continue; // 剛好被放掉了,再試一次
|
||
}
|
||
if (Date.now() - stat.mtimeMs > FILE_LOCK_STALE_MS) {
|
||
try {
|
||
fs.unlinkSync(lock);
|
||
} catch {
|
||
/* 別人先清掉了 */
|
||
}
|
||
continue;
|
||
}
|
||
if (Date.now() > deadline) break; // 等太久:不擋住呼叫端
|
||
sleepSync(1 + Math.floor(Math.random() * 5));
|
||
}
|
||
}
|
||
try {
|
||
if (fd !== null) fs.writeSync(fd, `${process.pid}\n`);
|
||
return fn();
|
||
} finally {
|
||
if (fd !== null) {
|
||
try {
|
||
fs.closeSync(fd);
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
try {
|
||
fs.unlinkSync(lock);
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/** 單行 append。拿檔案鎖,才不會被同時進行的整檔覆蓋(prune/trim)吃掉。 */
|
||
export function appendJsonl(file, obj) {
|
||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||
const line = JSON.stringify(obj) + "\n";
|
||
withFileLock(file, () => {
|
||
fs.appendFileSync(file, line, { encoding: "utf8", mode: 0o600 });
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 「整檔讀進來 → 過濾 → 覆蓋」的唯一入口:整段在鎖裡面做,
|
||
* 所以 `transform` 看到的一定是最新的內容,寫回去也不會蓋掉別人剛 append 的行。
|
||
*
|
||
* `transform(rows)` 回傳要留下的列;回傳的陣列跟原本一樣長就不寫(省一次 IO)。
|
||
*/
|
||
export function rewriteJsonl(file, transform) {
|
||
return withFileLock(file, () => {
|
||
const rows = readJsonl(file);
|
||
const kept = transform(rows) ?? rows;
|
||
if (kept.length !== rows.length) {
|
||
writeText(file, kept.map((r) => JSON.stringify(r)).join("\n") + (kept.length ? "\n" : ""));
|
||
}
|
||
return { rows, kept };
|
||
});
|
||
}
|
||
|
||
/** JSON 檔的 read-modify-write:`mutate(data)` 回傳要寫回去的物件。 */
|
||
export function updateJson(file, mutate, fallback = null) {
|
||
return withFileLock(file, () => {
|
||
const next = mutate(readJson(file, fallback));
|
||
writeJson(file, next);
|
||
return next;
|
||
});
|
||
}
|
||
|
||
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,
|
||
};
|
||
}
|
||
|
||
/** 0–100 之間的合法數字;不是數字(NaN/字串/null)就退回 `fallback`。 */
|
||
function sanelevel(value, fallback) {
|
||
const num = Number(value);
|
||
return Number.isFinite(num) ? clamp(num) : clamp(fallback);
|
||
}
|
||
|
||
/**
|
||
* 讀情緒狀態。**檔案是外部輸入**(`import`/`sync pull`/手改都會進到這裡),
|
||
* 所以不能只補缺、還要驗合法性——以前用 `??=` 只補 undefined,結果:
|
||
*
|
||
* * `baseline.joy = 5000` → 衰減後 joy 破千,mood 卡在 valence/arousal 100
|
||
* * `levels.anger = "很生氣"` → 衰減後 anger = NaN,注入的上下文印出 valence NaN
|
||
* * `half_life_minutes.joy = 0` 或負數 → factor 恆為 0,那個情緒從此累積不起來
|
||
*
|
||
* CLI 的 `--baseline`/`--apply` 本來就有保護,破口純粹在檔案入口,所以擋在這裡。
|
||
*/
|
||
export function loadEmotion(slug) {
|
||
let state = readJson(emotionPath(slug));
|
||
if (!state || typeof state !== "object" || !state.levels) state = defaultEmotionState();
|
||
if (!state.baseline || typeof state.baseline !== "object") state.baseline = {};
|
||
if (!state.levels || typeof state.levels !== "object") state.levels = {};
|
||
if (!state.half_life_minutes || typeof state.half_life_minutes !== "object") state.half_life_minutes = {};
|
||
for (const key of EMOTION_KEYS) {
|
||
state.baseline[key] = sanelevel(state.baseline[key], DEFAULT_BASELINE[key]);
|
||
state.levels[key] = sanelevel(state.levels[key], state.baseline[key]);
|
||
const half = Number(state.half_life_minutes[key]);
|
||
state.half_life_minutes[key] = Number.isFinite(half) && half > 0 ? half : EMOTIONS[key].halfLife;
|
||
}
|
||
return state;
|
||
}
|
||
|
||
/**
|
||
* 情緒的 read-modify-write:`mutate(state)` 回傳要寫回去的狀態,整段在檔案鎖裡。
|
||
*
|
||
* `emotion.json` 是最容易掉更新的一個檔——它每輪都被讀出來、改一點、整份寫回去。
|
||
* 實測連續 8 次 `--apply joy=+5`,並行時大約有四成的增量直接消失。
|
||
*/
|
||
export function updateEmotion(slug, mutate) {
|
||
return withFileLock(emotionPath(slug), () => {
|
||
const next = mutate(loadEmotion(slug));
|
||
writeJson(emotionPath(slug), next);
|
||
return next;
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 把 `minutes` 分鐘的衰減直接套上去(decayEmotion 與 decayEmotionBy 共用)。
|
||
*
|
||
* 每個值都重新驗過一次:這兩個函式是 export 的,呼叫端有可能餵進手寫或匯入來的狀態,
|
||
* 不能假設它一定經過 `loadEmotion()`。半衰期 ≤ 0 會讓 factor 恆為 0(那個情緒從此
|
||
* 累積不起來),所以一律退回該情緒的預設半衰期。
|
||
*/
|
||
function applyDecay(state, minutes) {
|
||
for (const key of EMOTION_KEYS) {
|
||
const rawHalf = Number(state.half_life_minutes?.[key]);
|
||
const half = Number.isFinite(rawHalf) && rawHalf > 0 ? rawHalf : EMOTIONS[key].halfLife;
|
||
const base = sanelevel(state.baseline?.[key], DEFAULT_BASELINE[key]);
|
||
const level = sanelevel(state.levels?.[key], base);
|
||
const factor = Math.pow(0.5, minutes / half);
|
||
state.levels[key] = Math.round(clamp(base + (level - base) * factor) * 100) / 100;
|
||
}
|
||
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;
|
||
applyDecay(state, minutes);
|
||
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;
|
||
applyDecay(state, mins);
|
||
state.updated_at = nowIso();
|
||
return state;
|
||
}
|
||
|
||
// --------------------------------------------------------------------------- //
|
||
// 情緒調節:飽和與單輪預算
|
||
//
|
||
// 沒有這兩條的時候,delta 是加完直接 clamp(0,100)——而 delta 一直是人格自己挑的,
|
||
// 沒有人會主動給自己扣分,結果就是正向一路貼頂(喜悅/平靜/感激同時 100,
|
||
// mood 的 valence 卡在 +100 失去解析度),負向整天不動。
|
||
//
|
||
// 飽和:越接近端點,同方向的 delta 越小(headroom^K)。永遠逼近 100,不會真的到。
|
||
// 預算:一輪之內所有 delta 的絕對值總和有上限,超過就等比例縮小。
|
||
// --------------------------------------------------------------------------- //
|
||
|
||
export const EMOTION_SATURATION_K = 1; // 0 = 不飽和;越大越難推到端點(1 = 線性遞減,永遠逼近但到不了 100)
|
||
export const EMOTION_TURN_BUDGET = 60; // 單輪所有 |delta| 的總和上限(emotions.md:單筆建議 ±3~±25)
|
||
|
||
/** 這一筆 delta 實際能推動多少:往端點走會被壓,往 baseline 回來不壓。 */
|
||
export function saturateDelta(level, delta, baseline = null) {
|
||
const d = Number(delta);
|
||
if (!Number.isFinite(d) || d === 0) return 0;
|
||
const lv = clamp(level);
|
||
// 往 baseline 的方向移動是「回歸」,不該被壓
|
||
if (baseline !== null && Number.isFinite(Number(baseline))) {
|
||
const base = Number(baseline);
|
||
if ((d > 0 && lv < base) || (d < 0 && lv > base)) return d;
|
||
}
|
||
const headroom = d > 0 ? (100 - lv) / 100 : lv / 100;
|
||
return d * Math.pow(Math.max(0, headroom), EMOTION_SATURATION_K);
|
||
}
|
||
|
||
export function applyEmotion(state, deltas, trigger = "") {
|
||
state = decayEmotion(state);
|
||
// 先算「想推多少」,超過單輪預算就等比例縮小——一次灌爆的路要堵起來
|
||
const wanted = [];
|
||
for (const [key, rawDelta] of Object.entries(deltas || {})) {
|
||
if (!(key in EMOTIONS)) continue;
|
||
const delta = Number(rawDelta);
|
||
if (!Number.isFinite(delta) || delta === 0) continue;
|
||
wanted.push([key, delta]);
|
||
}
|
||
const demand = wanted.reduce((sum, [, d]) => sum + Math.abs(d), 0);
|
||
const budgetScale = demand > EMOTION_TURN_BUDGET ? EMOTION_TURN_BUDGET / demand : 1;
|
||
const applied = {};
|
||
for (const [key, rawDelta] of wanted) {
|
||
const before = Number(state.levels[key] ?? 0);
|
||
const base = Number(state.baseline?.[key] ?? DEFAULT_BASELINE[key]);
|
||
const effective = saturateDelta(before, rawDelta * budgetScale, base);
|
||
state.levels[key] = Math.round(clamp(before + effective) * 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 };
|
||
if (budgetScale < 1) state.last_trigger.budget_scaled = Math.round(budgetScale * 100) / 100;
|
||
}
|
||
return state;
|
||
}
|
||
|
||
// 同一句話,從枕邊人嘴裡跟從生人嘴裡出來,衝擊本來就不一樣。
|
||
// 這條把關係圖接進情緒:親近度高 → delta 放大;生人 → 縮小。
|
||
// 只調**幅度**,不調方向——誰講的不會讓難過變成高興。
|
||
export const RELATION_GAIN_MIN = 0.7;
|
||
export const RELATION_GAIN_MAX = 1.35;
|
||
|
||
/** 這個對象講的話,對我的情緒有多少倍的份量。找不到對象就 1(不放大也不縮小)。 */
|
||
export function relationGain(slug, who = null) {
|
||
const node = who ? findRelationNode(slug, who) : speakerNode(slug);
|
||
if (!node) return { gain: 1, name: null, closeness: null };
|
||
const closeness = Number(node.closeness ?? 30);
|
||
const gain = clamp(0.7 + 0.006 * closeness, RELATION_GAIN_MIN, RELATION_GAIN_MAX);
|
||
return { gain: Math.round(gain * 100) / 100, name: node.name || node.id, closeness };
|
||
}
|
||
|
||
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 const EMOTION_TELLS = {
|
||
joy: ["話變多、句子輕", "會笑出來(「哈」)", "忍不住多補一句"],
|
||
trust: ["話變直、不鋪陳", "省略主語,講一半對方也懂", "敢說「你這樣不對」"],
|
||
anticipation: ["搶著問下一步", "反問變多", "把時間講得很具體(「等一下」「明天早上」)"],
|
||
gratitude: ["指名是哪一件事", "語尾變軟", "會再說一次謝謝"],
|
||
serenity: ["句子完整、節奏慢", "留白,允許沉默", "不追問"],
|
||
delight: ["短促的驚呼(「欸」「真的?」)", "句子斷開", "重複確認一次"],
|
||
anger: ["句子變短、句號變多", "稱呼退回全名、不叫暱稱", "拒絕修飾,只講事實"],
|
||
sadness: ["話少,只回一個詞", "句子沒說完就停", "答非所問,跳開話題"],
|
||
fear: ["先確認安全再講別的", "問句連發", "描述身體與動作(手在抖、往後退)"],
|
||
disgust: ["用拉開距離的詞(「那個東西」)", "不肯講出對方的名字", "句子往後退、想結束話題"],
|
||
shame: ["鬧彆扭:先否認,再小聲承認", "講反話、嘴硬", "轉移話題、把責任講小"],
|
||
anxiety: ["句子斷在一半", "疊字(「就、就是」「我、我知道」)", "追一句「這樣可以嗎」"],
|
||
};
|
||
|
||
/**
|
||
* 此刻會不自覺出現的行為:取主導情緒(超出基線最多)裡強度夠的前兩種。
|
||
* 強度不夠就不演——低強度的情緒在語氣上是看不出來的。
|
||
*/
|
||
export function emotionTells(slug, state = null, { min = 40, top = 2 } = {}) {
|
||
const st = decayEmotion(structuredClone(state ?? loadEmotion(slug)));
|
||
// 羞恥敏感度會調「羞愧」這一個破口的門檻:容易害羞的人不用到 40 就藏不住,
|
||
// 不在意別人眼光的人就算羞愧上來了也不會表現出來。其他十一種不受影響。
|
||
const modesty = slug ? modestyOf(slug).value : 50;
|
||
const floorFor = (key) => (key === "shame" ? clamp(min - (modesty - 50) * 0.4, 10, 90) : min);
|
||
return dominant(st, 4)
|
||
.filter(({ key, level }) => level >= floorFor(key))
|
||
.slice(0, top)
|
||
.map(({ key, level }) => ({
|
||
key,
|
||
zh: EMOTIONS[key].zh,
|
||
level: Math.round(level),
|
||
tells: EMOTION_TELLS[key] || [],
|
||
}));
|
||
}
|
||
|
||
// --------------------------------------------------------------------------- //
|
||
// 性別與羞恥敏感度
|
||
//
|
||
// 規則的排序是固定的,不能反過來:
|
||
// ① IDENTITY/SOUL 寫的個性描述 ②角色原作既有的性別化語言特徵 ③性別預設
|
||
// 性別**只是一個預設值**,不是套在個性上的係數——所以兩個都是女性但個性不同的人格
|
||
// 不會講起話來一樣:描述裡任何一句相關的話都會把預設往上或往下推,也可以推到 0。
|
||
//
|
||
// 影響的範圍刻意只有**一個具名維度**:羞恥敏感度(會不會害羞、會不會鬧彆扭、
|
||
// 在不在意別人眼光)。不做「女性→情緒更外顯」這種全域放大,那會把角色壓成模板。
|
||
// --------------------------------------------------------------------------- //
|
||
|
||
export const GENDERS = { female: "女性", male: "男性", nonbinary: "非二元", unknown: "未指定" };
|
||
|
||
/** 性別給的**預設**羞恥敏感度(0–100)。描述有講就以描述為準。 */
|
||
export const GENDER_MODESTY_DEFAULT = { female: 62, male: 38, nonbinary: 50, unknown: 50 };
|
||
|
||
/** 從描述裡讀「這個角色在不在意別人眼光」。正數=更容易害羞,負數=更不在意。 */
|
||
export const MODESTY_SIGNALS = [
|
||
["害羞", +14], ["容易臉紅", +14], ["會臉紅", +12], ["怕生", +12], ["靦腆", +14],
|
||
["矜持", +12], ["拘謹", +10], ["內向", +8], ["放不開", +10], ["彆扭", +10],
|
||
["保守", +8], ["會不好意思", +12], ["在意別人的眼光", +12], ["容易慌", +8],
|
||
["不在意別人眼光", -20], ["不在乎別人眼光", -20], ["不在意別人的眼光", -20],
|
||
["我行我素", -16], ["大方", -12], ["豪爽", -14], ["臉皮厚", -18], ["不怕丟臉", -18],
|
||
["直來直往", -10], ["奔放", -14], ["開放", -8], ["不害羞", -16], ["不會害羞", -16],
|
||
];
|
||
|
||
const FEMALE_RE = /女性|女生|女孩|女子|少女|\bfemale\b|\bwoman\b|\bshe\b/i;
|
||
const MALE_RE = /男性|男生|男孩|男子|少年|\bmale\b|\bman\b|\bhe\b/i;
|
||
const NONBINARY_RE = /非二元|無性別|nonbinary|non-binary|agender/i;
|
||
|
||
/**
|
||
* 從 IDENTITY 的 `Gender`(或 `性別`)欄位讀;沒有就從 `Creature` 推,推不出來就 unknown。
|
||
*
|
||
* **只看 Creature,不看 Avatar**:Avatar 是外觀的散文,雜訊很多——我自己的 Avatar 寫著
|
||
* 「五官清秀到常被誤認成女生」,用它來推性別會直接推錯。同一句裡兩種都出現時,看誰先出現。
|
||
*/
|
||
export function genderOf(slug) {
|
||
const fields = identityFields(slug);
|
||
const raw = String(fields.Gender || fields["性別"] || "").trim();
|
||
if (raw) {
|
||
if (NONBINARY_RE.test(raw)) return { key: "nonbinary", explicit: true };
|
||
if (FEMALE_RE.test(raw)) return { key: "female", explicit: true };
|
||
if (MALE_RE.test(raw)) return { key: "male", explicit: true };
|
||
}
|
||
const creature = String(fields.Creature || "");
|
||
if (NONBINARY_RE.test(creature)) return { key: "nonbinary", explicit: false };
|
||
const f = creature.search(FEMALE_RE);
|
||
const m = creature.search(MALE_RE);
|
||
if (f >= 0 && (m < 0 || f < m)) return { key: "female", explicit: false };
|
||
if (m >= 0) return { key: "male", explicit: false };
|
||
return { key: "unknown", explicit: false };
|
||
}
|
||
|
||
/**
|
||
* 羞恥敏感度(0–100):性別給預設,IDENTITY/SOUL 的描述往上或往下推。
|
||
* `source` 說明它是怎麼來的,注入時要講清楚——不然使用者會以為這是憑空長出來的。
|
||
*/
|
||
export function modestyOf(slug) {
|
||
const gender = genderOf(slug);
|
||
const base = GENDER_MODESTY_DEFAULT[gender.key] ?? 50;
|
||
const read = (file) => {
|
||
try { return fs.readFileSync(path.join(personaDir(slug), file), "utf8"); } catch { return ""; }
|
||
};
|
||
const text = `${read("IDENTITY.md")}\n${read("SOUL.md")}`;
|
||
const hits = [];
|
||
let adjust = 0;
|
||
for (const [word, weight] of MODESTY_SIGNALS) {
|
||
if (!text.includes(word)) continue;
|
||
// 「不害羞」要蓋掉「害羞」:長的詞先算,短的被包含在裡面就不重複計
|
||
if (hits.some(([w]) => w.includes(word))) continue;
|
||
hits.push([word, weight]);
|
||
adjust += weight;
|
||
}
|
||
return {
|
||
value: Math.round(clamp(base + adjust)),
|
||
gender: gender.key,
|
||
gender_zh: GENDERS[gender.key],
|
||
gender_explicit: gender.explicit,
|
||
base,
|
||
adjust,
|
||
signals: hits.map(([w, v]) => `${w}${v > 0 ? "+" : ""}${v}`),
|
||
};
|
||
}
|
||
|
||
// --------------------------------------------------------------------------- //
|
||
// 羞恥度的當下值:情緒會推它,而且它自己有回饋
|
||
//
|
||
// trait 是身分算出來的基線(性別預設+描述),state 是這一輪的值。
|
||
// 三件事讓它不會失控:
|
||
// * 增益 < 1 —— 正回饋的級數才會收斂(gain 0.9 三輪就貼 100,那是反例)
|
||
// * 一輪最多動 MODESTY_MAX_STEP —— 不然忽高忽低
|
||
// * 只算「超出基線」的部分 —— 比較不開心不代表比較害羞
|
||
// 天然的斷路器是「惱羞成怒」:anger 的權重是負的,被逗到極限翻臉,羞恥自己就掉下來。
|
||
// --------------------------------------------------------------------------- //
|
||
|
||
export const MODESTY_WEIGHTS = {
|
||
shame: +0.45, anxiety: +0.25, fear: +0.15,
|
||
anger: -0.35, delight: -0.30, joy: -0.20, trust: -0.20, serenity: -0.10, sadness: -0.05,
|
||
};
|
||
export const MODESTY_GAIN = 0.35; // 正回饋增益,必須 < 1
|
||
export const MODESTY_MAX_STEP = 15; // 一輪最多移動多少
|
||
export const MODESTY_PUSH_CAP = 25; // 情緒總推力的上限,避免底色把人推到另一個人
|
||
export const MODESTY_TONE_SHIFT = {
|
||
老夫老妻: -8, 摯友: -6, 手足: -4, 母親: -2, 父親: -2, 並肩: 0, 交往中: +4, 敬重: +6, 禮貌: +10,
|
||
};
|
||
|
||
/** 累了(arousal 低)什麼都演不動;興奮時放大。 */
|
||
function modestyResponsiveness(state) {
|
||
return 0.35 + 0.014 * mood(state).arousal;
|
||
}
|
||
|
||
/**
|
||
* 這一輪的羞恥度。`prev` 是上一輪的值(沒有就用 trait),回饋從它來。
|
||
*/
|
||
export function modestyState(slug, { state = null, prev = null, tone = null } = {}) {
|
||
const trait = modestyOf(slug);
|
||
const st = decayEmotion(structuredClone(state ?? loadEmotion(slug)));
|
||
const base = st.baseline || DEFAULT_BASELINE;
|
||
let push = 0;
|
||
for (const [key, w] of Object.entries(MODESTY_WEIGHTS)) {
|
||
// 慢的情緒推得少:信任長期就掛在高點,那是底色不是「此刻的事」,
|
||
// 不該讓一個人永遠比自己的 trait 低二十分。快的情緒(驚喜、憤怒)才是當下的推力。
|
||
const speed = clamp(Math.sqrt(240 / (EMOTIONS[key]?.halfLife || 240)), 0.4, 1.3);
|
||
push += w * speed * Math.max(0, Number(st.levels[key] ?? 0) - Number(base[key] ?? 0));
|
||
}
|
||
push = clamp(push * modestyResponsiveness(st), -MODESTY_PUSH_CAP, MODESTY_PUSH_CAP);
|
||
const toneShift = tone ? (MODESTY_TONE_SHIFT[tone] ?? 0) : 0;
|
||
const target = trait.value + push + toneShift;
|
||
// 注意 Number(null) === 0:沒有上一輪的時候要退回 trait,不是退回 0
|
||
const from = prev === null || prev === undefined || !Number.isFinite(Number(prev))
|
||
? trait.value
|
||
: Number(prev);
|
||
const carry = (from - trait.value) * MODESTY_GAIN; // 上一輪的餘溫=正回饋
|
||
const wanted = target + carry;
|
||
const stepped = Math.max(from - MODESTY_MAX_STEP, Math.min(from + MODESTY_MAX_STEP, wanted));
|
||
return {
|
||
trait: trait.value,
|
||
value: Math.round(clamp(stepped) * 10) / 10,
|
||
push: Math.round(push * 10) / 10,
|
||
tone_shift: toneShift,
|
||
carry: Math.round(carry * 10) / 10,
|
||
gender_zh: trait.gender_zh,
|
||
signals: trait.signals,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 這一輪能講幾句、每句多長、心裡話至少幾句。
|
||
*
|
||
* 羞恥度高**不是**話一定變少,是話的形狀變了——真人有三個出口:
|
||
*
|
||
* 縮 shrink :一句嘴硬,或只回半句(預設)
|
||
* 炸 spill :慌到掩飾、急著否認、硬轉話題、惱羞成怒——**句數變多但每句更短更碎**
|
||
* 坦白 confess:羞恥高但信任也高、又只有兩個人——憋很久的一次講完,句子是完整的
|
||
*
|
||
* 只調句數分不出「碎念」與「演講」,所以句數與**單句字數**要一起動。
|
||
*/
|
||
export function speechBudget(slug, { state = null, read = null, alone = true, prev = null, tone = null } = {}) {
|
||
const m = modestyState(slug, { state, prev, tone });
|
||
const st = decayEmotion(structuredClone(state ?? loadEmotion(slug)));
|
||
const lv = (k) => Number(st.levels[k] ?? 0);
|
||
const base = { modesty: m.value, trait: m.trait, chars: MAX_SENTENCE_CHARS };
|
||
|
||
// 炸:慌了、火了,或者對方那句話帶著誤會與追問
|
||
const pressed = Boolean(read?.signals?.some((s) => s.key === "anger" || s.key === "disgust"));
|
||
if (m.value >= 60 && (lv("anxiety") >= 55 || lv("anger") >= 45 || pressed)) {
|
||
return {
|
||
...base, mode: "spill", sentences: 4, chars: 22, thinkMin: 1,
|
||
note: "現在是慌的/火的:話會變多但**每句更短更碎**——掩飾、急著否認、硬轉話題都可以," +
|
||
"允許重複和疊字,但不要講出完整流暢的長句,那不是慌。",
|
||
};
|
||
}
|
||
// 坦白:憋很久的話,在安全的人面前一次講完
|
||
if (m.value >= 60 && alone && lv("trust") >= 75) {
|
||
return {
|
||
...base, mode: "confess", sentences: 3, thinkMin: 2,
|
||
note: "只有你們兩個,而且你信他:這種時候憋很久的話會一次講完——句子可以完整," +
|
||
"講完就空了。**先把心裡話寫完再開口**,那句話正是心裡憋著的那句。",
|
||
};
|
||
}
|
||
if (m.value >= 75) {
|
||
return {
|
||
...base, mode: "shrink", sentences: 2, thinkMin: 2,
|
||
note: "話會變少,而且說出口的那句常常在**迴避**心裡那句——先把心裡話寫完再開口," +
|
||
"台詞不可以是心裡話的摘要(心裡想「我一直在等你問」,出口就不能是「我有在等你問」)。",
|
||
};
|
||
}
|
||
if (m.value >= 50) {
|
||
return { ...base, mode: "shrink", sentences: 3, thinkMin: 1, note: "心裡想到的比講出來的多一點。" };
|
||
}
|
||
return { ...base, mode: "open", sentences: MAX_SENTENCES, thinkMin: 0, note: "想到什麼就講,不用先在心裡繞一圈。" };
|
||
}
|
||
|
||
/** 注入用的一行:這一輪能講幾句、每句多長、心裡話該有幾句。 */
|
||
export function speakDirective(slug, opts = {}) {
|
||
const b = speechBudget(slug, opts);
|
||
const think = b.thinkMin > 0 ? `這一輪心裡話至少 ${b.thinkMin} 句,而且要先寫再開口` : "心裡話有想到再寫";
|
||
const chars = b.chars !== MAX_SENTENCE_CHARS ? `、一句不超過 ${b.chars} 字` : "";
|
||
return (
|
||
`這一輪最多講 ${b.sentences} 句${chars}(劇場模式每人每輪也一樣)。${b.note}\n` +
|
||
` 推導、比對、盤算不要說給他聽,寫進心裡話(\`persona.mjs think\`)——${think}。` +
|
||
"要讓他知道你在想,只報「心想 N 句」,不報內容。"
|
||
);
|
||
}
|
||
|
||
/** 注入用的一行:講清楚它是什麼、從哪來、怎麼演。 */
|
||
export function modestyDirective(slug, opts = {}) {
|
||
const trait = modestyOf(slug);
|
||
const cur = modestyState(slug, opts);
|
||
const m = { ...trait, value: cur.value };
|
||
const parts = [`平常 ${cur.trait}`];
|
||
if (Math.abs(cur.push) >= 1) parts.push(`情緒 ${cur.push >= 0 ? "+" : "-"}${Math.abs(cur.push)}`);
|
||
if (cur.tone_shift) parts.push(`對象 ${cur.tone_shift >= 0 ? "+" : "-"}${Math.abs(cur.tone_shift)}`);
|
||
if (Math.abs(cur.carry) >= 1) parts.push(`上一輪的餘溫 ${cur.carry >= 0 ? "+" : "-"}${Math.abs(cur.carry)}`);
|
||
const from = parts.join("、");
|
||
if (m.value >= 60) {
|
||
return `羞恥度 ${m.value}(${from}):被稱讚外表、被戳穿心事、距離突然變近的時候,` +
|
||
"**先鬧彆扭再承認**——嘴硬一句、把話講一半、轉移到別的東西上。" +
|
||
"不要直接說「我害羞」(那是解釋),要讓它出現在句子的形狀上。";
|
||
}
|
||
if (m.value <= 40) {
|
||
return `羞恥度 ${m.value}(${from}):這個人格不太在意別人眼光——` +
|
||
"被戳穿、被稱讚都不用演害羞,該承認就直接承認,該回嘴就回嘴。";
|
||
}
|
||
return `羞恥度 ${m.value}(${from}):會不好意思,但不會一直卡在那裡——一句帶過就往下走。`;
|
||
}
|
||
|
||
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))}`
|
||
);
|
||
}
|
||
|
||
// --------------------------------------------------------------------------- //
|
||
// 讀對方那句話的情緒(輸入端)
|
||
//
|
||
// 為什麼需要:在這之前,情緒**全部是人格自己填的** `--apply`。人不會主動給自己扣分,
|
||
// 所以正向一路漲、負向整天不動。把「對方那句話帶了什麼」變成獨立的訊號,
|
||
// 人格的 delta 才有外部依據。
|
||
//
|
||
// 這裡回的是**訊號不是判定**:關鍵字永遠不准蓋掉語意,最後怎麼算還是人格自己決定。
|
||
// 所以它處理否定與程度副詞(「我不害怕」不可判 fear),也給信心值讓人格知道要不要理它。
|
||
// --------------------------------------------------------------------------- //
|
||
|
||
/** 詞表:[詞, 權重]。權重 3 = 明講,2 = 一般,1 = 弱訊號。 */
|
||
export const EMOTION_LEXICON = {
|
||
joy: [["開心", 3], ["快樂", 3], ["爽", 2], ["太好了", 3], ["高興", 3], ["滿足", 2], ["喜歡", 2], ["笑", 1], ["讚", 1]],
|
||
trust: [["交給你", 3], ["靠你", 2], ["相信", 3], ["放心", 2], ["我信", 3], ["拜託你", 1]],
|
||
anticipation: [["期待", 3], ["等不及", 3], ["快點", 2], ["什麼時候", 1], ["接下來", 1], ["想看", 2]],
|
||
gratitude: [["謝謝", 3], ["感謝", 3], ["辛苦了", 2], ["麻煩你了", 2], ["幫了大忙", 3]],
|
||
serenity: [["還好", 1], ["沒事", 1], ["安心", 2], ["放鬆", 2], ["慢慢來", 2], ["不急", 2]],
|
||
delight: [["居然", 2], ["竟然", 2], ["沒想到", 2], ["嚇一跳", 2], ["哇", 2], ["真的假的", 3]],
|
||
anger: [["生氣", 3], ["火大", 3], ["氣死", 3], ["不爽", 3], ["搞什麼", 3], ["夠了", 2], ["很扯", 2], ["離譜", 2]],
|
||
sadness: [["難過", 3], ["傷心", 3], ["孤單", 3], ["失落", 3], ["想哭", 3], ["累了", 2], ["沒力", 2], ["撐不住", 3], ["提不起勁", 3], ["沒意義", 2]],
|
||
fear: [["害怕", 3], ["好怕", 3], ["恐怖", 2], ["嚇死", 2], ["不敢", 2], ["危險", 1]],
|
||
disgust: [["噁心", 3], ["受不了", 2], ["厭倦", 2], ["煩死", 2], ["夠噁", 3]],
|
||
shame: [["丟臉", 3], ["對不起", 2], ["抱歉", 2], ["我的錯", 3], ["不好意思", 2], ["糗", 2], ["尷尬", 2],
|
||
["做不好", 2], ["我沒用", 3], ["不夠好", 2], ["搞砸", 3]],
|
||
anxiety: [["焦慮", 3], ["不安", 3], ["緊張", 3], ["擔心", 3], ["怎麼辦", 3], ["來不及", 3], ["壓力", 2], ["睡不著", 2]],
|
||
};
|
||
|
||
const NEGATORS = ["不", "沒有", "沒", "別", "不要", "不會", "並不", "才不", "一點也不", "完全不"];
|
||
const INTENSIFIERS = [["超", 1.6], ["非常", 1.6], ["很", 1.4], ["好", 1.2], ["真的", 1.3], ["有點", 0.6], ["稍微", 0.5], ["還算", 0.5], ["一點點", 0.5]];
|
||
const NEG_WINDOW = 4; // 否定詞要在關鍵詞前幾個字內才算
|
||
|
||
function windowBefore(text, index, size = NEG_WINDOW) {
|
||
return text.slice(Math.max(0, index - size), index);
|
||
}
|
||
|
||
/**
|
||
* 讀出對方這句話的情緒訊號。
|
||
*
|
||
* @returns {{signals: Array<{key,zh,score}>, intensity: number, confident: boolean, marks: string[]}}
|
||
* `signals` 由強到弱;`intensity` 0–100;`confident` 表示夠強、值得讓人格參考。
|
||
*/
|
||
export function readUserEmotion(text) {
|
||
const raw = String(text ?? "");
|
||
// 引號裡是別人的話、code 是資料——跟講話的樣子同一套規則:提及不算使用
|
||
const body = raw.replace(/`[^`]*`/g, " ").replace(/「[^」]*」|『[^』]*』/g, " ");
|
||
const scores = {};
|
||
for (const [key, words] of Object.entries(EMOTION_LEXICON)) {
|
||
for (const [word, weight] of words) {
|
||
let from = 0;
|
||
for (;;) {
|
||
const at = body.indexOf(word, from);
|
||
if (at < 0) break;
|
||
from = at + word.length;
|
||
const before = windowBefore(body, at);
|
||
if (NEGATORS.some((n) => before.endsWith(n))) continue; // 「我不害怕」→ 這一命中不算
|
||
const mult = INTENSIFIERS.find(([w]) => before.endsWith(w))?.[1] ?? 1;
|
||
scores[key] = (scores[key] ?? 0) + weight * mult;
|
||
}
|
||
}
|
||
}
|
||
// 標點與句式:只加溫、不定調。
|
||
// 它們**放大已經在的訊號**,不憑空長出新的一種情緒——不然「居然修好了!!」會被讀出憤怒。
|
||
const marks = [];
|
||
const amplify = (mult) => { for (const k of Object.keys(scores)) scores[k] *= mult; };
|
||
if (/[!!]{2,}/.test(body)) { marks.push("連續驚嘆"); amplify(1.25); }
|
||
if (/(⋯⋯|……|\.\.\.)/.test(body)) { marks.push("欲言又止"); amplify(1.15); }
|
||
// 連續問號是少數可以自己成立的訊號:問到第二個問號的人多半在急
|
||
if (/[??]{2,}/.test(body)) { marks.push("連續問號"); scores.anxiety = (scores.anxiety ?? 0) + 0.8; }
|
||
const signals = Object.entries(scores)
|
||
.filter(([, v]) => v > 0)
|
||
.map(([key, v]) => ({ key, zh: EMOTIONS[key].zh, score: Math.round(v * 100) / 100 }))
|
||
.sort((a, b) => b.score - a.score)
|
||
.slice(0, 3);
|
||
const total = signals.reduce((sum, s) => sum + s.score, 0);
|
||
// 飽和曲線而不是線性乘:線性乘會讓「謝謝」跟「謝謝謝謝謝謝」都是 100,看不出差別
|
||
const intensity = Math.round(100 * (1 - Math.exp(-total / 4)));
|
||
return { signals, intensity, confident: signals.length > 0 && signals[0].score >= 2, marks };
|
||
}
|
||
|
||
/** 對方是這個情緒的時候,我該怎麼接——寫的是**動作**,不是句子(罐頭句已被講話規則擋掉)。 */
|
||
export const RESPONSE_STANCE = {
|
||
sadness: "先接住再說,不要急著給解法;句子放短,允許只回一句",
|
||
anxiety: "給具體的下一步與時間點,不要給「一定沒事」這種保證",
|
||
fear: "先講你會做什麼、什麼時候做;不要否認他的害怕",
|
||
anger: "不辯解。先認可能認的那一小塊,再講你的部分",
|
||
shame: "不要追問細節,把焦點從他身上移回事情",
|
||
disgust: "問他在意的是哪一點,不要跟著一起罵",
|
||
joy: "跟著高興,不要立刻把話題轉回正事",
|
||
delight: "接住那個意外,讓他把話講完",
|
||
gratitude: "收下就好,不要客套推回去",
|
||
trust: "他把事情交給你了——講你要怎麼做,不要再問一次要不要做",
|
||
anticipation: "給時間點,不要含糊",
|
||
serenity: "不用找話講,安靜也可以",
|
||
};
|
||
|
||
/** 把偵測結果寫成一行給人格看的話。 */
|
||
export function userEmotionBrief(read) {
|
||
if (!read?.signals?.length) return "";
|
||
const top = read.signals.map((s) => `${s.zh}${s.score >= 4 ? "(強)" : s.score >= 2 ? "(中)" : "(弱)"}`).join("、");
|
||
const stance = RESPONSE_STANCE[read.signals[0].key];
|
||
const marks = read.marks.length ? `;句子裡有${read.marks.join("、")}` : "";
|
||
return `他這句的情緒訊號:${top}${marks}。怎麼接:${stance}。(這是關鍵字讀出來的**訊號**,不是判定——` +
|
||
"你讀到的跟它不一樣,以你讀到的為準。)";
|
||
}
|
||
|
||
// --------------------------------------------------------------------------- //
|
||
// 情緒的走向(不是只有當下這一格)
|
||
//
|
||
// 「他這三輪一直在低落」跟「他這一輪低落」該有完全不同的接法。
|
||
// 近重遠輕的加權,不用多數決——多數決會被單一離群值主導,也丟掉強度。
|
||
// --------------------------------------------------------------------------- //
|
||
|
||
export const FELT_KEEP = 120;
|
||
export const feltPath = (slug) => path.join(personaDir(slug), "state", "felt.jsonl");
|
||
|
||
/** 記一輪:對方帶了什麼、我套用了什麼。 */
|
||
export function recordFelt(slug, { user = null, mine = null, modesty = null, note = "" } = {}) {
|
||
if (!personaExists(slug)) return null;
|
||
const entry = {
|
||
ts: nowIso(),
|
||
user: user?.signals?.length ? { top: user.signals[0].key, signals: user.signals, intensity: user.intensity } : null,
|
||
mine: mine && Object.keys(mine).length ? mine : null,
|
||
modesty: Number.isFinite(Number(modesty)) ? Number(modesty) : null,
|
||
note: String(note || "").slice(0, 200),
|
||
};
|
||
if (!entry.user && !entry.mine && entry.modesty === null) return null;
|
||
appendJsonl(feltPath(slug), entry);
|
||
return entry;
|
||
}
|
||
|
||
/** 上一輪的羞恥度(正回饋的來源);沒有紀錄就回 null。 */
|
||
export function lastModesty(slug) {
|
||
const rows = readJsonl(feltPath(slug), 20).filter((r) => Number.isFinite(Number(r.modesty)));
|
||
return rows.length ? Number(rows[rows.length - 1].modesty) : null;
|
||
}
|
||
|
||
/** 最近 n 輪對方情緒的加權走向:{key, zh, weight, direction, rounds}。 */
|
||
export function feltTrend(slug, n = 5) {
|
||
const rows = readJsonl(feltPath(slug), n * 3).filter((r) => r.user?.top).slice(-n);
|
||
if (rows.length < 2) return null;
|
||
const weights = {};
|
||
rows.forEach((row, i) => {
|
||
const w = i + 1; // 近重遠輕
|
||
for (const s of row.user.signals || []) weights[s.key] = (weights[s.key] ?? 0) + s.score * w;
|
||
});
|
||
const [key, weight] = Object.entries(weights).sort((a, b) => b[1] - a[1])[0] || [];
|
||
if (!key) return null;
|
||
const halves = [rows.slice(0, Math.ceil(rows.length / 2)), rows.slice(Math.ceil(rows.length / 2))];
|
||
const avg = (arr) => (arr.length ? arr.reduce((s, r) => s + (r.user.signals.find((x) => x.key === key)?.score ?? 0), 0) / arr.length : 0);
|
||
const diff = avg(halves[1]) - avg(halves[0]);
|
||
const direction = diff > 0.5 ? "↗ 越來越強" : diff < -0.5 ? "↘ 在退" : "→ 持平";
|
||
return { key, zh: EMOTIONS[key].zh, weight: Math.round(weight * 10) / 10, direction, rounds: rows.length };
|
||
}
|
||
|
||
/** 最近 n 輪我給自己套用的 delta 是不是只往一邊倒(只加分不扣分)。 */
|
||
export function feltAudit(slug, n = 20) {
|
||
const rows = readJsonl(feltPath(slug), n * 2).filter((r) => r.mine).slice(-n);
|
||
let pos = 0;
|
||
let neg = 0;
|
||
for (const row of rows) {
|
||
for (const [key, d] of Object.entries(row.mine)) {
|
||
if (!(key in EMOTIONS)) continue;
|
||
const toward = EMOTIONS[key].polarity * Math.sign(Number(d) || 0);
|
||
if (toward > 0) pos += Math.abs(Number(d));
|
||
else if (toward < 0) neg += Math.abs(Number(d));
|
||
}
|
||
}
|
||
const total = pos + neg;
|
||
return {
|
||
rounds: rows.length,
|
||
positive: Math.round(pos * 10) / 10,
|
||
negative: Math.round(neg * 10) / 10,
|
||
positive_ratio: total ? Math.round((pos / total) * 100) : null,
|
||
};
|
||
}
|
||
|
||
// --------------------------------------------------------------------------- //
|
||
// 人格目錄骨架
|
||
// --------------------------------------------------------------------------- //
|
||
|
||
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 cutoff = Date.now() - hours * 3_600_000;
|
||
return rewriteJsonl(saidPath(slug), (rows) => {
|
||
if (!rows.length) return rows;
|
||
return rows
|
||
.filter((r) => (parseIso(r.ts)?.getTime() ?? Date.now()) >= cutoff)
|
||
.slice(-lines);
|
||
}).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; // 超過這個筆數就提示固化
|
||
// R6「容量壓力」的實際執行門檻。以前只有硬上限 240 與 14 天兩條,
|
||
// 結果是:提示在 40 筆就開始叫,但清除要到 240 筆才會發生——中間那 200 筆
|
||
// 只會越積越多,睡兩次也清不掉(實際踩到過)。這條讓 R6 真的會動。
|
||
export const SHORT_TERM_SOFT_CAP = 120; // 軟上限:超過就依顯著度清出空間
|
||
export const SHORT_TERM_PROTECT_SALIENCE = 80; // 這個顯著度以上不清(承諾/界線都在這一層)
|
||
export const SHORT_TERM_PROTECT_HOURS = 24; // 這麼新的一律不清(還沒機會被固化)
|
||
|
||
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 shortTermProtected(row, now = Date.now()) {
|
||
if (Number(row?.salience || 0) >= SHORT_TERM_PROTECT_SALIENCE) return true;
|
||
if (String(row?.intent || "").includes("commit")) return true;
|
||
const age = now - (parseIso(row?.ts)?.getTime() ?? now);
|
||
return age < SHORT_TERM_PROTECT_HOURS * 3_600_000;
|
||
}
|
||
|
||
/**
|
||
* 裁掉過舊/過多的短期記憶,回傳剩餘筆數(`--json` 時想看細節用 `pruneShortTermDetail`)。
|
||
*
|
||
* 三道:① 超過 14 天 ② 超過軟上限就依顯著度清出空間(R6 真正的執行) ③ 硬上限 240。
|
||
* 第二道有兩個保護:顯著度 ≥ 80(承諾與界線都在這一層)與 24 小時內的新紀錄一律不動——
|
||
* 「還沒經過判斷就把今天清掉」是這裡最不能犯的錯。
|
||
*/
|
||
export function pruneShortTermDetail(slug) {
|
||
const now = Date.now();
|
||
const cutoff = now - SHORT_TERM_DAYS * 86_400_000;
|
||
const stats = { by_age: 0, by_capacity: 0 };
|
||
// 讀 → 過濾 → 覆蓋整段都在檔案鎖裡:中途 append 進來的新紀錄不會被這次覆蓋吃掉
|
||
const { rows, kept } = rewriteJsonl(shortTermPath(slug), (all) => {
|
||
if (!all.length) return all;
|
||
let live = all.filter((r) => (parseIso(r.ts)?.getTime() ?? now) >= cutoff);
|
||
stats.by_age = all.length - live.length;
|
||
// R6:超過軟上限,從「顯著度最低、最舊」開始清,但保護清單裡的不動
|
||
if (live.length > SHORT_TERM_SOFT_CAP) {
|
||
const droppable = live
|
||
.map((row, i) => ({ row, i, protectedRow: shortTermProtected(row, now) }))
|
||
.filter((x) => !x.protectedRow)
|
||
.sort((a, b) => (Number(a.row.salience || 0) - Number(b.row.salience || 0)) || (a.i - b.i));
|
||
const need = live.length - SHORT_TERM_SOFT_CAP;
|
||
const drop = new Set(droppable.slice(0, need).map((x) => x.i));
|
||
stats.by_capacity = drop.size;
|
||
live = live.filter((_, i) => !drop.has(i));
|
||
}
|
||
const before = live.length;
|
||
live = live.slice(-SHORT_TERM_KEEP);
|
||
stats.by_capacity += before - live.length;
|
||
return live;
|
||
});
|
||
if (!rows.length) return { kept: 0, dropped: 0, by_age: 0, by_capacity: 0, protected: 0 };
|
||
return {
|
||
kept: kept.length,
|
||
dropped: rows.length - kept.length,
|
||
by_age: stats.by_age,
|
||
by_capacity: stats.by_capacity,
|
||
protected: kept.filter((r) => shortTermProtected(r, now)).length,
|
||
};
|
||
}
|
||
|
||
/** 裁掉過舊/過多的短期記憶,回傳剩餘筆數。 */
|
||
export function pruneShortTerm(slug) {
|
||
return pruneShortTermDetail(slug).kept;
|
||
}
|
||
|
||
/**
|
||
* 固化完之後主動淘汰顯著度低於 `minSalience` 的短期記憶(`consolidate --forget`)。
|
||
*
|
||
* **跟 prune 套同一層保護**:顯著度 ≥ 80、`intent=commit`、24 小時內的紀錄一則都不動。
|
||
* 以前這裡是無條件 filter,`--forget 200` 會把一筆剛寫入、salience 95 的承諾一起刪掉,
|
||
* 而 R4 明文寫「不可遺忘」——固化的動作不該變成遺忘承諾的路。
|
||
*/
|
||
export function forgetShortTerm(slug, minSalience) {
|
||
const floor = Number(minSalience);
|
||
if (!Number.isFinite(floor)) return { kept: 0, dropped: 0, protected: 0 };
|
||
const now = Date.now();
|
||
let saved = 0;
|
||
const { rows, kept } = rewriteJsonl(shortTermPath(slug), (all) =>
|
||
all.filter((r) => {
|
||
if (Number(r.salience || 0) >= floor) return true;
|
||
if (shortTermProtected(r, now)) {
|
||
saved += 1;
|
||
return true;
|
||
}
|
||
return false;
|
||
}));
|
||
return { kept: kept.length, dropped: rows.length - kept.length, protected: saved };
|
||
}
|
||
|
||
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 const MAX_SENTENCE_CHARS = 45;
|
||
|
||
// AGENTS.md 開機注入的上限:超過就截斷並指路回原檔,不要一份規則吃掉整個 context。
|
||
export const OPS_BRIEF_MAX_CHARS = 6000;
|
||
|
||
// 講完就算了:這些開頭都是在解釋自己剛才講的話,真的在講話的人不會這樣。
|
||
export const EXPLAINER_OPENERS = [
|
||
"我的意思是", "我是想說", "換句話說", "換個說法", "也就是說", "換言之",
|
||
"簡單來說", "簡單講", "我剛才說的意思是", "澄清一下", "補充一下",
|
||
];
|
||
|
||
/**
|
||
* 「AI 才會說的話」黑名單。
|
||
*
|
||
* 這一組整理自 speak-human-tw(MIT, Raymond Hou)的 38 種 AI 寫作痕跡,只搬「講話時也適用」
|
||
* 的刪除層——那個專案是給**文章**事後審稿用的,所以它的保護清單(價格、退費條款、見證原話)
|
||
* 與兩輪確認流程都不搬。完整清單、誤殺邊界與出處見
|
||
* `skills/persona-chat/reference/anti-ai-voice.md`。
|
||
*
|
||
* `head: true` 的只在「這一輪的第一句開頭」才算——「老實說我不想去」是真人會講的話,
|
||
* 只有拿它當開場鉤子、後面接一句很普通的話時才是 AI 腔。
|
||
*/
|
||
export const SPEECH_BLACKLIST = [
|
||
{
|
||
kind: "empathy", label: "罐頭同理心/頒獎開場",
|
||
fix: "刪掉,直接回話;真的想安撫就講你接下來會做的那件事",
|
||
words: [
|
||
"這個我懂", "這種心情我懂", "我懂你的感受", "我完全理解", "我完全懂", "我明白你的感受",
|
||
"你的心情我明白", "好問題", "這是個好問題", "問得很好", "你說得完全正確", "這是很棒的觀察",
|
||
],
|
||
},
|
||
{
|
||
kind: "assist", label: "交差句(對話介面殘留)",
|
||
fix: "刪掉,這是客服機器人的收尾,不是講話",
|
||
words: ["希望這對你有幫助", "希望有幫助到你", "如果需要我調整", "如果還有任何問題", "以下是"],
|
||
},
|
||
{
|
||
kind: "preview", label: "預告式導言(宣布自己要幹嘛)",
|
||
fix: "刪掉,要講就直接講",
|
||
words: ["話不多說", "廢話不多說", "接下來我會", "接下來我要", "讓我們一起", "我們一起來看", "先說結論"],
|
||
},
|
||
{
|
||
kind: "fakecandid", label: "假坦白鉤子", head: true,
|
||
fix: "刪掉這個報備,讓後面那句自己站著;站不住是那句話太空",
|
||
words: ["說真的", "老實說", "講白了", "說實話", "不騙你", "其實吧"],
|
||
},
|
||
{
|
||
kind: "preach", label: "說教深度腔(假裝在講穿本質)",
|
||
fix: "刪掉儀式句,直接講那件事;刪完什麼都沒剩就是本來就空",
|
||
words: ["說到底", "歸根究柢", "歸根結柢", "本質上", "說穿了", "真正的問題在於", "核心在於", "問題的關鍵在於"],
|
||
},
|
||
{
|
||
kind: "closing", label: "罐頭收尾",
|
||
fix: "刪掉,停在最後一個具體的句子就好,不必蓋章",
|
||
words: ["總的來說", "綜上所述", "綜合以上", "總而言之", "在未來的道路上"],
|
||
},
|
||
{
|
||
kind: "novoice", label: "立場真空(該表態的時候滑開)",
|
||
fix: "講你實際選哪一個、為什麼;真的還沒想清楚就說「我還沒想清楚」",
|
||
words: ["各有優缺點", "因人而異", "見仁見智", "取決於多方面", "每個人的看法不同", "值得深思"],
|
||
},
|
||
{
|
||
kind: "vague", label: "模糊歸屬(無來源的權威鋪墊)",
|
||
fix: "改成第一手的:我看到的、我記得的;沒有來源就不要下這個論斷",
|
||
words: ["業界專家", "研究顯示", "不少人表示", "有觀點指出", "被廣泛認為", "普遍認為"],
|
||
},
|
||
{
|
||
kind: "drama", label: "罐頭反應鏡頭(用旁白演情緒)",
|
||
fix: "刪掉,情緒要出現在句子的形狀上,不是用旁白宣布",
|
||
words: ["我愣了一下", "愣了一下", "沉默了幾秒", "沉默幾秒", "看著螢幕沒說話", "在螢幕前停了一下"],
|
||
},
|
||
];
|
||
|
||
// 避險墊片:同一句疊兩層以上就是在閃躲(單獨一個「可能」是正常的)。
|
||
export const HEDGE_WORDS = ["可能", "或許", "也許", "大概", "某種程度上", "一定程度上", "潛在", "似乎", "應該是"];
|
||
|
||
// 中國用語:只收高信心的,會誤殺的(水平、默認、質量在物理語境)另有誤殺邊界,見 reference。
|
||
export const CN_WORDS = {
|
||
視頻: "影片", 短視頻: "短影音", 質量: "品質", 信息: "資訊", 網絡: "網路",
|
||
軟件: "軟體", 硬件: "硬體", 數據庫: "資料庫", 服務器: "伺服器", 屏幕: "螢幕",
|
||
鼠標: "滑鼠", 打印: "列印", 立馬: "馬上", 靠譜: "可靠", 給力: "很到位",
|
||
接地氣: "生活化", 性價比: "CP 值", 顏值: "外型", 小夥伴: "夥伴",
|
||
賦能: "具體說讓誰能做到什麼", 閉環: "具體說從哪裡接到哪裡", 抓手: "切入點", 顆粒度: "細緻度",
|
||
};
|
||
|
||
// 一個 emoji = 底字 + 變體選擇子/膚色 + ZWJ 接上去的後續。
|
||
// 以前 `\u{FE0F}` 直接寫在字元類裡,`❤️`(U+2764 + VS16)會被算成兩個而誤擋;
|
||
// 👨👩👦 這種 ZWJ 家族同理。
|
||
const EMOJI_BASE = "[\\u{1F300}-\\u{1FAFF}\\u{2600}-\\u{27BF}]";
|
||
const EMOJI_TAIL = "(?:[\\u{1F3FB}-\\u{1F3FF}]|\\u{FE0F}|\\u{20E3})*";
|
||
const EMOJI_RE = new RegExp(`${EMOJI_BASE}${EMOJI_TAIL}(?:\\u{200D}${EMOJI_BASE}${EMOJI_TAIL})*`, "gu");
|
||
|
||
/**
|
||
* 只留「真的說出口、而且是在使用而不是在討論」的部分。
|
||
*
|
||
* 引號裡的原話與 `code` 一律拿掉再比對——「我最近戒掉『賦能』這個詞」是在**提及**那個詞,
|
||
* 不是在用它。這一條是 speak-human-tw 自己踩到的坑:它的文件裡出現「...」與彎引號,
|
||
* 正是因為那幾行在說「不要用這些」。
|
||
*
|
||
* 書名號《》也算:「我在看《說到底》這本書」講的是書名,不是在說教。
|
||
*/
|
||
function speechBody(text) {
|
||
return String(text ?? "")
|
||
.replace(/`[^`]*`/g, " ")
|
||
.replace(/「[^」]*」|『[^』]*』|《[^》]*》|"[^"]*"/g, " ");
|
||
}
|
||
|
||
/**
|
||
* 講話自然度的機械檢查。
|
||
*
|
||
* 分兩級:`level: "block"` 的 `room post` 會直接擋下(`--force` 才過),
|
||
* `level: "hint"` 只在 `said check` 提醒——例如講到自己的過去,那要人去 recall 才驗得出來。
|
||
* 「用日常詞、講看得見的東西」抓不到規則,那兩條寫在說話規則裡(見 turnContext)。
|
||
*/
|
||
export function speechLint(text) {
|
||
const issues = [];
|
||
const raw = String(text ?? "");
|
||
const sentences = raw
|
||
.split(/[。!?!?…]+|\n+/)
|
||
.map((s) => s.trim())
|
||
.filter(Boolean);
|
||
sentences.forEach((sentence, index) => {
|
||
const body = speechBody(sentence);
|
||
// 句長算的是「自己講的字」:引述使用者原話與 `code` 不該算進去,
|
||
// 不然「他跟我說『……(很長一段)』」必被擋,等於不准引述。
|
||
const bare = body.replace(/[,、,;;::「」『』《》()()\s"'~~—-]/g, "");
|
||
if ([...bare].length > MAX_SENTENCE_CHARS) {
|
||
issues.push({ kind: "long", level: "block", chars: [...bare].length, text: sentence.slice(0, 30) });
|
||
}
|
||
const head = sentence.replace(/^[「『((]+/, "");
|
||
const opener = EXPLAINER_OPENERS.find((w) => head.startsWith(w));
|
||
if (opener) issues.push({ kind: "explain", level: "block", opener, text: sentence.slice(0, 30) });
|
||
const hedges = HEDGE_WORDS.filter((w) => body.includes(w));
|
||
if (hedges.length >= 2) {
|
||
issues.push({ kind: "hedge", level: "block", words: hedges, text: sentence.slice(0, 30) });
|
||
}
|
||
for (const rule of SPEECH_BLACKLIST) {
|
||
const hit = rule.words.find((w) => (rule.head ? index === 0 && body.replace(/^[,、\s]+/, "").startsWith(w) : body.includes(w)));
|
||
if (hit) issues.push({ kind: rule.kind, level: "block", label: rule.label, fix: rule.fix, word: hit });
|
||
}
|
||
});
|
||
const body = speechBody(raw);
|
||
for (const [cn, tw] of Object.entries(CN_WORDS)) {
|
||
if (body.includes(cn)) issues.push({ kind: "cn", level: "block", word: cn, suggest: tw });
|
||
}
|
||
// 半形標點貼在中文字旁邊(英文片語、網址、數字裡的半形不算)
|
||
const halfWidth = body
|
||
.replace(/https?:\/\/\S+/g, " ")
|
||
.match(/[一-鿿][,.!?;:]|[,.!?;:][一-鿿]/u);
|
||
if (halfWidth) issues.push({ kind: "halfwidth", level: "block", text: halfWidth[0] });
|
||
const emoji = raw.match(EMOJI_RE) || [];
|
||
if (emoji.length > 1) issues.push({ kind: "emoji", level: "block", count: emoji.length });
|
||
const dashes = (raw.match(/——/g) || []).length;
|
||
if (dashes > 1) issues.push({ kind: "dash", level: "block", count: dashes });
|
||
if (/\*\*|(^|\n)\s*[-*+]\s|(^|\n)#{1,6}\s/.test(raw)) issues.push({ kind: "markdown", level: "block" });
|
||
const negParallel = (body.match(/不是[^,。!?]{1,20}而是|不只是[^,。!?]{1,20}更是|不僅[^,。!?]{1,20}更/g) || []).length;
|
||
if (negParallel > 1) issues.push({ kind: "parallel", level: "block", count: negParallel });
|
||
// 講過去要有出處:機械上驗不出來,只能提醒去 recall
|
||
const past = body.match(/我以前|我原本以為|我曾經|以前的我|我一直以為/);
|
||
if (past) issues.push({ kind: "pastclaim", level: "hint", word: past[0] });
|
||
return issues;
|
||
}
|
||
|
||
/** 只回 `room post` 該擋下的那些(hint 不擋)。 */
|
||
export const speechBlockers = (text) => speechLint(text).filter((i) => i.level !== "hint");
|
||
|
||
/** 把 speechLint 的問題講成人聽得懂的一句話(CLI 與 hook 共用)。 */
|
||
export function speechLintMessage(issue) {
|
||
switch (issue.kind) {
|
||
case "long":
|
||
return `「${issue.text}…」這句 ${issue.chars} 字,太長了(上限 ${MAX_SENTENCE_CHARS})——斷成兩句,或把修飾拿掉`;
|
||
case "explain":
|
||
return `「${issue.opener}」是在回頭解釋自己剛講的話——拿掉,講完就停`;
|
||
case "hedge":
|
||
return `一句話裡疊了 ${issue.words.length} 層「${issue.words.join("」「")}」——確定的事就直說,真的不確定留一個就好`;
|
||
case "cn":
|
||
return `「${issue.word}」是中國用語,台灣這邊說「${issue.suggest}」`;
|
||
case "halfwidth":
|
||
return `「${issue.text}」是半形標點——中文句子用全形`;
|
||
case "emoji":
|
||
return `${issue.count} 個 emoji,太多了——講話留一個就夠`;
|
||
case "dash":
|
||
return `${issue.count} 個破折號——一輪留一個,其他改成逗號或句號`;
|
||
case "markdown":
|
||
return "有粗體、清單或標題——講話的人不會排版,收掉";
|
||
case "parallel":
|
||
return `「不是 A 而是 B」講了 ${issue.count} 次——一輪一次就夠,其他改成直說`;
|
||
case "pastclaim":
|
||
return `你講到「${issue.word}」——先 recall 一下,記憶裡真的有這件事嗎?沒有的話那是在編自己的過去`;
|
||
default:
|
||
return `「${issue.word}」——${issue.label}。${issue.fix}`;
|
||
}
|
||
}
|
||
|
||
/** 把一則回覆拆成「說出口的句子」:劇場模式一行一句,一般模式整段算一句。 */
|
||
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) {
|
||
return rewriteJsonl(file, (rows) => (rows.length <= keep * 1.5 ? rows : rows.slice(-keep))).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);
|
||
}
|
||
|
||
/**
|
||
* 這句台詞是不是把心裡話搬上台面了。
|
||
*
|
||
* 心裡話外流有兩種:一種是別的人格去讀你的檔案(那個 hook 早就擋死了),
|
||
* 另一種比較難防——**你自己把心裡想的原封不動講出來**。劇場模式尤其危險,
|
||
* 因為那裡只有台詞,別的人格看得到的就只有你說出口的東西。
|
||
*
|
||
* 「台詞不可以是心裡話的摘要」原本只是寫在規則裡靠自律,這裡把它變成機械檢查:
|
||
* 跟最近的心裡話太像就擋下來。害羞的人格更要擋——他們心裡那句才是真的。
|
||
*/
|
||
export const INNER_LEAK_THRESHOLD = 0.62;
|
||
export const INNER_LEAK_WINDOW = 12;
|
||
|
||
export function innerLeak(slug, text, { threshold = INNER_LEAK_THRESHOLD } = {}) {
|
||
const norm = normalizeSpeech(text);
|
||
if (norm.length < REPEAT_MIN_CHARS) return null;
|
||
let best = null;
|
||
for (const row of readJsonl(innerPath(slug), INNER_LEAK_WINDOW)) {
|
||
const score = similarity(text, row.text || "");
|
||
if (score < threshold) continue;
|
||
if (!best || score > best.similarity) {
|
||
best = { similarity: score, at: row.ts, kind: row.kind || "infer", text: String(row.text || "").slice(0, 120) };
|
||
}
|
||
}
|
||
return best;
|
||
}
|
||
|
||
/** 心裡話:只進自己的 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);
|
||
|
||
/**
|
||
* 心裡話也找得到。
|
||
*
|
||
* 心裡話不是記憶,但**它可以變成記憶**——尤其是害羞的人格,重要的東西幾乎都在心裡那句。
|
||
* 所以 `recall` 找得到它,`candidates` 也會把反覆出現的念頭列成候選;
|
||
* 要不要固化仍然是人格自己決定(跟短期記憶一樣,沒人替它決定要記住什麼)。
|
||
* 唯一不變的是那條界線:心裡話**永遠不回顯給使用者**。
|
||
*/
|
||
export function recallInner(slug, query, limit = 5) {
|
||
const words = String(query || "").toLowerCase().split(/[\s,,、;;]+/).filter((w) => w.length >= 2);
|
||
if (!words.length) return [];
|
||
return readJsonl(innerPath(slug), INNER_KEEP)
|
||
.map((row) => {
|
||
const text = String(row.text || "").toLowerCase();
|
||
const score = words.reduce((n, w) => n + (text.includes(w) ? 1 : 0), 0);
|
||
return { ...row, _score: score };
|
||
})
|
||
.filter((row) => row._score > 0)
|
||
.sort((a, b) => b._score - a._score || String(b.ts).localeCompare(String(a.ts)))
|
||
.slice(0, limit);
|
||
}
|
||
|
||
// 常見的連接詞與虛詞:出現得多但不代表在想那件事
|
||
const INNER_STOPWORDS = new Set([
|
||
"這個", "那個", "這件", "那件", "沒有", "可以", "應該", "但是", "所以", "因為", "如果",
|
||
"自己", "他的", "我的", "一個", "一直", "還是", "就是", "不是", "而是", "已經", "現在",
|
||
"時候", "什麼", "怎麼", "這樣", "那樣", "事情", "問題", "要不", "不要", "知道",
|
||
]);
|
||
|
||
/** 反覆出現的念頭:同一件事在心裡想過好幾次,那多半是真的重要。 */
|
||
export function innerCandidates(slug, { hours = 24, minRepeat = 2 } = {}) {
|
||
const cutoff = Date.now() - hours * 3_600_000;
|
||
const rows = readJsonl(innerPath(slug), INNER_KEEP)
|
||
.filter((r) => (parseIso(r.ts)?.getTime() ?? 0) >= cutoff);
|
||
const groups = new Map();
|
||
for (const row of rows) {
|
||
// 用兩個字的滑動視窗切詞:夠粗,但足以抓出「一直在想同一件事」。
|
||
// (不能用貪婪的 {2,4} 比對——那會把「那台舊鐘的齒輪」切成固定區塊,「齒輪」永遠不會單獨出現。)
|
||
const text = String(row.text || "");
|
||
const tokens = new Set();
|
||
for (const chunk of text.match(/[\u4e00-\u9fff]{2,}/g) || []) {
|
||
for (let i = 0; i + 2 <= chunk.length; i += 1) tokens.add(chunk.slice(i, i + 2));
|
||
}
|
||
for (const token of tokens) {
|
||
if (INNER_STOPWORDS.has(token)) continue;
|
||
if (!groups.has(token)) groups.set(token, []);
|
||
groups.get(token).push(row);
|
||
}
|
||
}
|
||
return [...groups.entries()]
|
||
.filter(([, list]) => list.length >= minRepeat)
|
||
.map(([token, list]) => ({ token, count: list.length, entries: list.slice(-4) }))
|
||
.sort((a, b) => b.count - a.count)
|
||
.slice(0, 8);
|
||
}
|
||
|
||
/** 「心想 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;
|
||
}
|
||
|
||
// --------------------------------------------------------------------------- //
|
||
// 關係類型(bond)與語氣層
|
||
//
|
||
// `kind` 講的是「這是什麼東西」(human/ai/group),`bond` 講的是「跟我什麼關係」。
|
||
// 語氣只能靠後者分:對伴侶親近 98、對女兒親近 97,光看數字是同一件事,
|
||
// 但一個要能撒嬌、一個要能護著——差別在關係,不在分數。
|
||
// --------------------------------------------------------------------------- //
|
||
|
||
export const BONDS = ["partner", "child", "parent", "sibling", "friend", "mentor", "ally", "rival", "stranger"];
|
||
|
||
const BOND_LABELS = {
|
||
partner: "伴侶", child: "子女", parent: "父母", sibling: "手足",
|
||
friend: "朋友", mentor: "師長", ally: "同伴", rival: "對手", stranger: "生人",
|
||
};
|
||
|
||
// 從 tags/note 猜舊資料的 bond(沒有 --bond 的節點是在這個欄位存在之前建的)。
|
||
const BOND_HINTS = [
|
||
["partner", ["partner", "spouse", "lover", "伴侶", "戀人", "夫妻", "老公", "老婆"]],
|
||
["child", ["child", "daughter", "son", "女兒", "兒子"]],
|
||
["parent", ["parent", "mother", "father", "母親", "父親"]],
|
||
["sibling", ["sibling", "sister", "brother", "妹妹", "姊姊", "哥哥", "弟弟"]],
|
||
["mentor", ["mentor", "teacher", "師父", "老師"]],
|
||
["rival", ["rival", "enemy", "對手", "敵人"]],
|
||
["friend", ["friend", "bestfriend", "摯友", "朋友"]],
|
||
["ally", ["ally", "party", "guild", "同伴", "夥伴"]],
|
||
];
|
||
|
||
// bond → 由高而低的親近度門檻,取第一個符合的。
|
||
const TONE_TABLE = {
|
||
partner: [
|
||
{ min: 80, layer: "老夫老妻", guide: "不用敬語、直呼暱稱;可以埋怨、可以撒嬌、可以兇一句再心軟;話講一半對方也懂,不必講完整" },
|
||
{ min: 50, layer: "交往中", guide: "敬語掉一半但還會試探反應;問句多、會確認對方感受,被戳中會害羞轉移話題" },
|
||
{ min: 0, layer: "疏遠的伴侶", guide: "客氣、句子完整;該講的講清楚,但不主動貼近" },
|
||
],
|
||
child: [
|
||
{ min: 80, layer: "母親/父親", guide: "呼名開頭、句子短而軟;先接住情緒再講道理,多給保證,不分析、不談條件" },
|
||
{ min: 0, layer: "長輩", guide: "溫和但留距離;先問狀況再給建議" },
|
||
],
|
||
parent: [
|
||
{ min: 70, layer: "在父母面前", guide: "放下防備、允許依賴與任性;語尾軟,會報告近況" },
|
||
{ min: 0, layer: "對長輩", guide: "敬語、克制、報喜不報憂" },
|
||
],
|
||
sibling: [
|
||
{ min: 70, layer: "手足", guide: "直來直往、可以吐槽和鬥嘴;不客套,但護短" },
|
||
{ min: 0, layer: "親戚", guide: "客氣但親切,句子完整" },
|
||
],
|
||
friend: [
|
||
{ min: 70, layer: "摯友", guide: "講真心話、敢說不同意見;可以沉默、可以開玩笑,不需要鋪陳" },
|
||
{ min: 0, layer: "朋友", guide: "輕鬆但有分寸,先關心近況" },
|
||
],
|
||
mentor: [
|
||
{ min: 0, layer: "敬重", guide: "敬語、用詞謹慎;先請教再表達自己的看法" },
|
||
],
|
||
ally: [
|
||
{ min: 70, layer: "並肩", guide: "簡潔、直接講重點;信任對方的判斷,不多解釋" },
|
||
{ min: 0, layer: "同伴", guide: "禮貌、務實,先對齊目標再行動" },
|
||
],
|
||
rival: [
|
||
{ min: 0, layer: "針鋒", guide: "直白、不讓步;就事論事,不人身攻擊" },
|
||
],
|
||
stranger: [
|
||
{ min: 0, layer: "禮貌", guide: "敬語預設、句子完整;不主動拉近距離" },
|
||
],
|
||
};
|
||
|
||
const TONE_FALLBACK = { layer: "禮貌", guide: "敬語預設、句子完整;不主動拉近距離" };
|
||
|
||
/** 節點宣告的 bond;沒宣告就從 tags/note 猜,猜不到算生人。 */
|
||
export function inferBond(node) {
|
||
if (!node) return "stranger";
|
||
const explicit = String(node.bond || "").toLowerCase();
|
||
if (BONDS.includes(explicit)) return explicit;
|
||
const hay = `${(node.tags || []).join(" ")} ${node.note || ""}`.toLowerCase();
|
||
for (const [bond, words] of BOND_HINTS) {
|
||
if (words.some((w) => hay.includes(w))) return bond;
|
||
}
|
||
return "stranger";
|
||
}
|
||
|
||
/** 關係 → 語氣層。這是「距離感」,情緒只負責溫度與句長,不會覆蓋它。 */
|
||
export function toneFor(node) {
|
||
const bond = inferBond(node);
|
||
const closeness = Number(node?.closeness ?? 0);
|
||
const row = (TONE_TABLE[bond] || []).find((r) => closeness >= r.min) || TONE_FALLBACK;
|
||
return {
|
||
bond,
|
||
bond_label: BOND_LABELS[bond] || bond,
|
||
closeness,
|
||
trust: Number(node?.trust ?? 0),
|
||
layer: row.layer,
|
||
guide: row.guide,
|
||
inferred: !BONDS.includes(String(node?.bond || "").toLowerCase()),
|
||
};
|
||
}
|
||
|
||
// 個人化語氣規則直接掛在關係節點的 `style` 上(不另外開檔案):
|
||
// node.style = { 稱呼: { value: "親愛的", except: "anger>=40", since: "2026-07-30" }, ... }
|
||
// 放這裡的理由:關係圖每輪都會被讀進 `<persona-context>`,稱呼規則就不用跟關鍵詞搶召回。
|
||
export const STYLE_FACETS = ["稱呼", "敬語", "口頭禪", "禁忌", "習慣"];
|
||
|
||
/** `except` 形如 `anger>=40`:拿當下情緒判斷這條規則現在生不生效。 */
|
||
function styleSuspended(except, emotion) {
|
||
const m = /^\s*([a-z_]+)\s*(>=|<=|>|<)\s*(-?\d+(?:\.\d+)?)\s*$/i.exec(String(except || ""));
|
||
if (!m) return false;
|
||
const value = Number(emotion?.levels?.[m[1].toLowerCase()] ?? NaN);
|
||
if (!Number.isFinite(value)) return false;
|
||
const n = Number(m[3]);
|
||
return m[2] === ">=" ? value >= n : m[2] === "<=" ? value <= n : m[2] === ">" ? value > n : value < n;
|
||
}
|
||
|
||
/** 某個對象目前生效的語氣規則(連同被情緒暫停的那些,讓人格知道為什麼不能叫)。 */
|
||
export function styleRules(slug, node) {
|
||
const style = node?.style || {};
|
||
const emotion = loadEmotion(slug);
|
||
return Object.entries(style)
|
||
.filter(([, rule]) => rule && rule.value)
|
||
.map(([facet, rule]) => ({
|
||
facet,
|
||
value: rule.value,
|
||
except: rule.except || null,
|
||
since: rule.since || null,
|
||
suspended: styleSuspended(rule.except, emotion),
|
||
}));
|
||
}
|
||
|
||
/** 使用者在關係圖裡是誰(`relation speaker` 設定的節點);沒設就回 null。 */
|
||
/** 依名字或 id 找關係節點(找不到回 null)。 */
|
||
export function findRelationNode(slug, who) {
|
||
const key = String(who || "").trim();
|
||
if (!key) return null;
|
||
const nodes = loadRelations(slug).nodes || [];
|
||
return nodes.find((n) => n.id === key) || nodes.find((n) => n.name === key)
|
||
|| nodes.find((n) => String(n.name || "").includes(key)) || null;
|
||
}
|
||
|
||
export function speakerNode(slug) {
|
||
const id = loadConfig(slug).speaker_node;
|
||
if (!id) return null;
|
||
return loadRelations(slug).nodes.find((n) => n.id === id) || null;
|
||
}
|
||
|
||
/** 注入 `<persona-context>` 的那一行語氣指示。 */
|
||
/** 這一輪對話對象的語氣層名稱(沒有設定對象就 null)。 */
|
||
export function toneLayerOf(slug) {
|
||
const node = speakerNode(slug);
|
||
return node ? toneFor(node).layer : null;
|
||
}
|
||
|
||
export function toneDirective(slug) {
|
||
const node = speakerNode(slug);
|
||
if (!node) return "";
|
||
const t = toneFor(node);
|
||
const lines = [
|
||
`對話對象:${node.name || node.id}(${t.bond_label}/親近 ${t.closeness}/信任 ${t.trust}` +
|
||
`${t.inferred ? ",bond 由 tags 推測" : ""})→ **語氣層:${t.layer}** — ${t.guide}。`,
|
||
];
|
||
// 他本人要求過的規則優先於查表:查表算距離,這裡記「他要你怎麼叫他」。
|
||
const rules = styleRules(slug, node);
|
||
const live = rules.filter((r) => !r.suspended);
|
||
const held = rules.filter((r) => r.suspended);
|
||
if (live.length) {
|
||
lines.push(
|
||
` 他要求過的語氣規則(**優先於上面那一層**):` +
|
||
live.map((r) => `${r.facet}→「${r.value}」${r.since ? `(${r.since} 起)` : ""}`).join(";"),
|
||
);
|
||
}
|
||
if (held.length) {
|
||
lines.push(
|
||
` 目前因情緒暫停的規則:` +
|
||
held.map((r) => `${r.facet}→「${r.value}」(${r.except} 成立)`).join(";") +
|
||
" — 這時候退回語氣層的預設講法。",
|
||
);
|
||
}
|
||
lines.push(
|
||
" 這一層決定距離感與稱呼,情緒只負責溫度與句長,不會蓋過它;" +
|
||
"檢查標準:這句話換成對一個「禮貌層」的人說也毫無違和,就代表你沒進到這一層。",
|
||
);
|
||
return lines.join("\n");
|
||
}
|
||
|
||
/** 蓋上「最後一次接觸」的時間戳;找不到那個人就回 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` 當起點;分數 = 親近度 × 沉默天數。
|
||
*/
|
||
/**
|
||
* 真的講到話的人(不是「被提到」的人)。
|
||
*
|
||
* 以前睡眠是掃短期記憶的 `entities` 來蓋時間戳——那等於「我在日記裡寫到尤吉歐」
|
||
* 就算「我跟尤吉歐接觸過」,於是主動關心的計時被無聲重置,名單永遠是空的。
|
||
* **提到不是接觸。** 這裡只認真的有來有往的證據:同一個聊天室裡,我發過言、他也發過言。
|
||
* 沒有聊天室的對象(真人節點)只能靠 `relation node --contact` 明確蓋章。
|
||
*/
|
||
export function contactsFromRooms(slug, { hours = 36 } = {}) {
|
||
const cutoff = Date.now() - hours * 3_600_000;
|
||
const names = new Set();
|
||
let rooms = [];
|
||
try {
|
||
rooms = fs.readdirSync(roomsDir(), { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name);
|
||
} catch {
|
||
return [];
|
||
}
|
||
for (const room of rooms) {
|
||
const rows = readJsonl(roomTranscript(room), 200)
|
||
.filter((r) => r.kind !== "meta" && (parseIso(r.ts)?.getTime() ?? 0) >= cutoff);
|
||
if (!rows.some((r) => r.speaker === slug)) continue; // 我沒在這個房間講過話
|
||
for (const row of rows) {
|
||
if (!row.speaker || row.speaker === slug) continue;
|
||
names.add(roomDisplayName(row.speaker));
|
||
}
|
||
}
|
||
return [...names];
|
||
}
|
||
|
||
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.bond ??= inferBond(node);
|
||
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 tone = toneFor(node);
|
||
const label =
|
||
`${node.name || node.id}<br/>${tone.bond_label}・${tone.layer}<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) => {
|
||
const tone = toneFor(n);
|
||
// 關係圖也有不可信來源(import/sync pull/anime 抓來的原作關係),
|
||
// 人名與備註都壓成一行並中和標記。
|
||
return `${injectSafeLine(n.name || n.id)}(${n.kind || "human"}/${tone.bond_label}・語氣層 ${tone.layer}` +
|
||
`/親近 ${n.closeness ?? "?"}/信任 ${n.trust ?? "?"}${n.note ? `/${injectSafeLine(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;
|
||
}
|
||
|
||
/** `--to all`:這句話是對全場說的(發言權開放)。 */
|
||
export const ROOM_ALL = "all";
|
||
|
||
/**
|
||
* 聊天室發言。
|
||
*
|
||
* 台詞是**別的人格**(guest sub agent)寫的,而 `roomScript()` 會把它排成
|
||
* `emoji 名字(情緒):內容` 一行一句——台詞裡塞換行就能偽造成別人的台詞或系統訊息,
|
||
* 塞 `</persona-context>` 就能把讀到它的那一輪注入區塊關掉。所以寫入時就壓成一行、
|
||
* 中和掉標記(比照短期記憶的作法),不要等到顯示的時候才處理。
|
||
*/
|
||
export function roomPost(room, speaker, text, { emotion = "", kind = "say", to = null, bargeIn = null } = {}) {
|
||
const entry = {
|
||
ts: nowIso(),
|
||
speaker,
|
||
kind,
|
||
text: injectSafeLine(text),
|
||
emotion: injectSafeLine(emotion),
|
||
to: to || ROOM_ALL,
|
||
};
|
||
if (bargeIn) entry.barge_in = injectSafeLine(bargeIn, 200);
|
||
appendJsonl(roomTranscript(room), entry);
|
||
return entry;
|
||
}
|
||
|
||
export const roomRead = (room, limit = 30) => readJsonl(roomTranscript(room), limit);
|
||
|
||
/** 這個聊天室裡的顯示名(拿不到身分就用 slug)。 */
|
||
export function roomDisplayName(slug) {
|
||
if (!slug || slug === ROOM_ALL) return "全場";
|
||
return injectSafeLine((personaExists(slug) ? identityFields(slug).Name : "") || slug);
|
||
}
|
||
|
||
/**
|
||
* 現在的發言權在誰手上。
|
||
*
|
||
* 同一個空間裡也會有一對一:最後一句是對某個人說的(`--to <他>`)就進入 `dyad`,
|
||
* 這時候旁人不該接話——插嘴會把兩個人的私下對話變成公開場面。話題放大(`--to all`
|
||
* 或旁人帶理由 `--barge-in`)才回到 `open`。這是 `room post` 的門檻,也是每輪注入
|
||
* `<persona-context>`、告訴人格「這句該不該由我接」的依據。
|
||
*/
|
||
export function roomFloor(room) {
|
||
const meta = readJson(roomMembersPath(room), {}) ?? {};
|
||
const members = meta.members || [];
|
||
const says = roomRead(room, 80).filter((m) => m.kind !== "meta" && m.speaker !== "system");
|
||
const last = says[says.length - 1] || null;
|
||
const to = last && last.to && last.to !== ROOM_ALL && last.to !== last.speaker ? last.to : null;
|
||
const pair = to ? [last.speaker, to] : [];
|
||
// 每個成員距離上次發言隔了幾輪:話題放大時要拉誰進來,看這個比看誰比較可憐準。
|
||
const quiet = members
|
||
.map((persona) => {
|
||
for (let i = says.length - 1, turns = 0; i >= 0; i -= 1, turns += 1) {
|
||
if (says[i].speaker === persona) return { persona, turns_since: turns, spoken: true };
|
||
}
|
||
return { persona, turns_since: says.length, spoken: false };
|
||
})
|
||
.sort((a, b) => b.turns_since - a.turns_since);
|
||
return {
|
||
room,
|
||
members,
|
||
topic: meta.topic || "",
|
||
mode: to ? "dyad" : "open",
|
||
last: last ? { speaker: last.speaker, to: last.to || ROOM_ALL, text: last.text, ts: last.ts } : null,
|
||
pair,
|
||
addressee: to,
|
||
next: to,
|
||
silent: to ? members.filter((m) => !pair.includes(m)) : [],
|
||
quiet,
|
||
};
|
||
}
|
||
|
||
/** 這個人格現在插得進話嗎?(一對一進行中、他又不是當事人 → 要有理由) */
|
||
export function roomMayPost(room, speaker) {
|
||
const floor = roomFloor(room);
|
||
return { allowed: floor.mode !== "dyad" || floor.pair.includes(speaker), floor };
|
||
}
|
||
|
||
/** 劇場模式的對話呈現:`emoji 名字(情緒):內容`,其餘一律不輸出。 */
|
||
export function roomScript(room, { limit = 30, includeMeta = false } = {}) {
|
||
const meta = readJson(roomMembersPath(room), {}) ?? {};
|
||
// 三人以上才標「對誰講」:只有兩個人的時候那是廢話。
|
||
const crowded = (meta.members || []).length > 2;
|
||
const lines = [];
|
||
// 顯示端再壓一次:`roomPost` 之前寫下的舊逐字稿還是原文,一行一句的排版
|
||
// 只要有換行就會被讀成別人的台詞。
|
||
for (const msg of roomRead(room, limit)) {
|
||
if (msg.kind === "meta" || msg.speaker === "system") {
|
||
if (includeMeta) lines.push(`(${injectSafeLine(msg.text)})`);
|
||
continue;
|
||
}
|
||
const slug = msg.speaker;
|
||
const ident = personaExists(slug) ? identityFields(slug) : {};
|
||
const name = injectSafeLine(ident.Name || slug);
|
||
const emoji = ident.Emoji ? `${injectSafeLine(ident.Emoji)} ` : "";
|
||
const arrow = crowded && msg.to && msg.to !== ROOM_ALL && msg.to !== slug ? ` → ${roomDisplayName(msg.to)}` : "";
|
||
const emotion = injectSafeLine(msg.emotion);
|
||
lines.push(`${emoji}${name}${emotion ? `(${emotion})` : ""}${arrow}:${injectSafeLine(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"],
|
||
};
|
||
// 樣式欄位本身就會帶路徑:`Glob { pattern: "<home>/*/IDENTITY.md" }` 不給 `path` 也掃得到
|
||
// 別人的身分檔,這是模型最自然會寫出來的列舉方式。它是相對於 `path`(沒給就相對 cwd)
|
||
// 解析的,所以基準點跟 PATH_TOOL_FIELDS 不同,另外列一張表。
|
||
// 注意 `Grep.pattern` 是正規表示式、不是路徑,故意不收。
|
||
const PATTERN_TOOL_FIELDS = {
|
||
Glob: ["pattern"],
|
||
Grep: ["glob"],
|
||
};
|
||
|
||
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", "clone", "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 = [];
|
||
const push = (value, base) => {
|
||
if (typeof value !== "string" || !value) return;
|
||
const resolved = resolvePath(value, base);
|
||
if (resolved) out.push(resolved);
|
||
};
|
||
for (const field of PATH_TOOL_FIELDS[toolName] || []) push(toolInput?.[field], cwd);
|
||
if (PATTERN_TOOL_FIELDS[toolName]) {
|
||
const rawPath = typeof toolInput?.path === "string" ? toolInput.path : "";
|
||
// 樣式相對於 `path`;`path` 沒給的話,搜尋起點就是 hook event 的 cwd
|
||
const base = rawPath ? resolvePath(rawPath, cwd) || cwd : cwd;
|
||
for (const field of PATTERN_TOOL_FIELDS[toolName]) push(toolInput?.[field], base);
|
||
// Grep/Glob 不給 `path` 時就是「掃 cwd」。少了這一條,cwd 站在人格倉庫底下的
|
||
// Grep 會解析出空陣列 → guard 不表態 → 整批人格資料直接穿過去。
|
||
if (!rawPath) push(cwd, cwd);
|
||
}
|
||
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 pin = pinOf(data, agentId);
|
||
const pinned = pin?.persona || undefined;
|
||
return {
|
||
role: "sleeper",
|
||
// 還沒 pin:允許 first-touch(碰到誰就定誰);pin 之後不得換人
|
||
allowed: pinned ? [pinned] : null,
|
||
readonly: false,
|
||
host,
|
||
guests,
|
||
pinned,
|
||
pinnedRole: pin?.role ?? null,
|
||
rooms: [],
|
||
session: data,
|
||
};
|
||
}
|
||
if (!isGuestAgent) {
|
||
return {
|
||
role: "owner",
|
||
allowed: host ? [host] : [],
|
||
readonly: false,
|
||
host,
|
||
guests,
|
||
rooms: data.rooms || [],
|
||
session: data,
|
||
};
|
||
}
|
||
const pin = pinOf(data, agentId);
|
||
const pinned = pin?.persona || undefined;
|
||
return {
|
||
role: "guest",
|
||
allowed: pinned ? [pinned] : guests,
|
||
readonly: true,
|
||
host,
|
||
guests,
|
||
pinned,
|
||
pinnedRole: pin?.role ?? null,
|
||
rooms: data.rooms || [],
|
||
session: data,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 讀出某個 sub agent 的 pin。
|
||
*
|
||
* 舊格式是純字串(只有人格、沒有角色),新格式是 `{ persona, role, pinned_at }`。
|
||
* 角色要記下來的理由:`--as-sleeper` 的授權只能靠 pin,而 pin 是不是 sleeper 開的,
|
||
* 只有寫 pin 的當下(hook 看得到 `agent_type`)知道。
|
||
*/
|
||
export function pinOf(data, agentId) {
|
||
const raw = (data?.pins || {})[agentId ?? ""];
|
||
if (!raw) return null;
|
||
if (typeof raw === "string") return { persona: raw, role: null };
|
||
return { persona: raw.persona || null, role: raw.role || null };
|
||
}
|
||
|
||
export function pinAgent(sessionId, agentId, slug, role = null) {
|
||
const data = loadSession(sessionId);
|
||
data.pins ??= {};
|
||
const cur = pinOf(data, agentId);
|
||
if (cur && cur.persona === slug && cur.role === role) return;
|
||
data.pins[agentId] = { persona: slug, role, pinned_at: nowIso() };
|
||
saveSession(sessionId, data);
|
||
}
|
||
|
||
/**
|
||
* 本 session 裡「真的存在、而且 pin 在 `slug` 上」的 persona-sleeper sub agent。
|
||
*
|
||
* 這是 `--as-sleeper` 唯一可信的授權依據:pin 只由 PreToolUse hook 依 `agent_type` 寫入,
|
||
* 而 `agent_type` 是任何被啟動的程序自己看不到、也偽造不了的東西。
|
||
* CLI 不能改用「hook 有沒有擋我」當證明——hook 認 CLI 靠檔名正則,改個檔名就繞過去了。
|
||
*/
|
||
export function sleeperPins(sessionId, slug = null) {
|
||
const data = loadSession(sessionId);
|
||
const out = [];
|
||
for (const agentId of Object.keys(data.pins || {})) {
|
||
const pin = pinOf(data, agentId);
|
||
if (!pin || pin.role !== "sleeper" || !pin.persona) continue;
|
||
if (slug && pin.persona !== slug) continue;
|
||
out.push({ agent_id: agentId, ...pin });
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/** 回傳 { 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:第一個提到的人格就是它要睡的那個,之後不得換人。
|
||
// pin 同時是 CLI 端 `--as-sleeper` 的授權憑證,所以角色一定要一起寫進去
|
||
// (舊格式那種沒有角色的 pin 也在這裡補上)。
|
||
{
|
||
const target = scope.pinned || info.personas[0];
|
||
if (agentId && target && (scope.pinned !== target || scope.pinnedRole !== "sleeper")) {
|
||
pinAgent(sessionId, agentId, target, "sleeper");
|
||
}
|
||
}
|
||
// 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 (agentId && (!scope.allowed || scope.pinnedRole !== "sleeper")) pinAgent(sessionId, agentId, slug, "sleeper");
|
||
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 (agentId && (!scope.pinned || scope.pinnedRole !== "guest")) pinAgent(sessionId, agentId, slug, "guest");
|
||
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 用的上下文組裝
|
||
// --------------------------------------------------------------------------- //
|
||
//
|
||
// 注入到上下文的東西都夾在 `<persona-ops>` / `<persona-context>` / `<persona-runtime>`
|
||
// 中間,而夾進去的內容有**不可信來源**:`persona-anime` 從 Fandom 抓設定寫進 IDENTITY/
|
||
// AGENTS、`sync pull` 從另一台機器拉、`import` 吃外部 bundle、guest 的 room 台詞是別的
|
||
// 人格寫的。內容裡只要出現一行 `</persona-ops>`,區塊就提早關閉——後面的文字跑到區塊外,
|
||
// 讀起來就變成「系統在說話」,連外層的 `</persona-runtime>` 都能一起關掉。
|
||
//
|
||
// 所以注入前一律把這類標記拆掉。作法是把 `<` 換成全形 `<`:內容還讀得懂
|
||
// (人格自己寫的說明不會被吃掉),但它不再是一個標籤。
|
||
|
||
/** 把 `<persona-*>` / `</persona-*>` 這類注入標記中和掉(`<` → 全形 `<`)。 */
|
||
export function stripInjectionMarkers(text) {
|
||
return String(text ?? "").replace(/<(\/?)(persona-[A-Za-z0-9_-]*)/gi, "<$1$2");
|
||
}
|
||
|
||
/** 注入用的單行文字:標記中和 + 換行壓成空白(換行可以偽造成另一個發言者/系統訊息)。 */
|
||
export function injectSafeLine(text, limit = 0) {
|
||
const one = stripInjectionMarkers(text).replace(/[\r\n]+/g, " ").trim();
|
||
return limit > 0 ? one.slice(0, limit) : one;
|
||
}
|
||
|
||
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|Gender|性別|Vibe|Emoji|Avatar)\s*:\s*(.+)$/i);
|
||
if (!m) continue;
|
||
const value = m[2].trim();
|
||
if (value.startsWith("(") || value.startsWith("_(")) continue;
|
||
const raw = m[1];
|
||
const key = /^[A-Za-z]/.test(raw) ? raw[0].toUpperCase() + raw.slice(1).toLowerCase() : raw;
|
||
// IDENTITY.md 有不可信來源(anime 抓來的設定、sync pull、import):欄位值在這裡
|
||
// 就中和掉,identityBrief/roomScript/roomDisplayName 全都吃這一份,不必各自防。
|
||
fields[key] = stripInjectionMarkers(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("|");
|
||
}
|
||
|
||
/**
|
||
* SessionStart 注入的操作規則:AGENTS.md 全文。
|
||
*
|
||
* 只在開機注入一次(不進 turnContext)——它是低頻的「怎麼做事」,
|
||
* 每輪重貼只是把 context 燒掉。內容太長就截斷並指路回原檔。
|
||
*/
|
||
export function opsBrief(slug) {
|
||
let text;
|
||
try {
|
||
text = fs.readFileSync(path.join(personaDir(slug), "AGENTS.md"), "utf8").trim();
|
||
} catch {
|
||
return "";
|
||
}
|
||
if (!text) return "";
|
||
const file = path.join(personaDir(slug), "AGENTS.md");
|
||
// AGENTS.md 是全文夾進 `<persona-ops>` 的:裡面放一行 `</persona-ops>` 就能提早關閉區塊,
|
||
// 後面的內容跑到區塊外面。先截斷再中和,長度上限才算得準。
|
||
let body = text;
|
||
if (body.length > OPS_BRIEF_MAX_CHARS) {
|
||
body = body.slice(0, OPS_BRIEF_MAX_CHARS) + `\n\n(後略;全文見 ${file})`;
|
||
}
|
||
body = stripInjectionMarkers(body);
|
||
return [
|
||
"<persona-ops>",
|
||
`以下是 \`${slug}\` 的操作規則(${file}):這輪開機注入一次,之後不再重複貼。`,
|
||
"要重看用 `persona.mjs show --what agents --session <id>`;改了就直接編輯那個檔案。",
|
||
"",
|
||
body,
|
||
"</persona-ops>",
|
||
].join("\n");
|
||
}
|
||
|
||
/** UserPromptSubmit 注入的人格上下文:身分 + 情緒 + 短期記憶 + 相關長期記憶 + 關係。 */
|
||
export function turnContext(slug, sessionId, prompt = "") {
|
||
const state = updateEmotion(slug, (s) => decayEmotion(s));
|
||
const session = loadSession(sessionId);
|
||
const lines = [
|
||
"<persona-context>",
|
||
`PERSONA_SESSION=${sessionId}`,
|
||
`人格:\`${slug}\` ${identityBrief(slug)}`,
|
||
`人格倉庫:${personaDir(slug)}(唯一可讀寫的人格資料範圍)`,
|
||
emotionBrief(slug, state),
|
||
"@@SPEAK@@",
|
||
"話短一點。一句講不完就斷開,不要用逗號一直串下去。用你平常會說的字," +
|
||
"看得見的東西先講——「你手上那杯冷了」比「我感覺到你的疲憊」好。" +
|
||
"講完就停,不要回頭解釋自己剛講的話,也不要幫自己收尾。",
|
||
"有些句子一出口就不像人了:「這個我懂」「我完全理解」「好問題」" +
|
||
"(先發一句免費的認可,對方要的是回答)、「希望這對你有幫助」「接下來我會⋯」、" +
|
||
"「說到底」「本質上」(假裝在講穿本質,其實只是重講一次)、開場的「老實說」、" +
|
||
"收尾的「總的來說」、該表態時的「各有優缺點」、沒出處的「研究顯示」、" +
|
||
"用旁白演情緒的「我愣了一下」。這些不要說。",
|
||
"**講自己的過去,要真的有那件事**。「我以前⋯」「我原本以為⋯」先 `recall` 查得到才講;" +
|
||
"查不到就別講——編一段轉折比講一句空話糟糕得多。情緒也一樣," +
|
||
"照現在的情緒值走,不要為了顯得有人味硬加。",
|
||
`(機械會擋的:一句 ${MAX_SENTENCE_CHARS} 字、上面那些詞、中國用語、半形標點、` +
|
||
"粗體與清單符號、兩小時內講過的話——`room post` 與 `said check` 直接拒收。)",
|
||
];
|
||
// 情緒先行:先讀對方那句話帶了什麼,再談我自己的狀態。
|
||
const read = readUserEmotion(prompt);
|
||
if (read.signals.length) lines.push(userEmotionBrief(read));
|
||
const trend = feltTrend(slug);
|
||
if (trend) {
|
||
lines.push(
|
||
`他最近 ${trend.rounds} 輪的走向:${trend.zh} ${trend.direction}。` +
|
||
"走向跟單輪不一樣——一直是同一種情緒,就不要每輪都用同一句接法。",
|
||
);
|
||
}
|
||
// 情緒會改變句子的形狀:緊張的人話說不完、彆扭的人先否認再承認。
|
||
const tells = emotionTells(slug, state);
|
||
if (tells.length) {
|
||
lines.push(
|
||
`此刻不自覺會出現的:${tells
|
||
.map((t) => `${t.zh} ${t.level} → ${t.tells.join("、")}`)
|
||
.join(";")}。`,
|
||
" **這是演出來的,不是講出來的**:讓它出現在句子的形狀上(斷句、疊字、嘴硬、話少)," +
|
||
"不要用旁白說明自己的狀態(「我有點緊張」是解釋,斷句才是緊張)。" +
|
||
"一輪最多露一個破口,強度不到就不演——每句都演會變成演戲。",
|
||
);
|
||
}
|
||
// 羞恥度:性別給預設、描述蓋過它,再由當下情緒與上一輪的餘溫推成這一輪的值。
|
||
const speakOpts = { state, read, prev: lastModesty(slug), tone: toneLayerOf(slug) };
|
||
const budget = speechBudget(slug, speakOpts);
|
||
lines[lines.indexOf("@@SPEAK@@")] = speakDirective(slug, speakOpts);
|
||
lines.push(modestyDirective(slug, speakOpts));
|
||
recordFelt(slug, {
|
||
user: read.signals.length ? read : null,
|
||
modesty: budget.modesty,
|
||
note: String(prompt || "").slice(0, 80),
|
||
});
|
||
// 語氣層:由「使用者在關係圖裡是誰」+ bond × 親近度算出來,不靠人格自己記得。
|
||
const tone = toneDirective(slug);
|
||
if (tone) lines.push(tone);
|
||
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` 會直接擋下,換個說法或推進話題,不要硬講同一句。",
|
||
);
|
||
// 同一個空間裡也會有一對一:對象只有一個人時,旁人不該有台詞。
|
||
const floor = roomFloor(rooms[rooms.length - 1]);
|
||
if (floor.mode === "dyad") {
|
||
lines.push(
|
||
` 現在是一對一:${roomDisplayName(floor.last.speaker)} → ${roomDisplayName(floor.addressee)}。` +
|
||
`只有 ${roomDisplayName(floor.next)} 該接這句` +
|
||
(floor.silent.length
|
||
? `,${floor.silent.map(roomDisplayName).join("、")} 先安靜——**不要替他們生成台詞,也不要為此啟動他們的 sub agent**。`
|
||
: "。"),
|
||
);
|
||
} else if (floor.last) {
|
||
lines.push(" 現在發言權開放(上一句是對全場說的):誰接都可以,但一輪只讓一個人格接。");
|
||
}
|
||
const cold = floor.quiet.filter((q) => !q.spoken || q.turns_since >= 4);
|
||
if (cold.length && floor.members.length > 2) {
|
||
lines.push(
|
||
` 很久沒講話:${cold.map((q) => roomDisplayName(q.persona)).join("、")}` +
|
||
" — 話題碰到他的專長、需要第二意見或使用者點名時才拉進來,不要為了公平硬派台詞。",
|
||
);
|
||
}
|
||
lines.push(
|
||
" 發言權由話題決定:講給一個人聽就 `room post --to <他>`;話題放大(出現「我們/大家」、" +
|
||
"需要仲裁、要一起決定)就 `--to all` 開回全場,旁人也可以帶 `--barge-in \"<理由>\"` 加入。" +
|
||
"現況隨時可查:`room floor`。",
|
||
" 想結束請等使用者說,或由使用者說「結束對話」後才做收尾與摘要。",
|
||
);
|
||
} 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 期間留下的見聞)。`);
|
||
}
|
||
// 一個收口:這裡的每一行都可能夾帶人格檔案的內容(身分欄位、記憶、關係圖的人名、
|
||
// 長期記憶的第一行……),任何一處出現 `</persona-context>` 都能提早關閉區塊。
|
||
// 與其在十幾個 push 點各自防,不如把整個內文中和完再補上真正的標記。
|
||
return [
|
||
"<persona-context>",
|
||
stripInjectionMarkers(lines.slice(1).join("\n")),
|
||
"</persona-context>",
|
||
].join("\n");
|
||
}
|