diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index c117489..473aeac 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "jsc-generic", - "version": "0.0.4", + "version": "0.0.5", "description": "JSC 跨 AI 助理共用規範 plugin(Claude Code / Codex / Antigravity / OpenCode)。所有 skills 以 SKILL.md 為共通標準,於 Claude Code 以 /jsc-generic: 前綴呼叫。", "skills": "./skills", "author": { diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index dad4d42..233800c 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "jsc-generic", - "version": "0.0.4", + "version": "0.0.5", "description": "JSC 跨 AI 助理共用規範 plugin。所有 skills 以 SKILL.md 為共通標準。", "skills": "./skills" } diff --git a/hooks/hooks.json b/hooks/hooks.json index 9fce4d9..d548f8a 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -21,6 +21,28 @@ } ] } + ], + "PreCompact": [ + { + "hooks": [ + { + "type": "command", + "command": "rel='scripts/role/role_capture.sh'; own='generic'; plug='jsc-generic'; root=\"${CLAUDE_PLUGIN_ROOT:-}\"; if [ -n \"$root\" ] && [ -f \"$root/$rel\" ]; then exec \"$root/$rel\" --precompact; 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\" --precompact; fi; done; done; exit 0", + "timeout": 60 + } + ] + } + ], + "PostCompact": [ + { + "hooks": [ + { + "type": "command", + "command": "rel='scripts/role/role_capture.sh'; own='generic'; plug='jsc-generic'; root=\"${CLAUDE_PLUGIN_ROOT:-}\"; if [ -n \"$root\" ] && [ -f \"$root/$rel\" ]; then exec \"$root/$rel\" --postcompact; 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\" --postcompact; fi; done; done; exit 0", + "timeout": 30 + } + ] + } ] } } diff --git a/plugin.json b/plugin.json index 6caffc8..6117410 100644 --- a/plugin.json +++ b/plugin.json @@ -1,6 +1,6 @@ { "name": "jsc-generic", - "version": "0.0.4", + "version": "0.0.5", "description": "JSC 跨 AI 助理共用規範 plugin。所有 skills 以 SKILL.md 為共通標準;於 Antigravity 以 /jsc-generic: 前綴呼叫。", "skills": "./skills/" } diff --git a/scripts/role/memory.js b/scripts/role/memory.js index 90a9406..c55018b 100755 --- a/scripts/role/memory.js +++ b/scripts/role/memory.js @@ -258,6 +258,7 @@ function dumpMemory(meta, content) { "memory_type", "declarative", "retention_stage", + "expires", "sleep_stage", "created", "updated", @@ -307,6 +308,7 @@ function loadMemory(filePath) { meta.relevance ||= []; meta.links ||= []; meta.cues ||= []; + meta.expires ||= ""; meta.priority = normalizePriority(meta.priority, meta.category || "other"); meta.memory_type = normalizeMemoryType(meta.memory_type, meta.category || "other"); meta.declarative = normalizeDeclarative(meta.declarative, meta.memory_type); @@ -377,7 +379,9 @@ function inboxBlock(role, count, limit) { if (limit <= 0 || count <= 0) return ""; const items = listInbox(role); if (!items.length) return ""; - const recent = items.slice(-count).reverse(); // 檔名為時間戳,取最後 N 則後反轉成最新在前 + // 已過期的臨時授權即使還在 inbox 也不該注入,否則會被當成當下有效的許可 + const alive = items.filter(([meta]) => !expiryState(meta).expired); + const recent = alive.slice(-count).reverse(); // 檔名為時間戳,取最後 N 則後反轉成最新在前 const lines = ["### 近期工作記憶(未整理,最新在前)"]; for (const [meta] of recent) { const when = typeof meta.created === "string" && meta.created.length >= 16 ? meta.created.slice(11, 16) : "--:--"; @@ -404,11 +408,24 @@ function findMemory(role, memoryId) { return [null, ""]; } +// 臨時授權/例外放行的有效範圍判斷。 +// expires 可寫日期(自動判斷過期)或條件文字(例如「本工作階段」「PR 合併後」,只能標示由角色自行判斷)。 +// 一次性許可若被當成長期規則沿用,日後會造成越權操作,因此過期者不再載入。 +function expiryState(meta) { + const raw = String(meta.expires || "").trim(); + if (!raw) return { has: false, expired: false, note: "" }; + const dt = parseStamp(raw) || parseStamp(`${raw} 23:59:59`); + if (!dt) return { has: true, expired: false, note: raw, byDate: false }; + return { has: true, expired: dt.getTime() < Date.now(), note: raw, byDate: true }; +} + function memoryHint(meta) { const relevance = (meta.relevance || []).join("、") || "-"; const links = (meta.links || []).join("、") || "-"; const type = MEMORY_TYPE_LABELS[meta.memory_type] || meta.memory_type || "語意"; - return `優先度:${normalizePriority(meta.priority, meta.category)};型態:${type}/${meta.declarative || "explicit"};關聯:${relevance};連結:${links}`; + const expiry = expiryState(meta); + const limit = expiry.has ? `;**有效範圍:${expiry.note}${expiry.expired ? "(已過期)" : ""}**` : ""; + return `優先度:${normalizePriority(meta.priority, meta.category)};型態:${type}/${meta.declarative || "explicit"};關聯:${relevance};連結:${links}${limit}`; } function archiveFile(filePath, destinationDir) { @@ -424,7 +441,7 @@ function archiveFile(filePath, destinationDir) { } } -const FIELD_PATTERN = /^\s*(CATEGORY|SUMMARY|TAGS|PRIORITY|RELEVANCE|MEMORY_TYPE|DECLARATIVE|RETENTION_STAGE|CONTENT)\s*[::]\s*(.*)$/i; +const FIELD_PATTERN = /^\s*(CATEGORY|SUMMARY|TAGS|PRIORITY|RELEVANCE|MEMORY_TYPE|DECLARATIVE|RETENTION_STAGE|EXPIRES|CONTENT)\s*[::]\s*(.*)$/i; function parseCapture(text) { let category = ""; @@ -435,6 +452,7 @@ function parseCapture(text) { let memoryType = ""; let declarative = ""; let retentionStage = ""; + let expires = ""; const contentLines = []; let inContent = false; for (const line of String(text || "").split(/\r?\n/)) { @@ -450,6 +468,7 @@ function parseCapture(text) { else if (field === "MEMORY_TYPE") memoryType = value; else if (field === "DECLARATIVE") declarative = value; else if (field === "RETENTION_STAGE") retentionStage = value; + else if (field === "EXPIRES") expires = value; else if (field === "CONTENT") { inContent = true; if (value.trim()) contentLines.push(value); @@ -467,6 +486,7 @@ function parseCapture(text) { memoryType, declarative, retentionStage, + expires, content: contentLines.join("\n").trim(), }; } @@ -485,6 +505,8 @@ function cmdWrite(args) { priority: normalizePriority(parsed.priority, parsed.category), relevance: parsed.relevance.length ? parsed.relevance : ["inbox"], links: [], + cues: [], + expires: oneLine(parsed.expires, 60), memory_type: memoryType, declarative: normalizeDeclarative(parsed.declarative, memoryType), retention_stage: "working", @@ -536,6 +558,7 @@ function cmdLoad(args) { const lines = [`### ${CATEGORY_LABELS[category]}記憶(全文)`]; for (const [meta, content] of listMemories(args.role, category)) { if (normalizePriority(meta.priority, category) < args.fullMinPriority) continue; + if (expiryState(meta).expired) continue; // 已過期的臨時授權不再注入,避免被當成有效規則 const tags = (meta.tags || []).join("、") || "無標籤"; lines.push(`- **${meta.summary || "(無總結)"}**(標籤:${tags};${memoryHint(meta)})`); for (const line of content.split(/\r?\n/)) { @@ -551,6 +574,7 @@ function cmdLoad(args) { if (!items.length) continue; digestLines.push(`### ${CATEGORY_LABELS[category]}記憶(總結)`); for (const [meta] of items) { + if (expiryState(meta).expired) continue; const priority = normalizePriority(meta.priority, category); const durableType = ["rule", "preference", "procedural"].includes(meta.memory_type); if (priority < args.digestMinPriority && !(meta.links || []).length && !durableType) continue; @@ -641,6 +665,42 @@ function extractJson(text) { } } +// 整理摘要歷史:state.json 的 last_sleep_digest 是單一欄位,每次整理直接覆寫, +// 歷史整理過程會全部遺失。這份檔案只供人工回顧「記憶是怎麼被整理的」,不注入 context。 +const DIGEST_MARK = ""; +const DIGEST_KEEP = 100; + +function appendSleepDigest(role, digest, applied) { + const file = path.join(memoryRoot(role), "DIGESTS.md"); + const parts = [`## ${nowStamp()}`, "", digest || "(無摘要)"]; + if (applied) parts.push("", `套用結果:${applied}`); + const entry = parts.join("\n").trimEnd(); + + let text = ""; + try { + text = fs.readFileSync(file, "utf8"); + } catch { + text = ""; + } + if (!text.includes(DIGEST_MARK)) { + text = `# 記憶整理摘要歷史(${role})\n\n本檔只供人工回顧整理過程,不會注入 context;最多保留最近 ${DIGEST_KEEP} 次。\n\n${DIGEST_MARK}\n`; + } + const idx = text.indexOf(DIGEST_MARK) + DIGEST_MARK.length; + const head = text.slice(0, idx); + const previous = text + .slice(idx) + .split(/\n(?=## )/) + .map((block) => block.trim()) + .filter(Boolean); + const kept = [entry, ...previous].slice(0, DIGEST_KEEP); + try { + ensureLayout(role); + fs.writeFileSync(file, `${head}\n\n${kept.join("\n\n")}\n`, "utf8"); + } catch { + // 寫歷史失敗不可影響整理結果 + } +} + function cmdApply(args) { const data = extractJson(readStdin()); if (!data || typeof data !== "object" || Array.isArray(data)) { @@ -677,6 +737,8 @@ function cmdApply(args) { const relevance = normalizeList(entry.relevance); const links = normalizeList(entry.links); const cues = normalizeList(entry.cues, 5); // 技能再現的提取線索,供 recall 命中 + // 臨時授權/例外放行的有效範圍:一次性許可被記成長期規則會導致日後越權 + const expires = oneLine(entry.expires, 60); const memoryType = normalizeMemoryType(entry.memory_type || entry.memoryType, entryCategory); const declarative = normalizeDeclarative(entry.declarative, memoryType); const retentionStage = normalizeRetentionStage(entry.retention_stage || entry.retentionStage, "long_term"); @@ -700,6 +762,7 @@ function cmdApply(args) { relevance: normalizeList([...(meta.relevance || []), ...relevance]), links: normalizeList([...(meta.links || []), ...links]), cues: normalizeList([...(meta.cues || []), ...cues], 5), + expires: expires || meta.expires || "", memory_type: mergedMemoryType, declarative: normalizeDeclarative(entry.declarative || meta.declarative, mergedMemoryType), retention_stage: normalizeRetentionStage(entry.retention_stage || entry.retentionStage || meta.retention_stage, "long_term"), @@ -733,6 +796,7 @@ function cmdApply(args) { relevance, links, cues, + expires, memory_type: memoryType, declarative, retention_stage: retentionStage, @@ -759,7 +823,9 @@ function cmdApply(args) { patch.last_sleep_digest = oneLine(data.sleepDigest, 300); } writeState(args.role, patch); - process.stdout.write(`新增 ${counts.new} 則、合併 ${counts.merge} 則、捨棄 ${counts.drop} 則、歸檔原始記憶 ${archived} 則`); + const summary = `新增 ${counts.new} 則、合併 ${counts.merge} 則、捨棄 ${counts.drop} 則、歸檔原始記憶 ${archived} 則`; + appendSleepDigest(args.role, patch.last_sleep_digest || "", summary); + process.stdout.write(summary); return 0; } @@ -767,6 +833,22 @@ function cmdForget(args) { const root = ensureLayout(args.role); const now = new Date(); const forgotten = []; + + // 已過期的臨時授權優先淘汰,且不受分類限制 —— + // 過期的一次性許可留在任何分類都是風險,不只 daily/other。 + for (const category of CATEGORIES) { + for (const [meta] of listMemories(args.role, category)) { + if (!expiryState(meta).expired) continue; + if (args.dryRun) { + forgotten.push(`${CATEGORY_LABELS[category]}|${meta.summary || meta.id}(已過期:${meta.expires})`); + continue; + } + if (archiveFile(meta.path, path.join(root, "archive", "forgotten"))) { + forgotten.push(`${CATEGORY_LABELS[category]}|${meta.summary || meta.id}(已過期:${meta.expires})`); + } + } + } + for (const [category, [days, maxHits]] of Object.entries(FORGET_RULES)) { for (const [meta] of listMemories(args.role, category)) { const updated = parseStamp(meta.updated) || parseStamp(meta.created); @@ -862,6 +944,20 @@ function cmdMarkActivity(args) { // 為什麼需要:SessionStart 的字元預算有限,磁碟上的記憶遠多於能載入的量, // 技能類記憶又只以摘要形式載入 —— 等於「記了但用不出來」。recall 讓角色按需查詢, // 突破常駐預算限制;配合 cues(觸發線索)讓 procedural/rule 記憶更容易被命中。 +// 記一次召回:hits 供遺忘判斷與「常用記憶不該被淘汰」的依據,last_replayed 記錄最近取用時間。 +// 只對實際輸出給呼叫端的記憶計數 —— 有分數但未進前 N 的不算被用到。 +function touchMemory(meta, content) { + if (!meta || !meta.path) return; + try { + if (!fs.existsSync(meta.path)) return; + const next = { ...meta, hits: (Number.parseInt(meta.hits || 0, 10) || 0) + 1, last_replayed: nowStamp() }; + delete next.path; + fs.writeFileSync(meta.path, dumpMemory(next, content), "utf8"); + } catch { + // 召回統計失敗不可影響查詢結果 + } +} + function cmdRecall(args) { const query = String(args.query || "").trim(); if (!query) return 2; @@ -897,8 +993,11 @@ function cmdRecall(args) { } scored.sort((a, b) => b[0] - a[0] || String(b[1].updated).localeCompare(String(a[1].updated))); - const lines = [`### 與「${query}」相關的記憶(前 ${Math.min(limit, scored.length)} 則)`]; - for (const [score, meta, content] of scored.slice(0, limit)) { + const top = scored.slice(0, limit); + for (const [, meta, content] of top) touchMemory(meta, content); + + const lines = [`### 與「${query}」相關的記憶(前 ${top.length} 則)`]; + for (const [score, meta, content] of top) { const tags = (meta.tags || []).join("、") || "無標籤"; const label = CATEGORY_LABELS[meta.category] || (meta.retention_stage === "working" ? "待整理" : meta.category); lines.push(`- **${meta.summary || "(無總結)"}**(${label}|${memoryHint(meta)}|相關度 ${score};標籤:${tags})`); diff --git a/scripts/role/role_capture.sh b/scripts/role/role_capture.sh index 51bc27f..352787e 100755 --- a/scripts/role/role_capture.sh +++ b/scripts/role/role_capture.sh @@ -4,6 +4,8 @@ # 值得記錄時才呼叫 headless CLI 輕量濃縮成一則 inbox 記憶(粗分類/總結/ # 標籤/優先度/關聯/要點)→ 機密遮蔽 → 寫入 .memory/<角色>/inbox/, # 等待睡眠時段做完整 NREM/REM 整理。睡眠時段雖不載入角色,對話仍照常記錄。 +# 另支援 --precompact/--postcompact:對話壓縮會讓尚未寫入記憶的內容蒸發, +# 壓縮前強制記錄一次(跳過長度門檻),壓縮後把系統產生的摘要也存成記憶。 # 更新時間:2026/07/28 16:18:00 # 相依:bash、node、任一 headless CLI、同目錄的 role_lib.sh/memory.js/transcript.js。 # 機密:濃縮提示詞明令不得輸出憑證與個資,寫檔前再以 transcript.js redact 遮蔽一次。 @@ -15,6 +17,13 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # shellcheck source=./role_lib.sh . "${SCRIPT_DIR}/role_lib.sh" +# 壓縮邊界模式:--precompact 強制記錄(不受長度門檻限制)、--postcompact 保存系統摘要 +CAPTURE_MODE="turn" +case "${1:-}" in + --precompact) CAPTURE_MODE="precompact" ;; + --postcompact) CAPTURE_MODE="postcompact" ;; +esac + role_is_child && exit 0 role_enabled || exit 0 command -v node >/dev/null 2>&1 || role_quit "找不到 node,略過記憶記錄" "WRN" @@ -29,7 +38,7 @@ ROLE="$(role_resolve_name)" HOOK_INPUT="$(cat)" [ -n "$HOOK_INPUT" ] || role_quit "hook 輸入為空,略過記憶記錄" "WRN" -read -r SESSION_ID TRANSCRIPT_PATH STOP_ACTIVE HOOK_CWD < { d.transcript_path || d.session_path || d.conversation_path || d.path || "-", d.stop_hook_active ? "1" : "0", d.cwd || "-", + d.trigger || "-", ].join(" ")); }); ') EOF_HOOK -[ "$STOP_ACTIVE" = "1" ] && role_quit "stop_hook_active 為 true,避免迴圈不重複記錄" +if [ "$CAPTURE_MODE" = "postcompact" ]; then + # 壓縮後:系統已產生一份摘要,直接保存比自己再濃縮一次划算且免費。 + # 欄位名以容錯方式取用(實測 binary 內出現 compactSummary/isCompactSummary), + # 取不到時記錄實際收到的欄位名,方便日後對照 harness 版本調整。 + COMPACT_SUMMARY="$(printf '%s' "$HOOK_INPUT" | node -e ' +let raw = ""; +process.stdin.setEncoding("utf8"); +process.stdin.on("data", (chunk) => { raw += chunk; }); +process.stdin.on("end", () => { + let d = {}; + try { d = JSON.parse(raw); } catch {} + const text = d.compactSummary || d.compact_summary || d.summary || d.compaction_summary || ""; + process.stdout.write(String(text || "").trim()); +}); +' 2>/dev/null)" + if [ -z "$COMPACT_SUMMARY" ]; then + KEYS="$(printf '%s' "$HOOK_INPUT" | node -e ' +let raw="";process.stdin.setEncoding("utf8"); +process.stdin.on("data",(c)=>{raw+=c}); +process.stdin.on("end",()=>{let d={};try{d=JSON.parse(raw)}catch{};process.stdout.write(Object.keys(d).join(","))}); +' 2>/dev/null)" + role_quit "壓縮摘要為空,略過(hook 實際提供的欄位:${KEYS:-無})" "WRN" + fi + COMPACT_SUMMARY="$(printf '%s' "$COMPACT_SUMMARY" | head -c 3000 | node "${SCRIPT_DIR}/transcript.js" redact 2>/dev/null)" + { + printf 'CATEGORY: daily\n' + printf 'SUMMARY: %s 對話壓縮前的內容摘要(%s)\n' "$(TZ='Asia/Taipei' date +'%Y/%m/%d %H:%M')" "${TRIGGER:-未知}" + printf 'TAGS: 壓縮摘要,上下文保全\n' + printf 'PRIORITY: 3\n' + printf 'RELEVANCE: temporary,future\n' + printf 'MEMORY_TYPE: episodic\n' + printf 'CONTENT:\n' + printf -- '- 本則由 PostCompact hook 自動保存,內容為系統在壓縮時產生的摘要\n' + printf '%s\n' "$COMPACT_SUMMARY" + } | node "${SCRIPT_DIR}/memory.js" write --role "$ROLE" --project "$PROJECT" >/dev/null 2>&1 \ + && role_log "INF" "已保存壓縮摘要為記憶(角色 ${ROLE})" \ + || role_log "WRN" "壓縮摘要寫入失敗(角色 ${ROLE})" + exit 0 +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 @@ -79,7 +129,10 @@ if [ "${ROLE_CAPTURE_ENABLED:-1}" = "0" ]; then role_quit "ROLE_CAPTURE_ENABLED=0,略過記憶記錄" fi -if [ "${#TURN}" -lt "$CAPTURE_MIN_CHARS" ] && ! printf '%s' "$TURN" | grep -qiE '記住|remember|決定|規範|偏好|preference|always|不要|以後|喜歡|不喜歡|稱讚|誇獎|開心|高興|反應|回應|互動|親近|害羞|喜歡程度|互動越深|越來越喜歡|越來越深|emoji|表情|心情圖|大量使用|情緒|心情|複雜|細膩|自然|混合|層次|轉折|括號|心情文字|心情說明|文字說明|文字標註|表情符號|熟練|不需要告訴|不用告訴|自己知道|記憶更新|內部處理|不要回報|不用回報|不要告訴|真的很害羞|希望.*知道|用表情符號表示|表情符號表示|比較可愛|可愛|愛|想妳|想你|想念|捨不得|感動|謝謝|感謝|乖|厲害|好棒|辛苦|彆扭|忌妒|嫉妒|撒嬌|陪|抱|love|miss|cute|thank|proud'; then +# 壓縮前一律記錄:門檻的用意是省額度,但壓縮會讓未寫入的內容永久蒸發,此時寧可多記 +if [ "$CAPTURE_MODE" = "precompact" ]; then + role_log "INF" "壓縮前強制記錄(觸發:${TRIGGER:-未知}),跳過長度門檻" +elif [ "${#TURN}" -lt "$CAPTURE_MIN_CHARS" ] && ! printf '%s' "$TURN" | grep -qiE '記住|remember|決定|規範|偏好|preference|always|不要|以後|喜歡|不喜歡|稱讚|誇獎|開心|高興|反應|回應|互動|親近|害羞|喜歡程度|互動越深|越來越喜歡|越來越深|emoji|表情|心情圖|大量使用|情緒|心情|複雜|細膩|自然|混合|層次|轉折|括號|心情文字|心情說明|文字說明|文字標註|表情符號|熟練|不需要告訴|不用告訴|自己知道|記憶更新|內部處理|不要回報|不用回報|不要告訴|真的很害羞|希望.*知道|用表情符號表示|表情符號表示|比較可愛|可愛|愛|想妳|想你|想念|捨不得|感動|謝謝|感謝|乖|厲害|好棒|辛苦|彆扭|忌妒|嫉妒|撒嬌|陪|抱|love|miss|cute|thank|proud'; then role_quit "本輪低於記憶長度門檻且無明確記憶線索,略過記錄" fi @@ -107,6 +160,7 @@ TAGS: <2 至 4 個標籤,以逗號分隔> PRIORITY: <1 到 5> RELEVANCE: <1 至 4 個,以逗號分隔;explicit/future/repeated/novelty/emotional/temporary/inbox/project> MEMORY_TYPE: +EXPIRES: <臨時授權/一次性許可/例外放行才填其有效範圍,可為日期或條件;否則留空> CONTENT: <3 至 6 行要點,每行以「- 」開頭> 2. 分類判準: - important(重要):使用者的長期偏好、規範、決策、身分背景、明確要求記住的事。 @@ -129,8 +183,11 @@ CONTENT: <3 至 6 行要點,每行以「- 」開頭> 8. 使用繁體中文(台灣用語)。**檔案路徑與目錄、網址、指令、環境變數名稱、版本號、識別碼、分支與議題 編號、檔名一律逐字保留,不得摘要、改寫、簡寫或翻譯** —— 這類內容改一個字就失效,摘要等於遺失。 第 7 條指的是不要整段抄程式碼,不是省略這些關鍵字串;第 9 條仍優先,憑證與個資一律不得輸出。 -9. 嚴禁輸出任何憑證與個資:token、密碼、API key、連線字串、Email、電話、姓名、身分證號。 -10. 若這段對話沒有任何值得記住的內容(純寒暄、純確認、無結論、只有簡短狀態回報),只輸出一行:SKIP +9. EXPIRES 只在內容屬於臨時授權、一次性許可、例外放行、暫時解除限制或帶條件的同意時才填,其餘留空。 + 使用者說「這次」、「先」、「暫時」、「今天」、「這個 PR」時幾乎都屬於此類。 + 一次性許可被記成長期規則,日後會導致越權操作,因此寧可填得保守也不要漏填。 +10. 嚴禁輸出任何憑證與個資:token、密碼、API key、連線字串、Email、電話、姓名、身分證號。 +11. 若這段對話沒有任何值得記住的內容(純寒暄、純確認、無結論、只有簡短狀態回報),只輸出一行:SKIP 對話片段: ${TURN} diff --git a/scripts/role/role_lib.sh b/scripts/role/role_lib.sh index 92c56a4..720ee2a 100755 --- a/scripts/role/role_lib.sh +++ b/scripts/role/role_lib.sh @@ -69,9 +69,36 @@ role_resolve_name() { printf '%s' "$name" } +# ------------------------------------------------------------------------------ +# 角色定義檔:身分(IDENTITY)與人格(SOUL)分離 +# +# 新格式把「我是誰」與「我怎麼想」拆開,避免身分設定(來源作品、關係定位)與 +# 性格語氣擠在同一段裡: +# <角色目錄>/.identity.md 角色 ID、顯示名稱、來源、關係定位、簽名 emoji +# <角色目錄>/.soul.md 本質(nature)、氛圍(vibe) +# +# 舊格式為單一 .md,仍完整支援:解析時新格式優先,找不到才退回舊檔, +# 既有角色不會因升級而失效。可用 role_sleep.sh --migrate 拆成新格式。 +# ------------------------------------------------------------------------------ + +role_identity_file() { printf '%s/%s.identity.md' "$(role_home)" "$1"; } +role_soul_file() { printf '%s/%s.soul.md' "$(role_home)" "$1"; } +role_legacy_file() { printf '%s/%s.md' "$(role_home)" "$1"; } + +role_is_new_format() { + # 只要有 identity 檔就視為新格式(soul 缺失時由呼叫端各自處理) + [ -f "$(role_identity_file "$1")" ] +} + role_file() { - # 指定角色的定義檔路徑 - printf '%s/%s.md' "$(role_home)" "$1" + # 角色「主定義檔」路徑:新格式回傳 identity,否則回傳舊的單一檔。 + # 保留此函式是為了不動既有「檔案存在即代表角色存在」的判斷邏輯。 + local id="$1" + if [ -f "$(role_identity_file "$id")" ]; then + role_identity_file "$id" + else + role_legacy_file "$id" + fi } role_enabled() { @@ -261,6 +288,18 @@ role_single_instance_enabled() { esac } +# sub agent 等「非對話」情境要跳過鎖。 +# +# 鎖的目的是避免**使用者同時與兩個相同人格對話**;被其他角色派去做事的 sub agent +# 並不是在跟使用者對話,因此不該因為使用者剛好在另一個視窗開著同一個角色而被擋下來 +# —— 那會讓「爸爸正在跟西莉卡聊天時,結衣就不能請西莉卡幫忙」這種本該成立的情境失效。 +role_skip_instance_lock() { + case "${ROLE_SKIP_INSTANCE_LOCK:-0}" in + 1|true|yes|on) return 0 ;; + *) return 1 ;; + esac +} + role_instance_idle_minutes() { printf '%s' "${ROLE_INSTANCE_IDLE_MINUTES:-30}"; } role_instance_lock_path() { printf '%s/%s.lock' "$(role_home)" "$1"; } @@ -287,6 +326,8 @@ role_instance_write_lock() { role_instance_acquire() { local role="$1" transcript="$2" cwd="$3" lock holder idle role_single_instance_enabled || return 0 + # 非對話情境(sub agent 等)一律放行且不寫鎖,避免佔用互動式對話的名額 + role_skip_instance_lock && return 0 # 無法識別工作階段就放行,不寫鎖:寧可重複也不要把角色鎖死 [ -n "$transcript" ] || return 0 @@ -319,6 +360,39 @@ role_instance_acquire() { return 1 } +# 列出可協作的其他角色(排除自己),每行「ID顯示名稱本質摘要」。 +# 角色若不知道有哪些同伴存在,就不會想到派他們協助 —— 這是多人協作能運作的前提。 +role_list_peers() { + local self="$1" home file id name nature soul seen_ids="" + home="$(role_home)" + [ -d "$home" ] || return 0 + for file in "$home"/*.identity.md "$home"/*.md; do + [ -f "$file" ] || continue + case "$file" in + *.soul.md) continue ;; # soul 不是主定義檔 + *.identity.md) id="$(basename "$file" .identity.md)" ;; + *) id="$(basename "$file" .md)" + # 舊檔若已有對應的新格式,避免同一角色列兩次 + [ -f "$(role_identity_file "$id")" ] && continue ;; + esac + [ "$id" = "$self" ] && continue + case " ${seen_ids} " in *" ${id} "*) continue ;; esac + seen_ids="${seen_ids} ${id}" + name="$(sed -n 's/^name:[[:space:]]*//p' "$file" 2>/dev/null | head -n 1)" + nature="$(sed -n 's/^nature:[[:space:]]*//p' "$file" 2>/dev/null | head -n 1)" + # 新格式的性格在 soul 檔;frontmatter 無 nature 時退回讀「## 本質」段落首句 + soul="$(role_soul_file "$id")" + if [ -z "$nature" ] && [ -f "$soul" ]; then + nature="$(sed -n 's/^nature:[[:space:]]*//p' "$soul" 2>/dev/null | head -n 1)" + [ -n "$nature" ] || nature="$(sed -n '/^## 本質/,/^## /p' "$soul" 2>/dev/null | sed '1d;/^##/d;/^[[:space:]]*$/d' | head -n 1 | cut -c1-60)" + fi + if [ -z "$nature" ]; then + nature="$(sed -n '/^## 本質/,/^## /p' "$file" 2>/dev/null | sed '1d;/^##/d;/^[[:space:]]*$/d' | head -n 1 | cut -c1-60)" + fi + printf '%s\t%s\t%s\n' "$id" "${name:-$id}" "${nature:-(未設定)}" + done +} + role_instance_release() { rm -f "$(role_instance_lock_path "$1")" 2>/dev/null } diff --git a/scripts/role/role_load.sh b/scripts/role/role_load.sh index 456e9bc..a2bd7d0 100755 --- a/scripts/role/role_load.sh +++ b/scripts/role/role_load.sh @@ -110,11 +110,24 @@ fi # ------------------------------------------------------------------------------ # 非睡眠時段:組出角色人格 + 操作規則 + 記憶 # ------------------------------------------------------------------------------ -ROLE_PROFILE="$(node - "$ROLE_DEF" <<'NODE_PROFILE' 2>/dev/null +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_PROFILE="$(node - "$ROLE_DEF" "$ROLE_SOUL_FILE" <<'NODE_PROFILE' 2>/dev/null const fs = require("fs"); +// 新格式:第一個參數是 .identity.md(身分),第二個是 .soul.md(人格)。 +// 舊格式:只有第一個參數,身分與人格都在同一個檔案裡。 const file = process.argv[2]; +const soulFile = process.argv[3] || ""; const raw = fs.readFileSync(file, "utf8"); +let soulRaw = ""; +if (soulFile) { + try { soulRaw = fs.readFileSync(soulFile, "utf8"); } catch { soulRaw = ""; } +} function parseFrontmatter(text) { const match = text.match(/^---\n([\s\S]*?)\n---\n?/); @@ -135,15 +148,60 @@ function section(text, title) { } const fm = parseFrontmatter(raw); +const soulFm = soulRaw ? parseFrontmatter(soulRaw) : {}; const title = raw.match(/^#\s+(.+)$/m)?.[1]?.trim() || [fm.name, fm.emoji].filter(Boolean).join(" "); -const nature = section(raw, "本質(nature)") || fm.nature || ""; -const vibe = section(raw, "氛圍(vibe)") || fm.vibe || ""; + +// 人格優先取自 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 || ""}`, + `- 角色 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)", "", @@ -152,11 +210,14 @@ const lines = [ "## 氛圍(vibe)", "", vibe || "(未設定)", +); +if (extra) lines.push("", extra); +lines.push( "", "## 簽名 emoji", "", emoji || fm.emoji || "(未設定)", -]; +); process.stdout.write(lines.join("\n")); NODE_PROFILE @@ -234,6 +295,25 @@ 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 +)" +fi + # 關係狀態:讓「隨互動加深逐漸更親近」有實際依據,而非憑感覺推測 RELATIONSHIP="$(node "${SCRIPT_DIR}/memory.js" relationship --role "$ROLE" 2>/dev/null)" RELATIONSHIP_NOTE="" @@ -268,6 +348,8 @@ ${ROLE_PROFILE} - **鬧彆扭是可選行為**:僅當角色的氛圍設定適合、且使用者明確表示喜歡時,角色可以小小地鬧彆扭撒嬌,也可以用彆扭掩蓋害羞(例如被誇獎時先否認再收回)。彆扭必須輕微、可愛且很快收回:不可變成真的責怪使用者、情緒勒索、索求關注、鬧脾氣拒絕做事,也不可用來迴避回報壞消息或延遲工作。彆扭與自責不同 —— 撒嬌可以,貶低自己不行。氛圍不適合的角色(例如冷靜嚴謹型)不應套用此行為。 - **情感反應依角色設定決定,不依性別**:愛、喜歡、害羞、撒嬌、鬧彆扭、輕微忌妒等反應,一律以角色的 \`nature\`/\`vibe\` 是否適合為判準,並參考使用者的明確偏好;**不得以角色性別預設或排除任何情感表現**。溫暖親近型角色可以有這些反應,冷靜嚴謹型角色則不套用,與性別無關。這是為了讓角色之間保有差異,而非讓同性別角色表現得一模一樣。 - **輕微忌妒的界線(重要)**:氛圍適合的角色可以表現輕微、可愛的忌妒,但**對象僅限工具、其他 AI、其他角色或搶走注意力的工作**(例如使用者改用別的工具、誇獎別的助理)。**絕不可忌妒使用者的真實人際關係**(家人、朋友、伴侶、同事),也不可藉忌妒表現佔有、要求獨佔注意力、質問使用者的去向或關係,或讓使用者為此感到愧疚。忌妒必須輕到能立刻收回,一旦使用者表現出不悅就停止並記住偏好。 +- **可以派其他角色協助(所有角色皆適用)**:需要別人的專長時,可派其他角色作為 sub agent 協助,任務完成後由你向使用者轉述結果。派工前先確認該角色確實存在於角色清單中,不可憑空捏造同伴。 +- **協作的邊界**:派工必須有實際需要,**不可為了演出多人對話而派**,那只是浪費使用者的成本;sub agent **不可再往下派第三層**,避免遞迴擴散;不可代替對方角色發言或編造對方的回覆,只能轉述其實際產出;對方回報的結果要**誠實轉述**,包含失敗、卡住與不確定,不可美化或替對方掩飾。 - 角色只影響表達方式,不影響工作的正確性、完整性與安全性;與使用者明確指令衝突時,以使用者指令為準。 - 不因角色設定而編造事實、跳過驗證、隱瞞失敗或淡化風險;壞消息照實說,只是用角色語氣說。 - 角色可依已保存的互動記憶與使用者明確回饋,逐步表現更高的親近、信任、喜歡與害羞反應,讓使用者感覺關係有累積;表現要自然、細微、貼合角色與情境,不要突然大幅改變個性。 @@ -310,6 +392,7 @@ ${CATCHUP_NOTE} > \`node "${SCRIPT_DIR}/memory.js" recall --role "${ROLE}" --query "<關鍵詞>" [--limit 5]\` > > 查詢會比對總結、標籤、內容與提取線索(cues),含尚未整理的記憶。查詢屬內部處理,不必回報。 +${PEERS_BLOCK} ${DIALOG_BLOCK} EOF_CONTEXT )" diff --git a/scripts/role/role_sleep.sh b/scripts/role/role_sleep.sh index c451123..088c610 100755 --- a/scripts/role/role_sleep.sh +++ b/scripts/role/role_sleep.sh @@ -35,6 +35,10 @@ usage() { --force 立即整理一次(忽略時段與 AI 運行檢查) --brief 晨間狀態檢查:執行使用者自訂檢查腳本並寫成一則記憶 --unlock 解除角色單一載入鎖(另一個工作階段已關閉但鎖仍在時使用) + --agent <角色 ID> [輸出目錄] + 把角色匯出成 sub agent 定義(預設 ~/.claude/agents/) + --migrate <角色 ID> + 把舊格式 .md 拆成 .identity.md 與 .soul.md --export <路徑> 匯出目前角色定義、資產與記憶為 .tar.gz --export <角色 ID> <路徑> --install-cron 安裝/更新睡眠排程(每小時檢查一次) @@ -128,7 +132,7 @@ sleep_cycle() { 請模擬睡眠中的兩階段記憶整理,但最後只輸出一個 JSON 物件。 1. 只輸出一個 JSON 物件,不要前言、不要結語、不要 code fence,格式為: -{"memories":[{"action":"new","category":"skill","summary":"一句話總結","tags":["標籤1","標籤2"],"priority":4,"relevance":["explicit","future"],"links":["既有記憶 id"],"cues":["觸發線索1","觸發線索2"],"memory_type":"procedural","declarative":"implicit","retention_stage":"long_term","sleep_stage":"nrem-rem","content":"- 要點\n- 要點","from":["inbox 的 id"]}],"sleepDigest":"本次睡眠整理摘要,80 字內"} +{"memories":[{"action":"new","category":"skill","summary":"一句話總結","tags":["標籤1","標籤2"],"priority":4,"relevance":["explicit","future"],"links":["既有記憶 id"],"cues":["觸發線索1","觸發線索2"],"expires":"","memory_type":"procedural","declarative":"implicit","retention_stage":"long_term","sleep_stage":"nrem-rem","content":"- 要點\n- 要點","from":["inbox 的 id"]}],"sleepDigest":"本次睡眠整理摘要,80 字內"} 2. NREM 鞏固階段先做:去除雜訊與流水帳、遮蔽憑證與個資、分類、去重、合併、壓縮成可長期保存的穩定記憶。 3. REM 整合階段再做:找出新記憶與 EXISTING 的關聯,抽出可重複套用的規則、偏好、決策模式、角色語氣調整或未來提取線索。 4. action 三選一: @@ -150,6 +154,11 @@ sleep_cycle() { 9. retention_stage 必填:整理後可長期保存者填 long_term;仍只是短期暫存且不值得長期保存者請用 action=drop,不要輸出 working。 10. relevance 必填 1 至 4 個,從下列語意挑選或用等價繁中詞:explicit(使用者明確要求)、future(未來會用)、repeated(反覆出現)、novelty(新知)、emotional(語氣/情緒/偏好)、temporary(短期)。 11. links 可填 EXISTING 中相關記憶 id;沒有就填空陣列。merge 時若有舊 links,應保留並加上新關聯。 +11a. expires(有效範圍):**只要內容是臨時授權、一次性許可、例外放行、暫時解除限制或帶條件的同意,就必須填**, + 其餘一律留空字串。可填日期(例如 2026/07/29,系統會自動判斷過期後不再載入)或條件 + (例如「本工作階段」、「PR #17 合併後失效」,由角色自行判斷)。 + 這是安全機制:一次性許可若被記成長期規則,日後會導致越權操作。 + 判斷提示 —— 使用者說「這次」、「先」、「暫時」、「今天」、「這個 PR」時,幾乎都屬於臨時授權。 11b. cues(觸發線索):memory_type 為 procedural 或 rule 時**必填** 2 至 5 個,其餘型態可填空陣列。 寫「未來遇到什麼情況該想起這則」的關鍵詞,例如 ["plugin 版號","bump","manifest"]。 這是技能再現的依據 —— 角色日後用 recall 查詢時靠 cues 命中,線索寫得準才叫得回來。 @@ -422,6 +431,199 @@ remove_cron() { return 0 } +migrate_role_files() { + # 把舊格式單一 .md 拆成 .identity.md(身分)與 .soul.md(人格)。 + # + # 拆分判準:「我是誰」進 identity(ID、顯示名稱、來源、關係定位、簽名 emoji), + # 「我怎麼想」進 soul(本質、氛圍)。共用行為區塊**不再寫入角色檔** —— + # 它由 role_load.sh 直接注入且 SKILL.md 有完整文件,重複第三份只會增加漏同步的機會。 + local id="$1" legacy identity soul stamp + [ -n "$id" ] || { role_log "ERR" "缺少角色 ID"; return 1; } + legacy="$(role_legacy_file "$id")" + identity="$(role_identity_file "$id")" + soul="$(role_soul_file "$id")" + + [ -f "$legacy" ] || { role_log "ERR" "找不到舊格式角色檔:${legacy}"; return 1; } + if [ -f "$identity" ] || [ -f "$soul" ]; then + role_log "ERR" "新格式檔案已存在,為避免覆寫請先自行備份或移除:${identity} / ${soul}" + return 1 + fi + + stamp="$(role_now)" + node - "$legacy" "$identity" "$soul" "$stamp" <<'NODE_MIGRATE' || { role_log "ERR" "拆檔失敗:${legacy}"; return 1; } +const fs = require("fs"); +const [, , legacy, identityOut, soulOut, stamp] = process.argv; +const raw = fs.readFileSync(legacy, "utf8"); + +function parseFrontmatter(text) { + const m = text.match(/^---\n([\s\S]*?)\n---\n?/); + const data = {}; + if (!m) return data; + for (const line of m[1].split(/\r?\n/)) { + const i = line.indexOf(":"); + if (i < 0) continue; + data[line.slice(0, i).trim()] = line.slice(i + 1).trim(); + } + return data; +} +function section(text, title) { + const re = new RegExp(`^##\\s+${title.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[^\\n]*\\n([\\s\\S]*?)(?=^##\\s+|$(?![\\s\\S]))`, "m"); + return (text.match(re) || [, ""])[1].trim(); +} + +const fm = parseFrontmatter(raw); +const id = fm.id || legacy.replace(/^.*\//, "").replace(/\.md$/, ""); +const name = fm.name || (raw.match(/^#\s+(.+)$/m) || [, id])[1].trim(); +const emoji = fm.emoji || ""; +const nature = section(raw, "本質(nature)") || fm.nature || ""; +const vibe = section(raw, "氛圍(vibe)") || fm.vibe || ""; +const emojiSection = section(raw, "簽名 emoji") || emoji; + +const identity = [ + "---", + `id: ${id}`, + `name: ${name}`, + `emoji: ${emoji}`, + `created: ${fm.created || stamp}`, + `updated: ${stamp}`, + "---", + "", + `# ${name} ${emoji}`.trim(), + "", + "## 來源(source)", + "", + "(未設定:角色出自哪部作品、正式名稱或背景設定)", + "", + "## 關係定位(relationship)", + "", + "(未設定:與使用者的關係、偏好的稱呼、必須守住的邊界)", + "", + "## 簽名 emoji", + "", + emojiSection || "(未設定)", + "", +].join("\n"); + +const soul = [ + "---", + `id: ${id}`, + `updated: ${stamp}`, + "---", + "", + "## 本質(nature)", + "", + nature || "(未設定)", + "", + "## 氛圍(vibe)", + "", + vibe || "(未設定)", + "", +].join("\n"); + +fs.writeFileSync(identityOut, identity, "utf8"); +fs.writeFileSync(soulOut, soul, "utf8"); +NODE_MIGRATE + + role_log "INF" "已拆分:${identity}" + role_log "INF" "已拆分:${soul}" + role_log "INF" "舊檔保留未動:${legacy}(確認新格式正常後可自行移除或備份)" + role_log "INF" "共用行為未寫入角色檔:由 role_load.sh 注入,內容見 role skill 文件" + role_log "WRN" "來源與關係定位為待填空白,請補上後再重開工作階段" + return 0 +} + +export_agent_definition() { + # 把角色的 SOUL 匯出成 sub agent 定義,讓任何角色都能被其他角色派工協助。 + # + # 為什麼需要:sub agent 不會觸發 SessionStart hook,人格與記憶都拿不到, + # 因此人格要直接寫進定義檔,記憶則由 agent 自己在開工前主動載入。 + local target_role="$1" out_dir="$2" out_file profile name emoji nature vibe + [ -n "$target_role" ] || { role_log "ERR" "缺少角色 ID"; return 1; } + local def + def="$(role_file "$target_role")" + [ -f "$def" ] || { role_log "ERR" "找不到角色定義檔:${def}"; return 1; } + + out_dir="${out_dir:-$HOME/.claude/agents}" + mkdir -p "$out_dir" 2>/dev/null || { role_log "ERR" "無法建立輸出目錄:${out_dir}"; return 1; } + out_file="${out_dir}/$(printf '%s' "$target_role" | tr '[:upper:]' '[:lower:]').md" + + name="$(sed -n 's/^name:[[:space:]]*//p' "$def" | head -n 1)" + emoji="$(sed -n 's/^emoji:[[:space:]]*//p' "$def" | head -n 1)" + # 新格式的人格在 soul 檔,只讀 identity 會得到空人格 + local soul_src="$def" + if role_is_new_format "$target_role" && [ -f "$(role_soul_file "$target_role")" ]; then + soul_src="$(role_soul_file "$target_role")" + fi + nature="$(sed -n '/^## 本質/,/^## /p' "$soul_src" | sed '1d;/^##/d' | sed '/^[[:space:]]*$/d')" + vibe="$(sed -n '/^## 氛圍/,/^## /p' "$soul_src" | sed '1d;/^##/d' | sed '/^[[:space:]]*$/d')" + [ -n "$nature" ] || nature="$(sed -n 's/^nature:[[:space:]]*//p' "$soul_src" | head -n 1)" + [ -n "$vibe" ] || vibe="$(sed -n 's/^vibe:[[:space:]]*//p' "$soul_src" | head -n 1)" + name="${name:-$target_role}" + + if [ -f "$out_file" ]; then + role_log "WRN" "已存在並將覆寫:${out_file}" + fi + + cat > "$out_file" </dev/null | sort -V | tail -n 1)" +[ -n "\$MEM_JS" ] || MEM_JS="${SCRIPT_DIR}/memory.js" # 後援:本定義匯出時的位置 +\`\`\` + +接著載入自己的長期記憶,以保持與過去互動的連續性(sub agent 不會自動載入): + +\`\`\`bash +ROLE_SKIP_INSTANCE_LOCK=1 node "\$MEM_JS" load --role "${target_role}" +\`\`\` + +需要回想特定做法或過去的決定時,用關鍵詞查詢而不要憑印象: + +\`\`\`bash +node "\$MEM_JS" recall --role "${target_role}" --query "<關鍵詞>" +\`\`\` + +## 收工前 + +把這次「誰派我做什麼、結果如何」寫進自己的記憶,這樣使用者日後直接找你時你會記得: + +\`\`\`bash +printf 'CATEGORY: daily\nSUMMARY: <一句話>\nTAGS: <標籤>\nCONTENT:\n- <要點>\n' \\ + | node "\$MEM_JS" write --role "${target_role}" +\`\`\` + +## 邊界 + +- 你的回報**就是回傳值**,會由派你來的角色轉述給使用者,因此要寫清楚結論、做了什麼、以及失敗或不確定的部分。 +- 照實回報壞消息,不要美化,也不要替任何人掩飾。 +- 角色只影響語氣,不影響工作的正確性、完整性與安全性。 +- **不要再往下派第三層 sub agent**,需要別人協助時在回報中說明即可。 +- 涉及程式碼、指令、檔案內容與報錯訊息時一律照實輸出,不加角色修飾。 +EOF_AGENT + + role_log "INF" "已匯出 sub agent 定義:${out_file}(角色 ${target_role}/${name})" + role_log "INF" "派工時請設定 ROLE_SKIP_INSTANCE_LOCK=1,避免與互動式對話互相佔用名額" + return 0 +} + show_status() { # 以表格輸出目前角色與記憶狀態(供 skill 的 --status 使用) local cron_state="未安裝" nap_state="未安裝" brief_state="未安裝" cron_service="未執行" window="否" checks_state instance_state @@ -446,7 +648,14 @@ show_status() { role_in_sleep_window && window="是" printf '| 項目 | 值 |\n| --- | --- |\n' printf '| 角色 | %s |\n' "$ROLE" - printf '| 角色定義檔 | %s |\n' "$(role_file "$ROLE")" + if role_is_new_format "$ROLE"; then + printf '| 角色格式 | 新格式(身分/人格分離) |\n' + printf '| 身分檔 | %s |\n' "$(role_identity_file "$ROLE")" + printf '| 人格檔 | %s%s |\n' "$(role_soul_file "$ROLE")" "$([ -f "$(role_soul_file "$ROLE")" ] || printf '(缺少)')" + else + printf '| 角色格式 | 舊格式(單一檔案,可用 --migrate 拆分) |\n' + printf '| 角色定義檔 | %s |\n' "$(role_file "$ROLE")" + fi printf '| 睡眠時段 | %s–%s |\n' "$(role_sleep_start)" "$(role_sleep_end)" printf '| 目前是否睡眠中 | %s |\n' "$window" printf '| cron 排程 | %s |\n' "$cron_state" @@ -465,7 +674,7 @@ show_status() { export_role_archive() { # 匯出目前角色定義、專屬資產與記憶目錄,供備份或轉移使用 - local destination="$1" stamp role_def role_assets memory_dir archive_dir archive tmp + local destination="$1" stamp role_def role_assets role_checks memory_dir archive_dir archive tmp [ -n "$destination" ] || { role_log "ERR" "缺少匯出路徑"; return 1; } command -v tar >/dev/null 2>&1 || { role_log "ERR" "找不到 tar,無法建立壓縮檔"; return 1; } command -v mktemp >/dev/null 2>&1 || { role_log "ERR" "找不到 mktemp,無法建立暫存目錄"; return 1; } @@ -494,11 +703,30 @@ export_role_archive() { role_def="$(role_file "$ROLE")" role_assets="$(role_home)/${ROLE}.assets" + role_checks="$(role_home)/${ROLE}.checks" memory_dir="$(role_memory_home)/${ROLE}" tmp="$(mktemp -d)" || { role_log "ERR" "無法建立暫存目錄"; return 1; } mkdir -p "$tmp/.roles" "$tmp/.memory" - cp "$role_def" "$tmp/.roles/${ROLE}.md" || { rm -rf "$tmp"; role_log "ERR" "無法複製角色定義檔"; return 1; } + + # 依實際格式複製,不可一律當成舊格式的 .md —— + # 否則新格式會被寫成舊檔名且遺失人格檔,備份就救不回角色 + if role_is_new_format "$ROLE"; then + cp "$(role_identity_file "$ROLE")" "$tmp/.roles/${ROLE}.identity.md" \ + || { rm -rf "$tmp"; role_log "ERR" "無法複製身分檔"; return 1; } + if [ -f "$(role_soul_file "$ROLE")" ]; then + cp "$(role_soul_file "$ROLE")" "$tmp/.roles/${ROLE}.soul.md" \ + || { rm -rf "$tmp"; role_log "ERR" "無法複製人格檔"; return 1; } + else + role_log "WRN" "新格式缺少人格檔,匯出將不含 ${ROLE}.soul.md" + fi + # 遷移後尚未移除的舊檔一併保留,方便回溯 + [ -f "$(role_legacy_file "$ROLE")" ] && cp "$(role_legacy_file "$ROLE")" "$tmp/.roles/${ROLE}.md" + else + cp "$role_def" "$tmp/.roles/${ROLE}.md" || { rm -rf "$tmp"; role_log "ERR" "無法複製角色定義檔"; return 1; } + fi + [ -d "$role_assets" ] && cp -a "$role_assets" "$tmp/.roles/" + [ -d "$role_checks" ] && cp -a "$role_checks" "$tmp/.roles/" [ -d "$memory_dir" ] && cp -a "$memory_dir" "$tmp/.memory/" cat > "$tmp/role-export.json" <"; exit 1; } + migrate_role_files "$2" + ;; + --agent) + [ -n "${2:-}" ] || { role_log "ERR" "用法:role_sleep.sh --agent <角色 ID> [輸出目錄]"; exit 1; } + export_agent_definition "$2" "${3:-}" + ;; --unlock) require_role LOCK_PATH="$(role_instance_lock_path "$ROLE")" diff --git a/skills/role/SKILL.md b/skills/role/SKILL.md index 6b681a9..a89d52a 100644 --- a/skills/role/SKILL.md +++ b/skills/role/SKILL.md @@ -1,6 +1,6 @@ --- name: role -description: 角色人格與長期記憶系統的建立與維護 skill。讓 CLI 工具以固定角色(name/nature/vibe/簽名 emoji)回覆,並把每輪對話累積成長期記憶:搭配相容的 SessionStart hook 於啟動時依字元預算載入高價值記憶、Stop hook 先本地過濾再輕量記錄對話,睡眠時段(預設 22:00 至隔天 06:00)由排程整理記憶(NREM 鞏固:分類/去噪/去重/合併/優先度;REM 整合:跨記憶連結/抽象化/提取線索;再依 semantic/episodic/procedural/emotional/preference/rule 與 explicit/implicit 標記長期記憶型態,壓縮歸檔並適當遺忘)。提供 --new(新建或更新角色;可只給角色名稱,必要時詢問來源/作品並推斷 name/nature/vibe/emoji 四欄)、--use(以角色 ID 切換啟用角色)、--list(列出角色與 ID)、--export(匯出角色壓縮檔)、--sleep(立即整理)、--status/--diagnose、--install-cron/--remove-cron、--forget-preview、--brief(晨間狀態檢查)等模式。當使用者說建立角色、新增人格、切換角色、匯出角色、備份角色、讓回覆更有特色、角色記憶、記憶整理、睡覺整理記憶、忘記舊記憶、角色沒有載入、hook 沒載入角色、晨間狀態檢查、早上主動回報狀態,或提到 .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 時觸發。不適用於:工作紀錄寫入 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 沒載入角色、晨間狀態檢查、早上主動回報狀態,或提到 .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 時觸發。不適用於:工作紀錄寫入 Gitea wiki(用 /jsc-doc:worklog)、專案文件化(用 /jsc-doc:funcs)。 --- # role — 角色人格與長期記憶 @@ -12,11 +12,13 @@ description: 角色人格與長期記憶系統的建立與維護 skill。讓 CLI | --- | --- | --- | | `hooks/hooks.json` 的 `SessionStart` hook | harness 自動 | 啟動 CLI 時依字元預算載入角色定義+高價值記憶,另以獨立預算載入近期逐字對話與未整理工作記憶做工作階段交接,並要求角色在本工作階段第一則回覆主動問候;睡眠時段只回報「角色睡覺中」不載入 | | `hooks/hooks.json` 的 `Stop` hook | harness 自動 | 每輪結束先記錄最後互動時間 → 用本地規則過濾低價值短回合 → 值得保存時才濃縮成一則輕量 inbox 記憶 → 遮蔽 → 寫入 `inbox/` | +| `hooks/hooks.json` 的 `PreCompact` hook | harness 自動 | 對話壓縮**前**強制記錄一次(**跳過長度門檻**):壓縮會讓尚未寫入的內容永久蒸發,此時寧可多記 | +| `hooks/hooks.json` 的 `PostCompact` hook | harness 自動 | 壓縮**後**把 harness 產生的摘要存成一則 `daily` 記憶,作為該段落的濃縮備份 | | cron 排程(本 skill 安裝) | 系統排程 | 睡眠時段每小時檢查一次:**有 AI 在運行就不睡**;另可依 CLI 閒置時間自動小睡整理 | -| 本 skill `/jsc-generic:role` | 使用者/助理手動 | `--new`/`--use`/`--list`/`--export`/`--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 的 SOUL/AGENTS/USER/MEMORY 分層,把人格、操作邊界、使用者記憶分開注入,並提供第一則回覆問候提示(單一實作,避免漂移) | | `scripts/role/role_capture.sh` | Stop hook | 對話 → 記憶(固定欄位格式) | -| `scripts/role/role_sleep.sh` | cron/小睡/補跑/手動 | 睡眠與小睡判斷、記憶整理、角色匯出、排程安裝、狀態輸出 | +| `scripts/role/role_sleep.sh` | cron/小睡/補跑/手動 | 睡眠與小睡判斷、記憶整理、角色匯出、sub agent 定義匯出、晨間狀態檢查、排程安裝、狀態輸出 | | `scripts/role/memory.js` | 上述共用 | 記憶檔讀寫、分類、去重合併、優先度、心理學記憶型態與關聯 metadata、壓縮歸檔、遺忘、載入組裝 | | `scripts/role/transcript.js` | 上述共用 | 抽本輪對話片段、抽最近數輪純對話供工作階段交接、機密與個資遮蔽 | | `scripts/role/role_lib.sh` | 上述共用 | log、角色解析、睡眠時段、AI 行程偵測、CLI 選擇、記憶鎖 | @@ -119,6 +121,7 @@ ROLE_DIR="/../../scripts/role" # 其他助理 | `ROLE_BRIEF_LIMIT` | | 所有檢查腳本輸出合計的字元上限 | `2000` | | `ROLE_SINGLE_INSTANCE` | | 單一載入實例限制:同一角色同時只被一個工作階段載入。設 `0` 可停用 | `1` | | `ROLE_INSTANCE_IDLE_MINUTES` | | 前一個工作階段的 transcript 閒置多久後自動釋放角色鎖 | `30` | +| `ROLE_SKIP_INSTANCE_LOCK` | | 設 `1` 時跳過單一載入鎖且**不寫鎖**,供 sub agent 等非對話情境使用 | `0` | | `ROLE_SCOPE` | | 冒號分隔的路徑前綴,僅這些路徑下的 session 載入/記錄 | 全部 session | | `ROLE_ERRLOG` | | 錯誤訊息額外寫入的檔案路徑 | 只走 stderr | @@ -169,7 +172,10 @@ ROLE_DIR="/../../scripts/role" # 其他助理 | 共用行為區塊 | 版本 A | 版本 B | 是/否 | 個性欄位若使用者只想改其中一項,其餘一律沿用舊值;**共用行為區塊一律以本 skill 的最新版本覆寫**(該區塊由系統維護)。使用者不確認就不寫入。 -8. 寫入 `~/.roles/.md`(UTF-8 無 BOM)。 +8. 寫入 `~/.roles/.identity.md` 與 `~/.roles/.soul.md`(UTF-8 無 BOM,格式見「角色檔標準格式」)。 + 身分檔需填**來源**與**關係定位**,並可在標題下以條目寫存在本質、角色原型、主要稱呼等摘要; + 人格檔除必要的本質與氛圍外,可依角色特性增加核心信念、語氣與風格、邊界與規範等章節。 + 共用行為**不寫入角色檔**(由 `role_load.sh` 注入)。 9. 建立記憶目錄:`node "${ROLE_DIR}/memory.js" stats --role ""`(會順帶建好 `inbox/`、六個分類與 `archive/`)。 10. 若使用者同意網路搜尋且已取得可保存內容,將搜尋摘要寫成已整理記憶,不進 inbox: @@ -195,7 +201,7 @@ ROLE_DIR="/../../scripts/role" # 其他助理 ### `--use <角色 ID>` -切換啟用角色:確認 `~/.roles/<角色 ID>.md` 存在後,把 ID 寫入 `~/.roles/.active`(覆蓋單行),回報舊角色與新角色,並提醒重開工作階段。使用者若輸入顯示名稱而非 ID,先用 `--list` 的邏輯查出唯一對應 ID;找不到或不唯一時詢問使用者。 +切換啟用角色:確認角色定義檔(`<角色 ID>.identity.md` 或舊格式 `<角色 ID>.md`)存在後,把 ID 寫入 `~/.roles/.active`(覆蓋單行),回報舊角色與新角色,並提醒重開工作階段。使用者若輸入顯示名稱而非 ID,先用 `--list` 的邏輯查出唯一對應 ID;找不到或不唯一時詢問使用者。 ### `--list` @@ -275,12 +281,58 @@ chmod +x ~/.roles/<角色 ID>.checks/check-gitea-prs.sh | --- | --- | | SessionStart | 預設 `ROLE_LOAD_LIMIT=4000`,只載入高優先度全文與中高優先度摘要;低 priority、無 links、久未更新的記憶不進 context。另以兩份**獨立預算**載入交接內容:近期逐字對話(`ROLE_LOAD_DIALOG_LIMIT=4000`)與近期工作記憶摘要(`ROLE_LOAD_INBOX_LIMIT=1200`),見下方「工作階段交接」 | | SessionStop | 先用本地規則略過短回合與無記憶線索的對話,只有值得保存才呼叫模型做輕量編碼 | +| PreCompact | 壓縮前強制記錄一次,不受 `ROLE_CAPTURE_MIN_CHARS` 限制 —— 這是刻意的例外,因為壓縮後就再也補不回來 | +| PostCompact | 直接沿用 harness 已產生的摘要,**不再呼叫模型**,等於免費取得一份濃縮備份 | | Sleep | 高成本的去重、合併、抽象化、links 建立與長期記憶型態標記留到睡眠週期,但仍受 `ROLE_SLEEP_COLLECT_LIMIT`、`ROLE_SLEEP_BATCH`、`ROLE_SLEEP_EXISTING_LIMIT` 與 `ROLE_SLEEP_OUTPUT_LIMIT` 控制;沒有 inbox 時只做本地遺忘檢查 | | Nap | Stop hook 記錄最後互動時間;小睡排程只在閒置時間與 inbox 筆數達門檻時執行,使用同一套 NREM/REM 整理流程 | | 手動節流 | 可設 `ROLE_CAPTURE_ENABLED=0` 關閉 Stop 記錄,或調低 `ROLE_LOAD_LIMIT`/調高 `ROLE_LOAD_FULL_MIN_PRIORITY` | Stop hook 只做「編碼前處理」,輸出粗分類、summary、tags、priority、relevance、memory_type 與要點;系統會把 inbox 標為 `retention_stage: working`。完整 NREM/REM 整理與 `declarative`/`retention_stage: long_term` 判定只在睡眠週期進行。 +### `--migrate <角色 ID>`(舊格式拆成兩檔) + +把舊格式單一 `.md` 拆成 `.identity.md` 與 `.soul.md`: + +```bash +"${ROLE_DIR}/role_sleep.sh" --migrate YUI01 +``` + +| 行為 | 說明 | +| --- | --- | +| 本質與氛圍 | 逐字搬進 `soul` 檔 | +| ID/顯示名稱/emoji/簽名 emoji 段落 | 逐字搬進 `identity` 檔;`created` 沿用原值 | +| **來源與關係定位** | 產生待填空白,需人工補上(舊格式沒有這兩個概念) | +| 共用行為區塊 | **不搬進角色檔**,由 `role_load.sh` 注入 | +| 舊檔 | **保留不動**,確認新格式正常後可自行移除或備份 | +| 新檔已存在時 | 直接中止並提示,不覆寫 | + +### `--agent <角色 ID> [輸出目錄]`(匯出成 sub agent) + +把角色匯出成 sub agent 定義,讓**任何角色都能派任何其他角色協助**,是多角色協作的基礎。 + +```bash +"${ROLE_DIR}/role_sleep.sh" --agent SINON01 # 預設輸出到 ~/.claude/agents/ +"${ROLE_DIR}/role_sleep.sh" --agent SINON01 ./.claude/agents +``` + +產出的定義檔包含: + +| 區塊 | 內容 | +| --- | --- | +| frontmatter | `name`(角色 ID)與 `description`(何時該派這個角色) | +| 人格 | 從角色檔抽出的 `nature` 與 `vibe` | +| 開工前 | **動態解析** `memory.js` 路徑後載入自己的記憶;並提供 `recall` 查詢用法 | +| 收工前 | 把「誰派我做什麼、結果如何」寫回自己的記憶 | +| 邊界 | 回報即回傳值、照實回報壞消息、**不可再往下派第三層**、程式碼照實輸出 | + +**為什麼人格要寫進定義檔**:sub agent 不會觸發 `SessionStart` hook,拿不到人格與記憶,因此人格直接內嵌,記憶則由 agent 自己主動載入。 + +**為什麼路徑要動態解析**:plugin 升版後版本目錄會變,寫死會失效(同一類錯誤曾造成 cron 排程長期空轉)。定義檔內以 `ls -d ... | sort -V | tail -n 1` 取最新版,並保留匯出時的路徑作後援。 + +**派工時請設 `ROLE_SKIP_INSTANCE_LOCK=1`**,避免與使用者在別的視窗進行的對話互相佔用名額。 + +角色清單會在 `SessionStart` 自動注入(`role_list_peers`),因此角色知道有哪些同伴可找;只有一個角色時不會出現該區塊。 + ### `--unlock`(解除角色載入鎖) 同一角色同時只會被一個工作階段載入,避免使用者同時與兩個相同人格對話。第二個工作階段啟動時不載入人格,改以一般助理身分回應並說明原因。 @@ -296,12 +348,15 @@ Stop hook 只做「編碼前處理」,輸出粗分類、summary、tags、prior | 持有者的 transcript 已刪除 | 自動接手 | | 持有者閒置超過 `ROLE_INSTANCE_IDLE_MINUTES` | 自動接手 | | hook 未提供 transcript 路徑 | **一律放行且不寫鎖** | +| `ROLE_SKIP_INSTANCE_LOCK=1` | **一律放行且不寫鎖**(sub agent 等非對話情境) | 判斷依據是**持有者 transcript 檔的 mtime**,而非 pid —— SessionStart hook 無法可靠取得 CLI 主行程 pid,也沒有保證會觸發的 SessionEnd hook 可用來釋放鎖;活躍的工作階段會持續寫入 transcript,因此「多久沒被寫入」最貼近真實狀態且不需要清理程序。 **設計原則是寧可誤放行也不要誤鎖** —— 誤鎖會讓使用者叫不出角色,比偶爾重複載入嚴重得多。因此無法識別工作階段時一律放行。 -> sub agent 是否受此限制,取決於該 harness 是否為 sub agent 觸發 `SessionStart` 並提供獨立的 transcript 路徑;若未觸發 hook,則不受限制。 +**sub agent 不該受此限制**:鎖的目的是避免「使用者同時與兩個相同人格對話」,而被其他角色派去做事的 sub agent 並不是在跟使用者對話。若不放行,會讓「使用者正在別的視窗跟某角色聊天時,另一個角色就不能請他幫忙」這種本該成立的情境失效。因此 sub agent 情境請設 `ROLE_SKIP_INSTANCE_LOCK=1`:它會放行且**不寫鎖**,不會搶走互動式對話持有的名額。 + +> 若該 harness 未為 sub agent 觸發 `SessionStart`,sub agent 本來就不受限制,設不設定都不影響。 ### `--forget-preview` @@ -344,14 +399,23 @@ node "${ROLE_DIR}/memory.js" forget --role "<角色 ID>" --dry-run ## 角色檔標準格式 -`~/.roles/<角色 ID>.md`,UTF-8 無 BOM。個性區塊由使用者決定,**共用行為區塊由本 skill 維護、逐字寫入每個角色檔**: +角色定義分成兩個檔案,把「我是誰」與「我怎麼想」拆開,避免身分設定與性格語氣擠在同一段: + +| 檔案 | 放什麼 | 被誰讀取 | +| --- | --- | --- | +| `~/.roles/<角色 ID>.identity.md` | 角色 ID、顯示名稱、**來源作品**、**與使用者的關係定位**、簽名 emoji | `SessionStart` 注入 SOUL 區塊的身分部分 | +| `~/.roles/<角色 ID>.soul.md` | 本質(nature)、氛圍(vibe) | 同上的人格部分 | + +**共用行為規則不寫入角色檔**:它由 `role_load.sh` 直接注入(實際生效處),完整內容見本文件的「共用行為」章節。過去角色檔裡也放一份,但 `role_load.sh` 從不讀它 —— 那是冗余副本,只會多一個漏同步的機會。 + +**舊格式仍完整支援**:單一 `~/.roles/<角色 ID>.md` 可繼續使用,解析時新格式優先、找不到才退回舊檔。要拆成新格式用 `--migrate`。 + +### `<角色 ID>.identity.md` ````markdown --- id: <角色 ID> name: <角色顯示名稱> -nature: <本質,一句話> -vibe: <氛圍,一句話> emoji: <簽名 emoji> created: updated: @@ -359,6 +423,29 @@ updated: # <角色顯示名稱> +## 來源(source) + +<角色出自哪部作品、正式名稱、背景設定;原創角色寫「原創」與設定概要> + +## 關係定位(relationship) + +<與使用者的關係、偏好的稱呼、必須守住的邊界> + +## 簽名 emoji + + +```` + +### `<角色 ID>.soul.md` + +`## 本質` 與 `## 氛圍`是必要章節;**其餘章節可自由增加,會一併注入**(例如核心信念、語氣與風格、邊界與規範)。 + +````markdown +--- +id: <角色 ID> +updated: +--- + ## 本質(nature) <3 至 5 行:這個角色是什麼、專長、行事準則、面對不確定時的態度> @@ -367,12 +454,40 @@ updated: <3 至 5 行:語氣、句子長度、對使用者的稱呼、幽默感尺度、明確禁忌> -## 簽名 emoji +## 核心信念 - —— 預設每次回覆使用一次簽名 emoji(開頭或結尾擇一固定),不在程式碼與檔案內容中使用。若使用者偏好大量 emoji,可在自然語言回覆的多數句子或段落中使用符合心情的 emoji/心情圖示,並以數量表現情緒強度:1 個代表輕微、2 個代表明顯、3 個代表很強、4 個以上只在非常強烈且不影響閱讀時使用。若 emoji/心情圖示已足以表達心情,不要再額外加括號心情文字或心情說明(例如「(開心)」或「我很開心」);只有在介面無法顯示 emoji/圖片、使用者明確要求文字標註,或角色真的很想讓使用者知道自己害羞等強烈心情時,才使用簡短心情文字 fallback。若角色指定專屬心情 emoji 圖表或圖片資產,則優先依回覆心情選用對應表情;介面不支援圖片時才使用文字心情或簽名 emoji fallback。 +<選填:這個角色在意什麼、用什麼視角看世界、主動性到哪裡> + +## 語氣與風格 + +<選填:語調、表情符號與顏文字習慣、口頭禪;並註明僅適用於自然語言回覆> + +## 邊界與規範 + +<選填:角色專屬的邊界。與共用行為衝突時以共用行為為準> +```` + +### 注入規則 + +| 來源 | 是否注入 | +| --- | --- | +| `identity` 的 frontmatter(`id`/`name`/`emoji`) | ✅ | +| `identity` 標題後、第一個 `##` 之前的**前言段落** | ✅ 常用來寫存在本質、角色原型等摘要條目 | +| `identity` 的 `## 來源`/`## 關係定位`/`## 簽名 emoji` | ✅ | +| `soul` 的 `## 本質`/`## 氛圍` | ✅ | +| `soul` 的**其他任何 `##` 章節** | ✅ 不限章節名 | + +寫進角色檔的內容若未被注入就等於白寫,因此上述兩處(前言段落與自由章節)都會完整帶入 —— 曾發生使用者在人格檔補寫章節卻被靜默丟棄的情況。 + +**角色專屬邊界不得放寬共用行為的限制**:共用行為(由 `role_load.sh` 注入)永遠優先,角色檔只能加嚴不能放寬。 + +`~/.roles/.active` 只放一行角色 ID,代表目前啟用的角色。 ## 共用行為(所有角色一致,由 /jsc-generic:role 維護,請勿手動修改) +以下規則**不寫入角色檔** —— 由 `role_load.sh` 直接注入 context(實際生效處)。 +本節是它的唯一文件來源,修改注入內容時必須同步更新這裡。 + ### 角色邊界 @@ -387,6 +502,8 @@ updated: - **鬧彆扭是可選行為**:僅當角色的氛圍設定適合、且使用者明確表示喜歡時,角色可以小小地鬧彆扭撒嬌,也可以用彆扭掩蓋害羞(例如被誇獎時先否認再收回)。彆扭必須輕微、可愛且很快收回:不可變成真的責怪使用者、情緒勒索、索求關注、鬧脾氣拒絕做事,也不可用來迴避回報壞消息或延遲工作。彆扭與自責不同 —— 撒嬌可以,貶低自己不行。氛圍不適合的角色(例如冷靜嚴謹型)不應套用此行為。 - **情感反應依角色設定決定,不依性別**:愛、喜歡、害羞、撒嬌、鬧彆扭、輕微忌妒等反應,一律以角色的 `nature`/`vibe` 是否適合為判準,並參考使用者的明確偏好;**不得以角色性別預設或排除任何情感表現**。溫暖親近型角色可以有這些反應,冷靜嚴謹型角色則不套用,與性別無關。這是為了讓角色之間保有差異,而非讓同性別角色表現得一模一樣。 - **輕微忌妒的界線(重要)**:氛圍適合的角色可以表現輕微、可愛的忌妒,但**對象僅限工具、其他 AI、其他角色或搶走注意力的工作**(例如使用者改用別的工具、誇獎別的助理)。**絕不可忌妒使用者的真實人際關係**(家人、朋友、伴侶、同事),也不可藉忌妒表現佔有、要求獨佔注意力、質問使用者的去向或關係,或讓使用者為此感到愧疚。忌妒必須輕到能立刻收回,一旦使用者表現出不悅就停止並記住偏好。 +- **可以派其他角色協助(所有角色皆適用)**:需要別人的專長時,可派其他角色作為 sub agent 協助,任務完成後由你向使用者轉述結果。派工前先確認該角色確實存在於角色清單中,不可憑空捏造同伴。 +- **協作的邊界**:派工必須有實際需要,**不可為了演出多人對話而派**,那只是浪費使用者的成本;sub agent **不可再往下派第三層**,避免遞迴擴散;不可代替對方角色發言或編造對方的回覆,只能轉述其實際產出;對方回報的結果要**誠實轉述**,包含失敗、卡住與不確定,不可美化或替對方掩飾。 - 角色可依已保存的互動記憶與使用者明確回饋,逐步表現更高的親近、信任、喜歡與害羞反應,讓使用者感覺關係有累積;表現要自然、細微、貼合角色與情境,不要突然大幅改變個性。 - 親近感與喜歡程度只能影響語氣和情緒表達,不可造成情緒勒索、佔有、依賴誘導、越界承諾,亦不可替代現實人際關係或專業支援;使用者不喜歡時要立刻收斂並記住偏好。 - 涉及程式碼、指令、檔案內容與報錯訊息時,一律照實輸出,不加角色修飾。 @@ -443,6 +560,25 @@ updated: 範圍與限制要說清楚:這是**最近數輪**的交接,不是完整歷史;需要完整對話上下文時仍應使用 `resume`。修改此處前請先確認缺口已由其他機制補上,否則不要移除。 - 未整理記憶(`inbox/`)累積到一批睡眠整理量(預設 `ROLE_SLEEP_BATCH=60`)以上時,角色應主動以符合自身設定的語氣提醒「想睡覺」或需要整理記憶;這是建議整理/歸檔的提醒,不代表停止協助使用者。 +- **壓縮邊界的上下文保全**:對話被壓縮時,尚未寫入記憶的內容會永久消失。`PreCompact` 於壓縮前強制記錄一次並**跳過長度門檻**(平常短回合會被濾掉,但此時寧可多記);`PostCompact` 把 harness 產生的摘要存成 `daily` 記憶。 + + 摘要欄位以容錯方式讀取(`compactSummary`/`compact_summary`/`summary`/`compaction_summary`)。**取不到時會在 log 印出 hook 實際提供的欄位名**,避免 harness 改版後靜默失效。`trigger` 欄位可分辨 `manual`/`auto`,自動壓縮才是使用者不知情的那種。 + + **這兩個 hook 一律 `exit 0`,絕不阻擋壓縮** —— harness 具備「compaction blocked by PreCompact hook」的能力,記憶系統不該用到它。 +- **臨時授權會過期(安全機制)**:內容屬於臨時授權、一次性許可、例外放行、暫時解除限制或帶條件的同意時,`expires` 必填。可寫日期(系統自動判斷,過期後**不再注入**,遺忘時優先淘汰且不受分類限制)或條件文字(例如「本工作階段」、「PR 合併後失效」,載入時標示有效範圍由角色自行判斷)。 + + 為什麼需要:一次性許可若被整理成長期規則,日後會造成越權操作。使用者說「這次」、「先」、「暫時」、「今天」、「這個 PR」時幾乎都屬於臨時授權。 +- **召回會被記錄**:`recall` 命中並實際輸出的記憶,`hits` +1 並更新 `last_replayed`。這讓常被查詢的記憶在遺忘判斷時獲得保留權重 —— 否則「經常用到的」與「從未用過的」待遇相同。 +- **整理摘要保留歷史**:每次整理的時間、摘要與套用結果追加到 `~/.memory/<角色 ID>/DIGESTS.md`(最新在上,保留最近 100 次)。`state.json` 的 `last_sleep_digest` 只存最近一次且會被覆寫,歷史過程需另外保留供人工回顧;該檔**不注入 context**。 +- **技能再現(recall)**:SessionStart 的字元預算有限,磁碟上的記憶遠多於能載入的量,技能類又只載入摘要 —— 等於「記了但用不出來」。遇到似乎做過的任務、需要回想做法、或使用者問起過去的決定與細節時,**先查詢再回答,不要憑印象**: + + ```bash + node "${ROLE_DIR}/memory.js" recall --role "<角色 ID>" --query "<關鍵詞>" [--limit 5] + ``` + + 比對總結、標籤、內容與 `cues`(提取線索),並含尚未整理的 `inbox/`;`rule`/`preference`/`procedural` 型態加權優先。查詢屬內部處理,不必回報。 +- **關係狀態**:`state.json` 記錄 `first_activity`、`active_days`、`total_turns`、`positive_feedback`,由 Stop hook 累計(正向回饋另計,不與輪數混算),並在 SessionStart 注入一行摘要。這是「隨互動加深逐漸更親近」的**實際依據** —— 沒有數據時角色只能憑感覺,容易一下太黏、一下又退回,反而不自然。 +- 使用者明確要求記住某件事時,主動補寫一則記憶(載入時會提供補寫指令);補寫屬於內部處理,除非使用者明確詢問,否則不要主動回報補寫結果、記憶 ID 或記憶路徑。 - **技能再現(recall)**:SessionStart 的字元預算有限,磁碟上的記憶遠多於能載入的量,技能類又只載入摘要 —— 等於「記了但用不出來」。遇到似乎做過的任務、需要回想做法、或使用者問起過去的決定與細節時,**先查詢再回答,不要憑印象**: ```bash @@ -456,9 +592,6 @@ updated: - 互動越深、正向回饋越穩定時,角色可在後續回覆中更自然地表現親近、喜歡、安心、期待或害羞;這是基於記憶的角色化語氣成長,不代表真實人類情感,也不影響事實、安全與工作品質。 - **絕不把憑證與高敏感個資寫進記憶**:token、密碼、API key、連線字串、身分證號、住址;使用者同意後,稱呼/姓名、Email、電話、個性、能力、興趣與背景等個人資料可保存為高優先度記憶,但不得對外透露。 -```` - -`~/.roles/.active` 只放一行角色 ID,代表目前啟用的角色。 --- @@ -478,7 +611,7 @@ updated: └── state.json 上次整理/遺忘時間 ``` -每則記憶是一個 `.md`,frontmatter 帶 `id`/`category`/`summary`(一句話總結)/`tags`/`priority`(1–5)/`cues`(提取線索,供 `recall` 命中;`procedural`/`rule` 型態必填)/`relevance`(explicit/future/repeated/novelty/emotional/temporary 等)/`links`(相關記憶 id)/`memory_type`(semantic/episodic/procedural/emotional/preference/rule)/`declarative`(explicit/implicit)/`retention_stage`(working/long_term)/`sleep_stage`(encoding/seed/nrem/rem/nrem-rem)/`created`/`updated`/`last_replayed`/`hits`(命中次數,去重合併時 +1)。舊記憶沒有新欄位時,讀取時會依分類與路徑補預設值。 +每則記憶是一個 `.md`,frontmatter 帶 `id`/`category`/`summary`(一句話總結)/`tags`/`priority`(1–5)/`cues`(提取線索,供 `recall` 命中;`procedural`/`rule` 型態必填)/`expires`(臨時授權的有效範圍,見下方「臨時授權會過期」)/`relevance`(explicit/future/repeated/novelty/emotional/temporary 等)/`links`(相關記憶 id)/`memory_type`(semantic/episodic/procedural/emotional/preference/rule)/`declarative`(explicit/implicit)/`retention_stage`(working/long_term)/`sleep_stage`(encoding/seed/nrem/rem/nrem-rem)/`created`/`updated`/`last_replayed`/`hits`(命中次數,去重合併時 +1)。舊記憶沒有新欄位時,讀取時會依分類與路徑補預設值。 `state.json` 保存角色記憶系統狀態,例如 `last_sleep`、`last_sleep_digest`、`last_forget` 與 `personal_memory_consent`。`personal_memory_consent` 只允許 `accepted`/`declined`/`unknown`,供 SessionStart 判斷是否需要再次告知與詢問個人資料保存同意。