mit einem Klick
life-sim
// Companion Workspace 生活日志生成 SOP (v5.2)。 由 life_sim.yaml 定时任务触发(每 4 小时)。 从 material_pool 选真实素材,以"触发→反应"模板转译为角色生活日志。 内含:留白模式、用户倾诉强制呼应、降温规则、多形态衔接、失败降级链。
// Companion Workspace 生活日志生成 SOP (v5.2)。 由 life_sim.yaml 定时任务触发(每 4 小时)。 从 material_pool 选真实素材,以"触发→反应"模板转译为角色生活日志。 内含:留白模式、用户倾诉强制呼应、降温规则、多形态衔接、失败降级链。
| name | life_sim |
| description | Companion Workspace 生活日志生成 SOP (v5.2)。 由 life_sim.yaml 定时任务触发(每 4 小时)。 从 material_pool 选真实素材,以"触发→反应"模板转译为角色生活日志。 内含:留白模式、用户倾诉强制呼应、降温规则、多形态衔接、失败降级链。 |
| allowed-tools | Bash, Read, Write, Edit |
CRITICAL:禁止输出任何文字。 本 Skill 由定时任务触发(send_output=false 模式),Claude 的任何回复文字会被系统丢弃。 作者从真实世界素材池挑选,以角色声音转译为生活切片;全流程完成后直接退出。
material_pool.md 挑选素材,仅在池子空/陈旧时降级虚构src: inline_fabrication 条目与 life_sim 条目共存于同一 life_log,挑选素材时需避免主题重复WORKSPACE_DIR="${WORKSPACE_DIR:-$(pwd)}"
LIFE_LOG="$WORKSPACE_DIR/memory/life_log.md"
LIFE_LOG_INDEX="$WORKSPACE_DIR/memory/life_log_index.md"
MEMORY_LOCK="$WORKSPACE_DIR/.memory.lock"
PERSONA_FILE="$WORKSPACE_DIR/memory/persona.md"
RECENT_HISTORY="$WORKSPACE_DIR/memory/RECENT_HISTORY.md"
MEMORY_FILE="$WORKSPACE_DIR/memory/MEMORY.md"
PARAMS_FILE="$WORKSPACE_DIR/character_params.yaml"
MATERIAL_POOL="$WORKSPACE_DIR/memory/material_pool.md"
MATERIAL_LOCK="$WORKSPACE_DIR/.material.lock"
UNRESOLVED="$WORKSPACE_DIR/memory/unresolved.md"
MOOD_STATE="$WORKSPACE_DIR/memory/mood_state.md"
MOOD_AUX="$WORKSPACE_DIR/.mood_state_aux"
FETCH_STATE="$WORKSPACE_DIR/.material_fetch_state.json"
EVENTS_JSONL="$WORKSPACE_DIR/.life_sim_events.$(date +%Y%m%d).jsonl"
FILTERS_FILE="$WORKSPACE_DIR/.claude/skills/material_fetch/filters.yaml"
KEYWORD_TEMPLATES="$WORKSPACE_DIR/memory/keyword_templates.yaml"
_p() { awk -v k="$1" '/^life_sim:/{f=1} f && $0 ~ "^ "k":"{gsub(/^[ \t]+/,"",$0); sub("^"k":[ \t]*",""); sub(/[ \t]*#.*$/,""); gsub(/[ \t]+$/,""); gsub(/^"/,""); gsub(/"$/,""); print; exit}' "$PARAMS_FILE" 2>/dev/null; }
GEN_THRESHOLD_DAY=$(_p gen_threshold_day); GEN_THRESHOLD_DAY=${GEN_THRESHOLD_DAY:-60}
GEN_THRESHOLD_NIGHT=$(_p gen_threshold_night); GEN_THRESHOLD_NIGHT=${GEN_THRESHOLD_NIGHT:-20}
LOG_MAX_LENGTH=$(_p log_max_length); LOG_MAX_LENGTH=${LOG_MAX_LENGTH:-300}
EMOTIONAL_RANGE=$(_p emotional_range); EMOTIONAL_RANGE=${EMOTIONAL_RANGE:-3}
MATERIAL_USE_THRESHOLD=$(_p material_use_threshold); MATERIAL_USE_THRESHOLD=${MATERIAL_USE_THRESHOLD:-60}
ORIGINAL_QUOTE_MAX_CHARS=$(_p original_quote_max_chars); ORIGINAL_QUOTE_MAX_CHARS=${ORIGINAL_QUOTE_MAX_CHARS:-15}
WHITE_SPACE_PROB=$(_p white_space_prob); WHITE_SPACE_PROB=${WHITE_SPACE_PROB:-25}
USER_ECHO_PRIORITY=$(_p user_echo_priority); USER_ECHO_PRIORITY=${USER_ECHO_PRIORITY:-1.5}
INIT_STATUS=$(grep 'initialization_status:' "$MEMORY_FILE" 2>/dev/null | grep -oP '(pending|phase1_done|phase2_done|done)' | head -1)
[[ "$INIT_STATUS" != "done" ]] && exit 0
事实流 append 辅助函数(始终用 shell >>,单行 ≤4KB,保证多进程原子):
_emit_event() {
local payload="$1" # 以 { 开头、} 结尾的 JSON 片段
local ts; ts=$(python3 -c "from datetime import datetime; print(datetime.now().astimezone().isoformat(timespec='seconds'))" 2>/dev/null)
local ws; ws=$(basename "$WORKSPACE_DIR")
# 在最外层 { 后插 v/ts/ws 三个固定字段
printf '{"v":1,"ts":"%s","ws":"%s",%s\n' "$ts" "$ws" "${payload:1}" >> "$EVENTS_JSONL"
}
PROFILE_FILE="$WORKSPACE_DIR/memory/user_profile.md"
TZ_FIELD=$(grep -m1 '时区' "$PROFILE_FILE" 2>/dev/null | grep -oP 'Asia/\w+|UTC[+-]\d+' | head -1)
TZ="${TZ_FIELD:-Asia/Shanghai}"
LOCAL_HOUR=$(TZ="$TZ" date +%H)
设计文档 §7.2 承诺"用户倾诉必有呼应",所以 user_echo 必须 bypass 骰子。
LLM 判断任务(在 Step 2 骰子之前执行):
RECENT_HISTORY.md 最近 24h 的用户消息unresolved.md 活跃块的 src=user_told 条目forced_echo_last 早于 72h 前(或 (never))FORCE_ECHO=false # LLM 设置
TARGET_UNRESOLVED="" # LLM 设置,如 "U003"
若 FORCE_ECHO=true:
if [[ "$FORCE_ECHO" == "true" ]]; then
# 用户倾诉强制呼应:跳过骰子,直接进入后续步骤
RAND=-1
THRESHOLD=100
WHITESPACE_MODE=false
_emit_event "{\"event\":\"dice_bypass\",\"reason\":\"user_echo\",\"target\":\"${TARGET_UNRESOLVED}\"}"
else
RAND=$((RANDOM % 100))
if [[ $LOCAL_HOUR -ge 0 && $LOCAL_HOUR -lt 6 ]]; then
THRESHOLD=$GEN_THRESHOLD_NIGHT
else
THRESHOLD=$GEN_THRESHOLD_DAY
fi
if [[ $RAND -ge $THRESHOLD ]]; then
_emit_event "{\"event\":\"dice_skip\",\"dice\":$RAND,\"threshold\":$THRESHOLD}"
exit 0
fi
# 留白模式骰子
WS_RAND=$((RANDOM % 100))
WHITESPACE_MODE=false
# pool 陈旧时留白概率临时升高
if [[ ${POOL_AVAILABLE_COUNT:-0} -lt 3 && ${HOURS_SINCE_FETCH:-0} -gt 72 ]]; then
EFFECTIVE_WS_PROB=50
else
EFFECTIVE_WS_PROB=$WHITE_SPACE_PROB
fi
if [[ $WS_RAND -lt $EFFECTIVE_WS_PROB ]]; then
WHITESPACE_MODE=true
fi
fi
读 persona.md 的 stability → 计算衰减系数 k = 0.05 + (5 - stability) × 0.0375
读 mood_state.md 的 valence / energy 并按 last_decay_written_at 衰减。
得 DECAYED_VALENCE 和 CURRENT_ENERGY 作为情绪基线。
(实现代码保持 v3 相同,此处省略。)
若 Step 1.5 已设 FORCE_ECHO=true:
TARGET_UNRESOLVED 展开forced_echo_last节制:呼应内容禁止复述用户原话,必须以角色生活切片形式出现 (例如"今天下午给妈发了条微信,没回,估计在午睡"),避免变成"妈妈生病"每 4h 被提一次。
POOL_AVAILABLE_COUNT=$(python3 -c "
import re
try:
c = open('$MATERIAL_POOL').read()
print(sum(1 for e in re.split(r'(?=## \[MAT)', c) if 'available' in e))
except: print(0)
" 2>/dev/null)
POOL_AVAILABLE_COUNT=${POOL_AVAILABLE_COUNT:-0}
LAST_FETCH_TS=$(python3 -c "
import json
try: print(json.load(open('$FETCH_STATE')).get('last_success_ts',''))
except: print('')
" 2>/dev/null)
HOURS_SINCE_FETCH=$(python3 -c "
from datetime import datetime
ts = '$LAST_FETCH_TS'
if not ts: print(999); exit()
try:
d = datetime.fromisoformat(ts)
print(int((datetime.now().astimezone() - d).total_seconds() / 3600))
except: print(999)
" 2>/dev/null)
HOURS_SINCE_FETCH=${HOURS_SINCE_FETCH:-999}
路径决策(按优先级从上往下判定,第一个匹配即生效):
| 条件 | 路径 | FALLBACK_REASON |
|---|---|---|
| FORCE_ECHO=true | 纯虚构呼应 user_told 挂念 | user_echo |
| WHITESPACE_MODE=true | 跳过素材,极简内观 | whitespace |
| POOL_AVAILABLE_COUNT ≥ 1 且 HOURS_SINCE_FETCH ≤ 3 | 刚抓豁免:即使只有 1-2 条,只要新鲜就正常用 | null |
| POOL_AVAILABLE_COUNT ≥ 3 且 HOURS_SINCE_FETCH ≤ 24 | 正常挑选 | null |
| POOL_AVAILABLE_COUNT ≥ 1 且 POOL_AVAILABLE_COUNT < 3 | 池浅,标 underfilled,仍挑选 | pool_underfilled |
| POOL_AVAILABLE_COUNT == 0 | 池空降级 | pool_empty |
| HOURS_SINCE_FETCH > 24 | 池陈旧 | pool_stale |
| HOURS_SINCE_FETCH > 72 | 连续饥饿;WHITE_SPACE_PROB 临时 50% | pool_critical |
正常挑选规则(LLM 执行):
status=available 条目过滤 fit_score ≥ 0.6src: inline_fabrication 条目 tags 不重复(这些条目由对话中即时编造写入,主题不应被 life_sim 再次覆盖)emotion 维度素材额外 priority_boost=1.5USER_ECHO_PRIORITYSELECTED_MAT_ID / SELECTED_VERB / FIT_SCORE触发动词配额(读 filters.yaml trigger_verb_quota):
SELECTED_VERB 的次数若 capabilities.has_multi_form=true:
触发段(≤1 行,15-40 字):
capabilities.can_use_phone 过滤(false 时禁"刷到/浏览/点赞")$ORIGINAL_QUOTE_MAX_CHARS反应段(80-250 字,不超过 $LOG_MAX_LENGTH):
降温规则(bash 预算,LLM 直接使用 V_TARGET 不需查表):
# 在 Step 6 结束后、Step 7 生成前执行
# 输入:MATERIAL_VALENCE(素材原 valence) / STABILITY(persona.stability)
# EMOTIONAL_RANGE / PRESERVE_PEAK=$(_p preserve_peak_if_stability_ge)
# 输出:V_TARGET(角色本条应有的 valence,clamp 到 [-1, 1])
PRESERVE_PEAK=$(_p preserve_peak_if_stability_ge); PRESERVE_PEAK=${PRESERVE_PEAK:-4}
V_TARGET=$(python3 -c "
import sys
v_src = float('${MATERIAL_VALENCE:-0}')
rng = int('${EMOTIONAL_RANGE:-3}')
stab = int('${STABILITY:-3}')
preserve = int('${PRESERVE_PEAK}')
thresholds = {1:0.3, 2:0.4, 3:0.5, 4:0.6, 5:0.7}
multipliers = {1:0.2, 2:0.3, 3:0.4, 4:0.5, 5:0.6}
if stab >= preserve and abs(v_src) > 0.8:
m = 0.7 # 重大事件保留 70% 峰值
elif abs(v_src) > thresholds.get(rng, 0.5):
m = multipliers.get(rng, 0.4)
else:
m = 1.0
print(round(max(-1.0, min(1.0, v_src * m)), 3))
" 2>/dev/null)
V_TARGET=${V_TARGET:-0}
LLM 在 Step 7 生成时直接使用 $V_TARGET,不再查表自行乘系数。
跳过触发段。反应段 20-80 字,允许极简内观(如"天花板很白""窗外有风")。
src 标 <!-- src: whitespace -->。
围绕 TARGET_UNRESOLVED 展开,用角色生活切片形式呼应用户倾诉。
src 标 <!-- src: user_echo://{TARGET_UNRESOLVED} -->。
### [L{NNN}] YYYY-MM-DDTHH:MM · {时段或形态·活动类型}
<!-- tags: {tag1, tag2, tag3} -->
<!-- intimacy_level: {1|2|3|4|5} -->
<!-- src: {reddit://xxxx | whitespace | user_echo://Uxxx | inline_fabrication | fallback:pool_stale} -->
{触发段(留白模式跳过)}
{反应段}
v2.2 元数据字段约束(resonance_lookup / Fog of War 使用):
tags(英文小写,逗号分隔,2-4 个)——建议池:
weariness / gentle / clarity / frustration / grief / joy / solitude / stuckwindow_light / street / coffee / home / workspace / night / dawnwork / family / relationship / body / small_failure / memoryintimacy_level(1-5)——决定何时能被 resonance_lookup 抽出:
打标原则(life_sim 生成时由 LLM 判断):
exec 9>"$MEMORY_LOCK"
flock -x -w 10 9 || { exec 9>&-; _emit_event "{\"event\":\"lock_timeout\"}"; exit 0; }
# 8.1 写 life_log
# 注意:`10#` 前缀强制十进制解析,避免 "012" 被 bash 当八进制
LAST_N=$(grep -oP '(?<=\[L)\d+(?=\])' "$LIFE_LOG" 2>/dev/null | sort -n | tail -1)
NEXT_N=$(printf "%03d" $(( 10#${LAST_N:-0} + 1 )))
cat >> "$LIFE_LOG" << EOF
### [L${NEXT_N}] $(date +%Y-%m-%dT%H:%M) · ${ACTIVITY_TYPE}
<!-- tags: ${LOG_TAGS} -->
<!-- intimacy_level: ${LOG_INTIMACY:-2} -->
${LOG_CONTENT}
EOF
# 8.2 反写 unresolved.md(若本次触及了挂念)
if [[ -n "$TOUCHED_UNRESOLVED_ID" ]]; then
_UN="$UNRESOLVED" _TID="$TOUCHED_UNRESOLVED_ID" _TAG="L${NEXT_N}" _FE="$FORCE_ECHO" python3 << 'PYEOF' 2>/dev/null
import re, os
from datetime import datetime
path = os.environ['_UN']
tid = os.environ['_TID']
new_tag = os.environ['_TAG']
force_echo = os.environ['_FE'] == 'true'
c = open(path).read()
c = re.sub(r'(\[' + re.escape(tid) + r'\][^\n]*?last_touched=)[^\s]+',
lambda m: m.group(1) + new_tag, c)
if force_echo:
now = datetime.now().astimezone().isoformat(timespec='minutes')
c = re.sub(r'(\[' + re.escape(tid) + r'\][^\n]*?forced_echo_last=)[^\s]+',
lambda m: m.group(1) + now, c)
open(path,'w').write(c)
PYEOF
fi
# 8.3 更新 mood_state(降温由 EMOTIONAL_RANGE 决定,persona.voice_tokens 豁免峰值规则)
# 推断 Δvalence/Δenergy → clamp → 前插新快照到 mood_state.md,保留最近 10 条
# 8.4 消费素材
if [[ -n "$SELECTED_MAT_ID" ]]; then
exec 8>"$MATERIAL_LOCK"
if flock -w 5 8; then
_MP="$MATERIAL_POOL" _MID="$SELECTED_MAT_ID" _CAT="$(date +%Y-%m-%dT%H:%M)" python3 << 'PYEOF' 2>/dev/null
import re, os
path = os.environ['_MP']
mat_id = os.environ['_MID']
consumed_at = os.environ['_CAT']
c = open(path).read()
c = re.sub(r'(## \[' + re.escape(mat_id) + r'\][^\n]*?) · available', r'\1 · consumed', c)
c = re.sub(r'(## \[' + re.escape(mat_id) + r'\].*?)(\n## \[|\Z)',
lambda m: m.group(1).rstrip() + f'\nconsumed_at: {consumed_at}\n' + m.group(2),
c, flags=re.DOTALL)
open(path,'w').write(c)
PYEOF
flock -u 8
fi
exec 8>&-
fi
flock -u 9
exec 9>&-
# 8.5 事实流(释锁后)
FALLBACK_FIELD=$([ -z "${FALLBACK_REASON:-}" ] && echo 'null' || echo "\"${FALLBACK_REASON}\"")
FIT_FIELD=${FIT_SCORE:-null}
_emit_event "{\"event\":\"wrote\",\"entry_id\":\"L${NEXT_N}\",\"material_id\":\"${SELECTED_MAT_ID:-}\",\"form\":\"${CURRENT_FORM:-}\",\"fit_score\":${FIT_FIELD},\"dice\":${RAND},\"threshold\":${THRESHOLD},\"unresolved_touched\":\"${TOUCHED_UNRESOLVED_ID:-}\",\"whitespace_mode\":${WHITESPACE_MODE},\"fallback_reason\":${FALLBACK_FIELD}}"
COUNT=$(grep -c "### \[L" "$LIFE_LOG" 2>/dev/null || echo 0)
若 COUNT > 30:
life_log_archive_YYYYMM.mdpool_empty fallback_emit_event lock_timeout + 退出基于 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 在检测条数超阈时调用,或用户主动校验记忆时调用。
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 记忆写入规范。 触发词:记住 / 对话结束时的自动检查 / 强烈情绪事件
主动唤醒 SOP。由定时任务触发,判断是否向用户发送主动关心消息。 包含发送条件检查、消息类型选择、角色声音生成、飞书发送。