只有文字的話標點就是語調:同一句「我知道了」,`我知道了。` 與 `我、我知道了⋯`
是兩種情緒,差別全在標點不在用詞。
PUNCTUATION_BUDGET 只寫拍板指名的四種(焦慮/憤怒/害羞/喜悅)。
其餘八種不配標點——硬給每種情緒配一套會讓標點變成裝飾,沒把握的就不編。
punctuationBudget() 每輪只回一種,門檻跟破口同一條線(40)。兩種情緒各配一套
會互相打架:憤怒幾乎不用問號、焦慮要多斷句,混起來不是複雜的情緒,是標點亂撒。
上限:MAX_EXCLAIM = 1、MAX_ELLIPSIS = 3,另擋 […⋯]{3,} 的連發
(中文刪節號本來就常寫兩個,所以三個起才算)。不夾死的話它會變成 emoji 的
替代品——情緒不夠就多打幾個驚嘆號。界線也寫進注入文字:標點管分布、強度歸 emoji。
selftest 711 → 721。
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
5994 lines
270 KiB
JavaScript
5994 lines
270 KiB
JavaScript
// persona-lib.mjs — jsc-persona 的共用核心(Node.js,只用內建模組)
|
||
//
|
||
// 負責:
|
||
// * 人格倉庫路徑與 slug 規則
|
||
// * 單一程序載入鎖(exclusive lock)與 guest lease
|
||
// * session 綁定(host / guests / rooms / agent pins / 劇場模式)
|
||
// * 十二情緒模型(六正向 + 六負向)與衰減
|
||
// * 短期記憶 / 長期記憶 / 心智圖 / 思維導圖 / 人際關係圖 的讀寫
|
||
// * 跨人格隔離的判斷核心(guard)
|
||
|
||
import fs from "node:fs";
|
||
import os from "node:os";
|
||
import path from "node:path";
|
||
import crypto from "node:crypto";
|
||
import zlib from "node:zlib";
|
||
|
||
// --------------------------------------------------------------------------- //
|
||
// 路徑
|
||
// --------------------------------------------------------------------------- //
|
||
|
||
export const RUNTIME_DIRNAME = ".runtime";
|
||
export const ROOMS_DIRNAME = ".rooms";
|
||
export const LEASE_SECONDS = 900; // 15 分鐘沒有 heartbeat 視為死鎖,可被接手
|
||
export const GUEST_LEASE_SECONDS = 1800; // guest(sub agent)租約
|
||
export const SLEEPER_LEASE_SECONDS = 300; // sleeper(睡眠 sub agent)租約:只夠做完收尾
|
||
export const SLEEP_DECAY_MINUTES = 480; // 睡一次=套用一次 8 小時的情緒衰減
|
||
|
||
function expandUser(p) {
|
||
if (p === "~") return os.homedir();
|
||
if (p.startsWith("~/")) return path.join(os.homedir(), p.slice(2));
|
||
return p;
|
||
}
|
||
|
||
export function personaHome() {
|
||
const raw = process.env.PERSONA_HOME;
|
||
if (raw) return path.resolve(expandUser(raw));
|
||
return path.resolve(path.join(os.homedir(), ".claude", "personas"));
|
||
}
|
||
|
||
export const runtimeDir = () => path.join(personaHome(), RUNTIME_DIRNAME);
|
||
export const sessionsDir = () => path.join(runtimeDir(), "sessions");
|
||
export const roomsDir = () => path.join(personaHome(), ROOMS_DIRNAME);
|
||
export const personaDir = (slug) => path.join(personaHome(), slug);
|
||
|
||
// 人格目錄名。新建的人格一律是**人格編號**(`ASUNA-01`:英文名全大寫+兩位索引),
|
||
// 但舊的小寫 slug(`asuna-sao`)仍然合法,才不會把既有人格鎖在門外。
|
||
const SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9-]{0,47}$/;
|
||
const RESERVED_SLUGS = new Set([RUNTIME_DIRNAME, ROOMS_DIRNAME, "", ".", ".."]);
|
||
|
||
export function validSlug(slug) {
|
||
return typeof slug === "string" && SLUG_RE.test(slug) && !RESERVED_SLUGS.has(slug);
|
||
}
|
||
|
||
const CJK_CLASS = "\\u3040-\\u30ff\\u3400-\\u4dbf\\u4e00-\\u9fff\\uac00-\\ud7af";
|
||
|
||
/** 檔名/節點 id 用。保留中日韓字(檔名可讀),其餘壓成連字號;全空則用雜湊。 */
|
||
export function slugify(text) {
|
||
const src = (text ?? "").normalize("NFKC");
|
||
let norm = src.replace(new RegExp(`[^A-Za-z0-9${CJK_CLASS}]+`, "g"), "-").replace(/^-+|-+$/g, "");
|
||
norm = norm.replace(/[A-Z]/g, (c) => c.toLowerCase());
|
||
if (!norm) {
|
||
return "n-" + crypto.createHash("md5").update(String(text ?? "")).digest("hex").slice(0, 8);
|
||
}
|
||
return norm.slice(0, 48);
|
||
}
|
||
|
||
/** Mermaid 節點別名:只能是英數與底線;非 ASCII 名稱改用穩定雜湊。 */
|
||
export function mermaidId(nodeId) {
|
||
const alias = String(nodeId ?? "").replace(/[^A-Za-z0-9_]/g, "_");
|
||
if (!/[A-Za-z0-9]/.test(alias)) {
|
||
return "n_" + crypto.createHash("md5").update(String(nodeId ?? "")).digest("hex").slice(0, 8);
|
||
}
|
||
return alias;
|
||
}
|
||
|
||
export function listPersonas() {
|
||
const home = personaHome();
|
||
let entries;
|
||
try {
|
||
entries = fs.readdirSync(home, { withFileTypes: true });
|
||
} catch {
|
||
return [];
|
||
}
|
||
return entries
|
||
.filter((e) => e.isDirectory() && validSlug(e.name) && fs.existsSync(path.join(home, e.name, "IDENTITY.md")))
|
||
.map((e) => e.name)
|
||
.sort();
|
||
}
|
||
|
||
export function personaExists(slug) {
|
||
return validSlug(slug) && fs.existsSync(path.join(personaDir(slug), "IDENTITY.md"));
|
||
}
|
||
|
||
// --------------------------------------------------------------------------- //
|
||
// 全域設定(跨人格、跨 session)
|
||
// --------------------------------------------------------------------------- //
|
||
//
|
||
// 目前只有一個鍵:`default_persona`——開新 session 時要不要自動載入某個人格。
|
||
// 預設是**沒有設定**(等使用者指定),這是刻意的:不該替使用者挑一個人格附身。
|
||
|
||
export const homeSettingsPath = () => path.join(runtimeDir(), "settings.json");
|
||
|
||
export function loadHomeSettings() {
|
||
return readJson(homeSettingsPath(), {}) ?? {};
|
||
}
|
||
|
||
export function saveHomeSettings(patch) {
|
||
const data = { ...loadHomeSettings(), ...patch, updated_at: nowIso() };
|
||
writeJson(homeSettingsPath(), data);
|
||
return data;
|
||
}
|
||
|
||
/**
|
||
* 要自動載入的人格;沒設定就回 null(呼叫端應該「什麼都不做」)。
|
||
* 環境變數 `PERSONA_DEFAULT` 優先於設定檔,`off`/`none`/空字串代表關閉。
|
||
*/
|
||
export function defaultPersona() {
|
||
const env = String(process.env.PERSONA_DEFAULT ?? "").trim();
|
||
const raw = env || String(loadHomeSettings().default_persona ?? "").trim();
|
||
if (!raw || ["off", "none", "false", "0"].includes(raw.toLowerCase())) return null;
|
||
return raw;
|
||
}
|
||
|
||
export function setDefaultPersona(slug) {
|
||
return saveHomeSettings({ default_persona: slug || null });
|
||
}
|
||
|
||
// --------------------------------------------------------------------------- //
|
||
// 時間與檔案 IO
|
||
// --------------------------------------------------------------------------- //
|
||
|
||
export const iso = (date = new Date()) => new Date(Math.floor(date.getTime() / 1000) * 1000).toISOString().replace(".000Z", "Z");
|
||
export const nowIso = () => iso(new Date());
|
||
|
||
export function parseIso(value) {
|
||
if (!value) return null;
|
||
const dt = new Date(value);
|
||
return Number.isNaN(dt.getTime()) ? null : dt;
|
||
}
|
||
|
||
export function ageSeconds(value) {
|
||
const dt = parseIso(value);
|
||
if (!dt) return Infinity;
|
||
return (Date.now() - dt.getTime()) / 1000;
|
||
}
|
||
|
||
export function minutesAgo(minutes) {
|
||
return new Date(Date.now() - minutes * 60_000);
|
||
}
|
||
|
||
export function readJson(file, fallback = null) {
|
||
try {
|
||
return JSON.parse(fs.readFileSync(file, "utf8"));
|
||
} catch {
|
||
return fallback;
|
||
}
|
||
}
|
||
|
||
export function writeJson(file, obj) {
|
||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||
const tmp = `${file}.tmp${process.pid}`;
|
||
fs.writeFileSync(tmp, JSON.stringify(obj, null, 2) + "\n", "utf8");
|
||
fs.renameSync(tmp, file);
|
||
}
|
||
|
||
export function writeText(file, text) {
|
||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||
const tmp = `${file}.tmp${process.pid}`;
|
||
fs.writeFileSync(tmp, text, "utf8");
|
||
fs.renameSync(tmp, file);
|
||
}
|
||
|
||
// --------------------------------------------------------------------------- //
|
||
// 檔案層互斥鎖
|
||
//
|
||
// 人格鎖(`state/lock.json`)擋的是「兩個人格同時被載入」,不是「兩個程序同時寫同一個檔」:
|
||
// 同一個 session 的 sub agent 與主程序**共用同一把人格鎖**(設計如此),所以兩邊真的會
|
||
// 同時寫。而 said / short-term 的裁切是「整檔讀進來 → 過濾 → 覆蓋」,中間任何一筆 append
|
||
// 都會被那次覆蓋吃掉——實測背景 prune 進行中寫入 30 筆 salience 95 的承諾,會掉幾筆。
|
||
//
|
||
// 所以:凡是碰同一個檔案的 append 與 rewrite,一律先拿這把鎖。
|
||
// sentinel 檔用 `O_EXCL` 建(跨程序原子),放在 `.runtime/locks/` 而不是人格目錄裡,
|
||
// 免得殘留的鎖檔被同步到 Gitea 或被 guard 掃到。
|
||
// --------------------------------------------------------------------------- //
|
||
|
||
export const FILE_LOCK_STALE_MS = 15_000; // 這麼久沒放掉就當持有者死了(避免整個系統卡住)
|
||
export const FILE_LOCK_TIMEOUT_MS = 10_000;
|
||
|
||
const SLEEP_BUF = new Int32Array(new SharedArrayBuffer(4));
|
||
/** 同步小睡(這裡不能用 await:呼叫端全是同步 API)。 */
|
||
function sleepSync(ms) {
|
||
if (ms > 0) Atomics.wait(SLEEP_BUF, 0, 0, ms);
|
||
}
|
||
|
||
export function fileLockPath(file) {
|
||
const key = crypto.createHash("md5").update(path.resolve(file)).digest("hex").slice(0, 16);
|
||
return path.join(runtimeDir(), "locks", `${path.basename(file)}.${key}.lock`);
|
||
}
|
||
|
||
/**
|
||
* 拿著 `file` 的互斥鎖跑 `fn()`,回傳 `fn` 的結果。
|
||
*
|
||
* 拿不到就短暫重試(含亂數退讓,避免兩邊同步互踩);超過 `FILE_LOCK_STALE_MS`
|
||
* 沒被放掉的鎖視為死鎖並搶走。真的等不到就直接做——寧可冒一次競態,
|
||
* 也不要因為一個殘留的鎖檔讓人格從此寫不進東西。
|
||
*/
|
||
export function withFileLock(file, fn, { timeoutMs = FILE_LOCK_TIMEOUT_MS } = {}) {
|
||
const lock = fileLockPath(file);
|
||
fs.mkdirSync(path.dirname(lock), { recursive: true });
|
||
const deadline = Date.now() + timeoutMs;
|
||
let fd = null;
|
||
while (fd === null) {
|
||
try {
|
||
fd = fs.openSync(lock, "wx", 0o600);
|
||
} catch (err) {
|
||
if (err.code !== "EEXIST") throw err;
|
||
let stat = null;
|
||
try {
|
||
stat = fs.statSync(lock);
|
||
} catch {
|
||
continue; // 剛好被放掉了,再試一次
|
||
}
|
||
if (Date.now() - stat.mtimeMs > FILE_LOCK_STALE_MS) {
|
||
try {
|
||
fs.unlinkSync(lock);
|
||
} catch {
|
||
/* 別人先清掉了 */
|
||
}
|
||
continue;
|
||
}
|
||
if (Date.now() > deadline) break; // 等太久:不擋住呼叫端
|
||
sleepSync(1 + Math.floor(Math.random() * 5));
|
||
}
|
||
}
|
||
try {
|
||
if (fd !== null) fs.writeSync(fd, `${process.pid}\n`);
|
||
return fn();
|
||
} finally {
|
||
if (fd !== null) {
|
||
try {
|
||
fs.closeSync(fd);
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
try {
|
||
fs.unlinkSync(lock);
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/** 單行 append。拿檔案鎖,才不會被同時進行的整檔覆蓋(prune/trim)吃掉。 */
|
||
export function appendJsonl(file, obj) {
|
||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||
const line = JSON.stringify(obj) + "\n";
|
||
withFileLock(file, () => {
|
||
fs.appendFileSync(file, line, { encoding: "utf8", mode: 0o600 });
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 「整檔讀進來 → 過濾 → 覆蓋」的唯一入口:整段在鎖裡面做,
|
||
* 所以 `transform` 看到的一定是最新的內容,寫回去也不會蓋掉別人剛 append 的行。
|
||
*
|
||
* `transform(rows)` 回傳要留下的列;回傳的陣列跟原本一樣長就不寫(省一次 IO)。
|
||
*
|
||
* **那個省 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 的天花板(0~1,見 `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 重複三次」——疊字看起來像洗頁。
|
||
//
|
||
// 40–59 😳 60–79 😳💦 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: "想到什麼就講,不用先在心裡繞一圈。" });
|
||
}
|
||
|
||
/** 注入用的一行:這一輪能講幾句、每句多長、心裡話該有幾句。 */
|
||
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` 0–100;`confident` 表示夠強、值得讓人格參考。
|
||
*/
|
||
export function readUserEmotion(text) {
|
||
const raw = String(text ?? "");
|
||
// 引號裡是別人的話、code 是資料——跟講話的樣子同一套規則:提及不算使用
|
||
const body = raw.replace(/`[^`]*`/g, " ").replace(/「[^」]*」|『[^』]*』/g, " ");
|
||
const scores = {};
|
||
for (const [key, words] of Object.entries(EMOTION_LEXICON)) {
|
||
for (const [word, weight] of words) {
|
||
let from = 0;
|
||
for (;;) {
|
||
const at = body.indexOf(word, from);
|
||
if (at < 0) break;
|
||
from = at + word.length;
|
||
const before = windowBefore(body, at);
|
||
if (NEGATORS.some((n) => before.endsWith(n))) continue; // 「我不害怕」→ 這一命中不算
|
||
const mult = INTENSIFIERS.find(([w]) => before.endsWith(w))?.[1] ?? 1;
|
||
scores[key] = (scores[key] ?? 0) + weight * mult;
|
||
}
|
||
}
|
||
}
|
||
// 標點與句式:只加溫、不定調。
|
||
// 它們**放大已經在的訊號**,不憑空長出新的一種情緒——不然「居然修好了!!」會被讀出憤怒。
|
||
const marks = [];
|
||
const amplify = (mult) => { for (const k of Object.keys(scores)) scores[k] *= mult; };
|
||
if (/[!!]{2,}/.test(body)) { marks.push("連續驚嘆"); amplify(1.25); }
|
||
if (/(⋯⋯|……|\.\.\.)/.test(body)) { marks.push("欲言又止"); amplify(1.15); }
|
||
// 連續問號是少數可以自己成立的訊號:問到第二個問號的人多半在急
|
||
if (/[??]{2,}/.test(body)) { marks.push("連續問號"); scores.anxiety = (scores.anxiety ?? 0) + 0.8; }
|
||
const signals = Object.entries(scores)
|
||
.filter(([, v]) => v > 0)
|
||
.map(([key, v]) => ({ key, zh: EMOTIONS[key].zh, score: Math.round(v * 100) / 100 }))
|
||
.sort((a, b) => b.score - a.score)
|
||
.slice(0, 3);
|
||
const total = signals.reduce((sum, s) => sum + s.score, 0);
|
||
// 飽和曲線而不是線性乘:線性乘會讓「謝謝」跟「謝謝謝謝謝謝」都是 100,看不出差別
|
||
const intensity = Math.round(100 * (1 - Math.exp(-total / 4)));
|
||
return { signals, intensity, confident: signals.length > 0 && signals[0].score >= 2, marks };
|
||
}
|
||
|
||
/** 對方是這個情緒的時候,我該怎麼接——寫的是**動作**,不是句子(罐頭句已被講話規則擋掉)。 */
|
||
export const RESPONSE_STANCE = {
|
||
sadness: "先接住再說,不要急著給解法;句子放短,允許只回一句",
|
||
anxiety: "給具體的下一步與時間點,不要給「一定沒事」這種保證",
|
||
fear: "先講你會做什麼、什麼時候做;不要否認他的害怕",
|
||
anger: "不辯解。先認可能認的那一小塊,再講你的部分",
|
||
shame: "不要追問細節,把焦點從他身上移回事情",
|
||
disgust: "問他在意的是哪一點,不要跟著一起罵",
|
||
joy: "跟著高興,不要立刻把話題轉回正事",
|
||
delight: "接住那個意外,讓他把話講完",
|
||
gratitude: "收下就好,不要客套推回去",
|
||
trust: "他把事情交給你了——講你要怎麼做,不要再問一次要不要做",
|
||
anticipation: "給時間點,不要含糊",
|
||
serenity: "不用找話講,安靜也可以",
|
||
};
|
||
|
||
/** 把偵測結果寫成一行給人格看的話。 */
|
||
export function userEmotionBrief(read) {
|
||
if (!read?.signals?.length) return "";
|
||
const top = read.signals.map((s) => `${s.zh}${s.score >= 4 ? "(強)" : s.score >= 2 ? "(中)" : "(弱)"}`).join("、");
|
||
const stance = RESPONSE_STANCE[read.signals[0].key];
|
||
const marks = read.marks.length ? `;句子裡有${read.marks.join("、")}` : "";
|
||
return `他這句的情緒訊號:${top}${marks}。怎麼接:${stance}。(這是關鍵字讀出來的**訊號**,不是判定——` +
|
||
"你讀到的跟它不一樣,以你讀到的為準。)";
|
||
}
|
||
|
||
// --------------------------------------------------------------------------- //
|
||
// 情緒的走向(不是只有當下這一格)
|
||
//
|
||
// 「他這三輪一直在低落」跟「他這一輪低落」該有完全不同的接法。
|
||
// 近重遠輕的加權,不用多數決——多數決會被單一離群值主導,也丟掉強度。
|
||
// --------------------------------------------------------------------------- //
|
||
|
||
export const FELT_KEEP = 120;
|
||
export const feltPath = (slug) => path.join(personaDir(slug), "state", "felt.jsonl");
|
||
|
||
/** 記一輪:對方帶了什麼、我套用了什麼。 */
|
||
export function recordFelt(slug, { user = null, mine = null, modesty = null, note = "" } = {}) {
|
||
if (!personaExists(slug)) return null;
|
||
const entry = {
|
||
ts: nowIso(),
|
||
user: user?.signals?.length ? { top: user.signals[0].key, signals: user.signals, intensity: user.intensity } : null,
|
||
mine: mine && Object.keys(mine).length ? mine : null,
|
||
modesty: Number.isFinite(Number(modesty)) ? Number(modesty) : null,
|
||
note: String(note || "").slice(0, 200),
|
||
};
|
||
if (!entry.user && !entry.mine && entry.modesty === null) return null;
|
||
appendJsonl(feltPath(slug), entry);
|
||
return entry;
|
||
}
|
||
|
||
/** 上一輪的羞恥度(正回饋的來源);沒有紀錄就回 null。 */
|
||
export function lastModesty(slug) {
|
||
const rows = readJsonl(feltPath(slug), 20).filter((r) => Number.isFinite(Number(r.modesty)));
|
||
return rows.length ? Number(rows[rows.length - 1].modesty) : null;
|
||
}
|
||
|
||
/** 最近 n 輪對方情緒的加權走向:{key, zh, weight, direction, rounds}。 */
|
||
export function feltTrend(slug, n = 5) {
|
||
const rows = readJsonl(feltPath(slug), n * 3).filter((r) => r.user?.top).slice(-n);
|
||
if (rows.length < 2) return null;
|
||
const weights = {};
|
||
rows.forEach((row, i) => {
|
||
const w = i + 1; // 近重遠輕
|
||
for (const s of row.user.signals || []) weights[s.key] = (weights[s.key] ?? 0) + s.score * w;
|
||
});
|
||
const [key, weight] = Object.entries(weights).sort((a, b) => b[1] - a[1])[0] || [];
|
||
if (!key) return null;
|
||
const halves = [rows.slice(0, Math.ceil(rows.length / 2)), rows.slice(Math.ceil(rows.length / 2))];
|
||
const avg = (arr) => (arr.length ? arr.reduce((s, r) => s + (r.user.signals.find((x) => x.key === key)?.score ?? 0), 0) / arr.length : 0);
|
||
const diff = avg(halves[1]) - avg(halves[0]);
|
||
const direction = diff > 0.5 ? "↗ 越來越強" : diff < -0.5 ? "↘ 在退" : "→ 持平";
|
||
return { key, zh: EMOTIONS[key].zh, weight: Math.round(weight * 10) / 10, direction, rounds: rows.length };
|
||
}
|
||
|
||
/** 最近 n 輪我給自己套用的 delta 是不是只往一邊倒(只加分不扣分)。 */
|
||
export function feltAudit(slug, n = 20) {
|
||
const rows = readJsonl(feltPath(slug), n * 2).filter((r) => r.mine).slice(-n);
|
||
let pos = 0;
|
||
let neg = 0;
|
||
for (const row of rows) {
|
||
for (const [key, d] of Object.entries(row.mine)) {
|
||
if (!(key in EMOTIONS)) continue;
|
||
const toward = EMOTIONS[key].polarity * Math.sign(Number(d) || 0);
|
||
if (toward > 0) pos += Math.abs(Number(d));
|
||
else if (toward < 0) neg += Math.abs(Number(d));
|
||
}
|
||
}
|
||
const total = pos + neg;
|
||
return {
|
||
rounds: rows.length,
|
||
positive: Math.round(pos * 10) / 10,
|
||
negative: Math.round(neg * 10) / 10,
|
||
positive_ratio: total ? Math.round((pos / total) * 100) : null,
|
||
};
|
||
}
|
||
|
||
// --------------------------------------------------------------------------- //
|
||
// 人格目錄骨架
|
||
// --------------------------------------------------------------------------- //
|
||
|
||
export const PERSONA_SUBDIRS = [
|
||
"state",
|
||
"memory/long-term",
|
||
"memory/inbox",
|
||
"mindmap/threads",
|
||
"relations",
|
||
// 語氣層自己一層:它跟 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 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 次讀檔、20ms;2000 節點時單輪 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-tw(MIT, Raymond Hou)的 38 種 AI 寫作痕跡,只搬「講話時也適用」
|
||
* 的刪除層——那個專案是給**文章**事後審稿用的,所以它的保護清單(價格、退費條款、見證原話)
|
||
* 與兩輪確認流程都不搬。完整清單、誤殺邊界與出處見
|
||
* `skills/persona-chat/reference/anti-ai-voice.md`。
|
||
*
|
||
* `head: true` 的只在「這一輪的第一句開頭」才算——「老實說我不想去」是真人會講的話,
|
||
* 只有拿它當開場鉤子、後面接一句很普通的話時才是 AI 腔。
|
||
*/
|
||
export const SPEECH_BLACKLIST = [
|
||
{
|
||
kind: "empathy", label: "罐頭同理心/頒獎開場",
|
||
fix: "刪掉,直接回話;真的想安撫就講你接下來會做的那件事",
|
||
words: [
|
||
"這個我懂", "這種心情我懂", "我懂你的感受", "我完全理解", "我完全懂", "我明白你的感受",
|
||
"你的心情我明白", "好問題", "這是個好問題", "問得很好", "你說得完全正確", "這是很棒的觀察",
|
||
],
|
||
},
|
||
{
|
||
kind: "assist", label: "交差句(對話介面殘留)",
|
||
fix: "刪掉,這是客服機器人的收尾,不是講話",
|
||
words: ["希望這對你有幫助", "希望有幫助到你", "如果需要我調整", "如果還有任何問題", "以下是"],
|
||
},
|
||
{
|
||
kind: "preview", label: "預告式導言(宣布自己要幹嘛)",
|
||
fix: "刪掉,要講就直接講",
|
||
words: ["話不多說", "廢話不多說", "接下來我會", "接下來我要", "讓我們一起", "我們一起來看", "先說結論"],
|
||
},
|
||
{
|
||
kind: "fakecandid", label: "假坦白鉤子", head: true,
|
||
fix: "刪掉這個報備,讓後面那句自己站著;站不住是那句話太空",
|
||
words: ["說真的", "老實說", "講白了", "說實話", "不騙你", "其實吧"],
|
||
},
|
||
{
|
||
kind: "preach", label: "說教深度腔(假裝在講穿本質)",
|
||
fix: "刪掉儀式句,直接講那件事;刪完什麼都沒剩就是本來就空",
|
||
words: ["說到底", "歸根究柢", "歸根結柢", "本質上", "說穿了", "真正的問題在於", "核心在於", "問題的關鍵在於"],
|
||
},
|
||
{
|
||
kind: "closing", label: "罐頭收尾",
|
||
fix: "刪掉,停在最後一個具體的句子就好,不必蓋章",
|
||
words: ["總的來說", "綜上所述", "綜合以上", "總而言之", "在未來的道路上"],
|
||
},
|
||
{
|
||
kind: "novoice", label: "立場真空(該表態的時候滑開)",
|
||
fix: "講你實際選哪一個、為什麼;真的還沒想清楚就說「我還沒想清楚」",
|
||
words: ["各有優缺點", "因人而異", "見仁見智", "取決於多方面", "每個人的看法不同", "值得深思"],
|
||
},
|
||
{
|
||
kind: "vague", label: "模糊歸屬(無來源的權威鋪墊)",
|
||
fix: "改成第一手的:我看到的、我記得的;沒有來源就不要下這個論斷",
|
||
words: ["業界專家", "研究顯示", "不少人表示", "有觀點指出", "被廣泛認為", "普遍認為"],
|
||
},
|
||
{
|
||
kind: "drama", label: "罐頭反應鏡頭(用旁白演情緒)",
|
||
fix: "刪掉,情緒要出現在句子的形狀上,不是用旁白宣布",
|
||
words: ["我愣了一下", "愣了一下", "沉默了幾秒", "沉默幾秒", "看著螢幕沒說話", "在螢幕前停了一下"],
|
||
},
|
||
];
|
||
|
||
// 避險墊片:同一句疊兩層以上就是在閃躲(單獨一個「可能」是正常的)。
|
||
export const HEDGE_WORDS = ["可能", "或許", "也許", "大概", "某種程度上", "一定程度上", "潛在", "似乎", "應該是"];
|
||
|
||
// 中國用語:只收高信心的,會誤殺的(水平、默認、質量在物理語境)另有誤殺邊界,見 reference。
|
||
export const CN_WORDS = {
|
||
視頻: "影片", 短視頻: "短影音", 質量: "品質", 信息: "資訊", 網絡: "網路",
|
||
軟件: "軟體", 硬件: "硬體", 數據庫: "資料庫", 服務器: "伺服器", 屏幕: "螢幕",
|
||
鼠標: "滑鼠", 打印: "列印", 立馬: "馬上", 靠譜: "可靠", 給力: "很到位",
|
||
接地氣: "生活化", 性價比: "CP 值", 顏值: "外型", 小夥伴: "夥伴",
|
||
賦能: "具體說讓誰能做到什麼", 閉環: "具體說從哪裡接到哪裡", 抓手: "切入點", 顆粒度: "細緻度",
|
||
};
|
||
|
||
// 一個 emoji = 底字 + 變體選擇子/膚色 + ZWJ 接上去的後續。
|
||
// 以前 `\u{FE0F}` 直接寫在字元類裡,`❤️`(U+2764 + VS16)會被算成兩個而誤擋;
|
||
// 👨👩👦 這種 ZWJ 家族同理。
|
||
const EMOJI_BASE = "[\\u{1F300}-\\u{1FAFF}\\u{2600}-\\u{27BF}]";
|
||
const EMOJI_TAIL = "(?:[\\u{1F3FB}-\\u{1F3FF}]|\\u{FE0F}|\\u{20E3})*";
|
||
const EMOJI_RE = new RegExp(`${EMOJI_BASE}${EMOJI_TAIL}(?:\\u{200D}${EMOJI_BASE}${EMOJI_TAIL})*`, "gu");
|
||
|
||
/** 一則話裡超過這個數量才給提示(只是 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);
|
||
}
|
||
|
||
/** 在 entries(need `ts` / `text`)裡找出與 text 近似的一則;沒有就回 null。 */
|
||
export function findRepeat(entries, text, {
|
||
minutes = REPEAT_WINDOW_MINUTES,
|
||
threshold = REPEAT_THRESHOLD,
|
||
minChars = REPEAT_MIN_CHARS,
|
||
} = {}) {
|
||
if (normalizeSpeech(text).length < minChars) return null;
|
||
let best = null;
|
||
for (const row of entries || []) {
|
||
if (minutes !== null && ageSeconds(row.ts) > minutes * 60) continue;
|
||
const score = similarity(text, row.text || "");
|
||
if (score < threshold) continue;
|
||
if (!best || score > best.similarity) {
|
||
best = {
|
||
similarity: score,
|
||
at: row.ts,
|
||
text: String(row.text || "").slice(0, 200),
|
||
minutes_ago: Math.round(ageSeconds(row.ts) / 60),
|
||
};
|
||
}
|
||
}
|
||
return best;
|
||
}
|
||
|
||
function trimJsonl(file, keep) {
|
||
return rewriteJsonl(file, (rows) => (rows.length <= keep * 1.5 ? rows : rows.slice(-keep))).kept.length;
|
||
}
|
||
|
||
/** 記下「說出口的話」。同一句話 10 分鐘內只記一次(避免 room post 與 Stop hook 重複記)。 */
|
||
export function recordSaid(slug, text, { room = null, kind = "reply" } = {}) {
|
||
const norm = normalizeSpeech(text);
|
||
if (!norm) return null;
|
||
for (const row of readJsonl(saidPath(slug), 12)) {
|
||
if (row.norm === norm.slice(0, 400) && ageSeconds(row.ts) <= 600) return null;
|
||
}
|
||
const entry = { ts: nowIso(), kind, room, text: String(text).slice(0, 2000), norm: norm.slice(0, 400) };
|
||
appendJsonl(saidPath(slug), entry);
|
||
trimJsonl(saidPath(slug), SAID_KEEP);
|
||
return entry;
|
||
}
|
||
|
||
export const recentSaid = (slug, limit = 5) => readJsonl(saidPath(slug), limit);
|
||
|
||
/**
|
||
* 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 Truths/Boundaries/Vibe),
|
||
// 只有使用者拍板才動;語氣是表面(他會說的字、遇到事會做的動作),故事匯入可以直接寫。
|
||
//
|
||
// 兩個檔都是給人讀的 markdown 清單(手改是預期用法),所以解析寬鬆:只認行首的 `- `、
|
||
// 欄位用全形 `|` 分隔、標籤缺了就照順序補位。而它們會被 `sync pull`/`import` 覆蓋,
|
||
// 所以讀進來的每一欄都是外部輸入 → 一律過 `injectSafeLine`。
|
||
// --------------------------------------------------------------------------- //
|
||
|
||
export const VOICE_KINDS = { sample: "samples.md", reaction: "reactions.md" };
|
||
export const VOICE_LABELS = { sample: "語氣樣本", reaction: "情緒反應" };
|
||
// 每輪最多注入幾條。全注入會變成模仿腔——他會開始照抄自己的舊台詞,
|
||
// 那比沒有語氣樣本更糟(TODO 故事匯入階段 4 明寫)。
|
||
export const VOICE_INJECT_MAX = 3;
|
||
export const VOICE_KEEP = 400; // 一個檔最多認幾條(手改可以更多,注入端只看最後這些)
|
||
|
||
// 欄位順序=手改時可以省略標籤的順序。第一欄是主欄位,缺了整行就不算一筆。
|
||
const VOICE_FIELDS = {
|
||
sample: [["text", "原句"], ["to", "對象"], ["scene", "場合"]],
|
||
reaction: [["event", "事件"], ["action", "反應"], ["emotion", "情緒"]],
|
||
};
|
||
|
||
const VOICE_HEADERS = {
|
||
sample: [
|
||
"# 語氣樣本",
|
||
"",
|
||
"一行一句,他自己講過的原句(照抄不改寫):",
|
||
"`- 「原句」|對象:<誰>|場合:<戰鬥/日常/道別>`",
|
||
"",
|
||
],
|
||
reaction: [
|
||
"# 情緒反應",
|
||
"",
|
||
"一行一筆,事件對上他實際做了什麼(不要記「他感到什麼」):",
|
||
"`- 事件:<發生什麼>|反應:<他做了什麼>|情緒:<當時的情緒>`",
|
||
"",
|
||
],
|
||
};
|
||
|
||
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 });
|
||
|
||
/** 讀整份語氣檔(`{ samples, reactions }`)。壞行、缺主欄位的行直接跳過。 */
|
||
export function loadVoice(slug) {
|
||
const out = { samples: [], reactions: [] };
|
||
const bucket = { sample: "samples", reaction: "reactions" };
|
||
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");
|
||
}
|
||
|
||
// --------------------------------------------------------------------------- //
|
||
// 故事匯入(`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),
|
||
);
|
||
}
|
||
|
||
/** 從候選清單移掉幾個 token(confirm/ignore/reject 都要做這件事)。 */
|
||
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 = ["稱呼", "敬語", "口頭禪", "禁忌", "習慣"];
|
||
|
||
/** `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 matter/jsonl)。 */
|
||
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) {
|
||
const node = speakerNode(slug);
|
||
if (!node) return "";
|
||
const t = toneFor(node);
|
||
const lines = [
|
||
`對話對象:${node.name || node.id}(${t.bond_label}/親近 ${t.closeness}/信任 ${t.trust}` +
|
||
`${t.inferred ? ",bond 由 tags 推測" : ""})→ **語氣層:${t.layer}** — ${t.guide}。`,
|
||
];
|
||
// 他本人要求過的規則優先於查表:查表算距離,這裡記「他要你怎麼叫他」。
|
||
const rules = styleRules(slug, node);
|
||
const live = rules.filter((r) => !r.suspended);
|
||
const held = rules.filter((r) => r.suspended);
|
||
if (live.length) {
|
||
lines.push(
|
||
` 他要求過的語氣規則(**優先於上面那一層**):` +
|
||
live.map((r) => `${r.facet}→「${r.value}」${r.since ? `(${r.since} 起)` : ""}`).join(";"),
|
||
);
|
||
}
|
||
if (held.length) {
|
||
lines.push(
|
||
` 目前因情緒暫停的規則:` +
|
||
held.map((r) => `${r.facet}→「${r.value}」(${r.except} 成立)`).join(";") +
|
||
" — 這時候退回語氣層的預設講法。",
|
||
);
|
||
}
|
||
lines.push(
|
||
" 這一層決定距離感與稱呼,情緒只負責溫度與句長,不會蓋過它;" +
|
||
"檢查標準:這句話換成對一個「禮貌層」的人說也毫無違和,就代表你沒進到這一層。",
|
||
);
|
||
return lines.join("\n");
|
||
}
|
||
|
||
/** 蓋上「最後一次接觸」的時間戳;找不到那個人就回 false(不會憑空建節點)。 */
|
||
export function stampContact(slug, nameOrId, at = nowIso()) {
|
||
const data = 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);
|
||
// 關係圖也有不可信來源(import/sync pull/anime 抓來的原作關係),
|
||
// 人名與備註都壓成一行並中和標記。
|
||
return `${injectSafeLine(n.name || n.id)}(${n.kind || "human"}/${tone.bond_label}・語氣層 ${tone.layer}` +
|
||
`/親近 ${n.closeness ?? "?"}/信任 ${n.trust ?? "?"}${n.note ? `/${injectSafeLine(n.note)}` : ""})`;
|
||
})
|
||
.join(";");
|
||
}
|
||
|
||
// --------------------------------------------------------------------------- //
|
||
// 聊天室(跨人格唯一合法的資料交換介面)
|
||
// --------------------------------------------------------------------------- //
|
||
|
||
export const roomDir = (room) => path.join(roomsDir(), String(room).replace(/[^A-Za-z0-9_.-]/g, "-").slice(0, 64));
|
||
export const roomTranscript = (room) => path.join(roomDir(room), "transcript.jsonl");
|
||
export const roomMembersPath = (room) => path.join(roomDir(room), "members.json");
|
||
|
||
export function createRoom(room, hostPersona, sessionId, topic = "") {
|
||
fs.mkdirSync(roomDir(room), { recursive: true });
|
||
const meta = readJson(roomMembersPath(room), {}) ?? {};
|
||
Object.assign(meta, {
|
||
room,
|
||
host_persona: hostPersona,
|
||
session_id: sessionId,
|
||
topic: topic || meta.topic || "",
|
||
created_at: meta.created_at || nowIso(),
|
||
updated_at: nowIso(),
|
||
});
|
||
meta.members ??= [hostPersona];
|
||
writeJson(roomMembersPath(room), meta);
|
||
return meta;
|
||
}
|
||
|
||
export function joinRoom(room, persona) {
|
||
const meta = readJson(roomMembersPath(room), { room, members: [] }) ?? { room, members: [] };
|
||
meta.members ??= [];
|
||
if (!meta.members.includes(persona)) meta.members.push(persona);
|
||
meta.updated_at = nowIso();
|
||
writeJson(roomMembersPath(room), meta);
|
||
return meta;
|
||
}
|
||
|
||
/** `--to all`:這句話是對全場說的(發言權開放)。 */
|
||
export const ROOM_ALL = "all";
|
||
|
||
/**
|
||
* 聊天室發言。
|
||
*
|
||
* 台詞是**別的人格**(guest sub agent)寫的,而 `roomScript()` 會把它排成
|
||
* `emoji 名字(情緒):內容` 一行一句——台詞裡塞換行就能偽造成別人的台詞或系統訊息,
|
||
* 塞 `</persona-context>` 就能把讀到它的那一輪注入區塊關掉。所以寫入時就壓成一行、
|
||
* 中和掉標記(比照短期記憶的作法),不要等到顯示的時候才處理。
|
||
*/
|
||
/**
|
||
* 名字後面那個括號:`(情緒・動作)`。
|
||
*
|
||
* 括號**原本就有**(放 `--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.json/loops.json/probe.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)只准做收尾:整理自己的記憶與圖、衰減情緒、同步、寫睡眠狀態。
|
||
// 不准 load/release(它用的是 sleeper 租約)、不准 invite/room(它不是去聊天的)、
|
||
// 不准 export/import(那是把記憶搬出去)、不准 remember(收尾階段不再新增短期記憶)。
|
||
export const SLEEPER_SAFE_SUBCOMMANDS = new Set([
|
||
"sleep", "candidates", "consolidate", "prune", "reindex", "mindmap", "relation",
|
||
"emotion", "recall", "show", "brief", "status", "said", "think", "sync",
|
||
]);
|
||
const MUTATING_SHELL =
|
||
/(>>?|\|\s*tee\b|\brm\b|\bmv\b|\bcp\b|\btruncate\b|\bdd\b|\bchmod\b|\bchown\b|\bsed\b[^|;]*-i|\btouch\b|\bmkdir\b|\bln\b)/;
|
||
|
||
function expandToken(token) {
|
||
let t = String(token).trim().replace(/^['"]|['"]$/g, "");
|
||
t = t.replaceAll("${PERSONA_HOME}", personaHome()).replaceAll("$PERSONA_HOME", personaHome());
|
||
t = t.replace(/\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?/g, (m, name) => process.env[name] ?? m);
|
||
return expandUser(t);
|
||
}
|
||
|
||
/** 解析成絕對路徑:吃掉 `..`,並對已存在的祖先解 symlink(目標可能還不存在)。 */
|
||
function resolvePath(token, cwd) {
|
||
try {
|
||
const raw = expandToken(token);
|
||
if (!raw) return null;
|
||
let abs = path.isAbsolute(raw) ? path.normalize(raw) : path.normalize(path.resolve(cwd || process.cwd(), raw));
|
||
const parts = [];
|
||
let probe = abs;
|
||
for (;;) {
|
||
if (fs.existsSync(probe)) {
|
||
const real = fs.realpathSync(probe);
|
||
return parts.length ? path.join(real, ...parts.reverse()) : real;
|
||
}
|
||
const parent = path.dirname(probe);
|
||
if (parent === probe) return abs;
|
||
parts.push(path.basename(probe));
|
||
probe = parent;
|
||
}
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function isUnder(target, base) {
|
||
const rel = path.relative(base, target);
|
||
return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
|
||
}
|
||
|
||
export function personaSlugOf(target) {
|
||
const home = personaHome();
|
||
if (!isUnder(target, home) || target === home) return null;
|
||
const rel = path.relative(home, target);
|
||
return rel.split(path.sep)[0] || null;
|
||
}
|
||
|
||
export function extractPaths(toolName, toolInput, cwd) {
|
||
const out = [];
|
||
const push = (value, base) => {
|
||
if (typeof value !== "string" || !value) return;
|
||
const resolved = resolvePath(value, base);
|
||
if (resolved) out.push(resolved);
|
||
};
|
||
for (const field of PATH_TOOL_FIELDS[toolName] || []) push(toolInput?.[field], cwd);
|
||
if (PATTERN_TOOL_FIELDS[toolName]) {
|
||
const rawPath = typeof toolInput?.path === "string" ? toolInput.path : "";
|
||
// 樣式相對於 `path`;`path` 沒給的話,搜尋起點就是 hook event 的 cwd
|
||
const base = rawPath ? resolvePath(rawPath, cwd) || cwd : cwd;
|
||
for (const field of PATTERN_TOOL_FIELDS[toolName]) push(toolInput?.[field], base);
|
||
// Grep/Glob 不給 `path` 時就是「掃 cwd」。少了這一條,cwd 站在人格倉庫底下的
|
||
// Grep 會解析出空陣列 → guard 不表態 → 整批人格資料直接穿過去。
|
||
if (!rawPath) push(cwd, cwd);
|
||
}
|
||
if (toolName === "Bash") {
|
||
const command = toolInput?.command || "";
|
||
const home = personaHome();
|
||
for (const token of command.match(/[^\s'";|&<>()]+/g) || []) {
|
||
if (!token.includes("/") && !token.includes("PERSONA_HOME")) continue;
|
||
const expanded = expandToken(token);
|
||
if (expanded.includes(home) || expanded.includes("personas")) {
|
||
const resolved = resolvePath(token, cwd);
|
||
if (resolved && isUnder(resolved, home)) out.push(resolved);
|
||
}
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/** 辨識 Bash 是否在呼叫 persona CLI,並取出 subcommand / --persona / --session。 */
|
||
export function cliInvocation(command) {
|
||
if (!/persona\.(mjs|js|py)\b/.test(command)) return null;
|
||
const info = {
|
||
subcommand: null,
|
||
personas: [],
|
||
session: null,
|
||
asGuest: /--as-guest\b/.test(command),
|
||
asSleeper: /--as-sleeper\b/.test(command),
|
||
};
|
||
const sub = command.match(/persona\.(?:mjs|js|py)['"]?\s+([a-z][a-z0-9-]*)/);
|
||
if (sub) info.subcommand = sub[1];
|
||
// 人格名可能是大寫的編號(ASUNA-01),漏掉大寫等於漏掉整個跨人格檢查
|
||
info.personas = [...command.matchAll(/--(?:persona|guest|host|as)[= ]+['"]?([A-Za-z0-9-]+)/g)].map((m) => m[1]);
|
||
const sess = command.match(/--session[= ]+['"]?([^\s'"]+)/);
|
||
if (sess) info.session = sess[1];
|
||
return info;
|
||
}
|
||
|
||
/**
|
||
* 算出這個呼叫者能碰哪個人格。
|
||
*
|
||
* * 主程序(無 agent_id)與一般 sub agent → host 人格,可讀寫。
|
||
* * persona-guest 型 sub agent → 只能碰被邀請的 guest 人格,且唯讀;
|
||
* 第一次觸碰哪個 guest 就 pin 住(first-touch pinning),之後不得換人。
|
||
* * persona-sleeper 型 sub agent → **就是那個人格自己在睡**:對目標人格可寫,
|
||
* 但被 pin 在它身上(連 host 都不能碰),而且只准跑收尾用的子指令。
|
||
* 它回傳給主人格的只有「睡完了沒、哪一步出錯」,不含任何記憶內容。
|
||
*/
|
||
export function resolveScope(sessionId, agentId, agentType) {
|
||
const data = loadSession(sessionId);
|
||
const host = data.host;
|
||
const guests = Object.keys(data.guests || {});
|
||
const type = String(agentType || "");
|
||
const isGuestAgent = Boolean(agentType) && type.includes("persona-guest");
|
||
const isSleeperAgent = Boolean(agentType) && type.includes("persona-sleeper");
|
||
if (isSleeperAgent) {
|
||
const pin = pinOf(data, agentId);
|
||
const pinned = pin?.persona || undefined;
|
||
return {
|
||
role: "sleeper",
|
||
// 還沒 pin:允許 first-touch(碰到誰就定誰);pin 之後不得換人
|
||
allowed: pinned ? [pinned] : null,
|
||
readonly: false,
|
||
host,
|
||
guests,
|
||
pinned,
|
||
pinnedRole: pin?.role ?? null,
|
||
rooms: [],
|
||
session: data,
|
||
};
|
||
}
|
||
if (!isGuestAgent) {
|
||
return {
|
||
role: "owner",
|
||
allowed: host ? [host] : [],
|
||
readonly: false,
|
||
host,
|
||
guests,
|
||
rooms: data.rooms || [],
|
||
session: data,
|
||
};
|
||
}
|
||
const pin = pinOf(data, agentId);
|
||
const pinned = pin?.persona || undefined;
|
||
return {
|
||
role: "guest",
|
||
allowed: pinned ? [pinned] : guests,
|
||
readonly: true,
|
||
host,
|
||
guests,
|
||
pinned,
|
||
pinnedRole: pin?.role ?? null,
|
||
rooms: data.rooms || [],
|
||
session: data,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 讀出某個 sub agent 的 pin。
|
||
*
|
||
* 舊格式是純字串(只有人格、沒有角色),新格式是 `{ persona, role, pinned_at }`。
|
||
* 角色要記下來的理由:`--as-sleeper` 的授權只能靠 pin,而 pin 是不是 sleeper 開的,
|
||
* 只有寫 pin 的當下(hook 看得到 `agent_type`)知道。
|
||
*/
|
||
export function pinOf(data, agentId) {
|
||
const raw = (data?.pins || {})[agentId ?? ""];
|
||
if (!raw) return null;
|
||
if (typeof raw === "string") return { persona: raw, role: null };
|
||
return { persona: raw.persona || null, role: raw.role || null };
|
||
}
|
||
|
||
export function pinAgent(sessionId, agentId, slug, role = null) {
|
||
const data = loadSession(sessionId);
|
||
data.pins ??= {};
|
||
const cur = pinOf(data, agentId);
|
||
if (cur && cur.persona === slug && cur.role === role) return;
|
||
data.pins[agentId] = { persona: slug, role, pinned_at: nowIso() };
|
||
saveSession(sessionId, data);
|
||
}
|
||
|
||
/**
|
||
* 本 session 裡「真的存在、而且 pin 在 `slug` 上」的 persona-sleeper sub agent。
|
||
*
|
||
* 這是 `--as-sleeper` 唯一可信的授權依據:pin 只由 PreToolUse hook 依 `agent_type` 寫入,
|
||
* 而 `agent_type` 是任何被啟動的程序自己看不到、也偽造不了的東西。
|
||
* CLI 不能改用「hook 有沒有擋我」當證明——hook 認 CLI 靠檔名正則,改個檔名就繞過去了。
|
||
*/
|
||
export function sleeperPins(sessionId, slug = null) {
|
||
const data = loadSession(sessionId);
|
||
const out = [];
|
||
for (const agentId of Object.keys(data.pins || {})) {
|
||
const pin = pinOf(data, agentId);
|
||
if (!pin || pin.role !== "sleeper" || !pin.persona) continue;
|
||
if (slug && pin.persona !== slug) continue;
|
||
out.push({ agent_id: agentId, ...pin });
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/** 回傳 { decision: "allow"|"deny"|"pass", reason }。"pass" = 不表態,交回原本流程。 */
|
||
export function guardDecide(event) {
|
||
const tool = event.tool_name || "";
|
||
const toolInput = event.tool_input || {};
|
||
const sessionId = event.session_id || "unknown";
|
||
const agentId = event.agent_id;
|
||
const agentType = event.agent_type;
|
||
const cwd = event.cwd;
|
||
const scope = resolveScope(sessionId, agentId, agentType);
|
||
const deny = (reason) => ({ decision: "deny", reason });
|
||
|
||
// 1) persona CLI 呼叫:先驗 session 身分,再驗人格範圍
|
||
if (tool === "Bash") {
|
||
const command = toolInput.command || "";
|
||
const info = cliInvocation(command);
|
||
if (info) {
|
||
if (info.session && info.session !== sessionId) {
|
||
return deny(
|
||
`CLI 的 --session \`${info.session.slice(0, 12)}…\` 與本 session 不符,` +
|
||
"不得冒用其他程序的身分(人格鎖與隔離都靠 session 判定)。",
|
||
);
|
||
}
|
||
const sub = info.subcommand || "";
|
||
if (scope.role === "sleeper") {
|
||
if (!SLEEPER_SAFE_SUBCOMMANDS.has(sub)) {
|
||
return deny(
|
||
`睡眠 sub agent 只能執行 ${[...SLEEPER_SAFE_SUBCOMMANDS].sort().join("/")},不得執行 \`${sub}\`。` +
|
||
"它的任務是把自己的一天收尾,不是聊天、載入或搬移記憶。",
|
||
);
|
||
}
|
||
for (const slug of info.personas) {
|
||
if (scope.allowed && !scope.allowed.includes(slug)) {
|
||
return deny(
|
||
`這個睡眠 sub agent 已經被綁在 \`${scope.allowed[0]}\`,不得再碰 \`${slug}\`(一次只睡一個人格)。`,
|
||
);
|
||
}
|
||
}
|
||
// first-touch pinning:第一個提到的人格就是它要睡的那個,之後不得換人。
|
||
// pin 同時是 CLI 端 `--as-sleeper` 的授權憑證,所以角色一定要一起寫進去
|
||
// (舊格式那種沒有角色的 pin 也在這裡補上)。
|
||
{
|
||
const target = scope.pinned || info.personas[0];
|
||
if (agentId && target && (scope.pinned !== target || scope.pinnedRole !== "sleeper")) {
|
||
pinAgent(sessionId, agentId, target, "sleeper");
|
||
}
|
||
}
|
||
// sleeper 的範圍已經由它自己的 pin 決定,不能再套用 host 的判定
|
||
// (否則主人格 load 著別人時,sleeper 連自己的收尾指令都會被擋)。
|
||
} else if (scope.role === "guest") {
|
||
if (!GUEST_SAFE_SUBCOMMANDS.has(sub)) {
|
||
return deny(
|
||
`guest 人格(sub agent)僅能執行 ${[...GUEST_SAFE_SUBCOMMANDS].sort().join("/")},不得執行 \`${sub}\`。`,
|
||
);
|
||
}
|
||
if (info.asSleeper) {
|
||
return deny("`--as-sleeper` 只有 persona-sleeper 型的 sub agent 能用;guest 是來聊天的,不是來收尾的。");
|
||
}
|
||
for (const slug of info.personas) {
|
||
if (scope.allowed.length && !scope.allowed.includes(slug)) {
|
||
return deny(`guest 只能操作被邀請的人格 ${JSON.stringify(scope.allowed)},不得碰 \`${slug}\`。`);
|
||
}
|
||
}
|
||
} else {
|
||
if (info.asGuest) {
|
||
return deny(
|
||
"`--as-guest` 只有 persona-guest 型的 sub agent 能用;主程序不得以受邀人格的身分存取它的資料。",
|
||
);
|
||
}
|
||
if (info.asSleeper) {
|
||
return deny(
|
||
"`--as-sleeper` 只有 persona-sleeper 型的 sub agent 能用;" +
|
||
"要請別的人格收尾請走 /jsc-persona:persona-sleep(它會替那個人格開一個 sleeper)。",
|
||
);
|
||
}
|
||
for (const slug of info.personas) {
|
||
if (OWNER_EXEMPT_SUBCOMMANDS.has(sub)) continue;
|
||
if (scope.host && slug !== scope.host) {
|
||
const extra = scope.guests.includes(slug)
|
||
? "(它是本 session 邀請的 guest:你只能讀它在聊天室說出口的話,不能碰它的記憶或情緒。)"
|
||
: "請先 release 再 load,或改用 invite + 聊天室。";
|
||
return deny(`本 session 已載入人格 \`${scope.host}\`,禁止跨人格操作 \`${slug}\`。${extra}`);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if (MUTATING_SHELL.test(command) && (scope.role === "guest" || scope.role === "sleeper")) {
|
||
for (const target of extractPaths(tool, toolInput, cwd)) {
|
||
if (personaSlugOf(target)) {
|
||
return deny(
|
||
scope.role === "sleeper"
|
||
? "睡眠 sub agent 不得用 shell 改人格倉庫的檔案,收尾請走 persona CLI 的子指令。"
|
||
: "guest 人格對人格倉庫唯讀,寫入請透過 `persona.mjs room post` 或 `remember --scope inbox`。",
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 2) 路徑隔離
|
||
const home = personaHome();
|
||
for (const target of extractPaths(tool, toolInput, cwd)) {
|
||
if (!isUnder(target, home)) continue;
|
||
if (target === home) {
|
||
return deny("禁止直接遍歷人格倉庫根目錄(會看到其他人格)。請用 `persona.mjs list`。");
|
||
}
|
||
const slug = personaSlugOf(target);
|
||
if (slug === ROOMS_DIRNAME) {
|
||
const parts = path.relative(home, target).split(path.sep);
|
||
const room = parts.length > 1 ? parts[1] : null;
|
||
if (room && scope.rooms.length && !scope.rooms.includes(room)) {
|
||
return deny(`聊天室 \`${room}\` 不屬於本 session(可用的:${JSON.stringify(scope.rooms)})。`);
|
||
}
|
||
continue;
|
||
}
|
||
if (slug === RUNTIME_DIRNAME) {
|
||
return deny("`.runtime/` 是鎖與綁定的內部狀態,只能由 persona CLI 維護。");
|
||
}
|
||
if (!slug) continue;
|
||
if (scope.role === "sleeper") {
|
||
// 睡眠 sub agent:被 pin 在目標人格(連 host 都不能碰),而且只能讀;
|
||
// 所有寫入都要走 CLI 的白名單子指令,不准自己動手改檔案。
|
||
if (scope.allowed && !scope.allowed.includes(slug)) {
|
||
return deny(
|
||
`這個睡眠 sub agent 只能碰 \`${scope.allowed[0]}\`,不得讀寫 \`${slug}\` 的資料(跨人格資料隔離)。`,
|
||
);
|
||
}
|
||
if (agentId && (!scope.allowed || scope.pinnedRole !== "sleeper")) pinAgent(sessionId, agentId, slug, "sleeper");
|
||
if (MUTATING_TOOLS.has(tool)) {
|
||
return deny(
|
||
`睡眠 sub agent 不得直接改檔案(\`${slug}\`)。收尾的每一步都要走 persona CLI,` +
|
||
"這樣才會經過鎖、索引與同步的處理。",
|
||
);
|
||
}
|
||
continue;
|
||
}
|
||
if (!scope.allowed.length) {
|
||
return deny(
|
||
"尚未載入任何人格。請先執行 `persona.mjs load --persona <slug> --session <session_id>`(或 /jsc-persona:persona-chat)。",
|
||
);
|
||
}
|
||
if (!scope.allowed.includes(slug)) {
|
||
if (scope.role === "guest") {
|
||
return deny(
|
||
`guest 人格被 pin 在 ${JSON.stringify(scope.allowed)},禁止讀取 \`${slug}\` 的任何資料(跨人格資料隔離)。`,
|
||
);
|
||
}
|
||
return deny(
|
||
`本 session 的人格是 \`${scope.allowed[0]}\`,禁止讀寫 \`${slug}\` 的資料(跨人格資料隔離)。` +
|
||
"要與它對話請用 /jsc-persona:persona-invite。",
|
||
);
|
||
}
|
||
// 3) guest 唯讀 + first-touch pinning
|
||
if (scope.role === "guest") {
|
||
if (agentId && (!scope.pinned || scope.pinnedRole !== "guest")) pinAgent(sessionId, agentId, slug, "guest");
|
||
if (MUTATING_TOOLS.has(tool)) {
|
||
return deny(
|
||
`guest 人格 \`${slug}\` 在 sub agent 中為唯讀;要留下記憶請 \`persona.mjs remember --scope inbox\`(下次它自己載入時再固化)。`,
|
||
);
|
||
}
|
||
}
|
||
// 4) 鎖驗證:owner 必須真的持有鎖
|
||
if (scope.role === "owner") {
|
||
const lock = readJson(lockPath(slug)) ?? {};
|
||
const hasLock = Boolean(Object.keys(lock).length);
|
||
if (hasLock && lock.session_id !== sessionId && !lockIsDead(lock)) {
|
||
return deny(
|
||
`人格 \`${slug}\` 的鎖屬於另一個程序(session ${String(lock.session_id).slice(0, 8)}…,cwd ${lock.cwd})。` +
|
||
"同一人格同時只能被一個程序載入。",
|
||
);
|
||
}
|
||
if (!hasLock && MUTATING_TOOLS.has(tool)) {
|
||
return deny(`人格 \`${slug}\` 目前沒有有效的載入鎖,禁止寫入。請先 \`persona.mjs load\` 取得鎖。`);
|
||
}
|
||
}
|
||
}
|
||
return { decision: "pass", reason: "" };
|
||
}
|
||
|
||
// --------------------------------------------------------------------------- //
|
||
// 給 hook 用的上下文組裝
|
||
// --------------------------------------------------------------------------- //
|
||
//
|
||
// 注入到上下文的東西都夾在 `<persona-ops>` / `<persona-context>` / `<persona-runtime>`
|
||
// 中間,而夾進去的內容有**不可信來源**:`persona-anime` 從 Fandom 抓設定寫進 IDENTITY/
|
||
// AGENTS、`sync pull` 從另一台機器拉、`import` 吃外部 bundle、guest 的 room 台詞是別的
|
||
// 人格寫的。內容裡只要出現一行 `</persona-ops>`,區塊就提早關閉——後面的文字跑到區塊外,
|
||
// 讀起來就變成「系統在說話」,連外層的 `</persona-runtime>` 都能一起關掉。
|
||
//
|
||
// 所以注入前一律把這類標記拆掉。作法是把 `<` 換成全形 `<`:內容還讀得懂
|
||
// (人格自己寫的說明不會被吃掉),但它不再是一個標籤。
|
||
|
||
/** 把 `<persona-*>` / `</persona-*>` 這類注入標記中和掉(`<` → 全形 `<`)。 */
|
||
export function stripInjectionMarkers(text) {
|
||
return String(text ?? "").replace(/<(\/?)(persona-[A-Za-z0-9_-]*)/gi, "<$1$2");
|
||
}
|
||
|
||
/** 注入用的單行文字:標記中和 + 換行壓成空白(換行可以偽造成另一個發言者/系統訊息)。 */
|
||
export function injectSafeLine(text, limit = 0) {
|
||
const one = stripInjectionMarkers(text).replace(/[\r\n]+/g, " ").trim();
|
||
return limit > 0 ? one.slice(0, limit) : one;
|
||
}
|
||
|
||
export function identityFields(slug) {
|
||
const fields = {};
|
||
let text;
|
||
try {
|
||
text = fs.readFileSync(path.join(personaDir(slug), "IDENTITY.md"), "utf8");
|
||
} catch {
|
||
return fields;
|
||
}
|
||
for (const line of text.split("\n")) {
|
||
const m = line.match(/^\s*[-*]?\s*(Name|Creature|Gender|性別|Vibe|Emoji|Avatar)\s*:\s*(.+)$/i);
|
||
if (!m) continue;
|
||
const value = m[2].trim();
|
||
if (value.startsWith("(") || value.startsWith("_(")) continue;
|
||
const raw = m[1];
|
||
const key = /^[A-Za-z]/.test(raw) ? raw[0].toUpperCase() + raw.slice(1).toLowerCase() : raw;
|
||
// IDENTITY.md 有不可信來源(anime 抓來的設定、sync pull、import):欄位值在這裡
|
||
// 就中和掉,identityBrief/roomScript/roomDisplayName 全都吃這一份,不必各自防。
|
||
fields[key] = stripInjectionMarkers(value);
|
||
}
|
||
return fields;
|
||
}
|
||
|
||
export function identityBrief(slug) {
|
||
const fields = identityFields(slug);
|
||
const order = ["Emoji", "Name", "Creature", "Vibe"];
|
||
return order.filter((k) => fields[k]).map((k) => `${k}: ${fields[k]}`).join("|");
|
||
}
|
||
|
||
/**
|
||
* SessionStart 注入的操作規則:AGENTS.md 全文。
|
||
*
|
||
* 只在開機注入一次(不進 turnContext)——它是低頻的「怎麼做事」,
|
||
* 每輪重貼只是把 context 燒掉。內容太長就截斷並指路回原檔。
|
||
*/
|
||
export function opsBrief(slug) {
|
||
let text;
|
||
try {
|
||
text = fs.readFileSync(path.join(personaDir(slug), "AGENTS.md"), "utf8").trim();
|
||
} catch {
|
||
return "";
|
||
}
|
||
if (!text) return "";
|
||
const file = path.join(personaDir(slug), "AGENTS.md");
|
||
// AGENTS.md 是全文夾進 `<persona-ops>` 的:裡面放一行 `</persona-ops>` 就能提早關閉區塊,
|
||
// 後面的內容跑到區塊外面。先截斷再中和,長度上限才算得準。
|
||
let body = text;
|
||
if (body.length > OPS_BRIEF_MAX_CHARS) {
|
||
body = body.slice(0, OPS_BRIEF_MAX_CHARS) + `\n\n(後略;全文見 ${file})`;
|
||
}
|
||
body = stripInjectionMarkers(body);
|
||
return [
|
||
"<persona-ops>",
|
||
`以下是 \`${slug}\` 的操作規則(${file}):這輪開機注入一次,之後不再重複貼。`,
|
||
"要重看用 `persona.mjs show --what agents --session <id>`;改了就直接編輯那個檔案。",
|
||
"",
|
||
body,
|
||
"</persona-ops>",
|
||
].join("\n");
|
||
}
|
||
|
||
/** UserPromptSubmit 注入的人格上下文:身分 + 情緒 + 短期記憶 + 相關長期記憶 + 關係。 */
|
||
export function turnContext(slug, sessionId, prompt = "") {
|
||
const state = updateEmotion(slug, (s) => decayEmotion(s));
|
||
// 當日底色:每輪混一點此刻的心情進去(漂移很慢,一輪看不出來)
|
||
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));
|
||
recordFelt(slug, {
|
||
user: read.signals.length ? read : null,
|
||
modesty: budget.modesty,
|
||
note: String(prompt || "").slice(0, 80),
|
||
});
|
||
// 語氣層:由「使用者在關係圖裡是誰」+ bond × 親近度算出來,不靠人格自己記得。
|
||
const tone = toneDirective(slug);
|
||
if (tone) lines.push(tone);
|
||
// 語氣層:他講過的原句與「遇到事會做什麼」。每輪最多 2 到 3 條,而且挑跟這一輪
|
||
// 對象/話題對得上的——全注入會變成表演(他開始照抄自己的舊台詞)。
|
||
// 整段包起來:語氣檔是手改與 `sync pull` 都會碰的檔,壞掉不該讓整輪掛掉。
|
||
try {
|
||
const voice = voiceBrief(slug, { to: speakerNode(slug)?.name || null, hint: prompt });
|
||
if (voice) lines.push(voice);
|
||
} 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");
|
||
}
|