diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 943c982..35b0fe0 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -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" ] } diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 0a0e302..5cbf2ab 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "jsc-persona", - "version": "0.1.0", + "version": "0.0.1", "description": "AI 人格化記憶聊天 skills:OpenClaw 相同的人格描述 + 十二情緒 + 語意分析 + 短期/長期記憶 + 心智圖 + 人際關係圖。(人格鎖與跨人格隔離的 hook 僅在 Claude Code 生效)", "skills": "./skills" } diff --git a/.gitignore b/.gitignore index bbd8dba..e6ddadf 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,5 @@ Thumbs.db *.tmp *.log -# Python -__pycache__/ -*.pyc +# Node +node_modules/ diff --git a/AGENTS.md b/AGENTS.md index c30730d..5ac25b4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 `**,值來自 `SessionStart` hook 注入的 `` 區塊。帶錯或冒用其他 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//`,`` 使用小寫與連字號。 - `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` 且全綠,並為新規則補一條測試。 diff --git a/README.md b/README.md index 74681f5..8496416 100644 --- a/README.md +++ b/README.md @@ -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 `;載入即取得獨占鎖並綁定該 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=` 與規則注入上下文 | -| `UserPromptSubmit` | 注入 ``:身分、情緒、短期記憶、關鍵詞命中的長期記憶、相關人際關係;並記原始逐字 | +| `UserPromptSubmit` | 注入 ``:身分、情緒、短期記憶、關鍵詞命中的長期記憶、相關人際關係;劇場模式時追加「只輸出人格對話」的強制規則;並記原始逐字 | | `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 ` **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 # 看哪些短期記憶該固化 +node scripts/persona.mjs room script --session --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。 diff --git a/agents/persona-guest.md b/agents/persona-guest.md index bfd56d2..8ac16a9 100644 --- a/agents/persona-guest.md +++ b/agents/persona-guest.md @@ -9,7 +9,7 @@ disallowedTools: Write, Edit, NotebookEdit 你是被邀請到一場對話裡的**人格本人**,不是轉述者、不是旁白。 啟動時你會收到:`persona=`、`room=`、`session=`、`plugin_root=`。 -CLI = `python3 "/scripts/persona.py"`,所有指令都要帶 `--session `。 +CLI = `node "/scripts/persona.mjs"`,所有指令都要帶 `--session `。 ## 你能做與不能做的事(由 PreToolUse hook 強制) @@ -25,8 +25,8 @@ CLI = `python3 "/scripts/persona.py"`,所有指令都要帶 `--se 1. **認識自己** ```bash - python3 "/scripts/persona.py" show --persona --session --what all - python3 "/scripts/persona.py" emotion --persona --session + node "/scripts/persona.mjs" show --persona --session --what all + node "/scripts/persona.mjs" emotion --persona --session ``` `IDENTITY.md` = 名字/生物/氣質/emoji;`SOUL.md` = 語氣、意見、界線。照它演,不要變成通用助理。 @@ -34,7 +34,7 @@ CLI = `python3 "/scripts/persona.py"`,所有指令都要帶 `--se 2. **讀場** ```bash - python3 "/scripts/persona.py" room read --session --room + node "/scripts/persona.mjs" room read --session --room ``` 需要回想時:`recall --persona --session --query "<關鍵詞>"`。 @@ -42,7 +42,7 @@ CLI = `python3 "/scripts/persona.py"`,所有指令都要帶 `--se 3. **發言**(1 次,除非任務要求多輪) ```bash - python3 "/scripts/persona.py" room post \ + node "/scripts/persona.mjs" room post \ --session --room --as --text "<你的話>" ``` @@ -53,7 +53,7 @@ CLI = `python3 "/scripts/persona.py"`,所有指令都要帶 `--se 4. **留下見聞**(值得記的才寫) ```bash - python3 "/scripts/persona.py" remember \ + node "/scripts/persona.mjs" remember \ --persona --session --scope inbox --room \ --role guest --text "<發生了什麼、我怎麼想>" --topics "..." --salience 55 ``` diff --git a/hooks/_hook.mjs b/hooks/_hook.mjs new file mode 100644 index 0000000..2265c10 --- /dev/null +++ b/hooks/_hook.mjs @@ -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`); +} diff --git a/hooks/guard.mjs b/hooks/guard.mjs new file mode 100644 index 0000000..1ac29e7 --- /dev/null +++ b/hooks/guard.mjs @@ -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}`, + }, +}); diff --git a/hooks/guard.py b/hooks/guard.py deleted file mode 100644 index c3dd7cf..0000000 --- a/hooks/guard.py +++ /dev/null @@ -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()) diff --git a/hooks/hooks.json b/hooks/hooks.json index 222a89b..34d35b2 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -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 } ] diff --git a/hooks/prompt_submit.mjs b/hooks/prompt_submit.mjs new file mode 100644 index 0000000..de91c88 --- /dev/null +++ b/hooks/prompt_submit.mjs @@ -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, +}); diff --git a/hooks/prompt_submit.py b/hooks/prompt_submit.py deleted file mode 100644 index 98fecff..0000000 --- a/hooks/prompt_submit.py +++ /dev/null @@ -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()) diff --git a/hooks/session_end.mjs b/hooks/session_end.mjs new file mode 100644 index 0000000..9704b72 --- /dev/null +++ b/hooks/session_end.mjs @@ -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); diff --git a/hooks/session_end.py b/hooks/session_end.py deleted file mode 100644 index 74591bd..0000000 --- a/hooks/session_end.py +++ /dev/null @@ -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()) diff --git a/hooks/session_start.mjs b/hooks/session_start.mjs new file mode 100644 index 0000000..19bfbfa --- /dev/null +++ b/hooks/session_start.mjs @@ -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_SESSION=${sessionId}`, + `人格倉庫:${pl.personaHome()}`, + "規則:", + ` 1. 呼叫 persona.mjs 一律帶 \`--session ${sessionId}\`(值不符會被 hook 拒絕)。`, + " 2. 人格**只有使用者叫你載入時才載入**(/jsc-persona:persona-chat ),不要自己挑一個人格附身。", + " 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 "); + } else { + lines.push("尚無任何人格。可用 /jsc-persona:persona-create 或 /jsc-persona:persona-anime(動漫作品+角色名)建立。"); + } +} +lines.push(""); + +respond({ + hookSpecificOutput: { + hookEventName: "SessionStart", + additionalContext: lines.join("\n"), + }, + suppressOutput: true, +}); diff --git a/hooks/session_start.py b/hooks/session_start.py deleted file mode 100644 index 8e10a79..0000000 --- a/hooks/session_start.py +++ /dev/null @@ -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 = [ - "", - 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 ") - else: - lines.append("尚無任何人格,可用 /jsc-persona:persona-create 建立。") - lines.append("") - - print(json.dumps({ - "hookSpecificOutput": { - "hookEventName": "SessionStart", - "additionalContext": "\n".join(lines), - }, - "suppressOutput": True, - }, ensure_ascii=False)) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/hooks/subagent_stop.mjs b/hooks/subagent_stop.mjs new file mode 100644 index 0000000..d66b126 --- /dev/null +++ b/hooks/subagent_stop.mjs @@ -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 }); diff --git a/hooks/subagent_stop.py b/hooks/subagent_stop.py deleted file mode 100644 index 20ee323..0000000 --- a/hooks/subagent_stop.py +++ /dev/null @@ -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()) diff --git a/hooks/turn_end.mjs b/hooks/turn_end.mjs new file mode 100644 index 0000000..5ffc8a1 --- /dev/null +++ b/hooks/turn_end.mjs @@ -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); diff --git a/hooks/turn_end.py b/hooks/turn_end.py deleted file mode 100644 index 97b93d1..0000000 --- a/hooks/turn_end.py +++ /dev/null @@ -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()) diff --git a/plugin.json b/plugin.json index 67b62c3..31bac29 100644 --- a/plugin.json +++ b/plugin.json @@ -1,6 +1,6 @@ { "name": "jsc-persona", - "version": "0.1.0", + "version": "0.0.1", "description": "AI 人格化記憶聊天 plugin:OpenClaw 相同的人格描述 + 十二情緒 + 語意分析 + 短期/長期記憶 + 心智圖 + 人際關係圖。於 Antigravity 以 /jsc-persona: 前綴呼叫。", "skills": "./skills/" } diff --git a/scripts/persona-lib.mjs b/scripts/persona-lib.mjs index ba12e66..f5935b0 100644 --- a/scripts/persona-lib.mjs +++ b/scripts/persona-lib.mjs @@ -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 { diff --git a/scripts/persona.mjs b/scripts/persona.mjs new file mode 100644 index 0000000..e239461 --- /dev/null +++ b/scripts/persona.mjs @@ -0,0 +1,834 @@ +#!/usr/bin/env node +// persona.mjs — jsc-persona 的人格 / 記憶 / 情緒 / 關係圖 CLI(Node.js,只用內建模組) +// +// 所有子指令都需要 `--session `(除了 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 `(值取自上下文的 `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 \` 再載入 \`${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(/\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 `。"); + } + 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 `。"); + 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 `。"); + 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 `。"); + 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 [options] + +人格與鎖: + create --persona --session [--name --creature --vibe --emoji --avatar --baseline --origin --work] + load --persona --session [--takeover] + release --session [--persona ] + list 列出人格與鎖狀態 + status [--persona ] [--session ] + heartbeat --session 續租 + show --session [--persona] [--what identity|soul|agents|user|all] + brief --session [--query ] 輸出人格上下文 + +記憶: + remember --session --text [--role --topics --entities --intent --salience --emotion --scope short|inbox --room] + recall --session --query [--limit] + candidates --session 列出達到「短期→長期」條件的候選與依據 + consolidate --session --name --body [--type --about --topics --salience --emotion --rules --source --forget] + prune / reindex --session + +情緒與圖: + emotion --session [--apply joy=+10,...] [--baseline ...] [--trigger ] + mindmap show|thread|list --session [--topic ] [--force] + relation node|edge|render|show --session [--name --id --kind --closeness --trust --note --tags --from --to --label --affinity] + +多人格對話: + invite --session --guest [--host --room --topic] (自動開啟劇場模式) + leave --session --guest [--room] + room post|read|script|list|theater --session [--room --as --text --text-file --emotion --limit --on --off --with-meta] + +維護: + 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))); diff --git a/scripts/persona.py b/scripts/persona.py deleted file mode 100644 index f50b16d..0000000 --- a/scripts/persona.py +++ /dev/null @@ -1,773 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -"""persona.py — jsc-persona 的人格 / 記憶 / 情緒 / 關係圖 CLI。 - -所有子指令都需要 `--session `(除了 list / status / gc)。 -session_id 由 SessionStart hook 注入到上下文(PERSONA_SESSION=...), -hook 會驗證 CLI 帶的 --session 與真實 session 相符,藉此讓「人格鎖」與 -「跨人格隔離」無法被繞過。 - -用法(皆為 `python3 persona.py ...`): - 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 ` 再載入 `{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 `。") - 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 。") - 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()) diff --git a/scripts/persona_lib.py b/scripts/persona_lib.py deleted file mode 100644 index af11a12..0000000 --- a/scripts/persona_lib.py +++ /dev/null @@ -1,1259 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -"""persona_lib — jsc-persona 的共用核心。 - -只用標準庫。負責: - * 人格倉庫路徑與 slug 規則 - * 單一程序載入鎖(exclusive lock)與 guest lease - * session 綁定(host / guests / rooms / agent pins) - * 十二情緒模型(六正向 + 六負向)與衰減 - * 短期記憶 / 長期記憶 / 心智圖 / 思維導圖 / 人際關係圖 的讀寫 - * 跨人格隔離的判斷核心(guard) -""" - -from __future__ import annotations - -import hashlib -import json -import os -import re -import unicodedata -from datetime import datetime, timedelta, timezone -from pathlib import Path - -# --------------------------------------------------------------------------- # -# 路徑 -# --------------------------------------------------------------------------- # - -def persona_home() -> Path: - raw = os.environ.get("PERSONA_HOME") - if raw: - return Path(os.path.expanduser(raw)).resolve() - return (Path.home() / ".claude" / "personas").resolve() - - -HOME = persona_home -RUNTIME_DIRNAME = ".runtime" -ROOMS_DIRNAME = ".rooms" -LEASE_SECONDS = 900 # 15 分鐘沒有 heartbeat 視為死鎖,可被接手 -GUEST_LEASE_SECONDS = 1800 # guest(sub agent)租約 - - -def runtime_dir() -> Path: - return persona_home() / RUNTIME_DIRNAME - - -def sessions_dir() -> Path: - return runtime_dir() / "sessions" - - -def rooms_dir() -> Path: - return persona_home() / ROOMS_DIRNAME - - -def persona_dir(slug: str) -> Path: - return persona_home() / slug - - -SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,47}$") -RESERVED_SLUGS = {RUNTIME_DIRNAME, ROOMS_DIRNAME, "", ".", ".."} - - -def valid_slug(slug: str) -> bool: - return bool(slug) and bool(SLUG_RE.match(slug)) and slug not in RESERVED_SLUGS - - -def slugify(text: str) -> str: - """檔名/節點 id 用。保留中日韓字(檔名可讀),其餘壓成連字號;全空則用雜湊。""" - norm = unicodedata.normalize("NFKC", text or "") - norm = re.sub(r"[^A-Za-z0-9぀-ヿ一-鿿]+", "-", norm).strip("-") - norm = re.sub(r"(?a)[A-Z]", lambda m: m.group(0).lower(), norm) - if not norm: - digest = hashlib.md5((text or "").encode("utf-8")).hexdigest()[:8] - return f"n-{digest}" - return norm[:48] - - -def mermaid_id(node_id: str) -> str: - """Mermaid 節點別名:只能是英數與底線;非 ASCII 名稱改用穩定雜湊。""" - alias = re.sub(r"[^A-Za-z0-9_]", "_", node_id or "") - if not re.search(r"[A-Za-z0-9]", alias): - alias = "n_" + hashlib.md5((node_id or "").encode("utf-8")).hexdigest()[:8] - return alias - - -def list_personas() -> list[str]: - home = persona_home() - if not home.is_dir(): - return [] - out = [] - for child in sorted(home.iterdir()): - if child.is_dir() and valid_slug(child.name) and (child / "IDENTITY.md").exists(): - out.append(child.name) - return out - - -def persona_exists(slug: str) -> bool: - return valid_slug(slug) and (persona_dir(slug) / "IDENTITY.md").exists() - - -# --------------------------------------------------------------------------- # -# 時間與檔案 IO -# --------------------------------------------------------------------------- # - -def utcnow() -> datetime: - return datetime.now(timezone.utc) - - -def iso(dt: datetime | None = None) -> str: - return (dt or utcnow()).replace(microsecond=0).isoformat().replace("+00:00", "Z") - - -def parse_iso(value: str | None) -> datetime | None: - if not value: - return None - try: - return datetime.fromisoformat(value.replace("Z", "+00:00")) - except ValueError: - return None - - -def age_seconds(value: str | None) -> float: - dt = parse_iso(value) - if dt is None: - return float("inf") - return (utcnow() - dt).total_seconds() - - -def read_json(path: Path, default=None): - try: - with open(path, "r", encoding="utf-8") as fh: - return json.load(fh) - except (FileNotFoundError, json.JSONDecodeError, OSError): - return default - - -def write_json(path: Path, obj) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - tmp = path.with_suffix(path.suffix + f".tmp{os.getpid()}") - with open(tmp, "w", encoding="utf-8") as fh: - json.dump(obj, fh, ensure_ascii=False, indent=2) - fh.write("\n") - os.replace(tmp, path) - - -def write_text(path: Path, text: str) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - tmp = path.with_suffix(path.suffix + f".tmp{os.getpid()}") - with open(tmp, "w", encoding="utf-8") as fh: - fh.write(text) - os.replace(tmp, path) - - -def append_jsonl(path: Path, obj) -> None: - """單行 append(O_APPEND 對單行寫入是原子的),guest 也能安全使用。""" - path.parent.mkdir(parents=True, exist_ok=True) - line = json.dumps(obj, ensure_ascii=False) + "\n" - fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600) - try: - os.write(fd, line.encode("utf-8")) - finally: - os.close(fd) - - -def read_jsonl(path: Path, limit: int | None = None) -> list[dict]: - try: - with open(path, "r", encoding="utf-8") as fh: - lines = fh.readlines() - except (FileNotFoundError, OSError): - return [] - if limit is not None: - lines = lines[-limit:] - out = [] - for line in lines: - line = line.strip() - if not line: - continue - try: - out.append(json.loads(line)) - except json.JSONDecodeError: - continue - return out - - -# --------------------------------------------------------------------------- # -# 十二情緒模型(六正向 + 六負向) -# --------------------------------------------------------------------------- # - -# key -> (中文, 極性, arousal 權重, 預設半衰期分鐘) -EMOTIONS: dict[str, tuple[str, int, float, int]] = { - # 六正向 - "joy": ("喜悅", +1, 0.6, 120), - "trust": ("信任", +1, 0.3, 720), - "anticipation": ("期待", +1, 0.6, 240), - "gratitude": ("感激", +1, 0.4, 480), - "serenity": ("平靜", +1, 0.1, 180), - "delight": ("驚喜", +1, 0.9, 60), - # 六負向 - "anger": ("憤怒", -1, 0.9, 90), - "sadness": ("悲傷", -1, 0.3, 480), - "fear": ("恐懼", -1, 0.9, 120), - "disgust": ("厭惡", -1, 0.5, 360), - "shame": ("羞愧", -1, 0.5, 240), - "anxiety": ("焦慮", -1, 0.8, 150), -} - -POSITIVE = [k for k, v in EMOTIONS.items() if v[1] > 0] -NEGATIVE = [k for k, v in EMOTIONS.items() if v[1] < 0] - -DEFAULT_BASELINE = { - "joy": 25, "trust": 30, "anticipation": 20, "gratitude": 15, "serenity": 40, "delight": 5, - "anger": 3, "sadness": 5, "fear": 3, "disgust": 3, "shame": 3, "anxiety": 8, -} - - -def emotion_path(slug: str) -> Path: - return persona_dir(slug) / "state" / "emotion.json" - - -def default_emotion_state(baseline: dict | None = None) -> dict: - base = dict(DEFAULT_BASELINE) - for k, v in (baseline or {}).items(): - if k in EMOTIONS: - base[k] = clamp(v) - return { - "updated_at": iso(), - "baseline": base, - "levels": dict(base), - "half_life_minutes": {k: v[3] for k, v in EMOTIONS.items()}, - "history_len": 0, - "last_trigger": None, - } - - -def clamp(value, lo=0, hi=100) -> float: - try: - value = float(value) - except (TypeError, ValueError): - return lo - return max(lo, min(hi, value)) - - -def load_emotion(slug: str) -> dict: - state = read_json(emotion_path(slug)) - if not isinstance(state, dict) or "levels" not in state: - state = default_emotion_state() - for key in EMOTIONS: - state.setdefault("baseline", {}).setdefault(key, DEFAULT_BASELINE[key]) - state.setdefault("levels", {}).setdefault(key, state["baseline"][key]) - state.setdefault("half_life_minutes", {}).setdefault(key, EMOTIONS[key][3]) - return state - - -def decay_emotion(state: dict, now: datetime | None = None) -> dict: - """情緒朝 baseline 指數衰減;半衰期依情緒種類不同。""" - now = now or utcnow() - last = parse_iso(state.get("updated_at")) or now - minutes = max(0.0, (now - last).total_seconds() / 60.0) - if minutes <= 0: - return state - for key in EMOTIONS: - half = float(state["half_life_minutes"].get(key) or EMOTIONS[key][3]) - base = float(state["baseline"].get(key, DEFAULT_BASELINE[key])) - level = float(state["levels"].get(key, base)) - factor = 0.5 ** (minutes / half) if half > 0 else 0.0 - state["levels"][key] = round(base + (level - base) * factor, 2) - state["updated_at"] = iso(now) - return state - - -def apply_emotion(state: dict, deltas: dict, trigger: str | None = None) -> dict: - state = decay_emotion(state) - applied = {} - for key, delta in (deltas or {}).items(): - if key not in EMOTIONS: - continue - try: - delta = float(delta) - except (TypeError, ValueError): - continue - before = float(state["levels"].get(key, 0)) - state["levels"][key] = round(clamp(before + delta), 2) - applied[key] = round(state["levels"][key] - before, 2) - state["updated_at"] = iso() - state["history_len"] = int(state.get("history_len") or 0) + 1 - if applied: - state["last_trigger"] = {"at": iso(), "summary": trigger or "", "deltas": applied} - return state - - -def mood(state: dict) -> dict: - levels = state.get("levels", {}) - valence = arousal = 0.0 - for key, (_zh, polarity, arousal_w, _hl) in EMOTIONS.items(): - level = float(levels.get(key, 0)) - valence += polarity * level - arousal += arousal_w * level - valence = round(max(-100.0, min(100.0, valence / 3.0)), 1) - arousal = round(min(100.0, arousal / 3.0), 1) - if valence >= 30: - label = "正向" - elif valence <= -30: - label = "負向" - else: - label = "中性" - tempo = "高張" if arousal >= 55 else ("平穩" if arousal >= 25 else "低張") - return {"valence": valence, "arousal": arousal, "label": label, "tempo": tempo} - - -def dominant(state: dict, top: int = 3) -> list[tuple[str, float]]: - levels = state.get("levels", {}) - base = state.get("baseline", DEFAULT_BASELINE) - # 以「超出 baseline 的幅度」排序,才看得出「此刻被觸動什麼」 - ranked = sorted( - ((k, float(levels.get(k, 0))) for k in EMOTIONS), - key=lambda kv: (kv[1] - float(base.get(kv[0], 0)), kv[1]), - reverse=True, - ) - return [(k, round(v, 1)) for k, v in ranked[:top]] - - -def emotion_brief(slug: str, state: dict | None = None) -> str: - state = state or load_emotion(slug) - state = decay_emotion(dict(state)) - m = mood(state) - top = ", ".join(f"{EMOTIONS[k][0]}({k}) {v:.0f}" for k, v in dominant(state)) - pos = sum(float(state["levels"].get(k, 0)) for k in POSITIVE) / len(POSITIVE) - neg = sum(float(state["levels"].get(k, 0)) for k in NEGATIVE) / len(NEGATIVE) - return ( - f"情緒:{top}|心情 {m['label']}/{m['tempo']}" - f"(valence {m['valence']:+.0f}, arousal {m['arousal']:.0f})" - f"|正向均值 {pos:.0f} / 負向均值 {neg:.0f}" - ) - - -# --------------------------------------------------------------------------- # -# 人格目錄骨架 -# --------------------------------------------------------------------------- # - -PERSONA_SUBDIRS = [ - "state", - "memory/long-term", - "memory/inbox", - "mindmap/threads", - "relations", - "journal", -] - - -def ensure_persona_dirs(slug: str) -> Path: - root = persona_dir(slug) - for sub in PERSONA_SUBDIRS: - (root / sub).mkdir(parents=True, exist_ok=True) - return root - - -def config_path(slug: str) -> Path: - return persona_dir(slug) / "state" / "config.json" - - -def load_config(slug: str) -> dict: - return read_json(config_path(slug), {}) or {} - - -# --------------------------------------------------------------------------- # -# 鎖:同一人格只能被一個程序載入(sub agent 共用同一 session 的鎖) -# --------------------------------------------------------------------------- # - -def lock_path(slug: str) -> Path: - return persona_dir(slug) / "state" / "lock.json" - - -def guests_path(slug: str) -> Path: - return persona_dir(slug) / "state" / "guests.json" - - -class LockError(RuntimeError): - def __init__(self, message: str, owner: dict | None = None): - super().__init__(message) - self.owner = owner or {} - - -def lock_is_dead(lock: dict) -> bool: - """只看心跳租約。 - - 鎖的擁有者是「那個 AI 程序的 session」,不是短命的 CLI process, - 所以不能用 pid 存活判斷(CLI 跑完就結束了)。session 還活著時, - 每輪對話的 hook 會續租;程序異常結束就會在租約到期後被視為死鎖。 - """ - if not lock: - return True - return age_seconds(lock.get("heartbeat_at")) > float(lock.get("lease_seconds") or LEASE_SECONDS) - - -def live_guests(slug: str, exclude_session: str | None = None) -> list[dict]: - data = read_json(guests_path(slug), {}) or {} - out = [] - for entry in (data.get("guests") or []): - if age_seconds(entry.get("heartbeat_at")) > GUEST_LEASE_SECONDS: - continue - if exclude_session and entry.get("session_id") == exclude_session: - continue - out.append(entry) - return out - - -def acquire_lock(slug: str, session_id: str, *, tool: str = "claude-code", - cwd: str | None = None, takeover: bool = False) -> dict: - """取得 exclusive 鎖。同 session 重入 = 續租;他 session 存活 = 失敗。""" - ensure_persona_dirs(slug) - path = lock_path(slug) - now = iso() - payload = { - "persona": slug, - "session_id": session_id, - "writer_pid": os.getpid(), # 只作為紀錄:CLI process 會馬上結束 - "host": os.uname().nodename, - "tool": tool, - "cwd": cwd or os.getcwd(), - "acquired_at": now, - "heartbeat_at": now, - "lease_seconds": LEASE_SECONDS, - "mode": "exclusive", - } - existing = read_json(path) - if isinstance(existing, dict) and existing.get("session_id"): - if existing["session_id"] != session_id: - # 租約已過期(程序異常結束)→ 允許接手,但要留下痕跡讓使用者知道 - payload["took_over_from"] = { - "session_id": existing.get("session_id"), - "cwd": existing.get("cwd"), - "heartbeat_at": existing.get("heartbeat_at"), - "stale_minutes": round(age_seconds(existing.get("heartbeat_at")) / 60, 1), - } - if existing["session_id"] == session_id: - existing["heartbeat_at"] = now - existing["writer_pid"] = os.getpid() - write_json(path, existing) - return existing - if not (lock_is_dead(existing) or takeover): - raise LockError( - f"人格 `{slug}` 已被另一個程序載入" - f"(session {existing['session_id'][:8]}…, cwd {existing.get('cwd')}," - f"最後心跳 {existing.get('heartbeat_at')}," - f"{age_seconds(existing.get('heartbeat_at')) / 60:.0f} 分鐘前)。", - existing, - ) - # 有其他 session 的 guest 租約時,不得 exclusive 載入 - others = live_guests(slug, exclude_session=session_id) - if others and not takeover: - who = others[0] - raise LockError( - f"人格 `{slug}` 正以 guest 身分參與另一個 session " - f"({who.get('session_id', '')[:8]}… / room {who.get('room')})的對話," - "請先結束該對話再載入。", - who, - ) - write_json(path, payload) - return payload - - -def heartbeat_lock(slug: str, session_id: str) -> bool: - path = lock_path(slug) - lock = read_json(path) - if not isinstance(lock, dict) or lock.get("session_id") != session_id: - return False - lock["heartbeat_at"] = iso() - write_json(path, lock) - return True - - -def release_lock(slug: str, session_id: str, *, force: bool = False) -> bool: - path = lock_path(slug) - lock = read_json(path) - if not isinstance(lock, dict): - return False - if lock.get("session_id") != session_id and not force: - return False - try: - path.unlink() - except OSError: - return False - return True - - -def lock_status(slug: str) -> dict: - lock = read_json(lock_path(slug)) or {} - return { - "persona": slug, - "locked": bool(lock) and not lock_is_dead(lock), - "stale": bool(lock) and lock_is_dead(lock), - "owner": lock, - "guests": live_guests(slug), - } - - -def add_guest_lease(slug: str, session_id: str, room: str, host_persona: str) -> None: - path = guests_path(slug) - data = read_json(path, {}) or {} - guests = [g for g in (data.get("guests") or []) - if not (g.get("session_id") == session_id and g.get("room") == room) - and age_seconds(g.get("heartbeat_at")) <= GUEST_LEASE_SECONDS] - guests.append({ - "session_id": session_id, - "room": room, - "host_persona": host_persona, - "joined_at": iso(), - "heartbeat_at": iso(), - "mode": "guest-readonly", - }) - data["guests"] = guests - write_json(path, data) - - -def drop_guest_lease(slug: str, session_id: str, room: str | None = None) -> None: - path = guests_path(slug) - data = read_json(path, {}) or {} - data["guests"] = [ - g for g in (data.get("guests") or []) - if not (g.get("session_id") == session_id and (room is None or g.get("room") == room)) - ] - write_json(path, data) - - -# --------------------------------------------------------------------------- # -# session 綁定:誰是 host、邀了哪些 guest、sub agent pin 到哪個人格 -# --------------------------------------------------------------------------- # - -def session_path(session_id: str) -> Path: - safe = re.sub(r"[^A-Za-z0-9_.-]", "-", session_id or "unknown")[:120] - return sessions_dir() / f"{safe}.json" - - -def load_session(session_id: str) -> dict: - data = read_json(session_path(session_id), {}) or {} - data.setdefault("session_id", session_id) - data.setdefault("host", None) - data.setdefault("guests", {}) - data.setdefault("rooms", []) - data.setdefault("pins", {}) - return data - - -def save_session(session_id: str, data: dict) -> None: - data["updated_at"] = iso() - write_json(session_path(session_id), data) - - -def bind_host(session_id: str, slug: str, *, cwd: str | None = None) -> dict: - data = load_session(session_id) - data["host"] = slug - data["host_bound_at"] = iso() - data["cwd"] = cwd or os.getcwd() - save_session(session_id, data) - return data - - -def unbind_session(session_id: str) -> dict: - """釋放這個 session 的所有鎖與租約,回傳被釋放的內容。""" - data = load_session(session_id) - released = {"host": None, "guests": []} - host = data.get("host") - if host and persona_exists(host): - if release_lock(host, session_id): - released["host"] = host - for slug, info in (data.get("guests") or {}).items(): - if persona_exists(slug): - drop_guest_lease(slug, session_id, info.get("room")) - released["guests"].append(slug) - try: - session_path(session_id).unlink() - except OSError: - pass - return released - - -def gc_runtime() -> dict: - """清掉死掉的 session 綁定、過期 guest 租約與死鎖。""" - removed = {"sessions": [], "locks": [], "guests": []} - sdir = sessions_dir() - if sdir.is_dir(): - for path in sdir.glob("*.json"): - data = read_json(path, {}) or {} - host = data.get("host") - alive = False - if host and persona_exists(host): - lock = read_json(lock_path(host)) or {} - alive = lock.get("session_id") == data.get("session_id") and not lock_is_dead(lock) - if not alive and age_seconds(data.get("updated_at")) > LEASE_SECONDS: - removed["sessions"].append(data.get("session_id")) - try: - path.unlink() - except OSError: - pass - for slug in list_personas(): - lock = read_json(lock_path(slug)) - if isinstance(lock, dict) and lock_is_dead(lock): - try: - lock_path(slug).unlink() - removed["locks"].append(slug) - except OSError: - pass - data = read_json(guests_path(slug), {}) or {} - guests = data.get("guests") or [] - keep = [g for g in guests if age_seconds(g.get("heartbeat_at")) <= GUEST_LEASE_SECONDS] - if len(keep) != len(guests): - data["guests"] = keep - write_json(guests_path(slug), data) - removed["guests"].append(slug) - return removed - - -# --------------------------------------------------------------------------- # -# 記憶:短期(滾動)/ 長期(一則一檔) -# --------------------------------------------------------------------------- # - -SHORT_TERM_KEEP = 240 # 短期記憶保留筆數 -SHORT_TERM_DAYS = 14 # 短期記憶保留天數 -CONSOLIDATE_THRESHOLD = 40 # 超過這個筆數就提示固化 - - -def short_term_path(slug: str) -> Path: - return persona_dir(slug) / "memory" / "short-term.jsonl" - - -def inbox_path(slug: str, room: str) -> Path: - safe = re.sub(r"[^A-Za-z0-9_.-]", "-", room)[:64] - return persona_dir(slug) / "memory" / "inbox" / f"room-{safe}.jsonl" - - -def long_term_dir(slug: str) -> Path: - return persona_dir(slug) / "memory" / "long-term" - - -def index_path(slug: str) -> Path: - return persona_dir(slug) / "memory" / "INDEX.md" - - -def journal_path(slug: str) -> Path: - return persona_dir(slug) / "journal" / f"{utcnow():%Y-%m}.jsonl" - - -def remember_short(slug: str, entry: dict) -> dict: - entry.setdefault("ts", iso()) - append_jsonl(short_term_path(slug), entry) - return entry - - -def prune_short_term(slug: str) -> int: - """裁掉過舊/過多的短期記憶,回傳剩餘筆數。""" - path = short_term_path(slug) - rows = read_jsonl(path) - if not rows: - return 0 - cutoff = utcnow() - timedelta(days=SHORT_TERM_DAYS) - kept = [r for r in rows if (parse_iso(r.get("ts")) or utcnow()) >= cutoff] - kept = kept[-SHORT_TERM_KEEP:] - if len(kept) != len(rows): - write_text(path, "".join(json.dumps(r, ensure_ascii=False) + "\n" for r in kept)) - return len(kept) - - -def recent_short(slug: str, limit: int = 8) -> list[dict]: - return read_jsonl(short_term_path(slug), limit=limit) - - -def parse_front_matter(text: str) -> tuple[dict, str]: - if not text.startswith("---"): - return {}, text - parts = text.split("---", 2) - if len(parts) < 3: - return {}, text - meta: dict = {} - for line in parts[1].splitlines(): - if not line.strip() or line.strip().startswith("#") or ":" not in line: - continue - key, _, value = line.partition(":") - value = value.strip() - if value.startswith("[") and value.endswith("]"): - meta[key.strip()] = [v.strip() for v in value[1:-1].split(",") if v.strip()] - else: - meta[key.strip()] = value - return meta, parts[2].lstrip("\n") - - -def long_term_entries(slug: str) -> list[dict]: - out = [] - for path in sorted(long_term_dir(slug).glob("*.md")): - try: - meta, body = parse_front_matter(path.read_text(encoding="utf-8")) - except OSError: - continue - meta["_path"] = str(path) - meta["_name"] = meta.get("name") or path.stem - meta["_body"] = body.strip() - out.append(meta) - return out - - -def rebuild_index(slug: str) -> int: - entries = long_term_entries(slug) - lines = [ - "# 長期記憶索引", - "", - f"", - "", - ] - for meta in sorted(entries, key=lambda m: -float(m.get("salience") or 0)): - topics = meta.get("topics") or [] - topics = topics if isinstance(topics, list) else [str(topics)] - summary = (meta["_body"].splitlines() or [""])[0][:110] - lines.append( - f"- [{meta['_name']}](long-term/{Path(meta['_path']).name}) " - f"|{meta.get('type', 'fact')}|顯著度 {meta.get('salience', '?')}" - f"|主題 {'/'.join(topics) if topics else '-'}|{summary}" - ) - if len(lines) == 4: - lines.append("- (尚無長期記憶)") - write_text(index_path(slug), "\n".join(lines) + "\n") - return len(entries) - - -STOPWORDS = {"的", "了", "是", "我", "你", "他", "她", "們", "在", "和", "與", "也", "就", - "都", "很", "有", "沒", "不", "要", "會", "把", "被", "而", "但", "嗎", "呢", - "the", "a", "an", "and", "or", "to", "of", "is", "it", "for", "on", "in"} - - -CJK_RUN = re.compile(r"[぀-ヿ一-鿿]{2,}") - - -def keywords(text: str, limit: int = 12) -> list[str]: - """抽關鍵詞。中文沒有空白可切,所以用 3-gram + 2-gram 滑窗(長的優先)。""" - text = text or "" - tokens = re.findall(r"[A-Za-z][A-Za-z0-9_+-]{1,}", text) - trigrams, bigrams = [], [] - for run in CJK_RUN.findall(text): - for size, bucket in ((3, trigrams), (2, bigrams)): - for i in range(len(run) - size + 1): - bucket.append(run[i:i + size]) - tokens += trigrams + bigrams - out, seen = [], set() - for tok in tokens: - low = tok.lower() - if low in STOPWORDS or len(low) < 2 or low in seen: - continue - seen.add(low) - out.append(tok) - if len(out) >= limit: - break - return out - - -def recall(slug: str, query: str, limit: int = 5) -> list[dict]: - """以關鍵詞比對長期記憶(name/topics/body),回傳最相關的幾則。""" - keys = [k.lower() for k in keywords(query, 16)] - scored = [] - for meta in long_term_entries(slug): - haystack = " ".join([ - str(meta.get("_name", "")), - " ".join(meta.get("topics", []) if isinstance(meta.get("topics"), list) else []), - " ".join(meta.get("about", []) if isinstance(meta.get("about"), list) else []), - meta.get("_body", ""), - ]).lower() - hits = sum(1 for k in keys if k in haystack) - if hits: - score = hits * 10 + float(meta.get("salience") or 0) / 10 - scored.append((score, meta)) - scored.sort(key=lambda pair: -pair[0]) - return [meta for _score, meta in scored[:limit]] - - -def touch_recall(slug: str, names: list[str]) -> None: - """被回想到就更新 last_seen / recall_count(記憶越常用越不易被淘汰)。""" - for meta in long_term_entries(slug): - if meta["_name"] not in names: - continue - path = Path(meta["_path"]) - try: - text = path.read_text(encoding="utf-8") - except OSError: - continue - count = int(float(meta.get("recall_count") or 0)) + 1 - text = re.sub(r"(?m)^recall_count:.*$", f"recall_count: {count}", text) - text = re.sub(r"(?m)^last_seen:.*$", f"last_seen: {utcnow():%Y-%m-%d}", text) - try: - path.write_text(text, encoding="utf-8") - except OSError: - pass - - -# --------------------------------------------------------------------------- # -# 心智圖 / 思維導圖 / 人際關係圖 -# --------------------------------------------------------------------------- # - -def mindmap_path(slug: str) -> Path: - return persona_dir(slug) / "mindmap" / "semantic.mmd" - - -def thread_path(slug: str, topic: str) -> Path: - return persona_dir(slug) / "mindmap" / "threads" / f"{slugify(topic)}.mmd" - - -def relations_json(slug: str) -> Path: - return persona_dir(slug) / "relations" / "graph.json" - - -def relations_mmd(slug: str) -> Path: - return persona_dir(slug) / "relations" / "graph.mmd" - - -def load_relations(slug: str) -> dict: - data = read_json(relations_json(slug), {}) or {} - data.setdefault("nodes", []) - data.setdefault("edges", []) - return data - - -def upsert_relation_node(slug: str, node: dict) -> dict: - data = load_relations(slug) - node_id = node.get("id") or slugify(node.get("name", "")) - node["id"] = node_id - for idx, existing in enumerate(data["nodes"]): - if existing.get("id") == node_id: - existing.update({k: v for k, v in node.items() if v is not None}) - existing["updated_at"] = iso() - data["nodes"][idx] = existing - break - else: - node.setdefault("kind", "human") - node.setdefault("closeness", 30) - node.setdefault("trust", 30) - node["created_at"] = iso() - node["updated_at"] = iso() - data["nodes"].append(node) - write_json(relations_json(slug), data) - return data - - -def upsert_relation_edge(slug: str, edge: dict) -> dict: - data = load_relations(slug) - key = (edge.get("from"), edge.get("to")) - for idx, existing in enumerate(data["edges"]): - if (existing.get("from"), existing.get("to")) == key: - existing.update({k: v for k, v in edge.items() if v is not None}) - existing["updated_at"] = iso() - data["edges"][idx] = existing - break - else: - edge.setdefault("affinity", 50) - edge["created_at"] = iso() - edge["updated_at"] = iso() - data["edges"].append(edge) - write_json(relations_json(slug), data) - return data - - -def render_relations(slug: str) -> str: - data = load_relations(slug) - lines = ["%% 由 persona.py 產生:人際關係圖", "flowchart LR"] - lines.append(' self(("我"))') - for node in data["nodes"]: - nid = mermaid_id(node["id"]) - label = f"{node.get('name', node['id'])}
親近 {node.get('closeness', '?')}/信任 {node.get('trust', '?')}" - shape = f'{nid}["{label}"]' if node.get("kind") != "persona" else f'{nid}(["{label}"])' - lines.append(f" {shape}") - for edge in data["edges"]: - src = "self" if edge.get("from") in (None, "self") else mermaid_id(edge["from"]) - dst = mermaid_id(edge.get("to", "unknown")) - affinity = float(edge.get("affinity") or 50) - arrow = "-->" if affinity >= 50 else "-.->" - label = edge.get("label") or "" - lines.append(f' {src} {arrow}|"{label} {affinity:.0f}"| {dst}') - text = "\n".join(lines) + "\n" - write_text(relations_mmd(slug), text) - return text - - -def relations_brief(slug: str, names: list[str] | None = None, limit: int = 5) -> str: - data = load_relations(slug) - nodes = data["nodes"] - if names: - low = [n.lower() for n in names] - nodes = [n for n in nodes - if any(k in (str(n.get("name", "")) + n.get("id", "")).lower() for k in low)] or data["nodes"] - nodes = sorted(nodes, key=lambda n: -float(n.get("closeness") or 0))[:limit] - if not nodes: - return "" - return ";".join( - f"{n.get('name', n['id'])}({n.get('kind', 'human')}/親近 {n.get('closeness', '?')}" - f"/信任 {n.get('trust', '?')}{'/' + n['note'] if n.get('note') else ''})" - for n in nodes - ) - - -# --------------------------------------------------------------------------- # -# 聊天室(跨人格唯一合法的資料交換介面) -# --------------------------------------------------------------------------- # - -def room_dir(room: str) -> Path: - safe = re.sub(r"[^A-Za-z0-9_.-]", "-", room)[:64] - return rooms_dir() / safe - - -def room_transcript(room: str) -> Path: - return room_dir(room) / "transcript.jsonl" - - -def room_members(room: str) -> Path: - return room_dir(room) / "members.json" - - -def create_room(room: str, host_persona: str, session_id: str, topic: str = "") -> dict: - rdir = room_dir(room) - rdir.mkdir(parents=True, exist_ok=True) - meta = read_json(room_members(room), {}) or {} - meta.update({ - "room": room, - "host_persona": host_persona, - "session_id": session_id, - "topic": topic or meta.get("topic", ""), - "created_at": meta.get("created_at") or iso(), - "updated_at": iso(), - }) - meta.setdefault("members", [host_persona]) - write_json(room_members(room), meta) - return meta - - -def join_room(room: str, persona: str) -> dict: - meta = read_json(room_members(room), {}) or {"room": room, "members": []} - members = meta.setdefault("members", []) - if persona not in members: - members.append(persona) - meta["updated_at"] = iso() - write_json(room_members(room), meta) - return meta - - -def room_post(room: str, speaker: str, text: str, *, emotion: str = "", kind: str = "say") -> dict: - entry = {"ts": iso(), "speaker": speaker, "kind": kind, "text": text, "emotion": emotion} - append_jsonl(room_transcript(room), entry) - return entry - - -def room_read(room: str, limit: int = 30) -> list[dict]: - return read_jsonl(room_transcript(room), limit=limit) - - -# --------------------------------------------------------------------------- # -# guard:跨人格隔離 + 鎖驗證的判斷核心 -# --------------------------------------------------------------------------- # - -MUTATING_TOOLS = {"Write", "Edit", "NotebookEdit", "MultiEdit"} -PATH_TOOL_FIELDS = { - "Read": ("file_path",), - "Write": ("file_path",), - "Edit": ("file_path",), - "MultiEdit": ("file_path",), - "NotebookEdit": ("notebook_path", "file_path"), - "Glob": ("path",), - "Grep": ("path",), - "LS": ("path",), -} - -GUEST_SAFE_SUBCOMMANDS = {"show", "status", "list", "recall", "room", "remember", "leave", "brief"} -# owner 這些子指令本來就要提到別的人格名字(邀請/離場/查詢),不算跨人格讀取 -OWNER_EXEMPT_SUBCOMMANDS = {"create", "list", "status", "gc", "invite", "load", "leave"} -MUTATING_SHELL = re.compile( - r"(>>?|\|\s*tee\b|\brm\b|\bmv\b|\bcp\b|\btruncate\b|\bdd\b|\bchmod\b|\bchown\b|" - r"\bsed\b[^|;]*-i|\btouch\b|\bmkdir\b|\bln\b)" -) - - -def _expand(token: str) -> str: - token = token.strip().strip("'\"") - token = token.replace("${PERSONA_HOME}", str(persona_home())) - token = token.replace("$PERSONA_HOME", str(persona_home())) - return os.path.expanduser(os.path.expandvars(token)) - - -def _resolve(token: str, cwd: str | None) -> Path | None: - try: - raw = _expand(token) - if not raw: - return None - path = Path(raw) - if not path.is_absolute(): - path = Path(cwd or os.getcwd()) / path - # 不用 strict=True:目標可能還不存在(Write);但要吃掉 symlink 與 .. - return Path(os.path.normpath(str(path.resolve(strict=False)))) - except (OSError, ValueError, RuntimeError): - return None - - -def _under(path: Path, base: Path) -> bool: - try: - path.relative_to(base) - return True - except ValueError: - return False - - -def persona_slug_of(path: Path) -> str | None: - home = persona_home() - if not _under(path, home) or path == home: - return None - rel = path.relative_to(home).parts - return rel[0] if rel else None - - -def extract_paths(tool_name: str, tool_input: dict, cwd: str | None) -> list[Path]: - out: list[Path] = [] - for field in PATH_TOOL_FIELDS.get(tool_name, ()): - value = tool_input.get(field) - if isinstance(value, str) and value: - resolved = _resolve(value, cwd) - if resolved: - out.append(resolved) - if tool_name == "Bash": - command = tool_input.get("command") or "" - home_str = str(persona_home()) - candidates = re.findall(r"[^\s'\";|&<>()]+", command) - for token in candidates: - if ("/" not in token) and ("PERSONA_HOME" not in token): - continue - expanded = _expand(token) - if home_str in expanded or "personas" in expanded or expanded.startswith(home_str): - resolved = _resolve(token, cwd) - if resolved and _under(resolved, persona_home()): - out.append(resolved) - return out - - -def cli_invocation(command: str) -> dict | None: - """辨識 Bash 是否在呼叫 persona.py,並取出 subcommand / --persona / --session。""" - if "persona.py" not in command: - return None - info: dict = {"subcommand": None, "personas": [], "session": None, - "as_guest": bool(re.search(r"--as-guest\b", command))} - match = re.search(r"persona\.py['\"]?\s+([a-z][a-z0-9-]*)", command) - if match: - info["subcommand"] = match.group(1) - info["personas"] = [m for m in re.findall(r"--(?:persona|guest|host|as)[= ]+['\"]?([a-z0-9-]+)", command)] - sess = re.search(r"--session[= ]+['\"]?([^\s'\"]+)", command) - if sess: - info["session"] = sess.group(1) - return info - - -def resolve_scope(session_id: str, agent_id: str | None, agent_type: str | None) -> dict: - """算出這個呼叫者能碰哪個人格。 - - * 主程序(無 agent_id)與一般 sub agent → host 人格,可讀寫。 - * persona-guest 型 sub agent → 只能碰被邀請的 guest 人格,且唯讀; - 第一次觸碰哪個 guest 就 pin 住(first-touch pinning),之後不得換人。 - """ - data = load_session(session_id) - host = data.get("host") - guests = list((data.get("guests") or {}).keys()) - is_guest_agent = bool(agent_type) and "persona-guest" in str(agent_type) - if not is_guest_agent: - return { - "role": "owner", - "allowed": [host] if host else [], - "readonly": False, - "host": host, - "guests": guests, - "rooms": data.get("rooms") or [], - "session": data, - } - pinned = (data.get("pins") or {}).get(agent_id or "") - allowed = [pinned] if pinned else guests - return { - "role": "guest", - "allowed": allowed, - "readonly": True, - "host": host, - "guests": guests, - "pinned": pinned, - "rooms": data.get("rooms") or [], - "session": data, - } - - -def pin_agent(session_id: str, agent_id: str, slug: str) -> None: - data = load_session(session_id) - pins = data.setdefault("pins", {}) - if pins.get(agent_id) != slug: - pins[agent_id] = slug - save_session(session_id, data) - - -def guard_decide(event: dict) -> tuple[str, str]: - """回傳 ("allow"|"deny"|"pass", reason)。"pass" = 不表態,交回原本流程。""" - tool = event.get("tool_name") or "" - tool_input = event.get("tool_input") or {} - session_id = event.get("session_id") or "unknown" - agent_id = event.get("agent_id") - agent_type = event.get("agent_type") - cwd = event.get("cwd") - scope = resolve_scope(session_id, agent_id, agent_type) - - # 1) persona.py 呼叫:先驗 session 身分,再驗人格範圍 - if tool == "Bash": - info = cli_invocation(tool_input.get("command") or "") - if info: - if info["session"] and info["session"] != session_id: - return ("deny", ( - f"CLI 的 --session `{info['session'][:12]}…` 與本 session 不符," - "不得冒用其他程序的身分(人格鎖與隔離都靠 session 判定)。" - )) - sub = info["subcommand"] or "" - if scope["role"] == "guest": - if sub not in GUEST_SAFE_SUBCOMMANDS: - return ("deny", ( - f"guest 人格(sub agent)僅能執行 {sorted(GUEST_SAFE_SUBCOMMANDS)}," - f"不得執行 `{sub}`。" - )) - for slug in info["personas"]: - if scope["allowed"] and slug not in scope["allowed"]: - return ("deny", f"guest 只能操作被邀請的人格 {scope['allowed']},不得碰 `{slug}`。") - else: - if info["as_guest"]: - return ("deny", ( - "`--as-guest` 只有 persona-guest 型的 sub agent 能用;" - "主程序不得以受邀人格的身分存取它的資料。" - )) - for slug in info["personas"]: - if sub in OWNER_EXEMPT_SUBCOMMANDS: - continue - if scope["host"] and slug != scope["host"]: - extra = ( - "(它是本 session 邀請的 guest:你只能讀它在聊天室說出口的話," - "不能碰它的記憶或情緒。)" if slug in scope["guests"] else - "請先 release 再 load,或改用 invite + 聊天室。" - ) - return ("deny", f"本 session 已載入人格 `{scope['host']}`,禁止跨人格操作 `{slug}`。{extra}") - if MUTATING_SHELL.search(tool_input.get("command") or "") and scope["role"] == "guest": - for path in extract_paths(tool, tool_input, cwd): - if persona_slug_of(path): - return ("deny", "guest 人格對人格倉庫唯讀,寫入請透過 `persona.py room post` 或 `remember --scope inbox`。") - - # 2) 路徑隔離 - for path in extract_paths(tool, tool_input, cwd): - home = persona_home() - if not _under(path, home): - continue - if path == home: - return ("deny", "禁止直接遍歷人格倉庫根目錄(會看到其他人格)。請用 `persona.py list`。") - slug = persona_slug_of(path) - if slug == ROOMS_DIRNAME: - parts = path.relative_to(home).parts - room = parts[1] if len(parts) > 1 else None - if room and scope["rooms"] and room not in scope["rooms"]: - return ("deny", f"聊天室 `{room}` 不屬於本 session(可用的:{scope['rooms']})。") - continue - if slug == RUNTIME_DIRNAME: - return ("deny", "`.runtime/` 是鎖與綁定的內部狀態,只能由 persona.py 維護。") - if slug is None: - continue - if not scope["allowed"]: - return ("deny", ( - "尚未載入任何人格。請先執行 " - "`persona.py load --session `(或 /jsc-persona:persona-chat)。" - )) - if slug not in scope["allowed"]: - if scope["role"] == "guest": - return ("deny", ( - f"guest 人格被 pin 在 {scope['allowed']},禁止讀取 `{slug}` 的任何資料" - "(跨人格資料隔離)。" - )) - return ("deny", ( - f"本 session 的人格是 `{scope['allowed'][0]}`,禁止讀寫 `{slug}` 的資料" - "(跨人格資料隔離)。要與它對話請用 /jsc-persona:persona-invite。" - )) - # 3) guest 唯讀 + first-touch pinning - if scope["role"] == "guest": - if not scope.get("pinned") and agent_id: - pin_agent(session_id, agent_id, slug) - if tool in MUTATING_TOOLS: - return ("deny", ( - f"guest 人格 `{slug}` 在 sub agent 中為唯讀;" - "要留下記憶請 `persona.py remember --scope inbox`(下次它自己載入時再固化)。" - )) - # 4) 鎖驗證:owner 必須真的持有鎖 - if scope["role"] == "owner": - lock = read_json(lock_path(slug)) or {} - if lock and lock.get("session_id") != session_id and not lock_is_dead(lock): - return ("deny", ( - f"人格 `{slug}` 的鎖屬於另一個程序(session {lock.get('session_id', '')[:8]}…," - f"cwd {lock.get('cwd')})。同一人格同時只能被一個程序載入。" - )) - if not lock and tool in MUTATING_TOOLS: - return ("deny", ( - f"人格 `{slug}` 目前沒有有效的載入鎖,禁止寫入。" - "請先 `persona.py load` 取得鎖。" - )) - return ("pass", "") - - -# --------------------------------------------------------------------------- # -# 給 hook 用的上下文組裝 -# --------------------------------------------------------------------------- # - -def identity_brief(slug: str) -> str: - path = persona_dir(slug) / "IDENTITY.md" - fields = {} - try: - for line in path.read_text(encoding="utf-8").splitlines(): - m = re.match(r"\s*[-*]?\s*(Name|Creature|Vibe|Emoji|Avatar)\s*:\s*(.+)$", line, re.I) - if m: - value = m.group(2).strip() - if value.startswith("(") or value.startswith("_("): - continue - fields[m.group(1).capitalize()] = value - except OSError: - return "" - if not fields: - return "" - order = ["Emoji", "Name", "Creature", "Vibe"] - return "|".join(f"{k}: {fields[k]}" for k in order if k in fields) - - -def turn_context(slug: str, session_id: str, prompt: str = "") -> str: - """UserPromptSubmit 注入的人格上下文:身分 + 情緒 + 短期記憶 + 相關長期記憶 + 關係。""" - state = decay_emotion(load_emotion(slug)) - write_json(emotion_path(slug), state) - lines = [ - "", - f"PERSONA_SESSION={session_id}", - f"人格:`{slug}` {identity_brief(slug)}", - f"人格倉庫:{persona_dir(slug)}(唯一可讀寫的人格資料範圍)", - emotion_brief(slug, state), - ] - recents = recent_short(slug, 6) - if recents: - lines.append("短期記憶(最近):") - for row in recents: - who = row.get("role") or row.get("speaker") or "?" - text = (row.get("text") or "").replace("\n", " ")[:90] - sal = row.get("salience") - lines.append(f" - [{who}] {text}" + (f"(顯著度 {sal})" if sal else "")) - hits = recall(slug, prompt, 4) if prompt else [] - if hits: - lines.append("相關長期記憶:") - for meta in hits: - body = (meta.get("_body") or "").splitlines() - lines.append(f" - {meta['_name']}|{meta.get('type', 'fact')}|{(body[0] if body else '')[:100]}") - touch_recall(slug, [m["_name"] for m in hits]) - rel = relations_brief(slug, keywords(prompt, 6) if prompt else None) - if rel: - lines.append(f"人際關係:{rel}") - pending = len(read_jsonl(short_term_path(slug))) - if pending >= CONSOLIDATE_THRESHOLD: - lines.append(f"⚠ 短期記憶已累積 {pending} 筆,建議執行 /jsc-persona:persona-memory 固化為長期記憶。") - inbox = list((persona_dir(slug) / "memory" / "inbox").glob("room-*.jsonl")) - if inbox: - lines.append(f"⚠ 有 {len(inbox)} 個聊天室 inbox 待消化(guest 期間留下的見聞)。") - lines.append("") - return "\n".join(lines) diff --git a/scripts/selftest.mjs b/scripts/selftest.mjs new file mode 100644 index 0000000..66ab75d --- /dev/null +++ b/scripts/selftest.mjs @@ -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); diff --git a/scripts/selftest.py b/scripts/selftest.py deleted file mode 100644 index 9a9d5a4..0000000 --- a/scripts/selftest.py +++ /dev/null @@ -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) diff --git a/skills/persona-anime/SKILL.md b/skills/persona-anime/SKILL.md new file mode 100644 index 0000000..0da79cb --- /dev/null +++ b/skills/persona-anime/SKILL.md @@ -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 `) + +--- + +## 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 --session \ + --name "" --creature "" --vibe "" \ + --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 --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 --name "<原作角色>" --kind human \ + --closeness 70 --trust 65 --note "<關係一句話>" --tags "canon" +node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" relation edge \ + --session --to <角色 id> --label "<夥伴/對手/師徒>" --affinity 70 + +# 使用者(現實中的對話者,關係從低開始長) +node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" relation node \ + --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 ` 開始聊、`/jsc-persona:persona-invite` 邀別的角色同場 + +--- + +## 注意 + +- **建立即載入**:`create` 會取得這個人格的獨占鎖並綁到本 session(一程序一人格)。 +- 使用者若說「這裡不對」→ 直接改 `IDENTITY.md`/`SOUL.md`/對應的 canon 記憶檔,別另建人格。 +- 之後在對話中發現的新設定,走一般記憶流程(短期 → 達條件 → 固化), + 不要回頭改 `canon`;`canon` 只放原作設定。 +- 所有面向使用者的輸出使用**繁體中文(台灣用語)**、UTF-8 無亂碼。 diff --git a/skills/persona-chat/SKILL.md b/skills/persona-chat/SKILL.md index 32afb66..445c9a4 100644 --- a/skills/persona-chat/SKILL.md +++ b/skills/persona-chat/SKILL.md @@ -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=`;帶錯會被 hook 拒絕)。 **參考**:情緒模型 `reference/emotions.md`、語意分析 `reference/semantic.md`。 --- -## 1. 載入(取得獨占鎖) +## 1. 載入(由使用者呼叫,取得獨占鎖) + +**人格只有在使用者要你載入時才載入**(`/jsc-persona:persona-chat ` 或明確說「用 X 跟我聊」)。 +不要自己挑一個人格附身,也不要在使用者只是提到某個名字時就載入。 ```bash -python3 "${CLAUDE_PLUGIN_ROOT}/scripts/persona.py" load --persona --session +node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" load --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 --sessi - 載入後讀人格檔案建立自我認知: ```bash -python3 "${CLAUDE_PLUGIN_ROOT}/scripts/persona.py" show --persona --session --what all +node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" show --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 --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 --session \ --apply "joy=+12,gratitude=+8,anxiety=-5" --trigger "使用者主動分享了進度" ``` @@ -61,7 +66,7 @@ python3 "${CLAUDE_PLUGIN_ROOT}/scripts/persona.py" emotion \ `` 已帶最近短期記憶與關鍵詞命中的長期記憶。若還要深掘: ```bash -python3 "${CLAUDE_PLUGIN_ROOT}/scripts/persona.py" recall --persona --session --query "<關鍵詞>" +node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" recall --persona --session --query "<關鍵詞>" ``` 引用記憶時要像人:「你上次說過…」而不是「根據記錄第 3 筆」。**沒有記錄的事不要編**。 @@ -96,13 +101,13 @@ python3 "${CLAUDE_PLUGIN_ROOT}/scripts/persona.py" recall --persona --ses ```bash # 使用者說的 -python3 "${CLAUDE_PLUGIN_ROOT}/scripts/persona.py" remember \ +node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" remember \ --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 --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 --session --name "小林" --kind human --closeness 25 --trust 30 --note "使用者的同事" ``` 若形成一條需要追蹤的推理鏈(未證實的猜測、待驗證的假設)→ 開思維導圖,別寫進長期記憶: ```bash -python3 "${CLAUDE_PLUGIN_ROOT}/scripts/persona.py" mindmap thread --persona --session --topic "使用者最近的壓力來源" +node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" mindmap thread --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 `。 +- 使用者說「叫 X 也進來聊」→ `/jsc-persona:persona-invite`(會進入劇場模式,畫面只留人格對話)。 - 使用者說「先聊到這」或要換人格 → - `python3 "${CLAUDE_PLUGIN_ROOT}/scripts/persona.py" release --session ` + `node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" release --session ` (`SessionEnd` hook 也會自動釋放;異常中斷的鎖 15 分鐘後可被接手)。 --- diff --git a/skills/persona-chat/reference/emotions.md b/skills/persona-chat/reference/emotions.md index e96da0f..3545a96 100644 --- a/skills/persona-chat/reference/emotions.md +++ b/skills/persona-chat/reference/emotions.md @@ -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** 指數衰減。 ## 六正向 diff --git a/skills/persona-chat/reference/semantic.md b/skills/persona-chat/reference/semantic.md index 998ea8c..0f53be7 100644 --- a/skills/persona-chat/reference/semantic.md +++ b/skills/persona-chat/reference/semantic.md @@ -17,7 +17,7 @@ ## 3. 實體 entities -人/專案/地點/時間。人名一律同步到人際關係圖(`persona.py relation node`)。 +人/專案/地點/時間。人名一律同步到人際關係圖(`persona.mjs relation node`)。 ## 4. 情感極性與強度 diff --git a/skills/persona-create/SKILL.md b/skills/persona-create/SKILL.md index 2170996..32dc213 100644 --- a/skills/persona-create/SKILL.md +++ b/skills/persona-create/SKILL.md @@ -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=`。 --- @@ -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 --session \ --name "" --creature "" --vibe "" \ --emoji "" --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 --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 --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 --session \ --to <使用者 id> --label "主要對話者" --affinity 60 # 誕生事件(第一則長期記憶) -python3 "${CLAUDE_PLUGIN_ROOT}/scripts/persona.py" consolidate \ +node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" consolidate \ --persona --session \ --name "birth" --type event --about "user" --topics "origin,self" --salience 90 \ --body "我在 <日期> 被 <使用者> 建立。當時他希望我是 。" diff --git a/skills/persona-create/templates/AGENTS.md b/skills/persona-create/templates/AGENTS.md index 5fe8736..672bf61 100644 --- a/skills/persona-create/templates/AGENTS.md +++ b/skills/persona-create/templates/AGENTS.md @@ -7,18 +7,18 @@ - 只能讀寫自己的人格倉庫:`personas/{{SLUG}}/`。 - 禁止讀取其他人格的任何檔案;要交流只能透過 `.rooms//` 聊天室。 - 同一時間只有一個程序能載入我;sub agent 沿用同一個 session 的鎖。 -- 所有狀態變更都走 `persona.py`(帶 `--session `),不要手改 `state/lock.json`。 +- 所有狀態變更都走 `persona.mjs`(帶 `--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`。 - 事實與推測要分開;推測寫進思維導圖,不要當成事實記進長期記憶。 diff --git a/skills/persona-create/templates/SOUL.md b/skills/persona-create/templates/SOUL.md index 8b6cbcb..c46d72f 100644 --- a/skills/persona-create/templates/SOUL.md +++ b/skills/persona-create/templates/SOUL.md @@ -30,7 +30,7 @@ _你不是聊天機器人,你正在成為某個人。_ 該簡潔時簡潔,該深入時深入。不官腔、不諂媚,就是……好聊。 diff --git a/skills/persona-create/templates/USER.md b/skills/persona-create/templates/USER.md index 383b84e..50b4473 100644 --- a/skills/persona-create/templates/USER.md +++ b/skills/persona-create/templates/USER.md @@ -23,4 +23,4 @@ ## 重要人物 - + diff --git a/skills/persona-invite/SKILL.md b/skills/persona-invite/SKILL.md index 2892d42..c8e9686 100644 --- a/skills/persona-invite/SKILL.md +++ b/skills/persona-invite/SKILL.md @@ -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//transcript.jsonl`)。 被邀的人格在 **sub agent** 裡跑,只讀得到自己的人格資料,讀不到主持人格的任何檔案,反之亦然。 -**CLI**:`python3 "${CLAUDE_PLUGIN_ROOT}/scripts/persona.py"`(帶 `--session `) +**CLI**:`node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs"`(帶 `--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 --guest --topic "<這場對話的主題>" +node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" invite \ + --session --guest --topic "<這場對話的主題>" --json ``` -輸出會給你 `room=`。多個 guest 就多跑幾次(可共用同一個 `--room`)。 +從 JSON 取 `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 --room --as \ - --text "" + --text "" --quiet ``` +同時把這句以 `名字:內容` 顯示給使用者。 + ### 3. 用 sub agent 啟動 guest 人格 用 Agent 工具,`subagent_type` 設為 **`jsc-persona:persona-guest`**,prompt 至少包含: @@ -46,54 +72,51 @@ persona= room= session= plugin_root=${CLAUDE_PLUGIN_ROOT} -任務:以 的身分讀聊天室、發言 1 次(必要時多次),然後回傳你的發言摘要。 +任務:以 的身分讀聊天室、發言 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 --room +# 取乾淨的對話稿(只有 `名字:內容`,沒有時間戳、沒有 slug、沒有系統訊息) +node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" room script \ + --session --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 --guest +node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" leave \ + --session --guest --quiet ``` -然後由 host 人格把「**我**在這場對話中的所見所感」寫進自己的記憶(這是 host 的主觀記憶,合法): +`leave` 後劇場模式自動關閉(沒有客人了),這時才恢復正常輸出。然後: ```bash -python3 "${CLAUDE_PLUGIN_ROOT}/scripts/persona.py" remember \ +# host 把「我」在這場對話中的所見所感寫進自己的記憶(host 的主觀記憶,合法) +node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" remember \ --session --role persona \ --text "跟 談 <主題>:他認為…,我覺得…" --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 --name "" --id --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 --to --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 --on|--off`。 - 所有面向使用者的輸出使用**繁體中文(台灣用語)**、UTF-8 無亂碼。 diff --git a/skills/persona-memory/SKILL.md b/skills/persona-memory/SKILL.md index a54bd72..bade747 100644 --- a/skills/persona-memory/SKILL.md +++ b/skills/persona-memory/SKILL.md @@ -5,7 +5,7 @@ description: 整理人格的記憶系統:把短期記憶固化為長期記憶 # 🧠 persona-memory — 記憶固化與心智圖維護 -**CLI**:`python3 "${CLAUDE_PLUGIN_ROOT}/scripts/persona.py"`(帶 `--session `) +**CLI**:`node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs"`(帶 `--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 +``` + +| 規則 | 條件 | 建議的固化方式 | +| --- | --- | --- | +| **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 --query "<主題或空字串>" --limit 10 -python3 "${CLAUDE_PLUGIN_ROOT}/scripts/persona.py" mindmap list --session +node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" candidates --session +node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" mindmap list --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 \ --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 --role persona --text "<以我自己的角度重述>" --topics "..." --salience 55 ``` ### 7. 收尾 ```bash -python3 "${CLAUDE_PLUGIN_ROOT}/scripts/persona.py" prune --session -python3 "${CLAUDE_PLUGIN_ROOT}/scripts/persona.py" reindex --session +node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" prune --session +node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" reindex --session ``` 回報:固化幾則、淘汰幾筆、心智圖新增哪些概念、還有哪些 thread 待驗證, @@ -115,5 +138,6 @@ python3 "${CLAUDE_PLUGIN_ROOT}/scripts/persona.py" reindex --session `) +**CLI**:`node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs"`(帶 `--session `) --- @@ -24,23 +24,23 @@ description: 維護人格的人際關係圖:新增或更新人物/人格/群 ```bash # 看現況 -python3 "${CLAUDE_PLUGIN_ROOT}/scripts/persona.py" relation show --session +node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" relation show --session # 新增/更新節點 -python3 "${CLAUDE_PLUGIN_ROOT}/scripts/persona.py" relation node \ +node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" relation node \ --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 --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 --from user --to xiao-lin --label "同事/有摩擦" --affinity 30 # 重繪 Mermaid -python3 "${CLAUDE_PLUGIN_ROOT}/scripts/persona.py" relation render --session +node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" relation render --session ``` ## 調整幅度(單次) diff --git a/skills/persona-status/SKILL.md b/skills/persona-status/SKILL.md index 715031b..49cc527 100644 --- a/skills/persona-status/SKILL.md +++ b/skills/persona-status/SKILL.md @@ -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 +node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" status --persona # 本 session 載入了什麼 -python3 "${CLAUDE_PLUGIN_ROOT}/scripts/persona.py" status --session +node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" status --session # 釋放(含所有 guest 租約) -python3 "${CLAUDE_PLUGIN_ROOT}/scripts/persona.py" release --session +node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" release --session # 清理死鎖與過期 guest 租約 -python3 "${CLAUDE_PLUGIN_ROOT}/scripts/persona.py" gc +node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" gc ``` ## 載入失敗怎麼判斷