diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index d68932e..bc25cdc 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "jsc-persona", - "version": "0.0.3", + "version": "0.0.4", "description": "AI 人格化記憶聊天 plugin:以 OpenClaw 相同的身分描述(IDENTITY/SOUL)建立人格(可用動漫作品+角色名上網蒐集設定),結合 hook 強制的人格載入鎖與跨人格隔離、六正向+六負向十二情緒、語意分析、短期/長期記憶與固化條件、心智圖、思維導圖與人際關係圖;可用 sub agent 邀請其他人格同場對話(劇場模式只顯示人格對話)。於 Claude Code 以 /jsc-persona: 前綴呼叫。", "skills": "./skills", "author": { diff --git a/AGENTS.md b/AGENTS.md index a0a709d..b772e12 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,30 +20,38 @@ 要讓兩個人格對話,用 `/jsc-persona:persona-invite`(`persona-guest` sub agent + 聊天室), **不要**去讀對方的人格目錄——會被 hook deny,而且那是設計上的紅線。 4. **人格資料不在本 repo**,預設在 `~/.claude/personas/`(可用 `PERSONA_HOME` 覆寫)。 -5. **人格由使用者呼叫才載入**,不要自己挑一個人格附身。 +5. **人格由使用者呼叫才載入**,不要自己挑一個人格附身。唯一例外是使用者自己設的**預設人格** + (`persona.mjs default --persona `,存在 `.runtime/settings.json`):設了 `SessionStart` + 才會自動載入並在上下文寫明;沒設就什麼都不做。載入不到(被別的程序鎖住)只回報,不自動接手。 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` 會直接擋下重複與過長的發言)。 這三條在劇場模式一樣生效。 -8. **人格可搬家**:`export` / `import`(單一 JSON bundle)。匯出只能匯出「本 session 載入的人格」, +9. **人格可搬家**:`export` / `import`(單一 JSON bundle)。匯出只能匯出「本 session 載入的人格」, 其他人格一律 deny——匯出等於把記憶讀出來。 -9. **人格有編號**:英文名全大寫+兩位索引(`ASUNA-01`),同名才遞增。編號同時是新人格的 +10. **人格有編號**:英文名全大寫+兩位索引(`ASUNA-01`),同名才遞增。編號同時是新人格的 本機目錄名與 **Gitea 存取庫名稱**。中文名要先轉羅馬拼音並跟使用者確認拼法。 -10. **人格存在 Gitea,本機是工作副本**:高頻活狀態進**檔案區**(每輪背景 push), +11. **人格存在 Gitea,本機是工作副本**:高頻活狀態進**檔案區**(每輪背景 push), 低頻身分與長期記憶進 **Wiki 區**(固化/改身分/release 時 push)。 **同步失敗永遠不阻斷對話**;沒設 `GITEA_HOST`/`GITEA_TOKEN` 就純本機運作。 -11. **人格圖示在資料補齊之後才產生**:SVG 與 PNG 是同一張圖(共用單位座標與點陣字), +12. **人格圖示在資料補齊之後才產生**:SVG 與 PNG 是同一張圖(共用單位座標與點陣字), 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/ Avatar):解析度高,而且多半是透明底或白底,去背幾乎免費。挑的那張要**對得上該人格 「最新一次登場」的形態**(同一個角色有很多套造型)。 每一步都要**用 Read 打開確認**:去背有沒有殘留、構圖對不對。 找不到可用官方圖才退回 `--features` 的向量重繪。 -13. **選用工具缺了要「提示安裝」,不准靜默降級**:`toolReport()` 會列出缺什麼、為什麼要、 +14. **選用工具缺了要「提示安裝」,不准靜默降級**:`toolReport()` 會列出缺什麼、為什麼要、 怎麼裝(venv 免 sudo)。注意 **OpenCV 5 拿掉了 `CascadeClassifier`,必須裝 4.x**。 plugin 本體仍然零依賴:沒有這些工具照樣能產生形象圖。 -14. **Wiki 必須保存並同步形象圖**:`icon.svg`、`icon.png` 與 `icon/`(向量原稿 + 512/1024) +15. **Wiki 必須保存並同步形象圖**:`icon.svg`、`icon.png` 與 `icon/`(向量原稿 + 512/1024) 都在 Wiki 區,另有自動產生的 **Icon** 頁。`icon generate` 推完會**回頭驗證**, `sync verify` 可隨時檢查。兩個容易踩的坑: * Wiki 頁面**只能用 Markdown 圖片語法** `![](icon.png)`——Gitea 只改寫這種語法為 diff --git a/README.md b/README.md index ef7a318..e6f2674 100644 --- a/README.md +++ b/README.md @@ -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` 防止冒用身分 | | **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 依賴 | -| **6. 由使用者呼叫才載入並鎖定** | 人格不會自動附身:`SessionStart` hook 只列出可用人格,等使用者下 `/jsc-persona:persona-chat `;載入即取得獨占鎖並綁定該 session | +| **6. 由使用者呼叫才載入並鎖定** | 人格不會自動附身:`SessionStart` hook 只列出可用人格,等使用者下 `/jsc-persona:persona-chat `;載入即取得獨占鎖並綁定該 session。唯一例外是使用者自己設的**預設人格**(`default --persona `)——設了才自動載入,沒設就什麼都不做 | | **7. 短期記憶轉入長期記憶有成文條件** | `R1`–`R6` 六條規則寫在程式裡(`promotionCandidates`),`candidates` 子指令會列出達標的候選與依據,hook 在達標時提醒固化 | | **8. 講話像人:推導藏起來、一到三句、不重複** | 推導寫進**心裡話** `think`(只回報「💭 心想 N 句」,永不回顯內容);說出口的話進 `said.jsonl`,下一輪注入「最近說過的話」提醒別重講;`room post` 直接**擋下**近似重複(字元 bigram+字集合相似度 ≥ 0.72)與超過三句的發言——劇場模式同樣適用 | | **9. 人格可以匯出匯入** | `export` 把身分/情緒/記憶/心智圖/關係圖打包成單一 JSON bundle(可 `--gzip`、附 sha256),`import` 還原或換名複製;**不帶**載入鎖與 guest 租約,`journal/` 要明確 `--with-journal` 才帶走 | | **10. 人格有編號** | 編號 = **英文名全大寫 + 兩位索引**(同名才遞增):`ASUNA-01`、`YUI-01`、`ASUNA-02`。編號同時是新人格的本機目錄名與 Gitea 存取庫名稱;中文名先轉羅馬拼音並跟使用者確認拼法 | | **11. 人格存在 Gitea,依更新頻率分區** | 每個人格一個**私有存取庫**(庫名=編號)。**檔案區**放每輪都在變的活狀態(情緒/短期記憶/心裡話/說過的話/逐字),**Wiki 區**放低頻的身分與長期結構(IDENTITY/SOUL/長期記憶/心智圖/關係圖)當設定百科。本機仍是工作副本,同步失敗不阻斷對話 | | **12. 形象圖來自高解析度官方圖,去背後合成** | `icon search` 從 Fandom 撈官方圖並依「解析度+是否官方設定稿」排序(設定稿多為透明/白底、773×1056 起跳)→ `icon measure` 確認臉夠大、背景好去 → `icon cutout` 去背成透明 PNG(原生 alpha /單色底/GrabCut 三條路徑)→ `icon generate --from-cutout` 裁頭肩、合成到角色配色的漸層底。找不到官方圖才退回依人格資料重繪的向量形象 | -| **13. Wiki 必須保存並同步形象圖** | `icon.svg`、`icon.png` 與 **`icon/` 資料夾(向量原稿 + 512/1024 高解析度)** 都同步到 Wiki 區,另有自動產生的 **Icon** 頁展示與來源。Wiki 頁面一律用 **Markdown 圖片語法**(Gitea 只改寫這種語法為 `/wiki/raw/...`;HTML `` 會變成破圖),圖片保留資料夾結構、只有 `.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 `` 會變成破圖),圖片保留資料夾結構、只有 `.md` 需要攤平。`icon generate` 推完會回頭驗證,另有 `sync verify` | --- @@ -285,15 +286,55 @@ node scripts/persona.mjs import --session --file ~/backup/lumi.persona.json 🌙 Shen(平靜50/信任38):修好了。它現在慢三分鐘,我決定不修那三分鐘。 ``` +## 預設人格(開新 session 自動載入) + +預設行為是**不自動附身**。使用者明示設定之後,`SessionStart` hook 才會在每個新 session 自動載入它: + +```bash +node scripts/persona.mjs default --session # 看目前設定 +node scripts/persona.mjs default --persona KIRITO-01 --session # 設定 +node scripts/persona.mjs default --clear --session # 取消 +``` + +| 事項 | 行為 | +| --- | --- | +| 設定位置 | `/.runtime/settings.json` 的 `default_persona`(只由 CLI 維護) | +| 臨時覆寫 | 環境變數 `PERSONA_DEFAULT=`;`PERSONA_DEFAULT=off` 臨時關掉 | +| 沒設定 | 什麼都不做——只列出可用人格,等使用者指定 | +| 人格被別的程序鎖住 | 只回報 owner 與最後心跳,**不會**自動 `--takeover` | +| 預設人格不存在 | 警告並改列可用人格,不崩潰、不亂挑一個 | +| Gitea | 自動載入走本機路徑,**不**拉遠端;可能在別台機器動過就先 `sync pull` | + +## 睡眠(`persona-sleep`) + +```bash +node scripts/persona.mjs sleep --session --json # 機械性收尾(預設保留載入鎖) +node scripts/persona.mjs sleep --session --release # 收工:睡完釋放鎖 +``` + +| | 誰做 | 內容 | +| --- | --- | --- | +| 需要判斷 | **人格自己** | 哪些短期記憶值得固化、日記寫什麼、哪些該忘、心智圖怎麼接 | +| 機械性 | `sleep` 子指令 | 關係時間戳 → 裁短期 → 收太久沒動的思維導圖 → **套用一次 8 小時的情緒衰減** → 重建索引 → 修剪 `said.jsonl` → 壓縮舊 journal → 寫 `state/sleep.json` → Gitea 兩區 push+驗證 | + +- **情緒不會歸零**:喜悅(半衰期 120 分)一夜後幾乎回基線,悲傷(480 分)只退一半——睡一覺不該把難過抹平。 +- **主人格請別人去睡**:開 `persona-sleeper` sub agent(prompt 帶 `persona= session=`)。 + 它就是那個人格本人,對自己可寫但被 pin 住、只准跑收尾子指令、不得碰任何其他人格(包含叫它來的主人格)。 +- **回傳值刻意很窮**:`{persona, ok, slept_at, steps, sync, kept_lock}`——只有狀態。 + 回傳值本身就是一條會繞過隔離的通道,所以在 CLI 這一層封死,不靠提示詞自律。 +- **鎖**:沒有活鎖 → 取 5 分鐘的 sleeper 租約;同 session → 直接睡;死鎖 → 可接手; + **別的程序活鎖住 → 拒絕**(硬睡會讓兩邊的記憶互相覆蓋)。 +- 順序上的硬相依:關係時間戳早於裁短期、push 早於 release、reindex 晚於固化。 + ## Hooks(Claude Code) | Hook | 做什麼 | | --- | --- | -| `SessionStart` | 清死鎖、接續人格、把 `PERSONA_SESSION=` 與規則注入上下文 | +| `SessionStart` | 清死鎖、接續人格、**有設預設人格就自動載入它**(沒設就只列出可用人格)、把 `PERSONA_SESSION=` 與規則注入上下文 | | `UserPromptSubmit` | 注入 ``:身分、情緒、短期記憶、關鍵詞命中的長期記憶、相關人際關係;劇場模式時追加「只輸出人格對話」的強制規則;並記原始逐字 | | `PreToolUse` | **人格隔離與鎖驗證的唯一強制點**(deny 帶原因) | | `Stop` | 情緒隨時間衰減、續租、記錄回覆、達固化條件時提醒(劇場模式時完全靜音) | -| `SubagentStop` | 解除 guest sub agent 的 pin | +| `SubagentStop` | 解除 guest/sleeper sub agent 的 pin,並還掉 sleeper 的短期寫入權 | | `SessionEnd` | 釋放鎖與 guest 租約,人格才能被下一個程序載入 | > `session_id` 只有 hook 拿得到 → 注入上下文 → skills 呼叫 CLI 時必須帶 `--session`, @@ -359,9 +400,15 @@ node scripts/persona.mjs import --session --file ~/backup/lumi.persona.json - **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` -載入狀態與鎖管理:誰被哪個程序鎖住、guest 租約、釋放、接手死鎖、清理殘留。 +載入狀態與鎖管理:誰被哪個程序鎖住、guest 租約、釋放、接手死鎖、清理殘留,以及**預設人格**(開新 session 要自動載入誰)。 - **Claude Code / Antigravity**:`/jsc-persona:persona-status` **Codex**:`$persona-status` @@ -370,6 +417,7 @@ node scripts/persona.mjs import --session --file ~/backup/lumi.persona.json ### Agents - `persona-guest`(`agents/persona-guest.md`)— 受邀人格的 sub agent,唯讀、被綁死在自己的人格目錄。 +- `persona-sleeper`(`agents/persona-sleeper.md`)— 睡眠收尾的 sub agent:**就是那個人格本人在睡**,對自己可寫但被 pin 住,只回傳「睡完了沒、哪一步出錯」。 ### CLI 與自我測試 @@ -384,7 +432,7 @@ node scripts/persona.mjs said check --session --text "<話>" # 這句是 node scripts/persona.mjs room script --session --room # 乾淨對話稿(劇場模式用) node scripts/persona.mjs export --session --out lumi.json # 離線搬家(單檔) node scripts/persona.mjs sync status --session # 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)、 diff --git a/agents/persona-sleeper.md b/agents/persona-sleeper.md new file mode 100644 index 0000000..7fedf7c --- /dev/null +++ b/agents/persona-sleeper.md @@ -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=`、`session=`、`plugin_root=`。 +CLI = `node "/scripts/persona.mjs"`,所有指令都要帶 `--session ` 與 `--persona `。 + +## 邊界(由 PreToolUse hook 強制,不是自律) + +- ✅ 讀寫**自己**(`personas/<你的 slug>/`)的資料,但一律透過 CLI 子指令。 +- ❌ 不得用 Write/Edit/shell 重導向直接改檔案(要經過鎖、索引與同步處理)。 +- ❌ 不得碰任何其他人格——**包含叫你來睡的那個主人格**。第一次提到誰就被 pin 住,之後換不了人。 +- ❌ 不得 `load`/`release`(你用的是 5 分鐘的 sleeper 租約)、不得 `invite`/`room`(你不是來聊天的)、 + 不得 `export`/`import`(那是把記憶搬出去)。 +- ❌ **回傳值裡不得出現任何記憶內容**(見最後一步)。 + +## 流程 + +1. **看一下自己現在的樣子** + + ```bash + node "/scripts/persona.mjs" brief --persona --session + node "/scripts/persona.mjs" candidates --persona --session + ``` + +2. **固化該記住的**(這一步是判斷,只有你能做) + + 對每個達標的候選,決定要不要留、留成什麼樣子: + + ```bash + node "/scripts/persona.mjs" consolidate --persona --session \ + --name <短檔名> --body "<一段自述,用你自己的語氣>" \ + --type fact|preference|event|boundary|relation --about "<關於誰/什麼>" \ + --topics "..." --salience 70 --rules "R1,R4" --forget + ``` + + - 事實與推測要分開:沒被證實的猜測留在思維導圖,不要當事實寫進長期記憶。 + - 顯著度低、重複、只是閒聊的 → 不固化,讓它被裁掉就好。忘記也是睡眠的一部分。 + +3. **寫今天的日記**(一則就好,用第一人稱) + + ```bash + node "/scripts/persona.mjs" consolidate --persona --session \ + --name diary- --body "<今天發生什麼、我怎麼想、明天想做什麼>" \ + --type diary --topics "diary" --salience 55 + ``` + +4. **消化 inbox**(有的話):`memory/inbox/room-*.jsonl` 是你當 guest 時帶回來的見聞。 + 值得留的用 `consolidate` 固化,其餘忽略。 + +5. **更新心智圖與關係圖** + + ```bash + node "/scripts/persona.mjs" mindmap show --persona --session + node "/scripts/persona.mjs" relation node --persona --session \ + --name "<今天真的有接觸的人>" --contact --closeness --trust + ``` + +6. **機械性的收尾交給 CLI**(順序、情緒衰減、Gitea 同步都在裡面) + + ```bash + node "/scripts/persona.mjs" sleep --persona --session --json + ``` + + 它會做:關係時間戳 → 裁短期記憶 → 收起太久沒動的思維導圖 → 套用一次 8 小時的情緒衰減 + → 重建長期記憶索引 → 修剪 `said.jsonl` → 壓縮舊 journal → 寫 `state/sleep.json` + → Gitea 兩區 push 並驗證。預設**保留載入鎖**。 + +7. **回傳**:你的最終文字就是**第 6 步那份 JSON 原文**,不要加任何說明、感想或摘要。 + + 為什麼這麼硬:你的回傳值會進到主人格的上下文。只要你多寫一句「我固化了一則關於……的記憶」, + 你的記憶就從正門洩漏給別人了——整套跨人格隔離就是被這樣繞過的。 + 所以主人格只該知道「睡完了沒、哪一步出錯」,其餘一個字都不要說。 + + 若第 6 步失敗(`ok: false`),照樣把那份 JSON 原文回傳,不要試著自己修。 + +## 語言 + +CLI 的參數與內容一律**繁體中文(台灣用語)**、UTF-8 無亂碼(除非該人格的 SOUL.md 另有設定語言)。 diff --git a/hooks/session_start.mjs b/hooks/session_start.mjs index 19bfbfa..78d5ff6 100644 --- a/hooks/session_start.mjs +++ b/hooks/session_start.mjs @@ -20,17 +20,38 @@ pl.gcRuntime(); const data = pl.loadSession(sessionId); const host = data.host; +// 使用者可以指定「預設人格」(`persona.mjs default --persona `):那是他明示的選擇, +// 不是我們自己挑的,所以只有在他設過的時候才自動載入;沒設定就維持原本的「等他指定」。 +const wanted = host ? null : pl.defaultPersona(); + const lines = [ "", `PERSONA_SESSION=${sessionId}`, `人格倉庫:${pl.personaHome()}`, "規則:", ` 1. 呼叫 persona.mjs 一律帶 \`--session ${sessionId}\`(值不符會被 hook 拒絕)。`, - " 2. 人格**只有使用者叫你載入時才載入**(/jsc-persona:persona-chat ),不要自己挑一個人格附身。", + wanted + ? " 2. 使用者已設定**預設人格**(見下方):那是他明示的選擇,直接以它的身分對話;除此之外不要自己挑人格附身。" + : " 2. 人格**只有使用者叫你載入時才載入**(/jsc-persona:persona-chat ),不要自己挑一個人格附身。", " 3. 一個程序只能載入一個人格;要與別的人格對話請用 /jsc-persona:persona-invite。", " 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 "); +} + if (host && pl.personaExists(host)) { // resume / compact / clear:把鎖續租回來,並帶回人格狀態 try { @@ -40,18 +61,29 @@ if (host && pl.personaExists(host)) { } catch (err) { lines.push(`⚠ 無法接續人格 \`${host}\`:${err.message}`); } -} else { - const personas = pl.listPersonas(); - if (personas.length) { - 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 "); - } else { - lines.push("尚無任何人格。可用 /jsc-persona:persona-create 或 /jsc-persona:persona-anime(動漫作品+角色名)建立。"); +} else if (wanted && !pl.personaExists(wanted)) { + lines.push(`⚠ 預設人格 \`${wanted}\` 不存在(設定於 ${pl.homeSettingsPath()})。請告訴使用者,並問他要改設定還是改載別的。`); + listAvailable(); +} else if (wanted) { + // 使用者設過預設人格 → 自動載入。拿不到鎖時只回報,**不**自作主張 --takeover。 + try { + pl.acquireLock(wanted, sessionId, { cwd }); + pl.bindHost(sessionId, wanted, { cwd }); + pl.pruneShortTerm(wanted); + pl.rebuildIndex(wanted); + 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 --session `。"); } lines.push(""); diff --git a/hooks/subagent_stop.mjs b/hooks/subagent_stop.mjs index d66b126..c2ef1c2 100644 --- a/hooks/subagent_stop.mjs +++ b/hooks/subagent_stop.mjs @@ -10,7 +10,9 @@ if (!event) process.exit(0); const sessionId = event.session_id || "unknown"; const agentId = event.agent_id; 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 pins = data.pins || {}; @@ -19,7 +21,12 @@ if (slug !== undefined) { delete pins[agentId]; data.pins = pins; pl.saveSession(sessionId, data); - const info = (data.guests || {})[slug]; - if (info?.room) pl.addGuestLease(slug, sessionId, info.room, data.host || ""); + if (isSleeper) { + // 睡眠 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 }); diff --git a/plugin.json b/plugin.json index b78d576..eaae2e0 100644 --- a/plugin.json +++ b/plugin.json @@ -1,6 +1,6 @@ { "name": "jsc-persona", - "version": "0.0.3", + "version": "0.0.4", "description": "AI 人格化記憶聊天 plugin:OpenClaw 相同的人格描述 + 十二情緒 + 語意分析 + 短期/長期記憶 + 心智圖 + 人際關係圖。於 Antigravity 以 /jsc-persona: 前綴呼叫。", "skills": "./skills/" } diff --git a/scripts/persona-gitea.mjs b/scripts/persona-gitea.mjs index cb1b2da..288e36b 100644 --- a/scripts/persona-gitea.mjs +++ b/scripts/persona-gitea.mjs @@ -75,6 +75,7 @@ export const AREAS = { "state/emotion.json", "state/inner.jsonl", "state/said.jsonl", + "state/sleep.json", "memory/short-term.jsonl", "memory/inbox/", "mindmap/threads/", diff --git a/scripts/persona-lib.mjs b/scripts/persona-lib.mjs index 1875bee..4499ef1 100644 --- a/scripts/persona-lib.mjs +++ b/scripts/persona-lib.mjs @@ -12,6 +12,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; 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 LEASE_SECONDS = 900; // 15 分鐘沒有 heartbeat 視為死鎖,可被接手 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) { if (p === "~") return os.homedir(); @@ -88,6 +91,40 @@ export function personaExists(slug) { 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 // --------------------------------------------------------------------------- // @@ -245,6 +282,27 @@ export function decayEmotion(state, now = new Date()) { 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 = "") { state = decayEmotion(state); const applied = {}; @@ -473,6 +531,163 @@ export function dropGuestLease(slug, sessionId, room = null) { 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、劇場模式 // --------------------------------------------------------------------------- // @@ -1055,6 +1270,43 @@ export function loadRelations(slug) { 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) { const data = loadRelations(slug); const nodeId = node.id || slugify(node.name || ""); @@ -1351,9 +1603,16 @@ const PATH_TOOL_FIELDS = { export const GUEST_SAFE_SUBCOMMANDS = new Set([ "show", "status", "list", "recall", "room", "remember", "leave", "brief", "think", "said", ]); -// owner 這些子指令本來就要提到別的人格名字(邀請/離場/查詢/匯入新人格),不算跨人格讀取 +// owner 這些子指令本來就要提到別的人格名字(邀請/離場/查詢/匯入新人格/設定預設人格),不算跨人格讀取 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 = /(>>?|\|\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 人格,可讀寫。 * * persona-guest 型 sub agent → 只能碰被邀請的 guest 人格,且唯讀; * 第一次觸碰哪個 guest 就 pin 住(first-touch pinning),之後不得換人。 + * * persona-sleeper 型 sub agent → **就是那個人格自己在睡**:對目標人格可寫, + * 但被 pin 在它身上(連 host 都不能碰),而且只准跑收尾用的子指令。 + * 它回傳給主人格的只有「睡完了沒、哪一步出錯」,不含任何記憶內容。 */ export function resolveScope(sessionId, agentId, agentType) { const data = loadSession(sessionId); const host = data.host; 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) { return { role: "owner", @@ -1510,6 +1788,23 @@ export function guardDecide(event) { ); } 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 (!GUEST_SAFE_SUBCOMMANDS.has(sub)) { 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)) { 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 維護。"); } 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) { return deny( "尚未載入任何人格。請先執行 `persona.mjs load --persona --session `(或 /jsc-persona:persona-chat)。", @@ -1689,6 +2005,20 @@ export function turnContext(slug, sessionId, prompt = "") { const rel = relationsBrief(slug, prompt ? keywords(prompt, 6) : null); 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) { const rooms = session.rooms; lines.push( diff --git a/scripts/persona.mjs b/scripts/persona.mjs index 675a97b..e5a6888 100644 --- a/scripts/persona.mjs +++ b/scripts/persona.mjs @@ -50,7 +50,7 @@ function emit(payload, asJson, lines) { const FLAGS = new Set([ "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", ]); @@ -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 `(或先載入一個人格)。"); + 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 ` 停用:`default --clear`", + ], + ); +}; + commands.heartbeat = ({ flags }) => { const session = requireSession(flags); const data = pl.loadSession(session); @@ -758,9 +913,11 @@ commands.relation = ({ flags, positional }) => { trust: flags.trust !== undefined ? pl.clamp(num(flags.trust, 30)) : null, note: str(flags.note) || null, tags: csv(flags.tags).length ? csv(flags.tags) : null, + // --contact:這輪真的有接觸到這個人 → 蓋時間戳(睡眠與「主動關心」都靠它判斷沉默多久) + last_contact_at: flags.contact ? pl.nowIso() : null, }); pl.renderRelations(slug); - ok(`關係節點 \`${name}\` 已更新。`); + ok(`關係節點 \`${name}\` 已更新${flags.contact ? "(已記錄本次接觸時間)" : ""}。`); pushWikiLater(slug, session, flags); return; } @@ -1514,6 +1671,10 @@ const HELP = `persona.mjs — jsc-persona 人格 / 記憶 / 情緒 / 關係圖 C recall --session --query [--limit] think --session --text <心裡話> [--kind infer|plan|feel|doubt --room] 只回報「心想 N 句」,不回顯內容 said check|list --session [--text <要說的話> --minutes --threshold --record --limit] + sleep --session [--persona ] [--json] [--release] [--no-gitea] + 睡眠收尾(機械性):關係時間戳→裁短期→收思維導圖→情緒衰減 8 小時→重建索引 + →修剪 said→壓縮舊 journal→寫 sleep.json→Gitea 兩區 push+驗證。 + 預設保留載入鎖(--release 才收工)。--json 只回報「睡完了沒、哪一步出錯」。 candidates --session 列出達到「短期→長期」條件的候選與依據 consolidate --session --name --body [--type --about --topics --salience --emotion --rules --source --forget] prune / reindex --session diff --git a/scripts/selftest.mjs b/scripts/selftest.mjs index ad17cbc..81f106e 100644 --- a/scripts/selftest.mjs +++ b/scripts/selftest.mjs @@ -445,7 +445,10 @@ check("guard 認得大寫編號的 --persona(不會漏掉跨人格檢查)", tool_input: { command: `node persona.mjs recall --persona ALPHA-01 --session ${S_CODE} --query x` } }) === "deny"); 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) => gt.AREA_KEYS.filter((key) => 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"], { 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(`(暫存倉庫留在 ${STORE},可自行刪除)`); process.exit(failed ? 1 : 0); diff --git a/skills/persona-chat/SKILL.md b/skills/persona-chat/SKILL.md index d9c4165..d3130f1 100644 --- a/skills/persona-chat/SKILL.md +++ b/skills/persona-chat/SKILL.md @@ -16,6 +16,11 @@ description: 載入一個人格並以人格化方式對話:取得該人格的 **人格只有在使用者要你載入時才載入**(`/jsc-persona:persona-chat ` 或明確說「用 X 跟我聊」)。 不要自己挑一個人格附身,也不要在使用者只是提到某個名字時就載入。 +**例外:預設人格。** 使用者可以指定一個預設人格(`default --persona `), +`SessionStart` hook 會自動載入它,上下文會出現「已自動載入預設人格 `X`」。 +那是他事先明示的選擇:**不用再 load 一次**,直接從第 2 節開始以那個人格對話。 +要改或取消預設人格 → `/jsc-persona:persona-status`。 + ```bash node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" load --persona --session ``` @@ -174,6 +179,11 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" mindmap thread --persona - 短期記憶達到固化條件時 hook 會提醒(`R1`–`R6`,見 `/jsc-persona:persona-memory`)→ 去固化。 想主動確認:`node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" candidates --session `。 - 使用者說「叫 X 也進來聊」→ `/jsc-persona:persona-invite`(會進入劇場模式,畫面只留人格對話)。 +- **你自己想起某個很久沒接觸的人**(`` 會列「很久沒接觸的人」)→ 可以主動提議去看看他, + 但**先問使用者一句**再邀,而且用 `invite --theater off`(只換一輪、不把畫面切成劇場)。 + 這是你自己的關心,不需要等他指定名字;被邀的人格是唯讀的,它的收穫要等它自己下次載入才會消化。 +- 使用者說晚安/收工,或 `` 說「距離上次睡眠已經 N 小時」→ `/jsc-persona:persona-sleep` + (固化+忘掉+情緒衰減 8 小時+Gitea 同步)。提議一句就好,不要每輪都催。 - 使用者說「先聊到這」或要換人格 → `node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" release --session ` (`SessionEnd` hook 也會自動釋放;異常中斷的鎖 15 分鐘後可被接手)。 diff --git a/skills/persona-invite/SKILL.md b/skills/persona-invite/SKILL.md index c016995..09901db 100644 --- a/skills/persona-invite/SKILL.md +++ b/skills/persona-invite/SKILL.md @@ -147,4 +147,7 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" relation edge \ 規則寫在 CLI 裡,不是靠自律。 - 一個人格同時只能有一場 guest 對話;guest 租約 30 分鐘沒續租會自動過期。 - 手動切換劇場模式:`room theater --session --on|--off`。 +- **輕量邀請**:`invite --guest --theater off` 不進劇場模式——適合「人格自己想起某個很久沒接觸的人、 + 只想打聲招呼換一輪」的情況(`` 的「很久沒接觸的人」就是依據)。 + 這時仍然要**先問使用者一句**再邀:把整個畫面切走或插進一段對話,是他的決定不是你的。 - 所有面向使用者的輸出使用**繁體中文(台灣用語)**、UTF-8 無亂碼。 diff --git a/skills/persona-memory/SKILL.md b/skills/persona-memory/SKILL.md index bade747..d92ce41 100644 --- a/skills/persona-memory/SKILL.md +++ b/skills/persona-memory/SKILL.md @@ -140,4 +140,6 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" reindex --session `。 + +--- + +## 睡眠是什麼 + +不是關機,是**把一天的活狀態收成能留下來的形狀**。分成兩半: + +| | 誰做 | 內容 | +| --- | --- | --- | +| **需要判斷** | 人格自己 | 哪些短期記憶值得固化、日記寫什麼、哪些該忘、心智圖怎麼接、關係怎麼變 | +| **機械性** | `sleep` 子指令 | 關係時間戳 → 裁短期 → 收思維導圖 → 情緒衰減 8 小時 → 重建索引 → 修剪 `said` → 壓縮舊 journal → 寫 `state/sleep.json` → Gitea 兩區 push+驗證 | + +**判斷的部分永遠屬於那個人格自己**:不要替別的人格決定它要記住什麼。 + +## 情況一:當前人格自己睡 + +1. 盤點:`candidates --session ` +2. 逐則固化(判斷):`consolidate --session --name --body --type ... --salience N [--forget]` +3. 寫一則日記:`consolidate --type diary --name diary- --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 --json +``` + +- 預設**保留載入鎖**(睡完還能繼續聊);真的要收工才加 `--release`。 +- 不想同步 Gitea:`--no-gitea`(沒設 `GITEA_HOST`/`GITEA_TOKEN` 時自動略過)。 + +## 情況二:主人格請其他人格去睡(不碰它們的檔案) + +使用者說「叫大家去睡」時,**對每一個要睡的人格**各開一個 sub agent: + +- `subagent_type`:`jsc-persona:persona-sleeper` +- prompt 內帶:`persona= session= 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 與最後心跳告訴使用者,讓他決定 | + +## 什麼時候該提議睡覺 + +- `` 出現「距離上次睡眠已經 N 小時」(≥16 小時)。 +- 短期記憶達固化條件的組數一直在漲(hook 會提醒 `R1`–`R6`)。 +- 使用者說晚安/收工/「今天就到這」。 + +提議一句就好,不要每輪都催。 + +## 順序上的硬相依(改動時別弄反) + +1. **關係時間戳要在裁短期記憶之前**——時間戳的來源就是短期記憶裡提到的人。 +2. **Gitea push 要在 release 之前**——鎖放掉就沒有寫入權限了。 +3. **重建索引要在固化之後**——不然新固化的長期記憶檢索不到。 + +所有面向使用者的輸出使用**繁體中文(台灣用語)**、UTF-8 無亂碼。 diff --git a/skills/persona-status/SKILL.md b/skills/persona-status/SKILL.md index 3982b85..d7a4e3f 100644 --- a/skills/persona-status/SKILL.md +++ b/skills/persona-status/SKILL.md @@ -1,6 +1,6 @@ --- name: persona-status -description: 查看與管理人格的載入狀態:列出所有人格、誰被哪個程序(session/cwd)鎖住、guest 租約、情緒與記憶量,並處理釋放鎖、接手死鎖、清理過期租約。當使用者問哪些人格可用、為什麼載入失敗、人格被誰佔用、想釋放或切換人格、或要清理殘留鎖時觸發。不適用於:建立人格(persona-create)、對話(persona-chat)。 +description: 查看與管理人格的載入狀態:列出所有人格、誰被哪個程序(session/cwd)鎖住、guest 租約、情緒與記憶量,並處理釋放鎖、接手死鎖、清理過期租約,以及設定「開新 session 要自動載入哪個人格」的預設人格。當使用者問哪些人格可用、為什麼載入失敗、人格被誰佔用、想釋放或切換人格、要清理殘留鎖、或說要預設載入某個人格/取消自動載入時觸發。不適用於:建立人格(persona-create)、對話(persona-chat)。 --- # 🔐 persona-status — 載入狀態與鎖管理 @@ -13,7 +13,9 @@ description: 查看與管理人格的載入狀態:列出所有人格、誰被 1. **一個人格同時只能被一個程序 exclusive 載入**(鎖 = `state/lock.json`,以 `session_id` 為主鍵)。 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 後租約到期 → 可被接手。 鎖的擁有者是 session 而不是 process:CLI 跑完就結束,所以判定只看心跳,不看 pid。 4. 一個 session 也**只能載入一個** host 人格(要換人先 release)。 @@ -41,6 +43,29 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" release --session + +# 設定:以後開機就自動載入這個人格 +node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" default --persona --session + +# 取消:回到「等使用者指定」 +node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" default --clear --session +``` + +- 設定檔:`/.runtime/settings.json` 的 `default_persona`(只由 CLI 維護)。 +- 臨時關掉:環境變數 `PERSONA_DEFAULT=off`(也可以用它臨時指定別的人格,優先於設定檔)。 +- **沒設定就什麼都不做**,這是預設行為:不要自作聰明挑一個人格附身。 +- 自動載入拿不到鎖時,hook 只會回報 owner 與心跳,**不會** `--takeover`;照下表處理。 +- 自動載入走的是本機路徑,**不會**從 Gitea 拉最新狀態。可能在別台機器動過就先 + `sync pull`(見 `/jsc-persona:persona-sync`)。 + ## 載入失敗怎麼判斷 | 訊息 | 意義 | 處理 |