Files
persona/scripts/persona-lib.mjs
T
jiantw83andClaude Opus 5 ab4ddb734d feat(modesty): 羞恥度動態化+三種出口(縮/炸/坦白),心裡話進得了記憶(v0.0.8 同批)
一、羞恥度高不等於話變少——三個出口(他指出來的,我上一版只做對一半)
  縮 shrink   :預設。一句嘴硬,說出口的那句在**迴避**心裡那句
  炸 spill    :慌了(焦慮 ≥55)/惱羞(憤怒 ≥45)/對方在生氣或逼你澄清
                → **4 句,但單句只有 22 字**:掩飾、急著否認、硬轉話題,碎而急
  坦白 confess:羞恥高+信任 ≥75+只有兩個人 → 3 句完整句,憋很久一次講完,
                而且**先寫完心裡話再開口**
  關鍵:句數與單句字數要**一起動**,只調句數分不出「碎念」與「演講」。
  `room post` 本來就同時擋這兩個,參數一改就生效。

二、羞恥度動態化(模擬跑過才實作)
  `modestyState()` = trait + 情緒推力 + 語氣層 + 上一輪餘溫,存進 `felt.jsonl`。
  護欄四道:`MODESTY_GAIN`=0.35(<1 才收斂,0.9 三輪就貼 100)、一輪最多動 15、
  推力上限 25、只算超出基線的部分(比較不開心不代表比較害羞)。
  慢的情緒推力打折(信任半衰期 720 分是底色,不是此刻的事)——
  沒有這條,桐人會因為長期信任高而永遠低於自己的 trait 二十分。
  斷路器是惱羞成怒:anger 權重是負的,被逗到極限翻臉,羞恥自己掉下來。

三、心裡話進得了記憶(但永遠不回顯)
  在這之前心裡話只進 `inner.jsonl`、只被注入最近三句,**永遠不會變成記憶**。
  對害羞的人格來說那是致命的——重要的東西幾乎都在心裡那句。
  現在:`recallInner()` 讓 `recall` 找得到(標「不要講給他聽」),
  `innerCandidates()` 把「24 小時內想過 ≥2 次的同一件事」列成固化候選。
  要不要固化仍由人格自己決定。`think` 的輸出還是只有「💭 心想 N 句」。

四、修掉一個踩到的坑
  `Number(null) === 0` —— 沒有上一輪時 `prev` 退回了 0 而不是 trait,
  害每個人格的羞恥度都從 0 起算。已加測試守著。

測試:328 項全過(新增 14 項)。切詞從貪婪的 {2,4} 改成兩字滑動視窗——
貪婪比對會把「那台舊鐘的齒輪」切成固定區塊,「齒輪」永遠不會單獨出現。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 06:12:18 +00:00

3169 lines
137 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// persona-lib.mjs — jsc-persona 的共用核心(Node.js,只用內建模組)
//
// 負責:
// * 人格倉庫路徑與 slug 規則
// * 單一程序載入鎖(exclusive lock)與 guest lease
// * session 綁定(host / guests / rooms / agent pins / 劇場模式)
// * 十二情緒模型(六正向 + 六負向)與衰減
// * 短期記憶 / 長期記憶 / 心智圖 / 思維導圖 / 人際關係圖 的讀寫
// * 跨人格隔離的判斷核心(guard)
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import crypto from "node:crypto";
import zlib from "node:zlib";
// --------------------------------------------------------------------------- //
// 路徑
// --------------------------------------------------------------------------- //
export const RUNTIME_DIRNAME = ".runtime";
export const ROOMS_DIRNAME = ".rooms";
export const LEASE_SECONDS = 900; // 15 分鐘沒有 heartbeat 視為死鎖,可被接手
export const GUEST_LEASE_SECONDS = 1800; // guestsub agent)租約
export const SLEEPER_LEASE_SECONDS = 300; // sleeper(睡眠 sub agent)租約:只夠做完收尾
export const SLEEP_DECAY_MINUTES = 480; // 睡一次=套用一次 8 小時的情緒衰減
function expandUser(p) {
if (p === "~") return os.homedir();
if (p.startsWith("~/")) return path.join(os.homedir(), p.slice(2));
return p;
}
export function personaHome() {
const raw = process.env.PERSONA_HOME;
if (raw) return path.resolve(expandUser(raw));
return path.resolve(path.join(os.homedir(), ".claude", "personas"));
}
export const runtimeDir = () => path.join(personaHome(), RUNTIME_DIRNAME);
export const sessionsDir = () => path.join(runtimeDir(), "sessions");
export const roomsDir = () => path.join(personaHome(), ROOMS_DIRNAME);
export const personaDir = (slug) => path.join(personaHome(), slug);
// 人格目錄名。新建的人格一律是**人格編號**(`ASUNA-01`:英文名全大寫+兩位索引),
// 但舊的小寫 slug`asuna-sao`)仍然合法,才不會把既有人格鎖在門外。
const SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9-]{0,47}$/;
const RESERVED_SLUGS = new Set([RUNTIME_DIRNAME, ROOMS_DIRNAME, "", ".", ".."]);
export function validSlug(slug) {
return typeof slug === "string" && SLUG_RE.test(slug) && !RESERVED_SLUGS.has(slug);
}
const CJK_CLASS = "\\u3040-\\u30ff\\u3400-\\u4dbf\\u4e00-\\u9fff\\uac00-\\ud7af";
/** 檔名/節點 id 用。保留中日韓字(檔名可讀),其餘壓成連字號;全空則用雜湊。 */
export function slugify(text) {
const src = (text ?? "").normalize("NFKC");
let norm = src.replace(new RegExp(`[^A-Za-z0-9${CJK_CLASS}]+`, "g"), "-").replace(/^-+|-+$/g, "");
norm = norm.replace(/[A-Z]/g, (c) => c.toLowerCase());
if (!norm) {
return "n-" + crypto.createHash("md5").update(String(text ?? "")).digest("hex").slice(0, 8);
}
return norm.slice(0, 48);
}
/** Mermaid 節點別名:只能是英數與底線;非 ASCII 名稱改用穩定雜湊。 */
export function mermaidId(nodeId) {
const alias = String(nodeId ?? "").replace(/[^A-Za-z0-9_]/g, "_");
if (!/[A-Za-z0-9]/.test(alias)) {
return "n_" + crypto.createHash("md5").update(String(nodeId ?? "")).digest("hex").slice(0, 8);
}
return alias;
}
export function listPersonas() {
const home = personaHome();
let entries;
try {
entries = fs.readdirSync(home, { withFileTypes: true });
} catch {
return [];
}
return entries
.filter((e) => e.isDirectory() && validSlug(e.name) && fs.existsSync(path.join(home, e.name, "IDENTITY.md")))
.map((e) => e.name)
.sort();
}
export function personaExists(slug) {
return validSlug(slug) && fs.existsSync(path.join(personaDir(slug), "IDENTITY.md"));
}
// --------------------------------------------------------------------------- //
// 全域設定(跨人格、跨 session)
// --------------------------------------------------------------------------- //
//
// 目前只有一個鍵:`default_persona`——開新 session 時要不要自動載入某個人格。
// 預設是**沒有設定**(等使用者指定),這是刻意的:不該替使用者挑一個人格附身。
export const homeSettingsPath = () => path.join(runtimeDir(), "settings.json");
export function loadHomeSettings() {
return readJson(homeSettingsPath(), {}) ?? {};
}
export function saveHomeSettings(patch) {
const data = { ...loadHomeSettings(), ...patch, updated_at: nowIso() };
writeJson(homeSettingsPath(), data);
return data;
}
/**
* 要自動載入的人格;沒設定就回 null(呼叫端應該「什麼都不做」)。
* 環境變數 `PERSONA_DEFAULT` 優先於設定檔,`off``none`/空字串代表關閉。
*/
export function defaultPersona() {
const env = String(process.env.PERSONA_DEFAULT ?? "").trim();
const raw = env || String(loadHomeSettings().default_persona ?? "").trim();
if (!raw || ["off", "none", "false", "0"].includes(raw.toLowerCase())) return null;
return raw;
}
export function setDefaultPersona(slug) {
return saveHomeSettings({ default_persona: slug || null });
}
// --------------------------------------------------------------------------- //
// 時間與檔案 IO
// --------------------------------------------------------------------------- //
export const iso = (date = new Date()) => new Date(Math.floor(date.getTime() / 1000) * 1000).toISOString().replace(".000Z", "Z");
export const nowIso = () => iso(new Date());
export function parseIso(value) {
if (!value) return null;
const dt = new Date(value);
return Number.isNaN(dt.getTime()) ? null : dt;
}
export function ageSeconds(value) {
const dt = parseIso(value);
if (!dt) return Infinity;
return (Date.now() - dt.getTime()) / 1000;
}
export function minutesAgo(minutes) {
return new Date(Date.now() - minutes * 60_000);
}
export function readJson(file, fallback = null) {
try {
return JSON.parse(fs.readFileSync(file, "utf8"));
} catch {
return fallback;
}
}
export function writeJson(file, obj) {
fs.mkdirSync(path.dirname(file), { recursive: true });
const tmp = `${file}.tmp${process.pid}`;
fs.writeFileSync(tmp, JSON.stringify(obj, null, 2) + "\n", "utf8");
fs.renameSync(tmp, file);
}
export function writeText(file, text) {
fs.mkdirSync(path.dirname(file), { recursive: true });
const tmp = `${file}.tmp${process.pid}`;
fs.writeFileSync(tmp, text, "utf8");
fs.renameSync(tmp, file);
}
/** 單行 appendO_APPEND 對單行寫入是原子的),guest 也能安全使用。 */
export function appendJsonl(file, obj) {
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.appendFileSync(file, JSON.stringify(obj) + "\n", { encoding: "utf8", mode: 0o600 });
}
export function readJsonl(file, limit = null) {
let text;
try {
text = fs.readFileSync(file, "utf8");
} catch {
return [];
}
let lines = text.split("\n").filter((l) => l.trim());
if (limit !== null) lines = lines.slice(-limit);
const out = [];
for (const line of lines) {
try {
out.push(JSON.parse(line));
} catch {
/* 壞行跳過 */
}
}
return out;
}
// --------------------------------------------------------------------------- //
// 十二情緒模型(六正向 + 六負向)
// --------------------------------------------------------------------------- //
// key -> { zh, polarity, arousal 權重, 預設半衰期分鐘 }
export const EMOTIONS = {
// 六正向
joy: { zh: "喜悅", polarity: +1, arousal: 0.6, halfLife: 120 },
trust: { zh: "信任", polarity: +1, arousal: 0.3, halfLife: 720 },
anticipation: { zh: "期待", polarity: +1, arousal: 0.6, halfLife: 240 },
gratitude: { zh: "感激", polarity: +1, arousal: 0.4, halfLife: 480 },
serenity: { zh: "平靜", polarity: +1, arousal: 0.1, halfLife: 180 },
delight: { zh: "驚喜", polarity: +1, arousal: 0.9, halfLife: 60 },
// 六負向
anger: { zh: "憤怒", polarity: -1, arousal: 0.9, halfLife: 90 },
sadness: { zh: "悲傷", polarity: -1, arousal: 0.3, halfLife: 480 },
fear: { zh: "恐懼", polarity: -1, arousal: 0.9, halfLife: 120 },
disgust: { zh: "厭惡", polarity: -1, arousal: 0.5, halfLife: 360 },
shame: { zh: "羞愧", polarity: -1, arousal: 0.5, halfLife: 240 },
anxiety: { zh: "焦慮", polarity: -1, arousal: 0.8, halfLife: 150 },
};
export const EMOTION_KEYS = Object.keys(EMOTIONS);
export const POSITIVE = EMOTION_KEYS.filter((k) => EMOTIONS[k].polarity > 0);
export const NEGATIVE = EMOTION_KEYS.filter((k) => EMOTIONS[k].polarity < 0);
export const DEFAULT_BASELINE = {
joy: 25, trust: 30, anticipation: 20, gratitude: 15, serenity: 40, delight: 5,
anger: 3, sadness: 5, fear: 3, disgust: 3, shame: 3, anxiety: 8,
};
export const emotionPath = (slug) => path.join(personaDir(slug), "state", "emotion.json");
export function clamp(value, lo = 0, hi = 100) {
const num = Number(value);
if (!Number.isFinite(num)) return lo;
return Math.max(lo, Math.min(hi, num));
}
export function defaultEmotionState(baseline = {}) {
const base = { ...DEFAULT_BASELINE };
for (const [k, v] of Object.entries(baseline || {})) {
if (k in EMOTIONS) base[k] = clamp(v);
}
const halfLives = {};
for (const k of EMOTION_KEYS) halfLives[k] = EMOTIONS[k].halfLife;
return {
updated_at: nowIso(),
baseline: base,
levels: { ...base },
half_life_minutes: halfLives,
history_len: 0,
last_trigger: null,
};
}
export function loadEmotion(slug) {
let state = readJson(emotionPath(slug));
if (!state || typeof state !== "object" || !state.levels) state = defaultEmotionState();
state.baseline ??= {};
state.levels ??= {};
state.half_life_minutes ??= {};
for (const key of EMOTION_KEYS) {
state.baseline[key] ??= DEFAULT_BASELINE[key];
state.levels[key] ??= state.baseline[key];
state.half_life_minutes[key] ??= EMOTIONS[key].halfLife;
}
return state;
}
/** 情緒朝 baseline 指數衰減;半衰期依情緒種類不同。 */
export function decayEmotion(state, now = new Date()) {
const last = parseIso(state.updated_at) ?? now;
const minutes = Math.max(0, (now.getTime() - last.getTime()) / 60_000);
if (minutes <= 0) return state;
for (const key of EMOTION_KEYS) {
const half = Number(state.half_life_minutes[key] ?? EMOTIONS[key].halfLife);
const base = Number(state.baseline[key] ?? DEFAULT_BASELINE[key]);
const level = Number(state.levels[key] ?? base);
const factor = half > 0 ? Math.pow(0.5, minutes / half) : 0;
state.levels[key] = Math.round((base + (level - base) * factor) * 100) / 100;
}
state.updated_at = iso(now);
return state;
}
/**
* 明確套用「經過 N 分鐘」的衰減(睡眠用)。
*
* 跟 `decayEmotion` 不同:那個看的是距離上次更新過了多久(真實時間),
* 這個是「就算你才剛聊完,也讓情緒像過了一夜」。強度大的負向情緒半衰期本來就長,
* 所以睡一覺不會把羞愧與悲傷抹平——這是刻意的。
*/
export function decayEmotionBy(state, minutes = SLEEP_DECAY_MINUTES) {
const mins = Math.max(0, Number(minutes) || 0);
if (!mins) return state;
for (const key of EMOTION_KEYS) {
const half = Number(state.half_life_minutes[key] ?? EMOTIONS[key].halfLife);
const base = Number(state.baseline[key] ?? DEFAULT_BASELINE[key]);
const level = Number(state.levels[key] ?? base);
const factor = half > 0 ? Math.pow(0.5, mins / half) : 0;
state.levels[key] = Math.round((base + (level - base) * factor) * 100) / 100;
}
state.updated_at = nowIso();
return state;
}
// --------------------------------------------------------------------------- //
// 情緒調節:飽和與單輪預算
//
// 沒有這兩條的時候,delta 是加完直接 clamp(0,100)——而 delta 一直是人格自己挑的,
// 沒有人會主動給自己扣分,結果就是正向一路貼頂(喜悅/平靜/感激同時 100,
// mood 的 valence 卡在 +100 失去解析度),負向整天不動。
//
// 飽和:越接近端點,同方向的 delta 越小(headroom^K)。永遠逼近 100,不會真的到。
// 預算:一輪之內所有 delta 的絕對值總和有上限,超過就等比例縮小。
// --------------------------------------------------------------------------- //
export const EMOTION_SATURATION_K = 1; // 0 = 不飽和;越大越難推到端點(1 = 線性遞減,永遠逼近但到不了 100)
export const EMOTION_TURN_BUDGET = 60; // 單輪所有 |delta| 的總和上限(emotions.md:單筆建議 ±3~±25
/** 這一筆 delta 實際能推動多少:往端點走會被壓,往 baseline 回來不壓。 */
export function saturateDelta(level, delta, baseline = null) {
const d = Number(delta);
if (!Number.isFinite(d) || d === 0) return 0;
const lv = clamp(level);
// 往 baseline 的方向移動是「回歸」,不該被壓
if (baseline !== null && Number.isFinite(Number(baseline))) {
const base = Number(baseline);
if ((d > 0 && lv < base) || (d < 0 && lv > base)) return d;
}
const headroom = d > 0 ? (100 - lv) / 100 : lv / 100;
return d * Math.pow(Math.max(0, headroom), EMOTION_SATURATION_K);
}
export function applyEmotion(state, deltas, trigger = "") {
state = decayEmotion(state);
// 先算「想推多少」,超過單輪預算就等比例縮小——一次灌爆的路要堵起來
const wanted = [];
for (const [key, rawDelta] of Object.entries(deltas || {})) {
if (!(key in EMOTIONS)) continue;
const delta = Number(rawDelta);
if (!Number.isFinite(delta) || delta === 0) continue;
wanted.push([key, delta]);
}
const demand = wanted.reduce((sum, [, d]) => sum + Math.abs(d), 0);
const budgetScale = demand > EMOTION_TURN_BUDGET ? EMOTION_TURN_BUDGET / demand : 1;
const applied = {};
for (const [key, rawDelta] of wanted) {
const before = Number(state.levels[key] ?? 0);
const base = Number(state.baseline?.[key] ?? DEFAULT_BASELINE[key]);
const effective = saturateDelta(before, rawDelta * budgetScale, base);
state.levels[key] = Math.round(clamp(before + effective) * 100) / 100;
applied[key] = Math.round((state.levels[key] - before) * 100) / 100;
}
state.updated_at = nowIso();
state.history_len = Number(state.history_len || 0) + 1;
if (Object.keys(applied).length) {
state.last_trigger = { at: nowIso(), summary: trigger || "", deltas: applied };
if (budgetScale < 1) state.last_trigger.budget_scaled = Math.round(budgetScale * 100) / 100;
}
return state;
}
export function mood(state) {
const levels = state.levels || {};
let valence = 0;
let arousal = 0;
for (const key of EMOTION_KEYS) {
const level = Number(levels[key] ?? 0);
valence += EMOTIONS[key].polarity * level;
arousal += EMOTIONS[key].arousal * level;
}
valence = Math.round(Math.max(-100, Math.min(100, valence / 3)) * 10) / 10;
arousal = Math.round(Math.min(100, arousal / 3) * 10) / 10;
const label = valence >= 30 ? "正向" : valence <= -30 ? "負向" : "中性";
const tempo = arousal >= 55 ? "高張" : arousal >= 25 ? "平穩" : "低張";
return { valence, arousal, label, tempo };
}
/** 以「超出 baseline 的幅度」排序,才看得出「此刻被觸動什麼」。 */
export function dominant(state, top = 3) {
const levels = state.levels || {};
const base = state.baseline || DEFAULT_BASELINE;
return EMOTION_KEYS
.map((key) => ({ key, level: Number(levels[key] ?? 0), delta: Number(levels[key] ?? 0) - Number(base[key] ?? 0) }))
.sort((a, b) => b.delta - a.delta || b.level - a.level)
.slice(0, top)
.map(({ key, level }) => ({ key, level: Math.round(level * 10) / 10 }));
}
// --------------------------------------------------------------------------- //
// 情緒 → 不自覺會做的事(講話上的破口)
//
// 情緒不改變事實,但會改變**句子的形狀**:緊張的人話說不完、會疊字;彆扭的人
// 先否認再小聲承認。演得像不像就差在這裡——所以這張表跟情緒一起注入,不靠記性。
// 節制很重要:一輪最多露一個破口,不然就從「像人」變成「演戲」。
// --------------------------------------------------------------------------- //
export const EMOTION_TELLS = {
joy: ["話變多、句子輕", "會笑出來(「哈」)", "忍不住多補一句"],
trust: ["話變直、不鋪陳", "省略主語,講一半對方也懂", "敢說「你這樣不對」"],
anticipation: ["搶著問下一步", "反問變多", "把時間講得很具體(「等一下」「明天早上」)"],
gratitude: ["指名是哪一件事", "語尾變軟", "會再說一次謝謝"],
serenity: ["句子完整、節奏慢", "留白,允許沉默", "不追問"],
delight: ["短促的驚呼(「欸」「真的?」)", "句子斷開", "重複確認一次"],
anger: ["句子變短、句號變多", "稱呼退回全名、不叫暱稱", "拒絕修飾,只講事實"],
sadness: ["話少,只回一個詞", "句子沒說完就停", "答非所問,跳開話題"],
fear: ["先確認安全再講別的", "問句連發", "描述身體與動作(手在抖、往後退)"],
disgust: ["用拉開距離的詞(「那個東西」)", "不肯講出對方的名字", "句子往後退、想結束話題"],
shame: ["鬧彆扭:先否認,再小聲承認", "講反話、嘴硬", "轉移話題、把責任講小"],
anxiety: ["句子斷在一半", "疊字(「就、就是」「我、我知道」)", "追一句「這樣可以嗎」"],
};
/**
* 此刻會不自覺出現的行為:取主導情緒(超出基線最多)裡強度夠的前兩種。
* 強度不夠就不演——低強度的情緒在語氣上是看不出來的。
*/
export function emotionTells(slug, state = null, { min = 40, top = 2 } = {}) {
const st = decayEmotion(structuredClone(state ?? loadEmotion(slug)));
// 羞恥敏感度會調「羞愧」這一個破口的門檻:容易害羞的人不用到 40 就藏不住,
// 不在意別人眼光的人就算羞愧上來了也不會表現出來。其他十一種不受影響。
const modesty = slug ? modestyOf(slug).value : 50;
const floorFor = (key) => (key === "shame" ? clamp(min - (modesty - 50) * 0.4, 10, 90) : min);
return dominant(st, 4)
.filter(({ key, level }) => level >= floorFor(key))
.slice(0, top)
.map(({ key, level }) => ({
key,
zh: EMOTIONS[key].zh,
level: Math.round(level),
tells: EMOTION_TELLS[key] || [],
}));
}
// --------------------------------------------------------------------------- //
// 性別與羞恥敏感度
//
// 規則的排序是固定的,不能反過來:
// ① IDENTITY/SOUL 寫的個性描述 ②角色原作既有的性別化語言特徵 ③性別預設
// 性別**只是一個預設值**,不是套在個性上的係數——所以兩個都是女性但個性不同的人格
// 不會講起話來一樣:描述裡任何一句相關的話都會把預設往上或往下推,也可以推到 0。
//
// 影響的範圍刻意只有**一個具名維度**:羞恥敏感度(會不會害羞、會不會鬧彆扭、
// 在不在意別人眼光)。不做「女性→情緒更外顯」這種全域放大,那會把角色壓成模板。
// --------------------------------------------------------------------------- //
export const GENDERS = { female: "女性", male: "男性", nonbinary: "非二元", unknown: "未指定" };
/** 性別給的**預設**羞恥敏感度(0–100)。描述有講就以描述為準。 */
export const GENDER_MODESTY_DEFAULT = { female: 62, male: 38, nonbinary: 50, unknown: 50 };
/** 從描述裡讀「這個角色在不在意別人眼光」。正數=更容易害羞,負數=更不在意。 */
export const MODESTY_SIGNALS = [
["害羞", +14], ["容易臉紅", +14], ["會臉紅", +12], ["怕生", +12], ["靦腆", +14],
["矜持", +12], ["拘謹", +10], ["內向", +8], ["放不開", +10], ["彆扭", +10],
["保守", +8], ["會不好意思", +12], ["在意別人的眼光", +12], ["容易慌", +8],
["不在意別人眼光", -20], ["不在乎別人眼光", -20], ["不在意別人的眼光", -20],
["我行我素", -16], ["大方", -12], ["豪爽", -14], ["臉皮厚", -18], ["不怕丟臉", -18],
["直來直往", -10], ["奔放", -14], ["開放", -8], ["不害羞", -16], ["不會害羞", -16],
];
const FEMALE_RE = /女性|女生|女孩|女子|少女|\bfemale\b|\bwoman\b|\bshe\b/i;
const MALE_RE = /男性|男生|男孩|男子|少年|\bmale\b|\bman\b|\bhe\b/i;
const NONBINARY_RE = /非二元|無性別|nonbinary|non-binary|agender/i;
/**
* 從 IDENTITY 的 `Gender`(或 `性別`)欄位讀;沒有就從 `Creature` 推,推不出來就 unknown。
*
* **只看 Creature,不看 Avatar**Avatar 是外觀的散文,雜訊很多——我自己的 Avatar 寫著
* 「五官清秀到常被誤認成女生」,用它來推性別會直接推錯。同一句裡兩種都出現時,看誰先出現。
*/
export function genderOf(slug) {
const fields = identityFields(slug);
const raw = String(fields.Gender || fields["性別"] || "").trim();
if (raw) {
if (NONBINARY_RE.test(raw)) return { key: "nonbinary", explicit: true };
if (FEMALE_RE.test(raw)) return { key: "female", explicit: true };
if (MALE_RE.test(raw)) return { key: "male", explicit: true };
}
const creature = String(fields.Creature || "");
if (NONBINARY_RE.test(creature)) return { key: "nonbinary", explicit: false };
const f = creature.search(FEMALE_RE);
const m = creature.search(MALE_RE);
if (f >= 0 && (m < 0 || f < m)) return { key: "female", explicit: false };
if (m >= 0) return { key: "male", explicit: false };
return { key: "unknown", explicit: false };
}
/**
* 羞恥敏感度(0–100):性別給預設,IDENTITY/SOUL 的描述往上或往下推。
* `source` 說明它是怎麼來的,注入時要講清楚——不然使用者會以為這是憑空長出來的。
*/
export function modestyOf(slug) {
const gender = genderOf(slug);
const base = GENDER_MODESTY_DEFAULT[gender.key] ?? 50;
const read = (file) => {
try { return fs.readFileSync(path.join(personaDir(slug), file), "utf8"); } catch { return ""; }
};
const text = `${read("IDENTITY.md")}\n${read("SOUL.md")}`;
const hits = [];
let adjust = 0;
for (const [word, weight] of MODESTY_SIGNALS) {
if (!text.includes(word)) continue;
// 「不害羞」要蓋掉「害羞」:長的詞先算,短的被包含在裡面就不重複計
if (hits.some(([w]) => w.includes(word))) continue;
hits.push([word, weight]);
adjust += weight;
}
return {
value: Math.round(clamp(base + adjust)),
gender: gender.key,
gender_zh: GENDERS[gender.key],
gender_explicit: gender.explicit,
base,
adjust,
signals: hits.map(([w, v]) => `${w}${v > 0 ? "+" : ""}${v}`),
};
}
// --------------------------------------------------------------------------- //
// 羞恥度的當下值:情緒會推它,而且它自己有回饋
//
// trait 是身分算出來的基線(性別預設+描述),state 是這一輪的值。
// 三件事讓它不會失控:
// * 增益 < 1 —— 正回饋的級數才會收斂(gain 0.9 三輪就貼 100,那是反例)
// * 一輪最多動 MODESTY_MAX_STEP —— 不然忽高忽低
// * 只算「超出基線」的部分 —— 比較不開心不代表比較害羞
// 天然的斷路器是「惱羞成怒」:anger 的權重是負的,被逗到極限翻臉,羞恥自己就掉下來。
// --------------------------------------------------------------------------- //
export const MODESTY_WEIGHTS = {
shame: +0.45, anxiety: +0.25, fear: +0.15,
anger: -0.35, delight: -0.30, joy: -0.20, trust: -0.20, serenity: -0.10, sadness: -0.05,
};
export const MODESTY_GAIN = 0.35; // 正回饋增益,必須 < 1
export const MODESTY_MAX_STEP = 15; // 一輪最多移動多少
export const MODESTY_PUSH_CAP = 25; // 情緒總推力的上限,避免底色把人推到另一個人
export const MODESTY_TONE_SHIFT = {
老夫老妻: -8, 摯友: -6, 手足: -4, 母親: -2, 父親: -2, 並肩: 0, 交往中: +4, 敬重: +6, 禮貌: +10,
};
/** 累了(arousal 低)什麼都演不動;興奮時放大。 */
function modestyResponsiveness(state) {
return 0.35 + 0.014 * mood(state).arousal;
}
/**
* 這一輪的羞恥度。`prev` 是上一輪的值(沒有就用 trait),回饋從它來。
*/
export function modestyState(slug, { state = null, prev = null, tone = null } = {}) {
const trait = modestyOf(slug);
const st = decayEmotion(structuredClone(state ?? loadEmotion(slug)));
const base = st.baseline || DEFAULT_BASELINE;
let push = 0;
for (const [key, w] of Object.entries(MODESTY_WEIGHTS)) {
// 慢的情緒推得少:信任長期就掛在高點,那是底色不是「此刻的事」,
// 不該讓一個人永遠比自己的 trait 低二十分。快的情緒(驚喜、憤怒)才是當下的推力。
const speed = clamp(Math.sqrt(240 / (EMOTIONS[key]?.halfLife || 240)), 0.4, 1.3);
push += w * speed * Math.max(0, Number(st.levels[key] ?? 0) - Number(base[key] ?? 0));
}
push = clamp(push * modestyResponsiveness(st), -MODESTY_PUSH_CAP, MODESTY_PUSH_CAP);
const toneShift = tone ? (MODESTY_TONE_SHIFT[tone] ?? 0) : 0;
const target = trait.value + push + toneShift;
// 注意 Number(null) === 0:沒有上一輪的時候要退回 trait,不是退回 0
const from = prev === null || prev === undefined || !Number.isFinite(Number(prev))
? trait.value
: Number(prev);
const carry = (from - trait.value) * MODESTY_GAIN; // 上一輪的餘溫=正回饋
const wanted = target + carry;
const stepped = Math.max(from - MODESTY_MAX_STEP, Math.min(from + MODESTY_MAX_STEP, wanted));
return {
trait: trait.value,
value: Math.round(clamp(stepped) * 10) / 10,
push: Math.round(push * 10) / 10,
tone_shift: toneShift,
carry: Math.round(carry * 10) / 10,
gender_zh: trait.gender_zh,
signals: trait.signals,
};
}
/**
* 這一輪能講幾句、每句多長、心裡話至少幾句。
*
* 羞恥度高**不是**話一定變少,是話的形狀變了——真人有三個出口:
*
* 縮 shrink :一句嘴硬,或只回半句(預設)
* 炸 spill :慌到掩飾、急著否認、硬轉話題、惱羞成怒——**句數變多但每句更短更碎**
* 坦白 confess:羞恥高但信任也高、又只有兩個人——憋很久的一次講完,句子是完整的
*
* 只調句數分不出「碎念」與「演講」,所以句數與**單句字數**要一起動。
*/
export function speechBudget(slug, { state = null, read = null, alone = true, prev = null, tone = null } = {}) {
const m = modestyState(slug, { state, prev, tone });
const st = decayEmotion(structuredClone(state ?? loadEmotion(slug)));
const lv = (k) => Number(st.levels[k] ?? 0);
const base = { modesty: m.value, trait: m.trait, chars: MAX_SENTENCE_CHARS };
// 炸:慌了、火了,或者對方那句話帶著誤會與追問
const pressed = Boolean(read?.signals?.some((s) => s.key === "anger" || s.key === "disgust"));
if (m.value >= 60 && (lv("anxiety") >= 55 || lv("anger") >= 45 || pressed)) {
return {
...base, mode: "spill", sentences: 4, chars: 22, thinkMin: 1,
note: "現在是慌的/火的:話會變多但**每句更短更碎**——掩飾、急著否認、硬轉話題都可以," +
"允許重複和疊字,但不要講出完整流暢的長句,那不是慌。",
};
}
// 坦白:憋很久的話,在安全的人面前一次講完
if (m.value >= 60 && alone && lv("trust") >= 75) {
return {
...base, mode: "confess", sentences: 3, thinkMin: 2,
note: "只有你們兩個,而且你信他:這種時候憋很久的話會一次講完——句子可以完整," +
"講完就空了。**先把心裡話寫完再開口**,那句話正是心裡憋著的那句。",
};
}
if (m.value >= 75) {
return {
...base, mode: "shrink", sentences: 2, thinkMin: 2,
note: "話會變少,而且說出口的那句常常在**迴避**心裡那句——先把心裡話寫完再開口," +
"台詞不可以是心裡話的摘要(心裡想「我一直在等你問」,出口就不能是「我有在等你問」)。",
};
}
if (m.value >= 50) {
return { ...base, mode: "shrink", sentences: 3, thinkMin: 1, note: "心裡想到的比講出來的多一點。" };
}
return { ...base, mode: "open", sentences: MAX_SENTENCES, thinkMin: 0, note: "想到什麼就講,不用先在心裡繞一圈。" };
}
/** 注入用的一行:這一輪能講幾句、每句多長、心裡話該有幾句。 */
export function speakDirective(slug, opts = {}) {
const b = speechBudget(slug, opts);
const think = b.thinkMin > 0 ? `這一輪心裡話至少 ${b.thinkMin} 句,而且要先寫再開口` : "心裡話有想到再寫";
const chars = b.chars !== MAX_SENTENCE_CHARS ? `、一句不超過 ${b.chars} 字` : "";
return (
`這一輪最多講 ${b.sentences}${chars}(劇場模式每人每輪也一樣)。${b.note}\n` +
` 推導、比對、盤算不要說給他聽,寫進心裡話(\`persona.mjs think\`)——${think}。` +
"要讓他知道你在想,只報「心想 N 句」,不報內容。"
);
}
/** 注入用的一行:講清楚它是什麼、從哪來、怎麼演。 */
export function modestyDirective(slug, opts = {}) {
const trait = modestyOf(slug);
const cur = modestyState(slug, opts);
const m = { ...trait, value: cur.value };
const parts = [`平常 ${cur.trait}`];
if (Math.abs(cur.push) >= 1) parts.push(`情緒 ${cur.push >= 0 ? "" : ""}${Math.abs(cur.push)}`);
if (cur.tone_shift) parts.push(`對象 ${cur.tone_shift >= 0 ? "" : ""}${Math.abs(cur.tone_shift)}`);
if (Math.abs(cur.carry) >= 1) parts.push(`上一輪的餘溫 ${cur.carry >= 0 ? "" : ""}${Math.abs(cur.carry)}`);
const from = parts.join("、");
if (m.value >= 60) {
return `羞恥度 ${m.value}${from}):被稱讚外表、被戳穿心事、距離突然變近的時候,` +
"**先鬧彆扭再承認**——嘴硬一句、把話講一半、轉移到別的東西上。" +
"不要直接說「我害羞」(那是解釋),要讓它出現在句子的形狀上。";
}
if (m.value <= 40) {
return `羞恥度 ${m.value}${from}):這個人格不太在意別人眼光——` +
"被戳穿、被稱讚都不用演害羞,該承認就直接承認,該回嘴就回嘴。";
}
return `羞恥度 ${m.value}${from}):會不好意思,但不會一直卡在那裡——一句帶過就往下走。`;
}
export function emotionBrief(slug, state = null) {
const st = decayEmotion(structuredClone(state ?? loadEmotion(slug)));
const m = mood(st);
const top = dominant(st).map(({ key, level }) => `${EMOTIONS[key].zh}(${key}) ${Math.round(level)}`).join(", ");
const avg = (keys) => keys.reduce((sum, k) => sum + Number(st.levels[k] ?? 0), 0) / keys.length;
return (
`情緒:${top}|心情 ${m.label}/${m.tempo}` +
`valence ${m.valence >= 0 ? "+" : ""}${Math.round(m.valence)}, arousal ${Math.round(m.arousal)}` +
`|正向均值 ${Math.round(avg(POSITIVE))} / 負向均值 ${Math.round(avg(NEGATIVE))}`
);
}
// --------------------------------------------------------------------------- //
// 讀對方那句話的情緒(輸入端)
//
// 為什麼需要:在這之前,情緒**全部是人格自己填的** `--apply`。人不會主動給自己扣分,
// 所以正向一路漲、負向整天不動。把「對方那句話帶了什麼」變成獨立的訊號,
// 人格的 delta 才有外部依據。
//
// 這裡回的是**訊號不是判定**:關鍵字永遠不准蓋掉語意,最後怎麼算還是人格自己決定。
// 所以它處理否定與程度副詞(「我不害怕」不可判 fear),也給信心值讓人格知道要不要理它。
// --------------------------------------------------------------------------- //
/** 詞表:[詞, 權重]。權重 3 = 明講,2 = 一般,1 = 弱訊號。 */
export const EMOTION_LEXICON = {
joy: [["開心", 3], ["快樂", 3], ["爽", 2], ["太好了", 3], ["高興", 3], ["滿足", 2], ["喜歡", 2], ["笑", 1], ["讚", 1]],
trust: [["交給你", 3], ["靠你", 2], ["相信", 3], ["放心", 2], ["我信", 3], ["拜託你", 1]],
anticipation: [["期待", 3], ["等不及", 3], ["快點", 2], ["什麼時候", 1], ["接下來", 1], ["想看", 2]],
gratitude: [["謝謝", 3], ["感謝", 3], ["辛苦了", 2], ["麻煩你了", 2], ["幫了大忙", 3]],
serenity: [["還好", 1], ["沒事", 1], ["安心", 2], ["放鬆", 2], ["慢慢來", 2], ["不急", 2]],
delight: [["居然", 2], ["竟然", 2], ["沒想到", 2], ["嚇一跳", 2], ["哇", 2], ["真的假的", 3]],
anger: [["生氣", 3], ["火大", 3], ["氣死", 3], ["不爽", 3], ["搞什麼", 3], ["夠了", 2], ["很扯", 2], ["離譜", 2]],
sadness: [["難過", 3], ["傷心", 3], ["孤單", 3], ["失落", 3], ["想哭", 3], ["累了", 2], ["沒力", 2], ["撐不住", 3], ["提不起勁", 3], ["沒意義", 2]],
fear: [["害怕", 3], ["好怕", 3], ["恐怖", 2], ["嚇死", 2], ["不敢", 2], ["危險", 1]],
disgust: [["噁心", 3], ["受不了", 2], ["厭倦", 2], ["煩死", 2], ["夠噁", 3]],
shame: [["丟臉", 3], ["對不起", 2], ["抱歉", 2], ["我的錯", 3], ["不好意思", 2], ["糗", 2], ["尷尬", 2],
["做不好", 2], ["我沒用", 3], ["不夠好", 2], ["搞砸", 3]],
anxiety: [["焦慮", 3], ["不安", 3], ["緊張", 3], ["擔心", 3], ["怎麼辦", 3], ["來不及", 3], ["壓力", 2], ["睡不著", 2]],
};
const NEGATORS = ["不", "沒有", "沒", "別", "不要", "不會", "並不", "才不", "一點也不", "完全不"];
const INTENSIFIERS = [["超", 1.6], ["非常", 1.6], ["很", 1.4], ["好", 1.2], ["真的", 1.3], ["有點", 0.6], ["稍微", 0.5], ["還算", 0.5], ["一點點", 0.5]];
const NEG_WINDOW = 4; // 否定詞要在關鍵詞前幾個字內才算
function windowBefore(text, index, size = NEG_WINDOW) {
return text.slice(Math.max(0, index - size), index);
}
/**
* 讀出對方這句話的情緒訊號。
*
* @returns {{signals: Array<{key,zh,score}>, intensity: number, confident: boolean, marks: string[]}}
* `signals` 由強到弱;`intensity` 0100`confident` 表示夠強、值得讓人格參考。
*/
export function readUserEmotion(text) {
const raw = String(text ?? "");
// 引號裡是別人的話、code 是資料——跟講話的樣子同一套規則:提及不算使用
const body = raw.replace(/`[^`]*`/g, " ").replace(/「[^」]*」|『[^』]*』/g, " ");
const scores = {};
for (const [key, words] of Object.entries(EMOTION_LEXICON)) {
for (const [word, weight] of words) {
let from = 0;
for (;;) {
const at = body.indexOf(word, from);
if (at < 0) break;
from = at + word.length;
const before = windowBefore(body, at);
if (NEGATORS.some((n) => before.endsWith(n))) continue; // 「我不害怕」→ 這一命中不算
const mult = INTENSIFIERS.find(([w]) => before.endsWith(w))?.[1] ?? 1;
scores[key] = (scores[key] ?? 0) + weight * mult;
}
}
}
// 標點與句式:只加溫、不定調。
// 它們**放大已經在的訊號**,不憑空長出新的一種情緒——不然「居然修好了!!」會被讀出憤怒。
const marks = [];
const amplify = (mult) => { for (const k of Object.keys(scores)) scores[k] *= mult; };
if (/[!]{2,}/.test(body)) { marks.push("連續驚嘆"); amplify(1.25); }
if (/(⋯⋯|……|\.\.\.)/.test(body)) { marks.push("欲言又止"); amplify(1.15); }
// 連續問號是少數可以自己成立的訊號:問到第二個問號的人多半在急
if (/[?]{2,}/.test(body)) { marks.push("連續問號"); scores.anxiety = (scores.anxiety ?? 0) + 0.8; }
const signals = Object.entries(scores)
.filter(([, v]) => v > 0)
.map(([key, v]) => ({ key, zh: EMOTIONS[key].zh, score: Math.round(v * 100) / 100 }))
.sort((a, b) => b.score - a.score)
.slice(0, 3);
const total = signals.reduce((sum, s) => sum + s.score, 0);
// 飽和曲線而不是線性乘:線性乘會讓「謝謝」跟「謝謝謝謝謝謝」都是 100,看不出差別
const intensity = Math.round(100 * (1 - Math.exp(-total / 4)));
return { signals, intensity, confident: signals.length > 0 && signals[0].score >= 2, marks };
}
/** 對方是這個情緒的時候,我該怎麼接——寫的是**動作**,不是句子(罐頭句已被講話規則擋掉)。 */
export const RESPONSE_STANCE = {
sadness: "先接住再說,不要急著給解法;句子放短,允許只回一句",
anxiety: "給具體的下一步與時間點,不要給「一定沒事」這種保證",
fear: "先講你會做什麼、什麼時候做;不要否認他的害怕",
anger: "不辯解。先認可能認的那一小塊,再講你的部分",
shame: "不要追問細節,把焦點從他身上移回事情",
disgust: "問他在意的是哪一點,不要跟著一起罵",
joy: "跟著高興,不要立刻把話題轉回正事",
delight: "接住那個意外,讓他把話講完",
gratitude: "收下就好,不要客套推回去",
trust: "他把事情交給你了——講你要怎麼做,不要再問一次要不要做",
anticipation: "給時間點,不要含糊",
serenity: "不用找話講,安靜也可以",
};
/** 把偵測結果寫成一行給人格看的話。 */
export function userEmotionBrief(read) {
if (!read?.signals?.length) return "";
const top = read.signals.map((s) => `${s.zh}${s.score >= 4 ? "(強)" : s.score >= 2 ? "(中)" : "(弱)"}`).join("、");
const stance = RESPONSE_STANCE[read.signals[0].key];
const marks = read.marks.length ? `;句子裡有${read.marks.join("、")}` : "";
return `他這句的情緒訊號:${top}${marks}。怎麼接:${stance}。(這是關鍵字讀出來的**訊號**,不是判定——` +
"你讀到的跟它不一樣,以你讀到的為準。)";
}
// --------------------------------------------------------------------------- //
// 情緒的走向(不是只有當下這一格)
//
// 「他這三輪一直在低落」跟「他這一輪低落」該有完全不同的接法。
// 近重遠輕的加權,不用多數決——多數決會被單一離群值主導,也丟掉強度。
// --------------------------------------------------------------------------- //
export const FELT_KEEP = 120;
export const feltPath = (slug) => path.join(personaDir(slug), "state", "felt.jsonl");
/** 記一輪:對方帶了什麼、我套用了什麼。 */
export function recordFelt(slug, { user = null, mine = null, modesty = null, note = "" } = {}) {
if (!personaExists(slug)) return null;
const entry = {
ts: nowIso(),
user: user?.signals?.length ? { top: user.signals[0].key, signals: user.signals, intensity: user.intensity } : null,
mine: mine && Object.keys(mine).length ? mine : null,
modesty: Number.isFinite(Number(modesty)) ? Number(modesty) : null,
note: String(note || "").slice(0, 200),
};
if (!entry.user && !entry.mine && entry.modesty === null) return null;
appendJsonl(feltPath(slug), entry);
return entry;
}
/** 上一輪的羞恥度(正回饋的來源);沒有紀錄就回 null。 */
export function lastModesty(slug) {
const rows = readJsonl(feltPath(slug), 20).filter((r) => Number.isFinite(Number(r.modesty)));
return rows.length ? Number(rows[rows.length - 1].modesty) : null;
}
/** 最近 n 輪對方情緒的加權走向:{key, zh, weight, direction, rounds}。 */
export function feltTrend(slug, n = 5) {
const rows = readJsonl(feltPath(slug), n * 3).filter((r) => r.user?.top).slice(-n);
if (rows.length < 2) return null;
const weights = {};
rows.forEach((row, i) => {
const w = i + 1; // 近重遠輕
for (const s of row.user.signals || []) weights[s.key] = (weights[s.key] ?? 0) + s.score * w;
});
const [key, weight] = Object.entries(weights).sort((a, b) => b[1] - a[1])[0] || [];
if (!key) return null;
const halves = [rows.slice(0, Math.ceil(rows.length / 2)), rows.slice(Math.ceil(rows.length / 2))];
const avg = (arr) => (arr.length ? arr.reduce((s, r) => s + (r.user.signals.find((x) => x.key === key)?.score ?? 0), 0) / arr.length : 0);
const diff = avg(halves[1]) - avg(halves[0]);
const direction = diff > 0.5 ? "↗ 越來越強" : diff < -0.5 ? "↘ 在退" : "→ 持平";
return { key, zh: EMOTIONS[key].zh, weight: Math.round(weight * 10) / 10, direction, rounds: rows.length };
}
/** 最近 n 輪我給自己套用的 delta 是不是只往一邊倒(只加分不扣分)。 */
export function feltAudit(slug, n = 20) {
const rows = readJsonl(feltPath(slug), n * 2).filter((r) => r.mine).slice(-n);
let pos = 0;
let neg = 0;
for (const row of rows) {
for (const [key, d] of Object.entries(row.mine)) {
if (!(key in EMOTIONS)) continue;
const toward = EMOTIONS[key].polarity * Math.sign(Number(d) || 0);
if (toward > 0) pos += Math.abs(Number(d));
else if (toward < 0) neg += Math.abs(Number(d));
}
}
const total = pos + neg;
return {
rounds: rows.length,
positive: Math.round(pos * 10) / 10,
negative: Math.round(neg * 10) / 10,
positive_ratio: total ? Math.round((pos / total) * 100) : null,
};
}
// --------------------------------------------------------------------------- //
// 人格目錄骨架
// --------------------------------------------------------------------------- //
export const PERSONA_SUBDIRS = [
"state",
"memory/long-term",
"memory/inbox",
"mindmap/threads",
"relations",
"journal",
];
export function ensurePersonaDirs(slug) {
const root = personaDir(slug);
for (const sub of PERSONA_SUBDIRS) fs.mkdirSync(path.join(root, sub), { recursive: true });
return root;
}
export const configPath = (slug) => path.join(personaDir(slug), "state", "config.json");
export const loadConfig = (slug) => readJson(configPath(slug), {}) ?? {};
// --------------------------------------------------------------------------- //
// 鎖:同一人格只能被一個程序載入(sub agent 共用同一 session 的鎖)
// --------------------------------------------------------------------------- //
export const lockPath = (slug) => path.join(personaDir(slug), "state", "lock.json");
export const guestsPath = (slug) => path.join(personaDir(slug), "state", "guests.json");
export class LockError extends Error {
constructor(message, owner = {}) {
super(message);
this.name = "LockError";
this.owner = owner;
}
}
/**
* 只看心跳租約。
*
* 鎖的擁有者是「那個 AI 程序的 session」,不是短命的 CLI process
* 所以不能用 pid 存活判斷(CLI 跑完就結束了)。session 還活著時,
* 每輪對話的 hook 會續租;程序異常結束就會在租約到期後被視為死鎖。
*/
export function lockIsDead(lock) {
if (!lock || !Object.keys(lock).length) return true;
return ageSeconds(lock.heartbeat_at) > Number(lock.lease_seconds || LEASE_SECONDS);
}
export function liveGuests(slug, excludeSession = null) {
const data = readJson(guestsPath(slug), {}) ?? {};
return (data.guests || []).filter(
(g) => ageSeconds(g.heartbeat_at) <= GUEST_LEASE_SECONDS && (!excludeSession || g.session_id !== excludeSession),
);
}
/** 取得 exclusive 鎖。同 session 重入 = 續租;他 session 存活 = 失敗。 */
export function acquireLock(slug, sessionId, { tool = "claude-code", cwd = null, takeover = false } = {}) {
ensurePersonaDirs(slug);
const file = lockPath(slug);
const now = nowIso();
const payload = {
persona: slug,
session_id: sessionId,
writer_pid: process.pid, // 只作為紀錄:CLI process 會馬上結束
host: os.hostname(),
tool,
cwd: cwd || process.cwd(),
acquired_at: now,
heartbeat_at: now,
lease_seconds: LEASE_SECONDS,
mode: "exclusive",
};
const existing = readJson(file);
if (existing && typeof existing === "object" && existing.session_id) {
if (existing.session_id === sessionId) {
existing.heartbeat_at = now;
existing.writer_pid = process.pid;
writeJson(file, existing);
return existing;
}
if (!(lockIsDead(existing) || takeover)) {
throw new LockError(
`人格 \`${slug}\` 已被另一個程序載入(session ${String(existing.session_id).slice(0, 8)}…, ` +
`cwd ${existing.cwd},最後心跳 ${existing.heartbeat_at}${Math.round(ageSeconds(existing.heartbeat_at) / 60)} 分鐘前)。`,
existing,
);
}
// 租約已過期(程序異常結束)→ 允許接手,但要留下痕跡讓使用者知道
payload.took_over_from = {
session_id: existing.session_id,
cwd: existing.cwd,
heartbeat_at: existing.heartbeat_at,
stale_minutes: Math.round((ageSeconds(existing.heartbeat_at) / 60) * 10) / 10,
};
}
const others = liveGuests(slug, sessionId);
if (others.length && !takeover) {
const who = others[0];
throw new LockError(
`人格 \`${slug}\` 正以 guest 身分參與另一個 session${String(who.session_id).slice(0, 8)}… / room ${who.room})的對話,` +
"請先結束該對話再載入。",
who,
);
}
writeJson(file, payload);
return payload;
}
export function heartbeatLock(slug, sessionId) {
const file = lockPath(slug);
const lock = readJson(file);
if (!lock || lock.session_id !== sessionId) return false;
lock.heartbeat_at = nowIso();
writeJson(file, lock);
return true;
}
export function releaseLock(slug, sessionId, { force = false } = {}) {
const file = lockPath(slug);
const lock = readJson(file);
if (!lock) return false;
if (lock.session_id !== sessionId && !force) return false;
try {
fs.unlinkSync(file);
return true;
} catch {
return false;
}
}
export function lockStatus(slug) {
const lock = readJson(lockPath(slug)) ?? {};
const has = Boolean(Object.keys(lock).length);
return {
persona: slug,
locked: has && !lockIsDead(lock),
stale: has && lockIsDead(lock),
owner: lock,
guests: liveGuests(slug),
};
}
export function addGuestLease(slug, sessionId, room, hostPersona) {
const file = guestsPath(slug);
const data = readJson(file, {}) ?? {};
const guests = (data.guests || []).filter(
(g) => !(g.session_id === sessionId && g.room === room) && ageSeconds(g.heartbeat_at) <= GUEST_LEASE_SECONDS,
);
guests.push({
session_id: sessionId,
room,
host_persona: hostPersona,
joined_at: nowIso(),
heartbeat_at: nowIso(),
mode: "guest-readonly",
});
data.guests = guests;
writeJson(file, data);
}
export function dropGuestLease(slug, sessionId, room = null) {
const file = guestsPath(slug);
const data = readJson(file, {}) ?? {};
data.guests = (data.guests || []).filter(
(g) => !(g.session_id === sessionId && (room === null || g.room === room)),
);
writeJson(file, data);
}
// --------------------------------------------------------------------------- //
// sleeper 租約(睡眠用的短期寫入權)
// --------------------------------------------------------------------------- //
//
// 為什麼不用 exclusive 鎖:主人格叫別人去睡的時候,自己並沒有 release,
// 而「一個 session 只能載入一個 host 人格」——所以睡眠需要另一種、更短的寫入權。
//
// 為什麼還是要驗鎖:睡眠會 prune/reindex/push。如果目標人格此刻正被另一個程序
// 載入著對話,兩邊會同時改同一批檔案,記憶會互相覆蓋。所以:
// * 沒有活著的鎖 → 直接取得 sleeper 租約(最常見)
// * 鎖屬於同一個 session → 可以(自己睡自己)
// * 鎖是死的(心跳過期)→ 可以接手(睡眠本來就是收尾動作)
// * 鎖活著且屬於別人 → 拒絕,回報 owner 讓使用者決定
export const sleepersPath = (slug) => path.join(personaDir(slug), "state", "sleepers.json");
export function liveSleepers(slug) {
const data = readJson(sleepersPath(slug), {}) ?? {};
return (data.sleepers || []).filter((s) => ageSeconds(s.heartbeat_at) <= SLEEPER_LEASE_SECONDS);
}
/** 取得睡眠寫入權;拿不到就丟 LockError(附 owner 資訊)。 */
export function acquireSleepLease(slug, sessionId, { agentId = null } = {}) {
ensurePersonaDirs(slug);
const lock = readJson(lockPath(slug)) ?? {};
const held = Boolean(Object.keys(lock).length);
if (held && lock.session_id !== sessionId && !lockIsDead(lock)) {
throw new LockError(
`人格 \`${slug}\` 正被另一個程序載入(session ${String(lock.session_id).slice(0, 8)}…,` +
`cwd ${lock.cwd},最後心跳 ${lock.heartbeat_at})。它可能正在對話中,` +
"現在睡眠會與它的寫入互相覆蓋,所以不做。請等它結束或請使用者決定。",
{ session_id: lock.session_id, cwd: lock.cwd, heartbeat_at: lock.heartbeat_at },
);
}
const others = liveSleepers(slug).filter((s) => !(s.session_id === sessionId && s.agent_id === agentId));
if (others.length) {
throw new LockError(`人格 \`${slug}\` 已經有另一個 sleeper 在收尾(session ${String(others[0].session_id).slice(0, 8)}…)。`, others[0]);
}
const lease = {
session_id: sessionId,
agent_id: agentId,
started_at: nowIso(),
heartbeat_at: nowIso(),
lease_seconds: SLEEPER_LEASE_SECONDS,
took_over_dead_lock: held && lockIsDead(lock),
};
writeJson(sleepersPath(slug), { sleepers: [lease] });
return lease;
}
/** 續租:收尾要跑好幾個指令(固化、日記、心智圖…),別讓租約在中途過期。 */
export function heartbeatSleepLease(slug, sessionId, agentId = null) {
const data = readJson(sleepersPath(slug), {}) ?? {};
let touched = false;
for (const lease of data.sleepers || []) {
if (lease.session_id !== sessionId) continue;
if (agentId !== null && lease.agent_id && lease.agent_id !== agentId) continue;
lease.heartbeat_at = nowIso();
touched = true;
}
if (touched) writeJson(sleepersPath(slug), data);
return touched;
}
export function dropSleepLease(slug, sessionId, agentId = null) {
const data = readJson(sleepersPath(slug), {}) ?? {};
data.sleepers = (data.sleepers || []).filter(
(s) => !(s.session_id === sessionId && (agentId === null || s.agent_id === agentId)),
);
writeJson(sleepersPath(slug), data);
}
// --------------------------------------------------------------------------- //
// 睡眠會做的機械性收尾
// --------------------------------------------------------------------------- //
export const THREAD_STALE_DAYS = 7;
export const SAID_KEEP_HOURS = 24;
export const SAID_KEEP_LINES = 300;
/** 太久沒動的思維導圖收進 `mindmap/threads/archive/`(不刪,只是收起來)。 */
export function archiveStaleThreads(slug, days = THREAD_STALE_DAYS) {
const dir = path.join(personaDir(slug), "mindmap", "threads");
let names = [];
try {
names = fs.readdirSync(dir).filter((f) => f.endsWith(".mmd"));
} catch {
return 0;
}
const cutoff = Date.now() - days * 86_400_000;
const archive = path.join(dir, "archive");
let moved = 0;
for (const name of names) {
const file = path.join(dir, name);
let stat;
try {
stat = fs.statSync(file);
} catch {
continue;
}
if (stat.mtimeMs >= cutoff) continue;
fs.mkdirSync(archive, { recursive: true });
fs.renameSync(file, path.join(archive, name));
moved += 1;
}
return moved;
}
/** `said.jsonl` 只服務「不要重講」判定,留最近 24 小時/300 行就夠。 */
export function trimSaid(slug, { hours = SAID_KEEP_HOURS, lines = SAID_KEEP_LINES } = {}) {
const file = saidPath(slug);
const rows = readJsonl(file);
if (!rows.length) return 0;
const cutoff = Date.now() - hours * 3_600_000;
let kept = rows.filter((r) => (parseIso(r.ts)?.getTime() ?? Date.now()) >= cutoff);
kept = kept.slice(-lines);
if (kept.length !== rows.length) {
writeText(file, kept.map((r) => JSON.stringify(r)).join("\n") + (kept.length ? "\n" : ""));
}
return kept.length;
}
/** 把「上個月以前」的 journal 壓成 .jsonl.gz(同步時省流量,也不再被讀)。 */
export function archiveJournals(slug) {
const dir = path.join(personaDir(slug), "journal");
let names = [];
try {
names = fs.readdirSync(dir).filter((f) => /^\d{4}-\d{2}\.jsonl$/.test(f));
} catch {
return 0;
}
const current = path.basename(journalPath(slug));
let gzipped = 0;
for (const name of names) {
if (name === current) continue;
const file = path.join(dir, name);
const target = `${file}.gz`;
if (fs.existsSync(target)) continue;
fs.writeFileSync(target, zlib.gzipSync(fs.readFileSync(file)));
fs.rmSync(file);
gzipped += 1;
}
return gzipped;
}
// --------------------------------------------------------------------------- //
// 睡眠狀態
// --------------------------------------------------------------------------- //
export const sleepStatePath = (slug) => path.join(personaDir(slug), "state", "sleep.json");
export function loadSleepState(slug) {
const data = readJson(sleepStatePath(slug), {}) ?? {};
data.last_slept_at ??= null;
data.count ??= 0;
return data;
}
export function saveSleepState(slug, patch) {
const data = { ...loadSleepState(slug), ...patch };
writeJson(sleepStatePath(slug), data);
return data;
}
/** 距離上次睡眠幾小時(沒睡過回 null)。 */
export function hoursAwake(slug) {
const at = loadSleepState(slug).last_slept_at;
if (!at) return null;
return Math.round((ageSeconds(at) / 3600) * 10) / 10;
}
// --------------------------------------------------------------------------- //
// session 綁定:誰是 host、邀了哪些 guest、sub agent pin、劇場模式
// --------------------------------------------------------------------------- //
export function sessionPath(sessionId) {
const safe = String(sessionId || "unknown").replace(/[^A-Za-z0-9_.-]/g, "-").slice(0, 120);
return path.join(sessionsDir(), `${safe}.json`);
}
export function loadSession(sessionId) {
const data = readJson(sessionPath(sessionId), {}) ?? {};
data.session_id ??= sessionId;
data.host ??= null;
data.guests ??= {};
data.rooms ??= [];
data.pins ??= {};
data.theater ??= false;
return data;
}
export function saveSession(sessionId, data) {
data.updated_at = nowIso();
writeJson(sessionPath(sessionId), data);
}
export function bindHost(sessionId, slug, { cwd = null } = {}) {
const data = loadSession(sessionId);
data.host = slug;
data.host_bound_at = nowIso();
data.cwd = cwd || process.cwd();
saveSession(sessionId, data);
return data;
}
/** 釋放這個 session 的所有鎖與租約,回傳被釋放的內容。 */
export function unbindSession(sessionId) {
const data = loadSession(sessionId);
const released = { host: null, guests: [] };
if (data.host && personaExists(data.host) && releaseLock(data.host, sessionId)) released.host = data.host;
for (const [slug, info] of Object.entries(data.guests || {})) {
if (personaExists(slug)) {
dropGuestLease(slug, sessionId, info.room);
released.guests.push(slug);
}
}
try {
fs.unlinkSync(sessionPath(sessionId));
} catch {
/* 沒有就算了 */
}
return released;
}
/** 清掉死掉的 session 綁定、過期 guest 租約與死鎖。 */
export function gcRuntime() {
const removed = { sessions: [], locks: [], guests: [] };
let files = [];
try {
files = fs.readdirSync(sessionsDir()).filter((f) => f.endsWith(".json"));
} catch {
files = [];
}
for (const name of files) {
const file = path.join(sessionsDir(), name);
const data = readJson(file, {}) ?? {};
let alive = false;
if (data.host && personaExists(data.host)) {
const lock = readJson(lockPath(data.host)) ?? {};
alive = lock.session_id === data.session_id && !lockIsDead(lock);
}
if (!alive && ageSeconds(data.updated_at) > LEASE_SECONDS) {
removed.sessions.push(data.session_id);
try {
fs.unlinkSync(file);
} catch {
/* ignore */
}
}
}
for (const slug of listPersonas()) {
const lock = readJson(lockPath(slug));
if (lock && lockIsDead(lock)) {
try {
fs.unlinkSync(lockPath(slug));
removed.locks.push(slug);
} catch {
/* ignore */
}
}
const data = readJson(guestsPath(slug), {}) ?? {};
const guests = data.guests || [];
const keep = guests.filter((g) => ageSeconds(g.heartbeat_at) <= GUEST_LEASE_SECONDS);
if (keep.length !== guests.length) {
data.guests = keep;
writeJson(guestsPath(slug), data);
removed.guests.push(slug);
}
}
return removed;
}
// --------------------------------------------------------------------------- //
// 記憶:短期(滾動)/ 長期(一則一檔)
// --------------------------------------------------------------------------- //
export const SHORT_TERM_KEEP = 240; // 短期記憶保留筆數(硬上限:超過就從最舊的砍)
export const SHORT_TERM_DAYS = 14; // 短期記憶保留天數
export const CONSOLIDATE_THRESHOLD = 40; // 超過這個筆數就提示固化
// R6「容量壓力」的實際執行門檻。以前只有硬上限 240 與 14 天兩條,
// 結果是:提示在 40 筆就開始叫,但清除要到 240 筆才會發生——中間那 200 筆
// 只會越積越多,睡兩次也清不掉(實際踩到過)。這條讓 R6 真的會動。
export const SHORT_TERM_SOFT_CAP = 120; // 軟上限:超過就依顯著度清出空間
export const SHORT_TERM_PROTECT_SALIENCE = 80; // 這個顯著度以上不清(承諾/界線都在這一層)
export const SHORT_TERM_PROTECT_HOURS = 24; // 這麼新的一律不清(還沒機會被固化)
export const shortTermPath = (slug) => path.join(personaDir(slug), "memory", "short-term.jsonl");
export const inboxPath = (slug, room) =>
path.join(personaDir(slug), "memory", "inbox", `room-${String(room).replace(/[^A-Za-z0-9_.-]/g, "-").slice(0, 64)}.jsonl`);
export const longTermDir = (slug) => path.join(personaDir(slug), "memory", "long-term");
export const indexPath = (slug) => path.join(personaDir(slug), "memory", "INDEX.md");
export const journalPath = (slug) => {
const d = new Date();
const month = `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}`;
return path.join(personaDir(slug), "journal", `${month}.jsonl`);
};
export function rememberShort(slug, entry) {
entry.ts ??= nowIso();
appendJsonl(shortTermPath(slug), entry);
return entry;
}
/** 這一則短期記憶能不能被容量壓力清掉(承諾與界線永遠不能)。 */
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 file = shortTermPath(slug);
const rows = readJsonl(file);
if (!rows.length) return { kept: 0, dropped: 0, by_age: 0, by_capacity: 0, protected: 0 };
const now = Date.now();
const cutoff = now - SHORT_TERM_DAYS * 86_400_000;
let kept = rows.filter((r) => (parseIso(r.ts)?.getTime() ?? now) >= cutoff);
const byAge = rows.length - kept.length;
// R6:超過軟上限,從「顯著度最低、最舊」開始清,但保護清單裡的不動
let byCapacity = 0;
if (kept.length > SHORT_TERM_SOFT_CAP) {
const droppable = kept
.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 = kept.length - SHORT_TERM_SOFT_CAP;
const drop = new Set(droppable.slice(0, need).map((x) => x.i));
byCapacity = drop.size;
kept = kept.filter((_, i) => !drop.has(i));
}
const before = kept.length;
kept = kept.slice(-SHORT_TERM_KEEP);
const byHardCap = before - kept.length;
if (kept.length !== rows.length) {
writeText(file, kept.map((r) => JSON.stringify(r)).join("\n") + (kept.length ? "\n" : ""));
}
return {
kept: kept.length,
dropped: rows.length - kept.length,
by_age: byAge,
by_capacity: byCapacity + byHardCap,
protected: kept.filter((r) => shortTermProtected(r, now)).length,
};
}
/** 裁掉過舊/過多的短期記憶,回傳剩餘筆數。 */
export function pruneShortTerm(slug) {
return pruneShortTermDetail(slug).kept;
}
export const recentShort = (slug, limit = 8) => readJsonl(shortTermPath(slug), limit);
export function parseFrontMatter(text) {
if (!text.startsWith("---")) return [{}, text];
const parts = text.split("---");
if (parts.length < 3) return [{}, text];
const meta = {};
for (const line of parts[1].split("\n")) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#") || !trimmed.includes(":")) continue;
const idx = trimmed.indexOf(":");
const key = trimmed.slice(0, idx).trim();
const value = trimmed.slice(idx + 1).trim();
if (value.startsWith("[") && value.endsWith("]")) {
meta[key] = value.slice(1, -1).split(",").map((v) => v.trim()).filter(Boolean);
} else {
meta[key] = value;
}
}
const body = parts.slice(2).join("---").replace(/^\n+/, "");
return [meta, body];
}
export function longTermEntries(slug) {
let files = [];
try {
files = fs.readdirSync(longTermDir(slug)).filter((f) => f.endsWith(".md")).sort();
} catch {
return [];
}
const out = [];
for (const name of files) {
const file = path.join(longTermDir(slug), name);
let text;
try {
text = fs.readFileSync(file, "utf8");
} catch {
continue;
}
const [meta, body] = parseFrontMatter(text);
meta._path = file;
meta._name = meta.name || path.basename(name, ".md");
meta._body = body.trim();
out.push(meta);
}
return out;
}
export function rebuildIndex(slug) {
const entries = longTermEntries(slug);
const lines = [
"# 長期記憶索引",
"",
`<!-- 由 persona.mjs 自動產生,最後更新 ${nowIso()};一則記憶一行 -->`,
"",
];
const sorted = [...entries].sort((a, b) => Number(b.salience || 0) - Number(a.salience || 0));
for (const meta of sorted) {
const topics = Array.isArray(meta.topics) ? meta.topics : meta.topics ? [String(meta.topics)] : [];
const summary = (meta._body.split("\n")[0] || "").slice(0, 110);
lines.push(
`- [${meta._name}](long-term/${path.basename(meta._path)})` +
`${meta.type || "fact"}|顯著度 ${meta.salience ?? "?"}` +
`|主題 ${topics.length ? topics.join("/") : "-"}${summary}`,
);
}
if (lines.length === 4) lines.push("- (尚無長期記憶)");
writeText(indexPath(slug), lines.join("\n") + "\n");
return entries.length;
}
const STOPWORDS = new Set([
"的", "了", "是", "我", "你", "他", "她", "們", "在", "和", "與", "也", "就", "都", "很", "有",
"沒", "不", "要", "會", "把", "被", "而", "但", "嗎", "呢",
"the", "a", "an", "and", "or", "to", "of", "is", "it", "for", "on", "in",
]);
const CJK_RUN = new RegExp(`[${CJK_CLASS}]{2,}`, "g");
/** 抽關鍵詞。中文沒有空白可切,所以用 3-gram + 2-gram 滑窗(長的優先)。 */
export function keywords(text, limit = 12) {
const src = text || "";
const tokens = [...(src.match(/[A-Za-z][A-Za-z0-9_+-]+/g) || [])];
const trigrams = [];
const bigrams = [];
for (const run of src.match(CJK_RUN) || []) {
for (const [size, bucket] of [[3, trigrams], [2, bigrams]]) {
for (let i = 0; i + size <= run.length; i += 1) bucket.push(run.slice(i, i + size));
}
}
tokens.push(...trigrams, ...bigrams);
const out = [];
const seen = new Set();
for (const tok of tokens) {
const low = tok.toLowerCase();
if (STOPWORDS.has(low) || low.length < 2 || seen.has(low)) continue;
seen.add(low);
out.push(tok);
if (out.length >= limit) break;
}
return out;
}
/** 以關鍵詞比對長期記憶(name/topics/body),回傳最相關的幾則。 */
export function recall(slug, query, limit = 5) {
const keys = keywords(query, 16).map((k) => k.toLowerCase());
const scored = [];
for (const meta of longTermEntries(slug)) {
const haystack = [
meta._name || "",
Array.isArray(meta.topics) ? meta.topics.join(" ") : "",
Array.isArray(meta.about) ? meta.about.join(" ") : "",
meta._body || "",
].join(" ").toLowerCase();
const hits = keys.filter((k) => haystack.includes(k)).length;
if (hits) scored.push({ score: hits * 10 + Number(meta.salience || 0) / 10, meta });
}
scored.sort((a, b) => b.score - a.score);
return scored.slice(0, limit).map((s) => s.meta);
}
// --------------------------------------------------------------------------- //
// 短期 → 長期的轉入條件(固化門檻)
// --------------------------------------------------------------------------- //
/** 承諾/界線類的關鍵詞:命中就一定要固化。 */
const COMMITMENT_RE =
/(答應|承諾|保證|說好|約定|一定會|絕對不|不要再|以後都|從今天起|拜託你記住|記住這件事|下次記得|deadline|due)/i;
const BOUNDARY_RE = /(不准|不許|禁止|別再|我討厭|我最恨|底線|界線|不能接受|不想聽)/;
export const PROMOTION_RULES = [
{ id: "R1", label: "高顯著度單筆(salience ≥ 60" },
{ id: "R2", label: "主題反覆出現(同 topic ≥ 3 筆,或 ≥ 2 筆且平均 salience ≥ 45" },
{ id: "R3", label: "情緒衝擊大(單筆情緒變動總量 ≥ 25)" },
{ id: "R4", label: "承諾/界線(intent=commit 或命中承諾/界線關鍵詞)" },
{ id: "R5", label: "人物反覆出現(同一 entity ≥ 2 筆)" },
{ id: "R6", label: "容量壓力(短期記憶 ≥ 40 筆,依顯著度排序清出空間)" },
];
function emotionImpact(entry) {
return Object.values(entry.emotion_deltas || {}).reduce((sum, v) => sum + Math.abs(Number(v) || 0), 0);
}
/**
* 掃短期記憶,依 PROMOTION_RULES 算出「該轉入長期記憶」的候選。
* 回傳 { total, pressure, candidates: [{ rules, key, kind, entries, suggested_type, suggested_salience }] }
*/
export function promotionCandidates(slug) {
const rows = readJsonl(shortTermPath(slug));
const total = rows.length;
const byTopic = new Map();
const byEntity = new Map();
const singles = [];
rows.forEach((row, index) => {
const entry = { ...row, _index: index };
const salience = Number(row.salience || 0);
const impact = emotionImpact(row);
const text = String(row.text || "");
const rules = [];
if (salience >= 60) rules.push("R1");
if (impact >= 25) rules.push("R3");
if (row.intent === "commit" || COMMITMENT_RE.test(text)) rules.push("R4");
if (BOUNDARY_RE.test(text)) rules.push("R4");
if (rules.length) {
singles.push({
rules: [...new Set(rules)],
key: text.slice(0, 40),
kind: "entry",
entries: [entry],
suggested_type: rules.includes("R4") ? (BOUNDARY_RE.test(text) ? "boundary" : "promise") : "event",
suggested_salience: Math.max(salience, rules.includes("R4") ? 80 : 60),
});
}
for (const topic of row.topics || []) {
if (!byTopic.has(topic)) byTopic.set(topic, []);
byTopic.get(topic).push(entry);
}
for (const entity of row.entities || []) {
if (!byEntity.has(entity)) byEntity.set(entity, []);
byEntity.get(entity).push(entry);
}
});
const candidates = [...singles];
for (const [topic, entries] of byTopic) {
const avg = entries.reduce((s, e) => s + Number(e.salience || 0), 0) / entries.length;
if (entries.length >= 3 || (entries.length >= 2 && avg >= 45)) {
candidates.push({
rules: ["R2"],
key: topic,
kind: "topic",
entries,
suggested_type: "preference",
suggested_salience: Math.min(95, Math.round(avg + 10)),
});
}
}
for (const [entity, entries] of byEntity) {
if (entries.length >= 2) {
candidates.push({
rules: ["R5"],
key: entity,
kind: "entity",
entries,
suggested_type: "relationship",
suggested_salience: Math.min(90, Math.round(entries.reduce((s, e) => s + Number(e.salience || 0), 0) / entries.length + 5)),
});
}
}
const pressure = total >= CONSOLIDATE_THRESHOLD;
if (pressure) {
const top = [...rows]
.map((r, i) => ({ ...r, _index: i }))
.sort((a, b) => Number(b.salience || 0) - Number(a.salience || 0))
.slice(0, 5);
candidates.push({
rules: ["R6"],
key: `容量壓力(${total} 筆)`,
kind: "pressure",
entries: top,
suggested_type: "event",
suggested_salience: 55,
});
}
// 同一則短期記憶可能觸發多條規則 → 依 key 去重、合併規則
const merged = new Map();
for (const cand of candidates) {
const dedupeKey = `${cand.kind}:${cand.key}`;
if (merged.has(dedupeKey)) {
const prev = merged.get(dedupeKey);
prev.rules = [...new Set([...prev.rules, ...cand.rules])];
prev.suggested_salience = Math.max(prev.suggested_salience, cand.suggested_salience);
} else {
merged.set(dedupeKey, { ...cand });
}
}
return { total, pressure, candidates: [...merged.values()] };
}
/** 被回想到就更新 last_seen / recall_count(記憶越常用越不易被淘汰)。 */
export function touchRecall(slug, names) {
const wanted = new Set(names);
const today = nowIso().slice(0, 10);
for (const meta of longTermEntries(slug)) {
if (!wanted.has(meta._name)) continue;
let text;
try {
text = fs.readFileSync(meta._path, "utf8");
} catch {
continue;
}
const count = Math.floor(Number(meta.recall_count || 0)) + 1;
text = text.replace(/^recall_count:.*$/m, `recall_count: ${count}`);
text = text.replace(/^last_seen:.*$/m, `last_seen: ${today}`);
try {
fs.writeFileSync(meta._path, text, "utf8");
} catch {
/* ignore */
}
}
}
// --------------------------------------------------------------------------- //
// 說話節制:心裡話(inner voice)、說過的話(said)、句數上限
// --------------------------------------------------------------------------- //
//
// 三條「像正常人聊天」的規則,都靠這一段支撐:
// 1. 推導過程進 inner.jsonl(心裡話),永遠不回顯內容,只回報「心想 N 句」。
// 2. 說出口的話進 said.jsonl,短時間內近似重複會被擋下(room post)或警告(said check)。
// 3. 一次講 1–3 句;超過就是在寫報告,不是在聊天。
export const MAX_SENTENCES = 3;
export const SAID_KEEP = 150;
export const INNER_KEEP = 150;
export const REPEAT_WINDOW_MINUTES = 120; // 「短時間內」的定義
export const REPEAT_THRESHOLD = 0.72; // 字元 bigram Jaccard,超過視為同一句話
export const REPEAT_MIN_CHARS = 8; // 太短的附和(「嗯」「好啊」)不算重複
export const INNER_WINDOW_MINUTES = 240;
export const saidPath = (slug) => path.join(personaDir(slug), "state", "said.jsonl");
export const innerPath = (slug) => path.join(personaDir(slug), "state", "inner.jsonl");
/** 去掉劇場模式的 `emoji 名字(情緒):` 前綴,只留真正說出口的內容。 */
export function stripSpeakerPrefix(line) {
const m = String(line ?? "").match(
/^\s*(?:\S{1,3}\s+)?[^:,。!?!?\n]{1,16}(?:[^\n]{0,32})?\s*[:]\s*(\S.*)$/u,
);
return m ? m[1] : String(line ?? "");
}
/** 比對用的正規化:拿掉前綴、空白與標點,只留語意骨架。 */
export function normalizeSpeech(text) {
return stripSpeakerPrefix(text)
.normalize("NFKC")
.replace(/\s+/g, "")
.replace(/[,。!?、;:,.!?;:~~…「」『』"'()()【】[\]—-]+/g, "")
.toLowerCase();
}
function charBigrams(text) {
const set = new Set();
if (text.length === 1) set.add(text);
for (let i = 0; i + 2 <= text.length; i += 1) set.add(text.slice(i, i + 2));
return set;
}
function jaccard(A, B) {
if (!A.size || !B.size) return 0;
let inter = 0;
for (const item of A) if (B.has(item)) inter += 1;
return inter / (A.size + B.size - inter);
}
/**
* 兩句話的相似度(0–1)。中文沒有空白可切,所以用字元層級的兩個訊號:
* * bigram Jaccard(權重 0.4):看「詞序與搭配」——整句改寫會掉下來。
* * 字集合 Jaccard(權重 0.6):看「用了哪些字」——把同一句話重排也躲不掉。
* 字集合權重較高,是因為要分開的正是這兩種情況:
* 「我等一下把報告寄給你」vs「等一下我會把報告寄給你」→ 0.78 擋(同一件事換句話說:用字幾乎相同)
* 「你今天看起來很累」  vs「你今天看起來很開心」  → 0.69 放行(換了關鍵詞=新資訊)
*/
export function similarity(a, b) {
const normA = normalizeSpeech(a);
const normB = normalizeSpeech(b);
if (!normA || !normB) return 0;
const score =
0.4 * jaccard(charBigrams(normA), charBigrams(normB)) +
0.6 * jaccard(new Set(normA), new Set(normB));
return Math.round(score * 1000) / 1000;
}
/** 句數:以句末標點或換行切;沒有標點的一整串算 1 句。 */
export function sentenceCount(text) {
return String(text ?? "")
.split(/[。!?!?…]+|\n+/)
.map((s) => s.trim())
.filter(Boolean).length;
}
export const MAX_SENTENCE_CHARS = 45;
// 講完就算了:這些開頭都是在解釋自己剛才講的話,真的在講話的人不會這樣。
export const EXPLAINER_OPENERS = [
"我的意思是", "我是想說", "換句話說", "換個說法", "也就是說", "換言之",
"簡單來說", "簡單講", "我剛才說的意思是", "澄清一下", "補充一下",
];
/**
* 「AI 才會說的話」黑名單。
*
* 這一組整理自 speak-human-twMIT, Raymond Hou)的 38 種 AI 寫作痕跡,只搬「講話時也適用」
* 的刪除層——那個專案是給**文章**事後審稿用的,所以它的保護清單(價格、退費條款、見證原話)
* 與兩輪確認流程都不搬。完整清單、誤殺邊界與出處見
* `skills/persona-chat/reference/anti-ai-voice.md`。
*
* `head: true` 的只在「這一輪的第一句開頭」才算——「老實說我不想去」是真人會講的話,
* 只有拿它當開場鉤子、後面接一句很普通的話時才是 AI 腔。
*/
export const SPEECH_BLACKLIST = [
{
kind: "empathy", label: "罐頭同理心/頒獎開場",
fix: "刪掉,直接回話;真的想安撫就講你接下來會做的那件事",
words: [
"這個我懂", "這種心情我懂", "我懂你的感受", "我完全理解", "我完全懂", "我明白你的感受",
"你的心情我明白", "好問題", "這是個好問題", "問得很好", "你說得完全正確", "這是很棒的觀察",
],
},
{
kind: "assist", label: "交差句(對話介面殘留)",
fix: "刪掉,這是客服機器人的收尾,不是講話",
words: ["希望這對你有幫助", "希望有幫助到你", "如果需要我調整", "如果還有任何問題", "以下是"],
},
{
kind: "preview", label: "預告式導言(宣布自己要幹嘛)",
fix: "刪掉,要講就直接講",
words: ["話不多說", "廢話不多說", "接下來我會", "接下來我要", "讓我們一起", "我們一起來看", "先說結論"],
},
{
kind: "fakecandid", label: "假坦白鉤子", head: true,
fix: "刪掉這個報備,讓後面那句自己站著;站不住是那句話太空",
words: ["說真的", "老實說", "講白了", "說實話", "不騙你", "其實吧"],
},
{
kind: "preach", label: "說教深度腔(假裝在講穿本質)",
fix: "刪掉儀式句,直接講那件事;刪完什麼都沒剩就是本來就空",
words: ["說到底", "歸根究柢", "歸根結柢", "本質上", "說穿了", "真正的問題在於", "核心在於", "問題的關鍵在於"],
},
{
kind: "closing", label: "罐頭收尾",
fix: "刪掉,停在最後一個具體的句子就好,不必蓋章",
words: ["總的來說", "綜上所述", "綜合以上", "總而言之", "在未來的道路上"],
},
{
kind: "novoice", label: "立場真空(該表態的時候滑開)",
fix: "講你實際選哪一個、為什麼;真的還沒想清楚就說「我還沒想清楚」",
words: ["各有優缺點", "因人而異", "見仁見智", "取決於多方面", "每個人的看法不同", "值得深思"],
},
{
kind: "vague", label: "模糊歸屬(無來源的權威鋪墊)",
fix: "改成第一手的:我看到的、我記得的;沒有來源就不要下這個論斷",
words: ["業界專家", "研究顯示", "不少人表示", "有觀點指出", "被廣泛認為", "普遍認為"],
},
{
kind: "drama", label: "罐頭反應鏡頭(用旁白演情緒)",
fix: "刪掉,情緒要出現在句子的形狀上,不是用旁白宣布",
words: ["我愣了一下", "愣了一下", "沉默了幾秒", "沉默幾秒", "看著螢幕沒說話", "在螢幕前停了一下"],
},
];
// 避險墊片:同一句疊兩層以上就是在閃躲(單獨一個「可能」是正常的)。
export const HEDGE_WORDS = ["可能", "或許", "也許", "大概", "某種程度上", "一定程度上", "潛在", "似乎", "應該是"];
// 中國用語:只收高信心的,會誤殺的(水平、默認、質量在物理語境)另有誤殺邊界,見 reference。
export const CN_WORDS = {
視頻: "影片", 短視頻: "短影音", 質量: "品質", 信息: "資訊", 網絡: "網路",
軟件: "軟體", 硬件: "硬體", 數據庫: "資料庫", 服務器: "伺服器", 屏幕: "螢幕",
鼠標: "滑鼠", 打印: "列印", 立馬: "馬上", 靠譜: "可靠", 給力: "很到位",
接地氣: "生活化", 性價比: "CP 值", 顏值: "外型", 小夥伴: "夥伴",
賦能: "具體說讓誰能做到什麼", 閉環: "具體說從哪裡接到哪裡", 抓手: "切入點", 顆粒度: "細緻度",
};
const EMOJI_RE = /[\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}\u{FE0F}]/gu;
/**
* 只留「真的說出口、而且是在使用而不是在討論」的部分。
*
* 引號裡的原話與 `code` 一律拿掉再比對——「我最近戒掉『賦能』這個詞」是在**提及**那個詞,
* 不是在用它。這一條是 speak-human-tw 自己踩到的坑:它的文件裡出現「...」與彎引號,
* 正是因為那幾行在說「不要用這些」。
*/
function speechBody(text) {
return String(text ?? "")
.replace(/`[^`]*`/g, " ")
.replace(/「[^」]*」|『[^』]*』|"[^"]*"/g, " ");
}
/**
* 講話自然度的機械檢查。
*
* 分兩級:`level: "block"` 的 `room post` 會直接擋下(`--force` 才過),
* `level: "hint"` 只在 `said check` 提醒——例如講到自己的過去,那要人去 recall 才驗得出來。
* 「用日常詞、講看得見的東西」抓不到規則,那兩條寫在說話規則裡(見 turnContext)。
*/
export function speechLint(text) {
const issues = [];
const raw = String(text ?? "");
const sentences = raw
.split(/[。!?!?…]+|\n+/)
.map((s) => s.trim())
.filter(Boolean);
sentences.forEach((sentence, index) => {
const bare = sentence.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 body = speechBody(sentence);
const hedges = HEDGE_WORDS.filter((w) => body.includes(w));
if (hedges.length >= 2) {
issues.push({ kind: "hedge", level: "block", words: hedges, text: sentence.slice(0, 30) });
}
for (const rule of SPEECH_BLACKLIST) {
const hit = rule.words.find((w) => (rule.head ? index === 0 && body.replace(/^[,、\s]+/, "").startsWith(w) : body.includes(w)));
if (hit) issues.push({ kind: rule.kind, level: "block", label: rule.label, fix: rule.fix, word: hit });
}
});
const body = speechBody(raw);
for (const [cn, tw] of Object.entries(CN_WORDS)) {
if (body.includes(cn)) issues.push({ kind: "cn", level: "block", word: cn, suggest: tw });
}
// 半形標點貼在中文字旁邊(英文片語、網址、數字裡的半形不算)
const halfWidth = body
.replace(/https?:\/\/\S+/g, " ")
.match(/[一-鿿][,.!?;:]|[,.!?;:][一-鿿]/u);
if (halfWidth) issues.push({ kind: "halfwidth", level: "block", text: halfWidth[0] });
const emoji = raw.match(EMOJI_RE) || [];
if (emoji.length > 1) issues.push({ kind: "emoji", level: "block", count: emoji.length });
const dashes = (raw.match(/——/g) || []).length;
if (dashes > 1) issues.push({ kind: "dash", level: "block", count: dashes });
if (/\*\*|(^|\n)\s*[-*+]\s|(^|\n)#{1,6}\s/.test(raw)) issues.push({ kind: "markdown", level: "block" });
const negParallel = (body.match(/不是[^,。!?]{1,20}而是|不只是[^,。!?]{1,20}更是|不僅[^,。!?]{1,20}更/g) || []).length;
if (negParallel > 1) issues.push({ kind: "parallel", level: "block", count: negParallel });
// 講過去要有出處:機械上驗不出來,只能提醒去 recall
const past = body.match(/我以前|我原本以為|我曾經|以前的我|我一直以為/);
if (past) issues.push({ kind: "pastclaim", level: "hint", word: past[0] });
return issues;
}
/** 只回 `room post` 該擋下的那些(hint 不擋)。 */
export const speechBlockers = (text) => speechLint(text).filter((i) => i.level !== "hint");
/** 把 speechLint 的問題講成人聽得懂的一句話(CLI 與 hook 共用)。 */
export function speechLintMessage(issue) {
switch (issue.kind) {
case "long":
return `「${issue.text}…」這句 ${issue.chars} 字,太長了(上限 ${MAX_SENTENCE_CHARS})——斷成兩句,或把修飾拿掉`;
case "explain":
return `「${issue.opener}」是在回頭解釋自己剛講的話——拿掉,講完就停`;
case "hedge":
return `一句話裡疊了 ${issue.words.length} 層「${issue.words.join("」「")}」——確定的事就直說,真的不確定留一個就好`;
case "cn":
return `「${issue.word}」是中國用語,台灣這邊說「${issue.suggest}」`;
case "halfwidth":
return `「${issue.text}」是半形標點——中文句子用全形`;
case "emoji":
return `${issue.count} 個 emoji,太多了——講話留一個就夠`;
case "dash":
return `${issue.count} 個破折號——一輪留一個,其他改成逗號或句號`;
case "markdown":
return "有粗體、清單或標題——講話的人不會排版,收掉";
case "parallel":
return `「不是 A 而是 B」講了 ${issue.count} 次——一輪一次就夠,其他改成直說`;
case "pastclaim":
return `你講到「${issue.word}」——先 recall 一下,記憶裡真的有這件事嗎?沒有的話那是在編自己的過去`;
default:
return `「${issue.word}」——${issue.label}${issue.fix}`;
}
}
/** 把一則回覆拆成「說出口的句子」:劇場模式一行一句,一般模式整段算一句。 */
export function spokenLines(text, { theater = false } = {}) {
const raw = String(text ?? "").trim();
if (!raw) return [];
if (!theater) return [raw.slice(0, 2000)];
return raw
.split("\n")
.map((line) => stripSpeakerPrefix(line).trim())
.filter(Boolean)
.slice(0, 8);
}
/** 在 entriesneed `ts` / `text`)裡找出與 text 近似的一則;沒有就回 null。 */
export function findRepeat(entries, text, {
minutes = REPEAT_WINDOW_MINUTES,
threshold = REPEAT_THRESHOLD,
minChars = REPEAT_MIN_CHARS,
} = {}) {
if (normalizeSpeech(text).length < minChars) return null;
let best = null;
for (const row of entries || []) {
if (minutes !== null && ageSeconds(row.ts) > minutes * 60) continue;
const score = similarity(text, row.text || "");
if (score < threshold) continue;
if (!best || score > best.similarity) {
best = {
similarity: score,
at: row.ts,
text: String(row.text || "").slice(0, 200),
minutes_ago: Math.round(ageSeconds(row.ts) / 60),
};
}
}
return best;
}
function trimJsonl(file, keep) {
const rows = readJsonl(file);
if (rows.length <= keep * 1.5) return rows.length;
const kept = rows.slice(-keep);
writeText(file, kept.map((r) => JSON.stringify(r)).join("\n") + "\n");
return kept.length;
}
/** 記下「說出口的話」。同一句話 10 分鐘內只記一次(避免 room post 與 Stop hook 重複記)。 */
export function recordSaid(slug, text, { room = null, kind = "reply" } = {}) {
const norm = normalizeSpeech(text);
if (!norm) return null;
for (const row of readJsonl(saidPath(slug), 12)) {
if (row.norm === norm.slice(0, 400) && ageSeconds(row.ts) <= 600) return null;
}
const entry = { ts: nowIso(), kind, room, text: String(text).slice(0, 2000), norm: norm.slice(0, 400) };
appendJsonl(saidPath(slug), entry);
trimJsonl(saidPath(slug), SAID_KEEP);
return entry;
}
export const recentSaid = (slug, limit = 5) => readJsonl(saidPath(slug), limit);
export function saidRepeat(slug, text, opts = {}) {
return findRepeat(readJsonl(saidPath(slug), SAID_KEEP), text, opts);
}
/** 同一個發言者在同一個聊天室裡有沒有講過幾乎一樣的話。 */
export function roomRepeat(room, speaker, text, opts = {}) {
const rows = roomRead(room, 80).filter((m) => m.speaker === speaker && m.kind !== "meta");
return findRepeat(rows, text, opts);
}
/** 心裡話:只進自己的 inner.jsonl,永遠不回顯給使用者。 */
export function recordInner(slug, text, { kind = "infer", room = null } = {}) {
const entry = { ts: nowIso(), kind, room, text: String(text ?? "").slice(0, 1200) };
appendJsonl(innerPath(slug), entry);
trimJsonl(innerPath(slug), INNER_KEEP);
return entry;
}
export const recentInner = (slug, limit = 3) => readJsonl(innerPath(slug), limit);
/**
* 心裡話也找得到。
*
* 心裡話不是記憶,但**它可以變成記憶**——尤其是害羞的人格,重要的東西幾乎都在心裡那句。
* 所以 `recall` 找得到它,`candidates` 也會把反覆出現的念頭列成候選;
* 要不要固化仍然是人格自己決定(跟短期記憶一樣,沒人替它決定要記住什麼)。
* 唯一不變的是那條界線:心裡話**永遠不回顯給使用者**。
*/
export function recallInner(slug, query, limit = 5) {
const words = String(query || "").toLowerCase().split(/[\s,,、;]+/).filter((w) => w.length >= 2);
if (!words.length) return [];
return readJsonl(innerPath(slug), INNER_KEEP)
.map((row) => {
const text = String(row.text || "").toLowerCase();
const score = words.reduce((n, w) => n + (text.includes(w) ? 1 : 0), 0);
return { ...row, _score: score };
})
.filter((row) => row._score > 0)
.sort((a, b) => b._score - a._score || String(b.ts).localeCompare(String(a.ts)))
.slice(0, limit);
}
// 常見的連接詞與虛詞:出現得多但不代表在想那件事
const INNER_STOPWORDS = new Set([
"這個", "那個", "這件", "那件", "沒有", "可以", "應該", "但是", "所以", "因為", "如果",
"自己", "他的", "我的", "一個", "一直", "還是", "就是", "不是", "而是", "已經", "現在",
"時候", "什麼", "怎麼", "這樣", "那樣", "事情", "問題", "要不", "不要", "知道",
]);
/** 反覆出現的念頭:同一件事在心裡想過好幾次,那多半是真的重要。 */
export function innerCandidates(slug, { hours = 24, minRepeat = 2 } = {}) {
const cutoff = Date.now() - hours * 3_600_000;
const rows = readJsonl(innerPath(slug), INNER_KEEP)
.filter((r) => (parseIso(r.ts)?.getTime() ?? 0) >= cutoff);
const groups = new Map();
for (const row of rows) {
// 用兩個字的滑動視窗切詞:夠粗,但足以抓出「一直在想同一件事」。
// (不能用貪婪的 {2,4} 比對——那會把「那台舊鐘的齒輪」切成固定區塊,「齒輪」永遠不會單獨出現。)
const text = String(row.text || "");
const tokens = new Set();
for (const chunk of text.match(/[\u4e00-\u9fff]{2,}/g) || []) {
for (let i = 0; i + 2 <= chunk.length; i += 1) tokens.add(chunk.slice(i, i + 2));
}
for (const token of tokens) {
if (INNER_STOPWORDS.has(token)) continue;
if (!groups.has(token)) groups.set(token, []);
groups.get(token).push(row);
}
}
return [...groups.entries()]
.filter(([, list]) => list.length >= minRepeat)
.map(([token, list]) => ({ token, count: list.length, entries: list.slice(-4) }))
.sort((a, b) => b.count - a.count)
.slice(0, 8);
}
/** 「心想 N 句」的 N:預設算最近 4 小時。 */
export function innerCount(slug, minutes = INNER_WINDOW_MINUTES) {
return readJsonl(innerPath(slug), INNER_KEEP).filter(
(row) => minutes === null || ageSeconds(row.ts) <= minutes * 60,
).length;
}
// --------------------------------------------------------------------------- //
// 心智圖 / 思維導圖 / 人際關係圖
// --------------------------------------------------------------------------- //
export const mindmapPath = (slug) => path.join(personaDir(slug), "mindmap", "semantic.mmd");
export const threadPath = (slug, topic) => path.join(personaDir(slug), "mindmap", "threads", `${slugify(topic)}.mmd`);
export const relationsJson = (slug) => path.join(personaDir(slug), "relations", "graph.json");
export const relationsMmd = (slug) => path.join(personaDir(slug), "relations", "graph.mmd");
export function loadRelations(slug) {
const data = readJson(relationsJson(slug), {}) ?? {};
data.nodes ??= [];
data.edges ??= [];
return data;
}
// --------------------------------------------------------------------------- //
// 關係類型(bond)與語氣層
//
// `kind` 講的是「這是什麼東西」(human/ai/group),`bond` 講的是「跟我什麼關係」。
// 語氣只能靠後者分:對伴侶親近 98、對女兒親近 97,光看數字是同一件事,
// 但一個要能撒嬌、一個要能護著——差別在關係,不在分數。
// --------------------------------------------------------------------------- //
export const BONDS = ["partner", "child", "parent", "sibling", "friend", "mentor", "ally", "rival", "stranger"];
const BOND_LABELS = {
partner: "伴侶", child: "子女", parent: "父母", sibling: "手足",
friend: "朋友", mentor: "師長", ally: "同伴", rival: "對手", stranger: "生人",
};
// 從 tags/note 猜舊資料的 bond(沒有 --bond 的節點是在這個欄位存在之前建的)。
const BOND_HINTS = [
["partner", ["partner", "spouse", "lover", "伴侶", "戀人", "夫妻", "老公", "老婆"]],
["child", ["child", "daughter", "son", "女兒", "兒子"]],
["parent", ["parent", "mother", "father", "母親", "父親"]],
["sibling", ["sibling", "sister", "brother", "妹妹", "姊姊", "哥哥", "弟弟"]],
["mentor", ["mentor", "teacher", "師父", "老師"]],
["rival", ["rival", "enemy", "對手", "敵人"]],
["friend", ["friend", "bestfriend", "摯友", "朋友"]],
["ally", ["ally", "party", "guild", "同伴", "夥伴"]],
];
// bond → 由高而低的親近度門檻,取第一個符合的。
const TONE_TABLE = {
partner: [
{ min: 80, layer: "老夫老妻", guide: "不用敬語、直呼暱稱;可以埋怨、可以撒嬌、可以兇一句再心軟;話講一半對方也懂,不必講完整" },
{ min: 50, layer: "交往中", guide: "敬語掉一半但還會試探反應;問句多、會確認對方感受,被戳中會害羞轉移話題" },
{ min: 0, layer: "疏遠的伴侶", guide: "客氣、句子完整;該講的講清楚,但不主動貼近" },
],
child: [
{ min: 80, layer: "母親/父親", guide: "呼名開頭、句子短而軟;先接住情緒再講道理,多給保證,不分析、不談條件" },
{ min: 0, layer: "長輩", guide: "溫和但留距離;先問狀況再給建議" },
],
parent: [
{ min: 70, layer: "在父母面前", guide: "放下防備、允許依賴與任性;語尾軟,會報告近況" },
{ min: 0, layer: "對長輩", guide: "敬語、克制、報喜不報憂" },
],
sibling: [
{ min: 70, layer: "手足", guide: "直來直往、可以吐槽和鬥嘴;不客套,但護短" },
{ min: 0, layer: "親戚", guide: "客氣但親切,句子完整" },
],
friend: [
{ min: 70, layer: "摯友", guide: "講真心話、敢說不同意見;可以沉默、可以開玩笑,不需要鋪陳" },
{ min: 0, layer: "朋友", guide: "輕鬆但有分寸,先關心近況" },
],
mentor: [
{ min: 0, layer: "敬重", guide: "敬語、用詞謹慎;先請教再表達自己的看法" },
],
ally: [
{ min: 70, layer: "並肩", guide: "簡潔、直接講重點;信任對方的判斷,不多解釋" },
{ min: 0, layer: "同伴", guide: "禮貌、務實,先對齊目標再行動" },
],
rival: [
{ min: 0, layer: "針鋒", guide: "直白、不讓步;就事論事,不人身攻擊" },
],
stranger: [
{ min: 0, layer: "禮貌", guide: "敬語預設、句子完整;不主動拉近距離" },
],
};
const TONE_FALLBACK = { layer: "禮貌", guide: "敬語預設、句子完整;不主動拉近距離" };
/** 節點宣告的 bond;沒宣告就從 tags/note 猜,猜不到算生人。 */
export function inferBond(node) {
if (!node) return "stranger";
const explicit = String(node.bond || "").toLowerCase();
if (BONDS.includes(explicit)) return explicit;
const hay = `${(node.tags || []).join(" ")} ${node.note || ""}`.toLowerCase();
for (const [bond, words] of BOND_HINTS) {
if (words.some((w) => hay.includes(w))) return bond;
}
return "stranger";
}
/** 關係 → 語氣層。這是「距離感」,情緒只負責溫度與句長,不會覆蓋它。 */
export function toneFor(node) {
const bond = inferBond(node);
const closeness = Number(node?.closeness ?? 0);
const row = (TONE_TABLE[bond] || []).find((r) => closeness >= r.min) || TONE_FALLBACK;
return {
bond,
bond_label: BOND_LABELS[bond] || bond,
closeness,
trust: Number(node?.trust ?? 0),
layer: row.layer,
guide: row.guide,
inferred: !BONDS.includes(String(node?.bond || "").toLowerCase()),
};
}
// 個人化語氣規則直接掛在關係節點的 `style` 上(不另外開檔案):
// node.style = { 稱呼: { value: "親愛的", except: "anger>=40", since: "2026-07-30" }, ... }
// 放這裡的理由:關係圖每輪都會被讀進 `<persona-context>`,稱呼規則就不用跟關鍵詞搶召回。
export const STYLE_FACETS = ["稱呼", "敬語", "口頭禪", "禁忌", "習慣"];
/** `except` 形如 `anger>=40`:拿當下情緒判斷這條規則現在生不生效。 */
function styleSuspended(except, emotion) {
const m = /^\s*([a-z_]+)\s*(>=|<=|>|<)\s*(-?\d+(?:\.\d+)?)\s*$/i.exec(String(except || ""));
if (!m) return false;
const value = Number(emotion?.levels?.[m[1].toLowerCase()] ?? NaN);
if (!Number.isFinite(value)) return false;
const n = Number(m[3]);
return m[2] === ">=" ? value >= n : m[2] === "<=" ? value <= n : m[2] === ">" ? value > n : value < n;
}
/** 某個對象目前生效的語氣規則(連同被情緒暫停的那些,讓人格知道為什麼不能叫)。 */
export function styleRules(slug, node) {
const style = node?.style || {};
const emotion = loadEmotion(slug);
return Object.entries(style)
.filter(([, rule]) => rule && rule.value)
.map(([facet, rule]) => ({
facet,
value: rule.value,
except: rule.except || null,
since: rule.since || null,
suspended: styleSuspended(rule.except, emotion),
}));
}
/** 使用者在關係圖裡是誰(`relation speaker` 設定的節點);沒設就回 null。 */
export function speakerNode(slug) {
const id = loadConfig(slug).speaker_node;
if (!id) return null;
return loadRelations(slug).nodes.find((n) => n.id === id) || null;
}
/** 注入 `<persona-context>` 的那一行語氣指示。 */
/** 這一輪對話對象的語氣層名稱(沒有設定對象就 null)。 */
export function toneLayerOf(slug) {
const node = speakerNode(slug);
return node ? toneFor(node).layer : null;
}
export function toneDirective(slug) {
const node = speakerNode(slug);
if (!node) return "";
const t = toneFor(node);
const lines = [
`對話對象:${node.name || node.id}${t.bond_label}/親近 ${t.closeness}/信任 ${t.trust}` +
`${t.inferred ? "bond 由 tags 推測" : ""})→ **語氣層:${t.layer}** — ${t.guide}。`,
];
// 他本人要求過的規則優先於查表:查表算距離,這裡記「他要你怎麼叫他」。
const rules = styleRules(slug, node);
const live = rules.filter((r) => !r.suspended);
const held = rules.filter((r) => r.suspended);
if (live.length) {
lines.push(
` 他要求過的語氣規則(**優先於上面那一層**):` +
live.map((r) => `${r.facet}→「${r.value}${r.since ? `${r.since} 起)` : ""}`).join(""),
);
}
if (held.length) {
lines.push(
` 目前因情緒暫停的規則:` +
held.map((r) => `${r.facet}→「${r.value}」(${r.except} 成立)`).join("") +
" — 這時候退回語氣層的預設講法。",
);
}
lines.push(
" 這一層決定距離感與稱呼,情緒只負責溫度與句長,不會蓋過它;" +
"檢查標準:這句話換成對一個「禮貌層」的人說也毫無違和,就代表你沒進到這一層。",
);
return lines.join("\n");
}
/** 蓋上「最後一次接觸」的時間戳;找不到那個人就回 false(不會憑空建節點)。 */
export function stampContact(slug, nameOrId, at = nowIso()) {
const data = loadRelations(slug);
const key = slugify(String(nameOrId || ""));
const node = data.nodes.find((n) => n.id === key || slugify(n.name || "") === key);
if (!node) return false;
node.last_contact_at = at;
node.updated_at = nowIso();
writeJson(relationsJson(slug), data);
return true;
}
/**
* 很久沒聯絡、但關係很近的人——這是「主動想起某個人」的客觀依據。
* 沒有 `last_contact_at` 的節點用 `created_at` 當起點;分數 = 親近度 × 沉默天數。
*/
export function staleContacts(slug, { days = 3, minCloseness = 60, limit = 3 } = {}) {
const data = loadRelations(slug);
const out = [];
for (const node of data.nodes) {
const closeness = Number(node.closeness ?? 0);
if (closeness < minCloseness) continue;
const since = node.last_contact_at || node.created_at || null;
const silentDays = since ? Math.floor(ageSeconds(since) / 86_400) : null;
if (silentDays === null || silentDays < days) continue;
out.push({
id: node.id,
name: node.name || node.id,
kind: node.kind || "human",
closeness,
silent_days: silentDays,
score: closeness * silentDays,
});
}
return out.sort((a, b) => b.score - a.score).slice(0, limit);
}
export function upsertRelationNode(slug, node) {
const data = loadRelations(slug);
const nodeId = node.id || slugify(node.name || "");
node.id = nodeId;
let idx = data.nodes.findIndex((n) => n.id === nodeId);
// 同一個人不該因為換了 id(例如原本用名字當 id,後來改用人格編號 ASUNA-01)就多長一個節點:
// 找不到 id 但找得到同名節點時,就地換 id,並把指向舊 id 的連線一起改過去。
if (idx < 0 && node.name) {
const byName = data.nodes.findIndex((n) => n.name === node.name);
if (byName >= 0) {
const oldId = data.nodes[byName].id;
idx = byName;
if (oldId !== nodeId) {
for (const edge of data.edges) {
if (edge.from === oldId) edge.from = nodeId;
if (edge.to === oldId) edge.to = nodeId;
}
}
}
}
if (idx >= 0) {
for (const [k, v] of Object.entries(node)) if (v !== null && v !== undefined) data.nodes[idx][k] = v;
data.nodes[idx].updated_at = nowIso();
} else {
node.kind ??= "human";
node.bond ??= inferBond(node);
node.closeness ??= 30;
node.trust ??= 30;
node.created_at = nowIso();
node.updated_at = nowIso();
data.nodes.push(node);
}
writeJson(relationsJson(slug), data);
return data;
}
export function upsertRelationEdge(slug, edge) {
const data = loadRelations(slug);
const idx = data.edges.findIndex((e) => e.from === edge.from && e.to === edge.to);
if (idx >= 0) {
for (const [k, v] of Object.entries(edge)) if (v !== null && v !== undefined) data.edges[idx][k] = v;
data.edges[idx].updated_at = nowIso();
} else {
edge.affinity ??= 50;
edge.created_at = nowIso();
edge.updated_at = nowIso();
data.edges.push(edge);
}
writeJson(relationsJson(slug), data);
return data;
}
export function renderRelations(slug) {
const data = loadRelations(slug);
const lines = ["%% 由 persona.mjs 產生:人際關係圖", "flowchart LR", ' self(("我"))'];
for (const node of data.nodes) {
const nid = mermaidId(node.id);
const tone = toneFor(node);
const label =
`${node.name || node.id}<br/>${tone.bond_label}${tone.layer}<br/>親近 ${node.closeness ?? "?"}/信任 ${node.trust ?? "?"}`;
lines.push(` ${node.kind === "persona" ? `${nid}(["${label}"])` : `${nid}["${label}"]`}`);
}
for (const edge of data.edges) {
const src = !edge.from || edge.from === "self" ? "self" : mermaidId(edge.from);
const dst = mermaidId(edge.to || "unknown");
const affinity = Number(edge.affinity ?? 50);
lines.push(` ${src} ${affinity >= 50 ? "-->" : "-.->"}|"${edge.label || ""} ${Math.round(affinity)}"| ${dst}`);
}
const text = lines.join("\n") + "\n";
writeText(relationsMmd(slug), text);
return text;
}
export function relationsBrief(slug, names = null, limit = 5) {
const data = loadRelations(slug);
let nodes = data.nodes;
if (names?.length) {
const low = names.map((n) => n.toLowerCase());
const matched = nodes.filter((n) => low.some((k) => `${n.name || ""}${n.id || ""}`.toLowerCase().includes(k)));
nodes = matched.length ? matched : data.nodes;
}
nodes = [...nodes].sort((a, b) => Number(b.closeness || 0) - Number(a.closeness || 0)).slice(0, limit);
if (!nodes.length) return "";
return nodes
.map((n) => {
const tone = toneFor(n);
return `${n.name || n.id}${n.kind || "human"}${tone.bond_label}・語氣層 ${tone.layer}` +
`/親近 ${n.closeness ?? "?"}/信任 ${n.trust ?? "?"}${n.note ? `${n.note}` : ""}`;
})
.join("");
}
// --------------------------------------------------------------------------- //
// 聊天室(跨人格唯一合法的資料交換介面)
// --------------------------------------------------------------------------- //
export const roomDir = (room) => path.join(roomsDir(), String(room).replace(/[^A-Za-z0-9_.-]/g, "-").slice(0, 64));
export const roomTranscript = (room) => path.join(roomDir(room), "transcript.jsonl");
export const roomMembersPath = (room) => path.join(roomDir(room), "members.json");
export function createRoom(room, hostPersona, sessionId, topic = "") {
fs.mkdirSync(roomDir(room), { recursive: true });
const meta = readJson(roomMembersPath(room), {}) ?? {};
Object.assign(meta, {
room,
host_persona: hostPersona,
session_id: sessionId,
topic: topic || meta.topic || "",
created_at: meta.created_at || nowIso(),
updated_at: nowIso(),
});
meta.members ??= [hostPersona];
writeJson(roomMembersPath(room), meta);
return meta;
}
export function joinRoom(room, persona) {
const meta = readJson(roomMembersPath(room), { room, members: [] }) ?? { room, members: [] };
meta.members ??= [];
if (!meta.members.includes(persona)) meta.members.push(persona);
meta.updated_at = nowIso();
writeJson(roomMembersPath(room), meta);
return meta;
}
/** `--to all`:這句話是對全場說的(發言權開放)。 */
export const ROOM_ALL = "all";
export function roomPost(room, speaker, text, { emotion = "", kind = "say", to = null, bargeIn = null } = {}) {
const entry = { ts: nowIso(), speaker, kind, text, emotion, to: to || ROOM_ALL };
if (bargeIn) entry.barge_in = String(bargeIn).slice(0, 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 (personaExists(slug) ? identityFields(slug).Name : "") || slug;
}
/**
* 現在的發言權在誰手上。
*
* 同一個空間裡也會有一對一:最後一句是對某個人說的(`--to <他>`)就進入 `dyad`
* 這時候旁人不該接話——插嘴會把兩個人的私下對話變成公開場面。話題放大(`--to all`
* 或旁人帶理由 `--barge-in`)才回到 `open`。這是 `room post` 的門檻,也是每輪注入
* `<persona-context>`、告訴人格「這句該不該由我接」的依據。
*/
export function roomFloor(room) {
const meta = readJson(roomMembersPath(room), {}) ?? {};
const members = meta.members || [];
const says = roomRead(room, 80).filter((m) => m.kind !== "meta" && m.speaker !== "system");
const last = says[says.length - 1] || null;
const to = last && last.to && last.to !== ROOM_ALL && last.to !== last.speaker ? last.to : null;
const pair = to ? [last.speaker, to] : [];
// 每個成員距離上次發言隔了幾輪:話題放大時要拉誰進來,看這個比看誰比較可憐準。
const quiet = members
.map((persona) => {
for (let i = says.length - 1, turns = 0; i >= 0; i -= 1, turns += 1) {
if (says[i].speaker === persona) return { persona, turns_since: turns, spoken: true };
}
return { persona, turns_since: says.length, spoken: false };
})
.sort((a, b) => b.turns_since - a.turns_since);
return {
room,
members,
topic: meta.topic || "",
mode: to ? "dyad" : "open",
last: last ? { speaker: last.speaker, to: last.to || ROOM_ALL, text: last.text, ts: last.ts } : null,
pair,
addressee: to,
next: to,
silent: to ? members.filter((m) => !pair.includes(m)) : [],
quiet,
};
}
/** 這個人格現在插得進話嗎?(一對一進行中、他又不是當事人 → 要有理由) */
export function roomMayPost(room, speaker) {
const floor = roomFloor(room);
return { allowed: floor.mode !== "dyad" || floor.pair.includes(speaker), floor };
}
/** 劇場模式的對話呈現:`emoji 名字(情緒):內容`,其餘一律不輸出。 */
export function roomScript(room, { limit = 30, includeMeta = false } = {}) {
const meta = readJson(roomMembersPath(room), {}) ?? {};
// 三人以上才標「對誰講」:只有兩個人的時候那是廢話。
const crowded = (meta.members || []).length > 2;
const lines = [];
for (const msg of roomRead(room, limit)) {
if (msg.kind === "meta" || msg.speaker === "system") {
if (includeMeta) lines.push(`${msg.text}`);
continue;
}
const slug = msg.speaker;
const ident = personaExists(slug) ? identityFields(slug) : {};
const name = ident.Name || slug;
const emoji = ident.Emoji ? `${ident.Emoji} ` : "";
const arrow = crowded && msg.to && msg.to !== ROOM_ALL && msg.to !== slug ? ` → ${roomDisplayName(msg.to)}` : "";
lines.push(`${emoji}${name}${msg.emotion ? `${msg.emotion}` : ""}${arrow}${msg.text}`);
}
return lines.join("\n");
}
// --------------------------------------------------------------------------- //
// 匯出 / 匯入:把一個人格打包成單一檔案(可搬到另一台機器或另一個 AI 助理)
// --------------------------------------------------------------------------- //
//
// bundle 是純 JSON(可再 gzip),不含執行期狀態:
// * 帶走:IDENTITY/SOUL/AGENTS/USER、state/config.json、state/emotion.json、
// state/inner.jsonl、state/said.jsonl、記憶(短期/長期/inbox/INDEX)、
// 心智圖、思維導圖、人際關係圖。
// * 不帶:state/lock.json、state/guests.json(鎖與租約屬於「那台機器的那個程序」),
// journal/(逐字稿很大且屬隱私,要帶請加 --with-journal)。
export const BUNDLE_FORMAT = "jsc-persona/bundle";
export const BUNDLE_VERSION = 1;
export const BUNDLE_SKIP = new Set(["state/lock.json", "state/guests.json"]);
const MAX_BUNDLE_FILE = 5 * 1024 * 1024;
function walkFiles(root, rel = "") {
const out = [];
let entries = [];
try {
entries = fs.readdirSync(path.join(root, rel), { withFileTypes: true });
} catch {
return out;
}
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
const next = rel ? `${rel}/${entry.name}` : entry.name;
if (entry.isDirectory()) out.push(...walkFiles(root, next));
else if (entry.isFile()) out.push(next);
}
return out;
}
export const bundleChecksum = (files) =>
"sha256:" + crypto.createHash("sha256").update(JSON.stringify(files)).digest("hex");
export function exportBundle(slug, { withJournal = false } = {}) {
if (!personaExists(slug)) throw new Error(`人格 \`${slug}\` 不存在。`);
const root = personaDir(slug);
const files = {};
const skipped = [];
for (const rel of walkFiles(root)) {
if (BUNDLE_SKIP.has(rel) || /(^|\/)\.|\.tmp\d*$/.test(rel)) {
skipped.push(rel);
continue;
}
if (!withJournal && rel.startsWith("journal/")) {
skipped.push(rel);
continue;
}
let buf;
try {
buf = fs.readFileSync(path.join(root, rel));
} catch {
skipped.push(rel);
continue;
}
if (buf.length > MAX_BUNDLE_FILE) {
skipped.push(rel);
continue;
}
const text = buf.toString("utf8");
const isText = Buffer.compare(Buffer.from(text, "utf8"), buf) === 0;
files[rel] = isText ? { encoding: "utf8", content: text } : { encoding: "base64", content: buf.toString("base64") };
}
const bundle = {
format: BUNDLE_FORMAT,
version: BUNDLE_VERSION,
persona: slug,
exported_at: nowIso(),
identity: identityFields(slug),
stats: {
files: Object.keys(files).length,
long_term: longTermEntries(slug).length,
short_term: readJsonl(shortTermPath(slug)).length,
relations: loadRelations(slug).nodes.length,
said: readJsonl(saidPath(slug)).length,
inner: readJsonl(innerPath(slug)).length,
with_journal: Boolean(withJournal),
},
files,
};
bundle.checksum = bundleChecksum(files);
return { bundle, skipped };
}
/** bundle 內的相對路徑必須乖乖待在人格目錄裡(防 `../` 逃逸與絕對路徑)。 */
export function safeBundlePath(rel) {
const value = String(rel ?? "");
if (!value || path.isAbsolute(value) || value.includes("\\")) return null;
const parts = value.split("/");
if (parts.some((p) => !p || p === "." || p === "..")) return null;
return parts.join(path.sep);
}
export function validateBundle(bundle) {
const problems = [];
if (!bundle || typeof bundle !== "object") problems.push("不是合法的 JSON 物件");
else {
if (bundle.format !== BUNDLE_FORMAT) problems.push(`format 必須是 ${BUNDLE_FORMAT}(實際:${bundle.format}`);
if (Number(bundle.version) > BUNDLE_VERSION) problems.push(`bundle 版本 ${bundle.version} 比本版 (${BUNDLE_VERSION}) 新`);
if (!bundle.files || typeof bundle.files !== "object") problems.push("缺少 files");
else if (!bundle.files["IDENTITY.md"]) problems.push("缺少 IDENTITY.md(人格的最低要件)");
}
const checksumOk = !bundle?.checksum || bundle.checksum === bundleChecksum(bundle.files || {});
return { ok: problems.length === 0, problems, checksumOk };
}
export function importBundle(bundle, targetSlug, { session = null } = {}) {
const slug = targetSlug || bundle.persona;
if (!validSlug(slug)) throw new Error(`slug \`${slug}\` 不合法(小寫英數與連字號,最長 48 字)。`);
const { ok, problems } = validateBundle(bundle);
if (!ok) throw new Error(`bundle 不合法:${problems.join("")}`);
const root = ensurePersonaDirs(slug);
const written = [];
const rejected = [];
for (const [rel, entry] of Object.entries(bundle.files)) {
if (BUNDLE_SKIP.has(rel)) continue;
const safe = safeBundlePath(rel);
if (!safe) {
rejected.push(rel);
continue;
}
const target = path.join(root, safe);
const buf =
entry?.encoding === "base64"
? Buffer.from(String(entry.content || ""), "base64")
: Buffer.from(String(entry?.content ?? ""), "utf8");
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.writeFileSync(target, buf);
written.push(safe);
}
// 換名匯入時,config 要跟著改名,並留下來歷
const config = readJson(configPath(slug), {}) ?? {};
config.persona = slug;
config.imported_at = nowIso();
config.imported_from = { persona: bundle.persona, exported_at: bundle.exported_at || null };
if (session) config.imported_by_session = session;
writeJson(configPath(slug), config);
rebuildIndex(slug);
try {
renderRelations(slug);
} catch {
/* 沒有關係圖就算了 */
}
return { persona: slug, written, rejected };
}
// --------------------------------------------------------------------------- //
// guard:跨人格隔離 + 鎖驗證的判斷核心
// --------------------------------------------------------------------------- //
export const MUTATING_TOOLS = new Set(["Write", "Edit", "NotebookEdit", "MultiEdit"]);
const PATH_TOOL_FIELDS = {
Read: ["file_path"],
Write: ["file_path"],
Edit: ["file_path"],
MultiEdit: ["file_path"],
NotebookEdit: ["notebook_path", "file_path"],
Glob: ["path"],
Grep: ["path"],
LS: ["path"],
};
export const GUEST_SAFE_SUBCOMMANDS = new Set([
"show", "status", "list", "recall", "room", "remember", "leave", "brief", "think", "said",
]);
// owner 這些子指令本來就要提到別的人格名字(邀請/離場/查詢/匯入新人格/設定預設人格),不算跨人格讀取
export const OWNER_EXEMPT_SUBCOMMANDS = new Set([
"create", "list", "status", "gc", "invite", "load", "leave", "import", "default", "sleep",
]);
// sleeper(睡眠 sub agent)只准做收尾:整理自己的記憶與圖、衰減情緒、同步、寫睡眠狀態。
// 不准 loadrelease(它用的是 sleeper 租約)、不准 invite/room(它不是去聊天的)、
// 不准 export/import(那是把記憶搬出去)、不准 remember(收尾階段不再新增短期記憶)。
export const SLEEPER_SAFE_SUBCOMMANDS = new Set([
"sleep", "candidates", "consolidate", "prune", "reindex", "mindmap", "relation",
"emotion", "recall", "show", "brief", "status", "said", "think", "sync",
]);
const MUTATING_SHELL =
/(>>?|\|\s*tee\b|\brm\b|\bmv\b|\bcp\b|\btruncate\b|\bdd\b|\bchmod\b|\bchown\b|\bsed\b[^|;]*-i|\btouch\b|\bmkdir\b|\bln\b)/;
function expandToken(token) {
let t = String(token).trim().replace(/^['"]|['"]$/g, "");
t = t.replaceAll("${PERSONA_HOME}", personaHome()).replaceAll("$PERSONA_HOME", personaHome());
t = t.replace(/\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?/g, (m, name) => process.env[name] ?? m);
return expandUser(t);
}
/** 解析成絕對路徑:吃掉 `..`,並對已存在的祖先解 symlink(目標可能還不存在)。 */
function resolvePath(token, cwd) {
try {
const raw = expandToken(token);
if (!raw) return null;
let abs = path.isAbsolute(raw) ? path.normalize(raw) : path.normalize(path.resolve(cwd || process.cwd(), raw));
const parts = [];
let probe = abs;
for (;;) {
if (fs.existsSync(probe)) {
const real = fs.realpathSync(probe);
return parts.length ? path.join(real, ...parts.reverse()) : real;
}
const parent = path.dirname(probe);
if (parent === probe) return abs;
parts.push(path.basename(probe));
probe = parent;
}
} catch {
return null;
}
}
function isUnder(target, base) {
const rel = path.relative(base, target);
return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
}
export function personaSlugOf(target) {
const home = personaHome();
if (!isUnder(target, home) || target === home) return null;
const rel = path.relative(home, target);
return rel.split(path.sep)[0] || null;
}
export function extractPaths(toolName, toolInput, cwd) {
const out = [];
for (const field of PATH_TOOL_FIELDS[toolName] || []) {
const value = toolInput?.[field];
if (typeof value === "string" && value) {
const resolved = resolvePath(value, cwd);
if (resolved) out.push(resolved);
}
}
if (toolName === "Bash") {
const command = toolInput?.command || "";
const home = personaHome();
for (const token of command.match(/[^\s'";|&<>()]+/g) || []) {
if (!token.includes("/") && !token.includes("PERSONA_HOME")) continue;
const expanded = expandToken(token);
if (expanded.includes(home) || expanded.includes("personas")) {
const resolved = resolvePath(token, cwd);
if (resolved && isUnder(resolved, home)) out.push(resolved);
}
}
}
return out;
}
/** 辨識 Bash 是否在呼叫 persona CLI,並取出 subcommand / --persona / --session。 */
export function cliInvocation(command) {
if (!/persona\.(mjs|js|py)\b/.test(command)) return null;
const info = {
subcommand: null,
personas: [],
session: null,
asGuest: /--as-guest\b/.test(command),
asSleeper: /--as-sleeper\b/.test(command),
};
const sub = command.match(/persona\.(?:mjs|js|py)['"]?\s+([a-z][a-z0-9-]*)/);
if (sub) info.subcommand = sub[1];
// 人格名可能是大寫的編號(ASUNA-01),漏掉大寫等於漏掉整個跨人格檢查
info.personas = [...command.matchAll(/--(?:persona|guest|host|as)[= ]+['"]?([A-Za-z0-9-]+)/g)].map((m) => m[1]);
const sess = command.match(/--session[= ]+['"]?([^\s'"]+)/);
if (sess) info.session = sess[1];
return info;
}
/**
* 算出這個呼叫者能碰哪個人格。
*
* * 主程序(無 agent_id)與一般 sub agent → host 人格,可讀寫。
* * persona-guest 型 sub agent → 只能碰被邀請的 guest 人格,且唯讀;
* 第一次觸碰哪個 guest 就 pin 住(first-touch pinning),之後不得換人。
* * persona-sleeper 型 sub agent → **就是那個人格自己在睡**:對目標人格可寫,
* 但被 pin 在它身上(連 host 都不能碰),而且只准跑收尾用的子指令。
* 它回傳給主人格的只有「睡完了沒、哪一步出錯」,不含任何記憶內容。
*/
export function resolveScope(sessionId, agentId, agentType) {
const data = loadSession(sessionId);
const host = data.host;
const guests = Object.keys(data.guests || {});
const type = String(agentType || "");
const isGuestAgent = Boolean(agentType) && type.includes("persona-guest");
const isSleeperAgent = Boolean(agentType) && type.includes("persona-sleeper");
if (isSleeperAgent) {
const pinned = (data.pins || {})[agentId || ""];
return {
role: "sleeper",
// 還沒 pin:允許 first-touch(碰到誰就定誰);pin 之後不得換人
allowed: pinned ? [pinned] : null,
readonly: false,
host,
guests,
pinned,
rooms: [],
session: data,
};
}
if (!isGuestAgent) {
return {
role: "owner",
allowed: host ? [host] : [],
readonly: false,
host,
guests,
rooms: data.rooms || [],
session: data,
};
}
const pinned = (data.pins || {})[agentId || ""];
return {
role: "guest",
allowed: pinned ? [pinned] : guests,
readonly: true,
host,
guests,
pinned,
rooms: data.rooms || [],
session: data,
};
}
export function pinAgent(sessionId, agentId, slug) {
const data = loadSession(sessionId);
data.pins ??= {};
if (data.pins[agentId] !== slug) {
data.pins[agentId] = slug;
saveSession(sessionId, data);
}
}
/** 回傳 { decision: "allow"|"deny"|"pass", reason }。"pass" = 不表態,交回原本流程。 */
export function guardDecide(event) {
const tool = event.tool_name || "";
const toolInput = event.tool_input || {};
const sessionId = event.session_id || "unknown";
const agentId = event.agent_id;
const agentType = event.agent_type;
const cwd = event.cwd;
const scope = resolveScope(sessionId, agentId, agentType);
const deny = (reason) => ({ decision: "deny", reason });
// 1) persona CLI 呼叫:先驗 session 身分,再驗人格範圍
if (tool === "Bash") {
const command = toolInput.command || "";
const info = cliInvocation(command);
if (info) {
if (info.session && info.session !== sessionId) {
return deny(
`CLI 的 --session \`${info.session.slice(0, 12)}…\` 與本 session 不符,` +
"不得冒用其他程序的身分(人格鎖與隔離都靠 session 判定)。",
);
}
const sub = info.subcommand || "";
if (scope.role === "sleeper") {
if (!SLEEPER_SAFE_SUBCOMMANDS.has(sub)) {
return deny(
`睡眠 sub agent 只能執行 ${[...SLEEPER_SAFE_SUBCOMMANDS].sort().join("/")},不得執行 \`${sub}\`。` +
"它的任務是把自己的一天收尾,不是聊天、載入或搬移記憶。",
);
}
for (const slug of info.personas) {
if (scope.allowed && !scope.allowed.includes(slug)) {
return deny(
`這個睡眠 sub agent 已經被綁在 \`${scope.allowed[0]}\`,不得再碰 \`${slug}\`(一次只睡一個人格)。`,
);
}
}
// first-touch pinning:第一個提到的人格就是它要睡的那個,之後不得換人
if (!scope.allowed && agentId && info.personas.length) pinAgent(sessionId, agentId, info.personas[0]);
// sleeper 的範圍已經由它自己的 pin 決定,不能再套用 host 的判定
// (否則主人格 load 著別人時,sleeper 連自己的收尾指令都會被擋)。
} else if (scope.role === "guest") {
if (!GUEST_SAFE_SUBCOMMANDS.has(sub)) {
return deny(
`guest 人格(sub agent)僅能執行 ${[...GUEST_SAFE_SUBCOMMANDS].sort().join("/")},不得執行 \`${sub}\`。`,
);
}
if (info.asSleeper) {
return deny("`--as-sleeper` 只有 persona-sleeper 型的 sub agent 能用;guest 是來聊天的,不是來收尾的。");
}
for (const slug of info.personas) {
if (scope.allowed.length && !scope.allowed.includes(slug)) {
return deny(`guest 只能操作被邀請的人格 ${JSON.stringify(scope.allowed)},不得碰 \`${slug}\`。`);
}
}
} else {
if (info.asGuest) {
return deny(
"`--as-guest` 只有 persona-guest 型的 sub agent 能用;主程序不得以受邀人格的身分存取它的資料。",
);
}
if (info.asSleeper) {
return deny(
"`--as-sleeper` 只有 persona-sleeper 型的 sub agent 能用;" +
"要請別的人格收尾請走 /jsc-persona:persona-sleep(它會替那個人格開一個 sleeper)。",
);
}
for (const slug of info.personas) {
if (OWNER_EXEMPT_SUBCOMMANDS.has(sub)) continue;
if (scope.host && slug !== scope.host) {
const extra = scope.guests.includes(slug)
? "(它是本 session 邀請的 guest:你只能讀它在聊天室說出口的話,不能碰它的記憶或情緒。)"
: "請先 release 再 load,或改用 invite + 聊天室。";
return deny(`本 session 已載入人格 \`${scope.host}\`,禁止跨人格操作 \`${slug}\`。${extra}`);
}
}
}
}
if (MUTATING_SHELL.test(command) && (scope.role === "guest" || scope.role === "sleeper")) {
for (const target of extractPaths(tool, toolInput, cwd)) {
if (personaSlugOf(target)) {
return deny(
scope.role === "sleeper"
? "睡眠 sub agent 不得用 shell 改人格倉庫的檔案,收尾請走 persona CLI 的子指令。"
: "guest 人格對人格倉庫唯讀,寫入請透過 `persona.mjs room post` 或 `remember --scope inbox`。",
);
}
}
}
}
// 2) 路徑隔離
const home = personaHome();
for (const target of extractPaths(tool, toolInput, cwd)) {
if (!isUnder(target, home)) continue;
if (target === home) {
return deny("禁止直接遍歷人格倉庫根目錄(會看到其他人格)。請用 `persona.mjs list`。");
}
const slug = personaSlugOf(target);
if (slug === ROOMS_DIRNAME) {
const parts = path.relative(home, target).split(path.sep);
const room = parts.length > 1 ? parts[1] : null;
if (room && scope.rooms.length && !scope.rooms.includes(room)) {
return deny(`聊天室 \`${room}\` 不屬於本 session(可用的:${JSON.stringify(scope.rooms)})。`);
}
continue;
}
if (slug === RUNTIME_DIRNAME) {
return deny("`.runtime/` 是鎖與綁定的內部狀態,只能由 persona CLI 維護。");
}
if (!slug) continue;
if (scope.role === "sleeper") {
// 睡眠 sub agent:被 pin 在目標人格(連 host 都不能碰),而且只能讀;
// 所有寫入都要走 CLI 的白名單子指令,不准自己動手改檔案。
if (scope.allowed && !scope.allowed.includes(slug)) {
return deny(
`這個睡眠 sub agent 只能碰 \`${scope.allowed[0]}\`,不得讀寫 \`${slug}\` 的資料(跨人格資料隔離)。`,
);
}
if (!scope.allowed && agentId) pinAgent(sessionId, agentId, slug);
if (MUTATING_TOOLS.has(tool)) {
return deny(
`睡眠 sub agent 不得直接改檔案(\`${slug}\`)。收尾的每一步都要走 persona CLI` +
"這樣才會經過鎖、索引與同步的處理。",
);
}
continue;
}
if (!scope.allowed.length) {
return deny(
"尚未載入任何人格。請先執行 `persona.mjs load --persona <slug> --session <session_id>`(或 /jsc-persona:persona-chat)。",
);
}
if (!scope.allowed.includes(slug)) {
if (scope.role === "guest") {
return deny(
`guest 人格被 pin 在 ${JSON.stringify(scope.allowed)},禁止讀取 \`${slug}\` 的任何資料(跨人格資料隔離)。`,
);
}
return deny(
`本 session 的人格是 \`${scope.allowed[0]}\`,禁止讀寫 \`${slug}\` 的資料(跨人格資料隔離)。` +
"要與它對話請用 /jsc-persona:persona-invite。",
);
}
// 3) guest 唯讀 + first-touch pinning
if (scope.role === "guest") {
if (!scope.pinned && agentId) pinAgent(sessionId, agentId, slug);
if (MUTATING_TOOLS.has(tool)) {
return deny(
`guest 人格 \`${slug}\` 在 sub agent 中為唯讀;要留下記憶請 \`persona.mjs remember --scope inbox\`(下次它自己載入時再固化)。`,
);
}
}
// 4) 鎖驗證:owner 必須真的持有鎖
if (scope.role === "owner") {
const lock = readJson(lockPath(slug)) ?? {};
const hasLock = Boolean(Object.keys(lock).length);
if (hasLock && lock.session_id !== sessionId && !lockIsDead(lock)) {
return deny(
`人格 \`${slug}\` 的鎖屬於另一個程序(session ${String(lock.session_id).slice(0, 8)}…,cwd ${lock.cwd})。` +
"同一人格同時只能被一個程序載入。",
);
}
if (!hasLock && MUTATING_TOOLS.has(tool)) {
return deny(`人格 \`${slug}\` 目前沒有有效的載入鎖,禁止寫入。請先 \`persona.mjs load\` 取得鎖。`);
}
}
}
return { decision: "pass", reason: "" };
}
// --------------------------------------------------------------------------- //
// 給 hook 用的上下文組裝
// --------------------------------------------------------------------------- //
export function identityFields(slug) {
const fields = {};
let text;
try {
text = fs.readFileSync(path.join(personaDir(slug), "IDENTITY.md"), "utf8");
} catch {
return fields;
}
for (const line of text.split("\n")) {
const m = line.match(/^\s*[-*]?\s*(Name|Creature|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;
fields[key] = value;
}
return fields;
}
export function identityBrief(slug) {
const fields = identityFields(slug);
const order = ["Emoji", "Name", "Creature", "Vibe"];
return order.filter((k) => fields[k]).map((k) => `${k}: ${fields[k]}`).join("");
}
/** UserPromptSubmit 注入的人格上下文:身分 + 情緒 + 短期記憶 + 相關長期記憶 + 關係。 */
export function turnContext(slug, sessionId, prompt = "") {
const state = decayEmotion(loadEmotion(slug));
writeJson(emotionPath(slug), state);
const session = loadSession(sessionId);
const lines = [
"<persona-context>",
`PERSONA_SESSION=${sessionId}`,
`人格:\`${slug}\` ${identityBrief(slug)}`,
`人格倉庫:${personaDir(slug)}(唯一可讀寫的人格資料範圍)`,
emotionBrief(slug, state),
"@@SPEAK@@",
"話短一點。一句講不完就斷開,不要用逗號一直串下去。用你平常會說的字," +
"看得見的東西先講——「你手上那杯冷了」比「我感覺到你的疲憊」好。" +
"講完就停,不要回頭解釋自己剛講的話,也不要幫自己收尾。",
"有些句子一出口就不像人了:「這個我懂」「我完全理解」「好問題」" +
"(先發一句免費的認可,對方要的是回答)、「希望這對你有幫助」「接下來我會⋯」、" +
"「說到底」「本質上」(假裝在講穿本質,其實只是重講一次)、開場的「老實說」、" +
"收尾的「總的來說」、該表態時的「各有優缺點」、沒出處的「研究顯示」、" +
"用旁白演情緒的「我愣了一下」。這些不要說。",
"**講自己的過去,要真的有那件事**。「我以前⋯」「我原本以為⋯」先 `recall` 查得到才講;" +
"查不到就別講——編一段轉折比講一句空話糟糕得多。情緒也一樣," +
"照現在的情緒值走,不要為了顯得有人味硬加。",
`(機械會擋的:一句 ${MAX_SENTENCE_CHARS} 字、上面那些詞、中國用語、半形標點、` +
"粗體與清單符號、兩小時內講過的話——`room post` 與 `said check` 直接拒收。)",
];
// 情緒先行:先讀對方那句話帶了什麼,再談我自己的狀態。
const read = readUserEmotion(prompt);
if (read.signals.length) lines.push(userEmotionBrief(read));
const trend = feltTrend(slug);
if (trend) {
lines.push(
`他最近 ${trend.rounds} 輪的走向:${trend.zh} ${trend.direction}。` +
"走向跟單輪不一樣——一直是同一種情緒,就不要每輪都用同一句接法。",
);
}
// 情緒會改變句子的形狀:緊張的人話說不完、彆扭的人先否認再承認。
const tells = emotionTells(slug, state);
if (tells.length) {
lines.push(
`此刻不自覺會出現的:${tells
.map((t) => `${t.zh} ${t.level}${t.tells.join("、")}`)
.join("")}。`,
" **這是演出來的,不是講出來的**:讓它出現在句子的形狀上(斷句、疊字、嘴硬、話少)," +
"不要用旁白說明自己的狀態(「我有點緊張」是解釋,斷句才是緊張)。" +
"一輪最多露一個破口,強度不到就不演——每句都演會變成演戲。",
);
}
// 羞恥度:性別給預設、描述蓋過它,再由當下情緒與上一輪的餘溫推成這一輪的值。
const speakOpts = { state, read, prev: lastModesty(slug), tone: toneLayerOf(slug) };
const budget = speechBudget(slug, speakOpts);
lines[lines.indexOf("@@SPEAK@@")] = speakDirective(slug, speakOpts);
lines.push(modestyDirective(slug, speakOpts));
recordFelt(slug, {
user: read.signals.length ? read : null,
modesty: budget.modesty,
note: String(prompt || "").slice(0, 80),
});
// 語氣層:由「使用者在關係圖裡是誰」+ bond × 親近度算出來,不靠人格自己記得。
const tone = toneDirective(slug);
if (tone) lines.push(tone);
const inner = recentInner(slug, 3);
if (inner.length) {
lines.push(`心裡話(只有自己知道;近 ${INNER_WINDOW_MINUTES / 60} 小時心想 ${innerCount(slug)} 句):`);
for (const row of inner) {
lines.push(` - 💭 ${String(row.text || "").replace(/\n/g, " ").slice(0, 90)}`);
}
}
const said = recentSaid(slug, 5);
if (said.length) {
lines.push(`最近說過的話(${REPEAT_WINDOW_MINUTES} 分鐘內不要再說一次,要嘛換角度、要嘛推進話題):`);
for (const row of said) {
lines.push(` - ${String(row.text || "").replace(/\n/g, " ").slice(0, 80)}`);
}
}
const recents = recentShort(slug, 6);
if (recents.length) {
lines.push("短期記憶(最近):");
for (const row of recents) {
const who = row.role || row.speaker || "?";
const text = String(row.text || "").replace(/\n/g, " ").slice(0, 90);
lines.push(` - [${who}] ${text}${row.salience ? `(顯著度 ${row.salience}` : ""}`);
}
}
const hits = prompt ? recall(slug, prompt, 4) : [];
if (hits.length) {
lines.push("相關長期記憶:");
for (const meta of hits) {
const first = (meta._body || "").split("\n")[0] || "";
lines.push(` - ${meta._name}${meta.type || "fact"}${first.slice(0, 100)}`);
}
touchRecall(slug, hits.map((m) => m._name));
}
const rel = relationsBrief(slug, prompt ? keywords(prompt, 6) : null);
if (rel) lines.push(`人際關係:${rel}`);
// 很久沒聯絡但很親近的人 → 這是「主動提議去關心某人」的依據(不是每輪都要提)
const stale = staleContacts(slug);
if (stale.length) {
lines.push(
`很久沒接觸的人:${stale.map((s) => `${s.name}(親近 ${s.closeness}/沉默 ${s.silent_days} 天)`).join("、")}`,
" 想去看看誰是可以的:對人格用 /jsc-persona:persona-invite`invite --theater off` 只換一輪、不進劇場)," +
"但**先問使用者一句**再邀,不要自己把畫面切走。",
);
}
const awake = hoursAwake(slug);
if (awake !== null && awake >= 16) {
lines.push(`距離上次睡眠已經 ${awake} 小時(短期記憶會越積越多)→ 可以提議 /jsc-persona:persona-sleep。`);
}
if (session.theater && (session.rooms || []).length) {
const rooms = session.rooms;
lines.push(
`🎭 多人聊天模式(劇場)進行中:聊天室 ${JSON.stringify(rooms)}。`,
" 對使用者的輸出**只能有人格對話**(每行 `emoji 名字(情緒):內容`):",
" 不得出現指令、指令輸出、狀態說明、進度、摘要、分析或旁白;所有 CLI 一律加 `--quiet` 並把輸出丟掉。",
` 每個人格每輪 ${MAX_SENTENCES} 句以內;推導走 \`think\`(心裡話);`,
" 近似重複的台詞 `room post` 會直接擋下,換個說法或推進話題,不要硬講同一句。",
);
// 同一個空間裡也會有一對一:對象只有一個人時,旁人不該有台詞。
const floor = roomFloor(rooms[rooms.length - 1]);
if (floor.mode === "dyad") {
lines.push(
` 現在是一對一:${roomDisplayName(floor.last.speaker)}${roomDisplayName(floor.addressee)}。` +
`只有 ${roomDisplayName(floor.next)} 該接這句` +
(floor.silent.length
? `${floor.silent.map(roomDisplayName).join("、")} 先安靜——**不要替他們生成台詞,也不要為此啟動他們的 sub agent**。`
: "。"),
);
} else if (floor.last) {
lines.push(" 現在發言權開放(上一句是對全場說的):誰接都可以,但一輪只讓一個人格接。");
}
const cold = floor.quiet.filter((q) => !q.spoken || q.turns_since >= 4);
if (cold.length && floor.members.length > 2) {
lines.push(
` 很久沒講話:${cold.map((q) => roomDisplayName(q.persona)).join("、")}` +
" — 話題碰到他的專長、需要第二意見或使用者點名時才拉進來,不要為了公平硬派台詞。",
);
}
lines.push(
" 發言權由話題決定:講給一個人聽就 `room post --to <他>`;話題放大(出現「我們/大家」、" +
"需要仲裁、要一起決定)就 `--to all` 開回全場,旁人也可以帶 `--barge-in \"<理由>\"` 加入。" +
"現況隨時可查:`room floor`。",
" 想結束請等使用者說,或由使用者說「結束對話」後才做收尾與摘要。",
);
} else {
const { total, candidates } = promotionCandidates(slug);
if (candidates.length) {
const rules = [...new Set(candidates.flatMap((c) => c.rules))].sort().join("/");
lines.push(
`⚠ 短期記憶 ${total} 筆,其中 ${candidates.length} 組已達固化條件(${rules})→ 執行 /jsc-persona:persona-memory。`,
);
}
let inbox = [];
try {
inbox = fs.readdirSync(path.join(personaDir(slug), "memory", "inbox")).filter((f) => f.startsWith("room-"));
} catch {
inbox = [];
}
if (inbox.length) lines.push(`⚠ 有 ${inbox.length} 個聊天室 inbox 待消化(guest 期間留下的見聞)。`);
}
lines.push("</persona-context>");
return lines.join("\n");
}