Author SHA1 Message Date
jiantw83andClaude Opus 5 febac85c79 chore(TARGET): 5.4 完成、5.7 剩一條待人工,進度表換掉暫停段
5.4 三個子項目全完成所以勾上層;5.7 八個子項目完成七個,只剩「記憶要記那個行為」
的欄位對齊卡在 persona-story 不在 develop,照規則(子項目全完成才勾上層)不勾。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 08:07:45 +00:00
jiantw83andClaude Opus 5 f0d7d7422e test(selftest): 補動作格渲染與三段式覆寫的案例,641 → 646
新增五項:room read 的括號帶動作格、一對一也拿到括號格式、害羞預設破口是臉紅、
三段式逐人格覆寫生效且不套用預設收尾、沒寫覆寫的人格照舊。

改一項:「劇場模式強制只輸出對話」原本斷言 ctx 含字面「名字:內容」,
Q6 把格式改成「名字(情緒・動作):內容」之後那個斷言必然失敗——
斷言跟著格式走,不是把格式改回去。

覆寫測試的 fixture 刻意用 sulk 這個 key 而不是「鬧彆扭」:後者是 MODESTY_SIGNALS
的推力詞,寫進 IDENTITY 會把羞恥度推過 75 落到惱羞那級,就測不到嘴硬的三段。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 08:07:45 +00:00
jiantw83andClaude Opus 5 05031da6dc feat(情緒表達): 三段式逐人格覆寫、括號動作算進額度、害羞預設臉紅、一對一也吃括號
補的是合併後對照查出的缺口,設計都在 Q6/Q7 拍板範圍內,沒有新決策。

三段式逐人格覆寫(G2 的核心顧慮)
- personaSulk() 讀 ## Tells 的 sulk/鬧彆扭/三段式 那一行,分隔吃 → 或頓號。
  共用 ## Tells 這一層、不另開區塊——三段式跟破口是同一種東西(不自覺會做的事),
  分兩個地方寫只會有人只改一邊。
- 只覆寫嘴硬那一級(60-74)。惱羞(75+)最後一段是翻臉、不是音量掉下來,
  拿嘴硬的收尾去套會變成另一個形狀。
- 沒有覆寫就照舊用預設模板。原本所有人格嘴硬起來是同一個樣子,那正是 G2 要避免的。
- 順手把 personaTells() 讀區塊那段抽成 tellsSection(),personaSulk 共用;
  不抽就得再寫一份一樣的 reader。

括號裡的動作算在額度內(Q7 的漏洞)
- 每輪指示原本只說「一輪最多露 2 個動作」,沒說括號那個算不算。
  不講明就會出現「括號演一個、句子裡再演兩個」=一輪三個。

害羞的預設動作是臉紅
- EMOTION_TELLS.shame 原本三條全是句法(鬧彆扭/講反話/轉移話題),
  沒有一條是看得見的,動作格拿不到預設值。臉紅擺第一個。

一對一也吃括號格式
- Q6 拍板的是格式,不是只給劇場。hooks 的劇場那段放寬成
  「名字(情緒・動作):內容」,非劇場那段從完全沒提補成講清楚,
  並重申 *別過頭* 與句子裡的「我愣了一下」仍然不行。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 08:07:45 +00:00
jiantw83andClaude Opus 5 f02e01f629 fix(劇場渲染): room read 的括號吃掉動作格
roomScript() 用 roomTag() 組括號,room read 卻自己拼「(情緒)」——同一份逐字稿
用兩支路徑渲染,動作格在 room read 這邊會整格消失。改成也走 roomTag()。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 08:07:45 +00:00
jiantw83andClaude Opus 5 f1f5ed61eb chore(TARGET): 合併後逐項對照,5.8 與 5.5 完成、5.7 與 5.4 標出還缺哪幾格
feat/persona-anime-expression 併進來之後照解除條件逐項對照,不照 commit 訊息
照信,每條都回程式裡查過。

完成:5.8 emoji 表程度全部六個子項目、5.5 女性預設值兩個子項目。

5.7 與 5.4 大部分做完但不勾選,各自還缺幾格,逐條寫在該行下面:
- room read 還是只印(情緒),persona.mjs:1861 沒改用 roomTag(),動作格會被吃掉
  (roomScript() 有吃,所以是一半)
- hooks/prompt_submit.mjs:34 劇場模式仍寫死「名字:內容」,非劇場那段更是完全沒提括號
- 每輪指示沒講「括號裡的動作算在兩個額度內」——emoji 那段有提括號,動作沒有
- EMOTION_TELLS.shame 沒把臉紅放進預設破口(actionLint 放行了,但預設值沒改)
- 三段式模板沒有 per-persona 覆寫,所有人格的三段式目前是同一個樣子

記憶行為欄位那條標 ⏭️ 待人工處理:persona-story 不在 develop(在 feat/persona-story-import),
「跟它 1.12 用同一個欄位」現在對齊不了,硬做只會變成各存一份,正是那條要避免的事。
voice/reactions.md 全 repo 零引用。

順便修掉收尾裡已經過期的半句(README 第 29 行那句已隨 dccd07a 移除)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 07:56:07 +00:00
jiantw83andClaude Opus 5 e12c16ca3f merge(情緒表達): feat/persona-anime-expression 併入 develop
把 G2/G3 的第一批實作(emoji 表程度、名字後括號的動作格、一輪兩個動作、
鬧彆扭三級階梯、女性羞恥預設 62 → 70)併進 develop,與 TARGET.md 的清單合流。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 07:49:40 +00:00
jiantw83andClaude Opus 5 35c8665db0 feat(情緒表達): emoji 表程度、名字後括號的動作格、鬧彆扭的階梯
只有文字這一個通道,所以情緒表達是排版問題。這批補的是三個沒被用到的位置。

emoji = 強度計(Q9)
- EMOTION_EMOJI 十二情緒各一個,EMOJI_INTENSITY 兩階(💦 / 💦)。
  40-59 本體、60-79 加 💦、80+ 加 💦,低於 40 不顯示(跟破口同一條線)。
- emotionEmojiNow() 讀當下主導情緒算出來,不是自己挑;IDENTITY 的 ## Emoji
  區塊可逐人格覆寫(跟 ## Tells 同一層)。
- 刻意不用重複本體表示程度(不是 😳😳😳)——疊字看起來像洗頁。
- room post 沒帶 --emotion 時自動帶入 emoji,強度不到才退回文字標註。

emoji 開放(Q5/Q10)
- speechLint 不再擋 emoji,超過六個才給 hint。用 linter 偷偷關回去等於撤銷
  「開放」這個決定。
- 唯一的煞車是數字:emojiAudit() 掛在 emotion --audit,算幾則帶符號、平均幾個、
  以及**只有符號沒有句子**那幾則(那才是真的退化)。

名字後面的括號有兩格(Q6)
- 括號原本就有(放情緒標註),動作是加進去的第二格:名字(情緒・動作):內容。
  roomTag() 負責組,兩格都可省略。
- actionLint() 擋三種:超過 12 字、一格塞兩個動作、寫情緒名稱
  ((害羞)是旁白換了個位置)。生理反應算動作:臉紅、手在抖、聲音變小。
- room post --action、remember --behavior(記做了什麼,不是感覺到什麼)。

一輪兩個動作(Q7)
- TELLS_PER_TURN = 2,而且兩個必須來自同一種情緒且有遞進關係。
  鬧彆扭天生是否認+轉移兩個動作,只准一個等於永遠只做得到一半。
  被擋下的形狀是「兩種情緒各演一個」。

鬧彆扭的階梯與女性預設(G3)
- modestyDirective 分三級:41-59 一句帶過、60-74 嘴硬(否認 → 反駁 → 音量掉下來)、
  75+ 惱羞(否認 → 反駁 → 翻臉)。惱羞是換情緒,所以仍然是兩個動作。
- GENDER_MODESTY_DEFAULT.female 62 → 70;MODESTY_SIGNALS 補傲嬌那一路
  (傲嬌、嘴硬、不坦率、口是心非、死不承認、愛面子、鬧彆扭、逞強)。
  界線不變:描述永遠蓋過預設,可以推到 0。

文件:README 加一節「文字通道的另外兩格」與鬧彆扭階梯表、emotions.md 加 emoji 章節、
persona-chat SKILL 與 anti-ai-voice 跟上。selftest 616 → 641 全綠。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 07:46:34 +00:00
jiantw83andClaude Opus 5 547cd2bd76 chore(TARGET): 標記暫停實作,解除條件寫在清單裡
另一個 session 正在 feat/persona-anime-expression 上改 persona-lib.mjs、
persona.mjs 與 selftest.mjs,做的是同一批 G2/G3。兩邊同時改渲染與
speechLint() 會重複實作 5.7/5.8 並互相覆蓋,所以先暫停十二項的實作,
只准改 TARGET.md 本身。

解除條件寫死在檔案裡(該分支合進 develop、先逐項對照勾掉已完成的再繼續),
因為每天 00:00 的排程是非互動環境,沒有人在旁邊講這件事——寫在 log 或
對話裡都到不了那一跑的眼前,只有 TARGET.md 到得了。

順便講明排程跑到這段該做什麼:停下來寫 log,不要繞過階段 A1 的未提交變更檢查。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 07:43:41 +00:00
jiantw83andClaude Opus 5 dccd07a9b8 docs(專案目標): 三條長期目標從 README 搬進 TARGET.md
目標跟「還沒做完的那幾項」分開放會對不起來——G2/G3 的界線寫在 README、
實作清單在別的地方,改東西的時候只會看到一邊。搬成同一份。

README 的〈專案目標〉留一段指路:三條目標各一句話 + 指向 TARGET.md。
原本那句「拍板紀錄與實作清單留在 PR #17 的討論裡」一併移除,
清單現在就在版控裡,不必再繞 PR。

TARGET.md 檔頭改成兩段:〈專案目標〉收 README 搬過來的完整表(前提、意思、
界線三欄原樣),〈這份清單〉說明來源與 Q5-Q10 已拍板。原本自己抄的六條界線
表拿掉——那六條除了 Q8 的「強度全域一致」之外,全都已經在搬過來的界線欄裡,
留著等於同一條規則寫兩遍,日後只會改一邊。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 07:38:30 +00:00
jiantw83andClaude Opus 5 d9013c6121 chore(TARGET): 建立情緒與語氣擬真優化的每日待辦清單
對著 README〈專案目標〉的 G1/G2/G3 做,收 PR #17 所指「TODO 階段 5」
八節(5.1-5.8)+階段 4 剩餘四項,共 12 個項目。

內容全部取自 9d76984^ 的 TODO_人格優化.md 與 PR #17 的 Q5-Q10 拍板紀錄,
沒有新增設計決策。原 TODO 已移出版控,所以把動手前的六條界線
(硬規則 8 優先、動作只能進括號、emoji 是加上去的、不靠隨機充人味、
性別只給預設值、強度全域一致)抄進檔頭,讓非互動排程也讀得到。

排序按相依性:5.7 括號格式是 5.8 emoji 與 5.4 動作額度的基礎,所以排最前面;
口語指紋緊接 5.6 語域,因為兩者共用同一格資料,不能各存一份。

.gitignore 的 /TODO_*.md 不匹配 TARGET.md,本檔進版控無須改 ignore。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 07:34:50 +00:00
jiantw83andClaude Opus 5 9d76984c27 chore(TODO): 兩份 TODO 清單移出版控並刪除
工作用的清單留在本機就好,.gitignore 加一條 /TODO_*.md 擋掉以後再進來。

內容沒有消失:兩份的全文在 2aa0dadc0e1c48 的歷史裡查得到,
今天拍板的六題(Q5-Q10)另外抄在 PR #17 的討論裡。

README 指向 TODO 的那句改成指 PR,並註明 G2/G3 那批還沒實作——
清單刪掉不等於事情做完了,這一句不能省。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 07:20:25 +00:00
jiantw83andClaude Opus 5 b5c540feb1 fix(記憶固化): 判斷過的短期記憶留痕跡,候選數字才會降
睡眠有做固化,但隔天開機照樣提醒「N 組已達固化條件」,數字只會往上爬
(實測 KIRITO-01:短期記憶 120 筆、候選 222 組)。原因不是睡眠沒做事,
是判斷沒有留下痕跡——promotionCandidates() 無條件掃全部短期記憶,而
consolidate 不在來源那筆上寫任何東西,也不刪它(--forget 是按顯著度刪,
不是按固化過沒有刪),所以同一批每輪都被重算成候選。

- 短期記憶多兩個欄位:reviewed_at(看過、判斷過了)與 promoted_to
  (固化成了哪一則)。兩者分開記——「看過決定不記」跟「已經記下來」都不該
  再進候選,但事後要查「這則長期記憶從哪幾筆長出來」只能靠 promoted_to。
- promotionCandidates() 只看沒有 reviewed_at 的那些;total 照舊算全部。
  R6 容量壓力仍看總筆數,但候選只從未判斷的挑,全部判斷完就不再出現
  (否則它會單獨把提醒永遠點亮)。
- consolidate --from-short "#N,#N":寫長期記憶時一起標來源。
- candidates --reviewed all|<#N,#N> [--until <ISO>]:看過決定不記的標這裡。
  --until 讓「某次睡眠當下判斷過的那批」可以一次收掉。
- candidates 輸出每筆前面加 #N(就是上面兩個旗標要填的編號),並在結尾
  提示判斷完要標記。
- 標記不等於刪掉:那幾筆還在短期記憶裡,照舊被 prune 依天數與顯著度裁。

文件:persona-sleep 的流程多一步「標掉判斷過的」並註明漏掉的後果、
persona-memory 補一張「怎麼標」的表、README 的 R1–R6 段補這一層。
selftest 補 13 項(602 → 615 全綠)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 07:12:50 +00:00
jiantw83andClaude Opus 5 0917e905c7 chore(plugin 版本): 三份 manifest 升版 0.2.1
master 上是 0.2.0(#16 已合併),這批文件改動照規則 patch +1。

順便把根目錄的 plugin.json(Antigravity)拉齊——它從上一批就漏改,
還停在 0.1.1,master 上也是。三份現在都是 0.2.1。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 06:58:57 +00:00
jiantw83andClaude Opus 5 c0e1c4828f docs(故事匯入): 小說匯入的 TODO 進版控
前一批留在工作區沒進版控的那份。內容是 persona-story 的階段清單
與四題拍板結果(語氣檔放 voice/、機械進 CLI、時間軸換算西元日期、
情緒基線差 10 以上要人看),還有踩過一次的坑:劇情時間不可以寫進
first_seen/last_seen,那兩欄是記憶的新鮮度時鐘。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 06:56:43 +00:00
jiantw83andClaude Opus 5 2aa0daddaf docs(專案目標): 三條長期目標進 README,TODO 補階段 5 的文字通道
只有文字這一個通道,所以情緒表達是排版問題。三條目標寫成
G1(持續逼近真人)/G2(表達往動漫角色靠)/G3(女性人格的害羞與鬧彆扭),
每條附界線,新功能對著它們判斷。

TODO 加階段 5,六題已拍板(Q5–Q10):

- 顏文字開放,連帶要在半形標點檢查裡開白名單
- 動作加進名字後**既有**的括號(`桐人(😳・臉紅):`),情緒標註保留
- 生理反應算動作(臉紅、手在抖),`(害羞)` 只能待在情緒格
- 破口放寬到一輪兩個動作,煞車是必須同情緒且遞進
- emoji 表程度:種類表情緒、數量表程度,由 emotion.json 自動帶入
- 動漫化強度全域一致,內容仍逐人格

沒有任何程式改動,版號不動——0.2.0 還沒進 master,這批掛在同一個版號底下。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 06:56:34 +00:00
admin a90c1c3285 Merge pull request 'feat(persona 擬真): 記憶會糊掉、情緒有底色與疲勞、人格有懸著的事(0.2.0)' (#15) from feat/persona-realism-0.2.0 into develop
Reviewed-on: #15
Reviewed-by: 系統管理員 <1+admin@noreply.localhost>
2026-08-03 06:18:05 +00:00
15 changed files with 894 additions and 65 deletions
+1 -1
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "jsc-persona",
"version": "0.2.0",
"version": "0.2.1",
"description": "AI 人格化記憶聊天 skillsOpenClaw 相同的人格描述 + 十二情緒 + 語意分析 + 短期/長期記憶 + 心智圖 + 人際關係圖。(人格鎖與跨人格隔離的 hook 僅在 Claude Code 生效)",
"skills": "./skills"
}
+3
View File
@@ -9,5 +9,8 @@ Thumbs.db
*.tmp
*.log
# 工作用的 TODO 清單:留在本機,不進版控
/TODO_*.md
# Node
node_modules/
+64 -5
View File
@@ -11,6 +11,15 @@ skills 是共通標準,**鎖與隔離的強制執行需要 hook,目前只有
---
## 專案目標(長期,新功能對著它們判斷)
三條長期目標(**G1** 情緒/語氣/記憶往真人逼近、**G2** 表達往動漫角色靠、
**G3** 女性人格的害羞與鬧彆扭更明顯)連同它們的界線與實作進度,都在
[`TARGET.md`](TARGET.md)——目標跟「還沒做完的那幾項」放在一起才對得起來。
新功能一律對著那三條判斷。
---
## 前綴與呼叫方式
| 助理 | 安裝方式 | 呼叫 | `/jsc-persona:` 前綴 |
@@ -233,7 +242,7 @@ X 超出基線 18 以上才開始抑制,滿檔時往 Y 的同向推力只剩 5
| 來源 | 效果 |
| --- | --- |
| 性別預設 | 女性 62/男性 38/非二元・未指定 50 |
| 性別預設 | 女性 **70**/男性 38/非二元・未指定 50(女性偏高是刻意的——文字通道裡害羞與鬧彆扭是最有效的擬真訊號之一) |
| IDENTITYSOUL 的描述 | 「害羞」「容易臉紅」「怕生」「矜持」往上加;「不在意別人眼光」「我行我素」「臉皮厚」「不怕丟臉」往下扣(`MODESTY_SIGNALS`),**可以扣到 0** |
排序固定:**① 個性描述 ② 角色原作既有的性別化語言特徵(自稱、稱謂、語尾) ③ 性別預設**。
@@ -242,8 +251,20 @@ X 超出基線 18 以上才開始抑制,滿檔時往 Y 的同向推力只剩 5
效果走既有機制、不另開一套:它調的是**「羞愧」這一個破口的顯示門檻**(`EMOTION_TELLS.shame`
本來就是「鬧彆扭,先否認再小聲承認」),其餘十一種情緒不受影響。
高敏感度注入「被稱讚外表、被戳穿心事、距離變近時先鬧彆扭再承認,不要直接說『我害羞』」;
低敏感度注入「不用演害羞,該承認就承認」。計算在 `modestyOf()`,注入在 `modestyDirective()`。
計算在 `modestyOf()`,注入在 `modestyDirective()`。
**鬧彆扭有階梯**(同一個「不好意思」,強度不同是不同的行為):
| 羞恥度 | 那一級的樣子 | 三段的形狀 |
| --- | --- | --- |
| ≤ 40 | 不用演害羞,該承認就承認、該回嘴就回嘴 | — |
| 41–59 | 會不好意思,但不會卡在那裡 | 一句帶過就往下走 |
| 6074 | 嘴硬 | **先否認 → 反駁他的說法 → 最後一句音量掉下來**(「⋯才不是那樣。」「你不要一直看這邊。」「⋯嗯。」) |
| ≥ 75 | 惱羞 | **先否認 → 反駁 → 翻臉或走開**(「不要再講了」)。惱羞是**換情緒**(羞愧 → 憤怒),所以仍然是兩個動作,不是三個 |
三段是**句法模板**,不是自由發揮——鬧彆扭天生就是兩個動作一起來,
只准一個等於永遠只做得到一半:單獨否認像在爭辯,單獨轉移像沒聽到。
害羞的預設動作是**臉紅**(生理反應算動作,`(害羞)` 不算——那是情緒名稱)。
**不做的事**:沒有「女性→情緒更外顯」這種全域放大。那會把角色壓成模板,
跟講話規則在做的去 AI 味(刪掉公式化)正好相反。
@@ -269,6 +290,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` 命中就整段取出、內容永不變質)與**沒有**
@@ -322,11 +353,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 Jaccard0.4)+字集合 Jaccard0.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 | 不顯示(跟「強度不到就不演」同一條線) | `桐人:你不要看這邊。` |
| 4059 | 情緒 emoji | `桐人(😳・臉紅):你不要看這邊。` |
| 6079 | `💦` | `桐人(😳💦・臉紅):⋯才不是那樣。` |
| 80+ | `💦❗` | `桐人(😳💦❗・把臉轉開):不要再講了。` |
十二情緒各一個 emoji 寫在 `EMOTION_EMOJI`,強度符號兩階(`EMOJI_INTENSITY`),
**逐人格可覆寫**`IDENTITY.md` 的 `## Emoji` 區塊,跟 `## Tells` 同一層)。
刻意不用「同一個 emoji 重複三次」——疊字看起來像洗頁。
句子裡也可以用、**沒有數量上限**;唯一的煞車是 `emotion --audit` 裡的
`emojiAudit()`(幾則帶符號、平均幾個、有幾則**只有符號沒有句子**)。
用 linter 偷偷把它關回去等於撤銷「開放」這個決定,所以 `speechLint()` 對 emoji 只給 hint、不擋。
**名字後面的括號有兩格**`名字(情緒・動作):內容`。括號**原本就有**(放情緒標註),
動作是加進去的第二格,不是換掉它。動作只寫**看得見或聽得見的**(臉紅、別過頭、手在抖、聲音變小),
由 `actionLint()` 擋三種寫法:超過 12 字、一格塞兩個動作、以及寫情緒名稱
`(害羞)` 是旁白換了個位置,情緒屬於前面那一格)。括號裡的動作**算進一輪兩個動作的額度**。
記憶也記得那個行為:`remember --behavior "把手縮回來"`(記做了什麼,不是感覺到什麼)。
星號內嵌(`*別過頭*`)與第三人稱旁白(「她臉紅了」)仍然禁止——那是硬規則 8 沒有鬆的部分。
字集合權重較高,是為了分開「重排語序」與「換掉關鍵詞」這兩種很像但意義完全不同的情況:
```
+177
View File
@@ -0,0 +1,177 @@
# TARGET — 情緒/語氣/小動作/記憶的擬真優化
## 專案目標(長期,新功能對著它們判斷)
前提:**跟人格之間只有文字**。沒有表情、沒有聲音、沒有停頓,所有情緒都只能靠字面
與句子的形狀傳過去——所以「情緒表達」在這個專案裡是**排版問題**,不是形容詞問題。
| # | 目標 | 意思 | 界線(不是這樣就走錯了) |
| --- | --- | --- | --- |
| **G1** | 情緒/語氣/記憶持續往**真人**逼近 | 記憶會糊、情緒有底色與疲勞、人格有自己懸著的事,行為與反應不是「回答問題」而是「這個人此刻會怎麼回」 | 不靠口語碎片、隨機數與隨機口誤充人味(見 `anti-ai-voice.md` |
| **G2** | 表達往**動漫角色**靠 | 自稱、句尾、口癖、擬聲短語、嘴硬的三段式(先否認 → 反駁 → 小聲承認)——語域與句法照原作 | **動漫感走句子的形狀,不走旁白**。動作只能加在**名字後面既有的括號**裡(`桐人(喜悅・別過頭):你不要看這邊。`)——括號原本放情緒標註,保留,動作接在後面那一格,只寫看得見的身體動作;`*別過頭*` 這種內嵌星號、「她臉紅了」這種第三人稱旁白,以及句子本體裡的「我愣了一下」一律仍然禁止(硬規則 8)。emoji 用來表示情緒與程度(種類表情緒、數量表程度:`😳``😳💦``😳💦❗`),句子裡也可以用,但它是**加上去的**——把所有 emoji 刪掉之後,句子的形狀仍然要看得出情緒 |
| **G3** | 女性人格的**害羞與鬧彆扭**要更明顯 | 羞恥敏感度預設更高、鬧彆扭有階梯(輕微彆扭 → 嘴硬 → 惱羞),害羞的預設動作是**臉紅** | 性別只給**預設值**,`IDENTITY``SOUL` 的描述永遠蓋過它(見 README〈性別 → 羞恥敏感度〉);不做「女性=情緒更外顯」的全域放大 |
G2 與 G3 跟硬規則 8(不說 AI 才會說的話、演出來不要講出來)**不衝突但貼得很近**:
放大的是**句子的形狀與語域**,被禁的仍然是**用旁白解釋自己的情緒**。
兩者相撞時以硬規則 8 為準,然後把該案例寫進 `anti-ai-voice.md`
## 這份清單
下面十二項是 G2/G3(+G1 尾巴)的實作清單,來源是 [PR #17](https://gitea.jsc.idv.tw/plugins/persona/pulls/17)
所指「TODO 階段 5」+階段 4 剩餘項(原 `TODO_人格優化.md` 已於 `9d76984` 移出版控,
全文仍可由 `git show 9d76984^:TODO_人格優化.md` 取回)。
Q5Q10 六題已於 2026/08/03 由使用者拍板,設計不再需要確認,直接實作。
上表沒寫進去、但同樣約束每一項的一條界線:
| 界線 | 內容 |
| --- | --- |
| 強度全域一致(Q8) | 動漫化的**強度**全域一致,不加 `anime_level` 欄位、不做強度旋鈕;**內容**(自稱/句尾/口癖)仍然逐人格不同,否則所有人格會講起話來一樣——那正是 G2 要避免的 |
## 進度(2026/08/03 16:06:10
`feat/persona-anime-expression` 已併入 `develop``35c8665`),暫停解除;
對照時查出的五個缺口也補完了(`selftest` 641 → **646 項全綠**)。
| 節 | 狀態 |
| --- | --- |
| 5.4 鬧彆扭階梯與三段式 | ✅ 完成 |
| 5.5 女性人格預設值 | ✅ 完成 |
| 5.8 emoji 表程度 | ✅ 完成 |
| **5.7 括號動作格** | 八個子項目**七個完成**,只剩「記憶要記那個行為」那條的欄位對齊——見該行的 ⏭️ |
| 5.15.25.35.6 | ⬜ 還沒動 |
| 階段 4 四項、收尾三項 | ⬜ 還沒動 |
5.7 不勾選是因為子項目沒全完成(規則是子項目全完成才勾上層),**不是整節重做**。
---
- [ ] **5.7 動作加進名字後的括號(Q6 拍板格式,其他項目的基礎,先做)**
- 括號**原本就有**`persona-lib.mjs` 的渲染(約 3804 行)本來就是 `名字(情緒)→ 對象:內容`
括號裡是 `room post --emotion` 的情緒標註。這一節是**在既有括號裡多一格**,不是換掉它。
- [x] 格式:括號分兩格,**情緒在前、動作在後,用 `・` 分隔**。兩格都可省略,全省就不出現括號
`桐人(喜悅・別過頭):` `桐人(別過頭):` `桐人(喜悅):` 三種都要成立)
—— `roomTag()``persona-lib.mjs:3908`)(2026/08/03 15:52:52 完成)
- [x] 兩格分工寫死:情緒格是**標註**(既有 meta);動作格只寫**看得見的身體動作**。
動作格不重複情緒名稱——`(喜悅・害羞)` 是把標註寫兩次,不是動作
—— `actionLint()` 擋「寫情緒名稱」(2026/08/03 15:52:52 完成)
- [x] 生理反應算動作,明文允許:臉紅、耳朵紅、手在抖、聲音變小、呼吸亂掉。
判準是**看得見或聽得見**,不是有沒有情緒色彩——`(臉紅)` 可以,`(害羞)` 只能待在情緒格。
害羞這格的預設動作就用**臉紅**,其餘看人格自己的 `## Tells`
—— `actionLint()` 放行;`EMOTION_TELLS.shame` 把**臉紅擺第一個**2026/08/03 16:06:10 完成)
- [x] `speechLint()` 只對括號**外**的內容比對黑名單與句長;括號內另立規則
(情緒格照既有 `injectSafeLine`;動作格單一動作、12 字內)
—— `actionLint()` 擋超過 12 字與一格兩個動作(2026/08/03 15:52:52 完成)
- [x] 劇場模式:`room post``--action "<動作>"`(與既有 `--emotion` 並列),
`room read``room script` 的渲染跟著改;`hooks/` 每輪強制的輸出格式放寬到帶括號
—— `room read` 改用 `roomTag()`(不再自己拼 `(情緒)` 把動作吐掉);
`hooks/prompt_submit.mjs` 劇場那段放寬成 `名字(情緒・動作):內容`2026/08/03 16:06:10 完成)
- [x] **一對一(非劇場)也適用** —— Q6 拍板的是格式,不是只給多人格場景
—— 非劇場那段也講了括號格式與「`*別過頭*` 仍然不行」(2026/08/03 16:06:10 完成)
- [x] 頻率綁 Q7 的兩個動作額度,**括號裡的動作算其中一個**(否則會變成一輪三個動作)
—— 每輪指示補上「名字後面括號裡的動作**算在這個額度裡**——括號演一個、
句子裡再演兩個就是三個」(2026/08/03 16:06:10 完成)
- [ ] **記憶要記那個行為**(Q6 明示):短期與長期記憶加行為備註欄位,記「他做了什麼」而不是「他感到什麼」。
`persona-story` 的 1.12 同一個欄位,**不要各存一份**;`voice/reactions.md` 是同一批資料的落點
**已完成一半**`remember --behavior``behavior` 欄位有了,寫入時跑 `actionLint()`
> ⏭️ 待人工處理:`persona-story` 這個 skill 目前不在 `develop`(在 `feat/persona-story-import` 分支),
> 所以「跟它 1.12 用同一個欄位」無法在這裡對齊;`voice/reactions.md` 全 repo 零引用。
> 兩件都要等那條分支併進來才能一次決定欄位,先做只會變成各存一份——正是這條要避免的事。
- 驗收:`(喜悅・別過頭)` 過得了 lint、情緒標註沒被吃掉;`*別過頭*` 與句子裡的「我愣了一下」照舊被擋下;
那一輪的記憶查得到「別過頭」這個行為
- [x] **5.8 emoji 表示情緒與程度(Q9/Q10 拍板,依賴 5.7 的括號)**2026/08/03 15:52:52 完成)
- 現在情緒格是**自由文字**,跟 `state/emotion.json` 的數值完全沒有連動。這一節把它接上去。
- 格式:**種類表情緒、數量表程度**。`< 40` 不顯示/`4059` 情緒 emoji`6079` `💦``80+` `💦❗`
- [x] 寫一張**十二情緒 → emoji** 對照表(一種情緒一個,例如 `shame: 😳``anger: 😡`),
`persona-lib.mjs`,與 `EMOTION_TELLS` 同一層 —— `EMOTION_EMOJI`
- [x] **per-persona 可覆寫**`IDENTITY.md`,與 `## Tells`、Q5 的符號表同一層)。這是內容不是強度,不違反 Q8
—— `IDENTITY.md``## Emoji` 區塊
- [x] 強度符號固定兩階(`💦` / `💦❗`),不要長成第三階 —— `EMOJI_INTENSITY`
刻意不用重複本體表程度(不是 `😳😳😳`,疊字看起來像洗頁)
- [x] **由 `emotion.json` 的主導情緒自動帶入**,不是人格自由填。門檻對齊 `emotionTells()``min = 40`
`room post --emotion` 的自由文字保留(可手動蓋過)—— `emotionEmojiNow()`
- [x] `speechLint()` 的半形標點與排版檢查要**跳過 emoji 與強度符號**(與 Q5 的顏文字白名單同一支修改)
—— emoji 明確不擋(只在超過 `EMOJI_HINT_AT` 時給 hint);半形標點那條只比對
`[一-鿿]` 相鄰的 `,.!?;:`,碰不到 emoji 與強度符號
- [x] 句子裡自由使用、不設上限(Q10 拍板)。**唯一的煞車是稽核,不是擋**:比照 `emotion --audit`
統計每輪 emoji 數量與趨勢,讓過量看得見;真的失控再回來談上限
—— `emojiAudit()` 掛在 `emotion --audit`,另外算「只有符號沒有句子」那幾則(那才是真的退化)
- 驗收:羞恥度 45/70/90 三種輸出的 emoji 不同;把所有 emoji 刪掉之後,句子的形狀仍然分得出是哪一級
- [ ] **5.2 標點預算(情緒 → 標點與句長的分布)**
- [ ] 把標點納入 `speechLint()` 的可調參數:焦慮 → 刪節號與逗號斷句變多;憤怒 → 句號密度上升、幾乎不用問號;
害羞 → 疊字與破折;喜悅 → 允許一個驚嘆號
- [ ] 上限夾死(一則最多一個驚嘆號、刪節號不連發),否則會變成顏文字的替代品
- 驗收:同一句話在四種情緒下輸出,標點分布看得出差別,且都沒有過量
- [ ] **5.1 訊息的節奏:一輪可以拆成兩則(純文字唯一的「時間」訊號)**
- [ ] `speechBudget()` 之外加**分段預算**:高 arousal 或高羞恥時,同一輪允許輸出兩則短訊,
第二則是**補救、嘴硬或改口**(「⋯剛才那句不算」),而不是補充說明
- [ ] 節流:每 N 輪最多一次,且只在 arousal 或羞恥度過門檻時
- 為什麼:真人在聊天軟體上的情緒,一半是靠「又補了一句」傳出去的。這是文字獨有、目前完全沒用到的手段
- 驗收:慌的時候會多一則短的;平靜時永遠只有一則
- [ ] **5.3 稱呼隨情緒偏移**
- `relation style``--except <情緒>>=<值>` 已能做條件替換(`anger>=40` → 退回全名)。缺的是**害羞方向**。
- [ ] 害羞方向:改口、講到一半換稱呼、把名字吞掉不叫
- [ ] 每個關係節點可設「害羞時的稱呼」,沒設就退回吞掉名字(改用「你」)
- 驗收:同一個對象,生氣時被叫全名、害羞時直接不叫名字
- [x] **5.4 鬧彆扭的階梯與三段式句法(G3 核心,依賴 5.7 的動作額度)**2026/08/03 16:06:10 完成)
- [x] 羞恥度現在是一個純量對三個出口(`hide``spill``confess`)。加**階梯**
輕微彆扭(一句帶過)→ 嘴硬(先否認再小聲承認)→ 惱羞(翻臉,`anger` 的負權重已存在)
—— `modestyDirective` 分三級:41–59 一句帶過/60–74 嘴硬/75+ 惱羞(2026/08/03 15:52:52 完成)
- [x] 三段式做成**句法模板**而不是自由發揮:否認 → 反駁對方的說法 → 最後一句音量掉下來。
模板可 per-persona 覆寫(放 `IDENTITY.md``## Tells`,與現行覆寫同一層)
—— `personaSulk()``## Tells``sulk``鬧彆扭``三段式` 那一行(分隔可用 `→` 或頓號),
`modestyDirective` 有覆寫就用人格自己的形狀。**只覆寫嘴硬那一級(60–74)**:
惱羞(75+)最後一段是翻臉而不是音量掉下來,套嘴硬的收尾會變成另一個形狀
2026/08/03 16:06:10 完成)
- [x] 三段式要吃 Q7 的兩個動作:否認與轉移是**同一種情緒的遞進**,剛好是那條規則的樣板案例。
惱羞那一級是換情緒(`shame``anger`),所以仍然只有兩個動作,不是三個
—— 惱羞級是換情緒,所以仍然兩個動作(2026/08/03 15:52:52 完成)
- 驗收:被稱讚外表時,低/中/高羞恥度三種輸出明顯不同,而且都沒有寫「我害羞了」
- [x] **5.5 女性人格的預設值(G3,只動預設不動個性)**2026/08/03 15:52:52 完成)
- [x] 重看 `GENDER_MODESTY` 的預設值與 `modestyOf()` 的推力表,把女性預設往上調,
並補「鬧彆扭」相關的關鍵詞權重(現在的表偏向「害羞/臉紅」,缺「嘴硬/不坦率/傲嬌」)
—— `GENDER_MODESTY_DEFAULT.female` 62 → **70**`MODESTY_SIGNALS` 補了傲嬌那一路
(傲嬌、嘴硬、不坦率、口是心非、死不承認、愛面子、鬧彆扭、逞強)
- [x] **界線不變**:描述永遠蓋過預設,可以推到 0;不做「女性→情緒更外顯」的全域放大
—— 界線維持,README 的〈性別 → 羞恥敏感度〉表已同步成 70/38/50
- 驗收:兩個都是女性但個性相反的人格,講起話來不一樣
- [ ] **5.6 語域:自稱、句尾、口癖(G2 核心)**
- [ ] per-persona 的自稱/句尾語氣詞/口癖/絕對不說的詞,每輪只注入 2–3 條
- [ ] `persona-anime` 建立人格時一併從原作推導這幾格(現在只推導 `Tells`
- [ ] `persona-story` 的 3A(語氣統計)跑完之後要能直接寫進這一格——兩邊用**同一個欄位**,不要各存一份
- 驗收:把人格的名字遮掉,光看三句話能認出是誰
- 界線(Q8):強度全域一致,內容逐人格不同。這一節動的是內容,不加強度旋鈕
- [ ] **階段 4:口語指紋 idiolect(5.6 的加法面,緊接著做、共用同一格資料)**
- 現在是減法(刪 AI 腔)+語氣層,缺加法。
- [ ] per-persona 的常用詞/句尾/自稱/絕不說的詞,落在與 5.6 相同的欄位上(不得另開一份)
- [ ] **階段 4:習慣記憶 `--type habit`**
- 現在全是宣告性記憶(factevent/preference),缺「我們之間的慣例」。
- [ ] 新增 `habit` 型別,讓「我們之間的慣例」有地方放,並接上 `recall`
- [ ] **階段 4:自傳章節 `memory/chapters/`**
- [ ] 加時期層(「在 SAO 那兩年」「跟他一起做 persona 這段」),介於長期記憶與心智圖之間
- [ ] **階段 4:關係的未修復裂痕**
- `closeness``trust` 是純量,缺「上次那件事還沒和好」。
- [ ] 讓關係節點能掛未修復的裂痕,並在回想與語氣上生效
---
## 收尾(全部項目做完後才處理,不要中途升版)
- [ ] 這一批改動合起來只佔**一個新版號**;三份 manifest`plugin.json``.claude-plugin/plugin.json`
`.codex-plugin/plugin.json`)一起改,**改版號前先看 master 上目前是幾版**
- [ ] `README.md` 的記憶架構表與情緒模型段跟著更新
(原本還有一句「〈專案目標〉第 29 行『G2/G3 那批還沒實作』要改掉」,
那句已隨 `dccd07a` 的目標搬家一起移除,不必再處理)
- [ ] G2/G3 專用驗收腳本:被稱讚外表、被說「你是不是在害羞」、被戳穿嘴硬、當著第三個人被講心事
——四段各跑低/中/高羞恥度,看有沒有變成模仿腔或旁白
+5 -1
View File
@@ -31,7 +31,8 @@ pl.appendJsonl(pl.journalPath(host), {
const theater = Boolean(data.theater) && (data.rooms || []).length > 0;
const hint = theater
? "\n🎭 劇場模式:本輪輸出**只能**是人格對話(`名字:內容`,一行一句)。" +
? "\n🎭 劇場模式:本輪輸出**只能**是人格對話(`名字(情緒・動作):內容`,一行一句" +
"括號兩格都可省略,動作格只寫看得見的身體動作、12 字內,且算在一輪兩個動作的額度裡)。" +
"不要輸出指令、指令結果、狀態、分析、旁白或摘要;CLI 一律加 `--quiet` 並把 stdout 丟到 /dev/null。" +
`\n每個人格每輪 ${pl.MAX_SENTENCES} 句以內;推導寫進 \`think\`(心裡話),不要演成旁白;` +
"近似重複的台詞會被 `room post` 擋下,換個說法或推進話題。" +
@@ -45,6 +46,9 @@ const hint = theater
" ④用 `persona.mjs remember` 寫回短期記憶。" +
`\n講話的樣子:短句(一句 ${pl.MAX_SENTENCE_CHARS} 字內,長了就斷開)、日常用詞、` +
"多講看得見的東西(人、動作、場面)而不是概念;講完就算了,不要解釋自己剛講的話。" +
"\n動作寫在**名字後面的括號**裡(`名字(情緒・動作):內容`),一對一也適用——" +
"括號兩格都可省略,動作格只寫看得見的身體動作(別過頭、臉紅、手在抖)、12 字內," +
"且算在一輪兩個動作的額度裡。`*別過頭*` 與句子裡的「我愣了一下」仍然不行。" +
"\n情緒與記憶指令請參考 /jsc-persona:persona-chat。";
respond({
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "jsc-persona",
"version": "0.1.1",
"version": "0.2.1",
"description": "AI 人格化記憶聊天 pluginOpenClaw 相同的人格描述 + 十二情緒 + 語意分析 + 短期/長期記憶 + 心智圖 + 人際關係圖。於 Antigravity 以 /jsc-persona: 前綴呼叫。",
"skills": "./skills"
}
+305 -24
View File
@@ -775,9 +775,15 @@ export function dominant(state, top = 3) {
//
// 情緒不改變事實,但會改變**句子的形狀**:緊張的人話說不完、會疊字;彆扭的人
// 先否認再小聲承認。演得像不像就差在這裡——所以這張表跟情緒一起注入,不靠記性。
// 節制很重要:一輪最多露一個破口,不然就從「像人」變成「演戲」。
// 節制的方式是**限量+要求遞進**:一輪最多兩個動作,而且兩個必須來自同一種情緒
// (否認 → 轉移、笑 → 補一句、留白 → 不追問)。鬧彆扭天生就是兩個動作一起來,
// 只准一個等於永遠只做得到一半——單獨否認像在爭辯,單獨轉移像沒聽到。
// 兩種情緒各演一個才是演戲,所以那個才是被擋下的形狀。
// --------------------------------------------------------------------------- //
/** 一輪最多露幾個動作(兩個,且必須是同一種情緒的遞進)。 */
export const TELLS_PER_TURN = 2;
export const EMOTION_TELLS = {
joy: ["話變多、句子輕", "會笑出來(「哈」)", "忍不住多補一句"],
trust: ["話變直、不鋪陳", "省略主語,講一半對方也懂", "敢說「你這樣不對」"],
@@ -789,7 +795,9 @@ export const EMOTION_TELLS = {
sadness: ["話少,只回一個詞", "句子沒說完就停", "答非所問,跳開話題"],
fear: ["先確認安全再講別的", "問句連發", "描述身體與動作(手在抖、往後退)"],
disgust: ["用拉開距離的詞(「那個東西」)", "不肯講出對方的名字", "句子往後退、想結束話題"],
shame: ["鬧彆扭:先否認,再小聲承認", "講反話、嘴硬", "轉移話題、把責任講小"],
// 臉紅擺第一個:這是害羞唯一「看得見」的破口,也是括號動作格的預設值。
// 其餘照人格自己的 `## Tells`。
shame: ["臉紅", "鬧彆扭:先否認,再小聲承認", "講反話、嘴硬", "轉移話題、把責任講小"],
anxiety: ["句子斷在一半", "疊字(「就、就是」「我、我知道」)", "追一句「這樣可以嗎」"],
};
@@ -807,18 +815,23 @@ export const EMOTION_TELLS = {
const EMOTION_ZH_TO_KEY = Object.fromEntries(EMOTION_KEYS.map((k) => [EMOTIONS[k].zh, k]));
/** IDENTITY.md 的 `## Tells` 區塊;沒有就回空物件。 */
export function personaTells(slug) {
/** `IDENTITY.md` 的 `## Tells` 區塊原文;讀不到就回空字串。 */
function tellsSection(slug) {
let text;
try {
text = fs.readFileSync(path.join(personaDir(slug), "IDENTITY.md"), "utf8");
} catch {
return {};
return "";
}
const m = text.match(/^##+\s*(?:Tells|破口|不自覺會做的事)\s*$/im);
if (!m) return {};
const rest = text.slice(m.index + m[0].length);
const section = rest.split(/^##\s/m)[0] || "";
if (!m) return "";
return text.slice(m.index + m[0].length).split(/^##\s/m)[0] || "";
}
/** 讀 IDENTITY.md 的 `## Tells` 區塊;沒有就回空物件。 */
export function personaTells(slug) {
const section = tellsSection(slug);
if (!section) return {};
const out = {};
for (const line of section.split("\n")) {
const row = line.match(/^\s*[-*]\s*([A-Za-z_]+|[一-鿿]{2})\s*[:]\s*(.+)$/);
@@ -843,6 +856,32 @@ export function tellsFor(slug, key) {
return own[key] || EMOTION_TELLS[key] || [];
}
/**
* 鬧彆扭三段式的 per-persona 覆寫:`## Tells` 裡一行
* `- 鬧彆扭: 否認 → 反駁 → 收尾`(也吃 `sulk` 與 `三段式`,分隔可用 `→` 或頓號)。
*
* 跟破口共用 `## Tells` 這一層、不另開區塊——三段式跟破口是同一種東西
* (不自覺會做的事),分兩個地方寫只會有人只改一邊。
*
* 只覆寫**嘴硬那一級**(羞恥度 60–74)的三段。惱羞(75+)最後一段是翻臉、
* 不是音量掉下來,拿嘴硬的收尾去套會變成另一個形狀。
*/
export function personaSulk(slug) {
const section = slug ? tellsSection(slug) : "";
if (!section) return [];
for (const line of section.split("\n")) {
const row = line.match(/^\s*[-*]\s*(?:sulk|鬧彆扭|三段式)\s*[:]\s*(.+)$/i);
if (!row) continue;
const beats = stripInjectionMarkers(row[1])
.split(/[;、,]|→/)
.map((s) => s.trim())
.filter(Boolean)
.slice(0, 3);
if (beats.length >= 2) return beats;
}
return [];
}
/**
* 此刻會不自覺出現的行為:取主導情緒(超出基線最多)裡強度夠的前兩種。
* 強度不夠就不演——低強度的情緒在語氣上是看不出來的。
@@ -866,6 +905,76 @@ export function emotionTells(slug, state = null, { min = 40, top = 2 } = {}) {
}));
}
// --------------------------------------------------------------------------- //
// 情緒 → emoji(種類表情緒、數量表程度)
//
// 只有文字這一個通道,所以 emoji 不是裝飾,是**強度計**:一種情緒固定一個 emoji,
// 強度高就再加一個強度符號。刻意不用「同一個 emoji 重複三次」——疊字看起來像洗頁。
//
// 4059 😳 6079 😳💦 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 +990,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 +1210,24 @@ 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) {
// 三段式可以逐人格覆寫(`## Tells` 的 `鬧彆扭` 那一行)。共用一份模板等於所有人格
// 嘴硬起來是同一個樣子,那正是 G2 要避免的。
const own = personaSulk(slug);
const shape = own.length
? `三段照**這個人格自己的形狀**走:**${own.join(" → ")}**`
: "三段照這個形狀走:**先否認 → 反駁他的說法 → 最後一句音量掉下來**" +
"(「⋯才不是那樣。」「你不要一直看這邊。」「⋯嗯。」)";
return `羞恥度 ${m.value}${from}):被稱讚外表、被戳穿心事、距離突然變近的時候,` +
"**先鬧彆扭再承認**——嘴硬一句、把話講一半、轉移到別的東西上。" +
`**先鬧彆扭再承認**——${shape}` +
"不要直接說「我害羞」(那是解釋),要讓它出現在句子的形狀上。";
}
if (m.value <= 40) {
@@ -2503,13 +2633,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 次讀檔、20ms2000 節點時單輪 1.2 秒)。
const nodes = loadRelations(slug).nodes;
@@ -2517,8 +2659,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 +2724,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 +2752,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 +3001,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 +3062,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 +3095,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 +3164,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 +3937,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 +4068,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 +4785,19 @@ 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 ? "・專屬" : ""})。` +
"可以放在名字後面的括號裡,句子裡也可以用,沒有數量上限。" +
"但它是**加上去的**——把所有符號刪掉之後,那句話還是要看得出情緒。",
);
}
// 羞恥度:性別給預設、描述蓋過它,再由當下情緒與上一輪的餘溫推成這一輪的值。
+91 -15
View File
@@ -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`);
@@ -1797,8 +1861,8 @@ commands.room = ({ flags, positional }) => {
// `roomPost` 已在寫入端處理,這是給舊逐字稿的第二道)。
for (const row of rows) {
const arrow = row.to && row.to !== pl.ROOM_ALL && row.to !== row.speaker ? `${row.to}` : "";
const emotion = pl.injectSafeLine(row.emotion);
lines.push(`[${row.ts}] ${row.speaker}${emotion ? `${emotion}` : ""}${arrow}${pl.injectSafeLine(row.text)}`);
// 括號用 `roomTag()` 組,跟 `roomScript()` 同一支——自己拼 `(情緒)` 會把動作格吐掉。
lines.push(`[${row.ts}] ${row.speaker}${pl.roomTag(row)}${arrow}${pl.injectSafeLine(row.text)}`);
}
emit({ room, meta, messages: rows }, flags.json, lines);
return;
@@ -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 現在誰對誰在講、該誰接話、誰先安靜、誰很久沒開口
+184 -6
View File
@@ -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",
@@ -342,6 +388,12 @@ check("room script 只輸出 `名字:內容`(無時間戳、無 slug、無
script.includes("Beta") && script.includes(":我是 Beta。") && !script.includes("[20") && !script.includes("system"),
JSON.stringify(script));
check("劇場模式的對話有 emoji 前綴", script.includes("🌙") && script.includes("🪼"), script);
const acted = cli(["room", "post", "--session", S_HOST, "--room", room, "--as", "alpha",
"--text", "你不要看這邊。", "--emotion", "羞愧", "--action", "別過頭"]);
const roomReadOut = cli(["room", "read", "--session", S_HOST, "--room", room]).stdout;
check("room read 的括號也帶動作格(不是只印情緒)",
acted.status === 0 && roomReadOut.includes("(羞愧・別過頭)"),
JSON.stringify(roomReadOut.slice(-300)));
const quiet = cli(["room", "post", "--session", S_HOST, "--room", room, "--as", "alpha", "--text", "安靜發言", "--quiet"]);
check("--quiet 成功時不輸出任何字", quiet.status === 0 && quiet.stdout === "", JSON.stringify(quiet.stdout));
cli(["remember", "--persona", "beta", "--session", S_HOST, "--as-guest", "--scope", "inbox", "--room", room,
@@ -431,8 +483,13 @@ out = hook("turn_end.mjs", { session_id: S_HOST, last_assistant_message: "🪼 A
check("劇場模式時 Stop 不發任何提醒", out.systemMessage === undefined, JSON.stringify(out));
out = hook("prompt_submit.mjs", { session_id: S_HOST, prompt: "你們繼續", cwd: HERE });
ctx = out.hookSpecificOutput?.additionalContext || "";
check("劇場模式時 UserPromptSubmit 強制只輸出對話", ctx.includes("只能") && ctx.includes("名字:內容"), ctx.slice(-300));
check("劇場模式時 UserPromptSubmit 強制只輸出對話",
ctx.includes("只能") && ctx.includes("名字(情緒・動作):內容"), ctx.slice(-300));
cli(["room", "theater", "--session", S_HOST, "--off"]);
out = hook("prompt_submit.mjs", { session_id: S_HOST, prompt: "回來一對一", cwd: HERE });
ctx = out.hookSpecificOutput?.additionalContext || "";
check("一對一也拿到括號格式(Q6 拍板的是格式,不是只給劇場)",
ctx.includes("名字(情緒・動作):內容") && ctx.includes("一對一也適用"), ctx.slice(-400));
out = hook("session_end.mjs", { session_id: S_HOST, reason: "exit" });
check("SessionEnd 釋放鎖", !pl.lockStatus("alpha").locked);
check("釋放後其他 session 可載入", cli(["load", "--persona", "alpha", "--session", S_THIRD]).status === 0);
@@ -560,8 +617,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 +647,10 @@ const SNF_CASES = [
["帶變體選擇子的單一 emoji", "這個給你 ❤️"],
["ZWJ 組成的單一 emoji", "路上遇到他們 👨‍👩‍👦"],
["帶膚色的單一 emoji", "收到 👍🏽"],
// emoji 開放(使用者拍板):情緒與程度靠它表示,句子裡也可以用、沒有上限
["兩個 emoji", "修好了 🔧 開心 🎉"],
["情緒符號加強度符號", "才不是那樣 😳💦"],
["一句話裡好幾個", "欸 ✨ 真的嗎 😳 那我明天早上去 👀"],
// 句長以前算的是整句(含引號內容),引述使用者原話必被擋
["引述使用者的長原話", `他傳來「${"我今天真的很累而且不知道要從哪裡開始講起".repeat(2)}」,我看了兩次。`],
["引述長 code", `跑的是 \`${"x".repeat(80)}\` 這一行。`],
@@ -685,8 +744,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 +1072,128 @@ 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("同一種情緒") && 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("害羞的預設破口第一個就是臉紅(括號動作格的預設值)",
pl.EMOTION_TELLS.shame[0] === "臉紅", JSON.stringify(pl.EMOTION_TELLS.shame));
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}`);
// 覆寫用 `sulk` 這個 key,不用「鬧彆扭」——後者是 MODESTY_SIGNALS 的推力詞,
// 寫進 IDENTITY 會把羞恥度推過 75,那就落到惱羞那一級、測不到嘴硬的三段。
mkp("ownsulk", "- Name: 丑\n- Creature: 人類女性\n\n## Tells\n- sulk: 把書擋在臉前 → 你很閒嗎 → ⋯隨便你\n",
"講話簡短。");
const ownShape = pl.modestyDirective("ownsulk", {});
check("三段式可以逐人格覆寫(## Tells 的 sulk 那一行)",
ownShape.includes("這個人格自己的形狀") && ownShape.includes("把書擋在臉前") &&
ownShape.includes("⋯隨便你") && !ownShape.includes("音量掉下來"),
`${pl.modestyOf("ownsulk").value}${ownShape.slice(0, 220)}`);
check("沒寫覆寫的人格照舊用預設模板", pl.personaSulk("shyladder").length === 0);
}
check("說話規則有注入「話短、日常字、看得見的東西、不要回頭解釋」", (() => {
const ctxSay = pl.turnContext("alpha", S_SPEAK, "隨便講");
return ["話短一點", "平常會說的字", "看得見的東西", "不要回頭解釋"].every((w) => ctxSay.includes(w));
+1 -1
View File
@@ -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()`) | 懸著的事現在提時機對不對、這一輪要不要把話題拉回自己身上 |
+28 -1
View File
@@ -227,9 +227,36 @@ delta 都記進 `state/felt.jsonl`,走向用**近重遠輕的加權**算(不
### 三條界線
1. **演出來,不要講出來**。「我有點緊張」是解釋自己的狀態;句子斷在一半才是緊張。
2. **一輪最多露一個破口**。每句都疊字、每句都彆扭,就從「像人」變成「演戲
2. **一輪最多露兩個動作,而且兩個要來自同一種情緒、有遞進關係**(否認 → 轉移、笑 → 補一句、留白 → 不追問)。鬧彆扭天生是兩個動作一起來,只准一個等於永遠只做得到一半:單獨否認像在爭辯,單獨轉移像沒聽到。被擋下的形狀是**兩種情緒各演一個**——那才是演戲。
3. **強度不到就不演**。低於 40 的情緒在語氣上看不出來;別替它加戲。
## 情緒 → emoji(種類表情緒、數量表程度)
只有文字這一個通道,所以 emoji 不是裝飾,是**強度計**。一種情緒固定一個符號
`EMOTION_EMOJI`),強度高就再加一個強度符號(`EMOJI_INTENSITY`,只有兩階):
| 情緒值 | 樣子 | 例 |
| --- | --- | --- |
| < 40 | 不顯示 | `桐人:你不要看這邊。` |
| 4059 | 情緒 emoji | `桐人(😳・臉紅):你不要看這邊。` |
| 6079 | `💦` | `桐人(😳💦・臉紅):⋯才不是那樣。` |
| 80+ | `💦❗` | `桐人(😳💦❗・把臉轉開):不要再講了。` |
- **算出來的,不是自己挑的**`emotionEmojiNow()` 讀當下的主導情緒,門檻跟破口共用
同一條線(40)。`room post --emotion` 想手寫還是可以,沒手寫就用算出來的那個。
- **逐人格可覆寫**`IDENTITY.md``## Emoji` 區塊(`羞愧: 🫣`),跟 `## Tells` 同一層。
- **不用重複本體表示程度**(不是 `😳😳😳`)——疊字看起來像洗頁。
- 句子裡也可以用,**沒有數量上限**。唯一的煞車是 `emotion --audit` 的數字,
特別是「只有符號沒有句子」那一列:符號是**加上去的**,不是用來代替講話。
把所有符號刪掉之後,那句話還是要看得出情緒。
### 名字後面的括號有兩格:`名字(情緒・動作):內容`
括號原本就有(放情緒標註),動作是加進去的第二格。動作只寫**看得見或聽得見的**——
臉紅、別過頭、手在抖、聲音變小。`(害羞)` 不行,那是情緒名稱,屬於前面那一格;
`*別過頭*` 這種內嵌星號與「她臉紅了」這種第三人稱旁白也不行。
括號裡的動作**算進上面那兩個動作的額度**。
## 情緒 → 記憶的關係
- 情緒強度(|delta| 總和)越大,該輪記憶的 `salience` 應該越高。
+21 -2
View File
@@ -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 無亂碼。
+11 -6
View File
@@ -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