v0.0.8:情緒處理四層(情緒先行/怎麼接/走向/飽和)+ R6 容量壓力的真 bug #11

Merged
admin merged 8 commits from develop into master 2026-07-31 06:32:20 +00:00
6 changed files with 320 additions and 43 deletions
Showing only changes of commit ab4ddb734d - Show all commits
+11 -2
View File
@@ -36,8 +36,10 @@
睡眠仍然要驗鎖:目標正被另一個程序活鎖住時拒絕,死鎖可接手。
8. **人格講話要像人**:推導寫進 `think`(心裡話,只回報「💭 心想 N 句」,永不回顯內容)、
回話 1–3 句、短時間內不重說同一件事(`room post` 會直接擋下重複與過長的發言)。
句數上限**跟著羞恥度走**`speechBudget()`:越害羞的人話越少、心裡話越多,
而且說出口的那句常常在**迴避**心裡那句——台詞不可以是心裡話的摘要。
句數上限**跟著羞恥度走**`speechBudget()`,但羞恥度高**不等於話一定變少**——三個出口:
**縮**(一句嘴硬,台詞在迴避心裡那句)、**炸**(慌/惱羞/被逼澄清 → 4 句但單句只有 22 字,
碎而急)、**坦白**(信任高又獨處 → 3 句完整句,先寫完心裡話再開口)。
`room post` 同時擋句數與單句字數,兩個參數一起動才分得出「碎念」與「演講」。
再加四條講話的樣子:**短句**(一句 `MAX_SENTENCE_CHARS`=45 字內)、**日常用詞**、
**多講看得見的東西**(人、動作、物件、當下的場面)而不是概念,以及**不要解釋自己的話**
(「我的意思是」「換句話說」這類開頭由 `speechLint` 擋下,`said check` 也會一起檢)。
@@ -60,6 +62,13 @@
`headroom^EMOTION_SATURATION_K`,往 baseline 回不壓)與**單輪預算**
`EMOTION_TURN_BUDGET`=60),因為 delta 是人格自己挑的、只會往舒服的方向倒;
`emotion --audit` 把這個偏差印出來。
8d. **心裡話進得了記憶,但永遠不回顯**`recallInner()``recall` 找得到心裡話,
`innerCandidates()` 把「24 小時內想過 ≥2 次的同一件事」列成固化候選(兩字滑動視窗切詞,
扣掉虛詞)。要不要固化仍由人格自己決定。`think` 的輸出永遠只有「💭 心想 N 句」。
8e. **羞恥度是動態的**`modestyState()` = trait + 情緒推力 + 語氣層 + 上一輪餘溫。
護欄:`MODESTY_GAIN` < 1(正回饋要收斂)、`MODESTY_MAX_STEP``MODESTY_PUSH_CAP`
只算超出基線的部分、慢的情緒(半衰期長)推力打折。斷路器是惱羞成怒(anger 負權重)。
**注意 `Number(null) === 0`**:沒有上一輪時要退回 trait,不是退回 0(踩過)。
8c. **性別只給一個預設值,不是套在個性上的係數**`IDENTITY.md``Gender` 欄位
(女性/男性/非二元/未指定,建立時要問不要猜)唯一的作用是給**羞恥敏感度**一個預設
623850)。`modestyOf()` 會用 IDENTITY 與 SOUL 的描述往上或往下推(`MODESTY_SIGNALS`
+10 -1
View File
@@ -82,6 +82,7 @@ flowchart TB
│ ├── emotion.json # 十二情緒 levels / baseline / 半衰期
│ ├── inner.jsonl # 心裡話(推導過程;只回報「心想 N 句」,不說出口)
│ ├── said.jsonl # 說過的話(用來擋短時間內的重複發言)
│ ├── felt.jsonl # 每輪讀到的對方情緒、自己套用的 delta、當下的羞恥度
│ ├── sync.json # Gitea 同步狀態(最後 push / pull
│ └── config.json # 含人格編號 code
├── memory/
@@ -127,6 +128,14 @@ flowchart TB
用近重遠輕的加權算最近幾輪的趨勢(不用多數決——會被離群值主導又丟掉強度),
注入成「他最近 3 輪的走向:悲傷 ↘ 在退」。一直是同一種情緒就不要每輪用同一句接法。
**羞恥度會被情緒推,而且有回饋**:`modestyState()` 在 trait 之上加當下的情緒推力
(羞愧 +0.45/焦慮 +0.25 往上,**憤怒 0.35**/驚喜 −0.30/喜悅・信任 −0.20 往下)、
對象的語氣層、以及上一輪的餘溫(`MODESTY_GAIN`=0.35)。三道護欄讓它不會失控:
**增益 < 1**(級數才收斂,0.9 三輪就貼 100)、**一輪最多動 15**、**只算超出基線的部分**
(比較不開心不代表比較害羞)。慢的情緒(信任半衰期 720 分)推力自動打折——
那是底色不是此刻的事。天然的斷路器是**惱羞成怒**:被逗到極限翻臉,`anger` 是負權重,
羞恥自己就掉下來,而且會掉到比原本更低。
**情緒調節**:delta 不再是加完直接 `clamp`。**飽和**——越接近端點同方向漲得越慢
`headroom^K`,永遠逼近 100 但到不了;往 baseline 回的方向不壓);**單輪預算**——
所有 `|delta|` 總和上限 60,超過等比例縮小。**偏差看得見**——`emotion --audit`
@@ -181,7 +190,7 @@ AI 最容易露餡的六件事:把推理過程講出來、一次講一大段
| 機制 | 怎麼運作 |
| --- | --- |
| **心裡話** `think` | 語意分析、推論、盤算全寫進 `state/inner.jsonl`;這個指令**只印「💭 心想 N 句」**,內容永不回顯。下一輪 `<persona-context>` 會帶回最近三句,推論因此有連續性,但使用者只看得到狀態 |
| **一到三句** | `<persona-context>` 每輪注入上限;`room post` 對超過上限的發言直接拒收(`--force` 例外)。**上限跟著羞恥度走**:越害羞的人話越少(羞恥度 ≥ 75 → 2 句),`speechBudget()` 同時把心裡話的**下限**拉高——越害羞的人,心裡話比說出口的話重要,而且**說出口的那句常常正好在迴避心裡那句**,落差才是那個角色 |
| **一到三句** | `<persona-context>` 每輪注入上限;`room post` 對超過上限的發言直接拒收(`--force` 例外)。**上限跟著羞恥度走**`speechBudget()`),而且羞恥度高**不等於話一定變少**——有三個出口:**縮**(一句嘴硬,說出口的那句在迴避心裡那句)、**炸**(慌了/惱羞/被逼著澄清 → 4 句但每句只有 22 字,碎、急、重複)、**坦白**(信任高又只有兩個人 → 3 句完整句,憋很久一次講完)。心裡話的**下限**同時拉高:越害羞的人,心裡話比說出口的話重要,落差才是那個角色 |
| **不重複** | 說出口的話由 `Stop` hook 自動記進 `state/said.jsonl``said check` 可事前確認,`room post` 事中攔截。相似度=字元 bigram Jaccard0.4)+字集合 Jaccard0.6),≥ 0.72 視為同一句 |
| **情緒的破口** | 情緒不改變事實,但會改變**句子的形狀**:焦慮 → 句子斷在一半、疊字(「我、我知道」);羞愧 → 鬧彆扭,先否認再小聲承認;憤怒 → 短句、稱呼退回全名;悲傷 → 只回一個詞。十二情緒各自的破口寫在 `EMOTION_TELLS``emotionTells()` 每輪挑主導情緒裡強度 ≥ 40 的前兩種注入。三條界線:**演出來不要講出來**(「我有點緊張」是解釋,斷句才是緊張)、**一輪最多露一個破口**、**強度不到就不演**。混合狀態(緊張=焦慮+期待、害羞=喜悅+羞愧、賭氣=憤怒+悲傷…)的對照表在 `skills/persona-chat/reference/emotions.md` |
| **短句白話** | 四條講話的樣子每輪注入:**短句**(一句 45 字內,`MAX_SENTENCE_CHARS`)、**日常用詞**、**多講看得見的東西**(人、動作、物件、場面)而不是概念、**不要解釋自己的話**。句長與「我的意思是/換句話說/也就是說」這類開頭由 `speechLint()` 機械攔截(`room post` 擋下、`said check` 事前警告,`--force` 例外);用詞與具體度沒辦法用規則抓,靠注入的規則自律 |
+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);
+16 -1
View File
@@ -744,8 +744,14 @@ commands.recall = ({ flags }) => {
lines.push("短期記憶(最近):");
for (const row of recents) lines.push(`- [${row.role || "?"}] ${String(row.text || "").slice(0, 110)}`);
}
// 心裡話也找得到——害羞的人格,重要的東西幾乎都在心裡那句
const inner = pl.recallInner(slug, query, limit);
if (inner.length) {
lines.push("心裡話(只有你自己看得到,不要講給他聽):");
for (const row of inner) lines.push(`- 💭 [${row.kind || "infer"}] ${String(row.text || "").slice(0, 110)}`);
}
pl.touchRecall(slug, hits.map((m) => m._name));
emit({ persona: slug, long_term: hits, short_term: recents }, flags.json, lines);
emit({ persona: slug, long_term: hits, short_term: recents, inner }, flags.json, lines);
};
/**
@@ -836,6 +842,15 @@ commands.candidates = ({ flags }) => {
...pl.PROMOTION_RULES.map((r) => ` ${r.id} ${r.label}`),
];
if (!result.candidates.length) lines.push("目前沒有需要固化的內容(未達任何條件)。");
// 心裡話裡反覆出現的念頭:想過好幾次的事,多半是真的重要
const thoughts = pl.innerCandidates(slug);
if (thoughts.length) {
lines.push(`\n心裡話裡一直在想的事(${thoughts.length} 組,24 小時內)——要不要記住由你決定:`);
for (const g of thoughts) {
lines.push(`${g.token}」想過 ${g.count}`);
for (const e of g.entries.slice(-2)) lines.push(` - 💭 ${String(e.text || "").slice(0, 80)}`);
}
}
for (const cand of result.candidates) {
lines.push(
`\n[${cand.rules.join("+")}] ${cand.kind}${cand.key}」→ 建議 type=${cand.suggested_type}, ` +
+70 -2
View File
@@ -562,13 +562,81 @@ console.log("\n性別與羞恥敏感度(描述優先,性別只是預設)")
down.value < 38 && down.signals.length > 0, JSON.stringify(down));
check("「不害羞」不會被「害羞」重複計成加強",
pl.modestyOf("gdown").adjust < 0, JSON.stringify(down.signals));
check("注入的那一行會講清楚數字從哪來",
pl.modestyDirective("gup").includes("性別預設") && pl.modestyDirective("gup").includes("描述"),
check("注入的那一行會講清楚數字從哪來(平常幾分、情緒推了多少)",
pl.modestyDirective("gup").includes("平常") && /羞恥度 \d/.test(pl.modestyDirective("gup")),
pl.modestyDirective("gup"));
check("低敏感度的人格被明講「不用演害羞」",
pl.modestyDirective("gdown").includes("不用演害羞"));
}
// --------------------------------------------------------------------------- //
// 羞恥度的三種出口:縮、炸、坦白;以及正回饋要收斂
console.log("\n羞恥度的三種出口與回饋");
{
const em = (over) => {
const st = pl.defaultEmotionState();
Object.assign(st.levels, over);
return st;
};
const shy = "shy"; // 前面建過:人類女性 + 怕生 → trait 高
const calm = pl.speechBudget(shy, { state: em({}) });
const panic = pl.speechBudget(shy, { state: em({ shame: 60, anxiety: 70 }) });
const angry = pl.speechBudget(shy, { state: em({ shame: 60, anger: 60 }) });
const safe = pl.speechBudget(shy, { state: em({ shame: 60, trust: 85 }), alone: true });
const pressed = pl.speechBudget(shy, {
state: em({ shame: 55 }),
read: { signals: [{ key: "anger", zh: "憤怒", score: 5 }] },
});
check("慌了會「炸」:句數變多、但每句更短", panic.mode === "spill" && panic.sentences > calm.sentences && panic.chars < calm.chars,
JSON.stringify(panic));
check("對方在生氣、被逼著澄清 → 也是炸", pressed.mode === "spill", JSON.stringify(pressed));
check("惱羞成怒會把羞恥壓下去(anger 權重是負的)",
pl.modestyState(shy, { state: em({ shame: 60, anger: 60 }) }).value
< pl.modestyState(shy, { state: em({ shame: 60 }) }).value,
`angry=${angry.modesty} shy=${calm.modesty}`);
check("信任高又只有兩個人 → 坦白,句子完整、心裡話要先寫",
safe.mode === "confess" && safe.chars === pl.MAX_SENTENCE_CHARS && safe.thinkMin >= 2, JSON.stringify(safe));
// 正回饋要收斂:連續同一個推力,跑很多輪也不會貼到 100
let v = null;
let last = null;
for (let i = 0; i < 60; i += 1) {
last = v;
v = pl.modestyState(shy, { state: em({ shame: 55 }), prev: v }).value;
}
// 收斂=跑到後面不再變動(爆走的模型每一輪都還在往上頂)
check("正回饋會收斂(gain < 1,級數有極限)", Math.abs(v - last) < 0.5, `最後兩輪 ${last}${v}`);
let low = null;
for (let i = 0; i < 60; i += 1) low = pl.modestyState("gm", { state: em({ shame: 30 }), prev: low }).value;
check("推力不大時停在上限以下(不是每次都貼 100)", low < 100, `停在 ${low}`);
check("增益設定在收斂範圍內", pl.MODESTY_GAIN < 1 && pl.MODESTY_GAIN > 0, `gain=${pl.MODESTY_GAIN}`);
check("沒有上一輪時退回 trait,不是退回 0Number(null) 的坑)",
Math.abs(pl.modestyState(shy, { state: em({}), prev: null }).value - pl.modestyOf(shy).value) <= pl.MODESTY_MAX_STEP,
JSON.stringify(pl.modestyState(shy, { state: em({}), prev: null })));
check("慢的情緒(信任)不會把人永遠推離自己的 trait",
Math.abs(pl.modestyState(shy, { state: em({ trust: 90 }) }).value - pl.modestyOf(shy).value) < 20,
JSON.stringify(pl.modestyState(shy, { state: em({ trust: 90 }) })));
}
// --------------------------------------------------------------------------- //
// 心裡話也進得了記憶(但永遠不回顯給使用者)
console.log("\n心裡話 → 記憶");
{
cli(["think", "--persona", "alpha", "--session", S_SPEAK, "--text", "那台舊鐘的齒輪還是卡著,我一直在想這件事"]);
cli(["think", "--persona", "alpha", "--session", S_SPEAK, "--text", "齒輪的問題我想到第三次了,可能要換掉整組"]);
const found = pl.recallInner("alpha", "齒輪");
check("recall 找得到心裡話", found.length >= 2, JSON.stringify(found.map((r) => r.text)));
const thoughts = pl.innerCandidates("alpha");
check("反覆想過的事會變成固化候選",
thoughts.some((g) => g.token.includes("齒輪") && g.count >= 2), JSON.stringify(thoughts.slice(0, 3)));
const out = cli(["recall", "--persona", "alpha", "--session", S_SPEAK, "--query", "齒輪"]).stdout;
check("recall 的輸出把心裡話標成「不要講給他聽」", out.includes("心裡話") && out.includes("💭"), out.slice(0, 200));
const cand = cli(["candidates", "--persona", "alpha", "--session", S_SPEAK]).stdout;
check("candidates 會列出「一直在想的事」", cand.includes("一直在想的事"), cand.slice(-300));
const thinkOut = cli(["think", "--persona", "alpha", "--session", S_SPEAK, "--text", "這句不該被看到"]).stdout;
check("think 仍然只回報數量,不回顯內容",
thinkOut.includes("心想") && !thinkOut.includes("這句不該被看到"), thinkOut.slice(0, 80));
}
// --------------------------------------------------------------------------- //
// 短期記憶的容量壓力:R6 要真的會動,但不能把承諾與今天的紀錄清掉
console.log("\n短期記憶:容量壓力(R6)真的會清,而且有保護");
+16 -6
View File
@@ -101,18 +101,26 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" show --persona <slug> --session
前三條與 ④ 的最後一項是自律;句長、「解釋自己的話」與 ⑤ 的黑名單,`room post``said check`
會直接擋下(`--force` 例外)。講到自己過去時 `said check` 只提醒、不擋——那要你自己去 `recall` 驗。
## 2b. 羞恥敏感度(每輪注入,不用自己記)
## 2b. 羞恥度(每輪注入,不用自己記)
`<persona-context>` 會給你一行「羞恥敏感度 N」與它是怎麼算出來的。數字高就是**被稱讚外表、
被戳穿心事、距離突然變近的時候先鬧彆扭再承認**——嘴硬一句、把話講一半、轉移到別的東西上;
數字低就是不用演害羞,該承認就承認、該回嘴就回嘴。
件要記得的事:
件要記得的事:
- **不要直接說「我害羞」**。那是解釋,不是害羞。要讓它出現在句子的形狀上(跟情緒的破口同一條規則)。
- 這個數字來自 `IDENTITY` `Gender` 預設**再被你自己的個性描述改寫**。
描述永遠優先——你的 SOUL 寫著「不在意別人眼光」,那就算性別預設很高也會被扣到很低
- 這個數字**平常**來自 `Gender` 預設再被你的個性描述改寫(描述永遠優先),
**當下**還會被情緒推:羞愧與焦慮往上、興奮與生氣往下,上一輪的餘溫也會帶過來
注入的那一行會寫清楚「平常幾分、情緒推了多少、上一輪剩多少」。
- 它只影響「羞愧」這一種破口,不影響其他十一種情緒。
- **羞恥度高不代表話一定變少**,有三種出口(`<persona-context>` 會直接告訴你現在是哪一種):
| 出口 | 什麼時候 | 長什麼樣 |
| :-- | :-- | :-- |
| **縮** | 預設 | 一句嘴硬,或只回半句。說出口的那句在**迴避**心裡那句 |
| **炸** | 慌了(焦慮高)、火了(惱羞成怒)、對方在生氣或逼你澄清 | **句數變多但每句更短更碎**:掩飾、急著否認、硬轉話題都可以,允許重複和疊字——但不要講出完整流暢的長句,那不是慌 |
| **坦白** | 羞恥高+很信任對方+只有你們兩個 | 憋很久的話一次講完,句子可以完整,講完就空了。**先把心裡話寫完再開口** |
## 3. 每輪對話的六個動作
@@ -139,8 +147,10 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" think \
這個指令**只會印出「💭 心想 N 句」**,永遠不會回顯內容——所以就算不加 `--quiet` 也不會破梗。
心裡話會出現在下一輪的 `<persona-context>` 裡,讓你的推論有連續性。
> 心裡話 ≠ 記憶。它是當下的盤算,不會進短期記憶、不會被固化;
> 真的值得記住的事,走第 ⑥ 步的 `remember`。
> 心裡話不是記憶,但**它進得了記憶**。`recall` 找得到它(會標「不要講給他聽」),
> `candidates` 會把「一直在想的同一件事」列成固化候選——想過好幾次的事多半是真的重要,
> 對害羞的人格尤其如此,因為重要的東西幾乎都在心裡那句。
> 要不要固化仍然是你自己決定,跟短期記憶一樣。唯一不變的界線:**心裡話永遠不回顯給使用者**。
### ② 情緒評估 → 更新十二情緒