Commit ddd7e5c17e23cb648b8f6d81acab7d5b1b7bb206
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>
Showing
4 changed files
with
50 additions
and
30 deletions
CLAUDE.md
| @@ -53,7 +53,7 @@ Pixel 上微信发送/跟踪的落地,纯设备内运行(不依赖 Mac): | @@ -53,7 +53,7 @@ Pixel 上微信发送/跟踪的落地,纯设备内运行(不依赖 Mac): | ||
| 53 | | `~/.dsh/skills/` | 保留的 ~47 个 skill(**必须平铺**,dsh 不递归嵌套目录);原 597 个 Hermes skill 仍在 `~/.hermes/skills/`,只待归档 | | 53 | | `~/.dsh/skills/` | 保留的 ~47 个 skill(**必须平铺**,dsh 不递归嵌套目录);原 597 个 Hermes skill 仍在 `~/.hermes/skills/`,只待归档 | |
| 54 | | `~/.dsh/profiles/{headless,web}/cordis.patch.yml` | 默认模型 `zai-coding/glm-5.3-flash`、persona(原 SOUL.md)、手机 MCP 接入 | | 54 | | `~/.dsh/profiles/{headless,web}/cordis.patch.yml` | 默认模型 `zai-coding/glm-5.3-flash`、persona(原 SOUL.md)、手机 MCP 接入 | |
| 55 | | `/data/adb/service.d/{crond,dsh-web,wechat-reply-watcher}.sh` | Magisk 开机自启(Hermes gateway 相关脚本已挪到 `/data/adb/retired/`) | | 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 | **微信自动回复引擎**: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。 | 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,8 +6,6 @@ D=/data/data/com.termux/files/home/pixelphone/scripts | ||
| 6 | L=/data/data/com.termux/files/home/pixelphone/cron-logs | 6 | L=/data/data/com.termux/files/home/pixelphone/cron-logs |
| 7 | */5 * * * * python3 $D/reply-sender.py >> $L/reply-sender.log 2>&1 | 7 | */5 * * * * python3 $D/reply-sender.py >> $L/reply-sender.log 2>&1 |
| 8 | */10 * * * * bash $D/wechat-viewer-watchdog.sh >> $L/viewer-watchdog.log 2>&1 | 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 | 0 */6 * * * bash $D/boss-resume.sh >> $L/boss-resume.log 2>&1 | 9 | 0 */6 * * * bash $D/boss-resume.sh >> $L/boss-resume.log 2>&1 |
| 12 | 0 6 * * * bash $D/gate-audit-cron.sh >> $L/gate-audit.log 2>&1 | 10 | 0 6 * * * bash $D/gate-audit-cron.sh >> $L/gate-audit.log 2>&1 |
| 13 | # TGO 日报链(取代 Hermes 调度器):01 生成 / 02 出图 / 03 发送;失败下个整点重试(窗口内每 10 分钟) | 11 | # TGO 日报链(取代 Hermes 调度器):01 生成 / 02 出图 / 03 发送;失败下个整点重试(窗口内每 10 分钟) |
| @@ -18,3 +16,4 @@ L=/data/data/com.termux/files/home/pixelphone/cron-logs | @@ -18,3 +16,4 @@ L=/data/data/com.termux/files/home/pixelphone/cron-logs | ||
| 18 | 0 * * * * pgrep -f tgo_summarize_batch >/dev/null || python3 $D/tgo_summarize_batch.py >> $L/tgo-summary.log 2>&1 | 16 | 0 * * * * pgrep -f tgo_summarize_batch >/dev/null || python3 $D/tgo_summarize_batch.py >> $L/tgo-summary.log 2>&1 |
| 19 | # 原图保存 A+B(旧调度器定频 02/06/10/14/18/22 点,放 :15 避开 02:00 出图;约30分钟接管屏幕,锁屏/微信未起自动跳过) | 17 | # 原图保存 A+B(旧调度器定频 02/06/10/14/18/22 点,放 :15 避开 02:00 出图;约30分钟接管屏幕,锁屏/微信未起自动跳过) |
| 20 | 15 2,6,10,14,18,22 * * * bash $D/orig-save.sh >> $L/orig-save.log 2>&1 | 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) -> str: | @@ -309,6 +309,7 @@ def build_media_section(date: str, prof: dict = None) -> str: | ||
| 309 | import html as _html | 309 | import html as _html |
| 310 | parts.append('<div class="media-grid">') | 310 | parts.append('<div class="media-grid">') |
| 311 | for m in imgs: | 311 | for m in imgs: |
| 312 | + u = _url(m) | ||
| 312 | # meta 行:发送人 · HH:MM · 群名(2026-10-08 用户要求补充显示) | 313 | # meta 行:发送人 · HH:MM · 群名(2026-10-08 用户要求补充显示) |
| 313 | bits = [] | 314 | bits = [] |
| 314 | if (m.get("sender") or "").strip(): | 315 | if (m.get("sender") or "").strip(): |
| @@ -325,24 +326,39 @@ def build_media_section(date: str, prof: dict = None) -> str: | @@ -325,24 +326,39 @@ def build_media_section(date: str, prof: dict = None) -> str: | ||
| 325 | if cap else "") | 326 | if cap else "") |
| 326 | parts.append( | 327 | parts.append( |
| 327 | f'<div class="media-img-cell">' | 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 | f'{meta_html}{cap_html}</div>' | 331 | f'{meta_html}{cap_html}</div>' |
| 331 | ) | 332 | ) |
| 332 | parts.append("</div>") | 333 | parts.append("</div>") |
| 333 | if vids: | 334 | if vids: |
| 335 | + import html as _html | ||
| 334 | parts.append('<div class="media-videos">') | 336 | parts.append('<div class="media-videos">') |
| 335 | for m in vids: | 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 | parts.append("</div>") | 355 | parts.append("</div>") |
| 340 | if files: | 356 | if files: |
| 341 | import html as _html | 357 | import html as _html |
| 342 | parts.append('<ul class="media-files">') | 358 | parts.append('<ul class="media-files">') |
| 343 | for m in files: | 359 | for m in files: |
| 344 | title = _html.escape(m.get("title") or "文件") | 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 | # meta:发送人 · HH:MM · 大小 · 群名(sender/create_time 2026-10-08 新增) | 362 | # meta:发送人 · HH:MM · 大小 · 群名(sender/create_time 2026-10-08 新增) |
| 347 | bits = [b for b in [ | 363 | bits = [b for b in [ |
| 348 | (m.get("sender") or "").strip() or None, | 364 | (m.get("sender") or "").strip() or None, |
device/wechat-viewer/app.py
| @@ -245,6 +245,15 @@ def _wechat_share_des(url: str) -> str: | @@ -245,6 +245,15 @@ def _wechat_share_des(url: str) -> str: | ||
| 245 | return "" | 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 | def _maybe_dsh(job_type: str, cmd: list[str]) -> list[str]: | 257 | def _maybe_dsh(job_type: str, cmd: list[str]) -> list[str]: |
| 249 | try: | 258 | try: |
| 250 | cfg = json.loads(_JOB_ENGINE_FILE.read_text()) | 259 | cfg = json.loads(_JOB_ENGINE_FILE.read_text()) |
| @@ -4714,20 +4723,20 @@ def api_doctor(): | @@ -4714,20 +4723,20 @@ def api_doctor(): | ||
| 4714 | checks["cron"] = {"ok": False, "detail": str(e)[:100]} | 4723 | checks["cron"] = {"ok": False, "detail": str(e)[:100]} |
| 4715 | all_ok = False | 4724 | all_ok = False |
| 4716 | 4725 | ||
| 4717 | - # 7. Hermes CLI 可达性 | 4726 | + # 7. dsh CLI 可达性 |
| 4718 | try: | 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 | "detail": result.stdout.strip()[:80] if result.returncode == 0 | 4731 | "detail": result.stdout.strip()[:80] if result.returncode == 0 |
| 4723 | else f"exit={result.returncode} {result.stderr[:80]}"} | 4732 | else f"exit={result.returncode} {result.stderr[:80]}"} |
| 4724 | if result.returncode != 0: | 4733 | if result.returncode != 0: |
| 4725 | all_ok = False | 4734 | all_ok = False |
| 4726 | except FileNotFoundError: | 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 | all_ok = False | 4737 | all_ok = False |
| 4729 | except Exception as e: | 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 | all_ok = False | 4740 | all_ok = False |
| 4732 | 4741 | ||
| 4733 | # 8. 模型 API(快速连通性检查 — 仅检查配置文件,不打实际 API 调用) | 4742 | # 8. 模型 API(快速连通性检查 — 仅检查配置文件,不打实际 API 调用) |
| @@ -5132,16 +5141,16 @@ def call_hermes(user_input: str, timeout: int = 120) -> str: | @@ -5132,16 +5141,16 @@ def call_hermes(user_input: str, timeout: int = 120) -> str: | ||
| 5132 | """ | 5141 | """ |
| 5133 | try: | 5142 | try: |
| 5134 | r = subprocess.run( | 5143 | r = subprocess.run( |
| 5135 | - ["hermes", "chat", "-q", user_input, "-Q"], | 5144 | + _dsh_chat_cmd(user_input), |
| 5136 | capture_output=True, text=True, | 5145 | capture_output=True, text=True, |
| 5137 | timeout=timeout, | 5146 | timeout=timeout, |
| 5138 | cwd="/data/data/com.termux/files/home" | 5147 | cwd="/data/data/com.termux/files/home" |
| 5139 | ) | 5148 | ) |
| 5140 | ai_reply = (r.stdout or "").strip() | 5149 | ai_reply = (r.stdout or "").strip() |
| 5141 | if not ai_reply and r.stderr: | 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 | elif not ai_reply: | 5152 | elif not ai_reply: |
| 5144 | - ai_reply = "[Hermes 返回空回复]" | 5153 | + ai_reply = "[dsh 返回空回复]" |
| 5145 | return ai_reply | 5154 | return ai_reply |
| 5146 | except subprocess.TimeoutExpired: | 5155 | except subprocess.TimeoutExpired: |
| 5147 | return "[Hermes:gate=TIMEOUT] 调用超时" | 5156 | return "[Hermes:gate=TIMEOUT] 调用超时" |
| @@ -5167,10 +5176,7 @@ def call_hermes_stream(user_input: str, timeout: int = 120, profile: str = ""): | @@ -5167,10 +5176,7 @@ def call_hermes_stream(user_input: str, timeout: int = 120, profile: str = ""): | ||
| 5167 | from threading import Timer | 5176 | from threading import Timer |
| 5168 | proc = None | 5177 | proc = None |
| 5169 | try: | 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 | proc = subprocess.Popen( | 5180 | proc = subprocess.Popen( |
| 5175 | cmd, | 5181 | cmd, |
| 5176 | stdout=subprocess.PIPE, stderr=subprocess.PIPE, | 5182 | stdout=subprocess.PIPE, stderr=subprocess.PIPE, |
| @@ -5454,7 +5460,7 @@ def api_regenerate(conv_id): | @@ -5454,7 +5460,7 @@ def api_regenerate(conv_id): | ||
| 5454 | 5460 | ||
| 5455 | def generate(): | 5461 | def generate(): |
| 5456 | proc = subprocess.Popen( | 5462 | proc = subprocess.Popen( |
| 5457 | - ["hermes", "ask", hermes_input], | 5463 | + _dsh_chat_cmd(hermes_input), |
| 5458 | stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, | 5464 | stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, |
| 5459 | env={**os.environ, "NO_COLOR": "1"} | 5465 | env={**os.environ, "NO_COLOR": "1"} |
| 5460 | ) | 5466 | ) |
| @@ -6007,10 +6013,7 @@ def _scan_profiles(): | @@ -6007,10 +6013,7 @@ def _scan_profiles(): | ||
| 6007 | info = _scan_one(HERMES_HOME, "default") | 6013 | info = _scan_one(HERMES_HOME, "default") |
| 6008 | # Try to get description from hermes profile show | 6014 | # Try to get description from hermes profile show |
| 6009 | try: | 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 | if r.returncode == 0: | 6017 | if r.returncode == 0: |
| 6015 | for line in r.stdout.splitlines(): | 6018 | for line in r.stdout.splitlines(): |
| 6016 | if line.lower().startswith("model:"): | 6019 | if line.lower().startswith("model:"): |
| @@ -6131,7 +6134,8 @@ def api_profile_create(): | @@ -6131,7 +6134,8 @@ def api_profile_create(): | ||
| 6131 | if target_dir.exists(): | 6134 | if target_dir.exists(): |
| 6132 | return jsonify({"ok": False, "error": f"profile '{name}' already exists"}), 409 | 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 | if description: | 6139 | if description: |
| 6136 | cmd.extend(["--description", description]) | 6140 | cmd.extend(["--description", description]) |
| 6137 | 6141 | ||
| @@ -6171,7 +6175,8 @@ def api_profile_delete(name): | @@ -6171,7 +6175,8 @@ def api_profile_delete(name): | ||
| 6171 | # Maybe it's a hermes profile not in profiles/ dir | 6175 | # Maybe it's a hermes profile not in profiles/ dir |
| 6172 | pass | 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 | r = subprocess.run(cmd, capture_output=True, text=True, timeout=30) | 6180 | r = subprocess.run(cmd, capture_output=True, text=True, timeout=30) |
| 6176 | if r.returncode != 0: | 6181 | if r.returncode != 0: |
| 6177 | return jsonify({"ok": False, "error": r.stderr.strip() or r.stdout.strip() or "delete failed"}), 500 | 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,7 +6197,7 @@ def api_profile_chat(name): | ||
| 6192 | if not message: | 6197 | if not message: |
| 6193 | return jsonify({"ok": False, "error": "message is required"}), 400 | 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 | r = subprocess.run(cmd, capture_output=True, text=True, timeout=60) | 6201 | r = subprocess.run(cmd, capture_output=True, text=True, timeout=60) |
| 6197 | if r.returncode != 0: | 6202 | if r.returncode != 0: |
| 6198 | err = r.stderr.strip() or r.stdout.strip() or "chat failed" | 6203 | err = r.stderr.strip() or r.stdout.strip() or "chat failed" |