From ac47b7a2ed154267a4368a86a17ed9831667a5a7 Mon Sep 17 00:00:00 2001 From: ypshidifu Date: Fri, 25 Sep 2026 10:18:28 +0800 Subject: [PATCH] =?UTF-8?q?=E7=83=AD=E9=87=8D=E8=BD=BD=E5=89=8D=E7=BD=AE?= =?UTF-8?q?=E6=8E=A2=E6=B4=BB+skill=E8=B7=A8=E6=A0=B9=E6=89=BE=E7=9C=9F?= =?UTF-8?q?=E8=BA=AB=EF=BC=9Amcpctl=E5=AF=B9=E8=B0=83=E5=90=8E=E5=85=88=5F?= =?UTF-8?q?live=5Foc=E6=8E=A2=E6=B4=BB(WSL=E6=9C=AA=E8=B7=91/OC=E6=9C=AA?= =?UTF-8?q?=E7=9B=91=E5=90=AC=E5=88=99=E8=B7=B3=E8=BF=87=E9=87=8D=E8=BD=BD?= =?UTF-8?q?=E6=8C=89=E6=88=90=E5=8A=9F=E8=BF=94=E5=9B=9E,=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E6=8A=A5WinError10061=E5=A4=B1=E8=B4=A5,host=E5=A4=8D?= =?UTF-8?q?=E7=94=A8=E7=BB=99=E6=8E=A8=E9=80=81=E4=B8=8Ereload),ocskill?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=5Falt=5Fskill=5Fmd=E8=B7=A8=E5=B7=B2?= =?UTF-8?q?=E7=9F=A5=E6=A0=B9(.workbuddy/.config=E5=90=ABtrash/WSL?= =?UTF-8?q?=E5=AF=B9=E5=BA=94=E6=A0=B9,OC=5FSKILL=5FALT=5FROOTS=E5=8F=AF?= =?UTF-8?q?=E8=BF=BD=E5=8A=A0)=E6=89=BE=E5=90=8C=E5=90=8DSKILL.md=E7=9C=9F?= =?UTF-8?q?=E8=BA=AB,WSL=E4=BA=8C=E5=B1=82=E7=A9=BA=E7=BB=93=E6=9E=9C?= =?UTF-8?q?=E8=A1=A5=E8=AF=B4=E6=98=8E=E8=A1=8C=E4=B8=8D=E9=9D=99=E9=BB=98?= =?UTF-8?q?,=E4=B8=A4=E5=A5=97=E5=8D=95=E6=B5=8B=E5=85=A8=E7=BB=BF(6?= =?UTF-8?q?=E9=A1=B9+15=E9=A1=B9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 24 +++++++++++- _test_diag.py | 37 ++++++++++++++++++ _test_mcpctl.py | 84 +++++++++++++++++++++++++++++++++++++++++ mcpctl.py | 88 +++++++++++++++++++++++++++++++------------ ocskill.py | 99 +++++++++++++++++++++++++++++++++++++++++++++++-- 5 files changed, 303 insertions(+), 29 deletions(-) create mode 100644 _test_mcpctl.py diff --git a/README.md b/README.md index 5afa2b4..75f7b3b 100644 --- a/README.md +++ b/README.md @@ -139,6 +139,14 @@ python mcpctl.py off --no-reload # 只对调文件,不调用重载 热重载打的是 `POST /api/location/reload`,会重建 location 并重读磁盘配置,MCP 立即生效; 失败时配置文件已经对调完成,重启 OC 一样生效。 +**前置探活:没有运行中的 OC 就不重载、也不算失败(2026-09-25 用户反馈)**: +对调完成后先 `_live_oc()` —— WSL 目标先查 VM 是否在跑(没跑直接跳过、不发网络请求), +再端口发现 + 探活找运行中的 OC。都没有 → 打印「无需热重载:… 配置已对调完成, +等 OC 下次启动时会自动加载新配置」并按成功返回,**不再报** +「热重载失败:… WinError 10061 连接拒绝」(WSL/OC 都没启动时冷启动会自己加载)。 +探活命中的 host 直接复用给推送兜底与 reload(不重复探测);只有 +「OC 在跑但 reload 返回 401/404/错误」才照旧算真实失败。 + **开启方向多了「推送兜底」(双保险)**:`on` 在对调+写 flag 之后、reload 之前, 先把活配置里的 `mcp` 推进**运行中的 OC**。三端同一套语义——**GET+merge 补 shell → PATCH 真实路由**,按各自运行方式实现: @@ -188,10 +196,22 @@ UNC 目录扫描(那会触发冷启动卡死),直接报错返回。 - 目标读不出来时不再用 realpath 硬解(那只会解出链接自身、误报断链), 直接标注「疑似 WSL 内软链接,交由二层诊断」; - 推送失败(缺 SKILL.md)的报错里同样附带这份诊断,回答「真实文件在哪」。 +- **跨根兜底:空壳目录直接找出「真身在别处」(2026-09-25 用户反馈)**—— + 实测来源目录(`.config/opencode/skills/`,两端)是**真实空目录、不是链接**, + 但大模型照样能调用:真身在另一个根 `~/.workbuddy/skills//SKILL.md` + (即 `wb` 终端)。结论行不再止步「仍未找到」,而是跨已知根找同名 SKILL.md: + 本机 `~/.workbuddy/skills`、`~/.config/opencode/skills`(含 `.trash` 回收站)、 + 当前目录 `.workbuddy/skills`、WSL 端对应两根(folder 是 WSL UNC 直查, + 否则 VM 在跑才查),并可用环境变量 `OC_SKILL_ALT_ROOTS`(`os.pathsep` 分隔) + 追加自定义根;找到就列出真身路径并提示「改选包含真身的目录(如 wb 终端)作来源」。 + 另外 WSL 二层查完**空结果也会说话**(「ℹ WSL 内二层已查:没有 SKILL.md、也没有链接」), + 不再静默——静默会被误读成二层没跑。 **回归单测**:`python _test_diag.py`(空壳 / 正常 skill / junction 链接 / 断链 / -大小写 / WSL UNC 真链接共 15 项断言,2 个文件 symlink 场景在拦截 symlink 的 -机器上自动 SKIP;含 WSL 场景,首次跑会拉起 VM)。 +大小写 / WSL UNC 真链接 / 跨根兜底等 15+ 项断言,2 个文件 symlink 场景在拦截 symlink 的 +机器上自动 SKIP;含 WSL 场景,首次跑会拉起 VM); +`python _test_mcpctl.py`(热重载前置探活 6 项:未运行 skip 不探测、失败仍报错、 +host 复用等)。 --- diff --git a/_test_diag.py b/_test_diag.py index fa6e20d..8f3c16b 100644 --- a/_test_diag.py +++ b/_test_diag.py @@ -9,6 +9,7 @@ from pathlib import Path sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from ocskill import ( # noqa: E402 diag_skill_md, skill_brief, probe_push, find_skill_md, _wsl_running, + _alt_skill_md, ) FAIL: list[str] = [] @@ -131,6 +132,9 @@ if unc.exists(): notes = diag_skill_md(unc) check("WSL UNC 空壳 -> 报空的/仍未找到", any(("目录是空的" in n) or ("仍未找到" in n) for n in notes), str(notes)) + if _wsl_running("Ubuntu-24.04"): + check("WSL UNC 空壳 -> 二层已查有说明行(不静默)", + any("WSL 内二层" in n for n in notes), str(notes)) else: print("SKIP WSL UNC 空壳(路径不存在):", unc) @@ -170,6 +174,39 @@ res = probe_push(str(empty)) check("probe_push 空壳 -> ok=False 且带 diag", res.get("ok") is False and res.get("diag"), str(res)[:300]) +# ---------- 11. 空壳但同名 skill 真身在别的根(2026-09-25 用户反馈) ---------- +# 真实场景:来源目录是空壳,但大模型能调用 —— 真身在 ~/.workbuddy/skills(wb 终端)。 +# 测试用 OC_SKILL_ALT_ROOTS 注入一个「另一个根」,避免往真实 wb 目录里写文件。 +altbase = tmp / "alt_roots" +(altbase / "alt-skill").mkdir(parents=True) +(altbase / "alt-skill" / "SKILL.md").write_text("---\nname: alt-skill\ndescription: d\nt\n---\n", + encoding="utf-8") +shell2 = tmp / "alt-skill" +shell2.mkdir() +os.environ["OC_SKILL_ALT_ROOTS"] = str(altbase) +try: + notes = diag_skill_md(shell2) +finally: + os.environ.pop("OC_SKILL_ALT_ROOTS", None) +check("空壳 + 其它根有真身 -> 结论给出真身路径", + any("真身在别处" in n for n in notes) + and any(str(altbase / "alt-skill" / "SKILL.md") in n for n in notes), + str(notes)) +b = skill_brief("alt-skill", tmp) +check("skill_brief 空壳仍 has_md=False(真身只在诊断里提示)", + b.get("has_md") is False and b.get("diag"), str(b)[:200]) + +# ---------- 12. 跨端/回收站兜底(环境依赖,不满足就 SKIP) ---------- +gkd = Path.home() / ".config" / "opencode" / "skills" / "gkd-shizuku-keepalive" +gkd_alts = _alt_skill_md(gkd) if gkd.is_dir() and find_skill_md(gkd) is None else [] +if gkd_alts: + notes = diag_skill_md(gkd) + check("本机空壳 -> 找到回收站/跨端里的真身", + any("真身在别处" in n for n in notes) and any(gkd_alts[0] in n for n in notes), + str(notes)) +else: + print("SKIP 跨端回收站兜底(环境不满足:目录不存在/非空壳/无处可找)") + print() print(("ALL PASS" if not FAIL else "FAILURES: " + ", ".join(FAIL))) sys.exit(1 if FAIL else 0) diff --git a/_test_mcpctl.py b/_test_mcpctl.py new file mode 100644 index 0000000..a009dfa --- /dev/null +++ b/_test_mcpctl.py @@ -0,0 +1,84 @@ +# -*- coding: utf-8 -*- +"""mcpctl 热重载前置探活单测(2026-09-25 用户反馈)。 + +用户场景:mcp↔wsl 开关对调后,WSL/OC 没在跑 → 报「热重载失败:… WinError 10061 +连接拒绝」。期望:没有可重载的 OC 就跳过重载、按成功返回(下次冷启动自动加载), +只有「OC 在跑但重载出错」才算失败。 +""" +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import mcpctl # noqa: E402 + +FAIL: list[str] = [] + + +def check(name: str, cond: bool, extra: str = "") -> None: + print(("PASS " if cond else "FAIL ") + name + (" | " + extra if extra else "")) + if not cond: + FAIL.append(name) + + +class _A: # 最小 args 替身(只用到 _live_oc/do_reload 需要的字段) + distro = "Ubuntu-24.04" + password = "" + port = 4096 + timeout = 2.0 + + +args = _A() +_orig = (mcpctl._wsl_running, mcpctl._find_live_host, mcpctl._post) + + +def _no_net(*_a, **_k): + raise AssertionError("这一步不该做网络探测") + + +try: + # ---------- 1. WSL 目标 + VM 未运行 -> 直接 skip,绝不探测 ---------- + mcpctl._wsl_running = lambda d: False + mcpctl._find_live_host = _no_net + h, pwd, skip = mcpctl._live_oc("wsl", args) + check("WSL 未运行 -> skip 且不做探测", + h == "" and bool(skip) and "WSL 未启动" in skip, str(skip)) + + # ---------- 2. WSL 在跑但 OC 没在跑 -> skip 带探测原因(不再是「失败」) ---------- + mcpctl._wsl_running = lambda d: True + mcpctl._find_live_host = ( + lambda t, a, pw: ("", "/root/.config/opencode", "http://172.29.95.185:4096 连不上") + ) + h, pwd, skip = mcpctl._live_oc("wsl", args) + check("OC 未运行 -> skip 带原因", h == "" and bool(skip) and "连不上" in skip, str(skip)) + + # ---------- 3. OC 在跑 -> 不 skip,host 直接复用 ---------- + mcpctl._find_live_host = ( + lambda t, a, pw: ("http://127.0.0.1:4096", "/root/.config/opencode", "") + ) + h, pwd, skip = mcpctl._live_oc("wsl", args) + check("OC 运行中 -> 不 skip 且拿到 host", + skip is None and h.startswith("http"), str((h, skip))) + + # ---------- 4. do_reload 复用 host -> 不再探测,直接 reload 成功 ---------- + mcpctl._find_live_host = _no_net + mcpctl._post = lambda url, pw, timeout, data=b"{}", method="POST": (200, "{}") + ok, msg = mcpctl.do_reload("wsl", args, host="http://127.0.0.1:4096", password="x") + check("do_reload 复用 host -> 热重载成功", ok and "热重载成功" in msg, msg) + + # ---------- 5. OC 在跑但 reload 返回错误 -> 仍算真实失败(不能吞) ---------- + mcpctl._post = lambda url, pw, timeout, data=b"{}", method="POST": (0, "boom") + ok, msg = mcpctl.do_reload("wsl", args, host="http://127.0.0.1:4096", password="x") + check("OC 在跑但重载连不上 -> 仍报失败", (not ok) and "连不上" in msg, msg) + + # ---------- 6. 本机目标 OC 没在跑 -> skip(本机目标不看 WSL 状态) ---------- + mcpctl._find_live_host = ( + lambda t, a, pw: ("", "C:/cfg", "没有找到运行中的 OC(4096 未监听,端口发现也未命中)") + ) + h, pwd, skip = mcpctl._live_oc("local", args) + check("local OC 未运行 -> skip", h == "" and bool(skip) and "没有找到" in skip, str(skip)) +finally: + mcpctl._wsl_running, mcpctl._find_live_host, mcpctl._post = _orig + +print() +print(("ALL PASS" if not FAIL else "FAILURES: " + ", ".join(FAIL))) +sys.exit(1 if FAIL else 0) diff --git a/mcpctl.py b/mcpctl.py index 08a6905..1851861 100644 --- a/mcpctl.py +++ b/mcpctl.py @@ -5,8 +5,8 @@ mcpctl —— 命令行版的 MCP 配置对调开关(与 TUI 插件 / server 做什么: status 查看当前模式与 MCP 开/关状态(只读,不改任何文件) - on 把配置对调为「含 MCP」的原始版,并调用 reload 热重载 - off 把配置对调为「不含 MCP」的安静版,并调用 reload 热重载 + on 把配置对调为「含 MCP」的原始版,OC 在跑则热重载(没跑就跳过) + off 把配置对调为「不含 MCP」的安静版,OC 在跑则热重载(没跑就跳过) mode 查看或修改 mcpcfg.txt 模式键(自动 / 手动 / 关闭) 模式语义(与插件完全一致): @@ -551,14 +551,17 @@ def _find_live_host(target: str, args, password: str) -> tuple[str, str, str]: return "", directory, last or "没有找到运行中的 OC(4096 未监听,端口发现也未命中)" -def do_reload(target: str, args) -> tuple[bool, str]: - password = args.password or _read_password( - wsl_config_dir(args.distro) if target == "wsl" else _home_config() - ) - # 端口发现 + 探活:先找到真正在跑的 OC(4096 是默认值,实测常是随机端口) - host, _dir, why = _find_live_host(target, args, password) +def do_reload(target: str, args, host: str | None = None, + password: str | None = None) -> tuple[bool, str]: + if password is None: + password = args.password or _read_password( + wsl_config_dir(args.distro) if target == "wsl" else _home_config() + ) if not host: - return False, why + # 端口发现 + 探活:先找到真正在跑的 OC(4096 是默认值,实测常是随机端口) + host, _dir, why = _find_live_host(target, args, password) + if not host: + return False, why code, body = _post(host + "/api/location/reload", password, args.timeout) if 200 <= code < 300: return True, f"热重载成功({host}/api/location/reload)" @@ -571,7 +574,8 @@ def do_reload(target: str, args) -> tuple[bool, str]: return False, f"{host} 连不上(OC 可能没在运行)" -def push_mcp_config(args) -> tuple[bool, str]: +def push_mcp_config(args, host: str | None = None, + password: str | None = None) -> tuple[bool, str]: """reload 之前的兜底:把活配置里的 mcp 直接 PATCH 进运行中的 OC。 与 tui.js / ai-mcp.ts 的 pushMcpConfig 同一套链: 端口发现 + GET /api/config?directory= 探活并取 shell(GET+merge 三级兜底) @@ -590,13 +594,18 @@ def push_mcp_config(args) -> tuple[bool, str]: if not isinstance(mcp, dict) or not ((mcp.get("servers") or {})): return False, "活配置里没有 mcp.servers,无可推送内容" - password = args.password or _read_password( - wsl_config_dir(args.distro) if args.target == "wsl" else _home_config() - ) + if password is None: + password = args.password or _read_password( + wsl_config_dir(args.distro) if args.target == "wsl" else _home_config() + ) # 1) 端口发现 + 探活:GET /api/config 200 = 命中运行中的 OC(4096 常无监听) - host, directory, why = _find_live_host(args.target, args, password) - if not host: - return False, why + # 调用方(_switch)已探活时直接复用 host,不重复探测 + if host: + directory = _cfg_directory(args.target, args.distro) + else: + host, directory, why = _find_live_host(args.target, args, password) + if not host: + return False, why encoded = urllib.parse.quote(directory, safe=":/") # 2) GET+merge 补 shell:GET 运行中 config 的 info.shell → 活文件 shell → null @@ -678,6 +687,31 @@ def cmd_status(args) -> int: return 0 if not cfg.get("error") else 2 +def _live_oc(target: str, args) -> tuple[str, str, str | None]: + """热重载前置探活(2026-09-25 用户反馈新增)。 + + 返回 (host, password, skip_reason): + skip_reason 为 None → host 是运行中的 OC,可直接热重载(复用,不二探); + skip_reason 非 None → 没有可重载的 OC,调用方打印原因后按成功返回 —— + WSL 没启动 / OC 没在跑时,配置文件已经对调好,下次冷启动 OC 自己就会 + 加载,绝不能报「热重载失败」(如 WinError 10061 连接拒绝)。 + """ + if target == "wsl": + try: + wsl_on = _wsl_running(args.distro) + except Exception: + wsl_on = False + if not wsl_on: + return "", "", "WSL 未启动(OC 自然也没在运行)" + password = args.password or _read_password( + wsl_config_dir(args.distro) if target == "wsl" else _home_config() + ) + host, _dir, why = _find_live_host(target, args, password) + if not host: + return "", password, (why or "没有找到运行中的 OC") + return host, password, None + + def _switch(args, want_on: bool) -> int: # 写操作:WSL 没跑先唤醒(gate 在任何 UNC .exists() 之前) err = wsl_gate(args, need_write=True) @@ -720,22 +754,30 @@ def _switch(args, want_on: bool) -> int: print(f"配置已对调,MCP 现在是「{'开启' if after else '关闭'}」。") + if args.no_reload: + print("已按 --no-reload 跳过热重载,需重启 OC 生效。") + return 0 + + # 热重载前置探活(2026-09-25 用户反馈):WSL 没启动 / OC 没在跑就不重载、 + # 也不算失败 —— 配置文件已对调好,下次冷启动 OC 自己就会加载 + host, pwd, skip = _live_oc(args.target, args) + if skip: + print(f"无需热重载:{skip}。") + print(" 配置已对调完成,等 OC 下次启动时会自动加载新配置。") + return 0 + # 兜底推送(仅开启方向):reload 之前先把 mcp 配置送进运行中的 OC, # 推送成功 = 双保险;推送失败不阻断,走 reload 主通道。 # 推送失败不在这里就报负面消息 —— 它只是额外的双保险通道, # 是否真的生效由 reload 结果决定(避免「MCP 已可用却提示兜底」误导用户)。 pushed = None pmsg = "" - if want_on and not args.no_reload: - pushed, pmsg = push_mcp_config(args) + if want_on: + pushed, pmsg = push_mcp_config(args, host=host, password=pwd) if pushed: print(f"mcp 配置已先推送进 OC:{pmsg}") - if args.no_reload: - print("已按 --no-reload 跳过热重载,需重启 OC 生效。") - return 0 - - ok, msg = do_reload(args.target, args) + ok, msg = do_reload(args.target, args, host=host, password=pwd) if ok: if pushed: print(f"{msg},mcp 配置已先送入 + 热重载(双保险)。") diff --git a/ocskill.py b/ocskill.py index 7d0bea1..e031baa 100644 --- a/ocskill.py +++ b/ocskill.py @@ -736,6 +736,79 @@ def _resolve_link_real(path: Path, target: str) -> Path | None: return None +def _alt_skill_md(folder) -> list[str]: + """空壳/断链目录时,跨已知 skill 根目录找同名 skill 的 SKILL.md 真身。 + + 2026-09-25 用户实测场景:来源目录(如 .config/opencode/skills/)两端 + 都是空壳、也没有任何链接,但大模型照样能调用该 skill —— 因为真身在另一个 + 根(~/.workbuddy/skills//SKILL.md,即 wb 终端)。诊断必须把「原始文件 + 位置」找出来,而不是只报「仍未找到」。 + + 搜索根(可用环境变量 OC_SKILL_ALT_ROOTS 以 os.pathsep 追加): + 本机 ~/.workbuddy/skills、~/.config/opencode/skills(含 .trash 回收站)、 + 当前目录 .workbuddy/skills;Windows 上另查 WSL 端对应两根(folder 是 + WSL UNC 直接查,否则 VM 在跑才查)。 + 全程只读、任何一步失败跳过;返回找到的 SKILL.md 路径列表(去重, + 已排除「诊断对象自己」)。 + """ + name = Path(folder).name + if not name: + return [] + home = Path.home() + roots: list[Path] = [home / ".workbuddy" / "skills", + home / ".config" / "opencode" / "skills"] + extra = os.environ.get("OC_SKILL_ALT_ROOTS", "") + roots += [Path(p) for p in extra.split(os.pathsep) if p.strip()] + try: + roots.append(Path.cwd() / ".workbuddy" / "skills") + except OSError: + pass + if os.name == "nt": + parts = _unc_distro_rest(Path(folder)) + if parts: + wsl_on = True # folder 本身就在 WSL 里,同端根一定可查 + distro = parts[0] + else: + distro = DEFAULT_DISTRO + try: + wsl_on = _wsl_running(distro) + except Exception: + wsl_on = False + if wsl_on: + unc = Path("\\\\wsl.localhost\\%s" % distro) + roots += [unc / "root" / ".workbuddy" / "skills", + unc / "root" / ".config" / "opencode" / "skills"] + + def _norm(p) -> str: + return os.path.normcase(os.path.abspath(str(p))) + + self_dir = _norm(folder) + out: list[str] = [] + seen: set[str] = set() + for r in roots: + # <根>/<同名> + 回收站 <根>/.trash/<时间戳>/<同名> + cands = [r / name] + try: + if (r / ".trash").is_dir(): + cands += [t / name for t in sorted((r / ".trash").iterdir()) + if t.is_dir()] + except OSError: + pass + for cand in cands: + if _norm(cand) == self_dir: + continue # 就是诊断对象自己(已知空壳,别自己找自己) + try: + md = find_skill_md(cand) + except OSError: + md = None + if md: + key = _norm(md) + if key not in seen: + seen.add(key) + out.append(str(md)) + return out + + def diag_skill_md(folder) -> list[str]: """SKILL.md 缺失时的软链接诊断(只读,2026-09-24 按用户反馈新增)。 @@ -884,24 +957,42 @@ def diag_skill_md(folder) -> list[str]: timeout=20) except Exception: code, out = 1, b"" - if code == 0 and out: + if code == 0: + hits = 0 for ln in out.decode("utf-8", "replace").splitlines(): ln = ln.strip() if ln.startswith("MD "): wsl_md = ln[3:].strip() found_md.append(wsl_md) + hits += 1 notes.append(f" ✓ WSL 内找到 SKILL.md:{wsl_md}") elif ln.startswith("LINK "): + hits += 1 notes.append(f"WSL 内链接:{ln[5:].strip()}") + if not hits: + # 空结果也要说话(2026-09-25):静默会让人误以为二层没跑 + notes.append(" ℹ WSL 内二层已查:目录里没有 SKILL.md、也没有链接" + "(WSL 内视角与 Windows 一致)") + else: + notes.append(f" ℹ WSL 内二层查询未完成(rc={code}),只按 Windows 视角给结论") else: # 不静默跳过(2026-09-24 单测暴露):说明二层没查,让用户知道还能再查 notes.append(" ℹ WSL 当前未运行,跳过 WSL 内二层诊断" "(唤醒 WSL 后重试,可查出链接真身;VM 冷启动约 5–20 秒,不自动唤醒)") - # 结论 + # 结论:先跨已知根找同名真身(空壳目录的常见真相 —— 真身在 wb 等另一个根, + # 大模型调用的正是那个文件),找不到才回退「仍未找到」 if not found_md: - notes.append("→ 仍未找到 SKILL.md 的真实位置:目录可能是空壳 / 链接目标里也没有文件," - "建议换一个完整的 skill 来源再推。") + alts = _alt_skill_md(folder) + if alts: + notes.append("→ 本目录里没有 SKILL.md(空壳/断链),但同名 skill 的真身在别处:") + for a in alts: + notes.append(f" {a}") + notes.append(" 大模型实际调用的就是上面这些文件;推送请改选包含真身的目录" + "(如 wb 终端)作为来源。") + else: + notes.append("→ 仍未找到 SKILL.md 的真实位置:目录可能是空壳 / 链接目标里也没有文件," + "建议换一个完整的 skill 来源再推。") return notes