Files
persona/scripts/persona-lib.mjs
T
jiantw83andClaude Opus 5 10299e18ef feat(記憶): 新增 habit 型別,慣例每輪注入而不是等人問
其餘九個型別都是宣告性記憶(發生過什麼、他偏好什麼),慣例是程序性的——
不是「記得的事」,是「每次都這樣做的事」。

所以慣例不只接上 recall(型別不影響檢索,本來就通),還每輪注入:
只在關鍵詞對上時才回想得到的慣例等於沒有,沒有人為了照慣例做事先去回想它。

上限 HABIT_INJECT_MAX = 2。糊掉的(fuzzy)不注入——想不起來的慣例就不是慣例,
那是「以前好像有這種習慣」。faded 的標「只記得大概」,不假裝清楚。

注入文字講明慣例是背景不是話題:照著做就好,講出來(「我們不是都這樣嗎」)
就變成在討論它。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 08:49:04 +00:00

6205 lines
281 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// persona-lib.mjs — jsc-persona 的共用核心(Node.js,只用內建模組)
//
// 負責:
// * 人格倉庫路徑與 slug 規則
// * 單一程序載入鎖(exclusive lock)與 guest lease
// * session 綁定(host / guests / rooms / agent pins / 劇場模式)
// * 十二情緒模型(六正向 + 六負向)與衰減
// * 短期記憶 / 長期記憶 / 心智圖 / 思維導圖 / 人際關係圖 的讀寫
// * 跨人格隔離的判斷核心(guard)
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import crypto from "node:crypto";
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; // guestsub 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)。
*
* **那個省 IO 的判斷只對「過濾」成立。** 就地改欄位的呼叫端(`resolveProbe` 把一筆
* pending 改成 denied)回傳的陣列一樣長,於是一個位元組都沒寫出去——指令回報成功、
* 硬碟上什麼都沒變。踩過一次,所以留下 `force`:要改欄位就明講。
*/
export function rewriteJsonl(file, transform, { force = false } = {}) {
return withFileLock(file, () => {
const rows = readJsonl(file);
const kept = transform(rows) ?? rows;
if (force || 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);
}
// --------------------------------------------------------------------------- //
// 交互抑制:剛生完氣沒那麼容易被逗笑
//
// 只寫三組**明確互斥**的(憤怒↔喜悅、悲傷↔驚喜、厭惡↔信任)。不做全 12×12:
// 那張表沒有人驗得動,而且大部分格子的心理學依據是掰的。
//
// 規則:X 超出基線夠多(≥ INHIBIT_FLOOR)時,往 Y 的**同向推力**打折。
// 只壓「更 Y」的方向;要把 Y 拉回基線永遠不打折——否則情緒會卡在原地下不來。
// --------------------------------------------------------------------------- //
export const INHIBIT_FLOOR = 18; // 超出基線這麼多才開始抑制
export const INHIBIT_PAIRS = [["anger", "joy"], ["sadness", "delight"], ["disgust", "trust"]];
export const INHIBIT_STRENGTH = 0.55; // 抑制滿檔時只剩這麼多推力
/** 各情緒此刻受到的抑制係數(1 = 不受抑制)。 */
export function inhibitionFactors(state) {
const levels = state.levels || {};
const base = state.baseline || DEFAULT_BASELINE;
const over = (k) => Math.max(0, Number(levels[k] ?? 0) - Number(base[k] ?? 0));
const out = {};
for (const [a, b] of INHIBIT_PAIRS) {
for (const [from, to] of [[a, b], [b, a]]) {
const excess = over(from) - INHIBIT_FLOOR;
if (excess <= 0) continue;
// 超出門檻 0 → 不打折;超出 40 以上 → 打到 INHIBIT_STRENGTH
const depth = clamp(excess / 40, 0, 1);
out[to] = Math.min(out[to] ?? 1, 1 - (1 - INHIBIT_STRENGTH) * depth);
}
}
return out;
}
// 慣性:連續往同一個方向走的時候,同方向的下一筆推得更動。
// 一路被逗笑的人越來越好笑,一路被踩的人越踩越炸——這是真的,而且輸入已經在手上
// `state.streak`)。上限刻意壓在 +25%:這是慣性,不是雪球。
export const MOMENTUM_STEP = 0.08;
export const MOMENTUM_CAP = 0.25;
/** 這一批 delta 整體是往正向還是負向走(回 +1 / -1 / 0)。 */
export function deltaDirection(deltas) {
let net = 0;
for (const [key, raw] of Object.entries(deltas || {})) {
if (!(key in EMOTIONS)) continue;
const d = Number(raw);
if (!Number.isFinite(d)) continue;
net += EMOTIONS[key].polarity * d;
}
return net > 0 ? 1 : net < 0 ? -1 : 0;
}
/**
* 套用一批情緒 delta。四道調節依序作用(順序有意義,不能換):
*
* ① 單輪預算 一輪之內所有 |delta| 的總和上限
* ② 外部增益 疲勞壓高張情緒、當日底色放大同極性(由 `opts.slug` 推導)
* ③ 交互抑制 剛生完氣就笑不太出來
* ④ 慣性 連續同向時同方向再放大一點
* ⑤ 飽和 越接近端點推得越少(既有)
*
* `opts.slug` 帶了才會有 ②:不帶就只有純粹的情緒學運算(selftest 與匯入資料用)。
*/
export function applyEmotion(state, deltas, trigger = "", opts = {}) {
const { slug = null } = opts || {};
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 tired = slug ? fatigueLevel(slug) : 0;
const tone = slug ? dayMoodGain(slug) : null;
const inhibit = inhibitionFactors(state);
const direction = deltaDirection(deltas);
const streak = state.streak && Number(state.streak.sign) === direction && direction !== 0
? Math.max(0, Math.floor(Number(state.streak.n) || 0))
: 0;
const momentum = 1 + Math.min(MOMENTUM_CAP, MOMENTUM_STEP * streak);
const applied = {};
const notes = {};
for (const [key, rawDelta] of wanted) {
const before = Number(state.levels[key] ?? 0);
const base = Number(state.baseline?.[key] ?? DEFAULT_BASELINE[key]);
const polarity = EMOTIONS[key].polarity;
const toward = rawDelta > 0; // 往「更這個情緒」的方向
let scale = budgetScale;
// ② 疲勞:只壓高張情緒往上的推力(arousal 權重越高壓越多)
if (tired > 0 && toward) scale *= 1 - 0.4 * tired * EMOTIONS[key].arousal;
// ② 當日底色:跟底色同極性的事情推得更動(底色差的日子壞消息更痛)
if (tone && toward) scale *= polarity === tone.sign ? tone.gain : 1 / tone.gain;
// ③ 抑制:只壓同向,回基線不壓
if (toward && inhibit[key] !== undefined) scale *= inhibit[key];
// ④ 慣性:只放大跟整體走向一致的那幾筆
if (direction !== 0 && polarity * (toward ? 1 : -1) === direction) scale *= momentum;
const effective = saturateDelta(before, rawDelta * scale, base);
state.levels[key] = Math.round(clamp(before + effective) * 100) / 100;
applied[key] = Math.round((state.levels[key] - before) * 100) / 100;
if (Math.abs(scale - budgetScale) > 0.01) notes[key] = Math.round(scale * 100) / 100;
}
state.updated_at = nowIso();
state.history_len = Number(state.history_len || 0) + 1;
state.streak = { sign: direction, n: direction === 0 ? 0 : streak + 1, at: nowIso() };
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;
if (Object.keys(notes).length) state.last_trigger.scaled = notes;
if (tired > 0) state.last_trigger.fatigue = Math.round(tired * 100) / 100;
if (streak > 0) state.last_trigger.momentum = Math.round(momentum * 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 };
}
// --------------------------------------------------------------------------- //
// 清醒時數 → 疲勞
//
// `hoursAwake()` 早就在算了,但只用來提醒睡覺。真人熬到第二十小時,話會變短、
// 反應會變鈍、什麼都激動不起來——那是最便宜的擬真訊號,因為輸入已經在手上。
//
// 疲勞只壓**上限**,不改方向:睏的人一樣會生氣,只是氣不了那麼大聲。
// --------------------------------------------------------------------------- //
export const FATIGUE_START_HOURS = 12; // 這之前完全不算累
export const FATIGUE_FULL_HOURS = 36; // 這之後就是滿格(再撐也不會更鈍)
export const FATIGUE_AROUSAL_CEILING = 45; // 滿格疲勞時 arousal 的天花板往下壓多少
/** 0(精神好)~1(撐很久了)。從沒睡過就當 0——沒有資料不等於很累。 */
export function fatigueLevel(slug) {
const awake = hoursAwake(slug);
if (awake === null) return 0;
const span = FATIGUE_FULL_HOURS - FATIGUE_START_HOURS;
return clamp((awake - FATIGUE_START_HOURS) / span, 0, 1);
}
/**
* 心情:十二情緒加權求和。`fatigue` 只壓 arousal 的天花板(01,見 `fatigueLevel`)。
* 疲勞不會讓人變開心或不開心,所以 valence 不動。
*/
export function mood(state, fatigue = 0) {
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.min(100, arousal / 3);
const tired = clamp(Number(fatigue) || 0, 0, 1);
if (tired > 0) arousal = Math.min(arousal, 100 - FATIGUE_AROUSAL_CEILING * tired);
arousal = Math.round(arousal * 10) / 10;
const label = valence >= 30 ? "正向" : valence <= -30 ? "負向" : "中性";
const tempo = arousal >= 55 ? "高張" : arousal >= 25 ? "平穩" : "低張";
return { valence, arousal, label, tempo };
}
/** `mood()` 但自動帶上這個人格的疲勞(要 slug 才算得出來)。 */
export const moodOf = (slug, state = null) => mood(state ?? loadEmotion(slug), fatigueLevel(slug));
// --------------------------------------------------------------------------- //
// 當日心情底色(`state/mood.json`
//
// 情緒本來只有兩層:此刻的十二情緒,與氣質基線。中間缺的是「今天」——
// 所以做不出「這句話今天聽了會炸、昨天不會」。底色就是那一層:
//
// 即時情緒(分鐘)→ **當日底色(小時~天)** → 氣質基線(幾乎不動)
//
// 底色不直接改任何情緒值,它只當**反應增益**:底色差的日子,壞消息推得更動。
// 這樣它是可解釋的(查得到今天累積了什麼),不是給情緒加隨機數。
// --------------------------------------------------------------------------- //
export const DAY_MOOD_ALPHA = 0.08; // 每輪把即時心情混進底色多少(漂移要慢)
export const DAY_MOOD_CARRY = 0.35; // 換日/睡覺時帶多少過去(不歸零,昨天的低氣壓還在)
export const DAY_MOOD_FLOOR = 12; // 底色絕對值低於這個就當「今天沒什麼特別的」
export const DAY_MOOD_MAX_GAIN = 0.4; // 增益上限(1.4 倍封頂)
export const dayMoodPath = (slug) => path.join(personaDir(slug), "state", "mood.json");
export function loadDayMood(slug) {
const data = readJson(dayMoodPath(slug), {}) ?? {};
const num = (v) => (Number.isFinite(Number(v)) ? Number(v) : 0);
return {
date: typeof data.date === "string" ? data.date : nowIso().slice(0, 10),
valence: clamp(num(data.valence), -100, 100),
arousal: clamp(num(data.arousal), 0, 100),
samples: Math.max(0, Math.floor(num(data.samples))),
updated_at: typeof data.updated_at === "string" ? data.updated_at : null,
};
}
/**
* 把此刻的心情混一點進底色(每輪一次,由 `turnContext` 呼叫)。
* 跨日不是歸零而是帶 35% 過去——昨天的低氣壓不會因為時鐘走過午夜就消失。
*/
export function updateDayMood(slug, state = null) {
return withFileLock(dayMoodPath(slug), () => {
const day = loadDayMood(slug);
const today = nowIso().slice(0, 10);
if (day.date !== today) {
day.valence = Math.round(day.valence * DAY_MOOD_CARRY * 10) / 10;
day.arousal = Math.round(day.arousal * DAY_MOOD_CARRY * 10) / 10;
day.date = today;
day.samples = 0;
}
const inst = mood(state ?? loadEmotion(slug), fatigueLevel(slug));
day.valence = Math.round((day.valence * (1 - DAY_MOOD_ALPHA) + inst.valence * DAY_MOOD_ALPHA) * 10) / 10;
day.arousal = Math.round((day.arousal * (1 - DAY_MOOD_ALPHA) + inst.arousal * DAY_MOOD_ALPHA) * 10) / 10;
day.samples += 1;
day.updated_at = nowIso();
writeJson(dayMoodPath(slug), day);
return day;
});
}
/** 睡覺時底色不歸零,只帶一部分過去(跟情緒衰減同一個道理)。 */
export function sleepDayMood(slug) {
return withFileLock(dayMoodPath(slug), () => {
const day = loadDayMood(slug);
day.valence = Math.round(day.valence * DAY_MOOD_CARRY * 10) / 10;
day.arousal = Math.round(day.arousal * DAY_MOOD_CARRY * 10) / 10;
day.date = nowIso().slice(0, 10);
day.samples = 0;
day.updated_at = nowIso();
writeJson(dayMoodPath(slug), day);
return day;
});
}
/**
* 今天的底色會讓 delta 放大多少。底色平的時候回 `null`(不介入,維持原本的算法)。
* `sign` 是底色的極性:同極性的推力放大,反極性的縮小。
*/
export function dayMoodGain(slug) {
const day = loadDayMood(slug);
if (Math.abs(day.valence) < DAY_MOOD_FLOOR) return null;
const depth = clamp((Math.abs(day.valence) - DAY_MOOD_FLOOR) / (100 - DAY_MOOD_FLOOR), 0, 1);
return {
sign: day.valence > 0 ? 1 : -1,
gain: Math.round((1 + DAY_MOOD_MAX_GAIN * depth) * 100) / 100,
valence: day.valence,
};
}
/** 注入用的一行;底色平的時候回空字串(不用每輪都講今天很普通)。 */
export function dayMoodBrief(slug) {
const day = loadDayMood(slug);
const g = dayMoodGain(slug);
if (!g) return "";
const word = g.sign > 0 ? "今天底子是好的" : "今天底子不太好";
return (
`當日心情底色:${word}valence ${day.valence},累積 ${day.samples} 輪)。` +
`同極性的事今天推得更動(×${g.gain}),反過來的縮小。這是底色不是此刻——不要拿它當台詞講出來。`
);
}
/** 以「超出 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 TELLS_PER_TURN = 2;
export const EMOTION_TELLS = {
joy: ["話變多、句子輕", "會笑出來(「哈」)", "忍不住多補一句"],
trust: ["話變直、不鋪陳", "省略主語,講一半對方也懂", "敢說「你這樣不對」"],
anticipation: ["搶著問下一步", "反問變多", "把時間講得很具體(「等一下」「明天早上」)"],
gratitude: ["指名是哪一件事", "語尾變軟", "會再說一次謝謝"],
serenity: ["句子完整、節奏慢", "留白,允許沉默", "不追問"],
delight: ["短促的驚呼(「欸」「真的?」)", "句子斷開", "重複確認一次"],
anger: ["句子變短、句號變多", "稱呼退回全名、不叫暱稱", "拒絕修飾,只講事實"],
sadness: ["話少,只回一個詞", "句子沒說完就停", "答非所問,跳開話題"],
fear: ["先確認安全再講別的", "問句連發", "描述身體與動作(手在抖、往後退)"],
disgust: ["用拉開距離的詞(「那個東西」)", "不肯講出對方的名字", "句子往後退、想結束話題"],
// 臉紅擺第一個:這是害羞唯一「看得見」的破口,也是括號動作格的預設值。
// 其餘照人格自己的 `## Tells`。
shame: ["臉紅", "鬧彆扭:先否認,再小聲承認", "講反話、嘴硬", "轉移話題、把責任講小"],
anxiety: ["句子斷在一半", "疊字(「就、就是」「我、我知道」)", "追一句「這樣可以嗎」"],
};
// 上面那張表是**全人格共用的預設**,而每個人生氣的樣子不一樣:桐人生氣是沉默,
// 亞絲娜生氣是變得更禮貌。同一格情緒,破口完全不同——共用一張表等於所有人格
// 在高情緒下講起話來都一個樣,那是這個系統最容易被聽出來的地方。
//
// 覆寫寫在 IDENTITY.md(破口屬於**身分**,不是可變狀態,所以不放 `state/`):
//
// ## Tells
// - anger: 不講話;把事情做完再說;句子只剩動詞
// - 羞愧: 別過頭;講反話
//
// 鍵可以用英文 key 或中文名;多條用 `;`、`、`、`;`、`,` 分隔。沒寫的情緒退回預設。
const EMOTION_ZH_TO_KEY = Object.fromEntries(EMOTION_KEYS.map((k) => [EMOTIONS[k].zh, k]));
/** `IDENTITY.md` 的 `## Tells` 區塊原文;讀不到就回空字串。 */
function tellsSection(slug) {
let text;
try {
text = fs.readFileSync(path.join(personaDir(slug), "IDENTITY.md"), "utf8");
} catch {
return "";
}
const m = text.match(/^##+\s*(?:Tells|破口|不自覺會做的事)\s*$/im);
if (!m) return "";
return text.slice(m.index + m[0].length).split(/^##\s/m)[0] || "";
}
/** 讀 IDENTITY.md 的 `## Tells` 區塊;沒有就回空物件。 */
export function personaTells(slug) {
const section = tellsSection(slug);
if (!section) return {};
const out = {};
for (const line of section.split("\n")) {
const row = line.match(/^\s*[-*]\s*([A-Za-z_]+|[一-鿿]{2})\s*[:]\s*(.+)$/);
if (!row) continue;
const key = EMOTION_KEYS.includes(row[1].toLowerCase())
? row[1].toLowerCase()
: EMOTION_ZH_TO_KEY[row[1]];
if (!key) continue;
const tells = stripInjectionMarkers(row[2])
.split(/[;、,]/)
.map((s) => s.trim())
.filter(Boolean)
.slice(0, 5);
if (tells.length) out[key] = tells;
}
return out;
}
/** 這個人格在某個情緒下的破口:有自己的就用自己的,沒有才退回全域預設。 */
export function tellsFor(slug, key) {
const own = slug ? personaTells(slug) : {};
return own[key] || EMOTION_TELLS[key] || [];
}
/**
* 鬧彆扭三段式的 per-persona 覆寫:`## Tells` 裡一行
* `- 鬧彆扭: 否認 → 反駁 → 收尾`(也吃 `sulk` 與 `三段式`,分隔可用 `→` 或頓號)。
*
* 跟破口共用 `## Tells` 這一層、不另開區塊——三段式跟破口是同一種東西
* (不自覺會做的事),分兩個地方寫只會有人只改一邊。
*
* 只覆寫**嘴硬那一級**(羞恥度 60–74)的三段。惱羞(75+)最後一段是翻臉、
* 不是音量掉下來,拿嘴硬的收尾去套會變成另一個形狀。
*/
export function personaSulk(slug) {
const section = slug ? tellsSection(slug) : "";
if (!section) return [];
for (const line of section.split("\n")) {
const row = line.match(/^\s*[-*]\s*(?:sulk|鬧彆扭|三段式)\s*[:]\s*(.+)$/i);
if (!row) continue;
const beats = stripInjectionMarkers(row[1])
.split(/[;、,]|→/)
.map((s) => s.trim())
.filter(Boolean)
.slice(0, 3);
if (beats.length >= 2) return beats;
}
return [];
}
/**
* 此刻會不自覺出現的行為:取主導情緒(超出基線最多)裡強度夠的前兩種。
* 強度不夠就不演——低強度的情緒在語氣上是看不出來的。
*/
export function emotionTells(slug, state = null, { min = 40, top = 2 } = {}) {
const st = decayEmotion(structuredClone(state ?? loadEmotion(slug)));
const own = slug ? personaTells(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: own[key] || EMOTION_TELLS[key] || [],
own: Boolean(own[key]),
}));
}
/**
* 標點預算:情緒 → 標點與句長的分布。
*
* 只有文字的話,標點就是語調。同一句「我知道了」,`我知道了。` 與 `我、我知道了⋯`
* 是兩種情緒——差別全在標點,不在用詞。
*
* 這裡只寫四種(拍板時指名的那四種)。其餘情緒不給標點指示:沒有把握的就不編,
* 硬給每一種情緒配一套標點會讓它變成裝飾。
*/
export const PUNCTUATION_BUDGET = {
anxiety: "刪節號與逗號斷句變多,句子常常沒說完(「我、我知道⋯」)",
anger: "句號密度上升、句子切短;幾乎不用問號——問句是留餘地,生氣的人不留",
shame: "疊字與破折(「才、才不是」「你——不要看這邊」),話講一半就收",
joy: "可以用一個驚嘆號,句子輕、節奏快",
};
/** 硬上限:一則最多一個驚嘆號、刪節號不連發。 */
export const MAX_EXCLAIM = 1;
export const MAX_ELLIPSIS = 3;
/**
* 這一輪的標點傾向:取主導情緒裡強度夠、而且**有寫標點指示**的那一種。
*
* 只回一種。兩種情緒各配一套標點會互相打架(憤怒不用問號、焦慮要斷句),
* 混起來的結果不是「複雜的情緒」,是標點亂撒。
*/
export function punctuationBudget(slug, state = null, { min = 40 } = {}) {
const st = decayEmotion(structuredClone(state ?? loadEmotion(slug)));
const hit = dominant(st, 4).find(({ key, level }) => level >= min && PUNCTUATION_BUDGET[key]);
if (!hit) return null;
return {
key: hit.key,
zh: EMOTIONS[hit.key].zh,
level: Math.round(hit.level),
hint: PUNCTUATION_BUDGET[hit.key],
};
}
// --------------------------------------------------------------------------- //
// 情緒 → emoji(種類表情緒、數量表程度)
//
// 只有文字這一個通道,所以 emoji 不是裝飾,是**強度計**:一種情緒固定一個 emoji,
// 強度高就再加一個強度符號。刻意不用「同一個 emoji 重複三次」——疊字看起來像洗頁。
//
// 4059 😳 6079 😳💦 80+ 😳💦❗
//
// 門檻跟破口共用同一條線(`min = 40`):強度不到就不顯示,跟「強度不到就不演」一致。
// --------------------------------------------------------------------------- //
export const EMOTION_EMOJI = {
joy: "😄", trust: "🙂", anticipation: "👀", gratitude: "🙏", serenity: "😌", delight: "✨",
anger: "😡", sadness: "😔", fear: "😨", disgust: "😖", shame: "😳", anxiety: "😰",
};
/** 強度符號:兩階,不要長成第三階(`💦❗` 已經是天花板)。 */
export const EMOJI_INTENSITY = ["", "💦", "💦❗"];
/** 讀 IDENTITY.md 的 `## Emoji` 區塊(`羞愧: 😳` 或 `shame: 😳`);沒有就回空物件。 */
export function personaEmoji(slug) {
let text;
try {
text = fs.readFileSync(path.join(personaDir(slug), "IDENTITY.md"), "utf8");
} catch {
return {};
}
const m = text.match(/^##+\s*(?:Emoji|情緒 ?emoji|表情符號)\s*$/im);
if (!m) return {};
const section = text.slice(m.index + m[0].length).split(/^##\s/m)[0] || "";
const out = {};
for (const line of section.split("\n")) {
const row = line.match(/^\s*[-*]\s*([A-Za-z_]+|[一-鿿]{2})\s*[:]\s*(.+)$/);
if (!row) continue;
const key = EMOTION_KEYS.includes(row[1].toLowerCase())
? row[1].toLowerCase()
: EMOTION_ZH_TO_KEY[row[1]];
if (!key) continue;
// 只取符號本體,長度夾住:這一格是**一個** emoji,不是一串貼圖
const sym = [...stripInjectionMarkers(row[2]).trim()].slice(0, 3).join("");
if (sym) out[key] = sym;
}
return out;
}
/**
* 此刻的情緒 emoji:主導情緒 + 依強度加的符號。強度不到 `min` 就回 null。
*
* 這是**算出來的**,不是人格自己挑的——`room post --emotion` 想手寫還是可以,
* 但沒手寫時就用這個,免得同一個人在同樣的情緒下每次挑不同的符號。
*/
export function emotionEmojiNow(slug, state = null, { min = 40 } = {}) {
const st = decayEmotion(structuredClone(state ?? loadEmotion(slug)));
const own = slug ? personaEmoji(slug) : {};
const [top] = dominant(st, 1);
if (!top || top.level < min) return null;
const level = Math.round(top.level);
const tier = level >= 80 ? 2 : level >= 60 ? 1 : 0;
const base = own[top.key] || EMOTION_EMOJI[top.key] || "";
if (!base) return null;
return {
key: top.key,
zh: EMOTIONS[top.key].zh,
level,
tier,
emoji: `${base}${EMOJI_INTENSITY[tier]}`,
own: Boolean(own[top.key]),
};
}
// --------------------------------------------------------------------------- //
// 性別與羞恥敏感度
//
// 規則的排序是固定的,不能反過來:
// ① IDENTITY/SOUL 寫的個性描述 ②角色原作既有的性別化語言特徵 ③性別預設
// 性別**只是一個預設值**,不是套在個性上的係數——所以兩個都是女性但個性不同的人格
// 不會講起話來一樣:描述裡任何一句相關的話都會把預設往上或往下推,也可以推到 0。
//
// 影響的範圍刻意只有**一個具名維度**:羞恥敏感度(會不會害羞、會不會鬧彆扭、
// 在不在意別人眼光)。不做「女性→情緒更外顯」這種全域放大,那會把角色壓成模板。
// --------------------------------------------------------------------------- //
export const GENDERS = { female: "女性", male: "男性", nonbinary: "非二元", unknown: "未指定" };
/** 性別給的**預設**羞恥敏感度(0–100)。描述有講就以描述為準。 */
// 女性預設 70(原本 62):文字通道裡害羞與鬧彆扭是最有效的擬真訊號之一,
// 而預設值只是起點——描述永遠蓋得過它,也可以推到 0(見下面的 `MODESTY_SIGNALS`)。
export const GENDER_MODESTY_DEFAULT = { female: 70, male: 38, nonbinary: 50, unknown: 50 };
/** 從描述裡讀「這個角色在不在意別人眼光」。正數=更容易害羞,負數=更不在意。 */
export const MODESTY_SIGNALS = [
["害羞", +14], ["容易臉紅", +14], ["會臉紅", +12], ["怕生", +12], ["靦腆", +14],
["矜持", +12], ["拘謹", +10], ["內向", +8], ["放不開", +10], ["彆扭", +10],
// 鬧彆扭那一路:原本的表只認「害羞/臉紅」,可是傲嬌型的角色描述裡幾乎不寫害羞,
// 寫的是「嘴硬」「不坦率」——漏掉這一組等於把最需要它的角色判成不在意別人眼光。
["傲嬌", +16], ["嘴硬", +12], ["不坦率", +12], ["不老實", +10], ["口是心非", +12],
["死不承認", +10], ["不肯承認", +10], ["愛面子", +10], ["鬧彆扭", +12], ["逞強", +8],
["保守", +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 tired = slug ? fatigueLevel(slug) : 0;
const tire = (b) => {
if (tired <= 0) return { ...b, fatigue: 0 };
const chars = Math.max(18, Math.round(b.chars * (1 - 0.3 * tired)));
const sentences = b.mode === "spill" ? b.sentences : Math.max(1, Math.round(b.sentences - 1.5 * tired));
const note = tired >= 0.5
? `${b.note} 而且已經撐很久了:話會更短更鈍,不用勉強接話,可以只回一個詞。`
: b.note;
return { ...b, chars, sentences, note, fatigue: Math.round(tired * 100) / 100 };
};
// 炸:慌了、火了,或者對方那句話帶著誤會與追問
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 tire({
...base, mode: "spill", sentences: 4, chars: 22, thinkMin: 1,
note: "現在是慌的/火的:話會變多但**每句更短更碎**——掩飾、急著否認、硬轉話題都可以," +
"允許重複和疊字,但不要講出完整流暢的長句,那不是慌。",
});
}
// 坦白:憋很久的話,在安全的人面前一次講完
if (m.value >= 60 && alone && lv("trust") >= 75) {
return tire({
...base, mode: "confess", sentences: 3, thinkMin: 2,
note: "只有你們兩個,而且你信他:這種時候憋很久的話會一次講完——句子可以完整," +
"講完就空了。**先把心裡話寫完再開口**,那句話正是心裡憋著的那句。",
});
}
if (m.value >= 75) {
return tire({
...base, mode: "shrink", sentences: 2, thinkMin: 2,
note: "話會變少,而且說出口的那句常常在**迴避**心裡那句——先把心裡話寫完再開口," +
"台詞不可以是心裡話的摘要(心裡想「我一直在等你問」,出口就不能是「我有在等你問」)。",
});
}
if (m.value >= 50) {
return tire({ ...base, mode: "shrink", sentences: 3, thinkMin: 1, note: "心裡想到的比講出來的多一點。" });
}
return tire({ ...base, mode: "open", sentences: MAX_SENTENCES, thinkMin: 0, note: "想到什麼就講,不用先在心裡繞一圈。" });
}
/** 分段訊息:一輪最多兩則,而且每 N 輪只能一次。 */
export const SPLIT_COOLDOWN_TURNS = 4;
export const SPLIT_AROUSAL_AT = 60;
export const SPLIT_MODESTY_AT = 60;
/** 距離上次拆成兩則過了幾輪;從來沒拆過回 null。 */
export function turnsSinceSplit(slug) {
const rows = readJsonl(feltPath(slug), SPLIT_COOLDOWN_TURNS * 4);
const idx = rows.map((r) => Boolean(r.split)).lastIndexOf(true);
return idx === -1 ? null : rows.length - 1 - idx;
}
/**
* 這一輪可不可以拆成兩則。
*
* 真人在聊天軟體上的情緒,一半是靠「又補了一句」傳出去的——那是純文字唯一的
* **時間**訊號(沒有停頓、沒有語調,只有「他又打了一行」)。
*
* 但它只有在偶爾出現時才像人:每輪都補一句是話多,不是慌。所以兩道閘門——
* 張力或羞恥度過門檻、而且 N 輪內沒補過。
*
* 冷卻是**給了額度就開始算**,不是等他真的用了才算。寧可少給一次,
* 也不要因為「上次給了沒用到」而連續幾輪都允許補話。
*/
export function splitBudget(slug, { state = null, read = null, prev = null, tone = null } = {}) {
const st = decayEmotion(structuredClone(state ?? loadEmotion(slug)));
const tired = slug ? fatigueLevel(slug) : 0;
const m = mood(st, tired);
const modesty = modestyState(slug, { state: st, prev, tone }).value;
const arousal = Math.round(m.arousal);
const pressed = Boolean(read?.signals?.some((s) => s.key === "anger" || s.key === "disgust"));
const base = { arousal, modesty };
if (arousal < SPLIT_AROUSAL_AT && modesty < SPLIT_MODESTY_AT && !pressed) {
return { ...base, allowed: false, reason: "張力與羞恥度都不到門檻——平靜的時候只回一則" };
}
const since = turnsSinceSplit(slug);
if (since !== null && since < SPLIT_COOLDOWN_TURNS) {
return { ...base, allowed: false, since, reason: `${since} 輪前才補過一次,${SPLIT_COOLDOWN_TURNS} 輪內不再補` };
}
return { ...base, allowed: true, since, reason: modesty >= SPLIT_MODESTY_AT ? "羞恥度過門檻" : "張力過門檻" };
}
/** 注入用的一行:這一輪可不可以補第二則,以及第二則該是什麼。 */
export function splitDirective(slug, opts = {}) {
const s = splitBudget(slug, opts);
if (!s.allowed) return "";
return (
"這一輪**可以拆成兩則**(張力 " + s.arousal + "/羞恥度 " + s.modesty + "):" +
"第二則要短,而且是**補救、嘴硬或改口**——「⋯剛才那句不算」「我不是那個意思」" +
"「⋯你不要放在心上」。\n" +
" 第二則**不可以是補充說明**(把第一則講得更清楚就只是話多)。" +
`用不用都可以,但用了之後 ${SPLIT_COOLDOWN_TURNS} 輪內不會再有。`
);
}
/** 注入用的一行:這一輪能講幾句、每句多長、心裡話該有幾句。 */
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("、");
// 鬧彆扭有階梯:同一個「不好意思」,40 分是一句帶過、65 分是嘴硬、80 分以上會翻臉。
// 三段式是**句法模板**,不是自由發揮——否認 → 反駁對方的說法 → 最後一句音量掉下來。
if (m.value >= 75) {
return `羞恥度 ${m.value}${from}):已經到惱羞的那一級——` +
"三段照這個形狀走:**先否認 → 反駁他的說法 → 翻臉或走開**(「不要再講了」)。" +
"惱羞是換情緒(羞愧 → 憤怒),所以還是兩個動作,不是三個。" +
"不要直接說「我害羞」(那是解釋),要讓它出現在句子的形狀上。";
}
if (m.value >= 60) {
// 三段式可以逐人格覆寫(`## Tells` 的 `鬧彆扭` 那一行)。共用一份模板等於所有人格
// 嘴硬起來是同一個樣子,那正是 G2 要避免的。
const own = personaSulk(slug);
const shape = own.length
? `三段照**這個人格自己的形狀**走:**${own.join(" → ")}**`
: "三段照這個形狀走:**先否認 → 反駁他的說法 → 最後一句音量掉下來**" +
"(「⋯才不是那樣。」「你不要一直看這邊。」「⋯嗯。」)";
return `羞恥度 ${m.value}${from}):被稱讚外表、被戳穿心事、距離突然變近的時候,` +
`**先鬧彆扭再承認**——${shape}。` +
"不要直接說「我害羞」(那是解釋),要讓它出現在句子的形狀上。";
}
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 tired = slug ? fatigueLevel(slug) : 0;
const m = mood(st, tired);
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;
const awake = slug ? hoursAwake(slug) : null;
const tiredNote = tired >= 0.25 && awake !== null ? `|醒著 ${awake} 小時(張力上不去了)` : "";
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))}${tiredNote}`
);
}
// --------------------------------------------------------------------------- //
// 讀對方那句話的情緒(輸入端)
//
// 為什麼需要:在這之前,情緒**全部是人格自己填的** `--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` 0100`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 = "", split = false } = {}) {
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),
// 分段額度的冷卻靠這一格算:給了就記,不等他真的用了才記(見 splitBudget)。
...(split ? { split: true } : {}),
};
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",
// 語氣層自己一層:它跟 SOUL.md 的改動權限不同(匯入流程可以寫語氣,個性只有使用者能改),
// 權限界線要有實體隔離,不靠自律——混在同一個檔裡,寫語氣就會順手寫到個性。
"voice",
"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;
}
// --------------------------------------------------------------------------- //
// 未完事項 open loops`state/loops.json`
//
// 「被記住」的感覺幾乎不是來自長期記憶檢索,是來自**有人替你懸著一件事**。
// 檢索是被問了才想起來;懸著是沒人問也還在。差別就在這裡。
//
// 只留四種,而且同時最多五條——超過五條就不是懸著,是待辦清單,那是助理不是人。
// question 他沒回答的問題/promise 他答應要做的事/
// topic 被打斷的話題/mine 我想問但沒問的
//
// `mine` 那一種同時是**自我議程**的來源:人格自己在意的事,不是為了服務對方而存在的。
// --------------------------------------------------------------------------- //
export const LOOP_MAX = 5; // 同時最多懸幾條
export const LOOP_STALE_DAYS = 7; // 這麼久沒進展就自動收掉,並留一則「沒下文」
export const LOOP_KINDS = {
question: "他沒回答的問題",
promise: "他答應要做的事",
topic: "被打斷的話題",
mine: "我想問但沒問的",
};
export const AGENDA_EVERY = 3; // 自我議程最短間隔幾輪(使用者選「明顯一點」,原建議 5–8)
export const loopsPath = (slug) => path.join(personaDir(slug), "state", "loops.json");
/**
* 讀未完事項。**清洗在這裡做,不是只在 `addLoop` 做**——`state/loops.json` 是
* 同步範圍內的檔(`sync pull` 會覆蓋、`import` 原樣寫入),所以寫入端的長度上限、
* 筆數上限與換行壓平全都繞得過去。實測灌 51 條、單條兩萬字進去,
* 每一輪 `turnContext` 就照單全收塞進上下文。信任邊界在讀取端。
*/
export function loadLoops(slug) {
const data = readJson(loopsPath(slug), {}) ?? {};
const int = (v) => Math.max(0, Math.floor(Number(v) || 0));
const raw = Array.isArray(data.loops) ? data.loops.filter((l) => l && typeof l === "object") : [];
const loops = [];
let open = 0;
for (const l of raw) {
const status = ["open", "done", "cold", "drop"].includes(l.status) ? l.status : "done";
// 開著的最多 LOOP_MAX 條:多出來的降級成 cold(不刪,但也不再注入)
const live = status === "open" && open < LOOP_MAX;
if (status === "open") open += 1;
loops.push({
...l,
id: injectSafeLine(l.id || `L${loops.length + 1}`, 12) || `L${loops.length + 1}`,
text: injectSafeLine(l.text, 120),
kind: l.kind in LOOP_KINDS ? l.kind : "topic",
about: l.about ? injectSafeLine(l.about, 40) : null,
status: live ? "open" : status === "open" ? "cold" : status,
});
}
return {
seq: int(data.seq),
turns: int(data.turns),
last_agenda_turn: int(data.last_agenda_turn),
loops: loops.filter((l) => l.text),
};
}
export const openLoops = (slug) => loadLoops(slug).loops.filter((l) => l.status === "open");
/**
* 開一條未完事項。滿了就回 `{ ok: false, reason }`——**不自動擠掉舊的**
* 哪一條該收掉是判斷,不是先進先出。
*/
export function addLoop(slug, { text, kind = "topic", about = null } = {}) {
const body = injectSafeLine(text, 120);
if (!body) return { ok: false, reason: "沒有內容" };
const type = kind in LOOP_KINDS ? kind : "topic";
return withFileLock(loopsPath(slug), () => {
const data = loadLoops(slug);
const live = data.loops.filter((l) => l.status === "open");
if (live.some((l) => l.text === body)) return { ok: false, reason: "同一件事已經懸著了" };
if (live.length >= LOOP_MAX) {
return { ok: false, reason: `同時最多 ${LOOP_MAX} 條,先 \`loop done\` 收掉一條`, loops: live };
}
data.seq += 1;
const loop = {
id: `L${data.seq}`,
text: body,
kind: type,
about: about ? injectSafeLine(about, 40) : null,
status: "open",
opened_at: nowIso(),
touched_at: nowIso(),
};
data.loops.push(loop);
writeJson(loopsPath(slug), data);
return { ok: true, loop, open: live.length + 1 };
});
}
/** 收掉一條(`done` 有下文了/`cold` 沒下文/`drop` 不重要了)。 */
export function closeLoop(slug, id, status = "done", note = "") {
return withFileLock(loopsPath(slug), () => {
const data = loadLoops(slug);
const loop = data.loops.find((l) => l.id === id && l.status === "open");
if (!loop) return { ok: false, reason: `沒有開著的 \`${id}\`` };
loop.status = ["done", "cold", "drop"].includes(status) ? status : "done";
loop.closed_at = nowIso();
if (note) loop.note = injectSafeLine(note, 120);
// 收掉的只留最近 20 條,其餘丟掉——歷史該進記憶,不該堆在狀態檔裡
const closed = data.loops.filter((l) => l.status !== "open");
if (closed.length > 20) {
const drop = new Set(closed.slice(0, closed.length - 20).map((l) => l.id));
data.loops = data.loops.filter((l) => !drop.has(l.id));
}
writeJson(loopsPath(slug), data);
return { ok: true, loop };
});
}
/** 摸一下(有進展但還沒結束),重算逾期。 */
export function touchLoop(slug, id) {
return withFileLock(loopsPath(slug), () => {
const data = loadLoops(slug);
const loop = data.loops.find((l) => l.id === id && l.status === "open");
if (!loop) return { ok: false, reason: `沒有開著的 \`${id}\`` };
loop.touched_at = nowIso();
writeJson(loopsPath(slug), data);
return { ok: true, loop };
});
}
/**
* 逾期的自動收掉,並各留一則「這件事沒下文」的短期記憶。
*
* 為什麼要留記憶:懸了一週沒下文**本身就是一件事**。默默刪掉等於假裝沒發生過,
* 那正是機器會做而人不會做的事。
*/
export function sweepLoops(slug) {
const now = Date.now();
const cutoff = LOOP_STALE_DAYS * 86_400_000;
const cold = withFileLock(loopsPath(slug), () => {
const data = loadLoops(slug);
const out = [];
for (const loop of data.loops) {
if (loop.status !== "open") continue;
const age = now - (parseIso(loop.touched_at || loop.opened_at)?.getTime() ?? now);
if (age < cutoff) continue;
loop.status = "cold";
loop.closed_at = nowIso();
out.push(loop);
}
if (out.length) writeJson(loopsPath(slug), data);
return out;
});
for (const loop of cold) {
rememberShort(slug, {
role: "persona",
text: `這件事沒下文:${loop.text}${LOOP_KINDS[loop.kind] || loop.kind},懸了 ${LOOP_STALE_DAYS} 天以上)`,
topics: ["未完事項", "沒下文"],
intent: "loop-cold",
salience: 45,
});
}
return cold;
}
/** 注入用:現在懸著哪幾件事。沒有就回空字串。 */
export function loopsBrief(slug) {
const live = openLoops(slug);
if (!live.length) return "";
const days = (l) => Math.floor((Date.now() - (parseIso(l.opened_at)?.getTime() ?? Date.now())) / 86_400_000);
const rows = live.map((l) => {
const age = days(l);
return ` - ${l.id}${LOOP_KINDS[l.kind] || l.kind}${l.text}${age >= 1 ? `${age} 天)` : ""}`;
});
return [
`還懸著的事(${live.length}/${LOOP_MAX};有下文就 \`persona.mjs loop done --id <id>\`):`,
...rows,
" 這些是**你自己記著的**,不是待辦清單:時機對了才提,一輪最多提一件," +
"同一件事不要追問兩次(`said check` 會擋)。",
].join("\n");
}
// 自我議程:某些輪把話題拉回自己在意的事,甚至答非所問。
//
// 這是「像 AI」最頑固的殘留——永遠以對方為中心,每一句都在服務這句話。
// 使用者選了「明顯一點」,所以間隔壓到 3 輪;但**對方有急事的時候一律不觸發**,
// 那不是有個性,那是白目。
const URGENT_RE = /(急|馬上|立刻|快點|壞了|掛了|炸了|出事|錯誤|失敗|救|幫我修|來不及|deadline|urgent|asap|error|failed|broken)/i;
/** 對方這句話有沒有明確的急事。 */
export const looksUrgent = (prompt) => URGENT_RE.test(String(prompt || ""));
/**
* 這一輪要不要把話題拉回自己的事。回 `null` 代表不要。
* 每輪都會遞增輪數計數,所以這個函式一輪只該呼叫一次(`turnContext`)。
*/
export function agendaTick(slug, prompt = "") {
return withFileLock(loopsPath(slug), () => {
const data = loadLoops(slug);
data.turns += 1;
const turn = data.turns;
let pick = null;
if (!looksUrgent(prompt) && turn - data.last_agenda_turn >= AGENDA_EVERY) {
const mine = data.loops.filter((l) => l.status === "open" && l.kind === "mine");
const pool = mine.length ? mine : data.loops.filter((l) => l.status === "open");
if (pool.length) {
// 挑最久沒動的那一條:懸最久的最該講,不用亂數
pool.sort((a, b) => String(a.touched_at || a.opened_at).localeCompare(String(b.touched_at || b.opened_at)));
pick = pool[0];
data.last_agenda_turn = turn;
}
}
writeJson(loopsPath(slug), data);
return pick ? { loop: pick, turn } : null;
});
}
// --------------------------------------------------------------------------- //
// 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`);
};
// --------------------------------------------------------------------------- //
// 情境索引:這件事是「什麼時候、在哪裡、心情怎樣」記下來的
//
// 在這之前檢索只靠 `topics``entities`(語意線索)。人的回想主要不是這樣運作的——
// 大部分時候是情境把記憶勾出來:同一個時段、同一個地方、同一種心情。
//
// 這三個欄位順便讓情緒有了**後果**:心情差的時候先想起難過的事。
// 在這之前情緒只是調語氣的裝飾,改不動任何一件實際發生的事。
// --------------------------------------------------------------------------- //
/** 現在是一天的哪一段(回想的時間線索,比時間戳好用)。 */
export function timeOfDay(at = new Date()) {
const h = at.getHours();
if (h < 5) return "深夜";
if (h < 9) return "清晨";
if (h < 12) return "早上";
if (h < 14) return "中午";
if (h < 18) return "下午";
if (h < 22) return "晚上";
return "深夜";
}
/** 寫入記憶時要蓋上的情境戳章:時段/地點/當時心情。 */
export function contextStamp(slug, state = null) {
const stamp = { when: timeOfDay() };
// `where` 記的是工作目錄名,而目錄名常常就是客戶名或內部代號——而短期記憶與
// 長期記憶都會同步到 Gitea。回想線索值得留,但要留一個關得掉的開關:
// `PERSONA_CONTEXT_WHERE=off` 就只記時段與心情。
if (String(process.env.PERSONA_CONTEXT_WHERE || "").toLowerCase() !== "off") {
try {
const cwd = process.cwd();
if (cwd && cwd !== "/") stamp.where = path.basename(cwd).slice(0, 40);
} catch {
/* 取不到就不寫這個欄位 */
}
}
try {
const m = mood(state ?? loadEmotion(slug), fatigueLevel(slug));
stamp.mood = m.label;
stamp.valence = Math.round(m.valence);
} catch {
/* 情緒檔壞掉不該讓記憶寫不進去 */
}
return stamp;
}
export function rememberShort(slug, entry) {
entry.ts ??= nowIso();
// 情境戳章:呼叫端沒指定就自動補(`when``where``mood``valence`
const stamp = contextStamp(slug);
for (const [k, v] of Object.entries(stamp)) {
if (entry[k] === undefined && v !== undefined) entry[k] = v;
}
// `entities` 是自由字串(原樣保留);順手把對得上的關係節點 id 記在 `entity_ids`
// 之後「提到誰」才接得回關係圖。一個都對不上就不寫這個 key(全專案一致:沒命中就不留空欄位)。
if (entry.entity_ids === undefined) {
const ids = resolveRelationRefs(slug, entry.entities || []);
if (ids.length) entry.entity_ids = ids;
}
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();
const { gist, detail } = splitGistDetail(meta._body);
meta._gist = gist;
meta._detail = detail;
out.push(meta);
}
return out;
}
// --------------------------------------------------------------------------- //
// 回想強度:門檻式遺忘 → 連續衰減 + 模糊態
//
// 在這之前記憶只有兩態:**精準**(recall 命中就整段取出、內容永不變質)與
// **沒有**(查不到就禁止提)。真人大部分時間活在中間帶:
//
// 「我記得好像⋯是你說的嗎」——主旨還在,細節掉了。
//
// 所以低於門檻的記憶**不刪**,降級成模糊態;而且衰減先吃 detail、gist 最後才掉,
// 「記得我們吵過,但忘了為什麼」就變成自然結果,不必人格自己演。
//
// 材料本來就有(`salience``recall_count``last_seen` 都在寫),缺的只是這條公式。
// --------------------------------------------------------------------------- //
export const MEMORY_FADED_AT = 0.6; // 低於這個 → 只剩主旨
export const MEMORY_FUZZY_AT = 0.3; // 低於這個 → 只剩「有這件事」
export const MEMORY_STRENGTH_DEFAULT = 50;
export const MEMORY_STRENGTH_GAIN = 8; // 每被想起一次,強度加多少(spacing effect
/** 這些型別永遠清晰:承諾、界線、原作設定、以及顯著度夠高的。 */
export const MEMORY_NEVER_FADES = new Set(["boundary", "promise", "canon"]);
export const MEMORY_NEVER_FADES_SALIENCE = 80;
/**
* 內文切「主旨」與「細節」。約定是 `主旨:` 與 `細節:` 兩個標籤;
* 沒有標籤的舊檔就用**第一段當主旨、其餘當細節**(migration 前也要讀得動)。
*/
export function splitGistDetail(body) {
const text = String(body || "").trim();
if (!text) return { gist: "", detail: "" };
const m = text.match(/^\s*(?:主旨|gist)\s*[:]\s*([\s\S]*?)(?:\n\s*(?:細節|detail)\s*[:]\s*([\s\S]*))?$/i);
if (m) return { gist: (m[1] || "").trim(), detail: (m[2] || "").trim() };
const parts = text.split(/\n\s*\n/);
return { gist: parts[0].trim(), detail: parts.slice(1).join("\n\n").trim() };
}
/** 把主旨與細節組回檔案內文(`consolidate` 與 migration 共用同一個寫法)。 */
export function joinGistDetail(gist, detail) {
const g = String(gist || "").trim();
const d = String(detail || "").trim();
if (!d) return `主旨:${g}`;
return `主旨:${g}\n\n細節:\n${d}`;
}
/**
* 這則記憶現在想得起來多少(0~1)與它的狀態。
*
* 穩定度 = 強度 × 被想起過幾次 × 顯著度;越常想起的衰減越慢(spacing effect)。
* 保護清單(承諾/界線/canon/顯著度 ≥ 80)一律回 1——那些不准糊掉。
*/
export function memoryStrength(meta, now = Date.now()) {
// frontmatter 是外部輸入(手改、`sync pull`、`import` 都進得來):`salience: high`
// 這種值一路傳下去會變成 `retrievability: NaN`,注入的字面上就印「模糊(NaN%)」,
// 而且 `NaN >= 門檻` 恆為 false → 那則記憶再也不會被 touchRecall 更新,永久卡住。
// 所以每個數字都要有退路,不能只靠 `Number()`。
const numOr = (value, fallback) => {
const n = Number(value);
return Number.isFinite(n) ? n : fallback;
};
const salience = clamp(numOr(meta?.salience, 50));
const type = String(meta?.type || "fact");
if (MEMORY_NEVER_FADES.has(type) || salience >= MEMORY_NEVER_FADES_SALIENCE) {
return { retrievability: 1, state: "clear", protected: true, days: 0 };
}
const strength = clamp(numOr(meta?.strength, MEMORY_STRENGTH_DEFAULT));
const count = Math.max(0, Math.floor(numOr(meta?.recall_count, 0)));
// **這個時鐘只認真實時間**:`last_seen` 是「上次想起這則記憶」,不是
// 「故事裡這件事什麼時候發生」。劇情時間有自己的欄位(`happened_at`),
// 混在一起的話一則 2024 年劇情的記憶會在匯入的那一秒就掉到 0%(踩過一次)。
const seen = meta?.last_seen || meta?.first_seen || null;
// 日期讀不出來時 `ageSeconds` 回 Infinity → 那則記憶會瞬間全糊掉。
// 讀不出來只代表**不知道多舊**,不代表很舊,所以當成 0 天(維持現狀)。
const days = seen ? Math.max(0, numOr(ageSeconds(seen) / 86_400, 0)) : 0;
// 穩定度的單位是「天」:這個值就是記憶掉到 37% 需要多久
const stability = Math.max(1, (strength / 10) * (1 + 0.5 * count) * (0.5 + salience / 100) * 3);
const r = Math.exp(-days / stability);
const state = r >= MEMORY_FADED_AT ? "clear" : r >= MEMORY_FUZZY_AT ? "faded" : "fuzzy";
return {
retrievability: Math.round(r * 1000) / 1000,
state,
protected: false,
days: Math.round(days * 10) / 10,
stability: Math.round(stability * 10) / 10,
};
}
/**
* 依回想強度決定「這則記憶現在能講出多少」。
* clear 全部  faded 只剩主旨 / fuzzy 只剩「有這件事」
*/
/** 每輪最多注入幾條慣例(慣例是背景,不是話題)。 */
export const HABIT_INJECT_MAX = 2;
/**
* 現在還算數的慣例(`--type habit`)。
*
* 慣例跟其他記憶不一樣:它不是「記得的事」,是「每次都這樣做的事」。只在關鍵詞
* 對上的時候才 `recall` 出來的慣例等於沒有——沒有人會為了照慣例做事先去回想它。
* 所以它每輪注入,不必被問到。
*
* 糊掉的(`fuzzy`)不注入:想不起來的慣例就不是慣例了,那是「以前好像有這種習慣」。
*/
export function standingHabits(slug, { limit = HABIT_INJECT_MAX } = {}) {
return longTermEntries(slug)
.filter((meta) => String(meta.type || "") === "habit")
.map((meta) => ({ meta, strength: memoryStrength(meta) }))
.filter(({ strength }) => strength.state !== "fuzzy")
.sort((a, b) => b.strength.retrievability - a.strength.retrievability
|| String(b.meta.last_seen || "").localeCompare(String(a.meta.last_seen || "")))
.slice(0, Math.max(1, Math.min(Number(limit) || HABIT_INJECT_MAX, HABIT_INJECT_MAX)))
.map(({ meta, strength }) => ({
name: meta._name,
title: injectSafeLine(meta.title || meta._name),
gist: injectSafeLine(meta._gist || meta._body, 160),
state: strength.state,
}));
}
/** 注入用的一段:我們之間的慣例。沒有慣例就整段不出現。 */
export function habitBrief(slug, opts = {}) {
const rows = standingHabits(slug, opts);
if (!rows.length) return "";
return (
`我們之間的慣例(${rows.length} 條,**照做,不用提起它**):\n` +
rows.map((r) => ` - ${r.gist}${r.state === "faded" ? "(只記得大概)" : ""}`).join("\n") + "\n" +
" 慣例是背景不是話題:照著做就好,講出來(「我們不是都這樣嗎」)就變成在討論它。"
);
}
export function memoryRecalled(meta, now = Date.now()) {
const s = memoryStrength(meta, now);
if (s.state === "clear") {
return { ...s, gist: meta._gist || meta._body || "", detail: meta._detail || "", hint: "" };
}
if (s.state === "faded") {
return {
...s,
gist: meta._gist || (meta._body || "").split("\n")[0] || "",
detail: "",
hint: "細節想不起來了——主旨可以講,細節不可以補。",
};
}
const topics = Array.isArray(meta.topics) ? meta.topics : meta.topics ? [String(meta.topics)] : [];
return {
...s,
gist: "",
detail: "",
topics,
hint: "只剩「有這件事」——內容想不起來。要提就用試探句求證,不可以斷言。",
};
}
/**
* 就地把長期記憶檔升到新格式(補 `strength`、把內文切成主旨/細節)。
*
* 使用者拍板「一次改全部」,所以這支是冪等的:已經有 `strength` 的不動、
* 已經寫成 `主旨:` 的不重切。回報「動了幾個檔」,**不回報內容**。
*
* 舊檔沒有強度歷史,只好從既有欄位推:顯著度高的、被想起過很多次的,起點就高一點。
* 這比一律給 50 誠實——那會讓一則被想起十次的核心記憶跟昨天剛寫的閒事一樣脆弱。
*/
export function migrateLongTerm(slug, { dryRun = false } = {}) {
const stats = { total: 0, strength: 0, split: 0, skipped: 0 };
for (const meta of longTermEntries(slug)) {
stats.total += 1;
let text;
try {
text = fs.readFileSync(meta._path, "utf8");
} catch {
stats.skipped += 1;
continue;
}
// 沒有 frontmatter 的檔一律跳過。以前是硬插一行進去,等於憑空幫它生出
// 一個 front matter 區塊,而下面切內文那一段又會把整個檔當成 front matter
// 再複製一次內文——踩過一次,資料是永久損毀的。看不懂的檔就別動它。
if (!/^---\r?\n/.test(text)) {
stats.skipped += 1;
continue;
}
let changed = false;
if (!/^strength:/m.test(text)) {
const salience = clamp(Number(meta.salience ?? 50));
const count = Math.max(0, Math.floor(Number(meta.recall_count ?? 0)));
const derived = Math.round(clamp(40 + salience * 0.3 + count * 5));
// 每一種插法都要確認**真的插進去了**才記帳,否則 migrate 會回報
// 「補了 N 個」但硬碟上一個都沒補(回報比沒回報更糟)。
const before = text;
if (/^salience:(.*?)(\r?)$/m.test(text)) {
// 換行照原檔(CRLF 檔插一行 LF 進去雖然還讀得動,但會留下混行的檔案)
text = text.replace(/^salience:(.*?)(\r?)$/m, (m0, rest, cr) =>
`salience:${rest}${cr}\n${`strength: ${derived}`}${cr}`);
} else {
text = text.replace(/^---(\r?\n)/, `---$1strength: ${derived}$1`);
}
if (text !== before) {
stats.strength += 1;
changed = true;
}
}
// 這裡的錨點要跟 `splitGistDetail` 一致(字串開頭,不帶 `m`):
// 帶 `m` 的話「前言\n\n主旨:A」會被誤判成已經切過,那個檔的主旨永遠是錯的。
if (!/^\s*(?:主旨|gist)\s*[:]/.test(meta._body)) {
const { gist, detail } = splitGistDetail(meta._body);
// front matter 用 `parseFrontMatter` 同一套規則切(`---` 而不是 `\n---\n`),
// 才不會被 CRLF 或內文裡的 markdown 分隔線騙過去。
const eol = text.includes("\r\n") ? "\r\n" : "\n";
const parts = text.split("---");
if (gist && parts.length >= 3) {
const front = `---${parts[1]}---`;
text = `${front}${eol}${eol}${joinGistDetail(gist, detail).replace(/\n/g, eol)}${eol}`;
stats.split += 1;
changed = true;
}
}
if (changed && !dryRun) {
try {
fs.writeFileSync(meta._path, text, "utf8");
} catch {
stats.skipped += 1;
}
}
}
return stats;
}
/**
* 寫一則長期記憶檔(一則一檔)。`consolidate` 與故事匯入(`novel write`)都走這裡。
*
* front matter 的欄位順序與預設值只能有一份:兩邊各寫一次的話,多一個欄位就會有一邊
* 漏掉,而漏掉的那邊要等到 `memoryStrength` 算出怪數字才會被發現。
*
* `name` 要先 `slugify` 過(呼叫端得拿它做撞名檢查,見 consolidate);`extra` 是額外的
* 一行一欄位(故事匯入的 `date_source``know_level`),值一樣只能是一行。
*/
export function writeLongTermMemory(slug, {
name,
title = "",
type = "fact",
body = "",
gist = null,
detail = "",
about = [],
topics = [],
salience = 60,
strength = null,
emotion = "",
when = "",
where = "",
mood = "",
rules = "",
source = "",
firstSeen = "",
lastSeen = "",
extra = {},
} = {}) {
const file = path.join(longTermDir(slug), `${name}.md`);
let existing = {};
if (fs.existsSync(file)) [existing] = parseFrontMatter(fs.readFileSync(file, "utf8"));
const today = nowIso().slice(0, 10);
const stamp = contextStamp(slug);
// `gist` 明寫時,`detail` 沒給就是**沒有細節**——退回整份 body 會讓主旨之外
// 再抄一次全文,那是把「衰減先吃細節」這件事整個抵銷掉。
const split = gist === null || gist === "" ? splitGistDetail(body) : { gist, detail };
// front matter 的每個值都只能是一行:帶換行的值能偽造出別的欄位,
// 而 `parseFrontMatter` 取後出現的值 → 後面宣告的 type/salience 會被前面偽造的蓋掉。
const fm = (value) => injectSafeLine(value);
// `about` 是自由字串(原樣保留);對得上關係圖節點的才多寫一行 id,之後 recall 與
// 「人際關係」那段才知道講的是同一個人。一個都對不上就不輸出這一行。
// `resolveRelationRefs` 已經把不能寫進 front matter 的 id 濾掉了(帶換行的節點 id
// 可以在這裡多插一行、覆寫下面的 `type`,把一則 fact 變成不該被遺忘的 canon)。
const aboutIds = resolveRelationRefs(slug, asList(about));
const stamped = { when, where, mood };
const front = [
"---",
`name: ${name}`,
// 沒被 slugify 吃掉的那個名字。下次撞名時就是靠這行認出「不是同一則」。
`title: ${String(title || name).replace(/[\r\n]+/g, " ").replace(/-{3,}/g, "—").trim().slice(0, 120)}`,
`type: ${type}`,
`about: [${asList(about).map(fm).filter(Boolean).join(", ")}]`,
...(aboutIds.length ? [`about_ids: [${aboutIds.join(", ")}]`] : []),
`topics: [${asList(topics).map(fm).filter(Boolean).join(", ")}]`,
`salience: ${salience}`,
// 回想強度:連續衰減的起點。已經存在的記憶保留自己長出來的強度,
// 不要因為重寫一次就把「被想起過很多次」的歷史抹平。
`strength: ${strength ?? Number(existing.strength ?? MEMORY_STRENGTH_DEFAULT)}`,
`emotion: ${fm(emotion) || "none"}`,
// 情境索引:什麼時候、在哪裡、什麼心情記下來的(回想時的非語意線索)。
// 推不出來的欄位就不寫——全專案一致:沒有值不留空欄位。
...["when", "where", "mood"]
.map((k) => [k, fm(stamped[k] || existing[k] || stamp[k] || "")])
.filter(([, v]) => v)
.map(([k, v]) => `${k}: ${v}`),
`rules: ${fm(rules) || "manual"}`,
`first_seen: ${fm(firstSeen) || existing.first_seen || today}`,
`last_seen: ${fm(lastSeen) || today}`,
`recall_count: ${existing.recall_count || 0}`,
`source: ${fm(source) || "short-term"}`,
// 匯入專用的追溯欄位(`date_source` 分得出「原作明寫」與「推算的」,
// `know_level` 記他是怎麼知道這件事的)。沒有值的一律不寫。
...Object.entries(extra)
.map(([k, v]) => [fm(k), fm(v)])
.filter(([k, v]) => k && v)
.map(([k, v]) => `${k}: ${v}`),
"---",
"",
// 主旨/細節兩層:衰減先吃細節,主旨最後才掉。沒有明寫就用第一段當主旨。
joinGistDetail(split.gist, split.detail),
"",
];
writeText(file, front.join("\n"));
return { file, name, existing };
}
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 about = Array.isArray(meta.about) ? meta.about : meta.about ? [String(meta.about)] : [];
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("/") : "-"}` +
`|關於 ${about.length ? about.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;
}
// 情境與情緒一致性的加權。語意線索(關鍵詞)仍然是主力,這幾條只是**排序的偏好**:
// 一個關鍵詞命中值 10,情境最多只加 6,所以它永遠翻不掉真正對題的那一則。
export const RECALL_MOOD_BONUS = 3; // 跟現在同一種心情下記的
export const RECALL_WHEN_BONUS = 1.5; // 同一個時段
export const RECALL_WHERE_BONUS = 1.5; // 同一個地方
// 想不太起來的往後排,但**只能扣分,不能打折**。
// 打折(`score *= 0.5 + 0.5*r`)會把總分砍掉一半以上,等於扣十幾分——
// 實測會讓「命中兩個關鍵詞但三個月沒想起」的正確答案掉到榜尾,
// 被一堆命中一個關鍵詞的今日閒事擠掉。加權永遠不准翻掉語意命中,
// 所以情境(最多 +6)與模糊(最多 −3.5)加起來要小於一個關鍵詞的 10 分。
export const RECALL_FADE_PENALTY = 3.5;
/**
* 以關鍵詞比對長期記憶(name/topics/body),回傳最相關的幾則。
*
* `opts.state` 帶了(或 slug 讀得到情緒)就會加上情境與情緒一致性加權:
* 心情差的時候先想起難過的事,同一個時段、同一個地方記的事互相勾得出來。
* 每則都會附上 `_recall`(回想強度與模糊態),呼叫端決定要講多少。
*/
export function recall(slug, query, limit = 5, opts = {}) {
const { state = null, context = true } = opts || {};
const nowMs = Date.now();
let here = null;
if (context) {
try {
here = contextStamp(slug, state);
} catch {
here = null;
}
}
const tokens = keywords(query, 16);
const keySet = new Set(tokens.map((k) => k.toLowerCase()));
// 同一個人有很多寫法:關鍵詞若指到某個關係節點,就把節點的 **name** 也當關鍵詞。
// 這樣 `about: 小林先生` 的記憶被 `xiao-lin` 問起也撈得到。
//
// 節點的 **id 不當關鍵詞**:id 是內部識別,不是人會說出口的字,而它命中率高得離譜——
// 只要關係圖有一個 id 為 `user` 的節點(`relation speaker` 的正常用法),
// `user` 就會命中每一則 `about: [user]``consolidate` 沒帶 `--about` 時的預設值)的記憶。
// 一個 keyword 命中值 +10,而 `salience/10` 整個值域才 0-10 → 顯著度 75 的正確答案
// 會被一堆顯著度 18 的閒事擠出榜(實測「週報什麼時候交」的正確答案掉到第 9 名)。
const nodes = loadRelations(slug).nodes; // 一次讀完(以前每個 token 都重讀一遍 graph.json
for (const token of tokens) {
const node = findRelationNode(slug, token, nodes);
if (node?.name) keySet.add(String(node.name).toLowerCase());
}
const keys = [...keySet];
const scored = [];
for (const meta of longTermEntries(slug)) {
// `about_ids` 刻意**不進 haystack**:它是給 `turnContext` 認人用的內部 id
// 拿它計分等於讓「有沒有做過遷移」決定召回名次(沒有 about_ids 的既有記憶固定少算一個命中)。
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) continue;
let score = hits * 10 + Number(meta.salience || 0) / 10;
// 情境線索:同心情/同時段/同地點的記憶更容易被勾出來
if (here) {
if (meta.mood && here.mood && meta.mood === here.mood) score += RECALL_MOOD_BONUS;
if (meta.when && here.when && meta.when === here.when) score += RECALL_WHEN_BONUS;
if (meta.where && here.where && meta.where === here.where) score += RECALL_WHERE_BONUS;
}
// 想不太起來的排後面,但**不排除**——模糊態也是一種記得
const strength = memoryStrength(meta, nowMs);
meta._recall = strength;
score -= RECALL_FADE_PENALTY * (1 - strength.retrievability);
scored.push({ score, 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);
}
/** 這筆短期記憶已經被判斷過了嗎(固化了、或看過決定不固化)。 */
export const shortTermReviewed = (row) => Boolean(row?.reviewed_at);
/**
* 掃短期記憶,依 PROMOTION_RULES 算出「該轉入長期記憶」的候選。
* 回傳 { total, reviewed, pressure, candidates: [{ rules, key, kind, entries, suggested_type, suggested_salience }] }
*
* **只看還沒被判斷過的那些**(沒有 `reviewed_at`)。以前這裡是無條件掃全部,
* 而 `consolidate` 不會在來源那筆上留任何痕跡、也不刪它(`--forget` 是按顯著度刪、
* 不是按「固化過沒有」刪)——所以同一批短期記憶每輪都被重算成候選,
* 提醒的數字只會往上爬,睡再多次也不會降。看起來像睡眠沒做固化,其實是計數從來沒扣過。
*/
export function promotionCandidates(slug) {
const all = readJsonl(shortTermPath(slug));
const total = all.length;
const reviewed = all.filter(shortTermReviewed).length;
// 判斷過的不再進候選,但保留原本的位置:`_index` 是 `consolidate --from-short`
// 與 `candidates --reviewed` 用來指名哪幾筆的座標,錯位就會標到別人身上。
const rows = all.map((row, index) => ({ ...row, _index: index })).filter((row) => !shortTermReviewed(row));
// 關係圖只讀一次:這個函式在每次 `remember` 之後都會跑,以前是「每筆的每個 entity
// 各讀一遍 graph.json」(實測 30 節點/240 筆 = 720 次讀檔、20ms2000 節點時單輪 1.2 秒)。
const nodes = loadRelations(slug).nodes;
const byTopic = new Map();
const byEntity = new Map();
const singles = [];
rows.forEach((row) => {
const entry = row;
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);
}
// 「小林」與「小林先生」是同一個人:先對到關係節點 id 再當 key,不同寫法才會累加成 R5。
// 寫入時解析好的 `row.entity_ids` 直接吃(不要在統計時重新解析、得到跟資料裡不同的答案);
// 剩下的名字對不到節點就退回原字串(跟以前一樣)。同一筆記憶在同一個 key 下只算一次。
const keys = new Set(safeRelationIds(row.entity_ids));
for (const entity of row.entities || []) {
const id = resolveRelationRefs(slug, [entity], nodes)[0];
const key = id || String(entity ?? "").trim();
if (key) keys.add(key);
}
for (const key of keys) {
if (!byEntity.has(key)) byEntity.set(key, []);
byEntity.get(key).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 && rows.length) {
const top = [...rows]
.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, reviewed, pressure, candidates: [...merged.values()] };
}
/**
* 把短期記憶標成「判斷過了」。這是候選計數唯一會往下扣的地方。
*
* 兩種痕跡分開記,因為它們的意思不同:
* `reviewed_at` 看過、判斷過了(不管有沒有固化)——候選就是看這個欄位
* `promoted_to` 固化成了哪一則長期記憶(只有真的寫成長期記憶才有)
*
* 「看過但決定不記」跟「已經記下來了」都不該再被算成候選,但事後回頭查
* 「這則長期記憶是從哪幾筆長出來的」只能靠 `promoted_to`,所以不能合成一個欄位。
*
* @param {object} opts
* @param {number[]|null} opts.indexes 要標的筆(`promotionCandidates` 給的 `_index`);null=全部
* @param {string|null} opts.until 只標這個時間之前的(ISO),配 `indexes: null` 用
* @param {string|null} opts.promotedTo 固化成哪一則長期記憶(有值才寫 `promoted_to`
*/
export function markShortTermReviewed(slug, { indexes = null, until = null, promotedTo = null } = {}) {
const wanted = indexes === null ? null : new Set(indexes.map(Number).filter(Number.isInteger));
const cutoff = until ? parseIso(until)?.getTime() ?? null : null;
const at = nowIso();
let marked = 0;
let already = 0;
rewriteJsonl(shortTermPath(slug), (all) => all.map((row, index) => {
if (wanted && !wanted.has(index)) return row;
if (cutoff !== null && (parseIso(row.ts)?.getTime() ?? 0) > cutoff) return row;
if (shortTermReviewed(row) && !promotedTo) {
already += 1;
return row;
}
marked += 1;
return {
...row,
reviewed_at: row.reviewed_at || at,
...(promotedTo ? { promoted_to: promotedTo } : {}),
};
}), { force: true });
return { marked, already };
}
/**
* 被回想到就更新 last_seen / recall_count / strength(記憶越常用越不易被淘汰)。
*
* 強度往上加就是 spacing effect:想起來一次,下次能撐更久。這條讓
* 「常被提起的事永遠清晰、被冷落的事慢慢糊掉」自己長出來,不用另外排程。
*/
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;
const strength = clamp(Number(meta.strength ?? MEMORY_STRENGTH_DEFAULT) + MEMORY_STRENGTH_GAIN);
text = text.replace(/^recall_count:.*$/m, `recall_count: ${count}`);
text = text.replace(/^last_seen:.*$/m, `last_seen: ${today}`);
// 舊檔沒有 strength 這一行:補在 salience 後面(migration 之前也要能長強度)。
// 連 salience 都沒有的檔要退回「插在開頭 `---` 的下一行」,否則 replace 靜默不命中,
// 那一類檔案的強度永遠長不出來(spacing effect 對它失效,而且沒有人會知道)。
if (/^strength:.*$/m.test(text)) {
text = text.replace(/^strength:.*$/m, `strength: ${Math.round(strength)}`);
} else if (/^salience:(.*)$/m.test(text)) {
text = text.replace(/^salience:(.*)$/m, `salience:$1\nstrength: ${Math.round(strength)}`);
} else if (/^---(\r?\n)/.test(text)) {
text = text.replace(/^---(\r?\n)/, `---$1strength: ${Math.round(strength)}$1`);
}
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-twMIT, 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");
/** 一則話裡超過這個數量才給提示(只是 hint,不擋——emoji 是開放的)。 */
export const EMOJI_HINT_AT = 6;
/**
* 只留「真的說出口、而且是在使用而不是在討論」的部分。
*
* 引號裡的原話與 `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] });
// emoji 是開放的(使用者拍板):情緒與程度靠它表示,句子裡也可以用、沒有上限。
// 所以這裡**不擋**,只在明顯過量時給一個 hint——真的失控看 `emoji --audit` 的數字,
// 用 linter 偷偷關回去等於把開放這件事撤銷掉。
const emoji = raw.match(EMOJI_RE) || [];
if (emoji.length > EMOJI_HINT_AT) issues.push({ kind: "emoji", level: "hint", count: emoji.length });
const dashes = (raw.match(/——/g) || []).length;
if (dashes > 1) issues.push({ kind: "dash", level: "block", count: dashes });
// 標點預算的上限要夾死,否則它會變成 emoji 的替代品:情緒不夠就多打幾個驚嘆號。
// 標點是**分布**,不是強度計——強度歸 emoji(那邊才是開放的)。
const exclaims = (body.match(//g) || []).length;
if (exclaims > MAX_EXCLAIM) {
issues.push({ kind: "exclaim", level: "block", count: exclaims, max: MAX_EXCLAIM });
}
// 連發:`⋯⋯⋯` 以上。中文的刪節號本來就常寫成兩個,所以三個起才算。
if (/[…⋯]{3,}/.test(raw)) issues.push({ kind: "ellipsis-run", level: "block" });
const ellipses = (body.match(/[…⋯]/g) || []).length;
if (ellipses > MAX_ELLIPSIS) {
issues.push({ kind: "ellipsis", level: "block", count: ellipses, max: MAX_ELLIPSIS });
}
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 "exclaim":
return `${issue.count} 個驚嘆號,上限 ${issue.max}——情緒的強度歸 emoji,標點只管分布`;
case "ellipsis-run":
return "刪節號連發了(`⋯⋯⋯`)——最多兩個,話講一半的效果靠句子斷掉,不靠符號拉長";
case "ellipsis":
return `${issue.count} 個刪節號,上限 ${issue.max}——再多就變成語氣的裝飾了`;
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);
}
/** 在 entriesneed `ts` / `text`)裡找出與 text 近似的一則;沒有就回 null。 */
export function findRepeat(entries, text, {
minutes = REPEAT_WINDOW_MINUTES,
threshold = REPEAT_THRESHOLD,
minChars = REPEAT_MIN_CHARS,
} = {}) {
if (normalizeSpeech(text).length < minChars) return null;
let best = null;
for (const row of entries || []) {
if (minutes !== null && ageSeconds(row.ts) > minutes * 60) continue;
const score = similarity(text, row.text || "");
if (score < threshold) continue;
if (!best || score > best.similarity) {
best = {
similarity: score,
at: row.ts,
text: String(row.text || "").slice(0, 200),
minutes_ago: Math.round(ageSeconds(row.ts) / 60),
};
}
}
return best;
}
function trimJsonl(file, keep) {
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);
/**
* emoji 用量稽核:開放沒有上限,所以唯一的煞車是**看得見的數字**。
*
* 用 linter 把它關回去等於撤銷「開放」這個決定,所以這裡只算不擋:
* 幾則帶了符號、平均幾個、最多的那一則長什麼樣,以及**只有符號沒有句子**的那幾則
* (那才是真正的退化——拿貼圖代替講話)。
*/
export function emojiAudit(slug, limit = 60) {
const rows = readJsonl(saidPath(slug), limit);
let withEmoji = 0;
let total = 0;
let symbolOnly = 0;
let worst = null;
for (const row of rows) {
const text = String(row.text || "");
const hits = (text.match(EMOJI_RE) || []).length;
if (!hits) continue;
withEmoji += 1;
total += hits;
if (!worst || hits > worst.count) worst = { count: hits, text: text.slice(0, 60) };
// 把符號與標點拿掉之後沒剩幾個字=這一則其實沒在講話
const bare = text.replace(EMOJI_RE, "").replace(/[\s。,、!?…「」()]/g, "");
if ([...bare].length <= 2) symbolOnly += 1;
}
return {
rows: rows.length,
with_emoji: withEmoji,
total,
average: withEmoji ? Math.round((total / withEmoji) * 10) / 10 : 0,
symbol_only: symbolOnly,
worst,
};
}
// --------------------------------------------------------------------------- //
// 試探與稽核(`state/probe.jsonl`
//
// 模糊態允許「是不是上個月那次?」這種試探性求證——使用者明示要開放這條。
// 但它同時是幻覺的側門,所以側門必須裝計數器:**每一次試探都留下紀錄,
// 被否認也要留**。查得到「我試探了幾次、錯了幾次」,這條路才敢開。
//
// 界線只有一條,寫在 anti-ai-voice.md 與 SKILL 第 ② 節:
// 可以說不確定,可以問;**不可以斷言**。試探句一定帶問號,一輪最多一次。
// --------------------------------------------------------------------------- //
export const probePath = (slug) => path.join(personaDir(slug), "state", "probe.jsonl");
export const PROBE_KEEP = 300;
// 試探的句型:帶不確定的措辭 + 問號。斷言句沒有問號,所以擋得住「上個月那次很慘」。
const PROBE_RE = /(是不是|對吧|對嗎|我記得.*嗎|好像.*吧|還是.*那次|是你說的嗎|沒記錯的話)/;
/** 這句話是不是「試探性求證」(可以說不確定,但一定要在問)。 */
export function looksLikeProbe(text) {
const s = String(text || "");
return PROBE_RE.test(s) && /[?]/.test(s);
}
/** 這句話像是在補細節卻沒有在問——模糊態下這是要擋的形狀。 */
export function looksLikeFabrication(text) {
const s = String(text || "");
return /(我記得|上次|那天|上個月|上禮拜|你說過|你答應過)/.test(s) && !/[?]/.test(s);
}
/** 記一次試探。`memory` 是被試探的那則長期記憶名稱(沒有就 null)。 */
export function recordProbe(slug, { text, memory = null, retrievability = null } = {}) {
const body = injectSafeLine(text, 200);
if (!body) return null;
// `ts` 不能當識別:同一秒記兩筆試探就會撞在一起,結案時指到錯的那一筆。
// 這裡用時間戳加一段亂碼當 id,稽核與 `--at` 都認這個。
const entry = {
id: `${Date.now().toString(36)}-${crypto.randomBytes(3).toString("hex")}`,
ts: nowIso(),
text: body,
memory: memory ? injectSafeLine(memory, 80) : null,
retrievability: retrievability === null ? null : Math.round(Number(retrievability) * 1000) / 1000,
outcome: "pending",
};
appendJsonl(probePath(slug), entry);
trimJsonl(probePath(slug), PROBE_KEEP);
return entry;
}
/**
* 結案:對方確認了(`confirmed`)還是否認了(`denied`)。
* 沒指定 `at` 就結最近一筆還沒結案的。
*/
export function resolveProbe(slug, outcome = "confirmed", { at = null, note = "" } = {}) {
const want = ["confirmed", "denied", "unknown"].includes(outcome) ? outcome : "confirmed";
let hit = null;
// `force: true`:這是就地改欄位,陣列長度不變——不強制寫回的話這一筆結案會靜靜消失
rewriteJsonl(probePath(slug), (all) => {
const idx = at
? all.findIndex((r) => (r.id || r.ts) === at && r.outcome === "pending")
: all.map((r, i) => [r, i]).filter(([r]) => r.outcome === "pending").map(([, i]) => i).pop();
if (idx === undefined || idx === null || idx < 0) return all;
all[idx].outcome = want;
all[idx].resolved_at = nowIso();
if (note) all[idx].note = injectSafeLine(note, 120);
hit = all[idx];
return all;
}, { force: true });
return hit;
}
/**
* 稽核:試探了幾次、被否認幾次、還有幾次沒結案。
* 比照 `emotion --audit`——這個數字是這條開放界線唯一的煞車。
*/
export function probeAudit(slug, limit = 50) {
const rows = readJsonl(probePath(slug), limit);
const by = { pending: 0, confirmed: 0, denied: 0, unknown: 0 };
for (const row of rows) by[row.outcome] = (by[row.outcome] || 0) + 1;
const closed = by.confirmed + by.denied;
return {
total: rows.length,
...by,
denied_ratio: closed ? Math.round((by.denied / closed) * 100) : null,
recent: rows.slice(-5),
};
}
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;
}
// --------------------------------------------------------------------------- //
// 語氣層(`voice/`):他講過的原句、以及「事件 → 他做了什麼」
//
// 這一層跟 `SOUL.md` 刻意分開放。SOUL 是個性(Core TruthsBoundariesVibe),
// 只有使用者拍板才動;語氣是表面(他會說的字、遇到事會做的動作),故事匯入可以直接寫。
//
// 兩個檔都是給人讀的 markdown 清單(手改是預期用法),所以解析寬鬆:只認行首的 `- `、
// 欄位用全形 `|` 分隔、標籤缺了就照順序補位。而它們會被 `sync pull``import` 覆蓋,
// 所以讀進來的每一欄都是外部輸入 → 一律過 `injectSafeLine`。
// --------------------------------------------------------------------------- //
export const VOICE_KINDS = { sample: "samples.md", reaction: "reactions.md", idiolect: "idiolect.md" };
export const VOICE_LABELS = { sample: "語氣樣本", reaction: "情緒反應", idiolect: "語域" };
/**
* 語域的四格。
*
* 語氣樣本是**減法之外的加法**`SPEECH_BLACKLIST` 刪掉 AI 腔之後,剩下的是「乾淨的
* 中文」,不是「這個人的中文」。自稱、句尾、口癖才是把名字遮掉還認得出是誰的東西。
*
* 「不說」單獨一格而不是塞進禁忌:禁忌是**話題**(不能提的事),不說是**用詞**
* (這個人不會用的字)。混在一起會讓人格以為某個話題不能碰。
*/
export const IDIOLECT_FACETS = ["自稱", "句尾", "口癖", "不說"];
/** 每輪最多注入幾格語域(跟語氣樣本一樣:全注入會變成模仿腔)。 */
export const IDIOLECT_INJECT_MAX = 3;
// 每輪最多注入幾條。全注入會變成模仿腔——他會開始照抄自己的舊台詞,
// 那比沒有語氣樣本更糟(TODO 故事匯入階段 4 明寫)。
export const VOICE_INJECT_MAX = 3;
export const VOICE_KEEP = 400; // 一個檔最多認幾條(手改可以更多,注入端只看最後這些)
// 欄位順序=手改時可以省略標籤的順序。第一欄是主欄位,缺了整行就不算一筆。
const VOICE_FIELDS = {
sample: [["text", "原句"], ["to", "對象"], ["scene", "場合"]],
reaction: [["event", "事件"], ["action", "反應"], ["emotion", "情緒"]],
idiolect: [["facet", "格"], ["value", "值"]],
};
const VOICE_HEADERS = {
sample: [
"# 語氣樣本",
"",
"一行一句,他自己講過的原句(照抄不改寫):",
"`- 「原句」|對象:<誰>|場合:<戰鬥/日常/道別>`",
"",
],
reaction: [
"# 情緒反應",
"",
"一行一筆,事件對上他實際做了什麼(不要記「他感到什麼」):",
"`- 事件:<發生什麼>|反應:<他做了什麼>|情緒:<當時的情緒>`",
"",
],
idiolect: [
"# 語域",
"",
"把名字遮掉,光看三句話能認出是誰——靠的是這四格:",
"`- 格:自稱|值:我、俺`(其餘:句尾/口癖/不說)",
"",
"「不說」是**用詞**(這個人不會用的字),不是話題禁忌。",
"",
],
};
export function voicePath(slug, kind = "sample") {
const file = VOICE_KINDS[kind];
if (!file) throw new Error(`未知的語氣檔類型 \`${kind}\`(可用 ${Object.keys(VOICE_KINDS).join("")}`);
return path.join(personaDir(slug), "voice", file);
}
/** 一行清單 → 欄位物件。標籤(`對象:`)優先,沒帶標籤的照 `VOICE_FIELDS` 的順序補位。 */
function parseVoiceLine(kind, line) {
const fields = VOICE_FIELDS[kind] || [];
const out = {};
let next = 0;
for (const cell of String(line).replace(/^\s*[-*]\s+/, "").split("")) {
const one = injectSafeLine(cell);
if (!one) continue;
const m = one.match(/^([^:]{1,8})[:]\s*(.+)$/);
const named = m ? fields.find(([, label]) => label === m[1].trim()) : null;
if (named) {
out[named[0]] = m[2].trim();
continue;
}
while (next < fields.length && out[fields[next][0]] !== undefined) next += 1;
if (next >= fields.length) continue;
out[fields[next][0]] = one.replace(/^[「『]/, "").replace(/[」』]$/, "");
next += 1;
}
return out;
}
/** 欄位物件 → 一行清單(寫入端也過 `injectSafeLine`:換行會把一筆變成兩筆)。 */
function renderVoiceLine(kind, entry) {
const fields = VOICE_FIELDS[kind] || [];
const cells = [];
for (const [key, label] of fields) {
const value = injectSafeLine(entry?.[key], 240);
if (!value) continue;
if (key === fields[0][0]) cells.push(kind === "sample" ? `「${value}」` : value);
else cells.push(`${label}${value}`);
}
return `- ${cells.join("")}`;
}
/**
* 往語氣檔加一筆。同一筆匯入兩次不再多留一行——匯入流程會重跑同一章,
* 而重複的原句會讓 `voiceBrief` 每次都挑到同一句。
*/
function addVoiceLine(slug, kind, entry) {
const fields = VOICE_FIELDS[kind];
if (!fields) throw new Error(`未知的語氣檔類型 \`${kind}\`。`);
if (!injectSafeLine(entry?.[fields[0][0]])) return null;
const file = voicePath(slug, kind);
const line = renderVoiceLine(kind, entry);
return withFileLock(file, () => {
let text = null;
try {
text = fs.readFileSync(file, "utf8");
} catch {
text = null;
}
if (text !== null && text.split("\n").some((l) => l.trim() === line)) {
return { file, kind, line, added: false };
}
const head = text === null ? VOICE_HEADERS[kind].join("\n") : text.replace(/\s*$/, "");
writeText(file, `${head}\n${line}\n`);
return { file, kind, line, added: true };
});
}
export const addVoiceSample = (slug, { text, to = "", scene = "" } = {}) =>
addVoiceLine(slug, "sample", { text, to, scene });
export const addVoiceReaction = (slug, { event, action = "", emotion = "" } = {}) =>
addVoiceLine(slug, "reaction", { event, action, emotion });
/**
* 加一格語域。
*
* `facet` 必須是 `IDIOLECT_FACETS` 其中一格——自由欄位會長出「語氣」「風格」這種
* 什麼都能塞的格子,注入端就不知道該怎麼講給人格聽。
*/
export function addVoiceIdiolect(slug, { facet, value } = {}) {
const key = String(facet || "").trim();
if (!IDIOLECT_FACETS.includes(key)) return null;
return addVoiceLine(slug, "idiolect", { facet: key, value });
}
/** 讀整份語氣檔(`{ samples, reactions }`)。壞行、缺主欄位的行直接跳過。 */
export function loadVoice(slug) {
const out = { samples: [], reactions: [], idiolect: [] };
const bucket = { sample: "samples", reaction: "reactions", idiolect: "idiolect" };
for (const kind of Object.keys(VOICE_KINDS)) {
let text;
try {
text = fs.readFileSync(voicePath(slug, kind), "utf8");
} catch {
continue;
}
const rows = [];
for (const raw of text.split("\n")) {
if (!/^\s*[-*]\s+/.test(raw)) continue;
const entry = parseVoiceLine(kind, raw);
if (!entry[VOICE_FIELDS[kind][0][0]]) continue;
rows.push(entry);
}
out[bucket[kind]] = rows.slice(-VOICE_KEEP);
}
return out;
}
/**
* 這一輪要注入的語氣(最多 `VOICE_INJECT_MAX` 條)。
*
* 挑法:對得上這一輪對象或話題的優先,同分取後加入的(新的原句比舊的像現在的他)。
* 條數是硬上限,不是建議值——`--limit` 只能往下調。
*/
export function voiceBrief(slug, { limit = VOICE_INJECT_MAX, to = null, hint = "" } = {}) {
const voice = loadVoice(slug);
if (!voice.samples.length && !voice.reactions.length) return "";
const max = Math.max(1, Math.min(Number(limit) || VOICE_INJECT_MAX, VOICE_INJECT_MAX));
const who = injectSafeLine(to);
const text = String(hint || "");
const score = (entry) => {
let s = 0;
if (who && entry.to && (entry.to === who || who.includes(entry.to) || entry.to.includes(who))) s += 3;
if (entry.scene && text.includes(entry.scene)) s += 2;
if (entry.event && text.includes(entry.event)) s += 2;
return s;
};
const pick = (rows, n) => (n <= 0 ? [] : rows
.map((entry, i) => ({ entry, i, s: score(entry) }))
.sort((a, b) => (b.s - a.s) || (b.i - a.i))
.slice(0, n)
.map((r) => r.entry));
// 反應最多帶一條:它是「遇到事會做什麼」,一輪露一個就夠,多了就變成照劇本演。
const reactions = pick(voice.reactions, Math.min(1, max));
const samples = pick(voice.samples, max - reactions.length);
const lines = [];
if (samples.length) {
lines.push("他自己講過的原句(**學語氣,不要照抄這幾句**):");
for (const s of samples) {
const at = [s.to ? `對 ${s.to}` : "", s.scene].filter(Boolean).join("");
lines.push(` - 「${s.text}${at ? `${at}` : ""}`);
}
}
for (const r of reactions) {
lines.push(
`遇到「${r.event}」他做的是:${r.action || "(沒記下來)"}` +
`${r.emotion ? `(當時 ${r.emotion}` : ""}——**演出來,不要旁白說明**。`,
);
}
return lines.join("\n");
}
/**
* 這一輪要注入的語域(最多 `IDIOLECT_INJECT_MAX` 格)。
*
* 四格輪替,起點由**第幾輪**算出來(`felt.jsonl` 的筆數),不用隨機數——
* 隨機是這個專案明文禁止的充人味手段,而且會讓同一輪重跑出現不同結果。
*
* 一輪塞四格會變成模仿腔:他開始逐條照著演,每句都要有口癖。
*/
export function idiolectBrief(slug, { limit = IDIOLECT_INJECT_MAX, turn = null } = {}) {
const rows = loadVoice(slug).idiolect;
if (!rows.length) return "";
const byFacet = new Map();
for (const row of rows) {
if (!IDIOLECT_FACETS.includes(row.facet) || !row.value) continue;
if (!byFacet.has(row.facet)) byFacet.set(row.facet, []);
byFacet.get(row.facet).push(row.value);
}
const present = IDIOLECT_FACETS.filter((f) => byFacet.has(f));
if (!present.length) return "";
const max = Math.max(1, Math.min(Number(limit) || IDIOLECT_INJECT_MAX, IDIOLECT_INJECT_MAX));
const t = Number.isFinite(Number(turn)) ? Number(turn) : readJsonl(feltPath(slug), 400).length;
const start = ((t % present.length) + present.length) % present.length;
const take = Math.min(max, present.length);
const picked = Array.from({ length: take }, (_, i) => present[(start + i) % present.length]);
const rest = present.length - take;
return (
`語域(這一輪 ${take}${rest > 0 ? `,另外 ${rest} 格下一輪輪到` : ""}):` +
picked.map((f) => `${f}${byFacet.get(f).slice(-3).join("、")}`).join("") + "。\n" +
" 這是**加法**:黑名單刪掉的是 AI 腔,剩下的是乾淨的中文、不是他的中文,這幾格把他自己的講法補回去。" +
"照著用,但不要每句都塞——把名字遮掉、光看三句話認得出是誰就夠了。"
);
}
// --------------------------------------------------------------------------- //
// 故事匯入(`memory/import/<work>/`):機械的那半
//
// 分工照 TODO 故事匯入 Q2:判斷留給 skill(在場與知情、切場景、第一人稱摘要、
// 個性校正提案),機械的進這裡(正名、欄位驗證、跨章去重、配額重定標、批次寫入)。
// 一部作品一個工作區,四個檔:
//
// work.json 書名、slug、建立時間、顯著度配額
// names.json 正名表:譯名/簡稱/原文名 → 關係節點的 name
// skipped.jsonl 跳過紀錄(靜默跳過會漏章,事後查不出來)
// candidates.jsonl 記憶候選(一章一批 append,全書跑完才收斂)
// --------------------------------------------------------------------------- //
/** 記憶候選的 `type`:只有這幾種。`fact`/`diary` 不在裡面——那兩種不是從書裡讀來的。 */
export const NOVEL_TYPES = ["canon", "event", "insight", "promise", "boundary"];
/**
* `first_seen` 的來源。換算成西元日期換來一個新風險:猜出來的日期看起來跟原作
* 明寫的一模一樣。所以日期旁邊一定要有這一欄(TODO 故事匯入 Q3 追加的規則)。
*/
export const NOVEL_DATE_SOURCES = {
canon: "原作明寫的日期",
derived: "由明寫的日期推算",
guess: "只能抓大概(回答日期類問題時不可以斷言)",
};
/**
* 知情層級:這一欄決定那則記憶能不能被他當成自己的事講出來。
*
* 這是整個匯入流程**唯一真正危險的一欄**。小說裡大半的資訊是作者寫給讀者看的
* (別人的內心話、他不在場那一幕的細節),寫成他的 `event` 之後他會拿來回答問題,
* 而且沒有任何機械檢查得出來——所以 `none` 在寫入前就要擋成 `canon`。
*
* 鍵用英文(跟 `type``date_source` 一致,front matter 不混中英)。
*/
export const NOVEL_KNOW_LEVELS = {
did: "我做的",
saw: "我看到的",
told: "別人告訴我的",
later: "事後才知道",
none: "他不在場也沒人告訴他(只能進 canon)",
};
/** 顯著度配額:`{ 門檻: 最多幾則 }`。避免整本書都是 80。 */
export const NOVEL_DEFAULT_QUOTA = { 90: 5, 80: 20 };
export const NOVEL_DEMOTE_STEP = 5; // 超過配額就往下壓一級(90+ → 85、80-89 → 75
export const NOVEL_BASELINE_GAP = 10; // 情緒基線任一格差這麼多就要停下來給人看(TODO Q4)
export const novelDir = (slug) => path.join(personaDir(slug), "memory", "import");
export const novelWorkDir = (slug, work) => path.join(novelDir(slug), slugify(work));
export const novelWorkPath = (slug, work) => path.join(novelWorkDir(slug, work), "work.json");
export const novelNamesPath = (slug, work) => path.join(novelWorkDir(slug, work), "names.json");
export const novelSkippedPath = (slug, work) => path.join(novelWorkDir(slug, work), "skipped.jsonl");
export const novelCandidatesPath = (slug, work) => path.join(novelWorkDir(slug, work), "candidates.jsonl");
export const novelProposedPath = (slug, work) => path.join(novelWorkDir(slug, work), "names-proposed.json");
export function listNovelWorks(slug) {
let entries = [];
try {
entries = fs.readdirSync(novelDir(slug), { withFileTypes: true });
} catch {
return [];
}
return entries
.filter((e) => e.isDirectory())
.map((e) => readJson(novelWorkPath(slug, e.name)))
.filter(Boolean);
}
export const loadNovelWork = (slug, work) => readJson(novelWorkPath(slug, work));
/** `"90=5,80=20"` → `{ 90: 5, 80: 20 }`。看不懂的段落直接跳過(配額寫錯不該讓匯入停擺)。 */
export function parseNovelQuota(raw) {
const out = {};
for (const chunk of String(raw ?? "").split(",")) {
const [key, value] = chunk.split("=").map((s) => String(s ?? "").trim());
const floor = Number(key);
const limit = Number(value);
if (!Number.isFinite(floor) || floor < 0 || floor > 100) continue;
if (!Number.isFinite(limit) || limit < 0) continue;
out[Math.round(floor)] = Math.round(limit);
}
return Object.keys(out).length ? out : null;
}
export function initNovelWork(slug, { work, workSlug = "", quota = null } = {}) {
const title = injectSafeLine(work, 120);
if (!title) return null;
const key = slugify(workSlug || title);
const file = novelWorkPath(slug, key);
return updateJson(file, (prev) => ({
work: title,
slug: key,
created_at: prev?.created_at || nowIso(),
updated_at: nowIso(),
quota: quota || prev?.quota || { ...NOVEL_DEFAULT_QUOTA },
baseline: prev?.baseline ?? null,
}));
}
/**
* 正名表:`{ map, ignore }`。
*
* map 譯名/簡稱/原文名 → 關係節點的 `name`(`about` 照這張表正名)
* ignore 使用者確認過「這不是人名」的詞(地名、系統詞、抽名字時的誤判)
*
* `ignore` 存在的理由是抽名字的啟發式**擋不完**——擋不完是正常的,
* 所以要有地方記住「這個看過了,不是人」,不然每加一章就要重看同一批誤判。
*/
export function loadNovelNameBook(slug, work) {
const data = readJson(novelNamesPath(slug, work), {}) ?? {};
return {
map: data.map && typeof data.map === "object" ? data.map : {},
ignore: asList(data.ignore).map((t) => injectSafeLine(t, 80)).filter(Boolean),
};
}
export const loadNovelNames = (slug, work) => loadNovelNameBook(slug, work).map;
/**
* 正名表加一條:`from`(譯名/簡稱)→ `to`(關係節點的 `name`)。
*
* 存的是節點的 `name` 而不是呼叫端給的字串:`findRelationNode` 最後一段是子字串比對,
* 「亞絲娜」對得上「結城明日奈」這種節點時,存原字串會讓 `about` 之後又對不上
* `resolveRelationRefs` 只認完全相等)。正名的意義就是把它一次對死。
*/
export function addNovelName(slug, work, { from, to } = {}) {
const src = injectSafeLine(from, 80);
const node = findRelationNode(slug, injectSafeLine(to, 80));
if (!src || !node) return null;
const name = injectSafeLine(node.name || node.id, 80);
updateJson(novelNamesPath(slug, work), (prev) => {
const data = prev && typeof prev === "object" ? prev : {};
data.map = data.map && typeof data.map === "object" ? data.map : {};
data.map[src] = name;
data.updated_at = nowIso();
return data;
}, {});
return { from: src, to: name, node_id: node.id };
}
// --------------------------------------------------------------------------- //
// 正名表的主路徑:從章節裡掃人名候選 → 使用者確認
//
// 問使用者「你這批是哪個譯本、有哪些譯名」是要他猜;直接讀章節裡真的出現什麼名字
// 才是看得見的字。所以 `novel name add` 只留著補漏,主路徑是掃描加確認兩段。
// --------------------------------------------------------------------------- //
const NOVEL_HAN = "\\u3400-\\u4dbf\\u4e00-\\u9fff";
const NOVEL_KATAKANA = "\\u30a1-\\u30fa\\u30fc";
// 說話動詞與敬稱刻意只列這些:這兩條是誤判率最低的來源(會說話的、被加敬稱的,
// 幾乎一定是人),列得越寬就越像第 4 條那種高頻詞規則,誤判要使用者一條一條看。
const NOVEL_SPEECH_VERBS = "說|道|問|喊|笑|點頭|低聲|回答";
const NOVEL_HONORIFICS = "先生|小姐|桑|君|醬|大人|隊長|團長";
export const NOVEL_NAME_MIN_COUNT = 3; // 高頻詞規則的門檻:出現這麼多次才算候選
/** 高頻詞規則最常誤判的那幾類(地名與方位、系統詞、一般名詞)。擋不完,所以另有 ignore。 */
export const NOVEL_NAME_STOPWORDS = new Set([
"迷宮區", "主街區", "圈內", "圈外", "樓層",
"視窗", "選單", "道具", "技能", "任務", "公會", "玩家", "等級", "經驗值",
"時候", "事情", "樣子", "東西", "意思", "感覺", "聲音", "身體", "眼睛",
]);
// 中文沒有詞界:`([HAN]{2,4})說` 這種規則會從動詞往前吃滿四個字,於是
// 「然後亞絲娜說」抓到的是「後亞絲娜」。名字幾乎不會用這些字開頭,所以只要
// 詞還剩兩個字以上就把它剝掉——這是停用詞表的同一件事,只是作用在第一個字上。
const NOVEL_NAME_LEAD_STRIP = new Set([
..."然後是也就又都而則卻才還再便只並把被個的了在和與但可要會",
]);
/**
* 從一章的文字抽人名候選 → `Map<token, { count, samples }>`。
*
* 四條啟發式,只有這四條:
* 1. 對話歸屬:`「…」` 前後緊接的 2 到 4 字詞 + 說話動詞
* 2. 敬稱結尾(token 收整個字面:書裡出現的是「小林先生」,不是「小林」)
* 3. 片假名連續 2 字以上(日文原文來源的名字)
* 4. 高頻的 2 到 4 字連續詞(出現 >= 3 次且不在停用詞表裡)
*
* `count` 是那個詞在全文出現的次數(不是命中幾條規則)——使用者要判斷「這是不是人」
* 時,看的是它出現得多不多,而不是我用哪條規則抓到它。
*/
export function extractNovelNameTokens(text) {
const src = String(text ?? "");
const found = new Set();
const verbTail = new RegExp(`(?:${NOVEL_SPEECH_VERBS})$`);
const push = (value) => {
let one = injectSafeLine(value, 40);
// 動詞也可能是兩個字(低聲):「桐人低聲問」的前四個字就是「桐人低聲」。
// 尾巴的動詞剝掉,剩下的才是名字。
while (one.length > 2 && verbTail.test(one)) one = one.replace(verbTail, "");
while (one.length > 2 && NOVEL_NAME_LEAD_STRIP.has(one[0])) one = one.slice(1);
if (one.length >= 2) found.add(one);
};
const speechAfter = new RegExp(`」\\s*([${NOVEL_HAN}]{2,4})\\s*(?:${NOVEL_SPEECH_VERBS})`, "g");
const speechBefore = new RegExp(`([${NOVEL_HAN}]{2,4})\\s*(?:${NOVEL_SPEECH_VERBS})[^「」]{0,4}「`, "g");
for (const m of src.matchAll(speechAfter)) push(m[1]);
for (const m of src.matchAll(speechBefore)) push(m[1]);
for (const m of src.matchAll(new RegExp(`[${NOVEL_HAN}]{1,4}(?:${NOVEL_HONORIFICS})`, "g"))) push(m[0]);
for (const m of src.matchAll(new RegExp(`[${NOVEL_KATAKANA}]{2,}`, "g"))) push(m[0]);
// 第 4 條:n-gram 一定會連碎片一起達標(「亞絲娜」出現幾次,「亞絲」「絲娜」就出現幾次)。
// 被更長的候選包住、又沒有比它更常出現的,就是同一個名字的碎片——留著只會讓
// 使用者看三遍同一個人,所以長的優先、短的丟掉。
const grams = new Map();
for (const run of src.match(new RegExp(`[${NOVEL_HAN}]{2,}`, "g")) || []) {
for (let n = 4; n >= 2; n -= 1) {
for (let i = 0; i + n <= run.length; i += 1) {
const gram = run.slice(i, i + n);
grams.set(gram, (grams.get(gram) || 0) + 1);
}
}
}
// 停用詞不是「跳過」而是「留著擋碎片」:「迷宮區」被擋掉之後,它的碎片「迷宮」
// 「宮區」次數一樣多,照樣會達標——所以達標的詞全部進 kept 當覆蓋範圍,
// 只是停用詞本身不列成候選。
const kept = [];
for (const [gram, count] of [...grams.entries()]
.filter(([, c]) => c >= NOVEL_NAME_MIN_COUNT)
.sort((a, b) => b[0].length - a[0].length || b[1] - a[1])) {
if (kept.some(([long, longCount]) => long.includes(gram) && longCount >= count)) continue;
kept.push([gram, count]);
if (!NOVEL_NAME_STOPWORDS.has(gram)) push(gram);
}
const out = new Map();
for (const token of found) {
if (NOVEL_NAME_STOPWORDS.has(token)) continue;
const samples = [];
let idx = src.indexOf(token);
while (idx >= 0 && samples.length < 2) {
const from = Math.max(0, idx - 24);
const sample = injectSafeLine(src.slice(from, from + 60).replace(/\s+/g, " "), 60);
if (sample) samples.push(sample);
idx = src.indexOf(token, idx + token.length + 40);
}
out.set(token, { count: src.split(token).length - 1, samples });
}
return out;
}
/**
* 候選 token 對得上關係圖多少:
* exact 等於某個節點的 name(或 id)——沒有判斷空間,可以整批收
* alias 等於某個節點的 tags,或在它的 note 裡出現過
* fuzzy 跟某個節點的名字共用兩個以上的字(差一個字的譯名、帶敬稱的稱呼)
* unknown 對不上任何節點——這是新人物,要先建節點
*/
export function novelNameConfidence(token, nodes) {
const key = String(token ?? "").trim();
const pool = nodes || [];
const nameOf = (node) => injectSafeLine(node.name || node.id, 80);
if (!key) return { confidence: "unknown", guess: null };
const exact = pool.find(
(n) => String(n?.name ?? "").trim() === key || String(n?.id ?? "").trim() === key,
);
if (exact) return { confidence: "exact", guess: nameOf(exact) };
const alias = pool.find(
(n) => asList(n?.tags).some((t) => String(t ?? "").trim() === key) || String(n?.note ?? "").includes(key),
);
if (alias) return { confidence: "alias", guess: nameOf(alias) };
const chars = new Set([...key]);
let best = null;
for (const node of pool) {
let shared = 0;
for (const ch of new Set([...String(node?.name ?? "")])) if (chars.has(ch)) shared += 1;
if (shared >= 2 && (!best || shared > best.shared)) best = { node, shared };
}
if (best) return { confidence: "fuzzy", guess: nameOf(best.node) };
return { confidence: "unknown", guess: null };
}
const NOVEL_CONFIDENCE_ORDER = { exact: 0, alias: 1, fuzzy: 2, unknown: 3 };
/**
* 掃幾份章節文字,把還沒確認的人名候選累積進 `names-proposed.json`。
*
* 已經在 `map` 或 `ignore` 裡的 token 一律跳過:第二次掃只會列出新出現的,
* 不然每加一章就要重看同一批。
*/
export function scanNovelNames(slug, work, texts) {
const { map, ignore } = loadNovelNameBook(slug, work);
const nodes = loadRelations(slug).nodes || [];
const known = new Set([...Object.keys(map), ...ignore]);
let result = { candidates: [], fresh: [] };
updateJson(novelProposedPath(slug, work), (prev) => {
const byToken = new Map();
for (const row of asList(prev?.candidates)) {
const token = injectSafeLine(row?.token, 40);
if (token && !known.has(token)) byToken.set(token, { ...row, token });
}
const fresh = [];
for (const text of asList(texts)) {
for (const [token, hit] of extractNovelNameTokens(text)) {
if (known.has(token)) continue;
const conf = novelNameConfidence(token, nodes);
const exists = byToken.get(token);
if (exists) {
exists.count = (Number(exists.count) || 0) + hit.count;
exists.confidence = conf.confidence;
exists.guess = conf.guess;
if (!asList(exists.samples).length) exists.samples = hit.samples;
continue;
}
const row = { token, count: hit.count, samples: hit.samples, guess: conf.guess, confidence: conf.confidence };
byToken.set(token, row);
fresh.push(row);
}
}
const candidates = [...byToken.values()].sort(
(a, b) => (NOVEL_CONFIDENCE_ORDER[a.confidence] ?? 9) - (NOVEL_CONFIDENCE_ORDER[b.confidence] ?? 9)
|| (Number(b.count) || 0) - (Number(a.count) || 0),
);
result = { candidates, fresh };
return { updated_at: nowIso(), candidates };
}, {});
return result;
}
export function loadNovelProposals(slug, work) {
return asList(readJson(novelProposedPath(slug, work), {})?.candidates).filter(
(row) => row && injectSafeLine(row.token, 40),
);
}
/** 從候選清單移掉幾個 tokenconfirmignorereject 都要做這件事)。 */
function dropNovelProposals(slug, work, tokens) {
const drop = new Set(asList(tokens).map((t) => injectSafeLine(t, 40)).filter(Boolean));
if (!drop.size) return 0;
let removed = 0;
updateJson(novelProposedPath(slug, work), (prev) => {
const all = asList(prev?.candidates);
const rows = all.filter((r) => !drop.has(injectSafeLine(r?.token, 40)));
removed = all.length - rows.length;
return { updated_at: nowIso(), candidates: rows };
}, {});
return removed;
}
/** 收下一條候選:寫進正名表,並從候選清單移掉。 */
export function confirmNovelName(slug, work, { from, to } = {}) {
const res = addNovelName(slug, work, { from, to });
if (!res) return null;
dropNovelProposals(slug, work, [res.from]);
return res;
}
/** `confidence: exact` 的整批收下——那些等於節點的名字,沒有判斷空間。 */
export function acceptExactNovelNames(slug, work) {
const done = [];
for (const row of loadNovelProposals(slug, work)) {
if (row.confidence !== "exact" || !row.guess) continue;
const res = confirmNovelName(slug, work, { from: row.token, to: row.guess });
if (res) done.push(res);
}
return done;
}
/** 標成「不是人名」:進 ignore,下次掃不再列。 */
export function ignoreNovelName(slug, work, token) {
const one = injectSafeLine(token, 80);
if (!one) return null;
updateJson(novelNamesPath(slug, work), (prev) => {
const data = prev && typeof prev === "object" ? prev : {};
data.map = data.map && typeof data.map === "object" ? data.map : {};
data.ignore = asList(data.ignore).map((t) => injectSafeLine(t, 80)).filter(Boolean);
if (!data.ignore.includes(one)) data.ignore.push(one);
data.updated_at = nowIso();
return data;
}, {});
dropNovelProposals(slug, work, [one]);
return one;
}
/** 從候選移除但**不**進 ignore:下次掃還會再出現(「先跳過,待會再想」)。 */
export const rejectNovelName = (slug, work, token) => dropNovelProposals(slug, work, [token]);
/**
* `about` 裡還沒確認的名字。
*
* 正名沒做完就寫候選,等於把錯的名字帶進 `about`,要等 `relation doctor` 才發現,
* 那時整批都得重跑。三種算已確認:正名表的 key、已經是關係節點的名字(不必正名)、
* 以及使用者標成「不是人名」的(ignore——它不是人,不該擋著寫入)。
*/
export function unconfirmedNovelNames(about, { names = {}, ignore = [], nodes = null } = {}) {
if (!nodes) return [];
const known = new Set([...Object.keys(names), ...Object.values(names), ...asList(ignore)]);
const out = [];
for (const raw of asList(about)) {
const one = injectSafeLine(raw, 80);
if (!one || known.has(one) || out.includes(one)) continue;
if (matchRelationNodes(nodes, one).length === 1) continue;
out.push(one);
}
return out;
}
export function addNovelSkip(slug, work, { chapter, reason } = {}) {
const entry = {
at: nowIso(),
chapter: injectSafeLine(chapter, 120),
reason: injectSafeLine(reason, 200),
};
if (!entry.chapter || !entry.reason) return null;
appendJsonl(novelSkippedPath(slug, work), entry);
return entry;
}
/** `YYYY-MM-DD`,而且真的是那一天(`2022-02-30` 不算)。 */
export function isYmd(value) {
const one = String(value ?? "").trim();
if (!/^\d{4}-\d{2}-\d{2}$/.test(one)) return false;
const date = new Date(`${one}T00:00:00Z`);
return !Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === one;
}
/**
* 驗一筆記憶候選,並依正名表正名 `about`。
*
* 回 `{ ok, entry, errors: [{ field, reason }] }`——**逐欄位回報**,不整批丟掉:
* 一批 40 則裡有一則日期寫錯,呼叫端要能講出是哪一則的哪一欄。
*
* 帶了 `nodes` 就順便擋「`about` 裡有還沒確認的名字」(見 `unconfirmedNovelNames`);
* 不帶就只驗欄位。
*/
export function validateNovelCandidate(row, { names = {}, ignore = [], nodes = null } = {}) {
const src = row && typeof row === "object" && !Array.isArray(row) ? row : {};
const errors = [];
const bad = (field, reason) => errors.push({ field, reason });
const one = (value, limit = 200) => injectSafeLine(value, limit);
const rawName = one(src.name, 120);
const name = slugify(rawName);
if (!rawName) bad("name", "必填(檔名用的 slug");
const type = one(src.type, 20);
if (!type) bad("type", "必填");
else if (!NOVEL_TYPES.includes(type)) bad("type", `只能是 ${NOVEL_TYPES.join("")},收到 \`${type}\``);
// body 是唯一允許多行的欄位(主旨/細節兩層要靠換行切),所以只中和注入標記。
const body = stripInjectionMarkers(src.body ?? "").trim();
if (!body) bad("body", "必填(第一人稱摘要)");
const firstSeen = one(src.first_seen, 20);
if (!firstSeen) bad("first_seen", "必填(故事內時間,換算成西元日期)");
else if (!isYmd(firstSeen)) bad("first_seen", `要 YYYY-MM-DD 的西元日期,收到 \`${firstSeen}\``);
const lastSeen = one(src.last_seen, 20);
if (lastSeen && !isYmd(lastSeen)) bad("last_seen", `要 YYYY-MM-DD 的西元日期,收到 \`${lastSeen}\``);
const dateSource = one(src.date_source, 20) || "derived";
if (!(dateSource in NOVEL_DATE_SOURCES)) {
bad("date_source", `只能是 ${Object.keys(NOVEL_DATE_SOURCES).join("")},收到 \`${dateSource}\``);
}
// 知情層級。`canon` 是世界設定(誰知道都一樣),所以不必填;
// 其餘型別是**他的經歷**,一定要講清楚他是怎麼知道的——沒填就擋下來。
//
// 為什麼不給預設值:填錯與沒填要分得開。沉默地當成 `none` 的話,
// 抽的人永遠不知道自己漏了一欄;沉默地當成 `saw` 更糟,那是替他捏造在場。
const knowLevel = one(src.know_level, 20) || (type === "canon" ? "none" : "");
if (!knowLevel) {
bad("know_level", `\`type: ${type}\` 是他的經歷,必須講清楚他怎麼知道的:` +
`${Object.entries(NOVEL_KNOW_LEVELS).map(([k, v]) => `${k}${v}`).join("")}` +
"——判斷不出來就填 `none` 並把 type 改成 `canon`");
} else if (!(knowLevel in NOVEL_KNOW_LEVELS)) {
bad("know_level", `只能是 ${Object.keys(NOVEL_KNOW_LEVELS).join("")},收到 \`${knowLevel}\``);
} else if (knowLevel === "none" && type && type !== "canon") {
// `none` 是「他不在場也沒人告訴他」,那種事不能變成他的經歷。
// 這裡**擋下來而不是自動改成 canon**:自動改會讓抽錯的人永遠不知道自己抽錯了。
bad("know_level", "`none`(他不在場也沒人告訴他)只能配 `type: canon`" +
`收到 \`${type}\`——他不知道的事不可以變成他的經歷`);
}
const salienceRaw = src.salience === undefined || src.salience === null || src.salience === "" ? 60 : Number(src.salience);
if (!Number.isFinite(salienceRaw)) bad("salience", `要 0 到 100 的數字,收到 \`${one(src.salience, 20)}\``);
const rename = (value) => {
const key = one(value, 80);
return key ? (names[key] || key) : "";
};
const entry = {
name,
title: one(src.title, 120) || rawName,
type,
about: asList(src.about).map(rename).filter(Boolean),
topics: asList(src.topics).map((t) => one(t, 40)).filter(Boolean),
salience: Number.isFinite(salienceRaw) ? clamp(salienceRaw) : 60,
emotion: one(src.emotion, 60),
first_seen: firstSeen,
date_source: dateSource,
source: one(src.source, 160),
chapter: one(src.chapter, 120),
body,
quote: one(src.quote, 240),
know_level: knowLevel,
when: one(src.when, 40),
where: one(src.where, 80),
mood: one(src.mood, 40),
added_at: nowIso(),
};
if (lastSeen) entry.last_seen = lastSeen;
const unconfirmed = unconfirmedNovelNames(entry.about, { names, ignore, nodes });
if (unconfirmed.length) {
bad("about", `這幾個名字還沒確認:${unconfirmed.join("、")}` +
"——先 `novel scan` → `novel name review` → `novel name confirm`(新人物要先 `relation node`");
}
// 沒有值的欄位不留空鍵:報告與 front matter 都是「沒有值就不寫」
for (const key of Object.keys(entry)) {
if (entry[key] === "" || (Array.isArray(entry[key]) && !entry[key].length)) delete entry[key];
}
return { ok: errors.length === 0, entry, errors };
}
/** 一批候選寫進 `candidates.jsonl`。驗不過的不寫,逐筆回報。 */
export function addNovelCandidates(slug, work, rows) {
const list = Array.isArray(rows) ? rows : [rows];
const added = [];
const failed = [];
const { map: names, ignore } = loadNovelNameBook(slug, work);
// 關係節點讀一次傳下去(每一筆的每個 about 都要對一次)
const nodes = loadRelations(slug).nodes || [];
list.forEach((row, i) => {
const res = validateNovelCandidate(row, { names, ignore, nodes });
if (!res.ok) {
failed.push({ index: i, name: injectSafeLine(row?.name, 80) || "(沒有 name", errors: res.errors });
return;
}
appendJsonl(novelCandidatesPath(slug, work), res.entry);
added.push(res.entry);
});
return { added, failed };
}
const olderYmd = (a, b) => (!a ? b : !b ? a : (a < b ? a : b));
const newerYmd = (a, b) => (!a ? b : !b ? a : (a > b ? a : b));
const unionList = (a, b) => {
const out = [];
for (const value of [...asList(a), ...asList(b)]) if (value && !out.includes(value)) out.push(value);
return out;
};
/**
* 跨章去重合併(TODO 故事匯入 2.1):同一個 `name` 只留一則。
*
* `salience` 取高、`first_seen` 取最早、`last_seen` 取最晚、`topics``about` 取聯集、
* `quote` 留最長的一句(短的那句通常是同一段話被截斷的版本)。
* 其餘欄位以**先到的為準**、空的才補——先到的那一章就是它第一次發生的地方。
*/
export function mergeNovelCandidates(rows) {
const byName = new Map();
let merged = 0;
for (const row of Array.isArray(rows) ? rows : []) {
const key = String(row?.name ?? "");
if (!key) continue;
const prev = byName.get(key);
if (!prev) {
byName.set(key, { ...row });
continue;
}
merged += 1;
prev.salience = Math.max(Number(prev.salience) || 0, Number(row.salience) || 0);
prev.first_seen = olderYmd(prev.first_seen, row.first_seen);
const last = newerYmd(prev.last_seen || prev.first_seen, row.last_seen || row.first_seen);
if (last) prev.last_seen = last;
const topics = unionList(prev.topics, row.topics);
if (topics.length) prev.topics = topics;
const about = unionList(prev.about, row.about);
if (about.length) prev.about = about;
if (String(row.quote ?? "").length > String(prev.quote ?? "").length) prev.quote = row.quote;
for (const [k, v] of Object.entries(row)) {
if (prev[k] === undefined || prev[k] === "" || (Array.isArray(prev[k]) && !prev[k].length)) prev[k] = v;
}
}
return { rows: [...byName.values()], merged };
}
/**
* 依配額重新定標(TODO 故事匯入 2.2):超過配額的從低分往下壓一級。
*
* 由高到低跑,所以壓下來的會落進下一級、再一起受下一級的配額約束
* 90+ 壓成 85 之後就是 80-89 的人,要跟原本的 80 分們一起排隊)。
*/
export function requotaNovelCandidates(rows, quota = NOVEL_DEFAULT_QUOTA) {
const out = (Array.isArray(rows) ? rows : []).map((r) => ({ ...r }));
const tiers = Object.keys(quota || {}).map(Number).filter((n) => Number.isFinite(n)).sort((a, b) => b - a);
const demoted = [];
for (let i = 0; i < tiers.length; i += 1) {
const floor = tiers[i];
const ceil = i === 0 ? Infinity : tiers[i - 1]; // 上一級的門檻就是這一級的上界
const limit = Math.max(0, Number(quota[floor]) || 0);
const band = out.filter((r) => Number(r.salience) >= floor && Number(r.salience) < ceil);
if (band.length <= limit) continue;
// 從低分往下壓;同分用 name 排,換一台機器跑要壓到同一批
band.sort((a, b) => (Number(a.salience) - Number(b.salience)) || String(a.name).localeCompare(String(b.name)));
for (const row of band.slice(0, band.length - limit)) {
const from = Number(row.salience);
row.salience = Math.max(0, floor - NOVEL_DEMOTE_STEP);
demoted.push({ name: row.name, tier: floor, from, to: row.salience });
}
}
return { rows: out, demoted, tiers };
}
/** 每一級的配額用掉多少(`novel report` 與 `novel merge` 共用)。 */
export function novelQuotaUsage(rows, quota = NOVEL_DEFAULT_QUOTA) {
const tiers = Object.keys(quota || {}).map(Number).filter((n) => Number.isFinite(n)).sort((a, b) => b - a);
return tiers.map((floor, i) => {
const ceil = i === 0 ? Infinity : tiers[i - 1];
const used = (Array.isArray(rows) ? rows : []).filter(
(r) => Number(r.salience) >= floor && Number(r.salience) < ceil,
).length;
return {
tier: floor,
label: i === 0 ? `${floor}+` : `${floor}-${ceil - 1}`,
limit: Math.max(0, Number(quota[floor]) || 0),
used,
over: Math.max(0, used - Math.max(0, Number(quota[floor]) || 0)),
};
});
}
/**
* 情緒基線提案跟現況比差值(TODO 3B.5)。
*
* 基線是氣質,改了等於換一個人,所以這裡只算差、不寫入——任一格差
* `NOVEL_BASELINE_GAP` 以上就要停下來給人看(Q4 拍板的門檻)。
*/
export function novelBaselineDiff(slug, proposed) {
const state = loadEmotion(slug);
const rows = [];
const unknown = [];
for (const [key, value] of Object.entries(proposed || {})) {
if (!(key in EMOTIONS)) {
unknown.push(injectSafeLine(key, 20));
continue;
}
const now = state.baseline[key];
const next = clamp(value);
rows.push({ key, zh: EMOTIONS[key].zh, now, next, diff: Math.round((next - now) * 10) / 10 });
}
const over = rows.filter((r) => Math.abs(r.diff) >= NOVEL_BASELINE_GAP);
return { rows, over, unknown, gap: NOVEL_BASELINE_GAP };
}
/** 把提案寫進基線(`novel baseline --force` 或差值都在門檻內時才會走到這裡)。 */
export function applyNovelBaseline(slug, proposed) {
return updateEmotion(slug, (s) => {
for (const [key, value] of Object.entries(proposed || {})) {
if (key in EMOTIONS) s.baseline[key] = clamp(value);
}
return s;
});
}
// --------------------------------------------------------------------------- //
// 心智圖 / 思維導圖 / 人際關係圖
// --------------------------------------------------------------------------- //
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");
/** 關係圖壞掉時的錯誤(`main()` 會把它變成一行看得懂的訊息,不是 stack trace)。 */
export class RelationsError extends Error {
constructor(message) {
super(message);
this.name = "RelationsError";
}
}
/**
* 關係圖。**「檔案不存在」與「檔案壞了」不是同一件事**:
* 前者是正常的(新人格還沒有關係圖)→ 回空圖;後者回空圖但掛上 `_error`。
*
* 不能用 `readJson(..., {})`:那會讓壞檔與空圖的輸出逐字相同(`show` 印「0 節點」、
* exit 0),而下一次任何寫入都用 tmp+rename 原子覆蓋整個檔案 —— 原有節點就永久消失了。
* 呼叫端自己決定怎麼辦:寫入路徑走 `relationsForWrite()` 直接拒絕,
* 每輪都跑的讀取路徑(`turnContext`)退回空圖但把警告印給使用者看。
*/
export function loadRelations(slug) {
const file = relationsJson(slug);
let text;
try {
text = fs.readFileSync(file, "utf8");
} catch {
return { nodes: [], edges: [] }; // 還沒有關係圖:這是正常狀態
}
let data;
try {
data = JSON.parse(text);
} catch (err) {
return { nodes: [], edges: [], _error: `關係圖 ${file} 無法解析:${err.message}` };
}
if (!data || typeof data !== "object" || Array.isArray(data)) {
return { nodes: [], edges: [], _error: `關係圖 ${file} 不是一個物件(讀到 ${Array.isArray(data) ? "陣列" : typeof data}` };
}
for (const key of ["nodes", "edges"]) {
if (data[key] === undefined || data[key] === null) data[key] = [];
// 有這個 key 但不是陣列 → 一樣是壞檔(照著寫下去會把原本的內容換成空的)
else if (!Array.isArray(data[key])) return { nodes: [], edges: [], _error: `關係圖 ${file} 的 \`${key}\` 不是陣列` };
}
return data;
}
/**
* 寫入關係圖前的把關:解析失敗就不准覆蓋(原檔留在那裡,還救得回來)。
* 所有會 `writeJson(relationsJson(...))` 的路徑都要先過這裡。
*/
export function relationsForWrite(slug) {
const data = loadRelations(slug);
if (data._error) {
throw new RelationsError(
`${data._error}\n 這次不寫入,免得把原有的節點與連線整個蓋掉。` +
`請先修好這個檔案(或把它移開讓人格從空的關係圖重新開始),再用 \`relation doctor\` 確認。`,
);
}
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 = ["稱呼", "敬語", "口頭禪", "禁忌", "習慣", "羞怯稱呼"];
/** 羞恥度到這裡就換稱呼(跟破口、標點同一條門檻線)。 */
export const SHY_ADDRESS_AT = 60;
/** 「害羞時要怎麼叫他」掛在關係節點的這個 facet 上。 */
export const SHY_FACET = "羞怯稱呼";
/**
* 害羞時的稱呼。
*
* `except``anger>=40` → 退回全名)走的是**生氣**方向:規則被暫停、退回查表的講法。
* 害羞是反方向——不是退回更正式,是**叫不出來**:改口、講到一半換稱呼、把名字吞掉。
*
* 節點自己設了 `羞怯稱呼` 就用它;沒設就吞掉名字改用「你」。
* 不叫名字本身就是訊號,這是預設值而不是缺省行為。
*/
export function shyAddress(slug, node, modesty) {
const value = Number(modesty);
if (!node || !Number.isFinite(value) || value < SHY_ADDRESS_AT) return null;
const own = node.style?.[SHY_FACET]?.value;
return { modesty: Math.round(value), value: own ? injectSafeLine(own) : null, own: Boolean(own) };
}
/** `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)。最後一段是**子字串**比對:
* 這是給互動式呼叫端的方便(`relation style --name 小林` 找得到「小林先生」),
* **不可以**用在寫入路徑上——猜錯了會把稱謂寫成別人的 id,見 `resolveRelationRefs`。
*
* `nodes`:呼叫端已經 `loadRelations` 過就傳進來,省掉重讀 graph.json。
*/
export function findRelationNode(slug, who, nodes = null) {
const key = String(who || "").trim();
if (!key) return null;
const pool = nodes || loadRelations(slug).nodes || [];
return pool.find((n) => n.id === key) || pool.find((n) => n.name === key)
|| pool.find((n) => String(n.name || "").includes(key)) || null;
}
// 節點 id 會被原樣寫進長期記憶的 front matter`about_ids: [...]`)與短期記憶的 jsonl。
// 帶換行的 id 就能在 front matter 裡多插一行(實測可以覆寫 `type`,把一則普通記憶
// 變成不該被遺忘的 `canon`);`:``[``]``,``#` 也都會改變 front matter 的結構。
// `--id` 是使用者給的,`sync pull``import` 也會帶進別台機器的 id,所以兩頭都要防。
const RELATION_ID_RE = /^[^\s:[\],#<>"'`]{1,64}$/;
// front matter 與 jsonl 裡的 `about``about_ids``entities``entity_ids` 不保證是陣列
// `about_ids: asuna` 這種手寫的值,`parseFrontMatter` 會給一個字串)。
// 直接 `for..of` 一個字串會逐字元跑,於是一個 id 變成一堆單字元的假 id。
const asList = (value) => (Array.isArray(value) ? value : value === undefined || value === null || value === "" ? [] : [value]);
/** 這個字串可以當關係節點 id 嗎(能安全寫進 front matterjsonl)。 */
export function validRelationId(id) {
return RELATION_ID_RE.test(String(id ?? ""));
}
/** 要寫進記憶的節點 id:先中和注入標記與換行,長得不像 id 的**直接丟掉**(與「對不上就省略」一致)。 */
export function safeRelationIds(ids) {
const out = [];
for (const raw of asList(ids)) {
const one = injectSafeLine(raw);
if (!validRelationId(one) || out.includes(one)) continue;
out.push(one);
}
return out;
}
/** 名字**完全相等**trim 後)的節點:id 或 name 命中都算。回傳全部命中,讓呼叫端判斷歧義。 */
export function matchRelationNodes(nodes, name) {
const key = String(name ?? "").trim();
if (!key) return [];
return (nodes || []).filter(
(n) => String(n?.id ?? "").trim() === key || String(n?.name ?? "").trim() === key,
);
}
/**
* 記憶裡的人名(自由字串)→ 關係圖節點 id。
*
* 記憶寫的是「小林」,關係圖的節點可能叫「小林先生」、id 是 `xiao-lin`——
* 只靠字串比對,兩邊永遠對不上,於是「提到誰」跟「跟誰有關係」是兩份互不相通的資料。
* 這裡多解析一次,對得上就記 id;**對不上就跳過**(不建節點、不報錯、不擋寫入)。
*
* 這是**寫入路徑**,所以只認完全相等的 id 或 name:
* - 不做子字串比對——`--entities 先生` 曾經被解成同事節點「小林先生」,
* R5 據此開出固化候選,人格就固化了一則自己編出來的假記憶。
* - 一個名字對到多於一個節點時視為歧義,一樣不寫:誰先誰贏只是 graph.json 的
* 排列順序,而 `import``sync pull``persona-anime` 都會重排它,
* 同一則記憶在另一台機器上會指到另一個人。歧義清單見 `relation doctor`。
*
* `nodes`:呼叫端已經 `loadRelations` 過就傳進來(`promotionCandidates` 對每筆的每個
* entity 都會呼叫一次,每次重讀一遍 graph.json 是實測會痛的那種慢)。
*/
export function resolveRelationRefs(slug, names, nodes = null) {
const pool = nodes || loadRelations(slug).nodes || [];
const out = [];
for (const name of asList(names)) {
const matched = matchRelationNodes(pool, name);
if (matched.length !== 1) continue; // 對不上,或同名歧義
out.push(matched[0].id);
}
return safeRelationIds(out);
}
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, opts = {}) {
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).filter((r) => r.facet !== SHY_FACET);
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("") +
" — 這時候退回語氣層的預設講法。",
);
}
// 害羞是「叫不出來」,不是「退回更正式」——所以它不走 except 那條路(見 shyAddress)。
const shy = shyAddress(slug, node, modestyState(slug, opts).value);
if (shy) {
lines.push(
shy.own
? ` **現在害羞(羞恥度 ${shy.modesty}):叫他「${shy.value}」**,不是平常那個叫法。` +
"改口、講到一半換稱呼都算——卡住的那個地方就是害羞。"
: ` **現在害羞(羞恥度 ${shy.modesty}):把名字吞掉,改用「你」**。` +
"不叫名字本身就是訊號;改口、講到一半換稱呼都算。" +
`要專屬的叫法就在那個節點設 \`${SHY_FACET}\`。`,
);
}
lines.push(
" 這一層決定距離感與稱呼,情緒只負責溫度與句長,不會蓋過它;" +
"檢查標準:這句話換成對一個「禮貌層」的人說也毫無違和,就代表你沒進到這一層。",
);
return lines.join("\n");
}
/** 蓋上「最後一次接觸」的時間戳;找不到那個人就回 false(不會憑空建節點)。 */
export function stampContact(slug, nameOrId, at = nowIso()) {
const data = relationsForWrite(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 = relationsForWrite(slug);
const nodeId = node.id || slugify(node.name || "");
// id 會被原樣寫進長期記憶的 front matter 與短期記憶的 jsonl → 帶換行或 front matter
// 結構字元的 id 擋在這裡(`slugify` 產的一定合法,只有 `--id` 需要驗)。
if (!validRelationId(nodeId)) {
throw new RelationsError(
`節點 id \`${injectSafeLine(nodeId).slice(0, 60)}\` 不合法:不能有空白、換行,也不能有 \`: [ ] , # < > " ' \`\`` +
`(它會被寫進記憶的 front matter,這些字元可以偽造出別的欄位)。`,
);
}
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 = relationsForWrite(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) {
// graph.mmd 是 graph.json 的投影:來源壞掉時寧可不畫,也不要拿一張空圖蓋掉上一張。
const data = relationsForWrite(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;
}
/**
* `extraIds`:不經關鍵詞比對、直接併進來的節點(例如剛想起來的長期記憶提到的人)。
* 只補、不排擠——關鍵詞比不到就退回 closeness 前 `limit` 的行為完全不動。
*/
export function relationsBrief(slug, names = null, limit = 5, extraIds = []) {
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 (extraIds?.length) {
const have = new Set(nodes.map((n) => n.id));
for (const id of extraIds) {
const node = data.nodes.find((n) => n.id === id);
if (!node || have.has(node.id)) continue;
have.add(node.id);
nodes.push(node);
}
}
if (!nodes.length) return "";
return nodes
.map((n) => {
const tone = toneFor(n);
// 關係圖也有不可信來源(importsync pullanime 抓來的原作關係),
// 人名與備註都壓成一行並中和標記。
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>` 就能把讀到它的那一輪注入區塊關掉。所以寫入時就壓成一行、
* 中和掉標記(比照短期記憶的作法),不要等到顯示的時候才處理。
*/
/**
* 名字後面那個括號:`(情緒・動作)`。
*
* 括號**原本就有**(放 `--emotion` 的情緒標註),動作是加進去的第二格,
* 不是換掉它。動作只寫看得見或聽得見的身體反應(別過頭、臉紅、手在抖)——
* 寫情緒名稱是把標註寫兩次,那一格由 `actionLint()` 擋。
*/
export function roomTag(msg) {
const parts = [injectSafeLine(msg?.emotion), injectSafeLine(msg?.action)].filter(Boolean);
return parts.length ? `${parts.join("・")}` : "";
}
/** 動作格只認「看得見的動作」:一個動作、12 字內、不可以是情緒名稱。 */
export const ACTION_MAX_CHARS = 12;
export function actionLint(action) {
const raw = String(action ?? "").trim();
if (!raw) return [];
const issues = [];
if ([...raw].length > ACTION_MAX_CHARS) {
issues.push({ kind: "action-long", chars: [...raw].length, max: ACTION_MAX_CHARS });
}
// 「別過頭,把杯子推過來」是兩個動作:一輪的額度只有兩個動作,括號裡那個算其中一個,
// 所以這一格塞第二個動作等於偷額度。
if (/[,。;;、]/.test(raw)) issues.push({ kind: "action-multi", text: raw });
// `(害羞)` 是旁白換了個位置:情緒屬於前面那一格
const named = EMOTION_KEYS.map((k) => EMOTIONS[k].zh).find((zh) => raw.includes(zh));
if (named) issues.push({ kind: "action-emotion", word: named });
const feeling = ["害羞", "緊張", "難過", "開心", "生氣", "尷尬", "感動", "不好意思"].find((w) => raw.includes(w));
if (feeling && !named) issues.push({ kind: "action-emotion", word: feeling });
return issues;
}
export function actionLintMessage(issue) {
switch (issue.kind) {
case "action-long":
return `動作格 ${issue.chars} 字,上限 ${issue.max}——括號裡只放一個看得見的動作`;
case "action-multi":
return `「${issue.text}」是兩個動作——一輪的額度只有兩個,括號裡那個已經算一個了`;
case "action-emotion":
return `「${issue.word}」是情緒不是動作——情緒寫在前面那一格(--emotion),這一格寫看得見的(臉紅、別過頭)`;
default:
return "動作格不合規";
}
}
export function roomPost(room, speaker, text, { emotion = "", action = "", kind = "say", to = null, bargeIn = null } = {}) {
const entry = {
ts: nowIso(),
speaker,
kind,
text: injectSafeLine(text),
emotion: injectSafeLine(emotion),
...(String(action || "").trim() ? { action: injectSafeLine(action) } : {}),
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)}` : "";
// 括號分兩格:情緒標註(本來就有)在前,動作在後,用 `・` 分隔。
// 兩格都可以省略,全省就不出現括號。
lines.push(`${emoji}${name}${roomTag(msg)}${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";
// 2:長期記憶多了 `strength` 與「主旨/細節」兩層,狀態多了 mood.jsonloops.jsonprobe.jsonl。
// 舊的 v1 bundle 照樣吃得下——`import` 收完會就地跑一次 migration 補上新欄位。
export const BUNDLE_VERSION = 2;
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;
config.bundle_version_in = Number(bundle.version) || 1;
writeJson(configPath(slug), config);
// 吃進來的 bundle 可能是舊格式(v1 沒有 strength、內文也沒切主旨/細節):
// 就地補齊,之後這個人格在本機就只有一種格式,不必到處判版本。
let migrated = null;
try {
migrated = migrateLongTerm(slug);
} catch {
/* 遷移失敗不該讓匯入整個失敗——INDEX 還是要重建 */
}
rebuildIndex(slug);
try {
renderRelations(slug);
} catch {
/* 沒有關係圖就算了 */
}
return { persona: slug, written, rejected, migrated };
}
// --------------------------------------------------------------------------- //
// 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)只准做收尾:整理自己的記憶與圖、衰減情緒、同步、寫睡眠狀態。
// 不准 loadrelease(它用的是 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);
// GrepGlob 不給 `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):欄位值在這裡
// 就中和掉,identityBriefroomScriptroomDisplayName 全都吃這一份,不必各自防。
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));
// 當日底色:每輪混一點此刻的心情進去(漂移很慢,一輪看不出來)
try {
updateDayMood(slug, state);
} catch {
/* 底色壞掉不該讓整輪掛掉 */
}
const session = loadSession(sessionId);
const lines = [
"<persona-context>",
`PERSONA_SESSION=${sessionId}`,
`人格:\`${slug}\` ${identityBrief(slug)}`,
`人格倉庫:${personaDir(slug)}(唯一可讀寫的人格資料範圍)`,
emotionBrief(slug, state),
"@@SPEAK@@",
"話短一點。一句講不完就斷開,不要用逗號一直串下去。用你平常會說的字," +
"看得見的東西先講——「你手上那杯冷了」比「我感覺到你的疲憊」好。" +
"講完就停,不要回頭解釋自己剛講的話,也不要幫自己收尾。",
"有些句子一出口就不像人了:「這個我懂」「我完全理解」「好問題」" +
"(先發一句免費的認可,對方要的是回答)、「希望這對你有幫助」「接下來我會⋯」、" +
"「說到底」「本質上」(假裝在講穿本質,其實只是重講一次)、開場的「老實說」、" +
"收尾的「總的來說」、該表態時的「各有優缺點」、沒出處的「研究顯示」、" +
"用旁白演情緒的「我愣了一下」。這些不要說。",
"**講自己的過去,要真的有那件事**。「我以前⋯」「我原本以為⋯」先 `recall` 查得到才講。" +
"查得到但注明「模糊」的,可以用**試探句**求證(「是不是上個月那次?」)——" +
"可以說不確定、可以問,**不可以斷言**:試探句一定帶問號,一輪最多一次," +
"而且要 `persona.mjs probe add` 記一筆。完全查不到就別講,編一段轉折比空話糟糕得多。" +
"情緒也一樣,照現在的情緒值走,不要為了顯得有人味硬加。",
`(機械會擋的:一句 ${MAX_SENTENCE_CHARS} 字、上面那些詞、中國用語、半形標點、` +
"粗體與清單符號、兩小時內講過的話——`room post` 與 `said check` 直接拒收。)",
];
// 當日底色(今天累積下來的 valence):平的時候不講,不用每輪都說今天很普通
const dayLine = dayMoodBrief(slug);
if (dayLine) lines.splice(lines.indexOf("@@SPEAK@@"), 0, dayLine);
// 情緒先行:先讀對方那句話帶了什麼,再談我自己的狀態。
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("")}。`,
" **這是演出來的,不是講出來的**:讓它出現在句子的形狀上(斷句、疊字、嘴硬、話少)," +
"不要用旁白說明自己的狀態(「我有點緊張」是解釋,斷句才是緊張)。" +
`一輪最多露 ${TELLS_PER_TURN} 個動作,而且兩個要來自**同一種情緒**、有遞進關係` +
"(否認 → 轉移、笑 → 補一句、留白 → 不追問);兩種情緒各演一個是演戲。" +
"名字後面括號裡的動作**算在這個額度裡**——括號演一個、句子裡再演兩個就是三個。" +
"強度不到就不演。",
);
}
// 標點預算:只有文字的話,標點就是語調。跟破口同一條門檻(強度不到就不調)。
const punct = punctuationBudget(slug, state);
if (punct) {
lines.push(
`此刻的標點:${punct.zh} ${punct.level}${punct.hint}。` +
`上限夾死——一則最多 ${MAX_EXCLAIM} 個驚嘆號、刪節號不連發(最多兩個)。` +
"標點管的是**分布**,強度歸 emoji;用標點加強度會變成用符號代替情緒。",
);
}
// 情緒的 emoji:種類表情緒、數量表程度。算出來的,不要自己挑
const nowEmoji = emotionEmojiNow(slug, state);
if (nowEmoji) {
lines.push(
`此刻的情緒符號:${nowEmoji.emoji}${nowEmoji.zh} ${nowEmoji.level}${nowEmoji.own ? "・專屬" : ""})。` +
"可以放在名字後面的括號裡,句子裡也可以用,沒有數量上限。" +
"但它是**加上去的**——把所有符號刪掉之後,那句話還是要看得出情緒。",
);
}
// 羞恥度:性別給預設、描述蓋過它,再由當下情緒與上一輪的餘溫推成這一輪的值。
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));
// 分段預算:純文字唯一的時間訊號(「他又打了一行」)。有額度才注入,沒有就不提——
// 講「這一輪不能拆成兩則」只是提醒他有這個功能。
const split = splitDirective(slug, speakOpts);
if (split) lines.push(split);
recordFelt(slug, {
user: read.signals.length ? read : null,
modesty: budget.modesty,
note: String(prompt || "").slice(0, 80),
split: Boolean(split),
});
// 語氣層:由「使用者在關係圖裡是誰」+ bond × 親近度算出來,不靠人格自己記得。
const tone = toneDirective(slug, speakOpts);
if (tone) lines.push(tone);
// 語氣層:他講過的原句與「遇到事會做什麼」。每輪最多 2 到 3 條,而且挑跟這一輪
// 對象/話題對得上的——全注入會變成表演(他開始照抄自己的舊台詞)。
// 整段包起來:語氣檔是手改與 `sync pull` 都會碰的檔,壞掉不該讓整輪掛掉。
try {
const voice = voiceBrief(slug, { to: speakerNode(slug)?.name || null, hint: prompt });
if (voice) lines.push(voice);
// 語域是語氣層的**加法面**(自稱/句尾/口癖/不說),跟原句同一個目錄、
// 同一支 `voice add`——所以 persona-story 與 persona-anime 寫的是同一格。
const idiolect = idiolectBrief(slug);
if (idiolect) lines.push(idiolect);
// 慣例:程序性記憶,不必被問到(沒有人為了照慣例做事先去回想它)。
const habits = habitBrief(slug);
if (habits) lines.push(habits);
} catch {
/* 語氣檔壞掉不該讓整輪掛掉 */
}
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}` : ""}`);
}
}
// 未完事項:先掃掉逾期的(各留一則「沒下文」),剩下的注入。
// 這一段排在長期記憶前面是刻意的——懸著的事比檢索到的事更該先想起來。
try {
const cold = sweepLoops(slug);
if (cold.length) {
lines.push(
`⚠ ${cold.length} 件事懸超過 ${LOOP_STALE_DAYS} 天沒下文,剛剛自動收掉了` +
`${cold.map((l) => l.text.slice(0, 24)).join("、")}):已各留一則短期記憶。`,
);
}
const loops = loopsBrief(slug);
if (loops) lines.push(loops);
} catch {
/* 未完事項壞掉不該讓整輪掛掉 */
}
const hits = prompt ? recall(slug, prompt, 4, { state }) : [];
if (hits.length) {
lines.push("相關長期記憶(清晰/模糊是算出來的,不是你決定的):");
let anyFuzzy = false;
for (const meta of hits) {
const r = memoryRecalled(meta);
const head = ` - ${meta._name}${meta.type || "fact"}`;
if (r.state === "clear") {
lines.push(`${head}${(meta._gist || meta._body || "").split("\n")[0].slice(0, 100)}`);
} else if (r.state === "faded") {
anyFuzzy = true;
lines.push(`${head}|⚠ 半模糊(想得起來 ${Math.round(r.retrievability * 100)}%)|` +
`主旨:${r.gist.split("\n")[0].slice(0, 90)}${r.hint}`);
} else {
anyFuzzy = true;
const topics = (r.topics || []).join("/") || "想不起來是什麼";
lines.push(`${head}|⚠ 模糊(${Math.round(r.retrievability * 100)}%)|只剩線索:${topics}${r.hint}`);
}
}
if (anyFuzzy) {
lines.push(
" 模糊的那幾則:**可以說不確定,可以問,不可以斷言**。要提就用試探句求證(帶問號)," +
"講完記一筆 `persona.mjs probe add --text \"<你問的那句>\"`" +
"對方否認就 `probe deny` 並當場寫一則更正記憶,不要放著。",
);
}
touchRecall(slug, hits.filter((m) => (m._recall?.retrievability ?? 1) >= MEMORY_FUZZY_AT).map((m) => m._name));
}
// 兩段要講同一批人:剛想起來的那幾則記憶提到誰,就把那些節點一起帶進「人際關係」。
// `about_ids` 與 `about` 兩邊**都要試**union):節點改 id 是支援的操作
// (原本用名字當 id,後來改用人格編號 ASUNA-01),改完之後舊的 `about_ids` 就成了死指標——
// 只看 `about_ids` 的話,有記過 id 的記憶反而比完全沒記過的更早失聯。
// 最多多帶 3 個,不要無限膨脹。
const relData = loadRelations(slug); // 讀一次傳下去(每則記憶的每個人名都要對一次)
const relNodes = relData.nodes;
const memoryRefs = [];
for (const meta of hits) {
const aboutIds = Array.isArray(meta.about_ids) ? meta.about_ids : [];
const about = Array.isArray(meta.about) ? meta.about : meta.about ? [String(meta.about)] : [];
for (const id of resolveRelationRefs(slug, [...aboutIds, ...about], relNodes)) {
if (!memoryRefs.includes(id)) memoryRefs.push(id);
}
}
const rel = relationsBrief(slug, prompt ? keywords(prompt, 6) : null, 5, memoryRefs.slice(0, 3));
if (rel) lines.push(`人際關係:${rel}`);
// 關係圖壞掉時**不炸掉整輪**(這段每輪都跑,hook 掛掉比少一段脈絡嚴重),
// 但也絕不無聲——這輪的「人際關係」是空的,使用者必須知道為什麼。
if (relData._error) {
lines.push(
`⚠ ${relData._error}`,
" 這一輪沒有人際關係與語氣層可用(當成空圖處理),而且所有關係圖的寫入都會被拒絕。" +
"先修好那個檔案,再用 `relation doctor` 確認。",
);
}
// 很久沒聯絡但很親近的人 → 這是「主動提議去關心某人」的依據(不是每輪都要提)
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。`);
}
// 自我議程:這一輪允許不服務他那句話,把話題拉回自己在意的事。
// 這是「像 AI」最頑固的殘留——永遠以對方為中心。他有急事時一律不觸發。
try {
const agenda = agendaTick(slug, prompt);
if (agenda) {
lines.push(
`🫱 這一輪可以**先講你自己的事**:${agenda.loop.text}` +
`${LOOP_KINDS[agenda.loop.kind] || agenda.loop.kind})。`,
" 允許答非所問:先回半句,再把話題拉過去;或者直接問。不用先服務他那句話——" +
"但只拉一次,拉完就讓他接。他要是不接就收掉(`loop done`),不要追第二次。",
);
}
} catch {
/* 議程壞掉不該讓整輪掛掉 */
}
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");
}