소스 정보
- 저장소
- kid0317/cc_workspace_bot
- 최근 소스 활동
- 2026년 7월 18일 10:49
- 감지된 SKILL.md 언어
- 중국어
- 스타
- 26
- 포크
- 4
설치 방법
기본적으로 소스를 먼저 확인하는 Prompt가 선택됩니다. 직접 명령으로 전환하거나 로컬 사본을 다운로드할 수도 있습니다.
소스 파일 검토
설치 여부를 결정하기 전에 SKILL.md와 SkillsMP에 표시된 보조 파일을 읽어 보세요.
메뉴
기본적으로 소스를 먼저 확인하는 Prompt가 선택됩니다. 직접 명령으로 전환하거나 로컬 사본을 다운로드할 수도 있습니다.
설치 여부를 결정하기 전에 SKILL.md와 SkillsMP에 표시된 보조 파일을 읽어 보세요.
Codex 또는 Claude로 설치 이 Prompt를 복사해 Codex, Claude 또는 다른 어시스턴트에 붙여 넣으면 Skill 페이지를 검토하고 설치를 진행할 수 있습니다.
직접 명령은 검토 Prompt를 거치지 않습니다. 실행하기 전에 소스를 확인하세요.
npx skills add https://github.com/kid0317/cc_workspace_bot --skill proactive명령은 한 줄로 유지됩니다. 복사하기 전에 가로로 스크롤해 전체 내용을 확인하세요.
로컬 사본을 원하시나요? SkillsMP에서 현재 제공할 수 있는 파일을 다운로드하세요.
基于 persona.md 的 personality_dims,按映射公式计算行为参数,写入 character_params.yaml。 由 calibrate_params 定时任务触发(每7天),或初始化完成时立即触发一次。 其他 SKILL/hook 在检测到 persona_checksum 不一致时,也会直接调用 recalculate.sh 同步重算。
飞书操作:向用户/群组发送消息(文字/富文本/图片/文件)、读写云文档/电子表格/多维表格、查询群成员、管理日历事件。适合推送通知、发送处理结果文件、读取共享文档、批量发送报告等场景。
查询 Langfuse 可观测平台数据,帮助定位 Claude Code 任务执行异常
SOC 직업 분류 기준
SKILL.md 표시 중
| 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: "
} >
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):
⚠️ CRITICAL — 严禁修改下方 bash 代码
- 必须使用 Step 1 已读取的 $DB_PATH 和 $CHANNEL_KEY(来自 SESSION_CONTEXT.md,真值源)
- 禁止写死旧共享 DB 路径(例如仓库根目录的
bot.db),这会跨 workspace 污染- 禁止截短 $CHANNEL_KEY(如 split(":")[0]、去掉 :app_id 后缀),channel_key 必须完整精确匹配,不要用 LIKE
- 禁止自己即兴写 SQL/Python 替代下方代码——曾发生过 LLM 改写 SQL 导致跨 app 拉到别人对话写入 RECENT_HISTORY.md,主动消息内容彻底错乱的事故
如果 $DB_PATH 或 $CHANNEL_KEY 为空,直接跳过 Step 4,不要尝试"修复"。
前置:重新生成 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 )
lines.append(f)
(ws / / ).write_text(.(lines))
PYEOF
读取 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" >> ""
[[ -n ]];
>>
为什么用 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 视为满足条件)