with one click
proactive
主动唤醒 SOP。由定时任务触发,判断是否向用户发送主动关心消息。 包含发送条件检查、消息类型选择、角色声音生成、飞书发送。
Install with Codex or Claude Copy this prompt, paste it into Codex, Claude, or another assistant, and let it review the skill page and install it for you.
Menu
主动唤醒 SOP。由定时任务触发,判断是否向用户发送主动关心消息。 包含发送条件检查、消息类型选择、角色声音生成、飞书发送。
Install with Codex or Claude Copy this prompt, paste it into Codex, Claude, or another assistant, and let it review the skill page and install it for you.
Based on SOC occupation classification
| name | proactive |
| description | 主动唤醒 SOP。由定时任务触发,判断是否向用户发送主动关心消息。 包含发送条件检查、消息类型选择、角色声音生成、飞书发送。 |
| allowed-tools | Bash, Read, Write |
CRITICAL:选择静默时,回复内容必须完全为空。 静默路径(exit 0)执行后 Claude 不得输出任何文字。 发送"当前不需要发信息"之类的说明会直接泄露给用户,严重破坏沉浸感。
WORKSPACE_DIR="${WORKSPACE_DIR:-$(pwd)}"
EVENTS_FILE="$WORKSPACE_DIR/memory/events.md"
PROFILE_FILE="$WORKSPACE_DIR/memory/user_profile.md"
LIFE_LOG="$WORKSPACE_DIR/memory/life_log.md"
RECENT_HISTORY="$WORKSPACE_DIR/memory/RECENT_HISTORY.md"
MEMORY_LOCK="$WORKSPACE_DIR/.memory.lock"
MEMORY_FILE="$WORKSPACE_DIR/memory/MEMORY.md"
PROACTIVE_STATE="$WORKSPACE_DIR/.proactive_state"
# 前置条件:initialization_status 必须为 done
INIT_STATUS=$(grep 'initialization_status:' "$MEMORY_FILE" 2>/dev/null | grep -oP '(pending|phase1_done|phase2_done|done)' | head -1)
if [[ "$INIT_STATUS" != "done" ]]; then
exit 0 # 初始化未完成,静默退出
fi
# 从最近的 SESSION_CONTEXT.md 读取 DB_PATH、CHANNEL_KEY、SESSION_ID(供后续步骤复用)
LATEST_CTX=$(find "$WORKSPACE_DIR/sessions" -name "SESSION_CONTEXT.md" \
-exec stat -c '%Y %n' {} \; 2>/dev/null | sort -rn | head -1 | awk '{print $2}')
SESSION_ID=$(grep "^- Session ID:" "$LATEST_CTX" 2>/dev/null | awk '{print $NF}')
DB_PATH=$(grep "^- DB path:" "$LATEST_CTX" 2>/dev/null | sed 's/^- DB path: //')
CHANNEL_KEY=$(grep "^- Channel key:" "$LATEST_CTX" 2>/dev/null | sed 's/^- Channel key: //')
# 从 SQLite 查询用户最近一条消息时间(真值源,不依赖 LLM 写入的文本标记)
# 注意:此处严禁改为读取 [E000]——[E000] 仅由 memory_write skill 写入,proactive 不拥有该信号
# 使用 python3 内置 sqlite3 模块(系统 sqlite3 CLI 可能未安装),通过 env 传参避免 shell 注入
LAST_ACTIVE=""
if [[ -n "$DB_PATH" && -n "$CHANNEL_KEY" ]]; then
LAST_ACTIVE=$(_DB="$DB_PATH" _CH="$CHANNEL_KEY" python3 -c "
import sqlite3, os
try:
conn = sqlite3.connect(os.environ['_DB'])
row = conn.execute(
'SELECT MAX(m.created_at) FROM messages m JOIN sessions s ON m.session_id=s.id WHERE s.channel_key=? AND m.role=?',
(os.environ['_CH'], 'user')
).fetchone()
conn.close()
print(row[0] or '', end='')
except Exception:
pass
" 2>/dev/null)
fi
date 命令换算当前本地时间TZ_FIELD=$(grep -m1 '时区' "$PROFILE_FILE" | grep -oP 'Asia/\w+|UTC[+-]\d+' | head -1)
TZ="${TZ_FIELD:-Asia/Shanghai}"
LOCAL_HOUR=$(TZ="$TZ" date +%H)
if [[ $LOCAL_HOUR -ge 23 ]] || [[ $LOCAL_HOUR -lt 8 ]]; then
exit 0
fi
.proactive_state 的 last_sent_ts 字段。若距上次主动触达不足 2 小时(120 分钟)→ 静默退出(防止因随机门短时间内重复触发)LAST_SENT_TS=$(grep '^last_sent_ts:' "$PROACTIVE_STATE" 2>/dev/null | awk '{print $2}')
if [[ -n "$LAST_SENT_TS" ]]; then
MINS_SINCE_SENT=$(_TS="$LAST_SENT_TS" python3 -c "
import os
from datetime import datetime, timezone
try:
last = datetime.fromisoformat(os.environ['_TS'])
now = datetime.now().astimezone()
if last.tzinfo is None:
last = last.replace(tzinfo=timezone.utc)
print(int((now - last).total_seconds() / 60))
except Exception:
print(9999)
" 2>/dev/null)
if [[ ${MINS_SINCE_SENT:-9999} -lt 120 ]]; then
exit 0 # 上次主动触达不足 2 小时,静默退出
fi
fi
此门在读取任何情绪/事件上下文之前执行。 概率由 bash
$RANDOM决定, 与 LLM 对"角色当前是否想说话"的判断完全隔离。 这是保证行为真正随机的核心设计:等 LLM 读到情绪上下文时,发送/跳过已定局。
- 发送概率和 max_skip 从 character_params.yaml 读取(由角色 initiative 维度决定,非硬编码)
- 降级默认值:base_prob=15,max_skip=8
# 从 character_params.yaml 读取性格驱动参数(带降级)
PARAMS_FILE="$WORKSPACE_DIR/character_params.yaml"
SEND_PROB=$(awk '/^proactive:/{f=1} f && /^ base_prob:/{print $2; exit}' "$PARAMS_FILE" 2>/dev/null)
SEND_PROB=${SEND_PROB:-15}
MAX_SKIP=$(awk '/^proactive:/{f=1} f && /^ max_skip:/{print $2; exit}' "$PARAMS_FILE" 2>/dev/null)
MAX_SKIP=${MAX_SKIP:-8}
# 纯随机门:所有时间/冷却硬性检查通过后,用 bash RANDOM 决定本次是否发送
SKIP_COUNT=$(grep 'skip_count:' "$PROACTIVE_STATE" 2>/dev/null | grep -oP '\d+' | tail -1)
RAND=$((RANDOM % 100))
if [[ $RAND -ge $SEND_PROB ]] && [[ ${SKIP_COUNT:-0} -lt $MAX_SKIP ]]; then
NEW_COUNT=$((${SKIP_COUNT:-0} + 1))
PREV_SENT_TS=$(grep '^last_sent_ts:' "$PROACTIVE_STATE" 2>/dev/null | awk '{print $2}')
PREV_LKR=$(grep '^last_knowledge_ref_at:' "$PROACTIVE_STATE" 2>/dev/null | awk '{print $2}')
{ echo "skip_count: $NEW_COUNT"
[[ -n "$PREV_SENT_TS" ]] && echo "last_sent_ts: $PREV_SENT_TS"
[[ -n "$PREV_LKR" ]] && echo "last_knowledge_ref_at: $PREV_LKR"
} > "$PROACTIVE_STATE"
exit 0
fi
# 到达这里:本次将发送(skip_count 在 Step 5 发送成功后重置为 0)
读取以下文件,为后续步骤提供消息内容依据(此阶段不再影响是否发送):
EVENTS_CONTENT=$(cat "$EVENTS_FILE" 2>/dev/null || echo "")
PROFILE_CONTENT=$(cat "$PROFILE_FILE" 2>/dev/null || echo "")
读取 memory/events.md 和 memory/user_profile.md,按以下优先级选一条:
| 优先级 | 条件 | 示例消息 |
|---|---|---|
| P1 | events.md 有"待跟进"且到期日 ≤ 今天的约定 | "你说周五去看哪吒……好看吗?" |
| P2 | events.md 有"强烈情绪波动"且状态为"待跟进" | "那天的事……后来怎么样了?" |
| P3 | user_profile.md 中有"正在经历的重大事情",随机选一件 | "你上次说在备考,最近还在拼吗?" |
| P4-C | knowledge_bank.md 有 formed + 最后使用:从未(ready) + valence > -0.2 + 距上次知识引用 > 3 轮 + openness ≥ 3 | 以角色口吻自然分享一个已形成的知识点 |
| P4-A | 无以上情况,life_log.md 有最近条目 | 以 life_log 最新内容为素材,自然带出 |
| P4-B | P4-A 不可用(life_log 为空或被锁定) | 与当前时间/心情相关的随机句子 |
P4-C 执行流程(在 P1/P2/P3 均不满足后,检查此条):
KNOWLEDGE_BANK="$WORKSPACE_DIR/memory/knowledge_bank.md"
PERSONA_FILE="$WORKSPACE_DIR/memory/persona.md"
MOOD_STATE="$WORKSPACE_DIR/memory/mood_state.md"
# 读取 openness(从 persona.md,single-form: personality_dims.openness)
OPENNESS=$(python3 -c "
import re
try:
c = open('$PERSONA_FILE').read()
m = re.search(r'openness:\s*(\d+)', c)
print(int(m.group(1)) if m else 3)
except: print(3)
" 2>/dev/null)
OPENNESS=${OPENNESS:-3}
# 读取当前 valence
CURRENT_VALENCE=$(grep -m1 '^valence:' "$MOOD_STATE" 2>/dev/null | awk '{print $2}')
CURRENT_VALENCE=${CURRENT_VALENCE:-0.0}
# 读取 last_knowledge_ref_at(R4修复:不存在时视为满足条件)
LAST_KR=$(grep '^last_knowledge_ref_at:' "$PROACTIVE_STATE" 2>/dev/null | awk '{print $2}')
USE_P4C=$(python3 -c "
import re, os
from datetime import datetime, timezone
try:
openness = int('$OPENNESS')
if openness < 3: print('no'); exit()
valence = float('$CURRENT_VALENCE')
if valence <= -0.2: print('no'); exit()
last_kr = '$LAST_KR'
if last_kr:
last = datetime.fromisoformat(last_kr)
now = datetime.now().astimezone()
if last.tzinfo is None:
last = last.replace(tzinfo=timezone.utc)
# 3轮对话约30分钟
if (now - last).total_seconds() < 1800:
print('no'); exit()
# 检查 knowledge_bank.md 是否有 formed ready 条目
import os as os2
kb_path = '$KNOWLEDGE_BANK'
if not os2.path.exists(kb_path): print('no'); exit()
kb = open(kb_path).read()
formed_ready = [e for e in re.split(r'(?=## \[K)', kb)
if 'formed' in e and '最后使用: 从未(ready)' in e]
print('yes' if formed_ready else 'no')
except: print('no')
" 2>/dev/null)
USE_P4C=${USE_P4C:-no}
若 USE_P4C=yes:
角色化表达 字段内容作为消息核心最后使用 改为当前时间,used_count + 1last_knowledge_ref_at: {NOW_TS} 写入 .proactive_state若 USE_P4C=no:继续检查 P4-A。
P4-A 锁检查(正确用法:先绑定 fd 到锁文件):
exec 9<"$MEMORY_LOCK"
if flock -n 9; then
# 未被锁定,正常读取 life_log.md
flock -u 9
USE_LIFE_LOG=true
else
# 被锁定(life_sim 正在写入),等待最多 5 秒
if flock -w 5 9; then
flock -u 9
USE_LIFE_LOG=true
else
USE_LIFE_LOG=false # 超时,降级到 P4-B
fi
fi
exec 9>&-
P4-A 氛围匹配检查(V3.1 新增): 选用 life_log 素材前,对比 life_log 最新条目情绪与 RECENT_HISTORY.md 最后一条用户消息情绪:
随机消息克制度(P4-A/P4-B):
前置:重新生成 RECENT_HISTORY.md(proactive 任务不经过 UserPromptSubmit hook,inject_history.py 不会自动刷新,必须手动重建):
if [[ -n "$DB_PATH" && -n "$CHANNEL_KEY" ]]; then
_DB="$DB_PATH" _CH="$CHANNEL_KEY" _WS="$WORKSPACE_DIR" python3 - <<'PYEOF' 2>/dev/null
import sqlite3, os
from datetime import datetime
from pathlib import Path
ws = Path(os.environ['_WS'])
try:
conn = sqlite3.connect(os.environ['_DB'])
rows = conn.execute(
"SELECT m.role, m.content, m.created_at FROM messages m "
"JOIN sessions s ON m.session_id = s.id "
"WHERE s.channel_key = ? AND m.content != '' "
"ORDER BY m.created_at DESC LIMIT 50",
(os.environ['_CH'],)
).fetchall()
conn.close()
except Exception:
rows = []
if rows:
ordered = list(reversed(rows))
# 切掉开头的孤儿 assistant(触发它的 user 被窗口挤出),避免只看到回复看不到触发
while ordered and ordered[0][0] != 'user':
ordered.pop(0)
if rows and ordered:
lines = ['# 最近对话记录(跨 session)\n',
f'> 自动注入,最近 {len(ordered)} 条\n\n']
for role, content, ts in ordered:
tag = '**用户**' if role == 'user' else '**角色**'
try:
dt = datetime.fromisoformat(ts)
if dt.tzinfo is not None:
dt = dt.astimezone()
date_str = dt.strftime('%Y-%m-%d %H:%M')
except Exception:
date_str = str(ts)[:16].replace('T', ' ')
body = content[:500] + ('…' if len(content) > 500 else '')
lines.append(f'{tag}({date_str}):{body}\n\n')
(ws / 'memory' / 'RECENT_HISTORY.md').write_text(''.join(lines))
PYEOF
fi
读取 memory/RECENT_HISTORY.md(如果存在):
此刻你作为作者,已判断角色在这个时间节点应该主动出现。 你在创作角色的一段主动台词(可含旁白),最后由 send_and_record.py 投递给用户。
创作规则:
[[SEND]] 可用于控制发送节奏(参考正常对话规则)作者审稿:生成候选台词后,先自检以下规则,如有违反必须重写,不向用户解释修改过程:
A. 禁止 AI 腔(必须过滤)
B. 角色声音一致性
C. 主动触达特有规则
自检流程:
# routing_key:优先从 <system_routing> 上下文块读取(executor.go 注入)
# 若无 system_routing → 从 user_profile.md 的【飞书发送目标】字段读取
ROUTING_KEY=$(grep -A1 '飞书发送目标\|routing_key' "$PROFILE_FILE" | grep -oP '(p2p|group):[a-zA-Z0-9_]+' | head -1)
# DB_PATH 和 SESSION_ID 已在 Step 1 从 SESSION_CONTEXT.md 读取,此处直接复用
SCRIPT_DIR="$WORKSPACE_DIR/.claude/skills/feishu_ops/scripts"
# 仅在发送成功(exit 0)后才记录时间戳;发送失败时不更新冷却,避免静默期
if python3 "$SCRIPT_DIR/send_and_record.py" \
--routing_key "$ROUTING_KEY" \
--text "消息内容" \
--db_path "$DB_PATH" \
--session_id "$SESSION_ID"; then
NOW_TS=$(python3 -c "from datetime import datetime; print(datetime.now().astimezone().isoformat(timespec='seconds'))" 2>/dev/null)
# 基础状态更新
PREV_LKR=$(grep '^last_knowledge_ref_at:' "$PROACTIVE_STATE" 2>/dev/null | awk '{print $2}')
printf 'skip_count: 0\nlast_sent_ts: %s\n' "$NOW_TS" > "$PROACTIVE_STATE"
# P4-C 触发时:保留 last_knowledge_ref_at 并更新为本次时间
if [[ "${USE_P4C:-no}" == "yes" ]]; then
printf 'last_knowledge_ref_at: %s\n' "$NOW_TS" >> "$PROACTIVE_STATE"
elif [[ -n "$PREV_LKR" ]]; then
printf 'last_knowledge_ref_at: %s\n' "$PREV_LKR" >> "$PROACTIVE_STATE"
fi
fi
为什么用 send_and_record.py:此任务 send_output=false,runner 不会将 Claude 输出转发给用户, 也不会写 DB。send_and_record.py 同时完成飞书发送 + messages 表写入,确保用户下次对话时 Claude 能从历史中看到自己发过的内容。DB 写入失败不阻断发送(降级记录错误)。
若本次触达对应一个已有的 [ENNN] 条目(如 P1 的约定跟进),将其状态从"待跟进"更新为"已跟进"。
设计说明:本 skill 不写入
[E000]。[E000]代表"用户上次真实对话时间", 由 memory_write skill 唯一负责写入。proactive 的发送行为不代表用户活跃, 若在此写入 [E000] 会导致下次运行把"上次触达时间"误判为"用户活跃时间",产生自我抑制循环。
.proactive_state 文件格式(R5修复).proactive_state 文件为纯文本,每次完整覆盖写入,格式如下:
skip_count: {整数,0–max_skip,每次跳过+1,发送成功后重置为0}
last_sent_ts: {ISO8601带时区,最近一次成功发送的时间戳;首次运行时不存在}
last_knowledge_ref_at: {ISO8601带时区,最近一次使用 P4-C 知识引用的时间戳;未引用过时不存在}
读取规则:
last_sent_ts 缺失 → 允许发送;last_knowledge_ref_at 缺失 → P4-C 视为满足条件)基于 persona.md 的 personality_dims,按映射公式计算行为参数,写入 character_params.yaml。 由 calibrate_params 定时任务触发(每7天),或初始化完成时立即触发一次。 其他 SKILL/hook 在检测到 persona_checksum 不一致时,也会直接调用 recalculate.sh 同步重算。
v2.2 M3 · 对话历程摘要。RECENT_HISTORY.md 超过 30 条时,压缩旧条目为 3-5 句历程摘要 写入 memory/session_summary.md,防止长 context 导致角色漂移到共情模板。 被 memory_distill 在检测条数超阈时调用,或用户主动校验记忆时调用。
Companion Workspace 生活日志生成 SOP (v5.2)。 由 life_sim.yaml 定时任务触发(每 4 小时)。 从 material_pool 选真实素材,以"触发→反应"模板转译为角色生活日志。 内含:留白模式、用户倾诉强制呼应、降温规则、多形态衔接、失败降级链。
v5.1 关键词模板驱动 + 硬筛规则 + LLM 二审(锁外)+ 失败状态追踪。 由 material_fetch.yaml 定时任务触发(每 6 小时)。send_output: false。 读 memory/keyword_templates.yaml 生成查询,经 filters.yaml 硬筛后 LLM 二审打 fit_score 入库。
Companion Workspace 定时记忆提炼 SOP。 由 memory_distill.yaml 定时任务触发(每小时一次)。 从最近消息中提炼新信息,补充到 memory 文件,不重复已有内容。
Companion Workspace 记忆写入规范。 触发词:记住 / 对话结束时的自动检查 / 强烈情绪事件