""" Mind CLI — 拾音采集端(内容枢纽时代 v0.3.0 瘦身版)。 定位(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 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 注入 # ── 常量 ────────────────────────────────────────────────────── _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: """获取 MindPass JWT(环境变量 MINDOS_JWT)。""" return os.environ.get("MINDOS_JWT") def _load_state() -> dict | None: try: 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 def _pid_alive(pid: int) -> bool: try: os.kill(pid, 0) return True except ProcessLookupError: return False except PermissionError: return True @click.group() def main(): """MindOS 拾音采集端 — 实时流式推送 Cloud ASR。""" @main.group() def record(): """管理系统录音(实时流式 → Cloud ASR,不落盘)。""" @record.command(name="start") @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): """开始录音(前台运行,Ctrl+C 停止)。 实时流式:采集 PCM16 帧 → WS 直推 Cloud ASR,不落盘。 """ 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: 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") 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(): """查看录音状态。""" state = _load_state() if not state or not _pid_alive(state.get("pid", -1)): click.echo("⏹️ 未在录音") return 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__": main()