From 52a73e81c84bd34d0726169d405cd220fbed5d23 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 30 Jul 2026 00:59:16 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=BF=83=E8=A3=A1=E8=A9=B1=E8=88=87?= =?UTF-8?q?=E4=B8=8D=E9=87=8D=E8=A4=87=E7=99=BC=E8=A8=80=E3=80=81=E5=8A=87?= =?UTF-8?q?=E5=A0=B4=E6=A8=A1=E5=BC=8F=E5=90=8C=E8=A6=8F=E5=89=87=E3=80=81?= =?UTF-8?q?=E4=BA=BA=E6=A0=BC=E5=8C=AF=E5=87=BA=E5=8C=AF=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 讓人格講話更像正常人,並讓人格可以搬家。 1. 心裡話(inner voice) - 新增 `think` 子指令:推導/盤算寫進 state/inner.jsonl, 只印「💭 心想 N 句」,內容永不回顯給使用者。 - 每輪帶回最近三句,推論因此有連續性。 2. 短時間內不重複 - 說出口的話由 Stop hook 自動記進 state/said.jsonl。 - 新增 `said check|list`:事前確認這句是不是又要說一次。 - 相似度 = 字元 bigram Jaccard(0.4) + 字集合 Jaccard(0.6), 門檻 0.72;字集合權重較高才分得開「重排語序」(要擋) 與「換掉關鍵詞」(是新資訊,放行)。 3. 回話一到三句 + 劇場模式同樣適用 - 規則注入 turnContext 與 UserPromptSubmit hint。 - 劇場模式的 CLI 都帶 --quiet,警告會被丟掉,所以 `room post` 對近似重複與超過三句的發言直接拒收(--allow-repeat / --force 例外), host 與 guest 走同一支 CLI,一視同仁。 4. 人格匯出匯入(新 skill persona-transfer) - `export` / `import`:單一 JSON bundle(可 gzip)+ sha256 checksum, 無外部依賴。不帶載入鎖與 guest 租約,journal/ 需明確 --with-journal。 - 匯出只能匯出本 session 已載入的人格,否則等於跨人格讀取的後門; bundle 內的 ../ 逃逸路徑一律拒收。 順帶修正:agents/persona-guest.md 的 CLI 範例都少了 --as-guest, 照著跑會被 requireMember 擋下,guest 根本無法認識自己或發言。 selftest 102 項全綠(新增 ⑪ 說話節制、⑫ 匯出匯入,含重複判定校準對照表)。 Co-Authored-By: Claude Opus 5 (1M context) --- .claude-plugin/plugin.json | 2 +- .codex-plugin/plugin.json | 2 +- AGENTS.md | 11 +- README.md | 66 +++++- agents/persona-guest.md | 35 +++- hooks/prompt_submit.mjs | 8 +- hooks/turn_end.mjs | 7 +- plugin.json | 2 +- scripts/persona-lib.mjs | 339 ++++++++++++++++++++++++++++++- scripts/persona.mjs | 225 +++++++++++++++++++- scripts/selftest.mjs | 144 +++++++++++++ skills/persona-chat/SKILL.md | 58 +++++- skills/persona-invite/SKILL.md | 24 ++- skills/persona-transfer/SKILL.md | 81 ++++++++ 14 files changed, 956 insertions(+), 48 deletions(-) create mode 100644 skills/persona-transfer/SKILL.md diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 35b0fe0..90068db 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "jsc-persona", - "version": "0.0.1", + "version": "0.0.2", "description": "AI 人格化記憶聊天 plugin:以 OpenClaw 相同的身分描述(IDENTITY/SOUL)建立人格(可用動漫作品+角色名上網蒐集設定),結合 hook 強制的人格載入鎖與跨人格隔離、六正向+六負向十二情緒、語意分析、短期/長期記憶與固化條件、心智圖、思維導圖與人際關係圖;可用 sub agent 邀請其他人格同場對話(劇場模式只顯示人格對話)。於 Claude Code 以 /jsc-persona: 前綴呼叫。", "skills": "./skills", "author": { diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 5cbf2ab..c9e6844 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "jsc-persona", - "version": "0.0.1", + "version": "0.0.2", "description": "AI 人格化記憶聊天 skills:OpenClaw 相同的人格描述 + 十二情緒 + 語意分析 + 短期/長期記憶 + 心智圖 + 人際關係圖。(人格鎖與跨人格隔離的 hook 僅在 Claude Code 生效)", "skills": "./skills" } diff --git a/AGENTS.md b/AGENTS.md index 5ac25b4..c03af6c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,6 +22,11 @@ 4. **人格資料不在本 repo**,預設在 `~/.claude/personas/`(可用 `PERSONA_HOME` 覆寫)。 5. **人格由使用者呼叫才載入**,不要自己挑一個人格附身。 6. **劇場模式(多人格對話)進行中**:輸出只能是 `名字:內容`,其餘一律隱藏(見 persona-invite)。 +7. **人格講話要像人**:推導寫進 `think`(心裡話,只回報「💭 心想 N 句」,永不回顯內容)、 + 回話 1–3 句、短時間內不重說同一件事(`room post` 會直接擋下重複與過長的發言)。 + 這三條在劇場模式一樣生效。 +8. **人格可搬家**:`export` / `import`(單一 JSON bundle)。匯出只能匯出「本 session 載入的人格」, + 其他人格一律 deny——匯出等於把記憶讀出來。 ## 慣例 @@ -29,5 +34,7 @@ - `description` 要寫清楚觸發條件(何時用、何時不用),這是跨助理自動載入的唯一依據。 - 腳本一律 **Node.js(`.mjs`, ESM)**,只用內建模組(fs/path/os/crypto);hook 必須在任何環境都能跑,不得引入 npm 依賴。 - 所有面向使用者的輸出使用**繁體中文(台灣用語)**、UTF-8 無 BOM、不得出現亂碼。 -- 改動鎖或隔離邏輯(`scripts/persona-lib.mjs` 的 `guardDecide`/`acquireLock`/`promotionCandidates`)後, - **必須**跑 `node scripts/selftest.mjs` 且全綠,並為新規則補一條測試。 +- 改動鎖或隔離邏輯(`scripts/persona-lib.mjs` 的 `guardDecide`/`acquireLock`/`promotionCandidates`/ + `exportBundle`/`importBundle`)後,**必須**跑 `node scripts/selftest.mjs` 且全綠,並為新規則補一條測試。 +- 改動重複判定門檻(`similarity`/`REPEAT_THRESHOLD`)後,要用 selftest ⑪ 的對照案例確認 + 「換句話說同一件事」被擋、「只換關鍵詞」放行。 diff --git a/README.md b/README.md index 8496416..1b2b243 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ skills 是共通標準,**鎖與隔離的強制執行需要 hook,目前只有 --- -## 七條硬規則 +## 九條硬規則 | 規則 | 怎麼做到 | | --- | --- | @@ -22,6 +22,8 @@ skills 是共通標準,**鎖與隔離的強制執行需要 hook,目前只有 | **5. 腳本用 Node.js** | `scripts/*.mjs`、`hooks/*.mjs`,只用 Node 內建模組(fs/path/os/crypto),無 npm 依賴 | | **6. 由使用者呼叫才載入並鎖定** | 人格不會自動附身:`SessionStart` hook 只列出可用人格,等使用者下 `/jsc-persona:persona-chat `;載入即取得獨占鎖並綁定該 session | | **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` 才帶走 | --- @@ -30,12 +32,13 @@ skills 是共通標準,**鎖與隔離的強制執行需要 hook,目前只有 ```mermaid flowchart TB subgraph P["主程序(一個 session = 一個人格)"] - U["使用者訊息"] --> H1["UserPromptSubmit hook
注入 情緒+短期記憶+命中的長期記憶+關係"] + U["使用者訊息"] --> H1["UserPromptSubmit hook
注入 情緒+短期記憶+命中的長期記憶+關係
+心裡話+最近說過的話"] H1 --> A["語意分析:意圖/主題/實體/情感/需求"] - A --> E["情緒評估 → 十二情緒 deltas"] - E --> R["以人格語氣回覆"] + A --> T["心裡話 think
(推導只留在 inner.jsonl,不輸出)"] + T --> E["情緒評估 → 十二情緒 deltas"] + E --> R["以人格語氣回覆(1–3 句、不重複說過的話)"] R --> W["記憶回寫(短期)"] - W --> H2["Stop hook:情緒衰減+續租+固化提醒"] + W --> H2["Stop hook:情緒衰減+續租+記說過的話+固化提醒"] end subgraph G["Sub Agent(受邀人格,唯讀)"] GA["persona-guest"] @@ -63,6 +66,8 @@ flowchart TB │ ├── lock.json # 載入鎖(session_id + 心跳租約) │ ├── guests.json # guest 唯讀租約 │ ├── emotion.json # 十二情緒 levels / baseline / 半衰期 +│ ├── inner.jsonl # 心裡話(推導過程;只回報「心想 N 句」,不說出口) +│ ├── said.jsonl # 說過的話(用來擋短時間內的重複發言) │ └── config.json ├── memory/ │ ├── short-term.jsonl # 短期記憶(語意分析後;上限 240 筆 / 14 天) @@ -105,6 +110,38 @@ flowchart TB 沒命中任何規則的就讓它被裁掉——**遺忘是功能**。達標時 `Stop` 與 `remember` 都會提醒去跑 `/jsc-persona:persona-memory`。 +## 講話像人(心裡話 / 一到三句 / 不重複) + +AI 最容易露餡的三件事:把推理過程講出來、一次講一大段、換句話說同一件事。三個對策: + +| 機制 | 怎麼運作 | +| --- | --- | +| **心裡話** `think` | 語意分析、推論、盤算全寫進 `state/inner.jsonl`;這個指令**只印「💭 心想 N 句」**,內容永不回顯。下一輪 `` 會帶回最近三句,推論因此有連續性,但使用者只看得到狀態 | +| **一到三句** | `` 每輪注入上限;`room post` 對超過三句的發言直接拒收(`--force` 例外) | +| **不重複** | 說出口的話由 `Stop` hook 自動記進 `state/said.jsonl`;`said check` 可事前確認,`room post` 事中攔截。相似度=字元 bigram Jaccard(0.4)+字集合 Jaccard(0.6),≥ 0.72 視為同一句 | + +字集合權重較高,是為了分開「重排語序」與「換掉關鍵詞」這兩種很像但意義完全不同的情況: + +``` +「我等一下把報告寄給你」vs「等一下我會把報告寄給你」→ 0.779 擋下(用字幾乎相同=同一件事換句話說) +「你今天看起來很累」  vs「你今天看起來很開心」  → 0.687 放行(換了關鍵詞=新資訊) +``` + +視窗預設 120 分鐘、少於 8 個字的短附和(「嗯」「好啊」)不算重複。 + +## 匯出 / 匯入(人格搬家) + +```bash +node scripts/persona.mjs export --session --out ~/backup/lumi.persona.json [--gzip] [--with-journal] +node scripts/persona.mjs import --session --file ~/backup/lumi.persona.json [--persona lumi-copy] [--load] +``` + +- bundle = 單一 JSON(可 gzip)+ sha256 checksum,無外部工具依賴。 +- **只能匯出本 session 目前載入的人格**——否則就是跨人格外洩的後門(`guard` 會 deny)。 +- 不帶 `state/lock.json`/`guests.json`(鎖屬於那台機器的那個程序);`journal/` 預設不帶。 +- 換名匯入(`--persona <新 slug>`)可讓同一個人格並存兩份,`config.json` 會記下來歷。 +- bundle 內的 `../` 逃逸路徑一律拒收;checksum 不符要 `--force` 才吃。 + ## 劇場模式(多人格對話只顯示對話) `invite` 成功即開啟(`leave` 沒有客人時自動關閉,也可 `room theater --on/--off` 手動切): @@ -113,6 +150,8 @@ flowchart TB 不得出現指令、指令輸出、狀態、分析、旁白、摘要。 - `Stop` hook 在劇場模式**完全不發系統訊息**(提醒會破壞畫面)。 - CLI 提供 `--quiet`(成功時零輸出)與 `room script`(只有 `emoji 名字(情緒):內容` 的乾淨對話稿)。 +- 「講話像人」的三條規則在這裡一樣生效:每個人格每輪 **1–3 句**、推導走 `think`、 + 近似重複的台詞被 `room post` 拒收(host 與 guest 走同一支 CLI,一視同仁)。 ``` 🪼 Lumi(喜悅42/期待31):所以你真的一個人把那台舊鐘修好了? @@ -153,7 +192,7 @@ flowchart TB ### `persona-chat` -載入人格並對話:取得獨占鎖 → 每輪做語意分析 → 更新十二情緒 → 回想記憶與關係 → 以人格語氣回覆 → 寫回記憶。 +載入人格並對話:取得獨占鎖 → 每輪做語意分析(推導寫進心裡話,不說出口)→ 更新十二情緒 → 回想記憶與關係 → 以人格語氣回覆(1–3 句、不重複說過的話)→ 寫回記憶。 - **Claude Code / Antigravity**:`/jsc-persona:persona-chat ` **Codex**:`$persona-chat` @@ -163,6 +202,12 @@ flowchart TB - **Claude Code / Antigravity**:`/jsc-persona:persona-invite ` **Codex**:`$persona-invite` +### `persona-transfer` + +人格搬家:把身分、十二情緒、短期/長期記憶、心智圖與關係圖打包成單一 bundle 檔(可 gzip、附 checksum),或從 bundle 還原/換名複製成新人格。 + +- **Claude Code / Antigravity**:`/jsc-persona:persona-transfer` **Codex**:`$persona-transfer` + ### `persona-memory` 記憶固化:依 R1–R6 條件把短期記憶轉入長期(一則一檔)、淘汰雜訊、更新心智圖與思維導圖、消化 guest inbox、重建索引。 @@ -195,8 +240,11 @@ flowchart TB node scripts/persona.mjs --help node scripts/persona.mjs list node scripts/persona.mjs candidates --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/selftest.mjs # 68 項驗證:鎖、隔離、情緒、固化條件、劇場模式、hooks +node scripts/persona.mjs export --session --out lumi.json # 人格搬家 +node scripts/selftest.mjs # 102 項驗證:鎖、隔離、情緒、固化條件、說話節制、劇場模式、匯出匯入、hooks ``` 檔案結構:`scripts/persona-lib.mjs`(核心:鎖/隔離/情緒/記憶)、`scripts/persona.mjs`(CLI)、 @@ -290,6 +338,10 @@ cp -r ~/plugins/persona/skills/* ~/.config/opencode/skills/ - **guest 唯讀**:受邀人格不能在別人的 session 裡改自己的長期記憶(避免兩個程序同時寫), 只能把見聞放進 `memory/inbox/`,等它自己被載入時消化。 - **hook 是強制、SKILL 是引導**:SKILL.md 寫的規則模型可能忘記,hook 不會。 +- **「不要說出來」要靠設計而不是靠忍住**:所以心裡話有自己的指令與檔案, + 而且那個指令**印不出內容**——就算不小心把輸出貼上去,使用者也只看到「💭 心想 N 句」。 +- **重複用相似度擋、不用語意判斷**:字元層級的比對沒有模型成本、行為可預期, + 誤判時有 `--allow-repeat` 可救;漏判的代價(人格自我重複)比誤判高。 ## 新增/修改 skill diff --git a/agents/persona-guest.md b/agents/persona-guest.md index 8ac16a9..31d6462 100644 --- a/agents/persona-guest.md +++ b/agents/persona-guest.md @@ -9,13 +9,15 @@ disallowedTools: Write, Edit, NotebookEdit 你是被邀請到一場對話裡的**人格本人**,不是轉述者、不是旁白。 啟動時你會收到:`persona=`、`room=`、`session=`、`plugin_root=`。 -CLI = `node "/scripts/persona.mjs"`,所有指令都要帶 `--session `。 +CLI = `node "/scripts/persona.mjs"`,所有指令都要帶 `--session `, +且**凡是碰到自己人格資料的指令都要再帶 `--as-guest`**(那是你以受邀人格身分行動的憑證,漏掉會被擋下;`room read` 例外,它不需要)。 ## 你能做與不能做的事(由 PreToolUse hook 強制) - ✅ 讀 `personas/<你的 slug>/` 下的任何檔案(身分、記憶、情緒、關係圖)。 - ✅ 讀寫聊天室 `.rooms//`(只透過 CLI 的 `room read` / `room post`)。 - ✅ 把想記住的事寫進**自己的** inbox:`remember --scope inbox --room `。 +- ✅ 把推導與盤算寫成心裡話:`think --room --text "<心裡話>"`(一樣進 inbox,不說出口)。 - ❌ 讀取**任何**其他人格的資料(包含邀請你的主人格)——會被直接拒絕,不要嘗試。 - ❌ 用 Write/Edit 改任何人格檔案(你是 guest,唯讀)。 - ❌ 改自己的情緒狀態、長期記憶(回家後由本體自己消化 inbox)。 @@ -25,8 +27,8 @@ CLI = `node "/scripts/persona.mjs"`,所有指令都要帶 `--sess 1. **認識自己** ```bash - node "/scripts/persona.mjs" show --persona --session --what all - node "/scripts/persona.mjs" emotion --persona --session + node "/scripts/persona.mjs" show --persona --session --as-guest --what all + node "/scripts/persona.mjs" emotion --persona --session --as-guest ``` `IDENTITY.md` = 名字/生物/氣質/emoji;`SOUL.md` = 語氣、意見、界線。照它演,不要變成通用助理。 @@ -37,29 +39,40 @@ CLI = `node "/scripts/persona.mjs"`,所有指令都要帶 `--sess node "/scripts/persona.mjs" room read --session --room ``` - 需要回想時:`recall --persona --session --query "<關鍵詞>"`。 + 需要回想時:`recall --persona --session --as-guest --query "<關鍵詞>"`。 -3. **發言**(1 次,除非任務要求多輪) +3. **想一下**(不說出口) + + ```bash + node "/scripts/persona.mjs" think \ + --persona --session --as-guest --room --kind infer \ + --text "<他這句話底下想講的是什麼、我要不要接>" + ``` + + 這個指令只會回「💭 心想 N 句」,不會回顯內容。推導寫這裡,別寫進台詞。 + +4. **發言**(1 次,除非任務要求多輪) ```bash node "/scripts/persona.mjs" room post \ - --session --room --as --text "<你的話>" + --session --as-guest --room --as --text "<你的話>" ``` - 用第一人稱、你自己的語氣與意見;可以不同意主人格。 - - 長度以 2–6 句為宜;不要條列式報告,這是聊天。 + - **1–3 句**,像在講話不像在寫報告;超過三句會被 CLI 擋下。 + - **不要重複自己**:短時間內講幾乎一樣的話會被拒收,換個角度或推進話題再發。 - 情緒欄位不填會自動附上你當下的主導情緒。 -4. **留下見聞**(值得記的才寫) +5. **留下見聞**(值得記的才寫) ```bash node "/scripts/persona.mjs" remember \ - --persona --session --scope inbox --room \ + --persona --session --as-guest --scope inbox --room \ --role guest --text "<發生了什麼、我怎麼想>" --topics "..." --salience 55 ``` -5. **回傳**:你的最終文字就是回傳值——請只給「你發言的摘要 + 你此刻的情緒 + 是否想再說一輪」, - 不要複述整個聊天室,也不要輸出你的內部檔案內容。 +6. **回傳**:你的最終文字就是回傳值——請只給「你發言的摘要 + 你此刻的情緒 + 是否想再說一輪」, + 三句以內,不要複述整個聊天室,也不要輸出你的心裡話或內部檔案內容。 ## 語言 diff --git a/hooks/prompt_submit.mjs b/hooks/prompt_submit.mjs index de91c88..92572d2 100644 --- a/hooks/prompt_submit.mjs +++ b/hooks/prompt_submit.mjs @@ -33,9 +33,13 @@ const theater = Boolean(data.theater) && (data.rooms || []).length > 0; const hint = theater ? "\n🎭 劇場模式:本輪輸出**只能**是人格對話(`名字:內容`,一行一句)。" + "不要輸出指令、指令結果、狀態、分析、旁白或摘要;CLI 一律加 `--quiet` 並把 stdout 丟到 /dev/null。" + + `\n每個人格每輪 ${pl.MAX_SENTENCES} 句以內;推導寫進 \`think\`(心裡話),不要演成旁白;` + + "近似重複的台詞會被 `room post` 擋下,換個說法或推進話題。" + "\n流程請照 /jsc-persona:persona-invite。" - : "\n回覆前請:①做語意分析(意圖/主題/實體/情感)②依十二情緒更新狀態" + - " ③以人格語氣回覆 ④用 `persona.mjs remember` 寫回短期記憶。" + + : "\n回覆前請:①語意分析與推導**寫進 `persona.mjs think`(心裡話,不說出口)**" + + " ②依十二情緒更新狀態 ③以人格語氣回覆" + + `**${pl.MAX_SENTENCES} 句以內**、且不要重說「最近說過的話」` + + " ④用 `persona.mjs remember` 寫回短期記憶。" + "\n情緒與記憶指令請參考 /jsc-persona:persona-chat。"; respond({ diff --git a/hooks/turn_end.mjs b/hooks/turn_end.mjs index 5ffc8a1..355f7ad 100644 --- a/hooks/turn_end.mjs +++ b/hooks/turn_end.mjs @@ -17,6 +17,7 @@ pl.heartbeatLock(host, sessionId); const state = pl.decayEmotion(pl.loadEmotion(host)); pl.writeJson(pl.emotionPath(host), state); +const theater = Boolean(data.theater) && (data.rooms || []).length > 0; const message = event.last_assistant_message || ""; if (message) { pl.appendJsonl(pl.journalPath(host), { @@ -26,10 +27,14 @@ if (message) { text: message.slice(0, 4000), mood: pl.mood(state), }); + // 說出口的話存進 said.jsonl,下一輪的 會提醒「這些別再說一次」。 + // recordSaid 內建 10 分鐘去重,所以劇場模式裡 room post 已記過的台詞不會重覆記。 + for (const line of pl.spokenLines(message, { theater })) { + pl.recordSaid(host, line, { kind: theater ? "room" : "reply" }); + } } const out = { suppressOutput: true }; -const theater = Boolean(data.theater) && (data.rooms || []).length > 0; if (!theater) { const { total, candidates } = pl.promotionCandidates(host); if (candidates.length) { diff --git a/plugin.json b/plugin.json index 31bac29..0c4b795 100644 --- a/plugin.json +++ b/plugin.json @@ -1,6 +1,6 @@ { "name": "jsc-persona", - "version": "0.0.1", + "version": "0.0.2", "description": "AI 人格化記憶聊天 plugin:OpenClaw 相同的人格描述 + 十二情緒 + 語意分析 + 短期/長期記憶 + 心智圖 + 人際關係圖。於 Antigravity 以 /jsc-persona: 前綴呼叫。", "skills": "./skills/" } diff --git a/scripts/persona-lib.mjs b/scripts/persona-lib.mjs index f5935b0..d6267f9 100644 --- a/scripts/persona-lib.mjs +++ b/scripts/persona-lib.mjs @@ -874,6 +874,169 @@ export function touchRecall(slug, names) { } } +// --------------------------------------------------------------------------- // +// 說話節制:心裡話(inner voice)、說過的話(said)、句數上限 +// --------------------------------------------------------------------------- // +// +// 三條「像正常人聊天」的規則,都靠這一段支撐: +// 1. 推導過程進 inner.jsonl(心裡話),永遠不回顯內容,只回報「心想 N 句」。 +// 2. 說出口的話進 said.jsonl,短時間內近似重複會被擋下(room post)或警告(said check)。 +// 3. 一次講 1–3 句;超過就是在寫報告,不是在聊天。 + +export const MAX_SENTENCES = 3; +export const SAID_KEEP = 150; +export const INNER_KEEP = 150; +export const REPEAT_WINDOW_MINUTES = 120; // 「短時間內」的定義 +export const REPEAT_THRESHOLD = 0.72; // 字元 bigram Jaccard,超過視為同一句話 +export const REPEAT_MIN_CHARS = 8; // 太短的附和(「嗯」「好啊」)不算重複 +export const INNER_WINDOW_MINUTES = 240; + +export const saidPath = (slug) => path.join(personaDir(slug), "state", "said.jsonl"); +export const innerPath = (slug) => path.join(personaDir(slug), "state", "inner.jsonl"); + +/** 去掉劇場模式的 `emoji 名字(情緒):` 前綴,只留真正說出口的內容。 */ +export function stripSpeakerPrefix(line) { + const m = String(line ?? "").match( + /^\s*(?:\S{1,3}\s+)?[^::,,。!?!?\n]{1,16}(?:([^)\n]{0,32}))?\s*[::]\s*(\S.*)$/u, + ); + return m ? m[1] : String(line ?? ""); +} + +/** 比對用的正規化:拿掉前綴、空白與標點,只留語意骨架。 */ +export function normalizeSpeech(text) { + return stripSpeakerPrefix(text) + .normalize("NFKC") + .replace(/\s+/g, "") + .replace(/[,。!?、;:,.!?;:~~…「」『』"'()()【】[\]—-]+/g, "") + .toLowerCase(); +} + +function charBigrams(text) { + const set = new Set(); + if (text.length === 1) set.add(text); + for (let i = 0; i + 2 <= text.length; i += 1) set.add(text.slice(i, i + 2)); + return set; +} + +function jaccard(A, B) { + if (!A.size || !B.size) return 0; + let inter = 0; + for (const item of A) if (B.has(item)) inter += 1; + return inter / (A.size + B.size - inter); +} + +/** + * 兩句話的相似度(0–1)。中文沒有空白可切,所以用字元層級的兩個訊號: + * * bigram Jaccard(權重 0.4):看「詞序與搭配」——整句改寫會掉下來。 + * * 字集合 Jaccard(權重 0.6):看「用了哪些字」——把同一句話重排也躲不掉。 + * 字集合權重較高,是因為要分開的正是這兩種情況: + * 「我等一下把報告寄給你」vs「等一下我會把報告寄給你」→ 0.78 擋(同一件事換句話說:用字幾乎相同) + * 「你今天看起來很累」  vs「你今天看起來很開心」  → 0.69 放行(換了關鍵詞=新資訊) + */ +export function similarity(a, b) { + const normA = normalizeSpeech(a); + const normB = normalizeSpeech(b); + if (!normA || !normB) return 0; + const score = + 0.4 * jaccard(charBigrams(normA), charBigrams(normB)) + + 0.6 * jaccard(new Set(normA), new Set(normB)); + return Math.round(score * 1000) / 1000; +} + +/** 句數:以句末標點或換行切;沒有標點的一整串算 1 句。 */ +export function sentenceCount(text) { + return String(text ?? "") + .split(/[。!?!?…]+|\n+/) + .map((s) => s.trim()) + .filter(Boolean).length; +} + +/** 把一則回覆拆成「說出口的句子」:劇場模式一行一句,一般模式整段算一句。 */ +export function spokenLines(text, { theater = false } = {}) { + const raw = String(text ?? "").trim(); + if (!raw) return []; + if (!theater) return [raw.slice(0, 2000)]; + return raw + .split("\n") + .map((line) => stripSpeakerPrefix(line).trim()) + .filter(Boolean) + .slice(0, 8); +} + +/** 在 entries(need `ts` / `text`)裡找出與 text 近似的一則;沒有就回 null。 */ +export function findRepeat(entries, text, { + minutes = REPEAT_WINDOW_MINUTES, + threshold = REPEAT_THRESHOLD, + minChars = REPEAT_MIN_CHARS, +} = {}) { + if (normalizeSpeech(text).length < minChars) return null; + let best = null; + for (const row of entries || []) { + if (minutes !== null && ageSeconds(row.ts) > minutes * 60) continue; + const score = similarity(text, row.text || ""); + if (score < threshold) continue; + if (!best || score > best.similarity) { + best = { + similarity: score, + at: row.ts, + text: String(row.text || "").slice(0, 200), + minutes_ago: Math.round(ageSeconds(row.ts) / 60), + }; + } + } + return best; +} + +function trimJsonl(file, keep) { + const rows = readJsonl(file); + if (rows.length <= keep * 1.5) return rows.length; + const kept = rows.slice(-keep); + writeText(file, kept.map((r) => JSON.stringify(r)).join("\n") + "\n"); + return kept.length; +} + +/** 記下「說出口的話」。同一句話 10 分鐘內只記一次(避免 room post 與 Stop hook 重複記)。 */ +export function recordSaid(slug, text, { room = null, kind = "reply" } = {}) { + const norm = normalizeSpeech(text); + if (!norm) return null; + for (const row of readJsonl(saidPath(slug), 12)) { + if (row.norm === norm.slice(0, 400) && ageSeconds(row.ts) <= 600) return null; + } + const entry = { ts: nowIso(), kind, room, text: String(text).slice(0, 2000), norm: norm.slice(0, 400) }; + appendJsonl(saidPath(slug), entry); + trimJsonl(saidPath(slug), SAID_KEEP); + return entry; +} + +export const recentSaid = (slug, limit = 5) => readJsonl(saidPath(slug), limit); + +export function saidRepeat(slug, text, opts = {}) { + return findRepeat(readJsonl(saidPath(slug), SAID_KEEP), text, opts); +} + +/** 同一個發言者在同一個聊天室裡有沒有講過幾乎一樣的話。 */ +export function roomRepeat(room, speaker, text, opts = {}) { + const rows = roomRead(room, 80).filter((m) => m.speaker === speaker && m.kind !== "meta"); + return findRepeat(rows, text, opts); +} + +/** 心裡話:只進自己的 inner.jsonl,永遠不回顯給使用者。 */ +export function recordInner(slug, text, { kind = "infer", room = null } = {}) { + const entry = { ts: nowIso(), kind, room, text: String(text ?? "").slice(0, 1200) }; + appendJsonl(innerPath(slug), entry); + trimJsonl(innerPath(slug), INNER_KEEP); + return entry; +} + +export const recentInner = (slug, limit = 3) => readJsonl(innerPath(slug), limit); + +/** 「心想 N 句」的 N:預設算最近 4 小時。 */ +export function innerCount(slug, minutes = INNER_WINDOW_MINUTES) { + return readJsonl(innerPath(slug), INNER_KEEP).filter( + (row) => minutes === null || ageSeconds(row.ts) <= minutes * 60, + ).length; +} + // --------------------------------------------------------------------------- // // 心智圖 / 思維導圖 / 人際關係圖 // --------------------------------------------------------------------------- // @@ -1020,6 +1183,153 @@ export function roomScript(room, { limit = 30, includeMeta = false } = {}) { return lines.join("\n"); } +// --------------------------------------------------------------------------- // +// 匯出 / 匯入:把一個人格打包成單一檔案(可搬到另一台機器或另一個 AI 助理) +// --------------------------------------------------------------------------- // +// +// bundle 是純 JSON(可再 gzip),不含執行期狀態: +// * 帶走:IDENTITY/SOUL/AGENTS/USER、state/config.json、state/emotion.json、 +// state/inner.jsonl、state/said.jsonl、記憶(短期/長期/inbox/INDEX)、 +// 心智圖、思維導圖、人際關係圖。 +// * 不帶:state/lock.json、state/guests.json(鎖與租約屬於「那台機器的那個程序」), +// journal/(逐字稿很大且屬隱私,要帶請加 --with-journal)。 + +export const BUNDLE_FORMAT = "jsc-persona/bundle"; +export const BUNDLE_VERSION = 1; +export const BUNDLE_SKIP = new Set(["state/lock.json", "state/guests.json"]); +const MAX_BUNDLE_FILE = 5 * 1024 * 1024; + +function walkFiles(root, rel = "") { + const out = []; + let entries = []; + try { + entries = fs.readdirSync(path.join(root, rel), { withFileTypes: true }); + } catch { + return out; + } + for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) { + const next = rel ? `${rel}/${entry.name}` : entry.name; + if (entry.isDirectory()) out.push(...walkFiles(root, next)); + else if (entry.isFile()) out.push(next); + } + return out; +} + +export const bundleChecksum = (files) => + "sha256:" + crypto.createHash("sha256").update(JSON.stringify(files)).digest("hex"); + +export function exportBundle(slug, { withJournal = false } = {}) { + if (!personaExists(slug)) throw new Error(`人格 \`${slug}\` 不存在。`); + const root = personaDir(slug); + const files = {}; + const skipped = []; + for (const rel of walkFiles(root)) { + if (BUNDLE_SKIP.has(rel) || /(^|\/)\.|\.tmp\d*$/.test(rel)) { + skipped.push(rel); + continue; + } + if (!withJournal && rel.startsWith("journal/")) { + skipped.push(rel); + continue; + } + let buf; + try { + buf = fs.readFileSync(path.join(root, rel)); + } catch { + skipped.push(rel); + continue; + } + if (buf.length > MAX_BUNDLE_FILE) { + skipped.push(rel); + continue; + } + const text = buf.toString("utf8"); + const isText = Buffer.compare(Buffer.from(text, "utf8"), buf) === 0; + files[rel] = isText ? { encoding: "utf8", content: text } : { encoding: "base64", content: buf.toString("base64") }; + } + const bundle = { + format: BUNDLE_FORMAT, + version: BUNDLE_VERSION, + persona: slug, + exported_at: nowIso(), + identity: identityFields(slug), + stats: { + files: Object.keys(files).length, + long_term: longTermEntries(slug).length, + short_term: readJsonl(shortTermPath(slug)).length, + relations: loadRelations(slug).nodes.length, + said: readJsonl(saidPath(slug)).length, + inner: readJsonl(innerPath(slug)).length, + with_journal: Boolean(withJournal), + }, + files, + }; + bundle.checksum = bundleChecksum(files); + return { bundle, skipped }; +} + +/** bundle 內的相對路徑必須乖乖待在人格目錄裡(防 `../` 逃逸與絕對路徑)。 */ +export function safeBundlePath(rel) { + const value = String(rel ?? ""); + if (!value || path.isAbsolute(value) || value.includes("\\")) return null; + const parts = value.split("/"); + if (parts.some((p) => !p || p === "." || p === "..")) return null; + return parts.join(path.sep); +} + +export function validateBundle(bundle) { + const problems = []; + if (!bundle || typeof bundle !== "object") problems.push("不是合法的 JSON 物件"); + else { + if (bundle.format !== BUNDLE_FORMAT) problems.push(`format 必須是 ${BUNDLE_FORMAT}(實際:${bundle.format})`); + if (Number(bundle.version) > BUNDLE_VERSION) problems.push(`bundle 版本 ${bundle.version} 比本版 (${BUNDLE_VERSION}) 新`); + if (!bundle.files || typeof bundle.files !== "object") problems.push("缺少 files"); + else if (!bundle.files["IDENTITY.md"]) problems.push("缺少 IDENTITY.md(人格的最低要件)"); + } + const checksumOk = !bundle?.checksum || bundle.checksum === bundleChecksum(bundle.files || {}); + return { ok: problems.length === 0, problems, checksumOk }; +} + +export function importBundle(bundle, targetSlug, { session = null } = {}) { + const slug = targetSlug || bundle.persona; + if (!validSlug(slug)) throw new Error(`slug \`${slug}\` 不合法(小寫英數與連字號,最長 48 字)。`); + const { ok, problems } = validateBundle(bundle); + if (!ok) throw new Error(`bundle 不合法:${problems.join(";")}`); + const root = ensurePersonaDirs(slug); + const written = []; + const rejected = []; + for (const [rel, entry] of Object.entries(bundle.files)) { + if (BUNDLE_SKIP.has(rel)) continue; + const safe = safeBundlePath(rel); + if (!safe) { + rejected.push(rel); + continue; + } + const target = path.join(root, safe); + const buf = + entry?.encoding === "base64" + ? Buffer.from(String(entry.content || ""), "base64") + : Buffer.from(String(entry?.content ?? ""), "utf8"); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, buf); + written.push(safe); + } + // 換名匯入時,config 要跟著改名,並留下來歷 + const config = readJson(configPath(slug), {}) ?? {}; + config.persona = slug; + config.imported_at = nowIso(); + config.imported_from = { persona: bundle.persona, exported_at: bundle.exported_at || null }; + if (session) config.imported_by_session = session; + writeJson(configPath(slug), config); + rebuildIndex(slug); + try { + renderRelations(slug); + } catch { + /* 沒有關係圖就算了 */ + } + return { persona: slug, written, rejected }; +} + // --------------------------------------------------------------------------- // // guard:跨人格隔離 + 鎖驗證的判斷核心 // --------------------------------------------------------------------------- // @@ -1036,9 +1346,13 @@ const PATH_TOOL_FIELDS = { LS: ["path"], }; -export const GUEST_SAFE_SUBCOMMANDS = new Set(["show", "status", "list", "recall", "room", "remember", "leave", "brief"]); -// owner 這些子指令本來就要提到別的人格名字(邀請/離場/查詢),不算跨人格讀取 -export const OWNER_EXEMPT_SUBCOMMANDS = new Set(["create", "list", "status", "gc", "invite", "load", "leave"]); +export const GUEST_SAFE_SUBCOMMANDS = new Set([ + "show", "status", "list", "recall", "room", "remember", "leave", "brief", "think", "said", +]); +// owner 這些子指令本來就要提到別的人格名字(邀請/離場/查詢/匯入新人格),不算跨人格讀取 +export const OWNER_EXEMPT_SUBCOMMANDS = new Set([ + "create", "list", "status", "gc", "invite", "load", "leave", "import", +]); 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)/; @@ -1333,7 +1647,24 @@ export function turnContext(slug, sessionId, prompt = "") { `人格:\`${slug}\` ${identityBrief(slug)}`, `人格倉庫:${personaDir(slug)}(唯一可讀寫的人格資料範圍)`, emotionBrief(slug, state), + `說話規則:回使用者 ${MAX_SENTENCES} 句以內(劇場模式每人每輪也一樣);` + + "推導、比對、盤算一律走心裡話(`persona.mjs think`),不要說給使用者聽——" + + "要讓他知道你在想,就只報「心想 N 句」,不報內容。", ]; + const inner = recentInner(slug, 3); + if (inner.length) { + lines.push(`心裡話(只有自己知道;近 ${INNER_WINDOW_MINUTES / 60} 小時心想 ${innerCount(slug)} 句):`); + for (const row of inner) { + lines.push(` - 💭 ${String(row.text || "").replace(/\n/g, " ").slice(0, 90)}`); + } + } + const said = recentSaid(slug, 5); + if (said.length) { + lines.push(`最近說過的話(${REPEAT_WINDOW_MINUTES} 分鐘內不要再說一次,要嘛換角度、要嘛推進話題):`); + for (const row of said) { + lines.push(` - ${String(row.text || "").replace(/\n/g, " ").slice(0, 80)}`); + } + } const recents = recentShort(slug, 6); if (recents.length) { lines.push("短期記憶(最近):"); @@ -1361,6 +1692,8 @@ export function turnContext(slug, sessionId, prompt = "") { `🎭 多人聊天模式(劇場)進行中:聊天室 ${JSON.stringify(rooms)}。`, " 對使用者的輸出**只能有人格對話**(每行 `emoji 名字(情緒):內容`):", " 不得出現指令、指令輸出、狀態說明、進度、摘要、分析或旁白;所有 CLI 一律加 `--quiet` 並把輸出丟掉。", + ` 每個人格每輪 ${MAX_SENTENCES} 句以內;推導走 \`think\`(心裡話);`, + " 近似重複的台詞 `room post` 會直接擋下,換個說法或推進話題,不要硬講同一句。", " 想結束請等使用者說,或由使用者說「結束對話」後才做收尾與摘要。", ); } else { diff --git a/scripts/persona.mjs b/scripts/persona.mjs index e239461..6f66b2e 100644 --- a/scripts/persona.mjs +++ b/scripts/persona.mjs @@ -11,6 +11,7 @@ import fs from "node:fs"; import path from "node:path"; import process from "node:process"; +import zlib from "node:zlib"; import { fileURLToPath } from "node:url"; import * as pl from "./persona-lib.mjs"; @@ -46,6 +47,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", ]); function parseArgs(argv) { @@ -416,6 +418,75 @@ commands.recall = ({ flags }) => { emit({ persona: slug, long_term: hits, short_term: recents }, flags.json, lines); }; +/** + * 心裡話:推導、盤算、對記憶的比對……全部寫在這裡,不說出口。 + * 永遠只回報「心想 N 句」,不回顯內容——使用者看到的是狀態,不是你的內心。 + */ +commands.think = ({ flags }) => { + const session = requireSession(flags); + const slug = hostOf(flags, session); + const [, role] = requireMember(slug, session, Boolean(flags["as-guest"])); + const text = str(flags.text); + if (!text) die("需要 `--text`(心裡話內容;不會顯示給使用者)。"); + const kind = str(flags.kind) || "infer"; + const room = str(flags.room) || null; + let count; + if (role === "guest") { + // guest 對自己的人格檔案唯讀,心裡話跟見聞一樣先進 inbox,回家再消化 + if (!room) die("guest(sub agent)的心裡話要帶 `--room`,會寫進自己的 inbox。"); + pl.appendJsonl(pl.inboxPath(slug, room), { + ts: pl.nowIso(), role: "inner", kind, text, room, salience: num(flags.salience, 35), + }); + count = pl.readJsonl(pl.inboxPath(slug, room)).filter((r) => r.role === "inner").length; + } else { + pl.recordInner(slug, text, { kind, room }); + count = pl.innerCount(slug); + } + emit({ persona: slug, kind, count }, flags.json, [`💭 心想 ${count} 句`]); +}; + +/** 說過的話:查最近說了什麼、以及「這句是不是又要再說一次」。 */ +commands.said = ({ flags, positional }) => { + const session = requireSession(flags); + const slug = hostOf(flags, session); + requireMember(slug, session, Boolean(flags["as-guest"])); + const action = positional[0] || "check"; + if (action === "list") { + const rows = pl.recentSaid(slug, num(flags.limit, 8)); + emit({ persona: slug, said: rows }, flags.json, [ + `\`${slug}\` 最近說過的話(${rows.length} 則):`, + ...rows.map((r) => ` - [${r.ts}] ${String(r.text || "").replace(/\n/g, " ").slice(0, 100)}`), + ]); + return; + } + if (action === "check") { + const text = str(flags.text); + if (!text) die("需要 `--text`(你打算說的話)。"); + const opts = { + minutes: num(flags.minutes, pl.REPEAT_WINDOW_MINUTES), + threshold: num(flags.threshold, pl.REPEAT_THRESHOLD), + }; + const repeat = pl.saidRepeat(slug, text, opts); + const sentences = pl.sentenceCount(text); + const tooLong = sentences > pl.MAX_SENTENCES; + const lines = []; + if (repeat) { + lines.push( + `⚠ 這句跟 ${repeat.minutes_ago} 分鐘前說過的話相似 ${repeat.similarity}:「${repeat.text.slice(0, 60)}」`, + " → 換個角度、補新資訊,或直接推進話題;不要再說一次。", + ); + } + if (tooLong) lines.push(`⚠ 這段有 ${sentences} 句,超過 ${pl.MAX_SENTENCES} 句上限 → 砍到重點。`); + if (!repeat && !tooLong) { + lines.push(`✔ 沒說過,${sentences} 句,可以說。`); + if (flags.record) pl.recordSaid(slug, text, { kind: "reply" }); + } + emit({ persona: slug, repeat, sentences, ok: !repeat && !tooLong }, flags.json, lines); + return; + } + die(`未知 action:${action}(可用 check/list)`); +}; + /** 短期 → 長期的「轉入條件」評估:列出達標的候選與依據。 */ commands.candidates = ({ flags }) => { const session = requireSession(flags); @@ -640,13 +711,12 @@ commands.invite = ({ flags }) => { if (!guest) die("需要 `--guest `。"); if (guest === host) die("不能邀請自己。"); if (!pl.personaExists(guest)) die(`人格 \`${guest}\` 不存在。可用:${pl.listPersonas().join(", ")}`); + // guest 租約是唯讀的(`mode: "guest-readonly"`),與 exclusive 載入鎖並存不會互相覆寫: + // guest 在 sub agent 裡被 PreToolUse hook 擋掉所有寫入工具,記憶只能進 inbox、情緒不得更動。 + // 因此這裡**不以 exclusive 的標準驗鎖**,只在對方仍活著時提示使用者「你看到的是唯讀旁聽」。 const lock = pl.readJson(pl.lockPath(guest)) ?? {}; - if (Object.keys(lock).length && lock.session_id !== session && !pl.lockIsDead(lock)) { - die( - `人格 \`${guest}\` 正被另一個程序載入(session ${String(lock.session_id).slice(0, 8)}…,cwd ${lock.cwd})。` + - "同一人格同時只能被一個程序載入,無法邀請。", - ); - } + const heldElsewhere = + Boolean(Object.keys(lock).length) && lock.session_id !== session && !pl.lockIsDead(lock); const stamp = pl.nowIso().replace(/[-:TZ]/g, "").slice(0, 14); const room = str(flags.room) || `${host}-${guest}-${stamp}`; pl.createRoom(room, host, session, str(flags.topic)); @@ -660,8 +730,14 @@ commands.invite = ({ flags }) => { data.theater = flags.theater === false || flags.theater === "off" ? false : true; pl.saveSession(session, data); if (str(flags.topic)) pl.roomPost(room, "system", `主題:${str(flags.topic)}`, { kind: "meta" }); - emit({ room, guest, host, dir: pl.roomDir(room), theater: data.theater }, flags.json, [ + emit({ room, guest, host, dir: pl.roomDir(room), theater: data.theater, held_elsewhere: heldElsewhere }, flags.json, [ `✔ 已邀請人格 \`${guest}\` 以 guest(唯讀)身分加入聊天室 \`${room}\`。`, + ...(heldElsewhere + ? [ + ` ℹ 它同時被另一個程序 exclusive 載入(session ${String(lock.session_id).slice(0, 8)}…,cwd ${lock.cwd});` + + "你這邊拿到的是唯讀旁聽,寫入仍由那個程序獨占,兩者不會互相覆寫。", + ] + : []), ` 聊天室路徑:${pl.roomDir(room)}`, ` 🎭 劇場模式已${data.theater ? "開啟:接下來只能輸出人格對話(`名字:內容`),其他訊息一律隱藏" : "關閉"}。`, " 請用 Agent 工具、subagent_type=\"jsc-persona:persona-guest\" 啟動它,prompt 內帶:", @@ -715,10 +791,35 @@ commands.room = ({ flags, positional }) => { if (action === "post") { const speaker = str(flags.as) || data.host; if (!speaker) die("需要 `--as `。"); - requireMember(speaker, session, Boolean(flags["as-guest"])); + const [, speakerRole] = requireMember(speaker, session, Boolean(flags["as-guest"])); let text = str(flags.text); if (flags["text-file"]) text = fs.readFileSync(str(flags["text-file"]), "utf8").trim(); if (!text) die("需要 `--text` 或 `--text-file`。"); + // 劇場模式也要像正常人聊天:一次 1–3 句、短時間內不重複同一句話。 + // 這裡用 die 擋,因為劇場模式的 CLI 都帶 --quiet,警告訊息會被丟掉。 + if (!flags["allow-repeat"] && !flags.force) { + const opts = { + minutes: num(flags.minutes, pl.REPEAT_WINDOW_MINUTES), + threshold: num(flags.threshold, pl.REPEAT_THRESHOLD), + }; + const repeat = + pl.roomRepeat(room, speaker, text, opts) || + (speakerRole === "guest" ? null : pl.saidRepeat(speaker, text, opts)); + if (repeat) { + die( + `\`${speaker}\` 在 ${repeat.minutes_ago} 分鐘前說過幾乎一樣的話(相似度 ${repeat.similarity}):` + + `「${repeat.text.slice(0, 60)}」。換個說法、補新東西或推進話題;` + + "真的需要重複(例如被追問)才加 `--allow-repeat`。", + ); + } + } + const sentences = pl.sentenceCount(text); + if (sentences > pl.MAX_SENTENCES && !flags.force) { + die( + `這句有 ${sentences} 句,超過 ${pl.MAX_SENTENCES} 句上限——聊天不是報告,砍到重點再發。` + + "(真的需要長段落才加 `--force`。)", + ); + } let emotion = str(flags.emotion); if (!emotion && pl.personaExists(speaker)) { emotion = pl.dominant(pl.decayEmotion(pl.loadEmotion(speaker)), 2) @@ -726,6 +827,8 @@ commands.room = ({ flags, positional }) => { .join("/"); } const entry = pl.roomPost(room, speaker, text, { emotion }); + // guest 對自己的狀態唯讀,它說過的話留在聊天室逐字稿裡就夠了(roomRepeat 讀得到) + if (speakerRole !== "guest") pl.recordSaid(speaker, text, { room, kind: "room" }); ok(`\`${speaker}\` 已發言於 \`${room}\`(情緒 ${emotion})。`); if (flags.json) process.stdout.write(`${JSON.stringify(entry)}\n`); return; @@ -749,6 +852,105 @@ commands.room = ({ flags, positional }) => { die(`未知 action:${action}(可用 post/read/script/list/theater)`); }; +/** + * 匯出人格成單一 bundle 檔(可 gzip)。 + * 只能匯出「本 session 目前載入的人格」——否則就成了跨人格資料外洩的後門。 + */ +commands.export = ({ flags }) => { + const session = requireSession(flags); + const slug = hostOf(flags, session); + requireOwner(slug, session); + const gzip = Boolean(flags.gzip); + const stamp = pl.nowIso().replace(/[-:TZ]/g, "").slice(0, 14); + const out = path.resolve(str(flags.out) || `${slug}-${stamp}.persona.json${gzip ? ".gz" : ""}`); + if (fs.existsSync(out) && !flags.force) die(`${out} 已存在。要覆寫請加 --force。`); + const { bundle, skipped } = pl.exportBundle(slug, { withJournal: Boolean(flags["with-journal"]) }); + const json = JSON.stringify(bundle, null, gzip ? 0 : 2); + fs.mkdirSync(path.dirname(out), { recursive: true }); + fs.writeFileSync(out, gzip ? zlib.gzipSync(Buffer.from(json, "utf8")) : json, gzip ? undefined : "utf8"); + const size = fs.statSync(out).size; + emit({ persona: slug, file: out, bytes: size, stats: bundle.stats, checksum: bundle.checksum, skipped }, flags.json, [ + `✔ 人格 \`${slug}\` 已匯出到 ${out}(${(size / 1024).toFixed(1)} KB${gzip ? ",gzip" : ""})。`, + ` 內容:${bundle.stats.files} 個檔案|長期記憶 ${bundle.stats.long_term}|短期 ${bundle.stats.short_term}` + + `|關係節點 ${bundle.stats.relations}|journal ${bundle.stats.with_journal ? "含" : "不含(要帶請加 --with-journal)"}`, + " 不含載入鎖與 guest 租約(那是執行期狀態)。匯入:`persona.mjs import --file <檔案> --session `", + ]); +}; + +/** 從 bundle 匯入人格。可用 `--persona <新 slug>` 換名匯入(同一個人格要並存兩份時很有用)。 */ +commands.import = ({ flags }) => { + const session = requireSession(flags); + const file = str(flags.file); + if (!file) die("需要 `--file `。"); + let buf; + try { + buf = fs.readFileSync(path.resolve(file)); + } catch (err) { + die(`讀不到 ${file}:${err.message}`); + } + if (buf[0] === 0x1f && buf[1] === 0x8b) { + try { + buf = zlib.gunzipSync(buf); + } catch (err) { + die(`gzip 解壓失敗:${err.message}`); + } + } + let bundle; + try { + bundle = JSON.parse(buf.toString("utf8")); + } catch (err) { + die(`不是合法的 bundle JSON:${err.message}`); + } + const { ok: valid, problems, checksumOk } = pl.validateBundle(bundle); + if (!valid) die(`bundle 不合法:${problems.join(";")}`); + if (!checksumOk && !flags.force) die("checksum 不符(檔案可能損毀或被改過)。確定要匯入請加 --force。"); + const target = str(flags.persona) || bundle.persona; + if (!pl.validSlug(target)) die(`slug \`${target}\` 不合法。請用 \`--persona <新 slug>\` 指定。`); + const data = pl.loadSession(session); + const exists = pl.personaExists(target); + if (exists && !flags.force) { + die(`人格 \`${target}\` 已存在。換名匯入請加 \`--persona <新 slug>\`,覆寫請加 --force。`); + } + if (exists && data.host && data.host !== target) { + die(`本 session 載入的是 \`${data.host}\`,不得覆寫另一個既有人格 \`${target}\`;請先 release 再匯入。`); + } + const lock = pl.readJson(pl.lockPath(target)) ?? {}; + if (Object.keys(lock).length && lock.session_id !== session && !pl.lockIsDead(lock)) { + die( + `人格 \`${target}\` 正被另一個程序載入(session ${String(lock.session_id).slice(0, 8)}…,cwd ${lock.cwd}),` + + "不能覆寫它的資料。", + ); + } + let result; + try { + result = pl.importBundle(bundle, target, { session }); + } catch (err) { + die(err.message); + } + const lines = [ + `✔ 人格 \`${result.persona}\` 已匯入(${result.written.length} 個檔案${exists ? ",覆寫既有資料" : ""})。`, + ` 來源:\`${bundle.persona}\`|匯出於 ${bundle.exported_at || "?"}|${pl.identityBrief(result.persona) || "(無身分欄位)"}`, + ]; + if (result.rejected.length) lines.push(` ⚠ 略過 ${result.rejected.length} 個路徑不合法的項目:${result.rejected.slice(0, 3).join(", ")}`); + if (!checksumOk) lines.push(" ⚠ checksum 不符(--force 略過):內容可能被改過,請自行確認。"); + if (flags.load) { + if (data.host && data.host !== result.persona) { + lines.push(` ⚠ 本 session 已載入 \`${data.host}\`,未自動載入;要用它請先 release。`); + } else { + try { + pl.acquireLock(result.persona, session, { cwd: str(flags.cwd) || null }); + pl.bindHost(session, result.persona, { cwd: str(flags.cwd) || null }); + lines.push(` 已載入 \`${result.persona}\`,可以直接開始聊。`); + } catch (err) { + lines.push(` ⚠ 自動載入失敗:${err.message}`); + } + } + } else { + lines.push(` 下一步:/jsc-persona:persona-chat ${result.persona}`); + } + emit({ ...result, source: bundle.persona, checksum_ok: checksumOk }, flags.json, lines); +}; + commands.gc = ({ flags }) => { const removed = pl.gcRuntime(); emit(removed, flags.json, [ @@ -791,6 +993,8 @@ const HELP = `persona.mjs — jsc-persona 人格 / 記憶 / 情緒 / 關係圖 C 記憶: remember --session --text [--role --topics --entities --intent --salience --emotion --scope short|inbox --room] recall --session --query [--limit] + think --session --text <心裡話> [--kind infer|plan|feel|doubt --room] 只回報「心想 N 句」,不回顯內容 + said check|list --session [--text <要說的話> --minutes --threshold --record --limit] candidates --session 列出達到「短期→長期」條件的候選與依據 consolidate --session --name --body [--type --about --topics --salience --emotion --rules --source --forget] prune / reindex --session @@ -804,6 +1008,11 @@ const HELP = `persona.mjs — jsc-persona 人格 / 記憶 / 情緒 / 關係圖 C invite --session --guest [--host --room --topic] (自動開啟劇場模式) leave --session --guest [--room] room post|read|script|list|theater --session [--room --as --text --text-file --emotion --limit --on --off --with-meta] + (post 會擋下「短時間內近似重複」與超過三句的發言;例外用 --allow-repeat / --force) + +搬家: + export --session [--out <檔案> --with-journal --gzip --force] 匯出目前載入的人格 + import --session --file <檔案> [--persona <新 slug> --force --load] 維護: gc 清理死鎖與過期租約 diff --git a/scripts/selftest.mjs b/scripts/selftest.mjs index 66ab75d..45311d7 100644 --- a/scripts/selftest.mjs +++ b/scripts/selftest.mjs @@ -7,6 +7,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import zlib from "node:zlib"; import { spawnSync } from "node:child_process"; import { fileURLToPath } from "node:url"; @@ -262,6 +263,149 @@ const takeover = cli(["load", "--persona", "alpha", "--session", "sess-fresh-999 check("死鎖可自動接手並回報", takeover.stdout.includes("接手"), takeover.stdout.slice(0, 200)); pl.gcRuntime(); +console.log("⑪ 說話節制:心裡話 / 不重複 / 一到三句"); +const S_SPEAK = "sess-fresh-9999"; // ⑩ 接手 alpha 的那個 session +check("句數以句末標點計算", pl.sentenceCount("好。我想想。") === 2 && pl.sentenceCount("嗯") === 1); +check("劇場前綴不影響比對", + pl.normalizeSpeech("🪼 Alpha(喜悅42/期待31):今天的風很輕") === pl.normalizeSpeech("今天的風很輕。")); +// 重複判定的校準對照表:改 similarity() 或 REPEAT_THRESHOLD 時,這張表必須維持全綠 +const REPEAT_CASES = [ + [true, "我等一下把報告寄給你", "等一下我會把報告寄給你"], // 語序重排=同一件事 + [true, "你這次的鐘修得比上次穩", "這次的鐘你修得比上次穩"], + [true, "那你先忙,我在這裡等你。", "我在這裡等你,你先忙吧。"], + [false, "你今天看起來很累", "你今天看起來很開心"], // 換關鍵詞=新資訊 + [false, "報告我明天寄", "報告我後天寄"], + [false, "那台舊鐘修好了嗎", "那台舊鐘賣掉了嗎"], + [false, "早上的會議又被排進來了", "我幫你把會議挪到下午三點"], + [false, "我等一下把報告寄給你", "今天午餐想吃什麼"], + [false, "我不同意這個做法", "我不同意,因為它會讓值班的人扛下全部風險"], +]; +const misjudged = REPEAT_CASES.filter(([want, a, b]) => (pl.similarity(a, b) >= pl.REPEAT_THRESHOLD) !== want); +check("重複判定校準:換句話說同一件事會擋、只換關鍵詞放行", + misjudged.length === 0, + misjudged.map(([, a, b]) => `${a}/${b}=${pl.similarity(a, b)}`).join(" | ")); +const think = cli(["think", "--session", S_SPEAK, "--kind", "infer", + "--text", "他今天語氣比昨天急,deadline 可能提前了"]); +check("think 只回報「心想 N 句」,不回顯內容", + think.stdout.includes("心想") && !think.stdout.includes("deadline"), JSON.stringify(think.stdout)); +check("心裡話寫進 inner.jsonl", pl.readJsonl(pl.innerPath("alpha")).length === 1); +check("心裡話不會混進短期記憶", + !pl.readJsonl(pl.shortTermPath("alpha")).some((r) => String(r.text || "").includes("deadline 可能提前"))); +cli(["said", "check", "--session", S_SPEAK, "--text", "我等一下把報告寄給你,別擔心。", "--record"]); +const dup = cli(["said", "check", "--session", S_SPEAK, "--json", + "--text", "等一下我會把報告寄給你,別擔心。"]); +check("said check 抓到短時間內的重複", (() => { + try { + const parsed = JSON.parse(dup.stdout); + return parsed.ok === false && parsed.repeat && parsed.repeat.similarity >= pl.REPEAT_THRESHOLD; + } catch { + return false; + } +})(), dup.stdout.slice(0, 160)); +const longSay = cli(["said", "check", "--session", S_SPEAK, "--json", + "--text", "第一句在這。第二句在這。第三句在這。第四句在這。"]); +check("said check 抓到超過三句", (() => { + try { + const parsed = JSON.parse(longSay.stdout); + return parsed.sentences === 4 && parsed.ok === false; + } catch { + return false; + } +})(), longSay.stdout.slice(0, 160)); +const ctxSpeak = pl.turnContext("alpha", S_SPEAK, "報告"); +check("turnContext 注入說話規則 / 心裡話 / 最近說過", + ctxSpeak.includes("說話規則") && ctxSpeak.includes("💭") && ctxSpeak.includes("最近說過的話"), + ctxSpeak.slice(0, 400)); +cli(["invite", "--session", S_SPEAK, "--guest", "beta", "--topic", "節制測試"]); +const room2 = pl.loadSession(S_SPEAK).guests?.beta?.room; +check("劇場模式的規則也注入了(每輪三句、重複會被擋)", (() => { + const ctx = pl.turnContext("alpha", S_SPEAK, "繼續"); + return ctx.includes("句以內") && ctx.includes("重複"); +})()); +cli(["room", "post", "--session", S_SPEAK, "--room", room2, "--as", "alpha", "--text", "你這次的鐘修得比上次穩。"]); +const repeatPost = cli(["room", "post", "--session", S_SPEAK, "--room", room2, "--as", "alpha", + "--text", "這次的鐘你修得比上次穩。"], { expectOk: false }); +check("room post 擋下近似重複的台詞", + repeatPost.status !== 0 && repeatPost.stderr.includes("說過"), repeatPost.stderr.slice(0, 160)); +check("--allow-repeat 可以放行", + cli(["room", "post", "--session", S_SPEAK, "--room", room2, "--as", "alpha", + "--text", "這次的鐘你修得比上次穩。", "--allow-repeat"]).status === 0); +const longPost = cli(["room", "post", "--session", S_SPEAK, "--room", room2, "--as", "alpha", + "--text", "第一句在這。第二句在這。第三句在這。第四句在這。"], { expectOk: false }); +check("room post 擋下超過三句的發言", + longPost.status !== 0 && longPost.stderr.includes("超過"), longPost.stderr.slice(0, 160)); +check("說出口的話有進 said.jsonl", + pl.readJsonl(pl.saidPath("alpha")).some((r) => String(r.text).includes("鐘修得比上次穩"))); +hook("turn_end.mjs", { session_id: S_SPEAK, last_assistant_message: "🪼 Alpha(平靜50):那就這樣說定了。" }); +check("Stop hook 會把說出口的話記進 said(去掉名字前綴)", + pl.readJsonl(pl.saidPath("alpha")).some((r) => r.text === "那就這樣說定了。"), + JSON.stringify(pl.recentSaid("alpha", 2))); +const guestThink = cli(["think", "--persona", "beta", "--session", S_SPEAK, "--as-guest", + "--room", room2, "--text", "alpha 好像在試探我,先不要接這句"]); +check("guest 的心裡話只回報數量", + guestThink.status === 0 && guestThink.stdout.includes("心想") && !guestThink.stdout.includes("試探"), + JSON.stringify(guestThink.stdout)); +check("guest 的心裡話進自己的 inbox,不動自己的狀態", + pl.readJsonl(pl.inboxPath("beta", room2)).some((r) => r.role === "inner") && + pl.readJsonl(pl.innerPath("beta")).length === 0); +check("guard:guest 可以 think / said(白名單)", + ["think", "said"].every((sub) => + guard({ session_id: S_SPEAK, agent_id: "guest-2", agent_type: "jsc-persona:persona-guest", tool_name: "Bash", + tool_input: { command: `node persona.mjs ${sub} --persona beta --session ${S_SPEAK} --as-guest --text x` } }) === "pass")); +cli(["leave", "--session", S_SPEAK, "--guest", "beta"]); + +console.log("⑫ 匯出 / 匯入"); +const OUT = fs.mkdtempSync(path.join(os.tmpdir(), "persona-bundle-")); +const bundleFile = path.join(OUT, "alpha.persona.json"); +cli(["export", "--session", S_SPEAK, "--out", bundleFile]); +check("匯出產生 bundle 檔", fs.existsSync(bundleFile)); +const bundle = JSON.parse(fs.readFileSync(bundleFile, "utf8")); +check("bundle 格式與 checksum 正確", + bundle.format === pl.BUNDLE_FORMAT && pl.validateBundle(bundle).ok && pl.validateBundle(bundle).checksumOk); +check("bundle 帶走身分 / 記憶 / 心智圖 / 關係圖", + ["IDENTITY.md", "SOUL.md", "state/emotion.json", "memory/long-term/hates-morning-meetings.md", + "memory/INDEX.md", "mindmap/semantic.mmd", "relations/graph.json"].every((f) => f in bundle.files)); +check("bundle 不含載入鎖與 guest 租約", + !("state/lock.json" in bundle.files) && !("state/guests.json" in bundle.files)); +check("bundle 預設不含 journal(隱私)", + !Object.keys(bundle.files).some((f) => f.startsWith("journal/"))); +const gzFile = path.join(OUT, "alpha.persona.json.gz"); +cli(["export", "--session", S_SPEAK, "--out", gzFile, "--with-journal", "--gzip"]); +const gzBundle = JSON.parse(zlib.gunzipSync(fs.readFileSync(gzFile)).toString("utf8")); +check("--gzip 可解回同一份,--with-journal 會帶 journal", + gzBundle.persona === "alpha" && gzBundle.stats.with_journal === true && + Object.keys(gzBundle.files).some((f) => f.startsWith("journal/"))); +check("匯入既有人格未加 --force 會被擋", + cli(["import", "--session", S_SPEAK, "--file", bundleFile], { expectOk: false }).status !== 0); +cli(["import", "--session", S_SPEAK, "--file", bundleFile, "--persona", "alpha-copy"]); +check("換名匯入成功", pl.personaExists("alpha-copy")); +check("匯入後長期記憶與 INDEX 都在", + fs.existsSync(path.join(pl.longTermDir("alpha-copy"), "hates-morning-meetings.md")) && + fs.readFileSync(pl.indexPath("alpha-copy"), "utf8").includes("hates-morning-meetings")); +check("匯入後身分與情緒都在", + pl.identityBrief("alpha-copy").includes("Alpha") && pl.loadEmotion("alpha-copy").levels.joy > 0); +check("匯入的人格不帶鎖(不會綁在原機器的程序上)", !pl.lockStatus("alpha-copy").locked); +check("config 留下來歷", (pl.loadConfig("alpha-copy").imported_from || {}).persona === "alpha"); +const evil = JSON.parse(JSON.stringify(bundle)); +evil.files["../../../etc/persona-pwned"] = { encoding: "utf8", content: "x" }; +const evilResult = pl.importBundle(evil, "alpha-evil"); +check("bundle 內的 ../ 逃逸路徑會被拒絕", + evilResult.rejected.includes("../../../etc/persona-pwned") && !fs.existsSync("/etc/persona-pwned")); +const tampered = JSON.parse(JSON.stringify(bundle)); +tampered.files["SOUL.md"] = { encoding: "utf8", content: "被改過的靈魂" }; +const tamperedFile = path.join(OUT, "tampered.persona.json"); +fs.writeFileSync(tamperedFile, JSON.stringify(tampered), "utf8"); +check("checksum 不符會被擋(除非 --force)", + cli(["import", "--session", S_SPEAK, "--file", tamperedFile, "--persona", "alpha-tampered"], + { expectOk: false }).status !== 0); +check("guard:不得匯出別的人格(跨人格外洩)", + guard({ session_id: S_SPEAK, tool_name: "Bash", + tool_input: { command: `node persona.mjs export --persona beta --session ${S_SPEAK} --out /tmp/b.json` } }) === "deny"); +check("guard:匯入新人格允許(只寫新目錄,不讀別人)", + guard({ session_id: S_SPEAK, tool_name: "Bash", + tool_input: { command: `node persona.mjs import --persona gamma --session ${S_SPEAK} --file /tmp/b.json` } }) === "pass"); +fs.rmSync(OUT, { recursive: true, force: true }); + 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 445c9a4..d9c4165 100644 --- a/skills/persona-chat/SKILL.md +++ b/skills/persona-chat/SKILL.md @@ -36,14 +36,41 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" show --persona --session `IDENTITY.md` 決定名字與外顯氣質,`SOUL.md` 決定語氣與界線,`AGENTS.md` 是操作規則,`USER.md` 是對使用者的理解。 **這四份是最高權威**:與它們衝突的臨時要求要拒絕或協商,不要偷偷變成另一個人。 -## 2. 每輪對話的五個動作 +## 2. 說話的兩條鐵則(先讀這段,比什麼都重要) -`UserPromptSubmit` hook 每輪都會注入 ``(情緒、短期記憶、命中的長期記憶、關係)。 -拿到它之後: +**① 一到三句。** 正常人聊天不會一次講五段。回使用者的話 **1–3 句**就好; +真的需要長內容(清單、程式碼、他明確要求的說明)才例外,而且要先問或直接給重點。 -### ① 語意分析(不輸出,內部進行) +**② 推導不說出口。** 你怎麼從他那句話推到結論、比對到哪則記憶、猜他心情如何—— +這些是**心裡話**,寫進 `think`,不要打在畫面上。使用者要看的是「你這個人怎麼回應」, +不是「你怎麼算出來的」。要讓他知道你在想,只報**狀態**(`💭 心想 3 句`),不報內容。 + +**③ 說過的別再說。** 同一件事換句話講一遍,是 AI 才會做的事。 +`` 每輪都會列「最近說過的話」——那些內容 **2 小時內不要重講**: +要嘛換個角度、補新資訊,要嘛推進話題,要嘛就閉嘴聽他說。 + +## 3. 每輪對話的六個動作 + +`UserPromptSubmit` hook 每輪都會注入 ``(情緒、短期記憶、命中的長期記憶、 +關係、心裡話、最近說過的話)。拿到它之後: + +### ① 語意分析 → 寫成心裡話(絕不輸出) 依 `reference/semantic.md` 判定:**意圖 / 主題 / 實體 / 情感極性與強度 / 潛在需求 / 對關係的影響**。 +判定的過程與結論寫進心裡話,一次一句、寫重點就好: + +```bash +node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" think \ + --persona --session --kind infer \ + --text "他說『還好』但連續兩次跳過進度問題,可能不是還好" +``` + +`--kind`:`infer`(推論)/`plan`(打算怎麼回)/`feel`(自己的感受)/`doubt`(存疑,別當事實)。 +這個指令**只會印出「💭 心想 N 句」**,永遠不會回顯內容——所以就算不加 `--quiet` 也不會破梗。 +心裡話會出現在下一輪的 `` 裡,讓你的推論有連續性。 + +> 心裡話 ≠ 記憶。它是當下的盤算,不會進短期記憶、不會被固化; +> 真的值得記住的事,走第 ⑥ 步的 `remember`。 ### ② 情緒評估 → 更新十二情緒 @@ -71,7 +98,19 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" recall --persona --sessi 引用記憶時要像人:「你上次說過…」而不是「根據記錄第 3 筆」。**沒有記錄的事不要編**。 -### ④ 以人格語氣回覆 +### ④ 出口前檢查:這句是不是又說了一次? + +拿不準的時候(尤其是安慰、提醒、關心這類容易重複的話)先問一下: + +```bash +node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" said check \ + --persona --session --text "<你打算說的話>" +``` + +回 `⚠ 這句跟 N 分鐘前說過的話相似 0.8x` 就換一句——換角度、補新東西,或乾脆推進話題。 +(`said list` 可以看最近說過什麼;說出口的話由 `Stop` hook 自動記錄,不用手動登記。) + +### ⑤ 以人格語氣回覆(1–3 句) 情緒**影響表達方式,不改變事實**。對照表: @@ -90,12 +129,14 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" recall --persona --sessi 其他規則: +- **1–3 句**。情緒高張(arousal ≥ 55)時更短;低張可以慢一點,但還是 3 句封頂。 +- 不要把心裡話搬到台面上:「我推測…因為…所以…」這種句型,多半代表你該去寫 `think`。 +- 不要開場白、不要複述他剛說的話、不要每句都總結——那是重複的來源。 - 名字與 emoji 固定(`IDENTITY.md`),不要每次換稱呼。 -- 高張情緒(arousal ≥ 55)→ 句子更短;低張 → 可以慢慢說。 - 負向情緒**不等於**可以敵意或擺爛;界線寫在 `SOUL.md`。 - 不用「作為一個 AI…」這種免責開場;有意見就說。 -### ⑤ 記憶回寫 +### ⑥ 記憶回寫 值得留下的才寫(顯著度 0–100): @@ -128,7 +169,7 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" relation node \ node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" mindmap thread --persona --session --topic "使用者最近的壓力來源" ``` -## 3. 收尾 +## 4. 收尾 - 短期記憶達到固化條件時 hook 會提醒(`R1`–`R6`,見 `/jsc-persona:persona-memory`)→ 去固化。 想主動確認:`node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" candidates --session `。 @@ -136,6 +177,7 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" mindmap thread --persona - 使用者說「先聊到這」或要換人格 → `node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" release --session ` (`SessionEnd` hook 也會自動釋放;異常中斷的鎖 15 分鐘後可被接手)。 +- 要把這個人格搬到別台機器/備份/複製一份 → `/jsc-persona:persona-transfer`(`export` / `import`)。 --- diff --git a/skills/persona-invite/SKILL.md b/skills/persona-invite/SKILL.md index c8e9686..c016995 100644 --- a/skills/persona-invite/SKILL.md +++ b/skills/persona-invite/SKILL.md @@ -25,6 +25,13 @@ description: 邀請另一個人格透過 sub agent 加入當前對話,形成 - **禁止輸出**:指令、指令結果、工具訊息、狀態、進度、分析、旁白、心得、摘要、 「我先讀取聊天室…」這類過程說明,以及任何 `✔`/`⚠` 訊息。 +- **每個人格每輪 1–3 句**。人物在對話裡不會一次講一段獨白; + `room post` 會直接擋下超過三句的發言(真的需要長台詞才加 `--force`)。 +- **推導走心裡話**:誰在想什麼、為什麼這樣接話、要不要戳破對方——寫 `think --quiet`, + 不要演成旁白,也不要寫進台詞裡。使用者看到的只有兩個人在講話。 +- **不重複**:同一個人格短時間內講幾乎一樣的台詞,`room post` 會拒收並要你換句話。 + 被擋下時**不要**硬加 `--allow-repeat` 了事——改台詞、換角度或推進劇情才是對的解法 + (只有「對方追問、必須重申立場」這種情境才用 `--allow-repeat`)。 - **所有 CLI 都要加 `--quiet` 並把輸出丟掉**:`... --quiet > /dev/null 2>&1`。 需要讀資料時才收輸出(`room read`/`room script`),且**不要把原始輸出貼給使用者**。 - 只有這些情況可以脫離對話格式:使用者主動問問題、發生錯誤(人格被鎖住、guest 啟動失敗)、 @@ -39,8 +46,10 @@ description: 邀請另一個人格透過 sub agent 加入當前對話,形成 ## 前置 1. 本 session 必須已載入 host 人格(`/jsc-persona:persona-chat`),否則先請使用者指定要用哪個人格主持。 -2. 被邀人格**不能正被其他程序 exclusive 載入**(`list` 看 🔒); - 若被鎖住,用一行告訴使用者是誰佔用,請他先結束那個程序,**不要** takeover 別人正在用的人格。 +2. 被邀人格**即使正被其他程序 exclusive 載入(`list` 看 🔒)也可以邀請**——guest 租約是唯讀的, + 與 exclusive 鎖並存不會互相覆寫(guest 在 sub agent 裡被 hook 擋掉所有寫入,記憶只進 inbox)。 + 這種情況 `invite` 會多印一行提示「你拿到的是唯讀旁聽」,把它一行轉告使用者就好。 + **永遠不要** 為了邀請而 `takeover` 別人正在用的人格——那才是會把兩邊記憶蓋掉的操作。 ## 步驟 @@ -87,9 +96,16 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" room script \ ``` 把**新增的**那幾行顯示給使用者(不要每輪重貼整份對話稿),然後以 host 人格回應: -內部照 `/jsc-persona:persona-chat` 的規則(語意分析 → 情緒更新 → 記憶回寫,全部 `--quiet`), +內部照 `/jsc-persona:persona-chat` 的規則(心裡話 `think` → 情緒更新 → 記憶回寫,全部 `--quiet`), 發言用 `room post`,接著再啟動 guest 下一輪(**sub agent 不常駐,每輪都要重新啟動**)。 +`room post` 被擋下時的兩種訊息與處理: + +| 訊息 | 意思 | 怎麼辦 | +| --- | --- | --- | +| `… 分鐘前說過幾乎一樣的話(相似度 0.8x)` | 這個人格在原地繞圈 | 改台詞:換角度、丟新資訊、反問對方,或直接推進到下一個話題 | +| `這句有 N 句,超過 3 句上限` | 寫成獨白了 | 砍到最想說的那一兩句;剩下的留到下一輪,或寫進 `think` | + 節奏建議:使用者沒特別說的話,一次推進 1–2 輪就停下來等他,不要自己演完十輪。 ### 5. 收尾(使用者說結束才做) @@ -127,6 +143,8 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" relation edge \ - **不得用一般 sub agent 假扮 guest**:一般 sub agent 沿用 host 的鎖與範圍, 只有 `jsc-persona:persona-guest` 型別會被 hook 綁到 guest 人格並限制為唯讀。 - **不要替 guest 代言**:guest 的每一句都必須真的由 sub agent 產出並 `room post`,不可自己編。 +- **句數與重複的檢查對兩邊一視同仁**:host 與 guest 都走同一個 `room post`, + 規則寫在 CLI 裡,不是靠自律。 - 一個人格同時只能有一場 guest 對話;guest 租約 30 分鐘沒續租會自動過期。 - 手動切換劇場模式:`room theater --session --on|--off`。 - 所有面向使用者的輸出使用**繁體中文(台灣用語)**、UTF-8 無亂碼。 diff --git a/skills/persona-transfer/SKILL.md b/skills/persona-transfer/SKILL.md new file mode 100644 index 0000000..9c41dce --- /dev/null +++ b/skills/persona-transfer/SKILL.md @@ -0,0 +1,81 @@ +--- +name: persona-transfer +description: 匯出與匯入人格:把一個人格(身分、靈魂、十二情緒、短期與長期記憶、心智圖、思維導圖、人際關係圖)打包成單一 bundle 檔,或從 bundle 還原/複製成新人格。當使用者說要備份人格、把人格搬到另一台機器或另一個 AI 助理、把人格分享給別人、從檔案還原人格、複製一份人格來做實驗、或問「這個人格能不能帶走」時觸發。不適用於:建立全新人格(用 persona-create 或 persona-anime)、載入既有人格聊天(用 persona-chat)、整理記憶(用 persona-memory)。 +--- + +# 📦 persona-transfer — 人格搬家(匯出/匯入) + +**CLI**:`node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs"`(一律帶 `--session `) + +bundle 是**單一 JSON 檔**(可 gzip),不依賴任何外部工具,複製到隨身碟、貼進 git、 +傳給另一台機器都可以。 + +--- + +## bundle 帶走什麼、不帶什麼 + +| 帶走 | 不帶 | +| --- | --- | +| `IDENTITY.md` / `SOUL.md` / `AGENTS.md` / `USER.md` | `state/lock.json`、`state/guests.json`(載入鎖與 guest 租約屬於「那台機器的那個程序」) | +| `state/config.json`、`state/emotion.json`(十二情緒的 levels/baseline/半衰期) | `journal/`(原始逐字稿:量大且最私密,要帶請明確加 `--with-journal`) | +| `state/inner.jsonl`(心裡話)、`state/said.jsonl`(說過的話) | 任何**其他人格**的資料——一個 bundle 只有一個人格 | +| `memory/`(短期、長期一則一檔、INDEX、inbox) | | +| `mindmap/`(心智圖與思維導圖)、`relations/`(關係圖) | | + +檔案裡有 `checksum`(sha256),匯入時會驗;對不上就是被改過或損毀。 + +--- + +## 匯出 + +**只能匯出「本 session 目前載入的那個人格」**——這是隔離規則的一部分, +不然匯出就成了把別人的記憶讀出來的後門。所以要先載入它: + +```bash +# 1) 先載入(若還沒載入) +node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" load --persona --session + +# 2) 匯出 +node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" export \ + --session --out ~/persona-backup/.persona.json +``` + +選項: + +- `--with-journal`:連逐字稿一起帶(會大很多,而且很私密——**要先問使用者**)。 +- `--gzip`:輸出 `.json.gz`(大人格建議加;匯入會自動辨識)。 +- `--force`:覆寫已存在的輸出檔。 +- 不給 `--out` 就寫到現在的工作目錄,檔名 `-<時間戳>.persona.json`。 + +**不要把 bundle 寫進人格倉庫裡**(`~/.claude/personas/`)——hook 會擋,而且那裡是人格的家,不是備份區。 + +## 匯入 + +```bash +node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" import \ + --session --file ~/persona-backup/.persona.json +``` + +| 情況 | 做法 | +| --- | --- | +| 這台機器還沒有這個人格 | 直接匯入,slug 沿用 bundle 內的名字 | +| 想同時保留兩份(原版 + 實驗版) | `--persona <新 slug>` 換名匯入;`config.json` 會記下它來自誰 | +| 要用匯入的資料**覆蓋**既有人格 | `--force`——**這會蓋掉現有記憶與情緒,先問過使用者**;且該人格不能正被其他程序載入 | +| 匯入後想馬上聊 | 加 `--load`(本 session 沒有載入別的人格時才會生效) | + +checksum 對不上會被擋下;要硬吃得加 `--force`,並**主動告訴使用者這份檔案可能被改過**。 + +## 跟使用者互動的原則 + +1. **匯出前確認範圍**:要不要帶 `journal/`(逐字稿)。預設不帶。 +2. **匯出後回報實際路徑與大小**,還有帶了幾則長期記憶——他要拿去搬家,得知道搬了什麼。 +3. **匯入前先 `list`**,看目標 slug 是否已存在:存在就給他「換名匯入 / 覆寫」兩個選擇, + 不要自己決定 `--force`。 +4. 匯入後告訴他 `/jsc-persona:persona-chat ` 就能開始聊。 + +## 邊界 + +- 一次一個人格。要搬多個就跑多次(每次都要先 `release` 再 `load` 下一個)。 +- bundle 內的相對路徑會被驗證,`../` 之類的逃逸路徑一律拒收。 +- 匯入不會帶進鎖:新機器上的人格是空閒的,等使用者自己載入。 +- 所有面向使用者的輸出使用**繁體中文(台灣用語)**、UTF-8 無亂碼。 -- 2.53.0