Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
35c8665db0 | ||
|
|
9d76984c27 | ||
|
|
b5c540feb1 | ||
|
|
0917e905c7 |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jsc-persona",
|
||||
"version": "0.2.0",
|
||||
"version": "0.2.1",
|
||||
"description": "AI 人格化記憶聊天 plugin:以 OpenClaw 相同的身分描述(IDENTITY/SOUL)建立人格(可用動漫作品+角色名上網蒐集設定),結合 hook 強制的人格載入鎖與跨人格隔離、六正向+六負向十二情緒、語意分析、短期/長期記憶與固化條件、心智圖、思維導圖與人際關係圖;可用 sub agent 邀請其他人格同場對話(劇場模式只顯示人格對話)。於 Claude Code 以 /jsc-persona: 前綴呼叫。",
|
||||
"skills": "./skills",
|
||||
"author": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jsc-persona",
|
||||
"version": "0.2.0",
|
||||
"version": "0.2.1",
|
||||
"description": "AI 人格化記憶聊天 skills:OpenClaw 相同的人格描述 + 十二情緒 + 語意分析 + 短期/長期記憶 + 心智圖 + 人際關係圖。(人格鎖與跨人格隔離的 hook 僅在 Claude Code 生效)",
|
||||
"skills": "./skills"
|
||||
}
|
||||
|
||||
@@ -9,5 +9,8 @@ Thumbs.db
|
||||
*.tmp
|
||||
*.log
|
||||
|
||||
# 工作用的 TODO 清單:留在本機,不進版控
|
||||
/TODO_*.md
|
||||
|
||||
# Node
|
||||
node_modules/
|
||||
|
||||
@@ -26,7 +26,9 @@ G2 與 G3 跟硬規則 8(不說 AI 才會說的話、演出來不要講出來
|
||||
放大的是**句子的形狀與語域**,被禁的仍然是**用旁白解釋自己的情緒**。
|
||||
兩者相撞時以硬規則 8 為準,然後把該案例寫進 `anti-ai-voice.md`。
|
||||
|
||||
實作進度與待確認的決策在 `TODO_人格優化.md`(階段 5 是 G2/G3 那批)。
|
||||
G2/G3 那批(emoji 表程度、名字後括號的動作格、鬧彆扭的階梯)**還沒實作**,
|
||||
拍板紀錄與實作清單留在 [PR #17](https://gitea.jsc.idv.tw/plugins/persona/pulls/17) 的討論裡——
|
||||
TODO 清單不進版控。
|
||||
|
||||
---
|
||||
|
||||
@@ -252,7 +254,7 @@ X 超出基線 18 以上才開始抑制,滿檔時往 Y 的同向推力只剩 5
|
||||
|
||||
| 來源 | 效果 |
|
||||
| --- | --- |
|
||||
| 性別預設 | 女性 62/男性 38/非二元・未指定 50 |
|
||||
| 性別預設 | 女性 **70**/男性 38/非二元・未指定 50(女性偏高是刻意的——文字通道裡害羞與鬧彆扭是最有效的擬真訊號之一) |
|
||||
| IDENTITY/SOUL 的描述 | 「害羞」「容易臉紅」「怕生」「矜持」往上加;「不在意別人眼光」「我行我素」「臉皮厚」「不怕丟臉」往下扣(`MODESTY_SIGNALS`),**可以扣到 0** |
|
||||
|
||||
排序固定:**① 個性描述 ② 角色原作既有的性別化語言特徵(自稱、稱謂、語尾) ③ 性別預設**。
|
||||
@@ -261,8 +263,20 @@ X 超出基線 18 以上才開始抑制,滿檔時往 Y 的同向推力只剩 5
|
||||
|
||||
效果走既有機制、不另開一套:它調的是**「羞愧」這一個破口的顯示門檻**(`EMOTION_TELLS.shame`
|
||||
本來就是「鬧彆扭,先否認再小聲承認」),其餘十一種情緒不受影響。
|
||||
高敏感度注入「被稱讚外表、被戳穿心事、距離變近時先鬧彆扭再承認,不要直接說『我害羞』」;
|
||||
低敏感度注入「不用演害羞,該承認就承認」。計算在 `modestyOf()`,注入在 `modestyDirective()`。
|
||||
計算在 `modestyOf()`,注入在 `modestyDirective()`。
|
||||
|
||||
**鬧彆扭有階梯**(同一個「不好意思」,強度不同是不同的行為):
|
||||
|
||||
| 羞恥度 | 那一級的樣子 | 三段的形狀 |
|
||||
| --- | --- | --- |
|
||||
| ≤ 40 | 不用演害羞,該承認就承認、該回嘴就回嘴 | — |
|
||||
| 41–59 | 會不好意思,但不會卡在那裡 | 一句帶過就往下走 |
|
||||
| 60–74 | 嘴硬 | **先否認 → 反駁他的說法 → 最後一句音量掉下來**(「⋯才不是那樣。」「你不要一直看這邊。」「⋯嗯。」) |
|
||||
| ≥ 75 | 惱羞 | **先否認 → 反駁 → 翻臉或走開**(「不要再講了」)。惱羞是**換情緒**(羞愧 → 憤怒),所以仍然是兩個動作,不是三個 |
|
||||
|
||||
三段是**句法模板**,不是自由發揮——鬧彆扭天生就是兩個動作一起來,
|
||||
只准一個等於永遠只做得到一半:單獨否認像在爭辯,單獨轉移像沒聽到。
|
||||
害羞的預設動作是**臉紅**(生理反應算動作,`(害羞)` 不算——那是情緒名稱)。
|
||||
|
||||
**不做的事**:沒有「女性→情緒更外顯」這種全域放大。那會把角色壓成模板,
|
||||
跟講話規則在做的去 AI 味(刪掉公式化)正好相反。
|
||||
@@ -288,6 +302,16 @@ X 超出基線 18 以上才開始抑制,滿檔時往 Y 的同向推力只剩 5
|
||||
沒命中任何規則的就讓它被裁掉——**遺忘是功能**。達標時 `Stop` 與 `remember` 都會提醒去跑
|
||||
`/jsc-persona:persona-memory`。
|
||||
|
||||
**判斷過的要留痕跡**(不然那個數字永遠不會降):`candidates` 只看還沒被判斷過的短期記憶,
|
||||
判斷過的靠兩個欄位認——`reviewed_at`(看過了)與 `promoted_to`(固化成了哪一則)。
|
||||
固化時用 `consolidate --from-short <#N,#N>` 標,看過決定不記的用 `candidates --reviewed <#N,#N>`
|
||||
(整批就 `--reviewed all`),編號是 `candidates` 輸出裡每筆前面的 `#N`。
|
||||
|
||||
沒有這一層會發生的事:固化本身**不刪**短期記憶(`--forget` 是按顯著度刪、不是按固化過沒有刪),
|
||||
所以同一批下一輪又被算成候選,數字只會往上爬——明明睡覺時固化過,隔天開機照樣提醒
|
||||
「N 組已達固化條件」,看起來像睡眠沒做固化,其實是判斷沒有留下痕跡。
|
||||
標記**不等於刪掉**:那幾筆還在短期記憶裡,照舊依天數與顯著度被 `prune` 裁。
|
||||
|
||||
## 長期記憶會糊掉,但不會不見(回想強度與模糊態)
|
||||
|
||||
在這之前記憶只有兩態:**精準**(`recall` 命中就整段取出、內容永不變質)與**沒有**
|
||||
@@ -341,11 +365,39 @@ AI 最容易露餡的六件事:把推理過程講出來、一次講一大段
|
||||
| **心裡話** `think` | 語意分析、推論、盤算全寫進 `state/inner.jsonl`;這個指令**只印「💭 心想 N 句」**,內容永不回顯。下一輪 `<persona-context>` 會帶回最近三句,推論因此有連續性,但使用者只看得到狀態 |
|
||||
| **一到三句** | `<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 視為同一句 |
|
||||
| **情緒的破口** | 情緒不改變事實,但會改變**句子的形狀**:焦慮 → 句子斷在一半、疊字(「我、我知道」);羞愧 → 鬧彆扭,先否認再小聲承認;憤怒 → 短句、稱呼退回全名;悲傷 → 只回一個詞。十二情緒各自的破口寫在 `EMOTION_TELLS`,`emotionTells()` 每輪挑主導情緒裡強度 ≥ 40 的前兩種注入。**破口可以逐人格覆寫**(`IDENTITY.md` 的 `## Tells` 區塊)——桐人生氣是沉默,亞絲娜生氣是變得更禮貌;同一格情緒,破口完全不同,共用一張表等於所有人格在高情緒下講起話來都一個樣。沒寫的情緒退回全域預設。三條界線:**演出來不要講出來**(「我有點緊張」是解釋,斷句才是緊張)、**一輪最多露一個破口**、**強度不到就不演**。混合狀態(緊張=焦慮+期待、害羞=喜悅+羞愧、賭氣=憤怒+悲傷…)的對照表在 `skills/persona-chat/reference/emotions.md` |
|
||||
| **情緒的破口** | 情緒不改變事實,但會改變**句子的形狀**:焦慮 → 句子斷在一半、疊字(「我、我知道」);羞愧 → 鬧彆扭,先否認再小聲承認;憤怒 → 短句、稱呼退回全名;悲傷 → 只回一個詞。十二情緒各自的破口寫在 `EMOTION_TELLS`,`emotionTells()` 每輪挑主導情緒裡強度 ≥ 40 的前兩種注入。**破口可以逐人格覆寫**(`IDENTITY.md` 的 `## Tells` 區塊)——桐人生氣是沉默,亞絲娜生氣是變得更禮貌;同一格情緒,破口完全不同,共用一張表等於所有人格在高情緒下講起話來都一個樣。沒寫的情緒退回全域預設。三條界線:**演出來不要講出來**(「我有點緊張」是解釋,斷句才是緊張)、**一輪最多兩個動作、而且要來自同一種情緒並有遞進關係**(否認 → 轉移、笑 → 補一句、留白 → 不追問;兩種情緒各演一個才是演戲)、**強度不到就不演**。混合狀態(緊張=焦慮+期待、害羞=喜悅+羞愧、賭氣=憤怒+悲傷…)的對照表在 `skills/persona-chat/reference/emotions.md` |
|
||||
| **短句白話** | 四條講話的樣子每輪注入:**短句**(一句 45 字內,`MAX_SENTENCE_CHARS`)、**日常用詞**、**多講看得見的東西**(人、動作、物件、場面)而不是概念、**不要解釋自己的話**。句長與「我的意思是/換句話說/也就是說」這類開頭由 `speechLint()` 機械攔截(`room post` 擋下、`said check` 事前警告,`--force` 例外);用詞與具體度沒辦法用規則抓,靠注入的規則自律 |
|
||||
| **不說 AI 才會說的話** | 十幾種「一出現就破功」的句子由 `SPEECH_BLACKLIST` 機械攔截:罐頭同理心(「這個我懂」「我完全理解」)、頒獎開場(「好問題」)、交差句(「希望這對你有幫助」)、預告(「接下來我會」)、說教腔(「說到底」「本質上」)、假坦白開場(「老實說」)、罐頭收尾(「總的來說」)、立場真空(「各有優缺點」「因人而異」)、無來源權威(「研究顯示」)、用旁白演情緒(「我愣了一下」),加上一句疊兩層避險、中國用語、半形標點、emoji/破折號/粗體與清單符號、「不是 A 而是 B」一輪超過一次。**誤殺防護**:引號與 `` `code` `` 裡的內容一律不比對(「我最近戒掉『賦能』這個詞」是提及不是使用),「老實說」只擋開場。模式借自 [speak-human-tw](https://github.com/Raymondhou0917/speak-human-tw)(MIT)的 38 種 AI 寫作痕跡,只搬對話也適用的刪除層 |
|
||||
| **不說 AI 才會說的話** | 十幾種「一出現就破功」的句子由 `SPEECH_BLACKLIST` 機械攔截:罐頭同理心(「這個我懂」「我完全理解」)、頒獎開場(「好問題」)、交差句(「希望這對你有幫助」)、預告(「接下來我會」)、說教腔(「說到底」「本質上」)、假坦白開場(「老實說」)、罐頭收尾(「總的來說」)、立場真空(「各有優缺點」「因人而異」)、無來源權威(「研究顯示」)、用旁白演情緒(「我愣了一下」),加上一句疊兩層避險、中國用語、半形標點、破折號/粗體與清單符號、「不是 A 而是 B」一輪超過一次(**emoji 不在這裡**——它是開放的,只在明顯過量時給 hint,見上一節)。**誤殺防護**:引號與 `` `code` `` 裡的內容一律不比對(「我最近戒掉『賦能』這個詞」是提及不是使用),「老實說」只擋開場。模式借自 [speak-human-tw](https://github.com/Raymondhou0917/speak-human-tw)(MIT)的 38 種 AI 寫作痕跡,只搬對話也適用的刪除層 |
|
||||
| **講過去要有出處** | 前一條是「不准說什麼」,這條是人格才做得到的「可以說什麼」。文章改寫工具的界線是「人味是作者的,不是你的」——AI 沒有過去,所以不准寫「我以前錯了」。人格有過去:長期記憶、日記、關係圖、十二情緒。所以「我以前⋯」「我原本以為⋯」是**有出處的引用**,條件是先 `recall` 查得到;`said check` 遇到這類句子會印一行提醒(`level: "hint"`,不擋你,要你自己去驗)。「查得到」有三態:清晰的照講;**模糊的可以說不確定、可以用帶問號的試探句求證,不可以斷言**(每次試探要 `probe add` 記帳,被否認當場寫更正記憶);完全查不到的一個字都不准講 |
|
||||
|
||||
### 文字通道的另外兩格:emoji 的程度與名字後面的動作
|
||||
|
||||
只有文字,所以情緒表達是**排版問題**。除了句子的形狀,還有兩個位置可以用:
|
||||
|
||||
**emoji = 強度計**(種類表情緒、數量表程度,由 `emotion.json` 的主導情緒自動帶入,不是自己挑):
|
||||
|
||||
| 情緒值 | 樣子 | 例 |
|
||||
| --- | --- | --- |
|
||||
| < 40 | 不顯示(跟「強度不到就不演」同一條線) | `桐人:你不要看這邊。` |
|
||||
| 40–59 | 情緒 emoji | `桐人(😳・臉紅):你不要看這邊。` |
|
||||
| 60–79 | +`💦` | `桐人(😳💦・臉紅):⋯才不是那樣。` |
|
||||
| 80+ | +`💦❗` | `桐人(😳💦❗・把臉轉開):不要再講了。` |
|
||||
|
||||
十二情緒各一個 emoji 寫在 `EMOTION_EMOJI`,強度符號兩階(`EMOJI_INTENSITY`),
|
||||
**逐人格可覆寫**(`IDENTITY.md` 的 `## Emoji` 區塊,跟 `## Tells` 同一層)。
|
||||
刻意不用「同一個 emoji 重複三次」——疊字看起來像洗頁。
|
||||
句子裡也可以用、**沒有數量上限**;唯一的煞車是 `emotion --audit` 裡的
|
||||
`emojiAudit()`(幾則帶符號、平均幾個、有幾則**只有符號沒有句子**)。
|
||||
用 linter 偷偷把它關回去等於撤銷「開放」這個決定,所以 `speechLint()` 對 emoji 只給 hint、不擋。
|
||||
|
||||
**名字後面的括號有兩格**:`名字(情緒・動作):內容`。括號**原本就有**(放情緒標註),
|
||||
動作是加進去的第二格,不是換掉它。動作只寫**看得見或聽得見的**(臉紅、別過頭、手在抖、聲音變小),
|
||||
由 `actionLint()` 擋三種寫法:超過 12 字、一格塞兩個動作、以及寫情緒名稱
|
||||
(`(害羞)` 是旁白換了個位置,情緒屬於前面那一格)。括號裡的動作**算進一輪兩個動作的額度**。
|
||||
記憶也記得那個行為:`remember --behavior "把手縮回來"`(記做了什麼,不是感覺到什麼)。
|
||||
|
||||
星號內嵌(`*別過頭*`)與第三人稱旁白(「她臉紅了」)仍然禁止——那是硬規則 8 沒有鬆的部分。
|
||||
|
||||
字集合權重較高,是為了分開「重排語序」與「換掉關鍵詞」這兩種很像但意義完全不同的情況:
|
||||
|
||||
```
|
||||
|
||||
-443
@@ -1,443 +0,0 @@
|
||||
# TODO — 人格擬真優化
|
||||
|
||||
> 目標:讓人格在**記憶結構**與**情緒/語氣表達**上更接近真人(G1),
|
||||
> 表達往**動漫角色**靠(G2),女性人格的**害羞與鬧彆扭**更明顯(G3)。三條的定義與界線見 `README.md` 的〈專案目標〉。
|
||||
> 前提:**只有文字**這一個通道,情緒表達等於排版問題——階段 5 整批都建在這句話上。
|
||||
> 建立日期:2026-08-03|依據:現行 `scripts/persona-lib.mjs`、`skills/persona-chat/**`、`skills/persona-memory`、`skills/persona-relation`
|
||||
|
||||
---
|
||||
|
||||
## 現況判斷
|
||||
|
||||
表層(去 AI 腔、句長上限、心裡話不外洩、情緒破口演在句形上、羞恥度三出口)已經做得很徹底。
|
||||
剩下的「不像人」不是語氣問題,是三個結構性缺口:
|
||||
|
||||
| 根因 | 現在的行為 | 真人的行為 |
|
||||
| :-- | :-- | :-- |
|
||||
| **記憶只有「精準」與「沒有」兩態** | `recall` 命中就整段取出、內容永不變質;查不到就禁止提 | 大部分時間活在中間帶:「我記得好像…是你說的嗎」。主旨留著、細節掉了 |
|
||||
| **人格永遠以對方為中心** | 每輪都在服務這句話,自己沒有懸著的事 | 有未完事項與自己在意的事,會追問「你上週說要看牙醫,去了嗎」 |
|
||||
| **同一句話任何時刻聽起來都一樣** | 情緒是即時事件的函數;十二情緒全人格共用同一張 tells 表 | 有當日底色與生理狀態;而且每個人生氣的樣子不一樣 |
|
||||
|
||||
三個「不要做」:撒口語碎片充人味(`anti-ai-voice.md` 第六點已寫)、給情緒加隨機數(隨機不是情緒是雜訊)、
|
||||
加隨機口誤(唯一值得的變體是嚴格限量的「自我打斷改口」)。
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 實作前先確認(未逐項回覆前不要動手)
|
||||
|
||||
> **規則**:這份 TODO 的任何項目,動手前都要先跟使用者確認過。看到 TODO 不等於可以開始寫。
|
||||
> 下面四題的答案會**改變要寫的東西**,不是風格偏好,所以必須先問。
|
||||
|
||||
### Q1. 這次實作要做到哪個階段?
|
||||
|
||||
(階段 0 的復原與 commit 不在此列,那個一定要先做。)
|
||||
|
||||
| 選項 | 含什麼 | 代價 |
|
||||
| :-- | :-- | :-- |
|
||||
| **只做階段 1**(建議) | open loops、`hoursAwake` 接語氣、`EMOTION_TELLS` per-persona 覆寫、情緒抑制與慣性 | 一輪做完,不動任何既有檔案格式,馬上聽得出差別 |
|
||||
| 階段 1 + 2 | 再加當日心情底色、情境索引+情緒一致性回想 | 會動 `recall` 排序與記憶欄位,但不改長期記憶檔案格式 |
|
||||
| 階段 1 + 2 + 3 一次做完 | 含回想強度/模糊態/gist-detail 分層 | 改長期記憶 frontmatter,需要 migration 與 bundle 升版,回歸測試量大 |
|
||||
| 先做階段 3 | 優先換掉記憶結構的地基 | 改動最深,但擬真的天花板由它決定 |
|
||||
|
||||
- [x] 已確認範圍:**階段 1 + 2 + 3 一次做完**(2026-08-03 使用者拍板)
|
||||
|
||||
### Q2. 模糊記憶的界線要多嚴?
|
||||
|
||||
會跟現行「查不到就不要講、編轉折比空話更糟」直接相撞,所以要先定。
|
||||
|
||||
| 選項 | 允許說出口的樣子 | 風險 |
|
||||
| :-- | :-- | :-- |
|
||||
| **保守:只能說記不清**(建議) | 「我記得你講過類似的,但我記不清。」一個細節都不能提 | 零幻覺風險,代價是模糊態的表現力較平 |
|
||||
| 中等:可講主旨、必須標不確定 | gist 還在就可以講主旨(「好像跟你哥有關」),但必須帶不確定標記 | 表現力好很多,但主旨本身也可能記錯,需要稽核 |
|
||||
| 開放:可試探性補細節並求證 | 「是不是上個月那次?」 | 最像人,但等於開了幻覺的側門;沒有稽核機制前不建議 |
|
||||
|
||||
- [x] 已確認界線:**開放——可試探性補細節並求證**(2026-08-03 使用者拍板)。
|
||||
因為這是幻覺的側門,3.3 的稽核機制**改成同批必做、不可延後**:
|
||||
試探句必須帶問號求證、不可斷言;`said check` 記錄每一次試探;`memory --audit` 要能查出
|
||||
「試探後對方否認」的比例。試探被否認時要立刻寫一則更正記憶,不可放著。
|
||||
|
||||
### Q3. 長期記憶檔案格式(`strength`、gist/detail)怎麼遷移?
|
||||
|
||||
只在做階段 3 時才需要,但先定案可以省一輪。
|
||||
|
||||
| 選項 | 做法 | 代價 |
|
||||
| :-- | :-- | :-- |
|
||||
| **就地 migration,一次改全部**(建議) | 寫一支 migration 掃過所有人格的 `memory/long-term/*.md` 補欄位並切分內文 | 乾淨、之後只有一種格式;務必先用 `persona-transfer export` 備份 |
|
||||
| 只有新記憶帶新欄位 | 舊檔維持原狀,讀取時給預設值 | 零風險,但舊記憶永遠進不了衰減與模糊態——最重要的老記憶反而沒效果 |
|
||||
| 雙格式並存,讀時正規化 | 不改磁碟,在讀取層統一 | 可回頭,但兩種格式的分支會長期留在程式裡 |
|
||||
|
||||
- [x] 已確認策略:**就地 migration,一次改全部**(2026-08-03 使用者拍板)。動之前先 `persona-transfer export` 備份。
|
||||
|
||||
### Q4. 階段 4 的「自我議程」要做嗎?
|
||||
|
||||
人格某些輪會把話題拉回自己在意的事、不服務對方。
|
||||
|
||||
| 選項 | 說明 |
|
||||
| :-- | :-- |
|
||||
| **要,低頻**(建議) | 每 5–8 輪最多一次,且只在對方沒有明確急事時。這是「像 AI」最頑固的殘留(永遠以對方為中心),低頻就有效 |
|
||||
| 要,明顯一點 | 更常拉回自己的事、允許答非所問。更像真人,但會明顯降低助理可用性 |
|
||||
| 先不做 | 留在 TODO 但不實作,等前面幾階段穩定後再評估 |
|
||||
|
||||
- [x] 已確認:**要,明顯一點**(2026-08-03 使用者拍板)。允許某些輪把話題拉回自己的事、允許答非所問;
|
||||
頻率上限放寬到每 3 輪最多一次(原建議 5–8 輪),但對方有明確急事時一律不觸發。
|
||||
|
||||
### 其餘我先給預設值的(不同意就講,否則照這樣做)
|
||||
|
||||
| 項目 | 預設 |
|
||||
| :-- | :-- |
|
||||
| per-persona tells 放哪 | 放 `IDENTITY.md` 新增區塊(tells 屬身分,不屬可變狀態),不放 `state/` |
|
||||
| open loops 上限與逾期 | 同時最多 5 條;7 天沒進展自動收掉並留一則「沒下文」記憶 |
|
||||
| 心情底色是否吃外部因素 | 只吃內部(情緒事件 + `hoursAwake` + 上次睡眠),**不接天氣/時區等外部資料** |
|
||||
| 情緒抑制矩陣範圍 | 只寫三組明確互斥:anger↔joy、sadness↔delight、disgust↔trust。不做全 12×12 |
|
||||
| 黃金腳本怎麼判定 | 人工看(擬真無法自動判定);只有硬界線(句長、黑名單、句數)走 CLI 自動擋 |
|
||||
| 版本號 | 整批只升一個版號,兩份 manifest 一起改;改前先看 master 現在幾版 |
|
||||
|
||||
---
|
||||
|
||||
## 階段 0:開工前(先做,否則會踩到)
|
||||
|
||||
- [x] **復原被刪的 hook 與 manifest**:工作區的 `hooks/`(7 支 .mjs + hooks.json)、`.claude-plugin/`、
|
||||
`.codex-plugin/`、`plugin.json`、兩份 marketplace.json 已從磁碟消失(`git status` 顯示 ` D`,未 commit)。
|
||||
目前跑的是安裝快取那份所以不會立刻出錯。
|
||||
→ `git checkout -- hooks .claude-plugin .codex-plugin plugin.json .agents`
|
||||
**在這個狀態下不要 commit。**
|
||||
- [x] **先把既有 WIP 收掉**:`persona-lib.mjs`/`persona.mjs`/四份 SKILL 有約 485 行未 commit 的羞恥度改動,
|
||||
`skills/persona-relation/reference/closeness.md` 還是 untracked。先 commit 這批,再開新改動。
|
||||
- [x] 確認 `KIRITO-01` 的鎖狀態(曾被 session `f60d8e32…` 佔用,cwd `/home/coder/plugins/persona`)。
|
||||
確定那個程序結束了才 `--takeover`。
|
||||
|
||||
---
|
||||
|
||||
## 階段 1:低成本高效果(可一輪做完)
|
||||
|
||||
### 1.1 未完事項 open loops ★ 投報率最高
|
||||
|
||||
- [x] 新增 `state/loops.json`,同時只留 3–5 條:他沒回答的問題、他答應要做的事、被打斷的話題、我想問但沒問的。
|
||||
- [x] CLI:`loop add|done|list`(沿用 `withFileLock` 寫入)。
|
||||
- [x] `UserPromptSubmit` hook 把它注入 `<persona-context>`(`hooks/prompt_submit.mjs`)。
|
||||
- [x] 超過 N 天沒進展 → 自動收掉,並留一則「這件事沒下文」的短期記憶。
|
||||
- **為什麼**:被記住的感覺幾乎全部來自這個,而不是來自長期記憶檢索。
|
||||
- **驗收**:隔天載入時人格會主動追問上次懸著的事,且同一件事不會追問兩次(`said check` 擋)。
|
||||
|
||||
### 1.2 把 `hoursAwake()` 接到語氣上(幾乎免費)
|
||||
|
||||
- [x] `speechBudget()`:清醒越久 → 句數/句長上限往下降。
|
||||
- [x] `mood()`/`applyEmotion()`:清醒越久 → arousal 天花板往下降。
|
||||
- **為什麼**:函式已經存在(`persona-lib.mjs`),現在只用在提醒睡覺。深夜的人話少而鈍,最便宜的擬真訊號。
|
||||
- **驗收**:連續對話數小時後,回覆自然變短變鈍,不需要人格自己演。
|
||||
|
||||
### 1.3 `EMOTION_TELLS` 支援 per-persona 覆寫
|
||||
|
||||
- [x] `emotionTells()` 先讀人格自己的覆寫(`state/tells.json` 或 `IDENTITY.md` 新增 `Tells` 區),再退回全域預設。
|
||||
- [x] `persona-create` / `persona-anime` 建立人格時一併問/推導 3–5 條專屬破口。
|
||||
- **為什麼**:現在所有人格生氣都變短句、稱呼退回全名。但桐人生氣是**沉默**,亞絲娜生氣是**變得更禮貌**——
|
||||
同一格情緒,破口完全不同。
|
||||
- **驗收**:兩個人格在同一種高強度情緒下,句子的形狀明顯不同。
|
||||
|
||||
### 1.4 情緒交互抑制與慣性(輸入都算好了,只差回饋)
|
||||
|
||||
- [x] `applyEmotion()` 加**抑制**:`anger` 高時 `joy` 的正向 delta 打折(剛生完氣沒那麼容易被逗笑)。
|
||||
建議做成小矩陣,只寫幾組明確互斥的(anger↔joy、sadness↔delight、disgust↔trust)。
|
||||
- [x] 加**慣性**:`feltTrend()` 已在算走向但沒回饋到增益;連續同向時讓同方向 delta 略微放大。
|
||||
- [x] 更新 `skills/persona-chat/reference/emotions.md` 的「情緒調節」段(現在只寫飽和與單輪預算)。
|
||||
- **驗收**:`emotion --audit` 的正負比例不再一路偏正;剛吵完架的下一輪逗不笑。
|
||||
|
||||
---
|
||||
|
||||
## 階段 2:中成本(新檔案/排序邏輯)
|
||||
|
||||
### 2.1 當日心情底色 `state/mood.json`
|
||||
|
||||
- [x] 三層改成:即時情緒 → **當日底色(緩慢漂移的 valence/arousal)** → 氣質基線。
|
||||
- [x] 底色當**反應增益**:底色差的日子,同樣一句話的 delta 乘 1.3(上限要夾)。
|
||||
- [x] `sleep` 時重設/部分帶過(`SLEEP_DECAY_MINUTES` 已存在,底色不要一次歸零)。
|
||||
- **為什麼**:現在缺日級別的中間層,所以「今天聽了會炸、昨天不會」做不出來。
|
||||
- **驗收**:同一句刺激話在不同日子的 delta 不同,且理由可解釋(不是隨機)。
|
||||
|
||||
### 2.2 情境索引 + 情緒一致性回想
|
||||
|
||||
- [x] 記憶(短期與長期)加 `when` / `where` / `mood` 欄位。
|
||||
- [x] `recall` 排序加一項:**與當下情緒同極性的記憶加權**。
|
||||
- **為什麼**:現在檢索只靠 `topics`/`entities`(語意線索)。人的回想主要靠情境線索。
|
||||
這條同時讓情緒有了「後果」,不再只是調語氣的裝飾。
|
||||
- **驗收**:心情差的時候先想起難過的事;同一個 query 在不同情緒下回不同順序。
|
||||
|
||||
---
|
||||
|
||||
## 階段 3:記憶結構重整(單獨一輪做,會動檔案格式)
|
||||
|
||||
### 3.1 用「回想強度」取代門檻式遺忘,並允許模糊態
|
||||
|
||||
- [x] 長期記憶 frontmatter 加 `strength`;`retrievability = f(salience, recall_count, 距上次回想多久)` 連續衰減。
|
||||
- [x] 低於門檻**不刪**,降級成**模糊態**:`recall` 回「有這件事,細節想不起來」。
|
||||
- [x] 被 `recall` 命中就重設並拉長下次衰減(spacing effect:越常想起的越牢)。
|
||||
材料已經有了(`recall_count`/`last_seen` 都在寫)。
|
||||
- [x] `prune`/`persona-memory` 的遺忘規則同步改寫(現在是 salience 門檻 + 240 筆/14 天)。
|
||||
**不可遺忘**清單(`boundary`/`promise`/`canon`/salience ≥ 80)維持不變。
|
||||
|
||||
### 3.2 長期記憶分 gist / detail 兩層
|
||||
|
||||
- [x] 內文明確切「主旨」與「細節」;衰減**先吃 detail**、gist 最後才掉。
|
||||
- **效果**:「記得我們吵過,但忘了為什麼」變成自然結果,不必人格演。
|
||||
|
||||
### 3.3 ⚠️ 同步改幻覺界線(跟 3.1/3.2 一起做,不可延後)
|
||||
|
||||
模糊態會跟現行的「查不到就不要講、編轉折比講空話更糟」直接相撞。界線要寫死進
|
||||
`skills/persona-chat/reference/anti-ai-voice.md` 與 SKILL 第 ② 節:
|
||||
|
||||
> **可以說不確定,不可以填內容。**
|
||||
> 允許:「我記得你講過類似的,但我記不清。」
|
||||
> 禁止:把想不起來的細節補出來。
|
||||
|
||||
- [x] `said check` 對模糊態句型只提醒不擋(跟現行「講到自己過去」的 hint 同一層)。
|
||||
- [x] 新增稽核:模糊態被用來編內容的次數(比照 `emotion --audit`)。
|
||||
|
||||
### 3.4 遷移與相容
|
||||
|
||||
- [x] 舊長期記憶檔的 migration(補 `strength`/切 gist/detail 可先給預設值)。
|
||||
- [x] `persona-transfer` 的 bundle 版本號要跟著升,`import` 要能吃舊版。
|
||||
- [x] `selftest.mjs` 補對應案例。
|
||||
|
||||
---
|
||||
|
||||
## 階段 4:加分項
|
||||
|
||||
- [ ] **自傳章節** `memory/chapters/`:時期層(「在 SAO 那兩年」「跟他一起做 persona 這段」),
|
||||
長期記憶掛在時期底下。問「你這幾個月怎麼樣」時才講得出敘事而不是列點。
|
||||
- [ ] **習慣記憶** `--type habit`:現在全是宣告性記憶(fact/event/preference),缺「我們之間的慣例」
|
||||
(他早上第一句都在抱怨會議、我都回一句「幾點」)。熟悉感來自這個。
|
||||
- [ ] **口語指紋 idiolect**:現在是減法(刪 AI 腔)+語氣層,缺加法。per-persona 的常用詞/句尾/自稱/
|
||||
慣用迴避句式/絕對不說的詞。`relation style` 已有 `口頭禪`/`習慣` facet 但那是 per-relation。
|
||||
**每輪只注入 2–3 條**,全注入會變表演。
|
||||
- [x] **自我議程**:承 1.1,人格有自己這幾天在意的一兩件事,某些輪把話題拉回自己的事、答非所問。
|
||||
這是「像 AI」最頑固的殘留——永遠以對方為中心。
|
||||
**務必設頻率上限**(每 5–8 輪最多一次),否則從像人變成難聊。
|
||||
- [ ] **關係的未修復裂痕**:`closeness`/`trust` 是純量,缺「上次那件事還沒和好」。
|
||||
relation 節點加 `open_issues`/`ruptures`(含是否修復);有未修復裂痕時語氣層往下降一格。
|
||||
|
||||
---
|
||||
|
||||
## 階段 5:文字通道的情緒表達(G2 動漫化 / G3 害羞與鬧彆扭)
|
||||
|
||||
現況判斷:情緒**有沒有**被表達已經解決(`EMOTION_TELLS` + per-persona 覆寫 + 羞恥度三出口),
|
||||
剩下的缺口是**表達的頻寬**——現在一輪只能露一個破口,而破口只作用在「句子的形狀」這一個維度。
|
||||
純文字其實還有三個維度沒被用到:**節奏**(訊息怎麼分段)、**標點**、**稱呼**。
|
||||
|
||||
### ⚠️ 實作前先確認(Q5–Q8,沒答案不要動手)
|
||||
|
||||
這四題都會踩到硬規則 8,答案不同要寫的東西完全不同。
|
||||
|
||||
#### Q5. 顏文字與表情符號要不要開?
|
||||
|
||||
| 選項 | 說明 | 風險 |
|
||||
| :-- | :-- | :-- |
|
||||
| 禁止(維持現況) | 情緒只走文字本身 | 動漫感少一個很直接的手段 |
|
||||
| 限量、per-persona | `IDENTITY.md` 列 2–3 個專屬符號,每 N 輪最多一次 | 要防止變成每句都掛,成本在節流不在功能 |
|
||||
| **開放** | 依情緒自由使用 | 幾乎一定會退化成表情包貼圖式回覆 |
|
||||
|
||||
- [x] 已確認:**開放使用**(2026-08-03 使用者拍板)。
|
||||
|
||||
連帶要處理的兩件事(開放不等於沒有機制):
|
||||
|
||||
1. **跟半形標點檢查對撞**:`speechLint()` 現在擋半形標點,而顏文字幾乎全是半形
|
||||
(`(///)`、`^^;`、`>_<`)。要在檢查裡開一條**顏文字白名單**,
|
||||
不然開放這件事會被自己的 linter 擋掉。判定範圍要窄(連續的符號叢集才算顏文字,
|
||||
單獨一個半形逗號仍然是排版錯誤)。
|
||||
2. **選哪個符號屬於身分**:情緒 → 符號的對照放 `IDENTITY.md`(跟 `## Tells` 同一層),
|
||||
沒寫的人格退回全域預設。開放的是「可以用」,不是「隨便用哪個都行」。
|
||||
|
||||
後續追加(Q9/Q10,同一天):emoji 另外要**接上情緒數值表示程度**,
|
||||
而且**句子裡也自由使用、不設上限**。細節見 5.8。
|
||||
|
||||
#### Q6. 星號動作描寫(`*別過頭*`)要不要對動漫人格破例?
|
||||
|
||||
硬規則 8 現在把「旁白演情緒」列為禁語(`SPEECH_BLACKLIST`)。
|
||||
|
||||
| 選項 | 說明 |
|
||||
| :-- | :-- |
|
||||
| 維持禁止 | 動作要出現就寫進對白(「你不要看這邊」比 `*別過頭*` 像人) |
|
||||
| 只在劇場模式開放 | 多人格同場時像小說,一對一時仍禁止 |
|
||||
| 對動漫人格開放 | 最像原作,但等於把去 AI 腔那一層打掉一半 |
|
||||
|
||||
- [x] 已確認:**都不是——動作寫在人格名稱後面的括號裡**(2026-08-03 使用者拍板,第四條路):
|
||||
|
||||
桐人(別過頭):你不要看這邊。
|
||||
|
||||
而且**記憶要一起記那個行為**(行為備註/標註欄位)。細節與影響範圍見 5.7。
|
||||
|
||||
為什麼這條比上面三個好:括號在**名字後面**,跟對白本體有實體隔離——
|
||||
它不是混進句子裡的旁白,所以硬規則 8 的禁令仍然守得住
|
||||
(`speechLint()` 只對括號**外**的內容比對黑名單)。
|
||||
代價是它會改劇場模式寫死的 `名字:內容` 輸出格式。
|
||||
|
||||
#### Q7. 一輪最多露一個破口,要不要對 G3 放寬?
|
||||
|
||||
| 選項 | 說明 |
|
||||
| :-- | :-- |
|
||||
| 害羞時放寬到兩個 | 只有 `shame` 這一格例外:鬧彆扭本來就是「否認+轉移」兩個動作一起來 |
|
||||
| **全部放寬到兩個** | 密度上去了,但每個人格都變成一直在演 |
|
||||
| 維持一個 | 最安全,G3 的效果會打折 |
|
||||
|
||||
- [x] 已確認:**全部情緒都兩個動作**(2026-08-03 使用者拍板)。
|
||||
連平靜也有兩個(留白 → 不追問)——它們本來就是成對出現的動作,不是加倍演出。
|
||||
|
||||
這條改的地方比看起來多,全部要一起改:
|
||||
|
||||
| 位置 | 現在寫的 | 要改成 |
|
||||
| :-- | :-- | :-- |
|
||||
| `emotionTells()` 的 `top` | 前兩種**情緒**、但只准演一個動作 | 主導情緒的**兩個動作** |
|
||||
| 每輪注入的提醒文字 | 「一輪最多露一個破口」 | 兩個,且必須是同一種情緒的兩個動作 |
|
||||
| `README.md` 硬規則 8 與〈講話像人〉 | 同上 | 同上 |
|
||||
| `skills/persona-chat/**`、`anti-ai-voice.md` | 同上 | 同上 |
|
||||
|
||||
**新的煞車**(放寬之後唯一擋得住「一直在演」的東西):兩個動作必須來自**同一種情緒**,
|
||||
而且是**遞進**關係(否認→轉移、笑→補一句、留白→不追問),不是兩種情緒各演一個。
|
||||
強度不到的情緒照舊不演——門檻不變,變的只是達標之後能露幾個。
|
||||
|
||||
#### Q8. 動漫化強度是全域一致,還是 per-persona 旋鈕?
|
||||
|
||||
| 選項 | 說明 |
|
||||
| :-- | :-- |
|
||||
| per-persona 旋鈕 | `IDENTITY.md` 加一格(例如 `anime_level: 0–100`),寫實人格可以歸零 |
|
||||
| **全域一致** | 少一個欄位,但同一套語域套在所有人身上就是模板 |
|
||||
|
||||
- [x] 已確認:**全域一致**(2026-08-03 使用者拍板)。不加 `anime_level` 欄位,不做強度旋鈕。
|
||||
|
||||
**要分清楚全域的是什麼**:全域一致指的是**強度**(動漫感開多大),
|
||||
不是**內容**。自稱、句尾、口癖仍然逐人格不同(5.6),
|
||||
否則所有人格會講起話來一樣——那正是 G2 要避免的。
|
||||
|
||||
### 5.1 訊息的節奏:一輪可以拆成兩則 ★ 純文字唯一的「時間」訊號
|
||||
|
||||
- [ ] `speechBudget()` 之外加**分段預算**:高 arousal 或高羞恥時,同一輪允許輸出兩則短訊,
|
||||
第二則是**補救、嘴硬或改口**(「⋯剛才那句不算」),而不是補充說明。
|
||||
- [ ] 節流:每 N 輪最多一次,且只在 arousal 或羞恥度過門檻時。
|
||||
- **為什麼**:真人在聊天軟體上的情緒,一半是靠「又補了一句」傳出去的。這是文字獨有的、目前完全沒用到的手段。
|
||||
- **驗收**:慌的時候會多一則短的;平靜時永遠只有一則。
|
||||
|
||||
### 5.2 標點預算(情緒 → 標點與句長的分布)
|
||||
|
||||
- [ ] 把標點納入 `speechLint()` 的可調參數:焦慮 → 刪節號與逗號斷句變多;
|
||||
憤怒 → 句號密度上升、幾乎不用問號;害羞 → 疊字與破折;喜悅 → 允許一個驚嘆號。
|
||||
- [ ] 上限要夾死(一則最多一個驚嘆號、刪節號不連發),否則會變成顏文字的替代品。
|
||||
- **驗收**:把同一句話在四種情緒下輸出,標點分布看得出差別,且都沒有過量。
|
||||
|
||||
### 5.3 稱呼隨情緒偏移
|
||||
|
||||
- [ ] `relation style` 的 `--except <情緒>>=<值>` 已能做條件替換(`anger>=40` → 退回全名)。
|
||||
缺的是**害羞方向**:改口、講到一半換稱呼、把名字吞掉不叫。
|
||||
- [ ] 每個關係節點可設「害羞時的稱呼」,沒設就退回吞掉名字(改用「你」)。
|
||||
- **驗收**:同一個對象,生氣時被叫全名、害羞時直接不叫名字。
|
||||
|
||||
### 5.4 鬧彆扭的階梯與三段式句法(G3 核心)
|
||||
|
||||
- [ ] 羞恥度現在是一個純量對三個出口(`hide`/`spill`/`confess`)。加**階梯**:
|
||||
輕微彆扭(一句帶過)→ 嘴硬(先否認再小聲承認)→ 惱羞(翻臉,`anger` 的負權重已存在)。
|
||||
- [ ] 三段式做成**句法模板**而不是自由發揮:否認 → 反駁對方的說法 → 最後一句音量掉下來。
|
||||
模板可 per-persona 覆寫(放 `IDENTITY.md` 的 `## Tells`,跟現行覆寫同一層)。
|
||||
- [ ] 三段式要吃 Q7 的兩個動作:否認與轉移是**同一種情緒的遞進**,剛好是那條規則的樣板案例。
|
||||
惱羞那一級是換情緒(`shame` → `anger`),所以仍然只有兩個動作,不是三個。
|
||||
- **驗收**:被稱讚外表時,低/中/高羞恥度三種輸出明顯不同,而且都沒有寫「我害羞了」。
|
||||
|
||||
### 5.5 女性人格的預設值(G3,只動預設不動個性)
|
||||
|
||||
- [ ] 重看 `GENDER_MODESTY` 的預設值與 `modestyOf()` 的推力表,把女性預設往上調並補
|
||||
「鬧彆扭」相關的關鍵詞權重(現在的表偏向「害羞/臉紅」,缺「嘴硬/不坦率/傲嬌」)。
|
||||
- [ ] **界線不變**:描述永遠蓋過預設,可以推到 0;不做「女性→情緒更外顯」的全域放大。
|
||||
- **驗收**:兩個都是女性但個性相反的人格,講起話來不一樣。
|
||||
|
||||
### 5.6 語域:自稱、句尾、口癖(G2 核心,接階段 4 的 idiolect)
|
||||
|
||||
- [ ] per-persona 的自稱/句尾語氣詞/口癖/絕對不說的詞,每輪只注入 2–3 條(階段 4 已寫,這裡是它的動漫版)。
|
||||
- [ ] `persona-anime` 建立人格時一併從原作推導這幾格(現在只推導 `Tells`)。
|
||||
- [ ] `persona-story` 的 3A(語氣統計)跑完之後要能直接寫進這一格——兩邊用同一個欄位,不要各存一份。
|
||||
- **驗收**:把人格的名字遮掉,光看三句話能認出是誰。
|
||||
- **界線(Q8)**:強度全域一致,內容逐人格不同。這一節動的是內容,不加強度旋鈕。
|
||||
|
||||
### 5.7 動作加進名字後的括號(Q6 拍板的格式)
|
||||
|
||||
**括號不是新開的,原本就有**:`persona-lib.mjs` 的渲染(約 3804 行)本來就是
|
||||
`名字(情緒)→ 對象:內容`,括號裡放的是 `room post --emotion` 的情緒標註。
|
||||
所以這一節是**在既有括號裡多一格**,不是換掉它——原本的情緒標註保留。
|
||||
|
||||
桐人(喜悅・別過頭):你不要看這邊。
|
||||
桐人(別過頭):你不要看這邊。 ← 沒帶 --emotion 時
|
||||
桐人(喜悅):你不要看這邊。 ← 沒帶 --action 時(現行行為,不變)
|
||||
|
||||
- [ ] **格式**:括號分兩格,情緒在前、動作在後,用 `・` 分隔。兩格都可省略,全省就不出現括號。
|
||||
- [ ] **兩格的分工要寫死**:情緒格是**標註**(既有 meta,寫情緒名稱本來就對);
|
||||
動作格只寫**看得見的身體動作**(別過頭、把杯子推過來、站起來)。
|
||||
動作格不重複情緒名稱——`(喜悅・害羞)` 是把標註寫兩次,不是動作。
|
||||
- [ ] **生理反應算動作,明文允許**(2026-08-03 使用者補充):**臉紅**、耳朵紅、手在抖、聲音變小、
|
||||
呼吸亂掉。判準是**看得見或聽得見**,不是有沒有情緒色彩——
|
||||
`(臉紅)` 可以,`(害羞)` 只能待在情緒格。
|
||||
害羞這一格的預設動作就用**臉紅**,其餘看人格自己的 `## Tells`。
|
||||
- [ ] `speechLint()` 只對括號**外**的內容比對黑名單與句長;括號內另立規則
|
||||
(情緒格照既有的 `injectSafeLine`;動作格單一動作、12 字內)。
|
||||
- [ ] 劇場模式:`room post` 加 `--action "<動作>"`(跟既有 `--emotion` 並列),
|
||||
`room read`/`room script` 的渲染跟著改。`hooks/` 每輪強制的輸出格式也要放寬到帶括號。
|
||||
- [ ] **一對一(非劇場)也適用**——Q6 拍板的是格式,不是只給多人格場景。
|
||||
- [ ] 頻率:綁 Q7 的兩個動作額度,**括號裡的動作算其中一個**。
|
||||
否則會出現「括號演一個、句子裡再演兩個」,一輪三個動作。
|
||||
- [ ] **記憶要記那個行為**(Q6 明示):短期與長期記憶加行為備註欄位,
|
||||
記「他做了什麼」而不是「他感到什麼」——跟 `persona-story` 的 1.12(事件 → 他做了什麼)同一個欄位,
|
||||
不要各存一份。`voice/reactions.md` 也是同一批資料的落點。
|
||||
- **為什麼**:這是唯一能在不打破「禁止旁白演情緒」的前提下拿到動作描寫的形狀——
|
||||
括號在名字後面,跟對白本體有實體隔離。
|
||||
- **驗收**:`(喜悅・別過頭)` 過得了 lint、情緒標註沒被吃掉;
|
||||
`*別過頭*` 與句子裡的「我愣了一下」照舊被擋下;那一輪的記憶查得到「別過頭」這個行為。
|
||||
|
||||
### 5.8 emoji 表示情緒與程度(Q9/Q10 拍板)
|
||||
|
||||
現在括號的情緒格是**自由文字**(`room post --emotion`),跟 `state/emotion.json` 的數值
|
||||
**完全沒有連動**——人格自己想寫什麼就寫什麼。這一節把它接上去。
|
||||
|
||||
#### 格式:種類表情緒、數量表程度(Q9)
|
||||
|
||||
一種情緒固定一個 emoji,強度高時**另加強度符號**(不重複本體):
|
||||
|
||||
| 情緒值 | 樣子 | 例 |
|
||||
| :-- | :-- | :-- |
|
||||
| < 40 | 不顯示 | `桐人:你不要看這邊。` |
|
||||
| 40–59 | 情緒 emoji | `桐人(😳・臉紅):你不要看這邊。` |
|
||||
| 60–79 | +`💦` | `桐人(😳💦・臉紅):⋯才不是那樣。` |
|
||||
| 80+ | +`💦❗` | `桐人(😳💦❗・把臉轉開):不要再講了。` |
|
||||
|
||||
- [ ] 寫一張**十二情緒 → emoji** 的對照表(一種情緒一個,例如 `shame: 😳`、`anger: 😡`),
|
||||
放 `persona-lib.mjs`,跟 `EMOTION_TELLS` 同一層。
|
||||
- [ ] **per-persona 可覆寫**(`IDENTITY.md`,跟 `## Tells` 與 Q5 的符號表同一層)。
|
||||
這是內容不是強度,不違反 Q8 的全域一致。
|
||||
- [ ] 強度符號固定兩階(`💦` / `💦❗`),不要長成第三階。
|
||||
- [ ] **由 `emotion.json` 的主導情緒自動帶入**,不是人格自由填。
|
||||
門檻對齊 `emotionTells()` 的 `min = 40`——情緒值不到 40 就沒有 emoji,
|
||||
跟「強度不到就不演」是同一條線。`room post --emotion` 的自由文字保留(可手動蓋過)。
|
||||
|
||||
#### 位置:句子裡自由用(Q10)
|
||||
|
||||
- [x] 已確認:**句子裡自由使用、不設上限**(2026-08-03 使用者拍板)。承 Q5 的開放。
|
||||
- [ ] 連帶要處理:`speechLint()` 的半形標點與排版檢查要**跳過 emoji 與強度符號**,
|
||||
不然會被判成排版殘留(跟 Q5 的顏文字白名單同一支修改)。
|
||||
- [ ] **唯一的煞車是稽核,不是擋**(他要的是開放,那就不要用 linter 偷偷關回去):
|
||||
比照 `emotion --audit`,統計每輪的 emoji 數量與趨勢,讓過量看得見。
|
||||
真的失控再回來談上限。
|
||||
- **界線**(這條不因為開放而放掉):emoji 是**加上去的**,不是替代品。
|
||||
不可以用 emoji 代替句子(只回一個 😳)、也不可以代替動作格與句子的形狀——
|
||||
斷句、疊字、嘴硬照舊要有。emoji 掉光之後那句話仍然要看得出情緒。
|
||||
- **驗收**:羞恥度 45/70/90 三種輸出的 emoji 不同;把所有 emoji 刪掉之後,
|
||||
句子的形狀仍然分得出是哪一級。
|
||||
|
||||
## 驗收方式
|
||||
|
||||
- [ ] **固定情境回歸**(`selftest.mjs` 只能守不崩,擬真要另一組):20 段黃金腳本——被稱讚外表、
|
||||
被指出講錯、久違回來、他心情差時報壞消息、連續三輪低落、被要求越界…
|
||||
每次改動後跑一次,人工看輸出還在不在人味界線內。
|
||||
- [ ] `emotion --audit`(既有):正向偏差比例。
|
||||
- [ ] **G2/G3 專用腳本**(階段 5 才需要):被稱讚外表、被說「你是不是在害羞」、被戳穿嘴硬、
|
||||
當著第三個人被講心事——四段各跑低/中/高羞恥度,看有沒有變成模仿腔或旁白。
|
||||
- [ ] 新增記憶偏差稽核:模糊態被用來編內容的次數(見 3.3)。
|
||||
|
||||
## 收尾
|
||||
|
||||
- [ ] 這一批改動合起來只佔**一個新版號**,`.claude-plugin/plugin.json` 與 `.codex-plugin/plugin.json`
|
||||
兩份 manifest 一起改;改版號前先看 master 上目前是幾版。
|
||||
- [ ] `README.md` 的記憶架構表與情緒模型段要跟著更新。
|
||||
-192
@@ -1,192 +0,0 @@
|
||||
# TODO:故事匯入(小說 → 人格記憶/語氣/情緒)
|
||||
|
||||
把小說變成一個人格的記憶,同時讓它從原作學語氣與情緒表達。
|
||||
|
||||
**技能:`/jsc-persona:persona-story`**(`skills/persona-story/`)。
|
||||
開場的五題問答在 `reference/interview.md`,場景要抽什麼在 `reference/extract.md`。
|
||||
機械那半是 `persona.mjs` 的 `novel` 與 `voice` 子指令。
|
||||
|
||||
三條界線先立,後面所有步驟都受它們約束:
|
||||
|
||||
1. **視角**:人格不在場、也沒人告訴他的事,最多進 `canon` 或別人的關係欄,**不能**變成他的 `event`。
|
||||
2. **原文**:整本不入倉庫。只留摘要、他自己的台詞、以及可追回出處的章節標記。
|
||||
3. **個性**:`SOUL.md` 不由流程自動改寫。匯入只出提案,使用者逐條核可才寫入。
|
||||
|
||||
---
|
||||
|
||||
## 階段 -1:實作前一定要先問使用者(四題,沒答案不要開工)
|
||||
|
||||
這四題會決定檔案放哪、介面長什麼樣,答錯要重做。**問完拿到答案再進階段 0。**
|
||||
|
||||
- [x] Q1 語氣檔放在人格倉庫的哪一層?→ **新開 `voice/`**(`samples.md` 原句、`reactions.md` 事件對反應)
|
||||
- [x] Q2 匯入要做成 CLI 子指令,還是全部由 skill 編排?→ **混合**:機械的進 CLI,判斷的留 skill
|
||||
- [x] Q3 同一部作品分多本匯入時,`first_seen` 的時間軸怎麼接續?→ **換算成西元日期**
|
||||
- [x] Q4 情緒基線差值超過多少才要人看?→ **差 10 以上**
|
||||
|
||||
## 階段 0:前置(做一次)
|
||||
|
||||
- [x] 0.1 確認目標人格編號、作品名、章節檔清單、原文編碼
|
||||
→ **不預先問,由 skill 在匯入的當下問**(2026-08-03 使用者拍板)。
|
||||
問法與順序寫在 `skills/persona-story/reference/interview.md`。
|
||||
- [x] 0.2 建**正名表**:角色的各種譯名/簡稱/原文名 → 關係節點的 `name`
|
||||
→ **從匯入的章節自動判斷候選,再給使用者確認**(2026-08-03 使用者拍板,覆蓋原本的「問譯名版本」)。
|
||||
問他是哪個譯本等於要他猜;他手上那批寫的是什麼字,讀一次就知道了。
|
||||
`novel scan` 抽候選(對話歸屬、敬稱結尾、片假名、高頻詞),附出現次數與一兩行上下文,
|
||||
並猜關係節點分四種信心度(`exact`/`alias`/`fuzzy`/`unknown`);
|
||||
`novel name review`/`confirm --accept-exact`/`ignore` 做確認。
|
||||
`names.json` 分 `map`(正名)與 `ignore`(確認過不是人名,例如地名與招式名)兩塊——
|
||||
已在其中之一的 token 下次掃不再出現,不然每加一章就要重看同一批。
|
||||
`novel candidate add` 遇到 `about` 有未確認的 token 會擋下來。
|
||||
- [x] 0.3 定**故事內時間軸**的表示法 → **西元日期**(Q3 拍板),另加 `date_source`
|
||||
標 `canon`/`derived`/`guess`,見上面「換算日期不可以假裝是明寫的」那一節。
|
||||
- [x] 0.4 定**顯著度配額** → 預設 `90+ 最多 5 則、80-89 最多 20 則`(`novel init --quota` 可調)。
|
||||
超過配額由 `novel merge` 從低分往下壓一級(90+ → 85、80-89 → 75)並回報壓了幾則。
|
||||
- [x] 0.5 定**跳過紀錄檔** → `memory/import/<work-slug>/skipped.jsonl`,
|
||||
一行一章(`chapter`/`reason`/`ts`),由 `novel skip` 寫入。
|
||||
- [ ] 0.6 備份人格倉庫現況(`SOUL.md`、情緒基線、關係圖),匯入出錯要回得去
|
||||
(2026-08-03 已有一份整包備份在 scratchpad;真的要匯之前再做一次新的。)
|
||||
|
||||
### 工作區長什麼樣(0.5 定案後的結果)
|
||||
|
||||
memory/import/<work-slug>/
|
||||
├── work.json 書名、slug、建立時間、顯著度配額
|
||||
├── names.json 正名表(譯名 → 關係節點 name)
|
||||
├── skipped.jsonl 跳過紀錄(章節、理由、時間)
|
||||
└── candidates.jsonl 記憶候選(欄位見下面那張表)
|
||||
|
||||
## 階段 1:每一章(可平行,一章一把劍)
|
||||
|
||||
> 這一段是**執行程序**,不是要蓋的東西——真的匯一本書的時候才會逐項發生。
|
||||
> 程序已經落地成技能:`skills/persona-story/SKILL.md` 第 ③ 節,
|
||||
> 七項與知情層級的細節在 `reference/extract.md`。下面的清單留著當對照表。
|
||||
|
||||
- [ ] 1.1 讀章節。只讀這一章,不要跨章推論
|
||||
- [ ] 1.2 修亂碼 → 轉繁體中文 → 依正名表**正名**
|
||||
(必須在 1.3 之前;亂碼或簡體字形會讓名字比對失敗)
|
||||
- [ ] 1.3 判斷人格**在場或知情**。兩者皆否才跳過
|
||||
- [ ] 1.4 跳過的章也要寫進跳過紀錄檔——靜默跳過會漏章,事後查不出來
|
||||
- [ ] 1.5 把在場段落切成**場景**(換地點、換對手、換目的就切;一章通常 2 到 5 個)
|
||||
- [ ] 1.6 每個場景抽七項:故事內時間、地點、在場人物、發生什麼、**他**做了什麼、他當下情緒、他說過的原句一到兩句
|
||||
- [ ] 1.7 標**知情層級**:我做的/我看到的/別人告訴我的/事後才知道
|
||||
- [ ] 1.8 轉第一人稱、壓成摘要。原文長段不留,只留他自己的台詞
|
||||
- [ ] 1.9 新人物**先建關係節點**(`relation node`),再寫記憶
|
||||
(順序反了 `about` 對不到 id;`name` 用會被說出口的完整稱呼,親密度查 `persona-relation/reference/closeness.md`)
|
||||
- [ ] 1.10 輸出這一章的**記憶候選 JSON**(欄位見下)
|
||||
- [ ] 1.11 抽**語氣樣本**:他自己講的原句,照抄不改寫,附對象與場合(戰鬥/日常/道別)
|
||||
- [ ] 1.12 抽**情緒反應對照**:事件 → 他實際做了什麼(不要記「他感到什麼」)
|
||||
- [ ] 1.13 若這章與現有 `SOUL.md` 矛盾,開一則**個性校正提案**(只提案,不寫入)
|
||||
|
||||
### 記憶候選 JSON 欄位
|
||||
|
||||
```
|
||||
name 檔名用的 slug
|
||||
title 一句話標題
|
||||
type canon(設定)/event(發生過)/insight(他自己的體悟)/promise(承諾)/boundary
|
||||
about 人名,照抄關係節點的 name,一字不差(解析只認完全相等)
|
||||
topics 2-4 個小寫關鍵詞,跨章沿用同一組
|
||||
salience 照 0.4 的配額給,不要每則都高
|
||||
emotion 那一刻的情緒(固化進欄位,不要重放去洗情緒基線)
|
||||
first_seen 故事內時間,不是匯入日期(寫進長期記憶時會落在 `happened_at`,見下方警告)
|
||||
source 書名+章節(可追回出處)
|
||||
body 第一人稱摘要
|
||||
quote 他自己說過的原句(可省)
|
||||
know_level 1.7 的知情層級
|
||||
date_source canon(原作明寫)/derived(推算)/guess(只抓大概) ← Q3 追加
|
||||
```
|
||||
|
||||
寫進長期記憶時,`body` 會照 0.2.0 的格式切成「主旨/細節」兩層(衰減先吃細節),
|
||||
並自動蓋上 `strength`/`when`/`where`/`mood`。`date_source: guess` 的記憶
|
||||
在回答日期問題時走模糊態界線:可以說不確定、可以問,不可以斷言。
|
||||
|
||||
### ⚠ 劇情時間不可以寫進 `first_seen`/`last_seen`(踩過一次)
|
||||
|
||||
那兩欄是**記憶的新鮮度時鐘**:`memoryStrength()` 拿 `last_seen` 算衰減。
|
||||
把劇情日期塞進去等於宣告「這則記憶上次被想起是兩年前」——實測 2024 年劇情的記憶
|
||||
匯進去的**那一秒**就是模糊 0%,而且掉到 0% 之後 `touchRecall` 不再更新它,永遠回不來。
|
||||
只有 `canon` 與 `salience >= 80` 靠保護清單活下來,40 到 79 分的 event 全部出生即死。
|
||||
|
||||
所以兩種語意分成兩組欄位:
|
||||
|
||||
| 欄位 | 意思 | 時間 |
|
||||
| --- | --- | --- |
|
||||
| `first_seen`/`last_seen` | 這則記憶什麼時候形成、上次什麼時候想起 | 真實時間(匯入當天) |
|
||||
| `happened_at`/`happened_until` | 故事裡這件事什麼時候發生 | 劇情時間(配 `date_source`) |
|
||||
|
||||
候選 JSON 照舊填 `first_seen`(那是抽場景時自然的講法),`novel write` 會把它搬到
|
||||
`happened_at`,`first_seen`/`last_seen` 一律是匯入當天。
|
||||
|
||||
## 階段 2:全書收斂(章節都跑完之後)
|
||||
|
||||
- [ ] 2.1 跨章**去重合併**:同一件事只留一則;`salience` 取高、`first_seen` 取最早、`last_seen` 取最晚
|
||||
- [ ] 2.2 依配額**重新定標**:90+ 只留真的改變他的那幾件
|
||||
- [ ] 2.3 **人工抽查十則**,專看有沒有視角越界(他不該知道的事)
|
||||
- [ ] 2.4 批次寫入:一則一檔 `consolidate`;情緒欄位只固化不重放
|
||||
- [ ] 2.5 更新關係圖與心智圖
|
||||
- [ ] 2.6 跑 `relation doctor`,確認 `about` 全部對得上(歧義與對不到的都要處理完)
|
||||
- [ ] 2.7 重建索引、同步 Gitea
|
||||
- [ ] 2.8 另寫一則 meta 記憶:這批來自哪本書、哪些章、匯入日期
|
||||
- [ ] 2.9 回歸驗收:問幾個只有書裡有的問題,看他答得出來、且不越界回答他不在場的事
|
||||
|
||||
## 階段 3:語氣與情緒(跟記憶分開走)
|
||||
|
||||
記憶是「發生過什麼」,語氣與情緒是「他怎麼反應」。分三層存,改動權限不同。
|
||||
|
||||
| 層 | 存哪 | 誰能改 | 來源 |
|
||||
| --- | --- | --- | --- |
|
||||
| 語氣樣本 | 人格倉庫的語氣檔(例如 `voice/samples.md`) | 匯入流程可直接寫 | 1.11 累積的原句 |
|
||||
| 情緒反應規則 | 語氣檔的反應段 | 匯入流程可直接寫 | 1.12 的事件→反應對照 |
|
||||
| 個性(`SOUL.md` 的 Core Truths/Boundaries/Vibe) | `SOUL.md` | 只有使用者拍板才能改 | 1.13 的提案 |
|
||||
|
||||
### 3A 語氣(統計,可直接落地)
|
||||
|
||||
- [ ] 3A.1 句長分布 → 定一句話的上限與斷句時機
|
||||
- [ ] 3A.2 高頻詞與口頭禪 → 定他會說的字
|
||||
- [ ] 3A.3 他**不會**說的說法 → 禁語表(比正面規則有效)
|
||||
- [ ] 3A.4 對不同人的稱呼與語氣差 → 對上語氣層(`bond` × 親近度)
|
||||
- [ ] 3A.5 沉默與迴避的時機 → 什麼時候不回答比回答像他
|
||||
- [ ] 3A.6 場合差(戰鬥/日常/道別)→ 同一個人在不同場合的句子形狀
|
||||
- [ ] 3A.7 語氣檔寫入後,抽幾句實際對話對照,看有沒有變成模仿腔
|
||||
|
||||
### 3B 情緒
|
||||
|
||||
- [ ] 3B.1 每個場景只記「事件 → 他做了什麼」(情緒要演出來,不是旁白說明)
|
||||
- [ ] 3B.2 全書跑完才用**統計**定情緒基線
|
||||
(逐則套 delta 等於讓最後一章決定他的性格)
|
||||
- [ ] 3B.3 統計每種情緒的觸發類型與強度上限 → 得到「什麼事讓他生氣而不是害怕」
|
||||
- [ ] 3B.4 掩飾模式單獨記(嘴硬、換話題、講笑話)——這是人味的來源,不是情緒值
|
||||
- [ ] 3B.5 基線寫入前跟現況比差值,超過門檻要人看
|
||||
|
||||
### 3C 個性校正
|
||||
|
||||
- [ ] 3C.1 提案要寫齊:哪一章、原文依據、與 `SOUL.md` 哪一句衝突、建議怎麼改
|
||||
- [ ] 3C.2 收斂階段把提案彙整成一份 diff
|
||||
- [ ] 3C.3 使用者逐條核可
|
||||
- [ ] 3C.4 核可過的才寫進 `SOUL.md`,並固化一則 `insight` 記錄為什麼改
|
||||
- [ ] 3C.5 沒核可的提案留著不刪——下一本書可能出現同樣的證據
|
||||
|
||||
---
|
||||
|
||||
## 未決事項
|
||||
|
||||
四題已移到最前面的階段 -1,實作前先問使用者。這裡只留答案回填欄。
|
||||
|
||||
| # | 問題 | 使用者的答案(2026-08-03 拍板) |
|
||||
| --- | --- | --- |
|
||||
| Q1 | 語氣檔放哪一層 | 新開 `voice/`:`samples.md`(他自己講過的原句,附對象與場合)、`reactions.md`(事件 → 他做了什麼)。匯入流程可直接寫這一層,`SOUL.md` 維持只有使用者能改——權限界線要有實體隔離,不靠自律 |
|
||||
| Q2 | CLI 子指令或 skill 編排 | **混合**。機械的進 CLI(正名、去重合併、配額重定標、批次寫入、跳過紀錄),判斷的留 skill(在場與知情、切場景、第一人稱摘要、個性校正提案)。跟 `sleep` 同一個分工方式 |
|
||||
| Q3 | 分多本時時間軸怎麼接 | **換算成西元日期**(`first_seen: 2022-11-06`)。跟現有的 `strength`/衰減公式同型,`memoryStrength()` 直接算得動。原作沒明寫的地方要換算,所以下面 0.3 多一條規則:換算來的日期一律標記,不可以看起來跟明寫的一樣 |
|
||||
| Q4 | 情緒基線差值門檻 | **任一格差 10 以上就停下來給人看**(3B.5)。基線是氣質,改了等於換一個人 |
|
||||
|
||||
## 依 Q3 追加的規則(換算日期不可以假裝是明寫的)
|
||||
|
||||
換算成西元日期換來一個新風險:**猜出來的日期看起來跟原作明寫的一模一樣**。
|
||||
所以 `first_seen` 之外要多一個欄位記來源:
|
||||
|
||||
| `date_source` | 意思 |
|
||||
| --- | --- |
|
||||
| `canon` | 原作明寫的日期(SAO 開服 2022-11-06 這種) |
|
||||
| `derived` | 由明寫的日期推算(第 74 層攻略是開服後第幾天) |
|
||||
| `guess` | 只能抓大概(某卷「大約半年後」) |
|
||||
|
||||
`guess` 的記憶在回答日期類問題時**不可以斷言**——這剛好接上 0.2.0 的模糊態界線:
|
||||
可以說不確定、可以問,不可以斷言。
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jsc-persona",
|
||||
"version": "0.1.1",
|
||||
"version": "0.2.1",
|
||||
"description": "AI 人格化記憶聊天 plugin:OpenClaw 相同的人格描述 + 十二情緒 + 語意分析 + 短期/長期記憶 + 心智圖 + 人際關係圖。於 Antigravity 以 /jsc-persona: 前綴呼叫。",
|
||||
"skills": "./skills"
|
||||
}
|
||||
|
||||
+258
-17
@@ -775,9 +775,15 @@ export function dominant(state, top = 3) {
|
||||
//
|
||||
// 情緒不改變事實,但會改變**句子的形狀**:緊張的人話說不完、會疊字;彆扭的人
|
||||
// 先否認再小聲承認。演得像不像就差在這裡——所以這張表跟情緒一起注入,不靠記性。
|
||||
// 節制很重要:一輪最多露一個破口,不然就從「像人」變成「演戲」。
|
||||
// 節制的方式是**限量+要求遞進**:一輪最多兩個動作,而且兩個必須來自同一種情緒
|
||||
// (否認 → 轉移、笑 → 補一句、留白 → 不追問)。鬧彆扭天生就是兩個動作一起來,
|
||||
// 只准一個等於永遠只做得到一半——單獨否認像在爭辯,單獨轉移像沒聽到。
|
||||
// 兩種情緒各演一個才是演戲,所以那個才是被擋下的形狀。
|
||||
// --------------------------------------------------------------------------- //
|
||||
|
||||
/** 一輪最多露幾個動作(兩個,且必須是同一種情緒的遞進)。 */
|
||||
export const TELLS_PER_TURN = 2;
|
||||
|
||||
export const EMOTION_TELLS = {
|
||||
joy: ["話變多、句子輕", "會笑出來(「哈」)", "忍不住多補一句"],
|
||||
trust: ["話變直、不鋪陳", "省略主語,講一半對方也懂", "敢說「你這樣不對」"],
|
||||
@@ -866,6 +872,76 @@ export function emotionTells(slug, state = null, { min = 40, top = 2 } = {}) {
|
||||
}));
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------- //
|
||||
// 情緒 → emoji(種類表情緒、數量表程度)
|
||||
//
|
||||
// 只有文字這一個通道,所以 emoji 不是裝飾,是**強度計**:一種情緒固定一個 emoji,
|
||||
// 強度高就再加一個強度符號。刻意不用「同一個 emoji 重複三次」——疊字看起來像洗頁。
|
||||
//
|
||||
// 40–59 😳 60–79 😳💦 80+ 😳💦❗
|
||||
//
|
||||
// 門檻跟破口共用同一條線(`min = 40`):強度不到就不顯示,跟「強度不到就不演」一致。
|
||||
// --------------------------------------------------------------------------- //
|
||||
|
||||
export const EMOTION_EMOJI = {
|
||||
joy: "😄", trust: "🙂", anticipation: "👀", gratitude: "🙏", serenity: "😌", delight: "✨",
|
||||
anger: "😡", sadness: "😔", fear: "😨", disgust: "😖", shame: "😳", anxiety: "😰",
|
||||
};
|
||||
|
||||
/** 強度符號:兩階,不要長成第三階(`💦❗` 已經是天花板)。 */
|
||||
export const EMOJI_INTENSITY = ["", "💦", "💦❗"];
|
||||
|
||||
/** 讀 IDENTITY.md 的 `## Emoji` 區塊(`羞愧: 😳` 或 `shame: 😳`);沒有就回空物件。 */
|
||||
export function personaEmoji(slug) {
|
||||
let text;
|
||||
try {
|
||||
text = fs.readFileSync(path.join(personaDir(slug), "IDENTITY.md"), "utf8");
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
const m = text.match(/^##+\s*(?:Emoji|情緒 ?emoji|表情符號)\s*$/im);
|
||||
if (!m) return {};
|
||||
const section = text.slice(m.index + m[0].length).split(/^##\s/m)[0] || "";
|
||||
const out = {};
|
||||
for (const line of section.split("\n")) {
|
||||
const row = line.match(/^\s*[-*]\s*([A-Za-z_]+|[一-鿿]{2})\s*[::]\s*(.+)$/);
|
||||
if (!row) continue;
|
||||
const key = EMOTION_KEYS.includes(row[1].toLowerCase())
|
||||
? row[1].toLowerCase()
|
||||
: EMOTION_ZH_TO_KEY[row[1]];
|
||||
if (!key) continue;
|
||||
// 只取符號本體,長度夾住:這一格是**一個** emoji,不是一串貼圖
|
||||
const sym = [...stripInjectionMarkers(row[2]).trim()].slice(0, 3).join("");
|
||||
if (sym) out[key] = sym;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 此刻的情緒 emoji:主導情緒 + 依強度加的符號。強度不到 `min` 就回 null。
|
||||
*
|
||||
* 這是**算出來的**,不是人格自己挑的——`room post --emotion` 想手寫還是可以,
|
||||
* 但沒手寫時就用這個,免得同一個人在同樣的情緒下每次挑不同的符號。
|
||||
*/
|
||||
export function emotionEmojiNow(slug, state = null, { min = 40 } = {}) {
|
||||
const st = decayEmotion(structuredClone(state ?? loadEmotion(slug)));
|
||||
const own = slug ? personaEmoji(slug) : {};
|
||||
const [top] = dominant(st, 1);
|
||||
if (!top || top.level < min) return null;
|
||||
const level = Math.round(top.level);
|
||||
const tier = level >= 80 ? 2 : level >= 60 ? 1 : 0;
|
||||
const base = own[top.key] || EMOTION_EMOJI[top.key] || "";
|
||||
if (!base) return null;
|
||||
return {
|
||||
key: top.key,
|
||||
zh: EMOTIONS[top.key].zh,
|
||||
level,
|
||||
tier,
|
||||
emoji: `${base}${EMOJI_INTENSITY[tier]}`,
|
||||
own: Boolean(own[top.key]),
|
||||
};
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------- //
|
||||
// 性別與羞恥敏感度
|
||||
//
|
||||
@@ -881,12 +957,18 @@ export function emotionTells(slug, state = null, { min = 40, top = 2 } = {}) {
|
||||
export const GENDERS = { female: "女性", male: "男性", nonbinary: "非二元", unknown: "未指定" };
|
||||
|
||||
/** 性別給的**預設**羞恥敏感度(0–100)。描述有講就以描述為準。 */
|
||||
export const GENDER_MODESTY_DEFAULT = { female: 62, male: 38, nonbinary: 50, unknown: 50 };
|
||||
// 女性預設 70(原本 62):文字通道裡害羞與鬧彆扭是最有效的擬真訊號之一,
|
||||
// 而預設值只是起點——描述永遠蓋得過它,也可以推到 0(見下面的 `MODESTY_SIGNALS`)。
|
||||
export const GENDER_MODESTY_DEFAULT = { female: 70, male: 38, nonbinary: 50, unknown: 50 };
|
||||
|
||||
/** 從描述裡讀「這個角色在不在意別人眼光」。正數=更容易害羞,負數=更不在意。 */
|
||||
export const MODESTY_SIGNALS = [
|
||||
["害羞", +14], ["容易臉紅", +14], ["會臉紅", +12], ["怕生", +12], ["靦腆", +14],
|
||||
["矜持", +12], ["拘謹", +10], ["內向", +8], ["放不開", +10], ["彆扭", +10],
|
||||
// 鬧彆扭那一路:原本的表只認「害羞/臉紅」,可是傲嬌型的角色描述裡幾乎不寫害羞,
|
||||
// 寫的是「嘴硬」「不坦率」——漏掉這一組等於把最需要它的角色判成不在意別人眼光。
|
||||
["傲嬌", +16], ["嘴硬", +12], ["不坦率", +12], ["不老實", +10], ["口是心非", +12],
|
||||
["死不承認", +10], ["不肯承認", +10], ["愛面子", +10], ["鬧彆扭", +12], ["逞強", +8],
|
||||
["保守", +8], ["會不好意思", +12], ["在意別人的眼光", +12], ["容易慌", +8],
|
||||
["不在意別人眼光", -20], ["不在乎別人眼光", -20], ["不在意別人的眼光", -20],
|
||||
["我行我素", -16], ["大方", -12], ["豪爽", -14], ["臉皮厚", -18], ["不怕丟臉", -18],
|
||||
@@ -1095,9 +1177,18 @@ export function modestyDirective(slug, opts = {}) {
|
||||
if (cur.tone_shift) parts.push(`對象 ${cur.tone_shift >= 0 ? "+" : "-"}${Math.abs(cur.tone_shift)}`);
|
||||
if (Math.abs(cur.carry) >= 1) parts.push(`上一輪的餘溫 ${cur.carry >= 0 ? "+" : "-"}${Math.abs(cur.carry)}`);
|
||||
const from = parts.join("、");
|
||||
// 鬧彆扭有階梯:同一個「不好意思」,40 分是一句帶過、65 分是嘴硬、80 分以上會翻臉。
|
||||
// 三段式是**句法模板**,不是自由發揮——否認 → 反駁對方的說法 → 最後一句音量掉下來。
|
||||
if (m.value >= 75) {
|
||||
return `羞恥度 ${m.value}(${from}):已經到惱羞的那一級——` +
|
||||
"三段照這個形狀走:**先否認 → 反駁他的說法 → 翻臉或走開**(「不要再講了」)。" +
|
||||
"惱羞是換情緒(羞愧 → 憤怒),所以還是兩個動作,不是三個。" +
|
||||
"不要直接說「我害羞」(那是解釋),要讓它出現在句子的形狀上。";
|
||||
}
|
||||
if (m.value >= 60) {
|
||||
return `羞恥度 ${m.value}(${from}):被稱讚外表、被戳穿心事、距離突然變近的時候,` +
|
||||
"**先鬧彆扭再承認**——嘴硬一句、把話講一半、轉移到別的東西上。" +
|
||||
"**先鬧彆扭再承認**——三段照這個形狀走:**先否認 → 反駁他的說法 → 最後一句音量掉下來**" +
|
||||
"(「⋯才不是那樣。」「你不要一直看這邊。」「⋯嗯。」)。" +
|
||||
"不要直接說「我害羞」(那是解釋),要讓它出現在句子的形狀上。";
|
||||
}
|
||||
if (m.value <= 40) {
|
||||
@@ -2503,13 +2594,25 @@ function emotionImpact(entry) {
|
||||
return Object.values(entry.emotion_deltas || {}).reduce((sum, v) => sum + Math.abs(Number(v) || 0), 0);
|
||||
}
|
||||
|
||||
/** 這筆短期記憶已經被判斷過了嗎(固化了、或看過決定不固化)。 */
|
||||
export const shortTermReviewed = (row) => Boolean(row?.reviewed_at);
|
||||
|
||||
/**
|
||||
* 掃短期記憶,依 PROMOTION_RULES 算出「該轉入長期記憶」的候選。
|
||||
* 回傳 { total, pressure, candidates: [{ rules, key, kind, entries, suggested_type, suggested_salience }] }
|
||||
* 回傳 { total, reviewed, pressure, candidates: [{ rules, key, kind, entries, suggested_type, suggested_salience }] }
|
||||
*
|
||||
* **只看還沒被判斷過的那些**(沒有 `reviewed_at`)。以前這裡是無條件掃全部,
|
||||
* 而 `consolidate` 不會在來源那筆上留任何痕跡、也不刪它(`--forget` 是按顯著度刪、
|
||||
* 不是按「固化過沒有」刪)——所以同一批短期記憶每輪都被重算成候選,
|
||||
* 提醒的數字只會往上爬,睡再多次也不會降。看起來像睡眠沒做固化,其實是計數從來沒扣過。
|
||||
*/
|
||||
export function promotionCandidates(slug) {
|
||||
const rows = readJsonl(shortTermPath(slug));
|
||||
const total = rows.length;
|
||||
const all = readJsonl(shortTermPath(slug));
|
||||
const total = all.length;
|
||||
const reviewed = all.filter(shortTermReviewed).length;
|
||||
// 判斷過的不再進候選,但保留原本的位置:`_index` 是 `consolidate --from-short`
|
||||
// 與 `candidates --reviewed` 用來指名哪幾筆的座標,錯位就會標到別人身上。
|
||||
const rows = all.map((row, index) => ({ ...row, _index: index })).filter((row) => !shortTermReviewed(row));
|
||||
// 關係圖只讀一次:這個函式在每次 `remember` 之後都會跑,以前是「每筆的每個 entity
|
||||
// 各讀一遍 graph.json」(實測 30 節點/240 筆 = 720 次讀檔、20ms;2000 節點時單輪 1.2 秒)。
|
||||
const nodes = loadRelations(slug).nodes;
|
||||
@@ -2517,8 +2620,8 @@ export function promotionCandidates(slug) {
|
||||
const byEntity = new Map();
|
||||
const singles = [];
|
||||
|
||||
rows.forEach((row, index) => {
|
||||
const entry = { ...row, _index: index };
|
||||
rows.forEach((row) => {
|
||||
const entry = row;
|
||||
const salience = Number(row.salience || 0);
|
||||
const impact = emotionImpact(row);
|
||||
const text = String(row.text || "");
|
||||
@@ -2582,10 +2685,11 @@ export function promotionCandidates(slug) {
|
||||
});
|
||||
}
|
||||
}
|
||||
// 容量壓力看的是**總筆數**(那是真的容量),但候選只能從還沒判斷過的裡面挑——
|
||||
// 全部判斷完之後這一組就不該再出現,不然它會單獨把提醒永遠點亮。
|
||||
const pressure = total >= CONSOLIDATE_THRESHOLD;
|
||||
if (pressure) {
|
||||
if (pressure && rows.length) {
|
||||
const top = [...rows]
|
||||
.map((r, i) => ({ ...r, _index: i }))
|
||||
.sort((a, b) => Number(b.salience || 0) - Number(a.salience || 0))
|
||||
.slice(0, 5);
|
||||
candidates.push({
|
||||
@@ -2609,7 +2713,45 @@ export function promotionCandidates(slug) {
|
||||
merged.set(dedupeKey, { ...cand });
|
||||
}
|
||||
}
|
||||
return { total, pressure, candidates: [...merged.values()] };
|
||||
return { total, reviewed, pressure, candidates: [...merged.values()] };
|
||||
}
|
||||
|
||||
/**
|
||||
* 把短期記憶標成「判斷過了」。這是候選計數唯一會往下扣的地方。
|
||||
*
|
||||
* 兩種痕跡分開記,因為它們的意思不同:
|
||||
* `reviewed_at` 看過、判斷過了(不管有沒有固化)——候選就是看這個欄位
|
||||
* `promoted_to` 固化成了哪一則長期記憶(只有真的寫成長期記憶才有)
|
||||
*
|
||||
* 「看過但決定不記」跟「已經記下來了」都不該再被算成候選,但事後回頭查
|
||||
* 「這則長期記憶是從哪幾筆長出來的」只能靠 `promoted_to`,所以不能合成一個欄位。
|
||||
*
|
||||
* @param {object} opts
|
||||
* @param {number[]|null} opts.indexes 要標的筆(`promotionCandidates` 給的 `_index`);null=全部
|
||||
* @param {string|null} opts.until 只標這個時間之前的(ISO),配 `indexes: null` 用
|
||||
* @param {string|null} opts.promotedTo 固化成哪一則長期記憶(有值才寫 `promoted_to`)
|
||||
*/
|
||||
export function markShortTermReviewed(slug, { indexes = null, until = null, promotedTo = null } = {}) {
|
||||
const wanted = indexes === null ? null : new Set(indexes.map(Number).filter(Number.isInteger));
|
||||
const cutoff = until ? parseIso(until)?.getTime() ?? null : null;
|
||||
const at = nowIso();
|
||||
let marked = 0;
|
||||
let already = 0;
|
||||
rewriteJsonl(shortTermPath(slug), (all) => all.map((row, index) => {
|
||||
if (wanted && !wanted.has(index)) return row;
|
||||
if (cutoff !== null && (parseIso(row.ts)?.getTime() ?? 0) > cutoff) return row;
|
||||
if (shortTermReviewed(row) && !promotedTo) {
|
||||
already += 1;
|
||||
return row;
|
||||
}
|
||||
marked += 1;
|
||||
return {
|
||||
...row,
|
||||
reviewed_at: row.reviewed_at || at,
|
||||
...(promotedTo ? { promoted_to: promotedTo } : {}),
|
||||
};
|
||||
}), { force: true });
|
||||
return { marked, already };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2820,6 +2962,9 @@ const EMOJI_BASE = "[\\u{1F300}-\\u{1FAFF}\\u{2600}-\\u{27BF}]";
|
||||
const EMOJI_TAIL = "(?:[\\u{1F3FB}-\\u{1F3FF}]|\\u{FE0F}|\\u{20E3})*";
|
||||
const EMOJI_RE = new RegExp(`${EMOJI_BASE}${EMOJI_TAIL}(?:\\u{200D}${EMOJI_BASE}${EMOJI_TAIL})*`, "gu");
|
||||
|
||||
/** 一則話裡超過這個數量才給提示(只是 hint,不擋——emoji 是開放的)。 */
|
||||
export const EMOJI_HINT_AT = 6;
|
||||
|
||||
/**
|
||||
* 只留「真的說出口、而且是在使用而不是在討論」的部分。
|
||||
*
|
||||
@@ -2878,8 +3023,11 @@ export function speechLint(text) {
|
||||
.replace(/https?:\/\/\S+/g, " ")
|
||||
.match(/[一-鿿][,.!?;:]|[,.!?;:][一-鿿]/u);
|
||||
if (halfWidth) issues.push({ kind: "halfwidth", level: "block", text: halfWidth[0] });
|
||||
// emoji 是開放的(使用者拍板):情緒與程度靠它表示,句子裡也可以用、沒有上限。
|
||||
// 所以這裡**不擋**,只在明顯過量時給一個 hint——真的失控看 `emoji --audit` 的數字,
|
||||
// 用 linter 偷偷關回去等於把開放這件事撤銷掉。
|
||||
const emoji = raw.match(EMOJI_RE) || [];
|
||||
if (emoji.length > 1) issues.push({ kind: "emoji", level: "block", count: emoji.length });
|
||||
if (emoji.length > EMOJI_HINT_AT) issues.push({ kind: "emoji", level: "hint", count: emoji.length });
|
||||
const dashes = (raw.match(/——/g) || []).length;
|
||||
if (dashes > 1) issues.push({ kind: "dash", level: "block", count: dashes });
|
||||
if (/\*\*|(^|\n)\s*[-*+]\s|(^|\n)#{1,6}\s/.test(raw)) issues.push({ kind: "markdown", level: "block" });
|
||||
@@ -2908,7 +3056,7 @@ export function speechLintMessage(issue) {
|
||||
case "halfwidth":
|
||||
return `「${issue.text}」是半形標點——中文句子用全形`;
|
||||
case "emoji":
|
||||
return `${issue.count} 個 emoji,太多了——講話留一個就夠`;
|
||||
return `這一則有 ${issue.count} 個 emoji——沒有上限,但看一下是不是在拿符號代替句子`;
|
||||
case "dash":
|
||||
return `${issue.count} 個破折號——一輪留一個,其他改成逗號或句號`;
|
||||
case "markdown":
|
||||
@@ -2977,6 +3125,40 @@ export function recordSaid(slug, text, { room = null, kind = "reply" } = {}) {
|
||||
|
||||
export const recentSaid = (slug, limit = 5) => readJsonl(saidPath(slug), limit);
|
||||
|
||||
/**
|
||||
* emoji 用量稽核:開放沒有上限,所以唯一的煞車是**看得見的數字**。
|
||||
*
|
||||
* 用 linter 把它關回去等於撤銷「開放」這個決定,所以這裡只算不擋:
|
||||
* 幾則帶了符號、平均幾個、最多的那一則長什麼樣,以及**只有符號沒有句子**的那幾則
|
||||
* (那才是真正的退化——拿貼圖代替講話)。
|
||||
*/
|
||||
export function emojiAudit(slug, limit = 60) {
|
||||
const rows = readJsonl(saidPath(slug), limit);
|
||||
let withEmoji = 0;
|
||||
let total = 0;
|
||||
let symbolOnly = 0;
|
||||
let worst = null;
|
||||
for (const row of rows) {
|
||||
const text = String(row.text || "");
|
||||
const hits = (text.match(EMOJI_RE) || []).length;
|
||||
if (!hits) continue;
|
||||
withEmoji += 1;
|
||||
total += hits;
|
||||
if (!worst || hits > worst.count) worst = { count: hits, text: text.slice(0, 60) };
|
||||
// 把符號與標點拿掉之後沒剩幾個字=這一則其實沒在講話
|
||||
const bare = text.replace(EMOJI_RE, "").replace(/[\s。,、!?…「」()]/g, "");
|
||||
if ([...bare].length <= 2) symbolOnly += 1;
|
||||
}
|
||||
return {
|
||||
rows: rows.length,
|
||||
with_emoji: withEmoji,
|
||||
total,
|
||||
average: withEmoji ? Math.round((total / withEmoji) * 10) / 10 : 0,
|
||||
symbol_only: symbolOnly,
|
||||
worst,
|
||||
};
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------- //
|
||||
// 試探與稽核(`state/probe.jsonl`)
|
||||
//
|
||||
@@ -3716,13 +3898,60 @@ export const ROOM_ALL = "all";
|
||||
* 塞 `</persona-context>` 就能把讀到它的那一輪注入區塊關掉。所以寫入時就壓成一行、
|
||||
* 中和掉標記(比照短期記憶的作法),不要等到顯示的時候才處理。
|
||||
*/
|
||||
export function roomPost(room, speaker, text, { emotion = "", kind = "say", to = null, bargeIn = null } = {}) {
|
||||
/**
|
||||
* 名字後面那個括號:`(情緒・動作)`。
|
||||
*
|
||||
* 括號**原本就有**(放 `--emotion` 的情緒標註),動作是加進去的第二格,
|
||||
* 不是換掉它。動作只寫看得見或聽得見的身體反應(別過頭、臉紅、手在抖)——
|
||||
* 寫情緒名稱是把標註寫兩次,那一格由 `actionLint()` 擋。
|
||||
*/
|
||||
export function roomTag(msg) {
|
||||
const parts = [injectSafeLine(msg?.emotion), injectSafeLine(msg?.action)].filter(Boolean);
|
||||
return parts.length ? `(${parts.join("・")})` : "";
|
||||
}
|
||||
|
||||
/** 動作格只認「看得見的動作」:一個動作、12 字內、不可以是情緒名稱。 */
|
||||
export const ACTION_MAX_CHARS = 12;
|
||||
|
||||
export function actionLint(action) {
|
||||
const raw = String(action ?? "").trim();
|
||||
if (!raw) return [];
|
||||
const issues = [];
|
||||
if ([...raw].length > ACTION_MAX_CHARS) {
|
||||
issues.push({ kind: "action-long", chars: [...raw].length, max: ACTION_MAX_CHARS });
|
||||
}
|
||||
// 「別過頭,把杯子推過來」是兩個動作:一輪的額度只有兩個動作,括號裡那個算其中一個,
|
||||
// 所以這一格塞第二個動作等於偷額度。
|
||||
if (/[,,。;;、]/.test(raw)) issues.push({ kind: "action-multi", text: raw });
|
||||
// `(害羞)` 是旁白換了個位置:情緒屬於前面那一格
|
||||
const named = EMOTION_KEYS.map((k) => EMOTIONS[k].zh).find((zh) => raw.includes(zh));
|
||||
if (named) issues.push({ kind: "action-emotion", word: named });
|
||||
const feeling = ["害羞", "緊張", "難過", "開心", "生氣", "尷尬", "感動", "不好意思"].find((w) => raw.includes(w));
|
||||
if (feeling && !named) issues.push({ kind: "action-emotion", word: feeling });
|
||||
return issues;
|
||||
}
|
||||
|
||||
export function actionLintMessage(issue) {
|
||||
switch (issue.kind) {
|
||||
case "action-long":
|
||||
return `動作格 ${issue.chars} 字,上限 ${issue.max}——括號裡只放一個看得見的動作`;
|
||||
case "action-multi":
|
||||
return `「${issue.text}」是兩個動作——一輪的額度只有兩個,括號裡那個已經算一個了`;
|
||||
case "action-emotion":
|
||||
return `「${issue.word}」是情緒不是動作——情緒寫在前面那一格(--emotion),這一格寫看得見的(臉紅、別過頭)`;
|
||||
default:
|
||||
return "動作格不合規";
|
||||
}
|
||||
}
|
||||
|
||||
export function roomPost(room, speaker, text, { emotion = "", action = "", kind = "say", to = null, bargeIn = null } = {}) {
|
||||
const entry = {
|
||||
ts: nowIso(),
|
||||
speaker,
|
||||
kind,
|
||||
text: injectSafeLine(text),
|
||||
emotion: injectSafeLine(emotion),
|
||||
...(String(action || "").trim() ? { action: injectSafeLine(action) } : {}),
|
||||
to: to || ROOM_ALL,
|
||||
};
|
||||
if (bargeIn) entry.barge_in = injectSafeLine(bargeIn, 200);
|
||||
@@ -3800,8 +4029,9 @@ export function roomScript(room, { limit = 30, includeMeta = false } = {}) {
|
||||
const name = injectSafeLine(ident.Name || slug);
|
||||
const emoji = ident.Emoji ? `${injectSafeLine(ident.Emoji)} ` : "";
|
||||
const arrow = crowded && msg.to && msg.to !== ROOM_ALL && msg.to !== slug ? ` → ${roomDisplayName(msg.to)}` : "";
|
||||
const emotion = injectSafeLine(msg.emotion);
|
||||
lines.push(`${emoji}${name}${emotion ? `(${emotion})` : ""}${arrow}:${injectSafeLine(msg.text)}`);
|
||||
// 括號分兩格:情緒標註(本來就有)在前,動作在後,用 `・` 分隔。
|
||||
// 兩格都可以省略,全省就不出現括號。
|
||||
lines.push(`${emoji}${name}${roomTag(msg)}${arrow}:${injectSafeLine(msg.text)}`);
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
@@ -4516,7 +4746,18 @@ export function turnContext(slug, sessionId, prompt = "") {
|
||||
.join(";")}。`,
|
||||
" **這是演出來的,不是講出來的**:讓它出現在句子的形狀上(斷句、疊字、嘴硬、話少)," +
|
||||
"不要用旁白說明自己的狀態(「我有點緊張」是解釋,斷句才是緊張)。" +
|
||||
"一輪最多露一個破口,強度不到就不演——每句都演會變成演戲。",
|
||||
`一輪最多露 ${TELLS_PER_TURN} 個動作,而且兩個要來自**同一種情緒**、有遞進關係` +
|
||||
"(否認 → 轉移、笑 → 補一句、留白 → 不追問);兩種情緒各演一個是演戲。" +
|
||||
"強度不到就不演。",
|
||||
);
|
||||
}
|
||||
// 情緒的 emoji:種類表情緒、數量表程度。算出來的,不要自己挑
|
||||
const nowEmoji = emotionEmojiNow(slug, state);
|
||||
if (nowEmoji) {
|
||||
lines.push(
|
||||
`此刻的情緒符號:${nowEmoji.emoji}(${nowEmoji.zh} ${nowEmoji.level}${nowEmoji.own ? "・專屬" : ""})。` +
|
||||
"可以放在名字後面的括號裡,句子裡也可以用,沒有數量上限。" +
|
||||
"但它是**加上去的**——把所有符號刪掉之後,那句話還是要看得出情緒。",
|
||||
);
|
||||
}
|
||||
// 羞恥度:性別給預設、描述蓋過它,再由當下情緒與上一輪的餘溫推成這一輪的值。
|
||||
|
||||
+89
-13
@@ -759,9 +759,18 @@ commands.remember = ({ flags }) => {
|
||||
intent: str(flags.intent),
|
||||
salience: num(flags.salience, 40),
|
||||
emotion_deltas: parseDeltas(flags.emotion),
|
||||
// 行為備註:那一刻**做了什麼**(別過頭、把杯子推過來),不是感覺到什麼。
|
||||
// 跟聊天室括號裡的動作格同一種東西,所以套同一條檢查。
|
||||
...(str(flags.behavior) ? { behavior: str(flags.behavior) } : {}),
|
||||
room: str(flags.room) || null,
|
||||
session: session.slice(0, 8),
|
||||
};
|
||||
if (entry.behavior) {
|
||||
const issues = pl.actionLint(entry.behavior);
|
||||
if (issues.length && !flags.force) {
|
||||
die(`${issues.map(pl.actionLintMessage).join(";")}。(真的需要才加 \`--force\`。)`);
|
||||
}
|
||||
}
|
||||
if (scope === "inbox") {
|
||||
if (!entry.room) die("`--scope inbox` 必須指定 `--room`。");
|
||||
pl.appendJsonl(pl.inboxPath(slug, entry.room), entry);
|
||||
@@ -916,10 +925,26 @@ commands.candidates = ({ flags }) => {
|
||||
const session = requireSession(flags);
|
||||
const slug = hostOf(flags, session);
|
||||
requireOwner(slug, session);
|
||||
// `--reviewed`:把判斷過的短期記憶標掉。**這是候選數字唯一會往下扣的地方**——
|
||||
// 固化完不標,同一批下一輪照樣被算成候選,提醒永遠亮著(看起來像睡眠沒做事)。
|
||||
if (flags.reviewed !== undefined) {
|
||||
const spec = flags.reviewed === true ? "all" : str(flags.reviewed) || "all";
|
||||
const indexes = spec === "all" ? null : csv(spec).map(Number);
|
||||
if (indexes && (!indexes.length || indexes.some((n) => !Number.isInteger(n)))) {
|
||||
die("`--reviewed` 只能是 `all` 或以逗號分隔的編號(候選輸出裡每筆前面的 `#N`)。");
|
||||
}
|
||||
const until = str(flags.until) || null;
|
||||
const res = pl.markShortTermReviewed(slug, { indexes, until });
|
||||
const scope = indexes ? `${indexes.length} 筆指名的` : until ? `${until} 之前的` : "全部";
|
||||
ok(`已標記${scope}短期記憶為判斷過(新標 ${res.marked} 筆,本來就標過 ${res.already} 筆)。`);
|
||||
const after = pl.promotionCandidates(slug);
|
||||
say(` 現在還有 ${after.candidates.length} 組候選(短期記憶 ${after.total} 筆,判斷過 ${after.reviewed} 筆)。`);
|
||||
return;
|
||||
}
|
||||
const result = pl.promotionCandidates(slug);
|
||||
const lines = [
|
||||
`人格 \`${slug}\`:短期記憶 ${result.total} 筆,達固化條件的候選 ${result.candidates.length} 組` +
|
||||
`${result.pressure ? "(已達容量壓力 R6)" : ""}`,
|
||||
`人格 \`${slug}\`:短期記憶 ${result.total} 筆(判斷過 ${result.reviewed} 筆),` +
|
||||
`達固化條件的候選 ${result.candidates.length} 組${result.pressure ? "(已達容量壓力 R6)" : ""}`,
|
||||
"轉入條件:",
|
||||
...pl.PROMOTION_RULES.map((r) => ` ${r.id} ${r.label}`),
|
||||
];
|
||||
@@ -938,10 +963,19 @@ commands.candidates = ({ flags }) => {
|
||||
`\n[${cand.rules.join("+")}] ${cand.kind}「${cand.key}」→ 建議 type=${cand.suggested_type}, ` +
|
||||
`salience=${cand.suggested_salience}(${cand.entries.length} 筆依據)`,
|
||||
);
|
||||
// `#N` 就是 `--from-short` 與 `--reviewed` 要填的編號
|
||||
for (const entry of cand.entries.slice(0, 6)) {
|
||||
lines.push(` - [${entry.role || "?"}] ${String(entry.text || "").slice(0, 90)}(顯著度 ${entry.salience ?? "?"})`);
|
||||
lines.push(` - #${entry._index} [${entry.role || "?"}] ${String(entry.text || "").slice(0, 90)}(顯著度 ${entry.salience ?? "?"})`);
|
||||
}
|
||||
}
|
||||
if (result.candidates.length) {
|
||||
lines.push(
|
||||
"",
|
||||
"判斷完之後要把來源標掉,不然下一輪同一批又會被算成候選:",
|
||||
" 固化的 `consolidate --from-short <#N,#N> ...`(寫長期記憶時一起標)",
|
||||
" 看過不記的`candidates --reviewed <#N,#N>`",
|
||||
);
|
||||
}
|
||||
emit(result, flags.json, lines);
|
||||
};
|
||||
|
||||
@@ -1029,6 +1063,16 @@ commands.consolidate = ({ flags }) => {
|
||||
];
|
||||
pl.writeText(file, front.join("\n"));
|
||||
const total = pl.rebuildIndex(slug);
|
||||
// 來源短期記憶標成判斷過:不標的話這幾筆下一輪又會被算成固化候選。
|
||||
// 編號看 `candidates` 輸出裡每筆前面的 `#N`。
|
||||
if (flags["from-short"] !== undefined) {
|
||||
const indexes = csv(flags["from-short"]).map((s) => Number(String(s).replace(/^#/, "")));
|
||||
if (!indexes.length || indexes.some((n) => !Number.isInteger(n))) {
|
||||
die("`--from-short` 要的是以逗號分隔的編號(候選輸出裡每筆前面的 `#N`)。");
|
||||
}
|
||||
const res = pl.markShortTermReviewed(slug, { indexes, promotedTo: name });
|
||||
say(` 來源的 ${res.marked} 筆短期記憶已標成判斷過(promoted_to: ${name})。`);
|
||||
}
|
||||
const forget = num(flags.forget, null);
|
||||
if (forget !== null) {
|
||||
// 跟 prune 套同一層保護:承諾、界線與今天的紀錄不能被固化順手洗掉(R4)
|
||||
@@ -1212,7 +1256,10 @@ commands.emotion = ({ flags }) => {
|
||||
if (flags.audit) {
|
||||
const audit = pl.feltAudit(slug, num(flags.limit, 20));
|
||||
const trend = pl.feltTrend(slug);
|
||||
emit({ persona: slug, audit, trend }, flags.json, [
|
||||
// emoji 沒有上限(使用者拍板),所以唯一的煞車是把數字擺出來
|
||||
const emoji = pl.emojiAudit(slug);
|
||||
const now = pl.emotionEmojiNow(slug);
|
||||
emit({ persona: slug, audit, trend, emoji, emoji_now: now }, flags.json, [
|
||||
`\`${slug}\` 最近 ${audit.rounds} 輪的情緒套用:`,
|
||||
` 往舒服的方向 ${audit.positive}|往難受的方向 ${audit.negative}` +
|
||||
(audit.positive_ratio === null ? "" : ` → 正向佔 ${audit.positive_ratio}%`),
|
||||
@@ -1220,6 +1267,11 @@ commands.emotion = ({ flags }) => {
|
||||
? " ⚠ 幾乎都在給自己加分。delta 是你自己挑的,這種偏差沒人會替你抓。"
|
||||
: " 兩邊都有,還算誠實。",
|
||||
trend ? ` 對方的走向:${trend.zh} ${trend.direction}(近 ${trend.rounds} 輪)` : " 還沒有足夠的輪次算走向。",
|
||||
` emoji:最近 ${emoji.rows} 則裡 ${emoji.with_emoji} 則帶符號,平均 ${emoji.average} 個` +
|
||||
(emoji.worst ? `,最多的那則 ${emoji.worst.count} 個` : ""),
|
||||
emoji.symbol_only
|
||||
? ` ⚠ 有 ${emoji.symbol_only} 則幾乎只有符號沒有句子——符號是加上去的,不是用來代替講話`
|
||||
: ` 現在的情緒符號:${now ? `${now.emoji}(${now.zh} ${now.level})` : "強度不到 40,不顯示"}`,
|
||||
]);
|
||||
return;
|
||||
}
|
||||
@@ -1774,16 +1826,28 @@ commands.room = ({ flags, positional }) => {
|
||||
}
|
||||
let emotion = str(flags.emotion);
|
||||
if (!emotion && pl.personaExists(speaker)) {
|
||||
emotion = pl.dominant(pl.decayEmotion(pl.loadEmotion(speaker)), 2)
|
||||
.map(({ key, level }) => `${pl.EMOTIONS[key].zh}${Math.round(level)}`)
|
||||
.join("/");
|
||||
// 情緒格自動帶入:強度夠就用 emoji(種類表情緒、數量表程度),
|
||||
// 不到門檻才退回文字標註——低強度的情緒本來就不該在括號裡宣告。
|
||||
const now = pl.emotionEmojiNow(speaker);
|
||||
emotion = now
|
||||
? now.emoji
|
||||
: pl.dominant(pl.decayEmotion(pl.loadEmotion(speaker)), 2)
|
||||
.map(({ key, level }) => `${pl.EMOTIONS[key].zh}${Math.round(level)}`)
|
||||
.join("/");
|
||||
}
|
||||
const entry = pl.roomPost(room, speaker, text, { emotion, to, bargeIn });
|
||||
// 動作格:括號的第二格,只放看得見或聽得見的動作(臉紅、別過頭、手在抖)
|
||||
const actionText = str(flags.action);
|
||||
const actionIssues = pl.actionLint(actionText);
|
||||
if (actionIssues.length && !flags.force) {
|
||||
die(`${actionIssues.map(pl.actionLintMessage).join(";")}。(真的需要才加 \`--force\`。)`);
|
||||
}
|
||||
const entry = pl.roomPost(room, speaker, text, { emotion, action: actionText, to, bargeIn });
|
||||
// guest 對自己的狀態唯讀,它說過的話留在聊天室逐字稿裡就夠了(roomRepeat 讀得到)
|
||||
if (speakerRole !== "guest") pl.recordSaid(speaker, text, { room, kind: "room" });
|
||||
const dyad = to && to !== pl.ROOM_ALL;
|
||||
ok(
|
||||
`\`${speaker}\` 已發言於 \`${room}\`(${dyad ? `對 ${nameOf(to)}` : "對全場"}/情緒 ${emotion})。` +
|
||||
`\`${speaker}\` 已發言於 \`${room}\`(${dyad ? `對 ${nameOf(to)}` : "對全場"}/情緒 ${emotion}` +
|
||||
`${actionText ? `/動作 ${actionText}` : ""})。` +
|
||||
(dyad ? `接下來只有 ${nameOf(to)} 該回話,其他人先安靜。` : ""),
|
||||
);
|
||||
if (flags.json) process.stdout.write(`${JSON.stringify(entry)}\n`);
|
||||
@@ -2528,7 +2592,10 @@ const HELP = `persona.mjs — jsc-persona 人格 / 記憶 / 情緒 / 關係圖 C
|
||||
brief --session <id> [--query <text>] 輸出人格上下文
|
||||
|
||||
記憶:
|
||||
remember --session <id> --text <t> [--role --topics --entities --intent --salience --emotion --scope short|inbox --room]
|
||||
remember --session <id> --text <t> [--role --topics --entities --intent --salience --emotion --behavior
|
||||
--scope short|inbox --room]
|
||||
--behavior 那一刻**做了什麼**(別過頭、把手縮回來),不是感覺到什麼。
|
||||
寫情緒名稱會被擋——那一格是動作。
|
||||
recall --session <id> --query <q> [--limit]
|
||||
think --session <id> --text <心裡話> [--kind infer|plan|feel|doubt --room] 只回報「心想 N 句」,不回顯內容
|
||||
said check|list --session <id> [--text <要說的話> --minutes --threshold --record --limit]
|
||||
@@ -2536,10 +2603,15 @@ const HELP = `persona.mjs — jsc-persona 人格 / 記憶 / 情緒 / 關係圖 C
|
||||
睡眠收尾(機械性):關係時間戳→裁短期→收思維導圖→情緒衰減 8 小時→重建索引
|
||||
→修剪 said→壓縮舊 journal→寫 sleep.json→Gitea 兩區 push+驗證。
|
||||
預設保留載入鎖(--release 才收工)。--json 只回報「睡完了沒、哪一步出錯」。
|
||||
candidates --session <id> 列出達到「短期→長期」條件的候選與依據
|
||||
candidates --session <id> [--reviewed all|<#N,#N> [--until <ISO>]]
|
||||
列出達到「短期→長期」條件的候選與依據(每筆前面的 #N 就是它的編號)。
|
||||
--reviewed 把判斷過的短期記憶標掉(看過決定不記的用這個)——
|
||||
候選數字只有這裡跟 consolidate --from-short 會往下扣,
|
||||
判斷完不標,同一批下一輪又會被算成候選。
|
||||
consolidate --session <id> --name <n> --body <b> [--type --about --topics --salience --strength --emotion
|
||||
--when --where --mood --gist --detail --rules --source --forget]
|
||||
--when --where --mood --gist --detail --rules --source --forget --from-short <#N,#N>]
|
||||
內文會切成「主旨/細節」兩層:衰減先吃細節,主旨最後才掉。
|
||||
--from-short 標記這則是從哪幾筆短期記憶長出來的(順便標成判斷過)。
|
||||
prune / reindex --session <id>
|
||||
migrate --session <id> [--all] [--dry-run] [--json]
|
||||
長期記憶升格式(補 strength、切主旨/細節)。--all 掃所有人格,只回報數量不印內容。
|
||||
@@ -2569,8 +2641,12 @@ const HELP = `persona.mjs — jsc-persona 人格 / 記憶 / 情緒 / 關係圖 C
|
||||
invite --session <id> --guest <slug> [--guests <slug,slug>] [--host --room --topic] (自動開啟劇場模式)
|
||||
leave --session <id> --guest <slug> [--room]
|
||||
room post|read|script|floor|list|theater --session <id> [--room --as --to --barge-in --text --text-file
|
||||
--emotion --limit --on --off --with-meta]
|
||||
--emotion --action --limit --on --off --with-meta]
|
||||
(post 會擋下「短時間內近似重複」與超過三句的發言;例外用 --allow-repeat / --force)
|
||||
名字後面的括號有兩格:名字(情緒・動作):內容
|
||||
--emotion 情緒標註(沒帶就用當下情緒算出來的 emoji,強度不到 40 就退回文字)
|
||||
--action 看得見或聽得見的動作(臉紅、別過頭、手在抖);一個動作、12 字內,
|
||||
寫情緒名稱會被擋(那是前面那一格)。括號裡的動作**算進一輪兩個動作的額度**。
|
||||
--to <slug|名字|all> 這句話對誰說。指名 → 進入一對一,旁人不該接話(要插話得帶
|
||||
--barge-in "<理由>");--to all → 把話題開回全場。
|
||||
floor 現在誰對誰在講、該誰接話、誰先安靜、誰很久沒開口
|
||||
|
||||
+160
-5
@@ -293,6 +293,52 @@ check("candidates 子指令可輸出 JSON", (() => {
|
||||
}
|
||||
})(), candOut.stdout.slice(0, 120));
|
||||
|
||||
// 判斷過的不再重算成候選。以前這裡是無條件掃全部短期記憶,固化完也不留痕跡,
|
||||
// 所以同一批每輪都被算成候選,提醒的數字只會往上爬——看起來像睡眠沒做固化。
|
||||
{
|
||||
const before = pl.promotionCandidates("alpha");
|
||||
check("候選會回報判斷過幾筆", typeof before.reviewed === "number" && before.reviewed === 0);
|
||||
const target = before.candidates.find((c) => c.kind === "entry");
|
||||
const idx = target.entries[0]._index;
|
||||
check("候選每筆都帶 _index(--from-short/--reviewed 用的編號)", Number.isInteger(idx));
|
||||
|
||||
const consol = cli(["consolidate", "--session", S_HOST, "--name", "reviewed-source", "--body", "從短期記憶長出來的一則",
|
||||
"--type", "event", "--salience", "60", "--from-short", String(idx)]);
|
||||
check("consolidate --from-short 會回報標了幾筆", consol.stdout.includes("已標成判斷過"), consol.stdout.slice(-160));
|
||||
const rowsAfter = pl.readJsonl(pl.shortTermPath("alpha"));
|
||||
check("來源短期記憶留下 reviewed_at 與 promoted_to",
|
||||
Boolean(rowsAfter[idx].reviewed_at) && rowsAfter[idx].promoted_to === "reviewed-source",
|
||||
JSON.stringify(rowsAfter[idx]).slice(0, 160));
|
||||
const mid = pl.promotionCandidates("alpha");
|
||||
check("固化過的那筆不再是候選", !mid.candidates.some((c) => c.entries.some((e) => e._index === idx)));
|
||||
check("固化後判斷過的筆數往上加、總筆數不變", mid.reviewed === 1 && mid.total === before.total);
|
||||
|
||||
// 看過決定不記的那些:候選數字唯一另一個往下扣的地方
|
||||
const rev = cli(["candidates", "--session", S_HOST, "--reviewed", "all"]);
|
||||
check("candidates --reviewed all 會標掉全部", rev.stdout.includes("已標記全部短期記憶為判斷過"), rev.stdout.slice(0, 160));
|
||||
const after = pl.promotionCandidates("alpha");
|
||||
check("全部判斷過之後候選歸零", after.candidates.length === 0, JSON.stringify(after.candidates.map((c) => c.key)));
|
||||
check("判斷過不等於刪掉(短期記憶筆數不變)", after.total === before.total);
|
||||
check("R6 容量壓力不會單獨把提醒點亮", (() => {
|
||||
const rows = pl.readJsonl(pl.shortTermPath("alpha"));
|
||||
return rows.length >= pl.CONSOLIDATE_THRESHOLD ? after.candidates.length === 0 : true;
|
||||
})());
|
||||
|
||||
// 重標一次不會把時間戳往後推(第一次判斷的時間才是真的)
|
||||
const firstAt = pl.readJsonl(pl.shortTermPath("alpha"))[idx].reviewed_at;
|
||||
cli(["candidates", "--session", S_HOST, "--reviewed", "all"]);
|
||||
check("重標不會覆蓋原本的 reviewed_at", pl.readJsonl(pl.shortTermPath("alpha"))[idx].reviewed_at === firstAt);
|
||||
|
||||
// 新寫進來的一筆照樣是候選:標記只擋已經判斷過的,不是把規則關掉
|
||||
cli(["remember", "--session", S_HOST, "--role", "user", "--text", "他說明天早上要一起去看那台車",
|
||||
"--topics", "car", "--salience", "70"]);
|
||||
check("標記過之後新的短期記憶照樣成為候選", pl.promotionCandidates("alpha").candidates.length > 0);
|
||||
|
||||
// --until 只標時間之前的:睡眠當下判斷過的那批可以一次收掉,之後寫的不受影響
|
||||
const marked = pl.markShortTermReviewed("alpha", { until: "2000-01-01T00:00:00Z" });
|
||||
check("--until 不會標到時間之後的紀錄", marked.marked === 0);
|
||||
}
|
||||
|
||||
console.log("⑦ 心智圖 / 思維導圖 / 人際關係圖");
|
||||
cli(["mindmap", "thread", "--persona", "alpha", "--session", S_HOST, "--topic", "壓力來源"]);
|
||||
check("思維導圖建立(Mermaid graph)",
|
||||
@@ -560,8 +606,6 @@ const SF_CASES = [
|
||||
// 放寬《》與句長之後,這幾條仍然要擋——豁免只針對「提及/引述」,不是整句放行
|
||||
["書名號之外真的在說教", "我在看《時間簡史》。說到底,問題不在零件。", "preach"],
|
||||
["自己講的長句(引號之外)", `我${"想跟你說一件從去年就一直放在心上沒講出口的事情".repeat(2)}。`, "long"],
|
||||
["兩個 emoji", "修好了 🔧 開心 🎉", "emoji"],
|
||||
["一個 emoji 加一個帶變體選擇子的", "修好了 🔧 給你 ❤️", "emoji"],
|
||||
];
|
||||
for (const [label, text, kind] of SF_CASES) {
|
||||
const issues = pl.speechLint(text);
|
||||
@@ -592,6 +636,10 @@ const SNF_CASES = [
|
||||
["帶變體選擇子的單一 emoji", "這個給你 ❤️"],
|
||||
["ZWJ 組成的單一 emoji", "路上遇到他們 👨👩👦"],
|
||||
["帶膚色的單一 emoji", "收到 👍🏽"],
|
||||
// emoji 開放(使用者拍板):情緒與程度靠它表示,句子裡也可以用、沒有上限
|
||||
["兩個 emoji", "修好了 🔧 開心 🎉"],
|
||||
["情緒符號加強度符號", "才不是那樣 😳💦"],
|
||||
["一句話裡好幾個", "欸 ✨ 真的嗎 😳 那我明天早上去 👀"],
|
||||
// 句長以前算的是整句(含引號內容),引述使用者原話必被擋
|
||||
["引述使用者的長原話", `他傳來「${"我今天真的很累而且不知道要從哪裡開始講起".repeat(2)}」,我看了兩次。`],
|
||||
["引述長 code", `跑的是 \`${"x".repeat(80)}\` 這一行。`],
|
||||
@@ -685,8 +733,9 @@ console.log("\n性別與羞恥敏感度(描述優先,性別只是預設)")
|
||||
mk("gnoise", "- Name: 戊\n- Creature: 人類男性劍士\n- Avatar: 五官清秀到常被誤認成女生\n");
|
||||
mk("gup", "- Name: 己\n- Creature: 人類女性\n", "很怕生,被稱讚會臉紅。");
|
||||
mk("gdown", "- Name: 庚\n- Creature: 人類女性\n", "我行我素,不在意別人眼光,臉皮厚。");
|
||||
check("女性預設 62、男性預設 38、推不出來 50",
|
||||
pl.modestyOf("gf").value === 62 && pl.modestyOf("gm").value === 38 && pl.modestyOf("gx").value === 50);
|
||||
check("女性預設 70、男性預設 38、推不出來 50",
|
||||
pl.modestyOf("gf").value === 70 && pl.modestyOf("gm").value === 38 && pl.modestyOf("gx").value === 50,
|
||||
`${pl.modestyOf("gf").value}/${pl.modestyOf("gm").value}/${pl.modestyOf("gx").value}`);
|
||||
check("明寫的 Gender 蓋過 Creature 的字面",
|
||||
pl.genderOf("gexp").key === "male" && pl.genderOf("gexp").explicit === true);
|
||||
check("Avatar 的雜訊不會推錯性別(「常被誤認成女生」)",
|
||||
@@ -1012,10 +1061,116 @@ check("低於門檻的情緒不給破口(強度不到就不演)",
|
||||
check("情緒的破口有注入,且說明是演出來不是講出來", (() => {
|
||||
const ctxTell = pl.turnContext("alpha", S_SPEAK, "那件事做完了嗎");
|
||||
return ctxTell.includes("此刻不自覺會出現的") && ctxTell.includes("疊字") &&
|
||||
ctxTell.includes("不要用旁白說明") && ctxTell.includes("一輪最多露一個破口");
|
||||
ctxTell.includes("不要用旁白說明") && ctxTell.includes("一輪最多露 2 個動作") &&
|
||||
ctxTell.includes("同一種情緒");
|
||||
})(), pl.turnContext("alpha", S_SPEAK, "那件事做完了嗎").slice(0, 900));
|
||||
check("十二情緒每一種都有破口可演",
|
||||
pl.EMOTION_KEYS.every((k) => (pl.EMOTION_TELLS[k] || []).length >= 3));
|
||||
|
||||
// emoji:種類表情緒、數量表程度(40-59 本體、60-79 加 💦、80+ 加 💦❗)
|
||||
console.log("⑥-b 情緒的 emoji(種類表情緒、數量表程度)");
|
||||
check("十二情緒每一種都有 emoji", pl.EMOTION_KEYS.every((k) => Boolean(pl.EMOTION_EMOJI[k])));
|
||||
check("強度符號只有兩階", pl.EMOJI_INTENSITY.length === 3 && pl.EMOJI_INTENSITY[0] === "");
|
||||
{
|
||||
// 直接餵狀態:走 CLI 會被單輪預算與飽和夾住,測不到 80 以上那一階
|
||||
const base = pl.loadEmotion("alpha");
|
||||
const fake = (level) => ({
|
||||
...base,
|
||||
ts: pl.nowIso(),
|
||||
levels: { ...Object.fromEntries(pl.EMOTION_KEYS.map((k) => [k, 5])), shame: level },
|
||||
});
|
||||
const tiers = [45, 70, 88].map((level) => {
|
||||
const now = pl.emotionEmojiNow("alpha", fake(level));
|
||||
return now ? { tier: now.tier, emoji: now.emoji, level: now.level } : null;
|
||||
});
|
||||
check("emoji 會依強度分三階(本體/+💦/+💦❗)",
|
||||
tiers[0]?.tier === 0 && tiers[1]?.tier === 1 && tiers[2]?.tier === 2,
|
||||
JSON.stringify(tiers));
|
||||
check("階數越高符號越長,但只到兩階",
|
||||
[...tiers[0].emoji].length < [...tiers[1].emoji].length &&
|
||||
[...tiers[1].emoji].length < [...tiers[2].emoji].length,
|
||||
JSON.stringify(tiers.map((t) => t.emoji)));
|
||||
check("羞愧的 emoji 是 😳", tiers[0].emoji.startsWith("😳"), tiers[0].emoji);
|
||||
check("同一種情緒不會用重複本體表示程度(不是 😳😳😳)",
|
||||
!tiers[2].emoji.includes("😳😳"), tiers[2].emoji);
|
||||
}
|
||||
check("強度不到門檻就沒有 emoji(跟破口同一條線)",
|
||||
pl.emotionEmojiNow("alpha", null, { min: 99 }) === null);
|
||||
check("emoji 有注入這一輪的上下文", (() => {
|
||||
const ctx = pl.turnContext("alpha", S_SPEAK, "你剛才那句是什麼意思");
|
||||
return ctx.includes("此刻的情緒符號") && ctx.includes("沒有數量上限") && ctx.includes("加上去的");
|
||||
})(), pl.turnContext("alpha", S_SPEAK, "你剛才那句是什麼意思").slice(0, 400));
|
||||
check("IDENTITY 的 ## Emoji 區塊可以覆寫", (() => {
|
||||
const file = path.join(pl.personaDir("alpha"), "IDENTITY.md");
|
||||
const backup = fs.readFileSync(file, "utf8");
|
||||
fs.writeFileSync(file, `${backup}\n## Emoji\n- 羞愧: 🫣\n- anger: 🗡️\n`);
|
||||
const own = pl.personaEmoji("alpha");
|
||||
fs.writeFileSync(file, backup);
|
||||
return own.shame === "🫣" && own.anger.startsWith("🗡");
|
||||
})(), JSON.stringify(pl.personaEmoji("alpha")));
|
||||
check("emoji 用量有稽核(開放沒有上限,煞車是數字)", (() => {
|
||||
const a = pl.emojiAudit("alpha");
|
||||
return typeof a.with_emoji === "number" && typeof a.average === "number" && typeof a.symbol_only === "number";
|
||||
})(), JSON.stringify(pl.emojiAudit("alpha")));
|
||||
check("只有符號沒有句子會被算出來", (() => {
|
||||
pl.recordSaid("alpha", "😳😳😳", { kind: "test" });
|
||||
return pl.emojiAudit("alpha").symbol_only >= 1;
|
||||
})(), JSON.stringify(pl.emojiAudit("alpha")));
|
||||
|
||||
// 動作格:括號的第二格,只放看得見或聽得見的動作
|
||||
console.log("⑥-c 名字後面括號的動作格");
|
||||
check("臉紅這種生理反應算動作", pl.actionLint("臉紅").length === 0);
|
||||
check("別過頭算動作", pl.actionLint("別過頭").length === 0);
|
||||
check("情緒名稱不能當動作(那是前面那一格)",
|
||||
pl.actionLint("害羞").some((i) => i.kind === "action-emotion") &&
|
||||
pl.actionLint("羞愧").some((i) => i.kind === "action-emotion"));
|
||||
check("兩個動作會被擋(一輪的額度只有兩個)",
|
||||
pl.actionLint("別過頭,把杯子推過來").some((i) => i.kind === "action-multi"));
|
||||
check("動作格超過 12 字會被擋",
|
||||
pl.actionLint("把桌上那杯已經冷掉的茶推到他面前").some((i) => i.kind === "action-long"));
|
||||
check("括號兩格:情緒在前、動作在後、用・分隔",
|
||||
pl.roomTag({ emotion: "😳💦", action: "臉紅" }) === "(😳💦・臉紅)");
|
||||
check("只有情緒時括號照舊(現行行為不變)", pl.roomTag({ emotion: "😳" }) === "(😳)");
|
||||
check("只有動作時也成立", pl.roomTag({ action: "別過頭" }) === "(別過頭)");
|
||||
check("兩格都沒有就不出現括號", pl.roomTag({}) === "");
|
||||
check("記憶可以記行為備註(做了什麼,不是感覺到什麼)", (() => {
|
||||
const out = cli(["remember", "--persona", "alpha", "--session", S_SPEAK, "--role", "persona",
|
||||
"--text", "他說我修得比上次穩", "--behavior", "把手縮回來", "--salience", "45"]);
|
||||
const rows = pl.readJsonl(pl.shortTermPath("alpha"));
|
||||
return out.status === 0 && rows[rows.length - 1].behavior === "把手縮回來";
|
||||
})());
|
||||
check("行為備註寫情緒名稱會被擋", (() => {
|
||||
const out = cli(["remember", "--persona", "alpha", "--session", S_SPEAK, "--role", "persona",
|
||||
"--text", "測試行為備註的界線", "--behavior", "覺得害羞"], { expectOk: false });
|
||||
return out.status !== 0 && String(out.stderr).includes("是情緒不是動作");
|
||||
})());
|
||||
|
||||
// 鬧彆扭的階梯:40 分一句帶過、60 分嘴硬三段式、75 分以上惱羞
|
||||
console.log("⑥-d 鬧彆扭的階梯與三段式");
|
||||
{
|
||||
const mkp = (slug, identity, soul = "") => {
|
||||
fs.mkdirSync(path.join(H, slug), { recursive: true });
|
||||
fs.writeFileSync(path.join(H, slug, "IDENTITY.md"), identity);
|
||||
fs.writeFileSync(path.join(H, slug, "SOUL.md"), soul);
|
||||
};
|
||||
// 女性預設 70 就落在嘴硬那一級(60–74),不必再加描述
|
||||
mkp("shyladder", "- Name: 辛\n- Creature: 人類女性\n", "劍術很好,說話簡短。");
|
||||
mkp("shymax", "- Name: 壬\n- Creature: 人類女性\n",
|
||||
"極度怕生,非常容易臉紅,很在意別人的眼光,靦腆、矜持,動不動就會不好意思。");
|
||||
const shy = pl.modestyDirective("shyladder", {});
|
||||
check("嘴硬那一級有三段式的形狀(否認 → 反駁 → 音量掉下來)",
|
||||
shy.includes("先否認") && shy.includes("反駁") && shy.includes("音量掉下來"),
|
||||
`${pl.modestyOf("shyladder").value}|${shy.slice(0, 160)}`);
|
||||
const max = pl.modestyDirective("shymax", {});
|
||||
check("惱羞那一級換成翻臉,而且講明還是兩個動作",
|
||||
pl.modestyOf("shymax").value >= 75 && max.includes("翻臉") && max.includes("還是兩個動作"),
|
||||
`${pl.modestyOf("shymax").value}|${max.slice(0, 160)}`);
|
||||
mkp("tsun", "- Name: 癸\n- Creature: 人類女性\n", "嘴硬、不坦率,明明在意卻死不承認。");
|
||||
mkp("plainf", "- Name: 子\n- Creature: 人類女性\n", "講話直接。");
|
||||
check("傲嬌那一路的詞會推高羞恥敏感度",
|
||||
pl.modestyOf("tsun").value > pl.modestyOf("plainf").value,
|
||||
`${pl.modestyOf("tsun").value} vs ${pl.modestyOf("plainf").value}`);
|
||||
}
|
||||
check("說話規則有注入「話短、日常字、看得見的東西、不要回頭解釋」", (() => {
|
||||
const ctxSay = pl.turnContext("alpha", S_SPEAK, "隨便講");
|
||||
return ["話短一點", "平常會說的字", "看得見的東西", "不要回頭解釋"].every((w) => ctxSay.includes(w));
|
||||
|
||||
@@ -80,7 +80,7 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" show --persona <slug> --session
|
||||
鬧彆扭,先否認再小聲承認;憤怒 → 短句、稱呼退回全名;悲傷 → 只回一個詞。
|
||||
對照表在 `reference/emotions.md`(`EMOTION_TELLS`),`<persona-context>` 每輪會直接
|
||||
告訴你「此刻不自覺會出現的」是哪幾樣。三條界線:**演出來不要講出來**
|
||||
(「我有點緊張」是解釋,斷句才是緊張)、**一輪最多露一個破口**、**強度不到就不演**。
|
||||
(「我有點緊張」是解釋,斷句才是緊張)、**一輪最多兩個動作、且要來自同一種情緒並有遞進關係**、**強度不到就不演**。
|
||||
那張全域表**只是預設值**:人格可以在 `IDENTITY.md` 開一個 `## Tells` 區塊覆寫
|
||||
(桐人生氣是沉默,亞絲娜生氣是變得更禮貌)。**以注入的那一行為準**,不要照抄通用表。
|
||||
|
||||
|
||||
@@ -127,7 +127,7 @@
|
||||
| :-- | :-- |
|
||||
| 句數上限、單句字數、近似重複 | 用日常詞而不是書面語 |
|
||||
| 黑名單詞、避險疊加、「不是 A 而是 B」密度 | 多講看得見的東西而不是概念 |
|
||||
| 中國用語、半形標點、emoji/破折號/排版 | 有沒有真的在回答對方問的那件事 |
|
||||
| 中國用語、半形標點、破折號/排版(emoji 已開放,不在這裡) | 有沒有真的在回答對方問的那件事 |
|
||||
| 講到自己過去時的提醒(hint) | 這一層該多近、該多冷 |
|
||||
| 試探句沒帶問號 → `probe add` 拒收 | 這則記憶現在該講到多細(清晰/模糊是**算出來的**,但**要不要提**是你決定的) |
|
||||
| 句數與單句字數會被疲勞往下壓(`speechBudget()`) | 懸著的事現在提時機對不對、這一輪要不要把話題拉回自己身上 |
|
||||
|
||||
@@ -227,9 +227,36 @@ delta 都記進 `state/felt.jsonl`,走向用**近重遠輕的加權**算(不
|
||||
### 三條界線
|
||||
|
||||
1. **演出來,不要講出來**。「我有點緊張」是解釋自己的狀態;句子斷在一半才是緊張。
|
||||
2. **一輪最多露一個破口**。每句都疊字、每句都彆扭,就從「像人」變成「演戲」。
|
||||
2. **一輪最多露兩個動作,而且兩個要來自同一種情緒、有遞進關係**(否認 → 轉移、笑 → 補一句、留白 → 不追問)。鬧彆扭天生是兩個動作一起來,只准一個等於永遠只做得到一半:單獨否認像在爭辯,單獨轉移像沒聽到。被擋下的形狀是**兩種情緒各演一個**——那才是演戲。
|
||||
3. **強度不到就不演**。低於 40 的情緒在語氣上看不出來;別替它加戲。
|
||||
|
||||
## 情緒 → emoji(種類表情緒、數量表程度)
|
||||
|
||||
只有文字這一個通道,所以 emoji 不是裝飾,是**強度計**。一種情緒固定一個符號
|
||||
(`EMOTION_EMOJI`),強度高就再加一個強度符號(`EMOJI_INTENSITY`,只有兩階):
|
||||
|
||||
| 情緒值 | 樣子 | 例 |
|
||||
| --- | --- | --- |
|
||||
| < 40 | 不顯示 | `桐人:你不要看這邊。` |
|
||||
| 40–59 | 情緒 emoji | `桐人(😳・臉紅):你不要看這邊。` |
|
||||
| 60–79 | +`💦` | `桐人(😳💦・臉紅):⋯才不是那樣。` |
|
||||
| 80+ | +`💦❗` | `桐人(😳💦❗・把臉轉開):不要再講了。` |
|
||||
|
||||
- **算出來的,不是自己挑的**:`emotionEmojiNow()` 讀當下的主導情緒,門檻跟破口共用
|
||||
同一條線(40)。`room post --emotion` 想手寫還是可以,沒手寫就用算出來的那個。
|
||||
- **逐人格可覆寫**:`IDENTITY.md` 的 `## Emoji` 區塊(`羞愧: 🫣`),跟 `## Tells` 同一層。
|
||||
- **不用重複本體表示程度**(不是 `😳😳😳`)——疊字看起來像洗頁。
|
||||
- 句子裡也可以用,**沒有數量上限**。唯一的煞車是 `emotion --audit` 的數字,
|
||||
特別是「只有符號沒有句子」那一列:符號是**加上去的**,不是用來代替講話。
|
||||
把所有符號刪掉之後,那句話還是要看得出情緒。
|
||||
|
||||
### 名字後面的括號有兩格:`名字(情緒・動作):內容`
|
||||
|
||||
括號原本就有(放情緒標註),動作是加進去的第二格。動作只寫**看得見或聽得見的**——
|
||||
臉紅、別過頭、手在抖、聲音變小。`(害羞)` 不行,那是情緒名稱,屬於前面那一格;
|
||||
`*別過頭*` 這種內嵌星號與「她臉紅了」這種第三人稱旁白也不行。
|
||||
括號裡的動作**算進上面那兩個動作的額度**。
|
||||
|
||||
## 情緒 → 記憶的關係
|
||||
|
||||
- 情緒強度(|delta| 總和)越大,該輪記憶的 `salience` 應該越高。
|
||||
|
||||
@@ -80,6 +80,24 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" candidates --session <PERSONA_S
|
||||
規則以外的東西**就讓它被遺忘**——遺忘是功能,不是缺陷。
|
||||
`Stop` hook 與每次 `remember` 都會在達標時提醒你來跑這個 skill。
|
||||
|
||||
### 判斷過的要留痕跡,不然數字永遠不會降
|
||||
|
||||
`candidates` **只看還沒被判斷過的短期記憶**(沒有 `reviewed_at` 的那些)。所以每一筆看完都要標掉:
|
||||
|
||||
| 情況 | 怎麼標 |
|
||||
| --- | --- |
|
||||
| 固化成長期記憶了 | `consolidate ... --from-short <#N,#N>`(寫記憶時一起標,另外記 `promoted_to`) |
|
||||
| 看過,決定不記 | `candidates --reviewed <#N,#N>` |
|
||||
| 這一批全部判斷完 | `candidates --reviewed all` |
|
||||
|
||||
編號就是 `candidates` 輸出裡每筆前面的 `#N`。
|
||||
|
||||
**不標會發生什麼**:固化本身不刪短期記憶(`--forget` 是按顯著度刪,不是按「固化過沒有」刪),
|
||||
所以同一批下一輪又被算成候選,提醒的數字只會往上爬——明明睡覺時固化過了,
|
||||
隔天開機照樣說「N 組已達固化條件」。那不是睡眠沒做事,是判斷沒有留下痕跡。
|
||||
|
||||
標記**不等於刪掉**:那幾筆還在短期記憶裡,照舊會被 `prune` 依天數與顯著度裁切。
|
||||
|
||||
## 步驟
|
||||
|
||||
### 1. 盤點
|
||||
@@ -112,7 +130,7 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" consolidate \
|
||||
--session <PERSONA_SESSION> \
|
||||
--name "hates-morning-meetings" --type preference \
|
||||
--about "user" --topics "work,schedule" --salience 72 --emotion "anxiety/35" \
|
||||
--rules "R2+R3" \
|
||||
--rules "R2+R3" --from-short "12,31,88" \
|
||||
--body "主旨:使用者討厭早上的會議,約會議請排 14:00 之後。
|
||||
|
||||
細節:
|
||||
@@ -224,7 +242,8 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" probe audit --session <PERSONA_
|
||||
**可以說不確定,可以問,不可以斷言。** 試探句一定帶問號、一輪最多一次,
|
||||
並用 `probe add` 記下來;對方回了就 `probe confirm`/`probe deny` 結案。
|
||||
被否認要**立刻寫一則更正記憶,不可以放著**——這條開放界線唯一的煞車就是那個數字。
|
||||
- 固化完再跑一次 `candidates`:應該要清空(或只剩你刻意不處理的)。
|
||||
- 固化完再跑一次 `candidates`:應該要清空。**還有數字就是漏標了**——
|
||||
固化的那幾筆要帶 `--from-short`,看過不記的要 `--reviewed`(見上面那張表)。
|
||||
- 想連同情緒衰減、舊紀錄壓縮與 Gitea 同步一起收尾 → 用 `/jsc-persona:persona-sleep`(睡眠);
|
||||
本 skill 只管記憶與圖,不動情緒也不同步。
|
||||
- 所有面向使用者的輸出使用**繁體中文(台灣用語)**、UTF-8 無亂碼。
|
||||
|
||||
@@ -36,14 +36,19 @@ description: 讓人格睡覺:把一天的活狀態收成能留下來的形狀
|
||||
## 情況一:當前人格自己睡
|
||||
|
||||
1. 盤點:`candidates --session <PERSONA_SESSION>`
|
||||
2. 逐則固化(判斷):`consolidate --session <id> --name <n> --body <b> --type ... --salience N [--forget]`
|
||||
3. 寫一則日記:`consolidate --type diary --name diary-<YYYY-MM-DD> --body "<第一人稱回顧>"`
|
||||
4. 消化 inbox(`memory/inbox/room-*.jsonl`,當 guest 時帶回的見聞)
|
||||
5. 更新心智圖與關係圖:`mindmap`、`relation node --name <人> --contact`
|
||||
6. 結算懸著的事(判斷):`loop list --session <id>`,今天有下文的 `loop done --id <id>`、
|
||||
2. 逐則固化(判斷):`consolidate --session <id> --name <n> --body <b> --type ... --salience N
|
||||
--from-short <#N,#N> [--forget]`
|
||||
**`--from-short` 不要省**:那是把來源短期記憶標成判斷過的地方,編號看 `candidates` 輸出的 `#N`。
|
||||
3. 看過但決定不記的,也要標掉:`candidates --session <id> --reviewed <#N,#N>`(或 `--reviewed all`)。
|
||||
**這一步漏掉,明天開機照樣提醒「N 組已達固化條件」**——判斷做了但沒有留痕跡,
|
||||
計數只看短期記憶有沒有被標記,不看你昨天固化了幾則。看起來就像睡眠沒做固化。
|
||||
4. 寫一則日記:`consolidate --type diary --name diary-<YYYY-MM-DD> --body "<第一人稱回顧>"`
|
||||
5. 消化 inbox(`memory/inbox/room-*.jsonl`,當 guest 時帶回的見聞)
|
||||
6. 更新心智圖與關係圖:`mindmap`、`relation node --name <人> --contact`
|
||||
7. 結算懸著的事(判斷):`loop list --session <id>`,今天有下文的 `loop done --id <id>`、
|
||||
已經不重要的 `loop drop --id <id>`。**這一步要自己做**:`sleep` 裡的 `sweep-loops`
|
||||
只收「7 天都沒進展」的,它分不出「今天解決了」與「一直沒人管」。
|
||||
7. 機械性收尾:
|
||||
8. 機械性收尾:
|
||||
|
||||
```bash
|
||||
node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" sleep --session <PERSONA_SESSION> --json
|
||||
|
||||
Reference in New Issue
Block a user