Procházet zdrojové kódy

feat: plugin-tips-cn 0.4.1 — 把 Hermes 英文系统提示换成随机中文

- 三层网:工具结果 / 助手回复 / 适配器出站(网关自己发的通知不走钩子,必须包传输层)
- 词库 106 类 / 672 条中文候选,重点类 10-18 条
- validate_phrases.py:拦截占位符与正则组不匹配这类静默 bug
- npm 分发:bin/install.js 一键装进 $HERMES_HOME/plugins/
fmode-ops před 1 dnem
revize
2fc44c7be2
10 změnil soubory, kde provedl 2320 přidání a 0 odebrání
  1. 5 0
      .gitignore
  2. 34 0
      CHANGELOG.md
  3. 21 0
      LICENSE
  4. 142 0
      README.md
  5. 295 0
      __init__.py
  6. 56 0
      bin/install.js
  7. 40 0
      package.json
  8. 1674 0
      phrases.json
  9. 13 0
      plugin.yaml
  10. 40 0
      validate_phrases.py

+ 5 - 0
.gitignore

@@ -0,0 +1,5 @@
+node_modules/
+*.log
+.DS_Store
+__pycache__/
+*.pyc

+ 34 - 0
CHANGELOG.md

@@ -0,0 +1,34 @@
+# 更新日志
+
+版本号只用小/中版本递进:加一批词库或一类场景 = minor(+0.1.0),小修 = patch(+0.0.1)。
+
+## 0.4.1
+- 补齐最后 17 个短类别,全表最少 3 条、重点类 10–18 条
+- 文案整体迭代第二稿:报错类加「托底感」,句式去同质化
+
+## 0.4.0
+- 词库扩到 106 类 / 672 条
+- 新增 21 类:停机/重启通知、Recovered reply(重启/重连/限流)、Cronjob Response 包装、
+  以及「它没回话:」后面的 **15 种解释**(连续改方向、上游不应答、只收回半截、额度用尽…)
+- 全表文案润色:更有温度、给能量、不油腻
+- 新增 `validate_phrases.py`:占位符与正则组不匹配这类静默 bug 自动拦截
+
+## 0.3.2
+- 修复 `http_error` 候选用 `{n}` 而正则组名为 `code`,导致输出字面量 `{n}`
+
+## 0.3.1
+- 适配器包装从 5 个方法扩到 22 个:审批/澄清/斜杠确认、图片文件说明文字、诊断推送、
+  最终回复路径;一个方法里的多个文本参数(title + message)也一并处理
+- 新增「返回值即文本」的包装(审批提示文案在函数内部拼)
+
+## 0.3.0
+- 改名 `plugin-tips-cn`
+- **新增第 3 层网:包住所有平台适配器的出站 `send`/`edit_message` 等方法** ——
+  网关自己发到聊天的通知不走任何插件钩子,这是「装了却没生效」的真因
+- 词库扩到 83 类 / 551 条,补全忙时回执、心跳、自主进化、后台任务、cron 等族
+
+## 0.2.0
+- 补 29 类「真正发到聊天里的话」(忙时回执、心跳、审批、会话重置…)
+
+## 0.1.0
+- 首版:`transform_tool_result` + `transform_llm_output` 两层网,21 类工具报错文案

+ 21 - 0
LICENSE

@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 fmode
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.

+ 142 - 0
README.md

@@ -0,0 +1,142 @@
+# plugin-tips-cn
+
+**让 Hermes 说人话。** 把 Hermes 和工具吐出来的英文系统提示,就地换成**随机的日常中文**——
+同一种情况每次说法都不一样,消息看着不像机器。
+
+> 适用于 [Hermes Agent](https://hermes-agent.nousresearch.com/docs)。词库 **106 类 / 672 条**中文候选。
+
+---
+
+## 它解决什么
+
+Hermes 本体没有 i18n。`config.yaml` 里的 `language` / `locale` / 顶层 `system_prompt` **都没人读**,
+运行时硬编码的英文只能从**消息出口**改。
+
+唯一官方支持的例外是 `display.status_phrases` + `$HERMES_HOME/status_phrases.yaml`,
+但它只覆盖长任务心跳这类表面;**其余全靠本插件**。
+
+## 效果示例
+
+| 原来(英文) | 换成(随机一条) |
+|---|---|
+| `⚡ Interrupting current task (3 min elapsed). I'll respond shortly.` | 收到!先按下暂停键,马上看你这条。 (跑了 3 分钟) |
+| `⏳ Queued for the next turn. I'll respond once the current task finishes.` | 手头这摊还没收工,你这条我收好了,一腾出手就办。 |
+| `⏩ Steered into current run. Your message arrives after the next tool call.` | 收到,你这条我捎在手上了,下一步就按它走。 |
+| `↪ Redirected current run. I'll adjust using your correction.` | 好,方向调过来了,按你的纠正走。 |
+| `⏳ Working — 12 min — running: browser_exec` | 还在跑,12 分钟了——稳住,就快出结果了。 — 此刻在干:browser_exec |
+| `💾 Self-improvement review: Memory updated · Skill 'x' patched` | 💾 这一轮的修行成果:🧠 这段我刻进记忆里了。 · 🔧 「x」又精进了一点。 |
+| `⚠️ No reply: the request was cancelled by a new correction on every attempt…` | ⚠️ 它没回话: 你连着改了几次方向,每次都把这一轮打断了。我不硬撑了——你最后那条纠正已在队列里,下一步就照它走。 |
+| `⚠️ Hermes is shutting down — your current task will be interrupted…` | ⚠️ 我要下线一会儿——手上这个任务会中断。等我回来,你随便说句话,我就接着往下做。 |
+| `Cronjob Response: 每日额度日报` | 📬 定时任务「每日额度日报」的结果: |
+| `Permission denied` | 这条路没权限——不硬闯,我换条道走。 |
+| `[Command timed out after 60s]` | 等了 60 秒还没动静,先掐了。 |
+| `Traceback (most recent call last):` | 程序崩了一下,堆栈如下。 |
+
+## 三层网(少一层就会漏)
+
+| 层 | 手段 | 覆盖 |
+|---|---|---|
+| 1 | `transform_tool_result` | 工具结果(超时、权限、路径、接口报错…) |
+| 2 | `transform_llm_output` | 助手最终回复 |
+| 3 | **包住适配器的出站方法** | **网关自己发到聊天的通知** |
+
+**第 3 层是关键。** 忙时回执、进度心跳、自主进化复盘、后台任务收工、cron 投递……
+源码里是直接 `adapter.send(...)` 发出去的,**不经过任何插件钩子**。只做 1+2 的话,
+聊天里看到的还是英文——这就是「装了插件却没生效」的真因。
+
+第 3 层具体包了每个平台适配器(含子类)的 22 个发文本方法:`send` / `edit_message` /
+`send_stream_frame` / `send_private_notice` / `send_slash_confirm` / `send_clarify` /
+`send_exec_approval` / `send_image`(caption) / `send_document` / `send_voice` / `send_video` /
+`emit_warning` / `send_final_ledgered` …,以及「返回值即文本」的 `_format_exec_approval`。
+位置参数和关键字参数都兜住,一个方法里有多个文本参数(`title` + `message`)也一并处理。
+
+## 安装
+
+### 方式一:npm
+
+```bash
+npx plugin-tips-cn                       # 装到 $HERMES_HOME/plugins/plugin-tips-cn
+hermes plugins enable plugin-tips-cn     # 启用
+# 然后重启一次网关
+```
+
+### 方式二:直接拷目录
+
+```bash
+cp -r plugin-tips-cn "$HERMES_HOME/plugins/"
+hermes plugins validate "$HERMES_HOME/plugins/plugin-tips-cn"
+hermes plugins enable plugin-tips-cn
+```
+
+### 方式三:git clone
+
+```bash
+git clone https://git.fmode.cn/fmode/plugin-tips-cn.git "$HERMES_HOME/plugins/plugin-tips-cn"
+hermes plugins enable plugin-tips-cn
+```
+
+> **必须重启一次网关**:插件在进程启动时 discovery 一次,长驻进程不会自动重扫。
+> 容器里由 s6 托管的网关,杀掉进程会自动拉起。
+
+## 改词库(不用重启)
+
+只改 `phrases.json`——按 mtime **热加载**,改完立刻生效。只有改 `.py` / `plugin.yaml` 才要重启网关。
+
+```jsonc
+{
+  "id": "cmd_timeout",                       // 类别 id
+  "label": "命令超时",                        // 给人看的说明
+  "patterns": ["\\[Command timed out after (?P<n>\\d+)s\\]"],   // 正则,忽略大小写,可用命名组
+  "variants": ["等了 {n} 秒还没动静,先掐了。", "..."]           // 命中时随机挑一条,想加多少加多少
+}
+```
+
+改完跑一遍自检:
+
+```bash
+python3 validate_phrases.py
+```
+
+它会拦下四类静默 bug:JSON 不合法、正则编译失败、**候选用 `{n}` 而正则组名叫 `code`**
+(这种错不会报异常,只会在运行时把字面量 `{n}` 发给用户)、类别缺 patterns/variants。
+
+## 开关
+
+| 环境变量 | 作用 |
+|---|---|
+| `PLUGIN_TIPS_CN_DISABLE=1` | 整个插件静默(默认开) |
+| `PLUGIN_TIPS_CN_LOG=1` | 每次替换打一条日志,方便调试 |
+| `PLUGIN_TIPS_CN_NO_SEND=1` | 只关掉「适配器出口」那一层 |
+
+## 验证
+
+插件会连**你自己的工具输出**一起汉化,所以验证脚本打印的「原文」会被换掉、看着像没生效。
+把空格换成 `·` 再打印就能绕过(正则匹配不上):
+
+```python
+print(out.replace(" ", "·"))
+```
+
+端到端自检:
+
+```bash
+echo "⚡ Interrupting current task. I'll respond to your message shortly."
+# 读回来是中文就说明网关里的插件在跑
+```
+
+验证第 3 层是否装上了:看网关日志里有没有
+
+```
+plugin-tips-cn: 已包住 N 个适配器出站方法(覆盖 M 个类)
+```
+
+## 设计原则
+
+- **只做「已知句式 → 中文候选」的就地替换**,其余文字和结构原样保留,所以 JSON / 代码块不会被打坏
+- 技术细节(路径、错误码、秒数、工具名)**保留**,不吞信息
+- 不替换结构化字段(如 `"exit_code": 124`),只替换自然语言句子
+- 替换失败一律吞掉——**绝不因为改文案而拦住发送**
+
+## 许可
+
+MIT

+ 295 - 0
__init__.py

@@ -0,0 +1,295 @@
+"""plugin-tips-cn — 把 Hermes 发出来的英文系统提示,就地换成随机的日常中文说法。
+
+三层网(缺一层就会漏):
+
+1. ``transform_tool_result`` —— 工具结果里的英文(超时、权限、路径、接口报错…)
+2. ``transform_llm_output``  —— 助手最终回复里的同类英文
+3. **适配器出口包装** —— 网关自己发到聊天的通知(忙时回执、进度心跳、自主进化复盘、
+   后台任务收工、cron 失败…)。**这些不走任何插件钩子**(源码里直接
+   ``adapter.send(...)``),所以只能把适配器的文本发送方法包起来。这是本插件
+   最关键的一层,少了它,聊天里看到的还是英文。
+
+词库在 ``phrases.json``,每类可放任意多条中文候选,命中随机挑一条;改完不用重启
+(按 mtime 热加载)。
+
+开关(环境变量,新旧名字都认):
+  PLUGIN_TIPS_CN_DISABLE=1 / CN_VOICE_DISABLE=1   整个插件静默(默认开)
+  PLUGIN_TIPS_CN_LOG=1     / CN_VOICE_LOG=1       每次替换打日志
+  PLUGIN_TIPS_CN_NO_SEND=1                        只关「适配器出口」那一层
+"""
+
+from __future__ import annotations
+
+import inspect
+import json
+import logging
+import os
+import random
+import re
+from pathlib import Path
+from typing import Any, Dict, List, Optional, Tuple
+
+logger = logging.getLogger(__name__)
+
+_HERE = Path(__file__).resolve().parent
+_PHRASES = _HERE / "phrases.json"
+
+# 太大的文本(多 MB 的工具输出)跳过:收益低、还拖慢循环。
+_MAX_LEN = 200_000
+_TRUTHY = {"1", "true", "yes", "on"}
+
+# 适配器上「发文本」的方法名。send/edit_message 覆盖绝大多数;
+# send_stream_frame 是原生流式(企微走这条);send_exec_approval/send_clarify/
+# send_slash_confirm 是审批与提问;send_image 等带 caption;emit_warning 是诊断推送。
+_TEXT_METHODS = (
+    "send", "edit_message", "send_draft", "send_stream_frame", "send_private_notice",
+    "send_slash_confirm", "send_clarify", "send_exec_approval", "retire_clarify_card",
+    "send_image", "send_image_file", "send_animation", "send_voice", "send_video",
+    "send_document", "send_multiple_images", "emit_warning", "emit_media_warning",
+    "send_final_ledgered", "_send_final_text", "_send_plain_fallback", "_send_with_retry",
+)
+# 参数名命中这些就替换(一个方法可能有多个,比如 send_slash_confirm 的 title+message)
+_TEXT_PARAMS = (
+    "content", "text", "message", "caption", "title", "notice", "question",
+    "text_content", "prompt",
+)
+# 这些方法「返回值」才是要发出去的文本(文本在函数内部拼,包参数没用)
+_FORMAT_METHODS = ("_format_exec_approval", "_format_clarify_text")
+
+
+
+def _flag(*names: str) -> bool:
+    for name in names:
+        if os.environ.get(name, "").strip().lower() in _TRUTHY:
+            return True
+    return False
+
+
+def _disabled() -> bool:
+    return _flag("PLUGIN_TIPS_CN_DISABLE", "CN_VOICE_DISABLE")
+
+
+# ────────────────────────── 词库 ──────────────────────────
+
+
+def _load_categories() -> List[Dict[str, Any]]:
+    """读词库并预编译正则;坏规则只跳过自己,不拖垮插件。"""
+    try:
+        with open(_PHRASES, encoding="utf-8") as fh:
+            data = json.load(fh)
+    except Exception as exc:  # pragma: no cover - 配置问题
+        logger.warning("plugin-tips-cn: 词库读取失败 %s: %s", _PHRASES, exc)
+        return []
+
+    cats: List[Dict[str, Any]] = []
+    for raw in data.get("categories", []) or []:
+        pats = []
+        for pat in raw.get("patterns", []) or []:
+            try:
+                pats.append(re.compile(pat, re.IGNORECASE))
+            except re.error as exc:
+                logger.warning("plugin-tips-cn: 跳过非法正则 %r (%s): %s", pat, raw.get("id"), exc)
+        variants = [v for v in (raw.get("variants") or []) if isinstance(v, str) and v.strip()]
+        if pats and variants:
+            cats.append({"id": raw.get("id", "?"), "pats": pats, "variants": variants})
+    return cats
+
+
+_CATEGORIES: List[Dict[str, Any]] = _load_categories()
+_CATS_MTIME: float = 0.0
+try:
+    _CATS_MTIME = _PHRASES.stat().st_mtime
+except OSError:
+    pass
+
+
+def _categories() -> List[Dict[str, Any]]:
+    """词库改了就热加载(一次 stat,开销可忽略),省得每改一句话都重启网关。"""
+    global _CATEGORIES, _CATS_MTIME
+    try:
+        mtime = _PHRASES.stat().st_mtime
+    except OSError:
+        return _CATEGORIES
+    if mtime != _CATS_MTIME:
+        _CATEGORIES = _load_categories()
+        _CATS_MTIME = mtime
+        logger.info("plugin-tips-cn: 词库已热加载,共 %d 类", len(_CATEGORIES))
+    return _CATEGORIES
+
+
+def localize(text: str) -> Tuple[str, List[str]]:
+    """返回 (替换后的文本, 命中的类别 id 列表)。没命中就原样返回。"""
+    if not isinstance(text, str) or not text or _disabled():
+        return text, []
+    if len(text) > _MAX_LEN:
+        return text, []
+
+    hits: List[str] = []
+    out = text
+
+    for cat in _categories():
+        variants = cat["variants"]
+
+        def _replace(match: "re.Match[str]", _variants: List[str] = variants, _cid: str = cat["id"]) -> str:
+            phrase = random.choice(_variants)
+            groups = {k: v for k, v in (match.groupdict() or {}).items() if v is not None}
+            if groups:
+                try:
+                    phrase = phrase.format(**groups)
+                except (KeyError, IndexError, ValueError):
+                    pass
+            hits.append(_cid)
+            return phrase
+
+        for pat in cat["pats"]:
+            out = pat.sub(_replace, out)
+
+    if hits and _flag("PLUGIN_TIPS_CN_LOG", "CN_VOICE_LOG"):
+        logger.info("plugin-tips-cn: 命中 %s", ", ".join(sorted(set(hits))))
+    return out, hits
+
+
+def _loc(value: Any) -> Any:
+    """只处理字符串;命中才换。"""
+    if isinstance(value, str):
+        new, hits = localize(value)
+        return new if hits else value
+    return value
+
+
+# ─────────────────── 第三层:适配器出口包装 ───────────────────
+
+
+def _wrap_method(cls: type, name: str) -> int:
+    """包住 cls 自己的 name 方法,把它的**所有**文本参数过一遍词库。幂等。"""
+    fn = cls.__dict__.get(name)
+    if fn is None or getattr(fn, "_tips_cn_wrapped", False):
+        return 0
+    if not inspect.iscoroutinefunction(fn):
+        return 0
+    try:
+        params = list(inspect.signature(fn).parameters)[1:]  # 去掉 self
+    except (TypeError, ValueError):
+        return 0
+    # 一个方法可能不止一个文本参数(send_slash_confirm 的 title + message)
+    names = {i: p for i, p in enumerate(params) if p in _TEXT_PARAMS}
+    if not names:
+        return 0
+
+    async def wrapper(self, *args, **kwargs):
+        try:
+            a = list(args)
+            for i, pname in names.items():
+                if len(a) > i:
+                    a[i] = _loc(a[i])
+                elif pname in kwargs:
+                    kwargs[pname] = _loc(kwargs[pname])
+            args = tuple(a)
+        except Exception:
+            pass  # 替换失败绝不拦发送
+        return await fn(self, *args, **kwargs)
+
+    wrapper._tips_cn_wrapped = True
+    try:
+        wrapper.__name__ = getattr(fn, "__name__", name)
+        wrapper.__doc__ = getattr(fn, "__doc__", None)
+    except Exception:
+        pass
+    setattr(cls, name, wrapper)
+    return 1
+
+
+def _wrap_formatter(cls: type, name: str) -> int:
+    """包住「返回值就是文本」的方法:审批提示这类文案是在函数内部拼的,包参数没用。"""
+    fn = cls.__dict__.get(name)
+    if fn is None or getattr(fn, "_tips_cn_wrapped", False):
+        return 0
+    if inspect.iscoroutinefunction(fn):
+        return 0
+
+    def wrapper(*args, **kwargs):
+        out = fn(*args, **kwargs)
+        try:
+            return _loc(out)
+        except Exception:
+            return out
+
+    wrapper._tips_cn_wrapped = True
+    try:
+        wrapper.__name__ = getattr(fn, "__name__", name)
+        wrapper.__doc__ = getattr(fn, "__doc__", None)
+    except Exception:
+        pass
+    setattr(cls, name, wrapper)
+    return 1
+
+
+def patch_adapters() -> int:
+    """把出口焊死:所有平台适配器(含子类)的文本发送方法都过一遍词库。幂等,可反复调。"""
+    if _disabled() or _flag("PLUGIN_TIPS_CN_NO_SEND"):
+        return 0
+    try:
+        from gateway.platforms.base import BasePlatformAdapter
+    except Exception as exc:
+        logger.debug("plugin-tips-cn: 拿不到 BasePlatformAdapter(%s)", exc)
+        return 0
+    total = 0
+    seen, stack = set(), [BasePlatformAdapter]
+    while stack:
+        cls = stack.pop()
+        if cls in seen:
+            continue
+        seen.add(cls)
+        for name in _TEXT_METHODS:
+            try:
+                total += _wrap_method(cls, name)
+            except Exception as exc:
+                logger.debug("plugin-tips-cn: 包 %s.%s 失败:%s", cls.__name__, name, exc)
+        for name in _FORMAT_METHODS:
+            try:
+                total += _wrap_formatter(cls, name)
+            except Exception as exc:
+                logger.debug("plugin-tips-cn: 包 %s.%s 失败:%s", cls.__name__, name, exc)
+        try:
+            stack.extend(cls.__subclasses__())
+        except Exception:
+            pass
+    if total:
+        logger.info("plugin-tips-cn: 已包住 %d 个适配器出站方法(覆盖 %d 个类)", total, len(seen))
+    return total
+
+
+# ────────────────────────── 钩子 ──────────────────────────
+
+
+def _on_transform_tool_result(tool_name: str = "", result: Any = None, **_: Any) -> Optional[str]:
+    if not isinstance(result, str):
+        return None
+    new, hits = localize(result)
+    return new if hits else None
+
+
+def _on_transform_llm_output(response_text: Any = None, **_: Any) -> Optional[str]:
+    if not isinstance(response_text, str):
+        return None
+    new, hits = localize(response_text)
+    return new if hits else None
+
+
+def _on_pre_gateway_dispatch(**_: Any):
+    """每条入站消息都顺手补一次适配器包装(适配器可能是插件加载之后才导入的)。"""
+    try:
+        patch_adapters()
+    except Exception:
+        pass
+    return None
+
+
+def register(ctx) -> None:
+    ctx.register_hook("transform_tool_result", _on_transform_tool_result)
+    ctx.register_hook("transform_llm_output", _on_transform_llm_output)
+    ctx.register_hook("pre_gateway_dispatch", _on_pre_gateway_dispatch)
+    try:
+        patch_adapters()
+    except Exception as exc:
+        logger.warning("plugin-tips-cn: 适配器出口包装失败:%s", exc)

+ 56 - 0
bin/install.js

@@ -0,0 +1,56 @@
+#!/usr/bin/env node
+/**
+ * plugin-tips-cn 安装器(npm 分发用)。
+ *
+ *   npx plugin-tips-cn              # 装到 $HERMES_HOME/plugins/plugin-tips-cn(默认 ~/.hermes)
+ *   npx plugin-tips-cn --check      # 只看目标位置,不写文件
+ *   HERMES_HOME=/opt/data npx plugin-tips-cn
+ *
+ * 装完还需要在 Hermes 里启用并重启一次网关(插件目录不会被长驻进程自动重扫):
+ *   hermes plugins enable plugin-tips-cn
+ */
+const fs = require("fs");
+const os = require("os");
+const path = require("path");
+
+const PKG_ROOT = path.resolve(__dirname, "..");
+const FILES = ["plugin.yaml", "__init__.py", "phrases.json", "validate_phrases.py", "README.md", "LICENSE"];
+const DIR_NAME = "plugin-tips-cn";
+
+function hermesHome() {
+  return process.env.HERMES_HOME || path.join(os.homedir(), ".hermes");
+}
+
+function main() {
+  const args = process.argv.slice(2);
+  const checkOnly = args.includes("--check") || args.includes("-c");
+  const home = hermesHome();
+  const target = path.join(home, "plugins", DIR_NAME);
+
+  console.log(`Hermes 家目录: ${home}`);
+  console.log(`插件目标位置: ${target}`);
+  if (checkOnly) return;
+
+  const missing = FILES.filter((f) => !fs.existsSync(path.join(PKG_ROOT, f)));
+  if (missing.length) {
+    console.error(`✗ 包里缺文件: ${missing.join(", ")}`);
+    process.exit(1);
+  }
+
+  fs.mkdirSync(target, { recursive: true });
+  let n = 0;
+  for (const f of FILES) {
+    fs.copyFileSync(path.join(PKG_ROOT, f), path.join(target, f));
+    n += 1;
+  }
+  console.log(`✓ 已复制 ${n} 个文件到 ${target}`);
+  console.log("");
+  console.log("还差两步(Hermes 不会自动重扫插件目录):");
+  console.log("  1) hermes plugins enable plugin-tips-cn");
+  console.log("  2) 重启网关一次(容器里 s6 托管的网关,杀掉会自动拉起)");
+  console.log("");
+  console.log("改词库不用重启:phrases.json 按 mtime 热加载。");
+  console.log("自检词库:python3 " + path.join(target, "validate_phrases.py"));
+}
+
+main();

+ 40 - 0
package.json

@@ -0,0 +1,40 @@
+{
+  "name": "plugin-tips-cn",
+  "version": "0.4.1",
+  "description": "让 Hermes 说人话:把英文系统提示就地换成随机中文(忙时回执、进度心跳、自主进化复盘、后台任务、超时、权限、接口报错…)。三层网覆盖,词库 106 类 / 672 条。",
+  "keywords": [
+    "hermes",
+    "hermes-agent",
+    "plugin",
+    "chinese",
+    "zh-cn",
+    "i18n",
+    "localization",
+    "tips"
+  ],
+  "license": "MIT",
+  "author": "fmode",
+  "homepage": "https://git.fmode.cn/fmode/plugin-tips-cn",
+  "repository": {
+    "type": "git",
+    "url": "git+https://git.fmode.cn/fmode/plugin-tips-cn.git"
+  },
+  "files": [
+    "plugin.yaml",
+    "__init__.py",
+    "phrases.json",
+    "validate_phrases.py",
+    "README.md",
+    "LICENSE",
+    "bin/"
+  ],
+  "bin": {
+    "plugin-tips-cn": "bin/install.js"
+  },
+  "engines": {
+    "node": ">=14"
+  },
+  "scripts": {
+    "test": "node -e \"require('./bin/install.js')\" --check"
+  }
+}

+ 1674 - 0
phrases.json

@@ -0,0 +1,1674 @@
+{
+  "version": 4,
+  "_note": "cn-voice 词库。category = 一类会重复出现的英文提示;patterns 是匹配正则(忽略大小写,可用命名组如 (?P<n>\\d+)),variants 是中文候选(可用 {n} 引用命名组),命中时随机挑一条。",
+  "_rule": "重要:pattern 只匹配「该短语本身」,不要用 [^\\n]{0,N} 这类尾巴去吃后面的文字——那会把句子其余部分切碎,留下 'crape' 这种残渣。要整行替换就明确写 [^\\n]*。",
+  "categories": [
+    {
+      "id": "skill_attach_removed",
+      "label": "技能附件删除(按文件名)",
+      "patterns": [
+        "File '(?P<f>[^']+)' removed from skill '(?P<n>[^']+)'\\."
+      ],
+      "variants": [
+        "📄 已从技能「{n}」里删掉 {f}。",
+        "📄 技能「{n}」的 {f} 已移除。",
+        "📄 「{n}」里的 {f} 清掉了。",
+        "📄 {f} 从技能「{n}」里撤走了。"
+      ]
+    },
+    {
+      "id": "shutdown_notice",
+      "label": "停机通知",
+      "patterns": [
+        "⚠️\\s+Hermes\\s+is\\s+shutting\\s+down\\s+—\\s+your\\s+current\\s+task\\s+will\\s+be\\s+interrupted\\.\\s+When\\s+it\\s+is\\s+back\\s+online,\\s+send\\s+any\\s+message\\s+and\\s+I'll\\s+try\\s+to\\s+pick\\s+up\\s+where\\s+we\\s+left\\s+off\\."
+      ],
+      "variants": [
+        "⚠️ 我要下线一会儿——手上这个任务会中断。等我回来,你随便说句话,我就接着往下做。",
+        "⚠️ 先停一下机——当前任务会断在这儿。我回来后你吱一声,我尽量从断点续上。",
+        "⚠️ 要关机维护了——正在跑的任务会被打断。等我上线,你发一句我就接着干。",
+        "⚠️ 我要暂时下线——手上的活会停住。回来后你招呼一声,我从这儿继续。"
+      ]
+    },
+    {
+      "id": "restart_notice",
+      "label": "重启通知",
+      "patterns": [
+        "⚠️\\s+Hermes\\s+is\\s+restarting\\s+—\\s+your\\s+current\\s+task\\s+will\\s+be\\s+interrupted\\.\\s+Send\\s+any\\s+message\\s+after\\s+the\\s+restart\\s+and\\s+I'll\\s+try\\s+to\\s+resume\\s+where\\s+you\\s+left\\s+off\\."
+      ],
+      "variants": [
+        "⚠️ 我要重启一下——当前任务会中断。重启完你发句话,我尽量接着刚才的做。",
+        "⚠️ 重启中——手上这活会断。等我回来你招呼一声,我从断点续上。",
+        "⚠️ 先重启,任务会暂停。回来后发一条消息,我接着往下走。",
+        "⚠️ 重启一下,当前任务会打断。完事后你吱一声,我续上进度。"
+      ]
+    },
+    {
+      "id": "recovered_reply_restart",
+      "label": "重复回复标记·重启",
+      "patterns": [
+        "♻️\\s+Recovered\\s+reply\\s+—\\s+the\\s+gateway\\s+restarted\\s+during\\s+delivery,\\s+so\\s+this\\s+may\\s+be\\s+a\\s+duplicate:"
+      ],
+      "variants": [
+        "♻️ 补发的回复——刚才发送时网关重启了,这条可能和上面重复:",
+        "♻️ 重发一遍——发送中途网关重启过,内容可能重复,见谅:",
+        "♻️ 这条是补发的——重启把发送打断了,你可能已经看过:",
+        "♻️ 补一条——网关重启导致重发,如果重复请忽略:",
+        "♻️ 重发——刚才重启把消息截断了,这条可能重复:"
+      ]
+    },
+    {
+      "id": "recovered_reply_reconnect",
+      "label": "重复回复标记·重连",
+      "patterns": [
+        "♻️\\s+Recovered\\s+reply\\s+—\\s+the\\s+messaging\\s+platform\\s+reconnected\\s+after\\s+the\\s+original\\s+delivery\\s+failed,\\s+so\\s+this\\s+may\\s+be\\s+a\\s+duplicate:"
+      ],
+      "variants": [
+        "♻️ 补发的回复——上次发送失败后平台重连了,这条可能重复:",
+        "♻️ 重发一遍——连接断过又回来了,你可能已经收到过:",
+        "♻️ 补一条——重连之后重发的,重复的话忽略即可:",
+        "♻️ 重发——连接抖了一下,这条可能和上面重了:"
+      ]
+    },
+    {
+      "id": "recovered_reply_flood",
+      "label": "重复回复标记·限流",
+      "patterns": [
+        "♻️\\s+Recovered\\s+reply\\s+—\\s+the\\s+messaging\\s+platform's\\s+rate\\s+limit\\s+refused\\s+the\\s+original,\\s+so\\s+part\\s+of\\s+it\\s+may\\s+already\\s+have\\s+arrived\\s+above:"
+      ],
+      "variants": [
+        "♻️ 补发的回复——刚才被平台限流挡下了,可能有一部分已经发出去了:",
+        "♻️ 重发一遍——被限流拦了一次,上面可能已经有半条:",
+        "♻️ 补一条——限流把消息截了,上面可能已收到一部分:",
+        "♻️ 重发——撞上平台限流,这条可能重复,见谅:"
+      ]
+    },
+    {
+      "id": "cronjob_response_header",
+      "label": "定时任务结果头",
+      "patterns": [
+        "Cronjob Response: (?P<t>.+)"
+      ],
+      "variants": [
+        "📬 定时任务「{t}」的结果:",
+        "📬 定时「{t}」跑完了,汇报如下:",
+        "📬 任务「{t}」的产出:",
+        "📬 「{t}」按时送达:",
+        "📬 定时任务「{t}」来交作业了:"
+      ]
+    },
+    {
+      "id": "cronjob_jobid",
+      "label": "定时任务号",
+      "patterns": [
+        "\\(job_id: (?P<i>[^)]+)\\)"
+      ],
+      "variants": [
+        "(任务号:{i})",
+        "(编号 {i})",
+        "(任务 ID:{i})",
+        "(内部号 {i})"
+      ]
+    },
+    {
+      "id": "cronjob_footer",
+      "label": "定时任务管理提示",
+      "patterns": [
+        "To stop or manage this job, send me a new message \\(e\\.g\\. \"stop reminder (?P<t>[^\"]+)\"\\)\\."
+      ],
+      "variants": [
+        "想停掉或调整这个任务,回我一句就行(比如「停止提醒 {t}」)。",
+        "要停要改都可以,直接跟我说(例如「停止提醒 {t}」)。",
+        "这个任务想关想改,回我一句话就行(如「停止提醒 {t}」)。",
+        "需要停掉或改动,直接说(比如「停止提醒 {t}」)。"
+      ]
+    },
+    {
+      "id": "explain_empty_response",
+      "label": "没回话·重试也没出",
+      "patterns": [
+        "(?P<model>\\S+)\\s+didn't\\s+produce\\s+a\\s+reply\\s+this\\s+time,\\s+even\\s+after\\s+retries\\.\\s+Send\\s+`continue`\\s+to\\s+try\\s+again,\\s+or\\s+switch\\s+models\\s+with\\s+/model\\."
+      ],
+      "variants": [
+        "{model} 这次没吐出回复,重试也没成。发 `continue` 再来一次,或者用 /model 换个模型。",
+        "{model} 这回哑火了,重试也没反应。发 `continue` 让它再试,或用 /model 换一个。",
+        "{model} 这轮一个字都没说——不是你的问题。发 `continue` 我催它重来,或者 /model 换人。",
+        "{model} 空手而归,重试也没捞着内容。发 `continue` 继续,或换个模型试试。",
+        "{model} 这次卡壳了。发 `continue` 让它重来,或者 /model 换个更稳的。",
+        "{model} 没给任何内容,重试也一样。发 `continue` 再试,或 /model 换模型。",
+        "{model} 这轮罢工了。别急——发 `continue` 我叫它重来;想换人就 /model。",
+        "{model} 没产出东西,重试也没用。发 `continue`,或者 /model 换个模型。"
+      ]
+    },
+    {
+      "id": "explain_all_retries_exhausted",
+      "label": "没回话·上游一直没答",
+      "patterns": [
+        "the\\s+model\\s+provider\\s+didn't\\s+answer\\s+after\\s+all\\s+retries\\.\\s+Send\\s+/retry,\\s+or\\s+switch\\s+models\\s+with\\s+/model\\."
+      ],
+      "variants": [
+        "上游模型重试到最后一轮都没应答。发 /retry 再试一次,或用 /model 换个模型。",
+        "模型服务那边一直没回应,重试都用完了。发 /retry,或换个模型试试。",
+        "上游一直沉默,重试次数也耗光了。发 /retry 我再敲一次门,或 /model 换条路。",
+        "对面模型始终没回话。发 /retry 重试,或者 /model 换个更稳的。",
+        "模型端到最后一刻都没应答。发 /retry 再试,或换模型。",
+        "重试全用完了,上游还是没声。发 /retry,或 /model 换人。",
+        "模型服务那边一直不吭声。发 /retry 我再试,或者 /model 换一个。",
+        "上游把重试都耗完了还没答。发 /retry 继续,或 /model 换个模型。"
+      ]
+    },
+    {
+      "id": "explain_partial_stream",
+      "label": "没回话·只收回半截",
+      "patterns": [
+        "streaming\\s+stopped\\s+early\\s+and\\s+only\\s+a\\s+partial\\s+response\\s+was\\s+recovered\\.\\s+Send\\s+`continue`\\s+to\\s+resume\\s+from\\s+where\\s+it\\s+stopped\\."
+      ],
+      "variants": [
+        "流式输出中途断了,只收回半截。发 `continue` 让它从断点接着说完。",
+        "输出流断在半路,只抢救回一部分。发 `continue` 接着往下说。",
+        "话说到一半流就断了,后半截没接住。发 `continue`,我让它接着说。",
+        "只收到半句话,剩下的丢了。发 `continue` 补上后半段。",
+        "流提前结束,内容不全。发 `continue` 让它续上。",
+        "半路断流,只拿到一部分。发 `continue` 继续说完。",
+        "输出被截断了,前面这些先看着。发 `continue` 我把剩下的要回来。",
+        "流断在半截——发 `continue`,让它从断的地方接。"
+      ]
+    },
+    {
+      "id": "explain_fallback_prior",
+      "label": "没回话·这轮没新内容",
+      "patterns": [
+        "no\\s+new\\s+content\\s+was\\s+produced\\s+this\\s+turn;\\s+showing\\s+recovered\\s+prior\\s+context\\.\\s+Send\\s+`continue`\\s+to\\s+retry\\."
+      ],
+      "variants": [
+        "这一轮没产出新内容,先把你之前的内容调出来了。发 `continue` 重试。",
+        "这轮空转了,我先把上一段上下文还给你。发 `continue` 再来一次。",
+        "没有新东西可给,先把之前的接上。发 `continue` 我重新跑一遍。",
+        "这轮没生成内容,只能先把旧的捞回来。发 `continue` 重试。",
+        "白跑了一轮,先拿回上次的内容。发 `continue` 再试一次。",
+        "新内容没出来,先垫上之前的。发 `continue` 让它重来。",
+        "这轮没结果,我把前文先递给你。发 `continue` 继续。",
+        "空转了一轮——发 `continue`,我重新要一次答案。"
+      ]
+    },
+    {
+      "id": "explain_redirect_limit",
+      "label": "没回话·连续改方向",
+      "patterns": [
+        "the\\s+request\\s+was\\s+cancelled\\s+by\\s+a\\s+new\\s+correction\\s+on\\s+every\\s+attempt,\\s+so\\s+the\\s+turn\\s+stopped\\s+instead\\s+of\\s+retrying\\s+forever\\.\\s+Your\\s+last\\s+correction\\s+is\\s+queued\\s+as\\s+the\\s+next\\s+message\\."
+      ],
+      "variants": [
+        "每次刚要答,你的新纠正就把请求打断了,所以我停在这儿,不再无限重试。你最后那条纠正已经排进队列,马上按它来。",
+        "你连着改了几次方向,每次都把这一轮打断了。我不硬撑了——你最后那条纠正已在队列里,下一步就照它走。",
+        "方向改了太多次,每次重来都被打断。我先收手,不再空转。你最后那条纠正已排好,马上执行。",
+        "连续几次纠正都在半路截断了请求,我停下等你定稿。最后那条纠正已进队列,接着就办。",
+        "你一直在调方向,每次请求都被打断。我不再循环重试了——最新那条纠正排队中,下一步按它来。",
+        "改方向的次数到上限了,我主动停下,免得空转。你最后那条纠正已经排上,马上就办。",
+        "每次刚起步就被新纠正叫停,我不硬扛了。最后一条纠正在队列里,下一步就照它走。",
+        "方向反复调整,请求反复被打断,我先停手。你最新那条纠正已入队,紧接着执行。"
+      ]
+    },
+    {
+      "id": "explain_rebuilt_limit",
+      "label": "没回话·供应商轮着挂",
+      "patterns": [
+        "every\\s+provider\\s+in\\s+the\\s+fallback\\s+chain\\s+kept\\s+failing\\s+over,\\s+so\\s+the\\s+turn\\s+stopped\\s+instead\\s+of\\s+retrying\\s+forever\\.\\s+Send\\s+`continue`\\s+or\\s+switch\\s+provider\\."
+      ],
+      "variants": [
+        "备选供应商一个接一个地挂,我就不无限重试了。发 `continue` 继续,或者换个供应商。",
+        "整条兜底链都在互相甩锅,我先停下。发 `continue`,或者手动换个供应商。",
+        "挨个供应商都试过了,全挂。发 `continue` 再跑一轮,或换个更靠谱的。",
+        "兜底链整条失灵,我不再空转。发 `continue`,或者换供应商。",
+        "从主到备全试遍,都没接住。发 `continue` 继续,或手动换一家。",
+        "供应商轮着出问题,先停。发 `continue` 重试,或者换人。",
+        "备用方案全军覆没。发 `continue`,或换个供应商再战。",
+        "链路上的供应商都试过了都不行。发 `continue` 再来,或换一家。"
+      ]
+    },
+    {
+      "id": "explain_budget_exhausted",
+      "label": "没回话·额度用尽",
+      "patterns": [
+        "the\\s+per\\-turn\\s+iteration/cost\\s+budget\\s+was\\s+exhausted\\s+before\\s+a\\s+final\\s+answer\\.\\s+Send\\s+`continue`\\s+to\\s+keep\\s+going\\."
+      ],
+      "variants": [
+        "这一轮迭代/费用预算先用完了,还没给出最终答案。发 `continue` 接着跑。",
+        "这轮的花销额度见底了。发 `continue`,我继续往下做。",
+        "预算到顶,话还没说完。发 `continue` 我接着干。",
+        "这轮额度用完——别担心,发 `continue` 就能续上。",
+        "迭代预算先见底了。发 `continue`,我把剩下的事做完。",
+        "这轮的钱花到上限了。发 `continue` 继续跑。",
+        "预算耗尽在半路。发 `continue` 我给你接着做。",
+        "额度用满了,还差最后一步。发 `continue` 补完。"
+      ]
+    },
+    {
+      "id": "explain_ctx_too_small",
+      "label": "没回话·上下文太小",
+      "patterns": [
+        "the\\s+local\\s+model's\\s+context\\s+window\\s+was\\s+too\\s+small\\s+to\\s+finish\\.\\s+Increase\\s+the\\s+context\\s+size\\s+or\\s+use\\s+a\\s+larger\\s+model\\."
+      ],
+      "variants": [
+        "本地模型的上下文窗口太小,装不下。调大 context 或换个更大的模型。",
+        "本地模型脑容量不够,塞不下了。把上下文调大,或换个大模型。",
+        "上下文窗口太小,撑不住这次任务。调大它,或换个更大的模型。",
+        "本地模型装不下这么多内容。加大 context,或换模型。",
+        "上下文额度不够用了。调大窗口,或换个容量更大的模型。",
+        "模型记不住这么多——把上下文调大,或换个大模型。",
+        "本地模型容量到顶。调大 context 再试,或换模型。",
+        "窗口太小塞不进去。调大 context,或换一个更大的。"
+      ]
+    },
+    {
+      "id": "explain_pending_tool",
+      "label": "没回话·工具结果悬着",
+      "patterns": [
+        "the\\s+turn\\s+stopped\\s+while\\s+a\\s+tool\\s+result\\s+was\\s+still\\s+pending\\s+and\\s+the\\s+model\\s+produced\\s+no\\s+follow\\-up\\s+text\\.\\s+Send\\s+`continue`\\s+to\\s+let\\s+it\\s+summarize\\."
+      ],
+      "variants": [
+        "工具结果还没回来这轮就停了,模型也没接着说话。发 `continue`,让它把结果总结一下。",
+        "有个工具结果悬在半空,这轮就断了。发 `continue`,我把它收拢成结论。",
+        "工具跑完了但没人接话,这轮空在这。发 `continue` 我来收尾。",
+        "结果还在路上,话就停了。发 `continue`,让它把结果讲完。",
+        "工具有结果,模型没跟进。发 `continue` 我把它变成结论。",
+        "卡在工具返回这一步。发 `continue`,接着总结。",
+        "结果悬着没人处理。发 `continue`,我来接上。",
+        "工具结果没被消化。发 `continue` 让它说完。"
+      ]
+    },
+    {
+      "id": "explain_interrupted_api",
+      "label": "没回话·调用被打断",
+      "patterns": [
+        "the\\s+request\\s+was\\s+interrupted\\s+mid\\-call\\s+before\\s+a\\s+reply\\s+was\\s+received\\.\\s+Send\\s+`continue`\\s+to\\s+retry\\."
+      ],
+      "variants": [
+        "请求发出去还没等到回复就被打断了。发 `continue` 重试。",
+        "这次调用半路被掐了,回复没收到。发 `continue` 再来一次。",
+        "请求被打断在途中,答案没回来。发 `continue` 我重发一遍。",
+        "调用中途断了——不是你的问题。发 `continue` 重试即可。",
+        "请求还没回就被打断。发 `continue`,我再来一次。",
+        "半路被叫停,回复丢了。发 `continue` 重跑。",
+        "这次请求没走完。发 `continue` 继续。",
+        "调用被中断在中间。发 `continue`,重试一次。"
+      ]
+    },
+    {
+      "id": "explain_max_iterations",
+      "label": "没回话·步数上限",
+      "patterns": [
+        "the\\s+maximum\\s+tool\\-iteration\\s+limit\\s+was\\s+reached\\s+before\\s+a\\s+final\\s+answer\\.\\s+Send\\s+`continue`\\s+to\\s+keep\\s+going,\\s+or\\s+raise\\s+`max_iterations`\\."
+      ],
+      "variants": [
+        "工具调用步数到顶了,还没给出最终答案。发 `continue` 继续,或把 `max_iterations` 调大。",
+        "步数用满了。发 `continue` 接着走,或者提高 `max_iterations` 上限。",
+        "迭代次数到上限,事情还没办完。发 `continue` 我接着做,或调大 `max_iterations`。",
+        "步数打满——活还没干完。发 `continue` 继续,或把上限调高。",
+        "这一轮的步数用尽了。发 `continue`,我接着推进。",
+        "到迭代上限了。发 `continue` 继续,或调大 `max_iterations`。",
+        "步数上限卡住了进度。发 `continue` 我接着来,或放宽上限。",
+        "走满步数还没收尾。发 `continue` 补完,或提高上限。"
+      ]
+    },
+    {
+      "id": "explain_error_near_max",
+      "label": "没回话·临门出错",
+      "patterns": [
+        "an\\s+error\\s+occurred\\s+near\\s+the\\s+iteration\\s+limit\\s+before\\s+a\\s+final\\s+answer\\.\\s+Check\\s+the\\s+tool\\s+output\\s+above,\\s+then\\s+send\\s+`continue`\\."
+      ],
+      "variants": [
+        "快到最后一步时出了错,没给出最终答案。先看上面的工具输出,再发 `continue`。",
+        "临门一脚踢偏了。上面的工具输出先过一眼,然后发 `continue`。",
+        "快到终点时报了错。看看上面的输出,再发 `continue` 我接着走。",
+        "最后关头出错,答案没出来。先看上面的工具输出,再发 `continue`。",
+        "差一步就完成,偏偏出错。上面输出看一眼,然后 `continue`。",
+        "收尾时出了岔子。先核对上面的输出,再发 `continue`。",
+        "快到头了却报错。看看上面的工具输出,再 `continue`。",
+        "最后一段出错。先看上面的输出,然后发 `continue` 续上。"
+      ]
+    },
+    {
+      "id": "explain_repeated_errors",
+      "label": "没回话·反复报错",
+      "patterns": [
+        "the\\s+turn\\s+kept\\s+failing\\s+with\\s+repeated\\s+errors\\s+and\\s+was\\s+stopped\\s+early\\s+instead\\s+of\\s+retrying\\s+forever\\.\\s+Check\\s+the\\s+errors\\s+above,\\s+then\\s+send\\s+`continue`\\s+to\\s+retry\\."
+      ],
+      "variants": [
+        "一直重复报错,我就提前收手了,不无限重试。上面的报错看一眼,再发 `continue`。",
+        "同样的错反复出现,我先停下。看下上面的错误,然后发 `continue` 再试。",
+        "错误重复太多次,我不空转了。上面的报错先看,再 `continue`。",
+        "一直踩同一个坑,我先停。上面错误看一眼,然后 `continue`。",
+        "反复报同样的错,我主动收手。核对上面的错误,再发 `continue`。",
+        "错误停不下来,我按住了。上面的报错看下,然后 `continue` 继续。",
+        "同一个错循环出现,先停手。看上面的信息,再 `continue`。",
+        "错误反复,我不再硬试。上面的报错先过一眼,再 `continue`。"
+      ]
+    },
+    {
+      "id": "exec_approval",
+      "label": "命令审批提示",
+      "patterns": [
+        "Command approval required: (?P<c>.+)"
+      ],
+      "variants": [
+        "⚠️ 这条命令得你点头才跑:{c}",
+        "⚠️ 需要你批准:{c}",
+        "⚠️ 这条要授权:{c}",
+        "⚠️ 拦下了,等你批准:{c}",
+        "⚠️ 执行前先问你一声:{c}"
+      ]
+    },
+    {
+      "id": "bg_task_complete",
+      "label": "后台任务完成",
+      "patterns": [
+        "✅\\s*Background task complete"
+      ],
+      "variants": [
+        "✅ 后台任务跑完了——结果在这。",
+        "✅ 派出去的活回来了。",
+        "✅ 后台那摊子收工。",
+        "✅ 后台任务已完成,来验收。",
+        "✅ 后台活儿干完了,请过目。",
+        "✅ 交出去的活有结果了。"
+      ]
+    },
+    {
+      "id": "bg_task_prompt",
+      "label": "后台任务:原提示",
+      "patterns": [
+        "Prompt: \"(?P<p>[^\"]{0,120})\""
+      ],
+      "variants": [
+        "任务:{p}",
+        "你当时说的:{p}",
+        "原始指令:{p}",
+        "你的要求:{p}"
+      ]
+    },
+    {
+      "id": "cron_failed",
+      "label": "定时任务失败",
+      "patterns": [
+        "⚠️\\s*Cron '(?P<j>[^']+)' failed: "
+      ],
+      "variants": [
+        "⚠️ 定时任务「{j}」这次没跑成:",
+        "⚠️ 「{j}」执行失败:",
+        "⚠️ 定时任务「{j}」出错了:",
+        "⚠️ 「{j}」摔了一跤:",
+        "⚠️ 定时「{j}」没跑通:",
+        "⚠️ 「{j}」报错了:"
+      ]
+    },
+    {
+      "id": "cron_timeout",
+      "label": "定时任务脚本超时",
+      "patterns": [
+        "its script timed out\\. No model was invoked\\."
+      ],
+      "variants": [
+        "它的脚本超时了,没走到模型那一步。",
+        "脚本等超时了,模型都没来得及调。",
+        "脚本跑太久被掐了,模型没启动。",
+        "脚本自己超时退场,模型没轮到。"
+      ]
+    },
+    {
+      "id": "cron_stalled",
+      "label": "定时任务卡死",
+      "patterns": [
+        "the job stalled — it stopped doing anything for too long"
+      ],
+      "variants": [
+        "任务卡住了,太久没动静。",
+        "它僵住了,半天没反应。",
+        "任务停摆,长时间无进展。",
+        "它原地不动太久了,我给停了。"
+      ]
+    },
+    {
+      "id": "sudo_tip",
+      "label": "sudo 提示",
+      "patterns": [
+        "💡\\s*Tip: Subagents cannot prompt for a sudo password\\."
+      ],
+      "variants": [
+        "💡 小提示:子任务没法问你要 sudo 密码。",
+        "💡 注意:子任务里弹不出 sudo 密码框。",
+        "💡 子任务不能问密码,这一步得你自己来。",
+        "💡 提醒:sudo 密码只能在主会话里给。",
+        "💡 子任务拿不到密码,需要 sudo 得在主对话做。"
+      ]
+    },
+    {
+      "id": "no_reply",
+      "label": "没有回复",
+      "patterns": [
+        "⚠️\\s*No reply\\b"
+      ],
+      "variants": [
+        "⚠️ 没有回复",
+        "⚠️ 它没回话",
+        "⚠️ 没等到回复",
+        "⚠️ 对面沉默了",
+        "⚠️ 这次没接上",
+        "⚠️ 回复落空了"
+      ]
+    },
+    {
+      "id": "skills_skipped",
+      "label": "技能没找到被跳过",
+      "patterns": [
+        "⚠️\\s*Skill\\(s\\) not found and skipped: "
+      ],
+      "variants": [
+        "⚠️ 这些技能没找到,跳过了:",
+        "⚠️ 有几个技能不存在,已跳过:",
+        "⚠️ 技能缺失,略过:",
+        "⚠️ 没找到这几个技能,先跳过去:"
+      ]
+    },
+    {
+      "id": "gateway_restarted",
+      "label": "网关重启成功",
+      "patterns": [
+        "♻️?\\s*Gateway restarted successfully"
+      ],
+      "variants": [
+        "♻️ 网关重启完成",
+        "♻️ 重启好了,我回来了",
+        "♻️ 网关已成功重启",
+        "♻️ 满血复活,继续干"
+      ]
+    },
+    {
+      "id": "steer_queued_acp",
+      "label": "意见已并入当前任务",
+      "patterns": [
+        "⏩\\s*Steer queued for the active turn: "
+      ],
+      "variants": [
+        "⏩ 已并入当前任务:",
+        "⏩ 你这条我捎上了:",
+        "⏩ 已插进正在跑的任务:",
+        "⏩ 收到,带进当前任务了:"
+      ]
+    },
+    {
+      "id": "skill_file_not_found",
+      "label": "技能里没有该文件",
+      "patterns": [
+        "File '(?P<f>[^']+)' not found in skill '(?P<n>[^']+)'\\."
+      ],
+      "variants": [
+        "技能「{n}」里没有 {f} 这个文件。",
+        "「{n}」里找不到 {f}。",
+        "技能「{n}」下没有 {f}。",
+        "「{n}」中不存在 {f}。"
+      ]
+    },
+    {
+      "id": "unknown_action",
+      "label": "不认识的动作",
+      "patterns": [
+        "Unknown action '(?P<a>[^']+)'\\. Use: "
+      ],
+      "variants": [
+        "不认识这个动作「{a}」,可用的是:",
+        "没有「{a}」这个操作,能用的有:",
+        "「{a}」不在支持列表里,可用的是:",
+        "「{a}」不认识——能用的动作是:"
+      ]
+    },
+    {
+      "id": "memory_content_required",
+      "label": "记忆缺内容",
+      "patterns": [
+        "Content is required for '(?P<a>[^']+)' action\\."
+      ],
+      "variants": [
+        "「{a}」这个动作得给内容。",
+        "做「{a}」得带上内容。",
+        "「{a}」缺了内容,补上再试。",
+        "「{a}」要写点什么,内容不能空。"
+      ]
+    },
+    {
+      "id": "memory_unavailable",
+      "label": "记忆不可用",
+      "patterns": [
+        "Memory is not available\\. It may be disabled in config or this environment\\."
+      ],
+      "variants": [
+        "记忆功能没开,可能被配置禁用了。",
+        "记忆不可用,检查下配置是不是关了。",
+        "记忆没启用——看看配置里是不是关掉了。",
+        "记忆这块没开,配置里确认一下。"
+      ]
+    },
+    {
+      "id": "memory_writes_disabled",
+      "label": "记忆写入被禁用",
+      "patterns": [
+        "Built-in (?P<l>\\w+) writes are disabled in memory config\\."
+      ],
+      "variants": [
+        "配置里把内置的 {l} 写入关了。",
+        "内置 {l} 写入被配置禁用。",
+        "{l} 的写入在配置里被关掉了。",
+        "内置 {l} 不让写了,配置里开着才行。"
+      ]
+    },
+    {
+      "id": "bg_review_no_delete",
+      "label": "复盘不能删记忆",
+      "patterns": [
+        "Background review may not delete memory entries"
+      ],
+      "variants": [
+        "复盘不能自己删记忆条目。",
+        "自动复盘没权限删记忆。",
+        "这条记忆复盘动不了——它不能自己删。",
+        "自动复盘不许删记忆,需要你来定。"
+      ]
+    },
+    {
+      "id": "skill_patched",
+      "label": "技能打补丁(复盘产生)",
+      "patterns": [
+        "Skill '(?P<n>[^']+)' patched"
+      ],
+      "variants": [
+        "🔧 技能「{n}」打了个补丁——更稳了。",
+        "🔧 「{n}」又精进了一点。",
+        "🔧 技能「{n}」已微调。",
+        "🔧 「{n}」磨得更锋利了。",
+        "🔧 技能「{n}」修正完毕。",
+        "🔧 「{n}」悄悄变强了。",
+        "🔧 技能「{n}」补丁已上。",
+        "🔧 「{n}」参数调优完成。",
+        "🔧 技能「{n}」已迭代。",
+        "🔧 「{n}」强化成功。",
+        "🔧 给「{n}」上了个补丁,下次更准。",
+        "🔧 技能「{n}」小修了一下,效果更好。"
+      ]
+    },
+    {
+      "id": "skill_created",
+      "label": "新技能铸成(复盘产生)",
+      "patterns": [
+        "Skill '(?P<n>[^']+)' created"
+      ],
+      "variants": [
+        "✨ 习得新技能「{n}」——以后这事我更快。",
+        "✨ 技能「{n}」已解锁。",
+        "✨ 新招式到手:「{n}」。",
+        "✨ 技能树 +1:「{n}」。",
+        "✨ 「{n}」这门手艺,我学会了。",
+        "✨ 已掌握「{n}」,随时能上手。",
+        "✨ 技能「{n}」已写入技能栏。",
+        "✨ 解锁成就:{n}。",
+        "✨ 「{n}」到手,下次直接用。",
+        "✨ 新技能「{n}」铸成。",
+        "✨ 又多了一件趁手的工具:「{n}」。",
+        "✨ 「{n}」已刻进骨骼。"
+      ]
+    },
+    {
+      "id": "skill_updated",
+      "label": "技能重铸(复盘产生)",
+      "patterns": [
+        "Skill '(?P<n>[^']+)' updated \\(full rewrite\\)"
+      ],
+      "variants": [
+        "🔄 技能「{n}」已重铸——更顺手了。",
+        "🔄 「{n}」整本重写,脱胎换骨。",
+        "🔄 技能「{n}」翻新完成。",
+        "🔄 「{n}」重练了一遍,比之前利落。",
+        "🔄 技能「{n}」升级到新版本。",
+        "🔄 「{n}」改头换面,回头试试。",
+        "🔄 技能「{n}」重修完毕。",
+        "🔄 「{n}」推倒重来,现在顺多了。",
+        "🔄 技能「{n}」重铸成型。"
+      ]
+    },
+    {
+      "id": "skill_deleted",
+      "label": "技能封存(复盘产生)",
+      "patterns": [
+        "Skill '(?P<n>[^']+)' deleted"
+      ],
+      "variants": [
+        "🗑 技能「{n}」已封存——不用了就收起来。",
+        "🗑 「{n}」已从技能栏移除。",
+        "🗑 技能「{n}」退役了。",
+        "🗑 「{n}」已删除。",
+        "🗑 技能「{n}」已封印。",
+        "🗑 「{n}」不要了,已清掉。"
+      ]
+    },
+    {
+      "id": "skill_file_written",
+      "label": "技能附件写入(复盘产生)",
+      "patterns": [
+        "Skill '(?P<n>[^']+)' written"
+      ],
+      "variants": [
+        "📄 技能「{n}」新增了一份资料。",
+        "📄 「{n}」的参考文件已存好。",
+        "📄 技能「{n}」的附件已写入。",
+        "📄 「{n}」多了一份配套文件。"
+      ]
+    },
+    {
+      "id": "skill_file_removed",
+      "label": "技能附件移除(复盘产生)",
+      "patterns": [
+        "Skill '(?P<n>[^']+)' removed"
+      ],
+      "variants": [
+        "📄 技能「{n}」的附件已移除。",
+        "📄 「{n}」删掉了一份资料。",
+        "📄 技能「{n}」的附件不要了,已清掉。"
+      ]
+    },
+    {
+      "id": "memory_updated",
+      "label": "记忆已写入(复盘产生)",
+      "patterns": [
+        "\\bMemory updated\\b"
+      ],
+      "variants": [
+        "🧠 这段我刻进记忆里了。",
+        "🧠 已存档——以后不用你再讲一遍。",
+        "🧠 记住了,下次直接调用。",
+        "🧠 已写入长期记忆,放心。",
+        "🧠 这点很重要,我收好了。",
+        "🧠 记忆已更新,随时可用。",
+        "🧠 已记住,不会忘。",
+        "🧠 收进记忆库了。",
+        "🧠 已刻录,下次见。",
+        "🧠 这段经历我留下了。",
+        "🧠 记住了——你不用重复第二遍。",
+        "🧠 已归入长期记忆。",
+        "🧠 记忆 +1,攒着用。",
+        "🧠 已存档,下次接着聊。"
+      ]
+    },
+    {
+      "id": "user_profile_updated",
+      "label": "主人档案更新(复盘产生)",
+      "patterns": [
+        "\\bUser profile updated\\b"
+      ],
+      "variants": [
+        "🧠 关于你的了解,又深了一层。",
+        "🧠 你的档案已更新。",
+        "🧠 我记住了你的偏好。",
+        "🧠 人物卡刷新完成。",
+        "🧠 你的画像更清晰了。",
+        "🧠 已更新对你的了解,下次更合拍。",
+        "🧠 关于你的事,我记下了。",
+        "🧠 你的设定已同步。",
+        "🧠 更懂你一点了。",
+        "🧠 主人档案 +1。"
+      ]
+    },
+    {
+      "id": "self_improvement_review",
+      "label": "自主进化结算",
+      "patterns": [
+        "💾\\s*Self-improvement review:\\s*"
+      ],
+      "variants": [
+        "💾 进化结算:",
+        "💾 这一轮的修行成果:",
+        "💾 又攒下一点本事:",
+        "💾 进化日志已更新:",
+        "💾 刚偷偷练了一波:",
+        "💾 复盘结束,收获如下:",
+        "💾 自我进化报告:",
+        "💾 又变强了一点点:",
+        "💾 修为提升:",
+        "💾 闭关结束,出关汇报:",
+        "💾 经验值已到账:",
+        "💾 我的成长记录:",
+        "💾 本轮复盘归档:",
+        "💾 偷偷升级了一格:",
+        "💾 进化进度刷新:",
+        "💾 刚才那点心得,记下来了:",
+        "💾 自我升级小结:",
+        "💾 攒经验中,已入账:"
+      ]
+    },
+    {
+      "id": "bg_process_done",
+      "label": "后台任务收工",
+      "patterns": [
+        "\\[IMPORTANT: Background process (?P<sid>\\S+) finished with exit code (?P<n>-?\\d+)\\.?\\]"
+      ],
+      "variants": [
+        "【后台任务 {sid} 收工,退出码 {n}】",
+        "【{sid} 跑完了,退出码 {n}】",
+        "【后台那摊子完事:{sid},退出码 {n}】",
+        "【派出去的活回来了:{sid},退出码 {n}】",
+        "【{sid} 已结束,退出码 {n}】"
+      ]
+    },
+    {
+      "id": "bg_process_running",
+      "label": "后台任务仍在跑",
+      "patterns": [
+        "\\[IMPORTANT: Background process (?P<sid>\\S+) is still running~"
+      ],
+      "variants": [
+        "【后台任务 {sid} 还在跑】",
+        "【{sid} 还没收工,继续等】",
+        "【后台那摊子还在动:{sid}】",
+        "【{sid} 仍在进行中】"
+      ]
+    },
+    {
+      "id": "hermes_update",
+      "label": "Hermes 自更新",
+      "patterns": [
+        "✅ Hermes update finished",
+        "❌ Hermes update failed",
+        "Hermes update timed out"
+      ],
+      "variants": [
+        "✅ 我自己升级完了,回来了。",
+        "❌ 这次升级没成,我看下日志。",
+        "⏰ 升级超时了,得手动看看。",
+        "✅ 更新完成,重新上线。",
+        "❌ 自更新失败,稍后重试。"
+      ]
+    },
+    {
+      "id": "busy_detail_elapsed",
+      "label": "忙时已耗时",
+      "patterns": [
+        "(?P<n>\\d+) min elapsed"
+      ],
+      "variants": [
+        "已过 {n} 分钟",
+        "跑了 {n} 分钟",
+        "已经 {n} 分钟",
+        "用时 {n} 分钟",
+        "{n} 分钟了",
+        "花了 {n} 分钟"
+      ]
+    },
+    {
+      "id": "busy_detail_running",
+      "label": "忙时当前动作",
+      "patterns": [
+        "running: (?P<t>[A-Za-z_][\\w]*)"
+      ],
+      "variants": [
+        "正在做:{t}",
+        "当前动作:{t}",
+        "手上是:{t}",
+        "在跑:{t}",
+        "此刻在干:{t}"
+      ]
+    },
+    {
+      "id": "heartbeat_working",
+      "label": "长任务心跳",
+      "patterns": [
+        "⏳ Working — (?P<n>\\d+) min",
+        "Working — (?P<n>\\d+) min"
+      ],
+      "variants": [
+        "还在跑,{n} 分钟了——稳住,就快出结果了。",
+        "没停,{n} 分钟了,别走开。",
+        "手上活还剩一点,{n} 分钟了。",
+        "还在推进,{n} 分钟了,再给我一点时间。",
+        "{n} 分钟了,还活着,还在跑。",
+        "还在吭哧吭哧干,{n} 分钟了。",
+        "进展中,{n} 分钟了,快了。",
+        "没偷懒,{n} 分钟了,马上就好。",
+        "还在处理,{n} 分钟了,稍安勿躁。",
+        "跑了 {n} 分钟,还没到终点,但一直在动。",
+        "还在算,{n} 分钟了,别急。",
+        "手上这事比较磨,{n} 分钟了。",
+        "{n} 分钟了,进展稳定,继续等。",
+        "还在跑,{n} 分钟了——值得等。"
+      ]
+    },
+    {
+      "id": "busy_steer_sub",
+      "label": "意见并入任务及其子任务",
+      "patterns": [
+        "⏩ Steered into current run and its active subagent\\(s\\)(?P<d>.*?)\\.\\s*Your message arrives after their next tool call\\.",
+        "⏩?\\s*Steered into current run and its active subagent\\(s\\)(?P<d>)"
+      ],
+      "variants": [
+        "收到,正在跑的任务和它带的那几个子任务,都按你说的来。",
+        "好,你的意思我同步给了当前任务和它的分支。",
+        "收到,连同子任务一起调整,下一步就生效。",
+        "明白,任务和它的下属都带上你的意见了。",
+        "好,已经并进去,包括它正在带的那几个子任务。",
+        "收到,主任务和子任务一起改道,马上就按这个走。",
+        "行,带上了——正跑的任务连同它的分支都听你的。",
+        "好,同步完成,任务和子任务下一步都按你说的。"
+      ]
+    },
+    {
+      "id": "busy_steer",
+      "label": "意见并入当前任务",
+      "patterns": [
+        "⏩ Steered into current run(?P<d>.*?)\\.\\s*Your message arrives after the next tool call\\.",
+        "⏩?\\s*Steered into current run(?P<d>)"
+      ],
+      "variants": [
+        "收到,你这条我捎在手上了,下一步就按它走。",
+        "好,已经并进正在跑的任务,下个动作就照办。",
+        "收到,边跑边听你的,下一步马上调整。",
+        "明白,把你的意思带上了,紧接着就生效。",
+        "好,插进去了——下一次动作就是你要的。",
+        "收到,顺手改道,下个动作按你说的来。",
+        "行,你这条已经并进去,马上见效。",
+        "收到,带着你的意见继续跑,下一步就调整。",
+        "好,我把它捎上了,紧接着的一步就按这个办。",
+        "明白,边干边听,下个动作就按你的意思。",
+        "收到,你的话已经带上,下一步立刻生效。",
+        "好,并进当前任务了,接着的动作就照你说的。"
+      ]
+    },
+    {
+      "id": "busy_interrupt",
+      "label": "插话打断当前任务",
+      "patterns": [
+        "⚡ Interrupting current task(?P<d>.*?)\\.\\s*I'll respond to your message shortly\\.",
+        "⚡?\\s*Interrupting current task(?P<d>)"
+      ],
+      "variants": [
+        "收到!先按下暂停键,马上看你这条。",
+        "好,手头这摊先搁一下,你的话优先。",
+        "明白,打断当前进度,先接你的。",
+        "收到,我把手里这步收个尾,马上转过来。",
+        "行,当前任务先靠边站,听你的。",
+        "收到,先停一停——你这条更重要。",
+        "好,临时改道,先办你说的。",
+        "收到,我先把眼前这步按住,马上回你。",
+        "得嘞,手头活儿先放放,先听你的。",
+        "收到,切换优先级,你这条先来。",
+        "好,我把正在进行的事挂起,马上过来。",
+        "明白,先中断——你的事最要紧。"
+      ]
+    },
+    {
+      "id": "busy_queue",
+      "label": "排队等下一轮",
+      "patterns": [
+        "⏳ Queued for the next turn(?P<d>.*?)\\.\\s*I'll respond once the current task finishes\\.",
+        "⏳?\\s*Queued for the next turn(?P<d>)"
+      ],
+      "variants": [
+        "手头这摊还没收工,你这条我收好了,一腾出手就办。",
+        "收到,先给你排上,前面这个跑完立刻轮到你。",
+        "手上还占着,你这条我记下了,稍后就办。",
+        "好,先存着——当前任务一结束,我马上接你这条。",
+        "这会儿还腾不出手,你这条排在队首,很快就到。",
+        "收到,给你挂个号,前一个收尾就来。",
+        "手上活儿没完,你这条我放在最前面,跑完就办。",
+        "好,先记下,等这轮跑完第一件事就是你的。",
+        "收到,排队中——前面一完事,立刻办你这条。",
+        "现在还在忙上一件,你这条我先收进队列。",
+        "好,稍等一下,手上的事收个尾就轮到你。",
+        "收到,你这条我插在队首,前面的跑完马上接。"
+      ]
+    },
+    {
+      "id": "busy_subagent",
+      "label": "子任务还在跑",
+      "patterns": [
+        "⏳ Subagent working(?P<d>.*?)\\s*—\\s*your message is queued for when it finishes \\(use /stop to cancel everything\\)\\.",
+        "⏳?\\s*Subagent working(?P<d>)"
+      ],
+      "variants": [
+        "子任务还在跑,你这条我先收好,它一收尾就办。",
+        "下面还有分支在跑,你这条排队等它完。",
+        "子任务没结束,先给你排上,很快就到。",
+        "还在跑子任务,你这条等它收工接着办。",
+        "子任务进行中,你这条我记着了,完了就轮到你。",
+        "手下还有活没干完,你这条先排在后面。",
+        "子任务还在忙,你这条稍等,它完事就办。",
+        "分支任务没结束,你这条我先存着。"
+      ]
+    },
+    {
+      "id": "busy_compressing",
+      "label": "正在压缩上下文",
+      "patterns": [
+        "⏳ Compressing context(?P<d>.*?)\\s*—\\s*your message is queued for when it finishes \\(use /stop to cancel everything\\)\\.",
+        "⏳?\\s*Compressing context(?P<d>)"
+      ],
+      "variants": [
+        "正在整理前面对话,你这条先排着,整理完就办。",
+        "在压缩上下文,你这条排队等它完。",
+        "正在收拾记忆,先记下你这条,完了马上办。",
+        "上下文压缩中,你这条稍等,完了就轮到你。",
+        "在把前面的对话收拢一下,你这条先排上。",
+        "正在精简上下文,你这条我收好了。",
+        "整理记忆中,你这条稍等片刻。",
+        "在收拾上下文,完了第一件事就是你这条。"
+      ]
+    },
+    {
+      "id": "busy_redirect",
+      "label": "按纠正调整方向",
+      "patterns": [
+        "↪ Redirected current run(?P<d>.*?)\\.\\s*I'll adjust using your correction\\.",
+        "↪?\\s*Redirected current run(?P<d>)"
+      ],
+      "variants": [
+        "好,方向调过来了,按你的纠正走。",
+        "收到,马上拐弯,照你说的重来。",
+        "明白,我改一下做法,按你给的来。",
+        "行,方向已修正,照你的意思调整。",
+        "收到,纠偏完成,接下来按你的走。",
+        "好,我把航向调正,按你的纠正来。",
+        "收到,思路已改,照你说的办。",
+        "明白,马上按你的纠正调整。",
+        "好,拐个弯,按你的说法继续。",
+        "收到,方向对了,就按你这个来。"
+      ]
+    },
+    {
+      "id": "busy_reject",
+      "label": "忙时拒收",
+      "patterns": [
+        "⏳ Agent is running — `/(?P<c>[^`]+)` can't run mid-turn\\. Wait for the current response or `/stop` first\\."
+      ],
+      "variants": [
+        "⏳ 手上还在跑任务,`/{c}` 这轮先跑不了。等它出结果,或者发 /stop 停掉。",
+        "⏳ 当前任务没结束,`/{c}` 插不进来。等一下,或者发 /stop 中止。",
+        "⏳ 这轮还在跑,`/{c}` 得等等。急的话发 /stop。",
+        "⏳ 忙着的,`/{c}` 现在执行不了。等这轮完,或发 /stop。",
+        "⏳ 正处理中,`/{c}` 先放放。等结果或发 /stop。"
+      ]
+    },
+    {
+      "id": "agent_busy_goal",
+      "label": "忙时不能改目标",
+      "patterns": [
+        "Agent is running — use /goal status / pause / clear / wait mid-run, or /stop before setting a new goal\\."
+      ],
+      "variants": [
+        "手上还在跑任务,要改目标先看状态、暂停或清掉,也可以直接发 /stop。",
+        "任务进行中,改目标得先停掉或改现有的。",
+        "还在跑,先处理完手上的再定新目标。",
+        "正忙着,要换目标先发 /stop 或调整现有的。"
+      ]
+    },
+    {
+      "id": "agent_busy_loop",
+      "label": "忙时不能设循环",
+      "patterns": [
+        "Agent is running — use /loop status / pause / stop mid-run, or /stop before setting a new loop\\."
+      ],
+      "variants": [
+        "手上还在跑任务,要设循环先看状态、暂停或停掉,也可以直接发 /stop。",
+        "任务进行中,设新循环得先停掉或改现有的。",
+        "还在跑,先处理完再设循环。",
+        "正忙着,要加循环先发 /stop 或调整。"
+      ]
+    },
+    {
+      "id": "steer_queued",
+      "label": "无任务时排队",
+      "patterns": [
+        "Agent still starting — /steer queued for the next turn\\.",
+        "No active agent — /steer queued for the next turn\\."
+      ],
+      "variants": [
+        "还没跑起来,你这条先排上,等会儿就办。",
+        "当前没有在跑的任务,你这条先排队。",
+        "手上还没任务,你这条排上了。",
+        "暂时空闲,你这条先记下,马上就办。",
+        "还没开始,你这条排一下。"
+      ]
+    },
+    {
+      "id": "steer_rejected",
+      "label": "空内容无法并入",
+      "patterns": [
+        "Steer rejected \\(empty payload\\)\\.?",
+        "Steer rejected\\.?"
+      ],
+      "variants": [
+        "没内容我插不进去,你补一句再说。",
+        "这条是空的,说点啥我才好带进去。",
+        "空消息插不进去,麻烦写点内容。",
+        "没有内容,没法并入,补一句吧。"
+      ]
+    },
+    {
+      "id": "paused_resume",
+      "label": "恢复运行",
+      "patterns": [
+        "▶️ Resumed — new work is accepted again\\.",
+        "Resumed — new work is accepted again\\."
+      ],
+      "variants": [
+        "恢复了,可以继续派活。",
+        "好了,接着来吧。",
+        "恢复运行,有新任务可以发了。",
+        "已经恢复,随时接活。"
+      ]
+    },
+    {
+      "id": "not_paused",
+      "label": "本来没暂停",
+      "patterns": [
+        "Hermes wasn't paused\\.?"
+      ],
+      "variants": [
+        "本来就没暂停——不用恢复。",
+        "它是正常跑的,没停过。",
+        "没在暂停状态,一切照常。",
+        "没暂停,直接继续就行。",
+        "它一直醒着,不用叫。",
+        "没有暂停过,照常运转。"
+      ]
+    },
+    {
+      "id": "drain_notice",
+      "label": "网关停机不收活",
+      "patterns": [
+        "Gateway is \\w+ and is not accepting another turn right now\\.",
+        "Gateway \\w+ — queued for the next turn after it comes back\\."
+      ],
+      "variants": [
+        "正在重启或停机中,这会儿先不接新任务。",
+        "网关在收尾,暂时不收新的活。",
+        "正在停机,先不接新任务了。",
+        "服务在重启,这会儿先等等。",
+        "网关正忙着重启,暂不接活。"
+      ]
+    },
+    {
+      "id": "gateway_online",
+      "label": "网关上线",
+      "patterns": [
+        "♻️ Gateway online/restarted",
+        "Gateway online/restarted"
+      ],
+      "variants": [
+        "♻️ 网关上线了",
+        "♻️ 我回来了,可以继续",
+        "♻️ 网关已重启完成",
+        "♻️ 重新上线,随时接活",
+        "♻️ 服务已恢复,在线"
+      ]
+    },
+    {
+      "id": "session_reset_stopped",
+      "label": "被停后会话重置",
+      "patterns": [
+        "◐ Session reset after being stopped\\.",
+        "Session reset after being stopped\\."
+      ],
+      "variants": [
+        "◐ 会话被停过,已重置。",
+        "◐ 上一轮被中断,对话已重置。",
+        "◐ 因为被停过,会话重开了。",
+        "◐ 已停止过,会话重置完成。"
+      ]
+    },
+    {
+      "id": "compress_couldnt",
+      "label": "压缩到头",
+      "patterns": [
+        "could not be compressed further\\.?",
+        "Your next message will start a fresh session\\.?"
+      ],
+      "variants": [
+        "已经压不动了,你下一条会开一段新对话。",
+        "压缩到头了,下条消息重新起一段。",
+        "没法再压缩了,下一条就换新会话。",
+        "已经最简了,下条消息开新对话。"
+      ]
+    },
+    {
+      "id": "retry_hint",
+      "label": "重试/重开提示",
+      "patterns": [
+        "Use /retry to try again, or /new to start a fresh conversation\\.?",
+        "Use /reset only if you intentionally want to start a new conversation\\.?"
+      ],
+      "variants": [
+        "想重来发 /retry,想重开一段发 /new。",
+        "重试发 /retry,换新对话发 /new。",
+        "可以用 /retry 重试,或 /new 开新对话。",
+        "再试一次发 /retry,重开就发 /new。"
+      ]
+    },
+    {
+      "id": "bg_no_signin",
+      "label": "后台任务无登录",
+      "patterns": [
+        "The background task couldn't start because no AI model sign-in is"
+      ],
+      "variants": [
+        "❌ 后台任务起不来,因为还没登录 AI 模型。",
+        "❌ 没登录模型,后台任务开不了。",
+        "❌ 后台任务启动失败:模型未登录。",
+        "❌ 要先登录 AI 模型,后台任务才能跑。"
+      ]
+    },
+    {
+      "id": "continue_goal",
+      "label": "继续既定目标",
+      "patterns": [
+        "\\[Continuing toward your standing goal\\]"
+      ],
+      "variants": [
+        "[继续推进你定的目标]",
+        "[接着做你设的长期目标]",
+        "[按既定目标继续]",
+        "[继续朝目标推进]"
+      ]
+    },
+    {
+      "id": "approve_once",
+      "label": "审批:只批一次",
+      "patterns": [
+        "\\*\\*Approve Once\\*\\* — proceed this time only"
+      ],
+      "variants": [
+        "**只批这次** —— 这一次放行",
+        "**批准一次** —— 就这次执行",
+        "**这次同意** —— 仅本轮放行",
+        "**单次批准** —— 只执行这一次",
+        "**就这一次** —— 本次放行"
+      ]
+    },
+    {
+      "id": "approve_always",
+      "label": "审批:总是批准",
+      "patterns": [
+        "\\*\\*Always Approve\\*\\* — proceed and silence this prompt permanently"
+      ],
+      "variants": [
+        "**总是批准** —— 以后同类不再问",
+        "**一律同意** —— 以后不再提示",
+        "**永久批准** —— 这类不再问",
+        "**总是放行** —— 不再提示",
+        "**全部同意** —— 以后直接执行"
+      ]
+    },
+    {
+      "id": "approve_cancel",
+      "label": "审批:取消",
+      "patterns": [
+        "\\*\\*Cancel\\*\\* — keep current conversation"
+      ],
+      "variants": [
+        "**取消** —— 保持当前对话",
+        "**算了** —— 什么都不做",
+        "**取消** —— 不动现有对话",
+        "**不执行** —— 保持原样",
+        "**放弃** —— 维持现状"
+      ]
+    },
+    {
+      "id": "unauthorized_dm",
+      "label": "非主人私聊",
+      "patterns": [
+        "Hi! I'm a personal assistant and can only chat with my own owner, so I can't help you directly\\. Sorry!"
+      ],
+      "variants": [
+        "你好~我是专属助理,只服务我的主人,没法直接帮到你,抱歉啦。",
+        "嗨,我是私人助理,只能跟我主人聊,帮不上你,不好意思。",
+        "你好,这个号是专属助理,只对接主人,帮不了你哦。",
+        "你好呀,我是私人的小助手,只跟主人对话,抱歉。",
+        "嗨~我是专属助理,只服务主人一个,没法帮你。"
+      ]
+    },
+    {
+      "id": "turn_not_processed",
+      "label": "整轮没跑起来",
+      "patterns": [
+        "Your request was not processed\\.[^\\n]*",
+        "Your request was not processed[^\\n]*"
+      ],
+      "variants": [
+        "刚才那下没接住,麻烦你再发一次,我马上办。",
+        "这一轮跑空了,你再发一遍,这次一定接住。",
+        "信号丢了一格,重发一下就好。",
+        "这轮没接上,你再说一遍,我立刻做。",
+        "手滑没接住,再来一次?",
+        "刚走神了,你重复一下,马上办。",
+        "这条没进到我这,重发一次就行。",
+        "掉线了一下,你再说一次,我马上接。",
+        "这轮空转了,麻烦你重发,我这次稳稳接住。",
+        "刚才没跑起来,你发一遍,我立刻开工。",
+        "消息在传输中溜了,麻烦再来一次。",
+        "这轮没启动成功,你重发,我马上处理。"
+      ]
+    },
+    {
+      "id": "turn_partial",
+      "label": "半途中断、可能已改动",
+      "patterns": [
+        "This turn did not complete\\.[^\\n]*",
+        "This turn did not complete[^\\n]*"
+      ],
+      "variants": [
+        "这轮半路断了,前面可能已经动过东西——重发前先确认一下。",
+        "任务跑了一半散架了,已经做过的部分你先过一眼。",
+        "中途断了,有些改动可能已经落下,核对一下再重来。",
+        "这轮没走完,前面的动作说不定已经生效了。",
+        "半途熄火,先看看已经跑过的那几步。",
+        "断在半路了,动手前先核对已发生的改动。",
+        "这轮没跑完,别急着重发,先确认已经动过的。",
+        "半道停了,前面留下的痕迹请先看一眼。",
+        "跑一半就断了,可能有副作用已经产生,先核验。",
+        "中途停了,已经落地的改动得先确认。"
+      ]
+    },
+    {
+      "id": "msg_not_processed",
+      "label": "单条消息没处理",
+      "patterns": [
+        "Your message was not processed[^\\n]*",
+        "this message was not processed[^\\n]*"
+      ],
+      "variants": [
+        "这条没接住,麻烦重发一次。",
+        "消息溜了,再发一遍。",
+        "这条我漏了,重发一下。",
+        "没收到这条,再来一次。",
+        "手滑漏掉了,麻烦重发。",
+        "这条掉地上了,再发一次。",
+        "刚才那条没进我脑子,重发一下。",
+        "这条没进来,麻烦再发。"
+      ]
+    },
+    {
+      "id": "turn_interrupted",
+      "label": "上一轮被打断",
+      "patterns": [
+        "\\[System note: The previous turn was interrupted[^\\]]*\\]",
+        "The previous turn was interrupted by[^\\n]*"
+      ],
+      "variants": [
+        "上一轮被打断了,我接着往下做。",
+        "刚被叫停了,我继续。",
+        "前面中断了,我从这儿接着来。",
+        "上一轮没跑完,我续上。",
+        "被按下暂停了,现在继续。",
+        "中断了一次,我接着办。",
+        "刚才被打断,我把它做完。",
+        "上一轮半途停了,我从断点续上。"
+      ]
+    },
+    {
+      "id": "cmd_timeout",
+      "label": "命令超时",
+      "patterns": [
+        "\\[?Command timed out after (?P<n>\\d+)\\s*s(?:econds)?\\]?",
+        "Command timed out after (?P<n>\\d+) seconds"
+      ],
+      "variants": [
+        "等了 {n} 秒还没动静,先掐了。",
+        "这条命令 {n} 秒了还在那儿杵着,我撤了。",
+        "超时 {n} 秒,先放它一马。",
+        "{n} 秒没回音,先停了。",
+        "它磨蹭了 {n} 秒,我先收了。",
+        "等了 {n} 秒没结果,换个方式。",
+        "{n} 秒还没跑完,我掐了重来。",
+        "这条太慢了({n} 秒),先停。",
+        "撑到 {n} 秒就超时了,撤。",
+        "它卡了 {n} 秒没反应,我先断掉。"
+      ]
+    },
+    {
+      "id": "exit_timeout",
+      "label": "超时退出码",
+      "patterns": [
+        "Exit 124:[^\\n]*",
+        "the command hit its timeout[^\\n]*"
+      ],
+      "variants": [
+        "命令超时退出(124),想多等就调大 timeout。",
+        "它到点自己退了(124),要更久就加 timeout。",
+        "超时退出码 124,需要的话把 timeout 调大。",
+        "跑到超时就退了(124),想等更久就改 timeout。",
+        "124:超时退出,加大 timeout 能等更久。",
+        "退出码 124 就是超时,需要就调 timeout。",
+        "它等不及退了(124),要慢工就调 timeout。"
+      ]
+    },
+    {
+      "id": "refuse_overwrite",
+      "label": "拒写未读文件",
+      "patterns": [
+        "Refusing to overwrite (?P<p>[^\\n:]+):[^\\n]*"
+      ],
+      "variants": [
+        "{p} 我还没读过,不敢直接覆盖——先读一遍再说。",
+        "没看过 {p},先读再写,免得把内容盖掉。",
+        "{p} 没读过就覆盖太危险,我先看一遍。",
+        "为免覆盖,{p} 我先读再动。"
+      ]
+    },
+    {
+      "id": "file_not_read",
+      "label": "文件没先读",
+      "patterns": [
+        "[^\\n]*was not read by this agent\\.[^\\n]*",
+        "Read the file first so you can write an informed edit[^\\n]*"
+      ],
+      "variants": [
+        "这文件我还没读过,先读一遍再改。",
+        "没看过就改容易出事,先读一下。",
+        "先读再写,免得把原内容盖掉。",
+        "得先看看这文件长什么样,再动手。"
+      ]
+    },
+    {
+      "id": "script_path_relative",
+      "label": "脚本路径要相对",
+      "patterns": [
+        "Script path must be relative to [^\\n]*",
+        "Place scripts in [^\\n]*and use just the filename[^\\n]*"
+      ],
+      "variants": [
+        "脚本路径要写成相对的(相对脚本目录),别用绝对路径。",
+        "路径得是相对目录的,绝对路径和 ~ 都不行。",
+        "脚本路径请用相对写法,绝对路径会被拒。",
+        "路径要相对脚本目录——绝对路径过不了校验。"
+      ]
+    },
+    {
+      "id": "tool_name_required",
+      "label": "调用缺工具名",
+      "patterns": [
+        "tool_call calls\\[\\d+\\] requires a 'name'",
+        "requires a 'name'"
+      ],
+      "variants": [
+        "这个调用少了工具名。",
+        "得写上要调哪个工具。",
+        "缺了工具名,补一下。",
+        "没说是哪个工具,补个名字。"
+      ]
+    },
+    {
+      "id": "patch_no_match",
+      "label": "替换没匹配上",
+      "patterns": [
+        "Could not find a match for old_string[^\\n]*",
+        "Failed to find a match for old_string[^\\n]*",
+        "old_string not found[^\\n]*"
+      ],
+      "variants": [
+        "文件里找不到这段原文,替换不了。",
+        "没匹配上,你给的那段和文件里对不上。",
+        "找不到要替换的内容,确认下原文。",
+        "这段在文件里没找到,先看看是不是改了。",
+        "原文对不上——先核对一下再替换。"
+      ]
+    },
+    {
+      "id": "skill_desc_too_long",
+      "label": "技能简介超长",
+      "patterns": [
+        "Description is \\d+ chars[^\\n]*",
+        "must fit the \\d+-char[^\\n]*"
+      ],
+      "variants": [
+        "技能简介超长了,得压到 60 字符以内。",
+        "简介太长了(限 60 字符),删一点。",
+        "简介超出 60 字符上限,精简一下。",
+        "简介得再短些,60 字符是硬线。"
+      ]
+    },
+    {
+      "id": "perm_denied",
+      "label": "权限不足",
+      "patterns": [
+        "Permission denied",
+        "EACCES: permission denied"
+      ],
+      "variants": [
+        "这条路没权限——不硬闯,我换条道走。",
+        "权限不够,我绕开它,别卡在这。",
+        "被权限挡了一下,没事,另找路子。",
+        "这门锁着,我走别的门。",
+        "没权限,先放它一放,我换个方式。",
+        "权限这关过不去,我另想办法,不耽误事。",
+        "被挡在门外了——换条路,继续走。",
+        "这儿不让进,我绕过去。"
+      ]
+    },
+    {
+      "id": "no_such_file",
+      "label": "路径不存在",
+      "patterns": [
+        "No such file or directory",
+        "no such file or directory"
+      ],
+      "variants": [
+        "这路径是空的——没这个文件,确认下名字。",
+        "找不着。路径不对,或者文件被挪走了。",
+        "翻遍了没这东西,看看路径是不是写错。",
+        "这儿什么都没有,路径得再核一下。",
+        "文件不在——名字或目录,挑一个改。",
+        "没找到这个文件,先确认下它还在不在。",
+        "路径指向空气,检查一下再走。",
+        "这文件不存在,换个名字或路径试试。"
+      ]
+    },
+    {
+      "id": "http_error",
+      "label": "接口报错",
+      "patterns": [
+        "HTTP Error (?P<code>\\d{3}):\\s*[A-Za-z]+",
+        "HTTP Error (?P<code>\\d{3})",
+        "Client error '(?P<code>\\d{3})[^']*'",
+        "status_code['\"]?\\s*[:=]\\s*(?P<code>\\d{3})"
+      ],
+      "variants": [
+        "接口回 {code} 了,这次没成。",
+        "对面给了 {code},请求没通过。",
+        "服务端回 {code},先放一放。",
+        "调用被拒({code}),换个姿势再试。",
+        "接口 {code},这条路走不通。",
+        "对方回 {code},这次先算了。",
+        "{code}:请求没被接受。",
+        "接口返回 {code}——看看是不是权限或参数的问题。",
+        "被 {code} 挡回来了,我换个方式。"
+      ]
+    },
+    {
+      "id": "connect_failed",
+      "label": "连接失败",
+      "patterns": [
+        "Connect call failed",
+        "failed reconnect attempts",
+        "Connection refused",
+        "Failed to connect"
+      ],
+      "variants": [
+        "连不上——网络或服务那边的问题,我来重试。",
+        "握不上手,先等等再试。",
+        "连接被拒,对方可能没开。",
+        "没连上,我再试一次。",
+        "线路不通,稍后重来。",
+        "接不上头——别急,我重连。",
+        "通道打不开,换条路再试。",
+        "对端没应答,我先重连看看。"
+      ]
+    },
+    {
+      "id": "websocket_closed",
+      "label": "长连接断开",
+      "patterns": [
+        "websocket closed",
+        "aibot websocket not subscribed"
+      ],
+      "variants": [
+        "长连接断了,会自动重连——不用管。",
+        "通道掉了,正在接回来。",
+        "WebSocket 断了,重连中,稍等。",
+        "链路断了,别慌,它会自己恢复。",
+        "连接掉了,我重新挂上。",
+        "通道断开,等它回来就好。",
+        "长连接抖了一下,正在自动恢复。"
+      ]
+    },
+    {
+      "id": "traceback",
+      "label": "程序抛异常",
+      "patterns": [
+        "Traceback \\(most recent call last\\):"
+      ],
+      "variants": [
+        "程序崩了一下,堆栈如下。",
+        "抛异常了,看堆栈。",
+        "出错了,堆栈在这儿。",
+        "报异常了,我看看。",
+        "崩了,堆栈贴出来。",
+        "程序摔了一跤,堆栈在下面。",
+        "翻车了,堆栈如下。",
+        "抛错了,堆栈在这。"
+      ]
+    },
+    {
+      "id": "delegation_complete",
+      "label": "后台任务完成",
+      "patterns": [
+        "\\[ASYNC DELEGATION COMPLETE[^\\]]*\\]",
+        "\\[ASYNC DELEGATION BATCH COMPLETE[^\\]]*\\]"
+      ],
+      "variants": [
+        "后台那个任务交差了。",
+        "派出去的活干完了。",
+        "后台任务收工,结果在这。",
+        "分出去的任务回来了。",
+        "后台那摊子完事了。",
+        "交给后台的活有结果了。",
+        "后台的任务回来了,结果如下。"
+      ]
+    },
+    {
+      "id": "stream_activity",
+      "label": "正在接收输出",
+      "patterns": [
+        "receiving stream response"
+      ],
+      "variants": [
+        "正在收流式输出…",
+        "数据还在往里进,稍等。",
+        "流里还有内容,我在读。",
+        "正在接收回复流,快好了。",
+        "内容还在往外吐,接住中。",
+        "流还没结束,我继续收。",
+        "数据流还没走完,稍候。"
+      ]
+    },
+    {
+      "id": "cmd_blocked",
+      "label": "命令被拦",
+      "patterns": [
+        "Command blocked",
+        "blocked by policy"
+      ],
+      "variants": [
+        "这条命令被策略拦下了——不硬来,换条路。",
+        "不让跑,被规则挡回来了。",
+        "命令被拦,我换个方式达成。",
+        "被拦住了,不硬闯。",
+        "这条被禁止执行,另找办法。",
+        "策略不批这条,绕一下。",
+        "被拦下了——换个合规的写法再试。"
+      ]
+    },
+    {
+      "id": "busy_tail_respond_shortly",
+      "label": "尾句:马上回你",
+      "patterns": [
+        "I'll respond to your message shortly\\.?"
+      ],
+      "variants": [
+        "马上回你。",
+        "这就回你。",
+        "稍等,我马上看。",
+        "马上处理你这条。",
+        "回你就在下一秒。"
+      ]
+    },
+    {
+      "id": "busy_tail_queue",
+      "label": "尾句:跑完就办",
+      "patterns": [
+        "I'll respond once the current task finishes\\.?"
+      ],
+      "variants": [
+        "跑完就接着办。",
+        "它一收尾我就接。",
+        "等这轮完就轮到你。",
+        "完了我立刻办。",
+        "收工第一件事就是它。"
+      ]
+    },
+    {
+      "id": "busy_tail_steer",
+      "label": "尾句:下个动作带上",
+      "patterns": [
+        "Your message arrives after the next tool call\\.?"
+      ],
+      "variants": [
+        "下个动作就带上你这条。",
+        "下一步就按你说的来。",
+        "紧接着的一步就生效。",
+        "马上照办。"
+      ]
+    },
+    {
+      "id": "busy_tail_steer_sub",
+      "label": "尾句:子任务下个动作带上",
+      "patterns": [
+        "Your message arrives after their next tool call\\.?"
+      ],
+      "variants": [
+        "它们下个动作就带上你这条。",
+        "子任务下一步就按你说的来。",
+        "分支也会跟着一起调。",
+        "子任务也照这个走。"
+      ]
+    },
+    {
+      "id": "busy_tail_redirect",
+      "label": "尾句:按纠正调整",
+      "patterns": [
+        "I'll adjust using your correction\\.?"
+      ],
+      "variants": [
+        "我按你的纠正调整。",
+        "照你说的改。",
+        "马上按你给的调。",
+        "就按你这个来。"
+      ]
+    },
+    {
+      "id": "busy_tail_demoted",
+      "label": "尾句:排队+/stop",
+      "patterns": [
+        "your message is queued for when it finishes \\(use /stop to cancel everything\\)\\.?"
+      ],
+      "variants": [
+        "你这条已排队,等它干完就办(想全停发 /stop)。",
+        "先给你排上,完了就处理(要中止全部发 /stop)。",
+        "已进队列,跑完接着办(全停就发 /stop)。",
+        "排上了——要立刻全停,发 /stop。"
+      ]
+    }
+  ]
+}

+ 13 - 0
plugin.yaml

@@ -0,0 +1,13 @@
+name: plugin-tips-cn
+version: 0.4.1
+description: "中文口吻:把 Hermes 发出来的英文系统提示(忙时回执、进度心跳、自主进化复盘、后台任务、命令超时、权限不足、接口报错、长连接断开…)就地换成随机的日常中文说法,同一种情况每次说法都不同,消息看着不像机器。三层网:工具结果、助手回复、以及所有平台适配器的出站 send/edit_message。词库 phrases.json,可自行加类别和候选句。"
+author: fmode
+kind: backend
+hooks:
+  - transform_tool_result
+  - transform_llm_output
+  - pre_gateway_dispatch
+provides_hooks:
+  - transform_tool_result
+  - transform_llm_output
+  - pre_gateway_dispatch

+ 40 - 0
validate_phrases.py

@@ -0,0 +1,40 @@
+#!/usr/bin/env python3
+"""词库自检:JSON 合法 / 正则能编译 / 占位符与命名组一一对应 / 候选条数。
+
+用法:python3 validate_phrases.py [phrases.json]
+每次改完词库都跑一遍——占位符写错(候选用 {n} 而正则组叫 code)不会报错,
+只会在运行时把 {n} 字面量发给用户,肉眼很难发现。
+"""
+import json, re, sys, pathlib
+
+path = pathlib.Path(sys.argv[1] if len(sys.argv) > 1 else "phrases.json")
+data = json.loads(path.read_text(encoding="utf-8"))
+cats = data.get("categories", [])
+problems, total, minv = [], 0, 10 ** 9
+for c in cats:
+    cid = c.get("id", "?")
+    groups = set()
+    for pat in c.get("patterns", []):
+        try:
+            groups |= set(re.compile(pat, re.IGNORECASE).groupindex)
+        except re.error as exc:
+            problems.append(f"{cid}: 正则编译失败 {pat!r} -> {exc}")
+    variants = c.get("variants") or []
+    total += len(variants)
+    minv = min(minv, len(variants))
+    for v in variants:
+        missing = set(re.findall(r"\{(\w+)\}", v)) - groups
+        if missing:
+            problems.append(f"{cid}: 候选用了 {sorted(missing)},正则里没有这些组({sorted(groups)})")
+    if not c.get("patterns"):
+        problems.append(f"{cid}: 没有 patterns")
+    if not variants:
+        problems.append(f"{cid}: 没有 variants")
+
+print(f"类别 {len(cats)} | 候选 {total} | 最少的一类 {minv} 条")
+if problems:
+    print(f"\n✗ 发现 {len(problems)} 个问题:")
+    for p in problems:
+        print("   -", p)
+    sys.exit(1)
+print("✓ 全部通过")