Commit ddd7e5c17e23cb648b8f6d81acab7d5b1b7bb206

Authored by yibo
1 parent 2a9e228e

refactor(wechat-viewer): 聊天/doctor/重新生成改走 dsh,profile 管理下线;停用学习管线 cron

- _dsh_chat_cmd: call_hermes / call_hermes_stream / regenerate 用 dsh headless(stdout 即最终答案)
- /api/doctor: hermes_cli → dsh_cli
- profile create/delete 返回 501(dsh 无 profile 概念),profile chat 统一走 dsh
- crontab 移除 learn-pipeline-trigger / learn-executor

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
CLAUDE.md
... ... @@ -53,7 +53,7 @@ Pixel 上微信发送/跟踪的落地,纯设备内运行(不依赖 Mac):
53 53 | `~/.dsh/skills/` | 保留的 ~47 个 skill(**必须平铺**,dsh 不递归嵌套目录);原 597 个 Hermes skill 仍在 `~/.hermes/skills/`,只待归档 |
54 54 | `~/.dsh/profiles/{headless,web}/cordis.patch.yml` | 默认模型 `zai-coding/glm-5.3-flash`、persona(原 SOUL.md)、手机 MCP 接入 |
55 55 | `/data/adb/service.d/{crond,dsh-web,wechat-reply-watcher}.sh` | Magisk 开机自启(Hermes gateway 相关脚本已挪到 `/data/adb/retired/`) |
56   -| Termux `crond`(`crontab -l`,见 `device/crontab.txt`) | 取代 Hermes cron:reply-sender 5min、viewer 看门狗 10min、学习管线、BOSS 简历 6h、gate-audit、**TGO 日报 01/02/03 点链**(`daily-digest.sh gen|img|send`)+ 每小时 TGO 预摘要 |
  56 +| Termux `crond`(`crontab -l`,见 `device/crontab.txt`) | 取代 Hermes cron:reply-sender 5min、viewer 看门狗 10min、BOSS 简历 6h(**学习管线 cron 已于 2026-10-17 停用**:`learn-and-build` 产出写进 Hermes skill/memory,下线后无落点)、gate-audit、**TGO 日报 01/02/03 点链**(`daily-digest.sh gen|img|send`)+ 每小时 TGO 预摘要 |
57 57  
58 58 **微信自动回复引擎**:member(闲聊)走 `~/dsh/run-reply-member.sh`(无工具、`read-only`、人设取自 `~/pixelphone/SOUL.md`,对方是任意陌生人,输入不可信),admin(任务)走 `~/dsh/run-headless.sh`(全工具);orchestrator 里已无 Hermes 分支。admin 读 `~/.dsh/skills/reply-task-executor`,手机 MCP 已放开;但 `WECHAT_REPLY_NO_DIRECT_SEND=1` 闸门仍会让 `wechat_hook_send.send_*` 直发被拒,**发送只能走 orchestrator**。不可信内容的任务(viewer 文章摘要)用 `--patch ~/dsh/patches/no-phone.yml` 关掉手机 MCP。
59 59  
... ...
device/crontab.txt
... ... @@ -6,8 +6,6 @@ D=/data/data/com.termux/files/home/pixelphone/scripts
6 6 L=/data/data/com.termux/files/home/pixelphone/cron-logs
7 7 */5 * * * * python3 $D/reply-sender.py >> $L/reply-sender.log 2>&1
8 8 */10 * * * * bash $D/wechat-viewer-watchdog.sh >> $L/viewer-watchdog.log 2>&1
9   -0 */2 * * * bash $D/learn-pipeline-trigger.sh >> $L/learn-classify.log 2>&1
10   -0 * * * * bash $D/learn-executor.sh >> $L/learn-executor.log 2>&1
11 9 0 */6 * * * bash $D/boss-resume.sh >> $L/boss-resume.log 2>&1
12 10 0 6 * * * bash $D/gate-audit-cron.sh >> $L/gate-audit.log 2>&1
13 11 # TGO 日报链(取代 Hermes 调度器):01 生成 / 02 出图 / 03 发送;失败下个整点重试(窗口内每 10 分钟)
... ... @@ -18,3 +16,4 @@ L=/data/data/com.termux/files/home/pixelphone/cron-logs
18 16 0 * * * * pgrep -f tgo_summarize_batch >/dev/null || python3 $D/tgo_summarize_batch.py >> $L/tgo-summary.log 2>&1
19 17 # 原图保存 A+B(旧调度器定频 02/06/10/14/18/22 点,放 :15 避开 02:00 出图;约30分钟接管屏幕,锁屏/微信未起自动跳过)
20 18 15 2,6,10,14,18,22 * * * bash $D/orig-save.sh >> $L/orig-save.log 2>&1
  19 +# 学习管线(learn-pipeline-trigger 每 2h / learn-executor 每小时)已于 2026-10-17 停用:learn-and-build 的产出写进 Hermes 的 skill/memory,Hermes 下线后无落点。恢复需先改 learn-and-build 的落点。
... ...
device/pixelphone/digest/wechat-group-digest/scripts/gen_digest_html.py
... ... @@ -309,6 +309,7 @@ def build_media_section(date: str, prof: dict = None) -&gt; str:
309 309 import html as _html
310 310 parts.append('<div class="media-grid">')
311 311 for m in imgs:
  312 + u = _url(m)
312 313 # meta 行:发送人 · HH:MM · 群名(2026-10-08 用户要求补充显示)
313 314 bits = []
314 315 if (m.get("sender") or "").strip():
... ... @@ -325,24 +326,39 @@ def build_media_section(date: str, prof: dict = None) -&gt; str:
325 326 if cap else "")
326 327 parts.append(
327 328 f'<div class="media-img-cell">'
328   - f'<a href="{m["oss_url"]}" target="_blank" rel="noopener">'
329   - f'<img src="{m["oss_url"]}" loading="lazy" alt="{m.get("title", "")}"></a>'
  329 + f'<a href="{u}" target="_blank" rel="noopener">'
  330 + f'<img src="{u}" loading="lazy" alt="{m.get("title", "")}"></a>'
330 331 f'{meta_html}{cap_html}</div>'
331 332 )
332 333 parts.append("</div>")
333 334 if vids:
  335 + import html as _html
334 336 parts.append('<div class="media-videos">')
335 337 for m in vids:
336   - parts.append(
337   - f'<video controls preload="metadata" src="{m["oss_url"]}"></video>'
338   - )
  338 + u = _url(m)
  339 + if u.lower().split("?")[0].endswith(".mp4"):
  340 + # 完整视频文件在本地/已上传:<video> 原生播放
  341 + p = m.get("poster_digest_url") or m.get("poster_url") or ""
  342 + poster_attr = f' poster="{p}"' if p else ""
  343 + parts.append(
  344 + f'<video controls preload="metadata" src="{u}"{poster_attr}></video>'
  345 + )
  346 + else:
  347 + # 只有封面缩略图(视频文件未下载):渲染成可点击封面,点开看大图
  348 + title = _html.escape(m.get("title") or "视频")
  349 + parts.append(
  350 + f'<a class="media-vid-cell" href="{u}" target="_blank" rel="noopener" '
  351 + f'title="查看视频封面(视频文件未下载到本机)">'
  352 + f'<img src="{u}" loading="lazy" alt="{title}">'
  353 + f'<span class="media-vid-badge">🎬 视频</span></a>'
  354 + )
339 355 parts.append("</div>")
340 356 if files:
341 357 import html as _html
342 358 parts.append('<ul class="media-files">')
343 359 for m in files:
344 360 title = _html.escape(m.get("title") or "文件")
345   - url = _html.escape(m.get("oss_url") or "", quote=True)
  361 + url = _html.escape(_url(m), quote=True)
346 362 # meta:发送人 · HH:MM · 大小 · 群名(sender/create_time 2026-10-08 新增)
347 363 bits = [b for b in [
348 364 (m.get("sender") or "").strip() or None,
... ...
device/wechat-viewer/app.py
... ... @@ -245,6 +245,15 @@ def _wechat_share_des(url: str) -&gt; str:
245 245 return ""
246 246  
247 247  
  248 +def _dsh_chat_cmd(user_input: str) -> list[str]:
  249 + """聊天界面用:dsh headless(stdout=最终答案,reasoning 在 stderr);关手机 MCP——对话内容不可信。
  250 + ponytail: 无多轮会话/无 profile 概念,每问一次起一个 dsh(~10s+);要多轮可用 --session-id。"""
  251 + return [_DSH_BIN, "headless", "--patch", _DSH_NO_PHONE, user_input]
  252 +
  253 +
  254 +_PROFILE_GONE = "Hermes profile 管理已随 Hermes 下线(dsh 无 profile 概念)"
  255 +
  256 +
248 257 def _maybe_dsh(job_type: str, cmd: list[str]) -> list[str]:
249 258 try:
250 259 cfg = json.loads(_JOB_ENGINE_FILE.read_text())
... ... @@ -4714,20 +4723,20 @@ def api_doctor():
4714 4723 checks["cron"] = {"ok": False, "detail": str(e)[:100]}
4715 4724 all_ok = False
4716 4725  
4717   - # 7. Hermes CLI 可达性
  4726 + # 7. dsh CLI 可达性
4718 4727 try:
4719   - result = subprocess.run(["hermes", "--version"], capture_output=True,
4720   - text=True, timeout=10)
4721   - checks["hermes_cli"] = {"ok": result.returncode == 0,
  4728 + result = subprocess.run([_DSH_BIN, "--version"], capture_output=True,
  4729 + text=True, timeout=20)
  4730 + checks["dsh_cli"] = {"ok": result.returncode == 0,
4722 4731 "detail": result.stdout.strip()[:80] if result.returncode == 0
4723 4732 else f"exit={result.returncode} {result.stderr[:80]}"}
4724 4733 if result.returncode != 0:
4725 4734 all_ok = False
4726 4735 except FileNotFoundError:
4727   - checks["hermes_cli"] = {"ok": False, "detail": "hermes binary not found in PATH"}
  4736 + checks["dsh_cli"] = {"ok": False, "detail": "dsh binary not found"}
4728 4737 all_ok = False
4729 4738 except Exception as e:
4730   - checks["hermes_cli"] = {"ok": False, "detail": str(e)[:100]}
  4739 + checks["dsh_cli"] = {"ok": False, "detail": str(e)[:100]}
4731 4740 all_ok = False
4732 4741  
4733 4742 # 8. 模型 API(快速连通性检查 — 仅检查配置文件,不打实际 API 调用)
... ... @@ -5132,16 +5141,16 @@ def call_hermes(user_input: str, timeout: int = 120) -&gt; str:
5132 5141 """
5133 5142 try:
5134 5143 r = subprocess.run(
5135   - ["hermes", "chat", "-q", user_input, "-Q"],
  5144 + _dsh_chat_cmd(user_input),
5136 5145 capture_output=True, text=True,
5137 5146 timeout=timeout,
5138 5147 cwd="/data/data/com.termux/files/home"
5139 5148 )
5140 5149 ai_reply = (r.stdout or "").strip()
5141 5150 if not ai_reply and r.stderr:
5142   - ai_reply = f"[Hermes 错误] {r.stderr[:200]}"
  5151 + ai_reply = f"[dsh 错误] {r.stderr[-200:]}"
5143 5152 elif not ai_reply:
5144   - ai_reply = "[Hermes 返回空回复]"
  5153 + ai_reply = "[dsh 返回空回复]"
5145 5154 return ai_reply
5146 5155 except subprocess.TimeoutExpired:
5147 5156 return "[Hermes:gate=TIMEOUT] 调用超时"
... ... @@ -5167,10 +5176,7 @@ def call_hermes_stream(user_input: str, timeout: int = 120, profile: str = &quot;&quot;):
5167 5176 from threading import Timer
5168 5177 proc = None
5169 5178 try:
5170   - cmd = ["hermes"]
5171   - if profile and profile != "default":
5172   - cmd.extend(["-p", profile])
5173   - cmd.extend(["chat", "-q", user_input, "-Q"])
  5179 + cmd = _dsh_chat_cmd(user_input) # profile 参数已无意义(dsh 无 profile),保留签名兼容调用方
5174 5180 proc = subprocess.Popen(
5175 5181 cmd,
5176 5182 stdout=subprocess.PIPE, stderr=subprocess.PIPE,
... ... @@ -5454,7 +5460,7 @@ def api_regenerate(conv_id):
5454 5460  
5455 5461 def generate():
5456 5462 proc = subprocess.Popen(
5457   - ["hermes", "ask", hermes_input],
  5463 + _dsh_chat_cmd(hermes_input),
5458 5464 stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True,
5459 5465 env={**os.environ, "NO_COLOR": "1"}
5460 5466 )
... ... @@ -6007,10 +6013,7 @@ def _scan_profiles():
6007 6013 info = _scan_one(HERMES_HOME, "default")
6008 6014 # Try to get description from hermes profile show
6009 6015 try:
6010   - r = subprocess.run(
6011   - ["hermes", "profile", "show", "default"],
6012   - capture_output=True, text=True, timeout=10
6013   - )
  6016 + r = subprocess.CompletedProcess([], 1, "", "") # Hermes profile show 已下线
6014 6017 if r.returncode == 0:
6015 6018 for line in r.stdout.splitlines():
6016 6019 if line.lower().startswith("model:"):
... ... @@ -6131,7 +6134,8 @@ def api_profile_create():
6131 6134 if target_dir.exists():
6132 6135 return jsonify({"ok": False, "error": f"profile '{name}' already exists"}), 409
6133 6136  
6134   - cmd = ["hermes", "profile", "create", name, "--no-skills"]
  6137 + return jsonify({"ok": False, "error": _PROFILE_GONE}), 501
  6138 + cmd = ["hermes", "profile", "create", name, "--no-skills"] # 死代码,随 Hermes 清理时一并删
6135 6139 if description:
6136 6140 cmd.extend(["--description", description])
6137 6141  
... ... @@ -6171,7 +6175,8 @@ def api_profile_delete(name):
6171 6175 # Maybe it's a hermes profile not in profiles/ dir
6172 6176 pass
6173 6177  
6174   - cmd = ["hermes", "profile", "delete", name, "-y"]
  6178 + return jsonify({"ok": False, "error": _PROFILE_GONE}), 501
  6179 + cmd = ["hermes", "profile", "delete", name, "-y"] # 死代码
6175 6180 r = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
6176 6181 if r.returncode != 0:
6177 6182 return jsonify({"ok": False, "error": r.stderr.strip() or r.stdout.strip() or "delete failed"}), 500
... ... @@ -6192,7 +6197,7 @@ def api_profile_chat(name):
6192 6197 if not message:
6193 6198 return jsonify({"ok": False, "error": "message is required"}), 400
6194 6199  
6195   - cmd = ["hermes", "-p", name, "chat", "-q", message]
  6200 + cmd = _dsh_chat_cmd(message) # profile 无意义,统一走 dsh;name 仅用于展示
6196 6201 r = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
6197 6202 if r.returncode != 0:
6198 6203 err = r.stderr.strip() or r.stdout.strip() or "chat failed"
... ...