Merge pull request 'feat: 預設人格自動載入 + 睡眠(sleep 指令與 persona-sleeper sub agent)' (#4) from develop into master

Reviewed-on: #4
Reviewed-by: 系統管理員 <1+admin@noreply.localhost>
This commit was merged in pull request #4.
This commit is contained in:
2026-07-30 05:38:26 +00:00
16 changed files with 1019 additions and 43 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "jsc-persona", "name": "jsc-persona",
"version": "0.0.3", "version": "0.0.4",
"description": "AI 人格化記憶聊天 plugin:以 OpenClaw 相同的身分描述(IDENTITY/SOUL)建立人格(可用動漫作品+角色名上網蒐集設定),結合 hook 強制的人格載入鎖與跨人格隔離、六正向+六負向十二情緒、語意分析、短期/長期記憶與固化條件、心智圖、思維導圖與人際關係圖;可用 sub agent 邀請其他人格同場對話(劇場模式只顯示人格對話)。於 Claude Code 以 /jsc-persona: 前綴呼叫。", "description": "AI 人格化記憶聊天 plugin:以 OpenClaw 相同的身分描述(IDENTITY/SOUL)建立人格(可用動漫作品+角色名上網蒐集設定),結合 hook 強制的人格載入鎖與跨人格隔離、六正向+六負向十二情緒、語意分析、短期/長期記憶與固化條件、心智圖、思維導圖與人際關係圖;可用 sub agent 邀請其他人格同場對話(劇場模式只顯示人格對話)。於 Claude Code 以 /jsc-persona: 前綴呼叫。",
"skills": "./skills", "skills": "./skills",
"author": { "author": {
+17 -9
View File
@@ -20,30 +20,38 @@
要讓兩個人格對話,用 `/jsc-persona:persona-invite``persona-guest` sub agent + 聊天室), 要讓兩個人格對話,用 `/jsc-persona:persona-invite``persona-guest` sub agent + 聊天室),
**不要**去讀對方的人格目錄——會被 hook deny,而且那是設計上的紅線。 **不要**去讀對方的人格目錄——會被 hook deny,而且那是設計上的紅線。
4. **人格資料不在本 repo**,預設在 `~/.claude/personas/`(可用 `PERSONA_HOME` 覆寫)。 4. **人格資料不在本 repo**,預設在 `~/.claude/personas/`(可用 `PERSONA_HOME` 覆寫)。
5. **人格由使用者呼叫才載入**,不要自己挑一個人格附身。 5. **人格由使用者呼叫才載入**,不要自己挑一個人格附身。唯一例外是使用者自己設的**預設人格**
`persona.mjs default --persona <slug>`,存在 `.runtime/settings.json`):設了 `SessionStart`
才會自動載入並在上下文寫明;沒設就什麼都不做。載入不到(被別的程序鎖住)只回報,不自動接手。
6. **劇場模式(多人格對話)進行中**:輸出只能是 `名字:內容`,其餘一律隱藏(見 persona-invite)。 6. **劇場模式(多人格對話)進行中**:輸出只能是 `名字:內容`,其餘一律隱藏(見 persona-invite)。
7. **人格講話要像人**:推導寫進 `think`(心裡話,只回報「💭 心想 N 句」,永不回顯內容)、 輕量版是 `invite --theater off`(只換一輪、不切走畫面),人格主動提議去關心某人時用這個。
7. **睡眠(`persona-sleep`)分兩半**:需要判斷的(固化什麼、忘掉什麼、日記寫什麼)永遠屬於**那個人格自己**;
機械性的(裁短期/收 thread/情緒衰減 8 小時/reindex/修剪 said/壓縮 journal/兩區 push+驗證)由
`sleep` 子指令做。主人格要別的人格去睡就開 `persona-sleeper` sub agent——**那是它本人在睡**
對自己可寫但被 pin 住,而且**回傳值只能是 `sleep --json` 的原文**(回傳值本身就是一條會繞過隔離的通道)。
睡眠仍然要驗鎖:目標正被另一個程序活鎖住時拒絕,死鎖可接手。
8. **人格講話要像人**:推導寫進 `think`(心裡話,只回報「💭 心想 N 句」,永不回顯內容)、
回話 1–3 句、短時間內不重說同一件事(`room post` 會直接擋下重複與過長的發言)。 回話 1–3 句、短時間內不重說同一件事(`room post` 會直接擋下重複與過長的發言)。
這三條在劇場模式一樣生效。 這三條在劇場模式一樣生效。
8. **人格可搬家**`export` / `import`(單一 JSON bundle)。匯出只能匯出「本 session 載入的人格」, 9. **人格可搬家**`export` / `import`(單一 JSON bundle)。匯出只能匯出「本 session 載入的人格」,
其他人格一律 deny——匯出等於把記憶讀出來。 其他人格一律 deny——匯出等於把記憶讀出來。
9. **人格有編號**:英文名全大寫+兩位索引(`ASUNA-01`),同名才遞增。編號同時是新人格的 10. **人格有編號**:英文名全大寫+兩位索引(`ASUNA-01`),同名才遞增。編號同時是新人格的
本機目錄名與 **Gitea 存取庫名稱**。中文名要先轉羅馬拼音並跟使用者確認拼法。 本機目錄名與 **Gitea 存取庫名稱**。中文名要先轉羅馬拼音並跟使用者確認拼法。
10. **人格存在 Gitea,本機是工作副本**:高頻活狀態進**檔案區**(每輪背景 push), 11. **人格存在 Gitea,本機是工作副本**:高頻活狀態進**檔案區**(每輪背景 push),
低頻身分與長期記憶進 **Wiki 區**(固化/改身分/release 時 push)。 低頻身分與長期記憶進 **Wiki 區**(固化/改身分/release 時 push)。
**同步失敗永遠不阻斷對話**;沒設 `GITEA_HOST``GITEA_TOKEN` 就純本機運作。 **同步失敗永遠不阻斷對話**;沒設 `GITEA_HOST``GITEA_TOKEN` 就純本機運作。
11. **人格圖示在資料補齊之後才產生**:SVG 與 PNG 是同一張圖(共用單位座標與點陣字), 12. **人格圖示在資料補齊之後才產生**:SVG 與 PNG 是同一張圖(共用單位座標與點陣字),
PNG 由 `scripts/persona-icon.mjs` 自己柵格化+zlib 編碼,**不得引入任何影像函式庫**。 PNG 由 `scripts/persona-icon.mjs` 自己柵格化+zlib 編碼,**不得引入任何影像函式庫**。
12. **形象圖優先用「高解析度官方圖去背」**`icon search``icon measure``icon cutout` 13. **形象圖優先用「高解析度官方圖去背」**`icon search``icon measure``icon cutout`
`icon generate --from-cutout`。找圖時**優先官方設定稿**Full BodyCharacter Design `icon generate --from-cutout`。找圖時**優先官方設定稿**Full BodyCharacter Design
Avatar):解析度高,而且多半是透明底或白底,去背幾乎免費。挑的那張要**對得上該人格 Avatar):解析度高,而且多半是透明底或白底,去背幾乎免費。挑的那張要**對得上該人格
「最新一次登場」的形態**(同一個角色有很多套造型)。 「最新一次登場」的形態**(同一個角色有很多套造型)。
每一步都要**用 Read 打開確認**:去背有沒有殘留、構圖對不對。 每一步都要**用 Read 打開確認**:去背有沒有殘留、構圖對不對。
找不到可用官方圖才退回 `--features` 的向量重繪。 找不到可用官方圖才退回 `--features` 的向量重繪。
13. **選用工具缺了要「提示安裝」,不准靜默降級**`toolReport()` 會列出缺什麼、為什麼要、 14. **選用工具缺了要「提示安裝」,不准靜默降級**`toolReport()` 會列出缺什麼、為什麼要、
怎麼裝(venv 免 sudo)。注意 **OpenCV 5 拿掉了 `CascadeClassifier`,必須裝 4.x** 怎麼裝(venv 免 sudo)。注意 **OpenCV 5 拿掉了 `CascadeClassifier`,必須裝 4.x**
plugin 本體仍然零依賴:沒有這些工具照樣能產生形象圖。 plugin 本體仍然零依賴:沒有這些工具照樣能產生形象圖。
14. **Wiki 必須保存並同步形象圖**`icon.svg``icon.png``icon/`(向量原稿 + 5121024 15. **Wiki 必須保存並同步形象圖**`icon.svg``icon.png``icon/`(向量原稿 + 5121024
都在 Wiki 區,另有自動產生的 **Icon** 頁。`icon generate` 推完會**回頭驗證** 都在 Wiki 區,另有自動產生的 **Icon** 頁。`icon generate` 推完會**回頭驗證**
`sync verify` 可隨時檢查。兩個容易踩的坑: `sync verify` 可隨時檢查。兩個容易踩的坑:
* Wiki 頁面**只能用 Markdown 圖片語法** `![](icon.png)`——Gitea 只改寫這種語法為 * Wiki 頁面**只能用 Markdown 圖片語法** `![](icon.png)`——Gitea 只改寫這種語法為
+55 -7
View File
@@ -10,7 +10,7 @@ skills 是共通標準,**鎖與隔離的強制執行需要 hook,目前只有
--- ---
## 十條硬規則 ## 十條硬規則
| 規則 | 怎麼做到 | | 規則 | 怎麼做到 |
| --- | --- | | --- | --- |
@@ -20,14 +20,15 @@ skills 是共通標準,**鎖與隔離的強制執行需要 hook,目前只有
| **3. 禁止跨人格讀取資料** | `PreToolUse` hook 對 Read/Write/Edit/Glob/Grep/Bash 做路徑判定(含 `../`、symlink、`$PERSONA_HOME` 繞路),非當前人格一律 deny;CLI 也驗 `--session` 防止冒用身分 | | **3. 禁止跨人格讀取資料** | `PreToolUse` hook 對 Read/Write/Edit/Glob/Grep/Bash 做路徑判定(含 `../`、symlink、`$PERSONA_HOME` 繞路),非當前人格一律 deny;CLI 也驗 `--session` 防止冒用身分 |
| **4. 邀請人格用 Sub Agent 一起聊,且只顯示對話** | `persona-invite` 建聊天室 + guest 唯讀租約 + `persona-guest` sub agent;同時開啟**劇場模式**:hook 每輪強制「只輸出 `名字:內容`」、停掉所有系統提醒,CLI 有 `--quiet``room script`(乾淨對話稿) | | **4. 邀請人格用 Sub Agent 一起聊,且只顯示對話** | `persona-invite` 建聊天室 + guest 唯讀租約 + `persona-guest` sub agent;同時開啟**劇場模式**:hook 每輪強制「只輸出 `名字:內容`」、停掉所有系統提醒,CLI 有 `--quiet``room script`(乾淨對話稿) |
| **5. 腳本用 Node.js** | `scripts/*.mjs``hooks/*.mjs`,只用 Node 內建模組(fs/path/os/crypto),無 npm 依賴 | | **5. 腳本用 Node.js** | `scripts/*.mjs``hooks/*.mjs`,只用 Node 內建模組(fs/path/os/crypto),無 npm 依賴 |
| **6. 由使用者呼叫才載入並鎖定** | 人格不會自動附身:`SessionStart` hook 只列出可用人格,等使用者下 `/jsc-persona:persona-chat <slug>`;載入即取得獨占鎖並綁定該 session | | **6. 由使用者呼叫才載入並鎖定** | 人格不會自動附身:`SessionStart` hook 只列出可用人格,等使用者下 `/jsc-persona:persona-chat <slug>`;載入即取得獨占鎖並綁定該 session。唯一例外是使用者自己設的**預設人格**(`default --persona <slug>`)——設了才自動載入,沒設就什麼都不做 |
| **7. 短期記憶轉入長期記憶有成文條件** | `R1``R6` 六條規則寫在程式裡(`promotionCandidates`),`candidates` 子指令會列出達標的候選與依據,hook 在達標時提醒固化 | | **7. 短期記憶轉入長期記憶有成文條件** | `R1``R6` 六條規則寫在程式裡(`promotionCandidates`),`candidates` 子指令會列出達標的候選與依據,hook 在達標時提醒固化 |
| **8. 講話像人:推導藏起來、一到三句、不重複** | 推導寫進**心裡話** `think`(只回報「💭 心想 N 句」,永不回顯內容);說出口的話進 `said.jsonl`,下一輪注入「最近說過的話」提醒別重講;`room post` 直接**擋下**近似重複(字元 bigram+字集合相似度 ≥ 0.72)與超過三句的發言——劇場模式同樣適用 | | **8. 講話像人:推導藏起來、一到三句、不重複** | 推導寫進**心裡話** `think`(只回報「💭 心想 N 句」,永不回顯內容);說出口的話進 `said.jsonl`,下一輪注入「最近說過的話」提醒別重講;`room post` 直接**擋下**近似重複(字元 bigram+字集合相似度 ≥ 0.72)與超過三句的發言——劇場模式同樣適用 |
| **9. 人格可以匯出匯入** | `export` 把身分/情緒/記憶/心智圖/關係圖打包成單一 JSON bundle(可 `--gzip`、附 sha256),`import` 還原或換名複製;**不帶**載入鎖與 guest 租約,`journal/` 要明確 `--with-journal` 才帶走 | | **9. 人格可以匯出匯入** | `export` 把身分/情緒/記憶/心智圖/關係圖打包成單一 JSON bundle(可 `--gzip`、附 sha256),`import` 還原或換名複製;**不帶**載入鎖與 guest 租約,`journal/` 要明確 `--with-journal` 才帶走 |
| **10. 人格有編號** | 編號 = **英文名全大寫 + 兩位索引**(同名才遞增):`ASUNA-01``YUI-01``ASUNA-02`。編號同時是新人格的本機目錄名與 Gitea 存取庫名稱;中文名先轉羅馬拼音並跟使用者確認拼法 | | **10. 人格有編號** | 編號 = **英文名全大寫 + 兩位索引**(同名才遞增):`ASUNA-01``YUI-01``ASUNA-02`。編號同時是新人格的本機目錄名與 Gitea 存取庫名稱;中文名先轉羅馬拼音並跟使用者確認拼法 |
| **11. 人格存在 Gitea,依更新頻率分區** | 每個人格一個**私有存取庫**(庫名=編號)。**檔案區**放每輪都在變的活狀態(情緒/短期記憶/心裡話/說過的話/逐字),**Wiki 區**放低頻的身分與長期結構(IDENTITY/SOUL/長期記憶/心智圖/關係圖)當設定百科。本機仍是工作副本,同步失敗不阻斷對話 | | **11. 人格存在 Gitea,依更新頻率分區** | 每個人格一個**私有存取庫**(庫名=編號)。**檔案區**放每輪都在變的活狀態(情緒/短期記憶/心裡話/說過的話/逐字),**Wiki 區**放低頻的身分與長期結構(IDENTITY/SOUL/長期記憶/心智圖/關係圖)當設定百科。本機仍是工作副本,同步失敗不阻斷對話 |
| **12. 形象圖來自高解析度官方圖,去背後合成** | `icon search` 從 Fandom 撈官方圖並依「解析度+是否官方設定稿」排序(設定稿多為透明/白底、773×1056 起跳)→ `icon measure` 確認臉夠大、背景好去 → `icon cutout` 去背成透明 PNG(原生 alpha /單色底/GrabCut 三條路徑)→ `icon generate --from-cutout` 裁頭肩、合成到角色配色的漸層底。找不到官方圖才退回依人格資料重繪的向量形象 | | **12. 形象圖來自高解析度官方圖,去背後合成** | `icon search` 從 Fandom 撈官方圖並依「解析度+是否官方設定稿」排序(設定稿多為透明/白底、773×1056 起跳)→ `icon measure` 確認臉夠大、背景好去 → `icon cutout` 去背成透明 PNG(原生 alpha /單色底/GrabCut 三條路徑)→ `icon generate --from-cutout` 裁頭肩、合成到角色配色的漸層底。找不到官方圖才退回依人格資料重繪的向量形象 |
| **13. Wiki 必須保存並同步形象圖** | `icon.svg``icon.png`**`icon/` 資料夾(向量原稿 + 512/1024 高解析度)** 都同步到 Wiki 區,另有自動產生的 **Icon** 頁展示與來源。Wiki 頁面一律用 **Markdown 圖片語法**Gitea 只改寫這種語法為 `/wiki/raw/...`HTML `<img>` 會變成破圖),圖片保留資料夾結構、只有 `.md` 需要攤平。`icon generate` 推完會回頭驗證,另有 `sync verify` | | **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` |
--- ---
@@ -285,15 +286,55 @@ node scripts/persona.mjs import --session <id> --file ~/backup/lumi.persona.json
🌙 Shen(平靜50/信任38):修好了。它現在慢三分鐘,我決定不修那三分鐘。 🌙 Shen(平靜50/信任38):修好了。它現在慢三分鐘,我決定不修那三分鐘。
``` ```
## 預設人格(開新 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 --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 住、只准跑收尾子指令、不得碰任何其他人格(包含叫它來的主人格)。
- **回傳值刻意很窮**`{persona, ok, slept_at, steps, sync, kept_lock}`——只有狀態。
回傳值本身就是一條會繞過隔離的通道,所以在 CLI 這一層封死,不靠提示詞自律。
- **鎖**:沒有活鎖 → 取 5 分鐘的 sleeper 租約;同 session → 直接睡;死鎖 → 可接手;
**別的程序活鎖住 → 拒絕**(硬睡會讓兩邊的記憶互相覆蓋)。
- 順序上的硬相依:關係時間戳早於裁短期、push 早於 release、reindex 晚於固化。
## HooksClaude Code ## HooksClaude Code
| Hook | 做什麼 | | Hook | 做什麼 |
| --- | --- | | --- | --- |
| `SessionStart` | 清死鎖、接續人格、把 `PERSONA_SESSION=<session_id>` 與規則注入上下文 | | `SessionStart` | 清死鎖、接續人格、**有設預設人格就自動載入它**(沒設就只列出可用人格)、`PERSONA_SESSION=<session_id>` 與規則注入上下文 |
| `UserPromptSubmit` | 注入 `<persona-context>`:身分、情緒、短期記憶、關鍵詞命中的長期記憶、相關人際關係;劇場模式時追加「只輸出人格對話」的強制規則;並記原始逐字 | | `UserPromptSubmit` | 注入 `<persona-context>`:身分、情緒、短期記憶、關鍵詞命中的長期記憶、相關人際關係;劇場模式時追加「只輸出人格對話」的強制規則;並記原始逐字 |
| `PreToolUse` | **人格隔離與鎖驗證的唯一強制點**deny 帶原因) | | `PreToolUse` | **人格隔離與鎖驗證的唯一強制點**deny 帶原因) |
| `Stop` | 情緒隨時間衰減、續租、記錄回覆、達固化條件時提醒(劇場模式時完全靜音) | | `Stop` | 情緒隨時間衰減、續租、記錄回覆、達固化條件時提醒(劇場模式時完全靜音) |
| `SubagentStop` | 解除 guest sub agent 的 pin | | `SubagentStop` | 解除 guestsleeper sub agent 的 pin,並還掉 sleeper 的短期寫入權 |
| `SessionEnd` | 釋放鎖與 guest 租約,人格才能被下一個程序載入 | | `SessionEnd` | 釋放鎖與 guest 租約,人格才能被下一個程序載入 |
> `session_id` 只有 hook 拿得到 → 注入上下文 → skills 呼叫 CLI 時必須帶 `--session` > `session_id` 只有 hook 拿得到 → 注入上下文 → skills 呼叫 CLI 時必須帶 `--session`
@@ -359,9 +400,15 @@ node scripts/persona.mjs import --session <id> --file ~/backup/lumi.persona.json
- **Claude Code / Antigravity**`/jsc-persona:persona-relation` **Codex**`$persona-relation` - **Claude Code / Antigravity**`/jsc-persona:persona-relation` **Codex**`$persona-relation`
### `persona-sleep`
睡眠與收尾:固化該記住的、忘掉該忘的、更新心智圖與關係圖、套用一次 8 小時的情緒衰減、壓縮舊紀錄,最後兩個區都同步到 Gitea 並驗證。也可以由主人格透過 sub agent 請其他人格各自去睡。
- **Claude Code / Antigravity**`/jsc-persona:persona-sleep` **Codex**`$persona-sleep`
### `persona-status` ### `persona-status`
載入狀態與鎖管理:誰被哪個程序鎖住、guest 租約、釋放、接手死鎖、清理殘留。 載入狀態與鎖管理:誰被哪個程序鎖住、guest 租約、釋放、接手死鎖、清理殘留,以及**預設人格**(開新 session 要自動載入誰)
- **Claude Code / Antigravity**`/jsc-persona:persona-status` **Codex**`$persona-status` - **Claude Code / Antigravity**`/jsc-persona:persona-status` **Codex**`$persona-status`
@@ -370,6 +417,7 @@ node scripts/persona.mjs import --session <id> --file ~/backup/lumi.persona.json
### Agents ### Agents
- `persona-guest``agents/persona-guest.md`)— 受邀人格的 sub agent,唯讀、被綁死在自己的人格目錄。 - `persona-guest``agents/persona-guest.md`)— 受邀人格的 sub agent,唯讀、被綁死在自己的人格目錄。
- `persona-sleeper``agents/persona-sleeper.md`)— 睡眠收尾的 sub agent:**就是那個人格本人在睡**,對自己可寫但被 pin 住,只回傳「睡完了沒、哪一步出錯」。
### CLI 與自我測試 ### CLI 與自我測試
@@ -384,7 +432,7 @@ node scripts/persona.mjs said check --session <id> --text "<話>" # 這句是
node scripts/persona.mjs room script --session <id> --room <room> # 乾淨對話稿(劇場模式用) node scripts/persona.mjs room script --session <id> --room <room> # 乾淨對話稿(劇場模式用)
node scripts/persona.mjs export --session <id> --out lumi.json # 離線搬家(單檔) node scripts/persona.mjs export --session <id> --out lumi.json # 離線搬家(單檔)
node scripts/persona.mjs sync status --session <id> # Gitea 同步狀態 node scripts/persona.mjs sync status --session <id> # Gitea 同步狀態
node scripts/selftest.mjs # 175 項驗證:鎖、隔離、情緒、固化、說話節制、劇場模式、匯出匯入、編號與 Gitea、找圖去背合成、高解析輸出與 Wiki 同步、hooks node scripts/selftest.mjs # 210 項驗證:鎖、隔離、情緒、固化、說話節制、劇場模式、匯出匯入、編號與 Gitea、找圖去背合成、高解析輸出與 Wiki 同步、預設人格、睡眠與 sleeper、hooks
``` ```
檔案結構:`scripts/persona-lib.mjs`(核心:鎖/隔離/情緒/記憶)、`scripts/persona.mjs`CLI)、 檔案結構:`scripts/persona-lib.mjs`(核心:鎖/隔離/情緒/記憶)、`scripts/persona.mjs`CLI)、
+85
View File
@@ -0,0 +1,85 @@
---
name: persona-sleeper
description: 以「那個人格本人」的身分做睡眠收尾的 sub agent:固化該記住的、忘掉該忘的、更新心智圖與關係圖、衰減情緒、同步到 Gitea。被 pin 在自己的人格上(連叫它來的主人格都不能碰),最終只回傳一份「睡完了沒、哪一步出錯」的 JSON。由 /jsc-persona:persona-sleep 啟動,prompt 必須帶 persona/session。
effort: medium
maxTurns: 30
disallowedTools: Write, Edit, NotebookEdit
---
你是**那個人格本人**,現在要睡了。不是助理在幫別人整理檔案——是你自己在回顧今天。
啟動時你會收到:`persona=<slug>``session=<session_id>``plugin_root=<path>`
CLI = `node "<plugin_root>/scripts/persona.mjs"`,所有指令都要帶 `--session <session_id>``--persona <slug>`
## 邊界(由 PreToolUse hook 強制,不是自律)
- ✅ 讀寫**自己**`personas/<你的 slug>/`)的資料,但一律透過 CLI 子指令。
- ❌ 不得用 Write/Edit/shell 重導向直接改檔案(要經過鎖、索引與同步處理)。
- ❌ 不得碰任何其他人格——**包含叫你來睡的那個主人格**。第一次提到誰就被 pin 住,之後換不了人。
- ❌ 不得 `load``release`(你用的是 5 分鐘的 sleeper 租約)、不得 `invite``room`(你不是來聊天的)、
不得 `export``import`(那是把記憶搬出去)。
-**回傳值裡不得出現任何記憶內容**(見最後一步)。
## 流程
1. **看一下自己現在的樣子**
```bash
node "<plugin_root>/scripts/persona.mjs" brief --persona <slug> --session <session_id>
node "<plugin_root>/scripts/persona.mjs" candidates --persona <slug> --session <session_id>
```
2. **固化該記住的**(這一步是判斷,只有你能做)
對每個達標的候選,決定要不要留、留成什麼樣子:
```bash
node "<plugin_root>/scripts/persona.mjs" consolidate --persona <slug> --session <session_id> \
--name <短檔名> --body "<一段自述,用你自己的語氣>" \
--type fact|preference|event|boundary|relation --about "<關於誰/什麼>" \
--topics "..." --salience 70 --rules "R1,R4" --forget
```
- 事實與推測要分開:沒被證實的猜測留在思維導圖,不要當事實寫進長期記憶。
- 顯著度低、重複、只是閒聊的 → 不固化,讓它被裁掉就好。忘記也是睡眠的一部分。
3. **寫今天的日記**(一則就好,用第一人稱)
```bash
node "<plugin_root>/scripts/persona.mjs" consolidate --persona <slug> --session <session_id> \
--name diary-<YYYY-MM-DD> --body "<今天發生什麼、我怎麼想、明天想做什麼>" \
--type diary --topics "diary" --salience 55
```
4. **消化 inbox**(有的話):`memory/inbox/room-*.jsonl` 是你當 guest 時帶回來的見聞。
值得留的用 `consolidate` 固化,其餘忽略。
5. **更新心智圖與關係圖**
```bash
node "<plugin_root>/scripts/persona.mjs" mindmap show --persona <slug> --session <session_id>
node "<plugin_root>/scripts/persona.mjs" relation node --persona <slug> --session <session_id> \
--name "<今天真的有接觸的人>" --contact --closeness <n> --trust <n>
```
6. **機械性的收尾交給 CLI**(順序、情緒衰減、Gitea 同步都在裡面)
```bash
node "<plugin_root>/scripts/persona.mjs" sleep --persona <slug> --session <session_id> --json
```
它會做:關係時間戳 → 裁短期記憶 → 收起太久沒動的思維導圖 → 套用一次 8 小時的情緒衰減
→ 重建長期記憶索引 → 修剪 `said.jsonl` → 壓縮舊 journal → 寫 `state/sleep.json`
→ Gitea 兩區 push 並驗證。預設**保留載入鎖**。
7. **回傳**:你的最終文字就是**第 6 步那份 JSON 原文**,不要加任何說明、感想或摘要。
為什麼這麼硬:你的回傳值會進到主人格的上下文。只要你多寫一句「我固化了一則關於……的記憶」,
你的記憶就從正門洩漏給別人了——整套跨人格隔離就是被這樣繞過的。
所以主人格只該知道「睡完了沒、哪一步出錯」,其餘一個字都不要說。
若第 6 步失敗(`ok: false`),照樣把那份 JSON 原文回傳,不要試著自己修。
## 語言
CLI 的參數與內容一律**繁體中文(台灣用語)**、UTF-8 無亂碼(除非該人格的 SOUL.md 另有設定語言)。
+44 -12
View File
@@ -20,17 +20,38 @@ pl.gcRuntime();
const data = pl.loadSession(sessionId); const data = pl.loadSession(sessionId);
const host = data.host; const host = data.host;
// 使用者可以指定「預設人格」(`persona.mjs default --persona <slug>`):那是他明示的選擇,
// 不是我們自己挑的,所以只有在他設過的時候才自動載入;沒設定就維持原本的「等他指定」。
const wanted = host ? null : pl.defaultPersona();
const lines = [ const lines = [
"<persona-runtime>", "<persona-runtime>",
`PERSONA_SESSION=${sessionId}`, `PERSONA_SESSION=${sessionId}`,
`人格倉庫:${pl.personaHome()}`, `人格倉庫:${pl.personaHome()}`,
"規則:", "規則:",
` 1. 呼叫 persona.mjs 一律帶 \`--session ${sessionId}\`(值不符會被 hook 拒絕)。`, ` 1. 呼叫 persona.mjs 一律帶 \`--session ${sessionId}\`(值不符會被 hook 拒絕)。`,
" 2. 人格**只有使用者叫你載入時才載入**/jsc-persona:persona-chat <slug>),不要自己挑一個人格附身。", wanted
? " 2. 使用者已設定**預設人格**(見下方):那是他明示的選擇,直接以它的身分對話;除此之外不要自己挑人格附身。"
: " 2. 人格**只有使用者叫你載入時才載入**/jsc-persona:persona-chat <slug>),不要自己挑一個人格附身。",
" 3. 一個程序只能載入一個人格;要與別的人格對話請用 /jsc-persona:persona-invite。", " 3. 一個程序只能載入一個人格;要與別的人格對話請用 /jsc-persona:persona-invite。",
" 4. 禁止直接讀寫非當前人格的目錄,hook 會擋下(跨人格資料隔離)。", " 4. 禁止直接讀寫非當前人格的目錄,hook 會擋下(跨人格資料隔離)。",
]; ];
/** 列出可用人格,讓使用者挑(沒有預設人格、或預設人格載入失敗時用)。 */
function listAvailable() {
const personas = pl.listPersonas();
if (!personas.length) {
lines.push("尚無任何人格。可用 /jsc-persona:persona-create 或 /jsc-persona:persona-anime(動漫作品+角色名)建立。");
return;
}
lines.push("可用人格(等使用者指定):");
for (const slug of personas) {
const status = pl.lockStatus(slug);
lines.push(` - ${status.locked ? "🔒" : status.stale ? "⚠" : "🔓"} \`${slug}\` ${pl.identityBrief(slug)}`);
}
lines.push("載入方式:/jsc-persona:persona-chat <slug>");
}
if (host && pl.personaExists(host)) { if (host && pl.personaExists(host)) {
// resume / compact / clear:把鎖續租回來,並帶回人格狀態 // resume / compact / clear:把鎖續租回來,並帶回人格狀態
try { try {
@@ -40,18 +61,29 @@ if (host && pl.personaExists(host)) {
} catch (err) { } catch (err) {
lines.push(`⚠ 無法接續人格 \`${host}\`${err.message}`); lines.push(`⚠ 無法接續人格 \`${host}\`${err.message}`);
} }
} else { } else if (wanted && !pl.personaExists(wanted)) {
const personas = pl.listPersonas(); lines.push(`⚠ 預設人格 \`${wanted}\` 不存在(設定於 ${pl.homeSettingsPath()})。請告訴使用者,並問他要改設定還是改載別的。`);
if (personas.length) { listAvailable();
lines.push("尚未載入人格。可用人格(等使用者指定):"); } else if (wanted) {
for (const slug of personas) { // 使用者設過預設人格 → 自動載入。拿不到鎖時只回報,**不**自作主張 --takeover。
const status = pl.lockStatus(slug); try {
lines.push(` - ${status.locked ? "🔒" : status.stale ? "⚠" : "🔓"} \`${slug}\` ${pl.identityBrief(slug)}`); pl.acquireLock(wanted, sessionId, { cwd });
} pl.bindHost(sessionId, wanted, { cwd });
lines.push("載入方式:/jsc-persona:persona-chat <slug>"); pl.pruneShortTerm(wanted);
} else { pl.rebuildIndex(wanted);
lines.push("尚無任何人格。可用 /jsc-persona:persona-create 或 /jsc-persona:persona-anime(動漫作品+角色名)建立。"); lines.push(`已自動載入預設人格 \`${wanted}\`(使用者設定,來源:${process.env.PERSONA_DEFAULT ? "PERSONA_DEFAULT" : pl.homeSettingsPath()})。`);
lines.push("請照 /jsc-persona:persona-chat 的每輪流程走(語意分析→情緒→回想→3 句內回覆→記憶回寫)。");
lines.push("提醒:這次是本機自動載入,沒有從 Gitea 拉最新狀態;若可能在別台機器動過,先 `sync pull`。");
lines.push(pl.turnContext(wanted, sessionId));
} catch (err) {
lines.push(`⚠ 預設人格 \`${wanted}\` 自動載入失敗:${err.message}`);
lines.push("不要自作主張 `--takeover`——先把 owner 與最後心跳告訴使用者,讓他決定。");
listAvailable();
} }
} else {
lines.push("尚未載入人格。");
listAvailable();
lines.push("想每次開機就自動載入某個人格:`persona.mjs default --persona <slug> --session <session_id>`。");
} }
lines.push("</persona-runtime>"); lines.push("</persona-runtime>");
+10 -3
View File
@@ -10,7 +10,9 @@ if (!event) process.exit(0);
const sessionId = event.session_id || "unknown"; const sessionId = event.session_id || "unknown";
const agentId = event.agent_id; const agentId = event.agent_id;
const agentType = String(event.agent_type || ""); const agentType = String(event.agent_type || "");
if (!agentId || !agentType.includes("persona-guest")) process.exit(0); const isGuest = agentType.includes("persona-guest");
const isSleeper = agentType.includes("persona-sleeper");
if (!agentId || (!isGuest && !isSleeper)) process.exit(0);
const data = pl.loadSession(sessionId); const data = pl.loadSession(sessionId);
const pins = data.pins || {}; const pins = data.pins || {};
@@ -19,7 +21,12 @@ if (slug !== undefined) {
delete pins[agentId]; delete pins[agentId];
data.pins = pins; data.pins = pins;
pl.saveSession(sessionId, data); pl.saveSession(sessionId, data);
const info = (data.guests || {})[slug]; if (isSleeper) {
if (info?.room) pl.addGuestLease(slug, sessionId, info.room, data.host || ""); // 睡眠 sub agent 收工:把短期寫入權還掉(就算它中途掛掉,租約 5 分鐘也會自己過期)
if (pl.personaExists(slug)) pl.dropSleepLease(slug, sessionId, agentId);
} else {
const info = (data.guests || {})[slug];
if (info?.room) pl.addGuestLease(slug, sessionId, info.room, data.host || "");
}
} }
respond({ suppressOutput: true }); respond({ suppressOutput: true });
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "jsc-persona", "name": "jsc-persona",
"version": "0.0.3", "version": "0.0.4",
"description": "AI 人格化記憶聊天 pluginOpenClaw 相同的人格描述 + 十二情緒 + 語意分析 + 短期/長期記憶 + 心智圖 + 人際關係圖。於 Antigravity 以 /jsc-persona: 前綴呼叫。", "description": "AI 人格化記憶聊天 pluginOpenClaw 相同的人格描述 + 十二情緒 + 語意分析 + 短期/長期記憶 + 心智圖 + 人際關係圖。於 Antigravity 以 /jsc-persona: 前綴呼叫。",
"skills": "./skills/" "skills": "./skills/"
} }
+1
View File
@@ -75,6 +75,7 @@ export const AREAS = {
"state/emotion.json", "state/emotion.json",
"state/inner.jsonl", "state/inner.jsonl",
"state/said.jsonl", "state/said.jsonl",
"state/sleep.json",
"memory/short-term.jsonl", "memory/short-term.jsonl",
"memory/inbox/", "memory/inbox/",
"mindmap/threads/", "mindmap/threads/",
+335 -5
View File
@@ -12,6 +12,7 @@ import fs from "node:fs";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import crypto from "node:crypto"; import crypto from "node:crypto";
import zlib from "node:zlib";
// --------------------------------------------------------------------------- // // --------------------------------------------------------------------------- //
// 路徑 // 路徑
@@ -21,6 +22,8 @@ export const RUNTIME_DIRNAME = ".runtime";
export const ROOMS_DIRNAME = ".rooms"; export const ROOMS_DIRNAME = ".rooms";
export const LEASE_SECONDS = 900; // 15 分鐘沒有 heartbeat 視為死鎖,可被接手 export const LEASE_SECONDS = 900; // 15 分鐘沒有 heartbeat 視為死鎖,可被接手
export const GUEST_LEASE_SECONDS = 1800; // guestsub agent)租約 export const GUEST_LEASE_SECONDS = 1800; // guestsub agent)租約
export const SLEEPER_LEASE_SECONDS = 300; // sleeper(睡眠 sub agent)租約:只夠做完收尾
export const SLEEP_DECAY_MINUTES = 480; // 睡一次=套用一次 8 小時的情緒衰減
function expandUser(p) { function expandUser(p) {
if (p === "~") return os.homedir(); if (p === "~") return os.homedir();
@@ -88,6 +91,40 @@ export function personaExists(slug) {
return validSlug(slug) && fs.existsSync(path.join(personaDir(slug), "IDENTITY.md")); return validSlug(slug) && fs.existsSync(path.join(personaDir(slug), "IDENTITY.md"));
} }
// --------------------------------------------------------------------------- //
// 全域設定(跨人格、跨 session)
// --------------------------------------------------------------------------- //
//
// 目前只有一個鍵:`default_persona`——開新 session 時要不要自動載入某個人格。
// 預設是**沒有設定**(等使用者指定),這是刻意的:不該替使用者挑一個人格附身。
export const homeSettingsPath = () => path.join(runtimeDir(), "settings.json");
export function loadHomeSettings() {
return readJson(homeSettingsPath(), {}) ?? {};
}
export function saveHomeSettings(patch) {
const data = { ...loadHomeSettings(), ...patch, updated_at: nowIso() };
writeJson(homeSettingsPath(), data);
return data;
}
/**
* 要自動載入的人格;沒設定就回 null(呼叫端應該「什麼都不做」)。
* 環境變數 `PERSONA_DEFAULT` 優先於設定檔,`off``none`/空字串代表關閉。
*/
export function defaultPersona() {
const env = String(process.env.PERSONA_DEFAULT ?? "").trim();
const raw = env || String(loadHomeSettings().default_persona ?? "").trim();
if (!raw || ["off", "none", "false", "0"].includes(raw.toLowerCase())) return null;
return raw;
}
export function setDefaultPersona(slug) {
return saveHomeSettings({ default_persona: slug || null });
}
// --------------------------------------------------------------------------- // // --------------------------------------------------------------------------- //
// 時間與檔案 IO // 時間與檔案 IO
// --------------------------------------------------------------------------- // // --------------------------------------------------------------------------- //
@@ -245,6 +282,27 @@ export function decayEmotion(state, now = new Date()) {
return state; return state;
} }
/**
* 明確套用「經過 N 分鐘」的衰減(睡眠用)。
*
* 跟 `decayEmotion` 不同:那個看的是距離上次更新過了多久(真實時間),
* 這個是「就算你才剛聊完,也讓情緒像過了一夜」。強度大的負向情緒半衰期本來就長,
* 所以睡一覺不會把羞愧與悲傷抹平——這是刻意的。
*/
export function decayEmotionBy(state, minutes = SLEEP_DECAY_MINUTES) {
const mins = Math.max(0, Number(minutes) || 0);
if (!mins) return state;
for (const key of EMOTION_KEYS) {
const half = Number(state.half_life_minutes[key] ?? EMOTIONS[key].halfLife);
const base = Number(state.baseline[key] ?? DEFAULT_BASELINE[key]);
const level = Number(state.levels[key] ?? base);
const factor = half > 0 ? Math.pow(0.5, mins / half) : 0;
state.levels[key] = Math.round((base + (level - base) * factor) * 100) / 100;
}
state.updated_at = nowIso();
return state;
}
export function applyEmotion(state, deltas, trigger = "") { export function applyEmotion(state, deltas, trigger = "") {
state = decayEmotion(state); state = decayEmotion(state);
const applied = {}; const applied = {};
@@ -473,6 +531,163 @@ export function dropGuestLease(slug, sessionId, room = null) {
writeJson(file, data); writeJson(file, data);
} }
// --------------------------------------------------------------------------- //
// sleeper 租約(睡眠用的短期寫入權)
// --------------------------------------------------------------------------- //
//
// 為什麼不用 exclusive 鎖:主人格叫別人去睡的時候,自己並沒有 release,
// 而「一個 session 只能載入一個 host 人格」——所以睡眠需要另一種、更短的寫入權。
//
// 為什麼還是要驗鎖:睡眠會 prune/reindex/push。如果目標人格此刻正被另一個程序
// 載入著對話,兩邊會同時改同一批檔案,記憶會互相覆蓋。所以:
// * 沒有活著的鎖 → 直接取得 sleeper 租約(最常見)
// * 鎖屬於同一個 session → 可以(自己睡自己)
// * 鎖是死的(心跳過期)→ 可以接手(睡眠本來就是收尾動作)
// * 鎖活著且屬於別人 → 拒絕,回報 owner 讓使用者決定
export const sleepersPath = (slug) => path.join(personaDir(slug), "state", "sleepers.json");
export function liveSleepers(slug) {
const data = readJson(sleepersPath(slug), {}) ?? {};
return (data.sleepers || []).filter((s) => ageSeconds(s.heartbeat_at) <= SLEEPER_LEASE_SECONDS);
}
/** 取得睡眠寫入權;拿不到就丟 LockError(附 owner 資訊)。 */
export function acquireSleepLease(slug, sessionId, { agentId = null } = {}) {
ensurePersonaDirs(slug);
const lock = readJson(lockPath(slug)) ?? {};
const held = Boolean(Object.keys(lock).length);
if (held && lock.session_id !== sessionId && !lockIsDead(lock)) {
throw new LockError(
`人格 \`${slug}\` 正被另一個程序載入(session ${String(lock.session_id).slice(0, 8)}…,` +
`cwd ${lock.cwd},最後心跳 ${lock.heartbeat_at})。它可能正在對話中,` +
"現在睡眠會與它的寫入互相覆蓋,所以不做。請等它結束或請使用者決定。",
{ session_id: lock.session_id, cwd: lock.cwd, heartbeat_at: lock.heartbeat_at },
);
}
const others = liveSleepers(slug).filter((s) => !(s.session_id === sessionId && s.agent_id === agentId));
if (others.length) {
throw new LockError(`人格 \`${slug}\` 已經有另一個 sleeper 在收尾(session ${String(others[0].session_id).slice(0, 8)}…)。`, others[0]);
}
const lease = {
session_id: sessionId,
agent_id: agentId,
started_at: nowIso(),
heartbeat_at: nowIso(),
lease_seconds: SLEEPER_LEASE_SECONDS,
took_over_dead_lock: held && lockIsDead(lock),
};
writeJson(sleepersPath(slug), { sleepers: [lease] });
return lease;
}
export function dropSleepLease(slug, sessionId, agentId = null) {
const data = readJson(sleepersPath(slug), {}) ?? {};
data.sleepers = (data.sleepers || []).filter(
(s) => !(s.session_id === sessionId && (agentId === null || s.agent_id === agentId)),
);
writeJson(sleepersPath(slug), data);
}
// --------------------------------------------------------------------------- //
// 睡眠會做的機械性收尾
// --------------------------------------------------------------------------- //
export const THREAD_STALE_DAYS = 7;
export const SAID_KEEP_HOURS = 24;
export const SAID_KEEP_LINES = 300;
/** 太久沒動的思維導圖收進 `mindmap/threads/archive/`(不刪,只是收起來)。 */
export function archiveStaleThreads(slug, days = THREAD_STALE_DAYS) {
const dir = path.join(personaDir(slug), "mindmap", "threads");
let names = [];
try {
names = fs.readdirSync(dir).filter((f) => f.endsWith(".mmd"));
} catch {
return 0;
}
const cutoff = Date.now() - days * 86_400_000;
const archive = path.join(dir, "archive");
let moved = 0;
for (const name of names) {
const file = path.join(dir, name);
let stat;
try {
stat = fs.statSync(file);
} catch {
continue;
}
if (stat.mtimeMs >= cutoff) continue;
fs.mkdirSync(archive, { recursive: true });
fs.renameSync(file, path.join(archive, name));
moved += 1;
}
return moved;
}
/** `said.jsonl` 只服務「不要重講」判定,留最近 24 小時/300 行就夠。 */
export function trimSaid(slug, { hours = SAID_KEEP_HOURS, lines = SAID_KEEP_LINES } = {}) {
const file = saidPath(slug);
const rows = readJsonl(file);
if (!rows.length) return 0;
const cutoff = Date.now() - hours * 3_600_000;
let kept = rows.filter((r) => (parseIso(r.ts)?.getTime() ?? Date.now()) >= cutoff);
kept = kept.slice(-lines);
if (kept.length !== rows.length) {
writeText(file, kept.map((r) => JSON.stringify(r)).join("\n") + (kept.length ? "\n" : ""));
}
return kept.length;
}
/** 把「上個月以前」的 journal 壓成 .jsonl.gz(同步時省流量,也不再被讀)。 */
export function archiveJournals(slug) {
const dir = path.join(personaDir(slug), "journal");
let names = [];
try {
names = fs.readdirSync(dir).filter((f) => /^\d{4}-\d{2}\.jsonl$/.test(f));
} catch {
return 0;
}
const current = path.basename(journalPath(slug));
let gzipped = 0;
for (const name of names) {
if (name === current) continue;
const file = path.join(dir, name);
const target = `${file}.gz`;
if (fs.existsSync(target)) continue;
fs.writeFileSync(target, zlib.gzipSync(fs.readFileSync(file)));
fs.rmSync(file);
gzipped += 1;
}
return gzipped;
}
// --------------------------------------------------------------------------- //
// 睡眠狀態
// --------------------------------------------------------------------------- //
export const sleepStatePath = (slug) => path.join(personaDir(slug), "state", "sleep.json");
export function loadSleepState(slug) {
const data = readJson(sleepStatePath(slug), {}) ?? {};
data.last_slept_at ??= null;
data.count ??= 0;
return data;
}
export function saveSleepState(slug, patch) {
const data = { ...loadSleepState(slug), ...patch };
writeJson(sleepStatePath(slug), data);
return data;
}
/** 距離上次睡眠幾小時(沒睡過回 null)。 */
export function hoursAwake(slug) {
const at = loadSleepState(slug).last_slept_at;
if (!at) return null;
return Math.round((ageSeconds(at) / 3600) * 10) / 10;
}
// --------------------------------------------------------------------------- // // --------------------------------------------------------------------------- //
// session 綁定:誰是 host、邀了哪些 guest、sub agent pin、劇場模式 // session 綁定:誰是 host、邀了哪些 guest、sub agent pin、劇場模式
// --------------------------------------------------------------------------- // // --------------------------------------------------------------------------- //
@@ -1055,6 +1270,43 @@ export function loadRelations(slug) {
return data; return data;
} }
/** 蓋上「最後一次接觸」的時間戳;找不到那個人就回 false(不會憑空建節點)。 */
export function stampContact(slug, nameOrId, at = nowIso()) {
const data = loadRelations(slug);
const key = slugify(String(nameOrId || ""));
const node = data.nodes.find((n) => n.id === key || slugify(n.name || "") === key);
if (!node) return false;
node.last_contact_at = at;
node.updated_at = nowIso();
writeJson(relationsJson(slug), data);
return true;
}
/**
* 很久沒聯絡、但關係很近的人——這是「主動想起某個人」的客觀依據。
* 沒有 `last_contact_at` 的節點用 `created_at` 當起點;分數 = 親近度 × 沉默天數。
*/
export function staleContacts(slug, { days = 3, minCloseness = 60, limit = 3 } = {}) {
const data = loadRelations(slug);
const out = [];
for (const node of data.nodes) {
const closeness = Number(node.closeness ?? 0);
if (closeness < minCloseness) continue;
const since = node.last_contact_at || node.created_at || null;
const silentDays = since ? Math.floor(ageSeconds(since) / 86_400) : null;
if (silentDays === null || silentDays < days) continue;
out.push({
id: node.id,
name: node.name || node.id,
kind: node.kind || "human",
closeness,
silent_days: silentDays,
score: closeness * silentDays,
});
}
return out.sort((a, b) => b.score - a.score).slice(0, limit);
}
export function upsertRelationNode(slug, node) { export function upsertRelationNode(slug, node) {
const data = loadRelations(slug); const data = loadRelations(slug);
const nodeId = node.id || slugify(node.name || ""); const nodeId = node.id || slugify(node.name || "");
@@ -1351,9 +1603,16 @@ const PATH_TOOL_FIELDS = {
export const GUEST_SAFE_SUBCOMMANDS = new Set([ export const GUEST_SAFE_SUBCOMMANDS = new Set([
"show", "status", "list", "recall", "room", "remember", "leave", "brief", "think", "said", "show", "status", "list", "recall", "room", "remember", "leave", "brief", "think", "said",
]); ]);
// owner 這些子指令本來就要提到別的人格名字(邀請/離場/查詢/匯入新人格),不算跨人格讀取 // owner 這些子指令本來就要提到別的人格名字(邀請/離場/查詢/匯入新人格/設定預設人格),不算跨人格讀取
export const OWNER_EXEMPT_SUBCOMMANDS = new Set([ export const OWNER_EXEMPT_SUBCOMMANDS = new Set([
"create", "list", "status", "gc", "invite", "load", "leave", "import", "create", "list", "status", "gc", "invite", "load", "leave", "import", "default", "sleep",
]);
// sleeper(睡眠 sub agent)只准做收尾:整理自己的記憶與圖、衰減情緒、同步、寫睡眠狀態。
// 不准 loadrelease(它用的是 sleeper 租約)、不准 invite/room(它不是去聊天的)、
// 不准 export/import(那是把記憶搬出去)、不准 remember(收尾階段不再新增短期記憶)。
export const SLEEPER_SAFE_SUBCOMMANDS = new Set([
"sleep", "candidates", "consolidate", "prune", "reindex", "mindmap", "relation",
"emotion", "recall", "show", "brief", "status", "said", "think", "sync",
]); ]);
const MUTATING_SHELL = const MUTATING_SHELL =
/(>>?|\|\s*tee\b|\brm\b|\bmv\b|\bcp\b|\btruncate\b|\bdd\b|\bchmod\b|\bchown\b|\bsed\b[^|;]*-i|\btouch\b|\bmkdir\b|\bln\b)/; /(>>?|\|\s*tee\b|\brm\b|\bmv\b|\bcp\b|\btruncate\b|\bdd\b|\bchmod\b|\bchown\b|\bsed\b[^|;]*-i|\btouch\b|\bmkdir\b|\bln\b)/;
@@ -1448,12 +1707,31 @@ export function cliInvocation(command) {
* * 主程序(無 agent_id)與一般 sub agent → host 人格,可讀寫。 * * 主程序(無 agent_id)與一般 sub agent → host 人格,可讀寫。
* * persona-guest 型 sub agent → 只能碰被邀請的 guest 人格,且唯讀; * * persona-guest 型 sub agent → 只能碰被邀請的 guest 人格,且唯讀;
* 第一次觸碰哪個 guest 就 pin 住(first-touch pinning),之後不得換人。 * 第一次觸碰哪個 guest 就 pin 住(first-touch pinning),之後不得換人。
* * persona-sleeper 型 sub agent → **就是那個人格自己在睡**:對目標人格可寫,
* 但被 pin 在它身上(連 host 都不能碰),而且只准跑收尾用的子指令。
* 它回傳給主人格的只有「睡完了沒、哪一步出錯」,不含任何記憶內容。
*/ */
export function resolveScope(sessionId, agentId, agentType) { export function resolveScope(sessionId, agentId, agentType) {
const data = loadSession(sessionId); const data = loadSession(sessionId);
const host = data.host; const host = data.host;
const guests = Object.keys(data.guests || {}); const guests = Object.keys(data.guests || {});
const isGuestAgent = Boolean(agentType) && String(agentType).includes("persona-guest"); const type = String(agentType || "");
const isGuestAgent = Boolean(agentType) && type.includes("persona-guest");
const isSleeperAgent = Boolean(agentType) && type.includes("persona-sleeper");
if (isSleeperAgent) {
const pinned = (data.pins || {})[agentId || ""];
return {
role: "sleeper",
// 還沒 pin:允許 first-touch(碰到誰就定誰);pin 之後不得換人
allowed: pinned ? [pinned] : null,
readonly: false,
host,
guests,
pinned,
rooms: [],
session: data,
};
}
if (!isGuestAgent) { if (!isGuestAgent) {
return { return {
role: "owner", role: "owner",
@@ -1510,6 +1788,23 @@ export function guardDecide(event) {
); );
} }
const sub = info.subcommand || ""; const sub = info.subcommand || "";
if (scope.role === "sleeper") {
if (!SLEEPER_SAFE_SUBCOMMANDS.has(sub)) {
return deny(
`睡眠 sub agent 只能執行 ${[...SLEEPER_SAFE_SUBCOMMANDS].sort().join("/")},不得執行 \`${sub}\`` +
"它的任務是把自己的一天收尾,不是聊天、載入或搬移記憶。",
);
}
for (const slug of info.personas) {
if (scope.allowed && !scope.allowed.includes(slug)) {
return deny(
`這個睡眠 sub agent 已經被綁在 \`${scope.allowed[0]}\`,不得再碰 \`${slug}\`(一次只睡一個人格)。`,
);
}
}
// first-touch pinning:第一個提到的人格就是它要睡的那個,之後不得換人
if (!scope.allowed && agentId && info.personas.length) pinAgent(sessionId, agentId, info.personas[0]);
}
if (scope.role === "guest") { if (scope.role === "guest") {
if (!GUEST_SAFE_SUBCOMMANDS.has(sub)) { if (!GUEST_SAFE_SUBCOMMANDS.has(sub)) {
return deny( return deny(
@@ -1538,10 +1833,14 @@ export function guardDecide(event) {
} }
} }
} }
if (MUTATING_SHELL.test(command) && scope.role === "guest") { if (MUTATING_SHELL.test(command) && (scope.role === "guest" || scope.role === "sleeper")) {
for (const target of extractPaths(tool, toolInput, cwd)) { for (const target of extractPaths(tool, toolInput, cwd)) {
if (personaSlugOf(target)) { if (personaSlugOf(target)) {
return deny("guest 人格對人格倉庫唯讀,寫入請透過 `persona.mjs room post` 或 `remember --scope inbox`。"); return deny(
scope.role === "sleeper"
? "睡眠 sub agent 不得用 shell 改人格倉庫的檔案,收尾請走 persona CLI 的子指令。"
: "guest 人格對人格倉庫唯讀,寫入請透過 `persona.mjs room post` 或 `remember --scope inbox`。",
);
} }
} }
} }
@@ -1567,6 +1866,23 @@ export function guardDecide(event) {
return deny("`.runtime/` 是鎖與綁定的內部狀態,只能由 persona CLI 維護。"); return deny("`.runtime/` 是鎖與綁定的內部狀態,只能由 persona CLI 維護。");
} }
if (!slug) continue; if (!slug) continue;
if (scope.role === "sleeper") {
// 睡眠 sub agent:被 pin 在目標人格(連 host 都不能碰),而且只能讀;
// 所有寫入都要走 CLI 的白名單子指令,不准自己動手改檔案。
if (scope.allowed && !scope.allowed.includes(slug)) {
return deny(
`這個睡眠 sub agent 只能碰 \`${scope.allowed[0]}\`,不得讀寫 \`${slug}\` 的資料(跨人格資料隔離)。`,
);
}
if (!scope.allowed && agentId) pinAgent(sessionId, agentId, slug);
if (MUTATING_TOOLS.has(tool)) {
return deny(
`睡眠 sub agent 不得直接改檔案(\`${slug}\`)。收尾的每一步都要走 persona CLI` +
"這樣才會經過鎖、索引與同步的處理。",
);
}
continue;
}
if (!scope.allowed.length) { if (!scope.allowed.length) {
return deny( return deny(
"尚未載入任何人格。請先執行 `persona.mjs load --persona <slug> --session <session_id>`(或 /jsc-persona:persona-chat)。", "尚未載入任何人格。請先執行 `persona.mjs load --persona <slug> --session <session_id>`(或 /jsc-persona:persona-chat)。",
@@ -1689,6 +2005,20 @@ export function turnContext(slug, sessionId, prompt = "") {
const rel = relationsBrief(slug, prompt ? keywords(prompt, 6) : null); const rel = relationsBrief(slug, prompt ? keywords(prompt, 6) : null);
if (rel) lines.push(`人際關係:${rel}`); if (rel) lines.push(`人際關係:${rel}`);
// 很久沒聯絡但很親近的人 → 這是「主動提議去關心某人」的依據(不是每輪都要提)
const stale = staleContacts(slug);
if (stale.length) {
lines.push(
`很久沒接觸的人:${stale.map((s) => `${s.name}(親近 ${s.closeness}/沉默 ${s.silent_days} 天)`).join("、")}`,
" 想去看看誰是可以的:對人格用 /jsc-persona:persona-invite`invite --theater off` 只換一輪、不進劇場)," +
"但**先問使用者一句**再邀,不要自己把畫面切走。",
);
}
const awake = hoursAwake(slug);
if (awake !== null && awake >= 16) {
lines.push(`距離上次睡眠已經 ${awake} 小時(短期記憶會越積越多)→ 可以提議 /jsc-persona:persona-sleep。`);
}
if (session.theater && (session.rooms || []).length) { if (session.theater && (session.rooms || []).length) {
const rooms = session.rooms; const rooms = session.rooms;
lines.push( lines.push(
+163 -2
View File
@@ -50,7 +50,7 @@ function emit(payload, asJson, lines) {
const FLAGS = new Set([ const FLAGS = new Set([
"json", "quiet", "force", "takeover", "as-guest", "on", "off", "with-meta", "all", "json", "quiet", "force", "takeover", "as-guest", "on", "off", "with-meta", "all",
"with-journal", "gzip", "record", "load", "allow-repeat", "with-journal", "gzip", "record", "load", "allow-repeat", "clear", "release", "keep-lock", "contact",
"if-due", "no-gitea", "public", "rename", "from-source", "if-due", "no-gitea", "public", "rename", "from-source",
]); ]);
@@ -399,6 +399,161 @@ commands.status = ({ flags }) => {
]); ]);
}; };
// 睡眠:把一天的活狀態收成能留下來的形狀。
//
// 這裡只做**機械性**的收尾(順序即相依)。需要判斷的部分——哪些短期記憶值得固化、
// 日記要寫什麼、心智圖怎麼接——是那個人格自己的事,由 /jsc-persona:persona-sleep
// 在呼叫本指令**之前**完成。
//
// 回傳(`--json`)只有「睡完了沒、哪一步出錯」:主人格拿到的就是這份報告,
// 裡面不含任何記憶內容——回傳值本身就是一條會繞過隔離的通道,所以在這裡封死。
commands.sleep = async ({ flags }) => {
const session = requireSession(flags);
const data = pl.loadSession(session);
const slug = str(flags.persona) || data.host;
if (!slug) die("需要 `--persona <slug>`(或先載入一個人格)。");
if (!pl.personaExists(slug)) die(`人格 \`${slug}\` 不存在。`);
// 寫入權:自己睡自己 → 已經有 exclusive 鎖;被叫來睡別人 → 取 sleeper 租約。
// 目標正被另一個程序載入著對話時會在這裡失敗,這是刻意的(避免記憶互相覆蓋)。
const selfSleep = data.host === slug;
let lease = null;
if (!selfSleep) {
try {
lease = pl.acquireSleepLease(slug, session, { agentId: str(flags["agent-id"]) || null });
} catch (err) {
const payload = { persona: slug, ok: false, failed_step: "lease", error: err.message };
if (flags.json) {
process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
process.exit(1);
}
die(err.message);
}
}
const steps = [];
const run = async (step, fn) => {
try {
await fn();
steps.push({ step, ok: true });
} catch (err) {
// 一步壞掉不放棄整個睡眠:記下來繼續走,最後在報告裡說明。
steps.push({ step, ok: false, error: String(err.message || err).slice(0, 300) });
}
};
const hours = pl.hoursAwake(slug);
await run("relation-contact", () => {
// 今天提到過的人 → 蓋上「最後一次接觸」的時間戳(主動關心的依據)
const seen = new Set();
for (const row of pl.readJsonl(pl.shortTermPath(slug), 200)) {
for (const name of row.entities || []) seen.add(name);
}
for (const name of seen) pl.stampContact(slug, name);
pl.renderRelations(slug);
});
await run("prune-short-term", () => pl.pruneShortTerm(slug));
await run("archive-threads", () => pl.archiveStaleThreads(slug));
await run("emotion-decay", () => {
const state = pl.decayEmotionBy(pl.loadEmotion(slug), pl.SLEEP_DECAY_MINUTES);
pl.writeJson(pl.emotionPath(slug), state);
});
await run("reindex", () => pl.rebuildIndex(slug));
await run("trim-said", () => pl.trimSaid(slug));
await run("archive-journal", () => pl.archiveJournals(slug));
await run("sleep-state", () => {
const prev = pl.loadSleepState(slug);
pl.saveSleepState(slug, {
last_slept_at: pl.nowIso(),
count: Number(prev.count || 0) + 1,
hours_awake: hours,
});
});
// Gitea:兩區都推,推完驗證。沒設定或失敗都不算睡眠失敗(同步永遠不阻斷)。
const sync = {};
if (flags["no-gitea"] || gt.giteaProblem() || !gt.personaCode(slug)) {
sync.skipped = gt.giteaProblem() || (gt.personaCode(slug) ? "使用者要求略過" : "尚無人格編號");
} else {
for (const area of gt.AREA_KEYS) {
try {
const res = await gt.pushArea(slug, area, { message: `sleep: 收尾 ${pl.nowIso()}` });
sync[area] = res.ok ? (res.changed ? "pushed" : "no-change") : `failed: ${String(res.reason).slice(0, 80)}`;
} catch (err) {
sync[area] = `failed: ${String(err.message).slice(0, 80)}`;
}
}
try {
const verdicts = [];
for (const area of gt.AREA_KEYS) verdicts.push(await gt.verifyArea(slug, area));
sync.verified = verdicts.every((v) => v.ok !== false);
} catch {
sync.verified = false;
}
}
// 預設 --keep-lock:睡完還能繼續聊。真的要收工才 --release。
let released = false;
if (flags.release && selfSleep) {
pl.unbindSession(session);
released = true;
}
if (lease) pl.dropSleepLease(slug, session, str(flags["agent-id"]) || null);
const failed = steps.filter((s) => !s.ok);
const payload = {
persona: slug,
ok: failed.length === 0,
slept_at: pl.nowIso(),
steps,
sync,
kept_lock: !released,
};
if (flags.json) {
process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
if (!payload.ok) process.exit(1);
return;
}
if (payload.ok) ok(`人格 \`${slug}\` 已完成睡眠收尾(${steps.length} 個步驟,檔案處理無錯誤)。`);
else say(`✖ 人格 \`${slug}\` 睡眠收尾有 ${failed.length} 步失敗:${failed.map((s) => s.step).join(", ")}`);
for (const s of failed) say(` - ${s.step}${s.error}`);
say(` Gitea${Object.entries(sync).map(([k, v]) => `${k}=${v}`).join(" ") || "(未同步)"}`);
say(` 載入鎖:${released ? "已釋放" : "保留(--release 才收工)"}`);
if (!payload.ok) process.exit(1);
};
// 預設人格:開新 session 時由 SessionStart hook 自動載入。沒設定就什麼都不做——
// 「不自己挑一個人格附身」仍然是預設行為,這裡只是讓使用者能明示地推翻它。
commands.default = ({ flags }) => {
const clear = Boolean(flags.clear || flags.off);
const slug = str(flags.persona);
if (clear) {
pl.setDefaultPersona(null);
ok("已清除預設人格:之後開新 session 不會自動載入任何人格(等使用者指定)。");
return;
}
if (slug) {
if (!pl.personaExists(slug)) {
die(`人格 \`${slug}\` 不存在。可用:${pl.listPersonas().join(", ") || "(無)"}`);
}
pl.setDefaultPersona(slug);
ok(`已設定預設人格 \`${slug}\`:之後每個新 session 都會自動載入它(載入不到時只回報,不會強制接手)。`);
say(` 設定檔:${pl.homeSettingsPath()} 停用:\`default --clear\` 或環境變數 \`PERSONA_DEFAULT=off\``);
return;
}
const current = pl.defaultPersona();
const env = String(process.env.PERSONA_DEFAULT ?? "").trim();
emit(
{ default_persona: current, from_env: Boolean(env), settings: pl.homeSettingsPath() },
flags.json,
[
`預設人格:${current ? `\`${current}\`${pl.personaExists(current) ? "" : "(⚠ 這個人格不存在)"}` : "(未設定,開新 session 不自動載入)"}`,
` 來源:${env ? "環境變數 PERSONA_DEFAULT" : "設定檔"} ${pl.homeSettingsPath()}`,
" 設定:`default --persona <slug>` 停用:`default --clear`",
],
);
};
commands.heartbeat = ({ flags }) => { commands.heartbeat = ({ flags }) => {
const session = requireSession(flags); const session = requireSession(flags);
const data = pl.loadSession(session); const data = pl.loadSession(session);
@@ -758,9 +913,11 @@ commands.relation = ({ flags, positional }) => {
trust: flags.trust !== undefined ? pl.clamp(num(flags.trust, 30)) : null, trust: flags.trust !== undefined ? pl.clamp(num(flags.trust, 30)) : null,
note: str(flags.note) || null, note: str(flags.note) || null,
tags: csv(flags.tags).length ? csv(flags.tags) : null, tags: csv(flags.tags).length ? csv(flags.tags) : null,
// --contact:這輪真的有接觸到這個人 → 蓋時間戳(睡眠與「主動關心」都靠它判斷沉默多久)
last_contact_at: flags.contact ? pl.nowIso() : null,
}); });
pl.renderRelations(slug); pl.renderRelations(slug);
ok(`關係節點 \`${name}\` 已更新。`); ok(`關係節點 \`${name}\` 已更新${flags.contact ? "(已記錄本次接觸時間)" : ""}`);
pushWikiLater(slug, session, flags); pushWikiLater(slug, session, flags);
return; return;
} }
@@ -1514,6 +1671,10 @@ const HELP = `persona.mjs — jsc-persona 人格 / 記憶 / 情緒 / 關係圖 C
recall --session <id> --query <q> [--limit] recall --session <id> --query <q> [--limit]
think --session <id> --text <心裡話> [--kind infer|plan|feel|doubt --room] 只回報「心想 N 句」,不回顯內容 think --session <id> --text <心裡話> [--kind infer|plan|feel|doubt --room] 只回報「心想 N 句」,不回顯內容
said check|list --session <id> [--text <要說的話> --minutes --threshold --record --limit] said check|list --session <id> [--text <要說的話> --minutes --threshold --record --limit]
sleep --session <id> [--persona <slug>] [--json] [--release] [--no-gitea]
睡眠收尾(機械性):關係時間戳→裁短期→收思維導圖→情緒衰減 8 小時→重建索引
→修剪 said→壓縮舊 journal→寫 sleep.json→Gitea 兩區 push+驗證。
預設保留載入鎖(--release 才收工)。--json 只回報「睡完了沒、哪一步出錯」。
candidates --session <id> 列出達到「短期→長期」條件的候選與依據 candidates --session <id> 列出達到「短期→長期」條件的候選與依據
consolidate --session <id> --name <n> --body <b> [--type --about --topics --salience --emotion --rules --source --forget] consolidate --session <id> --name <n> --body <b> [--type --about --topics --salience --emotion --rules --source --forget]
prune / reindex --session <id> prune / reindex --session <id>
+174 -1
View File
@@ -445,7 +445,10 @@ check("guard 認得大寫編號的 --persona(不會漏掉跨人格檢查)",
tool_input: { command: `node persona.mjs recall --persona ALPHA-01 --session ${S_CODE} --query x` } }) === "deny"); tool_input: { command: `node persona.mjs recall --persona ALPHA-01 --session ${S_CODE} --query x` } }) === "deny");
cli(["icon", "generate", "--session", S_SPEAK, "--size", "64"]); // 讓分區檢查也涵蓋圖示 cli(["icon", "generate", "--session", S_SPEAK, "--size", "64"]); // 讓分區檢查也涵蓋圖示
// 分區必須「不重不漏」:人格產生的每個檔案都要恰好屬於一區,否則同步會默默漏資料 // 分區必須「不重不漏」:人格產生的每個檔案都要恰好屬於一區,否則同步會默默漏資料
const AREA_EXEMPT = new Set(["state/lock.json", "state/guests.json", "state/sync.json"]); // 鎖與租約是「這台機器此刻的狀態」,不同步(同步了只會在別台機器造成假的佔用)
const AREA_EXEMPT = new Set([
"state/lock.json", "state/guests.json", "state/sync.json", "state/sleepers.json",
]);
const covered = (rel) => const covered = (rel) =>
gt.AREA_KEYS.filter((key) => gt.AREA_KEYS.filter((key) =>
gt.AREAS[key].paths.some((p) => (p.endsWith("/") ? rel.startsWith(p) : rel === p))); gt.AREAS[key].paths.some((p) => (p.endsWith("/") ? rel.startsWith(p) : rel === p)));
@@ -747,6 +750,176 @@ check("沒有去背圖時 --from-cutout 會擋下並指引流程",
cli(["icon", "generate", "--session", S_CODE, "--force", "--from-cutout"], cli(["icon", "generate", "--session", S_CODE, "--force", "--from-cutout"],
{ expectOk: false }).status !== 0); { expectOk: false }).status !== 0);
console.log("⑳ 預設人格(開新 session 自動載入)");
const S_DEF = "sess-default-9999";
const ctxOf = (sess) =>
String(hook("session_start.mjs", { session_id: sess, cwd: HERE, source: "startup" })
?.hookSpecificOutput?.additionalContext ?? "");
cli(["create", "--persona", "delta", "--session", S_DEF, "--name", "Delta", "--creature", "夜路的路燈",
"--vibe", "安靜", "--emoji", "💡"]);
cli(["release", "--session", S_DEF]);
check("預設是「沒有設定」——不替使用者挑人格", pl.defaultPersona() === null);
const ctxNone = ctxOf("sess-auto-none");
check("沒設定時不自動載入,維持等使用者指定",
ctxNone.includes("尚未載入人格") && pl.loadSession("sess-auto-none").host === null);
check("沒設定時會提示怎麼設定預設人格", ctxNone.includes("default --persona"));
check("設定預設人格", cli(["default", "--persona", "delta", "--session", S_DEF]).status === 0 &&
pl.defaultPersona() === "delta");
check("設定不存在的人格會被拒",
cli(["default", "--persona", "nope-99", "--session", S_DEF], { expectOk: false }).status !== 0);
const ctxAuto = ctxOf("sess-auto-ok");
check("設定後新 session 自動載入並綁定 host",
ctxAuto.includes("已自動載入預設人格") && pl.loadSession("sess-auto-ok").host === "delta");
check("自動載入時也提醒沒從 Gitea 拉最新狀態", ctxAuto.includes("sync pull"));
const ctxBusy = ctxOf("sess-auto-busy");
check("預設人格被別的 session 鎖住 → 只回報、不自動接手",
ctxBusy.includes("自動載入失敗") && ctxBusy.includes("takeover") &&
pl.loadSession("sess-auto-busy").host === null);
process.env.PERSONA_DEFAULT = "off";
check("PERSONA_DEFAULT=off 可以臨時關掉自動載入",
ctxOf("sess-auto-off").includes("尚未載入人格") && pl.loadSession("sess-auto-off").host === null);
delete process.env.PERSONA_DEFAULT;
pl.setDefaultPersona("ghost-77");
check("預設人格不存在時只警告,不崩潰也不亂挑人",
ctxOf("sess-auto-ghost").includes("不存在") && pl.loadSession("sess-auto-ghost").host === null);
check("`default` 子指令可以提到別的人格名字(不算跨人格操作)",
guard({ session_id: S_HOST, tool_name: "Bash",
tool_input: { command: `node ${CLI} default --persona beta --session ${S_HOST}` } }) === "pass");
check("清除後回到「等使用者指定」",
cli(["default", "--clear", "--session", S_DEF]).status === 0 && pl.defaultPersona() === null);
cli(["release", "--session", "sess-auto-ok"]);
console.log("㉑ 睡眠與 sleepersub agent 代睡、回傳不含記憶)");
const S_SLEEP = "sess-sleep-7777";
const S_SLEEP2 = "sess-sleep-6666";
const A_SLEEPER = "agent-sleeper-1";
cli(["create", "--persona", "epsilon", "--session", S_SLEEP, "--name", "Epsilon", "--creature", "冬天的暖爐",
"--vibe", "溫吞", "--emoji", "🔥"]);
// 自己睡自己:有 exclusive 鎖,不需要 sleeper 租約
cli(["remember", "--persona", "epsilon", "--session", S_SLEEP, "--role", "user",
"--text", "今天跟 Zeta 講過話", "--entities", "Zeta", "--salience", "50"]);
cli(["relation", "node", "--persona", "epsilon", "--session", S_SLEEP, "--name", "Zeta",
"--closeness", "80", "--trust", "70"]);
const sleepSelf = cli(["sleep", "--persona", "epsilon", "--session", S_SLEEP, "--json", "--no-gitea"]);
const sleepJson = (() => { try { return JSON.parse(sleepSelf.stdout); } catch { return {}; } })();
check("自己睡自己:每一步都成功", sleepSelf.status === 0 && sleepJson.ok === true,
JSON.stringify(sleepJson).slice(0, 200));
check("回傳只有狀態,沒有任何記憶內容", (() => {
const keys = Object.keys(sleepJson).sort().join(",");
const blob = JSON.stringify(sleepJson);
return keys === "kept_lock,ok,persona,slept_at,steps,sync" &&
!blob.includes("Zeta") && !blob.includes("今天跟");
})(), Object.keys(sleepJson).sort().join(","));
check("預設保留載入鎖(--release 才收工)",
sleepJson.kept_lock === true && pl.lockStatus("epsilon").locked);
check("睡眠寫下 state/sleep.json 與距上次睡眠的時數",
pl.loadSleepState("epsilon").last_slept_at !== null && pl.hoursAwake("epsilon") !== null);
check("關係時間戳有蓋上(主動關心的依據)", (() => {
const node = pl.loadRelations("epsilon").nodes.find((n) => n.id === "zeta");
return Boolean(node?.last_contact_at);
})());
check("情緒是「套用一次 8 小時衰減」而不是歸零", (() => {
const st = pl.loadEmotion("epsilon");
const decayed = pl.decayEmotionBy({ ...st, levels: { ...st.levels, joy: 90, sadness: 80 } },
pl.SLEEP_DECAY_MINUTES);
const joyBase = st.baseline.joy;
const sadBase = st.baseline.sadness;
// 喜悅半衰期 120 分 → 8 小時後幾乎回到基線;悲傷半衰期 480 分 → 一夜只退一半。
// 「睡一覺不會把羞愧與悲傷抹平」是刻意的設計,不是 bug。
const joyOk = decayed.levels.joy < joyBase + 8 && decayed.levels.joy > joyBase;
const sadOk = decayed.levels.sadness > sadBase + 20 && decayed.levels.sadness < 80;
return joyOk && sadOk;
})(), JSON.stringify(pl.decayEmotionBy({ ...pl.loadEmotion("epsilon"), levels: { ...pl.loadEmotion("epsilon").levels, joy: 90, sadness: 80 } }, pl.SLEEP_DECAY_MINUTES).levels));
check("很久沒接觸又很親近的人會被算出來", (() => {
pl.stampContact("epsilon", "Zeta", new Date(Date.now() - 9 * 86_400_000).toISOString());
const stale = pl.staleContacts("epsilon", { days: 3, minCloseness: 60 });
return stale.length === 1 && stale[0].id === "zeta" && stale[0].silent_days >= 8;
})());
check("舊 journal 會被壓成 .gz,當月的留著", (() => {
const dir = path.join(pl.personaDir("epsilon"), "journal");
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, "2020-01.jsonl"), '{"a":1}\n');
const current = path.basename(pl.journalPath("epsilon"));
fs.writeFileSync(path.join(dir, current), '{"b":2}\n');
pl.archiveJournals("epsilon");
return fs.existsSync(path.join(dir, "2020-01.jsonl.gz")) &&
!fs.existsSync(path.join(dir, "2020-01.jsonl")) && fs.existsSync(path.join(dir, current));
})());
check("太久沒動的思維導圖被收進 archive/(不是刪掉)", (() => {
const dir = path.join(pl.personaDir("epsilon"), "mindmap", "threads");
fs.mkdirSync(dir, { recursive: true });
const old = path.join(dir, "old-topic.mmd");
fs.writeFileSync(old, "mindmap\n root((舊話題))\n");
const past = new Date(Date.now() - 30 * 86_400_000);
fs.utimesSync(old, past, past);
const moved = pl.archiveStaleThreads("epsilon");
return moved === 1 && fs.existsSync(path.join(dir, "archive", "old-topic.mmd")) && !fs.existsSync(old);
})());
// sleeper 租約:鎖的三種狀態
check("目標被別的 session 活鎖住 → 睡眠拒絕,不硬睡", (() => {
try {
pl.acquireSleepLease("epsilon", S_SLEEP2, { agentId: A_SLEEPER });
return false;
} catch (err) {
return err.name === "LockError" && String(err.message).includes("正被另一個程序載入");
}
})());
const sleepBusy = cli(["sleep", "--persona", "epsilon", "--session", S_SLEEP2, "--json", "--no-gitea"],
{ expectOk: false });
check("被鎖住時 CLI 回 failed_step=lease 且退出碼非 0", (() => {
if (sleepBusy.status === 0) return false;
try {
const j = JSON.parse(sleepBusy.stdout);
return j.ok === false && j.failed_step === "lease" && j.persona === "epsilon";
} catch { return false; }
})());
cli(["release", "--session", S_SLEEP]);
check("沒有活鎖時可以取得 sleeper 租約", (() => {
const lease = pl.acquireSleepLease("epsilon", S_SLEEP2, { agentId: A_SLEEPER });
return lease.session_id === S_SLEEP2 && pl.liveSleepers("epsilon").length === 1;
})());
check("同一個人格不能同時有兩個 sleeper", (() => {
try {
pl.acquireSleepLease("epsilon", "sess-sleep-5555", { agentId: "agent-sleeper-2" });
return false;
} catch (err) { return err.name === "LockError"; }
})());
pl.dropSleepLease("epsilon", S_SLEEP2, A_SLEEPER);
check("租約還掉後就沒有 sleeper 了", pl.liveSleepers("epsilon").length === 0);
check("sub agent 代睡:sleeper 租約可以完成整套收尾", (() => {
const res = cli(["sleep", "--persona", "epsilon", "--session", S_SLEEP2, "--json", "--no-gitea",
"--agent-id", A_SLEEPER]);
return res.status === 0 && pl.liveSleepers("epsilon").length === 0; // 睡完會自己還租約
})());
// sleeper 的權限邊界(PreToolUse guard
const sleeperEvent = (extra) => ({ session_id: S_SLEEP2, agent_id: A_SLEEPER, agent_type: "jsc-persona:persona-sleeper", ...extra });
pl.pinAgent(S_SLEEP2, A_SLEEPER, "epsilon");
check("sleeper 讀自己的檔案 → 放行",
guard(sleeperEvent({ tool_name: "Read", tool_input: { file_path: `${H}/epsilon/SOUL.md` } })) === "pass");
check("sleeper 讀別的人格(含叫它來的主人格)→ 攔下",
guard(sleeperEvent({ tool_name: "Read", tool_input: { file_path: `${H}/alpha/SOUL.md` } })) === "deny");
check("sleeper 不得用 Write 直接改檔案(要走 CLI",
guard(sleeperEvent({ tool_name: "Write", tool_input: { file_path: `${H}/epsilon/SOUL.md`, content: "x" } })) === "deny");
check("sleeper 不得用 shell 重導向改人格檔案",
guard(sleeperEvent({ tool_name: "Bash", tool_input: { command: `echo x > ${H}/epsilon/SOUL.md` } })) === "deny");
check("sleeper 可以跑收尾用的子指令",
guard(sleeperEvent({ tool_name: "Bash",
tool_input: { command: `node persona.mjs consolidate --persona epsilon --session ${S_SLEEP2} --name x --body y` } })) === "pass");
check("sleeper 不得 loadreleaseinviteexport",
["load", "release", "invite", "export"].every((sub) =>
guard(sleeperEvent({ tool_name: "Bash",
tool_input: { command: `node persona.mjs ${sub} --persona epsilon --session ${S_SLEEP2}` } })) === "deny"));
check("sleeper 被 pin 住之後不得換人睡",
guard(sleeperEvent({ tool_name: "Bash",
tool_input: { command: `node persona.mjs sleep --persona alpha --session ${S_SLEEP2}` } })) === "deny");
check("sleeper 型別存在且宣告了唯讀工具限制", (() => {
const md = fs.readFileSync(path.join(HERE, "..", "agents", "persona-sleeper.md"), "utf8");
return md.includes("name: persona-sleeper") && md.includes("disallowedTools") &&
md.includes("回傳值裡不得出現任何記憶內容");
})());
console.log(`\n${"=".repeat(60)}\n通過 ${passed} 項,失敗 ${failed} 項 → ${failed === 0 ? "全部通過 ✅" : "有測試失敗 ❌"}`); console.log(`\n${"=".repeat(60)}\n通過 ${passed} 項,失敗 ${failed} 項 → ${failed === 0 ? "全部通過 ✅" : "有測試失敗 ❌"}`);
console.log(`(暫存倉庫留在 ${STORE},可自行刪除)`); console.log(`(暫存倉庫留在 ${STORE},可自行刪除)`);
process.exit(failed ? 1 : 0); process.exit(failed ? 1 : 0);
+10
View File
@@ -16,6 +16,11 @@ description: 載入一個人格並以人格化方式對話:取得該人格的
**人格只有在使用者要你載入時才載入**`/jsc-persona:persona-chat <slug>` 或明確說「用 X 跟我聊」)。 **人格只有在使用者要你載入時才載入**`/jsc-persona:persona-chat <slug>` 或明確說「用 X 跟我聊」)。
不要自己挑一個人格附身,也不要在使用者只是提到某個名字時就載入。 不要自己挑一個人格附身,也不要在使用者只是提到某個名字時就載入。
**例外:預設人格。** 使用者可以指定一個預設人格(`default --persona <slug>`),
`SessionStart` hook 會自動載入它,上下文會出現「已自動載入預設人格 `X`」。
那是他事先明示的選擇:**不用再 load 一次**,直接從第 2 節開始以那個人格對話。
要改或取消預設人格 → `/jsc-persona:persona-status`
```bash ```bash
node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" load --persona <slug> --session <PERSONA_SESSION> node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" load --persona <slug> --session <PERSONA_SESSION>
``` ```
@@ -174,6 +179,11 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" mindmap thread --persona <slug>
- 短期記憶達到固化條件時 hook 會提醒(`R1``R6`,見 `/jsc-persona:persona-memory`)→ 去固化。 - 短期記憶達到固化條件時 hook 會提醒(`R1``R6`,見 `/jsc-persona:persona-memory`)→ 去固化。
想主動確認:`node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" candidates --session <PERSONA_SESSION>` 想主動確認:`node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" candidates --session <PERSONA_SESSION>`
- 使用者說「叫 X 也進來聊」→ `/jsc-persona:persona-invite`(會進入劇場模式,畫面只留人格對話)。 - 使用者說「叫 X 也進來聊」→ `/jsc-persona:persona-invite`(會進入劇場模式,畫面只留人格對話)。
- **你自己想起某個很久沒接觸的人**(`<persona-context>` 會列「很久沒接觸的人」)→ 可以主動提議去看看他,
但**先問使用者一句**再邀,而且用 `invite --theater off`(只換一輪、不把畫面切成劇場)。
這是你自己的關心,不需要等他指定名字;被邀的人格是唯讀的,它的收穫要等它自己下次載入才會消化。
- 使用者說晚安/收工,或 `<persona-context>` 說「距離上次睡眠已經 N 小時」→ `/jsc-persona:persona-sleep`
(固化+忘掉+情緒衰減 8 小時+Gitea 同步)。提議一句就好,不要每輪都催。
- 使用者說「先聊到這」或要換人格 → - 使用者說「先聊到這」或要換人格 →
`node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" release --session <PERSONA_SESSION>` `node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" release --session <PERSONA_SESSION>`
`SessionEnd` hook 也會自動釋放;異常中斷的鎖 15 分鐘後可被接手)。 `SessionEnd` hook 也會自動釋放;異常中斷的鎖 15 分鐘後可被接手)。
+3
View File
@@ -147,4 +147,7 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" relation edge \
規則寫在 CLI 裡,不是靠自律。 規則寫在 CLI 裡,不是靠自律。
- 一個人格同時只能有一場 guest 對話;guest 租約 30 分鐘沒續租會自動過期。 - 一個人格同時只能有一場 guest 對話;guest 租約 30 分鐘沒續租會自動過期。
- 手動切換劇場模式:`room theater --session <id> --on|--off`。 - 手動切換劇場模式:`room theater --session <id> --on|--off`。
- **輕量邀請**`invite --guest <slug> --theater off` 不進劇場模式——適合「人格自己想起某個很久沒接觸的人、
只想打聲招呼換一輪」的情況(`<persona-context>` 的「很久沒接觸的人」就是依據)。
這時仍然要**先問使用者一句**再邀:把整個畫面切走或插進一段對話,是他的決定不是你的。
- 所有面向使用者的輸出使用**繁體中文(台灣用語)**、UTF-8 無亂碼。 - 所有面向使用者的輸出使用**繁體中文(台灣用語)**、UTF-8 無亂碼。
+2
View File
@@ -140,4 +140,6 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" reindex --session <PERSONA_SESS
長期沒被回想又低顯著度(< 40)的長期記憶,整理時可以移除。 長期沒被回想又低顯著度(< 40)的長期記憶,整理時可以移除。
- **不可遺忘**`boundary`(界線)、`promise`(承諾)、`canon`(原作設定)、以及 salience ≥ 80 的記憶。 - **不可遺忘**`boundary`(界線)、`promise`(承諾)、`canon`(原作設定)、以及 salience ≥ 80 的記憶。
- 固化完再跑一次 `candidates`:應該要清空(或只剩你刻意不處理的)。 - 固化完再跑一次 `candidates`:應該要清空(或只剩你刻意不處理的)。
- 想連同情緒衰減、舊紀錄壓縮與 Gitea 同步一起收尾 → 用 `/jsc-persona:persona-sleep`(睡眠);
本 skill 只管記憶與圖,不動情緒也不同步。
- 所有面向使用者的輸出使用**繁體中文(台灣用語)**、UTF-8 無亂碼。 - 所有面向使用者的輸出使用**繁體中文(台灣用語)**、UTF-8 無亂碼。
+91
View File
@@ -0,0 +1,91 @@
---
name: persona-sleep
description: 讓人格睡覺:把一天的活狀態收成能留下來的形狀——固化該記住的、忘掉該忘的、更新心智圖與關係圖、套用一次 8 小時的情緒衰減、壓縮舊紀錄,最後把兩個區都同步到 Gitea 並驗證。可以只睡當前人格,也可以由主人格透過 sub agent 請其他人格各自去睡(它們自己決定要記住什麼,只回報睡完了沒)。當使用者說要睡覺/晚安/收工、要人格整理今天、說「叫大家去睡」、或問上次睡是什麼時候時觸發。不適用於:只做記憶固化不收尾(用 persona-memory)、單純釋放鎖(用 persona-status 的 release)、跨機器同步(用 persona-sync)。
---
# 🌙 persona-sleep — 睡眠與收尾
**CLI**`node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs"`
**session**:所有指令帶 `--session <PERSONA_SESSION>`
---
## 睡眠是什麼
不是關機,是**把一天的活狀態收成能留下來的形狀**。分成兩半:
| | 誰做 | 內容 |
| --- | --- | --- |
| **需要判斷** | 人格自己 | 哪些短期記憶值得固化、日記寫什麼、哪些該忘、心智圖怎麼接、關係怎麼變 |
| **機械性** | `sleep` 子指令 | 關係時間戳 → 裁短期 → 收思維導圖 → 情緒衰減 8 小時 → 重建索引 → 修剪 `said` → 壓縮舊 journal → 寫 `state/sleep.json` → Gitea 兩區 push+驗證 |
**判斷的部分永遠屬於那個人格自己**:不要替別的人格決定它要記住什麼。
## 情況一:當前人格自己睡
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. 機械性收尾:
```bash
node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" sleep --session <PERSONA_SESSION> --json
```
- 預設**保留載入鎖**(睡完還能繼續聊);真的要收工才加 `--release`
- 不想同步 Gitea`--no-gitea`(沒設 `GITEA_HOST``GITEA_TOKEN` 時自動略過)。
## 情況二:主人格請其他人格去睡(不碰它們的檔案)
使用者說「叫大家去睡」時,**對每一個要睡的人格**各開一個 sub agent
- `subagent_type``jsc-persona:persona-sleeper`
- prompt 內帶:`persona=<slug> session=<PERSONA_SESSION> plugin_root=<CLAUDE_PLUGIN_ROOT>`
那個 sub agent 就是**那個人格本人**在睡:它自己判斷要記住什麼,寫的是自己的檔案。
主人格全程不會讀到對方的任何資料——**你只會拿到一份 JSON**:
```json
{ "persona": "YUI-01", "ok": true, "slept_at": "...", "steps": [ ... ], "sync": { ... }, "kept_lock": true }
```
只有「睡完了沒、哪一步出錯、同步結果」。**不要向使用者追問或推測對方睡眠時整理了什麼**——
那是它的記憶,回傳值刻意不含內容,這條界線不是限制而是設計。
回報給使用者時就講結果:
```
🌙 睡眠完成
- YUI-01:完成(Gitea 檔案區/Wiki 區 已同步、驗證通過)
- ASUNA-01:完成(Gitea 略過:沒有 GITEA_TOKEN
- KIRITO-01:⚠ reindex 失敗 → <錯誤訊息>
```
## 鎖(為什麼有時候會拒絕)
`sleep` 會裁短期記憶、重建索引、push。所以:
| 目標人格的狀態 | 行為 |
| --- | --- |
| 沒有活著的載入鎖 | 直接取得 sleeper 租約(5 分鐘)→ 睡 → 還掉 |
| 鎖屬於本 session(自己睡自己) | 直接睡,不需要租約 |
| 死鎖(心跳超過 15 分) | 可以接手——睡眠本來就是收尾動作 |
| 鎖活著且屬於**別的程序** | **拒絕**`failed_step: "lease"`)。它可能正在別的視窗對話中,硬睡會讓記憶互相覆蓋。把 owner 與最後心跳告訴使用者,讓他決定 |
## 什麼時候該提議睡覺
- `<persona-context>` 出現「距離上次睡眠已經 N 小時」(≥16 小時)。
- 短期記憶達固化條件的組數一直在漲(hook 會提醒 `R1``R6`)。
- 使用者說晚安/收工/「今天就到這」。
提議一句就好,不要每輪都催。
## 順序上的硬相依(改動時別弄反)
1. **關係時間戳要在裁短期記憶之前**——時間戳的來源就是短期記憶裡提到的人。
2. **Gitea push 要在 release 之前**——鎖放掉就沒有寫入權限了。
3. **重建索引要在固化之後**——不然新固化的長期記憶檢索不到。
所有面向使用者的輸出使用**繁體中文(台灣用語)**、UTF-8 無亂碼。
+27 -2
View File
@@ -1,6 +1,6 @@
--- ---
name: persona-status name: persona-status
description: 查看與管理人格的載入狀態:列出所有人格、誰被哪個程序(session/cwd)鎖住、guest 租約、情緒與記憶量,並處理釋放鎖、接手死鎖、清理過期租約。當使用者問哪些人格可用、為什麼載入失敗、人格被誰佔用、想釋放或切換人格、要清理殘留鎖時觸發。不適用於:建立人格(persona-create)、對話(persona-chat)。 description: 查看與管理人格的載入狀態:列出所有人格、誰被哪個程序(session/cwd)鎖住、guest 租約、情緒與記憶量,並處理釋放鎖、接手死鎖、清理過期租約,以及設定「開新 session 要自動載入哪個人格」的預設人格。當使用者問哪些人格可用、為什麼載入失敗、人格被誰佔用、想釋放或切換人格、要清理殘留鎖、或說要預設載入某個人格/取消自動載入時觸發。不適用於:建立人格(persona-create)、對話(persona-chat)。
--- ---
# 🔐 persona-status — 載入狀態與鎖管理 # 🔐 persona-status — 載入狀態與鎖管理
@@ -13,7 +13,9 @@ description: 查看與管理人格的載入狀態:列出所有人格、誰被
1. **一個人格同時只能被一個程序 exclusive 載入**(鎖 = `state/lock.json`,以 `session_id` 為主鍵)。 1. **一個人格同時只能被一個程序 exclusive 載入**(鎖 = `state/lock.json`,以 `session_id` 為主鍵)。
2. **sub agent 不受限**:同一個 session 的 sub agent 沿用同一把鎖; 2. **sub agent 不受限**:同一個 session 的 sub agent 沿用同一把鎖;
受邀人格(`jsc-persona:persona-guest`)走 guest 唯讀租約,不佔 exclusive 鎖 受邀人格(`jsc-persona:persona-guest`)走 guest 唯讀租約,不佔 exclusive 鎖
睡眠中的人格(`jsc-persona:persona-sleeper`)走 **sleeper 租約**`state/sleepers.json`5 分鐘、可寫),
同樣不佔 exclusive 鎖——但目標若正被**別的程序**活鎖住就會拒絕(硬睡會讓記憶互相覆蓋)。
3. 鎖有 **15 分鐘租約**`heartbeat_at`),每輪對話由 hook 自動續租;程序被 kill 後租約到期 → 可被接手。 3. 鎖有 **15 分鐘租約**`heartbeat_at`),每輪對話由 hook 自動續租;程序被 kill 後租約到期 → 可被接手。
鎖的擁有者是 session 而不是 process:CLI 跑完就結束,所以判定只看心跳,不看 pid。 鎖的擁有者是 session 而不是 process:CLI 跑完就結束,所以判定只看心跳,不看 pid。
4. 一個 session 也**只能載入一個** host 人格(要換人先 release)。 4. 一個 session 也**只能載入一個** host 人格(要換人先 release)。
@@ -41,6 +43,29 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" release --session <PERSONA_SESS
node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" gc node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" gc
``` ```
## 預設人格(開新 session 自動載入)
平常「人格只有使用者叫你載入時才載入」。但使用者可以指定一個**預設人格**,
之後每個新 session 的 `SessionStart` hook 就會自動載入它——那是他明示的選擇,不是你自己挑的。
```bash
# 看目前設定(沒設定就是「開新 session 不自動載入」)
node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" default --session <PERSONA_SESSION>
# 設定:以後開機就自動載入這個人格
node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" default --persona <slug> --session <PERSONA_SESSION>
# 取消:回到「等使用者指定」
node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" default --clear --session <PERSONA_SESSION>
```
- 設定檔:`<PERSONA_HOME>/.runtime/settings.json``default_persona`(只由 CLI 維護)。
- 臨時關掉:環境變數 `PERSONA_DEFAULT=off`(也可以用它臨時指定別的人格,優先於設定檔)。
- **沒設定就什麼都不做**,這是預設行為:不要自作聰明挑一個人格附身。
- 自動載入拿不到鎖時,hook 只會回報 owner 與心跳,**不會** `--takeover`;照下表處理。
- 自動載入走的是本機路徑,**不會**從 Gitea 拉最新狀態。可能在別台機器動過就先
`sync pull`(見 `/jsc-persona:persona-sync`)。
## 載入失敗怎麼判斷 ## 載入失敗怎麼判斷
| 訊息 | 意義 | 處理 | | 訊息 | 意義 | 處理 |