feat: 預設人格自動載入 + 睡眠(sleep 指令與 persona-sleeper sub agent)
兩件事,共用「關係圖 last_contact_at」這個零件,所以放同一個 commit (兩者在 persona-lib/persona.mjs/README 裡的 hunk 是交錯的,硬拆會拆壞)。 ## 預設人格(開新 session 自動載入) - 新增 `default` 子指令:查詢/`--persona <slug>` 設定/`--clear` 取消。 - 設定存在 `<PERSONA_HOME>/.runtime/settings.json` 的 `default_persona`, 環境變數 `PERSONA_DEFAULT` 優先(`off` 可臨時關掉)。 - `SessionStart` hook 四條路徑:沒設定 → 維持原本「等使用者指定」;設定了 → 取鎖、綁 host、 prune、reindex 並注入人格狀態;拿不到鎖 → 只回報 owner 與心跳,不自作主張 takeover; 人格不存在 → 警告並改列可用人格。 - 「不替使用者挑一個人格附身」仍然是預設行為,這只是讓他能明示地推翻它。 ## 睡眠 - 新增 `sleep` 子指令(機械性收尾,順序即相依):關係時間戳 → 裁短期記憶 → 收起太久沒動的思維導圖 → 套用一次 8 小時的情緒衰減 → 重建索引 → 修剪 said → 壓縮舊 journal → 寫 state/sleep.json → Gitea 兩區 push 並驗證。 預設保留載入鎖(`--release` 才收工),每一步各自 try/catch,一步壞掉不放棄整場睡眠。 - 需要判斷的部分(固化什麼、忘掉什麼、日記寫什麼)留給人格自己,由 skill 驅動。 - 新增 `persona-sleeper` sub agent 型別:**那個人格本人在睡**。對自己可寫但被 pin 住 (連叫它來的主人格都不能碰)、只准跑 15 個收尾子指令、不得用 Write/Edit 或 shell 改檔案。 - 新增 sleeper 租約(`state/sleepers.json`,300 秒):沒活鎖就取得、同 session 直接睡、 死鎖可接手、**別的程序活鎖住則拒絕**(硬睡會讓兩邊的記憶互相覆蓋)。 - `--json` 回傳刻意很窮:只有 persona/ok/slept_at/steps/sync/kept_lock。 sub agent 的回傳值會進主人格的上下文,是一條會從正門繞過跨人格隔離的通道, 所以在 CLI 這一層封死,不靠提示詞自律。 - 關係圖新增 `last_contact_at`(`relation node --contact`/睡眠自動蓋), 並用 `staleContacts()` 算出「很久沒接觸又很親近的人」——人格主動提議去關心誰的依據。 輕量邀請用既有的 `invite --theater off`(不切走畫面),但要先問使用者一句。 - 新增 skill `persona-sleep`;`SubagentStop` 會還掉 sleeper 租約。 ## 驗證 `node scripts/selftest.mjs` → 210 項全綠(新增第 ⑳ 節 12 項、第 ㉑ 節 23 項), 其中包含「回傳值不含任何記憶內容」與 sleeper 的六條權限邊界。 版本號 0.0.5。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "jsc-persona",
|
"name": "jsc-persona",
|
||||||
"version": "0.0.3",
|
"version": "0.0.5",
|
||||||
"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": {
|
||||||
|
|||||||
@@ -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 Body/Character Design/
|
→ `icon generate --from-cutout`。找圖時**優先官方設定稿**(Full Body/Character 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/`(向量原稿 + 512/1024)
|
15. **Wiki 必須保存並同步形象圖**:`icon.svg`、`icon.png` 與 `icon/`(向量原稿 + 512/1024)
|
||||||
都在 Wiki 區,另有自動產生的 **Icon** 頁。`icon generate` 推完會**回頭驗證**,
|
都在 Wiki 區,另有自動產生的 **Icon** 頁。`icon generate` 推完會**回頭驗證**,
|
||||||
`sync verify` 可隨時檢查。兩個容易踩的坑:
|
`sync verify` 可隨時檢查。兩個容易踩的坑:
|
||||||
* Wiki 頁面**只能用 Markdown 圖片語法** ``——Gitea 只改寫這種語法為
|
* Wiki 頁面**只能用 Markdown 圖片語法** ``——Gitea 只改寫這種語法為
|
||||||
|
|||||||
@@ -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 agent(prompt 帶 `persona=<slug> session=<id>`)。
|
||||||
|
它就是那個人格本人,對自己可寫但被 pin 住、只准跑收尾子指令、不得碰任何其他人格(包含叫它來的主人格)。
|
||||||
|
- **回傳值刻意很窮**:`{persona, ok, slept_at, steps, sync, kept_lock}`——只有狀態。
|
||||||
|
回傳值本身就是一條會繞過隔離的通道,所以在 CLI 這一層封死,不靠提示詞自律。
|
||||||
|
- **鎖**:沒有活鎖 → 取 5 分鐘的 sleeper 租約;同 session → 直接睡;死鎖 → 可接手;
|
||||||
|
**別的程序活鎖住 → 拒絕**(硬睡會讓兩邊的記憶互相覆蓋)。
|
||||||
|
- 順序上的硬相依:關係時間戳早於裁短期、push 早於 release、reindex 晚於固化。
|
||||||
|
|
||||||
## Hooks(Claude Code)
|
## Hooks(Claude 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` | 解除 guest/sleeper 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)、
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "jsc-persona",
|
"name": "jsc-persona",
|
||||||
"version": "0.0.3",
|
"version": "0.0.5",
|
||||||
"description": "AI 人格化記憶聊天 plugin:OpenClaw 相同的人格描述 + 十二情緒 + 語意分析 + 短期/長期記憶 + 心智圖 + 人際關係圖。於 Antigravity 以 /jsc-persona: 前綴呼叫。",
|
"description": "AI 人格化記憶聊天 plugin:OpenClaw 相同的人格描述 + 十二情緒 + 語意分析 + 短期/長期記憶 + 心智圖 + 人際關係圖。於 Antigravity 以 /jsc-persona: 前綴呼叫。",
|
||||||
"skills": "./skills/"
|
"skills": "./skills/"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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; // guest(sub agent)租約
|
export const GUEST_LEASE_SECONDS = 1800; // guest(sub 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)只准做收尾:整理自己的記憶與圖、衰減情緒、同步、寫睡眠狀態。
|
||||||
|
// 不准 load/release(它用的是 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
@@ -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
@@ -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("㉑ 睡眠與 sleeper(sub 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 不得 load/release/invite/export",
|
||||||
|
["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);
|
||||||
|
|||||||
@@ -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 分鐘後可被接手)。
|
||||||
|
|||||||
@@ -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 無亂碼。
|
||||||
|
|||||||
@@ -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 無亂碼。
|
||||||
|
|||||||
@@ -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 無亂碼。
|
||||||
@@ -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`)。
|
||||||
|
|
||||||
## 載入失敗怎麼判斷
|
## 載入失敗怎麼判斷
|
||||||
|
|
||||||
| 訊息 | 意義 | 處理 |
|
| 訊息 | 意義 | 處理 |
|
||||||
|
|||||||
Reference in New Issue
Block a user