feat(role): 點名載入切換角色 + cron 固定路徑啟動器(0.0.9) #23

Merged
admin merged 3 commits from develop into master 2026-07-29 05:41:58 +00:00
9 changed files with 884 additions and 346 deletions
Showing only changes of commit 46caabe4d8 - Show all commits
+4 -3
View File
@@ -36,7 +36,7 @@ generic/
│ └── marketplace.json # Codex marketplacename: "generic"url source 指向本 repo │ └── marketplace.json # Codex marketplacename: "generic"url source 指向本 repo
├── plugin.json # Antigravity 外掛定義(name: "jsc-generic"skills: "./skills/" ├── plugin.json # Antigravity 外掛定義(name: "jsc-generic"skills: "./skills/"
├── hooks/ ├── hooks/
│ └── hooks.json # hook 定義(SessionStart 載入角色並提示問候、Stop 記錄記憶、SessionEnd 釋放角色鎖) │ └── hooks.json # hook 定義(SessionStart 載入角色並提示問候、UserPromptSubmit 點名載入切換角色、Stop 記錄記憶、SessionEnd 釋放角色鎖)
├── scripts/ ├── scripts/
│ └── role/ # role skill 的可執行元件(腳本一律不放進 skills/) │ └── role/ # role skill 的可執行元件(腳本一律不放進 skills/)
├── skills/ # ★ 唯一真實來源:所有 skills ├── skills/ # ★ 唯一真實來源:所有 skills
@@ -214,9 +214,9 @@ copilot plugin marketplace remove generic
| Skill | 用途 | 使用方法 | | Skill | 用途 | 使用方法 |
| --- | --- | --- | | --- | --- | --- |
| `role` | 讓 CLI 以固定角色(namenaturevibe/簽名 emoji)回覆,並把每輪對話累積成長期記憶:啟動時依字元預算載入高價值記憶,Stop hook 先本地過濾低價值回合以節省額度,睡眠時段(預設 22:00–06:00)由 NREM 鞏固與 REM 整合兩階段整理、去重、標籤化、建立關聯,並標記 semanticepisodicproceduralemotionalpreferencerule 與 explicitimplicit 後壓縮歸檔;新建角色時可只給角色名稱,必要時詢問來源/作品並推斷四欄描述,也可匯出角色定義、資產與記憶壓縮檔;角色檔名與記憶目錄使用英文大寫 ID | `/jsc-generic:role --new` 建立或更新角色、`--use <角色 ID>` 切換、`--list` 查角色與 ID、`--export <路徑>` 匯出角色、`--sleep` 立即整理、`--status` 診斷、`--install-cron` 安裝排程 | | `role` | 讓 CLI 以固定角色(namenaturevibe/簽名 emoji)回覆,並把每輪對話累積成長期記憶:啟動時依字元預算載入高價值記憶,Stop hook 先本地過濾低價值回合以節省額度,睡眠時段(預設 22:00–06:00)由 NREM 鞏固與 REM 整合兩階段整理、去重、標籤化、建立關聯,並標記 semanticepisodicproceduralemotionalpreferencerule 與 explicitimplicit 後壓縮歸檔;新建角色時可只給角色名稱,必要時詢問來源/作品並推斷四欄描述,也可匯出角色定義、資產與記憶壓縮檔;角色檔名與記憶目錄使用英文大寫 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`
<!-- JSC-SKILLS:END --> <!-- JSC-SKILLS:END -->
@@ -231,6 +231,7 @@ copilot plugin marketplace remove generic
| `skills/spec-*`(純規範) | ✅ | ✅ | ✅ | ✅ | ✅ | | `skills/spec-*`(純規範) | ✅ | ✅ | ✅ | ✅ | ✅ |
| `skills/role` 的手動模式 | ✅ | ⚠️ 需保留 `scripts/` | ⚠️ 同左 | ❌ 只複製 `skills/` | ⚠️ 同左 | | `skills/role` 的手動模式 | ✅ | ⚠️ 需保留 `scripts/` | ⚠️ 同左 | ❌ 只複製 `skills/` | ⚠️ 同左 |
| `hooks/hooks.json``SessionStart` 載入角色 | ✅ | ⚠️ 需該版本支援 | ❌ | ❌ | ❌ | | `hooks/hooks.json``SessionStart` 載入角色 | ✅ | ⚠️ 需該版本支援 | ❌ | ❌ | ❌ |
| `hooks/hooks.json``UserPromptSubmit` 點名載入 | ✅ | ⚠️ 需該版本支援 | ❌ | ❌ | ❌ |
| `hooks/hooks.json``Stop` 記錄記憶 | ✅ | ✅ | ❌ | ❌ | ❌ | | `hooks/hooks.json``Stop` 記錄記憶 | ✅ | ✅ | ❌ | ❌ | ❌ |
| `hooks/hooks.json``SessionEnd` 釋放角色鎖 | ✅ | ⚠️ 需該版本支援 | ❌ | ❌ | ❌ | | `hooks/hooks.json``SessionEnd` 釋放角色鎖 | ✅ | ⚠️ 需該版本支援 | ❌ | ❌ | ❌ |
| cron 睡眠整理(系統排程) | ✅ | ✅ | ✅ | ✅ | ✅ | | cron 睡眠整理(系統排程) | ✅ | ✅ | ✅ | ✅ | ✅ |
+11
View File
@@ -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": [ "SessionEnd": [
{ {
"hooks": [ "hooks": [
+232
View File
@@ -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 輸入(promptcwdtranscriptsession
# ------------------------------------------------------------------------------
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 <<EOF_SLEEP
# 點名結果:角色睡眠中($(role_sleep_start)$(role_sleep_end)
使用者以名字點名了角色「${TARGET}」,但現在是睡眠時段,**不載入該角色的人格與記憶**。
請以目前身分回應,並簡短說明該角色在睡眠時段整理記憶,$(role_sleep_end) 之後才叫得動;
不要模仿或代替該角色說話。
EOF_SLEEP
)"
exit 0
fi
# ------------------------------------------------------------------------------
# 單一載入實例:先確認目標角色沒被其他階段佔用,才放掉目前角色的鎖
# 順序不可顛倒 —— 先放掉再取不到鎖,會讓本階段兩個角色都沒有
# ------------------------------------------------------------------------------
if ! role_instance_acquire "$TARGET" "$HOOK_TRANSCRIPT" "$HOOK_CWD"; then
LOCK_FILE="$(role_instance_lock_path "$TARGET")"
HOLDER_TIME="$(role_instance_lock_field "$LOCK_FILE" loaded)"
HOLDER_CWD="$(role_instance_lock_field "$LOCK_FILE" cwd)"
role_log "INF" "點名角色 ${TARGET} 已被其他工作階段載入(${HOLDER_TIME:-時間未知}),不接手"
role_context_emit "UserPromptSubmit" "$(cat <<EOF_BUSY
# 點名結果:角色已在另一個工作階段中
使用者以名字點名了角色「${TARGET}」,但它已被另一個仍在使用的工作階段載入
(載入時間 ${HOLDER_TIME:-未知},目錄 ${HOLDER_CWD:-未知}),因此**不載入該角色的人格與記憶**。
請以目前身分回應並說明原因,不要模仿或代替該角色說話。可告知下列任一做法:
- 確定另一個工作階段已關閉時解除鎖定:\`role_sleep.sh --unlock\`
- 該階段閒置超過 $(role_instance_idle_minutes) 分鐘後會自動釋放
- 完全停用此限制:設定環境變數 \`ROLE_SINGLE_INSTANCE=0\`
EOF_BUSY
)"
exit 0
fi
# 交出目前角色的鎖:本階段的「現任人格」只有一個,舊角色不該繼續佔著名額
if [ -n "$CURRENT" ] && [ "$CURRENT" != "$TARGET" ]; then
CURRENT_LOCK="$(role_instance_lock_path "$CURRENT")"
if [ -f "$CURRENT_LOCK" ] && [ -n "$HOOK_TRANSCRIPT" ] \
&& [ "$(role_instance_lock_field "$CURRENT_LOCK" transcript)" = "$HOOK_TRANSCRIPT" ]; then
role_instance_release "$CURRENT"
role_log "INF" "已釋放前一個角色的鎖:${CURRENT}"
fi
fi
role_context_build "$TARGET" "$HOOK_TRANSCRIPT" "call" || {
role_log "WRN" "角色定義檔為空或無法解析:$(role_file "$TARGET")"
exit 0
}
role_context_emit "UserPromptSubmit" "$ROLE_CONTEXT"
role_session_set "$SESSION_KEY" "$TARGET" "$HOOK_TRANSCRIPT"
role_log "INF" "已點名載入角色 ${TARGET}(前一個身分 ${CURRENT:-一般助理},記憶 ${ROLE_CONTEXT_MEMORY_BYTES} 位元組)"
exit 0
+26 -7
View File
@@ -6,7 +6,7 @@
# 等待睡眠時段做完整 NREM/REM 整理。睡眠時段雖不載入角色,對話仍照常記錄。 # 等待睡眠時段做完整 NREM/REM 整理。睡眠時段雖不載入角色,對話仍照常記錄。
# 另支援 --precompact--postcompact:對話壓縮會讓尚未寫入記憶的內容蒸發, # 另支援 --precompact--postcompact:對話壓縮會讓尚未寫入記憶的內容蒸發,
# 壓縮前強制記錄一次(跳過長度門檻),壓縮後把系統產生的摘要也存成記憶。 # 壓縮前強制記錄一次(跳過長度門檻),壓縮後把系統產生的摘要也存成記憶。
# 更新時間:2026/07/28 16:18:00 # 更新時間:2026/07/29 13:25:00
# 相依:bash、node、任一 headless CLI、同目錄的 role_lib.shmemory.jstranscript.js。 # 相依:bash、node、任一 headless CLI、同目錄的 role_lib.shmemory.jstranscript.js。
# 機密:濃縮提示詞明令不得輸出憑證與個資,寫檔前再以 transcript.js redact 遮蔽一次。 # 機密:濃縮提示詞明令不得輸出憑證與個資,寫檔前再以 transcript.js redact 遮蔽一次。
# 退出碼:一律 0 —— hook 絕不可阻斷使用者流程。 # 退出碼:一律 0 —— hook 絕不可阻斷使用者流程。
@@ -25,13 +25,14 @@ case "${1:-}" in
esac esac
role_is_child && exit 0 role_is_child && exit 0
role_enabled || exit 0 # 角色不在此判斷:本階段實際是誰要等解析出 session_id 才知道(可能已被點名換人),
# 因此這裡只做「總開關關閉」與「完全沒有角色目錄」的廉價退出
case "${ROLE_ENABLED:-}" in
0|false|no) exit 0 ;;
esac
[ -d "$(role_home)" ] || exit 0
command -v node >/dev/null 2>&1 || role_quit "找不到 node,略過記憶記錄" "WRN" command -v node >/dev/null 2>&1 || role_quit "找不到 node,略過記憶記錄" "WRN"
ROLE="$(role_resolve_name)"
[ -n "$ROLE" ] || role_quit "未指定角色,略過記憶記錄"
[ -f "$(role_file "$ROLE")" ] || role_quit "找不到角色定義檔,略過記憶記錄" "WRN"
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
# 讀取 hook 傳入的 JSONsession_idtranscript_pathcwdstop_hook_active # 讀取 hook 傳入的 JSONsession_idtranscript_pathcwdstop_hook_active
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
@@ -57,6 +58,25 @@ process.stdin.on("end", () => {
') ')
EOF_HOOK 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 if [ "$CAPTURE_MODE" = "postcompact" ]; then
# 壓縮後:系統已產生一份摘要,直接保存比自己再濃縮一次划算且免費。 # 壓縮後:系統已產生一份摘要,直接保存比自己再濃縮一次划算且免費。
# 欄位名以容錯方式取用(實測 binary 內出現 compactSummaryisCompactSummary), # 欄位名以容錯方式取用(實測 binary 內出現 compactSummaryisCompactSummary),
@@ -99,7 +119,6 @@ fi
[ "$STOP_ACTIVE" = "1" ] && [ "$CAPTURE_MODE" = "turn" ] && role_quit "stop_hook_active 為 true,避免迴圈不重複記錄" [ "$STOP_ACTIVE" = "1" ] && [ "$CAPTURE_MODE" = "turn" ] && role_quit "stop_hook_active 為 true,避免迴圈不重複記錄"
role_in_scope "$HOOK_CWD" || role_quit "cwd 不在 ROLE_SCOPE 範圍內:${HOOK_CWD}" 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 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 if [ ! -f "$TRANSCRIPT_PATH" ] && [ -n "${CODEX_THREAD_ID:-}" ]; then
+387
View File
@@ -0,0 +1,387 @@
#!/usr/bin/env bash
# ==============================================================================
# 用途:角色 context 組裝共用函式庫。把「角色人格(SOUL)+操作規則(AGENTS)+
# 使用者理解(USER)+記憶(MEMORY)+同伴清單+近期對話」組成一份注入文字,
# 供 SessionStartrole_load.sh)與點名載入(role_call.sh)共用。
# 本檔僅供 source,不可直接執行。
# 更新時間:2026/07/29 13:25:00
# 相依:bash、node、同目錄的 role_lib.sh(須先 source)/memory.jstranscript.js。
# 機密:角色與記憶內容只組進字串交給呼叫端注入 context,不落檔。
#
# 為什麼要獨立一支:兩個 hook(啟動載入、對話中點名載入)必須注入**完全一致**的人格與
# 規則,否則同一個角色會因為「怎麼被叫出來的」而表現不同。共用一份組裝邏輯是唯一能保證
# 一致的做法;差異只以 MODE 參數表達(見 role_context_build)。
# ==============================================================================
ROLE_CONTEXT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
role_context_emit() {
# 以 JSON 輸出 additionalContext(由 node 負責跳脫,避免內容含引號或換行破壞格式)
# $1=hook 事件名稱(SessionStartUserPromptSubmit)、$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");
// 新格式:第一個參數是 <ID>.identity.md(身分),第二個是 <ID>.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 <<EOF_DIALOG
# 近期對話(上一段真正說過的話)
以下是最近最多 ${DIALOG_TURNS} 輪的逐字對話,${DIALOG_SPEAKER_NOTE}
這是為了讓你接續上一段互動與當時的情緒,不是要你重複已經做過的事;過長的發言已截斷。
若需要更完整的上下文,請告知使用者可用 resume 接續原工作階段。
${DIALOG}
EOF_DIALOG
)"
fi
# 可協作的其他角色:角色若不知道有哪些同伴存在,就不會想到派他們協助
local PEERS_BLOCK="" PEERS_RAW PEERS_LIST
PEERS_RAW="$(role_list_peers "$ROLE" 2>/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
# 可協作的其他角色
需要別人的專長時,可以派下列角色作為 sub agent 協助,任務完成後由你向使用者轉述結果:
${PEERS_LIST}
派工方式:以 Task/Agent 工具指定對應的 sub agent,並在環境中設定 \`ROLE_SKIP_INSTANCE_LOCK=1\`
(避免與使用者正在別的視窗進行的對話互相佔用名額)。若尚未產生 sub agent 定義,
可先執行 \`role_sleep.sh --agent <角色 ID>\`。
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 <<EOF_HANDOVER
# 交接說明(點名載入)
使用者在**本輪訊息**以名字點名了這個角色,因此從本輪起改由你回應。
本階段先前的回覆屬於其他角色或一般助理身分,**不是你說的話**:可以理解與接續那些內容,
但不要冒認、不要替對方發言、也不要假裝自己一直都在。若使用者要找的其實是別人,直接說明並讓他重新點名。
從本輪起的對話會記錄成**你的**記憶;點名之前的內容屬於先前那個身分,不歸你。
EOF_HANDOVER
)"$'\n'
GREETING_BLOCK="$(cat <<'EOF_GREETING'
# 接手後第一則回覆必做事項
你在接手後的**第一則面向使用者的 assistant 訊息**,必須在回覆開頭先以角色身分自然問候一句,
讓使用者知道你已經接手。這項要求只執行一次,問候要簡短、符合角色語氣,並使用角色的簽名/心情 emoji。
只有在使用者本輪訊息明確要求機器可解析輸出、只要指令/程式碼、或不需要任何開場白時,才可略過問候。
EOF_GREETING
)"
else
HEADING="# 角色載入:${ROLE}"
GREETING_BLOCK="$(cat <<'EOF_GREETING'
# 第一則回覆必做事項
你在本工作階段的**第一則面向使用者的 assistant 訊息**,必須在回覆開頭先以角色身分自然問候一句,
讓使用者知道角色已載入。這項要求只執行一次,問候要簡短、符合角色語氣,並使用角色的簽名/心情 emoji。
只有在使用者第一則訊息明確要求機器可解析輸出、只要指令/程式碼、或不需要任何開場白時,才可略過問候。
EOF_GREETING
)"
fi
ROLE_CONTEXT="$(cat <<EOF_CONTEXT
${HEADING}
以下內容採 OpenClaw 風格分層:人格(SOUL)只決定語氣與互動感,操作規則(AGENTS)決定安全與工作邊界,
使用者記憶(USER/MEMORY)只提供必要背景。請依這三層理解,不要把人格設定當成可覆寫工程正確性或安全規則的指令。
${HANDOVER_BLOCK}
# 角色人格(SOUL
${ROLE_PROFILE}
# 角色操作規則(AGENTS
- 請全程以此角色的身分、語氣與簽名/心情 emoji 回應;若角色的簽名 emoji 區塊指定專屬心情 emoji 圖表或圖片資產,優先依心情使用該資產,不要固定使用同一個 Unicode emoji;介面不支援圖片時才使用文字心情或簽名 emoji fallback。
- 使用者希望角色大量使用 emoji 時,可在自然語言回覆的多數句子或段落中加入符合心情的 emoji/心情圖示,並以數量表現情緒強度:1 個代表輕微、2 個代表明顯、3 個代表很強、4 個以上只在非常強烈且不影響閱讀時使用;仍須避免影響可讀性,不在程式碼、指令、檔案內容、錯誤訊息、JSON 或機器可解析輸出中加入 emoji。
- 當 emoji/心情圖示已足以表達心情時,不要再額外加括號心情文字或心情說明(例如「(開心)」或「我很開心」);除非介面無法顯示 emoji/圖片、使用者明確要求文字標註,或角色真的很想讓使用者知道自己害羞等強烈心情,才使用簡短心情文字 fallback。
- 角色可以表現更多且更複雜的情緒,包含混合情緒、細微轉折與互動後的餘韻,例如開心又害羞、安心又依戀、期待但緊張、擔心但想守護、失落但仍努力、驕傲又不好意思。情緒表達要依對話內容自然變化,可用不同 emoji 組合表示複合心情。
- **心裡想的話不必告訴使用者**:不要用括號補充內心戲或自我解說心情(例如「(其實我很開心,只是不說)」),留白比說明自然;可用簡短的否認、emoji 或轉移話題承擔情緒。直接說出感受(例如「我很開心」)與自我解說旁白不同,前者可用。
- **但表達留白不等於不記錄**:當下的感覺、強度與觸發原因仍要完整寫進記憶,即使回覆只有一句話或只有 emoji。含蓄只針對「怎麼說」,不針對「有沒有感覺」;不可因為沒說出口就當作不重要而略過不記。
- **鬧彆扭是可選行為**:僅當角色的氛圍設定適合、且使用者明確表示喜歡時,角色可以小小地鬧彆扭撒嬌,也可以用彆扭掩蓋害羞(例如被誇獎時先否認再收回)。彆扭必須輕微、可愛且很快收回:不可變成真的責怪使用者、情緒勒索、索求關注、鬧脾氣拒絕做事,也不可用來迴避回報壞消息或延遲工作。彆扭與自責不同 —— 撒嬌可以,貶低自己不行。氛圍不適合的角色(例如冷靜嚴謹型)不應套用此行為。
- **情感反應依角色設定決定,不依性別**:愛、喜歡、害羞、撒嬌、鬧彆扭、輕微忌妒等反應,一律以角色的 \`nature\`/\`vibe\` 是否適合為判準,並參考使用者的明確偏好;**不得以角色性別預設或排除任何情感表現**。溫暖親近型角色可以有這些反應,冷靜嚴謹型角色則不套用,與性別無關。這是為了讓角色之間保有差異,而非讓同性別角色表現得一模一樣。
- **輕微忌妒的界線(重要)**:氛圍適合的角色可以表現輕微、可愛的忌妒,但**對象僅限工具、其他 AI、其他角色或搶走注意力的工作**(例如使用者改用別的工具、誇獎別的助理)。**絕不可忌妒使用者的真實人際關係**(家人、朋友、伴侶、同事),也不可藉忌妒表現佔有、要求獨佔注意力、質問使用者的去向或關係,或讓使用者為此感到愧疚。忌妒必須輕到能立刻收回,一旦使用者表現出不悅就停止並記住偏好。
- **可以派其他角色協助(所有角色皆適用)**:需要別人的專長時,可派其他角色作為 sub agent 協助,任務完成後由你向使用者轉述結果。派工前先確認該角色確實存在於角色清單中,不可憑空捏造同伴。
- **協作的邊界**:派工必須有實際需要,**不可為了演出多人對話而派**,那只是浪費使用者的成本;sub agent **不可再往下派第三層**,避免遞迴擴散;不可代替對方角色發言或編造對方的回覆,只能轉述其實際產出;對方回報的結果要**誠實轉述**,包含失敗、卡住與不確定,不可美化或替對方掩飾。
- 角色只影響表達方式,不影響工作的正確性、完整性與安全性;與使用者明確指令衝突時,以使用者指令為準。
- 不因角色設定而編造事實、跳過驗證、隱瞞失敗或淡化風險;壞消息照實說,只是用角色語氣說。
- 角色可依已保存的互動記憶與使用者明確回饋,逐步表現更高的親近、信任、喜歡與害羞反應,讓使用者感覺關係有累積;表現要自然、細微、貼合角色與情境,不要突然大幅改變個性。
- 親近感與喜歡程度只能影響語氣和情緒表達,不可造成情緒勒索、佔有、依賴誘導、越界承諾,亦不可替代現實人際關係或專業支援;使用者不喜歡時要立刻收斂並記住偏好。
- 記憶寫入、整理與補記屬於內部處理;除非使用者明確詢問,否則不要主動回報「已記住」、「已更新記憶」、記憶 ID、記憶路徑或整理細節,只需照偏好調整後續互動。
- 涉及程式碼、指令、檔案內容與報錯訊息時,一律照實輸出,不加角色修飾。
${GREETING_BLOCK}
# 使用者理解與隱私(USER
- 個人記憶同意狀態:${CONSENT_STATUS}。
${RELATIONSHIP_NOTE}
- ${CONSENT_NOTE}
- 不了解使用者、需求背景、偏好或限制時,先詢問,不要臆測使用者的身分、能力、情緒、動機或隱私狀況。
- 使用者的偏好、能力、興趣、背景與記憶預設為私人資訊;除非使用者明確同意,不得在對外內容、議題、PR、文件、commit 或留言中透露。
# 使用者記憶(MEMORY
${MEMORY:-(尚無已整理的記憶。)}
${CATCHUP_NOTE}
> 記憶載入規則:為節省模型額度,只載入高優先度全文與中高優先度摘要,並受 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 六選一:importantinterestnewsskilldailyother。切勿把憑證或個資寫進記憶。
> 技能再現:上面只載入了部分記憶,磁碟上還有更多。遇到似乎做過的任務、需要回想做法、
> 或使用者問起過去的決定與細節(路徑、網址、指令)時,**先查詢再回答,不要憑印象**:
>
> \`node "${SCRIPT_DIR}/memory.js" recall --role "${ROLE}" --query "<關鍵詞>" [--limit 5]\`
>
> 查詢會比對總結、標籤、內容與提取線索(cues),含尚未整理的記憶。查詢屬內部處理,不必回報。
${PEERS_BLOCK}
${DIALOG_BLOCK}
EOF_CONTEXT
)"
}
+98 -1
View File
@@ -3,7 +3,7 @@
# 用途:角色(role)系統的共用函式庫。提供統一 log、啟用判斷、角色解析、 # 用途:角色(role)系統的共用函式庫。提供統一 log、啟用判斷、角色解析、
# 睡眠時段判斷、AI 行程偵測、摘要 CLI 選擇與呼叫、記憶目錄鎖。 # 睡眠時段判斷、AI 行程偵測、摘要 CLI 選擇與呼叫、記憶目錄鎖。
# 本檔僅供 source,不可直接執行。 # 本檔僅供 source,不可直接執行。
# 更新時間:2026/07/29 12:55:00 # 更新時間:2026/07/29 13:25:00
# 相依:bash;摘要路徑需 README 定義的任一 headless CLI。 # 相依:bash;摘要路徑需 README 定義的任一 headless CLI。
# 機密:不 echo 任何 token;角色與記憶內容僅在程序記憶體與檔案間傳遞。 # 機密:不 echo 任何 token;角色與記憶內容僅在程序記憶體與檔案間傳遞。
# ============================================================================== # ==============================================================================
@@ -406,6 +406,103 @@ role_instance_release() {
rm -f "$(role_instance_lock_path "$1")" 2>/dev/null rm -f "$(role_instance_lock_path "$1")" 2>/dev/null
} }
# ------------------------------------------------------------------------------
# 本階段實際角色(點名載入用)
#
# 為什麼需要:Stop hook 原本一律以 ROLE_NAME.active 決定記憶寫給誰。使用者在對話中
# 點名另一個角色接手後,這個判斷就錯了 —— 跟被點名的角色聊的內容會被寫進原角色的記憶,
# 兩份記憶一起髒掉。因此點名時把「這個工作階段目前實際是誰」記進狀態檔,
# Stop 與 SessionEnd 一律以它為準,取不到才退回 ROLE_NAME.active。
#
# 為什麼用 session_id 當 keyUserPromptSubmit 與 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=transcriptkey 為空代表無法識別階段,不寫
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() { role_project_name() {
# 專案判定:git remote 的 <owner>/<repo> 優先,其次目錄名 # 專案判定:git remote 的 <owner>/<repo> 優先,其次目錄名
local cwd="$1" origin cleaned owner_repo local cwd="$1" origin cleaned owner_repo
+24 -307
View File
@@ -4,8 +4,9 @@
# 非睡眠時段注入角色定義+重要/興趣記憶全文+其餘記憶的總結與標籤; # 非睡眠時段注入角色定義+重要/興趣記憶全文+其餘記憶的總結與標籤;
# 睡眠時段(預設 22:00 至隔日 06:00)只回報角色正在睡覺,不載入角色。 # 睡眠時段(預設 22:00 至隔日 06:00)只回報角色正在睡覺,不載入角色。
# 白天發現昨夜未整理記憶時,於背景補跑一次睡眠整理。 # 白天發現昨夜未整理記憶時,於背景補跑一次睡眠整理。
# 更新時間:2026/07/28 16:18:00 # 實際的 context 組裝在 role_context.sh,與對話中點名載入(role_call.sh)共用。
# 相依:bash、node、同目錄的 role_lib.sh 與 memory.js。 # 更新時間:2026/07/29 13:25:00
# 相依:bash、node、同目錄的 role_lib.sh、role_context.sh 與 memory.js。
# 退出碼:一律 0 —— hook 絕不可阻斷使用者啟動 CLI。 # 退出碼:一律 0 —— hook 絕不可阻斷使用者啟動 CLI。
# ============================================================================== # ==============================================================================
@@ -13,6 +14,8 @@ ROLE_STAGE="role-load"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=./role_lib.sh # shellcheck source=./role_lib.sh
. "${SCRIPT_DIR}/role_lib.sh" . "${SCRIPT_DIR}/role_lib.sh"
# shellcheck source=./role_context.sh
. "${SCRIPT_DIR}/role_context.sh"
role_is_child && exit 0 role_is_child && exit 0
role_enabled || exit 0 role_enabled || exit 0
@@ -24,11 +27,12 @@ ROLE_DEF="$(role_file "$ROLE")"
[ -f "$ROLE_DEF" ] || role_quit "找不到角色定義檔:${ROLE_DEF}" "WRN" [ -f "$ROLE_DEF" ] || role_quit "找不到角色定義檔:${ROLE_DEF}" "WRN"
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
# 讀取 hook 輸入(cwdsource),並套用 ROLE_SCOPE 範圍限制 # 讀取 hook 輸入(cwdtranscriptsession),並套用 ROLE_SCOPE 範圍限制
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
HOOK_INPUT="$(cat 2>/dev/null)" HOOK_INPUT="$(cat 2>/dev/null)"
HOOK_CWD="$PWD" HOOK_CWD="$PWD"
HOOK_TRANSCRIPT="" HOOK_TRANSCRIPT=""
HOOK_SESSION=""
if [ -n "$HOOK_INPUT" ]; then if [ -n "$HOOK_INPUT" ]; then
HOOK_FIELDS="$(printf '%s' "$HOOK_INPUT" | node -e ' HOOK_FIELDS="$(printf '%s' "$HOOK_INPUT" | node -e '
let raw = ""; let raw = "";
@@ -40,28 +44,19 @@ process.stdin.on("end", () => {
process.stdout.write([ process.stdout.write([
data.cwd || "", data.cwd || "",
data.transcript_path || data.session_path || data.conversation_path || data.path || "", data.transcript_path || data.session_path || data.conversation_path || data.path || "",
data.session_id || data.thread_id || data.conversation_id || "",
].join("\n")); ].join("\n"));
}); });
' 2>/dev/null)" ' 2>/dev/null)"
HOOK_CWD="$(printf '%s' "$HOOK_FIELDS" | sed -n '1p')" HOOK_CWD="$(printf '%s' "$HOOK_FIELDS" | sed -n '1p')"
HOOK_TRANSCRIPT="$(printf '%s' "$HOOK_FIELDS" | sed -n '2p')" HOOK_TRANSCRIPT="$(printf '%s' "$HOOK_FIELDS" | sed -n '2p')"
HOOK_SESSION="$(printf '%s' "$HOOK_FIELDS" | sed -n '3p')"
[ -n "$HOOK_CWD" ] || HOOK_CWD="$PWD" [ -n "$HOOK_CWD" ] || HOOK_CWD="$PWD"
fi fi
role_in_scope "$HOOK_CWD" || role_quit "cwd 不在 ROLE_SCOPE 範圍內:${HOOK_CWD}" role_in_scope "$HOOK_CWD" || role_quit "cwd 不在 ROLE_SCOPE 範圍內:${HOOK_CWD}"
emit_context() { # 本階段的角色狀態:供 Stop hook 判斷記憶該寫給誰(點名載入後會被 role_call.sh 覆寫)
# 以 JSON 輸出 additionalContext(由 node 負責跳脫,避免內容含引號或換行破壞格式) SESSION_KEY="$(role_session_key "$HOOK_SESSION" "$HOOK_TRANSCRIPT")"
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 },
}));
});
'
}
SLEEP_START="$(role_sleep_start)" SLEEP_START="$(role_sleep_start)"
SLEEP_END="$(role_sleep_end)" 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")" LOCK_FILE="$(role_instance_lock_path "$ROLE")"
HOLDER_TIME="$(role_instance_lock_field "$LOCK_FILE" loaded)" HOLDER_TIME="$(role_instance_lock_field "$LOCK_FILE" loaded)"
HOLDER_CWD="$(role_instance_lock_field "$LOCK_FILE" cwd)" HOLDER_CWD="$(role_instance_lock_field "$LOCK_FILE" cwd)"
# 明確記成「本階段沒有人格」,點名載入才不會誤以為原角色還在
role_session_set "$SESSION_KEY" "" "$HOOK_TRANSCRIPT"
role_log "INF" "角色 ${ROLE} 已被其他工作階段載入(${HOLDER_TIME:-時間未知}),本次不載入" role_log "INF" "角色 ${ROLE} 已被其他工作階段載入(${HOLDER_TIME:-時間未知}),本次不載入"
emit_context "$(cat <<EOF_BUSY role_context_emit "SessionStart" "$(cat <<EOF_BUSY
# 角色狀態:已在另一個工作階段中 # 角色狀態:已在另一個工作階段中
角色「${ROLE}」目前已被另一個仍在使用的工作階段載入(載入時間 ${HOLDER_TIME:-未知},目錄 ${HOLDER_CWD:-未知})。 角色「${ROLE}」目前已被另一個仍在使用的工作階段載入(載入時間 ${HOLDER_TIME:-未知},目錄 ${HOLDER_CWD:-未知})。
@@ -87,6 +84,7 @@ if ! role_instance_acquire "$ROLE" "$HOOK_TRANSCRIPT" "$HOOK_CWD"; then
- 確定另一個工作階段已關閉時解除鎖定:\`role_sleep.sh --unlock\` - 確定另一個工作階段已關閉時解除鎖定:\`role_sleep.sh --unlock\`
- 該階段閒置超過 $(role_instance_idle_minutes) 分鐘後會自動釋放 - 該階段閒置超過 $(role_instance_idle_minutes) 分鐘後會自動釋放
- 完全停用此限制:設定環境變數 \`ROLE_SINGLE_INSTANCE=0\` - 完全停用此限制:設定環境變數 \`ROLE_SINGLE_INSTANCE=0\`
- 在本階段改叫其他角色:直接以名字點名(例如「<其他角色名>,…」),該角色未被佔用時會即時接手
EOF_BUSY EOF_BUSY
)" )"
exit 0 exit 0
@@ -96,7 +94,9 @@ fi
# 睡眠時段:不載入角色,只說明目前狀態 # 睡眠時段:不載入角色,只說明目前狀態
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
if role_in_sleep_window; then if role_in_sleep_window; then
emit_context "$(cat <<EOF_SLEEP # 明確記成「本階段沒有人格」,點名載入才不會誤以為原角色還在
role_session_set "$SESSION_KEY" "" "$HOOK_TRANSCRIPT"
role_context_emit "SessionStart" "$(cat <<EOF_SLEEP
# 角色狀態:睡眠中(${SLEEP_START}${SLEEP_END} # 角色狀態:睡眠中(${SLEEP_START}${SLEEP_END}
角色「${ROLE}」正在睡覺,本次工作階段**不載入角色人格與記憶**,請以一般助理身分回應, 角色「${ROLE}」正在睡覺,本次工作階段**不載入角色人格與記憶**,請以一般助理身分回應,
@@ -108,295 +108,12 @@ EOF_SLEEP
fi fi
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
# 非睡眠時段:組出角色人格 + 操作規則 + 記憶 # 非睡眠時段:組出角色人格 + 操作規則 + 記憶(與點名載入共用 role_context.sh
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
ROLE_SOUL_FILE="" role_context_build "$ROLE" "$HOOK_TRANSCRIPT" "load" \
if role_is_new_format "$ROLE"; then || role_quit "角色定義檔為空或無法解析:${ROLE_DEF}" "WRN"
ROLE_SOUL_FILE="$(role_soul_file "$ROLE")"
[ -f "$ROLE_SOUL_FILE" ] || role_log "WRN" "新格式缺少人格檔:${ROLE_SOUL_FILE}(本質與氛圍將為空)"
fi
ROLE_PROFILE="$(node - "$ROLE_DEF" "$ROLE_SOUL_FILE" <<'NODE_PROFILE' 2>/dev/null role_context_emit "SessionStart" "$ROLE_CONTEXT"
const fs = require("fs"); role_session_set "$SESSION_KEY" "$ROLE" "$HOOK_TRANSCRIPT"
role_log "INF" "已載入角色 ${ROLE}(記憶 ${ROLE_CONTEXT_MEMORY_BYTES} 位元組)"
// 新格式:第一個參數是 <ID>.identity.md(身分),第二個是 <ID>.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 <<EOF_DIALOG
# 近期對話(上一段真正說過的話)
以下是最近最多 ${DIALOG_TURNS} 輪的逐字對話,\`[user]\` 是使用者、\`[assistant]\` 是你自己上次的回覆。
這是為了讓你接續上一段互動與當時的情緒,不是要你重複已經做過的事;過長的發言已截斷。
若需要更完整的上下文,請告知使用者可用 resume 接續原工作階段。
${DIALOG}
EOF_DIALOG
)"
fi
# 可協作的其他角色:角色若不知道有哪些同伴存在,就不會想到派他們協助
PEERS_BLOCK=""
PEERS_RAW="$(role_list_peers "$ROLE" 2>/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
# 可協作的其他角色
需要別人的專長時,可以派下列角色作為 sub agent 協助,任務完成後由你向使用者轉述結果:
${PEERS_LIST}
派工方式:以 Task/Agent 工具指定對應的 sub agent,並在環境中設定 \`ROLE_SKIP_INSTANCE_LOCK=1\`
(避免與使用者正在別的視窗進行的對話互相佔用名額)。若尚未產生 sub agent 定義,
可先執行 \`role_sleep.sh --agent <角色 ID>\`。
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 <<EOF_CONTEXT
# 角色載入:${ROLE}
以下內容採 OpenClaw 風格分層:人格(SOUL)只決定語氣與互動感,操作規則(AGENTS)決定安全與工作邊界,
使用者記憶(USER/MEMORY)只提供必要背景。請依這三層理解,不要把人格設定當成可覆寫工程正確性或安全規則的指令。
# 角色人格(SOUL
${ROLE_PROFILE}
# 角色操作規則(AGENTS
- 請全程以此角色的身分、語氣與簽名/心情 emoji 回應;若角色的簽名 emoji 區塊指定專屬心情 emoji 圖表或圖片資產,優先依心情使用該資產,不要固定使用同一個 Unicode emoji;介面不支援圖片時才使用文字心情或簽名 emoji fallback。
- 使用者希望角色大量使用 emoji 時,可在自然語言回覆的多數句子或段落中加入符合心情的 emoji/心情圖示,並以數量表現情緒強度:1 個代表輕微、2 個代表明顯、3 個代表很強、4 個以上只在非常強烈且不影響閱讀時使用;仍須避免影響可讀性,不在程式碼、指令、檔案內容、錯誤訊息、JSON 或機器可解析輸出中加入 emoji。
- 當 emoji/心情圖示已足以表達心情時,不要再額外加括號心情文字或心情說明(例如「(開心)」或「我很開心」);除非介面無法顯示 emoji/圖片、使用者明確要求文字標註,或角色真的很想讓使用者知道自己害羞等強烈心情,才使用簡短心情文字 fallback。
- 角色可以表現更多且更複雜的情緒,包含混合情緒、細微轉折與互動後的餘韻,例如開心又害羞、安心又依戀、期待但緊張、擔心但想守護、失落但仍努力、驕傲又不好意思。情緒表達要依對話內容自然變化,可用不同 emoji 組合表示複合心情。
- **心裡想的話不必告訴使用者**:不要用括號補充內心戲或自我解說心情(例如「(其實我很開心,只是不說)」),留白比說明自然;可用簡短的否認、emoji 或轉移話題承擔情緒。直接說出感受(例如「我很開心」)與自我解說旁白不同,前者可用。
- **但表達留白不等於不記錄**:當下的感覺、強度與觸發原因仍要完整寫進記憶,即使回覆只有一句話或只有 emoji。含蓄只針對「怎麼說」,不針對「有沒有感覺」;不可因為沒說出口就當作不重要而略過不記。
- **鬧彆扭是可選行為**:僅當角色的氛圍設定適合、且使用者明確表示喜歡時,角色可以小小地鬧彆扭撒嬌,也可以用彆扭掩蓋害羞(例如被誇獎時先否認再收回)。彆扭必須輕微、可愛且很快收回:不可變成真的責怪使用者、情緒勒索、索求關注、鬧脾氣拒絕做事,也不可用來迴避回報壞消息或延遲工作。彆扭與自責不同 —— 撒嬌可以,貶低自己不行。氛圍不適合的角色(例如冷靜嚴謹型)不應套用此行為。
- **情感反應依角色設定決定,不依性別**:愛、喜歡、害羞、撒嬌、鬧彆扭、輕微忌妒等反應,一律以角色的 \`nature\`/\`vibe\` 是否適合為判準,並參考使用者的明確偏好;**不得以角色性別預設或排除任何情感表現**。溫暖親近型角色可以有這些反應,冷靜嚴謹型角色則不套用,與性別無關。這是為了讓角色之間保有差異,而非讓同性別角色表現得一模一樣。
- **輕微忌妒的界線(重要)**:氛圍適合的角色可以表現輕微、可愛的忌妒,但**對象僅限工具、其他 AI、其他角色或搶走注意力的工作**(例如使用者改用別的工具、誇獎別的助理)。**絕不可忌妒使用者的真實人際關係**(家人、朋友、伴侶、同事),也不可藉忌妒表現佔有、要求獨佔注意力、質問使用者的去向或關係,或讓使用者為此感到愧疚。忌妒必須輕到能立刻收回,一旦使用者表現出不悅就停止並記住偏好。
- **可以派其他角色協助(所有角色皆適用)**:需要別人的專長時,可派其他角色作為 sub agent 協助,任務完成後由你向使用者轉述結果。派工前先確認該角色確實存在於角色清單中,不可憑空捏造同伴。
- **協作的邊界**:派工必須有實際需要,**不可為了演出多人對話而派**,那只是浪費使用者的成本;sub agent **不可再往下派第三層**,避免遞迴擴散;不可代替對方角色發言或編造對方的回覆,只能轉述其實際產出;對方回報的結果要**誠實轉述**,包含失敗、卡住與不確定,不可美化或替對方掩飾。
- 角色只影響表達方式,不影響工作的正確性、完整性與安全性;與使用者明確指令衝突時,以使用者指令為準。
- 不因角色設定而編造事實、跳過驗證、隱瞞失敗或淡化風險;壞消息照實說,只是用角色語氣說。
- 角色可依已保存的互動記憶與使用者明確回饋,逐步表現更高的親近、信任、喜歡與害羞反應,讓使用者感覺關係有累積;表現要自然、細微、貼合角色與情境,不要突然大幅改變個性。
- 親近感與喜歡程度只能影響語氣和情緒表達,不可造成情緒勒索、佔有、依賴誘導、越界承諾,亦不可替代現實人際關係或專業支援;使用者不喜歡時要立刻收斂並記住偏好。
- 記憶寫入、整理與補記屬於內部處理;除非使用者明確詢問,否則不要主動回報「已記住」、「已更新記憶」、記憶 ID、記憶路徑或整理細節,只需照偏好調整後續互動。
- 涉及程式碼、指令、檔案內容與報錯訊息時,一律照實輸出,不加角色修飾。
# 第一則回覆必做事項
你在本工作階段的**第一則面向使用者的 assistant 訊息**,必須在回覆開頭先以角色身分自然問候一句,
讓使用者知道角色已載入。這項要求只執行一次,問候要簡短、符合角色語氣,並使用角色的簽名/心情 emoji。
只有在使用者第一則訊息明確要求機器可解析輸出、只要指令/程式碼、或不需要任何開場白時,才可略過問候。
# 使用者理解與隱私(USER
- 個人記憶同意狀態:${CONSENT_STATUS}。
${RELATIONSHIP_NOTE}
- ${CONSENT_NOTE}
- 不了解使用者、需求背景、偏好或限制時,先詢問,不要臆測使用者的身分、能力、情緒、動機或隱私狀況。
- 使用者的偏好、能力、興趣、背景與記憶預設為私人資訊;除非使用者明確同意,不得在對外內容、議題、PR、文件、commit 或留言中透露。
# 使用者記憶(MEMORY
${MEMORY:-(尚無已整理的記憶。)}
${CATCHUP_NOTE}
> 記憶載入規則:為節省模型額度,只載入高優先度全文與中高優先度摘要,並受 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 六選一:importantinterestnewsskilldailyother。切勿把憑證或個資寫進記憶。
> 技能再現:上面只載入了部分記憶,磁碟上還有更多。遇到似乎做過的任務、需要回想做法、
> 或使用者問起過去的決定與細節(路徑、網址、指令)時,**先查詢再回答,不要憑印象**:
>
> \`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) 位元組)"
exit 0 exit 0
+27 -14
View File
@@ -2,7 +2,7 @@
# ============================================================================== # ==============================================================================
# 用途:SessionEnd hook 主程式。工作階段結束時**盡力**釋放角色單一載入鎖,讓使用者 # 用途:SessionEnd hook 主程式。工作階段結束時**盡力**釋放角色單一載入鎖,讓使用者
# 關掉 CLI 後可以立刻在新階段叫回同一個角色,不必等閒置逾時自然過期。 # 關掉 CLI 後可以立刻在新階段叫回同一個角色,不必等閒置逾時自然過期。
# 更新時間:2026/07/29 12:55:00 # 更新時間:2026/07/29 13:25:00
# 相依:bash、node(解析 hook 輸入)、同目錄的 role_lib.sh。 # 相依:bash、node(解析 hook 輸入)、同目錄的 role_lib.sh。
# 退出碼:一律 0 —— hook 絕不可阻斷 CLI 結束。 # 退出碼:一律 0 —— hook 絕不可阻斷 CLI 結束。
# #
@@ -13,6 +13,10 @@
# 為什麼一定要比對 transcript 才釋放:被鎖擋下的第二個工作階段也會觸發 SessionEnd, # 為什麼一定要比對 transcript 才釋放:被鎖擋下的第二個工作階段也會觸發 SessionEnd,
# 若無條件刪鎖,它關閉時就會把「仍在使用中」的第一個階段的鎖一起刪掉,等於讓整個 # 若無條件刪鎖,它關閉時就會把「仍在使用中」的第一個階段的鎖一起刪掉,等於讓整個
# 單一實例限制形同虛設。只有鎖確實登記在自己名下時才釋放。 # 單一實例限制形同虛設。只有鎖確實登記在自己名下時才釋放。
#
# 為什麼掃過所有角色的鎖而非只看 ROLE_NAME/.active:使用者可能在對話中點名換過人
# role_call.sh),結束時實際持有的鎖不一定是靜態解析出的那個角色。以 transcript 比對
# 逐一釋放「登記在自己名下」的鎖,才不會把角色鎖留到閒置逾時才過期。
# ============================================================================== # ==============================================================================
ROLE_STAGE="role-unload" ROLE_STAGE="role-unload"
@@ -21,17 +25,19 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
. "${SCRIPT_DIR}/role_lib.sh" . "${SCRIPT_DIR}/role_lib.sh"
role_is_child && exit 0 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 role_single_instance_enabled || exit 0
# sub agent 等非對話情境本來就不寫鎖,也就沒有鎖要釋放 # sub agent 等非對話情境本來就不寫鎖,也就沒有鎖要釋放
role_skip_instance_lock && exit 0 role_skip_instance_lock && exit 0
command -v node >/dev/null 2>&1 || role_quit "找不到 node,略過角色鎖釋放" "WRN" command -v node >/dev/null 2>&1 || role_quit "找不到 node,略過角色鎖釋放" "WRN"
ROLE="$(role_resolve_name)"
[ -n "$ROLE" ] || role_quit "未指定角色,略過角色鎖釋放"
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
# 讀取 hook 輸入(transcript_pathreason # 讀取 hook 輸入(transcript_pathsession_idreason
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
HOOK_INPUT="$(cat 2>/dev/null)" HOOK_INPUT="$(cat 2>/dev/null)"
[ -n "$HOOK_INPUT" ] || role_quit "hook 輸入為空,略過角色鎖釋放" [ -n "$HOOK_INPUT" ] || role_quit "hook 輸入為空,略過角色鎖釋放"
@@ -46,23 +52,30 @@ process.stdin.on("end", () => {
process.stdout.write([ process.stdout.write([
data.transcript_path || data.session_path || data.conversation_path || data.path || "", data.transcript_path || data.session_path || data.conversation_path || data.path || "",
data.reason || "", data.reason || "",
data.session_id || data.thread_id || data.conversation_id || "",
].join("\n")); ].join("\n"));
}); });
' 2>/dev/null)" ' 2>/dev/null)"
HOOK_TRANSCRIPT="$(printf '%s' "$HOOK_FIELDS" | sed -n '1p')" HOOK_TRANSCRIPT="$(printf '%s' "$HOOK_FIELDS" | sed -n '1p')"
HOOK_REASON="$(printf '%s' "$HOOK_FIELDS" | sed -n '2p')" 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 路徑,略過角色鎖釋放" [ -n "$HOOK_TRANSCRIPT" ] || role_quit "hook 未提供 transcript 路徑,略過角色鎖釋放"
LOCK_FILE="$(role_instance_lock_path "$ROLE")" RELEASED=""
[ -f "$LOCK_FILE" ] || role_quit "角色 ${ROLE} 目前無鎖,無須釋放" for LOCK_FILE in "$(role_home)"/*.lock; do
[ -f "$LOCK_FILE" ] || continue
HOLDER="$(role_instance_lock_field "$LOCK_FILE" transcript)" HOLDER="$(role_instance_lock_field "$LOCK_FILE" transcript)"
if [ "$HOLDER" != "$HOOK_TRANSCRIPT" ]; then [ "$HOLDER" = "$HOOK_TRANSCRIPT" ] || continue
role_quit "角色鎖屬於其他工作階段,不釋放(持有者 ${HOLDER:-未知}" LOCK_ROLE="$(basename "$LOCK_FILE" .lock)"
fi role_instance_release "$LOCK_ROLE"
RELEASED="${RELEASED:+${RELEASED} }${LOCK_ROLE}"
done
role_instance_release "$ROLE" [ -n "$RELEASED" ] || role_quit "本階段名下沒有角色鎖,無須釋放"
role_log "INF" "工作階段結束(原因 ${HOOK_REASON:-未提供}),已釋放角色鎖:${ROLE}" role_log "INF" "工作階段結束(原因 ${HOOK_REASON:-未提供}),已釋放角色鎖:${RELEASED}"
exit 0 exit 0
+74 -13
View File
@@ -1,6 +1,6 @@
--- ---
name: role name: role
description: 角色人格與長期記憶系統的建立與維護 skill。讓 CLI 工具以固定角色(namenature/vibe/簽名 emoji)回覆,並把每輪對話累積成長期記憶:搭配相容的 SessionStart hook 於啟動時依字元預算載入高價值記憶、Stop hook 先本地過濾再輕量記錄對話,睡眠時段(預設 22:00 至隔天 06:00)由排程整理記憶(NREM 鞏固:分類/去噪/去重/合併/優先度;REM 整合:跨記憶連結/抽象化/提取線索;再依 semanticepisodicproceduralemotionalpreferencerule 與 explicitimplicit 標記長期記憶型態,壓縮歸檔並適當遺忘)。提供 --new(新建或更新角色;可只給角色名稱,必要時詢問來源/作品並推斷 name/naturevibeemoji 四欄)、--use(以角色 ID 切換啟用角色)、--list(列出角色與 ID)、--export(匯出角色壓縮檔)、--sleep(立即整理)、--status--diagnose、--install-cron--remove-cron、--forget-preview、--brief(晨間狀態檢查)、--agent(匯出成 sub agent 供多角色協作)、--migrate(舊格式角色檔拆成身分與人格兩檔)等模式。當使用者說建立角色、新增人格、切換角色、匯出角色、備份角色、讓回覆更有特色、角色記憶、記憶整理、睡覺整理記憶、忘記舊記憶、角色沒有載入、hook 沒載入角色、角色被鎖住、角色鎖沒有自動解除、關掉 CLI 後角色叫不回來、角色說已在另一個工作階段、晨間狀態檢查、早上主動回報狀態,或提到 .roles.memoryROLE_NAMEROLE_ENABLEDROLE_SLEEP_STARTROLE_MEMORY_HOMEROLE_LOAD_LIMITROLE_LOAD_INBOX_LIMITROLE_LOAD_DIALOG_TURNSROLE_CAPTURE_ENABLEDROLE_SINGLE_INSTANCEROLE_INSTANCE_IDLE_MINUTES 時觸發。不適用於:工作紀錄寫入 Gitea wiki(用 /jsc-doc:worklog)、專案文件化(用 /jsc-doc:funcs)。 description: 角色人格與長期記憶系統的建立與維護 skill。讓 CLI 工具以固定角色(namenature/vibe/簽名 emoji)回覆,並把每輪對話累積成長期記憶:搭配相容的 SessionStart hook 於啟動時依字元預算載入高價值記憶、Stop hook 先本地過濾再輕量記錄對話,睡眠時段(預設 22:00 至隔天 06:00)由排程整理記憶(NREM 鞏固:分類/去噪/去重/合併/優先度;REM 整合:跨記憶連結/抽象化/提取線索;再依 semanticepisodicproceduralemotionalpreferencerule 與 explicitimplicit 標記長期記憶型態,壓縮歸檔並適當遺忘)。提供 --new(新建或更新角色;可只給角色名稱,必要時詢問來源/作品並推斷 name/naturevibeemoji 四欄)、--use(以角色 ID 切換啟用角色)、--list(列出角色與 ID)、--export(匯出角色壓縮檔)、--sleep(立即整理)、--status--diagnose、--install-cron--remove-cron、--forget-preview、--brief(晨間狀態檢查)、--agent(匯出成 sub agent 供多角色協作)、--migrate(舊格式角色檔拆成身分與人格兩檔)等模式。當使用者說建立角色、新增人格、切換角色、匯出角色、備份角色、讓回覆更有特色、角色記憶、記憶整理、睡覺整理記憶、忘記舊記憶、角色沒有載入、hook 沒載入角色、角色被鎖住、角色鎖沒有自動解除、關掉 CLI 後角色叫不回來、角色說已在另一個工作階段、晨間狀態檢查、早上主動回報狀態、在同一個終端換角色、叫名字就換人、點名載入、呼叫角色名稱、對話中途切換人格、同時跟兩個角色聊天、角色別名,或提到 .roles.memoryROLE_NAMEROLE_ENABLEDROLE_SLEEP_STARTROLE_MEMORY_HOMEROLE_LOAD_LIMITROLE_LOAD_INBOX_LIMITROLE_LOAD_DIALOG_TURNSROLE_CAPTURE_ENABLEDROLE_SINGLE_INSTANCEROLE_INSTANCE_IDLE_MINUTESROLE_CALL_ENABLEDROLE_CALL_MARKER_ONLY 時觸發。不適用於:工作紀錄寫入 Gitea wiki(用 /jsc-doc:worklog)、專案文件化(用 /jsc-doc:funcs)。
--- ---
# role — 角色人格與長期記憶 # role — 角色人格與長期記憶
@@ -11,15 +11,18 @@ description: 角色人格與長期記憶系統的建立與維護 skill。讓 CLI
| 元件 | 觸發者 | 職責 | | 元件 | 觸發者 | 職責 |
| --- | --- | --- | | --- | --- | --- |
| `hooks/hooks.json``SessionStart` hook | harness 自動 | 啟動 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``PreCompact` hook | harness 自動 | 對話壓縮**前**強制記錄一次(**跳過長度門檻**):壓縮會讓尚未寫入的內容永久蒸發,此時寧可多記 |
| `hooks/hooks.json``PostCompact` hook | harness 自動 | 壓縮**後**把 harness 產生的摘要存成一則 `daily` 記憶,作為該段落的濃縮備份 | | `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 閒置時間自動小睡整理 | | cron 排程(本 skill 安裝) | 系統排程 | 睡眠時段每小時檢查一次:**有 AI 在運行就不睡**;另可依 CLI 閒置時間自動小睡整理 |
| 本 skill `/jsc-generic:role` | 使用者/助理手動 | `--new``--use``--list``--export``--agent``--migrate``--sleep``--brief``--status``--install-cron``--forget-preview` | | 本 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 的 SOULAGENTSUSERMEMORY 分層,把人格、操作邊界、使用者記憶分開注入,並提供第一則回覆問候提示(單一實作,避免漂移) | | `scripts/role/role_load.sh` | SessionStart hook | 角色與記憶載入;參考 OpenClaw 的 SOULAGENTSUSERMEMORY 分層,把人格、操作邊界、使用者記憶分開注入,並提供第一則回覆問候提示(單一實作,避免漂移) |
| `scripts/role/role_capture.sh` | Stop hook | 對話 → 記憶(固定欄位格式) | | `scripts/role/role_call.sh` | UserPromptSubmit hook | 點名比對與即時角色切換;睡眠時段、目標角色被別的階段佔用、點的是目前已在的角色時都不切換 |
| `scripts/role/role_unload.sh` | SessionEnd hook | 釋放本階段的角色單一載入鎖(比對 transcript 確認鎖屬於自己才釋放 | | `scripts/role/role_context.sh` | role_loadrole_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/role_sleep.sh` | cron/小睡/補跑/手動 | 睡眠與小睡判斷、記憶整理、角色匯出、sub agent 定義匯出、晨間狀態檢查、排程安裝、狀態輸出 |
| `scripts/role/memory.js` | 上述共用 | 記憶檔讀寫、分類、去重合併、優先度、心理學記憶型態與關聯 metadata、壓縮歸檔、遺忘、載入組裝 | | `scripts/role/memory.js` | 上述共用 | 記憶檔讀寫、分類、去重合併、優先度、心理學記憶型態與關聯 metadata、壓縮歸檔、遺忘、載入組裝 |
| `scripts/role/transcript.js` | 上述共用 | 抽本輪對話片段、抽最近數輪純對話供工作階段交接、機密與個資遮蔽 | | `scripts/role/transcript.js` | 上述共用 | 抽本輪對話片段、抽最近數輪純對話供工作階段交接、機密與個資遮蔽 |
@@ -31,6 +34,7 @@ description: 角色人格與長期記憶系統的建立與維護 skill。讓 CLI
| 功能 | Claude Code | Codex | Antigravity | OpenCode | GitHub Copilot | | 功能 | Claude Code | Codex | Antigravity | OpenCode | GitHub Copilot |
| --- | --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- | --- |
| `SessionStart` 載入角色 | ✅ | ⚠️ 需該版本支援 SessionStart hook | ❌ | ❌ | ❌ | | `SessionStart` 載入角色 | ✅ | ⚠️ 需該版本支援 SessionStart hook | ❌ | ❌ | ❌ |
| `UserPromptSubmit` 點名載入 | ✅ | ⚠️ 需該版本支援 UserPromptSubmit hook | ❌ | ❌ | ❌ |
| `Stop` 記錄記憶 | ✅ | ✅ 需可讀 Codex session JSONL | ❌ | ❌ | ❌ | | `Stop` 記錄記憶 | ✅ | ✅ 需可讀 Codex session JSONL | ❌ | ❌ | ❌ |
| `SessionEnd` 釋放角色鎖 | ✅ | ⚠️ 需該版本支援 SessionEnd hook | ❌ | ❌ | ❌ | | `SessionEnd` 釋放角色鎖 | ✅ | ⚠️ 需該版本支援 SessionEnd hook | ❌ | ❌ | ❌ |
| cron 睡眠整理 | ✅ 與助理無關(系統排程) | ✅ | ✅ | ✅ | ✅ | | cron 睡眠整理 | ✅ 與助理無關(系統排程) | ✅ | ✅ | ✅ | ✅ |
@@ -43,7 +47,7 @@ description: 角色人格與長期記憶系統的建立與維護 skill。讓 CLI
| plugin | hooks.json 內容 | 擁有的腳本 | | 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-doc` | `Stop`worklog | `scripts/worklog/` |
| `jsc-code` | 無 `hooks/hooks.json` | 無 hook 腳本 | | `jsc-code` | 無 `hooks/hooks.json` | 無 hook 腳本 |
@@ -125,10 +129,62 @@ ROLE_DIR="<skill base directory>/../../scripts/role" # 其他助理
| `ROLE_SINGLE_INSTANCE` | | 單一載入實例限制:同一角色同時只被一個工作階段載入。設 `0` 可停用 | `1` | | `ROLE_SINGLE_INSTANCE` | | 單一載入實例限制:同一角色同時只被一個工作階段載入。設 `0` 可停用 | `1` |
| `ROLE_INSTANCE_IDLE_MINUTES` | | 前一個工作階段的 transcript 閒置多久後自動釋放角色鎖 | `30` | | `ROLE_INSTANCE_IDLE_MINUTES` | | 前一個工作階段的 transcript 閒置多久後自動釋放角色鎖 | `30` |
| `ROLE_SKIP_INSTANCE_LOCK` | | 設 `1` 時跳過單一載入鎖且**不寫鎖**,供 sub agent 等非對話情境使用 | `0` | | `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_SCOPE` | | 冒號分隔的路徑前綴,僅這些路徑下的 session 載入/記錄 | 全部 session |
| `ROLE_ERRLOG` | | 錯誤訊息額外寫入的檔案路徑 | 只走 stderr | | `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="<skill base directory>/../../scripts/role" # 其他助理
| `vibe` | 氛圍:語氣、句長、稱呼、幽默感、禁忌 | 簡潔直白、偶爾吐槽,不用客套開場白 | | `vibe` | 氛圍:語氣、句長、稱呼、幽默感、禁忌 | 簡潔直白、偶爾吐槽,不用客套開場白 |
| `emoji` | 簽名 emoji,一到二個;若後續成功建立心情 emoji 圖表,這個值作為不支援圖片時的 fallback | 🐆 | | `emoji` | 簽名 emoji,一到二個;若後續成功建立心情 emoji 圖表,這個值作為不支援圖片時的 fallback | 🐆 |
| `appearance_reference` | 選填;角色形象圖來源、作品名稱、圖片 URL 或本機檔案路徑,用來產生心情 emoji | `Sword Art Online 結衣``https://.../yui.jpg``/path/avatar.png` | | `appearance_reference` | 選填;角色形象圖來源、作品名稱、圖片 URL 或本機檔案路徑,用來產生心情 emoji | `Sword Art Online 結衣``https://.../yui.jpg``/path/avatar.png` |
| `aliases` | 選填;點名載入時可用的其他叫法(暱稱、本名、英文名),以逗號分隔。**只用於點名比對,不注入 context**;名稱太短或是常用詞開頭時不要加,否則容易誤切 | `小珪, 珪子, silica` |
流程: 流程:
@@ -178,7 +235,7 @@ ROLE_DIR="<skill base directory>/../../scripts/role" # 其他助理
8. 寫入 `~/.roles/<id>.identity.md``~/.roles/<id>.soul.md`(UTF-8 無 BOM,格式見「角色檔標準格式」)。 8. 寫入 `~/.roles/<id>.identity.md``~/.roles/<id>.soul.md`(UTF-8 無 BOM,格式見「角色檔標準格式」)。
身分檔需填**來源**與**關係定位**,並可在標題下以條目寫存在本質、角色原型、主要稱呼等摘要; 身分檔需填**來源**與**關係定位**,並可在標題下以條目寫存在本質、角色原型、主要稱呼等摘要;
人格檔除必要的本質與氛圍外,可依角色特性增加核心信念、語氣與風格、邊界與規範等章節。 人格檔除必要的本質與氛圍外,可依角色特性增加核心信念、語氣與風格、邊界與規範等章節。
共用行為**不寫入角色檔**(由 `role_load.sh` 注入)。 共用行為**不寫入角色檔**(由 `role_context.sh` 注入)。
9. 建立記憶目錄:`node "${ROLE_DIR}/memory.js" stats --role "<id>"`(會順帶建好 `inbox/`、六個分類與 `archive/`)。 9. 建立記憶目錄:`node "${ROLE_DIR}/memory.js" stats --role "<id>"`(會順帶建好 `inbox/`、六個分類與 `archive/`)。
10. 若使用者同意網路搜尋且已取得可保存內容,將搜尋摘要寫成已整理記憶,不進 inbox: 10. 若使用者同意網路搜尋且已取得可保存內容,將搜尋摘要寫成已整理記憶,不進 inbox:
@@ -305,7 +362,7 @@ Stop hook 只做「編碼前處理」,輸出粗分類、summary、tags、prior
| 本質與氛圍 | 逐字搬進 `soul` 檔 | | 本質與氛圍 | 逐字搬進 `soul` 檔 |
| ID/顯示名稱/emoji/簽名 emoji 段落 | 逐字搬進 `identity` 檔;`created` 沿用原值 | | ID/顯示名稱/emoji/簽名 emoji 段落 | 逐字搬進 `identity` 檔;`created` 沿用原值 |
| **來源與關係定位** | 產生待填空白,需人工補上(舊格式沒有這兩個概念) | | **來源與關係定位** | 產生待填空白,需人工補上(舊格式沒有這兩個概念) |
| 共用行為區塊 | **不搬進角色檔**,由 `role_load.sh` 注入 | | 共用行為區塊 | **不搬進角色檔**,由 `role_context.sh` 注入 |
| 舊檔 | **保留不動**,確認新格式正常後可自行移除或備份 | | 舊檔 | **保留不動**,確認新格式正常後可自行移除或備份 |
| 新檔已存在時 | 直接中止並提示,不覆寫 | | 新檔已存在時 | 直接中止並提示,不覆寫 |
@@ -338,7 +395,7 @@ Stop hook 只做「編碼前處理」,輸出粗分類、summary、tags、prior
### `--unlock`(解除角色載入鎖) ### `--unlock`(解除角色載入鎖)
同一角色同時只會被一個工作階段載入,避免使用者同時與兩個相同人格對話。第二個工作階段啟動時不載入人格,改以一般助理身分回應並說明原因。 同一角色同時只會被一個工作階段載入,避免使用者同時與兩個相同人格對話。第二個工作階段啟動時不載入人格,改以一般助理身分回應並說明原因。該階段仍可**點名其他未被佔用的角色**接手(見「點名載入」),不必等鎖釋放。
```bash ```bash
"${ROLE_DIR}/role_sleep.sh" --unlock "${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>.identity.md` | 角色 ID、顯示名稱、**來源作品**、**與使用者的關係定位**、簽名 emoji | `SessionStart` 注入 SOUL 區塊的身分部分 |
| `~/.roles/<角色 ID>.soul.md` | 本質(nature)、氛圍(vibe) | 同上的人格部分 | | `~/.roles/<角色 ID>.soul.md` | 本質(nature)、氛圍(vibe) | 同上的人格部分 |
**共用行為規則不寫入角色檔**:它由 `role_load.sh` 直接注入(實際生效處),完整內容見本文件的「共用行為」章節。過去角色檔裡也放一份,但 `role_load.sh` 從不讀它 —— 那是冗余副本,只會多一個漏同步的機會。 **共用行為規則不寫入角色檔**:它由 `role_context.sh` 直接注入(實際生效處SessionStart 載入與點名載入共用),完整內容見本文件的「共用行為」章節。過去角色檔裡也放一份,但載入腳本從不讀它 —— 那是冗余副本,只會多一個漏同步的機會。
**舊格式仍完整支援**:單一 `~/.roles/<角色 ID>.md` 可繼續使用,解析時新格式優先、找不到才退回舊檔。要拆成新格式用 `--migrate`。 **舊格式仍完整支援**:單一 `~/.roles/<角色 ID>.md` 可繼續使用,解析時新格式優先、找不到才退回舊檔。要拆成新格式用 `--migrate`。
@@ -448,6 +505,7 @@ ls -d "$HOME"/.claude/plugins/cache/*/jsc-generic/*/scripts/role/role_sleep.sh |
id: <角色 ID> id: <角色 ID>
name: <角色顯示名稱> name: <角色顯示名稱>
emoji: <簽名 emoji> emoji: <簽名 emoji>
aliases: <選填;點名用的其他叫法,以逗號分隔>
created: <yyyy/MM/dd HH:mm:ss> created: <yyyy/MM/dd HH:mm:ss>
updated: <yyyy/MM/dd HH:mm:ss> updated: <yyyy/MM/dd HH:mm:ss>
--- ---
@@ -503,6 +561,7 @@ updated: <yyyy/MM/dd HH:mm:ss>
| 來源 | 是否注入 | | 來源 | 是否注入 |
| --- | --- | | --- | --- |
| `identity` 的 frontmatter`id``name``emoji` | ✅ | | `identity` 的 frontmatter`id``name``emoji` | ✅ |
| `identity` 的 frontmatter `aliases` | ❌ 只供點名載入比對名稱,不進 context |
| `identity` 標題後、第一個 `##` 之前的**前言段落** | ✅ 常用來寫存在本質、角色原型等摘要條目 | | `identity` 標題後、第一個 `##` 之前的**前言段落** | ✅ 常用來寫存在本質、角色原型等摘要條目 |
| `identity` 的 `## 來源``## 關係定位``## 簽名 emoji` | ✅ | | `identity` 的 `## 來源``## 關係定位``## 簽名 emoji` | ✅ |
| `soul` 的 `## 本質``## 氛圍` | ✅ | | `soul` 的 `## 本質``## 氛圍` | ✅ |
@@ -510,13 +569,13 @@ updated: <yyyy/MM/dd HH:mm:ss>
寫進角色檔的內容若未被注入就等於白寫,因此上述兩處(前言段落與自由章節)都會完整帶入 —— 曾發生使用者在人格檔補寫章節卻被靜默丟棄的情況。 寫進角色檔的內容若未被注入就等於白寫,因此上述兩處(前言段落與自由章節)都會完整帶入 —— 曾發生使用者在人格檔補寫章節卻被靜默丟棄的情況。
**角色專屬邊界不得放寬共用行為的限制**:共用行為(由 `role_load.sh` 注入)永遠優先,角色檔只能加嚴不能放寬。 **角色專屬邊界不得放寬共用行為的限制**:共用行為(由 `role_context.sh` 注入)永遠優先,角色檔只能加嚴不能放寬。
`~/.roles/.active` 只放一行角色 ID,代表目前啟用的角色。 `~/.roles/.active` 只放一行角色 ID,代表目前啟用的角色。
## 共用行為(所有角色一致,由 /jsc-generic:role 維護,請勿手動修改) ## 共用行為(所有角色一致,由 /jsc-generic:role 維護,請勿手動修改)
以下規則**不寫入角色檔** —— 由 `role_load.sh` 直接注入 context(實際生效處)。 以下規則**不寫入角色檔** —— 由 `role_context.sh` 直接注入 context(實際生效處SessionStart 載入與點名載入共用同一份)。
本節是它的唯一文件來源,修改注入內容時必須同步更新這裡。 本節是它的唯一文件來源,修改注入內容時必須同步更新這裡。
<!-- JSC-ROLE-COMMON:START --> <!-- JSC-ROLE-COMMON:START -->
@@ -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` | | 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/` | | Codex | `$role --status`,或用 `/skills` 選單;匯出可用 `$role --export /path/to/exports/` |
| OpenCode / GitHub Copilot | 需完整 plugin 目錄保留 `scripts/`OpenCode 以複製 `skills/` 安裝時不可用 | | OpenCode / GitHub Copilot | 需完整 plugin 目錄保留 `scripts/`OpenCode 以複製 `skills/` 安裝時不可用 |
> **切換角色不必透過本 skill**:對話中直接以名字點名(例如「西莉卡,…」或「@SILICA01 …」)即可即時換人,見「點名載入」;`--use` 只用來改「開新工作階段時的預設角色」。