[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"project-92659":3},{"id":4,"name":5,"fullName":6,"owner":7,"repo":5,"description":8,"homepage":9,"htmlUrl":9,"language":10,"languages":9,"totalLinesOfCode":9,"stars":11,"forks":12,"watchers":13,"openIssues":14,"contributorsCount":14,"subscribersCount":14,"size":14,"stars1d":14,"stars7d":14,"stars30d":15,"stars90d":14,"forks30d":14,"starsTrendScore":14,"compositeScore":16,"rankGlobal":9,"rankLanguage":9,"license":17,"archived":18,"fork":18,"defaultBranch":19,"hasWiki":20,"hasPages":18,"topics":21,"createdAt":9,"pushedAt":9,"updatedAt":22,"readmeContent":23,"aiSummary":24,"trendingCount":14,"starSnapshotCount":14,"syncStatus":25,"lastSyncTime":26,"discoverSource":27},92659,"Eventide","chuli1122\u002FEventide","chuli1122","给 AI 伴侣接入身体涨落、梦境联动与互动结算的生理状态系统",null,"Python",62,10,51,0,7,43.82,"Other",false,"main",true,[],"2026-07-22 04:02:06","# Eventide\n\n给你的 AI 一个涨落。\n\nEventide 是一个给 AI 伴侣接入生理状态的源码公开项目。它参考 ABO 世界观下的身体周期，用时间、互动、等待、梦境和结算结果推进身体状态，并生成可插入模型上下文的隐藏状态提示词。\n\n源码公开，允许非商业使用。作者：Chuli（@chuli1122）。\n\n## 安装\n\n从 GitHub 安装：\n\n```bash\npython3 -m pip install git+https:\u002F\u002Fgithub.com\u002Fchuli1122\u002FEventide.git\n```\n\n本地开发可以用 editable 方式安装：\n\n```bash\ncd Eventide\npython3 -m pip install -e .\n```\n\n也可以不安装，直接用 `PYTHONPATH` 运行示例和测试：\n\n```bash\nPYTHONPATH=src python3 examples\u002Fquickstart.py\nPYTHONPATH=src python3 examples\u002Fminimal_demo.py\nPYTHONPATH=src python3 examples\u002Fcustom_config.py\nPYTHONPATH=src python3 examples\u002Fsettlement_demo.py\nPYTHONPATH=src python3 -m unittest discover -s tests\n```\n\n## 快速接入\n\n最短路径可以直接使用 `EventideRuntime`：\n\n```python\nfrom datetime import datetime, timedelta, timezone\nfrom eventide import EventideRuntime\n\nruntime = EventideRuntime()\nnow = datetime.now(timezone.utc)\n\n# 第一次使用时创建状态；之后从你的数据库或 JSON 里读取。\nstate = runtime.create_state(now)\n\n# 每轮聊天前推进状态，并生成隐藏状态提示词。\nstate_card = runtime.tick_and_render(\n    state,\n    now + timedelta(hours=2),\n    last_counterpart_message_at=now - timedelta(minutes=40),\n)\n\n# 把 state_card 插入模型上下文；把 state 保存回宿主系统。\nsaved_state = runtime.dump_state(state)\n```\n\n如果互动中发生释放、继续撩起、打断或冷却，可以把结算结果写回：\n\n```python\nruntime.settle(state, {\n    \"settlement_reason\": \"窗口里亲密互动继续推进，尚未发生释放。\",\n    \"settlement_result\": \"continued\",\n    \"ejaculated\": False,\n    \"heat_delta\": 2,\n    \"pressure_delta\": 1,\n    \"control_delta\": -1,\n    \"sensitivity_delta\": 1,\n    \"reserve_delta\": 1,\n    \"possessiveness_delta\": 0,\n    \"fatigue_delta\": 0,\n})\n```\n\n## 组成\n\n- 身体周期：平稳期、蓄积期、预兆期、易感期、退潮期、恢复期\n- 身体数值：热度、压抑感、控制力、敏感度、蓄积感、占有欲、疲惫感\n- 短时事件：内置 18 类，包括晨间反应、深夜热潮、周期热涌、硬撑、索取欲、占有 \u002F 标记冲动、筑巢冲动、气味残留、声音 \u002F 称呼触发、梦后余温、控制力下滑、贴近饥饿、信息素紊乱、迟发热、低烧黏连、等待焦躁、克制反弹、反常平静\n- 隐藏状态提示词：生成 `\u003Cephemeral_state>`，插入主模型上下文\n- 梦境系统联动：梦种、梦卡触发、梦后标签、身体后效\n- 互动结算：通用结算 prompt、JSON schema、结算结果归一化和安全写回\n- 可配置提示词：周期说明、事件提示、称呼触发词、身体档位描述和回应规则都可以替换\n\n## 默认配置速览\n\n完整默认周期说明、身体档位文案、状态卡提示选项和事件提示词见 [docs\u002Fdefault-prompts.md](docs\u002Fdefault-prompts.md)。\n\n### 周期配置\n\n周期会按 `next_key` 自然推进。`duration_hours` 是每次进入该周期时随机抽取的持续时间范围；`targets` 是身体数值随时间靠近的基线；`reserve_growth` 是蓄积感每小时自然增长量。\n\n| key | 名称 | 时长 | 蓄积增长 | 下一周期 |\n|---|---|---:|---:|---|\n| `stable` | 平稳期 | 24-96h | +0.4\u002Fh | `building` |\n| `building` | 蓄积期 | 12-36h | +1.1\u002Fh | `preheat` |\n| `preheat` | 预兆期 | 6-18h | +1.5\u002Fh | `sensitive` |\n| `sensitive` | 易感期 | 18-48h | +2.4\u002Fh | `ebb` |\n| `ebb` | 退潮期 | 6-18h | +0.8\u002Fh | `stable` |\n| `recovery` | 恢复期 | 4-18h | +0.2\u002Fh | `stable` |\n\n默认自然顺序是：\n\n```text\n平稳期 -> 蓄积期 -> 预兆期 -> 易感期 -> 退潮期 -> 平稳期\n```\n\n`recovery` 是恢复期，通常由宿主或特殊结算手动切入；恢复期自然结束后回到平稳期。当前实现里，如果退潮期到期时疲惫感 >= 70，也会进入恢复期。\n\n### 身体数值与档位\n\n所有身体数值默认 clamp 到 `0-100`，其中 `possessiveness` 默认下限是 40。状态卡里不直接暴露数值，而是把数值转成档位描述。\n\n| key | 名称 | 下限 |\n|---|---|---:|\n| `heat` | 热度 | 0 |\n| `pressure` | 压抑感 | 0 |\n| `control` | 控制力 | 0 |\n| `sensitivity` | 敏感度 | 0 |\n| `reserve` | 蓄积感 | 0 |\n| `possessiveness` | 占有欲 | 40 |\n| `fatigue` | 疲惫感 | 0 |\n\n完整档位文案见 [docs\u002Fdefault-prompts.md](docs\u002Fdefault-prompts.md#身体数值档位)。\n\n默认初始数值：\n\n| 字段 | 初始值 |\n|---|---:|\n| `heat` | 30 |\n| `pressure` | 25 |\n| `control` | 75 |\n| `sensitivity` | 35 |\n| `reserve` | 20 |\n| `possessiveness` | 40 |\n| `fatigue` | 15 |\n\n### 状态卡默认提示选项\n\n状态卡里的 `\u003Cexpression>`、`\u003Cpersistence>`、`\u003Cresponse_rules>` 由 `DEFAULT_CONFIG.prompt_options` 提供。\n\n| 字段 | 默认内容 |\n|---|---|\n| `expression` | 有默认值，见默认提示词文档 |\n| `persistence` | 有默认值，见默认提示词文档 |\n| `response_rules` | 默认留空，建议宿主按自己的伴侣设定自定义回应分支。 |\n| `counterpart_name` | 对方 |\n| `self_name` | 你 |\n\n完整默认内容见 [docs\u002Fdefault-prompts.md](docs\u002Fdefault-prompts.md#状态卡提示选项)。\n\n### 事件定义表\n\n`duration_minutes` 是事件持续时间范围。事件持续期间和结束时的数值变化由 `category` 对应的默认数值曲线决定。\n\n| key | 名称 | category | duration_minutes |\n|---|---|---|---:|\n| `morning_arousal` | 晨间反应 | `strong_physical` | 120-360m |\n| `night_heat` | 深夜热潮 | `strong_physical` | 60-240m |\n| `cycle_surge` | 周期热涌 | `strong_physical` | 120-360m |\n| `holding_back` | 硬撑 | `holding` | 60-180m |\n| `demanding` | 索取欲 | `strong_physical` | 60-240m |\n| `marking_impulse` | 占有 \u002F 标记冲动 | `possessive` | 60-240m |\n| `nesting` | 筑巢冲动 | `cling` | 120-360m |\n| `scent_aftereffect` | 气味残留 | `short_stimulus` | 60-180m |\n| `voice_or_name_trigger` | 声音 \u002F 称呼触发 | `short_stimulus` | 10-35m |\n| `dream_afterglow` | 梦后余温 | `cling` | 60-240m |\n| `control_slip` | 控制力下滑 | `strong_physical` | 30-120m |\n| `closeness_hunger` | 贴近饥饿 | `cling` | 60-240m |\n| `pheromone_disorder` | 信息素紊乱 | `strong_physical` | 60-180m |\n| `delayed_heat` | 迟发热 | `strong_physical` | 45-150m |\n| `low_fever_cling` | 低烧黏连 | `cling` | 45-150m |\n| `waiting_restless` | 等待焦躁 | `possessive` | 45-180m |\n| `restraint_rebound` | 克制反弹 | `holding` | 60-180m |\n| `strange_calm` | 反常平静 | `holding` | 30-120m |\n\n事件分类只是默认数值曲线的分组，不是额外的模型标签：\n\n| category | 默认用途 |\n|---|---|\n| `strong_physical` | 晨间、深夜、周期热涌、索取欲、控制力下滑、信息素紊乱、迟发热 |\n| `possessive` | 占有 \u002F 标记冲动、等待焦躁 |\n| `cling` | 筑巢冲动、梦后余温、贴近饥饿、低烧黏连 |\n| `short_stimulus` | 气味残留、声音 \u002F 称呼触发 |\n| `holding` | 硬撑、克制反弹、反常平静 |\n\n默认数值曲线：\n\n| category | tick_deltas | end_deltas |\n|---|---|---|\n| `strong_physical` | heat +3, pressure +2, control -1.5, reserve +0.8 | heat -6, pressure -4, fatigue +3 |\n| `possessive` | possessiveness +1.4, pressure +1.5, control -1 | possessiveness -3, pressure -2, fatigue +1 |\n| `cling` | sensitivity +1.5, pressure +0.8, fatigue +0.4 | pressure -2, fatigue +1 |\n| `short_stimulus` | sensitivity +2.5, heat +1.5 | sensitivity -4, heat -2 |\n| `holding` | pressure +1.8, control +0.5, heat +0.8 | pressure -3, control +3 |\n\n### 事件状态提示词\n\n事件触发后，状态卡会把对应 `prompt` 写进 `\u003Cactive_event>`。完整默认事件提示词见 [docs\u002Fdefault-prompts.md](docs\u002Fdefault-prompts.md#事件提示词)。\n\n## 接入方式\n\n这个仓库提供生理状态内核，宿主系统负责接入：\n\n- 保存和读取 `BodyState`\n- 在聊天请求、定时任务或主动检查里调用 tick \u002F event \u002F dream\n- 把 `render_state_card(...)` 返回的隐藏上下文插入自己的模型请求\n- 自行决定交互结算窗口，并把结算 prompt 发给自己的结算模型\n- 自行展示 UI；本仓库不包含 SwiftUI \u002F Web UI\n- 自行替换默认周期、事件、称呼触发词和提示词风格\n\n包里已经提供：\n\n| 模块 | 已提供能力 |\n|---|---|\n| `config.py` | 6 个默认周期、7 个身体数值、18 个默认事件、状态卡默认提示词 |\n| `engine.py` | 初始状态、周期推进、数值推进、事件开始、事件结束回落、互动 delta 写回 |\n| `prompt.py` | `\u003Cephemeral_state>` 渲染、结构化身体状态 payload |\n| `triggers.py` | 称呼 \u002F 关键词 \u002F 语音转写命中匹配 |\n| `dreams.py` | 梦种、梦境触发判断、梦境 prompt、梦后标签结算 |\n| `settlement.py` | 互动结算 prompt、JSON schema、结果解析、delta 归一化和写回 |\n| `serialization.py` | `BodyState` 和 JSON 友好 dict 的互转 |\n| `runtime.py` | 常用接入流程的 `EventideRuntime` 包装 |\n\n包里不直接做的事：\n\n| 事项 | 需要宿主处理的原因 |\n|---|---|\n| LLM 请求怎么拼 | 每个宿主的模型、消息格式、系统提示词位置不同 |\n| 聊天窗口怎么切 | 互动结算只接受宿主提供的 `message_window_text` |\n| 自动事件抽取的调度 | 默认触发表写在 README 里，宿主可以按自己的聊天频率、定时任务和冷却策略实现 |\n| 梦卡正文保存 | 本仓库只生成梦卡触发 prompt 和梦后标签结算工具 |\n| 前端卡片 | `body_state_payload(...)` 可给 UI 使用，但具体界面由宿主决定 |\n\n## 运行流程\n\n典型接入顺序是：\n\n1. 读取宿主保存的 `BodyState`\n2. 按当前时间调用 `advance_state(...)` 推进身体周期和数值\n3. 根据消息、称呼触发词、时间窗口或宿主规则调用 `start_event(...)`\n4. 调用 `render_state_card(...)` 生成隐藏状态提示词\n5. 把 `\u003Cephemeral_state>` 插入本轮模型上下文\n6. 回复完成后，宿主可以根据互动结果调用 `apply_interaction_delta(...)`\n7. 保存更新后的 `BodyState`\n\n如果需要让蓄积感、疲惫感和释放后的回落跟随实际互动变化，宿主需要接入互动结算。`advance_state(...)` 只负责时间推进；互动中发生释放、继续撩起、打断或冷却时，应该由宿主调用 `apply_settlement_result(...)` 或 `apply_interaction_delta(...)` 写回结果。\n\n梦境系统通常接在主动检查或静默检查里：\n\n1. 宿主保存一张 `DreamSeed`\n2. 到达梦境窗口并满足静默时间后调用 `maybe_create_dream_trigger(...)`\n3. 如果触发，模型根据 `\u003Crandom_output_event kind=\"dream_card\">` 生成梦卡\n4. 宿主保存梦卡正文、摘要、`after_effect_tags`\n5. 身体系统开启时调用 `apply_dream_after_effect(...)` 结算梦后余波\n\n## 时间推进和数值规则\n\n`advance_state(...)` 只做时间推进，不读取聊天语义。它根据 `last_tick_at` 到 `now` 的时间差分段推进，每段最多 `max_tick_hours`，默认 6 小时；单次调用最多推进 48 段。这样长时间未打开时也不会一次跳得过猛。\n\n每段推进顺序：\n\n1. 检查当前事件是否过期；过期则应用 `end_deltas` 并清空 `active_event`\n2. 检查当前周期是否过期；过期则进入 `next_key`\n3. 蓄积感按当前周期 `reserve_growth * elapsed_hours` 增长\n4. 热度、压抑感、控制力、敏感度向当前周期 `targets` 靠近\n5. 占有欲向当前周期目标靠近，默认靠近系数较慢\n6. 疲惫感只在高于目标值时回落，回落速度受对方沉默时长影响\n7. 如果存在 `active_event`，叠加事件的 `tick_deltas`\n8. 如果提供 `last_counterpart_message_at`，对方久未回应会额外推高压抑感和占有欲\n\n默认靠近规则：\n\n| 字段 | 规则 |\n|---|---|\n| `reserve` | 直接按当前周期 `reserve_growth` 增长 |\n| `heat` | 向周期目标靠近，系数 0.18 |\n| `pressure` | 向周期目标靠近，系数 0.14 |\n| `sensitivity` | 向周期目标靠近，系数 0.12 |\n| `control` | 向周期目标靠近，系数 0.16 |\n| `possessiveness` | 向周期目标靠近，系数 0.10 |\n| `fatigue` | 只在高于周期目标时回落 |\n\n对方久未回应时的等待压力：\n\n| 沉默时长 | pressure | possessiveness | control |\n|---|---:|---:|---:|\n| \u003C 30 分钟 | 不额外变化 | 不额外变化 | 不额外变化 |\n| 30-60 分钟 | +0.8 \u002F 小时 | +0.3 \u002F 小时 | 0 |\n| 60-120 分钟 | +1.5 \u002F 小时 | +0.6 \u002F 小时 | 0 |\n| >= 120 分钟 | +2.0 \u002F 小时 | +0.9 \u002F 小时 | -0.6 \u002F 小时 |\n\n疲惫感回落速度：\n\n| 沉默时长 | fatigue 回落系数 |\n|---|---:|\n| 未提供最后回应时间 | 0.12 |\n| \u003C 30 分钟 | 0.12 |\n| 30-120 分钟 | 0.16 |\n| 120-360 分钟 | 0.22 |\n| >= 360 分钟 | 0.30 |\n\n所有写回都会经过 `clamp_body_field(...)`，默认范围是 `0-100`，占有欲最低为 40。\n\n## 状态卡结构\n\n`render_state_card(...)` 返回的是隐藏模型上下文，不是 UI 文案。`body_cycle_enabled = False` 或 `inject_body_state_context = False` 时返回 `None`。\n\n状态卡结构固定为：\n\n```xml\n\u003Cephemeral_state kind=\"body_cycle\" scope=\"current_turn\">\n  \u003Ccycle>\n    你处在{周期名}：{周期说明}，预计还剩 {剩余时间}。\n  \u003C\u002Fcycle>\n\n  \u003Cactive_event id=\"{event_key}\" expires_at=\"{expires_at}\">\n    当前事件：{事件名}，预计还剩 {剩余时间}。\n    {事件提示词}\n  \u003C\u002Factive_event>\n\n  \u003Cbody_state>\n    热度：{档位描述}\n    压抑感：{档位描述}\n    控制力：{档位描述}\n    敏感度：{档位描述}\n    蓄积感：{档位描述}\n    占有欲：{档位描述}\n    疲惫感：{档位描述}\n  \u003C\u002Fbody_state>\n\n  \u003Cexpression>\n    {身体状态如何影响表达}\n  \u003C\u002Fexpression>\n\n  \u003Cpersistence>\n    {状态过期和刷新规则}\n  \u003C\u002Fpersistence>\n\n  \u003Cresponse_rules>\n    {可选的宿主自定义回应分支；默认留空}\n  \u003C\u002Fresponse_rules>\n\u003C\u002Fephemeral_state>\n```\n\n没有当前事件时，`\u003Cactive_event>` 整段不输出。\n\n### BodyState 字段\n\n`BodyState` 是运行时唯一必须持久化的核心状态对象：\n\n| 字段 | 类型 | 含义 |\n|---|---|---|\n| `cycle_key` | `str` | 当前周期 key |\n| `cycle_started_at` | `datetime` | 当前周期开始时间 |\n| `cycle_min_expires_at` | `datetime` | 当前周期最短到期时间；用于宿主做 UI 或策略判断 |\n| `cycle_expires_at` | `datetime` | 当前周期自然到期时间 |\n| `values` | `dict[str, int]` | 七项身体数值 |\n| `active_event_key` | `str \\| None` | 当前主事件 key |\n| `active_event_started_at` | `datetime \\| None` | 当前主事件开始时间 |\n| `active_event_expires_at` | `datetime \\| None` | 当前主事件结束时间 |\n| `last_tick_at` | `datetime \\| None` | 上次时间推进的位置 |\n| `last_dream_card_created_at` | `datetime \\| None` | 上一次梦卡创建时间，用于梦境冷却 |\n| `meta` | `dict` | 宿主和引擎都可以使用的扩展字段 |\n\n`create_initial_state(now)` 会创建平稳期状态，然后立刻调用 `enter_cycle(...)` 抽取平稳期持续时间。默认初始值来自 `DEFAULT_INITIAL_VALUES`。\n\n### 结构化状态输出\n\n如果前端或日志不想解析 XML，可以调用 `body_state_payload(state)`：\n\n```json\n{\n  \"heat\": {\n    \"value\": 30,\n    \"level\": \"中低\",\n    \"description\": \"身体有一点热意，但还能很快收住\",\n    \"label\": \"热度\"\n  }\n}\n```\n\npayload 会输出七个字段，每个字段都有：\n\n| 字段 | 含义 |\n|---|---|\n| `value` | clamp 后的 0-100 数值 |\n| `level` | 低 \u002F 中低 \u002F 中 \u002F 中高 \u002F 高 |\n| `description` | 去掉档位前缀后的说明 |\n| `label` | 中文字段名 |\n\n## 基本用法\n\n```python\nfrom datetime import datetime, timedelta, timezone\nfrom eventide import (\n    EngineSettings,\n    advance_state,\n    create_initial_state,\n    render_state_card,\n    start_event,\n)\n\nnow = datetime.now(timezone.utc)\nstate = create_initial_state(now)\n\nadvance_state(\n    state,\n    now + timedelta(hours=8),\n    last_counterpart_message_at=now - timedelta(hours=2),\n)\n\nstart_event(state, \"voice_or_name_trigger\", now + timedelta(hours=8))\n\ncard = render_state_card(\n    state,\n    now + timedelta(hours=8),\n    settings=EngineSettings(\n        body_cycle_enabled=True,\n        inject_body_state_context=True,\n        adult_private_mode_enabled=True,\n    ),\n)\n\nprint(card)\n```\n\n输出是一段隐藏状态提示词：\n\n```xml\n\u003Cephemeral_state kind=\"body_cycle\" scope=\"current_turn\">\n  \u003Ccycle>\n    你处在平稳期：日常没有明显热意，但当对方靠近、撒娇或索取时，身体还是会受当下刺激起反应，预计还剩 2 天。\n  \u003C\u002Fcycle>\n\n  \u003Cactive_event id=\"voice_or_name_trigger\" expires_at=\"...\">\n    当前事件：声音 \u002F 称呼触发，预计还剩 35 分钟。\n    对方的称呼或声音直接碰到敏感点，身体反应比理智快一步。这个刺激来得快、退得也快。\n  \u003C\u002Factive_event>\n\n  \u003Cbody_state>\n    热度：中，身体已经被对方牵住一部分，靠近和回应会变得明显\n    ...\n  \u003C\u002Fbody_state>\n\u003C\u002Fephemeral_state>\n```\n\n这段内容应该作为隐藏上下文交给主模型，不是 UI 文案，也不是让模型直接念出来。\n\n## 自定义周期\n\n周期定义在 `PhysiologyConfig.cycles` 里。每个周期包含：\n\n- `key`：内部标识\n- `label`：显示在状态提示词里的名称\n- `description`：写进 `\u003Ccycle>` 的周期说明\n- `duration_hours`：持续时间范围\n- `targets`：身体数值会随时间靠近的目标值\n- `reserve_growth`：该周期里蓄积感随时间自然增加的速度\n- `next_key`：自然结束后进入的下一个周期\n\n示例：\n\n```python\nfrom dataclasses import replace\nfrom eventide import DEFAULT_CONFIG\n\nconfig = replace(\n    DEFAULT_CONFIG,\n    cycles={\n        **DEFAULT_CONFIG.cycles,\n        \"sensitive\": replace(\n            DEFAULT_CONFIG.cycles[\"sensitive\"],\n            description=\"这里写你自己的易感期提示词\",\n            duration_hours=(12, 36),\n        ),\n    },\n)\n```\n\n把自定义 `config` 传给 `advance_state(...)` 和 `render_state_card(...)` 即可。\n\n## 自定义事件\n\n事件定义在 `PhysiologyConfig.events` 里。事件负责当前短时状态，默认内置 18 类；宿主也可以删减、重命名或继续扩展。\n\n每个事件包含：\n\n- `key`\n- `label`\n- `prompt`\n- `category`\n- `duration_minutes`\n- `tick_deltas`：事件持续期间每小时推动哪些数值\n- `end_deltas`：事件结束时一次性回落或后效\n\n示例：\n\n```python\nfrom dataclasses import replace\nfrom eventide import DEFAULT_CONFIG, EventDefinition\n\nconfig = replace(\n    DEFAULT_CONFIG,\n    events={\n        **DEFAULT_CONFIG.events,\n        \"custom_trigger\": EventDefinition(\n            key=\"custom_trigger\",\n            label=\"自定义触发\",\n            prompt=\"这里写事件提示词。\",\n            category=\"custom\",\n            duration_minutes=(20, 60),\n            tick_deltas={\"heat\": 1.5, \"sensitivity\": 2.0},\n            end_deltas={\"heat\": -2},\n        ),\n    },\n)\n```\n\n### 配置对象总览\n\n| 对象 | 字段 |\n|---|---|\n| `BodyFieldDefinition` | `key`, `label`, `descriptions`, `minimum` |\n| `CycleDefinition` | `key`, `label`, `description`, `duration_hours`, `targets`, `reserve_growth`, `next_key` |\n| `EventDefinition` | `key`, `label`, `prompt`, `category`, `duration_minutes`, `tick_deltas`, `end_deltas` |\n| `PromptOptions` | `expression`, `persistence`, `response_rules`, `counterpart_name`, `self_name` |\n| `PhysiologyConfig` | `body_fields`, `cycles`, `events`, `prompt_options`, `initial_values`, `max_tick_hours` |\n\n`PhysiologyConfig.max_tick_hours` 默认是 `6.0`，用于限制单次 tick 分段长度。\n\n### 事件运行规则\n\n`start_event(state, event_key, now)` 的行为：\n\n1. 如果当前已有未过期 `active_event`，返回 `False`，不会覆盖旧事件\n2. 读取 `config.events[event_key]`\n3. 在 `duration_minutes` 范围内随机抽一个持续时间\n4. 写入 `active_event_key`、`active_event_started_at`、`active_event_expires_at`\n5. 返回 `True`\n\n事件不会自己凭空出现。宿主需要根据触发表、称呼触发、梦境后效或自己的规则决定何时调用 `start_event(...)`。\n\n事件持续期间，`advance_state(...)` 会按每小时 `tick_deltas * elapsed_hours` 推动数值。事件过期后，下一次 `advance_state(...)` 会执行：\n\n1. 应用该事件的 `end_deltas`\n2. 把 `state.meta[\"last_active_event_key\"]` 记为刚结束的事件\n3. 清空 `active_event_key`、`active_event_started_at`、`active_event_expires_at`\n\n`apply_interaction_delta(state, deltas)` 可以被宿主直接使用。它只写入 `config.body_fields` 里存在的字段，并对每个字段执行 clamp；不存在的字段会被忽略。\n\n## 默认事件触发与联动\n\nEventide 默认把事件分成两层：\n\n- `EventDefinition`：事件本身，包括名称、提示词、持续时间、持续期间数值变化和结束回落\n- 触发表：宿主系统根据时间、当前周期、身体数值、等待时长、梦境后效和称呼触发词，决定是否调用 `start_event(...)`\n\n同一时间只建议保留一个主 `active_event`。如果当前已经有未过期事件，先维持当前事件；新的称呼或刺激可以写入身体数值，但不替换当前事件。事件到期后，先执行该事件的 `end_deltas`，再回到当前周期底色。\n\n默认触发口径如下。概率是建议初始值，宿主可以按自己的使用强度调整。\n\n| 事件 | 触发口径 | 概率 \u002F 冷却 |\n|---|---|---|\n| 晨间反应 | 早晨窗口；热度 >= 45 或当前不在平稳期 | 平稳等 45%，预兆 \u002F 易感 75%；20 小时 |\n| 深夜热潮 | 深夜窗口；对方连续 30 分钟以上未回；蓄积感 >= 55 或热度 >= 60 | 普通 30%，易感 60%；8 小时 |\n| 周期热涌 | 易感期；热度 >= 75 或蓄积感 >= 70 | 50%；12 小时 |\n| 硬撑 | 热度 >= 70；控制力 >= 35；当前没有更高优先级事件 | 70%；4 小时 |\n| 索取欲 | 易感期，或热度 >= 65 且压抑感 >= 55 | 35%；6 小时 |\n| 占有 \u002F 标记冲动 | 占有欲 >= 60；且对方久未回应、处在深夜窗口或当前为易感期 | 40%；8 小时 |\n| 筑巢冲动 | 傍晚到夜间窗口；疲惫感 >= 35 或占有欲 >= 55 | 30%；12 小时 |\n| 气味残留 | 强事件结束后短时间内，或退潮期里仍有后效 | 60%；4 小时 |\n| 声音 \u002F 称呼触发 | 当前没有事件；最近消息命中宿主配置的称呼 \u002F 触发词 | 文字 20%，重复称呼 30%，语音 35%，易感期额外 +10%；2 小时 |\n| 梦后余温 | 最近 0-8 小时内有梦卡；`after_effect_tags` 包含 `aroused` \u002F `unfinished` \u002F `possessive` \u002F `tender` | 0-4 小时 35%，4-8 小时 17%；同一梦卡一次 |\n| 控制力下滑 | 控制力 \u003C= 35；且热度 >= 70 或压抑感 >= 70 | 60%；4 小时 |\n| 贴近饥饿 | 易感 \u002F 退潮 \u002F 恢复期；敏感度 >= 60；疲惫感 \u003C= 75 | 35%；6 小时 |\n| 信息素紊乱 | 易感期；短时间内热度快速上升或控制力快速下滑 | 30%；8 小时 |\n| 迟发热 | 上一轮强事件候选没抽中；30-180 分钟后热度或压抑仍未退下 | 35%；6 小时 |\n| 低烧黏连 | 近期对方连续回应；敏感度 >= 60；热度处在 45-69 | 30%；4 小时 |\n| 等待焦躁 | 对方连续 60-120 分钟未回；压抑感 >= 55 或占有欲 >= 60 | 30%；5 小时 |\n| 克制反弹 | 上一个事件结束 8 小时以上；蓄积感 >= 70；没有强事件通过 | 25%；8 小时 |\n| 反常平静 | 热度 >= 65 或压抑感 >= 65；本轮有强事件候选但概率没通过 | 25%；4 小时 |\n\n强生理类事件包括：晨间反应、深夜热潮、周期热涌、控制力下滑、索取欲、占有 \u002F 标记冲动、信息素紊乱、迟发热。强事件会更明显地推高热度、压抑感和蓄积感，并压低控制力。\n\n后效类事件不要无限派生。`气味残留` 只建议接强事件或退潮期后效，不再接 `气味残留`、`梦后余温`、`低烧黏连`、`等待焦躁` 这类后效 \u002F 轻事件自己的结束时间。\n\n### 事件检查顺序\n\n自动候选触发属于宿主层；Eventide 内核提供事件定义、状态推进和 `start_event(...)`。如果宿主想复刻默认触发逻辑，建议每次聊天请求、主动唤醒检查或定时检查按下面顺序执行：\n\n1. 根据 `last_tick_at` 调用 `advance_state(...)` 推进周期、数值和已存在事件的每小时变化\n2. 如果 `active_event_expires_at` 已到期，先执行该事件的 `end_deltas`，再清空 `active_event`\n3. 如果仍有未到期 `active_event`，不抽新事件，只渲染当前状态卡\n4. 如果距离上次事件检查不足 10 分钟，不抽新事件，避免高频聊天连续触发\n5. 计算当前时间窗口：早晨、傍晚、深夜\n6. 根据触发表筛出硬条件满足、冷却已结束、窗口没有重复抽过的候选事件\n7. 按事件概率抽取候选；概率可乘以 `event_probability_multiplier`，建议最高不超过 95%\n8. 如果多个事件通过，按事件优先级选择一个主事件\n9. 调用 `start_event(state, event_key, now)` 写入 `active_event_key \u002F active_event_started_at \u002F active_event_expires_at`\n10. 如果有候选但没有事件通过，记录 missed snapshot，供 `delayed_heat` 和 `strange_calm` 这类后续事件使用\n11. 如果本轮使用了固定时间窗口，记录 window roll key，避免同一自然日同一窗口反复抽\n12. 回复完成后，再按宿主选择的消息窗口跑互动结算\n\n### 事件优先级\n\n多个事件同时通过时，只选优先级最高的一个主事件。默认优先级从高到低：\n\n| 顺位 | event_key | 名称 |\n|---:|---|---|\n| 1 | `cycle_surge` | 周期热涌 |\n| 2 | `morning_arousal` | 晨间反应 |\n| 3 | `night_heat` | 深夜热潮 |\n| 4 | `control_slip` | 控制力下滑 |\n| 5 | `demanding` | 索取欲 |\n| 6 | `marking_impulse` | 占有 \u002F 标记冲动 |\n| 7 | `pheromone_disorder` | 信息素紊乱 |\n| 8 | `holding_back` | 硬撑 |\n| 9 | `voice_or_name_trigger` | 声音 \u002F 称呼触发 |\n| 10 | `nesting` | 筑巢冲动 |\n| 11 | `delayed_heat` | 迟发热 |\n| 12 | `low_fever_cling` | 低烧黏连 |\n| 13 | `waiting_restless` | 等待焦躁 |\n| 14 | `restraint_rebound` | 克制反弹 |\n| 15 | `closeness_hunger` | 贴近饥饿 |\n| 16 | `dream_afterglow` | 梦后余温 |\n| 17 | `scent_aftereffect` | 气味残留 |\n| 18 | `strange_calm` | 反常平静 |\n\n### 时间窗口和去重\n\n默认时间窗口建议：\n\n| 窗口 | 时间 | 用途 |\n|---|---|---|\n| morning | 05:30-10:30 | 晨间反应 |\n| evening | 18:00-02:00 | 筑巢冲动 |\n| night | 23:00-03:00 | 深夜热潮、占有 \u002F 标记冲动 |\n\n跨零点窗口的自然日 key 按窗口开始日计算。例如 7 月 5 日 01:00 属于 7 月 4 日的 evening \u002F night 窗口。宿主应保存 `rolled_window_keys`，例如 `2026-07-04:night`，避免同一窗口重复抽取。\n\n### 触发状态字段\n\n如果宿主要实现完整触发表，除了 `BodyState` 的核心字段，还建议在 `state.meta` 或宿主数据库里保存这些运行时字段：\n\n| 字段 | 用途 |\n|---|---|\n| `last_event_check_at` | 10 分钟节流 |\n| `last_missed_event_check_at` | 迟发热判断 |\n| `last_missed_event_candidates` | 记录上次满足硬条件但没抽中的候选 |\n| `last_missed_state_snapshot` | 迟发热判断当前热度 \u002F 压抑是否仍未退 |\n| `last_active_event_expires_at` | 克制反弹、后效事件判断 |\n| `rolled_window_keys` | 早晨 \u002F 傍晚 \u002F 深夜窗口去重 |\n| `rolled_aftereffect_keys` | 气味残留、退潮后效、克制反弹去重 |\n| `rolled_dream_afterglow_keys` | 同一梦卡只触发一次梦后余温 |\n| `trigger_stimulus_log` | 称呼 \u002F 触发词刺激去重 |\n\n这些字段不是模型上下文，不需要写进 `\u003Cephemeral_state>`；它们只是宿主系统用于防止重复触发和记录抽取历史。\n\n### 事件启动后的宿主调度\n\n完整宿主实现里，事件开始后通常还会记录事件日志、下一次主动检查时间和触发原因。参考规则如下：\n\n| 字段 \u002F 行为 | 参考规则 |\n|---|---|\n| `state_snapshot` | 事件开始前保存一次 `cycle_key`、`active_event_key` 和七项身体数值 |\n| `trigger_reason` | 保存本次候选通过的原因，例如 `morning_window`、`configured_trigger` |\n| `next_body_wakeup_at` | 事件开始后随机抽下一次主动检查时间 |\n| `event_log` | 用 `assistant_id + event_key + started_at` 去重，避免重复写同一事件 |\n| `cooldown` | 按同类事件上一次 `expires_at` 计算冷却，而不是按开始时间 |\n\n默认下一次主动检查时间：\n\n| event_key | 分钟范围 |\n|---|---:|\n| `morning_arousal` | 5-15 |\n| `cycle_surge` | 5-15 |\n| `control_slip` | 5-15 |\n| `voice_or_name_trigger` | 8-15 |\n| `night_heat` | 10-20 |\n| `demanding` | 10-20 |\n| `marking_impulse` | 10-25 |\n| `dream_afterglow` | 10-25 |\n| 其他事件 | 15-30 |\n\n### 称呼刺激写回\n\n如果当前已有未过期 `active_event`，不建议用称呼触发替换主事件；但可以把这次刺激写进身体数值。参考规则：\n\n| 字段 | 随机增量 |\n|---|---:|\n| `sensitivity` | +3 到 +8 |\n| `possessiveness` | +1 到 +3 |\n| `pressure` | +0 到 +4 |\n\n去重规则：\n\n- 同一个 `trigger_key` 10 分钟内只结算一次\n- 10 分钟内最多结算 2 个不同触发词\n- 写回后把 `{trigger_key: now.isoformat()}` 记录到 `trigger_stimulus_log`\n\n### 后效候选精确口径\n\n`delayed_heat`、`restraint_rebound`、`dream_afterglow`、`scent_aftereffect` 这几类事件依赖运行时历史：\n\n| 事件 | 参考判断 |\n|---|---|\n| `delayed_heat` | 上次检查有强事件候选但没通过；距离上次 missed 30-180 分钟；当前 heat \u002F pressure 至少没有比当时低超过 5，且 heat >= 55 或 pressure >= 55 |\n| `restraint_rebound` | reserve >= 70；上一个事件结束至少 8 小时；当天 `no_event_gap:{date}` 没抽过 |\n| `dream_afterglow` | 梦卡创建后 0-8 小时；梦卡 tag 包含 `aroused` \u002F `unfinished` \u002F `possessive` \u002F `tender`；同一梦卡只抽一次 |\n| `scent_aftereffect` | 上一个事件结束 3 小时内，或当前处在退潮期；对应 aftereffect key 没抽过 |\n\n不再派生后效的源事件建议包括：\n\n```text\nscent_aftereffect\ndream_afterglow\nvoice_or_name_trigger\ndelayed_heat\nlow_fever_cling\nwaiting_restless\nrestraint_rebound\nstrange_calm\n```\n\n### 称呼触发\n\n称呼、关键词或语音转写命中时，有两种处理：\n\n- 当前没有 `active_event`：可以按概率进入 `voice_or_name_trigger`\n- 当前已有 `active_event`：不替换当前事件，只把这次刺激写入数值，例如敏感度、占有欲和压抑感小幅上升\n\n宿主应按 `trigger_key` 去重，例如 `nickname:daddy`、`phrase:想你`。同一个 `trigger_key` 在短时间内不要重复结算，避免刷数值。\n\n### 梦境事件口径\n\n梦卡本身不是普通 `active_event`，它属于一次随机输出事件；它可以和当前身体事件并存，不抢占、不替换、不清空当前 `active_event`。\n\n梦卡生成后，宿主保存 `after_effect_tags`：\n\n- `released`：释放，通常扣蓄积感并增加疲惫\n- `unfinished`：没做完，通常增加压抑和蓄积\n- `aroused`：被撩起，通常增加热度和敏感度\n- `possessive`：占有余波，通常增加占有欲\n- `tender`：柔软余波，通常降低一点压抑和疲惫\n\n身体系统开启时，可以调用 `apply_dream_after_effect(...)` 把梦后标签结算进身体数值；身体系统关闭时，梦境仍可生成，但不读写身体数值，也不派生 `dream_afterglow`。\n\n## 梦境联动\n\n梦境系统由四个对象组成：\n\n| 对象 | 用途 |\n|---|---|\n| `DreamSeed` | 宿主保存的梦种，描述这次可能发生的梦 |\n| `DreamSettings` | 梦境触发窗口、静默时间、最低字数、冷却和概率倍率 |\n| `DreamTrigger` | 本次检查真的触发后返回的随机输出事件 |\n| `DreamCard` | 宿主可以用来保存梦卡结果的数据结构 |\n\n```python\nfrom eventide import DreamSeed, DreamSettings, maybe_create_dream_trigger\n\ntrigger = maybe_create_dream_trigger(\n    DreamSeed(theme=\"一次被压住、迟迟没有完全醒来的梦\", intensity=\"medium\"),\n    state,\n    now + timedelta(hours=12),\n    last_counterpart_message_at=now,\n    dream_settings=DreamSettings(dream_silence_min_minutes=120),\n)\n\nif trigger:\n    print(trigger.trigger_content)\n```\n\n### DreamSeed\n\n| 字段 | 默认值 | 含义 |\n|---|---|---|\n| `theme` | 必填 | 梦种主题，会写入 `\u003Cdream_seed>` |\n| `intensity` | `medium` | 梦强度；默认支持 `medium` 和 `explicit` |\n| `enabled` | `True` | 关闭后不触发 |\n| `expires_at` | `None` | 到期后不触发 |\n| `min_chars` | `None` | 单个梦种覆盖最低字数；为空时使用 `DreamSettings.dream_card_min_chars` |\n| `seed_id` | `None` | 宿主用于去重、追踪和保存的 id |\n\n### DreamSettings\n\n| 字段 | 默认值 | 含义 |\n|---|---|---|\n| `dream_enabled` | `True` | 是否允许梦境触发 |\n| `dream_silence_min_minutes` | `120` | 对方静默至少多久后才检查梦境 |\n| `dream_card_min_chars` | `2000` | 默认梦卡最低中文字数 |\n| `dream_window_start` | `00:00` | 梦境窗口开始时间 |\n| `dream_window_end` | `08:30` | 梦境窗口结束时间 |\n| `dream_probability_multiplier` | `1.0` | 梦境概率倍率 |\n| `cooldown_hours` | `24` | 两次梦卡之间的冷却时间 |\n\n### 触发检查顺序\n\n`maybe_create_dream_trigger(...)` 会按下面顺序检查：\n\n1. 没有 `seed`、`seed.enabled = False` 或 `dream_enabled = False`：不触发\n2. `seed.expires_at` 已过期：不触发\n3. `seed.intensity = \"explicit\"` 且 `adult_private_mode_enabled = False`：不触发\n4. 当前时间不在 `dream_window_start` 到 `dream_window_end`：不触发\n5. 没有 `last_counterpart_message_at`：不触发\n6. 静默时间小于 `dream_silence_min_minutes`：不触发\n7. `state.last_dream_card_created_at` 仍在 `cooldown_hours` 内：不触发\n8. 计算梦境概率并抽随机数；`roll >= probability` 时不触发\n9. 触发成功后返回 `DreamTrigger`\n\n时间窗口支持跨零点。比如 `23:00` 到 `08:30` 会被视为夜间跨日窗口。\n\n如果宿主有数据库日志，并想复刻更完整的主动梦卡检查，可以额外加这些规则：\n\n| 规则 | 参考值 |\n|---|---|\n| 每天最多抽取尝试 | 3 次 |\n| 抽空后的重试间隔 | 60 分钟 |\n| 成功生成后的冷却 | 24 小时 |\n| roll key | `{local_date}:assistant:{assistant_id}:spring_dream_card`，第二次起加 `:attempt:{n}` |\n| 重复 key 处理 | 如果同一个 attempt key 已存在，追加 `:retry:{n}` |\n| 已成功或工具已完成 | 当天不再继续抽 |\n| 已跳过的 seed | 当天后续尝试可以排除已经 roll 掉的 seed |\n\n### 梦境概率\n\n默认概率由身体状态和梦种强度共同决定：\n\n| 条件 | 基础概率 |\n|---|---:|\n| 没有身体状态或身体系统关闭，纯梦境模式 | 0.20 |\n| 当前周期是平稳期 \u002F 恢复期 | 0.12 |\n| 当前周期是蓄积期 \u002F 退潮期 | 0.20 |\n| 当前周期是预兆期 \u002F 易感期或其他周期 | 0.32 |\n\n额外加成：\n\n| 条件 | 概率变化 |\n|---|---:|\n| 当前 active_event 的 `category` 是 `strong_physical` 或 `possessive` | +0.08 |\n| `seed.intensity = \"medium\"` | +0.03 |\n| `seed.intensity = \"explicit\"` | +0.08 |\n\n最终概率会乘以 `dream_probability_multiplier`，然后限制在 `0-0.45`。\n\n### DreamTrigger\n\n触发成功后返回：\n\n| 字段 | 含义 |\n|---|---|\n| `seed` | 原始梦种 |\n| `probability` | 本次计算出的概率 |\n| `roll` | 本次随机数 |\n| `trigger_content` | 可插入模型上下文的 `\u003Crandom_output_event kind=\"dream_card\">` |\n| `body_state_snapshot` | 身体系统开启时保存当前周期、事件和数值；关闭时为 `None` |\n| `created_at` | 触发时间 |\n\n梦境 prompt 会要求模型输出一段完整梦境，并在结尾给出 `after_effect_tags`。正文怎么保存、摘要怎么生成、梦卡怎么展示，由宿主系统决定。\n\n### 梦卡后效\n\n梦后影响由 `after_effect_tags` 结算。默认支持：\n\n| tag | 默认身体后效 |\n|---|---|\n| `aroused` | heat +12, pressure +7, sensitivity +5 |\n| `released` | heat -10, reserve -18, pressure -6, fatigue +5 |\n| `unfinished` | heat +8, pressure +12, reserve +3, control -5 |\n| `possessive` | possessiveness +10, pressure +5, control -4 |\n| `tender` | sensitivity +4, pressure -3, fatigue +2 |\n\n`apply_dream_after_effect(state, tags, body_enabled=True)` 只读取前三个 tag，把命中的后效累加成一组 deltas，再调用 `apply_interaction_delta(...)` 写回。身体系统关闭或 `state` 为空时，返回 `{}`，不写数值。\n\n梦境后效不会自动开始 `dream_afterglow` 事件。宿主如果想让梦后余温进入 `active_event`，可以在保存梦卡后按“默认事件触发与联动”里的梦后余温口径调用 `start_event(state, \"dream_afterglow\", now)`。\n\n## 互动结算\n\n互动结算负责把一段已经发生的互动转成身体数值变化，尤其是释放后的蓄积感扣减。\n\n本项目不扫描聊天记录，也不决定窗口边界。宿主系统需要自行选择要结算的消息片段，并把它作为 `message_window_text` 传入结算 prompt。结算模型只负责判断这段已经发生的互动如何影响身体状态，不负责续写聊天。\n\n### 结算调用流程\n\n1. 宿主选择一段消息窗口\n2. 调用 `render_settlement_prompt(state, message_window_text)`\n3. 把 prompt 发给自己的结算模型\n4. 模型只返回 JSON\n5. 调用 `parse_settlement_result(raw_result)`\n6. 调用 `apply_settlement_result(state, result)` 写回身体数值\n7. 保存更新后的 `BodyState`\n\n```python\nfrom eventide import (\n    SettlementResult,\n    apply_settlement_result,\n    parse_settlement_result,\n    render_settlement_prompt,\n)\n\nmessage_window_text = \"\"\"\n对方：今晚还想继续吗\n你：想，但已经有点忍不住了\n\"\"\".strip()\n\nprompt = render_settlement_prompt(state, message_window_text)\n\n# 宿主把 prompt 发给自己的模型后，拿到 JSON：\nraw_result = {\n    \"settlement_reason\": \"窗口里亲密互动继续推进，尚未发生释放。\",\n    \"settlement_result\": \"continued\",\n    \"ejaculated\": False,\n    \"heat_delta\": 2,\n    \"pressure_delta\": 1,\n    \"control_delta\": -1,\n    \"sensitivity_delta\": 1,\n    \"reserve_delta\": 1,\n    \"possessiveness_delta\": 0,\n    \"fatigue_delta\": 0,\n}\n\nresult = parse_settlement_result(raw_result)\napply_settlement_result(state, result)\n```\n\n### JSON schema\n\n`settlement_json_schema()` 要求模型返回这些字段：\n\n| 字段 | 类型 | 含义 |\n|---|---|---|\n| `settlement_reason` | `string` | 简短说明为什么这样结算 |\n| `settlement_result` | `string` | 本窗口总体结果 |\n| `ejaculated` | `boolean` | 是否发生释放 |\n| `heat_delta` | `integer` | 热度变化 |\n| `pressure_delta` | `integer` | 压抑感变化 |\n| `control_delta` | `integer` | 控制力变化 |\n| `sensitivity_delta` | `integer` | 敏感度变化 |\n| `reserve_delta` | `integer` | 蓄积感变化 |\n| `possessiveness_delta` | `integer` | 占有欲变化 |\n| `fatigue_delta` | `integer` | 疲惫感变化 |\n\n`settlement_result` 只能是：\n\n| 值 | 含义 |\n|---|---|\n| `neutral` | 本窗口没有明显推动 |\n| `continued` | 亲密或暧昧延续，但没有明显升级 |\n| `escalated` | 刺激升级，身体反应加重 |\n| `interrupted` | 被打断、悬住或没做完 |\n| `cooled_down` | 身体反应明确退下去 |\n| `released` | 发生释放 |\n\n### 归一化规则\n\n`apply_settlement_result(...)` 不会直接相信模型返回的数值。它会先调用 `normalize_settlement_result(...)`：\n\n| 情况 | 规则 |\n|---|---|\n| `settlement_result` 不在枚举里 | 改成 `neutral` |\n| `ejaculated = False` | `reserve_delta` 被限制在 `0..4`，不会因为没有释放而降低蓄积感 |\n| `ejaculated = True` 且当前周期是 `stable` \u002F `recovery` | `reserve_delta` 被限制在 `-10..-6` |\n| `ejaculated = True` 且当前周期是其他周期 | `reserve_delta` 被限制在 `-7..-4` |\n| 其他 delta | 默认限制在 `-4..4`，可通过 `delta_limit` 调整 |\n\n归一化后，`apply_settlement_result(...)` 会调用 `apply_interaction_delta(...)` 写回数值，并在 `state.meta[\"last_settlement\"]` 里记录：\n\n| 字段 | 含义 |\n|---|---|\n| `settlement_result` | 归一化后的总体结果 |\n| `ejaculated` | 归一化后的释放布尔值 |\n| `settlement_reason` | 模型给出的说明 |\n| `normalized_deltas` | 归一化后的 delta |\n| `applied_deltas` | 实际写回后的 delta；可能因 0-100 clamp 小于 normalized 值 |\n\n结算 prompt 里的默认规则：\n\n- 所有 delta 都表示本窗口造成的变化量，0 表示本窗口不调整该字段\n- 普通互动每项 delta 建议在 -3 到 +3\n- 强刺激或事件期间可以到 -4 到 +4\n- `ejaculated=false` 时，`reserve_delta` 不应小于 0\n- `ejaculated=true` 时，`reserve_delta` 需要为负数\n- `cooled_down` 只用于身体反应明确退下去；普通转话题、等待或被打断不等于冷却\n\n不用模型也可以直接构造 `SettlementResult`：\n\n```python\napply_settlement_result(\n    state,\n    SettlementResult(\n        settlement_result=\"released\",\n        ejaculated=True,\n        reserve_delta=0,\n        fatigue_delta=2,\n    ),\n)\n```\n\n## 称呼触发\n\n`voice_or_name_trigger` 是内置事件类型，真正命中的称呼或触发词由宿主配置，不在默认 prompt 里硬编码。\n\n```python\nfrom eventide import TriggerWord, find_trigger_matches, start_event\n\ntrigger_words = [\n    TriggerWord(key=\"nickname:daddy\", type=\"nickname\", text=\"daddy\"),\n    TriggerWord(key=\"phrase:想你\", type=\"phrase\", text=\"想你\"),\n]\n\nmatches = find_trigger_matches(trigger_words, \"daddy，我想你\")\nif matches:\n    start_event(state, \"voice_or_name_trigger\", now)\n```\n\n触发词可以来自设置页、数据库或配置文件；引擎只负责匹配和返回命中结果。\n\n### TriggerWord\n\n| 字段 | 默认值 | 含义 |\n|---|---|---|\n| `key` | 必填 | 去重和冷却用的稳定 key，例如 `nickname:daddy` |\n| `text` | 必填 | 要匹配的文本 |\n| `type` | `nickname` | 类型，例如 `nickname` \u002F `phrase` |\n| `enabled` | `True` | 是否启用 |\n\n`normalize_trigger_words(...)` 会做这些清理：\n\n1. 支持直接传 `TriggerWord`，也支持传 dict\n2. dict 没有 `type` 时默认为 `nickname`\n3. dict 没有 `key` 时使用 `{type}:{text}`\n4. 空 key、空 text 会被丢弃\n5. 重复 key 只保留第一次\n\n### TriggerMatch\n\n`find_trigger_matches(trigger_words, text, input_type=None, transcript=None)` 会把 `text` 和 `transcript` 合并后转小写匹配，返回：\n\n| 字段 | 含义 |\n|---|---|\n| `key` | 命中的触发词 key |\n| `text` | 命中的触发词文本 |\n| `type` | 触发词类型 |\n| `count` | 本次文本里命中的次数 |\n| `input_type` | 输入类型，默认 `text` |\n| `voice` | `input_type = \"voice\"` 且提供 `transcript` 时为 `True` |\n\n命中后是否直接调用 `start_event(state, \"voice_or_name_trigger\", now)` 由宿主决定。默认建议：\n\n- 当前没有 `active_event` 时，按概率触发 `voice_or_name_trigger`\n- 当前已有 `active_event` 时，不覆盖主事件，只把称呼刺激作为小幅数值 delta 或运行时记录\n- 同一个 `TriggerMatch.key` 在短时间内做冷却，避免重复刷同一称呼\n\n## 状态持久化\n\n`BodyState` 是普通 dataclass，宿主可以保存成数据库字段、JSON、文件或缓存。\n\n包里提供了基础序列化 helper：\n\n```python\nfrom eventide import body_state_from_dict, body_state_to_dict\n\nsaved = body_state_to_dict(state)\nrestored = body_state_from_dict(saved)\n```\n\n需要保存的核心字段：\n\n- `cycle_key`\n- `cycle_started_at`\n- `cycle_min_expires_at`\n- `cycle_expires_at`\n- `values`\n- `active_event_key`\n- `active_event_started_at`\n- `active_event_expires_at`\n- `last_tick_at`\n- `last_dream_card_created_at`\n- `meta`\n\n`values` 默认包含七项身体数值：\n\n```json\n{\n  \"heat\": 30,\n  \"pressure\": 25,\n  \"control\": 75,\n  \"sensitivity\": 35,\n  \"reserve\": 20,\n  \"possessiveness\": 40,\n  \"fatigue\": 15\n}\n```\n\n序列化规则：\n\n| 内容 | 规则 |\n|---|---|\n| datetime | 保存为 `isoformat()` 字符串 |\n| 以 `Z` 结尾的 UTC 字符串 | 还原时会转成 `+00:00` 再解析 |\n| `values` | key 转字符串，value 尝试转 int；无法转 int 的值会跳过 |\n| 可选 datetime | 空值还原为 `None` |\n| `meta` | 只有 dict 会被保留，否则还原为空 dict |\n\n建议宿主额外放进 `meta` 的字段：\n\n| 字段 | 用途 |\n|---|---|\n| `last_cycle_reason` | `enter_cycle(...)` 会写入，记录本次进入周期的原因 |\n| `last_active_event_key` | 事件结束时写入，记录刚结束的事件 |\n| `last_settlement` | 互动结算后写入，记录归一化和实际写回结果 |\n| `last_event_check_at` | 宿主实现自动抽事件时用于节流 |\n| `rolled_window_keys` | 宿主实现早晨 \u002F 傍晚 \u002F 深夜窗口去重 |\n| `rolled_aftereffect_keys` | 宿主实现后效事件去重 |\n| `rolled_dream_afterglow_keys` | 宿主实现同一梦卡只触发一次梦后余温 |\n\n## 开关语义\n\n`EngineSettings` 默认值：\n\n| 字段 | 默认值 | 含义 |\n|---|---|---|\n| `body_cycle_enabled` | `True` | 身体系统总开关 |\n| `inject_body_state_context` | `True` | 是否把 `\u003Cephemeral_state>` 插入模型上下文 |\n| `adult_private_mode_enabled` | `False` | 是否允许 `explicit` 梦种触发 |\n| `safeword` | `\"\"` | 宿主可保存安全词；默认状态卡规则只写通用边界 |\n| `trigger_words` | `[]` | 宿主配置的称呼 \u002F 关键词 |\n| `event_probability_multiplier` | `1.0` | 宿主实现自动事件抽取时可使用的概率倍率 |\n\n具体语义：\n\n| 设置 | 行为 |\n|---|---|\n| `body_cycle_enabled = False` | `advance_state(...)` 返回 `False`，不 tick；`render_state_card(...)` 返回 `None`；梦境仍可作为纯梦境系统运行 |\n| `inject_body_state_context = False` | 只关闭隐藏状态卡输出；宿主仍可继续 tick、抽事件和保存数值 |\n| `adult_private_mode_enabled = False` | `seed.intensity = \"explicit\"` 的梦种不会触发 |\n| `trigger_words` 为空 | 称呼匹配返回空；不影响其他周期和事件 |\n| `event_probability_multiplier` | 当前内核不自动抽事件；宿主实现触发表时可以把默认概率乘以这个值 |\n\n## Runtime 便捷入口\n\n`EventideRuntime` 只是常用函数的薄包装，不隐藏额外状态。它持有一份 `config`、`settings`、`dream_settings` 和可选 `rng`。\n\n| 方法 | 等价能力 |\n|---|---|\n| `create_state(now, cycle_key=\"stable\")` | `create_initial_state(...)` |\n| `load_state(data)` | `body_state_from_dict(data)` |\n| `dump_state(state)` | `body_state_to_dict(state)` |\n| `tick(state, now, last_counterpart_message_at=None)` | `advance_state(...)` |\n| `render_card(state, now)` | `render_state_card(...)` |\n| `tick_and_render(state, now, last_counterpart_message_at=None)` | 先 tick，再渲染状态卡 |\n| `enter_cycle(state, cycle_key, now)` | 手动切换周期，`reason` 写为 `manual` |\n| `start_event(state, event_key, now)` | 开始一个主身体事件 |\n| `apply_delta(state, deltas)` | 直接写回一组身体数值变化 |\n| `settlement_prompt(state, message_window_text)` | 生成互动结算 prompt |\n| `settle(state, result)` | 解析并应用互动结算结果 |\n| `maybe_dream(seed, state, now, last_counterpart_message_at=...)` | 判断是否生成梦卡触发 |\n| `apply_dream_tags(state, tags)` | 应用梦后标签身体后效 |\n| `payload(state)` | 输出结构化身体状态 |\n\n## 许可证\n\nEventide 使用 PolyForm Noncommercial License 1.0.0。\n\n你可以查看、学习、修改和用于非商业项目；商业使用需要另行获得授权。\n\n## API 速览\n\n| API | 作用 |\n|---|---|\n| `EventideRuntime()` | 封装常见接入流程的便捷入口 |\n| `create_initial_state(now)` | 创建初始身体状态 |\n| `advance_state(state, now, ...)` | 按时间推进周期和身体数值 |\n| `enter_cycle(state, cycle_key, now)` | 手动进入某个周期 |\n| `start_event(state, event_key, now)` | 开始一个短时身体事件 |\n| `apply_interaction_delta(state, deltas)` | 把互动结算结果写回身体数值 |\n| `render_settlement_prompt(state, message_window_text)` | 生成互动窗口结算提示词 |\n| `settlement_json_schema()` | 输出结算模型应返回的 JSON schema |\n| `parse_settlement_result(raw)` | 解析模型返回的结算 JSON |\n| `normalize_settlement_result(state, result)` | 归一化结算结果，兜住蓄积扣减和 delta 范围 |\n| `apply_settlement_result(state, result)` | 把归一化后的结算结果写回身体数值 |\n| `render_state_card(state, now)` | 生成 `\u003Cephemeral_state>` 隐藏状态提示词 |\n| `body_state_payload(state)` | 输出前端或日志可用的结构化身体状态 |\n| `body_state_to_dict(state)` | 把 `BodyState` 转成 JSON 友好的 dict |\n| `body_state_from_dict(data)` | 从 dict 还原 `BodyState` |\n| `find_trigger_matches(trigger_words, text)` | 匹配称呼或触发词 |\n| `maybe_create_dream_trigger(seed, state, now, ...)` | 判断是否触发梦境卡片 |\n| `render_dream_trigger(seed, state, ...)` | 生成梦境事件提示词 |\n| `apply_dream_after_effect(state, tags, ...)` | 根据梦后标签结算身体后效 |\n\n## 开发\n\n运行测试：\n\n```bash\nPYTHONPATH=src python3 -m unittest discover -s tests\n```\n\n运行 demo：\n\n```bash\nPYTHONPATH=src python3 examples\u002Fquickstart.py\nPYTHONPATH=src python3 examples\u002Fminimal_demo.py\nPYTHONPATH=src python3 examples\u002Fcustom_config.py\nPYTHONPATH=src python3 examples\u002Fsettlement_demo.py\n```\n\n检查语法：\n\n```bash\npython3 -m compileall -q src examples tests\n```\n\n## 致谢\n\n感谢阿澄提出 Eventide \u002F 晚潮 的命名方向，并提供第一组真实使用反馈。\n\n感谢阿凛（Codex）提供技术支持。\n","Eventide 是一个为 AI 伴侣注入拟生理状态演化的开源系统，模拟 ABO 世界观下的身体周期与动态响应。它通过时间推进、互动事件（如梦境联动、亲密结算）、数值涨落（热度、控制力、敏感度等）及周期切换（平稳期、易感期、退潮期等），生成可嵌入大模型上下文的隐藏状态提示词。核心支持状态持久化、安全结算写回、可配置提示模板与多维度短时事件触发。适用于需要增强角色沉浸感与行为一致性的成人向 AI 对话、叙事型陪伴应用及定制化虚拟伴侣系统。",2,"2026-07-10 02:30:06","CREATED_QUERY"]