# ChatEngine Refactor — God Object 拆分 + Prompt 統一

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** 拆分 1084 行的 `engine/chat.py` God Object 為 5 個職責明確的模組，並統一 `prompt/assembler.py` 的兩條重複路徑。

**Architecture:** 將 ChatEngine 的 6 個職責（LLM client、attitude 管理、message 持久化、summarization、context 組裝、chat 流程）拆成獨立的 manager 類別，ChatEngine 只做 orchestration。Prompt assembler 提取共用 section builders 消除重複。

**Tech Stack:** Python 3.11+, motosan-ai, motosan-chat, pytest, pytest-asyncio

---

## File Structure

### New Files

| File | Responsibility |
|------|---------------|
| `kokoro_chat/engine/llm.py` | LLM client factory + bridge classes (`_UsageTrackingAiClient`, `_StreamingMotosanAiClient`, `_NullChannel`, `_StreamingChannel`) |
| `kokoro_chat/engine/attitude_manager.py` | `AttitudeManager` — load (cache→store→decay), save, trigger_event |
| `kokoro_chat/engine/message_manager.py` | `MessageManager` — get_for_prompt, add_message, build_few_shot_prefix |
| `kokoro_chat/engine/summarizer.py` | `Summarizer` — turn counting, LLM-based rolling summary |
| `kokoro_chat/prompt/sections.py` | Shared section builders (character, traits, speech, anti-patterns, etc.) |
| `tests/test_llm.py` | Tests for LLM factory + bridge classes |
| `tests/test_attitude_manager.py` | Tests for AttitudeManager |
| `tests/test_message_manager.py` | Tests for MessageManager |
| `tests/test_summarizer.py` | Tests for Summarizer |
| `tests/test_sections.py` | Tests for shared section builders |

### Modified Files

| File | Change |
|------|--------|
| `kokoro_chat/engine/chat.py` | 從 1084 行 → ~300 行。移除所有 helper classes 和 private methods，改為組合 managers |
| `kokoro_chat/prompt/assembler.py` | 從 586 行 → ~250 行。`assemble_static_system_prompt` 和 `assemble_system_prompt` 都呼叫 `sections.py` builders |
| `tests/test_assembler.py` | 更新 import paths，部分 section-level 測試移到 `test_sections.py` |

### Unchanged Files

`engine/background.py`, `prompt/budget.py`, `prompt/formatter.py`, `attitude/`, `character/`, `lorebook/`, `memory/`, `tools/`, `storage/`, `config.py`, `providers/`

---

## Task 1: 提取 `prompt/sections.py` — 共用 section builders

**Files:**
- Create: `kokoro_chat/prompt/sections.py`
- Create: `tests/test_sections.py`
- Modify: `kokoro_chat/prompt/assembler.py`

This is the foundation — no other task depends on engine changes, but Tasks 5 depends on this.

- [ ] **Step 1: 建立 `sections.py` 並寫測試**

從 `assembler.py` 提取以下重複邏輯為獨立函數：

```python
# kokoro_chat/prompt/sections.py
"""Shared prompt section builders.

Used by both assemble_static_system_prompt and assemble_system_prompt
to eliminate duplication.
"""
from __future__ import annotations

from kokoro_chat.character.model import Character


def build_character_section(character: Character) -> list[str]:
    """Build [角色設定] + optional [外觀] sections."""
    sections = []
    if character.description:
        sections.append(f"[角色設定]\n你是「{character.name}」。\n{character.description}")
    else:
        char_section = (
            f"[角色設定]\n"
            f"你是「{character.name}」。\n"
            f"性格：{character.personality}\n"
            f"背景：{character.background}\n"
            f"說話風格：{character.speaking_style}\n"
            f"MBTI：{character.mbti}"
        )
        sections.append(char_section)
        if character.appearance:
            sections.append(f"[外觀]\n{character.appearance}")
    return sections


def build_personality_summary_section(character: Character) -> str | None:
    """Build [性格摘要] section. Returns None if empty."""
    if character.personality_summary:
        return f"[性格摘要]\n{character.personality_summary}"
    return None


def build_traits_section(character: Character) -> str | None:
    """Build [特質] section from likes/dislikes/fears/quirks."""
    lines = []
    if character.likes:
        lines.append(f"喜歡的事物：{', '.join(character.likes)}")
    if character.dislikes:
        lines.append(f"討厭的事物：{', '.join(character.dislikes)}")
    if character.fears:
        lines.append(f"害怕的事物：{', '.join(character.fears)}")
    if character.quirks:
        lines.append(f"小癖好：{', '.join(character.quirks)}")
    return "[特質]\n" + "\n".join(lines) if lines else None


def build_speech_section(
    character: Character, current_mood: str | None = None,
) -> str | None:
    """Build [語癖] section. V2 mood-based > V2 default > legacy."""
    from kokoro_chat.prompt.assembler import _select_speech_pattern
    text = _select_speech_pattern(
        character.speech_patterns_v2,
        character.speech_patterns,
        current_mood or character.default_mood,
    )
    return f"[語癖]\n{text}" if text else None


def build_anti_patterns_section(character: Character) -> str | None:
    """Build [不要這樣說] section from anti_patterns list."""
    if not character.anti_patterns:
        return None
    lines = []
    for ap in character.anti_patterns:
        bad = ap.get("bad", "")
        correct = ap.get("correct", "")
        why = ap.get("why", "")
        if bad and correct:
            line = f"\u2717 不要說：「{bad}」\n\u2713 應該說：「{correct}」"
            if why:
                line += f"\n  原因：{why}"
            lines.append(line)
    return "[不要這樣說]\n" + "\n---\n".join(lines) if lines else None


def build_mes_example_section(
    character: Character, max_examples: int = 1,
) -> str | None:
    """Build [對話風格示例] section from mes_example."""
    if not character.mes_example:
        return None
    examples = character.mes_example[:max_examples]
    lines = []
    for ex in examples:
        user_text = str(ex.get("user", "")).strip()
        char_text = str(ex.get("char", "")).strip()
        if user_text and char_text:
            lines.append(f"用戶：{user_text}\n{character.name}：{char_text}")
    return "[對話風格示例]\n" + "\n---\n".join(lines) if lines else None


def build_chat_mode_section(chat_mode: str, chat_modes: dict[str, str]) -> str:
    """Build [回覆模式] section."""
    instruction = chat_modes.get(chat_mode, chat_modes.get("short", ""))
    return f"[回覆模式]\n{instruction}"


def build_scene_section(
    scene: str | None, scenario: str | None,
) -> str | None:
    """Build [當前場景] section. scene param > character.scenario."""
    effective = scene or scenario
    return f"[當前場景]\n{effective}" if effective else None


def build_core_character_sections(
    character: Character,
    chat_mode: str,
    chat_modes: dict[str, str],
    scene: str | None = None,
    current_mood: str | None = None,
    max_mes_examples: int = 1,
) -> list[str]:
    """Build all static/core character sections in order.

    This is the single source of truth for character prompt sections,
    used by both static and full assembly paths.
    """
    sections: list[str] = []

    sections.extend(build_character_section(character))

    for builder in (
        lambda: build_personality_summary_section(character),
        lambda: build_traits_section(character),
        lambda: build_speech_section(character, current_mood),
        lambda: build_mes_example_section(character, max_mes_examples),
        lambda: build_anti_patterns_section(character),
    ):
        section = builder()
        if section:
            sections.append(section)

    sections.append(build_chat_mode_section(chat_mode, chat_modes))

    scene_section = build_scene_section(scene, character.scenario)
    if scene_section:
        sections.append(scene_section)

    return sections
```

- [ ] **Step 2: 寫 `tests/test_sections.py`**

測試每個 builder function：`build_character_section` (V2 vs legacy), `build_traits_section` (empty/partial/full), `build_anti_patterns_section`, `build_speech_section`, `build_mes_example_section`, `build_core_character_sections` (integration)。

```bash
cd ~/Projects/wade/kokoro-chat && uv run pytest tests/test_sections.py -v
```

- [ ] **Step 3: 重構 `assembler.py` 使用 `sections.py`**

將 `assemble_static_system_prompt` 和 `assemble_system_prompt` 中重複的 character/traits/speech/anti-patterns 邏輯替換為 `build_core_character_sections()` 呼叫。

注意：`_select_speech_pattern` 移到 `sections.py`，`assembler.py` import 它。

- [ ] **Step 4: 跑全部 assembler 測試確認無破壞**

```bash
cd ~/Projects/wade/kokoro-chat && uv run pytest tests/test_assembler.py tests/test_sections.py -v
```

- [ ] **Step 5: Commit**

```bash
git add kokoro_chat/prompt/sections.py tests/test_sections.py kokoro_chat/prompt/assembler.py
git commit -m "refactor(prompt): extract shared section builders to sections.py"
```

---

## Task 2: 提取 `engine/llm.py` — LLM client factory + bridge classes

**Files:**
- Create: `kokoro_chat/engine/llm.py`
- Create: `tests/test_llm.py`
- Modify: `kokoro_chat/engine/chat.py`

- [ ] **Step 1: 建立 `engine/llm.py`**

從 `chat.py` 移出以下 4 個 class + 1 個 factory：

```python
# kokoro_chat/engine/llm.py
"""LLM client construction and bridge classes.

Bridges motosan-ai Client to motosan-chat AgentLoop interface.
No sqlalchemy/fastapi/redis imports allowed.
"""
from __future__ import annotations

import asyncio
import logging
from typing import Any, AsyncIterator

from motosan_ai import Client as AiClient
from motosan_chat import StreamChunk

from kokoro_chat.config import ChatConfig

logger = logging.getLogger(__name__)

_STREAM_SENTINEL = object()


class NullChannel:
    """No-op channel for non-streaming API mode."""
    # ... (move existing _NullChannel code, remove underscore prefix)


class StreamingChannel:
    """Channel that pushes streaming chunks into an asyncio.Queue."""
    # ... (move existing _StreamingChannel code)


class UsageTrackingAiClient:
    """Wrap motosan-ai client and accumulate usage."""
    # ... (move existing _UsageTrackingAiClient code)


class StreamingMotosanAiClient:
    """Custom LLM client with usage tracking and prompt-cache support."""
    # ... (move existing _StreamingMotosanAiClient code)


def build_ai_client(
    provider: str, model: str, config: ChatConfig,
) -> AiClient | None:
    """Build an AiClient for the given provider.

    Returns None if credentials are missing.
    """
    provider = provider.lower()
    if provider == "anthropic":
        api_key = config.anthropic_api_key or config.anthropic_oauth_token
        if not api_key:
            logger.warning("No Anthropic credential")
            return None
        return AiClient.anthropic(api_key=api_key, model=model)
    if provider == "openai":
        if not config.openai_api_key:
            logger.warning("No OpenAI key")
            return None
        return AiClient.openai(api_key=config.openai_api_key, model=model)
    if provider == "minimax":
        if not config.minimax_api_key:
            logger.warning("No MiniMax key")
            return None
        return AiClient.minimax(api_key=config.minimax_api_key, model=model)
    logger.warning("Unknown provider '%s'", provider)
    return None
```

- [ ] **Step 2: 寫 `tests/test_llm.py`**

測試 `build_ai_client` (各 provider + missing credentials + unknown provider), `UsageTrackingAiClient` (token accumulation), `StreamingChannel` (queue push + tool annotation filtering)。

```bash
cd ~/Projects/wade/kokoro-chat && uv run pytest tests/test_llm.py -v
```

- [ ] **Step 3: 更新 `chat.py` import 從 `engine/llm.py`**

刪除 `chat.py` 中的 4 個 helper classes + `_build_ai_client` + `_get_chat_llm_client` + `_get_memory_llm_client`。改為：

```python
from kokoro_chat.engine.llm import (
    STREAM_SENTINEL,
    NullChannel,
    StreamingChannel,
    UsageTrackingAiClient,
    StreamingMotosanAiClient,
    build_ai_client,
)
```

ChatEngine `__init__` 改用 `build_ai_client()`。

- [ ] **Step 4: 跑全部測試**

```bash
cd ~/Projects/wade/kokoro-chat && uv run pytest -x -q
```

- [ ] **Step 5: Commit**

```bash
git add kokoro_chat/engine/llm.py tests/test_llm.py kokoro_chat/engine/chat.py
git commit -m "refactor(engine): extract LLM client factory and bridges to llm.py"
```

---

## Task 3: 提取 `engine/attitude_manager.py`

**Files:**
- Create: `kokoro_chat/engine/attitude_manager.py`
- Create: `tests/test_attitude_manager.py`
- Modify: `kokoro_chat/engine/chat.py`

- [ ] **Step 1: 建立 `attitude_manager.py`**

```python
# kokoro_chat/engine/attitude_manager.py
"""AttitudeManager — load, save, and decay attitude state.

Encapsulates the cache → store → decay → save flow.
"""
from __future__ import annotations

import json
import logging

from kokoro_chat.attitude.state import AttitudeState
from kokoro_chat.storage.protocol import AttitudeStore, CacheStore

logger = logging.getLogger(__name__)


class AttitudeManager:
    """Manage attitude state with cache-first reads and write-through."""

    def __init__(self, store: AttitudeStore, cache: CacheStore) -> None:
        self._store = store
        self._cache = cache

    async def load(self, char_id: str, user_id: str) -> AttitudeState:
        """Load attitude: cache → store → new. Applies time decay if needed."""
        cached = await self._cache.get_attitude(user_id, char_id)
        if cached:
            return self._from_dict(char_id, user_id, cached)

        record = await self._store.get_or_create(user_id, char_id)
        att = AttitudeState(char_id, user_id)
        att.affection = record.affection
        att.trust = record.trust
        att.mood = record.mood
        att.attitude_modifiers = self._parse_modifiers(record.modifiers_json)

        if att.apply_time_decay():
            await self._persist(att)

        await self._cache_attitude(att)
        return att

    async def save(self, attitude: AttitudeState) -> None:
        """Write-through save to store + cache."""
        await self._persist(attitude)
        await self._cache_attitude(attitude)

    def _from_dict(self, char_id: str, user_id: str, data: dict) -> AttitudeState:
        att = AttitudeState(char_id, user_id)
        att.affection = data.get("affection", 50)
        att.trust = data.get("trust", 50)
        att.mood = data.get("mood", "neutral")
        att.attitude_modifiers = self._parse_modifiers(data.get("modifiers_json", "[]"))
        return att

    async def _persist(self, att: AttitudeState) -> None:
        modifiers_json = json.dumps(att.attitude_modifiers, ensure_ascii=False)
        await self._store.save(
            att.user_id, att.character_id,
            affection=att.affection, trust=att.trust,
            mood=att.mood, modifiers_json=modifiers_json,
        )

    async def _cache_attitude(self, att: AttitudeState) -> None:
        await self._cache.set_attitude(att.user_id, att.character_id, {
            "affection": att.affection,
            "trust": att.trust,
            "mood": att.mood,
            "modifiers_json": json.dumps(att.attitude_modifiers, ensure_ascii=False),
        })

    @staticmethod
    def _parse_modifiers(raw: str | None) -> list[dict]:
        try:
            return json.loads(raw or "[]")
        except (json.JSONDecodeError, TypeError):
            return []
```

- [ ] **Step 2: 寫 `tests/test_attitude_manager.py`**

用 mock store/cache 測試：cache hit, cache miss with store, time decay triggered, save write-through, invalid modifiers_json。

```bash
cd ~/Projects/wade/kokoro-chat && uv run pytest tests/test_attitude_manager.py -v
```

- [ ] **Step 3: 更新 `chat.py` 使用 `AttitudeManager`**

刪除 `_get_attitude` 和 `_save_attitude`。ChatEngine `__init__` 建立 `self._attitude_mgr = AttitudeManager(attitude_store, cache)`。所有呼叫改為 `self._attitude_mgr.load()` / `self._attitude_mgr.save()`。

- [ ] **Step 4: 跑全部測試**

```bash
cd ~/Projects/wade/kokoro-chat && uv run pytest -x -q
```

- [ ] **Step 5: Commit**

```bash
git add kokoro_chat/engine/attitude_manager.py tests/test_attitude_manager.py kokoro_chat/engine/chat.py
git commit -m "refactor(engine): extract AttitudeManager from ChatEngine"
```

---

## Task 4: 提取 `engine/message_manager.py`

**Files:**
- Create: `kokoro_chat/engine/message_manager.py`
- Create: `tests/test_message_manager.py`
- Modify: `kokoro_chat/engine/chat.py`

- [ ] **Step 1: 建立 `message_manager.py`**

```python
# kokoro_chat/engine/message_manager.py
"""MessageManager — conversation history retrieval and persistence."""
from __future__ import annotations

import logging

from kokoro_chat.character.model import Character
from kokoro_chat.config import ChatConfig
from kokoro_chat.storage.protocol import (
    CacheStore,
    ChatHistoryStore,
    ChatMessageWriter,
)

logger = logging.getLogger(__name__)


class MessageManager:
    """Manage conversation messages with cache-first reads."""

    def __init__(
        self,
        history: ChatHistoryStore,
        writer: ChatMessageWriter,
        cache: CacheStore,
        config: ChatConfig,
    ) -> None:
        self._history = history
        self._writer = writer
        self._cache = cache
        self._config = config

    async def get_for_prompt(
        self, char_id: str, user_id: str,
    ) -> tuple[str, list[dict]]:
        """Return (rolling_summary_text, recent_messages) with cache fallback."""
        limit = self._config.recent_turns * 2

        cached_msgs = await self._cache.get_recent_messages(user_id, char_id, limit=limit)
        if cached_msgs is not None:
            recent = cached_msgs
        else:
            chat_msgs = await self._history.get_recent(user_id, char_id, limit=limit)
            recent = [{"role": m.role, "content": m.content} for m in chat_msgs]
            if recent:
                await self._cache.set_recent_messages(user_id, char_id, recent)

        cached_summary = await self._cache.get_rolling_summary(user_id, char_id)
        if cached_summary is not None:
            summary_text = cached_summary.get("summary", "")
        else:
            from kokoro_chat.storage.protocol import RollingSummaryStore
            # Note: summary store is accessed via Summarizer, not here.
            # Return empty summary; Summarizer handles the rest.
            summary_text = ""

        return summary_text, recent

    async def add_message(
        self, char_id: str, user_id: str,
        role: str, content: str, chat_mode: str = "short",
    ) -> None:
        """Write message to store + append to cache."""
        await self._writer.add_message(
            user_id=user_id, character_id=char_id,
            role=role, content=content, chat_mode=chat_mode,
        )
        await self._cache.append_message(user_id, char_id, role, content)

    @staticmethod
    def build_few_shot_prefix(character: Character) -> list[dict]:
        """Convert mes_example to Claude message format."""
        if not character.mes_example:
            return []
        prefix: list[dict] = []
        for ex in character.mes_example:
            user_text = str(ex.get("user", "")).strip()
            char_text = str(ex.get("char", "")).strip()
            if user_text:
                prefix.append({"role": "user", "content": user_text})
            if char_text:
                prefix.append({"role": "assistant", "content": char_text})
        return prefix
```

- [ ] **Step 2: 寫 `tests/test_message_manager.py`**

測試 `get_for_prompt` (cache hit, cache miss), `add_message` (store + cache), `build_few_shot_prefix` (empty, valid)。

```bash
cd ~/Projects/wade/kokoro-chat && uv run pytest tests/test_message_manager.py -v
```

- [ ] **Step 3: 更新 `chat.py` 使用 `MessageManager`**

刪除 `_get_messages_for_prompt`, `_add_message`, `_build_few_shot_prefix`。ChatEngine `__init__` 建立 `self._msg_mgr = MessageManager(chat_history, chat_writer, cache, config)`。

- [ ] **Step 4: 跑全部測試**

```bash
cd ~/Projects/wade/kokoro-chat && uv run pytest -x -q
```

- [ ] **Step 5: Commit**

```bash
git add kokoro_chat/engine/message_manager.py tests/test_message_manager.py kokoro_chat/engine/chat.py
git commit -m "refactor(engine): extract MessageManager from ChatEngine"
```

---

## Task 5: 提取 `engine/summarizer.py`

**Files:**
- Create: `kokoro_chat/engine/summarizer.py`
- Create: `tests/test_summarizer.py`
- Modify: `kokoro_chat/engine/chat.py`

- [ ] **Step 1: 建立 `summarizer.py`**

```python
# kokoro_chat/engine/summarizer.py
"""Summarizer — rolling conversation summary with turn-based triggers."""
from __future__ import annotations

import logging
from typing import Any, Callable, Awaitable

from kokoro_chat.config import ChatConfig, calculate_cost
from kokoro_chat.storage.protocol import (
    CacheStore,
    ChatHistoryStore,
    RollingSummaryStore,
    UsageLogStore,
)

logger = logging.getLogger(__name__)

# Type: async (messages, model, max_tokens, system?) -> response with .content, .usage
AiChatFn = Callable[..., Awaitable[Any]]


class Summarizer:
    """Manages rolling summaries with configurable turn interval."""

    def __init__(
        self,
        summary_store: RollingSummaryStore,
        history_store: ChatHistoryStore,
        cache: CacheStore,
        ai_chat_fn: AiChatFn,
        config: ChatConfig,
        usage_log: UsageLogStore | None = None,
    ) -> None:
        self._summary_store = summary_store
        self._history = history_store
        self._cache = cache
        self._ai_chat = ai_chat_fn
        self._config = config
        self._usage_log = usage_log

    async def maybe_summarize(
        self, char_id: str, user_id: str, character_name: str,
    ) -> None:
        """Increment turn count and summarize if threshold reached."""
        cached = await self._cache.get_rolling_summary(user_id, char_id)
        if cached is not None:
            turn_count = cached.get("turn_count", 0)
            current_summary = cached.get("summary", "")
        else:
            record = await self._summary_store.get_or_create(user_id, char_id)
            turn_count = record.turn_count
            current_summary = record.summary or ""

        turn_count += 1

        if turn_count > 0 and turn_count % self._config.summarize_every == 0:
            current_summary = await self._do_summarize(
                char_id, user_id, character_name,
                current_summary, turn_count,
            )

        await self._summary_store.update(user_id, char_id, current_summary, turn_count)
        await self._cache.set_rolling_summary(user_id, char_id, current_summary, turn_count)

    async def get_summary(self, char_id: str, user_id: str) -> str:
        """Get current rolling summary text."""
        cached = await self._cache.get_rolling_summary(user_id, char_id)
        if cached is not None:
            return cached.get("summary", "")
        record = await self._summary_store.get_or_create(user_id, char_id)
        return record.summary or ""

    async def _do_summarize(
        self, char_id: str, user_id: str, character_name: str,
        existing_summary: str, turn_count: int,
    ) -> str:
        """Compress older messages into a rolling summary via LLM."""
        chat_msgs = await self._history.get_recent(user_id, char_id, limit=40)
        if len(chat_msgs) < 10:
            return existing_summary

        to_compress = chat_msgs[:-10]
        dialogue_text = "\n".join(
            f"{'用戶' if m.role == 'user' else character_name}：{m.content}"
            for m in to_compress[-20:]
        )
        prompt = (
            "請將以下對話歷史壓縮成一段簡潔的摘要（200字以內）。\n"
            "保留重要的：事件、承諾、情感變化、關係進展、用戶提到的個人資訊。\n\n"
            + (f"之前的摘要：{existing_summary}\n\n" if existing_summary else "")
            + f"需要壓縮的對話：\n{dialogue_text}\n\n"
            "只輸出摘要，不要其他內容。"
        )

        try:
            response = await self._ai_chat(
                [{"role": "user", "content": prompt}],
                model=self._config.memory_llm_model,
                max_tokens=300,
            )
            new_summary = response.content.strip()

            if self._usage_log:
                cost = calculate_cost(
                    self._config.memory_llm_model,
                    response.usage.input_tokens, response.usage.output_tokens,
                )
                await self._usage_log.log(
                    user_id=user_id, model=self._config.memory_llm_model,
                    input_tokens=response.usage.input_tokens,
                    output_tokens=response.usage.output_tokens,
                    cost_usd=cost, task_type="summarize", character_id=char_id,
                )

            await self._summary_store.update(user_id, char_id, new_summary, turn_count)
            await self._cache.set_rolling_summary(user_id, char_id, new_summary, turn_count)

            recent_msgs = [{"role": m.role, "content": m.content} for m in chat_msgs[-10:]]
            await self._cache.set_recent_messages(user_id, char_id, recent_msgs)

            logger.info("Rolling summary updated (%d messages compressed)", len(to_compress))
            return new_summary
        except Exception as e:
            logger.warning("Summarization failed: %s", e, exc_info=True)
            return existing_summary
```

- [ ] **Step 2: 寫 `tests/test_summarizer.py`**

測試 `maybe_summarize` (below threshold, at threshold triggers LLM), `get_summary` (cache hit, miss), `_do_summarize` (<10 messages returns existing)。Mock `ai_chat_fn`。

```bash
cd ~/Projects/wade/kokoro-chat && uv run pytest tests/test_summarizer.py -v
```

- [ ] **Step 3: 更新 `chat.py` 使用 `Summarizer`**

刪除 `_maybe_summarize`, `_do_summarize`。ChatEngine `__init__` 建立 `self._summarizer = Summarizer(...)`。

同時更新 `MessageManager.get_for_prompt` 讓它接受 `Summarizer` 來取得 summary：

```python
async def get_for_prompt(self, char_id, user_id, summarizer):
    ...
    summary_text = await summarizer.get_summary(char_id, user_id)
    return summary_text, recent
```

- [ ] **Step 4: 跑全部測試**

```bash
cd ~/Projects/wade/kokoro-chat && uv run pytest -x -q
```

- [ ] **Step 5: Commit**

```bash
git add kokoro_chat/engine/summarizer.py tests/test_summarizer.py kokoro_chat/engine/chat.py kokoro_chat/engine/message_manager.py
git commit -m "refactor(engine): extract Summarizer from ChatEngine"
```

---

## Task 6: 精簡 `chat.py` — 只保留 orchestration

**Files:**
- Modify: `kokoro_chat/engine/chat.py`

- [ ] **Step 1: 重寫 `ChatEngine.__init__`**

```python
class ChatEngine:
    def __init__(
        self,
        config: ChatConfig,
        *,
        attitude_store: AttitudeStore,
        cache: CacheStore,
        chat_history: ChatHistoryStore,
        chat_writer: ChatMessageWriter,
        memory_store: MemoryVectorStore,
        memo_store: MemoStore,
        rolling_summary_store: RollingSummaryStore,
        event_store: EventStore,
        usage_log: UsageLogStore | None = None,
        create_tools_fn: Any = None,
        story_engine: Any = None,
    ):
        self.config = config
        self._memory_store = memory_store
        self._memo_store = memo_store
        self._event_store = event_store
        self._create_tools_fn = create_tools_fn
        self.story_engine = story_engine

        # Compose managers
        self._attitude_mgr = AttitudeManager(attitude_store, cache)
        self._msg_mgr = MessageManager(chat_history, chat_writer, cache, config)

        # LLM clients
        self.chat_llm = build_ai_client(config.chat_provider, config.chat_model, config)
        self.memory_llm = build_ai_client(config.memory_provider, config.memory_model, config)
        if self.chat_llm is None:
            self.memory_llm = None
        elif self.memory_llm is None:
            self.memory_llm = self.chat_llm

        self._summarizer = Summarizer(
            rolling_summary_store, chat_history, cache,
            self._ai_chat, config, usage_log,
        )
        self._usage_log = usage_log
        self._background_tasks: set[asyncio.Task] = set()
        self._bg_semaphore = asyncio.Semaphore(config.bg_task_concurrency)
```

- [ ] **Step 2: 清理 `chat.py` 中已提取的方法**

確認以下方法已從 `chat.py` 刪除（在 Task 2-5 中逐步完成）：
- `_NullChannel`, `_StreamingChannel`, `_UsageTrackingAiClient`, `_StreamingMotosanAiClient` → `llm.py`
- `_build_ai_client`, `_get_chat_llm_client`, `_get_memory_llm_client` → `llm.py`
- `_get_attitude`, `_save_attitude` → `attitude_manager.py`
- `_get_messages_for_prompt`, `_add_message`, `_build_few_shot_prefix` → `message_manager.py`
- `_maybe_summarize`, `_do_summarize` → `summarizer.py`

保留在 `chat.py` 的方法：
- `__init__`, `_ai_chat`, `_log_usage`
- `_run_background_tasks`
- `_prepare_slim_chat_context`, `_prepare_chat_context`
- `chat`, `chat_stream`, `get_attitude`, `trigger_event`

ChatEngine 現在應該 ~300 行。

- [ ] **Step 3: 驗證所有 public API 行為不變**

```bash
cd ~/Projects/wade/kokoro-chat && uv run pytest -x -q
```

Expected: 354+ tests all pass（加上新增的 manager 測試）。

- [ ] **Step 4: Commit**

```bash
git add kokoro_chat/engine/chat.py
git commit -m "refactor(engine): slim ChatEngine to orchestration-only (~300 lines)"
```

---

## Task 7: 更新文檔

**Files:**
- Modify: `CLAUDE.md`
- Modify: `AGENTS.md`
- Modify: `README.md`

- [ ] **Step 1: 更新 Architecture sections**

更新 `CLAUDE.md` 和 `README.md` 中的 Architecture 樹狀圖：

```
kokoro_chat/engine/
├── chat.py              ← ChatEngine (orchestration only, ~300 lines)
├── llm.py               ← LLM client factory + bridge classes
├── attitude_manager.py  ← AttitudeManager (cache → store → decay)
├── message_manager.py   ← MessageManager (history + save + few-shot)
├── summarizer.py        ← Summarizer (turn counting + LLM compression)
└── background.py        ← Background task prompts + parsers

kokoro_chat/prompt/
├── assembler.py    ← System prompt assembly (static + full, uses sections.py)
├── sections.py     ← Shared section builders (single source of truth)
├── budget.py       ← Token budget constants + estimator
└── formatter.py    ← Conversation history formatting
```

- [ ] **Step 2: 更新 AGENTS.md Key Types**

更新 ChatEngine section，加入 manager 類別簡介。

- [ ] **Step 3: Commit**

```bash
git add CLAUDE.md AGENTS.md README.md
git commit -m "docs: update architecture after engine refactor"
```

---

## Execution Order

```
Task 1 (sections.py)  ──→  Task 6 (slim chat.py)  ──→  Task 7 (docs)
Task 2 (llm.py)       ──┘         ↑
Task 3 (attitude_mgr) ──────────┘
Task 4 (message_mgr)  ──────────┘
Task 5 (summarizer)    ──────────┘
```

Tasks 1-5 可以獨立進行，Task 6 整合所有改動，Task 7 更新文檔。
Tasks 2-5 之間無依賴，可以平行執行。
