Files
persona/README.md
T
jiantw83andClaude Opus 5 23751c2c05 feat(emotion): 情緒先行、走向、飽和與偏差稽核(四層,v0.0.7 同批)
參考兩篇 ithelp 文章(Day25 情緒辨識模組、Day26 整合與情緒記憶池)的**架構**,
實作全部重寫——它們的六類關鍵字 first-match、罐頭哲學回應與模式切換都比現有的
十二情緒模型差,照抄是降級,而且它的罐頭回應正是上一個 commit 剛封殺的罐頭同理心。

一、輸入端:`readUserEmotion(text)`
  在這之前情緒**全部是人格自己填的** `--apply`,而人不會主動給自己扣分——正向一路貼頂、
  負向整天不動。現在每輪先讀對方那句話:十二類加權詞表(強3/一般2/弱1)、程度副詞乘係數、
  **否定會擋掉那次命中**(「我不害怕」不算 fear)、引號與 `code` 內不比對(提及不算使用)、
  標點只放大既有訊號不憑空長出新情緒(「居然修好了!!」不會被讀成憤怒)。
  強度用飽和曲線不是線性乘。回的是**訊號不是判定**,人格可以推翻。`emotion --read` 可單測。

二、怎麼接:`RESPONSE_STANCE`
  十二種各一條**動作**(悲傷→先接住不要急著給解法;憤怒→不辯解,先認可能認的那一小塊),
  不是罐頭句——罐頭句由 `SPEECH_BLACKLIST` 擋著。

三、走向:`state/felt.jsonl` + `feltTrend()`
  每輪的偵測結果與套用的 delta 都記下來,近重遠輕的加權算最近 5 輪的趨勢
  (不用多數決:會被離群值主導又丟掉強度),注入「他最近 3 輪的走向:悲傷 ↘ 在退」。
  新檔案已歸到 Gitea 檔案區(高頻)。

四、情緒調節:飽和與單輪預算
  `applyEmotion` 不再是加完直接 clamp。飽和=越接近端點同方向漲越慢(headroom^K,K=1,
  往 baseline 回的方向不壓);單輪預算=所有 |delta| 總和上限 60,超過等比例縮小。
  `emotion --audit` 印出最近幾輪往舒服/往難受的比例,正向 ≥90% 會被點名。
  依據:今天 85 次套用讓 joy/serenity/gratitude/anticipation 都到過 100、負向均值整天 14–17。

測試:297 項全過(新增 24 項,含讀取的 SF6/SNF4、飽和、預算、回歸方向不壓、走向、audit、
--read 不改狀態)。既有的「情緒有被施加」改成符合飽和後的語意。

版本:與 PR #10 同一批未合併的改動,維持 0.0.7。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 04:21:08 +00:00

593 lines
41 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# jsc-persona — AI 人格化記憶聊天
一套讓 AI **成為某個人**、而不只是回答問題的 plugin。
**OpenClaw 相同的人格描述**`IDENTITY.md` 五欄位 + `SOUL.md` 四段落)建立人格,
再用 **hook 強制**的人格鎖與跨人格隔離,把 **十二情緒**、**語意分析**、
**短期/長期記憶**、**心智圖/思維導圖**、**人際關係圖** 綁在一起。
可同時安裝於 **Claude Code、Codex、Antigravity、OpenCode**
skills 是共通標準,**鎖與隔離的強制執行需要 hook,目前只有 Claude Code 支援**(見「跨助理支援度」)。
---
## 十四條硬規則
| 規則 | 怎麼做到 |
| --- | --- |
| **1a. 用與 OpenClaw 相同的描述建立人格** | `persona-create` 逐項索取 `Name` / `Creature` / `Vibe` / `Emoji` / `Avatar`(連括號提示文字都照 OpenClaw 原文),`SOUL.md` 沿用 `Core Truths` / `Boundaries` / `Vibe` / `Continuity` 段落結構 |
| **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`(乾淨對話稿)。**同場也分一對一與全場**:`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 <slug>`;載入即取得獨占鎖並綁定該 session。唯一例外是使用者自己設的**預設人格**(`default --persona <slug>`)——設了才自動載入,沒設就什麼都不做 |
| **7. 短期記憶轉入長期記憶有成文條件** | `R1``R6` 六條規則寫在程式裡(`promotionCandidates`),`candidates` 子指令會列出達標的候選與依據,hook 在達標時提醒固化 |
| **8. 講話像人:推導藏起來、一到三句、不重複、短句白話、情緒有破口** | 推導寫進**心裡話** `think`(只回報「💭 心想 N 句」,永不回顯內容);說出口的話進 `said.jsonl`,下一輪注入「最近說過的話」提醒別重講;`room post` 直接**擋下**近似重複(字元 bigram+字集合相似度 ≥ 0.72)與超過三句的發言。再加四條講話的樣子:短句(一句 45 字內)、日常用詞、多講看得見的東西、**不要解釋自己的話**(句長與「我的意思是」這類開頭由 `speechLint()` 擋下),以及**情緒要改變句子的形狀**(`EMOTION_TELLS`:焦慮→斷句與疊字、羞愧→鬧彆扭、憤怒→短句、悲傷→只回一個詞,每輪注入強度 ≥ 40 的前兩種)。最後一層是**不說 AI 才會說的話**:罐頭同理心(「這個我懂」)、頒獎開場、交差句、預告、說教腔、假坦白開場、罐頭收尾、立場真空、無來源權威、旁白演情緒、中國用語、半形標點與排版殘留,由 `SPEECH_BLACKLIST` 擋下(模式借自 speak-human-twMIT;引號內與 `` `code` `` 不比對,提及不算使用);換來的義務是**講自己的過去要有出處**(「我以前⋯」只能講 `recall` 查得到的事)——劇場模式同樣適用 |
| **9. 人格可以匯出匯入** | `export` 把身分/情緒/記憶/心智圖/關係圖打包成單一 JSON bundle(可 `--gzip`、附 sha256),`import` 還原或換名複製;**不帶**載入鎖與 guest 租約,`journal/` 要明確 `--with-journal` 才帶走 |
| **10. 人格有編號** | 編號 = **英文名全大寫 + 兩位索引**(同名才遞增):`ASUNA-01`、`YUI-01`、`ASUNA-02`。編號同時是新人格的本機目錄名與 Gitea 存取庫名稱;中文名先轉羅馬拼音並跟使用者確認拼法 |
| **11. 人格存在 Gitea,依更新頻率分區** | 每個人格一個**私有存取庫**(庫名=編號)。**檔案區**放每輪都在變的活狀態(情緒/短期記憶/心裡話/說過的話/逐字),**Wiki 區**放低頻的身分與長期結構(IDENTITY/SOUL/長期記憶/心智圖/關係圖)當設定百科。本機仍是工作副本,同步失敗不阻斷對話 |
| **12. 形象圖來自高解析度官方圖,去背後合成** | `icon search` 從 Fandom 撈官方圖並依「解析度+是否官方設定稿」排序(設定稿多為透明/白底、773×1056 起跳)→ `icon measure` 確認臉夠大、背景好去 → `icon cutout` 去背成透明 PNG(原生 alpha /單色底/GrabCut 三條路徑)→ `icon generate --from-cutout` 裁頭肩、合成到角色配色的漸層底。找不到官方圖才退回依人格資料重繪的向量形象 |
| **13. 睡眠把一天收成能留下來的形狀** | `persona-sleep`:需要判斷的(固化什麼/忘掉什麼/日記)由**人格自己**做,機械性的由 `sleep` 子指令做(關係時間戳→裁短期→收思維導圖→情緒衰減 8 小時→重建索引→修剪 said→壓縮 journal→兩區 push+驗證)。主人格可透過 `persona-sleeper` sub agent 請別的人格去睡——**那是它本人在睡**,回傳值只有「睡完了沒、哪一步出錯」,不含任何記憶內容 |
| **14. Wiki 必須保存並同步形象圖** | `icon.svg`、`icon.png` 與 **`icon/` 資料夾(向量原稿 + 512/1024 高解析度)** 都同步到 Wiki 區,另有自動產生的 **Icon** 頁展示與來源。Wiki 頁面一律用 **Markdown 圖片語法**Gitea 只改寫這種語法為 `/wiki/raw/...`HTML `<img>` 會變成破圖),圖片保留資料夾結構、只有 `.md` 需要攤平。`icon generate` 推完會回頭驗證,另有 `sync verify` |
---
## 架構
```mermaid
flowchart TB
subgraph P["主程序(一個 session = 一個人格)"]
U["使用者訊息"] --> H1["UserPromptSubmit hook<br/>注入 情緒+短期記憶+命中的長期記憶+關係<br/>+心裡話+最近說過的話"]
H1 --> A["語意分析:意圖/主題/實體/情感/需求"]
A --> T["心裡話 think<br/>(推導只留在 inner.jsonl,不輸出)"]
T --> E["情緒評估 → 十二情緒 deltas"]
E --> R["以人格語氣回覆(1–3 句、不重複說過的話)"]
R --> W["記憶回寫(短期)"]
W --> H2["Stop hook:情緒衰減+續租+記說過的話+固化提醒"]
end
subgraph G["Sub Agent(受邀人格,唯讀)"]
GA["persona-guest"]
end
subgraph S["人格倉庫 ~/.claude/personas(工作副本)"]
PA["ASUNA-01/IDENTITY SOUL 記憶 情緒 心智圖 關係圖"]
PB["YUI-01/|…"]
RM[".rooms/room/transcript.jsonl"]
end
subgraph GT["Gitea(存取庫名稱 = 人格編號)"]
GF["ASUNA-01 檔案區<br/>高頻活狀態"]
GW["ASUNA-01 Wiki<br/>低頻設定百科"]
end
P -->|"只能碰自己"| PA
GA -->|"只能碰自己"| PB
P <-->|"唯一合法交流管道"| RM
GA <--> RM
PA -->|"每輪背景 push"| GF
PA -->|"固化/改身分/release"| GW
GF -.->|"載入時 pull"| PA
GW -.-> PA
```
## 人格倉庫(預設 `~/.claude/personas/<slug>/`,可用 `PERSONA_HOME` 覆寫)
```
<slug>/
├── IDENTITY.md # 身分卡:Name / Creature / Vibe / Emoji / AvatarOpenClaw 同欄位)
├── SOUL.md # 靈魂:Core Truths / Boundaries / Vibe / Continuity + 情緒傾向
├── AGENTS.md # 操作規則(與個性分離)
├── icon.svg / icon.png # 人格圖示(由編號/名字/emoji 決定,也是 Gitea 存取庫頭像)
├── USER.md # 對使用者的畫像(事實/推測分開)
├── state/
│ ├── lock.json # 載入鎖(session_id + 心跳租約)
│ ├── guests.json # guest 唯讀租約
│ ├── emotion.json # 十二情緒 levels / baseline / 半衰期
│ ├── inner.jsonl # 心裡話(推導過程;只回報「心想 N 句」,不說出口)
│ ├── said.jsonl # 說過的話(用來擋短時間內的重複發言)
│ ├── sync.json # Gitea 同步狀態(最後 push / pull
│ └── config.json # 含人格編號 code
├── memory/
│ ├── short-term.jsonl # 短期記憶(語意分析後;上限 240 筆 / 14 天)
│ ├── long-term/*.md # 長期記憶(一則一檔 + frontmatter
│ ├── INDEX.md # 長期記憶索引(自動產生)
│ └── inbox/room-*.jsonl # 當 guest 時留下的見聞,待本體消化
├── mindmap/
│ ├── semantic.mmd # 心智圖:概念的長期關聯(Mermaid mindmap
│ └── threads/*.mmd # 思維導圖:單一話題的推理鏈(Mermaid graph,短期)
├── relations/
│ ├── graph.json # 人際關係圖(親近度/信任度/連線)
│ └── graph.mmd # Mermaid 呈現(自動產生)
└── journal/YYYY-MM.jsonl # 原始逐字 + 情緒史(hook 自動寫)
(另有 `.sync/files/`、`.sync/wiki/`:兩個同步區的 git clone 快取,可安全刪除)
```
## 十二情緒
| 六正向 | 六負向 |
| --- | --- |
| 喜悅 `joy`、信任 `trust`、期待 `anticipation`、感激 `gratitude`、平靜 `serenity`、驚喜 `delight` | 憤怒 `anger`、悲傷 `sadness`、恐懼 `fear`、厭惡 `disgust`、羞愧 `shame`、焦慮 `anxiety` |
- 每種 0–100,各有**不同半衰期**(驚喜 60 分最快、信任 720 分最慢),每輪自動朝 `baseline` 指數衰減。
- 由十二情緒推導 `valence`(正向/中性/負向)與 `arousal`(高張/平穩/低張),決定語氣與句長。
- **主導情緒**取「超出基線最多」的前三名,所以個性底色不會永遠霸榜。
- 觸發規則與事件→delta 對照表:`skills/persona-chat/reference/emotions.md`。
**情緒先行:先讀對方那句話(`readUserEmotion`)。** 在這之前情緒**全部是人格自己填的**,
而人不會主動給自己扣分——結果是正向一路貼頂、負向整天不動。現在每輪注入之前會先讀一次
對方的話:十二類加權詞表(強 3/一般 2/弱 1)、程度副詞乘係數(超 ×1.6、有點 ×0.6)、
**否定會擋掉那次命中**(「我不害怕」不算 fear)、引號與 `` `code` `` 內不比對(提及不算使用)、
標點只放大既有訊號不憑空長出新情緒。回的是**訊號不是判定**,人格讀到的不一樣就以人格為準。
單獨測:`emotion --read "<他說的話>"`。
**怎麼接(`RESPONSE_STANCE`)**:注入的是**動作**不是句子——悲傷「先接住,不要急著給解法」、
憤怒「不辯解,先認可能認的那一小塊」、焦慮「給具體的下一步與時間點,不要給保證」。
罐頭同理心那類句子由講話規則擋掉。
**走向(`feltTrend`)**:每輪的偵測結果與套用的 delta 記進 `state/felt.jsonl`
用近重遠輕的加權算最近幾輪的趨勢(不用多數決——會被離群值主導又丟掉強度),
注入成「他最近 3 輪的走向:悲傷 ↘ 在退」。一直是同一種情緒就不要每輪用同一句接法。
**情緒調節**:delta 不再是加完直接 `clamp`。**飽和**——越接近端點同方向漲得越慢
`headroom^K`,永遠逼近 100 但到不了;往 baseline 回的方向不壓);**單輪預算**——
所有 `|delta|` 總和上限 60,超過等比例縮小。**偏差看得見**——`emotion --audit`
印出最近幾輪往舒服/往難受的總量與比例,正向佔 ≥ 90% 會被點名。
## 短期 → 長期的轉入條件(R1–R6)
寫在 `scripts/persona-lib.mjs` 的 `promotionCandidates()`,用 `candidates` 子指令查:
| 規則 | 條件 | 建議固化為 |
| --- | --- | --- |
| **R1** | 單筆顯著度 ≥ 60 | `event` / `fact` |
| **R2** | 同一 topic ≥ 3 筆,或 ≥ 2 筆且平均顯著度 ≥ 45 | `preference` |
| **R3** | 單筆情緒變動總量 ≥ 25 | `event`(帶情緒錨點) |
| **R4** | `intent=commit` 或命中承諾/界線關鍵詞 | `promise` / `boundary`salience ≥ 80,不可遺忘) |
| **R5** | 同一人物(entity)≥ 2 筆 | `relationship`(並更新關係圖) |
| **R6** | 短期記憶 ≥ 40 筆(容量壓力) | 依顯著度清出空間 |
沒命中任何規則的就讓它被裁掉——**遺忘是功能**。達標時 `Stop` 與 `remember` 都會提醒去跑
`/jsc-persona:persona-memory`。
## 講話像人(心裡話 / 一到三句 / 不重複 / 短句白話 / 情緒的破口 / 不說 AI 才會說的話)
AI 最容易露餡的六件事:把推理過程講出來、一次講一大段、換句話說同一件事、講完再解釋一遍、
說自己在緊張但句子完整得像稿子、開口先發一句「這個我懂」。六個對策:
| 機制 | 怎麼運作 |
| --- | --- |
| **心裡話** `think` | 語意分析、推論、盤算全寫進 `state/inner.jsonl`;這個指令**只印「💭 心想 N 句」**,內容永不回顯。下一輪 `<persona-context>` 會帶回最近三句,推論因此有連續性,但使用者只看得到狀態 |
| **一到三句** | `<persona-context>` 每輪注入上限;`room post` 對超過三句的發言直接拒收(`--force` 例外) |
| **不重複** | 說出口的話由 `Stop` hook 自動記進 `state/said.jsonl``said check` 可事前確認,`room post` 事中攔截。相似度=字元 bigram Jaccard0.4)+字集合 Jaccard0.6),≥ 0.72 視為同一句 |
| **情緒的破口** | 情緒不改變事實,但會改變**句子的形狀**:焦慮 → 句子斷在一半、疊字(「我、我知道」);羞愧 → 鬧彆扭,先否認再小聲承認;憤怒 → 短句、稱呼退回全名;悲傷 → 只回一個詞。十二情緒各自的破口寫在 `EMOTION_TELLS``emotionTells()` 每輪挑主導情緒裡強度 ≥ 40 的前兩種注入。三條界線:**演出來不要講出來**(「我有點緊張」是解釋,斷句才是緊張)、**一輪最多露一個破口**、**強度不到就不演**。混合狀態(緊張=焦慮+期待、害羞=喜悅+羞愧、賭氣=憤怒+悲傷…)的對照表在 `skills/persona-chat/reference/emotions.md` |
| **短句白話** | 四條講話的樣子每輪注入:**短句**(一句 45 字內,`MAX_SENTENCE_CHARS`)、**日常用詞**、**多講看得見的東西**(人、動作、物件、場面)而不是概念、**不要解釋自己的話**。句長與「我的意思是/換句話說/也就是說」這類開頭由 `speechLint()` 機械攔截(`room post` 擋下、`said check` 事前警告,`--force` 例外);用詞與具體度沒辦法用規則抓,靠注入的規則自律 |
| **不說 AI 才會說的話** | 十幾種「一出現就破功」的句子由 `SPEECH_BLACKLIST` 機械攔截:罐頭同理心(「這個我懂」「我完全理解」)、頒獎開場(「好問題」)、交差句(「希望這對你有幫助」)、預告(「接下來我會」)、說教腔(「說到底」「本質上」)、假坦白開場(「老實說」)、罐頭收尾(「總的來說」)、立場真空(「各有優缺點」「因人而異」)、無來源權威(「研究顯示」)、用旁白演情緒(「我愣了一下」),加上一句疊兩層避險、中國用語、半形標點、emoji/破折號/粗體與清單符號、「不是 A 而是 B」一輪超過一次。**誤殺防護**:引號與 `` `code` `` 裡的內容一律不比對(「我最近戒掉『賦能』這個詞」是提及不是使用),「老實說」只擋開場。模式借自 [speak-human-tw](https://github.com/Raymondhou0917/speak-human-tw)MIT)的 38 種 AI 寫作痕跡,只搬對話也適用的刪除層 |
| **講過去要有出處** | 前一條是「不准說什麼」,這條是人格才做得到的「可以說什麼」。文章改寫工具的界線是「人味是作者的,不是你的」——AI 沒有過去,所以不准寫「我以前錯了」。人格有過去:長期記憶、日記、關係圖、十二情緒。所以「我以前⋯」「我原本以為⋯」是**有出處的引用**,條件是先 `recall` 查得到;`said check` 遇到這類句子會印一行提醒(`level: "hint"`,不擋你,要你自己去驗),查不到就不要講 |
字集合權重較高,是為了分開「重排語序」與「換掉關鍵詞」這兩種很像但意義完全不同的情況:
```
「我等一下把報告寄給你」vs「等一下我會把報告寄給你」→ 0.779 擋下(用字幾乎相同=同一件事換句話說)
「你今天看起來很累」  vs「你今天看起來很開心」  → 0.687 放行(換了關鍵詞=新資訊)
```
視窗預設 120 分鐘、少於 8 個字的短附和(「嗯」「好啊」)不算重複。
## 人格圖示(SVG + PNG,零外部依賴)
建立人格**並補齊 IDENTITYSOUL 之後**產生,512×512。**優先是人物形象圖(看得到臉)**,
依環境有無工具分三種樣式:
流程:**找官方圖 → 量測 → 去背 → 裁頭肩 → 合成**
| 步驟 | 指令 | 產出 |
| --- | --- | --- |
| 1. 找圖 | `icon search --wiki <sub> --page <角色>` | 候選清單,📐 = 官方設定稿(優先) |
| 2. 量測 | `icon measure --photo <網址>` | 解析度、臉多大、背景透明/單色/有場景 |
| 3. 去背 | `icon cutout --photo <網址>` | `icon/portrait-cutout.png`(透明 PNG |
| 4. 合成 | `icon generate --from-cutout --palette ...` | `icon.svg` + `icon.png` + `icon/` 多解析度 |
去背三條路徑自動選:**原生 alpha**(官方設定稿常見,完美)→ **單色底移除**(很好)→
**GrabCut**(有場景時,邊緣普通;黑髮角色容易被誤切,這時該換設定稿)。
| 樣式 | 條件 | 長什麼樣 |
| --- | --- | --- |
| **`cutout`** | 有去背圖 → 最佳 | 官方原圖裁頭肩,疊在角色配色的漸層底上 |
| **`portrait`** | 沒有可用官方圖 | 依特徵重繪的人物頭像,五官俱全 |
| **`badge`** | 完全沒有參考圖 | 雙色漸層 + 編號前兩個字母 |
柵格器對每個圖形先算 bounding box 再逐點測試,1024×1024(3× 超取樣)約 1.8 秒。
**找到的圖片只能當底稿**:產出的 SVG 沒有 `<image>`、沒有 base64、沒有外連,每個像素都是畫出來的。
裁底稿需要的工具缺了,CLI **會印出安裝指令**,不會靜默降級:
```bash
python3 -m venv ~/.cache/jsc-persona/venv
~/.cache/jsc-persona/venv/bin/pip install pillow "opencv-python-headless<5" # OpenCV 5 拿掉了 CascadeClassifier
curl -sL -o ~/.cache/jsc-persona/lbpcascade_animeface.xml \
https://raw.githubusercontent.com/nagadomi/lbpcascade_animeface/master/lbpcascade_animeface.xml
```
(venv 放這個路徑會被自動偵測;也可用 `PERSONA_PYTHON` 指定。)
**配色一律取自實際看過的參考圖**
| 元素 | 取自照片的哪裡 |
| --- | --- |
| 對角漸層 | `hair`(髮色)→ `accent`(服裝主色) |
| 外框 | `eye`(瞳色) |
| 點陣紋 | `light`(最亮的部位) |
| 中央兩個字母 | 編號前兩字(`ASUNA-01` → `AS` |
`--palette` 必須配 `--source-url`(CLI 強制):配色是從哪張圖來的要留得下來。
兩個顏色一深一淺時(藍黑髮 + 淡粉洋裝),程式會把較亮的一端往較暗的壓到對比 ≥ 3.2,
確保字讀得到——顏色仍然是照片來的,只是收斂色階。
沒有參考圖時(原創人格)才退回雜湊配色:`sha256(編號|NameEmoji)` 決定漸層與點陣紋。
- **同一個人格永遠得到同一張圖**(純函數,沒有隨機);`ASUNA-01` 與 `ASUNA-02` 明顯不同。
- SVG 與 PNG **是同一張圖**:兩者共用同一組單位座標與同一份點陣字資料。
- PNG 由**自寫的柵格器**畫出(3× 超取樣 + 盒式縮減當反鋸齒),再用 `zlib` 手工組出
IHDRIDATIEND 與 CRC32。這台機器沒有 rsvginkscapeimagemagick,也沒有影像函式庫,
而本專案禁止 npm 依賴——所以就自己畫。
- **為什麼不直接用找到的圖**:那是別人的美術作品。底稿只用來「看」,圖示由本工具重畫。
- **為什麼沒有 emoji**:把 emoji 畫進 PNG 需要字型柵格化,環境裡連 emoji 字型都沒有;
emoji 仍參與雜湊配色。
- 圖示屬於低頻資料 → `icon.svg` 與 `icon.png` 都同步到 **Wiki 區**,並自動產生一頁 **Icon**
展示兩種格式與來源;PNG 同時設成 Gitea **存取庫頭像**。
```bash
node scripts/persona.mjs icon faces --session <id> --photo "<圖片網址>"
node scripts/persona.mjs icon headshot --session <id> --photo "<圖片網址>" --pick 1
node scripts/persona.mjs icon generate --session <id> --force \
--palette "hair=#d9a45b,eye=#9e5b3e,accent=#c0392b,secondary=#e77a8e,light=#f2ebe3,skin=#f7ddc4" \
--features "hairstyle=straight,length=very-long,fringe=parted,eyes=almond,expression=calm,collar=v,ahoge=yes" \
--source-url "<底稿那張圖的網址>" --source-note "<作品(年份)+重繪依據>"
node scripts/persona.mjs icon show --session <id> # 樣式、配色、特徵、來源
node scripts/persona.mjs sync verify --session <id> --area wiki # 確認 Wiki 真的同步了
```
## 人格編號與 Gitea 儲存
**編號 = 英文名全大寫 + 兩位索引**,同名才遞增,也就是 Gitea 存取庫的名稱:
```
亞絲娜(第一個) → ASUNA-01 結衣 → YUI-01 另一個亞絲娜 → ASUNA-02
```
每個人格一個**私有存取庫**,內容依**更新頻率**分兩區:
| 區 | 放什麼 | 何時 push |
| --- | --- | --- |
| **檔案區**(主存取庫) | 高頻活狀態:`emotion.json`、`short-term.jsonl`、`inner.jsonl`、`said.jsonl`、`inbox/`、`mindmap/threads/`、`journal/` | 每輪對話後由 `Stop` hook 背景推送(`PERSONA_SYNC_MIN_SECONDS` 節流) |
| **Wiki 區** | 低頻設定:`IDENTITY``SOUL``AGENTS``USER`、長期記憶、`INDEX`、心智圖、關係圖 | 記憶固化、改身分/關係圖、`release` 時 |
- **本機永遠是工作副本**:hook 每輪讀寫本機檔案,不經網路;Gitea 掛掉照樣能聊天。
**同步失敗永遠不阻斷對話。**
- 載入人格時會先 `pull`;兩邊都改過同一個檔案就**停下來不覆蓋本機**,由使用者決定保留哪一邊。
- Gitea 的 wiki 只有根目錄的 `.md` 會變成頁面(1.27 實測子目錄頁面 404),所以
`memory/long-term/xxx.md` 攤平成 `Memory-xxx.md`,原始路徑記在 `_paths.json`
Wiki 首頁自動列出所有長期記憶的連結,變成真的讀得下去的「設定百科」。
```bash
export GITEA_HOST=https://gitea.example.com
export GITEA_TOKEN=<個人存取權杖>
export PERSONA_GITEA_OWNER=<帳號或組織> # 選填,預設 token 本人
export PERSONA_GITEA=off # 需要時整個關掉
node scripts/persona.mjs code assign --session <id> --romaji Asuna --rename # 既有人格遷移
node scripts/persona.mjs sync status|init|push|pull --session <id> [--area files|wiki|all]
```
存取庫**預設私有**——人格裡是使用者的個人記憶,公開必須由使用者明講(`--public`)。
## 匯出 / 匯入(人格搬家)
```bash
node scripts/persona.mjs export --session <id> --out ~/backup/lumi.persona.json [--gzip] [--with-journal]
node scripts/persona.mjs import --session <id> --file ~/backup/lumi.persona.json [--persona lumi-copy] [--load]
```
- bundle = 單一 JSON(可 gzip)+ sha256 checksum,無外部工具依賴。
- **只能匯出本 session 目前載入的人格**——否則就是跨人格外洩的後門(`guard` 會 deny)。
- 不帶 `state/lock.json``guests.json`(鎖屬於那台機器的那個程序);`journal/` 預設不帶。
- 換名匯入(`--persona <新 slug>`)可讓同一個人格並存兩份,`config.json` 會記下來歷。
- bundle 內的 `../` 逃逸路徑一律拒收;checksum 不符要 `--force` 才吃。
## 劇場模式(多人格對話只顯示對話)
`invite` 成功即開啟(`leave` 沒有客人時自動關閉,也可 `room theater --on/--off` 手動切):
- `UserPromptSubmit` hook 每輪注入強制規則:輸出**只能**是 `名字:內容`
不得出現指令、指令輸出、狀態、分析、旁白、摘要。
- `Stop` hook 在劇場模式**完全不發系統訊息**(提醒會破壞畫面)。
- CLI 提供 `--quiet`(成功時零輸出)與 `room script`(只有 `emoji 名字(情緒):內容` 的乾淨對話稿)。
- 「講話像人」的三條規則在這裡一樣生效:每個人格每輪 **1–3 句**、推導走 `think`、
近似重複的台詞被 `room post` 拒收(host 與 guest 走同一支 CLI,一視同仁)。
```
🪼 Lumi(喜悅42/期待31):所以你真的一個人把那台舊鐘修好了?
🌙 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` 回報現況:誰對誰在講、該誰接話、誰先安靜、誰隔了幾輪沒開口
(被冷落太久是資訊——下次話題碰到他的領域時優先給他,而不是硬插一句「我也這麼覺得」)。
- 同一份現況每輪也注入 `<persona-context>`,所以人格自己知道這句該不該由他接。
- 什麼算「話題放大」:出現「我們/大家」、要一起決定的事、需要第二意見或仲裁、
講到某個在場者的專長或他認識的人、使用者點名。判準是**話題**,不是公平。
## 預設人格(開新 session 自動載入)
預設行為是**不自動附身**。使用者明示設定之後,`SessionStart` hook 才會在每個新 session 自動載入它:
```bash
node scripts/persona.mjs default --session <PERSONA_SESSION> # 看目前設定
node scripts/persona.mjs default --persona KIRITO-01 --session <PERSONA_SESSION> # 設定
node scripts/persona.mjs default --clear --session <PERSONA_SESSION> # 取消
```
| 事項 | 行為 |
| --- | --- |
| 設定位置 | `<PERSONA_HOME>/.runtime/settings.json` 的 `default_persona`(只由 CLI 維護) |
| 臨時覆寫 | 環境變數 `PERSONA_DEFAULT=<slug>``PERSONA_DEFAULT=off` 臨時關掉 |
| 沒設定 | 什麼都不做——只列出可用人格,等使用者指定 |
| 人格被別的程序鎖住 | 只回報 owner 與最後心跳,**不會**自動 `--takeover` |
| 預設人格不存在 | 警告並改列可用人格,不崩潰、不亂挑一個 |
| Gitea | 自動載入走本機路徑,**不**拉遠端;可能在別台機器動過就先 `sync pull` |
## 睡眠(`persona-sleep`
```bash
node scripts/persona.mjs sleep --session <PERSONA_SESSION> --json # 機械性收尾(預設保留載入鎖)
node scripts/persona.mjs sleep --personas <slug,slug> --session <PERSONA_SESSION> --json
node scripts/persona.mjs sleep --session <PERSONA_SESSION> --release # 收工:睡完釋放鎖
```
| | 誰做 | 內容 |
| --- | --- | --- |
| 需要判斷 | **人格自己** | 哪些短期記憶值得固化、日記寫什麼、哪些該忘、心智圖怎麼接 |
| 機械性 | `sleep` 子指令 | 關係時間戳 → 裁短期 → 收太久沒動的思維導圖 → **套用一次 8 小時的情緒衰減** → 重建索引 → 修剪 `said.jsonl` → 壓縮舊 journal → 寫 `state/sleep.json` → Gitea 兩區 push+驗證 |
- **情緒不會歸零**:喜悅(半衰期 120 分)一夜後幾乎回基線,悲傷(480 分)只退一半——睡一覺不該把難過抹平。
- **主人格請別人去睡**:開 `persona-sleeper` sub agentprompt 帶 `persona=<slug> session=<id>`)。
它就是那個人格本人,對自己可寫但被 pin 住、只准跑收尾子指令、不得碰任何其他人格(包含叫它來的主人格)。
- **一次睡多個人格**:可用 `sleep --personas A,B` 批次處理;主程序只是把流程排成一串,**每個人格仍各自判斷、各自收尾、各自回 JSON**。
- **回傳值刻意很窮**`{persona, ok, slept_at, steps, sync, kept_lock}`——只有狀態。
回傳值本身就是一條會繞過隔離的通道,所以在 CLI 這一層封死,不靠提示詞自律。
- **鎖**:沒有活鎖 → 取 5 分鐘的 sleeper 租約;同 session → 直接睡;死鎖 → 可接手;
**別的程序活鎖住 → 拒絕**(硬睡會讓兩邊的記憶互相覆蓋)。
- 順序上的硬相依:關係時間戳早於裁短期、push 早於 release、reindex 晚於固化。
## HooksClaude Code
| Hook | 做什麼 |
| --- | --- |
| `SessionStart` | 清死鎖、接續人格、**有設預設人格就自動載入它**(沒設就只列出可用人格)、把 `PERSONA_SESSION=<session_id>` 與規則注入上下文 |
| `UserPromptSubmit` | 注入 `<persona-context>`:身分、情緒、短期記憶、關鍵詞命中的長期記憶、相關人際關係;劇場模式時追加「只輸出人格對話」的強制規則;並記原始逐字 |
| `PreToolUse` | **人格隔離與鎖驗證的唯一強制點**(deny 帶原因) |
| `Stop` | 情緒隨時間衰減、續租、記錄回覆、達固化條件時提醒(劇場模式時完全靜音) |
| `SubagentStop` | 解除 guestsleeper sub agent 的 pin,並還掉 sleeper 的短期寫入權 |
| `SessionEnd` | 釋放鎖與 guest 租約,人格才能被下一個程序載入 |
> `session_id` 只有 hook 拿得到 → 注入上下文 → skills 呼叫 CLI 時必須帶 `--session`
> hook 會驗證是否相符。**這是「一人格一程序」與「跨人格隔離」不能被繞過的關鍵**。
---
## Skills 目錄
<!-- JSC-SKILLS:START -->
### `persona-create`
建立人格:以 OpenClaw 相同的五個身分欄位與 SOUL 段落訪談使用者,初始化情緒基線、記憶、心智圖與關係圖,並立即取得載入鎖。
- **Claude Code / Antigravity**`/jsc-persona:persona-create` **Codex**`$persona-create`
### `persona-anime`
用「動漫作品+角色名」建立人格:上網蒐集角色公開設定 → 映射成 OpenClaw 五欄位與 SOUL → 固化成 `canon` 基礎記憶(附來源)+原作關係圖+情緒基線。
- **Claude Code / Antigravity**`/jsc-persona:persona-anime 《作品》 角色名` **Codex**`$persona-anime`
### `persona-chat`
載入人格並對話:取得獨占鎖 → 每輪做語意分析(推導寫進心裡話,不說出口)→ 更新十二情緒 → 回想記憶與關係 → 以人格語氣回覆(1–3 句、不重複說過的話)→ 寫回記憶。
- **Claude Code / Antigravity**`/jsc-persona:persona-chat <slug>` **Codex**`$persona-chat`
### `persona-invite`
邀請一個或多個人格透過 `persona-guest` sub agent 加入聊天室,進入**劇場模式**(畫面只留 `名字:內容` 的人格對話);結束後讓它們離場並把見聞留在各自的 inbox。多位 guest 可用 `invite --guests A,B` 同場加入。同場的發言權分**一對一**(`--to <他>`,旁人不該接話)與**全場**`--to all`),現況查 `room floor`。
- **Claude Code / Antigravity**`/jsc-persona:persona-invite <slug>` **Codex**`$persona-invite`
### `persona-transfer`
人格搬家:把身分、十二情緒、短期/長期記憶、心智圖與關係圖打包成單一 bundle 檔(可 gzip、附 checksum),或從 bundle 還原/換名複製成新人格。
- **Claude Code / Antigravity**`/jsc-persona:persona-transfer` **Codex**`$persona-transfer`
### `persona-icon`
依人格**最新一次登場**的官方視覺產生圖示:上網查最新造型 → 下載並親眼看過參考圖 → 取髮色/瞳色/服裝色 → 繪製 `icon.svg` + `icon.png`,來源網址一併存證。
- **Claude Code / Antigravity**`/jsc-persona:persona-icon` **Codex**`$persona-icon`
### `persona-sync`
人格編號與 Gitea 儲存:指派編號(`ASUNA-01`)、開以編號命名的私有存取庫、高頻活狀態同步到檔案區、低頻身分與長期記憶同步到 Wiki 區,並處理既有人格遷移與同步衝突。
- **Claude Code / Antigravity**`/jsc-persona:persona-sync` **Codex**`$persona-sync`
### `persona-memory`
記憶固化:依 R1–R6 條件把短期記憶轉入長期(一則一檔)、淘汰雜訊、更新心智圖與思維導圖、消化 guest inbox、重建索引。
- **Claude Code / Antigravity**`/jsc-persona:persona-memory` **Codex**`$persona-memory`
### `persona-relation`
人際關係圖維護:節點/連線、親近度與信任度調整、輸出 Mermaid 關係圖。
- **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` 批次收尾多個人格。
- **Claude Code / Antigravity**`/jsc-persona:persona-sleep` **Codex**`$persona-sleep`
### `persona-status`
載入狀態與鎖管理:誰被哪個程序鎖住、guest 租約、釋放、接手死鎖、清理殘留,以及**預設人格**(開新 session 要自動載入誰)。
- **Claude Code / Antigravity**`/jsc-persona:persona-status` **Codex**`$persona-status`
<!-- JSC-SKILLS:END -->
### Agents
- `persona-guest``agents/persona-guest.md`)— 受邀人格的 sub agent,唯讀、被綁死在自己的人格目錄。
- `persona-sleeper``agents/persona-sleeper.md`)— 睡眠收尾的 sub agent:**就是那個人格本人在睡**,對自己可寫但被 pin 住,只回傳「睡完了沒、哪一步出錯」。
### CLI 與自我測試
所有狀態變更都經過 `scripts/persona.mjs`**Node.js ≥ 18**,只用內建模組,無 npm 依賴):
```bash
node scripts/persona.mjs --help
node scripts/persona.mjs list
node scripts/persona.mjs candidates --session <PERSONA_SESSION> # 看哪些短期記憶該固化
node scripts/persona.mjs think --session <id> --text "<推導>" # 心裡話(只回報「心想 N 句」)
node scripts/persona.mjs said check --session <id> --text "<話>" # 這句是不是又要說一次?
node scripts/persona.mjs room script --session <id> --room <room> # 乾淨對話稿(劇場模式用)
node scripts/persona.mjs room floor --session <id> --room <room> # 發言權:誰對誰在講、該誰接話
node scripts/persona.mjs export --session <id> --out lumi.json # 離線搬家(單檔)
node scripts/persona.mjs sync status --session <id> # Gitea 同步狀態
node scripts/selftest.mjs # 248 項驗證:鎖、隔離、情緒、固化、說話節制、劇場模式與發言權、匯出匯入、編號與 Gitea、找圖去背合成、高解析輸出與 Wiki 同步、預設人格、睡眠與 sleeper、hooks
```
檔案結構:`scripts/persona-lib.mjs`(核心:鎖/隔離/情緒/記憶)、`scripts/persona.mjs`CLI)、
`scripts/persona-gitea.mjs`(編號與 Gitea 同步)、`scripts/persona-icon.mjs`(形象圖:SVG + 自寫 PNG 編碼)、`scripts/portrait.py`(選用:照片裁臉)、`hooks/*.mjs`(六個 hook)、`scripts/selftest.mjs`(自我測試)。
---
## 跨助理支援度
| 助理 | skills | hooks(鎖/隔離強制) | 受邀人格 sub agent |
| --- | --- | --- | --- |
| Claude Code | ✅ `/jsc-persona:<name>` | ✅ 完整 | ✅ `jsc-persona:persona-guest` |
| Codex | ✅ `$<name>` | ❌ | ⚠ 需自行以子任務模擬 |
| Antigravity | ✅ `/jsc-persona:<name>` | ❌ | ⚠ |
| OpenCode | ✅ 依描述自動觸發 | ❌ | ⚠ |
> 沒有 hook 的助理仍會遵守 CLI 層的檢查(`--session` 綁定、`require_owner``require_member`、
> guest 唯讀),但那是**自律**而非強制:真正的 deny 只有 Claude Code 的 `PreToolUse` 做得到。
---
## 安裝 / 更新 / 移除
> 指令中的 repo 網址換成你的:`https://gitea.jsc.idv.tw/plugins/persona.git`
> **Claude / Codex 從 git URL 安裝(會 clone 遠端),請先把本 repo `push` 到 gitea。**
### Claude Code
```bash
claude plugin marketplace add https://gitea.jsc.idv.tw/plugins/persona.git
claude plugin install jsc-persona@jsc-plugins
# 更新
claude plugin marketplace update jsc-plugins
claude plugin update jsc-persona@jsc-plugins
# 移除
claude plugin uninstall jsc-persona@jsc-plugins
```
- 工作階段內 slash 版(等價):把 `claude plugin` 換成 `/plugin`。
- 本機開發(免 push):`claude plugin marketplace add /home/coder/plugins/persona` 後再 install。
- 安裝後**重啟工作階段**讓 hooks 生效;用 `/hooks` 確認六個 hook 都在。
### Codex
```bash
codex plugin marketplace add https://gitea.jsc.idv.tw/plugins/persona.git
codex plugin add jsc-persona@jsc-plugins
codex plugin marketplace upgrade jsc-plugins # 更新
codex plugin remove jsc-persona@jsc-plugins # 移除
```
### Antigravity`agy`
> `agy plugin install <url>` 目前只支援 github.comgitea 請 clone 後用本地路徑。
```bash
git clone https://gitea.jsc.idv.tw/plugins/persona.git ~/plugins/persona
agy plugin install ~/plugins/persona
# 更新:git -C ~/plugins/persona pull && agy plugin uninstall jsc-persona && agy plugin install ~/plugins/persona
```
### OpenCode
```bash
git clone https://gitea.jsc.idv.tw/plugins/persona.git ~/plugins/persona
mkdir -p ~/.config/opencode/skills
cp -r ~/plugins/persona/skills/* ~/.config/opencode/skills/
```
> **Windows PowerShell**`cp -r A B` → `Copy-Item A B -Recurse -Force`、`~` → `$HOME`。
### headless 一次性執行
| 助理 | 指令 |
| --- | --- |
| Claude Code | `claude -p "/jsc-persona:persona-chat lumi"` |
| Codex | `codex exec '$persona-chat lumi'` |
| Antigravity | `agy -p "/jsc-persona:persona-chat lumi"` |
| OpenCode | `opencode run "用 lumi 這個人格跟我聊聊"` |
---
## 設計取捨(讀之前先知道)
- **記憶是被策展的,不是全存**:逐字稿進 `journal/`,但只有經過語意分析、有顯著度的內容才進短期記憶,
再由 `persona-memory` 決定什麼值得成為長期記憶。**遺忘是功能**。
- **事實與推測分離**:推論走思維導圖(`mindmap/threads/`),驗證後才升格長期記憶。
- **鎖的擁有者是 session 不是 process**CLI 跑完就結束,所以租約判定只看心跳。
- **guest 唯讀**:受邀人格不能在別人的 session 裡改自己的長期記憶(避免兩個程序同時寫),
只能把見聞放進 `memory/inbox/`,等它自己被載入時消化。
- **hook 是強制、SKILL 是引導**SKILL.md 寫的規則模型可能忘記,hook 不會。
- **「不要說出來」要靠設計而不是靠忍住**:所以心裡話有自己的指令與檔案,
而且那個指令**印不出內容**——就算不小心把輸出貼上去,使用者也只看到「💭 心想 N 句」。
- **重複用相似度擋、不用語意判斷**:字元層級的比對沒有模型成本、行為可預期,
誤判時有 `--allow-repeat` 可救;漏判的代價(人格自我重複)比誤判高。
## 新增/修改 skill
1. 複製一個現有 skill 目錄,改 `SKILL.md` 的 `name` 與 `description`(描述要寫清楚何時用、何時不用)。
2. 需要新的狀態操作 → 加到 `scripts/persona.mjs` 的子指令,並在 `scripts/selftest.mjs` 補測試。
3. 動到隔離規則 → 一定要在 `selftest.mjs` 的第 ③(隔離)/⑧(guest 與劇場模式)區加對應案例,跑到全綠。
4. 把 skill 補進上方「Skills 目錄」區塊。
5. bump `.claude-plugin/plugin.json`、`.codex-plugin/plugin.json`、`plugin.json` 三處 `version`commit 後 push。