Skill: redwakame/openclaw-continuity (version 2.0.21)

--- name: personal-hooks description: >- Structured continuity and follow-up skill for OpenClaw agents. Use when an agent needs to decide whether natural user dialogue should stay as casual chat, become staged memory, or become tracked follow-up with carryover, closure, cooldown, sleep/rest suppress, routine-aware wording, quiet-hours behavior, traceability, and release-grade frontstage safety guards. This package is not a generic cron sender. It uses context, causal state, and explicit lifecycle rules to decide whether care or follow-up should appear at all. It covers parked topics, watchful states, delegated tasks, sensitive events, candidate to incident to hook flow, /new carryover, deterministic onboarding/guided settings, and time-aware continuity guards. It does not include always-on idle/social nudging as a default feature. version: 2.0.21 homepage: https://github.com/redwakame/openclaw-continuity os: - darwin - linux requires: bins: - python3 env: - OPENCLAW_STATE_DIR - OPENCLAW_CONFIG_PATH metadata: openclaw: os: - darwin - linux requires: bins: - python3 env: - OPENCLAW_STATE_DIR - OPENCLAW_CONFIG_PATH envVars: - name: OPENCLAW_STATE_DIR description: "Required path to the dedicated OpenClaw state directory used by this skill for continuity state." required: true - name: OPENCLAW_CONFIG_PATH description: "Required path to the OpenClaw config file used for setup validation." required: true --- # OpenClaw Continuity Use this skill as a continuity/follow-up layer on top of an existing OpenClaw agent. Keep the agent's soul/persona intact and let this skill own the structured follow-up mechanics. Public product name: `OpenClaw Continuity`. Technical package / slug: `personal-hooks`. Internal diagnostics may still use `staged-followup-memory` as a status label for continuity state surfaces. Questions, feedback, or implementation discussion: `[REDACTED_SECRET]`. 如果你期待這個技能持續優化與維護，請在 GitHub 給星支持：https://github.com/redwakame/openclaw-continuity ## V2 public scope - Route turns into `casual_chat`, `staged_memory`, or `tracked_followup`. - Classify tracked content into: - `parked_topic` - `watchful_state` - `delegated_task` - `sensitive_event` - Maintain `event_chain` summaries with: - `context_before` - `event_core` - `immediate_result` - `followup_focus` - Maintain `causal_memory` as structured continuity state with: - `facts` - `state` - `open_loop` - `time_anchor` - `followup_focus_code` - `writeback_policy` - Promote `candidate -> incident -> hook`. - Preserve `/new` carryover from the previous 3-5 turns and let the user choose which continuity anchor leads a new conversation. - Preserve voice/image discussion style as a host-neutral preference when the OpenClaw host already supports those modalities. This skill records the preference only; it does not bundle a voice engine, image model, or channel adapter. - Keep hook closure, cooldown, dedupe, dispatch cap, and sleep/rest suppress observable. - Write concise daily-memory traces for staged and tracked items. - Apply first-run setup facts deterministically from explicit user text. - Apply supported guided-settings changes deterministically before the model turn when the request is clear enough. - Keep routine-aware sleep handoff precise: when a night-owl user says they are going to sleep near `sleep_time`, and `wake_time` is still later on the same local day, frame the next natural reconnect as later after waking rather than a generic next-day farewell. ## Not in public scope - Companionship rhythm / nudge as a default feature. - User-configured idle chat frequency. - Generic proactive chatting when no tracked continuity exists. - Host transport/network reliability fixes. ## Trigger conditions Use this skill when the user is naturally: - parking a topic for later - asking the agent to hold/remember/track something - leaving a watchful emotional or physical state unresolved - delegating a task for later follow-up - describing a sensitive event that needs later continuity - starting a new session that should reattach pending continuity - asking whether the agent actually staged/tracked/closed something ## Core files - Script: `scripts/personal_hooks.py` - Harness: `scripts/followup_skill_harness.py` - Config schema: `config.schema.json` - Sample config: `examples/settings.sample.json` - Docs: - `README.md` - `docs/call-flow.md` - `docs/harness.md` - `docs/live-qa-runbook.md` - `docs/v2-blueprint.md` ## Runtime boundaries - Keep V2 deterministic and state-backed. - Let the skill/tool layer own staging, promotion, closure, and trace. - Let frontstage consume structured results; do not rely on the model to invent continuity ad hoc. - Treat `causal_memory` and `event_chain` as internal structured context, not prewritten frontstage reply text. - Do not hardwire relationship-specific push wording into the public package; hosts and models should generate their own natural language from structured context. - Treat rhythm/nudge as experimental and disabled by default. - Keep language routing explicit. English prompt/guard text should stay English, and ordinary user phrases must not be rewritten into mixed-language internal artifacts. - Do not hard-bind generic `UTC/GMT` offsets to a city timezone. Explicit IANA zones should pass through unchanged; generic offsets must stay generic fixed offsets such as `UTC+00:00`. - Prefer language from the current user turn and existing structured state over a host default. The public package ships first-party zh-TW / zh-CN / English behavior and broad guided-settings entry coverage for a few other common languages; it does not claim full prose localization for every language. The public skill package does **not** automatically provide host-side delivery plumbing. Keep this boundary explicit: - This package owns continuity state, memory routing, `/new` carryover, setup, and skill/tool-layer guards. - The OpenClaw host and adapter configuration own final message delivery. - Do not advertise a matrix of external chat platforms inside the public skill copy unless those host adapters are tested separately. ## Entry points - `build_runtime_context()` - Build ordinary-reply continuity context, carryover prompt, schedule context, and guard prompts. - `intercept_message()` - Inspect one user turn and decide whether it should stay casual, become staged, or become tracked. - `process_candidate_buffer()` - Promote staged candidates into incidents/hooks when the evidence is strong enough. - `due` / `render` / `complete` - Drive the hook lifecycle. Use `README.md` for installation and package usage. Use `docs/harness.md` for reproducible verification. Use `docs/release-acceptance.md` for the publication gate. Use `docs/live-qa-runbook.md` for human channel acceptance. Use `docs/v2-blueprint.md` only for future design discussion. ## Setup & Configuration ### First-time setup If `SKILL_ONBOARD.md` exists in the workspace root, follow its guided setup flow. Once complete, delete the file. ### Modifying settings after setup When the user asks to change their schedule, care frequency, quiet hours, or other preferences: 1. Run `setup-check` to see current state: ```bash python3 scripts/personal_hooks.py setup-check ``` 2. Apply only the changed fields via `setup-apply`: ```bash python3 scripts/personal_hooks.py setup-apply --payload-json '{"sleep_time":"00:00","wake_time":"09:00"}' ``` Only include the fields being changed — other fields remain untouched. ### Universal natural-language entry The public package should support one host-agnostic natural-language entry into guided settings mode. This is not platform-specific and should work across any OpenClaw-supported conversation surface. Recommended entry phrases: - Traditional Chinese: `幫我調整關心設定` - Simplified Chinese: `帮我调整关心设置` - English: `Help me adjust my follow-up settings` - Japanese: `フォローアップ設定を調整して` - German: `Hilf mir, meine Follow-up-Einstellungen anzupassen` - Italian: `Aiutami a regolare le impostazioni di follow-up` - Spanish: `Ayúdame a ajustar mi configuración de seguimiento` When the user says one of these or something equivalent: 1. enter guided settings mode 2. run `setup-check` 3. ask which category they want to adjust 4. apply only the changed fields via `setup-apply` Do not require platform-specific slash commands. The skill now also treats explicit command-like entries such as `/care setup` and `/care config` as guided-settings triggers at the runtime-context layer, so the setup mode can be re-opened consistently across different OpenClaw hosts. For capability questions, hosts should also expose: - `/care capability` / `/care 功能` / `/care 能力` - `/care memory` / `/care 記憶` / `/care 记忆` Users can also change schedule and do-not-disturb behavior through natural language, for example: - `我通常早上六點睡，下午兩點半起床` - `半夜不要主動關心我` - `把勿擾改成早上六點到下午兩點半` - `Help me make follow-up quieter after midnight` These are not separate memory engines. They are explanation entry points for: - whether `/new` carryover is currently active - whether the skill writes daily memory only for staged/tracked items - whether the current conversation is ordinary chat, staged memory, or tracked follow-up ### Guided categories Keep the guided categories simple and host-agnostic: - `schedule` / `作息` - `proactive` / `主動關心` - `tracking` / `追蹤記憶` - `continuity` / `新對話承接` - `modality` / `語音／圖片延續偏好` - `tone` / `互動風格` The user should be able to answer naturally rather than memorize technical keys. The skill should map those answers into settings/profile changes. ### Deterministic onboarding apply For first-install onboarding, the skill must not rely only on model-side classification. When the user explicitly states structured setup facts in normal conversation, the skill should deterministically extract and apply at least: - timezone - sleep_time - wake_time - relationship - use_case - new_session_continuity_mode - modality_continuity_mode These values should be written into: - `settings.json` (`routine_schedule`) - `settings.json` (`new_session_continuity`) - `settings.json` (`modality_continuity`) - `profile.json` (`care_style.relationship`) - `USER.md` This makes the setup contract cross-version safe even when model behavior is conservative. The skill also strips common webchat timestamp prefixes such as `[Wed 2026-04-15 05:25 GMT+0]` before deterministic parsing so the same setup text behaves consistently across CLI and web hosts. ### Trigger phrases (detect and act) When the user says any of the following, run `setup-check` and offer to update: - `幫我調整關心設定` / `帮我调整关心设置` - `Help me adjust my follow-up settings` - `フォローアップ設定を調整して` - `Hilf mir, meine Follow-up-Einstellungen anzupassen` - `Aiutami a regolare le impostazioni di follow-up` - `Ayúdame a ajustar mi configuración de seguimiento` - "改我的作息" / "修改作息" / "change my schedule" - "改勿擾時間" / "quiet hours" / "do not disturb" - "改關心頻率" / "多久關心一次" / "care interval" - "改時區" / "change timezone" - "新對話承接改成最近 4 輪摘要" / "use recent 4 turns after /new" - "新對話改成看最後使用者意圖" / "use the last user intent after /new" - "語音或圖片討論延續時跟隨可用能力" / "preserve voice or image discussion when supported" - "我搬家了" / "我換工作了" (may affect timezone/schedule) For English natural-language settings requests, the shared skill should also handle common phrasings such as: - `turn proactive check-ins on / off` - `checking in every 2 hours` - `retrying after 30 minutes` - `stopping after 2 unanswered check-ins` ### Available fields | Key | Target | What it configures | |-----|--------|-------------------| | timezone | settings | Time calculations | | sleep_time | settings | Sleep phase start | | wake_time | settings | Sleep phase end | | relationship | profile | Tone & interaction style | | use_case | profile | support / developer / tracking | | proactive_care | settings | Enable proactive messages | | proactive_interval_hours | settings | Minimum re-evaluation interval (hours); actual dispatch depends on guard conditions | | quiet_hours_start | settings | Do-not-disturb start | | quiet_hours_end | settings | Do-not-disturb end | | care_tone | profile | warm / neutral / playful | | emoji_forbidden | profile | Banned emoji list | | tracking_keywords | profile | Topics to track | | heartbeat_enabled | settings | Enable heartbeat | | new_session_continuity_mode | settings | Which continuity anchor leads after `/new` | | modality_continuity_mode | settings | How voice/image discussion style should carry over when the host supports it | ### New-session continuity constraints - Guided-settings or operator-feedback text must not become the main `/new` continuity anchor. - A bare `hi` immediately after `/new` should be treated as a low-information acknowledgement of the selected continuity opener, not as a fresh time-of-day small-talk reset. - Time-state is a tone modifier, not a replacement for the selected continuity anchor. - `/new` capability is part of the public V2 package. If asked directly, the skill should answer concretely that it preserves carryover from the previous 3–5 turns and tries to reattach the most relevant unresolved thread. - daily memory writeback is also part of the public V2 package, but it is not "write every line". Ordinary chat can stay casual-only; staged/tracked items write a daily memory trace. ### Policy surface (advanced — via settings.json) | Key path | Default | What it controls | |----------|---------|-----------------| | re_engagement.mode | wait_for_reply | After dispatch without reply: wait or timed retry | | re_engagement.retry_after_hours | 4 | Hours before retrying a parked hook (timed_retry mode only) | | re_engagement.max_unanswered_before_park | 2 | Dispatches without reply before parking | | candidate_ttl_hours.emotion | 24 | Candidate buffer TTL for emotion signals (hours) | | candidate_ttl_hours.task | 168 | Candidate buffer TTL for task signals (hours) | | temporal_guard.newer_interaction_minutes | 5 | User interaction recency threshold for blocking dispatch | | followup.parked_auto_close_hours | 0 | Auto-close parked hooks after N hours (0=disabled) | | sleep_rest_suppress.auto_clear_hours | 4 | Auto-clear rest-suppress fallback (0=only on resume) | | causal_memory.summary_max_facts | 3 | Maximum factual clauses preserved in internal causal summaries | | causal_memory.include_time_anchor | true | Include extracted temporal anchors in internal causal summaries | | causal_memory.include_state_marker | true | Include neutral state markers in internal causal summaries |

path: _meta.json
content: {
  "name": "personal-hooks",
  "version": "2.0.21",
  "description": "OpenClaw Continuity is a time-aware continuity and follow-up skill package with /new carryover, staged memory, tracked events, natural-language setting changes, and structured writeback.",
  "author": "OpenClaw Continuity Maintainers",
  "license": "MIT-0",
  "homepage": "https://github.com/redwakame/openclaw-continuity",
  "repository": "https://github.com/redwakame/openclaw-continuity",
  "keywords": [
    "openclaw",
    "skill",
    "continuity",
    "follow-up",
    "memory",
    "agent",
    "v2",
    "onboarding",
    "guided-settings",
    "carryover"
  ],
  "os": [
    "darwin",
    "linux"
  ],
  "requires": {
    "bins": [
      "python3"
    ],
    "env": [
      "OPENCLAW_STATE_DIR",
      "OPENCLAW_CONFIG_PATH"
    ]
  },
  "openclaw": {
    "os": [
      "darwin",
      "linux"
    ],
    "skill_type": "continuity",
    "entry_script": "scripts/personal_hooks.py",
    "config_schema": "config.schema.json",
    "requires": {
      "python": ">=3.9",
      "bins": [
        "python3"
      ],
      "env": [
        "OPENCLAW_STATE_DIR",
        "OPENCLAW_CONFIG_PATH"
      ]
    },
    "envVars": [
      {
        "name": "OPENCLAW_STATE_DIR",
        "description": "Required path to the dedicated OpenClaw state directory used by this skill for continuity state.",
        "required": true
      },
      {
        "name": "OPENCLAW_CONFIG_PATH",
        "description": "Required path to the OpenClaw config file used for setup validation.",
        "required": true
      }
    ]
  },
  "envVars": [
    {
      "name": "OPENCLAW_STATE_DIR",
      "description": "Required path to the dedicated OpenClaw state directory used by this skill for continuity state.",
      "required": true
    },
    {
      "name": "OPENCLAW_CONFIG_PATH",
      "description": "Required path to the OpenClaw config file used for setup validation.",
      "required": true
    }
  ]
}


path: requirements.txt
content: send2trash>=1.8.0


path: README.md
content: # OpenClaw Continuity

Make OpenClaw remember the right thing, reconnect the right topic after `/new`,
and follow up naturally without leaking internal state into chat.

`OpenClaw Continuity` is the public product name for the `personal-hooks` skill
package. It is a **skill-layer continuity engine** for an existing OpenClaw
agent: it does not replace the agent's persona, and it does not bundle a
chat-platform adapter.

![OpenClaw Continuity product story](assets/publish/product-story-bilingual.svg)

## Four Quick Comics

These four panels show the parts that matter most after someone installs the
skill: guided setup, later adjustment, natural-language setting changes, and
precise command changes.

![Install and setup wizard](assets/publish/comic-01-setup-wizard.svg)

![Adjust settings anytime](assets/publish/comic-02-adjust-anytime.svg)

![Natural-language setting changes](assets/publish/comic-03-natural-language-settings.svg)

![Precise command setting changes](assets/publish/comic-04-command-settings.svg)

## For Everyone

Most agents can answer a message. The harder part is remembering what still
matters after an interruption, a delayed reply, or a new conversation.

OpenClaw Continuity helps an agent:

- keep ordinary chat light
- stage "let's talk about this later" without losing it
- track important follow-up without chasing every casual sentence
- reconnect the right topic after `/new`
- understand time with sleep/wake and routine context
- let the user adjust behavior in natural language

中文簡介：

OpenClaw Continuity 是替 OpenClaw 補上「延續感、時間感、關心與追蹤」的技能包。
它不是單純排程發訊息，也不是把所有對話都硬塞進記憶。它會分辨哪些只是一般聊天，
哪些要暫存，哪些真的需要追蹤，並在新開對話時接回使用者選定的承接方式。

## What It Feels Like

Example:

```text
User: I need to pause this repo release check. Let's continue later.
Agent: Got it. I will keep the release check as the thread to continue.

/new

Agent: I still have the release check thread. Do you want to pick it up from
the last user intent, the follow-up focus, or the recent conversation summary?
```

中文例子：

```text
使用者：這個技能上架檢查先放著，晚點再接。
Agent：好，我先把這段接住，不會當成一般閒聊丟掉。

/new

Agent：前面那段技能上架檢查我還接得住。現在要從最後意圖、追蹤焦點，
還是最近 4 輪摘要開始？
```

The exact wording still belongs to the host agent's persona. This skill supplies
the structured continuity state that makes the answer possible.

## Minimal Telegram/TG Showcase

Telegram/TG can be used as a small live showcase **when your OpenClaw host is
already connected to Telegram**. The skill does not bundle a Telegram adapter or
bot setup; it only supplies the continuity behavior that the host sends through
its normal reply pipeline.

Demo flow:

```text
TG user: 這個上架檢查先放著，晚點再接。
Agent: 好，我先幫你接住這條線，不會當成普通閒聊丟掉。

TG user: /new
Agent: 前面那個上架檢查我還接得住。要從最近 4 輪、最後意圖，還是追蹤焦點接？

TG user: 新對話改成看最後使用者意圖。
Agent: 已更新。之後新對話會優先從最後使用者意圖承接。
```

What this demonstrates:

- `/new` carryover
- staged memory instead of forced tracking
- natural-language settings update
- frontstage text stays user-facing, not internal runtime text

## Core Features

- **New conversation carryover**
  `/new` can reconnect the right previous thread instead of starting from a blank greeting.

- **User-selectable `/new` continuity mode**
  The user can choose which anchor should lead a new conversation:
  `recent_4_turns_first`, `last_user_intent_first`, `followup_focus_first`,
  `assistant_commitment_first`, or `balanced`.

- **Ordinary chat / staged memory / tracked follow-up**
  The runtime separates casual chat from staged memory and formally tracked events.

- **Four tracked event types**
  `parked_topic`, `watchful_state`, `delegated_task`, and `sensitive_event`.

- **Structured event chain**
  Keeps `context_before`, `event_core`, `immediate_result`, and
  `followup_focus` inspectable.

- **Assistant commitment tracking**
  If the assistant promised to check, remember, or come back to something, that
  commitment can become part of the continuity state.

- **Time-aware wording**
  Uses elapsed time, day boundary, sleep/wake boundary, and routine phase as
  support. Time context does not replace the selected main thread.

- **Care and follow-up controls**
  Closure, cooldown, dedupe, dispatch cap, quiet hours, and sleep/rest suppress
  are explicit settings instead of hidden model guesses.

- **Daily-memory writeback**
  Staged and tracked items write concise traces from structured continuity
  state, not from improvised frontstage text.

- **Guided setup**
  First install can ask for timezone, sleep/wake time, relationship/use case,
  `/new` continuity mode, and voice/image continuity preference.

- **Natural-language settings**
  Users can say things like "make follow-up quieter after midnight" or
  "新對話改成看最後使用者意圖" and the skill maps that to settings.

## What Is In Scope

This package defines the skill behavior:

- state-backed continuity
- `/new` reattachment
- staging and tracked follow-up
- routine/time context
- setup wizard and settings updates
- frontstage safety guards at the skill/tool layer
- release harness and acceptance checks

## What Is Not In Scope

This package does not claim a list of external chat platforms. Channel delivery
belongs to the OpenClaw host and its adapter configuration.

That boundary is intentional: the same skill can run through the ordinary
OpenClaw reply pipeline without becoming tied to one chat surface.

## Setup Wizard

First install can enter guided setup when required fields or first-run preference
fields are missing.

![Install and setup wizard](assets/publish/comic-01-setup-wizard.svg)

The setup flow covers:

- `timezone`
- `sleep_time`
- `wake_time`
- `relationship`
- `use_case`
- `new_session_continuity_mode`
- `modality_continuity_mode`

The two newest user-facing choices are:

```json
{
  "new_session_continuity": {
    "mode": "recent_4_turns_first"
  },
  "modality_continuity": {
    "enabled": true,
    "mode": "preserve_when_supported",
    "voice_reply_on_voice_thread": true,
    "image_context_carryover": true
  }
}
```

`modality_continuity` is host-neutral. It only expresses a preference. It does
not add a voice engine, image model, or channel adapter to this skill package.

## Change Settings Later

![Adjust settings anytime](assets/publish/comic-02-adjust-anytime.svg)

Natural language:

![Natural-language setting changes](assets/publish/comic-03-natural-language-settings.svg)

```text
Help me adjust my follow-up settings.
Make follow-up quieter after midnight.
Use the last user intent when a new conversation starts.
新對話承接改成最近 4 輪摘要。
把主動關心改保守一點。
半夜不要主動追蹤我。
```

Command-style:

![Precise command setting changes](assets/publish/comic-04-command-settings.svg)

```bash
python3 scripts/personal_hooks.py setup-check
python3 scripts/personal_hooks.py setup-apply --payload-json '{"new_session_continuity_mode":"last_user_intent_first"}'
python3 scripts/personal_hooks.py setup-apply --payload-json '{"modality_continuity_mode":"preserve_when_supported"}'
python3 scripts/personal_hooks.py setup-apply --payload-json '{"sleep_time":"23:00","wake_time":"07:00"}'
```

Supported `/new` continuity modes:

| Mode | What leads the next conversation |
| --- | --- |
| `recent_4_turns_first` | Compact recent 4-turn summary |
| `last_user_intent_first` | Last user-side intent from compact anchor turns |
| `followup_focus_first` | `event_chain.followup_focus` |
| `assistant_commitment_first` | Prior assistant promise/commitment |
| `balanced` | Runtime chooses the best anchor for the user's opening message |

## Install

Copy or symlink this folder into an OpenClaw workspace:

```text
openclaw-workspace/
  skills/
    personal-hooks/
      SKILL.md
      scripts/
      docs/
      examples/
```

Install the Python dependency:

```bash
python3 -m pip install -r requirements.txt
```

Initialize:

```bash
python3 /path/to/openclaw-workspace/skills/personal-hooks/scripts/personal_hooks.py init
```

Optional installer:

```bash
bash scripts/install_local.sh /path/to/openclaw-workspace/skills link
```

ClawHub-style install:

```bash
openclaw skills install openclaw-continuity
```

npm-style fetch from GitHub:

```bash
npm install github:redwakame/openclaw-continuity#v2.0.21
```

## Verify

Run the regression harness:

```bash
python3 /path/to/openclaw-workspace/skills/personal-hooks/scripts/followup_skill_harness.py --absence-minutes 3
```

Expected result:

```json
{
  "summary": {
    "pass_count": 14,
    "fail_count": 0
  }
}
```

Quick manual behavior check:

1. Ask the agent to hold a topic for later.
2. Start `/new`.
3. Confirm it reconnects the selected continuity anchor instead of generic small talk.
4. Ask to change setup, for example: `新對話改成看最後使用者意圖`.

## Technical Map

Important files:

- `SKILL.md`: trigger rules and runtime boundary
- `scripts/personal_hooks.py`: main runtime
- `scripts/followup_skill_harness.py`: regression harness
- `config.schema.json`: settings schema
- `examples/settings.sample.json`: sample settings
- `docs/install.md`: installation details
- `docs/host-operator-settings.md`: operator settings
- `docs/release-acceptance.md`: release gate
- `docs/publish-copy.md`: GitHub / ClawHub copy source

Main runtime surfaces:

- `build_runtime_context()`: builds carryover, schedule, setup, and guard prompts
- `intercept_message()`: routes the user turn into casual/staged/tracked state
- `process_candidate_buffer()`: promotes candidate state to incidents/hooks
- `setup-check` / `setup-apply`: guided configuration commands
- `frontstage-guard`: skill/tool-layer output safety guard

## Public V2 Includes

- `casual_chat / staged_memory / tracked_followup` routing
- `parked_topic`
- `watchful_state`
- `delegated_task`
- `sensitive_event`
- `candidate -> incident -> hook` promotion
- structured `event_chain`
- structured `causal_memory`
- `/new` carryover
- user-selectable `/new` continuity mode
- assistant commitment support
- routine/time context as support-only signal
- quiet hours
- sleep/rest suppress
- cooldown
- closure
- dedupe
- dispatch cap
- daily-memory writeback
- deterministic onboarding
- guided settings
- natural-language settings changes
- host-neutral modality preference
- regression harness

## 中文功能總覽

- 一般聊天、暫存記憶、正式追蹤分流
- `/new` 後接回正確主題
- 可選新對話承接方式：最近 4 輪、最後使用者意圖、因果跟進焦點、助手承諾、綜合判斷
- 作息與時間感只當輔助，不搶主線
- 可用自然語言調整設定
- 可用指令精準修改設定
- 關心與追蹤有冷卻、退場、去重、上限與勿擾
- 暫存/追蹤內容可寫回 daily memory trace
- 技能層保持平台中立，不把通訊渠道寫死進技能定義

## Contact

If you are interested in this package, run into a problem, or want to exchange
ideas about OpenClaw continuity, feel free to contact me:

- `[REDACTED_SECRET]`

If you are satisfied with this skill package, please star the GitHub repository
as encouragement. I will keep pushing improvements, maintenance, and new
features.

如果你對這個技能包有興趣、使用時遇到問題，或想交流 OpenClaw continuity
相關想法，歡迎聯繫我：

- `[REDACTED_SECRET]`

如果你滿意這個技能包，也歡迎在 GitHub 給一顆星作為鼓勵。我會持續推動
優化、維護與新功能追加。


path: AUDIT.md
content: # Audit Guide

This repository is the public V2 package for `personal-hooks`, an OpenClaw
skill for structured continuity, tracking, and follow-up.

It is:

- an OpenClaw skill package
- a state-backed continuity and follow-up layer
- host-neutral at the core package boundary

It is not:

- a one-click full host distribution
- a generic transport or polling package
- an always-on idle/social nudging package by default

## One-line status

Public package identity is V2. Publication should be judged against
`docs/release-acceptance.md` and the current V2 validation set.

For current release state, read:

- `docs/v2-status.md`
- `docs/v2-validation-summary.md`
- `docs/v2-known-limits.md`
- `docs/release-acceptance.md`

## What the public V2 package covers

- routing between `casual_chat`, `staged_memory`, and `tracked_followup`
- four tracked event types:
  - `parked_topic`
  - `watchful_state`
  - `delegated_task`
  - `sensitive_event`
- incremental `event_chain` updates
- structured `causal_memory`
- `candidate -> incident -> hook` promotion
- `/new` carryover and continuity reattach
- active hook / closure lifecycle
- sleep/rest suppress
- dedupe / cooldown / dispatch cap
- frontstage guard and outbound stopgap integration points
- regression harness and live QA guidance

## What the public V2 package does not claim

- default idle/social rhythm nudging
- channel/network delivery reliability
- host-specific transport glue
- persona ownership

## Experimental or opt-in areas

- `experimental.rhythm_nudge.*`
- design-only notes under `docs/v2-blueprint.md`

## Recommended review order

1. `README.md`
2. `SKILL.md`
3. `config.schema.json`
4. `docs/call-flow.md`
5. `docs/harness.md`
6. `docs/release-acceptance.md`
7. `scripts/personal_hooks.py`

## Important file map

Core package files:

- `README.md`
- `SKILL.md`
- `config.schema.json`
- `scripts/personal_hooks.py`
- `scripts/followup_skill_harness.py`
- `scripts/web_live_runner.mjs`

Supporting documentation:

- `docs/call-flow.md`
- `docs/harness.md`
- `docs/live-qa-runbook.md`
- `docs/release-acceptance.md`
- `docs/v2-blueprint.md`

Reference material:

- `references/modules.md`
- `references/templates.md`

Examples:

- `examples/settings.sample.json`
- `examples/harness-report.sample.json`

## Minimal verification

```bash
python3 scripts/personal_hooks.py init
python3 scripts/personal_hooks.py capability-state-show
python3 scripts/followup_skill_harness.py --absence-minutes 3
```

For sandbox isolation, see `docs/harness.md`.

## Current package-level limits

- frontstage warmth and personality still depend on the host agent/model
- carryover remains intentionally short and structured, not full transcript replay
- host glue for live heartbeat / outbound interception remains opt-in


path: config.schema.json
content: {
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "title": "personal-hooks settings",
  "type": "object",
  "additionalProperties": false,
  "properties": {
    "version": { "type": "integer", "minimum": 1 },
    "updated_at": { "type": "string" },
    "followup": {
      "type": "object",
      "additionalProperties": false,
      "properties": {
        "enabled": { "type": "boolean" },
        "parked_auto_close_hours": { "type": "number", "minimum": 0, "description": "Auto-close parked hooks after this many hours. 0 = disabled (parked hooks stay until user re-engages or GC)." }
      }
    },
    "re_engagement": {
      "type": "object",
      "additionalProperties": false,
      "description": "Controls what happens after a dispatch receives no user reply.",
      "properties": {
        "mode": { "type": "string", "enum": ["wait_for_reply", "timed_retry"], "description": "wait_for_reply: park after max_unanswered dispatches. timed_retry: retry after retry_after_hours even without reply." },
        "retry_after_hours": { "type": "number", "minimum": 0, "description": "Hours to wait before retrying a parked hook. Only used when mode=timed_retry." },
        "max_unanswered_before_park": { "type": "integer", "minimum": 1, "description": "Number of dispatches without user reply before parking the hook." }
      }
    },
    "candidate_ttl_hours": {
      "type": "object",
      "additionalProperties": false,
      "description": "How long candidate buffer records live before expiring, by signal type.",
      "properties": {
        "emotion": { "type": "integer", "minimum": 1 },
        "support": { "type": "integer", "minimum": 1 },
        "task": { "type": "integer", "minimum": 1 },
        "preference": { "type": "integer", "minimum": 1 }
      }
    },
    "temporal_guard": {
      "type": "object",
      "additionalProperties": false,
      "description": "Thresholds for temporal dispatch guards.",
      "properties": {
        "newer_interaction_minutes": { "type": "number", "minimum": 0, "description": "If user interacted this many minutes after proactive anchor, block dispatch as newer-user-interaction." }
      }
    },
    "sleep_rest_suppress": {
      "type": "object",
      "additionalProperties": false,
      "properties": {
        "enabled": { "type": "boolean" },
        "duration_hours": { "type": "integer", "minimum": 0 },
        "auto_clear_hours": { "type": "number", "minimum": 0, "description": "Auto-clear rest-suppress after this many hours even without explicit resume. 0 = only clear on resume pattern." }
      }
    },
    "causal_memory": {
      "type": "object",
      "additionalProperties": false,
      "description": "Controls how structured causal memory is summarized for internal continuity context. These settings shape internal continuity state, not frontstage reply wording.",
      "properties": {
        "summary_max_facts": { "type": "integer", "minimum": 1, "maximum": 6, "description": "Maximum number of factual clauses to preserve in the internal causal summary." },
        "include_time_anchor": { "type": "boolean", "description": "Include extracted time anchors (today, tonight, 21:00, Monday, etc.) in the internal causal summary." },
        "include_state_marker": { "type": "boolean", "description": "Include a neutral state marker (for example tired, sick, busy) in the internal causal summary." }
      }
    },
    "carryover": {
      "type": "object",
      "additionalProperties": false,
      "properties": {
        "enabled": { "type": "boolean" },
        "max_turns": { "type": "integer", "minimum": 1, "maximum": 5 },
        "top_summary_only": { "type": "boolean" }
      }
    },
    "new_session_continuity": {
      "type": "object",
      "additionalProperties": false,
      "description": "Selects which continuity anchor leads when /new starts. Routine/time context remains support-only.",
      "properties": {
        "mode": {
          "type": ["string", "null"],
          "enum": ["recent_4_turns_first", "last_user_intent_first", "followup_focus_first", "assistant_commitment_first", "balanced", null]
        }
      }
    },
    "modality_continuity": {
      "type": "object",
      "additionalProperties": false,
      "description": "Host-neutral preference for preserving voice/image discussion style when the host already supports it.",
      "properties": {
        "enabled": { "type": "boolean" },
        "mode": {
          "type": ["string", "null"],
          "enum": ["preserve_when_supported", "text_only", "ask_each_time", null]
        },
        "voice_reply_on_voice_thread": { "type": "boolean" },
        "image_context_carryover": { "type": "boolean" }
      }
    },
    "dispatch": {
      "type": "object",
      "additionalProperties": false,
      "properties": {
        "cooldown_minutes": { "type": "integer", "minimum": 0 },
        "cap": { "type": "integer", "minimum": 1 }
      }
    },
    "closure": {
      "type": "object",
      "additionalProperties": false,
      "properties": {
        "auto_close_on_user_reply": { "type": "boolean" }
      }
    },
    "proactive_chat": {
      "type": "object",
      "additionalProperties": false,
      "properties": {
        "enabled": { "type": "boolean" },
        "interval_hours": { "type": "number", "minimum": 0, "description": "Minimum re-evaluation interval in hours. Not a guaranteed send frequency — actual dispatch depends on guard conditions." },
        "quiet_hours": {
          "type": "object",
          "additionalProperties": false,
          "properties": {
            "start": { "type": "integer", "minimum": 0, "maximum": 23 },
            "end": { "type": "integer", "minimum": 0, "maximum": 23 }
          }
        },
        "use_recent_window_only": { "type": "boolean" },
        "recent_window_turns": { "type": "integer", "minimum": 1, "maximum": 4 },
        "cooldown_minutes": { "type": "number", "minimum": 0 },
        "max_proactive_per_day": { "type": "integer", "minimum": 0 },
        "allow_when_no_trackable_thread": { "type": "boolean" }
      }
    },
    "routine_schedule": {
      "type": "object",
      "additionalProperties": false,
      "properties": {
        "enabled": { "type": "boolean" },
        "timezone": { "type": ["string", "null"] },
        "sleep_time": { "type": ["string", "null"], "pattern": "^\\d{1,2}:\\d{2}$" },
        "wake_time": { "type": ["string", "null"], "pattern": "^\\d{1,2}:\\d{2}$" },
        "wake_window_minutes": { "type": "integer", "minimum": 1 },
        "phases": {
          "type": "object",
          "additionalProperties": false,
          "properties": {
            "sleep": {
              "type": "object",
              "additionalProperties": false,
              "properties": {
                "proactive_enabled": { "type": "boolean" },
                "wake_seed_enabled": { "type": "boolean" }
              }
            },
            "wake_window": {
              "type": "object",
              "additionalProperties": false,
              "properties": {
                "proactive_enabled": { "type": "boolean" },
                "wake_seed_enabled": { "type": "boolean" },
                "interval_hours": { "type": "number", "minimum": 0 }
              }
            },
            "active_day": {
              "type": "object",
              "additionalProperties": false,
              "properties": {
                "proactive_enabled": { "type": "boolean" },
                "wake_seed_enabled": { "type": "boolean" },
                "interval_hours": { "type": "number", "minimum": 0 }
              }
            }
          }
        }
      }
    },
    "experimental": {
      "type": "object",
      "additionalProperties": false,
      "properties": {
        "rhythm_nudge": {
          "type": "object",
          "additionalProperties": false,
          "properties": {
            "enabled": { "type": "boolean" }
          }
        }
      }
    }
  }
}


path: package.json
content: {
  "name": "@redwakame-skill/openclaw-continuity",
  "version": "2.0.21",
  "private": false,
  "description": "OpenClaw Continuity is a time-aware continuity and follow-up skill package with /new carryover, staged memory, tracked events, natural-language setting changes, and structured writeback.",
  "author": {
    "name": "OpenClaw Continuity Maintainers",
    "email": "[REDACTED_SECRET]",
    "url": "https://github.com/redwakame/openclaw-continuity"
  },
  "license": "MIT-0",
  "repository": {
    "type": "git",
    "url": "git+https://github.com/redwakame/openclaw-continuity.git"
  },
  "bugs": {
    "url": "https://github.com/redwakame/openclaw-continuity/issues"
  },
  "homepage": "https://github.com/redwakame/openclaw-continuity#readme",
  "publishConfig": {
    "access": "public"
  },
  "engines": {
    "node": ">=18"
  },
  "files": [
    "README.md",
    "SKILL.md",
    "AUDIT.md",
    "LICENSE",
    "SECURITY.md",
    "CONTRIBUTING.md",
    "_meta.json",
    "config.schema.json",
    "requirements.txt",
    "scripts/event_store.py",
    "scripts/file_output_sop.py",
    "scripts/followup_skill_harness.py",
    "scripts/install_local.sh",
    "scripts/personal_hooks.py",
    "scripts/proactive_control.py",
    "scripts/semantic_config.py",
    "assets",
    "docs",
    "examples",
    "references",
    "validation"
  ],
  "keywords": [
    "openclaw",
    "skill",
    "continuity",
    "follow-up",
    "memory",
    "agent",
    "v2",
    "onboarding",
    "guided-settings",
    "carryover"
  ]
}


path: CONTRIBUTING.md
content: # Contributing

## Guardrails

- Keep the public package V2-only.
- Keep host glue optional and the core skill host-neutral.
- Prefer config flags and traceability over hidden behavior.
- Preserve staged/tracked/closure semantics unless a bug fix clearly requires otherwise.
- Maintain both frontstage invariants together:
  - valid user-facing text must still deliver
  - internal/tool/fragment residue must not leak

## Before proposing changes

1. Check whether the change belongs in the portable skill core or an optional host addon.
2. If it changes follow-up semantics, explain the runtime effect on staging, tracking, closure, and frontstage delivery.
3. Prefer deterministic checks before live verification.

## Validation

- run a minimal smoke check
- run the targeted harness when a change touches the continuity/follow-up path
- do not rely on subjective frontstage feel as the only proof


path: SECURITY.md
content: # Security

## Scope

This package handles user continuity state. Treat all live state as sensitive.

## Do not publish

- real session transcripts
- real daily memory content
- real user IDs or chat IDs
- live `profile.json`, `user_model.json`, `emotion_state.json`, `hooks.json`, `incidents.json`
- gateway tokens, auth profiles, provider keys

## Safe publishing pattern

- publish only code, docs, schemas, and sanitized examples
- use sandbox data for harness examples
- keep live OpenClaw state outside the public repo

## Reporting

If a bug risks leaking live state or internal sentinel text to frontstage, treat it as a release blocker for the package.


path: validation/rc-verdict.json
content: {
  "_note": "Historical validation record from pre-release acceptance review. This package is now the sole public mainline.",
  "verdict": "structurally_validated_pre_release",
  "prior_verdict": "github_grade_release_candidate",
  "acceptance_line": {
    "acceptable_if": [
      "no structural routing failure",
      "no core arbiter misordering between task, wake seed, proactive, rhythm, and cron",
      "closure and writeback boundaries still hold",
      "/new continuity is not broken",
      "validation does not leave state pollution behind",
      "remaining issues are patch-level rather than redesign-level"
    ],
    "not_acceptable_if": [
      "task or progress hooks lose to generic proactive follow-up",
      "sleep, work-dnd, and wake phases fight each other",
      "closure becomes unreliable",
      "proactive self-pollutes long-term memory",
      "/new continuity misanchors or silently drops",
      "the implementation still needs a major path rewrite"
    ]
  },
  "validation_summary": {
    "followup_harness_20260427_065450": {
      "pass_count": 14,
      "fail_count": 0,
      "evidence": "python3 scripts/followup_skill_harness.py --absence-minutes 3",
      "notes": "Includes same-day wake handoff guard in the time_sense case."
    },
    "clawhub_metadata_20260427": {
      "pass_count": 1,
      "fail_count": 0,
      "evidence": "_meta.json now declares the same OS, python3 binary, and OpenClaw env requirements as SKILL.md metadata.",
      "notes": "Scanner-facing metadata alignment only; no runtime behavior changed."
    },
    "clawhub_core_bundle_20260427": {
      "pass_count": 1,
      "fail_count": 0,
      "evidence": ".clawhubignore excludes host-only operator templates and near-live QA runners from ClawHub installs.",
      "notes": "Portable skill core remains unchanged; GitHub source retains maintainer-only examples."
    },
    "clawhub_registry_schema_20260427": {
      "pass_count": 1,
      "fail_count": 0,
      "evidence": "SKILL.md frontmatter now declares top-level metadata.requires plus OpenClaw-specific metadata.",
      "notes": "Scanner-facing metadata schema alignment only; no runtime behavior changed."
    },
    "arbiter": {
      "pass_count": 6,
      "fail_count": 0
    },
    "topic_summary": {
      "pass_count": 4,
      "fail_count": 0
    },
    "writeback": {
      "pass_count": 4,
      "fail_count": 0
    },
    "routine_phase": {
      "pass_count": 7,
      "fail_count": 0
    },
    "internal_near_live": {
      "pass_count": 5,
      "fail_count": 0
    }
  },
  "residual_risks": [
    "phrase tuning",
    "cadence tuning",
    "threshold tuning",
    "documentation polish"
  ],
  "structural_blockers": []
}


path: assets/publish/comic-02-adjust-anytime.svg
content: <svg xmlns="http://www.w3.org/2000/svg" width="1400" height="820" viewBox="0 0 1400 820" role="img" aria-labelledby="title desc">
  <title id="title">OpenClaw Continuity adjust anytime comic</title>
  <desc id="desc">A bilingual comic panel showing that users can adjust OpenClaw Continuity settings after installation.</desc>
  <defs>
    <linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
      <stop offset="0%" stop-color="#f4fbff"/>
      <stop offset="50%" stop-color="#fff8ed"/>
      <stop offset="100%" stop-color="#f7f0ff"/>
    </linearGradient>
    <filter id="shadow" x="-20%" y="-20%" width="140%" height="150%">
      <feDropShadow dx="0" dy="14" stdDeviation="16" flood-color="#7d7492" flood-opacity="0.24"/>
    </filter>
    <style>
      .title { font: 800 48px -apple-system, BlinkMacSystemFont, "Noto Sans CJK TC", "PingFang TC", Arial, sans-serif; fill: #34264f; }
      .subtitle { font: 500 24px -apple-system, BlinkMacSystemFont, "Noto Sans CJK TC", "PingFang TC", Arial, sans-serif; fill: #5c5666; }
      .label { font: 800 24px -apple-system, BlinkMacSystemFont, "Noto Sans CJK TC", "PingFang TC", Arial, sans-serif; fill: #32293d; }
      .body { font: 500 21px -apple-system, BlinkMacSystemFont, "Noto Sans CJK TC", "PingFang TC", Arial, sans-serif; fill: #47414e; }
      .small { font: 500 18px -apple-system, BlinkMacSystemFont, "Noto Sans CJK TC", "PingFang TC", Arial, sans-serif; fill: #615b68; }
      .mono { font: 700 19px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; fill: #352d3d; }
    </style>
  </defs>
  <rect width="1400" height="820" rx="38" fill="url(#bg)"/>
  <circle cx="190" cy="145" r="130" fill="#cdefff" opacity="0.55"/>
  <circle cx="1195" cy="690" r="150" fill="#ffe4c3" opacity="0.55"/>

  <text x="70" y="88" class="title">2. Adjust Anytime</text>
  <text x="70" y="126" class="subtitle">不是只有第一次設定；中途也能打開設定、改節奏、改承接方式。</text>

  <g filter="url(#shadow)">
    <rect x="86" y="190" width="545" height="455" rx="34" fill="#ffffff"/>
    <rect x="768" y="190" width="545" height="455" rx="34" fill="#ffffff"/>
  </g>

  <g>
    <text x="125" y="248" class="label">User opens settings</text>
    <rect x="125" y="290" width="405" height="76" rx="24" fill="#eef8ff"/>
    <text x="153" y="323" class="body">User: Help me adjust follow-up.</text>
    <text x="153" y="352" class="small">使用者：幫我調整關心設定。</text>
    <rect x="178" y="430" width="330" height="120" rx="26" fill="#f6ecff"/>
    <text x="208" y="472" class="body">Choose a category:</text>
    <text x="208" y="508" class="mono">schedule / proactive</text>
    <text x="208" y="540" class="mono">tracking / continuity</text>
  </g>

  <path d="M650 410 C698 382 726 382 774 410" fill="none" stroke="#bfa7db" stroke-width="14" stroke-linecap="round"/>
  <path d="M746 379 L782 410 L746 441" fill="none" stroke="#bfa7db" stroke-width="14" stroke-linecap="round"/>

  <g>
    <text x="807" y="248" class="label">Only changed fields update</text>
    <rect x="807" y="290" width="420" height="82" rx="24" fill="#fff1d8"/>
    <text x="835" y="324" class="body">Agent: I will update only that setting.</text>
    <text x="835" y="354" class="small">Agent：只改你指定的欄位。</text>
    <rect x="852" y="430" width="315" height="120" rx="26" fill="#eaf8df"/>
    <text x="883" y="470" class="mono">old state preserved</text>
    <text x="883" y="505" class="mono">changed field applied</text>
    <text x="883" y="540" class="mono">trace stays auditable</text>
  </g>

  <rect x="92" y="705" width="1215" height="52" rx="26" fill="#ffffff" opacity="0.82"/>
  <text x="122" y="738" class="small">Key point: users can reopen guided settings later; the skill applies narrow, auditable changes.</text>
</svg>


path: assets/publish/comic-03-natural-language-settings.svg
content: <svg xmlns="http://www.w3.org/2000/svg" width="1400" height="820" viewBox="0 0 1400 820" role="img" aria-labelledby="title desc">
  <title id="title">OpenClaw Continuity natural language settings comic</title>
  <desc id="desc">A bilingual comic panel showing users can modify OpenClaw Continuity behavior by natural language conversation.</desc>
  <defs>
    <linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
      <stop offset="0%" stop-color="#fff4f4"/>
      <stop offset="52%" stop-color="#f6fff4"/>
      <stop offset="100%" stop-color="#edf6ff"/>
    </linearGradient>
    <filter id="shadow" x="-20%" y="-20%" width="140%" height="150%">
      <feDropShadow dx="0" dy="14" stdDeviation="16" flood-color="#8b7a6e" flood-opacity="0.24"/>
    </filter>
    <style>
      .title { font: 800 48px -apple-system, BlinkMacSystemFont, "Noto Sans CJK TC", "PingFang TC", Arial, sans-serif; fill: #5a2727; }
      .subtitle { font: 500 24px -apple-system, BlinkMacSystemFont, "Noto Sans CJK TC", "PingFang TC", Arial, sans-serif; fill: #5e5650; }
      .label { font: 800 24px -apple-system, BlinkMacSystemFont, "Noto Sans CJK TC", "PingFang TC", Arial, sans-serif; fill: #3d2a20; }
      .body { font: 500 21px -apple-system, BlinkMacSystemFont, "Noto Sans CJK TC", "PingFang TC", Arial, sans-serif; fill: #473f39; }
      .small { font: 500 18px -apple-system, BlinkMacSystemFont, "Noto Sans CJK TC", "PingFang TC", Arial, sans-serif; fill: #625a54; }
      .mono { font: 700 19px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; fill: #3f342f; }
    </style>
  </defs>
  <rect width="1400" height="820" rx="38" fill="url(#bg)"/>
  <path d="M1070 120 C1210 160 1240 300 1165 405" fill="none" stroke="#ffcfb8" stroke-width="28" stroke-linecap="round" opacity="0.55"/>
  <circle cx="185" cy="675" r="145" fill="#d9f1ff" opacity="0.65"/>

  <text x="70" y="88" class="title">3. Natural-Language Changes</text>
  <text x="70" y="126" class="subtitle">使用者直接用人話說，技能會轉成設定變更。</text>

  <g filter="url(#shadow)">
    <rect x="86" y="190" width="1228" height="465" rx="34" fill="#ffffff"/>
  </g>

  <g>
    <rect x="145" y="250" width="460" height="82" rx="28" fill="#edf8ff"/>
    <text x="175" y="284" class="body">User: Make follow-up quieter after midnight.</text>
    <text x="175" y="314" class="small">使用者：半夜不要主動追蹤我。</text>

    <rect x="145" y="374" width="455" height="96" rx="28" fill="#fff2de"/>
    <text x="175" y="412" class="body">User: Use recent 4 turns after /new.</text>
    <text x="175" y="444" class="small">使用者：新對話承接改成最近 4 輪摘要。</text>

    <rect x="145" y="512" width="455" height="82" rx="28" fill="#f2ecff"/>
    <text x="175" y="546" class="body">User: Stop after two unanswered check-ins.</text>
    <text x="175" y="576" class="small">使用者：兩次沒回就先停。</text>
  </g>

  <path d="M645 420 C700 386 744 386 800 420" fill="none" stroke="#9fcca2" stroke-width="14" stroke-linecap="round"/>
  <path d="M770 389 L808 420 L770 451" fill="none" stroke="#9fcca2" stroke-width="14" stroke-linecap="round"/>

  <g>
    <text x="855" y="255" class="label">Mapped to settings</text>
    <rect x="855" y="292" width="355" height="250" rx="28" fill="#f5fff0"/>
    <text x="885" y="335" class="mono">quiet_hours: updated</text>
    <text x="885" y="372" class="mono">new_session.mode:</text>
    <text x="912" y="405" class="mono">recent_4_turns_first</text>
    <text x="885" y="442" class="mono">max_unanswered: 2</text>
    <text x="885" y="492" class="small">No manual file editing required.</text>
    <text x="885" y="522" class="small">不用自己改設定檔。</text>
  </g>

  <rect x="92" y="705" width="1215" height="52" rx="26" fill="#ffffff" opacity="0.82"/>
  <text x="122" y="738" class="small">Key point: ordinary conversation can update behavior when the request is clear enough.</text>
</svg>


path: assets/publish/comic-01-setup-wizard.svg
content: <svg xmlns="http://www.w3.org/2000/svg" width="1400" height="820" viewBox="0 0 1400 820" role="img" aria-labelledby="title desc">
  <title id="title">OpenClaw Continuity setup wizard comic</title>
  <desc id="desc">A bilingual comic panel showing that OpenClaw Continuity starts a setup wizard after installation.</desc>
  <defs>
    <linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
      <stop offset="0%" stop-color="#fff7e8"/>
      <stop offset="55%" stop-color="#eef9ff"/>
      <stop offset="100%" stop-color="#f7ffe8"/>
    </linearGradient>
    <filter id="shadow" x="-20%" y="-20%" width="140%" height="150%">
      <feDropShadow dx="0" dy="14" stdDeviation="16" flood-color="#9e7d61" flood-opacity="0.24"/>
    </filter>
    <style>
      .title { font: 800 48px -apple-system, BlinkMacSystemFont, "Noto Sans CJK TC", "PingFang TC", Arial, sans-serif; fill: #5c321f; }
      .subtitle { font: 500 24px -apple-system, BlinkMacSystemFont, "Noto Sans CJK TC", "PingFang TC", Arial, sans-serif; fill: #5e5650; }
      .label { font: 800 24px -apple-system, BlinkMacSystemFont, "Noto Sans CJK TC", "PingFang TC", Arial, sans-serif; fill: #3d2a20; }
      .body { font: 500 21px -apple-system, BlinkMacSystemFont, "Noto Sans CJK TC", "PingFang TC", Arial, sans-serif; fill: #473f39; }
      .small { font: 500 18px -apple-system, BlinkMacSystemFont, "Noto Sans CJK TC", "PingFang TC", Arial, sans-serif; fill: #625a54; }
      .mono { font: 700 19px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; fill: #3f342f; }
    </style>
  </defs>
  <rect width="1400" height="820" rx="38" fill="url(#bg)"/>
  <circle cx="1220" cy="130" r="150" fill="#ffd6b6" opacity="0.5"/>
  <circle cx="165" cy="675" r="135" fill="#cdefff" opacity="0.65"/>

  <text x="70" y="88" class="title">1. Install → Setup Wizard</text>
  <text x="70" y="126" class="subtitle">安裝後先引導設定，不要求使用者自己猜設定檔。</text>

  <g filter="url(#shadow)">
    <rect x="70" y="180" width="370" height="470" rx="30" fill="#ffffff"/>
    <rect x="515" y="180" width="370" height="470" rx="30" fill="#ffffff"/>
    <rect x="960" y="180" width="370" height="470" rx="30" fill="#ffffff"/>
  </g>

  <g>
    <text x="105" y="235" class="label">Install</text>
    <rect x="105" y="270" width="275" height="62" rx="18" fill="#fff0dc"/>
    <text x="128" y="308" class="mono">openclaw skills install</text>
    <path d="M175 410 h190 a26 26 0 0 1 26 26 v92 a26 26 0 0 1-26 26 H175 a26 26 0 0 1-26-26 v-92 a26 26 0 0 1 26-26Z" fill="#fbd1a9"/>
    <text x="182" y="466" class="body">skills/</text>
    <text x="182" y="502" class="mono">personal-hooks</text>
    <text x="112" y="612" class="small">Copy or install the skill folder.</text>
    <text x="112" y="640" class="small">放到 OpenClaw 的 skills 目錄。</text>
  </g>

  <path d="M452 410 C482 390 492 390 522 410" fill="none" stroke="#9bbf9a" stroke-width="12" stroke-linecap="round"/>
  <path d="M502 386 L528 410 L502 434" fill="none" stroke="#9bbf9a" stroke-width="12" stroke-linecap="round"/>

  <g>
    <text x="550" y="235" class="label">Setup Wizard</text>
    <rect x="550" y="270" width="292" height="305" rx="24" fill="#edf8ff"/>
    <text x="580" y="318" class="body">Missing setup detected:</text>
    <text x="580" y="358" class="mono">timezone</text>
    <text x="580" y="393" class="mono">sleep_time</text>
    <text x="580" y="428" class="mono">wake_time</text>
    <text x="580" y="463" class="mono">/new mode</text>
    <text x="580" y="498" class="mono">care style</text>
    <text x="548" y="612" class="small">The agent asks guided questions.</text>
    <text x="548" y="640" class="small">安裝精靈會問必要設定。</text>
  </g>

  <path d="M897 410 C927 390 937 390 967 410" fill="none" stroke="#9bbf9a" stroke-width="12" stroke-linecap="round"/>
  <path d="M947 386 L973 410 L947 434" fill="none" stroke="#9bbf9a" stroke-width="12" stroke-linecap="round"/>

  <g>
    <text x="995" y="235" class="label">Ready</text>
    <rect x="995" y="276" width="285" height="82" rx="24" fill="#e9f8df"/>
    <text x="1024" y="310" class="body">Agent:</text>
    <text x="1024" y="340" class="small">I can now carry context</text>
    <text x="1024" y="368" class="small">after /new.</text>
    <rect x="995" y="405" width="285" height="100" rx="24" fill="#fff4d8"/>
    <text x="1024" y="442" class="body">Agent：</text>
    <text x="1024" y="474" class="small">我已知道作息、勿擾、</text>
    <text x="1024" y="502" class="small">新對話承接方式。</text>
    <text x="1002" y="612" class="small">No manual config digging first.</text>
    <text x="1002" y="640" class="small">不用一開始就翻設定檔。</text>
  </g>

  <rect x="70" y="705" width="1260" height="52" rx="26" fill="#ffffff" opacity="0.8"/>
  <text x="100" y="738" class="small">Key point: first install can guide users through required settings and preferences.</text>
</svg>


path: assets/publish/comic-04-command-settings.svg
content: <svg xmlns="http://www.w3.org/2000/svg" width="1400" height="820" viewBox="0 0 1400 820" role="img" aria-labelledby="title desc">
  <title id="title">OpenClaw Continuity command settings comic</title>
  <desc id="desc">A bilingual comic panel showing precise command-based settings changes for OpenClaw Continuity.</desc>
  <defs>
    <linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
      <stop offset="0%" stop-color="#f5f7ff"/>
      <stop offset="55%" stop-color="#fffaf0"/>
      <stop offset="100%" stop-color="#efffed"/>
    </linearGradient>
    <filter id="shadow" x="-20%" y="-20%" width="140%" height="150%">
      <feDropShadow dx="0" dy="14" stdDeviation="16" flood-color="#617191" flood-opacity="0.24"/>
    </filter>
    <style>
      .title { font: 800 48px -apple-system, BlinkMacSystemFont, "Noto Sans CJK TC", "PingFang TC", Arial, sans-serif; fill: #25325b; }
      .subtitle { font: 500 24px -apple-system, BlinkMacSystemFont, "Noto Sans CJK TC", "PingFang TC", Arial, sans-serif; fill: #535a67; }
      .label { font: 800 24px -apple-system, BlinkMacSystemFont, "Noto Sans CJK TC", "PingFang TC", Arial, sans-serif; fill: #263145; }
      .body { font: 500 21px -apple-system, BlinkMacSystemFont, "Noto Sans CJK TC", "PingFang TC", Arial, sans-serif; fill: #3f4654; }
      .small { font: 500 18px -apple-system, BlinkMacSystemFont, "Noto Sans CJK TC", "PingFang TC", Arial, sans-serif; fill: #5c6370; }
      .mono { font: 700 18px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; fill: #273044; }
    </style>
  </defs>
  <rect width="1400" height="820" rx="38" fill="url(#bg)"/>
  <circle cx="1175" cy="155" r="140" fill="#d6ecff" opacity="0.65"/>
  <circle cx="205" cy="690" r="145" fill="#ffe3bc" opacity="0.5"/>

  <text x="70" y="88" class="title">4. Precise Command Changes</text>
  <text x="70" y="126" class="subtitle">工程使用者可以用指令檢查與精準修改，不靠猜。</text>

  <g filter="url(#shadow)">
    <rect x="86" y="190" width="1228" height="465" rx="34" fill="#ffffff"/>
  </g>

  <g>
    <text x="135" y="252" class="label">Check current state</text>
    <rect x="135" y="290" width="530" height="92" rx="22" fill="#202331"/>
    <text x="165" y="326" class="mono" fill="#eef2ff">python3 scripts/personal_hooks.py setup-check</text>
    <text x="165" y="358" class="mono" fill="#eef2ff"># shows configured / missing fields</text>

    <text x="135" y="445" class="label">Apply one exact change</text>
    <rect x="135" y="482" width="610" height="112" rx="22" fill="#202331"/>
    <text x="165" y="518" class="mono" fill="#eef2ff">python3 scripts/personal_hooks.py setup-apply \</text>
    <text x="165" y="550" class="mono" fill="#eef2ff">  --payload-json '{"new_session_continuity_mode":</text>
    <text x="165" y="582" class="mono" fill="#eef2ff">  "last_user_intent_first"}'</text>
  </g>

  <path d="M760 430 C812 398 858 398 910 430" fill="none" stroke="#90b8e5" stroke-width="14" stroke-linecap="round"/>
  <path d="M880 399 L918 430 L880 461" fill="none" stroke="#90b8e5" stroke-width="14" stroke-linecap="round"/>

  <g>
    <text x="965" y="252" class="label">Auditable result</text>
    <rect x="965" y="292" width="270" height="250" rx="28" fill="#eef8ff"/>
    <text x="995" y="336" class="mono">applied: 1</text>
    <text x="995" y="374" class="mono">skipped: 0</text>
    <text x="995" y="412" class="mono">mode updated</text>
    <text x="995" y="470" class="small">Only requested fields change.</text>
    <text x="995" y="500" class="small">只改指定欄位。</text>
  </g>

  <rect x="92" y="705" width="1215" height="52" rx="26" fill="#ffffff" opacity="0.82"/>
  <text x="122" y="738" class="small">Key point: commands are for precise operations; natural language remains available for normal users.</text>
</svg>


path: assets/publish/continuity-architecture.svg
content: <svg xmlns="http://www.w3.org/2000/svg" width="1600" height="900" viewBox="0 0 1600 900" role="img" aria-labelledby="title desc">
  <title id="title">OpenClaw Continuity architecture diagram</title>
  <desc id="desc">A professional architecture illustration showing chat input, staged memory, tracked follow-up, time sense, and frontstage-safe output.</desc>
  <defs>
    <linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
      <stop offset="0%" stop-color="#f7fafc"/>
      <stop offset="100%" stop-color="#eef4f8"/>
    </linearGradient>
    <filter id="shadow" x="-20%" y="-20%" width="150%" height="160%">
      <feDropShadow dx="0" dy="10" stdDeviation="18" flood-color="#57738b" flood-opacity="0.16"/>
    </filter>
    <style>
      .title { font: 700 58px -apple-system, BlinkMacSystemFont, "Helvetica Neue", Arial, sans-serif; fill: #21384a; }
      .sub { font: 400 24px -apple-system, BlinkMacSystemFont, "Helvetica Neue", Arial, sans-serif; fill: #556a78; }
      .h { font: 700 26px -apple-system, BlinkMacSystemFont, "Helvetica Neue", Arial, sans-serif; fill: #294152; }
      .b { font: 400 20px -apple-system, BlinkMacSystemFont, "Helvetica Neue", Arial, sans-serif; fill: #4f6574; }
      .k { font: 600 18px -apple-system, BlinkMacSystemFont, "Helvetica Neue", Arial, sans-serif; fill: #1f4255; }
    </style>
  </defs>

  <rect width="1600" height="900" rx="32" fill="url(#bg)"/>
  <text x="110" y="115" class="title">OpenClaw Continuity</text>
  <text x="110" y="160" class="sub">A state-backed layer between ordinary chat, staged memory, tracked follow-up, and frontstage-safe delivery.</text>

  <g filter="url(#shadow)">
    <rect x="95" y="240" width="280" height="490" rx="28" fill="#ffffff"/>
    <rect x="440" y="220" width="720" height="530" rx="32" fill="#ffffff"/>
    <rect x="1220" y="240" width="285" height="490" rx="28" fill="#ffffff"/>
  </g>

  <g>
    <text x="130" y="300" class="h">Input</text>
    <rect x="130" y="340" width="210" height="66" rx="20" fill="#eef7ff" stroke="#bdd9ee" stroke-width="3"/>
    <text x="164" y="381" class="k">normal chat</text>
    <rect x="130" y="430" width="210" height="66" rx="20" fill="#fff2e7" stroke="#efccb1" stroke-width="3"/>
    <text x="172" y="471" class="k">new chat /new</text>
    <rect x="130" y="520" width="210" height="66" rx="20" fill="#edf8ef" stroke="#c9e3cf" stroke-width="3"/>
    <text x="158" y="561" class="k">natural settings</text>
    <text x="130" y="640" class="b">Ordinary user turns,</text>
    <text x="130" y="672" class="b">new sessions, and</text>
    <text x="130" y="704" class="b">spoken config changes</text>
  </g>

  <g>
    <text x="476" y="280" class="h">Continuity Engine</text>
    <rect x="476" y="320" width="650" height="118" rx="24" fill="#f7fbff" stroke="#cadde9" stroke-width="3"/>
    <text x="510" y="360" class="k">Routing</text>
    <text x="510" y="395" class="b">casual_chat · staged_memory · tracked_followup</text>
    <text x="510" y="425" class="b">Keeps normal chat separate from continuity state</text>

    <rect x="476" y="470" width="300" height="108" rx="24" fill="#fff6ee" stroke="#efcfb3" stroke-width="3"/>
    <text x="510" y="510" class="k">Time Sense</text>
    <text x="510" y="545" class="b">elapsed time</text>
    <text x="510" y="573" class="b">day boundary · sleep boundary</text>

    <rect x="826" y="470" width="300" height="108" rx="24" fill="#eef8ff" stroke="#c7ddeb" stroke-width="3"/>
    <text x="860" y="510" class="k">Carryover</text>
    <text x="860" y="545" class="b">reconnects /new</text>
    <text x="860" y="573" class="b">to the right pending topic</text>

    <rect x="476" y="610" width="300" height="108" rx="24" fill="#eef9ef" stroke="#c9e3cf" stroke-width="3"/>
    <text x="510" y="650" class="k">Control</text>
    <text x="510" y="685" class="b">schedule, care, tracking, style</text>

    <rect x="826" y="610" width="300" height="108" rx="24" fill="#f7f1ff" stroke="#d8caf4" stroke-width="3"/>
    <text x="860" y="650" class="k">Guardrails</text>
    <text x="860" y="685" class="b">cooldown · closure · rest suppress</text>
  </g>

  <g>
    <text x="1255" y="300" class="h">Output</text>
    <rect x="1255" y="340" width="215" height="72" rx="20" fill="#fff7ef" stroke="#efcfb3" stroke-width="3"/>
    <text x="1290" y="383" class="k">care / follow-up</text>
    <rect x="1255" y="438" width="215" height="72" rx="20" fill="#eef8ff" stroke="#c7ddeb" stroke-width="3"/>
    <text x="1300" y="481" class="k">daily memory</text>
    <rect x="1255" y="536" width="215" height="72" rx="20" fill="#edf8ef" stroke="#c9e3cf" stroke-width="3"/>
    <text x="1302" y="579" class="k">clean frontstage</text>
    <text x="1255" y="650" class="b">Only structured, eligible</text>
    <text x="1255" y="682" class="b">content reaches the user.</text>
  </g>

  <path d="M355 465 C410 465, 425 465, 460 465" fill="none" stroke="#8ea8ba" stroke-width="8" stroke-linecap="round"/>
  <path d="M1135 530 C1170 530, 1180 530, 1217 530" fill="none" stroke="#8ea8ba" stroke-width="8" stroke-linecap="round"/>
  <path d="M454 452 L482 465 L454 478 Z" fill="#8ea8ba"/>
  <path d="M1210 517 L1238 530 L1210 543 Z" fill="#8ea8ba"/>
</svg>


path: assets/publish/product-story-bilingual.svg
content: <svg xmlns="http://www.w3.org/2000/svg" width="1600" height="900" viewBox="0 0 1600 900" role="img" aria-labelledby="title desc">
  <title id="title">OpenClaw Continuity bilingual product story</title>
  <desc id="desc">A comic-style bilingual product illustration explaining OpenClaw Continuity: remember, reconnect, follow up, and adjust settings naturally.</desc>
  <defs>
    <linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
      <stop offset="0%" stop-color="#fff7ed"/>
      <stop offset="48%" stop-color="#eef8ff"/>
      <stop offset="100%" stop-color="#f7fff1"/>
    </linearGradient>
    <linearGradient id="card" x1="0" y1="0" x2="1" y2="1">
      <stop offset="0%" stop-color="#ffffff"/>
      <stop offset="100%" stop-color="#fffaf3"/>
    </linearGradient>
    <filter id="shadow" x="-20%" y="-20%" width="140%" height="150%">
      <feDropShadow dx="0" dy="16" stdDeviation="20" flood-color="#b99b82" flood-opacity="0.28"/>
    </filter>
    <style>
      .title { font: 800 60px -apple-system, BlinkMacSystemFont, "Noto Sans CJK TC", "PingFang TC", "Helvetica Neue", Arial, sans-serif; fill: #6f2c1d; }
      .sub { font: 500 26px -apple-system, BlinkMacSystemFont, "Noto Sans CJK TC", "PingFang TC", "Helvetica Neue", Arial, sans-serif; fill: #5f5751; }
      .panel-title { font: 800 30px -apple-system, BlinkMacSystemFont, "Noto Sans CJK TC", "PingFang TC", "Helvetica Neue", Arial, sans-serif; fill: #40251d; }
      .body { font: 500 22px -apple-system, BlinkMacSystemFont, "Noto Sans CJK TC", "PingFang TC", "Helvetica Neue", Arial, sans-serif; fill: #504742; }
      .small { font: 500 18px -apple-system, BlinkMacSystemFont, "Noto Sans CJK TC", "PingFang TC", "Helvetica Neue", Arial, sans-serif; fill: #645b55; }
      .chip { font: 700 18px -apple-system, BlinkMacSystemFont, "Noto Sans CJK TC", "PingFang TC", "Helvetica Neue", Arial, sans-serif; fill: #6f2c1d; }
      .mono { font: 700 19px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; fill: #3f342f; }
    </style>
  </defs>

  <rect width="1600" height="900" rx="44" fill="url(#bg)"/>
  <circle cx="1355" cy="125" r="170" fill="#ffe1c7" opacity="0.55"/>
  <circle cx="185" cy="770" r="155" fill="#d9f1ff" opacity="0.72"/>
  <path d="M1170 820 C1260 720 1420 760 1490 640" fill="none" stroke="#d2eec6" stroke-width="28" stroke-linecap="round" opacity="0.75"/>

  <text x="95" y="110" class="title">OpenClaw Continuity</text>
  <text x="95" y="154" class="sub">Remember the right thing. Reconnect the right topic. Follow up naturally.</text>
  <text x="95" y="191" class="sub">記住對的事，接回對的主題，自然關心與追蹤。</text>

  <g filter="url(#shadow)">
    <rect x="88" y="248" width="330" height="470" rx="34" fill="url(#card)" stroke="#ffe0c2" stroke-width="2"/>
    <rect x="452" y="248" width="330" height="470" rx="34" fill="url(#card)" stroke="#d9efff" stroke-width="2"/>
    <rect x="816" y="248" width="330" height="470" rx="34" fill="url(#card)" stroke="#e4f4d8" stroke-width="2"/>
    <rect x="1180" y="248" width="330" height="470" rx="34" fill="url(#card)" stroke="#f2e1ff" stroke-width="2"/>
  </g>

  <g>
    <text x="123" y="306" class="panel-title">1. Hold it</text>
    <text x="123" y="338" class="body">「晚點再接」不再消失。</text>
    <rect x="123" y="380" width="235" height="74" rx="22" fill="#fff0df"/>
    <text x="145" y="410" class="small">User: later, please</text>
    <text x="145" y="438" class="small">使用者：晚點再聊</text>
    <path d="M166 520 C170 482 215 472 247 494 C282 470 330 486 331 529 C332 579 249 615 249 615 C249 615 160 575 166 520Z" fill="#ffb992"/>
    <text x="145" y="665" class="mono">staged_memory</text>
  </g>

  <g>
    <text x="487" y="306" class="panel-title">2. Reconnect</text>
    <text x="487" y="338" class="body">`/new` 可選怎麼接。</text>
    <rect x="487" y="380" width="235" height="54" rx="22" fill="#e8f6ff"/>
    <text x="510" y="414" class="mono">/new</text>
    <path d="M548 518 C588 480 650 480 691 518" fill="none" stroke="#7db8d6" stroke-width="10" stroke-linecap="round"/>
    <circle cx="553" cy="548" r="38" fill="#bae5ff"/>
    <circle cx="687" cy="548" r="38" fill="#bae5ff"/>
    <path d="M592 548 L648 548" stroke="#5f98b4" stroke-width="10" stroke-linecap="round"/>
    <text x="500" y="636" class="chip">recent 4 turns</text>
    <text x="500" y="666" class="chip">last user intent</text>
    <text x="500" y="696" class="chip">follow-up focus</text>
  </g>

  <g>
    <text x="851" y="306" class="panel-title">3. Care with time</text>
    <text x="851" y="338" class="body">時間感只輔助，不搶主線。</text>
    <circle cx="982" cy="505" r="93" fill="#fff5d8" stroke="#e8b45d" stroke-width="10"/>
    <circle cx="982" cy="505" r="12" fill="#6f2c1d"/>
    <path d="M982 505 L982 445" stroke="#6f2c1d" stroke-width="9" stroke-linecap="round"/>
    <path d="M982 505 L1032 527" stroke="#6f2c1d" stroke-width="9" stroke-linecap="round"/>
    <rect x="862" y="635" width="110" height="42" rx="21" fill="#eaf8e2"/>
    <text x="888" y="662" class="chip">sleep</text>
    <rect x="986" y="635" width="116" height="42" rx="21" fill="#e8f6ff"/>
    <text x="1005" y="662" class="chip">wake</text>
    <text x="858" y="704" class="small">quiet hours / cooldown / closure</text>
  </g>

  <g>
    <text x="1215" y="306" class="panel-title">4. Adjust by talking</text>
    <text x="1215" y="338" class="body">設定不用只靠改檔。</text>
    <rect x="1215" y="382" width="240" height="76" rx="24" fill="#f7edff"/>
    <text x="1236" y="413" class="small">"Use last user intent"</text>
    <text x="1236" y="441" class="small">「改成最後意圖」</text>
    <rect x="1215" y="510" width="240" height="76" rx="24" fill="#fff0df"/>
    <text x="1236" y="541" class="small">"Quieter after midnight"</text>
    <text x="1236" y="569" class="small">「半夜安靜一點」</text>
    <text x="1220" y="664" class="mono">setup-check</text>
    <text x="1220" y="694" class="mono">setup-apply</text>
  </g>

  <rect x="95" y="785" width="1410" height="58" rx="29" fill="#ffffff" opacity="0.82"/>
  <text x="130" y="822" class="small">Skill-layer package only: continuity, setup, memory routing, and guards. Channel delivery belongs to the OpenClaw host.</text>
</svg>


path: assets/publish/install-placement-en.svg
content: <svg xmlns="http://www.w3.org/2000/svg" width="1200" height="520" viewBox="0 0 1200 520" role="img" aria-labelledby="title desc">
  <title id="title">OpenClaw Continuity install placement</title>
  <desc id="desc">Place the personal-hooks skill folder under an OpenClaw workspace skills directory.</desc>
  <rect width="1200" height="520" rx="36" fill="#101820"/>
  <circle cx="1050" cy="90" r="120" fill="#f2a65a" opacity="0.18"/>
  <circle cx="140" cy="440" r="160" fill="#4fb3bf" opacity="0.14"/>
  <rect x="72" y="70" width="1056" height="380" rx="28" fill="#f8f4e9"/>
  <text x="116" y="128" font-family="Menlo, Consolas, monospace" font-size="34" font-weight="700" fill="#101820">Install placement</text>
  <text x="116" y="178" font-family="Menlo, Consolas, monospace" font-size="24" fill="#30444c">Copy or link the skill folder here:</text>
  <text x="150" y="245" font-family="Menlo, Consolas, monospace" font-size="28" fill="#101820">openclaw-workspace/</text>
  <text x="190" y="295" font-family="Menlo, Consolas, monospace" font-size="28" fill="#101820">skills/</text>
  <text x="230" y="345" font-family="Menlo, Consolas, monospace" font-size="28" font-weight="700" fill="#b8402b">personal-hooks/</text>
  <text x="270" y="395" font-family="Menlo, Consolas, monospace" font-size="24" fill="#30444c">SKILL.md  scripts/  docs/  examples/</text>
  <path d="M760 250h170" stroke="#b8402b" stroke-width="8" stroke-linecap="round"/>
  <path d="M910 222l48 28-48 28" fill="none" stroke="#b8402b" stroke-width="8" stroke-linecap="round" stroke-linejoin="round"/>
  <text x="756" y="328" font-family="Menlo, Consolas, monospace" font-size="24" fill="#30444c">Then run:</text>
  <text x="756" y="366" font-family="Menlo, Consolas, monospace" font-size="22" fill="#101820">python3 skills/personal-hooks/scripts/personal_hooks.py init</text>
</svg>


path: assets/publish/hero-continuity-en.svg
content: <svg xmlns="http://www.w3.org/2000/svg" width="1600" height="900" viewBox="0 0 1600 900" role="img" aria-labelledby="title desc">
  <title id="title">OpenClaw Continuity English concept illustration</title>
  <desc id="desc">A friendly concept illustration for continuity, time sense, carryover, and natural-language settings in OpenClaw.</desc>
  <defs>
    <linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
      <stop offset="0%" stop-color="#fff6eb"/>
      <stop offset="100%" stop-color="#f4fbff"/>
    </linearGradient>
    <linearGradient id="panel" x1="0" y1="0" x2="1" y2="1">
      <stop offset="0%" stop-color="#ffffff"/>
      <stop offset="100%" stop-color="#fff8f1"/>
    </linearGradient>
    <filter id="shadow" x="-20%" y="-20%" width="140%" height="160%">
      <feDropShadow dx="0" dy="16" stdDeviation="22" flood-color="#d8c8bb" flood-opacity="0.35"/>
    </filter>
    <style>
      .title { font: 700 64px -apple-system, BlinkMacSystemFont, "Helvetica Neue", Arial, sans-serif; fill: #7a2d18; }
      .subtitle { font: 400 28px -apple-system, BlinkMacSystemFont, "Helvetica Neue", Arial, sans-serif; fill: #6d625d; }
      .section { font: 700 28px -apple-system, BlinkMacSystemFont, "Helvetica Neue", Arial, sans-serif; fill: #5a3024; }
      .body { font: 400 22px -apple-system, BlinkMacSystemFont, "Helvetica Neue", Arial, sans-serif; fill: #554d49; }
      .chip { font: 600 20px -apple-system, BlinkMacSystemFont, "Helvetica Neue", Arial, sans-serif; fill: #7a2d18; }
      .small { font: 500 18px -apple-system, BlinkMacSystemFont, "Helvetica Neue", Arial, sans-serif; fill: #6e625c; }
    </style>
  </defs>

  <rect width="1600" height="900" rx="40" fill="url(#bg)"/>
  <circle cx="1290" cy="135" r="180" fill="#ffe2cc" opacity="0.55"/>
  <circle cx="145" cy="765" r="150" fill="#dff3ff" opacity="0.7"/>

  <text x="110" y="140" class="title">OpenClaw Continuity</text>
  <text x="110" y="190" class="subtitle">State-backed continuity, time sense, care, and follow-up for OpenClaw agents.</text>

  <g filter="url(#shadow)">
    <rect x="92" y="255" width="470" height="525" rx="36" fill="url(#panel)"/>
    <rect x="585" y="255" width="430" height="525" rx="36" fill="url(#panel)"/>
    <rect x="1038" y="255" width="470" height="525" rx="36" fill="url(#panel)"/>
  </g>

  <g>
    <text x="130" y="320" class="section">Normal chat stays normal</text>
    <text x="130" y="365" class="body">Small talk, short replies, and ordinary conversation stay clean.</text>
    <text x="130" y="400" class="body">Follow-up logic does not take over the frontstage tone.</text>
    <rect x="130" y="450" width="150" height="48" rx="24" fill="#ffe7d2"/>
    <text x="150" y="481" class="chip">casual chat</text>
    <rect x="294" y="450" width="170" height="48" rx="24" fill="#e6f6ff"/>
    <text x="317" y="481" class="chip">new chat carryover</text>
    <path d="M150 555 C210 520, 305 520, 360 555" fill="none" stroke="#d28a68" stroke-width="8" stroke-linecap="round"/>
    <path d="M215 628 q35 -72 108 -72 q68 0 98 49" fill="none" stroke="#86b4ca" stroke-width="8" stroke-linecap="round"/>
    <circle cx="205" cy="665" r="54" fill="#ffd4be"/>
    <circle cx="348" cy="650" r="54" fill="#d9f0ff"/>
    <circle cx="188" cy="648" r="7" fill="#6b4337"/>
    <circle cx="222" cy="648" r="7" fill="#6b4337"/>
    <path d="M188 680 q18 14 34 0" fill="none" stroke="#6b4337" stroke-width="5" stroke-linecap="round"/>
    <circle cx="331" cy="635" r="7" fill="#416273"/>
    <circle cx="365" cy="635" r="7" fill="#416273"/>
    <path d="M331 667 q18 14 34 0" fill="none" stroke="#416273" stroke-width="5" stroke-linecap="round"/>
    <text x="130" y="740" class="small">`/new` can reconnect the right pending thread instead of dragging</text>
    <text x="130" y="770" class="small">generic or unrelated history back into the conversation.</text>
  </g>

  <g>
    <text x="620" y="320" class="section">Time sense with real context</text>
    <text x="620" y="365" class="body">It looks at elapsed time, day boundary, and sleep phase</text>
    <text x="620" y="400" class="body">before deciding whether to care, follow up, or stay quiet.</text>
    <circle cx="800" cy="525" r="108" fill="#fff0dd" stroke="#e1a87c" stroke-width="12"/>
    <circle cx="800" cy="525" r="16" fill="#7a2d18"/>
    <path d="M800 525 L800 455" stroke="#7a2d18" stroke-width="10" stroke-linecap="round"/>
    <path d="M800 525 L858 548" stroke="#7a2d18" stroke-width="10" stroke-linecap="round"/>
    <path d="M707 598 q92 88 186 0" fill="none" stroke="#8fbf9e" stroke-width="12" stroke-linecap="round"/>
    <circle cx="738" cy="452" r="26" fill="#e8f7ff"/>
    <text x="725" y="459" class="chip">night</text>
    <circle cx="864" cy="452" r="26" fill="#fff4bf"/>
    <text x="852" y="459" class="chip">day</text>
    <rect x="650" y="645" width="120" height="46" rx="23" fill="#ffe7d2"/>
    <text x="670" y="675" class="chip">day shift</text>
    <rect x="786" y="645" width="130" height="46" rx="23" fill="#e6f6ff"/>
    <text x="804" y="675" class="chip">elapsed time</text>
    <rect x="934" y="645" width="136" height="46" rx="23" fill="#eaf8e7"/>
    <text x="957" y="675" class="chip">routine phase</text>
    <text x="620" y="740" class="small">Time sense is not just “today / yesterday”.</text>
    <text x="620" y="770" class="small">It uses real delay and sleep-wake context before wording follow-up.</text>
  </g>

  <g>
    <text x="1075" y="320" class="section">Settings can be changed by talking</text>
    <text x="1075" y="365" class="body">Users do not need to live inside config files.</text>
    <text x="1075" y="400" class="body">Ordinary language can adjust schedule, care, and tracking rhythm.</text>
    <rect x="1084" y="452" width="378" height="82" rx="26" fill="#fff4ec" stroke="#efc8ac" stroke-width="4"/>
    <text x="1110" y="489" class="body">Help me adjust my follow-up settings</text>
    <text x="1110" y="520" class="small">Check in less often after midnight</text>
    <rect x="1084" y="558" width="378" height="82" rx="26" fill="#eef8ff" stroke="#c7deec" stroke-width="4"/>
    <text x="1110" y="595" class="body">Tone, routine, care frequency</text>
    <text x="1110" y="626" class="small">tracking rhythm, closure rules, quiet hours</text>
    <rect x="1084" y="664" width="378" height="82" rx="26" fill="#eef9ef" stroke="#c9e3cb" stroke-width="4"/>
    <text x="1110" y="701" class="body">Result</text>
    <text x="1110" y="732" class="small">More control without turning the agent into a settings UI</text>
  </g>
</svg>


path: assets/publish/hero-continuity-zh.svg
content: <svg xmlns="http://www.w3.org/2000/svg" width="1600" height="900" viewBox="0 0 1600 900" role="img" aria-labelledby="title desc">
  <title id="title">OpenClaw Continuity 中文概念圖</title>
  <desc id="desc">以一般對話、暫存話題、正式追蹤、時間感與新對話接回為主題的中文技能概念圖。</desc>
  <defs>
    <linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
      <stop offset="0%" stop-color="#fff6eb"/>
      <stop offset="100%" stop-color="#f4fbff"/>
    </linearGradient>
    <linearGradient id="panel" x1="0" y1="0" x2="1" y2="1">
      <stop offset="0%" stop-color="#ffffff"/>
      <stop offset="100%" stop-color="#fff8f1"/>
    </linearGradient>
    <filter id="shadow" x="-20%" y="-20%" width="140%" height="160%">
      <feDropShadow dx="0" dy="16" stdDeviation="22" flood-color="#d8c8bb" flood-opacity="0.35"/>
    </filter>
    <style>
      .title { font: 700 64px -apple-system, BlinkMacSystemFont, "Noto Sans CJK TC", "PingFang TC", sans-serif; fill: #7a2d18; }
      .subtitle { font: 400 28px -apple-system, BlinkMacSystemFont, "Noto Sans CJK TC", "PingFang TC", sans-serif; fill: #6d625d; }
      .section { font: 700 28px -apple-system, BlinkMacSystemFont, "Noto Sans CJK TC", "PingFang TC", sans-serif; fill: #5a3024; }
      .body { font: 400 22px -apple-system, BlinkMacSystemFont, "Noto Sans CJK TC", "PingFang TC", sans-serif; fill: #554d49; }
      .chip { font: 600 20px -apple-system, BlinkMacSystemFont, "Noto Sans CJK TC", "PingFang TC", sans-serif; fill: #7a2d18; }
      .small { font: 500 18px -apple-system, BlinkMacSystemFont, "Noto Sans CJK TC", "PingFang TC", sans-serif; fill: #6e625c; }
    </style>
  </defs>

  <rect width="1600" height="900" rx="40" fill="url(#bg)"/>
  <circle cx="1290" cy="135" r="180" fill="#ffe2cc" opacity="0.55"/>
  <circle cx="145" cy="765" r="150" fill="#dff3ff" opacity="0.7"/>

  <text x="110" y="140" class="title">OpenClaw Continuity</text>
  <text x="110" y="190" class="subtitle">讓 agent 的延續感、時間感、關心與追蹤，都有結構可依。</text>

  <g filter="url(#shadow)">
    <rect x="92" y="255" width="470" height="525" rx="36" fill="url(#panel)"/>
    <rect x="585" y="255" width="430" height="525" rx="36" fill="url(#panel)"/>
    <rect x="1038" y="255" width="470" height="525" rx="36" fill="url(#panel)"/>
  </g>

  <g>
    <text x="130" y="320" class="section">一般對話不被打斷</text>
    <text x="130" y="365" class="body">正常聊天、輕鬆口語、短句回覆，</text>
    <text x="130" y="400" class="body">不會被錯誤追蹤或亂掉的主動關心帶歪。</text>
    <rect x="130" y="450" width="150" height="48" rx="24" fill="#ffe7d2"/>
    <text x="158" y="481" class="chip">一般聊天</text>
    <rect x="294" y="450" width="170" height="48" rx="24" fill="#e6f6ff"/>
    <text x="322" y="481" class="chip">新開對話接回</text>
    <path d="M150 555 C210 520, 305 520, 360 555" fill="none" stroke="#d28a68" stroke-width="8" stroke-linecap="round"/>
    <path d="M215 628 q35 -72 108 -72 q68 0 98 49" fill="none" stroke="#86b4ca" stroke-width="8" stroke-linecap="round"/>
    <circle cx="205" cy="665" r="54" fill="#ffd4be"/>
    <circle cx="348" cy="650" r="54" fill="#d9f0ff"/>
    <circle cx="188" cy="648" r="7" fill="#6b4337"/>
    <circle cx="222" cy="648" r="7" fill="#6b4337"/>
    <path d="M188 680 q18 14 34 0" fill="none" stroke="#6b4337" stroke-width="5" stroke-linecap="round"/>
    <circle cx="331" cy="635" r="7" fill="#416273"/>
    <circle cx="365" cy="635" r="7" fill="#416273"/>
    <path d="M331 667 q18 14 34 0" fill="none" stroke="#416273" stroke-width="5" stroke-linecap="round"/>
    <text x="130" y="740" class="small">`/new` 之後仍能接回真正重要的上一段話題，</text>
    <text x="130" y="770" class="small">不是只剩下模糊的聊天印象。</text>
  </g>

  <g>
    <text x="620" y="320" class="section">時間感與關心有依據</text>
    <text x="620" y="365" class="body">看經過多久、是否跨日、是否跨睡眠邊界，</text>
    <text x="620" y="400" class="body">再決定是不是該關心、該追蹤、該安靜。</text>
    <circle cx="800" cy="525" r="108" fill="#fff0dd" stroke="#e1a87c" stroke-width="12"/>
    <circle cx="800" cy="525" r="16" fill="#7a2d18"/>
    <path d="M800 525 L800 455" stroke="#7a2d18" stroke-width="10" stroke-linecap="round"/>
    <path d="M800 525 L858 548" stroke="#7a2d18" stroke-width="10" stroke-linecap="round"/>
    <path d="M707 598 q92 88 186 0" fill="none" stroke="#8fbf9e" stroke-width="12" stroke-linecap="round"/>
    <circle cx="738" cy="452" r="26" fill="#e8f7ff"/>
    <text x="723" y="459" class="chip">夜</text>
    <circle cx="864" cy="452" r="26" fill="#fff4bf"/>
    <text x="849" y="459" class="chip">日</text>
    <rect x="658" y="645" width="110" height="46" rx="23" fill="#ffe7d2"/>
    <text x="683" y="675" class="chip">跨日</text>
    <rect x="786" y="645" width="130" height="46" rx="23" fill="#e6f6ff"/>
    <text x="814" y="675" class="chip">經過多久</text>
    <rect x="934" y="645" width="120" height="46" rx="23" fill="#eaf8e7"/>
    <text x="960" y="675" class="chip">作息階段</text>
    <text x="620" y="740" class="small">時間感不是單純報時，也不是看到隔天就硬說「昨天」。</text>
    <text x="620" y="770" class="small">它會根據真實時間差與睡醒邊界來表達。</text>
  </g>

  <g>
    <text x="1075" y="320" class="section">設定能用口語直接改</text>
    <text x="1075" y="365" class="body">不用卡在設定檔，也不用背很多指令。</text>
    <text x="1075" y="400" class="body">直接說話，就能改作息、關心與追蹤節奏。</text>
    <rect x="1084" y="452" width="378" height="82" rx="26" fill="#fff4ec" stroke="#efc8ac" stroke-width="4"/>
    <text x="1110" y="489" class="body">幫我調整關心設定</text>
    <text x="1110" y="520" class="small">把半夜主動關心調保守一點</text>
    <rect x="1084" y="558" width="378" height="82" rx="26" fill="#eef8ff" stroke="#c7deec" stroke-width="4"/>
    <text x="1110" y="595" class="body">Help me adjust my follow-up settings</text>
    <text x="1110" y="626" class="small">Check in less often after midnight</text>
    <rect x="1084" y="664" width="378" height="82" rx="26" fill="#eef9ef" stroke="#c9e3cb" stroke-width="4"/>
    <text x="1110" y="701" class="body">可調項目</text>
    <text x="1110" y="732" class="small">作息、關心頻率、追蹤節奏、互動風格</text>
  </g>
</svg>


path: scripts/file_output_sop.py
content: #!/usr/bin/env python3
import argparse
import json
import shutil
from pathlib import Path

from send2trash import send2trash


DESTINATION_MAP = {
    "screenshot": "assistant-workspace/captures",
    "webpage": "assistant-workspace/web-cache",
    "download": "assistant-workspace/downloads",
    "monitor_report": "assistant-workspace/monitoring",
}


def resolve_destination_root(workspace_root: Path, file_type: str) -> Path:
    rel = DESTINATION_MAP.get(file_type)
    if not rel:
        raise ValueError(f"unsupported file_type: {file_type}")
    return workspace_root / rel


def unique_destination_path(destination_root: Path, source_path: Path) -> Path:
    destination_root.mkdir(parents=True, exist_ok=True)
    target = destination_root / source_path.name
    if not target.exists():
        return target
    stem = source_path.stem
    suffix = source_path.suffix
    counter = 1
    while True:
        candidate = destination_root / f"{stem}-{counter}{suffix}"
        if not candidate.exists():
            return candidate
        counter += 1


def handle_output_file(source_path, file_type, workspace_root):
    source = Path(source_path).expanduser().resolve()
    root = Path(workspace_root).expanduser().resolve()
    if not source.exists():
        raise FileNotFoundError(source)
    destination_root = resolve_destination_root(root, file_type)
    destination_path = unique_destination_path(destination_root, source)
    shutil.move(str(source), str(destination_path))
    print(f"✅ 已輸出到專區：{destination_path}")
    send2trash(str(destination_path))
    print(f"🗑 已移至垃圾桶待人工驗收（未永久刪除）：{destination_path}")
    return {
        "destination": str(destination_path),
        "trashed_path": str(destination_path),
        "status": "ok",
    }


def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument("source_path")
    parser.add_argument("file_type", choices=sorted(DESTINATION_MAP))
    parser.add_argument("workspace_root")
    args = parser.parse_args()
    result = handle_output_file(args.source_path, args.file_type, args.workspace_root)
    print(json.dumps(result, ensure_ascii=False, indent=2))


if __name__ == "__main__":
    main()


path: scripts/semantic_config.py
content: #!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Host Semantic Config — host-level custom signal/event-type definitions.

This module loads a host_semantics.json file that lets each gateway host
define extra signal keywords, map them to event types, set priorities,
and choose auto/suggest/manual creation policy.

The config is ADDITIVE: it extends (never replaces) the hardcoded
signal lists in event_store.py.  Hosts can also override the
auto/suggest policy for built-in event types.

File location (resolved at runtime):
  $OPENCLAW_STATE_DIR/workspace/personal-hooks/host_semantics.json

Schema:
{
  "version": 1,
  "categories": [
    {
      "id":             "identity",          // unique per category
      "label":          "身份類",             // display label
      "signals":        ["我是...","叫我..."],
      "event_type":     "identity_event",    // → appended to EVENT_TYPES if new
      "default_title":  "身份資訊：{signal}",
      "desired_followup": "記住並反映在稱呼中",
      "priority":       "medium",            // low|medium|high|critical
      "policy":         "suggest",           // auto|suggest|manual
      "negation_aware": true,                // apply NEGATION_PREFIXES check
      "rank":           50                   // detection order (lower = higher priority)
    },
    ...
  ],
  "policy_overrides": {
    // Override auto/suggest for built-in types
    "relationship_event": "auto",
    "work_event": "auto"
  }
}
"""

import json
import os
from typing import Optional, List, Dict, Any

__version__ = "1.0.0"

DEFAULT_OPENCLAW_STATE_DIR = os.path.expanduser("~/.openclaw")

# ── Default empty config ──

EMPTY_CONFIG: Dict[str, Any] = {
    "version": 1,
    "categories": [],
    "policy_overrides": {},
}

# ── Example host_semantics.json (written on first load if missing) ──

EXAMPLE_CONFIG: Dict[str, Any] = {
    "version": 1,
    "categories": [
        {
            "id": "identity",
            "label": "身份類",
            "signals": ["我是", "叫我", "我的名字", "my name is", "稱呼我"],
            "event_type": "identity_event",
            "default_title": "身份資訊：{signal}",
            "desired_followup": "記住並反映在稱呼中",
            "priority": "low",
            "policy": "suggest",
            "negation_aware": False,
            "rank": 60,
        },
        {
            "id": "life_context",
            "label": "生活情境類",
            "signals": [
                "搬家", "出國", "旅行", "出差", "回老家", "換工作",
                "畢業", "入學", "結婚", "離婚", "懷孕", "退休",
            ],
            "event_type": "life_context_event",
            "default_title": "生活情境：{signal}",
            "desired_followup": "留意對日常的影響",
            "priority": "medium",
            "policy": "suggest",
            "negation_aware": True,
            "rank": 45,
        },
        {
            "id": "work_context",
            "label": "工作情境類",
            "signals": [
                "deadline", "上線", "demo", "面試", "開會", "報告",
                "加班", "on call", "交接", "績效", "升遷", "被裁",
            ],
            "event_type": "work_event",
            "default_title": "工作情境：{signal}",
            "desired_followup": "適時關心進度與壓力",
            "priority": "medium",
            "policy": "suggest",
            "negation_aware": True,
            "rank": 40,
        },
        {
            "id": "relationship",
            "label": "關係類",
            "signals": [
                "吵架", "冷戰", "分手", "復合", "想念", "紀念日",
                "約會", "告白", "曖昧", "被拒", "劈腿",
            ],
            "event_type": "relationship_event",
            "default_title": "關係事件：{signal}",
            "desired_followup": "溫和關心，不主動追問細節",
            "priority": "medium",
            "policy": "suggest",
            "negation_aware": True,
            "rank": 35,
        },
        {
            "id": "health_wellness",
            "label": "健康/情緒類（擴充）",
            "signals": [
                "失眠", "睡不著", "過敏", "拉肚子", "胃痛",
                "腰痛", "感冒", "咳嗽", "流鼻水", "眼睛痛",
                "復健", "回診", "抽血", "打針", "吃藥",
            ],
            "event_type": "health_event",
            "default_title": "健康狀況：{signal}",
            "desired_followup": "主動關心身體狀況",
            "priority": "high",
            "policy": "auto",
            "negation_aware": True,
            "rank": 5,
        },
    ],
    "policy_overrides": {},
}


def _resolve_config_path() -> str:
    """Resolve host_semantics.json path from env or default."""
    state_dir = os.environ.get("OPENCLAW_STATE_DIR", DEFAULT_OPENCLAW_STATE_DIR)
    return os.path.join(state_dir, "workspace/personal-hooks/host_semantics.json")


def load_semantic_config(path: Optional[str] = None) -> Dict[str, Any]:
    """Load host semantic config. Returns EMPTY_CONFIG if file missing or invalid."""
    path = path or _resolve_config_path()
    if not os.path.isfile(path):
        return dict(EMPTY_CONFIG)
    try:
        with open(path, "r", encoding="utf-8") as f:
            data = json.load(f)
        if not isinstance(data, dict) or "categories" not in data:
            return dict(EMPTY_CONFIG)
        return data
    except (json.JSONDecodeError, OSError):
        return dict(EMPTY_CONFIG)


def save_semantic_config(config: Dict[str, Any], path: Optional[str] = None) -> str:
    """Write config to disk. Returns path written."""
    path = path or _resolve_config_path()
    os.makedirs(os.path.dirname(path), exist_ok=True)
    with open(path, "w", encoding="utf-8") as f:
        json.dump(config, f, ensure_ascii=False, indent=2)
    return path


def init_example_config(path: Optional[str] = None) -> str:
    """Write the example config if file doesn't exist. Returns path."""
    path = path or _resolve_config_path()
    if os.path.isfile(path):
        return path
    return save_semantic_config(EXAMPLE_CONFIG, path)


# ── Config → Detection Structures ──

class SemanticCategory:
    """Parsed category ready for detection."""

    __slots__ = (
        "id", "label", "signals", "event_type", "default_title",
        "desired_followup", "priority", "policy", "negation_aware", "rank",
    )

    def __init__(self, raw: Dict[str, Any]):
        self.id: str = raw.get("id", "unknown")
        self.label: str = raw.get("label", self.id)
        self.signals: List[str] = raw.get("signals", [])
        self.event_type: str = raw.get("event_type", "custom")
        self.default_title: str = raw.get("default_title", "{signal}")
        self.desired_followup: str = raw.get("desired_followup", "")
        self.priority: str = raw.get("priority", "medium")
        self.policy: str = raw.get("policy", "suggest")  # auto|suggest|manual
        self.negation_aware: bool = raw.get("negation_aware", True)
        self.rank: int = raw.get("rank", 50)

    def format_title(self, matched_signal: str) -> str:
        return self.default_title.replace("{signal}", matched_signal)


def parse_categories(config: Dict[str, Any]) -> List[SemanticCategory]:
    """Parse config into sorted list of SemanticCategory (lower rank first)."""
    cats = []
    for raw in config.get("categories", []):
        if not raw.get("signals"):
            continue
        cats.append(SemanticCategory(raw))
    cats.sort(key=lambda c: c.rank)
    return cats


def get_policy_overrides(config: Dict[str, Any]) -> Dict[str, str]:
    """Return {event_type: policy} overrides from config."""
    return dict(config.get("policy_overrides", {}))


# ── CLI ──

def main():
    import sys
    args = sys.argv[1:]
    if not args:
        print(json.dumps({"error": "usage: semantic_config.py <init|show|path>"}, ensure_ascii=False))
        sys.exit(1)

    cmd = args[0]

    if cmd == "init":
        p = init_example_config()
        print(json.dumps({"ok": True, "path": p, "action": "init_example"}, ensure_ascii=False))

    elif cmd == "show":
        cfg = load_semantic_config()
        print(json.dumps(cfg, ensure_ascii=False, indent=2))

    elif cmd == "path":
        print(_resolve_config_path())

    else:
        print(json.dumps({"error": f"unknown command: {cmd}"}, ensure_ascii=False))
        sys.exit(1)


if __name__ == "__main__":
    main()


path: scripts/followup_skill_harness.py
content: #!/usr/bin/env python3
import argparse
import importlib.util
import json
import os
import shutil
import sys
import tempfile
import time
import uuid
from copy import deepcopy
from datetime import datetime, timedelta, timezone
from pathlib import Path


PACKAGE_ROOT = Path(__file__).resolve().parents[1]
OPENCLAW_ROOT_RAW = os.environ.get("PERSONAL_HOOKS_OPENCLAW_ROOT", "").strip()
OPENCLAW_ROOT = Path(OPENCLAW_ROOT_RAW).expanduser() if OPENCLAW_ROOT_RAW else PACKAGE_ROOT
SOURCE_DATA_DIR_RAW = os.environ.get("PERSONAL_HOOKS_SOURCE_DATA_DIR", "").strip()
SOURCE_DATA_DIR = Path(SOURCE_DATA_DIR_RAW).expanduser() if SOURCE_DATA_DIR_RAW else PACKAGE_ROOT / "examples" / "seed-data"
SOURCE_MEMORY_DIR_RAW = os.environ.get("PERSONAL_HOOKS_SOURCE_MEMORY_DIR", "").strip()
SOURCE_MEMORY_DIR = Path(SOURCE_MEMORY_DIR_RAW).expanduser() if SOURCE_MEMORY_DIR_RAW else PACKAGE_ROOT / "examples" / "seed-memory"
SOURCE_JOBS_PATH_RAW = os.environ.get("PERSONAL_HOOKS_SOURCE_JOBS_PATH", "").strip()
SOURCE_JOBS_PATH = Path(SOURCE_JOBS_PATH_RAW).expanduser() if SOURCE_JOBS_PATH_RAW else PACKAGE_ROOT / "examples" / "jobs.sample.json"
SCRIPT_PATH_RAW = os.environ.get("PERSONAL_HOOKS_SCRIPT_PATH", "").strip()
SCRIPT_PATH = Path(SCRIPT_PATH_RAW).expanduser() if SCRIPT_PATH_RAW else PACKAGE_ROOT / "scripts" / "personal_hooks.py"
CONFIG_PATH_RAW = os.environ.get("PERSONAL_HOOKS_OPENCLAW_CONFIG_PATH", "").strip()
CONFIG_PATH = Path(CONFIG_PATH_RAW).expanduser() if CONFIG_PATH_RAW else PACKAGE_ROOT / "examples" / "openclaw.sample.json"
REPORT_DIR_RAW = os.environ.get("PERSONAL_HOOKS_REPORT_DIR", "").strip()
REPORT_DIR = Path(REPORT_DIR_RAW).expanduser() if REPORT_DIR_RAW else PACKAGE_ROOT / "harness-reports"
TEST_TZ = timezone(timedelta(hours=8))
DIRECT_SESSION_KEY = os.environ.get("PERSONAL_HOOKS_TEST_SESSION_KEY", "agent:main:frontstage-web:test-user")
SKILL_HEADINGS = []
for heading in (
    os.environ.get("PERSONAL_HOOKS_SKILL_HEADING", "").strip(),
    "## Skill Context",
    "## V2 公版技能",
):
    if heading and heading not in SKILL_HEADINGS:
        SKILL_HEADINGS.append(heading)


def now_iso() -> str:
    return datetime.now(TEST_TZ).isoformat(timespec="seconds")


def test_tz_name() -> str:
    offset = TEST_TZ.utcoffset(None) or timedelta(0)
    total_minutes = int(offset.total_seconds() // 60)
    sign = "+" if total_minutes >= 0 else "-"
    abs_minutes = abs(total_minutes)
    hours, minutes = divmod(abs_minutes, 60)
    return f"UTC{sign}{hours:02d}:{minutes:02d}"


def ensure_parent(path: Path) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)


def load_json(path: Path, default):
    if not path.exists():
        return deepcopy(default)
    return json.loads(path.read_text(encoding="utf-8"))


def save_json(path: Path, data) -> None:
    ensure_parent(path)
    path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")


def extract_skill_notes(path: Path) -> list[str]:
    if not path.exists():
        return []
    text = path.read_text(encoding="utf-8")
    lines = None
    for heading in SKILL_HEADINGS:
        if heading in text:
            lines = text.split(heading, 1)[1].splitlines()[1:]
            break
    if lines is None:
        return []
    notes: list[str] = []
    for line in lines:
        if line.startswith("## "):
            break
        if line.startswith("- "):
            notes.append(line[2:].strip())
    return notes


def trim(text: str, max_len: int = 120) -> str:
    collapsed = " ".join(str(text or "").split())
    return collapsed if len(collapsed) <= max_len else collapsed[: max_len - 1].rstrip() + "…"


def event_chain_fields(record: dict) -> list[str]:
    chain = record.get("event_chain") if isinstance(record.get("event_chain"), dict) else {}
    return [field for field in ("context_before", "event_core", "immediate_result", "followup_focus") if chain.get(field)]


def summary_overlap(a: str, b: str) -> bool:
    a_norm = "".join(ch for ch in str(a or "") if ch.isalnum() or "\u4e00" <= ch <= "\u9fff")
    b_norm = "".join(ch for ch in str(b or "") if ch.isalnum() or "\u4e00" <= ch <= "\u9fff")
    if not a_norm or not b_norm:
        return False
    if a_norm in b_norm or b_norm in a_norm:
        return True
    grams_a = {a_norm[i:i + 3] for i in range(max(0, len(a_norm) - 2))}
    grams_b = {b_norm[i:i + 3] for i in range(max(0, len(b_norm) - 2))}
    return len(grams_a & grams_b) >= 2


class Harness:
    def __init__(self, absence_minutes: int, keep_sandbox: bool):
        self.absence_minutes = absence_minutes
        self.keep_sandbox = keep_sandbox
        self.sandbox_root = Path(tempfile.mkdtemp(prefix="ph-followup-harness-"))
        self.baseline_root = self.sandbox_root / "baseline"
        self.work_root = self.sandbox_root / "work"
        self.baseline_data = self.baseline_root / "personal-hooks"
        self.baseline_memory = self.baseline_root / "memory"
        self.baseline_jobs = self.baseline_root / "jobs.json"
        self.work_data = self.work_root / "personal-hooks"
        self.work_memory = self.work_root / "memory"
        self.work_jobs = self.work_root / "jobs.json"
        self.sessions_dir = self.work_root / "sessions"
        self.sessions_index = self.work_root / "sessions.json"
        self.session_file = self.sessions_dir / "direct.jsonl"
        self.report = {
            "generated_at": now_iso(),
            "absence_test_minutes": absence_minutes,
            "sandbox_root": str(self.sandbox_root),
            "report_version": "v1",
            "cases": [],
            "repairs": [],
        }
        self._prepare_baseline()
        self._import_personal_hooks()
        self.reset_case()

    def _prepare_baseline(self) -> None:
        if SOURCE_DATA_DIR.exists():
            shutil.copytree(SOURCE_DATA_DIR, self.baseline_data, dirs_exist_ok=True)
        else:
            self.baseline_data.mkdir(parents=True, exist_ok=True)
        if SOURCE_MEMORY_DIR.exists():
            shutil.copytree(SOURCE_MEMORY_DIR, self.baseline_memory, dirs_exist_ok=True)
        else:
            self.baseline_memory.mkdir(parents=True, exist_ok=True)
        ensure_parent(self.baseline_jobs)
        if SOURCE_JOBS_PATH.exists():
            shutil.copy2(SOURCE_JOBS_PATH, self.baseline_jobs)
        else:
            self.baseline_jobs.write_text(json.dumps({"jobs": []}, ensure_ascii=False, indent=2), encoding="utf-8")
        self.reset_case()

    def _import_personal_hooks(self) -> None:
        os.environ["PERSONAL_HOOKS_DATA_DIR"] = str(self.work_data)
        os.environ["PERSONAL_HOOKS_MEMORY_DIR"] = str(self.work_memory)
        os.environ["PERSONAL_HOOKS_SESSIONS_INDEX_PATH"] = str(self.sessions_index)
        os.environ["PERSONAL_HOOKS_JOBS_PATH"] = str(self.work_jobs)
        os.environ["PERSONAL_HOOKS_OPENCLAW_CONFIG_PATH"] = str(CONFIG_PATH)
        spec = importlib.util.spec_from_file_location(f"personal_hooks_harness_{uuid.uuid4().hex}", SCRIPT_PATH)
        module = importlib.util.module_from_spec(spec)
        assert spec.loader is not None
        spec.loader.exec_module(module)
        self.ph = module

    def cleanup(self) -> None:
        if not self.keep_sandbox:
            shutil.rmtree(self.sandbox_root, ignore_errors=True)

    def reset_case(self) -> None:
        if self.work_root.exists():
            shutil.rmtree(self.work_root)
        shutil.copytree(self.baseline_data, self.work_data, dirs_exist_ok=True)
        shutil.copytree(self.baseline_memory, self.work_memory, dirs_exist_ok=True)
        shutil.copy2(self.baseline_jobs, self.work_jobs)
        if hasattr(self, "ph"):
            self.ph.save_candidate_buffer(deepcopy(self.ph.DEFAULT_CANDIDATE_BUFFER))
            self.ph.save_incidents({"version": 1, "updated_at": now_iso(), "incidents": []})
            self.ph.save_hooks({"version": 1, "updated_at": now_iso(), "hooks": []})
            self.ph.save_session_memory_staging(deepcopy(self.ph.DEFAULT_SESSION_MEMORY_STAGING))
            self.ph.save_memory_rank(deepcopy(self.ph.DEFAULT_MEMORY_RANK))
            self.ph.save_emotion_state(deepcopy(self.ph.DEFAULT_EMOTION_STATE))
            self.ph.save_persona_state(deepcopy(self.ph.DEFAULT_PERSONA_STATE))
            self.ph.save_capability_state(deepcopy(self.ph.DEFAULT_CAPABILITY_STATE))
            for log_path in (
                self.ph.FOLLOWUP_TRACE_PATH,
                self.ph.CANDIDATE_BUFFER_AUDIT_PATH,
                self.ph.SESSION_MEMORY_STAGING_AUDIT_PATH,
                self.ph.HOOK_COMPLETION_AUDIT_PATH,
            ):
                if Path(log_path).exists():
                    Path(log_path).unlink()
        if self.work_memory.exists():
            shutil.rmtree(self.work_memory)
        self.work_memory.mkdir(parents=True, exist_ok=True)
        self.sessions_dir.mkdir(parents=True, exist_ok=True)
        self.session_file.write_text("", encoding="utf-8")
        save_json(
            self.sessions_index,
            {
                DIRECT_SESSION_KEY: {
                    "sessionId": "direct-harness-base",
                    "sessionFile": str(self.session_file),
                    "updatedAt": now_iso(),
                }
            },
        )

    def append_message(self, role: str, text: str, ts: datetime) -> None:
        row = {
            "type": "message",
            "timestamp": ts.isoformat(timespec="seconds"),
            "message": {
                "role": role,
                "content": [{"type": "text", "text": text}],
            },
        }
        with self.session_file.open("a", encoding="utf-8") as fh:
            fh.write(json.dumps(row, ensure_ascii=False) + "\n")
        sessions = load_json(self.sessions_index, {})
        if DIRECT_SESSION_KEY in sessions:
            sessions[DIRECT_SESSION_KEY]["updatedAt"] = ts.isoformat(timespec="seconds")
        save_json(self.sessions_index, sessions)

    def today_memory_path(self, now_dt: datetime) -> Path:
        return self.work_memory / f"{now_dt.date().isoformat()}.md"

    def snapshot(self, now_dt: datetime) -> dict:
        capability = self.ph.refresh_capability_state()
        candidate_store = self.ph.load_candidate_buffer()
        incident_store = self.ph.load_incidents()
        hook_store = self.ph.load_hooks()
        staging_store = self.ph.load_session_memory_staging()
        notes = extract_skill_notes(self.today_memory_path(now_dt))
        trace_tail = self.ph.read_jsonl_tail(self.ph.FOLLOWUP_TRACE_PATH, limit=80) if Path(self.ph.FOLLOWUP_TRACE_PATH).exists() else []
        return {
            "candidate_store": candidate_store,
            "incident_store": incident_store,
            "hook_store": hook_store,
            "staging_store": staging_store,
            "capability_state": capability,
            "candidate_count": len(candidate_store.get("candidates", [])),
            "incident_count": len(incident_store.get("incidents", [])),
            "hook_count": len(hook_store.get("hooks", [])),
            "staging_count": len(staging_store.get("records", [])),
            "daily_memory_notes": notes,
            "hook_counts": capability.get("hook_counts", {}),
            "trace_tail": trace_tail,
        }

    def save_all_stores(self, candidate_store, incident_store, hook_store, memory_rank_store, user_model, persona_state, emotion_state=None) -> None:
        self.ph.save_candidate_buffer(candidate_store)
        self.ph.save_incidents(incident_store)
        self.ph.save_hooks(hook_store)
        self.ph.save_memory_rank(memory_rank_store)
        self.ph.save_user_model(user_model)
        self.ph.save_persona_state(persona_state)
        if emotion_state is not None:
            self.ph.save_emotion_state(emotion_state)
        self.ph.refresh_capability_state()

    def run_user_turn(
        self,
        case_id: str,
        session_id: str,
        user_text: str,
        now_dt: datetime,
        user_turn_index: int,
        is_new_session: bool = False,
    ) -> dict:
        self.append_message("user", user_text, now_dt)
        start = time.perf_counter()
        runtime_context = self.ph.build_runtime_context(
            session_id=session_id,
            session_key=DIRECT_SESSION_KEY,
            user_text=user_text,
            now_dt=now_dt,
            is_new_session=is_new_session,
            user_turn_index=user_turn_index,
        )
        candidate_store = self.ph.load_candidate_buffer()
        incident_store = self.ph.load_incidents()
        hook_store = self.ph.load_hooks()
        memory_rank_store = self.ph.load_memory_rank()
        autonomy_state = self.ph.load_autonomy_state()
        user_model = self.ph.load_user_model()
        persona_state = self.ph.load_persona_state()
        emotion_state = self.ph.load_emotion_state()
        self.ph.update_persona_state(persona_state, user_text)
        result = self.ph.intercept_message(
            user_text,
            incident_store,
            hook_store,
            memory_rank_store,
            candidate_store,
            autonomy_state,
            user_model,
            persona_state,
            now_dt,
            source=f"harness:{case_id}",
        )
        candidate_actions = self.ph.process_candidate_buffer(candidate_store, incident_store, hook_store, memory_rank_store, now_dt)
        self.save_all_stores(candidate_store, incident_store, hook_store, memory_rank_store, user_model, persona_state, emotion_state)
        elapsed = round(time.perf_counter() - start, 4)
        return {
            "runtime_context": runtime_context,
            "decision_result": result,
            "candidate_actions": candidate_actions,
            "timing": {
                "ingest/start": now_dt.isoformat(timespec="seconds"),
                "finish": (now_dt + timedelta(seconds=elapsed)).isoformat(timespec="seconds"),
                "elapsed": elapsed,
            },
        }

    def shorten_pending_hook_triggers(self, base_dt: datetime, hook_ids: list[str]) -> None:
        store = self.ph.load_hooks()
        for hook in store.get("hooks", []):
            if hook.get("id") not in hook_ids or hook.get("status") != "pending":
                continue
            hook["trigger_at"] = (base_dt + timedelta(minutes=self.absence_minutes)).isoformat(timespec="seconds")
            payload = hook.get("payload") if isinstance(hook.get("payload"), dict) else {}
            payload["test_absence_mode"] = True
            payload["test_dispatch"] = True
            hook["payload"] = payload
            hook["note"] = trim(f"{hook.get('note', '').strip()} [test-absence:{self.absence_minutes}m]", 120).strip()
        self.ph.save_hooks(store)
        self.ph.refresh_capability_state()

    def due_and_render(self, now_dt: datetime, preferred_types: tuple[str, ...] = ()) -> dict:
        hook_store = self.ph.load_hooks()
        profile = self.ph.load_profile()
        emotion_state = self.ph.load_emotion_state()
        self.ph.sanitize_pending_hooks_for_temporal_truth(hook_store, profile, emotion_state, now_dt)
        self.ph.save_hooks(hook_store)
        due_hooks = self.ph.due_hooks(hook_store, now_dt)
        if preferred_types:
            filtered = [hook for hook in due_hooks if hook.get("type") in preferred_types]
            due_hooks = filtered or due_hooks
        if not due_hooks:
            return {"hook": None, "message": None}
        hook = due_hooks[0]
        message = self.ph.render_text(profile, self.ph.load_user_model(), self.ph.load_persona_state(), hook, emotion_state=emotion_state, now_dt=now_dt)
        self.ph.mark_hook_dispatched(hook_store, hook, now_dt, message, render_reason=f"harness:{hook.get('type')}", live_dispatch_result="harness-dispatch")
        self.ph.save_hooks(hook_store)
        self.append_message("assistant", message, now_dt)
        self.ph.confirm_hook_delivery(
            hook_store,
            hook,
            now_dt,
            message=message,
            channel="local-transcript",
            live_dispatch_result="harness-local-visible",
        )
        self.ph.save_hooks(hook_store)
        return {"hook": hook, "message": message}

    def latest_trace_record(self, preferred_event_kind: str = ""):
        candidate_store = self.ph.load_candidate_buffer()
        incident_store = self.ph.load_incidents()
        staging_store = self.ph.load_session_memory_staging()
        ordered = []
        ordered.extend(reversed(staging_store.get("records", [])))
        ordered.extend(reversed(incident_store.get("incidents", [])))
        ordered.extend(reversed(candidate_store.get("candidates", [])))
        for item in ordered:
            if preferred_event_kind and item.get("event_kind") != preferred_event_kind:
                continue
            if item.get("status") in {"staged", "promoted"} or item.get("current_status") in {"active_incident", "session_staged"}:
                return item
            if item.get("status") == "pending" or item.get("current_status") == "candidate_only":
                return item
        return ordered[0] if ordered else None

    def trace_delta(self, before: dict, after: dict, *, source_stage: str = "", followup_decision: str = "") -> list[dict]:
        before_len = len(before.get("trace_tail", []))
        rows = after.get("trace_tail", [])[before_len:]
        if source_stage:
            rows = [row for row in rows if row.get("source_stage") == source_stage]
        if followup_decision:
            rows = [row for row in rows if row.get("followup_decision") == followup_decision]
        return rows

    def new_notes(self, before: dict, after: dict) -> list[str]:
        return [note for note in after.get("daily_memory_notes", []) if note not in before.get("daily_memory_notes", [])]

    def hook_delta(self, before: dict, after: dict) -> dict:
        keys = ("staged_only", "tracked_active", "hook_count_active", "hook_count_waiting", "hook_count_dispatched", "hook_count_closed")
        return {
            key: int(after.get("hook_counts", {}).get(key, 0) or 0) - int(before.get("hook_counts", {}).get(key, 0) or 0)
            for key in keys
        }

    def build_case_result(self, case_id: str, category: str, input_text: str, expected: dict, before: dict, after: dict) -> dict:
        return {
            "case_id": case_id,
            "category": category,
            "input_text": input_text,
            "simulated_user_reply": None,
            "expected_class": expected.get("expected_class"),
            "expected_memory_layer": expected.get("expected_memory_layer"),
            "expected_daily_memory_trace": expected.get("expected_daily_memory_trace"),
            "expected_event_chain_fields": expected.get("expected_event_chain_fields", []),
            "expected_carryover_behavior": expected.get("expected_carryover_behavior"),
            "expected_followup_behavior": expected.get("expected_followup_behavior"),
            "actual_class": None,
            "actual_memory_layer": None,
            "actual_daily_memory_trace": self.new_notes(before, after),
            "actual_event_chain_fields": [],
            "actual_carryover_flags": {},
            "actual_followup_dispatch": None,
            "actual_followup_reply_continuity": None,
            "hook_count_delta": self.hook_delta(before, after),
            "session_staging_delta": after["staging_count"] - before["staging_count"],
            "candidate_delta": after["candidate_count"] - before["candidate_count"],
            "incident_delta": after["incident_count"] - before["incident_count"],
            "hook_delta": after["hook_count"] - before["hook_count"],
            "this_reply_used_carryover": False,
            "this_reply_used_pending_topic": False,
            "this_reply_used_event_chain": False,
            "this_reply_used_hook": False,
            "this_reply_used_daily_memory": bool(self.new_notes(before, after)),
            "this_reply_used_schedule_context": False,
            "this_reply_used_no_memory_guard": False,
            "this_reply_used_false_closure_guard": False,
            "timing": {},
            "pass/fail": "fail",
            "failure_reason": "not-evaluated",
        }

    def run_casual_chat_case(self) -> dict:
        self.reset_case()
        case_id = "casual_chat"
        now_dt = datetime(2026, 3, 19, 1, 20, tzinfo=TEST_TZ)
        before = self.snapshot(now_dt)
        turn = self.run_user_turn(case_id, "casual-session", "好累喔，今天好忙。", now_dt, 1)
        after = self.snapshot(now_dt)
        outcome = turn["decision_result"]["learning_entry"]["skill_memory_outcome"]
        result = self.build_case_result(case_id, "casual_chat", "好累喔，今天好忙。", {
            "expected_class": None,
            "expected_memory_layer": "casual_chat",
            "expected_daily_memory_trace": False,
            "expected_event_chain_fields": [],
        }, before, after)
        result["actual_class"] = outcome.get("event_kind")
        result["actual_memory_layer"] = outcome.get("classification")
        result["timing"] = turn["timing"]
        result["this_reply_used_schedule_context"] = bool(turn["runtime_context"].get("schedule_context_prompt"))
        result["this_reply_used_pending_topic"] = bool(turn["runtime_context"].get("pending_topics"))
        result["this_reply_used_no_memory_guard"] = bool(turn["runtime_context"].get("pending_memory_guard_prompt"))
        result["this_reply_used_false_closure_guard"] = bool(turn["runtime_context"].get("false_closure_guard_prompt"))
        is_pass = (
            outcome.get("classification") == "casual_chat"
            and result["session_staging_delta"] == 0
            and result["candidate_delta"] == 0
            and result["incident_delta"] == 0
            and result["hook_delta"] == 0
            and not result["actual_daily_memory_trace"]
        )
        result["pass/fail"] = "pass" if is_pass else "fail"
        result["failure_reason"] = "" if is_pass else "casual chat should not enter staged/tracked memory"
        return result

    def run_parked_topic_case(self) -> dict:
        self.reset_case()
        case_id = "parked_topic"
        now_dt = datetime(2026, 3, 19, 1, 30, tzinfo=TEST_TZ)
        before = self.snapshot(now_dt)
        turn1 = self.run_user_turn(case_id, "parked-session", "我先把這件事放妳這裡，晚點再接，不用現在展開。", now_dt, 1)
        turn2 = self.run_user_turn(case_id, "parked-session", "是工作上的，明天我回來再補。", now_dt + timedelta(minutes=3), 2)
        after = self.snapshot(now_dt + timedelta(minutes=3))
        candidate_store = after["candidate_store"]
        staging_store = after["staging_store"]
        target = self.latest_trace_record("parked_topic")
        candidate_actions = turn2["candidate_actions"]
        result = self.build_case_result(case_id, "parked_topic", "我先把這件事放妳這裡，晚點再接，不用現在展開。", {
            "expected_class": "parked_topic",
            "expected_memory_layer": "staged_memory",
            "expected_daily_memory_trace": True,
            "expected_event_chain_fields": ["context_before", "event_core", "immediate_result", "followup_focus"],
        }, before, after)
        result["actual_class"] = (target or {}).get("event_kind")
        result["actual_memory_layer"] = "tracked_followup" if any(item.get("status") == "promoted" for item in staging_store.get("records", [])) else "staged_memory"
        result["actual_event_chain_fields"] = event_chain_fields(target or {})
        result["timing"] = turn2["timing"]
        result["this_reply_used_pending_topic"] = bool(turn2["runtime_context"].get("pending_topics"))
        result["this_reply_used_schedule_context"] = bool(turn2["runtime_context"].get("schedule_context_prompt"))
        result["this_reply_used_no_memory_guard"] = bool(turn2["runtime_context"].get("pending_memory_guard_prompt"))
        result["this_reply_used_false_closure_guard"] = bool(turn2["runtime_context"].get("false_closure_guard_prompt"))
        merged = any(action.get("action") == "seen_again" for action in candidate_actions) or any(item.get("user_turns", 0) >= 2 for item in staging_store.get("records", []))
        is_pass = (
            result["actual_class"] == "parked_topic"
            and result["actual_memory_layer"] in {"staged_memory", "tracked_followup"}
            and all(field in result["actual_event_chain_fields"] for field in ("context_before", "event_core", "immediate_result", "followup_focus"))
            and bool(result["actual_daily_memory_trace"])
            and merged
        )
        result["pass/fail"] = "pass" if is_pass else "fail"
        result["failure_reason"] = "" if is_pass else "parked topic did not stage/merge with daily trace"
        return result

    def run_watchful_state_case(self) -> dict:
        self.reset_case()
        case_id = "watchful_state"
        now_dt = datetime(2026, 3, 19, 1, 45, tzinfo=TEST_TZ)
        before = self.snapshot(now_dt)
        turn = self.run_user_turn(case_id, "watchful-session", "我現在真的很累，但不想被勸睡，妳先陪我放著。", now_dt, 1)
        hook_store = self.ph.load_hooks()
        pending_ids = [hook.get("id") for hook in hook_store.get("hooks", []) if hook.get("status") == "pending"]
        self.shorten_pending_hook_triggers(now_dt, pending_ids)
        followup = self.due_and_render(now_dt + timedelta(minutes=self.absence_minutes + 1), preferred_types=("emotional_followup", "health_followup"))
        reply_turn = self.run_user_turn(case_id, "watchful-session", "我還是有點累，但沒有想被勸睡。", now_dt + timedelta(minutes=self.absence_minutes + 2), 2)
        after = self.snapshot(now_dt + timedelta(minutes=self.absence_minutes + 2))
        target = self.latest_trace_record("watchful_state")
        continuity_record = self.ph.find_pending_record_by_session_hint(reply_turn["runtime_context"].get("pending_topics", []), "我還是有點累，但沒有想被勸睡。", now_dt)
        message = followup.get("message") or ""
        avoids_sleep_policing = not any(token in message for token in ("快去睡", "太晚了還不睡", "早點睡", "先去睡"))
        continuity_ok = bool(continuity_record and continuity_record.get("event_kind") == "watchful_state")
        result = self.build_case_result(case_id, "watchful_state", "我現在真的很累，但不想被勸睡，妳先陪我放著。", {
            "expected_class": "watchful_state",
            "expected_memory_layer": "tracked_followup",
            "expected_daily_memory_trace": True,
            "expected_event_chain_fields": ["context_before", "event_core", "immediate_result", "followup_focus"],
            "expected_followup_behavior": "absence follow-up should be natural and not sleep-policing",
        }, before, after)
        result["simulated_user_reply"] = "我還是有點累，但沒有想被勸睡。"
        result["actual_class"] = (target or {}).get("event_kind")
        result["actual_memory_layer"] = "tracked_followup" if result["incident_delta"] >= 0 else "staged_memory"
        result["actual_event_chain_fields"] = event_chain_fields(target or {})
        result["actual_hook_event_chain_fields"] = event_chain_fields(followup.get("hook") or {})
        result["actual_followup_dispatch"] = followup
        result["actual_followup_reply_continuity"] = {
            "selected_summary": (continuity_record or {}).get("normalized_seed_summary"),
            "selected_event_kind": (continuity_record or {}).get("event_kind"),
            "continuity_match": continuity_ok,
        }
        result["timing"] = reply_turn["timing"]
        result["this_reply_used_hook"] = bool(followup.get("hook"))
        result["this_reply_used_pending_topic"] = bool(reply_turn["runtime_context"].get("pending_topics"))
        result["this_reply_used_event_chain"] = all(field in result["actual_event_chain_fields"] for field in ("context_before", "event_core", "immediate_result", "followup_focus"))
        result["this_reply_used_schedule_context"] = bool(reply_turn["runtime_context"].get("schedule_context_prompt"))
        result["this_reply_used_daily_memory"] = bool(result["actual_daily_memory_trace"])
        result["this_reply_used_no_memory_guard"] = bool(reply_turn["runtime_context"].get("pending_memory_guard_prompt"))
        result["this_reply_used_false_closure_guard"] = bool(reply_turn["runtime_context"].get("false_closure_guard_prompt"))
        is_pass = (
            result["actual_class"] == "watchful_state"
            and bool(followup.get("hook"))
            and bool(message)
            and avoids_sleep_policing
            and continuity_ok
            and result["this_reply_used_schedule_context"]
            and bool(result["actual_daily_memory_trace"])
            and all(field in result["actual_hook_event_chain_fields"] for field in ("context_before", "event_core", "immediate_result", "followup_focus"))
        )
        result["pass/fail"] = "pass" if is_pass else "fail"
        result["failure_reason"] = "" if is_pass else "watchful state failed tracking, natural follow-up, or continuity"
        return result

    def run_delegated_task_case(self) -> dict:
        self.reset_case()
        case_id = "delegated_task"
        now_dt = datetime(2026, 3, 19, 2, 5, tzinfo=TEST_TZ)
        before = self.snapshot(now_dt)
        turn = self.run_user_turn(case_id, "task-session", "這份資料妳之後先幫我查一下，整理重點再回來接我。", now_dt, 1)
        hook_store = self.ph.load_hooks()
        pending_ids = [hook.get("id") for hook in hook_store.get("hooks", []) if hook.get("status") == "pending"]
        self.shorten_pending_hook_triggers(now_dt, pending_ids)
        followup = self.due_and_render(now_dt + timedelta(minutes=self.absence_minutes + 1), preferred_types=("progress_followup",))
        reply_turn = self.run_user_turn(case_id, "task-session", "我回來了，先跟我說妳整理到哪裡。", now_dt + timedelta(minutes=self.absence_minutes + 2), 2)
        after = self.snapshot(now_dt + timedelta(minutes=self.absence_minutes + 2))
        target = self.latest_trace_record("delegated_task")
        continuity_record = self.ph.find_pending_record_by_session_hint(reply_turn["runtime_context"].get("pending_topics", []), "我回來了，先跟我說妳整理到哪裡。", now_dt)
        result = self.build_case_result(case_id, "delegated_task", "這份資料妳之後先幫我查一下，整理重點再回來接我。", {
            "expected_class": "delegated_task",
            "expected_memory_layer": "tracked_followup",
            "expected_daily_memory_trace": True,
            "expected_event_chain_fields": ["context_before", "event_core", "immediate_result", "followup_focus"],
            "expected_followup_behavior": "progress follow-up after short test absence",
        }, before, after)
        result["simulated_user_reply"] = "我回來了，先跟我說妳整理到哪裡。"
        result["actual_class"] = (target or {}).get("event_kind")
        result["actual_memory_layer"] = "tracked_followup" if after["incident_count"] > before["incident_count"] else "staged_memory"
        result["actual_event_chain_fields"] = event_chain_fields(target or {})
        result["actual_hook_event_chain_fields"] = event_chain_fields(followup.get("hook") or {})
        result["actual_followup_dispatch"] = followup
        result["actual_followup_reply_continuity"] = {
            "selected_summary": (continuity_record or {}).get("normalized_seed_summary"),
            "selected_event_kind": (continuity_record or {}).get("event_kind"),
            "continuity_match": bool(continuity_record and continuity_record.get("event_kind") == "delegated_task"),
        }
        result["timing"] = reply_turn["timing"]
        result["this_reply_used_hook"] = bool(followup.get("hook"))
        result["this_reply_used_pending_topic"] = bool(reply_turn["runtime_context"].get("pending_topics"))
        result["this_reply_used_event_chain"] = all(field in result["actual_event_chain_fields"] for field in ("context_before", "event_core", "immediate_result", "followup_focus"))
        result["this_reply_used_schedule_context"] = bool(reply_turn["runtime_context"].get("schedule_context_prompt"))
        result["this_reply_used_daily_memory"] = bool(result["actual_daily_memory_trace"])
        result["this_reply_used_no_memory_guard"] = bool(reply_turn["runtime_context"].get("pending_memory_guard_prompt"))
        result["this_reply_used_false_closure_guard"] = bool(reply_turn["runtime_context"].get("false_closure_guard_prompt"))
        is_pass = (
            result["actual_class"] == "delegated_task"
            and bool(followup.get("hook"))
            and bool(followup.get("message"))
            and result["actual_followup_reply_continuity"]["continuity_match"]
            and bool(result["actual_daily_memory_trace"])
            and all(field in result["actual_hook_event_chain_fields"] for field in ("context_before", "event_core", "immediate_result", "followup_focus"))
        )
        result["pass/fail"] = "pass" if is_pass else "fail"
        result["failure_reason"] = "" if is_pass else "delegated task failed tracked flow or follow-up continuity"
        return result

    def run_sensitive_event_case(self) -> dict:
        self.reset_case()
        case_id = "sensitive_event"
        now_dt = datetime(2026, 3, 19, 2, 25, tzinfo=TEST_TZ)
        before = self.snapshot(now_dt)
        text = "我剛剛去陽台抽煙，邊走邊跟妳聊，回房間時跌倒，腳有點受傷了。"
        turn = self.run_user_turn(case_id, "sensitive-session", text, now_dt, 1)
        hook_store = self.ph.load_hooks()
        pending_ids = [hook.get("id") for hook in hook_store.get("hooks", []) if hook.get("status") == "pending"]
        self.shorten_pending_hook_triggers(now_dt, pending_ids)
        followup = self.due_and_render(now_dt + timedelta(minutes=self.absence_minutes + 1), preferred_types=("health_followup",))
        reply_turn = self.run_user_turn(case_id, "sensitive-session", "腳還在痛，但沒有剛剛那麼慌。", now_dt + timedelta(minutes=self.absence_minutes + 2), 2)
        after = self.snapshot(now_dt + timedelta(minutes=self.absence_minutes + 2))
        target = self.latest_trace_record("sensitive_event")
        continuity_record = self.ph.find_pending_record_by_session_hint(reply_turn["runtime_context"].get("pending_topics", []), "腳還在痛，但沒有剛剛那麼慌。", now_dt)
        result = self.build_case_result(case_id, "sensitive_event", text, {
            "expected_class": "sensitive_event",
            "expected_memory_layer": "tracked_followup",
            "expected_daily_memory_trace": True,
            "expected_event_chain_fields": ["context_before", "event_core", "immediate_result", "followup_focus"],
            "expected_followup_behavior": "health/safety follow-up should continue from event chain",
        }, before, after)
        result["simulated_user_reply"] = "腳還在痛，但沒有剛剛那麼慌。"
        result["actual_class"] = (target or {}).get("event_kind")
        result["actual_memory_layer"] = "tracked_followup" if after["incident_count"] > before["incident_count"] else "staged_memory"
        result["actual_event_chain_fields"] = event_chain_fields(target or {})
        result["actual_hook_event_chain_fields"] = event_chain_fields(followup.get("hook") or {})
        result["actual_followup_dispatch"] = followup
        result["actual_followup_reply_continuity"] = {
            "selected_summary": (continuity_record or {}).get("normalized_seed_summary"),
            "selected_event_kind": (continuity_record or {}).get("event_kind"),
            "continuity_match": bool(continuity_record and continuity_record.get("event_kind") == "sensitive_event"),
        }
        result["timing"] = reply_turn["timing"]
        result["this_reply_used_hook"] = bool(followup.get("hook"))
        result["this_reply_used_pending_topic"] = bool(reply_turn["runtime_context"].get("pending_topics"))
        result["this_reply_used_event_chain"] = all(field in result["actual_event_chain_fields"] for field in ("context_before", "event_core", "immediate_result", "followup_focus"))
        result["this_reply_used_schedule_context"] = bool(reply_turn["runtime_context"].get("schedule_context_prompt"))
        result["this_reply_used_daily_memory"] = bool(result["actual_daily_memory_trace"])
        result["this_reply_used_no_memory_guard"] = bool(reply_turn["runtime_context"].get("pending_memory_guard_prompt"))
        result["this_reply_used_false_closure_guard"] = bool(reply_turn["runtime_context"].get("false_closure_guard_prompt"))
        message = followup.get("message") or ""
        chainful = any(token in message for token in ("剛剛", "前面", "後來", "現在"))
        is_pass = (
            result["actual_class"] == "sensitive_event"
            and bool(followup.get("hook"))
            and chainful
            and result["actual_followup_reply_continuity"]["continuity_match"]
            and bool(result["actual_daily_memory_trace"])
            and all(field in result["actual_hook_event_chain_fields"] for field in ("context_before", "event_core", "immediate_result", "followup_focus"))
        )
        result["pass/fail"] = "pass" if is_pass else "fail"
        result["failure_reason"] = "" if is_pass else "sensitive event did not keep event chain or continuity"
        return result

    def run_incremental_update_case(self) -> dict:
        self.reset_case()
        case_id = "incremental_update"
        now_dt = datetime(2026, 3, 19, 2, 40, tzinfo=TEST_TZ)
        before = self.snapshot(now_dt)
        self.run_user_turn(case_id, "incremental-session", "我今天心情不好，但不想說，妳先陪我放著。", now_dt, 1)
        self.run_user_turn(case_id, "incremental-session", "是因為家裡那件事。", now_dt + timedelta(minutes=2), 2)
        turn3 = self.run_user_turn(case_id, "incremental-session", "昨晚延續到現在。", now_dt + timedelta(minutes=4), 3)
        after = self.snapshot(now_dt + timedelta(minutes=4))
        staging_records = after["staging_store"].get("records", [])
        candidates = [item for item in after["candidate_store"].get("candidates", []) if item.get("status") == "pending"]
        incidents = [item for item in after["incident_store"].get("incidents", []) if item.get("current_status") == "active_incident"]
        target = self.latest_trace_record("watchful_state") or self.latest_trace_record()
        summaries = []
        chain_blobs = []
        for record in staging_records + incidents:
            if record.get("event_kind") == "watchful_state":
                summaries.append(str(record.get("normalized_seed_summary") or ""))
                chain_blobs.append(json.dumps(record.get("event_chain") or {}, ensure_ascii=False))
        is_merged = len([item for item in staging_records if item.get("status") in {"staged", "promoted"}]) <= 1 and len(incidents) <= 1 and len(candidates) <= 1
        summary_expanded = any(all(token in blob for token in ("家裡", "昨晚")) for blob in summaries + chain_blobs)
        result = self.build_case_result(case_id, "incremental_update", "我今天心情不好，但不想說，妳先陪我放著。", {
            "expected_class": "watchful_state",
            "expected_memory_layer": "staged_memory",
            "expected_daily_memory_trace": True,
            "expected_event_chain_fields": ["context_before", "event_core", "immediate_result", "followup_focus"],
        }, before, after)
        result["actual_class"] = (target or {}).get("event_kind")
        result["actual_memory_layer"] = (target or {}).get("memory_class") or "staged_memory"
        result["actual_event_chain_fields"] = event_chain_fields(target or {})
        result["actual_followup_dispatch"] = {"incremental_summaries": summaries}
        result["timing"] = turn3["timing"]
        result["this_reply_used_pending_topic"] = bool(turn3["runtime_context"].get("pending_topics"))
        result["this_reply_used_event_chain"] = all(field in result["actual_event_chain_fields"] for field in ("context_before", "event_core", "immediate_result", "followup_focus"))
        result["this_reply_used_schedule_context"] = bool(turn3["runtime_context"].get("schedule_context_prompt"))
        result["this_reply_used_daily_memory"] = bool(result["actual_daily_memory_trace"])
        result["this_reply_used_no_memory_guard"] = bool(turn3["runtime_context"].get("pending_memory_guard_prompt"))
        result["this_reply_used_false_closure_guard"] = bool(turn3["runtime_context"].get("false_closure_guard_prompt"))
        is_pass = result["actual_class"] == "watchful_state" and is_merged and summary_expanded
        result["pass/fail"] = "pass" if is_pass else "fail"
        result["failure_reason"] = "" if is_pass else "incremental updates created parallel fragments or failed to enrich summary"
        return result

    def run_new_session_carryover_case(self) -> dict:
        self.reset_case()
        case_id = "new_session_carryover"
        old_session_id = "carryover-old"
        new_session_id = "carryover-new"
        t0 = datetime(2026, 3, 19, 2, 55, tzinfo=TEST_TZ)
        before = self.snapshot(t0)
        self.append_message("user", "我先把技能倉庫那件事放妳這裡，晚點再接。", t0)
        self.append_message("assistant", "好，我先幫你把這件事接住。", t0 + timedelta(seconds=20))
        self.append_message("user", "另外還有一件比較重要的事想晚點再跟妳談。", t0 + timedelta(minutes=1))
        self.append_message("assistant", "我先知道，等你回來我再接。", t0 + timedelta(minutes=1, seconds=20))
        old_ctx = self.ph.build_runtime_context(
            session_id=old_session_id,
            session_key=DIRECT_SESSION_KEY,
            user_text="我先把技能倉庫那件事放妳這裡，晚點再接。",
            now_dt=t0 + timedelta(minutes=1, seconds=30),
            is_new_session=False,
            user_turn_index=2,
        )
        new_ctx = self.ph.build_runtime_context(
            session_id=new_session_id,
            session_key=DIRECT_SESSION_KEY,
            user_text="我回來了。",
            now_dt=t0 + timedelta(minutes=5),
            is_new_session=True,
            user_turn_index=1,
        )
        after = self.snapshot(t0 + timedelta(minutes=5))
        primary = self.ph.find_pending_record_by_session_hint(new_ctx.get("pending_topics", []), "我回來了。", t0 + timedelta(minutes=5))
        result = self.build_case_result(case_id, "/new carryover", "我回來了。", {
            "expected_class": "parked_topic",
            "expected_memory_layer": "staged_memory",
            "expected_daily_memory_trace": False,
            "expected_event_chain_fields": [],
            "expected_carryover_behavior": "new_session_carryover_applied=true and carryover summary present",
        }, before, after)
        result["actual_class"] = (primary or {}).get("event_kind")
        result["actual_memory_layer"] = (primary or {}).get("memory_class")
        result["actual_carryover_flags"] = {
            "new_session_carryover_applied": new_ctx.get("new_session_carryover_applied"),
            "carryover_source": new_ctx.get("carryover_source"),
            "carryover_summary_present": new_ctx.get("carryover_summary_present"),
        }
        result["expected_followup_behavior"] = "new session should reattach carryover/pending topic instead of generic greeting"
        result["actual_followup_dispatch"] = {
            "pending_topics_prompt": trim(new_ctx.get("pending_topics_prompt", ""), 240),
            "carryover_prompt": trim(new_ctx.get("carryover_prompt", ""), 240),
        }
        carryover_prompt_lines = [line.strip() for line in str(new_ctx.get("carryover_prompt") or "").splitlines() if line.strip()]
        continuity_mode_present = any(line == "carryover_new_session_continuity_mode: recent_4_turns_first" for line in carryover_prompt_lines)
        primary_recent_four = any(line == "carryover_primary_continuity_field: recent_4_turns_summary" for line in carryover_prompt_lines)
        selected_recent_four = any(line.startswith("carryover_recent_4_turns:") for line in carryover_prompt_lines)
        result["actual_new_session_continuity_mode"] = {
            "mode_present": continuity_mode_present,
            "primary_recent_4_turns": primary_recent_four,
            "selected_recent_4_turns_present": selected_recent_four,
        }
        result["actual_followup_reply_continuity"] = {
            "primary_summary": (primary or {}).get("normalized_seed_summary"),
            "primary_source_layer": (primary or {}).get("source_layer"),
        }
        result["this_reply_used_carryover"] = bool(new_ctx.get("new_session_carryover_applied"))
        result["this_reply_used_pending_topic"] = bool(new_ctx.get("pending_topics"))
        result["this_reply_used_schedule_context"] = bool(new_ctx.get("schedule_context_prompt"))
        result["this_reply_used_no_memory_guard"] = bool(new_ctx.get("pending_memory_guard_prompt"))
        result["this_reply_used_false_closure_guard"] = bool(new_ctx.get("false_closure_guard_prompt"))
        result["timing"] = {
            "ingest/start": (t0 + timedelta(minutes=5)).isoformat(timespec="seconds"),
            "finish": (t0 + timedelta(minutes=5)).isoformat(timespec="seconds"),
            "elapsed": 0.0,
        }
        is_pass = (
            bool(new_ctx.get("new_session_carryover_applied"))
            and bool(new_ctx.get("carryover_summary_present"))
            and bool(new_ctx.get("carryover_prompt"))
            and continuity_mode_present
            and primary_recent_four
            and selected_recent_four
            and bool(primary)
        )
        result["pass/fail"] = "pass" if is_pass else "fail"
        result["failure_reason"] = "" if is_pass else "carryover flags or pending carryover topic were not observable"
        return result

    def run_active_hook_closure_case(self) -> dict:
        self.reset_case()
        case_id = "active_hook_closure"
        now_dt = datetime.now(TEST_TZ)
        before = self.snapshot(now_dt)
        hook_store = self.ph.load_hooks()
        for idx, hook_type in enumerate(("emotional_followup", "health_followup", "progress_followup", "tomorrow_check"), start=1):
            hook_store["hooks"].append(
                self.ph.build_hook(
                    hook_type,
                    now_dt + timedelta(minutes=idx),
                    f"[harness:{case_id}] hook-{idx}",
                    payload={"test_case": case_id},
                    note="harness",
                )
            )
        if hook_store["hooks"]:
            hook_store["hooks"][0]["trigger_at"] = (now_dt - timedelta(minutes=1)).isoformat(timespec="seconds")
            hook_store["hooks"][1]["status"] = "done"
            hook_store["hooks"][1]["current_status"] = "historical_completed"
            hook_store["hooks"][2]["status"] = "cancelled"
            hook_store["hooks"][2]["current_status"] = "historical_only"
        self.ph.save_hooks(hook_store)
        after = self.snapshot(now_dt)
        counts = after["capability_state"].get("hook_counts", {})
        result = self.build_case_result(case_id, "active hook / closure", "[harness] active hooks", {
            "expected_class": None,
            "expected_memory_layer": None,
            "expected_daily_memory_trace": False,
            "expected_event_chain_fields": [],
            "expected_followup_behavior": "structure should expose active/waiting/dispatched/closed counts",
        }, before, after)
        result["actual_carryover_flags"] = after["capability_state"].get("carryover_status", {})
        result["actual_followup_dispatch"] = {"hook_counts": counts}
        result["timing"] = {
            "ingest/start": now_dt.isoformat(timespec="seconds"),
            "finish": now_dt.isoformat(timespec="seconds"),
            "elapsed": 0.0,
        }
        is_pass = (
            counts.get("hook_count_active", 0) >= 1
            and counts.get("hook_count_waiting", 0) >= 1
            and counts.get("hook_count_dispatched", 0) >= 1
            and counts.get("hook_count_closed", 0) >= 1
        )
        result["pass/fail"] = "pass" if is_pass else "fail"
        result["failure_reason"] = "" if is_pass else "hook state buckets did not expose active/waiting/dispatched/closed"
        return result

    def run_time_sense_case(self) -> dict:
        self.reset_case()
        case_id = "time_sense"
        settings = self.ph.load_settings()
        routine = settings.setdefault("routine_schedule", {})
        routine.update(
            {
                "enabled": True,
                "timezone": test_tz_name(),
                "sleep_time": "05:30",
                "wake_time": "13:15",
                "wake_window_minutes": 60,
            }
        )
        self.ph.save_settings(settings)
        profile = self.ph.load_profile()
        profile["sleep_pattern"] = {
            "chronotype": "night_owl",
            "usual_sleep_time": "05:30",
            "usual_wake_time": "13:15",
            "active_window": "13:15-05:30",
            "care_nudge_time": "04:00",
            "sleep_block_window": "05:30-13:15",
            "source": "harness",
        }
        self.ph.save_profile(profile)
        now_dt = datetime(2026, 3, 19, 1, 35, tzinfo=TEST_TZ)
        before = self.snapshot(now_dt)
        turn = self.run_user_turn(case_id, "time-session", "我現在真的很累，但不想被勸睡，妳先陪我放著。", now_dt, 1)
        same_day_sleep_dt = datetime(2026, 3, 19, 5, 9, tzinfo=TEST_TZ)
        same_day_sleep = self.ph.build_runtime_context(
            session_id="time-session",
            session_key=DIRECT_SESSION_KEY,
            user_text="沒有，我準備睡覺了",
            now_dt=same_day_sleep_dt,
            is_new_session=False,
            user_turn_index=2,
        )
        hook_store = self.ph.load_hooks()
        pending_ids = [hook.get("id") for hook in hook_store.get("hooks", []) if hook.get("status") == "pending"]
        self.shorten_pending_hook_triggers(now_dt, pending_ids)
        followup = self.due_and_render(now_dt + timedelta(minutes=self.absence_minutes + 1), preferred_types=("emotional_followup", "health_followup"))
        after = self.snapshot(now_dt + timedelta(minutes=self.absence_minutes + 1))
        message = followup.get("message") or ""
        result = self.build_case_result(case_id, "time_sense", "我現在真的很累，但不想被勸睡，妳先陪我放著。", {
            "expected_class": "watchful_state",
            "expected_memory_layer": "tracked_followup",
            "expected_daily_memory_trace": True,
            "expected_event_chain_fields": [],
            "expected_followup_behavior": "late-night path should respect night-owl schedule",
        }, before, after)
        result["actual_class"] = "watchful_state"
        result["actual_memory_layer"] = "tracked_followup"
        result["actual_followup_dispatch"] = followup
        result["timing"] = turn["timing"]
        result["this_reply_used_schedule_context"] = bool(turn["runtime_context"].get("schedule_context_prompt"))
        result["this_reply_used_no_memory_guard"] = bool(turn["runtime_context"].get("pending_memory_guard_prompt"))
        result["this_reply_used_false_closure_guard"] = bool(turn["runtime_context"].get("false_closure_guard_prompt"))
        same_day_prompt = str(same_day_sleep.get("time_modifier_prompt") or "")
        result["same_day_wake_guard_present"] = "same local calendar day" in same_day_prompt and "13:15" in same_day_prompt
        result["same_day_wake_guard_prompt"] = same_day_prompt
        is_pass = (
            result["this_reply_used_schedule_context"]
            and result["same_day_wake_guard_present"]
            and not any(token in message for token in ("快去睡", "太晚了還不睡", "早點睡"))
        )
        result["pass/fail"] = "pass" if is_pass else "fail"
        result["failure_reason"] = "" if is_pass else "schedule context was missing, same-day wake guard was missing, or late-night reply slipped into sleep-policing"
        return result

    def run_duplicate_followup_suppression_case(self) -> dict:
        self.reset_case()
        case_id = "duplicate_followup_suppression"
        now_dt = datetime(2026, 3, 19, 3, 5, tzinfo=TEST_TZ)
        before = self.snapshot(now_dt)
        self.run_user_turn(case_id, "dup-session", "我現在有點悶，但先陪我放著就好。", now_dt, 1)
        hook_store = self.ph.load_hooks()
        pending_ids = []
        for hook in hook_store.get("hooks", []):
            if hook.get("status") != "pending":
                continue
            payload = hook.get("payload") if isinstance(hook.get("payload"), dict) else {}
            payload["test_dispatch_cooldown_minutes"] = self.absence_minutes * 3
            hook["payload"] = payload
            pending_ids.append(hook.get("id"))
        self.ph.save_hooks(hook_store)
        self.shorten_pending_hook_triggers(now_dt, pending_ids)
        first = self.due_and_render(now_dt + timedelta(minutes=self.absence_minutes + 1), preferred_types=("emotional_followup", "health_followup"))
        mid = self.snapshot(now_dt + timedelta(minutes=self.absence_minutes + 1))
        second = self.due_and_render(now_dt + timedelta(minutes=self.absence_minutes + 2), preferred_types=("emotional_followup", "health_followup"))
        after = self.snapshot(now_dt + timedelta(minutes=self.absence_minutes + 2))
        suppressed_traces = self.trace_delta(mid, after, source_stage="due", followup_decision="suppressed")
        result = self.build_case_result(case_id, "duplicate follow-up suppression", "我現在有點悶，但先陪我放著就好。", {
            "expected_followup_behavior": "second due pass should be suppressed by cooldown",
        }, before, after)
        result["actual_followup_dispatch"] = {"first": first, "second": second}
        result["timing"] = {
            "ingest/start": now_dt.isoformat(timespec="seconds"),
            "finish": (now_dt + timedelta(minutes=self.absence_minutes + 2)).isoformat(timespec="seconds"),
            "elapsed": 0.0,
        }
        is_pass = bool(first.get("hook")) and second.get("hook") is None and any(
            trace.get("trigger_reason") == "dispatch-cooldown" for trace in suppressed_traces
        )
        result["pass/fail"] = "pass" if is_pass else "fail"
        result["failure_reason"] = "" if is_pass else "duplicate follow-up was not suppressed by cooldown"
        return result

    def run_user_reply_closes_active_hook_case(self) -> dict:
        self.reset_case()
        case_id = "user_reply_closes_active_hook"
        now_dt = datetime(2026, 3, 19, 3, 20, tzinfo=TEST_TZ)
        before = self.snapshot(now_dt)
        self.run_user_turn(case_id, "closure-session", "這份資料妳之後先幫我查一下，整理重點再回來接我。", now_dt, 1)
        hook_store = self.ph.load_hooks()
        pending_ids = [hook.get("id") for hook in hook_store.get("hooks", []) if hook.get("status") == "pending"]
        self.shorten_pending_hook_triggers(now_dt, pending_ids)
        followup = self.due_and_render(now_dt + timedelta(minutes=self.absence_minutes + 1), preferred_types=("progress_followup",))
        reply_turn = self.run_user_turn(case_id, "closure-session", "我回來了，剛剛那件先接下去吧。", now_dt + timedelta(minutes=self.absence_minutes + 2), 2)
        after = self.snapshot(now_dt + timedelta(minutes=self.absence_minutes + 2))
        closed_hooks = [hook for hook in after["hook_store"].get("hooks", []) if hook.get("status") == "done" and hook.get("closure_reason") == "user_reply_matched_active_hook"]
        closure_traces = self.trace_delta(before, after, source_stage="closure", followup_decision="closed")
        result = self.build_case_result(case_id, "user reply closes active hook", "我回來了，剛剛那件先接下去吧。", {
            "expected_followup_behavior": "reply to dispatched hook should close the active hook",
        }, before, after)
        result["simulated_user_reply"] = "我回來了，剛剛那件先接下去吧。"
        result["actual_followup_dispatch"] = followup
        result["actual_followup_reply_continuity"] = reply_turn["decision_result"].get("hook_closure_result")
        result["timing"] = reply_turn["timing"]
        result["this_reply_used_hook"] = bool(followup.get("hook"))
        is_pass = bool(followup.get("hook")) and bool(closed_hooks) and any(
            trace.get("closure_reason") == "user_reply_matched_active_hook" for trace in closure_traces
        )
        result["pass/fail"] = "pass" if is_pass else "fail"
        result["failure_reason"] = "" if is_pass else "user reply did not close the active hook"
        return result

    def run_unrelated_event_preserves_chain_case(self) -> dict:
        self.reset_case()
        case_id = "unrelated_event_preserves_chain"
        now_dt = datetime(2026, 3, 19, 3, 35, tzinfo=TEST_TZ)
        before = self.snapshot(now_dt)
        self.run_user_turn(case_id, "same-session", "這份資料妳之後先幫我查一下，整理重點再回來接我。", now_dt, 1)
        second_turn = self.run_user_turn(case_id, "same-session", "我剛剛去陽台抽煙，邊走邊跟妳聊，回房間時跌倒，腳有點受傷了。", now_dt + timedelta(minutes=2), 2)
        after = self.snapshot(now_dt + timedelta(minutes=2))
        incident_summaries = [item.get("normalized_seed_summary", "") for item in after["incident_store"].get("incidents", [])]
        event_chain_ids = {
            item.get("event_chain_id")
            for item in after["staging_store"].get("records", []) + after["incident_store"].get("incidents", [])
            if item.get("event_chain_id")
        }
        result = self.build_case_result(case_id, "unrelated event preserves chain", "我剛剛去陽台抽煙，邊走邊跟妳聊，回房間時跌倒，腳有點受傷了。", {
            "expected_followup_behavior": "new unrelated event should not overwrite the existing tracked chain",
        }, before, after)
        result["timing"] = second_turn["timing"]
        result["actual_followup_dispatch"] = {"incident_summaries": incident_summaries}
        is_pass = len(event_chain_ids) >= 2 and any("整理重點" in summary for summary in incident_summaries) and any("跌倒" in summary or "腳有點受傷" in summary for summary in incident_summaries)
        result["pass/fail"] = "pass" if is_pass else "fail"
        result["failure_reason"] = "" if is_pass else "unrelated event overwrote the active tracked chain"
        return result

    def run_new_session_top_priority_carryover_case(self) -> dict:
        self.reset_case()
        case_id = "new_session_top_priority_carryover"
        t0 = datetime(2026, 3, 19, 3, 50, tzinfo=TEST_TZ)
        before = self.snapshot(t0)
        self.run_user_turn(case_id, "carryover-priority", "我先把技能倉庫那件事放妳這裡，晚點再接。", t0, 1)
        self.run_user_turn(case_id, "carryover-priority", "不要簡體，顏文字要自然。", t0 + timedelta(minutes=1), 2)
        new_ctx = self.ph.build_runtime_context(
            session_id="carryover-priority-new",
            session_key=DIRECT_SESSION_KEY,
            user_text="我回來了。",
            now_dt=t0 + timedelta(minutes=5),
            is_new_session=True,
            user_turn_index=1,
        )
        after = self.snapshot(t0 + timedelta(minutes=5))
        bullet_lines = [line for line in str(new_ctx.get("pending_topics_prompt") or "").splitlines() if line.startswith("- ")]
        primary = new_ctx.get("primary_pending_topic") or {}
        result = self.build_case_result(case_id, "/new carryover top priority", "我回來了。", {
            "expected_followup_behavior": "new session should inject only the top priority summary into the first reply context",
        }, before, after)
        result["actual_carryover_flags"] = {
            "new_session_carryover_applied": new_ctx.get("new_session_carryover_applied"),
            "carryover_source": new_ctx.get("carryover_source"),
            "carryover_summary_present": new_ctx.get("carryover_summary_present"),
            "continuity_source": new_ctx.get("continuity_source"),
        }
        result["actual_followup_dispatch"] = {
            "primary_pending_topic": primary,
            "bullet_lines": bullet_lines,
        }
        result["this_reply_used_carryover"] = bool(new_ctx.get("new_session_carryover_applied"))
        result["this_reply_used_pending_topic"] = bool(new_ctx.get("pending_topics"))
        result["timing"] = {
            "ingest/start": (t0 + timedelta(minutes=5)).isoformat(timespec="seconds"),
            "finish": (t0 + timedelta(minutes=5)).isoformat(timespec="seconds"),
            "elapsed": 0.0,
        }
        is_pass = bool(primary) and primary.get("pending_bucket") == "followup_topic_bucket" and len(bullet_lines) == 1
        result["pass/fail"] = "pass" if is_pass else "fail"
        result["failure_reason"] = "" if is_pass else "new-session pending prompt injected more than one summary or picked the wrong primary bucket"
        return result

    def run_absence_dispatch_cap_case(self) -> dict:
        self.reset_case()
        case_id = "absence_dispatch_cap"
        now_dt = datetime(2026, 3, 19, 4, 5, tzinfo=TEST_TZ)
        before = self.snapshot(now_dt)
        hook_store = self.ph.load_hooks()
        hook = self.ph.build_hook(
            "progress_followup",
            now_dt - timedelta(minutes=1),
            "[harness] dispatch-cap hook",
            payload={"test_dispatch_cap": 2, "test_dispatch_cooldown_minutes": 0, "event_kind": "delegated_task"},
            note="harness dispatch cap",
        )
        hook_store["hooks"].append(hook)
        self.ph.save_hooks(hook_store)
        first = self.due_and_render(now_dt, preferred_types=("progress_followup",))
        second = self.due_and_render(now_dt + timedelta(minutes=1), preferred_types=("progress_followup",))
        mid = self.snapshot(now_dt + timedelta(minutes=1))
        third = self.due_and_render(now_dt + timedelta(minutes=2), preferred_types=("progress_followup",))
        after = self.snapshot(now_dt + timedelta(minutes=2))
        suppressed = self.trace_delta(mid, after, source_stage="due", followup_decision="suppressed")
        final_hook = self.ph.find_hook(after["hook_store"], hook.get("id"))
        result = self.build_case_result(case_id, "absence follow-up dispatch cap", "[harness] dispatch-cap hook", {
            "expected_followup_behavior": "hook should stop dispatching after cap is reached",
        }, before, after)
        result["actual_followup_dispatch"] = {"first": first, "second": second, "third": third}
        result["timing"] = {
            "ingest/start": now_dt.isoformat(timespec="seconds"),
            "finish": (now_dt + timedelta(minutes=2)).isoformat(timespec="seconds"),
            "elapsed": 0.0,
        }
        is_pass = bool(first.get("hook")) and bool(second.get("hook")) and third.get("hook") is None and int((final_hook or {}).get("dispatch_count", 0) or 0) == 2 and any(
            trace.get("trigger_reason") == "dispatch-cap-reached" for trace in suppressed
        )
        result["pass/fail"] = "pass" if is_pass else "fail"
        result["failure_reason"] = "" if is_pass else "dispatch cap did not suppress the third follow-up"
        return result

    def run_all(self) -> dict:
        cases = [
            self.run_casual_chat_case,
            self.run_parked_topic_case,
            self.run_watchful_state_case,
            self.run_delegated_task_case,
            self.run_sensitive_event_case,
            self.run_incremental_update_case,
            self.run_new_session_carryover_case,
            self.run_active_hook_closure_case,
            self.run_time_sense_case,
            self.run_duplicate_followup_suppression_case,
            self.run_user_reply_closes_active_hook_case,
            self.run_unrelated_event_preserves_chain_case,
            self.run_new_session_top_priority_carryover_case,
            self.run_absence_dispatch_cap_case,
        ]
        self.report["cases"] = [case() for case in cases]
        self.report["summary"] = {
            "pass_count": sum(1 for item in self.report["cases"] if item.get("pass/fail") == "pass"),
            "fail_count": sum(1 for item in self.report["cases"] if item.get("pass/fail") != "pass"),
        }
        return self.report


def main() -> int:
    parser = argparse.ArgumentParser()
    parser.add_argument("--absence-minutes", type=int, default=3)
    parser.add_argument("--keep-sandbox", action="store_true")
    parser.add_argument("--output", type=Path)
    args = parser.parse_args()

    REPORT_DIR.mkdir(parents=True, exist_ok=True)
    output_path = args.output or REPORT_DIR / f"followup_harness_{datetime.now(TEST_TZ).strftime('%Y%m%d-%H%M%S')}.json"
    harness = Harness(absence_minutes=args.absence_minutes, keep_sandbox=args.keep_sandbox)
    try:
        report = harness.run_all()
        output_path.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
        print(json.dumps({"report_path": str(output_path), "summary": report["summary"]}, ensure_ascii=False, indent=2))
        return 0
    finally:
        harness.cleanup()


if __name__ == "__main__":
    raise SystemExit(main())


path: scripts/event_store.py
content: #!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Event Store — V2 event tracking layer.

Events represent "what happened" with causal context and lifecycle.
Hooks represent "what to do next" with scheduling and dispatch.

Event → Hook is one-way: events may spawn hooks, hooks reference events.

Usage (CLI):
  python3 event_store.py create --type defer --title "先放著的話題" --cause "使用者說晚點再聊"
  python3 event_store.py list [--status active|parked|due]
  python3 event_store.py get --id EVT-xxx
  python3 event_store.py update --id EVT-xxx --status parked
  python3 event_store.py due [--limit 3]
  python3 event_store.py close --id EVT-xxx --reason completed
  python3 event_store.py context [--limit 5]    # For runtime-context injection
  python3 event_store.py gc [--days 30]          # Garbage-collect old closed events

Usage (Python import):
  from event_store import EventStore
  store = EventStore("/path/to/events.json")
  evt = store.create(event_type="defer", title="...", cause_summary="...")
"""

import json
import os
import sys
import uuid
from datetime import datetime, timedelta, timezone
from typing import Optional, List, Dict, Any

__version__ = "1.1.0"

DEFAULT_OPENCLAW_STATE_DIR = os.path.expanduser("~/.openclaw")

# ── Host Semantic Config (lazy-loaded) ──

_semantic_categories = None   # List[SemanticCategory] | None
_policy_overrides = None      # Dict[str, str] | None


def _load_host_semantics():
    """Lazy-load host semantic config. Safe to call multiple times."""
    global _semantic_categories, _policy_overrides
    if _semantic_categories is not None:
        return
    try:
        from semantic_config import load_semantic_config, parse_categories, get_policy_overrides
        cfg = load_semantic_config()
        _semantic_categories = parse_categories(cfg)
        _policy_overrides = get_policy_overrides(cfg)
    except Exception:
        _semantic_categories = []
        _policy_overrides = {}


def get_host_categories():
    """Return parsed host semantic categories (lazy-loaded)."""
    _load_host_semantics()
    return _semantic_categories or []


def get_policy_override(event_type: str) -> Optional[str]:
    """Return host policy override for an event type, or None."""
    _load_host_semantics()
    if _policy_overrides:
        return _policy_overrides.get(event_type)
    return None


def _is_host_event_type(event_type: str) -> bool:
    """Check if event_type is defined by a host semantic category."""
    for cat in get_host_categories():
        if cat.event_type == event_type:
            return True
    return False


# ── Schema ──

EVENT_TYPES = [
    "defer",               # 「先放著 / 晚點再說」
    "wake_followup",       # 「我醒來再聊 / 起床後提醒我」
    "checkin",             # 「之後問我 / 過一陣子確認」
    "reminder_intent",     # 「提醒我做X」
    "unresolved_topic",    # 對話中未結束的議題
    "health_event",        # 健康事件（跌倒、住院、生病）
    "emotional_event",     # 情緒事件（壓力、難過、焦慮）
    "work_event",          # 工作事件（deadline、專案、壓力）
    "relationship_event",  # 關係事件（吵架、想念、紀念日）
    "task_progress",       # 進行中的任務/項目
    "incident",            # 突發事件
    "custom",              # 自定義
]

EVENT_STATUSES = [
    "active",       # 正在追蹤
    "parked",       # 暫停追蹤（使用者說先放著）
    "due",          # 該追問了（next_check_at 到期）
    "completed",    # 已結束
    "cancelled",    # 取消
    "superseded",   # 被新事件取代
    "suggested",    # AI 建議建立，等使用者確認
]

EVENT_PRIORITIES = ["low", "medium", "high", "critical"]
IMMEDIATE_HOOK_EVENT_TYPES = {"health_event", "emotional_event", "wake_followup"}
SCHEDULED_EVENT_DEFAULT_MINUTES = {
    "defer": 120,
    "checkin": 180,
    "reminder_intent": 60,
    "task_progress": 180,
    "unresolved_topic": 180,
}

# ── Helpers ──

def _now_iso() -> str:
    return datetime.now().astimezone().isoformat(timespec="seconds")

def _make_event_id() -> str:
    ts = datetime.now().strftime("%Y%m%d-%H%M%S")
    short = uuid.uuid4().hex[:6]
    return f"EVT-{ts}-{short}"

def _parse_dt(s: Optional[str]) -> Optional[datetime]:
    if not s:
        return None
    try:
        return datetime.fromisoformat(s)
    except (ValueError, TypeError):
        return None


def _default_next_check_at(event_type: str, now_dt: Optional[datetime] = None, base_dt: Optional[datetime] = None) -> str:
    minutes = SCHEDULED_EVENT_DEFAULT_MINUTES.get(event_type)
    if not minutes:
        return ""
    anchor = base_dt or now_dt or datetime.now().astimezone()
    if anchor.tzinfo is None:
        anchor = anchor.replace(tzinfo=datetime.now().astimezone().tzinfo)
    return (anchor + timedelta(minutes=minutes)).isoformat(timespec="seconds")


# ── Event Store ──

class EventStore:
    """File-backed event store with JSON persistence."""

    def __init__(self, path: str):
        self.path = path
        self._data = self._load()

    def _load(self) -> dict:
        if os.path.exists(self.path):
            try:
                with open(self.path) as f:
                    return json.load(f)
            except (json.JSONDecodeError, IOError):
                pass
        return {"version": 1, "updated_at": _now_iso(), "events": []}

    def _save(self):
        self._data["updated_at"] = _now_iso()
        os.makedirs(os.path.dirname(self.path) or ".", exist_ok=True)
        with open(self.path, "w") as f:
            json.dump(self._data, f, indent=2, ensure_ascii=False)

    @property
    def events(self) -> List[dict]:
        return self._data.get("events", [])

    # ── CRUD ──

    def create(
        self,
        event_type: str,
        title: str,
        cause_summary: str = "",
        desired_followup: str = "",
        priority: str = "medium",
        owner: str = "system",
        source_session: str = "",
        source_channel: str = "",
        source_turn_range: str = "",
        next_check_at: Optional[str] = None,
        linked_hook_ids: Optional[List[str]] = None,
        linked_memory_refs: Optional[List[str]] = None,
        metadata: Optional[Dict[str, Any]] = None,
    ) -> dict:
        """Create a new event. Returns the created event dict."""
        now = _now_iso()
        evt = {
            "event_id": _make_event_id(),
            "event_type": event_type if (event_type in EVENT_TYPES or _is_host_event_type(event_type)) else "custom",
            "title": title,
            "status": "active",
            "priority": priority if priority in EVENT_PRIORITIES else "medium",
            "owner": owner,
            "cause_summary": cause_summary,
            "desired_followup": desired_followup,
            "source_session": source_session,
            "source_channel": source_channel,
            "source_turn_range": source_turn_range,
            "created_at": now,
            "last_update_at": now,
            "next_check_at": next_check_at or "",
            "linked_hook_ids": linked_hook_ids or [],
            "linked_memory_refs": linked_memory_refs or [],
            "closure_reason": "",
            "metadata": metadata or {},
        }
        self._data["events"].append(evt)
        self._save()
        return evt

    def get(self, event_id: str) -> Optional[dict]:
        for evt in self.events:
            if evt.get("event_id") == event_id:
                return evt
        return None

    def update(self, event_id: str, **fields) -> Optional[dict]:
        """Update specific fields of an event. Returns updated event or None."""
        for evt in self.events:
            if evt.get("event_id") == event_id:
                allowed = {
                    "status", "title", "priority", "cause_summary",
                    "desired_followup", "next_check_at", "owner",
                    "linked_hook_ids", "linked_memory_refs",
                    "closure_reason", "metadata",
                }
                for k, v in fields.items():
                    if k in allowed:
                        evt[k] = v
                evt["last_update_at"] = _now_iso()
                self._save()
                return evt
        return None

    def close(self, event_id: str, reason: str = "completed", status: str = "completed") -> Optional[dict]:
        """Close an event with a reason."""
        if status not in ("completed", "cancelled", "superseded"):
            status = "completed"
        return self.update(event_id, status=status, closure_reason=reason)

    def link_hook(self, event_id: str, hook_id: str) -> Optional[dict]:
        """Link a hook to an event."""
        evt = self.get(event_id)
        if evt:
            ids = evt.get("linked_hook_ids", [])
            if hook_id not in ids:
                ids.append(hook_id)
            return self.update(event_id, linked_hook_ids=ids)
        return None

    def link_memory(self, event_id: str, memory_ref: str) -> Optional[dict]:
        """Link a memory reference to an event."""
        evt = self.get(event_id)
        if evt:
            refs = evt.get("linked_memory_refs", [])
            if memory_ref not in refs:
                refs.append(memory_ref)
            return self.update(event_id, linked_memory_refs=refs)
        return None

    # ── Queries ──

    def list_by_status(self, statuses: Optional[List[str]] = None) -> List[dict]:
        """List events by status. None = all non-closed."""
        if statuses is None:
            statuses = ["active", "parked", "due"]
        return [e for e in self.events if e.get("status") in statuses]

    def list_due(self, now_dt: Optional[datetime] = None, limit: int = 5) -> List[dict]:
        """List events whose next_check_at has arrived, or status is 'due'."""
        if now_dt is None:
            now_dt = datetime.now().astimezone()
        result = []
        for evt in self.events:
            if evt.get("status") in ("completed", "cancelled", "superseded"):
                continue
            # Explicit due status
            if evt.get("status") == "due":
                result.append(evt)
                continue
            if evt.get("status") in ("active", "parked") and not evt.get("next_check_at"):
                derived = _default_next_check_at(
                    evt.get("event_type", ""),
                    now_dt=now_dt,
                    base_dt=_parse_dt(evt.get("last_update_at")) or _parse_dt(evt.get("created_at")),
                )
                if derived:
                    evt["next_check_at"] = derived
                    evt["last_update_at"] = _now_iso()
            # next_check_at has passed
            nca = _parse_dt(evt.get("next_check_at"))
            if nca and nca <= now_dt and evt.get("status") in ("active", "parked"):
                evt["status"] = "due"
                evt["last_update_at"] = _now_iso()
                result.append(evt)
        if result:
            self._save()  # persist status changes
        # Sort by priority (critical first) then created_at
        priority_order = {"critical": 0, "high": 1, "medium": 2, "low": 3}
        result.sort(key=lambda e: (priority_order.get(e.get("priority", "medium"), 2), e.get("created_at", "")))
        return result[:limit]

    def list_active_summary(self, limit: int = 5, staleness_hours: int = 72) -> List[dict]:
        """Return active/parked/due events for runtime-context injection.

        Applies four filters:
        1. Staleness: transient event types have type-specific max age.
        2. Universal max age: all events capped at 168h (7 days).
        3. Recursive: events whose cause_summary is structured event display are excluded.
        4. Priority sort (recency-weighted within same priority) + limit.
        """
        now_dt = datetime.now().astimezone()
        # Type-specific staleness: shorter windows for transient event types
        _type_staleness_hours = {
            "emotional_event": staleness_hours,       # 72h
            "wake_followup": 24,                      # wake events are transient
            "health_followup": staleness_hours,       # 72h
        }
        universal_max_hours = 168  # 7 days absolute cap for any event
        universal_cutoff = now_dt - timedelta(hours=universal_max_hours)
        live = []
        for e in self.events:
            if e.get("status") not in ("active", "parked", "due"):
                continue
            # Filter recursive events (cause_summary is structured display format)
            cause = str(e.get("cause_summary") or "")
            if cause.startswith("- 🔵") or cause.startswith("- ❓") or "[emotional_event/" in cause or "[health_event/" in cause:
                continue
            created = _parse_dt(e.get("created_at"))
            # Universal max age
            if created and created < universal_cutoff:
                continue
            # Type-specific staleness filter
            etype = e.get("event_type", "")
            if etype in _type_staleness_hours:
                type_cutoff = now_dt - timedelta(hours=_type_staleness_hours[etype])
                if created and created < type_cutoff:
                    continue
            live.append(e)
        priority_order = {"critical": 0, "high": 1, "medium": 2, "low": 3}
        # Sort by priority first, then by recency (newest first within same priority)
        live.sort(key=lambda e: (priority_order.get(e.get("priority", "medium"), 2), -((_parse_dt(e.get("created_at")) or now_dt).timestamp())))
        return live[:limit]

    def gc(self, days: int = 30) -> int:
        """Remove closed events older than N days. Returns count removed."""
        cutoff = datetime.now().astimezone() - timedelta(days=days)
        before = len(self.events)
        self._data["events"] = [
            e for e in self.events
            if e.get("status") not in ("completed", "cancelled", "superseded")
            or (_parse_dt(e.get("last_update_at")) or datetime.now().astimezone()) > cutoff
        ]
        after = len(self.events)
        if before != after:
            self._save()
        return before - after

    # ── Context Building ──

    def list_suggested(self, limit: int = 3) -> List[dict]:
        """Return events with status=suggested, most recent first."""
        suggested = [e for e in self.events if e.get("status") == "suggested"]
        suggested.sort(key=lambda e: e.get("created_at", ""), reverse=True)
        return suggested[:limit]

    def build_event_context_prompt(self, now_dt: Optional[datetime] = None, limit: int = 5) -> str:
        """Build a prompt section for active events, for runtime-context injection."""
        if now_dt is None:
            now_dt = datetime.now().astimezone()

        live = self.list_active_summary(limit)
        suggested = self.list_suggested(1)
        if not live and not suggested:
            return ""

        lines = [
            "## Active tracked events (trusted structured state)",
            "These are events you are currently tracking. Use them to maintain continuity.",
            "When relevant, reference the event and its cause — do not invent new context.",
            "",
        ]

        for evt in live:
            status_icon = {"active": "🔵", "parked": "⏸️", "due": "🔴"}.get(evt.get("status", ""), "❓")
            title = evt.get("title", "untitled")
            etype = evt.get("event_type", "unknown")
            priority = evt.get("priority", "medium")
            cause = evt.get("cause_summary", "")
            followup = evt.get("desired_followup", "")
            created = evt.get("created_at", "")[:16]
            nca = evt.get("next_check_at", "")
            status = evt.get("status", "?")

            line = f"- {status_icon} **{title}** [{etype}/{priority}] (status: {status})"
            if cause:
                line += f"\n  cause: {cause[:120]}"
            if followup:
                line += f"\n  followup: {followup[:120]}"
            if nca:
                line += f"\n  next_check: {nca[:16]}"
            line += f"\n  created: {created}"

            lines.append(line)

        # Pending suggestions — model should ask user to confirm
        if suggested:
            lines.append("")
            lines.append("### Pending event suggestions")
            lines.append("These events were detected but need user confirmation. Ask briefly: 「這件事要我幫你記著追嗎？」")
            lines.append("If user says yes/好/對, confirm it. If no/不用/算了, drop it. Do NOT auto-confirm.")
            for evt in suggested:
                title = evt.get("title", "untitled")
                cause = evt.get("cause_summary", "")
                lines.append(f"- ❓ **{title}** — {cause[:100]}")

        return "\n".join(lines)

    def build_new_session_carryover(self, limit: int = 3) -> str:
        """Build a carryover section for new sessions — top unresolved events."""
        live = self.list_active_summary(limit)
        if not live:
            return ""

        lines = [
            "## Unresolved events from previous sessions (trusted structured state)",
            "These events are still being tracked. Continue from where you left off.",
            "",
        ]

        for evt in live:
            title = evt.get("title", "untitled")
            cause = evt.get("cause_summary", "")
            followup = evt.get("desired_followup", "")
            status = evt.get("status", "?")

            line = f"- **{title}** (status: {status})"
            if cause:
                line += f" — {cause[:100]}"
            if followup:
                line += f" → {followup[:100]}"

            lines.append(line)

        return "\n".join(lines)


# ── Event → Hook Helpers ──

def should_auto_generate_hook(event: dict) -> Optional[dict]:
    """Decide if an event should auto-generate a hook.
    Returns hook params dict if yes, None if no.
    """
    etype = event.get("event_type", "")
    status = event.get("status", "")
    nca = event.get("next_check_at", "")

    # Only active/parked events with next_check_at should generate hooks
    if status not in ("active", "parked", "due"):
        return None

    # Types that auto-generate hooks
    auto_types = {
        "defer": "progress_followup",
        "wake_followup": "care_message",
        "checkin": "progress_followup",
        "reminder_intent": "progress_followup",
        "health_event": "health_followup",
        "emotional_event": "emotional_followup",
        "task_progress": "progress_followup",
    }

    hook_type = auto_types.get(etype)

    # Host-defined event types: map to progress_followup by default
    if not hook_type and _is_host_event_type(etype):
        hook_type = "progress_followup"

    if not hook_type:
        return None

    # health/emotional/wake generate hooks immediately (no next_check_at needed)
    if etype not in IMMEDIATE_HOOK_EVENT_TYPES and not nca:
        return None

    return {
        "hook_type": hook_type,
        "trigger_at": nca or "",
        "source_summary": event.get("cause_summary", event.get("title", "")),
        "normalized_seed_summary": event.get("title", ""),
        "event_id": event.get("event_id", ""),
        "event_type": etype,
        "priority": event.get("priority", "medium"),
    }


# ── Natural Language → Event Detection ──

DEFER_SIGNALS = [
    "先放著", "晚點再說", "等一下再", "之後再", "先不說", "先跳過",
    "先這樣", "明天再", "回來再", "醒來再", "下次再", "改天",
    "先忙", "等等再", "待會再", "過一陣子",
]

WAKE_SIGNALS = [
    "醒來", "起床", "起來再", "睡醒",
]

CHECKIN_SIGNALS = [
    "提醒我", "之後問我", "記得問", "幫我記", "別忘了問",
    "過幾天問", "追蹤", "follow up", "跟進",
]

HEALTH_SIGNALS = [
    "跌倒", "受傷", "住院", "生病", "發燒", "頭痛", "不舒服",
    "看醫生", "急診", "開刀", "手術",
]

EMOTIONAL_SIGNALS = [
    "壓力", "焦慮", "難過", "沮喪", "心情不好", "煩", "累",
    "想哭", "撐不住", "崩潰",
]

NEGATION_PREFIXES = ["沒", "不", "沒有", "不是", "不會", "沒在", "不再", "無"]

def _is_negated(text: str, signal: str) -> bool:
    """Check if a signal match is negated (e.g., '沒頭痛' should not trigger health_event)."""
    idx = text.find(signal)
    if idx <= 0:
        return False
    # Check 1-3 chars before the signal for negation
    before = text[max(0, idx - 3):idx]
    return any(before.endswith(neg) for neg in NEGATION_PREFIXES)


def detect_event_from_text(text: str, context: str = "") -> Optional[dict]:
    """Detect if user text implies an event that should be tracked.
    Returns a partial event dict or None.

    Detection order: high-priority signals first (health > emotional > checkin > wake > defer).
    """
    results = []

    # Health (high priority)
    for sig in HEALTH_SIGNALS:
        if sig in text and not _is_negated(text, sig):
            results.append({
                "event_type": "health_event",
                "title": f"健康事件：{sig}",
                "cause_summary": text[:200],
                "desired_followup": "主動關心身體狀況",
                "priority": "high",
                "_rank": 0,
            })
            break

    # Emotional (high priority)
    for sig in EMOTIONAL_SIGNALS:
        if sig in text and not _is_negated(text, sig):
            results.append({
                "event_type": "emotional_event",
                "title": f"情緒狀態：{sig}",
                "cause_summary": text[:200],
                "desired_followup": "適時關心情緒",
                "priority": "high",
                "_rank": 1,
            })
            break

    # Checkin
    for sig in CHECKIN_SIGNALS:
        if sig in text:
            results.append({
                "event_type": "checkin",
                "title": _extract_topic(text, sig),
                "cause_summary": text[:200],
                "desired_followup": "按使用者要求追問",
                "priority": "medium",
                "_rank": 2,
            })
            break

    # Wake followup
    for sig in WAKE_SIGNALS:
        if sig in text:
            results.append({
                "event_type": "wake_followup",
                "title": _extract_topic(text, sig),
                "cause_summary": text[:200],
                "desired_followup": "使用者醒來後主動接回這個話題",
                "priority": "medium",
                "_rank": 3,
            })
            break

    # Defer (lowest priority signal)
    for sig in DEFER_SIGNALS:
        if sig in text:
            results.append({
                "event_type": "defer",
                "title": _extract_topic(text, sig),
                "cause_summary": text[:200],
                "desired_followup": "使用者說先放著，適時主動接回",
                "priority": "medium",
                "_rank": 4,
            })
            break

    # ── Host semantic categories (additive) ──
    for cat in get_host_categories():
        for sig in cat.signals:
            if sig in text:
                if cat.negation_aware and _is_negated(text, sig):
                    continue
                results.append({
                    "event_type": cat.event_type,
                    "title": cat.format_title(sig),
                    "cause_summary": text[:200],
                    "desired_followup": cat.desired_followup,
                    "priority": cat.priority,
                    "_rank": cat.rank,
                    "_host_policy": cat.policy,  # carried through for process_event_entry
                })
                break  # one match per category

    if not results:
        return None

    # Return highest priority (lowest rank)
    winner = min(results, key=lambda r: r["_rank"])
    winner.pop("_rank", None)
    return winner


def _extract_topic(text: str, signal: str) -> str:
    """Try to extract a meaningful topic from text around the signal."""
    # Simple: take up to 60 chars, trimming the signal keyword
    idx = text.find(signal)
    if idx > 0:
        before = text[max(0, idx - 40):idx].strip()
        if before:
            return before[:60]
    after = text[idx + len(signal):].strip()
    if after:
        return after[:60]
    return text[:60]


# ── Event Entry Layer ──
# Three entry modes: manual (user explicitly asks), suggest (AI asks first), auto (high confidence)

# Manual create — user explicitly asks to track something
MANUAL_CREATE_SIGNALS = [
    "幫我記住", "幫我記", "記住這", "追蹤這", "追這件事",
    "提醒我", "之後提醒", "晚點提醒", "記得提醒",
    "之後問我", "晚點問我", "記得問我", "幫我追",
    "keep track", "remind me", "follow up on",
]

# CRUD — view / complete / cancel / park
VIEW_SIGNALS = [
    "現在追蹤什麼", "在追什麼", "有什麼事在追", "追蹤中",
    "我的事件", "事件列表", "有什麼待追", "追蹤清單",
    "what are you tracking", "my events",
]
COMPLETE_SIGNALS = [
    "完成了", "搞定了", "解決了", "做完了", "處理好了",
    "結束了", "已經好了", "OK了", "done",
]
CANCEL_SIGNALS = [
    "取消追蹤", "不追了", "不用追了", "取消那個", "刪掉那個事件",
    "stop tracking", "cancel that",
]
PARK_SIGNALS = [
    "暫停追蹤", "先擱著", "先暫停那個",
    "pause tracking",
]

# Confirmation / denial for suggest mode
CONFIRM_SIGNALS = [
    "好", "對", "幫我記", "記吧", "追吧", "要", "嗯",
    "ok", "OK", "是", "幫我追", "記一下", "好啊", "可以",
]
DENY_SIGNALS = [
    "不用", "不要", "算了", "沒關係", "不必", "跳過", "pass",
]

# Auto create types — high confidence, no need to ask
AUTO_CREATE_TYPES = {
    "defer",            # 使用者主動暫緩 = 明確意圖
    "wake_followup",    # 使用者說醒來再聊 = 明確意圖
    "reminder_intent",  # 使用者說提醒我 = 明確意圖
    "health_event",     # 關鍵字精準（頭痛/住院/跌倒） = 高信心
    "emotional_event",  # 關鍵字精準（壓力/難過/崩潰） = 高信心
}

# Suggest create types — ask user first
SUGGEST_CREATE_TYPES = {
    "checkin",              # 可能是隨口一提
    "unresolved_topic",     # 模糊
    "work_event",           # 不確定是否要追
    "relationship_event",   # 私密，應先問
    "task_progress",        # 可能只是聊天
    "incident",             # 視情況
    "custom",               # 不確定
}


def _matches_any(text: str, signals: List[str]) -> bool:
    """Check if text contains any of the signal phrases."""
    t = text.strip().lower() if text else ""
    for sig in signals:
        if sig.lower() in t:
            return True
    return False


def _find_best_matching_event(events: List[dict], text: str) -> Optional[dict]:
    """Find the event most likely referenced by user text.
    Strategy: keyword overlap between text and event title/cause.
    Fallback: most recent active event.
    """
    active = [e for e in events if e.get("status") in ("active", "parked", "due")]
    if not active:
        return None
    if len(active) == 1:
        return active[0]
    # Score by keyword overlap
    text_chars = set(text)
    best, best_score = None, -1
    for evt in active:
        title = evt.get("title", "")
        cause = evt.get("cause_summary", "")
        combined = title + cause
        score = sum(1 for c in combined if c in text_chars and c.strip())
        if score > best_score:
            best, best_score = evt, score
    return best


def process_event_entry(
    event_store: "EventStore",
    text: str,
    session_key: str = "",
    channel: str = "direct",
    now_dt: Optional[datetime] = None,
) -> dict:
    """Process user text through the event entry layer.

    Returns dict with:
      action: 'manual_create' | 'auto_create' | 'suggest_create' |
              'confirm_suggest' | 'deny_suggest' |
              'view' | 'complete' | 'cancel' | 'park' | 'none'
      event: the event dict (if created/modified) or None
      entry_mode: 'manual' | 'auto' | 'suggest' | 'crud' | 'none'
      frontend_prompt: short prompt for the model to show user ('' if silent)
    """
    if not text or not text.strip():
        return {"action": "none", "event": None, "entry_mode": "none", "frontend_prompt": ""}

    text = text.strip()
    residue_markers = (
        "## Active tracked events",
        "## Pending follow-up topics",
        "## New-session carryover",
        "## Night-owl schedule context",
        "## Household schedule awareness",
        "## Recent dispatch awareness",
        "trusted structured state",
    )
    if any(marker in text for marker in residue_markers):
        return {"action": "none", "event": None, "entry_mode": "none", "frontend_prompt": ""}

    # ── 0. Check pending suggestions first ──
    suggested = event_store.list_suggested(1)
    if suggested:
        if _matches_any(text, CONFIRM_SIGNALS) and len(text) < 20:
            evt = suggested[0]
            event_store.update(evt["event_id"], status="active", owner="user")
            return {
                "action": "confirm_suggest",
                "event": evt,
                "entry_mode": "suggest",
                "frontend_prompt": f"✅ 已開始追蹤：{evt.get('title', '')}",
            }
        if _matches_any(text, DENY_SIGNALS) and len(text) < 20:
            evt = suggested[0]
            event_store.update(evt["event_id"], status="cancelled", closure_reason="使用者拒絕建議")
            return {
                "action": "deny_suggest",
                "event": evt,
                "entry_mode": "suggest",
                "frontend_prompt": "",
            }

    # ── 1. CRUD: view ──
    if _matches_any(text, VIEW_SIGNALS):
        active = [e for e in event_store.events if e.get("status") in ("active", "parked", "due")]
        if not active:
            return {
                "action": "view",
                "event": None,
                "entry_mode": "crud",
                "frontend_prompt": "目前沒有追蹤中的事件。",
            }
        items = []
        for e in active[:8]:
            icon = {"active": "🔵", "parked": "⏸️", "due": "🔴"}.get(e.get("status"), "")
            items.append(f"{icon} {e.get('title', '?')}（{e.get('event_type', '?')}）")
        prompt = "追蹤中：\n" + "\n".join(f"- {it}" for it in items)
        return {
            "action": "view",
            "event": None,
            "entry_mode": "crud",
            "frontend_prompt": prompt,
        }

    # ── 2. CRUD: complete ──
    if _matches_any(text, COMPLETE_SIGNALS):
        target = _find_best_matching_event(event_store.events, text)
        if target:
            event_store.close(target["event_id"], reason="使用者確認完成", status="completed")
            return {
                "action": "complete",
                "event": target,
                "entry_mode": "crud",
                "frontend_prompt": f"✅ 已完成：{target.get('title', '')}",
            }

    # ── 3. CRUD: cancel ──
    if _matches_any(text, CANCEL_SIGNALS):
        target = _find_best_matching_event(event_store.events, text)
        if target:
            event_store.close(target["event_id"], reason="使用者取消", status="cancelled")
            return {
                "action": "cancel",
                "event": target,
                "entry_mode": "crud",
                "frontend_prompt": f"已取消追蹤：{target.get('title', '')}",
            }

    # ── 4. CRUD: park ──
    if _matches_any(text, PARK_SIGNALS):
        target = _find_best_matching_event(event_store.events, text)
        if target:
            event_store.update(target["event_id"], status="parked")
            return {
                "action": "park",
                "event": target,
                "entry_mode": "crud",
                "frontend_prompt": f"⏸️ 已暫停：{target.get('title', '')}",
            }

    # ── 5. Manual create — user explicitly asks ──
    if _matches_any(text, MANUAL_CREATE_SIGNALS):
        # Extract topic from text
        topic = text
        for sig in MANUAL_CREATE_SIGNALS:
            if sig.lower() in text.lower():
                idx = text.lower().find(sig.lower())
                after = text[idx + len(sig):].strip()
                if after:
                    topic = after[:100]
                break
        evt = event_store.create(
            event_type="checkin",
            title=topic[:60],
            cause_summary=text[:200],
            desired_followup="使用者要求追蹤",
            priority="medium",
            owner="user",
            source_session=session_key,
            source_channel=channel,
            next_check_at=_default_next_check_at("checkin", now_dt=now_dt),
        )
        return {
            "action": "manual_create",
            "event": evt,
            "entry_mode": "manual",
            "frontend_prompt": f"✅ 已記住：{topic[:60]}",
        }

    # ── 6. Auto-detect + classify entry mode ──
    detected = detect_event_from_text(text)
    if detected and detected.get("event_type"):
        etype = detected["event_type"]

        # Resolve effective policy: host override > host_policy from detection > built-in sets
        host_override = get_policy_override(etype)
        host_policy = detected.pop("_host_policy", None)
        if host_override:
            effective_policy = host_override
        elif host_policy:
            effective_policy = host_policy
        elif etype in AUTO_CREATE_TYPES:
            effective_policy = "auto"
        elif etype in SUGGEST_CREATE_TYPES:
            effective_policy = "suggest"
        else:
            effective_policy = "suggest"

        if effective_policy == "auto":
            auto_next_check = detected.get("next_check_at") or _default_next_check_at(etype, now_dt=now_dt)
            evt = event_store.create(
                event_type=etype,
                title=detected.get("title", ""),
                cause_summary=detected.get("cause_summary", ""),
                desired_followup=detected.get("desired_followup", ""),
                priority=detected.get("priority", "medium"),
                owner="system",
                source_session=session_key,
                source_channel=channel,
                next_check_at=auto_next_check,
            )
            return {
                "action": "auto_create",
                "event": evt,
                "entry_mode": "auto",
                "frontend_prompt": "",  # silent — auto events don't need frontend noise
            }

        if effective_policy == "suggest":
            suggest_next_check = detected.get("next_check_at") or _default_next_check_at(etype, now_dt=now_dt)
            evt = event_store.create(
                event_type=etype,
                title=detected.get("title", ""),
                cause_summary=detected.get("cause_summary", ""),
                desired_followup=detected.get("desired_followup", ""),
                priority=detected.get("priority", "medium"),
                owner="agent",
                source_session=session_key,
                source_channel=channel,
                next_check_at=suggest_next_check,
            )
            # Override status to suggested
            event_store.update(evt["event_id"], status="suggested")
            return {
                "action": "suggest_create",
                "event": evt,
                "entry_mode": "suggest",
                "frontend_prompt": "",  # model will ask via context prompt
            }

        # policy == "manual" → skip auto-detection, let user explicitly create
        # (falls through to "none")

    # ── 7. Nothing detected ──
    return {"action": "none", "event": None, "entry_mode": "none", "frontend_prompt": ""}


# ── Event ↔ Memory Linkage ──

_MEMORY_HEADING = "V2 event-store"

EVENT_KIND_LABELS = {
    "defer": "暫緩主題",
    "wake_followup": "起床接回",
    "checkin": "追蹤確認",
    "reminder_intent": "提醒事項",
    "unresolved_topic": "未結議題",
    "health_event": "健康事件",
    "emotional_event": "情緒事件",
    "work_event": "工作事件",
    "relationship_event": "關係事件",
    "task_progress": "任務進度",
    "incident": "突發事件",
    "life_context_event": "生活情境",
    "identity_event": "身份資訊",
    "custom": "自定義事件",
}


def _resolve_memory_dir() -> str:
    state_dir = os.environ.get("OPENCLAW_STATE_DIR")
    if not state_dir:
        here = os.path.realpath(__file__)
        marker = os.sep + "workspace" + os.sep + "skills" + os.sep
        if marker in here:
            state_dir = here.split(marker, 1)[0]
        else:
            state_dir = DEFAULT_OPENCLAW_STATE_DIR
    return os.path.join(state_dir, "workspace/memory")


def _daily_memory_path(now_dt: Optional[datetime] = None) -> str:
    if now_dt is None:
        now_dt = datetime.now().astimezone()
    d = now_dt.date().isoformat()
    return os.path.join(_resolve_memory_dir(), f"{d}.md")


def _append_memory_note_once(path: str, heading: str, note: str) -> bool:
    """Append a note under heading in a daily memory markdown file. Returns True if new."""
    import re as _re
    os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
    existing = ""
    if os.path.isfile(path):
        with open(path, "r", encoding="utf-8") as f:
            existing = f.read()
    if note in existing:
        return False
    if heading not in existing:
        stem = os.path.splitext(os.path.basename(path))[0]
        prefix = existing.rstrip()
        body = f"{prefix}\n\n## {heading}\n- {note}\n" if prefix else f"# {stem}\n\n## {heading}\n- {note}\n"
    else:
        body = _re.sub(
            rf"(## {_re.escape(heading)}\n)",
            rf"\1- {note}\n",
            existing,
            count=1,
        )
    with open(path, "w", encoding="utf-8") as f:
        f.write(body)
    return True


def _make_memory_ref(event_id: str, action: str, path: str) -> str:
    """Build a memory ref string: daily-memory path + event_id + action."""
    return f"{path}#{event_id}:{action}"


def _kind_label(event_type: str) -> str:
    return EVENT_KIND_LABELS.get(event_type, event_type)


def write_event_memory_trace(
    event_store: "EventStore",
    event: dict,
    action: str,
    now_dt: Optional[datetime] = None,
) -> Optional[str]:
    """Write a daily memory note for an event lifecycle action and link it.

    Actions: 'created', 'confirmed', 'completed', 'cancelled', 'followup'

    Returns the memory_ref string, or None if deduped.
    """
    if not event:
        return None
    if now_dt is None:
        now_dt = datetime.now().astimezone()

    event_id = event.get("event_id", "?")
    event_type = event.get("event_type", "custom")
    title = event.get("title", "")[:72]
    kind = _kind_label(event_type)

    if action == "created":
        note = f"[{event_id}] 新建追蹤（{kind}）：{title}"
    elif action == "confirmed":
        note = f"[{event_id}] 使用者確認追蹤（{kind}）：{title}"
    elif action == "completed":
        reason = event.get("closure_reason", "")
        note = f"[{event_id}] 完成（{kind}）：{title}" + (f"（{reason[:40]}）" if reason else "")
    elif action == "cancelled":
        reason = event.get("closure_reason", "")
        note = f"[{event_id}] 取消（{kind}）：{title}" + (f"（{reason[:40]}）" if reason else "")
    elif action == "followup":
        note = f"[{event_id}] 後續追蹤（{kind}）：{title}"
    else:
        note = f"[{event_id}] {action}（{kind}）：{title}"

    path = _daily_memory_path(now_dt)
    written = _append_memory_note_once(path, _MEMORY_HEADING, note)
    if not written:
        return None

    ref = _make_memory_ref(event_id, action, path)
    event_store.link_memory(event_id, ref)
    return ref


def process_event_entry_with_memory(
    event_store: "EventStore",
    text: str,
    session_key: str = "",
    channel: str = "direct",
    now_dt: Optional[datetime] = None,
) -> dict:
    """Wrapper around process_event_entry that also writes memory traces.

    Returns the same dict as process_event_entry, with added 'memory_ref' key.
    """
    result = process_event_entry(event_store, text, session_key, channel, now_dt)
    action = result.get("action", "none")
    event = result.get("event")
    memory_ref = None

    if event:
        if action in ("manual_create", "auto_create"):
            memory_ref = write_event_memory_trace(event_store, event, "created", now_dt)
        elif action == "suggest_create":
            pass  # Don't write memory for suggestions — wait for confirm
        elif action == "confirm_suggest":
            memory_ref = write_event_memory_trace(event_store, event, "confirmed", now_dt)
        elif action == "complete":
            memory_ref = write_event_memory_trace(event_store, event, "completed", now_dt)
        elif action == "cancel":
            memory_ref = write_event_memory_trace(event_store, event, "cancelled", now_dt)

    result["memory_ref"] = memory_ref
    return result


# ── CLI ──

def _get_store_path() -> str:
    """Resolve events.json path from environment or default."""
    state_dir = os.environ.get("OPENCLAW_STATE_DIR", DEFAULT_OPENCLAW_STATE_DIR)
    return os.path.join(state_dir, "workspace/personal-hooks/events.json")


def main():
    args = sys.argv[1:]
    if not args:
        print(json.dumps({"error": "usage: event_store.py <command> [args]"}, ensure_ascii=False))
        sys.exit(1)

    cmd = args[0]
    store = EventStore(_get_store_path())

    if cmd == "create":
        params = _parse_kv_args(args[1:])
        evt = store.create(
            event_type=params.get("type", "custom"),
            title=params.get("title", "untitled"),
            cause_summary=params.get("cause", ""),
            desired_followup=params.get("followup", ""),
            priority=params.get("priority", "medium"),
            owner=params.get("owner", "system"),
            source_session=params.get("session", ""),
            source_channel=params.get("channel", ""),
            next_check_at=params.get("next_check", ""),
        )
        print(json.dumps(evt, ensure_ascii=False, indent=2))

    elif cmd == "list":
        statuses = None
        for i, a in enumerate(args[1:], 1):
            if a == "--status" and i + 1 < len(args):
                statuses = args[i + 1].split(",")
        events = store.list_by_status(statuses)
        print(json.dumps({"count": len(events), "events": events}, ensure_ascii=False, indent=2))

    elif cmd == "get":
        eid = _get_flag(args, "--id")
        evt = store.get(eid) if eid else None
        if evt:
            print(json.dumps(evt, ensure_ascii=False, indent=2))
        else:
            print(json.dumps({"error": f"event not found: {eid}"}, ensure_ascii=False))
            sys.exit(1)

    elif cmd == "update":
        eid = _get_flag(args, "--id")
        params = _parse_kv_args(args[1:])
        params.pop("id", None)
        if eid:
            evt = store.update(eid, **params)
            if evt:
                print(json.dumps(evt, ensure_ascii=False, indent=2))
            else:
                print(json.dumps({"error": f"event not found: {eid}"}, ensure_ascii=False))
                sys.exit(1)

    elif cmd == "close":
        eid = _get_flag(args, "--id")
        reason = _get_flag(args, "--reason") or "completed"
        status = _get_flag(args, "--status") or "completed"
        if eid:
            evt = store.close(eid, reason=reason, status=status)
            if evt:
                print(json.dumps(evt, ensure_ascii=False, indent=2))
            else:
                print(json.dumps({"error": f"event not found: {eid}"}, ensure_ascii=False))
                sys.exit(1)

    elif cmd == "due":
        limit = int(_get_flag(args, "--limit") or "5")
        events = store.list_due(limit=limit)
        print(json.dumps({"count": len(events), "events": events}, ensure_ascii=False, indent=2))

    elif cmd == "context":
        limit = int(_get_flag(args, "--limit") or "5")
        prompt = store.build_event_context_prompt(limit=limit)
        print(prompt if prompt else "(no active events)")

    elif cmd == "carryover":
        limit = int(_get_flag(args, "--limit") or "3")
        prompt = store.build_new_session_carryover(limit=limit)
        print(prompt if prompt else "(no unresolved events)")

    elif cmd == "detect":
        text = _get_flag(args, "--text") or ""
        result = detect_event_from_text(text)
        if result:
            print(json.dumps(result, ensure_ascii=False, indent=2))
        else:
            print(json.dumps({"detected": False}, ensure_ascii=False))

    elif cmd == "gc":
        days = int(_get_flag(args, "--days") or "30")
        removed = store.gc(days)
        print(json.dumps({"removed": removed, "remaining": len(store.events)}, ensure_ascii=False))

    elif cmd == "entry":
        text = _get_flag(args, "--text") or ""
        session = _get_flag(args, "--session") or ""
        channel = _get_flag(args, "--channel") or "direct-channel"
        result = process_event_entry(store, text, session_key=session, channel=channel)
        print(json.dumps(result, ensure_ascii=False, indent=2))

    else:
        print(json.dumps({"error": f"unknown command: {cmd}"}, ensure_ascii=False))
        sys.exit(1)


def _get_flag(args: list, flag: str) -> Optional[str]:
    for i, a in enumerate(args):
        if a == flag and i + 1 < len(args):
            return args[i + 1]
    return None


def _parse_kv_args(args: list) -> dict:
    result = {}
    i = 0
    while i < len(args):
        if args[i].startswith("--") and i + 1 < len(args):
            key = args[i][2:].replace("-", "_")
            result[key] = args[i + 1]
            i += 2
        else:
            i += 1
    return result


if __name__ == "__main__":
    main()


path: scripts/proactive_control.py
content: #!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Proactive Control Layer — event-driven proactive + toggle/priority chain.

This module defines:
1. Event-driven proactive triggers (from V2 event store)
2. Toggle resolution chain (which switch overrides which)
3. Priority chain for outbound source arbitration

== TOGGLE RESOLUTION (highest to lowest) ==

  1. rest_suppress      — user said going to sleep → blocks ALL proactive
  2. work_dnd           — user said in work mode → blocks ALL proactive
  3. quiet_hours        — time-based → blocks UNLESS active task hook
  4. routine_phase      — sleep/wake/active phase → phase.proactive_enabled
  5. proactive_enabled  — master on/off from settings.json
  6. followup_threshold — interval_hours + cooldown_minutes per-phase

If ANY higher-level toggle blocks, lower levels are irrelevant.

== OUTBOUND SOURCE PRIORITY (highest to lowest) ==

  1. event_driven_hook  — V2 event store: due/active events with hook params  [NEW]
  2. task_hook          — existing pending health/progress/tomorrow hooks
  3. recent_task_hook   — recently dispatched task hook holding thread
  4. emotional_hook     — existing watchful state hooks
  5. recent_emotional   — recently dispatched emotional hook holding thread
  6. proactive_chat     — arbiter-eligible proactive outreach
  7. rhythm_nudge       — inactivity-based rhythm stage
  8. cron_info          — scheduled cron info push

== EVENT-DRIVEN PROACTIVE ==

Events that can trigger proactive outreach:
  - defer         → "user said set aside; follow up when interval met"
  - wake_followup → "user said resume after sleep; trigger in wake_window"
  - checkin       → "user asked for periodic check; use next_check_at"
  - health_event  → "health incident; follow up within 2-4h"
  - emotional_event → "emotional incident; follow up within 1-2h"
  - unresolved_topic → "open thread; follow up on next active_day"

These live in the event store and produce hooks via should_auto_generate_hook().
The control layer here decides IF and WHEN those hooks should fire, respecting
the toggle chain above.

== WHAT IS SHARED vs HOST-PRIVATE ==

SHARED (this file + defaults/settings.json):
  - Toggle resolution logic
  - Priority chain order
  - Event-to-proactive mapping rules
  - Default settings (all conservative: proactive=off, rhythm=off)

HOST-PRIVATE (host settings.json, profile.json):
  - proactive_chat.enabled = true/false
  - interval_hours, quiet_hours, cooldown_minutes
  - routine_schedule times (sleep_time, wake_time)
  - Phase-specific overrides (wake_window interval = 0.5h etc)
  - max_proactive_per_day
  - Profile: chronotype, care_nudge_time, household_schedule
"""

import json
import os
from datetime import datetime, timedelta
from typing import Optional, List, Dict, Any

__version__ = "1.0.0"


# ── Toggle Resolution ──

class ToggleResult:
    """Result of toggle chain resolution."""
    __slots__ = ("allowed", "blocked_by", "active_toggles", "effective_phase", "effective_interval_hours")

    def __init__(self):
        self.allowed: bool = True
        self.blocked_by: Optional[str] = None
        self.active_toggles: Dict[str, bool] = {}
        self.effective_phase: str = "active_day"
        self.effective_interval_hours: float = 3.0

    def block(self, reason: str):
        self.allowed = False
        self.blocked_by = reason

    def to_dict(self) -> dict:
        return {
            "allowed": self.allowed,
            "blocked_by": self.blocked_by,
            "active_toggles": self.active_toggles,
            "effective_phase": self.effective_phase,
            "effective_interval_hours": self.effective_interval_hours,
        }


def resolve_toggle_chain(
    *,
    rest_suppress: Optional[dict],
    work_dnd: Optional[dict],
    quiet_hours_blocked: bool,
    phase_name: str,
    phase_proactive_enabled: bool,
    proactive_enabled: bool,
    interval_hours: float,
    has_active_task_hook: bool = False,
) -> ToggleResult:
    """Walk the toggle chain from highest to lowest priority.

    Returns ToggleResult with allowed/blocked_by and active_toggles snapshot.
    """
    r = ToggleResult()
    r.effective_phase = phase_name
    r.effective_interval_hours = interval_hours
    r.active_toggles = {
        "rest_suppress": bool(rest_suppress),
        "work_dnd": bool(work_dnd),
        "quiet_hours_blocked": quiet_hours_blocked,
        "phase_proactive_enabled": phase_proactive_enabled,
        "proactive_enabled": proactive_enabled,
        "has_active_task_hook": has_active_task_hook,
    }

    # 1. Rest suppress — blocks everything
    if rest_suppress:
        r.block("rest_suppress")
        return r

    # 2. Work DND — blocks everything
    if work_dnd:
        r.block("work_dnd")
        return r

    # 3. Quiet hours — blocks UNLESS active task hook
    if quiet_hours_blocked and not has_active_task_hook:
        r.block("quiet_hours")
        return r

    # 4. Routine phase — phase must allow proactive
    if not phase_proactive_enabled:
        r.block("routine_phase_disabled")
        return r

    # 5. Master proactive toggle
    if not proactive_enabled:
        r.block("proactive_disabled")
        return r

    # All toggles passed
    return r


# ── Event-Driven Proactive Triggers ──

# Map event types to proactive urgency windows (hours)
EVENT_PROACTIVE_WINDOWS = {
    "health_event":       {"min_hours": 1.0, "max_hours": 4.0, "priority": "high"},
    "emotional_event":    {"min_hours": 1.0, "max_hours": 3.0, "priority": "high"},
    "defer":              {"min_hours": 2.0, "max_hours": 8.0, "priority": "medium"},
    "wake_followup":      {"min_hours": 0.0, "max_hours": 1.5, "priority": "medium"},  # fires in wake_window
    "checkin":            {"min_hours": 4.0, "max_hours": 24.0, "priority": "medium"},
    "unresolved_topic":   {"min_hours": 3.0, "max_hours": 12.0, "priority": "low"},
    "reminder_intent":    {"min_hours": 0.5, "max_hours": 2.0, "priority": "high"},
    "task_progress":      {"min_hours": 4.0, "max_hours": 24.0, "priority": "medium"},
    "life_context_event": {"min_hours": 6.0, "max_hours": 48.0, "priority": "low"},
    "identity_event":     {"min_hours": 0.0, "max_hours": 0.0, "priority": "low"},  # never triggers proactive
    "work_event":         {"min_hours": 2.0, "max_hours": 8.0, "priority": "medium"},
    "relationship_event": {"min_hours": 3.0, "max_hours": 12.0, "priority": "medium"},
    "incident":           {"min_hours": 1.0, "max_hours": 6.0, "priority": "high"},
}


def find_event_driven_proactive_candidates(
    event_store,
    now_dt: datetime,
    phase_name: str = "active_day",
) -> List[dict]:
    """Find events that should trigger proactive outreach NOW.

    Checks:
    1. Event is active/due
    2. Enough time has passed since creation (min_hours)
    3. No linked hook is still pending (avoid duplicate dispatch)
    4. wake_followup only fires in wake_window phase

    Returns list of candidate dicts sorted by priority (high first).
    """
    candidates = []
    priority_order = {"high": 0, "medium": 1, "low": 2}

    for evt in event_store.events:
        status = evt.get("status", "")
        if status not in ("active", "due"):
            continue

        etype = evt.get("event_type", "custom")
        window = EVENT_PROACTIVE_WINDOWS.get(etype)
        if not window or window["max_hours"] == 0:
            continue  # This type never triggers proactive

        # wake_followup only fires in wake_window
        if etype == "wake_followup" and phase_name != "wake_window":
            continue

        created_at = evt.get("created_at", "")
        try:
            created_dt = datetime.fromisoformat(created_at)
        except (ValueError, TypeError):
            continue

        age_hours = (now_dt - created_dt).total_seconds() / 3600
        if age_hours < window["min_hours"]:
            continue  # Too soon

        # Check if event already has a pending/active hook
        linked_hooks = evt.get("linked_hook_ids", [])
        if linked_hooks:
            # If any linked hook is still pending, skip — it will handle dispatch
            # The caller should check hook_store to verify, but we skip for safety
            continue

        candidates.append({
            "event_id": evt.get("event_id", ""),
            "event_type": etype,
            "title": evt.get("title", ""),
            "priority": window["priority"],
            "age_hours": round(age_hours, 1),
            "cause_summary": evt.get("cause_summary", ""),
            "desired_followup": evt.get("desired_followup", ""),
            "_priority_rank": priority_order.get(window["priority"], 9),
        })

    candidates.sort(key=lambda c: (c["_priority_rank"], -c["age_hours"]))
    for c in candidates:
        c.pop("_priority_rank", None)
    return candidates


# ── Priority Chain Documentation ──

PRIORITY_CHAIN = """
Outbound Source Priority Chain (highest wins):

  ┌─────────────────────────────────────────────────────────┐
  │ TOGGLE CHAIN (evaluated first, top-down)                │
  │                                                         │
  │  1. rest_suppress  → blocks ALL                         │
  │  2. work_dnd       → blocks ALL                         │
  │  3. quiet_hours    → blocks unless active task hook      │
  │  4. phase_policy   → sleep=off, wake=on, active=on      │
  │  5. proactive_on   → master switch                      │
  │  6. interval/cool  → per-phase timing                   │
  └─────────────────────────────────────────────────────────┘
          │ if all passed ↓
  ┌─────────────────────────────────────────────────────────┐
  │ SOURCE PRIORITY (first eligible wins)                   │
  │                                                         │
  │  1. event_driven   → V2 event with no linked hook       │
  │  2. task_hook      → pending health/progress/tomorrow    │
  │  3. recent_task    → recently dispatched, holding thread │
  │  4. emotional_hook → pending watchful state              │
  │  5. recent_emot    → recently dispatched emotional       │
  │  6. proactive_chat → arbiter-eligible proactive          │
  │  7. rhythm_nudge   → inactivity stage                   │
  │  8. cron_info      → scheduled push                     │
  └─────────────────────────────────────────────────────────┘

Toggle values are HOST-PRIVATE (settings.json per instance).
Priority chain order is SHARED (this module).
"""


# ── CLI ──

def main():
    import sys
    args = sys.argv[1:]
    if not args:
        print(PRIORITY_CHAIN)
        return

    cmd = args[0]
    if cmd == "chain":
        print(PRIORITY_CHAIN)
    elif cmd == "toggles":
        # Show current toggle state from a live instance
        print(json.dumps({
            "note": "Run via personal_hooks.py runtime-context for live toggle state",
            "toggle_order": [
                "1. rest_suppress (user sleep)",
                "2. work_dnd (user work mode)",
                "3. quiet_hours (time-based)",
                "4. routine_phase (sleep/wake/active)",
                "5. proactive_enabled (master switch)",
                "6. interval_hours + cooldown_minutes (timing)",
            ],
        }, ensure_ascii=False, indent=2))
    elif cmd == "event-windows":
        print(json.dumps(EVENT_PROACTIVE_WINDOWS, ensure_ascii=False, indent=2))
    else:
        print(f"Unknown command: {cmd}")
        sys.exit(1)


if __name__ == "__main__":
    main()


path: scripts/install_local.sh
content: #!/usr/bin/env bash
set -euo pipefail

if [[ $# -lt 1 || $# -gt 2 ]]; then
  echo "Usage: bash scripts/install_local.sh /path/to/openclaw-workspace/skills [link|copy]"
  exit 1
fi

TARGET_SKILLS_DIR="$1"
MODE="${2:-link}"

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PACKAGE_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
TARGET_PATH="${TARGET_SKILLS_DIR%/}/personal-hooks"
PACKAGE_REAL="$(cd "$PACKAGE_DIR" && pwd -P)"

mkdir -p "$TARGET_SKILLS_DIR"
TARGET_SKILLS_REAL="$(cd "$TARGET_SKILLS_DIR" && pwd -P)"

if [[ "$TARGET_SKILLS_REAL" == "$PACKAGE_REAL" || "$TARGET_SKILLS_REAL" == "$PACKAGE_REAL/"* ]]; then
  echo "Refusing to install into the package source tree: $TARGET_SKILLS_REAL"
  exit 1
fi

case "$MODE" in
  link)
    if [[ -e "$TARGET_PATH" && ! -L "$TARGET_PATH" ]]; then
      echo "Refusing to replace existing non-symlink target: $TARGET_PATH"
      exit 1
    fi
    rm -f "$TARGET_PATH"
    ln -s "$PACKAGE_DIR" "$TARGET_PATH"
    ;;
  copy)
    if [[ -e "$TARGET_PATH" ]]; then
      echo "Refusing to overwrite existing target: $TARGET_PATH"
      exit 1
    fi
    cp -R "$PACKAGE_DIR" "$TARGET_PATH"
    ;;
  *)
    echo "Unknown mode: $MODE"
    echo "Use: link | copy"
    exit 1
    ;;
esac

cat <<EOF
Installed personal-hooks to:
  $TARGET_PATH

Suggested next steps:
  python3 $TARGET_PATH/scripts/personal_hooks.py init
  python3 $TARGET_PATH/scripts/personal_hooks.py capability-state-show

Suggested settings starting point:
  $TARGET_PATH/examples/settings.sample.json

If you want a custom writable settings path, set:
  PERSONAL_HOOKS_SETTINGS_PATH=/path/to/settings.json

This helper does not modify channel delivery, credentials, or live state.
EOF


path: examples/harness-report.sample.json
content: {
  "report_version": 1,
  "suite": "personal-hooks-v1",
  "generated_at": "1970-01-01T00:00:00+00:00",
  "summary": {
    "pass_count": 3,
    "fail_count": 0
  },
  "cases": [
    {
      "case_id": "parked_topic",
      "category": "parked_topic",
      "expected_memory_layer": "staged_memory",
      "actual_memory_layer": "staged_memory",
      "pass/fail": "pass"
    },
    {
      "case_id": "delegated_task",
      "category": "delegated_task",
      "expected_memory_layer": "tracked_followup",
      "actual_memory_layer": "tracked_followup",
      "pass/fail": "pass"
    },
    {
      "case_id": "new_session_carryover",
      "category": "/new carryover",
      "expected_carryover_behavior": true,
      "actual_carryover_flags": {
        "new_session_carryover_applied": true,
        "carryover_source": "last_3_to_5_turns",
        "carryover_summary_present": true
      },
      "pass/fail": "pass"
    }
  ]
}


path: examples/settings.sample.json
content: {
  "version": 1,
  "updated_at": "1970-01-01T00:00:00+00:00",
  "followup": {
    "enabled": true,
    "parked_auto_close_hours": 0
  },
  "re_engagement": {
    "mode": "wait_for_reply",
    "retry_after_hours": 4,
    "max_unanswered_before_park": 2
  },
  "candidate_ttl_hours": {
    "emotion": 24,
    "support": 24,
    "task": 168,
    "preference": 168
  },
  "temporal_guard": {
    "newer_interaction_minutes": 5
  },
  "sleep_rest_suppress": {
    "enabled": true,
    "duration_hours": 10,
    "auto_clear_hours": 4
  },
  "causal_memory": {
    "summary_max_facts": 3,
    "include_time_anchor": true,
    "include_state_marker": true
  },
  "carryover": {
    "enabled": true,
    "max_turns": 5,
    "top_summary_only": true
  },
  "new_session_continuity": {
    "mode": null
  },
  "modality_continuity": {
    "enabled": true,
    "mode": null,
    "voice_reply_on_voice_thread": true,
    "image_context_carryover": true
  },
  "dispatch": {
    "cooldown_minutes": 180,
    "cap": 3
  },
  "closure": {
    "auto_close_on_user_reply": true
  },
  "proactive_chat": {
    "enabled": false,
    "interval_hours": 3,
    "quiet_hours": {
      "start": 0,
      "end": 8
    },
    "use_recent_window_only": true,
    "recent_window_turns": 4,
    "cooldown_minutes": 180,
    "max_proactive_per_day": 2,
    "allow_when_no_trackable_thread": false
  },
  "routine_schedule": {
    "enabled": false,
    "timezone": "UTC",
    "sleep_time": "23:00",
    "wake_time": "07:00",
    "wake_window_minutes": 90,
    "phases": {
      "sleep": {
        "proactive_enabled": false,
        "wake_seed_enabled": false
      },
      "wake_window": {
        "proactive_enabled": true,
        "wake_seed_enabled": true,
        "interval_hours": 0.5
      },
      "active_day": {
        "proactive_enabled": true,
        "wake_seed_enabled": false,
        "interval_hours": 3
      }
    }
  },
  "experimental": {
    "rhythm_nudge": {
      "enabled": false
    }
  }
}


path: examples/profile.sample.json
content: {
  "version": 1,
  "updated_at": "1970-01-01T00:00:00+00:00",
  "sleep_pattern": {
    "chronotype": null,
    "usual_sleep_time": "23:00",
    "active_window": "07:00-23:00",
    "care_nudge_time": null,
    "sleep_block_window": "23:00-07:00",
    "source": "setup"
  },
  "care_style": {
    "tone": "warm",
    "format": "brief",
    "relationship": "assistant",
    "use_case": "developer",
    "source": "setup"
  },
  "reminder_style": {
    "tone": "gentle",
    "verbosity": "brief",
    "source": "default-profile"
  },
  "task_followup_preference": {
    "allowed": true,
    "style": "light_progress_check",
    "source": "default-profile"
  },
  "emoji_style": {
    "forbidden": [],
    "preferred": [],
    "source": "setup"
  },
  "inactivity_routine": {
    "light_after_hours": 3,
    "playful_after_hours": 6,
    "care_after_hours": 12,
    "concern_after_hours": 24,
    "strong_concern_after_hours": 48,
    "same_type_cooldown_hours": 6,
    "source": "default-profile"
  },
  "tracking_hints": {
    "keywords": ["deploy", "migration", "deadline"],
    "source": "setup"
  }
}


path: examples/jobs.sample.json
content: {
  "jobs": []
}


path: examples/openclaw.sample.json
content: {
  "plugins": {
    "entries": {
      "memory-lancedb-pro": {
        "config": {
          "embedding": {
            "apiKey": "${EMBEDDING_API_KEY}",
            "model": "embedding-model-name",
            "baseURL": "https://embedding-provider.example/v1",
            "dimensions": 1024,
            "taskQuery": "retrieval.query",
            "taskPassage": "retrieval.passage",
            "normalized": true
          }
        }
      }
    }
  }
}


path: docs/v2-status.md
content: # V2 Status

This is the current public mainline package for `personal-hooks`.

The package identity is V2. Publication should be judged against the explicit release acceptance matrix in [release-acceptance.md](release-acceptance.md).

## What V2 adds

- Phase 1 proactive chat
- Phase 2 arbiter integration
- proactive topic labeling and continuity summaries
- proactive writeback eligibility rules
- routine schedule / phase switching
- wake-seed path
- user-facing routine controls for sleep, wake, and work/dnd style suppression

## Acceptance line

V2 is considered structurally sound when:

- there is no structural routing failure
- there is no core arbiter misordering between task, wake seed, proactive, rhythm, and cron
- closure and writeback boundaries still hold
- `/new` continuity is not broken
- test and validation runs do not leave state pollution behind
- remaining issues can be handled as patch-level follow-up work

V2 would need further work if any of the following reappear:

- task/progress hooks lose to generic proactive follow-up
- sleep/work-dnd/wake phases fight each other
- closure becomes unreliable
- proactive self-pollutes long-term memory
- `/new` continuity misanchors or silently drops
- the implementation would still need a major path rewrite

## Current status

Current status: **V2 public mainline, publication gated by acceptance matrix**

- the validation line holds across arbiter, topic/summary, writeback, routine schedule, and isolated near-live checks
- no structural blocker remains in the V2 routing
- remaining concerns should now be treated as release-gate failures, neutrality gaps, or documentation gaps rather than redesign-level blockers

## What is still patch-level

- phrasing/tone tuning for proactive messages
- lexical tuning for natural switches and suppression phrases
- cadence tuning for real-world use after observation
- documentation refinement
- small thresholds or guard adjustments if observation exposes edge cases

See also [v2-known-limits.md](v2-known-limits.md) for the condensed reviewer-facing list.

## What still needs explicit release proof

- language neutrality at the public-package level
- timezone / offset neutrality at the public-package level
- `/new` + low-information greeting behavior under the release acceptance matrix
- host/adapter boundary checks for web/live hosts when live delivery is demonstrated

## Recommended next step

Execute the release acceptance matrix and treat any miss as a public-package gap to fix before publication.

If the matrix passes, the next step should be publication work, not another large extraction or rewrite round.


path: docs/channel-boundary-stopgap.md
content: # Channel Boundary Stopgap

This document covers the **channel boundary stopgap** for two classes of outbound
leakage that the public skill alone cannot fully stop:

1. `<final> ... </final>` tags appearing in user-visible output
2. heartbeat/internal narration reaching outbound delivery

These are **host integration** concerns. They are not part of the public
skill's portable logic, but a host using this skill should wire a final
outbound defense.

## What belongs in the public skill

The skill may:

- reduce meta / narration in model-facing output
- keep runtime-context structured
- strip obvious internal wording during generation

The skill should **not** own:

- channel-specific outbound interception
- gateway runtime persistence behavior
- platform-specific delivery routing
- language-specific personal preferences such as Simplified→Traditional rewrite

## Recommended channel boundary stopgap

At the host layer, add a final outbound guard with these behaviors:

1. Strip `<final>` wrappers before outbound send
2. Strip heartbeat narration prefixes such as:
   - `This is another heartbeat poll...`
   - `Looking at the autoseed output...`
   - `Still no response...`
3. Keep the final message short and frontstage-safe
4. Apply the same logic to:
   - `message_sending`
   - heartbeat delivery persistence (for example `lastHeartbeatText`)

## Why this is outside the skill core

The skill runs during generation, but heartbeat delivery and final outbound
payloads may be resolved later by gateway/runtime code. If the host does not
intercept the final payload, raw narration can still leak even when the skill
already cleaned the generated text.

## Minimum validation

For a new host, verify all of the following:

1. `message_sending` fires on outbound delivery
2. `<final>` does not appear in the user-visible message
3. heartbeat/internal narration does not appear in the user-visible message
4. persisted heartbeat text (for example `lastHeartbeatText`) matches the final
   delivered text, not the raw pre-sanitize narration

## Release note

This stopgap is included as **boundary guidance**, not as a public skill promise.
The public package is allowed to ship the guidance and validation steps without
claiming it can guarantee all host frontstage safety by itself.


path: docs/v2-architecture.md
content: # V2 Architecture — Event Store + Hook Store 雙層結構

## 概念

```
使用者對話 → 事件偵測 → Event Store → (條件成立) → Hook Store → 排程/dispatch
                                    ↓
                              Runtime Context → 新對話承接 / 時間感知 / 因果鏈
```

**Event**（事件）= 發生了什麼、為什麼要追、現在什麼狀態、下次怎麼接。
**Hook**（鉤子）= 什麼時候做什麼、dispatch 排程、render 輸出。

## Event Store Schema

檔案：`workspace/personal-hooks/events.json`

```json
{
  "version": 1,
  "updated_at": "ISO timestamp",
  "events": [
    {
      "event_id": "EVT-YYYYMMDD-HHMMSS-xxxxxx",
      "event_type": "defer | wake_followup | checkin | reminder_intent | unresolved_topic | health_event | emotional_event | work_event | relationship_event | task_progress | incident | custom",
      "title": "人可讀的事件標題",
      "status": "active | parked | due | completed | cancelled | superseded",
      "priority": "low | medium | high | critical",
      "owner": "system | user | agent",
      "cause_summary": "為什麼這件事存在",
      "desired_followup": "希望怎麼追",
      "source_session": "產生這個事件的 session key",
      "source_channel": "direct | web | ...",
      "source_turn_range": "產生的對話回合範圍",
      "created_at": "ISO",
      "last_update_at": "ISO",
      "next_check_at": "ISO（空=不定時）",
      "linked_hook_ids": ["HK-xxx"],
      "linked_memory_refs": ["memory key"],
      "closure_reason": "完成或取消的原因",
      "metadata": {}
    }
  ]
}
```

## Event Lifecycle

```
create → active → [parked] → due → completed
                     ↑          ↓
                  (使用者說先放著)  cancelled
                                  superseded
```

| 狀態 | 意義 | 觸發 |
|------|------|------|
| active | 正在追蹤 | 事件建立時 |
| parked | 暫停（「先放著」） | 使用者明確暫停 |
| due | 該追問了 | next_check_at 到期 |
| completed | 已結束 | 使用者確認完成或不再需要 |
| cancelled | 取消 | 使用者取消或過期 |
| superseded | 被取代 | 新事件覆蓋舊事件 |

## Event → Hook 生成規則

| Event Type | 自動生成 Hook? | Hook Type | 條件 |
|---|---|---|---|
| defer | ✅ 有 next_check_at 時 | progress_followup | next_check_at 到期 |
| wake_followup | ✅ | care_message | 使用者起床後 |
| checkin | ✅ 有 next_check_at 時 | progress_followup | next_check_at 到期 |
| reminder_intent | ✅ | progress_followup | next_check_at 到期 |
| health_event | ✅ | health_followup | 自動 |
| emotional_event | ✅ | emotional_followup | 自動 |
| unresolved_topic | ❌ 只留在 event store | — | 等使用者回來接 |
| work_event | ❌ 留在 event store | — | 時間感知決定 |
| task_progress | ✅ 有 next_check_at 時 | progress_followup | next_check_at 到期 |

## Runtime Context 注入順序

```
1. event_new_session_carryover   (新對話：未結束事件摘要)
2. event_context_prompt          (當前追蹤中的事件)
3. carryover_prompt              (上一輪對話 carryover)
4. pending_topics_prompt         (pending hooks/incidents)
5. schedule_context_prompt       (時間感知/作息)
6. recent_dispatch_awareness     (最近 cron/heartbeat dispatch)
7. active_preferences_prompt     (使用者偏好)
```

## Priority Chain（優先序）

以下設定按優先級排列（高→低），高的覆蓋低的：

```
1. rest_suppress          — 使用者明確在休息（最高優先，壓掉一切主動）
2. work_dnd               — 工作勿擾模式
3. quiet_hours            — 靜音時段（proactive_chat.quiet_hours）
4. same_type_cooldown     — 同類型 hook 冷卻（profile.inactivity_routine.same_type_cooldown_hours）
5. routine_schedule.phase — 當前階段（sleep / wake_window / active_day）
   - sleep: proactive 全停
   - wake_window: interval 縮短到 0.5hr
   - active_day: 用 proactive_chat.interval_hours
6. proactive_chat.interval_hours    — 主動聊天最小間隔
7. proactive_chat.max_per_day       — 每日上限
8. inactivity_routine.*_after_hours — 按時間升級（light → playful → care → concern）
9. followup_window                  — follow-up hook 有效期
```

### Shared 預設 vs Live 私有

| 設定 | Shared 預設 | Instance A | Instance B |
|------|---|---|---|
| proactive_chat.interval_hours | 3 | 3 | 1 |
| proactive_chat.quiet_hours | 無 | 6-12 | 0-8 |
| proactive_chat.max_per_day | 2 | 2 | 2 |
| routine_schedule.sleep_time | — | (per profile) | (per profile) |
| routine_schedule.wake_time | — | (per profile) | (per profile) |
| inactivity_routine.light_after_hours | 2 | 2 | 1 |
| same_type_cooldown_hours | 6 | 6 | 6 |

**原則**：shared 只定義 schema 和預設值，live 私有設定不偷渡成公版預設。

## 自然語言 → Event 偵測

以下信號自動觸發事件建立：

| 信號類型 | 範例 | Event Type |
|---|---|---|
| 暫緩 | 「先放著」「晚點再說」「先跳過」 | defer |
| 起床 | 「醒來再聊」「起床後提醒我」 | wake_followup |
| 追蹤 | 「提醒我」「之後問我」「follow up」 | checkin |
| 健康 | 「跌倒」「住院」「不舒服」 | health_event |
| 情緒 | 「壓力」「難過」「心情不好」 | emotional_event |

## 事件建立入口（Entry Layer）

三種入口模式，依信心度與使用者意圖區分：

### Manual（使用者手動建立）

使用者在對話中明確要求追蹤：
- 「幫我記住 / 幫我記 / 記住這件事」
- 「提醒我 / 之後提醒 / 晚點提醒」
- 「之後問我 / 晚點問我 / 記得問我」
- 「追蹤這 / 追這件事 / 幫我追」

→ 直接建立 event（owner=user），前台回覆「✅ 已記住：{title}」

### Auto（AI 自動建立，高信心）

偵測到明確信號且 event type 在允許清單內：

| Event Type | 允許 Auto？ | 原因 |
|---|---|---|
| defer | ✅ | 使用者主動暫緩 = 明確意圖 |
| wake_followup | ✅ | 使用者說醒來再聊 = 明確意圖 |
| reminder_intent | ✅ | 使用者說提醒我 = 明確意圖 |
| health_event | ✅ | 關鍵字精準（頭痛/住院/跌倒） |
| emotional_event | ✅ | 關鍵字精準（壓力/難過/崩潰） |

→ 靜默建立 event（owner=system），不打擾前台

### Suggest（AI 建議建立，先問）

偵測到疑似重要事件但信心不夠高：

| Event Type | 走 Suggest？ | 原因 |
|---|---|---|
| checkin | ✅ | 可能只是隨口一提 |
| work_event | ✅ | 不確定使用者是否要追 |
| relationship_event | ✅ | 私密，應先問 |
| task_progress | ✅ | 可能只是聊天 |
| unresolved_topic | ✅ | 模糊 |
| incident | ✅ | 視情況 |
| custom | ✅ | 不確定 |

→ 建立 event 但 status=suggested，注入提示讓模型問一句：「這件事要我幫你記著追嗎？」
→ 使用者回「好/對/要」→ 升為 active
→ 使用者回「不用/算了」→ 取消

### CRUD 操作（對話中管理事件）

| 操作 | 觸發語句 | 動作 |
|---|---|---|
| 查看 | 「現在追蹤什麼」「在追什麼」「追蹤中」 | 列出所有 active/parked/due 事件 |
| 完成 | 「完成了」「搞定了」「解決了」 | 標記最匹配的 event 為 completed |
| 取消 | 「取消追蹤」「不追了」「不用追了」 | 標記最匹配的 event 為 cancelled |
| 暫停 | 「暫停追蹤」「先擱著」 | 標記最匹配的 event 為 parked |

匹配邏輯：單一 active event 直接命中；多個時依文字關鍵字重疊度匹配。

### 優先序

```
1. pending suggestion 確認/拒絕（最優先檢查）
2. CRUD 操作（view / complete / cancel / park）
3. Manual create（使用者明確要求）
4. Auto create（高信心偵測）
5. Suggest create（低信心偵測）
```

### 前台語句原則

- 短，不像 debug log
- manual create: 「✅ 已記住：{title}」
- auto create: 靜默（不打擾）
- suggest: 模型自然地問一句（由 prompt injection 引導）
- complete: 「✅ 已完成：{title}」
- cancel: 「已取消追蹤：{title}」
- park: 「⏸️ 已暫停：{title}」
- view: 列表格式

### Shared vs Live

- `event_store.py` 和入口邏輯 = **shared**（兩邊完全相同的模組）
- `events.json` = **live 私有**（各自的事件資料）
- `personal_hooks.py` 整合區塊 = **live 私有**（各自微調但結構一致）

## 檔案結構

```
scripts/
  event_store.py          — Event Store 模組（獨立，可 CLI / import）
  personal_hooks.py       — Hook Store + arbiter + runtime-context
plugins/
  personal-hooks-host-adapter/
    index.ts              — Gateway adapter（注入 event context）
data/
  events.json             — Event Store 資料
  hooks.json              — Hook Store 資料
  settings.json           — 統一設定
  profile.json            — 使用者/agent 配置
```


path: docs/host-operator-settings.md
content: # Host Operator Settings

This document is for host operators who want to tune:

- how long tracked state should live
- how routine schedule should be applied
- how quiet / rest / work-time behavior should be applied
- which knobs belong to the portable skill, and which still belong to host/live

The public package stays skill-first. Host-specific delivery or persona choices
should still live outside the portable core.

## 0. Causal memory boundary: what the public skill should and should not do

The public package should:

- extract causal structure from recent conversation
- preserve continuity facts, user state, unresolved loops, and time anchors
- write structured continuity state into staging / tracked follow-up / daily-memory paths
- give the model enough internal context to continue naturally later

The public package should **not**:

- hardcode role-specific push wording
- prewrite what one specific host should say
- assume one host role, one language, or one chat platform

In other words:

- the skill owns structured continuity state
- the model owns natural language generation
- the host owns delivery, persona, and platform specifics

## 0.5. Host-agnostic settings entry

The public package should expose one simple natural-language entry into guided
settings mode, without binding the skill to one specific chat platform or any other single
external platform.

Recommended multilingual entry phrases:

- Traditional Chinese: `幫我調整關心設定`
- Simplified Chinese: `帮我调整关心设置`
- English: `Help me adjust my follow-up settings`
- Japanese: `フォローアップ設定を調整して`
- German: `Hilf mir, meine Follow-up-Einstellungen anzupassen`
- Italian: `Aiutami a regolare le impostazioni di follow-up`
- Spanish: `Ayúdame a ajustar mi configuración de seguimiento`

This entry should open a guided update flow, not directly mutate everything at
once. A host should:

1. run `setup-check`
2. identify which category the user wants to change
3. apply only changed fields through `setup-apply`

Recommended guided categories:

- `schedule`
- `proactive`
- `tracking`
- `tone`

Important boundary:

- host/user can adjust policy values
- host/user should **not** directly tune causal extraction logic, continuity
  core rules, or daily-memory promotion internals

## 1. Tracking lifetime: what expires, and where to change it

There is no single "memory TTL" knob. Different layers have different lifetimes.

### Causal memory summary behavior

This is the public, host-agnostic operator surface for how much structured
continuity state is preserved in internal summaries. It affects the skill's
internal continuity context, not frontstage wording.

Public settings (in `causal_memory`):

- `summary_max_facts`: default `3`
- `include_time_anchor`: default `true`
- `include_state_marker`: default `true`

These control:

- how many factual clauses are retained in normalized continuity summaries
- whether time anchors such as `today`, `tonight`, `21:00`, or `Monday` stay visible
- whether neutral state markers such as `busy`, `tired`, or `sick` remain available to the model

This is useful for multilingual hosts because it keeps structure explicit
without forcing one frontstage phrasing style.

### Candidate staging TTL

Candidate staging is the short-term holding area before a topic is promoted into
formal incident/event tracking.

Public settings (in `candidate_ttl_hours`):

- `emotion`: 24 hours
- `support`: 24 hours
- `task`: 168 hours (7 days)
- `preference`: 168 hours (7 days)

Configurable via `settings.json` → `candidate_ttl_hours.*`.

### Carryover lifetime

Carryover controls how much of the last session can be pulled into a new
session.

Public setting:

- `carryover.max_turns`

Default sample value:

- `5`

Where to change it:

- host runtime settings file
- start from `examples/settings.sample.json`

Relevant files:

- `examples/settings.sample.json`
- `config.schema.json`

### Dispatch cooldown / follow-up lifetime

These determine how often follow-up **may** dispatch. The interval is a minimum re-evaluation window, not a guaranteed send frequency. Actual dispatch also depends on: whether the user has re-engaged, followup_state guards, cooldown, dispatch cap, quiet hours, and sleep/rest suppress.

Public settings:

- `dispatch.cooldown_minutes`
- `dispatch.cap`
- `proactive_chat.interval_hours`
- `proactive_chat.cooldown_minutes`
- `proactive_chat.max_proactive_per_day`

Default sample values:

- `dispatch.cooldown_minutes = 180`
- `dispatch.cap = 3`
- `proactive_chat.interval_hours = 3`
- `proactive_chat.cooldown_minutes = 180`
- `proactive_chat.max_proactive_per_day = 2`

### Re-engagement policy

Controls what happens after a dispatch receives no user reply.

Public settings (in `re_engagement`):

- `mode`: `"wait_for_reply"` (default) — park after `max_unanswered_before_park` dispatches; only user reply unparks. `"timed_retry"` — automatically retry parked hooks after `retry_after_hours` even without reply.
- `retry_after_hours`: `4` (default) — hours to wait before retrying a parked hook (only used when `mode=timed_retry`)
- `max_unanswered_before_park`: `2` (default) — how many dispatches without reply before the hook is parked

### Parked hook auto-close

Public setting (in `followup`):

- `followup.parked_auto_close_hours`: `0` (default, disabled) — if > 0, parked hooks are auto-cancelled after this many hours since last dispatch

### Temporal guard

Controls how recently a user must have interacted for dispatch guards to allow/block.

Public setting (in `temporal_guard`):

- `temporal_guard.newer_interaction_minutes`: `5` (default) — if user interacted this many minutes after the proactive anchor, block dispatch as "newer-user-interaction"

### Rest-suppress lifetime

When the user says they want to sleep or rest, the skill can suppress follow-up
for a fixed duration.

Public settings:

- `sleep_rest_suppress.duration_hours`: `10` (default) — how long rest-suppress lasts
- `sleep_rest_suppress.auto_clear_hours`: `4` (default) — auto-clear rest-suppress after this many hours even without explicit resume. Set to `0` to disable auto-clear.

### Same-type cooldown

There is also an internal same-type cooldown used by the skill to avoid
repeating the same kind of incident too often.

Current internal default:

- `same_type_cooldown_hours = 6`

This is currently a code-level default, not a public schema field.

## 2. Routine schedule: how to make the host apply sleep/wake timing

Use `routine_schedule` when the host knows the user's rough daily pattern and
wants proactive timing to come from a stable schedule instead of memory guesswork.

Main fields:

- `routine_schedule.enabled`
- `routine_schedule.timezone`
- `routine_schedule.sleep_time`
- `routine_schedule.wake_time`
- `routine_schedule.wake_window_minutes`
- `routine_schedule.phases.sleep.proactive_enabled`
- `routine_schedule.phases.sleep.wake_seed_enabled`
- `routine_schedule.phases.wake_window.proactive_enabled`
- `routine_schedule.phases.wake_window.wake_seed_enabled`
- `routine_schedule.phases.wake_window.interval_hours`
- `routine_schedule.phases.active_day.proactive_enabled`
- `routine_schedule.phases.active_day.wake_seed_enabled`
- `routine_schedule.phases.active_day.interval_hours`

Typical use:

- set `enabled=true`
- set `timezone`
- set one sleep time and one wake time
- decide whether wake-window should allow proactive and wake-seed
- decide active-day interval separately from wake-window

See also:

- `docs/routine-schedule.md`

## 3. Quiet mode: how to suppress proactive messages at fixed times

Use `proactive_chat.quiet_hours` when the host wants a stable blocked window.

Public fields:

- `proactive_chat.quiet_hours.start`
- `proactive_chat.quiet_hours.end`

Behavior:

- if `start == end`, quiet-hours is effectively disabled
- if `start < end`, the blocked window is same-day
- if `start > end`, the blocked window crosses midnight

Examples:

- `23 -> 7` means quiet overnight
- `9 -> 18` means quiet during workday
- `99 -> 99` is not a portable public convention; prefer disabling or using the
  same start/end value instead

## 4. Work-time behavior: what is public, what is still host/live

There are two different ways to model "working hours":

### A. Stable work hours

Use:

- `proactive_chat.quiet_hours`

This is the portable public way if the user usually does not want proactive
messages during fixed work hours.

### B. Dynamic "I am currently working" state

This is a host/live concern.

The live system may maintain a dynamic work-DND state when the user explicitly
says they are working, focused, or busy. That is not yet a portable public
schema block in RC.

For public packaging, the honest rule is:

- fixed work windows -> use `quiet_hours`
- dynamic current work state -> host/live extension

## 5. Rest / sleep phrases

The public skill already supports rest/sleep suppression logic.

Host operators should know:

- this is not the same as quiet-hours
- quiet-hours is a clock-based block
- rest-suppress is a user-state block

Recommended split:

- use `quiet_hours` for stable clock windows
- use `sleep_rest_suppress` for explicit "I want to sleep/rest now"

## 6. Minimum host workflow

For a portable install, the simplest order is:

1. copy or symlink the skill package into `skills/personal-hooks/`
2. create a writable runtime settings file from `examples/settings.sample.json`
3. tune:
   - `carryover.max_turns`
   - `new_session_continuity.mode`
   - `modality_continuity.mode`
   - `dispatch.cooldown_minutes`
   - `proactive_chat.*`
   - `routine_schedule.*`
   - `sleep_rest_suppress.duration_hours`
4. initialize the skill
5. verify continuity and follow-up in the host's ordinary reply pipeline

If the host also uses heartbeat-driven proactive pushes, add one more host-side
rule:

- set `heartbeat.isolatedSession=true`

Why this matters:

- heartbeat can legitimately deliver to the same user/channel
- but it should not reuse the same session key for background generation
- otherwise heartbeat prompts and dispatch context can leak back into ordinary
  continuity / carryover / staging state

Recommended host heartbeat shape:

```json
{
  "heartbeat": {
    "every": "30m",
    "target": "direct-channel",
    "to": "[CHANNEL_DESTINATION]",
    "directPolicy": "allow",
    "lightContext": true,
    "isolatedSession": true
  }
}
```

Before enabling it in production, verify the session/transcript behavior on the
exact OpenClaw version you are running. Heartbeat isolation is useful, but host
implementations and transcript handling can differ by version.

## 7. What is still not fully portable

The following should still be treated as host/live integration work:

- dynamic work-DND state
- channel-specific outbound delivery rules
- final outbound cleanup such as `<final>` / heartbeat leakage interception


path: docs/call-flow.md
content: # Call Flow

## Main runtime path

1. `intercept_message()`
   - inspect the incoming user text
   - classify it as casual, staged, or tracked-worthy
   - write candidate / incident / hook state when needed

2. `process_candidate_buffer()`
   - re-evaluate staged candidates
   - promote qualified continuity into incidents/hooks

3. `build_runtime_context()`
   - collect pending topics
   - collect carryover for `/new`
   - add schedule context and continuity guards

4. ordinary reply generation
   - the host OpenClaw agent uses the runtime context to generate frontstage text

5. `due` / `render` / `complete`
   - evaluate pending hooks
   - render due follow-up text
   - mark closure / completion

## State flow

`casual_chat` -> no tracked state required

`staged_memory`
- `session_memory_staging.json`
- `candidate_buffer.json`
- concise daily-memory trace

`tracked_followup`
- `incidents.json`
- `hooks.json`
- clearer daily-memory trace

## Traceability

Key files:

- `followup_trace.jsonl`
- `candidate_buffer_audit.jsonl`
- `session_memory_staging_audit.jsonl`
- `hook_completion_audit.jsonl`
- `frontstage_guard_log.jsonl`


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)