feat(role): 點名載入切換角色 + cron 固定路徑啟動器(0.0.9) #23
@@ -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`。
|
||||
|
||||
<!-- JSC-SKILLS:END -->
|
||||
|
||||
@@ -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 睡眠整理(系統排程) | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
|
||||
@@ -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": [
|
||||
|
||||
Executable
+232
@@ -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 <<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
|
||||
@@ -6,7 +6,7 @@
|
||||
# 等待睡眠時段做完整 NREM/REM 整理。睡眠時段雖不載入角色,對話仍照常記錄。
|
||||
# 另支援 --precompact/--postcompact:對話壓縮會讓尚未寫入記憶的內容蒸發,
|
||||
# 壓縮前強制記錄一次(跳過長度門檻),壓縮後把系統產生的摘要也存成記憶。
|
||||
# 更新時間:2026/07/28 16:18:00
|
||||
# 更新時間:2026/07/29 13:25:00
|
||||
# 相依:bash、node、任一 headless CLI、同目錄的 role_lib.sh/memory.js/transcript.js。
|
||||
# 機密:濃縮提示詞明令不得輸出憑證與個資,寫檔前再以 transcript.js redact 遮蔽一次。
|
||||
# 退出碼:一律 0 —— hook 絕不可阻斷使用者流程。
|
||||
@@ -25,13 +25,14 @@ case "${1:-}" in
|
||||
esac
|
||||
|
||||
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"
|
||||
|
||||
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
|
||||
|
||||
Executable
+387
@@ -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");
|
||||
|
||||
// 新格式:第一個參數是 <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 六選一:important/interest/news/skill/daily/other。切勿把憑證或個資寫進記憶。
|
||||
|
||||
> 技能再現:上面只載入了部分記憶,磁碟上還有更多。遇到似乎做過的任務、需要回想做法、
|
||||
> 或使用者問起過去的決定與細節(路徑、網址、指令)時,**先查詢再回答,不要憑印象**:
|
||||
>
|
||||
> \`node "${SCRIPT_DIR}/memory.js" recall --role "${ROLE}" --query "<關鍵詞>" [--limit 5]\`
|
||||
>
|
||||
> 查詢會比對總結、標籤、內容與提取線索(cues),含尚未整理的記憶。查詢屬內部處理,不必回報。
|
||||
${PEERS_BLOCK}
|
||||
${DIALOG_BLOCK}
|
||||
EOF_CONTEXT
|
||||
)"
|
||||
}
|
||||
@@ -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 的 <owner>/<repo> 優先,其次目錄名
|
||||
local cwd="$1" origin cleaned owner_repo
|
||||
|
||||
+24
-307
@@ -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
|
||||
role_context_emit "SessionStart" "$(cat <<EOF_BUSY
|
||||
# 角色狀態:已在另一個工作階段中
|
||||
|
||||
角色「${ROLE}」目前已被另一個仍在使用的工作階段載入(載入時間 ${HOLDER_TIME:-未知},目錄 ${HOLDER_CWD:-未知})。
|
||||
@@ -87,6 +84,7 @@ if ! role_instance_acquire "$ROLE" "$HOOK_TRANSCRIPT" "$HOOK_CWD"; then
|
||||
- 確定另一個工作階段已關閉時解除鎖定:\`role_sleep.sh --unlock\`
|
||||
- 該階段閒置超過 $(role_instance_idle_minutes) 分鐘後會自動釋放
|
||||
- 完全停用此限制:設定環境變數 \`ROLE_SINGLE_INSTANCE=0\`
|
||||
- 在本階段改叫其他角色:直接以名字點名(例如「<其他角色名>,…」),該角色未被佔用時會即時接手
|
||||
EOF_BUSY
|
||||
)"
|
||||
exit 0
|
||||
@@ -96,7 +94,9 @@ fi
|
||||
# 睡眠時段:不載入角色,只說明目前狀態
|
||||
# ------------------------------------------------------------------------------
|
||||
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})
|
||||
|
||||
角色「${ROLE}」正在睡覺,本次工作階段**不載入角色人格與記憶**,請以一般助理身分回應,
|
||||
@@ -108,295 +108,12 @@ EOF_SLEEP
|
||||
fi
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# 非睡眠時段:組出角色人格 + 操作規則 + 記憶
|
||||
# 非睡眠時段:組出角色人格 + 操作規則 + 記憶(與點名載入共用 role_context.sh)
|
||||
# ------------------------------------------------------------------------------
|
||||
ROLE_SOUL_FILE=""
|
||||
if role_is_new_format "$ROLE"; then
|
||||
ROLE_SOUL_FILE="$(role_soul_file "$ROLE")"
|
||||
[ -f "$ROLE_SOUL_FILE" ] || role_log "WRN" "新格式缺少人格檔:${ROLE_SOUL_FILE}(本質與氛圍將為空)"
|
||||
fi
|
||||
role_context_build "$ROLE" "$HOOK_TRANSCRIPT" "load" \
|
||||
|| role_quit "角色定義檔為空或無法解析:${ROLE_DEF}" "WRN"
|
||||
|
||||
ROLE_PROFILE="$(node - "$ROLE_DEF" "$ROLE_SOUL_FILE" <<'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 "$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 六選一: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
|
||||
|
||||
+28
-15
@@ -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
|
||||
|
||||
+74
-13
@@ -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="<skill base directory>/../../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="<skill base directory>/../../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="<skill base directory>/../../scripts/role" # 其他助理
|
||||
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/`)。
|
||||
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: <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 `aliases` | ❌ 只供點名載入比對名稱,不進 context |
|
||||
| `identity` 標題後、第一個 `##` 之前的**前言段落** | ✅ 常用來寫存在本質、角色原型等摘要條目 |
|
||||
| `identity` 的 `## 來源`/`## 關係定位`/`## 簽名 emoji` | ✅ |
|
||||
| `soul` 的 `## 本質`/`## 氛圍` | ✅ |
|
||||
@@ -510,13 +569,13 @@ updated: <yyyy/MM/dd HH:mm:ss>
|
||||
|
||||
寫進角色檔的內容若未被注入就等於白寫,因此上述兩處(前言段落與自由章節)都會完整帶入 —— 曾發生使用者在人格檔補寫章節卻被靜默丟棄的情況。
|
||||
|
||||
**角色專屬邊界不得放寬共用行為的限制**:共用行為(由 `role_load.sh` 注入)永遠優先,角色檔只能加嚴不能放寬。
|
||||
**角色專屬邊界不得放寬共用行為的限制**:共用行為(由 `role_context.sh` 注入)永遠優先,角色檔只能加嚴不能放寬。
|
||||
|
||||
`~/.roles/.active` 只放一行角色 ID,代表目前啟用的角色。
|
||||
|
||||
## 共用行為(所有角色一致,由 /jsc-generic:role 維護,請勿手動修改)
|
||||
|
||||
以下規則**不寫入角色檔** —— 由 `role_load.sh` 直接注入 context(實際生效處)。
|
||||
以下規則**不寫入角色檔** —— 由 `role_context.sh` 直接注入 context(實際生效處,SessionStart 載入與點名載入共用同一份)。
|
||||
本節是它的唯一文件來源,修改注入內容時必須同步更新這裡。
|
||||
|
||||
<!-- 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` |
|
||||
| Codex | `$role --status`,或用 `/skills` 選單;匯出可用 `$role --export /path/to/exports/` |
|
||||
| OpenCode / GitHub Copilot | 需完整 plugin 目錄保留 `scripts/`;OpenCode 以複製 `skills/` 安裝時不可用 |
|
||||
|
||||
> **切換角色不必透過本 skill**:對話中直接以名字點名(例如「西莉卡,…」或「@SILICA01 …」)即可即時換人,見「點名載入」;`--use` 只用來改「開新工作階段時的預設角色」。
|
||||
|
||||
Reference in New Issue
Block a user