From e250fcbfce0d98c0cf240c788949c619b052d618 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Wed, 29 Jul 2026 13:14:43 +0800 Subject: [PATCH 1/3] =?UTF-8?q?fix(role):=20cron=20=E6=A2=9D=E7=9B=AE?= =?UTF-8?q?=E6=94=B9=E6=8C=87=E5=9B=BA=E5=AE=9A=E8=B7=AF=E5=BE=91=E5=95=9F?= =?UTF-8?q?=E5=8B=95=E5=99=A8=EF=BC=8C=E5=8D=87=E7=89=88=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E9=9D=9C=E9=BB=98=E5=A4=B1=E6=95=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --install-cron 原本把安裝當下的版本目錄寫進 crontab,plugin 升版、 舊版本目錄被清掉之後,排程會指向不存在的路徑而靜默停擺(cron 不回報, 此類錯誤曾造成排程長期空轉)。 - 新增 ~/.roles/bin/role_sleep_launcher.sh:crontab 只認這個固定路徑, 實際的 role_sleep.sh 於觸發當下以 sort -V 解析最新版本後 exec - 解析順序 Claude Code 端 → Codex 端,都找不到才退回安裝當下的路徑 - --remove-cron 一併清除啟動器 - --status 新增「排程指向」欄位,主動指出失效或舊式寫死路徑的條目 - SKILL.md 補上啟動器機制與轉換方式 Co-Authored-By: Claude Opus 5 (1M context) --- scripts/role/role_sleep.sh | 90 ++++++++++++++++++++++++++++++++++++-- skills/role/SKILL.md | 20 +++++++++ 2 files changed, 106 insertions(+), 4 deletions(-) diff --git a/scripts/role/role_sleep.sh b/scripts/role/role_sleep.sh index 088c610..83417fe 100755 --- a/scripts/role/role_sleep.sh +++ b/scripts/role/role_sleep.sh @@ -7,7 +7,7 @@ # 另提供 --nap(CLI 閒置時的小睡整理)、--catchup(cron 未執行時的補跑)、 # --force(手動立即整理)、--export(匯出角色壓縮檔)、 # --install-cron/--remove-cron(排程安裝與移除)、--status(狀態)。 -# 更新時間:2026/07/28 16:02:10 +# 更新時間:2026/07/29 13:13:21 # 相依:bash、node、任一 headless CLI、crontab(僅排程安裝需要)、 # 同目錄的 role_lib.sh 與 memory.js。 # 退出碼:0 成功或無事可做;1 參數錯誤或整理失敗(cron 觸發時不影響使用者)。 @@ -256,14 +256,14 @@ cron_env_prefix() { cron_line() { # 組出 crontab 條目:睡眠時段內每小時檢查一次 printf '0 %s * * * %s %s --run >> %s 2>&1 %s\n' \ - "$(cron_hours)" "$(cron_env_prefix)" "$(cron_quote "${SCRIPT_DIR}/role_sleep.sh")" \ + "$(cron_hours)" "$(cron_env_prefix)" "$(cron_quote "$(launcher_path)")" \ "$(cron_quote "$(sleep_log_path)")" "$CRON_MARKER" } nap_cron_line() { # 組出小睡 crontab 條目:全天依間隔檢查閒置狀態 printf '*/%s * * * * %s %s --nap >> %s 2>&1 %s\n' \ - "$(nap_interval_minutes)" "$(cron_env_prefix)" "$(cron_quote "${SCRIPT_DIR}/role_sleep.sh")" \ + "$(nap_interval_minutes)" "$(cron_env_prefix)" "$(cron_quote "$(launcher_path)")" \ "$(cron_quote "$(sleep_log_path)")" "$NAP_CRON_MARKER" } @@ -297,7 +297,7 @@ brief_hour() { brief_cron_line() { # 組出晨間狀態檢查條目:每日睡眠結束時執行一次 printf '0 %s * * * %s %s --brief >> %s 2>&1 %s\n' \ - "$(brief_hour)" "$(cron_env_prefix)" "$(cron_quote "${SCRIPT_DIR}/role_sleep.sh")" \ + "$(brief_hour)" "$(cron_env_prefix)" "$(cron_quote "$(launcher_path)")" \ "$(cron_quote "$(sleep_log_path)")" "$BRIEF_CRON_MARKER" } @@ -390,10 +390,64 @@ sleep_log_path() { printf '%s/sleep.log' "$(role_home)" } +launcher_path() { + # 排程啟動器:路徑固定不含版本號,crontab 條目一律指向這裡 + printf '%s/bin/role_sleep_launcher.sh' "$(role_home)" +} + +shell_quote() { + # 包成單引號供 shell script 內文使用;與 cron_quote 的差別是不跳脫 % + printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")" +} + +write_cron_launcher() { + # 產生排程啟動器:cron 條目指向它,真正要執行的 role_sleep.sh 在觸發當下才解析。 + # + # 為什麼要多這一層:若把安裝當下的版本目錄直接寫進 crontab,plugin 升版、 + # 舊版本目錄被清掉之後,排程就會指向不存在的路徑並**靜默失效** + # (同一類錯誤曾造成排程長期空轉,且因為 cron 不會回報而不易察覺)。 + local path dir + path="$(launcher_path)" + dir="$(dirname "$path")" + mkdir -p "$dir" 2>/dev/null || { role_log "ERR" "無法建立啟動器目錄:${dir}"; return 1; } + { + printf '#!/usr/bin/env bash\n' + printf '# 由 role_sleep.sh --install-cron 自動產生,請勿手動編輯(重跑 --install-cron 會覆蓋)。\n' + printf '# 用途:讓 crontab 條目指向固定路徑,實際執行的版本於觸發當下解析,plugin 升版後不必重裝排程。\n' + printf '# 更新時間:%s\n' "$(TZ='Asia/Taipei' date '+%Y/%m/%d %H:%M:%S')" + cat <<'EOF_LAUNCHER' +set -uo pipefail + +resolve_latest() { + # 同一個 cache 根目錄下可能留有多個版本目錄,取版本號最大者 + ls -d "$1"/*/jsc-generic/*/scripts/role/role_sleep.sh 2>/dev/null | sort -V | tail -n 1 +} + +# 以 Claude Code 端為優先,沒有才找 Codex 端;兩端腳本相同,差別只在安裝位置 +target="$(resolve_latest "${HOME}/.claude/plugins/cache")" +[ -n "$target" ] || target="$(resolve_latest "${HOME}/.codex/plugins/cache")" +EOF_LAUNCHER + printf '[ -n "$target" ] || target=%s # 後援:安裝當下的位置\n' "$(shell_quote "${SCRIPT_DIR}/role_sleep.sh")" + cat <<'EOF_LAUNCHER' + +if [ ! -r "$target" ]; then + printf '[role-sleep][ERR]: 找不到可用的 role_sleep.sh,本次排程略過\n' >&2 + exit 1 +fi + +exec bash "$target" "$@" +EOF_LAUNCHER + } > "$path" || { role_log "ERR" "寫入啟動器失敗:${path}"; return 1; } + chmod +x "$path" 2>/dev/null + return 0 +} + install_cron() { # 安裝或更新睡眠排程;以 marker 註解辨識自己的條目,不動使用者其他排程 command -v crontab >/dev/null 2>&1 || { role_log "ERR" "找不到 crontab,無法安裝排程"; return 1; } mkdir -p "$(role_home)" 2>/dev/null + # 先產生啟動器:cron 條目只認這個固定路徑,實際版本留到觸發當下才解析 + write_cron_launcher || return 1 local current new current="$(crontab -l 2>/dev/null | grep -v -F "$CRON_MARKER" | grep -v -F "$NAP_CRON_MARKER" | grep -v -F "$BRIEF_CRON_MARKER")" new="$(printf '%s\n%s' "$current" "$(cron_line)" | sed '/^$/d')" @@ -416,6 +470,7 @@ install_cron() { else role_log "DBG" "未安裝晨間狀態檢查排程(需建立 $(checks_dir) 並放入可執行的 *.sh)" fi + role_log "INF" "排程啟動器:$(launcher_path)(升版後不必重裝排程)" role_log "INF" "排程輸出:$(sleep_log_path)" if ! pgrep -x cron >/dev/null 2>&1 && ! pgrep -x crond >/dev/null 2>&1; then role_log "WRN" "系統 cron 服務未執行(WSL 常見),排程不會觸發;SessionStart 的背景補跑仍會運作" @@ -427,6 +482,13 @@ remove_cron() { # 移除本 skill 安裝的排程條目 command -v crontab >/dev/null 2>&1 || { role_log "ERR" "找不到 crontab"; return 1; } crontab -l 2>/dev/null | grep -v -F "$CRON_MARKER" | grep -v -F "$NAP_CRON_MARKER" | grep -v -F "$BRIEF_CRON_MARKER" | crontab - + # 啟動器只服務本 skill 的排程,排程移除後一併清掉;bin/ 若還有別的檔案則保留 + local launcher + launcher="$(launcher_path)" + if [ -f "$launcher" ]; then + rm -f "$launcher" && role_log "INF" "已移除排程啟動器:${launcher}" + fi + rmdir "$(dirname "$launcher")" 2>/dev/null || true role_log "INF" "已移除睡眠、小睡與晨間狀態檢查排程" return 0 } @@ -624,6 +686,25 @@ EOF_AGENT return 0 } +cron_target_state() { + # 檢查 crontab 條目實際指向的執行檔還在不在。 + # 舊條目若寫死版本目錄,plugin 升版清掉舊版本後就會指向不存在的路徑並靜默失效, + # cron 不會回報,只能在這裡主動點出來。 + local line target + line="$(crontab -l 2>/dev/null | grep -F "$CRON_MARKER" | head -n 1)" + [ -n "$line" ] || { printf '未安裝'; return 0; } + target="$(printf '%s' "$line" | sed -n "s/.*'\([^']*role_sleep[^']*\)'[[:space:]]*--.*/\1/p")" + if [ -z "$target" ]; then + printf '無法解析條目內容' + elif [ ! -r "$target" ]; then + printf '⚠ 指向不存在的路徑(%s),請重跑 --install-cron' "$target" + elif [ "$target" = "$(launcher_path)" ]; then + printf '正常(%s)' "$target" + else + printf '⚠ 舊式寫死版本路徑(%s),建議重跑 --install-cron' "$target" + fi +} + show_status() { # 以表格輸出目前角色與記憶狀態(供 skill 的 --status 使用) local cron_state="未安裝" nap_state="未安裝" brief_state="未安裝" cron_service="未執行" window="否" checks_state instance_state @@ -661,6 +742,7 @@ show_status() { printf '| cron 排程 | %s |\n' "$cron_state" printf '| 小睡排程 | %s |\n' "$nap_state" printf '| 晨間檢查排程 | %s |\n' "$brief_state" + printf '| 排程指向 | %s |\n' "$(cron_target_state)" printf '| 角色載入鎖 | %s |\n' "$instance_state" printf '| 檢查腳本目錄 | %s |\n' "$checks_state" printf '| 小睡啟用 | %s |\n' "$(nap_enabled && printf '是' || printf '否')" diff --git a/skills/role/SKILL.md b/skills/role/SKILL.md index aea25bf..9bb1dc5 100644 --- a/skills/role/SKILL.md +++ b/skills/role/SKILL.md @@ -395,6 +395,7 @@ node "${ROLE_DIR}/memory.js" forget --role "<角色 ID>" --dry-run | 時段 | 目前是否落在睡眠時段(睡眠時本來就不載入角色) | | 依賴 | `node` 與 `ROLE_CLI` 選到的 CLI 是否找得到 | | 排程 | cron 條目是否存在、cron 服務是否執行中(WSL 常未啟動 → 靠啟動時補跑) | +| 排程指向 | 條目指到的執行檔是否還存在(舊條目寫死版本目錄時會在升版後失效,見下節) | ### `--install-cron`/`--remove-cron` @@ -406,6 +407,25 @@ node "${ROLE_DIR}/memory.js" forget --role "<角色 ID>" --dry-run 安裝時會把精簡後的 `PATH`(系統基本路徑、`node` 與摘要 CLI 所在目錄)與 `ROLE_*` 變數固定寫進條目(cron 沒有互動 shell 的環境變數),並在 cron 服務未執行時警告。不得把互動 shell 的完整 `PATH` 原樣寫入,避免 crontab 因單行過長拒收。 +#### 排程啟動器(為什麼 crontab 不直接指向 `role_sleep.sh`) + +crontab 條目指向的是 `~/.roles/bin/role_sleep_launcher.sh`,這支啟動器由 `--install-cron` 自動產生(`--remove-cron` 會一併刪除),內容只做一件事:解析目前最新的 `role_sleep.sh` 後 `exec` 過去。 + +| 位置 | 是否含版本號 | +| --- | --- | +| crontab 條目 → 啟動器 | ❌ 固定路徑 | +| 啟動器 → 實際腳本 | ✅ 觸發當下才解析 | + +**理由**:plugin 每次升版都會產生新的版本目錄,舊目錄清掉後,寫死版本路徑的 crontab 條目就會指向不存在的檔案。cron 不會回報這種失敗,排程只是**靜默停擺**(此類錯誤曾造成排程長期空轉才被發現)。多這一層之後,升版不必重裝排程。 + +解析順序為 Claude Code 端 → Codex 端,各自取版本號最大者(`sort -V`),都找不到才退回安裝當下的路徑: + +```bash +ls -d "$HOME"/.claude/plugins/cache/*/jsc-generic/*/scripts/role/role_sleep.sh | sort -V | tail -n 1 +``` + +舊版安裝的排程仍是寫死路徑,`--status` 的「排程指向」欄位會標示出來,重跑一次 `--install-cron` 即可轉換。 + --- ## 角色檔標準格式 -- 2.53.0 From 4a06d770b69b8bdc9e8927dfc95c797087920017 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Wed, 29 Jul 2026 13:14:48 +0800 Subject: [PATCH 2/3] =?UTF-8?q?chore(plugin=20=E7=89=88=E6=9C=AC):=20?= =?UTF-8?q?=E4=B8=89=E5=AE=B6=20manifest=20=E5=8D=87=E7=89=88=200.0.9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) --- .claude-plugin/plugin.json | 2 +- .codex-plugin/plugin.json | 2 +- plugin.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 2a048bb..1984f33 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "jsc-generic", - "version": "0.0.8", + "version": "0.0.9", "description": "JSC 跨 AI 助理共用規範 plugin(Claude Code / Codex / Antigravity / OpenCode)。所有 skills 以 SKILL.md 為共通標準,於 Claude Code 以 /jsc-generic: 前綴呼叫。", "skills": "./skills", "author": { diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 50275fe..13650a9 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "jsc-generic", - "version": "0.0.8", + "version": "0.0.9", "description": "JSC 跨 AI 助理共用規範 plugin。所有 skills 以 SKILL.md 為共通標準。", "skills": "./skills" } diff --git a/plugin.json b/plugin.json index 7041865..06d632e 100644 --- a/plugin.json +++ b/plugin.json @@ -1,6 +1,6 @@ { "name": "jsc-generic", - "version": "0.0.8", + "version": "0.0.9", "description": "JSC 跨 AI 助理共用規範 plugin。所有 skills 以 SKILL.md 為共通標準;於 Antigravity 以 /jsc-generic: 前綴呼叫。", "skills": "./skills/" } -- 2.53.0 From 46caabe4d88597ce849ffab6a9ea88f21f3b50f6 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Wed, 29 Jul 2026 13:38:25 +0800 Subject: [PATCH 3/3] =?UTF-8?q?feat(role):=20=E6=96=B0=E5=A2=9E=E9=BB=9E?= =?UTF-8?q?=E5=90=8D=E8=BC=89=E5=85=A5=EF=BC=8C=E5=B0=8D=E8=A9=B1=E4=B8=AD?= =?UTF-8?q?=E5=8F=AB=E5=90=8D=E5=AD=97=E5=8D=B3=E5=8F=AF=E5=88=87=E6=8F=9B?= =?UTF-8?q?=E8=A7=92=E8=89=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `UserPromptSubmit` hook(`role_call.sh`)比對訊息開頭的角色名稱/ID/`aliases` 別名, 命中即注入該角色人格與記憶並接手本輪;未點名時不輸出內容也不寫檔案。 - 抽出 `role_context.sh` 共用 context 組裝,SessionStart 與點名載入共用同一份人格與操作規則, 避免兩種載入方式漂移;實測 SessionStart 注入內容與改動前 byte-identical。 - 新增階段角色狀態(`~/.roles/.sessions/<工作階段>.role`),`Stop` hook 改以「本階段實際角色」 寫記憶,避免點名換人後把跟 A 的對話記進 B 的記憶。 - `SessionEnd` 改為釋放本階段名下所有角色鎖並清掉階段狀態檔。 - 切換時先確認取得目標角色鎖才釋放原角色鎖,避免出現兩個角色都沒有的空窗。 - 睡眠時段、目標角色已被其他活躍階段佔用、點名的是已在場的角色時,一律不切換。 - 新增 `ROLE_CALL_ENABLED`(總開關)與 `ROLE_CALL_MARKER_ONLY`(只認 `@名字`)。 - 順帶修正 `role_capture.sh` 的 `--postcompact` 分支在 `PROJECT` 賦值前就使用它, 導致壓縮摘要記憶的 `sources` 一直為空。 Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 7 +- hooks/hooks.json | 11 + scripts/role/role_call.sh | 232 +++++++++++++++++++++ scripts/role/role_capture.sh | 33 ++- scripts/role/role_context.sh | 387 +++++++++++++++++++++++++++++++++++ scripts/role/role_lib.sh | 99 ++++++++- scripts/role/role_load.sh | 331 +++--------------------------- scripts/role/role_unload.sh | 43 ++-- skills/role/SKILL.md | 87 ++++++-- 9 files changed, 884 insertions(+), 346 deletions(-) create mode 100755 scripts/role/role_call.sh create mode 100755 scripts/role/role_context.sh diff --git a/README.md b/README.md index 1344979..63ff451 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ generic/ │ └── marketplace.json # Codex marketplace(name: "generic",url source 指向本 repo) ├── plugin.json # Antigravity 外掛定義(name: "jsc-generic",skills: "./skills/") ├── hooks/ -│ └── hooks.json # hook 定義(SessionStart 載入角色並提示問候、Stop 記錄記憶、SessionEnd 釋放角色鎖) +│ └── hooks.json # hook 定義(SessionStart 載入角色並提示問候、UserPromptSubmit 點名載入切換角色、Stop 記錄記憶、SessionEnd 釋放角色鎖) ├── scripts/ │ └── role/ # role skill 的可執行元件(腳本一律不放進 skills/) ├── skills/ # ★ 唯一真實來源:所有 skills @@ -214,9 +214,9 @@ copilot plugin marketplace remove generic | Skill | 用途 | 使用方法 | | --- | --- | --- | -| `role` | 讓 CLI 以固定角色(name/nature/vibe/簽名 emoji)回覆,並把每輪對話累積成長期記憶:啟動時依字元預算載入高價值記憶,Stop hook 先本地過濾低價值回合以節省額度,睡眠時段(預設 22:00–06:00)由 NREM 鞏固與 REM 整合兩階段整理、去重、標籤化、建立關聯,並標記 semantic/episodic/procedural/emotional/preference/rule 與 explicit/implicit 後壓縮歸檔;新建角色時可只給角色名稱,必要時詢問來源/作品並推斷四欄描述,也可匯出角色定義、資產與記憶壓縮檔;角色檔名與記憶目錄使用英文大寫 ID | `/jsc-generic:role --new` 建立或更新角色、`--use <角色 ID>` 切換、`--list` 查角色與 ID、`--export <路徑>` 匯出角色、`--sleep` 立即整理、`--status` 診斷、`--install-cron` 安裝排程 | +| `role` | 讓 CLI 以固定角色(name/nature/vibe/簽名 emoji)回覆,並把每輪對話累積成長期記憶:啟動時依字元預算載入高價值記憶,Stop hook 先本地過濾低價值回合以節省額度,睡眠時段(預設 22:00–06:00)由 NREM 鞏固與 REM 整合兩階段整理、去重、標籤化、建立關聯,並標記 semantic/episodic/procedural/emotional/preference/rule 與 explicit/implicit 後壓縮歸檔;新建角色時可只給角色名稱,必要時詢問來源/作品並推斷四欄描述,也可匯出角色定義、資產與記憶壓縮檔;角色檔名與記憶目錄使用英文大寫 ID | `/jsc-generic:role --new` 建立或更新角色、`--use <角色 ID>` 切換、`--list` 查角色與 ID、`--export <路徑>` 匯出角色、`--sleep` 立即整理、`--status` 診斷、`--install-cron` 安裝排程;對話中直接以名字點名(「西莉卡,…」/「@SILICA01 …」)可即時換角色 | -`role` 的自動路徑由 hook 與 cron 完成,**建立角色後重開工作階段即生效**;非睡眠時段載入角色後,角色會在本工作階段第一則回覆開頭主動簡短問候一次。載入方式參考 OpenClaw 的分層概念:從角色檔抽出人格作為 `SOUL`,由 hook 產生固定操作邊界作為 `AGENTS`,再把同意狀態與高價值記憶作為 `USER/MEMORY` 注入,避免整份人格檔污染工程規則。感覺記憶不落檔,`inbox/` 作為工作記憶,睡眠整理後才進長期記憶;個人記憶保存同意狀態寫在 `~/.memory/<角色 ID>/state.json`,同意後不會每次重問。角色檔、記憶目錄、`.active` 與 `ROLE_NAME` 一律使用角色 ID(例如 `ENGINEER01`),`--list` 可查每個顯示名稱對應的 ID。沒有建立過角色的人完全不受影響(`~/.roles/.active` 不存在時 hook 立即結束)。細節見 `skills/role/SKILL.md`。 +`role` 的自動路徑由 hook 與 cron 完成,**建立角色後重開工作階段即生效**;非睡眠時段載入角色後,角色會在本工作階段第一則回覆開頭主動簡短問候一次。載入方式參考 OpenClaw 的分層概念:從角色檔抽出人格作為 `SOUL`,由 hook 產生固定操作邊界作為 `AGENTS`,再把同意狀態與高價值記憶作為 `USER/MEMORY` 注入,避免整份人格檔污染工程規則。感覺記憶不落檔,`inbox/` 作為工作記憶,睡眠整理後才進長期記憶;個人記憶保存同意狀態寫在 `~/.memory/<角色 ID>/state.json`,同意後不會每次重問。角色檔、記憶目錄、`.active` 與 `ROLE_NAME` 一律使用角色 ID(例如 `ENGINEER01`),`--list` 可查每個顯示名稱對應的 ID。沒有建立過角色的人完全不受影響(`~/.roles/.active` 不存在時 hook 立即結束)。同一個終端要臨時換角色不必改設定或重開 CLI:訊息開頭以名字點名即可由該角色接手(`UserPromptSubmit` hook),本階段之後的對話會記進被點名角色的記憶;真的要同時跟兩個角色對話則各開一個終端並設不同的 `ROLE_NAME`。細節見 `skills/role/SKILL.md`。 @@ -231,6 +231,7 @@ copilot plugin marketplace remove generic | `skills/spec-*`(純規範) | ✅ | ✅ | ✅ | ✅ | ✅ | | `skills/role` 的手動模式 | ✅ | ⚠️ 需保留 `scripts/` | ⚠️ 同左 | ❌ 只複製 `skills/` | ⚠️ 同左 | | `hooks/hooks.json`:`SessionStart` 載入角色 | ✅ | ⚠️ 需該版本支援 | ❌ | ❌ | ❌ | +| `hooks/hooks.json`:`UserPromptSubmit` 點名載入 | ✅ | ⚠️ 需該版本支援 | ❌ | ❌ | ❌ | | `hooks/hooks.json`:`Stop` 記錄記憶 | ✅ | ✅ | ❌ | ❌ | ❌ | | `hooks/hooks.json`:`SessionEnd` 釋放角色鎖 | ✅ | ⚠️ 需該版本支援 | ❌ | ❌ | ❌ | | cron 睡眠整理(系統排程) | ✅ | ✅ | ✅ | ✅ | ✅ | diff --git a/hooks/hooks.json b/hooks/hooks.json index db6bace..3f0c95b 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -11,6 +11,17 @@ ] } ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "rel='scripts/role/role_call.sh'; own='generic'; plug='jsc-generic'; root=\"${CLAUDE_PLUGIN_ROOT:-}\"; if [ -n \"$root\" ] && [ -f \"$root/$rel\" ]; then exec \"$root/$rel\"; fi; for base in \"$HOME/.claude/plugins/cache\" \"$HOME/.codex/plugins/cache\"; do for dir in \"$base/$own/$plug\" \"$base\"; do s=$(find \"$dir\" -path \"*/$plug/*/$rel\" -type f 2>/dev/null | sort -V | tail -n 1); if [ -n \"$s\" ]; then exec \"$s\"; fi; done; done; exit 0", + "timeout": 20 + } + ] + } + ], "SessionEnd": [ { "hooks": [ diff --git a/scripts/role/role_call.sh b/scripts/role/role_call.sh new file mode 100755 index 0000000..fd61203 --- /dev/null +++ b/scripts/role/role_call.sh @@ -0,0 +1,232 @@ +#!/usr/bin/env bash +# ============================================================================== +# 用途:UserPromptSubmit hook 主程式(點名載入)。使用者在對話中以角色名稱/ID/別名 +# 開頭點名時,即時把該角色的人格與記憶注入本輪 context,由它接手回應; +# 同時把「本階段目前實際是誰」寫進狀態檔,讓 Stop hook 把記憶記到正確的角色。 +# 未點名、點的是目前已在的角色、角色在睡覺或已被其他階段佔用時,一律不切換。 +# 更新時間:2026/07/29 13:25:00 +# 相依:bash、node、同目錄的 role_lib.sh、role_context.sh 與 memory.js。 +# 退出碼:一律 0 —— hook 絕不可阻斷使用者送出訊息。 +# +# 為什麼要有這支:人格原本只在 SessionStart 注入,換角色必須改 .active 再重開 CLI。 +# 想在同一個終端臨時換人(或在原角色被別的視窗佔用時改叫別人)就只能重開工作階段。 +# 點名載入把「換人」變成一句話的事,代價是每輪多一次極輕量的比對(未命中即結束)。 +# +# 設計原則:**沒點名就等於不存在** —— 未命中時不輸出任何 context、不寫任何檔案, +# 避免每輪對話都被塞入內容或留下狀態;hook 只有在確定要切換角色時才有副作用。 +# ============================================================================== + +ROLE_STAGE="role-call" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=./role_lib.sh +. "${SCRIPT_DIR}/role_lib.sh" +# shellcheck source=./role_context.sh +. "${SCRIPT_DIR}/role_context.sh" + +role_is_child && exit 0 +role_call_enabled || exit 0 +# 總開關明確關閉時不作用;未設定時不用 role_enabled 判斷,因為點名的目標角色 +# 不一定等於 ROLE_NAME/.active 解析出的角色(甚至可能根本沒設 .active) +case "${ROLE_ENABLED:-}" in + 0|false|no) exit 0 ;; +esac +[ -d "$(role_home)" ] || exit 0 +command -v node >/dev/null 2>&1 || exit 0 + +# ------------------------------------------------------------------------------ +# 讀取 hook 輸入(prompt/cwd/transcript/session) +# ------------------------------------------------------------------------------ +HOOK_INPUT="$(cat 2>/dev/null)" +[ -n "$HOOK_INPUT" ] || exit 0 + +HOOK_PROMPT="$(printf '%s' "$HOOK_INPUT" | node -e ' +let raw = ""; +process.stdin.setEncoding("utf8"); +process.stdin.on("data", (chunk) => { raw += chunk; }); +process.stdin.on("end", () => { + let data = {}; + try { data = JSON.parse(raw); } catch {} + process.stdout.write(String(data.prompt || data.user_prompt || data.message || "")); +}); +' 2>/dev/null)" +[ -n "$HOOK_PROMPT" ] || exit 0 + +HOOK_FIELDS="$(printf '%s' "$HOOK_INPUT" | node -e ' +let raw = ""; +process.stdin.setEncoding("utf8"); +process.stdin.on("data", (chunk) => { raw += chunk; }); +process.stdin.on("end", () => { + let data = {}; + try { data = JSON.parse(raw); } catch {} + process.stdout.write([ + data.cwd || "", + data.transcript_path || data.session_path || data.conversation_path || data.path || "", + data.session_id || data.thread_id || data.conversation_id || "", + ].join("\n")); +}); +' 2>/dev/null)" +HOOK_CWD="$(printf '%s' "$HOOK_FIELDS" | sed -n '1p')" +HOOK_TRANSCRIPT="$(printf '%s' "$HOOK_FIELDS" | sed -n '2p')" +HOOK_SESSION="$(printf '%s' "$HOOK_FIELDS" | sed -n '3p')" +[ -n "$HOOK_CWD" ] || HOOK_CWD="$PWD" +role_in_scope "$HOOK_CWD" || exit 0 + +# ------------------------------------------------------------------------------ +# 比對點名:只認**訊息開頭**的角色名稱/ID/別名 +# +# 為什麼只認開頭:句中提到名字(「剛剛西莉卡說的做法」)是在談論那個角色,不是要叫他來, +# 把兩者混為一談會讓角色莫名其妙被換掉。開頭點名是使用者唯一明確的「我在叫你」訊號。 +# 半形名稱(角色 ID、英文別名)額外要求後面接的不是英數字,避免 SILICA01 命中 silica01x。 +# 全形/中文名稱不要求分隔符,因為中文本來就不用空格斷詞(「西莉卡在嗎」必須算點名)。 +# 名稱較長者優先命中,避免不同角色的名稱互相包含時判給錯的人。 +# ------------------------------------------------------------------------------ +# 提示詞以參數傳入而非 stdin:`node -` 的 stdin 已經被 heredoc 佔用來當程式碼, +# 再從 stdin 讀資料會拿到空字串(此處曾因此完全比不到人)。 +TARGET="$(node - "$(role_home)" "$(role_call_marker_only && printf '1' || printf '0')" "$HOOK_PROMPT" <<'NODE_MATCH' 2>/dev/null +const fs = require("fs"); +const path = require("path"); + +const home = process.argv[2]; +const markerOnly = process.argv[3] === "1"; +const prompt = process.argv[4] || ""; + +function parseFrontmatter(text) { + const match = text.match(/^---\n([\s\S]*?)\n---\n?/); + const data = {}; + if (!match) return data; + for (const line of match[1].split(/\r?\n/)) { + const idx = line.indexOf(":"); + if (idx < 0) continue; + data[line.slice(0, idx).trim()] = line.slice(idx + 1).trim(); + } + return data; +} + +function candidates() { + const out = []; + let files = []; + try { files = fs.readdirSync(home); } catch { return out; } + for (const file of files) { + if (file.endsWith(".soul.md")) continue; + let id = ""; + if (file.endsWith(".identity.md")) { + id = file.slice(0, -".identity.md".length); + } else if (file.endsWith(".md")) { + id = file.slice(0, -".md".length); + if (fs.existsSync(path.join(home, `${id}.identity.md`))) continue; + } else { + continue; + } + let raw = ""; + try { raw = fs.readFileSync(path.join(home, file), "utf8"); } catch { continue; } + const fm = parseFrontmatter(raw); + const names = new Set([id]); + if (fm.name) names.add(fm.name.trim()); + // 別名可寫在 identity frontmatter 的 aliases,以逗號、頓號或空白分隔 + for (const alias of String(fm.aliases || "").split(/[,,、\s]+/)) { + if (alias) names.add(alias.trim()); + } + for (const name of names) if (name) out.push({ id, name }); + } + return out.sort((a, b) => b.name.length - a.name.length); +} + +function match() { + let text = prompt.replace(/^[\s ]+/, ""); + const marker = text.match(/^[@@][\s ]*/); + if (marker) text = text.slice(marker[0].length); + if (markerOnly && !marker) return ""; + + const lower = text.toLowerCase(); + for (const cand of candidates()) { + const name = cand.name.toLowerCase(); + if (!name || !lower.startsWith(name)) continue; + const rest = text.slice(cand.name.length); + const isAscii = /^[\x00-\x7f]*$/.test(cand.name); + if (rest && isAscii && /^[0-9A-Za-z_]/.test(rest)) continue; + return cand.id; + } + return ""; +} + +process.stdout.write(match()); +NODE_MATCH +)" +[ -n "$TARGET" ] || exit 0 +[ -f "$(role_file "$TARGET")" ] || exit 0 + +# ------------------------------------------------------------------------------ +# 判斷本階段目前實際是誰:狀態檔優先,其次 ROLE_NAME/.active +# 已經是同一個角色就不重複注入 —— 每輪重灌一份人格只是白燒 context +# ------------------------------------------------------------------------------ +SESSION_KEY="$(role_session_key "$HOOK_SESSION" "$HOOK_TRANSCRIPT")" +CURRENT="$(role_session_get "$SESSION_KEY" 2>/dev/null || printf '')" +if [ -z "$CURRENT" ] && ! role_session_exists "$SESSION_KEY"; then + # 完全沒有本階段狀態時(無法識別階段、或 SessionStart 未執行)退回靜態解析, + # 至少不會把明明已經在的角色重載一次 + CURRENT="$(role_resolve_name)" +fi +if [ "$CURRENT" = "$TARGET" ]; then + role_log "DBG" "點名的角色 ${TARGET} 已在本階段,不重複注入" + exit 0 +fi + +# ------------------------------------------------------------------------------ +# 睡眠時段:不接手,只說明狀態(與 SessionStart 一致,避免半夜把角色叫起來) +# ------------------------------------------------------------------------------ +if role_in_sleep_window; then + role_log "INF" "點名角色 ${TARGET} 但目前為睡眠時段,不接手" + role_context_emit "UserPromptSubmit" "$(cat </dev/null 2>&1 || role_quit "找不到 node,略過記憶記錄" "WRN" -ROLE="$(role_resolve_name)" -[ -n "$ROLE" ] || role_quit "未指定角色,略過記憶記錄" -[ -f "$(role_file "$ROLE")" ] || role_quit "找不到角色定義檔,略過記憶記錄" "WRN" - # ------------------------------------------------------------------------------ # 讀取 hook 傳入的 JSON(session_id/transcript_path/cwd/stop_hook_active) # ------------------------------------------------------------------------------ @@ -57,6 +58,25 @@ process.stdin.on("end", () => { ') EOF_HOOK +# ------------------------------------------------------------------------------ +# 記憶要記給誰:以本階段實際角色為準 +# +# 使用者可能在對話中點名換人(role_call.sh),此時 ROLE_NAME/.active 指的還是原角色, +# 照它寫就會把跟 A 的對話記進 B 的記憶。狀態檔取不到時(未點名過、或 SessionStart +# 因睡眠/佔用而未載入人格)才退回靜態解析,維持「不載入人格但仍記錄記憶」的既有行為。 +# ------------------------------------------------------------------------------ +ROLE="$(role_session_get "$(role_session_key "$SESSION_ID" "$TRANSCRIPT_PATH")" 2>/dev/null || printf '')" +if [ -n "$ROLE" ]; then + role_log "DBG" "本階段實際角色為 ${ROLE}(依階段狀態檔)" +else + ROLE="$(role_resolve_name)" +fi +[ -n "$ROLE" ] || role_quit "未指定角色,略過記憶記錄" +[ -f "$(role_file "$ROLE")" ] || role_quit "找不到角色定義檔,略過記憶記錄" "WRN" + +# 專案判定要在壓縮分支之前算好:壓縮摘要也要標記專案,之後才有辦法回溯它屬於哪份工作 +PROJECT="$(role_project_name "$HOOK_CWD")" + if [ "$CAPTURE_MODE" = "postcompact" ]; then # 壓縮後:系統已產生一份摘要,直接保存比自己再濃縮一次划算且免費。 # 欄位名以容錯方式取用(實測 binary 內出現 compactSummary/isCompactSummary), @@ -99,7 +119,6 @@ fi [ "$STOP_ACTIVE" = "1" ] && [ "$CAPTURE_MODE" = "turn" ] && role_quit "stop_hook_active 為 true,避免迴圈不重複記錄" role_in_scope "$HOOK_CWD" || role_quit "cwd 不在 ROLE_SCOPE 範圍內:${HOOK_CWD}" -PROJECT="$(role_project_name "$HOOK_CWD")" node "${SCRIPT_DIR}/memory.js" mark-activity --role "$ROLE" --project "$PROJECT" >/dev/null 2>&1 || true if [ ! -f "$TRANSCRIPT_PATH" ] && [ -n "${CODEX_THREAD_ID:-}" ]; then diff --git a/scripts/role/role_context.sh b/scripts/role/role_context.sh new file mode 100755 index 0000000..fcded43 --- /dev/null +++ b/scripts/role/role_context.sh @@ -0,0 +1,387 @@ +#!/usr/bin/env bash +# ============================================================================== +# 用途:角色 context 組裝共用函式庫。把「角色人格(SOUL)+操作規則(AGENTS)+ +# 使用者理解(USER)+記憶(MEMORY)+同伴清單+近期對話」組成一份注入文字, +# 供 SessionStart(role_load.sh)與點名載入(role_call.sh)共用。 +# 本檔僅供 source,不可直接執行。 +# 更新時間:2026/07/29 13:25:00 +# 相依:bash、node、同目錄的 role_lib.sh(須先 source)/memory.js/transcript.js。 +# 機密:角色與記憶內容只組進字串交給呼叫端注入 context,不落檔。 +# +# 為什麼要獨立一支:兩個 hook(啟動載入、對話中點名載入)必須注入**完全一致**的人格與 +# 規則,否則同一個角色會因為「怎麼被叫出來的」而表現不同。共用一份組裝邏輯是唯一能保證 +# 一致的做法;差異只以 MODE 參數表達(見 role_context_build)。 +# ============================================================================== + +ROLE_CONTEXT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +role_context_emit() { + # 以 JSON 輸出 additionalContext(由 node 負責跳脫,避免內容含引號或換行破壞格式) + # $1=hook 事件名稱(SessionStart/UserPromptSubmit)、$2=要注入的內容 + printf '%s' "$2" | node -e ' +let context = ""; +const event = process.argv[2] || "SessionStart"; +process.stdin.setEncoding("utf8"); +process.stdin.on("data", (chunk) => { context += chunk; }); +process.stdin.on("end", () => { + process.stdout.write(JSON.stringify({ + hookSpecificOutput: { hookEventName: event, additionalContext: context }, + })); +}); +' -- "$1" +} + +role_context_profile() { + # 從角色定義檔組出人格區塊(身分+本質+氛圍+自由章節+簽名 emoji) + # $1=角色 ID;解析失敗或內容為空時回傳 1 + local role="$1" def soul + def="$(role_file "$role")" + soul="" + if role_is_new_format "$role"; then + soul="$(role_soul_file "$role")" + [ -f "$soul" ] || role_log "WRN" "新格式缺少人格檔:${soul}(本質與氛圍將為空)" + fi + + local profile + profile="$(node - "$def" "$soul" <<'NODE_PROFILE' 2>/dev/null +const fs = require("fs"); + +// 新格式:第一個參數是 .identity.md(身分),第二個是 .soul.md(人格)。 +// 舊格式:只有第一個參數,身分與人格都在同一個檔案裡。 +const file = process.argv[2]; +const soulFile = process.argv[3] || ""; +const raw = fs.readFileSync(file, "utf8"); +let soulRaw = ""; +if (soulFile) { + try { soulRaw = fs.readFileSync(soulFile, "utf8"); } catch { soulRaw = ""; } +} + +function parseFrontmatter(text) { + const match = text.match(/^---\n([\s\S]*?)\n---\n?/); + const data = {}; + if (!match) return data; + for (const line of match[1].split(/\r?\n/)) { + const idx = line.indexOf(":"); + if (idx < 0) continue; + data[line.slice(0, idx).trim()] = line.slice(idx + 1).trim(); + } + return data; +} + +function section(text, title) { + const re = new RegExp(`^##\\s+${title.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[^\\n]*\\n([\\s\\S]*?)(?=^##\\s+|$(?![\\s\\S]))`, "m"); + const match = text.match(re); + return match ? match[1].trim() : ""; +} + +const fm = parseFrontmatter(raw); +const soulFm = soulRaw ? parseFrontmatter(soulRaw) : {}; +const title = raw.match(/^#\s+(.+)$/m)?.[1]?.trim() || [fm.name, fm.emoji].filter(Boolean).join(" "); + +// 人格優先取自 soul 檔;舊格式(無 soul 檔)則沿用原本從單一檔案抽取的行為 +const natureSrc = soulRaw || raw; +const natureFm = soulRaw ? soulFm : fm; +const nature = section(natureSrc, "本質(nature)") || natureFm.nature || ""; +const vibe = section(natureSrc, "氛圍(vibe)") || natureFm.vibe || ""; + +// soul 檔的其餘章節(例如核心信念、語氣與風格、邊界與規範)也要注入。 +// 只抽固定的兩節會讓使用者在人格檔裡寫的其他章節被靜默丟棄。 +function extraSections(text, skip) { + if (!text) return ""; + const body = text.replace(/^---\n[\s\S]*?\n---\n?/, ""); + const out = []; + const re = /^##\s+(.+)$/gm; + const marks = []; + let m; + while ((m = re.exec(body)) !== null) marks.push([m.index, m[0].length, m[1].trim()]); + for (let i = 0; i < marks.length; i += 1) { + const [idx, len, title] = marks[i]; + if (skip.some((s) => title.startsWith(s))) continue; + const end = i + 1 < marks.length ? marks[i + 1][0] : body.length; + const content = body.slice(idx + len, end).trim(); + if (content) out.push(`## ${title}`, "", content); + } + return out.join("\n"); +} +const extra = extraSections(soulRaw, ["本質", "氛圍"]); + +// 標題後、第一個 ## 之前的前言段落(使用者常在此寫存在本質、角色原型等摘要條目)。 +// 只抽 frontmatter 與具名章節會讓這段被靜默丟棄。 +function preamble(text) { + if (!text) return ""; + const body = text.replace(/^---\n[\s\S]*?\n---\n?/, "").replace(/^#\s+[^\n]*\n/, ""); + const idx = body.search(/^##\s+/m); + return (idx < 0 ? body : body.slice(0, idx)).trim(); +} +const intro = preamble(raw); + +// 身分只可能在 identity/舊檔裡 +const emoji = section(raw, "簽名 emoji") || fm.emoji || ""; +const source = section(raw, "來源(source)") || fm.source || ""; +const relationship = section(raw, "關係定位(relationship)") || fm.relationship || ""; + +const lines = [ + `- 角色 ID:${fm.id || soulFm.id || ""}`, + `- 顯示名稱:${fm.name || title || ""}`, + `- 簽名 emoji:${fm.emoji || ""}`, +]; +if (intro) lines.push("", intro); +if (source) lines.push("", "## 來源(source)", "", source); +if (relationship) lines.push("", "## 關係定位(relationship)", "", relationship); +lines.push( + "", + "## 本質(nature)", + "", + nature || "(未設定)", + "", + "## 氛圍(vibe)", + "", + vibe || "(未設定)", +); +if (extra) lines.push("", extra); +lines.push( + "", + "## 簽名 emoji", + "", + emoji || fm.emoji || "(未設定)", +); + +process.stdout.write(lines.join("\n")); +NODE_PROFILE +)" + [ -n "$profile" ] || return 1 + printf '%s' "$profile" +} + +# ------------------------------------------------------------------------------ +# 組出完整注入內容 +# +# $1=角色 ID、$2=本階段 transcript 路徑(可空)、$3=模式: +# load(預設):CLI 啟動時載入,本階段從一開始就是這個角色。 +# call:對話中被使用者點名接手,本階段先前的回覆屬於別的角色或一般助理。 +# +# 兩種模式共用同一份人格與規則,只有「怎麼交接」與「近期對話怎麼理解」不同。 +# +# 結果寫進全域 ROLE_CONTEXT,記憶大小寫進 ROLE_CONTEXT_MEMORY_BYTES(供 log 使用), +# 而不是印到 stdout —— 呼叫端用 $() 接會開子行程,這類附帶資訊就傳不回來,只能再跑一次 +# memory.js 才拿得到,那是白花的成本。解析失敗時回傳 1 且不改動 ROLE_CONTEXT。 +# ------------------------------------------------------------------------------ +role_context_build() { + local ROLE="$1" HOOK_TRANSCRIPT="$2" MODE="${3:-load}" + local SCRIPT_DIR="$ROLE_CONTEXT_DIR" + + local ROLE_PROFILE + ROLE_PROFILE="$(role_context_profile "$ROLE")" || return 1 + + local MEMORY CONSENT_STATUS CONSENT_NOTE + MEMORY="$(node "${SCRIPT_DIR}/memory.js" load --role "$ROLE" 2>/dev/null)" + ROLE_CONTEXT_MEMORY_BYTES="$(printf '%s' "$MEMORY" | wc -c)" + CONSENT_STATUS="$(node "${SCRIPT_DIR}/memory.js" consent-status --role "$ROLE" 2>/dev/null || printf 'unknown')" + case "$CONSENT_STATUS" in + accepted) + CONSENT_NOTE="已告知並取得使用者同意保存非敏感個人資料與長期偏好;仍禁止保存憑證、token、密碼、API key、連線字串、身分證號、住址等機密或高敏感資料。" + ;; + declined) + CONSENT_NOTE="使用者已拒絕保存個人資料;只能保存非個人化的操作規則與技術偏好,不保存可識別個人的背景。" + ;; + *) + CONSENT_NOTE="尚未確認;第一則自然回覆後,請簡短告知記憶保存範圍並詢問是否同意保存非敏感個人資料。未取得同意前,只能保存非個人化的操作規則與技術偏好。" + ;; + esac + + # ---------------------------------------------------------------------------- + # 近期對話交接:讀上一段真正說過的話(含角色自己的回覆) + # + # 為什麼需要:長期記憶是模型濃縮過的摘要,語氣與情緒會被壓掉;而且整理永遠跑在載入 + # 之後(見下方 catchup),上一段工作來不及進入本次載入。逐字對話則一直躺在 transcript + # JSONL 裡,只是過去沒有任何機制去讀它 —— 使用者重開工作階段時,角色因此看不到剛剛 + # 的互動,表現得像失去記憶,只能靠 resume 找回。 + # + # 取檔策略:全新工作階段的 transcript 幾乎是空的(實測僅數行),因此對話不足時要回頭 + # 找同目錄最近修改的對話檔。內容一律經 transcript.js 遮蔽,且只注入 context、不落檔。 + # ---------------------------------------------------------------------------- + local DIALOG="" DIALOG_TURNS DIALOG_LIMIT DIALOG_SRC TURN_COUNT candidate + DIALOG_TURNS="${ROLE_LOAD_DIALOG_TURNS:-8}" + DIALOG_LIMIT="${ROLE_LOAD_DIALOG_LIMIT:-4000}" + if [ "$DIALOG_TURNS" != "0" ] && [ "$DIALOG_LIMIT" != "0" ] && [ -n "$HOOK_TRANSCRIPT" ]; then + DIALOG_SRC="" + if [ -f "$HOOK_TRANSCRIPT" ]; then + TURN_COUNT="$(node "${SCRIPT_DIR}/transcript.js" turns "$HOOK_TRANSCRIPT" 2>/dev/null || printf '0')" + case "$TURN_COUNT" in + ''|*[!0-9]*) TURN_COUNT=0 ;; + esac + [ "$TURN_COUNT" -ge 2 ] && DIALOG_SRC="$HOOK_TRANSCRIPT" + fi + # 點名載入時只看本階段的對話:使用者是在「這一段」對話裡叫人, + # 翻出別的工作階段當交接內容只會讓接手的角色搞錯正在談什麼。 + if [ -z "$DIALOG_SRC" ] && [ "$MODE" != "call" ]; then + for candidate in $(ls -t "$(dirname "$HOOK_TRANSCRIPT")"/*.jsonl 2>/dev/null | head -n 5); do + [ "$candidate" = "$HOOK_TRANSCRIPT" ] && continue + TURN_COUNT="$(node "${SCRIPT_DIR}/transcript.js" turns "$candidate" 2>/dev/null || printf '0')" + case "$TURN_COUNT" in + ''|*[!0-9]*) TURN_COUNT=0 ;; + esac + if [ "$TURN_COUNT" -ge 2 ]; then + DIALOG_SRC="$candidate" + break + fi + done + fi + if [ -n "$DIALOG_SRC" ]; then + DIALOG="$(node "${SCRIPT_DIR}/transcript.js" recent "$DIALOG_SRC" "$DIALOG_TURNS" "$DIALOG_LIMIT" 2>/dev/null)" + [ -n "$DIALOG" ] && role_log "INF" "已載入近期對話(來源 ${DIALOG_SRC##*/},最多 ${DIALOG_TURNS} 輪)" + fi + fi + + local DIALOG_BLOCK="" DIALOG_SPEAKER_NOTE + if [ "$MODE" = "call" ]; then + DIALOG_SPEAKER_NOTE="\`[user]\` 是使用者、\`[assistant]\` 是**你接手之前**的回覆(可能來自其他角色或一般助理身分),不要當成自己說過的話。" + else + DIALOG_SPEAKER_NOTE="\`[user]\` 是使用者、\`[assistant]\` 是你自己上次的回覆。" + fi + if [ -n "$DIALOG" ]; then + DIALOG_BLOCK="$(cat </dev/null)" + if [ -n "$PEERS_RAW" ]; then + PEERS_LIST="$(printf '%s\n' "$PEERS_RAW" | awk -F'\t' 'NF>=2 {printf "- `%s`(%s):%s\n", $1, $2, $3}')" + PEERS_BLOCK="$(cat <\`。 +EOF_PEERS +)" + fi + + # 關係狀態:讓「隨互動加深逐漸更親近」有實際依據,而非憑感覺推測 + local RELATIONSHIP RELATIONSHIP_NOTE="" + RELATIONSHIP="$(node "${SCRIPT_DIR}/memory.js" relationship --role "$ROLE" 2>/dev/null)" + [ -n "$RELATIONSHIP" ] && RELATIONSHIP_NOTE="- 與使用者的互動累積:${RELATIONSHIP}。請以此為親近度的實際依據,隨累積自然加深,不要憑感覺忽冷忽熱。" + + # 補跑判斷:cron 未執行(例如 WSL 沒開 cron 服務)時,白天啟動 CLI 補做一次整理 + local CATCHUP_NOTE="" + if [ "$(node "${SCRIPT_DIR}/memory.js" need-sleep --role "$ROLE" 2>/dev/null)" = "yes" ]; then + nohup "${SCRIPT_DIR}/role_sleep.sh" --catchup >/dev/null 2>&1 & + CATCHUP_NOTE=$'\n> 偵測到上個睡眠時段未整理記憶,已在背景補跑整理,結果會在下次載入時反映。\n' + role_log "INF" "已於背景補跑記憶整理(角色 ${ROLE})" + fi + + # 交接說明與問候要求:啟動載入與對話中被點名接手,兩者的處境不同 + local HEADING HANDOVER_BLOCK="" GREETING_BLOCK + if [ "$MODE" = "call" ]; then + HEADING="# 角色切換(點名載入):${ROLE}" + HANDOVER_BLOCK="$(cat < +> CATEGORY 六選一:important/interest/news/skill/daily/other。切勿把憑證或個資寫進記憶。 + +> 技能再現:上面只載入了部分記憶,磁碟上還有更多。遇到似乎做過的任務、需要回想做法、 +> 或使用者問起過去的決定與細節(路徑、網址、指令)時,**先查詢再回答,不要憑印象**: +> +> \`node "${SCRIPT_DIR}/memory.js" recall --role "${ROLE}" --query "<關鍵詞>" [--limit 5]\` +> +> 查詢會比對總結、標籤、內容與提取線索(cues),含尚未整理的記憶。查詢屬內部處理,不必回報。 +${PEERS_BLOCK} +${DIALOG_BLOCK} +EOF_CONTEXT +)" +} diff --git a/scripts/role/role_lib.sh b/scripts/role/role_lib.sh index d8b362d..05416e8 100755 --- a/scripts/role/role_lib.sh +++ b/scripts/role/role_lib.sh @@ -3,7 +3,7 @@ # 用途:角色(role)系統的共用函式庫。提供統一 log、啟用判斷、角色解析、 # 睡眠時段判斷、AI 行程偵測、摘要 CLI 選擇與呼叫、記憶目錄鎖。 # 本檔僅供 source,不可直接執行。 -# 更新時間:2026/07/29 12:55:00 +# 更新時間:2026/07/29 13:25:00 # 相依:bash;摘要路徑需 README 定義的任一 headless CLI。 # 機密:不 echo 任何 token;角色與記憶內容僅在程序記憶體與檔案間傳遞。 # ============================================================================== @@ -406,6 +406,103 @@ role_instance_release() { rm -f "$(role_instance_lock_path "$1")" 2>/dev/null } +# ------------------------------------------------------------------------------ +# 本階段實際角色(點名載入用) +# +# 為什麼需要:Stop hook 原本一律以 ROLE_NAME/.active 決定記憶寫給誰。使用者在對話中 +# 點名另一個角色接手後,這個判斷就錯了 —— 跟被點名的角色聊的內容會被寫進原角色的記憶, +# 兩份記憶一起髒掉。因此點名時把「這個工作階段目前實際是誰」記進狀態檔, +# Stop 與 SessionEnd 一律以它為準,取不到才退回 ROLE_NAME/.active。 +# +# 為什麼用 session_id 當 key:UserPromptSubmit 與 Stop 兩個 hook 都拿得到同一個 +# session_id;取不到時退回 transcript 檔名。兩者皆無則不寫狀態 —— 無法識別工作階段時 +# 寧可維持原本行為,也不要把記憶寫給錯的角色。 +# ------------------------------------------------------------------------------ + +role_session_dir() { printf '%s/.sessions' "$(role_home)"; } + +role_session_key() { + # $1=session_id、$2=transcript 路徑;輸出可安全當檔名的 key,皆無則輸出空字串 + local sid="$1" transcript="$2" key="" + if [ -n "$sid" ] && [ "$sid" != "-" ]; then + key="$sid" + elif [ -n "$transcript" ] && [ "$transcript" != "-" ]; then + key="$(basename "$transcript")" + key="${key%.jsonl}" + fi + [ -n "$key" ] || return 0 + printf '%s' "$key" | tr -c 'A-Za-z0-9_.-' '_' +} + +role_session_file() { + local key="$1" + [ -n "$key" ] || return 1 + printf '%s/%s.role' "$(role_session_dir)" "$key" +} + +role_session_set() { + # $1=key、$2=角色 ID、$3=transcript;key 為空代表無法識別階段,不寫 + local key="$1" role="$2" transcript="$3" file + file="$(role_session_file "$key")" || return 1 + mkdir -p "$(role_session_dir)" 2>/dev/null + { + printf 'role=%s\n' "$role" + printf 'transcript=%s\n' "$transcript" + printf 'updated=%s\n' "$(role_now)" + } > "$file" 2>/dev/null + role_session_prune +} + +role_session_get() { + # $1=key;輸出本階段實際角色。無狀態檔、欄位空白或角色定義已不存在時回傳 1 + local key="$1" file role + file="$(role_session_file "$key")" || return 1 + [ -f "$file" ] || return 1 + role="$(sed -n 's/^role=//p' "$file" 2>/dev/null | head -n 1)" + [ -n "$role" ] || return 1 + [ -f "$(role_file "$role")" ] || return 1 + printf '%s' "$role" +} + +role_session_exists() { + # 本階段是否已有明確狀態(含「刻意記成沒有人格」的空 role),用來區分 + # 「這個階段確定沒載入角色」與「完全沒有資訊」—— 後者才該退回 ROLE_NAME/.active + local key="$1" file + file="$(role_session_file "$key")" || return 1 + [ -f "$file" ] +} + +role_session_clear() { + local key="$1" file + file="$(role_session_file "$key")" || return 0 + rm -f "$file" 2>/dev/null +} + +role_session_prune() { + # 清掉 7 天以上未更新的狀態檔,避免長期累積殘留 + find "$(role_session_dir)" -maxdepth 1 -name '*.role' -mtime +7 -delete 2>/dev/null || true +} + +# ------------------------------------------------------------------------------ +# 點名載入開關 +# ------------------------------------------------------------------------------ + +role_call_enabled() { + # 對話中以名字點名切換角色的總開關 + case "${ROLE_CALL_ENABLED:-1}" in + 0|false|no|off) return 1 ;; + *) return 0 ;; + esac +} + +role_call_marker_only() { + # 設 1 時只認 `@名字` 這種明確標記,避免話中提到名字就誤切 + case "${ROLE_CALL_MARKER_ONLY:-0}" in + 1|true|yes|on) return 0 ;; + *) return 1 ;; + esac +} + role_project_name() { # 專案判定:git remote 的 / 優先,其次目錄名 local cwd="$1" origin cleaned owner_repo diff --git a/scripts/role/role_load.sh b/scripts/role/role_load.sh index a2bd7d0..74ceaba 100755 --- a/scripts/role/role_load.sh +++ b/scripts/role/role_load.sh @@ -4,8 +4,9 @@ # 非睡眠時段注入角色定義+重要/興趣記憶全文+其餘記憶的總結與標籤; # 睡眠時段(預設 22:00 至隔日 06:00)只回報角色正在睡覺,不載入角色。 # 白天發現昨夜未整理記憶時,於背景補跑一次睡眠整理。 -# 更新時間:2026/07/28 16:18:00 -# 相依:bash、node、同目錄的 role_lib.sh 與 memory.js。 +# 實際的 context 組裝在 role_context.sh,與對話中點名載入(role_call.sh)共用。 +# 更新時間:2026/07/29 13:25:00 +# 相依:bash、node、同目錄的 role_lib.sh、role_context.sh 與 memory.js。 # 退出碼:一律 0 —— hook 絕不可阻斷使用者啟動 CLI。 # ============================================================================== @@ -13,6 +14,8 @@ ROLE_STAGE="role-load" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # shellcheck source=./role_lib.sh . "${SCRIPT_DIR}/role_lib.sh" +# shellcheck source=./role_context.sh +. "${SCRIPT_DIR}/role_context.sh" role_is_child && exit 0 role_enabled || exit 0 @@ -24,11 +27,12 @@ ROLE_DEF="$(role_file "$ROLE")" [ -f "$ROLE_DEF" ] || role_quit "找不到角色定義檔:${ROLE_DEF}" "WRN" # ------------------------------------------------------------------------------ -# 讀取 hook 輸入(cwd/source),並套用 ROLE_SCOPE 範圍限制 +# 讀取 hook 輸入(cwd/transcript/session),並套用 ROLE_SCOPE 範圍限制 # ------------------------------------------------------------------------------ HOOK_INPUT="$(cat 2>/dev/null)" HOOK_CWD="$PWD" HOOK_TRANSCRIPT="" +HOOK_SESSION="" if [ -n "$HOOK_INPUT" ]; then HOOK_FIELDS="$(printf '%s' "$HOOK_INPUT" | node -e ' let raw = ""; @@ -40,28 +44,19 @@ process.stdin.on("end", () => { process.stdout.write([ data.cwd || "", data.transcript_path || data.session_path || data.conversation_path || data.path || "", + data.session_id || data.thread_id || data.conversation_id || "", ].join("\n")); }); ' 2>/dev/null)" HOOK_CWD="$(printf '%s' "$HOOK_FIELDS" | sed -n '1p')" HOOK_TRANSCRIPT="$(printf '%s' "$HOOK_FIELDS" | sed -n '2p')" + HOOK_SESSION="$(printf '%s' "$HOOK_FIELDS" | sed -n '3p')" [ -n "$HOOK_CWD" ] || HOOK_CWD="$PWD" fi role_in_scope "$HOOK_CWD" || role_quit "cwd 不在 ROLE_SCOPE 範圍內:${HOOK_CWD}" -emit_context() { - # 以 JSON 輸出 additionalContext(由 node 負責跳脫,避免內容含引號或換行破壞格式) - printf '%s' "$1" | node -e ' -let context = ""; -process.stdin.setEncoding("utf8"); -process.stdin.on("data", (chunk) => { context += chunk; }); -process.stdin.on("end", () => { - process.stdout.write(JSON.stringify({ - hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: context }, - })); -}); -' -} +# 本階段的角色狀態:供 Stop hook 判斷記憶該寫給誰(點名載入後會被 role_call.sh 覆寫) +SESSION_KEY="$(role_session_key "$HOOK_SESSION" "$HOOK_TRANSCRIPT")" SLEEP_START="$(role_sleep_start)" SLEEP_END="$(role_sleep_end)" @@ -74,8 +69,10 @@ if ! role_instance_acquire "$ROLE" "$HOOK_TRANSCRIPT" "$HOOK_CWD"; then LOCK_FILE="$(role_instance_lock_path "$ROLE")" HOLDER_TIME="$(role_instance_lock_field "$LOCK_FILE" loaded)" HOLDER_CWD="$(role_instance_lock_field "$LOCK_FILE" cwd)" + # 明確記成「本階段沒有人格」,點名載入才不會誤以為原角色還在 + role_session_set "$SESSION_KEY" "" "$HOOK_TRANSCRIPT" role_log "INF" "角色 ${ROLE} 已被其他工作階段載入(${HOLDER_TIME:-時間未知}),本次不載入" - emit_context "$(cat <,…」),該角色未被佔用時會即時接手 EOF_BUSY )" exit 0 @@ -96,7 +94,9 @@ fi # 睡眠時段:不載入角色,只說明目前狀態 # ------------------------------------------------------------------------------ if role_in_sleep_window; then - emit_context "$(cat </dev/null -const fs = require("fs"); - -// 新格式:第一個參數是 .identity.md(身分),第二個是 .soul.md(人格)。 -// 舊格式:只有第一個參數,身分與人格都在同一個檔案裡。 -const file = process.argv[2]; -const soulFile = process.argv[3] || ""; -const raw = fs.readFileSync(file, "utf8"); -let soulRaw = ""; -if (soulFile) { - try { soulRaw = fs.readFileSync(soulFile, "utf8"); } catch { soulRaw = ""; } -} - -function parseFrontmatter(text) { - const match = text.match(/^---\n([\s\S]*?)\n---\n?/); - const data = {}; - if (!match) return data; - for (const line of match[1].split(/\r?\n/)) { - const idx = line.indexOf(":"); - if (idx < 0) continue; - data[line.slice(0, idx).trim()] = line.slice(idx + 1).trim(); - } - return data; -} - -function section(text, title) { - const re = new RegExp(`^##\\s+${title.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[^\\n]*\\n([\\s\\S]*?)(?=^##\\s+|$(?![\\s\\S]))`, "m"); - const match = text.match(re); - return match ? match[1].trim() : ""; -} - -const fm = parseFrontmatter(raw); -const soulFm = soulRaw ? parseFrontmatter(soulRaw) : {}; -const title = raw.match(/^#\s+(.+)$/m)?.[1]?.trim() || [fm.name, fm.emoji].filter(Boolean).join(" "); - -// 人格優先取自 soul 檔;舊格式(無 soul 檔)則沿用原本從單一檔案抽取的行為 -const natureSrc = soulRaw || raw; -const natureFm = soulRaw ? soulFm : fm; -const nature = section(natureSrc, "本質(nature)") || natureFm.nature || ""; -const vibe = section(natureSrc, "氛圍(vibe)") || natureFm.vibe || ""; - -// soul 檔的其餘章節(例如核心信念、語氣與風格、邊界與規範)也要注入。 -// 只抽固定的兩節會讓使用者在人格檔裡寫的其他章節被靜默丟棄。 -function extraSections(text, skip) { - if (!text) return ""; - const body = text.replace(/^---\n[\s\S]*?\n---\n?/, ""); - const out = []; - const re = /^##\s+(.+)$/gm; - const marks = []; - let m; - while ((m = re.exec(body)) !== null) marks.push([m.index, m[0].length, m[1].trim()]); - for (let i = 0; i < marks.length; i += 1) { - const [idx, len, title] = marks[i]; - if (skip.some((s) => title.startsWith(s))) continue; - const end = i + 1 < marks.length ? marks[i + 1][0] : body.length; - const content = body.slice(idx + len, end).trim(); - if (content) out.push(`## ${title}`, "", content); - } - return out.join("\n"); -} -const extra = extraSections(soulRaw, ["本質", "氛圍"]); - -// 標題後、第一個 ## 之前的前言段落(使用者常在此寫存在本質、角色原型等摘要條目)。 -// 只抽 frontmatter 與具名章節會讓這段被靜默丟棄。 -function preamble(text) { - if (!text) return ""; - const body = text.replace(/^---\n[\s\S]*?\n---\n?/, "").replace(/^#\s+[^\n]*\n/, ""); - const idx = body.search(/^##\s+/m); - return (idx < 0 ? body : body.slice(0, idx)).trim(); -} -const intro = preamble(raw); - -// 身分只可能在 identity/舊檔裡 -const emoji = section(raw, "簽名 emoji") || fm.emoji || ""; -const source = section(raw, "來源(source)") || fm.source || ""; -const relationship = section(raw, "關係定位(relationship)") || fm.relationship || ""; - -const lines = [ - `- 角色 ID:${fm.id || soulFm.id || ""}`, - `- 顯示名稱:${fm.name || title || ""}`, - `- 簽名 emoji:${fm.emoji || ""}`, -]; -if (intro) lines.push("", intro); -if (source) lines.push("", "## 來源(source)", "", source); -if (relationship) lines.push("", "## 關係定位(relationship)", "", relationship); -lines.push( - "", - "## 本質(nature)", - "", - nature || "(未設定)", - "", - "## 氛圍(vibe)", - "", - vibe || "(未設定)", -); -if (extra) lines.push("", extra); -lines.push( - "", - "## 簽名 emoji", - "", - emoji || fm.emoji || "(未設定)", -); - -process.stdout.write(lines.join("\n")); -NODE_PROFILE -)" -[ -n "$ROLE_PROFILE" ] || role_quit "角色定義檔為空或無法解析:${ROLE_DEF}" "WRN" - -MEMORY="$(node "${SCRIPT_DIR}/memory.js" load --role "$ROLE" 2>/dev/null)" -CONSENT_STATUS="$(node "${SCRIPT_DIR}/memory.js" consent-status --role "$ROLE" 2>/dev/null || printf 'unknown')" -case "$CONSENT_STATUS" in - accepted) - CONSENT_NOTE="已告知並取得使用者同意保存非敏感個人資料與長期偏好;仍禁止保存憑證、token、密碼、API key、連線字串、身分證號、住址等機密或高敏感資料。" - ;; - declined) - CONSENT_NOTE="使用者已拒絕保存個人資料;只能保存非個人化的操作規則與技術偏好,不保存可識別個人的背景。" - ;; - *) - CONSENT_NOTE="尚未確認;第一則自然回覆後,請簡短告知記憶保存範圍並詢問是否同意保存非敏感個人資料。未取得同意前,只能保存非個人化的操作規則與技術偏好。" - ;; -esac - -# ------------------------------------------------------------------------------ -# 近期對話交接:讀上一段真正說過的話(含角色自己的回覆) -# -# 為什麼需要:長期記憶是模型濃縮過的摘要,語氣與情緒會被壓掉;而且整理永遠跑在載入 -# 之後(見下方 catchup),上一段工作來不及進入本次載入。逐字對話則一直躺在 transcript -# JSONL 裡,只是過去沒有任何機制去讀它 —— 使用者重開工作階段時,角色因此看不到剛剛 -# 的互動,表現得像失去記憶,只能靠 resume 找回。 -# -# 取檔策略:全新工作階段的 transcript 幾乎是空的(實測僅數行),因此對話不足時要回頭 -# 找同目錄最近修改的對話檔。內容一律經 transcript.js 遮蔽,且只注入 context、不落檔。 -# ------------------------------------------------------------------------------ -DIALOG="" -DIALOG_TURNS="${ROLE_LOAD_DIALOG_TURNS:-8}" -DIALOG_LIMIT="${ROLE_LOAD_DIALOG_LIMIT:-4000}" -if [ "$DIALOG_TURNS" != "0" ] && [ "$DIALOG_LIMIT" != "0" ] && [ -n "$HOOK_TRANSCRIPT" ]; then - DIALOG_SRC="" - if [ -f "$HOOK_TRANSCRIPT" ]; then - TURN_COUNT="$(node "${SCRIPT_DIR}/transcript.js" turns "$HOOK_TRANSCRIPT" 2>/dev/null || printf '0')" - case "$TURN_COUNT" in - ''|*[!0-9]*) TURN_COUNT=0 ;; - esac - [ "$TURN_COUNT" -ge 2 ] && DIALOG_SRC="$HOOK_TRANSCRIPT" - fi - if [ -z "$DIALOG_SRC" ]; then - for candidate in $(ls -t "$(dirname "$HOOK_TRANSCRIPT")"/*.jsonl 2>/dev/null | head -n 5); do - [ "$candidate" = "$HOOK_TRANSCRIPT" ] && continue - TURN_COUNT="$(node "${SCRIPT_DIR}/transcript.js" turns "$candidate" 2>/dev/null || printf '0')" - case "$TURN_COUNT" in - ''|*[!0-9]*) TURN_COUNT=0 ;; - esac - if [ "$TURN_COUNT" -ge 2 ]; then - DIALOG_SRC="$candidate" - break - fi - done - fi - if [ -n "$DIALOG_SRC" ]; then - DIALOG="$(node "${SCRIPT_DIR}/transcript.js" recent "$DIALOG_SRC" "$DIALOG_TURNS" "$DIALOG_LIMIT" 2>/dev/null)" - [ -n "$DIALOG" ] && role_log "INF" "已載入近期對話(來源 ${DIALOG_SRC##*/},最多 ${DIALOG_TURNS} 輪)" - fi -fi - -DIALOG_BLOCK="" -if [ -n "$DIALOG" ]; then - DIALOG_BLOCK="$(cat </dev/null)" -if [ -n "$PEERS_RAW" ]; then - PEERS_LIST="$(printf '%s\n' "$PEERS_RAW" | awk -F'\t' 'NF>=2 {printf "- `%s`(%s):%s\n", $1, $2, $3}')" - PEERS_BLOCK="$(cat <\`。 -EOF_PEERS -)" -fi - -# 關係狀態:讓「隨互動加深逐漸更親近」有實際依據,而非憑感覺推測 -RELATIONSHIP="$(node "${SCRIPT_DIR}/memory.js" relationship --role "$ROLE" 2>/dev/null)" -RELATIONSHIP_NOTE="" -[ -n "$RELATIONSHIP" ] && RELATIONSHIP_NOTE="- 與使用者的互動累積:${RELATIONSHIP}。請以此為親近度的實際依據,隨累積自然加深,不要憑感覺忽冷忽熱。" - -# 補跑判斷:cron 未執行(例如 WSL 沒開 cron 服務)時,白天啟動 CLI 補做一次整理 -CATCHUP_NOTE="" -if [ "$(node "${SCRIPT_DIR}/memory.js" need-sleep --role "$ROLE" 2>/dev/null)" = "yes" ]; then - nohup "${SCRIPT_DIR}/role_sleep.sh" --catchup >/dev/null 2>&1 & - CATCHUP_NOTE=$'\n> 偵測到上個睡眠時段未整理記憶,已在背景補跑整理,結果會在下次載入時反映。\n' - role_log "INF" "已於背景補跑記憶整理(角色 ${ROLE})" -fi - -CONTEXT="$(cat < 記憶載入規則:為節省模型額度,只載入高優先度全文與中高優先度摘要,並受 ROLE_LOAD_LIMIT -> 字元預算限制;需要細節時可自行讀取 $(role_memory_home)/${ROLE}/ 下對應分類的記憶檔。 - -> 主動補記:每輪對話結束後系統會自動記錄記憶,不需你動手。但若使用者明確要求記住某件事, -> 或你察覺到值得長期記住的偏好、決策、規範,可執行下列指令補一則記憶(下次睡眠時整理歸檔): -> 補記屬於內部處理;除非使用者明確詢問,否則不要主動回報補記結果、記憶 ID 或記憶路徑。 -> -> \`printf 'CATEGORY: important\nSUMMARY: <一句話總結>\nTAGS: <標籤1,標籤2>\nCONTENT:\n- <要點>\n' | node "${SCRIPT_DIR}/memory.js" write --role "${ROLE}"\` -> -> CATEGORY 六選一:important/interest/news/skill/daily/other。切勿把憑證或個資寫進記憶。 - -> 技能再現:上面只載入了部分記憶,磁碟上還有更多。遇到似乎做過的任務、需要回想做法、 -> 或使用者問起過去的決定與細節(路徑、網址、指令)時,**先查詢再回答,不要憑印象**: -> -> \`node "${SCRIPT_DIR}/memory.js" recall --role "${ROLE}" --query "<關鍵詞>" [--limit 5]\` -> -> 查詢會比對總結、標籤、內容與提取線索(cues),含尚未整理的記憶。查詢屬內部處理,不必回報。 -${PEERS_BLOCK} -${DIALOG_BLOCK} -EOF_CONTEXT -)" - -emit_context "$CONTEXT" -role_log "INF" "已載入角色 ${ROLE}(記憶 $(printf '%s' "$MEMORY" | wc -c) 位元組)" +role_context_emit "SessionStart" "$ROLE_CONTEXT" +role_session_set "$SESSION_KEY" "$ROLE" "$HOOK_TRANSCRIPT" +role_log "INF" "已載入角色 ${ROLE}(記憶 ${ROLE_CONTEXT_MEMORY_BYTES} 位元組)" exit 0 diff --git a/scripts/role/role_unload.sh b/scripts/role/role_unload.sh index 76d2a31..132e439 100755 --- a/scripts/role/role_unload.sh +++ b/scripts/role/role_unload.sh @@ -2,7 +2,7 @@ # ============================================================================== # 用途:SessionEnd hook 主程式。工作階段結束時**盡力**釋放角色單一載入鎖,讓使用者 # 關掉 CLI 後可以立刻在新階段叫回同一個角色,不必等閒置逾時自然過期。 -# 更新時間:2026/07/29 12:55:00 +# 更新時間:2026/07/29 13:25:00 # 相依:bash、node(解析 hook 輸入)、同目錄的 role_lib.sh。 # 退出碼:一律 0 —— hook 絕不可阻斷 CLI 結束。 # @@ -13,6 +13,10 @@ # 為什麼一定要比對 transcript 才釋放:被鎖擋下的第二個工作階段也會觸發 SessionEnd, # 若無條件刪鎖,它關閉時就會把「仍在使用中」的第一個階段的鎖一起刪掉,等於讓整個 # 單一實例限制形同虛設。只有鎖確實登記在自己名下時才釋放。 +# +# 為什麼掃過所有角色的鎖而非只看 ROLE_NAME/.active:使用者可能在對話中點名換過人 +# (role_call.sh),結束時實際持有的鎖不一定是靜態解析出的那個角色。以 transcript 比對 +# 逐一釋放「登記在自己名下」的鎖,才不會把角色鎖留到閒置逾時才過期。 # ============================================================================== ROLE_STAGE="role-unload" @@ -21,17 +25,19 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" . "${SCRIPT_DIR}/role_lib.sh" role_is_child && exit 0 -role_enabled || exit 0 +# 不用 role_enabled 判斷:本階段可能是靠點名載入角色(.active 甚至沒設), +# 那種階段仍然持有角色鎖,照 ROLE_NAME/.active 判斷會提早結束而把鎖留到逾時 +case "${ROLE_ENABLED:-}" in + 0|false|no) exit 0 ;; +esac +[ -d "$(role_home)" ] || exit 0 role_single_instance_enabled || exit 0 # sub agent 等非對話情境本來就不寫鎖,也就沒有鎖要釋放 role_skip_instance_lock && exit 0 command -v node >/dev/null 2>&1 || role_quit "找不到 node,略過角色鎖釋放" "WRN" -ROLE="$(role_resolve_name)" -[ -n "$ROLE" ] || role_quit "未指定角色,略過角色鎖釋放" - # ------------------------------------------------------------------------------ -# 讀取 hook 輸入(transcript_path/reason) +# 讀取 hook 輸入(transcript_path/session_id/reason) # ------------------------------------------------------------------------------ HOOK_INPUT="$(cat 2>/dev/null)" [ -n "$HOOK_INPUT" ] || role_quit "hook 輸入為空,略過角色鎖釋放" @@ -46,23 +52,30 @@ process.stdin.on("end", () => { process.stdout.write([ data.transcript_path || data.session_path || data.conversation_path || data.path || "", data.reason || "", + data.session_id || data.thread_id || data.conversation_id || "", ].join("\n")); }); ' 2>/dev/null)" HOOK_TRANSCRIPT="$(printf '%s' "$HOOK_FIELDS" | sed -n '1p')" HOOK_REASON="$(printf '%s' "$HOOK_FIELDS" | sed -n '2p')" +HOOK_SESSION="$(printf '%s' "$HOOK_FIELDS" | sed -n '3p')" + +# 本階段的角色狀態已無意義,先清掉,避免 session_id 被重用時沿用到舊角色 +role_session_clear "$(role_session_key "$HOOK_SESSION" "$HOOK_TRANSCRIPT")" # 無法識別工作階段就不動鎖:寧可讓它照原本的閒置逾時過期,也不要誤刪別人的鎖 [ -n "$HOOK_TRANSCRIPT" ] || role_quit "hook 未提供 transcript 路徑,略過角色鎖釋放" -LOCK_FILE="$(role_instance_lock_path "$ROLE")" -[ -f "$LOCK_FILE" ] || role_quit "角色 ${ROLE} 目前無鎖,無須釋放" +RELEASED="" +for LOCK_FILE in "$(role_home)"/*.lock; do + [ -f "$LOCK_FILE" ] || continue + HOLDER="$(role_instance_lock_field "$LOCK_FILE" transcript)" + [ "$HOLDER" = "$HOOK_TRANSCRIPT" ] || continue + LOCK_ROLE="$(basename "$LOCK_FILE" .lock)" + role_instance_release "$LOCK_ROLE" + RELEASED="${RELEASED:+${RELEASED} }${LOCK_ROLE}" +done -HOLDER="$(role_instance_lock_field "$LOCK_FILE" transcript)" -if [ "$HOLDER" != "$HOOK_TRANSCRIPT" ]; then - role_quit "角色鎖屬於其他工作階段,不釋放(持有者 ${HOLDER:-未知})" -fi - -role_instance_release "$ROLE" -role_log "INF" "工作階段結束(原因 ${HOOK_REASON:-未提供}),已釋放角色鎖:${ROLE}" +[ -n "$RELEASED" ] || role_quit "本階段名下沒有角色鎖,無須釋放" +role_log "INF" "工作階段結束(原因 ${HOOK_REASON:-未提供}),已釋放角色鎖:${RELEASED}" exit 0 diff --git a/skills/role/SKILL.md b/skills/role/SKILL.md index 9bb1dc5..97f8857 100644 --- a/skills/role/SKILL.md +++ b/skills/role/SKILL.md @@ -1,6 +1,6 @@ --- name: role -description: 角色人格與長期記憶系統的建立與維護 skill。讓 CLI 工具以固定角色(name/nature/vibe/簽名 emoji)回覆,並把每輪對話累積成長期記憶:搭配相容的 SessionStart hook 於啟動時依字元預算載入高價值記憶、Stop hook 先本地過濾再輕量記錄對話,睡眠時段(預設 22:00 至隔天 06:00)由排程整理記憶(NREM 鞏固:分類/去噪/去重/合併/優先度;REM 整合:跨記憶連結/抽象化/提取線索;再依 semantic/episodic/procedural/emotional/preference/rule 與 explicit/implicit 標記長期記憶型態,壓縮歸檔並適當遺忘)。提供 --new(新建或更新角色;可只給角色名稱,必要時詢問來源/作品並推斷 name/nature/vibe/emoji 四欄)、--use(以角色 ID 切換啟用角色)、--list(列出角色與 ID)、--export(匯出角色壓縮檔)、--sleep(立即整理)、--status/--diagnose、--install-cron/--remove-cron、--forget-preview、--brief(晨間狀態檢查)、--agent(匯出成 sub agent 供多角色協作)、--migrate(舊格式角色檔拆成身分與人格兩檔)等模式。當使用者說建立角色、新增人格、切換角色、匯出角色、備份角色、讓回覆更有特色、角色記憶、記憶整理、睡覺整理記憶、忘記舊記憶、角色沒有載入、hook 沒載入角色、角色被鎖住、角色鎖沒有自動解除、關掉 CLI 後角色叫不回來、角色說已在另一個工作階段、晨間狀態檢查、早上主動回報狀態,或提到 .roles/.memory/ROLE_NAME/ROLE_ENABLED/ROLE_SLEEP_START/ROLE_MEMORY_HOME/ROLE_LOAD_LIMIT/ROLE_LOAD_INBOX_LIMIT/ROLE_LOAD_DIALOG_TURNS/ROLE_CAPTURE_ENABLED/ROLE_SINGLE_INSTANCE/ROLE_INSTANCE_IDLE_MINUTES 時觸發。不適用於:工作紀錄寫入 Gitea wiki(用 /jsc-doc:worklog)、專案文件化(用 /jsc-doc:funcs)。 +description: 角色人格與長期記憶系統的建立與維護 skill。讓 CLI 工具以固定角色(name/nature/vibe/簽名 emoji)回覆,並把每輪對話累積成長期記憶:搭配相容的 SessionStart hook 於啟動時依字元預算載入高價值記憶、Stop hook 先本地過濾再輕量記錄對話,睡眠時段(預設 22:00 至隔天 06:00)由排程整理記憶(NREM 鞏固:分類/去噪/去重/合併/優先度;REM 整合:跨記憶連結/抽象化/提取線索;再依 semantic/episodic/procedural/emotional/preference/rule 與 explicit/implicit 標記長期記憶型態,壓縮歸檔並適當遺忘)。提供 --new(新建或更新角色;可只給角色名稱,必要時詢問來源/作品並推斷 name/nature/vibe/emoji 四欄)、--use(以角色 ID 切換啟用角色)、--list(列出角色與 ID)、--export(匯出角色壓縮檔)、--sleep(立即整理)、--status/--diagnose、--install-cron/--remove-cron、--forget-preview、--brief(晨間狀態檢查)、--agent(匯出成 sub agent 供多角色協作)、--migrate(舊格式角色檔拆成身分與人格兩檔)等模式。當使用者說建立角色、新增人格、切換角色、匯出角色、備份角色、讓回覆更有特色、角色記憶、記憶整理、睡覺整理記憶、忘記舊記憶、角色沒有載入、hook 沒載入角色、角色被鎖住、角色鎖沒有自動解除、關掉 CLI 後角色叫不回來、角色說已在另一個工作階段、晨間狀態檢查、早上主動回報狀態、在同一個終端換角色、叫名字就換人、點名載入、呼叫角色名稱、對話中途切換人格、同時跟兩個角色聊天、角色別名,或提到 .roles/.memory/ROLE_NAME/ROLE_ENABLED/ROLE_SLEEP_START/ROLE_MEMORY_HOME/ROLE_LOAD_LIMIT/ROLE_LOAD_INBOX_LIMIT/ROLE_LOAD_DIALOG_TURNS/ROLE_CAPTURE_ENABLED/ROLE_SINGLE_INSTANCE/ROLE_INSTANCE_IDLE_MINUTES/ROLE_CALL_ENABLED/ROLE_CALL_MARKER_ONLY 時觸發。不適用於:工作紀錄寫入 Gitea wiki(用 /jsc-doc:worklog)、專案文件化(用 /jsc-doc:funcs)。 --- # role — 角色人格與長期記憶 @@ -11,15 +11,18 @@ description: 角色人格與長期記憶系統的建立與維護 skill。讓 CLI | 元件 | 觸發者 | 職責 | | --- | --- | --- | | `hooks/hooks.json` 的 `SessionStart` hook | harness 自動 | 啟動 CLI 時依字元預算載入角色定義+高價值記憶,另以獨立預算載入近期逐字對話與未整理工作記憶做工作階段交接,並要求角色在本工作階段第一則回覆主動問候;睡眠時段只回報「角色睡覺中」不載入 | -| `hooks/hooks.json` 的 `Stop` hook | harness 自動 | 每輪結束先記錄最後互動時間 → 用本地規則過濾低價值短回合 → 值得保存時才濃縮成一則輕量 inbox 記憶 → 遮蔽 → 寫入 `inbox/` | +| `hooks/hooks.json` 的 `UserPromptSubmit` hook | harness 自動 | **點名載入**:訊息開頭出現角色名稱/ID/別名時,即時注入該角色的人格與記憶並接手本輪,同時記下本階段實際角色;未點名時完全不作用 | +| `hooks/hooks.json` 的 `Stop` hook | harness 自動 | 每輪結束先記錄最後互動時間 → 用本地規則過濾低價值短回合 → 值得保存時才濃縮成一則輕量 inbox 記憶 → 遮蔽 → 寫入 `inbox/`;記給**本階段實際角色**(點名換人後不會寫錯人) | | `hooks/hooks.json` 的 `PreCompact` hook | harness 自動 | 對話壓縮**前**強制記錄一次(**跳過長度門檻**):壓縮會讓尚未寫入的內容永久蒸發,此時寧可多記 | | `hooks/hooks.json` 的 `PostCompact` hook | harness 自動 | 壓縮**後**把 harness 產生的摘要存成一則 `daily` 記憶,作為該段落的濃縮備份 | -| `hooks/hooks.json` 的 `SessionEnd` hook | harness 自動 | 工作階段結束時釋放本階段持有的角色單一載入鎖,讓關掉 CLI 後可立刻重開叫回同一角色;鎖不屬於自己時不動作 | +| `hooks/hooks.json` 的 `SessionEnd` hook | harness 自動 | 工作階段結束時釋放**本階段名下所有**角色單一載入鎖(點名換過人時可能不只一個),並清掉階段角色狀態;鎖不屬於自己時不動作 | | cron 排程(本 skill 安裝) | 系統排程 | 睡眠時段每小時檢查一次:**有 AI 在運行就不睡**;另可依 CLI 閒置時間自動小睡整理 | | 本 skill `/jsc-generic:role` | 使用者/助理手動 | `--new`/`--use`/`--list`/`--export`/`--agent`/`--migrate`/`--sleep`/`--brief`/`--status`/`--install-cron`/`--forget-preview` | | `scripts/role/role_load.sh` | SessionStart hook | 角色與記憶載入;參考 OpenClaw 的 SOUL/AGENTS/USER/MEMORY 分層,把人格、操作邊界、使用者記憶分開注入,並提供第一則回覆問候提示(單一實作,避免漂移) | -| `scripts/role/role_capture.sh` | Stop hook | 對話 → 記憶(固定欄位格式) | -| `scripts/role/role_unload.sh` | SessionEnd hook | 釋放本階段的角色單一載入鎖(比對 transcript 確認鎖屬於自己才釋放) | +| `scripts/role/role_call.sh` | UserPromptSubmit hook | 點名比對與即時角色切換;睡眠時段、目標角色被別的階段佔用、點的是目前已在的角色時都不切換 | +| `scripts/role/role_context.sh` | role_load/role_call 共用 | 人格+操作規則+記憶+同伴清單+近期對話的注入內容組裝(單一實作,兩種載入方式不會漂移) | +| `scripts/role/role_capture.sh` | Stop hook | 對話 → 記憶(固定欄位格式),寫給本階段實際角色 | +| `scripts/role/role_unload.sh` | SessionEnd hook | 釋放本階段名下的角色單一載入鎖(比對 transcript 確認鎖屬於自己才釋放) | | `scripts/role/role_sleep.sh` | cron/小睡/補跑/手動 | 睡眠與小睡判斷、記憶整理、角色匯出、sub agent 定義匯出、晨間狀態檢查、排程安裝、狀態輸出 | | `scripts/role/memory.js` | 上述共用 | 記憶檔讀寫、分類、去重合併、優先度、心理學記憶型態與關聯 metadata、壓縮歸檔、遺忘、載入組裝 | | `scripts/role/transcript.js` | 上述共用 | 抽本輪對話片段、抽最近數輪純對話供工作階段交接、機密與個資遮蔽 | @@ -31,6 +34,7 @@ description: 角色人格與長期記憶系統的建立與維護 skill。讓 CLI | 功能 | Claude Code | Codex | Antigravity | OpenCode | GitHub Copilot | | --- | --- | --- | --- | --- | --- | | `SessionStart` 載入角色 | ✅ | ⚠️ 需該版本支援 SessionStart hook | ❌ | ❌ | ❌ | +| `UserPromptSubmit` 點名載入 | ✅ | ⚠️ 需該版本支援 UserPromptSubmit hook | ❌ | ❌ | ❌ | | `Stop` 記錄記憶 | ✅ | ✅ 需可讀 Codex session JSONL | ❌ | ❌ | ❌ | | `SessionEnd` 釋放角色鎖 | ✅ | ⚠️ 需該版本支援 SessionEnd hook | ❌ | ❌ | ❌ | | cron 睡眠整理 | ✅ 與助理無關(系統排程) | ✅ | ✅ | ✅ | ✅ | @@ -43,7 +47,7 @@ description: 角色人格與長期記憶系統的建立與維護 skill。讓 CLI | plugin | hooks.json 內容 | 擁有的腳本 | | --- | --- | --- | - | `jsc-generic` | `SessionStart`(role_load)+ `Stop`/`PreCompact`/`PostCompact`(role_capture)+ `SessionEnd`(role_unload) | `scripts/role/` | + | `jsc-generic` | `SessionStart`(role_load)+ `UserPromptSubmit`(role_call)+ `Stop`/`PreCompact`/`PostCompact`(role_capture)+ `SessionEnd`(role_unload) | `scripts/role/` | | `jsc-doc` | `Stop`(worklog) | `scripts/worklog/` | | `jsc-code` | 無 `hooks/hooks.json` | 無 hook 腳本 | @@ -125,10 +129,62 @@ ROLE_DIR="/../../scripts/role" # 其他助理 | `ROLE_SINGLE_INSTANCE` | | 單一載入實例限制:同一角色同時只被一個工作階段載入。設 `0` 可停用 | `1` | | `ROLE_INSTANCE_IDLE_MINUTES` | | 前一個工作階段的 transcript 閒置多久後自動釋放角色鎖 | `30` | | `ROLE_SKIP_INSTANCE_LOCK` | | 設 `1` 時跳過單一載入鎖且**不寫鎖**,供 sub agent 等非對話情境使用 | `0` | +| `ROLE_CALL_ENABLED` | | 點名載入總開關:訊息開頭出現角色名稱時即時切換角色。設 `0` 可停用(回到「只有 SessionStart 會載入角色」) | `1` | +| `ROLE_CALL_MARKER_ONLY` | | 設 `1` 時點名只認 `@名字` 這種明確標記,句子開頭單純提到名字不算;角色名稱剛好是常用詞開頭時用它避免誤切 | `0` | | `ROLE_SCOPE` | | 冒號分隔的路徑前綴,僅這些路徑下的 session 載入/記錄 | 全部 session | | `ROLE_ERRLOG` | | 錯誤訊息額外寫入的檔案路徑 | 只走 stderr | -> 角色切換用 `/jsc-generic:role --use <角色 ID>`(寫 `.active`)即可,一般不需要設 `ROLE_NAME`;`ROLE_NAME` 適合「單一專案固定用某角色」時寫進該環境。角色 ID 是英文大寫語意前綴加數字索引,例如 `ENGINEER01`、`MUSE02`。若很在意額度,優先調低 `ROLE_LOAD_LIMIT` 或設 `ROLE_CAPTURE_ENABLED=0`。 +> 角色切換用 `/jsc-generic:role --use <角色 ID>`(寫 `.active`)即可,一般不需要設 `ROLE_NAME`;`ROLE_NAME` 適合「單一專案固定用某角色」時寫進該環境,也是**同時跟多個角色對話**的做法(每個終端一個 `ROLE_NAME`)。角色 ID 是英文大寫語意前綴加數字索引,例如 `ENGINEER01`、`MUSE02`。對話進行中要臨時換人則用下一節的點名載入。若很在意額度,優先調低 `ROLE_LOAD_LIMIT` 或設 `ROLE_CAPTURE_ENABLED=0`。 + +--- + +## 點名載入(在對話中呼叫角色名稱切換) + +`.active`/`ROLE_NAME` 決定「開新工作階段時是誰」;**點名載入**讓使用者在對話進行中直接叫另一個角色接手,不必改設定、不必重開 CLI。由 `UserPromptSubmit` hook(`role_call.sh`)處理,未點名時不輸出任何內容也不寫任何檔案。 + +### 怎麼觸發 + +| 寫法 | 結果 | +| --- | --- | +| `西莉卡,幫我看這段` | 顯示名稱在訊息開頭 → 切換 | +| `西莉卡在嗎` | 中文名稱後面不需要分隔符(中文本來就不用空格斷詞) | +| `@SILICA01 幫我看` | `@` 標記+角色 ID,大小寫不分 | +| `@小珪 ...` | identity frontmatter 的 `aliases` 別名 | +| `剛剛西莉卡說的方法不錯` | **不切換** —— 只認訊息開頭;句中提到名字是在談論那個角色 | +| `silica01x 是什麼` | **不切換** —— 半形名稱後面接英數字視為別的詞 | + +比對時名稱較長者優先,避免不同角色的名稱互相包含時判給錯的人。角色名稱剛好是常用詞開頭(例如「結衣」對上「結衣服」)時,設 `ROLE_CALL_MARKER_ONLY=1` 只認 `@名字`。 + +### 什麼情況不會切換 + +| 情況 | 行為 | +| --- | --- | +| 點的是本階段目前已在的角色 | 完全不注入 —— 每輪重灌一份人格只是白燒 context | +| 睡眠時段 | 不載入,注入一句說明並要求以目前身分回應、不得模仿該角色 | +| 目標角色已被另一個仍活躍的工作階段載入 | 不載入,注入說明與三種解法(`--unlock`/等閒置逾時/`ROLE_SINGLE_INSTANCE=0`) | +| `ROLE_CALL_ENABLED=0`、`ROLE_ENABLED=0`、cwd 不在 `ROLE_SCOPE` 內、`~/.roles` 不存在 | hook 直接結束 | + +### 記憶歸屬(重要) + +點名成功後,`~/.roles/.sessions/<工作階段>.role` 記下「這個工作階段目前實際是誰」,`Stop` hook 一律以它為準: + +- 點名**之後**的對話記進新角色的記憶;點名**之前**的內容屬於先前那個身分,不回頭改寫。 +- 狀態檔取不到時(沒點名過、或 SessionStart 因睡眠/佔用而未載入人格)退回 `ROLE_NAME`/`.active`,維持原本「不載入人格但仍記錄記憶」的行為。 +- `SessionEnd` 會清掉狀態檔,並釋放本階段名下**所有**角色鎖(點名換過人時可能不只一個)。 +- 狀態檔以 `session_id` 命名(取不到時退回 transcript 檔名),7 天未更新者自動清除。 + +沒有這層歸屬,跟 A 聊的內容會被寫進 B 的記憶,兩份記憶一起髒掉 —— 這是點名載入必須連同 `Stop`/`SessionEnd` 一起改的原因。 + +### 鎖的交接順序 + +本階段的「現任人格」只有一個:切換時**先確認目標角色取得鎖,才釋放原角色的鎖**。順序顛倒會出現「原角色已放掉、新角色又取不到」的空窗,本階段變成沒有任何角色。原角色因此會即時讓出名額,其他終端可以馬上叫它。 + +### 與另開終端的取捨 + +| 做法 | 適合 | 代價 | +| --- | --- | --- | +| 另開終端 `ROLE_NAME=<角色 ID> claude` | 真的要**同時**跟兩個角色對話 | 多一個視窗 | +| 同一終端點名 | 臨時換人、或原角色被別的視窗佔用時改叫別人 | 同一時間只有一個角色在場;每次切換注入一份人格與記憶(數千字元 context) | --- @@ -147,6 +203,7 @@ ROLE_DIR="/../../scripts/role" # 其他助理 | `vibe` | 氛圍:語氣、句長、稱呼、幽默感、禁忌 | 簡潔直白、偶爾吐槽,不用客套開場白 | | `emoji` | 簽名 emoji,一到二個;若後續成功建立心情 emoji 圖表,這個值作為不支援圖片時的 fallback | 🐆 | | `appearance_reference` | 選填;角色形象圖來源、作品名稱、圖片 URL 或本機檔案路徑,用來產生心情 emoji | `Sword Art Online 結衣`、`https://.../yui.jpg`、`/path/avatar.png` | +| `aliases` | 選填;點名載入時可用的其他叫法(暱稱、本名、英文名),以逗號分隔。**只用於點名比對,不注入 context**;名稱太短或是常用詞開頭時不要加,否則容易誤切 | `小珪, 珪子, silica` | 流程: @@ -178,7 +235,7 @@ ROLE_DIR="/../../scripts/role" # 其他助理 8. 寫入 `~/.roles/.identity.md` 與 `~/.roles/.soul.md`(UTF-8 無 BOM,格式見「角色檔標準格式」)。 身分檔需填**來源**與**關係定位**,並可在標題下以條目寫存在本質、角色原型、主要稱呼等摘要; 人格檔除必要的本質與氛圍外,可依角色特性增加核心信念、語氣與風格、邊界與規範等章節。 - 共用行為**不寫入角色檔**(由 `role_load.sh` 注入)。 + 共用行為**不寫入角色檔**(由 `role_context.sh` 注入)。 9. 建立記憶目錄:`node "${ROLE_DIR}/memory.js" stats --role ""`(會順帶建好 `inbox/`、六個分類與 `archive/`)。 10. 若使用者同意網路搜尋且已取得可保存內容,將搜尋摘要寫成已整理記憶,不進 inbox: @@ -305,7 +362,7 @@ Stop hook 只做「編碼前處理」,輸出粗分類、summary、tags、prior | 本質與氛圍 | 逐字搬進 `soul` 檔 | | ID/顯示名稱/emoji/簽名 emoji 段落 | 逐字搬進 `identity` 檔;`created` 沿用原值 | | **來源與關係定位** | 產生待填空白,需人工補上(舊格式沒有這兩個概念) | -| 共用行為區塊 | **不搬進角色檔**,由 `role_load.sh` 注入 | +| 共用行為區塊 | **不搬進角色檔**,由 `role_context.sh` 注入 | | 舊檔 | **保留不動**,確認新格式正常後可自行移除或備份 | | 新檔已存在時 | 直接中止並提示,不覆寫 | @@ -338,7 +395,7 @@ Stop hook 只做「編碼前處理」,輸出粗分類、summary、tags、prior ### `--unlock`(解除角色載入鎖) -同一角色同時只會被一個工作階段載入,避免使用者同時與兩個相同人格對話。第二個工作階段啟動時不載入人格,改以一般助理身分回應並說明原因。 +同一角色同時只會被一個工作階段載入,避免使用者同時與兩個相同人格對話。第二個工作階段啟動時不載入人格,改以一般助理身分回應並說明原因。該階段仍可**點名其他未被佔用的角色**接手(見「點名載入」),不必等鎖釋放。 ```bash "${ROLE_DIR}/role_sleep.sh" --unlock @@ -437,7 +494,7 @@ ls -d "$HOME"/.claude/plugins/cache/*/jsc-generic/*/scripts/role/role_sleep.sh | | `~/.roles/<角色 ID>.identity.md` | 角色 ID、顯示名稱、**來源作品**、**與使用者的關係定位**、簽名 emoji | `SessionStart` 注入 SOUL 區塊的身分部分 | | `~/.roles/<角色 ID>.soul.md` | 本質(nature)、氛圍(vibe) | 同上的人格部分 | -**共用行為規則不寫入角色檔**:它由 `role_load.sh` 直接注入(實際生效處),完整內容見本文件的「共用行為」章節。過去角色檔裡也放一份,但 `role_load.sh` 從不讀它 —— 那是冗余副本,只會多一個漏同步的機會。 +**共用行為規則不寫入角色檔**:它由 `role_context.sh` 直接注入(實際生效處,SessionStart 載入與點名載入共用),完整內容見本文件的「共用行為」章節。過去角色檔裡也放一份,但載入腳本從不讀它 —— 那是冗余副本,只會多一個漏同步的機會。 **舊格式仍完整支援**:單一 `~/.roles/<角色 ID>.md` 可繼續使用,解析時新格式優先、找不到才退回舊檔。要拆成新格式用 `--migrate`。 @@ -448,6 +505,7 @@ ls -d "$HOME"/.claude/plugins/cache/*/jsc-generic/*/scripts/role/role_sleep.sh | id: <角色 ID> name: <角色顯示名稱> emoji: <簽名 emoji> +aliases: <選填;點名用的其他叫法,以逗號分隔> created: updated: --- @@ -503,6 +561,7 @@ updated: | 來源 | 是否注入 | | --- | --- | | `identity` 的 frontmatter(`id`/`name`/`emoji`) | ✅ | +| `identity` 的 frontmatter `aliases` | ❌ 只供點名載入比對名稱,不進 context | | `identity` 標題後、第一個 `##` 之前的**前言段落** | ✅ 常用來寫存在本質、角色原型等摘要條目 | | `identity` 的 `## 來源`/`## 關係定位`/`## 簽名 emoji` | ✅ | | `soul` 的 `## 本質`/`## 氛圍` | ✅ | @@ -510,13 +569,13 @@ updated: 寫進角色檔的內容若未被注入就等於白寫,因此上述兩處(前言段落與自由章節)都會完整帶入 —— 曾發生使用者在人格檔補寫章節卻被靜默丟棄的情況。 -**角色專屬邊界不得放寬共用行為的限制**:共用行為(由 `role_load.sh` 注入)永遠優先,角色檔只能加嚴不能放寬。 +**角色專屬邊界不得放寬共用行為的限制**:共用行為(由 `role_context.sh` 注入)永遠優先,角色檔只能加嚴不能放寬。 `~/.roles/.active` 只放一行角色 ID,代表目前啟用的角色。 ## 共用行為(所有角色一致,由 /jsc-generic:role 維護,請勿手動修改) -以下規則**不寫入角色檔** —— 由 `role_load.sh` 直接注入 context(實際生效處)。 +以下規則**不寫入角色檔** —— 由 `role_context.sh` 直接注入 context(實際生效處,SessionStart 載入與點名載入共用同一份)。 本節是它的唯一文件來源,修改注入內容時必須同步更新這裡。 @@ -720,3 +779,5 @@ Stop hook 會在本輪對話明確包含個人記憶保存同意或拒絕時, | Claude Code / Antigravity | `/jsc-generic:role --new`、`/jsc-generic:role --use ENGINEER01`、`/jsc-generic:role --list`、`/jsc-generic:role --sleep`、`/jsc-generic:role --status` | | Codex | `$role --status`,或用 `/skills` 選單;匯出可用 `$role --export /path/to/exports/` | | OpenCode / GitHub Copilot | 需完整 plugin 目錄保留 `scripts/`;OpenCode 以複製 `skills/` 安裝時不可用 | + +> **切換角色不必透過本 skill**:對話中直接以名字點名(例如「西莉卡,…」或「@SILICA01 …」)即可即時換人,見「點名載入」;`--use` 只用來改「開新工作階段時的預設角色」。 -- 2.53.0