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

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