Skill: kylnwu/test-continuity (version 2.0.22)

--- 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: 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: 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: 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: 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: _meta.json
content: ownerId: kn77md9ahr1tc0zvhm9pvda7w9822n9k
slug: openclaw-continuity
version: 2.0.21
publishedAt: 1777334319524

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-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-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/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: 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/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: 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: 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`


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/harness.md
content: # Harness

## Goal

Reproduce V2 continuity/follow-up behavior in a sandbox without mutating live state.

## Entry point

```bash
python3 scripts/followup_skill_harness.py --absence-minutes 3
```

## What it verifies

- casual vs staged vs tracked routing
- four tracked event types
- event_chain incremental update
- candidate -> incident -> hook flow
- `/new` carryover flags
- active hook / closure
- shortened absence dispatch in sandbox mode
- simulated user reply continuity
- schedule-context presence in runtime context
- frontstage-oriented continuity prompts remain structured and compact

## Recommended neutrality checks before release

Add at least one sandbox run for each of these:

- zh-TW onboarding with an explicit IANA timezone such as `Europe/Berlin`
- English onboarding with the same explicit IANA timezone
- generic offset onboarding such as `UTC+0` / `GMT+0` and confirm the stored
  value stays a generic fixed offset (for example `UTC+00:00`), not a city zone
- `/new` followed by a bare `hi` / `嗨` and confirm continuity stays thread-led
  rather than collapsing into generic time-of-day chatter

## Sandbox override

The harness uses env overrides so it can run against temporary state:

- `PERSONAL_HOOKS_DATA_DIR`
- `PERSONAL_HOOKS_MEMORY_DIR`
- `PERSONAL_HOOKS_SESSIONS_INDEX_PATH`
- `PERSONAL_HOOKS_JOBS_PATH`
- `PERSONAL_HOOKS_OPENCLAW_CONFIG_PATH`
- `PERSONAL_HOOKS_SETTINGS_PATH`

## Preconditions

- Python 3
- writable temp directory
- optional OpenClaw-compatible config if you want embedding fallback enabled

## Portable usage

The release package does not assume any specific live agent workspace.

Useful overrides:

- `PERSONAL_HOOKS_SCRIPT_PATH`
- `PERSONAL_HOOKS_OPENCLAW_CONFIG_PATH`
- `PERSONAL_HOOKS_SOURCE_DATA_DIR`
- `PERSONAL_HOOKS_SOURCE_MEMORY_DIR`
- `PERSONAL_HOOKS_SOURCE_JOBS_PATH`
- `PERSONAL_HOOKS_REPORT_DIR`
- `PERSONAL_HOOKS_TEST_SESSION_KEY`

If you do not provide source data directories, the harness seeds a clean sandbox from package defaults.

## Output

- timestamped JSON report
- case-level pass/fail
- state deltas
- carryover flags
- hook counts
- timing summaries


path: docs/host-boundary.md
content: # Host Boundary — Skill/Shared vs Host/Live

## Purpose

This document clarifies what belongs to the **public skill** (shared, portable)
and what belongs to the **host gateway / adapter** (private, instance-specific).
A public skill cannot claim it automatically guarantees channel frontstage
safety; the host must wire its own adapter hooks. See also
[`channel-boundary-stopgap.md`](channel-boundary-stopgap.md) for the minimal
channel-boundary stopgap used to suppress `<final>` leakage and weird heartbeat
text, and [`host-operator-settings.md`](host-operator-settings.md) for the
operator knobs that should be configured at the host/runtime level.

---

## Layer 1 — Skill / Shared (A-class)

These files ship with the skill and contain **no host-specific references**.

| Path | Role |
|------|------|
| `scripts/personal_hooks.py` | Frontstage guard: strips narration, enforces HEARTBEAT.md |
| `scripts/followup_skill_harness.py` | Follow-up scheduling harness |
| `scripts/web_live_runner.mjs` | Web live-check runner |
| `HEARTBEAT.md` (workspace optional) | Model-facing heartbeat instructions when the host keeps a heartbeat checklist |
| `config.schema.json` | Skill configuration schema |
| `docs/*` | Documentation |

**Rule**: No host-specific IDs, no gateway port numbers, no channel destination IDs, no agent IDs
in this layer. Temp-dir prefixes use `ph-` (personal-hooks).

---

## Layer 2 — Host / Adapter (B-class)

These files live **outside** the skill directory, in the gateway's plugin tree.
They are instance-specific and NOT part of the public skill.

| Path (relative to stateDir) | Role |
|------------------------------|------|
| `workspace/plugins/personal-hooks-host-adapter/index.ts` | Adapter: wires `message_sending` + `before_message_write` hooks |
| `openclaw.json` | Gateway config incl. `agents.defaults.heartbeat` |

**What the adapter does that the skill cannot**:

1. **`message_sending` hook** — intercepts ALL outbound (including heartbeat)
   inside `deliverOutboundPayloads`. This is the last line of defense against
   dirty narration reaching the outbound channel adapter. The skill's `personal_hooks.py` only
   runs during model generation; it cannot intercept the delivery pipeline.

2. **`before_message_write` hook** — strips session content for persistence.
   This does NOT affect outbound delivery (they are independent paths).

3. **`agents.defaults.heartbeat`** — gateway-level config required for the
   heartbeat outbound adapter to resolve. Without it, heartbeat delivery
   fails with "Outbound not configured for the selected channel".

4. **`heartbeat.isolatedSession = true`** — gateway-level config that keeps
   background heartbeat runs out of the main direct-conversation session.
   Without it, heartbeat prompts / dispatch context / internal narration can
   re-enter the same continuity store used by ordinary user replies.

5. **new-session startup bootstrap** — when a host starts a fresh `/new` or
   `/reset` session and no real human text exists yet, the host must not
   synthesize a fake user utterance just to drive startup logic. The safe
   startup path is:
   - run `runtime-context --user-text "/new" --is-new-session`
   - use trusted carryover/pending-topic state for internal bootstrap only
   - if the runtime injects a startup bootstrap block as a pseudo-`user` turn,
     treat it as internal control context, not as real human text
   - wait for the first actual human message before any `preagent-sync`
   - do **not** turn bootstrap text into fake user input
   - `/new` may still open from trusted carryover / pending-topic / tracked-followup / schedule context when the timing/safety context justifies it
   - do **not** resume pending topics on the user's behalf from fabricated user text

If a host fabricates text such as a greeting or rest phrase and feeds it into
`preagent-sync`, the skill will treat that text as real user input. This can
misfire sleep/rest suppression, erase the true continuity anchor, and distort
new-session behavior.

## Simple host/operator summary

If someone installs the public skill package, they get the shared continuity
mechanism. They do **not** automatically get every host/live integration.

The missing host pieces are usually:

- **Channel adapter / outbound last-defense**
  - the final outbound interception layer before channel delivery
- **gateway hook wiring**
  - the lifecycle hookup that makes the skill run at the right reply stages
- **live heartbeat host glue**
  - heartbeat scheduling/isolation/delivery wiring for reliable background
    follow-up

This is intentional. The public package stays portable; host/runtime integration
remains opt-in.

---

## Why the skill alone is not enough

```
Model generates reply
       │
       ▼
personal_hooks.py strips narration  ← Skill layer (A)
       │
       ▼
before_message_write persists       ← Adapter layer (B), session only
       │
       ▼
resolveHeartbeatReplyPayload        ← Gateway internal, uses RAW replyResult
       │
       ▼
deliverOutboundPayloads             ← Gateway internal
       │
       ▼
message_sending hook intercepts     ← Adapter layer (B), last defense
       │
       ▼
Channel adapter send
```

The gateway's `resolveHeartbeatReplyPayload` reads from the **raw** model
result, not the hooked session content. So even if the skill's Python guard
strips narration perfectly, a dirty heartbeat reply can still leak through
unless the adapter's `message_sending` hook catches it.

Also: if heartbeat shares the same session key as the direct user thread,
background heartbeat text can pollute carryover / staging / continuity even
when the visible outbound message is clean. The correct host fix is to set
`heartbeat.isolatedSession=true`, which moves heartbeat runs to
`<sessionKey>:heartbeat` while still allowing outbound delivery to the same user.

---

## Deployment checklist for a new host

1. Install skill to `workspace/skills/personal-hooks/`
2. Wire a host adapter at `workspace/plugins/personal-hooks-host-adapter/`
3. Configure `agents.defaults.heartbeat` in `openclaw.json`
4. Set `heartbeat.isolatedSession=true` for any host that uses background heartbeat pushes
5. Verify `message_sending` hook fires: look for `message_sending MODIFY` in gateway logs
6. Verify heartbeat delivery: check channel-delivery ACK entries after first tick
7. Verify heartbeat runs are using `:heartbeat` isolated sessions instead of the main direct session
8. Verify `<final>` tags and heartbeat narration are stripped from final outbound text
9. Verify `/new` startup does **not** invent fake user text before the first
   real human message


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/install.md
content: # Install guide

This document describes the smallest practical installation flow for `personal-hooks` in an existing OpenClaw environment.

Release posture:

- V2 public mainline package
- publication gate defined by `docs/release-acceptance.md`
- optional host glue remains opt-in

## Positioning

`personal-hooks` is:

- an OpenClaw V2 continuity/follow-up skill package
- browser/local-gateway first
- usable without one specific chat platform for its core behavior

It is not:

- a standalone app
- a single-platform package
- a replacement for the host agent's own soul/persona

## Expected directory layout

Typical workspace layout:

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

## Where to place the skill

Place the repository in your OpenClaw skills directory:

```text
/path/to/openclaw-workspace/skills/personal-hooks
```

You can either:

- copy the package into `skills/`
- symlink it into `skills/`
- install from a GitHub checkout
- install from a release ZIP
- install from an npm tarball produced by `npm pack`

Install the required Python dependency for the core skill:

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

## Copy install

```bash
cp -R /path/to/personal-hooks /path/to/openclaw-workspace/skills/personal-hooks
```

## Symlink install

```bash
ln -s /path/to/personal-hooks /path/to/openclaw-workspace/skills/personal-hooks
```

## Helper scripts

The package includes one bundled install helper:

Portable skill-only helper:

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

Supported modes:

- `link`
- `copy`

The helper only installs the skill directory into a target skills folder and prints the next commands to run. It does not modify any channel configuration.

This public V2 package does not bundle a root-level `install.sh`.
If an operator wants a workspace-wide wrapper around the portable skill,
that wrapper should live outside the portable package.

## npm tarball packaging

The repository includes a minimal `package.json` so the exact same skill folder
can be packed as an npm tarball:

```bash
npm pack
```

That tarball is still installed as an OpenClaw skill folder. It does not turn
the skill into a Node-only runtime package.

## ClawHub-style packaging

The repository also includes `_meta.json` so the package can be indexed by
ClawHub-style skill catalogs without changing the runtime payload.

## Default vs host integration

Default installation installs the **skill package only**.

It does **not** automatically:

- install a host adapter plugin
- patch gateway runtime files
- change outbound channel behavior

If you need host-side outbound filtering or channel delivery behavior, implement
and test it in the host adapter for your own deployment. This package does not
ship an installable channel adapter.

If the host uses background heartbeat pushes, also configure heartbeat session
isolation in `openclaw.json`:

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

Use `target: "direct-channel"` when you are routing to an explicit channel destination.
If you want the gateway to reuse the last external session instead, use
`target: "last"` and omit `to`.

`isolatedSession=true` keeps heartbeat background runs out of the main direct
conversation session, so the host can still push follow-ups without polluting
normal carryover/continuity state.

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.

If the host adapter contributes runtime context, keep that integration aligned
with the shared skill version. Recent public-package fixes rely on host runtime
context preserving `time_modifier_prompt` and the post-`/new`
low-information continuity guard when those surfaces are available.

### Simple rule of thumb

Installing the skill alone gives you the shared follow-up/continuity engine.

If you also want full live channel behavior, the host may still need to wire:

- **Channel adapter / outbound last-defense**
- **gateway hook wiring**
- **live heartbeat host glue**

Those pieces are host/operator work. They are not auto-applied by the portable
skill package.

## Settings path

The public package expects writable runtime state outside the package itself.

Useful runtime environment variables:

- `PERSONAL_HOOKS_DATA_DIR`
- `PERSONAL_HOOKS_MEMORY_DIR`
- `PERSONAL_HOOKS_SESSIONS_INDEX_PATH`
- `PERSONAL_HOOKS_JOBS_PATH`
- `PERSONAL_HOOKS_OPENCLAW_CONFIG_PATH`
- `PERSONAL_HOOKS_SETTINGS_PATH`

Start from:

- `examples/settings.sample.json`
- `config.schema.json`
- `docs/host-operator-settings.md`

The most common settings are:

- `followup.enabled`
- `sleep_rest_suppress.enabled`
- `sleep_rest_suppress.duration_hours`
- `carryover.enabled`
- `carryover.max_turns`
- `dispatch.cooldown_minutes`
- `dispatch.cap`
- `closure.auto_close_on_user_reply`

For deterministic first-run and guided-settings behavior, the current public
package now expects the shared skill to own these fields directly:

- onboarding:
  - `timezone`
  - `sleep_time`
  - `wake_time`
  - `relationship`
  - `use_case`
  - `new_session_continuity_mode`
  - `modality_continuity_mode`
- guided settings:
  - `proactive_chat.enabled`
  - `proactive_chat.interval_hours`
  - `proactive_chat.quiet_hours`
  - `re_engagement.mode`
  - `re_engagement.retry_after_hours`
  - `re_engagement.max_unanswered_before_park`
  - `routine_schedule.phases.active_day.interval_hours`
  - `new_session_continuity.mode`
  - `modality_continuity.mode`

`experimental.rhythm_nudge.enabled` is intentionally `false` by default and is not required for the public package.

For operator-facing knobs such as tracking lifetime, carryover depth,
routine schedule, and quiet-hours policy, see:

- `docs/host-operator-settings.md`

## Initialization

After installation:

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

## Minimal state check

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

## Minimal verification

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

The regression harness and the optional `web_live_runner.mjs` are included in
the public release package for reproducible local verification. They are test
helpers, not runtime requirements.

## Browser/local-gateway-first usage

The intended public V2 path is:

- install the skill into an OpenClaw workspace
- initialize it
- use it through the ordinary reply pipeline
- validate with browser/local gateway if desired

Channel-specific delivery is optional for package validation and is not required for the core skill behavior.

If you want channel-facing frontstage stopgap behavior, implement it in the host
adapter for your channel. The skill package itself stays portable and
channel-neutral.

## Common troubleshooting

If the skill does not initialize:

- verify the package is under the correct `skills/` directory
- verify Python 3 is available
- verify the runtime state path is writable

If capability state looks empty:

- run `init` first
- verify `PERSONAL_HOOKS_DATA_DIR` is pointing where you expect

If the harness cannot build a sandbox:

- verify your temp directory is writable
- verify optional OpenClaw config paths only if you need embedding fallback

If browser/local gateway usage does not reflect continuity state:

- verify the host OpenClaw environment is actually loading the skill from the intended `skills/` directory
- verify the ordinary reply pipeline uses the installed package, not another local copy


path: docs/live-qa-runbook.md
content: # Live QA Runbook

## Goal

Validate V2 on a real chat path after code/state integration.

## 10 manual scripts

1. parked topic
2. parked topic follow-up supplement
3. watchful state
4. delegated task
5. sensitive event
6. user reply closes active hook
7. duplicate follow-up suppression
8. dispatch cap
9. `/new` carryover
10. unrelated event does not overwrite active chain

## Expected outcomes

- staged/tracked state is observable
- carryover only appears when needed
- active hook closes on matched user reply
- duplicate or capped hooks do not keep re-firing
- different event chains stay separate
- frontstage output stays natural and does not leak internal labels

## If a script fails

Check:

- `event_kind`
- `event_chain_id`
- `source_stage`
- `followup_decision`
- `trigger_reason`
- `render_reason`
- `closure_reason`
- `continuity_source`
- `live_dispatch_result`

Primary trace file:

- `followup_trace.jsonl`

## Safe rollback

Keep a timestamped backup of:

- `scripts/personal_hooks.py`
- state files under the chosen data dir
- package-level config file

Then restore those files and rerun a minimal smoke check.


path: docs/publish-copy.md
content: # Publish Copy

Use this file as the local source for GitHub README sections, ClawHub listing
text, npm/release notes, and product-page copy.

Policy for this release: describe the **skill layer**. Do not list external chat
platforms as supported channels unless those adapters are tested separately.
Channel delivery belongs to the OpenClaw host and adapter configuration.

## Product Name

- Public product name: `OpenClaw Continuity`
- Technical package / slug: `personal-hooks`
- Current local version target: `2.0.21`
- Contact: `[REDACTED_SECRET]`
- Product illustration: `assets/publish/product-story-bilingual.svg`
- Setup wizard comic: `assets/publish/comic-01-setup-wizard.svg`
- Adjust-anytime comic: `assets/publish/comic-02-adjust-anytime.svg`
- Natural-language settings comic: `assets/publish/comic-03-natural-language-settings.svg`
- Command settings comic: `assets/publish/comic-04-command-settings.svg`

## One-Line Positioning

English:

> State-backed continuity for OpenClaw: remember the right thing, reconnect the
> right topic after `/new`, and follow up naturally with time-aware context.

中文：

> 替 OpenClaw 補上狀態支撐的延續感：記住對的事、`/new` 後接回對的主題，
> 並用時間感自然關心與追蹤。

## Short Description

English:

> A skill-layer continuity engine for OpenClaw agents. It separates ordinary
> chat, staged memory, and tracked follow-up; adds `/new` carryover, time sense,
> guided setup, natural-language settings, daily-memory writeback, and
> frontstage safety guards.

中文：

> OpenClaw 的技能層延續引擎。它會分辨一般聊天、暫存記憶與正式追蹤，
> 補上 `/new` 承接、時間感、安裝精靈、自然語言設定、每日記憶寫回與前台安全守門。

## ClawHub First Screen Copy

### English First

OpenClaw Continuity makes an OpenClaw agent feel coherent after interruption,
delay, or `/new`.

It is not a generic cron sender. It decides whether something should stay
ordinary chat, become staged memory, or become tracked follow-up. It can carry
the right thread into a new conversation, respect sleep/wake and quiet-hour
context, and let users adjust behavior by talking naturally.

### 中文接續

OpenClaw Continuity 不是單純排程發訊息。它的重點是讓 agent 記住「該記住的事」，
而不是把所有內容都塞進記憶或變成吵人的主動訊息。

它會把對話分成一般聊天、暫存記憶、正式追蹤；在 `/new` 後接回正確主題；
並且讓使用者可以用自然語言調整作息、勿擾、關心節奏與新對話承接方式。

## Minimal Telegram/TG Showcase Block

Use this only as a minimal live example, not as a channel-support matrix.

English:

> If your OpenClaw host is already connected to Telegram/TG, this skill can be
> demonstrated there through the normal host reply pipeline. Example: park a
> topic, start `/new`, and confirm the agent reconnects the selected continuity
> anchor instead of falling back to generic small talk. The Telegram adapter is
> host configuration; the continuity behavior comes from this skill.

中文：

> 如果你的 OpenClaw host 已經接好 Telegram/TG，可以用它做最小 live 展示：
> 先讓使用者說「這件事晚點再接」，再輸入 `/new`，確認 agent 接回正確承接點，
> 而不是掉回空泛閒聊。Telegram adapter 屬於 host 設定；這個技能提供的是
> continuity 行為本身。

Minimal demo script:

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

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

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

## Human-Friendly Feature List

- Remembers what still matters after a pause or new session.
- Keeps casual chat casual.
- Lets "talk later" become staged memory instead of disappearing.
- Promotes truly important items into tracked follow-up.
- Reconnects the right topic after `/new`.
- Uses routine/time context as support, not as the main thread.
- Supports quiet hours, sleep/rest suppress, cooldown, closure, dedupe, and dispatch caps.
- Lets users change settings in plain language.
- Writes concise daily-memory traces from structured state.
- Keeps internal runtime confusion out of frontstage chat.

中文：

- 中斷或新開對話後，還能接回真正重要的事。
- 一般聊天不會被硬塞成追蹤。
- 「晚點再說」可以先暫存，不會直接消失。
- 真的重要的事才升成正式追蹤。
- `/new` 後可以選擇承接方式。
- 作息與時間感只是輔助，不搶走主線。
- 支援勿擾、睡眠/休息抑制、冷卻、退場、去重與發送上限。
- 使用者可以用口語調整設定。
- 每日記憶寫回來自結構化狀態，不靠模型亂猜。
- 避免內部 runtime 訊息漏到前台。

## Complete Public Feature Checklist

- `casual_chat / staged_memory / tracked_followup` routing
- tracked categories:
  - `parked_topic`
  - `watchful_state`
  - `delegated_task`
  - `sensitive_event`
- `candidate -> incident -> hook` promotion
- structured `event_chain`
- structured `causal_memory`
- assistant commitment support
- `/new` carryover
- user-selectable `/new` continuity mode:
  - `recent_4_turns_first`
  - `last_user_intent_first`
  - `followup_focus_first`
  - `assistant_commitment_first`
  - `balanced`
- 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
- command-style settings changes
- host-neutral voice/image continuity preference
- skill/tool-layer frontstage guards
- regression harness and release acceptance checks

## Scope Boundary

Use this wording when reviewers ask whether the skill supports a specific chat
platform:

> This package is the OpenClaw skill layer. It provides continuity state,
> memory routing, `/new` carryover, setup, and safety guards. Message delivery
> depends on the OpenClaw host and its adapter configuration.

Do not advertise a channel matrix inside the skill listing.

## Setup Wizard Copy

Recommended image:

- `assets/publish/comic-01-setup-wizard.svg`

First install can guide the user through:

- timezone
- sleep time
- wake time
- relationship/use case
- proactive care preference
- quiet hours
- care tone
- tracking keywords
- new conversation carryover mode
- voice/image continuity preference when the host already supports those modalities

Natural-language examples:

```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 examples:

```bash
python3 scripts/personal_hooks.py setup-check
python3 scripts/personal_hooks.py setup-apply --payload-json '{"new_session_continuity_mode":"recent_4_turns_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"}'
```

## Install Placement Copy

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

Initialize:

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

Verify:

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

Expected:

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

## GitHub README Structure

Recommended order:

1. Product promise and bilingual comic image
2. Four quick comics:
   - setup wizard
   - adjust anytime
   - natural-language settings
   - command settings
3. "For Everyone" explanation
4. Example of `/new` continuity
5. Full feature list
6. Scope boundary
7. Setup wizard
8. Natural-language and command settings
9. Install
10. Verify
11. Technical map
12. Contact and star request

## GitHub Release Notes Draft

### English

`OpenClaw Continuity 2.0.21` focuses the public package on the portable skill
core and expands the product-facing documentation.

Highlights:

- adds user-selectable `/new` continuity modes
- adds host-neutral modality continuity preference
- makes first-install guided setup include those preference fields
- documents natural-language and command-style setting changes
- clarifies that channel delivery belongs to the OpenClaw host/adapter
- keeps public copy free of untested external-channel claims
- includes a bilingual product-story illustration and four focused setup/settings comics
- keeps the regression harness at `14/14` expected pass
- uses the official ClawHub YAML metadata format for runtime requirements

### 中文

`OpenClaw Continuity 2.0.21` 聚焦公開技能核心，並補齊產品化說明。

重點：

- 新增可選 `/new` 承接模式
- 新增 host-neutral 的語音/圖片延續偏好設定
- 第一次安裝精靈會詢問這些偏好
- 補齊自然語言與指令式設定方式
- 明確區分技能層與 OpenClaw host/adapter 的通訊交付責任
- 不在公開文案宣稱未驗證的外部渠道支援
- 加入雙語產品漫畫式示意圖
- regression harness 維持預期 `14/14` pass
- 使用 ClawHub 官方 YAML metadata 格式宣告 runtime requirements

## Star Request

English:

> If this skill helps and you want to keep improvements and maintenance moving,
> please star the GitHub repository.

中文：

> 如果這個技能對你有幫助，而且你也期待它持續優化與維護，歡迎在 GitHub
> 給一顆星，這會是最直接的支持。


path: docs/release-acceptance.md
content: # Release Acceptance

This document defines the publication gate for the public V2 package.

Pass this matrix before publishing to GitHub release ZIP, ClawHub-style catalog, npm tarball, or any mirrored package feed.

## Scope

This matrix validates the **public package**:

- shared skill
- package docs / install flow

It does **not** require mutating any live host memory or production session history.

## Test boundaries

- Prefer sandbox / harness runs first.
- Use host smoke checks only after sandbox passes.
- Do not use 60-second live heartbeat or short continuous polling on production hosts.
- Do not leave test carryover or test memory in a live user workspace.

## Required matrix

### 1. Language neutrality

Run at least:

- zh-TW onboarding + zh-TW follow-up flow
- English onboarding + English follow-up flow

Pass if:

- the package follows the current user language naturally
- locale is not forced to English when the host does not provide a locale
- internal prompt fragments do not leak into frontstage text
- ordinary user phrases are not rewritten into mixed-language artifacts

### 2. Timezone / offset neutrality

Run at least:

- explicit IANA timezone: `Europe/Berlin`
- generic offset: `UTC+0` or `GMT+0`

Pass if:

- explicit IANA timezone is preserved as given
- generic offset is stored as a generic fixed offset such as `UTC+00:00`
- generic offsets are not silently converted into a city timezone
- natural-language place names are not silently inferred unless the host config
  supplies an explicit mapping

### 3. Deterministic onboarding writeback

Run at least one onboarding case per primary language path.

Pass if these fields are written without relying on model-only classification:

- `timezone`
- `sleep_time`
- `wake_time`
- `relationship`
- `use_case`

And written destinations are correct:

- `settings.json`
- `profile.json`
- `USER.md`

### 4. Deterministic guided settings writeback

Run at least:

- proactive on/off
- proactive cadence
- quiet hours
- retry-after cadence
- stop-after unanswered count

Pass if the package writes the intended structured settings and does not depend on a host-specific hidden prompt to make the change real.

### 5. `/new` continuity

Run both:

- `/new` or equivalent new-session path
- post-new bare `hi` / `嗨`

Pass if:

- the opener reattaches to a real continuity source
- a bare greeting after `/new` stays thread-led or neutral
- the reply does not collapse into generic time-of-day chatter
- time may support the same thread, but time does not replace the thread

### 6. Frontstage cleanliness

Pass if the user-visible path does not leak:

- heartbeat narration
- runtime-context blocks
- carryover/internal control text
- structured-state labels
- mixed-language contamination created by the package itself

### 7. Host/adapter boundary

If a host adapter is used for live delivery, validate it separately from the
portable skill package.

Pass if:

- the host adapter does not force a host language by default
- the host adapter forwards `time_modifier_prompt` and low-information continuity guard when applicable
- the host adapter does not re-send internal runtime text after the skill/tool-layer guard

## Minimum release evidence

Ship with:

- one sandbox report covering routing / writeback / `/new`
- one neutrality report covering language and timezone/offset checks
- one host/adapter boundary note if live channel delivery is demonstrated

## Fail policy

Do not publish if any of these fail:

- host language is silently forced by the public package
- generic timezone offset is silently rewritten into a city timezone
- `/new` misanchors or bare `hi` / `嗨` drops the thread
- heartbeat or internal runtime text reaches the user-visible reply


path: docs/routine-schedule.md
content: # Routine Schedule (V2)

This document describes the structured `routine_schedule` settings included in the V2 feature set.

It is part of the V2 public package and remains optional.

## Purpose

`routine_schedule` provides a stable, structured source for:

- `sleep`
- `wake_window`
- `active_day`

It exists so proactive follow-up behavior does not rely only on inferred memory or log clues.

## Settings

```json
"routine_schedule": {
  "enabled": false,
  "timezone": "UTC",
  "sleep_time": "05:30",
  "wake_time": "15: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
    }
  }
}
```

## Phase precedence

Phase resolution follows this order:

1. structured `routine_schedule`
2. explicit current user state override, such as an early wake or early sleep request
3. memory/log/hook-derived wake fallback

Structured settings win over memory-derived guesses.

## Wake seed vs proactive chat

- `wake seed` is a wake-window-specific follow-up source
- it is intended for waking-up continuity, especially when a pending thread was explicitly left for after sleep
- normal `proactive_chat` is still separate and uses the recent conversation window
- wake seed uses structured `wake_time` / `wake_window_minutes` first, then falls back to memory-derived wake clues only when structured settings are unavailable

## What this does not claim

- it does not make V2 fully shipped
- it does not replace the rest of the continuity core
- it does not imply one specific delivery platform behavior
- it does not claim that rollout tuning is finished

## Operator note

If you are packaging this for a host/operator rather than a single live
instance, also read:

- `docs/host-operator-settings.md`

That document explains:

- how long staged/tracked records usually live
- which timing knobs are public settings
- how to use quiet-hours
- how to think about work-time vs rest-time behavior


path: docs/upgrade-pre-analysis.md
content: # OpenClaw Upgrade Pre-Analysis

Created: 2026-04-07
Current: v2026.3.24 (host A + host B)
Target: v2026.4.5 (not yet installed)
Status: analysis only, no upgrade executed

---

## 1. Dependency Map

### 1.1 Directory Structure (HIGH impact)

| Dependency | Code Location | Detail |
|-----------|---------------|--------|
| WORKSPACE_DIR | L25 | `SCRIPT_DIR.parents[2]` — assumes skill at `workspace/skills/*/scripts/` |
| sessions.json | L55 | `WORKSPACE_DIR.parent / "agents" / "main" / "sessions" / "sessions.json"` |
| cron/jobs.json | L56 | `WORKSPACE_DIR.parent / "cron" / "jobs.json"` |
| openclaw.json | L58 | `WORKSPACE_DIR.parent / "openclaw.json"` |
| runtime-src | L7949 | `WORKSPACE_DIR.parent / "runtime-src"` — scans `openclaw-v*` dirs |
| TypeScript sources | L7963-65 | `src/auto-reply/reply/get-reply.ts`, `get-reply-run.ts`, `src/channel/send.ts` |

All paths can be overridden via env vars (`PERSONAL_HOOKS_*`), mitigating risk.

### 1.2 Session Architecture (HIGH impact)

| Dependency | Detail |
|-----------|--------|
| Session key tokens | `:heartbeat`, `:direct:`, `:slash:`, `:cron:`, `:run:`, `:subagent:`, `:node-`, `:acp:` |
| `session_role_for_key()` | L7055-7073, classifies session as frontstage/control/unknown by key pattern |
| sessions.json schema | Root dict, key → entry dict with `sessionFile`, `updatedAt`, `lastHeartbeatText`, `lastHeartbeatSentAt` |
| Heartbeat detection | `":heartbeat" in session_key` — bypass all prompt injection |

### 1.3 Adapter Lifecycle Hooks (HIGH impact)

| CLI Command | Called By | Purpose |
|------------|-----------|---------|
| `preagent-sync` | Adapter (before model generation) | Intercept inbound user text, run decision loop |
| `runtime-context` | Adapter (prompt assembly) | Inject pending topics / preferences / schedule / setup into model context |
| `frontstage-guard` | Adapter (before outbound send) | Filter output for frontstage safety |

Adapter integration depends on:
- Anchor markers in TypeScript source (e.g. `"runtime-context"` string in `get-reply-run.ts`)
- JSON stdin/stdout contract (command → JSON output)
- Field names in runtime-context response (10+ prompt fields)

### 1.4 Heartbeat (MEDIUM impact)

| Dependency | Detail |
|-----------|--------|
| `HEARTBEAT_OK` token | Recognized as system token, early-returned from guard |
| `:heartbeat` session bypass | All prompt injection skipped for heartbeat sessions |
| `lastHeartbeatText` / `lastHeartbeatSentAt` | Read from sessions.json for dispatch awareness |
| Heartbeat isolation | Expects `heartbeat.isolatedSession=true` in openclaw.json |

### 1.5 Config Schema (MEDIUM impact)

| Path in openclaw.json | Purpose |
|----------------------|---------|
| `plugins.entries["memory-lancedb-pro"].config.embedding` | Embedding provider credentials (apiKey, model, baseURL) |
| `agents.defaults.heartbeat.*` | Heartbeat interval, isolation, directPolicy |

### 1.6 Outbound / Channel Routing (LOW impact)

| Dependency | Detail |
|-----------|--------|
| `PERSONAL_HOOKS_TARGET_CHANNEL` env var | Render channel selection |
| `PERSONAL_HOOKS_TARGET_TO` env var | Dispatch target |
| Render command output | `target_channel` field in render payload |

All parameterized via env vars — no hardcoded channel assumptions.

### 1.7 Tool / Command Interface (LOW impact)

- 30+ argparse subcommands, all output JSON to stdout
- Adapter parses specific fields from `runtime-context`, `preagent-sync`, `frontstage-guard`
- Other commands (profile-show, setup-check, etc.) are standalone utilities

---

## 2. Upgrade Blockers (evidence-based)

### BLOCKER-1: Plugin config path migration
- **Evidence**: v2026.3.22 changelog — "Plugin SDK surface is `openclaw/plugin-sdk/*`; `openclaw/extension-api` is removed"
- **Impact**: If v2026.4.x moves plugin config out of `plugins.entries`, `load_memory_lancedb_embedding_config()` (L2896) breaks
- **Bucket**: A (shared code reads this path)

### BLOCKER-2: Session key format changes
- **Evidence**: `session_role_for_key()` hardcodes 10+ token patterns
- **Impact**: If v2026.4.x changes session key naming convention, frontstage/control classification breaks → wrong content reaches wrong sessions
- **Bucket**: A (shared code)
- **Note**: No evidence v2026.4.x changes this, but no changelog available locally to confirm

### BLOCKER-3: TypeScript source path changes
- **Evidence**: `_resolve_openclaw_runtime_dir()` and `runtime_anchor_exists()` scan `src/auto-reply/reply/get-reply-run.ts`
- **Impact**: If runtime source reorganized, anchor detection fails → capability_state reports partial/missing
- **Bucket**: A (shared), but graceful degradation (returns "partial" not crash)

### BLOCKER-4: Adapter lifecycle hook invocation
- **Evidence**: Three CLI commands (`preagent-sync`, `runtime-context`, `frontstage-guard`) called by adapter at specific lifecycle points
- **Impact**: If adapter removes/renames these integration points, skill becomes inert
- **Bucket**: B (adapter patches are per-host)
- **Note**: v2026.3.24 added `before_dispatch` hook — may provide alternative integration path

---

## 3. Non-Blockers

| Item | Why not a blocker |
|------|------------------|
| Directory layout | All paths have env var overrides |
| Channel routing | Fully parameterized via env vars |
| Heartbeat timing | Config-driven, not hardcoded |
| Locale / i18n | Self-contained in skill, no OpenClaw dependency |
| Setup guide system | Fully internal, no runtime dependency |
| Dispatch awareness | Reads sessions.json but gracefully handles missing data |
| Node version floor | v2026.3.24 lowered to 22.14+; no skill impact |
| Browser changes | Skill has no browser dependency |
| Auth mode changes | Skill has no auth dependency |

---

## 4. Relevant v2026.3.22-3.24 Changes

| Change | Version | Impact on Skill |
|--------|---------|----------------|
| Plugin SDK overhaul | 3.22 | `openclaw/extension-api` removed — skill doesn't import this, but adapter patches might |
| `before_dispatch` hook | 3.24 | New lifecycle hook — potential alternative to current adapter patches |
| Cron heartbeat prompt suppression | 3.24 | Cron runs no longer read HEARTBEAT.md — aligns with skill's heartbeat isolation |
| Heartbeat direct delivery default change | 3.22→3.24 | Flipped twice — current default is `allow`; skill's heartbeat dispatch assumes this |
| Memory-lancedb proxy fix | 3.24 | Fixes proxy environments; no behavioral change for skill |
| `MEMORY.md` wins over `memory.md` | 3.22 | No impact — skill doesn't use MEMORY.md |

---

## 5. Upgrade Pre-Check Checklist

### Phase 1: Non-destructive (run before upgrade)
- [ ] Download v2026.4.5 changelog and search for BREAKING changes affecting: session key format, heartbeat, plugin config, hooks API
- [ ] Check if `plugins.entries["memory-lancedb-pro"].config.embedding` path still valid in v2026.4.5 config schema
- [ ] Check if `src/auto-reply/reply/get-reply-run.ts` still exists in v2026.4.5 runtime source
- [ ] Check if sessions.json schema unchanged (key format, field names)

### Phase 2: Isolated test (after installing v2026.4.5 alongside existing)
- [ ] `python3 personal_hooks.py init` — store initializes without error
- [ ] `python3 personal_hooks.py setup-check` — returns valid JSON
- [ ] `python3 personal_hooks.py runtime-context --session-key test --user-text "hello" --is-new-session` — returns valid JSON with all prompt fields
- [ ] `python3 personal_hooks.py frontstage-guard --text "測試訊息"` — returns valid JSON
- [ ] `python3 personal_hooks.py preagent-sync --text "測試"` — returns valid JSON
- [ ] `_resolve_openclaw_runtime_dir()` finds v2026.4.5 directory
- [ ] `load_memory_lancedb_embedding_config()` returns valid config or empty dict (not crash)

### Phase 3: Live smoke (after adapter reconnection)
- [ ] Heartbeat still fires (`:heartbeat` session created)
- [ ] `HEARTBEAT_OK` response not blocked
- [ ] `runtime-context` output injected into model prompt (check via test message)
- [ ] `frontstage-guard` still called on outbound messages
- [ ] `preagent-sync` still called on inbound messages
- [ ] Proactive dispatch still works (care_message / followup)
- [ ] Carryover summary persists across `/new`
- [ ] Dispatch awareness prompt appears in direct sessions after cron dispatch
- [ ] Setup prompt appears for unconfigured profile

### Phase 4: Regression (after 24h)
- [ ] No English template rendering (locale resolution correct)
- [ ] No frontstage guard false-positive kills
- [ ] Hook GC runs without error
- [ ] followup_trace.jsonl entries are clean

---

## 6. Recommended Test Order

1. **Phase 1 first** — zero risk, just read changelogs
2. **Phase 2 on staging** — install v2026.4.5 to a separate `runtime-src/openclaw-v2026.4.5/` directory without activating
3. **Phase 3 on one host** — upgrade the simpler host first (fewer hooks)
4. **Phase 4 wait** — 24h observation before upgrading the more complex host

---

## 7. Bucket Classification

| Layer | Bucket | Rationale |
|-------|--------|-----------|
| CLI command interface | A | Shared code, same commands everywhere |
| Session key parsing | A | Shared code in `session_role_for_key()` |
| Plugin config reading | A | Shared code in `load_memory_lancedb_embedding_config()` |
| Adapter patches (TypeScript) | B | Per-host, version-specific runtime patches |
| Manifest / locale config | B | Per-host configuration files |
| openclaw.json heartbeat config | B | Per-host config, not in skill code |
| Persona / SOUL.md | C | Not an upgrade concern |


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/v2-blueprint.md
content: # V2 Blueprint

## Positioning

This document records design-only V2 extensions that are not yet part of the
portable runtime contract.

## Candidate V2 features

- toggleable rhythm/nudge behavior
- configurable cadence/frequency
- proactive extension based on schedule and context
- optional daily-memory participation in proactive extension

## Already clarified

- outbound/channel failures should stay outside the portable skill contract
- task/continuity hooks and optional rhythm behavior should stay separate
- provider/model bottlenecks should not be misattributed to the continuity core

## Arbiter ideas for future V2 work

- rank multiple proactive sources before rendering
- keep sleep/rest suppress at highest priority
- never let operational/source-query replies reset absence
- let task/progress hooks outrank rhythm/nudge
- separate cron/info-push frontstage guards from hook/rhythm guards

## Not implemented here

This document is design-only. It is not the source of truth for shipped
runtime behavior; use `SKILL.md`, `README.md`, and the release gate docs for
that.


path: docs/v2-known-limits.md
content: # V2 Known Limits

This note lists the remaining limitations for the current V2 package.

These are not treated as structural blockers. They are the areas that may still
need patch-level tuning before or during publication review.

## Current known limits

- proactive wording can still be tuned for warmth and naturalness
- phrase lists for state switches and suppress triggers can still be tightened
- cadence thresholds may still need adjustment after more observation
- package documentation can still be smoothed and clarified

## What is not a current blocker

The following are **not** currently considered open structural blockers:

- arbiter ordering between task, wake seed, proactive, rhythm, and cron
- closure cleanliness
- proactive writeback boundary
- `/new` continuity alignment
- state cleanup after validation

## What would still block publication

If any of the following reappear, the package should not be considered
ready to freeze:

- task/progress follow-up loses to generic proactive behavior
- sleep, wake, or work/dnd phases conflict in a way that breaks routing
- proactive writes into long-term memory without stronger confirmation
- `/new` carryover misanchors or drops a tracked thread
- validation or review runs leave package contamination behind

## Practical takeaway

At this point, the package should be judged against the explicit release gate:

- tuning issues are acceptable
- structural path failures are not
- publication still requires the matrix in `docs/release-acceptance.md`


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/v2-validation-summary.md
content: # V2 Validation Summary

This summary consolidates the baseline validation evidence for the current V2 public mainline.

## Validation layers

### 1. Arbiter validation

- local validation run completed successfully
- result: `6/6 pass`

Validated:

- task hook beats proactive/rhythm
- rest suppress blocks dialog-style proactive sources
- operational/source-query does not reset the absence anchor
- proactive can win when no higher-priority source exists
- proactive beats rhythm when both are eligible
- cron/info yields according to the current collision strategy

### 2. Topic / summary validation

- local validation run completed successfully
- result: `4/4 pass`

Validated:

- task-like recent windows get a real topic label
- emotional/open-thread windows get a meaningful continuity summary
- weak windows fall back to light continuity instead of fake specificity
- trace/debug output is readable

### 3. Writeback validation

- local validation run completed successfully
- result: `4/4 pass`

Validated:

- proactive dispatch does not automatically pollute daily memory
- watchful/open-thread paths defer writeback until stronger confirmation
- weak-context proactive follow-up does not write
- user return after proactive raises eligibility in a controlled way

### 4. Routine / phase validation

- local validation run completed successfully
- result: `7/7 pass`

Validated:

- structured `sleep / wake_window / active_day` phase switching
- sleep phase blocks general proactive behavior
- wake window opens the wake-seed path
- active-day phase re-enables ordinary cadence
- explicit early wake can override the expected wake point
- memory fallback still works when no formal schedule exists

### 5. Internal near-live validation

- local near-live validation run completed successfully
- result: `5/5 pass`

Validated:

- task-like follow-up is owned by the task/progress line
- open-thread proactive follow-up is natural and topic-aware
- work/dnd suppress pauses general proactive behavior and can be cleared naturally
- sleep/wake transitions work with wake-seed routing
- `/new` continuity and `new_session_carryover_applied` now stay aligned

## Residual risk level

Residual issues are currently assessed as **patch-level**, not structural:

- wording refinement
- phrase-list tuning
- cadence threshold tuning
- documentation polish

## Cleanliness check

The package was rechecked for:

- private test terms
- live workspace paths
- personal identifiers
- session id / channel destination residue

No blocker-level public contamination remained after cleanup.

## What this summary does not replace

This summary is not the publication gate by itself.

Before release, also run the explicit matrix in [release-acceptance.md](release-acceptance.md), especially for:

- language neutrality
- timezone / generic-offset neutrality
- `/new` followed by bare `hi` / `嗨`
- host/adapter boundary checks when live channel delivery is demonstrated


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/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: 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/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: 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: references/modules.md
content: # V2 module map

## Core

- continuity routing
- staged memory promotion
- tracked follow-up
- carryover
- closure consistency
- frontstage safety

## State layer

- `profile.json`
- `user_model.json`
- `emotion_state.json`
- `persona_state.json`
- `memory_rank.json`
- `autonomy_state.json`
- `settings.json`

## Event layer

- `candidate_buffer.json`
- `incidents.json`
- `hooks.json`
- `session_memory_staging.json`

## Trace layer

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

## Public V2 tracked event types

- `parked_topic`
- `watchful_state`
- `delegated_task`
- `sensitive_event`

## Optional and host-boundary items

- host delivery concerns
- provider/network transport concerns
- optional rhythm or nudge layers


path: references/templates.md
content: # Frontstage notes

This file stays intentionally small. V2 does not own persona design; it protects continuity-facing frontstage behavior and keeps internal reasoning out of user-visible text.

## V2 frontstage rules

- keep frontstage text in natural Traditional Chinese
- avoid exposing hook, schedule, checker, runtime, or internal-source language to the user
- avoid unresolved fragments that require the user to guess what `這個` or `that` refers to
- avoid false closure when no concrete continuity anchor exists
- apply sleep/rest suppress only to follow-up behavior, not to ordinary user-facing replies

## V2 follow-up tone boundaries

- `emotional_followup`: accompany first, ask lightly
- `health_followup`: check condition gently, do not sound like generic health advice
- `progress_followup`: continue a parked task or topic without sounding like a manager reminder
- `tomorrow_check`: light next-step reminder with human warmth

## Optional note

Rhythm or nudge variants belong to optional layers. They are not part of the public V2 default unless a host explicitly enables and supports them.


path: requirements.txt
content: send2trash>=1.8.0


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/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/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: 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/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: 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": []
}


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