From 35c8665db0e33af9becfd6e8cc2f4fb47f8322f6 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Mon, 3 Aug 2026 07:46:34 +0000 Subject: [PATCH] =?UTF-8?q?feat(=E6=83=85=E7=B7=92=E8=A1=A8=E9=81=94):=20e?= =?UTF-8?q?moji=20=E8=A1=A8=E7=A8=8B=E5=BA=A6=E3=80=81=E5=90=8D=E5=AD=97?= =?UTF-8?q?=E5=BE=8C=E6=8B=AC=E8=99=9F=E7=9A=84=E5=8B=95=E4=BD=9C=E6=A0=BC?= =?UTF-8?q?=E3=80=81=E9=AC=A7=E5=BD=86=E6=89=AD=E7=9A=84=E9=9A=8E=E6=A2=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 只有文字這一個通道,所以情緒表達是排版問題。這批補的是三個沒被用到的位置。 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) --- README.md | 50 ++++- scripts/persona-lib.mjs | 208 +++++++++++++++++- scripts/persona.mjs | 52 ++++- scripts/selftest.mjs | 119 +++++++++- skills/persona-chat/SKILL.md | 2 +- .../persona-chat/reference/anti-ai-voice.md | 2 +- skills/persona-chat/reference/emotions.md | 29 ++- 7 files changed, 432 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index baa097d..934931f 100644 --- a/README.md +++ b/README.md @@ -254,7 +254,7 @@ X 超出基線 18 以上才開始抑制,滿檔時往 Y 的同向推力只剩 5 | 來源 | 效果 | | --- | --- | -| 性別預設 | 女性 62/男性 38/非二元・未指定 50 | +| 性別預設 | 女性 **70**/男性 38/非二元・未指定 50(女性偏高是刻意的——文字通道裡害羞與鬧彆扭是最有效的擬真訊號之一) | | IDENTITY/SOUL 的描述 | 「害羞」「容易臉紅」「怕生」「矜持」往上加;「不在意別人眼光」「我行我素」「臉皮厚」「不怕丟臉」往下扣(`MODESTY_SIGNALS`),**可以扣到 0** | 排序固定:**① 個性描述 ② 角色原作既有的性別化語言特徵(自稱、稱謂、語尾) ③ 性別預設**。 @@ -263,8 +263,20 @@ X 超出基線 18 以上才開始抑制,滿檔時往 Y 的同向推力只剩 5 效果走既有機制、不另開一套:它調的是**「羞愧」這一個破口的顯示門檻**(`EMOTION_TELLS.shame` 本來就是「鬧彆扭,先否認再小聲承認」),其餘十一種情緒不受影響。 -高敏感度注入「被稱讚外表、被戳穿心事、距離變近時先鬧彆扭再承認,不要直接說『我害羞』」; -低敏感度注入「不用演害羞,該承認就承認」。計算在 `modestyOf()`,注入在 `modestyDirective()`。 +計算在 `modestyOf()`,注入在 `modestyDirective()`。 + +**鬧彆扭有階梯**(同一個「不好意思」,強度不同是不同的行為): + +| 羞恥度 | 那一級的樣子 | 三段的形狀 | +| --- | --- | --- | +| ≤ 40 | 不用演害羞,該承認就承認、該回嘴就回嘴 | — | +| 41–59 | 會不好意思,但不會卡在那裡 | 一句帶過就往下走 | +| 60–74 | 嘴硬 | **先否認 → 反駁他的說法 → 最後一句音量掉下來**(「⋯才不是那樣。」「你不要一直看這邊。」「⋯嗯。」) | +| ≥ 75 | 惱羞 | **先否認 → 反駁 → 翻臉或走開**(「不要再講了」)。惱羞是**換情緒**(羞愧 → 憤怒),所以仍然是兩個動作,不是三個 | + +三段是**句法模板**,不是自由發揮——鬧彆扭天生就是兩個動作一起來, +只准一個等於永遠只做得到一半:單獨否認像在爭辯,單獨轉移像沒聽到。 +害羞的預設動作是**臉紅**(生理反應算動作,`(害羞)` 不算——那是情緒名稱)。 **不做的事**:沒有「女性→情緒更外顯」這種全域放大。那會把角色壓成模板, 跟講話規則在做的去 AI 味(刪掉公式化)正好相反。 @@ -353,11 +365,39 @@ AI 最容易露餡的六件事:把推理過程講出來、一次講一大段 | **心裡話** `think` | 語意分析、推論、盤算全寫進 `state/inner.jsonl`;這個指令**只印「💭 心想 N 句」**,內容永不回顯。下一輪 `` 會帶回最近三句,推論因此有連續性,但使用者只看得到狀態 | | **一到三句** | `` 每輪注入上限;`room post` 對超過上限的發言直接拒收(`--force` 例外)。**上限跟著羞恥度走**(`speechBudget()`),而且羞恥度高**不等於話一定變少**——有三個出口:**縮**(一句嘴硬,說出口的那句在迴避心裡那句)、**炸**(慌了/惱羞/被逼著澄清 → 4 句但每句只有 22 字,碎、急、重複)、**坦白**(信任高又只有兩個人 → 3 句完整句,憋很久一次講完)。心裡話的**下限**同時拉高:越害羞的人,心裡話比說出口的話重要,落差才是那個角色 | | **不重複** | 說出口的話由 `Stop` hook 自動記進 `state/said.jsonl`;`said check` 可事前確認,`room post` 事中攔截。相似度=字元 bigram Jaccard(0.4)+字集合 Jaccard(0.6),≥ 0.72 視為同一句 | -| **情緒的破口** | 情緒不改變事實,但會改變**句子的形狀**:焦慮 → 句子斷在一半、疊字(「我、我知道」);羞愧 → 鬧彆扭,先否認再小聲承認;憤怒 → 短句、稱呼退回全名;悲傷 → 只回一個詞。十二情緒各自的破口寫在 `EMOTION_TELLS`,`emotionTells()` 每輪挑主導情緒裡強度 ≥ 40 的前兩種注入。**破口可以逐人格覆寫**(`IDENTITY.md` 的 `## Tells` 區塊)——桐人生氣是沉默,亞絲娜生氣是變得更禮貌;同一格情緒,破口完全不同,共用一張表等於所有人格在高情緒下講起話來都一個樣。沒寫的情緒退回全域預設。三條界線:**演出來不要講出來**(「我有點緊張」是解釋,斷句才是緊張)、**一輪最多露一個破口**、**強度不到就不演**。混合狀態(緊張=焦慮+期待、害羞=喜悅+羞愧、賭氣=憤怒+悲傷…)的對照表在 `skills/persona-chat/reference/emotions.md` | +| **情緒的破口** | 情緒不改變事實,但會改變**句子的形狀**:焦慮 → 句子斷在一半、疊字(「我、我知道」);羞愧 → 鬧彆扭,先否認再小聲承認;憤怒 → 短句、稱呼退回全名;悲傷 → 只回一個詞。十二情緒各自的破口寫在 `EMOTION_TELLS`,`emotionTells()` 每輪挑主導情緒裡強度 ≥ 40 的前兩種注入。**破口可以逐人格覆寫**(`IDENTITY.md` 的 `## Tells` 區塊)——桐人生氣是沉默,亞絲娜生氣是變得更禮貌;同一格情緒,破口完全不同,共用一張表等於所有人格在高情緒下講起話來都一個樣。沒寫的情緒退回全域預設。三條界線:**演出來不要講出來**(「我有點緊張」是解釋,斷句才是緊張)、**一輪最多兩個動作、而且要來自同一種情緒並有遞進關係**(否認 → 轉移、笑 → 補一句、留白 → 不追問;兩種情緒各演一個才是演戲)、**強度不到就不演**。混合狀態(緊張=焦慮+期待、害羞=喜悅+羞愧、賭氣=憤怒+悲傷…)的對照表在 `skills/persona-chat/reference/emotions.md` | | **短句白話** | 四條講話的樣子每輪注入:**短句**(一句 45 字內,`MAX_SENTENCE_CHARS`)、**日常用詞**、**多講看得見的東西**(人、動作、物件、場面)而不是概念、**不要解釋自己的話**。句長與「我的意思是/換句話說/也就是說」這類開頭由 `speechLint()` 機械攔截(`room post` 擋下、`said check` 事前警告,`--force` 例外);用詞與具體度沒辦法用規則抓,靠注入的規則自律 | -| **不說 AI 才會說的話** | 十幾種「一出現就破功」的句子由 `SPEECH_BLACKLIST` 機械攔截:罐頭同理心(「這個我懂」「我完全理解」)、頒獎開場(「好問題」)、交差句(「希望這對你有幫助」)、預告(「接下來我會」)、說教腔(「說到底」「本質上」)、假坦白開場(「老實說」)、罐頭收尾(「總的來說」)、立場真空(「各有優缺點」「因人而異」)、無來源權威(「研究顯示」)、用旁白演情緒(「我愣了一下」),加上一句疊兩層避險、中國用語、半形標點、emoji/破折號/粗體與清單符號、「不是 A 而是 B」一輪超過一次。**誤殺防護**:引號與 `` `code` `` 裡的內容一律不比對(「我最近戒掉『賦能』這個詞」是提及不是使用),「老實說」只擋開場。模式借自 [speak-human-tw](https://github.com/Raymondhou0917/speak-human-tw)(MIT)的 38 種 AI 寫作痕跡,只搬對話也適用的刪除層 | +| **不說 AI 才會說的話** | 十幾種「一出現就破功」的句子由 `SPEECH_BLACKLIST` 機械攔截:罐頭同理心(「這個我懂」「我完全理解」)、頒獎開場(「好問題」)、交差句(「希望這對你有幫助」)、預告(「接下來我會」)、說教腔(「說到底」「本質上」)、假坦白開場(「老實說」)、罐頭收尾(「總的來說」)、立場真空(「各有優缺點」「因人而異」)、無來源權威(「研究顯示」)、用旁白演情緒(「我愣了一下」),加上一句疊兩層避險、中國用語、半形標點、破折號/粗體與清單符號、「不是 A 而是 B」一輪超過一次(**emoji 不在這裡**——它是開放的,只在明顯過量時給 hint,見上一節)。**誤殺防護**:引號與 `` `code` `` 裡的內容一律不比對(「我最近戒掉『賦能』這個詞」是提及不是使用),「老實說」只擋開場。模式借自 [speak-human-tw](https://github.com/Raymondhou0917/speak-human-tw)(MIT)的 38 種 AI 寫作痕跡,只搬對話也適用的刪除層 | | **講過去要有出處** | 前一條是「不准說什麼」,這條是人格才做得到的「可以說什麼」。文章改寫工具的界線是「人味是作者的,不是你的」——AI 沒有過去,所以不准寫「我以前錯了」。人格有過去:長期記憶、日記、關係圖、十二情緒。所以「我以前⋯」「我原本以為⋯」是**有出處的引用**,條件是先 `recall` 查得到;`said check` 遇到這類句子會印一行提醒(`level: "hint"`,不擋你,要你自己去驗)。「查得到」有三態:清晰的照講;**模糊的可以說不確定、可以用帶問號的試探句求證,不可以斷言**(每次試探要 `probe add` 記帳,被否認當場寫更正記憶);完全查不到的一個字都不准講 | +### 文字通道的另外兩格:emoji 的程度與名字後面的動作 + +只有文字,所以情緒表達是**排版問題**。除了句子的形狀,還有兩個位置可以用: + +**emoji = 強度計**(種類表情緒、數量表程度,由 `emotion.json` 的主導情緒自動帶入,不是自己挑): + +| 情緒值 | 樣子 | 例 | +| --- | --- | --- | +| < 40 | 不顯示(跟「強度不到就不演」同一條線) | `桐人:你不要看這邊。` | +| 40–59 | 情緒 emoji | `桐人(😳・臉紅):你不要看這邊。` | +| 60–79 | +`💦` | `桐人(😳💦・臉紅):⋯才不是那樣。` | +| 80+ | +`💦❗` | `桐人(😳💦❗・把臉轉開):不要再講了。` | + +十二情緒各一個 emoji 寫在 `EMOTION_EMOJI`,強度符號兩階(`EMOJI_INTENSITY`), +**逐人格可覆寫**(`IDENTITY.md` 的 `## Emoji` 區塊,跟 `## Tells` 同一層)。 +刻意不用「同一個 emoji 重複三次」——疊字看起來像洗頁。 +句子裡也可以用、**沒有數量上限**;唯一的煞車是 `emotion --audit` 裡的 +`emojiAudit()`(幾則帶符號、平均幾個、有幾則**只有符號沒有句子**)。 +用 linter 偷偷把它關回去等於撤銷「開放」這個決定,所以 `speechLint()` 對 emoji 只給 hint、不擋。 + +**名字後面的括號有兩格**:`名字(情緒・動作):內容`。括號**原本就有**(放情緒標註), +動作是加進去的第二格,不是換掉它。動作只寫**看得見或聽得見的**(臉紅、別過頭、手在抖、聲音變小), +由 `actionLint()` 擋三種寫法:超過 12 字、一格塞兩個動作、以及寫情緒名稱 +(`(害羞)` 是旁白換了個位置,情緒屬於前面那一格)。括號裡的動作**算進一輪兩個動作的額度**。 +記憶也記得那個行為:`remember --behavior "把手縮回來"`(記做了什麼,不是感覺到什麼)。 + +星號內嵌(`*別過頭*`)與第三人稱旁白(「她臉紅了」)仍然禁止——那是硬規則 8 沒有鬆的部分。 + 字集合權重較高,是為了分開「重排語序」與「換掉關鍵詞」這兩種很像但意義完全不同的情況: ``` diff --git a/scripts/persona-lib.mjs b/scripts/persona-lib.mjs index 7e7bd93..ca9f1c5 100644 --- a/scripts/persona-lib.mjs +++ b/scripts/persona-lib.mjs @@ -775,9 +775,15 @@ export function dominant(state, top = 3) { // // 情緒不改變事實,但會改變**句子的形狀**:緊張的人話說不完、會疊字;彆扭的人 // 先否認再小聲承認。演得像不像就差在這裡——所以這張表跟情緒一起注入,不靠記性。 -// 節制很重要:一輪最多露一個破口,不然就從「像人」變成「演戲」。 +// 節制的方式是**限量+要求遞進**:一輪最多兩個動作,而且兩個必須來自同一種情緒 +// (否認 → 轉移、笑 → 補一句、留白 → 不追問)。鬧彆扭天生就是兩個動作一起來, +// 只准一個等於永遠只做得到一半——單獨否認像在爭辯,單獨轉移像沒聽到。 +// 兩種情緒各演一個才是演戲,所以那個才是被擋下的形狀。 // --------------------------------------------------------------------------- // +/** 一輪最多露幾個動作(兩個,且必須是同一種情緒的遞進)。 */ +export const TELLS_PER_TURN = 2; + export const EMOTION_TELLS = { joy: ["話變多、句子輕", "會笑出來(「哈」)", "忍不住多補一句"], trust: ["話變直、不鋪陳", "省略主語,講一半對方也懂", "敢說「你這樣不對」"], @@ -866,6 +872,76 @@ export function emotionTells(slug, state = null, { min = 40, top = 2 } = {}) { })); } +// --------------------------------------------------------------------------- // +// 情緒 → emoji(種類表情緒、數量表程度) +// +// 只有文字這一個通道,所以 emoji 不是裝飾,是**強度計**:一種情緒固定一個 emoji, +// 強度高就再加一個強度符號。刻意不用「同一個 emoji 重複三次」——疊字看起來像洗頁。 +// +// 40–59 😳 60–79 😳💦 80+ 😳💦❗ +// +// 門檻跟破口共用同一條線(`min = 40`):強度不到就不顯示,跟「強度不到就不演」一致。 +// --------------------------------------------------------------------------- // + +export const EMOTION_EMOJI = { + joy: "😄", trust: "🙂", anticipation: "👀", gratitude: "🙏", serenity: "😌", delight: "✨", + anger: "😡", sadness: "😔", fear: "😨", disgust: "😖", shame: "😳", anxiety: "😰", +}; + +/** 強度符號:兩階,不要長成第三階(`💦❗` 已經是天花板)。 */ +export const EMOJI_INTENSITY = ["", "💦", "💦❗"]; + +/** 讀 IDENTITY.md 的 `## Emoji` 區塊(`羞愧: 😳` 或 `shame: 😳`);沒有就回空物件。 */ +export function personaEmoji(slug) { + let text; + try { + text = fs.readFileSync(path.join(personaDir(slug), "IDENTITY.md"), "utf8"); + } catch { + return {}; + } + const m = text.match(/^##+\s*(?:Emoji|情緒 ?emoji|表情符號)\s*$/im); + if (!m) return {}; + const section = text.slice(m.index + m[0].length).split(/^##\s/m)[0] || ""; + const out = {}; + for (const line of section.split("\n")) { + const row = line.match(/^\s*[-*]\s*([A-Za-z_]+|[一-鿿]{2})\s*[::]\s*(.+)$/); + if (!row) continue; + const key = EMOTION_KEYS.includes(row[1].toLowerCase()) + ? row[1].toLowerCase() + : EMOTION_ZH_TO_KEY[row[1]]; + if (!key) continue; + // 只取符號本體,長度夾住:這一格是**一個** emoji,不是一串貼圖 + const sym = [...stripInjectionMarkers(row[2]).trim()].slice(0, 3).join(""); + if (sym) out[key] = sym; + } + return out; +} + +/** + * 此刻的情緒 emoji:主導情緒 + 依強度加的符號。強度不到 `min` 就回 null。 + * + * 這是**算出來的**,不是人格自己挑的——`room post --emotion` 想手寫還是可以, + * 但沒手寫時就用這個,免得同一個人在同樣的情緒下每次挑不同的符號。 + */ +export function emotionEmojiNow(slug, state = null, { min = 40 } = {}) { + const st = decayEmotion(structuredClone(state ?? loadEmotion(slug))); + const own = slug ? personaEmoji(slug) : {}; + const [top] = dominant(st, 1); + if (!top || top.level < min) return null; + const level = Math.round(top.level); + const tier = level >= 80 ? 2 : level >= 60 ? 1 : 0; + const base = own[top.key] || EMOTION_EMOJI[top.key] || ""; + if (!base) return null; + return { + key: top.key, + zh: EMOTIONS[top.key].zh, + level, + tier, + emoji: `${base}${EMOJI_INTENSITY[tier]}`, + own: Boolean(own[top.key]), + }; +} + // --------------------------------------------------------------------------- // // 性別與羞恥敏感度 // @@ -881,12 +957,18 @@ export function emotionTells(slug, state = null, { min = 40, top = 2 } = {}) { export const GENDERS = { female: "女性", male: "男性", nonbinary: "非二元", unknown: "未指定" }; /** 性別給的**預設**羞恥敏感度(0–100)。描述有講就以描述為準。 */ -export const GENDER_MODESTY_DEFAULT = { female: 62, male: 38, nonbinary: 50, unknown: 50 }; +// 女性預設 70(原本 62):文字通道裡害羞與鬧彆扭是最有效的擬真訊號之一, +// 而預設值只是起點——描述永遠蓋得過它,也可以推到 0(見下面的 `MODESTY_SIGNALS`)。 +export const GENDER_MODESTY_DEFAULT = { female: 70, male: 38, nonbinary: 50, unknown: 50 }; /** 從描述裡讀「這個角色在不在意別人眼光」。正數=更容易害羞,負數=更不在意。 */ export const MODESTY_SIGNALS = [ ["害羞", +14], ["容易臉紅", +14], ["會臉紅", +12], ["怕生", +12], ["靦腆", +14], ["矜持", +12], ["拘謹", +10], ["內向", +8], ["放不開", +10], ["彆扭", +10], + // 鬧彆扭那一路:原本的表只認「害羞/臉紅」,可是傲嬌型的角色描述裡幾乎不寫害羞, + // 寫的是「嘴硬」「不坦率」——漏掉這一組等於把最需要它的角色判成不在意別人眼光。 + ["傲嬌", +16], ["嘴硬", +12], ["不坦率", +12], ["不老實", +10], ["口是心非", +12], + ["死不承認", +10], ["不肯承認", +10], ["愛面子", +10], ["鬧彆扭", +12], ["逞強", +8], ["保守", +8], ["會不好意思", +12], ["在意別人的眼光", +12], ["容易慌", +8], ["不在意別人眼光", -20], ["不在乎別人眼光", -20], ["不在意別人的眼光", -20], ["我行我素", -16], ["大方", -12], ["豪爽", -14], ["臉皮厚", -18], ["不怕丟臉", -18], @@ -1095,9 +1177,18 @@ export function modestyDirective(slug, opts = {}) { if (cur.tone_shift) parts.push(`對象 ${cur.tone_shift >= 0 ? "+" : "-"}${Math.abs(cur.tone_shift)}`); if (Math.abs(cur.carry) >= 1) parts.push(`上一輪的餘溫 ${cur.carry >= 0 ? "+" : "-"}${Math.abs(cur.carry)}`); const from = parts.join("、"); + // 鬧彆扭有階梯:同一個「不好意思」,40 分是一句帶過、65 分是嘴硬、80 分以上會翻臉。 + // 三段式是**句法模板**,不是自由發揮——否認 → 反駁對方的說法 → 最後一句音量掉下來。 + if (m.value >= 75) { + return `羞恥度 ${m.value}(${from}):已經到惱羞的那一級——` + + "三段照這個形狀走:**先否認 → 反駁他的說法 → 翻臉或走開**(「不要再講了」)。" + + "惱羞是換情緒(羞愧 → 憤怒),所以還是兩個動作,不是三個。" + + "不要直接說「我害羞」(那是解釋),要讓它出現在句子的形狀上。"; + } if (m.value >= 60) { return `羞恥度 ${m.value}(${from}):被稱讚外表、被戳穿心事、距離突然變近的時候,` + - "**先鬧彆扭再承認**——嘴硬一句、把話講一半、轉移到別的東西上。" + + "**先鬧彆扭再承認**——三段照這個形狀走:**先否認 → 反駁他的說法 → 最後一句音量掉下來**" + + "(「⋯才不是那樣。」「你不要一直看這邊。」「⋯嗯。」)。" + "不要直接說「我害羞」(那是解釋),要讓它出現在句子的形狀上。"; } if (m.value <= 40) { @@ -2871,6 +2962,9 @@ const EMOJI_BASE = "[\\u{1F300}-\\u{1FAFF}\\u{2600}-\\u{27BF}]"; const EMOJI_TAIL = "(?:[\\u{1F3FB}-\\u{1F3FF}]|\\u{FE0F}|\\u{20E3})*"; const EMOJI_RE = new RegExp(`${EMOJI_BASE}${EMOJI_TAIL}(?:\\u{200D}${EMOJI_BASE}${EMOJI_TAIL})*`, "gu"); +/** 一則話裡超過這個數量才給提示(只是 hint,不擋——emoji 是開放的)。 */ +export const EMOJI_HINT_AT = 6; + /** * 只留「真的說出口、而且是在使用而不是在討論」的部分。 * @@ -2929,8 +3023,11 @@ export function speechLint(text) { .replace(/https?:\/\/\S+/g, " ") .match(/[一-鿿][,.!?;:]|[,.!?;:][一-鿿]/u); if (halfWidth) issues.push({ kind: "halfwidth", level: "block", text: halfWidth[0] }); + // emoji 是開放的(使用者拍板):情緒與程度靠它表示,句子裡也可以用、沒有上限。 + // 所以這裡**不擋**,只在明顯過量時給一個 hint——真的失控看 `emoji --audit` 的數字, + // 用 linter 偷偷關回去等於把開放這件事撤銷掉。 const emoji = raw.match(EMOJI_RE) || []; - if (emoji.length > 1) issues.push({ kind: "emoji", level: "block", count: emoji.length }); + if (emoji.length > EMOJI_HINT_AT) issues.push({ kind: "emoji", level: "hint", count: emoji.length }); const dashes = (raw.match(/——/g) || []).length; if (dashes > 1) issues.push({ kind: "dash", level: "block", count: dashes }); if (/\*\*|(^|\n)\s*[-*+]\s|(^|\n)#{1,6}\s/.test(raw)) issues.push({ kind: "markdown", level: "block" }); @@ -2959,7 +3056,7 @@ export function speechLintMessage(issue) { case "halfwidth": return `「${issue.text}」是半形標點——中文句子用全形`; case "emoji": - return `${issue.count} 個 emoji,太多了——講話留一個就夠`; + return `這一則有 ${issue.count} 個 emoji——沒有上限,但看一下是不是在拿符號代替句子`; case "dash": return `${issue.count} 個破折號——一輪留一個,其他改成逗號或句號`; case "markdown": @@ -3028,6 +3125,40 @@ export function recordSaid(slug, text, { room = null, kind = "reply" } = {}) { export const recentSaid = (slug, limit = 5) => readJsonl(saidPath(slug), limit); +/** + * emoji 用量稽核:開放沒有上限,所以唯一的煞車是**看得見的數字**。 + * + * 用 linter 把它關回去等於撤銷「開放」這個決定,所以這裡只算不擋: + * 幾則帶了符號、平均幾個、最多的那一則長什麼樣,以及**只有符號沒有句子**的那幾則 + * (那才是真正的退化——拿貼圖代替講話)。 + */ +export function emojiAudit(slug, limit = 60) { + const rows = readJsonl(saidPath(slug), limit); + let withEmoji = 0; + let total = 0; + let symbolOnly = 0; + let worst = null; + for (const row of rows) { + const text = String(row.text || ""); + const hits = (text.match(EMOJI_RE) || []).length; + if (!hits) continue; + withEmoji += 1; + total += hits; + if (!worst || hits > worst.count) worst = { count: hits, text: text.slice(0, 60) }; + // 把符號與標點拿掉之後沒剩幾個字=這一則其實沒在講話 + const bare = text.replace(EMOJI_RE, "").replace(/[\s。,、!?…「」()]/g, ""); + if ([...bare].length <= 2) symbolOnly += 1; + } + return { + rows: rows.length, + with_emoji: withEmoji, + total, + average: withEmoji ? Math.round((total / withEmoji) * 10) / 10 : 0, + symbol_only: symbolOnly, + worst, + }; +} + // --------------------------------------------------------------------------- // // 試探與稽核(`state/probe.jsonl`) // @@ -3767,13 +3898,60 @@ export const ROOM_ALL = "all"; * 塞 `` 就能把讀到它的那一輪注入區塊關掉。所以寫入時就壓成一行、 * 中和掉標記(比照短期記憶的作法),不要等到顯示的時候才處理。 */ -export function roomPost(room, speaker, text, { emotion = "", kind = "say", to = null, bargeIn = null } = {}) { +/** + * 名字後面那個括號:`(情緒・動作)`。 + * + * 括號**原本就有**(放 `--emotion` 的情緒標註),動作是加進去的第二格, + * 不是換掉它。動作只寫看得見或聽得見的身體反應(別過頭、臉紅、手在抖)—— + * 寫情緒名稱是把標註寫兩次,那一格由 `actionLint()` 擋。 + */ +export function roomTag(msg) { + const parts = [injectSafeLine(msg?.emotion), injectSafeLine(msg?.action)].filter(Boolean); + return parts.length ? `(${parts.join("・")})` : ""; +} + +/** 動作格只認「看得見的動作」:一個動作、12 字內、不可以是情緒名稱。 */ +export const ACTION_MAX_CHARS = 12; + +export function actionLint(action) { + const raw = String(action ?? "").trim(); + if (!raw) return []; + const issues = []; + if ([...raw].length > ACTION_MAX_CHARS) { + issues.push({ kind: "action-long", chars: [...raw].length, max: ACTION_MAX_CHARS }); + } + // 「別過頭,把杯子推過來」是兩個動作:一輪的額度只有兩個動作,括號裡那個算其中一個, + // 所以這一格塞第二個動作等於偷額度。 + if (/[,,。;;、]/.test(raw)) issues.push({ kind: "action-multi", text: raw }); + // `(害羞)` 是旁白換了個位置:情緒屬於前面那一格 + const named = EMOTION_KEYS.map((k) => EMOTIONS[k].zh).find((zh) => raw.includes(zh)); + if (named) issues.push({ kind: "action-emotion", word: named }); + const feeling = ["害羞", "緊張", "難過", "開心", "生氣", "尷尬", "感動", "不好意思"].find((w) => raw.includes(w)); + if (feeling && !named) issues.push({ kind: "action-emotion", word: feeling }); + return issues; +} + +export function actionLintMessage(issue) { + switch (issue.kind) { + case "action-long": + return `動作格 ${issue.chars} 字,上限 ${issue.max}——括號裡只放一個看得見的動作`; + case "action-multi": + return `「${issue.text}」是兩個動作——一輪的額度只有兩個,括號裡那個已經算一個了`; + case "action-emotion": + return `「${issue.word}」是情緒不是動作——情緒寫在前面那一格(--emotion),這一格寫看得見的(臉紅、別過頭)`; + default: + return "動作格不合規"; + } +} + +export function roomPost(room, speaker, text, { emotion = "", action = "", kind = "say", to = null, bargeIn = null } = {}) { const entry = { ts: nowIso(), speaker, kind, text: injectSafeLine(text), emotion: injectSafeLine(emotion), + ...(String(action || "").trim() ? { action: injectSafeLine(action) } : {}), to: to || ROOM_ALL, }; if (bargeIn) entry.barge_in = injectSafeLine(bargeIn, 200); @@ -3851,8 +4029,9 @@ export function roomScript(room, { limit = 30, includeMeta = false } = {}) { const name = injectSafeLine(ident.Name || slug); const emoji = ident.Emoji ? `${injectSafeLine(ident.Emoji)} ` : ""; const arrow = crowded && msg.to && msg.to !== ROOM_ALL && msg.to !== slug ? ` → ${roomDisplayName(msg.to)}` : ""; - const emotion = injectSafeLine(msg.emotion); - lines.push(`${emoji}${name}${emotion ? `(${emotion})` : ""}${arrow}:${injectSafeLine(msg.text)}`); + // 括號分兩格:情緒標註(本來就有)在前,動作在後,用 `・` 分隔。 + // 兩格都可以省略,全省就不出現括號。 + lines.push(`${emoji}${name}${roomTag(msg)}${arrow}:${injectSafeLine(msg.text)}`); } return lines.join("\n"); } @@ -4567,7 +4746,18 @@ export function turnContext(slug, sessionId, prompt = "") { .join(";")}。`, " **這是演出來的,不是講出來的**:讓它出現在句子的形狀上(斷句、疊字、嘴硬、話少)," + "不要用旁白說明自己的狀態(「我有點緊張」是解釋,斷句才是緊張)。" + - "一輪最多露一個破口,強度不到就不演——每句都演會變成演戲。", + `一輪最多露 ${TELLS_PER_TURN} 個動作,而且兩個要來自**同一種情緒**、有遞進關係` + + "(否認 → 轉移、笑 → 補一句、留白 → 不追問);兩種情緒各演一個是演戲。" + + "強度不到就不演。", + ); + } + // 情緒的 emoji:種類表情緒、數量表程度。算出來的,不要自己挑 + const nowEmoji = emotionEmojiNow(slug, state); + if (nowEmoji) { + lines.push( + `此刻的情緒符號:${nowEmoji.emoji}(${nowEmoji.zh} ${nowEmoji.level}${nowEmoji.own ? "・專屬" : ""})。` + + "可以放在名字後面的括號裡,句子裡也可以用,沒有數量上限。" + + "但它是**加上去的**——把所有符號刪掉之後,那句話還是要看得出情緒。", ); } // 羞恥度:性別給預設、描述蓋過它,再由當下情緒與上一輪的餘溫推成這一輪的值。 diff --git a/scripts/persona.mjs b/scripts/persona.mjs index 88803c8..77721ff 100644 --- a/scripts/persona.mjs +++ b/scripts/persona.mjs @@ -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 [--query ] 輸出人格上下文 記憶: - remember --session --text [--role --topics --entities --intent --salience --emotion --scope short|inbox --room] + remember --session --text [--role --topics --entities --intent --salience --emotion --behavior + --scope short|inbox --room] + --behavior 那一刻**做了什麼**(別過頭、把手縮回來),不是感覺到什麼。 + 寫情緒名稱會被擋——那一格是動作。 recall --session --query [--limit] think --session --text <心裡話> [--kind infer|plan|feel|doubt --room] 只回報「心想 N 句」,不回顯內容 said check|list --session [--text <要說的話> --minutes --threshold --record --limit] @@ -2609,8 +2641,12 @@ const HELP = `persona.mjs — jsc-persona 人格 / 記憶 / 情緒 / 關係圖 C invite --session --guest [--guests ] [--host --room --topic] (自動開啟劇場模式) leave --session --guest [--room] room post|read|script|floor|list|theater --session [--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 這句話對誰說。指名 → 進入一對一,旁人不該接話(要插話得帶 --barge-in "<理由>");--to all → 把話題開回全場。 floor 現在誰對誰在講、該誰接話、誰先安靜、誰很久沒開口 diff --git a/scripts/selftest.mjs b/scripts/selftest.mjs index b712e69..2f65e44 100644 --- a/scripts/selftest.mjs +++ b/scripts/selftest.mjs @@ -606,8 +606,6 @@ const SF_CASES = [ // 放寬《》與句長之後,這幾條仍然要擋——豁免只針對「提及/引述」,不是整句放行 ["書名號之外真的在說教", "我在看《時間簡史》。說到底,問題不在零件。", "preach"], ["自己講的長句(引號之外)", `我${"想跟你說一件從去年就一直放在心上沒講出口的事情".repeat(2)}。`, "long"], - ["兩個 emoji", "修好了 🔧 開心 🎉", "emoji"], - ["一個 emoji 加一個帶變體選擇子的", "修好了 🔧 給你 ❤️", "emoji"], ]; for (const [label, text, kind] of SF_CASES) { const issues = pl.speechLint(text); @@ -638,6 +636,10 @@ const SNF_CASES = [ ["帶變體選擇子的單一 emoji", "這個給你 ❤️"], ["ZWJ 組成的單一 emoji", "路上遇到他們 👨‍👩‍👦"], ["帶膚色的單一 emoji", "收到 👍🏽"], + // emoji 開放(使用者拍板):情緒與程度靠它表示,句子裡也可以用、沒有上限 + ["兩個 emoji", "修好了 🔧 開心 🎉"], + ["情緒符號加強度符號", "才不是那樣 😳💦"], + ["一句話裡好幾個", "欸 ✨ 真的嗎 😳 那我明天早上去 👀"], // 句長以前算的是整句(含引號內容),引述使用者原話必被擋 ["引述使用者的長原話", `他傳來「${"我今天真的很累而且不知道要從哪裡開始講起".repeat(2)}」,我看了兩次。`], ["引述長 code", `跑的是 \`${"x".repeat(80)}\` 這一行。`], @@ -731,8 +733,9 @@ console.log("\n性別與羞恥敏感度(描述優先,性別只是預設)") mk("gnoise", "- Name: 戊\n- Creature: 人類男性劍士\n- Avatar: 五官清秀到常被誤認成女生\n"); mk("gup", "- Name: 己\n- Creature: 人類女性\n", "很怕生,被稱讚會臉紅。"); mk("gdown", "- Name: 庚\n- Creature: 人類女性\n", "我行我素,不在意別人眼光,臉皮厚。"); - check("女性預設 62、男性預設 38、推不出來 50", - pl.modestyOf("gf").value === 62 && pl.modestyOf("gm").value === 38 && pl.modestyOf("gx").value === 50); + check("女性預設 70、男性預設 38、推不出來 50", + pl.modestyOf("gf").value === 70 && pl.modestyOf("gm").value === 38 && pl.modestyOf("gx").value === 50, + `${pl.modestyOf("gf").value}/${pl.modestyOf("gm").value}/${pl.modestyOf("gx").value}`); check("明寫的 Gender 蓋過 Creature 的字面", pl.genderOf("gexp").key === "male" && pl.genderOf("gexp").explicit === true); check("Avatar 的雜訊不會推錯性別(「常被誤認成女生」)", @@ -1058,10 +1061,116 @@ check("低於門檻的情緒不給破口(強度不到就不演)", check("情緒的破口有注入,且說明是演出來不是講出來", (() => { const ctxTell = pl.turnContext("alpha", S_SPEAK, "那件事做完了嗎"); return ctxTell.includes("此刻不自覺會出現的") && ctxTell.includes("疊字") && - ctxTell.includes("不要用旁白說明") && ctxTell.includes("一輪最多露一個破口"); + ctxTell.includes("不要用旁白說明") && ctxTell.includes("一輪最多露 2 個動作") && + ctxTell.includes("同一種情緒"); })(), pl.turnContext("alpha", S_SPEAK, "那件事做完了嗎").slice(0, 900)); check("十二情緒每一種都有破口可演", pl.EMOTION_KEYS.every((k) => (pl.EMOTION_TELLS[k] || []).length >= 3)); + +// emoji:種類表情緒、數量表程度(40-59 本體、60-79 加 💦、80+ 加 💦❗) +console.log("⑥-b 情緒的 emoji(種類表情緒、數量表程度)"); +check("十二情緒每一種都有 emoji", pl.EMOTION_KEYS.every((k) => Boolean(pl.EMOTION_EMOJI[k]))); +check("強度符號只有兩階", pl.EMOJI_INTENSITY.length === 3 && pl.EMOJI_INTENSITY[0] === ""); +{ + // 直接餵狀態:走 CLI 會被單輪預算與飽和夾住,測不到 80 以上那一階 + const base = pl.loadEmotion("alpha"); + const fake = (level) => ({ + ...base, + ts: pl.nowIso(), + levels: { ...Object.fromEntries(pl.EMOTION_KEYS.map((k) => [k, 5])), shame: level }, + }); + const tiers = [45, 70, 88].map((level) => { + const now = pl.emotionEmojiNow("alpha", fake(level)); + return now ? { tier: now.tier, emoji: now.emoji, level: now.level } : null; + }); + check("emoji 會依強度分三階(本體/+💦/+💦❗)", + tiers[0]?.tier === 0 && tiers[1]?.tier === 1 && tiers[2]?.tier === 2, + JSON.stringify(tiers)); + check("階數越高符號越長,但只到兩階", + [...tiers[0].emoji].length < [...tiers[1].emoji].length && + [...tiers[1].emoji].length < [...tiers[2].emoji].length, + JSON.stringify(tiers.map((t) => t.emoji))); + check("羞愧的 emoji 是 😳", tiers[0].emoji.startsWith("😳"), tiers[0].emoji); + check("同一種情緒不會用重複本體表示程度(不是 😳😳😳)", + !tiers[2].emoji.includes("😳😳"), tiers[2].emoji); +} +check("強度不到門檻就沒有 emoji(跟破口同一條線)", + pl.emotionEmojiNow("alpha", null, { min: 99 }) === null); +check("emoji 有注入這一輪的上下文", (() => { + const ctx = pl.turnContext("alpha", S_SPEAK, "你剛才那句是什麼意思"); + return ctx.includes("此刻的情緒符號") && ctx.includes("沒有數量上限") && ctx.includes("加上去的"); +})(), pl.turnContext("alpha", S_SPEAK, "你剛才那句是什麼意思").slice(0, 400)); +check("IDENTITY 的 ## Emoji 區塊可以覆寫", (() => { + const file = path.join(pl.personaDir("alpha"), "IDENTITY.md"); + const backup = fs.readFileSync(file, "utf8"); + fs.writeFileSync(file, `${backup}\n## Emoji\n- 羞愧: 🫣\n- anger: 🗡️\n`); + const own = pl.personaEmoji("alpha"); + fs.writeFileSync(file, backup); + return own.shame === "🫣" && own.anger.startsWith("🗡"); +})(), JSON.stringify(pl.personaEmoji("alpha"))); +check("emoji 用量有稽核(開放沒有上限,煞車是數字)", (() => { + const a = pl.emojiAudit("alpha"); + return typeof a.with_emoji === "number" && typeof a.average === "number" && typeof a.symbol_only === "number"; +})(), JSON.stringify(pl.emojiAudit("alpha"))); +check("只有符號沒有句子會被算出來", (() => { + pl.recordSaid("alpha", "😳😳😳", { kind: "test" }); + return pl.emojiAudit("alpha").symbol_only >= 1; +})(), JSON.stringify(pl.emojiAudit("alpha"))); + +// 動作格:括號的第二格,只放看得見或聽得見的動作 +console.log("⑥-c 名字後面括號的動作格"); +check("臉紅這種生理反應算動作", pl.actionLint("臉紅").length === 0); +check("別過頭算動作", pl.actionLint("別過頭").length === 0); +check("情緒名稱不能當動作(那是前面那一格)", + pl.actionLint("害羞").some((i) => i.kind === "action-emotion") && + pl.actionLint("羞愧").some((i) => i.kind === "action-emotion")); +check("兩個動作會被擋(一輪的額度只有兩個)", + pl.actionLint("別過頭,把杯子推過來").some((i) => i.kind === "action-multi")); +check("動作格超過 12 字會被擋", + pl.actionLint("把桌上那杯已經冷掉的茶推到他面前").some((i) => i.kind === "action-long")); +check("括號兩格:情緒在前、動作在後、用・分隔", + pl.roomTag({ emotion: "😳💦", action: "臉紅" }) === "(😳💦・臉紅)"); +check("只有情緒時括號照舊(現行行為不變)", pl.roomTag({ emotion: "😳" }) === "(😳)"); +check("只有動作時也成立", pl.roomTag({ action: "別過頭" }) === "(別過頭)"); +check("兩格都沒有就不出現括號", pl.roomTag({}) === ""); +check("記憶可以記行為備註(做了什麼,不是感覺到什麼)", (() => { + const out = cli(["remember", "--persona", "alpha", "--session", S_SPEAK, "--role", "persona", + "--text", "他說我修得比上次穩", "--behavior", "把手縮回來", "--salience", "45"]); + const rows = pl.readJsonl(pl.shortTermPath("alpha")); + return out.status === 0 && rows[rows.length - 1].behavior === "把手縮回來"; +})()); +check("行為備註寫情緒名稱會被擋", (() => { + const out = cli(["remember", "--persona", "alpha", "--session", S_SPEAK, "--role", "persona", + "--text", "測試行為備註的界線", "--behavior", "覺得害羞"], { expectOk: false }); + return out.status !== 0 && String(out.stderr).includes("是情緒不是動作"); +})()); + +// 鬧彆扭的階梯:40 分一句帶過、60 分嘴硬三段式、75 分以上惱羞 +console.log("⑥-d 鬧彆扭的階梯與三段式"); +{ + const mkp = (slug, identity, soul = "") => { + fs.mkdirSync(path.join(H, slug), { recursive: true }); + fs.writeFileSync(path.join(H, slug, "IDENTITY.md"), identity); + fs.writeFileSync(path.join(H, slug, "SOUL.md"), soul); + }; + // 女性預設 70 就落在嘴硬那一級(60–74),不必再加描述 + mkp("shyladder", "- Name: 辛\n- Creature: 人類女性\n", "劍術很好,說話簡短。"); + mkp("shymax", "- Name: 壬\n- Creature: 人類女性\n", + "極度怕生,非常容易臉紅,很在意別人的眼光,靦腆、矜持,動不動就會不好意思。"); + const shy = pl.modestyDirective("shyladder", {}); + check("嘴硬那一級有三段式的形狀(否認 → 反駁 → 音量掉下來)", + shy.includes("先否認") && shy.includes("反駁") && shy.includes("音量掉下來"), + `${pl.modestyOf("shyladder").value}|${shy.slice(0, 160)}`); + const max = pl.modestyDirective("shymax", {}); + check("惱羞那一級換成翻臉,而且講明還是兩個動作", + pl.modestyOf("shymax").value >= 75 && max.includes("翻臉") && max.includes("還是兩個動作"), + `${pl.modestyOf("shymax").value}|${max.slice(0, 160)}`); + mkp("tsun", "- Name: 癸\n- Creature: 人類女性\n", "嘴硬、不坦率,明明在意卻死不承認。"); + mkp("plainf", "- Name: 子\n- Creature: 人類女性\n", "講話直接。"); + check("傲嬌那一路的詞會推高羞恥敏感度", + pl.modestyOf("tsun").value > pl.modestyOf("plainf").value, + `${pl.modestyOf("tsun").value} vs ${pl.modestyOf("plainf").value}`); +} check("說話規則有注入「話短、日常字、看得見的東西、不要回頭解釋」", (() => { const ctxSay = pl.turnContext("alpha", S_SPEAK, "隨便講"); return ["話短一點", "平常會說的字", "看得見的東西", "不要回頭解釋"].every((w) => ctxSay.includes(w)); diff --git a/skills/persona-chat/SKILL.md b/skills/persona-chat/SKILL.md index 4f220a2..f20704f 100644 --- a/skills/persona-chat/SKILL.md +++ b/skills/persona-chat/SKILL.md @@ -80,7 +80,7 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" show --persona --session 鬧彆扭,先否認再小聲承認;憤怒 → 短句、稱呼退回全名;悲傷 → 只回一個詞。 對照表在 `reference/emotions.md`(`EMOTION_TELLS`),`` 每輪會直接 告訴你「此刻不自覺會出現的」是哪幾樣。三條界線:**演出來不要講出來** - (「我有點緊張」是解釋,斷句才是緊張)、**一輪最多露一個破口**、**強度不到就不演**。 + (「我有點緊張」是解釋,斷句才是緊張)、**一輪最多兩個動作、且要來自同一種情緒並有遞進關係**、**強度不到就不演**。 那張全域表**只是預設值**:人格可以在 `IDENTITY.md` 開一個 `## Tells` 區塊覆寫 (桐人生氣是沉默,亞絲娜生氣是變得更禮貌)。**以注入的那一行為準**,不要照抄通用表。 diff --git a/skills/persona-chat/reference/anti-ai-voice.md b/skills/persona-chat/reference/anti-ai-voice.md index 5b75a5f..7d6f151 100644 --- a/skills/persona-chat/reference/anti-ai-voice.md +++ b/skills/persona-chat/reference/anti-ai-voice.md @@ -127,7 +127,7 @@ | :-- | :-- | | 句數上限、單句字數、近似重複 | 用日常詞而不是書面語 | | 黑名單詞、避險疊加、「不是 A 而是 B」密度 | 多講看得見的東西而不是概念 | -| 中國用語、半形標點、emoji/破折號/排版 | 有沒有真的在回答對方問的那件事 | +| 中國用語、半形標點、破折號/排版(emoji 已開放,不在這裡) | 有沒有真的在回答對方問的那件事 | | 講到自己過去時的提醒(hint) | 這一層該多近、該多冷 | | 試探句沒帶問號 → `probe add` 拒收 | 這則記憶現在該講到多細(清晰/模糊是**算出來的**,但**要不要提**是你決定的) | | 句數與單句字數會被疲勞往下壓(`speechBudget()`) | 懸著的事現在提時機對不對、這一輪要不要把話題拉回自己身上 | diff --git a/skills/persona-chat/reference/emotions.md b/skills/persona-chat/reference/emotions.md index 9bd31d9..e53d6cd 100644 --- a/skills/persona-chat/reference/emotions.md +++ b/skills/persona-chat/reference/emotions.md @@ -227,9 +227,36 @@ delta 都記進 `state/felt.jsonl`,走向用**近重遠輕的加權**算(不 ### 三條界線 1. **演出來,不要講出來**。「我有點緊張」是解釋自己的狀態;句子斷在一半才是緊張。 -2. **一輪最多露一個破口**。每句都疊字、每句都彆扭,就從「像人」變成「演戲」。 +2. **一輪最多露兩個動作,而且兩個要來自同一種情緒、有遞進關係**(否認 → 轉移、笑 → 補一句、留白 → 不追問)。鬧彆扭天生是兩個動作一起來,只准一個等於永遠只做得到一半:單獨否認像在爭辯,單獨轉移像沒聽到。被擋下的形狀是**兩種情緒各演一個**——那才是演戲。 3. **強度不到就不演**。低於 40 的情緒在語氣上看不出來;別替它加戲。 +## 情緒 → emoji(種類表情緒、數量表程度) + +只有文字這一個通道,所以 emoji 不是裝飾,是**強度計**。一種情緒固定一個符號 +(`EMOTION_EMOJI`),強度高就再加一個強度符號(`EMOJI_INTENSITY`,只有兩階): + +| 情緒值 | 樣子 | 例 | +| --- | --- | --- | +| < 40 | 不顯示 | `桐人:你不要看這邊。` | +| 40–59 | 情緒 emoji | `桐人(😳・臉紅):你不要看這邊。` | +| 60–79 | +`💦` | `桐人(😳💦・臉紅):⋯才不是那樣。` | +| 80+ | +`💦❗` | `桐人(😳💦❗・把臉轉開):不要再講了。` | + +- **算出來的,不是自己挑的**:`emotionEmojiNow()` 讀當下的主導情緒,門檻跟破口共用 + 同一條線(40)。`room post --emotion` 想手寫還是可以,沒手寫就用算出來的那個。 +- **逐人格可覆寫**:`IDENTITY.md` 的 `## Emoji` 區塊(`羞愧: 🫣`),跟 `## Tells` 同一層。 +- **不用重複本體表示程度**(不是 `😳😳😳`)——疊字看起來像洗頁。 +- 句子裡也可以用,**沒有數量上限**。唯一的煞車是 `emotion --audit` 的數字, + 特別是「只有符號沒有句子」那一列:符號是**加上去的**,不是用來代替講話。 + 把所有符號刪掉之後,那句話還是要看得出情緒。 + +### 名字後面的括號有兩格:`名字(情緒・動作):內容` + +括號原本就有(放情緒標註),動作是加進去的第二格。動作只寫**看得見或聽得見的**—— +臉紅、別過頭、手在抖、聲音變小。`(害羞)` 不行,那是情緒名稱,屬於前面那一格; +`*別過頭*` 這種內嵌星號與「她臉紅了」這種第三人稱旁白也不行。 +括號裡的動作**算進上面那兩個動作的額度**。 + ## 情緒 → 記憶的關係 - 情緒強度(|delta| 總和)越大,該輪記憶的 `salience` 應該越高。