diff --git a/README.md b/README.md new file mode 100644 index 0000000..b7d56f8 --- /dev/null +++ b/README.md @@ -0,0 +1,32 @@ +# MindOS CLI — 拾音采集端 + +> 内容枢纽时代(2026-08 战略逆转,SPEC_mindos_content_hub)下的最小形态: +> MindOS 不再自建"本地执行体",本包降级为**系统拾音采集端**。 + +## 功能 + +- `mind record start [--source system|mic] [--chat-id] [--token]` — 实时流式录音(不落盘,PCM16 帧直推 Cloud ASR) +- `mind record stop` / `mind record status` — 停止 / 查看状态 +- `_vendor/` hermes 快照 + `scripts/vendor_hermes.sh`(快照机制保留) + +## 安装 + +```bash +pipx install "mindos-cli[audio] @ git+https://git.brainwork.club/lidf/MindOS_CLI.git" +export MINDOS_JWT= +mind record start +``` + +## 2026-08 瘦身记录(v0.2.1 → v0.3.0) + +砍掉(宿主层由 WorkBuddy 等 MCP 消费端承接): + +- `chat`/`ask` 命令集(LLM 对话) +- `tunnel`(WS 隧道)、`tool_proxy`、`capability` +- 守护进程(8660 health server)、launchd 服务(`service.py`) +- `update` pipx 自更新 + +保留: + +- `record` 命令族(实时流式,直连 Cloud ASR `wss://agent.brainwork.club/mindos-next/ws/record`) +- `_vendor/` 快照 + `vendor_hermes.sh` diff --git a/mindcli/__init__.py b/mindcli/__init__.py index 0699978..5fe0355 100644 --- a/mindcli/__init__.py +++ b/mindcli/__init__.py @@ -1,17 +1,14 @@ """ -MindOS CLI — Cloud Hermes 的受管理执行节点。 +MindOS CLI — 拾音采集端(内容枢纽时代 v0.3.0 瘦身版)。 包初始化:将 _vendor/ 目录加入 sys.path, -使 Hermes 模块的内部 import 路径保持原样工作。 - -POC 验证结论:sys.path.insert(0, _vendor_dir) 一行即可, -不需要重写 Hermes 的 10K 行代码中的任何 import。 +使 Hermes 模块的内部 import 路径保持原样工作(快照机制保留)。 """ import os import sys -__version__ = "0.2.1" +__version__ = "0.3.0" # ── Vendor 路径注入 ────────────────────────────────────────── # 将 _vendor/ 目录加入 sys.path 头部, diff --git a/mindcli/capability.py b/mindcli/capability.py deleted file mode 100644 index 52f38ba..0000000 --- a/mindcli/capability.py +++ /dev/null @@ -1,87 +0,0 @@ -""" -Mind CLI — 能力扫描器。 - -扫描本地可用的内置工具和 MCP Server,生成 capability report -供 Tunnel 握手时上报给 Cloud。 -""" - -import os -import platform -from typing import Any - -import mindcli - - -# ── 内置工具注册表 ──────────────────────────────────── -# 只暴露 Cloud 端有意义的本地工具(文件操作、终端、搜索等) -# 浏览器工具、TTS 等纯交互工具不上报 -_BUILTIN_TOOLS: list[dict[str, str]] = [ - {"name": "terminal", "type": "builtin", "desc": "执行终端命令"}, - {"name": "file_read", "type": "builtin", "desc": "读取本地文件"}, - {"name": "file_write", "type": "builtin", "desc": "写入本地文件"}, - {"name": "file_ops", "type": "builtin", "desc": "文件操作(复制/移动/删除)"}, - {"name": "grep", "type": "builtin", "desc": "文本搜索"}, - {"name": "code_execution", "type": "builtin", "desc": "执行代码片段"}, -] - - -def scan_capabilities() -> dict[str, Any]: - """ - 扫描本地可用工具,返回 capability report。 - - 返回格式: - { - "version": "0.1.0", - "vendor": "16f9d020", - "platform": "Darwin", - "arch": "arm64", - "python": "3.12.11", - "tools": [ - {"name": "terminal", "type": "builtin", "desc": "..."}, - ... - ], - "mcp_servers": [] # Phase 2+ 从 config.yaml 读取 - } - """ - import sys - - return { - "version": mindcli.__version__, - "vendor": _get_vendor_commit(), - "platform": platform.system(), - "arch": platform.machine(), - "python": f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}", - "tools": list(_BUILTIN_TOOLS), - "mcp_servers": _scan_mcp_servers(), - } - - -def get_tool_names() -> list[str]: - """返回所有内置工具名称列表。""" - return [t["name"] for t in _BUILTIN_TOOLS] - - -def _scan_mcp_servers() -> list[dict[str, str]]: - """ - 扫描用户配置的第三方 MCP Server。 - - TODO: 从 ~/.mindcli/config.yaml 的 mcp_servers 段读取。 - """ - return [] - - -def _get_vendor_commit() -> str: - """读取 _vendor/VENDOR_COMMIT,只返回 commit hash。 - - VENDOR_COMMIT 是多行标记文件(source:/commit:/date:), - 只返回 commit: 行的值,不是整个文件内容。 - """ - commit_file = os.path.join(mindcli._VENDOR_DIR, "VENDOR_COMMIT") - try: - with open(commit_file) as f: - for line in f: - if line.startswith("commit:"): - return line.split(":", 1)[1].strip() - return "unknown" - except FileNotFoundError: - return "unknown" diff --git a/mindcli/cli.py b/mindcli/cli.py index f533f64..4676f8f 100644 --- a/mindcli/cli.py +++ b/mindcli/cli.py @@ -1,491 +1,174 @@ """ -Mind CLI — 命令行入口。 +Mind CLI — 拾音采集端(内容枢纽时代 v0.3.0 瘦身版)。 -通过 Click 定义 `mind` 命令族。 -- chat/ask 走 _vendor/run_agent.py 的 headless AIAgent(铁律 A:决策权归云端) -- LLM 调用 100% 走 Cloud Gateway(JWT 计费),不在本地拉起 vendor TUI -- --offline 备选:走 _vendor/cli.py 的完整 TUI(铁律 C:断开即自治) +定位(SPEC_mindos_content_hub 决策):MindOS 不再自建"本地执行体", +MindCLI 降级为**最小系统拾音工具**:采集系统音频/麦克风 → 实时流式 +推送到 Cloud ASR(dashscope_realtime 管线),转写由云端负责。 + +保留(2026-08 决策): + - record 命令族(实时流式,不落盘) + - _vendor/ 快照机制(scripts/vendor_hermes.sh) +砍掉(宿主层由 WorkBuddy 等 MCP 消费端承接): + - chat/ask 命令集、tunnel、tool_proxy、capability + - 守护进程(8660 health server)、launchd 服务(service.py) + - pipx 自更新(update) """ -import click +import asyncio import json import os +import signal import sys +import time +from pathlib import Path + +import click # 确保 _vendor/ 已注入 sys.path import mindcli # noqa: F401 — 触发 __init__.py 的 sys.path 注入 -# ── Cloud Gateway 配置 ──────────────────────────────────── - -def _get_cloud_gateway_url() -> str: - """Cloud Gateway 的 LLM API base URL。""" - return os.environ.get( - "MINDOS_GATEWAY_URL", - "https://agent.brainwork.club/mindos-next/llm", - ) +# ── 常量 ────────────────────────────────────────────────────── +_RECORD_WS_BASE = os.environ.get( + "MINDOS_RECORD_WS_URL", + "wss://agent.brainwork.club/mindos-next/ws/record", +) +_STATE_DIR = Path.home() / ".mindcli" +_STATE_FILE = _STATE_DIR / "record.json" def _get_jwt() -> str | None: - """获取 JWT(从 Tunnel 句柄或环境变量)。 + """获取 MindPass JWT(环境变量 MINDOS_JWT)。""" + return os.environ.get("MINDOS_JWT") - 优先从 health server 的 tunnel handle 获取; - 离线/无 tunnel 时回退到 MINDOS_JWT 环境变量。 - """ - jwt = os.environ.get("MINDOS_JWT") - if jwt: - return jwt - # 尝试从 health server 查询 tunnel 状态获取 JWT - #(JWT 存在 tunnel handle 内存中,不落盘) - import urllib.request +def _load_state() -> dict | None: try: - req = urllib.request.Request("http://127.0.0.1:8660/tunnel/status") - with urllib.request.urlopen(req, timeout=2) as resp: - data = json.loads(resp.read()) - if data.get("status") == "connected": - # tunnel 已连接,JWT 在 handle 内存中 - # 通过环境变量 MINDOS_JWT 传递(由 /tunnel/activate 时设置) - return os.environ.get("MINDOS_JWT") - except Exception: + with open(_STATE_FILE) as f: + return json.load(f) + except (FileNotFoundError, json.JSONDecodeError): + return None + + +def _save_state(state: dict) -> None: + _STATE_DIR.mkdir(parents=True, exist_ok=True) + with open(_STATE_FILE, "w") as f: + json.dump(state, f, ensure_ascii=False) + + +def _clear_state() -> None: + try: + _STATE_FILE.unlink() + except FileNotFoundError: pass - return None -# ── Click 命令组 ────────────────────────────────────────── - -@click.group(invoke_without_command=True) -@click.version_option(version=mindcli.__version__, prog_name="mind") -@click.pass_context -def main(ctx): - """MindOS NEXT CLI — Cloud Hermes 的本地执行节点。""" - if ctx.invoked_subcommand is None: - click.echo(ctx.get_help()) - - -@main.command() -@click.option("--model", "-m", default="", help="模型名称(默认使用配置文件)") -@click.option("--skills", "-s", multiple=True, help="加载指定 skill") -@click.option("--resume", "-r", default="", help="恢复指定会话 ID") -@click.option("--offline", is_flag=True, default=False, - help="离线模式:使用 vendor TUI + 本地 LLM 配置(铁律 C:断开即自治)") -def chat(model, skills, resume, offline): - """进入交互式 Chat。 - - 默认走 Cloud Gateway(JWT 计费)。 - --offline 走 _vendor/cli.py 的完整 TUI(本地 LLM 配置)。 - """ - if offline: - _chat_offline(model, skills, resume) - return - - _chat_cloud(model, skills, resume) - - -def _chat_cloud(model, skills, resume): - """走 Cloud Gateway 的交互式 chat(run_agent headless)。""" - jwt = _get_jwt() - if not jwt: - click.echo("❌ 未找到 JWT,请先连接 Tunnel:mind tunnel connect --token ") - click.echo(" 或设置环境变量: export MINDOS_JWT=") - sys.exit(1) - - from run_agent import AIAgent - - agent = AIAgent( - base_url=_get_cloud_gateway_url(), - api_key=jwt, - model=model or None, - enabled_toolsets=list(skills) if skills else None, - session_id=resume or None, - ) - - click.echo("🤖 Mind CLI (Cloud Gateway 模式)") - click.echo("=" * 50) - - # 交互循环 - conversation_history = [] - system_message = None - - while True: - try: - user_input = input("\n你: ").strip() - except (EOFError, KeyboardInterrupt): - click.echo("\n👋 再见!") - break - - if not user_input: - continue - if user_input.lower() in ("exit", "quit", "q"): - click.echo("👋 再见!") - break - - try: - result = agent.run_conversation( - user_message=user_input, - system_message=system_message, - conversation_history=conversation_history, - ) - # 更新会话历史 - if result.get("messages"): - conversation_history = result["messages"] - response = result.get("response", "") - if response: - click.echo(f"\n🤖 {response}") - except Exception as e: - click.echo(f"\n❌ 错误: {e}") - - -def _chat_offline(model, skills, resume): - """离线模式:走 vendor cli.py 的完整 TUI(本地 LLM 配置)。""" - from cli import main as hermes_main - - kwargs = {} - if model: - kwargs["model"] = model - if skills: - kwargs["skills"] = ",".join(skills) - if resume: - kwargs["resume"] = resume - - hermes_main(**kwargs) - - -@main.command() -@click.argument("question") -@click.option("--model", "-m", default="", help="模型名称") -@click.option("--format", "fmt", default="text", help="输出格式: text/json/markdown") -def ask(question, model, fmt): - """单次查询(= hermes -q)。 - - 走 Cloud Gateway(JWT 计费)。 - """ - jwt = _get_jwt() - if not jwt: - click.echo("❌ 未找到 JWT,请先连接 Tunnel:mind tunnel connect --token ") - sys.exit(1) - - from run_agent import AIAgent - - agent = AIAgent( - base_url=_get_cloud_gateway_url(), - api_key=jwt, - model=model or None, - ) - +def _pid_alive(pid: int) -> bool: try: - result = agent.run_conversation(user_message=question) - response = result.get("response", "") - - if fmt == "json": - click.echo(json.dumps({"question": question, "answer": response}, - ensure_ascii=False, indent=2)) - elif fmt == "markdown": - click.echo(f"## Q: {question}\n\n{response}") - else: - click.echo(response) - except Exception as e: - click.echo(f"❌ 错误: {e}", err=True) - sys.exit(1) + os.kill(pid, 0) + return True + except ProcessLookupError: + return False + except PermissionError: + return True -@main.command() -def health(): - """显示本地 CLI 健康状态。""" - status = { - "ok": True, - "version": mindcli.__version__, - "python": sys.version.split()[0], - "vendor": _get_vendor_commit(), - "tunnel": "disconnected", # Phase 2 实现 - "tools": 0, # Phase 2 实现 - } - click.echo(json.dumps(status, indent=2, ensure_ascii=False)) - - -@main.command() -@click.option("--port", "-p", default=8660, help="Health Server 端口") -def start(port): - """启动 Health Server(前台运行,Ctrl+C 退出)。""" - from mindcli.health import start_health_server - start_health_server(port=port, foreground=True) - - -@main.command(name="install-service") -def install_service(): - """注册为 macOS launchd 服务(开机自启)。""" - from mindcli.service import install_service as _install - _install() - - -@main.command(name="uninstall-service") -def uninstall_service(): - """注销 macOS launchd 服务。""" - from mindcli.service import uninstall_service as _uninstall - _uninstall() - - -@main.command() -def capabilities(): - """显示本地可用工具和 MCP Server。""" - from mindcli.capability import scan_capabilities - cap = scan_capabilities() - click.echo(json.dumps(cap, indent=2, ensure_ascii=False)) - - -@main.group() -def tunnel(): - """管理 Cloud ↔ Local 隧道。""" - pass - - -@tunnel.command() -def status(): - """查看 Tunnel 连接状态。""" - import urllib.request - try: - req = urllib.request.Request("http://127.0.0.1:8660/tunnel/status") - with urllib.request.urlopen(req, timeout=2) as resp: - data = json.loads(resp.read()) - click.echo(json.dumps(data, indent=2, ensure_ascii=False)) - except Exception: - click.echo(json.dumps({"status": "disconnected", "note": "Health Server 未运行"}, indent=2)) - - -@tunnel.command() -@click.option("--url", default="wss://agent.brainwork.club/mindos-next/ws/cli-tunnel", - help="Cloud Tunnel WebSocket URL") -@click.option("--token", required=True, help="MindPass JWT") -def connect(url, token): - """手动建立 Tunnel 连接(通常由浏览器自动触发)。""" - import urllib.request - data = json.dumps({"token": token, "tunnelUrl": url}).encode() - try: - req = urllib.request.Request( - "http://127.0.0.1:8660/tunnel/activate", - data=data, - headers={"Content-Type": "application/json"}, - ) - with urllib.request.urlopen(req, timeout=5) as resp: - result = json.loads(resp.read()) - click.echo(f"✅ Tunnel activate: {result}") - except Exception as e: - click.echo(f"❌ 失败: {e}") - click.echo(" 确认 Health Server 已运行(mind start)") +@click.group() +def main(): + """MindOS 拾音采集端 — 实时流式推送 Cloud ASR。""" @main.group() def record(): - """管理本地系统录音。""" - pass + """管理系统录音(实时流式 → Cloud ASR,不落盘)。""" @record.command(name="start") -@click.option("--token", default="", help="MindPass JWT(默认使用 Tunnel 已有的)") +@click.option("--token", default="", help="MindPass JWT(缺省读 MINDOS_JWT)") @click.option("--chat-id", default="", help="对话 ID") @click.option("--source", type=click.Choice(["system", "mic"]), default="system", help="音频源:system=系统音频(默认),mic=麦克风") def record_start(token, chat_id, source): - """开始录音(独立音频源 → Cloud ASR)。 + """开始录音(前台运行,Ctrl+C 停止)。 - 双工模式下,CLI 和浏览器各自独立推送,不做混音。 - 可多次调用不同 --source 启动多路录音(双工模式)。 + 实时流式:采集 PCM16 帧 → WS 直推 Cloud ASR,不落盘。 """ - import urllib.request - body = json.dumps({"token": token, "chatId": chat_id, "source": source}).encode() + jwt = token or _get_jwt() + if not jwt: + click.echo("❌ 未找到 JWT:请传 --token 或设置环境变量 MINDOS_JWT") + sys.exit(1) + + meeting_id = f"cli_rec_{int(time.time() * 1000)}" + ws_url = ( + f"{_RECORD_WS_BASE}?token={jwt}&chatId={chat_id}" + f"&meetingId={meeting_id}&source={source}" + ) + + # 保存状态(pid 供 record stop/status 跨进程识别) + _save_state({ + "pid": os.getpid(), + "meetingId": meeting_id, + "chatId": chat_id, + "source": source, + "startedAt": time.time(), + }) + + def _on_text(msg_type: str, text: str) -> None: + click.echo(f"[{msg_type}] {text}") + try: - req = urllib.request.Request( - "http://127.0.0.1:8660/record/start", - data=body, - headers={"Content-Type": "application/json"}, - ) - with urllib.request.urlopen(req, timeout=10) as resp: - result = json.loads(resp.read()) - if result.get("ok"): - click.echo(f"🎙️ 录音已开始 source={result.get('source')} meetingId={result.get('meetingId')}") - click.echo(" 使用 `mind record stop` 停止") - else: - click.echo(f"❌ {result.get('error')}") - except Exception as e: - click.echo(f"❌ 失败: {e}") - click.echo(" 确认 Health Server 已运行(mind start)") + from mindcli.pipelines.audio_capture import capture as capture_audio + asyncio.run(_run_capture( + capture_audio, ws_url, chat_id, meeting_id, source, _on_text, + )) + except KeyboardInterrupt: + pass + finally: + _clear_state() + + +async def _run_capture(capture_audio, ws_url, chat_id, meeting_id, source, on_text): + """前台录音循环:启动采集 → 等 SIGINT/SIGTERM → 优雅停止。""" + handle = await capture_audio( + ws_url=ws_url, chat_id=chat_id, meeting_id=meeting_id, + source=source, on_text=on_text, + ) + click.echo(f"🎙️ 录音已开始 source={source} meetingId={meeting_id}(Ctrl+C 停止)") + + stop_event = asyncio.Event() + loop = asyncio.get_running_loop() + loop.add_signal_handler(signal.SIGINT, stop_event.set) + loop.add_signal_handler(signal.SIGTERM, stop_event.set) + + try: + await stop_event.wait() + finally: + await handle.stop() + click.echo(f"⏹️ 录音已停止 meetingId={meeting_id}") @record.command(name="stop") -@click.option("--chat-id", default="", help="停止指定 chatId 的录音(默认停止全部)") -def record_stop(chat_id): - """停止录音。""" - import urllib.request - url = "http://127.0.0.1:8660/record/stop" - if chat_id: - url += f"?chatId={chat_id}" - try: - req = urllib.request.Request( - url, - data=b"{}", - headers={"Content-Type": "application/json"}, - ) - with urllib.request.urlopen(req, timeout=10) as resp: - result = json.loads(resp.read()) - if result.get("ok"): - if "stopped" in result: - click.echo(f"⏹️ 已停止 {result['stopped']} 路录音") - else: - click.echo(f"⏹️ 录音已停止 duration={result.get('duration')}s") - else: - click.echo(f"❌ {result.get('error')}") - except Exception as e: - click.echo(f"❌ 失败: {e}") +def record_stop(): + """停止录音(向 record start 进程发 SIGTERM)。""" + state = _load_state() + if not state or not _pid_alive(state.get("pid", -1)): + _clear_state() + click.echo("⏹️ 未在录音") + return + os.kill(state["pid"], signal.SIGTERM) + click.echo(f"⏹️ 已发送停止信号 pid={state['pid']}") @record.command(name="status") def record_status(): """查看录音状态。""" - import urllib.request - try: - req = urllib.request.Request("http://127.0.0.1:8660/record/status") - with urllib.request.urlopen(req, timeout=2) as resp: - data = json.loads(resp.read()) - if data.get("running"): - for cap in data.get("captures", []): - click.echo(f"🎙️ 录音中 duration={cap.get('duration')}s " - f"chatId={cap.get('chatId')} source={cap.get('source')}") - else: - click.echo("⏹️ 未在录音") - except Exception: - click.echo("⏹️ Health Server 未运行") - - -@main.command() -def update(): - """检查并升级到最新版本。""" - import subprocess - - click.echo(f"当前版本: v{mindcli.__version__}") - click.echo("正在检查最新版本...") - - remote = _fetch_remote_info() - if not remote: - click.echo("⚠️ 无法检查远端版本(网络问题?)") + state = _load_state() + if not state or not _pid_alive(state.get("pid", -1)): + click.echo("⏹️ 未在录音") return - - remoteVersion = remote.get("version") - if not remoteVersion: - click.echo("⚠️ 远端版本信息异常") - return - - if remoteVersion == mindcli.__version__: - click.echo(f"✅ 已是最新版本 v{mindcli.__version__}") - return - - # 防降级:远端版本低于本地时,不升级(服务器 versions.json 可能未同步) - if _compare_versions(remoteVersion, mindcli.__version__) < 0: - click.echo(f"⚠️ 本地 v{mindcli.__version__} 已高于远端 v{remoteVersion}(服务器版本未同步?)") - return - - click.echo(f"⬆️ 发现新版本 v{remoteVersion}") - if remote.get("releaseNotes"): - click.echo(f" {remote['releaseNotes']}") - click.echo("正在升级...") - - # 优先用远端下发的 upgradeCmd;无则本地检测安装方式 - installCmd = remote.get("upgradeCmd") or _detect_upgrade_command() - click.echo(f" 执行: {installCmd}") - - result = subprocess.run( - installCmd, - shell=True, # 含引号/管道,必须 shell=True - capture_output=True, - text=True, - ) - if result.returncode == 0: - click.echo(f"✅ 已升级到 v{remoteVersion}") - click.echo(" 请重启 Mind CLI(mind start)以生效") - else: - click.echo(f"❌ 升级失败: {result.stderr[:300]}") - click.echo(" 可手动执行: pipx reinstall mindos-cli") - - -def _compare_versions(v1: str, v2: str) -> int: - """比较两个语义化版本号。返回 -1 (v1v2)。""" - def _parse(v: str): - parts = [] - for p in v.strip().split("."): - try: - parts.append(int(p)) - except ValueError: - parts.append(0) - return parts - a, b = _parse(v1), _parse(v2) - # 补齐长度 - while len(a) < len(b): - a.append(0) - while len(b) < len(a): - b.append(0) - for x, y in zip(a, b): - if x < y: - return -1 - if x > y: - return 1 - return 0 - - -def _detect_upgrade_command() -> str: - """检测当前安装方式,返回正确的升级命令。 - - pipx 安装 → pipx reinstall(从 git URL 重新安装到隔离 venv) - pip 安装 → python -m pip install --upgrade(用当前解释器的 pip) - """ - import sys - - # 检测是否运行在 pipx 的 venv 中 - # pipx venv 路径特征:包含 .local/share/pipx/venvs/<包名> - python_path = sys.executable - if "pipx/venvs" in python_path or "pipx\\venvs" in python_path: - return "pipx reinstall mindos-cli" - - # 默认 pip 升级(用 sys.executable -m pip,避免 PATH 里找不到 pip) - return f"{sys.executable} -m pip install --upgrade git+https://git.brainwork.club/lidf/MindOS_CLI.git" - - -def _fetch_remote_info() -> dict | None: - """从 versions.json 获取远端 CLI 版本信息。 - - 返回 {"version": str, "upgradeCmd": str|None, "releaseNotes": str|None} 或 None。 - """ - import urllib.request - versionsUrl = "https://dl.brainwork.club/mindos-next/versions.json" - try: - req = urllib.request.Request(versionsUrl) - with urllib.request.urlopen(req, timeout=5) as resp: - data = json.loads(resp.read()) - cli = data.get("cli", {}) - return { - "version": cli.get("version"), - "upgradeCmd": cli.get("upgradeCmd"), - "releaseNotes": cli.get("releaseNotes"), - } - except Exception: - return None - - -def _check_remote_version(): - """从 versions.json 获取远端 CLI 最新版本号(兼容旧接口)。""" - info = _fetch_remote_info() - return info.get("version") if info else None - - -def _get_vendor_commit() -> str: - """读取 _vendor/VENDOR_COMMIT 文件获取 vendor 版本。""" - commitFile = os.path.join(mindcli._VENDOR_DIR, "VENDOR_COMMIT") - try: - with open(commitFile) as f: - for line in f: - if line.startswith("commit:"): - return line.split(":", 1)[1].strip() - return "unknown" - except FileNotFoundError: - return "unknown" + duration = round(time.time() - state.get("startedAt", time.time()), 1) + click.echo(f"🎙️ 录音中 duration={duration}s chatId={state.get('chatId', '')} " + f"source={state.get('source')} meetingId={state.get('meetingId')}") if __name__ == "__main__": diff --git a/mindcli/health.py b/mindcli/health.py deleted file mode 100644 index ce35aaa..0000000 --- a/mindcli/health.py +++ /dev/null @@ -1,333 +0,0 @@ -""" -Mind CLI — Health HTTP Server。 - -轻量 HTTP 端点(localhost:8660),供 Web UI 探测本地 CLI 是否在线。 -新增 /tunnel/activate 端点,接收浏览器 JWT 授权并启动 Tunnel。 -基于 http.server,不引入额外依赖。 -""" - -import asyncio -import json -import logging -import os -import platform -import sys -import time -import threading -from http.server import HTTPServer, BaseHTTPRequestHandler -from socketserver import ThreadingMixIn - -import mindcli - -logger = logging.getLogger("mindcli.health") - -# ── 运行时状态(由调用方管线通过回调更新) ────────────── -_tunnel_status = "disconnected" -_tool_count = 0 - -# ── 活跃录音句柄(按 chatId 索引,可多实例并存 = 双工模式)── -_active_captures: dict[str, "object"] = {} # chatId → CaptureHandle - -# ── Tunnel 句柄(由 /tunnel/activate 创建,单连接)──────── -_tunnel_handle = None # TunnelHandle | None - - -# asyncio 事件循环(tunnel 需要) -_loop: asyncio.AbstractEventLoop | None = None - -def _detect_capabilities() -> list[str]: - """检测当前 CLI 支持的能力列表。""" - caps = ["tunnel"] - try: - import sounddevice # noqa: F401 - caps.append("audio") - except ImportError: - pass - return caps - - -def _on_tunnel_status(status: str, tools: int = 0) -> None: - """Tunnel 状态变更回调(由 TunnelHandle 通过 on_status 调用)。 - - 替代旧版 tunnel.py 反向 import health.set_tunnel_status 的全局变量写入, - 实现单向数据流:Tunnel → 回调 → health 局部状态。 - """ - global _tunnel_status, _tool_count - _tunnel_status = status - _tool_count = tools - - -class _HealthHandler(BaseHTTPRequestHandler): - """处理 /health 和 /tunnel/activate 请求。""" - - def do_GET(self): - if self.path == "/health": - body = json.dumps({ - "ok": True, - "version": mindcli.__version__, - "vendor": _get_vendor_commit(), - "tunnel": _tunnel_status, - "tools": _tool_count, - "platform": platform.system(), - "pid": os.getpid(), - "capabilities": _detect_capabilities(), - }, ensure_ascii=False) - self._respond(200, body) - elif self.path == "/tunnel/status": - body = json.dumps({ - "status": _tunnel_handle.status if _tunnel_handle else "disconnected", - "userId": _tunnel_handle.user_id if _tunnel_handle else None, - "tools": _tool_count, - }) - self._respond(200, body) - elif self.path == "/record/status": - try: - # 遍历所有活跃录音句柄(双工模式下可有多路) - if _active_captures: - captures = [h.status() for h in _active_captures.values()] - else: - captures = [] - body = json.dumps({"running": len(captures) > 0, "captures": captures}, - ensure_ascii=False) - self._respond(200, body) - except Exception as e: - self._respond(500, json.dumps({"error": str(e)})) - else: - self._respond(404, json.dumps({"error": "Not Found"})) - - def do_POST(self): - """处理 POST 请求。""" - # 去掉 query string 后再匹配路由(/record/stop?chatId=xxx → /record/stop) - path = self.path.split("?")[0] - if path == "/tunnel/activate": - self._handle_tunnel_activate() - elif path == "/record/start": - self._handle_record_start() - elif path == "/record/stop": - self._handle_record_stop() - else: - self._respond(404, json.dumps({"error": "Not Found"})) - - def _handle_tunnel_activate(self): - """浏览器授权激活 Tunnel。""" - try: - content_length = int(self.headers.get("Content-Length", 0)) - raw = self.rfile.read(content_length) - data = json.loads(raw) - - token = data.get("token") - tunnel_url = data.get("tunnelUrl") - - if not token or not tunnel_url: - self._respond(400, json.dumps({"error": "Missing token or tunnelUrl"})) - return - - # 在 asyncio 事件循环中启动 tunnel(无状态管线 + 回调) - from mindcli.pipelines.tunnel_session import connect as tunnel_connect - - if _loop and _loop.is_running(): - # 若已有旧 handle,先断开 - global _tunnel_handle - async def _activate(): - global _tunnel_handle - if _tunnel_handle: - await _tunnel_handle.disconnect() - _tunnel_handle = await tunnel_connect( - url=tunnel_url, - jwt=token, - on_status=_on_tunnel_status, - ) - return {"ok": True, "status": "connecting"} - - future = asyncio.run_coroutine_threadsafe(_activate(), _loop) - result = future.result(timeout=5) - else: - result = {"ok": True, "status": "no_event_loop"} - - logger.info("[Health] Tunnel activate: %s", result) - self._respond(200, json.dumps(result)) - - except Exception as e: - logger.error("[Health] Tunnel activate 失败: %s", e) - self._respond(500, json.dumps({"error": str(e)})) - - def _handle_record_start(self): - """启动本地录音 → WS 推送到 Cloud ASR。""" - try: - content_length = int(self.headers.get("Content-Length", 0)) - raw = self.rfile.read(content_length) - data = json.loads(raw) if raw else {} - - token = data.get("token", "") - chat_id = data.get("chatId", "") - meeting_id = data.get("meetingId", f"rec_{int(time.time() * 1000)}") - - # 构建 Cloud ASR WS URL - ws_base = data.get("wsUrl", "") - if not ws_base: - # 默认使用 Tunnel 所知的 Cloud 地址 - ws_base = ( - f"wss://agent.brainwork.club/mindos-next/ws/record" - f"?token={token}&chatId={chat_id}&meetingId={meeting_id}&source=system" - ) - - source = data.get("source", "system") # "system" 或 "mic" - - if _loop and _loop.is_running(): - from mindcli.pipelines.audio_capture import capture as capture_audio - future = asyncio.run_coroutine_threadsafe( - capture_audio( - ws_url=ws_base, chat_id=chat_id, - meeting_id=meeting_id, source=source, - ), - _loop, - ) - handle = future.result(timeout=10) - # 存入 _active_captures,按 chatId 索引(双工模式可多路并存) - _active_captures[chat_id or meeting_id] = handle - result = {"ok": True, "meetingId": handle.meeting_id, "source": source} - else: - result = {"error": "事件循环未运行,请先 mind start"} - - self._respond(200, json.dumps(result, ensure_ascii=False)) - except Exception as e: - logger.error("[Health] Record start 失败: %s", e) - self._respond(500, json.dumps({"error": str(e)})) - - def _handle_record_stop(self): - """停止本地录音。""" - try: - # 支持 /record/stop?chatId=xxx 停止单路;无参则停止所有 - from urllib.parse import urlparse, parse_qs - parsed = urlparse(self.path) - qs = parse_qs(parsed.query) - target_chat = qs.get("chatId", [None])[0] - - if _loop and _loop.is_running(): - if target_chat: - # 停止指定 chatId 的单路录音 - handle = _active_captures.pop(target_chat, None) - if handle: - future = asyncio.run_coroutine_threadsafe(handle.stop(), _loop) - result = future.result(timeout=10) - else: - result = {"error": f"无活跃录音 chatId={target_chat}"} - elif _active_captures: - # 停止所有活跃录音(全局 [停止录音]) - results = [] - for cid in list(_active_captures.keys()): - handle = _active_captures.pop(cid, None) - if handle: - future = asyncio.run_coroutine_threadsafe(handle.stop(), _loop) - results.append(future.result(timeout=10)) - result = {"ok": True, "stopped": len(results), "results": results} - else: - result = {"error": "未在录音"} - else: - result = {"error": "事件循环未运行"} - - self._respond(200, json.dumps(result, ensure_ascii=False)) - except Exception as e: - logger.error("[Health] Record stop 失败: %s", e) - self._respond(500, json.dumps({"error": str(e)})) - - def do_OPTIONS(self): - """处理 CORS 预检请求。""" - self.send_response(204) - self._cors_headers() - self.end_headers() - - def _respond(self, code: int, body: str): - self.send_response(code) - self.send_header("Content-Type", "application/json; charset=utf-8") - self._cors_headers() - self.end_headers() - self.wfile.write(body.encode("utf-8")) - - def _cors_headers(self): - """允许浏览器跨域访问(Web UI → localhost:8660)。""" - self.send_header("Access-Control-Allow-Origin", "*") - self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS") - self.send_header("Access-Control-Allow-Headers", "Content-Type") - - def log_message(self, format, *args): - """静默日志,避免刷屏。""" - pass - - -class _ThreadedHTTPServer(ThreadingMixIn, HTTPServer): - """多线程 HTTP Server,避免慢客户端阻塞。""" - daemon_threads = True - - -def _get_vendor_commit() -> str: - """读取 _vendor/VENDOR_COMMIT 文件,提取 commit hash。 - - VENDOR_COMMIT 是多行标记文件(# 注释 + source:/commit:/date:), - 只返回 commit: 行的值,不是整个文件内容。 - """ - commit_file = os.path.join(mindcli._VENDOR_DIR, "VENDOR_COMMIT") - try: - with open(commit_file) as f: - for line in f: - if line.startswith("commit:"): - return line.split(":", 1)[1].strip() - return "unknown" - except FileNotFoundError: - return "unknown" - - -def start_health_server(port: int = 8660, foreground: bool = False) -> None: - """ - 启动 Health HTTP Server + asyncio 事件循环(Tunnel 需要)。 - - Args: - port: 监听端口(默认 8660) - foreground: True = 阻塞前台运行;False = 后台线程运行 - """ - global _loop - - # 确保 mindcli 下所有 logger 的 INFO 级别输出到控制台 - logging.basicConfig( - level=logging.INFO, - format="%(message)s", - force=True, - ) - - server = _ThreadedHTTPServer(("127.0.0.1", port), _HealthHandler) - - if foreground: - print(f"🩺 Mind CLI Health Server listening on http://127.0.0.1:{port}/health") - print(f" Tunnel activate: POST http://127.0.0.1:{port}/tunnel/activate") - print(f" Version: {mindcli.__version__} | PID: {os.getpid()}") - print(f" Press Ctrl+C to stop.\n") - - # HTTP Server 在独立线程 - http_thread = threading.Thread(target=server.serve_forever, daemon=True) - http_thread.start() - - # asyncio 事件循环在主线程(Tunnel WebSocket 需要) - _loop = asyncio.new_event_loop() - asyncio.set_event_loop(_loop) - try: - _loop.run_forever() - except KeyboardInterrupt: - print("\n🛑 Health Server stopped.") - finally: - _loop.close() - server.shutdown() - else: - # 后台模式:HTTP + asyncio 都在后台 - _loop = asyncio.new_event_loop() - - def _run_loop(): - asyncio.set_event_loop(_loop) - _loop.run_forever() - - loop_thread = threading.Thread(target=_run_loop, daemon=True) - loop_thread.start() - - http_thread = threading.Thread(target=server.serve_forever, daemon=True) - http_thread.start() - - return server diff --git a/mindcli/pipelines/tool_proxy.py b/mindcli/pipelines/tool_proxy.py deleted file mode 100644 index 9980e81..0000000 --- a/mindcli/pipelines/tool_proxy.py +++ /dev/null @@ -1,183 +0,0 @@ -""" -Mind CLI — 工具代理管线(无状态)。 - -在 _vendor/tools/ 之上加白名单过滤。Cloud 审批通过的工具才能执行。 -Managed 模式下:只允许 approved_tools 列表中的工具。 - -无状态:不持有进程级单例、不反向 import 调用方模块。 -白名单 set 由调用方传入,生命周期由调用方管理(通常是 TunnelHandle 持有)。 -""" - -import asyncio -import logging -import os -import subprocess -import sys -import shlex -from typing import Any, Callable - -logger = logging.getLogger("mindcli.pipelines.tool_proxy") - - -class ToolProxy: - """ - 治理层:只暴露 Cloud 审批通过的工具。 - - Cloud 通过 Tunnel 握手下发 approved_tools 白名单, - 后续 tool_call 请求先过白名单检查,再委托到内置执行器执行。 - - 非单例——可被任意调用方构造和持有。 - """ - - def __init__(self, approved_tools: list[str] | None = None): - self._approved: set[str] = set(approved_tools or []) - # 工具名 → 执行函数的映射 - self._executors: dict[str, Callable] = {} - self._register_executors() - - def update_approved(self, tools: list[str]) -> None: - """Cloud 热更新白名单(无需重连)。""" - old = self._approved - self._approved = set(tools) - added = self._approved - old - removed = old - self._approved - if added: - logger.info("[ToolProxy] 新增审批工具: %s", added) - if removed: - logger.info("[ToolProxy] 移除审批工具: %s", removed) - - def is_approved(self, tool_name: str) -> bool: - """检查工具是否在白名单中。""" - return tool_name in self._approved - - async def execute(self, tool_name: str, params: dict) -> dict: - """ - 执行工具调用。 - - Args: - tool_name: 工具名(如 "terminal"、"grep") - params: 工具参数 - - Returns: - {"output": "...", "exit_code": 0} 或 {"error": "..."} - """ - if not self.is_approved(tool_name): - logger.warning("[ToolProxy] 工具 '%s' 未审批,拒绝执行", tool_name) - return {"error": f"Tool '{tool_name}' not approved by Cloud"} - - executor = self._executors.get(tool_name) - if not executor: - return {"error": f"Tool '{tool_name}' has no executor"} - - try: - result = await executor(params) - return result - except Exception as e: - logger.error("[ToolProxy] 工具 '%s' 执行异常: %s", tool_name, e) - return {"error": f"Execution failed: {str(e)}"} - - def _register_executors(self) -> None: - """注册内置工具的执行函数。""" - self._executors["terminal"] = self._exec_terminal - self._executors["file_read"] = self._exec_file_read - self._executors["file_write"] = self._exec_file_write - self._executors["grep"] = self._exec_grep - self._executors["file_ops"] = self._exec_file_ops - self._executors["code_execution"] = self._exec_code - - # ── 内置工具执行器 ────────────────────────────── - - async def _exec_terminal(self, params: dict) -> dict: - """执行终端命令。""" - command = params.get("command", "") - cwd = params.get("cwd", os.path.expanduser("~")) - timeout = params.get("timeout", 30) - - try: - proc = await asyncio.create_subprocess_shell( - command, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - cwd=cwd, - ) - stdout, stderr = await asyncio.wait_for( - proc.communicate(), timeout=timeout - ) - return { - "output": stdout.decode("utf-8", errors="replace"), - "stderr": stderr.decode("utf-8", errors="replace"), - "exit_code": proc.returncode, - } - except asyncio.TimeoutError: - proc.kill() - return {"error": f"Command timed out after {timeout}s", "exit_code": -1} - - async def _exec_file_read(self, params: dict) -> dict: - """读取文件内容。""" - path = params.get("path", "") - if not path or not os.path.isfile(path): - return {"error": f"File not found: {path}"} - - try: - with open(path, "r", encoding="utf-8", errors="replace") as f: - content = f.read() - return {"output": content, "size": len(content)} - except Exception as e: - return {"error": str(e)} - - async def _exec_file_write(self, params: dict) -> dict: - """写入文件。""" - path = params.get("path", "") - content = params.get("content", "") - if not path: - return {"error": "No path specified"} - - try: - os.makedirs(os.path.dirname(path), exist_ok=True) - with open(path, "w", encoding="utf-8") as f: - f.write(content) - return {"output": f"Written {len(content)} bytes to {path}"} - except Exception as e: - return {"error": str(e)} - - async def _exec_grep(self, params: dict) -> dict: - """文本搜索(ripgrep / grep)。""" - pattern = params.get("pattern", "") - path = params.get("path", ".") - if not pattern: - return {"error": "No pattern specified"} - - cmd = f"grep -rn {shlex.quote(pattern)} {shlex.quote(path)}" - return await self._exec_terminal({"command": cmd, "timeout": 15}) - - async def _exec_file_ops(self, params: dict) -> dict: - """文件操作:copy / move / delete。""" - op = params.get("operation", "") - src = params.get("source", "") - dst = params.get("destination", "") - - if op == "copy": - cmd = f"cp -r {shlex.quote(src)} {shlex.quote(dst)}" - elif op == "move": - cmd = f"mv {shlex.quote(src)} {shlex.quote(dst)}" - elif op == "delete": - cmd = f"rm -rf {shlex.quote(src)}" - elif op == "list": - cmd = f"ls -la {shlex.quote(src)}" - else: - return {"error": f"Unknown operation: {op}"} - - return await self._exec_terminal({"command": cmd, "timeout": 15}) - - async def _exec_code(self, params: dict) -> dict: - """执行代码片段(Python)。""" - code = params.get("code", "") - language = params.get("language", "python") - - if language != "python": - return {"error": f"Unsupported language: {language}"} - - return await self._exec_terminal({ - "command": f"{sys.executable} -c {shlex.quote(code)}", - "timeout": 30, - }) diff --git a/mindcli/pipelines/tunnel_session.py b/mindcli/pipelines/tunnel_session.py deleted file mode 100644 index e81db28..0000000 --- a/mindcli/pipelines/tunnel_session.py +++ /dev/null @@ -1,274 +0,0 @@ -""" -Mind CLI — WebSocket Tunnel 会话管线(无状态)。 - -连接到 Cloud 端 mindcli_bridge,接收工具调用指令并在本地执行。 -采用 Browser-Donated JWT 认证:浏览器授权 CLI,CLI 不需独立认证。 - -无状态:不持有进程级单例、不反向 import 调用方模块(health.py)。 -状态变更通过 on_status 回调通知调用方,工具调用通过 on_dispatch 回调派发。 -生命周期由调用方(health.py)管理。 -""" - -import asyncio -import json -import logging -from typing import Callable - -logger = logging.getLogger("mindcli.pipelines.tunnel_session") - -# 连接状态 -DISCONNECTED = "disconnected" -CONNECTING = "connecting" -CONNECTED = "connected" - - -async def connect( - url: str, - jwt: str, - on_dispatch: Callable[[dict], None] | None = None, - on_status: Callable[[str, int], None] | None = None, -) -> "TunnelHandle": - """ - 建立 Tunnel 连接,返回句柄。 - - Args: - url: Cloud Tunnel WebSocket URL - jwt: MindPass JWT(浏览器提供) - on_dispatch: 工具调用派发回调(可选,默认走内部 ToolProxy) - on_status: 状态变更回调 (status_str, tool_count) - - Returns: - TunnelHandle(已在后台启动连接循环) - """ - handle = TunnelHandle(url, jwt, on_dispatch, on_status) - handle._start_connect_loop() - return handle - - -class TunnelHandle: - """ - CLI → Cloud WebSocket 隧道句柄。 - - 非单例——生命周期由调用方管理。 - - 生命周期: - 1. 调用方调 connect() → 后台启动 _connect_loop - 2. 握手 + 能力协商 → Cloud 下发 approved_tools → 创建 ToolProxy - 3. 消息循环:接收 tool_call → ToolProxy 执行 → 返回结果 - 4. 心跳维持 30s / 断线指数退避重连 - """ - - def __init__( - self, - url: str, - jwt: str, - on_dispatch: Callable[[dict], None] | None = None, - on_status: Callable[[str, int], None] | None = None, - ): - self._tunnel_url = url - self._jwt = jwt - self._on_dispatch = on_dispatch - self._on_status = on_status - - self._status = DISCONNECTED - self._ws = None - self._user_id: str | None = None - self._tool_proxy = None # ToolProxy 实例,握手成功后创建 - - # 后台任务 - self._reconnect_task: asyncio.Task | None = None - self._heartbeat_task: asyncio.Task | None = None - self._message_task: asyncio.Task | None = None - - # 重连参数 - self._reconnect_delay = 1.0 - self._max_reconnect_delay = 30.0 - self._reconnect_attempts = 0 - - @property - def status(self) -> str: - return self._status - - @property - def user_id(self) -> str | None: - return self._user_id - - def _set_status(self, status: str, tools: int = 0) -> None: - self._status = status - if self._on_status: - self._on_status(status, tools) - - def _start_connect_loop(self) -> None: - """在当前 event loop 中后台启动连接循环。""" - self._reconnect_task = asyncio.create_task(self._connect_loop()) - - async def activate(self, jwt: str, tunnel_url: str) -> dict: - """ - 更新 JWT + URL 并重新连接(由调用方在浏览器重新授权时调用)。 - - Args: - jwt: 新的 MindPass JWT - tunnel_url: Cloud Tunnel WebSocket URL - - Returns: - {"ok": True, "status": "connecting"} - """ - self._jwt = jwt - self._tunnel_url = tunnel_url - - # 取消旧连接 - await self.disconnect() - - # 后台启动新连接 - self._reconnect_task = asyncio.create_task(self._connect_loop()) - return {"ok": True, "status": "connecting"} - - async def disconnect(self) -> None: - """断开 Tunnel 连接。""" - # 取消所有后台任务 - for task in [self._reconnect_task, self._heartbeat_task, self._message_task]: - if task and not task.done(): - task.cancel() - try: - await task - except asyncio.CancelledError: - pass - - if self._ws: - try: - await self._ws.close() - except Exception: - pass - self._ws = None - - self._set_status(DISCONNECTED) - self._reconnect_attempts = 0 - self._reconnect_delay = 1.0 - logger.info("[Tunnel] 已断开") - - def update_approved(self, tools: list[str]) -> None: - """热更新工具白名单(Cloud 下发时调用)。""" - if self._tool_proxy: - self._tool_proxy.update_approved(tools) - - async def _connect_loop(self) -> None: - """连接循环:握手 → 消息循环 → 断线重连。""" - while True: - try: - self._set_status(CONNECTING) - await self._connect() - # 连接成功,重置重连参数 - self._reconnect_delay = 1.0 - self._reconnect_attempts = 0 - # 进入消息循环(阻塞直到断线) - await self._run() - except asyncio.CancelledError: - return - except Exception as e: - logger.warning("[Tunnel] 连接异常: %s", e) - - # 断线,指数退避重连 - self._set_status(DISCONNECTED) - self._reconnect_attempts += 1 - delay = min(self._reconnect_delay, self._max_reconnect_delay) - logger.info("[Tunnel] %ds 后重连(第 %d 次)...", delay, self._reconnect_attempts) - await asyncio.sleep(delay) - self._reconnect_delay = min(self._reconnect_delay * 2, self._max_reconnect_delay) - - async def _connect(self) -> None: - """WebSocket 握手 + 能力协商。""" - try: - import websockets - except ImportError: - logger.error("[Tunnel] websockets 未安装。运行: pip install websockets") - raise - - headers = {"Authorization": f"Bearer {self._jwt}"} - self._ws = await websockets.connect( - self._tunnel_url, - additional_headers=headers, - ping_interval=30, - ping_timeout=10, - close_timeout=5, - ) - - # 等待 Cloud 确认 - raw = await asyncio.wait_for(self._ws.recv(), timeout=10) - msg = json.loads(raw) - if msg.get("type") != "connected": - raise ConnectionError(f"握手失败: {msg}") - - self._user_id = msg.get("userId") - logger.info("[Tunnel] 已连接,userId=%s", self._user_id) - - # 发送能力报告 - from mindcli.capability import scan_capabilities - cap = scan_capabilities() - await self._ws.send(json.dumps({ - "type": "capability_report", - **cap, - })) - - # 接收审批结果 - raw = await asyncio.wait_for(self._ws.recv(), timeout=10) - approval = json.loads(raw) - approved: list[str] = [] - if approval.get("type") == "approved_tools": - approved = approval.get("tools", []) - # 初始化 ToolProxy(从 pipelines.tool_proxy 导入,无状态) - from mindcli.pipelines.tool_proxy import ToolProxy - self._tool_proxy = ToolProxy(approved_tools=approved) - logger.info("[Tunnel] 审批通过工具: %s", approved) - - # ★ 通过回调通知调用方状态(不反向 import health.py) - self._set_status(CONNECTED, len(approved)) - - async def _run(self) -> None: - """主消息循环:接收 Cloud 指令 → 本地执行 → 返回结果。""" - try: - async for raw in self._ws: - msg = json.loads(raw) - - if msg.get("jsonrpc") == "2.0" and msg.get("method") == "tool_call": - await self._handle_tool_call(msg) - elif msg.get("type") == "approved_tools": - # 热更新白名单 - if self._tool_proxy: - self._tool_proxy.update_approved(msg.get("tools", [])) - elif msg.get("type") == "ping": - await self._ws.send(json.dumps({"type": "pong"})) - else: - logger.debug("[Tunnel] 未知消息: %s", msg.get("type")) - except Exception as e: - logger.warning("[Tunnel] 消息循环异常: %s", e) - raise - - async def _handle_tool_call(self, msg: dict) -> None: - """处理工具调用请求。""" - call_id = msg.get("id", "unknown") - params = msg.get("params", {}) - tool_name = params.get("tool", "") - tool_args = params.get("args", {}) - - logger.info("[Tunnel] 工具调用: %s (id=%s)", tool_name, call_id) - - if self._on_dispatch: - # 调用方自定义派发 - result = self._on_dispatch(msg) - elif not self._tool_proxy: - result = {"error": "ToolProxy not initialized"} - else: - result = await self._tool_proxy.execute(tool_name, tool_args) - - # 截断过大的输出(防止 WS 阻塞) - output = result.get("output", "") - if isinstance(output, str) and len(output) > 50000: - result["output"] = output[:50000] + f"\n\n... [截断:原始 {len(output)} 字符]" - result["truncated"] = True - - response = { - "jsonrpc": "2.0", - "id": call_id, - "result": result, - } - await self._ws.send(json.dumps(response)) diff --git a/mindcli/service.py b/mindcli/service.py deleted file mode 100644 index 48abba5..0000000 --- a/mindcli/service.py +++ /dev/null @@ -1,116 +0,0 @@ -""" -Mind CLI — macOS Service 注册。 - -通过 launchd plist 实现 `mind start` 开机自启。 -""" - -import os -import platform -import shutil -import subprocess -import sys -import textwrap - -_LABEL = "club.brainwork.mindcli" -_PLIST_DIR = os.path.expanduser("~/Library/LaunchAgents") -_PLIST_PATH = os.path.join(_PLIST_DIR, f"{_LABEL}.plist") -_LOG_PATH = os.path.expanduser("~/Library/Logs/mindcli.log") - - -def _find_mind_executable() -> str: - """定位 mind 可执行文件路径。""" - # 优先使用 which - result = shutil.which("mind") - if result: - return result - # 回退:当前 Python 的 scripts 目录 - scripts_dir = os.path.join(os.path.dirname(sys.executable), "mind") - if os.path.isfile(scripts_dir): - return scripts_dir - raise FileNotFoundError( - "找不到 mind 可执行文件。请确认已运行 `pip install -e .` 安装 mindos-cli。" - ) - - -def install_service() -> None: - """ - 注册 Mind CLI 为 macOS launchd 服务。 - - 生成 plist → launchctl load → 开机自启。 - """ - if platform.system() != "Darwin": - print("⚠️ install-service 目前仅支持 macOS。") - print(" Linux 用户请手动创建 systemd unit。") - return - - mind_path = _find_mind_executable() - os.makedirs(_PLIST_DIR, exist_ok=True) - - plist_content = textwrap.dedent(f"""\ - - - - - Label - {_LABEL} - - ProgramArguments - - {mind_path} - start - - - RunAtLoad - - - KeepAlive - - - StandardOutPath - {_LOG_PATH} - - StandardErrorPath - {_LOG_PATH} - - ThrottleInterval - 10 - - - """) - - with open(_PLIST_PATH, "w") as f: - f.write(plist_content) - - # launchctl load - subprocess.run(["launchctl", "load", _PLIST_PATH], check=True) - - print(f"✅ Mind CLI 已注册为 launchd 服务") - print(f" Plist: {_PLIST_PATH}") - print(f" Log: {_LOG_PATH}") - print(f" Binary: {mind_path}") - print(f"\n 服务将在登录时自动启动。") - print(f" 手动启动:launchctl start {_LABEL}") - print(f" 手动停止:launchctl stop {_LABEL}") - - -def uninstall_service() -> None: - """ - 注销 Mind CLI launchd 服务。 - - launchctl unload → 删除 plist。 - """ - if platform.system() != "Darwin": - print("⚠️ uninstall-service 目前仅支持 macOS。") - return - - if not os.path.isfile(_PLIST_PATH): - print(f"⚠️ 未找到 plist: {_PLIST_PATH}") - print(f" Mind CLI 可能未注册为服务。") - return - - subprocess.run(["launchctl", "unload", _PLIST_PATH], check=False) - os.remove(_PLIST_PATH) - - print(f"✅ Mind CLI 服务已注销") - print(f" 已删除: {_PLIST_PATH}") diff --git a/pyproject.toml b/pyproject.toml index 4cc77be..6d6ec59 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,8 +4,8 @@ build-backend = "setuptools.build_meta" [project] name = "mindos-cli" -version = "0.2.1" -description = "MindOS NEXT 本地执行体 — Cloud Hermes 的受管理执行节点" +version = "0.3.0" +description = "MindOS NEXT 拾音采集端 — 实时流式推送 Cloud ASR(内容枢纽时代)" readme = "README.md" requires-python = ">=3.10" license = {text = "MIT"} @@ -14,22 +14,9 @@ authors = [ ] dependencies = [ - # Hermes 核心依赖 - "litellm>=1.30", - "openai>=1.0", - "rich>=13.0", - "prompt-toolkit>=3.0", - "tiktoken>=0.5", - "fire>=0.5", - "pyyaml>=6.0", - "aiohttp>=3.9", - # MCP 协议 - "mcp>=1.0", - # Mind CLI 专属 + # 拾音采集端最小依赖(chat/tunnel 相关依赖已随 2026-08 瘦身移除) "websockets>=12.0", "click>=8.0", - # Hermes _vendor 隐性依赖 - "python-dotenv>=1.0", ] [project.optional-dependencies] diff --git a/versions.json b/versions.json index 9dd2812..e9f2790 100644 --- a/versions.json +++ b/versions.json @@ -12,11 +12,11 @@ "releaseNotes": "V3.1.2:版本检查 + 更新提示 + alarms 定时检测" }, "cli": { - "version": "0.2.1", + "version": "0.3.0", "installCmd": "pipx install \"mindos-cli[audio] @ git+https://git.brainwork.club/lidf/MindOS_CLI.git\"", "upgradeCmd": "pipx reinstall mindos-cli", - "releaseDate": "2026-07-01", - "releaseNotes": "v0.2.1 修复 VENDOR_COMMIT 打包 + health vendor 字段解析 + bridge 版本号日志" + "releaseDate": "2026-08-09", + "releaseNotes": "v0.3.0 瘦身:内容枢纽时代仅保留拾音采集端(record 命令族,实时流式不落盘)+ vendor 快照;砍掉 chat/tunnel/守护进程/launchd/自更新(SPEC_mindos_content_hub 决策)" }, "hap": { "version": "0.0.1", @@ -24,4 +24,4 @@ "releaseDate": "", "releaseNotes": "开发中" } -} +} \ No newline at end of file