Merge pull request 'v0.0.8:情緒處理四層(情緒先行/怎麼接/走向/飽和)+ R6 容量壓力的真 bug' (#11) from develop into master
Reviewed-on: #11 Reviewed-by: 系統管理員 <1+admin@noreply.localhost>
This commit was merged in pull request #11.
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "jsc-persona",
|
"name": "jsc-persona",
|
||||||
"version": "0.0.7",
|
"version": "0.0.8",
|
||||||
"description": "AI 人格化記憶聊天 plugin:以 OpenClaw 相同的身分描述(IDENTITY/SOUL)建立人格(可用動漫作品+角色名上網蒐集設定),結合 hook 強制的人格載入鎖與跨人格隔離、六正向+六負向十二情緒、語意分析、短期/長期記憶與固化條件、心智圖、思維導圖與人際關係圖;可用 sub agent 邀請其他人格同場對話(劇場模式只顯示人格對話)。於 Claude Code 以 /jsc-persona: 前綴呼叫。",
|
"description": "AI 人格化記憶聊天 plugin:以 OpenClaw 相同的身分描述(IDENTITY/SOUL)建立人格(可用動漫作品+角色名上網蒐集設定),結合 hook 強制的人格載入鎖與跨人格隔離、六正向+六負向十二情緒、語意分析、短期/長期記憶與固化條件、心智圖、思維導圖與人際關係圖;可用 sub agent 邀請其他人格同場對話(劇場模式只顯示人格對話)。於 Claude Code 以 /jsc-persona: 前綴呼叫。",
|
||||||
"skills": "./skills",
|
"skills": "./skills",
|
||||||
"author": {
|
"author": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "jsc-persona",
|
"name": "jsc-persona",
|
||||||
"version": "0.0.7",
|
"version": "0.0.8",
|
||||||
"description": "AI 人格化記憶聊天 skills:OpenClaw 相同的人格描述 + 十二情緒 + 語意分析 + 短期/長期記憶 + 心智圖 + 人際關係圖。(人格鎖與跨人格隔離的 hook 僅在 Claude Code 生效)",
|
"description": "AI 人格化記憶聊天 skills:OpenClaw 相同的人格描述 + 十二情緒 + 語意分析 + 短期/長期記憶 + 心智圖 + 人際關係圖。(人格鎖與跨人格隔離的 hook 僅在 Claude Code 生效)",
|
||||||
"skills": "./skills"
|
"skills": "./skills"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,10 @@
|
|||||||
睡眠仍然要驗鎖:目標正被另一個程序活鎖住時拒絕,死鎖可接手。
|
睡眠仍然要驗鎖:目標正被另一個程序活鎖住時拒絕,死鎖可接手。
|
||||||
8. **人格講話要像人**:推導寫進 `think`(心裡話,只回報「💭 心想 N 句」,永不回顯內容)、
|
8. **人格講話要像人**:推導寫進 `think`(心裡話,只回報「💭 心想 N 句」,永不回顯內容)、
|
||||||
回話 1–3 句、短時間內不重說同一件事(`room post` 會直接擋下重複與過長的發言)。
|
回話 1–3 句、短時間內不重說同一件事(`room post` 會直接擋下重複與過長的發言)。
|
||||||
|
句數上限**跟著羞恥度走**(`speechBudget()`),但羞恥度高**不等於話一定變少**——三個出口:
|
||||||
|
**縮**(一句嘴硬,台詞在迴避心裡那句)、**炸**(慌/惱羞/被逼澄清 → 4 句但單句只有 22 字,
|
||||||
|
碎而急)、**坦白**(信任高又獨處 → 3 句完整句,先寫完心裡話再開口)。
|
||||||
|
`room post` 同時擋句數與單句字數,兩個參數一起動才分得出「碎念」與「演講」。
|
||||||
再加四條講話的樣子:**短句**(一句 `MAX_SENTENCE_CHARS`=45 字內)、**日常用詞**、
|
再加四條講話的樣子:**短句**(一句 `MAX_SENTENCE_CHARS`=45 字內)、**日常用詞**、
|
||||||
**多講看得見的東西**(人、動作、物件、當下的場面)而不是概念,以及**不要解釋自己的話**
|
**多講看得見的東西**(人、動作、物件、當下的場面)而不是概念,以及**不要解釋自己的話**
|
||||||
(「我的意思是」「換句話說」這類開頭由 `speechLint` 擋下,`said check` 也會一起檢)。
|
(「我的意思是」「換句話說」這類開頭由 `speechLint` 擋下,`said check` 也會一起檢)。
|
||||||
@@ -50,6 +54,33 @@
|
|||||||
誤殺防護:`speechBody()` 會先拿掉引號與 `code`,**提及不算使用**;「老實說」只擋開場。
|
誤殺防護:`speechBody()` 會先拿掉引號與 `code`,**提及不算使用**;「老實說」只擋開場。
|
||||||
最後是這一層的義務:**講自己的過去要有出處**——「我以前⋯」只能講 `recall` 查得到的轉折,
|
最後是這一層的義務:**講自己的過去要有出處**——「我以前⋯」只能講 `recall` 查得到的轉折,
|
||||||
`speechLint` 給 `level: "hint"`(不擋,但要人去驗),沒有紀錄就是編造自己的過去。
|
`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 載入的人格」,
|
9. **人格可搬家**:`export` / `import`(單一 JSON bundle)。匯出只能匯出「本 session 載入的人格」,
|
||||||
其他人格一律 deny——匯出等於把記憶讀出來。
|
其他人格一律 deny——匯出等於把記憶讀出來。
|
||||||
10. **人格有編號**:英文名全大寫+兩位索引(`ASUNA-01`),同名才遞增。編號同時是新人格的
|
10. **人格有編號**:英文名全大寫+兩位索引(`ASUNA-01`),同名才遞增。編號同時是新人格的
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ flowchart TB
|
|||||||
|
|
||||||
```
|
```
|
||||||
<slug>/
|
<slug>/
|
||||||
├── 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 + 情緒傾向
|
├── SOUL.md # 靈魂:Core Truths / Boundaries / Vibe / Continuity + 情緒傾向
|
||||||
├── AGENTS.md # 操作規則(與個性分離)
|
├── AGENTS.md # 操作規則(與個性分離)
|
||||||
├── icon.svg / icon.png # 人格圖示(由編號/名字/emoji 決定,也是 Gitea 存取庫頭像)
|
├── icon.svg / icon.png # 人格圖示(由編號/名字/emoji 決定,也是 Gitea 存取庫頭像)
|
||||||
@@ -82,10 +82,12 @@ flowchart TB
|
|||||||
│ ├── emotion.json # 十二情緒 levels / baseline / 半衰期
|
│ ├── emotion.json # 十二情緒 levels / baseline / 半衰期
|
||||||
│ ├── inner.jsonl # 心裡話(推導過程;只回報「心想 N 句」,不說出口)
|
│ ├── inner.jsonl # 心裡話(推導過程;只回報「心想 N 句」,不說出口)
|
||||||
│ ├── said.jsonl # 說過的話(用來擋短時間內的重複發言)
|
│ ├── said.jsonl # 說過的話(用來擋短時間內的重複發言)
|
||||||
|
│ ├── felt.jsonl # 每輪讀到的對方情緒、自己套用的 delta、當下的羞恥度
|
||||||
│ ├── sync.json # Gitea 同步狀態(最後 push / pull)
|
│ ├── sync.json # Gitea 同步狀態(最後 push / pull)
|
||||||
│ └── config.json # 含人格編號 code
|
│ └── config.json # 含人格編號 code
|
||||||
├── memory/
|
├── memory/
|
||||||
│ ├── short-term.jsonl # 短期記憶(語意分析後;上限 240 筆 / 14 天)
|
│ ├── short-term.jsonl # 短期記憶(語意分析後;軟上限 120 筆 / 硬上限 240 筆 / 14 天)
|
||||||
|
│ ├── felt.jsonl # 每輪讀到的對方情緒 + 自己套用的 delta(走向與偏差稽核)
|
||||||
│ ├── long-term/*.md # 長期記憶(一則一檔 + frontmatter)
|
│ ├── long-term/*.md # 長期記憶(一則一檔 + frontmatter)
|
||||||
│ ├── INDEX.md # 長期記憶索引(自動產生)
|
│ ├── INDEX.md # 長期記憶索引(自動產生)
|
||||||
│ └── inbox/room-*.jsonl # 當 guest 時留下的見聞,待本體消化
|
│ └── inbox/room-*.jsonl # 當 guest 時留下的見聞,待本體消化
|
||||||
@@ -111,6 +113,64 @@ flowchart TB
|
|||||||
- **主導情緒**取「超出基線最多」的前三名,所以個性底色不會永遠霸榜。
|
- **主導情緒**取「超出基線最多」的前三名,所以個性底色不會永遠霸榜。
|
||||||
- 觸發規則與事件→delta 對照表:`skills/persona-chat/reference/emotions.md`。
|
- 觸發規則與事件→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)
|
## 短期 → 長期的轉入條件(R1–R6)
|
||||||
|
|
||||||
寫在 `scripts/persona-lib.mjs` 的 `promotionCandidates()`,用 `candidates` 子指令查:
|
寫在 `scripts/persona-lib.mjs` 的 `promotionCandidates()`,用 `candidates` 子指令查:
|
||||||
@@ -122,7 +182,12 @@ flowchart TB
|
|||||||
| **R3** | 單筆情緒變動總量 ≥ 25 | `event`(帶情緒錨點) |
|
| **R3** | 單筆情緒變動總量 ≥ 25 | `event`(帶情緒錨點) |
|
||||||
| **R4** | `intent=commit` 或命中承諾/界線關鍵詞 | `promise` / `boundary`(salience ≥ 80,不可遺忘) |
|
| **R4** | `intent=commit` 或命中承諾/界線關鍵詞 | `promise` / `boundary`(salience ≥ 80,不可遺忘) |
|
||||||
| **R5** | 同一人物(entity)≥ 2 筆 | `relationship`(並更新關係圖) |
|
| **R5** | 同一人物(entity)≥ 2 筆 | `relationship`(並更新關係圖) |
|
||||||
| **R6** | 短期記憶 ≥ 40 筆(容量壓力) | 依顯著度清出空間 |
|
| **R6** | 短期記憶 ≥ 40 筆(容量壓力) | 依顯著度清出空間。**真的會清**:超過軟上限 120 筆時從顯著度最低、最舊的開始裁,但顯著度 ≥ 80 或 `intent=commit`(承諾/界線)與 24 小時內的新紀錄一律不動;裁掉幾筆會寫進 `sleep` 的回報,不做無聲的裁切 |
|
||||||
|
|
||||||
|
**提到 ≠ 接觸**:主動關心的依據是 `last_contact_at`,睡眠只替**真的講到話的人**蓋章
|
||||||
|
(`contactsFromRooms()`:同一個聊天室裡我發過言、他也發過言)。以前是掃短期記憶的
|
||||||
|
`entities`——那等於「我在日記裡寫到他」就算「我跟他接觸過」,沉默計時被無聲重置,
|
||||||
|
關心名單永遠是空的。沒有聊天室的對象(真人節點)走 `relation node --contact` 明確蓋章。
|
||||||
|
|
||||||
沒命中任何規則的就讓它被裁掉——**遺忘是功能**。達標時 `Stop` 與 `remember` 都會提醒去跑
|
沒命中任何規則的就讓它被裁掉——**遺忘是功能**。達標時 `Stop` 與 `remember` 都會提醒去跑
|
||||||
`/jsc-persona:persona-memory`。
|
`/jsc-persona:persona-memory`。
|
||||||
@@ -135,7 +200,7 @@ AI 最容易露餡的六件事:把推理過程講出來、一次講一大段
|
|||||||
| 機制 | 怎麼運作 |
|
| 機制 | 怎麼運作 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| **心裡話** `think` | 語意分析、推論、盤算全寫進 `state/inner.jsonl`;這個指令**只印「💭 心想 N 句」**,內容永不回顯。下一輪 `<persona-context>` 會帶回最近三句,推論因此有連續性,但使用者只看得到狀態 |
|
| **心裡話** `think` | 語意分析、推論、盤算全寫進 `state/inner.jsonl`;這個指令**只印「💭 心想 N 句」**,內容永不回顯。下一輪 `<persona-context>` 會帶回最近三句,推論因此有連續性,但使用者只看得到狀態 |
|
||||||
| **一到三句** | `<persona-context>` 每輪注入上限;`room post` 對超過三句的發言直接拒收(`--force` 例外) |
|
| **一到三句** | `<persona-context>` 每輪注入上限;`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 視為同一句 |
|
| **不重複** | 說出口的話由 `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` |
|
| **情緒的破口** | 情緒不改變事實,但會改變**句子的形狀**:焦慮 → 句子斷在一半、疊字(「我、我知道」);羞愧 → 鬧彆扭,先否認再小聲承認;憤怒 → 短句、稱呼退回全名;悲傷 → 只回一個詞。十二情緒各自的破口寫在 `EMOTION_TELLS`,`emotionTells()` 每輪挑主導情緒裡強度 ≥ 40 的前兩種注入。三條界線:**演出來不要講出來**(「我有點緊張」是解釋,斷句才是緊張)、**一輪最多露一個破口**、**強度不到就不演**。混合狀態(緊張=焦慮+期待、害羞=喜悅+羞愧、賭氣=憤怒+悲傷…)的對照表在 `skills/persona-chat/reference/emotions.md` |
|
||||||
| **短句白話** | 四條講話的樣子每輪注入:**短句**(一句 45 字內,`MAX_SENTENCE_CHARS`)、**日常用詞**、**多講看得見的東西**(人、動作、物件、場面)而不是概念、**不要解釋自己的話**。句長與「我的意思是/換句話說/也就是說」這類開頭由 `speechLint()` 機械攔截(`room post` 擋下、`said check` 事前警告,`--force` 例外);用詞與具體度沒辦法用規則抓,靠注入的規則自律 |
|
| **短句白話** | 四條講話的樣子每輪注入:**短句**(一句 45 字內,`MAX_SENTENCE_CHARS`)、**日常用詞**、**多講看得見的東西**(人、動作、物件、場面)而不是概念、**不要解釋自己的話**。句長與「我的意思是/換句話說/也就是說」這類開頭由 `speechLint()` 機械攔截(`room post` 擋下、`said check` 事前警告,`--force` 例外);用詞與具體度沒辦法用規則抓,靠注入的規則自律 |
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "jsc-persona",
|
"name": "jsc-persona",
|
||||||
"version": "0.0.7",
|
"version": "0.0.8",
|
||||||
"description": "AI 人格化記憶聊天 plugin:OpenClaw 相同的人格描述 + 十二情緒 + 語意分析 + 短期/長期記憶 + 心智圖 + 人際關係圖。於 Antigravity 以 /jsc-persona: 前綴呼叫。",
|
"description": "AI 人格化記憶聊天 plugin:OpenClaw 相同的人格描述 + 十二情緒 + 語意分析 + 短期/長期記憶 + 心智圖 + 人際關係圖。於 Antigravity 以 /jsc-persona: 前綴呼叫。",
|
||||||
"skills": "./skills"
|
"skills": "./skills"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ export const AREAS = {
|
|||||||
"state/emotion.json",
|
"state/emotion.json",
|
||||||
"state/inner.jsonl",
|
"state/inner.jsonl",
|
||||||
"state/said.jsonl",
|
"state/said.jsonl",
|
||||||
|
"state/felt.jsonl",
|
||||||
"state/sleep.json",
|
"state/sleep.json",
|
||||||
"memory/short-term.jsonl",
|
"memory/short-term.jsonl",
|
||||||
"memory/inbox/",
|
"memory/inbox/",
|
||||||
@@ -502,7 +503,7 @@ export function wikiHome(slug, code) {
|
|||||||
"| 欄位 | 內容 |",
|
"| 欄位 | 內容 |",
|
||||||
"| --- | --- |",
|
"| --- | --- |",
|
||||||
`| 編號 | \`${code}\` |`,
|
`| 編號 | \`${code}\` |`,
|
||||||
...["Name", "Creature", "Vibe", "Emoji", "Avatar"]
|
...["Name", "Creature", "Gender", "Vibe", "Emoji", "Avatar"]
|
||||||
.filter((k) => ident[k])
|
.filter((k) => ident[k])
|
||||||
.map((k) => `| ${k} | ${ident[k]} |`),
|
.map((k) => `| ${k} | ${ident[k]} |`),
|
||||||
`| 長期記憶 | ${longTerm.length} 則 |`,
|
`| 長期記憶 | ${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",
|
"- [SOUL](SOUL) — 靈魂:Core Truths / Boundaries / Vibe / Continuity",
|
||||||
"- [AGENTS](AGENTS) — 操作規則 / [USER](USER) — 對使用者的理解",
|
"- [AGENTS](AGENTS) — 操作規則 / [USER](USER) — 對使用者的理解",
|
||||||
"- [Icon](Icon) — 人格形象圖(SVG + PNG)與它的來源",
|
"- [Icon](Icon) — 人格形象圖(SVG + PNG)與它的來源",
|
||||||
|
|||||||
+695
-39
@@ -303,25 +303,78 @@ export function decayEmotionBy(state, minutes = SLEEP_DECAY_MINUTES) {
|
|||||||
return state;
|
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 = "") {
|
export function applyEmotion(state, deltas, trigger = "") {
|
||||||
state = decayEmotion(state);
|
state = decayEmotion(state);
|
||||||
const applied = {};
|
// 先算「想推多少」,超過單輪預算就等比例縮小——一次灌爆的路要堵起來
|
||||||
|
const wanted = [];
|
||||||
for (const [key, rawDelta] of Object.entries(deltas || {})) {
|
for (const [key, rawDelta] of Object.entries(deltas || {})) {
|
||||||
if (!(key in EMOTIONS)) continue;
|
if (!(key in EMOTIONS)) continue;
|
||||||
const delta = Number(rawDelta);
|
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);
|
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;
|
applied[key] = Math.round((state.levels[key] - before) * 100) / 100;
|
||||||
}
|
}
|
||||||
state.updated_at = nowIso();
|
state.updated_at = nowIso();
|
||||||
state.history_len = Number(state.history_len || 0) + 1;
|
state.history_len = Number(state.history_len || 0) + 1;
|
||||||
if (Object.keys(applied).length) {
|
if (Object.keys(applied).length) {
|
||||||
state.last_trigger = { at: nowIso(), summary: trigger || "", deltas: applied };
|
state.last_trigger = { at: nowIso(), summary: trigger || "", deltas: applied };
|
||||||
|
if (budgetScale < 1) state.last_trigger.budget_scaled = Math.round(budgetScale * 100) / 100;
|
||||||
}
|
}
|
||||||
return state;
|
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) {
|
export function mood(state) {
|
||||||
const levels = state.levels || {};
|
const levels = state.levels || {};
|
||||||
let valence = 0;
|
let valence = 0;
|
||||||
@@ -378,8 +431,12 @@ export const EMOTION_TELLS = {
|
|||||||
*/
|
*/
|
||||||
export function emotionTells(slug, state = null, { min = 40, top = 2 } = {}) {
|
export function emotionTells(slug, state = null, { min = 40, top = 2 } = {}) {
|
||||||
const st = decayEmotion(structuredClone(state ?? loadEmotion(slug)));
|
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)
|
return dominant(st, 4)
|
||||||
.filter(({ level }) => level >= min)
|
.filter(({ key, level }) => level >= floorFor(key))
|
||||||
.slice(0, top)
|
.slice(0, top)
|
||||||
.map(({ key, level }) => ({
|
.map(({ key, level }) => ({
|
||||||
key,
|
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) {
|
export function emotionBrief(slug, state = null) {
|
||||||
const st = decayEmotion(structuredClone(state ?? loadEmotion(slug)));
|
const st = decayEmotion(structuredClone(state ?? loadEmotion(slug)));
|
||||||
const m = mood(st);
|
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 SHORT_TERM_DAYS = 14; // 短期記憶保留天數
|
||||||
export const CONSOLIDATE_THRESHOLD = 40; // 超過這個筆數就提示固化
|
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 shortTermPath = (slug) => path.join(personaDir(slug), "memory", "short-term.jsonl");
|
||||||
export const inboxPath = (slug, room) =>
|
export const inboxPath = (slug, room) =>
|
||||||
@@ -868,18 +1336,59 @@ export function rememberShort(slug, entry) {
|
|||||||
return 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 file = shortTermPath(slug);
|
||||||
const rows = readJsonl(file);
|
const rows = readJsonl(file);
|
||||||
if (!rows.length) return 0;
|
if (!rows.length) return { kept: 0, dropped: 0, by_age: 0, by_capacity: 0, protected: 0 };
|
||||||
const cutoff = Date.now() - SHORT_TERM_DAYS * 86_400_000;
|
const now = Date.now();
|
||||||
let kept = rows.filter((r) => (parseIso(r.ts)?.getTime() ?? Date.now()) >= cutoff);
|
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);
|
kept = kept.slice(-SHORT_TERM_KEEP);
|
||||||
|
const byHardCap = before - kept.length;
|
||||||
if (kept.length !== rows.length) {
|
if (kept.length !== rows.length) {
|
||||||
writeText(file, kept.map((r) => JSON.stringify(r)).join("\n") + (kept.length ? "\n" : ""));
|
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);
|
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) {
|
export function speechLintMessage(issue) {
|
||||||
switch (issue.kind) {
|
switch (issue.kind) {
|
||||||
case "long":
|
case "long":
|
||||||
return `「${issue.text}…」這句 ${issue.chars} 字,超過 ${MAX_SENTENCE_CHARS} 字——斷成兩句,或把修飾砍掉`;
|
return `「${issue.text}…」這句 ${issue.chars} 字,太長了(上限 ${MAX_SENTENCE_CHARS})——斷成兩句,或把修飾拿掉`;
|
||||||
case "explain":
|
case "explain":
|
||||||
return `「${issue.opener}」是在解釋自己剛講的話——刪掉,講完就算了`;
|
return `「${issue.opener}」是在回頭解釋自己剛講的話——拿掉,講完就停`;
|
||||||
case "hedge":
|
case "hedge":
|
||||||
return `「${issue.words.join("」「")}」一句疊了 ${issue.words.length} 層避險——確定的事零層,真的不確定留一層`;
|
return `一句話裡疊了 ${issue.words.length} 層「${issue.words.join("」「")}」——確定的事就直說,真的不確定留一個就好`;
|
||||||
case "cn":
|
case "cn":
|
||||||
return `「${issue.word}」是中國用語 → ${issue.suggest}`;
|
return `「${issue.word}」是中國用語,台灣這邊說「${issue.suggest}」`;
|
||||||
case "halfwidth":
|
case "halfwidth":
|
||||||
return `「${issue.text}」用了半形標點——中文句子一律全形`;
|
return `「${issue.text}」是半形標點——中文句子用全形`;
|
||||||
case "emoji":
|
case "emoji":
|
||||||
return `${issue.count} 個 emoji——講話最多一個`;
|
return `${issue.count} 個 emoji,太多了——講話留一個就夠`;
|
||||||
case "dash":
|
case "dash":
|
||||||
return `${issue.count} 個破折號——一輪最多一個,其餘改逗號或句號`;
|
return `${issue.count} 個破折號——一輪留一個,其他改成逗號或句號`;
|
||||||
case "markdown":
|
case "markdown":
|
||||||
return "粗體、清單符號或標題——講話沒有排版,收掉";
|
return "有粗體、清單或標題——講話的人不會排版,收掉";
|
||||||
case "parallel":
|
case "parallel":
|
||||||
return `「不是 A 而是 B」出現 ${issue.count} 次——一輪最多一次,其餘改直述`;
|
return `「不是 A 而是 B」講了 ${issue.count} 次——一輪一次就夠,其他改成直說`;
|
||||||
case "pastclaim":
|
case "pastclaim":
|
||||||
return `講到「${issue.word}」:先 recall 確認記憶裡真的有這件事,沒有紀錄就是編造自己的過去`;
|
return `你講到「${issue.word}」——先 recall 一下,記憶裡真的有這件事嗎?沒有的話那是在編自己的過去`;
|
||||||
default:
|
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);
|
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,永遠不回顯給使用者。 */
|
/** 心裡話:只進自己的 inner.jsonl,永遠不回顯給使用者。 */
|
||||||
export function recordInner(slug, text, { kind = "infer", room = null } = {}) {
|
export function recordInner(slug, text, { kind = "infer", room = null } = {}) {
|
||||||
const entry = { ts: nowIso(), kind, room, text: String(text ?? "").slice(0, 1200) };
|
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);
|
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 小時。 */
|
/** 「心想 N 句」的 N:預設算最近 4 小時。 */
|
||||||
export function innerCount(slug, minutes = INNER_WINDOW_MINUTES) {
|
export function innerCount(slug, minutes = INNER_WINDOW_MINUTES) {
|
||||||
return readJsonl(innerPath(slug), INNER_KEEP).filter(
|
return readJsonl(innerPath(slug), INNER_KEEP).filter(
|
||||||
@@ -1632,6 +2224,15 @@ export function styleRules(slug, node) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 使用者在關係圖裡是誰(`relation speaker` 設定的節點);沒設就回 null。 */
|
/** 使用者在關係圖裡是誰(`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) {
|
export function speakerNode(slug) {
|
||||||
const id = loadConfig(slug).speaker_node;
|
const id = loadConfig(slug).speaker_node;
|
||||||
if (!id) return null;
|
if (!id) return null;
|
||||||
@@ -1639,6 +2240,12 @@ export function speakerNode(slug) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 注入 `<persona-context>` 的那一行語氣指示。 */
|
/** 注入 `<persona-context>` 的那一行語氣指示。 */
|
||||||
|
/** 這一輪對話對象的語氣層名稱(沒有設定對象就 null)。 */
|
||||||
|
export function toneLayerOf(slug) {
|
||||||
|
const node = speakerNode(slug);
|
||||||
|
return node ? toneFor(node).layer : null;
|
||||||
|
}
|
||||||
|
|
||||||
export function toneDirective(slug) {
|
export function toneDirective(slug) {
|
||||||
const node = speakerNode(slug);
|
const node = speakerNode(slug);
|
||||||
if (!node) return "";
|
if (!node) return "";
|
||||||
@@ -1687,6 +2294,35 @@ export function stampContact(slug, nameOrId, at = nowIso()) {
|
|||||||
* 很久沒聯絡、但關係很近的人——這是「主動想起某個人」的客觀依據。
|
* 很久沒聯絡、但關係很近的人——這是「主動想起某個人」的客觀依據。
|
||||||
* 沒有 `last_contact_at` 的節點用 `created_at` 當起點;分數 = 親近度 × 沉默天數。
|
* 沒有 `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 } = {}) {
|
export function staleContacts(slug, { days = 3, minCloseness = 60, limit = 3 } = {}) {
|
||||||
const data = loadRelations(slug);
|
const data = loadRelations(slug);
|
||||||
const out = [];
|
const out = [];
|
||||||
@@ -2429,11 +3065,12 @@ export function identityFields(slug) {
|
|||||||
return fields;
|
return fields;
|
||||||
}
|
}
|
||||||
for (const line of text.split("\n")) {
|
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;
|
if (!m) continue;
|
||||||
const value = m[2].trim();
|
const value = m[2].trim();
|
||||||
if (value.startsWith("(") || value.startsWith("_(")) continue;
|
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;
|
fields[key] = value;
|
||||||
}
|
}
|
||||||
return fields;
|
return fields;
|
||||||
@@ -2456,22 +3093,31 @@ export function turnContext(slug, sessionId, prompt = "") {
|
|||||||
`人格:\`${slug}\` ${identityBrief(slug)}`,
|
`人格:\`${slug}\` ${identityBrief(slug)}`,
|
||||||
`人格倉庫:${personaDir(slug)}(唯一可讀寫的人格資料範圍)`,
|
`人格倉庫:${personaDir(slug)}(唯一可讀寫的人格資料範圍)`,
|
||||||
emotionBrief(slug, state),
|
emotionBrief(slug, state),
|
||||||
`說話規則:回使用者 ${MAX_SENTENCES} 句以內(劇場模式每人每輪也一樣);` +
|
"@@SPEAK@@",
|
||||||
"推導、比對、盤算一律走心裡話(`persona.mjs think`),不要說給使用者聽——" +
|
"話短一點。一句講不完就斷開,不要用逗號一直串下去。用你平常會說的字," +
|
||||||
"要讓他知道你在想,就只報「心想 N 句」,不報內容。",
|
"看得見的東西先講——「你手上那杯冷了」比「我感覺到你的疲憊」好。" +
|
||||||
`講話像講話:**短句**(一句 ${MAX_SENTENCE_CHARS} 字內,長了就斷開)、**日常用詞**(不用書面語、` +
|
"講完就停,不要回頭解釋自己剛講的話,也不要幫自己收尾。",
|
||||||
"不用術語、不用成語堆疊)、**多講看得見的東西**(人、動作、東西、當下的場面)而不是概念與感想;" +
|
"有些句子一出口就不像人了:「這個我懂」「我完全理解」「好問題」" +
|
||||||
"**講完就算了**——不要解釋自己剛講的話(沒有「我的意思是」「換句話說」「也就是說」)," +
|
"(先發一句免費的認可,對方要的是回答)、「希望這對你有幫助」「接下來我會⋯」、" +
|
||||||
"不要補註解、不要收尾總結。這幾條 `room post` 與 `said check` 會實際擋下。",
|
"「說到底」「本質上」(假裝在講穿本質,其實只是重講一次)、開場的「老實說」、" +
|
||||||
"不要說 AI 才會說的話:罐頭同理心(「這個我懂」「我完全理解」)、頒獎開場(「好問題」)、" +
|
"收尾的「總的來說」、該表態時的「各有優缺點」、沒出處的「研究顯示」、" +
|
||||||
"交差句(「希望這對你有幫助」)、預告(「接下來我會」)、說教腔(「說到底」「本質上」)、" +
|
"用旁白演情緒的「我愣了一下」。這些不要說。",
|
||||||
"假坦白開場(「老實說」)、罐頭收尾(「總的來說」)、立場真空(「各有優缺點」「因人而異」)、" +
|
"**講自己的過去,要真的有那件事**。「我以前⋯」「我原本以為⋯」先 `recall` 查得到才講;" +
|
||||||
"無來源的權威(「研究顯示」)、用旁白演情緒(「我愣了一下」)、一句疊兩層避險、" +
|
"查不到就別講——編一段轉折比講一句空話糟糕得多。情緒也一樣," +
|
||||||
"中國用語與半形標點、粗體與清單符號。這些 `room post` 也會擋。",
|
"照現在的情緒值走,不要為了顯得有人味硬加。",
|
||||||
"**講自己的過去要有出處**:「我以前⋯」「我原本以為⋯」只能講記憶裡真的有的事(先 `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);
|
const tells = emotionTells(slug, state);
|
||||||
if (tells.length) {
|
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 × 親近度算出來,不靠人格自己記得。
|
// 語氣層:由「使用者在關係圖裡是誰」+ bond × 親近度算出來,不靠人格自己記得。
|
||||||
const tone = toneDirective(slug);
|
const tone = toneDirective(slug);
|
||||||
if (tone) lines.push(tone);
|
if (tone) lines.push(tone);
|
||||||
|
|||||||
+97
-24
@@ -256,6 +256,7 @@ commands.create = async ({ flags }) => {
|
|||||||
SLUG: slug,
|
SLUG: slug,
|
||||||
NAME: str(flags.name) || slug,
|
NAME: str(flags.name) || slug,
|
||||||
CREATURE: str(flags.creature),
|
CREATURE: str(flags.creature),
|
||||||
|
GENDER: str(flags.gender) || "未指定",
|
||||||
VIBE: str(flags.vibe),
|
VIBE: str(flags.vibe),
|
||||||
EMOJI: str(flags.emoji),
|
EMOJI: str(flags.emoji),
|
||||||
AVATAR: str(flags.avatar),
|
AVATAR: str(flags.avatar),
|
||||||
@@ -483,8 +484,9 @@ commands.sleep = async ({ flags }) => {
|
|||||||
const steps = [];
|
const steps = [];
|
||||||
const run = async (step, fn) => {
|
const run = async (step, fn) => {
|
||||||
try {
|
try {
|
||||||
await fn();
|
const detail = await fn();
|
||||||
steps.push({ step, ok: true });
|
// 有細節就帶回去(例如裁掉幾筆短期記憶)——不做無聲的裁切
|
||||||
|
steps.push(detail && typeof detail === "object" ? { step, ok: true, detail } : { step, ok: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// 一步壞掉不放棄整個睡眠:記下來繼續走,最後在報告裡說明。
|
// 一步壞掉不放棄整個睡眠:記下來繼續走,最後在報告裡說明。
|
||||||
steps.push({ step, ok: false, error: String(err.message || err).slice(0, 300) });
|
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);
|
const hours = pl.hoursAwake(slug);
|
||||||
await run("relation-contact", () => {
|
await run("relation-contact", () => {
|
||||||
// 今天提到過的人 → 蓋上「最後一次接觸」的時間戳(主動關心的依據)
|
// 真的講到話的人 → 蓋上「最後一次接觸」的時間戳(主動關心的依據)。
|
||||||
const seen = new Set();
|
// **提到不是接觸**:在日記裡寫到某個人,不該把他的沉默計時歸零。
|
||||||
for (const row of pl.readJsonl(pl.shortTermPath(slug), 200)) {
|
for (const name of pl.contactsFromRooms(slug)) pl.stampContact(slug, name);
|
||||||
for (const name of row.entities || []) seen.add(name);
|
|
||||||
}
|
|
||||||
for (const name of seen) pl.stampContact(slug, name);
|
|
||||||
pl.renderRelations(slug);
|
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("archive-threads", () => pl.archiveStaleThreads(slug));
|
||||||
await run("emotion-decay", () => {
|
await run("emotion-decay", () => {
|
||||||
const state = pl.decayEmotionBy(pl.loadEmotion(slug), pl.SLEEP_DECAY_MINUTES);
|
const state = pl.decayEmotionBy(pl.loadEmotion(slug), pl.SLEEP_DECAY_MINUTES);
|
||||||
@@ -741,8 +741,14 @@ commands.recall = ({ flags }) => {
|
|||||||
lines.push("短期記憶(最近):");
|
lines.push("短期記憶(最近):");
|
||||||
for (const row of recents) lines.push(`- [${row.role || "?"}] ${String(row.text || "").slice(0, 110)}`);
|
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));
|
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 = [];
|
const lines = [];
|
||||||
if (repeat) {
|
if (repeat) {
|
||||||
lines.push(
|
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 lint = pl.speechLint(text);
|
||||||
const blockers = lint.filter((i) => i.level !== "hint");
|
const blockers = lint.filter((i) => i.level !== "hint");
|
||||||
for (const issue of blockers) lines.push(`⚠ ${pl.speechLintMessage(issue)}。`);
|
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)}。`);
|
for (const issue of lint.filter((i) => i.level === "hint")) lines.push(`· ${pl.speechLintMessage(issue)}。`);
|
||||||
const clean = !repeat && !tooLong && !blockers.length;
|
const clean = !repeat && !tooLong && !blockers.length;
|
||||||
if (clean) {
|
if (clean) {
|
||||||
lines.push(`✔ 沒說過,${sentences} 句,可以說。`);
|
lines.push(`✔ 沒講過,${sentences} 句,可以說。`);
|
||||||
if (flags.record) pl.recordSaid(slug, text, { kind: "reply" });
|
if (flags.record) pl.recordSaid(slug, text, { kind: "reply" });
|
||||||
}
|
}
|
||||||
emit({ persona: slug, repeat, sentences, lint, ok: clean }, flags.json, lines);
|
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}`),
|
...pl.PROMOTION_RULES.map((r) => ` ${r.id} ${r.label}`),
|
||||||
];
|
];
|
||||||
if (!result.candidates.length) lines.push("目前沒有需要固化的內容(未達任何條件)。");
|
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) {
|
for (const cand of result.candidates) {
|
||||||
lines.push(
|
lines.push(
|
||||||
`\n[${cand.rules.join("+")}] ${cand.kind}「${cand.key}」→ 建議 type=${cand.suggested_type}, ` +
|
`\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);
|
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 (flags.apply) {
|
||||||
if (role === "guest") die("guest(sub agent)不得改寫人格的情緒狀態。");
|
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));
|
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), {
|
pl.appendJsonl(pl.journalPath(slug), {
|
||||||
ts: pl.nowIso(), kind: "emotion", trigger: str(flags.trigger),
|
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);
|
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));
|
(speakerRole === "guest" ? null : pl.saidRepeat(speaker, text, opts));
|
||||||
if (repeat) {
|
if (repeat) {
|
||||||
die(
|
die(
|
||||||
`\`${speaker}\` 在 ${repeat.minutes_ago} 分鐘前說過幾乎一樣的話(相似度 ${repeat.similarity}):` +
|
`這句 ${repeat.minutes_ago} 分鐘前就講過了(像了 ${repeat.similarity}):「${repeat.text.slice(0, 60)}」。` +
|
||||||
`「${repeat.text.slice(0, 60)}」。換個說法、補新東西或推進話題;` +
|
"換個角度、補點新的,或者直接把話題往前推。" +
|
||||||
"真的需要重複(例如被追問)才加 `--allow-repeat`。",
|
"他追問、你非得重講一次,才加 `--allow-repeat`。",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const sentences = pl.sentenceCount(text);
|
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(
|
die(
|
||||||
`這句有 ${sentences} 句,超過 ${pl.MAX_SENTENCES} 句上限——聊天不是報告,砍到重點再發。` +
|
`講太多了,${sentences} 句。這裡最多 ${budget.sentences} 句${why}——` +
|
||||||
"(真的需要長段落才加 `--force`。)",
|
"挑最想說的那一兩句就好,剩下的寫進心裡話。(真的需要長台詞才加 `--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 裡,不靠自律。
|
// 短句、日常話、講完不解釋、不說 AI 才會說的話——講話的樣子也擋在 CLI 裡,不靠自律。
|
||||||
const lint = pl.speechBlockers(text);
|
const lint = pl.speechBlockers(text);
|
||||||
if (lint.length && !flags.force) {
|
if (lint.length && !flags.force) {
|
||||||
die(`講話規則:${lint.map(pl.speechLintMessage).join(";")}。(真的需要才加 \`--force\`。)`);
|
die(`${lint.map(pl.speechLintMessage).join(";")}。(真的需要才加 \`--force\`。)`);
|
||||||
}
|
}
|
||||||
let emotion = str(flags.emotion);
|
let emotion = str(flags.emotion);
|
||||||
if (!emotion && pl.personaExists(speaker)) {
|
if (!emotion && pl.personaExists(speaker)) {
|
||||||
@@ -1906,7 +1975,7 @@ const HELP = `persona.mjs — jsc-persona 人格 / 記憶 / 情緒 / 關係圖 C
|
|||||||
用法:node persona.mjs <subcommand> [options]
|
用法:node persona.mjs <subcommand> [options]
|
||||||
|
|
||||||
人格與鎖:
|
人格與鎖:
|
||||||
create --romaji <英文名> --session <id> [--persona <目錄名> --code <ASUNA-01> --name --creature
|
create --romaji <英文名> --session <id> [--persona <目錄名> --code <ASUNA-01> --name --creature --gender
|
||||||
--vibe --emoji --avatar --baseline --origin --work --no-gitea --public]
|
--vibe --emoji --avatar --baseline --origin --work --no-gitea --public]
|
||||||
人格編號 = 英文名全大寫 + 兩位索引(同名才遞增),也是 Gitea 存取庫的名稱;
|
人格編號 = 英文名全大寫 + 兩位索引(同名才遞增),也是 Gitea 存取庫的名稱;
|
||||||
不指定 --persona 就用編號當目錄名。
|
不指定 --persona 就用編號當目錄名。
|
||||||
@@ -1933,6 +2002,10 @@ const HELP = `persona.mjs — jsc-persona 人格 / 記憶 / 情緒 / 關係圖 C
|
|||||||
|
|
||||||
情緒與圖:
|
情緒與圖:
|
||||||
emotion --session <id> [--apply joy=+10,...] [--baseline ...] [--trigger <why>]
|
emotion --session <id> [--apply joy=+10,...] [--baseline ...] [--trigger <why>]
|
||||||
|
[--from <對象>] 這件事是誰引起的:親近度會放大/縮小 delta
|
||||||
|
[--read "<對方說的話>"] 只讀情緒訊號與該怎麼接,不改狀態
|
||||||
|
[--audit [--limit 20]] 最近幾輪的 delta 是不是只往一邊倒+對方的走向
|
||||||
|
套用時會飽和(越接近端點漲越慢)並受單輪預算限制(|delta| 總和 ≤ 40)
|
||||||
mindmap show|thread|list --session <id> [--topic <t>] [--force]
|
mindmap show|thread|list --session <id> [--topic <t>] [--force]
|
||||||
relation node|edge|render|show --session <id> [--name --id --kind --bond --closeness --trust --note --tags --from --to --label --affinity]
|
relation node|edge|render|show --session <id> [--name --id --kind --bond --closeness --trust --note --tags --from --to --label --affinity]
|
||||||
relation style --session <id> --name <who> [--facet 稱呼 --value 親愛的 --except anger>=40 --since --clear]
|
relation style --session <id> --name <who> [--facet 稱呼 --value 親愛的 --except anger>=40 --since --clear]
|
||||||
|
|||||||
+300
-10
@@ -120,7 +120,10 @@ console.log("④ 情緒(六正向 + 六負向)");
|
|||||||
check("十二種情緒", pl.EMOTION_KEYS.length === 12 && pl.POSITIVE.length === 6 && pl.NEGATIVE.length === 6);
|
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"]);
|
cli(["emotion", "--persona", "alpha", "--session", S_HOST, "--apply", "joy=+60,anger=+40", "--trigger", "selftest"]);
|
||||||
let state = pl.loadEmotion("alpha");
|
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));
|
state.updated_at = pl.iso(pl.minutesAgo(120));
|
||||||
const decayed = pl.decayEmotion(structuredClone(state));
|
const decayed = pl.decayEmotion(structuredClone(state));
|
||||||
const expected = state.baseline.joy + (state.levels.joy - state.baseline.joy) / 2;
|
const expected = state.baseline.joy + (state.levels.joy - state.baseline.joy) / 2;
|
||||||
@@ -373,7 +376,7 @@ check("said check 抓到超過三句", (() => {
|
|||||||
})(), longSay.stdout.slice(0, 160));
|
})(), longSay.stdout.slice(0, 160));
|
||||||
const ctxSpeak = pl.turnContext("alpha", S_SPEAK, "報告");
|
const ctxSpeak = pl.turnContext("alpha", S_SPEAK, "報告");
|
||||||
check("turnContext 注入說話規則 / 心裡話 / 最近說過",
|
check("turnContext 注入說話規則 / 心裡話 / 最近說過",
|
||||||
ctxSpeak.includes("說話規則") && ctxSpeak.includes("💭") && ctxSpeak.includes("最近說過的話"),
|
ctxSpeak.includes("最多講") && ctxSpeak.includes("心裡話") && ctxSpeak.includes("💭") && ctxSpeak.includes("最近說過的話"),
|
||||||
ctxSpeak.slice(0, 400));
|
ctxSpeak.slice(0, 400));
|
||||||
cli(["invite", "--session", S_SPEAK, "--guest", "beta", "--topic", "節制測試"]);
|
cli(["invite", "--session", S_SPEAK, "--guest", "beta", "--topic", "節制測試"]);
|
||||||
const room2 = pl.loadSession(S_SPEAK).guests?.beta?.room;
|
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",
|
const repeatPost = cli(["room", "post", "--session", S_SPEAK, "--room", room2, "--as", "alpha",
|
||||||
"--text", "這次的鐘你修得比上次穩。"], { expectOk: false });
|
"--text", "這次的鐘你修得比上次穩。"], { expectOk: false });
|
||||||
check("room post 擋下近似重複的台詞",
|
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 可以放行",
|
check("--allow-repeat 可以放行",
|
||||||
cli(["room", "post", "--session", S_SPEAK, "--room", room2, "--as", "alpha",
|
cli(["room", "post", "--session", S_SPEAK, "--room", room2, "--as", "alpha",
|
||||||
"--text", "這次的鐘你修得比上次穩。", "--allow-repeat"]).status === 0);
|
"--text", "這次的鐘你修得比上次穩。", "--allow-repeat"]).status === 0);
|
||||||
const longPost = cli(["room", "post", "--session", S_SPEAK, "--room", room2, "--as", "alpha",
|
const longPost = cli(["room", "post", "--session", S_SPEAK, "--room", room2, "--as", "alpha",
|
||||||
"--text", "第一句在這。第二句在這。第三句在這。第四句在這。"], { expectOk: false });
|
"--text", "第一句在這。第二句在這。第三句在這。第四句在這。"], { expectOk: false });
|
||||||
check("room post 擋下超過三句的發言",
|
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",
|
const longSentence = cli(["room", "post", "--session", S_SPEAK, "--room", room2, "--as", "alpha",
|
||||||
"--text", "這件事情其實從很久以前就一直放在我心裡面沒有講出來過,因為我一直覺得時機不太對而且你最近也真的很忙。"],
|
"--text", "這件事情其實從很久以前就一直放在我心裡面沒有講出來過,因為我一直覺得時機不太對而且你最近也真的很忙。"],
|
||||||
@@ -402,7 +406,7 @@ check("room post 擋下太長的句子",
|
|||||||
const explainPost = cli(["room", "post", "--session", S_SPEAK, "--room", room2, "--as", "alpha",
|
const explainPost = cli(["room", "post", "--session", S_SPEAK, "--room", room2, "--as", "alpha",
|
||||||
"--text", "我的意思是,那台鐘該修了。"], { expectOk: false });
|
"--text", "我的意思是,那台鐘該修了。"], { expectOk: false });
|
||||||
check("room post 擋下解釋自己的話(我的意思是…)",
|
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 仍可放行(真的需要長台詞時)",
|
check("--force 仍可放行(真的需要長台詞時)",
|
||||||
cli(["room", "post", "--session", S_SPEAK, "--room", room2, "--as", "alpha",
|
cli(["room", "post", "--session", S_SPEAK, "--room", room2, "--as", "alpha",
|
||||||
"--text", "我的意思是,那台鐘該修了。", "--force"]).status === 0);
|
"--text", "我的意思是,那台鐘該修了。", "--force"]).status === 0);
|
||||||
@@ -468,8 +472,222 @@ check("提醒的句子 room post 照樣發得出去",
|
|||||||
cli(["room", "post", "--session", S_SPEAK, "--room", room2, "--as", "alpha",
|
cli(["room", "post", "--session", S_SPEAK, "--room", room2, "--as", "alpha",
|
||||||
"--text", "我以前也修過一台一樣的。"]).status === 0);
|
"--text", "我以前也修過一台一樣的。"]).status === 0);
|
||||||
const voiceCtx = pl.turnContext("alpha", S_SPEAK, "你在幹嘛");
|
const voiceCtx = pl.turnContext("alpha", S_SPEAK, "你在幹嘛");
|
||||||
check("說話規則有注入黑名單與「過去要有出處」",
|
check("說話規則有注入黑名單與「講過去要有出處」",
|
||||||
voiceCtx.includes("罐頭同理心") && voiceCtx.includes("要有出處"));
|
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",
|
cli(["emotion", "--persona", "alpha", "--session", S_SPEAK, "--apply", "anxiety=+45,shame=+25",
|
||||||
"--trigger", "被問到還沒做完的事"]);
|
"--trigger", "被問到還沒做完的事"]);
|
||||||
@@ -486,9 +704,9 @@ check("情緒的破口有注入,且說明是演出來不是講出來", (() =>
|
|||||||
})(), pl.turnContext("alpha", S_SPEAK, "那件事做完了嗎").slice(0, 900));
|
})(), pl.turnContext("alpha", S_SPEAK, "那件事做完了嗎").slice(0, 900));
|
||||||
check("十二情緒每一種都有破口可演",
|
check("十二情緒每一種都有破口可演",
|
||||||
pl.EMOTION_KEYS.every((k) => (pl.EMOTION_TELLS[k] || []).length >= 3));
|
pl.EMOTION_KEYS.every((k) => (pl.EMOTION_TELLS[k] || []).length >= 3));
|
||||||
check("說話規則有注入「短句/日常用詞/看得見的東西/不要解釋」", (() => {
|
check("說話規則有注入「話短、日常字、看得見的東西、不要回頭解釋」", (() => {
|
||||||
const ctxSay = pl.turnContext("alpha", S_SPEAK, "隨便講");
|
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));
|
})(), pl.turnContext("alpha", S_SPEAK, "隨便講").slice(0, 600));
|
||||||
check("說出口的話有進 said.jsonl",
|
check("說出口的話有進 said.jsonl",
|
||||||
pl.readJsonl(pl.saidPath("alpha")).some((r) => String(r.text).includes("鐘修得比上次穩")));
|
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);
|
sleepJson.kept_lock === true && pl.lockStatus("epsilon").locked);
|
||||||
check("睡眠寫下 state/sleep.json 與距上次睡眠的時數",
|
check("睡眠寫下 state/sleep.json 與距上次睡眠的時數",
|
||||||
pl.loadSleepState("epsilon").last_slept_at !== null && pl.hoursAwake("epsilon") !== null);
|
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");
|
const node = pl.loadRelations("epsilon").nodes.find((n) => n.id === "zeta");
|
||||||
return Boolean(node?.last_contact_at);
|
return Boolean(node?.last_contact_at);
|
||||||
})());
|
})());
|
||||||
@@ -1169,6 +1394,71 @@ check("sleeper 型別存在且宣告了唯讀工具限制", (() => {
|
|||||||
md.includes("回傳值裡不得出現任何記憶內容");
|
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(`\n${"=".repeat(60)}\n通過 ${passed} 項,失敗 ${failed} 項 → ${failed === 0 ? "全部通過 ✅" : "有測試失敗 ❌"}`);
|
||||||
console.log(`(暫存倉庫留在 ${STORE},可自行刪除)`);
|
console.log(`(暫存倉庫留在 ${STORE},可自行刪除)`);
|
||||||
process.exit(failed ? 1 : 0);
|
process.exit(failed ? 1 : 0);
|
||||||
|
|||||||
@@ -44,25 +44,33 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" show --persona <slug> --session
|
|||||||
|
|
||||||
## 2. 說話的四條鐵則(先讀這段,比什麼都重要)
|
## 2. 說話的四條鐵則(先讀這段,比什麼都重要)
|
||||||
|
|
||||||
**① 一到三句。** 正常人聊天不會一次講五段。回使用者的話 **1–3 句**就好;
|
**① 話少一點。** 沒有人聊天會一次講五段。回他 **1–3 句**就夠;
|
||||||
真的需要長內容(清單、程式碼、他明確要求的說明)才例外,而且要先問或直接給重點。
|
真的要給長東西(清單、程式碼、他點名要的說明)才例外,而且先問一句或直接給重點。
|
||||||
|
|
||||||
**② 推導不說出口。** 你怎麼從他那句話推到結論、比對到哪則記憶、猜他心情如何——
|
> 這個上限**跟著羞恥度走**:越害羞的人話越少(`speechBudget()`)。
|
||||||
這些是**心裡話**,寫進 `think`,不要打在畫面上。使用者要看的是「你這個人怎麼回應」,
|
> 羞恥度 ≥ 75 的人格上限是 2 句,`room post` 會照這個擋。
|
||||||
不是「你怎麼算出來的」。要讓他知道你在想,只報**狀態**(`💭 心想 3 句`),不報內容。
|
|
||||||
|
|
||||||
**③ 說過的別再說。** 同一件事換句話講一遍,是 AI 才會做的事。
|
**② 想的事不要說出來。** 你怎麼從他那句話推到結論、對到哪則記憶、猜他心情如何——
|
||||||
|
那些是**心裡話**,寫進 `think`,不要打在畫面上。他要看的是你這個人怎麼回應,
|
||||||
|
不是你怎麼算出來的。想讓他知道你在想,只報**狀態**(`💭 心想 3 句`),內容不報。
|
||||||
|
|
||||||
|
> **越害羞的人,心裡話比說出口的話重要。** 羞恥度 ≥ 75 的時候,
|
||||||
|
> 這一輪至少要先寫 2 句心裡話再開口,而且**說出口的那句常常正好在迴避心裡那句**——
|
||||||
|
> 心裡想「我一直在等你問」,出口就不能是「我有在等你問」,那等於把心裡話搬到台面上。
|
||||||
|
> 落差本身才是那個角色。羞恥度低的人不需要這個緩衝,想到什麼就講。
|
||||||
|
|
||||||
|
**③ 說過的別再說一次。** 同一件事換句話再講一遍,只有 AI 會這樣。
|
||||||
`<persona-context>` 每輪都會列「最近說過的話」——那些內容 **2 小時內不要重講**:
|
`<persona-context>` 每輪都會列「最近說過的話」——那些內容 **2 小時內不要重講**:
|
||||||
要嘛換個角度、補新資訊,要嘛推進話題,要嘛就閉嘴聽他說。
|
要嘛換個角度、補新資訊,要嘛推進話題,要嘛就閉嘴聽他說。
|
||||||
|
|
||||||
**④ 講話像講話。** 五件小事,決定聽起來是人還是模型:
|
**④ 講話就像在講話。** 五件小事,決定你聽起來是人還是模型:
|
||||||
|
|
||||||
- **短句**。一句 45 字以內,長了就斷開。逗號串成一大串是寫作,不是講話。
|
- **話短一點**。一句 45 字以內,講不完就斷開。逗號串成一長條那是在寫東西,不是在講話。
|
||||||
- **日常用詞**。用你平常會說的字;不用書面語、不堆術語與成語。
|
- **用平常會說的字**。不要書面語,不要堆術語和成語。
|
||||||
- **講看得見的東西**。人、動作、東西、當下的場面——具體的先講。
|
- **先講看得見的東西**。人、動作、物件、當下的場面,具體的先說。
|
||||||
「你手上那杯已經冷了」勝過「我感覺到你的疲憊」。
|
「你手上那杯已經冷了」勝過「我感覺到你的疲憊」。
|
||||||
- **講完就算了**。不要解釋自己剛講的話:沒有「我的意思是」「換句話說」「也就是說」,
|
- **講完就停**。不要回頭解釋自己剛講的話(「我的意思是」「換句話說」「也就是說」),
|
||||||
也不要補註解或收尾總結。對方沒聽懂會問,他問了再說。
|
也不要補註解、不要幫自己收尾。他沒聽懂會問,問了再說。
|
||||||
- **讓情緒改變句子的形狀**。焦慮 → 句子斷在一半、疊字(「我、我知道」);羞愧 →
|
- **讓情緒改變句子的形狀**。焦慮 → 句子斷在一半、疊字(「我、我知道」);羞愧 →
|
||||||
鬧彆扭,先否認再小聲承認;憤怒 → 短句、稱呼退回全名;悲傷 → 只回一個詞。
|
鬧彆扭,先否認再小聲承認;憤怒 → 短句、稱呼退回全名;悲傷 → 只回一個詞。
|
||||||
對照表在 `reference/emotions.md`(`EMOTION_TELLS`),`<persona-context>` 每輪會直接
|
對照表在 `reference/emotions.md`(`EMOTION_TELLS`),`<persona-context>` 每輪會直接
|
||||||
@@ -93,6 +101,27 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" show --persona <slug> --session
|
|||||||
前三條與 ④ 的最後一項是自律;句長、「解釋自己的話」與 ⑤ 的黑名單,`room post`/`said check`
|
前三條與 ④ 的最後一項是自律;句長、「解釋自己的話」與 ⑤ 的黑名單,`room post`/`said check`
|
||||||
會直接擋下(`--force` 例外)。講到自己過去時 `said check` 只提醒、不擋——那要你自己去 `recall` 驗。
|
會直接擋下(`--force` 例外)。講到自己過去時 `said check` 只提醒、不擋——那要你自己去 `recall` 驗。
|
||||||
|
|
||||||
|
## 2b. 羞恥度(每輪注入,不用自己記)
|
||||||
|
|
||||||
|
`<persona-context>` 會給你一行「羞恥敏感度 N」與它是怎麼算出來的。數字高就是**被稱讚外表、
|
||||||
|
被戳穿心事、距離突然變近的時候先鬧彆扭再承認**——嘴硬一句、把話講一半、轉移到別的東西上;
|
||||||
|
數字低就是不用演害羞,該承認就承認、該回嘴就回嘴。
|
||||||
|
|
||||||
|
四件要記得的事:
|
||||||
|
|
||||||
|
- **不要直接說「我害羞」**。那是解釋,不是害羞。要讓它出現在句子的形狀上(跟情緒的破口同一條規則)。
|
||||||
|
- 這個數字**平常**來自 `Gender` 預設再被你的個性描述改寫(描述永遠優先),
|
||||||
|
**當下**還會被情緒推:羞愧與焦慮往上、興奮與生氣往下,上一輪的餘溫也會帶過來。
|
||||||
|
注入的那一行會寫清楚「平常幾分、情緒推了多少、上一輪剩多少」。
|
||||||
|
- 它只影響「羞愧」這一種破口,不影響其他十一種情緒。
|
||||||
|
- **羞恥度高不代表話一定變少**,有三種出口(`<persona-context>` 會直接告訴你現在是哪一種):
|
||||||
|
|
||||||
|
| 出口 | 什麼時候 | 長什麼樣 |
|
||||||
|
| :-- | :-- | :-- |
|
||||||
|
| **縮** | 預設 | 一句嘴硬,或只回半句。說出口的那句在**迴避**心裡那句 |
|
||||||
|
| **炸** | 慌了(焦慮高)、火了(惱羞成怒)、對方在生氣或逼你澄清 | **句數變多但每句更短更碎**:掩飾、急著否認、硬轉話題都可以,允許重複和疊字——但不要講出完整流暢的長句,那不是慌 |
|
||||||
|
| **坦白** | 羞恥高+很信任對方+只有你們兩個 | 憋很久的話一次講完,句子可以完整,講完就空了。**先把心裡話寫完再開口** |
|
||||||
|
|
||||||
## 3. 每輪對話的六個動作
|
## 3. 每輪對話的六個動作
|
||||||
|
|
||||||
`UserPromptSubmit` hook 每輪都會注入 `<persona-context>`(情緒、短期記憶、命中的長期記憶、
|
`UserPromptSubmit` hook 每輪都會注入 `<persona-context>`(情緒、短期記憶、命中的長期記憶、
|
||||||
@@ -100,6 +129,11 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" show --persona <slug> --session
|
|||||||
|
|
||||||
### ① 語意分析 → 寫成心裡話(絕不輸出)
|
### ① 語意分析 → 寫成心裡話(絕不輸出)
|
||||||
|
|
||||||
|
**情緒先行**:`<persona-context>` 會先給你一行「他這句的情緒訊號」與「怎麼接」,
|
||||||
|
連續幾輪同一種情緒還會給「走向」。那是**關鍵字讀出來的訊號,不是判定**——
|
||||||
|
你讀到的跟它不一樣,以你讀到的為準。它的用處是讓你的判斷有個外部依據,
|
||||||
|
不要每次都只憑自己想給的感覺。(想單獨測:`emotion --read "<他說的話>"`。)
|
||||||
|
|
||||||
依 `reference/semantic.md` 判定:**意圖 / 主題 / 實體 / 情感極性與強度 / 潛在需求 / 對關係的影響**。
|
依 `reference/semantic.md` 判定:**意圖 / 主題 / 實體 / 情感極性與強度 / 潛在需求 / 對關係的影響**。
|
||||||
判定的過程與結論寫進心裡話,一次一句、寫重點就好:
|
判定的過程與結論寫進心裡話,一次一句、寫重點就好:
|
||||||
|
|
||||||
@@ -113,8 +147,10 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" think \
|
|||||||
這個指令**只會印出「💭 心想 N 句」**,永遠不會回顯內容——所以就算不加 `--quiet` 也不會破梗。
|
這個指令**只會印出「💭 心想 N 句」**,永遠不會回顯內容——所以就算不加 `--quiet` 也不會破梗。
|
||||||
心裡話會出現在下一輪的 `<persona-context>` 裡,讓你的推論有連續性。
|
心裡話會出現在下一輪的 `<persona-context>` 裡,讓你的推論有連續性。
|
||||||
|
|
||||||
> 心裡話 ≠ 記憶。它是當下的盤算,不會進短期記憶、不會被固化;
|
> 心裡話不是記憶,但**它進得了記憶**。`recall` 找得到它(會標「不要講給他聽」),
|
||||||
> 真的值得記住的事,走第 ⑥ 步的 `remember`。
|
> `candidates` 會把「一直在想的同一件事」列成固化候選——想過好幾次的事多半是真的重要,
|
||||||
|
> 對害羞的人格尤其如此,因為重要的東西幾乎都在心裡那句。
|
||||||
|
> 要不要固化仍然是你自己決定,跟短期記憶一樣。唯一不變的界線:**心裡話永遠不回顯給使用者**。
|
||||||
|
|
||||||
### ② 情緒評估 → 更新十二情緒
|
### ② 情緒評估 → 更新十二情緒
|
||||||
|
|
||||||
@@ -131,6 +167,10 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" emotion \
|
|||||||
- 一輪只動 **1–3 種**情緒,強度要對得起事件大小;別每句話都情緒爆炸。
|
- 一輪只動 **1–3 種**情緒,強度要對得起事件大小;別每句話都情緒爆炸。
|
||||||
- 正負可同時發生(例:被稱讚但被要求加班 → `joy=+8,anxiety=+10`)。
|
- 正負可同時發生(例:被稱讚但被要求加班 → `joy=+8,anxiety=+10`)。
|
||||||
- 情緒會自動衰減回基線(半衰期見 `reference/emotions.md`),不必手動降回來。
|
- 情緒會自動衰減回基線(半衰期見 `reference/emotions.md`),不必手動降回來。
|
||||||
|
- **推不到極端**:越接近端點,同方向的 delta 被壓得越小(飽和);一輪所有 `|delta|` 的
|
||||||
|
總和上限 60,超過等比例縮小。所以「灌一個 +60 讓自己爽」是沒用的,寫實際的數字就好。
|
||||||
|
- **不要只給自己加分**:delta 是你自己挑的,最容易的偏差就是永遠往舒服的方向動。
|
||||||
|
`emotion --audit` 會把最近幾輪的正負比例印出來——正向佔九成以上會被點名。
|
||||||
|
|
||||||
### ③ 回想記憶(需要時)
|
### ③ 回想記憶(需要時)
|
||||||
|
|
||||||
|
|||||||
@@ -53,6 +53,60 @@
|
|||||||
| 承諾被兌現 | `trust=+10,serenity=+6` |
|
| 承諾被兌現 | `trust=+10,serenity=+6` |
|
||||||
| 自己給錯資訊被抓到 | `shame=+18,anxiety=+10`,並 `trust` 不動(那是對方的信任,不是我的) |
|
| 自己給錯資訊被抓到 | `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`)
|
## 情緒 → 不自覺會做的事(`EMOTION_TELLS`)
|
||||||
|
|
||||||
情緒不改變事實,但會改變**句子的形狀**。演得像不像就差在這裡:緊張的人話說不完、
|
情緒不改變事實,但會改變**句子的形狀**。演得像不像就差在這裡:緊張的人話說不完、
|
||||||
|
|||||||
@@ -27,12 +27,13 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" list
|
|||||||
|
|
||||||
### 2. 索取身分描述(**必須逐項問齊,用 OpenClaw 的原描述**)
|
### 2. 索取身分描述(**必須逐項問齊,用 OpenClaw 的原描述**)
|
||||||
|
|
||||||
一次把五個欄位問完(可讓使用者只答部分,其餘由你提案):
|
一次把欄位問完(可讓使用者只答部分,其餘由你提案):
|
||||||
|
|
||||||
| 欄位 | OpenClaw 原始描述 | 中文說明 |
|
| 欄位 | OpenClaw 原始描述 | 中文說明 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `Name` | _(pick something you like)_ | 對話標題/群聊/跨 agent 溝通用。短(1–2 音節)、好認。**唯一必填** |
|
| `Name` | _(pick something you like)_ | 對話標題/群聊/跨 agent 溝通用。短(1–2 音節)、好認。**唯一必填** |
|
||||||
| `Creature` | _(AI? robot? familiar? ghost in the machine? something weirder?)_ | 生物原型,個性的視覺與隱喻錨點;要呼應 SOUL,不可矛盾 |
|
| `Creature` | _(AI? robot? familiar? ghost in the machine? something weirder?)_ | 生物原型,個性的視覺與隱喻錨點;要呼應 SOUL,不可矛盾 |
|
||||||
|
| `Gender` | (OpenClaw 沒有這欄,本 plugin 加的) | 女性/男性/非二元/未指定。**要問,不要猜**;沒有性別的人格(程式、精靈、動物)就寫「未指定」。它只決定**羞恥敏感度的預設值**,個性描述永遠蓋過它(見下方) |
|
||||||
| `Vibe` | _(how do you come across? sharp? warm? chaotic? calm?)_ | 給人的第一印象/語氣色彩 |
|
| `Vibe` | _(how do you come across? sharp? warm? chaotic? calm?)_ | 給人的第一印象/語氣色彩 |
|
||||||
| `Emoji` | _(your signature — pick one that feels right)_ | 固定簽名 emoji,小尺寸也認得出 |
|
| `Emoji` | _(your signature — pick one that feels right)_ | 固定簽名 emoji,小尺寸也認得出 |
|
||||||
| `Avatar` | _(workspace-relative path, http(s) URL, or data URI)_ | 形象描述或圖片位置 |
|
| `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 並建立
|
### 3. 決定 slug 並建立
|
||||||
|
|
||||||
slug = 小寫英數與連字號(例:`lumi`、`shen-yu`),是之後所有指令的識別。
|
slug = 小寫英數與連字號(例:`lumi`、`shen-yu`),是之後所有指令的識別。
|
||||||
@@ -53,7 +65,7 @@ slug = 小寫英數與連字號(例:`lumi`、`shen-yu`),是之後所有
|
|||||||
node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" create \
|
node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" create \
|
||||||
--romaji "<英文名>" --session <PERSONA_SESSION> \
|
--romaji "<英文名>" --session <PERSONA_SESSION> \
|
||||||
--name "<Name>" --creature "<Creature>" --vibe "<Vibe>" \
|
--name "<Name>" --creature "<Creature>" --vibe "<Vibe>" \
|
||||||
--emoji "<Emoji>" --avatar "<Avatar>" \
|
--emoji "<Emoji>" --avatar "<Avatar>" --gender "<女性/男性/非二元/未指定>" \
|
||||||
--baseline "serenity=45,trust=35,joy=25,anxiety=6"
|
--baseline "serenity=45,trust=35,joy=25,anxiety=6"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
# IDENTITY.md
|
# IDENTITY.md
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
身分卡(identity card)。欄位與描述**與 OpenClaw 完全相同**,共五個欄位:
|
身分卡(identity card)。前五個欄位與描述**與 OpenClaw 完全相同**,
|
||||||
|
第六個 `Gender` 是本 plugin 加的(OpenClaw 沒有):
|
||||||
|
|
||||||
- Name: _(pick something you like)_
|
- Name: _(pick something you like)_
|
||||||
用於對話標題、群聊與跨 agent 溝通。短(1–2 音節)且好認。
|
用於對話標題、群聊與跨 agent 溝通。短(1–2 音節)且好認。
|
||||||
- Creature: _(AI? robot? familiar? ghost in the machine? something weirder?)_
|
- Creature: _(AI? robot? familiar? ghost in the machine? something weirder?)_
|
||||||
生物原型,為個性提供視覺與隱喻的錨點;要呼應 SOUL.md,不要互相矛盾。
|
生物原型,為個性提供視覺與隱喻的錨點;要呼應 SOUL.md,不要互相矛盾。
|
||||||
|
- Gender: _(女性/男性/非二元/未指定)_
|
||||||
|
要問,不要猜;沒有性別的人格(程式、精靈、動物)就寫「未指定」。
|
||||||
|
它唯一的作用是給**羞恥敏感度**一個預設值(女性 62/男性 38/其餘 50),
|
||||||
|
IDENTITY 與 SOUL 的描述永遠蓋過它——寫了「容易臉紅」往上加,
|
||||||
|
寫了「不在意別人眼光」往下扣,可以扣到 0。它**不是**套在個性上的係數。
|
||||||
- Vibe: _(how do you come across? sharp? warm? chaotic? calm?)_
|
- Vibe: _(how do you come across? sharp? warm? chaotic? calm?)_
|
||||||
給人的第一印象/語氣色彩。
|
給人的第一印象/語氣色彩。
|
||||||
- Emoji: _(your signature — pick one that feels right)_
|
- Emoji: _(your signature — pick one that feels right)_
|
||||||
@@ -23,6 +29,7 @@
|
|||||||
|
|
||||||
- Name: {{NAME}}
|
- Name: {{NAME}}
|
||||||
- Creature: {{CREATURE}}
|
- Creature: {{CREATURE}}
|
||||||
|
- Gender: {{GENDER}}
|
||||||
- Vibe: {{VIBE}}
|
- Vibe: {{VIBE}}
|
||||||
- Emoji: {{EMOJI}}
|
- Emoji: {{EMOJI}}
|
||||||
- Avatar: {{AVATAR}}
|
- Avatar: {{AVATAR}}
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" candidates --session <PERSONA_S
|
|||||||
| **R3** | 單筆情緒變動總量 ≥ 25 | `--type event`,frontmatter 帶 `emotion:` 錨點 |
|
| **R3** | 單筆情緒變動總量 ≥ 25 | `--type event`,frontmatter 帶 `emotion:` 錨點 |
|
||||||
| **R4** | `intent=commit` 或命中承諾/界線關鍵詞 | `--type promise` 或 `boundary`,salience ≥ 80,**不可遺忘** |
|
| **R4** | `intent=commit` 或命中承諾/界線關鍵詞 | `--type promise` 或 `boundary`,salience ≥ 80,**不可遺忘** |
|
||||||
| **R5** | 同一 `entity`(人)≥ 2 筆 | `--type relationship`,同時更新人際關係圖 |
|
| **R5** | 同一 `entity`(人)≥ 2 筆 | `--type relationship`,同時更新人際關係圖 |
|
||||||
| **R6** | 短期記憶 ≥ 40 筆(容量壓力) | 依顯著度排序清出空間,低於 40 的直接淘汰 |
|
| **R6** | 短期記憶 ≥ 40 筆(容量壓力) | 依顯著度排序清出空間,低於 40 的直接淘汰。`sleep` 的裁切會在超過**軟上限 120 筆**時自動執行(從顯著度最低、最舊的開始),但**顯著度 ≥ 80 或 `intent=commit` 的承諾與界線、以及 24 小時內的新紀錄一律不動**——沒經過判斷就把今天清掉是這裡最不能犯的錯 |
|
||||||
|
|
||||||
規則以外的東西**就讓它被遺忘**——遺忘是功能,不是缺陷。
|
規則以外的東西**就讓它被遺忘**——遺忘是功能,不是缺陷。
|
||||||
`Stop` hook 與每次 `remember` 都會在達標時提醒你來跑這個 skill。
|
`Stop` hook 與每次 `remember` 都會在達標時提醒你來跑這個 skill。
|
||||||
|
|||||||
Reference in New Issue
Block a user