refactor(cli): v0.3.0 瘦身 — 内容枢纽时代仅保留拾音采集端

决策依据:SPEC_mindos_content_hub(2026-08 战略逆转)——MindOS 不再自建
本地执行体,宿主层由 WorkBuddy 等 MCP 消费端承接。

砍掉(-1500 行):
- cli.py chat/ask/health/start/install-service/uninstall-service/capabilities/
  tunnel/update 命令集(492→175 行)
- service.py(launchd 自启)、capability.py(能力上报)、health.py(8660 守护进程)
- pipelines/tool_proxy.py(工具白名单)、pipelines/tunnel_session.py(WS 隧道)
- pyproject 依赖精简:litellm/openai/rich/mcp 等移除,仅剩 websockets+click

保留/重构:
- pipelines/audio_capture.py(实时流式,不落盘)原样保留
- record 命令族重构为直接驱动 audio_capture(不再经 8660),跨进程
  stop/status 用 ~/.mindcli/record.json 状态文件(pid 识别)
- _vendor/ 快照 + scripts/vendor_hermes.sh 保留
- README.md 新增(含瘦身记录)

验证:py_compile 全绿;mind record status/start 错误路径冒烟通过;
假 token 实测 WS 401(云端端点在校验)+ 状态文件清理 ✓
This commit is contained in:
2026-08-09 16:47:38 +08:00
parent 5aa8fbc284
commit d760aa70b5
10 changed files with 166 additions and 1460 deletions
+32
View File
@@ -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=<mindpass-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`
+3 -6
View File
@@ -1,17 +1,14 @@
""" """
MindOS CLI — Cloud Hermes 的受管理执行节点 MindOS CLI — 拾音采集端(内容枢纽时代 v0.3.0 瘦身版)
包初始化:将 _vendor/ 目录加入 sys.path 包初始化:将 _vendor/ 目录加入 sys.path
使 Hermes 模块的内部 import 路径保持原样工作。 使 Hermes 模块的内部 import 路径保持原样工作(快照机制保留)
POC 验证结论:sys.path.insert(0, _vendor_dir) 一行即可,
不需要重写 Hermes 的 10K 行代码中的任何 import。
""" """
import os import os
import sys import sys
__version__ = "0.2.1" __version__ = "0.3.0"
# ── Vendor 路径注入 ────────────────────────────────────────── # ── Vendor 路径注入 ──────────────────────────────────────────
# 将 _vendor/ 目录加入 sys.path 头部, # 将 _vendor/ 目录加入 sys.path 头部,
-87
View File
@@ -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"
+124 -441
View File
@@ -1,491 +1,174 @@
""" """
Mind CLI — 命令行入口 Mind CLI — 拾音采集端(内容枢纽时代 v0.3.0 瘦身版)
通过 Click 定义 `mind` 命令族。 定位(SPEC_mindos_content_hub 决策):MindOS 不再自建"本地执行体"
- chat/ask 走 _vendor/run_agent.py 的 headless AIAgent(铁律 A:决策权归云端) MindCLI 降级为**最小系统拾音工具**:采集系统音频/麦克风 → 实时流式
- LLM 调用 100% 走 Cloud GatewayJWT 计费),不在本地拉起 vendor TUI 推送到 Cloud ASRdashscope_realtime 管线),转写由云端负责。
- --offline 备选:走 _vendor/cli.py 的完整 TUI(铁律 C:断开即自治)
保留(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 json
import os import os
import signal
import sys import sys
import time
from pathlib import Path
import click
# 确保 _vendor/ 已注入 sys.path # 确保 _vendor/ 已注入 sys.path
import mindcli # noqa: F401 — 触发 __init__.py 的 sys.path 注入 import mindcli # noqa: F401 — 触发 __init__.py 的 sys.path 注入
# ── Cloud Gateway 配置 ──────────────────────────────────── # ── 常量 ──────────────────────────────────────────────────────
_RECORD_WS_BASE = os.environ.get(
def _get_cloud_gateway_url() -> str: "MINDOS_RECORD_WS_URL",
"""Cloud Gateway 的 LLM API base URL。""" "wss://agent.brainwork.club/mindos-next/ws/record",
return os.environ.get( )
"MINDOS_GATEWAY_URL", _STATE_DIR = Path.home() / ".mindcli"
"https://agent.brainwork.club/mindos-next/llm", _STATE_FILE = _STATE_DIR / "record.json"
)
def _get_jwt() -> str | None: 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 def _load_state() -> dict | None:
#JWT 存在 tunnel handle 内存中,不落盘)
import urllib.request
try: try:
req = urllib.request.Request("http://127.0.0.1:8660/tunnel/status") with open(_STATE_FILE) as f:
with urllib.request.urlopen(req, timeout=2) as resp: return json.load(f)
data = json.loads(resp.read()) except (FileNotFoundError, json.JSONDecodeError):
if data.get("status") == "connected": return None
# tunnel 已连接,JWT 在 handle 内存中
# 通过环境变量 MINDOS_JWT 传递(由 /tunnel/activate 时设置)
return os.environ.get("MINDOS_JWT") def _save_state(state: dict) -> None:
except Exception: _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 pass
return None
# ── Click 命令组 ────────────────────────────────────────── def _pid_alive(pid: int) -> bool:
@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 GatewayJWT 计费)。
--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 的交互式 chatrun_agent headless)。"""
jwt = _get_jwt()
if not jwt:
click.echo("❌ 未找到 JWT,请先连接 Tunnelmind tunnel connect --token <JWT>")
click.echo(" 或设置环境变量: export MINDOS_JWT=<your-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 GatewayJWT 计费)。
"""
jwt = _get_jwt()
if not jwt:
click.echo("❌ 未找到 JWT,请先连接 Tunnelmind tunnel connect --token <JWT>")
sys.exit(1)
from run_agent import AIAgent
agent = AIAgent(
base_url=_get_cloud_gateway_url(),
api_key=jwt,
model=model or None,
)
try: try:
result = agent.run_conversation(user_message=question) os.kill(pid, 0)
response = result.get("response", "") return True
except ProcessLookupError:
if fmt == "json": return False
click.echo(json.dumps({"question": question, "answer": response}, except PermissionError:
ensure_ascii=False, indent=2)) return True
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)
@main.command() @click.group()
def health(): def main():
"""显示本地 CLI 健康状态""" """MindOS 拾音采集端 — 实时流式推送 Cloud ASR"""
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")
@main.group() @main.group()
def record(): def record():
"""管理本地系统录音。""" """管理系统录音(实时流式 → Cloud ASR,不落盘)"""
pass
@record.command(name="start") @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("--chat-id", default="", help="对话 ID")
@click.option("--source", type=click.Choice(["system", "mic"]), default="system", @click.option("--source", type=click.Choice(["system", "mic"]), default="system",
help="音频源:system=系统音频(默认),mic=麦克风") help="音频源:system=系统音频(默认),mic=麦克风")
def record_start(token, chat_id, source): def record_start(token, chat_id, source):
"""开始录音(独立音频源 → Cloud ASR)。 """开始录音(前台运行,Ctrl+C 停止)。
双工模式下,CLI 和浏览器各自独立推送,不做混音 实时流式:采集 PCM16 帧 → WS 直推 Cloud ASR,不落盘
可多次调用不同 --source 启动多路录音(双工模式)。
""" """
import urllib.request jwt = token or _get_jwt()
body = json.dumps({"token": token, "chatId": chat_id, "source": source}).encode() 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: try:
req = urllib.request.Request( from mindcli.pipelines.audio_capture import capture as capture_audio
"http://127.0.0.1:8660/record/start", asyncio.run(_run_capture(
data=body, capture_audio, ws_url, chat_id, meeting_id, source, _on_text,
headers={"Content-Type": "application/json"}, ))
) except KeyboardInterrupt:
with urllib.request.urlopen(req, timeout=10) as resp: pass
result = json.loads(resp.read()) finally:
if result.get("ok"): _clear_state()
click.echo(f"🎙️ 录音已开始 source={result.get('source')} meetingId={result.get('meetingId')}")
click.echo(" 使用 `mind record stop` 停止")
else: async def _run_capture(capture_audio, ws_url, chat_id, meeting_id, source, on_text):
click.echo(f"{result.get('error')}") """前台录音循环:启动采集 → 等 SIGINT/SIGTERM → 优雅停止。"""
except Exception as e: handle = await capture_audio(
click.echo(f"❌ 失败: {e}") ws_url=ws_url, chat_id=chat_id, meeting_id=meeting_id,
click.echo(" 确认 Health Server 已运行(mind start") 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") @record.command(name="stop")
@click.option("--chat-id", default="", help="停止指定 chatId 的录音(默认停止全部)") def record_stop():
def record_stop(chat_id): """停止录音(向 record start 进程发 SIGTERM)。"""
"""停止录音。""" state = _load_state()
import urllib.request if not state or not _pid_alive(state.get("pid", -1)):
url = "http://127.0.0.1:8660/record/stop" _clear_state()
if chat_id: click.echo("⏹️ 未在录音")
url += f"?chatId={chat_id}" return
try: os.kill(state["pid"], signal.SIGTERM)
req = urllib.request.Request( click.echo(f"⏹️ 已发送停止信号 pid={state['pid']}")
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}")
@record.command(name="status") @record.command(name="status")
def record_status(): def record_status():
"""查看录音状态。""" """查看录音状态。"""
import urllib.request state = _load_state()
try: if not state or not _pid_alive(state.get("pid", -1)):
req = urllib.request.Request("http://127.0.0.1:8660/record/status") click.echo("⏹️ 未在录音")
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("⚠️ 无法检查远端版本(网络问题?)")
return return
duration = round(time.time() - state.get("startedAt", time.time()), 1)
remoteVersion = remote.get("version") click.echo(f"🎙️ 录音中 duration={duration}s chatId={state.get('chatId', '')} "
if not remoteVersion: f"source={state.get('source')} meetingId={state.get('meetingId')}")
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 CLImind start)以生效")
else:
click.echo(f"❌ 升级失败: {result.stderr[:300]}")
click.echo(" 可手动执行: pipx reinstall mindos-cli")
def _compare_versions(v1: str, v2: str) -> int:
"""比较两个语义化版本号。返回 -1 (v1<v2), 0 (相等), 1 (v1>v2)。"""
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"
if __name__ == "__main__": if __name__ == "__main__":
-333
View File
@@ -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
-183
View File
@@ -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,
})
-274
View File
@@ -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))
-116
View File
@@ -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"""\
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>{_LABEL}</string>
<key>ProgramArguments</key>
<array>
<string>{mind_path}</string>
<string>start</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>{_LOG_PATH}</string>
<key>StandardErrorPath</key>
<string>{_LOG_PATH}</string>
<key>ThrottleInterval</key>
<integer>10</integer>
</dict>
</plist>
""")
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}")
+3 -16
View File
@@ -4,8 +4,8 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "mindos-cli" name = "mindos-cli"
version = "0.2.1" version = "0.3.0"
description = "MindOS NEXT 本地执行体 — Cloud Hermes 的受管理执行节点" description = "MindOS NEXT 拾音采集端 — 实时流式推送 Cloud ASR(内容枢纽时代)"
readme = "README.md" readme = "README.md"
requires-python = ">=3.10" requires-python = ">=3.10"
license = {text = "MIT"} license = {text = "MIT"}
@@ -14,22 +14,9 @@ authors = [
] ]
dependencies = [ dependencies = [
# Hermes 核心依赖 # 拾音采集端最小依赖(chat/tunnel 相关依赖已随 2026-08 瘦身移除)
"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 专属
"websockets>=12.0", "websockets>=12.0",
"click>=8.0", "click>=8.0",
# Hermes _vendor 隐性依赖
"python-dotenv>=1.0",
] ]
[project.optional-dependencies] [project.optional-dependencies]
+4 -4
View File
@@ -12,11 +12,11 @@
"releaseNotes": "V3.1.2:版本检查 + 更新提示 + alarms 定时检测" "releaseNotes": "V3.1.2:版本检查 + 更新提示 + alarms 定时检测"
}, },
"cli": { "cli": {
"version": "0.2.1", "version": "0.3.0",
"installCmd": "pipx install \"mindos-cli[audio] @ git+https://git.brainwork.club/lidf/MindOS_CLI.git\"", "installCmd": "pipx install \"mindos-cli[audio] @ git+https://git.brainwork.club/lidf/MindOS_CLI.git\"",
"upgradeCmd": "pipx reinstall mindos-cli", "upgradeCmd": "pipx reinstall mindos-cli",
"releaseDate": "2026-07-01", "releaseDate": "2026-08-09",
"releaseNotes": "v0.2.1 修复 VENDOR_COMMIT 打包 + health vendor 字段解析 + bridge 版本号日志" "releaseNotes": "v0.3.0 瘦身:内容枢纽时代仅保留拾音采集端(record 命令族,实时流式不落盘)+ vendor 快照;砍掉 chat/tunnel/守护进程/launchd/自更新(SPEC_mindos_content_hub 决策)"
}, },
"hap": { "hap": {
"version": "0.0.1", "version": "0.0.1",
@@ -24,4 +24,4 @@
"releaseDate": "", "releaseDate": "",
"releaseNotes": "开发中" "releaseNotes": "开发中"
} }
} }