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