feat(情緒表達): emoji 表程度、名字後括號的動作格、鬧彆扭的階梯

只有文字這一個通道,所以情緒表達是排版問題。這批補的是三個沒被用到的位置。

emoji = 強度計(Q9)
- EMOTION_EMOJI 十二情緒各一個,EMOJI_INTENSITY 兩階(💦 / 💦)。
  40-59 本體、60-79 加 💦、80+ 加 💦,低於 40 不顯示(跟破口同一條線)。
- emotionEmojiNow() 讀當下主導情緒算出來,不是自己挑;IDENTITY 的 ## Emoji
  區塊可逐人格覆寫(跟 ## Tells 同一層)。
- 刻意不用重複本體表示程度(不是 😳😳😳)——疊字看起來像洗頁。
- room post 沒帶 --emotion 時自動帶入 emoji,強度不到才退回文字標註。

emoji 開放(Q5/Q10)
- speechLint 不再擋 emoji,超過六個才給 hint。用 linter 偷偷關回去等於撤銷
  「開放」這個決定。
- 唯一的煞車是數字:emojiAudit() 掛在 emotion --audit,算幾則帶符號、平均幾個、
  以及**只有符號沒有句子**那幾則(那才是真的退化)。

名字後面的括號有兩格(Q6)
- 括號原本就有(放情緒標註),動作是加進去的第二格:名字(情緒・動作):內容。
  roomTag() 負責組,兩格都可省略。
- actionLint() 擋三種:超過 12 字、一格塞兩個動作、寫情緒名稱
  ((害羞)是旁白換了個位置)。生理反應算動作:臉紅、手在抖、聲音變小。
- room post --action、remember --behavior(記做了什麼,不是感覺到什麼)。

一輪兩個動作(Q7)
- TELLS_PER_TURN = 2,而且兩個必須來自同一種情緒且有遞進關係。
  鬧彆扭天生是否認+轉移兩個動作,只准一個等於永遠只做得到一半。
  被擋下的形狀是「兩種情緒各演一個」。

鬧彆扭的階梯與女性預設(G3)
- modestyDirective 分三級:41-59 一句帶過、60-74 嘴硬(否認 → 反駁 → 音量掉下來)、
  75+ 惱羞(否認 → 反駁 → 翻臉)。惱羞是換情緒,所以仍然是兩個動作。
- GENDER_MODESTY_DEFAULT.female 62 → 70;MODESTY_SIGNALS 補傲嬌那一路
  (傲嬌、嘴硬、不坦率、口是心非、死不承認、愛面子、鬧彆扭、逞強)。
  界線不變:描述永遠蓋過預設,可以推到 0。

文件:README 加一節「文字通道的另外兩格」與鬧彆扭階梯表、emotions.md 加 emoji 章節、
persona-chat SKILL 與 anti-ai-voice 跟上。selftest 616 → 641 全綠。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-03 07:46:34 +00:00
co-authored by Claude Opus 5
parent 9d76984c27
commit 35c8665db0
7 changed files with 432 additions and 30 deletions
+44 -8
View File
@@ -759,9 +759,18 @@ commands.remember = ({ flags }) => {
intent: str(flags.intent),
salience: num(flags.salience, 40),
emotion_deltas: parseDeltas(flags.emotion),
// 行為備註:那一刻**做了什麼**(別過頭、把杯子推過來),不是感覺到什麼。
// 跟聊天室括號裡的動作格同一種東西,所以套同一條檢查。
...(str(flags.behavior) ? { behavior: str(flags.behavior) } : {}),
room: str(flags.room) || null,
session: session.slice(0, 8),
};
if (entry.behavior) {
const issues = pl.actionLint(entry.behavior);
if (issues.length && !flags.force) {
die(`${issues.map(pl.actionLintMessage).join("")}。(真的需要才加 \`--force\`。)`);
}
}
if (scope === "inbox") {
if (!entry.room) die("`--scope inbox` 必須指定 `--room`。");
pl.appendJsonl(pl.inboxPath(slug, entry.room), entry);
@@ -1247,7 +1256,10 @@ commands.emotion = ({ flags }) => {
if (flags.audit) {
const audit = pl.feltAudit(slug, num(flags.limit, 20));
const trend = pl.feltTrend(slug);
emit({ persona: slug, audit, trend }, flags.json, [
// emoji 沒有上限(使用者拍板),所以唯一的煞車是把數字擺出來
const emoji = pl.emojiAudit(slug);
const now = pl.emotionEmojiNow(slug);
emit({ persona: slug, audit, trend, emoji, emoji_now: now }, flags.json, [
`\`${slug}\` 最近 ${audit.rounds} 輪的情緒套用:`,
` 往舒服的方向 ${audit.positive}|往難受的方向 ${audit.negative}` +
(audit.positive_ratio === null ? "" : ` → 正向佔 ${audit.positive_ratio}%`),
@@ -1255,6 +1267,11 @@ commands.emotion = ({ flags }) => {
? " ⚠ 幾乎都在給自己加分。delta 是你自己挑的,這種偏差沒人會替你抓。"
: " 兩邊都有,還算誠實。",
trend ? ` 對方的走向:${trend.zh} ${trend.direction}(近 ${trend.rounds} 輪)` : " 還沒有足夠的輪次算走向。",
` emoji:最近 ${emoji.rows} 則裡 ${emoji.with_emoji} 則帶符號,平均 ${emoji.average}` +
(emoji.worst ? `,最多的那則 ${emoji.worst.count}` : ""),
emoji.symbol_only
? ` ⚠ 有 ${emoji.symbol_only} 則幾乎只有符號沒有句子——符號是加上去的,不是用來代替講話`
: ` 現在的情緒符號:${now ? `${now.emoji}${now.zh} ${now.level}` : "強度不到 40,不顯示"}`,
]);
return;
}
@@ -1809,16 +1826,28 @@ commands.room = ({ flags, positional }) => {
}
let emotion = str(flags.emotion);
if (!emotion && pl.personaExists(speaker)) {
emotion = pl.dominant(pl.decayEmotion(pl.loadEmotion(speaker)), 2)
.map(({ key, level }) => `${pl.EMOTIONS[key].zh}${Math.round(level)}`)
.join("/");
// 情緒格自動帶入:強度夠就用 emoji(種類表情緒、數量表程度),
// 不到門檻才退回文字標註——低強度的情緒本來就不該在括號裡宣告。
const now = pl.emotionEmojiNow(speaker);
emotion = now
? now.emoji
: pl.dominant(pl.decayEmotion(pl.loadEmotion(speaker)), 2)
.map(({ key, level }) => `${pl.EMOTIONS[key].zh}${Math.round(level)}`)
.join("/");
}
const entry = pl.roomPost(room, speaker, text, { emotion, to, bargeIn });
// 動作格:括號的第二格,只放看得見或聽得見的動作(臉紅、別過頭、手在抖)
const actionText = str(flags.action);
const actionIssues = pl.actionLint(actionText);
if (actionIssues.length && !flags.force) {
die(`${actionIssues.map(pl.actionLintMessage).join("")}。(真的需要才加 \`--force\`。)`);
}
const entry = pl.roomPost(room, speaker, text, { emotion, action: actionText, to, bargeIn });
// guest 對自己的狀態唯讀,它說過的話留在聊天室逐字稿裡就夠了(roomRepeat 讀得到)
if (speakerRole !== "guest") pl.recordSaid(speaker, text, { room, kind: "room" });
const dyad = to && to !== pl.ROOM_ALL;
ok(
`\`${speaker}\` 已發言於 \`${room}\`${dyad ? `${nameOf(to)}` : "對全場"}/情緒 ${emotion})。` +
`\`${speaker}\` 已發言於 \`${room}\`${dyad ? `${nameOf(to)}` : "對全場"}/情緒 ${emotion}` +
`${actionText ? `/動作 ${actionText}` : ""})。` +
(dyad ? `接下來只有 ${nameOf(to)} 該回話,其他人先安靜。` : ""),
);
if (flags.json) process.stdout.write(`${JSON.stringify(entry)}\n`);
@@ -2563,7 +2592,10 @@ const HELP = `persona.mjs — jsc-persona 人格 / 記憶 / 情緒 / 關係圖 C
brief --session <id> [--query <text>] 輸出人格上下文
記憶:
remember --session <id> --text <t> [--role --topics --entities --intent --salience --emotion --scope short|inbox --room]
remember --session <id> --text <t> [--role --topics --entities --intent --salience --emotion --behavior
--scope short|inbox --room]
--behavior 那一刻**做了什麼**(別過頭、把手縮回來),不是感覺到什麼。
寫情緒名稱會被擋——那一格是動作。
recall --session <id> --query <q> [--limit]
think --session <id> --text <心裡話> [--kind infer|plan|feel|doubt --room] 只回報「心想 N 句」,不回顯內容
said check|list --session <id> [--text <要說的話> --minutes --threshold --record --limit]
@@ -2609,8 +2641,12 @@ const HELP = `persona.mjs — jsc-persona 人格 / 記憶 / 情緒 / 關係圖 C
invite --session <id> --guest <slug> [--guests <slug,slug>] [--host --room --topic] (自動開啟劇場模式)
leave --session <id> --guest <slug> [--room]
room post|read|script|floor|list|theater --session <id> [--room --as --to --barge-in --text --text-file
--emotion --limit --on --off --with-meta]
--emotion --action --limit --on --off --with-meta]
(post 會擋下「短時間內近似重複」與超過三句的發言;例外用 --allow-repeat / --force
名字後面的括號有兩格:名字(情緒・動作):內容
--emotion 情緒標註(沒帶就用當下情緒算出來的 emoji,強度不到 40 就退回文字)
--action 看得見或聽得見的動作(臉紅、別過頭、手在抖);一個動作、12 字內,
寫情緒名稱會被擋(那是前面那一格)。括號裡的動作**算進一輪兩個動作的額度**。
--to <slug|名字|all> 這句話對誰說。指名 → 進入一對一,旁人不該接話(要插話得帶
--barge-in "<理由>");--to all → 把話題開回全場。
floor 現在誰對誰在講、該誰接話、誰先安靜、誰很久沒開口