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>
This commit is contained in:
2026-07-31 06:12:18 +00:00
co-authored by Claude Opus 5
parent 5b52dd18c2
commit ab4ddb734d
6 changed files with 320 additions and 43 deletions
+197 -31
View File
@@ -516,55 +516,147 @@ export function modestyOf(slug) {
};
}
// --------------------------------------------------------------------------- //
// 羞恥度的當下值:情緒會推它,而且它自己有回饋
//
// 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 speechBudget(slug) {
const m = modestyOf(slug);
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 {
modesty: m.value, sentences: 2, thinkMin: 2, gap: "high",
...base, mode: "shrink", sentences: 2, thinkMin: 2,
note: "話會變少,而且說出口的那句常常在**迴避**心裡那句——先把心裡話寫完再開口," +
"台詞不可以是心裡話的摘要(心裡想「我一直在等你問」,出口就不能是「我有在等你問」)。",
};
}
if (m.value >= 50) {
return { modesty: m.value, sentences: 3, thinkMin: 1, gap: "mid", note: "心裡想到的比講出來的多一點。" };
return { ...base, mode: "shrink", sentences: 3, thinkMin: 1, note: "心裡想到的比講出來的多一點。" };
}
return { modesty: m.value, sentences: MAX_SENTENCES, thinkMin: 0, gap: "low", note: "想到什麼就講,不用先在心裡繞一圈。" };
return { ...base, mode: "open", sentences: MAX_SENTENCES, thinkMin: 0, note: "想到什麼就講,不用先在心裡繞一圈。" };
}
/** 注入用的一行:這一輪能講幾句、心裡話該有幾句。 */
export function speakDirective(slug) {
const b = speechBudget(slug);
/** 注入用的一行:這一輪能講幾句、每句多長、心裡話該有幾句。 */
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} 句(劇場模式每人每輪也一樣)。${b.note}\n` +
`這一輪最多講 ${b.sentences}${chars}(劇場模式每人每輪也一樣)。${b.note}\n` +
` 推導、比對、盤算不要說給他聽,寫進心裡話(\`persona.mjs think\`)——${think}` +
"要讓他知道你在想,只報「心想 N 句」,不報內容。"
);
}
/** 注入用的一行:講清楚它是什麼、從哪來、怎麼演。 */
export function modestyDirective(slug) {
const m = modestyOf(slug);
const from = m.signals.length
? `性別預設 ${m.base} ${m.adjust >= 0 ? "" : ""} 描述 ${Math.abs(m.adjust)}${m.signals.join("、")}`
: `性別預設(${m.gender_zh}`;
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}):被稱讚外表、被戳穿心事、距離突然變近的時候,` +
return `羞恥度 ${m.value}${from}):被稱讚外表、被戳穿心事、距離突然變近的時候,` +
"**先鬧彆扭再承認**——嘴硬一句、把話講一半、轉移到別的東西上。" +
"不要直接說「我害羞」(那是解釋),要讓它出現在句子的形狀上。";
}
if (m.value <= 40) {
return `羞恥敏感${m.value}${from}):這個人格不太在意別人眼光——` +
return `羞恥度 ${m.value}${from}):這個人格不太在意別人眼光——` +
"被戳穿、被稱讚都不用演害羞,該承認就直接承認,該回嘴就回嘴。";
}
return `羞恥敏感${m.value}${from}):會不好意思,但不會一直卡在那裡——一句帶過就往下走。`;
return `羞恥度 ${m.value}${from}):會不好意思,但不會一直卡在那裡——一句帶過就往下走。`;
}
export function emotionBrief(slug, state = null) {
@@ -696,19 +788,26 @@ export const FELT_KEEP = 120;
export const feltPath = (slug) => path.join(personaDir(slug), "state", "felt.jsonl");
/** 記一輪:對方帶了什麼、我套用了什麼。 */
export function recordFelt(slug, { user = null, mine = null, note = "" } = {}) {
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) return null;
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);
@@ -1878,6 +1977,62 @@ export function recordInner(slug, text, { kind = "infer", room = null } = {}) {
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(
@@ -2034,6 +2189,12 @@ export function speakerNode(slug) {
}
/** 注入 `<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 "";
@@ -2852,7 +3013,7 @@ export function turnContext(slug, sessionId, prompt = "") {
`人格:\`${slug}\` ${identityBrief(slug)}`,
`人格倉庫:${personaDir(slug)}(唯一可讀寫的人格資料範圍)`,
emotionBrief(slug, state),
speakDirective(slug),
"@@SPEAK@@",
"話短一點。一句講不完就斷開,不要用逗號一直串下去。用你平常會說的字," +
"看得見的東西先講——「你手上那杯冷了」比「我感覺到你的疲憊」好。" +
"講完就停,不要回頭解釋自己剛講的話,也不要幫自己收尾。",
@@ -2869,10 +3030,7 @@ export function turnContext(slug, sessionId, prompt = "") {
];
// 情緒先行:先讀對方那句話帶了什麼,再談我自己的狀態。
const read = readUserEmotion(prompt);
if (read.signals.length) {
lines.push(userEmotionBrief(read));
recordFelt(slug, { user: read, note: String(prompt || "").slice(0, 80) });
}
if (read.signals.length) lines.push(userEmotionBrief(read));
const trend = feltTrend(slug);
if (trend) {
lines.push(
@@ -2892,8 +3050,16 @@ export function turnContext(slug, sessionId, prompt = "") {
"一輪最多露一個破口,強度不到就不演——每句都演會變成演戲。",
);
}
// 羞恥敏感度:性別給預設IDENTITY/SOUL 的描述蓋過它(描述永遠優先)
lines.push(modestyDirective(slug));
// 羞恥度:性別給預設、描述蓋過它,再由當下情緒與上一輪的餘溫推成這一輪的值
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);