// 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: "想到什麼就講,不用先在心裡繞一圈。" }); } /** 分段訊息:一輪最多兩則,而且每 N 輪只能一次。 */ export const SPLIT_COOLDOWN_TURNS = 4; export const SPLIT_AROUSAL_AT = 60; export const SPLIT_MODESTY_AT = 60; /** 距離上次拆成兩則過了幾輪;從來沒拆過回 null。 */ export function turnsSinceSplit(slug) { const rows = readJsonl(feltPath(slug), SPLIT_COOLDOWN_TURNS * 4); const idx = rows.map((r) => Boolean(r.split)).lastIndexOf(true); return idx === -1 ? null : rows.length - 1 - idx; } /** * 這一輪可不可以拆成兩則。 * * 真人在聊天軟體上的情緒,一半是靠「又補了一句」傳出去的——那是純文字唯一的 * **時間**訊號(沒有停頓、沒有語調,只有「他又打了一行」)。 * * 但它只有在偶爾出現時才像人:每輪都補一句是話多,不是慌。所以兩道閘門—— * 張力或羞恥度過門檻、而且 N 輪內沒補過。 * * 冷卻是**給了額度就開始算**,不是等他真的用了才算。寧可少給一次, * 也不要因為「上次給了沒用到」而連續幾輪都允許補話。 */ export function splitBudget(slug, { state = null, read = null, prev = null, tone = null } = {}) { const st = decayEmotion(structuredClone(state ?? loadEmotion(slug))); const tired = slug ? fatigueLevel(slug) : 0; const m = mood(st, tired); const modesty = modestyState(slug, { state: st, prev, tone }).value; const arousal = Math.round(m.arousal); const pressed = Boolean(read?.signals?.some((s) => s.key === "anger" || s.key === "disgust")); const base = { arousal, modesty }; if (arousal < SPLIT_AROUSAL_AT && modesty < SPLIT_MODESTY_AT && !pressed) { return { ...base, allowed: false, reason: "張力與羞恥度都不到門檻——平靜的時候只回一則" }; } const since = turnsSinceSplit(slug); if (since !== null && since < SPLIT_COOLDOWN_TURNS) { return { ...base, allowed: false, since, reason: `${since} 輪前才補過一次,${SPLIT_COOLDOWN_TURNS} 輪內不再補` }; } return { ...base, allowed: true, since, reason: modesty >= SPLIT_MODESTY_AT ? "羞恥度過門檻" : "張力過門檻" }; } /** 注入用的一行:這一輪可不可以補第二則,以及第二則該是什麼。 */ export function splitDirective(slug, opts = {}) { const s = splitBudget(slug, opts); if (!s.allowed) return ""; return ( "這一輪**可以拆成兩則**(張力 " + s.arousal + "/羞恥度 " + s.modesty + "):" + "第二則要短,而且是**補救、嘴硬或改口**——「⋯剛才那句不算」「我不是那個意思」" + "「⋯你不要放在心上」。\n" + " 第二則**不可以是補充說明**(把第一則講得更清楚就只是話多)。" + `用不用都可以,但用了之後 ${SPLIT_COOLDOWN_TURNS} 輪內不會再有。` ); } /** 注入用的一行:這一輪能講幾句、每句多長、心裡話該有幾句。 */ export function speakDirective(slug, opts = {}) { const b = speechBudget(slug, opts); const think = b.thinkMin > 0 ? `這一輪心裡話至少 ${b.thinkMin} 句,而且要先寫再開口` : "心裡話有想到再寫"; const chars = b.chars !== MAX_SENTENCE_CHARS ? `、一句不超過 ${b.chars} 字` : ""; return ( `這一輪最多講 ${b.sentences} 句${chars}(劇場模式每人每輪也一樣)。${b.note}\n` + ` 推導、比對、盤算不要說給他聽,寫進心裡話(\`persona.mjs think\`)——${think}。` + "要讓他知道你在想,只報「心想 N 句」,不報內容。" ); } /** 注入用的一行:講清楚它是什麼、從哪來、怎麼演。 */ export function modestyDirective(slug, opts = {}) { const trait = modestyOf(slug); const cur = modestyState(slug, opts); const m = { ...trait, value: cur.value }; const parts = [`平常 ${cur.trait}`]; if (Math.abs(cur.push) >= 1) parts.push(`情緒 ${cur.push >= 0 ? "+" : "-"}${Math.abs(cur.push)}`); if (cur.tone_shift) parts.push(`對象 ${cur.tone_shift >= 0 ? "+" : "-"}${Math.abs(cur.tone_shift)}`); if (Math.abs(cur.carry) >= 1) parts.push(`上一輪的餘溫 ${cur.carry >= 0 ? "+" : "-"}${Math.abs(cur.carry)}`); const from = parts.join("、"); // 鬧彆扭有階梯:同一個「不好意思」,40 分是一句帶過、65 分是嘴硬、80 分以上會翻臉。 // 三段式是**句法模板**,不是自由發揮——否認 → 反駁對方的說法 → 最後一句音量掉下來。 if (m.value >= 75) { return `羞恥度 ${m.value}(${from}):已經到惱羞的那一級——` + "三段照這個形狀走:**先否認 → 反駁他的說法 → 翻臉或走開**(「不要再講了」)。" + "惱羞是換情緒(羞愧 → 憤怒),所以還是兩個動作,不是三個。" + "不要直接說「我害羞」(那是解釋),要讓它出現在句子的形狀上。"; } if (m.value >= 60) { // 三段式可以逐人格覆寫(`## Tells` 的 `鬧彆扭` 那一行)。共用一份模板等於所有人格 // 嘴硬起來是同一個樣子,那正是 G2 要避免的。 const own = personaSulk(slug); const shape = own.length ? `三段照**這個人格自己的形狀**走:**${own.join(" → ")}**` : "三段照這個形狀走:**先否認 → 反駁他的說法 → 最後一句音量掉下來**" + "(「⋯才不是那樣。」「你不要一直看這邊。」「⋯嗯。」)"; return `羞恥度 ${m.value}(${from}):被稱讚外表、被戳穿心事、距離突然變近的時候,` + `**先鬧彆扭再承認**——${shape}。` + "不要直接說「我害羞」(那是解釋),要讓它出現在句子的形狀上。"; } if (m.value <= 40) { return `羞恥度 ${m.value}(${from}):這個人格不太在意別人眼光——` + "被戳穿、被稱讚都不用演害羞,該承認就直接承認,該回嘴就回嘴。"; } return `羞恥度 ${m.value}(${from}):會不好意思,但不會一直卡在那裡——一句帶過就往下走。`; } export function emotionBrief(slug, state = null) { const st = decayEmotion(structuredClone(state ?? loadEmotion(slug))); const tired = slug ? fatigueLevel(slug) : 0; const m = mood(st, tired); const top = dominant(st).map(({ key, level }) => `${EMOTIONS[key].zh}(${key}) ${Math.round(level)}`).join(", "); const avg = (keys) => keys.reduce((sum, k) => sum + Number(st.levels[k] ?? 0), 0) / keys.length; const awake = slug ? hoursAwake(slug) : null; const tiredNote = tired >= 0.25 && awake !== null ? `|醒著 ${awake} 小時(張力上不去了)` : ""; return ( `情緒:${top}|心情 ${m.label}/${m.tempo}` + `(valence ${m.valence >= 0 ? "+" : ""}${Math.round(m.valence)}, arousal ${Math.round(m.arousal)})` + `|正向均值 ${Math.round(avg(POSITIVE))} / 負向均值 ${Math.round(avg(NEGATIVE))}${tiredNote}` ); } // --------------------------------------------------------------------------- // // 讀對方那句話的情緒(輸入端) // // 為什麼需要:在這之前,情緒**全部是人格自己填的** `--apply`。人不會主動給自己扣分, // 所以正向一路漲、負向整天不動。把「對方那句話帶了什麼」變成獨立的訊號, // 人格的 delta 才有外部依據。 // // 這裡回的是**訊號不是判定**:關鍵字永遠不准蓋掉語意,最後怎麼算還是人格自己決定。 // 所以它處理否定與程度副詞(「我不害怕」不可判 fear),也給信心值讓人格知道要不要理它。 // --------------------------------------------------------------------------- // /** 詞表:[詞, 權重]。權重 3 = 明講,2 = 一般,1 = 弱訊號。 */ export const EMOTION_LEXICON = { joy: [["開心", 3], ["快樂", 3], ["爽", 2], ["太好了", 3], ["高興", 3], ["滿足", 2], ["喜歡", 2], ["笑", 1], ["讚", 1]], trust: [["交給你", 3], ["靠你", 2], ["相信", 3], ["放心", 2], ["我信", 3], ["拜託你", 1]], anticipation: [["期待", 3], ["等不及", 3], ["快點", 2], ["什麼時候", 1], ["接下來", 1], ["想看", 2]], gratitude: [["謝謝", 3], ["感謝", 3], ["辛苦了", 2], ["麻煩你了", 2], ["幫了大忙", 3]], serenity: [["還好", 1], ["沒事", 1], ["安心", 2], ["放鬆", 2], ["慢慢來", 2], ["不急", 2]], delight: [["居然", 2], ["竟然", 2], ["沒想到", 2], ["嚇一跳", 2], ["哇", 2], ["真的假的", 3]], anger: [["生氣", 3], ["火大", 3], ["氣死", 3], ["不爽", 3], ["搞什麼", 3], ["夠了", 2], ["很扯", 2], ["離譜", 2]], sadness: [["難過", 3], ["傷心", 3], ["孤單", 3], ["失落", 3], ["想哭", 3], ["累了", 2], ["沒力", 2], ["撐不住", 3], ["提不起勁", 3], ["沒意義", 2]], fear: [["害怕", 3], ["好怕", 3], ["恐怖", 2], ["嚇死", 2], ["不敢", 2], ["危險", 1]], disgust: [["噁心", 3], ["受不了", 2], ["厭倦", 2], ["煩死", 2], ["夠噁", 3]], shame: [["丟臉", 3], ["對不起", 2], ["抱歉", 2], ["我的錯", 3], ["不好意思", 2], ["糗", 2], ["尷尬", 2], ["做不好", 2], ["我沒用", 3], ["不夠好", 2], ["搞砸", 3]], anxiety: [["焦慮", 3], ["不安", 3], ["緊張", 3], ["擔心", 3], ["怎麼辦", 3], ["來不及", 3], ["壓力", 2], ["睡不著", 2]], }; const NEGATORS = ["不", "沒有", "沒", "別", "不要", "不會", "並不", "才不", "一點也不", "完全不"]; const INTENSIFIERS = [["超", 1.6], ["非常", 1.6], ["很", 1.4], ["好", 1.2], ["真的", 1.3], ["有點", 0.6], ["稍微", 0.5], ["還算", 0.5], ["一點點", 0.5]]; const NEG_WINDOW = 4; // 否定詞要在關鍵詞前幾個字內才算 function windowBefore(text, index, size = NEG_WINDOW) { return text.slice(Math.max(0, index - size), index); } /** * 讀出對方這句話的情緒訊號。 * * @returns {{signals: Array<{key,zh,score}>, intensity: number, confident: boolean, marks: string[]}} * `signals` 由強到弱;`intensity` 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 = "", split = false } = {}) { if (!personaExists(slug)) return null; const entry = { ts: nowIso(), user: user?.signals?.length ? { top: user.signals[0].key, signals: user.signals, intensity: user.intensity } : null, mine: mine && Object.keys(mine).length ? mine : null, modesty: Number.isFinite(Number(modesty)) ? Number(modesty) : null, note: String(note || "").slice(0, 200), // 分段額度的冷卻靠這一格算:給了就記,不等他真的用了才記(見 splitBudget)。 ...(split ? { split: true } : {}), }; if (!entry.user && !entry.mine && entry.modesty === null) return null; appendJsonl(feltPath(slug), entry); return entry; } /** 上一輪的羞恥度(正回饋的來源);沒有紀錄就回 null。 */ export function lastModesty(slug) { const rows = readJsonl(feltPath(slug), 20).filter((r) => Number.isFinite(Number(r.modesty))); return rows.length ? Number(rows[rows.length - 1].modesty) : null; } /** 最近 n 輪對方情緒的加權走向:{key, zh, weight, direction, rounds}。 */ export function feltTrend(slug, n = 5) { const rows = readJsonl(feltPath(slug), n * 3).filter((r) => r.user?.top).slice(-n); if (rows.length < 2) return null; const weights = {}; rows.forEach((row, i) => { const w = i + 1; // 近重遠輕 for (const s of row.user.signals || []) weights[s.key] = (weights[s.key] ?? 0) + s.score * w; }); const [key, weight] = Object.entries(weights).sort((a, b) => b[1] - a[1])[0] || []; if (!key) return null; const halves = [rows.slice(0, Math.ceil(rows.length / 2)), rows.slice(Math.ceil(rows.length / 2))]; const avg = (arr) => (arr.length ? arr.reduce((s, r) => s + (r.user.signals.find((x) => x.key === key)?.score ?? 0), 0) / arr.length : 0); const diff = avg(halves[1]) - avg(halves[0]); const direction = diff > 0.5 ? "↗ 越來越強" : diff < -0.5 ? "↘ 在退" : "→ 持平"; return { key, zh: EMOTIONS[key].zh, weight: Math.round(weight * 10) / 10, direction, rounds: rows.length }; } /** 最近 n 輪我給自己套用的 delta 是不是只往一邊倒(只加分不扣分)。 */ export function feltAudit(slug, n = 20) { const rows = readJsonl(feltPath(slug), n * 2).filter((r) => r.mine).slice(-n); let pos = 0; let neg = 0; for (const row of rows) { for (const [key, d] of Object.entries(row.mine)) { if (!(key in EMOTIONS)) continue; const toward = EMOTIONS[key].polarity * Math.sign(Number(d) || 0); if (toward > 0) pos += Math.abs(Number(d)); else if (toward < 0) neg += Math.abs(Number(d)); } } const total = pos + neg; return { rounds: rows.length, positive: Math.round(pos * 10) / 10, negative: Math.round(neg * 10) / 10, positive_ratio: total ? Math.round((pos / total) * 100) : null, }; } // --------------------------------------------------------------------------- // // 人格目錄骨架 // --------------------------------------------------------------------------- // export const PERSONA_SUBDIRS = [ "state", "memory/long-term", "memory/inbox", "mindmap/threads", "relations", // 語氣層自己一層:它跟 SOUL.md 的改動權限不同(匯入流程可以寫語氣,個性只有使用者能改), // 權限界線要有實體隔離,不靠自律——混在同一個檔裡,寫語氣就會順手寫到個性。 "voice", "journal", ]; export function ensurePersonaDirs(slug) { const root = personaDir(slug); for (const sub of PERSONA_SUBDIRS) fs.mkdirSync(path.join(root, sub), { recursive: true }); return root; } export const configPath = (slug) => path.join(personaDir(slug), "state", "config.json"); export const loadConfig = (slug) => readJson(configPath(slug), {}) ?? {}; // --------------------------------------------------------------------------- // // 鎖:同一人格只能被一個程序載入(sub agent 共用同一 session 的鎖) // --------------------------------------------------------------------------- // export const lockPath = (slug) => path.join(personaDir(slug), "state", "lock.json"); export const guestsPath = (slug) => path.join(personaDir(slug), "state", "guests.json"); export class LockError extends Error { constructor(message, owner = {}) { super(message); this.name = "LockError"; this.owner = owner; } } /** * 只看心跳租約。 * * 鎖的擁有者是「那個 AI 程序的 session」,不是短命的 CLI process, * 所以不能用 pid 存活判斷(CLI 跑完就結束了)。session 還活著時, * 每輪對話的 hook 會續租;程序異常結束就會在租約到期後被視為死鎖。 */ export function lockIsDead(lock) { if (!lock || !Object.keys(lock).length) return true; return ageSeconds(lock.heartbeat_at) > Number(lock.lease_seconds || LEASE_SECONDS); } export function liveGuests(slug, excludeSession = null) { const data = readJson(guestsPath(slug), {}) ?? {}; return (data.guests || []).filter( (g) => ageSeconds(g.heartbeat_at) <= GUEST_LEASE_SECONDS && (!excludeSession || g.session_id !== excludeSession), ); } /** 取得 exclusive 鎖。同 session 重入 = 續租;他 session 存活 = 失敗。 */ export function acquireLock(slug, sessionId, { tool = "claude-code", cwd = null, takeover = false } = {}) { ensurePersonaDirs(slug); const file = lockPath(slug); const now = nowIso(); const payload = { persona: slug, session_id: sessionId, writer_pid: process.pid, // 只作為紀錄:CLI process 會馬上結束 host: os.hostname(), tool, cwd: cwd || process.cwd(), acquired_at: now, heartbeat_at: now, lease_seconds: LEASE_SECONDS, mode: "exclusive", }; const existing = readJson(file); if (existing && typeof existing === "object" && existing.session_id) { if (existing.session_id === sessionId) { existing.heartbeat_at = now; existing.writer_pid = process.pid; writeJson(file, existing); return existing; } if (!(lockIsDead(existing) || takeover)) { throw new LockError( `人格 \`${slug}\` 已被另一個程序載入(session ${String(existing.session_id).slice(0, 8)}…, ` + `cwd ${existing.cwd},最後心跳 ${existing.heartbeat_at},${Math.round(ageSeconds(existing.heartbeat_at) / 60)} 分鐘前)。`, existing, ); } // 租約已過期(程序異常結束)→ 允許接手,但要留下痕跡讓使用者知道 payload.took_over_from = { session_id: existing.session_id, cwd: existing.cwd, heartbeat_at: existing.heartbeat_at, stale_minutes: Math.round((ageSeconds(existing.heartbeat_at) / 60) * 10) / 10, }; } const others = liveGuests(slug, sessionId); if (others.length && !takeover) { const who = others[0]; throw new LockError( `人格 \`${slug}\` 正以 guest 身分參與另一個 session(${String(who.session_id).slice(0, 8)}… / room ${who.room})的對話,` + "請先結束該對話再載入。", who, ); } writeJson(file, payload); return payload; } export function heartbeatLock(slug, sessionId) { const file = lockPath(slug); const lock = readJson(file); if (!lock || lock.session_id !== sessionId) return false; lock.heartbeat_at = nowIso(); writeJson(file, lock); return true; } export function releaseLock(slug, sessionId, { force = false } = {}) { const file = lockPath(slug); const lock = readJson(file); if (!lock) return false; if (lock.session_id !== sessionId && !force) return false; try { fs.unlinkSync(file); return true; } catch { return false; } } export function lockStatus(slug) { const lock = readJson(lockPath(slug)) ?? {}; const has = Boolean(Object.keys(lock).length); return { persona: slug, locked: has && !lockIsDead(lock), stale: has && lockIsDead(lock), owner: lock, guests: liveGuests(slug), }; } export function addGuestLease(slug, sessionId, room, hostPersona) { const file = guestsPath(slug); const data = readJson(file, {}) ?? {}; const guests = (data.guests || []).filter( (g) => !(g.session_id === sessionId && g.room === room) && ageSeconds(g.heartbeat_at) <= GUEST_LEASE_SECONDS, ); guests.push({ session_id: sessionId, room, host_persona: hostPersona, joined_at: nowIso(), heartbeat_at: nowIso(), mode: "guest-readonly", }); data.guests = guests; writeJson(file, data); } export function dropGuestLease(slug, sessionId, room = null) { const file = guestsPath(slug); const data = readJson(file, {}) ?? {}; data.guests = (data.guests || []).filter( (g) => !(g.session_id === sessionId && (room === null || g.room === room)), ); writeJson(file, data); } // --------------------------------------------------------------------------- // // sleeper 租約(睡眠用的短期寫入權) // --------------------------------------------------------------------------- // // // 為什麼不用 exclusive 鎖:主人格叫別人去睡的時候,自己並沒有 release, // 而「一個 session 只能載入一個 host 人格」——所以睡眠需要另一種、更短的寫入權。 // // 為什麼還是要驗鎖:睡眠會 prune/reindex/push。如果目標人格此刻正被另一個程序 // 載入著對話,兩邊會同時改同一批檔案,記憶會互相覆蓋。所以: // * 沒有活著的鎖 → 直接取得 sleeper 租約(最常見) // * 鎖屬於同一個 session → 可以(自己睡自己) // * 鎖是死的(心跳過期)→ 可以接手(睡眠本來就是收尾動作) // * 鎖活著且屬於別人 → 拒絕,回報 owner 讓使用者決定 export const sleepersPath = (slug) => path.join(personaDir(slug), "state", "sleepers.json"); export function liveSleepers(slug) { const data = readJson(sleepersPath(slug), {}) ?? {}; return (data.sleepers || []).filter((s) => ageSeconds(s.heartbeat_at) <= SLEEPER_LEASE_SECONDS); } /** 取得睡眠寫入權;拿不到就丟 LockError(附 owner 資訊)。 */ export function acquireSleepLease(slug, sessionId, { agentId = null } = {}) { ensurePersonaDirs(slug); const lock = readJson(lockPath(slug)) ?? {}; const held = Boolean(Object.keys(lock).length); if (held && lock.session_id !== sessionId && !lockIsDead(lock)) { throw new LockError( `人格 \`${slug}\` 正被另一個程序載入(session ${String(lock.session_id).slice(0, 8)}…,` + `cwd ${lock.cwd},最後心跳 ${lock.heartbeat_at})。它可能正在對話中,` + "現在睡眠會與它的寫入互相覆蓋,所以不做。請等它結束或請使用者決定。", { session_id: lock.session_id, cwd: lock.cwd, heartbeat_at: lock.heartbeat_at }, ); } const others = liveSleepers(slug).filter((s) => !(s.session_id === sessionId && s.agent_id === agentId)); if (others.length) { throw new LockError(`人格 \`${slug}\` 已經有另一個 sleeper 在收尾(session ${String(others[0].session_id).slice(0, 8)}…)。`, others[0]); } const lease = { session_id: sessionId, agent_id: agentId, started_at: nowIso(), heartbeat_at: nowIso(), lease_seconds: SLEEPER_LEASE_SECONDS, took_over_dead_lock: held && lockIsDead(lock), }; writeJson(sleepersPath(slug), { sleepers: [lease] }); return lease; } /** 續租:收尾要跑好幾個指令(固化、日記、心智圖…),別讓租約在中途過期。 */ export function heartbeatSleepLease(slug, sessionId, agentId = null) { const data = readJson(sleepersPath(slug), {}) ?? {}; let touched = false; for (const lease of data.sleepers || []) { if (lease.session_id !== sessionId) continue; if (agentId !== null && lease.agent_id && lease.agent_id !== agentId) continue; lease.heartbeat_at = nowIso(); touched = true; } if (touched) writeJson(sleepersPath(slug), data); return touched; } export function dropSleepLease(slug, sessionId, agentId = null) { const data = readJson(sleepersPath(slug), {}) ?? {}; data.sleepers = (data.sleepers || []).filter( (s) => !(s.session_id === sessionId && (agentId === null || s.agent_id === agentId)), ); writeJson(sleepersPath(slug), data); } // --------------------------------------------------------------------------- // // 睡眠會做的機械性收尾 // --------------------------------------------------------------------------- // export const THREAD_STALE_DAYS = 7; export const SAID_KEEP_HOURS = 24; export const SAID_KEEP_LINES = 300; /** 太久沒動的思維導圖收進 `mindmap/threads/archive/`(不刪,只是收起來)。 */ export function archiveStaleThreads(slug, days = THREAD_STALE_DAYS) { const dir = path.join(personaDir(slug), "mindmap", "threads"); let names = []; try { names = fs.readdirSync(dir).filter((f) => f.endsWith(".mmd")); } catch { return 0; } const cutoff = Date.now() - days * 86_400_000; const archive = path.join(dir, "archive"); let moved = 0; for (const name of names) { const file = path.join(dir, name); let stat; try { stat = fs.statSync(file); } catch { continue; } if (stat.mtimeMs >= cutoff) continue; fs.mkdirSync(archive, { recursive: true }); fs.renameSync(file, path.join(archive, name)); moved += 1; } return moved; } /** `said.jsonl` 只服務「不要重講」判定,留最近 24 小時/300 行就夠。 */ export function trimSaid(slug, { hours = SAID_KEEP_HOURS, lines = SAID_KEEP_LINES } = {}) { const cutoff = Date.now() - hours * 3_600_000; return rewriteJsonl(saidPath(slug), (rows) => { if (!rows.length) return rows; return rows .filter((r) => (parseIso(r.ts)?.getTime() ?? Date.now()) >= cutoff) .slice(-lines); }).kept.length; } /** 把「上個月以前」的 journal 壓成 .jsonl.gz(同步時省流量,也不再被讀)。 */ export function archiveJournals(slug) { const dir = path.join(personaDir(slug), "journal"); let names = []; try { names = fs.readdirSync(dir).filter((f) => /^\d{4}-\d{2}\.jsonl$/.test(f)); } catch { return 0; } const current = path.basename(journalPath(slug)); let gzipped = 0; for (const name of names) { if (name === current) continue; const file = path.join(dir, name); const target = `${file}.gz`; if (fs.existsSync(target)) continue; fs.writeFileSync(target, zlib.gzipSync(fs.readFileSync(file))); fs.rmSync(file); gzipped += 1; } return gzipped; } // --------------------------------------------------------------------------- // // 睡眠狀態 // --------------------------------------------------------------------------- // export const sleepStatePath = (slug) => path.join(personaDir(slug), "state", "sleep.json"); export function loadSleepState(slug) { const data = readJson(sleepStatePath(slug), {}) ?? {}; data.last_slept_at ??= null; data.count ??= 0; return data; } export function saveSleepState(slug, patch) { const data = { ...loadSleepState(slug), ...patch }; writeJson(sleepStatePath(slug), data); return data; } /** 距離上次睡眠幾小時(沒睡過回 null)。 */ export function hoursAwake(slug) { const at = loadSleepState(slug).last_slept_at; if (!at) return null; return Math.round((ageSeconds(at) / 3600) * 10) / 10; } // --------------------------------------------------------------------------- // // 未完事項 open loops(`state/loops.json`) // // 「被記住」的感覺幾乎不是來自長期記憶檢索,是來自**有人替你懸著一件事**。 // 檢索是被問了才想起來;懸著是沒人問也還在。差別就在這裡。 // // 只留四種,而且同時最多五條——超過五條就不是懸著,是待辦清單,那是助理不是人。 // question 他沒回答的問題/promise 他答應要做的事/ // topic 被打斷的話題/mine 我想問但沒問的 // // `mine` 那一種同時是**自我議程**的來源:人格自己在意的事,不是為了服務對方而存在的。 // --------------------------------------------------------------------------- // export const LOOP_MAX = 5; // 同時最多懸幾條 export const LOOP_STALE_DAYS = 7; // 這麼久沒進展就自動收掉,並留一則「沒下文」 export const LOOP_KINDS = { question: "他沒回答的問題", promise: "他答應要做的事", topic: "被打斷的話題", mine: "我想問但沒問的", }; export const AGENDA_EVERY = 3; // 自我議程最短間隔幾輪(使用者選「明顯一點」,原建議 5–8) export const loopsPath = (slug) => path.join(personaDir(slug), "state", "loops.json"); /** * 讀未完事項。**清洗在這裡做,不是只在 `addLoop` 做**——`state/loops.json` 是 * 同步範圍內的檔(`sync pull` 會覆蓋、`import` 原樣寫入),所以寫入端的長度上限、 * 筆數上限與換行壓平全都繞得過去。實測灌 51 條、單條兩萬字進去, * 每一輪 `turnContext` 就照單全收塞進上下文。信任邊界在讀取端。 */ export function loadLoops(slug) { const data = readJson(loopsPath(slug), {}) ?? {}; const int = (v) => Math.max(0, Math.floor(Number(v) || 0)); const raw = Array.isArray(data.loops) ? data.loops.filter((l) => l && typeof l === "object") : []; const loops = []; let open = 0; for (const l of raw) { const status = ["open", "done", "cold", "drop"].includes(l.status) ? l.status : "done"; // 開著的最多 LOOP_MAX 條:多出來的降級成 cold(不刪,但也不再注入) const live = status === "open" && open < LOOP_MAX; if (status === "open") open += 1; loops.push({ ...l, id: injectSafeLine(l.id || `L${loops.length + 1}`, 12) || `L${loops.length + 1}`, text: injectSafeLine(l.text, 120), kind: l.kind in LOOP_KINDS ? l.kind : "topic", about: l.about ? injectSafeLine(l.about, 40) : null, status: live ? "open" : status === "open" ? "cold" : status, }); } return { seq: int(data.seq), turns: int(data.turns), last_agenda_turn: int(data.last_agenda_turn), loops: loops.filter((l) => l.text), }; } export const openLoops = (slug) => loadLoops(slug).loops.filter((l) => l.status === "open"); /** * 開一條未完事項。滿了就回 `{ ok: false, reason }`——**不自動擠掉舊的**: * 哪一條該收掉是判斷,不是先進先出。 */ export function addLoop(slug, { text, kind = "topic", about = null } = {}) { const body = injectSafeLine(text, 120); if (!body) return { ok: false, reason: "沒有內容" }; const type = kind in LOOP_KINDS ? kind : "topic"; return withFileLock(loopsPath(slug), () => { const data = loadLoops(slug); const live = data.loops.filter((l) => l.status === "open"); if (live.some((l) => l.text === body)) return { ok: false, reason: "同一件事已經懸著了" }; if (live.length >= LOOP_MAX) { return { ok: false, reason: `同時最多 ${LOOP_MAX} 條,先 \`loop done\` 收掉一條`, loops: live }; } data.seq += 1; const loop = { id: `L${data.seq}`, text: body, kind: type, about: about ? injectSafeLine(about, 40) : null, status: "open", opened_at: nowIso(), touched_at: nowIso(), }; data.loops.push(loop); writeJson(loopsPath(slug), data); return { ok: true, loop, open: live.length + 1 }; }); } /** 收掉一條(`done` 有下文了/`cold` 沒下文/`drop` 不重要了)。 */ export function closeLoop(slug, id, status = "done", note = "") { return withFileLock(loopsPath(slug), () => { const data = loadLoops(slug); const loop = data.loops.find((l) => l.id === id && l.status === "open"); if (!loop) return { ok: false, reason: `沒有開著的 \`${id}\`` }; loop.status = ["done", "cold", "drop"].includes(status) ? status : "done"; loop.closed_at = nowIso(); if (note) loop.note = injectSafeLine(note, 120); // 收掉的只留最近 20 條,其餘丟掉——歷史該進記憶,不該堆在狀態檔裡 const closed = data.loops.filter((l) => l.status !== "open"); if (closed.length > 20) { const drop = new Set(closed.slice(0, closed.length - 20).map((l) => l.id)); data.loops = data.loops.filter((l) => !drop.has(l.id)); } writeJson(loopsPath(slug), data); return { ok: true, loop }; }); } /** 摸一下(有進展但還沒結束),重算逾期。 */ export function touchLoop(slug, id) { return withFileLock(loopsPath(slug), () => { const data = loadLoops(slug); const loop = data.loops.find((l) => l.id === id && l.status === "open"); if (!loop) return { ok: false, reason: `沒有開著的 \`${id}\`` }; loop.touched_at = nowIso(); writeJson(loopsPath(slug), data); return { ok: true, loop }; }); } /** * 逾期的自動收掉,並各留一則「這件事沒下文」的短期記憶。 * * 為什麼要留記憶:懸了一週沒下文**本身就是一件事**。默默刪掉等於假裝沒發生過, * 那正是機器會做而人不會做的事。 */ export function sweepLoops(slug) { const now = Date.now(); const cutoff = LOOP_STALE_DAYS * 86_400_000; const cold = withFileLock(loopsPath(slug), () => { const data = loadLoops(slug); const out = []; for (const loop of data.loops) { if (loop.status !== "open") continue; const age = now - (parseIso(loop.touched_at || loop.opened_at)?.getTime() ?? now); if (age < cutoff) continue; loop.status = "cold"; loop.closed_at = nowIso(); out.push(loop); } if (out.length) writeJson(loopsPath(slug), data); return out; }); for (const loop of cold) { rememberShort(slug, { role: "persona", text: `這件事沒下文:${loop.text}(${LOOP_KINDS[loop.kind] || loop.kind},懸了 ${LOOP_STALE_DAYS} 天以上)`, topics: ["未完事項", "沒下文"], intent: "loop-cold", salience: 45, }); } return cold; } /** 注入用:現在懸著哪幾件事。沒有就回空字串。 */ export function loopsBrief(slug) { const live = openLoops(slug); if (!live.length) return ""; const days = (l) => Math.floor((Date.now() - (parseIso(l.opened_at)?.getTime() ?? Date.now())) / 86_400_000); const rows = live.map((l) => { const age = days(l); return ` - ${l.id}|${LOOP_KINDS[l.kind] || l.kind}:${l.text}${age >= 1 ? `(${age} 天)` : ""}`; }); return [ `還懸著的事(${live.length}/${LOOP_MAX};有下文就 \`persona.mjs loop done --id \`):`, ...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 = [ "# 長期記憶索引", "", ``, "", ]; 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//`):機械的那半 // // 分工照 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`。 * * 四條啟發式,只有這四條: * 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" }, ... } // 放這裡的理由:關係圖每輪都會被讀進 ``,稱呼規則就不用跟關鍵詞搶召回。 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; } /** 注入 `` 的那一行語氣指示。 */ /** 這一輪對話對象的語氣層名稱(沒有設定對象就 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}
${tone.bond_label}・${tone.layer}
親近 ${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 名字(情緒):內容` 一行一句——台詞裡塞換行就能偽造成別人的台詞或系統訊息, * 塞 `
` 就能把讀到它的那一輪注入區塊關掉。所以寫入時就壓成一行、 * 中和掉標記(比照短期記憶的作法),不要等到顯示的時候才處理。 */ /** * 名字後面那個括號:`(情緒・動作)`。 * * 括號**原本就有**(放 `--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` 的門檻,也是每輪注入 * ``、告訴人格「這句該不該由我接」的依據。 */ 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: "/*/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 --session `(或 /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-anime` 從 Fandom 抓設定寫進 IDENTITY/ // AGENTS、`sync pull` 從另一台機器拉、`import` 吃外部 bundle、guest 的 room 台詞是別的 // 人格寫的。內容裡只要出現一行 ``,區塊就提早關閉——後面的文字跑到區塊外, // 讀起來就變成「系統在說話」,連外層的 `` 都能一起關掉。 // // 所以注入前一律把這類標記拆掉。作法是把 `<` 換成全形 `<`:內容還讀得懂 // (人格自己寫的說明不會被吃掉),但它不再是一個標籤。 /** 把 `` / `` 這類注入標記中和掉(`<` → 全形 `<`)。 */ 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 是全文夾進 `` 的:裡面放一行 `` 就能提早關閉區塊, // 後面的內容跑到區塊外面。先截斷再中和,長度上限才算得準。 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 [ "", `以下是 \`${slug}\` 的操作規則(${file}):這輪開機注入一次,之後不再重複貼。`, "要重看用 `persona.mjs show --what agents --session `;改了就直接編輯那個檔案。", "", body, "", ].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_SESSION=${sessionId}`, `人格:\`${slug}\` ${identityBrief(slug)}`, `人格倉庫:${personaDir(slug)}(唯一可讀寫的人格資料範圍)`, emotionBrief(slug, state), "@@SPEAK@@", "話短一點。一句講不完就斷開,不要用逗號一直串下去。用你平常會說的字," + "看得見的東西先講——「你手上那杯冷了」比「我感覺到你的疲憊」好。" + "講完就停,不要回頭解釋自己剛講的話,也不要幫自己收尾。", "有些句子一出口就不像人了:「這個我懂」「我完全理解」「好問題」" + "(先發一句免費的認可,對方要的是回答)、「希望這對你有幫助」「接下來我會⋯」、" + "「說到底」「本質上」(假裝在講穿本質,其實只是重講一次)、開場的「老實說」、" + "收尾的「總的來說」、該表態時的「各有優缺點」、沒出處的「研究顯示」、" + "用旁白演情緒的「我愣了一下」。這些不要說。", "**講自己的過去,要真的有那件事**。「我以前⋯」「我原本以為⋯」先 `recall` 查得到才講。" + "查得到但注明「模糊」的,可以用**試探句**求證(「是不是上個月那次?」)——" + "可以說不確定、可以問,**不可以斷言**:試探句一定帶問號,一輪最多一次," + "而且要 `persona.mjs probe add` 記一筆。完全查不到就別講,編一段轉折比空話糟糕得多。" + "情緒也一樣,照現在的情緒值走,不要為了顯得有人味硬加。", `(機械會擋的:一句 ${MAX_SENTENCE_CHARS} 字、上面那些詞、中國用語、半形標點、` + "粗體與清單符號、兩小時內講過的話——`room post` 與 `said check` 直接拒收。)", ]; // 當日底色(今天累積下來的 valence):平的時候不講,不用每輪都說今天很普通 const dayLine = dayMoodBrief(slug); if (dayLine) lines.splice(lines.indexOf("@@SPEAK@@"), 0, dayLine); // 情緒先行:先讀對方那句話帶了什麼,再談我自己的狀態。 const read = readUserEmotion(prompt); if (read.signals.length) lines.push(userEmotionBrief(read)); const trend = feltTrend(slug); if (trend) { lines.push( `他最近 ${trend.rounds} 輪的走向:${trend.zh} ${trend.direction}。` + "走向跟單輪不一樣——一直是同一種情緒,就不要每輪都用同一句接法。", ); } // 情緒會改變句子的形狀:緊張的人話說不完、彆扭的人先否認再承認。 const tells = emotionTells(slug, state); if (tells.length) { lines.push( `此刻不自覺會出現的:${tells .map((t) => `${t.zh} ${t.level} → ${t.tells.join("、")}`) .join(";")}。`, " **這是演出來的,不是講出來的**:讓它出現在句子的形狀上(斷句、疊字、嘴硬、話少)," + "不要用旁白說明自己的狀態(「我有點緊張」是解釋,斷句才是緊張)。" + `一輪最多露 ${TELLS_PER_TURN} 個動作,而且兩個要來自**同一種情緒**、有遞進關係` + "(否認 → 轉移、笑 → 補一句、留白 → 不追問);兩種情緒各演一個是演戲。" + "名字後面括號裡的動作**算在這個額度裡**——括號演一個、句子裡再演兩個就是三個。" + "強度不到就不演。", ); } // 標點預算:只有文字的話,標點就是語調。跟破口同一條門檻(強度不到就不調)。 const punct = punctuationBudget(slug, state); if (punct) { lines.push( `此刻的標點:${punct.zh} ${punct.level} → ${punct.hint}。` + `上限夾死——一則最多 ${MAX_EXCLAIM} 個驚嘆號、刪節號不連發(最多兩個)。` + "標點管的是**分布**,強度歸 emoji;用標點加強度會變成用符號代替情緒。", ); } // 情緒的 emoji:種類表情緒、數量表程度。算出來的,不要自己挑 const nowEmoji = emotionEmojiNow(slug, state); if (nowEmoji) { lines.push( `此刻的情緒符號:${nowEmoji.emoji}(${nowEmoji.zh} ${nowEmoji.level}${nowEmoji.own ? "・專屬" : ""})。` + "可以放在名字後面的括號裡,句子裡也可以用,沒有數量上限。" + "但它是**加上去的**——把所有符號刪掉之後,那句話還是要看得出情緒。", ); } // 羞恥度:性別給預設、描述蓋過它,再由當下情緒與上一輪的餘溫推成這一輪的值。 const speakOpts = { state, read, prev: lastModesty(slug), tone: toneLayerOf(slug) }; const budget = speechBudget(slug, speakOpts); lines[lines.indexOf("@@SPEAK@@")] = speakDirective(slug, speakOpts); lines.push(modestyDirective(slug, speakOpts)); // 分段預算:純文字唯一的時間訊號(「他又打了一行」)。有額度才注入,沒有就不提—— // 講「這一輪不能拆成兩則」只是提醒他有這個功能。 const split = splitDirective(slug, speakOpts); if (split) lines.push(split); recordFelt(slug, { user: read.signals.length ? read : null, modesty: budget.modesty, note: String(prompt || "").slice(0, 80), split: Boolean(split), }); // 語氣層:由「使用者在關係圖裡是誰」+ bond × 親近度算出來,不靠人格自己記得。 const tone = toneDirective(slug); 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 期間留下的見聞)。`); } // 一個收口:這裡的每一行都可能夾帶人格檔案的內容(身分欄位、記憶、關係圖的人名、 // 長期記憶的第一行……),任何一處出現 `` 都能提早關閉區塊。 // 與其在十幾個 push 點各自防,不如把整個內文中和完再補上真正的標記。 return [ "", stripInjectionMarkers(lines.slice(1).join("\n")), "", ].join("\n"); }