1	#!/usr/bin/env python3
2	"""Mock 测试脚本 - 模拟 QQ 消息输入。"""
3
4	import asyncio
5	import json
6	import sys
7	import httpx
8
9
10	async def send_message(user_id: str, content: str):
11	    """发送自然语言消息。"""
12	    async with httpx.AsyncClient() as client:
13	        response = await client.post(
14	            "http://localhost:9600/api/mock/message",
15	            json={"user_id": user_id, "content": content},
16	            headers={"Content-Type": "application/json"},
17	            timeout=60.0,
18	        )
19	        if response.status_code != 200:
20	            print(f"[ERROR] {response.status_code}: {response.text}")
21	            return
22
23	        data = response.json()
24	        mode = data.get("mode", "unknown")
25	        command = data.get("command", "")
26	        reply = data.get("reply", "")
27
28	        if command:
29	            print(f"  命令: {command}")
30	        print(f"  回复: {reply}")
31
32
33	async def get_raw_output(user_id: str, limit: int = 5):
34	    """获取原始输出日志。"""
35	    async with httpx.AsyncClient() as client:
36	        response = await client.get(
37	            "http://localhost:9600/api/raw-output",
38	            params={"user_id": user_id, "limit": limit},
39	            timeout=30.0,
40	        )
41	        data = response.json()
42	        for log in data.get("logs", []):
43	            print(f"  [{log['timestamp']}] {log['command']}")
44	            if log["output"]:
45	                # 截取前 200 字符
46	                print(f"    {log['output'][:200]}")
47
48
49	async def main():
50	    if len(sys.argv) < 2:
51	        print("Usage: python mock_test.py <command> [args]")
52	        print("\nCommands:")
53	        print("  <text>          发送自然语言消息")
54	        print("  raw [user_id]   查看原始输出日志")
55	        return
56
57	    user_id = "test_user_001"
58
59	    if sys.argv[1] == "raw":
60	        uid = sys.argv[2] if len(sys.argv) > 2 else user_id
61	        limit = int(sys.argv[3]) if len(sys.argv) > 3 else 5
62	        await get_raw_output(uid, limit)
63	    else:
64	        content = " ".join(sys.argv[1:])
65	        print(f"发送: {content}")
66	        await send_message(user_id, content)
67
68
69	if __name__ == "__main__":
70	    asyncio.run(main())
71

#!/usr/bin/env python3
"""Mock 测试脚本 - 模拟 QQ 消息输入。"""

import asyncio
import json
import sys
import httpx

async def send_message(user_id: str, content: str):
    """发送自然语言消息。"""
    async with httpx.AsyncClient() as client:
        response = await client.post(
            "http://localhost:9600/api/mock/message",
            json={"user_id": user_id, "content": content},
            headers={"Content-Type": "application/json"},
            timeout=60.0,
        )
        if response.status_code != 200:
            print(f"[ERROR] {response.status_code}: {response.text}")
            return

        data = response.json()
        mode = data.get("mode", "unknown")
        command = data.get("command", "")
        reply = data.get("reply", "")

        if command:
            print(f"  命令: {command}")
        print(f"  回复: {reply}")

async def get_raw_output(user_id: str, limit: int = 5):
    """获取原始输出日志。"""
    async with httpx.AsyncClient() as client:
        response = await client.get(
            "http://localhost:9600/api/raw-output",
            params={"user_id": user_id, "limit": limit},
            timeout=30.0,
        )
        data = response.json()
        for log in data.get("logs", []):
            print(f"  [{log['timestamp']}] {log['command']}")
            if log["output"]:
                # 截取前 200 字符
                print(f"    {log['output'][:200]}")

async def main():
    if len(sys.argv) < 2:
        print("Usage: python mock_test.py <command> [args]")
        print("\nCommands:")
        print("  <text>          发送自然语言消息")
        print("  raw [user_id]   查看原始输出日志")
        return

    user_id = "test_user_001"

    if sys.argv[1] == "raw":
        uid = sys.argv[2] if len(sys.argv) > 2 else user_id
        limit = int(sys.argv[3]) if len(sys.argv) > 3 else 5
        await get_raw_output(uid, limit)
    else:
        content = " ".join(sys.argv[1:])
        print(f"发送: {content}")
        await send_message(user_id, content)

if __name__ == "__main__":
    asyncio.run(main())


1	"""系统信息 Skill。"""
2
3	import subprocess
4
5	from butler.skills.base import SkillBase, SkillContext, SkillResult
6
7
8	class SystemSkill(SkillBase):
9	    """获取系统负载和资源信息。"""
10
11	    name = "system"
12	    description = "获取系统负载和资源信息"
13	    triggers = ["/sys", "/system", "/load"]
14
15	    async def execute(self, ctx: SkillContext) -> SkillResult:
16	        args = ctx.args
17
18	        if not args or args[0] == "help":
19	            return SkillResult(success=False, output="用法: /system [load|disk|mem]")
20
21	        cmd = args[0]
22
23	        if cmd == "load":
24	            return self._get_load()
25	        elif cmd == "disk":
26	            return self._get_disk()
27	        elif cmd == "mem":
28	            return self._get_memory()
29
30	        return SkillResult(success=False, output=f"未知命令: {cmd}\n用法: /system [load|disk|mem]")
31
32	    def _get_load(self) -> SkillResult:
33	        try:
34	            result = subprocess.run(["uptime"], capture_output=True, text=True, timeout=5)
35	            return SkillResult(success=True, output=result.stdout.strip())
36	        except Exception as e:
37	            return SkillResult(success=False, output=f"获取系统负载失败: {e}")
38
39	    def _get_disk(self) -> SkillResult:
40	        try:
41	            result = subprocess.run(["df", "-h", "/"], capture_output=True, text=True, timeout=5)
42	            return SkillResult(success=True, output=result.stdout.strip())
43	        except Exception as e:
44	            return SkillResult(success=False, output=f"获取磁盘信息失败: {e}")
45
46	    def _get_memory(self) -> SkillResult:
47	        try:
48	            result = subprocess.run(["free", "-h"], capture_output=True, text=True, timeout=5)
49	            return SkillResult(success=True, output=result.stdout.strip())
50	        except Exception as e:
51	            return SkillResult(success=False, output=f"获取内存信息失败: {e}")
52

"""系统信息 Skill。"""

import subprocess

from butler.skills.base import SkillBase, SkillContext, SkillResult

class SystemSkill(SkillBase):
    """获取系统负载和资源信息。"""

    name = "system"
    description = "获取系统负载和资源信息"
    triggers = ["/sys", "/system", "/load"]

    async def execute(self, ctx: SkillContext) -> SkillResult:
        args = ctx.args

        if not args or args[0] == "help":
            return SkillResult(success=False, output="用法: /system [load|disk|mem]")

        cmd = args[0]

        if cmd == "load":
            return self._get_load()
        elif cmd == "disk":
            return self._get_disk()
        elif cmd == "mem":
            return self._get_memory()

        return SkillResult(success=False, output=f"未知命令: {cmd}\n用法: /system [load|disk|mem]")

    def _get_load(self) -> SkillResult:
        try:
            result = subprocess.run(["uptime"], capture_output=True, text=True, timeout=5)
            return SkillResult(success=True, output=result.stdout.strip())
        except Exception as e:
            return SkillResult(success=False, output=f"获取系统负载失败: {e}")

    def _get_disk(self) -> SkillResult:
        try:
            result = subprocess.run(["df", "-h", "/"], capture_output=True, text=True, timeout=5)
            return SkillResult(success=True, output=result.stdout.strip())
        except Exception as e:
            return SkillResult(success=False, output=f"获取磁盘信息失败: {e}")

    def _get_memory(self) -> SkillResult:
        try:
            result = subprocess.run(["free", "-h"], capture_output=True, text=True, timeout=5)
            return SkillResult(success=True, output=result.stdout.strip())
        except Exception as e:
            return SkillResult(success=False, output=f"获取内存信息失败: {e}")


1	"""日志记录 Skill。"""
2
3	import sqlite3
4	from pathlib import Path
5
6	from butler.skills.base import SkillBase, SkillContext, SkillResult
7
8
9	class LogSkill(SkillBase):
10	    """记录和检索 Shell 交互日志。"""
11
12	    name = "log"
13	    description = "记录和检索 Shell 交互日志"
14	    triggers = ["/log", "/history"]
15
16	    def __init__(self, db_path: str = "data/logs.db"):
17	        self.db_path = Path(db_path)
18	        if self.db_path != ":memory:":
19	            self.db_path.parent.mkdir(parents=True, exist_ok=True)
20	        self._init_db()
21
22	    def _init_db(self) -> None:
23	        conn = sqlite3.connect(self.db_path)
24	        conn.execute("""
25	            CREATE TABLE IF NOT EXISTS shell_logs (
26	                id INTEGER PRIMARY KEY AUTOINCREMENT,
27	                user_id TEXT NOT NULL,
28	                session_name TEXT NOT NULL,
29	                command TEXT NOT NULL,
30	                output TEXT,
31	                timestamp DATETIME DEFAULT CURRENT_TIMESTAMP
32	            )
33	        """)
34	        conn.execute("CREATE INDEX IF NOT EXISTS idx_user ON shell_logs(user_id)")
35	        conn.execute("CREATE INDEX IF NOT EXISTS idx_timestamp ON shell_logs(timestamp)")
36	        conn.commit()
37	        conn.close()
38
39	    def log_command(self, user_id: str, session_name: str, command: str, output: str = "") -> None:
40	        conn = sqlite3.connect(self.db_path)
41	        conn.execute(
42	            "INSERT INTO shell_logs (user_id, session_name, command, output) VALUES (?, ?, ?, ?)",
43	            (user_id, session_name, command, output),
44	        )
45	        conn.commit()
46	        conn.close()
47
48	    async def execute(self, ctx: SkillContext) -> SkillResult:
49	        args = ctx.args
50
51	        if not args or args[0] == "recent":
52	            limit = int(args[1]) if len(args) > 1 else 10
53	            return self._query_recent(ctx.user_id, limit)
54
55	        elif args[0] == "search":
56	            keyword = " ".join(args[1:]) if len(args) > 1 else ""
57	            return self._search_commands(ctx.user_id, keyword)
58
59	        return SkillResult(success=False, output="用法: /log [recent N|search 关键词]")
60
61	    def _query_recent(self, user_id: str, limit: int) -> SkillResult:
62	        conn = sqlite3.connect(self.db_path)
63	        cursor = conn.execute(
64	            "SELECT command, timestamp FROM shell_logs WHERE user_id = ? ORDER BY timestamp DESC LIMIT ?",
65	            (user_id, limit),
66	        )
67	        rows = cursor.fetchall()
68	        conn.close()
69
70	        output = "\n".join(f"[{row[1]}] {row[0]}" for row in rows)
71	        return SkillResult(success=True, output=output or "无日志记录")
72
73	    def _search_commands(self, user_id: str, keyword: str) -> SkillResult:
74	        conn = sqlite3.connect(self.db_path)
75	        cursor = conn.execute(
76	            "SELECT command, timestamp FROM shell_logs WHERE user_id = ? AND command LIKE ? ORDER BY timestamp DESC LIMIT 20",
77	            (user_id, f"%{keyword}%"),
78	        )
79	        rows = cursor.fetchall()
80	        conn.close()
81
82	        output = "\n".join(f"[{row[1]}] {row[0]}" for row in rows)
83	        return SkillResult(success=True, output=output or "无匹配记录")
84

"""日志记录 Skill。"""

import sqlite3
from pathlib import Path

from butler.skills.base import SkillBase, SkillContext, SkillResult

class LogSkill(SkillBase):
    """记录和检索 Shell 交互日志。"""

    name = "log"
    description = "记录和检索 Shell 交互日志"
    triggers = ["/log", "/history"]

    def __init__(self, db_path: str = "data/logs.db"):
        self.db_path = Path(db_path)
        if self.db_path != ":memory:":
            self.db_path.parent.mkdir(parents=True, exist_ok=True)
        self._init_db()

    def _init_db(self) -> None:
        conn = sqlite3.connect(self.db_path)
        conn.execute("""
            CREATE TABLE IF NOT EXISTS shell_logs (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                user_id TEXT NOT NULL,
                session_name TEXT NOT NULL,
                command TEXT NOT NULL,
                output TEXT,
                timestamp DATETIME DEFAULT CURRENT_TIMESTAMP
            )
        """)
        conn.execute("CREATE INDEX IF NOT EXISTS idx_user ON shell_logs(user_id)")
        conn.execute("CREATE INDEX IF NOT EXISTS idx_timestamp ON shell_logs(timestamp)")
        conn.commit()
        conn.close()

    def log_command(self, user_id: str, session_name: str, command: str, output: str = "") -> None:
        conn = sqlite3.connect(self.db_path)
        conn.execute(
            "INSERT INTO shell_logs (user_id, session_name, command, output) VALUES (?, ?, ?, ?)",
            (user_id, session_name, command, output),
        )
        conn.commit()
        conn.close()

    async def execute(self, ctx: SkillContext) -> SkillResult:
        args = ctx.args

        if not args or args[0] == "recent":
            limit = int(args[1]) if len(args) > 1 else 10
            return self._query_recent(ctx.user_id, limit)

        elif args[0] == "search":
            keyword = " ".join(args[1:]) if len(args) > 1 else ""
            return self._search_commands(ctx.user_id, keyword)

        return SkillResult(success=False, output="用法: /log [recent N|search 关键词]")

    def _query_recent(self, user_id: str, limit: int) -> SkillResult:
        conn = sqlite3.connect(self.db_path)
        cursor = conn.execute(
            "SELECT command, timestamp FROM shell_logs WHERE user_id = ? ORDER BY timestamp DESC LIMIT ?",
            (user_id, limit),
        )
        rows = cursor.fetchall()
        conn.close()

        output = "\n".join(f"[{row[1]}] {row[0]}" for row in rows)
        return SkillResult(success=True, output=output or "无日志记录")

    def _search_commands(self, user_id: str, keyword: str) -> SkillResult:
        conn = sqlite3.connect(self.db_path)
        cursor = conn.execute(
            "SELECT command, timestamp FROM shell_logs WHERE user_id = ? AND command LIKE ? ORDER BY timestamp DESC LIMIT 20",
            (user_id, f"%{keyword}%"),
        )
        rows = cursor.fetchall()
        conn.close()

        output = "\n".join(f"[{row[1]}] {row[0]}" for row in rows)
        return SkillResult(success=True, output=output or "无匹配记录")


1	"""配置管理模块。"""
2
3	import os
4	from pathlib import Path
5	from typing import Any
6
7	import yaml
8	from dotenv import load_dotenv
9	from pydantic import Field
10	from pydantic_settings import BaseSettings
11
12	# 先加载 .env 到环境变量
13	load_dotenv()
14
15
16	class Settings(BaseSettings):
17	    """应用配置。"""
18
19	    # 会话配置
20	    session_idle_timeout: int = Field(default=3600, description="会话空闲超时(秒)")
21	    session_max_per_user: int = Field(default=1, description="每用户最大会话数")
22	    session_max_total: int = Field(default=100, description="全局最大会话数")
23	    session_prefix: str = Field(default="butler_", description="会话名前缀")
24
25	    # 审批配置
26	    guardrail_enabled: bool = Field(default=True, description="是否启用命令审批")
27	    guardrail_approval_timeout: int = Field(default=300, description="审批超时(秒)")
28
29	    # 安全配置
30	    allowed_users: list[str] = Field(default_factory=list, description="允许的用户ID列表")
31
32	    # NapCat 配置
33	    napcat_ws_url: str = Field(default="ws://localhost:3001", description="NapCat WebSocket URL")
34	    napcat_token: str = Field(default="", description="NapCat 访问令牌")
35
36	    # Telegram 配置
37	    telegram_bot_token: str = Field(default="", description="Telegram Bot Token")
38	    telegram_allowed_chats: list[str] = Field(default_factory=list, description="允许的 Telegram 聊天 ID")
39
40	    # 启用的通道
41	    enabled_channels: list[str] = Field(default_factory=lambda: ["telegram"], description="启用的通道列表")
42
43	    # Inngest 配置
44	    inngest_event_key: str = Field(default="", description="Inngest Event Key")
45	    inngest_signing_key: str = Field(default="", description="Inngest Signing Key")
46
47	    # 日志配置
48	    log_level: str = Field(default="INFO", description="日志级别")
49
50	    # Claude API
51	    anthropic_api_key: str = Field(default="", description="Anthropic API Key")
52	    anthropic_base_url: str = Field(default="", description="Anthropic API Base URL")
53	    anthropic_model: str = Field(default="claude-sonnet-4-20250514", description="Claude 模型名称")
54
55	    model_config = {"env_prefix": "BUTLER_", "env_file": ".env", "extra": "ignore"}
56
57	    def is_user_allowed(self, user_id: str) -> bool:
58	        """检查用户是否在白名单中。"""
59	        if not self.allowed_users:
60	            return True
61	        return user_id in self.allowed_users
62
63
64	def load_config(config_path: Path | None = None) -> dict[str, Any]:
65	    """从 YAML 文件加载配置。"""
66	    if config_path is None:
67	        config_path = Path("config.yaml")
68
69	    if not config_path.exists():
70	        return {}
71
72	    with open(config_path) as f:
73	        return yaml.safe_load(f) or {}
74
75
76	def create_settings(config_path: Path | None = None) -> Settings:
77	    """创建配置实例，合并 YAML 和环境变量。"""
78	    config = load_config(config_path)
79	    butler_config = config.get("butler", {})
80
81	    flat_config: dict[str, Any] = {}
82
83	    session_cfg = butler_config.get("session", {})
84	    flat_config["session_idle_timeout"] = session_cfg.get("idle_timeout", 3600)
85	    flat_config["session_max_per_user"] = session_cfg.get("max_per_user", 1)
86	    flat_config["session_max_total"] = session_cfg.get("max_total", 100)
87	    flat_config["session_prefix"] = session_cfg.get("prefix", "butler_")
88
89	    guardrail_cfg = butler_config.get("guardrail", {})
90	    flat_config["guardrail_enabled"] = guardrail_cfg.get("enabled", True)
91	    flat_config["guardrail_approval_timeout"] = guardrail_cfg.get("approval_timeout", 300)
92
93	    security_cfg = butler_config.get("security", {})
94	    allowed = security_cfg.get("allowed_users", [])
95	    flat_config["allowed_users"] = allowed if allowed else []
96
97	    napcat_cfg = butler_config.get("napcat", {})
98	    flat_config["napcat_ws_url"] = napcat_cfg.get("ws_url", "ws://localhost:3001")
99	    flat_config["napcat_token"] = napcat_cfg.get("token", "")
100
101	    telegram_cfg = butler_config.get("telegram", {})
102	    flat_config["telegram_bot_token"] = (
103	        telegram_cfg.get("bot_token", "") or
104	        os.environ.get("TELEGRAM_BOT_TOKEN", "")
105	    )
106	    flat_config["telegram_allowed_chats"] = telegram_cfg.get("allowed_chats", [])
107
108	    channels_cfg = butler_config.get("channels", {})
109	    flat_config["enabled_channels"] = channels_cfg.get("enabled", ["telegram"])
110
111	    logging_cfg = butler_config.get("logging", {})
112	    flat_config["log_level"] = logging_cfg.get("level", "INFO")
113
114	    # Claude API - 支持多种环境变量名
115	    flat_config["anthropic_api_key"] = (
116	        butler_config.get("anthropic_api_key", "") or
117	        os.environ.get("ANTHROPIC_API_KEY", "") or
118	        os.environ.get("BUTLER_ANTHROPIC_API_KEY", "")
119	    )
120	    flat_config["anthropic_base_url"] = (
121	        butler_config.get("anthropic_base_url", "") or
122	        os.environ.get("ANTHROPIC_BASE_URL", "") or
123	        os.environ.get("BUTLER_ANTHROPIC_BASE_URL", "")
124	    )
125	    flat_config["anthropic_model"] = (
126	        butler_config.get("anthropic_model", "") or
127	        os.environ.get("ANTHROPIC_MODEL", "") or
128	        os.environ.get("BUTLER_ANTHROPIC_MODEL", "")
129	    )
130
131	    return Settings(**flat_config)
132
133
134	settings = create_settings()
135

"""配置管理模块。"""

import os
from pathlib import Path
from typing import Any

import yaml
from dotenv import load_dotenv
from pydantic import Field
from pydantic_settings import BaseSettings

# 先加载 .env 到环境变量
load_dotenv()

class Settings(BaseSettings):
    """应用配置。"""

    # 会话配置
    session_idle_timeout: int = Field(default=3600, description="会话空闲超时(秒)")
    session_max_per_user: int = Field(default=1, description="每用户最大会话数")
    session_max_total: int = Field(default=100, description="全局最大会话数")
    session_prefix: str = Field(default="butler_", description="会话名前缀")

    # 审批配置
    guardrail_enabled: bool = Field(default=True, description="是否启用命令审批")
    guardrail_approval_timeout: int = Field(default=300, description="审批超时(秒)")

    # 安全配置
    allowed_users: list[str] = Field(default_factory=list, description="允许的用户ID列表")

    # NapCat 配置
    napcat_ws_url: str = Field(default="ws://localhost:3001", description="NapCat WebSocket URL")
    napcat_token: str = Field(default="", description="NapCat 访问令牌")

    # Telegram 配置
    telegram_bot_token: str = Field(default="", description="Telegram Bot Token")
    telegram_allowed_chats: list[str] = Field(default_factory=list, description="允许的 Telegram 聊天 ID")

    # 启用的通道
    enabled_channels: list[str] = Field(default_factory=lambda: ["telegram"], description="启用的通道列表")

    # Inngest 配置
    inngest_event_key: str = Field(default="", description="Inngest Event Key")
    inngest_signing_key: str = Field(default="", description="Inngest Signing Key")

    # 日志配置
    log_level: str = Field(default="INFO", description="日志级别")

    # Claude API
    anthropic_api_key: str = Field(default="", description="Anthropic API Key")
    anthropic_base_url: str = Field(default="", description="Anthropic API Base URL")
    anthropic_model: str = Field(default="claude-sonnet-4-20250514", description="Claude 模型名称")

    model_config = {"env_prefix": "BUTLER_", "env_file": ".env", "extra": "ignore"}

    def is_user_allowed(self, user_id: str) -> bool:
        """检查用户是否在白名单中。"""
        if not self.allowed_users:
            return True
        return user_id in self.allowed_users

def load_config(config_path: Path | None = None) -> dict[str, Any]:
    """从 YAML 文件加载配置。"""
    if config_path is None:
        config_path = Path("config.yaml")

    if not config_path.exists():
        return {}

    with open(config_path) as f:
        return yaml.safe_load(f) or {}

def create_settings(config_path: Path | None = None) -> Settings:
    """创建配置实例，合并 YAML 和环境变量。"""
    config = load_config(config_path)
    butler_config = config.get("butler", {})

    flat_config: dict[str, Any] = {}

    session_cfg = butler_config.get("session", {})
    flat_config["session_idle_timeout"] = session_cfg.get("idle_timeout", 3600)
    flat_config["session_max_per_user"] = session_cfg.get("max_per_user", 1)
    flat_config["session_max_total"] = session_cfg.get("max_total", 100)
    flat_config["session_prefix"] = session_cfg.get("prefix", "butler_")

    guardrail_cfg = butler_config.get("guardrail", {})
    flat_config["guardrail_enabled"] = guardrail_cfg.get("enabled", True)
    flat_config["guardrail_approval_timeout"] = guardrail_cfg.get("approval_timeout", 300)

    security_cfg = butler_config.get("security", {})
    allowed = security_cfg.get("allowed_users", [])
    flat_config["allowed_users"] = allowed if allowed else []

    napcat_cfg = butler_config.get("napcat", {})
    flat_config["napcat_ws_url"] = napcat_cfg.get("ws_url", "ws://localhost:3001")
    flat_config["napcat_token"] = napcat_cfg.get("token", "")

    telegram_cfg = butler_config.get("telegram", {})
    flat_config["telegram_bot_token"] = (
        telegram_cfg.get("bot_token", "") or
        os.environ.get("TELEGRAM_BOT_TOKEN", "")
    )
    flat_config["telegram_allowed_chats"] = telegram_cfg.get("allowed_chats", [])

    channels_cfg = butler_config.get("channels", {})
    flat_config["enabled_channels"] = channels_cfg.get("enabled", ["telegram"])

    logging_cfg = butler_config.get("logging", {})
    flat_config["log_level"] = logging_cfg.get("level", "INFO")

    # Claude API - 支持多种环境变量名
    flat_config["anthropic_api_key"] = (
        butler_config.get("anthropic_api_key", "") or
        os.environ.get("ANTHROPIC_API_KEY", "") or
        os.environ.get("BUTLER_ANTHROPIC_API_KEY", "")
    )
    flat_config["anthropic_base_url"] = (
        butler_config.get("anthropic_base_url", "") or
        os.environ.get("ANTHROPIC_BASE_URL", "") or
        os.environ.get("BUTLER_ANTHROPIC_BASE_URL", "")
    )
    flat_config["anthropic_model"] = (
        butler_config.get("anthropic_model", "") or
        os.environ.get("ANTHROPIC_MODEL", "") or
        os.environ.get("BUTLER_ANTHROPIC_MODEL", "")
    )

    return Settings(**flat_config)

settings = create_settings()


对照设计规范，以下是当前未完成或部分完成的功能：

## 未完成功能清单

### 1. 终端输出敏感信息过滤 — **missing**
设计规范要求 `sanitize_output()` 在返回输出前自动过滤密码/API Key 等，但 `_process_message()` 中从未调用 `sanitize_output()`。

### 2. 上下文压缩 (`/compact`) — **missing**
设计规范定义了 `context_compaction` 工作流：总结 shell 状态（pwd, env, key output），清空历史上下文只保留快照。当前只有 `/clear`（简单清空），没有智能压缩。

### 3. Inngest 工作流与主流程不同步 — **partial**
`workflows/handle_message.py` 仍使用旧的逻辑（无 LLM、无命令路由、无 ContextManager），与 `main.py._process_message()` 完全不一致。如果通过 Inngest 触发消息，走的是旧流程。

### 4. 会话空闲超时清理 — **missing**
设计规范要求定时清理超时会话。`config.py` 有 `session_idle_timeout` 配置但没有对应的定时任务实现。

### 5. 交互模式手动切换 — **partial**
设计规范要求 `/interactive` 命令强制进入交互模式、`/force-nl` 强制回到 NL 模式。当前只有自动检测，没有手动切换命令。

### 6. 特殊按键发送 — **missing**
设计规范提到交互模式下可以发送 `C-c`、`C-d` 等特殊按键，但没有定义用户输入格式（如如何通过 Telegram 发送 Ctrl-C）。

### 7. 日志 Schema 不完整 — **partial**
设计规范的 shell_logs 表包含 `approved`、`approval_id`、`mode`、`dangerous` 等字段，当前只有基本字段。

### 8. 配置热更新 — **missing**
设计规范要求 `POST /config/reload` 和 `SIGHUP` 信号支持配置重载。

### 9. Skill 触发器未集成 — **partial**
`_process_message()` 中的 `/` 命令路由只处理了固定命令（/new, /list 等），没有走 SkillRegistry 匹配逻辑。SystemSkill（`/sys`、`/load`）和 LogSkill（`/log`、`/history`）在 `_process_message()` 中无法触发。

---

**优先级建议**：
- **高**：#1 敏感信息过滤（安全）、#9 Skill 触发器集成（功能缺失）、#3 Inngest 同步
- **中**：#5 交互模式手动切换、#6 特殊按键发送、#2 上下文压缩
- **低**：#4 超时清理、#7 日志字段、#8 配置热更新

需要我优先实现哪些？


node "${CLAUDE_PLUGIN_ROOT}/scripts/hooks/run-with-flags.js" "stop:check-console-log" "scripts/hooks/check-console-log.js" "standard,strict"

node "${CLAUDE_PLUGIN_ROOT}/scripts/hooks/run-with-flags.js" "stop:session-end" "scripts/hooks/session-end.js" "minimal,standard,strict"

node "${CLAUDE_PLUGIN_ROOT}/scripts/hooks/run-with-flags.js" "stop:evaluate-session" "scripts/hooks/evaluate-session.js" "minimal,standard,strict"

node "${CLAUDE_PLUGIN_ROOT}/scripts/hooks/run-with-flags.js" "stop:cost-tracker" "scripts/hooks/cost-tracker.js" "minimal,standard,strict"