@jayyuen66/dsh-ocr-review 0.1.7 → 0.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/host.js CHANGED
@@ -12,9 +12,9 @@ import { truncateEnd, truncateStart } from "@jayyuen66/dsh-plugin-shared/lib/tex
12
12
  import { fieldOf, hasMethods, isRecord } from "@jayyuen66/dsh-plugin-shared/lib/record";
13
13
  import { expandHomePath, resolveDshHome } from "@deepseek-ai/dsh-home-paths";
14
14
  import { errorText } from "@jayyuen66/dsh-plugin-shared/lib/errors";
15
- import { mkdir } from "node:fs/promises";
15
+ import { mkdir, readFile } from "node:fs/promises";
16
16
  import { withFileLock, writeFileAtomic } from "@deepseek-ai/dsh-atomic-write";
17
- //#region lib/messages.ts
17
+ // lib/messages.ts
18
18
  const MESSAGES = {
19
19
  zh: {
20
20
  middleEllipsis: " …[中间省略]… ",
@@ -46,9 +46,17 @@ const MESSAGES = {
46
46
  settingsServiceGone: "{ns} 所在的设置服务在读取过程中已不可用",
47
47
  repoRequired: "repo 必填,且为 Git 仓库绝对路径",
48
48
  repoRequiredUnknownWorkspace: "repo 必填:当前会话工作区未知,请显式传入 Git 仓库绝对路径",
49
- repoMustBeAbsolute: "repo 必须是绝对路径(以 / 开头):{root}",
49
+ repoMustBeAbsolute: "repo 必须是绝对路径:{root}",
50
50
  noNulBytes: "{name} 不能包含 NUL 字节",
51
- listTruncated: "{name} 超过 {limit} 条上限,已丢弃 {dropped} 条——审查范围会因此小于你给的清单",
51
+ boolMustBeBoolean: "{name} 必须是布尔值(true/false),收到 {received}",
52
+ repoRootUnreadable: "repo 根目录不可读:{path}",
53
+ repoFileMissing: "{name} 指向的文件不存在:{path}",
54
+ repoFileOutside: "{name} 必须指向 repo 根目录内的文件:{path}",
55
+ repoFileNotFile: "{name} 必须指向普通文件:{path}",
56
+ repoFileTooLarge: "{name} 文件过大({path},上限 {limit} 字节)",
57
+ previewNeedsForeground: "preview 是秒级前台操作,不能与 wait=false 同用",
58
+ previewRejectsResume: "preview 不读写会话,不能与 resume 同用",
59
+ listOverLimit: "{name} 超过 {limit} 条上限(多出 {dropped} 条)——命令未构造,本轮一条都没有审:请精简清单后重试,或按 commit/目录拆成多批分别调用",
52
60
  mustBeString: "{name} 必须是字符串(收到 {received})",
53
61
  listItemsMustBeString: "{name} 数组元素必须是字符串",
54
62
  mustBeStringOrList: "{name} 必须是字符串或字符串数组",
@@ -58,6 +66,7 @@ const MESSAGES = {
58
66
  branchNeedsFromAndTo: "scope=branch 需要同时提供 from 与 to",
59
67
  commitNeedsValue: "scope=commit 需要 commit 参数",
60
68
  unknownScope: "未知 scope:{scope}(workspace/commit/branch)",
69
+ scopeMismatch: "scope={scope} 与 {given} 不能同时给(scope 是声明,{given} 属于另一种范围;两者冲突时按哪个审没有正确答案)",
61
70
  resumeNeedsScope: "resume 需要 scope=commit 或 scope=branch(workspace 工作区评审不支持恢复,OCR 会直接拒绝);请同时给出 commit 或 from/to",
62
71
  waitMustBeBoolean: "wait 必须是布尔值(true/false),收到 {received}",
63
72
  pathsRequired: "paths 必填:至少传一个待审查文件的仓库相对路径",
@@ -66,16 +75,41 @@ const MESSAGES = {
66
75
  listRejectsId: "action=list 不接受 id(查单个会话请用 action=show 或 action=comments)",
67
76
  resultSchemaViolation: "ocr {tool}: 返回值不符合该工具的 output schema:{violations}",
68
77
  resultSchemaUnsupported: "ocr {tool}: 返回形态守卫不支持 output schema 关键字 {keyword}(插件内 bug:lib/output.ts 的关键字子集没跟上 schema)",
78
+ resultSchemaMalformed: "ocr {tool}: output schema 自身不合法——{detail}(插件内 bug:schema 的写法超出了返回形态守卫支持的官方子集)",
69
79
  ocrNotJson: "OCR 未返回合法 JSON 输出",
70
80
  ocrNotObject: "OCR JSON 输出不是对象",
71
81
  ocrStatusInvalid: "OCR 输出 status 非法或缺失:{status}(期望 {expected});exit 0 不代表评审完成,请查 stderr 与 ~/.opencodereview/sessions/",
72
82
  statusNone: "(无)",
83
+ ocrStatusFailed: "OCR 回执自报 status=failed(这一场评审没有成功完成)",
73
84
  ocrCommentsNotArray: "OCR 输出 comments 字段不是数组,无法确认评论完整性",
74
85
  warningSeparator: ":",
75
86
  reviewSummaryHint: "完整评论与逐文件失败详情请用 `ocr_session(action=show/comments, id=<session_id>)` 查看;start_line=0 表示 OCR 未能锚定行号,可依据 suggestion/existing_code 在文件中定位。两套口径不要混:droppedCount 与 invalidCommentCount 同时为 0 只说明**本摘要**列全了 OCR 产出的评论(否则调大 maxComments 或置 0 后复核);而**这一场有没有审完**看 coverage(selected 是分母,completed/reused/failed/waived 是它的互斥划分;coverage 为 null 时退回看 warnings 与 status=partial)和 budgetExceeded(true = token 预算花完提前收工,status 里只写 partial,看不出这一层)。toolCalls.failure 大于 0 说明当前模型的工具调用没跑通,换模型比重试同一个更省 token。切勿把「本摘要无 critical」当作「代码干净」。",
76
87
  lineZeroNote: "(line 0: 定位失败,按 existing_code 手动定位)",
77
88
  parseFailedWithReason: "{tool} 输出解析失败:{message}\n原始内容前 {limit} 码元(已脱敏):\n{raw}",
78
89
  parseFailedPlain: "{tool} 输出解析失败\n原始内容前 {limit} 码元(已脱敏):\n{raw}",
90
+ previewParseFailed: "OCR 的 --preview 回执不是可识别的文件清单(files 缺失或条目形状不合)",
91
+ rulesPathRequired: "rules check 的 path 必填(待查询的文件路径)",
92
+ textTruncated: "…[截断] 输出超过 {limit} 字符上限",
93
+ mcpReadFailed: "读取 MCP 配置失败({where}):{cause}",
94
+ mcpJsonInvalid: "MCP 配置不是合法 JSON:{cause}",
95
+ mcpJsonTooLarge: "MCP 配置超过 {limit} 字节上限",
96
+ mcpShapeInvalid: "MCP 配置必须是 {{name: entry}} 形式的对象",
97
+ mcpTooManyServers: "MCP servers 最多 {limit} 个",
98
+ mcpServerInvalid: "MCP server「{name}」的 {field} 不合法:{reason}",
99
+ mcpSaved: "MCP servers 已保存({count} 个)",
100
+ chatGptModelRequired: "ChatGPT 模型名必填",
101
+ chatGptSaved: "已切换到 ChatGPT provider,模型:{model}",
102
+ chatGptSaveFailed: "写入 ChatGPT 配置失败:{cause}",
103
+ compareNeedsBoth: "action=compare 需要同时给出 before 与 after 两个会话 id",
104
+ compareRejectsArgs: "action=compare 不接受 id/limit/severity/category",
105
+ exportRejectsArgs: "action=export 不接受 before/after/limit/severity/category",
106
+ exportOutputRequired: "action=export 需要宿主给出输出路径",
107
+ beforeAfterOnlyForCompare: "before/after 只适用于 action=compare",
108
+ filtersOnlyForComments: "severity/category 过滤只适用于 action=comments",
109
+ filtersNeedId: "severity/category 过滤需要同时给出会话 id",
110
+ invalidSeverity: "severity 过滤值必须是 critical/high/medium/low 之一,收到 {value}",
111
+ sessionCompareHint: "四桶含义:new=只在后一次出现;persisting=两次都有;resolved=只在前一次出现且后一次审过该文件;not_reviewed=只在前一次出现且后一次没审该文件(不算已解决)。",
112
+ sessionExportHint: "已导出为自包含 HTML 文件;路径在 sessionOutput 里,直接用浏览器打开即可(文件留在临时目录,不会自动删除)。",
79
113
  sourceSettingsUnavailable: "dsh 设置服务(ctx.settings)未装配:本插件需要它读取已配置的 provider 列表",
80
114
  sourceSettingsFailed: "调用 dsh 设置服务失败:{message}",
81
115
  sourceNamespaceUnavailable: "dsh 配置里没有 {ns} 命名空间:未安装 pi-ai 适配器,或还没配置任何 provider",
@@ -83,6 +117,7 @@ const MESSAGES = {
83
117
  badEnvKey: "非法 env key:{key}(get-cred 只接受 ^[A-Z0-9_]+$ 的大写 ref 名;请在 dsh 配置里把该 provider 的 apiKeyEnv 改成大写字母/数字/下划线)",
84
118
  configNotJson: "{where} 不是合法 JSON,已中止写入以保护既有配置(请先修复或删掉该文件):{cause}",
85
119
  configNotObject: "{where} 顶层不是 JSON 对象,已中止写入以保护既有配置",
120
+ configUnreadable: "{where} 读不动,已中止写入以保护既有配置(既有文件不会被覆盖):{cause}",
86
121
  providerNotInList: "provider 不在 dsh 配置的 {ns}.providers 列表:{provider}",
87
122
  modelNotInList: "model 不在 {provider} 的 models 列表中:{model}",
88
123
  protocolUnsupported: "provider {provider} 的 api 协议 {protocol} 无法映射到 OCR 支持的 protocol(openai/openai-responses/anthropic);请在 dsh 配置里改用 openai-completions/openai-responses/anthropic-messages,或升级本插件",
@@ -96,16 +131,19 @@ const MESSAGES = {
96
131
  warningUnreadable: "(一条格式无法解析的 warning:既不是字符串,也没有 file/message/type 字段)",
97
132
  diagnosticUnreadable: "(OCR 的 {field} 字段形态不合,这一场的诊断数据读不出来)",
98
133
  diagnosticOmitted: "(另有 {count} 条同类明细已省略,全量见 ocr_session 的 show/comments)",
99
- commaInListItem: "{name} 的数组形式里有一项自身含逗号:{item}。数组与逗号字符串两条路径 join 后无法区分,而 glob 花括号({ts,tsx})恰好含逗号——静默拆成多项会改变审查/过滤范围。改用逗号字符串形式。",
134
+ commaInListItem: "{name} 的数组形式里有一项自身含逗号:{item}。值最终按逗号拼接传给 ocr,含逗号的模式必被拆开、静默改变审查/过滤范围。请改写成不含逗号的等价模式(如把 {ts,tsx} 展开成两条),或去掉该条",
135
+ commaInStringBraces: "{name} 的字符串形式里有 glob 花括号内含逗号:{item}。按逗号拆分后花括号会被撕成多个无效模式,过滤条件失效、审查范围被无声扩大。请把花括号展开成多条模式({ts,tsx} 即两条)后再传入",
100
136
  probeNoVersion: "ocr version 跑通了(exit 0),但回执里读不出版本号:那通常是未发布构建。",
101
137
  probeTimedOut: "ocr version 超时({seconds} 秒内没回来):那条进程已被收走。稍后重试,或直接在终端里跑 ocr version——本插件不会替它代跑安装或升级。",
102
138
  providerModelRequired: "provider 与 model 必填",
103
139
  bodyParseFailed: "body 解析失败:{message}",
104
- reviewToolDescription: "用 open-code-review(ocr)对 Git 变更做深度学习代码审查:OCR 调用其自身配置的 LLM(provider/model 在 ocr-review 设置卡选择)独立审查,返回按严重度/类别聚合的评论摘要。scope=workspace 审工作区 staged+unstaged+untracked 变更(默认);scope=commit 审单个提交;scope=branch 用从/到分支对比(merge-base)。常用先 ocr_delegate_preview 看范围。effort 控制深度(low=1 轮/medium=2/high=3,越深越贵)。background 提供需求/业务上下文能显著提升质量。首次审查前确保 ocr 已配置且 'ocr llm test' 通过。",
105
- scanToolDescription: "用 open-code-review(ocr)做全文件扫描审查(无需 Git 变更/无 diff,适合审计陌生代码库或指定目录)。--path 逗号分隔的仓库相对目录/文件(缺省整个仓库)。与 ocr_review 一样由 OCR 自身 LLM 独立审查,返回聚合评论摘要。",
140
+ reviewToolDescription: "用 open-code-review(ocr)对 Git 变更做深度学习代码审查:OCR 调用其自身配置的 LLM(provider/model 在 ocr-review 设置卡选择)独立审查,返回按严重度/类别聚合的评论摘要。scope=workspace 审工作区 staged+unstaged+untracked 变更(默认);scope=commit 审单个提交;scope=branch 用从/到分支对比(merge-base)。常用先 ocr_delegate_preview 看范围。effort 控制深度(low=1 轮/medium=2/high=3,越深越贵)。background 提供需求/业务上下文能显著提升质量。preview=true 只做文件选择(零 LLM、不建会话);noFilter 保留全部评论;rule/backgroundFile 只收 repo 根内文件。首次审查前确保 ocr 已配置且 'ocr llm test' 通过。",
141
+ scanToolDescription: "用 open-code-review(ocr)做全文件扫描审查(无需 Git 变更/无 diff,适合审计陌生代码库或指定目录)。--path 逗号分隔的仓库相对目录/文件(缺省整个仓库,路径不存在会报错)。与 ocr_review 一样由 OCR 自身 LLM 独立审查,返回聚合评论摘要。preview=true 只列扫描范围;noPlan/noDedup/noSummary 降本;resume 续扫;rule 只收 repo 根内文件。",
106
142
  previewToolDescription: "open-code-review 委托模式第 1 步:列出待审查文件清单(mode/ref/merge_base/reviewable 文件路径+状态+增删行/被排除文件及原因),不调 LLM、秒级返回。用于确认 ocr_review/ocr_scan 的审查范围,或配合 ocr_delegate_rule 后由你自己(dsh 会话模型)读取 diff 完成审查。scope 与 ocr_review 一致(workspace/commit/branch)。",
107
143
  ruleToolDescription: "open-code-review 委托模式第 2 步:按文件路径返回适配的审查规则(按规则内容分组,共享规则的多个文件归一组)。把 ocr_delegate_preview 得到的 reviewable 路径传进来,作为你自己审查时的检查清单。规则文本较长时按批传入。",
108
- sessionToolDescription: "查询 open-code-review 的历史评审会话:action=list 列最近会话(含 session_id/mode/model/评论数,可用于 ocr_review --resume 恢复中断的评审);action=show 查看单个会话元数据与逐文件检查点;action=comments 取单个会话的评论明细。session_id 也可从 ocr_review/ocr_scan 的返回结果直接获取。",
144
+ sessionToolDescription: "查询 open-code-review 的历史评审会话:action=list 列最近会话(含 session_id/mode/model/评论数,可用于 ocr_review/ocr_scan --resume 恢复中断的评审);action=show 查看单个会话元数据与逐文件检查点;action=comments 取单个会话的评论明细(可按 severity/category 过滤);action=compare 比较两次会话的 new/persisting/resolved/not_reviewed 四桶;action=export 导出自包含 HTML。session_id 也可从 ocr_review/ocr_scan 的返回结果直接获取。",
145
+ rulesCheckToolDescription: "调试 open-code-review 的规则解析:对给定文件路径打印最终命中的规则层(--rule/项目/全局/内置)、glob 模式与规则正文。不调用 LLM、不建会话;用于排查「我的规则为什么没生效」。",
146
+ llmToolDescription: "只读查询 open-code-review 的 LLM 配置面:action=providers 列内置 provider;action=models 列当前 ChatGPT 账号可用的模型(需要先完成 ocr auth 登录)。不发起评审、不测试连通性。",
109
147
  paramRepo: "Git 仓库绝对路径(可选:缺省=当前会话工作区)",
110
148
  paramScope: "审查范围(缺省 workspace)",
111
149
  paramCommit: "scope=commit 时必填:提交哈希",
@@ -114,7 +152,7 @@ const MESSAGES = {
114
152
  paramCommitShort: "scope=commit 时必填",
115
153
  paramFromShort: "scope=branch 时必填",
116
154
  paramToShort: "scope=branch 时必填",
117
- paramEffort: "投入档位(缺省 medium)",
155
+ paramEffort: "投入档位 low/medium/high;不给时取设置卡默认(出厂 auto=跟随 OCR 自身配置)",
118
156
  paramBackground: "需求/业务上下文(官方 skill 推荐:调用前先用 git log/diff/提交信息快速分析变更意图,提取 3-5 句业务背景传入,是提升评审质量最有效的参数;从其它 agent 调用时始终携带 PR 描述/需求)",
119
157
  paramBackgroundShort: "需求/业务上下文",
120
158
  paramBackgroundPreview: "业务上下文(会出现在输出中供审查参考)",
@@ -124,32 +162,54 @@ const MESSAGES = {
124
162
  paramProvider: "本次运行临时指定已配置 provider",
125
163
  paramModel: "本次运行临时覆盖 model",
126
164
  paramResume: "从之前中断的会话 id 恢复(会话 id 在返回结果里)",
127
- paramScanPath: "仓库相对目录或文件(缺省整仓,可多值;上限 2000 条,超出会直接报错)",
165
+ paramScanPath: "仓库相对目录或文件(缺省整仓,可多值;上限 2000 条,超出会直接报错;路径不存在会报错而不是空扫描)",
166
+ paramPreview: "true=只列文件清单与排除原因,不调用 LLM、不建会话(秒级返回);与 resume/wait=false 互斥",
167
+ paramNoFilter: "true=跳过 OCR 的 LLM 评论后过滤,保留全部评论(召回优先,噪音更多)",
168
+ paramMaxGitProcs: "git 子进程并发上限(OCR --max-git-procs;缺省不传=原生 16)",
169
+ paramRule: "repo 根内的自定义规则 JSON 路径(OCR --rule;只允许仓库内普通文件)",
170
+ paramBackgroundFile: "repo 根内的 Markdown 需求文件路径(OCR --background-file;优先于 background)",
171
+ paramNoPlan: "true=跳过 scan 的逐文件 PLAN_TASK 预检(降本)",
172
+ paramNoDedup: "true=跳过 scan 的批内 DEDUP_TASK(降本)",
173
+ paramNoSummary: "true=跳过 scan 结束后的 PROJECT_SUMMARY_TASK(降本)",
174
+ paramScanResume: "从之前中断的 scan 会话 id 恢复",
128
175
  paramBatch: "分批策略",
129
176
  paramPaths: "待查询的仓库相对文件路径(至少 1 个,最多 200)",
130
- paramSessionAction: "list:列最近会话(缺省)/ show:查看单个 / comments:取评论",
131
- paramSessionId: "action=show/comments 时的会话 id",
177
+ paramSessionAction: "list:列最近会话(缺省)/ show:查看单个 / comments:取评论 / compare:比较两次会话 / export:导出 HTML",
178
+ paramSessionId: "action=show/comments/export 时的会话 id(export 缺省=最新一场)",
179
+ paramSessionBefore: "action=compare 时必填:较早的会话 id",
180
+ paramSessionAfter: "action=compare 时必填:较晚的会话 id",
181
+ paramSessionSeverity: "action=comments 时按严重度过滤(critical/high/medium/low)",
182
+ paramSessionCategory: "action=comments 时按类别过滤(如 bug/security)",
132
183
  paramSessionLimit: "list 上限 1-100(缺省 10)",
184
+ paramRulesPath: "待查询的文件路径(仓库相对或绝对;仅用于规则匹配)",
185
+ paramLlmAction: "providers:内置 provider 列表 / models:ChatGPT 账号模型列表",
133
186
  unitGroup: "组",
134
187
  unitFile: "文件",
135
188
  tuningConcurrency: "并发{unit}数(OCR --concurrency;缺省不传=OCR 原生 8)",
136
- tuningTimeout: "每{unit}任务超时分钟(OCR --timeout;0=不限时;缺省不传=OCR 原生 15)",
189
+ tuningTimeout: "每{unit}任务超时分钟(OCR --timeout;review 会按 effort 轮数线性放大,scan 不放大;0=不限时;缺省不传=OCR 原生 15)",
137
190
  tuningMaxTools: "每{unit}工具调用轮数上限(OCR --max-tools;0=模板默认;1-49 会被 OCR 抬到 50,帮助文本 min 50;缺省不传=模板默认)",
138
191
  tuningMaxToolsScan: "每{unit}工具调用轮数上限(OCR --max-tools;0=模板默认;只在大于模板默认值时生效,不抬到 50;缺省不传=模板默认)",
139
192
  tuningMaxTokens: "每{unit}提示词 token 上限(OCR --max-tokens;0=配置/模板默认;缺省不传=模板默认)",
140
193
  tuningMaxTokensBudget: "本次运行 total token(input+output)预算闸门,超出即停止派发部分结果(OCR --max-tokens-budget;0=不限;缺省不传=不限)",
141
194
  previewHint: "对每个 reviewable 文件:用 git diff <merge_base>..<to> -- <path>(branch)/git show <commit> -- <path>(commit)/ git diff HEAD -- <path> 或直接读文件(workspace 新文件)取 diff。",
195
+ filePreviewHint: "这是文件选择预览:没有调用 LLM、没有创建会话。要真正审查请去掉 preview 后重跑。列表按上限截断;truncated=true 时请用 exclude/path 缩小范围。",
142
196
  ruleHint: "按分组规则逐个审查对应文件,输出评论建议格式:{path, content, start_line, end_line, category, severity}。",
143
197
  sessionEmpty: "(暂无历史评审会话:先跑一次 ocr_review/ocr_scan 再查询)",
144
198
  sessionTruncated: "…[截断] 完整内容见 ~/.opencodereview/sessions/",
145
- sessionResumeHint: "要恢复中断的区间/commit 评审:ocr_review ... --resume <session_id>(工作区评审不支持恢复)。",
199
+ sessionResumeHint: "要恢复中断的区间/commit 评审:ocr_review ... --resume <session_id>(工作区评审不支持恢复);scan 会话用 ocr_scan ... --resume <session_id>。",
200
+ rulesCheckHint: "输出里的 Source 是命中的层(--rule/项目/全局/内置),Pattern 是命中的 glob;规则按声明顺序先到先得。",
201
+ llmHint: "providers 是内置清单,models 是 ChatGPT 账号专属列表;评审实际用哪个 provider/model 仍以 OCR 配置为准。",
146
202
  routingText: `
147
203
  - 用户要求「审查代码/Review/检查我的改动/审这个 commit/PR」时,用 ocr 工具做代码审查:
148
204
  - ocr_review:OCR 用自己配置的 LLM 独立审查工作区/commit/分支变更(effort 控制深度,可给 background 上下文)。
149
205
  - ocr_scan:对无 git 历史的目录或指定路径做全文件审查。
150
206
  - ocr_delegate_preview / ocr_delegate_rule:只取「文件清单 + 适配规则」的确定性脚手架(OCR 端零 LLM 消耗),
151
207
  配合 git diff 由你自己完成审查——适合复用本会话模型能力、或先确认审查范围再决定是否跑 ocr_review。
152
- - 审查前若不确定范围,先 ocr_delegate_preview(不花 token);范围过大时用 exclude 收敛或按 commit 拆。
208
+ - ocr_review / ocr_scan 的 preview=true:只列文件选择与排除原因,零 LLM、不建会话;scan 还支持
209
+ noPlan/noDedup/noSummary 降本与 resume 续扫。
210
+ - ocr_session:list/show/comments(可按 severity/category 过滤)/compare(new/persisting/resolved/not_reviewed)/export。
211
+ - ocr_rules_check / ocr_llm:规则解析与 LLM 清单的只读诊断,不评审、不花 token。
212
+ - 审查前若不确定范围,先 ocr_delegate_preview 或 preview=true(不花 token);范围过大时用 exclude 收敛或按 commit 拆。
153
213
  - OCR 端点/provider/model 由「设置 → 插件 → ocr-review 卡片」从宿主设置服务的 provider 清单选择
154
214
  (key 只按 api_key_cmd 动态读取,不落盘明文);也可在工具参数里用 provider/model 逐次覆盖。
155
215
  - 调用 ocr_review / ocr_scan 前,先用 git log/diff/提交信息快速分析变更意图,提取 3-5 句业务上下文
@@ -162,7 +222,7 @@ const MESSAGES = {
162
222
  - LLM 调用本身有重试:OCR 内置重试(429/408/409 与 5xx 自动退避重试);per-file 组失败被隔离,进
163
223
  coverage.failedItems(产出里没有 manifest 时进 warnings),不整体重跑。
164
224
  工具层超时(min(请求, shell 执行器自己设置项里的 maxTimeoutMs),缺省 10 分钟)或部分文件失败后,可
165
- ocr_session 查 session_id、用 ocr_review --resume 恢复中断的区间/commit 评审。
225
+ ocr_session 查 session_id、用 ocr_review --resume 恢复中断的区间/commit 评审,或 ocr_scan --resume 续扫。
166
226
  `.trim()
167
227
  },
168
228
  en: {
@@ -195,8 +255,16 @@ const MESSAGES = {
195
255
  settingsServiceGone: "the settings service holding {ns} became unavailable while being read",
196
256
  repoRequired: "repo is required and must be the absolute path of a Git repository",
197
257
  repoRequiredUnknownWorkspace: "repo is required: the current session workspace is unknown, pass the absolute path of a Git repository explicitly",
198
- repoMustBeAbsolute: "repo must be an absolute path (starting with /): {root}",
199
- listTruncated: "{name} exceeds the {limit}-item cap; {dropped} item(s) were dropped - the review will cover less than the list you passed",
258
+ repoMustBeAbsolute: "repo must be an absolute path: {root}",
259
+ boolMustBeBoolean: "{name} must be a boolean (true/false), got {received}",
260
+ repoRootUnreadable: "the repo root is not readable: {path}",
261
+ repoFileMissing: "{name} points at a file that does not exist: {path}",
262
+ repoFileOutside: "{name} must point at a file inside the repo root: {path}",
263
+ repoFileNotFile: "{name} must point at a regular file: {path}",
264
+ repoFileTooLarge: "{name} file is too large ({path}, cap {limit} bytes)",
265
+ previewNeedsForeground: "preview is a fast foreground operation and cannot be combined with wait=false",
266
+ previewRejectsResume: "preview does not read or write sessions and cannot be combined with resume",
267
+ listOverLimit: "{name} exceeds the {limit}-item cap ({dropped} extra) - the command was not constructed and nothing has been reviewed: trim the list and retry, or split the run into smaller batches by commit/directory",
200
268
  noNulBytes: "{name} must not contain NUL bytes",
201
269
  mustBeString: "{name} must be a string (got {received})",
202
270
  listItemsMustBeString: "the items of {name} must be strings",
@@ -207,6 +275,7 @@ const MESSAGES = {
207
275
  branchNeedsFromAndTo: "scope=branch requires both from and to",
208
276
  commitNeedsValue: "scope=commit requires the commit parameter",
209
277
  unknownScope: "unknown scope: {scope} (workspace/commit/branch)",
278
+ scopeMismatch: "scope={scope} cannot be combined with {given} (scope declares one range, {given} belongs to another; there is no correct way to pick one)",
210
279
  resumeNeedsScope: "resume requires scope=commit or scope=branch (workspace reviews cannot be resumed; OCR rejects it outright); pass commit or from/to as well",
211
280
  waitMustBeBoolean: "wait must be a boolean (true/false), got {received}",
212
281
  pathsRequired: "paths is required: pass at least one repo-relative path of a file to review",
@@ -215,16 +284,41 @@ const MESSAGES = {
215
284
  listRejectsId: "action=list does not take an id (use action=show or action=comments for a single session)",
216
285
  resultSchemaViolation: "ocr {tool}: the tool result does not match its output schema: {violations}",
217
286
  resultSchemaUnsupported: "ocr {tool}: the result guard does not support the output schema keyword {keyword} (plugin bug: the keyword subset in lib/output.ts lags the schema)",
287
+ resultSchemaMalformed: "ocr {tool}: the output schema itself is malformed — {detail} (plugin bug: the schema goes beyond the official subset the result guard supports)",
218
288
  ocrNotJson: "OCR returned no valid JSON output",
219
289
  ocrNotObject: "the OCR JSON output is not an object",
220
290
  ocrStatusInvalid: "OCR output has an invalid or missing status: {status} (expected {expected}); exit 0 does not mean the review completed — check stderr and ~/.opencodereview/sessions/",
221
291
  statusNone: "(none)",
292
+ ocrStatusFailed: "the OCR receipt reports status=failed (this review run did not complete)",
222
293
  ocrCommentsNotArray: "the comments field of the OCR output is not an array, comment completeness cannot be confirmed",
223
294
  warningSeparator: ": ",
224
295
  reviewSummaryHint: "Use `ocr_session(action=show/comments, id=<session_id>)` for the full comments and per-file failure details; start_line=0 means OCR could not anchor a line — locate it via suggestion/existing_code. Keep the two notions apart: droppedCount and invalidCommentCount both being 0 only means THIS SUMMARY listed every comment OCR produced (otherwise raise maxComments, or set it to 0, and re-check). Whether the run itself covered everything is answered by coverage (selected is the denominator, completed/reused/failed/waived are its disjoint partition; fall back to warnings and status=partial when coverage is null) and by budgetExceeded (true = the token budget stopped the run early — status still only says partial). A toolCalls.failure above 0 means the current model's tool calls did not work: switch models instead of retrying the same one. Never treat \"no critical in this summary\" as \"the code is clean\".",
225
296
  lineZeroNote: "(line 0: not anchored, locate it via existing_code)",
226
297
  parseFailedWithReason: "{tool} output parsing failed: {message}\nfirst {limit} UTF-16 code units of the raw output (redacted):\n{raw}",
227
298
  parseFailedPlain: "{tool} output parsing failed\nfirst {limit} UTF-16 code units of the raw output (redacted):\n{raw}",
299
+ previewParseFailed: "the OCR --preview receipt is not a recognizable file list (files missing or every entry malformed)",
300
+ rulesPathRequired: "rules check requires path (the file to look up)",
301
+ textTruncated: "…[truncated] output exceeded the {limit}-character cap",
302
+ mcpReadFailed: "failed to read the MCP config ({where}): {cause}",
303
+ mcpJsonInvalid: "the MCP config is not valid JSON: {cause}",
304
+ mcpJsonTooLarge: "the MCP config exceeds the {limit}-byte cap",
305
+ mcpShapeInvalid: "the MCP config must be an object of {name: entry}",
306
+ mcpTooManyServers: "at most {limit} MCP servers are allowed",
307
+ mcpServerInvalid: "MCP server \"{name}\" has an invalid {field}: {reason}",
308
+ mcpSaved: "MCP servers saved ({count})",
309
+ chatGptModelRequired: "a ChatGPT model slug is required",
310
+ chatGptSaved: "switched to the ChatGPT provider, model: {model}",
311
+ chatGptSaveFailed: "failed to write the ChatGPT config: {cause}",
312
+ compareNeedsBoth: "action=compare requires both before and after session ids",
313
+ compareRejectsArgs: "action=compare does not take id/limit/severity/category",
314
+ exportRejectsArgs: "action=export does not take before/after/limit/severity/category",
315
+ exportOutputRequired: "action=export requires an output path from the host",
316
+ beforeAfterOnlyForCompare: "before/after only apply to action=compare",
317
+ filtersOnlyForComments: "severity/category filters only apply to action=comments",
318
+ filtersNeedId: "severity/category filters require a session id",
319
+ invalidSeverity: "severity filter values must be one of critical/high/medium/low, got {value}",
320
+ sessionCompareHint: "Bucket meanings: new = only in the later run; persisting = in both; resolved = only in the earlier run and the later run reviewed that file; not_reviewed = only in the earlier run and the later run never looked at that file (not resolved).",
321
+ sessionExportHint: "Exported as a self-contained HTML file; the path is in sessionOutput — open it in a browser. It stays in the temp directory and is not deleted automatically.",
228
322
  sourceSettingsUnavailable: "the dsh settings service (ctx.settings) is not wired up: this plugin needs it to read the configured provider list",
229
323
  sourceSettingsFailed: "calling the dsh settings service failed: {message}",
230
324
  sourceNamespaceUnavailable: "the dsh configuration has no {ns} namespace: the pi-ai adapter is not installed, or no provider is configured yet",
@@ -232,6 +326,7 @@ const MESSAGES = {
232
326
  badEnvKey: "invalid env key: {key} (get-cred only accepts uppercase ref names matching ^[A-Z0-9_]+$; change the provider's apiKeyEnv in the dsh configuration to uppercase letters/digits/underscores)",
233
327
  configNotJson: "{where} is not valid JSON; the write was aborted to protect the existing configuration (fix or delete that file first): {cause}",
234
328
  configNotObject: "the top level of {where} is not a JSON object; the write was aborted to protect the existing configuration",
329
+ configUnreadable: "{where} could not be read; the write was aborted so the existing configuration is left untouched: {cause}",
235
330
  providerNotInList: "provider is not in the {ns}.providers list configured by dsh: {provider}",
236
331
  modelNotInList: "model is not in the models list of {provider}: {model}",
237
332
  protocolUnsupported: "the api protocol {protocol} of provider {provider} cannot be mapped to an OCR-supported protocol (openai/openai-responses/anthropic); use openai-completions/openai-responses/anthropic-messages in the dsh configuration, or upgrade this plugin",
@@ -245,16 +340,19 @@ const MESSAGES = {
245
340
  warningUnreadable: "(one unreadable warning entry: neither a string nor an object with file/message/type)",
246
341
  diagnosticUnreadable: "(the {field} field of the OCR output is malformed; its diagnostics cannot be read for this run)",
247
342
  diagnosticOmitted: "({count} more entries of the same kind were omitted; use ocr_session show/comments for the full list)",
248
- commaInListItem: "an item of the array form of {name} contains a comma itself: {item}. The array form and the comma-joined string are indistinguishable after joining, and glob braces ({ts,tsx}) contain commas — silently splitting changes the review/filter scope. Use the comma-separated string form.",
343
+ commaInListItem: "an item of the array form of {name} contains a comma itself: {item}. Values reach ocr comma-joined, so a pattern containing a comma is certain to be torn apart, silently changing the review/filter scope. Rewrite it as comma-free equivalents (e.g. expand {ts,tsx} into two patterns) or drop the item",
344
+ commaInStringBraces: "the string form of {name} contains a comma inside a glob brace: {item}. Splitting on commas tears the brace into several invalid patterns, silently changing the review/filter scope. Expand the brace into separate patterns ({ts,tsx} means two) and pass those",
249
345
  probeNoVersion: "ocr version ran fine (exit 0), yet no version could be read from its output: that usually means an unreleased build",
250
346
  probeTimedOut: "ocr version timed out (nothing back within {seconds}s): that process was reaped. Retry later, or run ocr version in a terminal yourself — this plugin never runs an install or upgrade on your behalf",
251
347
  providerModelRequired: "provider and model are both required",
252
348
  bodyParseFailed: "failed to parse the body: {message}",
253
- reviewToolDescription: "Deep code review of Git changes with open-code-review (ocr): OCR runs its own configured LLM (provider/model picked in the ocr-review settings card) as an independent reviewer and returns comments aggregated by severity/category. scope=workspace reviews staged+unstaged+untracked changes (default); scope=commit reviews one commit; scope=branch compares from/to branches (merge-base). Commonly start with ocr_delegate_preview to see the scope. effort controls depth (low=1 pass / medium=2 / high=3, deeper costs more). Passing requirements/business context via background markedly improves quality. Before the first review make sure ocr is configured and 'ocr llm test' passes.",
254
- scanToolDescription: "Whole-file scan review with open-code-review (ocr) — no Git changes or diff required, good for auditing an unfamiliar repository or given directories. --path takes comma-separated repo-relative directories/files (default: the whole repo). Like ocr_review, OCR's own LLM reviews independently and an aggregated comment summary is returned.",
349
+ reviewToolDescription: "Deep code review of Git changes with open-code-review (ocr): OCR runs its own configured LLM (provider/model picked in the ocr-review settings card) as an independent reviewer and returns comments aggregated by severity/category. scope=workspace reviews staged+unstaged+untracked changes (default); scope=commit reviews one commit; scope=branch compares from/to branches (merge-base). Commonly start with ocr_delegate_preview to see the scope. effort controls depth (low=1 pass / medium=2 / high=3, deeper costs more). Passing requirements/business context via background markedly improves quality. preview=true selects files only (no LLM, no session); noFilter keeps every comment; rule/backgroundFile accept repo-root files only. Before the first review make sure ocr is configured and 'ocr llm test' passes.",
350
+ scanToolDescription: "Whole-file scan review with open-code-review (ocr) — no Git changes or diff required, good for auditing an unfamiliar repository or given directories. --path takes comma-separated repo-relative directories/files (default: the whole repo; a nonexistent path errors). Like ocr_review, OCR's own LLM reviews independently and an aggregated comment summary is returned. preview=true lists the scan scope only; noPlan/noDedup/noSummary reduce cost; resume continues an interrupted scan; rule accepts a repo-root file only.",
255
351
  previewToolDescription: "Step 1 of open-code-review delegate mode: list the files to review (mode/ref/merge_base/reviewable path+status+added/removed lines, plus excluded files and why). No LLM call, returns in seconds. Use it to confirm the scope of ocr_review/ocr_scan, or together with ocr_delegate_rule to review the diffs yourself (the dsh session model). scope matches ocr_review (workspace/commit/branch).",
256
352
  ruleToolDescription: "Step 2 of open-code-review delegate mode: return the review rules that fit the given file paths (grouped by rule content; files sharing a rule land in one group). Feed the reviewable paths from ocr_delegate_preview and use the rules as your own review checklist. Pass paths in batches when the rule text is long.",
257
- sessionToolDescription: "Query open-code-review's past review sessions: action=list shows recent sessions (session_id/mode/model/comment counts, usable for ocr_review --resume of interrupted reviews); action=show prints one session's metadata and per-file checkpoints; action=comments returns one session's comment details. session_id is also returned by ocr_review/ocr_scan.",
353
+ sessionToolDescription: "Query open-code-review's past review sessions: action=list shows recent sessions (session_id/mode/model/comment counts, usable for ocr_review/ocr_scan --resume of interrupted reviews); action=show prints one session's metadata and per-file checkpoints; action=comments returns one session's comment details (optionally filtered by severity/category); action=compare compares two sessions into new/persisting/resolved/not_reviewed; action=export writes a self-contained HTML file. session_id is also returned by ocr_review/ocr_scan.",
354
+ rulesCheckToolDescription: "Debug open-code-review rule resolution: for a given file path, print the winning rule layer (--rule/project/global/built-in), the glob pattern and the rule body. No LLM call and no session; use it when a custom rule is not firing.",
355
+ llmToolDescription: "Read-only queries over open-code-review's LLM configuration: action=providers lists built-in providers; action=models lists the current ChatGPT account's models (requires an ocr auth sign-in first). It never runs a review or a connectivity test.",
258
356
  paramRepo: "Absolute path to the Git repository (optional: default = current session workspace)",
259
357
  paramScope: "Review scope (default workspace)",
260
358
  paramCommit: "Required with scope=commit: commit hash",
@@ -263,7 +361,7 @@ const MESSAGES = {
263
361
  paramCommitShort: "Required with scope=commit",
264
362
  paramFromShort: "Required with scope=branch",
265
363
  paramToShort: "Required with scope=branch",
266
- paramEffort: "Effort level (default medium)",
364
+ paramEffort: "Effort level low/medium/high; when omitted the settings card default applies (factory auto = follow the OCR config)",
267
365
  paramBackground: "Requirements/business context (official skill recommends: analyse the change intent via git log/diff/commit messages first and pass 3-5 sentences of context — the single most effective parameter for review quality; always carry the PR description/requirements when called from another agent)",
268
366
  paramBackgroundShort: "Requirements/business context",
269
367
  paramBackgroundPreview: "Business context (it shows up in the output for review reference)",
@@ -273,25 +371,43 @@ const MESSAGES = {
273
371
  paramProvider: "Temporarily use a configured provider for this run",
274
372
  paramModel: "Temporarily override the model for this run",
275
373
  paramResume: "Resume from a previously interrupted session id (the id is in the result)",
276
- paramScanPath: "Repo-relative directories or files (default: whole repo, multiple allowed; cap 2000 - exceeding it is an error, never a silent drop)",
374
+ paramScanPath: "Repo-relative directories or files (default: whole repo, multiple allowed; cap 2000 - exceeding it is an error; a nonexistent path errors instead of scanning nothing)",
375
+ paramPreview: "true = list files and exclusion reasons only; no LLM call, no session, returns in seconds. Mutually exclusive with resume/wait=false",
376
+ paramNoFilter: "true = skip OCR's LLM post-filter and keep every comment (recall over noise)",
377
+ paramMaxGitProcs: "Concurrent git subprocess cap (OCR --max-git-procs; unset = native 16)",
378
+ paramRule: "Path to a custom rule JSON inside the repo root (OCR --rule; regular files only)",
379
+ paramBackgroundFile: "Path to a Markdown requirements file inside the repo root (OCR --background-file; takes precedence over background)",
380
+ paramNoPlan: "true = skip scan's per-file PLAN_TASK pre-pass (cheaper)",
381
+ paramNoDedup: "true = skip scan's per-batch DEDUP_TASK (cheaper)",
382
+ paramNoSummary: "true = skip scan's post-run PROJECT_SUMMARY_TASK (cheaper)",
383
+ paramScanResume: "Resume a previously interrupted scan session id",
277
384
  paramBatch: "Batching strategy",
278
385
  paramPaths: "Repo-relative file paths to look up (at least 1, at most 200)",
279
- paramSessionAction: "list: recent sessions (default) / show: one session / comments: comment list",
280
- paramSessionId: "Session id for action=show/comments",
386
+ paramSessionAction: "list: recent sessions (default) / show: one session / comments: comment list / compare: two sessions / export: HTML",
387
+ paramSessionId: "Session id for action=show/comments/export (export defaults to the newest)",
388
+ paramSessionBefore: "Required for action=compare: the older session id",
389
+ paramSessionAfter: "Required for action=compare: the newer session id",
390
+ paramSessionSeverity: "action=comments severity filter (critical/high/medium/low)",
391
+ paramSessionCategory: "action=comments category filter (e.g. bug/security)",
281
392
  paramSessionLimit: "list cap 1-100 (default 10)",
393
+ paramRulesPath: "File path to look up (repo-relative or absolute; used only for rule matching)",
394
+ paramLlmAction: "providers: built-in provider list / models: ChatGPT account model list",
282
395
  unitGroup: "group",
283
396
  unitFile: "file",
284
397
  tuningConcurrency: "Concurrent {unit} count (OCR --concurrency; unset = OCR native 8)",
285
- tuningTimeout: "Per-{unit} task timeout in minutes (OCR --timeout; 0 = unlimited; unset = OCR native 15)",
398
+ tuningTimeout: "Per-{unit} task timeout in minutes (OCR --timeout; review scales it by the effort round count, scan does not; 0 = unlimited; unset = OCR native 15)",
286
399
  tuningMaxTools: "Per-{unit} tool-call round cap (OCR --max-tools; 0 = template default; 1-49 is raised to 50 by OCR, help text says min 50; unset = template default)",
287
400
  tuningMaxToolsScan: "Per-{unit} tool-call round cap (OCR --max-tools; 0 = template default; only takes effect above the template default, never raised to 50; unset = template default)",
288
401
  tuningMaxTokens: "Per-{unit} prompt token cap (OCR --max-tokens; 0 = config/template default; unset = template default)",
289
402
  tuningMaxTokensBudget: "Total token (input+output) budget gate for this run; dispatch stops once exceeded (OCR --max-tokens-budget; 0 = unlimited; unset = unlimited)",
290
403
  previewHint: "For each reviewable file, get its diff with git diff <merge_base>..<to> -- <path> (branch) / git show <commit> -- <path> (commit) / git diff HEAD -- <path>, or read the file directly (new workspace files).",
404
+ filePreviewHint: "This is a file-selection preview: no LLM call and no session were created. Drop preview and rerun to actually review. Lists are capped; when truncated=true, narrow the scope with exclude/path.",
291
405
  ruleHint: "Review the files of each group against its rule and emit comment suggestions in the shape {path, content, start_line, end_line, category, severity}.",
292
406
  sessionEmpty: "(no review sessions yet: run ocr_review/ocr_scan once before querying)",
293
407
  sessionTruncated: "…[truncated] full content in ~/.opencodereview/sessions/",
294
- sessionResumeHint: "To resume an interrupted range/commit review: ocr_review ... --resume <session_id> (workspace reviews cannot be resumed).",
408
+ sessionResumeHint: "To resume an interrupted range/commit review: ocr_review ... --resume <session_id> (workspace reviews cannot be resumed); scan sessions use ocr_scan ... --resume <session_id>.",
409
+ rulesCheckHint: "Source names the winning layer (--rule/project/global/built-in) and Pattern the glob; rules are first-match in declaration order.",
410
+ llmHint: "providers is the built-in catalogue; models is the ChatGPT account-specific list; the effective review provider/model still comes from OCR's own config.",
295
411
  routingText: `
296
412
  - When the user asks to "review the code / Review / check my changes / review this commit or PR", use the ocr tools:
297
413
  - ocr_review: OCR reviews workspace/commit/branch changes with its own configured LLM (effort controls depth, background carries context).
@@ -299,7 +415,11 @@ const MESSAGES = {
299
415
  - ocr_delegate_preview / ocr_delegate_rule: deterministic scaffolding that only returns "file list + fitting rules"
300
416
  (zero LLM cost on the OCR side); pair it with git diff and review yourself — good for reusing this session's model,
301
417
  or for confirming the scope before deciding to run ocr_review.
302
- - Unsure about the scope? Run ocr_delegate_preview first (costs no tokens); if the scope is huge, narrow it with
418
+ - preview=true on ocr_review / ocr_scan: list the file selection and exclusion reasons only — zero LLM, no session;
419
+ scan also supports noPlan/noDedup/noSummary to cut cost and resume to continue an interrupted scan.
420
+ - ocr_session: list/show/comments (severity/category filters)/compare (new/persisting/resolved/not_reviewed)/export.
421
+ - ocr_rules_check / ocr_llm: read-only diagnostics for rule resolution and the LLM catalogue; no review, no tokens.
422
+ - Unsure about the scope? Run ocr_delegate_preview or preview=true first (costs no tokens); if the scope is huge, narrow it with
303
423
  exclude or split by commit.
304
424
  - The OCR endpoint/provider/model comes from "Settings → Plugins → ocr-review card", fed by the provider list of
305
425
  the host settings service (the key is read dynamically through api_key_cmd and never stored in plaintext);
@@ -317,7 +437,7 @@ const MESSAGES = {
317
437
  isolated and land in coverage.failedItems (in warnings when the output carries no manifest) instead of re-running
318
438
  everything. After a tool-layer timeout (min(request, the maxTimeoutMs setting of the shell executor itself,
319
439
  10 minutes by default) or partial file failures, use ocr_session to find the session_id and ocr_review --resume
320
- to continue an interrupted range/commit review.
440
+ to continue an interrupted range/commit review, or ocr_scan --resume to continue a scan.
321
441
  `.trim()
322
442
  }
323
443
  };
@@ -333,8 +453,7 @@ const PLACEHOLDER = /\{(?<key>\w+)\}/gu;
333
453
  function format(template, params) {
334
454
  return template.replaceAll(PLACEHOLDER, (_all, key) => params[key] ?? "");
335
455
  }
336
- //#endregion
337
- //#region lib/output.ts
456
+ // lib/output.ts
338
457
  /** 守卫支持的 schema 关键字(官方 enforced subset 的全部关键字,见
339
458
  * installed dsh-tools lib/index.js:206;description/title/default/examples 是
340
459
  * 官方同样忽略的注解)。 */
@@ -352,14 +471,77 @@ const SUPPORTED_KEYWORDS = /* @__PURE__ */ new Set([
352
471
  "default",
353
472
  "examples"
354
473
  ]);
355
- /** schema 里的关键字必须全部落在守卫支持的子集内(递归查 oneOf/properties/items)。
356
- * 违例抛错而不是当违规值报:schema 是插件自己的代码,错的是代码不是模型的数据。 */
357
- function assertGuardable(schema, toolName, messages) {
474
+ /** 官方子集只认这几个标量类型名(`SCHEMA_TYPES`);`type` 写数组官方直接拒。 */
475
+ const SCHEMA_TYPES = /* @__PURE__ */ new Set([
476
+ "object",
477
+ "array",
478
+ "string",
479
+ "number",
480
+ "integer",
481
+ "boolean",
482
+ "null"
483
+ ]);
484
+ /**
485
+ * schema 里的关键字必须全部落在守卫支持的子集内(递归查 oneOf/properties/items),
486
+ * 关键字的**取值形态**也必须合法。
487
+ *
488
+ * 违例抛错而不是当违规值报:schema 是插件自己的代码,错的是代码不是模型的数据。
489
+ *
490
+ * 为什么取值形态也要查:只查键名的话,`type: ["string","null"]` 这种官方子集不认的
491
+ * 写法会让 `scalarMismatch` 走进最后那支 `value !== null`——于是 `null` 放行、字符串
492
+ * 反而判违规,守卫给出的结论与它自己声称支持的子集无关。`enum` 写成字符串同理:
493
+ * 走到 `enum.includes` 时成了子串匹配,非字符串值还会抛 `map is not a function`。
494
+ */
495
+ function malformed(toolName, detail, messages) {
496
+ return new Error(format(messages.resultSchemaMalformed, {
497
+ tool: toolName,
498
+ detail
499
+ }));
500
+ }
501
+ /** 关键字白名单:守卫只认官方子集,多一个都算插件自己的 schema 写错了。 */
502
+ function assertKnownKeywords(schema, toolName, messages) {
358
503
  for (const keyword of Object.keys(schema)) if (!SUPPORTED_KEYWORDS.has(keyword)) throw new Error(format(messages.resultSchemaUnsupported, {
359
504
  tool: toolName,
360
505
  keyword
361
506
  }));
362
- for (const branch of schema.oneOf ?? []) assertGuardable(branch, toolName, messages);
507
+ }
508
+ /** `type` 的取值形态:官方明确不接受类型数组,只收单个类型名。 */
509
+ function assertTypeShape(type, toolName, messages) {
510
+ if (type === void 0) return;
511
+ if (typeof type === "string" && SCHEMA_TYPES.has(type)) return;
512
+ throw malformed(toolName, `type 必须是单个类型名(${[...SCHEMA_TYPES].join("/")}),收到 ${JSON.stringify(type)}`, messages);
513
+ }
514
+ /** `oneOf` 的取值形态:至少两项的分支数组(单项等价于直接写那个分支,多半是漏写了别的分支)。 */
515
+ function assertOneOfShape(oneOf, toolName, messages) {
516
+ if (oneOf === void 0) return;
517
+ if (Array.isArray(oneOf) && oneOf.length >= 2) return;
518
+ throw malformed(toolName, "oneOf 必须是至少两项的数组", messages);
519
+ }
520
+ /** `enum` 的取值形态:非空数组。写成字符串时 `enum.includes` 会退化成子串匹配。 */
521
+ function assertEnumShape(enumValues, toolName, messages) {
522
+ if (enumValues === void 0) return;
523
+ if (Array.isArray(enumValues) && enumValues.length > 0) return;
524
+ throw malformed(toolName, "enum 必须是非空数组", messages);
525
+ }
526
+ /**
527
+ * schema 里的关键字必须全部落在守卫支持的子集内(递归查 oneOf/properties/items),
528
+ * 关键字的**取值形态**也必须合法。
529
+ *
530
+ * 违例抛错而不是当违规值报:schema 是插件自己的代码,错的是代码不是模型的数据。
531
+ *
532
+ * 为什么取值形态也要查:只查键名的话,`type: ["string","null"]` 这种官方子集不认的
533
+ * 写法会让 `scalarMismatch` 走进最后那支 `value !== null`——于是 `null` 放行、字符串
534
+ * 反而判违规,守卫给出的结论与它自己声称支持的子集无关。`enum` 写成字符串同理:
535
+ * 走到 `enum.includes` 时成了子串匹配,非字符串值还会抛 `map is not a function`。
536
+ */
537
+ function assertGuardable(schema, toolName, messages) {
538
+ assertKnownKeywords(schema, toolName, messages);
539
+ const { type, oneOf, enum: enumValues } = schema;
540
+ if (type !== void 0 && oneOf !== void 0) throw malformed(toolName, "type 与 oneOf 不能同时声明", messages);
541
+ assertTypeShape(type, toolName, messages);
542
+ assertOneOfShape(oneOf, toolName, messages);
543
+ assertEnumShape(enumValues, toolName, messages);
544
+ for (const branch of Array.isArray(oneOf) ? oneOf : []) assertGuardable(branch, toolName, messages);
363
545
  for (const property of Object.values(schema.properties ?? {})) assertGuardable(property, toolName, messages);
364
546
  const { items } = schema;
365
547
  if (items !== void 0) assertGuardable(items, toolName, messages);
@@ -421,8 +603,20 @@ const AUTH_HEADER_VALUE = /(?<name>\b(?:proxy-)?authorization["'\s]*[:=]["'\s]*)
421
603
  const BEARER_VALUE = /(?<scheme>\bbearer[ \t]+)[a-z0-9._~+/=-]{4,}/giu;
422
604
  /** `api_key: "xxx"`、`apiKey=xxx`、`token: xxx`、`x-api-key: xxx` 等赋值形态。
423
605
  * 值下限取 4 且容许一个 scheme 词:`token: Basic <base64>` 里赋值形态先能吃到的只是
424
- * scheme 词(Basic 恰好 5 字符),下限更高时整条失配,后面那个 base64 就原样进了日志。 */
425
- const KEY_ASSIGNMENT = /(?<name>\b(?:api[_-]?key|apikey|access[_-]?key|secret[_-]?key|client[_-]?secret|password|token)["'\s]*[:=]["'\s]*)(?:(?<scheme>[a-z][a-z0-9_-]*[ \t]+))?[^\s"',;)}\]]{4,}/giu;
606
+ * scheme 词(Basic 恰好 5 字符),下限更高时整条失配,后面那个 base64 就原样进了日志。
607
+ *
608
+ * 左边界用 `(?<![A-Za-z0-9])` 而**不是** `\b`:`_` 也属 `\w`,`DEEPSEEK_API_KEY=` 里
609
+ * `KEY` 之前的那个位置对 `\b` 不成立,于是最常见的环境变量写法整条失配——而「把
610
+ * `XXX_API_KEY=…` 抹掉」正是这道闸最该管的一类。
611
+ *
612
+ * 字符类写 `[A-Z0-9]` 而不是 `[A-Za-z0-9]`:这条正则带 `i` 标志,两者等价,
613
+ * 而重复的字符区间会让静态检查报 duplicates-in-character-class。 */
614
+ const KEY_ASSIGNMENT = /(?<name>(?<![A-Z0-9])(?:api[_-]?key|apikey|access[_-]?key|secret[_-]?key|client[_-]?secret|password|token)["'\s]*[:=]["'\s]*)(?:(?<scheme>[a-z][a-z0-9_-]*[ \t]+))?[^\s"',;)}\]]{4,}/giu;
615
+ /** GitHub 系 token 的**下划线**形态(`ghp_…`、`github_pat_…`):STANDALONE_KEY 要求
616
+ * 「字母前缀 + 连字符」分界,这类值里没有连字符,前缀后又紧跟下划线,故单列一条。
617
+ * 不并进 STANDALONE_KEY 是为了不放开那里的连字符要求——那条要求同时承担「别把
618
+ * kebab 小写路径当 key 抹掉」的职责。 */
619
+ const UNDERSCORE_TOKEN = /\b(?:gh[pousr]|github_pat)_[A-Za-z0-9_]{20,}\b/gu;
426
620
  const REDACTION = "***REDACTED***";
427
621
  /** 独立 key 形态的替换臂:随机串段里真有数字或大写才抹,否则原样交回(文件路径要保持可读)。 */
428
622
  function redactStandaloneKey(match) {
@@ -441,7 +635,7 @@ function redactStandaloneKey(match) {
441
635
  * 要求随机串特征,避免把要交给模型的文件路径一起抹掉。
442
636
  */
443
637
  function redactKeyMaterial(text) {
444
- return text.replaceAll(AUTH_HEADER_VALUE, `$<name>$<scheme>${REDACTION}`).replaceAll(BEARER_VALUE, `$<scheme>${REDACTION}`).replaceAll(KEY_ASSIGNMENT, `$<name>$<scheme>${REDACTION}`).replaceAll(STANDALONE_KEY, redactStandaloneKey);
638
+ return text.replaceAll(AUTH_HEADER_VALUE, `$<name>$<scheme>${REDACTION}`).replaceAll(BEARER_VALUE, `$<scheme>${REDACTION}`).replaceAll(KEY_ASSIGNMENT, `$<name>$<scheme>${REDACTION}`).replaceAll(UNDERSCORE_TOKEN, REDACTION).replaceAll(STANDALONE_KEY, redactStandaloneKey);
445
639
  }
446
640
  /** ECMA-48 的 CSI 序列:ESC `[` + 参数位 + 中间位 + 终结位(SGR 颜色码就是终结位 `m`)。 */
447
641
  const ANSI_CSI = new RegExp(`${String.fromCodePoint(27)}\\[[0-?]*[ -/]*[@-~]`, "gu");
@@ -466,8 +660,8 @@ function violationsOf(schema, value, path) {
466
660
  if (passing.length !== 1) violations.push(`${path}: matched ${passing.length} of ${oneOf.length} oneOf branches`);
467
661
  }
468
662
  const { type } = schema;
469
- if (type === "object") violations.push(...objectViolations(schema, value, path, violationsOf));
470
- else if (type === "array") violations.push(...arrayViolations(schema, value, path, violationsOf));
663
+ if (type === "object") for (const violation of objectViolations(schema, value, path, violationsOf)) violations.push(violation);
664
+ else if (type === "array") for (const violation of arrayViolations(schema, value, path, violationsOf)) violations.push(violation);
471
665
  else if (type !== void 0 && scalarMismatch(type, value)) violations.push(`${path}: expected ${type}`);
472
666
  if (schema.const !== void 0 && value !== schema.const) violations.push(`${path}: must be exactly ${JSON.stringify(schema.const)}`);
473
667
  if (schema.enum !== void 0 && !schema.enum.includes(value)) violations.push(`${path}: must be one of ${schema.enum.map((entry) => JSON.stringify(entry)).join("/")}`);
@@ -498,8 +692,7 @@ function assertToolOutput(schema, value, toolName, messages) {
498
692
  }
499
693
  return value;
500
694
  }
501
- //#endregion
502
- //#region lib/argv-guard.ts
695
+ // lib/argv-guard.ts
503
696
  /**
504
697
  * POSIX sh 单引号转义:' → '\''。把任意字符串安全放进单引号内,防注入。
505
698
  *
@@ -513,22 +706,96 @@ function shq(value) {
513
706
  return `'${value.replaceAll("'", String.raw`'\''`)}'`;
514
707
  }
515
708
  /**
516
- * 绝对路径校验(macOS/Linux):非空、trim 后以 / 开头、无 NUL 字节。
709
+ * 绝对路径校验:非空、trim 后是绝对路径、无 NUL 字节;交出前按路径形态归一成唯一形态。
517
710
  *
518
711
  * 先 trim 再判:调用点会先 trim 再传进来,而直接调用 `args.repo` 的路径若带首尾空白,
519
712
  * 会被「必须是绝对路径」这条误导性文案拦下——读起来像是路径写错了格式,实际只是多了
520
713
  * 几个空格。顺带也挡住全空白(trim 后为空 ⇒ 触发 repoRequired)。
714
+ *
715
+ * 归一(posix.normalize + 去尾随斜杠)的目的:同一个仓库只允许一种字符串形态到达下游。
716
+ * `/repo//sub`、`/repo/./a`、`/repo/` 与 `/repo/sub` 是同一目录的几种写法,而会话目录
717
+ * 等按路径派生的键会把这些写法各存一份;`..` 段按词法折叠成实际目标(`/repo/../..`
718
+ * 落到 `/`)——折叠是 normalize 对绝对路径的既有行为,不存在「解析后仍含 `..`」的
719
+ * 形态,所以这里没有可写的拒绝分支。
521
720
  */
522
721
  function assertAbsoluteRoot(root, messages) {
523
722
  const trimmed = typeof root === "string" ? root.trim() : "";
524
723
  if (trimmed.length === 0) throw new Error(messages.repoRequired);
525
724
  if (trimmed.includes("\0")) throw new Error(format(messages.noNulBytes, { name: "repo" }));
526
- if (!trimmed.startsWith("/")) throw new Error(format(messages.repoMustBeAbsolute, { root: trimmed }));
527
- return trimmed;
725
+ if (trimmed.startsWith("/")) {
726
+ const normalized = path.posix.normalize(trimmed);
727
+ return normalized.length > 1 ? normalized.replace(/\/+$/u, "") : normalized;
728
+ }
729
+ if (process.platform === "win32" && path.win32.isAbsolute(trimmed)) {
730
+ const normalized = path.win32.normalize(trimmed);
731
+ return normalized.length > 3 ? normalized.replace(/[\\/]+$/u, "") : normalized;
732
+ }
733
+ throw new Error(format(messages.repoMustBeAbsolute, { root: trimmed }));
734
+ }
735
+ /**
736
+ * `--rule` / `--background-file` 的路径闸:只允许 repo 根内的普通文件。
737
+ *
738
+ * 这两个 flag 会把文件内容读进 OCR 并可能送给 LLM;OCR 子进程以
739
+ * `danger-full-access` 运行,若放任 `../../.ssh/id_rsa` 之类的路径,一次工具调用就能
740
+ * 把仓库外的文件送进模型。realpath 先解开符号链接再比较,`path.resolve` 的文本前缀
741
+ * 判据挡不住指向仓库外的软链。
742
+ *
743
+ * @param root - 已校验为绝对路径的 repo 根
744
+ * @param value - 模型给的路径(相对 repo 或绝对)
745
+ * @param name - 参数名(报错点名)
746
+ * @param maxBytes - 允许的字节上限
747
+ * @param messages - 双语文案
748
+ * @returns 归一后的绝对路径
749
+ */
750
+ function resolveRepoFile(root, value, name, maxBytes, messages) {
751
+ if (typeof value !== "string") throw new TypeError(format(messages.mustBeString, {
752
+ name,
753
+ received: typeof value
754
+ }));
755
+ const text = value.trim();
756
+ if (text === "") throw new Error(format(messages.repoFileMissing, {
757
+ name,
758
+ path: ""
759
+ }));
760
+ if (text.includes("\0")) throw new Error(format(messages.noNulBytes, { name }));
761
+ let rootReal;
762
+ try {
763
+ rootReal = realpathSync(root);
764
+ } catch (error) {
765
+ throw new Error(format(messages.repoRootUnreadable, { path: root }), { cause: error });
766
+ }
767
+ let real;
768
+ try {
769
+ real = realpathSync(path.resolve(rootReal, text));
770
+ } catch (error) {
771
+ throw new Error(format(messages.repoFileMissing, {
772
+ name,
773
+ path: text
774
+ }), { cause: error });
775
+ }
776
+ const rel = path.relative(rootReal, real);
777
+ if (path.isAbsolute(rel) || rel === ".." || rel.startsWith(`..${path.sep}`)) throw new Error(format(messages.repoFileOutside, {
778
+ name,
779
+ path: text
780
+ }));
781
+ const stat = statSync(real);
782
+ if (!stat.isFile()) throw new Error(format(messages.repoFileNotFile, {
783
+ name,
784
+ path: text
785
+ }));
786
+ const { size } = stat;
787
+ if (size > maxBytes) throw new Error(format(messages.repoFileTooLarge, {
788
+ name,
789
+ path: text,
790
+ limit: String(maxBytes)
791
+ }));
792
+ return real;
528
793
  }
529
794
  /** limit 非法(NaN / ≤0 / 非整数)时的保守兜底上限:宁可比调用方的意图更严。 */
530
795
  const DEFAULT_LIST_LIMIT = 200;
796
+ /** `auto` = 不传 `--effort`,让 OCR 自己的配置决定;工具 schema 仍只暴露三档。 */
531
797
  const EFFORTS = [
798
+ "auto",
532
799
  "low",
533
800
  "medium",
534
801
  "high"
@@ -537,11 +804,28 @@ const EFFORTS = [
537
804
  function clampEffort(value, fallback) {
538
805
  return EFFORTS.find((level) => level === value) ?? fallback;
539
806
  }
807
+ /** 字符串里第一个落在 glob 花括号内的逗号位置(没有则 -1)。 */
808
+ function firstCommaInsideBraces(text) {
809
+ let depth = 0;
810
+ for (let index = 0; index < text.length; index += 1) {
811
+ const ch = text[index];
812
+ if (ch === "{") depth += 1;
813
+ else if (ch === "}" && depth > 0) depth -= 1;
814
+ else if (ch === "," && depth > 0) return index;
815
+ }
816
+ return -1;
817
+ }
540
818
  /** 入参 → 原始项序列(commaList 的第一段:只判「值从哪来」,不判内容)。
541
819
  * 数组分支保留逐元素 for-of 而不是 `value.map`:map 会跳过稀疏数组的空洞,
542
820
  * 空洞本是「非字符串元素」这一条该拦的形态,跳过就等于静默少一项过滤条件。 */
543
821
  function listSourceItems(value, name, messages) {
544
- if (typeof value === "string") return value.split(",");
822
+ if (typeof value === "string") {
823
+ if (firstCommaInsideBraces(value) >= 0) throw new TypeError(format(messages.commaInStringBraces, {
824
+ name,
825
+ item: value
826
+ }));
827
+ return value.split(",");
828
+ }
545
829
  if (Array.isArray(value)) {
546
830
  const items = [];
547
831
  for (const item of value) {
@@ -567,14 +851,15 @@ function takeTrimmedItems(items, name, limit, messages) {
567
851
  if (trimmed.length > 0 && out.length >= cap) dropped += 1;
568
852
  else if (trimmed.length > 0) out.push(trimmed);
569
853
  }
570
- if (dropped > 0) throw new Error(format(messages.listTruncated, {
854
+ if (dropped > 0) throw new Error(format(messages.listOverLimit, {
571
855
  name,
572
856
  dropped: String(dropped),
573
857
  limit: String(cap)
574
858
  }));
575
859
  return out;
576
860
  }
577
- /** 字符串数组 → 逗号拼接(--exclude/--path 契约)。
861
+ /** 入参 → 规范化条目数组(--exclude/--path 契约)。返回值是**条目数组**;把数组按逗号
862
+ * 拼接成 flag 值由调用点负责(lib/cli.ts 的 `shq(excludes.join(","))`)。
578
863
  * 元素类型严格:`value.map(String)` 会把对象变成 `[object Object]` 静默混入
579
864
  * 模式串(过滤条件失效 = 审查范围被扩大),故非字符串元素一律抛错。
580
865
  * 防注入由调用点 shq() 兜底。 */
@@ -601,8 +886,7 @@ const REVIEW_OUTPUT_FORMATS = [
601
886
  function resolveOutputFormat(value) {
602
887
  return REVIEW_OUTPUT_FORMATS.find((candidate) => candidate === value) ?? "json";
603
888
  }
604
- //#endregion
605
- //#region lib/cli.ts
889
+ // lib/cli.ts
606
890
  /** `ocr review|scan|delegate preview` 的背景说明 flag(三处命令构造共用一条)。
607
891
  * pushTextFlag 按 `flag.slice(2)` 反推报错用的参数名,故值必须带 `--` 前缀。 */
608
892
  /** `--exclude` 的模式条数上限。**不是审查范围的上限**,只是防一条失控输入把命令串撑爆;
@@ -610,6 +894,10 @@ function resolveOutputFormat(value) {
610
894
  const EXCLUDE_LIST_LIMIT = 2e3;
611
895
  /** `--path` 的路径条数上限(同 EXCLUDE_LIST_LIMIT 的取向:够大 + 超限告警)。 */
612
896
  const PATH_LIST_LIMIT = 2e3;
897
+ /** `--rule` / `--background-file` 的文件大小上限:规则是检查清单,背景是需求文档。 */
898
+ const RULE_FILE_MAX_BYTES = 262144;
899
+ const BACKGROUND_FILE_MAX_BYTES = 1048576;
900
+ const MAX_GIT_PROCS_FLAG = "--max-git-procs";
613
901
  const BACKGROUND_FLAG = "--background";
614
902
  /**
615
903
  * 本包的契约是按外部 `ocr` CLI 的哪一版核对的(README 的前置条件、装法建议与工具回显里
@@ -620,19 +908,27 @@ const BACKGROUND_FLAG = "--background";
620
908
  * test/readme-parity.test.ts 再断言两侧 README 写的也是它。三处只有一处是人写的、
621
909
  * 靠门禁钉住另两处,就不会出现「README 教用户装 A、代码按 B 校验」这种两边各自自洽的错。
622
910
  */
623
- const EXPECTED_OCR_CLI_VERSION = "1.13.0";
624
- /** root 解析:显式绝对路径优先;缺失/空串回退到当前会话工作区。 */
625
- function resolveRoot(value, fallback, messages) {
626
- if (typeof value === "string" && value.trim().length > 0) return assertAbsoluteRoot(value.trim(), messages);
627
- if (typeof fallback === "string" && fallback.trim().length > 0) return assertAbsoluteRoot(fallback.trim(), messages);
628
- throw new Error(messages.repoRequiredUnknownWorkspace);
629
- }
911
+ const EXPECTED_OCR_CLI_VERSION = "1.13.3";
630
912
  /** 报错用的值形态(永不调用可能被劫持的 toJSON/toString)。host.ts 的入参闸
631
913
  * (effort/wait)复用同一口径,故导出。 */
632
914
  function describeValue(value) {
633
915
  if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return String(value);
634
916
  return Array.isArray(value) ? "array" : typeof value;
635
917
  }
918
+ /** root 解析:显式绝对路径优先;缺失/空串回退到当前会话工作区。
919
+ *
920
+ * 错类型必须抛错,不能落进 fallback:宿主对工具入参零校验(见 `idValue` 的说明),
921
+ * `{repo: 123}` 静默回退成当前会话工作区,等于「调用方明确指定了仓库」被换成另一个
922
+ * 仓库去审,而回执里不会留下任何痕迹。空串/缺省才是「没指定」。 */
923
+ function resolveRoot(value, fallback, messages) {
924
+ if (value !== void 0 && value !== null && typeof value !== "string") throw new TypeError(format(messages.mustBeString, {
925
+ name: "repo",
926
+ received: describeValue(value)
927
+ }));
928
+ if (typeof value === "string" && value.trim().length > 0) return assertAbsoluteRoot(value.trim(), messages);
929
+ if (typeof fallback === "string" && fallback.trim().length > 0) return assertAbsoluteRoot(fallback.trim(), messages);
930
+ throw new Error(messages.repoRequiredUnknownWorkspace);
931
+ }
636
932
  /** 字符串标量参数:键存在即必须是字符串。
637
933
  *
638
934
  * 为什么拒绝而不是忽略:宿主对工具入参零校验(dsh tools index.ts
@@ -685,17 +981,73 @@ function pushIntFlag(parts, flag, value, min, messages) {
685
981
  }));
686
982
  parts.push(flag, String(num));
687
983
  }
984
+ /** 布尔 flag:undefined/null 视为 false;其余非布尔值抛错(字符串 "false" 不能当真值)。 */
985
+ function boolFlag(value, name, messages) {
986
+ if (value === void 0 || value === null) return false;
987
+ if (typeof value !== "boolean") throw new TypeError(format(messages.boolMustBeBoolean, {
988
+ name,
989
+ received: describeValue(value)
990
+ }));
991
+ return value;
992
+ }
993
+ function pushBoolFlag(parts, flag, value, name, messages) {
994
+ if (boolFlag(value, name, messages)) parts.push(flag);
995
+ }
996
+ /** `--rule` / `--background-file`:缺省/空串视为未给;其余交给路径闸(repo 根内普通文件)。 */
997
+ function repoFileOf(value, repo, name, maxBytes, messages) {
998
+ if (value === void 0 || value === null) return null;
999
+ if (typeof value === "string" && value.trim() === "") return null;
1000
+ return resolveRepoFile(repo, value, name, maxBytes, messages);
1001
+ }
1002
+ /** 预览与正常审查共用的选择面:exclude、rule、max-git-procs。 */
1003
+ function selectionFlags(args, repo, messages) {
1004
+ const parts = [];
1005
+ const excludes = commaList(args.exclude, "exclude", EXCLUDE_LIST_LIMIT, messages);
1006
+ if (excludes.length > 0) parts.push("--exclude", shq(excludes.join(",")));
1007
+ const rule = repoFileOf(args.rule, repo, "rule", RULE_FILE_MAX_BYTES, messages);
1008
+ if (rule !== null) parts.push("--rule", shq(rule));
1009
+ pushIntFlag(parts, MAX_GIT_PROCS_FLAG, args.maxGitProcs, 1, messages);
1010
+ return parts;
1011
+ }
688
1012
  /** 模式参数解析:workspace(默认)/commit/branch 互斥,非法组合与错类型抛错。
689
1013
  *
690
1014
  * 互斥判定必须在两个模式分支之前一次做完:若先判 commit(`commit 非空` 时条件
691
1015
  * 恒真),后面的「branch 与 commit 互斥」就永不可达,`{scope:branch, commit:x}`
692
1016
  * 会被静默降级成 commit 审查——审错范围却按结论上报。 */
693
- function scopeFlags(args, messages) {
1017
+ const KNOWN_SCOPES = /* @__PURE__ */ new Set([
1018
+ "workspace",
1019
+ "commit",
1020
+ "branch"
1021
+ ]);
1022
+ /** 与 `scope` 冲突时报告的是哪一种范围参数(两者都没给时返回空串)。 */
1023
+ function otherScopeOf(commit, from, to) {
1024
+ if (commit !== "") return "commit";
1025
+ return from === "" && to === "" ? "" : "from/to";
1026
+ }
1027
+ /**
1028
+ * `scope` 是**声明**,不是从别的参数反推出来的结论:先按 from/to/commit 推模式再看
1029
+ * scope,`{scope:"nope", from:"a", to:"b"}` 会被当成 branch 静默放行,而调用方声明的
1030
+ * 范围与真正审的范围不是一回事。
1031
+ */
1032
+ function declaredScopeOf(args, messages) {
694
1033
  const scopeName = idValue(args.scope, "scope", messages).toLowerCase();
695
- const scope = scopeName === "" ? "workspace" : scopeName;
1034
+ if (scopeName === "") return "workspace";
1035
+ if (!KNOWN_SCOPES.has(scopeName)) throw new Error(format(messages.unknownScope, { scope: scopeName }));
1036
+ return scopeName;
1037
+ }
1038
+ function scopeFlags(args, messages) {
1039
+ const scope = declaredScopeOf(args, messages);
1040
+ const explicit = idValue(args.scope, "scope", messages) !== "";
696
1041
  const from = idValue(args.from, "from", messages);
697
1042
  const to = idValue(args.to, "to", messages);
698
1043
  const commit = idValue(args.commit, "commit", messages);
1044
+ if (explicit && scope === "workspace") {
1045
+ const given = otherScopeOf(commit, from, to);
1046
+ if (given !== "") throw new Error(format(messages.scopeMismatch, {
1047
+ scope,
1048
+ given
1049
+ }));
1050
+ }
699
1051
  const wantsBranch = scope === "branch" || from !== "" || to !== "";
700
1052
  const wantsCommit = scope === "commit" || commit !== "";
701
1053
  if (wantsBranch && wantsCommit) throw new Error(messages.scopeBranchCommitExclusive);
@@ -712,7 +1064,6 @@ function scopeFlags(args, messages) {
712
1064
  if (scope === "commit" && commit === "") throw new Error(messages.commitNeedsValue);
713
1065
  return ["--commit", shq(commit)];
714
1066
  }
715
- if (scope !== "workspace") throw new Error(format(messages.unknownScope, { scope }));
716
1067
  return [];
717
1068
  }
718
1069
  /** 可选文本 flag:值缺省/空串则省略,错类型抛错(静默丢弃等于悄悄改变语义)。 */
@@ -996,6 +1347,7 @@ function jsonOnlyFormat(value, messages) {
996
1347
  function buildReviewCommand(args, outputPath, messages) {
997
1348
  const repo = assertAbsoluteRoot(args.repo, messages);
998
1349
  const outputFormat = jsonOnlyFormat(args.format, messages);
1350
+ const preview = boolFlag(args.preview, "preview", messages);
999
1351
  const parts = [
1000
1352
  ocrCommand(),
1001
1353
  "review",
@@ -1004,8 +1356,18 @@ function buildReviewCommand(args, outputPath, messages) {
1004
1356
  "--format",
1005
1357
  outputFormat
1006
1358
  ];
1359
+ const resume = idValue(args.resume, "resume", messages);
1360
+ if (preview) {
1361
+ if (resume !== "") throw new Error(messages.previewRejectsResume);
1362
+ parts.push("--preview", ...scopeFlags(args, messages), ...selectionFlags(args, repo, messages));
1363
+ if (outputPath !== void 0) parts.push("--output", shq(outputPath));
1364
+ return {
1365
+ command: parts.join(" "),
1366
+ workdir: repo
1367
+ };
1368
+ }
1007
1369
  const effort = clampEffort(args.effort, "medium");
1008
- parts.push("--effort", effort);
1370
+ if (effort !== "auto") parts.push("--effort", effort);
1009
1371
  pushIntFlag(parts, "--concurrency", args.concurrency, 1, messages);
1010
1372
  pushIntFlag(parts, "--timeout", args.timeoutMinutes, 0, messages);
1011
1373
  pushIntFlag(parts, "--max-tools", args.maxTools, 0, messages);
@@ -1013,7 +1375,12 @@ function buildReviewCommand(args, outputPath, messages) {
1013
1375
  pushIntFlag(parts, "--max-tokens-budget", args.maxTokensBudget, 0, messages);
1014
1376
  const scopeParts = scopeFlags(args, messages);
1015
1377
  parts.push(...scopeParts, ...optionalFlags(args, messages));
1016
- const resume = idValue(args.resume, "resume", messages);
1378
+ pushBoolFlag(parts, "--no-filter", args.noFilter, "noFilter", messages);
1379
+ const rule = repoFileOf(args.rule, repo, "rule", RULE_FILE_MAX_BYTES, messages);
1380
+ if (rule !== null) parts.push("--rule", shq(rule));
1381
+ const backgroundFile = repoFileOf(args.backgroundFile, repo, "backgroundFile", BACKGROUND_FILE_MAX_BYTES, messages);
1382
+ if (backgroundFile !== null) parts.push("--background-file", shq(backgroundFile));
1383
+ pushIntFlag(parts, MAX_GIT_PROCS_FLAG, args.maxGitProcs, 1, messages);
1017
1384
  if (resume !== "") {
1018
1385
  if (scopeParts.length === 0) throw new Error(messages.resumeNeedsScope);
1019
1386
  parts.push("--resume", shq(resume));
@@ -1031,6 +1398,9 @@ function buildReviewCommand(args, outputPath, messages) {
1031
1398
  function buildScanCommand(args, outputPath, messages) {
1032
1399
  const repo = assertAbsoluteRoot(args.repo, messages);
1033
1400
  const outputFormat = jsonOnlyFormat(args.format, messages);
1401
+ const preview = boolFlag(args.preview, "preview", messages);
1402
+ const resume = idValue(args.resume, "resume", messages);
1403
+ if (preview && resume !== "") throw new Error(messages.previewRejectsResume);
1034
1404
  const parts = [
1035
1405
  ocrCommand(),
1036
1406
  "scan",
@@ -1039,19 +1409,25 @@ function buildScanCommand(args, outputPath, messages) {
1039
1409
  "--format",
1040
1410
  outputFormat
1041
1411
  ];
1412
+ if (preview) parts.push("--preview");
1042
1413
  const paths = commaList(args.path, "path", PATH_LIST_LIMIT, messages);
1043
1414
  if (paths.length > 0) parts.push("--path", shq(paths.join(",")));
1044
- const excludes = commaList(args.exclude, "exclude", EXCLUDE_LIST_LIMIT, messages);
1045
- if (excludes.length > 0) parts.push("--exclude", shq(excludes.join(",")));
1046
- pushTextFlag(parts, "--batch", args.batch, messages);
1047
- pushTextFlag(parts, "--provider", args.provider, messages);
1048
- pushTextFlag(parts, "--model", args.model, messages);
1049
- pushIntFlag(parts, "--concurrency", args.concurrency, 1, messages);
1050
- pushIntFlag(parts, "--timeout", args.timeoutMinutes, 0, messages);
1051
- pushIntFlag(parts, "--max-tools", args.maxTools, 0, messages);
1052
- pushIntFlag(parts, "--max-tokens", args.maxTokens, 0, messages);
1053
- pushIntFlag(parts, "--max-tokens-budget", args.maxTokensBudget, 0, messages);
1054
- pushTextFlag(parts, BACKGROUND_FLAG, args.background, messages);
1415
+ if (!preview) {
1416
+ pushTextFlag(parts, "--batch", args.batch, messages);
1417
+ pushTextFlag(parts, "--provider", args.provider, messages);
1418
+ pushTextFlag(parts, "--model", args.model, messages);
1419
+ pushIntFlag(parts, "--concurrency", args.concurrency, 1, messages);
1420
+ pushIntFlag(parts, "--timeout", args.timeoutMinutes, 0, messages);
1421
+ pushIntFlag(parts, "--max-tools", args.maxTools, 0, messages);
1422
+ pushIntFlag(parts, "--max-tokens", args.maxTokens, 0, messages);
1423
+ pushIntFlag(parts, "--max-tokens-budget", args.maxTokensBudget, 0, messages);
1424
+ pushBoolFlag(parts, "--no-plan", args.noPlan, "noPlan", messages);
1425
+ pushBoolFlag(parts, "--no-dedup", args.noDedup, "noDedup", messages);
1426
+ pushBoolFlag(parts, "--no-summary", args.noSummary, "noSummary", messages);
1427
+ if (resume !== "") parts.push("--resume", shq(resume));
1428
+ }
1429
+ parts.push(...selectionFlags(args, repo, messages));
1430
+ if (!preview) pushTextFlag(parts, BACKGROUND_FLAG, args.background, messages);
1055
1431
  if (outputPath !== void 0) parts.push("--output", shq(outputPath));
1056
1432
  return {
1057
1433
  command: parts.join(" "),
@@ -1076,6 +1452,11 @@ function buildDelegatePreviewCommand(args, messages) {
1076
1452
  const excludes = commaList(args.exclude, "exclude", EXCLUDE_LIST_LIMIT, messages);
1077
1453
  if (excludes.length > 0) parts.push("--exclude", shq(excludes.join(",")));
1078
1454
  pushTextFlag(parts, BACKGROUND_FLAG, args.background, messages);
1455
+ const backgroundFile = repoFileOf(args.backgroundFile, repo, "backgroundFile", BACKGROUND_FILE_MAX_BYTES, messages);
1456
+ if (backgroundFile !== null) parts.push("--background-file", shq(backgroundFile));
1457
+ const rule = repoFileOf(args.rule, repo, "rule", RULE_FILE_MAX_BYTES, messages);
1458
+ if (rule !== null) parts.push("--rule", shq(rule));
1459
+ pushIntFlag(parts, MAX_GIT_PROCS_FLAG, args.maxGitProcs, 1, messages);
1079
1460
  return {
1080
1461
  command: parts.join(" "),
1081
1462
  workdir: repo
@@ -1086,25 +1467,74 @@ function buildDelegateRuleCommand(args, messages) {
1086
1467
  const repo = assertAbsoluteRoot(args.repo, messages);
1087
1468
  const paths = commaList(args.paths, "paths", 200, messages).map((filePath) => shq(filePath));
1088
1469
  if (paths.length === 0) throw new Error(messages.pathsRequired);
1470
+ const parts = [
1471
+ ocrCommand(),
1472
+ "delegate",
1473
+ "rule",
1474
+ "--format",
1475
+ "json"
1476
+ ];
1477
+ const rule = repoFileOf(args.rule, repo, "rule", RULE_FILE_MAX_BYTES, messages);
1478
+ if (rule !== null) parts.push("--rule", shq(rule));
1479
+ parts.push("--", ...paths);
1480
+ return {
1481
+ command: parts.join(" "),
1482
+ workdir: repo
1483
+ };
1484
+ }
1485
+ /** `ocr rules check <file>`:只做规则解析,不跑 LLM;`path` 是待查询的文件路径。 */
1486
+ function buildRulesCheckCommand(args, messages) {
1487
+ const repo = assertAbsoluteRoot(args.repo, messages);
1488
+ const filePath = idValue(args.path, "path", messages);
1489
+ if (filePath === "") throw new Error(messages.rulesPathRequired);
1490
+ const parts = [
1491
+ ocrCommand(),
1492
+ "rules",
1493
+ "check",
1494
+ "--repo",
1495
+ shq(repo)
1496
+ ];
1497
+ const rule = repoFileOf(args.rule, repo, "rule", RULE_FILE_MAX_BYTES, messages);
1498
+ if (rule !== null) parts.push("--rule", shq(rule));
1499
+ parts.push("--", shq(filePath));
1089
1500
  return {
1090
- command: [
1091
- ocrCommand(),
1092
- "delegate",
1093
- "rule",
1094
- "--format",
1095
- "json",
1096
- "--",
1097
- ...paths
1098
- ].join(" "),
1501
+ command: parts.join(" "),
1099
1502
  workdir: repo
1100
1503
  };
1101
1504
  }
1102
- /** session 子命令白名单(子命令名裸拼进命令串,只可能是这三个字面量)。 */
1505
+ /** `ocr llm providers|models`:只读诊断命令,不需要仓库目录。 */
1506
+ function buildLlmCommand(action) {
1507
+ const sub = action === "providers" ? "providers" : "models";
1508
+ return `${ocrCommand()} llm ${sub} --color never`;
1509
+ }
1510
+ /** `ocr auth status`:卡片只读查询 ChatGPT 登录态。 */
1511
+ function buildAuthStatusCommand() {
1512
+ return `${ocrCommand()} auth status --provider chatgpt --color never`;
1513
+ }
1514
+ /** 登录动作保持人工执行(OAuth 要浏览器 + 本地回调),卡片只显示这条命令。 */
1515
+ const CHATGPT_LOGIN_COMMAND = "ocr auth login --provider chatgpt";
1516
+ /** session 子命令白名单(子命令名裸拼进命令串,只可能是这些字面量)。 */
1103
1517
  const SESSION_ACTIONS = [
1104
1518
  "list",
1105
1519
  "show",
1106
- "comments"
1520
+ "comments",
1521
+ "compare",
1522
+ "export"
1523
+ ];
1524
+ const SESSION_SEVERITIES = [
1525
+ "critical",
1526
+ "high",
1527
+ "medium",
1528
+ "low"
1107
1529
  ];
1530
+ /** severity 过滤:大小写归一 + 白名单;未知值报错而不是交给 CLI 返回空集。 */
1531
+ function severityFilter(value, messages) {
1532
+ return commaList(value, "severity", SESSION_SEVERITIES.length, messages).map((item) => {
1533
+ const lower = item.toLowerCase();
1534
+ if (!SESSION_SEVERITIES.includes(lower)) throw new Error(format(messages.invalidSeverity, { value: item }));
1535
+ return lower;
1536
+ });
1537
+ }
1108
1538
  /** session list --limit:缺省 10(OCR 原生默认 20,本包收紧;OCR 的 0=不限在本包
1109
1539
  * 上限 100 下无意义)。显式给出时必须是 1-100 的整数(与工具 description 的承诺
1110
1540
  * 一致),越界/错类型抛错而不是钳制——静默钳到 1 会把「想看更多」的 0 变成
@@ -1115,35 +1545,135 @@ function sessionLimit(value, messages) {
1115
1545
  if (num === null || num < 1 || num > 100) throw new Error(format(messages.sessionLimitRange, { received: describeValue(value) }));
1116
1546
  return num;
1117
1547
  }
1118
- /** 构造 `ocr session list/show/comments` 命令(历史评审会话闭环,Json 输出)。 */
1548
+ function sessionListParts(ctx) {
1549
+ if (ctx.id !== "") throw new Error(ctx.messages.listRejectsId);
1550
+ if (ctx.before !== "" || ctx.after !== "") throw new Error(ctx.messages.beforeAfterOnlyForCompare);
1551
+ if (ctx.hasFilters) throw new Error(ctx.messages.filtersOnlyForComments);
1552
+ return [
1553
+ ocrCommand(),
1554
+ "session",
1555
+ "list",
1556
+ "--json",
1557
+ "--repo",
1558
+ shq(ctx.repo),
1559
+ "--limit",
1560
+ String(sessionLimit(ctx.limit, ctx.messages))
1561
+ ];
1562
+ }
1563
+ function sessionCompareParts(ctx) {
1564
+ if (ctx.before === "" || ctx.after === "") throw new Error(ctx.messages.compareNeedsBoth);
1565
+ if (ctx.id !== "" || ctx.limit !== void 0 && ctx.limit !== null || ctx.hasFilters) throw new Error(ctx.messages.compareRejectsArgs);
1566
+ return [
1567
+ ocrCommand(),
1568
+ "session",
1569
+ "compare",
1570
+ "--json",
1571
+ "--repo",
1572
+ shq(ctx.repo),
1573
+ "--",
1574
+ shq(ctx.before),
1575
+ shq(ctx.after)
1576
+ ];
1577
+ }
1578
+ function sessionExportParts(ctx) {
1579
+ if (ctx.before !== "" || ctx.after !== "" || ctx.limit !== void 0 && ctx.limit !== null) throw new Error(ctx.messages.exportRejectsArgs);
1580
+ if (ctx.hasFilters) throw new Error(ctx.messages.filtersOnlyForComments);
1581
+ if (ctx.output === "") throw new Error(ctx.messages.exportOutputRequired);
1582
+ const parts = [
1583
+ ocrCommand(),
1584
+ "session",
1585
+ "export",
1586
+ "--repo",
1587
+ shq(ctx.repo),
1588
+ "-o",
1589
+ shq(ctx.output)
1590
+ ];
1591
+ if (ctx.id !== "") parts.push("--", shq(ctx.id));
1592
+ return parts;
1593
+ }
1594
+ function sessionCommentsParts(ctx) {
1595
+ if (ctx.before !== "" || ctx.after !== "") throw new Error(ctx.messages.beforeAfterOnlyForCompare);
1596
+ if (ctx.id === "") {
1597
+ if (ctx.hasFilters) throw new Error(ctx.messages.filtersNeedId);
1598
+ return sessionListParts({
1599
+ ...ctx,
1600
+ hasFilters: false
1601
+ });
1602
+ }
1603
+ const parts = [
1604
+ ocrCommand(),
1605
+ "session",
1606
+ "comments",
1607
+ "--json",
1608
+ "--repo",
1609
+ shq(ctx.repo)
1610
+ ];
1611
+ if (ctx.severities.length > 0) parts.push("--severity", shq(ctx.severities.join(",")));
1612
+ if (ctx.categories.length > 0) parts.push("--category", shq(ctx.categories.join(",")));
1613
+ parts.push("--", shq(ctx.id));
1614
+ return parts;
1615
+ }
1616
+ function sessionShowParts(ctx) {
1617
+ if (ctx.before !== "" || ctx.after !== "") throw new Error(ctx.messages.beforeAfterOnlyForCompare);
1618
+ if (ctx.hasFilters) throw new Error(ctx.messages.filtersOnlyForComments);
1619
+ if (ctx.id === "") return sessionListParts({
1620
+ ...ctx,
1621
+ hasFilters: false
1622
+ });
1623
+ return [
1624
+ ocrCommand(),
1625
+ "session",
1626
+ "show",
1627
+ "--json",
1628
+ "--repo",
1629
+ shq(ctx.repo),
1630
+ "--",
1631
+ shq(ctx.id)
1632
+ ];
1633
+ }
1634
+ const SESSION_BUILDERS = {
1635
+ list: sessionListParts,
1636
+ compare: sessionCompareParts,
1637
+ export: sessionExportParts,
1638
+ comments: sessionCommentsParts,
1639
+ show: sessionShowParts
1640
+ };
1641
+ function sessionPartsOf(sub, ctx) {
1642
+ return SESSION_BUILDERS[sub](ctx);
1643
+ }
1644
+ function inferSessionAction(requested, ctx) {
1645
+ if (requested !== void 0) return requested;
1646
+ if (ctx.before !== "" || ctx.after !== "") return "compare";
1647
+ return ctx.id === "" ? "list" : "show";
1648
+ }
1649
+ /** 构造 `ocr session list/show/comments/compare/export` 命令(历史评审会话闭环)。 */
1119
1650
  function buildSessionCommand(args, messages) {
1120
1651
  const repo = assertAbsoluteRoot(args.repo, messages);
1121
- const id = idValue(args.id, "id", messages);
1122
1652
  const action = idValue(args.action, "action", messages);
1123
1653
  const requested = SESSION_ACTIONS.find((name) => name === action);
1124
1654
  if (action !== "" && requested === void 0) throw new Error(format(messages.invalidAction, {
1125
1655
  action,
1126
1656
  expected: SESSION_ACTIONS.join("/")
1127
1657
  }));
1128
- if (requested === "list" && id !== "") throw new Error(messages.listRejectsId);
1129
- let sub;
1130
- if (requested === "comments" || requested === "show") sub = id === "" ? "list" : requested;
1131
- else if (requested === "list") sub = "list";
1132
- else sub = id === "" ? "list" : "show";
1133
- const parts = [
1134
- ocrCommand(),
1135
- "session",
1136
- sub
1137
- ];
1138
- if (sub === "list") parts.push("--json", "--repo", shq(repo), "--limit", String(sessionLimit(args.limit, messages)));
1139
- else parts.push("--json", "--repo", shq(repo), shq(id));
1658
+ const ctx = {
1659
+ repo,
1660
+ id: idValue(args.id, "id", messages),
1661
+ before: idValue(args.before, "before", messages),
1662
+ after: idValue(args.after, "after", messages),
1663
+ severities: severityFilter(args.severity, messages),
1664
+ categories: commaList(args.category, "category", 50, messages),
1665
+ hasFilters: false,
1666
+ limit: args.limit,
1667
+ output: idValue(args.output, "output", messages),
1668
+ messages
1669
+ };
1670
+ ctx.hasFilters = ctx.severities.length > 0 || ctx.categories.length > 0;
1140
1671
  return {
1141
- command: parts.join(" "),
1672
+ command: sessionPartsOf(inferSessionAction(requested, ctx), ctx).join(" "),
1142
1673
  workdir: repo
1143
1674
  };
1144
1675
  }
1145
- //#endregion
1146
- //#region lib/severity.ts
1676
+ // lib/severity.ts
1147
1677
  const SEVERITY_ORDER = [
1148
1678
  "critical",
1149
1679
  "high",
@@ -1162,7 +1692,7 @@ const SEVERITY_ORDER = [
1162
1692
  * known 一并回传:归一是「必须发生」的降级,但降级本身不该是**无痕**的——调用方要能
1163
1693
  * 统计出「有多少条走了兜底」,否则契约异常被彻底吞掉,聚合里看不出来、排序里也看不出来。
1164
1694
  */
1165
- function normalizeSeverity(severity) {
1695
+ function classifySeverity(severity) {
1166
1696
  const matched = SEVERITY_ORDER.find((level) => level === severity.trim().toLowerCase());
1167
1697
  return {
1168
1698
  level: matched ?? "medium",
@@ -1171,7 +1701,7 @@ function normalizeSeverity(severity) {
1171
1701
  }
1172
1702
  /** 排序位:critical→high→medium→low(未知已归 medium,永不为 -1)。 */
1173
1703
  function severityRank(severity) {
1174
- return SEVERITY_ORDER.indexOf(normalizeSeverity(severity).level);
1704
+ return SEVERITY_ORDER.indexOf(classifySeverity(severity).level);
1175
1705
  }
1176
1706
  /** 聚合评论:按严重度/类别计数。severity 归类以 critical→high→medium→low 基准。 */
1177
1707
  function aggregateComments(comments) {
@@ -1184,10 +1714,11 @@ function aggregateComments(comments) {
1184
1714
  const categoryCounts = /* @__PURE__ */ new Map();
1185
1715
  let unknownSeverityCount = 0;
1186
1716
  for (const comment of comments) {
1187
- const { level, known } = normalizeSeverity(comment.severity);
1717
+ const { level, known } = classifySeverity(comment.severity);
1188
1718
  bySeverity[level] += 1;
1189
1719
  if (!known) unknownSeverityCount += 1;
1190
- if (comment.category) categoryCounts.set(comment.category, (categoryCounts.get(comment.category) ?? 0) + 1);
1720
+ const category = comment.category.trim();
1721
+ if (category !== "") categoryCounts.set(category, (categoryCounts.get(category) ?? 0) + 1);
1191
1722
  }
1192
1723
  const byCategory = Object.fromEntries(categoryCounts);
1193
1724
  return {
@@ -1197,16 +1728,19 @@ function aggregateComments(comments) {
1197
1728
  unknownSeverityCount
1198
1729
  };
1199
1730
  }
1200
- //#endregion
1201
- //#region lib/parse.ts
1731
+ // lib/parse.ts
1202
1732
  /**
1203
1733
  * OCR 评审 JSON 的 status 枚举(判据在 CLI 侧 `cmd/opencodereview/output.go` 的 jsonOutput
1204
1734
  * 装配与 `internal/session/manifest.go` 的 TerminalState)。
1205
1735
  * 白名单外的一律视为失败:exit 0 不等于评审成功——`{"error":"auth failed"}`
1206
1736
  * 这类「没有 status 的错误对象」若被当作 ok:true,模型会把「0 条评论」读成
1207
- * 「代码干净」,是最坏的一类假阳性。`failed` 也刻意留在白名单外:CLI 的退出契约是
1208
- * 「全场失败才非零」(`review_cmd.go` 的 reviewResultError),那份文档随非零退出
1209
- * 一起被 shell 侧的失败详情拦下,本解析拿不到它。
1737
+ * 「代码干净」,是最坏的一类假阳性。
1738
+ *
1739
+ * `failed` 在名单里,但**单独判成 ok:false**(见 `parseReviewOutput`)。两件事要分开:
1740
+ * ① 名字得认——非零退出时 CLI 照发的那份 manifest 就带 `status:"failed"`,
1741
+ * `withCoverageDetail` 要靠它读出「哪些文件没审成」;不认这个值就等于把失败明细整条丢掉。
1742
+ * ② 认了不等于成功——万一它带着 exit 0 回来,把它当 ok:true 就会把一份全场失败的回执
1743
+ * 摘要成「0 条评论」,那正是本段开头要避免的假阳性。
1210
1744
  *
1211
1745
  * partial 是 manifest 终态之一:部分文件失败但整体有覆盖、以及 token 预算截断后仍有
1212
1746
  * 覆盖,都以 exit 0 + status "partial" 输出。此前白名单漏了它,整场已花费 token 的
@@ -1240,9 +1774,8 @@ function str(value) {
1240
1774
  * 故作兜底值。
1241
1775
  */
1242
1776
  function nonNegativeInt(value) {
1243
- const truncated = Math.trunc(value);
1244
- if (!Number.isSafeInteger(truncated) || truncated < 0) return 0;
1245
- return truncated;
1777
+ if (!Number.isSafeInteger(value) || value < 0) return 0;
1778
+ return value;
1246
1779
  }
1247
1780
  /** 数值字段解析:number 与「数字字符串」都接受(OCR 部分 provider 实测把 start_line
1248
1781
  * 输出成 "42",一律当 0 会把真实评论判成「未锚定」而被摘要丢掉),空串/NaN/Infinity/
@@ -1265,6 +1798,42 @@ const DIAGNOSTIC_DETAIL_LIMIT = 8;
1265
1798
  function boolOf(value) {
1266
1799
  return value === true;
1267
1800
  }
1801
+ /** 走 plan 预算/工具轮次上限的两枚警告:它们是「限流截断」的唯一结构化信号。 */
1802
+ const LIMIT_WARNING_TYPES = /* @__PURE__ */ new Set(["review_limit_reached", "token_budget_reached"]);
1803
+ /** 把 project_summary 截到摘要预算内;截断事实单独带出,别让模型把半截当全文。 */
1804
+ const PROJECT_SUMMARY_LIMIT = 4e3;
1805
+ /** `llm` 身份:两个字段都读不出时回 null,而不是造一个空模型。 */
1806
+ function llmOf(raw) {
1807
+ if (!isRecord(raw)) return null;
1808
+ const provider = str(raw["provider"]).trim();
1809
+ const model = str(raw["model"]).trim();
1810
+ if (provider === "" && model === "") return null;
1811
+ return {
1812
+ provider: provider === "" ? null : provider,
1813
+ model
1814
+ };
1815
+ }
1816
+ /** `resume` 回执:字段全空时回 null(CLI 只在真的续审时发这个对象)。 */
1817
+ function retryOf(raw) {
1818
+ if (!isRecord(raw)) return null;
1819
+ const view = {
1820
+ totalRetries: num(raw["total_retries"]),
1821
+ recoveredRequests: num(raw["recovered_requests"]),
1822
+ failedRequests: num(raw["failed_requests"])
1823
+ };
1824
+ return view.totalRetries === 0 && view.recoveredRequests === 0 && view.failedRequests === 0 ? null : view;
1825
+ }
1826
+ function resumeOf(raw) {
1827
+ if (!isRecord(raw)) return null;
1828
+ const view = {
1829
+ resumedFrom: str(raw["resumed_from"]),
1830
+ reusedFiles: num(raw["reused_files"]),
1831
+ rerunFiles: num(raw["rerun_files"]),
1832
+ previousModel: str(raw["previous_model"]),
1833
+ currentModel: str(raw["current_model"])
1834
+ };
1835
+ return view.resumedFrom === "" && view.reusedFiles === 0 && view.rerunFiles === 0 && view.previousModel === "" && view.currentModel === "" ? null : view;
1836
+ }
1268
1837
  /**
1269
1838
  * 明细行收尾:截断时补一条「另有 N 条已省略」,让读的人知道后面还有,而不是以为看到了全部。
1270
1839
  * @param lines - 已渲染的明细行
@@ -1401,6 +1970,14 @@ function reviewFailure(status, message) {
1401
1970
  skipped: false,
1402
1971
  summary: null,
1403
1972
  budgetExceeded: false,
1973
+ limitReached: false,
1974
+ llm: null,
1975
+ projectSummary: null,
1976
+ projectSummaryTruncated: false,
1977
+ resume: null,
1978
+ groupCount: 0,
1979
+ retry: null,
1980
+ traceId: null,
1404
1981
  comments: [],
1405
1982
  warnings: [],
1406
1983
  toolCalls: null,
@@ -1423,9 +2000,13 @@ function reviewFailure(status, message) {
1423
2000
  */
1424
2001
  function diagnosticsOf(root, messages) {
1425
2002
  const warnings = [];
2003
+ let limitReached = false;
1426
2004
  const warningsRaw = root["warnings"];
1427
2005
  if (warningsRaw !== void 0) {
1428
- if (Array.isArray(warningsRaw)) for (const warn of warningsRaw) warnings.push(warningLineOf(warn, messages));
2006
+ if (Array.isArray(warningsRaw)) for (const warn of warningsRaw) {
2007
+ warnings.push(warningLineOf(warn, messages));
2008
+ limitReached ||= isRecord(warn) && LIMIT_WARNING_TYPES.has(str(warn["type"]));
2009
+ }
1429
2010
  else warnings.push(messages.warningUnreadable);
1430
2011
  }
1431
2012
  const toolCalls = toolCallsOf(root["tool_calls"], messages);
@@ -1435,7 +2016,8 @@ function diagnosticsOf(root, messages) {
1435
2016
  return {
1436
2017
  warnings,
1437
2018
  toolCalls: toolCalls.view,
1438
- coverage: coverage.view
2019
+ coverage: coverage.view,
2020
+ limitReached
1439
2021
  };
1440
2022
  }
1441
2023
  /**
@@ -1469,13 +2051,17 @@ function parseReviewOutput(text, messages) {
1469
2051
  else comments.push(comment);
1470
2052
  }
1471
2053
  const skipped = status === "skipped";
2054
+ const ok = status !== "failed";
2055
+ const message = str(root["message"]) || (ok ? "" : messages.ocrStatusFailed);
1472
2056
  const sumRaw = root["summary"];
1473
2057
  const sum = isRecord(sumRaw) ? sumRaw : void 0;
1474
2058
  const diagnostics = diagnosticsOf(root, messages);
2059
+ const rawProjectSummary = str(root["project_summary"]);
2060
+ const projectSummaryTruncated = rawProjectSummary.length > PROJECT_SUMMARY_LIMIT;
1475
2061
  return {
1476
- ok: true,
2062
+ ok,
1477
2063
  status,
1478
- message: str(root["message"]),
2064
+ message,
1479
2065
  skipped,
1480
2066
  summary: sum ? {
1481
2067
  filesReviewed: num(sum["files_reviewed"]),
@@ -1483,9 +2069,19 @@ function parseReviewOutput(text, messages) {
1483
2069
  totalTokens: num(sum["total_tokens"]),
1484
2070
  inputTokens: num(sum["input_tokens"]),
1485
2071
  outputTokens: num(sum["output_tokens"]),
2072
+ cacheReadTokens: num(sum["cache_read_tokens"]),
2073
+ cacheWriteTokens: num(sum["cache_write_tokens"]),
1486
2074
  elapsed: str(sum["elapsed"])
1487
2075
  } : null,
1488
2076
  budgetExceeded: sum === void 0 ? false : boolOf(sum["budget_exceeded"]),
2077
+ limitReached: diagnostics.limitReached,
2078
+ llm: llmOf(root["llm"]),
2079
+ projectSummary: rawProjectSummary === "" ? null : rawProjectSummary.slice(0, PROJECT_SUMMARY_LIMIT),
2080
+ projectSummaryTruncated,
2081
+ resume: resumeOf(root["resume"]),
2082
+ groupCount: Array.isArray(root["groups"]) ? root["groups"].length : 0,
2083
+ retry: retryOf(root["retry_report"]),
2084
+ traceId: str(root["trace_id"]) || null,
1489
2085
  comments,
1490
2086
  warnings: diagnostics.warnings,
1491
2087
  toolCalls: diagnostics.toolCalls,
@@ -1521,6 +2117,14 @@ function summarizeReview(parsed, messages, plan = DEFAULT_PLAN) {
1521
2117
  message: parsed.message || null,
1522
2118
  summary: parsed.summary ?? null,
1523
2119
  budgetExceeded: parsed.budgetExceeded,
2120
+ limitReached: parsed.limitReached,
2121
+ llm: parsed.llm,
2122
+ projectSummary: parsed.projectSummary,
2123
+ projectSummaryTruncated: parsed.projectSummaryTruncated,
2124
+ resume: parsed.resume,
2125
+ groupCount: parsed.groupCount,
2126
+ retry: parsed.retry,
2127
+ traceId: parsed.traceId,
1524
2128
  sessionId: parsed.sessionId || null,
1525
2129
  aggregation: agg,
1526
2130
  totalCommentCount: parsed.comments.length,
@@ -1677,327 +2281,1034 @@ function parseDelegateRules(text) {
1677
2281
  groups
1678
2282
  };
1679
2283
  }
1680
- //#endregion
1681
- //#region lib/routing.ts
1682
- const ROUTING_NAME = "ocr-review-routing";
1683
- const ROUTING_ORDER = 1555;
1684
- //#endregion
1685
- //#region lib/provider-projection.ts
1686
- /** llm-pi-ai 命名空间里 provider 表的字段名。 */
1687
- const PROVIDERS_FIELD = "providers";
1688
- /**
1689
- * llm-pi-ai 的 `api` 协议 → OCR config 的 `protocol` 映射。
1690
- *
1691
- * 两侧枚举的对照(dsh packages/llm/llm-pi-ai/src/provider.ts:44-48 的 PROTOCOLS
1692
- * 对 OCR config_cmd.go:796 的 "Protocol values: anthropic, anthropic-bedrock,
1693
- * openai, openai-responses"):openai-completions 与 OCR 的 openai 是同一条
1694
- * OpenAI Chat Completions 线协议;anthropic-messages 即 OCR 的 anthropic。
1695
- * 此前「非 openai-responses 一律 openai」的二值映射会把 anthropic-messages 的
1696
- * 端点按 OpenAI 线协议打出去——select 成功、OCR 调不通。表外值透传,让
1697
- * renderSelectedConfig 的白名单给出可读错误。
1698
- */
1699
- const OCR_PROTOCOL_BY_API = /* @__PURE__ */ new Map([
1700
- ["openai-completions", "openai"],
1701
- ["openai-responses", "openai-responses"],
1702
- ["anthropic-messages", "anthropic"]
1703
- ]);
1704
- /** 对象/数组形态的 `api` 的透传标记:可读,但绝不会被误认成任何合法协议名。 */
1705
- const API_UNREADABLE = "[unreadable]";
2284
+ /** 预览列表的条数上限:整仓扫描可能列出几千个文件,摘要只留足以判断范围的一截。 */
2285
+ const PREVIEW_FILE_LIMIT = 200;
2286
+ function previewFileOf(raw) {
2287
+ if (!isRecord(raw)) return null;
2288
+ const filePath = str(raw["path"]);
2289
+ if (filePath === "") return null;
2290
+ return {
2291
+ path: filePath,
2292
+ status: str(raw["status"]),
2293
+ insertions: num(raw["insertions"]),
2294
+ deletions: num(raw["deletions"]),
2295
+ willReview: raw["will_review"] === true,
2296
+ excludeReason: str(raw["exclude_reason"])
2297
+ };
2298
+ }
1706
2299
  /**
1707
- * settings 里的 `api` 字段 → 协议查表用的键。
2300
+ * 解析 `ocr review --preview --format json` / `ocr scan --preview --format json`。
1708
2301
  *
1709
- * 三种形态必须分开,否则畸形声明会被静默降级成「能用的」配置:
1710
- * - 未声明(undefined / null)⇒ "",交给 llm-pi-ai 的 openai-completions 兜底语义;
1711
- * - 字符串 ⇒ trim 后原样(表外值原样透传,由写侧白名单拒绝);
1712
- * - 数字 / 布尔 ⇒ String(),同样落进「表外值」这条正路;
1713
- * - 对象 / 数组 ⇒ 固定标记。用 String(raw) 会走 Object.prototype.toString 得到
1714
- * "[object Object]",那既无信息量又看着像协议名;显式标记更能说明「这里读不出来」。
2302
+ * 预览是 `model.Preview` 的裸对象(键 `files`),与 review/scan 的摘要**不是**同一
2303
+ * 形状;把它喂给 parseReviewOutput 只会得到一条解析失败。契约键一个都不命中、或
2304
+ * 条目全是坏形状时判失败,不能报成「0 个文件可审」。
1715
2305
  */
1716
- function apiKeyOf(raw) {
1717
- if (raw === void 0 || raw === null) return "";
1718
- if (typeof raw === "string") return raw.trim();
1719
- if (typeof raw === "number" || typeof raw === "boolean") return String(raw);
1720
- return API_UNREADABLE;
2306
+ function parsePreviewOutput(text) {
2307
+ let doc;
2308
+ try {
2309
+ doc = JSON.parse(text);
2310
+ } catch {
2311
+ return {
2312
+ ok: false,
2313
+ view: null
2314
+ };
2315
+ }
2316
+ if (!isRecord(doc) || !Array.isArray(doc["files"])) return {
2317
+ ok: false,
2318
+ view: null
2319
+ };
2320
+ const parsed = doc["files"].map((entry) => previewFileOf(entry)).filter((entry) => entry !== null);
2321
+ if (doc["files"].length > 0 && parsed.length === 0) return {
2322
+ ok: false,
2323
+ view: null
2324
+ };
2325
+ const reviewable = parsed.filter((entry) => entry.willReview);
2326
+ const excluded = parsed.filter((entry) => !entry.willReview);
2327
+ const countOf = (key, fallback) => {
2328
+ const raw = doc[key];
2329
+ return isCount(raw) ? num(raw) : fallback;
2330
+ };
2331
+ return {
2332
+ ok: true,
2333
+ view: {
2334
+ totalFiles: countOf("total_files", parsed.length),
2335
+ reviewableCount: countOf("reviewable_count", reviewable.length),
2336
+ excludedCount: countOf("excluded_count", excluded.length),
2337
+ totalInsertions: num(doc["total_insertions"]),
2338
+ totalDeletions: num(doc["total_deletions"]),
2339
+ reviewableFiles: reviewable.slice(0, PREVIEW_FILE_LIMIT),
2340
+ excludedFiles: excluded.slice(0, PREVIEW_FILE_LIMIT),
2341
+ truncated: reviewable.length > PREVIEW_FILE_LIMIT || excluded.length > PREVIEW_FILE_LIMIT
2342
+ }
2343
+ };
1721
2344
  }
1722
- /** 原始 model 对象 → DshModel(缺 id 视为无效,返回 null)。 */
1723
- function modelFromRaw(modelRaw) {
1724
- if (!isRecord(modelRaw)) return null;
1725
- const rec = modelRaw;
1726
- const id = typeof rec["id"] === "string" ? rec["id"].trim() : "";
1727
- if (!id) return null;
1728
- const name = typeof rec["name"] === "string" ? rec["name"].trim() : "";
2345
+ /** 每个桶的展示上限:四桶全铺开会把 compare 变成第二份全量评论。 */
2346
+ const COMPARE_BUCKET_LIMIT = 20;
2347
+ function compareSideOf(raw) {
1729
2348
  return {
1730
- id,
1731
- name: name === "" ? id : name
2349
+ sessionId: isRecord(raw) ? str(raw["session_id"]) : "",
2350
+ reviewMode: isRecord(raw) ? str(raw["review_mode"]) : "",
2351
+ range: isRecord(raw) ? str(raw["range"]) : ""
1732
2352
  };
1733
2353
  }
1734
- /** 原始 provider 对象 → DshProvider(缺 baseURL 视为无效,返回 null)。 */
1735
- function providerFromRaw(name, raw) {
1736
- if (!isRecord(raw)) return null;
1737
- const entry = raw;
1738
- const apiKeyEnv = typeof entry["apiKeyEnv"] === "string" && entry["apiKeyEnv"].trim() ? entry["apiKeyEnv"].trim() : null;
1739
- const api = apiKeyOf(entry["api"]);
1740
- const protocol = api === "" ? "openai" : OCR_PROTOCOL_BY_API.get(api) ?? api;
1741
- const baseURL = typeof entry["baseURL"] === "string" ? entry["baseURL"].trim() : "";
1742
- if (!baseURL) return null;
1743
- const models = Array.isArray(entry["models"]) ? entry["models"].map(modelFromRaw).filter((model) => model !== null) : [];
2354
+ function compareCommentOf(raw) {
2355
+ const comment = commentOf(raw);
2356
+ if (comment === null) return null;
1744
2357
  return {
1745
- name,
1746
- displayName: typeof entry["displayName"] === "string" && entry["displayName"] ? entry["displayName"] : name,
1747
- apiKeyEnv,
1748
- protocol,
1749
- baseURL,
1750
- models
2358
+ location: locationOf(comment),
2359
+ severity: comment.severity,
2360
+ category: comment.category,
2361
+ content: comment.content
1751
2362
  };
1752
2363
  }
1753
- /**
1754
- * settings 命名空间解析值 → provider 列表(纯投影,不含任何 key 值)。
1755
- * 上游形状(settings 里 llm-pi-ai.providers.<name>):
1756
- * { displayName?, apiKeyEnv?, api: openai-completions|openai-responses|anthropic-messages,
1757
- * baseURL, models?: [{id,name,…}] }
1758
- * 形状不合的条目整条丢弃(读侧宽容:卡片要能打开让用户去修),写侧才 fail-loud。
1759
- */
1760
- function providersFromSettingsValue(value) {
1761
- const providers = fieldOf(value, PROVIDERS_FIELD);
1762
- if (!isRecord(providers)) return [];
1763
- const out = [];
1764
- for (const [name, raw] of Object.entries(providers)) {
1765
- const parsed = providerFromRaw(name, raw);
1766
- if (parsed !== null) out.push(parsed);
1767
- }
1768
- return out;
1769
- }
1770
- //#endregion
1771
- //#region lib/provider-source.ts
1772
- /** provider 清单所在的官方 settings 命名空间(base bundle 的 pi-ai 适配器注册)。 */
1773
- const PI_AI_NAMESPACE = "llm-pi-ai";
1774
- const SOURCE_READY = Object.freeze({
1775
- status: "ready",
1776
- message: ""
1777
- });
1778
- /** settings 通道侧抛错(health / descriptors / 投影三处共用同一状态与同一份文案)。 */
1779
- const SOURCE_SETTINGS_FAILED = "settings-failed";
1780
- /**
1781
- * 读 provider 清单并把一切失败折进 source(不抛):会话热路径与卡片读面
1782
- * 都只消费返回值,服务缺席/抛错时卡片仍能显示原因。
1783
- */
1784
- function readProviders(gateway, messages) {
1785
- let health;
1786
- try {
1787
- health = gateway.health();
1788
- } catch (error) {
1789
- return {
1790
- providers: [],
1791
- source: {
1792
- status: SOURCE_SETTINGS_FAILED,
1793
- message: format(messages.sourceSettingsFailed, { message: errorText(error) })
1794
- }
1795
- };
1796
- }
1797
- if (!health.settings) return {
1798
- providers: [],
1799
- source: {
1800
- status: "settings-unavailable",
1801
- message: messages.sourceSettingsUnavailable
1802
- }
2364
+ /** 一个 compare 桶:缺失/非数组判整个 compare 失败,部分条目坏只丢该条并计入 dropped。 */
2365
+ function compareBucketOf(raw) {
2366
+ if (!Array.isArray(raw)) return null;
2367
+ return {
2368
+ items: raw.map((entry) => compareCommentOf(entry)).filter((entry) => entry !== null).slice(0, COMPARE_BUCKET_LIMIT),
2369
+ total: raw.length
1803
2370
  };
1804
- let descriptors;
2371
+ }
2372
+ /** 解析 `ocr session compare --json`:四个桶与两侧会话标识。 */
2373
+ function parseSessionCompare(text) {
2374
+ let doc;
1805
2375
  try {
1806
- descriptors = gateway.descriptors();
1807
- } catch (error) {
2376
+ doc = JSON.parse(text);
2377
+ } catch {
1808
2378
  return {
1809
- providers: [],
1810
- source: {
1811
- status: SOURCE_SETTINGS_FAILED,
1812
- message: format(messages.sourceSettingsFailed, { message: errorText(error) })
1813
- }
2379
+ ok: false,
2380
+ view: null
1814
2381
  };
1815
2382
  }
1816
- const hit = descriptors.find((descriptor) => descriptor.ns === PI_AI_NAMESPACE);
1817
- if (hit === void 0) return {
1818
- providers: [],
1819
- source: {
1820
- status: "namespace-unavailable",
1821
- message: format(messages.sourceNamespaceUnavailable, { ns: PI_AI_NAMESPACE })
1822
- }
1823
- };
1824
- if (!isRecord(fieldOf(hit.value, "providers"))) return {
1825
- providers: [],
1826
- source: {
1827
- status: "namespace-unavailable",
1828
- message: format(messages.sourceNamespaceUnavailable, { ns: PI_AI_NAMESPACE })
1829
- }
2383
+ if (!isRecord(doc)) return {
2384
+ ok: false,
2385
+ view: null
1830
2386
  };
1831
- let providers;
1832
- try {
1833
- providers = providersFromSettingsValue(hit.value);
1834
- } catch (error) {
1835
- return {
1836
- providers: [],
1837
- source: {
1838
- status: SOURCE_SETTINGS_FAILED,
1839
- message: format(messages.sourceSettingsFailed, { message: errorText(error) })
1840
- }
1841
- };
1842
- }
1843
- if (!health.credentials) return {
1844
- providers,
1845
- source: {
1846
- status: "credentials-unavailable",
1847
- message: messages.sourceCredentialsUnavailable
1848
- }
2387
+ const newBucket = compareBucketOf(doc["new"]);
2388
+ const persisting = compareBucketOf(doc["persisting"]);
2389
+ const resolved = compareBucketOf(doc["resolved"]);
2390
+ const notReviewed = compareBucketOf(doc["not_reviewed"]);
2391
+ if (newBucket === null || persisting === null || resolved === null || notReviewed === null) return {
2392
+ ok: false,
2393
+ view: null
1849
2394
  };
2395
+ const droppedCount = newBucket.total + persisting.total + resolved.total + notReviewed.total - (newBucket.items.length + persisting.items.length + resolved.items.length + notReviewed.items.length);
1850
2396
  return {
1851
- providers,
1852
- source: SOURCE_READY
2397
+ ok: true,
2398
+ view: {
2399
+ before: compareSideOf(doc["before"]),
2400
+ after: compareSideOf(doc["after"]),
2401
+ new: newBucket.items,
2402
+ persisting: persisting.items,
2403
+ resolved: resolved.items,
2404
+ notReviewed: notReviewed.items,
2405
+ counts: {
2406
+ new: newBucket.total,
2407
+ persisting: persisting.total,
2408
+ resolved: resolved.total,
2409
+ notReviewed: notReviewed.total
2410
+ },
2411
+ droppedCount
2412
+ }
1853
2413
  };
1854
2414
  }
1855
- /** 逐 provider 问凭据服务「有没有 key」(并行;只回布尔)。 */
1856
- async function markKeyFlags(gateway, providers) {
1857
- return await Promise.all(providers.map(async (providerCfg) => {
1858
- const ref = providerCfg.apiKeyEnv;
1859
- if (ref === null || ref === "") return {
1860
- ...providerCfg,
1861
- hasKey: false
1862
- };
1863
- const hasKey = await gateway.credentialConfigured(ref).catch(() => false);
1864
- return {
1865
- ...providerCfg,
1866
- hasKey
1867
- };
1868
- }));
1869
- }
1870
- //#endregion
1871
- //#region lib/config-store.ts
1872
- /** api_key_cmd 脚本名(pluginDir 兜底路径的唯一拼法来源)。 */
1873
- const GET_CRED_SCRIPT = path.join("scripts", "get-cred.mjs");
1874
- /** 未知值 → 安全字符串(非字符串给 '')。 */
1875
- function strOf(value) {
1876
- return typeof value === "string" ? value : "";
1877
- }
1878
- /** 读取外部 ocr CLI config.json 的当前形态(不含 key 值)。
1879
- * 只读侧刻意保持宽容:坏 JSON 也要能让设置卡片打开(用户正是靠卡片去修),
1880
- * 写侧由 parseExistingConfig fail-loud——两者语义不同,不可照抄。 */
1881
- function loadOcrConfig(configJsonText) {
1882
- let doc;
1883
- try {
1884
- const parsed = JSON.parse(configJsonText);
1885
- doc = isRecord(parsed) ? parsed : {};
1886
- } catch {
1887
- doc = {};
1888
- }
1889
- const provider = strOf(fieldOf(doc, "provider"));
1890
- const custom = fieldOf(doc, "custom_providers");
1891
- const entry = isRecord(custom) ? fieldOf(custom, provider) : void 0;
1892
- return {
1893
- current: {
1894
- provider,
1895
- model: isRecord(custom) ? strOf(fieldOf(entry, "model")) : "",
1896
- keyIsDynamic: isRecord(custom) && strOf(fieldOf(entry, "api_key_cmd")).length > 0,
1897
- url: isRecord(custom) ? strOf(fieldOf(entry, "url")) : ""
2415
+ // lib/tool-schemas.ts
2416
+ /** 可空 string 字段的 schema 片段(官方子集单类型,null 分支用 oneOf 表达)。 */
2417
+ const NULLABLE_STRING = { oneOf: [{ type: "string" }, { type: "null" }] };
2418
+ /** ocr_review / ocr_scan 的 wait=false 回执(startBackground 的返回对象)。 */
2419
+ const BACKGROUND_RECEIPT_SCHEMA = {
2420
+ type: "object",
2421
+ additionalProperties: false,
2422
+ required: [
2423
+ "started",
2424
+ "mode",
2425
+ "tool",
2426
+ "repo",
2427
+ "processStatus",
2428
+ "jobId",
2429
+ "polling"
2430
+ ],
2431
+ properties: {
2432
+ started: {
2433
+ type: "boolean",
2434
+ const: true
1898
2435
  },
1899
- raw: doc
1900
- };
1901
- }
1902
- /** config.json 的当前文本(读不到/不存在一律空串,交由 loadOcrConfig 的宽容读侧处理)。 */
1903
- function readText(configPath) {
1904
- try {
1905
- return readFileSync(configPath, "utf8");
1906
- } catch {
1907
- return "";
1908
- }
1909
- }
1910
- /**
1911
- * api_key_cmd 内嵌值的转义。POSIX 走单引号(ocr 在 Unix 用 `sh -c` 跑 api_key_cmd,
1912
- * keycmd_unix.go);Windows 的 ocr 用 **cmd.exe** 跑它(keycmd_windows.go),单引号
1913
- * 在那里是普通字符、命令必坏,故走双引号:值内 `"` 翻倍、收尾反斜杠再翻倍(防被
1914
- * CommandLineToArgvW 当成转义吃掉收尾引号)。ref 名白名单 `^[A-Z0-9_]+$` 不含两种
1915
- * 引号;脚本与数据目录是盘上常规路径。已知残留:cmd.exe 会对双引号内的 `%VAR%`
1916
- * 做环境展开,路径含 `%` 的 Windows 部署需自行规避(Windows 非目标平台,此处只
1917
- * 保证常规路径形态正确)。
1918
- */
1919
- function shqDyn(value) {
1920
- if (process.platform === "win32") return `"${value.replaceAll("\"", "\"\"").replace(/\\+$/u, (backslashes) => backslashes + backslashes)}"`;
1921
- return `'${value.replaceAll("'", String.raw`'\''`)}'`;
1922
- }
1923
- /** api_key_cmd 值构造:脚本路径与 env key 名(及可选的 dsh 数据目录)shq 转义后
1924
- * 拼成 shell 命令。第三参数是 get-cred.mjs 数据目录定位的**第一档**(显式参数 >
1925
- * $DSH_HOME > homedir()/.dsh):ocrConfigPath 重定向 ocr 子进程的 HOME 后,
1926
- * get-cred 的 homedir() 兜底会跟着跑偏,须在宿主侧把解析出的数据目录显式钉进
1927
- * 命令。undefined(未重定向)时省略,沿用 env/默认档。
1928
- * env key 白名单维持 ^[A-Z0-9_]+$(刻意比宿主 CredentialRef 的语法紧:OCR 侧
1929
- * 惯例大写,且这是写进 config.json 的可信输入)。 */
1930
- function buildApiKeyCmd(scriptPath, envKey, dshHome, messages) {
1931
- if (!/^[A-Z0-9_]+$/u.test(envKey)) throw new Error(format(messages.badEnvKey, { key: envKey }));
1932
- const parts = [
1933
- "node",
1934
- shqDyn(scriptPath),
1935
- shqDyn(envKey)
1936
- ];
1937
- if (dshHome !== void 0) parts.push(shqDyn(dshHome));
1938
- return parts.join(" ");
1939
- }
1940
- /**
1941
- * 读一个 shq 单引号串:解出它的值与结束位置。
1942
- *
1943
- * 认的是 shqDyn 自己写出的转义形态——值里的单引号被写成四字符的 '\''。所以遇到
1944
- * 单引号时,只有后面紧跟这四字符才算转义,否则是真闭合。未闭合(串被截断)返回
1945
- * undefined:宁可判「读不懂」,也不要把半截命令当成可信输入。
1946
- */
1947
- function readShqToken(text, start) {
1948
- let value = "";
1949
- let i = start + 1;
1950
- let closed = false;
1951
- while (i < text.length && !closed) {
1952
- const ch = text[i];
1953
- if (ch === "'" && text.startsWith(String.raw`'\''`, i)) {
1954
- value += "'";
1955
- i += 4;
1956
- } else if (ch === "'") {
1957
- closed = true;
1958
- i += 1;
1959
- } else {
1960
- value += ch;
1961
- i += 1;
1962
- }
2436
+ mode: {
2437
+ type: "string",
2438
+ const: "background"
2439
+ },
2440
+ tool: { type: "string" },
2441
+ repo: { type: "string" },
2442
+ processStatus: {
2443
+ type: "string",
2444
+ enum: [
2445
+ "running",
2446
+ "completed",
2447
+ "killed"
2448
+ ]
2449
+ },
2450
+ jobId: { type: "string" },
2451
+ polling: { type: "string" }
1963
2452
  }
1964
- return closed ? {
1965
- value,
1966
- next: i
1967
- } : null;
1968
- }
1969
- /** shq 串 → token 列表;形态不符(未闭合 / 出现裸词)返回 null。 */
1970
- function readShqTokens(body) {
1971
- const tokens = [];
1972
- let i = 0;
1973
- let malformed = false;
1974
- while (i < body.length && !malformed) {
1975
- while (i < body.length && (body[i] === " " || body[i] === " ")) i += 1;
1976
- if (i >= body.length) break;
1977
- if (body[i] === "'") {
1978
- const token = readShqToken(body, i);
1979
- if (token === null) malformed = true;
1980
- else {
1981
- tokens.push(token.value);
1982
- i = token.next;
2453
+ };
2454
+ /** summarizeReview(...) 的对象形状(lib/parse.ts 的 ReviewSummaryView,逐字段对齐)。 */
2455
+ const REVIEW_SUMMARY_SCHEMA = {
2456
+ type: "object",
2457
+ additionalProperties: false,
2458
+ required: [
2459
+ "status",
2460
+ "skipped",
2461
+ "message",
2462
+ "summary",
2463
+ "budgetExceeded",
2464
+ "limitReached",
2465
+ "llm",
2466
+ "projectSummary",
2467
+ "projectSummaryTruncated",
2468
+ "resume",
2469
+ "groupCount",
2470
+ "retry",
2471
+ "traceId",
2472
+ "sessionId",
2473
+ "aggregation",
2474
+ "totalCommentCount",
2475
+ "droppedCount",
2476
+ "invalidCommentCount",
2477
+ "topComments",
2478
+ "warnings",
2479
+ "toolCalls",
2480
+ "coverage",
2481
+ "hint"
2482
+ ],
2483
+ properties: {
2484
+ status: {
2485
+ type: "string",
2486
+ enum: [
2487
+ "success",
2488
+ "complete",
2489
+ "partial",
2490
+ "completed_with_warnings",
2491
+ "completed_with_errors",
2492
+ "skipped"
2493
+ ]
2494
+ },
2495
+ skipped: { type: "boolean" },
2496
+ message: NULLABLE_STRING,
2497
+ summary: { oneOf: [{ type: "null" }, {
2498
+ type: "object",
2499
+ additionalProperties: false,
2500
+ required: [
2501
+ "filesReviewed",
2502
+ "comments",
2503
+ "totalTokens",
2504
+ "inputTokens",
2505
+ "outputTokens",
2506
+ "cacheReadTokens",
2507
+ "cacheWriteTokens",
2508
+ "elapsed"
2509
+ ],
2510
+ properties: {
2511
+ filesReviewed: { type: "number" },
2512
+ comments: { type: "number" },
2513
+ totalTokens: { type: "number" },
2514
+ inputTokens: { type: "number" },
2515
+ outputTokens: { type: "number" },
2516
+ cacheReadTokens: { type: "number" },
2517
+ cacheWriteTokens: { type: "number" },
2518
+ elapsed: { type: "string" }
1983
2519
  }
1984
- } else malformed = true;
1985
- }
1986
- return malformed ? null : tokens;
1987
- }
1988
- /**
1989
- * 读一个 cmd.exe 双引号串:解出它的值与结束位置(`""` 是翻倍的引号)。
1990
- * 未闭合返回 null:宁可判「读不懂」,也不把半截命令当可信输入。
1991
- */
1992
- function readDqToken(body, start) {
1993
- let value = "";
1994
- let i = start + 1;
1995
- let closed = false;
1996
- while (i < body.length && !closed) {
1997
- const ch = body[i];
1998
- if (ch === "\"" && body[i + 1] === "\"") {
1999
- value += "\"";
2000
- i += 2;
2520
+ }] },
2521
+ budgetExceeded: { type: "boolean" },
2522
+ limitReached: { type: "boolean" },
2523
+ llm: { oneOf: [{ type: "null" }, {
2524
+ type: "object",
2525
+ additionalProperties: false,
2526
+ required: ["provider", "model"],
2527
+ properties: {
2528
+ provider: NULLABLE_STRING,
2529
+ model: { type: "string" }
2530
+ }
2531
+ }] },
2532
+ projectSummary: NULLABLE_STRING,
2533
+ projectSummaryTruncated: { type: "boolean" },
2534
+ groupCount: { type: "integer" },
2535
+ retry: { oneOf: [{ type: "null" }, {
2536
+ type: "object",
2537
+ additionalProperties: false,
2538
+ required: [
2539
+ "totalRetries",
2540
+ "recoveredRequests",
2541
+ "failedRequests"
2542
+ ],
2543
+ properties: {
2544
+ totalRetries: { type: "number" },
2545
+ recoveredRequests: { type: "number" },
2546
+ failedRequests: { type: "number" }
2547
+ }
2548
+ }] },
2549
+ traceId: NULLABLE_STRING,
2550
+ resume: { oneOf: [{ type: "null" }, {
2551
+ type: "object",
2552
+ additionalProperties: false,
2553
+ required: [
2554
+ "resumedFrom",
2555
+ "reusedFiles",
2556
+ "rerunFiles",
2557
+ "previousModel",
2558
+ "currentModel"
2559
+ ],
2560
+ properties: {
2561
+ resumedFrom: { type: "string" },
2562
+ reusedFiles: { type: "number" },
2563
+ rerunFiles: { type: "number" },
2564
+ previousModel: { type: "string" },
2565
+ currentModel: { type: "string" }
2566
+ }
2567
+ }] },
2568
+ sessionId: NULLABLE_STRING,
2569
+ aggregation: {
2570
+ type: "object",
2571
+ additionalProperties: false,
2572
+ required: [
2573
+ "total",
2574
+ "bySeverity",
2575
+ "byCategory",
2576
+ "unknownSeverityCount"
2577
+ ],
2578
+ properties: {
2579
+ total: { type: "integer" },
2580
+ unknownSeverityCount: { type: "integer" },
2581
+ bySeverity: {
2582
+ type: "object",
2583
+ additionalProperties: false,
2584
+ required: [
2585
+ "critical",
2586
+ "high",
2587
+ "medium",
2588
+ "low"
2589
+ ],
2590
+ properties: {
2591
+ critical: { type: "integer" },
2592
+ high: { type: "integer" },
2593
+ medium: { type: "integer" },
2594
+ low: { type: "integer" }
2595
+ }
2596
+ },
2597
+ byCategory: { type: "object" }
2598
+ }
2599
+ },
2600
+ totalCommentCount: { type: "integer" },
2601
+ droppedCount: { type: "integer" },
2602
+ invalidCommentCount: { type: "integer" },
2603
+ topComments: {
2604
+ type: "array",
2605
+ items: {
2606
+ type: "object",
2607
+ additionalProperties: false,
2608
+ required: [
2609
+ "location",
2610
+ "severity",
2611
+ "category",
2612
+ "content",
2613
+ "suggestion"
2614
+ ],
2615
+ properties: {
2616
+ location: { type: "string" },
2617
+ severity: { type: "string" },
2618
+ category: { type: "string" },
2619
+ content: { type: "string" },
2620
+ suggestion: NULLABLE_STRING
2621
+ }
2622
+ }
2623
+ },
2624
+ warnings: {
2625
+ type: "array",
2626
+ items: { type: "string" }
2627
+ },
2628
+ toolCalls: { oneOf: [{ type: "null" }, {
2629
+ type: "object",
2630
+ additionalProperties: false,
2631
+ required: [
2632
+ "total",
2633
+ "failure",
2634
+ "failures"
2635
+ ],
2636
+ properties: {
2637
+ total: { type: "integer" },
2638
+ failure: { type: "integer" },
2639
+ failures: {
2640
+ type: "array",
2641
+ items: { type: "string" }
2642
+ }
2643
+ }
2644
+ }] },
2645
+ coverage: { oneOf: [{ type: "null" }, {
2646
+ type: "object",
2647
+ additionalProperties: false,
2648
+ required: [
2649
+ "selected",
2650
+ "completed",
2651
+ "reused",
2652
+ "failed",
2653
+ "waived",
2654
+ "failedItems"
2655
+ ],
2656
+ properties: {
2657
+ selected: { type: "integer" },
2658
+ completed: { type: "integer" },
2659
+ reused: { type: "integer" },
2660
+ failed: { type: "integer" },
2661
+ waived: { type: "integer" },
2662
+ failedItems: {
2663
+ type: "array",
2664
+ items: { type: "string" }
2665
+ }
2666
+ }
2667
+ }] },
2668
+ hint: { type: "string" }
2669
+ }
2670
+ };
2671
+ /** review/scan `preview:true` 的文件清单对象(lib/parse.ts 的 PreviewView)。 */
2672
+ const PREVIEW_FILE_SCHEMA = {
2673
+ type: "object",
2674
+ additionalProperties: false,
2675
+ required: [
2676
+ "path",
2677
+ "status",
2678
+ "insertions",
2679
+ "deletions",
2680
+ "willReview",
2681
+ "excludeReason"
2682
+ ],
2683
+ properties: {
2684
+ path: { type: "string" },
2685
+ status: { type: "string" },
2686
+ insertions: { type: "number" },
2687
+ deletions: { type: "number" },
2688
+ willReview: { type: "boolean" },
2689
+ excludeReason: { type: "string" }
2690
+ }
2691
+ };
2692
+ /** ocr_review / ocr_scan 的输出:wait=false = 后台回执对象;前台成功 = 摘要对象;
2693
+ * preview=true = 文件预览对象;前台解析失败 = 一段诚实的说明字符串。四者恰一。 */
2694
+ const REVIEW_OUTPUT_SCHEMA = { oneOf: [
2695
+ BACKGROUND_RECEIPT_SCHEMA,
2696
+ REVIEW_SUMMARY_SCHEMA,
2697
+ {
2698
+ type: "object",
2699
+ additionalProperties: false,
2700
+ required: [
2701
+ "totalFiles",
2702
+ "reviewableCount",
2703
+ "excludedCount",
2704
+ "totalInsertions",
2705
+ "totalDeletions",
2706
+ "reviewableFiles",
2707
+ "excludedFiles",
2708
+ "truncated",
2709
+ "hint"
2710
+ ],
2711
+ properties: {
2712
+ totalFiles: { type: "number" },
2713
+ reviewableCount: { type: "number" },
2714
+ excludedCount: { type: "number" },
2715
+ totalInsertions: { type: "number" },
2716
+ totalDeletions: { type: "number" },
2717
+ reviewableFiles: {
2718
+ type: "array",
2719
+ items: PREVIEW_FILE_SCHEMA
2720
+ },
2721
+ excludedFiles: {
2722
+ type: "array",
2723
+ items: PREVIEW_FILE_SCHEMA
2724
+ },
2725
+ truncated: { type: "boolean" },
2726
+ hint: { type: "string" }
2727
+ }
2728
+ },
2729
+ { type: "string" }
2730
+ ] };
2731
+ /** ocr_delegate_preview:成功 = 清单对象;解析失败 = 说明字符串。 */
2732
+ const PREVIEW_OUTPUT_SCHEMA = { oneOf: [{
2733
+ type: "object",
2734
+ additionalProperties: false,
2735
+ required: [
2736
+ "mode",
2737
+ "repository",
2738
+ "mergeBase",
2739
+ "reviewableCount",
2740
+ "insertions",
2741
+ "deletions",
2742
+ "reviewableFiles",
2743
+ "excludedFiles",
2744
+ "hint"
2745
+ ],
2746
+ properties: {
2747
+ mode: { type: "string" },
2748
+ repository: { type: "string" },
2749
+ mergeBase: NULLABLE_STRING,
2750
+ reviewableCount: { type: "integer" },
2751
+ insertions: { type: "number" },
2752
+ deletions: { type: "number" },
2753
+ reviewableFiles: {
2754
+ type: "array",
2755
+ items: {
2756
+ type: "object",
2757
+ additionalProperties: false,
2758
+ required: [
2759
+ "path",
2760
+ "status",
2761
+ "insertions",
2762
+ "deletions"
2763
+ ],
2764
+ properties: {
2765
+ path: { type: "string" },
2766
+ status: { type: "string" },
2767
+ insertions: { type: "number" },
2768
+ deletions: { type: "number" }
2769
+ }
2770
+ }
2771
+ },
2772
+ excludedFiles: {
2773
+ type: "array",
2774
+ items: {
2775
+ type: "object",
2776
+ additionalProperties: false,
2777
+ required: ["path", "reason"],
2778
+ properties: {
2779
+ path: { type: "string" },
2780
+ reason: { type: "string" }
2781
+ }
2782
+ }
2783
+ },
2784
+ hint: { type: "string" }
2785
+ }
2786
+ }, { type: "string" }] };
2787
+ /** ocr_delegate_rule:成功 = 分组对象;解析失败 = 说明字符串。 */
2788
+ const RULES_OUTPUT_SCHEMA = { oneOf: [{
2789
+ type: "object",
2790
+ additionalProperties: false,
2791
+ required: [
2792
+ "groupCount",
2793
+ "groups",
2794
+ "hint"
2795
+ ],
2796
+ properties: {
2797
+ groupCount: { type: "integer" },
2798
+ groups: {
2799
+ type: "array",
2800
+ items: {
2801
+ type: "object",
2802
+ additionalProperties: false,
2803
+ required: [
2804
+ "source",
2805
+ "pattern",
2806
+ "files",
2807
+ "rule"
2808
+ ],
2809
+ properties: {
2810
+ source: { type: "string" },
2811
+ pattern: { type: "string" },
2812
+ files: {
2813
+ type: "array",
2814
+ items: { type: "string" }
2815
+ },
2816
+ rule: { type: "string" }
2817
+ }
2818
+ }
2819
+ },
2820
+ hint: { type: "string" }
2821
+ }
2822
+ }, { type: "string" }] };
2823
+ /** ocr_session:sessionOutput 本身就是一手透传的字符串(诚实的值),外层仍是对象
2824
+ * 好让 hint 有处安放。 */
2825
+ const SESSION_RAW_SCHEMA = {
2826
+ type: "object",
2827
+ additionalProperties: false,
2828
+ required: ["sessionOutput", "hint"],
2829
+ properties: {
2830
+ sessionOutput: { type: "string" },
2831
+ hint: { type: "string" }
2832
+ }
2833
+ };
2834
+ /** compare 的结构化四桶(lib/parse.ts 的 CompareView)。 */
2835
+ const COMPARE_COMMENT_SCHEMA = {
2836
+ type: "object",
2837
+ additionalProperties: false,
2838
+ required: [
2839
+ "location",
2840
+ "severity",
2841
+ "category",
2842
+ "content"
2843
+ ],
2844
+ properties: {
2845
+ location: { type: "string" },
2846
+ severity: { type: "string" },
2847
+ category: { type: "string" },
2848
+ content: { type: "string" }
2849
+ }
2850
+ };
2851
+ const COMPARE_SIDE_SCHEMA = {
2852
+ type: "object",
2853
+ additionalProperties: false,
2854
+ required: [
2855
+ "sessionId",
2856
+ "reviewMode",
2857
+ "range"
2858
+ ],
2859
+ properties: {
2860
+ sessionId: { type: "string" },
2861
+ reviewMode: { type: "string" },
2862
+ range: { type: "string" }
2863
+ }
2864
+ };
2865
+ const SESSION_OUTPUT_SCHEMA = { oneOf: [SESSION_RAW_SCHEMA, {
2866
+ type: "object",
2867
+ additionalProperties: false,
2868
+ required: [
2869
+ "before",
2870
+ "after",
2871
+ "new",
2872
+ "persisting",
2873
+ "resolved",
2874
+ "notReviewed",
2875
+ "counts",
2876
+ "droppedCount",
2877
+ "hint"
2878
+ ],
2879
+ properties: {
2880
+ before: COMPARE_SIDE_SCHEMA,
2881
+ after: COMPARE_SIDE_SCHEMA,
2882
+ new: {
2883
+ type: "array",
2884
+ items: COMPARE_COMMENT_SCHEMA
2885
+ },
2886
+ persisting: {
2887
+ type: "array",
2888
+ items: COMPARE_COMMENT_SCHEMA
2889
+ },
2890
+ resolved: {
2891
+ type: "array",
2892
+ items: COMPARE_COMMENT_SCHEMA
2893
+ },
2894
+ notReviewed: {
2895
+ type: "array",
2896
+ items: COMPARE_COMMENT_SCHEMA
2897
+ },
2898
+ counts: {
2899
+ type: "object",
2900
+ additionalProperties: false,
2901
+ required: [
2902
+ "new",
2903
+ "persisting",
2904
+ "resolved",
2905
+ "notReviewed"
2906
+ ],
2907
+ properties: {
2908
+ new: { type: "integer" },
2909
+ persisting: { type: "integer" },
2910
+ resolved: { type: "integer" },
2911
+ notReviewed: { type: "integer" }
2912
+ }
2913
+ },
2914
+ droppedCount: { type: "integer" },
2915
+ hint: { type: "string" }
2916
+ }
2917
+ }] };
2918
+ /** rules check / llm 这类只读文本诊断工具:一手输出 + hint。 */
2919
+ const TEXT_OUTPUT_SCHEMA = {
2920
+ type: "object",
2921
+ additionalProperties: false,
2922
+ required: ["output", "hint"],
2923
+ properties: {
2924
+ output: { type: "string" },
2925
+ hint: { type: "string" }
2926
+ }
2927
+ };
2928
+ // lib/routing.ts
2929
+ const ROUTING_NAME = "ocr-review-routing";
2930
+ const ROUTING_ORDER = 1555;
2931
+ // lib/provider-projection.ts
2932
+ /** llm-pi-ai 命名空间里 provider 表的字段名。 */
2933
+ const PROVIDERS_FIELD = "providers";
2934
+ /**
2935
+ * llm-pi-ai 的 `api` 协议 → OCR config 的 `protocol` 映射。
2936
+ *
2937
+ * 两侧枚举的对照(dsh packages/llm/llm-pi-ai/src/provider.ts:44-48 的 PROTOCOLS
2938
+ * 对 OCR config_cmd.go:796 的 "Protocol values: anthropic, anthropic-bedrock,
2939
+ * openai, openai-responses"):openai-completions 与 OCR 的 openai 是同一条
2940
+ * OpenAI Chat Completions 线协议;anthropic-messages 即 OCR 的 anthropic。
2941
+ * 此前「非 openai-responses 一律 openai」的二值映射会把 anthropic-messages 的
2942
+ * 端点按 OpenAI 线协议打出去——select 成功、OCR 调不通。表外值透传,让
2943
+ * renderSelectedConfig 的白名单给出可读错误。
2944
+ */
2945
+ /** Map 而非对象字面量:查表键来自用户可写的 settings 值,对象会继承 Object.prototype,
2946
+ * 于是 api="constructor" 取到 Object 这个构造函数、"__proto__" 取到 Object.prototype,
2947
+ * `?? api` 的原样透传兜底失效——protocol 被写成函数/对象,直接违反 `DshProvider.protocol
2948
+ * 的 string 声明,还会让 apply 侧的白名单错误文案里出现 "function toString() { [native
2949
+ * code] }"。Map.get 不查原型链,这两类键安全 miss 并落回「原样透传」这条正路。
2950
+ * provider 名那一侧的同款防护见文件下方的 UNUSABLE_PROVIDER_NAMES。 */
2951
+ const OCR_PROTOCOL_BY_API = /* @__PURE__ */ new Map([
2952
+ ["openai-completions", "openai"],
2953
+ ["openai-responses", "openai-responses"],
2954
+ ["anthropic-messages", "anthropic"]
2955
+ ]);
2956
+ /** 不能落进 config.json `custom_providers.<name>` 的 provider 名:`__proto__` 在普通对象
2957
+ * 上是 setter(赋值被静默忽略、写入凭空丢失),`constructor` / `prototype` 是继承属性
2958
+ * 名,与写侧的普通对象赋值组合都不是普通自有键。空名/全空白名单独由 trim 判据拦。 */
2959
+ const UNUSABLE_PROVIDER_NAMES = /* @__PURE__ */ new Set([
2960
+ "__proto__",
2961
+ "constructor",
2962
+ "prototype"
2963
+ ]);
2964
+ /** 1.13.3 的 `validateProviderName` 口径:点号分段不能为空或全空白。 */
2965
+ function isUsableProviderName(name) {
2966
+ if (UNUSABLE_PROVIDER_NAMES.has(name)) return false;
2967
+ return name.split(".").every((segment) => segment.trim() !== "");
2968
+ }
2969
+ /** 对象/数组形态的 `api` 的透传标记:可读,但绝不会被误认成任何合法协议名。 */
2970
+ const API_UNREADABLE = "[unreadable]";
2971
+ /**
2972
+ * settings 里的 `api` 字段 → 协议查表用的键;「未声明」以 null 表达。
2973
+ *
2974
+ * 四种形态必须分开,否则畸形声明会被静默降级成「能用的」配置:
2975
+ * - 未声明(undefined / null)⇒ null,交给 llm-pi-ai 的 openai-completions 兜底语义;
2976
+ * - 字符串 ⇒ trim 后原样,**含空串**(表外值原样透传,由写侧白名单拒绝——显式
2977
+ * `api: ""` 在上游同样不走 `??` 默认,兜底它等于把畸形声明改写成可用配置);
2978
+ * - 数字 / 布尔 ⇒ String(),同样落进「表外值」这条正路;
2979
+ * - 对象 / 数组 ⇒ 固定标记。用 String(raw) 会走 Object.prototype.toString 得到
2980
+ * "[object Object]",那既无信息量又看着像协议名;显式标记更能说明「这里读不出来」。
2981
+ */
2982
+ function apiKeyOf(raw) {
2983
+ if (raw === void 0 || raw === null) return null;
2984
+ if (typeof raw === "string") return raw.trim();
2985
+ if (typeof raw === "number" || typeof raw === "boolean") return String(raw);
2986
+ return API_UNREADABLE;
2987
+ }
2988
+ /** 原始 model 对象 → DshModel(缺 id 视为无效,返回 null)。 */
2989
+ function modelFromRaw(modelRaw) {
2990
+ if (!isRecord(modelRaw)) return null;
2991
+ const rec = modelRaw;
2992
+ const id = typeof rec["id"] === "string" ? rec["id"].trim() : "";
2993
+ if (!id) return null;
2994
+ const name = typeof rec["name"] === "string" ? rec["name"].trim() : "";
2995
+ return {
2996
+ id,
2997
+ name: name === "" ? id : name
2998
+ };
2999
+ }
3000
+ /** 原始 provider 对象 → DshProvider(缺 baseURL 或形状不合视为无效,返回 null)。 */
3001
+ function providerFromRaw(name, raw) {
3002
+ if (!isRecord(raw)) return null;
3003
+ const entry = raw;
3004
+ const apiKeyEnv = typeof entry["apiKeyEnv"] === "string" && entry["apiKeyEnv"].trim() ? entry["apiKeyEnv"].trim() : null;
3005
+ const api = apiKeyOf(entry["api"]);
3006
+ const protocol = api === null ? "openai" : OCR_PROTOCOL_BY_API.get(api) ?? api;
3007
+ const baseURL = typeof entry["baseURL"] === "string" ? entry["baseURL"].trim() : "";
3008
+ if (!baseURL || !/^https?:\/\//iu.test(baseURL)) return null;
3009
+ const modelsRaw = Array.isArray(entry["models"]) ? entry["models"] : [];
3010
+ const models = modelsRaw.map((model) => modelFromRaw(model)).filter((model) => model !== null);
3011
+ if (modelsRaw.length > 0 && models.length === 0) return null;
3012
+ const displayName = typeof entry["displayName"] === "string" ? entry["displayName"].trim() : "";
3013
+ return {
3014
+ name,
3015
+ displayName: displayName === "" ? name : displayName,
3016
+ apiKeyEnv,
3017
+ protocol,
3018
+ baseURL,
3019
+ models
3020
+ };
3021
+ }
3022
+ /**
3023
+ * settings 命名空间解析值 → provider 列表(纯投影,不含任何 key 值)。
3024
+ * 上游形状(settings 里 llm-pi-ai.providers.<name>):
3025
+ * { displayName?, apiKeyEnv?, api: openai-completions|openai-responses|anthropic-messages,
3026
+ * baseURL, models?: [{id,name,…}] }
3027
+ * 形状不合的条目整条丢弃(读侧宽容:卡片要能打开让用户去修),写侧才 fail-loud。
3028
+ */
3029
+ function providersFromSettingsValue(value) {
3030
+ const providers = fieldOf(value, PROVIDERS_FIELD);
3031
+ if (!isRecord(providers)) return [];
3032
+ const out = [];
3033
+ for (const [name, raw] of Object.entries(providers)) {
3034
+ const parsed = isUsableProviderName(name) ? providerFromRaw(name, raw) : null;
3035
+ if (parsed !== null) out.push(parsed);
3036
+ }
3037
+ return out;
3038
+ }
3039
+ // lib/provider-source.ts
3040
+ /** provider 清单所在的官方 settings 命名空间(base bundle 的 pi-ai 适配器注册)。 */
3041
+ const PI_AI_NAMESPACE = "llm-pi-ai";
3042
+ const SOURCE_READY = Object.freeze({
3043
+ status: "ready",
3044
+ message: ""
3045
+ });
3046
+ /** settings 通道侧抛错(health / descriptors / 投影三处共用同一状态与同一份文案)。 */
3047
+ const SOURCE_SETTINGS_FAILED = "settings-failed";
3048
+ /**
3049
+ * 读 provider 清单并把一切失败折进 source(不抛):会话热路径与卡片读面
3050
+ * 都只消费返回值,服务缺席/抛错时卡片仍能显示原因。
3051
+ */
3052
+ function readProviders(gateway, messages) {
3053
+ let health;
3054
+ try {
3055
+ health = gateway.health();
3056
+ } catch (error) {
3057
+ return {
3058
+ providers: [],
3059
+ source: {
3060
+ status: SOURCE_SETTINGS_FAILED,
3061
+ message: format(messages.sourceSettingsFailed, { message: errorText(error) })
3062
+ }
3063
+ };
3064
+ }
3065
+ if (!health.settings) return {
3066
+ providers: [],
3067
+ source: {
3068
+ status: "settings-unavailable",
3069
+ message: messages.sourceSettingsUnavailable
3070
+ }
3071
+ };
3072
+ let descriptors;
3073
+ try {
3074
+ descriptors = gateway.descriptors();
3075
+ } catch (error) {
3076
+ return {
3077
+ providers: [],
3078
+ source: {
3079
+ status: SOURCE_SETTINGS_FAILED,
3080
+ message: format(messages.sourceSettingsFailed, { message: errorText(error) })
3081
+ }
3082
+ };
3083
+ }
3084
+ let hit;
3085
+ try {
3086
+ if (!Array.isArray(descriptors)) throw new TypeError(`ctx.settings.describe() 没有给出数组(收到 ${typeof descriptors})`);
3087
+ hit = descriptors.find((descriptor) => descriptor.ns === PI_AI_NAMESPACE);
3088
+ } catch (error) {
3089
+ return {
3090
+ providers: [],
3091
+ source: {
3092
+ status: SOURCE_SETTINGS_FAILED,
3093
+ message: format(messages.sourceSettingsFailed, { message: errorText(error) })
3094
+ }
3095
+ };
3096
+ }
3097
+ if (hit === void 0) return {
3098
+ providers: [],
3099
+ source: {
3100
+ status: "namespace-unavailable",
3101
+ message: format(messages.sourceNamespaceUnavailable, { ns: PI_AI_NAMESPACE })
3102
+ }
3103
+ };
3104
+ const providersValue = fieldOf(hit.value, "providers");
3105
+ if (!isRecord(providersValue)) return {
3106
+ providers: [],
3107
+ source: {
3108
+ status: "namespace-unavailable",
3109
+ message: format(messages.sourceNamespaceUnavailable, { ns: PI_AI_NAMESPACE })
3110
+ }
3111
+ };
3112
+ let providers;
3113
+ try {
3114
+ providers = providersFromSettingsValue(hit.value);
3115
+ } catch (error) {
3116
+ return {
3117
+ providers: [],
3118
+ source: {
3119
+ status: SOURCE_SETTINGS_FAILED,
3120
+ message: format(messages.sourceSettingsFailed, { message: errorText(error) })
3121
+ }
3122
+ };
3123
+ }
3124
+ if (!health.credentials) return {
3125
+ providers,
3126
+ source: {
3127
+ status: "credentials-unavailable",
3128
+ message: messages.sourceCredentialsUnavailable
3129
+ }
3130
+ };
3131
+ return {
3132
+ providers,
3133
+ source: SOURCE_READY
3134
+ };
3135
+ }
3136
+ /** 逐 provider 问凭据服务「有没有 key」(并行;只回布尔)。 */
3137
+ async function markKeyFlags(gateway, providers) {
3138
+ return await Promise.all(providers.map(async (providerCfg) => {
3139
+ const ref = providerCfg.apiKeyEnv;
3140
+ if (ref === null || ref === "") return {
3141
+ ...providerCfg,
3142
+ hasKey: false
3143
+ };
3144
+ let hasKey;
3145
+ try {
3146
+ hasKey = await gateway.credentialConfigured(ref);
3147
+ } catch {
3148
+ hasKey = false;
3149
+ }
3150
+ return {
3151
+ ...providerCfg,
3152
+ hasKey
3153
+ };
3154
+ }));
3155
+ }
3156
+ // lib/config-store.ts
3157
+ /** api_key_cmd 脚本名(pluginDir 兜底路径的唯一拼法来源)。 */
3158
+ const GET_CRED_SCRIPT = path.join("scripts", "get-cred.mjs");
3159
+ /** 未知值 → 安全字符串(非字符串给 '')。 */
3160
+ function strOf(value) {
3161
+ return typeof value === "string" ? value : "";
3162
+ }
3163
+ /** 读取外部 ocr CLI config.json 的当前形态(不含 key 值)。
3164
+ * 只读侧刻意保持宽容:坏 JSON 也要能让设置卡片打开(用户正是靠卡片去修),
3165
+ * 写侧由 parseExistingConfig fail-loud——两者语义不同,不可照抄。 */
3166
+ function loadOcrConfig(configJsonText) {
3167
+ let doc;
3168
+ try {
3169
+ const parsed = JSON.parse(configJsonText);
3170
+ doc = isRecord(parsed) ? parsed : {};
3171
+ } catch {
3172
+ doc = {};
3173
+ }
3174
+ const provider = strOf(fieldOf(doc, "provider"));
3175
+ const custom = fieldOf(doc, "custom_providers");
3176
+ const entry = isRecord(custom) ? fieldOf(custom, provider) : void 0;
3177
+ return {
3178
+ current: {
3179
+ provider,
3180
+ model: isRecord(custom) ? strOf(fieldOf(entry, "model")) : "",
3181
+ keyIsDynamic: isRecord(custom) && strOf(fieldOf(entry, "api_key_cmd")).length > 0,
3182
+ url: isRecord(custom) ? strOf(fieldOf(entry, "url")) : ""
3183
+ },
3184
+ raw: doc
3185
+ };
3186
+ }
3187
+ /** 读侧专用:config.json 的当前文本,读不到一律空串。
3188
+ *
3189
+ * 只给「读侧要宽容」的那一类调用用(设置卡打开时渲染当前选择:盘上文件坏了也必须
3190
+ * 能打开卡片,用户正是靠卡片去修)。**写侧不能用它**——把 EACCES/EIO 折成空串等于
3191
+ * 把「读不到」当成「文件不存在」,随后整份覆盖就丢掉了用户既有的全部配置;写侧走
3192
+ * readExistingText。 */
3193
+ function readText(configPath) {
3194
+ try {
3195
+ return readFileSync(configPath, "utf8");
3196
+ } catch {
3197
+ return "";
3198
+ }
3199
+ }
3200
+ /** 文件不存在是正常态(首写、被用户删掉),其余读失败不是。 */
3201
+ function isNotFound$1(error) {
3202
+ return isRecord(error) && fieldOf(error, "code") === "ENOENT";
3203
+ }
3204
+ /** 写侧专用:把「文件不存在 / 内容为空」与「文件读不动」分开。
3205
+ *
3206
+ * 前者返回空串,`parseExistingConfig` 依约给出 `{}`,写出来的就是只含本次选择的
3207
+ * 新文档;后者必须抛错——这一层往下是「读 → 改 → 整份写回」,把读不到当成空文档
3208
+ * 会让一次瞬时 EIO 变成用户全部 providers / keys / mcp_servers / language 被覆盖,
3209
+ * 而备份(`.bak`)也救不了第二次。 */
3210
+ function readExistingText(configPath, messages) {
3211
+ try {
3212
+ return readFileSync(configPath, "utf8");
3213
+ } catch (error) {
3214
+ if (isNotFound$1(error)) return "";
3215
+ throw new Error(format(messages.configUnreadable, {
3216
+ where: configPath,
3217
+ cause: errorText(error)
3218
+ }), { cause: error });
3219
+ }
3220
+ }
3221
+ /**
3222
+ * api_key_cmd 内嵌值的转义。POSIX 走单引号(ocr 在 Unix 用 `sh -c` 跑 api_key_cmd,
3223
+ * keycmd_unix.go);Windows 的 ocr 用 **cmd.exe** 跑它(keycmd_windows.go),单引号
3224
+ * 在那里是普通字符、命令必坏,故走双引号:值内 `"` 翻倍、收尾反斜杠再翻倍(防被
3225
+ * CommandLineToArgvW 当成转义吃掉收尾引号)。ref 名白名单 `^[A-Z0-9_]+$` 不含两种
3226
+ * 引号;脚本与数据目录是盘上常规路径。已知残留:cmd.exe 会对双引号内的 `%VAR%`
3227
+ * 做环境展开,路径含 `%` 的 Windows 部署需自行规避(Windows 非目标平台,此处只
3228
+ * 保证常规路径形态正确)。
3229
+ */
3230
+ function shqDyn(value) {
3231
+ if (process.platform === "win32") return `"${value.replaceAll("\"", "\"\"").replace(/\\+$/u, (backslashes) => backslashes + backslashes)}"`;
3232
+ return `'${value.replaceAll("'", String.raw`'\''`)}'`;
3233
+ }
3234
+ /** api_key_cmd 值构造:脚本路径与 env key 名(及可选的 dsh 数据目录)shq 转义后
3235
+ * 拼成 shell 命令。第三参数是 get-cred.mjs 数据目录定位的**第一档**(显式参数 >
3236
+ * $DSH_HOME > homedir()/.dsh):ocrConfigPath 重定向 ocr 子进程的 HOME 后,
3237
+ * get-cred 的 homedir() 兜底会跟着跑偏,须在宿主侧把解析出的数据目录显式钉进
3238
+ * 命令。undefined(未重定向)时省略,沿用 env/默认档。
3239
+ * env key 白名单维持 ^[A-Z0-9_]+$(刻意比宿主 CredentialRef 的语法紧:OCR 侧
3240
+ * 惯例大写,且这是写进 config.json 的可信输入)。 */
3241
+ function buildApiKeyCmd(scriptPath, envKey, dshHome, messages) {
3242
+ if (!/^[A-Z0-9_]+$/u.test(envKey)) throw new Error(format(messages.badEnvKey, { key: envKey }));
3243
+ const parts = [
3244
+ "node",
3245
+ shqDyn(scriptPath),
3246
+ shqDyn(envKey)
3247
+ ];
3248
+ if (dshHome !== void 0) parts.push(shqDyn(dshHome));
3249
+ return parts.join(" ");
3250
+ }
3251
+ /**
3252
+ * 读一个 shq 单引号串:解出它的值与结束位置。
3253
+ *
3254
+ * 认的是 shqDyn 自己写出的转义形态——值里的单引号被写成四字符的 '\''。所以遇到
3255
+ * 单引号时,只有后面紧跟这四字符才算转义,否则是真闭合。未闭合(串被截断)返回
3256
+ * undefined:宁可判「读不懂」,也不要把半截命令当成可信输入。
3257
+ */
3258
+ function readShqToken(text, start) {
3259
+ let value = "";
3260
+ let i = start + 1;
3261
+ let closed = false;
3262
+ while (i < text.length && !closed) {
3263
+ const ch = text[i];
3264
+ if (ch === "'" && text.startsWith(String.raw`'\''`, i)) {
3265
+ value += "'";
3266
+ i += 4;
3267
+ } else if (ch === "'") {
3268
+ closed = true;
3269
+ i += 1;
3270
+ } else {
3271
+ value += ch;
3272
+ i += 1;
3273
+ }
3274
+ }
3275
+ return closed ? {
3276
+ value,
3277
+ next: i
3278
+ } : null;
3279
+ }
3280
+ /** shq 串 → token 列表;形态不符(未闭合 / 出现裸词)返回 null。 */
3281
+ function readShqTokens(body) {
3282
+ const tokens = [];
3283
+ let i = 0;
3284
+ let malformed = false;
3285
+ while (i < body.length && !malformed) {
3286
+ while (i < body.length && (body[i] === " " || body[i] === " ")) i += 1;
3287
+ if (i >= body.length) break;
3288
+ if (body[i] === "'") {
3289
+ const token = readShqToken(body, i);
3290
+ if (token === null) malformed = true;
3291
+ else {
3292
+ tokens.push(token.value);
3293
+ i = token.next;
3294
+ }
3295
+ } else malformed = true;
3296
+ }
3297
+ return malformed ? null : tokens;
3298
+ }
3299
+ /**
3300
+ * 读一个 cmd.exe 双引号串:解出它的值与结束位置(`""` 是翻倍的引号)。
3301
+ * 未闭合返回 null:宁可判「读不懂」,也不把半截命令当可信输入。
3302
+ */
3303
+ function readDqToken(body, start) {
3304
+ let value = "";
3305
+ let i = start + 1;
3306
+ let closed = false;
3307
+ while (i < body.length && !closed) {
3308
+ const ch = body[i];
3309
+ if (ch === "\"" && body[i + 1] === "\"") {
3310
+ value += "\"";
3311
+ i += 2;
2001
3312
  } else if (ch === "\"") {
2002
3313
  closed = true;
2003
3314
  i += 1;
@@ -2230,8 +3541,7 @@ function repointDanglingKeyScripts(doc, current, messages) {
2230
3541
  return [name];
2231
3542
  }) };
2232
3543
  }
2233
- //#endregion
2234
- //#region lib/ocr-config.ts
3544
+ // lib/ocr-config.ts
2235
3545
  /** 外部 ocr CLI 的默认配置目录/文件名(宿主服务管不到,只能由本包直读直写)。 */
2236
3546
  const OCR_CONFIG_DIR = ".opencodereview";
2237
3547
  const OCR_CONFIG_FILE = "config.json";
@@ -2352,7 +3662,7 @@ async function applyProviderSelection(gateway, paths, selection, messages) {
2352
3662
  const apiKeyCmd = buildApiKeyCmd(scriptPath, target.apiKeyEnv, resolveDshHome(), messages);
2353
3663
  await updateConfigLocked(paths.ocrConfigJson, async () => {
2354
3664
  const next = renderSelectedConfig({
2355
- existingText: readText(paths.ocrConfigJson),
3665
+ existingText: readExistingText(paths.ocrConfigJson, messages),
2356
3666
  configLabel: paths.ocrConfigJson,
2357
3667
  providers,
2358
3668
  provider,
@@ -2396,7 +3706,7 @@ function migrateEntry(entry, envKey, context) {
2396
3706
  * 返回 { migrated: string[]; skippedUnknown: string[] }。
2397
3707
  */
2398
3708
  async function migratePlaintextKeys(gateway, paths, messages) {
2399
- const precheck = readText(paths.ocrConfigJson);
3709
+ const precheck = readExistingText(paths.ocrConfigJson, messages);
2400
3710
  if (!precheck.trim()) return {
2401
3711
  migrated: [],
2402
3712
  skippedUnknown: []
@@ -2423,7 +3733,7 @@ async function migratePlaintextKeys(gateway, paths, messages) {
2423
3733
  messages
2424
3734
  };
2425
3735
  return await updateConfigLocked(paths.ocrConfigJson, async () => {
2426
- const doc = parseExistingConfig(readText(paths.ocrConfigJson), paths.ocrConfigJson, messages);
3736
+ const doc = parseExistingConfig(readExistingText(paths.ocrConfigJson, messages), paths.ocrConfigJson, messages);
2427
3737
  const customsRaw = fieldOf(doc, "custom_providers");
2428
3738
  if (!isRecord(customsRaw)) return {
2429
3739
  migrated: [],
@@ -2471,14 +3781,23 @@ async function ensureKeyScriptPointer(paths, messages) {
2471
3781
  script: scriptPath,
2472
3782
  dshHome: resolveDshHome()
2473
3783
  };
2474
- if (readText(paths.ocrConfigJson).trim() === "") return {
3784
+ let text;
3785
+ try {
3786
+ text = readExistingText(paths.ocrConfigJson, messages);
3787
+ } catch {
3788
+ return {
3789
+ repointed: [],
3790
+ checked: false
3791
+ };
3792
+ }
3793
+ if (text.trim() === "") return {
2475
3794
  repointed: [],
2476
3795
  checked: true
2477
3796
  };
2478
3797
  try {
2479
3798
  return {
2480
3799
  repointed: await updateConfigLocked(paths.ocrConfigJson, async () => {
2481
- const doc = parseExistingConfig(readText(paths.ocrConfigJson), paths.ocrConfigJson, messages);
3800
+ const doc = parseExistingConfig(readExistingText(paths.ocrConfigJson, messages), paths.ocrConfigJson, messages);
2482
3801
  const result = repointDanglingKeyScripts(doc, current, messages);
2483
3802
  if (result.repointed.length === 0) return result.repointed;
2484
3803
  await writeConfigAtomic(paths.ocrConfigJson, renderConfigDoc(doc));
@@ -2493,12 +3812,268 @@ async function ensureKeyScriptPointer(paths, messages) {
2493
3812
  };
2494
3813
  }
2495
3814
  }
2496
- //#endregion
2497
- //#region lib/remote-shape.ts
3815
+ // lib/remote-shape.ts
2498
3816
  /** 宿主服务键 = 客户端线上命名空间(两侧共用的唯一来源)。 */
2499
3817
  const OCR_REMOTE_NAMESPACE = "ocrReview";
2500
- //#endregion
2501
- //#region host.ts
3818
+ // lib/mcp-config.ts
3819
+ /** 整体上限:MCP 配置是一份人工维护的小表,不承载大文件。 */
3820
+ const MCP_MAX_BYTES = 65536;
3821
+ const MCP_MAX_SERVERS = 100;
3822
+ const MCP_MAX_ARRAY = 100;
3823
+ const MCP_MAX_TEXT = 4096;
3824
+ const KNOWN_FIELDS = /* @__PURE__ */ new Set([
3825
+ "type",
3826
+ "command",
3827
+ "args",
3828
+ "url",
3829
+ "headers",
3830
+ "tools",
3831
+ "setup",
3832
+ "env"
3833
+ ]);
3834
+ function failure(name, field, reason, messages) {
3835
+ return {
3836
+ ok: false,
3837
+ error: format(messages.mcpServerInvalid, {
3838
+ name,
3839
+ field,
3840
+ reason
3841
+ })
3842
+ };
3843
+ }
3844
+ function textField(value, name, field, messages) {
3845
+ if (typeof value !== "string" || value.trim() === "") return failure(name, field, "must be a non-empty string", messages);
3846
+ const trimmed = value.trim();
3847
+ if (trimmed.length > MCP_MAX_TEXT) return failure(name, field, `must be at most ${MCP_MAX_TEXT} characters`, messages);
3848
+ return {
3849
+ ok: true,
3850
+ value: trimmed
3851
+ };
3852
+ }
3853
+ function stringArray(value, name, field, messages) {
3854
+ if (!Array.isArray(value) || value.length > MCP_MAX_ARRAY) return failure(name, field, `must be an array of at most ${MCP_MAX_ARRAY} items`, messages);
3855
+ const out = [];
3856
+ for (const item of value) {
3857
+ if (typeof item !== "string" || item.length > MCP_MAX_TEXT) return failure(name, field, `items must be strings of at most ${MCP_MAX_TEXT} characters`, messages);
3858
+ out.push(item);
3859
+ }
3860
+ return {
3861
+ ok: true,
3862
+ value: out
3863
+ };
3864
+ }
3865
+ function headersField(value, name, messages) {
3866
+ if (!isRecord(value) || Object.keys(value).length > 50) return failure(name, "headers", "must be an object of at most 50 string headers", messages);
3867
+ const out = {};
3868
+ for (const [header, raw] of Object.entries(value)) {
3869
+ if (header.trim() === "" || typeof raw !== "string" || raw.length > MCP_MAX_TEXT) return failure(name, `headers.${header}`, "must be a non-empty name and a string value", messages);
3870
+ out[header] = raw;
3871
+ }
3872
+ return {
3873
+ ok: true,
3874
+ value: out
3875
+ };
3876
+ }
3877
+ function normalizeStdio(raw, name, messages) {
3878
+ if (raw["url"] !== void 0 || raw["headers"] !== void 0) return failure(name, "type", "stdio does not accept url/headers", messages);
3879
+ const command = textField(raw["command"], name, "command", messages);
3880
+ if (!command.ok) return command;
3881
+ const entry = {
3882
+ type: "stdio",
3883
+ command: command.value
3884
+ };
3885
+ if (raw["args"] !== void 0) {
3886
+ const args = stringArray(raw["args"], name, "args", messages);
3887
+ if (!args.ok) return args;
3888
+ entry["args"] = args.value;
3889
+ }
3890
+ if (raw["env"] !== void 0) {
3891
+ const env = stringArray(raw["env"], name, "env", messages);
3892
+ if (!env.ok) return env;
3893
+ if (env.value.some((item) => !item.includes("="))) return failure(name, "env", "items must be KEY=VALUE", messages);
3894
+ entry["env"] = env.value;
3895
+ }
3896
+ if (raw["setup"] !== void 0) {
3897
+ const setup = textField(raw["setup"], name, "setup", messages);
3898
+ if (!setup.ok) return setup;
3899
+ entry["setup"] = setup.value;
3900
+ }
3901
+ return {
3902
+ ok: true,
3903
+ value: entry
3904
+ };
3905
+ }
3906
+ function normalizeRemote(raw, name, messages) {
3907
+ if (raw["command"] !== void 0 || raw["args"] !== void 0 || raw["env"] !== void 0 || raw["setup"] !== void 0) return failure(name, "type", "remote does not accept command/args/env/setup", messages);
3908
+ const url = textField(raw["url"], name, "url", messages);
3909
+ if (!url.ok) return url;
3910
+ if (!/^https?:\/\//iu.test(url.value)) return failure(name, "url", "must start with http:// or https://", messages);
3911
+ const entry = {
3912
+ type: "remote",
3913
+ url: url.value
3914
+ };
3915
+ if (raw["headers"] !== void 0) {
3916
+ const headers = headersField(raw["headers"], name, messages);
3917
+ if (!headers.ok) return headers;
3918
+ entry["headers"] = headers.value;
3919
+ }
3920
+ return {
3921
+ ok: true,
3922
+ value: entry
3923
+ };
3924
+ }
3925
+ /** 单个 server 条目 → 只含已知字段的普通对象;未知字段直接拒绝,避免写出 OCR 不认的配置。 */
3926
+ function normalizeServer(name, raw, messages) {
3927
+ if (!isRecord(raw)) return failure(name, "entry", "must be an object", messages);
3928
+ for (const field of Object.keys(raw)) if (!KNOWN_FIELDS.has(field)) return failure(name, field, "is not a supported MCP field", messages);
3929
+ const type = raw["type"] === void 0 ? "stdio" : raw["type"];
3930
+ if (type !== "stdio" && type !== "remote") return failure(name, "type", "must be stdio or remote", messages);
3931
+ const base = type === "stdio" ? normalizeStdio(raw, name, messages) : normalizeRemote(raw, name, messages);
3932
+ if (!base.ok) return base;
3933
+ if (raw["tools"] !== void 0) {
3934
+ const tools = stringArray(raw["tools"], name, "tools", messages);
3935
+ if (!tools.ok) return tools;
3936
+ base.value["tools"] = tools.value;
3937
+ }
3938
+ return base;
3939
+ }
3940
+ function normalizeServers(raw, messages) {
3941
+ if (!isRecord(raw)) return {
3942
+ ok: false,
3943
+ error: messages.mcpShapeInvalid
3944
+ };
3945
+ const names = Object.keys(raw);
3946
+ if (names.length > MCP_MAX_SERVERS) return {
3947
+ ok: false,
3948
+ error: format(messages.mcpTooManyServers, { limit: String(MCP_MAX_SERVERS) })
3949
+ };
3950
+ const out = {};
3951
+ for (const name of names) {
3952
+ if (name.trim() === "" || name.includes("\0") || name.length > 64) return failure(name, "name", "must be a non-empty key of at most 64 characters", messages);
3953
+ const parsed = normalizeServer(name, raw[name], messages);
3954
+ if (!parsed.ok) return parsed;
3955
+ out[name] = parsed.value;
3956
+ }
3957
+ return {
3958
+ ok: true,
3959
+ value: out
3960
+ };
3961
+ }
3962
+ /** 读侧用异步读:卡片端点是 async 面,读大文件时不该占住事件循环。 */
3963
+ function isNotFound(error) {
3964
+ return isRecord(error) && error["code"] === "ENOENT";
3965
+ }
3966
+ /** 读当前 `mcp_servers`,交给卡片编辑;文件不存在按空表处理,坏 JSON/读不动返回可读错误。 */
3967
+ async function readMcpServers(configPath, messages) {
3968
+ let text;
3969
+ try {
3970
+ text = await readFile(configPath, "utf8");
3971
+ } catch (error) {
3972
+ if (!isNotFound(error)) return {
3973
+ ok: false,
3974
+ error: format(messages.mcpReadFailed, {
3975
+ where: configPath,
3976
+ cause: String(error)
3977
+ })
3978
+ };
3979
+ text = "";
3980
+ }
3981
+ if (!text.trim()) return {
3982
+ ok: true,
3983
+ json: "{}"
3984
+ };
3985
+ let doc;
3986
+ try {
3987
+ doc = parseExistingConfig(text, configPath, messages);
3988
+ } catch (error) {
3989
+ return {
3990
+ ok: false,
3991
+ error: format(messages.mcpReadFailed, {
3992
+ where: configPath,
3993
+ cause: String(error)
3994
+ })
3995
+ };
3996
+ }
3997
+ const servers = doc["mcp_servers"];
3998
+ if (servers === void 0 || servers === null) return {
3999
+ ok: true,
4000
+ json: "{}"
4001
+ };
4002
+ if (!isRecord(servers)) return {
4003
+ ok: false,
4004
+ error: messages.mcpShapeInvalid
4005
+ };
4006
+ return {
4007
+ ok: true,
4008
+ json: JSON.stringify(servers, null, 2)
4009
+ };
4010
+ }
4011
+ /** 校验并写回 `mcp_servers`;只改这一支,其余配置原样保留。 */
4012
+ async function saveMcpServers(configPath, json, messages) {
4013
+ if (Buffer.byteLength(json, "utf8") > MCP_MAX_BYTES) return {
4014
+ ok: false,
4015
+ error: format(messages.mcpJsonTooLarge, { limit: String(MCP_MAX_BYTES) })
4016
+ };
4017
+ let raw;
4018
+ try {
4019
+ raw = JSON.parse(json);
4020
+ } catch (error) {
4021
+ return {
4022
+ ok: false,
4023
+ error: format(messages.mcpJsonInvalid, { cause: String(error) })
4024
+ };
4025
+ }
4026
+ const normalized = normalizeServers(raw, messages);
4027
+ if (!normalized.ok) return normalized;
4028
+ try {
4029
+ return {
4030
+ ok: true,
4031
+ count: await updateConfigLocked(configPath, async () => {
4032
+ const doc = parseExistingConfig(readExistingText(configPath, messages), configPath, messages);
4033
+ doc["mcp_servers"] = normalized.value;
4034
+ await writeConfigAtomic(configPath, `${JSON.stringify(doc, null, 2)}\n`);
4035
+ return Object.keys(normalized.value).length;
4036
+ })
4037
+ };
4038
+ } catch (error) {
4039
+ return {
4040
+ ok: false,
4041
+ error: format(messages.mcpReadFailed, {
4042
+ where: configPath,
4043
+ cause: String(error)
4044
+ })
4045
+ };
4046
+ }
4047
+ }
4048
+ /** ChatGPT provider 的配置写入:不写 key,只设 provider 与模型。 */
4049
+ async function applyChatGptModel(configPath, model, messages) {
4050
+ const trimmed = model.trim();
4051
+ if (trimmed === "" || trimmed.length > 256 || trimmed.includes("\0")) return {
4052
+ ok: false,
4053
+ error: messages.chatGptModelRequired
4054
+ };
4055
+ try {
4056
+ await updateConfigLocked(configPath, async () => {
4057
+ const doc = parseExistingConfig(readExistingText(configPath, messages), configPath, messages);
4058
+ const providers = isRecord(doc["providers"]) ? doc["providers"] : {};
4059
+ const entry = isRecord(providers["chatgpt"]) ? providers["chatgpt"] : {};
4060
+ delete entry["api_key"];
4061
+ delete entry["api_key_cmd"];
4062
+ entry["model"] = trimmed;
4063
+ providers["chatgpt"] = entry;
4064
+ doc["providers"] = providers;
4065
+ doc["provider"] = "chatgpt";
4066
+ await writeConfigAtomic(configPath, `${JSON.stringify(doc, null, 2)}\n`);
4067
+ });
4068
+ return { ok: true };
4069
+ } catch (error) {
4070
+ return {
4071
+ ok: false,
4072
+ error: format(messages.chatGptSaveFailed, { cause: String(error) })
4073
+ };
4074
+ }
4075
+ }
4076
+ // host.ts
2502
4077
  const PLUGIN_DIR = import.meta.dirname;
2503
4078
  const MAX_TIMEOUT_REQUEST_MS = 2147483647;
2504
4079
  const LLM_TEST_TIMEOUT_MS = 9e4;
@@ -2513,460 +4088,156 @@ const STDOUT_MAX_BYTES = 4e5;
2513
4088
  * 护栏,把「整份读进内存」的失败形态从 OOM 换成一条点名文件的错误。 */
2514
4089
  const OUT_FILE_MAX_BYTES = 67108864;
2515
4090
  /** 解析失败时回显给模型的原始内容长度上限(UTF-16 码元;已脱敏)。
2516
- * 单位写码元而不是"字符":本常量喂给 `echoRaw`→`truncateEnd`,那一刀按码元数计
2517
- * (一张 emoji = 2 枚),写"字符"会把给模型的说明夸大约一倍。 */
2518
- const RAW_ECHO_LIMIT = 500;
2519
- /** 评论截断条数的内置默认(旧 BUILTIN_BASE 的那一项):schema 的 `.default()` 与
2520
- * 「读到坏值时的回退」(readMaxComments)共用这一个数,两处不许各写一份字面量。 */
2521
- const MAX_COMMENTS_DEFAULT = 12;
2522
- /** 官方注册表里本包作业的 kind;宿主据此签发的 id 形如 `ocr-review-<16 位 hex>`。 */
2523
- const OCR_JOB_KIND = "ocr-review";
2524
- /**
2525
- * 后台评审的兜底回收窗口(毫秒);0 = 不设窗口,进程只由宿主退出、会话 dispose、插件卸载
2526
- * 或显式 job_kill 结束。
2527
- *
2528
- * 本包刻意不套宿主 timeoutMs(`onExpiry: "none"`:外部 CLI 的正常时长跨度太大,套了就等于
2529
- * 替用户砍掉长评审)。不设窗口的代价落在注册表上:未拥有作业共用一只 10 格的桶(实测
2530
- * `running` 与 `stopping` 都计数,满员是**拒绝**后来的 start),一条挂死的 CLI 就永久占一格,
2531
- * 连别的插件也起不来。要给这一格腾位的部署把本项设成非零毫秒数:到点经注册表收,记录推到
2532
- * stopping,理由落进模型可见的 detail。
2533
- */
2534
- const OCR_BACKGROUND_MAX_MS = 0;
2535
- /** "仍在跑"的两枚官方状态:`stopping` 是取消已发出、进程还没收完。 */
2536
- function isLiveOcrStatus(status) {
2537
- return status === "running" || status === "stopping";
2538
- }
2539
- /**
2540
- * 剪掉本 kind 最老的已落定记录,给下一次登记腾出窗口(注册表按登记序返回,故取头部)。
2541
- * 在跑与正在收的一条不动:官方件对活作业的 remove 直接抛,而"静默杀掉用户发起的评审"
2542
- * 正是本包要消灭的形态。
2543
- * @param jobs 官方注册表
2544
- * @returns {void}
2545
- */
2546
- function pruneOcrJobHistory(jobs) {
2547
- const settled = jobs.list().filter((job) => job.kind === OCR_JOB_KIND && !isLiveOcrStatus(job.status));
2548
- const excess = settled.length - 9;
2549
- for (const job of settled.slice(0, Math.max(excess, 0))) jobs.remove(job.id);
2550
- }
2551
- /**
2552
- * 在 controller 闸门下起一条未拥有作业,起完立刻摘掉。
2553
- *
2554
- * 实测:挂上即放行、摘掉即恢复"没有 controller 服务这个 owner"的拒绝、可反复;而 global 层
2555
- * 那串 token 只被 `servesOwner` 读(installed dsh-jobs-local 全文唯一消费点),作业后续的
2556
- * read/kill/落定都不依赖它。所以常驻一枚等于在"宿主故意没装 job 工具"的组成里替全宿主
2557
- * 开着那道闸门——官方契约写的是"生产者不能起一台 owner 收不掉、停不掉的作业"。
2558
- */
2559
- function startUnderOcrController(jobs, spec) {
2560
- const detach = jobs.attachController("ocr-review: background");
2561
- try {
2562
- return jobs.start(spec);
2563
- } finally {
2564
- detach();
2565
- }
2566
- }
2567
- /**
2568
- * 等到兜底 deadline 到点,然后把仍在跑的那条经注册表收掉。
2569
- *
2570
- * 用注册表自己的 `wait(id, ms)` 而不是宿主 timer,也不是裸 `setTimeout`:前者要把
2571
- * `timer` 写进 HostCtx 的**必需**服务面(装载守卫一扩,没装那件的宿主连前台评审都装载
2572
- * 不了),后者会活过插件卸载。实测 `wait` 只等不杀、作业一落定就解掉它内部的定时器,
2573
- * 也不推进输出游标(模型侧 job_output 的读数不受影响)——所以落定时这里看一眼状态就返回。
2574
- * @param jobs 官方注册表
2575
- * @param id 这次后台评审的作业 id
2576
- * @param reason 到点回收时写进模型可见 detail 的理由
2577
- * @param backgroundMaxMs 窗口毫秒数;`<= 0`(部署级设置项 `ocrBackgroundMaxMs` 的默认值)
2578
- * 表示这条不设兜底窗口,直接返回、不去敲 `wait`
2579
- * @returns 等完这一条的兜底窗口;抛错一律就地收敛(记录没了就等于无事可做)
2580
- */
2581
- async function enforceOcrDeadline(jobs, id, reason, backgroundMaxMs) {
2582
- try {
2583
- if (backgroundMaxMs <= 0) return;
2584
- if ((await jobs.wait(id, backgroundMaxMs)).status === "running") jobs.kill(id, void 0, reason);
2585
- } catch {}
2586
- }
2587
- /**
2588
- * 卸载时收掉本包仍在跑(或正在收尾)的作业。
2589
- *
2590
- * 注册表随宿主活着,不会因为我们卸载而杀进程:一条留在 `running` 的 OCR 既继续占着未拥有
2591
- * 那一桶的容量位(实测满员是拒绝别人的 start),又没人再等它的输出。遍历的是注册表的
2592
- * `list()` 而不是本包记的账——本包不留常驻状态,跨会话/跨 fiber 起的作业也要能被收掉。
2593
- * @param jobs 官方注册表
2594
- * @param reason 写进模型可见 detail 的理由(双语字典取)
2595
- * @returns {void}
2596
- */
2597
- function reclaimOcrJobs(jobs, reason) {
2598
- const mine = jobs.list().filter((job) => job.kind === OCR_JOB_KIND);
2599
- for (const job of mine) if (isLiveOcrStatus(job.status)) jobs.kill(job.id, void 0, reason);
2600
- else jobs.remove(job.id);
2601
- }
2602
- /** 当前会话工作区(repo 缺省回退源);非字符串视为缺。
2603
- * 官方形状:`agent?: Agent` → `session: Session` → `header: SessionHeader` →
2604
- * `cwd?: string`。下面的逐层 `?.` 与 `typeof` 复核是跨进程契约的运行时闸:宿主半装配
2605
- * 递来的 agent 可能是 `null`、也可能没有 `session`/`header`(test/host.test.ts 钉住
2606
- * 这两条 fail-closed 路径),值一律经 `unknown` 读,绝不只信声明。 */
2607
- function sessionHeaderCwd(exec) {
2608
- const raw = exec.agent?.session?.header?.cwd;
2609
- return typeof raw === "string" && raw.length > 0 ? raw : void 0;
2610
- }
2611
- /** host 是否为本插件所需服务面(settings/shell/tools/systemPrompt/get/inject 及
2612
- * 其上要调用的方法)。守卫仅压缩类型面、避开 `as unknown as HostCtx` 断言。
2613
- * settings 这里点名的两个方法都会被调用:configure 登记本页的页面策略、describe
2614
- * 跨命名空间读(provider 清单 + 官方 locale 偏好);inject 挂那条页面策略的 effect。
2615
- * settings 面没有 register/get,多点名一个就是永不成立的死分支。 */
2616
- function isOcrReviewHost(value) {
2617
- if (!isRecord(value)) return false;
2618
- const { settings, shell, tools, systemPrompt, inject, get } = value;
2619
- return hasMethods(settings, ["configure", "describe"]) && hasMethods(shell, ["resolve", "execute"]) && hasMethods(tools, ["register"]) && hasMethods(systemPrompt, ["section"]) && typeof inject === "function" && typeof get === "function";
2620
- }
2621
- /** 从插件 Context 解析宿主服务面(守卫收窄,不做 `as unknown as` 断言)。 */
2622
- function resolveHost(ctx) {
2623
- if (!isOcrReviewHost(ctx)) throw new Error("ocr-review: 宿主缺少所需的 shell/tools/settings/systemPrompt/inject 服务界面(或其方法)");
2624
- return ctx;
2625
- }
2626
- /** describe() 行里按命名空间取解析值(跨命名空间读只有 describe() 一条路:
2627
- * `settings.get(ns)` 已删)。未注册该命名空间 → undefined,调用侧按默认值降级。
2628
- * @param rows - `ctx.settings.describe()` 的返回(全部条目的表单投影)。
2629
- * @param ns - 要读的命名空间(官方字段名是 `ns`)。
4091
+ * 单位写码元而不是"字符":本常量喂给 `echoRaw`→`truncateEnd`,那一刀按码元数计
4092
+ * (一张 emoji = 2 枚),写"字符"会把给模型的说明夸大约一倍。 */
4093
+ const RAW_ECHO_LIMIT = 500;
4094
+ /** 评论截断条数的内置默认(旧 BUILTIN_BASE 的那一项):schema 的 `.default()` 与
4095
+ * 「读到坏值时的回退」(readMaxComments)共用这一个数,两处不许各写一份字面量。 */
4096
+ const MAX_COMMENTS_DEFAULT = 12;
4097
+ /** 官方注册表里本包作业的 kind;宿主据此签发的 id 形如 `ocr-review-<16 位 hex>`。 */
4098
+ const OCR_JOB_KIND = "ocr-review";
4099
+ /**
4100
+ * 后台评审的兜底回收窗口(毫秒);0 = 不设窗口,进程只由宿主退出、会话 dispose、插件卸载
4101
+ * 或显式 job_kill 结束。
4102
+ *
4103
+ * 本包刻意不套宿主 timeoutMs(`onExpiry: "none"`:外部 CLI 的正常时长跨度太大,套了就等于
4104
+ * 替用户砍掉长评审)。不设窗口的代价落在注册表上:未拥有作业共用一只 10 格的桶(实测
4105
+ * `running` 与 `stopping` 都计数,满员是**拒绝**后来的 start),一条挂死的 CLI 就永久占一格,
4106
+ * 连别的插件也起不来。要给这一格腾位的部署把本项设成非零毫秒数:到点经注册表收,记录推到
4107
+ * stopping,理由落进模型可见的 detail。
2630
4108
  */
2631
- function describedValue(rows, ns) {
2632
- return rows.find((row) => row.ns === ns)?.value;
4109
+ const OCR_BACKGROUND_MAX_MS = 0;
4110
+ /** "仍在跑"的两枚官方状态:`stopping` 是取消已发出、进程还没收完。 */
4111
+ function isLiveOcrStatus(status) {
4112
+ return status === "running" || status === "stopping";
2633
4113
  }
2634
4114
  /**
2635
- * 取本包 host 侧文案(按官方 locale 偏好现取现用):设置页「常规」里换语言后,
2636
- * 下一次工具调用/端点请求即换新文案,不需要重启或重装插件。
2637
- * locale 命名空间未注册(无官方 client-locale 包)→ describe() 里没有这条 →
2638
- * undefined → 中文默认。
4115
+ * 剪掉本 kind 最老的已落定记录,给下一次登记腾出窗口(注册表按登记序返回,故取头部)。
4116
+ * 在跑与正在收的一条不动:官方件对活作业的 remove 直接抛,而"静默杀掉用户发起的评审"
4117
+ * 正是本包要消灭的形态。
4118
+ * @param jobs 官方注册表
4119
+ * @returns {void}
2639
4120
  */
2640
- function localeMessages(host) {
2641
- const preference = describedValue(host.settings.describe(), LOCALE_SETTINGS_NAMESPACE);
2642
- return messagesFor(MESSAGES, resolveLocalePreference(preference));
4121
+ function pruneOcrJobHistory(jobs) {
4122
+ const settled = jobs.list().filter((job) => job.kind === OCR_JOB_KIND && !isLiveOcrStatus(job.status));
4123
+ const excess = settled.length - 9;
4124
+ for (const job of settled.slice(0, Math.max(excess, 0))) jobs.remove(job.id);
2643
4125
  }
2644
4126
  /**
2645
- * render 只做 model-facing 展示:把 canonical value 本身 pretty-print 成文本块。
2646
- * execute 直返结构化对象(不再 stringify 进 `{ text }`),所以这里 stringify 的是
2647
- * 第一层也是唯一一层 JSON;循环结构/不可序列化值的两级兜底是防御,不是常规路径。
4127
+ * 在 controller 闸门下起一条未拥有作业,起完立刻摘掉。
4128
+ *
4129
+ * 实测:挂上即放行、摘掉即恢复"没有 controller 服务这个 owner"的拒绝、可反复;而 global 层
4130
+ * 那串 token 只被 `servesOwner` 读(installed dsh-jobs-local 全文唯一消费点),作业后续的
4131
+ * read/kill/落定都不依赖它。所以常驻一枚等于在"宿主故意没装 job 工具"的组成里替全宿主
4132
+ * 开着那道闸门——官方契约写的是"生产者不能起一台 owner 收不掉、停不掉的作业"。
2648
4133
  */
2649
- function textBlock(value) {
2650
- let text;
4134
+ function startUnderOcrController(jobs, spec) {
4135
+ const detach = jobs.attachController("ocr-review: background");
2651
4136
  try {
2652
- const json = JSON.stringify(value, null, 2);
2653
- text = typeof json === "string" ? json : String(value);
2654
- } catch {
2655
- try {
2656
- text = String(value);
2657
- } catch {
2658
- text = "[unrenderable value]";
2659
- }
4137
+ return jobs.start(spec);
4138
+ } finally {
4139
+ detach();
2660
4140
  }
2661
- return [{
2662
- type: "text",
2663
- text
2664
- }];
2665
4141
  }
2666
- /** 可空 string 字段的 schema 片段(官方子集单类型,null 分支用 oneOf 表达)。 */
2667
- const NULLABLE_STRING = { oneOf: [{ type: "string" }, { type: "null" }] };
2668
- /** ocr_review / ocr_scan 的输出:wait=false = 后台回执对象;前台成功 = 摘要对象;
2669
- * 前台解析失败 = 一段诚实的说明字符串。三者恰一(required 逐字段声明保证两个
2670
- * 对象分支互斥)。 */
2671
- const REVIEW_OUTPUT_SCHEMA = { oneOf: [
2672
- {
2673
- type: "object",
2674
- additionalProperties: false,
2675
- required: [
2676
- "started",
2677
- "mode",
2678
- "tool",
2679
- "repo",
2680
- "processStatus",
2681
- "jobId",
2682
- "polling"
2683
- ],
2684
- properties: {
2685
- started: {
2686
- type: "boolean",
2687
- const: true
2688
- },
2689
- mode: {
2690
- type: "string",
2691
- const: "background"
2692
- },
2693
- tool: { type: "string" },
2694
- repo: { type: "string" },
2695
- processStatus: {
2696
- type: "string",
2697
- enum: [
2698
- "running",
2699
- "completed",
2700
- "killed"
2701
- ]
2702
- },
2703
- jobId: { type: "string" },
2704
- polling: { type: "string" }
2705
- }
2706
- },
2707
- {
2708
- type: "object",
2709
- additionalProperties: false,
2710
- required: [
2711
- "status",
2712
- "skipped",
2713
- "message",
2714
- "summary",
2715
- "budgetExceeded",
2716
- "sessionId",
2717
- "aggregation",
2718
- "totalCommentCount",
2719
- "droppedCount",
2720
- "invalidCommentCount",
2721
- "topComments",
2722
- "warnings",
2723
- "toolCalls",
2724
- "coverage",
2725
- "hint"
2726
- ],
2727
- properties: {
2728
- status: {
2729
- type: "string",
2730
- enum: [
2731
- "success",
2732
- "complete",
2733
- "partial",
2734
- "completed_with_warnings",
2735
- "completed_with_errors",
2736
- "skipped"
2737
- ]
2738
- },
2739
- skipped: { type: "boolean" },
2740
- message: NULLABLE_STRING,
2741
- summary: { oneOf: [{ type: "null" }, {
2742
- type: "object",
2743
- additionalProperties: false,
2744
- required: [
2745
- "filesReviewed",
2746
- "comments",
2747
- "totalTokens",
2748
- "inputTokens",
2749
- "outputTokens",
2750
- "elapsed"
2751
- ],
2752
- properties: {
2753
- filesReviewed: { type: "number" },
2754
- comments: { type: "number" },
2755
- totalTokens: { type: "number" },
2756
- inputTokens: { type: "number" },
2757
- outputTokens: { type: "number" },
2758
- elapsed: { type: "string" }
2759
- }
2760
- }] },
2761
- budgetExceeded: { type: "boolean" },
2762
- sessionId: NULLABLE_STRING,
2763
- aggregation: {
2764
- type: "object",
2765
- additionalProperties: false,
2766
- required: [
2767
- "total",
2768
- "bySeverity",
2769
- "byCategory",
2770
- "unknownSeverityCount"
2771
- ],
2772
- properties: {
2773
- total: { type: "integer" },
2774
- unknownSeverityCount: { type: "integer" },
2775
- bySeverity: {
2776
- type: "object",
2777
- additionalProperties: false,
2778
- required: [
2779
- "critical",
2780
- "high",
2781
- "medium",
2782
- "low"
2783
- ],
2784
- properties: {
2785
- critical: { type: "integer" },
2786
- high: { type: "integer" },
2787
- medium: { type: "integer" },
2788
- low: { type: "integer" }
2789
- }
2790
- },
2791
- byCategory: { type: "object" }
2792
- }
2793
- },
2794
- totalCommentCount: { type: "integer" },
2795
- droppedCount: { type: "integer" },
2796
- invalidCommentCount: { type: "integer" },
2797
- topComments: {
2798
- type: "array",
2799
- items: {
2800
- type: "object",
2801
- additionalProperties: false,
2802
- required: [
2803
- "location",
2804
- "severity",
2805
- "category",
2806
- "content",
2807
- "suggestion"
2808
- ],
2809
- properties: {
2810
- location: { type: "string" },
2811
- severity: { type: "string" },
2812
- category: { type: "string" },
2813
- content: { type: "string" },
2814
- suggestion: NULLABLE_STRING
2815
- }
2816
- }
2817
- },
2818
- warnings: {
2819
- type: "array",
2820
- items: { type: "string" }
2821
- },
2822
- toolCalls: { oneOf: [{ type: "null" }, {
2823
- type: "object",
2824
- additionalProperties: false,
2825
- required: [
2826
- "total",
2827
- "failure",
2828
- "failures"
2829
- ],
2830
- properties: {
2831
- total: { type: "integer" },
2832
- failure: { type: "integer" },
2833
- failures: {
2834
- type: "array",
2835
- items: { type: "string" }
2836
- }
2837
- }
2838
- }] },
2839
- coverage: { oneOf: [{ type: "null" }, {
2840
- type: "object",
2841
- additionalProperties: false,
2842
- required: [
2843
- "selected",
2844
- "completed",
2845
- "reused",
2846
- "failed",
2847
- "waived",
2848
- "failedItems"
2849
- ],
2850
- properties: {
2851
- selected: { type: "integer" },
2852
- completed: { type: "integer" },
2853
- reused: { type: "integer" },
2854
- failed: { type: "integer" },
2855
- waived: { type: "integer" },
2856
- failedItems: {
2857
- type: "array",
2858
- items: { type: "string" }
2859
- }
2860
- }
2861
- }] },
2862
- hint: { type: "string" }
2863
- }
2864
- },
2865
- { type: "string" }
2866
- ] };
2867
- /** ocr_delegate_preview:成功 = 清单对象;解析失败 = 说明字符串。 */
2868
- const PREVIEW_OUTPUT_SCHEMA = { oneOf: [{
2869
- type: "object",
2870
- additionalProperties: false,
2871
- required: [
2872
- "mode",
2873
- "repository",
2874
- "mergeBase",
2875
- "reviewableCount",
2876
- "insertions",
2877
- "deletions",
2878
- "reviewableFiles",
2879
- "excludedFiles",
2880
- "hint"
2881
- ],
2882
- properties: {
2883
- mode: { type: "string" },
2884
- repository: { type: "string" },
2885
- mergeBase: NULLABLE_STRING,
2886
- reviewableCount: { type: "integer" },
2887
- insertions: { type: "number" },
2888
- deletions: { type: "number" },
2889
- reviewableFiles: {
2890
- type: "array",
2891
- items: {
2892
- type: "object",
2893
- additionalProperties: false,
2894
- required: [
2895
- "path",
2896
- "status",
2897
- "insertions",
2898
- "deletions"
2899
- ],
2900
- properties: {
2901
- path: { type: "string" },
2902
- status: { type: "string" },
2903
- insertions: { type: "number" },
2904
- deletions: { type: "number" }
2905
- }
2906
- }
2907
- },
2908
- excludedFiles: {
2909
- type: "array",
2910
- items: {
2911
- type: "object",
2912
- additionalProperties: false,
2913
- required: ["path", "reason"],
2914
- properties: {
2915
- path: { type: "string" },
2916
- reason: { type: "string" }
2917
- }
2918
- }
2919
- },
2920
- hint: { type: "string" }
2921
- }
2922
- }, { type: "string" }] };
2923
- /** ocr_delegate_rule:成功 = 分组对象;解析失败 = 说明字符串。 */
2924
- const RULES_OUTPUT_SCHEMA = { oneOf: [{
2925
- type: "object",
2926
- additionalProperties: false,
2927
- required: [
2928
- "groupCount",
2929
- "groups",
2930
- "hint"
2931
- ],
2932
- properties: {
2933
- groupCount: { type: "integer" },
2934
- groups: {
2935
- type: "array",
2936
- items: {
2937
- type: "object",
2938
- additionalProperties: false,
2939
- required: [
2940
- "source",
2941
- "pattern",
2942
- "files",
2943
- "rule"
2944
- ],
2945
- properties: {
2946
- source: { type: "string" },
2947
- pattern: { type: "string" },
2948
- files: {
2949
- type: "array",
2950
- items: { type: "string" }
2951
- },
2952
- rule: { type: "string" }
2953
- }
2954
- }
2955
- },
2956
- hint: { type: "string" }
2957
- }
2958
- }, { type: "string" }] };
2959
- /** ocr_session:sessionOutput 本身就是一手透传的字符串(诚实的值),外层仍是对象
2960
- * 好让 hint 有处安放。 */
2961
- const SESSION_OUTPUT_SCHEMA = {
2962
- type: "object",
2963
- additionalProperties: false,
2964
- required: ["sessionOutput", "hint"],
2965
- properties: {
2966
- sessionOutput: { type: "string" },
2967
- hint: { type: "string" }
4142
+ /**
4143
+ * 等到兜底 deadline 到点,然后把仍在跑的那条经注册表收掉。
4144
+ *
4145
+ * 用注册表自己的 `wait(id, ms)` 而不是宿主 timer,也不是裸 `setTimeout`:前者要把
4146
+ * `timer` 写进 HostCtx 的**必需**服务面(装载守卫一扩,没装那件的宿主连前台评审都装载
4147
+ * 不了),后者会活过插件卸载。实测 `wait` 只等不杀、作业一落定就解掉它内部的定时器,
4148
+ * 也不推进输出游标(模型侧 job_output 的读数不受影响)——所以落定时这里看一眼状态就返回。
4149
+ * @param jobs 官方注册表
4150
+ * @param id 这次后台评审的作业 id
4151
+ * @param reason 到点回收时写进模型可见 detail 的理由
4152
+ * @param backgroundMaxMs 窗口毫秒数;`<= 0`(部署级设置项 `ocrBackgroundMaxMs` 的默认值)
4153
+ * 表示这条不设兜底窗口,直接返回、不去敲 `wait`
4154
+ * @returns 等完这一条的兜底窗口;抛错一律就地收敛(记录没了就等于无事可做)
4155
+ */
4156
+ async function enforceOcrDeadline(jobs, id, reason, backgroundMaxMs) {
4157
+ try {
4158
+ if (backgroundMaxMs <= 0) return;
4159
+ if ((await jobs.wait(id, backgroundMaxMs)).status === "running") jobs.kill(id, void 0, reason);
4160
+ } catch {}
4161
+ }
4162
+ /**
4163
+ * 卸载时收掉本包仍在跑(或正在收尾)的作业。
4164
+ *
4165
+ * 注册表随宿主活着,不会因为我们卸载而杀进程:一条留在 `running` 的 OCR 既继续占着未拥有
4166
+ * 那一桶的容量位(实测满员是拒绝别人的 start),又没人再等它的输出。遍历的是注册表的
4167
+ * `list()` 而不是本包记的账——本包不留常驻状态,跨会话/跨 fiber 起的作业也要能被收掉。
4168
+ * @param jobs 官方注册表
4169
+ * @param reason 写进模型可见 detail 的理由(双语字典取)
4170
+ * @returns {void}
4171
+ */
4172
+ function reclaimOcrJobs(jobs, reason) {
4173
+ const mine = jobs.list().filter((job) => job.kind === OCR_JOB_KIND);
4174
+ for (const job of mine) if (isLiveOcrStatus(job.status)) jobs.kill(job.id, void 0, reason);
4175
+ else jobs.remove(job.id);
4176
+ }
4177
+ /** 当前会话工作区(repo 缺省回退源);非字符串视为缺。
4178
+ * 官方形状:`agent?: Agent` → `session: Session` → `header: SessionHeader` →
4179
+ * `cwd?: string`。下面的逐层 `?.` 与 `typeof` 复核是跨进程契约的运行时闸:宿主半装配
4180
+ * 递来的 agent 可能是 `null`、也可能没有 `session`/`header`(test/host.test.ts 钉住
4181
+ * 这两条 fail-closed 路径),值一律经 `unknown` 读,绝不只信声明。 */
4182
+ function sessionHeaderCwd(exec) {
4183
+ const raw = exec.agent?.session?.header?.cwd;
4184
+ return typeof raw === "string" && raw.length > 0 ? raw : void 0;
4185
+ }
4186
+ /** host 是否为本插件所需服务面(settings/shell/tools/systemPrompt/get/inject 及
4187
+ * 其上要调用的方法)。守卫仅压缩类型面、避开 `as unknown as HostCtx` 断言。
4188
+ * settings 这里点名的两个方法都会被调用:configure 登记本页的页面策略、describe
4189
+ * 跨命名空间读(provider 清单 + 官方 locale 偏好);inject 挂那条页面策略的 effect。
4190
+ * settings 面没有 register/get,多点名一个就是永不成立的死分支。 */
4191
+ function isOcrReviewHost(value) {
4192
+ if (!isRecord(value)) return false;
4193
+ const { settings, shell, tools, systemPrompt, inject, get } = value;
4194
+ return hasMethods(settings, ["configure", "describe"]) && hasMethods(shell, ["resolve", "execute"]) && hasMethods(tools, ["register"]) && hasMethods(systemPrompt, ["section"]) && typeof inject === "function" && typeof get === "function";
4195
+ }
4196
+ /** 从插件 Context 解析宿主服务面(守卫收窄,不做 `as unknown as` 断言)。 */
4197
+ function resolveHost(ctx) {
4198
+ if (!isOcrReviewHost(ctx)) throw new Error("ocr-review: 宿主缺少所需的 shell/tools/settings/systemPrompt/inject 服务界面(或其方法)");
4199
+ return ctx;
4200
+ }
4201
+ /** describe() 行里按命名空间取解析值(跨命名空间读只有 describe() 一条路:
4202
+ * `settings.get(ns)` 已删)。未注册该命名空间 → undefined,调用侧按默认值降级。
4203
+ * @param rows - `ctx.settings.describe()` 的返回(全部条目的表单投影)。
4204
+ * @param ns - 要读的命名空间(官方字段名是 `ns`)。
4205
+ */
4206
+ function describedValue(rows, ns) {
4207
+ return rows.find((row) => row.ns === ns)?.value;
4208
+ }
4209
+ /**
4210
+ * 取本包 host 侧文案(按官方 locale 偏好现取现用):设置页「常规」里换语言后,
4211
+ * 下一次工具调用/端点请求即换新文案,不需要重启或重装插件。
4212
+ * locale 命名空间未注册(无官方 client-locale 包)→ describe() 里没有这条 →
4213
+ * undefined → 中文默认。
4214
+ */
4215
+ function localeMessages(host) {
4216
+ const preference = describedValue(host.settings.describe(), LOCALE_SETTINGS_NAMESPACE);
4217
+ return messagesFor(MESSAGES, resolveLocalePreference(preference));
4218
+ }
4219
+ /**
4220
+ * render 只做 model-facing 展示:把 canonical value 本身 pretty-print 成文本块。
4221
+ * execute 直返结构化对象(不再 stringify 进 `{ text }`),所以这里 stringify 的是
4222
+ * 第一层也是唯一一层 JSON;循环结构/不可序列化值的两级兜底是防御,不是常规路径。
4223
+ */
4224
+ function textBlock(value) {
4225
+ let text;
4226
+ try {
4227
+ const json = JSON.stringify(value, null, 2);
4228
+ text = typeof json === "string" ? json : String(value);
4229
+ } catch {
4230
+ try {
4231
+ text = String(value);
4232
+ } catch {
4233
+ text = "[unrenderable value]";
4234
+ }
2968
4235
  }
2969
- };
4236
+ return [{
4237
+ type: "text",
4238
+ text
4239
+ }];
4240
+ }
2970
4241
  /**
2971
4242
  * ocr 子进程一律按 `danger-full-access` 请求(官方按调用携带的 `SandboxExecutionPolicy`)。
2972
4243
  *
@@ -2985,7 +4256,7 @@ const SESSION_OUTPUT_SCHEMA = {
2985
4256
  * 路径、没有自定义 HOME、没有目录级豁免;而 `SandboxMode` 的取值只有
2986
4257
  * `read-only | workspace-write | danger-full-access` 三档,中间那一档的可写根就是
2987
4258
  * `workspaceRoot` 一枚。两处声明都取自本包实际解析到的那版(dsh-sandbox-policy 与
2988
- * dsh-sandbox 的 0.2.1-rc.2,`lib/types/index.d.ts`)。
4259
+ * dsh-sandbox 的 0.2.1-rc.3,`lib/types/index.d.ts`)。
2989
4260
  * 可选的收紧路径只有「改 ocr 自己的状态落点」或「等上游加字段」,两者都不是本包能在
2990
4261
  * 一次补丁里做完的事。故此处维持放开,但把边界写明:放开的是 ocr 这一个子进程的整棵
2991
4262
  * 文件系统写权限,不是宿主其它能力;且 ocr 读什么、写什么仍由上面那条 argv 闸与
@@ -3191,7 +4462,7 @@ async function runCollect(host, command, workdir, run) {
3191
4462
  }
3192
4463
  function readEffort(config) {
3193
4464
  const raw = config.effort.get();
3194
- return raw === "low" || raw === "high" ? raw : "medium";
4465
+ return raw === "auto" || raw === "low" || raw === "high" ? raw : "medium";
3195
4466
  }
3196
4467
  function readLanguage(config) {
3197
4468
  const lang = config.language.get();
@@ -3360,10 +4631,11 @@ function tuningParams(messages, unit, maxToolsTemplate) {
3360
4631
  * 外部仍以 `Config` 名导入(export as),公开 API 不变。 */
3361
4632
  const configSchema = Schema.object({
3362
4633
  effort: Schema.union([
4634
+ "auto",
3363
4635
  "low",
3364
4636
  "medium",
3365
4637
  "high"
3366
- ]).default("medium").volatile(),
4638
+ ]).default("auto").volatile(),
3367
4639
  language: Schema.union(["中文", "English"]).default("中文").volatile(),
3368
4640
  autoVerify: Schema.boolean().default(true).volatile(),
3369
4641
  maxComments: Schema.natural().default(MAX_COMMENTS_DEFAULT).volatile(),
@@ -3399,7 +4671,7 @@ function pluginPaths(config, messages) {
3399
4671
  /**
3400
4672
  * ocr 子进程的 HOME 重定向值(工具执行侧专用,**不抛错**):设置项命中
3401
4673
  * `<X>/.opencodereview/config.json` 布局 → X;未设置/坏设置 → undefined。工具不
3402
- * 走 pluginPaths(那会在坏设置下把五个工具一并拖死),坏设置的错误由端点侧的
4674
+ * 走 pluginPaths(那会在坏设置下把七个工具一并拖死),坏设置的错误由端点侧的
3403
4675
  * resolveOcrConfigPath 报给卡片——「工具与设置照常可用」的口径不变。
3404
4676
  */
3405
4677
  function ocrHome(config) {
@@ -3412,7 +4684,7 @@ function ocrHome(config) {
3412
4684
  * 为什么必须在这一层而不是启动时:config.json 里的脚本路径是**写那一刻**的插件安装
3413
4685
  * 目录,而插件会被重装、换 profile(desktop / browser / 任意自定义名)、换发布形态。
3414
4686
  * 目录一变那条命令就悬空,ocr 侧报的是「resolve LLM endpoint 失败」,与真因毫无
3415
- * 关系——五个工具一次性全挂,而配置文件里没有任何线索指向「本包被挪了地方」。
4687
+ * 关系——七个工具一次性全挂,而配置文件里没有任何线索指向「本包被挪了地方」。
3416
4688
  * 当前路径一律取自 PLUGIN_DIR(import.meta.dirname),与 profile 命名
3417
4689
  * 无关,故 desktop / browser / 自定义名走的是同一条路径。
3418
4690
  *
@@ -3451,7 +4723,7 @@ function probeFailureReason(messages, exitCode, timedOut) {
3451
4723
  * **全局安装**(`scripts/update.js` 见到更新版本就 spawnSync `npm i -g <pkg>@<latest>`,
3452
4724
  * 默认 18 分钟冷却一次)。状态读侧不该有写侧副作用,所以这条不受 `ocrNoAutoUpdate`
3453
4725
  * 那个部署开关影响,恒定带上。
3454
- * - HOME 与五个工具走同一个重定向口。launcher 那句「自动更新没成功,请手工执行」的
4726
+ * - HOME 与七个工具走同一个重定向口。launcher 那句「自动更新没成功,请手工执行」的
3455
4727
  * 提示写在 `$HOME/.opencodereview/` 下,不带重定向就会去读另一个世界的状态,
3456
4728
  * 卡片报的与评审实际吃到的就不是同一份。
3457
4729
  * @param host 宿主服务面
@@ -3541,7 +4813,7 @@ function cliStatusPayload(status, config, messages, measured) {
3541
4813
  * @returns 同一份摘要,或 hint 尾部多了一句的副本。
3542
4814
  */
3543
4815
  function withCliVersionNote(summary, measured, messages) {
3544
- if (measured === null || measured === "1.13.0") return summary;
4816
+ if (measured === null || measured === "1.13.3") return summary;
3545
4817
  const note = format(messages.versionMismatch, {
3546
4818
  actual: measured,
3547
4819
  expected: EXPECTED_OCR_CLI_VERSION
@@ -3552,7 +4824,7 @@ function withCliVersionNote(summary, measured, messages) {
3552
4824
  };
3553
4825
  }
3554
4826
  /** 运行 ocr llm test 校验当前配置(select 后自动验证 / 手动测试共用)。
3555
- * 命令词与五个工具同一解析源(buildLlmTestCommand 走 ocrCommand():卡片验的那一份与评审
4827
+ * 命令词与七个工具同一解析源(buildLlmTestCommand 走 ocrCommand():卡片验的那一份与评审
3556
4828
  * 吃的那一份必须同一个二进制),HOME 随 ocrConfigPath 重定向。墙钟与输出上限都是 entry
3557
4829
  * config 字段(非 volatile,改值随重启生效)。 */
3558
4830
  async function runLlmTest(host, config) {
@@ -3566,8 +4838,8 @@ async function runLlmTest(host, config) {
3566
4838
  output
3567
4839
  };
3568
4840
  }
3569
- /** effort 入参闸:undefined/null → 设置卡默认;字符串 → 白名单钳制(未知值回落
3570
- * medium,与 --format 同一纪律);其余类型抛错——静默回落会改变审查深度。 */
4841
+ /** effort 入参闸:undefined/null → 设置卡默认(auto 表示跟随 OCR 配置);字符串 →
4842
+ * 白名单钳制(未知值回落 auto,与 --format 同一纪律);其余类型抛错——静默回落会改变审查深度。 */
3571
4843
  function effortOf(value, config, messages) {
3572
4844
  if (value === void 0 || value === null) return readEffort(config);
3573
4845
  if (typeof value !== "string") throw new TypeError(format(messages.mustBeString, {
@@ -3590,6 +4862,19 @@ function waitOf(value, messages) {
3590
4862
  if (typeof value !== "boolean") throw new TypeError(format(messages.waitMustBeBoolean, { received: describeValue(value) }));
3591
4863
  return value;
3592
4864
  }
4865
+ /** session action 的只读归一:错类型交给 builder 的消息表闸。 */
4866
+ function actionOf(value) {
4867
+ return typeof value === "string" ? value.trim().toLowerCase() : "";
4868
+ }
4869
+ /** preview 入参闸:缺省 false;非布尔抛错。 */
4870
+ function previewOf(value, messages) {
4871
+ if (value === void 0 || value === null) return false;
4872
+ if (typeof value !== "boolean") throw new TypeError(format(messages.boolMustBeBoolean, {
4873
+ name: "preview",
4874
+ received: describeValue(value)
4875
+ }));
4876
+ return value;
4877
+ }
3593
4878
  /** 服务界面齐备才算可用(get() 可能返回半装配对象,也可能返回 undefined)。 */
3594
4879
  function usableService(value, methods) {
3595
4880
  return value !== void 0 && hasMethods(value, methods) ? value : void 0;
@@ -3809,13 +5094,93 @@ async function testReply(deps) {
3809
5094
  };
3810
5095
  }
3811
5096
  }
5097
+ async function mcpReply(deps) {
5098
+ const msgs = deps.messages();
5099
+ try {
5100
+ const result = await readMcpServers(pluginPaths(deps.config, msgs).ocrConfigJson, msgs);
5101
+ return result.ok ? {
5102
+ ok: true,
5103
+ json: result.json
5104
+ } : {
5105
+ ok: false,
5106
+ error: result.error,
5107
+ status: 400
5108
+ };
5109
+ } catch (error) {
5110
+ return {
5111
+ ok: false,
5112
+ error: errorText(error),
5113
+ status: 500
5114
+ };
5115
+ }
5116
+ }
5117
+ async function saveMcpReply(json, deps) {
5118
+ const msgs = deps.messages();
5119
+ if (typeof json !== "string") return {
5120
+ ok: false,
5121
+ error: format(msgs.mustBeString, {
5122
+ name: "json",
5123
+ received: describeValue(json)
5124
+ }),
5125
+ status: 400
5126
+ };
5127
+ const result = await saveMcpServers(pluginPaths(deps.config, msgs).ocrConfigJson, json, msgs);
5128
+ return result.ok ? {
5129
+ ok: true,
5130
+ count: result.count
5131
+ } : {
5132
+ ok: false,
5133
+ error: result.error,
5134
+ status: 400
5135
+ };
5136
+ }
5137
+ async function authReply(deps) {
5138
+ try {
5139
+ const { exitCode, output } = await runCollect(deps.host, withHomeEnv(buildAuthStatusCommand(), ocrHome(deps.config)), process.env["HOME"] ?? "/", {
5140
+ timeoutMs: deps.config.llmTestTimeoutMs,
5141
+ stdoutMaxBytes: deps.config.stdoutMaxBytes,
5142
+ env: ocrEnv(deps.config)
5143
+ });
5144
+ return {
5145
+ ok: exitCode === 0,
5146
+ output,
5147
+ loginCommand: CHATGPT_LOGIN_COMMAND
5148
+ };
5149
+ } catch (error) {
5150
+ return {
5151
+ ok: false,
5152
+ error: errorText(error),
5153
+ status: 400
5154
+ };
5155
+ }
5156
+ }
5157
+ async function chatGptReply(model, deps) {
5158
+ const msgs = deps.messages();
5159
+ if (typeof model !== "string") return {
5160
+ ok: false,
5161
+ error: format(msgs.mustBeString, {
5162
+ name: "model",
5163
+ received: describeValue(model)
5164
+ }),
5165
+ status: 400
5166
+ };
5167
+ const result = await applyChatGptModel(pluginPaths(deps.config, msgs).ocrConfigJson, model, msgs);
5168
+ return result.ok ? {
5169
+ ok: true,
5170
+ model: model.trim()
5171
+ } : {
5172
+ ok: false,
5173
+ error: result.error,
5174
+ status: 400
5175
+ };
5176
+ }
3812
5177
  /**
3813
- * 设置卡的五条读/写方法(providers / select / migrate / test / probe)。
5178
+ * 设置卡的九条读/写方法(providers / select / migrate / test / probe / mcp / saveMcp / auth / chatGpt)。
3814
5179
  *
3815
5180
  * ⚠ 服务在**构造时**注册(cordis `Service` 的契约:`ctx.reflect.provide`,随所属 fiber
3816
5181
  * 卸载自动撤),所以调用方拿实例没有任何用处——装入口 `installOcrRemote` 说明了这一点。
3817
- * 五个方法都不依赖可选宿主服务(gateway 与取用口都由 apply 交进来),故它跟着 apply
3818
- * 无条件上线:宿主没有 web 层(TUI)时这五条照样在。
5182
+ * 九个方法都不依赖可选宿主服务(gateway 与取用口都由 apply 交进来),故它跟着 apply
5183
+ * 无条件上线:宿主没有 web 层(TUI)时这九条照样在。
3819
5184
  */
3820
5185
  var OcrReviewRemoteService = class extends TypertRemoteService {
3821
5186
  deps;
@@ -3849,6 +5214,22 @@ var OcrReviewRemoteService = class extends TypertRemoteService {
3849
5214
  versionSink: this.deps.extras.versionSink
3850
5215
  });
3851
5216
  }
5217
+ /** 读当前 `mcp_servers`(只回这一支,不带其他配置/keys)。 */
5218
+ async mcp() {
5219
+ return mcpReply(this.deps);
5220
+ }
5221
+ /** 校验并写入 `mcp_servers`;其他配置原样保留。 */
5222
+ async saveMcp(request) {
5223
+ return saveMcpReply(isRecord(request) ? request.json : void 0, this.deps);
5224
+ }
5225
+ /** 只读查询 ChatGPT 登录态;登录命令原样带回给卡片显示。 */
5226
+ async auth() {
5227
+ return authReply(this.deps);
5228
+ }
5229
+ /** 切到 ChatGPT provider 并写模型;key 由 OCR 的 auth 凭据库管,不写明文。 */
5230
+ async chatGpt(request) {
5231
+ return chatGptReply(isRecord(request) ? request.model : void 0, this.deps);
5232
+ }
3852
5233
  };
3853
5234
  /**
3854
5235
  * 标准装饰器的**程序化调用**:`@Remote` 的全部作用就是往类原型写一枚描述符,而标准装饰器
@@ -3873,6 +5254,10 @@ markRemote(OcrReviewRemoteService.prototype, "select");
3873
5254
  markRemote(OcrReviewRemoteService.prototype, "migrate");
3874
5255
  markRemote(OcrReviewRemoteService.prototype, "test");
3875
5256
  markRemote(OcrReviewRemoteService.prototype, "probe");
5257
+ markRemote(OcrReviewRemoteService.prototype, "mcp");
5258
+ markRemote(OcrReviewRemoteService.prototype, "saveMcp");
5259
+ markRemote(OcrReviewRemoteService.prototype, "auth");
5260
+ markRemote(OcrReviewRemoteService.prototype, "chatGpt");
3876
5261
  /**
3877
5262
  * 把读面装到这个上下文上(构造即注册,见类文档;host 侧唯一该调的口)。
3878
5263
  *
@@ -3910,6 +5295,70 @@ const productionProbe = () => resolveOcrAvailability();
3910
5295
  function assertOcrPresent(status, messages, install) {
3911
5296
  if (status.source === "none") throw new Error(format(messages.ocrBinaryMissing, { install }));
3912
5297
  }
5298
+ /** review 的预览支路:零 LLM、前台、临时 --output;抽出来只为把注册函数压回行数上限。 */
5299
+ async function runReviewPreview(run) {
5300
+ const { host, config, args, repo, signal, messages } = run;
5301
+ if (args["wait"] !== void 0 && args["wait"] !== null && !waitOf(args["wait"], messages)) throw new Error(messages.previewNeedsForeground);
5302
+ const tmp = mkdtempSync(path.join(tmpdir(), "ocr-review-preview-"));
5303
+ const outPath = path.join(tmp, "preview.json");
5304
+ try {
5305
+ const { command, workdir } = buildReviewCommand({
5306
+ ...args,
5307
+ repo,
5308
+ preview: true
5309
+ }, outPath, messages);
5310
+ await runForeground(host, withHomeEnv(command, ocrHome(config)), workdir, tuningOf(config), signal);
5311
+ const text = readOutFile(outPath, messages);
5312
+ const parsed = parsePreviewOutput(text);
5313
+ if (!parsed.ok) return format(messages.parseFailedWithReason, {
5314
+ tool: "ocr review --preview",
5315
+ message: messages.previewParseFailed,
5316
+ limit: String(RAW_ECHO_LIMIT),
5317
+ raw: echoRaw(text, RAW_ECHO_LIMIT)
5318
+ });
5319
+ return {
5320
+ ...parsed.view,
5321
+ hint: messages.filePreviewHint
5322
+ };
5323
+ } finally {
5324
+ rmSync(tmp, {
5325
+ recursive: true,
5326
+ force: true
5327
+ });
5328
+ }
5329
+ }
5330
+ /** scan 的预览支路(与 review 同形,只是不归一化 git 顶层)。 */
5331
+ async function runScanPreview(run) {
5332
+ const { host, config, args, repo, signal, messages } = run;
5333
+ if (args["wait"] !== void 0 && args["wait"] !== null && !waitOf(args["wait"], messages)) throw new Error(messages.previewNeedsForeground);
5334
+ const tmp = mkdtempSync(path.join(tmpdir(), "ocr-scan-preview-"));
5335
+ const outPath = path.join(tmp, "preview.json");
5336
+ try {
5337
+ const { command, workdir } = buildScanCommand({
5338
+ ...args,
5339
+ repo,
5340
+ preview: true
5341
+ }, outPath, messages);
5342
+ await runForeground(host, withHomeEnv(command, ocrHome(config)), workdir, tuningOf(config), signal);
5343
+ const text = readOutFile(outPath, messages);
5344
+ const parsed = parsePreviewOutput(text);
5345
+ if (!parsed.ok) return format(messages.parseFailedWithReason, {
5346
+ tool: "ocr scan --preview",
5347
+ message: messages.previewParseFailed,
5348
+ limit: String(RAW_ECHO_LIMIT),
5349
+ raw: echoRaw(text, RAW_ECHO_LIMIT)
5350
+ });
5351
+ return {
5352
+ ...parsed.view,
5353
+ hint: messages.filePreviewHint
5354
+ };
5355
+ } finally {
5356
+ rmSync(tmp, {
5357
+ recursive: true,
5358
+ force: true
5359
+ });
5360
+ }
5361
+ }
3913
5362
  function registerReviewTool(host, config, probe, versionSink) {
3914
5363
  const msgs = localeMessages(host);
3915
5364
  define(host, {
@@ -3978,6 +5427,26 @@ function registerReviewTool(host, config, probe, versionSink) {
3978
5427
  resume: {
3979
5428
  type: "string",
3980
5429
  description: msgs.paramResume
5430
+ },
5431
+ preview: {
5432
+ type: "boolean",
5433
+ description: msgs.paramPreview
5434
+ },
5435
+ noFilter: {
5436
+ type: "boolean",
5437
+ description: msgs.paramNoFilter
5438
+ },
5439
+ maxGitProcs: {
5440
+ type: "integer",
5441
+ description: msgs.paramMaxGitProcs
5442
+ },
5443
+ rule: {
5444
+ type: "string",
5445
+ description: msgs.paramRule
5446
+ },
5447
+ backgroundFile: {
5448
+ type: "string",
5449
+ description: msgs.paramBackgroundFile
3981
5450
  }
3982
5451
  }
3983
5452
  },
@@ -3987,6 +5456,14 @@ function registerReviewTool(host, config, probe, versionSink) {
3987
5456
  const cliStatus = probe();
3988
5457
  assertOcrPresent(cliStatus, runMsgs, ocrInstallText(cliStatus, config, runMsgs));
3989
5458
  const repo = await canonicalReviewRoot(host, ocrRepo(args, exec, runMsgs));
5459
+ if (previewOf(args["preview"], runMsgs)) return runReviewPreview({
5460
+ host,
5461
+ config,
5462
+ args,
5463
+ repo,
5464
+ signal: exec.signal,
5465
+ messages: runMsgs
5466
+ });
3990
5467
  const eff = effortOf(args["effort"], config, runMsgs);
3991
5468
  const tmp = waitOf(args["wait"], runMsgs) ? mkdtempSync(path.join(tmpdir(), "ocr-review-")) : void 0;
3992
5469
  const outPath = tmp === void 0 ? void 0 : path.join(tmp, "out.json");
@@ -4079,6 +5556,34 @@ function registerScanTool(host, config, probe, versionSink) {
4079
5556
  wait: {
4080
5557
  type: "boolean",
4081
5558
  description: msgs.paramWait
5559
+ },
5560
+ preview: {
5561
+ type: "boolean",
5562
+ description: msgs.paramPreview
5563
+ },
5564
+ noPlan: {
5565
+ type: "boolean",
5566
+ description: msgs.paramNoPlan
5567
+ },
5568
+ noDedup: {
5569
+ type: "boolean",
5570
+ description: msgs.paramNoDedup
5571
+ },
5572
+ noSummary: {
5573
+ type: "boolean",
5574
+ description: msgs.paramNoSummary
5575
+ },
5576
+ resume: {
5577
+ type: "string",
5578
+ description: msgs.paramScanResume
5579
+ },
5580
+ maxGitProcs: {
5581
+ type: "integer",
5582
+ description: msgs.paramMaxGitProcs
5583
+ },
5584
+ rule: {
5585
+ type: "string",
5586
+ description: msgs.paramRule
4082
5587
  }
4083
5588
  }
4084
5589
  },
@@ -4088,6 +5593,14 @@ function registerScanTool(host, config, probe, versionSink) {
4088
5593
  const cliStatus = probe();
4089
5594
  assertOcrPresent(cliStatus, runMsgs, ocrInstallText(cliStatus, config, runMsgs));
4090
5595
  const repo = ocrRepo(args, exec, runMsgs);
5596
+ if (previewOf(args["preview"], runMsgs)) return runScanPreview({
5597
+ host,
5598
+ config,
5599
+ args,
5600
+ repo,
5601
+ signal: exec.signal,
5602
+ messages: runMsgs
5603
+ });
4091
5604
  const tmp = waitOf(args["wait"], runMsgs) ? mkdtempSync(path.join(tmpdir(), "ocr-scan-")) : void 0;
4092
5605
  const outPath = tmp === void 0 ? void 0 : path.join(tmp, "out.json");
4093
5606
  await healKeyScriptPointer(host, config);
@@ -4276,7 +5789,9 @@ function registerSessionTool(host, config, probe) {
4276
5789
  enum: [
4277
5790
  "list",
4278
5791
  "show",
4279
- "comments"
5792
+ "comments",
5793
+ "compare",
5794
+ "export"
4280
5795
  ],
4281
5796
  description: msgs.paramSessionAction
4282
5797
  },
@@ -4284,6 +5799,24 @@ function registerSessionTool(host, config, probe) {
4284
5799
  type: "string",
4285
5800
  description: msgs.paramSessionId
4286
5801
  },
5802
+ before: {
5803
+ type: "string",
5804
+ description: msgs.paramSessionBefore
5805
+ },
5806
+ after: {
5807
+ type: "string",
5808
+ description: msgs.paramSessionAfter
5809
+ },
5810
+ severity: {
5811
+ type: "array",
5812
+ items: { type: "string" },
5813
+ description: msgs.paramSessionSeverity
5814
+ },
5815
+ category: {
5816
+ type: "array",
5817
+ items: { type: "string" },
5818
+ description: msgs.paramSessionCategory
5819
+ },
4287
5820
  limit: {
4288
5821
  type: "integer",
4289
5822
  description: msgs.paramSessionLimit
@@ -4296,6 +5829,47 @@ function registerSessionTool(host, config, probe) {
4296
5829
  const cliStatus = probe();
4297
5830
  assertOcrPresent(cliStatus, runMsgs, ocrInstallText(cliStatus, config, runMsgs));
4298
5831
  const repo = await canonicalReviewRoot(host, ocrRepo(args, exec, runMsgs));
5832
+ const action = actionOf(args["action"]);
5833
+ const before = typeof args["before"] === "string" ? args["before"].trim() : "";
5834
+ const after = typeof args["after"] === "string" ? args["after"].trim() : "";
5835
+ if (action === "compare" || action === "" && (before !== "" || after !== "")) {
5836
+ const { command, workdir } = buildSessionCommand({
5837
+ ...args,
5838
+ repo
5839
+ }, runMsgs);
5840
+ const stdout = await runForeground(host, withHomeEnv(command, ocrHome(config)), workdir, tuningOf(config), exec.signal);
5841
+ const parsed = parseSessionCompare(stdout.text);
5842
+ if (parsed.ok) return {
5843
+ ...parsed.view,
5844
+ hint: runMsgs.sessionCompareHint
5845
+ };
5846
+ return {
5847
+ sessionOutput: echoRaw(stdout.text, RAW_ECHO_LIMIT) + stdoutTruncationNote(config.stdoutMaxBytes, stdout, runMsgs),
5848
+ hint: runMsgs.sessionCompareHint
5849
+ };
5850
+ }
5851
+ if (action === "export") {
5852
+ const exportDir = mkdtempSync(path.join(tmpdir(), "ocr-session-export-"));
5853
+ const output = path.join(exportDir, "review.html");
5854
+ try {
5855
+ const { command, workdir } = buildSessionCommand({
5856
+ ...args,
5857
+ repo,
5858
+ output
5859
+ }, runMsgs);
5860
+ await runForeground(host, withHomeEnv(command, ocrHome(config)), workdir, tuningOf(config), exec.signal);
5861
+ return {
5862
+ sessionOutput: output,
5863
+ hint: runMsgs.sessionExportHint
5864
+ };
5865
+ } catch (error) {
5866
+ rmSync(exportDir, {
5867
+ recursive: true,
5868
+ force: true
5869
+ });
5870
+ throw error;
5871
+ }
5872
+ }
4299
5873
  const { command, workdir } = buildSessionCommand({
4300
5874
  ...args,
4301
5875
  repo
@@ -4312,6 +5886,83 @@ function registerSessionTool(host, config, probe) {
4312
5886
  }
4313
5887
  });
4314
5888
  }
5889
+ function registerRulesCheckTool(host, config, probe) {
5890
+ const msgs = localeMessages(host);
5891
+ define(host, {
5892
+ name: "ocr_rules_check",
5893
+ description: msgs.rulesCheckToolDescription,
5894
+ parameters: {
5895
+ type: "object",
5896
+ additionalProperties: false,
5897
+ properties: {
5898
+ repo: {
5899
+ type: "string",
5900
+ description: msgs.paramRepo
5901
+ },
5902
+ path: {
5903
+ type: "string",
5904
+ description: msgs.paramRulesPath
5905
+ },
5906
+ rule: {
5907
+ type: "string",
5908
+ description: msgs.paramRule
5909
+ }
5910
+ },
5911
+ required: ["path"]
5912
+ },
5913
+ schema: TEXT_OUTPUT_SCHEMA,
5914
+ run: async (args, exec) => {
5915
+ const runMsgs = localeMessages(host);
5916
+ const cliStatus = probe();
5917
+ assertOcrPresent(cliStatus, runMsgs, ocrInstallText(cliStatus, config, runMsgs));
5918
+ const repo = ocrRepo(args, exec, runMsgs);
5919
+ const { command, workdir } = buildRulesCheckCommand({
5920
+ ...args,
5921
+ repo
5922
+ }, runMsgs);
5923
+ const stdout = await runForeground(host, withHomeEnv(command, ocrHome(config)), workdir, tuningOf(config), exec.signal);
5924
+ const { text } = stdout;
5925
+ return {
5926
+ output: (text.length > 12e3 ? `${echoRaw(text, 11900)}\n${format(runMsgs.textTruncated, { limit: "12000" })}` : redactKeyMaterial(text)) + stdoutTruncationNote(config.stdoutMaxBytes, stdout, runMsgs),
5927
+ hint: runMsgs.rulesCheckHint
5928
+ };
5929
+ }
5930
+ });
5931
+ }
5932
+ function registerLlmTool(host, config, probe) {
5933
+ const msgs = localeMessages(host);
5934
+ define(host, {
5935
+ name: "ocr_llm",
5936
+ description: msgs.llmToolDescription,
5937
+ parameters: {
5938
+ type: "object",
5939
+ additionalProperties: false,
5940
+ properties: { action: {
5941
+ type: "string",
5942
+ enum: ["providers", "models"],
5943
+ description: msgs.paramLlmAction
5944
+ } },
5945
+ required: ["action"]
5946
+ },
5947
+ schema: TEXT_OUTPUT_SCHEMA,
5948
+ run: async (args, exec) => {
5949
+ const runMsgs = localeMessages(host);
5950
+ const cliStatus = probe();
5951
+ assertOcrPresent(cliStatus, runMsgs, ocrInstallText(cliStatus, config, runMsgs));
5952
+ const rawAction = args["action"];
5953
+ if (rawAction !== "providers" && rawAction !== "models") throw new Error(format(runMsgs.invalidAction, {
5954
+ action: typeof rawAction === "string" ? rawAction : describeValue(rawAction),
5955
+ expected: "providers/models"
5956
+ }));
5957
+ const stdout = await runForeground(host, withHomeEnv(buildLlmCommand(rawAction), ocrHome(config)), process.cwd(), tuningOf(config), exec.signal);
5958
+ const { text } = stdout;
5959
+ return {
5960
+ output: (text.length > 12e3 ? `${echoRaw(text, 11900)}\n${format(runMsgs.textTruncated, { limit: "12000" })}` : redactKeyMaterial(text)) + stdoutTruncationNote(config.stdoutMaxBytes, stdout, runMsgs),
5961
+ hint: runMsgs.llmHint
5962
+ };
5963
+ }
5964
+ });
5965
+ }
4315
5966
  /** 装载期自检结论里保留的输出上限(字符):卡片只用来提示,留全量没有意义。 */
4316
5967
  const ENDPOINT_VERIFY_CHARS = 400;
4317
5968
  /** 装载期端点自检(fire-and-forget):跑一次 `ocr llm test`,把结论写进本次 apply 的闭包,
@@ -4378,6 +6029,8 @@ function apply(ctx, config, probe) {
4378
6029
  registerDelegatePreviewTool(host, config, probe);
4379
6030
  registerDelegateRuleTool(host, config, probe);
4380
6031
  registerSessionTool(host, config, probe);
6032
+ registerRulesCheckTool(host, config, probe);
6033
+ registerLlmTool(host, config, probe);
4381
6034
  host.systemPrompt.section({
4382
6035
  name: ROUTING_NAME,
4383
6036
  order: ROUTING_ORDER,
@@ -4407,5 +6060,4 @@ var host_default = {
4407
6060
  apply(ctx, config, productionProbe);
4408
6061
  }
4409
6062
  };
4410
- //#endregion
4411
6063
  export { configSchema as Config, OcrReviewRemoteService, apply, assertOcrPresent, createGateway, host_default as default, endpointVerifyOf, installOcrRemote, joinedOutput, ocrEnv, ocrRepo, productionProbe, readLanguage, requiredKeys };