Commit 36ade1cb02e8542b4b5b4df23c795caf47c22e9f
1 parent
5ab66253
feat(dsh): GLM Coding Plan 额度用尽自动切 DeepSeek,恢复后切回;加 codex/claude 订阅引擎
- fallback.sh:GLM 报额度类错误(1308/1310/1316-1321/1309/1113)且无产出时改用 DeepSeek 重跑; 冷却到 next_flush_time,查不到至少 5h;瞬时 1302/1305 只冷却 60s。test-fallback.sh 为本地检查 - run-headless / run-reply-member / dsh-fb.sh(viewer 用) 接入;patches/deepseek.yml = deepseek-flash(V4.1 Flash) - run-codex.sh / run-claude.sh + orchestrator .reply_engine、viewer .job_engine 引擎切换 (实测:手机在大陆出口,Anthropic 403 地区限制,claude 引擎暂不可用) - patches/claude-sub.yml:订阅 OAuth token 直连被服务端拒,仅留作记录 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Showing
11 changed files
with
262 additions
and
10 deletions
device/dsh/dsh-fb.sh
0 → 100755
| 1 | +#!/data/data/com.termux/files/usr/bin/bash | |
| 2 | +# wechat-viewer 用:等价于 `dsh headless <args>`,但 GLM 额度用尽时自动切 DeepSeek(见 fallback.sh)。 | |
| 3 | +# 用法:dsh-fb.sh --patch no-phone.yml "prompt" | |
| 4 | +. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/fallback.sh" | |
| 5 | +dsh_with_fallback /data/data/com.termux/files/usr/bin/dsh headless @PATCH@ "$@" | ... | ... |
device/dsh/fallback.sh
0 → 100644
| 1 | +# source 后用:dsh_with_fallback <命令...>,命令里的 @PATCH@ 是 DeepSeek 补丁的插入点。 | |
| 2 | +# 先走默认模型(GLM Coding Plan);它额度用尽/限流且没产出答案时,改用 DeepSeek 重跑, | |
| 3 | +# 并写 $DSH_FB_STATE=冷却截止 epoch:冷却期内直接走 DeepSeek,冷却过后下一次调用先探测 GLM,恢复了就自动切回。 | |
| 4 | +# 错误码依据智谱文档 docs.bigmodel.cn/cn/api/api-code(均为 HTTP 429): | |
| 5 | +# 额度类 1308/1310/1316-1321(响应带 next_flush_time=限额重置时间)、1309 套餐到期、1113 欠费 | |
| 6 | +# → 冷却到 next_flush_time(+60s);解析不到时间 = 至少 5 小时(MIN_COOLDOWN) | |
| 7 | +# 瞬时类 1302 速率限制 / 1305 模型过载(文档:稍后重试)→ 只冷却 60s,别因瞬时 429 锁 5 小时 | |
| 8 | +# ponytail: 只在「没产出答案」时重跑(防止 admin 任务已执行过工具又被重放一遍); | |
| 9 | +# 错误码没覆盖到的 RATE_LIMIT/429 按瞬时类处理,上限=未知的长期限流会每 60s 白打一次 GLM; | |
| 10 | +# next_flush_time 的确切格式文档没写,按「秒/毫秒时间戳、或日期时间串(无时区=北京时间)」解析,真机见到后再收紧。 | |
| 11 | +DSH_FB_STATE="${DSH_FB_STATE:-$HOME/dsh/.glm_down_until}" | |
| 12 | +DSH_FB_MIN_COOLDOWN="${DSH_FB_MIN_COOLDOWN:-18000}" # 额度类查不到重置时间时的最短冷却=5 小时 | |
| 13 | +DSH_FB_MARGIN="${DSH_FB_MARGIN:-60}" # 在报错给的重置时间上多等一点 | |
| 14 | +DSH_FB_SHORT="${DSH_FB_SHORT:-60}" # 瞬时限流的冷却秒数 | |
| 15 | +DSH_FB_PATCH="${DSH_FB_PATCH:-$HOME/dsh/patches/deepseek.yml}" | |
| 16 | +_N='(^|[^0-9])'; _M='([^0-9]|$)' # 错误码前后不能是数字(macOS grep 不支持 \b) | |
| 17 | +DSH_FB_QUOTA="${DSH_FB_QUOTA:-QUOTA|next_flush_time|使用上限|${_N}(1308|1310|1309|1113|131[6-9]|132[01])${_M}}" | |
| 18 | +DSH_FB_TRANSIENT="${DSH_FB_TRANSIENT:-RATE_LIMIT|HTTP_429|${_N}(1302|1305)${_M}}" | |
| 19 | + | |
| 20 | +# $1=stderr 文件 → 输出额度类冷却截止 epoch | |
| 21 | +_dsh_fb_until() { | |
| 22 | + local now t; now=$(date +%s) | |
| 23 | + t=$(python3 - "$1" "$now" <<'PY' | |
| 24 | +import re, sys, calendar | |
| 25 | +txt, now = open(sys.argv[1], errors="replace").read(), int(sys.argv[2]) | |
| 26 | +DT = r"20\d\d-\d\d-\d\d[ T]\d\d:\d\d(?::\d\d)?(?:Z|[+-]\d\d:?\d\d)?" | |
| 27 | +def dt(v): # 日期时间串 → epoch;无时区按北京时间 | |
| 28 | + m = re.search(r"(20\d\d)-(\d\d)-(\d\d)[ T](\d\d):(\d\d)(?::(\d\d))?(Z|[+-]\d\d:?\d\d)?", v) | |
| 29 | + if not m: return 0 | |
| 30 | + y, mo, d, h, mi, s = (int(x or 0) for x in m.groups()[:6]) | |
| 31 | + z, off = m.group(7), 8 * 3600 | |
| 32 | + if z == "Z": off = 0 | |
| 33 | + elif z: off = (1 if z[0] == "+" else -1) * (int(z[1:3]) * 3600 + int(z[-2:]) * 60) | |
| 34 | + return calendar.timegm((y, mo, d, h, mi, s)) - off | |
| 35 | +t = 0 | |
| 36 | +k = re.search(r"next_flush_time\W{0,4}(\d{9,14})", txt) # 数字时间戳(秒或毫秒) | |
| 37 | +if k: | |
| 38 | + t = int(k.group(1)); t = t // 1000 if t > 10**11 else t | |
| 39 | +if not t: | |
| 40 | + k = re.search(r"next_flush_time\W{0,4}(" + DT + ")", txt) # 日期时间串 | |
| 41 | + if k: t = dt(k.group(1)) | |
| 42 | +if not t: | |
| 43 | + m = re.findall(DT, txt) # 文案里的「将在 … 重置」 | |
| 44 | + if m: t = dt(m[-1]) | |
| 45 | +if not t: | |
| 46 | + r = re.search(r"retry[- ]after\D{0,3}(\d+)", txt, re.I) | |
| 47 | + if r: t = now + int(r.group(1)) | |
| 48 | +if now < t <= now + 8 * 86400: print(t) | |
| 49 | +PY | |
| 50 | +) | |
| 51 | + if [ -n "$t" ]; then echo $(( t + DSH_FB_MARGIN )); else echo $(( now + DSH_FB_MIN_COOLDOWN )); fi | |
| 52 | +} | |
| 53 | + | |
| 54 | +dsh_with_fallback() { | |
| 55 | + local x pid rc down=0 glm=() ds=() out err | |
| 56 | + for x in "$@"; do | |
| 57 | + if [ "$x" = "@PATCH@" ]; then ds+=(--patch "$DSH_FB_PATCH"); else glm+=("$x"); ds+=("$x"); fi | |
| 58 | + done | |
| 59 | + [ -r "$DSH_FB_STATE" ] && down=$(cat "$DSH_FB_STATE" 2>/dev/null || echo 0) | |
| 60 | + export TMPDIR="${PREFIX:-/data/data/com.termux/files/usr}/tmp" # Android 环境里 TMPDIR 常被预设成不可写的 /data/local/tmp,必须覆盖 | |
| 61 | + out=$(mktemp) && err=$(mktemp) || exec "${glm[@]}" # 建不了临时文件就退回原行为,别让切换逻辑弄坏主链路 | |
| 62 | + trap 'kill "$pid" 2>/dev/null' TERM INT # 不再 exec,转发信号,超时强杀仍能杀到 dsh | |
| 63 | + if [ "$(date +%s)" -ge "${down:-0}" ]; then | |
| 64 | + "${glm[@]}" >"$out" 2>"$err" & pid=$!; wait "$pid"; rc=$? | |
| 65 | + if [ "$rc" -ne 0 ] && [ ! -s "$out" ]; then | |
| 66 | + if grep -qE "$DSH_FB_QUOTA" "$err"; then down=$(_dsh_fb_until "$err") | |
| 67 | + elif grep -qE "$DSH_FB_TRANSIENT" "$err"; then down=$(( $(date +%s) + DSH_FB_SHORT )) | |
| 68 | + else down=""; fi | |
| 69 | + if [ -n "$down" ]; then | |
| 70 | + echo "$down" > "$DSH_FB_STATE" | |
| 71 | + echo "[fallback] GLM 额度/限流,切 DeepSeek 至 epoch $down" >&2 | |
| 72 | + "${ds[@]}" >"$out" 2>"$err" & pid=$!; wait "$pid"; rc=$? | |
| 73 | + fi | |
| 74 | + fi | |
| 75 | + else | |
| 76 | + "${ds[@]}" >"$out" 2>"$err" & pid=$!; wait "$pid"; rc=$? | |
| 77 | + fi | |
| 78 | + cat "$err" >&2; cat "$out"; rm -f "$out" "$err"; return "$rc" | |
| 79 | +} | ... | ... |
device/dsh/patches/claude-sub.yml
0 → 100644
| 1 | +# Claude Code 订阅(OAuth token,sk-ant-oat…)作为 dsh provider; | |
| 2 | +# 用法:run-headless.sh --patch ~/dsh/patches/claude-sub.yml "任务" | |
| 3 | +# token 来自 ~/dsh/.secrets.env 的 CLAUDE_CODE_OAUTH_TOKEN(run-headless.sh 会 source) | |
| 4 | +- id: llm-pi-ai | |
| 5 | + config: | |
| 6 | + providers: | |
| 7 | + anthropic-sub: | |
| 8 | + displayName: Claude (subscription OAuth) | |
| 9 | + apiKeyEnv: CLAUDE_CODE_OAUTH_TOKEN | |
| 10 | + api: anthropic-messages | |
| 11 | + baseURL: https://api.anthropic.com | |
| 12 | + models: | |
| 13 | + - id: claude-sonnet-5-5 | |
| 14 | + contextWindow: 200000 | |
| 15 | + maxTokens: 32768 | |
| 16 | +- id: agent-default-model | |
| 17 | + config: | |
| 18 | + provider: anthropic-sub | |
| 19 | + model: claude-sonnet-5-5 | ... | ... |
device/dsh/patches/deepseek.yml
0 → 100644
| 1 | +# DeepSeek 官方 API 作为 dsh provider(走 dsh-llm-deepseek 直连插件,路由 deepseek-official)。 | |
| 2 | +# 用法:run-headless.sh --patch ~/dsh/patches/deepseek.yml "任务" | |
| 3 | +# key 来自 ~/dsh/.secrets.env 的 DEEPSEEK_API_KEY(run-headless.sh 会 source);国内网络可直连。 | |
| 4 | +# 模型:deepseek-flash(文本+图片,默认)/ deepseek-v4-pro(纯文本)。 | |
| 5 | +- insert: | |
| 6 | + - id: llm-deepseek | |
| 7 | + name: '@deepseek-ai/dsh-llm-deepseek-api-key' | |
| 8 | + config: | |
| 9 | + apiKeyEnv: DEEPSEEK_API_KEY | |
| 10 | +- id: agent-default-model | |
| 11 | + config: | |
| 12 | + provider: deepseek-official | |
| 13 | + model: deepseek-flash | ... | ... |
device/dsh/run-claude.sh
0 → 100755
| 1 | +#!/data/data/com.termux/files/usr/bin/bash | |
| 2 | +# Claude Code 订阅引擎(dsh 的替代)。用法:run-claude.sh <admin|member|chat> "prompt" | |
| 3 | +# 认证:Mac 上 `claude setup-token`(官方的 headless 长效 OAuth token,走订阅), | |
| 4 | +# 把 CLAUDE_CODE_OAUTH_TOKEN=... 写进 ~/dsh/.secrets.env(不进 git)。也可在手机上直接 `claude` 登录。 | |
| 5 | +# stdout=最终答案,与 dsh headless 同形。member/chat 是陌生人/抓取内容=不可信输入, | |
| 6 | +# 用 --tools "" 彻底关掉全部工具(比 codex 的 read-only 沙箱更严)。 | |
| 7 | +set -euo pipefail | |
| 8 | +export PREFIX="${PREFIX:-/data/data/com.termux/files/usr}" | |
| 9 | +export HOME="${HOME:-/data/data/com.termux/files/home}" | |
| 10 | +export PATH="$PREFIX/bin:$PATH" | |
| 11 | +export TMPDIR="$PREFIX/tmp"; mkdir -p "$TMPDIR" | |
| 12 | +# 官方 claude 2.1.150+ 只发原生二进制(无 android),固定在纯 JS 的 2.1.100,禁止自升级 | |
| 13 | +export DISABLE_AUTOUPDATER=1 | |
| 14 | +# Android 没有 /usr/bin/env,cli.js 的 shebang 直接执行会 ENOENT,所以显式用 node 跑 | |
| 15 | +claude() { exec node "$PREFIX/lib/node_modules/@anthropic-ai/claude-code/cli.js" "$@"; } | |
| 16 | +SECRETS="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/.secrets.env" | |
| 17 | +if [ -f "$SECRETS" ]; then set -a; source "$SECRETS"; set +a; fi | |
| 18 | + | |
| 19 | +MODE="$1"; P="$2" | |
| 20 | +case "$MODE" in | |
| 21 | + admin) claude -p "$P" --dangerously-skip-permissions ;; | |
| 22 | + member) | |
| 23 | + SOUL="$HOME/pixelphone/SOUL.md" | |
| 24 | + [ -r "$SOUL" ] && P="$(cat "$SOUL") | |
| 25 | + | |
| 26 | +$P" | |
| 27 | + claude -p "$P" --tools "" ;; | |
| 28 | + chat) claude -p "$P" --tools "" ;; | |
| 29 | + *) echo "mode must be admin|member|chat" >&2; exit 2 ;; | |
| 30 | +esac | ... | ... |
device/dsh/run-codex.sh
0 → 100755
| 1 | +#!/data/data/com.termux/files/usr/bin/bash | |
| 2 | +# Codex 订阅引擎(dsh 的替代)。用法:run-codex.sh <admin|member|chat> "prompt" | |
| 3 | +# 认证:手机上 `codex login --device-auth`(ChatGPT 订阅),token 落在 ~/.codex/auth.json,不进 git。 | |
| 4 | +# stdout=最终答案(codex exec 的进度走 stderr),与 dsh headless 同形,调用方不用改解析。 | |
| 5 | +# 本机无 bwrap/Landlock:admin 与 dsh 一致免沙箱;member/chat 是陌生人/抓取内容=不可信输入, | |
| 6 | +# 用 read-only 沙箱 + 无网络(ponytail: codex 无「零工具」开关,read-only 仍能读文件;上限=可读到手机上的文件, | |
| 7 | +# 若在意就保持 member 走 dsh no-tools)。 | |
| 8 | +set -euo pipefail | |
| 9 | +export PREFIX="${PREFIX:-/data/data/com.termux/files/usr}" | |
| 10 | +export HOME="${HOME:-/data/data/com.termux/files/home}" | |
| 11 | +export PATH="$PREFIX/bin:$PATH" | |
| 12 | +export TMPDIR="$PREFIX/tmp"; mkdir -p "$TMPDIR" | |
| 13 | + | |
| 14 | +MODE="$1"; P="$2" | |
| 15 | +case "$MODE" in | |
| 16 | + admin) | |
| 17 | + exec codex exec --skip-git-repo-check --dangerously-bypass-approvals-and-sandbox "$P" ;; | |
| 18 | + member) | |
| 19 | + SOUL="$HOME/pixelphone/SOUL.md" | |
| 20 | + [ -r "$SOUL" ] && P="$(cat "$SOUL") | |
| 21 | + | |
| 22 | +$P" | |
| 23 | + exec codex exec --skip-git-repo-check --sandbox read-only "$P" ;; | |
| 24 | + chat) | |
| 25 | + exec codex exec --skip-git-repo-check --sandbox read-only "$P" ;; | |
| 26 | + *) echo "mode must be admin|member|chat" >&2; exit 2 ;; | |
| 27 | +esac | ... | ... |
device/dsh/run-headless.sh
| ... | ... | @@ -20,4 +20,6 @@ if [ -f "$SECRETS_FILE" ]; then |
| 20 | 20 | set +a |
| 21 | 21 | fi |
| 22 | 22 | |
| 23 | -exec node --expose-internals "$DEPLOY_DIR/node_modules/.bin/dsh" --profile headless "$@" | |
| 23 | +# shellcheck disable=SC1091 | |
| 24 | +source "$DEPLOY_DIR/fallback.sh" # GLM 额度用尽自动切 DeepSeek,恢复后切回 | |
| 25 | +dsh_with_fallback node --expose-internals "$DEPLOY_DIR/node_modules/.bin/dsh" --profile headless @PATCH@ "$@" | ... | ... |
device/dsh/run-reply-member.sh
| ... | ... | @@ -8,4 +8,5 @@ SOUL=/data/data/com.termux/files/home/pixelphone/SOUL.md |
| 8 | 8 | P="$1"; [ -r "$SOUL" ] && P="$(cat "$SOUL") |
| 9 | 9 | |
| 10 | 10 | $1" |
| 11 | -exec /data/data/com.termux/files/usr/bin/dsh headless --patch /data/data/com.termux/files/home/dsh/patches/no-tools.yml "$P" | |
| 11 | +. /data/data/com.termux/files/home/dsh/fallback.sh # GLM 额度用尽自动切 DeepSeek,恢复后切回 | |
| 12 | +dsh_with_fallback /data/data/com.termux/files/usr/bin/dsh headless @PATCH@ --patch /data/data/com.termux/files/home/dsh/patches/no-tools.yml "$P" | ... | ... |
device/dsh/test-fallback.sh
0 → 100755
| 1 | +#!/usr/bin/env bash | |
| 2 | +# 本地可跑的检查:假 dsh 模拟 GLM 额度用尽 → 应切 DeepSeek;冷却过后应切回 GLM。 | |
| 3 | +set -u | |
| 4 | +D="$(cd "$(dirname "$0")" && pwd)"; T=$(mktemp -d) | |
| 5 | +export DSH_FB_STATE="$T/state" DSH_FB_MIN_COOLDOWN=2 DSH_FB_MARGIN=0 HOME="$T" | |
| 6 | +cat > "$T/fake" <<'F' | |
| 7 | +#!/usr/bin/env bash | |
| 8 | +if [[ "$*" == *deepseek* ]]; then echo "answer-from-deepseek"; exit 0; fi | |
| 9 | +if [ -e "$GLM_DOWN" ]; then echo "dsh: ${GLM_MSG:-QUOTA usage limit}" >&2; exit 1; fi | |
| 10 | +echo "answer-from-glm" | |
| 11 | +F | |
| 12 | +chmod +x "$T/fake"; export GLM_DOWN="$T/down" | |
| 13 | +. "$D/fallback.sh"; DSH_FB_PATCH="$T/deepseek.yml" | |
| 14 | +run() { dsh_with_fallback "$T/fake" @PATCH@ task 2>/dev/null; } | |
| 15 | +[ "$(run)" = answer-from-glm ] || { echo "FAIL 1 平时应走 GLM"; exit 1; } | |
| 16 | +touch "$GLM_DOWN" | |
| 17 | +[ "$(run)" = answer-from-deepseek ] || { echo "FAIL 2 额度用尽应切 DeepSeek"; exit 1; } | |
| 18 | +rm "$GLM_DOWN" | |
| 19 | +[ "$(run)" = answer-from-deepseek ] || { echo "FAIL 3 冷却期内应继续 DeepSeek"; exit 1; } | |
| 20 | +sleep 3 | |
| 21 | +[ "$(run)" = answer-from-glm ] || { echo "FAIL 4 冷却后 GLM 恢复应切回"; exit 1; } | |
| 22 | +# 额度类:冷却取 next_flush_time(3 种格式);取不到=MIN_COOLDOWN;瞬时类 1302=SHORT | |
| 23 | +export DSH_FB_MIN_COOLDOWN=100 DSH_FB_SHORT=7 | |
| 24 | +chk() { # chk <名字> <GLM_MSG> <最小秒> <最大秒> | |
| 25 | + rm -f "$DSH_FB_STATE"; touch "$GLM_DOWN"; export GLM_MSG="$2"; run >/dev/null | |
| 26 | + d=$(( $(cat "$DSH_FB_STATE") - $(date +%s) )) | |
| 27 | + [ "$d" -ge "$3" ] && [ "$d" -le "$4" ] || { echo "FAIL $1 期望 $3~$4s,实得 +${d}s"; exit 1; } | |
| 28 | +} | |
| 29 | +now=$(date +%s) | |
| 30 | +chk "秒级时间戳" "1308 {\"next_flush_time\":$((now+4))}" 1 8 | |
| 31 | +chk "毫秒时间戳" "1310 {\"next_flush_time\": $(( (now+4)*1000 ))}" 1 8 | |
| 32 | +chk "日期串(北京时间)" "1308 已达到使用上限 next_flush_time: $(python3 -c 'import time;print(time.strftime("%Y-%m-%d %H:%M:%S",time.gmtime(time.time()+8*3600+4)))')" 1 8 | |
| 33 | +chk "额度类无时间=最短冷却" "1308 已达到 5 小时的使用上限" 95 105 | |
| 34 | +chk "瞬时 1302=短冷却" "1302 您的账户已达到速率限制" 5 9 | |
| 35 | +rm -f "$DSH_FB_STATE"; touch "$GLM_DOWN"; export GLM_MSG="boom"; run >/dev/null; [ ! -e "$DSH_FB_STATE" ] || { echo "FAIL 未知错误不应切换"; exit 1; } | |
| 36 | +echo PASS | ... | ... |
device/pixelphone/wechat_reply/wechat_reply_orchestrator.py
| ... | ... | @@ -28,6 +28,16 @@ CIRCLE_DB = "/data/data/com.termux/files/home/wechat-viewer/circle.db" |
| 28 | 28 | EVENT_BUS = "/data/data/com.termux/files/home/pixelphone/event_bus" |
| 29 | 29 | DSH_RUNNER = "/data/data/com.termux/files/home/dsh/run-headless.sh" # admin:主人本人,全工具 |
| 30 | 30 | MEMBER_RUNNER = "/data/data/com.termux/files/home/dsh/run-reply-member.sh" # member:陌生人输入,无工具/只读/带人设 |
| 31 | +CODEX_RUNNER = "/data/data/com.termux/files/home/dsh/run-codex.sh" # Codex 订阅引擎:run-codex.sh <admin|member> prompt | |
| 32 | +CLAUDE_RUNNER = "/data/data/com.termux/files/home/dsh/run-claude.sh" # Claude Code 订阅引擎,用法同上 | |
| 33 | +ENGINE_FILE = "/data/data/com.termux/files/home/pixelphone/.reply_engine" # 内容 "codex"/"claude" 即切;缺省/出错=dsh,每次现读免重启 | |
| 34 | + | |
| 35 | + | |
| 36 | +def _engine(): | |
| 37 | + try: | |
| 38 | + return open(ENGINE_FILE).read().strip() or "dsh" | |
| 39 | + except OSError: | |
| 40 | + return "dsh" | |
| 31 | 41 | ADMIN_SKILL = "/data/data/com.termux/files/home/.dsh/skills/reply-task-executor/SKILL.md" |
| 32 | 42 | |
| 33 | 43 | TERMUX_UID = 10329 |
| ... | ... | @@ -165,7 +175,9 @@ def generate(context_text, event=None, role="member"): |
| 165 | 175 | f"遇到不确定的细节不要停下来等确认:按最合理的假设继续执行," |
| 166 | 176 | f"把假设和需要火星人拍板的事项列在报告末尾" |
| 167 | 177 | f"(涉及钱/承诺/约时间的除外,这类一律标记「需火星人确认」)。\n" |
| 168 | - f"任务完成后,把结果报告写入 {result_file}。\n" | |
| 178 | + f"总步数控制在 20 步内;做完第一轮核查就立刻把当前结论写入 {result_file}(哪怕不完整)," | |
| 179 | + f"之后每有新发现覆盖更新——超时会被强杀,没落盘的内容全部丢失。\n" | |
| 180 | + f"任务完成后,把最终结果报告写入 {result_file}。\n" | |
| 169 | 181 | f"报告用微信纯文本:短段落+简短列表,不要 Markdown 表格/井号标题/代码块。" |
| 170 | 182 | ) |
| 171 | 183 | else: |
| ... | ... | @@ -178,14 +190,14 @@ def generate(context_text, event=None, role="member"): |
| 178 | 190 | # dsh 没有 -s skill 机制:让它自己读 SKILL.md。admin 是主人本人,手机 MCP 放开; |
| 179 | 191 | # 直发仍被 WECHAT_REPLY_NO_DIRECT_SEND 闸门挡住(env 传给 MCP 子进程),发送只走 orchestrator。 |
| 180 | 192 | prompt = f"先用文件工具读取并严格遵循 skill:{ADMIN_SKILL}\n\n" + prompt |
| 181 | - cmd = [DSH_RUNNER, prompt] | |
| 193 | + cmd = {"codex": [CODEX_RUNNER, "admin", prompt], "claude": [CLAUDE_RUNNER, "admin", prompt]}.get(_engine(), [DSH_RUNNER, prompt]) | |
| 182 | 194 | else: |
| 183 | 195 | # 简单模式:零工具,纯粹文本补全 |
| 184 | 196 | prompt = ( |
| 185 | 197 | "【任务】下面是别人发到微信来的一条消息,请以你本人(火星人)的口吻,写一条你会真的发出去的" |
| 186 | 198 | "微信回复。只输出这条回复本身,不要解释、不要引号、不要用任何工具、不要真的发送。\n\n" |
| 187 | 199 | "【上下文】\n" + context_text) |
| 188 | - cmd = [MEMBER_RUNNER, prompt] | |
| 200 | + cmd = {"codex": [CODEX_RUNNER, "member", prompt], "claude": [CLAUDE_RUNNER, "member", prompt]}.get(_engine(), [MEMBER_RUNNER, prompt]) | |
| 189 | 201 | |
| 190 | 202 | env = dict(os.environ) |
| 191 | 203 | env.update({ |
| ... | ... | @@ -220,7 +232,18 @@ def generate(context_text, event=None, role="member"): |
| 220 | 232 | return draft, p.returncode, stderr, result_file if role == "admin" else "" |
| 221 | 233 | except subprocess.TimeoutExpired: |
| 222 | 234 | log("生成超时") |
| 223 | - return "", -1, "timeout", result_file if role == "admin" else "" | |
| 235 | + partial = "" | |
| 236 | + if role == "admin" and result_file: | |
| 237 | + # 超时被强杀:捞 dsh 已落盘的阶段性结论(结果文件,其次 skill 的 .plan.md),别让用户空等 | |
| 238 | + for f in (result_file, result_file + ".plan.md"): | |
| 239 | + try: | |
| 240 | + partial = open(f).read().strip()[:1500] | |
| 241 | + except OSError: | |
| 242 | + continue | |
| 243 | + if partial: | |
| 244 | + partial = "【任务超时没跑完,以下是阶段性结论】\n" + partial | |
| 245 | + break | |
| 246 | + return partial, -1, "timeout", result_file if role == "admin" else "" | |
| 224 | 247 | except Exception as e: |
| 225 | 248 | log(f"生成异常: {e}") |
| 226 | 249 | return "", -1, str(e)[:200], result_file if role == "admin" else "" | ... | ... |
device/wechat-viewer/app.py
| ... | ... | @@ -222,6 +222,11 @@ PHASE_TIER_PLAN = { |
| 222 | 222 | _JOB_ENGINE_FILE = Path(__file__).parent / ".job_engine" |
| 223 | 223 | _DSH_BIN = "/data/data/com.termux/files/usr/bin/dsh" |
| 224 | 224 | _DSH_NO_PHONE = "/data/data/com.termux/files/home/dsh/patches/no-phone.yml" |
| 225 | +_DSH_FB = "/data/data/com.termux/files/home/dsh/dsh-fb.sh" # = `dsh headless …`,GLM 额度用尽自动切 DeepSeek(见 dsh/fallback.sh) | |
| 226 | +_SUB_RUNNERS = { # .job_engine 值写 "codex"/"claude" 即走对应订阅 CLI(均为不可信内容的只读/无工具模式) | |
| 227 | + "codex": "/data/data/com.termux/files/home/dsh/run-codex.sh", | |
| 228 | + "claude": "/data/data/com.termux/files/home/dsh/run-claude.sh", | |
| 229 | +} | |
| 225 | 230 | _HERMES_SKILLS = Path("/data/data/com.termux/files/home/.dsh/skills") |
| 226 | 231 | |
| 227 | 232 | |
| ... | ... | @@ -248,16 +253,26 @@ def _wechat_share_des(url: str) -> str: |
| 248 | 253 | def _dsh_chat_cmd(user_input: str) -> list[str]: |
| 249 | 254 | """聊天界面用:dsh headless(stdout=最终答案,reasoning 在 stderr);关手机 MCP——对话内容不可信。 |
| 250 | 255 | ponytail: 无多轮会话/无 profile 概念,每问一次起一个 dsh(~10s+);要多轮可用 --session-id。""" |
| 251 | - return [_DSH_BIN, "headless", "--patch", _DSH_NO_PHONE, user_input] | |
| 256 | + if _engine_of("chat") in _SUB_RUNNERS: | |
| 257 | + return [_SUB_RUNNERS[_engine_of("chat")], "chat", user_input] | |
| 258 | + return [_DSH_FB, "--patch", _DSH_NO_PHONE, user_input] | |
| 252 | 259 | |
| 253 | 260 | |
| 254 | 261 | _PROFILE_GONE = "Hermes profile 管理已随 Hermes 下线(dsh 无 profile 概念)" |
| 255 | 262 | |
| 256 | 263 | |
| 257 | -def _maybe_dsh(job_type: str, cmd: list[str]) -> list[str]: | |
| 264 | +def _engine_of(job_type: str) -> str: | |
| 258 | 265 | try: |
| 259 | 266 | cfg = json.loads(_JOB_ENGINE_FILE.read_text()) |
| 260 | - if cfg.get(job_type, cfg.get("*", "hermes")) != "dsh" or cmd[:2] != ["hermes", "chat"]: | |
| 267 | + return cfg.get(job_type, cfg.get("*", "hermes")) | |
| 268 | + except Exception: | |
| 269 | + return "hermes" | |
| 270 | + | |
| 271 | + | |
| 272 | +def _maybe_dsh(job_type: str, cmd: list[str]) -> list[str]: | |
| 273 | + try: | |
| 274 | + engine = _engine_of(job_type) | |
| 275 | + if engine not in ("dsh", *_SUB_RUNNERS) or cmd[:2] != ["hermes", "chat"]: | |
| 261 | 276 | return cmd |
| 262 | 277 | prompt = cmd[cmd.index("-q") + 1] |
| 263 | 278 | flag = "-s" if "-s" in cmd else "--skills" |
| ... | ... | @@ -267,7 +282,9 @@ def _maybe_dsh(job_type: str, cmd: list[str]) -> list[str]: |
| 267 | 282 | if hit: |
| 268 | 283 | paths.append(str(hit)) |
| 269 | 284 | head = ("先用文件工具读取并严格遵循以下 skill:" + "、".join(paths) + "\n\n【任务】\n") if paths else "" |
| 270 | - return [_DSH_BIN, "headless", "--patch", _DSH_NO_PHONE, head + prompt] | |
| 285 | + if engine in _SUB_RUNNERS: | |
| 286 | + return [_SUB_RUNNERS[engine], "chat", head + prompt] | |
| 287 | + return [_DSH_FB, "--patch", _DSH_NO_PHONE, head + prompt] | |
| 271 | 288 | except Exception: |
| 272 | 289 | return cmd # 翻译失败不影响既有链路 |
| 273 | 290 | ... | ... |