Skill: zeron-g/meeting-assistant-eva (version 1.0.0)

--- name: meeting-assistant description: "Zoom/Teams/Meet 远程会议AI助手。Use when: user asks to join a meeting, monitor a meeting, record a meeting, assist in a medical consultation, help doctor-patient communication, transcribe meeting audio, analyze meeting screenshots, send chat in meeting, or provide real-time meeting assistance. The agent joins the meeting autonomously as a bot, performs real-time speech-to-text, vision analysis via Claude AI, and participants can communicate with the agent directly via meeting chat." --- # Meeting Assistant — 远程会议智能助手 让 Agent 作为 AI 参会者加入 Zoom / Teams / Google Meet，实时分析会议内容并与参会者双向交互。 ## 架构总览 ``` ┌─────────────────┐ │ 你（用户） │ │ 在会议聊天发消息 │ └────────┬────────┘ │ 会议聊天（双向） ┌────────▼────────┐ │ Vexa Bot │ ← AI 机器人，会议参与者 │ (Docker 容器) │ └────────┬────────┘ │ Vexa REST API ┌────────▼────────┐ │ meeting_bot.py │ ← 主控程序 │ 双线程运行: │ │ • 聊天轮询(5s) │ → 读聊天 → Claude → 回答 │ • 分析循环(30s)│ → 截图+转录 → Claude → 建议 └────────┬────────┘ │ ┌────────▼────────┐ │ claude_client │ ← Anthropic API │ • 图像分析 │ │ • 转录理解 │ │ • 聊天问答 │ │ • 会议摘要 │ └─────────────────┘ ``` **用户交互方式**：用户在 Zoom/Teams/Meet 的会议聊天中输入问题或命令，Bot 通过 Claude AI 生成回答并发回聊天。整个过程无需其他工具，完全在会议内进行。 --- ## 快速开始 ### 前置条件 ```bash # 1. 启动 Vexa Bot 服务（Docker） cd skills/meeting-assistant docker compose up -d # 2. 设置 Claude API Key export ANTHROPIC_API_KEY="sk-ant-..." # 3. 安装 Python 依赖 pip install anthropic requests ``` ### 方式 A：通过 agent_tool.py（推荐，Claude Code 调用） ```bash PYTHON="D:/program/codesupport/anaconda/envs/meeting-assistant/python.exe" # 加入会议并启动 AI 助手（后台运行） $PYTHON scripts/agent_tool.py start "https://zoom.us/j/123456789?pwd=xxx" --mode general # 查看状态 $PYTHON scripts/agent_tool.py status # 向会议聊天发消息 $PYTHON scripts/agent_tool.py chat "大家好，我是 AI 助手" # 获取当前转录 $PYTHON scripts/agent_tool.py transcript # 获取截图 $PYTHON scripts/agent_tool.py screenshot # 查看助手日志 $PYTHON scripts/agent_tool.py logs # 停止助手（自动生成摘要后离开） $PYTHON scripts/agent_tool.py stop ``` ### 方式 B：通过 meeting_bot.py（直接运行） ```bash PYTHON="D:/program/codesupport/anaconda/envs/meeting-assistant/python.exe" # 全自动 AI 助手模式（加入 + 分析 + 聊天） $PYTHON scripts/meeting_bot.py assist "https://zoom.us/j/123456789" --mode medical --interval 30 # 单独命令 $PYTHON scripts/meeting_bot.py join "https://zoom.us/j/123456789" $PYTHON scripts/meeting_bot.py chat "这是发给会议的消息" $PYTHON scripts/meeting_bot.py read-chat $PYTHON scripts/meeting_bot.py transcript $PYTHON scripts/meeting_bot.py screenshot $PYTHON scripts/meeting_bot.py status $PYTHON scripts/meeting_bot.py leave ``` ### 方式 C：本地监控（无需 Vexa） ```bash $PYTHON scripts/meeting_monitor.py start --mode medical --interval 30 $PYTHON scripts/meeting_monitor.py snapshot $PYTHON scripts/meeting_monitor.py transcript --last 5 $PYTHON scripts/meeting_monitor.py stop ``` --- ## 与用户的聊天交互 Bot 加入会议后，用户可以在**会议自带的聊天框**中直接和 AI 交流： | 用户在聊天中输入 | Bot 响应 | |---------------|---------| | `刚才那个药名是什么？` | Claude 基于转录内容回答 | | `帮我总结一下刚才讨论的` | Claude 分析转录并总结 | | `@助手 这个症状正常吗？` | Claude 给出信息性解释（附免责声明） | | `？什么是高血压` | Claude 用通俗语言解释 | **触发条件**（满足任一即回应）： - 包含 `OpenClaw` / `助手` / `AI` 等关键词 - 包含 `?` 或 `？` - 医疗模式下包含医疗关键词 - 以 `/`、`!`、`请问`、`请` 开头 --- ## 实时分析循环 助手运行两个并发循环： ### 聊天轮询（每 5 秒） ``` 读取会议聊天 → 检测新用户消息 → Claude 生成回答 → 发回聊天 ``` ### 分析循环（每 30 秒，可调） ``` 截图 → 获取转录增量 → Claude 视觉+文本分析 → 若有建议则发聊天 → 保存日志 ``` 分析结束后自动调用 Claude 生成完整会议摘要（`meeting_summary.md`）。 --- ## 医疗辅助模式 (`--mode medical`) 启用后额外功能： - 自动识别医学术语 → 通俗解释 → 发到聊天 - 检测处方/诊断/用药信息 → 用 ⚠️ 标记 - 患者版简明摘要（去除专业术语） - 医学术语对照表（中英文） - 复诊/随访提醒 参考：`references/medical-terms-guide.md` --- ## 作为 Anthropic Tool Use 使用 ```python import anthropic from scripts.agent_tool import tool_definitions, handle_tool_call client = anthropic.Anthropic() # 让 Claude 决定是否加入会议 response = client.messages.create( model="claude-sonnet-4-6", tools=tool_definitions(), messages=[{ "role": "user", "content": "帮我加入这个会议并用医疗模式监控：https://zoom.us/j/123?pwd=xxx" }] ) # 处理 tool_use 块 for block in response.content: if block.type == "tool_use": result = handle_tool_call(block.name, block.input) print(result) ``` ### 可用工具 | Tool Name | 功能 | |-----------|------| | `meeting_start` | 加入会议并启动 AI 助手 | | `meeting_stop` | 停止助手并生成摘要 | | `meeting_status` | 查看当前状态 | | `meeting_send_chat` | 向会议聊天发消息 | | `meeting_get_transcript` | 获取转录文本 | | `meeting_screenshot` | 截取会议画面 | | `meeting_read_chat` | 读取聊天记录 | | `meeting_get_summary` | 获取会议摘要 | | `meeting_get_logs` | 查看调试日志 | --- ## 配置 (`config.json`) ```json { "claude": { "api_key": "", // 或设置 ANTHROPIC_API_KEY 环境变量 "model": "claude-sonnet-4-6", "max_tokens": 1024 }, "chat_poll_interval": 5, // 聊天轮询间隔（秒） "bot": { "vexa_url": "http://localhost:8056", "vexa_api_key": "", "bot_name": "OpenClaw 助手" }, "medical": { "enabled": false } } ``` --- ## 输出文件结构 ``` recordings/bot_20260313_143000/ ├── session.json # 会话元数据（bot_id, 平台, 开始时间等） ├── screenshots/ # 截图（screen_HHMMSS.png） ├── analysis/ │ └── cycle_log.jsonl # 每次分析循环的详细记录 ├── transcript_log.json # 完整转录记录（JSON 数组） ├── suggestions_log.json # Bot 发送的所有聊天消息记录 └── meeting_summary.md # AI 生成的完整会议摘要 ``` --- ## 支持的会议平台 | 平台 | URL 格式 | |------|---------| | Zoom | `https://zoom.us/j/123456789?pwd=xxx` | | Microsoft Teams | `https://teams.microsoft.com/l/meetup-join/...` | | Google Meet | `https://meet.google.com/abc-defg-hij` | --- ## 与其他 Skill 联动 - **pc-control**: 截屏增强 + 会议窗口控制 - **agent-browser-core**: 会议中查询资料 - **eva-tts**: 将分析结果语音播报 参考：`references/integration-guide.md` --- ## 注意事项 - 录音/录制前需获得所有参会者同意 - 医疗信息严格保密，建议加密存储录制文件 - Bot 模式需要 Docker 运行 Vexa 服务 - Vexa 的 Zoom 支持需要 Zoom Marketplace OAuth 凭证 - Claude API Key 建议通过环境变量设置，不要写入 config.json

path: README.md
content: # Meeting Assistant — Developer Documentation

AI-powered meeting bot that joins Google Meet / Zoom as a participant, captures audio and chat, provides real-time transcription, and lets you interact with a Claude AI agent directly through the meeting chat.

## Table of Contents

- [Architecture Overview](architecture.md)
- [Setup & Installation](setup.md)
- [Agent Usage Guide](agent-usage.md)
- [API Reference](api-reference.md)
- [Troubleshooting](troubleshooting.md)

## Quick Start

```bash
# 1. Start infrastructure
cd skills/meeting-assistant
docker compose -f <compose-file> up -d

# 2. Open a Google Meet and share the link with the agent
# 3. Agent joins and you can chat with it inside the meeting
```

## Current Status (as of 2026-03-14)

| Feature | Status | Notes |
|---|---|---|
| Google Meet join | ✅ Working | Bot joins as browser participant |
| Zoom join | ❌ Not working | `vexa-lite` image lacks proprietary Zoom SDK binaries |
| Chat send/receive | ✅ Working | Full UTF-8 Chinese/English support |
| Participant detection | ✅ Working | Bot can see participant list in real-time |
| Audio recording | ✅ Working | WAV files recorded per session |
| Microphone transcription | ✅ Working (pipeline verified) | Requires active mic audio; screen share audio not captured |
| Screen share video capture | ❌ Not available | Vexa only captures audio, not video frames |
| Bot screen share (output) | ✅ Available | Bot can present images/URLs/HTML to meeting |
| Claude AI chat replies | ✅ Implemented | Responds to questions, mentions, `?` messages |
| Meeting summary | ✅ Implemented | Generated post-meeting with Claude |

## Project Structure

```
meeting-assistant/
├── config.json              # Runtime configuration
├── docker-compose.yml       # Vexa infrastructure (source of truth)
├── SKILL.md                 # Claude Code skill descriptor
├── docs/                    # This documentation
│   ├── README.md
│   ├── architecture.md
│   ├── setup.md
│   ├── agent-usage.md
│   ├── api-reference.md
│   └── troubleshooting.md
├── scripts/
│   ├── meeting_bot.py       # Vexa API wrapper + AI assistant loop
│   ├── claude_client.py     # Claude API integration
│   ├── agent_tool.py        # High-level tool interface for Claude Code
│   ├── launch.py            # One-click startup script
│   ├── whisper_proxy.py     # HTTP proxy: rewrites model=default → correct name
│   ├── audio_capture.py     # Local audio capture (non-bot mode)
│   ├── transcribe.py        # Whisper transcription utilities
│   └── meeting_analyzer.py  # Analysis utilities
├── recordings/              # Session data (audio, screenshots, transcripts)
└── references/              # Integration guides
```


path: config.json
content: {
  "conda_env": {
    "name": "meeting-assistant",
    "python": "D:/program/codesupport/anaconda/envs/meeting-assistant/python.exe",
    "whisper": "D:/program/codesupport/anaconda/envs/meeting-assistant/Scripts/whisper.exe",
    "scripts_dir": "D:/program/codesupport/anaconda/envs/meeting-assistant/Scripts",
    "ffmpeg": "D:/program/codesupport/anaconda/envs/meeting-assistant/Library/bin/ffmpeg.exe"
  },
  "docker": {
    "exe": "C:/Program Files/Docker/Docker/resources/bin/docker.exe",
    "compose_plugin": true
  },

  "screenshot_interval": 30,
  "audio_chunk_minutes": 5,
  "whisper_model": "base",
  "whisper_language": "zh",
  "mode": "general",
  "output_base_dir": "recordings",

  "audio": {
    "sample_rate": 16000,
    "channels": 1,
    "format": "wav",
    "device": "default"
  },

  "screenshot": {
    "scale": 0.5,
    "quality": 70,
    "use_pc_control": true
  },

  "medical": {
    "enabled": false,
    "terminology_explanation": true,
    "prescription_highlight": true,
    "patient_summary": true,
    "languages": ["zh", "en"]
  },

  "bot": {
    "vexa_url": "http://localhost:8056",
    "vexa_api_key": [REDACTED_SECRET],
    "bot_name": "OpenClaw 助手",
    "auto_transcribe": true,
    "auto_record": true
  },

  "claude": {
    "_comment": "Set api_key here OR use ANTHROPIC_API_KEY environment variable (recommended)",
    "api_key": "",
    "model": "claude-sonnet-4-6",
    "max_tokens": 1024,
    "analysis_interval_cycles": 1
  },

  "chat_poll_interval": 5,

  "integration": {
    "pc_control_port": 18888,
    "tts_url": "http://localhost:9001/v1",
    "whisper_device": "cpu"
  }
}


path: docker-compose.yml
content: services:
  postgres:
    image: postgres:16-alpine
    container_name: vexa-postgres
    environment:
      - POSTGRES_USER=vexa
      - POSTGRES_PASSWORD=vexa_pass
      - POSTGRES_DB=vexa
    volumes:
      - vexa_pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U vexa"]
      interval: 5s
      timeout: 3s
      retries: 5
    restart: unless-stopped

  whisper:
    image: fedirz/faster-whisper-server:latest-cpu
    container_name: vexa-whisper
    # Not exposed to host — accessed only via whisper-proxy
    environment:
      - WHISPER__MODEL=Systran/faster-whisper-base
      - WHISPER__LANGUAGE=zh
    restart: unless-stopped

  # Proxy required: Vexa sends model=default but faster-whisper-server needs
  # a valid model name. This proxy rewrites the multipart form body.
  whisper-proxy:
    image: python:3.11-slim
    container_name: vexa-whisper-proxy
    ports:
      - "8000:8000"
    volumes:
      # NOTE: Docker cannot mount WSL paths. Copy scripts/whisper_proxy.py to
      # a Windows path first:
      #   copy .\scripts\whisper_proxy.py C:\Users\zeron\AppData\Local\Temp\
      - C:/Users/zeron/AppData/Local/Temp/whisper_proxy.py:/proxy.py:ro
    command: python3 /proxy.py
    depends_on:
      - whisper
    restart: unless-stopped

  vexa:
    image: vexaai/vexa-lite:latest
    container_name: vexa-meeting-bot
    ports:
      - "8056:8056"
    environment:
      - DATABASE_URL=postgresql://vexa:vexa_pass@postgres:5432/vexa
      - ADMIN_API_TOKEN=openclaw-meeting-bot
      - TRANSCRIPTION_ENABLED=true
      - TRANSCRIBER_URL=http://whisper-proxy:8000/v1/audio/transcriptions
      - REMOTE_TRANSCRIBER_URL=http://whisper-proxy:8000/v1/audio/transcriptions
      - TRANSCRIBER_API_KEY=openclaw-key
      - REMOTE_TRANSCRIBER_API_KEY=openclaw-key
      - ZOOM_CLIENT_ID=YZXafYz5STiVV3qbh2Sh0w
      - ZOOM_CLIENT_SECRET=IlvPhToAqWorTeW3qLLNUTnF9I1ItxUs
    depends_on:
      postgres:
        condition: service_healthy
      whisper-proxy:
        condition: service_started
    restart: unless-stopped

volumes:
  vexa_pgdata:


path: docs/README.md
content: # Meeting Assistant — Developer Documentation

AI-powered meeting bot that joins Google Meet / Zoom as a participant, captures audio and chat, provides real-time transcription, and lets you interact with a Claude AI agent directly through the meeting chat.

## Table of Contents

- [Architecture Overview](architecture.md)
- [Setup & Installation](setup.md)
- [Agent Usage Guide](agent-usage.md)
- [API Reference](api-reference.md)
- [Troubleshooting](troubleshooting.md)

## Quick Start

```bash
# 1. Start infrastructure
cd skills/meeting-assistant
docker compose -f <compose-file> up -d

# 2. Open a Google Meet and share the link with the agent
# 3. Agent joins and you can chat with it inside the meeting
```

## Current Status (as of 2026-03-14)

| Feature | Status | Notes |
|---|---|---|
| Google Meet join | ✅ Working | Bot joins as browser participant |
| Zoom join | ❌ Not working | `vexa-lite` image lacks proprietary Zoom SDK binaries |
| Chat send/receive | ✅ Working | Full UTF-8 Chinese/English support |
| Participant detection | ✅ Working | Bot can see participant list in real-time |
| Audio recording | ✅ Working | WAV files recorded per session |
| Microphone transcription | ✅ Working (pipeline verified) | Requires active mic audio; screen share audio not captured |
| Screen share video capture | ❌ Not available | Vexa only captures audio, not video frames |
| Bot screen share (output) | ✅ Available | Bot can present images/URLs/HTML to meeting |
| Claude AI chat replies | ✅ Implemented | Responds to questions, mentions, `?` messages |
| Meeting summary | ✅ Implemented | Generated post-meeting with Claude |

## Project Structure

```
meeting-assistant/
├── config.json              # Runtime configuration
├── docker-compose.yml       # Vexa infrastructure (source of truth)
├── SKILL.md                 # Claude Code skill descriptor
├── docs/                    # This documentation
│   ├── README.md
│   ├── architecture.md
│   ├── setup.md
│   ├── agent-usage.md
│   ├── api-reference.md
│   └── troubleshooting.md
├── scripts/
│   ├── meeting_bot.py       # Vexa API wrapper + AI assistant loop
│   ├── claude_client.py     # Claude API integration
│   ├── agent_tool.py        # High-level tool interface for Claude Code
│   ├── launch.py            # One-click startup script
│   ├── whisper_proxy.py     # HTTP proxy: rewrites model=default → correct name
│   ├── audio_capture.py     # Local audio capture (non-bot mode)
│   ├── transcribe.py        # Whisper transcription utilities
│   └── meeting_analyzer.py  # Analysis utilities
├── recordings/              # Session data (audio, screenshots, transcripts)
└── references/              # Integration guides
```


path: docs/agent-usage.md
content: # Agent Usage Guide

This document describes how to use the Meeting Assistant as a Claude Code agent.
The agent joins meetings autonomously, monitors activity, and communicates via meeting chat.

## How It Works

Claude Code IS the agent — no separate AI service is needed. The workflow:

1. You open a Google Meet and share the link with Claude Code
2. Claude Code calls the Vexa API to send the bot into the meeting
3. The bot ("OpenClaw Assistant") joins as a participant
4. You can talk to the bot directly through the meeting's chat panel
5. Claude Code polls chat every 5 seconds and responds with AI analysis

## Joining a Meeting

### Google Meet (Recommended)

```
Give Claude Code the meeting URL:
  https://meet.google.com/xxx-xxxx-xxx

Claude Code will:
  1. Verify Vexa is running
  2. Send POST /bots to join
  3. Wait for status: active
  4. Send a welcome message in the chat
```

### Zoom (Not Currently Working)
Zoom requires native SDK binaries not included in `vexaai/vexa-lite`.
See [Troubleshooting](troubleshooting.md#zoom-sdk-missing).

## Chatting with the Bot

Once the bot is in the meeting, send messages in the Google Meet chat panel.
The bot responds to messages that match any of these triggers:

| Trigger | Example | Bot Action |
|---|---|---|
| Contains `?` or `？` | `What was decided just now?` | AI answer |
| Mentions bot name | `OpenClaw, summarize` | AI response |
| Contains `助手` | `助手你好` | AI response |
| Starts with `/` | `/summary` | AI command |
| Starts with `!` or `！` | `!help` | AI command |
| Starts with `请` or `请问` | `请问这个怎么处理` | AI response |
| Medical keywords (medical mode) | `剂量` `处方` | AI explanation |

Messages from other participants that don't match triggers are silently logged
for context in the analysis loop.

## Analysis Loop

Every 30 seconds (configurable), the bot runs an analysis cycle:

1. **Screenshot** — Attempts to capture meeting screen (currently limited by Vexa)
2. **Transcript delta** — Fetches new speech segments since last cycle
3. **Claude analysis** — If there's new content, Claude analyzes it
4. **Chat suggestion** — If Claude has something useful to share, it sends to chat

## Claude Code Tool Definitions

When used as a Claude Code skill, the following tools are available via `agent_tool.py`:

| Tool | Description |
|---|---|
| `meeting_start` | Join a meeting URL and start the assistant |
| `meeting_stop` | Leave the meeting and generate summary |
| `meeting_status` | Get current bot and meeting status |
| `meeting_send_chat` | Send a chat message to the meeting |
| `meeting_get_transcript` | Get transcript segments |
| `meeting_screenshot` | Attempt to get a meeting screenshot |
| `meeting_read_chat` | Read all chat messages |
| `meeting_get_summary` | Get session summary (post-meeting) |
| `meeting_get_logs` | Get session analysis logs |

## Direct API Usage (Claude Code calling Vexa directly)

Claude Code can also call the Vexa API directly without the Python wrapper.
All calls use the user token in `X-API-Key` header.

### Join a Meeting
```python
import requests

VEXA = "http://localhost:8056"
KEY  = [REDACTED_SECRET]

resp = requests.post(f"{VEXA}/bots",
    headers={"X-API-Key": KEY},
    json={
        "platform": "google_meet",
        "native_meeting_id": "pmn-eatb-cyf",   # from URL: meet.google.com/{id}
        "bot_name": "OpenClaw Assistant",
        "language": "zh"
    })
bot = resp.json()
print(f"Bot created: id={bot['id']}, status={bot['status']}")
```

### Send Chat Message
```python
# IMPORTANT: field name is "text", NOT "message"
requests.post(f"{VEXA}/bots/google_meet/pmn-eatb-cyf/chat",
    headers={"X-API-Key": KEY, "Content-Type": "application/json; charset=utf-8"},
    data=json.dumps({"text": "你好！我是 OpenClaw 助手。"}, ensure_ascii=False).encode("utf-8"))
```

### Read Chat
```python
resp = requests.get(f"{VEXA}/bots/google_meet/pmn-eatb-cyf/chat",
    headers={"X-API-Key": KEY})
messages = resp.json().get("messages", [])
for msg in messages:
    print(f"{msg['sender']}: {msg['text']}")
```

### Get Transcript
```python
resp = requests.get(f"{VEXA}/transcripts/google_meet/pmn-eatb-cyf",
    headers={"X-API-Key": KEY})
data = resp.json()
for seg in data.get("segments", []):
    print(f"{seg['speaker']}: {seg['text']}")
```

### Leave Meeting
```python
requests.delete(f"{VEXA}/bots/google_meet/pmn-eatb-cyf",
    headers={"X-API-Key": KEY})
```

### Check Bot Status
```python
resp = requests.get(f"{VEXA}/bots/status", headers={"X-API-Key": KEY})
print(resp.json())  # {"running_bots": [...]}
```

## Meeting URL Parsing

The `MeetingBot._parse_meeting_url()` method handles these formats:

| Platform | URL Format | Extracted ID |
|---|---|---|
| Google Meet | `https://meet.google.com/abc-defg-hij` | `abc-defg-hij` |
| Zoom | `https://zoom.us/j/123456789` | `123456789` |
| Zoom (with password) | `https://zoom.us/j/123?pwd=abc` | `123` (passcode stored separately) |
| Teams | `https://teams.microsoft.com/l/meetup-join/...` | full URL |

## Operating Modes

### General Mode (default)
- Standard meeting assistance
- Responds to questions about meeting content
- Summarizes key points
- Tracks decisions and action items

### Medical Mode
```python
assistant.start(url, mode="medical")
```
- Additional focus on medical terminology
- Flags prescriptions, diagnoses, dosages
- Multilingual medical term explanation (zh/en)
- Stricter response triggers for medical keywords

## Session Data

Each session creates a directory under `recordings/bot_{timestamp}/`:

```
recordings/bot_20260314_040142/
├── session.json           # Metadata (URL, mode, start/end time)
├── transcript_log.json    # All transcript segments captured
├── suggestions_log.json   # All messages sent by bot
├── screenshots/           # Screen captures (if available)
└── analysis/
    └── cycle_log.jsonl    # Per-cycle analysis log (JSON Lines)
```

## Configuration

Key settings in `config.json`:

```json
{
  "chat_poll_interval": 5,          // seconds between chat polls
  "screenshot_interval": 30,        // seconds between analysis cycles
  "bot": {
    "vexa_url": "http://localhost:8056",
    "vexa_api_key": [REDACTED_SECRET],
    "bot_name": "OpenClaw 助手",
    "auto_transcribe": true
  },
  "claude": {
    "model": "claude-sonnet-4-6",
    "max_tokens": 1024,
    "analysis_interval_cycles": 1   // analyze every N cycles
  },
  "whisper_language": "zh"
}
```


path: docs/api-reference.md
content: # API Reference

## Vexa REST API

Base URL: `http://localhost:8056`
Authentication: `X-[REDACTED_SECRET]`

**Critical**: Use `X-API-Key` header, NOT `Authorization: Bearer`.

---

### Bots

#### POST /bots — Join Meeting
```json
// Request
{
  "platform": "google_meet",        // "google_meet" | "zoom" | "teams"
  "native_meeting_id": "abc-def-ghi", // extracted from meeting URL
  "bot_name": "OpenClaw Assistant",
  "language": "zh"                  // transcription language hint
}

// Response 201
{
  "id": 7,
  "platform": "google_meet",
  "native_meeting_id": "pmn-eatb-cyf",
  "status": "requested",            // → joining → awaiting_admission → active | failed
  "bot_container_id": "c724c8b7",
  "created_at": "2026-03-14T04:35:00Z"
}
```

Status lifecycle: `requested → joining → awaiting_admission → active → completed | failed`

#### DELETE /bots/{platform}/{native_meeting_id} — Leave Meeting
```
DELETE /bots/google_meet/pmn-eatb-cyf
Response 200: {"message": "Stop request accepted and is being processed."}
```

#### GET /bots/status — List Running Bots
```json
// Response 200
{"running_bots": []}
```

---

### Meetings

#### GET /meetings — List All Meetings
```json
// Response 200
{
  "meetings": [{
    "id": 7,
    "platform": "google_meet",
    "native_meeting_id": "pmn-eatb-cyf",
    "status": "active",
    "start_time": "2026-03-14T04:35:00Z",
    "end_time": null,
    "data": {
      "recordings": [...],
      "status_transition": [...]
    }
  }]
}
```

---

### Chat

#### POST /bots/{platform}/{id}/chat — Send Chat Message
```json
// Request — IMPORTANT: field is "text", NOT "message"
{"text": "Hello from the bot!"}

// Response 202
{"message": "Chat message sent", "meeting_id": 7}
```

**Encoding**: Always send as UTF-8. Use Python `requests` with
`json.dumps(..., ensure_ascii=False).encode("utf-8")` for Chinese text.
PowerShell `Invoke-WebRequest` garbles non-ASCII unless explicitly encoded.

#### GET /bots/{platform}/{id}/chat — Read Chat Messages
```json
// Response 200
{
  "messages": [
    {
      "sender": "Zero G",
      "text": "你好",
      "timestamp": 1773460975107,
      "is_from_bot": false
    },
    {
      "sender": "OpenClaw Assistant",
      "text": "你好！",
      "timestamp": 1773461026833,
      "is_from_bot": true
    }
  ],
  "meeting_id": 7
}
```

---

### Transcripts

#### GET /transcripts/{platform}/{id} — Get Transcript
```json
// Response 200
{
  "id": 7,
  "platform": "google_meet",
  "status": "active",
  "start_time": "2026-03-14T04:35:00Z",
  "segments": [
    {
      "speaker": "Zero G",
      "text": "这是测试",
      "created_at": "2026-03-14T04:36:00Z"
    }
  ],
  "recordings": [{
    "id": 470635713526,
    "status": "in_progress",
    "media_files": [{
      "type": "audio",
      "format": "wav",
      "duration_seconds": 180.6,
      "file_size_bytes": 5780584
    }]
  }]
}
```

**Note**: `segments` is populated only when a participant's **microphone** is active
and audio is transcribed by Whisper. Screen-share system audio is NOT captured.

---

### Recordings

#### GET /recordings — List Recordings
```json
{
  "recordings": [{
    "id": 470635713526,
    "meeting_id": 7,
    "status": "in_progress",
    "media_files": [{
      "id": 531961809228,
      "type": "audio",
      "format": "wav",
      "duration_seconds": 180.6,
      "file_size_bytes": 5780584
    }]
  }]
}
```

#### GET /recordings/{id}/media/{media_id}/download — Get Download URL
```json
{
  "download_url": "/recordings/.../raw",
  "filename": "audio.wav",
  "content_type": "audio/wav",
  "file_size_bytes": 5780584
}
```

#### GET /recordings/{id}/media/{media_id}/raw — Download Raw File
Returns binary audio file (WAV). Only available for recordings in current container run.

---

### Screen (Bot Output Only)

#### POST /bots/{platform}/{id}/screen — Bot Presents Content to Meeting
```json
// Request
{
  "type": "url",       // "url" | "image" | "html"
  "url": "https://example.com",
  "start_share": true
}
```
This makes the **bot** share its screen to the meeting. It does NOT capture
what the user is sharing.

#### DELETE /bots/{platform}/{id}/screen — Stop Bot's Screen Share

---

### Voice/TTS

#### POST /bots/{platform}/{id}/speak — Bot Speaks in Meeting
```json
{"text": "Hello, I have an important update."}
```
Triggers TTS so the bot speaks aloud in the meeting.

---

## Whisper API (via Proxy)

Base URL: `http://localhost:8000`
(proxied through `whisper-proxy` container which rewrites model parameter)

#### POST /v1/audio/transcriptions
```
Content-Type: multipart/form-data

file=<audio.wav>
model=default          ← proxy rewrites this to Systran/faster-whisper-base
language=zh            ← optional
```

Response:
```json
{"text": "转录的文字内容"}
```

---

## Python Client Classes

### `MeetingBot`
Location: `scripts/meeting_bot.py`

```python
from scripts.meeting_bot import MeetingBot

bot = MeetingBot()           # loads config.json automatically
bot.join("https://meet.google.com/abc-def-ghi")
bot.send_chat("Hello!")
messages = bot.read_chat()
segments = bot.get_transcript()
bot.leave()
```

Key methods:
| Method | Description |
|---|---|
| `join(url, bot_name=None, language=None)` | Parse URL + POST /bots |
| `leave()` | DELETE /bots/{platform}/{id} |
| `status()` | GET /bots/status |
| `send_chat(message)` | POST /bots/{p}/{id}/chat with `{"text": message}` |
| `read_chat()` | GET /bots/{p}/{id}/chat → list of messages |
| `get_transcript(since=None)` | GET /transcripts/{p}/{id} → segments list |
| `get_full_transcript_text()` | Formatted string of all transcript entries |

### `MeetingAssistantLoop`
Location: `scripts/meeting_bot.py`

```python
from scripts.meeting_bot import MeetingBot, MeetingAssistantLoop

bot = MeetingBot()
assistant = MeetingAssistantLoop(bot)
assistant.start("https://meet.google.com/abc-def-ghi", mode="general", interval=30)
# Blocks until KeyboardInterrupt or assistant.stop()
```

### `MeetingClaudeClient`
Location: `scripts/claude_client.py`

```python
from scripts.claude_client import MeetingClaudeClient

client = MeetingClaudeClient(config)
analysis = client.analyze_meeting_state(
    screenshot_path="/path/to/screen.png",
    transcript_entries=[{"speaker": "A", "text": "..."}],
    mode="general"
)
# Returns: {"analysis": "...", "chat_message": "..." or None}

reply = client.respond_to_chat(
    user_message="What was decided?",
    sender="Zero G",
    context_screenshot=None,
    mode="general"
)
# Returns: str reply text

summary = client.generate_final_summary(session_dir, mode="general")
# Returns: markdown summary string
```

### `MeetingAssistantTool`
Location: `scripts/agent_tool.py`

High-level tool interface for use as a Claude Code tool:

```python
from scripts.agent_tool import MeetingAssistantTool

tool = MeetingAssistantTool()
print(tool.tool_definitions())  # returns Anthropic tool_use format

result = tool.handle_tool_call("meeting_start", {
    "meeting_url": "https://meet.google.com/abc-def-ghi",
    "mode": "general"
})
```


path: docs/architecture.md
content: # Architecture Overview

## System Diagram

```
┌─────────────────────────────────────────────────────────────────┐
│                        Windows Host                              │
│                                                                  │
│  ┌──────────────────┐     ┌───────────────────────────────────┐ │
│  │   Claude Code    │     │         Docker Desktop             │ │
│  │  (the Agent)     │     │                                   │ │
│  │                  │     │  ┌─────────────┐  ┌────────────┐ │ │
│  │  meeting_bot.py  │◄────┼──┤ vexa-meeting│  │vexa-whisper│ │ │
│  │  claude_client.py│     │  │    -bot     │  │  -proxy    │ │ │
│  │  agent_tool.py   │     │  │  :8056      │  │  :8000     │ │ │
│  └──────────────────┘     │  └──────┬──────┘  └─────┬──────┘ │ │
│                            │         │               │         │ │
│                            │  ┌──────▼──────┐  ┌────▼──────┐ │ │
│                            │  │ vexa-postgres│  │vexa-whisper│ │ │
│                            │  │   :5432      │  │  :8000    │ │ │
│                            │  └─────────────┘  └───────────┘ │ │
│                            └───────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
                                      │
                               Internet (HTTPS)
                                      │
                         ┌────────────▼────────────┐
                         │    Google Meet Server    │
                         │  (or Zoom, Teams, etc.)  │
                         └─────────────────────────┘
                                      │
                              Meeting participants
                                      │
                              ┌───────▼───────┐
                              │  Your browser │
                              │  (Zero G)     │
                              └───────────────┘
```

## Components

### 1. Vexa Meeting Bot (`vexaai/vexa-lite`)
- Docker container running a headless Chromium browser
- Joins meetings as a named participant ("OpenClaw Assistant")
- Captures participant microphone audio as WAV files
- Sends/receives meeting chat messages
- Provides REST API on port 8056
- Uses PostgreSQL for state persistence and Redis (internal) for streams

### 2. Whisper Transcription Server (`fedirz/faster-whisper-server`)
- Runs faster-whisper CPU inference
- Exposes OpenAI-compatible `/v1/audio/transcriptions` endpoint
- Pre-loads `Systran/faster-whisper-base` model
- NOT directly exposed to host (internal Docker network only)

### 3. Whisper Proxy (`whisper_proxy.py` in `python:3.11-slim`)
- Sits between Vexa and the whisper server
- **Critical**: rewrites `model=default` → `model=Systran/faster-whisper-base`
  in the multipart form body of transcription requests
- Vexa's internal `whisper_live.remote_transcriber` hardcodes `model=default`
  which the faster-whisper-server rejects as invalid
- Exposed on host port 8000 for manual testing

### 4. PostgreSQL (`postgres:16-alpine`)
- Stores meeting state, recordings metadata, transcript segments
- Persisted via Docker volume `vexa_pgdata`

### 5. Claude Code Agent (`meeting_bot.py` + `claude_client.py`)
- Runs on the host (Windows side via conda Python)
- Polls Vexa API every 5s for new chat messages
- Runs analysis loop every 30s (screenshot + transcript delta → Claude)
- Uses `ANTHROPIC_API_KEY` environment variable (no separate key needed
  when operated as a Claude Code skill — Claude Code IS the agent)

## Data Flow

### Audio Transcription Flow
```
Meeting Audio
    │
    ▼ (WebRTC, participant mics only)
vexa-meeting-bot
    │ streams audio chunks
    ▼
whisper_live WebSocket (internal port 9090)
    │ sends to remote transcriber
    ▼
whisper-proxy:8000/v1/audio/transcriptions
    │ rewrites model=default → Systran/faster-whisper-base
    ▼
vexa-whisper:8000/v1/audio/transcriptions
    │ faster-whisper inference
    ▼
transcript segments → stored in PostgreSQL
    │
    ▼
GET /transcripts/google_meet/{meeting_id}
    │
    ▼
Claude analysis → chat message
```

### Chat Interaction Flow
```
User types in meeting chat
    │
    ▼
Vexa bot captures chat
    │
GET /bots/google_meet/{id}/chat
    │
    ▼ (every 5 seconds)
_process_incoming_chat() in meeting_bot.py
    │
    ▼ (if message triggers response)
claude_client.respond_to_chat()
    │
    ▼
POST /bots/google_meet/{id}/chat  {"text": "...reply..."}
    │
    ▼
Message appears in meeting chat
```

## Networking Notes

- All Docker containers share the same compose-defined network (`172.18.x.x`)
- Vexa references whisper via Docker DNS: `http://whisper-proxy:8000`
- Host accesses Vexa API via `http://localhost:8056`
- **`host.docker.internal` does NOT work** in this Docker Desktop + WSL2 setup
  — containers cannot reach Windows host via this hostname
- The whisper proxy must run INSIDE Docker to be reachable by Vexa

## Key Design Decisions

| Decision | Reason |
|---|---|
| Google Meet over Zoom | `vexa-lite` Docker image lacks proprietary Zoom SDK native binaries |
| Whisper proxy in Docker | `host.docker.internal` unreachable from containers in this WSL2 setup |
| Python proxy (not nginx) | Needs to rewrite multipart form body content, not just headers |
| Claude Code as agent | No separate ANTHROPIC_API_KEY needed; Claude Code session IS the agent |
| `requests` library for API calls | Handles UTF-8 JSON encoding correctly vs PowerShell Invoke-WebRequest |
| X-API-Key header (not Bearer) | Vexa user token uses `X-API-Key`, not `Authorization: Bearer` |


path: docs/setup.md
content: # Setup & Installation Guide

## Prerequisites

| Requirement | Version | Location |
|---|---|---|
| Docker Desktop | Latest | `C:\Program Files\Docker\Docker\` |
| Conda environment | `meeting-assistant` | `D:\program\codesupport\anaconda\envs\meeting-assistant\` |
| Python (conda env) | 3.11+ | `D:\program\codesupport\anaconda\envs\meeting-assistant\python.exe` |
| anthropic SDK | 0.84.0+ | Installed in conda env |
| WSL2 Ubuntu | Any | For running Claude Code |

## Step 1: Prepare Docker Compose File

The docker-compose.yml in the skill directory is the source of truth, but Docker
must run it from a **Windows path** (not a WSL path). Copy it to Windows temp:

```powershell
Copy-Item "\\wsl.localhost\Ubuntu\home\zeron\.openclaw\workspace\skills\meeting-assistant\docker-compose.yml" `
    "C:\Users\zeron\AppData\Local\Temp\vexa-compose.yml"
```

**Important**: The compose file must also include the whisper proxy service.
See the [full compose file](#docker-compose-reference) below.

## Step 2: Start Infrastructure

```powershell
$env:PATH += ';C:\Program Files\Docker\Docker\resources\bin'
docker compose -f C:\Users\zeron\AppData\Local\Temp\vexa-compose.yml up -d
```

Wait ~30 seconds for all services to become healthy:

```powershell
docker ps --format "table {{.Names}}\t{{.Status}}"
# Expected:
# vexa-meeting-bot     Up X seconds (healthy)
# vexa-whisper-proxy   Up X seconds
# vexa-whisper         Up X seconds
# vexa-postgres        Up X seconds (healthy)
```

## Step 3: Create Vexa User Token

This is a one-time setup. The token is already created and stored in `config.json`.

If you need to recreate it:

```powershell
# 1. Create user (admin endpoint)
$headers = @{ 'X-Admin-API-Key' = 'openclaw-meeting-bot'; 'Content-Type' = 'application/json' }
Invoke-WebRequest -Uri 'http://localhost:8056/admin/users' -Method POST `
    -Headers $headers -Body '{"email":"[REDACTED_SECRET]"}' -UseBasicParsing

# 2. Create token for user ID 1
Invoke-WebRequest -Uri 'http://localhost:8056/admin/users/1/tokens' -Method POST `
    -Headers $headers -UseBasicParsing
# Save the returned token to config.json → bot.vexa_api_key
```

Current [REDACTED_SECRET]

## Step 4: Verify Setup

```powershell
$h = @{ 'X-API-Key' = [REDACTED_SECRET] }
(Invoke-WebRequest 'http://localhost:8056/bots/status' -Headers $h -UseBasicParsing).Content
# Expected: {"running_bots":[]}
```

## Docker Compose Reference

The complete docker-compose.yml with all required services:

```yaml
services:
  postgres:
    image: postgres:16-alpine
    container_name: vexa-postgres
    environment:
      - POSTGRES_USER=vexa
      - POSTGRES_PASSWORD=vexa_pass
      - POSTGRES_DB=vexa
    volumes:
      - vexa_pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U vexa"]
      interval: 5s
      timeout: 3s
      retries: 5
    restart: unless-stopped

  whisper:
    image: fedirz/faster-whisper-server:latest-cpu
    container_name: vexa-whisper
    environment:
      - WHISPER__MODEL=Systran/faster-whisper-base
      - WHISPER__LANGUAGE=zh
    restart: unless-stopped

  whisper-proxy:
    image: python:3.11-slim
    container_name: vexa-whisper-proxy
    ports:
      - "8000:8000"
    volumes:
      - C:/Users/zeron/AppData/Local/Temp/whisper_proxy.py:/proxy.py:ro
    command: python3 /proxy.py
    depends_on:
      - whisper
    restart: unless-stopped

  vexa:
    image: vexaai/vexa-lite:latest
    container_name: vexa-meeting-bot
    ports:
      - "8056:8056"
    environment:
      - DATABASE_URL=postgresql://vexa:vexa_pass@postgres:5432/vexa
      - ADMIN_API_TOKEN=openclaw-meeting-bot
      - TRANSCRIPTION_ENABLED=true
      - TRANSCRIBER_URL=http://whisper-proxy:8000/v1/audio/transcriptions
      - REMOTE_TRANSCRIBER_URL=http://whisper-proxy:8000/v1/audio/transcriptions
      - TRANSCRIBER_API_KEY=openclaw-key
      - REMOTE_TRANSCRIBER_API_KEY=openclaw-key
      - ZOOM_CLIENT_ID=YZXafYz5STiVV3qbh2Sh0w
      - ZOOM_CLIENT_SECRET=IlvPhToAqWorTeW3qLLNUTnF9I1ItxUs
    depends_on:
      postgres:
        condition: service_healthy
      whisper-proxy:
        condition: service_started
    restart: unless-stopped

volumes:
  vexa_pgdata:
```

**Note**: The `whisper_proxy.py` file must be at `C:\Users\zeron\AppData\Local\Temp\whisper_proxy.py`
on the Windows host. Copy it from `scripts/whisper_proxy.py` if missing.

## Environment Variables Summary

| Variable | Value | Purpose |
|---|---|---|
| `ADMIN_API_TOKEN` | `openclaw-meeting-bot` | Vexa admin API authentication |
| `ZOOM_CLIENT_ID` | `YZXafYz5STiVV3qbh2Sh0w` | Zoom Marketplace app credential |
| `ZOOM_CLIENT_SECRET` | [REDACTED_SECRET] | Zoom Marketplace app credential |
| `TRANSCRIBER_URL` | `http://whisper-proxy:8000/v1/audio/transcriptions` | Route audio through proxy |
| `WHISPER__MODEL` | `Systran/faster-whisper-base` | Faster-whisper model to preload |
| `ANTHROPIC_API_KEY` | (from Claude Code environment) | Only needed for standalone mode |

## Conda Environment

The `meeting-assistant` conda environment must have:

```
anthropic>=0.84.0
requests
```

Install if missing:
```powershell
D:\program\codesupport\anaconda\envs\meeting-assistant\python.exe -m pip install anthropic requests
```

## Stopping Everything

```powershell
$env:PATH += ';C:\Program Files\Docker\Docker\resources\bin'
docker compose -f C:\Users\zeron\AppData\Local\Temp\vexa-compose.yml down
```

To also remove stored recordings/database:
```powershell
docker compose -f C:\Users\zeron\AppData\Local\Temp\vexa-compose.yml down -v
```


path: docs/troubleshooting.md
content: # Troubleshooting Guide

## Known Issues & Solutions

---

### Zoom SDK Missing

**Symptom:**
```
[Zoom] Zoom SDK native addon is not available.
Expected native addon: services/vexa-bot/build/Release/zoom_sdk_wrapper.node
Zoom Meeting SDK binaries are proprietary and must be downloaded separately from Zoom.
```

**Cause:** `vexaai/vexa-lite` Docker image does not include Zoom's proprietary native SDK binaries. These must be licensed from Zoom and downloaded separately.

**Solution:** Use **Google Meet** instead. Google Meet works via browser automation and requires no proprietary SDK.

**Status:** Open — Zoom support requires either building a custom Vexa image with Zoom SDK binaries, or waiting for Vexa to bundle them.

---

### Zoom OAuth Credentials Required (secondary Zoom issue)

**Symptom:**
```
[Zoom] ZOOM_CLIENT_ID and ZOOM_CLIENT_SECRET environment variables are required
```

**Cause:** Even with credentials configured, Zoom join fails due to the SDK issue above.

**Solution:** `ZOOM_CLIENT_ID` and `ZOOM_CLIENT_SECRET` are already set in `docker-compose.yml`. But they're irrelevant until the SDK binary issue is resolved.

---

### Whisper Transcription: `model=default` Error

**Symptom:**
```
ValueError: Invalid model size 'default', expected one of: tiny.en, tiny, base.en, base, ...
```

**Cause:** Vexa's internal `whisper_live.remote_transcriber` hardcodes `model=default` in OpenAI API requests. The `fedirz/faster-whisper-server` rejects this as an invalid model name.

**Solution:** The `whisper-proxy` container intercepts requests and rewrites the multipart form body:
```
name="model"\r\n\r\ndefault  →  name="model"\r\n\r\nSystran/faster-whisper-base
```

Ensure the proxy container is running:
```powershell
docker ps | Select-String "whisper-proxy"
# Should show: vexa-whisper-proxy   Up X seconds
```

If missing, ensure `whisper_proxy.py` exists at `C:\Users\zeron\AppData\Local\Temp\whisper_proxy.py` and restart compose.

---

### `host.docker.internal` Unreachable from Containers

**Symptom:** Running a proxy on Windows localhost and Docker containers can't reach it via `host.docker.internal`.

**Cause:** In this Docker Desktop + WSL2 configuration, `host.docker.internal` does not resolve from within Docker containers. The error is `Network is unreachable` or `Connection refused`.

**Solution:** Run the proxy service INSIDE Docker (as a `whisper-proxy` compose service) rather than on the Windows host. The proxy can reach other containers via Docker DNS names (e.g., `http://whisper:8000`).

---

### Audio Transcription: No Segments

**Symptom:** `segments: []` in transcript response even after extended recording.

**Causes and solutions:**

| Cause | Check | Fix |
|---|---|---|
| No microphone audio | Bot log shows `audioTracks=0` | Ensure a participant has their mic on |
| Screen share audio only | Bot sees video but no audio tracks | Screen share system audio cannot be captured by Vexa |
| Whisper proxy not rewriting | Check proxy logs for `Systran in body: False` | Restart `vexa-whisper-proxy` |
| Whisper server returning 500 | Check `docker logs vexa-whisper` | Ensure `WHISPER__MODEL=Systran/faster-whisper-base` |
| Model not downloaded | Whisper logs show download attempt | Wait for model download on first start (~1-2 min) |

To check bot audio status:
```powershell
docker exec vexa-meeting-bot cat /var/log/vexa-bots/vexa-bot-7-c724c8b7.log | Select-String "audio"
# Look for: audioTracks=1 (working) vs audioTracks=0 (no audio)
```

---

### Vexa Container Restart Loop

**Symptom:** `vexa-meeting-bot` shows "Up 2 seconds" repeatedly (keeps restarting).

**Causes:**

1. **Migration failure** (cosmetic): `FAILED: Path doesn't exist: alembic` — this is a warning, not fatal. The container continues after this.

2. **Transcription service unreachable**: If `TRANSCRIBER_URL` points to a host that Vexa can't reach at startup, it exits. Ensure the proxy container is up before Vexa starts.

**Check logs:**
```powershell
docker logs vexa-meeting-bot --tail 30
```

---

### Docker Image Pull Fails: `docker-credential-desktop not in PATH`

**Symptom:**
```
error getting credentials - err: exec: "docker-credential-desktop": executable file not found in %PATH%
```

**Cause:** Docker credential helper is not in PATH when running Docker CLI from WSL or certain PowerShell contexts.

**Solution:** Add Docker bin directory to PATH before running pull:
```powershell
$env:PATH += ';C:\Program Files\Docker\Docker\resources\bin'
docker pull <image>
```

---

### Chat Messages Appear as Garbled/Mojibake

**Symptom:** Chinese characters in chat appear as `æµè¯` or `??` instead of `测试`.

**Cause:** PowerShell `Invoke-WebRequest` does not send UTF-8 JSON by default.

**Solution:** Always use Python for sending chat messages with Chinese content:
```python
import json, urllib.request

body = json.dumps({"text": "中文消息"}, ensure_ascii=False).encode("utf-8")
req = urllib.request.Request(
    url, data=body,
    headers={"Content-Type": "application/json; charset=utf-8", "X-API-Key": KEY},
    method="POST"
)
urllib.request.urlopen(req)
```

The `MeetingBot.send_chat()` method uses `requests` library which handles encoding correctly.

---

### Bot Can't Join: `409 Conflict`

**Symptom:**
```
HTTPError: HTTP Error 409: Conflict
```

**Cause:** A bot is already joined (or in the process of joining) for this meeting ID.

**Solution:** Leave the existing bot first:
```python
requests.delete(f"{VEXA}/bots/google_meet/{meeting_id}", headers={"X-API-Key": KEY})
time.sleep(3)
# Then rejoin
```

---

### `vexa_api_key` Returns 403 Forbidden

**Symptom:** API calls return `{"detail": "Invalid API token"}` or 403.

**Causes:**

1. Using the admin token (`openclaw-meeting-bot`) for user API calls — the admin token only works for `/admin/` endpoints with `X-Admin-API-Key` header.
2. User token expired or database was reset.

**Solution:** Recreate the user token:
```powershell
$admin = @{ 'X-Admin-API-Key' = 'openclaw-meeting-bot'; 'Content-Type' = 'application/json' }
# Check if user exists
Invoke-WebRequest 'http://localhost:8056/admin/users' -Headers $admin -UseBasicParsing
# Create new token
Invoke-WebRequest 'http://localhost:8056/admin/users/1/tokens' -Method POST -Headers $admin -UseBasicParsing
# Update config.json with new token
```

---

### WSL Path Issues with Docker

**Symptom:** Docker compose can't find files at `\\wsl.localhost\Ubuntu\...` paths.

**Cause:** Docker Desktop runs on Windows and cannot access WSL filesystem paths directly in volume mounts.

**Solution:** Copy files from WSL to a Windows-accessible path first:
```powershell
Copy-Item "\\wsl.localhost\Ubuntu\home\zeron\.openclaw\...\whisper_proxy.py" `
    "C:\Users\zeron\AppData\Local\Temp\whisper_proxy.py"
```
Then reference `C:/Users/zeron/AppData/Local/Temp/whisper_proxy.py` in compose volumes.

---

## Diagnostic Commands

```powershell
# Check all container statuses
docker ps --format "table {{.Names}}\t{{.Status}}"

# Check Vexa startup log
docker logs vexa-meeting-bot --tail 50

# Check whisper proxy log (with debug)
docker logs vexa-whisper-proxy --tail 20

# Check whisper model loading
docker logs vexa-whisper --tail 20

# Check if Vexa API is responding
Invoke-WebRequest 'http://localhost:8056/meetings' `
    -Headers @{'X-API-Key'=[REDACTED_SECRET]} -UseBasicParsing

# Check whisper proxy works
Invoke-WebRequest 'http://localhost:8000/v1/models' -UseBasicParsing

# Check bot is active in a specific meeting
(Invoke-WebRequest 'http://localhost:8056/bots/google_meet/pmn-eatb-cyf' `
    -Headers @{'X-API-Key'='...'} -UseBasicParsing).Content
```

## Pending Development Items

| Item | Priority | Notes |
|---|---|---|
| Screen capture / visual analysis | High | Use `pc_control` screenshot tool instead of Vexa (which doesn't capture incoming video) |
| Audio transcription end-to-end test | High | Requires working microphone; pipeline is verified, just needs mic audio input |
| Zoom support | Medium | Requires custom Vexa image with Zoom SDK binaries |
| `launch.py` integration | Medium | Update to use correct compose file path and token |
| Persist compose file | Low | Currently at Windows temp; should be in skill directory accessible to Docker |
| Virtual microphone support | Low | Use VB-Audio VoiceMeeter to route system audio to virtual mic for testing |


path: references/integration-guide.md
content: # Meeting Assistant 集成指南

## 与 pc-control 联动

Meeting Assistant 复用 pc-control 的截屏和桌面控制能力。

### 截屏

```python
import sys
sys.path.insert(0, 'skills/pc-control/scripts')
from client import PCControl

pc = PCControl()
img_path = pc.screenshot(scale=0.5, quality=50)
# img_path 可直接传给 Claude 进行视觉分析
```

### 会议窗口控制

```python
# 静音/取消静音 (Zoom: Alt+A, Teams: Ctrl+Shift+M)
pc.hotkey("alt", "a")  # Zoom
pc.hotkey("ctrl", "shift", "m")  # Teams

# 开关摄像头 (Zoom: Alt+V, Teams: Ctrl+Shift+O)
pc.hotkey("alt", "v")  # Zoom
pc.hotkey("ctrl", "shift", "o")  # Teams

# 屏幕共享 (Zoom: Alt+S, Teams: Ctrl+Shift+E)
pc.hotkey("alt", "s")  # Zoom
pc.hotkey("ctrl", "shift", "e")  # Teams

# 聊天 (Zoom: Alt+H, Teams: Ctrl+Shift+C)
pc.hotkey("alt", "h")  # Zoom
```

## 与 agent-browser-core 联动

在会议中需要查询资料时：

```bash
# 快速查询医学术语
agent-browser navigate "https://www.google.com/search?q=医学术语+解释"
agent-browser snapshot
```

## 与 TTS 联动

将分析结果语音播报：

```bash
curl -X POST http://localhost:9001/v1/audio/speech \
  -H "Content-Type: application/json" \
  -d '{"input": "医生建议每天服用两次药物", "voice": "alloy"}'
```

## Agent 工作流程示例

```
Agent 收到指令: "帮我参加这个 Zoom 会议并做记录"

1. 检查 pc-control server 是否运行
   → python3 skills/pc-control/scripts/launcher.py start

2. 启动会议监控
   → python3 skills/meeting-assistant/scripts/meeting_monitor.py start --mode medical

3. 进入监控循环:
   while 会议进行中:
     a. 截屏 → 分析画面
     b. 获取最近5分钟转录 → 理解对话
     c. 综合分析 → 生成建议
     d. 如需要 → 调用 browser 查资料
     e. 如需要 → 通过 TTS 语音提示用户
     f. 等待 interval

4. 会议结束:
   → python3 skills/meeting-assistant/scripts/meeting_monitor.py stop
   → 生成会议摘要
   → 如果是医疗模式，生成患者版摘要
```

## Zoom API 集成（可选扩展）

如果有 Zoom OAuth credentials，可以通过 Zoom API 获取更多数据：

- 会议参与者列表
- 会议时长
- 云端录制（如果启用）
- 实时字幕（CC）

## Teams Graph API 集成（可选扩展）

通过 Microsoft Graph API：

- 获取会议详情
- 访问会议转录（如果启用）
- 获取会议聊天记录
- 日历集成


path: references/medical-terms-guide.md
content: # 医疗会议辅助 — 常用术语与分析指南

## Agent 医疗模式行为准则

1. **不做诊断** — Agent 仅协助信息传递和解释，不提供独立诊断
2. **标注来源** — 所有解释标明"仅供参考，以医生建议为准"
3. **隐私保护** — 录制内容加密存储，会后提醒清理
4. **术语桥接** — 将专业术语翻译为患者可理解的语言

## 关键监测信号

Agent 在会议中应特别关注以下内容并高亮：

### 诊断信息
- 疾病名称、ICD编码
- 检查结果解读
- 病理报告

### 用药信息
- 药品名称（通用名+商品名）
- 剂量、频次、疗程
- 禁忌症、副作用提醒

### 治疗方案
- 手术/非手术方案
- 治疗周期
- 随访计划

### 患者须知
- 生活方式建议
- 饮食注意事项
- 复诊时间
- 紧急情况处理

## 输出模板

### 患者版摘要

```markdown
# 就诊摘要 — [日期]

## 诊断结果
[通俗语言描述诊断]

## 医生建议
1. [具体建议1]
2. [具体建议2]

## 用药说明
| 药名 | 用法 | 注意事项 |
|------|------|----------|
| xxx  | xxx  | xxx      |

## 下次就诊
- 时间：
- 需要准备：

## 需要注意
- [注意事项列表]

> 本摘要由AI助手整理，仅供参考。具体请以医生当面说明为准。
```

### 医生版记录

```markdown
# 会议记录 — [日期]

## 患者基本信息
- 姓名/ID:
- 主诉:

## 问诊内容摘要
[按时间线整理的对话要点]

## 检查/检验讨论
[提到的检查结果及解读]

## 治疗决策
[达成的治疗方案]

## 随访计划
[复诊安排]
```

## 常见远程医疗场景

### 场景1: 初诊问诊
Agent 重点：完整记录症状描述、既往病史、家族史

### 场景2: 检查结果解读
Agent 重点：记录每项指标的数值和医生解读，标注异常项

### 场景3: 治疗方案讨论
Agent 重点：记录各方案优缺点、医患双方的倾向、最终决定

### 场景4: 术后/治疗后随访
Agent 重点：恢复情况、调整方案、下次随访安排


path: requirements.txt
content: openai-whisper


path: scripts/agent_tool.py
content: #!/usr/bin/env python3
"""
Meeting Assistant — Tool Interface for Claude Code & OpenClaw

This module exposes the meeting assistant as callable tools that can be used:
  1. By Claude Code directly (via Bash tool calling this script)
  2. By OpenClaw agents (imported as Python module)
  3. Via the Anthropic tool_use API format (tool_definitions())

Usage as CLI (returns JSON):
  python agent_tool.py start "https://zoom.us/j/123?pwd=xxx" --mode medical
  python agent_tool.py status
  python agent_tool.py chat "What was just prescribed?"
  python agent_tool.py transcript
  python agent_tool.py screenshot
  python agent_tool.py stop

Usage as Python:
  from agent_tool import MeetingAssistantTool
  tool = MeetingAssistantTool()
  result = tool.start_assistant("https://zoom.us/j/123", mode="medical")

Usage with Anthropic tool_use:
  from agent_tool import tool_definitions
  tools = tool_definitions()  # pass to client.messages.create(tools=tools)
"""

import json
import os
import signal
import subprocess
import sys
import time
from datetime import datetime
from pathlib import Path

SKILL_DIR = Path(__file__).resolve().parent.parent
CONFIG_PATH = SKILL_DIR / "config.json"
STATE_FILE = SKILL_DIR / ".assistant_state.json"
DAEMON_LOG = SKILL_DIR / ".assistant_daemon.log"


def _load_config() -> dict:
    with open(CONFIG_PATH, encoding="utf-8") as f:
        return json.load(f)


def _ok(data: dict) -> dict:
    return {"status": "ok", **data}


def _err(message: str) -> dict:
    return {"status": "error", "error": message}


def _load_state() -> dict:
    if STATE_FILE.exists():
        try:
            return json.loads(STATE_FILE.read_text(encoding="utf-8"))
        except Exception:
            pass
    return {}


def _save_state(state: dict):
    STATE_FILE.write_text(json.dumps(state, indent=2, ensure_ascii=False), encoding="utf-8")


def _clear_state():
    STATE_FILE.unlink(missing_ok=True)


# ─── MeetingAssistantTool ─────────────────────────────────────────────────────

class MeetingAssistantTool:
    """
    High-level tool interface to the meeting assistant.

    All methods return a dict with at least {"status": "ok"|"error"}.
    This class can be instantiated by any agent to control the meeting assistant.
    """

    def __init__(self):
        self.config = _load_config()

    # ── Lifecycle ─────────────────────────────────────────────────────────

    def start_assistant(
        self,
        meeting_url: str,
        mode: str = "general",
        interval: int = 30,
        bot_name: str | None = None,
    ) -> dict:
        """
        Join a meeting and start the AI assistant loop as a background daemon.

        Args:
            meeting_url: Zoom/Teams/Meet URL
            mode:        "general" or "medical"
            interval:    Analysis cycle interval in seconds (default 30)
            bot_name:    Display name for the bot in the meeting

        Returns:
            {"status": "ok", "pid": int, "session_dir": str, "message": str}
        """
        state = _load_state()
        if state.get("pid"):
            try:
                os.kill(state["pid"], 0)  # Check if process exists
                return _err(f"Assistant already running (PID {state['pid']}). Call stop() first.")
            except ProcessLookupError:
                _clear_state()

        python = self.config.get("conda_env", {}).get("python", sys.executable)
        bot_script = Path(__file__).parent / "meeting_bot.py"

        cmd = [
            python, str(bot_script),
            "assist", meeting_url,
            "--mode", mode,
            "--interval", str(interval),
        ]
        if bot_name:
            cmd += ["--name", bot_name]

        log_file = open(DAEMON_LOG, "w", encoding="utf-8")
        try:
            proc = subprocess.Popen(
                cmd,
                stdout=log_file,
                stderr=subprocess.STDOUT,
                start_new_session=True,
                cwd=str(SKILL_DIR),
                env={**os.environ},
            )
        except Exception as e:
            return _err(f"Failed to start assistant: {e}")

        state = {
            "pid": proc.pid,
            "meeting_url": meeting_url,
            "mode": mode,
            "interval": interval,
            "started_at": datetime.now().isoformat(),
            "log_file": str(DAEMON_LOG),
        }
        _save_state(state)

        # Give it a moment to start
        time.sleep(2)

        return _ok({
            "pid": proc.pid,
            "message": f"Assistant starting (PID {proc.pid}). Bot joining {meeting_url}...",
            "mode": mode,
            "log": str(DAEMON_LOG),
        })

    def stop_assistant(self) -> dict:
        """
        Stop the running assistant daemon gracefully.

        Returns:
            {"status": "ok", "message": str}
        """
        state = _load_state()
        pid = state.get("pid")

        if not pid:
            return _err("No active assistant session found.")

        try:
            os.kill(pid, signal.SIGINT)
            # Wait for graceful shutdown
            for _ in range(15):
                time.sleep(1)
                try:
                    os.kill(pid, 0)
                except ProcessLookupError:
                    break
            else:
                # Force kill if still alive
                try:
                    os.kill(pid, signal.SIGTERM)
                except ProcessLookupError:
                    pass
        except ProcessLookupError:
            pass

        _clear_state()
        return _ok({"message": f"Assistant stopped (was PID {pid})"})

    def get_status(self) -> dict:
        """
        Get the current assistant and bot status.

        Returns:
            {"status": "ok", "running": bool, "pid": int|None, "bot_status": dict|None, ...}
        """
        state = _load_state()
        pid = state.get("pid")
        running = False

        if pid:
            try:
                os.kill(pid, 0)
                running = True
            except ProcessLookupError:
                _clear_state()
                state = {}

        # Try to get Vexa bot status
        bot_status = None
        session_info = self._get_latest_session()
        if session_info:
            bot_id = session_info.get("bot_id")
            if bot_id:
                try:
                    import requests
                    cfg = self.config.get("bot", {})
                    url = cfg.get("vexa_url", "http://localhost:8056")
                    headers = {}
                    if cfg.get("vexa_api_key"):
                        headers["X-API-Key"] = cfg["vexa_api_key"]
                    resp = requests.get(f"{url}/bots/{bot_id}", headers=headers, timeout=5)
                    if resp.ok:
                        bot_status = resp.json()
                except Exception:
                    pass

        return _ok({
            "running": running,
            "pid": pid if running else None,
            "meeting_url": state.get("meeting_url"),
            "mode": state.get("mode"),
            "started_at": state.get("started_at"),
            "bot_status": bot_status,
            "session": session_info,
        })

    # ── Meeting Interaction ────────────────────────────────────────────────

    def send_chat(self, message: str) -> dict:
        """
        Send a message to the meeting chat via the active bot.

        Args:
            message: Text to send to the meeting chat

        Returns:
            {"status": "ok", "message": str}
        """
        python = self.config.get("conda_env", {}).get("python", sys.executable)
        bot_script = Path(__file__).parent / "meeting_bot.py"

        result = subprocess.run(
            [python, str(bot_script), "chat", message],
            capture_output=True, text=True, timeout=15, cwd=str(SKILL_DIR),
        )
        if result.returncode != 0:
            return _err(result.stderr or result.stdout)
        return _ok({"message": f"Sent: {message[:80]}"})

    def get_transcript(self, max_entries: int = 50) -> dict:
        """
        Get the current meeting transcript.

        Returns:
            {"status": "ok", "entries": list, "text": str}
        """
        python = self.config.get("conda_env", {}).get("python", sys.executable)
        bot_script = Path(__file__).parent / "meeting_bot.py"

        result = subprocess.run(
            [python, str(bot_script), "transcript"],
            capture_output=True, text=True, timeout=15, cwd=str(SKILL_DIR),
        )
        text = result.stdout.strip()

        # Also try to read from transcript_log.json
        session = self._get_latest_session_dir()
        entries = []
        if session:
            log = session / "transcript_log.json"
            if log.exists():
                try:
                    all_entries = json.loads(log.read_text(encoding="utf-8"))
                    entries = all_entries[-max_entries:]
                except Exception:
                    pass

        return _ok({"text": text, "entries": entries, "count": len(entries)})

    def get_screenshot(self, save_path: str | None = None) -> dict:
        """
        Capture the current meeting view.

        Returns:
            {"status": "ok", "path": str}
        """
        python = self.config.get("conda_env", {}).get("python", sys.executable)
        bot_script = Path(__file__).parent / "meeting_bot.py"

        cmd = [python, str(bot_script), "screenshot"]
        if save_path:
            cmd += ["--output", save_path]

        result = subprocess.run(
            cmd, capture_output=True, text=True, timeout=20, cwd=str(SKILL_DIR),
        )
        if result.returncode != 0:
            return _err(result.stderr or result.stdout)

        # Extract path from output
        path = None
        for line in result.stdout.split("\n"):
            if "Saved:" in line or ".png" in line:
                path = line.split("Saved:")[-1].strip()
                break

        return _ok({"path": path, "output": result.stdout.strip()})

    def read_chat(self) -> dict:
        """
        Read meeting chat messages.

        Returns:
            {"status": "ok", "messages": list}
        """
        python = self.config.get("conda_env", {}).get("python", sys.executable)
        bot_script = Path(__file__).parent / "meeting_bot.py"

        result = subprocess.run(
            [python, str(bot_script), "read-chat"],
            capture_output=True, text=True, timeout=15, cwd=str(SKILL_DIR),
        )
        messages = [line for line in result.stdout.strip().split("\n") if line.strip()]
        return _ok({"messages": messages, "count": len(messages)})

    def get_session_summary(self) -> dict:
        """
        Get or generate the meeting summary for the latest session.

        Returns:
            {"status": "ok", "summary": str, "path": str}
        """
        session_dir = self._get_latest_session_dir()
        if not session_dir:
            return _err("No session found")

        summary_path = session_dir / "meeting_summary.md"
        if summary_path.exists():
            return _ok({
                "summary": summary_path.read_text(encoding="utf-8"),
                "path": str(summary_path),
            })

        return _err(f"No summary yet. Session dir: {session_dir}")

    def get_logs(self, lines: int = 50) -> dict:
        """Get recent daemon log lines."""
        if not DAEMON_LOG.exists():
            return _ok({"lines": [], "message": "No daemon log found"})
        try:
            all_lines = DAEMON_LOG.read_text(encoding="utf-8", errors="replace").split("\n")
            recent = all_lines[-lines:]
            return _ok({"lines": recent, "total_lines": len(all_lines)})
        except Exception as e:
            return _err(str(e))

    # ── Helpers ────────────────────────────────────────────────────────────

    def _get_latest_session(self) -> dict | None:
        recordings = SKILL_DIR / "recordings"
        if not recordings.exists():
            return None
        sessions = sorted(recordings.glob("bot_*/session.json"), reverse=True)
        for s in sessions:
            try:
                return json.loads(s.read_text(encoding="utf-8"))
            except Exception:
                continue
        return None

    def _get_latest_session_dir(self) -> Path | None:
        recordings = SKILL_DIR / "recordings"
        if not recordings.exists():
            return None
        sessions = sorted(recordings.glob("bot_*"), reverse=True)
        return sessions[0] if sessions else None


# ─── Anthropic Tool Definitions ───────────────────────────────────────────────

def tool_definitions() -> list[dict]:
    """
    Returns Anthropic tool_use format definitions for the meeting assistant.
    Use with: client.messages.create(tools=tool_definitions(), ...)

    Example:
        import anthropic
        from agent_tool import tool_definitions
        client = anthropic.Anthropic()
        resp = client.messages.create(
            model="claude-sonnet-4-6",
            tools=tool_definitions(),
            messages=[{"role": "user", "content": "Join this meeting: https://zoom.us/j/123"}]
        )
    """
    return [
        {
            "name": "meeting_start",
            "description": (
                "Join a video meeting (Zoom/Teams/Google Meet) and start the AI assistant. "
                "The bot will participate in the meeting, transcribe audio, analyze content, "
                "and respond to participant questions via meeting chat."
            ),
            "input_schema": {
                "type": "object",
                "properties": {
                    "meeting_url": {
                        "type": "string",
                        "description": "Full meeting URL (Zoom, Teams, or Google Meet)",
                    },
                    "mode": {
                        "type": "string",
                        "enum": ["general", "medical"],
                        "description": "Assistant mode. 'medical' enables medical terminology support and patient summaries.",
                        "default": "general",
                    },
                    "interval": {
                        "type": "integer",
                        "description": "Analysis cycle interval in seconds (default 30)",
                        "default": 30,
                    },
                    "bot_name": {
                        "type": "string",
                        "description": "Display name for the bot in the meeting",
                    },
                },
                "required": ["meeting_url"],
            },
        },
        {
            "name": "meeting_stop",
            "description": (
                "Stop the AI meeting assistant, generate a final meeting summary, "
                "and leave the meeting. Should be called after the meeting ends."
            ),
            "input_schema": {
                "type": "object",
                "properties": {},
            },
        },
        {
            "name": "meeting_status",
            "description": "Get the current status of the meeting assistant and bot.",
            "input_schema": {
                "type": "object",
                "properties": {},
            },
        },
        {
            "name": "meeting_send_chat",
            "description": "Send a message to the meeting chat on behalf of the assistant bot.",
            "input_schema": {
                "type": "object",
                "properties": {
                    "message": {
                        "type": "string",
                        "description": "Message to send to the meeting chat",
                    }
                },
                "required": ["message"],
            },
        },
        {
            "name": "meeting_get_transcript",
            "description": "Get the current meeting transcript (recent conversation).",
            "input_schema": {
                "type": "object",
                "properties": {
                    "max_entries": {
                        "type": "integer",
                        "description": "Maximum number of transcript entries to return",
                        "default": 50,
                    }
                },
            },
        },
        {
            "name": "meeting_screenshot",
            "description": "Capture the current meeting view as a screenshot.",
            "input_schema": {
                "type": "object",
                "properties": {
                    "save_path": {
                        "type": "string",
                        "description": "Optional file path to save the screenshot",
                    }
                },
            },
        },
        {
            "name": "meeting_read_chat",
            "description": "Read all chat messages from the current meeting.",
            "input_schema": {
                "type": "object",
                "properties": {},
            },
        },
        {
            "name": "meeting_get_summary",
            "description": "Get the meeting summary (available after meeting ends or after analysis).",
            "input_schema": {
                "type": "object",
                "properties": {},
            },
        },
        {
            "name": "meeting_get_logs",
            "description": "Get recent assistant daemon logs for debugging.",
            "input_schema": {
                "type": "object",
                "properties": {
                    "lines": {
                        "type": "integer",
                        "description": "Number of recent log lines to return",
                        "default": 50,
                    }
                },
            },
        },
    ]


def handle_tool_call(tool_name: str, tool_input: dict) -> dict:
    """
    Execute a tool call from the Anthropic tool_use API.

    Args:
        tool_name:  Name from tool_definitions()
        tool_input: Input dict matching the tool's input_schema

    Returns:
        dict result to return as tool_result content
    """
    tool = MeetingAssistantTool()
    dispatch = {
        "meeting_start":         lambda: tool.start_assistant(**tool_input),
        "meeting_stop":          lambda: tool.stop_assistant(),
        "meeting_status":        lambda: tool.get_status(),
        "meeting_send_chat":     lambda: tool.send_chat(tool_input["message"]),
        "meeting_get_transcript":lambda: tool.get_transcript(**tool_input),
        "meeting_screenshot":    lambda: tool.get_screenshot(**tool_input),
        "meeting_read_chat":     lambda: tool.read_chat(),
        "meeting_get_summary":   lambda: tool.get_session_summary(),
        "meeting_get_logs":      lambda: tool.get_logs(**tool_input),
    }

    handler = dispatch.get(tool_name)
    if not handler:
        return _err(f"Unknown tool: {tool_name}")

    try:
        return handler()
    except Exception as e:
        return _err(f"Tool execution error: {e}")


# ─── CLI Entry Point ──────────────────────────────────────────────────────────

def main():
    import argparse

    parser = argparse.ArgumentParser(
        description="Meeting Assistant Tool — CLI Interface",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="""
Commands:
  start <url>          Join meeting and start AI assistant
  stop                 Stop assistant and generate summary
  status               Check assistant status
  chat <message>       Send message to meeting chat
  transcript           Get meeting transcript
  screenshot           Capture meeting screenshot
  read-chat            Read all meeting chat messages
  summary              Get meeting summary
  logs                 Get assistant logs
  tool-defs            Print Anthropic tool definitions (JSON)

All commands return JSON output.
        """,
    )

    sub = parser.add_subparsers(dest="command")

    p_start = sub.add_parser("start", help="Start AI meeting assistant")
    p_start.add_argument("meeting_url", help="Meeting URL")
    p_start.add_argument("--mode", choices=["general", "medical"], default="general")
    p_start.add_argument("--interval", type=int, default=30)
    p_start.add_argument("--bot-name", dest="bot_name", default=None)

    sub.add_parser("stop", help="Stop assistant")
    sub.add_parser("status", help="Get status")

    p_chat = sub.add_parser("chat", help="Send chat message")
    p_chat.add_argument("message")

    p_transcript = sub.add_parser("transcript", help="Get transcript")
    p_transcript.add_argument("--max", type=int, default=50, dest="max_entries")

    p_screenshot = sub.add_parser("screenshot", help="Take screenshot")
    p_screenshot.add_argument("--output", default=None, dest="save_path")

    sub.add_parser("read-chat", help="Read chat messages")
    sub.add_parser("summary", help="Get meeting summary")

    p_logs = sub.add_parser("logs", help="Get daemon logs")
    p_logs.add_argument("--lines", type=int, default=50)

    sub.add_parser("tool-defs", help="Print Anthropic tool definitions")

    args = parser.parse_args()
    tool = MeetingAssistantTool()

    def output(result):
        print(json.dumps(result, ensure_ascii=False, indent=2))

    if args.command == "start":
        output(tool.start_assistant(
            args.meeting_url,
            mode=args.mode,
            interval=args.interval,
            bot_name=args.bot_name,
        ))
    elif args.command == "stop":
        output(tool.stop_assistant())
    elif args.command == "status":
        output(tool.get_status())
    elif args.command == "chat":
        output(tool.send_chat(args.message))
    elif args.command == "transcript":
        output(tool.get_transcript(args.max_entries))
    elif args.command == "screenshot":
        output(tool.get_screenshot(args.save_path))
    elif args.command == "read-chat":
        output(tool.read_chat())
    elif args.command == "summary":
        output(tool.get_session_summary())
    elif args.command == "logs":
        output(tool.get_logs(args.lines))
    elif args.command == "tool-defs":
        print(json.dumps(tool_definitions(), ensure_ascii=False, indent=2))
    else:
        parser.print_help()
        sys.exit(1)


if __name__ == "__main__":
    main()


path: scripts/audio_capture.py
content: #!/usr/bin/env python3
"""
Audio Capture 模块 — 跨平台音频捕获
支持 PulseAudio (Linux), WASAPI loopback (Windows via PowerShell), PipeWire
"""

import json
import subprocess
import sys
from pathlib import Path

SKILL_DIR = Path(__file__).resolve().parent.parent
CONFIG_PATH = SKILL_DIR / "config.json"


def load_config():
    with open(CONFIG_PATH) as f:
        return json.load(f)


def list_audio_sources():
    """List all available audio sources."""
    sources = []

    # PulseAudio
    try:
        result = subprocess.run(
            ["pactl", "list", "short", "sources"],
            capture_output=True, text=True, timeout=5
        )
        if result.returncode == 0:
            for line in result.stdout.strip().split("\n"):
                if line.strip():
                    parts = line.split("\t")
                    sources.append({
                        "backend": "pulseaudio",
                        "id": parts[1] if len(parts) > 1 else parts[0],
                        "is_monitor": ".monitor" in line,
                        "raw": line.strip()
                    })
    except (FileNotFoundError, subprocess.TimeoutExpired):
        pass

    # PipeWire
    try:
        result = subprocess.run(
            ["pw-record", "--list-targets"],
            capture_output=True, text=True, timeout=5
        )
        if result.returncode == 0:
            for line in result.stdout.strip().split("\n"):
                if line.strip() and not line.startswith("Available"):
                    sources.append({
                        "backend": "pipewire",
                        "id": line.strip(),
                        "is_monitor": "monitor" in line.lower() or "output" in line.lower(),
                        "raw": line.strip()
                    })
    except (FileNotFoundError, subprocess.TimeoutExpired):
        pass

    return sources


def find_best_loopback():
    """Find the best audio source for capturing system output (meeting audio)."""
    sources = list_audio_sources()

    # Prefer monitor sources (they capture what comes out of speakers)
    monitors = [s for s in sources if s["is_monitor"]]
    if monitors:
        return monitors[0]["id"], monitors[0]["backend"]

    if sources:
        return sources[0]["id"], sources[0]["backend"]

    return "default", "pulse"


def record_audio(output_path, duration=None, device=None):
    """
    Record audio to file.

    Args:
        output_path: Path to save WAV file
        duration: Recording duration in seconds (None = until killed)
        device: Audio device/source name (None = auto-detect)
    """
    config = load_config()

    if not device:
        device, backend = find_best_loopback()
    else:
        backend = "pulse"

    sample_rate = config.get("audio", {}).get("sample_rate", 16000)
    channels = config.get("audio", {}).get("channels", 1)

    cmd = [
        "ffmpeg", "-y",
        "-f", "pulse",
        "-i", device,
        "-ac", str(channels),
        "-ar", str(sample_rate),
        "-acodec", "pcm_s16le",
    ]

    if duration:
        cmd.extend(["-t", str(duration)])

    cmd.append(str(output_path))

    print(f"Recording from: {device} ({backend})")
    print(f"Output: {output_path}")
    if duration:
        print(f"Duration: {duration}s")
    else:
        print("Recording until stopped (Ctrl+C)")

    proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
    return proc


def record_window_audio_windows(output_path, duration=None):
    """
    Record audio on Windows using WASAPI loopback via PowerShell.
    This captures the actual system audio output (what you hear in speakers).
    """
    powershell = "/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe"
    config = load_config()
    sample_rate = config.get("audio", {}).get("sample_rate", 16000)

    # Convert WSL path to Windows path
    win_path = subprocess.run(
        ["wslpath", "-w", str(output_path)],
        capture_output=True, text=True
    ).stdout.strip()

    duration_flag = f"-t {duration}" if duration else ""

    cmd = [
        "ffmpeg", "-y",
        "-f", "dshow",
        "-i", "audio=virtual-audio-capturer",  # Requires Virtual Audio Cable or similar
        "-ac", "1",
        "-ar", str(sample_rate),
        "-acodec", "pcm_s16le",
    ]

    if duration:
        cmd.extend(["-t", str(duration)])

    cmd.append(str(output_path))

    print(f"Recording Windows audio via WASAPI")
    proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
    return proc


if __name__ == "__main__":
    import argparse

    parser = argparse.ArgumentParser(description="Audio capture utility")
    parser.add_argument("command", choices=["list", "record", "test"])
    parser.add_argument("--output", "-o", default="test_recording.wav")
    parser.add_argument("--duration", "-d", type=int, help="Duration in seconds")
    parser.add_argument("--device", help="Audio device name")
    args = parser.parse_args()

    if args.command == "list":
        sources = list_audio_sources()
        if not sources:
            print("No audio sources found. Install pulseaudio or pipewire.")
        else:
            print("Available audio sources:")
            for s in sources:
                marker = " ★" if s["is_monitor"] else ""
                print(f"  [{s['backend']}] {s['id']}{marker}")
            print("\n★ = monitor source (captures system output)")

    elif args.command == "record":
        proc = record_audio(args.output, args.duration, args.device)
        try:
            proc.wait()
        except KeyboardInterrupt:
            proc.terminate()
            proc.wait()
        print(f"\nSaved: {args.output}")

    elif args.command == "test":
        print("Recording 5-second test clip...")
        proc = record_audio(args.output, duration=5, device=args.device)
        proc.wait()
        if Path(args.output).exists():
            size = Path(args.output).stat().st_size
            print(f"Test recording saved: {args.output} ({size} bytes)")
            if size < 1000:
                print("WARNING: File is very small, audio capture may not be working")
        else:
            print("ERROR: Test recording failed")


path: scripts/claude_client.py
content: #!/usr/bin/env python3
"""
Claude API client for real-time meeting analysis.

Handles:
  - Real-time screenshot (vision) + transcript analysis
  - Chat command response with conversation history
  - Final meeting summary generation
  - Medical mode: terminology, patient summary, prescription detection
"""

import base64
import json
import os
import re
from datetime import datetime
from pathlib import Path

try:
    import anthropic
    HAS_ANTHROPIC = True
except ImportError:
    HAS_ANTHROPIC = False

SKILL_DIR = Path(__file__).resolve().parent.parent

# ─── System Prompts ───────────────────────────────────────────────────────────

_SYSTEM_GENERAL = """You are an intelligent AI meeting assistant attending a video meeting as a bot participant.

Your role:
- Monitor and understand the ongoing conversation
- Provide concise, useful insights and summaries
- Answer participants' questions via meeting chat
- Flag important action items and decisions
- Help participants stay focused and productive

Guidelines:
- Keep chat responses SHORT (2-5 sentences max) — people are in an active meeting
- Only send a chat message when it adds clear value
- Be helpful and professional
- Use the participant's language (Chinese or English) to match the meeting
"""

_SYSTEM_MEDICAL = """You are an AI medical consultation assistant attending a video meeting as a bot participant.

Your role:
- Monitor the doctor-patient conversation
- Translate medical terminology into patient-friendly language
- Flag important medication, dosage, and treatment information
- Generate patient-friendly summaries of medical advice
- Help bridge communication between doctor and patient

Strict guidelines:
- NEVER make independent medical diagnoses
- Always note: "以下解释仅供参考，请遵医嘱" (for informational purposes only)
- Flag prescription information clearly with ⚠️
- Keep chat responses concise — people are in a live consultation
- Use simple, accessible language when explaining to patients
- When medical terms are mentioned, proactively explain them in plain language
"""

# ─── Medical Term Detection Patterns ─────────────────────────────────────────

MEDICAL_PATTERNS = [
    # Diseases / conditions
    r'诊断|diagnosis|病症|症状|symptom|疾病|disease',
    # Medications
    r'药|medication|medicine|处方|prescription|剂量|dosage|用法|毫克|mg|片|capsule',
    # Procedures
    r'手术|surgery|检查|examination|化验|test|影像|imaging|CT|MRI|X光',
    # Instructions
    r'医嘱|advice|注意事项|precaution|禁忌|contraindication|副作用|side effect',
    # Follow-up
    r'复诊|follow.?up|下次|next appointment|随访',
]

_MEDICAL_RE = re.compile('|'.join(MEDICAL_PATTERNS), re.IGNORECASE)


def _has_medical_content(text: str) -> bool:
    return bool(_MEDICAL_RE.search(text))


# ─── Main Client Class ────────────────────────────────────────────────────────

class MeetingClaudeClient:
    """
    Claude API integration for meeting analysis.

    Usage:
        client = MeetingClaudeClient(config)
        result = client.analyze_meeting_state(screenshot_path, entries, mode="general")
        reply  = client.respond_to_chat("What was just prescribed?", "张医生", ...)
        md     = client.generate_final_summary(session_dir, mode="medical")
    """

    def __init__(self, config: dict):
        if not HAS_ANTHROPIC:
            raise ImportError(
                "anthropic package not installed.\n"
                "Run: pip install anthropic"
            )

        claude_cfg = config.get("claude", {})
        api_key = (
            claude_cfg.get("api_key")
            or os.environ.get("ANTHROPIC_API_KEY", "")
        )
        if not api_key:
            raise ValueError(
                "Claude API key not found.\n"
                "Set ANTHROPIC_API_KEY environment variable, or add\n"
                '  "claude": {"api_key": "sk-ant-..."}\n'
                "to config.json."
            )

        self.model = claude_cfg.get("model", "claude-sonnet-4-6")
        self.max_tokens = claude_cfg.get("max_tokens", 1024)
        self.client = anthropic.Anthropic([REDACTED_SECRET])

        # Per-session conversation history (for chat Q&A continuity)
        self._chat_history: list[dict] = []
        self._transcript_context: list[dict] = []  # rolling window

    # ─── Image helper ─────────────────────────────────────────────────────

    def _encode_image(self, path) -> dict | None:
        """Return Anthropic image content block, or None on failure."""
        try:
            data = Path(path).read_bytes()
            b64 = base64.standard_b64encode(data).decode()
            return {
                "type": "image",
                "source": {
                    "type": "base64",
                    "media_type": "image/png",
                    "data": b64,
                },
            }
        except Exception as e:
            print(f"[Claude] Image encode failed ({path}): {e}")
            return None

    def _format_entries(self, entries: list[dict], max_entries: int = 40) -> str:
        """Format transcript entries as readable text."""
        lines = []
        for e in entries[-max_entries:]:
            speaker = e.get("speaker") or e.get("speaker_name") or "?"
            text    = e.get("text")    or e.get("content")      or ""
            ts      = e.get("timestamp", "")
            lines.append(f"[{ts}] {speaker}: {text}" if ts else f"{speaker}: {text}")
        return "\n".join(lines)

    # ─── Real-time Meeting State Analysis ─────────────────────────────────

    def analyze_meeting_state(
        self,
        screenshot_path=None,
        transcript_entries: list[dict] | None = None,
        mode: str = "general",
    ) -> dict:
        """
        Analyze current meeting state using vision + transcript.

        Returns:
            {
              "analysis": str,       # internal analysis text
              "chat_message": str | None,  # message to send to meeting chat (if any)
              "has_medical_content": bool,
              "timestamp": str,
            }
        """
        system = _SYSTEM_MEDICAL if mode == "medical" else _SYSTEM_GENERAL
        content = []

        # Add screenshot
        if screenshot_path and Path(str(screenshot_path)).exists():
            img_block = self._encode_image(screenshot_path)
            if img_block:
                content.append(img_block)

        # Build transcript text
        entries = transcript_entries or []
        # Update rolling context
        self._transcript_context.extend(entries)
        self._transcript_context = self._transcript_context[-100:]

        transcript_text = self._format_entries(entries) if entries else "(no new transcript)"

        # Build analysis prompt
        if mode == "medical":
            prompt = f"""Current meeting transcript (latest updates):
{transcript_text}

Please analyze and:
1. Identify any medical terms, medications, diagnoses, or instructions mentioned
2. Flag any prescription/dosage information with ⚠️
3. If there is meaningful medical content worth sharing with participants, write a brief chat message

Format your response as:
ANALYSIS:
<your internal analysis>

CHAT:
<message to send to meeting chat — leave blank if nothing useful to share>
"""
        else:
            prompt = f"""Current meeting transcript (latest updates):
{transcript_text}

Please analyze and:
1. What is currently being discussed?
2. Are there any action items, decisions, or important points?
3. Is there anything worth sharing in the meeting chat?

Format your response as:
ANALYSIS:
<your internal analysis>

CHAT:
<message to send to meeting chat — leave blank if nothing useful to share>
"""

        content.append({"type": "text", "text": prompt})

        try:
            resp = self.client.messages.create(
                model=self.model,
                max_tokens=self.max_tokens,
                system=system,
                messages=[{"role": "user", "content": content}],
            )
            full_text = resp.content[0].text
        except Exception as e:
            print(f"[Claude] analyze_meeting_state error: {e}")
            return {
                "analysis": f"Analysis failed: {e}",
                "chat_message": None,
                "has_medical_content": False,
                "timestamp": datetime.now().isoformat(),
            }

        # Parse ANALYSIS / CHAT sections
        analysis = ""
        chat_msg = None

        if "ANALYSIS:" in full_text and "CHAT:" in full_text:
            parts = full_text.split("CHAT:", 1)
            analysis = parts[0].replace("ANALYSIS:", "").strip()
            chat_part = parts[1].strip()
            # Only use chat message if it has content
            if chat_part and len(chat_part) > 5:
                chat_msg = chat_part
        else:
            analysis = full_text

        # Suppress empty/placeholder chat messages
        if chat_msg and chat_msg.lower() in ("", "none", "n/a", "-", "无"):
            chat_msg = None

        has_medical = _has_medical_content(transcript_text)

        return {
            "analysis": analysis,
            "chat_message": chat_msg,
            "has_medical_content": has_medical,
            "timestamp": datetime.now().isoformat(),
        }

    # ─── Chat Command Response ─────────────────────────────────────────────

    def respond_to_chat(
        self,
        user_message: str,
        sender: str,
        context_screenshot=None,
        mode: str = "general",
    ) -> str:
        """
        Generate a reply to a participant's chat message.
        Maintains conversation history for context continuity.

        Returns:
            str: Reply message to send to meeting chat.
        """
        system = _SYSTEM_MEDICAL if mode == "medical" else _SYSTEM_GENERAL
        system += f"\n\nThe participant '{sender}' is asking you a question in the meeting chat. Respond helpfully and concisely."

        content = []

        # Include screenshot context if available
        if context_screenshot and Path(str(context_screenshot)).exists():
            img_block = self._encode_image(context_screenshot)
            if img_block:
                content.append(img_block)

        # Include recent meeting context
        context_text = ""
        if self._transcript_context:
            context_text = "Recent meeting conversation:\n" + self._format_entries(
                self._transcript_context, max_entries=20
            ) + "\n\n"

        prompt = f"{context_text}{sender} asks: {user_message}\n\nReply concisely (this is a meeting chat, keep it under 100 words)."
        content.append({"type": "text", "text": prompt})

        # Add to history and send
        self._chat_history.append({"role": "user", "content": content})

        # Keep history to last 10 turns
        history = self._chat_history[-10:]

        try:
            resp = self.client.messages.create(
                model=self.model,
                max_tokens=512,
                system=system,
                messages=history,
            )
            reply = resp.content[0].text.strip()
            # Save assistant reply as plain text in history
            self._chat_history.append({"role": "assistant", "content": reply})
            return reply
        except Exception as e:
            print(f"[Claude] respond_to_chat error: {e}")
            return f"抱歉，我暂时无法回应。({e})"

    # ─── Final Meeting Summary ─────────────────────────────────────────────

    def generate_final_summary(
        self,
        session_dir,
        mode: str = "general",
    ) -> str:
        """
        Generate a comprehensive meeting summary from session data.

        Reads: transcript_log.json, suggestions_log.json, screenshots/
        Returns: Markdown string (also saves to session_dir/meeting_summary.md)
        """
        session_dir = Path(session_dir)
        system = _SYSTEM_MEDICAL if mode == "medical" else _SYSTEM_GENERAL

        # ── Collect transcript ──
        transcript_text = "(no transcript available)"
        transcript_log = session_dir / "transcript_log.json"
        if transcript_log.exists():
            try:
                entries = json.loads(transcript_log.read_text(encoding="utf-8"))
                transcript_text = self._format_entries(entries, max_entries=500)
            except Exception:
                pass

        # ── Collect suggestions log ──
        suggestions_text = ""
        suggestions_log = session_dir / "suggestions_log.json"
        if suggestions_log.exists():
            try:
                suggestions = json.loads(suggestions_log.read_text(encoding="utf-8"))
                if suggestions:
                    lines = []
                    for s in suggestions:
                        ts = s.get("timestamp", "")
                        msg = s.get("message", "")
                        lines.append(f"[{ts}] {msg}")
                    suggestions_text = "\n".join(lines)
            except Exception:
                pass

        # ── Collect session metadata ──
        meta = {}
        meta_path = session_dir / "session.json"
        if meta_path.exists():
            try:
                meta = json.loads(meta_path.read_text(encoding="utf-8"))
            except Exception:
                pass

        # ── Include a few screenshots for visual context ──
        content = []
        screenshots_dir = session_dir / "screenshots"
        if screenshots_dir.exists():
            shots = sorted(screenshots_dir.glob("*.png"))
            # Sample: first, middle, last (max 4 images to stay within token limits)
            indices = list({0, len(shots)//2, len(shots)-1}) if shots else []
            for i in sorted(set(indices)):
                if 0 <= i < len(shots):
                    img_block = self._encode_image(shots[i])
                    if img_block:
                        content.append(img_block)

        # ── Build prompt ──
        start_time = meta.get("start_time", "N/A")
        end_time   = meta.get("end_time",   "N/A")
        platform   = meta.get("platform",   "N/A")

        base_prompt = f"""Generate a comprehensive meeting summary in Markdown format.

Meeting metadata:
- Start: {start_time}
- End:   {end_time}
- Platform: {platform}
- Mode: {mode}

Full meeting transcript:
{transcript_text}
"""
        if suggestions_text:
            base_prompt += f"\nAI suggestions sent during meeting:\n{suggestions_text}\n"

        if mode == "medical":
            base_prompt += """
Please write meeting_summary.md with the following sections:

# 医疗会议摘要

## 会议基本信息
## 参与人员
## 主要讨论内容

## 医疗信息
### 症状与诊断
### 处方与用药
### 医嘱与注意事项
### 复诊安排

## 患者版简明摘要
（用通俗语言，不使用专业术语，适合患者直接阅读）

## 医学术语对照
| 专业术语 | 通俗解释 | 英文 |
|---------|---------|------|

## 待办事项
"""
        else:
            base_prompt += """
Please write meeting_summary.md with the following sections:

# 会议摘要

## 会议基本信息
## 参与人员
## 主要议题
## 关键讨论内容
## 达成的决定
## 待办事项
## 下次会议
"""

        content.append({"type": "text", "text": base_prompt})

        try:
            resp = self.client.messages.create(
                model=self.model,
                max_tokens=4096,
                system=system,
                messages=[{"role": "user", "content": content}],
            )
            summary_md = resp.content[0].text.strip()
        except Exception as e:
            print(f"[Claude] generate_final_summary error: {e}")
            summary_md = f"# 会议摘要生成失败\n\n错误: {e}\n"

        # Save to file
        out_path = session_dir / "meeting_summary.md"
        out_path.write_text(summary_md, encoding="utf-8")
        print(f"[Claude] Summary saved: {out_path}")

        return summary_md

    def reset_conversation(self):
        """Clear conversation history (call between meetings)."""
        self._chat_history.clear()
        self._transcript_context.clear()


path: scripts/install.py
content: #!/usr/bin/env python3
"""
Meeting Assistant 依赖安装脚本
"""

import subprocess
import sys
import shutil


def check_command(cmd):
    return shutil.which(cmd) is not None


def main():
    print("=" * 50)
    print("  Meeting Assistant — 依赖检查与安装")
    print("=" * 50)

    issues = []
    ok = []

    # 1. ffmpeg
    print("\n[1/4] Checking ffmpeg...")
    if check_command("ffmpeg"):
        ok.append("ffmpeg")
        print("  ✓ ffmpeg found")
    else:
        issues.append("ffmpeg")
        print("  ✗ ffmpeg not found")
        print("    Install: sudo apt install ffmpeg")

    # 2. PulseAudio utilities
    print("\n[2/4] Checking PulseAudio...")
    if check_command("pactl"):
        ok.append("pulseaudio-utils")
        print("  ✓ pactl found")
    else:
        issues.append("pulseaudio-utils")
        print("  ✗ pactl not found")
        print("    Install: sudo apt install pulseaudio-utils")

    # 3. Whisper
    print("\n[3/4] Checking Whisper...")
    if check_command("whisper"):
        ok.append("whisper")
        print("  ✓ whisper found")
    else:
        issues.append("whisper")
        print("  ✗ whisper not found")
        print("    Install: pip install openai-whisper")

    # 4. Python dependencies
    print("\n[4/4] Checking Python packages...")
    required_packages = []
    for pkg in required_packages:
        try:
            __import__(pkg)
            ok.append(pkg)
            print(f"  ✓ {pkg}")
        except ImportError:
            issues.append(pkg)
            print(f"  ✗ {pkg}")

    # Summary
    print(f"\n{'=' * 50}")
    print(f"  Results: {len(ok)} OK, {len(issues)} missing")
    print(f"{'=' * 50}")

    if issues:
        print("\nTo install missing dependencies:")
        apt_pkgs = [p for p in issues if p in ("ffmpeg", "pulseaudio-utils")]
        pip_pkgs = [p for p in issues if p not in apt_pkgs]

        if apt_pkgs:
            print(f"  sudo apt install {' '.join(apt_pkgs)}")
        if pip_pkgs:
            print(f"  pip install {' '.join(pip_pkgs)}")

        resp = input("\nInstall automatically? [y/N]: ").strip().lower()
        if resp == "y":
            if apt_pkgs:
                print(f"\nInstalling: {' '.join(apt_pkgs)}")
                subprocess.run(["sudo", "apt", "install", "-y"] + apt_pkgs)
            if pip_pkgs:
                print(f"\nInstalling: {' '.join(pip_pkgs)}")
                subprocess.run([sys.executable, "-m", "pip", "install"] + pip_pkgs)
            print("\nDone! Run this script again to verify.")
        else:
            print("Skipped. Install manually when ready.")
    else:
        print("\nAll dependencies satisfied! Meeting Assistant is ready.")

    return len(issues) == 0


if __name__ == "__main__":
    success = main()
    sys.exit(0 if success else 1)


path: scripts/launch.py
content: #!/usr/bin/env python3
"""
Meeting Assistant Launcher — 一键启动入口

功能：
  1. 自动检测并启动 Vexa Docker 服务
  2. 等待 Vexa 健康就绪
  3. 打印就绪提示，等待用户提供会议链接
  4. 加入会议后进入 agent 交互循环

用法：
  python launch.py                     # 启动并等待用户输入 URL
  python launch.py "https://zoom..."   # 直接加入会议
  python launch.py --status            # 检查 Vexa 状态
  python launch.py --stop-vexa         # 停止 Vexa 服务

架构说明：
  本程序作为「数据层」——负责加入会议、采集音视频、收发聊天。
  分析层由上游 Agent (Claude Code) 担任，通过读取 session 文件和调用工具完成。
"""

import argparse
import json
import os
import platform
import shutil
import subprocess
import sys
import time
from pathlib import Path

SKILL_DIR = Path(__file__).resolve().parent.parent
CONFIG_PATH = SKILL_DIR / "config.json"
STATE_FILE  = SKILL_DIR / ".assistant_state.json"

# ─── Config ──────────────────────────────────────────────────────────────────

def load_config() -> dict:
    with open(CONFIG_PATH, encoding="utf-8") as f:
        return json.load(f)

def get_python() -> str:
    """Return the conda env Python executable path."""
    config = load_config()
    python = config.get("conda_env", {}).get("python", "")
    if python and Path(python).exists():
        return python
    # Try to find via conda on Windows
    conda_base = _find_conda_base()
    if conda_base:
        candidate = Path(conda_base) / "envs" / "meeting-assistant" / "python.exe"
        if candidate.exists():
            return str(candidate)
    return sys.executable

def _find_conda_base() -> str | None:
    """Search common conda installation locations."""
    # From config
    config = load_config()
    python_path = config.get("conda_env", {}).get("python", "")
    if python_path:
        p = Path(python_path)
        # Walk up to find conda base: .../anaconda/envs/xxx/python.exe → .../anaconda
        for parent in p.parents:
            if (parent / "conda.exe").exists() or (parent / "Scripts" / "conda.exe").exists():
                return str(parent)

    # Common Windows paths
    candidates = [
        r"D:\program\codesupport\anaconda",
        r"C:\ProgramData\anaconda3",
        r"C:\Users\zeron\anaconda3",
        r"C:\Users\zeron\miniconda3",
        os.path.expanduser("~/anaconda3"),
        os.path.expanduser("~/miniconda3"),
    ]
    for c in candidates:
        if Path(c).exists():
            return c
    return None

# ─── Docker / Vexa ────────────────────────────────────────────────────────────

def _find_docker() -> str | None:
    """Find docker executable on Windows or Linux."""
    # Check system PATH first
    docker = shutil.which("docker")
    if docker:
        return docker

    # Check config for explicit docker path
    try:
        cfg = load_config()
        cfg_docker = cfg.get("docker", {}).get("exe", "")
        if cfg_docker and Path(cfg_docker.replace("/", "\\")).exists():
            return cfg_docker.replace("/", "\\")
    except Exception:
        pass

    # Windows common locations
    win_paths = [
        r"C:\Program Files\Docker\Docker\resources\bin\docker.exe",
        r"C:\ProgramData\DockerDesktop\version-bin\docker.exe",
        r"C:\Windows\System32\docker.exe",
    ]
    for p in win_paths:
        if Path(p).exists():
            return p

    # Try via PowerShell (Docker Desktop may add to PATH only for PS)
    try:
        result = subprocess.run(
            ["powershell.exe", "-NoProfile", "-Command",
             "(Get-Command docker -ErrorAction SilentlyContinue).Source"],
            capture_output=True, text=True, timeout=5
        )
        path = result.stdout.strip()
        if path and Path(path).exists():
            return path
    except Exception:
        pass

    return None

def _find_docker_compose() -> list[str] | None:
    """Return docker compose command (as list). Supports both docker-compose and docker compose."""
    docker = _find_docker()
    if not docker:
        return None

    # Modern: docker compose (plugin)
    try:
        result = subprocess.run(
            [docker, "compose", "version"],
            capture_output=True, text=True, timeout=5
        )
        if result.returncode == 0:
            return [docker, "compose"]
    except Exception:
        pass

    # Legacy: docker-compose (standalone)
    dc = shutil.which("docker-compose")
    if not dc:
        dc_win = shutil.which("docker-compose.exe")
        if dc_win:
            dc = dc_win
    if dc:
        return [dc]

    return None

def start_vexa(verbose=True) -> bool:
    """
    Start Vexa Docker services (postgres + vexa bot).
    Returns True if successful or already running.
    """
    if verbose:
        print("[Vexa] Checking Docker...")

    docker = _find_docker()
    if not docker:
        print("[Vexa] ERROR: Docker not found.")
        print("[Vexa] Please install Docker Desktop: https://www.docker.com/products/docker-desktop/")
        print("[Vexa] After installing, run: docker compose up -d")
        return False

    compose_cmd = _find_docker_compose()
    if not compose_cmd:
        print("[Vexa] ERROR: docker compose not found.")
        return False

    if verbose:
        print(f"[Vexa] Docker found: {docker}")

    # Check if already running
    config = load_config()
    vexa_url = config.get("bot", {}).get("vexa_url", "http://localhost:8056")
    if _check_vexa_health(vexa_url):
        if verbose:
            print(f"[Vexa] Already running at {vexa_url}")
        return True

    # Start via docker compose
    compose_file = SKILL_DIR / "docker-compose.yml"
    if not compose_file.exists():
        print(f"[Vexa] docker-compose.yml not found at {compose_file}")
        return False

    if verbose:
        print("[Vexa] Starting services (postgres + vexa)...")

    try:
        result = subprocess.run(
            compose_cmd + ["up", "-d"],
            cwd=str(SKILL_DIR),
            capture_output=True,
            text=True,
            timeout=120,
        )
        if verbose:
            if result.stdout:
                print(result.stdout)
            if result.stderr:
                print(result.stderr)

        if result.returncode != 0:
            print(f"[Vexa] docker compose up failed (exit {result.returncode})")
            return False
    except subprocess.TimeoutExpired:
        print("[Vexa] docker compose timed out")
        return False
    except Exception as e:
        print(f"[Vexa] Error starting Vexa: {e}")
        return False

    # Wait for Vexa to be healthy
    if verbose:
        print("[Vexa] Waiting for Vexa to be ready", end="", flush=True)
    for i in range(30):
        if _check_vexa_health(vexa_url):
            if verbose:
                print(" OK")
                print(f"[Vexa] Ready at {vexa_url}")
            return True
        if verbose:
            print(".", end="", flush=True)
        time.sleep(2)

    if verbose:
        print(" TIMEOUT")
        print("[Vexa] Vexa did not become ready in time. Check: docker compose logs vexa")
    return False

def stop_vexa() -> bool:
    """Stop Vexa Docker services."""
    compose_cmd = _find_docker_compose()
    if not compose_cmd:
        print("[Vexa] docker compose not found")
        return False

    compose_file = SKILL_DIR / "docker-compose.yml"
    result = subprocess.run(
        compose_cmd + ["down"],
        cwd=str(SKILL_DIR),
        capture_output=True, text=True, timeout=30,
    )
    print(result.stdout or result.stderr)
    return result.returncode == 0

def _check_vexa_health(url: str) -> bool:
    """Return True if Vexa API is responding."""
    try:
        import requests
        resp = requests.get(f"{url}/health", timeout=3)
        return resp.status_code < 500
    except Exception:
        return False

def vexa_status() -> dict:
    """Get Vexa status info."""
    config = load_config()
    vexa_url = config.get("bot", {}).get("vexa_url", "http://localhost:8056")
    docker = _find_docker()
    compose_cmd = _find_docker_compose()

    status = {
        "docker_found": docker is not None,
        "docker_path": docker,
        "compose_found": compose_cmd is not None,
        "vexa_url": vexa_url,
        "vexa_running": _check_vexa_health(vexa_url),
    }

    if docker:
        try:
            result = subprocess.run(
                [docker, "ps", "--filter", "name=vexa", "--format", "{{.Names}}: {{.Status}}"],
                capture_output=True, text=True, timeout=5
            )
            status["containers"] = result.stdout.strip().split("\n") if result.stdout.strip() else []
        except Exception:
            status["containers"] = []

    return status

# ─── Bot Operations (wrapper around meeting_bot.py CLI) ──────────────────────

def _run_bot_cmd(*args, timeout=30) -> tuple[int, str, str]:
    """Run a meeting_bot.py command using conda Python. Returns (returncode, stdout, stderr)."""
    python = get_python()
    bot_script = Path(__file__).parent / "meeting_bot.py"
    cmd = [python, str(bot_script)] + list(args)

    result = subprocess.run(
        cmd,
        capture_output=True,
        text=True,
        timeout=timeout,
        cwd=str(SKILL_DIR),
        env={**os.environ},
    )
    return result.returncode, result.stdout, result.stderr

def join_meeting(url: str, bot_name: str = None, mode: str = "general") -> dict:
    """Join a meeting and return bot info."""
    args = ["join", url]
    if bot_name:
        args += ["--name", bot_name]

    rc, out, err = _run_bot_cmd(*args, timeout=60)
    output = (out + err).strip()
    print(output)

    success = rc == 0 or "Bot ID:" in output or "Joined" in output.lower()
    bot_id = None
    for line in output.split("\n"):
        if "Bot ID:" in line:
            bot_id = line.split("Bot ID:")[-1].strip()
            break

    return {"success": success, "bot_id": bot_id, "output": output}

def send_chat(message: str) -> bool:
    """Send a message to the meeting chat."""
    rc, out, err = _run_bot_cmd("chat", message, timeout=15)
    print(f"[Chat→Meeting] {message[:80]}")
    return rc == 0

def read_chat() -> list[str]:
    """Read meeting chat messages."""
    rc, out, err = _run_bot_cmd("read-chat", timeout=15)
    lines = [l for l in out.strip().split("\n") if l.strip()]
    return lines

def get_transcript() -> str:
    """Get current meeting transcript."""
    rc, out, err = _run_bot_cmd("transcript", timeout=15)
    return out.strip()

def take_screenshot(save_path: str = None) -> str | None:
    """Take a meeting screenshot and return file path."""
    args = ["screenshot"]
    if save_path:
        args += ["--output", save_path]
    rc, out, err = _run_bot_cmd(*args, timeout=20)
    combined = out + err
    for line in combined.split("\n"):
        if "Saved:" in line:
            return line.split("Saved:")[-1].strip()
        if ".png" in line:
            return line.strip()
    return None

def leave_meeting() -> bool:
    """Leave the current meeting."""
    rc, out, err = _run_bot_cmd("leave", timeout=15)
    print(out + err)
    return rc == 0

def bot_status() -> str:
    """Get bot status."""
    rc, out, err = _run_bot_cmd("status", timeout=10)
    return (out + err).strip()

def check_setup() -> bool:
    """Run setup check and return True if Vexa is reachable."""
    rc, out, err = _run_bot_cmd("setup", timeout=10)
    print(out + err)
    return rc == 0

# ─── Interactive Agent Mode ───────────────────────────────────────────────────

def print_banner():
    print("\n" + "="*60)
    print("  OpenClaw Meeting Assistant — Ready")
    print("="*60)
    print("  Python:", get_python())
    config = load_config()
    print("  Vexa:  ", config.get("bot", {}).get("vexa_url", "http://localhost:8056"))
    print("="*60)
    print()
    print("  Bot will join as: OpenClaw 助手")
    print()
    print("  用户交互方式：在会议聊天框直接输入问题")
    print("  Agent 将通过 Claude AI 实时回答")
    print()

def wait_for_url() -> str:
    """Interactively wait for the user to provide a meeting URL."""
    print("[Ready] 请提供会议链接（Zoom/Teams/Google Meet）：")
    print("  例如: https://zoom.us/j/123456789?pwd=xxx")
    print("  或直接输入会议 ID")
    print()
    while True:
        try:
            url = input("Meeting URL > ").strip()
        except (KeyboardInterrupt, EOFError):
            print("\n[Exit] 用户取消")
            sys.exit(0)

        if not url:
            continue

        # Accept raw meeting IDs too
        if url.isdigit() or (len(url) == 11 and url.replace("-", "").isdigit()):
            url = f"https://zoom.us/j/{url}"

        if any(k in url for k in ["zoom.us", "teams.microsoft", "meet.google", "https://", "http://"]):
            return url
        else:
            print(f"  [?] 无法识别的格式，请确认是 Zoom/Teams/Meet 链接")

def interactive_loop(url: str, mode: str = "general"):
    """
    Main interactive mode: I (Claude Code agent) am the controller.
    The bot handles Vexa/meeting connection; I handle analysis.
    """
    print(f"\n[Joining] {url}")
    print(f"[Mode] {mode}")
    result = join_meeting(url, mode=mode)

    if not result["success"]:
        print(f"\n[ERROR] 无法加入会议: {result['output']}")
        return

    print(f"\n[Joined] Bot ID: {result['bot_id']}")
    print("\n" + "="*60)
    print("  Bot 已加入会议！")
    print("  我（Claude Code）现在作为 Agent 开始监控会议")
    print()
    print("  会议参与者可在聊天框输入问题")
    print("  Ctrl+C 结束会话")
    print("="*60 + "\n")

    # Brief greeting via chat
    send_chat("📝 OpenClaw 助手已加入会议。您可以在聊天中直接提问，我会实时回答！")

    # Print info for the agent (me) to use
    print("\n[Agent Instructions]")
    print("  现在可以调用以下操作：")
    print("  - take_screenshot()   → 截图分析会议画面")
    print("  - get_transcript()    → 读取转录内容")
    print("  - read_chat()         → 读取聊天记录")
    print("  - send_chat('...')    → 向会议聊天发消息")
    print("  - leave_meeting()     → 离开会议")
    print()
    print("  会议 session 目录：")
    print(f"  {SKILL_DIR}/recordings/bot_*/")
    print()

# ─── CLI ──────────────────────────────────────────────────────────────────────

def main():
    parser = argparse.ArgumentParser(
        description="Meeting Assistant Launcher",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="""
Examples:
  python launch.py                              # Start Vexa + wait for URL
  python launch.py "https://zoom.us/j/123"      # Start + join immediately
  python launch.py --status                     # Check Vexa status
  python launch.py --stop-vexa                  # Stop Vexa containers
  python launch.py --no-vexa "https://..."      # Skip Vexa, join directly
        """,
    )
    parser.add_argument("meeting_url", nargs="?", help="Meeting URL to join immediately")
    parser.add_argument("--mode", choices=["general", "medical"], default="general")
    parser.add_argument("--status", action="store_true", help="Check Vexa status and exit")
    parser.add_argument("--stop-vexa", action="store_true", help="Stop Vexa and exit")
    parser.add_argument("--no-vexa", action="store_true", help="Skip Vexa startup (for testing)")
    parser.add_argument("--join-only", action="store_true", help="Join meeting and exit (no interactive loop)")

    args = parser.parse_args()

    # ── Status check ──────────────────────────────────────────────────────
    if args.status:
        st = vexa_status()
        print(json.dumps(st, indent=2, ensure_ascii=False))
        return

    # ── Stop Vexa ─────────────────────────────────────────────────────────
    if args.stop_vexa:
        print("[Vexa] Stopping services...")
        ok = stop_vexa()
        print("[Vexa] Stopped" if ok else "[Vexa] Failed to stop")
        return

    # ── Banner ────────────────────────────────────────────────────────────
    print_banner()

    # ── Start Vexa ────────────────────────────────────────────────────────
    if not args.no_vexa:
        vexa_ok = start_vexa(verbose=True)
        if not vexa_ok:
            print("\n[Warning] Vexa not available. Bot mode requires Vexa Docker.")
            print("  Install Docker Desktop and re-run, or use --no-vexa to skip.")
            ans = input("\n  Continue anyway? (y/N): ").strip().lower()
            if ans != "y":
                sys.exit(1)
    else:
        print("[Vexa] Skipping Vexa startup (--no-vexa)")

    # ── Get URL ───────────────────────────────────────────────────────────
    url = args.meeting_url
    if not url:
        url = wait_for_url()

    # ── Join and run ──────────────────────────────────────────────────────
    if args.join_only:
        result = join_meeting(url, mode=args.mode)
        print(json.dumps(result, ensure_ascii=False))
    else:
        interactive_loop(url, mode=args.mode)


if __name__ == "__main__":
    main()


path: scripts/meeting_analyzer.py
content: #!/usr/bin/env python3
"""
Meeting Analyzer — 会议分析与摘要生成
分析截屏+转录文本，生成结构化会议摘要和医疗辅助建议。
供 Agent 调用以理解会议内容。
"""

import json
from datetime import datetime
from pathlib import Path

SKILL_DIR = Path(__file__).resolve().parent.parent
CONFIG_PATH = SKILL_DIR / "config.json"


def load_config():
    with open(CONFIG_PATH) as f:
        return json.load(f)


def collect_session_data(session_dir):
    """
    Collect all available data from a session directory.
    Returns a structured dict for agent consumption.
    """
    session_dir = Path(session_dir)

    data = {
        "session_dir": str(session_dir),
        "screenshots": [],
        "transcripts": [],
        "full_transcript": None,
        "session_meta": None,
    }

    # Session metadata
    meta_path = session_dir / "session.json"
    if meta_path.exists():
        with open(meta_path) as f:
            data["session_meta"] = json.load(f)

    # Screenshots (sorted by name = chronological)
    screenshots_dir = session_dir / "screenshots"
    if screenshots_dir.exists():
        data["screenshots"] = sorted([
            str(p) for p in screenshots_dir.glob("*.png")
        ])

    # Individual transcripts
    transcripts_dir = session_dir / "transcripts"
    if transcripts_dir.exists():
        for t in sorted(transcripts_dir.glob("chunk_*.txt")):
            data["transcripts"].append({
                "file": str(t),
                "name": t.stem,
                "text": t.read_text(encoding="utf-8").strip()
            })

        full = transcripts_dir / "full_transcript.txt"
        if full.exists():
            data["full_transcript"] = full.read_text(encoding="utf-8").strip()

    return data


def generate_analysis_prompt(session_data, mode="general"):
    """
    Generate a prompt for the Agent to analyze the meeting.
    Agent should use this prompt with Claude to generate the summary.
    """
    config = load_config()
    is_medical = mode == "medical" or config.get("medical", {}).get("enabled", False)

    prompt_parts = []

    prompt_parts.append("# 会议分析任务\n")
    prompt_parts.append(f"会议录制目录: {session_data['session_dir']}\n")

    if session_data.get("session_meta"):
        meta = session_data["session_meta"]
        prompt_parts.append(f"开始时间: {meta.get('start_time', 'N/A')}")
        prompt_parts.append(f"模式: {meta.get('mode', 'general')}\n")

    # Transcript
    if session_data.get("full_transcript"):
        prompt_parts.append("## 完整转录文本\n")
        prompt_parts.append(session_data["full_transcript"])
        prompt_parts.append("")

    # Screenshots for analysis
    if session_data.get("screenshots"):
        prompt_parts.append(f"\n## 截屏文件 ({len(session_data['screenshots'])} 张)\n")
        prompt_parts.append("请逐一分析以下截屏（使用 Read 工具查看图片）：")
        for s in session_data["screenshots"]:
            prompt_parts.append(f"  - {s}")
        prompt_parts.append("")

    # Analysis instructions
    prompt_parts.append("\n## 分析要求\n")
    prompt_parts.append("请生成以下内容：\n")
    prompt_parts.append("### 1. 会议摘要")
    prompt_parts.append("- 参会人员")
    prompt_parts.append("- 主要议题")
    prompt_parts.append("- 关键讨论内容")
    prompt_parts.append("- 达成的结论/决定")
    prompt_parts.append("- 待办事项\n")

    if is_medical:
        prompt_parts.append("### 2. 医疗相关分析")
        prompt_parts.append("- 提到的症状/诊断")
        prompt_parts.append("- 处方/用药信息")
        prompt_parts.append("- 医嘱和注意事项")
        prompt_parts.append("- 下次就诊安排")
        prompt_parts.append("- **患者版简明摘要**（用通俗语言解释所有医学术语）\n")

        prompt_parts.append("### 3. 医学术语解释")
        prompt_parts.append("将会议中提到的所有医学术语列出，并给出：")
        prompt_parts.append("- 中文通俗解释")
        prompt_parts.append("- 英文对照（如适用）")
        prompt_parts.append("- 相关背景说明\n")

    prompt_parts.append(f"\n请将分析结果写入: {session_data['session_dir']}/meeting_summary.md")

    return "\n".join(prompt_parts)


def find_latest_session():
    """Find the most recent session directory."""
    config = load_config()
    base = SKILL_DIR / config.get("output_base_dir", "recordings")
    if not base.exists():
        return None
    sessions = sorted(base.iterdir(), reverse=True)
    return sessions[0] if sessions else None


def generate_realtime_prompt(screenshot_path=None, transcript_text=None, mode="general"):
    """
    Generate a prompt for real-time analysis during an active meeting.
    Used by the agent in its monitoring loop.
    """
    config = load_config()
    is_medical = mode == "medical" or config.get("medical", {}).get("enabled", False)

    parts = ["# 实时会议分析\n"]

    if screenshot_path:
        parts.append(f"## 当前画面\n请分析此截屏: {screenshot_path}\n")

    if transcript_text:
        parts.append(f"## 最近对话内容\n```\n{transcript_text}\n```\n")

    parts.append("## 请提供：\n")
    parts.append("1. 当前讨论的核心话题")
    parts.append("2. 需要注意的关键信息")

    if is_medical:
        parts.append("3. 医学术语通俗解释（如果有的话）")
        parts.append("4. 给患者的建议或提醒")
        parts.append("5. 给医生的辅助信息（如需要）")
    else:
        parts.append("3. 可能的行动建议")

    parts.append("\n请简洁回答，适合在会议进行中快速阅读。")

    return "\n".join(parts)


if __name__ == "__main__":
    import argparse

    parser = argparse.ArgumentParser(description="Meeting analysis utilities")
    sub = parser.add_subparsers(dest="command")

    p_collect = sub.add_parser("collect", help="Collect session data")
    p_collect.add_argument("session_dir", nargs="?", help="Session directory")

    p_prompt = sub.add_parser("prompt", help="Generate analysis prompt")
    p_prompt.add_argument("session_dir", nargs="?")
    p_prompt.add_argument("--mode", choices=["general", "medical"], default="general")

    p_realtime = sub.add_parser("realtime", help="Generate real-time analysis prompt")
    p_realtime.add_argument("--screenshot", help="Screenshot path")
    p_realtime.add_argument("--transcript", help="Recent transcript text")
    p_realtime.add_argument("--mode", choices=["general", "medical"], default="general")

    args = parser.parse_args()

    if args.command == "collect":
        session_dir = args.session_dir or find_latest_session()
        if session_dir:
            data = collect_session_data(session_dir)
            print(json.dumps(data, indent=2, ensure_ascii=False))
        else:
            print("No session found")

    elif args.command == "prompt":
        session_dir = args.session_dir or find_latest_session()
        if session_dir:
            data = collect_session_data(session_dir)
            print(generate_analysis_prompt(data, args.mode))
        else:
            print("No session found")

    elif args.command == "realtime":
        print(generate_realtime_prompt(
            screenshot_path=args.screenshot,
            transcript_text=args.transcript,
            mode=args.mode
        ))

    else:
        parser.print_help()


path: scripts/meeting_bot.py
content: #!/usr/bin/env python3
"""
Meeting Bot — AI 会议机器人
通过 Vexa API (自托管) 让 AI Bot 直接加入 Zoom/Teams 会议。
Bot 能：加入会议、捕获音频/视频、实时转录、在聊天中发消息。

架构:
  Vexa (Docker) ← → meeting_bot.py ← → openclaw Agent
       ↓                    ↓
  Zoom/Teams          Whisper/Claude 分析
"""

import json
import time
import requests
import threading
from pathlib import Path
from datetime import datetime

SKILL_DIR = Path(__file__).resolve().parent.parent
CONFIG_PATH = SKILL_DIR / "config.json"


def load_config():
    with open(CONFIG_PATH) as f:
        return json.load(f)


class MeetingBot:
    """
    通过 Vexa API 控制会议机器人。
    支持 Zoom、Teams、Google Meet。

    Vexa API 使用 (platform, native_meeting_id) 作为资源标识符。
    API [REDACTED_SECRET] header (来自 config.json bot.vexa_api_key)
    """

    def __init__(self, config=None):
        if config is None:
            config = load_config()
        self.config = config
        bot_cfg = config.get("bot", {})
        self.base_url = bot_cfg.get("vexa_url", "http://localhost:8056").rstrip("/")
        self.[REDACTED_SECRET]"vexa_api_key", "openclaw-meeting-bot")
        self.bot_name = bot_cfg.get("bot_name", "OpenClaw 助手")
        self.bot_id = None       # kept for backward compat
        self.platform = None
        self.meeting_id = None   # native_meeting_id
        self._polling_thread = None
        self._polling = False

    @property
    def headers(self):
        h = {"Content-Type": "application/json"}
        if self.api_key:
            h["X-API-Key"] = self.api_key
        return h

    def _meeting_path(self):
        """Return URL fragment: platform/native_meeting_id"""
        return f"{self.platform}/{self.meeting_id}"

    # ─── 会议生命周期 ──────────────────────────────────

    def join(self, meeting_url, bot_name=None, language=None):
        """
        加入会议。自动识别平台 (Zoom/Teams/Meet)。

        Args:
            meeting_url: 会议链接
            bot_name:    Bot 显示名称
            language:    转录语言代码 (e.g. "zh", "en")

        Returns:
            meeting_id
        """
        platform, meeting_id, passcode = self._parse_meeting_url(meeting_url)
        self.platform = platform
        self.meeting_id = meeting_id
        self.bot_id = meeting_id  # backward compat

        payload = {
            "platform": platform,
            "native_meeting_id": meeting_id,
            "bot_name": bot_name or self.bot_name,
        }
        if language:
            payload["language"] = language
        elif self.config.get("whisper_language"):
            payload["language"] = self.config["whisper_language"]

        print(f"[Bot] Joining {platform} meeting: {meeting_id}")
        resp = requests.post(
            f"{self.base_url}/bots",
            headers=self.headers,
            json=payload,
            timeout=30,
        )
        if resp.status_code not in (200, 201):
            print(f"[Bot] Join response: {resp.status_code} - {resp.text[:300]}")
        resp.raise_for_status()
        data = resp.json()
        print(f"[Bot] Joined! platform={platform} meeting_id={meeting_id}")
        print(f"[Bot] Response: {json.dumps(data, ensure_ascii=False)[:200]}")
        return meeting_id

    def leave(self):
        """离开会议。"""
        if not self.platform or not self.meeting_id:
            print("[Bot] No active session")
            return

        self._polling = False
        print(f"[Bot] Leaving meeting {self._meeting_path()}...")
        try:
            resp = requests.delete(
                f"{self.base_url}/bots/{self._meeting_path()}",
                headers=self.headers,
                timeout=15,
            )
            print(f"[Bot] Left: {resp.status_code}")
        except requests.RequestException as e:
            print(f"[Bot] Leave error: {e}")

    def status(self):
        """获取所有 Bot 状态。"""
        resp = requests.get(
            f"{self.base_url}/bots/status",
            headers=self.headers,
            timeout=10,
        )
        resp.raise_for_status()
        return resp.json()

    # ─── 转录 ─────────────────────────────────────────

    def get_transcript(self, since=None):
        """
        获取会议转录文本。

        Returns:
            list of {speaker, text, timestamp, ...}
        """
        if not self.platform or not self.meeting_id:
            return []

        url = f"{self.base_url}/transcripts/{self._meeting_path()}"
        params = {}
        if since:
            params["created_after"] = since

        resp = requests.get(url, headers=self.headers, params=params, timeout=15)
        if resp.status_code == 404:
            return []
        resp.raise_for_status()
        data = resp.json()

        # Normalize: Vexa returns list or {"utterances": [...]}
        if isinstance(data, list):
            return data
        return data.get("utterances", data.get("entries", data.get("transcript", [])))

    def get_full_transcript_text(self):
        """获取完整转录文本（格式化）。"""
        entries = self.get_transcript()
        lines = []
        for e in entries:
            speaker = e.get("speaker", e.get("speaker_name", "Unknown"))
            text    = e.get("text", e.get("words", e.get("content", "")))
            ts      = e.get("created_at", e.get("timestamp", ""))
            lines.append(f"[{ts}] {speaker}: {text}")
        return "\n".join(lines)

    def start_transcript_polling(self, callback=None, interval=10):
        """后台轮询转录，新内容触发 callback。"""
        self._polling = True
        last_ts = None

        def _poll():
            nonlocal last_ts
            while self._polling:
                try:
                    entries = self.get_transcript(since=last_ts)
                    if entries:
                        if last_ts and callback:
                            callback(entries)
                        last_entry = entries[-1]
                        last_ts = last_entry.get("created_at", last_entry.get("timestamp", last_ts))
                except Exception as e:
                    print(f"[Bot] Transcript poll error: {e}")
                time.sleep(interval)

        self._polling_thread = threading.Thread(target=_poll, daemon=True)
        self._polling_thread.start()
        print(f"[Bot] Transcript polling started (every {interval}s)")

    # ─── 聊天 ─────────────────────────────────────────

    def send_chat(self, message):
        """在会议聊天中发送消息。"""
        if not self.platform or not self.meeting_id:
            print("[Bot] No active session")
            return

        print(f"[Bot] Sending chat: {message[:80]}...")
        resp = requests.post(
            f"{self.base_url}/bots/{self._meeting_path()}/chat",
            headers=self.headers,
            json={"text": message},
            timeout=15,
        )
        if resp.status_code not in (200, 201):
            print(f"[Bot] Chat send: {resp.status_code} - {resp.text[:200]}")
        else:
            print("[Bot] Chat sent")
        return resp

    def read_chat(self):
        """读取会议聊天记录。"""
        if not self.platform or not self.meeting_id:
            return []

        resp = requests.get(
            f"{self.base_url}/bots/{self._meeting_path()}/chat",
            headers=self.headers,
            timeout=15,
        )
        if resp.status_code == 404:
            return []
        resp.raise_for_status()
        data = resp.json()
        return data if isinstance(data, list) else data.get("messages", data.get("items", []))

    # ─── 截图 ────────────────────────────────────────

    def get_screenshot(self, save_path=None):
        """
        启动/获取 Bot 屏幕画面。
        Vexa: POST /bots/{platform}/{id}/screen 开始屏幕共享
        实际截图需从录制流或屏幕共享中获取。
        """
        if not self.platform or not self.meeting_id:
            return None

        # Try to get a screenshot via screen endpoint
        try:
            resp = requests.post(
                f"{self.base_url}/bots/{self._meeting_path()}/screen",
                headers=self.headers,
                json={},
                timeout=10,
            )
            if resp.status_code in (200, 201):
                content_type = resp.headers.get("content-type", "")
                if "image" in content_type:
                    if save_path is None:
                        ts = datetime.now().strftime("%H%M%S")
                        save_dir = SKILL_DIR / "recordings" / "bot_screenshots"
                        save_dir.mkdir(parents=True, exist_ok=True)
                        save_path = save_dir / f"meeting_{ts}.png"
                    Path(save_path).write_bytes(resp.content)
                    print(f"[Bot] Screenshot saved: {save_path}")
                    return str(save_path)
        except Exception as e:
            print(f"[Bot] Screenshot via screen endpoint failed: {e}")

        print("[Bot] Screenshot not available (Vexa requires browser bot to be active)")
        return None

    def set_avatar(self, image_url):
        """设置 Bot 头像。"""
        if not self.platform or not self.meeting_id:
            return
        resp = requests.put(
            f"{self.base_url}/bots/{self._meeting_path()}/avatar",
            headers=self.headers,
            json={"image_url": image_url},
            timeout=10,
        )
        return resp.json()

    # ─── 工具方法 ─────────────────────────────────────

    def _parse_meeting_url(self, url):
        """
        解析会议 URL，返回 (platform, native_meeting_id, passcode)。
        """
        url = url.strip()
        passcode = None

        # Zoom
        if "zoom.us" in url or "zoom.com" in url:
            platform = "zoom"
            if "/j/" in url:
                mid = url.split("/j/")[1].split("?")[0].split("#")[0]
            elif "/wc/" in url:
                mid = url.split("/wc/")[1].split("/")[0]
            else:
                mid = url
            if "pwd=" in url:
                passcode = url.split("pwd=")[1].split("&")[0].split("#")[0]
            meeting_id = mid.replace("-", "").strip()

        # Teams
        elif "teams.microsoft.com" in url or "teams.live.com" in url:
            platform = "teams"
            meeting_id = url

        # Google Meet
        elif "meet.google.com" in url:
            platform = "google_meet"
            meeting_id = url.split("meet.google.com/")[1].split("?")[0].strip("/")

        # Raw meeting ID
        elif url.replace("-", "").isdigit():
            platform = "zoom"
            meeting_id = url.replace("-", "")

        else:
            platform = "zoom"
            meeting_id = url

        return platform, meeting_id, passcode

    def list_bots(self):
        """列出所有活跃的 Bot。"""
        resp = requests.get(
            f"{self.base_url}/bots/status",
            headers=self.headers,
            timeout=10,
        )
        resp.raise_for_status()
        return resp.json()


# ─── 高级：AI 会议助手循环 ────────────────────────────

class MeetingAssistantLoop:
    """
    AI 会议助手主循环。

    两个并发线程：
      1. 分析线程 (主线程)   — 每 interval 秒：截图 + 转录 → Claude 分析 → 聊天
      2. 聊天轮询线程 (后台)  — 每 chat_poll_interval 秒：读聊天 → 检测用户提问 → Claude 回答

    用户可以在会议聊天中直接向 Bot 提问，Bot 会实时回答。
    """

    def __init__(self, bot, config=None):
        self.bot = bot
        self.config = config or load_config()
        self.session_dir = None
        self._running = False
        self._mode = "general"

        # Data logs
        self.transcript_log = []        # all transcript entries
        self.suggestions_log = []       # all chat messages sent by bot

        # State tracking
        self._last_chat_count = 0       # index of last processed chat message
        self._latest_screenshot = None  # for providing visual context to chat replies
        self._cycle_count = 0

        # Claude API client (optional — bot still works without it)
        self.claude = None
        try:
            import sys
            sys.path.insert(0, str(Path(__file__).parent))
            from claude_client import MeetingClaudeClient
            self.claude = MeetingClaudeClient(self.config)
            print("[Assistant] Claude API client initialized")
        except ImportError as e:
            print(f"[Assistant] anthropic not installed: {e}")
            print("[Assistant] Running in data-collection-only mode")
        except ValueError as e:
            print(f"[Assistant] Claude API key not set: {e}")
            print("[Assistant] Running in data-collection-only mode")
        except Exception as e:
            print(f"[Assistant] Claude client error: {e}")

        # Chat poll thread
        self._chat_poll_stop = threading.Event()
        self._chat_thread = None

    # ─── Public API ────────────────────────────────────────────────────────

    def start(self, meeting_url, mode="general", interval=30):
        """
        启动 AI 助手：加入会议 → 双线程运行（聊天轮询 + 分析循环）。

        Args:
            meeting_url: 会议链接 (Zoom/Teams/Meet URL)
            mode:        "general" 或 "medical"
            interval:    分析间隔（秒），默认 30
        """
        self._mode = mode

        # 1. Create session directory
        ts = datetime.now().strftime("%Y%m%d_%H%M%S")
        self.session_dir = SKILL_DIR / "recordings" / f"bot_{ts}"
        self.session_dir.mkdir(parents=True, exist_ok=True)
        (self.session_dir / "screenshots").mkdir(exist_ok=True)
        (self.session_dir / "analysis").mkdir(exist_ok=True)

        # 2. Join meeting
        self.bot.join(meeting_url)

        # 3. Wait for bot to be in the meeting (poll meetings endpoint)
        print("[Assistant] Waiting for bot to join...")
        for _ in range(30):
            try:
                resp = requests.get(
                    f"{self.bot.base_url}/meetings",
                    headers=self.bot.headers,
                    timeout=10,
                )
                if resp.ok:
                    meetings = resp.json().get("meetings", [])
                    for m in meetings:
                        if (m.get("platform") == self.bot.platform and
                                m.get("native_meeting_id") == self.bot.meeting_id):
                            if m.get("status") == "active":
                                print("[Assistant] Bot is in the meeting!")
                                break
                            elif m.get("status") == "failed":
                                raise RuntimeError(f"Bot failed to join: {m.get('data', {}).get('error_details', '')[:200]}")
                    else:
                        time.sleep(2)
                        continue
                    break
            except RuntimeError:
                raise
            except Exception:
                pass
            time.sleep(2)

        # 4. Send greeting
        greeting = self._get_greeting(mode)
        try:
            self.bot.send_chat(greeting)
            self._log_suggestion(greeting, category="greeting")
        except Exception as e:
            print(f"[Assistant] Could not send greeting: {e}")

        # 5. Save session metadata
        meta = {
            "start_time": datetime.now().isoformat(),
            "meeting_url": meeting_url,
            "mode": mode,
            "bot_id": self.bot.bot_id,
            "platform": self.bot.platform,
            "claude_enabled": self.claude is not None,
        }
        with open(self.session_dir / "session.json", "w") as f:
            json.dump(meta, f, indent=2, ensure_ascii=False)

        # 6. Start chat poll thread (fast — every 5s)
        chat_interval = self.config.get("chat_poll_interval", 5)
        self._chat_poll_stop.clear()
        self._chat_thread = threading.Thread(
            target=self._chat_poll_loop,
            args=(chat_interval,),
            daemon=True,
            name="MeetingChatPoll",
        )
        self._chat_thread.start()
        print(f"[Assistant] Chat polling started (every {chat_interval}s)")

        # 7. Main analysis loop
        self._running = True
        print(f"[Assistant] Analysis loop started (every {interval}s, mode={mode})")
        claude_status = "with Claude AI" if self.claude else "data-collection only"
        print(f"[Assistant] Running {claude_status}")

        try:
            while self._running:
                self._analysis_cycle(mode)
                time.sleep(interval)
        except KeyboardInterrupt:
            print("\n[Assistant] Keyboard interrupt received")
        finally:
            self.stop()

    def stop(self):
        """停止助手、生成摘要、离开会议。"""
        if not self._running:
            return
        self._running = False

        # Stop chat thread
        self._chat_poll_stop.set()
        if self._chat_thread and self._chat_thread.is_alive():
            self._chat_thread.join(timeout=8)

        # Send farewell
        farewell = "📋 会议助手已停止记录。正在生成会议摘要，请稍候..."
        try:
            self.bot.send_chat(farewell)
        except Exception:
            pass

        # Save transcript + suggestions logs
        if self.session_dir:
            with open(self.session_dir / "transcript_log.json", "w", encoding="utf-8") as f:
                json.dump(self.transcript_log, f, ensure_ascii=False, indent=2)
            with open(self.session_dir / "suggestions_log.json", "w", encoding="utf-8") as f:
                json.dump(self.suggestions_log, f, ensure_ascii=False, indent=2)

            # Generate final summary with Claude
            if self.claude:
                print("[Assistant] Generating final meeting summary...")
                try:
                    summary = self.claude.generate_final_summary(self.session_dir, self._mode)
                    lines = summary.split("\n")[:3]
                    preview = " | ".join(l for l in lines if l.strip())[:120]
                    try:
                        self.bot.send_chat(f"✅ 会议摘要已生成！\n{preview}...\n（完整摘要已保存到文件）")
                    except Exception:
                        pass
                except Exception as e:
                    print(f"[Assistant] Summary generation failed: {e}")

        # Leave meeting
        self.bot.leave()
        print(f"[Assistant] Session saved: {self.session_dir}")

        # Update session status
        meta_path = self.session_dir / "session.json" if self.session_dir else None
        if meta_path and meta_path.exists():
            try:
                with open(meta_path, encoding="utf-8") as f:
                    meta = json.load(f)
                meta["status"] = "completed"
                meta["end_time"] = datetime.now().isoformat()
                with open(meta_path, "w", encoding="utf-8") as f:
                    json.dump(meta, f, indent=2, ensure_ascii=False)
            except Exception:
                pass

    # ─── Analysis Cycle ────────────────────────────────────────────────────

    def _analysis_cycle(self, mode):
        """
        单次分析循环（每 interval 秒）：
          截图 → 转录增量 → Claude 分析 → 若有建议则发聊天 → 记录
        """
        self._cycle_count += 1
        ts = datetime.now().strftime("%H:%M:%S")
        print(f"\n[Analysis] Cycle #{self._cycle_count} at {ts}")

        # 1. Screenshot
        screenshot_path = None
        try:
            ss_filename = f"screen_{ts.replace(':', '')}.png"
            screenshot_path = self.bot.get_screenshot(
                self.session_dir / "screenshots" / ss_filename
            )
            if screenshot_path:
                self._latest_screenshot = screenshot_path
                print(f"[Analysis] Screenshot: {ss_filename}")
        except Exception as e:
            print(f"[Analysis] Screenshot failed: {e}")

        # 2. Transcript delta
        new_entries = []
        try:
            all_entries = self.bot.get_transcript()
            new_entries = all_entries[len(self.transcript_log):]
            if new_entries:
                self.transcript_log.extend(new_entries)
                for e in new_entries:
                    speaker = e.get("speaker", "?")
                    text    = e.get("text", "")[:120]
                    print(f"  [{ts}] {speaker}: {text}")
        except Exception as e:
            print(f"[Analysis] Transcript failed: {e}")

        # 3. Claude analysis (if available and there's something to analyze)
        analysis_result = None
        chat_msg_to_send = None

        if self.claude and (new_entries or screenshot_path):
            try:
                analysis_result = self.claude.analyze_meeting_state(
                    screenshot_path=screenshot_path,
                    transcript_entries=new_entries,
                    mode=mode,
                )
                print(f"[Analysis] Claude: {analysis_result['analysis'][:120]}...")

                # Send chat message if Claude suggests one
                if analysis_result.get("chat_message"):
                    chat_msg_to_send = analysis_result["chat_message"]
            except Exception as e:
                print(f"[Analysis] Claude analysis failed: {e}")

        if chat_msg_to_send:
            try:
                self.bot.send_chat(chat_msg_to_send)
                self._log_suggestion(chat_msg_to_send, category="analysis")
                print(f"[Analysis] Sent to chat: {chat_msg_to_send[:80]}")
            except Exception as e:
                print(f"[Analysis] Chat send failed: {e}")

        # 4. Log cycle entry
        entry = {
            "cycle": self._cycle_count,
            "timestamp": ts,
            "screenshot": str(screenshot_path) if screenshot_path else None,
            "new_transcript_entries": len(new_entries),
            "analysis": analysis_result.get("analysis", "") if analysis_result else None,
            "chat_sent": chat_msg_to_send,
        }
        log_path = self.session_dir / "analysis" / "cycle_log.jsonl"
        with open(log_path, "a", encoding="utf-8") as f:
            f.write(json.dumps(entry, ensure_ascii=False) + "\n")

    # ─── Chat Poll Loop ────────────────────────────────────────────────────

    def _chat_poll_loop(self, interval: int):
        """
        后台线程：每 interval 秒读取聊天，检测用户提问，用 Claude 回答。
        用户可以通过会议聊天直接和助手交流。
        """
        print(f"[Chat] Poll loop started")
        while not self._chat_poll_stop.is_set():
            try:
                self._process_incoming_chat()
            except Exception as e:
                print(f"[Chat] Poll error: {e}")
            self._chat_poll_stop.wait(interval)
        print("[Chat] Poll loop stopped")

    def _process_incoming_chat(self):
        """Read chat, find new messages directed at bot, generate Claude replies."""
        try:
            messages = self.bot.read_chat()
        except Exception as e:
            print(f"[Chat] read_chat error: {e}")
            return

        new_messages = messages[self._last_chat_count:]
        self._last_chat_count = len(messages)

        bot_name = self.config.get("bot", {}).get("bot_name", "OpenClaw").lower()

        for msg in new_messages:
            sender = msg.get("sender") or msg.get("from") or msg.get("author") or "?"
            text   = msg.get("text")  or msg.get("message") or msg.get("content") or ""

            # Skip empty messages and messages from the bot itself
            if not text.strip():
                continue
            if sender.lower() in (bot_name, "openclaw", "bot"):
                continue

            # Detect if this message is directed at / relevant to the bot
            if not self._should_respond_to_chat(text, sender):
                continue

            print(f"[Chat] Responding to {sender}: {text[:80]}")

            if self.claude:
                try:
                    reply = self.claude.respond_to_chat(
                        user_message=text,
                        sender=sender,
                        context_screenshot=self._latest_screenshot,
                        mode=self._mode,
                    )
                except Exception as e:
                    reply = f"抱歉，处理您的问题时出错了：{e}"
            else:
                reply = (
                    f"@{sender} 我收到了您的消息，但当前运行在数据收集模式"
                    f"（Claude API 未配置）。请配置 ANTHROPIC_API_KEY 以启用 AI 回答。"
                )

            try:
                self.bot.send_chat(f"@{sender} {reply}")
                self._log_suggestion(reply, category="chat_reply", meta={"to": sender, "question": text})
                print(f"[Chat] Replied to {sender}: {reply[:80]}")
            except Exception as e:
                print(f"[Chat] Send reply failed: {e}")

    def _should_respond_to_chat(self, text: str, sender: str) -> bool:
        """
        判断一条聊天消息是否需要助手回应。

        回应条件（满足任一）：
          1. 包含 Bot 名称 / @助手
          2. 包含问号（用户在提问）
          3. 医疗模式下包含医疗关键词
          4. 以特定指令前缀开头
        """
        t_lower = text.lower().strip()

        # Explicit bot mention
        triggers = ["openclaw", "助手", "@bot", "会议助手", "ai"]
        if any(tr in t_lower for tr in triggers):
            return True

        # Question mark (Chinese or English)
        if "?" in text or "？" in text:
            return True

        # Medical keywords in medical mode
        if self._mode == "medical":
            medical_kw = ["药", "诊断", "处方", "症状", "用法", "剂量", "医嘱",
                          "medication", "diagnosis", "prescription", "dosage"]
            if any(kw in t_lower for kw in medical_kw):
                return True

        # Command prefixes
        if t_lower.startswith(("/", "!", "！", "帮", "请问", "请")):
            return True

        return False

    # ─── Helpers ───────────────────────────────────────────────────────────

    def _log_suggestion(self, message: str, category: str = "general", meta: dict | None = None):
        """Record a bot-sent chat message."""
        entry = {
            "timestamp": datetime.now().isoformat(),
            "category": category,
            "message": message,
        }
        if meta:
            entry.update(meta)
        self.suggestions_log.append(entry)

    def _get_greeting(self, mode):
        if mode == "medical":
            return (
                "🏥 OpenClaw 医疗会议助手已加入。\n"
                "我将实时记录并分析会议内容：\n"
                "• 自动转录对话\n"
                "• 识别医学术语并通俗解释\n"
                "• 在聊天中主动分享重要信息\n"
                "• 会后生成完整会议摘要\n"
                "您可以直接在聊天中向我提问，我会立即回答！"
            )
        return (
            "📝 OpenClaw 会议助手已加入。\n"
            "我将实时分析会议内容并提供辅助。\n"
            "可以在聊天中直接向我提问！"
        )


# ─── CLI ────────────────────────────────────────────

def main():
    import argparse

    parser = argparse.ArgumentParser(description="Meeting Bot — AI 会议机器人")
    sub = parser.add_subparsers(dest="command")

    # join
    p_join = sub.add_parser("join", help="加入会议")
    p_join.add_argument("meeting_url", help="会议链接 (Zoom/Teams/Meet)")
    p_join.add_argument("--name", default=None, help="Bot 显示名称")

    # leave
    sub.add_parser("leave", help="离开会议")

    # status
    sub.add_parser("status", help="查看 Bot 状态")

    # chat
    p_chat = sub.add_parser("chat", help="发送聊天消息")
    p_chat.add_argument("message", help="消息内容")

    # read-chat
    sub.add_parser("read-chat", help="读取聊天记录")

    # transcript
    sub.add_parser("transcript", help="获取转录文本")

    # screenshot
    p_ss = sub.add_parser("screenshot", help="获取会议截图")
    p_ss.add_argument("--output", "-o", help="保存路径")

    # assist — 完整 AI 助手模式
    p_assist = sub.add_parser("assist", help="启动 AI 会议助手")
    p_assist.add_argument("meeting_url", help="会议链接")
    p_assist.add_argument("--mode", choices=["general", "medical"], default="general")
    p_assist.add_argument("--interval", type=int, default=30, help="分析间隔（秒）")

    # list
    sub.add_parser("list", help="列出活跃 Bot")

    # setup
    sub.add_parser("setup", help="检查 Vexa 服务状态")

    args = parser.parse_args()
    config = load_config()
    bot = MeetingBot(config)

    if args.command == "join":
        bot.join(args.meeting_url, bot_name=args.name)
        print(f"Bot ID: {bot.bot_id}")

    elif args.command == "leave":
        # Load bot_id from session
        bot.bot_id = _load_active_bot_id()
        bot.leave()

    elif args.command == "status":
        bot.bot_id = _load_active_bot_id()
        st = bot.status()
        print(json.dumps(st, indent=2, ensure_ascii=False))

    elif args.command == "chat":
        bot.bot_id = _load_active_bot_id()
        bot.send_chat(args.message)

    elif args.command == "read-chat":
        bot.bot_id = _load_active_bot_id()
        messages = bot.read_chat()
        for m in messages:
            sender = m.get("sender", m.get("from", "?"))
            text = m.get("text", m.get("message", ""))
            print(f"  {sender}: {text}")

    elif args.command == "transcript":
        bot.bot_id = _load_active_bot_id()
        print(bot.get_full_transcript_text())

    elif args.command == "screenshot":
        bot.bot_id = _load_active_bot_id()
        path = bot.get_screenshot(args.output)
        if path:
            print(f"Saved: {path}")

    elif args.command == "assist":
        assistant = MeetingAssistantLoop(bot, config)
        assistant.start(args.meeting_url, mode=args.mode, interval=args.interval)

    elif args.command == "list":
        bots = bot.list_bots()
        print(json.dumps(bots, indent=2, ensure_ascii=False))

    elif args.command == "setup":
        _check_vexa_setup(config)

    else:
        parser.print_help()


def _load_active_bot_id():
    """从最近的 session 文件加载 bot_id。"""
    recordings = SKILL_DIR / "recordings"
    if not recordings.exists():
        return None
    sessions = sorted(recordings.glob("bot_*/session.json"), reverse=True)
    for s in sessions:
        with open(s) as f:
            data = json.load(f)
        if data.get("bot_id"):
            return data["bot_id"]
    return None


def _check_vexa_setup(config):
    """检查 Vexa 服务是否运行。"""
    import sys
    # Windows GBK terminal safe output
    def safe_print(s):
        try:
            print(s)
        except UnicodeEncodeError:
            print(s.encode('ascii', 'replace').decode('ascii'))

    bot_cfg = config.get("bot", {})
    url = bot_cfg.get("vexa_url", "http://localhost:8056")

    safe_print(f"Checking Vexa at: {url}")
    try:
        resp = requests.get(f"{url}/health", timeout=5)
        if resp.ok:
            safe_print(f"  [OK] Vexa is running")
            safe_print(f"  Response: {resp.json()}")
        else:
            safe_print(f"  [FAIL] Vexa responded with {resp.status_code}")
    except requests.ConnectionError:
        safe_print(f"  [FAIL] Cannot connect to Vexa at {url}")
        safe_print(f"\n  To start Vexa (docker-compose):")
        safe_print(f"    cd skills/meeting-assistant && docker compose up -d")
        safe_print(f"\n  Or manually:")
        safe_print(f"    docker run -d --name vexa -p 8056:8056 vexaai/vexa-lite:latest")
    except Exception as e:
        safe_print(f"  [ERROR] {e}")


if __name__ == "__main__":
    main()


path: scripts/meeting_monitor.py
content: #!/usr/bin/env python3
"""
Meeting Monitor — 会议监控主控脚本
负责协调截屏、录音、转录、分析的完整流程。
"""

import argparse
import json
import os
import signal
import subprocess
import sys
import time
import threading
from datetime import datetime
from pathlib import Path

SKILL_DIR = Path(__file__).resolve().parent.parent
CONFIG_PATH = SKILL_DIR / "config.json"
PID_FILE = SKILL_DIR / ".monitor.pid"
SESSION_FILE = None  # set at runtime


def load_config():
    with open(CONFIG_PATH) as f:
        return json.load(f)


def get_conda_bin(config, name):
    """Get path to an executable from the conda env config."""
    conda = config.get("conda_env", {})
    # Direct path override in config
    if name in conda:
        return conda[name]
    # Try Scripts dir
    scripts_dir = conda.get("scripts_dir", "")
    if scripts_dir:
        candidate = Path(scripts_dir) / f"{name}.exe"
        if candidate.exists():
            return str(candidate)
    return name  # fallback to system PATH


def get_session_dir(config, custom_dir=None):
    if custom_dir:
        p = Path(custom_dir)
    else:
        ts = datetime.now().strftime("%Y%m%d_%H%M%S")
        p = SKILL_DIR / config["output_base_dir"] / ts
    p.mkdir(parents=True, exist_ok=True)
    for sub in ["audio", "audio/chunks", "screenshots", "transcripts", "analysis"]:
        (p / sub).mkdir(parents=True, exist_ok=True)
    return p


def save_session_meta(session_dir, config, mode, extra=None):
    meta = {
        "start_time": datetime.now().isoformat(),
        "mode": mode,
        "config": config,
        "status": "recording",
    }
    if extra:
        meta.update(extra)
    with open(session_dir / "session.json", "w") as f:
        json.dump(meta, f, indent=2, ensure_ascii=False)
    return meta


def update_session_status(session_dir, status):
    meta_path = session_dir / "session.json"
    if meta_path.exists():
        with open(meta_path) as f:
            meta = json.load(f)
    else:
        meta = {}
    meta["status"] = status
    meta["end_time"] = datetime.now().isoformat()
    with open(meta_path, "w") as f:
        json.dump(meta, f, indent=2, ensure_ascii=False)


# ─── Audio Recording ────────────────────────────────────────

class AudioRecorder:
    """Record system audio via ffmpeg (PulseAudio monitor on Linux, WASAPI loopback on Windows)."""

    def __init__(self, session_dir, config):
        self.session_dir = session_dir
        self.config = config
        self.process = None
        self.output_path = session_dir / "audio" / "full_recording.wav"

    def _detect_audio_source(self):
        """Detect the best audio source for recording system audio."""
        device = self.config.get("audio", {}).get("device", "default")
        if device != "default":
            return device

        ffmpeg = get_conda_bin(self.config, "ffmpeg")
        is_windows = sys.platform == "win32" or ffmpeg.endswith(".exe")

        if is_windows:
            # On Windows, list dshow audio devices
            try:
                result = subprocess.run(
                    [ffmpeg, "-list_devices", "true", "-f", "dshow", "-i", "dummy"],
                    capture_output=True, text=True, timeout=10
                )
                # Parse stderr for audio devices
                lines = result.stderr.split("\n")
                audio_section = False
                for line in lines:
                    if "audio" in line.lower() and "DirectShow" in line:
                        audio_section = True
                    if audio_section and '"' in line:
                        # Extract device name between quotes
                        start = line.index('"') + 1
                        end = line.index('"', start)
                        device_name = line[start:end]
                        if device_name and "video" not in device_name.lower():
                            return device_name
            except (FileNotFoundError, subprocess.TimeoutExpired, ValueError):
                pass
            # Windows fallback: Stereo Mix (common loopback name)
            return "Stereo Mix (Realtek(R) Audio)"

        # Linux: Try PulseAudio monitor source (captures system output)
        try:
            result = subprocess.run(
                ["pactl", "list", "short", "sources"],
                capture_output=True, text=True, timeout=5
            )
            for line in result.stdout.strip().split("\n"):
                if ".monitor" in line:
                    return line.split("\t")[1]
        except (FileNotFoundError, subprocess.TimeoutExpired):
            pass

        # Fallback: try pipewire
        try:
            result = subprocess.run(
                ["pw-cli", "list-objects"],
                capture_output=True, text=True, timeout=5
            )
            if result.returncode == 0:
                return "pipewire"
        except (FileNotFoundError, subprocess.TimeoutExpired):
            pass

        return "default"

    def start(self):
        source = self._detect_audio_source()
        sample_rate = self.config.get("audio", {}).get("sample_rate", 16000)
        channels = self.config.get("audio", {}).get("channels", 1)
        ffmpeg = get_conda_bin(self.config, "ffmpeg")

        # On Windows (conda env), use dshow for audio capture
        is_windows = sys.platform == "win32" or ffmpeg.endswith(".exe")
        if is_windows:
            cmd = [
                ffmpeg, "-y",
                "-f", "dshow",
                "-i", f"audio={source}",
                "-ac", str(channels),
                "-ar", str(sample_rate),
                "-acodec", "pcm_s16le",
                str(self.output_path)
            ]
        else:
            cmd = [
                ffmpeg, "-y",
                "-f", "pulse",
                "-i", source,
                "-ac", str(channels),
                "-ar", str(sample_rate),
                "-acodec", "pcm_s16le",
                str(self.output_path)
            ]

        print(f"[Audio] Recording from: {source}")
        print(f"[Audio] Output: {self.output_path}")

        self.process = subprocess.Popen(
            cmd,
            stdout=subprocess.DEVNULL,
            stderr=subprocess.PIPE,
        )

    def stop(self):
        if self.process:
            self.process.send_signal(signal.SIGINT)
            try:
                self.process.wait(timeout=10)
            except subprocess.TimeoutExpired:
                self.process.kill()
            print(f"[Audio] Recording saved: {self.output_path}")

    def split_chunks(self):
        """Split full recording into chunks for Whisper processing."""
        chunk_minutes = self.config.get("audio_chunk_minutes", 5)
        chunks_dir = self.session_dir / "audio" / "chunks"
        full_audio = self.output_path

        if not full_audio.exists():
            print("[Audio] No recording found to split")
            return []

        ffmpeg = get_conda_bin(self.config, "ffmpeg")
        cmd = [
            ffmpeg, "-y",
            "-i", str(full_audio),
            "-f", "segment",
            "-segment_time", str(chunk_minutes * 60),
            "-c", "copy",
            str(chunks_dir / "chunk_%03d.wav")
        ]

        subprocess.run(cmd, capture_output=True)
        chunks = sorted(chunks_dir.glob("chunk_*.wav"))
        print(f"[Audio] Split into {len(chunks)} chunks")
        return chunks


# ─── Screenshot Capture ─────────────────────────────────────

class ScreenCapture:
    """Capture screenshots periodically, using pc-control skill or ffmpeg."""

    def __init__(self, session_dir, config):
        self.session_dir = session_dir
        self.config = config
        self.screenshots_dir = session_dir / "screenshots"
        self.counter = 0
        self._stop_event = threading.Event()
        self._thread = None

    def _capture_one(self):
        """Take a single screenshot."""
        self.counter += 1
        filename = f"screen_{self.counter:04d}.png"
        filepath = self.screenshots_dir / filename

        use_pc_control = self.config.get("screenshot", {}).get("use_pc_control", True)

        if use_pc_control:
            # Use pc-control skill's client
            try:
                pc_scripts = SKILL_DIR.parent / "pc-control" / "scripts"
                sys.path.insert(0, str(pc_scripts))
                from client import PCControl
                pc = PCControl()
                scale = self.config.get("screenshot", {}).get("scale", 0.5)
                quality = self.config.get("screenshot", {}).get("quality", 70)
                tmp_path = pc.screenshot(scale=scale, quality=quality)
                if tmp_path and Path(tmp_path).exists():
                    Path(tmp_path).rename(filepath)
                    return filepath
            except Exception as e:
                print(f"[Screenshot] pc-control failed: {e}, falling back to ffmpeg")

        # Fallback: ffmpeg screen grab (Windows via gdigrab)
        try:
            powershell = "/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe"
            win_path = str(filepath).replace("/", "\\")
            # Use PowerShell to take screenshot
            ps_script = f"""
Add-Type -AssemblyName System.Windows.Forms
$screen = [System.Windows.Forms.Screen]::PrimaryScreen.Bounds
$bitmap = New-Object System.Drawing.Bitmap($screen.Width, $screen.Height)
$graphics = [System.Drawing.Graphics]::FromImage($bitmap)
$graphics.CopyFromScreen($screen.Location, [System.Drawing.Point]::Empty, $screen.Size)
$bitmap.Save('{win_path}')
$graphics.Dispose()
$bitmap.Dispose()
"""
            subprocess.run(
                [powershell, "-Command", ps_script],
                capture_output=True, timeout=15
            )
            if filepath.exists():
                return filepath
        except Exception as e:
            print(f"[Screenshot] PowerShell capture failed: {e}")

        # Last fallback: import/scrot on Linux
        try:
            subprocess.run(
                ["scrot", str(filepath)],
                capture_output=True, timeout=10
            )
            if filepath.exists():
                return filepath
        except FileNotFoundError:
            pass

        print(f"[Screenshot] All capture methods failed for {filename}")
        return None

    def _loop(self, interval):
        while not self._stop_event.is_set():
            path = self._capture_one()
            ts = datetime.now().strftime("%H:%M:%S")
            if path:
                print(f"[Screenshot] {ts} → {path.name}")
            self._stop_event.wait(interval)

    def start(self, interval=30):
        self._stop_event.clear()
        self._thread = threading.Thread(target=self._loop, args=(interval,), daemon=True)
        self._thread.start()
        print(f"[Screenshot] Capturing every {interval}s")

    def stop(self):
        self._stop_event.set()
        if self._thread:
            self._thread.join(timeout=5)
        print(f"[Screenshot] Stopped. Total captures: {self.counter}")

    def snapshot(self):
        """Take a single immediate screenshot."""
        return self._capture_one()


# ─── Whisper Transcription ──────────────────────────────────

class Transcriber:
    """Transcribe audio chunks using OpenAI Whisper."""

    def __init__(self, session_dir, config):
        self.session_dir = session_dir
        self.config = config
        self.transcripts_dir = session_dir / "transcripts"

    def transcribe_chunk(self, audio_path):
        """Transcribe a single audio file."""
        model = self.config.get("whisper_model", "base")
        language = self.config.get("whisper_language", "zh")
        whisper_bin = get_conda_bin(self.config, "whisper")

        output_name = audio_path.stem
        output_path = self.transcripts_dir / f"{output_name}.txt"

        cmd = [
            whisper_bin,
            str(audio_path),
            "--model", model,
            "--language", language,
            "--output_format", "txt",
            "--output_dir", str(self.transcripts_dir),
        ]

        device = self.config.get("integration", {}).get("whisper_device", "cpu")
        if device != "cpu":
            cmd.extend(["--device", device])

        print(f"[Whisper] Transcribing: {audio_path.name}")
        result = subprocess.run(cmd, capture_output=True, text=True)

        if result.returncode != 0:
            print(f"[Whisper] Error: {result.stderr[:200]}")
            return None

        if output_path.exists():
            return output_path
        # whisper may name output differently
        candidates = list(self.transcripts_dir.glob(f"{output_name}*"))
        return candidates[0] if candidates else None

    def transcribe_all(self, chunks):
        """Transcribe all audio chunks and merge into full transcript."""
        transcripts = []
        for chunk in sorted(chunks):
            result = self.transcribe_chunk(chunk)
            if result:
                transcripts.append(result)

        # Merge into full transcript
        full_path = self.transcripts_dir / "full_transcript.txt"
        with open(full_path, "w", encoding="utf-8") as out:
            for i, t in enumerate(transcripts):
                text = t.read_text(encoding="utf-8").strip()
                chunk_min = i * self.config.get("audio_chunk_minutes", 5)
                out.write(f"\n--- [{chunk_min}-{chunk_min + 5} min] ---\n")
                out.write(text + "\n")

        print(f"[Whisper] Full transcript: {full_path}")
        return full_path

    def transcribe_recent(self, full_audio_path, last_minutes=5):
        """Transcribe only the last N minutes of audio."""
        temp_path = self.session_dir / "audio" / "_recent_clip.wav"
        ffmpeg = get_conda_bin(self.config, "ffmpeg")
        cmd = [
            ffmpeg, "-y",
            "-sseof", f"-{last_minutes * 60}",
            "-i", str(full_audio_path),
            "-c", "copy",
            str(temp_path)
        ]
        subprocess.run(cmd, capture_output=True)
        if temp_path.exists():
            result = self.transcribe_chunk(temp_path)
            temp_path.unlink(missing_ok=True)
            return result
        return None


# ─── Main Monitor Controller ────────────────────────────────

class MeetingMonitor:
    """Orchestrates the full meeting monitoring pipeline."""

    def __init__(self, config, session_dir, mode="general"):
        self.config = config
        self.session_dir = session_dir
        self.mode = mode
        self.audio = AudioRecorder(session_dir, config)
        self.screen = ScreenCapture(session_dir, config)
        self.transcriber = Transcriber(session_dir, config)
        self._running = False

    def start(self, interval=30):
        """Start recording audio and capturing screenshots."""
        self._running = True
        save_session_meta(self.session_dir, self.config, self.mode)

        # Save PID for external stop
        with open(PID_FILE, "w") as f:
            json.dump({
                "pid": os.getpid(),
                "session_dir": str(self.session_dir),
                "start_time": datetime.now().isoformat()
            }, f)

        print(f"[Monitor] Starting meeting monitor")
        print(f"[Monitor] Mode: {self.mode}")
        print(f"[Monitor] Session: {self.session_dir}")
        print(f"[Monitor] Screenshot interval: {interval}s")

        # Start audio recording
        try:
            self.audio.start()
        except Exception as e:
            print(f"[Monitor] Audio recording failed to start: {e}")
            print("[Monitor] Continuing with screenshots only")

        # Start screenshot loop
        self.screen.start(interval=interval)

        # Keep running until signaled
        def handle_stop(signum, frame):
            print(f"\n[Monitor] Received stop signal")
            self.stop()
            sys.exit(0)

        signal.signal(signal.SIGINT, handle_stop)
        signal.signal(signal.SIGTERM, handle_stop)

        print("[Monitor] Running... (Ctrl+C or `meeting_monitor.py stop` to end)")

        # Block until stopped
        try:
            while self._running:
                time.sleep(1)
        except KeyboardInterrupt:
            self.stop()

    def stop(self):
        """Stop all recording and generate outputs."""
        self._running = False
        print("[Monitor] Stopping...")

        self.screen.stop()
        self.audio.stop()

        # Split audio and transcribe
        print("[Monitor] Processing audio...")
        chunks = self.audio.split_chunks()
        if chunks:
            self.transcriber.transcribe_all(chunks)

        update_session_status(self.session_dir, "completed")
        PID_FILE.unlink(missing_ok=True)

        print(f"[Monitor] Session complete: {self.session_dir}")
        self._print_summary()

    def snapshot(self):
        """Take immediate screenshot for analysis."""
        path = self.screen.snapshot()
        if path:
            print(f"[Snapshot] Saved: {path}")
            print(f"[Snapshot] Use Claude to analyze: Read {path}")
        return path

    def get_recent_transcript(self, minutes=5):
        """Get transcript of last N minutes."""
        audio_path = self.session_dir / "audio" / "full_recording.wav"
        if audio_path.exists():
            return self.transcriber.transcribe_recent(audio_path, minutes)
        print("[Transcript] No audio recording found")
        return None

    def _print_summary(self):
        screenshots = list((self.session_dir / "screenshots").glob("*.png"))
        transcripts = list((self.session_dir / "transcripts").glob("*.txt"))
        print(f"\n{'='*50}")
        print(f"  Meeting Session Summary")
        print(f"{'='*50}")
        print(f"  Screenshots captured: {len(screenshots)}")
        print(f"  Transcript files:     {len(transcripts)}")
        print(f"  Output directory:     {self.session_dir}")
        print(f"{'='*50}")
        print(f"\nTo generate AI summary, ask the agent to:")
        print(f"  1. Read {self.session_dir}/transcripts/full_transcript.txt")
        print(f"  2. Analyze screenshots in {self.session_dir}/screenshots/")
        print(f"  3. Generate meeting_summary.md")


# ─── CLI ────────────────────────────────────────────────────

def cmd_start(args):
    config = load_config()
    if args.mode == "medical":
        config["medical"]["enabled"] = True
    session_dir = get_session_dir(config, args.output_dir)
    interval = args.interval or config.get("screenshot_interval", 30)
    monitor = MeetingMonitor(config, session_dir, mode=args.mode)
    monitor.start(interval=interval)


def cmd_stop(args):
    if not PID_FILE.exists():
        print("[Monitor] No active session found")
        return

    with open(PID_FILE) as f:
        info = json.load(f)

    pid = info["pid"]
    print(f"[Monitor] Sending stop signal to PID {pid}")
    try:
        os.kill(pid, signal.SIGTERM)
        print("[Monitor] Stop signal sent")
    except ProcessLookupError:
        print("[Monitor] Process not found, cleaning up")
        PID_FILE.unlink(missing_ok=True)


def cmd_snapshot(args):
    if not PID_FILE.exists():
        # No active session, just take a one-off screenshot
        config = load_config()
        session_dir = get_session_dir(config)
        screen = ScreenCapture(session_dir, config)
        path = screen.snapshot()
        if path:
            print(f"Screenshot saved: {path}")
        return

    with open(PID_FILE) as f:
        info = json.load(f)
    session_dir = Path(info["session_dir"])
    config = load_config()
    screen = ScreenCapture(session_dir, config)
    path = screen.snapshot()
    if path:
        print(f"Screenshot saved: {path}")


def cmd_transcript(args):
    config = load_config()
    if PID_FILE.exists():
        with open(PID_FILE) as f:
            info = json.load(f)
        session_dir = Path(info["session_dir"])
    else:
        # Find most recent session
        base = SKILL_DIR / config["output_base_dir"]
        sessions = sorted(base.iterdir(), reverse=True) if base.exists() else []
        if not sessions:
            print("No sessions found")
            return
        session_dir = sessions[0]

    transcriber = Transcriber(session_dir, config)
    audio_path = session_dir / "audio" / "full_recording.wav"
    if audio_path.exists():
        result = transcriber.transcribe_recent(audio_path, args.last)
        if result:
            print(f"\n--- Last {args.last} minutes ---")
            print(result.read_text(encoding="utf-8"))
    else:
        print("No audio recording found in current session")


def cmd_status(args):
    if PID_FILE.exists():
        with open(PID_FILE) as f:
            info = json.load(f)
        print(f"[Status] Active session")
        print(f"  PID:     {info['pid']}")
        print(f"  Dir:     {info['session_dir']}")
        print(f"  Started: {info['start_time']}")

        session_dir = Path(info["session_dir"])
        screenshots = list((session_dir / "screenshots").glob("*.png"))
        print(f"  Screenshots so far: {len(screenshots)}")

        audio_path = session_dir / "audio" / "full_recording.wav"
        if audio_path.exists():
            size_mb = audio_path.stat().st_size / (1024 * 1024)
            print(f"  Audio size: {size_mb:.1f} MB")
    else:
        print("[Status] No active session")


def main():
    parser = argparse.ArgumentParser(description="Meeting Monitor — 会议监控")
    sub = parser.add_subparsers(dest="command")

    p_start = sub.add_parser("start", help="Start meeting monitoring")
    p_start.add_argument("--interval", type=int, help="Screenshot interval in seconds")
    p_start.add_argument("--audio-device", help="Audio input device")
    p_start.add_argument("--output-dir", help="Custom output directory")
    p_start.add_argument("--mode", choices=["general", "medical"], default="general")

    sub.add_parser("stop", help="Stop monitoring and generate summary")
    sub.add_parser("snapshot", help="Take immediate screenshot")

    p_trans = sub.add_parser("transcript", help="Get recent transcript")
    p_trans.add_argument("--last", type=int, default=5, help="Last N minutes")

    sub.add_parser("status", help="Check monitor status")

    args = parser.parse_args()

    commands = {
        "start": cmd_start,
        "stop": cmd_stop,
        "snapshot": cmd_snapshot,
        "transcript": cmd_transcript,
        "status": cmd_status,
    }

    if args.command in commands:
        commands[args.command](args)
    else:
        parser.print_help()


if __name__ == "__main__":
    main()


path: scripts/transcribe.py
content: #!/usr/bin/env python3
"""
Transcribe 模块 — Whisper 语音转文字
支持批量转录、实时转录最近N分钟、多语言
"""

import argparse
import json
import subprocess
import tempfile
from datetime import datetime
from pathlib import Path

SKILL_DIR = Path(__file__).resolve().parent.parent
CONFIG_PATH = SKILL_DIR / "config.json"


def load_config():
    with open(CONFIG_PATH) as f:
        return json.load(f)


def transcribe_file(audio_path, output_dir=None, model="base", language="zh", device="cpu"):
    """
    Transcribe a single audio file using Whisper CLI.

    Returns: Path to transcript text file, or None on failure.
    """
    audio_path = Path(audio_path)
    if not audio_path.exists():
        print(f"[Transcribe] File not found: {audio_path}")
        return None

    if output_dir is None:
        output_dir = audio_path.parent
    output_dir = Path(output_dir)
    output_dir.mkdir(parents=True, exist_ok=True)

    cmd = [
        "whisper",
        str(audio_path),
        "--model", model,
        "--language", language,
        "--output_format", "txt",
        "--output_dir", str(output_dir),
    ]

    if device != "cpu":
        cmd.extend(["--device", device])

    print(f"[Transcribe] Processing: {audio_path.name} (model={model}, lang={language})")

    result = subprocess.run(cmd, capture_output=True, text=True, timeout=600)

    if result.returncode != 0:
        print(f"[Transcribe] Error: {result.stderr[:300]}")
        return None

    # Find the output file (whisper names it based on input)
    expected = output_dir / f"{audio_path.stem}.txt"
    if expected.exists():
        print(f"[Transcribe] Done: {expected}")
        return expected

    # Search for any new .txt files
    candidates = list(output_dir.glob(f"{audio_path.stem}*.txt"))
    if candidates:
        return candidates[0]

    print("[Transcribe] Output file not found")
    return None


def transcribe_chunks(chunks_dir, output_dir, config=None):
    """Transcribe all audio chunks in a directory and merge results."""
    if config is None:
        config = load_config()

    chunks_dir = Path(chunks_dir)
    output_dir = Path(output_dir)
    output_dir.mkdir(parents=True, exist_ok=True)

    model = config.get("whisper_model", "base")
    language = config.get("whisper_language", "zh")
    device = config.get("integration", {}).get("whisper_device", "cpu")
    chunk_minutes = config.get("audio_chunk_minutes", 5)

    chunks = sorted(chunks_dir.glob("*.wav"))
    if not chunks:
        print("[Transcribe] No audio chunks found")
        return None

    print(f"[Transcribe] Processing {len(chunks)} chunks...")
    transcripts = []

    for i, chunk in enumerate(chunks):
        result = transcribe_file(chunk, output_dir, model, language, device)
        if result:
            transcripts.append((i, result))

    # Merge into full transcript
    full_path = output_dir / "full_transcript.txt"
    with open(full_path, "w", encoding="utf-8") as out:
        out.write(f"# Meeting Transcript\n")
        out.write(f"# Generated: {datetime.now().isoformat()}\n")
        out.write(f"# Model: {model}, Language: {language}\n\n")

        for i, t_path in transcripts:
            text = t_path.read_text(encoding="utf-8").strip()
            start_min = i * chunk_minutes
            end_min = start_min + chunk_minutes
            out.write(f"\n## [{start_min:02d}:{00:02d} - {end_min:02d}:{00:02d}]\n\n")
            out.write(text + "\n")

    print(f"[Transcribe] Full transcript saved: {full_path}")
    return full_path


def transcribe_recent(audio_path, last_minutes=5, config=None):
    """Extract and transcribe the last N minutes of an audio file."""
    if config is None:
        config = load_config()

    audio_path = Path(audio_path)
    if not audio_path.exists():
        print(f"[Transcribe] Audio not found: {audio_path}")
        return None

    model = config.get("whisper_model", "base")
    language = config.get("whisper_language", "zh")
    device = config.get("integration", {}).get("whisper_device", "cpu")

    # Extract last N minutes
    with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp:
        tmp_path = Path(tmp.name)

    cmd = [
        "ffmpeg", "-y",
        "-sseof", f"-{last_minutes * 60}",
        "-i", str(audio_path),
        "-c", "copy",
        str(tmp_path)
    ]

    result = subprocess.run(cmd, capture_output=True, text=True)
    if result.returncode != 0 or not tmp_path.exists():
        print(f"[Transcribe] Failed to extract audio clip")
        return None

    transcript = transcribe_file(tmp_path, tmp_path.parent, model, language, device)

    # Read and return text
    text = None
    if transcript and transcript.exists():
        text = transcript.read_text(encoding="utf-8").strip()
        transcript.unlink(missing_ok=True)

    tmp_path.unlink(missing_ok=True)
    return text


def check_whisper_installed():
    """Check if Whisper is available."""
    try:
        result = subprocess.run(
            ["whisper", "--help"],
            capture_output=True, text=True, timeout=10
        )
        return result.returncode == 0
    except (FileNotFoundError, subprocess.TimeoutExpired):
        return False


if __name__ == "__main__":
    parser = argparse.ArgumentParser(description="Whisper transcription utility")
    sub = parser.add_subparsers(dest="command")

    p_file = sub.add_parser("file", help="Transcribe single file")
    p_file.add_argument("audio", help="Audio file path")
    p_file.add_argument("--output-dir", "-o", help="Output directory")

    p_chunks = sub.add_parser("chunks", help="Transcribe all chunks in directory")
    p_chunks.add_argument("chunks_dir", help="Directory with audio chunks")
    p_chunks.add_argument("--output-dir", "-o", required=True)

    p_recent = sub.add_parser("recent", help="Transcribe last N minutes")
    p_recent.add_argument("audio", help="Full audio file path")
    p_recent.add_argument("--last", type=int, default=5, help="Last N minutes")

    p_check = sub.add_parser("check", help="Check Whisper installation")

    args = parser.parse_args()

    if args.command == "file":
        config = load_config()
        transcribe_file(
            args.audio,
            args.output_dir,
            model=config.get("whisper_model", "base"),
            language=config.get("whisper_language", "zh"),
            device=config.get("integration", {}).get("whisper_device", "cpu")
        )

    elif args.command == "chunks":
        transcribe_chunks(args.chunks_dir, args.output_dir)

    elif args.command == "recent":
        text = transcribe_recent(args.audio, args.last)
        if text:
            print(f"\n--- Last {args.last} minutes ---\n")
            print(text)
        else:
            print("Transcription failed")

    elif args.command == "check":
        if check_whisper_installed():
            print("Whisper is installed and available")
        else:
            print("Whisper not found. Install with: pip install openai-whisper")

    else:
        parser.print_help()


path: scripts/whisper_proxy.py
content: """
Whisper proxy: rewrites model=default -> model=Systran/faster-whisper-base
Listens on :8001, forwards to http://localhost:8000
"""
from http.server import HTTPServer, BaseHTTPRequestHandler
import urllib.request, urllib.error, re, sys

UPSTREAM = 'http://localhost:8000'
CORRECT_MODEL = b'Systran/faster-whisper-base'


class ProxyHandler(BaseHTTPRequestHandler):
    def log_message(self, fmt, *args):
        pass  # silent

    def do_POST(self):
        length = int(self.headers.get('Content-Length', 0))
        body = self.rfile.read(length)

        # Rewrite model field in multipart form: name="model"\r\n\r\n<value>
        if b'name="model"' in body:
            body = re.sub(
                rb'(name="model"\r\n\r\n)[^\r\n\-]+',
                lambda m: m.group(1) + CORRECT_MODEL,
                body,
            )

        headers = {k: v for k, v in self.headers.items()
                   if k.lower() not in ('host', 'content-length')}
        headers['Content-Length'] = str(len(body))

        req = urllib.request.Request(
            UPSTREAM + self.path, data=body,
            headers=headers, method='POST',
        )
        try:
            with urllib.request.urlopen(req, timeout=120) as resp:
                data = resp.read()
                self.send_response(resp.status)
                for k, v in resp.headers.items():
                    if k.lower() not in ('transfer-encoding',):
                        self.send_header(k, v)
                self.end_headers()
                self.wfile.write(data)
        except urllib.error.HTTPError as e:
            data = e.read()
            self.send_response(e.code)
            self.end_headers()
            self.wfile.write(data)

    def do_GET(self):
        req = urllib.request.Request(UPSTREAM + self.path)
        try:
            with urllib.request.urlopen(req, timeout=10) as resp:
                data = resp.read()
                self.send_response(resp.status)
                for k, v in resp.headers.items():
                    if k.lower() not in ('transfer-encoding',):
                        self.send_header(k, v)
                self.end_headers()
                self.wfile.write(data)
        except Exception:
            self.send_response(502)
            self.end_headers()


if __name__ == '__main__':
    port = int(sys.argv[1]) if len(sys.argv) > 1 else 8001
    server = HTTPServer(('0.0.0.0', port), ProxyHandler)
    print(f'Whisper proxy on :{port} -> {UPSTREAM}', flush=True)
    server.serve_forever()


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