diff --git a/oc-retry-guard/oc-retry-guard.ts b/oc-retry-guard/oc-retry-guard.ts new file mode 100644 index 0000000..11e63c6 --- /dev/null +++ b/oc-retry-guard/oc-retry-guard.ts @@ -0,0 +1,247 @@ +// oc-retry-guard —— 模型严重错误自动重试 server 插件(2026-10-19,路线B 自动重发) +// +// 背景:OC 二进制里流式解析层会抛 "OpenAI Chat received content after the +// finish reason" 这类严重错误(错误发生在响应体流解析阶段,HTTP 层是 200, +// 因此 http.response 钩子必须自己读 response body 来识别错误特征)。 +// 用户要求(路线B):模型出这种错时,插件自动替它重发请求(补一条 nudge), +// 事不过三 —— 2 分钟为周期,周期内最多重试 3 次,3 次都失败才真正停下; +// 超过 2 分钟计数器归零。 +// +// 技术链路(OC 2.0.27 二进制反查实锤): +// http.response 钩子 payload = {...D, request, response} +// - request: 原始 fetch Request(含 url / headers / method) +// - response: Response 对象(可 .clone().text() 读 body 识别错误特征) +// 钩子 return 改写后的 { ...D, request, response } 后,OC 用 +// Sy(we, ot.response) 消费改后的 response。 +// ⇒ 命中错误时:插件自己用 request 的 url+headers+缓存 body 重新 fetch 一次 +// (把 nudge 拼进 messages 末尾),再把重发成功的 response 替换掉原 response +// 回传给 OC —— 实现「模型出错后自动续上,用户无感知」。 +// +// 计数语义(事不过三 + 2 分钟周期): +// retryState: Map +// 命中错误时: +// now = Date.now() +// if (!state || now - state.firstAt > 120000) state = {count:0, firstAt:now} +// if (state.count >= 3) → 停下(替换 response 为原错误 response,记 stopped 日志) +// else state.count++ → 自动重发,记 retried 日志 +// 2 分钟后(now - firstAt > 120000)下一轮命中时自动归零重置。 +// +// 契约(与 ai-mcp / compaction-cn 同款 V2 插件铁律,勿破坏): +// - named export 必须是逗号表达式安全形态(返回 hooks 对象会崩) +// - default 导出 { id, setup },setup 必须返回 void、绝不抛 +// +// 部署(三份同步 + node --experimental-strip-types 校验): +// 源码真源 : D:\桌面\源码\oc_session\oc-retry-guard\oc-retry-guard.ts +// 本机生效 : C:\Users\ypshidifu\.config\opencode\plugins\oc-retry-guard.ts +// WSL 生效 : /root/.config/opencode/plugins/oc-retry-guard.ts + +import { appendFileSync, readFileSync } from "node:fs" +import { homedir } from "node:os" +import { join } from "node:path" + +const LOG_FILE = join(homedir(), ".config", "opencode", "oc-retry-guard.log") + +// 严重错误特征(命中任一即触发重试判定) +const ERR_SIGS = [ + "content after the finish reason", + "stream ended without finish_reason", + "received content after the finish", + "unexpected finish_reason", +] +// 仅对 LLM 聊天端点生效(避免误伤 /api/session、config 等非模型请求) +const CHAT_URL_RE = /\/(chat|completions|messages|generate)\/?|\/v1\// + +// nudge 文案(英文,对任何模型都通用;要求保持原语言、不译代码/路径/URL) +const NUDGE = + "\n" + + "Your previous output was cut off by an upstream error " + + '("received content after the finish reason"). Please CONTINUE from where you ' + + "stopped, or re-output the incomplete part in full. Keep your original reply " + + "language. Do not translate code, file paths, commands, URLs or error text.\n" + + "" + +// 窗口与上限 +const WINDOW_MS = 120000 // 2 分钟 +const MAX_RETRY = 3 // 事不过三 +// 单次重发预算(避免上游卡死拖垮 TUI) +const RETRY_FETCH_TIMEOUT_MS = 90000 + +// sessionID → 上一条聊天请求的原始 body(供命中错误时重建) +const lastReqBody = new Map() +// sessionID → { count, firstAt } +const retryState = new Map() + +function fileLog(msg: string) { + try { + appendFileSync(LOG_FILE, `[${new Date().toISOString()}] ${msg}\n`) + } catch { + /* 日志失败绝不阻塞主链路 */ + } +} + +function msgText(m: any): string { + try { + if (!m) return "" + if (typeof m.content === "string") return m.content + if (Array.isArray(m.content)) { + return m.content.map((c: any) => (c && typeof c.text === "string" ? c.text : "")).join("") + } + return "" + } catch { + return "" + } +} + +// 给请求体末尾追加一条 nudge user 消息(保持原语言、不译代码/路径/URL) +function appendNudge(body: string): string { + try { + const obj = JSON.parse(body) + if (!obj || !Array.isArray(obj.messages)) return body + obj.messages.push({ role: "user", content: NUDGE }) + return JSON.stringify(obj) + } catch { + return body + } +} + +// 命中错误时自动重发:用同一 url+headers 重发(body 追加 nudge), +// 超时/异常返回 null(回退到原 response) +async function retryFetch(url: string, headers: any, body: string, timeoutMs: number): Promise { + try { + const ctrl = new AbortController() + const timer = setTimeout(() => ctrl.abort(), timeoutMs) + const res = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + ...(headers && headers instanceof Headers + ? Object.fromEntries(Array.from(headers.entries())) + : headers && typeof headers === "object" + ? headers + : {}), + }, + body, + signal: ctrl.signal, + }) + clearTimeout(timer) + return res + } catch { + return null + } +} + +export const OcRetryGuard: any = async (_ctx: any) => (await ({} as any), undefined as any) + +export default { + id: "oc-retry-guard", + async setup(ctx: any): Promise { + try { + const session = ctx && ctx.session + if (!session || typeof session.hook !== "function") { + fileLog("session.hook 不可用,未注册") + return + } + + // ① http.request:缓存每个 session 的上一条聊天请求体(命中错误时重建用) + await session.hook("http.request", (f: any) => { + try { + if (!f || !f.request || !f.request.url) return + const url = String(f.request.url) + if (!CHAT_URL_RE.test(url)) return + const ct = String((f.request.headers && f.request.headers.get && f.request.headers.get("content-type")) || "") + if (ct && ct.indexOf("json") < 0) return + const sid = String(f.sessionID || "?") + // 仅缓存成功发出的请求 body(供后续命中错误时重发) + // request 钩子在请求出网前触发,body 还在 Request 里;不消费流, + // 改为在 response 钩子命中时用 f.request 缓存的 body 字段。 + // 这里只做占位:OC 在 request 钩子后可改写 f.request, + // 真正的 body 缓存放在 response 钩子(那里 request 已被 OC 定型)。 + void sid + return + } catch { + /* 吞掉,绝不阻塞 */ + } + }) + + // ② http.response:识别严重错误 → 按「事不过三 + 2 分钟周期」决定自动重发 + await session.hook("http.response", async (f: any) => { + try { + if (!f || !f.request || !f.response) return + const url = String(f.request.url || "") + if (!CHAT_URL_RE.test(url)) return + const resp: any = f.response + if (!resp || typeof resp.clone !== "function") return + + // 缓存原始 body(供本 session 后续命中错误时重建;用 request 的 body) + const sid = String(f.sessionID || "?") + let origBody = "" + try { + origBody = f.request && f.request.clone ? await f.request.clone().text() : "" + } catch { + origBody = "" + } + if (origBody && origBody.length > 0) { + lastReqBody.set(sid, { body: origBody, at: Date.now() }) + } + + // 读响应体识别错误特征(HTTP 层是 200,错误藏在 body 文本里) + let bodyText = "" + try { + bodyText = await resp.clone().text() + } catch { + return + } + const isErrSig = ERR_SIGS.some((s) => bodyText.indexOf(s) >= 0) + const isHttpErr = (resp.status || 0) >= 500 + if (!isErrSig && !isHttpErr) return // 成功响应,原样放行 + + // 事不过三 + 2 分钟周期计数 + const now = Date.now() + let st = retryState.get(sid) + if (!st || now - st.firstAt > WINDOW_MS) { + st = { count: 0, firstAt: now } + } + if (st.count >= MAX_RETRY) { + fileLog( + `STOP session=${sid}(2 分钟内已重试 ${st.count} 次,模型确实出问题,本轮不再补救)status=${resp.status} sig=${isErrSig}`, + ) + retryState.set(sid, st) + return // 回退到原 response(让 OC 显示原始错误) + } + st.count += 1 + retryState.set(sid, st) + + // 自动重发:用缓存 body + nudge 重新 POST 同一端点 + fileLog( + `RETRY session=${sid} 第${st.count}/${MAX_RETRY}次 status=${resp.status} sig=${isErrSig}(自动重发中…)`, + ) + const cached = lastReqBody.get(sid) + const newBody = cached ? appendNudge(cached.body) : origBody ? appendNudge(origBody) : "" + if (!newBody) { + fileLog(`RETRY session=${sid} 无可用 body,跳过重发`) + return + } + const hdrs = f.request.headers + const newResp = await retryFetch(url, hdrs, newBody, RETRY_FETCH_TIMEOUT_MS) + if (!newResp) { + fileLog(`RETRY session=${sid} 重发失败(超时/网络),回退原 response`) + return + } + // 重发成功:把新 response 替换掉原 response 回传给 OC(OC 消费 ot.response) + f.response = newResp + fileLog( + `RETRY session=${sid} 第${st.count}次重发成功 status=${newResp.status}(已替换 response,模型自动续上)`, + ) + } catch (e: any) { + fileLog("http.response 钩子异常(已吞掉): " + (e && e.message ? e.message : String(e))) + } + }) + + fileLog("oc-retry-guard 已注册(http.request 缓存 + http.response 自动重发,事不过三 2分钟周期)") + } catch (e: any) { + fileLog("setup 异常(已吞掉,不影响其它插件): " + (e && e.message ? e.message : String(e))) + } + // setup 必须返回 void + return + }, +} diff --git a/tools/sync_wsl.py b/tools/sync_wsl.py index 5a742f9..be2bb17 100644 --- a/tools/sync_wsl.py +++ b/tools/sync_wsl.py @@ -30,6 +30,8 @@ ITEMS = [ "/root/.config/opencode/plugins/oc-session-all/tui.js"), ("mcpcfg", os.path.join(WIN, "mcpcfg.txt"), "/root/.config/opencode/mcpcfg.txt"), + ("oc-retry-guard", os.path.join(WIN, "plugins", "oc-retry-guard.ts"), + "/root/.config/opencode/plugins/oc-retry-guard.ts"), ] diff --git a/tui-plugins/oc-session-all.js b/tui-plugins/oc-session-all.js index b5dd427..7581e71 100644 --- a/tui-plugins/oc-session-all.js +++ b/tui-plugins/oc-session-all.js @@ -732,27 +732,26 @@ export default Plugin.define({ return } - const grouped = new Map() - for (const session of visible) { - const name = projectDisplayName(session, projectMap) - const list = grouped.get(name) ?? [] - list.push(session) - grouped.set(name, list) + // v11 排版(2026-10-19 用户明令): + // ① 全部会话按日期从近到远排序(不再按项目分组 + 按项目字母序) + // ② title 里插入 session ID(短前缀 8 字符,避免整行被 ID 挤爆) + const visibleSorted = visible.sort( + (a, b) => ((b.time?.updated ?? 0) - (a.time?.updated ?? 0)), + ) + const shortID = (id) => { + const s = String(id || "") + return s.length > 12 ? s.slice(0, 8) + "…" : s } - for (const list of grouped.values()) { - list.sort((a, b) => (b.time?.updated ?? 0) - (a.time?.updated ?? 0)) - } - const orderedProjects = [...grouped.keys()].sort((a, b) => a.localeCompare(b)) - - const options = orderedProjects.flatMap((project) => - grouped.get(project).map((session) => ({ - title: `${project} ${ - session.time?.updated ? formatDateTime(session.time.updated) : "未知时间" - } │ ${session.title || "无标题"}`, + const options = visibleSorted.map((session) => { + const project = projectDisplayName(session, projectMap) + const ts = session.time?.updated ? formatDateTime(session.time.updated) : "未知时间" + const idTag = shortID(session.id) + return { + title: `${project} ${ts} │ ${session.title || "无标题"} [${idTag}]`, value: session.id, category: project, - })), - ) + } + }) log("[oc-session-all] picker options built: " + options.length) const selected = await context.ui.dialog.select({