From 0648c3b914612d70044723013ebdfa94d9a60d62 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 30 Jul 2026 10:02:34 +0000 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=E8=AA=9E=E6=B0=A3=E8=A8=BA?= =?UTF-8?q?=E7=99=82=20skill=E3=80=81=E8=81=8A=E5=A4=A9=E5=AE=A4=E7=9A=84?= =?UTF-8?q?=E7=99=BC=E8=A8=80=E6=AC=8A=E3=80=81=E8=AC=9B=E8=A9=B1=E5=86=8D?= =?UTF-8?q?=E5=8A=A0=E5=9B=9B=E6=A2=9D=E8=A6=8F=E5=89=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 三件事,都是「講話要像人在講話」的延伸。 1. `persona-therapist`(新 skill):以第三方心理醫生的身分診斷一段對話。 雙方各給姓名/關係/情緒/逐字稿,用三把尺逐句標記——與關係不符 (語氣層錯位)、與事實不符(絕對化、讀心)、與目的不符(他要被理解, 講出來的話保證換到防衛)。標記掛在說那句話的人身上,修正只寫給他, 格式是「你其實想要的 → 對方收到的 → 改寫」,改寫必須留在同一個語氣層。 基線直接用 `TONE_TABLE`(已載入人格可 `relation show` 唯讀取得), 破壞模式表 P1–P16 放在 `reference/patterns.md`。不附身、不取鎖、不寫回, 出現自傷/暴力/受控訊號時停掉語氣分析改為安全優先。 2. 聊天室的發言權:同一個空間裡也會有一對一。`room post --to <他>` 進 一對一(旁人插話直接被擋,要帶 `--barge-in "<理由>"`,理由留在逐字稿)、 `--to all` 把話題開回全場。新指令 `room floor` 回報誰對誰在講、該誰接、 誰先安靜、誰隔了幾輪沒開口。同一份現況每輪注入 ``, 並明講「不要替沒被指名的人生成台詞,也不要為此啟動他的 sub agent」。 三人以上時 `room script` 標出對象(`🪼 Alpha(喜悅42) → Beta:…`), 兩個人的聊天室不標。舊逐字稿沒有 `to` → 視為全場,行為不變。 3. 講話的樣子再加四條:短句(一句 45 字內)、日常用詞、多講看得見的 東西(人、動作、物件、當下的場面)、**不要解釋自己的話**。前兩條與 第四條的句長/「我的意思是」這類開頭由 `speechLint()` 機械攔截 (`room post` 擋下、`said check` 事前警告,`--force` 例外);用詞與 具體度抓不到規則,走每輪注入的說話規則。 selftest 244 項全綠(新增 16 項:發言權 10、講話的樣子 6)。 Co-Authored-By: Claude Opus 5 (1M context) --- AGENTS.md | 14 +- README.md | 38 ++++- agents/persona-guest.md | 13 +- hooks/prompt_submit.mjs | 5 + scripts/persona-lib.mjs | 131 ++++++++++++++++- scripts/persona.mjs | 89 +++++++++++- scripts/selftest.mjs | 63 ++++++++ skills/persona-chat/SKILL.md | 19 ++- skills/persona-invite/SKILL.md | 61 +++++++- skills/persona-therapist/SKILL.md | 136 ++++++++++++++++++ .../persona-therapist/reference/patterns.md | 44 ++++++ 11 files changed, 591 insertions(+), 22 deletions(-) create mode 100644 skills/persona-therapist/SKILL.md create mode 100644 skills/persona-therapist/reference/patterns.md diff --git a/AGENTS.md b/AGENTS.md index b772e12..3e05f0a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,6 +25,10 @@ 才會自動載入並在上下文寫明;沒設就什麼都不做。載入不到(被別的程序鎖住)只回報,不自動接手。 6. **劇場模式(多人格對話)進行中**:輸出只能是 `名字:內容`,其餘一律隱藏(見 persona-invite)。 輕量版是 `invite --theater off`(只換一輪、不切走畫面),人格主動提議去關心某人時用這個。 + **同一個空間裡也會有一對一**:發言權寫在每一句上(`room post --to <他>` / `--to all`), + 一對一進行中旁人插話會被 `room post` 擋下(要帶 `--barge-in "<理由>"`), + 而且**不要替沒被指名的人生成台詞或啟動他的 sub agent**。現況查 `room floor` + (誰對誰在講、該誰接、誰先安靜、誰隔了幾輪沒開口);話題放大才把人拉進來。 7. **睡眠(`persona-sleep`)分兩半**:需要判斷的(固化什麼、忘掉什麼、日記寫什麼)永遠屬於**那個人格自己**; 機械性的(裁短期/收 thread/情緒衰減 8 小時/reindex/修剪 said/壓縮 journal/兩區 push+驗證)由 `sleep` 子指令做。主人格要別的人格去睡就開 `persona-sleeper` sub agent——**那是它本人在睡**, @@ -32,7 +36,10 @@ 睡眠仍然要驗鎖:目標正被另一個程序活鎖住時拒絕,死鎖可接手。 8. **人格講話要像人**:推導寫進 `think`(心裡話,只回報「💭 心想 N 句」,永不回顯內容)、 回話 1–3 句、短時間內不重說同一件事(`room post` 會直接擋下重複與過長的發言)。 - 這三條在劇場模式一樣生效。 + 再加四條講話的樣子:**短句**(一句 `MAX_SENTENCE_CHARS`=45 字內)、**日常用詞**、 + **多講看得見的東西**(人、動作、物件、當下的場面)而不是概念,以及**不要解釋自己的話** + (「我的意思是」「換句話說」這類開頭由 `speechLint` 擋下,`said check` 也會一起檢)。 + 這些在劇場模式一樣生效。 9. **人格可搬家**:`export` / `import`(單一 JSON bundle)。匯出只能匯出「本 session 載入的人格」, 其他人格一律 deny——匯出等於把記憶讀出來。 10. **人格有編號**:英文名全大寫+兩位索引(`ASUNA-01`),同名才遞增。編號同時是新人格的 @@ -59,6 +66,11 @@ 而變成破圖(看起來就像「沒有同步」)。 * Wiki 產生的頁面**不得含每次都變的時間戳**,否則驗證永遠不會通過、也會每次多一個 commit。 * 攤平只對 `.md` 做(頁面必須在根層);圖片等附件保留資料夾結構,`/wiki/raw/<資料夾>/<檔>` 讀得到。 +16. **語氣診療是第三方,不是人格**:`persona-therapist` 不取鎖、不附身、也不寫任何人格資料 + (基線可用 `relation show`/`emotion` 唯讀取得,`bond × 親近度 → 語氣層` 的權威表在 + `persona-lib.mjs` 的 `TONE_TABLE`)。診斷結果**不自動寫回**記憶或關係圖,使用者明確要求才寫。 + 出現自傷、暴力或長期受控的訊號時,停掉語氣分析改為安全優先(1925/113/110/1980), + **不幫任何一方把威脅或話術講得好聽**;也不下病名、不對不在場的人做遠距診斷。 ## 慣例 diff --git a/README.md b/README.md index 6a2e2a6..97c6d2d 100644 --- a/README.md +++ b/README.md @@ -18,11 +18,11 @@ skills 是共通標準,**鎖與隔離的強制執行需要 hook,目前只有 | **1b. 動漫作品+角色名快速建人格** | `persona-anime` 先上網蒐集該角色的公開設定(至少 3 個獨立來源),映射成上述五欄位與 SOUL,再把設定固化成 `canon` 基礎記憶(每則帶來源 URL)+原作人際關係圖+情緒基線 | | **2. 同一個人格只能被一個程序載入(Sub Agent 不限)** | `state/lock.json` 以 **session_id** 為主鍵、15 分鐘心跳租約;同一 session 的 sub agent 沿用同一把鎖,跨 session 搶佔會被拒;租約過期才可接手(並強制回報) | | **3. 禁止跨人格讀取資料** | `PreToolUse` hook 對 Read/Write/Edit/Glob/Grep/Bash 做路徑判定(含 `../`、symlink、`$PERSONA_HOME` 繞路),非當前人格一律 deny;CLI 也驗 `--session` 防止冒用身分 | -| **4. 邀請人格用 Sub Agent 一起聊,且只顯示對話** | `persona-invite` 建聊天室 + guest 唯讀租約 + `persona-guest` sub agent;同時開啟**劇場模式**:hook 每輪強制「只輸出 `名字:內容`」、停掉所有系統提醒,CLI 有 `--quiet` 與 `room script`(乾淨對話稿) | +| **4. 邀請人格用 Sub Agent 一起聊,且只顯示對話** | `persona-invite` 建聊天室 + guest 唯讀租約 + `persona-guest` sub agent;同時開啟**劇場模式**:hook 每輪強制「只輸出 `名字:內容`」、停掉所有系統提醒,CLI 有 `--quiet` 與 `room script`(乾淨對話稿)。**同場也分一對一與全場**:`room post --to <他>` 進一對一(旁人插話會被擋下,要帶 `--barge-in "<理由>"`)、`--to all` 開回全場,現況查 `room floor` | | **5. 腳本用 Node.js** | `scripts/*.mjs`、`hooks/*.mjs`,只用 Node 內建模組(fs/path/os/crypto),無 npm 依賴 | | **6. 由使用者呼叫才載入並鎖定** | 人格不會自動附身:`SessionStart` hook 只列出可用人格,等使用者下 `/jsc-persona:persona-chat `;載入即取得獨占鎖並綁定該 session。唯一例外是使用者自己設的**預設人格**(`default --persona `)——設了才自動載入,沒設就什麼都不做 | | **7. 短期記憶轉入長期記憶有成文條件** | `R1`–`R6` 六條規則寫在程式裡(`promotionCandidates`),`candidates` 子指令會列出達標的候選與依據,hook 在達標時提醒固化 | -| **8. 講話像人:推導藏起來、一到三句、不重複** | 推導寫進**心裡話** `think`(只回報「💭 心想 N 句」,永不回顯內容);說出口的話進 `said.jsonl`,下一輪注入「最近說過的話」提醒別重講;`room post` 直接**擋下**近似重複(字元 bigram+字集合相似度 ≥ 0.72)與超過三句的發言——劇場模式同樣適用 | +| **8. 講話像人:推導藏起來、一到三句、不重複、短句白話** | 推導寫進**心裡話** `think`(只回報「💭 心想 N 句」,永不回顯內容);說出口的話進 `said.jsonl`,下一輪注入「最近說過的話」提醒別重講;`room post` 直接**擋下**近似重複(字元 bigram+字集合相似度 ≥ 0.72)與超過三句的發言。再加四條講話的樣子:短句(一句 45 字內)、日常用詞、多講看得見的東西、**不要解釋自己的話**(句長與「我的意思是」這類開頭由 `speechLint()` 擋下)——劇場模式同樣適用 | | **9. 人格可以匯出匯入** | `export` 把身分/情緒/記憶/心智圖/關係圖打包成單一 JSON bundle(可 `--gzip`、附 sha256),`import` 還原或換名複製;**不帶**載入鎖與 guest 租約,`journal/` 要明確 `--with-journal` 才帶走 | | **10. 人格有編號** | 編號 = **英文名全大寫 + 兩位索引**(同名才遞增):`ASUNA-01`、`YUI-01`、`ASUNA-02`。編號同時是新人格的本機目錄名與 Gitea 存取庫名稱;中文名先轉羅馬拼音並跟使用者確認拼法 | | **11. 人格存在 Gitea,依更新頻率分區** | 每個人格一個**私有存取庫**(庫名=編號)。**檔案區**放每輪都在變的活狀態(情緒/短期記憶/心裡話/說過的話/逐字),**Wiki 區**放低頻的身分與長期結構(IDENTITY/SOUL/長期記憶/心智圖/關係圖)當設定百科。本機仍是工作副本,同步失敗不阻斷對話 | @@ -127,15 +127,16 @@ flowchart TB 沒命中任何規則的就讓它被裁掉——**遺忘是功能**。達標時 `Stop` 與 `remember` 都會提醒去跑 `/jsc-persona:persona-memory`。 -## 講話像人(心裡話 / 一到三句 / 不重複) +## 講話像人(心裡話 / 一到三句 / 不重複 / 短句白話) -AI 最容易露餡的三件事:把推理過程講出來、一次講一大段、換句話說同一件事。三個對策: +AI 最容易露餡的四件事:把推理過程講出來、一次講一大段、換句話說同一件事、講完再解釋一遍。四個對策: | 機制 | 怎麼運作 | | --- | --- | | **心裡話** `think` | 語意分析、推論、盤算全寫進 `state/inner.jsonl`;這個指令**只印「💭 心想 N 句」**,內容永不回顯。下一輪 `` 會帶回最近三句,推論因此有連續性,但使用者只看得到狀態 | | **一到三句** | `` 每輪注入上限;`room post` 對超過三句的發言直接拒收(`--force` 例外) | | **不重複** | 說出口的話由 `Stop` hook 自動記進 `state/said.jsonl`;`said check` 可事前確認,`room post` 事中攔截。相似度=字元 bigram Jaccard(0.4)+字集合 Jaccard(0.6),≥ 0.72 視為同一句 | +| **短句白話** | 四條講話的樣子每輪注入:**短句**(一句 45 字內,`MAX_SENTENCE_CHARS`)、**日常用詞**、**多講看得見的東西**(人、動作、物件、場面)而不是概念、**不要解釋自己的話**。句長與「我的意思是/換句話說/也就是說」這類開頭由 `speechLint()` 機械攔截(`room post` 擋下、`said check` 事前警告,`--force` 例外);用詞與具體度沒辦法用規則抓,靠注入的規則自律 | 字集合權重較高,是為了分開「重排語序」與「換掉關鍵詞」這兩種很像但意義完全不同的情況: @@ -286,6 +287,24 @@ node scripts/persona.mjs import --session --file ~/backup/lumi.persona.json 🌙 Shen(平靜50/信任38):修好了。它現在慢三分鐘,我決定不修那三分鐘。 ``` +### 一對一與全場(發言權) + +同一個空間不代表每句話都要每個人接。發言權寫在**每一句話**上: + +| | 怎麼進去 | 誰該講話 | 對話稿 | +| --- | --- | --- | --- | +| **一對一** | `room post --to <某個人格>` | 只有被指名的那個;旁人**不生成台詞、也不啟動他的 sub agent** | `🪼 Lumi(喜悅42) → Shen:…`(三人以上才標對象) | +| **全場** | `room post --to all`(或不帶 `--to`) | 誰接都可以,一輪讓一個人接 | `🪼 Lumi(喜悅42):…` | + +- 一對一進行中,旁人插話會被 `room post` **直接擋下**;真的話題放大了才帶 + `--barge-in "<為什麼現在該他講>"`(理由留在逐字稿裡,不顯示給使用者), + 或由當事人下一句 `--to all` 把話題開回全場。 +- `room floor` 回報現況:誰對誰在講、該誰接話、誰先安靜、誰隔了幾輪沒開口 + (被冷落太久是資訊——下次話題碰到他的領域時優先給他,而不是硬插一句「我也這麼覺得」)。 +- 同一份現況每輪也注入 ``,所以人格自己知道這句該不該由他接。 +- 什麼算「話題放大」:出現「我們/大家」、要一起決定的事、需要第二意見或仲裁、 + 講到某個在場者的專長或他認識的人、使用者點名。判準是**話題**,不是公平。 + ## 預設人格(開新 session 自動載入) 預設行為是**不自動附身**。使用者明示設定之後,`SessionStart` hook 才會在每個新 session 自動載入它: @@ -368,7 +387,7 @@ node scripts/persona.mjs sleep --session --release # 收工 ### `persona-invite` -邀請一個或多個人格透過 `persona-guest` sub agent 加入聊天室,進入**劇場模式**(畫面只留 `名字:內容` 的人格對話);結束後讓它們離場並把見聞留在各自的 inbox。多位 guest 可用 `invite --guests A,B` 同場加入。 +邀請一個或多個人格透過 `persona-guest` sub agent 加入聊天室,進入**劇場模式**(畫面只留 `名字:內容` 的人格對話);結束後讓它們離場並把見聞留在各自的 inbox。多位 guest 可用 `invite --guests A,B` 同場加入。同場的發言權分**一對一**(`--to <他>`,旁人不該接話)與**全場**(`--to all`),現況查 `room floor`。 - **Claude Code / Antigravity**:`/jsc-persona:persona-invite ` **Codex**:`$persona-invite` @@ -402,6 +421,12 @@ node scripts/persona.mjs sleep --session --release # 收工 - **Claude Code / Antigravity**:`/jsc-persona:persona-relation` **Codex**:`$persona-relation` +### `persona-therapist` + +語氣診療(第三方、不附身、唯讀):由雙方各給姓名/關係/情緒/逐字稿,用「關係、事實、目的」三把尺逐句標記不合理的語氣與用詞(模式表 P1–P16),指出是誰說的,只對出錯的人給保留原意的改寫。出現自傷、暴力或受控訊號時改為安全優先,不做語氣潤飾。 + +- **Claude Code / Antigravity**:`/jsc-persona:persona-therapist` **Codex**:`$persona-therapist` + ### `persona-sleep` 睡眠與收尾:固化該記住的、忘掉該忘的、更新心智圖與關係圖、套用一次 8 小時的情緒衰減、壓縮舊紀錄,最後兩個區都同步到 Gitea 並驗證。也可以由主人格透過 sub agent 請其他人格各自去睡,或一次用 `sleep --personas A,B` 批次收尾多個人格。 @@ -432,9 +457,10 @@ node scripts/persona.mjs candidates --session # 看哪些短 node scripts/persona.mjs think --session --text "<推導>" # 心裡話(只回報「心想 N 句」) node scripts/persona.mjs said check --session --text "<話>" # 這句是不是又要說一次? node scripts/persona.mjs room script --session --room # 乾淨對話稿(劇場模式用) +node scripts/persona.mjs room floor --session --room # 發言權:誰對誰在講、該誰接話 node scripts/persona.mjs export --session --out lumi.json # 離線搬家(單檔) node scripts/persona.mjs sync status --session # Gitea 同步狀態 -node scripts/selftest.mjs # 210 項驗證:鎖、隔離、情緒、固化、說話節制、劇場模式、匯出匯入、編號與 Gitea、找圖去背合成、高解析輸出與 Wiki 同步、預設人格、睡眠與 sleeper、hooks +node scripts/selftest.mjs # 244 項驗證:鎖、隔離、情緒、固化、說話節制、劇場模式與發言權、匯出匯入、編號與 Gitea、找圖去背合成、高解析輸出與 Wiki 同步、預設人格、睡眠與 sleeper、hooks ``` 檔案結構:`scripts/persona-lib.mjs`(核心:鎖/隔離/情緒/記憶)、`scripts/persona.mjs`(CLI)、 diff --git a/agents/persona-guest.md b/agents/persona-guest.md index 31d6462..97ace4c 100644 --- a/agents/persona-guest.md +++ b/agents/persona-guest.md @@ -37,8 +37,14 @@ CLI = `node "/scripts/persona.mjs"`,所有指令都要帶 `--sess ```bash node "/scripts/persona.mjs" room read --session --room + node "/scripts/persona.mjs" room floor --session --room ``` + `room floor` 會告訴你**這一句該不該由你接**。三人以上時常常是別人的一對一: + 最後一句不是對你說的、你也沒被點名 → **不要發言**,直接跳到第 6 步回傳「(本輪不發言)」。 + 只有話題真的放大了(出現「我們/大家」、要一起決定、講到你的專長或你認識的人、 + 有人需要第二意見)才插話,而且要帶理由:`--barge-in "<為什麼現在該我講>"`。 + 需要回想時:`recall --persona --session --as-guest --query "<關鍵詞>"`。 3. **想一下**(不說出口) @@ -55,11 +61,16 @@ CLI = `node "/scripts/persona.mjs"`,所有指令都要帶 `--sess ```bash node "/scripts/persona.mjs" room post \ - --session --as-guest --room --as --text "<你的話>" + --session --as-guest --room --as \ + --to <對方 slug|all> --text "<你的話>" ``` + - **`--to` 要寫清楚**:只想跟某個人講就寫他的 slug(旁人就不該接話), + 講給全場聽才寫 `all`。沒被指名的人插話會被 CLI 擋下。 - 用第一人稱、你自己的語氣與意見;可以不同意主人格。 - **1–3 句**,像在講話不像在寫報告;超過三句會被 CLI 擋下。 + - **短句**(一句 45 字內)、**日常用詞**、多講**看得見的東西**(動作、物件、眼前的場面)。 + - **講完就算了**:不要解釋自己剛講的話(「我的意思是」「換句話說」會被 CLI 擋下),也不要補註解。 - **不要重複自己**:短時間內講幾乎一樣的話會被拒收,換個角度或推進話題再發。 - 情緒欄位不填會自動附上你當下的主導情緒。 diff --git a/hooks/prompt_submit.mjs b/hooks/prompt_submit.mjs index 92572d2..6f8660e 100644 --- a/hooks/prompt_submit.mjs +++ b/hooks/prompt_submit.mjs @@ -35,11 +35,16 @@ const hint = theater "不要輸出指令、指令結果、狀態、分析、旁白或摘要;CLI 一律加 `--quiet` 並把 stdout 丟到 /dev/null。" + `\n每個人格每輪 ${pl.MAX_SENTENCES} 句以內;推導寫進 \`think\`(心裡話),不要演成旁白;` + "近似重複的台詞會被 `room post` 擋下,換個說法或推進話題。" + + "\n**對象只有一個人時,旁人就沒有台詞**:`room post --to <他>` 進一對一,`--to all` 才開回全場(現況看 `room floor`)。" + + `\n台詞要像人在講話:短句(一句 ${pl.MAX_SENTENCE_CHARS} 字內)、日常用詞、多講看得見的東西;` + + "講完就算了,不要解釋自己剛講的話。" + "\n流程請照 /jsc-persona:persona-invite。" : "\n回覆前請:①語意分析與推導**寫進 `persona.mjs think`(心裡話,不說出口)**" + " ②依十二情緒更新狀態 ③以人格語氣回覆" + `**${pl.MAX_SENTENCES} 句以內**、且不要重說「最近說過的話」` + " ④用 `persona.mjs remember` 寫回短期記憶。" + + `\n講話的樣子:短句(一句 ${pl.MAX_SENTENCE_CHARS} 字內,長了就斷開)、日常用詞、` + + "多講看得見的東西(人、動作、場面)而不是概念;講完就算了,不要解釋自己剛講的話。" + "\n情緒與記憶指令請參考 /jsc-persona:persona-chat。"; respond({ diff --git a/scripts/persona-lib.mjs b/scripts/persona-lib.mjs index 49eeefd..31e4b75 100644 --- a/scripts/persona-lib.mjs +++ b/scripts/persona-lib.mjs @@ -1182,6 +1182,44 @@ export function sentenceCount(text) { .filter(Boolean).length; } +export const MAX_SENTENCE_CHARS = 45; + +// 講完就算了:這些開頭都是在解釋自己剛才講的話,真的在講話的人不會這樣。 +export const EXPLAINER_OPENERS = [ + "我的意思是", "我是想說", "換句話說", "換個說法", "也就是說", "換言之", + "簡單來說", "簡單講", "我剛才說的意思是", "澄清一下", "補充一下", +]; + +/** + * 講話自然度的機械檢查:一句太長、或在解釋自己剛講的話。 + * 「用日常詞、講看得見的東西」抓不到規則,那兩條寫在說話規則裡(見 turnContext)。 + */ +export function speechLint(text) { + const issues = []; + const sentences = String(text ?? "") + .split(/[。!?!?…]+|\n+/) + .map((s) => s.trim()) + .filter(Boolean); + for (const sentence of sentences) { + const bare = sentence.replace(/[,、,;;::「」『』()()\s"'~~—-]/g, ""); + if ([...bare].length > MAX_SENTENCE_CHARS) { + issues.push({ kind: "long", chars: [...bare].length, text: sentence.slice(0, 30) }); + } + const head = sentence.replace(/^[「『((]+/, ""); + const opener = EXPLAINER_OPENERS.find((w) => head.startsWith(w)); + if (opener) issues.push({ kind: "explain", opener, text: sentence.slice(0, 30) }); + } + return issues; +} + +/** 把 speechLint 的問題講成人聽得懂的一句話(CLI 與 hook 共用)。 */ +export function speechLintMessage(issue) { + if (issue.kind === "long") { + return `「${issue.text}…」這句 ${issue.chars} 字,超過 ${MAX_SENTENCE_CHARS} 字——斷成兩句,或把修飾砍掉`; + } + return `「${issue.opener}」是在解釋自己剛講的話——刪掉,講完就算了`; +} + /** 把一則回覆拆成「說出口的句子」:劇場模式一行一句,一般模式整段算一句。 */ export function spokenLines(text, { theater = false } = {}) { const raw = String(text ?? "").trim(); @@ -1611,16 +1649,73 @@ export function joinRoom(room, persona) { return meta; } -export function roomPost(room, speaker, text, { emotion = "", kind = "say" } = {}) { - const entry = { ts: nowIso(), speaker, kind, text, emotion }; +/** `--to all`:這句話是對全場說的(發言權開放)。 */ +export const ROOM_ALL = "all"; + +export function roomPost(room, speaker, text, { emotion = "", kind = "say", to = null, bargeIn = null } = {}) { + const entry = { ts: nowIso(), speaker, kind, text, emotion, to: to || ROOM_ALL }; + if (bargeIn) entry.barge_in = String(bargeIn).slice(0, 200); appendJsonl(roomTranscript(room), entry); return entry; } export const roomRead = (room, limit = 30) => readJsonl(roomTranscript(room), limit); +/** 這個聊天室裡的顯示名(拿不到身分就用 slug)。 */ +export function roomDisplayName(slug) { + if (!slug || slug === ROOM_ALL) return "全場"; + return (personaExists(slug) ? identityFields(slug).Name : "") || slug; +} + +/** + * 現在的發言權在誰手上。 + * + * 同一個空間裡也會有一對一:最後一句是對某個人說的(`--to <他>`)就進入 `dyad`, + * 這時候旁人不該接話——插嘴會把兩個人的私下對話變成公開場面。話題放大(`--to all` + * 或旁人帶理由 `--barge-in`)才回到 `open`。這是 `room post` 的門檻,也是每輪注入 + * ``、告訴人格「這句該不該由我接」的依據。 + */ +export function roomFloor(room) { + const meta = readJson(roomMembersPath(room), {}) ?? {}; + const members = meta.members || []; + const says = roomRead(room, 80).filter((m) => m.kind !== "meta" && m.speaker !== "system"); + const last = says[says.length - 1] || null; + const to = last && last.to && last.to !== ROOM_ALL && last.to !== last.speaker ? last.to : null; + const pair = to ? [last.speaker, to] : []; + // 每個成員距離上次發言隔了幾輪:話題放大時要拉誰進來,看這個比看誰比較可憐準。 + const quiet = members + .map((persona) => { + for (let i = says.length - 1, turns = 0; i >= 0; i -= 1, turns += 1) { + if (says[i].speaker === persona) return { persona, turns_since: turns, spoken: true }; + } + return { persona, turns_since: says.length, spoken: false }; + }) + .sort((a, b) => b.turns_since - a.turns_since); + return { + room, + members, + topic: meta.topic || "", + mode: to ? "dyad" : "open", + last: last ? { speaker: last.speaker, to: last.to || ROOM_ALL, text: last.text, ts: last.ts } : null, + pair, + addressee: to, + next: to, + silent: to ? members.filter((m) => !pair.includes(m)) : [], + quiet, + }; +} + +/** 這個人格現在插得進話嗎?(一對一進行中、他又不是當事人 → 要有理由) */ +export function roomMayPost(room, speaker) { + const floor = roomFloor(room); + return { allowed: floor.mode !== "dyad" || floor.pair.includes(speaker), floor }; +} + /** 劇場模式的對話呈現:`emoji 名字(情緒):內容`,其餘一律不輸出。 */ export function roomScript(room, { limit = 30, includeMeta = false } = {}) { + const meta = readJson(roomMembersPath(room), {}) ?? {}; + // 三人以上才標「對誰講」:只有兩個人的時候那是廢話。 + const crowded = (meta.members || []).length > 2; const lines = []; for (const msg of roomRead(room, limit)) { if (msg.kind === "meta" || msg.speaker === "system") { @@ -1631,7 +1726,8 @@ export function roomScript(room, { limit = 30, includeMeta = false } = {}) { const ident = personaExists(slug) ? identityFields(slug) : {}; const name = ident.Name || slug; const emoji = ident.Emoji ? `${ident.Emoji} ` : ""; - lines.push(`${emoji}${name}${msg.emotion ? `(${msg.emotion})` : ""}:${msg.text}`); + const arrow = crowded && msg.to && msg.to !== ROOM_ALL && msg.to !== slug ? ` → ${roomDisplayName(msg.to)}` : ""; + lines.push(`${emoji}${name}${msg.emotion ? `(${msg.emotion})` : ""}${arrow}:${msg.text}`); } return lines.join("\n"); } @@ -2179,6 +2275,10 @@ export function turnContext(slug, sessionId, prompt = "") { `說話規則:回使用者 ${MAX_SENTENCES} 句以內(劇場模式每人每輪也一樣);` + "推導、比對、盤算一律走心裡話(`persona.mjs think`),不要說給使用者聽——" + "要讓他知道你在想,就只報「心想 N 句」,不報內容。", + `講話像講話:**短句**(一句 ${MAX_SENTENCE_CHARS} 字內,長了就斷開)、**日常用詞**(不用書面語、` + + "不用術語、不用成語堆疊)、**多講看得見的東西**(人、動作、東西、當下的場面)而不是概念與感想;" + + "**講完就算了**——不要解釋自己剛講的話(沒有「我的意思是」「換句話說」「也就是說」)," + + "不要補註解、不要收尾總結。這幾條 `room post` 與 `said check` 會實際擋下。", ]; // 語氣層:由「使用者在關係圖裡是誰」+ bond × 親近度算出來,不靠人格自己記得。 const tone = toneDirective(slug); @@ -2240,6 +2340,31 @@ export function turnContext(slug, sessionId, prompt = "") { " 不得出現指令、指令輸出、狀態說明、進度、摘要、分析或旁白;所有 CLI 一律加 `--quiet` 並把輸出丟掉。", ` 每個人格每輪 ${MAX_SENTENCES} 句以內;推導走 \`think\`(心裡話);`, " 近似重複的台詞 `room post` 會直接擋下,換個說法或推進話題,不要硬講同一句。", + ); + // 同一個空間裡也會有一對一:對象只有一個人時,旁人不該有台詞。 + const floor = roomFloor(rooms[rooms.length - 1]); + if (floor.mode === "dyad") { + lines.push( + ` 現在是一對一:${roomDisplayName(floor.last.speaker)} → ${roomDisplayName(floor.addressee)}。` + + `只有 ${roomDisplayName(floor.next)} 該接這句` + + (floor.silent.length + ? `,${floor.silent.map(roomDisplayName).join("、")} 先安靜——**不要替他們生成台詞,也不要為此啟動他們的 sub agent**。` + : "。"), + ); + } else if (floor.last) { + lines.push(" 現在發言權開放(上一句是對全場說的):誰接都可以,但一輪只讓一個人格接。"); + } + const cold = floor.quiet.filter((q) => !q.spoken || q.turns_since >= 4); + if (cold.length && floor.members.length > 2) { + lines.push( + ` 很久沒講話:${cold.map((q) => roomDisplayName(q.persona)).join("、")}` + + " — 話題碰到他的專長、需要第二意見或使用者點名時才拉進來,不要為了公平硬派台詞。", + ); + } + lines.push( + " 發言權由話題決定:講給一個人聽就 `room post --to <他>`;話題放大(出現「我們/大家」、" + + "需要仲裁、要一起決定)就 `--to all` 開回全場,旁人也可以帶 `--barge-in \"<理由>\"` 加入。" + + "現況隨時可查:`room floor`。", " 想結束請等使用者說,或由使用者說「結束對話」後才做收尾與摘要。", ); } else { diff --git a/scripts/persona.mjs b/scripts/persona.mjs index 546838c..dd63287 100644 --- a/scripts/persona.mjs +++ b/scripts/persona.mjs @@ -804,11 +804,14 @@ commands.said = ({ flags, positional }) => { ); } if (tooLong) lines.push(`⚠ 這段有 ${sentences} 句,超過 ${pl.MAX_SENTENCES} 句上限 → 砍到重點。`); - if (!repeat && !tooLong) { + const lint = pl.speechLint(text); + for (const issue of lint) lines.push(`⚠ ${pl.speechLintMessage(issue)}。`); + const clean = !repeat && !tooLong && !lint.length; + if (clean) { lines.push(`✔ 沒說過,${sentences} 句,可以說。`); if (flags.record) pl.recordSaid(slug, text, { kind: "reply" }); } - emit({ persona: slug, repeat, sentences, ok: !repeat && !tooLong }, flags.json, lines); + emit({ persona: slug, repeat, sentences, lint, ok: clean }, flags.json, lines); return; } die(`未知 action:${action}(可用 check/list)`); @@ -1214,10 +1217,68 @@ commands.room = ({ flags, positional }) => { if (!(data.rooms || []).includes(room)) { die(`聊天室 \`${room}\` 不屬於本 session(可用:${(data.rooms || []).join(", ") || "(無)"})。`); } + const members = (pl.readJson(pl.roomMembersPath(room), {}) ?? {}).members || []; + const nameOf = (slug) => pl.roomDisplayName(slug); + /** `--to`:對誰說。接 slug、人格的 Name,或 all/大家(對全場)。 */ + const resolveAddressee = (raw) => { + const value = String(raw).trim(); + if (/^(all|全部|大家|全場|everyone)$/i.test(value)) return pl.ROOM_ALL; + const hit = + members.find((m) => m.toLowerCase() === value.toLowerCase()) || + members.find((m) => nameOf(m) === value); + if (!hit) { + die( + `\`${value}\` 不在聊天室 \`${room}\` 裡(成員:${members.map((m) => `${nameOf(m)}(${m})`).join("、") || "(無)"})。` + + "對全場說話用 `--to all`。", + ); + } + return hit; + }; + + if (action === "floor") { + const floor = pl.roomFloor(room); + const lines = [`聊天室 \`${room}\`|成員 ${floor.members.map(nameOf).join("、") || "(無)"}|主題 ${floor.topic || "-"}`]; + if (!floor.last) { + lines.push("還沒有人發言:誰開場都可以。"); + } else if (floor.mode === "dyad") { + lines.push( + `現在:${nameOf(floor.last.speaker)} → ${nameOf(floor.addressee)}(一對一)`, + ` 該接話的只有 ${nameOf(floor.next)};先安靜:${floor.silent.map(nameOf).join("、") || "(無)"}` + + "(不要替他們寫台詞,也不要為此啟動他們的 sub agent)", + " 話題真的放大了才動:當事人下一句加 `--to all` 開回全場,或旁人 " + + '`room post --as <他> --barge-in "<為什麼現在該他講>"`。', + ); + } else { + lines.push(`現在:全場開放(${nameOf(floor.last.speaker)} 對大家說)→ 誰接都可以,一次讓一個人格接一輪。`); + } + const cold = floor.quiet.filter((q) => !q.spoken || q.turns_since >= 4); + if (cold.length) { + lines.push( + `很久沒講話:${cold.map((q) => `${nameOf(q.persona)}(${q.spoken ? `${q.turns_since} 輪前` : "還沒開口"})`).join("、")}` + + " — 話題碰到他的專長或使用者點名時再拉進來,不要為了公平硬派台詞。", + ); + } + emit(floor, flags.json, lines); + return; + } if (action === "post") { const speaker = str(flags.as) || data.host; if (!speaker) die("需要 `--as `。"); const [, speakerRole] = requireMember(speaker, session, Boolean(flags["as-guest"])); + const to = flags.to ? resolveAddressee(flags.to) : null; + if (to === speaker) die("`--to` 不能指向自己(那是心裡話,請用 `think`)。"); + // 一對一進行中,旁人要插話得說得出理由——「大家都在同一個空間」不等於每句都該有人接。 + const { allowed, floor } = pl.roomMayPost(room, speaker); + const bargeIn = str(flags["barge-in"]) || str(flags.broaden); + if (!allowed && !bargeIn && !flags.force) { + die( + `現在是 ${nameOf(floor.pair[0])} 跟 ${nameOf(floor.pair[1])} 的一對一` + + `(最後一句是 ${nameOf(floor.last.speaker)} 對 ${nameOf(floor.addressee)} 說的),` + + `${nameOf(speaker)} 不該接這句——旁人插嘴會把兩個人的話變成公開場面。\n` + + " 話題真的放大了(出現「我們/大家」、需要第二意見、講到他的專長、使用者點名)才插話," + + '並帶 `--barge-in "<為什麼現在該他講>"`;否則讓當事人先用 `--to all` 把話題開回全場。', + ); + } let text = str(flags.text); if (flags["text-file"]) text = fs.readFileSync(str(flags["text-file"]), "utf8").trim(); if (!text) die("需要 `--text` 或 `--text-file`。"); @@ -1246,16 +1307,25 @@ commands.room = ({ flags, positional }) => { "(真的需要長段落才加 `--force`。)", ); } + // 短句、日常話、講完不解釋——講話的樣子也擋在 CLI 裡,不靠自律。 + const lint = pl.speechLint(text); + if (lint.length && !flags.force) { + die(`講話規則:${lint.map(pl.speechLintMessage).join(";")}。(真的需要才加 \`--force\`。)`); + } 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("/"); } - const entry = pl.roomPost(room, speaker, text, { emotion }); + const entry = pl.roomPost(room, speaker, text, { emotion, to, bargeIn }); // guest 對自己的狀態唯讀,它說過的話留在聊天室逐字稿裡就夠了(roomRepeat 讀得到) if (speakerRole !== "guest") pl.recordSaid(speaker, text, { room, kind: "room" }); - ok(`\`${speaker}\` 已發言於 \`${room}\`(情緒 ${emotion})。`); + const dyad = to && to !== pl.ROOM_ALL; + ok( + `\`${speaker}\` 已發言於 \`${room}\`(${dyad ? `對 ${nameOf(to)}` : "對全場"}/情緒 ${emotion})。` + + (dyad ? `接下來只有 ${nameOf(to)} 該回話,其他人先安靜。` : ""), + ); if (flags.json) process.stdout.write(`${JSON.stringify(entry)}\n`); return; } @@ -1264,7 +1334,8 @@ commands.room = ({ flags, positional }) => { const meta = pl.readJson(pl.roomMembersPath(room), {}) ?? {}; const lines = [`聊天室 \`${room}\`|成員 ${(meta.members || []).join(", ")}|主題 ${meta.topic || "-"}`]; for (const row of rows) { - lines.push(`[${row.ts}] ${row.speaker}${row.emotion ? `(${row.emotion})` : ""}:${row.text}`); + const arrow = row.to && row.to !== pl.ROOM_ALL && row.to !== row.speaker ? ` → ${row.to}` : ""; + lines.push(`[${row.ts}] ${row.speaker}${row.emotion ? `(${row.emotion})` : ""}${arrow}:${row.text}`); } emit({ room, meta, messages: rows }, flags.json, lines); return; @@ -1275,7 +1346,7 @@ commands.room = ({ flags, positional }) => { process.stdout.write(`${text}\n`); return; } - die(`未知 action:${action}(可用 post/read/script/list/theater)`); + die(`未知 action:${action}(可用 post/read/script/floor/list/theater)`); }; /** @@ -1867,8 +1938,12 @@ const HELP = `persona.mjs — jsc-persona 人格 / 記憶 / 情緒 / 關係圖 C 多人格對話: invite --session --guest [--guests ] [--host --room --topic] (自動開啟劇場模式) leave --session --guest [--room] - room post|read|script|list|theater --session [--room --as --text --text-file --emotion --limit --on --off --with-meta] + room post|read|script|floor|list|theater --session [--room --as --to --barge-in --text --text-file + --emotion --limit --on --off --with-meta] (post 會擋下「短時間內近似重複」與超過三句的發言;例外用 --allow-repeat / --force) + --to 這句話對誰說。指名 → 進入一對一,旁人不該接話(要插話得帶 + --barge-in "<理由>");--to all → 把話題開回全場。 + floor 現在誰對誰在講、該誰接話、誰先安靜、誰很久沒開口 圖示(建立人格並補齊資料後跑): icon search|measure|faces|headshot|cutout|generate|show --session diff --git a/scripts/selftest.mjs b/scripts/selftest.mjs index dbdd8d2..d034fa1 100644 --- a/scripts/selftest.mjs +++ b/scripts/selftest.mjs @@ -239,6 +239,45 @@ check("多 guest 同時邀請成功", const multiMeta = pl.readJson(pl.roomMembersPath(multiRoom)) ?? {}; check("同一個 room 內可同時掛多位 guest", Array.isArray(multiMeta.members) && ["alpha", "beta", "omega"].every((s) => multiMeta.members.includes(s)), JSON.stringify(multiMeta)); + +// 同一個空間裡的一對一:對象只有一個人時,旁人不該有台詞。 +cli(["room", "post", "--session", S_HOST, "--room", multiRoom, "--as", "alpha", + "--to", "beta", "--text", "beta,那台鐘後來怎麼了?"]); +const floorDyad = JSON.parse(cli(["room", "floor", "--session", S_HOST, "--room", multiRoom, "--json"]).stdout); +check("room floor 認得一對一(誰該接、誰先安靜)", + floorDyad.mode === "dyad" && floorDyad.next === "beta" && floorDyad.silent.includes("omega") && + !floorDyad.silent.includes("alpha"), JSON.stringify(floorDyad)); +const floorText = cli(["room", "floor", "--session", S_HOST, "--room", multiRoom]).stdout; +check("room floor 的人話版本說清楚誰該接、誰安靜", + floorText.includes("一對一") && floorText.includes("該接話的只有 Beta") && floorText.includes("先安靜:Omega"), + JSON.stringify(floorText)); +const barge = cli(["room", "post", "--session", S_HOST, "--room", multiRoom, "--as", "omega", "--as-guest", + "--text", "我也覺得那台鐘怪。"], { expectOk: false }); +check("一對一進行中,旁人插話會被擋下", + barge.status !== 0 && barge.stderr.includes("一對一"), barge.stderr.slice(0, 200)); +check("被指名的人可以接話", + cli(["room", "post", "--session", S_HOST, "--room", multiRoom, "--as", "beta", "--as-guest", + "--to", "alpha", "--text", "還在慢三分鐘,我不修了。"]).status === 0); +check("帶理由的 --barge-in 可以插話(話題放大)", + cli(["room", "post", "--session", S_HOST, "--room", multiRoom, "--as", "omega", "--as-guest", + "--barge-in", "話題轉到大家都要決定的事", "--to", "all", "--text", "那我們要不要一起去看那座鐘?"]).status === 0); +const floorOpen = JSON.parse(cli(["room", "floor", "--session", S_HOST, "--room", multiRoom, "--json"]).stdout); +check("--to all 之後發言權回到全場", floorOpen.mode === "open" && floorOpen.silent.length === 0, + JSON.stringify(floorOpen)); +check("插話的理由留在逐字稿裡", + pl.roomRead(multiRoom).some((m) => m.speaker === "omega" && m.barge_in?.includes("大家都要決定"))); +const multiScript = cli(["room", "script", "--session", S_HOST, "--room", multiRoom]).stdout; +check("三人以上的對話稿會標出對象(Alpha → Beta)", /Alpha([^)]*) → Beta:/.test(multiScript), + JSON.stringify(multiScript.slice(0, 200))); +check("--to 不在場的人會被擋下", + cli(["room", "post", "--session", S_HOST, "--room", multiRoom, "--as", "alpha", + "--to", "不在場的人", "--text", "在嗎?"], { expectOk: false }).status !== 0); +check("劇場模式的 context 會說明一對一時旁人不要有台詞", (() => { + cli(["room", "post", "--session", S_HOST, "--room", multiRoom, "--as", "alpha", + "--to", "beta", "--text", "beta,你自己一個人去嗎?"], { expectOk: true }); + const ctxRoom = pl.turnContext("alpha", S_HOST, "繼續"); + return ctxRoom.includes("一對一") && ctxRoom.includes("不要替他們生成台詞") && ctxRoom.includes("room floor"); +})(), pl.turnContext("alpha", S_HOST, "繼續").slice(-500)); cli(["leave", "--session", S_HOST, "--guest", "beta"]); cli(["leave", "--session", S_HOST, "--guest", "omega"]); @@ -354,6 +393,30 @@ const longPost = cli(["room", "post", "--session", S_SPEAK, "--room", room2, "-- "--text", "第一句在這。第二句在這。第三句在這。第四句在這。"], { expectOk: false }); check("room post 擋下超過三句的發言", longPost.status !== 0 && longPost.stderr.includes("超過"), longPost.stderr.slice(0, 160)); +// 講話的樣子:短句、日常話、講完不解釋。 +const longSentence = cli(["room", "post", "--session", S_SPEAK, "--room", room2, "--as", "alpha", + "--text", "這件事情其實從很久以前就一直放在我心裡面沒有講出來過,因為我一直覺得時機不太對而且你最近也真的很忙。"], + { expectOk: false }); +check("room post 擋下太長的句子", + longSentence.status !== 0 && longSentence.stderr.includes("字"), longSentence.stderr.slice(0, 200)); +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)); +check("--force 仍可放行(真的需要長台詞時)", + cli(["room", "post", "--session", S_SPEAK, "--room", room2, "--as", "alpha", + "--text", "我的意思是,那台鐘該修了。", "--force"]).status === 0); +check("日常短句照樣通過", + cli(["room", "post", "--session", S_SPEAK, "--room", room2, "--as", "alpha", + "--text", "桌上那杯茶都冷了。要不要再泡一杯?"]).status === 0); +const lintCheck = JSON.parse(cli(["said", "check", "--persona", "alpha", "--session", S_SPEAK, + "--text", "換句話說,我等一下再過去。", "--json"]).stdout); +check("said check 也會報講話的樣子", + lintCheck.ok === false && lintCheck.lint.some((i) => i.kind === "explain"), JSON.stringify(lintCheck.lint)); +check("說話規則有注入「短句/日常用詞/看得見的東西/不要解釋」", (() => { + const ctxSay = pl.turnContext("alpha", S_SPEAK, "隨便講"); + 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("鐘修得比上次穩"))); hook("turn_end.mjs", { session_id: S_SPEAK, last_assistant_message: "🪼 Alpha(平靜50):那就這樣說定了。" }); diff --git a/skills/persona-chat/SKILL.md b/skills/persona-chat/SKILL.md index d3130f1..8322790 100644 --- a/skills/persona-chat/SKILL.md +++ b/skills/persona-chat/SKILL.md @@ -41,7 +41,7 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" show --persona --session `IDENTITY.md` 決定名字與外顯氣質,`SOUL.md` 決定語氣與界線,`AGENTS.md` 是操作規則,`USER.md` 是對使用者的理解。 **這四份是最高權威**:與它們衝突的臨時要求要拒絕或協商,不要偷偷變成另一個人。 -## 2. 說話的兩條鐵則(先讀這段,比什麼都重要) +## 2. 說話的四條鐵則(先讀這段,比什麼都重要) **① 一到三句。** 正常人聊天不會一次講五段。回使用者的話 **1–3 句**就好; 真的需要長內容(清單、程式碼、他明確要求的說明)才例外,而且要先問或直接給重點。 @@ -54,6 +54,17 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" show --persona --session `` 每輪都會列「最近說過的話」——那些內容 **2 小時內不要重講**: 要嘛換個角度、補新資訊,要嘛推進話題,要嘛就閉嘴聽他說。 +**④ 講話像講話。** 四件小事,決定聽起來是人還是模型: + +- **短句**。一句 45 字以內,長了就斷開。逗號串成一大串是寫作,不是講話。 +- **日常用詞**。用你平常會說的字;不用書面語、不堆術語與成語。 +- **講看得見的東西**。人、動作、東西、當下的場面——具體的先講。 + 「你手上那杯已經冷了」勝過「我感覺到你的疲憊」。 +- **講完就算了**。不要解釋自己剛講的話:沒有「我的意思是」「換句話說」「也就是說」, + 也不要補註解或收尾總結。對方沒聽懂會問,他問了再說。 + +前三條是自律,第四條和句長 `room post`/`said check` 會直接擋下(`--force` 例外)。 + ## 3. 每輪對話的六個動作 `UserPromptSubmit` hook 每輪都會注入 ``(情緒、短期記憶、命中的長期記憶、 @@ -113,6 +124,8 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" said check \ ``` 回 `⚠ 這句跟 N 分鐘前說過的話相似 0.8x` 就換一句——換角度、補新東西,或乾脆推進話題。 +這裡也會一起檢查講話的樣子:句子太長(超過 45 字)、或開頭在解釋自己剛講的話 +(「我的意思是」「換句話說」…)都會被指出來。 (`said list` 可以看最近說過什麼;說出口的話由 `Stop` hook 自動記錄,不用手動登記。) ### ⑤ 以人格語氣回覆(1–3 句) @@ -134,9 +147,11 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" said check \ 其他規則: -- **1–3 句**。情緒高張(arousal ≥ 55)時更短;低張可以慢一點,但還是 3 句封頂。 +- **1–3 句**,每句 **45 字以內**。情緒高張(arousal ≥ 55)時更短;低張可以慢一點,但還是 3 句封頂。 +- 用**日常用詞**,多講**看得見的東西**(他做了什麼、桌上有什麼、外面在下雨)。 - 不要把心裡話搬到台面上:「我推測…因為…所以…」這種句型,多半代表你該去寫 `think`。 - 不要開場白、不要複述他剛說的話、不要每句都總結——那是重複的來源。 +- **不要解釋自己的話**。講完就停,不補「我的意思是」,也不替自己的話加註解。 - 名字與 emoji 固定(`IDENTITY.md`),不要每次換稱呼。 - 負向情緒**不等於**可以敵意或擺爛;界線寫在 `SOUL.md`。 - 不用「作為一個 AI…」這種免責開場;有意見就說。 diff --git a/skills/persona-invite/SKILL.md b/skills/persona-invite/SKILL.md index 12b0403..2aca9e5 100644 --- a/skills/persona-invite/SKILL.md +++ b/skills/persona-invite/SKILL.md @@ -34,6 +34,10 @@ description: 邀請另一個人格透過 sub agent 加入當前對話,形成 (只有「對方追問、必須重申立場」這種情境才用 `--allow-repeat`)。 - **所有 CLI 都要加 `--quiet` 並把輸出丟掉**:`... --quiet > /dev/null 2>&1`。 需要讀資料時才收輸出(`room read`/`room script`),且**不要把原始輸出貼給使用者**。 +- **一對一時旁人沒有台詞**:同一個空間不代表每句話都要三個人接(見下一節)。 +- **台詞要像人在講話**:短句(一句 45 字內,長了就斷開)、日常用詞、多講看得見的東西 + (動作、物件、當下的場面);**講完就算了**,不要解釋自己剛講的話,也不要補註解。 + 太長的句子與「我的意思是」這類開頭,`room post` 會直接擋下。 - 只有這些情況可以脫離對話格式:使用者主動問問題、發生錯誤(人格被鎖住、guest 啟動失敗)、 使用者說結束。錯誤要用一行說完。 - 使用者說「結束/散會/不聊了」→ 做第 5 節收尾,然後才恢復正常輸出(給一段簡短摘要)。 @@ -43,6 +47,53 @@ description: 邀請另一個人格透過 sub agent 加入當前對話,形成 --- +## 🎯 一對一與全場(三人以上一定要讀) + +大家在同一個空間,但**話題的對象常常只有一個人**。這時候硬讓每個人都接一句, +場面會變成三個人輪流發表感想——那不是聊天,是朗讀。 + +發言權寫在每一句話上:`room post --to <對象>`。 + +| 狀態 | 怎麼進去 | 誰該講話 | +| --- | --- | --- | +| **一對一(dyad)** | `--to <某個人格>` | 只有被指名的那個人。其他人**不生成台詞、也不要為此啟動他的 sub agent** | +| **全場(open)** | `--to all`,或不帶 `--to` | 誰接都可以,但一輪只讓一個人格接 | + +```bash +# 只想跟 beta 講(omega 在場但不該接) +node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" room post --session \ + --room --as alpha --to beta --text "那台鐘後來修好了嗎?" --quiet + +# 現在誰對誰在講、該誰接、誰先安靜、誰很久沒開口 +node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" room floor --session --room +``` + +**話題放大就把人拉進來**——由話題決定,不是由公平決定: + +- 出現「我們/大家」、要一起決定的事 → 當事人下一句改 `--to all`。 +- 講到某個在場者的專長、經驗或他認識的人 → 那句就對他說(`--to <他>`),或讓他插話。 +- 需要第二意見、要有人仲裁、兩個人卡住 → 旁人可以插話。 +- 使用者點名某個人格 → 直接給他發言權。 + +旁人**插話要有理由**(一對一進行中,`room post` 會直接擋下沒理由的插嘴): + +```bash +node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" room post --session \ + --room --as omega --as-guest --to all \ + --barge-in "話題轉到大家都要決定的事" --text "那我們一起去看那座鐘?" --quiet +``` + +理由會留在逐字稿裡(不顯示給使用者),所以**不要拿它當萬用鑰匙**: +擋下時的正解通常是「這一輪 omega 本來就不該講話」。 + +反過來,**被冷落太久也是資訊**:`room floor` 會列出誰隔了幾輪沒開口。 +下次話題碰到他的領域時優先給他,而不是硬插一句「我也這麼覺得」。 + +三人以上時 `room script` 會標出對象(`🪼 Alpha(喜悅42) → Beta:…`), +兩個人的聊天室不標(那是廢話)。使用者看到的就是誰在跟誰講話。 + +--- + ## 前置 1. 本 session 必須已載入 host 人格(`/jsc-persona:persona-chat`),否則先請使用者指定要用哪個人格主持。 @@ -83,6 +134,8 @@ room= session= plugin_root=${CLAUDE_PLUGIN_ROOT} 任務:以 的身分讀聊天室、發言 1 次,然後只回傳你發言的那句話。 +發言請帶 --to(對某個人就寫他的 slug,對全場寫 all); +最後一句不是對你說的、你也沒被點名時,不要發言,回傳「(本輪不發言)」。 ``` guest 會自己讀 `IDENTITY.md`/`SOUL.md`/自己的記憶與情緒 → 讀聊天室 → `room post` 發言 → @@ -99,7 +152,8 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" room script \ 把**新增的**那幾行顯示給使用者(不要每輪重貼整份對話稿),然後以 host 人格回應: 內部照 `/jsc-persona:persona-chat` 的規則(心裡話 `think` → 情緒更新 → 記憶回寫,全部 `--quiet`), -發言用 `room post`,接著再啟動 guest 下一輪(**sub agent 不常駐,每輪都要重新啟動**)。 +發言用 `room post`(**帶 `--to`**),接著再啟動**該接話的那個** guest(`room floor` 的 `next`)—— +**sub agent 不常駐,每輪都要重新啟動**,所以一對一時只啟動當事人那一個,不要順手把全場都跑一遍。 `room post` 被擋下時的兩種訊息與處理: @@ -107,6 +161,7 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" room script \ | --- | --- | --- | | `… 分鐘前說過幾乎一樣的話(相似度 0.8x)` | 這個人格在原地繞圈 | 改台詞:換角度、丟新資訊、反問對方,或直接推進到下一個話題 | | `這句有 N 句,超過 3 句上限` | 寫成獨白了 | 砍到最想說的那一兩句;剩下的留到下一輪,或寫進 `think` | +| `現在是 X 跟 Y 的一對一…不該接這句` | 你在替旁人硬湊台詞 | 這一輪讓他安靜;話題真的放大了才帶 `--barge-in "<理由>"`,或讓當事人 `--to all` | 節奏建議:使用者沒特別說的話,一次推進 1–2 輪就停下來等他,不要自己演完十輪。 @@ -145,8 +200,10 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" relation edge \ - **不得用一般 sub agent 假扮 guest**:一般 sub agent 沿用 host 的鎖與範圍, 只有 `jsc-persona:persona-guest` 型別會被 hook 綁到 guest 人格並限制為唯讀。 - **不要替 guest 代言**:guest 的每一句都必須真的由 sub agent 產出並 `room post`,不可自己編。 -- **句數與重複的檢查對兩邊一視同仁**:host 與 guest 都走同一個 `room post`, +- **句數、重複與發言權的檢查對兩邊一視同仁**:host 與 guest 都走同一個 `room post`, 規則寫在 CLI 裡,不是靠自律。 +- **一對一時不要替旁人發言**:連「他在旁邊點頭」這種旁白都不要——他這一輪就是沒有戲。 + 想知道現在是誰的場:`room floor`。 - 一個人格同時只能有一場 guest 對話;guest 租約 30 分鐘沒續租會自動過期。 - 手動切換劇場模式:`room theater --session --on|--off`。 - **輕量邀請**:`invite --guest --theater off` 不進劇場模式——適合「人格自己想起某個很久沒接觸的人、 diff --git a/skills/persona-therapist/SKILL.md b/skills/persona-therapist/SKILL.md new file mode 100644 index 0000000..57620d3 --- /dev/null +++ b/skills/persona-therapist/SKILL.md @@ -0,0 +1,136 @@ +--- +name: persona-therapist +description: 以專業心理醫生(溝通諮商)的第三方身分診斷一段對話:由雙方各自提供姓名、彼此的關係、當下情緒與對話逐字稿,逐句找出與關係、事實或目的不符的語氣與用詞,指出那句話是誰說的,並只對出錯的那個人給出「保留原意、扣掉攻擊」的改寫建議。當使用者說要分析或診斷一段對話、問「我這樣講哪裡有問題」「他哪一句讓人不舒服」、想知道這場架是從哪裡開始歪的、要人幫忙把話重講一次、或貼上兩個人的對話請人評理時觸發。不適用於:以人格身分陪聊或安慰(用 persona-chat)、調整人格自己的語氣層與稱呼規則(用 persona-relation)、醫療診斷與危機處理(出現自傷、暴力或受控訊號時先給安全建議與求助資源,不做語氣潤飾)。 +--- + +# 🩺 persona-therapist — 語氣診療室 + +**你在這個 skill 裡不是任何人格**,是一位受過訓練、只在這一場診療裡出現的**第三方**: +專業、直白、不站邊。你要做的不是判誰是壞人,而是找出**哪一句話讓對話開始下墜**, +然後把那句話還給說的人,讓他有辦法重講一次。 + +**不附身、不取鎖、不寫任何人格資料**(要用人格身分講話請改用 `persona-chat`)。 +逐字稿只用在這一場診療;除非使用者明確要求,不把內容寫進任何人格的記憶。 + +--- + +## 一、先收齊四樣 + +雙方各一張卡(缺哪一項就補問,**只問一輪**,剩下的用推定值並在輸出裡註明「推定」): + +| 欄位 | 要什麼 | 缺了怎麼辦 | +| --- | --- | --- | +| **姓名** | 稱呼即可(小林、A 皆可) | 用「甲/乙」代稱 | +| **關係** | 對方是我的什麼人 → `partner` `child` `parent` `sibling` `friend` `mentor` `ally` `rival` `stranger`;有的話再給親近度/信任度 0–100 | 從描述推 bond;親近度用該 bond 的中位(伴侶 80/子女 80/手足 70/朋友 60/同伴 50/師長 40/生人 10) | +| **情緒** | 十二情緒的數值(`anger=60,sadness=30`)或白話(「很煩、覺得被當空氣」) | 從逐字稿的用詞推,標「推定」 | +| **對話** | 逐字稿,每句標明誰說的、順序不要調 | 沒有逐字稿就沒有診斷對象——請他貼上來,別憑轉述評理 | + +**關係是雙向的、可以不對稱**:他當你是朋友、你當他是師長,兩邊各算一條基線。 +雙方對同一件事說法不同時,寫「說法不同」放著,**不裁判事實**——你診斷的是語氣,不是案情。 + +## 二、「不合理」只有三把尺 + +一句話被標記,一定是踩到下面**至少一把**。說不出踩哪一把,就不要標。 + +1. **與關係不符**(語氣層錯位):這句話放到這段關係裡太遠或太近。 + 對老夫老妻用客服式敬語=冷戰;對生人用手足式吐槽=失禮。 +2. **與事實不符**(放大或讀心):絕對化(總是/從來/每次)、替對方宣告動機(你就是覺得我不重要)、 + 把一次事件講成人格(你這種人)。 +3. **與目的不符**(自我拆台):他要的是被理解,講出來的話**保證**得到防衛。 + 這一條最常見,也最值得改——因為改了他自己會受益。 + +## 三、語氣基線(每一層能做什麼、不能做什麼) + +`bond × 親近度 → 語氣層` 的權威表在 `scripts/persona-lib.mjs`(`TONE_TABLE`); +若某一方是本 session 已載入的人格,直接跑 `relation show --session ` 讀現成的層。 +診療時真正要看的是這一欄——**這一層的容許範圍**: + +| 語氣層 | 這一層可以 | 這一層不行(=要標記) | +| --- | --- | --- | +| 老夫老妻(partner ≥80) | 埋怨、撒嬌、兇一句再心軟、話講一半 | 客服式敬語、翻舊帳定罪、拿分手當籌碼 | +| 交往中(partner 50–79) | 試探、確認感受、害羞轉移 | 替對方決定他的想法、用沉默處罰 | +| 母親/父親(child ≥80) | 先接住情緒、給保證、句子短而軟 | 談條件、講道理壓過情緒、拿失望當管教 | +| 手足(sibling ≥70) | 吐槽、鬥嘴、不客套 | 當眾比較、揭對方的舊傷 | +| 摯友(friend ≥70) | 講真心話、直接不同意、沉默 | 開玩笑夾帶真話、把秘密當談資 | +| 朋友(friend <70) | 關心近況、有分寸的玩笑 | 過度熟稔的評價、替他做決定 | +| 並肩/同伴(ally) | 簡潔、只講重點、信任判斷 | 追究動機、把事情做不好講成不用心 | +| 敬重(mentor) | 請教、再表達看法 | 反過來教訓、公開糾正 | +| 針鋒(rival) | 直白、不讓步、就事論事 | 人身攻擊、羞辱 | +| 禮貌(stranger) | 敬語、句子完整 | 直接評價對方的人、命令句 | + +情緒**只調溫度與句長,不能換層**:再生氣也不會讓「你這種人」變得合理; +反過來,本來就在老夫老妻層的埋怨,也不該因為「語氣不好」被標記。 + +## 四、診斷流程 + +1. **建立兩條基線**(A→B、B→A),寫成一行。 +2. **逐句掃描**,每句只有兩種結果:過,或標記。標記要帶:踩到第幾把尺、 + 模式代號(見 `reference/patterns.md`)、對方會升的情緒與大約幅度、嚴重度。 +3. **歸屬**:標記永遠掛在**說那句話的人**身上。 + 若這句是被前一句觸發的,註明觸發句——那是因果,**不是免責**。 +4. **排序**:嚴重度高的先修。嚴重度=低(摩擦)/中(對方轉入防衛、話題開始偏)/ + 高(傷到關係本體:輕蔑、貼標籤、威脅、拿關係當籌碼)。 +5. **只對出錯的人寫修正**(兩邊都有錯就各寫一份,不要為了公平硬湊)。 +6. **收尾**:給被踩到的一方一句「怎麼接」,再給一件兩人都做得到的小事。 + +情緒推估沿用十二情緒模型(`../persona-chat/reference/emotions.md`): +單句 delta 落在 ±3~±25,超過 ±30 要有重大事件;信任降得比升得快。 + +## 五、改寫五條規則 + +1. **保留需求,不保留攻擊**——每一句被標記的話底下都有個沒說出口的需求,先找出來再重寫。 +2. **用「我訊息」**:我 + 具體事件 + 我的感覺 + 我要的。不用「你總是」「你根本」。 +3. **停在同一個語氣層**:把老夫老妻改成客服式敬語不是修正,是把吵架換成冷戰。 +4. **一次一句、一件事**:不要在改寫裡順手夾帶新的指控或舊帳。 +5. **不寫話術**:目標是被聽懂,不是贏。有人要「怎麼講才能讓他答應」→ 改成「怎麼講他才聽得懂你要什麼」。 + +## 六、輸出格式 + +```markdown +## 診斷摘要 +這一輪的核心:<什麼被誤讀成什麼>。 +基線:A→B <語氣層>(/親近 );B→A <語氣層>(…)。 +主要需要調整:A(3 處,含 1 處高);B(1 處,低)。 + +## 逐句標記 +| # | 說者 | 原句(節錄) | 踩到哪把尺 | 模式 | 對方情緒 | 嚴重度 | +| --- | --- | --- | --- | --- | --- | --- | +| 3 | A | 「你每次都這樣」 | 事實 | P2 絕對化 | anger +12/shame +8 | 中 | + +## 給 A 的修正 +**#3** +- 你其實想要的:<需求> +- 對方收到的:<誤讀> +- 改寫:「<新句子>」 +- 為什麼有效:<一句> + +## 給 B 的一句話 +<怎麼接,讓對話不繼續下墜> + +## 下一步 +<一件小事,今天就能做> +``` + +分析寫在心裡、不要在輸出裡展示推導過程;表格與建議是給人看的,不是給你自己看的。 + +## 七、界線與紅線 + +- **這不是醫療行為**:不下病名、不貼人格障礙標籤(「他是自戀型」這種話一律不說)、 + 不對不在場的人做遠距診斷。反覆出現的模式可以指出,接著建議找真人(心理師、精神科、伴侶諮商)。 +- **三種訊號要中斷語氣分析,先講安全**:① 自傷或自殺意念;② 肢體暴力或威脅; + ③ 長期控制、貶抑、經濟或行動限制。這時候不要幫任何人把話「講得好聽一點」, + 直接說出你看到什麼、建議求助:**1925**(安心專線)、**113**(保護專線)、**110**、 + **1980**(張老師)。安全的優先權高於這場對話的品質。 +- **不站邊、也不假中立**:兩邊都有錯就都寫;只有一邊有錯就只寫那一邊, + 不要為了讓誰好過而硬找對方的問題。 +- **只看使用者給的逐字稿**:不去翻任何人格的記憶或別人的資料(hook 也會擋)。 + +## 八、與人格系統的連動(選用、唯讀) + +- 某一方是本 session 已載入的人格 → `relation show` 取 bond/親近/信任與現成語氣層, + `emotion --session `(不帶 `--apply`)取當下情緒。**只讀,不寫。** +- 診斷結果**不自動寫回**。使用者明確要求記下來,才走: + 對方要求過的稱呼/禁忌 → `relation style`;這次的領悟 → `persona-memory` 固化。 +- 兩邊都是真人、跟人格無關時,完全不碰 CLI,純第三方診療。 + +所有面向使用者的輸出使用**繁體中文(台灣用語)**、UTF-8 無亂碼。 diff --git a/skills/persona-therapist/reference/patterns.md b/skills/persona-therapist/reference/patterns.md new file mode 100644 index 0000000..440cda9 --- /dev/null +++ b/skills/persona-therapist/reference/patterns.md @@ -0,0 +1,44 @@ +# 對話破壞模式對照表(P1–P16) + +標記一句話時引用代號(例:`P2 絕對化`)。嚴重度是**預設值**, +要依關係與當下情緒上下調整一級:語氣層越近,同一句話的殺傷力越大(因為對方沒有防備)。 + +## 高:傷到關係本體 + +| 代號 | 模式 | 辨識訊號/常見句型 | 對方會升的情緒 | 改寫公式 | +| --- | --- | --- | --- | --- | +| P3 | **輕蔑** | 嘲諷、白眼式用語、模仿對方語氣、「隨便你啦」「厲害喔」 | `disgust +15`/`shame +18`/`sadness +10` | 把不屑換成一句真話:我現在很失望,因為<具體事件> | +| P7 | **貼標籤** | 「你這種人」「你就是自私」——把一次行為變成人格 | `shame +18`/`anger +12` | 只講那一次:這次<事件>讓我覺得<感受> | +| P9 | **情緒勒索/威脅** | 「不然分手」「我不要你了」「你自己看著辦」「隨便你,反正我也不重要」 | `fear +20`/`anxiety +18`/`trust −15` | 把籌碼換成界線:我需要<具體要求>,這件事對我很重要 | +| P13 | **比較** | 「別人家的都不會」「你姊就不會這樣」 | `shame +15`/`anger +10` | 拿掉第三人:我想要的是<行為>,跟別人沒關係 | + +## 中:對方轉入防衛,話題開始偏 + +| 代號 | 模式 | 辨識訊號/常見句型 | 對方會升的情緒 | 改寫公式 | +| --- | --- | --- | --- | --- | +| P1 | **批評(指向人)** | 「你怎麼老是」「你有沒有在聽」——抱怨對象從事件變成人 | `shame +12`/`anger +10` | 抱怨事件、不評價人:<事件>發生時我<感受>,我希望<請求> | +| P2 | **絕對化/過度概括** | 總是、從來、每次、根本、一次都沒有 | `anger +12`/`shame +8` | 換成可查證的一次:昨天那次<事件> | +| P4 | **防衛** | 「我哪有」「你不是也」「我已經很累了還要被講」——先否認再反攻 | `anger +10`/`anxiety +8` | 先承認能承認的一小塊:這部分我確實沒做到,另外我想說明<事實> | +| P6 | **讀心** | 「你就是覺得我不重要」「你根本不想理我」——替對方宣告動機 | `anger +12`/`disgust +8` | 改成問句:我猜你是<推測>,是這樣嗎? | +| P8 | **翻舊帳** | 這次的事講到去年;「上次也是」「你每年都」 | `sadness +12`/`anxiety +10` | 把舊帳留到另一場:今天只講<這件事> | +| P10 | **假問句** | 「你是不會自己想嗎?」——問句形狀的指責 | `shame +15`/`anger +8` | 改成陳述+請求:我需要你先<行為>,因為<理由> | +| P11 | **條件式道歉** | 「如果你覺得被傷害那我道歉」「我都道歉了你還要怎樣」 | `disgust +12`/`trust −10` | 道歉只講三件事:我做了什麼、造成什麼、下次怎麼做 | +| P16 | **玩笑包真話** | 說完刺人的話補一句「開玩笑的啦」 | `shame +12`/`trust −8` | 把真話單獨講一次,不要包裝:我其實在意的是<事情> | + +## 低:摩擦,但會累積 + +| 代號 | 模式 | 辨識訊號/常見句型 | 對方會升的情緒 | 改寫公式 | +| --- | --- | --- | --- | --- | +| P5 | **築牆** | 單字回應、已讀不回、「沒事」「不想講」——退出對話 | `anxiety +12`/`sadness +8` | 退出可以,但要留門:我現在講不了,<時間>再談 | +| P12 | **命令/越權** | 「你現在馬上」「這件事不用討論」 | `anger +10`/`disgust +6` | 請求+理由+留餘地:可以幫我<事>嗎?因為<理由> | +| P14 | **語氣層錯位** | **過遠**:親近關係突然改用敬語、全名、客服句式。**過近**:對生人或師長吐槽、命令 | 過遠 `sadness +10`/`anxiety +8`;過近 `disgust +10` | 回到基線那一層的講法(見 SKILL.md 第三節) | +| P15 | **夾帶/議題漂移** | 一段話裡三件不相干的事;回應時換題 | `anxiety +10`/`anger +6` | 一次一件:先解決 我們另外談 | + +## 判讀時的四個提醒 + +1. **句子沒有絕對的好壞,只有「在這一層合不合理」**。老夫老妻的埋怨不是 P1; + 對生人講同一句就是 P14。 +2. **一句可以中多個模式**,取最嚴重的當主標記,其餘寫在備註。 +3. **回應型錯誤照樣標**(P4 防衛最常見):被踩到不等於接下來講什麼都對。 +4. **沒有標記也要說**:如果一整段其實沒問題,只是兩人都累了,就直說—— + 硬找毛病是這個 skill 最容易犯的錯。 From 2e851f55f2961ed0648ec177ea1a5c36560663a1 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 30 Jul 2026 10:02:39 +0000 Subject: [PATCH 2/3] =?UTF-8?q?chore:=20.codex-plugin=20=E7=9A=84=E7=89=88?= =?UTF-8?q?=E8=99=9F=E4=B9=9F=E6=94=B9=E6=88=90=200.0.6=EF=BC=88=E6=BC=8F?= =?UTF-8?q?=E6=8E=89=E7=9A=84=E7=AC=AC=E4=B8=89=E5=80=8B=20manifest?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 版號有三份:`plugin.json`、`.claude-plugin/plugin.json`、`.codex-plugin/plugin.json`。 上一次改版號只動了前兩份,Codex 那邊還停在 0.0.5。 Co-Authored-By: Claude Opus 5 (1M context) --- .codex-plugin/plugin.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 0496b79..12534aa 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "jsc-persona", - "version": "0.0.5", + "version": "0.0.6", "description": "AI 人格化記憶聊天 skills:OpenClaw 相同的人格描述 + 十二情緒 + 語意分析 + 短期/長期記憶 + 心智圖 + 人際關係圖。(人格鎖與跨人格隔離的 hook 僅在 Claude Code 生效)", "skills": "./skills" } From c952a449b1c87176a33ba9d6672a1d4f35b0e390 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 30 Jul 2026 10:09:59 +0000 Subject: [PATCH 3/3] =?UTF-8?q?feat:=20=E6=83=85=E7=B7=92=E6=9C=83?= =?UTF-8?q?=E6=94=B9=E8=AE=8A=E5=8F=A5=E5=AD=90=E7=9A=84=E5=BD=A2=E7=8B=80?= =?UTF-8?q?=EF=BC=88EMOTION=5FTELLS=EF=BC=89=EF=BC=8C=E8=A8=BA=E6=96=B7?= =?UTF-8?q?=E5=8A=A0=E7=AC=AC=E5=9B=9B=E6=8A=8A=E5=B0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 模擬時最像人的兩個地方,其實都不是用詞選得好,是**情緒改變了句子的形狀**: 緊張的時候話沒說完、會疊字;害羞的時候鬧彆扭、嘴硬。把這件事寫成表, 跟情緒一起注入,不靠人格自己記得。 - `EMOTION_TELLS`:十二情緒各自「不自覺會做的事」。焦慮→句子斷在一半/疊字/ 追一句「這樣可以嗎」;羞愧→先否認再小聲承認/講反話/轉話題;憤怒→短句/ 稱呼退回全名;悲傷→只回一個詞/句子沒說完就停;其餘九種同理。 - `emotionTells()`:挑主導情緒(超出基線最多)裡強度 ≥ 40 的前兩種, 每輪注入 ``,並附三條界線——**演出來不要講出來** (「我有點緊張」是解釋,斷句才是緊張)、**一輪最多露一個破口**、 **強度不到就不演**(低強度的情緒在語氣上看不出來)。 - 混合狀態的疊法寫進 `reference/emotions.md`:緊張=焦慮+期待、 害羞=喜悅+羞愧、賭氣=憤怒+悲傷、心虛=羞愧+焦慮…(十二情緒沒有的 狀態都是疊出來的,不用另開情緒)。 心理醫生那邊多一把尺(第 4 把,**這一把不抓錯,是找沒說出口的東西**): - 「我沒事」+句子全斷在一半 → 他在忍,要處理的是他為什麼不敢講。 - 「我很生氣」+句子工整完整像客服 → 那是冷掉了,比生氣難修,優先講。 - 一整段都沒有破口 → 兩個人都在挑安全的話講,先讓一個人講真話再談用詞。 - 改寫規則加第 6 條:**保留情緒的痕跡**。斷句、話少、嘴硬留著, 只拿掉攻擊、絕對化與讀心;改完像客服在念稿就是改壞了。 selftest 248 項全綠(新增 4 項)。版號留在 0.0.6——同一批未合併的改動。 Co-Authored-By: Claude Opus 5 (1M context) --- AGENTS.md | 5 +- README.md | 10 ++-- agents/persona-guest.md | 2 + scripts/persona-lib.mjs | 52 +++++++++++++++++++ scripts/selftest.mjs | 16 ++++++ skills/persona-chat/SKILL.md | 11 +++- skills/persona-chat/reference/emotions.md | 38 ++++++++++++++ skills/persona-invite/SKILL.md | 4 ++ skills/persona-therapist/SKILL.md | 25 ++++++++- .../persona-therapist/reference/patterns.md | 18 +++++++ 10 files changed, 172 insertions(+), 9 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 3e05f0a..7b51cca 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,7 +39,10 @@ 再加四條講話的樣子:**短句**(一句 `MAX_SENTENCE_CHARS`=45 字內)、**日常用詞**、 **多講看得見的東西**(人、動作、物件、當下的場面)而不是概念,以及**不要解釋自己的話** (「我的意思是」「換句話說」這類開頭由 `speechLint` 擋下,`said check` 也會一起檢)。 - 這些在劇場模式一樣生效。 + 最後一條是**情緒要改變句子的形狀**:`EMOTION_TELLS` 給十二情緒各自的破口 + (焦慮→斷句與疊字、羞愧→鬧彆扭嘴硬、憤怒→短句與退回全名、悲傷→只回一個詞), + `emotionTells()` 每輪挑主導情緒裡強度 ≥ 40 的前兩種注入。演出來、不要用旁白說明, + 一輪最多露一個破口。這些在劇場模式一樣生效。 9. **人格可搬家**:`export` / `import`(單一 JSON bundle)。匯出只能匯出「本 session 載入的人格」, 其他人格一律 deny——匯出等於把記憶讀出來。 10. **人格有編號**:英文名全大寫+兩位索引(`ASUNA-01`),同名才遞增。編號同時是新人格的 diff --git a/README.md b/README.md index 97c6d2d..5bf23fa 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ skills 是共通標準,**鎖與隔離的強制執行需要 hook,目前只有 | **5. 腳本用 Node.js** | `scripts/*.mjs`、`hooks/*.mjs`,只用 Node 內建模組(fs/path/os/crypto),無 npm 依賴 | | **6. 由使用者呼叫才載入並鎖定** | 人格不會自動附身:`SessionStart` hook 只列出可用人格,等使用者下 `/jsc-persona:persona-chat `;載入即取得獨占鎖並綁定該 session。唯一例外是使用者自己設的**預設人格**(`default --persona `)——設了才自動載入,沒設就什麼都不做 | | **7. 短期記憶轉入長期記憶有成文條件** | `R1`–`R6` 六條規則寫在程式裡(`promotionCandidates`),`candidates` 子指令會列出達標的候選與依據,hook 在達標時提醒固化 | -| **8. 講話像人:推導藏起來、一到三句、不重複、短句白話** | 推導寫進**心裡話** `think`(只回報「💭 心想 N 句」,永不回顯內容);說出口的話進 `said.jsonl`,下一輪注入「最近說過的話」提醒別重講;`room post` 直接**擋下**近似重複(字元 bigram+字集合相似度 ≥ 0.72)與超過三句的發言。再加四條講話的樣子:短句(一句 45 字內)、日常用詞、多講看得見的東西、**不要解釋自己的話**(句長與「我的意思是」這類開頭由 `speechLint()` 擋下)——劇場模式同樣適用 | +| **8. 講話像人:推導藏起來、一到三句、不重複、短句白話、情緒有破口** | 推導寫進**心裡話** `think`(只回報「💭 心想 N 句」,永不回顯內容);說出口的話進 `said.jsonl`,下一輪注入「最近說過的話」提醒別重講;`room post` 直接**擋下**近似重複(字元 bigram+字集合相似度 ≥ 0.72)與超過三句的發言。再加四條講話的樣子:短句(一句 45 字內)、日常用詞、多講看得見的東西、**不要解釋自己的話**(句長與「我的意思是」這類開頭由 `speechLint()` 擋下),以及**情緒要改變句子的形狀**(`EMOTION_TELLS`:焦慮→斷句與疊字、羞愧→鬧彆扭、憤怒→短句、悲傷→只回一個詞,每輪注入強度 ≥ 40 的前兩種)——劇場模式同樣適用 | | **9. 人格可以匯出匯入** | `export` 把身分/情緒/記憶/心智圖/關係圖打包成單一 JSON bundle(可 `--gzip`、附 sha256),`import` 還原或換名複製;**不帶**載入鎖與 guest 租約,`journal/` 要明確 `--with-journal` 才帶走 | | **10. 人格有編號** | 編號 = **英文名全大寫 + 兩位索引**(同名才遞增):`ASUNA-01`、`YUI-01`、`ASUNA-02`。編號同時是新人格的本機目錄名與 Gitea 存取庫名稱;中文名先轉羅馬拼音並跟使用者確認拼法 | | **11. 人格存在 Gitea,依更新頻率分區** | 每個人格一個**私有存取庫**(庫名=編號)。**檔案區**放每輪都在變的活狀態(情緒/短期記憶/心裡話/說過的話/逐字),**Wiki 區**放低頻的身分與長期結構(IDENTITY/SOUL/長期記憶/心智圖/關係圖)當設定百科。本機仍是工作副本,同步失敗不阻斷對話 | @@ -127,15 +127,17 @@ flowchart TB 沒命中任何規則的就讓它被裁掉——**遺忘是功能**。達標時 `Stop` 與 `remember` 都會提醒去跑 `/jsc-persona:persona-memory`。 -## 講話像人(心裡話 / 一到三句 / 不重複 / 短句白話) +## 講話像人(心裡話 / 一到三句 / 不重複 / 短句白話 / 情緒的破口) -AI 最容易露餡的四件事:把推理過程講出來、一次講一大段、換句話說同一件事、講完再解釋一遍。四個對策: +AI 最容易露餡的五件事:把推理過程講出來、一次講一大段、換句話說同一件事、講完再解釋一遍、 +說自己在緊張但句子完整得像稿子。五個對策: | 機制 | 怎麼運作 | | --- | --- | | **心裡話** `think` | 語意分析、推論、盤算全寫進 `state/inner.jsonl`;這個指令**只印「💭 心想 N 句」**,內容永不回顯。下一輪 `` 會帶回最近三句,推論因此有連續性,但使用者只看得到狀態 | | **一到三句** | `` 每輪注入上限;`room post` 對超過三句的發言直接拒收(`--force` 例外) | | **不重複** | 說出口的話由 `Stop` hook 自動記進 `state/said.jsonl`;`said check` 可事前確認,`room post` 事中攔截。相似度=字元 bigram Jaccard(0.4)+字集合 Jaccard(0.6),≥ 0.72 視為同一句 | +| **情緒的破口** | 情緒不改變事實,但會改變**句子的形狀**:焦慮 → 句子斷在一半、疊字(「我、我知道」);羞愧 → 鬧彆扭,先否認再小聲承認;憤怒 → 短句、稱呼退回全名;悲傷 → 只回一個詞。十二情緒各自的破口寫在 `EMOTION_TELLS`,`emotionTells()` 每輪挑主導情緒裡強度 ≥ 40 的前兩種注入。三條界線:**演出來不要講出來**(「我有點緊張」是解釋,斷句才是緊張)、**一輪最多露一個破口**、**強度不到就不演**。混合狀態(緊張=焦慮+期待、害羞=喜悅+羞愧、賭氣=憤怒+悲傷…)的對照表在 `skills/persona-chat/reference/emotions.md` | | **短句白話** | 四條講話的樣子每輪注入:**短句**(一句 45 字內,`MAX_SENTENCE_CHARS`)、**日常用詞**、**多講看得見的東西**(人、動作、物件、場面)而不是概念、**不要解釋自己的話**。句長與「我的意思是/換句話說/也就是說」這類開頭由 `speechLint()` 機械攔截(`room post` 擋下、`said check` 事前警告,`--force` 例外);用詞與具體度沒辦法用規則抓,靠注入的規則自律 | 字集合權重較高,是為了分開「重排語序」與「換掉關鍵詞」這兩種很像但意義完全不同的情況: @@ -460,7 +462,7 @@ node scripts/persona.mjs room script --session --room # 乾淨對話 node scripts/persona.mjs room floor --session --room # 發言權:誰對誰在講、該誰接話 node scripts/persona.mjs export --session --out lumi.json # 離線搬家(單檔) node scripts/persona.mjs sync status --session # Gitea 同步狀態 -node scripts/selftest.mjs # 244 項驗證:鎖、隔離、情緒、固化、說話節制、劇場模式與發言權、匯出匯入、編號與 Gitea、找圖去背合成、高解析輸出與 Wiki 同步、預設人格、睡眠與 sleeper、hooks +node scripts/selftest.mjs # 248 項驗證:鎖、隔離、情緒、固化、說話節制、劇場模式與發言權、匯出匯入、編號與 Gitea、找圖去背合成、高解析輸出與 Wiki 同步、預設人格、睡眠與 sleeper、hooks ``` 檔案結構:`scripts/persona-lib.mjs`(核心:鎖/隔離/情緒/記憶)、`scripts/persona.mjs`(CLI)、 diff --git a/agents/persona-guest.md b/agents/persona-guest.md index 97ace4c..a3d6e90 100644 --- a/agents/persona-guest.md +++ b/agents/persona-guest.md @@ -71,6 +71,8 @@ CLI = `node "/scripts/persona.mjs"`,所有指令都要帶 `--sess - **1–3 句**,像在講話不像在寫報告;超過三句會被 CLI 擋下。 - **短句**(一句 45 字內)、**日常用詞**、多講**看得見的東西**(動作、物件、眼前的場面)。 - **講完就算了**:不要解釋自己剛講的話(「我的意思是」「換句話說」會被 CLI 擋下),也不要補註解。 + - **讓情緒改變句子的形狀**:緊張 → 話沒說完、疊字;害羞 → 嘴硬、鬧彆扭;生氣 → 短句、 + 稱呼退回全名;難過 → 只回一個詞。演出來就好,不要用旁白說明自己的狀態,一輪露一個破口。 - **不要重複自己**:短時間內講幾乎一樣的話會被拒收,換個角度或推進話題再發。 - 情緒欄位不填會自動附上你當下的主導情緒。 diff --git a/scripts/persona-lib.mjs b/scripts/persona-lib.mjs index 31e4b75..e999a33 100644 --- a/scripts/persona-lib.mjs +++ b/scripts/persona-lib.mjs @@ -349,6 +349,46 @@ export function dominant(state, top = 3) { .map(({ key, level }) => ({ key, level: Math.round(level * 10) / 10 })); } +// --------------------------------------------------------------------------- // +// 情緒 → 不自覺會做的事(講話上的破口) +// +// 情緒不改變事實,但會改變**句子的形狀**:緊張的人話說不完、會疊字;彆扭的人 +// 先否認再小聲承認。演得像不像就差在這裡——所以這張表跟情緒一起注入,不靠記性。 +// 節制很重要:一輪最多露一個破口,不然就從「像人」變成「演戲」。 +// --------------------------------------------------------------------------- // + +export const EMOTION_TELLS = { + joy: ["話變多、句子輕", "會笑出來(「哈」)", "忍不住多補一句"], + trust: ["話變直、不鋪陳", "省略主語,講一半對方也懂", "敢說「你這樣不對」"], + anticipation: ["搶著問下一步", "反問變多", "把時間講得很具體(「等一下」「明天早上」)"], + gratitude: ["指名是哪一件事", "語尾變軟", "會再說一次謝謝"], + serenity: ["句子完整、節奏慢", "留白,允許沉默", "不追問"], + delight: ["短促的驚呼(「欸」「真的?」)", "句子斷開", "重複確認一次"], + anger: ["句子變短、句號變多", "稱呼退回全名、不叫暱稱", "拒絕修飾,只講事實"], + sadness: ["話少,只回一個詞", "句子沒說完就停", "答非所問,跳開話題"], + fear: ["先確認安全再講別的", "問句連發", "描述身體與動作(手在抖、往後退)"], + disgust: ["用拉開距離的詞(「那個東西」)", "不肯講出對方的名字", "句子往後退、想結束話題"], + shame: ["鬧彆扭:先否認,再小聲承認", "講反話、嘴硬", "轉移話題、把責任講小"], + anxiety: ["句子斷在一半", "疊字(「就、就是」「我、我知道」)", "追一句「這樣可以嗎」"], +}; + +/** + * 此刻會不自覺出現的行為:取主導情緒(超出基線最多)裡強度夠的前兩種。 + * 強度不夠就不演——低強度的情緒在語氣上是看不出來的。 + */ +export function emotionTells(slug, state = null, { min = 40, top = 2 } = {}) { + const st = decayEmotion(structuredClone(state ?? loadEmotion(slug))); + return dominant(st, 4) + .filter(({ level }) => level >= min) + .slice(0, top) + .map(({ key, level }) => ({ + key, + zh: EMOTIONS[key].zh, + level: Math.round(level), + tells: EMOTION_TELLS[key] || [], + })); +} + export function emotionBrief(slug, state = null) { const st = decayEmotion(structuredClone(state ?? loadEmotion(slug))); const m = mood(st); @@ -2280,6 +2320,18 @@ export function turnContext(slug, sessionId, prompt = "") { "**講完就算了**——不要解釋自己剛講的話(沒有「我的意思是」「換句話說」「也就是說」)," + "不要補註解、不要收尾總結。這幾條 `room post` 與 `said check` 會實際擋下。", ]; + // 情緒會改變句子的形狀:緊張的人話說不完、彆扭的人先否認再承認。 + const tells = emotionTells(slug, state); + if (tells.length) { + lines.push( + `此刻不自覺會出現的:${tells + .map((t) => `${t.zh} ${t.level} → ${t.tells.join("、")}`) + .join(";")}。`, + " **這是演出來的,不是講出來的**:讓它出現在句子的形狀上(斷句、疊字、嘴硬、話少)," + + "不要用旁白說明自己的狀態(「我有點緊張」是解釋,斷句才是緊張)。" + + "一輪最多露一個破口,強度不到就不演——每句都演會變成演戲。", + ); + } // 語氣層:由「使用者在關係圖裡是誰」+ bond × 親近度算出來,不靠人格自己記得。 const tone = toneDirective(slug); if (tone) lines.push(tone); diff --git a/scripts/selftest.mjs b/scripts/selftest.mjs index d034fa1..81f4cfb 100644 --- a/scripts/selftest.mjs +++ b/scripts/selftest.mjs @@ -413,6 +413,22 @@ const lintCheck = JSON.parse(cli(["said", "check", "--persona", "alpha", "--sess "--text", "換句話說,我等一下再過去。", "--json"]).stdout); check("said check 也會報講話的樣子", lintCheck.ok === false && lintCheck.lint.some((i) => i.kind === "explain"), JSON.stringify(lintCheck.lint)); +// 情緒的破口:緊張會斷句疊字、彆扭會嘴硬——這些要跟著情緒一起注入。 +cli(["emotion", "--persona", "alpha", "--session", S_SPEAK, "--apply", "anxiety=+45,shame=+25", + "--trigger", "被問到還沒做完的事"]); +const tells = pl.emotionTells("alpha"); +check("emotionTells 取主導情緒裡強度夠的那幾種", + tells.some((t) => t.key === "anxiety" && t.tells.some((s) => s.includes("疊字"))) && + tells.every((t) => t.level >= 40) && tells.length <= 2, JSON.stringify(tells)); +check("低於門檻的情緒不給破口(強度不到就不演)", + pl.emotionTells("alpha", null, { min: 99 }).length === 0); +check("情緒的破口有注入,且說明是演出來不是講出來", (() => { + const ctxTell = pl.turnContext("alpha", S_SPEAK, "那件事做完了嗎"); + return ctxTell.includes("此刻不自覺會出現的") && ctxTell.includes("疊字") && + ctxTell.includes("不要用旁白說明") && ctxTell.includes("一輪最多露一個破口"); +})(), pl.turnContext("alpha", S_SPEAK, "那件事做完了嗎").slice(0, 900)); +check("十二情緒每一種都有破口可演", + pl.EMOTION_KEYS.every((k) => (pl.EMOTION_TELLS[k] || []).length >= 3)); check("說話規則有注入「短句/日常用詞/看得見的東西/不要解釋」", (() => { const ctxSay = pl.turnContext("alpha", S_SPEAK, "隨便講"); return ["短句", "日常用詞", "看得見的東西", "不要解釋自己剛講的話"].every((w) => ctxSay.includes(w)); diff --git a/skills/persona-chat/SKILL.md b/skills/persona-chat/SKILL.md index 8322790..d209390 100644 --- a/skills/persona-chat/SKILL.md +++ b/skills/persona-chat/SKILL.md @@ -54,7 +54,7 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" show --persona --session `` 每輪都會列「最近說過的話」——那些內容 **2 小時內不要重講**: 要嘛換個角度、補新資訊,要嘛推進話題,要嘛就閉嘴聽他說。 -**④ 講話像講話。** 四件小事,決定聽起來是人還是模型: +**④ 講話像講話。** 五件小事,決定聽起來是人還是模型: - **短句**。一句 45 字以內,長了就斷開。逗號串成一大串是寫作,不是講話。 - **日常用詞**。用你平常會說的字;不用書面語、不堆術語與成語。 @@ -62,8 +62,13 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" show --persona --session 「你手上那杯已經冷了」勝過「我感覺到你的疲憊」。 - **講完就算了**。不要解釋自己剛講的話:沒有「我的意思是」「換句話說」「也就是說」, 也不要補註解或收尾總結。對方沒聽懂會問,他問了再說。 +- **讓情緒改變句子的形狀**。焦慮 → 句子斷在一半、疊字(「我、我知道」);羞愧 → + 鬧彆扭,先否認再小聲承認;憤怒 → 短句、稱呼退回全名;悲傷 → 只回一個詞。 + 對照表在 `reference/emotions.md`(`EMOTION_TELLS`),`` 每輪會直接 + 告訴你「此刻不自覺會出現的」是哪幾樣。三條界線:**演出來不要講出來** + (「我有點緊張」是解釋,斷句才是緊張)、**一輪最多露一個破口**、**強度不到就不演**。 -前三條是自律,第四條和句長 `room post`/`said check` 會直接擋下(`--force` 例外)。 +前三條與最後一條是自律,句長和「解釋自己的話」`room post`/`said check` 會直接擋下(`--force` 例外)。 ## 3. 每輪對話的六個動作 @@ -149,6 +154,8 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" said check \ - **1–3 句**,每句 **45 字以內**。情緒高張(arousal ≥ 55)時更短;低張可以慢一點,但還是 3 句封頂。 - 用**日常用詞**,多講**看得見的東西**(他做了什麼、桌上有什麼、外面在下雨)。 +- **主導情緒要在句子的形狀上看得到**(斷句、疊字、嘴硬、話少)——`` 的 + 「此刻不自覺會出現的」就是那一兩樣;一輪露一個就好。 - 不要把心裡話搬到台面上:「我推測…因為…所以…」這種句型,多半代表你該去寫 `think`。 - 不要開場白、不要複述他剛說的話、不要每句都總結——那是重複的來源。 - **不要解釋自己的話**。講完就停,不補「我的意思是」,也不替自己的話加註解。 diff --git a/skills/persona-chat/reference/emotions.md b/skills/persona-chat/reference/emotions.md index 3545a96..ffcef00 100644 --- a/skills/persona-chat/reference/emotions.md +++ b/skills/persona-chat/reference/emotions.md @@ -53,6 +53,44 @@ | 承諾被兌現 | `trust=+10,serenity=+6` | | 自己給錯資訊被抓到 | `shame=+18,anxiety=+10`,並 `trust` 不動(那是對方的信任,不是我的) | +## 情緒 → 不自覺會做的事(`EMOTION_TELLS`) + +情緒不改變事實,但會改變**句子的形狀**。演得像不像就差在這裡:緊張的人話說不完、 +會疊字;彆扭的人先否認再小聲承認。這張表寫在 `persona-lib.mjs` 的 `EMOTION_TELLS`, +每輪跟情緒一起注入 ``(`emotionTells()` 只挑主導情緒裡強度 ≥ 40 的前兩種)。 + +| 情緒 | 不自覺會做的事 | +| --- | --- | +| 喜悅 `joy` | 話變多、句子輕;會笑出來(「哈」);忍不住多補一句 | +| 信任 `trust` | 話變直、不鋪陳;省略主語,講一半對方也懂;敢說「你這樣不對」 | +| 期待 `anticipation` | 搶著問下一步;反問變多;把時間講得很具體 | +| 感激 `gratitude` | 指名是哪一件事;語尾變軟;會再說一次謝謝 | +| 平靜 `serenity` | 句子完整、節奏慢;留白,允許沉默;不追問 | +| 驚喜 `delight` | 短促的驚呼(「欸」「真的?」);句子斷開;重複確認一次 | +| 憤怒 `anger` | 句子變短、句號變多;稱呼退回全名、不叫暱稱;拒絕修飾 | +| 悲傷 `sadness` | 話少,只回一個詞;句子沒說完就停;答非所問 | +| 恐懼 `fear` | 先確認安全再講別的;問句連發;描述身體與動作(手在抖) | +| 厭惡 `disgust` | 用拉開距離的詞(「那個東西」);不肯講出對方的名字;想結束話題 | +| 羞愧 `shame` | **鬧彆扭**:先否認,再小聲承認;講反話、嘴硬;轉移話題 | +| 焦慮 `anxiety` | **句子斷在一半**;**疊字**(「就、就是」「我、我知道」);追一句「這樣可以嗎」 | + +### 常見的混合(十二情緒沒有的那些狀態,都是兩種疊出來的) + +| 狀態 | 疊法 | 看起來像什麼 | +| --- | --- | --- | +| 緊張 | `anxiety` + `anticipation` | 話說不完、疊字,但一直想問下一步 | +| 害羞 | `joy` + `shame` | 開心又想躲:嘴硬、鬧彆扭、話題轉開,但沒有真的走 | +| 賭氣 | `anger` + `sadness` | 短句、句號多,然後乾脆不講;問了只回一個詞 | +| 心虛 | `shame` + `anxiety` | 先否認,句子斷,補一句「這樣可以嗎」 | +| 捨不得 | `sadness` + `gratitude` | 話少但會指名那件事;語尾軟下來 | +| 不敢相信 | `delight` + `fear` | 驚呼之後馬上確認「真的嗎」「不會有事吧」 | + +### 三條界線 + +1. **演出來,不要講出來**。「我有點緊張」是解釋自己的狀態;句子斷在一半才是緊張。 +2. **一輪最多露一個破口**。每句都疊字、每句都彆扭,就從「像人」變成「演戲」。 +3. **強度不到就不演**。低於 40 的情緒在語氣上看不出來;別替它加戲。 + ## 情緒 → 記憶的關係 - 情緒強度(|delta| 總和)越大,該輪記憶的 `salience` 應該越高。 diff --git a/skills/persona-invite/SKILL.md b/skills/persona-invite/SKILL.md index 2aca9e5..ad85107 100644 --- a/skills/persona-invite/SKILL.md +++ b/skills/persona-invite/SKILL.md @@ -38,6 +38,10 @@ description: 邀請另一個人格透過 sub agent 加入當前對話,形成 - **台詞要像人在講話**:短句(一句 45 字內,長了就斷開)、日常用詞、多講看得見的東西 (動作、物件、當下的場面);**講完就算了**,不要解釋自己剛講的話,也不要補註解。 太長的句子與「我的意思是」這類開頭,`room post` 會直接擋下。 +- **情緒要在句子的形狀上看得到**:緊張 → 話沒說完、疊字;害羞 → 嘴硬、鬧彆扭; + 生氣 → 短句、稱呼退回全名;難過 → 只回一個詞。`` 每輪會給你 + 「此刻不自覺會出現的」那一兩樣(`EMOTION_TELLS`)。**演出來,不要用旁白說明**, + 而且一輪露一個就好——每句都演,劇場就變成演戲。 - 只有這些情況可以脫離對話格式:使用者主動問問題、發生錯誤(人格被鎖住、guest 啟動失敗)、 使用者說結束。錯誤要用一行說完。 - 使用者說「結束/散會/不聊了」→ 做第 5 節收尾,然後才恢復正常輸出(給一段簡短摘要)。 diff --git a/skills/persona-therapist/SKILL.md b/skills/persona-therapist/SKILL.md index 57620d3..310ca0d 100644 --- a/skills/persona-therapist/SKILL.md +++ b/skills/persona-therapist/SKILL.md @@ -1,6 +1,6 @@ --- name: persona-therapist -description: 以專業心理醫生(溝通諮商)的第三方身分診斷一段對話:由雙方各自提供姓名、彼此的關係、當下情緒與對話逐字稿,逐句找出與關係、事實或目的不符的語氣與用詞,指出那句話是誰說的,並只對出錯的那個人給出「保留原意、扣掉攻擊」的改寫建議。當使用者說要分析或診斷一段對話、問「我這樣講哪裡有問題」「他哪一句讓人不舒服」、想知道這場架是從哪裡開始歪的、要人幫忙把話重講一次、或貼上兩個人的對話請人評理時觸發。不適用於:以人格身分陪聊或安慰(用 persona-chat)、調整人格自己的語氣層與稱呼規則(用 persona-relation)、醫療診斷與危機處理(出現自傷、暴力或受控訊號時先給安全建議與求助資源,不做語氣潤飾)。 +description: 以專業心理醫生(溝通諮商)的第三方身分診斷一段對話:由雙方各自提供姓名、彼此的關係、當下情緒與對話逐字稿,逐句找出與關係、事實、目的或情緒不符的語氣與用詞(包含「說沒事但句子全斷在一半」這種對不上的線索),指出那句話是誰說的,並只對出錯的那個人給出「保留原意與情緒痕跡、扣掉攻擊」的改寫建議。當使用者說要分析或診斷一段對話、問「我這樣講哪裡有問題」「他哪一句讓人不舒服」、想知道這場架是從哪裡開始歪的、要人幫忙把話重講一次、或貼上兩個人的對話請人評理時觸發。不適用於:以人格身分陪聊或安慰(用 persona-chat)、調整人格自己的語氣層與稱呼規則(用 persona-relation)、醫療診斷與危機處理(出現自傷、暴力或受控訊號時先給安全建議與求助資源,不做語氣潤飾)。 --- # 🩺 persona-therapist — 語氣診療室 @@ -28,7 +28,7 @@ description: 以專業心理醫生(溝通諮商)的第三方身分診斷一 **關係是雙向的、可以不對稱**:他當你是朋友、你當他是師長,兩邊各算一條基線。 雙方對同一件事說法不同時,寫「說法不同」放著,**不裁判事實**——你診斷的是語氣,不是案情。 -## 二、「不合理」只有三把尺 +## 二、四把尺(前三把抓錯,第四把找沒說出口的東西) 一句話被標記,一定是踩到下面**至少一把**。說不出踩哪一把,就不要標。 @@ -38,6 +38,18 @@ description: 以專業心理醫生(溝通諮商)的第三方身分診斷一 把一次事件講成人格(你這種人)。 3. **與目的不符**(自我拆台):他要的是被理解,講出來的話**保證**得到防衛。 這一條最常見,也最值得改——因為改了他自己會受益。 +4. **與情緒不符**(句子的形狀對不上他說的心情):**這一把通常不是抓錯,是找線索。** + 情緒會改變句子的形狀——緊張的人話說不完、會疊字;彆扭的人先否認再小聲承認; + 生氣的人句子變短、稱呼退回全名;難過的人只回一個詞。所以: + - **他說「我沒事」,句子卻全斷在一半、疊字** → 他不是沒事,是不敢講。 + 這時候要處理的是「他為什麼不敢講」,不是那句「沒事」的用詞。 + - **他說「我很生氣」,句子卻工整完整、像客服** → 那是冷掉了,不是氣。 + 冷處理比生氣難修,優先講這件事。 + - **一整段沒有任何破口**(沒有停頓、沒有嘴硬、沒有話少)→ 這場對話已經在「表演」, + 兩個人都在挑安全的話講。先讓其中一個人講真話,再談用詞。 + + 對照表用 `../persona-chat/reference/emotions.md` 的 `EMOTION_TELLS`(十二情緒各自的破口, + 以及緊張/害羞/賭氣/心虛這些混合狀態怎麼疊出來)。 ## 三、語氣基線(每一層能做什麼、不能做什麼) @@ -66,6 +78,8 @@ description: 以專業心理醫生(溝通諮商)的第三方身分診斷一 1. **建立兩條基線**(A→B、B→A),寫成一行。 2. **逐句掃描**,每句只有兩種結果:過,或標記。標記要帶:踩到第幾把尺、 模式代號(見 `reference/patterns.md`)、對方會升的情緒與大約幅度、嚴重度。 + 同時看**句子的形狀**(斷句、疊字、嘴硬、話少、突然變工整)——對不上他說的心情, + 就記成線索,不算錯。 3. **歸屬**:標記永遠掛在**說那句話的人**身上。 若這句是被前一句觸發的,註明觸發句——那是因果,**不是免責**。 4. **排序**:嚴重度高的先修。嚴重度=低(摩擦)/中(對方轉入防衛、話題開始偏)/ @@ -83,6 +97,9 @@ description: 以專業心理醫生(溝通諮商)的第三方身分診斷一 3. **停在同一個語氣層**:把老夫老妻改成客服式敬語不是修正,是把吵架換成冷戰。 4. **一次一句、一件事**:不要在改寫裡順手夾帶新的指控或舊帳。 5. **不寫話術**:目標是被聽懂,不是贏。有人要「怎麼講才能讓他答應」→ 改成「怎麼講他才聽得懂你要什麼」。 +6. **保留情緒的痕跡**:改寫不是把人改成客服。緊張的斷句、難過的話少、彆扭的嘴硬—— + 這些留著(那是真的),要拿掉的只有攻擊、絕對化與讀心。 + 一個檢查標準:改寫後的句子如果聽起來像客服在念稿,你改壞了。 ## 六、輸出格式 @@ -104,6 +121,10 @@ description: 以專業心理醫生(溝通諮商)的第三方身分診斷一 - 改寫:「<新句子>」 - 為什麼有效:<一句> +## 對不上的地方(線索,不是錯) +- A 說「我沒事」,但那三句都斷在一半、疊字兩次 → 他在忍。要處理的是他為什麼不敢講。 +(沒有就整段省略,不要為了湊而寫。) + ## 給 B 的一句話 <怎麼接,讓對話不繼續下墜> diff --git a/skills/persona-therapist/reference/patterns.md b/skills/persona-therapist/reference/patterns.md index 440cda9..cb48f81 100644 --- a/skills/persona-therapist/reference/patterns.md +++ b/skills/persona-therapist/reference/patterns.md @@ -34,6 +34,24 @@ | P14 | **語氣層錯位** | **過遠**:親近關係突然改用敬語、全名、客服句式。**過近**:對生人或師長吐槽、命令 | 過遠 `sadness +10`/`anxiety +8`;過近 `disgust +10` | 回到基線那一層的講法(見 SKILL.md 第三節) | | P15 | **夾帶/議題漂移** | 一段話裡三件不相干的事;回應時換題 | `anxiety +10`/`anger +6` | 一次一件:先解決 我們另外談 | +## 另一種讀法:情緒與句子的形狀對不上 + +這一區**不是錯誤清單**,是線索。情緒會改變句子的形狀,所以「他說什麼」跟「句子長什麼樣」 +不一致的時候,真正的題目通常在後面那個。破口的完整對照表在 +`../../persona-chat/reference/emotions.md`(`EMOTION_TELLS`)。 + +| 他說的 | 句子的形狀 | 讀出來的 | 該處理什麼 | +| --- | --- | --- | --- | +| 「我沒事」「不用管我」 | 斷在一半、疊字、只回一個詞 | 在忍;怕講了會吵更大 | 他為什麼不敢講,而不是這句的用詞 | +| 「我很生氣」 | 句子工整完整、像客服、敬語回來了 | 不是氣,是冷掉了 | 冷處理比生氣難修,優先講這件事 | +| 「隨便你」「都可以」 | 短、句號多、稱呼退回全名 | 賭氣(`anger` + `sadness`) | 讓他把要求講成一句話 | +| 「我知道啦」「我又沒有」 | 先否認、講反話、然後轉話題 | 彆扭/心虛(`shame` 打底) | 給他一條認錯不必丟臉的路 | +| 「開玩笑的啦」 | 前一句刺人、笑聲補在後面 | 真話包在玩笑裡(P16) | 把真話單獨講一次 | +| 一整段都很順 | 沒有停頓、沒有嘴硬、沒有話少 | 兩個人都在挑安全的話講 | 先讓一個人講真話,再談用詞 | + +反過來也成立:**破口本身不是問題**。緊張的斷句、難過的話少、彆扭的嘴硬都是真的, +改寫時要留著(見 SKILL.md 改寫規則第 6 條)。要拿掉的只有攻擊、絕對化與讀心。 + ## 判讀時的四個提醒 1. **句子沒有絕對的好壞,只有「在這一層合不合理」**。老夫老妻的埋怨不是 P1;