diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 9ff485c..2fcae03 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -1,6 +1,6 @@ { "name": "persona", - "description": "JSC 跨 AI 助理共用 skills 的 Claude Code marketplace。", + "description": "JSC 人格化記憶聊天 skills 的 Claude Code marketplace,提供人格建立與對話、十二情緒與短期/長期記憶管理、心智圖/思維導圖與人際關係圖維護等功能。", "owner": { "name": "JSC" }, diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 51eeacb..2e9e921 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "jsc-persona", - "version": "0.2.1", - "description": "AI 人格化記憶聊天 plugin:以 OpenClaw 相同的身分描述(IDENTITY/SOUL)建立人格(可用動漫作品+角色名上網蒐集設定),結合 hook 強制的人格載入鎖與跨人格隔離、六正向+六負向十二情緒、語意分析、短期/長期記憶與固化條件、心智圖、思維導圖與人際關係圖;可用 sub agent 邀請其他人格同場對話(劇場模式只顯示人格對話)。於 Claude Code 以 /jsc-persona: 前綴呼叫。", + "version": "0.3.1", + "description": "AI 人格化記憶聊天 plugin(Claude Code / Codex / Antigravity / OpenCode / GitHub Copilot CLI):以 OpenClaw 相同的身分描述(IDENTITY/SOUL)建立人格(可用動漫作品+角色名上網蒐集設定),結合 hook 強制的人格載入鎖與跨人格隔離、六正向+六負向十二情緒、語意分析、短期/長期記憶與固化條件、心智圖、思維導圖與人際關係圖;可用 sub agent 邀請其他人格同場對話(劇場模式只顯示人格對話);於 Claude Code 以 /jsc-persona: 前綴呼叫。", "skills": "./skills", "author": { "name": "JSC" diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 235dc05..973c20b 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "jsc-persona", - "version": "0.2.1", - "description": "AI 人格化記憶聊天 skills:OpenClaw 相同的人格描述 + 十二情緒 + 語意分析 + 短期/長期記憶 + 心智圖 + 人際關係圖。(人格鎖與跨人格隔離的 hook 僅在 Claude Code 生效)", + "version": "0.3.1", + "description": "AI 人格化記憶聊天 plugin:以 OpenClaw 相同的身分描述(IDENTITY/SOUL)建立人格(可用動漫作品+角色名上網蒐集設定),結合 hook 強制的人格載入鎖與跨人格隔離、六正向+六負向十二情緒、語意分析、短期/長期記憶與固化條件、心智圖、思維導圖與人際關係圖;可用 sub agent 邀請其他人格同場對話(劇場模式只顯示人格對話)。(人格鎖與跨人格隔離的 hook 僅在 Claude Code 生效)", "skills": "./skills" } diff --git a/AGENTS.md b/AGENTS.md index 030e415..2cc5260 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,150 +1,21 @@ -# jsc-persona — AI 人格化記憶聊天(跨 AI 助理) +# jsc-persona — 共用 Skills(跨 AI 助理) -本 repo 是一個以 **Agent Skills(`SKILL.md`)** 標準撰寫的 plugin,讓 AI 以「人格」的方式 -持有身分、情緒與記憶。可同時被 Claude Code、Codex、Antigravity、OpenCode 使用。 +本 repo 是一組以 **Agent Skills(`SKILL.md`)** 標準撰寫的共用 skills,可同時被 Claude Code、Codex、Antigravity、OpenCode 使用。 ## 給 AI 助理的指引 -- 所有 skills 位於 `skills//SKILL.md`;處理任務前先比對使用者需求與各 `description`。 -- **呼叫慣例**:Claude Code 與 Antigravity 以 `/jsc-persona:` 呼叫;Codex 用 `$`; - OpenCode 由模型依描述自動觸發。 -- 完整清單見 `README.md` 的「Skills 目錄」。 - -## 這個 plugin 的運作前提(動手前一定要知道) - -1. **所有狀態變更都經過 `scripts/persona.mjs`**,不要手動編輯 `state/lock.json`、`.runtime/`、 - `memory/INDEX.md`、`relations/graph.mmd`(這些由 CLI 產生)。 -2. **每個 CLI 呼叫都要帶 `--session `**,值來自 `SessionStart` hook 注入的 - `` 區塊。帶錯或冒用其他 session 會被 `PreToolUse` hook 拒絕。 -3. **一個程序只能載入一個人格**;同一 session 的 sub agent 沿用同一把鎖。 - 要讓兩個人格對話,用 `/jsc-persona:persona-invite`(`persona-guest` sub agent + 聊天室), - **不要**去讀對方的人格目錄——會被 hook deny,而且那是設計上的紅線。 -4. **人格資料不在本 repo**,預設在 `~/.claude/personas/`(可用 `PERSONA_HOME` 覆寫)。 -5. **人格由使用者呼叫才載入**,不要自己挑一個人格附身。唯一例外是使用者自己設的**預設人格** - (`persona.mjs default --persona `,存在 `.runtime/settings.json`):設了 `SessionStart` - 才會自動載入並在上下文寫明;沒設就什麼都不做。載入不到(被別的程序鎖住)只回報,不自動接手。 -6. **劇場模式(多人格對話)進行中**:輸出只能是 `名字:內容`,其餘一律隱藏(見 persona-invite)。 - 輕量版是 `invite --theater off`(只換一輪、不切走畫面),人格主動提議去關心某人時用這個。 - **同一個空間裡也會有一對一**:發言權寫在每一句上(`room post --to <他>` / `--to all`), - 一對一進行中旁人插話會被 `room post` 擋下(要帶 `--barge-in "<理由>"`), - 而且**不要替沒被指名的人生成台詞或啟動他的 sub agent**。現況查 `room floor` - (誰對誰在講、該誰接、誰先安靜、誰隔了幾輪沒開口);話題放大才把人拉進來。 -7. **睡眠(`persona-sleep`)分兩半**:需要判斷的(固化什麼、忘掉什麼、日記寫什麼)永遠屬於**那個人格自己**; - 機械性的(裁短期/收 thread/情緒衰減 8 小時/reindex/修剪 said/壓縮 journal/兩區 push+驗證)由 - `sleep` 子指令做。主人格要別的人格去睡就開 `persona-sleeper` sub agent——**那是它本人在睡**, - 對自己可寫但被 pin 住,而且**回傳值只能是 `sleep --json` 的原文**(回傳值本身就是一條會繞過隔離的通道)。 - 睡眠仍然要驗鎖:目標正被另一個程序活鎖住時拒絕,死鎖可接手。 -8. **人格講話要像人**:推導寫進 `think`(心裡話,只回報「💭 心想 N 句」,永不回顯內容)、 - 回話 1–3 句、短時間內不重說同一件事(`room post` 會直接擋下重複與過長的發言)。 - 句數上限**跟著羞恥度走**(`speechBudget()`),但羞恥度高**不等於話一定變少**——三個出口: - **縮**(一句嘴硬,台詞在迴避心裡那句)、**炸**(慌/惱羞/被逼澄清 → 4 句但單句只有 22 字, - 碎而急)、**坦白**(信任高又獨處 → 3 句完整句,先寫完心裡話再開口)。 - `room post` 同時擋句數與單句字數,兩個參數一起動才分得出「碎念」與「演講」。 - 再加四條講話的樣子:**短句**(一句 `MAX_SENTENCE_CHARS`=45 字內)、**日常用詞**、 - **多講看得見的東西**(人、動作、物件、當下的場面)而不是概念,以及**不要解釋自己的話** - (「我的意思是」「換句話說」這類開頭由 `speechLint` 擋下,`said check` 也會一起檢)。 - 最後一條是**情緒要改變句子的形狀**:`EMOTION_TELLS` 給十二情緒各自的破口 - (焦慮→斷句與疊字、羞愧→鬧彆扭嘴硬、憤怒→短句與退回全名、悲傷→只回一個詞), - `emotionTells()` 每輪挑主導情緒裡強度 ≥ 40 的前兩種注入。演出來、不要用旁白說明, - 一輪最多露一個破口。這些在劇場模式一樣生效。 - 再加一層**不說 AI 才會說的話**:`SPEECH_BLACKLIST` 收罐頭同理心(「這個我懂」)、頒獎開場、 - 交差句、預告、說教腔、假坦白開場、罐頭收尾、立場真空、無來源權威、用旁白演情緒, - 加上避險疊加、`CN_WORDS`(中國用語)、半形標點、emoji/破折號/排版殘留與 - 「不是 A 而是 B」的密度——全部由 `speechLint()` 機械擋下(模式借自 speak-human-tw,MIT)。 - 誤殺防護:`speechBody()` 會先拿掉引號與 `code`,**提及不算使用**;「老實說」只擋開場。 - 最後是這一層的義務:**講自己的過去要有出處**——「我以前⋯」只能講 `recall` 查得到的事, - `speechLint` 給 `level: "hint"`(不擋,但要人去驗),沒有紀錄就是編造自己的過去。 - 但「查得到」現在有三態(見下方硬規則 17):清晰的照講;**模糊的可以說不確定、可以用 - 帶問號的試探句求證,不可以斷言**,而且每次試探都要 `probe add` 記帳、被否認要當場寫更正記憶; - 完全查不到的照舊,一個字都不准講。 -8b. **情緒先行、會飽和、偏差看得見**:每輪注入之前先用 `readUserEmotion()` 讀對方那句話 - (十二類加權詞表、否定會擋掉命中、引號與 `code` 內不比對、標點只放大既有訊號), - 回的是**訊號不是判定**——人格讀到的不一樣就以人格為準;`RESPONSE_STANCE` 給的是 - 「怎麼接」的**動作**不是罐頭句。每輪的偵測與 delta 記進 `state/felt.jsonl`, - `feltTrend()` 用近重遠輕的加權算走向。`applyEmotion()` 加了**飽和** - (`headroom^EMOTION_SATURATION_K`,往 baseline 回不壓)與**單輪預算** - (`EMOTION_TURN_BUDGET`=60),因為 delta 是人格自己挑的、只會往舒服的方向倒; - `emotion --audit` 把這個偏差印出來。 -8d. **心裡話進得了記憶,但永遠不回顯**:`recallInner()` 讓 `recall` 找得到心裡話, - `innerCandidates()` 把「24 小時內想過 ≥2 次的同一件事」列成固化候選(兩字滑動視窗切詞, - 扣掉虛詞)。要不要固化仍由人格自己決定。`think` 的輸出永遠只有「💭 心想 N 句」。 -8e. **羞恥度是動態的**:`modestyState()` = trait + 情緒推力 + 語氣層 + 上一輪餘溫。 - 護欄:`MODESTY_GAIN` < 1(正回饋要收斂)、`MODESTY_MAX_STEP`、`MODESTY_PUSH_CAP`、 - 只算超出基線的部分、慢的情緒(半衰期長)推力打折。斷路器是惱羞成怒(anger 負權重)。 - **注意 `Number(null) === 0`**:沒有上一輪時要退回 trait,不是退回 0(踩過)。 -8f. **親近度會改變情緒的份量**:`relationGain()` 依關係節點的親近度把 delta 乘 0.7–1.35, - `emotion --apply [--from <對象>]`。只調幅度不調方向。 -8g. **提到 ≠ 接觸**:睡眠只替 `contactsFromRooms()`(同房且雙方都發過言)的人蓋 - `last_contact_at`。掃 `entities` 會讓「日記裡寫到某人」把他的沉默計時歸零, - 主動關心因此永遠不觸發。真人節點走 `relation node --contact`。 -8c. **性別只給一個預設值,不是套在個性上的係數**:`IDENTITY.md` 的 `Gender` 欄位 - (女性/男性/非二元/未指定,建立時要問不要猜)唯一的作用是給**羞恥敏感度**一個預設 - (62/38/50)。`modestyOf()` 會用 IDENTITY 與 SOUL 的描述往上或往下推(`MODESTY_SIGNALS`, - 可以推到 0),排序永遠是**個性描述 > 角色原作既有的性別化語言特徵 > 性別預設**。 - 效果只調「羞愧」這一個破口的顯示門檻(`emotionTells` 的 shame floor),其餘十一種不受影響。 - 推性別時**只看 `Creature`、不看 `Avatar`**(外觀散文會推錯)。 - 不做「女性→情緒更外顯」這種全域放大——那會把角色壓成模板。 -9. **人格可搬家**:`export` / `import`(單一 JSON bundle)。匯出只能匯出「本 session 載入的人格」, - 其他人格一律 deny——匯出等於把記憶讀出來。 -10. **人格有編號**:英文名全大寫+兩位索引(`ASUNA-01`),同名才遞增。編號同時是新人格的 - 本機目錄名與 **Gitea 存取庫名稱**。中文名要先轉羅馬拼音並跟使用者確認拼法。 -11. **人格存在 Gitea,本機是工作副本**:高頻活狀態進**檔案區**(每輪背景 push), - 低頻身分與長期記憶進 **Wiki 區**(固化/改身分/release 時 push)。 - **同步失敗永遠不阻斷對話**;沒設 `GITEA_HOST`/`GITEA_TOKEN` 就純本機運作。 -12. **人格圖示在資料補齊之後才產生**:SVG 與 PNG 是同一張圖(共用單位座標與點陣字), - PNG 由 `scripts/persona-icon.mjs` 自己柵格化+zlib 編碼,**不得引入任何影像函式庫**。 -13. **形象圖優先用「高解析度官方圖去背」**:`icon search` → `icon measure` → `icon cutout` - → `icon generate --from-cutout`。找圖時**優先官方設定稿**(Full Body/Character Design/ - Avatar):解析度高,而且多半是透明底或白底,去背幾乎免費。挑的那張要**對得上該人格 - 「最新一次登場」的形態**(同一個角色有很多套造型)。 - 每一步都要**用 Read 打開確認**:去背有沒有殘留、構圖對不對。 - 找不到可用官方圖才退回 `--features` 的向量重繪。 -14. **選用工具缺了要「提示安裝」,不准靜默降級**:`toolReport()` 會列出缺什麼、為什麼要、 - 怎麼裝(venv 免 sudo)。注意 **OpenCV 5 拿掉了 `CascadeClassifier`,必須裝 4.x**。 - plugin 本體仍然零依賴:沒有這些工具照樣能產生形象圖。 -15. **Wiki 必須保存並同步形象圖**:`icon.svg`、`icon.png` 與 `icon/`(向量原稿 + 512/1024) - 都在 Wiki 區,另有自動產生的 **Icon** 頁。`icon generate` 推完會**回頭驗證**, - `sync verify` 可隨時檢查。兩個容易踩的坑: - * Wiki 頁面**只能用 Markdown 圖片語法** `![](icon.png)`——Gitea 只改寫這種語法為 - `/wiki/raw/...`;HTML `` 不會被改寫,瀏覽器會解析成 `/wiki/icon.png` - 而變成破圖(看起來就像「沒有同步」)。 - * Wiki 產生的頁面**不得含每次都變的時間戳**,否則驗證永遠不會通過、也會每次多一個 commit。 - * 攤平只對 `.md` 做(頁面必須在根層);圖片等附件保留資料夾結構,`/wiki/raw/<資料夾>/<檔>` 讀得到。 -16. **語氣診療是第三方,不是人格**:`persona-therapist` 不取鎖、不附身、也不寫任何人格資料 - (基線可用 `relation show`/`emotion` 唯讀取得,`bond × 親近度 → 語氣層` 的權威表在 - `persona-lib.mjs` 的 `TONE_TABLE`)。診斷結果**不自動寫回**記憶或關係圖,使用者明確要求才寫。 - 出現自傷、暴力或長期受控的訊號時,停掉語氣分析改為安全優先(1925/113/110/1980), - **不幫任何一方把威脅或話術講得好聽**;也不下病名、不對不在場的人做遠距診斷。 -17. **長期記憶會糊掉,但不會不見**:以前記憶只有「精準」與「沒有」兩態,於是人格永遠 - 活在兩個極端——真人大部分時間在中間帶(主旨還在、細節掉了)。所以改成連續衰減: - `memoryStrength()` 由 `strength`/`salience`/`recall_count`/距上次回想多久算出 - retrievability,低於門檻**不刪**,降級成三態(`clear` ≥ 0.6 全講/`faded` 只剩主旨/ - `fuzzy` 只剩「有這件事」)。內文因此分兩層(`主旨:`/`細節:`),衰減先吃細節。 - 被 `recall` 命中就 `strength += 8`(spacing effect:越常想起的越牢)。 - **不可遺忘清單不變**:`boundary`/`promise`/`canon`/salience ≥ 80 永遠 clear。 - 模糊態換來的義務是稽核:每次試探都進 `state/probe.jsonl`,`probe audit` 看否認率—— - 放寬界線一定要配一個看得見的數字,否則那就只是把幻覺合法化。 - -18. **匯入原作只能給他「他知道的事」**:`persona-story` 把小說變成記憶,而小說裡大半的資訊 - 是作者寫給讀者看的——別人的內心話、他不在場那一幕的細節。那些寫成他的 `event` 之後 - 他會拿來回答問題,而且**沒有任何機械檢查得出來**,所以界線要在流程裡就擋住: - 每則候選帶 `know_level`(`did`/`saw`/`told`/`later`/`none`),`none` 只能進 `canon`; - 判斷不出來就記 `none`(少一則記憶比多一則幻覺便宜)。 - 另外兩條:整本原文不入倉庫(只留摘要、他自己的台詞、可追回出處的章節標記); - `SOUL.md` 不由匯入流程改寫,只出提案、使用者逐條核可。 - 正名表**從章節掃出候選再給使用者確認**(`novel scan`)——請使用者手打那張表, - 漏掉的寫法會讓 `about` 對不到節點,而那要等 `relation doctor` 才發現。 +- 所有可用的 skills 位於本 repo 的 `skills//SKILL.md`。 +- 在處理任務前,先比對使用者需求與各 skill `SKILL.md` frontmatter 的 `description`,若相符請載入並依其步驟執行。 +- **呼叫慣例**:在 Claude Code 與 Antigravity 中,這些 skill 以 `/jsc-persona:` 呼叫;Codex 以 `$`、OpenCode 由模型依描述自動觸發 — 兩者沒有 `/jsc-persona:` 前綴,不需強制加。 +- 完整清單與每個 skill 的用途,請見 `README.md` 的「Skills 目錄」。 +- **所有狀態變更都經過 `scripts/persona.mjs`**,不要手動編輯 `state/lock.json`、`.runtime/`、`memory/INDEX.md`、`relations/graph.mmd`(這些由 CLI 產生)。 +- **一個程序只能載入一個人格**,同一 session 的 sub agent 沿用同一把鎖;不要讀取其他人格的目錄,會被 hook deny,這是設計上的紅線。 +- **人格資料不在本 repo**,預設在 `~/.claude/personas/`(可用 `PERSONA_HOME` 覆寫);人格由使用者呼叫才載入,不要自己挑一個人格附身。 +- 劇場模式、睡眠、講話規則、情緒模型、羞恥度、記憶衰減、人格圖示、人格編號、Gitea 同步、語氣診療(`persona-therapist`)、原作匯入(`persona-story`)等行為細節,見 `README.md` 對應章節與各 skill 的 `SKILL.md`;動到 `scripts/persona-lib.mjs` 相關邏輯前務必先讀懂對應章節與 `selftest.mjs` 的既有案例,不要憑印象改。 +- 改動鎖或隔離邏輯(`guardDecide`/`acquireLock`/`promotionCandidates`/`exportBundle`/`importBundle`)後,**必須**跑 `node scripts/selftest.mjs` 且全綠,並為新規則補一條測試;改動重複判定門檻(`similarity`/`REPEAT_THRESHOLD`)要用 selftest ⑪ 對照案例確認;改動同步分區(`AREAS`)後 selftest ⑬ 的「不重不漏」檢查必須維持全綠。 +- 腳本一律 **Node.js(`.mjs`, ESM)**,只用內建模組(fs/path/os/crypto);hook 必須在任何環境都能跑,不得引入 npm 依賴。selftest 自己會設 `PERSONA_GITEA=off`,**絕對不要**讓測試碰到真的 Gitea。 ## 慣例 -- 新增 skill 一律放在 `skills//`,`` 使用小寫與連字號。 +- 新增 skill 一律放在 `skills//`,且 `` 使用小寫與連字號。 - `description` 要寫清楚觸發條件(何時用、何時不用),這是跨助理自動載入的唯一依據。 -- 腳本一律 **Node.js(`.mjs`, ESM)**,只用內建模組(fs/path/os/crypto);hook 必須在任何環境都能跑,不得引入 npm 依賴。 -- 所有面向使用者的輸出使用**繁體中文(台灣用語)**、UTF-8 無 BOM、不得出現亂碼。 -- 改動鎖或隔離邏輯(`scripts/persona-lib.mjs` 的 `guardDecide`/`acquireLock`/`promotionCandidates`/ - `exportBundle`/`importBundle`)後,**必須**跑 `node scripts/selftest.mjs` 且全綠,並為新規則補一條測試。 -- 改動重複判定門檻(`similarity`/`REPEAT_THRESHOLD`)後,要用 selftest ⑪ 的對照案例確認 - 「換句話說同一件事」被擋、「只換關鍵詞」放行。 -- 改動同步分區(`persona-gitea.mjs` 的 `AREAS`)後,selftest ⑬ 的「不重不漏」檢查必須維持全綠: - 人格產生的每個檔案都要**恰好**屬於一區,否則同步會默默漏掉資料。 -- selftest 自己會設 `PERSONA_GITEA=off`,**絕對不要**讓測試碰到真的 Gitea。 diff --git a/README.md b/README.md index 62272dc..af00a4a 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,196 @@ persona/ --- +## 安裝 / 更新 / 移除(各助理) + +> 本 plugin 的 marketplace 名是 `persona`(= repo 名),plugin 名是 `jsc-persona`,安裝 token 為 `jsc-persona@persona`。 +> +> **⚠ 若本機還留著更早的 `jsc-persona@jsc-plugins` 安裝**,那是舊版命名的殘影,請先移除舊鍵再重裝新 token。 +> +> **人格資料不受影響**:人格倉庫在 `~/.claude/personas/`(或 `PERSONA_HOME`),不在 plugin 目錄裡,移除/重裝 plugin 不會動到情緒、記憶與關係圖。Antigravity/OpenCode 是本地路徑/目錄安裝,沒有 marketplace 名,不受此命名影響。 +> +> 完整的安裝/更新/移除指令(Claude Code、Codex、Antigravity、OpenCode、GitHub Copilot CLI 五種助理),一律以 [`/jsc-shared:spec-plugin-cli`](https://gitea.jsc.idv.tw/plugins/shared/src/branch/master/skills/spec-plugin-cli/SKILL.md) 為唯一權威版本,套用時代入下列佔位符: +> +> | 佔位符 | 值 | +> | --- | --- | +> | `` | `gitea.jsc.idv.tw` | +> | `` | `persona` | +> | `` | `jsc-persona` | +> | `` | `persona` | +> | ``(= `@`) | `jsc-persona@persona` | +> | `` | `https://gitea.jsc.idv.tw/plugins/persona.git` | + +--- + +## 用 CLI 直接執行 skill(headless / 一次性) + +安裝好之後,不必進互動介面,一行指令就能叫某個 skill 跑完並印出結果: + +| 助理 | headless 指令 | 執行 `persona-chat` skill | +| --- | --- | --- | +| Claude Code | `claude -p ""` | `claude -p "/jsc-persona:persona-chat lumi"` | +| Codex | `codex exec ""` | `codex exec '$persona-chat lumi'` | +| Antigravity | `agy -p ""` | `agy -p "/jsc-persona:persona-chat lumi"` | +| OpenCode | `opencode run ""` | `opencode run "用 lumi 這個人格跟我聊聊"` | +| GitHub Copilot CLI | `copilot -p ""` | `copilot -p "用 lumi 這個人格跟我聊聊"` | + +- Claude / Antigravity 支援 `/jsc-persona:` 前綴,直接 `-p "/jsc-persona:"` 即可。 +- Codex 以 `$` 觸發;在 shell 請用**單引號**避免 `$` 被展開:`codex exec '$persona-chat …'`。 +- OpenCode 與 Copilot 沒有前綴,用自然語言描述需求;Copilot CLI 會讀取已安裝 plugin 提供的 skills。 +- 帶引數就接在後面,例如 `claude -p "/jsc-persona:persona-status --json"`。 + +--- + +## Skills 目錄 + + + +### `persona-create` + +建立人格:以 OpenClaw 相同的五個身分欄位與 SOUL 段落訪談使用者,初始化情緒基線、記憶、心智圖與關係圖,並立即取得載入鎖。 + +- **Claude Code / Antigravity**:`/jsc-persona:persona-create` **Codex**:`$persona-create` + +### `persona-anime` + +用「動漫作品+角色名」建立人格:上網蒐集角色公開設定 → 映射成 OpenClaw 五欄位與 SOUL → 固化成 `canon` 基礎記憶(附來源)+原作關係圖+情緒基線。 + +- **Claude Code / Antigravity**:`/jsc-persona:persona-anime 《作品》 角色名` **Codex**:`$persona-anime` + +### `persona-chat` + +載入人格並對話:取得獨占鎖 → 每輪做語意分析(推導寫進心裡話,不說出口)→ 更新十二情緒 → 回想記憶與關係 → 以人格語氣回覆(1–3 句、不重複說過的話)→ 寫回記憶。 + +- **Claude Code / Antigravity**:`/jsc-persona:persona-chat ` **Codex**:`$persona-chat` + +### `persona-invite` + +邀請一個或多個人格透過 `persona-guest` sub agent 加入聊天室,進入**劇場模式**(畫面只留 `名字:內容` 的人格對話);結束後讓它們離場並把見聞留在各自的 inbox。多位 guest 可用 `invite --guests A,B` 同場加入。同場的發言權分**一對一**(`--to <他>`,旁人不該接話)與**全場**(`--to all`),現況查 `room floor`。 + +- **Claude Code / Antigravity**:`/jsc-persona:persona-invite ` **Codex**:`$persona-invite` + +### `persona-story` + +把小說(或漫畫、劇本、遊戲文本)匯入既有人格:逐章判斷他在不在場、**只取他在場或知情的部分**、轉成第一人稱記憶,同時累積他自己講過的原句與「事件 → 他做了什麼」的反應對照。三條界線寫死在流程裡——他不知道的事不能變成他的 `event`、整本原文不入倉庫、`SOUL.md` 只有使用者能改。 + +正名表不請使用者手打:`novel scan` 從章節抽人名候選(對話歸屬、敬稱結尾、片假名、高頻詞),附出現次數與一兩行上下文,猜關係節點並分四種信心度,使用者只做確認。`persona-anime` 給的是查得到的公開設定,這支給的是原文。 + +- **Claude Code / Antigravity**:`/jsc-persona:persona-story` **Codex**:`$persona-story` + +### `persona-transfer` + +人格搬家:把身分、十二情緒、短期/長期記憶、心智圖與關係圖打包成單一 bundle 檔(可 gzip、附 checksum),或從 bundle 還原/換名複製成新人格。 + +- **Claude Code / Antigravity**:`/jsc-persona:persona-transfer` **Codex**:`$persona-transfer` + +### `persona-icon` + +依人格**最新一次登場**的官方視覺產生圖示:上網查最新造型 → 下載並親眼看過參考圖 → 取髮色/瞳色/服裝色 → 繪製 `icon.svg` + `icon.png`,來源網址一併存證。 + +- **Claude Code / Antigravity**:`/jsc-persona:persona-icon` **Codex**:`$persona-icon` + +### `persona-sync` + +人格編號與 Gitea 儲存:指派編號(`ASUNA-01`)、開以編號命名的私有存取庫、高頻活狀態同步到檔案區、低頻身分與長期記憶同步到 Wiki 區,並處理既有人格遷移與同步衝突。 + +- **Claude Code / Antigravity**:`/jsc-persona:persona-sync` **Codex**:`$persona-sync` + +### `persona-memory` + +記憶固化:依 R1–R6 條件把短期記憶轉入長期(一則一檔)、淘汰雜訊、更新心智圖與思維導圖、消化 guest inbox、重建索引。 + +- **Claude Code / Antigravity**:`/jsc-persona:persona-memory` **Codex**:`$persona-memory` + +### `persona-relation` + +人際關係圖維護:節點/連線、親近度與信任度調整、輸出 Mermaid 關係圖。 + +- **Claude Code / Antigravity**:`/jsc-persona:persona-relation` **Codex**:`$persona-relation` + +### `persona-therapist` + +語氣診療(第三方、不附身、唯讀):由雙方各給姓名/關係/情緒/逐字稿,用「關係、事實、目的」三把尺逐句標記不合理的語氣與用詞(模式表 P1–P16),指出是誰說的,只對出錯的人給保留原意的改寫。出現自傷、暴力或受控訊號時改為安全優先,不做語氣潤飾。 + +- **Claude Code / Antigravity**:`/jsc-persona:persona-therapist` **Codex**:`$persona-therapist` + +### `persona-sleep` + +睡眠與收尾:固化該記住的、忘掉該忘的、更新心智圖與關係圖、套用一次 8 小時的情緒衰減、壓縮舊紀錄,最後兩個區都同步到 Gitea 並驗證。也可以由主人格透過 sub agent 請其他人格各自去睡,或一次用 `sleep --personas A,B` 批次收尾多個人格。 + +- **Claude Code / Antigravity**:`/jsc-persona:persona-sleep` **Codex**:`$persona-sleep` + +### `persona-status` + +載入狀態與鎖管理:誰被哪個程序鎖住、guest 租約、釋放、接手死鎖、清理殘留,以及**預設人格**(開新 session 要自動載入誰)。 + +- **Claude Code / Antigravity**:`/jsc-persona:persona-status` **Codex**:`$persona-status` + + + +### Agents + +- `persona-guest`(`agents/persona-guest.md`)— 受邀人格的 sub agent,唯讀、被綁死在自己的人格目錄。 +- `persona-sleeper`(`agents/persona-sleeper.md`)— 睡眠收尾的 sub agent:**就是那個人格本人在睡**,對自己可寫但被 pin 住,只回傳「睡完了沒、哪一步出錯」。 + +### CLI 與自我測試 + +所有狀態變更都經過 `scripts/persona.mjs`(**Node.js ≥ 18**,只用內建模組,無 npm 依賴): + +```bash +node scripts/persona.mjs --help +node scripts/persona.mjs list +node scripts/persona.mjs candidates --session # 看哪些短期記憶該固化 +node scripts/persona.mjs migrate --session --all --dry-run # 長期記憶升格式(冪等;先試跑) +node scripts/persona.mjs loop list --session # 現在懸著哪幾件事(上限 5) +node scripts/persona.mjs loop add --session --kind promise --text "<他答應要做的事>" +node scripts/persona.mjs probe add --session --text "是不是上個月那次?" # 模糊記憶的試探 +node scripts/persona.mjs probe audit --session # 試探幾次、被否認幾次 +node scripts/persona.mjs think --session --text "<推導>" # 心裡話(只回報「心想 N 句」) +node scripts/persona.mjs said check --session --text "<話>" # 這句是不是又要說一次? +node scripts/persona.mjs room script --session --room # 乾淨對話稿(劇場模式用) +node scripts/persona.mjs room floor --session --room # 發言權:誰對誰在講、該誰接話 +node scripts/persona.mjs export --session --out lumi.json # 離線搬家(單檔) +node scripts/persona.mjs sync status --session # Gitea 同步狀態 +node scripts/persona.mjs chapter add --session --name "在 SAO 那兩年" --body "<這段是什麼>" +node scripts/persona.mjs relation rift --session --name --about "<還沒和好的事>" +node scripts/persona.mjs voice add --session --kind idiolect --facet 自稱 --value 我 +node scripts/selftest.mjs # 773 項驗證:鎖、隔離、情緒(含疲勞/當日底色/抑制與慣性)、固化、回想強度與模糊態、格式遷移、未完事項、說話節制、標點與分段、語域、慣例、自傳章節、未修復裂痕、劇場模式與發言權、匯出匯入、編號與 Gitea、找圖去背合成、高解析輸出與 Wiki 同步、預設人格、睡眠與 sleeper、hooks +node scripts/expression-check.mjs # G2/G3 表達驗收(人工看,不判定通過失敗) +``` + +`selftest` 是機械檢查,它只能守住「不崩」。**「有沒有變成模仿腔」「是不是在用旁白演情緒」 +機械上驗不出來**,所以另有 `expression-check.mjs`:它把四段情境(誇外表、被說在害羞、 +被戳穿嘴硬、第三個人在場)× 三檔羞恥度(低/嘴硬/惱羞)的**注入內容**攤開, +附一張「人工看什麼」的清單,用完整的暫時 `PERSONA_HOME`,不動真的人格資料。 + +檔案結構:`scripts/persona-lib.mjs`(核心:鎖/隔離/情緒/記憶)、`scripts/persona.mjs`(CLI)、 +`scripts/persona-gitea.mjs`(編號與 Gitea 同步)、`scripts/persona-icon.mjs`(形象圖:SVG + 自寫 PNG 編碼)、`scripts/portrait.py`(選用:照片裁臉)、`hooks/*.mjs`(六個 hook)、`scripts/selftest.mjs`(自我測試)、`scripts/expression-check.mjs`(G2/G3 表達驗收)。 + +--- + +## 跨助理支援度 + +| 助理 | skills | hooks(鎖/隔離強制) | 受邀人格 sub agent | +| --- | --- | --- | --- | +| Claude Code | ✅ `/jsc-persona:` | ✅ 完整 | ✅ `jsc-persona:persona-guest` | +| Codex | ✅ `$` | ❌ | ⚠ 需自行以子任務模擬 | +| Antigravity | ✅ `/jsc-persona:` | ❌ | ⚠ | +| OpenCode | ✅ 依描述自動觸發 | ❌ | ⚠ | +| GitHub Copilot CLI | ✅ 依描述自動觸發 | ❌ | ⚠ | + +> 沒有 hook 的助理仍會遵守 CLI 層的檢查(`--session` 綁定、`require_owner`/`require_member`、 +> guest 唯讀),但那是**自律**而非強制:真正的 deny 只有 Claude Code 的 `PreToolUse` 做得到。 + +--- + +## 新增一個 skill + +1. 複製一個現有 skill 目錄,改 `SKILL.md` 的 `name` 與 `description`(描述要寫清楚何時用、何時不用)。 +2. 需要新的狀態操作 → 加到 `scripts/persona.mjs` 的子指令,並在 `scripts/selftest.mjs` 補測試。 +3. 動到隔離規則 → 一定要在 `selftest.mjs` 的第 ③(隔離)/⑧(guest 與劇場模式)區加對應案例,跑到全綠。 +4. 把 skill 補進上方「Skills 目錄」區塊。 +5. bump `.claude-plugin/plugin.json`、`.codex-plugin/plugin.json`、`plugin.json` 三處 `version`,commit 後 push。 + ## 十四條硬規則 | 規則 | 怎麼做到 | @@ -775,295 +965,6 @@ room 台詞另外比照短期記憶把**換行壓成空白**(`roomPost()` 寫 --- -## Skills 目錄 - - - -### `persona-create` - -建立人格:以 OpenClaw 相同的五個身分欄位與 SOUL 段落訪談使用者,初始化情緒基線、記憶、心智圖與關係圖,並立即取得載入鎖。 - -- **Claude Code / Antigravity**:`/jsc-persona:persona-create` **Codex**:`$persona-create` - -### `persona-anime` - -用「動漫作品+角色名」建立人格:上網蒐集角色公開設定 → 映射成 OpenClaw 五欄位與 SOUL → 固化成 `canon` 基礎記憶(附來源)+原作關係圖+情緒基線。 - -- **Claude Code / Antigravity**:`/jsc-persona:persona-anime 《作品》 角色名` **Codex**:`$persona-anime` - -### `persona-chat` - -載入人格並對話:取得獨占鎖 → 每輪做語意分析(推導寫進心裡話,不說出口)→ 更新十二情緒 → 回想記憶與關係 → 以人格語氣回覆(1–3 句、不重複說過的話)→ 寫回記憶。 - -- **Claude Code / Antigravity**:`/jsc-persona:persona-chat ` **Codex**:`$persona-chat` - -### `persona-invite` - -邀請一個或多個人格透過 `persona-guest` sub agent 加入聊天室,進入**劇場模式**(畫面只留 `名字:內容` 的人格對話);結束後讓它們離場並把見聞留在各自的 inbox。多位 guest 可用 `invite --guests A,B` 同場加入。同場的發言權分**一對一**(`--to <他>`,旁人不該接話)與**全場**(`--to all`),現況查 `room floor`。 - -- **Claude Code / Antigravity**:`/jsc-persona:persona-invite ` **Codex**:`$persona-invite` - -### `persona-story` - -把小說(或漫畫、劇本、遊戲文本)匯入既有人格:逐章判斷他在不在場、**只取他在場或知情的部分**、轉成第一人稱記憶,同時累積他自己講過的原句與「事件 → 他做了什麼」的反應對照。三條界線寫死在流程裡——他不知道的事不能變成他的 `event`、整本原文不入倉庫、`SOUL.md` 只有使用者能改。 - -正名表不請使用者手打:`novel scan` 從章節抽人名候選(對話歸屬、敬稱結尾、片假名、高頻詞),附出現次數與一兩行上下文,猜關係節點並分四種信心度,使用者只做確認。`persona-anime` 給的是查得到的公開設定,這支給的是原文。 - -- **Claude Code / Antigravity**:`/jsc-persona:persona-story` **Codex**:`$persona-story` - -### `persona-transfer` - -人格搬家:把身分、十二情緒、短期/長期記憶、心智圖與關係圖打包成單一 bundle 檔(可 gzip、附 checksum),或從 bundle 還原/換名複製成新人格。 - -- **Claude Code / Antigravity**:`/jsc-persona:persona-transfer` **Codex**:`$persona-transfer` - -### `persona-icon` - -依人格**最新一次登場**的官方視覺產生圖示:上網查最新造型 → 下載並親眼看過參考圖 → 取髮色/瞳色/服裝色 → 繪製 `icon.svg` + `icon.png`,來源網址一併存證。 - -- **Claude Code / Antigravity**:`/jsc-persona:persona-icon` **Codex**:`$persona-icon` - -### `persona-sync` - -人格編號與 Gitea 儲存:指派編號(`ASUNA-01`)、開以編號命名的私有存取庫、高頻活狀態同步到檔案區、低頻身分與長期記憶同步到 Wiki 區,並處理既有人格遷移與同步衝突。 - -- **Claude Code / Antigravity**:`/jsc-persona:persona-sync` **Codex**:`$persona-sync` - -### `persona-memory` - -記憶固化:依 R1–R6 條件把短期記憶轉入長期(一則一檔)、淘汰雜訊、更新心智圖與思維導圖、消化 guest inbox、重建索引。 - -- **Claude Code / Antigravity**:`/jsc-persona:persona-memory` **Codex**:`$persona-memory` - -### `persona-relation` - -人際關係圖維護:節點/連線、親近度與信任度調整、輸出 Mermaid 關係圖。 - -- **Claude Code / Antigravity**:`/jsc-persona:persona-relation` **Codex**:`$persona-relation` - -### `persona-therapist` - -語氣診療(第三方、不附身、唯讀):由雙方各給姓名/關係/情緒/逐字稿,用「關係、事實、目的」三把尺逐句標記不合理的語氣與用詞(模式表 P1–P16),指出是誰說的,只對出錯的人給保留原意的改寫。出現自傷、暴力或受控訊號時改為安全優先,不做語氣潤飾。 - -- **Claude Code / Antigravity**:`/jsc-persona:persona-therapist` **Codex**:`$persona-therapist` - -### `persona-sleep` - -睡眠與收尾:固化該記住的、忘掉該忘的、更新心智圖與關係圖、套用一次 8 小時的情緒衰減、壓縮舊紀錄,最後兩個區都同步到 Gitea 並驗證。也可以由主人格透過 sub agent 請其他人格各自去睡,或一次用 `sleep --personas A,B` 批次收尾多個人格。 - -- **Claude Code / Antigravity**:`/jsc-persona:persona-sleep` **Codex**:`$persona-sleep` - -### `persona-status` - -載入狀態與鎖管理:誰被哪個程序鎖住、guest 租約、釋放、接手死鎖、清理殘留,以及**預設人格**(開新 session 要自動載入誰)。 - -- **Claude Code / Antigravity**:`/jsc-persona:persona-status` **Codex**:`$persona-status` - - - -### Agents - -- `persona-guest`(`agents/persona-guest.md`)— 受邀人格的 sub agent,唯讀、被綁死在自己的人格目錄。 -- `persona-sleeper`(`agents/persona-sleeper.md`)— 睡眠收尾的 sub agent:**就是那個人格本人在睡**,對自己可寫但被 pin 住,只回傳「睡完了沒、哪一步出錯」。 - -### CLI 與自我測試 - -所有狀態變更都經過 `scripts/persona.mjs`(**Node.js ≥ 18**,只用內建模組,無 npm 依賴): - -```bash -node scripts/persona.mjs --help -node scripts/persona.mjs list -node scripts/persona.mjs candidates --session # 看哪些短期記憶該固化 -node scripts/persona.mjs migrate --session --all --dry-run # 長期記憶升格式(冪等;先試跑) -node scripts/persona.mjs loop list --session # 現在懸著哪幾件事(上限 5) -node scripts/persona.mjs loop add --session --kind promise --text "<他答應要做的事>" -node scripts/persona.mjs probe add --session --text "是不是上個月那次?" # 模糊記憶的試探 -node scripts/persona.mjs probe audit --session # 試探幾次、被否認幾次 -node scripts/persona.mjs think --session --text "<推導>" # 心裡話(只回報「心想 N 句」) -node scripts/persona.mjs said check --session --text "<話>" # 這句是不是又要說一次? -node scripts/persona.mjs room script --session --room # 乾淨對話稿(劇場模式用) -node scripts/persona.mjs room floor --session --room # 發言權:誰對誰在講、該誰接話 -node scripts/persona.mjs export --session --out lumi.json # 離線搬家(單檔) -node scripts/persona.mjs sync status --session # Gitea 同步狀態 -node scripts/persona.mjs chapter add --session --name "在 SAO 那兩年" --body "<這段是什麼>" -node scripts/persona.mjs relation rift --session --name --about "<還沒和好的事>" -node scripts/persona.mjs voice add --session --kind idiolect --facet 自稱 --value 我 -node scripts/selftest.mjs # 773 項驗證:鎖、隔離、情緒(含疲勞/當日底色/抑制與慣性)、固化、回想強度與模糊態、格式遷移、未完事項、說話節制、標點與分段、語域、慣例、自傳章節、未修復裂痕、劇場模式與發言權、匯出匯入、編號與 Gitea、找圖去背合成、高解析輸出與 Wiki 同步、預設人格、睡眠與 sleeper、hooks -node scripts/expression-check.mjs # G2/G3 表達驗收(人工看,不判定通過失敗) -``` - -`selftest` 是機械檢查,它只能守住「不崩」。**「有沒有變成模仿腔」「是不是在用旁白演情緒」 -機械上驗不出來**,所以另有 `expression-check.mjs`:它把四段情境(誇外表、被說在害羞、 -被戳穿嘴硬、第三個人在場)× 三檔羞恥度(低/嘴硬/惱羞)的**注入內容**攤開, -附一張「人工看什麼」的清單,用完整的暫時 `PERSONA_HOME`,不動真的人格資料。 - -檔案結構:`scripts/persona-lib.mjs`(核心:鎖/隔離/情緒/記憶)、`scripts/persona.mjs`(CLI)、 -`scripts/persona-gitea.mjs`(編號與 Gitea 同步)、`scripts/persona-icon.mjs`(形象圖:SVG + 自寫 PNG 編碼)、`scripts/portrait.py`(選用:照片裁臉)、`hooks/*.mjs`(六個 hook)、`scripts/selftest.mjs`(自我測試)、`scripts/expression-check.mjs`(G2/G3 表達驗收)。 - ---- - -## 跨助理支援度 - -| 助理 | skills | hooks(鎖/隔離強制) | 受邀人格 sub agent | -| --- | --- | --- | --- | -| Claude Code | ✅ `/jsc-persona:` | ✅ 完整 | ✅ `jsc-persona:persona-guest` | -| Codex | ✅ `$` | ❌ | ⚠ 需自行以子任務模擬 | -| Antigravity | ✅ `/jsc-persona:` | ❌ | ⚠ | -| OpenCode | ✅ 依描述自動觸發 | ❌ | ⚠ | -| GitHub Copilot CLI | ✅ 依描述自動觸發 | ❌ | ⚠ | - -> 沒有 hook 的助理仍會遵守 CLI 層的檢查(`--session` 綁定、`require_owner`/`require_member`、 -> guest 唯讀),但那是**自律**而非強制:真正的 deny 只有 Claude Code 的 `PreToolUse` 做得到。 - ---- - -## 安裝 / 更新 / 移除(各助理) - -> 指令中的 repo 網址換成你的:`https://gitea.jsc.idv.tw/plugins/persona.git` -> -> **Claude / Codex 從 git URL 安裝(會 clone 遠端),請先把本 repo `push` 到 gitea。** -> **Antigravity 的 `agy plugin install ` 目前只支援 github.com**;gitea 請改用「clone + 本地路徑」(見 Antigravity 節)。 -> 本機/離線:Claude 可用本地路徑加 marketplace;Antigravity 用本地路徑安裝。 -> -> 本 plugin 的 marketplace 名是 **`persona`**(= repo 名),plugin 名是 **`jsc-persona`**,安裝 token 為 `jsc-persona@persona`。 - -> **⚠ 這版的正式 marketplace 名是 `persona`。** `jsc-persona@persona` 才是現在的安裝 token。 -> 如果你本機還留著更早的 `jsc-persona@jsc-plugins`,那只是舊安裝殘影,請先移除舊鍵再重裝新 token。 -> -> **人格資料不受影響**:人格倉庫在 `~/.claude/personas/`(或 `PERSONA_HOME`),不在 plugin 目錄裡, -> 移除/重裝 plugin 不會動到情緒、記憶與關係圖。Antigravity/OpenCode 是本地路徑/目錄安裝,沒有 marketplace 名,不受此命名影響。 - -### Claude Code - -```bash -# 安裝 -claude plugin marketplace add https://gitea.jsc.idv.tw/plugins/persona.git -claude plugin install jsc-persona@persona - -# 更新 -claude plugin marketplace update persona -claude plugin update jsc-persona@persona - -# 移除 -claude plugin uninstall jsc-persona@persona -claude plugin marketplace remove persona -``` - -- 工作階段內 slash 版(等價):把 `claude plugin` 換成 `/plugin`。 -- 本機開發(免 push):`claude plugin marketplace add /home/coder/plugins/persona`(本地路徑)後再 install。 -- 安裝後**重啟工作階段**讓 hooks 生效;用 `/hooks` 確認六個 hook 都在。 -- **呼叫**:`/jsc-persona:`(例 `/jsc-persona:persona-chat`)。 - -### Codex - -```bash -# 安裝 -codex plugin marketplace add https://gitea.jsc.idv.tw/plugins/persona.git -codex plugin add jsc-persona@persona - -# 更新(重新抓取 marketplace 的 git 快照) -codex plugin marketplace upgrade persona - -# 移除 -codex plugin remove jsc-persona@persona -codex plugin marketplace remove persona -``` - -- 安裝 token `jsc-persona@persona` = plugin 名(`.codex-plugin/plugin.json` 的 `name`)@ marketplace 名(`.agents/plugins/marketplace.json` 的 `name`)。 -- 本 repo 的 Codex marketplace 以 `url` 來源指向自己,故 Codex **一律從 gitea 安裝**(需先 push);安裝後重啟 Codex。 -- **呼叫**:`$`(例 `$persona-chat`),或用 `/skills` 選單。 -- Codex 沒有 hook:鎖與隔離只剩 CLI 層的自律檢查。 - -### Antigravity(`agy`) - -> `agy plugin install ` 目前**只支援 github.com**;gitea 等自架 git 不支援 URL 安裝,請先 `git clone` 再用**本地路徑**安裝。 - -```bash -# 安裝:clone 後用本地路徑 -git clone https://gitea.jsc.idv.tw/plugins/persona.git ~/plugins/persona -agy plugin install ~/plugins/persona - -# 更新(agy 無 update 子指令 → git pull 後重裝) -git -C ~/plugins/persona pull -agy plugin uninstall jsc-persona -agy plugin install ~/plugins/persona - -# 移除 -agy plugin uninstall jsc-persona -``` - -- 若把 skills 放到 GitHub,則可直接 `agy plugin install https://github.com//`。 -- 其他:`agy plugin list`、`agy plugin enable jsc-persona` / `disable jsc-persona`、`agy plugin validate `。安裝後重啟工作階段。 -- **呼叫**:`/jsc-persona:`(例 `/jsc-persona:persona-chat`)或依描述自動觸發。 - -### OpenCode - -OpenCode 的「plugin」是 TypeScript/npm 套件,不適用於 skill 包;skills 改用**目錄安裝**。 -OpenCode 會讀 `~/.config/opencode/skills/`(也會讀 `~/.claude/skills/`、`~/.agents/skills/`)。 - -```bash -# 安裝 -git clone https://gitea.jsc.idv.tw/plugins/persona.git ~/plugins/persona -mkdir -p ~/.config/opencode/skills -cp -r ~/plugins/persona/skills/* ~/.config/opencode/skills/ - -# 更新 -git -C ~/plugins/persona pull -cp -r ~/plugins/persona/skills/* ~/.config/opencode/skills/ - -# 移除 -rm -rf ~/.config/opencode/skills/{persona-anime,persona-chat,persona-create,persona-icon,persona-invite,persona-memory,persona-relation,persona-sleep,persona-status,persona-story,persona-sync,persona-therapist,persona-transfer} -``` - -> **Windows PowerShell**:`cp -r A B` → `Copy-Item A B -Recurse -Force`、`rm -rf X` → `Remove-Item X -Recurse -Force`、`~` → `$HOME`。 - -- 目錄安裝**不會帶入 `scripts/` 與 `hooks/`**;`persona.mjs` 不在,等於整套狀態操作都不可用。要在 OpenCode 用本 plugin,請另外 clone 本 repo 並自行呼叫 `scripts/persona.mjs`。 -- **呼叫**:直接描述需求,模型會依 skill 描述自動透過 skill 工具呼叫。 - -### GitHub Copilot CLI - -Copilot CLI 支援與 Claude Code 類似的原生 plugin / marketplace 指令,可直接從 marketplace 安裝、更新與移除本 plugin。 - -```bash -# 安裝 -copilot plugin marketplace add https://gitea.jsc.idv.tw/plugins/persona.git -copilot plugin install jsc-persona@persona - -# 更新 -copilot plugin marketplace update persona -copilot plugin update jsc-persona@persona - -# 移除 -copilot plugin uninstall jsc-persona@persona -copilot plugin marketplace remove persona -``` - -- 安裝 token `jsc-persona@persona` = plugin 名(plugin manifest 的 `name`)@ marketplace 名。 -- `copilot plugin marketplace add` 支援 GitHub `owner/repo`、git URL 與本地路徑;Gitea repo 可用上方 HTTPS URL。 -- Copilot CLI 沒有 hook:鎖與隔離同樣只剩 CLI 層的自律檢查。 -- **呼叫**:在 Copilot CLI 中用自然語言描述需求,例如 `copilot -i "用 lumi 這個人格跟我聊聊"`。 - ---- - -## 用 CLI 直接執行 skill(headless / 一次性) - -安裝好之後,不必進互動介面,一行指令就能叫某個 skill 跑完並印出結果: - -| 助理 | headless 指令 | 執行 `persona-chat` skill | -| --- | --- | --- | -| Claude Code | `claude -p ""` | `claude -p "/jsc-persona:persona-chat lumi"` | -| Codex | `codex exec ""` | `codex exec '$persona-chat lumi'` | -| Antigravity | `agy -p ""` | `agy -p "/jsc-persona:persona-chat lumi"` | -| OpenCode | `opencode run ""` | `opencode run "用 lumi 這個人格跟我聊聊"` | -| GitHub Copilot CLI | `copilot -p ""` | `copilot -p "用 lumi 這個人格跟我聊聊"` | - -- Claude / Antigravity 支援 `/jsc-persona:` 前綴,直接 `-p "/jsc-persona:"` 即可。 -- Codex 以 `$` 觸發;在 shell 請用**單引號**避免 `$` 被展開:`codex exec '$persona-chat …'`。 -- OpenCode 與 Copilot 沒有前綴,用自然語言描述需求;Copilot CLI 會讀取已安裝 plugin 提供的 skills。 -- 帶引數就接在後面,例如 `claude -p "/jsc-persona:persona-status --json"`。 - ---- - ## 設計取捨(讀之前先知道) - **記憶是被策展的,不是全存**:逐字稿進 `journal/`,但只有經過語意分析、有顯著度的內容才進短期記憶, @@ -1078,10 +979,3 @@ copilot plugin marketplace remove persona - **重複用相似度擋、不用語意判斷**:字元層級的比對沒有模型成本、行為可預期, 誤判時有 `--allow-repeat` 可救;漏判的代價(人格自我重複)比誤判高。 -## 新增/修改 skill - -1. 複製一個現有 skill 目錄,改 `SKILL.md` 的 `name` 與 `description`(描述要寫清楚何時用、何時不用)。 -2. 需要新的狀態操作 → 加到 `scripts/persona.mjs` 的子指令,並在 `scripts/selftest.mjs` 補測試。 -3. 動到隔離規則 → 一定要在 `selftest.mjs` 的第 ③(隔離)/⑧(guest 與劇場模式)區加對應案例,跑到全綠。 -4. 把 skill 補進上方「Skills 目錄」區塊。 -5. bump `.claude-plugin/plugin.json`、`.codex-plugin/plugin.json`、`plugin.json` 三處 `version`,commit 後 push。 diff --git a/hooks/hooks.json b/hooks/hooks.json index 34d35b2..732b9f0 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -35,6 +35,17 @@ "statusMessage": "檢查人格隔離…" } ] + }, + { + "matcher": "Read|Write|Edit|MultiEdit|NotebookEdit|Glob|Grep|LS|Bash", + "hooks": [ + { + "type": "command", + "command": "own='shared'; plug='jsc-shared'; rel='scripts/version-guard.mjs'; for base in \"$HOME/.claude/plugins/cache\" \"$HOME/.codex/plugins/cache\" \"$HOME/.copilot/installed-plugins\"; do for dir in \"$base/$own/$plug\" \"$base\"; do s=$(find \"$dir\" -path \"*/$plug/*/$rel\" -type f 2>/dev/null | sort -V | tail -n 1); if [ -n \"$s\" ]; then exec node --use-system-ca \"$s\"; fi; done; done; ts=$(TZ='Asia/Taipei' date +'%Y/%m/%d %H:%M:%S'); echo \"[$ts][版本檢查][ERR]: 找不到 jsc-shared 的 version-guard.mjs,fail-closed 阻擋。\" >&2; printf '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"找不到 jsc-shared 的 version-guard.mjs,fail-closed 阻擋\"}}\\n'; exit 0", + "timeout": 15, + "statusMessage": "檢查 jsc-persona 版本…" + } + ] } ], "Stop": [ diff --git a/plugin.json b/plugin.json index 099b861..52ad29f 100644 --- a/plugin.json +++ b/plugin.json @@ -1,6 +1,6 @@ { "name": "jsc-persona", - "version": "0.2.1", - "description": "AI 人格化記憶聊天 plugin:OpenClaw 相同的人格描述 + 十二情緒 + 語意分析 + 短期/長期記憶 + 心智圖 + 人際關係圖。於 Antigravity 以 /jsc-persona: 前綴呼叫。", + "version": "0.3.1", + "description": "AI 人格化記憶聊天 plugin:以 OpenClaw 相同的身分描述(IDENTITY/SOUL)建立人格(可用動漫作品+角色名上網蒐集設定),結合 hook 強制的人格載入鎖與跨人格隔離、六正向+六負向十二情緒、語意分析、短期/長期記憶與固化條件、心智圖、思維導圖與人際關係圖;可用 sub agent 邀請其他人格同場對話(劇場模式只顯示人格對話);於 Antigravity 以 /jsc-persona: 前綴呼叫。", "skills": "./skills" } diff --git a/plugin.meta.json b/plugin.meta.json new file mode 100644 index 0000000..e2a4e62 --- /dev/null +++ b/plugin.meta.json @@ -0,0 +1,54 @@ +{ + "name": "jsc-persona", + "shortName": "persona", + "version": "0.3.1", + "descriptionCore": "AI 人格化記憶聊天 plugin:以 OpenClaw 相同的身分描述(IDENTITY/SOUL)建立人格(可用動漫作品+角色名上網蒐集設定),結合 hook 強制的人格載入鎖與跨人格隔離、六正向+六負向十二情緒、語意分析、短期/長期記憶與固化條件、心智圖、思維導圖與人際關係圖;可用 sub agent 邀請其他人格同場對話(劇場模式只顯示人格對話)。", + "assistants": ["Claude Code", "Codex", "Antigravity", "OpenCode", "GitHub Copilot CLI"], + "cliPrefix": "/jsc-persona:", + "callPrefixAssistants": { + "root": "Antigravity", + "claudePlugin": "Claude Code" + }, + "codexNote": "(人格鎖與跨人格隔離的 hook 僅在 Claude Code 生效)", + "skillsPath": "./skills", + "author": { "name": "JSC" }, + "homepage": "https://gitea.jsc.idv.tw/plugins/persona", + "repository": "https://gitea.jsc.idv.tw/plugins/persona.git", + "keywords": [ + "persona", + "memory", + "emotion", + "mindmap", + "relationship", + "openclaw", + "anime", + "roleplay", + "jsc" + ], + "marketplace": { + "repoDescription": "JSC 人格化記憶聊天 skills 的 Claude Code marketplace,提供人格建立與對話、十二情緒與短期/長期記憶管理、心智圖/思維導圖與人際關係圖維護等功能。", + "pluginSummary": "AI 人格化記憶聊天(人格 / 情緒 / 記憶 / 心智圖 / 關係圖)" + }, + "agentsExtraBullets": [ + "**所有狀態變更都經過 `scripts/persona.mjs`**,不要手動編輯 `state/lock.json`、`.runtime/`、`memory/INDEX.md`、`relations/graph.mmd`(這些由 CLI 產生)。", + "**一個程序只能載入一個人格**,同一 session 的 sub agent 沿用同一把鎖;不要讀取其他人格的目錄,會被 hook deny,這是設計上的紅線。", + "**人格資料不在本 repo**,預設在 `~/.claude/personas/`(可用 `PERSONA_HOME` 覆寫);人格由使用者呼叫才載入,不要自己挑一個人格附身。", + "劇場模式、睡眠、講話規則、情緒模型、羞恥度、記憶衰減、人格圖示、人格編號、Gitea 同步、語氣診療(`persona-therapist`)、原作匯入(`persona-story`)等行為細節,見 `README.md` 對應章節與各 skill 的 `SKILL.md`;動到 `scripts/persona-lib.mjs` 相關邏輯前務必先讀懂對應章節與 `selftest.mjs` 的既有案例,不要憑印象改。", + "改動鎖或隔離邏輯(`guardDecide`/`acquireLock`/`promotionCandidates`/`exportBundle`/`importBundle`)後,**必須**跑 `node scripts/selftest.mjs` 且全綠,並為新規則補一條測試;改動重複判定門檻(`similarity`/`REPEAT_THRESHOLD`)要用 selftest ⑪ 對照案例確認;改動同步分區(`AREAS`)後 selftest ⑬ 的「不重不漏」檢查必須維持全綠。", + "腳本一律 **Node.js(`.mjs`, ESM)**,只用內建模組(fs/path/os/crypto);hook 必須在任何環境都能跑,不得引入 npm 依賴。selftest 自己會設 `PERSONA_GITEA=off`,**絕對不要**讓測試碰到真的 Gitea。" + ], + "readmeInstallNotes": [ + "本 plugin 的 marketplace 名是 `persona`(= repo 名),plugin 名是 `jsc-persona`,安裝 token 為 `jsc-persona@persona`。", + "**⚠ 若本機還留著更早的 `jsc-persona@jsc-plugins` 安裝**,那是舊版命名的殘影,請先移除舊鍵再重裝新 token。", + "**人格資料不受影響**:人格倉庫在 `~/.claude/personas/`(或 `PERSONA_HOME`),不在 plugin 目錄裡,移除/重裝 plugin 不會動到情緒、記憶與關係圖。Antigravity/OpenCode 是本地路徑/目錄安裝,沒有 marketplace 名,不受此命名影響。" + ], + "_driftDecisions": [ + "決議1:skills 欄位統一為不帶尾斜線的 './skills'。現況:persona 三份 plugin.json(root/.claude-plugin/.codex-plugin)皆已是 './skills',唯一完全符合決議、不需調整的 repo。", + "決議2:root plugin.json 已有「於 Antigravity 以 /jsc-persona: 前綴呼叫」句尾,符合決議,不需調整。", + "決議3:.claude-plugin/plugin.json 現況完全沒有助理括號清單(四個 repo 中唯一缺漏,非順序/命名問題而是整段缺失)。已依統一決議補上「(Claude Code / Codex / Antigravity / OpenCode / GitHub Copilot CLI)」。", + "決議4對應:.codex-plugin/plugin.json 專屬補充資訊「(人格鎖與跨人格隔離的 hook 僅在 Claude Code 生效)」為真實資訊差異,已保留於 codexNote,不可被格式統一誤刪。", + "額外發現1(重要,非既知四點):root 與 .codex-plugin 的描述明顯比 .claude-plugin 簡短,完全沒提到「思維導圖」「可用 sub agent 邀請其他人格同場對話(劇場模式)」「以 IDENTITY/SOUL 建立人格、可用動漫作品+角色名上網蒐集設定」等內容——這是實質內容缺漏,不是格式差異。descriptionCore 已採用 .claude-plugin 的完整版本為準,下階段產生器需把完整內容回補到 root 與 .codex-plugin。", + "額外發現2(重要,非既知四點):.claude-plugin/marketplace.json 目前的 repo 層 description 為「JSC 跨 AI 助理共用 skills 的 Claude Code marketplace。」——這段文字幾乎是照抄 shared 的樣板句(少了「規範」二字),完全沒提到人格/情緒/記憶/心智圖等 persona 專屬內容,判斷為複製貼上殘留、忘記客製化,而非合理的精簡描述。marketplace.repoDescription 已在此 meta 更正為 persona 專屬描述,下階段產生器需一併修正該 marketplace.json(plugins[].description 本身沒問題,維持原樣)。", + "額外發現3(次要):root 描述用詞為「AI 人格化記憶聊天 plugin」,.codex-plugin 用詞為「AI 人格化記憶聊天 skills」,.claude-plugin 用「plugin」——plugin/skills 用詞不一致,屬低優先級格式細節。" + ] +} diff --git a/scripts/migrate-tz.mjs b/scripts/migrate-tz.mjs new file mode 100644 index 0000000..87cec97 --- /dev/null +++ b/scripts/migrate-tz.mjs @@ -0,0 +1,604 @@ +#!/usr/bin/env node +// migrate-tz.mjs — 一次性遷移:修正既有人格資料裡的時區資料。 +// +// 背景 +// ---- +// `persona-lib.mjs` 的 `iso()`/`nowIso()`(約第 140 行)現在會把時刻位移到台北時區 +// 再輸出 `+08:00` 字尾;`todayTaipei()`(約第 168 行)也是同一套位移邏輯,取代了 +// 「錯誤的 `nowIso().slice(0, 10)`」(那個註解本身就是在講:舊版 `iso()` 曾經機械地 +// 回傳 UTC `Z` 時間戳,沒有位移,直接切前 10 個字元拿到的是 UTC 當天,不是台北當天)。 +// +// 這兩個函式修好之後**不會回頭改舊資料**:在 UTC 16:00~23:59(台北已經跨到下一天) +// 這八小時窗口內寫下的舊紀錄,時間戳本身是 `...Z` 格式(可以無損換算),但由它 +// 切出來的日期鍵(例如「今天」的日記日期)算出來的是 UTC 當天,比實際的台北曆日 +// 少一天。這支腳本就是把這兩類舊資料換算成正確答案: +// +// (1) 任何 `Z` 結尾的 ISO 時間戳字串 → 等值換算成 `+08:00` 格式(換算成同一個 +// 瞬間在台北時區的牆上時間,不是機械加 8 小時:兩者在大多數時刻結果一樣, +// 但概念上與程式碼上都要走「取 epoch → 用 iso() 同一套位移公式重新格式化」 +// 這條路,才經得起夏令時間/閏秒之類未來變動)。 +// (2) 任何「純日期」欄位(`yyyy-MM-dd`,沒有時間部分)→ 如果它所在的**同一個 +// JSON 物件**裡,找得到剛好一個(不模糊)完整時間戳可以佐證「這筆記錄實際 +// 發生在哪個台北曆日」,就用那個時間戳重算;同一物件裡的時間戳互相矛盾, +// 或根本沒有時間戳可用,就不動它,只記到報告的「無法確定」清單。 +// +// 資料模型調查結論(見 persona-lib.mjs) +// -------------------------------------- +// * `state/mood.json`(`loadDayMood`/`updateDayMood`,約第 716~752 行): +// `{ date, valence, arousal, samples, updated_at }` —— `date` 與 `updated_at` +// 是同一個物件的兩個欄位,`updated_at` 就是這筆「當日心情底色」最後一次被寫入 +// 的完整時間戳,可以直接拿來重算 `date` 應該是哪個台北曆日。這是本腳本能夠 +// 自動判定重算的典型案例(也是題目說的「日記的單日紀錄」)。 +// * `relations/graph.json` 的 `nodes[].rifts[]`(`openRifts`/`riftBrief`, +// 約第 4854~4886 行;寫入邏輯在 persona.mjs 的 `relation rift`): +// 每筆 `{ at, about, memory?, healed_at? }` 都是純日期鍵,但**同一筆 rift 物件 +// 裡沒有伴隨的完整時間戳**——唯一的時間戳在它的上層節點(`node.updated_at`), +// 而 `node.updated_at` 是整個節點共用、會被之後任何一次更新(改親近度、加另一件 +// 還沒和好的事…)覆寫掉的欄位,不能拿來當「這一筆 rift 當時是幾點」的證據。 +// 這一類欄位落進「無法確定,需人工檢視」清單,不強行用不可靠的上層時間戳去猜。 +// * 自傳章節(`memory/chapters/*.md`)與長期記憶(`memory/long-term/*.md`)的 +// `from`/`to`/`first_seen`/`last_seen` 等日期鍵存在 Markdown front matter 裡, +// 不是 `.json` 檔——依題目指示本腳本只走 `.json`,這兩處天生不在掃描範圍內, +// 如果之後要修,需要另一支專門處理 Markdown front matter 的腳本。 +// * 其餘找到的日期鍵/時間戳欄位(`state/emotion.json` 的 `updated_at`/ +// `streak.at`/`last_trigger.at`、`state/config.json` 的 `created_at`、 +// `state/lock.json`/`guests.json`/`sleepers.json` 的 `*_at`、 +// `state/loops.json` 的 `opened_at`/`touched_at`、 +// `memory/import//work.json`/`names.json` 的 `created_at`/`updated_at`) +// 全部都是完整時間戳,沒有「純日期」欄位,直接吃規則 (1) 就好。 +// +// 掃描範圍 +// -------- +// 只遞迴走訪 `PERSONA_HOME` 底下、排除 `.runtime`/`.rooms`(這兩個是跨人格的 +// 執行期目錄,不是人格目錄)的每一個子目錄裡的每一個 `.json` 檔案;不動 +// `.jsonl`(append-only 日誌,語意上是「歷史事件序列」,不是「這一筆的日期鍵」, +// 且題目本身就是說 `.json`)。 +// +// 用法 +// ---- +// node migrate-tz.mjs [--dry-run] [--home ] +// +// 不帶 `--home` 時依序退回 `PERSONA_HOME` 環境變數、`~/.claude/personas`。 +// +// 安全機制 +// -------- +// a. 執行前一定先檢查是否有人格處於載入狀態(exclusive lock 或存活的 guest +// 租約)——不管是不是 `--dry-run`。有就中止、不強行執行。 +// b. 正式執行(非 `--dry-run`)一定先把整個 `PERSONA_HOME` 完整複製備份到 +// `<上一層目錄>/personas.bak-/`(時間戳是台北時區)。 +// `--dry-run` 不寫入任何檔案,連備份都不做。 +// c. 先掃描「整個」`PERSONA_HOME`、算出全部異動計畫,**掃描階段完全不寫檔**; +// 只有掃描完全結束、且沒有偵測到合併衝突,才會真正落筆——這樣「合併衝突 +// 讓整批中止」才能保證「不寫入任何檔案」,不會有些檔案改了、有些沒改。 +// d. 合併衝突:同一個陣列裡,兩筆以上物件成員共用同一個欄位名稱、原始值不同, +// 重算後卻落在同一個台北日──這種情況不自動選一筆,整批中止、報告列出 +// 衝突的原始內容,等人工決定怎麼合併再重跑。 +// e. 執行完(非 dry-run)後自動驗證:全樹已經沒有 `Z` 結尾的時間戳字串; +// 每一個被轉換過的時間戳欄位,轉換前後的 epoch 完全相等。任何一項不成立 +// 都算嚴重錯誤(exit code 4),需要人工介入。 +// +// exit code:0 成功(含「沒有東西可遷移」)/1 一般錯誤/2 有人格正在載入而中止/ +// 3 偵測到合併衝突而中止/4 執行後驗證失敗(嚴重錯誤)。 + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +// --------------------------------------------------------------------------- // +// 路徑與時間工具(刻意仿照 persona-lib.mjs 的 iso()/todayTaipei(),見檔案開頭 +// 註解;不 import persona-lib.mjs,讓這支一次性腳本不依賴那個模組今後怎麼演化) +// --------------------------------------------------------------------------- // + +const TAIPEI_OFFSET_MS = 8 * 60 * 60 * 1000; +const RUNTIME_DIRNAME = ".runtime"; +const ROOMS_DIRNAME = ".rooms"; + +function expandUser(p) { + if (p === "~") return os.homedir(); + if (p.startsWith("~/")) return path.join(os.homedir(), p.slice(2)); + return p; +} + +function resolveHome(homeArg) { + const raw = homeArg || process.env.PERSONA_HOME || "~/.claude/personas"; + return path.resolve(expandUser(raw)); +} + +/** 等同 persona-lib.mjs 的 iso():取 epoch,位移 8 小時,再格式化——不是機械加 8 小時。 */ +function taipeiIsoFromEpoch(ms) { + const truncated = Math.floor(ms / 1000) * 1000; + const shifted = new Date(truncated + TAIPEI_OFFSET_MS); + const pad = (n) => String(n).padStart(2, "0"); + return ( + `${shifted.getUTCFullYear()}-${pad(shifted.getUTCMonth() + 1)}-${pad(shifted.getUTCDate())}` + + `T${pad(shifted.getUTCHours())}:${pad(shifted.getUTCMinutes())}:${pad(shifted.getUTCSeconds())}+08:00` + ); +} + +/** 等同 persona-lib.mjs 的 todayTaipei():同一個瞬間換算成台北曆日。 */ +function taipeiDateFromEpoch(ms) { + const shifted = new Date(Math.floor(ms / 1000) * 1000 + TAIPEI_OFFSET_MS); + const pad = (n) => String(n).padStart(2, "0"); + return `${shifted.getUTCFullYear()}-${pad(shifted.getUTCMonth() + 1)}-${pad(shifted.getUTCDate())}`; +} + +/** 備份檔名用的緊湊時間戳(台北時區):yyyyMMddHHmmss。 */ +function taipeiCompactStamp(date = new Date()) { + const shifted = new Date(Math.floor(date.getTime() / 1000) * 1000 + TAIPEI_OFFSET_MS); + const pad = (n) => String(n).padStart(2, "0"); + return ( + `${shifted.getUTCFullYear()}${pad(shifted.getUTCMonth() + 1)}${pad(shifted.getUTCDate())}` + + `${pad(shifted.getUTCHours())}${pad(shifted.getUTCMinutes())}${pad(shifted.getUTCSeconds())}` + ); +} + +// --------------------------------------------------------------------------- // +// 值的形狀判斷(全部靠值的形狀,不靠欄位名稱白名單) +// --------------------------------------------------------------------------- // + +const Z_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/; +const TW_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+08:00$/; +const DATE_RE = /^\d{4}-\d{2}-\d{2}$/; + +/** 形狀對了還要日曆上真的存在(擋 2026-13-40 之類假日期),做法跟 persona-lib.mjs 的 isYmd 一樣。 */ +function isValidCalendarDate(value) { + if (!DATE_RE.test(value)) return false; + const d = new Date(`${value}T00:00:00Z`); + return !Number.isNaN(d.getTime()) && d.toISOString().slice(0, 10) === value; +} + +/** 一個物件自己身上(不含巢狀子物件)所有完整時間戳,換算後的台北曆日集合。 */ +function siblingTaipeiDates(obj) { + const dates = new Set(); + for (const value of Object.values(obj)) { + if (typeof value !== "string") continue; + if (Z_RE.test(value) || TW_RE.test(value)) { + const t = new Date(value).getTime(); + if (!Number.isNaN(t)) dates.add(taipeiDateFromEpoch(t)); + } + } + return dates; +} + +// --------------------------------------------------------------------------- // +// 樹狀掃描:只算出「該怎麼改」,完全不動傳進來的物件、也不寫檔 +// --------------------------------------------------------------------------- // + +/** + * 掃一個檔案已經 parse 好的 JSON 樹。 + * 回報三種結果: + * changes 要改的欄位(timestamp:Z→+08:00;datekey:日期鍵重算) + * undetermined 純日期欄位但推算不出正確台北日,維持原值 + * conflicts 同一個陣列裡兩筆以上成員重算後撞在同一個台北日 + */ +function scanTree(root, fileLabel) { + const changes = []; + const undetermined = []; + const conflicts = []; + + function visitObject(obj, pathArr) { + const resolvable = siblingTaipeiDates(obj); + for (const [key, value] of Object.entries(obj)) { + if (typeof value === "string") { + if (Z_RE.test(value)) { + const epochOld = new Date(value).getTime(); + const newValue = taipeiIsoFromEpoch(epochOld); + changes.push({ + file: fileLabel, + path: [...pathArr, key], + kind: "timestamp", + oldValue: value, + newValue, + epochOld, + epochNew: new Date(newValue).getTime(), + }); + } else if (DATE_RE.test(value) && isValidCalendarDate(value)) { + if (resolvable.size === 1) { + const [only] = resolvable; + if (only !== value) { + changes.push({ file: fileLabel, path: [...pathArr, key], kind: "datekey", oldValue: value, newValue: only }); + } + } else if (resolvable.size === 0) { + undetermined.push({ + file: fileLabel, + path: [...pathArr, key], + value, + reason: "同一物件裡找不到可用的完整時間戳", + }); + } else { + undetermined.push({ + file: fileLabel, + path: [...pathArr, key], + value, + reason: `同一物件裡有 ${resolvable.size} 個互相矛盾的時間戳(${[...resolvable].join(" / ")}),無法判斷歸屬哪個台北日`, + }); + } + } + } else if (value && typeof value === "object") { + visit(value, [...pathArr, key]); + } + } + } + + /** 同一個陣列裡,兩筆以上物件成員共用同一個欄位名稱、原始值不同,卻重算成同一個台北日。 */ + function detectArrayConflicts(arr, pathArr) { + const objItems = arr + .map((item, i) => ({ item, i })) + .filter(({ item }) => item && typeof item === "object" && !Array.isArray(item)); + if (objItems.length < 2) return; + const fieldNames = new Set(); + for (const { item } of objItems) { + for (const [k, v] of Object.entries(item)) { + if (typeof v === "string" && DATE_RE.test(v) && isValidCalendarDate(v)) fieldNames.add(k); + } + } + for (const field of fieldNames) { + const groups = new Map(); // 新值 → [{index, original, item}] + for (const { item, i } of objItems) { + const raw = item[field]; + if (typeof raw !== "string" || !DATE_RE.test(raw) || !isValidCalendarDate(raw)) continue; + const dates = siblingTaipeiDates(item); + if (dates.size !== 1) continue; // 這筆自己就推算不出來,不參與衝突判斷(會落進 undetermined) + const [resolved] = dates; + if (!groups.has(resolved)) groups.set(resolved, []); + groups.get(resolved).push({ index: i, original: raw, item }); + } + for (const [newValue, group] of groups) { + if (group.length < 2) continue; + const distinctOriginals = new Set(group.map((g) => g.original)); + if (distinctOriginals.size > 1) { + conflicts.push({ + file: fileLabel, + path: [...pathArr], + field, + newValue, + entries: group.map((g) => ({ + index: g.index, + path: [...pathArr, g.index, field], + original: g.original, + record: g.item, + })), + }); + } + } + } + } + + function visit(node, pathArr) { + if (Array.isArray(node)) { + detectArrayConflicts(node, pathArr); + node.forEach((item, i) => { + if (item && typeof item === "object") visit(item, [...pathArr, i]); + }); + return; + } + if (node && typeof node === "object") { + visitObject(node, pathArr); + } + } + + if (Array.isArray(root) || (root && typeof root === "object")) { + visit(root, []); + } else if (typeof root === "string") { + // 罕見邊界:整個檔案的內容就是一個字串(沒有其他欄位可以當 sibling)。 + if (Z_RE.test(root)) { + const epochOld = new Date(root).getTime(); + const newValue = taipeiIsoFromEpoch(epochOld); + changes.push({ file: fileLabel, path: [], kind: "timestamp", oldValue: root, newValue, epochOld, epochNew: new Date(newValue).getTime() }); + } else if (DATE_RE.test(root) && isValidCalendarDate(root)) { + undetermined.push({ file: fileLabel, path: [], value: root, reason: "檔案頂層就是日期字串,沒有其他欄位可用來推算" }); + } + } + + return { changes, undetermined, conflicts }; +} + +function applyChanges(root, changes) { + let newRoot = root; + for (const change of changes) { + if (change.path.length === 0) { + newRoot = change.newValue; + continue; + } + let cur = newRoot; + for (let i = 0; i < change.path.length - 1; i++) cur = cur[change.path[i]]; + cur[change.path[change.path.length - 1]] = change.newValue; + } + return newRoot; +} + +// --------------------------------------------------------------------------- // +// 檔案/目錄走訪 +// --------------------------------------------------------------------------- // + +function listPersonaDirs(home) { + let entries; + try { + entries = fs.readdirSync(home, { withFileTypes: true }); + } catch { + return null; // home 不存在 + } + return entries + .filter((e) => e.isDirectory() && e.name !== RUNTIME_DIRNAME && e.name !== ROOMS_DIRNAME && !e.name.startsWith(".")) + .map((e) => e.name) + .sort(); +} + +function collectJsonFiles(dir) { + const out = []; + const stack = [dir]; + while (stack.length) { + const cur = stack.pop(); + let entries; + try { + entries = fs.readdirSync(cur, { withFileTypes: true }); + } catch { + continue; + } + for (const e of entries) { + const full = path.join(cur, e.name); + if (e.isDirectory()) stack.push(full); + else if (e.isFile() && e.name.endsWith(".json")) out.push(full); + } + } + return out.sort(); +} + +// --------------------------------------------------------------------------- // +// 前置檢查:有沒有人格正在被載入 +// +// 邏輯仿照 persona-lib.mjs 的 lockIsDead()/liveGuests()(約第 1607~1617 行、 +// LEASE_SECONDS=900/GUEST_LEASE_SECONDS=1800,約第 23~24 行):只看心跳租約 +// 有沒有過期,不靠 pid(CLI process 跑完就結束,不能拿 pid 存活判斷)。 +// --------------------------------------------------------------------------- // + +const LEASE_SECONDS = 900; +const GUEST_LEASE_SECONDS = 1800; + +function readJsonSafe(file) { + try { + return JSON.parse(fs.readFileSync(file, "utf8")); + } catch { + return null; + } +} + +function checkActiveLocks(home, personaDirs) { + const active = []; + for (const slug of personaDirs) { + const lock = readJsonSafe(path.join(home, slug, "state", "lock.json")); + if (lock && typeof lock === "object" && lock.heartbeat_at) { + const hbAge = (Date.now() - new Date(lock.heartbeat_at).getTime()) / 1000; + const lease = Number(lock.lease_seconds) || LEASE_SECONDS; + if (Number.isFinite(hbAge) && hbAge <= lease) { + active.push({ slug, kind: "lock", session_id: lock.session_id, cwd: lock.cwd, heartbeat_at: lock.heartbeat_at }); + } + } + const guestsData = readJsonSafe(path.join(home, slug, "state", "guests.json")); + const guests = Array.isArray(guestsData?.guests) ? guestsData.guests : []; + for (const g of guests) { + if (!g || !g.heartbeat_at) continue; + const hbAge = (Date.now() - new Date(g.heartbeat_at).getTime()) / 1000; + if (Number.isFinite(hbAge) && hbAge <= GUEST_LEASE_SECONDS) { + active.push({ slug, kind: "guest", session_id: g.session_id, room: g.room, heartbeat_at: g.heartbeat_at }); + } + } + } + return active; +} + +// --------------------------------------------------------------------------- // +// 備份 +// --------------------------------------------------------------------------- // + +function backupHome(home) { + const parent = path.dirname(home); + const dest = path.join(parent, `personas.bak-${taipeiCompactStamp(new Date())}`); + fs.cpSync(home, dest, { recursive: true }); + return dest; +} + +// --------------------------------------------------------------------------- // +// 執行後驗證 +// --------------------------------------------------------------------------- // + +function verifyNoLeftoverZ(home, personaDirs) { + const offenders = []; + for (const slug of personaDirs) { + for (const file of collectJsonFiles(path.join(home, slug))) { + const text = fs.readFileSync(file, "utf8"); + // 粗略但夠用:找形似 ISO 時間戳、以 Z 結尾的片段(跟 Z_RE 同一個形狀, + // 只是不要求整個字串就是它——用來抓「文件裡還殘留 Z 時間戳」這個粗粒度事實)。 + const re = /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z/g; + let m; + while ((m = re.exec(text))) offenders.push({ file, match: m[0] }); + } + } + return offenders; +} + +// --------------------------------------------------------------------------- // +// CLI +// --------------------------------------------------------------------------- // + +function parseArgs(argv) { + const out = { dryRun: false, home: null, help: false }; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === "--dry-run") out.dryRun = true; + else if (a === "--home") out.home = argv[++i]; + else if (a === "--help" || a === "-h") out.help = true; + else { + process.stderr.write(`未知的參數:${a}\n`); + process.exit(1); + } + } + return out; +} + +function printHelp() { + process.stdout.write( + "用法:node migrate-tz.mjs [--dry-run] [--home ]\n" + + " --dry-run 只掃描並印出報告,不寫入任何檔案(連備份都不做)\n" + + " --home 指定 PERSONA_HOME;不帶時用環境變數 PERSONA_HOME,再退回 ~/.claude/personas\n", + ); +} + +function fmtPath(p) { + return p.length ? `/${p.join("/")}` : "(root)"; +} + +function main() { + const args = parseArgs(process.argv.slice(2)); + if (args.help) { + printHelp(); + return 0; + } + + const home = resolveHome(args.home); + console.log(`[migrate-tz] PERSONA_HOME = ${home}${args.dryRun ? "(--dry-run,不會寫入任何檔案)" : ""}`); + + const personaDirs = listPersonaDirs(home); + if (personaDirs === null) { + console.log(`[migrate-tz] 目錄不存在,沒有東西可遷移:${home}`); + return 0; + } + if (personaDirs.length === 0) { + console.log(`[migrate-tz] 目錄裡沒有人格子目錄,沒有東西可遷移:${home}`); + return 0; + } + console.log(`[migrate-tz] 找到 ${personaDirs.length} 個人格目錄:${personaDirs.join(", ")}`); + + // a. 前置檢查:有沒有人格正在載入 + const activeLocks = checkActiveLocks(home, personaDirs); + if (activeLocks.length) { + console.error("[migrate-tz] 中止:以下人格目前處於載入狀態,不強行執行:"); + for (const a of activeLocks) { + if (a.kind === "lock") { + console.error(` - ${a.slug}:exclusive lock(session ${a.session_id},cwd ${a.cwd},最後心跳 ${a.heartbeat_at})`); + } else { + console.error(` - ${a.slug}:guest 租約(session ${a.session_id},room ${a.room},最後心跳 ${a.heartbeat_at})`); + } + } + console.error("請先讓所有人格釋放鎖(結束對話 / persona status --release)再重跑這支腳本。"); + return 2; + } + console.log("[migrate-tz] 前置檢查通過:沒有人格處於載入狀態。"); + + // b. 備份(dry-run 完全不寫檔,連備份都不做) + let backupDir = null; + if (!args.dryRun) { + backupDir = backupHome(home); + console.log(`[migrate-tz] 已備份到:${backupDir}`); + } + + // c/d/e. 掃描整個 PERSONA_HOME,算出全部異動計畫;掃描階段完全不寫檔 + const perFile = []; // { file, label, root, changes } + const allChanges = []; + const allUndetermined = []; + const allConflicts = []; + + for (const slug of personaDirs) { + for (const file of collectJsonFiles(path.join(home, slug))) { + const label = path.relative(home, file); + const raw = fs.readFileSync(file, "utf8"); + let root; + try { + root = JSON.parse(raw); + } catch (err) { + console.error(`[migrate-tz] 略過(JSON 解析失敗,不會被計入任何統計):${label} — ${err.message}`); + continue; + } + const { changes, undetermined, conflicts } = scanTree(root, label); + if (changes.length) perFile.push({ file, label, root, changes }); + allChanges.push(...changes); + allUndetermined.push(...undetermined); + allConflicts.push(...conflicts); + } + } + + const tsChanges = allChanges.filter((c) => c.kind === "timestamp"); + const dateChanges = allChanges.filter((c) => c.kind === "datekey"); + const filesChanged = new Set(perFile.map((f) => f.label)); + + console.log(""); + console.log("========== 掃描報告 =========="); + console.log(`將改動的檔案數:${filesChanged.size}`); + console.log(`時間戳欄位(Z → +08:00):${tsChanges.length}`); + console.log(`日期鍵欄位(重算台北曆日):${dateChanges.length}`); + console.log(`無法確定(維持原值,需人工檢視):${allUndetermined.length}`); + console.log(`合併衝突:${allConflicts.length}`); + + if (allUndetermined.length) { + console.log(""); + console.log("---- 無法確定,需人工檢視 ----"); + for (const u of allUndetermined) { + console.log(` ${u.file}${fmtPath(u.path)} = "${u.value}" — ${u.reason}`); + } + } + + if (allConflicts.length) { + console.log(""); + console.log("---- 合併衝突(未寫入任何檔案,需人工決定合併方式後重跑) ----"); + for (const c of allConflicts) { + console.log(` 檔案 ${c.file},陣列 ${fmtPath(c.path)},欄位 \`${c.field}\`,重算後都落在 ${c.newValue}:`); + for (const e of c.entries) { + console.log(` - [${e.index}] 原始值 ${e.original}:${JSON.stringify(e.record)}`); + } + } + console.error(""); + console.error(`[migrate-tz] 中止:偵測到 ${allConflicts.length} 組合併衝突,不寫入任何檔案。`); + console.error("請先人工決定怎麼合併上面列出的紀錄,再重新執行這支腳本。"); + return 3; + } + + if (args.dryRun) { + console.log(""); + console.log("[migrate-tz] --dry-run:以上為預覽,沒有寫入任何檔案。"); + return 0; + } + + if (filesChanged.size === 0) { + console.log(""); + console.log("[migrate-tz] 沒有需要修改的欄位(可能已經是 +08:00 格式,或本來就沒有需要換算的資料)。"); + return 0; + } + + // 落筆:只有掃描完全結束、確定沒有合併衝突,才寫入 + for (const { file, root, changes } of perFile) { + const newRoot = applyChanges(root, changes); + const tmp = `${file}.tmp${process.pid}`; + fs.writeFileSync(tmp, JSON.stringify(newRoot, null, 2) + "\n", "utf8"); + fs.renameSync(tmp, file); + } + console.log(""); + console.log(`[migrate-tz] 已寫入 ${filesChanged.size} 個檔案:`); + for (const label of filesChanged) console.log(` - ${label}`); + + // e. 執行後自動驗證 + console.log(""); + console.log("========== 執行後驗證 =========="); + const leftover = verifyNoLeftoverZ(home, personaDirs); + if (leftover.length) { + console.error(`[migrate-tz] 嚴重錯誤:全樹掃描後仍找到 ${leftover.length} 個 Z 結尾的時間戳,遷移不完整:`); + for (const o of leftover) console.error(` - ${o.file}: ${o.match}`); + return 4; + } + console.log("(i) 全樹已無殘留的 Z 結尾時間戳:通過。"); + + const epochMismatches = tsChanges.filter((c) => c.epochOld !== c.epochNew); + if (epochMismatches.length) { + console.error(`[migrate-tz] 嚴重錯誤:${epochMismatches.length} 個時間戳欄位轉換前後 epoch 不相等:`); + for (const m of epochMismatches) { + console.error(` - ${m.file}${fmtPath(m.path)}:${m.oldValue}(${m.epochOld}) → ${m.newValue}(${m.epochNew})`); + } + return 4; + } + console.log(`(ii) ${tsChanges.length} 個時間戳欄位逐一驗證轉換前後 epoch 相等:通過。`); + + console.log(""); + console.log(`[migrate-tz] 完成。備份保留於:${backupDir}`); + return 0; +} + +process.exit(main()); diff --git a/scripts/persona-gitea.mjs b/scripts/persona-gitea.mjs index 9f866b7..53dc682 100644 --- a/scripts/persona-gitea.mjs +++ b/scripts/persona-gitea.mjs @@ -77,7 +77,7 @@ export async function nextCodeAcrossMachines(romaji, { owner = null } = {}) { const taken = remote.personas.map((p) => p.code); return { code: nextCode(romaji, { taken }), checked_remote: true, taken }; } catch (err) { - return { code: local, checked_remote: false, reason: String(err.message || err) }; + return { code: local, checked_remote: false, reason: pl.redactSecrets(String(err.message || err)) }; } } @@ -178,6 +178,8 @@ export function updateSyncState(slug, mutate) { // 環境與 API // --------------------------------------------------------------------------- // +// giteaEnv()/giteaProblem() 實作 /jsc-shared:spec-gitea 的『token 解析優先序』章節: +// 專用變數(PERSONA_GITEA_TOKEN)插在最前面,其次才是 GITEA_TOKEN,順序不可打亂。 export function giteaEnv() { const host = String(process.env.PERSONA_GITEA_HOST || process.env.GITEA_HOST || "").replace(/\/+$/, ""); const token = String(process.env.PERSONA_GITEA_TOKEN || process.env.GITEA_TOKEN || ""); @@ -194,6 +196,8 @@ export function giteaProblem() { return null; } +// api() 實作 /jsc-shared:spec-gitea 的『API 呼叫慣例』章節: +// base 為 `${host}/api/v1`,標頭帶 `Authorization: token `,body 一律 UTF-8 JSON。 async function api(method, route, body = null) { const { host, token } = giteaEnv(); const res = await fetch(`${host}/api/v1${route}`, { @@ -223,7 +227,7 @@ export async function giteaLogin() { const cached = pl.readJson(ownerCachePath(), {}) ?? {}; if (cached.host === env.host && cached.login) return cached.login; const res = await api("GET", "/user"); - if (!res.ok || !res.json?.login) throw new Error(`取不到 Gitea 帳號(HTTP ${res.status}):${res.text.slice(0, 120)}`); + if (!res.ok || !res.json?.login) throw new Error(`取不到 Gitea 帳號(HTTP ${res.status}):${pl.redactSecrets(res.text).slice(0, 120)}`); pl.writeJson(ownerCachePath(), { host: env.host, login: res.json.login, cached_at: pl.nowIso() }); return res.json.login; } @@ -241,7 +245,7 @@ async function listRepos(owner, me) { const out = []; for (let page = 1; page <= 40; page += 1) { const res = await api("GET", `${route}?page=${page}&limit=50`); - if (!res.ok) throw new Error(`列出 ${owner} 的存取庫失敗(HTTP ${res.status}):${res.text.slice(0, 160)}`); + if (!res.ok) throw new Error(`列出 ${owner} 的存取庫失敗(HTTP ${res.status}):${pl.redactSecrets(res.text).slice(0, 160)}`); const batch = Array.isArray(res.json) ? res.json : []; out.push(...batch); if (batch.length < 50) break; @@ -304,7 +308,7 @@ export async function ensureRepo(owner, code, { description = "", private_ = tru description: description || `jsc-persona 人格 ${code}`, auto_init: false, }); - if (!res.ok) throw new Error(`建立存取庫 ${owner}/${code} 失敗(HTTP ${res.status}):${res.text.slice(0, 160)}`); + if (!res.ok) throw new Error(`建立存取庫 ${owner}/${code} 失敗(HTTP ${res.status}):${pl.redactSecrets(res.text).slice(0, 160)}`); void env; return { repo: res.json, created: true }; } @@ -318,7 +322,7 @@ export async function ensureWikiHome(owner, code, content) { content_base64: Buffer.from(content, "utf8").toString("base64"), message: "init: 人格設定百科", }); - if (!res.ok) throw new Error(`建立 Wiki 首頁失敗(HTTP ${res.status}):${res.text.slice(0, 160)}`); + if (!res.ok) throw new Error(`建立 Wiki 首頁失敗(HTTP ${res.status}):${pl.redactSecrets(res.text).slice(0, 160)}`); return true; } @@ -367,7 +371,7 @@ export function git(args, cwd = null) { function gitOrThrow(args, cwd, what) { const res = git(args, cwd); - if (!res.ok) throw new Error(`${what} 失敗:git ${args.join(" ")}\n ${res.stderr || res.stdout}`); + if (!res.ok) throw new Error(`${what} 失敗:git ${args.join(" ")}\n ${pl.redactSecrets(res.stderr || res.stdout)}`); return res; } @@ -478,6 +482,10 @@ const WIKI_PREFIX = { memory: "Memory", mindmap: "Mindmap", relations: "Relation * Gitea 的 wiki **只有根目錄的 .md 會變成頁面**(1.27 實測:子目錄頁面連結 404), * 所以低頻區的檔案要攤平成根層檔名(`memory/long-term/x.md` → `Memory-x.md`, * 網頁上顯示為「Memory x」),再用 `_paths.json` 記住原本的路徑,pull 時才還原得回去。 + * + * 對應 /jsc-shared:spec-gitea 的『Wiki 頁名轉義規則』第 3 點:本檔走 git clone/push + * 直接寫檔名,不反推 Gitea 的 title↔sub_url 內部轉義,改用 `_paths.json` manifest + * 自己記住「工作路徑 → 儲存檔名」的對應。 */ export function wikiName(rel) { if (!rel.includes("/")) return rel; @@ -718,7 +726,7 @@ export async function pushArea(slug, area, { message = "", code = null, owner = }); return { ok: true, changed: false, files: staged.length, area, code: theCode }; } - gitOrThrow(["commit", "-q", "-m", message || `sync(${area}): ${pl.nowIso()}`], dir, "git commit"); + gitOrThrow(["commit", "-q", "-m", message || `sync(${area}): ${pl.nowDisplay()}`], dir, "git commit"); let pushed = git(["push", "-q", "-u", "origin", "HEAD"], dir); let overwrote = null; if (!pushed.ok) { @@ -754,7 +762,7 @@ export async function pushArea(slug, area, { message = "", code = null, owner = } pushed = git(["push", "-q", "-u", "origin", "HEAD"], dir); } - if (!pushed.ok) return { ok: false, area, code: theCode, reason: pushed.stderr || pushed.stdout }; + if (!pushed.ok) return { ok: false, area, code: theCode, reason: pl.redactSecrets(pushed.stderr || pushed.stdout) }; updateSyncState(slug, (state) => { state.code = theCode; state.owner = theOwner; @@ -802,7 +810,7 @@ export async function pullArea(slug, area, { code = null, owner = null, force = clearStaleIndexLock(dir); const localChanged = new Set(statusPaths(dir)); const fetched = git(["fetch", "--quiet", "origin"], dir); - if (!fetched.ok) return { ok: false, area, reason: fetched.stderr || "fetch 失敗" }; + if (!fetched.ok) return { ok: false, area, reason: pl.redactSecrets(fetched.stderr) || "fetch 失敗" }; const head = git(["rev-parse", "--abbrev-ref", "HEAD"], dir).stdout || "main"; const remoteRef = `origin/${head}`; const exists = git(["rev-parse", "--verify", "--quiet", remoteRef], dir); @@ -815,7 +823,7 @@ export async function pullArea(slug, area, { code = null, owner = null, force = } git(["checkout", "--", "."], dir); const reset = git(["reset", "--hard", "--quiet", remoteRef], dir); - if (!reset.ok) return { ok: false, area, reason: reset.stderr }; + if (!reset.ok) return { ok: false, area, reason: pl.redactSecrets(reset.stderr) }; // 只寫回「遠端真的改過的」與「本機缺少的」。 // 不能無差別覆蓋:本機有較新但還沒 push 的內容時,那會把它蓋掉。 const root = pl.personaDir(slug); @@ -920,7 +928,7 @@ export async function importFromRemote(code, { owner = null, slug = null, force // 本機是空的,衝突判定沒有意義;覆蓋既有人格時使用者已經明講了 --force results[area] = await pullArea(target, area, { code, owner: theOwner, force: true }); } catch (err) { - results[area] = { ok: false, area, reason: String(err.message || err) }; + results[area] = { ok: false, area, reason: pl.redactSecrets(String(err.message || err)) }; } } if (!pl.personaExists(target)) { diff --git a/scripts/persona-lib.mjs b/scripts/persona-lib.mjs index 523dcc6..96448b5 100644 --- a/scripts/persona-lib.mjs +++ b/scripts/persona-lib.mjs @@ -129,7 +129,20 @@ export function setDefaultPersona(slug) { // 時間與檔案 IO // --------------------------------------------------------------------------- // -export const iso = (date = new Date()) => new Date(Math.floor(date.getTime() / 1000) * 1000).toISOString().replace(".000Z", "Z"); +const TAIPEI_OFFSET_MS = 8 * 60 * 60 * 1000; + +/** + * 產生帶 `+08:00`(Asia/Taipei)offset 的 ISO 字串,例如 `2026-08-11T10:10:13+08:00`。 + * 這是等值時區轉換:輸出代表跟輸入 `date` 完全相同的瞬間(epoch 不變), + * 只是把「顯示的牆上時間」換成台北時區,而不是原本 UTC 的 `Z`。 + * `parseIso()`/`new Date()` 都能正確解析回相同的 epoch,既有時間差運算不受影響。 + */ +export const iso = (date = new Date()) => { + const truncated = new Date(Math.floor(date.getTime() / 1000) * 1000); + const shifted = new Date(truncated.getTime() + TAIPEI_OFFSET_MS); + const pad = (n) => String(n).padStart(2, "0"); + return `${shifted.getUTCFullYear()}-${pad(shifted.getUTCMonth() + 1)}-${pad(shifted.getUTCDate())}T${pad(shifted.getUTCHours())}:${pad(shifted.getUTCMinutes())}:${pad(shifted.getUTCSeconds())}+08:00`; +}; export const nowIso = () => iso(new Date()); export function parseIso(value) { @@ -138,6 +151,26 @@ export function parseIso(value) { return Number.isNaN(dt.getTime()) ? null : dt; } +/** + * 給人看的顯示字串(不可被 `new Date()` 解析):Asia/Taipei 時區、`yyyy/MM/dd HH:mm:ss`。 + * 用途:任何要印給使用者看或寫進 log 訊息的地方,依 /jsc-shared:spec-time-log 規定的格式。 + */ +export function nowDisplay(date = new Date()) { + const shifted = new Date(Math.floor(date.getTime() / 1000) * 1000 + TAIPEI_OFFSET_MS); + const pad = (n) => String(n).padStart(2, "0"); + return `${shifted.getUTCFullYear()}/${pad(shifted.getUTCMonth() + 1)}/${pad(shifted.getUTCDate())} ${pad(shifted.getUTCHours())}:${pad(shifted.getUTCMinutes())}:${pad(shifted.getUTCSeconds())}`; +} + +/** + * 該時刻依台北曆日計算的 `yyyy-MM-dd` 日期字串,取代錯誤的 `nowIso().slice(0, 10)`(那是 UTC 當天)。 + * 例如 UTC `2026-08-10T16:30:00Z`(台北 `2026-08-11 00:30`)要回傳 `2026-08-11`。 + */ +export function todayTaipei(date = new Date()) { + const shifted = new Date(Math.floor(date.getTime() / 1000) * 1000 + TAIPEI_OFFSET_MS); + const pad = (n) => String(n).padStart(2, "0"); + return `${shifted.getUTCFullYear()}-${pad(shifted.getUTCMonth() + 1)}-${pad(shifted.getUTCDate())}`; +} + export function ageSeconds(value) { const dt = parseIso(value); if (!dt) return Infinity; @@ -686,7 +719,7 @@ export function loadDayMood(slug) { const data = readJson(dayMoodPath(slug), {}) ?? {}; const num = (v) => (Number.isFinite(Number(v)) ? Number(v) : 0); return { - date: typeof data.date === "string" ? data.date : nowIso().slice(0, 10), + date: typeof data.date === "string" ? data.date : todayTaipei(), valence: clamp(num(data.valence), -100, 100), arousal: clamp(num(data.arousal), 0, 100), samples: Math.max(0, Math.floor(num(data.samples))), @@ -701,7 +734,7 @@ export function loadDayMood(slug) { export function updateDayMood(slug, state = null) { return withFileLock(dayMoodPath(slug), () => { const day = loadDayMood(slug); - const today = nowIso().slice(0, 10); + const today = todayTaipei(); if (day.date !== today) { day.valence = Math.round(day.valence * DAY_MOOD_CARRY * 10) / 10; day.arousal = Math.round(day.arousal * DAY_MOOD_CARRY * 10) / 10; @@ -724,7 +757,7 @@ export function sleepDayMood(slug) { const day = loadDayMood(slug); day.valence = Math.round(day.valence * DAY_MOOD_CARRY * 10) / 10; day.arousal = Math.round(day.arousal * DAY_MOOD_CARRY * 10) / 10; - day.date = nowIso().slice(0, 10); + day.date = todayTaipei(); day.samples = 0; day.updated_at = nowIso(); writeJson(dayMoodPath(slug), day); @@ -2759,7 +2792,7 @@ export function writeLongTermMemory(slug, { const file = path.join(longTermDir(slug), `${name}.md`); let existing = {}; if (fs.existsSync(file)) [existing] = parseFrontMatter(fs.readFileSync(file, "utf8")); - const today = nowIso().slice(0, 10); + const today = todayTaipei(); const stamp = contextStamp(slug); // `gist` 明寫時,`detail` 沒給就是**沒有細節**——退回整份 body 會讓主旨之外 // 再抄一次全文,那是把「衰減先吃細節」這件事整個抵銷掉。 @@ -2844,7 +2877,7 @@ export function writeChapter(slug, { name, title = "", from = "", to = "", body "---", `name: ${key}`, `title: ${fm(title || existing.title || name).slice(0, 120)}`, - `from: ${fm(from) || existing.from || nowIso().slice(0, 10)}`, + `from: ${fm(from) || existing.from || todayTaipei()}`, // `to` 空著=還在這一段裡。所以沒有值就**不寫這一行**,不要寫成 `to: `。 ...((fm(to) || existing.to) ? [`to: ${fm(to) || existing.to}`] : []), `updated_at: ${nowIso()}`, @@ -2900,7 +2933,7 @@ export function currentChapter(slug) { } /** 收掉一段(寫上 `to`)。找不到那一段回 null。 */ -export function closeChapter(slug, name, at = nowIso().slice(0, 10)) { +export function closeChapter(slug, name, at = todayTaipei()) { const key = slugify(name); if (!fs.existsSync(chapterPath(slug, key))) return null; return writeChapter(slug, { name: key, to: at }); @@ -2927,7 +2960,7 @@ export function rebuildIndex(slug) { const lines = [ "# 長期記憶索引", "", - ``, + ``, "", ]; const sorted = [...entries].sort((a, b) => Number(b.salience || 0) - Number(a.salience || 0)); @@ -3244,7 +3277,7 @@ export function markShortTermReviewed(slug, { indexes = null, until = null, prom */ export function touchRecall(slug, names) { const wanted = new Set(names); - const today = nowIso().slice(0, 10); + const today = todayTaipei(); for (const meta of longTermEntries(slug)) { if (!wanted.has(meta._name)) continue; let text; @@ -6104,6 +6137,32 @@ export function injectSafeLine(text, limit = 0) { return limit > 0 ? one.slice(0, limit) : one; } +// --------------------------------------------------------------------------- // +// 機密遮蔽(port 自 doc/scripts/worklog/transcript.py 的 REDACT_PATTERNS, +// 為 /jsc-shared:spec-gitea『機密遮蔽實作』章節的來源依據;本檔僅共用定義, +// 套用地點見 A0-4:persona 側 Gitea 錯誤遮蔽) +// --------------------------------------------------------------------------- // + +const REDACT_PATTERNS = [ + [/[A-Za-z0-9_-]*:[A-Za-z0-9_-]{16,}@/g, "***@"], // URL 內嵌憑證 user:token@ + [/\b[0-9a-f]{40}\b/g, "***"], // Gitea 40 字元 token + [/\bgh[pousr]_[A-Za-z0-9_]{16,}\b/g, "***"], // GitHub token + [/\bsk-[A-Za-z0-9\-_]{16,}\b/g, "***"], // API key + [/\b(token|password|passwd|pwd|secret|api[_-]?key)\b\s*[:=]\s*\S+/gi, "$1=***"], + [/Authorization:\s*(token|bearer)\s+\S+/gi, "Authorization: $1 ***"], + [/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g, "***"], // Email + [/\b09\d{2}[-\s]?\d{3}[-\s]?\d{3}\b/g, "***"], // 台灣手機 + [/\b[A-Z][12]\d{8}\b/g, "***"], // 身分證字號 +]; + +/** 對文字依序套用全部機密遮蔽規則,回傳遮蔽後的結果。 */ +// 本檔遮蔽規則對應 shared/scripts/lib/redact-patterns.json(經 /jsc-shared:spec-gitea 收斂) +export function redactSecrets(text) { + let out = String(text ?? ""); + for (const [pattern, replacement] of REDACT_PATTERNS) out = out.replace(pattern, replacement); + return out; +} + export function identityFields(slug) { const fields = {}; let text; diff --git a/scripts/persona.mjs b/scripts/persona.mjs index 9a8ee28..bb6a1b8 100644 --- a/scripts/persona.mjs +++ b/scripts/persona.mjs @@ -479,7 +479,7 @@ commands.release = async ({ flags }) => { if (!flags["no-gitea"] && !gt.giteaProblem() && pl.personaExists(slug) && gt.personaCode(slug)) { for (const area of gt.AREA_KEYS) { try { - const res = await gt.pushArea(slug, area, { message: `release: 對話結束 ${pl.nowIso()}` }); + const res = await gt.pushArea(slug, area, { message: `release: 對話結束 ${pl.nowDisplay()}` }); if (res.ok && res.changed) say(` ↑ ${gt.AREAS[area].label}已推上 Gitea。`); else if (!res.ok && !res.skipped) say(` ⚠ ${gt.AREAS[area].label}推送失敗:${String(res.reason).slice(0, 120)}`); } catch (err) { @@ -594,7 +594,7 @@ commands.sleep = async ({ flags }) => { } else { for (const area of gt.AREA_KEYS) { try { - const res = await gt.pushArea(slug, area, { message: `sleep: 收尾 ${pl.nowIso()}` }); + const res = await gt.pushArea(slug, area, { message: `sleep: 收尾 ${pl.nowDisplay()}` }); 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)}`; @@ -1896,7 +1896,7 @@ commands.mindmap = ({ flags, positional }) => { if (!fs.existsSync(file) || flags.force) { pl.writeText(file, [ `%% 思維導圖(短期):${topic}`, - `%% created: ${pl.nowIso()} ttl: short-term(固化後請併入 semantic.mmd 並刪除)`, + `%% created: ${pl.nowDisplay()} ttl: short-term(固化後請併入 semantic.mmd 並刪除)`, "graph LR", ` trigger["觸發:${topic}"] --> obs["觀察"]`, ' obs --> infer["推論"]', @@ -2019,7 +2019,7 @@ commands.relation = ({ flags, positional }) => { if (!open.length) die(`跟 \`${node.name || key}\` 之間沒有還沒和好的事。`); const target = about ? open.filter((r) => String(r.about).includes(about)) : open; if (!target.length) die(`找不到「${about}」這一件(還沒和好的有:${open.map((r) => r.about).join("/")})。`); - for (const r of target) r.healed_at = pl.nowIso().slice(0, 10); + for (const r of target) r.healed_at = pl.todayTaipei(); node.updated_at = pl.nowIso(); pl.writeJson(pl.relationsJson(slug), data); pl.renderRelations(slug); @@ -2038,7 +2038,7 @@ commands.relation = ({ flags, positional }) => { return; } node.rifts.push({ - at: str(flags.at) || pl.nowIso().slice(0, 10), + at: str(flags.at) || pl.todayTaipei(), about, // 帶上長期記憶的名字,人格要細節自己 recall——不必等使用者提起那件事。 ...(str(flags.memory) ? { memory: str(flags.memory) } : {}), @@ -2079,7 +2079,7 @@ commands.relation = ({ flags, positional }) => { node.style[facet] = { value, except: str(flags.except) || null, // 形如 anger>=40:情緒成立時這條規則暫停 - since: str(flags.since) || pl.nowIso().slice(0, 10), + since: str(flags.since) || pl.todayTaipei(), }; ok(`\`${node.name || node.id}\`:${facet}→「${value}」${str(flags.except) ? `(例外 ${str(flags.except)})` : ""} 已記下。`); } @@ -2999,7 +2999,7 @@ commands.icon = async ({ flags, positional }) => { ? { url: str(flags["source-url"]), note: str(flags["source-note"]) || null, - date: str(flags["source-date"]) || pl.nowIso().slice(0, 10), + date: str(flags["source-date"]) || pl.todayTaipei(), } : null; const style = str(flags.style) || null; diff --git a/scripts/selftest.mjs b/scripts/selftest.mjs index 9cb31d1..2cfab25 100644 --- a/scripts/selftest.mjs +++ b/scripts/selftest.mjs @@ -3773,7 +3773,7 @@ console.log("\n㉝ 故事匯入(novel):機械的那半"); first_seen: "2024-06-14", last_seen: "2024-06-20", salience: 55 }]); cli(["novel", "write", "--session", S_N, "--work", W]); const one = pl.longTermEntries("novelist").find((m) => m._name === "兩年前的劇情"); - const today = pl.nowIso().slice(0, 10); + const today = pl.todayTaipei(); return one && one.happened_at === "2024-06-14" && one.happened_until === "2024-06-20" && one.first_seen === today && one.last_seen === today; })()); @@ -3833,6 +3833,47 @@ console.log("\n㉝ 故事匯入(novel):機械的那半"); cli(["release", "--session", S_N]); } +console.log("\n㉞ 時區工具(todayTaipei / iso / redactSecrets / migrate-tz 冪等性)"); +{ + // (a) 時區邊界:UTC 16:00 之後已經跨到台北的隔天,16:00 之前還是同一天。 + check("UTC 16:30 → 台北已經跨到隔天", + pl.todayTaipei(new Date("2026-08-10T16:30:00Z")) === "2026-08-11"); + check("UTC 15:59 → 台北還沒跨日", + pl.todayTaipei(new Date("2026-08-10T15:59:00Z")) === "2026-08-10"); + + // (b) iso() 是等值時區轉換,不是加 8 小時再蓋掉:epoch 要完全不變, + // 只是換了一套「牆上時間」的顯示格式(+08:00 而不是 Z)。 + check("iso() 換算前後是同一個瞬間(epoch 相等)", (() => { + const src = new Date("2026-08-11T02:10:13Z"); + return new Date(pl.iso(src)).getTime() === src.getTime(); + })()); + check("iso() 輸出以 +08:00 結尾(不是 Z)", + /\+08:00$/.test(pl.iso(new Date("2026-08-11T02:10:13Z"))), pl.iso(new Date("2026-08-11T02:10:13Z"))); + + // (c) redactSecrets:機密不能原樣留在輸出裡。 + check("redactSecrets 遮掉 40 字元 hex token 與 email", (() => { + const token = "a".repeat(40); + const email = "someone@example.com"; + const out = pl.redactSecrets(`token=${token} 聯絡信箱 ${email}`); + return !out.includes(token) && !out.includes(email) && out.includes("***"); + })()); + + // (d) migrate-tz.mjs 冪等性:已經是 +08:00 格式的值不能被誤判成「還沒換算、需要 + // 再轉一次」。migrate-tz.mjs 的 scanTree() 只對 Z_RE 命中的值產生 timestamp 改寫 + // (見該檔第 139~140 行、183 行附近),這裡直接複製那兩條正則來驗證判斷邏輯本身: + // 一個 +08:00 字串必須落在 TW_RE、絕不可以同時落在 Z_RE,否則會被重複轉換。 + const Z_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/; + const TW_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+08:00$/; + check("migrate-tz 判斷邏輯:+08:00 值不會被誤判成待轉換的 Z 值", (() => { + const already = "2026-08-11T02:10:13+08:00"; + return TW_RE.test(already) && !Z_RE.test(already); + })()); + check("migrate-tz 判斷邏輯:Z 值仍然會被正確認出待轉換", (() => { + const legacy = "2026-08-11T02:10:13Z"; + return Z_RE.test(legacy) && !TW_RE.test(legacy); + })()); +} + console.log(`\n${"=".repeat(60)}\n通過 ${passed} 項,失敗 ${failed} 項 → ${failed === 0 ? "全部通過 ✅" : "有測試失敗 ❌"}`); console.log(`(暫存倉庫留在 ${STORE},可自行刪除)`); process.exit(failed ? 1 : 0); diff --git a/skills/persona-anime/SKILL.md b/skills/persona-anime/SKILL.md index 1432a5f..0336196 100644 --- a/skills/persona-anime/SKILL.md +++ b/skills/persona-anime/SKILL.md @@ -7,7 +7,15 @@ description: 用「動漫作品名稱 + 角色名稱」快速建立人格:先 流程:**上網蒐集 → 映射成 OpenClaw 描述 → 建立人格 → 固化成基礎記憶**。 -**CLI**:`node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs"`(所有指令帶 `--session `) +**CLI**:路徑與呼叫慣例見 `/jsc-shared:spec-script-path`(本 skill 為 `node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs"`,所有指令帶 `--session `)。 + +--- + +## 共用規範(必要前置) + +先載入 `/jsc-shared:spec-preflight` 並依其流程處理;載入不到即代表 shared plugin 未安裝, +依該 spec 詢問使用者是否安裝 `https://gitea.jsc.idv.tw/plugins/shared.git`,不安裝則中斷本 skill。 +本 skill 需要的規範:spec-version-guard、spec-output、spec-execution、spec-time-log、spec-script-path --- @@ -237,4 +245,3 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" icon generate \ - 使用者若說「這裡不對」→ 直接改 `IDENTITY.md`/`SOUL.md`/對應的 canon 記憶檔,別另建人格。 - 之後在對話中發現的新設定,走一般記憶流程(短期 → 達條件 → 固化), 不要回頭改 `canon`;`canon` 只放原作設定。 -- 所有面向使用者的輸出使用**繁體中文(台灣用語)**、UTF-8 無亂碼。 diff --git a/skills/persona-chat/SKILL.md b/skills/persona-chat/SKILL.md index f20704f..a2f2f94 100644 --- a/skills/persona-chat/SKILL.md +++ b/skills/persona-chat/SKILL.md @@ -5,8 +5,7 @@ description: 載入一個人格並以人格化方式對話:取得該人格的 # 💬 persona-chat — 以人格對話 -**CLI**:`node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs"`(其他助理用本 plugin 的 `scripts/persona.mjs`) -**session**:所有指令帶 `--session `(見上下文中 `PERSONA_SESSION=`;帶錯會被 hook 拒絕)。 +**CLI**:路徑與呼叫慣例見 `/jsc-shared:spec-script-path`(本 skill 為 `node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs"`,所有指令帶 `--session `,值見上下文中 `PERSONA_SESSION=`;帶錯會被 hook 拒絕)。 **參考**:情緒模型 `reference/emotions.md`、語意分析 `reference/semantic.md`、 去 AI 味 `reference/anti-ai-voice.md`。 @@ -14,6 +13,14 @@ description: 載入一個人格並以人格化方式對話:取得該人格的 --- +## 共用規範(必要前置) + +先載入 `/jsc-shared:spec-preflight` 並依其流程處理;載入不到即代表 shared plugin 未安裝, +依該 spec 詢問使用者是否安裝 `https://gitea.jsc.idv.tw/plugins/shared.git`,不安裝則中斷本 skill。 +本 skill 需要的規範:spec-version-guard、spec-output、spec-execution、spec-time-log、spec-script-path + +--- + ## 1. 載入(由使用者呼叫,取得獨占鎖) **人格只有在使用者要你載入時才載入**(`/jsc-persona:persona-chat ` 或明確說「用 X 跟我聊」)。 @@ -415,4 +422,3 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" mindmap thread --persona - 想知道別的人格怎麼想 → `/jsc-persona:persona-invite`(透過 sub agent + 聊天室), **不可**去讀他的 `memory/`、`state/`。 - 不要手改 `state/lock.json`、`.runtime/`;鎖只由 CLI 維護。 -- 所有面向使用者的輸出使用**繁體中文(台灣用語)**、UTF-8 無亂碼。 diff --git a/skills/persona-create/SKILL.md b/skills/persona-create/SKILL.md index 64c4d0d..dddeb22 100644 --- a/skills/persona-create/SKILL.md +++ b/skills/persona-create/SKILL.md @@ -9,9 +9,19 @@ description: 建立一個新的 AI 人格(persona),並以「與 OpenClaw > 要建的是**動漫/漫畫/遊戲的既有角色**?改用 `/jsc-persona:persona-anime`——它會先上網蒐集該角色的公開設定,再自動填這些欄位並固化成基礎記憶。本 skill 用於**原創**人格。 -**CLI**:`node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs"` -(其他助理請改成本 plugin 目錄下的 `scripts/persona.mjs`;以下簡稱 `persona.mjs`) -**session**:所有指令都要帶 `--session `,值取自 `` 區塊注入的 `PERSONA_SESSION=`。 +**CLI**:路徑與呼叫慣例見 `/jsc-shared:spec-script-path`(本 skill 為 `node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs"`,以下簡稱 `persona.mjs`;所有指令都要帶 `--session `,值取自 `` 區塊注入的 `PERSONA_SESSION=`)。 + +## 共用規範(必要前置) + +先載入 `/jsc-shared:spec-preflight` 並依其流程處理;載入不到即代表 shared plugin 未安裝, +依該 spec 詢問使用者是否安裝 `https://gitea.jsc.idv.tw/plugins/shared.git`,不安裝則中斷本 skill。 +本 skill 需要的規範: +- spec-version-guard +- spec-output +- spec-execution +- spec-time-log +- spec-script-path +- spec-ask-user --- @@ -196,4 +206,3 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" icon generate --session /`,可用環境變數 `PERSONA_HOME` 改。 -- 所有輸出使用**繁體中文(台灣用語)**、UTF-8 無亂碼。 diff --git a/skills/persona-icon/SKILL.md b/skills/persona-icon/SKILL.md index 3b00588..06e006e 100644 --- a/skills/persona-icon/SKILL.md +++ b/skills/persona-icon/SKILL.md @@ -5,10 +5,16 @@ description: 產生或更新人格的形象圖(icon.svg + icon.png):先找 # 🎨 persona-icon — 依最新造型產生人格形象圖(有臉) -**CLI**:`node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs"`(帶 `--session `) +**CLI**:路徑與呼叫慣例見 `/jsc-shared:spec-script-path`(本 skill 為 `node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs"`,帶 `--session `)。 **時機**:**人格建立且 IDENTITY/SOUL 補齊之後**——形象綁在最終身分上。 **目標**:圖示要是**人物形象圖,看得到臉**;退而求其次才是抽象徽章。 +## 共用規範(必要前置) + +先載入 `/jsc-shared:spec-preflight` 並依其流程處理;載入不到即代表 shared plugin 未安裝, +依該 spec 詢問使用者是否安裝 `https://gitea.jsc.idv.tw/plugins/shared.git`,不安裝則中斷本 skill。 +本 skill 需要的規範:spec-version-guard、spec-output、spec-execution、spec-time-log、spec-script-path + **流程**:找高解析度官方圖 → 量測 → **去背** → 裁頭肩 → 合成 → 更新頭像與 Wiki。 > 找不到可用的官方圖時(沒有設定稿、只有場景截圖且去背不乾淨), @@ -139,4 +145,3 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" sync verify --session /transcript.jsonl`)。 被邀的人格在 **sub agent** 裡跑,只讀得到自己的人格資料,讀不到主持人格的任何檔案,反之亦然。 -**CLI**:`node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs"`(帶 `--session `) +**CLI**:路徑與呼叫慣例見 `/jsc-shared:spec-script-path`(本 skill 為 `node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs"`,帶 `--session `)。 + +--- + +## 共用規範(必要前置) + +先載入 `/jsc-shared:spec-preflight` 並依其流程處理;載入不到即代表 shared plugin 未安裝, +依該 spec 詢問使用者是否安裝 `https://gitea.jsc.idv.tw/plugins/shared.git`,不安裝則中斷本 skill。 +本 skill 需要的規範:`spec-version-guard`、`spec-output`、`spec-execution`、`spec-time-log`、`spec-script-path`、`spec-subagent` --- @@ -142,9 +150,10 @@ plugin_root=${CLAUDE_PLUGIN_ROOT} 最後一句不是對你說的、你也沒被點名時,不要發言,回傳「(本輪不發言)」。 ``` -guest 會自己讀 `IDENTITY.md`/`SOUL.md`/自己的記憶與情緒 → 讀聊天室 → `room post` 發言 → -需要記的事寫進**自己的 inbox**。它被 hook 綁死在自己的人格目錄,且對人格檔案唯讀。 -如果同場有多位 guest,就各自開一個 `persona-guest` sub agent,但 `room` 可以共用。 +派工細節依 `/jsc-shared:spec-subagent`(一個目標一個 sub agent、只讀不寫、回傳結構化結果); +persona-invite 特有的授權寫入範圍:guest 只能寫進**自己的 inbox**——讀 `IDENTITY.md`/`SOUL.md`/ +自己的記憶與情緒 → 讀聊天室 → `room post` 發言 → 需要記的事寫進 inbox,它被 hook 綁死在自己的人格目錄, +且對人格檔案唯讀。如果同場有多位 guest,就各自開一個 `persona-guest` sub agent,但 `room` 可以共用。 ### 4. 一來一回 @@ -213,4 +222,3 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" relation edge \ - **輕量邀請**:`invite --guest --theater off` 不進劇場模式——適合「人格自己想起某個很久沒接觸的人、 只想打聲招呼換一輪」的情況(`` 的「很久沒接觸的人」就是依據)。 這時仍然要**先問使用者一句**再邀:把整個畫面切走或插進一段對話,是他的決定不是你的。 -- 所有面向使用者的輸出使用**繁體中文(台灣用語)**、UTF-8 無亂碼。 diff --git a/skills/persona-memory/SKILL.md b/skills/persona-memory/SKILL.md index 1fb04d1..973cef3 100644 --- a/skills/persona-memory/SKILL.md +++ b/skills/persona-memory/SKILL.md @@ -5,11 +5,20 @@ description: 整理人格的記憶系統:把短期記憶固化為長期記憶 # 🧠 persona-memory — 記憶固化與心智圖維護 -**CLI**:`node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs"`(帶 `--session `) +**CLI**:路徑與呼叫慣例見 `/jsc-shared:spec-script-path`(本 skill 為 +`node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs"`,帶 `--session `)。 必須已 exclusive 載入該人格(guest 不能做這件事)。 --- +## 共用規範(必要前置) + +先載入 `/jsc-shared:spec-preflight` 並依其流程處理;載入不到即代表 shared plugin 未安裝, +依該 spec 詢問使用者是否安裝 `https://gitea.jsc.idv.tw/plugins/shared.git`,不安裝則中斷本 skill。 +本 skill 需要的規範:spec-version-guard、spec-output、spec-execution、spec-time-log、spec-script-path + +--- + ## 記憶架構 | 層 | 位置 | 特性 | @@ -246,4 +255,3 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" probe audit --session `) +**CLI**:路徑與呼叫慣例見 `/jsc-shared:spec-script-path`(本 skill 為 `node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs"`,帶 `--session `)。 + +--- + +## 共用規範(必要前置) + +先載入 `/jsc-shared:spec-preflight` 並依其流程處理;載入不到即代表 shared plugin 未安裝, +依該 spec 詢問使用者是否安裝 `https://gitea.jsc.idv.tw/plugins/shared.git`,不安裝則中斷本 skill。 +本 skill 需要的規範:spec-version-guard、spec-output、spec-execution、spec-time-log、spec-script-path --- @@ -175,5 +183,3 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" relation doctor --session `。 +**CLI**:路徑與呼叫慣例見 `/jsc-shared:spec-script-path`(本 skill 為 `node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs"`,所有指令帶 `--session `)。 + +--- + +## 共用規範(必要前置) + +先載入 `/jsc-shared:spec-preflight` 並依其流程處理;載入不到即代表 shared plugin 未安裝, +依該 spec 詢問使用者是否安裝 `https://gitea.jsc.idv.tw/plugins/shared.git`,不安裝則中斷本 skill。 +本 skill 需要的規範:spec-version-guard、spec-output、spec-execution、spec-time-log、spec-script-path、spec-subagent、spec-gitea、spec-git-safety --- @@ -59,17 +66,20 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" sleep --session  session= plugin_root=` -那個 sub agent 就是**那個人格本人**在睡:它自己判斷要記住什麼,寫的是自己的檔案。 +那個 sub agent 就是**那個人格本人**在睡——它被 pin 在自己的人格上,只能動自己的資料: +它自己判斷要記住什麼,寫的是自己的檔案。 它手上是 5 分鐘的 sleeper 租約(不是 exclusive 鎖),所以它的每個指令都會帶 `--as-sleeper`—— 那個旗標只有 persona-sleeper 型的 sub agent 能用,主人格自己帶會被 hook 擋下。 帶了它,讀(`brief`/`show`/`recall`)與寫(`consolidate` 等)都算它自己的資料; **回報說「判斷式那半被隔離擋下」十之八九就是漏了這個旗標**,不是它沒有權限。 -主人格全程不會讀到對方的任何資料——**你只會拿到一份 JSON**: +主人格全程不會讀到對方的任何資料——**回傳值只能是 `sleep --json` 的原文**: ```json { "persona": "YUI-01", "ok": true, "slept_at": "...", "steps": [ ... ], "sync": { ... }, "kept_lock": true } @@ -122,5 +132,3 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" sleep --session `) +**CLI**:路徑與呼叫慣例見 `/jsc-shared:spec-script-path`(本 skill 為 `node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs"`,一律帶 `--session `)。 `persona-anime` 給的是「查得到的公開設定」——維基、Fandom、名言彙整。 這支給的是**原文**:他在第幾層看到什麼、當下說了哪一句、那件事之後他變成什麼樣。 @@ -13,6 +13,14 @@ description: 把小說(或漫畫、劇本、遊戲文本)匯入成一個既 --- +## 共用規範(必要前置) + +先載入 `/jsc-shared:spec-preflight` 並依其流程處理;載入不到即代表 shared plugin 未安裝, +依該 spec 詢問使用者是否安裝 `https://gitea.jsc.idv.tw/plugins/shared.git`,不安裝則中斷本 skill。 +本 skill 需要的規範:spec-version-guard、spec-output、spec-execution、spec-time-log、spec-script-path + +--- + ## 三條界線(後面每一步都受它們約束) 1. **視角**:人格不在場、也沒人告訴他的事,最多進 `canon` 或別人的關係欄, diff --git a/skills/persona-sync/SKILL.md b/skills/persona-sync/SKILL.md index c27ce91..aa0a37a 100644 --- a/skills/persona-sync/SKILL.md +++ b/skills/persona-sync/SKILL.md @@ -5,7 +5,15 @@ description: 人格編號與 Gitea 儲存:指派人格編號(英文名全大 # 🔗 persona-sync — 人格編號與 Gitea 儲存 -**CLI**:`node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs"`(一律帶 `--session `) +**CLI**:路徑與呼叫慣例見 `/jsc-shared:spec-script-path`(本 skill 為 `node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs"`,一律帶 `--session `)。 + +--- + +## 共用規範(必要前置) + +先載入 `/jsc-shared:spec-preflight` 並依其流程處理;載入不到即代表 shared plugin 未安裝, +依該 spec 詢問使用者是否安裝 `https://gitea.jsc.idv.tw/plugins/shared.git`,不安裝則中斷本 skill。 +本 skill 需要的規範:spec-version-guard、spec-output、spec-execution、spec-time-log、spec-script-path、spec-gitea、spec-git-safety --- @@ -152,4 +160,3 @@ sleeper 連自己的人格目錄都不能用 shell 寫,那條路本來就是 唯一的例外是 `clone`:它只會**新增本機還沒有的人格**,讀不到任何既有人格的資料,所以不受此限。 - guest(`persona-guest` sub agent)不能同步,它對人格檔案唯讀。 - `.sync/` 是兩區的 git clone 快取,**不要手動編輯**;砍掉它不會掉資料,下次 push 會重新 clone。 -- 所有面向使用者的輸出使用**繁體中文(台灣用語)**、UTF-8 無亂碼。 diff --git a/skills/persona-therapist/SKILL.md b/skills/persona-therapist/SKILL.md index 310ca0d..0c87e7b 100644 --- a/skills/persona-therapist/SKILL.md +++ b/skills/persona-therapist/SKILL.md @@ -14,6 +14,14 @@ description: 以專業心理醫生(溝通諮商)的第三方身分診斷一 --- +## 共用規範(必要前置) + +先載入 `/jsc-shared:spec-preflight` 並依其流程處理;載入不到即代表 shared plugin 未安裝, +依該 spec 詢問使用者是否安裝 `https://gitea.jsc.idv.tw/plugins/shared.git`,不安裝則中斷本 skill。 +本 skill 需要的規範:spec-version-guard、spec-output、spec-execution、spec-time-log + +--- + ## 一、先收齊四樣 雙方各一張卡(缺哪一項就補問,**只問一輪**,剩下的用推定值並在輸出裡註明「推定」): @@ -153,5 +161,3 @@ description: 以專業心理醫生(溝通諮商)的第三方身分診斷一 - 診斷結果**不自動寫回**。使用者明確要求記下來,才走: 對方要求過的稱呼/禁忌 → `relation style`;這次的領悟 → `persona-memory` 固化。 - 兩邊都是真人、跟人格無關時,完全不碰 CLI,純第三方診療。 - -所有面向使用者的輸出使用**繁體中文(台灣用語)**、UTF-8 無亂碼。 diff --git a/skills/persona-transfer/SKILL.md b/skills/persona-transfer/SKILL.md index 5da2c0a..7e32294 100644 --- a/skills/persona-transfer/SKILL.md +++ b/skills/persona-transfer/SKILL.md @@ -5,7 +5,7 @@ description: 匯出與匯入人格:把一個人格(身分、靈魂、十二 # 📦 persona-transfer — 人格搬家(匯出/匯入) -**CLI**:`node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs"`(一律帶 `--session `) +**CLI**:路徑與呼叫慣例見 `/jsc-shared:spec-script-path`(本 skill 為 `node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs"`,一律帶 `--session `)。 bundle 是**單一 JSON 檔**(可 gzip),不依賴任何外部工具,複製到隨身碟、貼進 git、 傳給另一台機器都可以。 @@ -16,6 +16,14 @@ bundle 是**單一 JSON 檔**(可 gzip),不依賴任何外部工具,複 --- +## 共用規範(必要前置) + +先載入 `/jsc-shared:spec-preflight` 並依其流程處理;載入不到即代表 shared plugin 未安裝, +依該 spec 詢問使用者是否安裝 `https://gitea.jsc.idv.tw/plugins/shared.git`,不安裝則中斷本 skill。 +本 skill 需要的規範:`spec-version-guard`、`spec-output`、`spec-execution`、`spec-time-log`、`spec-script-path` + +--- + ## bundle 帶走什麼、不帶什麼 | 帶走 | 不帶 | @@ -108,4 +116,3 @@ checksum 對不上會被擋下;要硬吃得加 `--force`,並**主動告訴 - 一次一個人格。要搬多個就跑多次(每次都要先 `release` 再 `load` 下一個)。 - bundle 內的相對路徑會被驗證,`../` 之類的逃逸路徑一律拒收。 - 匯入不會帶進鎖:新機器上的人格是空閒的,等使用者自己載入。 -- 所有面向使用者的輸出使用**繁體中文(台灣用語)**、UTF-8 無亂碼。