diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 53fb8aa..b6e732d 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "jsc-persona", - "version": "0.0.7", + "version": "0.0.8", "description": "AI 人格化記憶聊天 plugin:以 OpenClaw 相同的身分描述(IDENTITY/SOUL)建立人格(可用動漫作品+角色名上網蒐集設定),結合 hook 強制的人格載入鎖與跨人格隔離、六正向+六負向十二情緒、語意分析、短期/長期記憶與固化條件、心智圖、思維導圖與人際關係圖;可用 sub agent 邀請其他人格同場對話(劇場模式只顯示人格對話)。於 Claude Code 以 /jsc-persona: 前綴呼叫。", "skills": "./skills", "author": { diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 158b058..13d84c1 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "jsc-persona", - "version": "0.0.7", + "version": "0.0.8", "description": "AI 人格化記憶聊天 skills:OpenClaw 相同的人格描述 + 十二情緒 + 語意分析 + 短期/長期記憶 + 心智圖 + 人際關係圖。(人格鎖與跨人格隔離的 hook 僅在 Claude Code 生效)", "skills": "./skills" } diff --git a/AGENTS.md b/AGENTS.md index 9247996..876c870 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,6 +36,10 @@ 睡眠仍然要驗鎖:目標正被另一個程序活鎖住時拒絕,死鎖可接手。 8. **人格講話要像人**:推導寫進 `think`(心裡話,只回報「💭 心想 N 句」,永不回顯內容)、 回話 1–3 句、短時間內不重說同一件事(`room post` 會直接擋下重複與過長的發言)。 + 句數上限**跟著羞恥度走**(`speechBudget()`),但羞恥度高**不等於話一定變少**——三個出口: + **縮**(一句嘴硬,台詞在迴避心裡那句)、**炸**(慌/惱羞/被逼澄清 → 4 句但單句只有 22 字, + 碎而急)、**坦白**(信任高又獨處 → 3 句完整句,先寫完心裡話再開口)。 + `room post` 同時擋句數與單句字數,兩個參數一起動才分得出「碎念」與「演講」。 再加四條講話的樣子:**短句**(一句 `MAX_SENTENCE_CHARS`=45 字內)、**日常用詞**、 **多講看得見的東西**(人、動作、物件、當下的場面)而不是概念,以及**不要解釋自己的話** (「我的意思是」「換句話說」這類開頭由 `speechLint` 擋下,`said check` 也會一起檢)。 @@ -50,6 +54,33 @@ 誤殺防護:`speechBody()` 會先拿掉引號與 `code`,**提及不算使用**;「老實說」只擋開場。 最後是這一層的義務:**講自己的過去要有出處**——「我以前⋯」只能講 `recall` 查得到的轉折, `speechLint` 給 `level: "hint"`(不擋,但要人去驗),沒有紀錄就是編造自己的過去。 +8b. **情緒先行、會飽和、偏差看得見**:每輪注入之前先用 `readUserEmotion()` 讀對方那句話 + (十二類加權詞表、否定會擋掉命中、引號與 `code` 內不比對、標點只放大既有訊號), + 回的是**訊號不是判定**——人格讀到的不一樣就以人格為準;`RESPONSE_STANCE` 給的是 + 「怎麼接」的**動作**不是罐頭句。每輪的偵測與 delta 記進 `state/felt.jsonl`, + `feltTrend()` 用近重遠輕的加權算走向。`applyEmotion()` 加了**飽和** + (`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(踩過)。 +8f. **親近度會改變情緒的份量**:`relationGain()` 依關係節點的親近度把 delta 乘 0.7–1.35, + `emotion --apply [--from <對象>]`。只調幅度不調方向。 +8g. **提到 ≠ 接觸**:睡眠只替 `contactsFromRooms()`(同房且雙方都發過言)的人蓋 + `last_contact_at`。掃 `entities` 會讓「日記裡寫到某人」把他的沉默計時歸零, + 主動關心因此永遠不觸發。真人節點走 `relation node --contact`。 +8c. **性別只給一個預設值,不是套在個性上的係數**:`IDENTITY.md` 的 `Gender` 欄位 + (女性/男性/非二元/未指定,建立時要問不要猜)唯一的作用是給**羞恥敏感度**一個預設 + (62/38/50)。`modestyOf()` 會用 IDENTITY 與 SOUL 的描述往上或往下推(`MODESTY_SIGNALS`, + 可以推到 0),排序永遠是**個性描述 > 角色原作既有的性別化語言特徵 > 性別預設**。 + 效果只調「羞愧」這一個破口的顯示門檻(`emotionTells` 的 shame floor),其餘十一種不受影響。 + 推性別時**只看 `Creature`、不看 `Avatar`**(外觀散文會推錯)。 + 不做「女性→情緒更外顯」這種全域放大——那會把角色壓成模板。 9. **人格可搬家**:`export` / `import`(單一 JSON bundle)。匯出只能匯出「本 session 載入的人格」, 其他人格一律 deny——匯出等於把記憶讀出來。 10. **人格有編號**:英文名全大寫+兩位索引(`ASUNA-01`),同名才遞增。編號同時是新人格的 diff --git a/README.md b/README.md index 5ec7631..6d623b6 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,7 @@ flowchart TB ``` / -├── IDENTITY.md # 身分卡:Name / Creature / Vibe / Emoji / Avatar(OpenClaw 同欄位) +├── IDENTITY.md # 身分卡:Name / Creature / Gender / Vibe / Emoji / Avatar(Gender 是本 plugin 加的) ├── SOUL.md # 靈魂:Core Truths / Boundaries / Vibe / Continuity + 情緒傾向 ├── AGENTS.md # 操作規則(與個性分離) ├── icon.svg / icon.png # 人格圖示(由編號/名字/emoji 決定,也是 Gitea 存取庫頭像) @@ -82,10 +82,12 @@ flowchart TB │ ├── emotion.json # 十二情緒 levels / baseline / 半衰期 │ ├── inner.jsonl # 心裡話(推導過程;只回報「心想 N 句」,不說出口) │ ├── said.jsonl # 說過的話(用來擋短時間內的重複發言) +│ ├── felt.jsonl # 每輪讀到的對方情緒、自己套用的 delta、當下的羞恥度 │ ├── sync.json # Gitea 同步狀態(最後 push / pull) │ └── config.json # 含人格編號 code ├── memory/ -│ ├── short-term.jsonl # 短期記憶(語意分析後;上限 240 筆 / 14 天) +│ ├── short-term.jsonl # 短期記憶(語意分析後;軟上限 120 筆 / 硬上限 240 筆 / 14 天) +│ ├── felt.jsonl # 每輪讀到的對方情緒 + 自己套用的 delta(走向與偏差稽核) │ ├── long-term/*.md # 長期記憶(一則一檔 + frontmatter) │ ├── INDEX.md # 長期記憶索引(自動產生) │ └── inbox/room-*.jsonl # 當 guest 時留下的見聞,待本體消化 @@ -111,6 +113,64 @@ flowchart TB - **主導情緒**取「超出基線最多」的前三名,所以個性底色不會永遠霸榜。 - 觸發規則與事件→delta 對照表:`skills/persona-chat/reference/emotions.md`。 +**情緒先行:先讀對方那句話(`readUserEmotion`)。** 在這之前情緒**全部是人格自己填的**, +而人不會主動給自己扣分——結果是正向一路貼頂、負向整天不動。現在每輪注入之前會先讀一次 +對方的話:十二類加權詞表(強 3/一般 2/弱 1)、程度副詞乘係數(超 ×1.6、有點 ×0.6)、 +**否定會擋掉那次命中**(「我不害怕」不算 fear)、引號與 `` `code` `` 內不比對(提及不算使用)、 +標點只放大既有訊號不憑空長出新情緒。回的是**訊號不是判定**,人格讀到的不一樣就以人格為準。 +單獨測:`emotion --read "<他說的話>"`。 + +**怎麼接(`RESPONSE_STANCE`)**:注入的是**動作**不是句子——悲傷「先接住,不要急著給解法」、 +憤怒「不辯解,先認可能認的那一小塊」、焦慮「給具體的下一步與時間點,不要給保證」。 +罐頭同理心那類句子由講話規則擋掉。 + +**走向(`feltTrend`)**:每輪的偵測結果與套用的 delta 記進 `state/felt.jsonl`, +用近重遠輕的加權算最近幾輪的趨勢(不用多數決——會被離群值主導又丟掉強度), +注入成「他最近 3 輪的走向:悲傷 ↘ 在退」。一直是同一種情緒就不要每輪用同一句接法。 + +**羞恥度會被情緒推,而且有回饋**:`modestyState()` 在 trait 之上加當下的情緒推力 +(羞愧 +0.45/焦慮 +0.25 往上,**憤怒 −0.35**/驚喜 −0.30/喜悅・信任 −0.20 往下)、 +對象的語氣層、以及上一輪的餘溫(`MODESTY_GAIN`=0.35)。三道護欄讓它不會失控: +**增益 < 1**(級數才收斂,0.9 三輪就貼 100)、**一輪最多動 15**、**只算超出基線的部分** +(比較不開心不代表比較害羞)。慢的情緒(信任半衰期 720 分)推力自動打折—— +那是底色不是此刻的事。天然的斷路器是**惱羞成怒**:被逗到極限翻臉,`anger` 是負權重, +羞恥自己就掉下來,而且會掉到比原本更低。 + +**親近度會改變情緒的份量**(`relationGain()`):同一句話,從枕邊人嘴裡跟從生人嘴裡出來, +衝擊本來就不一樣。`emotion --apply` 會依對象的親近度把 delta 乘上 0.7–1.35 +(親近 96 → ×1.28、親近 8 → ×0.75),`--from <對象>` 可以指定是誰引起的。 +**只調幅度、不調方向**——誰講的都不會讓難過變成高興。 + +**情緒調節**:delta 不再是加完直接 `clamp`。**飽和**——越接近端點同方向漲得越慢 +(`headroom^K`,永遠逼近 100 但到不了;往 baseline 回的方向不壓);**單輪預算**—— +所有 `|delta|` 總和上限 60,超過等比例縮小。**偏差看得見**——`emotion --audit` +印出最近幾輪往舒服/往難受的總量與比例,正向佔 ≥ 90% 會被點名。 + +## 性別 → 羞恥敏感度(性別只是預設值,描述永遠蓋過它) + +`IDENTITY.md` 多一個 `Gender` 欄位(女性/男性/非二元/未指定;OpenClaw 沒有這欄,是本 plugin 加的)。 +建立人格時**要問,不要猜**;沒有性別的人格(程式、精靈、動物)就寫「未指定」。舊人格沒填時, +只從 `Creature` 推、**不看 `Avatar`**——外觀散文的雜訊會推錯(例:「五官清秀到常被誤認成女生」)。 + +它的作用範圍刻意只有**一個具名維度**:**羞恥敏感度**(會不會害羞、會不會鬧彆扭、在不在意別人眼光)。 + +| 來源 | 效果 | +| --- | --- | +| 性別預設 | 女性 62/男性 38/非二元・未指定 50 | +| IDENTITY/SOUL 的描述 | 「害羞」「容易臉紅」「怕生」「矜持」往上加;「不在意別人眼光」「我行我素」「臉皮厚」「不怕丟臉」往下扣(`MODESTY_SIGNALS`),**可以扣到 0** | + +排序固定:**① 個性描述 ② 角色原作既有的性別化語言特徵(自稱、稱謂、語尾) ③ 性別預設**。 +所以兩個都是女性但個性不同的人格,講起話來不會一樣——同樣寫「人類女性」, +補一句「怕生、被稱讚會臉紅」是 88,補一句「我行我素、不在意別人眼光、臉皮厚」是 8。 + +效果走既有機制、不另開一套:它調的是**「羞愧」這一個破口的顯示門檻**(`EMOTION_TELLS.shame` +本來就是「鬧彆扭,先否認再小聲承認」),其餘十一種情緒不受影響。 +高敏感度注入「被稱讚外表、被戳穿心事、距離變近時先鬧彆扭再承認,不要直接說『我害羞』」; +低敏感度注入「不用演害羞,該承認就承認」。計算在 `modestyOf()`,注入在 `modestyDirective()`。 + +**不做的事**:沒有「女性→情緒更外顯」這種全域放大。那會把角色壓成模板, +跟講話規則在做的去 AI 味(刪掉公式化)正好相反。 + ## 短期 → 長期的轉入條件(R1–R6) 寫在 `scripts/persona-lib.mjs` 的 `promotionCandidates()`,用 `candidates` 子指令查: @@ -122,7 +182,12 @@ flowchart TB | **R3** | 單筆情緒變動總量 ≥ 25 | `event`(帶情緒錨點) | | **R4** | `intent=commit` 或命中承諾/界線關鍵詞 | `promise` / `boundary`(salience ≥ 80,不可遺忘) | | **R5** | 同一人物(entity)≥ 2 筆 | `relationship`(並更新關係圖) | -| **R6** | 短期記憶 ≥ 40 筆(容量壓力) | 依顯著度清出空間 | +| **R6** | 短期記憶 ≥ 40 筆(容量壓力) | 依顯著度清出空間。**真的會清**:超過軟上限 120 筆時從顯著度最低、最舊的開始裁,但顯著度 ≥ 80 或 `intent=commit`(承諾/界線)與 24 小時內的新紀錄一律不動;裁掉幾筆會寫進 `sleep` 的回報,不做無聲的裁切 | + +**提到 ≠ 接觸**:主動關心的依據是 `last_contact_at`,睡眠只替**真的講到話的人**蓋章 +(`contactsFromRooms()`:同一個聊天室裡我發過言、他也發過言)。以前是掃短期記憶的 +`entities`——那等於「我在日記裡寫到他」就算「我跟他接觸過」,沉默計時被無聲重置, +關心名單永遠是空的。沒有聊天室的對象(真人節點)走 `relation node --contact` 明確蓋章。 沒命中任何規則的就讓它被裁掉——**遺忘是功能**。達標時 `Stop` 與 `remember` 都會提醒去跑 `/jsc-persona:persona-memory`。 @@ -135,7 +200,7 @@ AI 最容易露餡的六件事:把推理過程講出來、一次講一大段 | 機制 | 怎麼運作 | | --- | --- | | **心裡話** `think` | 語意分析、推論、盤算全寫進 `state/inner.jsonl`;這個指令**只印「💭 心想 N 句」**,內容永不回顯。下一輪 `` 會帶回最近三句,推論因此有連續性,但使用者只看得到狀態 | -| **一到三句** | `` 每輪注入上限;`room post` 對超過三句的發言直接拒收(`--force` 例外) | +| **一到三句** | `` 每輪注入上限;`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 的前兩種注入。三條界線:**演出來不要講出來**(「我有點緊張」是解釋,斷句才是緊張)、**一輪最多露一個破口**、**強度不到就不演**。混合狀態(緊張=焦慮+期待、害羞=喜悅+羞愧、賭氣=憤怒+悲傷…)的對照表在 `skills/persona-chat/reference/emotions.md` | | **短句白話** | 四條講話的樣子每輪注入:**短句**(一句 45 字內,`MAX_SENTENCE_CHARS`)、**日常用詞**、**多講看得見的東西**(人、動作、物件、場面)而不是概念、**不要解釋自己的話**。句長與「我的意思是/換句話說/也就是說」這類開頭由 `speechLint()` 機械攔截(`room post` 擋下、`said check` 事前警告,`--force` 例外);用詞與具體度沒辦法用規則抓,靠注入的規則自律 | diff --git a/plugin.json b/plugin.json index ef5484b..264303c 100644 --- a/plugin.json +++ b/plugin.json @@ -1,6 +1,6 @@ { "name": "jsc-persona", - "version": "0.0.7", + "version": "0.0.8", "description": "AI 人格化記憶聊天 plugin:OpenClaw 相同的人格描述 + 十二情緒 + 語意分析 + 短期/長期記憶 + 心智圖 + 人際關係圖。於 Antigravity 以 /jsc-persona: 前綴呼叫。", "skills": "./skills" } diff --git a/scripts/persona-gitea.mjs b/scripts/persona-gitea.mjs index 3ff232b..25c7e0f 100644 --- a/scripts/persona-gitea.mjs +++ b/scripts/persona-gitea.mjs @@ -75,6 +75,7 @@ export const AREAS = { "state/emotion.json", "state/inner.jsonl", "state/said.jsonl", + "state/felt.jsonl", "state/sleep.json", "memory/short-term.jsonl", "memory/inbox/", @@ -502,7 +503,7 @@ export function wikiHome(slug, code) { "| 欄位 | 內容 |", "| --- | --- |", `| 編號 | \`${code}\` |`, - ...["Name", "Creature", "Vibe", "Emoji", "Avatar"] + ...["Name", "Creature", "Gender", "Vibe", "Emoji", "Avatar"] .filter((k) => ident[k]) .map((k) => `| ${k} | ${ident[k]} |`), `| 長期記憶 | ${longTerm.length} 則 |`, @@ -511,7 +512,7 @@ export function wikiHome(slug, code) { "", "## 頁面", "", - "- [IDENTITY](IDENTITY) — 身分卡(Name / Creature / Vibe / Emoji / Avatar)", + "- [IDENTITY](IDENTITY) — 身分卡(Name / Creature / Gender / Vibe / Emoji / Avatar)", "- [SOUL](SOUL) — 靈魂:Core Truths / Boundaries / Vibe / Continuity", "- [AGENTS](AGENTS) — 操作規則 / [USER](USER) — 對使用者的理解", "- [Icon](Icon) — 人格形象圖(SVG + PNG)與它的來源", diff --git a/scripts/persona-lib.mjs b/scripts/persona-lib.mjs index ac5f94b..87ffa8c 100644 --- a/scripts/persona-lib.mjs +++ b/scripts/persona-lib.mjs @@ -303,25 +303,78 @@ export function decayEmotionBy(state, minutes = SLEEP_DECAY_MINUTES) { 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 applied = {}; + // 先算「想推多少」,超過單輪預算就等比例縮小——一次灌爆的路要堵起來 + const wanted = []; for (const [key, rawDelta] of Object.entries(deltas || {})) { if (!(key in EMOTIONS)) continue; const delta = Number(rawDelta); - if (!Number.isFinite(delta)) continue; + 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); - state.levels[key] = Math.round(clamp(before + delta) * 100) / 100; + 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; } +// 同一句話,從枕邊人嘴裡跟從生人嘴裡出來,衝擊本來就不一樣。 +// 這條把關係圖接進情緒:親近度高 → delta 放大;生人 → 縮小。 +// 只調**幅度**,不調方向——誰講的不會讓難過變成高興。 +export const RELATION_GAIN_MIN = 0.7; +export const RELATION_GAIN_MAX = 1.35; + +/** 這個對象講的話,對我的情緒有多少倍的份量。找不到對象就 1(不放大也不縮小)。 */ +export function relationGain(slug, who = null) { + const node = who ? findRelationNode(slug, who) : speakerNode(slug); + if (!node) return { gain: 1, name: null, closeness: null }; + const closeness = Number(node.closeness ?? 30); + const gain = clamp(0.7 + 0.006 * closeness, RELATION_GAIN_MIN, RELATION_GAIN_MAX); + return { gain: Math.round(gain * 100) / 100, name: node.name || node.id, closeness }; +} + export function mood(state) { const levels = state.levels || {}; let valence = 0; @@ -378,8 +431,12 @@ export const EMOTION_TELLS = { */ 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(({ level }) => level >= min) + .filter(({ key, level }) => level >= floorFor(key)) .slice(0, top) .map(({ key, level }) => ({ key, @@ -389,6 +446,234 @@ export function emotionTells(slug, state = null, { min = 40, top = 2 } = {}) { })); } +// --------------------------------------------------------------------------- // +// 性別與羞恥敏感度 +// +// 規則的排序是固定的,不能反過來: +// ① 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); @@ -401,6 +686,183 @@ export function emotionBrief(slug, state = null) { ); } +// --------------------------------------------------------------------------- // +// 讀對方那句話的情緒(輸入端) +// +// 為什麼需要:在這之前,情緒**全部是人格自己填的** `--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` 0–100;`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, + }; +} + // --------------------------------------------------------------------------- // // 人格目錄骨架 // --------------------------------------------------------------------------- // @@ -847,9 +1309,15 @@ export function gcRuntime() { // 記憶:短期(滾動)/ 長期(一則一檔) // --------------------------------------------------------------------------- // -export const SHORT_TERM_KEEP = 240; // 短期記憶保留筆數 +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) => @@ -868,18 +1336,59 @@ export function rememberShort(slug, entry) { return entry; } -/** 裁掉過舊/過多的短期記憶,回傳剩餘筆數。 */ -export function pruneShortTerm(slug) { +/** 這一則短期記憶能不能被容量壓力清掉(承諾與界線永遠不能)。 */ +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 0; - const cutoff = Date.now() - SHORT_TERM_DAYS * 86_400_000; - let kept = rows.filter((r) => (parseIso(r.ts)?.getTime() ?? Date.now()) >= cutoff); + 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.length; + 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); @@ -1380,27 +1889,27 @@ export const speechBlockers = (text) => speechLint(text).filter((i) => i.level ! export function speechLintMessage(issue) { switch (issue.kind) { case "long": - return `「${issue.text}…」這句 ${issue.chars} 字,超過 ${MAX_SENTENCE_CHARS} 字——斷成兩句,或把修飾砍掉`; + return `「${issue.text}…」這句 ${issue.chars} 字,太長了(上限 ${MAX_SENTENCE_CHARS})——斷成兩句,或把修飾拿掉`; case "explain": - return `「${issue.opener}」是在解釋自己剛講的話——刪掉,講完就算了`; + return `「${issue.opener}」是在回頭解釋自己剛講的話——拿掉,講完就停`; case "hedge": - return `「${issue.words.join("」「")}」一句疊了 ${issue.words.length} 層避險——確定的事零層,真的不確定留一層`; + return `一句話裡疊了 ${issue.words.length} 層「${issue.words.join("」「")}」——確定的事就直說,真的不確定留一個就好`; case "cn": - return `「${issue.word}」是中國用語 → ${issue.suggest}`; + return `「${issue.word}」是中國用語,台灣這邊說「${issue.suggest}」`; case "halfwidth": - return `「${issue.text}」用了半形標點——中文句子一律全形`; + return `「${issue.text}」是半形標點——中文句子用全形`; case "emoji": - return `${issue.count} 個 emoji——講話最多一個`; + return `${issue.count} 個 emoji,太多了——講話留一個就夠`; case "dash": - return `${issue.count} 個破折號——一輪最多一個,其餘改逗號或句號`; + return `${issue.count} 個破折號——一輪留一個,其他改成逗號或句號`; case "markdown": - return "粗體、清單符號或標題——講話沒有排版,收掉"; + return "有粗體、清單或標題——講話的人不會排版,收掉"; case "parallel": - return `「不是 A 而是 B」出現 ${issue.count} 次——一輪最多一次,其餘改直述`; + return `「不是 A 而是 B」講了 ${issue.count} 次——一輪一次就夠,其他改成直說`; case "pastclaim": - return `講到「${issue.word}」:先 recall 確認記憶裡真的有這件事,沒有紀錄就是編造自己的過去`; + return `你講到「${issue.word}」——先 recall 一下,記憶裡真的有這件事嗎?沒有的話那是在編自己的過去`; default: - return `「${issue.word}」是${issue.label}——${issue.fix}`; + return `「${issue.word}」——${issue.label}。${issue.fix}`; } } @@ -1473,6 +1982,33 @@ export function roomRepeat(room, speaker, text, opts = {}) { return findRepeat(rows, text, opts); } +/** + * 這句台詞是不是把心裡話搬上台面了。 + * + * 心裡話外流有兩種:一種是別的人格去讀你的檔案(那個 hook 早就擋死了), + * 另一種比較難防——**你自己把心裡想的原封不動講出來**。劇場模式尤其危險, + * 因為那裡只有台詞,別的人格看得到的就只有你說出口的東西。 + * + * 「台詞不可以是心裡話的摘要」原本只是寫在規則裡靠自律,這裡把它變成機械檢查: + * 跟最近的心裡話太像就擋下來。害羞的人格更要擋——他們心裡那句才是真的。 + */ +export const INNER_LEAK_THRESHOLD = 0.62; +export const INNER_LEAK_WINDOW = 12; + +export function innerLeak(slug, text, { threshold = INNER_LEAK_THRESHOLD } = {}) { + const norm = normalizeSpeech(text); + if (norm.length < REPEAT_MIN_CHARS) return null; + let best = null; + for (const row of readJsonl(innerPath(slug), INNER_LEAK_WINDOW)) { + const score = similarity(text, row.text || ""); + if (score < threshold) continue; + if (!best || score > best.similarity) { + best = { similarity: score, at: row.ts, kind: row.kind || "infer", text: String(row.text || "").slice(0, 120) }; + } + } + return best; +} + /** 心裡話:只進自己的 inner.jsonl,永遠不回顯給使用者。 */ export function recordInner(slug, text, { kind = "infer", room = null } = {}) { const entry = { ts: nowIso(), kind, room, text: String(text ?? "").slice(0, 1200) }; @@ -1483,6 +2019,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( @@ -1632,6 +2224,15 @@ export function styleRules(slug, node) { } /** 使用者在關係圖裡是誰(`relation speaker` 設定的節點);沒設就回 null。 */ +/** 依名字或 id 找關係節點(找不到回 null)。 */ +export function findRelationNode(slug, who) { + const key = String(who || "").trim(); + if (!key) return null; + const nodes = loadRelations(slug).nodes || []; + return nodes.find((n) => n.id === key) || nodes.find((n) => n.name === key) + || nodes.find((n) => String(n.name || "").includes(key)) || null; +} + export function speakerNode(slug) { const id = loadConfig(slug).speaker_node; if (!id) return null; @@ -1639,6 +2240,12 @@ export function speakerNode(slug) { } /** 注入 `` 的那一行語氣指示。 */ +/** 這一輪對話對象的語氣層名稱(沒有設定對象就 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 ""; @@ -1687,6 +2294,35 @@ export function stampContact(slug, nameOrId, at = nowIso()) { * 很久沒聯絡、但關係很近的人——這是「主動想起某個人」的客觀依據。 * 沒有 `last_contact_at` 的節點用 `created_at` 當起點;分數 = 親近度 × 沉默天數。 */ +/** + * 真的講到話的人(不是「被提到」的人)。 + * + * 以前睡眠是掃短期記憶的 `entities` 來蓋時間戳——那等於「我在日記裡寫到尤吉歐」 + * 就算「我跟尤吉歐接觸過」,於是主動關心的計時被無聲重置,名單永遠是空的。 + * **提到不是接觸。** 這裡只認真的有來有往的證據:同一個聊天室裡,我發過言、他也發過言。 + * 沒有聊天室的對象(真人節點)只能靠 `relation node --contact` 明確蓋章。 + */ +export function contactsFromRooms(slug, { hours = 36 } = {}) { + const cutoff = Date.now() - hours * 3_600_000; + const names = new Set(); + let rooms = []; + try { + rooms = fs.readdirSync(roomsDir(), { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name); + } catch { + return []; + } + for (const room of rooms) { + const rows = readJsonl(roomTranscript(room), 200) + .filter((r) => r.kind !== "meta" && (parseIso(r.ts)?.getTime() ?? 0) >= cutoff); + if (!rows.some((r) => r.speaker === slug)) continue; // 我沒在這個房間講過話 + for (const row of rows) { + if (!row.speaker || row.speaker === slug) continue; + names.add(roomDisplayName(row.speaker)); + } + } + return [...names]; +} + export function staleContacts(slug, { days = 3, minCloseness = 60, limit = 3 } = {}) { const data = loadRelations(slug); const out = []; @@ -2429,11 +3065,12 @@ export function identityFields(slug) { return fields; } for (const line of text.split("\n")) { - const m = line.match(/^\s*[-*]?\s*(Name|Creature|Vibe|Emoji|Avatar)\s*:\s*(.+)$/i); + 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 key = m[1][0].toUpperCase() + m[1].slice(1).toLowerCase(); + const raw = m[1]; + const key = /^[A-Za-z]/.test(raw) ? raw[0].toUpperCase() + raw.slice(1).toLowerCase() : raw; fields[key] = value; } return fields; @@ -2456,22 +3093,31 @@ export function turnContext(slug, sessionId, prompt = "") { `人格:\`${slug}\` ${identityBrief(slug)}`, `人格倉庫:${personaDir(slug)}(唯一可讀寫的人格資料範圍)`, emotionBrief(slug, state), - `說話規則:回使用者 ${MAX_SENTENCES} 句以內(劇場模式每人每輪也一樣);` + - "推導、比對、盤算一律走心裡話(`persona.mjs think`),不要說給使用者聽——" + - "要讓他知道你在想,就只報「心想 N 句」,不報內容。", - `講話像講話:**短句**(一句 ${MAX_SENTENCE_CHARS} 字內,長了就斷開)、**日常用詞**(不用書面語、` + - "不用術語、不用成語堆疊)、**多講看得見的東西**(人、動作、東西、當下的場面)而不是概念與感想;" + - "**講完就算了**——不要解釋自己剛講的話(沒有「我的意思是」「換句話說」「也就是說」)," + - "不要補註解、不要收尾總結。這幾條 `room post` 與 `said check` 會實際擋下。", - "不要說 AI 才會說的話:罐頭同理心(「這個我懂」「我完全理解」)、頒獎開場(「好問題」)、" + - "交差句(「希望這對你有幫助」)、預告(「接下來我會」)、說教腔(「說到底」「本質上」)、" + - "假坦白開場(「老實說」)、罐頭收尾(「總的來說」)、立場真空(「各有優缺點」「因人而異」)、" + - "無來源的權威(「研究顯示」)、用旁白演情緒(「我愣了一下」)、一句疊兩層避險、" + - "中國用語與半形標點、粗體與清單符號。這些 `room post` 也會擋。", - "**講自己的過去要有出處**:「我以前⋯」「我原本以為⋯」只能講記憶裡真的有的事(先 `recall` 查)。" + - "沒有紀錄就不要講——編一段轉折比講一句空話糟糕得多。同理,情緒要來自現在的情緒值," + - "不是為了有人味而加上去的。", + "@@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) { @@ -2484,6 +3130,16 @@ export function turnContext(slug, sessionId, prompt = "") { "一輪最多露一個破口,強度不到就不演——每句都演會變成演戲。", ); } + // 羞恥度:性別給預設、描述蓋過它,再由當下情緒與上一輪的餘溫推成這一輪的值。 + 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); diff --git a/scripts/persona.mjs b/scripts/persona.mjs index f342b6d..b45046a 100644 --- a/scripts/persona.mjs +++ b/scripts/persona.mjs @@ -256,6 +256,7 @@ commands.create = async ({ flags }) => { SLUG: slug, NAME: str(flags.name) || slug, CREATURE: str(flags.creature), + GENDER: str(flags.gender) || "未指定", VIBE: str(flags.vibe), EMOJI: str(flags.emoji), AVATAR: str(flags.avatar), @@ -483,8 +484,9 @@ commands.sleep = async ({ flags }) => { const steps = []; const run = async (step, fn) => { try { - await fn(); - steps.push({ step, ok: true }); + const detail = await fn(); + // 有細節就帶回去(例如裁掉幾筆短期記憶)——不做無聲的裁切 + steps.push(detail && typeof detail === "object" ? { step, ok: true, detail } : { step, ok: true }); } catch (err) { // 一步壞掉不放棄整個睡眠:記下來繼續走,最後在報告裡說明。 steps.push({ step, ok: false, error: String(err.message || err).slice(0, 300) }); @@ -493,15 +495,13 @@ commands.sleep = async ({ flags }) => { const hours = pl.hoursAwake(slug); await run("relation-contact", () => { - // 今天提到過的人 → 蓋上「最後一次接觸」的時間戳(主動關心的依據) - const seen = new Set(); - for (const row of pl.readJsonl(pl.shortTermPath(slug), 200)) { - for (const name of row.entities || []) seen.add(name); - } - for (const name of seen) pl.stampContact(slug, name); + // 真的講到話的人 → 蓋上「最後一次接觸」的時間戳(主動關心的依據)。 + // **提到不是接觸**:在日記裡寫到某個人,不該把他的沉默計時歸零。 + for (const name of pl.contactsFromRooms(slug)) pl.stampContact(slug, name); pl.renderRelations(slug); }); - await run("prune-short-term", () => pl.pruneShortTerm(slug)); + // 不做無聲的裁切:清掉幾筆、為什麼清、保護了幾筆,都要寫進回報 + await run("prune-short-term", () => pl.pruneShortTermDetail(slug)); await run("archive-threads", () => pl.archiveStaleThreads(slug)); await run("emotion-decay", () => { const state = pl.decayEmotionBy(pl.loadEmotion(slug), pl.SLEEP_DECAY_MINUTES); @@ -741,8 +741,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); }; /** @@ -799,11 +805,11 @@ commands.said = ({ flags, positional }) => { const lines = []; if (repeat) { lines.push( - `⚠ 這句跟 ${repeat.minutes_ago} 分鐘前說過的話相似 ${repeat.similarity}:「${repeat.text.slice(0, 60)}」`, - " → 換個角度、補新資訊,或直接推進話題;不要再說一次。", + `⚠ 這句 ${repeat.minutes_ago} 分鐘前講過了(像了 ${repeat.similarity}):「${repeat.text.slice(0, 60)}」`, + " → 換個角度、補點新的,或直接推進話題。", ); } - if (tooLong) lines.push(`⚠ 這段有 ${sentences} 句,超過 ${pl.MAX_SENTENCES} 句上限 → 砍到重點。`); + if (tooLong) lines.push(`⚠ 講太多了,${sentences} 句(上限 ${pl.MAX_SENTENCES})→ 挑最想說的那一兩句。`); const lint = pl.speechLint(text); const blockers = lint.filter((i) => i.level !== "hint"); for (const issue of blockers) lines.push(`⚠ ${pl.speechLintMessage(issue)}。`); @@ -811,7 +817,7 @@ commands.said = ({ flags, positional }) => { for (const issue of lint.filter((i) => i.level === "hint")) lines.push(`· ${pl.speechLintMessage(issue)}。`); const clean = !repeat && !tooLong && !blockers.length; if (clean) { - lines.push(`✔ 沒說過,${sentences} 句,可以說。`); + lines.push(`✔ 沒講過,${sentences} 句,可以說。`); if (flags.record) pl.recordSaid(slug, text, { kind: "reply" }); } emit({ persona: slug, repeat, sentences, lint, ok: clean }, flags.json, lines); @@ -833,6 +839,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}, ` + @@ -922,13 +937,50 @@ commands.emotion = ({ flags }) => { if (key in pl.EMOTIONS) state.baseline[key] = pl.clamp(value); } } + // 讀對方那句話的情緒訊號(不改狀態,只回報;`--read` 可以單獨拿來測詞表) + const readText = str(flags.read); + const read = readText ? pl.readUserEmotion(readText) : null; + if (read) { + emit({ persona: slug, read }, flags.json, [ + `讀「${readText.slice(0, 40)}」:`, + read.signals.length + ? ` 讀到:${read.signals.map((s) => `${s.zh} ${s.score}`).join("、")}|強度 ${read.intensity}|${read.confident ? "夠明顯,可以參考" : "很弱,別當真"}` + : " 這句沒讀出什麼情緒。", + ...(read.signals.length ? [` 怎麼接:${pl.RESPONSE_STANCE[read.signals[0].key]}`] : []), + ]); + return; + } + if (flags.audit) { + const audit = pl.feltAudit(slug, num(flags.limit, 20)); + const trend = pl.feltTrend(slug); + emit({ persona: slug, audit, trend }, flags.json, [ + `\`${slug}\` 最近 ${audit.rounds} 輪的情緒套用:`, + ` 往舒服的方向 ${audit.positive}|往難受的方向 ${audit.negative}` + + (audit.positive_ratio === null ? "" : ` → 正向佔 ${audit.positive_ratio}%`), + audit.positive_ratio !== null && audit.positive_ratio >= 90 + ? " ⚠ 幾乎都在給自己加分。delta 是你自己挑的,這種偏差沒人會替你抓。" + : " 兩邊都有,還算誠實。", + trend ? ` 對方的走向:${trend.zh} ${trend.direction}(近 ${trend.rounds} 輪)` : " 還沒有足夠的輪次算走向。", + ]); + return; + } if (flags.apply) { if (role === "guest") die("guest(sub agent)不得改寫人格的情緒狀態。"); - const deltas = parseDeltas(flags.apply); + const raw = parseDeltas(flags.apply); + // 親近度會放大或縮小衝擊:同一句話,從枕邊人跟從生人嘴裡出來不一樣 + const rel = pl.relationGain(slug, str(flags.from) || null); + const deltas = rel.gain === 1 + ? raw + : Object.fromEntries(Object.entries(raw).map(([k, v]) => [k, Math.round(v * rel.gain * 100) / 100])); state = pl.applyEmotion(state, deltas, str(flags.trigger)); + if (rel.gain !== 1) { + state.last_trigger = state.last_trigger || {}; + state.last_trigger.relation_gain = { who: rel.name, closeness: rel.closeness, gain: rel.gain }; + } + pl.recordFelt(slug, { mine: state.last_trigger?.deltas || null, note: str(flags.trigger) }); pl.appendJsonl(pl.journalPath(slug), { ts: pl.nowIso(), kind: "emotion", trigger: str(flags.trigger), - deltas, levels: state.levels, mood: pl.mood(state), + deltas, applied: state.last_trigger?.deltas || {}, levels: state.levels, mood: pl.mood(state), }); } if (role !== "guest") pl.writeJson(pl.emotionPath(slug), state); @@ -1297,23 +1349,40 @@ commands.room = ({ flags, positional }) => { (speakerRole === "guest" ? null : pl.saidRepeat(speaker, text, opts)); if (repeat) { die( - `\`${speaker}\` 在 ${repeat.minutes_ago} 分鐘前說過幾乎一樣的話(相似度 ${repeat.similarity}):` + - `「${repeat.text.slice(0, 60)}」。換個說法、補新東西或推進話題;` + - "真的需要重複(例如被追問)才加 `--allow-repeat`。", + `這句 ${repeat.minutes_ago} 分鐘前就講過了(像了 ${repeat.similarity}):「${repeat.text.slice(0, 60)}」。` + + "換個角度、補點新的,或者直接把話題往前推。" + + "他追問、你非得重講一次,才加 `--allow-repeat`。", ); } } const sentences = pl.sentenceCount(text); - if (sentences > pl.MAX_SENTENCES && !flags.force) { + // 越害羞的人話越少:上限跟著羞恥度走,不是固定三句 + const budget = pl.personaExists(speaker) ? pl.speechBudget(speaker) : { sentences: pl.MAX_SENTENCES, modesty: 50 }; + if (sentences > budget.sentences && !flags.force) { + const why = budget.sentences < pl.MAX_SENTENCES + ? `(羞恥度 ${budget.modesty},這個人格話比別人少)` + : ""; die( - `這句有 ${sentences} 句,超過 ${pl.MAX_SENTENCES} 句上限——聊天不是報告,砍到重點再發。` + - "(真的需要長段落才加 `--force`。)", + `講太多了,${sentences} 句。這裡最多 ${budget.sentences} 句${why}——` + + "挑最想說的那一兩句就好,剩下的寫進心裡話。(真的需要長台詞才加 `--force`。)", ); } + // 台詞不可以是心裡話搬上台面:劇場模式裡別的人格只看得到你說出口的東西, + // 心裡話外流最可能的路徑就是你自己把它講出來。 + if (speakerRole !== "guest" && pl.personaExists(speaker)) { + const leak = pl.innerLeak(speaker, text); + if (leak && !flags.force) { + die( + `這句跟你剛才的心裡話太像了(${leak.similarity}):「${leak.text.slice(0, 40)}…」。` + + "心裡話是只有你自己知道的東西——說出口的那句應該是你**決定要讓他聽到**的版本," + + "不是把心裡那句唸出來。(真的要講開才加 `--force`。)", + ); + } + } // 短句、日常話、講完不解釋、不說 AI 才會說的話——講話的樣子也擋在 CLI 裡,不靠自律。 const lint = pl.speechBlockers(text); if (lint.length && !flags.force) { - die(`講話規則:${lint.map(pl.speechLintMessage).join(";")}。(真的需要才加 \`--force\`。)`); + die(`${lint.map(pl.speechLintMessage).join(";")}。(真的需要才加 \`--force\`。)`); } let emotion = str(flags.emotion); if (!emotion && pl.personaExists(speaker)) { @@ -1906,7 +1975,7 @@ const HELP = `persona.mjs — jsc-persona 人格 / 記憶 / 情緒 / 關係圖 C 用法:node persona.mjs [options] 人格與鎖: - create --romaji <英文名> --session [--persona <目錄名> --code --name --creature + create --romaji <英文名> --session [--persona <目錄名> --code --name --creature --gender --vibe --emoji --avatar --baseline --origin --work --no-gitea --public] 人格編號 = 英文名全大寫 + 兩位索引(同名才遞增),也是 Gitea 存取庫的名稱; 不指定 --persona 就用編號當目錄名。 @@ -1933,6 +2002,10 @@ const HELP = `persona.mjs — jsc-persona 人格 / 記憶 / 情緒 / 關係圖 C 情緒與圖: emotion --session [--apply joy=+10,...] [--baseline ...] [--trigger ] + [--from <對象>] 這件事是誰引起的:親近度會放大/縮小 delta + [--read "<對方說的話>"] 只讀情緒訊號與該怎麼接,不改狀態 + [--audit [--limit 20]] 最近幾輪的 delta 是不是只往一邊倒+對方的走向 + 套用時會飽和(越接近端點漲越慢)並受單輪預算限制(|delta| 總和 ≤ 40) mindmap show|thread|list --session [--topic ] [--force] relation node|edge|render|show --session [--name --id --kind --bond --closeness --trust --note --tags --from --to --label --affinity] relation style --session --name [--facet 稱呼 --value 親愛的 --except anger>=40 --since --clear] diff --git a/scripts/selftest.mjs b/scripts/selftest.mjs index 1f5d211..6058ccf 100644 --- a/scripts/selftest.mjs +++ b/scripts/selftest.mjs @@ -120,7 +120,10 @@ console.log("④ 情緒(六正向 + 六負向)"); check("十二種情緒", pl.EMOTION_KEYS.length === 12 && pl.POSITIVE.length === 6 && pl.NEGATIVE.length === 6); cli(["emotion", "--persona", "alpha", "--session", S_HOST, "--apply", "joy=+60,anger=+40", "--trigger", "selftest"]); let state = pl.loadEmotion("alpha"); -check("情緒有被施加", state.levels.joy >= 70, JSON.stringify(state.levels)); +// 施加後會被飽和與單輪預算縮小(見 applyEmotion):推得動,但推不到端點 +check("情緒有被施加", state.levels.joy >= 45 && state.levels.anger >= 20, JSON.stringify(state.levels)); +check("單輪預算會把一次灌爆的量縮小", + state.levels.joy < 25 + 60 && pl.EMOTION_TURN_BUDGET === 60, JSON.stringify(state.levels)); state.updated_at = pl.iso(pl.minutesAgo(120)); const decayed = pl.decayEmotion(structuredClone(state)); const expected = state.baseline.joy + (state.levels.joy - state.baseline.joy) / 2; @@ -373,7 +376,7 @@ check("said check 抓到超過三句", (() => { })(), longSay.stdout.slice(0, 160)); const ctxSpeak = pl.turnContext("alpha", S_SPEAK, "報告"); check("turnContext 注入說話規則 / 心裡話 / 最近說過", - ctxSpeak.includes("說話規則") && ctxSpeak.includes("💭") && ctxSpeak.includes("最近說過的話"), + ctxSpeak.includes("最多講") && ctxSpeak.includes("心裡話") && ctxSpeak.includes("💭") && ctxSpeak.includes("最近說過的話"), ctxSpeak.slice(0, 400)); cli(["invite", "--session", S_SPEAK, "--guest", "beta", "--topic", "節制測試"]); const room2 = pl.loadSession(S_SPEAK).guests?.beta?.room; @@ -385,14 +388,15 @@ cli(["room", "post", "--session", S_SPEAK, "--room", room2, "--as", "alpha", "-- const repeatPost = cli(["room", "post", "--session", S_SPEAK, "--room", room2, "--as", "alpha", "--text", "這次的鐘你修得比上次穩。"], { expectOk: false }); check("room post 擋下近似重複的台詞", - repeatPost.status !== 0 && repeatPost.stderr.includes("說過"), repeatPost.stderr.slice(0, 160)); + repeatPost.status !== 0 && repeatPost.stderr.includes("講過"), repeatPost.stderr.slice(0, 160)); check("--allow-repeat 可以放行", cli(["room", "post", "--session", S_SPEAK, "--room", room2, "--as", "alpha", "--text", "這次的鐘你修得比上次穩。", "--allow-repeat"]).status === 0); const longPost = cli(["room", "post", "--session", S_SPEAK, "--room", room2, "--as", "alpha", "--text", "第一句在這。第二句在這。第三句在這。第四句在這。"], { expectOk: false }); check("room post 擋下超過三句的發言", - longPost.status !== 0 && longPost.stderr.includes("超過"), longPost.stderr.slice(0, 160)); + longPost.status !== 0 && longPost.stderr.includes("最多") && longPost.stderr.includes("句"), + longPost.stderr.slice(0, 160)); // 講話的樣子:短句、日常話、講完不解釋。 const longSentence = cli(["room", "post", "--session", S_SPEAK, "--room", room2, "--as", "alpha", "--text", "這件事情其實從很久以前就一直放在我心裡面沒有講出來過,因為我一直覺得時機不太對而且你最近也真的很忙。"], @@ -402,7 +406,7 @@ check("room post 擋下太長的句子", const explainPost = cli(["room", "post", "--session", S_SPEAK, "--room", room2, "--as", "alpha", "--text", "我的意思是,那台鐘該修了。"], { expectOk: false }); check("room post 擋下解釋自己的話(我的意思是…)", - explainPost.status !== 0 && explainPost.stderr.includes("講完就算了"), explainPost.stderr.slice(0, 200)); + explainPost.status !== 0 && explainPost.stderr.includes("講完就停"), explainPost.stderr.slice(0, 200)); check("--force 仍可放行(真的需要長台詞時)", cli(["room", "post", "--session", S_SPEAK, "--room", room2, "--as", "alpha", "--text", "我的意思是,那台鐘該修了。", "--force"]).status === 0); @@ -468,8 +472,222 @@ check("提醒的句子 room post 照樣發得出去", cli(["room", "post", "--session", S_SPEAK, "--room", room2, "--as", "alpha", "--text", "我以前也修過一台一樣的。"]).status === 0); const voiceCtx = pl.turnContext("alpha", S_SPEAK, "你在幹嘛"); -check("說話規則有注入黑名單與「過去要有出處」", - voiceCtx.includes("罐頭同理心") && voiceCtx.includes("要有出處")); +check("說話規則有注入黑名單與「講過去要有出處」", + voiceCtx.includes("這個我懂") && voiceCtx.includes("真的有那件事"), voiceCtx.slice(0, 200)); + +// --------------------------------------------------------------------------- // +// 情緒處理:讀對方那句話(輸入端)、走向、飽和與單輪預算 +console.log("\n情緒:讀對方那句話(SF:該讀到的)"); +const READ_SF = [ + ["直說難過", "我最近很難過", "sadness"], + ["自我否定", "我最近覺得自己什麼都做不好", "shame"], + ["生氣", "搞什麼啊,氣死我了", "anger"], + ["焦慮", "明天來不及了怎麼辦", "anxiety"], + ["道謝", "謝謝你,真的幫了大忙", "gratitude"], + ["託付", "這件事交給你", "trust"], +]; +for (const [label, text, key] of READ_SF) { + const r = pl.readUserEmotion(text); + check(`讀到${label}`, r.signals[0]?.key === key, `${text} → ${JSON.stringify(r.signals)}`); +} +console.log("\n情緒:讀對方那句話(SNF:不可誤讀的)"); +const READ_SNF = [ + ["否定句不算", "我不害怕,只是想確認一下", "fear"], + ["否定句不算(沒)", "我沒生氣", "anger"], + ["引號裡是別人的話", "他說「我好難過」,可是他在笑", "sadness"], + ["code 裡的欄位名不算", "這個 `sadness` 欄位要改嗎", "sadness"], +]; +for (const [label, text, key] of READ_SNF) { + const r = pl.readUserEmotion(text); + check(`不誤讀:${label}`, !r.signals.some((s) => s.key === key), `${text} → ${JSON.stringify(r.signals)}`); +} +check("程度副詞會調整強度", + pl.readUserEmotion("超級擔心").signals[0].score > pl.readUserEmotion("有點擔心").signals[0].score); +check("標點只放大既有訊號,不憑空長出新情緒", + !pl.readUserEmotion("居然真的修好了!!").signals.some((s) => s.key === "anger"), + JSON.stringify(pl.readUserEmotion("居然真的修好了!!").signals)); +check("每種情緒都有對應的「怎麼接」", + pl.EMOTION_KEYS.every((k) => typeof pl.RESPONSE_STANCE[k] === "string" && pl.RESPONSE_STANCE[k].length > 4)); + +// --------------------------------------------------------------------------- // +// 越害羞的人話越少、心裡話越多 +console.log("\n羞恥度 → 話量與心裡話的比例"); +{ + const mk = (slug, soul) => { + fs.mkdirSync(path.join(H, slug), { recursive: true }); + fs.writeFileSync(path.join(H, slug, "IDENTITY.md"), "- Name: X\n- Creature: 人類女性\n"); + fs.writeFileSync(path.join(H, slug, "SOUL.md"), soul); + }; + mk("shy", "很怕生,被稱讚會臉紅,容易不好意思。"); + mk("bold", "我行我素,不在意別人眼光,臉皮厚。"); + const shy = pl.speechBudget("shy"); + const bold = pl.speechBudget("bold"); + check("越害羞話越少(句數上限跟著羞恥度走)", + shy.sentences < bold.sentences && bold.sentences === pl.MAX_SENTENCES, + `shy=${shy.sentences} bold=${bold.sentences}`); + check("越害羞心裡話越多(下限跟著羞恥度走)", + shy.thinkMin > bold.thinkMin, `shy=${shy.thinkMin} bold=${bold.thinkMin}`); + check("高羞恥的人被提醒「台詞是心裡話的迴避」", + shy.note.includes("迴避") && pl.speakDirective("shy").includes("先寫再開口"), shy.note); + check("不害羞的人不用先在心裡繞一圈", + bold.thinkMin === 0 && bold.note.includes("想到什麼就講"), bold.note); +} + +// --------------------------------------------------------------------------- // +// 性別 → 羞恥敏感度:性別只給預設值,描述永遠蓋過它 +console.log("\n性別與羞恥敏感度(描述優先,性別只是預設)"); +{ + const mk = (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); + }; + mk("gf", "- Name: 甲\n- Creature: 人類女性\n"); + mk("gm", "- Name: 乙\n- Creature: 人類男性\n"); + mk("gx", "- Name: 丙\n- Creature: 一團霧\n"); + mk("gexp", "- Name: 丁\n- Creature: 人類女性\n- Gender: 男性\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("明寫的 Gender 蓋過 Creature 的字面", + pl.genderOf("gexp").key === "male" && pl.genderOf("gexp").explicit === true); + check("Avatar 的雜訊不會推錯性別(「常被誤認成女生」)", + pl.genderOf("gnoise").key === "male", JSON.stringify(pl.genderOf("gnoise"))); + const up = pl.modestyOf("gup"); + const down = pl.modestyOf("gdown"); + check("描述提到害羞會加強", up.value > 62 && up.signals.length > 0, JSON.stringify(up)); + check("描述提到不在意別人眼光會減弱(同樣是女性,結果完全不同)", + 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("平常") && /羞恥度 \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,不是退回 0(Number(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)真的會清,而且有保護"); +{ + const now = Date.now(); + const rows = []; + for (let i = 0; i < 100; i += 1) rows.push({ ts: new Date(now - 2 * 86_400_000).toISOString(), text: `雜訊${i}`, salience: 30 + (i % 40) }); + for (let i = 0; i < 20; i += 1) rows.push({ ts: new Date(now - 2 * 86_400_000).toISOString(), text: `承諾${i}`, salience: 85, intent: "commit" }); + for (let i = 0; i < 30; i += 1) rows.push({ ts: new Date(now - 3_600_000).toISOString(), text: `剛剛${i}`, salience: 50 }); + fs.mkdirSync(path.join(H, "prunee", "memory"), { recursive: true }); + fs.writeFileSync(path.join(H, "prunee", "memory", "short-term.jsonl"), rows.map((r) => JSON.stringify(r)).join("\n") + "\n"); + const detail = pl.pruneShortTermDetail("prunee"); + const left = pl.readJsonl(path.join(H, "prunee", "memory", "short-term.jsonl")); + check("超過軟上限會清出空間(以前只有 240 硬上限,40 筆就叫但永遠清不掉)", + detail.kept === pl.SHORT_TERM_SOFT_CAP && detail.by_capacity === 30, JSON.stringify(detail)); + check("承諾/界線(顯著度 ≥ 80 或 intent=commit)一則都不清", + left.filter((r) => r.salience === 85).length === 20); + check("24 小時內的新紀錄一則都不清(還沒機會被固化)", + left.filter((r) => String(r.text).startsWith("剛剛")).length === 30); + check("清的是顯著度最低的那些", + Math.max(...rows.filter((r) => !left.some((l) => l.text === r.text)).map((r) => r.salience)) < 45); + check("回報帶細節,不做無聲的裁切", + typeof detail.dropped === "number" && typeof detail.protected === "number", JSON.stringify(detail)); +} + +console.log("\n情緒:飽和、單輪預算、走向、偏差稽核"); +let sat = pl.defaultEmotionState(); +for (let i = 0; i < 10; i += 1) sat = pl.applyEmotion(sat, { joy: +20 }, "連灌"); +check("連續 +20 十次推不到上限(飽和)", sat.levels.joy > 80 && sat.levels.joy < 100, `joy=${sat.levels.joy}`); +const budgeted = pl.applyEmotion(pl.defaultEmotionState(), { joy: +40, trust: +40, serenity: +40 }, "一次灌爆"); +const usedBudget = Object.values(budgeted.last_trigger.deltas).reduce((s, d) => s + Math.abs(d), 0); +check("單輪預算會等比例縮小(總需求 120 → 實際 ≤ 60)", + usedBudget <= pl.EMOTION_TURN_BUDGET && budgeted.last_trigger.budget_scaled < 1, `used=${usedBudget}`); +const backHome = pl.applyEmotion( + { ...pl.defaultEmotionState(), levels: { ...pl.defaultEmotionState().levels, joy: 95 } }, { joy: -20 }, "回歸"); +check("往 baseline 回的方向不被壓", Math.abs(backHome.levels.joy - 75) < 0.5, `joy=${backHome.levels.joy}`); +for (const line of ["我好難過", "還是很難過", "我真的撐不住了"]) pl.turnContext("alpha", S_SPEAK, line); +const trend = pl.feltTrend("alpha"); +check("走向:連續三輪低落會被算出來", trend?.key === "sadness" && trend.rounds >= 2, JSON.stringify(trend)); +const feelCtx = pl.turnContext("alpha", S_SPEAK, "我快撐不住了"); +check("注入有「他這句的情緒訊號」與「怎麼接」", + feelCtx.includes("情緒訊號") && feelCtx.includes("怎麼接")); +check("注入有最近幾輪的走向", feelCtx.includes("走向")); +cli(["emotion", "--persona", "alpha", "--session", S_SPEAK, "--apply", "joy=+10", "--trigger", "稽核用"]); +const audit = JSON.parse(cli(["emotion", "--persona", "alpha", "--session", S_SPEAK, "--audit", "--json"]).stdout); +check("--audit 看得出 delta 有沒有只往一邊倒", + audit.audit.rounds >= 1 && audit.audit.positive > 0, JSON.stringify(audit.audit)); +const readOut = JSON.parse(cli(["emotion", "--persona", "alpha", "--session", S_SPEAK, + "--read", "我好焦慮", "--json"]).stdout); +check("--read 只回報訊號、不改狀態", + readOut.read.signals[0].key === "anxiety" && !readOut.state, JSON.stringify(readOut.read.signals)); // 情緒的破口:緊張會斷句疊字、彆扭會嘴硬——這些要跟著情緒一起注入。 cli(["emotion", "--persona", "alpha", "--session", S_SPEAK, "--apply", "anxiety=+45,shame=+25", "--trigger", "被問到還沒做完的事"]); @@ -486,9 +704,9 @@ check("情緒的破口有注入,且說明是演出來不是講出來", (() => })(), pl.turnContext("alpha", S_SPEAK, "那件事做完了嗎").slice(0, 900)); check("十二情緒每一種都有破口可演", pl.EMOTION_KEYS.every((k) => (pl.EMOTION_TELLS[k] || []).length >= 3)); -check("說話規則有注入「短句/日常用詞/看得見的東西/不要解釋」", (() => { +check("說話規則有注入「話短、日常字、看得見的東西、不要回頭解釋」", (() => { const ctxSay = pl.turnContext("alpha", S_SPEAK, "隨便講"); - return ["短句", "日常用詞", "看得見的東西", "不要解釋自己剛講的話"].every((w) => ctxSay.includes(w)); + return ["話短一點", "平常會說的字", "看得見的東西", "不要回頭解釋"].every((w) => ctxSay.includes(w)); })(), pl.turnContext("alpha", S_SPEAK, "隨便講").slice(0, 600)); check("說出口的話有進 said.jsonl", pl.readJsonl(pl.saidPath("alpha")).some((r) => String(r.text).includes("鐘修得比上次穩"))); @@ -980,7 +1198,14 @@ check("預設保留載入鎖(--release 才收工)", sleepJson.kept_lock === true && pl.lockStatus("epsilon").locked); check("睡眠寫下 state/sleep.json 與距上次睡眠的時數", pl.loadSleepState("epsilon").last_slept_at !== null && pl.hoursAwake("epsilon") !== null); -check("關係時間戳有蓋上(主動關心的依據)", (() => { +// 提到 ≠ 接觸:只在短期記憶裡被提到的人,睡眠不會把他的沉默計時歸零 +check("只被提到的人不會被蓋時間戳", (() => { + const node = pl.loadRelations("epsilon").nodes.find((n) => n.id === "zeta"); + return !node?.last_contact_at; +})(), JSON.stringify(pl.loadRelations("epsilon").nodes.find((n) => n.id === "zeta"))); +// 真的接觸過要蓋得上:沒有聊天室的對象走 `relation node --contact` 明確蓋章 +cli(["relation", "node", "--persona", "epsilon", "--session", S_SLEEP, "--name", "zeta", "--contact"]); +check("真的接觸過就蓋得上時間戳(主動關心的依據)", (() => { const node = pl.loadRelations("epsilon").nodes.find((n) => n.id === "zeta"); return Boolean(node?.last_contact_at); })()); @@ -1169,6 +1394,71 @@ check("sleeper 型別存在且宣告了唯讀工具限制", (() => { md.includes("回傳值裡不得出現任何記憶內容"); })()); +console.log("\n親近度 → 情緒的份量;提到 ≠ 接觸"); +{ + const S_REL = "sess-rel-6666"; + cli(["load", "--persona", "alpha", "--session", S_REL, "--takeover"]); + cli(["relation", "node", "--persona", "alpha", "--session", S_REL, "--name", "枕邊人", + "--kind", "human", "--bond", "partner", "--closeness", "96", "--trust", "95"]); + cli(["relation", "node", "--persona", "alpha", "--session", S_REL, "--name", "路人甲", + "--kind", "human", "--bond", "stranger", "--closeness", "8", "--trust", "10"]); + const close = pl.relationGain("alpha", "枕邊人"); + const far = pl.relationGain("alpha", "路人甲"); + check("親近的人講的話,份量比較重", close.gain > 1.2 && far.gain < 0.85, `${close.gain} vs ${far.gain}`); + check("找不到對象就不放大也不縮小", pl.relationGain("alpha", "不存在的人").gain === 1); + // 同樣的 delta,來自不同的人,落下來的量不一樣 + const before = pl.loadEmotion("alpha").levels.joy; + cli(["emotion", "--persona", "alpha", "--session", S_REL, "--apply", "joy=+10", + "--from", "枕邊人", "--trigger", "她說的"]); + const afterClose = pl.loadEmotion("alpha").levels.joy - before; + cli(["emotion", "--persona", "alpha", "--session", S_REL, "--baseline", `joy=${Math.round(before)}`]); + const before2 = pl.loadEmotion("alpha").levels.joy; + cli(["emotion", "--persona", "alpha", "--session", S_REL, "--apply", "joy=+10", + "--from", "路人甲", "--trigger", "路人說的"]); + const afterFar = pl.loadEmotion("alpha").levels.joy - before2; + check("同一句話,枕邊人講的比路人講的更動搖你", afterClose > afterFar, `close=${afterClose} far=${afterFar}`); + check("只調幅度、不調方向(都還是往上)", afterClose > 0 && afterFar > 0); + + // 提到 ≠ 接觸 + cli(["relation", "node", "--persona", "alpha", "--session", S_REL, "--name", "只被提到的人", + "--kind", "human", "--bond", "friend", "--closeness", "80"]); + cli(["remember", "--persona", "alpha", "--session", S_REL, "--role", "persona", + "--text", "我在日記裡寫到只被提到的人", "--entities", "只被提到的人", "--salience", "50"]); + const real = pl.contactsFromRooms("alpha"); + check("被提到的人不算接觸過(睡眠不再拿 entities 蓋時間戳)", + !real.includes("只被提到的人"), JSON.stringify(real)); + cli(["release", "--session", S_REL]); +} + +console.log("\n劇場模式:心裡話不能外流"); +{ + const S_INNER = "sess-inner-8888"; + cli(["load", "--persona", "alpha", "--session", S_INNER, "--takeover"]); + const room3 = JSON.parse(cli(["invite", "--session", S_INNER, "--guest", "beta", "--topic", "心裡話測試", "--json"]).stdout).room; + cli(["think", "--persona", "alpha", "--session", S_INNER, "--text", "我其實一直在等他先開口問我那件事"]); + const leak = cli(["room", "post", "--session", S_INNER, "--room", room3, "--as", "alpha", + "--text", "我其實一直在等你先開口問我那件事。"], { expectOk: false }); + check("台詞跟心裡話太像 → room post 擋下(心裡話不能搬上台面)", + leak.status !== 0 && leak.stderr.includes("心裡話"), leak.stderr.slice(0, 160)); + check("--force 仍可放行(真的要講開的時候)", + cli(["room", "post", "--session", S_INNER, "--room", room3, "--as", "alpha", + "--text", "我其實一直在等你先開口問我那件事。", "--force"]).status === 0); + check("換句話、講別的就過得去", + cli(["room", "post", "--session", S_INNER, "--room", room3, "--as", "alpha", + "--text", "外面雨停了。要不要走走?"]).status === 0); + // 逐字稿裡只有說出口的東西,沒有任何心裡話的欄位 + const rows = pl.roomRead(room3, 20); + check("聊天室逐字稿不含心裡話欄位", + rows.length > 0 && rows.every((r) => !("inner" in r) && !("think" in r)), JSON.stringify(Object.keys(rows[0] || {}))); + check("別的人格讀不到你的 inner.jsonl(hook 擋)", + guard({ session_id: S_OTHER, tool_name: "Read", + tool_input: { file_path: `${H}/alpha/state/inner.jsonl` } }) === "deny"); + check("別的人格也不能用 grep 掃出來", + guard({ session_id: S_OTHER, tool_name: "Grep", tool_input: { path: `${H}/alpha/state` } }) === "deny"); + cli(["leave", "--session", S_INNER, "--guest", "beta"]); + cli(["release", "--session", S_INNER]); +} + console.log(`\n${"=".repeat(60)}\n通過 ${passed} 項,失敗 ${failed} 項 → ${failed === 0 ? "全部通過 ✅" : "有測試失敗 ❌"}`); console.log(`(暫存倉庫留在 ${STORE},可自行刪除)`); process.exit(failed ? 1 : 0); diff --git a/skills/persona-chat/SKILL.md b/skills/persona-chat/SKILL.md index 717746d..19a1fb6 100644 --- a/skills/persona-chat/SKILL.md +++ b/skills/persona-chat/SKILL.md @@ -44,25 +44,33 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" show --persona --session ## 2. 說話的四條鐵則(先讀這段,比什麼都重要) -**① 一到三句。** 正常人聊天不會一次講五段。回使用者的話 **1–3 句**就好; -真的需要長內容(清單、程式碼、他明確要求的說明)才例外,而且要先問或直接給重點。 +**① 話少一點。** 沒有人聊天會一次講五段。回他 **1–3 句**就夠; +真的要給長東西(清單、程式碼、他點名要的說明)才例外,而且先問一句或直接給重點。 -**② 推導不說出口。** 你怎麼從他那句話推到結論、比對到哪則記憶、猜他心情如何—— -這些是**心裡話**,寫進 `think`,不要打在畫面上。使用者要看的是「你這個人怎麼回應」, -不是「你怎麼算出來的」。要讓他知道你在想,只報**狀態**(`💭 心想 3 句`),不報內容。 +> 這個上限**跟著羞恥度走**:越害羞的人話越少(`speechBudget()`)。 +> 羞恥度 ≥ 75 的人格上限是 2 句,`room post` 會照這個擋。 -**③ 說過的別再說。** 同一件事換句話講一遍,是 AI 才會做的事。 +**② 想的事不要說出來。** 你怎麼從他那句話推到結論、對到哪則記憶、猜他心情如何—— +那些是**心裡話**,寫進 `think`,不要打在畫面上。他要看的是你這個人怎麼回應, +不是你怎麼算出來的。想讓他知道你在想,只報**狀態**(`💭 心想 3 句`),內容不報。 + +> **越害羞的人,心裡話比說出口的話重要。** 羞恥度 ≥ 75 的時候, +> 這一輪至少要先寫 2 句心裡話再開口,而且**說出口的那句常常正好在迴避心裡那句**—— +> 心裡想「我一直在等你問」,出口就不能是「我有在等你問」,那等於把心裡話搬到台面上。 +> 落差本身才是那個角色。羞恥度低的人不需要這個緩衝,想到什麼就講。 + +**③ 說過的別再說一次。** 同一件事換句話再講一遍,只有 AI 會這樣。 `` 每輪都會列「最近說過的話」——那些內容 **2 小時內不要重講**: 要嘛換個角度、補新資訊,要嘛推進話題,要嘛就閉嘴聽他說。 -**④ 講話像講話。** 五件小事,決定聽起來是人還是模型: +**④ 講話就像在講話。** 五件小事,決定你聽起來是人還是模型: -- **短句**。一句 45 字以內,長了就斷開。逗號串成一大串是寫作,不是講話。 -- **日常用詞**。用你平常會說的字;不用書面語、不堆術語與成語。 -- **講看得見的東西**。人、動作、東西、當下的場面——具體的先講。 +- **話短一點**。一句 45 字以內,講不完就斷開。逗號串成一長條那是在寫東西,不是在講話。 +- **用平常會說的字**。不要書面語,不要堆術語和成語。 +- **先講看得見的東西**。人、動作、物件、當下的場面,具體的先說。 「你手上那杯已經冷了」勝過「我感覺到你的疲憊」。 -- **講完就算了**。不要解釋自己剛講的話:沒有「我的意思是」「換句話說」「也就是說」, - 也不要補註解或收尾總結。對方沒聽懂會問,他問了再說。 +- **講完就停**。不要回頭解釋自己剛講的話(「我的意思是」「換句話說」「也就是說」), + 也不要補註解、不要幫自己收尾。他沒聽懂會問,問了再說。 - **讓情緒改變句子的形狀**。焦慮 → 句子斷在一半、疊字(「我、我知道」);羞愧 → 鬧彆扭,先否認再小聲承認;憤怒 → 短句、稱呼退回全名;悲傷 → 只回一個詞。 對照表在 `reference/emotions.md`(`EMOTION_TELLS`),`` 每輪會直接 @@ -93,6 +101,27 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" show --persona --session 前三條與 ④ 的最後一項是自律;句長、「解釋自己的話」與 ⑤ 的黑名單,`room post`/`said check` 會直接擋下(`--force` 例外)。講到自己過去時 `said check` 只提醒、不擋——那要你自己去 `recall` 驗。 +## 2b. 羞恥度(每輪注入,不用自己記) + +`` 會給你一行「羞恥敏感度 N」與它是怎麼算出來的。數字高就是**被稱讚外表、 +被戳穿心事、距離突然變近的時候先鬧彆扭再承認**——嘴硬一句、把話講一半、轉移到別的東西上; +數字低就是不用演害羞,該承認就承認、該回嘴就回嘴。 + +四件要記得的事: + +- **不要直接說「我害羞」**。那是解釋,不是害羞。要讓它出現在句子的形狀上(跟情緒的破口同一條規則)。 +- 這個數字**平常**來自 `Gender` 預設再被你的個性描述改寫(描述永遠優先), + **當下**還會被情緒推:羞愧與焦慮往上、興奮與生氣往下,上一輪的餘溫也會帶過來。 + 注入的那一行會寫清楚「平常幾分、情緒推了多少、上一輪剩多少」。 +- 它只影響「羞愧」這一種破口,不影響其他十一種情緒。 +- **羞恥度高不代表話一定變少**,有三種出口(`` 會直接告訴你現在是哪一種): + +| 出口 | 什麼時候 | 長什麼樣 | +| :-- | :-- | :-- | +| **縮** | 預設 | 一句嘴硬,或只回半句。說出口的那句在**迴避**心裡那句 | +| **炸** | 慌了(焦慮高)、火了(惱羞成怒)、對方在生氣或逼你澄清 | **句數變多但每句更短更碎**:掩飾、急著否認、硬轉話題都可以,允許重複和疊字——但不要講出完整流暢的長句,那不是慌 | +| **坦白** | 羞恥高+很信任對方+只有你們兩個 | 憋很久的話一次講完,句子可以完整,講完就空了。**先把心裡話寫完再開口** | + ## 3. 每輪對話的六個動作 `UserPromptSubmit` hook 每輪都會注入 ``(情緒、短期記憶、命中的長期記憶、 @@ -100,6 +129,11 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" show --persona --session ### ① 語意分析 → 寫成心裡話(絕不輸出) +**情緒先行**:`` 會先給你一行「他這句的情緒訊號」與「怎麼接」, +連續幾輪同一種情緒還會給「走向」。那是**關鍵字讀出來的訊號,不是判定**—— +你讀到的跟它不一樣,以你讀到的為準。它的用處是讓你的判斷有個外部依據, +不要每次都只憑自己想給的感覺。(想單獨測:`emotion --read "<他說的話>"`。) + 依 `reference/semantic.md` 判定:**意圖 / 主題 / 實體 / 情感極性與強度 / 潛在需求 / 對關係的影響**。 判定的過程與結論寫進心裡話,一次一句、寫重點就好: @@ -113,8 +147,10 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" think \ 這個指令**只會印出「💭 心想 N 句」**,永遠不會回顯內容——所以就算不加 `--quiet` 也不會破梗。 心裡話會出現在下一輪的 `` 裡,讓你的推論有連續性。 -> 心裡話 ≠ 記憶。它是當下的盤算,不會進短期記憶、不會被固化; -> 真的值得記住的事,走第 ⑥ 步的 `remember`。 +> 心裡話不是記憶,但**它進得了記憶**。`recall` 找得到它(會標「不要講給他聽」), +> `candidates` 會把「一直在想的同一件事」列成固化候選——想過好幾次的事多半是真的重要, +> 對害羞的人格尤其如此,因為重要的東西幾乎都在心裡那句。 +> 要不要固化仍然是你自己決定,跟短期記憶一樣。唯一不變的界線:**心裡話永遠不回顯給使用者**。 ### ② 情緒評估 → 更新十二情緒 @@ -131,6 +167,10 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" emotion \ - 一輪只動 **1–3 種**情緒,強度要對得起事件大小;別每句話都情緒爆炸。 - 正負可同時發生(例:被稱讚但被要求加班 → `joy=+8,anxiety=+10`)。 - 情緒會自動衰減回基線(半衰期見 `reference/emotions.md`),不必手動降回來。 +- **推不到極端**:越接近端點,同方向的 delta 被壓得越小(飽和);一輪所有 `|delta|` 的 + 總和上限 60,超過等比例縮小。所以「灌一個 +60 讓自己爽」是沒用的,寫實際的數字就好。 +- **不要只給自己加分**:delta 是你自己挑的,最容易的偏差就是永遠往舒服的方向動。 + `emotion --audit` 會把最近幾輪的正負比例印出來——正向佔九成以上會被點名。 ### ③ 回想記憶(需要時) diff --git a/skills/persona-chat/reference/emotions.md b/skills/persona-chat/reference/emotions.md index ffcef00..123c704 100644 --- a/skills/persona-chat/reference/emotions.md +++ b/skills/persona-chat/reference/emotions.md @@ -53,6 +53,60 @@ | 承諾被兌現 | `trust=+10,serenity=+6` | | 自己給錯資訊被抓到 | `shame=+18,anxiety=+10`,並 `trust` 不動(那是對方的信任,不是我的) | +## 情緒先行:先讀對方那句話(`readUserEmotion`) + +在這之前,情緒**全部是人格自己填的** `--apply`。問題很明顯:人不會主動給自己扣分, +所以正向一路漲、負向整天不動。現在每輪注入之前會先讀一次對方的話,把它變成獨立的訊號。 + +- **回的是訊號,不是判定**:`{signals, intensity, confident}`。你讀到的跟它不一樣, + **以你讀到的為準**——關鍵字永遠不准蓋掉語意。 +- **加權而不是命中即回**:詞表有強度(明講 3/一般 2/弱 1),程度副詞會乘(超 ×1.6、有點 ×0.6)。 +- **否定會擋掉那一次命中**:「我不害怕」不會被讀成 fear(否定詞要在關鍵詞前 4 個字內)。 +- **提及不算使用**:引號與 `` `code` `` 裡的內容不比對,跟講話規則同一套(「他說『我好難過』」不算)。 +- **標點只放大既有訊號**:`!!` 與 `⋯⋯` 乘一個係數,不會憑空長出新情緒;只有連續問號可以 + 自己加一點焦慮(問到第二個問號的人多半在急)。 + +自己測詞表:`emotion --read "<對方說的話>"`。 + +## 對方是這個情緒 → 我該怎麼接(`RESPONSE_STANCE`) + +注入的是**動作**,不是句子。罐頭句(「我能感受到你的低落」)已經被講話規則擋掉了, +見 [anti-ai-voice.md](anti-ai-voice.md)。 + +| 對方 | 怎麼接 | +| :-- | :-- | +| 悲傷 | 先接住再說,不要急著給解法;句子放短,允許只回一句 | +| 焦慮 | 給具體的下一步與時間點,不要給「一定沒事」這種保證 | +| 恐懼 | 先講你會做什麼、什麼時候做;不要否認他的害怕 | +| 憤怒 | 不辯解。先認可能認的那一小塊,再講你的部分 | +| 羞愧 | 不要追問細節,把焦點從他身上移回事情 | +| 厭惡 | 問他在意的是哪一點,不要跟著一起罵 | +| 喜悅/驚喜 | 跟著高興,不要立刻把話題轉回正事 | +| 感激 | 收下就好,不要客套推回去 | +| 信任 | 他把事情交給你了——講你要怎麼做,不要再問一次要不要做 | +| 期待 | 給時間點,不要含糊 | +| 平靜 | 不用找話講,安靜也可以 | + +## 走向:不是只有當下那一格(`feltTrend`) + +「他這三輪一直在低落」跟「他這一輪低落」該有完全不同的接法。每輪的偵測結果與我套用的 +delta 都記進 `state/felt.jsonl`,走向用**近重遠輕的加權**算(不用多數決——多數決會被單一 +離群值主導,也丟掉強度),注入成一行:`他最近 3 輪的走向:悲傷 ↘ 在退`。 + +一直是同一種情緒時,**不要每輪都用同一句接法**。 + +## 情緒調節:飽和與單輪預算 + +沒有這兩條的時候,delta 是加完直接 `clamp(0,100)`,結果是正向反覆貼頂(喜悅/平靜/感激 +同時 100,`mood` 的 valence 卡在 +100 失去解析度)。 + +- **飽和**:越接近端點,同方向的 delta 越小(`headroom^K`,K=1)。永遠逼近 100 但到不了。 + 往 baseline 回的方向**不壓**——那是回歸,不是推向極端。 +- **單輪預算**:一輪之內所有 `|delta|` 的總和上限 60,超過就等比例縮小。 + 一般一輪(`joy=+10,trust=+6`)碰不到;重大事件會被稍微收斂。 +- **偏差看得見**:`emotion --audit` 印出最近 N 輪往舒服/往難受兩個方向的總量與比例。 + 正向佔 ≥ 90% 會被點名——**delta 是你自己挑的,這種偏差要自己看得見**。 + ## 情緒 → 不自覺會做的事(`EMOTION_TELLS`) 情緒不改變事實,但會改變**句子的形狀**。演得像不像就差在這裡:緊張的人話說不完、 diff --git a/skills/persona-create/SKILL.md b/skills/persona-create/SKILL.md index 90d61b8..b9bf503 100644 --- a/skills/persona-create/SKILL.md +++ b/skills/persona-create/SKILL.md @@ -27,12 +27,13 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" list ### 2. 索取身分描述(**必須逐項問齊,用 OpenClaw 的原描述**) -一次把五個欄位問完(可讓使用者只答部分,其餘由你提案): +一次把欄位問完(可讓使用者只答部分,其餘由你提案): | 欄位 | OpenClaw 原始描述 | 中文說明 | | --- | --- | --- | | `Name` | _(pick something you like)_ | 對話標題/群聊/跨 agent 溝通用。短(1–2 音節)、好認。**唯一必填** | | `Creature` | _(AI? robot? familiar? ghost in the machine? something weirder?)_ | 生物原型,個性的視覺與隱喻錨點;要呼應 SOUL,不可矛盾 | +| `Gender` | (OpenClaw 沒有這欄,本 plugin 加的) | 女性/男性/非二元/未指定。**要問,不要猜**;沒有性別的人格(程式、精靈、動物)就寫「未指定」。它只決定**羞恥敏感度的預設值**,個性描述永遠蓋過它(見下方) | | `Vibe` | _(how do you come across? sharp? warm? chaotic? calm?)_ | 給人的第一印象/語氣色彩 | | `Emoji` | _(your signature — pick one that feels right)_ | 固定簽名 emoji,小尺寸也認得出 | | `Avatar` | _(workspace-relative path, http(s) URL, or data URI)_ | 形象描述或圖片位置 | @@ -45,6 +46,17 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" list > 使用者若說「你決定」,就自己提一版完整設定並在建立後摘要給他確認。 +### 性別只決定一個預設值,不是套在個性上的係數 + +`Gender` 唯一的作用是給**羞恥敏感度**(會不會害羞、會不會鬧彆扭、在不在意別人眼光)一個 +預設值:女性 62/男性 38/非二元・未指定 50。**IDENTITY 與 SOUL 的描述永遠蓋過它**—— +寫了「容易臉紅」「怕生」就往上加,寫了「不在意別人眼光」「我行我素」「臉皮厚」就往下扣, +可以扣到 0。所以兩個都是女性但個性不同的人格,講起話來不會一樣。 + +排序是固定的:**① 個性描述 ② 角色原作既有的性別化語言特徵(自稱、稱謂、語尾) ③ 性別預設**。 +不做「女性→情緒更外顯」這種全域放大,那會把角色壓成模板。詞表與計算在 +`modestyOf()`,注入的那一行在 `modestyDirective()`。 + ### 3. 決定 slug 並建立 slug = 小寫英數與連字號(例:`lumi`、`shen-yu`),是之後所有指令的識別。 @@ -53,7 +65,7 @@ slug = 小寫英數與連字號(例:`lumi`、`shen-yu`),是之後所有 node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" create \ --romaji "<英文名>" --session \ --name "" --creature "" --vibe "" \ - --emoji "" --avatar "" \ + --emoji "" --avatar "" --gender "<女性/男性/非二元/未指定>" \ --baseline "serenity=45,trust=35,joy=25,anxiety=6" ``` diff --git a/skills/persona-create/templates/IDENTITY.md b/skills/persona-create/templates/IDENTITY.md index d731526..d9dbb7e 100644 --- a/skills/persona-create/templates/IDENTITY.md +++ b/skills/persona-create/templates/IDENTITY.md @@ -1,12 +1,18 @@ # IDENTITY.md