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
-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))