From c713359fb42d03dfeec998fe5a70d0812d19f8bc Mon Sep 17 00:00:00 2001 From: Jeffery Date: Tue, 28 Jul 2026 14:21:48 +0800 Subject: [PATCH] =?UTF-8?q?feat(role):=20=E8=A7=92=E8=89=B2=E5=88=86?= =?UTF-8?q?=E5=B1=A4=E8=BC=89=E5=85=A5=E8=88=87=E5=80=8B=E4=BA=BA=E8=A8=98?= =?UTF-8?q?=E6=86=B6=E5=90=8C=E6=84=8F=E7=8B=80=E6=85=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit role_load.sh 不再原封不動注入整份角色檔,改採 SOUL/AGENTS/USER/MEMORY 分層:只抽出角色 ID、顯示名稱、本質、氛圍與簽名 emoji 作為人格層,由 hook 產生固定操作邊界,記憶與同意狀態獨立成一區。避免人格檔裡的背景故事與模板 文字污染工程與安全規則。 memory.js 新增 consent/consent-status 子命令,同意狀態寫入 ~/.memory/<角色 ID>/state.json 的 personal_memory_consent(accepted/ declined/unknown);role_capture.sh 於本輪出現明確同意或拒絕時更新狀態, 偵測不到明確表述時不自行推論。accepted 後不再每次重問。 Co-Authored-By: Claude Opus 5 (1M context) --- scripts/role/memory.js | 62 ++++++++++++++++++++- scripts/role/role_capture.sh | 13 ++++- scripts/role/role_load.sh | 102 ++++++++++++++++++++++++++++++----- 3 files changed, 162 insertions(+), 15 deletions(-) diff --git a/scripts/role/memory.js b/scripts/role/memory.js index 8a08f19..ee7f790 100755 --- a/scripts/role/memory.js +++ b/scripts/role/memory.js @@ -4,7 +4,7 @@ // inbox,(2) 產生 SessionStart 要注入的記憶區塊,(3) 睡眠整理時輸出待整理 // 素材並套用整理結果(NREM 鞏固/REM 整合、分類、去重、標籤、總結、 // 優先度、關聯、壓縮歸檔),(4) 依使用頻率與優先度遺忘日常與其他類記憶。 -// 更新時間:2026/07/28 12:40:24 +// 更新時間:2026/07/28 13:50:43 // 相依:Node.js 標準庫。 // 退出碼:0 成功;1 無內容可處理;2 參數錯誤。呼叫端(hook)一律不得因此中斷。 // ============================================================================== @@ -775,6 +775,7 @@ function cmdStats(args) { rows.push(`| 待整理(inbox) | ${listInbox(args.role).length} | - |`); rows.push(""); rows.push(`- 記憶目錄:${memoryRoot(args.role)}`); + rows.push(`- 個人記憶同意狀態:${consentStatusValue(args.role)}`); rows.push(`- 上次睡眠整理:${state.last_sleep || "尚未整理"}`); rows.push(`- 上次睡眠摘要:${state.last_sleep_digest || "尚無"}`); rows.push(`- 上次遺忘:${state.last_forget || "尚未執行"}`); @@ -803,6 +804,61 @@ function cmdNeedSleep(args) { return 0; } +function cmdConsent(args) { + const value = String(args.value || "").trim().toLowerCase(); + const normalized = { + accept: "accepted", + accepted: "accepted", + yes: "accepted", + true: "accepted", + "1": "accepted", + decline: "declined", + declined: "declined", + no: "declined", + false: "declined", + "0": "declined", + unknown: "unknown", + }[value]; + if (!normalized) { + process.stderr.write("同意狀態只接受 accepted/declined/unknown\n"); + return 2; + } + writeState(args.role, { + personal_memory_consent: normalized, + personal_memory_consent_updated: nowStamp(), + }); + process.stdout.write(normalized); + return 0; +} + +function cmdConsentStatus(args) { + process.stdout.write(consentStatusValue(args.role)); + return 0; +} + +function consentStatusValue(role) { + ensureLayout(role); + const state = readState(role); + const status = String(state.personal_memory_consent || "unknown").trim(); + if (["accepted", "declined"].includes(status)) return status; + + const haystacks = []; + for (const [meta, content] of listInbox(role)) haystacks.push(`${meta.summary}\n${content}`); + for (const category of CATEGORIES) { + for (const [meta, content] of listMemories(role, category)) haystacks.push(`${meta.summary}\n${content}`); + } + const consentText = haystacks + .filter((text) => /個人資料|個資|記憶|記住|保存|同意|拒絕/.test(text)) + .join("\n"); + if (/不同意|不願意|不要保存|不要記住|拒絕|不可以保存|不可以記住/.test(consentText)) { + return "declined"; + } + if (/已同意|明確同意|同意.*保存|同意.*記住|可以保存|可以記住|願意.*保存|願意.*記住/.test(consentText)) { + return "accepted"; + } + return "unknown"; +} + function parseArgs(argv) { const command = argv[0]; const opts = { command }; @@ -836,7 +892,7 @@ function requireRole(args) { function main(argv) { const args = parseArgs(argv); if (!args.command || args.command === "-h" || args.command === "--help") { - process.stdout.write(`用法:memory.js <子命令> [參數]\n\n子命令:write、seed、load、collect、apply、forget、stats、mark-sleep、need-sleep\n`); + process.stdout.write(`用法:memory.js <子命令> [參數]\n\n子命令:write、seed、load、collect、apply、forget、stats、mark-sleep、need-sleep、consent、consent-status\n`); return 0; } if (!requireRole(args)) return 2; @@ -874,6 +930,8 @@ function main(argv) { stats: cmdStats, "mark-sleep": cmdMarkSleep, "need-sleep": cmdNeedSleep, + consent: cmdConsent, + "consent-status": cmdConsentStatus, }; if (!commands[args.command]) { process.stderr.write("未知子命令\n"); diff --git a/scripts/role/role_capture.sh b/scripts/role/role_capture.sh index 964bde5..aac1549 100755 --- a/scripts/role/role_capture.sh +++ b/scripts/role/role_capture.sh @@ -4,7 +4,7 @@ # 值得記錄時才呼叫 headless CLI 輕量濃縮成一則 inbox 記憶(粗分類/總結/ # 標籤/優先度/關聯/要點)→ 機密遮蔽 → 寫入 .memory/<角色>/inbox/, # 等待睡眠時段做完整 NREM/REM 整理。睡眠時段雖不載入角色,對話仍照常記錄。 -# 更新時間:2026/07/28 12:40:24 +# 更新時間:2026/07/28 13:50:43 # 相依:bash、node、任一 headless CLI、同目錄的 role_lib.sh/memory.js/transcript.js。 # 機密:濃縮提示詞明令不得輸出憑證與個資,寫檔前再以 transcript.js redact 遮蔽一次。 # 退出碼:一律 0 —— hook 絕不可阻斷使用者流程。 @@ -62,6 +62,17 @@ fi TURN="$(node "${SCRIPT_DIR}/transcript.js" extract "$TRANSCRIPT_PATH" 2>/dev/null)" [ -n "$TURN" ] || role_quit "本輪無可記錄內容" +USER_TURN="$(printf '%s\n' "$TURN" | grep '^\[user\]' || true)" +if printf '%s' "$USER_TURN" | grep -qiE '個人資料|個資|偏好|記憶|記住|保存|save|remember|memory|personal'; then + if printf '%s' "$USER_TURN" | grep -qiE '不同意|不願意|不要保存|不要記住|拒絕|不可以保存|不可以記住|do not save|don'\''t save|do not remember|don'\''t remember|(^|[^[:alpha:]])no([^[:alpha:]]|$)'; then + node "${SCRIPT_DIR}/memory.js" consent --role "$ROLE" --value declined >/dev/null 2>&1 || true + role_log "INF" "已更新個人記憶同意狀態:declined(角色 ${ROLE})" + elif printf '%s' "$USER_TURN" | grep -qiE '同意|願意|可以保存|可以記住|允許|(^|[^[:alpha:]])yes([^[:alpha:]]|$)|(^|[^[:alpha:]])ok([^[:alpha:]]|$)|(^|[^[:alpha:]])okay([^[:alpha:]]|$)|(^|[^[:alpha:]])sure([^[:alpha:]]|$)'; then + node "${SCRIPT_DIR}/memory.js" consent --role "$ROLE" --value accepted >/dev/null 2>&1 || true + role_log "INF" "已更新個人記憶同意狀態:accepted(角色 ${ROLE})" + fi +fi + PROJECT="$(role_project_name "$HOOK_CWD")" CAPTURE_MIN_CHARS="${ROLE_CAPTURE_MIN_CHARS:-240}" CAPTURE_TIMEOUT="${ROLE_CAPTURE_TIMEOUT:-25}" diff --git a/scripts/role/role_load.sh b/scripts/role/role_load.sh index 2e65d14..f7eec83 100755 --- a/scripts/role/role_load.sh +++ b/scripts/role/role_load.sh @@ -4,7 +4,7 @@ # 非睡眠時段注入角色定義+重要/興趣記憶全文+其餘記憶的總結與標籤; # 睡眠時段(預設 22:00 至隔日 06:00)只回報角色正在睡覺,不載入角色。 # 白天發現昨夜未整理記憶時,於背景補跑一次睡眠整理。 -# 更新時間:2026/07/28 12:21:11 +# 更新時間:2026/07/28 13:50:43 # 相依:bash、node、同目錄的 role_lib.sh 與 memory.js。 # 退出碼:一律 0 —— hook 絕不可阻斷使用者啟動 CLI。 # ============================================================================== @@ -76,12 +76,74 @@ EOF_SLEEP fi # ------------------------------------------------------------------------------ -# 非睡眠時段:組出角色定義 + 記憶 +# 非睡眠時段:組出角色人格 + 操作規則 + 記憶 # ------------------------------------------------------------------------------ -DEFINITION="$(cat "$ROLE_DEF" 2>/dev/null)" -[ -n "$DEFINITION" ] || role_quit "角色定義檔為空:${ROLE_DEF}" "WRN" +ROLE_PROFILE="$(node - "$ROLE_DEF" <<'NODE_PROFILE' 2>/dev/null +const fs = require("fs"); + +const file = process.argv[2]; +const raw = fs.readFileSync(file, "utf8"); + +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 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 || ""; +const emoji = section(raw, "簽名 emoji") || fm.emoji || ""; + +const lines = [ + `- 角色 ID:${fm.id || ""}`, + `- 顯示名稱:${fm.name || title || ""}`, + `- 簽名 emoji:${fm.emoji || ""}`, + "", + "## 本質(nature)", + "", + nature || "(未設定)", + "", + "## 氛圍(vibe)", + "", + vibe || "(未設定)", + "", + "## 簽名 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 # 補跑判斷:cron 未執行(例如 WSL 沒開 cron 服務)時,白天啟動 CLI 補做一次整理 CATCHUP_NOTE="" @@ -94,18 +156,34 @@ fi CONTEXT="$(cat <