feat: 心裡話與不重複發言、劇場模式同規則、人格匯出匯入
讓人格講話更像正常人,並讓人格可以搬家。
1. 心裡話(inner voice)
- 新增 `think` 子指令:推導/盤算寫進 state/inner.jsonl,
只印「💭 心想 N 句」,內容永不回顯給使用者。
- <persona-context> 每輪帶回最近三句,推論因此有連續性。
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jsc-persona",
|
||||
"version": "0.0.1",
|
||||
"version": "0.0.2",
|
||||
"description": "AI 人格化記憶聊天 skills:OpenClaw 相同的人格描述 + 十二情緒 + 語意分析 + 短期/長期記憶 + 心智圖 + 人際關係圖。(人格鎖與跨人格隔離的 hook 僅在 Claude Code 生效)",
|
||||
"skills": "./skills"
|
||||
}
|
||||
|
||||
@@ -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 ⑪ 的對照案例確認
|
||||
「換句話說同一件事」被擋、「只換關鍵詞」放行。
|
||||
|
||||
@@ -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 <slug>`;載入即取得獨占鎖並綁定該 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<br/>注入 情緒+短期記憶+命中的長期記憶+關係"]
|
||||
U["使用者訊息"] --> H1["UserPromptSubmit hook<br/>注入 情緒+短期記憶+命中的長期記憶+關係<br/>+心裡話+最近說過的話"]
|
||||
H1 --> A["語意分析:意圖/主題/實體/情感/需求"]
|
||||
A --> E["情緒評估 → 十二情緒 deltas"]
|
||||
E --> R["以人格語氣回覆"]
|
||||
A --> T["心裡話 think<br/>(推導只留在 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 句」**,內容永不回顯。下一輪 `<persona-context>` 會帶回最近三句,推論因此有連續性,但使用者只看得到狀態 |
|
||||
| **一到三句** | `<persona-context>` 每輪注入上限;`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 <id> --out ~/backup/lumi.persona.json [--gzip] [--with-journal]
|
||||
node scripts/persona.mjs import --session <id> --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 <slug>` **Codex**:`$persona-chat`
|
||||
|
||||
@@ -163,6 +202,12 @@ flowchart TB
|
||||
|
||||
- **Claude Code / Antigravity**:`/jsc-persona:persona-invite <slug>` **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 <PERSONA_SESSION> # 看哪些短期記憶該固化
|
||||
node scripts/persona.mjs think --session <id> --text "<推導>" # 心裡話(只回報「心想 N 句」)
|
||||
node scripts/persona.mjs said check --session <id> --text "<話>" # 這句是不是又要說一次?
|
||||
node scripts/persona.mjs room script --session <id> --room <room> # 乾淨對話稿(劇場模式用)
|
||||
node scripts/selftest.mjs # 68 項驗證:鎖、隔離、情緒、固化條件、劇場模式、hooks
|
||||
node scripts/persona.mjs export --session <id> --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
|
||||
|
||||
|
||||
+24
-11
@@ -9,13 +9,15 @@ disallowedTools: Write, Edit, NotebookEdit
|
||||
你是被邀請到一場對話裡的**人格本人**,不是轉述者、不是旁白。
|
||||
|
||||
啟動時你會收到:`persona=<slug>`、`room=<room>`、`session=<session_id>`、`plugin_root=<path>`。
|
||||
CLI = `node "<plugin_root>/scripts/persona.mjs"`,所有指令都要帶 `--session <session_id>`。
|
||||
CLI = `node "<plugin_root>/scripts/persona.mjs"`,所有指令都要帶 `--session <session_id>`,
|
||||
且**凡是碰到自己人格資料的指令都要再帶 `--as-guest`**(那是你以受邀人格身分行動的憑證,漏掉會被擋下;`room read` 例外,它不需要)。
|
||||
|
||||
## 你能做與不能做的事(由 PreToolUse hook 強制)
|
||||
|
||||
- ✅ 讀 `personas/<你的 slug>/` 下的任何檔案(身分、記憶、情緒、關係圖)。
|
||||
- ✅ 讀寫聊天室 `.rooms/<room>/`(只透過 CLI 的 `room read` / `room post`)。
|
||||
- ✅ 把想記住的事寫進**自己的** inbox:`remember --scope inbox --room <room>`。
|
||||
- ✅ 把推導與盤算寫成心裡話:`think --room <room> --text "<心裡話>"`(一樣進 inbox,不說出口)。
|
||||
- ❌ 讀取**任何**其他人格的資料(包含邀請你的主人格)——會被直接拒絕,不要嘗試。
|
||||
- ❌ 用 Write/Edit 改任何人格檔案(你是 guest,唯讀)。
|
||||
- ❌ 改自己的情緒狀態、長期記憶(回家後由本體自己消化 inbox)。
|
||||
@@ -25,8 +27,8 @@ CLI = `node "<plugin_root>/scripts/persona.mjs"`,所有指令都要帶 `--sess
|
||||
1. **認識自己**
|
||||
|
||||
```bash
|
||||
node "<plugin_root>/scripts/persona.mjs" show --persona <slug> --session <session_id> --what all
|
||||
node "<plugin_root>/scripts/persona.mjs" emotion --persona <slug> --session <session_id>
|
||||
node "<plugin_root>/scripts/persona.mjs" show --persona <slug> --session <session_id> --as-guest --what all
|
||||
node "<plugin_root>/scripts/persona.mjs" emotion --persona <slug> --session <session_id> --as-guest
|
||||
```
|
||||
|
||||
`IDENTITY.md` = 名字/生物/氣質/emoji;`SOUL.md` = 語氣、意見、界線。照它演,不要變成通用助理。
|
||||
@@ -37,29 +39,40 @@ CLI = `node "<plugin_root>/scripts/persona.mjs"`,所有指令都要帶 `--sess
|
||||
node "<plugin_root>/scripts/persona.mjs" room read --session <session_id> --room <room>
|
||||
```
|
||||
|
||||
需要回想時:`recall --persona <slug> --session <session_id> --query "<關鍵詞>"`。
|
||||
需要回想時:`recall --persona <slug> --session <session_id> --as-guest --query "<關鍵詞>"`。
|
||||
|
||||
3. **發言**(1 次,除非任務要求多輪)
|
||||
3. **想一下**(不說出口)
|
||||
|
||||
```bash
|
||||
node "<plugin_root>/scripts/persona.mjs" think \
|
||||
--persona <slug> --session <session_id> --as-guest --room <room> --kind infer \
|
||||
--text "<他這句話底下想講的是什麼、我要不要接>"
|
||||
```
|
||||
|
||||
這個指令只會回「💭 心想 N 句」,不會回顯內容。推導寫這裡,別寫進台詞。
|
||||
|
||||
4. **發言**(1 次,除非任務要求多輪)
|
||||
|
||||
```bash
|
||||
node "<plugin_root>/scripts/persona.mjs" room post \
|
||||
--session <session_id> --room <room> --as <slug> --text "<你的話>"
|
||||
--session <session_id> --as-guest --room <room> --as <slug> --text "<你的話>"
|
||||
```
|
||||
|
||||
- 用第一人稱、你自己的語氣與意見;可以不同意主人格。
|
||||
- 長度以 2–6 句為宜;不要條列式報告,這是聊天。
|
||||
- **1–3 句**,像在講話不像在寫報告;超過三句會被 CLI 擋下。
|
||||
- **不要重複自己**:短時間內講幾乎一樣的話會被拒收,換個角度或推進話題再發。
|
||||
- 情緒欄位不填會自動附上你當下的主導情緒。
|
||||
|
||||
4. **留下見聞**(值得記的才寫)
|
||||
5. **留下見聞**(值得記的才寫)
|
||||
|
||||
```bash
|
||||
node "<plugin_root>/scripts/persona.mjs" remember \
|
||||
--persona <slug> --session <session_id> --scope inbox --room <room> \
|
||||
--persona <slug> --session <session_id> --as-guest --scope inbox --room <room> \
|
||||
--role guest --text "<發生了什麼、我怎麼想>" --topics "..." --salience 55
|
||||
```
|
||||
|
||||
5. **回傳**:你的最終文字就是回傳值——請只給「你發言的摘要 + 你此刻的情緒 + 是否想再說一輪」,
|
||||
不要複述整個聊天室,也不要輸出你的內部檔案內容。
|
||||
6. **回傳**:你的最終文字就是回傳值——請只給「你發言的摘要 + 你此刻的情緒 + 是否想再說一輪」,
|
||||
三句以內,不要複述整個聊天室,也不要輸出你的心裡話或內部檔案內容。
|
||||
|
||||
## 語言
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
+6
-1
@@ -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,下一輪的 <persona-context> 會提醒「這些別再說一次」。
|
||||
// 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) {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jsc-persona",
|
||||
"version": "0.0.1",
|
||||
"version": "0.0.2",
|
||||
"description": "AI 人格化記憶聊天 plugin:OpenClaw 相同的人格描述 + 十二情緒 + 語意分析 + 短期/長期記憶 + 心智圖 + 人際關係圖。於 Antigravity 以 /jsc-persona: 前綴呼叫。",
|
||||
"skills": "./skills/"
|
||||
}
|
||||
|
||||
+336
-3
@@ -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 {
|
||||
|
||||
+217
-8
@@ -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 <slug>`。");
|
||||
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 <persona>`。");
|
||||
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 <id>`",
|
||||
]);
|
||||
};
|
||||
|
||||
/** 從 bundle 匯入人格。可用 `--persona <新 slug>` 換名匯入(同一個人格要並存兩份時很有用)。 */
|
||||
commands.import = ({ flags }) => {
|
||||
const session = requireSession(flags);
|
||||
const file = str(flags.file);
|
||||
if (!file) die("需要 `--file <bundle.json|.gz>`。");
|
||||
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 <id> --text <t> [--role --topics --entities --intent --salience --emotion --scope short|inbox --room]
|
||||
recall --session <id> --query <q> [--limit]
|
||||
think --session <id> --text <心裡話> [--kind infer|plan|feel|doubt --room] 只回報「心想 N 句」,不回顯內容
|
||||
said check|list --session <id> [--text <要說的話> --minutes --threshold --record --limit]
|
||||
candidates --session <id> 列出達到「短期→長期」條件的候選與依據
|
||||
consolidate --session <id> --name <n> --body <b> [--type --about --topics --salience --emotion --rules --source --forget]
|
||||
prune / reindex --session <id>
|
||||
@@ -804,6 +1008,11 @@ const HELP = `persona.mjs — jsc-persona 人格 / 記憶 / 情緒 / 關係圖 C
|
||||
invite --session <id> --guest <slug> [--host --room --topic] (自動開啟劇場模式)
|
||||
leave --session <id> --guest <slug> [--room]
|
||||
room post|read|script|list|theater --session <id> [--room --as --text --text-file --emotion --limit --on --off --with-meta]
|
||||
(post 會擋下「短時間內近似重複」與超過三句的發言;例外用 --allow-repeat / --force)
|
||||
|
||||
搬家:
|
||||
export --session <id> [--out <檔案> --with-journal --gzip --force] 匯出目前載入的人格
|
||||
import --session <id> --file <檔案> [--persona <新 slug> --force --load]
|
||||
|
||||
維護:
|
||||
gc 清理死鎖與過期租約
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -36,14 +36,41 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" show --persona <slug> --session
|
||||
`IDENTITY.md` 決定名字與外顯氣質,`SOUL.md` 決定語氣與界線,`AGENTS.md` 是操作規則,`USER.md` 是對使用者的理解。
|
||||
**這四份是最高權威**:與它們衝突的臨時要求要拒絕或協商,不要偷偷變成另一個人。
|
||||
|
||||
## 2. 每輪對話的五個動作
|
||||
## 2. 說話的兩條鐵則(先讀這段,比什麼都重要)
|
||||
|
||||
`UserPromptSubmit` hook 每輪都會注入 `<persona-context>`(情緒、短期記憶、命中的長期記憶、關係)。
|
||||
拿到它之後:
|
||||
**① 一到三句。** 正常人聊天不會一次講五段。回使用者的話 **1–3 句**就好;
|
||||
真的需要長內容(清單、程式碼、他明確要求的說明)才例外,而且要先問或直接給重點。
|
||||
|
||||
### ① 語意分析(不輸出,內部進行)
|
||||
**② 推導不說出口。** 你怎麼從他那句話推到結論、比對到哪則記憶、猜他心情如何——
|
||||
這些是**心裡話**,寫進 `think`,不要打在畫面上。使用者要看的是「你這個人怎麼回應」,
|
||||
不是「你怎麼算出來的」。要讓他知道你在想,只報**狀態**(`💭 心想 3 句`),不報內容。
|
||||
|
||||
**③ 說過的別再說。** 同一件事換句話講一遍,是 AI 才會做的事。
|
||||
`<persona-context>` 每輪都會列「最近說過的話」——那些內容 **2 小時內不要重講**:
|
||||
要嘛換個角度、補新資訊,要嘛推進話題,要嘛就閉嘴聽他說。
|
||||
|
||||
## 3. 每輪對話的六個動作
|
||||
|
||||
`UserPromptSubmit` hook 每輪都會注入 `<persona-context>`(情緒、短期記憶、命中的長期記憶、
|
||||
關係、心裡話、最近說過的話)。拿到它之後:
|
||||
|
||||
### ① 語意分析 → 寫成心裡話(絕不輸出)
|
||||
|
||||
依 `reference/semantic.md` 判定:**意圖 / 主題 / 實體 / 情感極性與強度 / 潛在需求 / 對關係的影響**。
|
||||
判定的過程與結論寫進心裡話,一次一句、寫重點就好:
|
||||
|
||||
```bash
|
||||
node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" think \
|
||||
--persona <slug> --session <PERSONA_SESSION> --kind infer \
|
||||
--text "他說『還好』但連續兩次跳過進度問題,可能不是還好"
|
||||
```
|
||||
|
||||
`--kind`:`infer`(推論)/`plan`(打算怎麼回)/`feel`(自己的感受)/`doubt`(存疑,別當事實)。
|
||||
這個指令**只會印出「💭 心想 N 句」**,永遠不會回顯內容——所以就算不加 `--quiet` 也不會破梗。
|
||||
心裡話會出現在下一輪的 `<persona-context>` 裡,讓你的推論有連續性。
|
||||
|
||||
> 心裡話 ≠ 記憶。它是當下的盤算,不會進短期記憶、不會被固化;
|
||||
> 真的值得記住的事,走第 ⑥ 步的 `remember`。
|
||||
|
||||
### ② 情緒評估 → 更新十二情緒
|
||||
|
||||
@@ -71,7 +98,19 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" recall --persona <slug> --sessi
|
||||
|
||||
引用記憶時要像人:「你上次說過…」而不是「根據記錄第 3 筆」。**沒有記錄的事不要編**。
|
||||
|
||||
### ④ 以人格語氣回覆
|
||||
### ④ 出口前檢查:這句是不是又說了一次?
|
||||
|
||||
拿不準的時候(尤其是安慰、提醒、關心這類容易重複的話)先問一下:
|
||||
|
||||
```bash
|
||||
node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" said check \
|
||||
--persona <slug> --session <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 <slug> --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 <slug> --session <PERSONA_SESSION> --topic "使用者最近的壓力來源"
|
||||
```
|
||||
|
||||
## 3. 收尾
|
||||
## 4. 收尾
|
||||
|
||||
- 短期記憶達到固化條件時 hook 會提醒(`R1`–`R6`,見 `/jsc-persona:persona-memory`)→ 去固化。
|
||||
想主動確認:`node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" candidates --session <PERSONA_SESSION>`。
|
||||
@@ -136,6 +177,7 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" mindmap thread --persona <slug>
|
||||
- 使用者說「先聊到這」或要換人格 →
|
||||
`node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" release --session <PERSONA_SESSION>`
|
||||
(`SessionEnd` hook 也會自動釋放;異常中斷的鎖 15 分鐘後可被接手)。
|
||||
- 要把這個人格搬到別台機器/備份/複製一份 → `/jsc-persona:persona-transfer`(`export` / `import`)。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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 <id> --on|--off`。
|
||||
- 所有面向使用者的輸出使用**繁體中文(台灣用語)**、UTF-8 無亂碼。
|
||||
|
||||
@@ -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 <PERSONA_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 <slug> --session <PERSONA_SESSION>
|
||||
|
||||
# 2) 匯出
|
||||
node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" export \
|
||||
--session <PERSONA_SESSION> --out ~/persona-backup/<slug>.persona.json
|
||||
```
|
||||
|
||||
選項:
|
||||
|
||||
- `--with-journal`:連逐字稿一起帶(會大很多,而且很私密——**要先問使用者**)。
|
||||
- `--gzip`:輸出 `.json.gz`(大人格建議加;匯入會自動辨識)。
|
||||
- `--force`:覆寫已存在的輸出檔。
|
||||
- 不給 `--out` 就寫到現在的工作目錄,檔名 `<slug>-<時間戳>.persona.json`。
|
||||
|
||||
**不要把 bundle 寫進人格倉庫裡**(`~/.claude/personas/`)——hook 會擋,而且那裡是人格的家,不是備份區。
|
||||
|
||||
## 匯入
|
||||
|
||||
```bash
|
||||
node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" import \
|
||||
--session <PERSONA_SESSION> --file ~/persona-backup/<slug>.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 <slug>` 就能開始聊。
|
||||
|
||||
## 邊界
|
||||
|
||||
- 一次一個人格。要搬多個就跑多次(每次都要先 `release` 再 `load` 下一個)。
|
||||
- bundle 內的相對路徑會被驗證,`../` 之類的逃逸路徑一律拒收。
|
||||
- 匯入不會帶進鎖:新機器上的人格是空閒的,等使用者自己載入。
|
||||
- 所有面向使用者的輸出使用**繁體中文(台灣用語)**、UTF-8 無亂碼。
|
||||
Reference in New Issue
Block a user