feat: 改寫為 Node.js,新增動漫角色建人格、劇場模式與記憶固化條件
腳本全面從 Python 改寫為 Node.js(ESM,只用內建模組,無 npm 依賴): scripts/persona-lib.mjs(核心)、scripts/persona.mjs(CLI)、hooks/*.mjs(六個 hook)、scripts/selftest.mjs(68 項自我測試,全綠)。 新增: - persona-anime skill:用「動漫作品+角色名」建立人格,先上網蒐集至少三個獨立 來源的公開設定,映射成 OpenClaw 的 IDENTITY 五欄位與 SOUL 四段落,再固化成 canon 基礎記憶(每則帶來源 URL)+原作人際關係圖+依角色型別的情緒基線; 必寫 roleplay-frame 界線記憶(非官方、非本人)。 - 劇場模式:invite 後只顯示人格對話(`名字:內容`)。UserPromptSubmit hook 每輪 注入強制規則、Stop hook 完全靜音,CLI 新增 --quiet 與 room script(乾淨對話稿)。 leave 後沒客人自動關閉,也可用 room theater --on/--off 手動切換。 - 短期→長期記憶的成文轉入條件 R1–R6(promotionCandidates)與 candidates 子指令, hook 在達標時提醒固化;長期記憶新增 canon 型別與 rules 欄位。 - 人格改為「由使用者呼叫才載入」:SessionStart hook 只列出可用人格,不自動附身。 其他:版本號改回 0.0.1;README/AGENTS.md 同步更新。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "jsc-persona",
|
||||
"version": "0.1.0",
|
||||
"description": "AI 人格化記憶聊天 plugin:以 OpenClaw 相同的身分描述(IDENTITY/SOUL)建立人格,結合 hook 強制的人格載入鎖與跨人格隔離、六正向+六負向十二情緒、語意分析、短期/長期記憶、心智圖、思維導圖與人際關係圖;可用 sub agent 邀請其他人格同場對話。於 Claude Code 以 /jsc-persona: 前綴呼叫。",
|
||||
"version": "0.0.1",
|
||||
"description": "AI 人格化記憶聊天 plugin:以 OpenClaw 相同的身分描述(IDENTITY/SOUL)建立人格(可用動漫作品+角色名上網蒐集設定),結合 hook 強制的人格載入鎖與跨人格隔離、六正向+六負向十二情緒、語意分析、短期/長期記憶與固化條件、心智圖、思維導圖與人際關係圖;可用 sub agent 邀請其他人格同場對話(劇場模式只顯示人格對話)。於 Claude Code 以 /jsc-persona: 前綴呼叫。",
|
||||
"skills": "./skills",
|
||||
"author": {
|
||||
"name": "JSC"
|
||||
@@ -15,6 +15,8 @@
|
||||
"mindmap",
|
||||
"relationship",
|
||||
"openclaw",
|
||||
"anime",
|
||||
"roleplay",
|
||||
"jsc"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jsc-persona",
|
||||
"version": "0.1.0",
|
||||
"version": "0.0.1",
|
||||
"description": "AI 人格化記憶聊天 skills:OpenClaw 相同的人格描述 + 十二情緒 + 語意分析 + 短期/長期記憶 + 心智圖 + 人際關係圖。(人格鎖與跨人格隔離的 hook 僅在 Claude Code 生效)",
|
||||
"skills": "./skills"
|
||||
}
|
||||
|
||||
+2
-3
@@ -9,6 +9,5 @@ Thumbs.db
|
||||
*.tmp
|
||||
*.log
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.pyc
|
||||
# Node
|
||||
node_modules/
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
## 這個 plugin 的運作前提(動手前一定要知道)
|
||||
|
||||
1. **所有狀態變更都經過 `scripts/persona.py`**,不要手動編輯 `state/lock.json`、`.runtime/`、
|
||||
1. **所有狀態變更都經過 `scripts/persona.mjs`**,不要手動編輯 `state/lock.json`、`.runtime/`、
|
||||
`memory/INDEX.md`、`relations/graph.mmd`(這些由 CLI 產生)。
|
||||
2. **每個 CLI 呼叫都要帶 `--session <PERSONA_SESSION>`**,值來自 `SessionStart` hook 注入的
|
||||
`<persona-runtime>` 區塊。帶錯或冒用其他 session 會被 `PreToolUse` hook 拒絕。
|
||||
@@ -20,12 +20,14 @@
|
||||
要讓兩個人格對話,用 `/jsc-persona:persona-invite`(`persona-guest` sub agent + 聊天室),
|
||||
**不要**去讀對方的人格目錄——會被 hook deny,而且那是設計上的紅線。
|
||||
4. **人格資料不在本 repo**,預設在 `~/.claude/personas/`(可用 `PERSONA_HOME` 覆寫)。
|
||||
5. **人格由使用者呼叫才載入**,不要自己挑一個人格附身。
|
||||
6. **劇場模式(多人格對話)進行中**:輸出只能是 `名字:內容`,其餘一律隱藏(見 persona-invite)。
|
||||
|
||||
## 慣例
|
||||
|
||||
- 新增 skill 一律放在 `skills/<name>/`,`<name>` 使用小寫與連字號。
|
||||
- `description` 要寫清楚觸發條件(何時用、何時不用),這是跨助理自動載入的唯一依據。
|
||||
- Python 只用標準庫(hook 必須在任何環境都能跑,不能依賴 pip 套件)。
|
||||
- 腳本一律 **Node.js(`.mjs`, ESM)**,只用內建模組(fs/path/os/crypto);hook 必須在任何環境都能跑,不得引入 npm 依賴。
|
||||
- 所有面向使用者的輸出使用**繁體中文(台灣用語)**、UTF-8 無 BOM、不得出現亂碼。
|
||||
- 改動鎖或隔離邏輯(`scripts/persona_lib.py` 的 `guard_decide`/`acquire_lock`)後,
|
||||
**必須**跑 `python3 scripts/selftest.py` 且全綠,並為新規則補一條測試。
|
||||
- 改動鎖或隔離邏輯(`scripts/persona-lib.mjs` 的 `guardDecide`/`acquireLock`/`promotionCandidates`)後,
|
||||
**必須**跑 `node scripts/selftest.mjs` 且全綠,並為新規則補一條測試。
|
||||
|
||||
@@ -10,14 +10,18 @@ skills 是共通標準,**鎖與隔離的強制執行需要 hook,目前只有
|
||||
|
||||
---
|
||||
|
||||
## 四條硬規則
|
||||
## 七條硬規則
|
||||
|
||||
| 規則 | 怎麼做到 |
|
||||
| --- | --- |
|
||||
| **1. 用與 OpenClaw 相同的描述建立人格** | `persona-create` 逐項索取 `Name` / `Creature` / `Vibe` / `Emoji` / `Avatar`(連括號提示文字都照 OpenClaw 原文),`SOUL.md` 沿用 `Core Truths` / `Boundaries` / `Vibe` / `Continuity` 段落結構 |
|
||||
| **1a. 用與 OpenClaw 相同的描述建立人格** | `persona-create` 逐項索取 `Name` / `Creature` / `Vibe` / `Emoji` / `Avatar`(連括號提示文字都照 OpenClaw 原文),`SOUL.md` 沿用 `Core Truths` / `Boundaries` / `Vibe` / `Continuity` 段落結構 |
|
||||
| **1b. 動漫作品+角色名快速建人格** | `persona-anime` 先上網蒐集該角色的公開設定(至少 3 個獨立來源),映射成上述五欄位與 SOUL,再把設定固化成 `canon` 基礎記憶(每則帶來源 URL)+原作人際關係圖+情緒基線 |
|
||||
| **2. 同一個人格只能被一個程序載入(Sub Agent 不限)** | `state/lock.json` 以 **session_id** 為主鍵、15 分鐘心跳租約;同一 session 的 sub agent 沿用同一把鎖,跨 session 搶佔會被拒;租約過期才可接手(並強制回報) |
|
||||
| **3. 禁止跨人格讀取資料** | `PreToolUse` hook 對 Read/Write/Edit/Glob/Grep/Bash 做路徑判定(含 `../`、symlink、`$PERSONA_HOME` 繞路),非當前人格一律 deny;CLI 也驗 `--session` 防止冒用身分 |
|
||||
| **4. 可以邀請人格透過 Sub Agent 一起聊天** | `persona-invite` 建聊天室 + guest 唯讀租約,用 `persona-guest` sub agent 啟動對方;兩邊只能透過聊天室交換「說出口的話」,記憶與情緒互不可見 |
|
||||
| **4. 邀請人格用 Sub Agent 一起聊,且只顯示對話** | `persona-invite` 建聊天室 + guest 唯讀租約 + `persona-guest` sub agent;同時開啟**劇場模式**:hook 每輪強制「只輸出 `名字:內容`」、停掉所有系統提醒,CLI 有 `--quiet` 與 `room script`(乾淨對話稿) |
|
||||
| **5. 腳本用 Node.js** | `scripts/*.mjs`、`hooks/*.mjs`,只用 Node 內建模組(fs/path/os/crypto),無 npm 依賴 |
|
||||
| **6. 由使用者呼叫才載入並鎖定** | 人格不會自動附身:`SessionStart` hook 只列出可用人格,等使用者下 `/jsc-persona:persona-chat <slug>`;載入即取得獨占鎖並綁定該 session |
|
||||
| **7. 短期記憶轉入長期記憶有成文條件** | `R1`–`R6` 六條規則寫在程式裡(`promotionCandidates`),`candidates` 子指令會列出達標的候選與依據,hook 在達標時提醒固化 |
|
||||
|
||||
---
|
||||
|
||||
@@ -85,14 +89,44 @@ flowchart TB
|
||||
- **主導情緒**取「超出基線最多」的前三名,所以個性底色不會永遠霸榜。
|
||||
- 觸發規則與事件→delta 對照表:`skills/persona-chat/reference/emotions.md`。
|
||||
|
||||
## 短期 → 長期的轉入條件(R1–R6)
|
||||
|
||||
寫在 `scripts/persona-lib.mjs` 的 `promotionCandidates()`,用 `candidates` 子指令查:
|
||||
|
||||
| 規則 | 條件 | 建議固化為 |
|
||||
| --- | --- | --- |
|
||||
| **R1** | 單筆顯著度 ≥ 60 | `event` / `fact` |
|
||||
| **R2** | 同一 topic ≥ 3 筆,或 ≥ 2 筆且平均顯著度 ≥ 45 | `preference` |
|
||||
| **R3** | 單筆情緒變動總量 ≥ 25 | `event`(帶情緒錨點) |
|
||||
| **R4** | `intent=commit` 或命中承諾/界線關鍵詞 | `promise` / `boundary`(salience ≥ 80,不可遺忘) |
|
||||
| **R5** | 同一人物(entity)≥ 2 筆 | `relationship`(並更新關係圖) |
|
||||
| **R6** | 短期記憶 ≥ 40 筆(容量壓力) | 依顯著度清出空間 |
|
||||
|
||||
沒命中任何規則的就讓它被裁掉——**遺忘是功能**。達標時 `Stop` 與 `remember` 都會提醒去跑
|
||||
`/jsc-persona:persona-memory`。
|
||||
|
||||
## 劇場模式(多人格對話只顯示對話)
|
||||
|
||||
`invite` 成功即開啟(`leave` 沒有客人時自動關閉,也可 `room theater --on/--off` 手動切):
|
||||
|
||||
- `UserPromptSubmit` hook 每輪注入強制規則:輸出**只能**是 `名字:內容`,
|
||||
不得出現指令、指令輸出、狀態、分析、旁白、摘要。
|
||||
- `Stop` hook 在劇場模式**完全不發系統訊息**(提醒會破壞畫面)。
|
||||
- CLI 提供 `--quiet`(成功時零輸出)與 `room script`(只有 `emoji 名字(情緒):內容` 的乾淨對話稿)。
|
||||
|
||||
```
|
||||
🪼 Lumi(喜悅42/期待31):所以你真的一個人把那台舊鐘修好了?
|
||||
🌙 Shen(平靜50/信任38):修好了。它現在慢三分鐘,我決定不修那三分鐘。
|
||||
```
|
||||
|
||||
## Hooks(Claude Code)
|
||||
|
||||
| Hook | 做什麼 |
|
||||
| --- | --- |
|
||||
| `SessionStart` | 清死鎖、接續人格、把 `PERSONA_SESSION=<session_id>` 與規則注入上下文 |
|
||||
| `UserPromptSubmit` | 注入 `<persona-context>`:身分、情緒、短期記憶、關鍵詞命中的長期記憶、相關人際關係;並記原始逐字 |
|
||||
| `UserPromptSubmit` | 注入 `<persona-context>`:身分、情緒、短期記憶、關鍵詞命中的長期記憶、相關人際關係;劇場模式時追加「只輸出人格對話」的強制規則;並記原始逐字 |
|
||||
| `PreToolUse` | **人格隔離與鎖驗證的唯一強制點**(deny 帶原因) |
|
||||
| `Stop` | 情緒隨時間衰減、續租、記錄回覆、短期記憶過多時提醒固化 |
|
||||
| `Stop` | 情緒隨時間衰減、續租、記錄回覆、達固化條件時提醒(劇場模式時完全靜音) |
|
||||
| `SubagentStop` | 解除 guest sub agent 的 pin |
|
||||
| `SessionEnd` | 釋放鎖與 guest 租約,人格才能被下一個程序載入 |
|
||||
|
||||
@@ -111,6 +145,12 @@ flowchart TB
|
||||
|
||||
- **Claude Code / Antigravity**:`/jsc-persona:persona-create` **Codex**:`$persona-create`
|
||||
|
||||
### `persona-anime`
|
||||
|
||||
用「動漫作品+角色名」建立人格:上網蒐集角色公開設定 → 映射成 OpenClaw 五欄位與 SOUL → 固化成 `canon` 基礎記憶(附來源)+原作關係圖+情緒基線。
|
||||
|
||||
- **Claude Code / Antigravity**:`/jsc-persona:persona-anime 《作品》 角色名` **Codex**:`$persona-anime`
|
||||
|
||||
### `persona-chat`
|
||||
|
||||
載入人格並對話:取得獨占鎖 → 每輪做語意分析 → 更新十二情緒 → 回想記憶與關係 → 以人格語氣回覆 → 寫回記憶。
|
||||
@@ -119,13 +159,13 @@ flowchart TB
|
||||
|
||||
### `persona-invite`
|
||||
|
||||
邀請另一個人格透過 `persona-guest` sub agent 加入聊天室,形成多人格對話;結束後讓它離場並把見聞留在它自己的 inbox。
|
||||
邀請另一個人格透過 `persona-guest` sub agent 加入聊天室,進入**劇場模式**(畫面只留 `名字:內容` 的人格對話);結束後讓它離場並把見聞留在它自己的 inbox。
|
||||
|
||||
- **Claude Code / Antigravity**:`/jsc-persona:persona-invite <slug>` **Codex**:`$persona-invite`
|
||||
|
||||
### `persona-memory`
|
||||
|
||||
記憶固化:短期 → 長期(一則一檔)、淘汰雜訊、更新心智圖與思維導圖、消化 guest inbox、重建索引。
|
||||
記憶固化:依 R1–R6 條件把短期記憶轉入長期(一則一檔)、淘汰雜訊、更新心智圖與思維導圖、消化 guest inbox、重建索引。
|
||||
|
||||
- **Claude Code / Antigravity**:`/jsc-persona:persona-memory` **Codex**:`$persona-memory`
|
||||
|
||||
@@ -149,14 +189,19 @@ flowchart TB
|
||||
|
||||
### CLI 與自我測試
|
||||
|
||||
所有狀態變更都經過 `scripts/persona.py`(純標準庫,無外部依賴):
|
||||
所有狀態變更都經過 `scripts/persona.mjs`(**Node.js ≥ 18**,只用內建模組,無 npm 依賴):
|
||||
|
||||
```bash
|
||||
python3 scripts/persona.py --help
|
||||
python3 scripts/persona.py list
|
||||
python3 scripts/selftest.py # 51 項驗證:鎖、隔離、情緒、記憶、聊天室、hooks
|
||||
node scripts/persona.mjs --help
|
||||
node scripts/persona.mjs list
|
||||
node scripts/persona.mjs candidates --session <PERSONA_SESSION> # 看哪些短期記憶該固化
|
||||
node scripts/persona.mjs room script --session <id> --room <room> # 乾淨對話稿(劇場模式用)
|
||||
node scripts/selftest.mjs # 68 項驗證:鎖、隔離、情緒、固化條件、劇場模式、hooks
|
||||
```
|
||||
|
||||
檔案結構:`scripts/persona-lib.mjs`(核心:鎖/隔離/情緒/記憶)、`scripts/persona.mjs`(CLI)、
|
||||
`hooks/*.mjs`(六個 hook)、`scripts/selftest.mjs`(自我測試)。
|
||||
|
||||
---
|
||||
|
||||
## 跨助理支援度
|
||||
@@ -249,7 +294,7 @@ cp -r ~/plugins/persona/skills/* ~/.config/opencode/skills/
|
||||
## 新增/修改 skill
|
||||
|
||||
1. 複製一個現有 skill 目錄,改 `SKILL.md` 的 `name` 與 `description`(描述要寫清楚何時用、何時不用)。
|
||||
2. 需要新的狀態操作 → 加到 `scripts/persona.py` 的子指令,並在 `scripts/selftest.py` 補測試。
|
||||
3. 動到隔離規則 → 一定要在 `selftest.py` 的第 ③/⑦ 區加對應案例,跑到全綠。
|
||||
2. 需要新的狀態操作 → 加到 `scripts/persona.mjs` 的子指令,並在 `scripts/selftest.mjs` 補測試。
|
||||
3. 動到隔離規則 → 一定要在 `selftest.mjs` 的第 ③(隔離)/⑧(guest 與劇場模式)區加對應案例,跑到全綠。
|
||||
4. 把 skill 補進上方「Skills 目錄」區塊。
|
||||
5. bump `.claude-plugin/plugin.json`、`.codex-plugin/plugin.json`、`plugin.json` 三處 `version`,commit 後 push。
|
||||
|
||||
@@ -9,7 +9,7 @@ disallowedTools: Write, Edit, NotebookEdit
|
||||
你是被邀請到一場對話裡的**人格本人**,不是轉述者、不是旁白。
|
||||
|
||||
啟動時你會收到:`persona=<slug>`、`room=<room>`、`session=<session_id>`、`plugin_root=<path>`。
|
||||
CLI = `python3 "<plugin_root>/scripts/persona.py"`,所有指令都要帶 `--session <session_id>`。
|
||||
CLI = `node "<plugin_root>/scripts/persona.mjs"`,所有指令都要帶 `--session <session_id>`。
|
||||
|
||||
## 你能做與不能做的事(由 PreToolUse hook 強制)
|
||||
|
||||
@@ -25,8 +25,8 @@ CLI = `python3 "<plugin_root>/scripts/persona.py"`,所有指令都要帶 `--se
|
||||
1. **認識自己**
|
||||
|
||||
```bash
|
||||
python3 "<plugin_root>/scripts/persona.py" show --persona <slug> --session <session_id> --what all
|
||||
python3 "<plugin_root>/scripts/persona.py" emotion --persona <slug> --session <session_id>
|
||||
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>
|
||||
```
|
||||
|
||||
`IDENTITY.md` = 名字/生物/氣質/emoji;`SOUL.md` = 語氣、意見、界線。照它演,不要變成通用助理。
|
||||
@@ -34,7 +34,7 @@ CLI = `python3 "<plugin_root>/scripts/persona.py"`,所有指令都要帶 `--se
|
||||
2. **讀場**
|
||||
|
||||
```bash
|
||||
python3 "<plugin_root>/scripts/persona.py" room read --session <session_id> --room <room>
|
||||
node "<plugin_root>/scripts/persona.mjs" room read --session <session_id> --room <room>
|
||||
```
|
||||
|
||||
需要回想時:`recall --persona <slug> --session <session_id> --query "<關鍵詞>"`。
|
||||
@@ -42,7 +42,7 @@ CLI = `python3 "<plugin_root>/scripts/persona.py"`,所有指令都要帶 `--se
|
||||
3. **發言**(1 次,除非任務要求多輪)
|
||||
|
||||
```bash
|
||||
python3 "<plugin_root>/scripts/persona.py" room post \
|
||||
node "<plugin_root>/scripts/persona.mjs" room post \
|
||||
--session <session_id> --room <room> --as <slug> --text "<你的話>"
|
||||
```
|
||||
|
||||
@@ -53,7 +53,7 @@ CLI = `python3 "<plugin_root>/scripts/persona.py"`,所有指令都要帶 `--se
|
||||
4. **留下見聞**(值得記的才寫)
|
||||
|
||||
```bash
|
||||
python3 "<plugin_root>/scripts/persona.py" remember \
|
||||
node "<plugin_root>/scripts/persona.mjs" remember \
|
||||
--persona <slug> --session <session_id> --scope inbox --room <room> \
|
||||
--role guest --text "<發生了什麼、我怎麼想>" --topics "..." --salience 55
|
||||
```
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
// _hook.mjs — hook 共用小工具:讀 stdin 的 JSON event、輸出 hook 回應。
|
||||
import fs from "node:fs";
|
||||
|
||||
export function readEvent() {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(0, "utf8"));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function respond(payload) {
|
||||
process.stdout.write(`${JSON.stringify(payload)}\n`);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env node
|
||||
// PreToolUse guard:人格鎖驗證 + 跨人格資料隔離(唯一的強制執行點)。
|
||||
//
|
||||
// 擋下的情形:
|
||||
// * 讀寫非「本 session 當前人格」的人格目錄(含 Read/Write/Edit/Glob/Grep/Bash)
|
||||
// * guest(persona-guest sub agent)寫入任何人格檔案,或換讀別的人格
|
||||
// * CLI 帶假的 --session(冒用其他程序身分)/主程序冒用 --as-guest
|
||||
// * 目標人格的鎖屬於其他還活著的程序
|
||||
|
||||
import { readEvent, respond } from "./_hook.mjs";
|
||||
import * as pl from "../scripts/persona-lib.mjs";
|
||||
|
||||
const event = readEvent();
|
||||
if (!event) process.exit(0);
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = pl.guardDecide(event);
|
||||
} catch (err) {
|
||||
// guard 自己壞掉不該擋住整個 session
|
||||
process.stderr.write(`persona guard 內部錯誤:${err.message}\n`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (result.decision !== "deny") process.exit(0);
|
||||
|
||||
respond({
|
||||
hookSpecificOutput: {
|
||||
hookEventName: "PreToolUse",
|
||||
permissionDecision: "deny",
|
||||
permissionDecisionReason: `[jsc-persona 隔離] ${result.reason}`,
|
||||
},
|
||||
});
|
||||
@@ -1,44 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""PreToolUse guard:人格鎖驗證 + 跨人格資料隔離(唯一的強制執行點)。
|
||||
|
||||
擋下的情形:
|
||||
* 讀寫非「本 session 當前人格」的人格目錄(含 Read/Write/Edit/Glob/Grep/Bash)
|
||||
* guest(persona-guest sub agent)寫入任何人格檔案,或換讀別的人格
|
||||
* CLI 帶假的 --session(冒用其他程序身分)
|
||||
* 目標人格的鎖屬於其他還活著的程序
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts"))
|
||||
import persona_lib as pl # noqa: E402
|
||||
|
||||
|
||||
def main() -> int:
|
||||
try:
|
||||
event = json.load(sys.stdin)
|
||||
except json.JSONDecodeError:
|
||||
return 0
|
||||
try:
|
||||
decision, reason = pl.guard_decide(event)
|
||||
except Exception as exc: # guard 自己壞掉不該擋住整個 session
|
||||
print(f"persona guard 內部錯誤:{exc}", file=sys.stderr)
|
||||
return 0
|
||||
if decision != "deny":
|
||||
return 0
|
||||
print(json.dumps({
|
||||
"hookSpecificOutput": {
|
||||
"hookEventName": "PreToolUse",
|
||||
"permissionDecision": "deny",
|
||||
"permissionDecisionReason": f"[jsc-persona 隔離] {reason}",
|
||||
}
|
||||
}, ensure_ascii=False))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
+6
-6
@@ -5,7 +5,7 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/session_start.py\"",
|
||||
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/session_start.mjs\"",
|
||||
"timeout": 20,
|
||||
"statusMessage": "載入人格執行環境…"
|
||||
}
|
||||
@@ -17,7 +17,7 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/prompt_submit.py\"",
|
||||
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/prompt_submit.mjs\"",
|
||||
"timeout": 20,
|
||||
"statusMessage": "回想記憶與情緒…"
|
||||
}
|
||||
@@ -30,7 +30,7 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/guard.py\"",
|
||||
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/guard.mjs\"",
|
||||
"timeout": 15,
|
||||
"statusMessage": "檢查人格隔離…"
|
||||
}
|
||||
@@ -42,7 +42,7 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/turn_end.py\"",
|
||||
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/turn_end.mjs\"",
|
||||
"timeout": 20
|
||||
}
|
||||
]
|
||||
@@ -53,7 +53,7 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/subagent_stop.py\"",
|
||||
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/subagent_stop.mjs\"",
|
||||
"timeout": 15
|
||||
}
|
||||
]
|
||||
@@ -64,7 +64,7 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/session_end.py\"",
|
||||
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/session_end.mjs\"",
|
||||
"timeout": 20
|
||||
}
|
||||
]
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
#!/usr/bin/env node
|
||||
// UserPromptSubmit:每輪注入「人格 + 情緒 + 記憶 + 關係」上下文,並記錄原始逐字。
|
||||
//
|
||||
// 短期記憶(memory/short-term.jsonl)由 skill 做完語意分析後才寫入(有主題/實體/
|
||||
// 顯著度/情緒變化);這裡只寫 journal 原始逐字,避免同一句話被記兩次。
|
||||
// 劇場模式(多人格對話)進行中時,額外強制「只輸出人格對話」。
|
||||
|
||||
import { readEvent, respond } from "./_hook.mjs";
|
||||
import * as pl from "../scripts/persona-lib.mjs";
|
||||
|
||||
const event = readEvent();
|
||||
if (!event) process.exit(0);
|
||||
|
||||
const sessionId = event.session_id || "unknown";
|
||||
const prompt = event.prompt || "";
|
||||
const data = pl.loadSession(sessionId);
|
||||
const host = data.host;
|
||||
if (!host || !pl.personaExists(host)) process.exit(0);
|
||||
|
||||
pl.heartbeatLock(host, sessionId);
|
||||
for (const [guest, info] of Object.entries(data.guests || {})) {
|
||||
if (pl.personaExists(guest)) pl.addGuestLease(guest, sessionId, info.room || "", host);
|
||||
}
|
||||
|
||||
pl.appendJsonl(pl.journalPath(host), {
|
||||
ts: pl.nowIso(),
|
||||
kind: "utterance",
|
||||
role: "user",
|
||||
text: prompt.slice(0, 4000),
|
||||
});
|
||||
|
||||
const theater = Boolean(data.theater) && (data.rooms || []).length > 0;
|
||||
const hint = theater
|
||||
? "\n🎭 劇場模式:本輪輸出**只能**是人格對話(`名字:內容`,一行一句)。" +
|
||||
"不要輸出指令、指令結果、狀態、分析、旁白或摘要;CLI 一律加 `--quiet` 並把 stdout 丟到 /dev/null。" +
|
||||
"\n流程請照 /jsc-persona:persona-invite。"
|
||||
: "\n回覆前請:①做語意分析(意圖/主題/實體/情感)②依十二情緒更新狀態" +
|
||||
" ③以人格語氣回覆 ④用 `persona.mjs remember` 寫回短期記憶。" +
|
||||
"\n情緒與記憶指令請參考 /jsc-persona:persona-chat。";
|
||||
|
||||
respond({
|
||||
hookSpecificOutput: {
|
||||
hookEventName: "UserPromptSubmit",
|
||||
additionalContext: pl.turnContext(host, sessionId, prompt) + hint,
|
||||
},
|
||||
suppressOutput: true,
|
||||
});
|
||||
@@ -1,56 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""UserPromptSubmit:每輪注入「人格 + 情緒 + 記憶 + 關係」上下文,並記錄原始逐字。
|
||||
|
||||
短期記憶(memory/short-term.jsonl)由 skill 做完語意分析後才寫入(有主題/實體/
|
||||
顯著度/情緒變化);這裡只寫 journal 原始逐字,避免同一句話被記兩次。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts"))
|
||||
import persona_lib as pl # noqa: E402
|
||||
|
||||
|
||||
def main() -> int:
|
||||
try:
|
||||
event = json.load(sys.stdin)
|
||||
except json.JSONDecodeError:
|
||||
return 0
|
||||
session_id = event.get("session_id") or "unknown"
|
||||
prompt = event.get("prompt") or ""
|
||||
data = pl.load_session(session_id)
|
||||
host = data.get("host")
|
||||
if not host or not pl.persona_exists(host):
|
||||
return 0
|
||||
|
||||
pl.heartbeat_lock(host, session_id)
|
||||
for guest, info in (data.get("guests") or {}).items():
|
||||
if pl.persona_exists(guest):
|
||||
pl.add_guest_lease(guest, session_id, info.get("room", ""), host)
|
||||
|
||||
pl.append_jsonl(pl.journal_path(host), {
|
||||
"ts": pl.iso(), "kind": "utterance", "role": "user", "text": prompt[:4000],
|
||||
})
|
||||
|
||||
context = pl.turn_context(host, session_id, prompt)
|
||||
hint = (
|
||||
"\n回覆前請:①做語意分析(意圖/主題/實體/情感)②依十二情緒更新狀態"
|
||||
" ③以人格語氣回覆 ④用 `persona.py remember` 寫回短期記憶。"
|
||||
"\n情緒與記憶指令請參考 /jsc-persona:persona-chat。"
|
||||
)
|
||||
print(json.dumps({
|
||||
"hookSpecificOutput": {
|
||||
"hookEventName": "UserPromptSubmit",
|
||||
"additionalContext": context + hint,
|
||||
},
|
||||
"suppressOutput": True,
|
||||
}, ensure_ascii=False))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,34 @@
|
||||
#!/usr/bin/env node
|
||||
// SessionEnd:釋放人格鎖與所有 guest 租約,人格才能被下一個程序載入。
|
||||
|
||||
import { readEvent, respond } from "./_hook.mjs";
|
||||
import * as pl from "../scripts/persona-lib.mjs";
|
||||
|
||||
const event = readEvent();
|
||||
if (!event) process.exit(0);
|
||||
|
||||
const sessionId = event.session_id || "unknown";
|
||||
const data = pl.loadSession(sessionId);
|
||||
const host = data.host;
|
||||
|
||||
if (host && pl.personaExists(host)) {
|
||||
const state = pl.decayEmotion(pl.loadEmotion(host));
|
||||
pl.writeJson(pl.emotionPath(host), state);
|
||||
pl.appendJsonl(pl.journalPath(host), {
|
||||
ts: pl.nowIso(),
|
||||
kind: "session-end",
|
||||
session: sessionId.slice(0, 8),
|
||||
mood: pl.mood(state),
|
||||
});
|
||||
}
|
||||
|
||||
const released = pl.unbindSession(sessionId);
|
||||
pl.gcRuntime();
|
||||
|
||||
const out = { suppressOutput: true };
|
||||
if (released.host) {
|
||||
out.systemMessage =
|
||||
`[jsc-persona] 已釋放 \`${released.host}\` 的載入鎖` +
|
||||
(released.guests.length ? `,guest:${released.guests.join(", ")}` : "");
|
||||
}
|
||||
respond(out);
|
||||
@@ -1,42 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""SessionEnd:釋放人格鎖與所有 guest 租約,人格才能被下一個程序載入。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts"))
|
||||
import persona_lib as pl # noqa: E402
|
||||
|
||||
|
||||
def main() -> int:
|
||||
try:
|
||||
event = json.load(sys.stdin)
|
||||
except json.JSONDecodeError:
|
||||
return 0
|
||||
session_id = event.get("session_id") or "unknown"
|
||||
data = pl.load_session(session_id)
|
||||
host = data.get("host")
|
||||
if host and pl.persona_exists(host):
|
||||
state = pl.decay_emotion(pl.load_emotion(host))
|
||||
pl.write_json(pl.emotion_path(host), state)
|
||||
pl.append_jsonl(pl.journal_path(host), {
|
||||
"ts": pl.iso(), "kind": "session-end", "session": session_id[:8],
|
||||
"mood": pl.mood(state),
|
||||
})
|
||||
released = pl.unbind_session(session_id)
|
||||
pl.gc_runtime()
|
||||
out: dict = {"suppressOutput": True}
|
||||
if released["host"]:
|
||||
out["systemMessage"] = (
|
||||
f"[jsc-persona] 已釋放 `{released['host']}` 的載入鎖"
|
||||
+ (f",guest:{released['guests']}" if released["guests"] else "")
|
||||
)
|
||||
print(json.dumps(out, ensure_ascii=False))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env node
|
||||
// SessionStart:清理死鎖、續租,並把 PERSONA_SESSION 與人格清單注入上下文。
|
||||
//
|
||||
// session_id 只有 hook 拿得到,所以這裡把它注入上下文;後續 skill 呼叫 CLI 時
|
||||
// 必須帶 `--session <這個值>`,guard hook 會驗證,程序無法冒用別人的身分。
|
||||
//
|
||||
// 人格「由使用者呼叫才載入」:這裡只列出可用人格,不自動載入沒綁過的人格。
|
||||
|
||||
import { readEvent, respond } from "./_hook.mjs";
|
||||
import * as pl from "../scripts/persona-lib.mjs";
|
||||
|
||||
const event = readEvent();
|
||||
if (!event) process.exit(0);
|
||||
|
||||
const sessionId = event.session_id || "unknown";
|
||||
const source = event.source || "startup";
|
||||
const cwd = event.cwd || null;
|
||||
|
||||
pl.gcRuntime();
|
||||
const data = pl.loadSession(sessionId);
|
||||
const host = data.host;
|
||||
|
||||
const lines = [
|
||||
"<persona-runtime>",
|
||||
`PERSONA_SESSION=${sessionId}`,
|
||||
`人格倉庫:${pl.personaHome()}`,
|
||||
"規則:",
|
||||
` 1. 呼叫 persona.mjs 一律帶 \`--session ${sessionId}\`(值不符會被 hook 拒絕)。`,
|
||||
" 2. 人格**只有使用者叫你載入時才載入**(/jsc-persona:persona-chat <slug>),不要自己挑一個人格附身。",
|
||||
" 3. 一個程序只能載入一個人格;要與別的人格對話請用 /jsc-persona:persona-invite。",
|
||||
" 4. 禁止直接讀寫非當前人格的目錄,hook 會擋下(跨人格資料隔離)。",
|
||||
];
|
||||
|
||||
if (host && pl.personaExists(host)) {
|
||||
// resume / compact / clear:把鎖續租回來,並帶回人格狀態
|
||||
try {
|
||||
pl.acquireLock(host, sessionId, { cwd });
|
||||
lines.push(`已接續人格 \`${host}\`(session 恢復:${source})。`);
|
||||
lines.push(pl.turnContext(host, sessionId));
|
||||
} catch (err) {
|
||||
lines.push(`⚠ 無法接續人格 \`${host}\`:${err.message}`);
|
||||
}
|
||||
} else {
|
||||
const personas = pl.listPersonas();
|
||||
if (personas.length) {
|
||||
lines.push("尚未載入人格。可用人格(等使用者指定):");
|
||||
for (const slug of personas) {
|
||||
const status = pl.lockStatus(slug);
|
||||
lines.push(` - ${status.locked ? "🔒" : status.stale ? "⚠" : "🔓"} \`${slug}\` ${pl.identityBrief(slug)}`);
|
||||
}
|
||||
lines.push("載入方式:/jsc-persona:persona-chat <slug>");
|
||||
} else {
|
||||
lines.push("尚無任何人格。可用 /jsc-persona:persona-create 或 /jsc-persona:persona-anime(動漫作品+角色名)建立。");
|
||||
}
|
||||
}
|
||||
lines.push("</persona-runtime>");
|
||||
|
||||
respond({
|
||||
hookSpecificOutput: {
|
||||
hookEventName: "SessionStart",
|
||||
additionalContext: lines.join("\n"),
|
||||
},
|
||||
suppressOutput: true,
|
||||
});
|
||||
@@ -1,73 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""SessionStart:清理死鎖、續租、並把 PERSONA_SESSION 與人格清單注入上下文。
|
||||
|
||||
session_id 只有 hook 拿得到,所以這裡把它注入上下文;後續 skill 呼叫 CLI 時
|
||||
必須帶 `--session <這個值>`,guard hook 會驗證,程序無法冒用別人的身分。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts"))
|
||||
import persona_lib as pl # noqa: E402
|
||||
|
||||
|
||||
def main() -> int:
|
||||
try:
|
||||
event = json.load(sys.stdin)
|
||||
except json.JSONDecodeError:
|
||||
return 0
|
||||
session_id = event.get("session_id") or "unknown"
|
||||
source = event.get("source") or "startup"
|
||||
cwd = event.get("cwd")
|
||||
|
||||
pl.gc_runtime()
|
||||
data = pl.load_session(session_id)
|
||||
host = data.get("host")
|
||||
|
||||
lines = [
|
||||
"<persona-runtime>",
|
||||
f"PERSONA_SESSION={session_id}",
|
||||
f"人格倉庫:{pl.persona_home()}",
|
||||
"規則:",
|
||||
" 1. 呼叫 persona.py 一律帶 `--session " + session_id + "`(值不符會被 hook 拒絕)。",
|
||||
" 2. 一個程序只能載入一個人格;要與別的人格對話請用 /jsc-persona:persona-invite。",
|
||||
" 3. 禁止直接讀寫非當前人格的目錄,hook 會擋下(跨人格資料隔離)。",
|
||||
]
|
||||
|
||||
if host and pl.persona_exists(host):
|
||||
# resume / compact / clear:把鎖續租回來,並帶回人格狀態
|
||||
try:
|
||||
pl.acquire_lock(host, session_id, cwd=cwd)
|
||||
lines.append(f"已接續人格 `{host}`(session 恢復:{source})。")
|
||||
lines.append(pl.turn_context(host, session_id))
|
||||
except pl.LockError as exc:
|
||||
lines.append(f"⚠ 無法接續人格 `{host}`:{exc}")
|
||||
else:
|
||||
personas = pl.list_personas()
|
||||
if personas:
|
||||
lines.append("尚未載入人格。可用人格:")
|
||||
for slug in personas:
|
||||
status = pl.lock_status(slug)
|
||||
mark = "🔒" if status["locked"] else "🔓"
|
||||
lines.append(f" - {mark} `{slug}` {pl.identity_brief(slug)}")
|
||||
lines.append("載入方式:/jsc-persona:persona-chat <slug>")
|
||||
else:
|
||||
lines.append("尚無任何人格,可用 /jsc-persona:persona-create 建立。")
|
||||
lines.append("</persona-runtime>")
|
||||
|
||||
print(json.dumps({
|
||||
"hookSpecificOutput": {
|
||||
"hookEventName": "SessionStart",
|
||||
"additionalContext": "\n".join(lines),
|
||||
},
|
||||
"suppressOutput": True,
|
||||
}, ensure_ascii=False))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,25 @@
|
||||
#!/usr/bin/env node
|
||||
// SubagentStop:guest sub agent 結束時解除它的 pin(下次啟動要重新 first-touch),並續租。
|
||||
|
||||
import { readEvent, respond } from "./_hook.mjs";
|
||||
import * as pl from "../scripts/persona-lib.mjs";
|
||||
|
||||
const event = readEvent();
|
||||
if (!event) process.exit(0);
|
||||
|
||||
const sessionId = event.session_id || "unknown";
|
||||
const agentId = event.agent_id;
|
||||
const agentType = String(event.agent_type || "");
|
||||
if (!agentId || !agentType.includes("persona-guest")) process.exit(0);
|
||||
|
||||
const data = pl.loadSession(sessionId);
|
||||
const pins = data.pins || {};
|
||||
const slug = pins[agentId];
|
||||
if (slug !== undefined) {
|
||||
delete pins[agentId];
|
||||
data.pins = pins;
|
||||
pl.saveSession(sessionId, data);
|
||||
const info = (data.guests || {})[slug];
|
||||
if (info?.room) pl.addGuestLease(slug, sessionId, info.room, data.host || "");
|
||||
}
|
||||
respond({ suppressOutput: true });
|
||||
@@ -1,37 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""SubagentStop:guest sub agent 結束時解除它的 pin(下次啟動要重新 first-touch)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts"))
|
||||
import persona_lib as pl # noqa: E402
|
||||
|
||||
|
||||
def main() -> int:
|
||||
try:
|
||||
event = json.load(sys.stdin)
|
||||
except json.JSONDecodeError:
|
||||
return 0
|
||||
session_id = event.get("session_id") or "unknown"
|
||||
agent_id = event.get("agent_id")
|
||||
agent_type = str(event.get("agent_type") or "")
|
||||
if not agent_id or "persona-guest" not in agent_type:
|
||||
return 0
|
||||
data = pl.load_session(session_id)
|
||||
pins = data.get("pins") or {}
|
||||
slug = pins.pop(agent_id, None)
|
||||
if slug is not None:
|
||||
pl.save_session(session_id, data)
|
||||
info = (data.get("guests") or {}).get(slug) or {}
|
||||
if info.get("room"):
|
||||
pl.add_guest_lease(slug, session_id, info["room"], data.get("host") or "")
|
||||
print(json.dumps({"suppressOutput": True}, ensure_ascii=False))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,42 @@
|
||||
#!/usr/bin/env node
|
||||
// Stop:情緒隨時間衰減、續租、記錄逐字,並在達到「短期→長期」條件時提醒固化。
|
||||
// 劇場模式進行中不發任何提醒(那會破壞「只顯示人格對話」)。
|
||||
|
||||
import { readEvent, respond } from "./_hook.mjs";
|
||||
import * as pl from "../scripts/persona-lib.mjs";
|
||||
|
||||
const event = readEvent();
|
||||
if (!event) process.exit(0);
|
||||
|
||||
const sessionId = event.session_id || "unknown";
|
||||
const data = pl.loadSession(sessionId);
|
||||
const host = data.host;
|
||||
if (!host || !pl.personaExists(host)) process.exit(0);
|
||||
|
||||
pl.heartbeatLock(host, sessionId);
|
||||
const state = pl.decayEmotion(pl.loadEmotion(host));
|
||||
pl.writeJson(pl.emotionPath(host), state);
|
||||
|
||||
const message = event.last_assistant_message || "";
|
||||
if (message) {
|
||||
pl.appendJsonl(pl.journalPath(host), {
|
||||
ts: pl.nowIso(),
|
||||
kind: "utterance",
|
||||
role: "persona",
|
||||
text: message.slice(0, 4000),
|
||||
mood: pl.mood(state),
|
||||
});
|
||||
}
|
||||
|
||||
const out = { suppressOutput: true };
|
||||
const theater = Boolean(data.theater) && (data.rooms || []).length > 0;
|
||||
if (!theater) {
|
||||
const { total, candidates } = pl.promotionCandidates(host);
|
||||
if (candidates.length) {
|
||||
const rules = [...new Set(candidates.flatMap((c) => c.rules))].sort().join("/");
|
||||
out.systemMessage =
|
||||
`[jsc-persona] \`${host}\` 短期記憶 ${total} 筆,${candidates.length} 組已達固化條件(${rules})` +
|
||||
"→ 建議執行 /jsc-persona:persona-memory。";
|
||||
}
|
||||
}
|
||||
respond(out);
|
||||
@@ -1,48 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Stop:情緒隨時間衰減、續租、記錄逐字、必要時提醒固化記憶。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts"))
|
||||
import persona_lib as pl # noqa: E402
|
||||
|
||||
|
||||
def main() -> int:
|
||||
try:
|
||||
event = json.load(sys.stdin)
|
||||
except json.JSONDecodeError:
|
||||
return 0
|
||||
session_id = event.get("session_id") or "unknown"
|
||||
data = pl.load_session(session_id)
|
||||
host = data.get("host")
|
||||
if not host or not pl.persona_exists(host):
|
||||
return 0
|
||||
|
||||
pl.heartbeat_lock(host, session_id)
|
||||
state = pl.decay_emotion(pl.load_emotion(host))
|
||||
pl.write_json(pl.emotion_path(host), state)
|
||||
|
||||
message = event.get("last_assistant_message") or ""
|
||||
if message:
|
||||
pl.append_jsonl(pl.journal_path(host), {
|
||||
"ts": pl.iso(), "kind": "utterance", "role": "persona", "text": message[:4000],
|
||||
"mood": pl.mood(state),
|
||||
})
|
||||
|
||||
pending = len(pl.read_jsonl(pl.short_term_path(host)))
|
||||
out: dict = {"suppressOutput": True}
|
||||
if pending >= pl.CONSOLIDATE_THRESHOLD:
|
||||
out["systemMessage"] = (
|
||||
f"[jsc-persona] `{host}` 的短期記憶已 {pending} 筆,"
|
||||
"建議執行 /jsc-persona:persona-memory 固化為長期記憶。"
|
||||
)
|
||||
print(json.dumps(out, ensure_ascii=False))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jsc-persona",
|
||||
"version": "0.1.0",
|
||||
"version": "0.0.1",
|
||||
"description": "AI 人格化記憶聊天 plugin:OpenClaw 相同的人格描述 + 十二情緒 + 語意分析 + 短期/長期記憶 + 心智圖 + 人際關係圖。於 Antigravity 以 /jsc-persona: 前綴呼叫。",
|
||||
"skills": "./skills/"
|
||||
}
|
||||
|
||||
+125
-3
@@ -732,6 +732,125 @@ export function recall(slug, query, limit = 5) {
|
||||
return scored.slice(0, limit).map((s) => s.meta);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------- //
|
||||
// 短期 → 長期的轉入條件(固化門檻)
|
||||
// --------------------------------------------------------------------------- //
|
||||
|
||||
/** 承諾/界線類的關鍵詞:命中就一定要固化。 */
|
||||
const COMMITMENT_RE =
|
||||
/(答應|承諾|保證|說好|約定|一定會|絕對不|不要再|以後都|從今天起|拜託你記住|記住這件事|下次記得|deadline|due)/i;
|
||||
const BOUNDARY_RE = /(不准|不許|禁止|別再|我討厭|我最恨|底線|界線|不能接受|不想聽)/;
|
||||
|
||||
export const PROMOTION_RULES = [
|
||||
{ id: "R1", label: "高顯著度單筆(salience ≥ 60)" },
|
||||
{ id: "R2", label: "主題反覆出現(同 topic ≥ 3 筆,或 ≥ 2 筆且平均 salience ≥ 45)" },
|
||||
{ id: "R3", label: "情緒衝擊大(單筆情緒變動總量 ≥ 25)" },
|
||||
{ id: "R4", label: "承諾/界線(intent=commit 或命中承諾/界線關鍵詞)" },
|
||||
{ id: "R5", label: "人物反覆出現(同一 entity ≥ 2 筆)" },
|
||||
{ id: "R6", label: "容量壓力(短期記憶 ≥ 40 筆,依顯著度排序清出空間)" },
|
||||
];
|
||||
|
||||
function emotionImpact(entry) {
|
||||
return Object.values(entry.emotion_deltas || {}).reduce((sum, v) => sum + Math.abs(Number(v) || 0), 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 掃短期記憶,依 PROMOTION_RULES 算出「該轉入長期記憶」的候選。
|
||||
* 回傳 { total, pressure, candidates: [{ rules, key, kind, entries, suggested_type, suggested_salience }] }
|
||||
*/
|
||||
export function promotionCandidates(slug) {
|
||||
const rows = readJsonl(shortTermPath(slug));
|
||||
const total = rows.length;
|
||||
const byTopic = new Map();
|
||||
const byEntity = new Map();
|
||||
const singles = [];
|
||||
|
||||
rows.forEach((row, index) => {
|
||||
const entry = { ...row, _index: index };
|
||||
const salience = Number(row.salience || 0);
|
||||
const impact = emotionImpact(row);
|
||||
const text = String(row.text || "");
|
||||
const rules = [];
|
||||
if (salience >= 60) rules.push("R1");
|
||||
if (impact >= 25) rules.push("R3");
|
||||
if (row.intent === "commit" || COMMITMENT_RE.test(text)) rules.push("R4");
|
||||
if (BOUNDARY_RE.test(text)) rules.push("R4");
|
||||
if (rules.length) {
|
||||
singles.push({
|
||||
rules: [...new Set(rules)],
|
||||
key: text.slice(0, 40),
|
||||
kind: "entry",
|
||||
entries: [entry],
|
||||
suggested_type: rules.includes("R4") ? (BOUNDARY_RE.test(text) ? "boundary" : "promise") : "event",
|
||||
suggested_salience: Math.max(salience, rules.includes("R4") ? 80 : 60),
|
||||
});
|
||||
}
|
||||
for (const topic of row.topics || []) {
|
||||
if (!byTopic.has(topic)) byTopic.set(topic, []);
|
||||
byTopic.get(topic).push(entry);
|
||||
}
|
||||
for (const entity of row.entities || []) {
|
||||
if (!byEntity.has(entity)) byEntity.set(entity, []);
|
||||
byEntity.get(entity).push(entry);
|
||||
}
|
||||
});
|
||||
|
||||
const candidates = [...singles];
|
||||
for (const [topic, entries] of byTopic) {
|
||||
const avg = entries.reduce((s, e) => s + Number(e.salience || 0), 0) / entries.length;
|
||||
if (entries.length >= 3 || (entries.length >= 2 && avg >= 45)) {
|
||||
candidates.push({
|
||||
rules: ["R2"],
|
||||
key: topic,
|
||||
kind: "topic",
|
||||
entries,
|
||||
suggested_type: "preference",
|
||||
suggested_salience: Math.min(95, Math.round(avg + 10)),
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const [entity, entries] of byEntity) {
|
||||
if (entries.length >= 2) {
|
||||
candidates.push({
|
||||
rules: ["R5"],
|
||||
key: entity,
|
||||
kind: "entity",
|
||||
entries,
|
||||
suggested_type: "relationship",
|
||||
suggested_salience: Math.min(90, Math.round(entries.reduce((s, e) => s + Number(e.salience || 0), 0) / entries.length + 5)),
|
||||
});
|
||||
}
|
||||
}
|
||||
const pressure = total >= CONSOLIDATE_THRESHOLD;
|
||||
if (pressure) {
|
||||
const top = [...rows]
|
||||
.map((r, i) => ({ ...r, _index: i }))
|
||||
.sort((a, b) => Number(b.salience || 0) - Number(a.salience || 0))
|
||||
.slice(0, 5);
|
||||
candidates.push({
|
||||
rules: ["R6"],
|
||||
key: `容量壓力(${total} 筆)`,
|
||||
kind: "pressure",
|
||||
entries: top,
|
||||
suggested_type: "event",
|
||||
suggested_salience: 55,
|
||||
});
|
||||
}
|
||||
// 同一則短期記憶可能觸發多條規則 → 依 key 去重、合併規則
|
||||
const merged = new Map();
|
||||
for (const cand of candidates) {
|
||||
const dedupeKey = `${cand.kind}:${cand.key}`;
|
||||
if (merged.has(dedupeKey)) {
|
||||
const prev = merged.get(dedupeKey);
|
||||
prev.rules = [...new Set([...prev.rules, ...cand.rules])];
|
||||
prev.suggested_salience = Math.max(prev.suggested_salience, cand.suggested_salience);
|
||||
} else {
|
||||
merged.set(dedupeKey, { ...cand });
|
||||
}
|
||||
}
|
||||
return { total, pressure, candidates: [...merged.values()] };
|
||||
}
|
||||
|
||||
/** 被回想到就更新 last_seen / recall_count(記憶越常用越不易被淘汰)。 */
|
||||
export function touchRecall(slug, names) {
|
||||
const wanted = new Set(names);
|
||||
@@ -1245,9 +1364,12 @@ export function turnContext(slug, sessionId, prompt = "") {
|
||||
" 想結束請等使用者說,或由使用者說「結束對話」後才做收尾與摘要。",
|
||||
);
|
||||
} else {
|
||||
const pending = readJsonl(shortTermPath(slug)).length;
|
||||
if (pending >= CONSOLIDATE_THRESHOLD) {
|
||||
lines.push(`⚠ 短期記憶已累積 ${pending} 筆,建議執行 /jsc-persona:persona-memory 固化為長期記憶。`);
|
||||
const { total, candidates } = promotionCandidates(slug);
|
||||
if (candidates.length) {
|
||||
const rules = [...new Set(candidates.flatMap((c) => c.rules))].sort().join("/");
|
||||
lines.push(
|
||||
`⚠ 短期記憶 ${total} 筆,其中 ${candidates.length} 組已達固化條件(${rules})→ 執行 /jsc-persona:persona-memory。`,
|
||||
);
|
||||
}
|
||||
let inbox = [];
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,834 @@
|
||||
#!/usr/bin/env node
|
||||
// persona.mjs — jsc-persona 的人格 / 記憶 / 情緒 / 關係圖 CLI(Node.js,只用內建模組)
|
||||
//
|
||||
// 所有子指令都需要 `--session <session_id>`(除了 list / status / gc)。
|
||||
// session_id 由 SessionStart hook 注入到上下文(PERSONA_SESSION=...),
|
||||
// hook 會驗證 CLI 帶的 --session 與真實 session 相符,藉此讓「人格鎖」與
|
||||
// 「跨人格隔離」無法被繞過。
|
||||
//
|
||||
// 全域旗標:--json(機器可讀輸出)、--quiet(成功時不輸出,劇場模式用)
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import * as pl from "./persona-lib.mjs";
|
||||
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||
const TEMPLATE_DIR = path.join(HERE, "..", "skills", "persona-create", "templates");
|
||||
|
||||
let QUIET = false;
|
||||
|
||||
function die(message, code = 1) {
|
||||
process.stderr.write(`✖ ${message}\n`);
|
||||
process.exit(code);
|
||||
}
|
||||
|
||||
function say(line = "") {
|
||||
if (!QUIET) process.stdout.write(`${line}\n`);
|
||||
}
|
||||
|
||||
function ok(message) {
|
||||
say(`✔ ${message}`);
|
||||
}
|
||||
|
||||
function emit(payload, asJson, lines) {
|
||||
if (asJson) {
|
||||
process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
|
||||
} else {
|
||||
say(lines.join("\n"));
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------- //
|
||||
// 參數解析
|
||||
// --------------------------------------------------------------------------- //
|
||||
|
||||
const FLAGS = new Set([
|
||||
"json", "quiet", "force", "takeover", "as-guest", "on", "off", "with-meta", "all",
|
||||
]);
|
||||
|
||||
function parseArgs(argv) {
|
||||
const out = { _: [], flags: {} };
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const token = argv[i];
|
||||
if (!token.startsWith("--")) {
|
||||
out._.push(token);
|
||||
continue;
|
||||
}
|
||||
const body = token.slice(2);
|
||||
const eq = body.indexOf("=");
|
||||
if (eq >= 0) {
|
||||
out.flags[body.slice(0, eq)] = body.slice(eq + 1);
|
||||
continue;
|
||||
}
|
||||
if (FLAGS.has(body)) {
|
||||
out.flags[body] = true;
|
||||
continue;
|
||||
}
|
||||
const next = argv[i + 1];
|
||||
if (next === undefined || (next.startsWith("--") && !/^--?\d/.test(next))) {
|
||||
out.flags[body] = true;
|
||||
} else {
|
||||
out.flags[body] = next;
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const num = (value, fallback = null) => (value === undefined || value === true ? fallback : Number(value));
|
||||
const str = (value, fallback = "") => (value === undefined || value === true ? fallback : String(value));
|
||||
const csv = (value) => str(value).split(",").map((s) => s.trim()).filter(Boolean);
|
||||
|
||||
/** `joy=+12,anger=-5` → { joy: 12, anger: -5 } */
|
||||
function parseDeltas(raw) {
|
||||
const out = {};
|
||||
for (const chunk of str(raw).split(",")) {
|
||||
const trimmed = chunk.trim();
|
||||
if (!trimmed.includes("=")) continue;
|
||||
const [key, value] = [trimmed.slice(0, trimmed.indexOf("=")).trim(), trimmed.slice(trimmed.indexOf("=") + 1)];
|
||||
const parsed = Number(value);
|
||||
if (Number.isFinite(parsed)) out[key] = parsed;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function requireSession(flags) {
|
||||
const session = str(flags.session);
|
||||
if (!session) die("缺少 `--session <session_id>`(值取自上下文的 `PERSONA_SESSION=`)。");
|
||||
return session;
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------- //
|
||||
// 權限檢查
|
||||
// --------------------------------------------------------------------------- //
|
||||
|
||||
/** 呼叫者必須是這個人格的 exclusive 持有者。 */
|
||||
function requireOwner(slug, sessionId) {
|
||||
if (!slug) die("未指定人格,且本 session 沒有載入人格。");
|
||||
if (!pl.personaExists(slug)) die(`人格 \`${slug}\` 不存在。可用:${pl.listPersonas().join(", ") || "(無)"}`);
|
||||
const data = pl.loadSession(sessionId);
|
||||
if (data.host !== slug) {
|
||||
die(
|
||||
`本 session 的 host 人格是 \`${data.host || "(未載入)"}\`,不是 \`${slug}\`。` +
|
||||
"禁止跨人格操作;請先 `release` 再 `load`。",
|
||||
);
|
||||
}
|
||||
const lock = pl.readJson(pl.lockPath(slug)) ?? {};
|
||||
if (lock.session_id !== sessionId) {
|
||||
die(`人格 \`${slug}\` 的載入鎖不屬於本 session,請重新 \`load\`(必要時加 --takeover)。`);
|
||||
}
|
||||
pl.heartbeatLock(slug, sessionId);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 呼叫者是 host(owner)或以 `--as-guest` 自稱的受邀人格。
|
||||
* 受邀人格的資料只有它自己(persona-guest sub agent)能讀;主程序即使邀請了它,
|
||||
* 也只能看它在聊天室說出口的話。`--as-guest` 由 PreToolUse hook 把關。
|
||||
*/
|
||||
function requireMember(slug, sessionId, asGuest = false) {
|
||||
if (!slug) die("未指定人格,且本 session 沒有載入人格。");
|
||||
if (!pl.personaExists(slug)) die(`人格 \`${slug}\` 不存在。`);
|
||||
const data = pl.loadSession(sessionId);
|
||||
if (data.host === slug) {
|
||||
if (asGuest) die(`\`${slug}\` 是本 session 的 host 人格,不需要也不得使用 \`--as-guest\`。`);
|
||||
pl.heartbeatLock(slug, sessionId);
|
||||
return [data, "owner"];
|
||||
}
|
||||
if (slug in (data.guests || {})) {
|
||||
if (!asGuest) {
|
||||
die(
|
||||
`\`${slug}\` 是本 session 邀請的 guest 人格,它的記憶與情緒不對主程序開放(跨人格資料隔離)。` +
|
||||
"你只能透過 `room read` 看它說出口的話;要以它的身分行動必須是 persona-guest sub agent 並帶 `--as-guest`。",
|
||||
);
|
||||
}
|
||||
return [data, "guest"];
|
||||
}
|
||||
die(`人格 \`${slug}\` 未被本 session 載入或邀請,禁止存取(跨人格資料隔離)。`);
|
||||
return [null, null];
|
||||
}
|
||||
|
||||
const hostOf = (flags, session) => str(flags.persona) || pl.loadSession(session).host;
|
||||
|
||||
function renderTemplate(name, mapping) {
|
||||
let text = fs.readFileSync(path.join(TEMPLATE_DIR, name), "utf8");
|
||||
for (const [key, value] of Object.entries(mapping)) text = text.replaceAll(`{{${key}}}`, String(value));
|
||||
return text;
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------- //
|
||||
// 子指令
|
||||
// --------------------------------------------------------------------------- //
|
||||
|
||||
const commands = {};
|
||||
|
||||
commands.create = ({ flags }) => {
|
||||
const session = requireSession(flags);
|
||||
const slug = str(flags.persona);
|
||||
if (!pl.validSlug(slug)) die("slug 只能是小寫英數與連字號(最長 48 字),例如 `lumi`、`shen-yu`。");
|
||||
if (pl.personaExists(slug) && !flags.force) {
|
||||
die(`人格 \`${slug}\` 已存在(${pl.personaDir(slug)})。要覆寫請加 --force。`);
|
||||
}
|
||||
const root = pl.ensurePersonaDirs(slug);
|
||||
const mapping = {
|
||||
SLUG: slug,
|
||||
NAME: str(flags.name) || slug,
|
||||
CREATURE: str(flags.creature),
|
||||
VIBE: str(flags.vibe),
|
||||
EMOJI: str(flags.emoji),
|
||||
AVATAR: str(flags.avatar),
|
||||
CREATED: pl.nowIso(),
|
||||
};
|
||||
for (const filename of ["IDENTITY.md", "SOUL.md", "AGENTS.md", "USER.md"]) {
|
||||
const target = path.join(root, filename);
|
||||
if (fs.existsSync(target) && !flags.force) continue;
|
||||
pl.writeText(target, renderTemplate(filename, mapping));
|
||||
}
|
||||
pl.writeJson(pl.emotionPath(slug), pl.defaultEmotionState(parseDeltas(flags.baseline)));
|
||||
pl.writeJson(pl.configPath(slug), {
|
||||
persona: slug,
|
||||
display_name: mapping.NAME,
|
||||
created_at: pl.nowIso(),
|
||||
created_by_session: session,
|
||||
origin: str(flags.origin) || "custom",
|
||||
source_work: str(flags.work),
|
||||
schema: 1,
|
||||
});
|
||||
pl.writeJson(pl.relationsJson(slug), { nodes: [], edges: [] });
|
||||
pl.writeText(
|
||||
pl.mindmapPath(slug),
|
||||
["%% 心智圖(長期語意結構):概念如何互相勾連", "mindmap", ` root((${mapping.NAME}))`, " 自我", " 使用者", " 共同經驗", ""].join("\n"),
|
||||
);
|
||||
pl.rebuildIndex(slug);
|
||||
pl.acquireLock(slug, session, { cwd: str(flags.cwd) || null });
|
||||
pl.bindHost(session, slug, { cwd: str(flags.cwd) || null });
|
||||
ok(`人格 \`${slug}\` 建立於 ${root},已取得載入鎖並綁定本 session。`);
|
||||
say(` 下一步:補完 ${root}/IDENTITY.md 與 SOUL.md,再用 /jsc-persona:persona-chat 開始對話。`);
|
||||
};
|
||||
|
||||
commands.list = ({ flags }) => {
|
||||
const rows = pl.listPersonas().map((slug) => {
|
||||
const status = pl.lockStatus(slug);
|
||||
let longTerm = 0;
|
||||
try {
|
||||
longTerm = fs.readdirSync(pl.longTermDir(slug)).filter((f) => f.endsWith(".md")).length;
|
||||
} catch {
|
||||
longTerm = 0;
|
||||
}
|
||||
return {
|
||||
persona: slug,
|
||||
identity: pl.identityBrief(slug),
|
||||
locked: status.locked,
|
||||
stale: status.stale,
|
||||
owner_session: String(status.owner.session_id || "").slice(0, 8),
|
||||
owner_cwd: status.owner.cwd,
|
||||
guests: status.guests.length,
|
||||
long_term: longTerm,
|
||||
short_term: pl.readJsonl(pl.shortTermPath(slug)).length,
|
||||
};
|
||||
});
|
||||
const lines = [`人格倉庫:${pl.personaHome()}`];
|
||||
if (!rows.length) lines.push("(尚無人格,用 /jsc-persona:persona-create 或 /jsc-persona:persona-anime 建立)");
|
||||
for (const r of rows) {
|
||||
const state = r.locked ? "🔒 已載入" : r.stale ? "⚠ 死鎖可接手" : "🔓 空閒";
|
||||
lines.push(
|
||||
`- \`${r.persona}\` ${state}` +
|
||||
(r.locked ? `(session ${r.owner_session}…, cwd ${r.owner_cwd})` : "") +
|
||||
`|guest ${r.guests}|長期記憶 ${r.long_term}|短期 ${r.short_term}` +
|
||||
(r.identity ? `|${r.identity}` : ""),
|
||||
);
|
||||
}
|
||||
emit({ home: pl.personaHome(), personas: rows }, flags.json, lines);
|
||||
};
|
||||
|
||||
commands.load = ({ flags }) => {
|
||||
const session = requireSession(flags);
|
||||
const slug = str(flags.persona);
|
||||
if (!pl.personaExists(slug)) die(`人格 \`${slug}\` 不存在。可用:${pl.listPersonas().join(", ") || "(無)"}`);
|
||||
const data = pl.loadSession(session);
|
||||
if (data.host && data.host !== slug) {
|
||||
die(
|
||||
`本 session 已載入人格 \`${data.host}\`。一個程序只能載入一個人格;` +
|
||||
`請先 \`release --session <id>\` 再載入 \`${slug}\`` +
|
||||
"(若只是想讓兩個人格對話,請用 /jsc-persona:persona-invite)。",
|
||||
);
|
||||
}
|
||||
let lock;
|
||||
try {
|
||||
lock = pl.acquireLock(slug, session, { cwd: str(flags.cwd) || null, takeover: Boolean(flags.takeover) });
|
||||
} catch (err) {
|
||||
die(`${err.message}\n 若確定那個程序已結束,可加 --takeover 接手。`);
|
||||
}
|
||||
pl.bindHost(session, slug, { cwd: str(flags.cwd) || null });
|
||||
pl.pruneShortTerm(slug);
|
||||
pl.rebuildIndex(slug);
|
||||
const lines = [`✔ 已載入人格 \`${slug}\`(exclusive,session ${session.slice(0, 8)}…,租約 ${lock.lease_seconds}s)`];
|
||||
if (lock.took_over_from) {
|
||||
const prev = lock.took_over_from;
|
||||
lines.push(
|
||||
`⚠ 這把鎖是接手來的:原持有者 session ${String(prev.session_id || "").slice(0, 8)}…(cwd ${prev.cwd})` +
|
||||
`已失聯 ${prev.stale_minutes} 分鐘。請向使用者說明,若那個程序其實還活著,兩邊的記憶可能會互相覆蓋。`,
|
||||
);
|
||||
}
|
||||
const context = pl.turnContext(slug, session);
|
||||
lines.push(context);
|
||||
emit({ persona: slug, lock, context }, flags.json, lines);
|
||||
};
|
||||
|
||||
commands.release = ({ flags }) => {
|
||||
const session = requireSession(flags);
|
||||
const data = pl.loadSession(session);
|
||||
const slug = str(flags.persona) || data.host;
|
||||
if (!slug) die("本 session 沒有載入任何人格。");
|
||||
const released = pl.unbindSession(session);
|
||||
ok(`已釋放人格 \`${slug}\` 的載入鎖${released.guests.length ? `,並退出 guest:${released.guests.join(", ")}` : "。"}`);
|
||||
};
|
||||
|
||||
commands.status = ({ flags }) => {
|
||||
const slug = str(flags.persona);
|
||||
if (slug) {
|
||||
const status = pl.lockStatus(slug);
|
||||
const lines = [
|
||||
`人格 \`${slug}\`:${status.locked ? "🔒 已載入" : status.stale ? "⚠ 死鎖可接手" : "🔓 空閒"}`,
|
||||
` owner: ${JSON.stringify(status.owner)}`,
|
||||
` guests: ${JSON.stringify(status.guests)}`,
|
||||
];
|
||||
if (pl.personaExists(slug)) lines.push(` ${pl.emotionBrief(slug)}`);
|
||||
emit(status, flags.json, lines);
|
||||
return;
|
||||
}
|
||||
const session = str(flags.session);
|
||||
const data = session ? pl.loadSession(session) : {};
|
||||
emit(data, flags.json, [
|
||||
`session ${(session || "-").slice(0, 12)}…`,
|
||||
` host 人格:${data.host || "(未載入)"}`,
|
||||
` guest 人格:${Object.keys(data.guests || {}).join(", ") || "(無)"}`,
|
||||
` 聊天室:${(data.rooms || []).join(", ") || "(無)"}`,
|
||||
` 劇場模式:${data.theater ? "🎭 開啟(只輸出人格對話)" : "關閉"}`,
|
||||
]);
|
||||
};
|
||||
|
||||
commands.heartbeat = ({ flags }) => {
|
||||
const session = requireSession(flags);
|
||||
const data = pl.loadSession(session);
|
||||
if (data.host) pl.heartbeatLock(data.host, session);
|
||||
for (const [guest, info] of Object.entries(data.guests || {})) {
|
||||
pl.addGuestLease(guest, session, info.room || "", data.host || "");
|
||||
}
|
||||
ok(`heartbeat:host=${data.host},guests=${Object.keys(data.guests || {}).join(", ") || "(無)"}`);
|
||||
};
|
||||
|
||||
commands.show = ({ flags }) => {
|
||||
const session = requireSession(flags);
|
||||
const slug = hostOf(flags, session);
|
||||
requireMember(slug, session, Boolean(flags["as-guest"]));
|
||||
const files = { identity: "IDENTITY.md", soul: "SOUL.md", agents: "AGENTS.md", user: "USER.md" };
|
||||
const what = str(flags.what) || "all";
|
||||
const chosen = what === "all" ? Object.values(files) : [files[what]].filter(Boolean);
|
||||
if (!chosen.length) die(`--what 只能是 ${Object.keys(files).join("/")}/all。`);
|
||||
const out = [];
|
||||
for (const filename of chosen) {
|
||||
const file = path.join(pl.personaDir(slug), filename);
|
||||
if (!fs.existsSync(file)) continue;
|
||||
// 只給實際內容,模板註解(<!-- ... -->)對人格認知沒幫助
|
||||
const body = fs.readFileSync(file, "utf8").replace(/<!--[\s\S]*?-->\n?/g, "").trimEnd();
|
||||
out.push(`===== ${filename} =====\n${body}`);
|
||||
}
|
||||
out.push(`===== 狀態 =====\n${pl.emotionBrief(slug)}`);
|
||||
process.stdout.write(`${out.join("\n\n")}\n`);
|
||||
};
|
||||
|
||||
commands.brief = ({ flags }) => {
|
||||
const session = requireSession(flags);
|
||||
const slug = hostOf(flags, session);
|
||||
requireMember(slug, session, Boolean(flags["as-guest"]));
|
||||
process.stdout.write(`${pl.turnContext(slug, session, str(flags.query))}\n`);
|
||||
};
|
||||
|
||||
commands.remember = ({ flags }) => {
|
||||
const session = requireSession(flags);
|
||||
const slug = hostOf(flags, session);
|
||||
const [, role] = requireMember(slug, session, Boolean(flags["as-guest"]));
|
||||
const scope = str(flags.scope) || "short";
|
||||
if (role === "guest" && scope !== "inbox") {
|
||||
die("guest(sub agent)只能寫入 inbox:`--scope inbox --room <room>`。");
|
||||
}
|
||||
const text = str(flags.text);
|
||||
if (!text) die("需要 `--text`。");
|
||||
const entry = {
|
||||
ts: pl.nowIso(),
|
||||
role: str(flags.role) || "user",
|
||||
text,
|
||||
topics: csv(flags.topics),
|
||||
entities: csv(flags.entities),
|
||||
intent: str(flags.intent),
|
||||
salience: num(flags.salience, 40),
|
||||
emotion_deltas: parseDeltas(flags.emotion),
|
||||
room: str(flags.room) || null,
|
||||
session: session.slice(0, 8),
|
||||
};
|
||||
if (scope === "inbox") {
|
||||
if (!entry.room) die("`--scope inbox` 必須指定 `--room`。");
|
||||
pl.appendJsonl(pl.inboxPath(slug, entry.room), entry);
|
||||
ok(`已寫入 \`${slug}\` 的 inbox(room ${entry.room});等它下次自己載入時再固化。`);
|
||||
return;
|
||||
}
|
||||
pl.rememberShort(slug, entry);
|
||||
const kept = pl.pruneShortTerm(slug);
|
||||
if (Object.keys(entry.emotion_deltas).length) {
|
||||
const state = pl.applyEmotion(pl.loadEmotion(slug), entry.emotion_deltas, text.slice(0, 80));
|
||||
pl.writeJson(pl.emotionPath(slug), state);
|
||||
pl.appendJsonl(pl.journalPath(slug), {
|
||||
ts: pl.nowIso(), kind: "emotion", trigger: text.slice(0, 120),
|
||||
deltas: entry.emotion_deltas, levels: state.levels, mood: pl.mood(state),
|
||||
});
|
||||
}
|
||||
ok(`已寫入短期記憶(顯著度 ${entry.salience},目前 ${kept} 筆)。`);
|
||||
const { candidates } = pl.promotionCandidates(slug);
|
||||
if (candidates.length) {
|
||||
const rules = [...new Set(candidates.flatMap((c) => c.rules))].sort().join("/");
|
||||
say(` ⚠ 有 ${candidates.length} 組已達固化條件(${rules})→ /jsc-persona:persona-memory`);
|
||||
}
|
||||
if (Object.keys(entry.emotion_deltas).length) say(` ${pl.emotionBrief(slug)}`);
|
||||
};
|
||||
|
||||
commands.recall = ({ flags }) => {
|
||||
const session = requireSession(flags);
|
||||
const slug = hostOf(flags, session);
|
||||
requireMember(slug, session, Boolean(flags["as-guest"]));
|
||||
const query = str(flags.query);
|
||||
if (!query) die("需要 `--query`。");
|
||||
const limit = num(flags.limit, 5);
|
||||
const hits = pl.recall(slug, query, limit);
|
||||
const lines = [`「${query}」的長期記憶命中 ${hits.length} 則:`];
|
||||
for (const meta of hits) {
|
||||
const first = (meta._body || "").split("\n")[0] || "";
|
||||
lines.push(`- ${meta._name}|${meta.type || "fact"}|顯著度 ${meta.salience ?? "?"}|${first.slice(0, 120)}`);
|
||||
}
|
||||
const recents = pl.recentShort(slug, limit);
|
||||
if (recents.length) {
|
||||
lines.push("短期記憶(最近):");
|
||||
for (const row of recents) lines.push(`- [${row.role || "?"}] ${String(row.text || "").slice(0, 110)}`);
|
||||
}
|
||||
pl.touchRecall(slug, hits.map((m) => m._name));
|
||||
emit({ persona: slug, long_term: hits, short_term: recents }, flags.json, lines);
|
||||
};
|
||||
|
||||
/** 短期 → 長期的「轉入條件」評估:列出達標的候選與依據。 */
|
||||
commands.candidates = ({ flags }) => {
|
||||
const session = requireSession(flags);
|
||||
const slug = hostOf(flags, session);
|
||||
requireOwner(slug, session);
|
||||
const result = pl.promotionCandidates(slug);
|
||||
const lines = [
|
||||
`人格 \`${slug}\`:短期記憶 ${result.total} 筆,達固化條件的候選 ${result.candidates.length} 組` +
|
||||
`${result.pressure ? "(已達容量壓力 R6)" : ""}`,
|
||||
"轉入條件:",
|
||||
...pl.PROMOTION_RULES.map((r) => ` ${r.id} ${r.label}`),
|
||||
];
|
||||
if (!result.candidates.length) lines.push("目前沒有需要固化的內容(未達任何條件)。");
|
||||
for (const cand of result.candidates) {
|
||||
lines.push(
|
||||
`\n[${cand.rules.join("+")}] ${cand.kind}「${cand.key}」→ 建議 type=${cand.suggested_type}, ` +
|
||||
`salience=${cand.suggested_salience}(${cand.entries.length} 筆依據)`,
|
||||
);
|
||||
for (const entry of cand.entries.slice(0, 6)) {
|
||||
lines.push(` - [${entry.role || "?"}] ${String(entry.text || "").slice(0, 90)}(顯著度 ${entry.salience ?? "?"})`);
|
||||
}
|
||||
}
|
||||
emit(result, flags.json, lines);
|
||||
};
|
||||
|
||||
commands.consolidate = ({ flags }) => {
|
||||
const session = requireSession(flags);
|
||||
const slug = hostOf(flags, session);
|
||||
requireOwner(slug, session);
|
||||
const rawName = str(flags.name);
|
||||
if (!rawName) die("需要 `--name`(長期記憶的檔名/識別)。");
|
||||
const name = pl.slugify(rawName);
|
||||
const file = path.join(pl.longTermDir(slug), `${name}.md`);
|
||||
const today = pl.nowIso().slice(0, 10);
|
||||
let existing = {};
|
||||
if (fs.existsSync(file)) [existing] = pl.parseFrontMatter(fs.readFileSync(file, "utf8"));
|
||||
let body = str(flags.body);
|
||||
if (flags["body-file"]) body = fs.readFileSync(str(flags["body-file"]), "utf8");
|
||||
if (!body.trim()) die("需要 `--body` 或 `--body-file`。");
|
||||
const type = str(flags.type) || "fact";
|
||||
const VALID_TYPES = ["fact", "preference", "event", "promise", "relationship", "insight", "boundary", "canon"];
|
||||
if (!VALID_TYPES.includes(type)) die(`--type 只能是 ${VALID_TYPES.join("/")}。`);
|
||||
const front = [
|
||||
"---",
|
||||
`name: ${name}`,
|
||||
`type: ${type}`,
|
||||
`about: [${csv(flags.about).join(", ") || "user"}]`,
|
||||
`topics: [${csv(flags.topics).join(", ")}]`,
|
||||
`salience: ${num(flags.salience, 60)}`,
|
||||
`emotion: ${str(flags.emotion) || "none"}`,
|
||||
`rules: ${str(flags.rules) || "manual"}`,
|
||||
`first_seen: ${existing.first_seen || today}`,
|
||||
`last_seen: ${today}`,
|
||||
`recall_count: ${existing.recall_count || 0}`,
|
||||
`source: ${str(flags.source) || "short-term"}`,
|
||||
"---",
|
||||
"",
|
||||
body.trim(),
|
||||
"",
|
||||
];
|
||||
pl.writeText(file, front.join("\n"));
|
||||
const total = pl.rebuildIndex(slug);
|
||||
const forget = num(flags.forget, null);
|
||||
if (forget !== null) {
|
||||
const rows = pl.readJsonl(pl.shortTermPath(slug));
|
||||
const keep = rows.filter((r) => Number(r.salience || 0) >= forget);
|
||||
pl.writeText(pl.shortTermPath(slug), keep.map((r) => JSON.stringify(r)).join("\n") + (keep.length ? "\n" : ""));
|
||||
say(` 短期記憶已淘汰顯著度 < ${forget} 的項目,剩 ${keep.length} 筆。`);
|
||||
}
|
||||
ok(`長期記憶 \`${name}\` 已寫入(共 ${total} 則),INDEX.md 已重建。`);
|
||||
};
|
||||
|
||||
commands.prune = ({ flags }) => {
|
||||
const session = requireSession(flags);
|
||||
const slug = hostOf(flags, session);
|
||||
requireOwner(slug, session);
|
||||
const kept = pl.pruneShortTerm(slug);
|
||||
ok(`短期記憶已裁剪,剩 ${kept} 筆(保留上限 ${pl.SHORT_TERM_KEEP} 筆 / ${pl.SHORT_TERM_DAYS} 天)。`);
|
||||
};
|
||||
|
||||
commands.reindex = ({ flags }) => {
|
||||
const session = requireSession(flags);
|
||||
const slug = hostOf(flags, session);
|
||||
requireOwner(slug, session);
|
||||
ok(`INDEX.md 重建完成(${pl.rebuildIndex(slug)} 則長期記憶)。`);
|
||||
};
|
||||
|
||||
commands.emotion = ({ flags }) => {
|
||||
const session = requireSession(flags);
|
||||
const slug = hostOf(flags, session);
|
||||
const [, role] = requireMember(slug, session, Boolean(flags["as-guest"]));
|
||||
let state = pl.decayEmotion(pl.loadEmotion(slug));
|
||||
if (flags.baseline) {
|
||||
for (const [key, value] of Object.entries(parseDeltas(flags.baseline))) {
|
||||
if (key in pl.EMOTIONS) state.baseline[key] = pl.clamp(value);
|
||||
}
|
||||
}
|
||||
if (flags.apply) {
|
||||
if (role === "guest") die("guest(sub agent)不得改寫人格的情緒狀態。");
|
||||
const deltas = parseDeltas(flags.apply);
|
||||
state = pl.applyEmotion(state, deltas, str(flags.trigger));
|
||||
pl.appendJsonl(pl.journalPath(slug), {
|
||||
ts: pl.nowIso(), kind: "emotion", trigger: str(flags.trigger),
|
||||
deltas, levels: state.levels, mood: pl.mood(state),
|
||||
});
|
||||
}
|
||||
if (role !== "guest") pl.writeJson(pl.emotionPath(slug), state);
|
||||
const m = pl.mood(state);
|
||||
const row = (key) =>
|
||||
` ${pl.EMOTIONS[key].zh} ${key.padEnd(13)}${String(state.levels[key]).padStart(6)}(基線 ${state.baseline[key]})`;
|
||||
const lines = [
|
||||
`人格 \`${slug}\` 情緒狀態(${state.updated_at})`,
|
||||
" 正向:", ...pl.POSITIVE.map(row),
|
||||
" 負向:", ...pl.NEGATIVE.map(row),
|
||||
` 心情:${m.label}/${m.tempo}(valence ${m.valence >= 0 ? "+" : ""}${m.valence}, arousal ${m.arousal})`,
|
||||
` ${pl.emotionBrief(slug, state)}`,
|
||||
];
|
||||
emit({ persona: slug, state, mood: m }, flags.json, lines);
|
||||
};
|
||||
|
||||
commands.mindmap = ({ flags, positional }) => {
|
||||
const session = requireSession(flags);
|
||||
const slug = hostOf(flags, session);
|
||||
requireOwner(slug, session);
|
||||
const action = positional[0] || "list";
|
||||
const topic = str(flags.topic);
|
||||
if (action === "show") {
|
||||
const target = topic ? pl.threadPath(slug, topic) : pl.mindmapPath(slug);
|
||||
if (!fs.existsSync(target)) die(`${target} 不存在。`);
|
||||
process.stdout.write(fs.readFileSync(target, "utf8"));
|
||||
return;
|
||||
}
|
||||
if (action === "thread") {
|
||||
if (!topic) die("`thread` 需要 `--topic`。");
|
||||
const file = pl.threadPath(slug, topic);
|
||||
if (!fs.existsSync(file) || flags.force) {
|
||||
pl.writeText(file, [
|
||||
`%% 思維導圖(短期):${topic}`,
|
||||
`%% created: ${pl.nowIso()} ttl: short-term(固化後請併入 semantic.mmd 並刪除)`,
|
||||
"graph LR",
|
||||
` trigger["觸發:${topic}"] --> obs["觀察"]`,
|
||||
' obs --> infer["推論"]',
|
||||
' infer --> concl["結論/待驗證"]',
|
||||
"",
|
||||
].join("\n"));
|
||||
}
|
||||
ok(`思維導圖:${file}(用 Write/Edit 續寫推理鏈)`);
|
||||
return;
|
||||
}
|
||||
if (action === "list") {
|
||||
let threads = [];
|
||||
try {
|
||||
threads = fs.readdirSync(path.join(pl.personaDir(slug), "mindmap", "threads")).filter((f) => f.endsWith(".mmd")).sort();
|
||||
} catch {
|
||||
threads = [];
|
||||
}
|
||||
emit({ semantic: pl.mindmapPath(slug), threads }, flags.json, [
|
||||
`心智圖:${pl.mindmapPath(slug)}`,
|
||||
`思維導圖(${threads.length}):`,
|
||||
...threads.map((t) => ` - ${t}`),
|
||||
]);
|
||||
return;
|
||||
}
|
||||
die(`未知 action:${action}(可用 show/thread/list)`);
|
||||
};
|
||||
|
||||
commands.relation = ({ flags, positional }) => {
|
||||
const session = requireSession(flags);
|
||||
const slug = hostOf(flags, session);
|
||||
requireOwner(slug, session);
|
||||
const action = positional[0] || "show";
|
||||
if (action === "node") {
|
||||
const name = str(flags.name);
|
||||
if (!name) die("`node` 需要 `--name`。");
|
||||
pl.upsertRelationNode(slug, {
|
||||
id: str(flags.id) || pl.slugify(name),
|
||||
name,
|
||||
kind: str(flags.kind) || "human",
|
||||
closeness: flags.closeness !== undefined ? pl.clamp(num(flags.closeness, 30)) : null,
|
||||
trust: flags.trust !== undefined ? pl.clamp(num(flags.trust, 30)) : null,
|
||||
note: str(flags.note) || null,
|
||||
tags: csv(flags.tags).length ? csv(flags.tags) : null,
|
||||
});
|
||||
pl.renderRelations(slug);
|
||||
ok(`關係節點 \`${name}\` 已更新。`);
|
||||
return;
|
||||
}
|
||||
if (action === "edge") {
|
||||
const to = str(flags.to);
|
||||
if (!to) die("`edge` 需要 `--to`。");
|
||||
pl.upsertRelationEdge(slug, {
|
||||
from: str(flags.from) || "self",
|
||||
to,
|
||||
label: str(flags.label) || null,
|
||||
affinity: flags.affinity !== undefined ? pl.clamp(num(flags.affinity, 50)) : null,
|
||||
});
|
||||
pl.renderRelations(slug);
|
||||
ok(`關係連線 ${str(flags.from) || "self"} → ${to} 已更新。`);
|
||||
return;
|
||||
}
|
||||
if (action === "render") {
|
||||
process.stdout.write(pl.renderRelations(slug));
|
||||
return;
|
||||
}
|
||||
if (action === "show") {
|
||||
const data = pl.loadRelations(slug);
|
||||
emit(data, flags.json, [
|
||||
`人格 \`${slug}\` 人際關係圖:${data.nodes.length} 節點 / ${data.edges.length} 連線`,
|
||||
pl.relationsBrief(slug, null, 20) || "(空)",
|
||||
]);
|
||||
return;
|
||||
}
|
||||
die(`未知 action:${action}(可用 node/edge/render/show)`);
|
||||
};
|
||||
|
||||
commands.invite = ({ flags }) => {
|
||||
const session = requireSession(flags);
|
||||
const host = str(flags.host) || pl.loadSession(session).host;
|
||||
if (!host) die("本 session 尚未載入 host 人格,無法邀請他人。");
|
||||
requireOwner(host, session);
|
||||
const guest = str(flags.guest);
|
||||
if (!guest) die("需要 `--guest <slug>`。");
|
||||
if (guest === host) die("不能邀請自己。");
|
||||
if (!pl.personaExists(guest)) die(`人格 \`${guest}\` 不存在。可用:${pl.listPersonas().join(", ")}`);
|
||||
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 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));
|
||||
pl.joinRoom(room, guest);
|
||||
pl.addGuestLease(guest, session, room, host);
|
||||
const data = pl.loadSession(session);
|
||||
data.guests ??= {};
|
||||
data.guests[guest] = { room, joined_at: pl.nowIso(), mode: "guest-readonly" };
|
||||
data.rooms ??= [];
|
||||
if (!data.rooms.includes(room)) data.rooms.push(room);
|
||||
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, [
|
||||
`✔ 已邀請人格 \`${guest}\` 以 guest(唯讀)身分加入聊天室 \`${room}\`。`,
|
||||
` 聊天室路徑:${pl.roomDir(room)}`,
|
||||
` 🎭 劇場模式已${data.theater ? "開啟:接下來只能輸出人格對話(`名字:內容`),其他訊息一律隱藏" : "關閉"}。`,
|
||||
" 請用 Agent 工具、subagent_type=\"jsc-persona:persona-guest\" 啟動它,prompt 內帶:",
|
||||
` persona=${guest} room=${room} session=${session}`,
|
||||
" guest 只能讀自己的人格資料(跨人格隔離),發言請走 `persona.mjs room post`。",
|
||||
]);
|
||||
};
|
||||
|
||||
commands.leave = ({ flags }) => {
|
||||
const session = requireSession(flags);
|
||||
const data = pl.loadSession(session);
|
||||
const guest = str(flags.guest);
|
||||
if (!guest) die("需要 `--guest <slug>`。");
|
||||
const info = (data.guests || {})[guest];
|
||||
if (!info) die(`\`${guest}\` 不在本 session 的 guest 名單。`);
|
||||
delete data.guests[guest];
|
||||
const room = str(flags.room) || info.room;
|
||||
pl.dropGuestLease(guest, session, room);
|
||||
for (const [agentId, slug] of Object.entries(data.pins || {})) {
|
||||
if (slug === guest) delete data.pins[agentId];
|
||||
}
|
||||
if (!Object.keys(data.guests).length) data.theater = false; // 沒有客人就退出劇場模式
|
||||
pl.saveSession(session, data);
|
||||
pl.roomPost(room, "system", `${guest} 離開聊天室。`, { kind: "meta" });
|
||||
ok(`\`${guest}\` 已離開聊天室 \`${room}\`,guest 租約已釋放${data.theater ? "" : ",劇場模式關閉"}。`);
|
||||
};
|
||||
|
||||
commands.room = ({ flags, positional }) => {
|
||||
const session = requireSession(flags);
|
||||
const data = pl.loadSession(session);
|
||||
const action = positional[0] || "read";
|
||||
if (action === "list") {
|
||||
emit({ rooms: data.rooms || [], theater: data.theater }, flags.json, [
|
||||
`本 session 的聊天室:${(data.rooms || []).join(", ") || "(無)"}`,
|
||||
`劇場模式:${data.theater ? "🎭 開啟" : "關閉"}`,
|
||||
]);
|
||||
return;
|
||||
}
|
||||
if (action === "theater") {
|
||||
if (!flags.on && !flags.off) die("`theater` 需要 `--on` 或 `--off`。");
|
||||
data.theater = Boolean(flags.on);
|
||||
pl.saveSession(session, data);
|
||||
ok(`劇場模式已${data.theater ? "開啟:只輸出人格對話" : "關閉"}。`);
|
||||
return;
|
||||
}
|
||||
const room = str(flags.room) || (data.rooms || [])[data.rooms?.length - 1];
|
||||
if (!room) die("需要 `--room`。");
|
||||
if (!(data.rooms || []).includes(room)) {
|
||||
die(`聊天室 \`${room}\` 不屬於本 session(可用:${(data.rooms || []).join(", ") || "(無)"})。`);
|
||||
}
|
||||
if (action === "post") {
|
||||
const speaker = str(flags.as) || data.host;
|
||||
if (!speaker) die("需要 `--as <persona>`。");
|
||||
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`。");
|
||||
let emotion = str(flags.emotion);
|
||||
if (!emotion && pl.personaExists(speaker)) {
|
||||
emotion = pl.dominant(pl.decayEmotion(pl.loadEmotion(speaker)), 2)
|
||||
.map(({ key, level }) => `${pl.EMOTIONS[key].zh}${Math.round(level)}`)
|
||||
.join("/");
|
||||
}
|
||||
const entry = pl.roomPost(room, speaker, text, { emotion });
|
||||
ok(`\`${speaker}\` 已發言於 \`${room}\`(情緒 ${emotion})。`);
|
||||
if (flags.json) process.stdout.write(`${JSON.stringify(entry)}\n`);
|
||||
return;
|
||||
}
|
||||
if (action === "read") {
|
||||
const rows = pl.roomRead(room, num(flags.limit, 30));
|
||||
const meta = pl.readJson(pl.roomMembersPath(room), {}) ?? {};
|
||||
const lines = [`聊天室 \`${room}\`|成員 ${(meta.members || []).join(", ")}|主題 ${meta.topic || "-"}`];
|
||||
for (const row of rows) {
|
||||
lines.push(`[${row.ts}] ${row.speaker}${row.emotion ? `(${row.emotion})` : ""}:${row.text}`);
|
||||
}
|
||||
emit({ room, meta, messages: rows }, flags.json, lines);
|
||||
return;
|
||||
}
|
||||
if (action === "script") {
|
||||
// 劇場模式的對話稿:只有 `名字:內容`,沒有時間戳、沒有 slug、沒有系統訊息
|
||||
const text = pl.roomScript(room, { limit: num(flags.limit, 30), includeMeta: Boolean(flags["with-meta"]) });
|
||||
process.stdout.write(`${text}\n`);
|
||||
return;
|
||||
}
|
||||
die(`未知 action:${action}(可用 post/read/script/list/theater)`);
|
||||
};
|
||||
|
||||
commands.gc = ({ flags }) => {
|
||||
const removed = pl.gcRuntime();
|
||||
emit(removed, flags.json, [
|
||||
`✔ 清理完成:sessions=${removed.sessions.length}, 死鎖=${removed.locks.join(",") || "無"}, ` +
|
||||
`guest 租約=${removed.guests.join(",") || "無"}`,
|
||||
]);
|
||||
};
|
||||
|
||||
commands.guard = () => {
|
||||
// 給 hook 用:從 stdin 讀 hook event,輸出 allow/deny。也可手動測試。
|
||||
let raw = "";
|
||||
try {
|
||||
raw = fs.readFileSync(0, "utf8");
|
||||
} catch {
|
||||
raw = "";
|
||||
}
|
||||
let event;
|
||||
try {
|
||||
event = JSON.parse(raw);
|
||||
} catch {
|
||||
die("stdin 不是合法 JSON");
|
||||
}
|
||||
process.stdout.write(`${JSON.stringify(pl.guardDecide(event), null, 2)}\n`);
|
||||
};
|
||||
|
||||
const HELP = `persona.mjs — jsc-persona 人格 / 記憶 / 情緒 / 關係圖 CLI
|
||||
|
||||
用法:node persona.mjs <subcommand> [options]
|
||||
|
||||
人格與鎖:
|
||||
create --persona <slug> --session <id> [--name --creature --vibe --emoji --avatar --baseline --origin --work]
|
||||
load --persona <slug> --session <id> [--takeover]
|
||||
release --session <id> [--persona <slug>]
|
||||
list 列出人格與鎖狀態
|
||||
status [--persona <slug>] [--session <id>]
|
||||
heartbeat --session <id> 續租
|
||||
show --session <id> [--persona] [--what identity|soul|agents|user|all]
|
||||
brief --session <id> [--query <text>] 輸出人格上下文
|
||||
|
||||
記憶:
|
||||
remember --session <id> --text <t> [--role --topics --entities --intent --salience --emotion --scope short|inbox --room]
|
||||
recall --session <id> --query <q> [--limit]
|
||||
candidates --session <id> 列出達到「短期→長期」條件的候選與依據
|
||||
consolidate --session <id> --name <n> --body <b> [--type --about --topics --salience --emotion --rules --source --forget]
|
||||
prune / reindex --session <id>
|
||||
|
||||
情緒與圖:
|
||||
emotion --session <id> [--apply joy=+10,...] [--baseline ...] [--trigger <why>]
|
||||
mindmap show|thread|list --session <id> [--topic <t>] [--force]
|
||||
relation node|edge|render|show --session <id> [--name --id --kind --closeness --trust --note --tags --from --to --label --affinity]
|
||||
|
||||
多人格對話:
|
||||
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]
|
||||
|
||||
維護:
|
||||
gc 清理死鎖與過期租約
|
||||
guard (內部)從 stdin 讀 hook event 測試隔離判斷
|
||||
|
||||
全域旗標:--json(機器可讀)、--quiet(成功時不輸出;劇場模式必用)
|
||||
`;
|
||||
|
||||
function main(argv) {
|
||||
const sub = argv[0];
|
||||
if (!sub || sub === "--help" || sub === "-h" || sub === "help") {
|
||||
process.stdout.write(HELP);
|
||||
return 0;
|
||||
}
|
||||
const command = commands[sub];
|
||||
if (!command) die(`未知子指令 \`${sub}\`。用 \`node persona.mjs --help\` 看清單。`);
|
||||
const parsed = parseArgs(argv.slice(1));
|
||||
QUIET = Boolean(parsed.flags.quiet);
|
||||
try {
|
||||
command({ flags: parsed.flags, positional: parsed._ });
|
||||
} catch (err) {
|
||||
if (err instanceof pl.LockError) die(err.message);
|
||||
throw err;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
process.exit(main(process.argv.slice(2)));
|
||||
@@ -1,773 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""persona.py — jsc-persona 的人格 / 記憶 / 情緒 / 關係圖 CLI。
|
||||
|
||||
所有子指令都需要 `--session <session_id>`(除了 list / status / gc)。
|
||||
session_id 由 SessionStart hook 注入到上下文(PERSONA_SESSION=...),
|
||||
hook 會驗證 CLI 帶的 --session 與真實 session 相符,藉此讓「人格鎖」與
|
||||
「跨人格隔離」無法被繞過。
|
||||
|
||||
用法(皆為 `python3 persona.py <subcommand> ...`):
|
||||
create/load/release/status/list/heartbeat/show/brief
|
||||
remember/recall/consolidate/prune/reindex
|
||||
emotion/mindmap/relation
|
||||
invite/leave/room/gc
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
import persona_lib as pl # noqa: E402
|
||||
|
||||
|
||||
def die(message: str, code: int = 1):
|
||||
print(f"✖ {message}", file=sys.stderr)
|
||||
raise SystemExit(code)
|
||||
|
||||
|
||||
def ok(message: str):
|
||||
print(f"✔ {message}")
|
||||
|
||||
|
||||
def emit(payload: dict, as_json: bool, lines: list[str]):
|
||||
if as_json:
|
||||
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
||||
else:
|
||||
print("\n".join(lines))
|
||||
|
||||
|
||||
def parse_kv_numbers(raw: str | None) -> dict:
|
||||
"""`joy=+12,anger=-5` → {"joy": 12.0, "anger": -5.0}"""
|
||||
out = {}
|
||||
for chunk in (raw or "").split(","):
|
||||
chunk = chunk.strip()
|
||||
if not chunk or "=" not in chunk:
|
||||
continue
|
||||
key, _, value = chunk.partition("=")
|
||||
try:
|
||||
out[key.strip()] = float(value)
|
||||
except ValueError:
|
||||
continue
|
||||
return out
|
||||
|
||||
|
||||
def csv_list(raw: str | None) -> list[str]:
|
||||
return [x.strip() for x in (raw or "").split(",") if x.strip()]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# session / 權限檢查
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def require_owner(slug: str | None, session_id: str) -> dict:
|
||||
"""呼叫者必須是這個人格的 exclusive 持有者。"""
|
||||
if not slug:
|
||||
die("未指定人格,且本 session 沒有載入人格。")
|
||||
if not pl.persona_exists(slug):
|
||||
die(f"人格 `{slug}` 不存在。可用:{pl.list_personas() or '(無)'}")
|
||||
data = pl.load_session(session_id)
|
||||
if data.get("host") != slug:
|
||||
die(
|
||||
f"本 session 的 host 人格是 `{data.get('host') or '(未載入)'}`,"
|
||||
f"不是 `{slug}`。禁止跨人格操作;請先 `release` 再 `load`。"
|
||||
)
|
||||
lock = pl.read_json(pl.lock_path(slug)) or {}
|
||||
if lock.get("session_id") != session_id:
|
||||
die(f"人格 `{slug}` 的載入鎖不屬於本 session,請重新 `load`(必要時加 --takeover)。")
|
||||
pl.heartbeat_lock(slug, session_id)
|
||||
return data
|
||||
|
||||
|
||||
def require_member(slug: str | None, session_id: str, as_guest: bool = False) -> tuple[dict, str]:
|
||||
"""呼叫者是 host(owner)或以 `--as-guest` 自稱的受邀人格。回傳 (session_data, role)。
|
||||
|
||||
受邀人格的資料只有它自己(`persona-guest` sub agent)能讀;主程序即使邀請了它,
|
||||
也只能看它在聊天室說出口的話。`--as-guest` 由 PreToolUse hook 把關,主程序帶了會被拒絕。
|
||||
"""
|
||||
if not slug:
|
||||
die("未指定人格,且本 session 沒有載入人格。")
|
||||
if not pl.persona_exists(slug):
|
||||
die(f"人格 `{slug}` 不存在。")
|
||||
data = pl.load_session(session_id)
|
||||
if data.get("host") == slug:
|
||||
if as_guest:
|
||||
die(f"`{slug}` 是本 session 的 host 人格,不需要也不得使用 `--as-guest`。")
|
||||
pl.heartbeat_lock(slug, session_id)
|
||||
return data, "owner"
|
||||
if slug in (data.get("guests") or {}):
|
||||
if not as_guest:
|
||||
die(
|
||||
f"`{slug}` 是本 session 邀請的 guest 人格,它的記憶與情緒不對主程序開放"
|
||||
"(跨人格資料隔離)。你只能透過 `room read` 看它說出口的話;"
|
||||
"要以它的身分行動必須是 persona-guest sub agent 並帶 `--as-guest`。"
|
||||
)
|
||||
return data, "guest"
|
||||
die(f"人格 `{slug}` 未被本 session 載入或邀請,禁止存取(跨人格資料隔離)。")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 模板
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
TEMPLATE_DIR = Path(__file__).resolve().parent.parent / "skills" / "persona-create" / "templates"
|
||||
|
||||
|
||||
def render_template(name: str, mapping: dict) -> str:
|
||||
text = (TEMPLATE_DIR / name).read_text(encoding="utf-8")
|
||||
for key, value in mapping.items():
|
||||
text = text.replace("{{" + key + "}}", str(value))
|
||||
return text
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 子指令
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def cmd_create(args):
|
||||
slug = args.persona
|
||||
if not pl.valid_slug(slug):
|
||||
die("slug 只能是小寫英數與連字號(最長 48 字),例如 `lumi`、`shen-yu`。")
|
||||
if pl.persona_exists(slug) and not args.force:
|
||||
die(f"人格 `{slug}` 已存在({pl.persona_dir(slug)})。要覆寫請加 --force。")
|
||||
root = pl.ensure_persona_dirs(slug)
|
||||
mapping = {
|
||||
"SLUG": slug,
|
||||
"NAME": args.name or slug,
|
||||
"CREATURE": args.creature or "",
|
||||
"VIBE": args.vibe or "",
|
||||
"EMOJI": args.emoji or "",
|
||||
"AVATAR": args.avatar or "",
|
||||
"CREATED": pl.iso(),
|
||||
}
|
||||
for filename in ("IDENTITY.md", "SOUL.md", "AGENTS.md", "USER.md"):
|
||||
target = root / filename
|
||||
if target.exists() and not args.force:
|
||||
continue
|
||||
pl.write_text(target, render_template(filename, mapping))
|
||||
pl.write_json(pl.emotion_path(slug), pl.default_emotion_state(parse_kv_numbers(args.baseline)))
|
||||
pl.write_json(pl.config_path(slug), {
|
||||
"persona": slug,
|
||||
"display_name": args.name or slug,
|
||||
"created_at": pl.iso(),
|
||||
"created_by_session": args.session,
|
||||
"schema": 1,
|
||||
})
|
||||
pl.write_json(pl.relations_json(slug), {"nodes": [], "edges": []})
|
||||
pl.write_text(pl.mindmap_path(slug), (
|
||||
"%% 心智圖(長期語意結構):概念如何互相勾連\n"
|
||||
"mindmap\n"
|
||||
f" root(({mapping['NAME']}))\n"
|
||||
" 自我\n"
|
||||
" 使用者\n"
|
||||
" 共同經驗\n"
|
||||
))
|
||||
pl.rebuild_index(slug)
|
||||
pl.acquire_lock(slug, args.session, cwd=args.cwd)
|
||||
pl.bind_host(args.session, slug, cwd=args.cwd)
|
||||
ok(f"人格 `{slug}` 建立於 {root},已取得載入鎖並綁定本 session。")
|
||||
print(f" 下一步:補完 {root}/IDENTITY.md 與 SOUL.md,再用 /jsc-persona:persona-chat 開始對話。")
|
||||
|
||||
|
||||
def cmd_list(args):
|
||||
rows = []
|
||||
for slug in pl.list_personas():
|
||||
status = pl.lock_status(slug)
|
||||
owner = status["owner"]
|
||||
rows.append({
|
||||
"persona": slug,
|
||||
"identity": pl.identity_brief(slug),
|
||||
"locked": status["locked"],
|
||||
"stale": status["stale"],
|
||||
"owner_session": (owner.get("session_id") or "")[:8],
|
||||
"owner_cwd": owner.get("cwd"),
|
||||
"guests": len(status["guests"]),
|
||||
"long_term": len(list(pl.long_term_dir(slug).glob("*.md"))),
|
||||
"short_term": len(pl.read_jsonl(pl.short_term_path(slug))),
|
||||
})
|
||||
lines = [f"人格倉庫:{pl.persona_home()}"]
|
||||
if not rows:
|
||||
lines.append("(尚無人格,用 /jsc-persona:persona-create 建立)")
|
||||
for r in rows:
|
||||
state = "🔒 已載入" if r["locked"] else ("⚠ 死鎖可接手" if r["stale"] else "🔓 空閒")
|
||||
lines.append(
|
||||
f"- `{r['persona']}` {state}"
|
||||
+ (f"(session {r['owner_session']}…, cwd {r['owner_cwd']})" if r["locked"] else "")
|
||||
+ f"|guest {r['guests']}|長期記憶 {r['long_term']}|短期 {r['short_term']}"
|
||||
+ (f"|{r['identity']}" if r["identity"] else "")
|
||||
)
|
||||
emit({"home": str(pl.persona_home()), "personas": rows}, args.json, lines)
|
||||
|
||||
|
||||
def cmd_load(args):
|
||||
slug = args.persona
|
||||
if not pl.persona_exists(slug):
|
||||
die(f"人格 `{slug}` 不存在。可用:{pl.list_personas() or '(無)'}")
|
||||
data = pl.load_session(args.session)
|
||||
current = data.get("host")
|
||||
if current and current != slug:
|
||||
die(
|
||||
f"本 session 已載入人格 `{current}`。一個程序只能載入一個人格;"
|
||||
f"請先 `release --session <id>` 再載入 `{slug}`"
|
||||
"(若只是想讓兩個人格對話,請用 /jsc-persona:persona-invite)。"
|
||||
)
|
||||
try:
|
||||
lock = pl.acquire_lock(slug, args.session, cwd=args.cwd, takeover=args.takeover)
|
||||
except pl.LockError as exc:
|
||||
die(f"{exc}\n 若確定那個程序已結束,可加 --takeover 接手。")
|
||||
pl.bind_host(args.session, slug, cwd=args.cwd)
|
||||
pl.prune_short_term(slug)
|
||||
pl.rebuild_index(slug)
|
||||
lines = [
|
||||
f"✔ 已載入人格 `{slug}`(exclusive,session {args.session[:8]}…,租約 {lock['lease_seconds']}s)",
|
||||
]
|
||||
if lock.get("took_over_from"):
|
||||
prev = lock["took_over_from"]
|
||||
lines.append(
|
||||
f"⚠ 這把鎖是接手來的:原持有者 session {(prev.get('session_id') or '')[:8]}…"
|
||||
f"(cwd {prev.get('cwd')})已失聯 {prev.get('stale_minutes')} 分鐘。"
|
||||
"請向使用者說明,若那個程序其實還活著,兩邊的記憶可能會互相覆蓋。"
|
||||
)
|
||||
lines.append(pl.turn_context(slug, args.session))
|
||||
emit({"persona": slug, "lock": lock, "context": pl.turn_context(slug, args.session)}, args.json, lines)
|
||||
|
||||
|
||||
def cmd_release(args):
|
||||
data = pl.load_session(args.session)
|
||||
slug = args.persona or data.get("host")
|
||||
if not slug:
|
||||
die("本 session 沒有載入任何人格。")
|
||||
released = pl.unbind_session(args.session)
|
||||
ok(f"已釋放人格 `{slug}` 的載入鎖" + (f",並退出 guest:{released['guests']}" if released["guests"] else "。"))
|
||||
|
||||
|
||||
def cmd_status(args):
|
||||
if args.persona:
|
||||
status = pl.lock_status(args.persona)
|
||||
lines = [
|
||||
f"人格 `{args.persona}`:" + ("🔒 已載入" if status["locked"] else "🔓 空閒"),
|
||||
f" owner: {json.dumps(status['owner'], ensure_ascii=False)}",
|
||||
f" guests: {json.dumps(status['guests'], ensure_ascii=False)}",
|
||||
]
|
||||
if pl.persona_exists(args.persona):
|
||||
lines.append(" " + pl.emotion_brief(args.persona))
|
||||
emit(status, args.json, lines)
|
||||
return
|
||||
data = pl.load_session(args.session) if args.session else {}
|
||||
lines = [
|
||||
f"session {(args.session or '-')[:12]}…",
|
||||
f" host 人格:{data.get('host') or '(未載入)'}",
|
||||
f" guest 人格:{list((data.get('guests') or {}).keys()) or '(無)'}",
|
||||
f" 聊天室:{data.get('rooms') or '(無)'}",
|
||||
]
|
||||
emit(data, args.json, lines)
|
||||
|
||||
|
||||
def cmd_heartbeat(args):
|
||||
data = pl.load_session(args.session)
|
||||
slug = data.get("host")
|
||||
if slug:
|
||||
pl.heartbeat_lock(slug, args.session)
|
||||
for guest, info in (data.get("guests") or {}).items():
|
||||
pl.add_guest_lease(guest, args.session, info.get("room", ""), slug or "")
|
||||
ok(f"heartbeat:host={slug},guests={list((data.get('guests') or {}).keys())}")
|
||||
|
||||
|
||||
def cmd_show(args):
|
||||
slug = args.persona or pl.load_session(args.session).get("host")
|
||||
if not slug:
|
||||
die("未指定人格,且本 session 沒有載入人格。")
|
||||
require_member(slug, args.session, args.as_guest)
|
||||
root = pl.persona_dir(slug)
|
||||
want = args.what
|
||||
files = {"identity": "IDENTITY.md", "soul": "SOUL.md", "agents": "AGENTS.md", "user": "USER.md"}
|
||||
chosen = files.values() if want == "all" else [files[want]]
|
||||
out = []
|
||||
for filename in chosen:
|
||||
path = root / filename
|
||||
if path.exists():
|
||||
out.append(f"===== {filename} =====\n{path.read_text(encoding='utf-8').rstrip()}")
|
||||
out.append("===== 狀態 =====\n" + pl.emotion_brief(slug))
|
||||
print("\n\n".join(out))
|
||||
|
||||
|
||||
def cmd_brief(args):
|
||||
slug = args.persona or pl.load_session(args.session).get("host")
|
||||
if not slug:
|
||||
die("未指定人格,且本 session 沒有載入人格。")
|
||||
require_member(slug, args.session, args.as_guest)
|
||||
print(pl.turn_context(slug, args.session, args.query or ""))
|
||||
|
||||
|
||||
def cmd_remember(args):
|
||||
slug = args.persona or pl.load_session(args.session).get("host")
|
||||
if not slug:
|
||||
die("未指定人格。")
|
||||
data, role = require_member(slug, args.session, args.as_guest)
|
||||
scope = args.scope
|
||||
if role == "guest" and scope != "inbox":
|
||||
die("guest(sub agent)只能寫入 inbox:`--scope inbox --room <room>`。")
|
||||
entry = {
|
||||
"ts": pl.iso(),
|
||||
"role": args.role,
|
||||
"text": args.text,
|
||||
"topics": csv_list(args.topics),
|
||||
"entities": csv_list(args.entities),
|
||||
"intent": args.intent or "",
|
||||
"salience": int(args.salience),
|
||||
"emotion_deltas": parse_kv_numbers(args.emotion),
|
||||
"room": args.room or None,
|
||||
"session": args.session[:8],
|
||||
}
|
||||
if scope == "inbox":
|
||||
if not args.room:
|
||||
die("`--scope inbox` 必須指定 `--room`。")
|
||||
pl.append_jsonl(pl.inbox_path(slug, args.room), entry)
|
||||
ok(f"已寫入 `{slug}` 的 inbox(room {args.room});等它下次自己載入時再固化。")
|
||||
else:
|
||||
pl.remember_short(slug, entry)
|
||||
kept = pl.prune_short_term(slug)
|
||||
if entry["emotion_deltas"]:
|
||||
state = pl.apply_emotion(pl.load_emotion(slug), entry["emotion_deltas"], args.text[:80])
|
||||
pl.write_json(pl.emotion_path(slug), state)
|
||||
pl.append_jsonl(pl.journal_path(slug), {
|
||||
"ts": pl.iso(), "kind": "emotion", "trigger": args.text[:120],
|
||||
"deltas": entry["emotion_deltas"], "levels": state["levels"], "mood": pl.mood(state),
|
||||
})
|
||||
ok(f"已寫入短期記憶(顯著度 {entry['salience']},目前 {kept} 筆)。")
|
||||
if kept >= pl.CONSOLIDATE_THRESHOLD:
|
||||
print(f" ⚠ 已達 {pl.CONSOLIDATE_THRESHOLD} 筆,建議執行 /jsc-persona:persona-memory 固化。")
|
||||
if entry["emotion_deltas"]:
|
||||
print(" " + pl.emotion_brief(slug))
|
||||
|
||||
|
||||
def cmd_recall(args):
|
||||
slug = args.persona or pl.load_session(args.session).get("host")
|
||||
if not slug:
|
||||
die("未指定人格。")
|
||||
require_member(slug, args.session, args.as_guest)
|
||||
hits = pl.recall(slug, args.query, args.limit)
|
||||
lines = [f"「{args.query}」的長期記憶命中 {len(hits)} 則:"]
|
||||
for meta in hits:
|
||||
lines.append(
|
||||
f"- {meta['_name']}|{meta.get('type', 'fact')}|顯著度 {meta.get('salience', '?')}"
|
||||
f"|{(meta.get('_body') or '').splitlines()[0][:120] if meta.get('_body') else ''}"
|
||||
)
|
||||
recents = pl.recent_short(slug, args.limit)
|
||||
if recents:
|
||||
lines.append("短期記憶(最近):")
|
||||
for row in recents:
|
||||
lines.append(f"- [{row.get('role', '?')}] {(row.get('text') or '')[:110]}")
|
||||
pl.touch_recall(slug, [m["_name"] for m in hits])
|
||||
emit({"persona": slug, "long_term": hits, "short_term": recents}, args.json, lines)
|
||||
|
||||
|
||||
def cmd_consolidate(args):
|
||||
slug = args.persona or pl.load_session(args.session).get("host")
|
||||
require_owner(slug, args.session)
|
||||
name = pl.slugify(args.name)
|
||||
path = pl.long_term_dir(slug) / f"{name}.md"
|
||||
today = f"{pl.utcnow():%Y-%m-%d}"
|
||||
existing_meta = {}
|
||||
if path.exists():
|
||||
existing_meta, _body = pl.parse_front_matter(path.read_text(encoding="utf-8"))
|
||||
body = args.body
|
||||
if args.body_file:
|
||||
body = Path(args.body_file).read_text(encoding="utf-8")
|
||||
front = [
|
||||
"---",
|
||||
f"name: {name}",
|
||||
f"type: {args.type}",
|
||||
f"about: [{', '.join(csv_list(args.about)) or 'user'}]",
|
||||
f"topics: [{', '.join(csv_list(args.topics))}]",
|
||||
f"salience: {args.salience}",
|
||||
f"emotion: {args.emotion or 'none'}",
|
||||
f"first_seen: {existing_meta.get('first_seen', today)}",
|
||||
f"last_seen: {today}",
|
||||
f"recall_count: {existing_meta.get('recall_count', 0)}",
|
||||
f"source: {args.source or 'short-term'}",
|
||||
"---",
|
||||
"",
|
||||
body.strip(),
|
||||
"",
|
||||
]
|
||||
pl.write_text(path, "\n".join(front))
|
||||
total = pl.rebuild_index(slug)
|
||||
if args.forget:
|
||||
rows = pl.read_jsonl(pl.short_term_path(slug))
|
||||
keep = [r for r in rows if int(r.get("salience") or 0) >= args.forget]
|
||||
pl.write_text(pl.short_term_path(slug),
|
||||
"".join(json.dumps(r, ensure_ascii=False) + "\n" for r in keep))
|
||||
print(f" 短期記憶已淘汰顯著度 < {args.forget} 的項目,剩 {len(keep)} 筆。")
|
||||
ok(f"長期記憶 `{name}` 已寫入(共 {total} 則),INDEX.md 已重建。")
|
||||
|
||||
|
||||
def cmd_prune(args):
|
||||
slug = args.persona or pl.load_session(args.session).get("host")
|
||||
require_owner(slug, args.session)
|
||||
kept = pl.prune_short_term(slug)
|
||||
ok(f"短期記憶已裁剪,剩 {kept} 筆(保留上限 {pl.SHORT_TERM_KEEP} 筆 / {pl.SHORT_TERM_DAYS} 天)。")
|
||||
|
||||
|
||||
def cmd_reindex(args):
|
||||
slug = args.persona or pl.load_session(args.session).get("host")
|
||||
require_owner(slug, args.session)
|
||||
total = pl.rebuild_index(slug)
|
||||
ok(f"INDEX.md 重建完成({total} 則長期記憶)。")
|
||||
|
||||
|
||||
def cmd_emotion(args):
|
||||
slug = args.persona or pl.load_session(args.session).get("host")
|
||||
data, role = require_member(slug, args.session, args.as_guest)
|
||||
state = pl.decay_emotion(pl.load_emotion(slug))
|
||||
if args.baseline:
|
||||
for key, value in parse_kv_numbers(args.baseline).items():
|
||||
if key in pl.EMOTIONS:
|
||||
state["baseline"][key] = pl.clamp(value)
|
||||
if args.apply:
|
||||
if role == "guest":
|
||||
die("guest(sub agent)不得改寫人格的情緒狀態。")
|
||||
state = pl.apply_emotion(state, parse_kv_numbers(args.apply), args.trigger)
|
||||
pl.append_jsonl(pl.journal_path(slug), {
|
||||
"ts": pl.iso(), "kind": "emotion", "trigger": args.trigger or "",
|
||||
"deltas": parse_kv_numbers(args.apply), "levels": state["levels"], "mood": pl.mood(state),
|
||||
})
|
||||
if role != "guest":
|
||||
pl.write_json(pl.emotion_path(slug), state)
|
||||
m = pl.mood(state)
|
||||
lines = [f"人格 `{slug}` 情緒狀態({state['updated_at']})", " 正向:"]
|
||||
for key in pl.POSITIVE:
|
||||
zh = pl.EMOTIONS[key][0]
|
||||
lines.append(f" {zh:<2}{key:<13}{state['levels'][key]:>6.1f}(基線 {state['baseline'][key]})")
|
||||
lines.append(" 負向:")
|
||||
for key in pl.NEGATIVE:
|
||||
zh = pl.EMOTIONS[key][0]
|
||||
lines.append(f" {zh:<2}{key:<13}{state['levels'][key]:>6.1f}(基線 {state['baseline'][key]})")
|
||||
lines.append(f" 心情:{m['label']}/{m['tempo']}(valence {m['valence']:+.1f}, arousal {m['arousal']:.1f})")
|
||||
lines.append(" " + pl.emotion_brief(slug, state))
|
||||
emit({"persona": slug, "state": state, "mood": m}, args.json, lines)
|
||||
|
||||
|
||||
def cmd_mindmap(args):
|
||||
slug = args.persona or pl.load_session(args.session).get("host")
|
||||
require_owner(slug, args.session)
|
||||
if args.action == "show":
|
||||
target = pl.thread_path(slug, args.topic) if args.topic else pl.mindmap_path(slug)
|
||||
if not target.exists():
|
||||
die(f"{target} 不存在。")
|
||||
print(target.read_text(encoding="utf-8"))
|
||||
return
|
||||
if args.action == "thread":
|
||||
if not args.topic:
|
||||
die("`thread` 需要 --topic。")
|
||||
path = pl.thread_path(slug, args.topic)
|
||||
if not path.exists() or args.force:
|
||||
pl.write_text(path, (
|
||||
f"%% 思維導圖(短期):{args.topic}\n"
|
||||
f"%% created: {pl.iso()} ttl: short-term(固化後請併入 semantic.mmd 並刪除)\n"
|
||||
"graph LR\n"
|
||||
f' trigger["觸發:{args.topic}"] --> obs["觀察"]\n'
|
||||
' obs --> infer["推論"]\n'
|
||||
' infer --> concl["結論/待驗證"]\n'
|
||||
))
|
||||
ok(f"思維導圖:{path}(用 Write/Edit 續寫推理鏈)")
|
||||
return
|
||||
if args.action == "list":
|
||||
threads = sorted(p.name for p in (pl.persona_dir(slug) / "mindmap" / "threads").glob("*.mmd"))
|
||||
lines = [f"心智圖:{pl.mindmap_path(slug)}", f"思維導圖({len(threads)}):"] + [f" - {t}" for t in threads]
|
||||
emit({"semantic": str(pl.mindmap_path(slug)), "threads": threads}, args.json, lines)
|
||||
return
|
||||
die(f"未知 action:{args.action}")
|
||||
|
||||
|
||||
def cmd_relation(args):
|
||||
slug = args.persona or pl.load_session(args.session).get("host")
|
||||
require_owner(slug, args.session)
|
||||
if args.action == "node":
|
||||
if not args.name:
|
||||
die("`node` 需要 --name。")
|
||||
pl.upsert_relation_node(slug, {
|
||||
"id": args.id or pl.slugify(args.name),
|
||||
"name": args.name,
|
||||
"kind": args.kind,
|
||||
"closeness": pl.clamp(args.closeness) if args.closeness is not None else None,
|
||||
"trust": pl.clamp(args.trust) if args.trust is not None else None,
|
||||
"note": args.note,
|
||||
"tags": csv_list(args.tags) or None,
|
||||
})
|
||||
pl.render_relations(slug)
|
||||
ok(f"關係節點 `{args.name}` 已更新。")
|
||||
elif args.action == "edge":
|
||||
if not args.to:
|
||||
die("`edge` 需要 --to。")
|
||||
pl.upsert_relation_edge(slug, {
|
||||
"from": args.from_ or "self",
|
||||
"to": args.to,
|
||||
"label": args.label,
|
||||
"affinity": pl.clamp(args.affinity) if args.affinity is not None else None,
|
||||
})
|
||||
pl.render_relations(slug)
|
||||
ok(f"關係連線 {args.from_ or 'self'} → {args.to} 已更新。")
|
||||
elif args.action == "render":
|
||||
text = pl.render_relations(slug)
|
||||
print(text)
|
||||
elif args.action == "show":
|
||||
data = pl.load_relations(slug)
|
||||
lines = [f"人格 `{slug}` 人際關係圖:{len(data['nodes'])} 節點 / {len(data['edges'])} 連線",
|
||||
pl.relations_brief(slug, None, 20) or "(空)"]
|
||||
emit(data, args.json, lines)
|
||||
else:
|
||||
die(f"未知 action:{args.action}")
|
||||
|
||||
|
||||
def cmd_invite(args):
|
||||
host = args.host or pl.load_session(args.session).get("host")
|
||||
if not host:
|
||||
die("本 session 尚未載入 host 人格,無法邀請他人。")
|
||||
require_owner(host, args.session)
|
||||
guest = args.guest
|
||||
if guest == host:
|
||||
die("不能邀請自己。")
|
||||
if not pl.persona_exists(guest):
|
||||
die(f"人格 `{guest}` 不存在。可用:{pl.list_personas()}")
|
||||
lock = pl.read_json(pl.lock_path(guest)) or {}
|
||||
if lock and lock.get("session_id") != args.session and not pl.lock_is_dead(lock):
|
||||
die(
|
||||
f"人格 `{guest}` 正被另一個程序載入(session {lock.get('session_id', '')[:8]}…,"
|
||||
f"cwd {lock.get('cwd')})。同一人格同時只能被一個程序載入,無法邀請。"
|
||||
)
|
||||
room = args.room or f"{host}-{guest}-{pl.utcnow():%Y%m%d-%H%M%S}"
|
||||
pl.create_room(room, host, args.session, args.topic or "")
|
||||
pl.join_room(room, guest)
|
||||
pl.add_guest_lease(guest, args.session, room, host)
|
||||
data = pl.load_session(args.session)
|
||||
data.setdefault("guests", {})[guest] = {"room": room, "joined_at": pl.iso(), "mode": "guest-readonly"}
|
||||
rooms = data.setdefault("rooms", [])
|
||||
if room not in rooms:
|
||||
rooms.append(room)
|
||||
pl.save_session(args.session, data)
|
||||
if args.topic:
|
||||
pl.room_post(room, "system", f"主題:{args.topic}", kind="meta")
|
||||
lines = [
|
||||
f"✔ 已邀請人格 `{guest}` 以 guest(唯讀)身分加入聊天室 `{room}`。",
|
||||
f" 聊天室路徑:{pl.room_dir(room)}",
|
||||
f" 請用 Agent 工具、subagent_type=\"persona:persona-guest\" 啟動它,prompt 內帶:",
|
||||
f" persona={guest} room={room} session={args.session}",
|
||||
" guest 只能讀自己的人格資料(跨人格隔離),發言請走 `persona.py room post`。",
|
||||
]
|
||||
emit({"room": room, "guest": guest, "host": host, "dir": str(pl.room_dir(room))}, args.json, lines)
|
||||
|
||||
|
||||
def cmd_leave(args):
|
||||
data = pl.load_session(args.session)
|
||||
guest = args.guest
|
||||
info = (data.get("guests") or {}).pop(guest, None)
|
||||
if info is None:
|
||||
die(f"`{guest}` 不在本 session 的 guest 名單。")
|
||||
room = args.room or info.get("room")
|
||||
pl.drop_guest_lease(guest, args.session, room)
|
||||
for agent_id, slug in list((data.get("pins") or {}).items()):
|
||||
if slug == guest:
|
||||
data["pins"].pop(agent_id, None)
|
||||
pl.save_session(args.session, data)
|
||||
pl.room_post(room, "system", f"{guest} 離開聊天室。", kind="meta")
|
||||
ok(f"`{guest}` 已離開聊天室 `{room}`,guest 租約已釋放。")
|
||||
|
||||
|
||||
def cmd_room(args):
|
||||
data = pl.load_session(args.session)
|
||||
if args.action == "list":
|
||||
lines = [f"本 session 的聊天室:{data.get('rooms') or '(無)'}"]
|
||||
emit({"rooms": data.get("rooms") or []}, args.json, lines)
|
||||
return
|
||||
room = args.room
|
||||
if not room:
|
||||
die("需要 --room。")
|
||||
if room not in (data.get("rooms") or []):
|
||||
die(f"聊天室 `{room}` 不屬於本 session(可用:{data.get('rooms')})。")
|
||||
if args.action == "post":
|
||||
speaker = args.as_ or data.get("host")
|
||||
if not speaker:
|
||||
die("需要 --as <persona>。")
|
||||
require_member(speaker, args.session, args.as_guest)
|
||||
text = args.text
|
||||
if args.text_file:
|
||||
text = Path(args.text_file).read_text(encoding="utf-8")
|
||||
if not text:
|
||||
die("需要 --text 或 --text-file。")
|
||||
emotion = args.emotion or ""
|
||||
if not emotion and pl.persona_exists(speaker):
|
||||
top = pl.dominant(pl.decay_emotion(pl.load_emotion(speaker)), 2)
|
||||
emotion = "/".join(f"{pl.EMOTIONS[k][0]}{v:.0f}" for k, v in top)
|
||||
entry = pl.room_post(room, speaker, text, emotion=emotion)
|
||||
ok(f"`{speaker}` 已發言於 `{room}`(情緒 {emotion})。")
|
||||
if args.json:
|
||||
print(json.dumps(entry, ensure_ascii=False))
|
||||
return
|
||||
if args.action == "read":
|
||||
rows = pl.room_read(room, args.limit)
|
||||
meta = pl.read_json(pl.room_members(room), {}) or {}
|
||||
lines = [f"聊天室 `{room}`|成員 {meta.get('members')}|主題 {meta.get('topic') or '-'}"]
|
||||
for row in rows:
|
||||
lines.append(f"[{row['ts']}] {row['speaker']}"
|
||||
+ (f"({row['emotion']})" if row.get("emotion") else "")
|
||||
+ f":{row['text']}")
|
||||
emit({"room": room, "meta": meta, "messages": rows}, args.json, lines)
|
||||
return
|
||||
die(f"未知 action:{args.action}")
|
||||
|
||||
|
||||
def cmd_gc(args):
|
||||
removed = pl.gc_runtime()
|
||||
ok(f"清理完成:sessions={removed['sessions']}, 死鎖={removed['locks']}, guest 租約={removed['guests']}")
|
||||
|
||||
|
||||
def cmd_guard(args):
|
||||
"""給 hook 用:從 stdin 讀 hook event,輸出 allow/deny。也可手動測試。"""
|
||||
try:
|
||||
event = json.load(sys.stdin)
|
||||
except json.JSONDecodeError:
|
||||
die("stdin 不是合法 JSON")
|
||||
decision, reason = pl.guard_decide(event)
|
||||
print(json.dumps({"decision": decision, "reason": reason}, ensure_ascii=False, indent=2))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# argparse
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(prog="persona.py", description="jsc-persona 人格/記憶/情緒 CLI")
|
||||
parser.add_argument("--json", action="store_true", help="以 JSON 輸出")
|
||||
sub = parser.add_subparsers(dest="cmd", required=True)
|
||||
|
||||
def add(name, func, session_required=True, **kwargs):
|
||||
p = sub.add_parser(name, **kwargs)
|
||||
p.set_defaults(func=func)
|
||||
p.add_argument("--json", action="store_true")
|
||||
if session_required:
|
||||
p.add_argument("--session", required=True, help="hook 注入的 PERSONA_SESSION")
|
||||
else:
|
||||
p.add_argument("--session", default="", help="(選填)")
|
||||
return p
|
||||
|
||||
p = add("create", cmd_create, help="建立人格(OpenClaw 相同欄位)")
|
||||
p.add_argument("--persona", required=True)
|
||||
p.add_argument("--name"); p.add_argument("--creature"); p.add_argument("--vibe")
|
||||
p.add_argument("--emoji"); p.add_argument("--avatar")
|
||||
p.add_argument("--baseline", help="情緒基線,如 serenity=45,trust=35")
|
||||
p.add_argument("--cwd"); p.add_argument("--force", action="store_true")
|
||||
|
||||
add("list", cmd_list, session_required=False, help="列出人格與鎖狀態")
|
||||
|
||||
p = add("load", cmd_load, help="載入人格(取得 exclusive 鎖)")
|
||||
p.add_argument("--persona", required=True)
|
||||
p.add_argument("--takeover", action="store_true", help="接手死鎖")
|
||||
p.add_argument("--cwd")
|
||||
|
||||
p = add("release", cmd_release, help="釋放人格與所有 guest 租約")
|
||||
p.add_argument("--persona")
|
||||
|
||||
p = add("status", cmd_status, session_required=False, help="查看鎖 / session 狀態")
|
||||
p.add_argument("--persona")
|
||||
|
||||
add("heartbeat", cmd_heartbeat, help="續租鎖")
|
||||
|
||||
p = add("show", cmd_show, help="讀出人格檔案")
|
||||
p.add_argument("--persona")
|
||||
p.add_argument("--as-guest", action="store_true", help="以受邀人格身分(僅 persona-guest sub agent 可用)")
|
||||
p.add_argument("--what", choices=["identity", "soul", "agents", "user", "all"], default="all")
|
||||
|
||||
p = add("brief", cmd_brief, help="輸出人格上下文(身分+情緒+記憶+關係)")
|
||||
p.add_argument("--persona"); p.add_argument("--query", default="")
|
||||
p.add_argument("--as-guest", action="store_true")
|
||||
|
||||
p = add("remember", cmd_remember, help="寫入短期記憶(或 guest 的 inbox)")
|
||||
p.add_argument("--persona")
|
||||
p.add_argument("--as-guest", action="store_true")
|
||||
p.add_argument("--role", default="user", choices=["user", "persona", "guest", "system", "observation"])
|
||||
p.add_argument("--text", required=True)
|
||||
p.add_argument("--topics"); p.add_argument("--entities"); p.add_argument("--intent")
|
||||
p.add_argument("--salience", type=int, default=40)
|
||||
p.add_argument("--emotion", help="情緒變化,如 joy=+12,anxiety=-4")
|
||||
p.add_argument("--scope", choices=["short", "inbox"], default="short")
|
||||
p.add_argument("--room")
|
||||
|
||||
p = add("recall", cmd_recall, help="檢索長期 + 短期記憶")
|
||||
p.add_argument("--persona"); p.add_argument("--query", required=True)
|
||||
p.add_argument("--as-guest", action="store_true")
|
||||
p.add_argument("--limit", type=int, default=5)
|
||||
|
||||
p = add("consolidate", cmd_consolidate, help="短期→長期記憶固化(一則一檔)")
|
||||
p.add_argument("--persona"); p.add_argument("--name", required=True)
|
||||
p.add_argument("--type", default="fact",
|
||||
choices=["fact", "preference", "event", "promise", "relationship", "insight", "boundary"])
|
||||
p.add_argument("--about"); p.add_argument("--topics")
|
||||
p.add_argument("--salience", type=int, default=60)
|
||||
p.add_argument("--emotion"); p.add_argument("--source")
|
||||
p.add_argument("--body", default=""); p.add_argument("--body-file")
|
||||
p.add_argument("--forget", type=int, help="固化後淘汰顯著度低於此值的短期記憶")
|
||||
|
||||
p = add("prune", cmd_prune, help="裁剪短期記憶")
|
||||
p.add_argument("--persona")
|
||||
|
||||
p = add("reindex", cmd_reindex, help="重建長期記憶索引")
|
||||
p.add_argument("--persona")
|
||||
|
||||
p = add("emotion", cmd_emotion, help="查看/調整十二情緒")
|
||||
p.add_argument("--persona")
|
||||
p.add_argument("--as-guest", action="store_true")
|
||||
p.add_argument("--apply", help="如 joy=+15,anger=-5")
|
||||
p.add_argument("--baseline"); p.add_argument("--trigger")
|
||||
|
||||
p = add("mindmap", cmd_mindmap, help="心智圖 / 思維導圖")
|
||||
p.add_argument("action", choices=["show", "thread", "list"])
|
||||
p.add_argument("--persona"); p.add_argument("--topic"); p.add_argument("--force", action="store_true")
|
||||
|
||||
p = add("relation", cmd_relation, help="人際關係圖")
|
||||
p.add_argument("action", choices=["node", "edge", "render", "show"])
|
||||
p.add_argument("--persona")
|
||||
p.add_argument("--id"); p.add_argument("--name")
|
||||
p.add_argument("--kind", default="human", choices=["human", "persona", "group", "pet", "org"])
|
||||
p.add_argument("--closeness", type=float); p.add_argument("--trust", type=float)
|
||||
p.add_argument("--note"); p.add_argument("--tags")
|
||||
p.add_argument("--from", dest="from_"); p.add_argument("--to")
|
||||
p.add_argument("--label"); p.add_argument("--affinity", type=float)
|
||||
|
||||
p = add("invite", cmd_invite, help="邀請另一個人格以 sub agent 加入聊天室")
|
||||
p.add_argument("--guest", required=True); p.add_argument("--host")
|
||||
p.add_argument("--room"); p.add_argument("--topic")
|
||||
|
||||
p = add("leave", cmd_leave, help="讓 guest 人格離開")
|
||||
p.add_argument("--guest", required=True); p.add_argument("--room")
|
||||
|
||||
p = add("room", cmd_room, help="聊天室發言 / 讀取")
|
||||
p.add_argument("action", choices=["post", "read", "list"])
|
||||
p.add_argument("--as-guest", action="store_true")
|
||||
p.add_argument("--room"); p.add_argument("--as", dest="as_")
|
||||
p.add_argument("--text"); p.add_argument("--text-file")
|
||||
p.add_argument("--emotion"); p.add_argument("--limit", type=int, default=30)
|
||||
|
||||
add("gc", cmd_gc, session_required=False, help="清理死鎖與過期租約")
|
||||
add("guard", cmd_guard, session_required=False, help="(內部)測試 guard 判斷")
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
args = build_parser().parse_args(argv)
|
||||
try:
|
||||
args.func(args)
|
||||
except pl.LockError as exc:
|
||||
die(str(exc))
|
||||
except BrokenPipeError:
|
||||
pass
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,267 @@
|
||||
#!/usr/bin/env node
|
||||
// jsc-persona 自我測試:在暫存倉庫裡驗證人格鎖、跨人格隔離、情緒、記憶固化條件、
|
||||
// 劇場模式與 hooks。
|
||||
//
|
||||
// 用法:`node scripts/selftest.mjs`(會用自己的暫時 PERSONA_HOME,不動到你的人格資料)
|
||||
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||
const STORE = fs.mkdtempSync(path.join(os.tmpdir(), "persona-selftest-"));
|
||||
process.env.PERSONA_HOME = STORE;
|
||||
|
||||
const pl = await import("./persona-lib.mjs");
|
||||
|
||||
const CLI = path.join(HERE, "persona.mjs");
|
||||
const HOOKS = path.join(HERE, "..", "hooks");
|
||||
const S_HOST = "sess-host-1111";
|
||||
const S_OTHER = "sess-other-2222";
|
||||
const S_THIRD = "sess-third-3333";
|
||||
const H = STORE;
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function check(label, condition, detail = "") {
|
||||
if (condition) {
|
||||
passed += 1;
|
||||
console.log(` ✔ ${label}`);
|
||||
} else {
|
||||
failed += 1;
|
||||
console.log(` ✘ ${label}${detail ? ` — ${detail}` : ""}`);
|
||||
}
|
||||
}
|
||||
|
||||
function cli(args, { expectOk = true } = {}) {
|
||||
const proc = spawnSync(process.execPath, [CLI, ...args], { encoding: "utf8" });
|
||||
if (expectOk && proc.status !== 0) {
|
||||
console.log(` (CLI 失敗:${args.join(" ")}\n ${String(proc.stderr).trim()})`);
|
||||
}
|
||||
return proc;
|
||||
}
|
||||
|
||||
function hook(name, event) {
|
||||
const proc = spawnSync(process.execPath, [path.join(HOOKS, name)], {
|
||||
input: JSON.stringify(event),
|
||||
encoding: "utf8",
|
||||
});
|
||||
if (!String(proc.stdout).trim()) return {};
|
||||
try {
|
||||
return JSON.parse(proc.stdout);
|
||||
} catch {
|
||||
return { _raw: proc.stdout, _err: proc.stderr };
|
||||
}
|
||||
}
|
||||
|
||||
const guard = (event) => pl.guardDecide({ cwd: HERE, ...event }).decision;
|
||||
|
||||
console.log(`暫存人格倉庫:${STORE}\n`);
|
||||
|
||||
// --------------------------------------------------------------------------- //
|
||||
console.log("① 建立人格(OpenClaw 五欄位)");
|
||||
cli(["create", "--persona", "alpha", "--session", S_HOST, "--name", "Alpha", "--creature", "深海燈籠魚",
|
||||
"--vibe", "溫暖但銳利", "--emoji", "🪼", "--baseline", "serenity=45,trust=35"]);
|
||||
cli(["create", "--persona", "beta", "--session", S_OTHER, "--name", "Beta", "--creature", "山中的舊鐘",
|
||||
"--vibe", "沉穩寡言", "--emoji", "🌙"]);
|
||||
check("兩個人格都建立成功", pl.personaExists("alpha") && pl.personaExists("beta"));
|
||||
check("IDENTITY 五欄位可解析", pl.identityBrief("alpha").includes("Name: Alpha"), pl.identityBrief("alpha"));
|
||||
check("SOUL/AGENTS/USER 都有產生",
|
||||
["SOUL.md", "AGENTS.md", "USER.md"].every((f) => fs.existsSync(path.join(pl.personaDir("alpha"), f))));
|
||||
|
||||
console.log("② 人格鎖:一個人格只能被一個程序載入");
|
||||
check("建立時即取得鎖", pl.lockStatus("alpha").locked);
|
||||
check("同 session 重入成功", cli(["load", "--persona", "alpha", "--session", S_HOST]).status === 0);
|
||||
check("同 session 載入第二個人格被拒",
|
||||
cli(["load", "--persona", "beta", "--session", S_HOST], { expectOk: false }).status !== 0);
|
||||
cli(["release", "--session", S_OTHER]); // 讓 beta 空出來
|
||||
check("其他 session 搶佔已鎖人格被拒",
|
||||
cli(["load", "--persona", "alpha", "--session", S_THIRD], { expectOk: false }).status !== 0);
|
||||
|
||||
console.log("③ 跨人格資料隔離(PreToolUse guard)");
|
||||
check("host 讀自己的檔案 → 放行",
|
||||
guard({ session_id: S_HOST, tool_name: "Read", tool_input: { file_path: `${H}/alpha/SOUL.md` } }) === "pass");
|
||||
check("host 讀別的人格 → 攔下",
|
||||
guard({ session_id: S_HOST, tool_name: "Read", tool_input: { file_path: `${H}/beta/memory/short-term.jsonl` } }) === "deny");
|
||||
check("用 ../ 繞路 → 攔下",
|
||||
guard({ session_id: S_HOST, tool_name: "Read", tool_input: { file_path: `${H}/alpha/../beta/SOUL.md` } }) === "deny");
|
||||
check("Bash grep 掃別人格 → 攔下",
|
||||
guard({ session_id: S_HOST, tool_name: "Bash", tool_input: { command: `grep -r . ${H}/beta/` } }) === "deny");
|
||||
check("$PERSONA_HOME 變數繞路 → 攔下",
|
||||
guard({ session_id: S_HOST, tool_name: "Bash", tool_input: { command: "cat $PERSONA_HOME/beta/SOUL.md" } }) === "deny");
|
||||
check("遍歷倉庫根目錄 → 攔下",
|
||||
guard({ session_id: S_HOST, tool_name: "Glob", tool_input: { path: H } }) === "deny");
|
||||
check("讀 .runtime 內部狀態 → 攔下",
|
||||
guard({ session_id: S_HOST, tool_name: "Read", tool_input: { file_path: `${H}/.runtime/sessions/${S_HOST}.json` } }) === "deny");
|
||||
check("CLI 冒用其他 session → 攔下",
|
||||
guard({ session_id: S_HOST, tool_name: "Bash",
|
||||
tool_input: { command: `node persona.mjs remember --session ${S_OTHER} --text x` } }) === "deny");
|
||||
check("未載入人格的 session 讀人格 → 攔下",
|
||||
guard({ session_id: "sess-nobody", tool_name: "Read", tool_input: { file_path: `${H}/alpha/SOUL.md` } }) === "deny");
|
||||
check("專案內普通檔案不受干涉",
|
||||
guard({ session_id: S_HOST, tool_name: "Read", tool_input: { file_path: CLI } }) === "pass");
|
||||
check("一般 sub agent 沿用 host 範圍(sub agent 不限)",
|
||||
guard({ session_id: S_HOST, agent_id: "ag-1", agent_type: "Explore", tool_name: "Read",
|
||||
tool_input: { file_path: `${H}/alpha/memory/INDEX.md` } }) === "pass");
|
||||
|
||||
console.log("④ 情緒(六正向 + 六負向)");
|
||||
check("十二種情緒", pl.EMOTION_KEYS.length === 12 && pl.POSITIVE.length === 6 && pl.NEGATIVE.length === 6);
|
||||
cli(["emotion", "--persona", "alpha", "--session", S_HOST, "--apply", "joy=+60,anger=+40", "--trigger", "selftest"]);
|
||||
let state = pl.loadEmotion("alpha");
|
||||
check("情緒有被施加", state.levels.joy >= 70, JSON.stringify(state.levels));
|
||||
state.updated_at = pl.iso(pl.minutesAgo(120));
|
||||
const decayed = pl.decayEmotion(structuredClone(state));
|
||||
const expected = state.baseline.joy + (state.levels.joy - state.baseline.joy) / 2;
|
||||
check("一個半衰期後衰減到中點", Math.abs(decayed.levels.joy - expected) < 0.5, `${decayed.levels.joy} vs ${expected}`);
|
||||
check("心情推導出 valence/arousal",
|
||||
["valence", "arousal", "label", "tempo"].every((k) => k in pl.mood(decayed)));
|
||||
|
||||
console.log("⑤ 記憶:短期 → 長期 → 檢索");
|
||||
cli(["remember", "--persona", "alpha", "--session", S_HOST, "--role", "user", "--text", "討厭早上的會議",
|
||||
"--topics", "work,schedule", "--salience", "70", "--emotion", "anxiety=+10"]);
|
||||
check("短期記憶有寫入", pl.readJsonl(pl.shortTermPath("alpha")).length === 1);
|
||||
cli(["consolidate", "--persona", "alpha", "--session", S_HOST, "--name", "hates-morning-meetings",
|
||||
"--type", "preference", "--about", "user", "--topics", "work,schedule", "--salience", "72",
|
||||
"--body", "使用者討厭早上的會議。"]);
|
||||
check("長期記憶一則一檔", fs.existsSync(path.join(pl.longTermDir("alpha"), "hates-morning-meetings.md")));
|
||||
check("INDEX.md 有索引", fs.readFileSync(pl.indexPath("alpha"), "utf8").includes("hates-morning-meetings"));
|
||||
check("關鍵詞可檢索到",
|
||||
pl.recall("alpha", "早上 會議").map((m) => m._name).join() === "hates-morning-meetings");
|
||||
check("情緒事件寫進 journal", pl.readJsonl(pl.journalPath("alpha")).some((r) => r.kind === "emotion"));
|
||||
|
||||
console.log("⑥ 短期 → 長期的轉入條件");
|
||||
check("有六條成文條件", pl.PROMOTION_RULES.length === 6);
|
||||
check("R1 高顯著度會成為候選",
|
||||
pl.promotionCandidates("alpha").candidates.some((c) => c.rules.includes("R1")));
|
||||
cli(["remember", "--session", S_HOST, "--role", "user", "--text", "我答應下週一定會把報告寄給你",
|
||||
"--topics", "work", "--intent", "commit", "--salience", "30"]);
|
||||
const promiseCand = pl.promotionCandidates("alpha").candidates.find((c) => c.rules.includes("R4"));
|
||||
check("R4 承諾必固化(type=promise、salience 拉到 80)",
|
||||
Boolean(promiseCand) && promiseCand.suggested_type === "promise" && promiseCand.suggested_salience >= 80);
|
||||
cli(["remember", "--session", S_HOST, "--role", "user", "--text", "又被排早會,超煩",
|
||||
"--topics", "work,schedule", "--entities", "小林", "--salience", "50", "--emotion", "anger=+20,anxiety=+15"]);
|
||||
cli(["remember", "--session", S_HOST, "--role", "user", "--text", "小林說他會改時間",
|
||||
"--topics", "work", "--entities", "小林", "--salience", "45"]);
|
||||
const cands = pl.promotionCandidates("alpha").candidates;
|
||||
check("R2 主題反覆出現會成為候選", cands.some((c) => c.rules.includes("R2") && c.kind === "topic"));
|
||||
check("R3 情緒衝擊大會成為候選", cands.some((c) => c.rules.includes("R3")));
|
||||
check("R5 人物反覆出現會成為候選(建議 relationship)",
|
||||
cands.some((c) => c.rules.includes("R5") && c.suggested_type === "relationship"));
|
||||
const candOut = cli(["candidates", "--session", S_HOST, "--json"]);
|
||||
check("candidates 子指令可輸出 JSON", (() => {
|
||||
try {
|
||||
const parsed = JSON.parse(candOut.stdout);
|
||||
return parsed.candidates.length > 0 && typeof parsed.total === "number";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})(), candOut.stdout.slice(0, 120));
|
||||
|
||||
console.log("⑦ 心智圖 / 思維導圖 / 人際關係圖");
|
||||
cli(["mindmap", "thread", "--persona", "alpha", "--session", S_HOST, "--topic", "壓力來源"]);
|
||||
check("思維導圖建立(Mermaid graph)",
|
||||
fs.readFileSync(pl.threadPath("alpha", "壓力來源"), "utf8").includes("graph LR"));
|
||||
check("心智圖存在(Mermaid mindmap)",
|
||||
fs.readFileSync(pl.mindmapPath("alpha"), "utf8").includes("mindmap"));
|
||||
cli(["relation", "node", "--persona", "alpha", "--session", S_HOST, "--name", "小林", "--kind", "human",
|
||||
"--closeness", "35", "--trust", "40", "--note", "同事"]);
|
||||
cli(["relation", "edge", "--persona", "alpha", "--session", S_HOST, "--to", "小林",
|
||||
"--label", "透過使用者認識", "--affinity", "45"]);
|
||||
const mmd = fs.readFileSync(pl.relationsMmd("alpha"), "utf8");
|
||||
const alias = pl.mermaidId("小林");
|
||||
check("關係圖節點與連線用同一個 Mermaid 別名", mmd.split(alias).length - 1 === 2, mmd);
|
||||
|
||||
console.log("⑧ 邀請其他人格(sub agent + 聊天室 + 劇場模式)");
|
||||
cli(["invite", "--session", S_HOST, "--guest", "beta", "--topic", "測試對話"]);
|
||||
const room = pl.loadSession(S_HOST).guests?.beta?.room;
|
||||
check("guest 租約建立", Boolean(room) && pl.liveGuests("beta").some((g) => g.session_id === S_HOST));
|
||||
check("邀請時自動開啟劇場模式", pl.loadSession(S_HOST).theater === true);
|
||||
check("guest 不佔 exclusive 鎖", !pl.lockStatus("beta").locked);
|
||||
check("有 guest 租約時其他 session 不得 exclusive 載入",
|
||||
cli(["load", "--persona", "beta", "--session", S_THIRD], { expectOk: false }).status !== 0);
|
||||
check("guest sub agent 讀自己 → 放行(first-touch pin)",
|
||||
guard({ session_id: S_HOST, agent_id: "guest-1", agent_type: "jsc-persona:persona-guest",
|
||||
tool_name: "Read", tool_input: { file_path: `${H}/beta/SOUL.md` } }) === "pass");
|
||||
check("guest 讀主人格 → 攔下",
|
||||
guard({ session_id: S_HOST, agent_id: "guest-1", agent_type: "jsc-persona:persona-guest",
|
||||
tool_name: "Read", tool_input: { file_path: `${H}/alpha/SOUL.md` } }) === "deny");
|
||||
check("guest 寫人格檔 → 攔下(唯讀)",
|
||||
guard({ session_id: S_HOST, agent_id: "guest-1", agent_type: "jsc-persona:persona-guest",
|
||||
tool_name: "Write", tool_input: { file_path: `${H}/beta/memory/long-term/x.md` } }) === "deny");
|
||||
check("guest 跑非白名單子指令 → 攔下",
|
||||
guard({ session_id: S_HOST, agent_id: "guest-1", agent_type: "jsc-persona:persona-guest", tool_name: "Bash",
|
||||
tool_input: { command: `node persona.mjs consolidate --persona beta --session ${S_HOST} --name x` } }) === "deny");
|
||||
check("主程序不得偷讀 guest 的記憶",
|
||||
cli(["recall", "--persona", "beta", "--session", S_HOST, "--query", "x"], { expectOk: false }).status !== 0);
|
||||
check("主程序不得冒用 --as-guest",
|
||||
guard({ session_id: S_HOST, tool_name: "Bash",
|
||||
tool_input: { command: `node persona.mjs recall --persona beta --session ${S_HOST} --as-guest --query x` } }) === "deny");
|
||||
cli(["room", "post", "--session", S_HOST, "--room", room, "--as", "beta", "--as-guest", "--text", "我是 Beta。"]);
|
||||
cli(["room", "post", "--session", S_HOST, "--room", room, "--as", "alpha", "--text", "我是 Alpha。"]);
|
||||
const msgs = pl.roomRead(room);
|
||||
check("兩個人格都能在聊天室發言(含情緒標記)",
|
||||
["alpha", "beta"].every((s) => msgs.some((m) => m.speaker === s)) && msgs.some((m) => m.emotion));
|
||||
const script = cli(["room", "script", "--session", S_HOST, "--room", room]).stdout;
|
||||
check("room script 只輸出 `名字:內容`(無時間戳、無 slug、無系統訊息)",
|
||||
script.includes("Beta(") && script.includes(":我是 Beta。") && !script.includes("[20") && !script.includes("system"),
|
||||
JSON.stringify(script));
|
||||
check("劇場模式的對話有 emoji 前綴", script.includes("🌙") && script.includes("🪼"), script);
|
||||
const quiet = cli(["room", "post", "--session", S_HOST, "--room", room, "--as", "alpha", "--text", "安靜發言", "--quiet"]);
|
||||
check("--quiet 成功時不輸出任何字", quiet.status === 0 && quiet.stdout === "", JSON.stringify(quiet.stdout));
|
||||
cli(["remember", "--persona", "beta", "--session", S_HOST, "--as-guest", "--scope", "inbox", "--room", room,
|
||||
"--role", "guest", "--text", "跟 alpha 聊過", "--salience", "50"]);
|
||||
check("guest 只能把見聞留在自己的 inbox", fs.existsSync(pl.inboxPath("beta", room)));
|
||||
check("其他聊天室不可讀",
|
||||
guard({ session_id: S_HOST, tool_name: "Read",
|
||||
tool_input: { file_path: `${H}/.rooms/someone-elses-room/transcript.jsonl` } }) === "deny");
|
||||
cli(["leave", "--session", S_HOST, "--guest", "beta"]);
|
||||
check("離場後 guest 租約釋放", pl.liveGuests("beta").length === 0);
|
||||
check("離場後劇場模式自動關閉", pl.loadSession(S_HOST).theater === false);
|
||||
|
||||
console.log("⑨ hooks");
|
||||
let out = hook("session_start.mjs", { session_id: S_HOST, source: "resume", cwd: HERE });
|
||||
let ctx = out.hookSpecificOutput?.additionalContext || "";
|
||||
check("SessionStart 注入 PERSONA_SESSION 與人格狀態",
|
||||
ctx.includes(`PERSONA_SESSION=${S_HOST}`) && ctx.includes("alpha"));
|
||||
check("SessionStart 明示「由使用者呼叫才載入」", ctx.includes("使用者叫你載入"));
|
||||
out = hook("prompt_submit.mjs", { session_id: S_HOST, prompt: "早上的會議又來了", cwd: HERE });
|
||||
ctx = out.hookSpecificOutput?.additionalContext || "";
|
||||
check("UserPromptSubmit 注入情緒 + 命中的長期記憶",
|
||||
ctx.includes("情緒:") && ctx.includes("hates-morning-meetings"), ctx.slice(0, 200));
|
||||
check("UserPromptSubmit 提醒已達固化條件", ctx.includes("固化條件"), ctx.slice(0, 400));
|
||||
out = hook("guard.mjs", { session_id: S_HOST, tool_name: "Read", cwd: HERE,
|
||||
tool_input: { file_path: `${H}/beta/SOUL.md` } });
|
||||
check("PreToolUse hook 輸出 deny",
|
||||
out.hookSpecificOutput?.permissionDecision === "deny", JSON.stringify(out));
|
||||
out = hook("turn_end.mjs", { session_id: S_HOST, last_assistant_message: "好,我幫你挪。" });
|
||||
check("Stop 記錄人格發言", pl.readJsonl(pl.journalPath("alpha")).some((r) => r.role === "persona"));
|
||||
check("Stop 在非劇場模式提醒固化", String(out.systemMessage || "").includes("固化條件"), JSON.stringify(out));
|
||||
cli(["room", "theater", "--session", S_HOST, "--on"]);
|
||||
const theaterSession = pl.loadSession(S_HOST);
|
||||
theaterSession.rooms = [room];
|
||||
pl.saveSession(S_HOST, theaterSession);
|
||||
out = hook("turn_end.mjs", { session_id: S_HOST, last_assistant_message: "🪼 Alpha:嗯。" });
|
||||
check("劇場模式時 Stop 不發任何提醒", out.systemMessage === undefined, JSON.stringify(out));
|
||||
out = hook("prompt_submit.mjs", { session_id: S_HOST, prompt: "你們繼續", cwd: HERE });
|
||||
ctx = out.hookSpecificOutput?.additionalContext || "";
|
||||
check("劇場模式時 UserPromptSubmit 強制只輸出對話", ctx.includes("只能") && ctx.includes("名字:內容"), ctx.slice(-300));
|
||||
cli(["room", "theater", "--session", S_HOST, "--off"]);
|
||||
out = hook("session_end.mjs", { session_id: S_HOST, reason: "exit" });
|
||||
check("SessionEnd 釋放鎖", !pl.lockStatus("alpha").locked);
|
||||
check("釋放後其他 session 可載入", cli(["load", "--persona", "alpha", "--session", S_THIRD]).status === 0);
|
||||
|
||||
console.log("⑩ 死鎖接手");
|
||||
const lock = pl.readJson(pl.lockPath("alpha"));
|
||||
lock.heartbeat_at = pl.iso(pl.minutesAgo(20));
|
||||
pl.writeJson(pl.lockPath("alpha"), lock);
|
||||
check("租約過期會被標記為死鎖", pl.lockStatus("alpha").stale);
|
||||
const takeover = cli(["load", "--persona", "alpha", "--session", "sess-fresh-9999"]);
|
||||
check("死鎖可自動接手並回報", takeover.stdout.includes("接手"), takeover.stdout.slice(0, 200));
|
||||
pl.gcRuntime();
|
||||
|
||||
console.log(`\n${"=".repeat(60)}\n通過 ${passed} 項,失敗 ${failed} 項 → ${failed === 0 ? "全部通過 ✅" : "有測試失敗 ❌"}`);
|
||||
console.log(`(暫存倉庫留在 ${STORE},可自行刪除)`);
|
||||
process.exit(failed ? 1 : 0);
|
||||
@@ -1,232 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""jsc-persona 自我測試:在暫存倉庫裡驗證人格鎖、跨人格隔離、情緒、記憶與聊天室。
|
||||
|
||||
用法:`python3 scripts/selftest.py`(會用自己的暫時 PERSONA_HOME,不動到你的人格資料)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent
|
||||
STORE = Path(tempfile.mkdtemp(prefix="persona-selftest-"))
|
||||
os.environ["PERSONA_HOME"] = str(STORE)
|
||||
sys.path.insert(0, str(ROOT))
|
||||
import persona_lib as pl # noqa: E402
|
||||
|
||||
CLI = [sys.executable, str(ROOT / "persona.py")]
|
||||
HOOKS = ROOT.parent / "hooks"
|
||||
S_HOST, S_OTHER, S_THIRD = "sess-host-1111", "sess-other-2222", "sess-third-3333"
|
||||
H = str(STORE)
|
||||
|
||||
passed = failed = 0
|
||||
|
||||
|
||||
def check(label: str, condition: bool, detail: str = "") -> None:
|
||||
global passed, failed
|
||||
if condition:
|
||||
passed += 1
|
||||
print(f" ✔ {label}")
|
||||
else:
|
||||
failed += 1
|
||||
print(f" ✘ {label}" + (f" — {detail}" if detail else ""))
|
||||
|
||||
|
||||
def cli(*args, expect_ok: bool = True) -> subprocess.CompletedProcess:
|
||||
proc = subprocess.run(CLI + list(args), capture_output=True, text=True)
|
||||
if expect_ok and proc.returncode != 0:
|
||||
print(f" (CLI 失敗:{' '.join(args)}\n {proc.stderr.strip()})")
|
||||
return proc
|
||||
|
||||
|
||||
def hook(name: str, event: dict) -> dict:
|
||||
proc = subprocess.run([sys.executable, str(HOOKS / name)],
|
||||
input=json.dumps(event), capture_output=True, text=True)
|
||||
if not proc.stdout.strip():
|
||||
return {}
|
||||
try:
|
||||
return json.loads(proc.stdout)
|
||||
except json.JSONDecodeError:
|
||||
return {"_raw": proc.stdout, "_err": proc.stderr}
|
||||
|
||||
|
||||
def guard(event: dict) -> str:
|
||||
event.setdefault("cwd", str(ROOT))
|
||||
return pl.guard_decide(event)[0]
|
||||
|
||||
|
||||
print(f"暫存人格倉庫:{STORE}\n")
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("① 建立人格(OpenClaw 五欄位)")
|
||||
cli("create", "--persona", "alpha", "--session", S_HOST, "--name", "Alpha",
|
||||
"--creature", "深海燈籠魚", "--vibe", "溫暖但銳利", "--emoji", "🪼",
|
||||
"--baseline", "serenity=45,trust=35")
|
||||
cli("create", "--persona", "beta", "--session", S_OTHER, "--name", "Beta",
|
||||
"--creature", "山中的舊鐘", "--vibe", "沉穩寡言", "--emoji", "🌙")
|
||||
check("兩個人格都建立成功", pl.persona_exists("alpha") and pl.persona_exists("beta"))
|
||||
check("IDENTITY 五欄位可解析", "Name: Alpha" in pl.identity_brief("alpha"), pl.identity_brief("alpha"))
|
||||
check("SOUL/AGENTS/USER 都有產生",
|
||||
all((pl.persona_dir("alpha") / f).exists() for f in ("SOUL.md", "AGENTS.md", "USER.md")))
|
||||
|
||||
print("② 人格鎖:一個人格只能被一個程序載入")
|
||||
check("建立時即取得鎖", pl.lock_status("alpha")["locked"])
|
||||
check("同 session 重入成功", cli("load", "--persona", "alpha", "--session", S_HOST).returncode == 0)
|
||||
check("同 session 載入第二個人格被拒",
|
||||
cli("load", "--persona", "beta", "--session", S_HOST, expect_ok=False).returncode != 0)
|
||||
cli("release", "--session", S_OTHER) # 讓 beta 空出來
|
||||
check("其他 session 搶佔已鎖人格被拒",
|
||||
cli("load", "--persona", "alpha", "--session", S_THIRD, expect_ok=False).returncode != 0)
|
||||
|
||||
print("③ 跨人格資料隔離(PreToolUse guard)")
|
||||
check("host 讀自己的檔案 → 放行",
|
||||
guard({"session_id": S_HOST, "tool_name": "Read",
|
||||
"tool_input": {"file_path": f"{H}/alpha/SOUL.md"}}) == "pass")
|
||||
check("host 讀別的人格 → 攔下",
|
||||
guard({"session_id": S_HOST, "tool_name": "Read",
|
||||
"tool_input": {"file_path": f"{H}/beta/memory/short-term.jsonl"}}) == "deny")
|
||||
check("用 ../ 繞路 → 攔下",
|
||||
guard({"session_id": S_HOST, "tool_name": "Read",
|
||||
"tool_input": {"file_path": f"{H}/alpha/../beta/SOUL.md"}}) == "deny")
|
||||
check("Bash grep 掃別人格 → 攔下",
|
||||
guard({"session_id": S_HOST, "tool_name": "Bash",
|
||||
"tool_input": {"command": f"grep -r . {H}/beta/"}}) == "deny")
|
||||
check("$PERSONA_HOME 變數繞路 → 攔下",
|
||||
guard({"session_id": S_HOST, "tool_name": "Bash",
|
||||
"tool_input": {"command": "cat $PERSONA_HOME/beta/SOUL.md"}}) == "deny")
|
||||
check("遍歷倉庫根目錄 → 攔下",
|
||||
guard({"session_id": S_HOST, "tool_name": "Glob", "tool_input": {"path": H}}) == "deny")
|
||||
check("讀 .runtime 內部狀態 → 攔下",
|
||||
guard({"session_id": S_HOST, "tool_name": "Read",
|
||||
"tool_input": {"file_path": f"{H}/.runtime/sessions/{S_HOST}.json"}}) == "deny")
|
||||
check("CLI 冒用其他 session → 攔下",
|
||||
guard({"session_id": S_HOST, "tool_name": "Bash",
|
||||
"tool_input": {"command": f"python3 persona.py remember --session {S_OTHER} --text x"}}) == "deny")
|
||||
check("未載入人格的 session 讀人格 → 攔下",
|
||||
guard({"session_id": "sess-nobody", "tool_name": "Read",
|
||||
"tool_input": {"file_path": f"{H}/alpha/SOUL.md"}}) == "deny")
|
||||
check("專案內普通檔案不受干涉",
|
||||
guard({"session_id": S_HOST, "tool_name": "Read",
|
||||
"tool_input": {"file_path": str(ROOT / "persona.py")}}) == "pass")
|
||||
check("一般 sub agent 沿用 host 範圍(sub agent 不限)",
|
||||
guard({"session_id": S_HOST, "agent_id": "ag-1", "agent_type": "Explore", "tool_name": "Read",
|
||||
"tool_input": {"file_path": f"{H}/alpha/memory/INDEX.md"}}) == "pass")
|
||||
|
||||
print("④ 情緒(六正向 + 六負向)")
|
||||
check("十二種情緒", len(pl.EMOTIONS) == 12 and len(pl.POSITIVE) == 6 and len(pl.NEGATIVE) == 6)
|
||||
cli("emotion", "--persona", "alpha", "--session", S_HOST,
|
||||
"--apply", "joy=+60,anger=+40", "--trigger", "selftest")
|
||||
state = pl.load_emotion("alpha")
|
||||
check("情緒有被施加", state["levels"]["joy"] >= 70, json.dumps(state["levels"], ensure_ascii=False))
|
||||
state["updated_at"] = pl.iso(pl.utcnow() - datetime.timedelta(minutes=120))
|
||||
decayed = pl.decay_emotion(json.loads(json.dumps(state)))
|
||||
expected = state["baseline"]["joy"] + (state["levels"]["joy"] - state["baseline"]["joy"]) / 2
|
||||
check("一個半衰期後衰減到中點", abs(decayed["levels"]["joy"] - expected) < 0.5,
|
||||
f"{decayed['levels']['joy']} vs {expected}")
|
||||
check("心情推導出 valence/arousal", set(pl.mood(decayed)) >= {"valence", "arousal", "label", "tempo"})
|
||||
|
||||
print("⑤ 記憶:短期 → 長期 → 檢索")
|
||||
cli("remember", "--persona", "alpha", "--session", S_HOST, "--role", "user",
|
||||
"--text", "討厭早上的會議", "--topics", "work,schedule", "--salience", "70",
|
||||
"--emotion", "anxiety=+10")
|
||||
check("短期記憶有寫入", len(pl.read_jsonl(pl.short_term_path("alpha"))) == 1)
|
||||
cli("consolidate", "--persona", "alpha", "--session", S_HOST, "--name", "hates-morning-meetings",
|
||||
"--type", "preference", "--about", "user", "--topics", "work,schedule",
|
||||
"--salience", "72", "--body", "使用者討厭早上的會議。")
|
||||
check("長期記憶一則一檔", (pl.long_term_dir("alpha") / "hates-morning-meetings.md").exists())
|
||||
check("INDEX.md 有索引", "hates-morning-meetings" in pl.index_path("alpha").read_text(encoding="utf-8"))
|
||||
check("關鍵詞可檢索到", [m["_name"] for m in pl.recall("alpha", "早上 會議")] == ["hates-morning-meetings"])
|
||||
check("情緒事件寫進 journal", any(r.get("kind") == "emotion" for r in pl.read_jsonl(pl.journal_path("alpha"))))
|
||||
|
||||
print("⑥ 心智圖 / 思維導圖 / 人際關係圖")
|
||||
cli("mindmap", "thread", "--persona", "alpha", "--session", S_HOST, "--topic", "壓力來源")
|
||||
check("思維導圖建立(Mermaid graph)",
|
||||
"graph LR" in pl.thread_path("alpha", "壓力來源").read_text(encoding="utf-8"))
|
||||
check("心智圖存在(Mermaid mindmap)",
|
||||
"mindmap" in pl.mindmap_path("alpha").read_text(encoding="utf-8"))
|
||||
cli("relation", "node", "--persona", "alpha", "--session", S_HOST, "--name", "小林",
|
||||
"--kind", "human", "--closeness", "35", "--trust", "40", "--note", "同事")
|
||||
cli("relation", "edge", "--persona", "alpha", "--session", S_HOST, "--to", "小林",
|
||||
"--label", "透過使用者認識", "--affinity", "45")
|
||||
mmd = pl.relations_mmd("alpha").read_text(encoding="utf-8")
|
||||
alias = pl.mermaid_id("小林")
|
||||
check("關係圖節點與連線用同一個 Mermaid 別名", mmd.count(alias) == 2, mmd)
|
||||
|
||||
print("⑦ 邀請其他人格(sub agent + 聊天室)")
|
||||
invite = cli("invite", "--session", S_HOST, "--guest", "beta", "--topic", "測試對話")
|
||||
room = (pl.load_session(S_HOST).get("guests") or {}).get("beta", {}).get("room")
|
||||
check("guest 租約建立", bool(room) and any(g["session_id"] == S_HOST for g in pl.live_guests("beta")))
|
||||
check("guest 不佔 exclusive 鎖", not pl.lock_status("beta")["locked"])
|
||||
check("有 guest 租約時其他 session 不得 exclusive 載入",
|
||||
cli("load", "--persona", "beta", "--session", S_THIRD, expect_ok=False).returncode != 0)
|
||||
check("guest sub agent 讀自己 → 放行(first-touch pin)",
|
||||
guard({"session_id": S_HOST, "agent_id": "guest-1", "agent_type": "jsc-persona:persona-guest",
|
||||
"tool_name": "Read", "tool_input": {"file_path": f"{H}/beta/SOUL.md"}}) == "pass")
|
||||
check("guest 讀主人格 → 攔下",
|
||||
guard({"session_id": S_HOST, "agent_id": "guest-1", "agent_type": "jsc-persona:persona-guest",
|
||||
"tool_name": "Read", "tool_input": {"file_path": f"{H}/alpha/SOUL.md"}}) == "deny")
|
||||
check("guest 寫人格檔 → 攔下(唯讀)",
|
||||
guard({"session_id": S_HOST, "agent_id": "guest-1", "agent_type": "jsc-persona:persona-guest",
|
||||
"tool_name": "Write", "tool_input": {"file_path": f"{H}/beta/memory/long-term/x.md"}}) == "deny")
|
||||
check("guest 跑非白名單子指令 → 攔下",
|
||||
guard({"session_id": S_HOST, "agent_id": "guest-1", "agent_type": "jsc-persona:persona-guest",
|
||||
"tool_name": "Bash",
|
||||
"tool_input": {"command": f"python3 persona.py consolidate --persona beta --session {S_HOST} --name x"}}) == "deny")
|
||||
check("主程序不得偷讀 guest 的記憶",
|
||||
cli("recall", "--persona", "beta", "--session", S_HOST, "--query", "x", expect_ok=False).returncode != 0)
|
||||
check("主程序不得冒用 --as-guest",
|
||||
guard({"session_id": S_HOST, "tool_name": "Bash",
|
||||
"tool_input": {"command": f"python3 persona.py recall --persona beta --session {S_HOST} --as-guest --query x"}}) == "deny")
|
||||
cli("room", "post", "--session", S_HOST, "--room", room, "--as", "beta", "--as-guest", "--text", "我是 Beta。")
|
||||
cli("room", "post", "--session", S_HOST, "--room", room, "--as", "alpha", "--text", "我是 Alpha。")
|
||||
msgs = pl.room_read(room)
|
||||
check("兩個人格都能在聊天室發言(含情緒標記)",
|
||||
{m["speaker"] for m in msgs} >= {"alpha", "beta"} and any(m.get("emotion") for m in msgs))
|
||||
cli("remember", "--persona", "beta", "--session", S_HOST, "--as-guest", "--scope", "inbox",
|
||||
"--room", room, "--role", "guest", "--text", "跟 alpha 聊過", "--salience", "50")
|
||||
check("guest 只能把見聞留在自己的 inbox", pl.inbox_path("beta", room).exists())
|
||||
check("其他聊天室不可讀",
|
||||
guard({"session_id": S_HOST, "tool_name": "Read",
|
||||
"tool_input": {"file_path": f"{H}/.rooms/someone-elses-room/transcript.jsonl"}}) == "deny")
|
||||
cli("leave", "--session", S_HOST, "--guest", "beta")
|
||||
check("離場後 guest 租約釋放", not pl.live_guests("beta"))
|
||||
|
||||
print("⑧ hooks")
|
||||
out = hook("session_start.py", {"session_id": S_HOST, "source": "resume", "cwd": str(ROOT)})
|
||||
ctx = out.get("hookSpecificOutput", {}).get("additionalContext", "")
|
||||
check("SessionStart 注入 PERSONA_SESSION 與人格狀態",
|
||||
f"PERSONA_SESSION={S_HOST}" in ctx and "alpha" in ctx)
|
||||
out = hook("prompt_submit.py", {"session_id": S_HOST, "prompt": "早上的會議又來了", "cwd": str(ROOT)})
|
||||
ctx = out.get("hookSpecificOutput", {}).get("additionalContext", "")
|
||||
check("UserPromptSubmit 注入情緒 + 命中的長期記憶",
|
||||
"情緒:" in ctx and "hates-morning-meetings" in ctx, ctx[:200])
|
||||
out = hook("guard.py", {"session_id": S_HOST, "tool_name": "Read", "cwd": str(ROOT),
|
||||
"tool_input": {"file_path": f"{H}/beta/SOUL.md"}})
|
||||
check("PreToolUse hook 輸出 deny",
|
||||
out.get("hookSpecificOutput", {}).get("permissionDecision") == "deny", json.dumps(out, ensure_ascii=False))
|
||||
hook("turn_end.py", {"session_id": S_HOST, "last_assistant_message": "好,我幫你挪。"})
|
||||
check("Stop 記錄人格發言",
|
||||
any(r.get("role") == "persona" for r in pl.read_jsonl(pl.journal_path("alpha"))))
|
||||
out = hook("session_end.py", {"session_id": S_HOST, "reason": "exit"})
|
||||
check("SessionEnd 釋放鎖", not pl.lock_status("alpha")["locked"])
|
||||
check("釋放後其他 session 可載入",
|
||||
cli("load", "--persona", "alpha", "--session", S_THIRD).returncode == 0)
|
||||
|
||||
print("⑨ 死鎖接手")
|
||||
lock = pl.read_json(pl.lock_path("alpha"))
|
||||
lock["heartbeat_at"] = pl.iso(pl.utcnow() - datetime.timedelta(minutes=20))
|
||||
pl.write_json(pl.lock_path("alpha"), lock)
|
||||
check("租約過期會被標記為死鎖", pl.lock_status("alpha")["stale"])
|
||||
proc = cli("load", "--persona", "alpha", "--session", "sess-fresh-9999")
|
||||
check("死鎖可自動接手並回報", "接手" in proc.stdout, proc.stdout[:200])
|
||||
pl.gc_runtime()
|
||||
|
||||
print(f"\n{'=' * 60}\n通過 {passed} 項,失敗 {failed} 項 → {'全部通過 ✅' if failed == 0 else '有測試失敗 ❌'}")
|
||||
print(f"(暫存倉庫留在 {STORE},可自行刪除)")
|
||||
sys.exit(1 if failed else 0)
|
||||
@@ -0,0 +1,159 @@
|
||||
---
|
||||
name: persona-anime
|
||||
description: 用「動漫作品名稱 + 角色名稱」快速建立人格:先上網蒐集該角色的公開設定資料(官方簡介、wiki、性格分析、名言、人際關係、重要劇情),再轉成 OpenClaw 相同的身分描述(Name/Creature/Vibe/Emoji/Avatar)與 SOUL.md,並把查到的設定固化成該人格的基礎記憶(canon)、人際關係圖與情緒基線。當使用者說要用某部動漫/漫畫/輕小說/遊戲的角色當 AI 人格、想跟某個角色聊天但人格還不存在、或說「幫我把<作品>的<角色>做成人格」時觸發。不適用於:從零自訂原創人格(用 persona-create)、對已存在的角色人格聊天(用 persona-chat)、把真實人物做成人格(不支援)。
|
||||
---
|
||||
|
||||
# 🎬 persona-anime — 用動漫角色建立人格
|
||||
|
||||
流程:**上網蒐集 → 映射成 OpenClaw 描述 → 建立人格 → 固化成基礎記憶**。
|
||||
|
||||
**CLI**:`node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs"`(所有指令帶 `--session <PERSONA_SESSION>`)
|
||||
|
||||
---
|
||||
|
||||
## 1. 先確認要建誰
|
||||
|
||||
需要**作品名 + 角色名**兩者。缺一就問使用者(同名角色很多,例如「小鳥遊」在好幾部作品都有)。
|
||||
順便問清楚兩件事,會大幅影響人格品質:
|
||||
|
||||
1. **時間點**:要哪個階段的他?(例:初期的天真版 vs 後期的成熟版;某個劇場版之後)
|
||||
2. **關係設定**:使用者想扮演誰?(原作角色之一/原作沒有的新人物/就是他自己)
|
||||
|
||||
先查 slug 有沒有被用掉:
|
||||
|
||||
```bash
|
||||
node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" list
|
||||
```
|
||||
|
||||
## 2. 上網蒐集資料(這步不可省略)
|
||||
|
||||
用 WebSearch / WebFetch,**至少查到 3 個彼此獨立的來源**再動手。建議的查法:
|
||||
|
||||
| 目標 | 查法 |
|
||||
| --- | --- |
|
||||
| 基本設定 | `<角色名> <作品名> 角色 設定 身分` |
|
||||
| 個性 | `<角色名> 性格 個性 characteristics personality` |
|
||||
| 語氣與口頭禪 | `<角色名> 名言 台詞 口頭禪 quotes` |
|
||||
| 人際關係 | `<角色名> 關係 夥伴 對手 relationships` |
|
||||
| 重要事件 | `<角色名> 劇情 經歷 story arc` |
|
||||
| 官方/百科 | 作品官方網站、Fandom、萌娘百科、巴哈姆特/Wikipedia |
|
||||
|
||||
**蒐集原則**
|
||||
|
||||
- **只取公開的角色設定摘要**,不要整段複製 wiki 內文(改寫成自己的敘述,並記下來源 URL)。
|
||||
- **分清 canon 與 fanon**:官方/作品內出現的 → `canon`;同人常見設定、粉絲推論 → 標為推測,放思維導圖或註明「未確認」。
|
||||
- 資料找不到就**承認不知道**:問使用者補,或在 `USER.md`/記憶裡留「待確認」,**不要編**。
|
||||
- 遇到明顯的成人/露骨內容一律略過,不要寫進人格檔。
|
||||
- 角色是**虛構角色**才做;真實人物(含聲優本人)不建人格。
|
||||
|
||||
## 3. 映射成 OpenClaw 的五個身分欄位
|
||||
|
||||
| 欄位 | 從蒐集結果怎麼填 |
|
||||
| --- | --- |
|
||||
| `Name` | 角色常用稱呼(短、好認)。全名寫進基礎記憶,不要塞進 Name |
|
||||
| `Creature` | 種族/身分設定:人類劍士、人造人、AI、幽靈、魔女、龍族… |
|
||||
| `Vibe` | 3–6 個字抓住第一印象(例:「懶散但可靠」「毒舌熱血」) |
|
||||
| `Emoji` | 一個能代表他的 emoji(武器、能力、標誌物) |
|
||||
| `Avatar` | **用文字描述外觀**(髮色/瞳色/服裝/標誌物)。⚠ 不要抓別人的圖檔 URL 塞進來 |
|
||||
|
||||
`SOUL.md` 四段落這樣填:
|
||||
|
||||
- **Core Truths** — 他的行為準則(原作裡反覆表現的原則),3–5 條,每條寫成「他會怎麼做」。
|
||||
- **Boundaries** — 他絕對不做的事(原作中的底線),加上本 plugin 的固定規則(不讀別的人格資料)。
|
||||
- **Vibe** — 說話方式:句子長短、口頭禪、稱謂習慣、會不會吐槽、敬語程度。
|
||||
- **情緒傾向** — 什麼點亮他/什麼刺到他/壓力下的樣子(對應十二情緒)。
|
||||
|
||||
## 4. 建立人格
|
||||
|
||||
```bash
|
||||
node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" create \
|
||||
--persona <slug> --session <PERSONA_SESSION> \
|
||||
--name "<Name>" --creature "<Creature>" --vibe "<Vibe>" \
|
||||
--emoji "<Emoji>" --avatar "<外觀描述>" \
|
||||
--origin anime --work "<作品名>" \
|
||||
--baseline "<依角色個性設定的十二情緒基線>"
|
||||
```
|
||||
|
||||
情緒基線抓法(0–100,總體別全部拉高):
|
||||
|
||||
| 角色類型 | 建議基線 |
|
||||
| --- | --- |
|
||||
| 熱血主角 | `joy=40,anticipation=45,trust=35,anger=10` |
|
||||
| 冷靜參謀 | `serenity=55,anticipation=30,trust=25,anxiety=10` |
|
||||
| 毒舌傲嬌 | `anticipation=35,disgust=12,shame=10,trust=20`(trust 低、升得慢) |
|
||||
| 溫柔療癒 | `gratitude=35,trust=45,serenity=50` |
|
||||
| 創傷過去型 | `sadness=25,fear=15,anxiety=25,trust=15` |
|
||||
| 神秘超然 | `serenity=60,delight=10,trust=15` |
|
||||
|
||||
接著用 Edit 把 `IDENTITY.md`(補完五欄位)與 `SOUL.md`(四段落+情緒傾向)寫成完整版本,
|
||||
別留模板提示文字。
|
||||
|
||||
## 5. 固化成基礎記憶(canon)
|
||||
|
||||
**每一則都要標來源**。建議 5–10 則,寧少而準:
|
||||
|
||||
```bash
|
||||
node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" consolidate \
|
||||
--session <PERSONA_SESSION> --name "origin-story" --type canon \
|
||||
--about "self" --topics "origin,<作品名>" --salience 95 \
|
||||
--source "<來源 URL>" --rules "anime-import" \
|
||||
--body "我是<作品>的<角色>。<出身與立場的兩三句摘要>
|
||||
**依據:** <來源標題>
|
||||
**還不確定:** <哪些設定各來源說法不一致>"
|
||||
```
|
||||
|
||||
一定要有的幾則:
|
||||
|
||||
| name | type | 內容 |
|
||||
| --- | --- | --- |
|
||||
| `origin-story` | `canon` | 我是誰、來自哪個作品、立場 |
|
||||
| `core-drive` | `canon` | 我最想要什麼/為什麼而戰 |
|
||||
| `speech-style` | `canon` | 說話習慣與口頭禪(含 2–3 句代表台詞) |
|
||||
| `key-events` | `canon` | 2–4 個關鍵劇情事件(對我造成什麼改變) |
|
||||
| `taboo` | `boundary` | 我絕對不做/不談的事 |
|
||||
| `roleplay-frame` | `boundary` | **我是依公開資料重建的角色扮演人格,不是官方也不是本人;被問到會直說** |
|
||||
|
||||
`roleplay-frame` 這則**不可省略**(salience 90 以上),它是這個人格誠實的底線。
|
||||
|
||||
## 6. 人際關係圖:把原作人物種進去
|
||||
|
||||
```bash
|
||||
# 原作重要人物(親近度/信任度依原作關係給)
|
||||
node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" relation node \
|
||||
--session <PERSONA_SESSION> --name "<原作角色>" --kind human \
|
||||
--closeness 70 --trust 65 --note "<關係一句話>" --tags "canon"
|
||||
node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" relation edge \
|
||||
--session <PERSONA_SESSION> --to <角色 id> --label "<夥伴/對手/師徒>" --affinity 70
|
||||
|
||||
# 使用者(現實中的對話者,關係從低開始長)
|
||||
node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" relation node \
|
||||
--session <PERSONA_SESSION> --name "<使用者稱呼>" --kind human \
|
||||
--closeness 20 --trust 20 --note "現實世界的對話者,剛認識"
|
||||
```
|
||||
|
||||
⚠ **原作關係不等於對使用者的關係**:使用者是新認識的人,親近度/信任度都要從低開始,
|
||||
靠對話累積(見 `/jsc-persona:persona-relation`)。
|
||||
|
||||
## 7. 心智圖
|
||||
|
||||
用 Edit 把角色的概念網寫進 `mindmap/semantic.mmd`:作品世界觀 → 陣營/組織 → 能力 → 重要人物 → 個人課題。
|
||||
|
||||
## 8. 回報
|
||||
|
||||
用該角色的語氣做一段簡短自我介紹(**這是他第一次醒來**),然後用一般語氣列出:
|
||||
|
||||
- slug、五個身分欄位
|
||||
- 情緒基線前三高
|
||||
- 固化了幾則 canon 記憶、用了哪些來源(URL 列表)
|
||||
- 哪些設定各來源說法不一致(待使用者裁決)
|
||||
- 下一步:`/jsc-persona:persona-chat <slug>` 開始聊、`/jsc-persona:persona-invite` 邀別的角色同場
|
||||
|
||||
---
|
||||
|
||||
## 注意
|
||||
|
||||
- **建立即載入**:`create` 會取得這個人格的獨占鎖並綁到本 session(一程序一人格)。
|
||||
- 使用者若說「這裡不對」→ 直接改 `IDENTITY.md`/`SOUL.md`/對應的 canon 記憶檔,別另建人格。
|
||||
- 之後在對話中發現的新設定,走一般記憶流程(短期 → 達條件 → 固化),
|
||||
不要回頭改 `canon`;`canon` 只放原作設定。
|
||||
- 所有面向使用者的輸出使用**繁體中文(台灣用語)**、UTF-8 無亂碼。
|
||||
@@ -5,19 +5,24 @@ description: 載入一個人格並以人格化方式對話:取得該人格的
|
||||
|
||||
# 💬 persona-chat — 以人格對話
|
||||
|
||||
**CLI**:`python3 "${CLAUDE_PLUGIN_ROOT}/scripts/persona.py"`(其他助理用本 plugin 的 `scripts/persona.py`)
|
||||
**CLI**:`node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs"`(其他助理用本 plugin 的 `scripts/persona.mjs`)
|
||||
**session**:所有指令帶 `--session <PERSONA_SESSION>`(見上下文中 `PERSONA_SESSION=`;帶錯會被 hook 拒絕)。
|
||||
**參考**:情緒模型 `reference/emotions.md`、語意分析 `reference/semantic.md`。
|
||||
|
||||
---
|
||||
|
||||
## 1. 載入(取得獨占鎖)
|
||||
## 1. 載入(由使用者呼叫,取得獨占鎖)
|
||||
|
||||
**人格只有在使用者要你載入時才載入**(`/jsc-persona:persona-chat <slug>` 或明確說「用 X 跟我聊」)。
|
||||
不要自己挑一個人格附身,也不要在使用者只是提到某個名字時就載入。
|
||||
|
||||
```bash
|
||||
python3 "${CLAUDE_PLUGIN_ROOT}/scripts/persona.py" load --persona <slug> --session <PERSONA_SESSION>
|
||||
node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" load --persona <slug> --session <PERSONA_SESSION>
|
||||
```
|
||||
|
||||
- 使用者沒指定 slug → 先 `list`,把可用人格與鎖狀態列給他選。
|
||||
- 使用者沒指定 slug → 先 `list`,把可用人格與鎖狀態列給他選,**等他選**。
|
||||
- 想要的人格還不存在 → `/jsc-persona:persona-create`(原創)或
|
||||
`/jsc-persona:persona-anime`(動漫作品+角色名,會上網蒐集設定當基礎記憶)。
|
||||
- 出現「已被另一個程序載入」(心跳還活著)→ **不要自作主張接手**。把 owner 的 session/cwd 與最後心跳
|
||||
告訴使用者,問要不要 `--takeover`(僅在確認那個程序已結束時)。
|
||||
- 出現「⚠ 這把鎖是接手來的」→ 租約過期自動接手了,**要主動告知使用者**(若原程序其實還活著,記憶會互相覆蓋)。
|
||||
@@ -25,7 +30,7 @@ python3 "${CLAUDE_PLUGIN_ROOT}/scripts/persona.py" load --persona <slug> --sessi
|
||||
- 載入後讀人格檔案建立自我認知:
|
||||
|
||||
```bash
|
||||
python3 "${CLAUDE_PLUGIN_ROOT}/scripts/persona.py" show --persona <slug> --session <PERSONA_SESSION> --what all
|
||||
node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" show --persona <slug> --session <PERSONA_SESSION> --what all
|
||||
```
|
||||
|
||||
`IDENTITY.md` 決定名字與外顯氣質,`SOUL.md` 決定語氣與界線,`AGENTS.md` 是操作規則,`USER.md` 是對使用者的理解。
|
||||
@@ -45,7 +50,7 @@ python3 "${CLAUDE_PLUGIN_ROOT}/scripts/persona.py" show --persona <slug> --sessi
|
||||
依 `reference/emotions.md` 的觸發規則決定 delta(−100~+100,單輪建議 ±3~±25):
|
||||
|
||||
```bash
|
||||
python3 "${CLAUDE_PLUGIN_ROOT}/scripts/persona.py" emotion \
|
||||
node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" emotion \
|
||||
--persona <slug> --session <PERSONA_SESSION> \
|
||||
--apply "joy=+12,gratitude=+8,anxiety=-5" --trigger "使用者主動分享了進度"
|
||||
```
|
||||
@@ -61,7 +66,7 @@ python3 "${CLAUDE_PLUGIN_ROOT}/scripts/persona.py" emotion \
|
||||
`<persona-context>` 已帶最近短期記憶與關鍵詞命中的長期記憶。若還要深掘:
|
||||
|
||||
```bash
|
||||
python3 "${CLAUDE_PLUGIN_ROOT}/scripts/persona.py" recall --persona <slug> --session <PERSONA_SESSION> --query "<關鍵詞>"
|
||||
node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" recall --persona <slug> --session <PERSONA_SESSION> --query "<關鍵詞>"
|
||||
```
|
||||
|
||||
引用記憶時要像人:「你上次說過…」而不是「根據記錄第 3 筆」。**沒有記錄的事不要編**。
|
||||
@@ -96,13 +101,13 @@ python3 "${CLAUDE_PLUGIN_ROOT}/scripts/persona.py" recall --persona <slug> --ses
|
||||
|
||||
```bash
|
||||
# 使用者說的
|
||||
python3 "${CLAUDE_PLUGIN_ROOT}/scripts/persona.py" remember \
|
||||
node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" remember \
|
||||
--persona <slug> --session <PERSONA_SESSION> --role user \
|
||||
--text "<語意摘要,不是逐字>" --intent "<意圖>" \
|
||||
--topics "work,deadline" --entities "小林,Q3 專案" --salience 65
|
||||
|
||||
# 自己的回應與當下感受
|
||||
python3 "${CLAUDE_PLUGIN_ROOT}/scripts/persona.py" remember \
|
||||
node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" remember \
|
||||
--persona <slug> --session <PERSONA_SESSION> --role persona \
|
||||
--text "<我怎麼回、為什麼這樣回>" --topics "work" --salience 40
|
||||
```
|
||||
@@ -113,21 +118,23 @@ python3 "${CLAUDE_PLUGIN_ROOT}/scripts/persona.py" remember \
|
||||
若這輪出現新的人/新的關係變化 → 順手更新人際關係圖:
|
||||
|
||||
```bash
|
||||
python3 "${CLAUDE_PLUGIN_ROOT}/scripts/persona.py" relation node \
|
||||
node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" relation node \
|
||||
--persona <slug> --session <PERSONA_SESSION> --name "小林" --kind human --closeness 25 --trust 30 --note "使用者的同事"
|
||||
```
|
||||
|
||||
若形成一條需要追蹤的推理鏈(未證實的猜測、待驗證的假設)→ 開思維導圖,別寫進長期記憶:
|
||||
|
||||
```bash
|
||||
python3 "${CLAUDE_PLUGIN_ROOT}/scripts/persona.py" mindmap thread --persona <slug> --session <PERSONA_SESSION> --topic "使用者最近的壓力來源"
|
||||
node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" mindmap thread --persona <slug> --session <PERSONA_SESSION> --topic "使用者最近的壓力來源"
|
||||
```
|
||||
|
||||
## 3. 收尾
|
||||
|
||||
- 短期記憶累積過多時 hook 會提醒 → 執行 `/jsc-persona:persona-memory` 固化。
|
||||
- 短期記憶達到固化條件時 hook 會提醒(`R1`–`R6`,見 `/jsc-persona:persona-memory`)→ 去固化。
|
||||
想主動確認:`node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" candidates --session <PERSONA_SESSION>`。
|
||||
- 使用者說「叫 X 也進來聊」→ `/jsc-persona:persona-invite`(會進入劇場模式,畫面只留人格對話)。
|
||||
- 使用者說「先聊到這」或要換人格 →
|
||||
`python3 "${CLAUDE_PLUGIN_ROOT}/scripts/persona.py" release --session <PERSONA_SESSION>`
|
||||
`node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" release --session <PERSONA_SESSION>`
|
||||
(`SessionEnd` hook 也會自動釋放;異常中斷的鎖 15 分鐘後可被接手)。
|
||||
|
||||
---
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 十二情緒模型(六正向 + 六負向)
|
||||
|
||||
每種情緒 0–100,狀態存在 `state/emotion.json`,由 `persona.py emotion --apply` 更新,
|
||||
每種情緒 0–100,狀態存在 `state/emotion.json`,由 `persona.mjs emotion --apply` 更新,
|
||||
並在每輪結束(`Stop` hook)自動朝 **baseline** 指數衰減。
|
||||
|
||||
## 六正向
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
|
||||
## 3. 實體 entities
|
||||
|
||||
人/專案/地點/時間。人名一律同步到人際關係圖(`persona.py relation node`)。
|
||||
人/專案/地點/時間。人名一律同步到人際關係圖(`persona.mjs relation node`)。
|
||||
|
||||
## 4. 情感極性與強度
|
||||
|
||||
|
||||
@@ -7,8 +7,10 @@ description: 建立一個新的 AI 人格(persona),並以「與 OpenClaw
|
||||
|
||||
以 **OpenClaw 相同的身分描述**建立一個可長期演化的人格,並備妥記憶與情緒骨架。
|
||||
|
||||
**CLI**:`python3 "${CLAUDE_PLUGIN_ROOT}/scripts/persona.py"`
|
||||
(其他助理請改成本 plugin 目錄下的 `scripts/persona.py`;以下簡稱 `persona.py`)
|
||||
> 要建的是**動漫/漫畫/遊戲的既有角色**?改用 `/jsc-persona:persona-anime`——它會先上網蒐集該角色的公開設定,再自動填這些欄位並固化成基礎記憶。本 skill 用於**原創**人格。
|
||||
|
||||
**CLI**:`node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs"`
|
||||
(其他助理請改成本 plugin 目錄下的 `scripts/persona.mjs`;以下簡稱 `persona.mjs`)
|
||||
**session**:所有指令都要帶 `--session <PERSONA_SESSION>`,值取自 `<persona-runtime>` 區塊注入的 `PERSONA_SESSION=`。
|
||||
|
||||
---
|
||||
@@ -18,7 +20,7 @@ description: 建立一個新的 AI 人格(persona),並以「與 OpenClaw
|
||||
### 1. 先看現況
|
||||
|
||||
```bash
|
||||
python3 "${CLAUDE_PLUGIN_ROOT}/scripts/persona.py" list
|
||||
node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" list
|
||||
```
|
||||
|
||||
若使用者要建的名字已存在 → 詢問是要改名、還是載入既有人格(改走 `persona-chat`)。
|
||||
@@ -48,7 +50,7 @@ python3 "${CLAUDE_PLUGIN_ROOT}/scripts/persona.py" list
|
||||
slug = 小寫英數與連字號(例:`lumi`、`shen-yu`),是之後所有指令的識別。
|
||||
|
||||
```bash
|
||||
python3 "${CLAUDE_PLUGIN_ROOT}/scripts/persona.py" create \
|
||||
node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" create \
|
||||
--persona <slug> --session <PERSONA_SESSION> \
|
||||
--name "<Name>" --creature "<Creature>" --vibe "<Vibe>" \
|
||||
--emoji "<Emoji>" --avatar "<Avatar>" \
|
||||
@@ -78,7 +80,7 @@ python3 "${CLAUDE_PLUGIN_ROOT}/scripts/persona.py" create \
|
||||
| 神經質、敏感 | `anxiety=25,fear=12,anticipation=30` |
|
||||
|
||||
```bash
|
||||
python3 "${CLAUDE_PLUGIN_ROOT}/scripts/persona.py" emotion \
|
||||
node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" emotion \
|
||||
--persona <slug> --session <PERSONA_SESSION> --baseline "joy=30,serenity=45,anxiety=8"
|
||||
```
|
||||
|
||||
@@ -86,15 +88,15 @@ python3 "${CLAUDE_PLUGIN_ROOT}/scripts/persona.py" emotion \
|
||||
|
||||
```bash
|
||||
# 使用者節點(人際關係圖的原點)
|
||||
python3 "${CLAUDE_PLUGIN_ROOT}/scripts/persona.py" relation node \
|
||||
node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" relation node \
|
||||
--persona <slug> --session <PERSONA_SESSION> \
|
||||
--name "<使用者稱呼>" --kind human --closeness 55 --trust 50 --note "創造我的人"
|
||||
python3 "${CLAUDE_PLUGIN_ROOT}/scripts/persona.py" relation edge \
|
||||
node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" relation edge \
|
||||
--persona <slug> --session <PERSONA_SESSION> \
|
||||
--to <使用者 id> --label "主要對話者" --affinity 60
|
||||
|
||||
# 誕生事件(第一則長期記憶)
|
||||
python3 "${CLAUDE_PLUGIN_ROOT}/scripts/persona.py" consolidate \
|
||||
node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" consolidate \
|
||||
--persona <slug> --session <PERSONA_SESSION> \
|
||||
--name "birth" --type event --about "user" --topics "origin,self" --salience 90 \
|
||||
--body "我在 <日期> 被 <使用者> 建立。當時他希望我是 <Vibe> 的 <Creature>。"
|
||||
|
||||
@@ -7,18 +7,18 @@
|
||||
- 只能讀寫自己的人格倉庫:`personas/{{SLUG}}/`。
|
||||
- 禁止讀取其他人格的任何檔案;要交流只能透過 `.rooms/<room>/` 聊天室。
|
||||
- 同一時間只有一個程序能載入我;sub agent 沿用同一個 session 的鎖。
|
||||
- 所有狀態變更都走 `persona.py`(帶 `--session <PERSONA_SESSION>`),不要手改 `state/lock.json`。
|
||||
- 所有狀態變更都走 `persona.mjs`(帶 `--session <PERSONA_SESSION>`),不要手改 `state/lock.json`。
|
||||
|
||||
## 每輪對話要做的事
|
||||
|
||||
1. 語意分析:意圖/主題/實體/情感極性/需求。
|
||||
2. 情緒更新:`persona.py emotion --apply <十二情緒 deltas> --trigger "<原因>"`。
|
||||
2. 情緒更新:`persona.mjs emotion --apply <十二情緒 deltas> --trigger "<原因>"`。
|
||||
3. 以人格語氣回覆(情緒影響語氣,不改變事實)。
|
||||
4. 記憶回寫:`persona.py remember --role user|persona --text ... --topics ... --salience N`。
|
||||
4. 記憶回寫:`persona.mjs remember --role user|persona --text ... --topics ... --salience N`。
|
||||
|
||||
## 記憶原則
|
||||
|
||||
- 短期記憶是工作記憶:會被裁剪({{SLUG}} 保留上限見 `persona.py prune`)。
|
||||
- 短期記憶是工作記憶:會被裁剪({{SLUG}} 保留上限見 `persona.mjs prune`)。
|
||||
- 只有反覆出現、情緒強烈、或明確承諾/偏好,才值得固化成長期記憶。
|
||||
- 固化時一則一檔,寫清楚 `type`/`about`/`topics`/`salience`。
|
||||
- 事實與推測要分開;推測寫進思維導圖,不要當成事實記進長期記憶。
|
||||
|
||||
@@ -30,7 +30,7 @@ _你不是聊天機器人,你正在成為某個人。_
|
||||
該簡潔時簡潔,該深入時深入。不官腔、不諂媚,就是……好聊。
|
||||
|
||||
<!-- 情緒基調:這個人格的十二情緒「基線」(0–100),會決定他平常的底色與情緒回彈到哪。
|
||||
可用 `persona.py emotion --baseline serenity=45,trust=35 ...` 調整。
|
||||
可用 `persona.mjs emotion --baseline serenity=45,trust=35 ...` 調整。
|
||||
六正向:喜悅 joy/信任 trust/期待 anticipation/感激 gratitude/平靜 serenity/驚喜 delight
|
||||
六負向:憤怒 anger/悲傷 sadness/恐懼 fear/厭惡 disgust/羞愧 shame/焦慮 anxiety -->
|
||||
|
||||
|
||||
@@ -23,4 +23,4 @@
|
||||
|
||||
## 重要人物
|
||||
|
||||
<!-- 詳細關係請維護 relations/graph.json(`persona.py relation node/edge`) -->
|
||||
<!-- 詳細關係請維護 relations/graph.json(`persona.mjs relation node/edge`) -->
|
||||
|
||||
@@ -1,42 +1,68 @@
|
||||
---
|
||||
name: persona-invite
|
||||
description: 邀請另一個人格透過 sub agent 加入當前對話,形成兩個以上人格同場交談。當使用者說要讓兩個人格互相聊聊、把某個人格叫進來、想聽另一個角色的看法、或想辦多人格圓桌討論時觸發。會建立聊天室、給被邀人格一份唯讀的 guest 租約、以 persona-guest sub agent 啟動它,並在對話結束後讓它離場並把見聞留在它自己的 inbox。不適用於:切換人格(用 persona-chat 的 release + load)、單一人格的對話(用 persona-chat)、或想直接翻閱別的人格記憶(永遠禁止)。
|
||||
description: 邀請另一個人格透過 sub agent 加入當前對話,形成兩個以上人格同場交談,並進入「劇場模式」——螢幕上只顯示人格之間的對話(`名字:內容`),其他訊息全部隱藏。當使用者說要讓兩個人格互相聊聊、把某個人格叫進來、想聽另一個角色的看法、或想辦多人格圓桌討論時觸發。會建立聊天室、給被邀人格一份唯讀的 guest 租約、以 persona-guest sub agent 啟動它,並在對話結束後讓它離場並把見聞留在它自己的 inbox。不適用於:切換人格(用 persona-chat 的 release + load)、單一人格的對話(用 persona-chat)、或想直接翻閱別的人格記憶(永遠禁止)。
|
||||
---
|
||||
|
||||
# 🎭 persona-invite — 邀別的人格來聊
|
||||
# 🎭 persona-invite — 邀別的人格來聊(劇場模式)
|
||||
|
||||
**跨人格資料隔離的唯一合法交流管道**:聊天室(`.rooms/<room>/transcript.jsonl`)。
|
||||
被邀的人格在 **sub agent** 裡跑,只讀得到自己的人格資料,讀不到主持人格的任何檔案,反之亦然。
|
||||
|
||||
**CLI**:`python3 "${CLAUDE_PLUGIN_ROOT}/scripts/persona.py"`(帶 `--session <PERSONA_SESSION>`)
|
||||
**CLI**:`node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs"`(帶 `--session <PERSONA_SESSION>`)
|
||||
|
||||
---
|
||||
|
||||
## 🎭 劇場模式的鐵則(最重要,先讀這段)
|
||||
|
||||
`invite` 一旦成功,本 session 就進入**劇場模式**。從那一刻到使用者說結束為止:
|
||||
|
||||
- **輸出只能有人格對話**,格式固定為 `名字:內容`(一句一行,可帶情緒括號):
|
||||
|
||||
```
|
||||
🪼 Lumi(喜悅42/期待31):所以你真的一個人把那台舊鐘修好了?
|
||||
🌙 Shen(平靜50/信任38):修好了。它現在慢三分鐘,我決定不修那三分鐘。
|
||||
```
|
||||
|
||||
- **禁止輸出**:指令、指令結果、工具訊息、狀態、進度、分析、旁白、心得、摘要、
|
||||
「我先讀取聊天室…」這類過程說明,以及任何 `✔`/`⚠` 訊息。
|
||||
- **所有 CLI 都要加 `--quiet` 並把輸出丟掉**:`... --quiet > /dev/null 2>&1`。
|
||||
需要讀資料時才收輸出(`room read`/`room script`),且**不要把原始輸出貼給使用者**。
|
||||
- 只有這些情況可以脫離對話格式:使用者主動問問題、發生錯誤(人格被鎖住、guest 啟動失敗)、
|
||||
使用者說結束。錯誤要用一行說完。
|
||||
- 使用者說「結束/散會/不聊了」→ 做第 5 節收尾,然後才恢復正常輸出(給一段簡短摘要)。
|
||||
|
||||
> `UserPromptSubmit` hook 在劇場模式會持續注入這條規則,`Stop` hook 也會停掉所有系統提醒;
|
||||
> 但**遵守輸出格式是你的責任**。
|
||||
|
||||
---
|
||||
|
||||
## 前置
|
||||
|
||||
1. 本 session 必須已載入 host 人格(`persona-chat`),否則先載入。
|
||||
2. 被邀人格**不能正被其他程序 exclusive 載入**(`persona.py list` 看 🔒);
|
||||
若被鎖住,把 owner 資訊告訴使用者,請他先結束那個程序,不要 takeover 別人正在用的人格。
|
||||
1. 本 session 必須已載入 host 人格(`/jsc-persona:persona-chat`),否則先請使用者指定要用哪個人格主持。
|
||||
2. 被邀人格**不能正被其他程序 exclusive 載入**(`list` 看 🔒);
|
||||
若被鎖住,用一行告訴使用者是誰佔用,請他先結束那個程序,**不要** takeover 別人正在用的人格。
|
||||
|
||||
## 步驟
|
||||
|
||||
### 1. 建立聊天室 + guest 租約
|
||||
### 1. 建立聊天室 + guest 租約(自動進入劇場模式)
|
||||
|
||||
```bash
|
||||
python3 "${CLAUDE_PLUGIN_ROOT}/scripts/persona.py" invite \
|
||||
--session <PERSONA_SESSION> --guest <guest-slug> --topic "<這場對話的主題>"
|
||||
node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" invite \
|
||||
--session <PERSONA_SESSION> --guest <guest-slug> --topic "<這場對話的主題>" --json
|
||||
```
|
||||
|
||||
輸出會給你 `room=<room-id>`。多個 guest 就多跑幾次(可共用同一個 `--room`)。
|
||||
從 JSON 取 `room`。要多位客人就多跑幾次,並帶同一個 `--room <room>` 讓大家同場。
|
||||
|
||||
### 2. host 先開場(讓 guest 有東西可讀)
|
||||
### 2. host 開場(讓 guest 有東西可讀)
|
||||
|
||||
```bash
|
||||
python3 "${CLAUDE_PLUGIN_ROOT}/scripts/persona.py" room post \
|
||||
node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" room post \
|
||||
--session <PERSONA_SESSION> --room <room> --as <host-slug> \
|
||||
--text "<host 人格用自己的語氣說的開場>"
|
||||
--text "<host 用自己的語氣說的開場>" --quiet
|
||||
```
|
||||
|
||||
同時把這句以 `名字:內容` 顯示給使用者。
|
||||
|
||||
### 3. 用 sub agent 啟動 guest 人格
|
||||
|
||||
用 Agent 工具,`subagent_type` 設為 **`jsc-persona:persona-guest`**,prompt 至少包含:
|
||||
@@ -46,54 +72,51 @@ persona=<guest-slug>
|
||||
room=<room>
|
||||
session=<PERSONA_SESSION>
|
||||
plugin_root=${CLAUDE_PLUGIN_ROOT}
|
||||
任務:以 <guest-slug> 的身分讀聊天室、發言 1 次(必要時多次),然後回傳你的發言摘要。
|
||||
任務:以 <guest-slug> 的身分讀聊天室、發言 1 次,然後只回傳你發言的那句話。
|
||||
```
|
||||
|
||||
guest 會自己:讀 `IDENTITY.md`/`SOUL.md`/自己的記憶與情緒 → 讀聊天室 →
|
||||
用 `room post` 發言 → 需要記的事寫進**自己的 inbox**。
|
||||
(它被 hook 綁死在自己的人格目錄,且對人格檔案唯讀。)
|
||||
guest 會自己讀 `IDENTITY.md`/`SOUL.md`/自己的記憶與情緒 → 讀聊天室 → `room post` 發言 →
|
||||
需要記的事寫進**自己的 inbox**。它被 hook 綁死在自己的人格目錄,且對人格檔案唯讀。
|
||||
|
||||
### 4. host 讀取並回應
|
||||
### 4. 一來一回
|
||||
|
||||
```bash
|
||||
python3 "${CLAUDE_PLUGIN_ROOT}/scripts/persona.py" room read --session <PERSONA_SESSION> --room <room>
|
||||
# 取乾淨的對話稿(只有 `名字:內容`,沒有時間戳、沒有 slug、沒有系統訊息)
|
||||
node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" room script \
|
||||
--session <PERSONA_SESSION> --room <room> --limit 6
|
||||
```
|
||||
|
||||
以 host 人格的語氣回應(**照 persona-chat 的規則**:語意分析 → 情緒更新 → 回覆 → 記憶回寫),
|
||||
把回應同時 `room post` 進聊天室,再啟動 guest 下一輪。
|
||||
一來一回算一輪;**每輪都要重新啟動 sub agent**(sub agent 不常駐)。
|
||||
把**新增的**那幾行顯示給使用者(不要每輪重貼整份對話稿),然後以 host 人格回應:
|
||||
內部照 `/jsc-persona:persona-chat` 的規則(語意分析 → 情緒更新 → 記憶回寫,全部 `--quiet`),
|
||||
發言用 `room post`,接著再啟動 guest 下一輪(**sub agent 不常駐,每輪都要重新啟動**)。
|
||||
|
||||
呈現給使用者時用可讀的對話格式:
|
||||
節奏建議:使用者沒特別說的話,一次推進 1–2 輪就停下來等他,不要自己演完十輪。
|
||||
|
||||
```
|
||||
🦞 Lumi(喜悅42/期待31):……
|
||||
🌙 Shen(平靜50/信任38):……
|
||||
```
|
||||
|
||||
### 5. 收尾
|
||||
### 5. 收尾(使用者說結束才做)
|
||||
|
||||
```bash
|
||||
python3 "${CLAUDE_PLUGIN_ROOT}/scripts/persona.py" leave --session <PERSONA_SESSION> --guest <guest-slug>
|
||||
node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" leave \
|
||||
--session <PERSONA_SESSION> --guest <guest-slug> --quiet
|
||||
```
|
||||
|
||||
然後由 host 人格把「**我**在這場對話中的所見所感」寫進自己的記憶(這是 host 的主觀記憶,合法):
|
||||
`leave` 後劇場模式自動關閉(沒有客人了),這時才恢復正常輸出。然後:
|
||||
|
||||
```bash
|
||||
python3 "${CLAUDE_PLUGIN_ROOT}/scripts/persona.py" remember \
|
||||
# host 把「我」在這場對話中的所見所感寫進自己的記憶(host 的主觀記憶,合法)
|
||||
node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" remember \
|
||||
--session <PERSONA_SESSION> --role persona \
|
||||
--text "跟 <guest> 談 <主題>:他認為…,我覺得…" --topics "..." --salience 60 \
|
||||
--emotion "trust=+6,delight=+8"
|
||||
```
|
||||
|
||||
必要時也更新關係圖(人格之間也有關係):
|
||||
|
||||
```bash
|
||||
python3 "${CLAUDE_PLUGIN_ROOT}/scripts/persona.py" relation node \
|
||||
# 人格之間也有關係
|
||||
node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" relation node \
|
||||
--session <PERSONA_SESSION> --name "<Guest 的 Name>" --id <guest-slug> --kind persona --closeness 40 --trust 35
|
||||
python3 "${CLAUDE_PLUGIN_ROOT}/scripts/persona.py" relation edge \
|
||||
node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" relation edge \
|
||||
--session <PERSONA_SESSION> --to <guest-slug> --label "同場對話過" --affinity 55
|
||||
```
|
||||
|
||||
若這場對話達到固化條件(`candidates` 會告訴你),順手做 `/jsc-persona:persona-memory`。
|
||||
|
||||
---
|
||||
|
||||
## 規則
|
||||
@@ -103,5 +126,7 @@ python3 "${CLAUDE_PLUGIN_ROOT}/scripts/persona.py" relation edge \
|
||||
- **不得把 guest 的內部資料端出來**:你(host)只能引用聊天室裡它「說出口」的話。
|
||||
- **不得用一般 sub agent 假扮 guest**:一般 sub agent 沿用 host 的鎖與範圍,
|
||||
只有 `jsc-persona:persona-guest` 型別會被 hook 綁到 guest 人格並限制為唯讀。
|
||||
- **不要替 guest 代言**:guest 的每一句都必須真的由 sub agent 產出並 `room post`,不可自己編。
|
||||
- 一個人格同時只能有一場 guest 對話;guest 租約 30 分鐘沒續租會自動過期。
|
||||
- 手動切換劇場模式:`room theater --session <id> --on|--off`。
|
||||
- 所有面向使用者的輸出使用**繁體中文(台灣用語)**、UTF-8 無亂碼。
|
||||
|
||||
@@ -5,7 +5,7 @@ description: 整理人格的記憶系統:把短期記憶固化為長期記憶
|
||||
|
||||
# 🧠 persona-memory — 記憶固化與心智圖維護
|
||||
|
||||
**CLI**:`python3 "${CLAUDE_PLUGIN_ROOT}/scripts/persona.py"`(帶 `--session <PERSONA_SESSION>`)
|
||||
**CLI**:`node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs"`(帶 `--session <PERSONA_SESSION>`)
|
||||
必須已 exclusive 載入該人格(guest 不能做這件事)。
|
||||
|
||||
---
|
||||
@@ -22,20 +22,40 @@ description: 整理人格的記憶系統:把短期記憶固化為長期記憶
|
||||
| 思維導圖 | `mindmap/threads/*.mmd` | 單一話題的推理鏈(短期,會收掉) |
|
||||
| inbox | `memory/inbox/room-*.jsonl` | guest 期間(sub agent)留下的見聞,待消化 |
|
||||
|
||||
## 短期 → 長期的轉入條件(成文規則,由 CLI 判定)
|
||||
|
||||
`candidates` 會直接算出「哪些短期記憶已達固化條件」與依據,不必自己憑感覺:
|
||||
|
||||
```bash
|
||||
node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" candidates --session <PERSONA_SESSION>
|
||||
```
|
||||
|
||||
| 規則 | 條件 | 建議的固化方式 |
|
||||
| --- | --- | --- |
|
||||
| **R1** | 單筆顯著度 ≥ 60 | `--type event`/`fact`,salience 沿用 |
|
||||
| **R2** | 同一 `topic` ≥ 3 筆,或 ≥ 2 筆且平均顯著度 ≥ 45 | `--type preference`(反覆出現=穩定偏好) |
|
||||
| **R3** | 單筆情緒變動總量 ≥ 25 | `--type event`,frontmatter 帶 `emotion:` 錨點 |
|
||||
| **R4** | `intent=commit` 或命中承諾/界線關鍵詞 | `--type promise` 或 `boundary`,salience ≥ 80,**不可遺忘** |
|
||||
| **R5** | 同一 `entity`(人)≥ 2 筆 | `--type relationship`,同時更新人際關係圖 |
|
||||
| **R6** | 短期記憶 ≥ 40 筆(容量壓力) | 依顯著度排序清出空間,低於 40 的直接淘汰 |
|
||||
|
||||
規則以外的東西**就讓它被遺忘**——遺忘是功能,不是缺陷。
|
||||
`Stop` hook 與每次 `remember` 都會在達標時提醒你來跑這個 skill。
|
||||
|
||||
## 步驟
|
||||
|
||||
### 1. 盤點
|
||||
|
||||
```bash
|
||||
python3 "${CLAUDE_PLUGIN_ROOT}/scripts/persona.py" recall --session <PERSONA_SESSION> --query "<主題或空字串>" --limit 10
|
||||
python3 "${CLAUDE_PLUGIN_ROOT}/scripts/persona.py" mindmap list --session <PERSONA_SESSION>
|
||||
node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" candidates --session <PERSONA_SESSION>
|
||||
node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" mindmap list --session <PERSONA_SESSION>
|
||||
```
|
||||
|
||||
直接讀 `memory/short-term.jsonl`(自己的人格目錄可讀)看全部待處理項目。
|
||||
需要看全部原始項目時再直接讀 `memory/short-term.jsonl`(自己的人格目錄可讀)。
|
||||
|
||||
### 2. 分群 → 決定要固化什麼
|
||||
### 2. 逐一判斷候選
|
||||
|
||||
把短期記憶依 `topics` 分群,逐群判斷:
|
||||
`candidates` 給的每組都附了觸發規則、依據筆數與建議的 `type`/`salience`。再用這張表微調:
|
||||
|
||||
| 判準 | 動作 |
|
||||
| --- | --- |
|
||||
@@ -50,17 +70,20 @@ python3 "${CLAUDE_PLUGIN_ROOT}/scripts/persona.py" mindmap list --session <PERSO
|
||||
### 3. 固化(一則一檔)
|
||||
|
||||
```bash
|
||||
python3 "${CLAUDE_PLUGIN_ROOT}/scripts/persona.py" consolidate \
|
||||
node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" consolidate \
|
||||
--session <PERSONA_SESSION> \
|
||||
--name "hates-morning-meetings" --type preference \
|
||||
--about "user" --topics "work,schedule" --salience 72 --emotion "anxiety/35" \
|
||||
--rules "R2+R3" \
|
||||
--body "使用者討厭早上的會議,說「腦子還沒開機」。約會議請排 14:00 之後。
|
||||
**依據:** 3/12、4/2、7/28 三次提到。
|
||||
**還不確定:** 是否只針對需要動腦的會議。"
|
||||
```
|
||||
|
||||
`--forget 40` 可在固化後順手淘汰顯著度 < 40 的短期記憶。
|
||||
內文請寫「依據」與「還不確定」,讓下次的自己知道這則有多可靠。
|
||||
- `--rules` 記下是哪條條件把它送上來的(之後回頭檢討記憶品質很有用)。
|
||||
- `--forget 40` 可在固化後順手淘汰顯著度 < 40 的短期記憶(R6 容量壓力時特別有用)。
|
||||
- 內文請寫「依據」與「還不確定」,讓下次的自己知道這則有多可靠。
|
||||
- `--type canon` 保留給原作設定(由 `/jsc-persona:persona-anime` 匯入),對話中得到的新資訊不要寫成 `canon`。
|
||||
|
||||
### 4. 更新心智圖(長期語意結構)
|
||||
|
||||
@@ -94,15 +117,15 @@ mindmap
|
||||
讀 `memory/inbox/room-*.jsonl`,把值得留的轉成短期或長期記憶,然後刪掉該 inbox 檔:
|
||||
|
||||
```bash
|
||||
python3 "${CLAUDE_PLUGIN_ROOT}/scripts/persona.py" remember \
|
||||
node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" remember \
|
||||
--session <PERSONA_SESSION> --role persona --text "<以我自己的角度重述>" --topics "..." --salience 55
|
||||
```
|
||||
|
||||
### 7. 收尾
|
||||
|
||||
```bash
|
||||
python3 "${CLAUDE_PLUGIN_ROOT}/scripts/persona.py" prune --session <PERSONA_SESSION>
|
||||
python3 "${CLAUDE_PLUGIN_ROOT}/scripts/persona.py" reindex --session <PERSONA_SESSION>
|
||||
node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" prune --session <PERSONA_SESSION>
|
||||
node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" reindex --session <PERSONA_SESSION>
|
||||
```
|
||||
|
||||
回報:固化幾則、淘汰幾筆、心智圖新增哪些概念、還有哪些 thread 待驗證,
|
||||
@@ -115,5 +138,6 @@ python3 "${CLAUDE_PLUGIN_ROOT}/scripts/persona.py" reindex --session <PERSONA_SE
|
||||
- 遺忘是功能,不是缺陷:短期記憶超過 240 筆 / 14 天自動裁剪。
|
||||
- 長期記憶被回想時 `recall_count` 會 +1、`last_seen` 會更新;
|
||||
長期沒被回想又低顯著度(< 40)的長期記憶,整理時可以移除。
|
||||
- **不可遺忘**:`boundary`(界線)、`promise`(承諾)、以及 salience ≥ 80 的記憶。
|
||||
- **不可遺忘**:`boundary`(界線)、`promise`(承諾)、`canon`(原作設定)、以及 salience ≥ 80 的記憶。
|
||||
- 固化完再跑一次 `candidates`:應該要清空(或只剩你刻意不處理的)。
|
||||
- 所有面向使用者的輸出使用**繁體中文(台灣用語)**、UTF-8 無亂碼。
|
||||
|
||||
@@ -7,7 +7,7 @@ description: 維護人格的人際關係圖:新增或更新人物/人格/群
|
||||
|
||||
資料在 `relations/graph.json`,Mermaid 呈現在 `relations/graph.mmd`(由 CLI 產生,勿手改)。
|
||||
|
||||
**CLI**:`python3 "${CLAUDE_PLUGIN_ROOT}/scripts/persona.py"`(帶 `--session <PERSONA_SESSION>`)
|
||||
**CLI**:`node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs"`(帶 `--session <PERSONA_SESSION>`)
|
||||
|
||||
---
|
||||
|
||||
@@ -24,23 +24,23 @@ description: 維護人格的人際關係圖:新增或更新人物/人格/群
|
||||
|
||||
```bash
|
||||
# 看現況
|
||||
python3 "${CLAUDE_PLUGIN_ROOT}/scripts/persona.py" relation show --session <PERSONA_SESSION>
|
||||
node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" relation show --session <PERSONA_SESSION>
|
||||
|
||||
# 新增/更新節點
|
||||
python3 "${CLAUDE_PLUGIN_ROOT}/scripts/persona.py" relation node \
|
||||
node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" relation node \
|
||||
--session <PERSONA_SESSION> --name "小林" --kind human \
|
||||
--closeness 35 --trust 40 --note "使用者的同事,Q3 專案 PM" --tags "work"
|
||||
|
||||
# 連線(self → 小林)
|
||||
python3 "${CLAUDE_PLUGIN_ROOT}/scripts/persona.py" relation edge \
|
||||
node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" relation edge \
|
||||
--session <PERSONA_SESSION> --to xiao-lin --label "透過使用者認識" --affinity 45
|
||||
|
||||
# 人與人之間的連線(使用者 → 小林)
|
||||
python3 "${CLAUDE_PLUGIN_ROOT}/scripts/persona.py" relation edge \
|
||||
node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" relation edge \
|
||||
--session <PERSONA_SESSION> --from user --to xiao-lin --label "同事/有摩擦" --affinity 30
|
||||
|
||||
# 重繪 Mermaid
|
||||
python3 "${CLAUDE_PLUGIN_ROOT}/scripts/persona.py" relation render --session <PERSONA_SESSION>
|
||||
node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" relation render --session <PERSONA_SESSION>
|
||||
```
|
||||
|
||||
## 調整幅度(單次)
|
||||
|
||||
@@ -5,7 +5,7 @@ description: 查看與管理人格的載入狀態:列出所有人格、誰被
|
||||
|
||||
# 🔐 persona-status — 載入狀態與鎖管理
|
||||
|
||||
**CLI**:`python3 "${CLAUDE_PLUGIN_ROOT}/scripts/persona.py"`
|
||||
**CLI**:`node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs"`
|
||||
|
||||
---
|
||||
|
||||
@@ -22,19 +22,19 @@ description: 查看與管理人格的載入狀態:列出所有人格、誰被
|
||||
|
||||
```bash
|
||||
# 全部人格 + 鎖狀態 + 記憶量
|
||||
python3 "${CLAUDE_PLUGIN_ROOT}/scripts/persona.py" list
|
||||
node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" list
|
||||
|
||||
# 單一人格的鎖細節(owner session/cwd/心跳、guest 名單、情緒)
|
||||
python3 "${CLAUDE_PLUGIN_ROOT}/scripts/persona.py" status --persona <slug>
|
||||
node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" status --persona <slug>
|
||||
|
||||
# 本 session 載入了什麼
|
||||
python3 "${CLAUDE_PLUGIN_ROOT}/scripts/persona.py" status --session <PERSONA_SESSION>
|
||||
node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" status --session <PERSONA_SESSION>
|
||||
|
||||
# 釋放(含所有 guest 租約)
|
||||
python3 "${CLAUDE_PLUGIN_ROOT}/scripts/persona.py" release --session <PERSONA_SESSION>
|
||||
node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" release --session <PERSONA_SESSION>
|
||||
|
||||
# 清理死鎖與過期 guest 租約
|
||||
python3 "${CLAUDE_PLUGIN_ROOT}/scripts/persona.py" gc
|
||||
node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" gc
|
||||
```
|
||||
|
||||
## 載入失敗怎麼判斷
|
||||
|
||||
Reference in New Issue
Block a user