@jayyuen66/dsh-ocr-review 0.1.7 → 0.1.9

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);
@@ -409,24 +591,45 @@ function arrayViolations(schema, value, path, walk) {
409
591
  * 能嵌套任意多个候选起点,而「run 内是否存在数字或大写」这类前瞻要把后面整段 run 反向
410
592
  * 回溯一遍——逐起点相乘就是 O(n²)(实测 400_000 字符 51 秒,正好是 `stdoutMaxBytes`
411
593
  * 的默认档)。run 是首个 `-` 之后的那一段:前缀只含字母,故首个 `-` 必是分界。 */
412
- const STANDALONE_KEY = /\b[A-Za-z]{2,5}-[A-Za-z0-9_-]{20,}\b/gu;
594
+ const STANDALONE_KEY = /\b[A-Za-z]{2,5}-[\w-]{20,}\b/gu;
413
595
  /** `Authorization: <scheme> <value>` / `Proxy-Authorization: …`:认**头名**而不是
414
596
  * 枚举认证方案。头名本身就是「后面跟着凭据」的信号,Basic / Token / Digest 乃至
415
597
  * vendor 自定义 scheme 都不会漏;而只认头名还避开了正文里「token based
416
598
  * authentication」这类措辞被误抹。scheme 组连同其后的空白一起捕获,回填时凭据被
417
599
  * 替换而 scheme 词保留。 */
418
- const AUTH_HEADER_VALUE = /(?<name>\b(?:proxy-)?authorization["'\s]*[:=]["'\s]*)(?:(?<scheme>[a-z][a-z0-9_-]*[ \t]+))?[^\s"',;)}\]]{4,}/giu;
600
+ const AUTH_HEADER_VALUE = /(?<name>\b(?:proxy-)?authorization["'\s]*[:=]["'\s]*)(?<scheme>[a-z][\w-]*[ \t]+)?[^\s"',;)}\]]{4,}/giu;
419
601
  /** 裸 scheme + 值(没有头名时 OCR 也会整行回显)。scheme 只留 bearer:它在散文中几乎
420
602
  * 不出现,而 token / basic 之类会撞上「token count」「basic usage」等普通措辞。 */
421
- const BEARER_VALUE = /(?<scheme>\bbearer[ \t]+)[a-z0-9._~+/=-]{4,}/giu;
603
+ const BEARER_VALUE = /(?<scheme>\bbearer[ \t]+)[\w.~+/=-]{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|access[_-]?key|secret[_-]?key|client[_-]?secret|password|token)["'\s]*[:=]["'\s]*)(?<scheme>[a-z][\w-]*[ \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)_\w{20,}\b/gu;
426
620
  const REDACTION = "***REDACTED***";
427
- /** 独立 key 形态的替换臂:随机串段里真有数字或大写才抹,否则原样交回(文件路径要保持可读)。 */
428
- function redactStandaloneKey(match) {
429
- return /[0-9A-Z]/u.test(match.slice(match.indexOf("-") + 1)) ? REDACTION : match;
621
+ /** token 尾部至少一枚非 `-` 的字母/数字。 */
622
+ const TOKEN_TAIL_CHARACTER = /[0-9A-Z]/u;
623
+ /** 独立 key 形态的替换臂:随机串段里真有数字或大写才抹,否则原样交回(文件路径要保持可读)。
624
+ * 两侧任一是路径分隔符的匹配是路径段而非 key:ocr CLI 会把仓库路径拍平成 kebab 段嵌进
625
+ * session 的 file_path(`…/sessions/Users-yuanjiang-Documents-…/uuid.jsonl`),原路径里
626
+ * 只要有大写组件(macOS 的 Documents/Desktop 一类)就过得了「数字或大写」那道护栏——
627
+ * 裸 key 从不出现在斜杠两侧,路径段也从不出现在别处,按上下文分比按字符集猜稳。 */
628
+ function redactStandaloneKey(match, offset, whole) {
629
+ const before = whole[offset - 1];
630
+ const after = whole[offset + match.length];
631
+ if (before === "/" || after === "/" || before === "\\" || after === "\\") return match;
632
+ return TOKEN_TAIL_CHARACTER.test(match.slice(match.indexOf("-") + 1)) ? REDACTION : match;
430
633
  }
431
634
  /**
432
635
  * 抹掉文本里 key 形状的片段。
@@ -441,7 +644,7 @@ function redactStandaloneKey(match) {
441
644
  * 要求随机串特征,避免把要交给模型的文件路径一起抹掉。
442
645
  */
443
646
  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);
647
+ 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
648
  }
446
649
  /** ECMA-48 的 CSI 序列:ESC `[` + 参数位 + 中间位 + 终结位(SGR 颜色码就是终结位 `m`)。 */
447
650
  const ANSI_CSI = new RegExp(`${String.fromCodePoint(27)}\\[[0-?]*[ -/]*[@-~]`, "gu");
@@ -466,8 +669,8 @@ function violationsOf(schema, value, path) {
466
669
  if (passing.length !== 1) violations.push(`${path}: matched ${passing.length} of ${oneOf.length} oneOf branches`);
467
670
  }
468
671
  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));
672
+ if (type === "object") for (const violation of objectViolations(schema, value, path, violationsOf)) violations.push(violation);
673
+ else if (type === "array") for (const violation of arrayViolations(schema, value, path, violationsOf)) violations.push(violation);
471
674
  else if (type !== void 0 && scalarMismatch(type, value)) violations.push(`${path}: expected ${type}`);
472
675
  if (schema.const !== void 0 && value !== schema.const) violations.push(`${path}: must be exactly ${JSON.stringify(schema.const)}`);
473
676
  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 +701,10 @@ function assertToolOutput(schema, value, toolName, messages) {
498
701
  }
499
702
  return value;
500
703
  }
501
- //#endregion
502
- //#region lib/argv-guard.ts
704
+ // lib/argv-guard.ts
705
+ /** 归一后剥掉的尾随分隔符。 */
706
+ const TRAILING_POSIX_SLASHES = /\/+$/u;
707
+ const TRAILING_PATH_SEPARATORS = /[\\/]+$/u;
503
708
  /**
504
709
  * POSIX sh 单引号转义:' → '\''。把任意字符串安全放进单引号内,防注入。
505
710
  *
@@ -513,22 +718,96 @@ function shq(value) {
513
718
  return `'${value.replaceAll("'", String.raw`'\''`)}'`;
514
719
  }
515
720
  /**
516
- * 绝对路径校验(macOS/Linux):非空、trim 后以 / 开头、无 NUL 字节。
721
+ * 绝对路径校验:非空、trim 后是绝对路径、无 NUL 字节;交出前按路径形态归一成唯一形态。
517
722
  *
518
723
  * 先 trim 再判:调用点会先 trim 再传进来,而直接调用 `args.repo` 的路径若带首尾空白,
519
724
  * 会被「必须是绝对路径」这条误导性文案拦下——读起来像是路径写错了格式,实际只是多了
520
725
  * 几个空格。顺带也挡住全空白(trim 后为空 ⇒ 触发 repoRequired)。
726
+ *
727
+ * 归一(posix.normalize + 去尾随斜杠)的目的:同一个仓库只允许一种字符串形态到达下游。
728
+ * `/repo//sub`、`/repo/./a`、`/repo/` 与 `/repo/sub` 是同一目录的几种写法,而会话目录
729
+ * 等按路径派生的键会把这些写法各存一份;`..` 段按词法折叠成实际目标(`/repo/../..`
730
+ * 落到 `/`)——折叠是 normalize 对绝对路径的既有行为,不存在「解析后仍含 `..`」的
731
+ * 形态,所以这里没有可写的拒绝分支。
521
732
  */
522
733
  function assertAbsoluteRoot(root, messages) {
523
734
  const trimmed = typeof root === "string" ? root.trim() : "";
524
735
  if (trimmed.length === 0) throw new Error(messages.repoRequired);
525
736
  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;
737
+ if (trimmed.startsWith("/")) {
738
+ const normalized = path.posix.normalize(trimmed);
739
+ return normalized.length > 1 ? normalized.replace(TRAILING_POSIX_SLASHES, "") : normalized;
740
+ }
741
+ if (process.platform === "win32" && path.win32.isAbsolute(trimmed)) {
742
+ const normalized = path.win32.normalize(trimmed);
743
+ return normalized.length > 3 ? normalized.replace(TRAILING_PATH_SEPARATORS, "") : normalized;
744
+ }
745
+ throw new Error(format(messages.repoMustBeAbsolute, { root: trimmed }));
746
+ }
747
+ /**
748
+ * `--rule` / `--background-file` 的路径闸:只允许 repo 根内的普通文件。
749
+ *
750
+ * 这两个 flag 会把文件内容读进 OCR 并可能送给 LLM;OCR 子进程以
751
+ * `danger-full-access` 运行,若放任 `../../.ssh/id_rsa` 之类的路径,一次工具调用就能
752
+ * 把仓库外的文件送进模型。realpath 先解开符号链接再比较,`path.resolve` 的文本前缀
753
+ * 判据挡不住指向仓库外的软链。
754
+ *
755
+ * @param root - 已校验为绝对路径的 repo 根
756
+ * @param value - 模型给的路径(相对 repo 或绝对)
757
+ * @param name - 参数名(报错点名)
758
+ * @param maxBytes - 允许的字节上限
759
+ * @param messages - 双语文案
760
+ * @returns 归一后的绝对路径
761
+ */
762
+ function resolveRepoFile(root, value, name, maxBytes, messages) {
763
+ if (typeof value !== "string") throw new TypeError(format(messages.mustBeString, {
764
+ name,
765
+ received: typeof value
766
+ }));
767
+ const text = value.trim();
768
+ if (text === "") throw new Error(format(messages.repoFileMissing, {
769
+ name,
770
+ path: ""
771
+ }));
772
+ if (text.includes("\0")) throw new Error(format(messages.noNulBytes, { name }));
773
+ let rootReal;
774
+ try {
775
+ rootReal = realpathSync(root);
776
+ } catch (error) {
777
+ throw new Error(format(messages.repoRootUnreadable, { path: root }), { cause: error });
778
+ }
779
+ let real;
780
+ try {
781
+ real = realpathSync(path.resolve(rootReal, text));
782
+ } catch (error) {
783
+ throw new Error(format(messages.repoFileMissing, {
784
+ name,
785
+ path: text
786
+ }), { cause: error });
787
+ }
788
+ const rel = path.relative(rootReal, real);
789
+ if (path.isAbsolute(rel) || rel === ".." || rel.startsWith(`..${path.sep}`)) throw new Error(format(messages.repoFileOutside, {
790
+ name,
791
+ path: text
792
+ }));
793
+ const stat = statSync(real);
794
+ if (!stat.isFile()) throw new Error(format(messages.repoFileNotFile, {
795
+ name,
796
+ path: text
797
+ }));
798
+ const { size } = stat;
799
+ if (size > maxBytes) throw new Error(format(messages.repoFileTooLarge, {
800
+ name,
801
+ path: text,
802
+ limit: String(maxBytes)
803
+ }));
804
+ return real;
528
805
  }
529
806
  /** limit 非法(NaN / ≤0 / 非整数)时的保守兜底上限:宁可比调用方的意图更严。 */
530
807
  const DEFAULT_LIST_LIMIT = 200;
808
+ /** `auto` = 不传 `--effort`,让 OCR 自己的配置决定;工具 schema 仍只暴露三档。 */
531
809
  const EFFORTS = [
810
+ "auto",
532
811
  "low",
533
812
  "medium",
534
813
  "high"
@@ -537,11 +816,28 @@ const EFFORTS = [
537
816
  function clampEffort(value, fallback) {
538
817
  return EFFORTS.find((level) => level === value) ?? fallback;
539
818
  }
819
+ /** 字符串里第一个落在 glob 花括号内的逗号位置(没有则 -1)。 */
820
+ function firstCommaInsideBraces(text) {
821
+ let depth = 0;
822
+ for (let index = 0; index < text.length; index += 1) {
823
+ const ch = text[index];
824
+ if (ch === "{") depth += 1;
825
+ else if (ch === "}" && depth > 0) depth -= 1;
826
+ else if (ch === "," && depth > 0) return index;
827
+ }
828
+ return -1;
829
+ }
540
830
  /** 入参 → 原始项序列(commaList 的第一段:只判「值从哪来」,不判内容)。
541
831
  * 数组分支保留逐元素 for-of 而不是 `value.map`:map 会跳过稀疏数组的空洞,
542
832
  * 空洞本是「非字符串元素」这一条该拦的形态,跳过就等于静默少一项过滤条件。 */
543
833
  function listSourceItems(value, name, messages) {
544
- if (typeof value === "string") return value.split(",");
834
+ if (typeof value === "string") {
835
+ if (firstCommaInsideBraces(value) >= 0) throw new TypeError(format(messages.commaInStringBraces, {
836
+ name,
837
+ item: value
838
+ }));
839
+ return value.split(",");
840
+ }
545
841
  if (Array.isArray(value)) {
546
842
  const items = [];
547
843
  for (const item of value) {
@@ -567,14 +863,15 @@ function takeTrimmedItems(items, name, limit, messages) {
567
863
  if (trimmed.length > 0 && out.length >= cap) dropped += 1;
568
864
  else if (trimmed.length > 0) out.push(trimmed);
569
865
  }
570
- if (dropped > 0) throw new Error(format(messages.listTruncated, {
866
+ if (dropped > 0) throw new Error(format(messages.listOverLimit, {
571
867
  name,
572
868
  dropped: String(dropped),
573
869
  limit: String(cap)
574
870
  }));
575
871
  return out;
576
872
  }
577
- /** 字符串数组 → 逗号拼接(--exclude/--path 契约)。
873
+ /** 入参 → 规范化条目数组(--exclude/--path 契约)。返回值是**条目数组**;把数组按逗号
874
+ * 拼接成 flag 值由调用点负责(lib/cli.ts 的 `shq(excludes.join(","))`)。
578
875
  * 元素类型严格:`value.map(String)` 会把对象变成 `[object Object]` 静默混入
579
876
  * 模式串(过滤条件失效 = 审查范围被扩大),故非字符串元素一律抛错。
580
877
  * 防注入由调用点 shq() 兜底。 */
@@ -601,15 +898,22 @@ const REVIEW_OUTPUT_FORMATS = [
601
898
  function resolveOutputFormat(value) {
602
899
  return REVIEW_OUTPUT_FORMATS.find((candidate) => candidate === value) ?? "json";
603
900
  }
604
- //#endregion
605
- //#region lib/cli.ts
606
- /** `ocr review|scan|delegate preview` 的背景说明 flag(三处命令构造共用一条)。
607
- * pushTextFlag 按 `flag.slice(2)` 反推报错用的参数名,故值必须带 `--` 前缀。 */
901
+ // lib/cli.ts
902
+ /** Homebrew 安装路径(`Cellar` / `Homebrew` 任一段)。 */
903
+ const HOMEBREW_PATH = /(?:^|[/\\])(?:Cellar|Homebrew)(?:[/\\]|$)/iu;
904
+ /** `ocr` 版本行:`open-code-review v1.13.3`(允许预发布/构建元数据)。 */
905
+ const OCR_VERSION = /open-code-review\s+v?(?<version>\d+\.\d+(?:\.\d+)?(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?)/u;
608
906
  /** `--exclude` 的模式条数上限。**不是审查范围的上限**,只是防一条失控输入把命令串撑爆;
609
907
  * 真实使用里几十条就到头,给到 2000 是为了"尽量不截断"——超了会明确告警,不静默丢。 */
610
908
  const EXCLUDE_LIST_LIMIT = 2e3;
611
909
  /** `--path` 的路径条数上限(同 EXCLUDE_LIST_LIMIT 的取向:够大 + 超限告警)。 */
612
910
  const PATH_LIST_LIMIT = 2e3;
911
+ /** `--rule` / `--background-file` 的文件大小上限:规则是检查清单,背景是需求文档。 */
912
+ const RULE_FILE_MAX_BYTES = 262144;
913
+ const BACKGROUND_FILE_MAX_BYTES = 1048576;
914
+ const MAX_GIT_PROCS_FLAG = "--max-git-procs";
915
+ /** `ocr review|scan|delegate preview` 的背景说明 flag(三处命令构造共用一条)。
916
+ * pushTextFlag 按 `flag.slice(2)` 反推报错用的参数名,故值必须带 `--` 前缀。 */
613
917
  const BACKGROUND_FLAG = "--background";
614
918
  /**
615
919
  * 本包的契约是按外部 `ocr` CLI 的哪一版核对的(README 的前置条件、装法建议与工具回显里
@@ -620,19 +924,27 @@ const BACKGROUND_FLAG = "--background";
620
924
  * test/readme-parity.test.ts 再断言两侧 README 写的也是它。三处只有一处是人写的、
621
925
  * 靠门禁钉住另两处,就不会出现「README 教用户装 A、代码按 B 校验」这种两边各自自洽的错。
622
926
  */
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
- }
927
+ const EXPECTED_OCR_CLI_VERSION = "1.13.3";
630
928
  /** 报错用的值形态(永不调用可能被劫持的 toJSON/toString)。host.ts 的入参闸
631
929
  * (effort/wait)复用同一口径,故导出。 */
632
930
  function describeValue(value) {
633
931
  if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return String(value);
634
932
  return Array.isArray(value) ? "array" : typeof value;
635
933
  }
934
+ /** root 解析:显式绝对路径优先;缺失/空串回退到当前会话工作区。
935
+ *
936
+ * 错类型必须抛错,不能落进 fallback:宿主对工具入参零校验(见 `idValue` 的说明),
937
+ * `{repo: 123}` 静默回退成当前会话工作区,等于「调用方明确指定了仓库」被换成另一个
938
+ * 仓库去审,而回执里不会留下任何痕迹。空串/缺省才是「没指定」。 */
939
+ function resolveRoot(value, fallback, messages) {
940
+ if (value !== void 0 && value !== null && typeof value !== "string") throw new TypeError(format(messages.mustBeString, {
941
+ name: "repo",
942
+ received: describeValue(value)
943
+ }));
944
+ if (typeof value === "string" && value.trim().length > 0) return assertAbsoluteRoot(value.trim(), messages);
945
+ if (typeof fallback === "string" && fallback.trim().length > 0) return assertAbsoluteRoot(fallback.trim(), messages);
946
+ throw new Error(messages.repoRequiredUnknownWorkspace);
947
+ }
636
948
  /** 字符串标量参数:键存在即必须是字符串。
637
949
  *
638
950
  * 为什么拒绝而不是忽略:宿主对工具入参零校验(dsh tools index.ts
@@ -685,17 +997,73 @@ function pushIntFlag(parts, flag, value, min, messages) {
685
997
  }));
686
998
  parts.push(flag, String(num));
687
999
  }
1000
+ /** 布尔 flag:undefined/null 视为 false;其余非布尔值抛错(字符串 "false" 不能当真值)。 */
1001
+ function boolFlag(value, name, messages) {
1002
+ if (value === void 0 || value === null) return false;
1003
+ if (typeof value !== "boolean") throw new TypeError(format(messages.boolMustBeBoolean, {
1004
+ name,
1005
+ received: describeValue(value)
1006
+ }));
1007
+ return value;
1008
+ }
1009
+ function pushBoolFlag(parts, flag, value, name, messages) {
1010
+ if (boolFlag(value, name, messages)) parts.push(flag);
1011
+ }
1012
+ /** `--rule` / `--background-file`:缺省/空串视为未给;其余交给路径闸(repo 根内普通文件)。 */
1013
+ function repoFileOf(value, repo, name, maxBytes, messages) {
1014
+ if (value === void 0 || value === null) return null;
1015
+ if (typeof value === "string" && value.trim() === "") return null;
1016
+ return resolveRepoFile(repo, value, name, maxBytes, messages);
1017
+ }
1018
+ /** 预览与正常审查共用的选择面:exclude、rule、max-git-procs。 */
1019
+ function selectionFlags(args, repo, messages) {
1020
+ const parts = [];
1021
+ const excludes = commaList(args.exclude, "exclude", EXCLUDE_LIST_LIMIT, messages);
1022
+ if (excludes.length > 0) parts.push("--exclude", shq(excludes.join(",")));
1023
+ const rule = repoFileOf(args.rule, repo, "rule", RULE_FILE_MAX_BYTES, messages);
1024
+ if (rule !== null) parts.push("--rule", shq(rule));
1025
+ pushIntFlag(parts, MAX_GIT_PROCS_FLAG, args.maxGitProcs, 1, messages);
1026
+ return parts;
1027
+ }
688
1028
  /** 模式参数解析:workspace(默认)/commit/branch 互斥,非法组合与错类型抛错。
689
1029
  *
690
1030
  * 互斥判定必须在两个模式分支之前一次做完:若先判 commit(`commit 非空` 时条件
691
1031
  * 恒真),后面的「branch 与 commit 互斥」就永不可达,`{scope:branch, commit:x}`
692
1032
  * 会被静默降级成 commit 审查——审错范围却按结论上报。 */
693
- function scopeFlags(args, messages) {
1033
+ const KNOWN_SCOPES = /* @__PURE__ */ new Set([
1034
+ "workspace",
1035
+ "commit",
1036
+ "branch"
1037
+ ]);
1038
+ /** 与 `scope` 冲突时报告的是哪一种范围参数(两者都没给时返回空串)。 */
1039
+ function otherScopeOf(commit, from, to) {
1040
+ if (commit !== "") return "commit";
1041
+ return from === "" && to === "" ? "" : "from/to";
1042
+ }
1043
+ /**
1044
+ * `scope` 是**声明**,不是从别的参数反推出来的结论:先按 from/to/commit 推模式再看
1045
+ * scope,`{scope:"nope", from:"a", to:"b"}` 会被当成 branch 静默放行,而调用方声明的
1046
+ * 范围与真正审的范围不是一回事。
1047
+ */
1048
+ function declaredScopeOf(args, messages) {
694
1049
  const scopeName = idValue(args.scope, "scope", messages).toLowerCase();
695
- const scope = scopeName === "" ? "workspace" : scopeName;
1050
+ if (scopeName === "") return "workspace";
1051
+ if (!KNOWN_SCOPES.has(scopeName)) throw new Error(format(messages.unknownScope, { scope: scopeName }));
1052
+ return scopeName;
1053
+ }
1054
+ function scopeFlags(args, messages) {
1055
+ const scope = declaredScopeOf(args, messages);
1056
+ const explicit = idValue(args.scope, "scope", messages) !== "";
696
1057
  const from = idValue(args.from, "from", messages);
697
1058
  const to = idValue(args.to, "to", messages);
698
1059
  const commit = idValue(args.commit, "commit", messages);
1060
+ if (explicit && scope === "workspace") {
1061
+ const given = otherScopeOf(commit, from, to);
1062
+ if (given !== "") throw new Error(format(messages.scopeMismatch, {
1063
+ scope,
1064
+ given
1065
+ }));
1066
+ }
699
1067
  const wantsBranch = scope === "branch" || from !== "" || to !== "";
700
1068
  const wantsCommit = scope === "commit" || commit !== "";
701
1069
  if (wantsBranch && wantsCommit) throw new Error(messages.scopeBranchCommitExclusive);
@@ -712,7 +1080,6 @@ function scopeFlags(args, messages) {
712
1080
  if (scope === "commit" && commit === "") throw new Error(messages.commitNeedsValue);
713
1081
  return ["--commit", shq(commit)];
714
1082
  }
715
- if (scope !== "workspace") throw new Error(format(messages.unknownScope, { scope }));
716
1083
  return [];
717
1084
  }
718
1085
  /** 可选文本 flag:值缺省/空串则省略,错类型抛错(静默丢弃等于悄悄改变语义)。 */
@@ -834,7 +1201,7 @@ function realPathOrSelf(candidate) {
834
1201
  function inferOcrInstallChannel(candidate) {
835
1202
  const resolved = realPathOrSelf(candidate);
836
1203
  if (resolved.includes("node_modules")) return "npm";
837
- return /(?:^|[/\\])(?:Cellar|Homebrew)(?:[/\\]|$)/iu.test(resolved) ? "brew" : "unknown";
1204
+ return HOMEBREW_PATH.test(resolved) ? "brew" : "unknown";
838
1205
  }
839
1206
  /** brew 的 formula 名与 npm 的包名不同:formula 不带 scope。 */
840
1207
  const OCR_BREW_FORMULA = "open-code-review";
@@ -973,7 +1340,7 @@ function buildVersionCommand() {
973
1340
  * 读不出即 null:未发布构建的 `<Version>` 是 `dev`,没有版本号可报,猜一个等于编一个。
974
1341
  */
975
1342
  function parseOcrVersion(text) {
976
- return /open-code-review\s+v?(?<version>\d+\.\d+(?:\.\d+)?(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?)/u.exec(text)?.groups?.["version"] ?? null;
1343
+ return OCR_VERSION.exec(text)?.groups?.["version"] ?? null;
977
1344
  }
978
1345
  /**
979
1346
  * `--format` 交给 OCR 的取值:本包只认 json——`lib/parse.ts` 的解析层只解 JSON 一份形状。
@@ -996,6 +1363,7 @@ function jsonOnlyFormat(value, messages) {
996
1363
  function buildReviewCommand(args, outputPath, messages) {
997
1364
  const repo = assertAbsoluteRoot(args.repo, messages);
998
1365
  const outputFormat = jsonOnlyFormat(args.format, messages);
1366
+ const preview = boolFlag(args.preview, "preview", messages);
999
1367
  const parts = [
1000
1368
  ocrCommand(),
1001
1369
  "review",
@@ -1004,8 +1372,18 @@ function buildReviewCommand(args, outputPath, messages) {
1004
1372
  "--format",
1005
1373
  outputFormat
1006
1374
  ];
1375
+ const resume = idValue(args.resume, "resume", messages);
1376
+ if (preview) {
1377
+ if (resume !== "") throw new Error(messages.previewRejectsResume);
1378
+ parts.push("--preview", ...scopeFlags(args, messages), ...selectionFlags(args, repo, messages));
1379
+ if (outputPath !== void 0) parts.push("--output", shq(outputPath));
1380
+ return {
1381
+ command: parts.join(" "),
1382
+ workdir: repo
1383
+ };
1384
+ }
1007
1385
  const effort = clampEffort(args.effort, "medium");
1008
- parts.push("--effort", effort);
1386
+ if (effort !== "auto") parts.push("--effort", effort);
1009
1387
  pushIntFlag(parts, "--concurrency", args.concurrency, 1, messages);
1010
1388
  pushIntFlag(parts, "--timeout", args.timeoutMinutes, 0, messages);
1011
1389
  pushIntFlag(parts, "--max-tools", args.maxTools, 0, messages);
@@ -1013,7 +1391,12 @@ function buildReviewCommand(args, outputPath, messages) {
1013
1391
  pushIntFlag(parts, "--max-tokens-budget", args.maxTokensBudget, 0, messages);
1014
1392
  const scopeParts = scopeFlags(args, messages);
1015
1393
  parts.push(...scopeParts, ...optionalFlags(args, messages));
1016
- const resume = idValue(args.resume, "resume", messages);
1394
+ pushBoolFlag(parts, "--no-filter", args.noFilter, "noFilter", messages);
1395
+ const rule = repoFileOf(args.rule, repo, "rule", RULE_FILE_MAX_BYTES, messages);
1396
+ if (rule !== null) parts.push("--rule", shq(rule));
1397
+ const backgroundFile = repoFileOf(args.backgroundFile, repo, "backgroundFile", BACKGROUND_FILE_MAX_BYTES, messages);
1398
+ if (backgroundFile !== null) parts.push("--background-file", shq(backgroundFile));
1399
+ pushIntFlag(parts, MAX_GIT_PROCS_FLAG, args.maxGitProcs, 1, messages);
1017
1400
  if (resume !== "") {
1018
1401
  if (scopeParts.length === 0) throw new Error(messages.resumeNeedsScope);
1019
1402
  parts.push("--resume", shq(resume));
@@ -1031,6 +1414,9 @@ function buildReviewCommand(args, outputPath, messages) {
1031
1414
  function buildScanCommand(args, outputPath, messages) {
1032
1415
  const repo = assertAbsoluteRoot(args.repo, messages);
1033
1416
  const outputFormat = jsonOnlyFormat(args.format, messages);
1417
+ const preview = boolFlag(args.preview, "preview", messages);
1418
+ const resume = idValue(args.resume, "resume", messages);
1419
+ if (preview && resume !== "") throw new Error(messages.previewRejectsResume);
1034
1420
  const parts = [
1035
1421
  ocrCommand(),
1036
1422
  "scan",
@@ -1039,19 +1425,25 @@ function buildScanCommand(args, outputPath, messages) {
1039
1425
  "--format",
1040
1426
  outputFormat
1041
1427
  ];
1428
+ if (preview) parts.push("--preview");
1042
1429
  const paths = commaList(args.path, "path", PATH_LIST_LIMIT, messages);
1043
1430
  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);
1431
+ if (!preview) {
1432
+ pushTextFlag(parts, "--batch", args.batch, messages);
1433
+ pushTextFlag(parts, "--provider", args.provider, messages);
1434
+ pushTextFlag(parts, "--model", args.model, messages);
1435
+ pushIntFlag(parts, "--concurrency", args.concurrency, 1, messages);
1436
+ pushIntFlag(parts, "--timeout", args.timeoutMinutes, 0, messages);
1437
+ pushIntFlag(parts, "--max-tools", args.maxTools, 0, messages);
1438
+ pushIntFlag(parts, "--max-tokens", args.maxTokens, 0, messages);
1439
+ pushIntFlag(parts, "--max-tokens-budget", args.maxTokensBudget, 0, messages);
1440
+ pushBoolFlag(parts, "--no-plan", args.noPlan, "noPlan", messages);
1441
+ pushBoolFlag(parts, "--no-dedup", args.noDedup, "noDedup", messages);
1442
+ pushBoolFlag(parts, "--no-summary", args.noSummary, "noSummary", messages);
1443
+ if (resume !== "") parts.push("--resume", shq(resume));
1444
+ }
1445
+ parts.push(...selectionFlags(args, repo, messages));
1446
+ if (!preview) pushTextFlag(parts, BACKGROUND_FLAG, args.background, messages);
1055
1447
  if (outputPath !== void 0) parts.push("--output", shq(outputPath));
1056
1448
  return {
1057
1449
  command: parts.join(" "),
@@ -1076,6 +1468,11 @@ function buildDelegatePreviewCommand(args, messages) {
1076
1468
  const excludes = commaList(args.exclude, "exclude", EXCLUDE_LIST_LIMIT, messages);
1077
1469
  if (excludes.length > 0) parts.push("--exclude", shq(excludes.join(",")));
1078
1470
  pushTextFlag(parts, BACKGROUND_FLAG, args.background, messages);
1471
+ const backgroundFile = repoFileOf(args.backgroundFile, repo, "backgroundFile", BACKGROUND_FILE_MAX_BYTES, messages);
1472
+ if (backgroundFile !== null) parts.push("--background-file", shq(backgroundFile));
1473
+ const rule = repoFileOf(args.rule, repo, "rule", RULE_FILE_MAX_BYTES, messages);
1474
+ if (rule !== null) parts.push("--rule", shq(rule));
1475
+ pushIntFlag(parts, MAX_GIT_PROCS_FLAG, args.maxGitProcs, 1, messages);
1079
1476
  return {
1080
1477
  command: parts.join(" "),
1081
1478
  workdir: repo
@@ -1086,25 +1483,74 @@ function buildDelegateRuleCommand(args, messages) {
1086
1483
  const repo = assertAbsoluteRoot(args.repo, messages);
1087
1484
  const paths = commaList(args.paths, "paths", 200, messages).map((filePath) => shq(filePath));
1088
1485
  if (paths.length === 0) throw new Error(messages.pathsRequired);
1486
+ const parts = [
1487
+ ocrCommand(),
1488
+ "delegate",
1489
+ "rule",
1490
+ "--format",
1491
+ "json"
1492
+ ];
1493
+ const rule = repoFileOf(args.rule, repo, "rule", RULE_FILE_MAX_BYTES, messages);
1494
+ if (rule !== null) parts.push("--rule", shq(rule));
1495
+ parts.push("--", ...paths);
1496
+ return {
1497
+ command: parts.join(" "),
1498
+ workdir: repo
1499
+ };
1500
+ }
1501
+ /** `ocr rules check <file>`:只做规则解析,不跑 LLM;`path` 是待查询的文件路径。 */
1502
+ function buildRulesCheckCommand(args, messages) {
1503
+ const repo = assertAbsoluteRoot(args.repo, messages);
1504
+ const filePath = idValue(args.path, "path", messages);
1505
+ if (filePath === "") throw new Error(messages.rulesPathRequired);
1506
+ const parts = [
1507
+ ocrCommand(),
1508
+ "rules",
1509
+ "check",
1510
+ "--repo",
1511
+ shq(repo)
1512
+ ];
1513
+ const rule = repoFileOf(args.rule, repo, "rule", RULE_FILE_MAX_BYTES, messages);
1514
+ if (rule !== null) parts.push("--rule", shq(rule));
1515
+ parts.push("--", shq(filePath));
1089
1516
  return {
1090
- command: [
1091
- ocrCommand(),
1092
- "delegate",
1093
- "rule",
1094
- "--format",
1095
- "json",
1096
- "--",
1097
- ...paths
1098
- ].join(" "),
1517
+ command: parts.join(" "),
1099
1518
  workdir: repo
1100
1519
  };
1101
1520
  }
1102
- /** session 子命令白名单(子命令名裸拼进命令串,只可能是这三个字面量)。 */
1521
+ /** `ocr llm providers|models`:只读诊断命令,不需要仓库目录。 */
1522
+ function buildLlmCommand(action) {
1523
+ const sub = action === "providers" ? "providers" : "models";
1524
+ return `${ocrCommand()} llm ${sub} --color never`;
1525
+ }
1526
+ /** `ocr auth status`:卡片只读查询 ChatGPT 登录态。 */
1527
+ function buildAuthStatusCommand() {
1528
+ return `${ocrCommand()} auth status --provider chatgpt --color never`;
1529
+ }
1530
+ /** 登录动作保持人工执行(OAuth 要浏览器 + 本地回调),卡片只显示这条命令。 */
1531
+ const CHATGPT_LOGIN_COMMAND = "ocr auth login --provider chatgpt";
1532
+ /** session 子命令白名单(子命令名裸拼进命令串,只可能是这些字面量)。 */
1103
1533
  const SESSION_ACTIONS = [
1104
1534
  "list",
1105
1535
  "show",
1106
- "comments"
1536
+ "comments",
1537
+ "compare",
1538
+ "export"
1539
+ ];
1540
+ const SESSION_SEVERITIES = [
1541
+ "critical",
1542
+ "high",
1543
+ "medium",
1544
+ "low"
1107
1545
  ];
1546
+ /** severity 过滤:大小写归一 + 白名单;未知值报错而不是交给 CLI 返回空集。 */
1547
+ function severityFilter(value, messages) {
1548
+ return commaList(value, "severity", SESSION_SEVERITIES.length, messages).map((item) => {
1549
+ const lower = item.toLowerCase();
1550
+ if (!SESSION_SEVERITIES.includes(lower)) throw new Error(format(messages.invalidSeverity, { value: item }));
1551
+ return lower;
1552
+ });
1553
+ }
1108
1554
  /** session list --limit:缺省 10(OCR 原生默认 20,本包收紧;OCR 的 0=不限在本包
1109
1555
  * 上限 100 下无意义)。显式给出时必须是 1-100 的整数(与工具 description 的承诺
1110
1556
  * 一致),越界/错类型抛错而不是钳制——静默钳到 1 会把「想看更多」的 0 变成
@@ -1115,35 +1561,135 @@ function sessionLimit(value, messages) {
1115
1561
  if (num === null || num < 1 || num > 100) throw new Error(format(messages.sessionLimitRange, { received: describeValue(value) }));
1116
1562
  return num;
1117
1563
  }
1118
- /** 构造 `ocr session list/show/comments` 命令(历史评审会话闭环,Json 输出)。 */
1564
+ function sessionListParts(ctx) {
1565
+ if (ctx.id !== "") throw new Error(ctx.messages.listRejectsId);
1566
+ if (ctx.before !== "" || ctx.after !== "") throw new Error(ctx.messages.beforeAfterOnlyForCompare);
1567
+ if (ctx.hasFilters) throw new Error(ctx.messages.filtersOnlyForComments);
1568
+ return [
1569
+ ocrCommand(),
1570
+ "session",
1571
+ "list",
1572
+ "--json",
1573
+ "--repo",
1574
+ shq(ctx.repo),
1575
+ "--limit",
1576
+ String(sessionLimit(ctx.limit, ctx.messages))
1577
+ ];
1578
+ }
1579
+ function sessionCompareParts(ctx) {
1580
+ if (ctx.before === "" || ctx.after === "") throw new Error(ctx.messages.compareNeedsBoth);
1581
+ if (ctx.id !== "" || ctx.limit !== void 0 && ctx.limit !== null || ctx.hasFilters) throw new Error(ctx.messages.compareRejectsArgs);
1582
+ return [
1583
+ ocrCommand(),
1584
+ "session",
1585
+ "compare",
1586
+ "--json",
1587
+ "--repo",
1588
+ shq(ctx.repo),
1589
+ "--",
1590
+ shq(ctx.before),
1591
+ shq(ctx.after)
1592
+ ];
1593
+ }
1594
+ function sessionExportParts(ctx) {
1595
+ if (ctx.before !== "" || ctx.after !== "" || ctx.limit !== void 0 && ctx.limit !== null) throw new Error(ctx.messages.exportRejectsArgs);
1596
+ if (ctx.hasFilters) throw new Error(ctx.messages.filtersOnlyForComments);
1597
+ if (ctx.output === "") throw new Error(ctx.messages.exportOutputRequired);
1598
+ const parts = [
1599
+ ocrCommand(),
1600
+ "session",
1601
+ "export",
1602
+ "--repo",
1603
+ shq(ctx.repo),
1604
+ "-o",
1605
+ shq(ctx.output)
1606
+ ];
1607
+ if (ctx.id !== "") parts.push("--", shq(ctx.id));
1608
+ return parts;
1609
+ }
1610
+ function sessionCommentsParts(ctx) {
1611
+ if (ctx.before !== "" || ctx.after !== "") throw new Error(ctx.messages.beforeAfterOnlyForCompare);
1612
+ if (ctx.id === "") {
1613
+ if (ctx.hasFilters) throw new Error(ctx.messages.filtersNeedId);
1614
+ return sessionListParts({
1615
+ ...ctx,
1616
+ hasFilters: false
1617
+ });
1618
+ }
1619
+ const parts = [
1620
+ ocrCommand(),
1621
+ "session",
1622
+ "comments",
1623
+ "--json",
1624
+ "--repo",
1625
+ shq(ctx.repo)
1626
+ ];
1627
+ if (ctx.severities.length > 0) parts.push("--severity", shq(ctx.severities.join(",")));
1628
+ if (ctx.categories.length > 0) parts.push("--category", shq(ctx.categories.join(",")));
1629
+ parts.push("--", shq(ctx.id));
1630
+ return parts;
1631
+ }
1632
+ function sessionShowParts(ctx) {
1633
+ if (ctx.before !== "" || ctx.after !== "") throw new Error(ctx.messages.beforeAfterOnlyForCompare);
1634
+ if (ctx.hasFilters) throw new Error(ctx.messages.filtersOnlyForComments);
1635
+ if (ctx.id === "") return sessionListParts({
1636
+ ...ctx,
1637
+ hasFilters: false
1638
+ });
1639
+ return [
1640
+ ocrCommand(),
1641
+ "session",
1642
+ "show",
1643
+ "--json",
1644
+ "--repo",
1645
+ shq(ctx.repo),
1646
+ "--",
1647
+ shq(ctx.id)
1648
+ ];
1649
+ }
1650
+ const SESSION_BUILDERS = {
1651
+ list: sessionListParts,
1652
+ compare: sessionCompareParts,
1653
+ export: sessionExportParts,
1654
+ comments: sessionCommentsParts,
1655
+ show: sessionShowParts
1656
+ };
1657
+ function sessionPartsOf(sub, ctx) {
1658
+ return SESSION_BUILDERS[sub](ctx);
1659
+ }
1660
+ function inferSessionAction(requested, ctx) {
1661
+ if (requested !== void 0) return requested;
1662
+ if (ctx.before !== "" || ctx.after !== "") return "compare";
1663
+ return ctx.id === "" ? "list" : "show";
1664
+ }
1665
+ /** 构造 `ocr session list/show/comments/compare/export` 命令(历史评审会话闭环)。 */
1119
1666
  function buildSessionCommand(args, messages) {
1120
1667
  const repo = assertAbsoluteRoot(args.repo, messages);
1121
- const id = idValue(args.id, "id", messages);
1122
1668
  const action = idValue(args.action, "action", messages);
1123
1669
  const requested = SESSION_ACTIONS.find((name) => name === action);
1124
1670
  if (action !== "" && requested === void 0) throw new Error(format(messages.invalidAction, {
1125
1671
  action,
1126
1672
  expected: SESSION_ACTIONS.join("/")
1127
1673
  }));
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));
1674
+ const ctx = {
1675
+ repo,
1676
+ id: idValue(args.id, "id", messages),
1677
+ before: idValue(args.before, "before", messages),
1678
+ after: idValue(args.after, "after", messages),
1679
+ severities: severityFilter(args.severity, messages),
1680
+ categories: commaList(args.category, "category", 50, messages),
1681
+ hasFilters: false,
1682
+ limit: args.limit,
1683
+ output: idValue(args.output, "output", messages),
1684
+ messages
1685
+ };
1686
+ ctx.hasFilters = ctx.severities.length > 0 || ctx.categories.length > 0;
1140
1687
  return {
1141
- command: parts.join(" "),
1688
+ command: sessionPartsOf(inferSessionAction(requested, ctx), ctx).join(" "),
1142
1689
  workdir: repo
1143
1690
  };
1144
1691
  }
1145
- //#endregion
1146
- //#region lib/severity.ts
1692
+ // lib/severity.ts
1147
1693
  const SEVERITY_ORDER = [
1148
1694
  "critical",
1149
1695
  "high",
@@ -1162,7 +1708,7 @@ const SEVERITY_ORDER = [
1162
1708
  * known 一并回传:归一是「必须发生」的降级,但降级本身不该是**无痕**的——调用方要能
1163
1709
  * 统计出「有多少条走了兜底」,否则契约异常被彻底吞掉,聚合里看不出来、排序里也看不出来。
1164
1710
  */
1165
- function normalizeSeverity(severity) {
1711
+ function classifySeverity(severity) {
1166
1712
  const matched = SEVERITY_ORDER.find((level) => level === severity.trim().toLowerCase());
1167
1713
  return {
1168
1714
  level: matched ?? "medium",
@@ -1171,7 +1717,7 @@ function normalizeSeverity(severity) {
1171
1717
  }
1172
1718
  /** 排序位:critical→high→medium→low(未知已归 medium,永不为 -1)。 */
1173
1719
  function severityRank(severity) {
1174
- return SEVERITY_ORDER.indexOf(normalizeSeverity(severity).level);
1720
+ return SEVERITY_ORDER.indexOf(classifySeverity(severity).level);
1175
1721
  }
1176
1722
  /** 聚合评论:按严重度/类别计数。severity 归类以 critical→high→medium→low 基准。 */
1177
1723
  function aggregateComments(comments) {
@@ -1184,10 +1730,11 @@ function aggregateComments(comments) {
1184
1730
  const categoryCounts = /* @__PURE__ */ new Map();
1185
1731
  let unknownSeverityCount = 0;
1186
1732
  for (const comment of comments) {
1187
- const { level, known } = normalizeSeverity(comment.severity);
1733
+ const { level, known } = classifySeverity(comment.severity);
1188
1734
  bySeverity[level] += 1;
1189
1735
  if (!known) unknownSeverityCount += 1;
1190
- if (comment.category) categoryCounts.set(comment.category, (categoryCounts.get(comment.category) ?? 0) + 1);
1736
+ const category = comment.category.trim();
1737
+ if (category !== "") categoryCounts.set(category, (categoryCounts.get(category) ?? 0) + 1);
1191
1738
  }
1192
1739
  const byCategory = Object.fromEntries(categoryCounts);
1193
1740
  return {
@@ -1197,16 +1744,21 @@ function aggregateComments(comments) {
1197
1744
  unknownSeverityCount
1198
1745
  };
1199
1746
  }
1200
- //#endregion
1201
- //#region lib/parse.ts
1747
+ // lib/parse.ts
1748
+ /** 只认十进制整数的行号/计数文本。 */
1749
+ const DECIMAL_INTEGER = /^\d+$/u;
1202
1750
  /**
1203
1751
  * OCR 评审 JSON 的 status 枚举(判据在 CLI 侧 `cmd/opencodereview/output.go` 的 jsonOutput
1204
1752
  * 装配与 `internal/session/manifest.go` 的 TerminalState)。
1205
1753
  * 白名单外的一律视为失败:exit 0 不等于评审成功——`{"error":"auth failed"}`
1206
1754
  * 这类「没有 status 的错误对象」若被当作 ok:true,模型会把「0 条评论」读成
1207
- * 「代码干净」,是最坏的一类假阳性。`failed` 也刻意留在白名单外:CLI 的退出契约是
1208
- * 「全场失败才非零」(`review_cmd.go` 的 reviewResultError),那份文档随非零退出
1209
- * 一起被 shell 侧的失败详情拦下,本解析拿不到它。
1755
+ * 「代码干净」,是最坏的一类假阳性。
1756
+ *
1757
+ * `failed` 在名单里,但**单独判成 ok:false**(见 `parseReviewOutput`)。两件事要分开:
1758
+ * ① 名字得认——非零退出时 CLI 照发的那份 manifest 就带 `status:"failed"`,
1759
+ * `withCoverageDetail` 要靠它读出「哪些文件没审成」;不认这个值就等于把失败明细整条丢掉。
1760
+ * ② 认了不等于成功——万一它带着 exit 0 回来,把它当 ok:true 就会把一份全场失败的回执
1761
+ * 摘要成「0 条评论」,那正是本段开头要避免的假阳性。
1210
1762
  *
1211
1763
  * partial 是 manifest 终态之一:部分文件失败但整体有覆盖、以及 token 预算截断后仍有
1212
1764
  * 覆盖,都以 exit 0 + status "partial" 输出。此前白名单漏了它,整场已花费 token 的
@@ -1240,9 +1792,8 @@ function str(value) {
1240
1792
  * 故作兜底值。
1241
1793
  */
1242
1794
  function nonNegativeInt(value) {
1243
- const truncated = Math.trunc(value);
1244
- if (!Number.isSafeInteger(truncated) || truncated < 0) return 0;
1245
- return truncated;
1795
+ if (!Number.isSafeInteger(value) || value < 0) return 0;
1796
+ return value;
1246
1797
  }
1247
1798
  /** 数值字段解析:number 与「数字字符串」都接受(OCR 部分 provider 实测把 start_line
1248
1799
  * 输出成 "42",一律当 0 会把真实评论判成「未锚定」而被摘要丢掉),空串/NaN/Infinity/
@@ -1251,7 +1802,7 @@ function num(value) {
1251
1802
  if (typeof value === "number") return nonNegativeInt(value);
1252
1803
  if (typeof value === "string") {
1253
1804
  const trimmed = value.trim();
1254
- if (!/^\d+$/u.test(trimmed)) return 0;
1805
+ if (!DECIMAL_INTEGER.test(trimmed)) return 0;
1255
1806
  return nonNegativeInt(Number(trimmed));
1256
1807
  }
1257
1808
  return 0;
@@ -1265,6 +1816,42 @@ const DIAGNOSTIC_DETAIL_LIMIT = 8;
1265
1816
  function boolOf(value) {
1266
1817
  return value === true;
1267
1818
  }
1819
+ /** 走 plan 预算/工具轮次上限的两枚警告:它们是「限流截断」的唯一结构化信号。 */
1820
+ const LIMIT_WARNING_TYPES = /* @__PURE__ */ new Set(["review_limit_reached", "token_budget_reached"]);
1821
+ /** 把 project_summary 截到摘要预算内;截断事实单独带出,别让模型把半截当全文。 */
1822
+ const PROJECT_SUMMARY_LIMIT = 4e3;
1823
+ /** `llm` 身份:两个字段都读不出时回 null,而不是造一个空模型。 */
1824
+ function llmOf(raw) {
1825
+ if (!isRecord(raw)) return null;
1826
+ const provider = str(raw["provider"]).trim();
1827
+ const model = str(raw["model"]).trim();
1828
+ if (provider === "" && model === "") return null;
1829
+ return {
1830
+ provider: provider === "" ? null : provider,
1831
+ model
1832
+ };
1833
+ }
1834
+ /** `resume` 回执:字段全空时回 null(CLI 只在真的续审时发这个对象)。 */
1835
+ function retryOf(raw) {
1836
+ if (!isRecord(raw)) return null;
1837
+ const view = {
1838
+ totalRetries: num(raw["total_retries"]),
1839
+ recoveredRequests: num(raw["recovered_requests"]),
1840
+ failedRequests: num(raw["failed_requests"])
1841
+ };
1842
+ return view.totalRetries === 0 && view.recoveredRequests === 0 && view.failedRequests === 0 ? null : view;
1843
+ }
1844
+ function resumeOf(raw) {
1845
+ if (!isRecord(raw)) return null;
1846
+ const view = {
1847
+ resumedFrom: str(raw["resumed_from"]),
1848
+ reusedFiles: num(raw["reused_files"]),
1849
+ rerunFiles: num(raw["rerun_files"]),
1850
+ previousModel: str(raw["previous_model"]),
1851
+ currentModel: str(raw["current_model"])
1852
+ };
1853
+ return view.resumedFrom === "" && view.reusedFiles === 0 && view.rerunFiles === 0 && view.previousModel === "" && view.currentModel === "" ? null : view;
1854
+ }
1268
1855
  /**
1269
1856
  * 明细行收尾:截断时补一条「另有 N 条已省略」,让读的人知道后面还有,而不是以为看到了全部。
1270
1857
  * @param lines - 已渲染的明细行
@@ -1280,7 +1867,7 @@ function capLines(lines, total, messages) {
1280
1867
  * 否则 "x" 或 -1 会被 num 折成 0,把「读不出」伪装成「零失败」)。 */
1281
1868
  function isCount(value) {
1282
1869
  if (typeof value === "number") return Number.isSafeInteger(value) && value >= 0;
1283
- return typeof value === "string" && /^\d+$/u.test(value.trim());
1870
+ return typeof value === "string" && DECIMAL_INTEGER.test(value.trim());
1284
1871
  }
1285
1872
  /**
1286
1873
  * 解析 `tool_calls`。字段缺失或为 null(这一场没有工具调用记录)返回 null;
@@ -1401,6 +1988,14 @@ function reviewFailure(status, message) {
1401
1988
  skipped: false,
1402
1989
  summary: null,
1403
1990
  budgetExceeded: false,
1991
+ limitReached: false,
1992
+ llm: null,
1993
+ projectSummary: null,
1994
+ projectSummaryTruncated: false,
1995
+ resume: null,
1996
+ groupCount: 0,
1997
+ retry: null,
1998
+ traceId: null,
1404
1999
  comments: [],
1405
2000
  warnings: [],
1406
2001
  toolCalls: null,
@@ -1423,9 +2018,13 @@ function reviewFailure(status, message) {
1423
2018
  */
1424
2019
  function diagnosticsOf(root, messages) {
1425
2020
  const warnings = [];
2021
+ let limitReached = false;
1426
2022
  const warningsRaw = root["warnings"];
1427
2023
  if (warningsRaw !== void 0) {
1428
- if (Array.isArray(warningsRaw)) for (const warn of warningsRaw) warnings.push(warningLineOf(warn, messages));
2024
+ if (Array.isArray(warningsRaw)) for (const warn of warningsRaw) {
2025
+ warnings.push(warningLineOf(warn, messages));
2026
+ limitReached ||= isRecord(warn) && LIMIT_WARNING_TYPES.has(str(warn["type"]));
2027
+ }
1429
2028
  else warnings.push(messages.warningUnreadable);
1430
2029
  }
1431
2030
  const toolCalls = toolCallsOf(root["tool_calls"], messages);
@@ -1435,7 +2034,8 @@ function diagnosticsOf(root, messages) {
1435
2034
  return {
1436
2035
  warnings,
1437
2036
  toolCalls: toolCalls.view,
1438
- coverage: coverage.view
2037
+ coverage: coverage.view,
2038
+ limitReached
1439
2039
  };
1440
2040
  }
1441
2041
  /**
@@ -1469,13 +2069,17 @@ function parseReviewOutput(text, messages) {
1469
2069
  else comments.push(comment);
1470
2070
  }
1471
2071
  const skipped = status === "skipped";
2072
+ const ok = status !== "failed";
2073
+ const message = str(root["message"]) || (ok ? "" : messages.ocrStatusFailed);
1472
2074
  const sumRaw = root["summary"];
1473
2075
  const sum = isRecord(sumRaw) ? sumRaw : void 0;
1474
2076
  const diagnostics = diagnosticsOf(root, messages);
2077
+ const rawProjectSummary = str(root["project_summary"]);
2078
+ const projectSummaryTruncated = rawProjectSummary.length > PROJECT_SUMMARY_LIMIT;
1475
2079
  return {
1476
- ok: true,
2080
+ ok,
1477
2081
  status,
1478
- message: str(root["message"]),
2082
+ message,
1479
2083
  skipped,
1480
2084
  summary: sum ? {
1481
2085
  filesReviewed: num(sum["files_reviewed"]),
@@ -1483,9 +2087,19 @@ function parseReviewOutput(text, messages) {
1483
2087
  totalTokens: num(sum["total_tokens"]),
1484
2088
  inputTokens: num(sum["input_tokens"]),
1485
2089
  outputTokens: num(sum["output_tokens"]),
2090
+ cacheReadTokens: num(sum["cache_read_tokens"]),
2091
+ cacheWriteTokens: num(sum["cache_write_tokens"]),
1486
2092
  elapsed: str(sum["elapsed"])
1487
2093
  } : null,
1488
2094
  budgetExceeded: sum === void 0 ? false : boolOf(sum["budget_exceeded"]),
2095
+ limitReached: diagnostics.limitReached,
2096
+ llm: llmOf(root["llm"]),
2097
+ projectSummary: rawProjectSummary === "" ? null : rawProjectSummary.slice(0, PROJECT_SUMMARY_LIMIT),
2098
+ projectSummaryTruncated,
2099
+ resume: resumeOf(root["resume"]),
2100
+ groupCount: Array.isArray(root["groups"]) ? root["groups"].length : 0,
2101
+ retry: retryOf(root["retry_report"]),
2102
+ traceId: str(root["trace_id"]) || null,
1489
2103
  comments,
1490
2104
  warnings: diagnostics.warnings,
1491
2105
  toolCalls: diagnostics.toolCalls,
@@ -1521,6 +2135,14 @@ function summarizeReview(parsed, messages, plan = DEFAULT_PLAN) {
1521
2135
  message: parsed.message || null,
1522
2136
  summary: parsed.summary ?? null,
1523
2137
  budgetExceeded: parsed.budgetExceeded,
2138
+ limitReached: parsed.limitReached,
2139
+ llm: parsed.llm,
2140
+ projectSummary: parsed.projectSummary,
2141
+ projectSummaryTruncated: parsed.projectSummaryTruncated,
2142
+ resume: parsed.resume,
2143
+ groupCount: parsed.groupCount,
2144
+ retry: parsed.retry,
2145
+ traceId: parsed.traceId,
1524
2146
  sessionId: parsed.sessionId || null,
1525
2147
  aggregation: agg,
1526
2148
  totalCommentCount: parsed.comments.length,
@@ -1677,80 +2299,747 @@ function parseDelegateRules(text) {
1677
2299
  groups
1678
2300
  };
1679
2301
  }
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]";
2302
+ /** 预览列表的条数上限:整仓扫描可能列出几千个文件,摘要只留足以判断范围的一截。 */
2303
+ const PREVIEW_FILE_LIMIT = 200;
2304
+ function previewFileOf(raw) {
2305
+ if (!isRecord(raw)) return null;
2306
+ const filePath = str(raw["path"]);
2307
+ if (filePath === "") return null;
2308
+ return {
2309
+ path: filePath,
2310
+ status: str(raw["status"]),
2311
+ insertions: num(raw["insertions"]),
2312
+ deletions: num(raw["deletions"]),
2313
+ willReview: raw["will_review"] === true,
2314
+ excludeReason: str(raw["exclude_reason"])
2315
+ };
2316
+ }
1706
2317
  /**
1707
- * settings 里的 `api` 字段 → 协议查表用的键。
2318
+ * 解析 `ocr review --preview --format json` / `ocr scan --preview --format json`。
1708
2319
  *
1709
- * 三种形态必须分开,否则畸形声明会被静默降级成「能用的」配置:
1710
- * - 未声明(undefined / null)⇒ "",交给 llm-pi-ai 的 openai-completions 兜底语义;
1711
- * - 字符串 ⇒ trim 后原样(表外值原样透传,由写侧白名单拒绝);
1712
- * - 数字 / 布尔 ⇒ String(),同样落进「表外值」这条正路;
1713
- * - 对象 / 数组 ⇒ 固定标记。用 String(raw) 会走 Object.prototype.toString 得到
1714
- * "[object Object]",那既无信息量又看着像协议名;显式标记更能说明「这里读不出来」。
2320
+ * 预览是 `model.Preview` 的裸对象(键 `files`),与 review/scan 的摘要**不是**同一
2321
+ * 形状;把它喂给 parseReviewOutput 只会得到一条解析失败。契约键一个都不命中、或
2322
+ * 条目全是坏形状时判失败,不能报成「0 个文件可审」。
1715
2323
  */
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;
2324
+ function parsePreviewOutput(text) {
2325
+ let doc;
2326
+ try {
2327
+ doc = JSON.parse(text);
2328
+ } catch {
2329
+ return {
2330
+ ok: false,
2331
+ view: null
2332
+ };
2333
+ }
2334
+ if (!isRecord(doc) || !Array.isArray(doc["files"])) return {
2335
+ ok: false,
2336
+ view: null
2337
+ };
2338
+ const parsed = doc["files"].map((entry) => previewFileOf(entry)).filter((entry) => entry !== null);
2339
+ if (doc["files"].length > 0 && parsed.length === 0) return {
2340
+ ok: false,
2341
+ view: null
2342
+ };
2343
+ const reviewable = parsed.filter((entry) => entry.willReview);
2344
+ const excluded = parsed.filter((entry) => !entry.willReview);
2345
+ const countOf = (key, fallback) => {
2346
+ const raw = doc[key];
2347
+ return isCount(raw) ? num(raw) : fallback;
2348
+ };
2349
+ return {
2350
+ ok: true,
2351
+ view: {
2352
+ totalFiles: countOf("total_files", parsed.length),
2353
+ reviewableCount: countOf("reviewable_count", reviewable.length),
2354
+ excludedCount: countOf("excluded_count", excluded.length),
2355
+ totalInsertions: num(doc["total_insertions"]),
2356
+ totalDeletions: num(doc["total_deletions"]),
2357
+ reviewableFiles: reviewable.slice(0, PREVIEW_FILE_LIMIT),
2358
+ excludedFiles: excluded.slice(0, PREVIEW_FILE_LIMIT),
2359
+ truncated: reviewable.length > PREVIEW_FILE_LIMIT || excluded.length > PREVIEW_FILE_LIMIT
2360
+ }
2361
+ };
1721
2362
  }
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() : "";
2363
+ /** 每个桶的展示上限:四桶全铺开会把 compare 变成第二份全量评论。 */
2364
+ const COMPARE_BUCKET_LIMIT = 20;
2365
+ function compareSideOf(raw) {
1729
2366
  return {
1730
- id,
1731
- name: name === "" ? id : name
2367
+ sessionId: isRecord(raw) ? str(raw["session_id"]) : "",
2368
+ reviewMode: isRecord(raw) ? str(raw["review_mode"]) : "",
2369
+ range: isRecord(raw) ? str(raw["range"]) : ""
1732
2370
  };
1733
2371
  }
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) : [];
2372
+ function compareCommentOf(raw) {
2373
+ const comment = commentOf(raw);
2374
+ if (comment === null) return null;
1744
2375
  return {
1745
- name,
1746
- displayName: typeof entry["displayName"] === "string" && entry["displayName"] ? entry["displayName"] : name,
1747
- apiKeyEnv,
1748
- protocol,
1749
- baseURL,
1750
- models
2376
+ location: locationOf(comment),
2377
+ severity: comment.severity,
2378
+ category: comment.category,
2379
+ content: comment.content
1751
2380
  };
1752
2381
  }
1753
- /**
2382
+ /** 一个 compare 桶:缺失/非数组判整个 compare 失败,部分条目坏只丢该条并计入 dropped。 */
2383
+ function compareBucketOf(raw) {
2384
+ if (!Array.isArray(raw)) return null;
2385
+ return {
2386
+ items: raw.map((entry) => compareCommentOf(entry)).filter((entry) => entry !== null).slice(0, COMPARE_BUCKET_LIMIT),
2387
+ total: raw.length
2388
+ };
2389
+ }
2390
+ /** 解析 `ocr session compare --json`:四个桶与两侧会话标识。 */
2391
+ function parseSessionCompare(text) {
2392
+ let doc;
2393
+ try {
2394
+ doc = JSON.parse(text);
2395
+ } catch {
2396
+ return {
2397
+ ok: false,
2398
+ view: null
2399
+ };
2400
+ }
2401
+ if (!isRecord(doc)) return {
2402
+ ok: false,
2403
+ view: null
2404
+ };
2405
+ const newBucket = compareBucketOf(doc["new"]);
2406
+ const persisting = compareBucketOf(doc["persisting"]);
2407
+ const resolved = compareBucketOf(doc["resolved"]);
2408
+ const notReviewed = compareBucketOf(doc["not_reviewed"]);
2409
+ if (newBucket === null || persisting === null || resolved === null || notReviewed === null) return {
2410
+ ok: false,
2411
+ view: null
2412
+ };
2413
+ const droppedCount = newBucket.total + persisting.total + resolved.total + notReviewed.total - (newBucket.items.length + persisting.items.length + resolved.items.length + notReviewed.items.length);
2414
+ return {
2415
+ ok: true,
2416
+ view: {
2417
+ before: compareSideOf(doc["before"]),
2418
+ after: compareSideOf(doc["after"]),
2419
+ new: newBucket.items,
2420
+ persisting: persisting.items,
2421
+ resolved: resolved.items,
2422
+ notReviewed: notReviewed.items,
2423
+ counts: {
2424
+ new: newBucket.total,
2425
+ persisting: persisting.total,
2426
+ resolved: resolved.total,
2427
+ notReviewed: notReviewed.total
2428
+ },
2429
+ droppedCount
2430
+ }
2431
+ };
2432
+ }
2433
+ // lib/tool-schemas.ts
2434
+ /** 可空 string 字段的 schema 片段(官方子集单类型,null 分支用 oneOf 表达)。 */
2435
+ const NULLABLE_STRING = { oneOf: [{ type: "string" }, { type: "null" }] };
2436
+ /** ocr_review / ocr_scan 的 wait=false 回执(startBackground 的返回对象)。 */
2437
+ const BACKGROUND_RECEIPT_SCHEMA = {
2438
+ type: "object",
2439
+ additionalProperties: false,
2440
+ required: [
2441
+ "started",
2442
+ "mode",
2443
+ "tool",
2444
+ "repo",
2445
+ "processStatus",
2446
+ "jobId",
2447
+ "polling"
2448
+ ],
2449
+ properties: {
2450
+ started: {
2451
+ type: "boolean",
2452
+ const: true
2453
+ },
2454
+ mode: {
2455
+ type: "string",
2456
+ const: "background"
2457
+ },
2458
+ tool: { type: "string" },
2459
+ repo: { type: "string" },
2460
+ processStatus: {
2461
+ type: "string",
2462
+ enum: [
2463
+ "running",
2464
+ "completed",
2465
+ "killed"
2466
+ ]
2467
+ },
2468
+ jobId: { type: "string" },
2469
+ polling: { type: "string" }
2470
+ }
2471
+ };
2472
+ /** summarizeReview(...) 的对象形状(lib/parse.ts 的 ReviewSummaryView,逐字段对齐)。 */
2473
+ const REVIEW_SUMMARY_SCHEMA = {
2474
+ type: "object",
2475
+ additionalProperties: false,
2476
+ required: [
2477
+ "status",
2478
+ "skipped",
2479
+ "message",
2480
+ "summary",
2481
+ "budgetExceeded",
2482
+ "limitReached",
2483
+ "llm",
2484
+ "projectSummary",
2485
+ "projectSummaryTruncated",
2486
+ "resume",
2487
+ "groupCount",
2488
+ "retry",
2489
+ "traceId",
2490
+ "sessionId",
2491
+ "aggregation",
2492
+ "totalCommentCount",
2493
+ "droppedCount",
2494
+ "invalidCommentCount",
2495
+ "topComments",
2496
+ "warnings",
2497
+ "toolCalls",
2498
+ "coverage",
2499
+ "hint"
2500
+ ],
2501
+ properties: {
2502
+ status: {
2503
+ type: "string",
2504
+ enum: [
2505
+ "success",
2506
+ "complete",
2507
+ "partial",
2508
+ "completed_with_warnings",
2509
+ "completed_with_errors",
2510
+ "skipped"
2511
+ ]
2512
+ },
2513
+ skipped: { type: "boolean" },
2514
+ message: NULLABLE_STRING,
2515
+ summary: { oneOf: [{ type: "null" }, {
2516
+ type: "object",
2517
+ additionalProperties: false,
2518
+ required: [
2519
+ "filesReviewed",
2520
+ "comments",
2521
+ "totalTokens",
2522
+ "inputTokens",
2523
+ "outputTokens",
2524
+ "cacheReadTokens",
2525
+ "cacheWriteTokens",
2526
+ "elapsed"
2527
+ ],
2528
+ properties: {
2529
+ filesReviewed: { type: "number" },
2530
+ comments: { type: "number" },
2531
+ totalTokens: { type: "number" },
2532
+ inputTokens: { type: "number" },
2533
+ outputTokens: { type: "number" },
2534
+ cacheReadTokens: { type: "number" },
2535
+ cacheWriteTokens: { type: "number" },
2536
+ elapsed: { type: "string" }
2537
+ }
2538
+ }] },
2539
+ budgetExceeded: { type: "boolean" },
2540
+ limitReached: { type: "boolean" },
2541
+ llm: { oneOf: [{ type: "null" }, {
2542
+ type: "object",
2543
+ additionalProperties: false,
2544
+ required: ["provider", "model"],
2545
+ properties: {
2546
+ provider: NULLABLE_STRING,
2547
+ model: { type: "string" }
2548
+ }
2549
+ }] },
2550
+ projectSummary: NULLABLE_STRING,
2551
+ projectSummaryTruncated: { type: "boolean" },
2552
+ groupCount: { type: "integer" },
2553
+ retry: { oneOf: [{ type: "null" }, {
2554
+ type: "object",
2555
+ additionalProperties: false,
2556
+ required: [
2557
+ "totalRetries",
2558
+ "recoveredRequests",
2559
+ "failedRequests"
2560
+ ],
2561
+ properties: {
2562
+ totalRetries: { type: "number" },
2563
+ recoveredRequests: { type: "number" },
2564
+ failedRequests: { type: "number" }
2565
+ }
2566
+ }] },
2567
+ traceId: NULLABLE_STRING,
2568
+ resume: { oneOf: [{ type: "null" }, {
2569
+ type: "object",
2570
+ additionalProperties: false,
2571
+ required: [
2572
+ "resumedFrom",
2573
+ "reusedFiles",
2574
+ "rerunFiles",
2575
+ "previousModel",
2576
+ "currentModel"
2577
+ ],
2578
+ properties: {
2579
+ resumedFrom: { type: "string" },
2580
+ reusedFiles: { type: "number" },
2581
+ rerunFiles: { type: "number" },
2582
+ previousModel: { type: "string" },
2583
+ currentModel: { type: "string" }
2584
+ }
2585
+ }] },
2586
+ sessionId: NULLABLE_STRING,
2587
+ aggregation: {
2588
+ type: "object",
2589
+ additionalProperties: false,
2590
+ required: [
2591
+ "total",
2592
+ "bySeverity",
2593
+ "byCategory",
2594
+ "unknownSeverityCount"
2595
+ ],
2596
+ properties: {
2597
+ total: { type: "integer" },
2598
+ unknownSeverityCount: { type: "integer" },
2599
+ bySeverity: {
2600
+ type: "object",
2601
+ additionalProperties: false,
2602
+ required: [
2603
+ "critical",
2604
+ "high",
2605
+ "medium",
2606
+ "low"
2607
+ ],
2608
+ properties: {
2609
+ critical: { type: "integer" },
2610
+ high: { type: "integer" },
2611
+ medium: { type: "integer" },
2612
+ low: { type: "integer" }
2613
+ }
2614
+ },
2615
+ byCategory: { type: "object" }
2616
+ }
2617
+ },
2618
+ totalCommentCount: { type: "integer" },
2619
+ droppedCount: { type: "integer" },
2620
+ invalidCommentCount: { type: "integer" },
2621
+ topComments: {
2622
+ type: "array",
2623
+ items: {
2624
+ type: "object",
2625
+ additionalProperties: false,
2626
+ required: [
2627
+ "location",
2628
+ "severity",
2629
+ "category",
2630
+ "content",
2631
+ "suggestion"
2632
+ ],
2633
+ properties: {
2634
+ location: { type: "string" },
2635
+ severity: { type: "string" },
2636
+ category: { type: "string" },
2637
+ content: { type: "string" },
2638
+ suggestion: NULLABLE_STRING
2639
+ }
2640
+ }
2641
+ },
2642
+ warnings: {
2643
+ type: "array",
2644
+ items: { type: "string" }
2645
+ },
2646
+ toolCalls: { oneOf: [{ type: "null" }, {
2647
+ type: "object",
2648
+ additionalProperties: false,
2649
+ required: [
2650
+ "total",
2651
+ "failure",
2652
+ "failures"
2653
+ ],
2654
+ properties: {
2655
+ total: { type: "integer" },
2656
+ failure: { type: "integer" },
2657
+ failures: {
2658
+ type: "array",
2659
+ items: { type: "string" }
2660
+ }
2661
+ }
2662
+ }] },
2663
+ coverage: { oneOf: [{ type: "null" }, {
2664
+ type: "object",
2665
+ additionalProperties: false,
2666
+ required: [
2667
+ "selected",
2668
+ "completed",
2669
+ "reused",
2670
+ "failed",
2671
+ "waived",
2672
+ "failedItems"
2673
+ ],
2674
+ properties: {
2675
+ selected: { type: "integer" },
2676
+ completed: { type: "integer" },
2677
+ reused: { type: "integer" },
2678
+ failed: { type: "integer" },
2679
+ waived: { type: "integer" },
2680
+ failedItems: {
2681
+ type: "array",
2682
+ items: { type: "string" }
2683
+ }
2684
+ }
2685
+ }] },
2686
+ hint: { type: "string" }
2687
+ }
2688
+ };
2689
+ /** review/scan `preview:true` 的文件清单对象(lib/parse.ts 的 PreviewView)。 */
2690
+ const PREVIEW_FILE_SCHEMA = {
2691
+ type: "object",
2692
+ additionalProperties: false,
2693
+ required: [
2694
+ "path",
2695
+ "status",
2696
+ "insertions",
2697
+ "deletions",
2698
+ "willReview",
2699
+ "excludeReason"
2700
+ ],
2701
+ properties: {
2702
+ path: { type: "string" },
2703
+ status: { type: "string" },
2704
+ insertions: { type: "number" },
2705
+ deletions: { type: "number" },
2706
+ willReview: { type: "boolean" },
2707
+ excludeReason: { type: "string" }
2708
+ }
2709
+ };
2710
+ /** ocr_review / ocr_scan 的输出:wait=false = 后台回执对象;前台成功 = 摘要对象;
2711
+ * preview=true = 文件预览对象;前台解析失败 = 一段诚实的说明字符串。四者恰一。 */
2712
+ const REVIEW_OUTPUT_SCHEMA = { oneOf: [
2713
+ BACKGROUND_RECEIPT_SCHEMA,
2714
+ REVIEW_SUMMARY_SCHEMA,
2715
+ {
2716
+ type: "object",
2717
+ additionalProperties: false,
2718
+ required: [
2719
+ "totalFiles",
2720
+ "reviewableCount",
2721
+ "excludedCount",
2722
+ "totalInsertions",
2723
+ "totalDeletions",
2724
+ "reviewableFiles",
2725
+ "excludedFiles",
2726
+ "truncated",
2727
+ "hint"
2728
+ ],
2729
+ properties: {
2730
+ totalFiles: { type: "number" },
2731
+ reviewableCount: { type: "number" },
2732
+ excludedCount: { type: "number" },
2733
+ totalInsertions: { type: "number" },
2734
+ totalDeletions: { type: "number" },
2735
+ reviewableFiles: {
2736
+ type: "array",
2737
+ items: PREVIEW_FILE_SCHEMA
2738
+ },
2739
+ excludedFiles: {
2740
+ type: "array",
2741
+ items: PREVIEW_FILE_SCHEMA
2742
+ },
2743
+ truncated: { type: "boolean" },
2744
+ hint: { type: "string" }
2745
+ }
2746
+ },
2747
+ { type: "string" }
2748
+ ] };
2749
+ /** ocr_delegate_preview:成功 = 清单对象;解析失败 = 说明字符串。 */
2750
+ const PREVIEW_OUTPUT_SCHEMA = { oneOf: [{
2751
+ type: "object",
2752
+ additionalProperties: false,
2753
+ required: [
2754
+ "mode",
2755
+ "repository",
2756
+ "mergeBase",
2757
+ "reviewableCount",
2758
+ "insertions",
2759
+ "deletions",
2760
+ "reviewableFiles",
2761
+ "excludedFiles",
2762
+ "hint"
2763
+ ],
2764
+ properties: {
2765
+ mode: { type: "string" },
2766
+ repository: { type: "string" },
2767
+ mergeBase: NULLABLE_STRING,
2768
+ reviewableCount: { type: "integer" },
2769
+ insertions: { type: "number" },
2770
+ deletions: { type: "number" },
2771
+ reviewableFiles: {
2772
+ type: "array",
2773
+ items: {
2774
+ type: "object",
2775
+ additionalProperties: false,
2776
+ required: [
2777
+ "path",
2778
+ "status",
2779
+ "insertions",
2780
+ "deletions"
2781
+ ],
2782
+ properties: {
2783
+ path: { type: "string" },
2784
+ status: { type: "string" },
2785
+ insertions: { type: "number" },
2786
+ deletions: { type: "number" }
2787
+ }
2788
+ }
2789
+ },
2790
+ excludedFiles: {
2791
+ type: "array",
2792
+ items: {
2793
+ type: "object",
2794
+ additionalProperties: false,
2795
+ required: ["path", "reason"],
2796
+ properties: {
2797
+ path: { type: "string" },
2798
+ reason: { type: "string" }
2799
+ }
2800
+ }
2801
+ },
2802
+ hint: { type: "string" }
2803
+ }
2804
+ }, { type: "string" }] };
2805
+ /** ocr_delegate_rule:成功 = 分组对象;解析失败 = 说明字符串。 */
2806
+ const RULES_OUTPUT_SCHEMA = { oneOf: [{
2807
+ type: "object",
2808
+ additionalProperties: false,
2809
+ required: [
2810
+ "groupCount",
2811
+ "groups",
2812
+ "hint"
2813
+ ],
2814
+ properties: {
2815
+ groupCount: { type: "integer" },
2816
+ groups: {
2817
+ type: "array",
2818
+ items: {
2819
+ type: "object",
2820
+ additionalProperties: false,
2821
+ required: [
2822
+ "source",
2823
+ "pattern",
2824
+ "files",
2825
+ "rule"
2826
+ ],
2827
+ properties: {
2828
+ source: { type: "string" },
2829
+ pattern: { type: "string" },
2830
+ files: {
2831
+ type: "array",
2832
+ items: { type: "string" }
2833
+ },
2834
+ rule: { type: "string" }
2835
+ }
2836
+ }
2837
+ },
2838
+ hint: { type: "string" }
2839
+ }
2840
+ }, { type: "string" }] };
2841
+ /** ocr_session:sessionOutput 本身就是一手透传的字符串(诚实的值),外层仍是对象
2842
+ * 好让 hint 有处安放。 */
2843
+ const SESSION_RAW_SCHEMA = {
2844
+ type: "object",
2845
+ additionalProperties: false,
2846
+ required: ["sessionOutput", "hint"],
2847
+ properties: {
2848
+ sessionOutput: { type: "string" },
2849
+ hint: { type: "string" }
2850
+ }
2851
+ };
2852
+ /** compare 的结构化四桶(lib/parse.ts 的 CompareView)。 */
2853
+ const COMPARE_COMMENT_SCHEMA = {
2854
+ type: "object",
2855
+ additionalProperties: false,
2856
+ required: [
2857
+ "location",
2858
+ "severity",
2859
+ "category",
2860
+ "content"
2861
+ ],
2862
+ properties: {
2863
+ location: { type: "string" },
2864
+ severity: { type: "string" },
2865
+ category: { type: "string" },
2866
+ content: { type: "string" }
2867
+ }
2868
+ };
2869
+ const COMPARE_SIDE_SCHEMA = {
2870
+ type: "object",
2871
+ additionalProperties: false,
2872
+ required: [
2873
+ "sessionId",
2874
+ "reviewMode",
2875
+ "range"
2876
+ ],
2877
+ properties: {
2878
+ sessionId: { type: "string" },
2879
+ reviewMode: { type: "string" },
2880
+ range: { type: "string" }
2881
+ }
2882
+ };
2883
+ const SESSION_OUTPUT_SCHEMA = { oneOf: [SESSION_RAW_SCHEMA, {
2884
+ type: "object",
2885
+ additionalProperties: false,
2886
+ required: [
2887
+ "before",
2888
+ "after",
2889
+ "new",
2890
+ "persisting",
2891
+ "resolved",
2892
+ "notReviewed",
2893
+ "counts",
2894
+ "droppedCount",
2895
+ "hint"
2896
+ ],
2897
+ properties: {
2898
+ before: COMPARE_SIDE_SCHEMA,
2899
+ after: COMPARE_SIDE_SCHEMA,
2900
+ new: {
2901
+ type: "array",
2902
+ items: COMPARE_COMMENT_SCHEMA
2903
+ },
2904
+ persisting: {
2905
+ type: "array",
2906
+ items: COMPARE_COMMENT_SCHEMA
2907
+ },
2908
+ resolved: {
2909
+ type: "array",
2910
+ items: COMPARE_COMMENT_SCHEMA
2911
+ },
2912
+ notReviewed: {
2913
+ type: "array",
2914
+ items: COMPARE_COMMENT_SCHEMA
2915
+ },
2916
+ counts: {
2917
+ type: "object",
2918
+ additionalProperties: false,
2919
+ required: [
2920
+ "new",
2921
+ "persisting",
2922
+ "resolved",
2923
+ "notReviewed"
2924
+ ],
2925
+ properties: {
2926
+ new: { type: "integer" },
2927
+ persisting: { type: "integer" },
2928
+ resolved: { type: "integer" },
2929
+ notReviewed: { type: "integer" }
2930
+ }
2931
+ },
2932
+ droppedCount: { type: "integer" },
2933
+ hint: { type: "string" }
2934
+ }
2935
+ }] };
2936
+ /** rules check / llm 这类只读文本诊断工具:一手输出 + hint。 */
2937
+ const TEXT_OUTPUT_SCHEMA = {
2938
+ type: "object",
2939
+ additionalProperties: false,
2940
+ required: ["output", "hint"],
2941
+ properties: {
2942
+ output: { type: "string" },
2943
+ hint: { type: "string" }
2944
+ }
2945
+ };
2946
+ // lib/routing.ts
2947
+ const ROUTING_NAME = "ocr-review-routing";
2948
+ const ROUTING_ORDER = 1555;
2949
+ // lib/provider-projection.ts
2950
+ /** llm-pi-ai 命名空间里 provider 表的字段名。 */
2951
+ const PROVIDERS_FIELD = "providers";
2952
+ /** provider 的 baseURL 只认 http(s)。 */
2953
+ const HTTP_URL$1 = /^https?:\/\//iu;
2954
+ /**
2955
+ * llm-pi-ai 的 `api` 协议 → OCR config 的 `protocol` 映射。
2956
+ *
2957
+ * 两侧枚举的对照(dsh packages/llm/llm-pi-ai/src/provider.ts:44-48 的 PROTOCOLS
2958
+ * 对 OCR config_cmd.go:796 的 "Protocol values: anthropic, anthropic-bedrock,
2959
+ * openai, openai-responses"):openai-completions 与 OCR 的 openai 是同一条
2960
+ * OpenAI Chat Completions 线协议;anthropic-messages 即 OCR 的 anthropic。
2961
+ * 此前「非 openai-responses 一律 openai」的二值映射会把 anthropic-messages 的
2962
+ * 端点按 OpenAI 线协议打出去——select 成功、OCR 调不通。表外值透传,让
2963
+ * renderSelectedConfig 的白名单给出可读错误。
2964
+ */
2965
+ /** Map 而非对象字面量:查表键来自用户可写的 settings 值,对象会继承 Object.prototype,
2966
+ * 于是 api="constructor" 取到 Object 这个构造函数、"__proto__" 取到 Object.prototype,
2967
+ * `?? api` 的原样透传兜底失效——protocol 被写成函数/对象,直接违反 `DshProvider.protocol
2968
+ * 的 string 声明,还会让 apply 侧的白名单错误文案里出现 "function toString() { [native
2969
+ * code] }"。Map.get 不查原型链,这两类键安全 miss 并落回「原样透传」这条正路。
2970
+ * provider 名那一侧的同款防护见文件下方的 UNUSABLE_PROVIDER_NAMES。 */
2971
+ const OCR_PROTOCOL_BY_API = /* @__PURE__ */ new Map([
2972
+ ["openai-completions", "openai"],
2973
+ ["openai-responses", "openai-responses"],
2974
+ ["anthropic-messages", "anthropic"]
2975
+ ]);
2976
+ /** 不能落进 config.json `custom_providers.<name>` 的 provider 名:`__proto__` 在普通对象
2977
+ * 上是 setter(赋值被静默忽略、写入凭空丢失),`constructor` / `prototype` 是继承属性
2978
+ * 名,与写侧的普通对象赋值组合都不是普通自有键。空名/全空白名单独由 trim 判据拦。 */
2979
+ const UNUSABLE_PROVIDER_NAMES = /* @__PURE__ */ new Set([
2980
+ "__proto__",
2981
+ "constructor",
2982
+ "prototype"
2983
+ ]);
2984
+ /** 1.13.3 的 `validateProviderName` 口径:点号分段不能为空或全空白。 */
2985
+ function isUsableProviderName(name) {
2986
+ if (UNUSABLE_PROVIDER_NAMES.has(name)) return false;
2987
+ return name.split(".").every((segment) => segment.trim() !== "");
2988
+ }
2989
+ /** 对象/数组形态的 `api` 的透传标记:可读,但绝不会被误认成任何合法协议名。 */
2990
+ const API_UNREADABLE = "[unreadable]";
2991
+ /**
2992
+ * settings 里的 `api` 字段 → 协议查表用的键;「未声明」以 null 表达。
2993
+ *
2994
+ * 四种形态必须分开,否则畸形声明会被静默降级成「能用的」配置:
2995
+ * - 未声明(undefined / null)⇒ null,交给 llm-pi-ai 的 openai-completions 兜底语义;
2996
+ * - 字符串 ⇒ trim 后原样,**含空串**(表外值原样透传,由写侧白名单拒绝——显式
2997
+ * `api: ""` 在上游同样不走 `??` 默认,兜底它等于把畸形声明改写成可用配置);
2998
+ * - 数字 / 布尔 ⇒ String(),同样落进「表外值」这条正路;
2999
+ * - 对象 / 数组 ⇒ 固定标记。用 String(raw) 会走 Object.prototype.toString 得到
3000
+ * "[object Object]",那既无信息量又看着像协议名;显式标记更能说明「这里读不出来」。
3001
+ */
3002
+ function apiKeyOf(raw) {
3003
+ if (raw === void 0 || raw === null) return null;
3004
+ if (typeof raw === "string") return raw.trim();
3005
+ if (typeof raw === "number" || typeof raw === "boolean") return String(raw);
3006
+ return API_UNREADABLE;
3007
+ }
3008
+ /** 原始 model 对象 → DshModel(缺 id 视为无效,返回 null)。 */
3009
+ function modelFromRaw(modelRaw) {
3010
+ if (!isRecord(modelRaw)) return null;
3011
+ const rec = modelRaw;
3012
+ const id = typeof rec["id"] === "string" ? rec["id"].trim() : "";
3013
+ if (!id) return null;
3014
+ const name = typeof rec["name"] === "string" ? rec["name"].trim() : "";
3015
+ return {
3016
+ id,
3017
+ name: name === "" ? id : name
3018
+ };
3019
+ }
3020
+ /** 原始 provider 对象 → DshProvider(缺 baseURL 或形状不合视为无效,返回 null)。 */
3021
+ function providerFromRaw(name, raw) {
3022
+ if (!isRecord(raw)) return null;
3023
+ const entry = raw;
3024
+ const apiKeyEnv = typeof entry["apiKeyEnv"] === "string" && entry["apiKeyEnv"].trim() ? entry["apiKeyEnv"].trim() : null;
3025
+ const api = apiKeyOf(entry["api"]);
3026
+ const protocol = api === null ? "openai" : OCR_PROTOCOL_BY_API.get(api) ?? api;
3027
+ const baseURL = typeof entry["baseURL"] === "string" ? entry["baseURL"].trim() : "";
3028
+ if (!baseURL || !HTTP_URL$1.test(baseURL)) return null;
3029
+ const modelsRaw = Array.isArray(entry["models"]) ? entry["models"] : [];
3030
+ const models = modelsRaw.map((model) => modelFromRaw(model)).filter((model) => model !== null);
3031
+ if (modelsRaw.length > 0 && models.length === 0) return null;
3032
+ const displayName = typeof entry["displayName"] === "string" ? entry["displayName"].trim() : "";
3033
+ return {
3034
+ name,
3035
+ displayName: displayName === "" ? name : displayName,
3036
+ apiKeyEnv,
3037
+ protocol,
3038
+ baseURL,
3039
+ models
3040
+ };
3041
+ }
3042
+ /**
1754
3043
  * settings 命名空间解析值 → provider 列表(纯投影,不含任何 key 值)。
1755
3044
  * 上游形状(settings 里 llm-pi-ai.providers.<name>):
1756
3045
  * { displayName?, apiKeyEnv?, api: openai-completions|openai-responses|anthropic-messages,
@@ -1762,13 +3051,12 @@ function providersFromSettingsValue(value) {
1762
3051
  if (!isRecord(providers)) return [];
1763
3052
  const out = [];
1764
3053
  for (const [name, raw] of Object.entries(providers)) {
1765
- const parsed = providerFromRaw(name, raw);
3054
+ const parsed = isUsableProviderName(name) ? providerFromRaw(name, raw) : null;
1766
3055
  if (parsed !== null) out.push(parsed);
1767
3056
  }
1768
3057
  return out;
1769
3058
  }
1770
- //#endregion
1771
- //#region lib/provider-source.ts
3059
+ // lib/provider-source.ts
1772
3060
  /** provider 清单所在的官方 settings 命名空间(base bundle 的 pi-ai 适配器注册)。 */
1773
3061
  const PI_AI_NAMESPACE = "llm-pi-ai";
1774
3062
  const SOURCE_READY = Object.freeze({
@@ -1813,7 +3101,19 @@ function readProviders(gateway, messages) {
1813
3101
  }
1814
3102
  };
1815
3103
  }
1816
- const hit = descriptors.find((descriptor) => descriptor.ns === PI_AI_NAMESPACE);
3104
+ let hit;
3105
+ try {
3106
+ if (!Array.isArray(descriptors)) throw new TypeError(`ctx.settings.describe() 没有给出数组(收到 ${typeof descriptors})`);
3107
+ hit = descriptors.find((descriptor) => descriptor.ns === PI_AI_NAMESPACE);
3108
+ } catch (error) {
3109
+ return {
3110
+ providers: [],
3111
+ source: {
3112
+ status: SOURCE_SETTINGS_FAILED,
3113
+ message: format(messages.sourceSettingsFailed, { message: errorText(error) })
3114
+ }
3115
+ };
3116
+ }
1817
3117
  if (hit === void 0) return {
1818
3118
  providers: [],
1819
3119
  source: {
@@ -1821,7 +3121,8 @@ function readProviders(gateway, messages) {
1821
3121
  message: format(messages.sourceNamespaceUnavailable, { ns: PI_AI_NAMESPACE })
1822
3122
  }
1823
3123
  };
1824
- if (!isRecord(fieldOf(hit.value, "providers"))) return {
3124
+ const providersValue = fieldOf(hit.value, "providers");
3125
+ if (!isRecord(providersValue)) return {
1825
3126
  providers: [],
1826
3127
  source: {
1827
3128
  status: "namespace-unavailable",
@@ -1860,17 +3161,26 @@ async function markKeyFlags(gateway, providers) {
1860
3161
  ...providerCfg,
1861
3162
  hasKey: false
1862
3163
  };
1863
- const hasKey = await gateway.credentialConfigured(ref).catch(() => false);
3164
+ let hasKey;
3165
+ try {
3166
+ hasKey = await gateway.credentialConfigured(ref);
3167
+ } catch {
3168
+ hasKey = false;
3169
+ }
1864
3170
  return {
1865
3171
  ...providerCfg,
1866
3172
  hasKey
1867
3173
  };
1868
3174
  }));
1869
3175
  }
1870
- //#endregion
1871
- //#region lib/config-store.ts
3176
+ // lib/config-store.ts
1872
3177
  /** api_key_cmd 脚本名(pluginDir 兜底路径的唯一拼法来源)。 */
1873
3178
  const GET_CRED_SCRIPT = path.join("scripts", "get-cred.mjs");
3179
+ /** 尾部反斜杠 / 翻倍反斜杠 / env 键 / 行首 node 命令。 */
3180
+ const TRAILING_BACKSLASHES = /\\+$/u;
3181
+ const DOUBLED_BACKSLASHES = /(?:\\\\)+$/u;
3182
+ const ENV_KEY = /^[A-Z0-9_]+$/u;
3183
+ const LEADING_NODE_CMD = /^node[ \t]+/u;
1874
3184
  /** 未知值 → 安全字符串(非字符串给 '')。 */
1875
3185
  function strOf(value) {
1876
3186
  return typeof value === "string" ? value : "";
@@ -1899,7 +3209,12 @@ function loadOcrConfig(configJsonText) {
1899
3209
  raw: doc
1900
3210
  };
1901
3211
  }
1902
- /** config.json 的当前文本(读不到/不存在一律空串,交由 loadOcrConfig 的宽容读侧处理)。 */
3212
+ /** 读侧专用:config.json 的当前文本,读不到一律空串。
3213
+ *
3214
+ * 只给「读侧要宽容」的那一类调用用(设置卡打开时渲染当前选择:盘上文件坏了也必须
3215
+ * 能打开卡片,用户正是靠卡片去修)。**写侧不能用它**——把 EACCES/EIO 折成空串等于
3216
+ * 把「读不到」当成「文件不存在」,随后整份覆盖就丢掉了用户既有的全部配置;写侧走
3217
+ * readExistingText。 */
1903
3218
  function readText(configPath) {
1904
3219
  try {
1905
3220
  return readFileSync(configPath, "utf8");
@@ -1907,6 +3222,27 @@ function readText(configPath) {
1907
3222
  return "";
1908
3223
  }
1909
3224
  }
3225
+ /** 文件不存在是正常态(首写、被用户删掉),其余读失败不是。 */
3226
+ function isNotFound$1(error) {
3227
+ return isRecord(error) && fieldOf(error, "code") === "ENOENT";
3228
+ }
3229
+ /** 写侧专用:把「文件不存在 / 内容为空」与「文件读不动」分开。
3230
+ *
3231
+ * 前者返回空串,`parseExistingConfig` 依约给出 `{}`,写出来的就是只含本次选择的
3232
+ * 新文档;后者必须抛错——这一层往下是「读 → 改 → 整份写回」,把读不到当成空文档
3233
+ * 会让一次瞬时 EIO 变成用户全部 providers / keys / mcp_servers / language 被覆盖,
3234
+ * 而备份(`.bak`)也救不了第二次。 */
3235
+ function readExistingText(configPath, messages) {
3236
+ try {
3237
+ return readFileSync(configPath, "utf8");
3238
+ } catch (error) {
3239
+ if (isNotFound$1(error)) return "";
3240
+ throw new Error(format(messages.configUnreadable, {
3241
+ where: configPath,
3242
+ cause: errorText(error)
3243
+ }), { cause: error });
3244
+ }
3245
+ }
1910
3246
  /**
1911
3247
  * api_key_cmd 内嵌值的转义。POSIX 走单引号(ocr 在 Unix 用 `sh -c` 跑 api_key_cmd,
1912
3248
  * keycmd_unix.go);Windows 的 ocr 用 **cmd.exe** 跑它(keycmd_windows.go),单引号
@@ -1917,7 +3253,7 @@ function readText(configPath) {
1917
3253
  * 保证常规路径形态正确)。
1918
3254
  */
1919
3255
  function shqDyn(value) {
1920
- if (process.platform === "win32") return `"${value.replaceAll("\"", "\"\"").replace(/\\+$/u, (backslashes) => backslashes + backslashes)}"`;
3256
+ if (process.platform === "win32") return `"${value.replaceAll("\"", "\"\"").replace(TRAILING_BACKSLASHES, (backslashes) => backslashes + backslashes)}"`;
1921
3257
  return `'${value.replaceAll("'", String.raw`'\''`)}'`;
1922
3258
  }
1923
3259
  /** api_key_cmd 值构造:脚本路径与 env key 名(及可选的 dsh 数据目录)shq 转义后
@@ -1928,7 +3264,7 @@ function shqDyn(value) {
1928
3264
  * env key 白名单维持 ^[A-Z0-9_]+$(刻意比宿主 CredentialRef 的语法紧:OCR 侧
1929
3265
  * 惯例大写,且这是写进 config.json 的可信输入)。 */
1930
3266
  function buildApiKeyCmd(scriptPath, envKey, dshHome, messages) {
1931
- if (!/^[A-Z0-9_]+$/u.test(envKey)) throw new Error(format(messages.badEnvKey, { key: envKey }));
3267
+ if (!ENV_KEY.test(envKey)) throw new Error(format(messages.badEnvKey, { key: envKey }));
1932
3268
  const parts = [
1933
3269
  "node",
1934
3270
  shqDyn(scriptPath),
@@ -2008,7 +3344,7 @@ function readDqToken(body, start) {
2008
3344
  }
2009
3345
  if (!closed) return null;
2010
3346
  return {
2011
- value: value.replace(/(?:\\\\)+$/u, (backslashes) => "\\".repeat(backslashes.length / 2)),
3347
+ value: value.replace(DOUBLED_BACKSLASHES, (backslashes) => "\\".repeat(backslashes.length / 2)),
2012
3348
  next: i
2013
3349
  };
2014
3350
  }
@@ -2050,13 +3386,13 @@ function readDqTokens(body) {
2050
3386
  * 把用户手写的凭据取用方式弄坏,而那正是本包明令不该插手的部分。
2051
3387
  */
2052
3388
  function parseApiKeyCmd(command) {
2053
- const body = command.replace(/^node[ \t]+/u, "");
3389
+ const body = command.replace(LEADING_NODE_CMD, "");
2054
3390
  if (body === command) return null;
2055
3391
  const tokens = readShqTokens(body) ?? readDqTokens(body);
2056
3392
  if (tokens === null) return null;
2057
3393
  const script = tokens[0] ?? "";
2058
3394
  const envKey = tokens[1] ?? "";
2059
- if (script === "" || tokens.length < 2 || tokens.length > 3 || !/^[A-Z0-9_]+$/u.test(envKey)) return null;
3395
+ if (script === "" || tokens.length < 2 || tokens.length > 3 || !ENV_KEY.test(envKey)) return null;
2060
3396
  return {
2061
3397
  script,
2062
3398
  envKey,
@@ -2230,8 +3566,7 @@ function repointDanglingKeyScripts(doc, current, messages) {
2230
3566
  return [name];
2231
3567
  }) };
2232
3568
  }
2233
- //#endregion
2234
- //#region lib/ocr-config.ts
3569
+ // lib/ocr-config.ts
2235
3570
  /** 外部 ocr CLI 的默认配置目录/文件名(宿主服务管不到,只能由本包直读直写)。 */
2236
3571
  const OCR_CONFIG_DIR = ".opencodereview";
2237
3572
  const OCR_CONFIG_FILE = "config.json";
@@ -2352,7 +3687,7 @@ async function applyProviderSelection(gateway, paths, selection, messages) {
2352
3687
  const apiKeyCmd = buildApiKeyCmd(scriptPath, target.apiKeyEnv, resolveDshHome(), messages);
2353
3688
  await updateConfigLocked(paths.ocrConfigJson, async () => {
2354
3689
  const next = renderSelectedConfig({
2355
- existingText: readText(paths.ocrConfigJson),
3690
+ existingText: readExistingText(paths.ocrConfigJson, messages),
2356
3691
  configLabel: paths.ocrConfigJson,
2357
3692
  providers,
2358
3693
  provider,
@@ -2396,7 +3731,7 @@ function migrateEntry(entry, envKey, context) {
2396
3731
  * 返回 { migrated: string[]; skippedUnknown: string[] }。
2397
3732
  */
2398
3733
  async function migratePlaintextKeys(gateway, paths, messages) {
2399
- const precheck = readText(paths.ocrConfigJson);
3734
+ const precheck = readExistingText(paths.ocrConfigJson, messages);
2400
3735
  if (!precheck.trim()) return {
2401
3736
  migrated: [],
2402
3737
  skippedUnknown: []
@@ -2423,7 +3758,7 @@ async function migratePlaintextKeys(gateway, paths, messages) {
2423
3758
  messages
2424
3759
  };
2425
3760
  return await updateConfigLocked(paths.ocrConfigJson, async () => {
2426
- const doc = parseExistingConfig(readText(paths.ocrConfigJson), paths.ocrConfigJson, messages);
3761
+ const doc = parseExistingConfig(readExistingText(paths.ocrConfigJson, messages), paths.ocrConfigJson, messages);
2427
3762
  const customsRaw = fieldOf(doc, "custom_providers");
2428
3763
  if (!isRecord(customsRaw)) return {
2429
3764
  migrated: [],
@@ -2471,34 +3806,301 @@ async function ensureKeyScriptPointer(paths, messages) {
2471
3806
  script: scriptPath,
2472
3807
  dshHome: resolveDshHome()
2473
3808
  };
2474
- if (readText(paths.ocrConfigJson).trim() === "") return {
3809
+ let text;
3810
+ try {
3811
+ text = readExistingText(paths.ocrConfigJson, messages);
3812
+ } catch {
3813
+ return {
3814
+ repointed: [],
3815
+ checked: false
3816
+ };
3817
+ }
3818
+ if (text.trim() === "") return {
2475
3819
  repointed: [],
2476
3820
  checked: true
2477
3821
  };
2478
3822
  try {
2479
3823
  return {
2480
- repointed: await updateConfigLocked(paths.ocrConfigJson, async () => {
2481
- const doc = parseExistingConfig(readText(paths.ocrConfigJson), paths.ocrConfigJson, messages);
2482
- const result = repointDanglingKeyScripts(doc, current, messages);
2483
- if (result.repointed.length === 0) return result.repointed;
2484
- await writeConfigAtomic(paths.ocrConfigJson, renderConfigDoc(doc));
2485
- return result.repointed;
2486
- }),
2487
- checked: true
3824
+ repointed: await updateConfigLocked(paths.ocrConfigJson, async () => {
3825
+ const doc = parseExistingConfig(readExistingText(paths.ocrConfigJson, messages), paths.ocrConfigJson, messages);
3826
+ const result = repointDanglingKeyScripts(doc, current, messages);
3827
+ if (result.repointed.length === 0) return result.repointed;
3828
+ await writeConfigAtomic(paths.ocrConfigJson, renderConfigDoc(doc));
3829
+ return result.repointed;
3830
+ }),
3831
+ checked: true
3832
+ };
3833
+ } catch {
3834
+ return {
3835
+ repointed: [],
3836
+ checked: false
3837
+ };
3838
+ }
3839
+ }
3840
+ // lib/remote-shape.ts
3841
+ /** 宿主服务键 = 客户端线上命名空间(两侧共用的唯一来源)。 */
3842
+ const OCR_REMOTE_NAMESPACE = "ocrReview";
3843
+ // lib/mcp-config.ts
3844
+ /** 整体上限:MCP 配置是一份人工维护的小表,不承载大文件。 */
3845
+ const MCP_MAX_BYTES = 65536;
3846
+ /** remote MCP server 的 url 只认 http(s)。 */
3847
+ const HTTP_URL = /^https?:\/\//iu;
3848
+ const MCP_MAX_SERVERS = 100;
3849
+ const MCP_MAX_ARRAY = 100;
3850
+ const MCP_MAX_TEXT = 4096;
3851
+ const KNOWN_FIELDS = /* @__PURE__ */ new Set([
3852
+ "type",
3853
+ "command",
3854
+ "args",
3855
+ "url",
3856
+ "headers",
3857
+ "tools",
3858
+ "setup",
3859
+ "env"
3860
+ ]);
3861
+ function failure(name, field, reason, messages) {
3862
+ return {
3863
+ ok: false,
3864
+ error: format(messages.mcpServerInvalid, {
3865
+ name,
3866
+ field,
3867
+ reason
3868
+ })
3869
+ };
3870
+ }
3871
+ function textField(value, name, field, messages) {
3872
+ if (typeof value !== "string" || value.trim() === "") return failure(name, field, "must be a non-empty string", messages);
3873
+ const trimmed = value.trim();
3874
+ if (trimmed.length > MCP_MAX_TEXT) return failure(name, field, `must be at most ${MCP_MAX_TEXT} characters`, messages);
3875
+ return {
3876
+ ok: true,
3877
+ value: trimmed
3878
+ };
3879
+ }
3880
+ function stringArray(value, name, field, messages) {
3881
+ 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);
3882
+ const out = [];
3883
+ for (const item of value) {
3884
+ 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);
3885
+ out.push(item);
3886
+ }
3887
+ return {
3888
+ ok: true,
3889
+ value: out
3890
+ };
3891
+ }
3892
+ function headersField(value, name, messages) {
3893
+ if (!isRecord(value) || Object.keys(value).length > 50) return failure(name, "headers", "must be an object of at most 50 string headers", messages);
3894
+ const out = {};
3895
+ for (const [header, raw] of Object.entries(value)) {
3896
+ 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);
3897
+ out[header] = raw;
3898
+ }
3899
+ return {
3900
+ ok: true,
3901
+ value: out
3902
+ };
3903
+ }
3904
+ function normalizeStdio(raw, name, messages) {
3905
+ if (raw["url"] !== void 0 || raw["headers"] !== void 0) return failure(name, "type", "stdio does not accept url/headers", messages);
3906
+ const command = textField(raw["command"], name, "command", messages);
3907
+ if (!command.ok) return command;
3908
+ const entry = {
3909
+ type: "stdio",
3910
+ command: command.value
3911
+ };
3912
+ if (raw["args"] !== void 0) {
3913
+ const args = stringArray(raw["args"], name, "args", messages);
3914
+ if (!args.ok) return args;
3915
+ entry["args"] = args.value;
3916
+ }
3917
+ if (raw["env"] !== void 0) {
3918
+ const env = stringArray(raw["env"], name, "env", messages);
3919
+ if (!env.ok) return env;
3920
+ if (env.value.some((item) => !item.includes("="))) return failure(name, "env", "items must be KEY=VALUE", messages);
3921
+ entry["env"] = env.value;
3922
+ }
3923
+ if (raw["setup"] !== void 0) {
3924
+ const setup = textField(raw["setup"], name, "setup", messages);
3925
+ if (!setup.ok) return setup;
3926
+ entry["setup"] = setup.value;
3927
+ }
3928
+ return {
3929
+ ok: true,
3930
+ value: entry
3931
+ };
3932
+ }
3933
+ function normalizeRemote(raw, name, messages) {
3934
+ 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);
3935
+ const url = textField(raw["url"], name, "url", messages);
3936
+ if (!url.ok) return url;
3937
+ if (!HTTP_URL.test(url.value)) return failure(name, "url", "must start with http:// or https://", messages);
3938
+ const entry = {
3939
+ type: "remote",
3940
+ url: url.value
3941
+ };
3942
+ if (raw["headers"] !== void 0) {
3943
+ const headers = headersField(raw["headers"], name, messages);
3944
+ if (!headers.ok) return headers;
3945
+ entry["headers"] = headers.value;
3946
+ }
3947
+ return {
3948
+ ok: true,
3949
+ value: entry
3950
+ };
3951
+ }
3952
+ /** 单个 server 条目 → 只含已知字段的普通对象;未知字段直接拒绝,避免写出 OCR 不认的配置。 */
3953
+ function normalizeServer(name, raw, messages) {
3954
+ if (!isRecord(raw)) return failure(name, "entry", "must be an object", messages);
3955
+ for (const field of Object.keys(raw)) if (!KNOWN_FIELDS.has(field)) return failure(name, field, "is not a supported MCP field", messages);
3956
+ const type = raw["type"] === void 0 ? "stdio" : raw["type"];
3957
+ if (type !== "stdio" && type !== "remote") return failure(name, "type", "must be stdio or remote", messages);
3958
+ const base = type === "stdio" ? normalizeStdio(raw, name, messages) : normalizeRemote(raw, name, messages);
3959
+ if (!base.ok) return base;
3960
+ if (raw["tools"] !== void 0) {
3961
+ const tools = stringArray(raw["tools"], name, "tools", messages);
3962
+ if (!tools.ok) return tools;
3963
+ base.value["tools"] = tools.value;
3964
+ }
3965
+ return base;
3966
+ }
3967
+ function normalizeServers(raw, messages) {
3968
+ if (!isRecord(raw)) return {
3969
+ ok: false,
3970
+ error: messages.mcpShapeInvalid
3971
+ };
3972
+ const names = Object.keys(raw);
3973
+ if (names.length > MCP_MAX_SERVERS) return {
3974
+ ok: false,
3975
+ error: format(messages.mcpTooManyServers, { limit: String(MCP_MAX_SERVERS) })
3976
+ };
3977
+ const out = {};
3978
+ for (const name of names) {
3979
+ 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);
3980
+ const parsed = normalizeServer(name, raw[name], messages);
3981
+ if (!parsed.ok) return parsed;
3982
+ out[name] = parsed.value;
3983
+ }
3984
+ return {
3985
+ ok: true,
3986
+ value: out
3987
+ };
3988
+ }
3989
+ /** 读侧用异步读:卡片端点是 async 面,读大文件时不该占住事件循环。 */
3990
+ function isNotFound(error) {
3991
+ return isRecord(error) && error["code"] === "ENOENT";
3992
+ }
3993
+ /** 读当前 `mcp_servers`,交给卡片编辑;文件不存在按空表处理,坏 JSON/读不动返回可读错误。 */
3994
+ async function readMcpServers(configPath, messages) {
3995
+ let text;
3996
+ try {
3997
+ text = await readFile(configPath, "utf8");
3998
+ } catch (error) {
3999
+ if (!isNotFound(error)) return {
4000
+ ok: false,
4001
+ error: format(messages.mcpReadFailed, {
4002
+ where: configPath,
4003
+ cause: String(error)
4004
+ })
4005
+ };
4006
+ text = "";
4007
+ }
4008
+ if (!text.trim()) return {
4009
+ ok: true,
4010
+ json: "{}"
4011
+ };
4012
+ let doc;
4013
+ try {
4014
+ doc = parseExistingConfig(text, configPath, messages);
4015
+ } catch (error) {
4016
+ return {
4017
+ ok: false,
4018
+ error: format(messages.mcpReadFailed, {
4019
+ where: configPath,
4020
+ cause: String(error)
4021
+ })
4022
+ };
4023
+ }
4024
+ const servers = doc["mcp_servers"];
4025
+ if (servers === void 0 || servers === null) return {
4026
+ ok: true,
4027
+ json: "{}"
4028
+ };
4029
+ if (!isRecord(servers)) return {
4030
+ ok: false,
4031
+ error: messages.mcpShapeInvalid
4032
+ };
4033
+ return {
4034
+ ok: true,
4035
+ json: JSON.stringify(servers, null, 2)
4036
+ };
4037
+ }
4038
+ /** 校验并写回 `mcp_servers`;只改这一支,其余配置原样保留。 */
4039
+ async function saveMcpServers(configPath, json, messages) {
4040
+ if (Buffer.byteLength(json, "utf8") > MCP_MAX_BYTES) return {
4041
+ ok: false,
4042
+ error: format(messages.mcpJsonTooLarge, { limit: String(MCP_MAX_BYTES) })
4043
+ };
4044
+ let raw;
4045
+ try {
4046
+ raw = JSON.parse(json);
4047
+ } catch (error) {
4048
+ return {
4049
+ ok: false,
4050
+ error: format(messages.mcpJsonInvalid, { cause: String(error) })
4051
+ };
4052
+ }
4053
+ const normalized = normalizeServers(raw, messages);
4054
+ if (!normalized.ok) return normalized;
4055
+ try {
4056
+ return {
4057
+ ok: true,
4058
+ count: await updateConfigLocked(configPath, async () => {
4059
+ const doc = parseExistingConfig(readExistingText(configPath, messages), configPath, messages);
4060
+ doc["mcp_servers"] = normalized.value;
4061
+ await writeConfigAtomic(configPath, `${JSON.stringify(doc, null, 2)}\n`);
4062
+ return Object.keys(normalized.value).length;
4063
+ })
4064
+ };
4065
+ } catch (error) {
4066
+ return {
4067
+ ok: false,
4068
+ error: format(messages.mcpReadFailed, {
4069
+ where: configPath,
4070
+ cause: String(error)
4071
+ })
2488
4072
  };
2489
- } catch {
4073
+ }
4074
+ }
4075
+ /** ChatGPT provider 的配置写入:不写 key,只设 provider 与模型。 */
4076
+ async function applyChatGptModel(configPath, model, messages) {
4077
+ const trimmed = model.trim();
4078
+ if (trimmed === "" || trimmed.length > 256 || trimmed.includes("\0")) return {
4079
+ ok: false,
4080
+ error: messages.chatGptModelRequired
4081
+ };
4082
+ try {
4083
+ await updateConfigLocked(configPath, async () => {
4084
+ const doc = parseExistingConfig(readExistingText(configPath, messages), configPath, messages);
4085
+ const providers = isRecord(doc["providers"]) ? doc["providers"] : {};
4086
+ const entry = isRecord(providers["chatgpt"]) ? providers["chatgpt"] : {};
4087
+ delete entry["api_key"];
4088
+ delete entry["api_key_cmd"];
4089
+ entry["model"] = trimmed;
4090
+ providers["chatgpt"] = entry;
4091
+ doc["providers"] = providers;
4092
+ doc["provider"] = "chatgpt";
4093
+ await writeConfigAtomic(configPath, `${JSON.stringify(doc, null, 2)}\n`);
4094
+ });
4095
+ return { ok: true };
4096
+ } catch (error) {
2490
4097
  return {
2491
- repointed: [],
2492
- checked: false
4098
+ ok: false,
4099
+ error: format(messages.chatGptSaveFailed, { cause: String(error) })
2493
4100
  };
2494
4101
  }
2495
4102
  }
2496
- //#endregion
2497
- //#region lib/remote-shape.ts
2498
- /** 宿主服务键 = 客户端线上命名空间(两侧共用的唯一来源)。 */
2499
- const OCR_REMOTE_NAMESPACE = "ocrReview";
2500
- //#endregion
2501
- //#region host.ts
4103
+ // host.ts
2502
4104
  const PLUGIN_DIR = import.meta.dirname;
2503
4105
  const MAX_TIMEOUT_REQUEST_MS = 2147483647;
2504
4106
  const LLM_TEST_TIMEOUT_MS = 9e4;
@@ -2543,10 +4145,13 @@ function isLiveOcrStatus(status) {
2543
4145
  * @param jobs 官方注册表
2544
4146
  * @returns {void}
2545
4147
  */
2546
- function pruneOcrJobHistory(jobs) {
2547
- const settled = jobs.list().filter((job) => job.kind === OCR_JOB_KIND && !isLiveOcrStatus(job.status));
4148
+ function pruneOcrJobHistory(jobs, owner, ledger) {
4149
+ const settled = jobs.list(owner).filter((job) => job.kind === OCR_JOB_KIND && !isLiveOcrStatus(job.status));
2548
4150
  const excess = settled.length - 9;
2549
- for (const job of settled.slice(0, Math.max(excess, 0))) jobs.remove(job.id);
4151
+ for (const job of settled.slice(0, Math.max(excess, 0))) {
4152
+ jobs.remove(job.id, owner);
4153
+ ledger.delete(job.id);
4154
+ }
2550
4155
  }
2551
4156
  /**
2552
4157
  * 在 controller 闸门下起一条未拥有作业,起完立刻摘掉。
@@ -2593,380 +4198,110 @@ async function enforceOcrDeadline(jobs, id, reason, backgroundMaxMs) {
2593
4198
  * @param jobs 官方注册表
2594
4199
  * @param reason 写进模型可见 detail 的理由(双语字典取)
2595
4200
  * @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`)。
2630
- */
2631
- function describedValue(rows, ns) {
2632
- return rows.find((row) => row.ns === ns)?.value;
2633
- }
2634
- /**
2635
- * 取本包 host 侧文案(按官方 locale 偏好现取现用):设置页「常规」里换语言后,
2636
- * 下一次工具调用/端点请求即换新文案,不需要重启或重装插件。
2637
- * locale 命名空间未注册(无官方 client-locale 包)→ describe() 里没有这条 →
2638
- * undefined → 中文默认。
2639
- */
2640
- function localeMessages(host) {
2641
- const preference = describedValue(host.settings.describe(), LOCALE_SETTINGS_NAMESPACE);
2642
- return messagesFor(MESSAGES, resolveLocalePreference(preference));
2643
- }
2644
- /**
2645
- * render 只做 model-facing 展示:把 canonical value 本身 pretty-print 成文本块。
2646
- * execute 直返结构化对象(不再 stringify 进 `{ text }`),所以这里 stringify 的是
2647
- * 第一层也是唯一一层 JSON;循环结构/不可序列化值的两级兜底是防御,不是常规路径。
2648
- */
2649
- function textBlock(value) {
2650
- let text;
2651
- 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
- }
2660
- }
2661
- return [{
2662
- type: "text",
2663
- text
2664
- }];
2665
- }
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" }
4201
+ */
4202
+ function reclaimSessionJobs(jobs, reason, owner, ledger) {
4203
+ for (const job of jobs.list(owner)) if (job.kind === OCR_JOB_KIND && job.owner === owner) {
4204
+ ledger.delete(job.id);
4205
+ if (isLiveOcrStatus(job.status)) jobs.kill(job.id, owner, reason);
4206
+ else jobs.remove(job.id, owner);
2957
4207
  }
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" }
4208
+ }
4209
+ /** 卸载时收一条有主作业:id 只有台账知道,kill/remove 必须带 owner 作 caller。 */
4210
+ function reclaimOwnedJob(jobs, id, owner, reason) {
4211
+ try {
4212
+ if (isLiveOcrStatus(jobs.get(id, owner).status)) jobs.kill(id, owner, reason);
4213
+ else jobs.remove(id, owner);
4214
+ } catch {}
4215
+ }
4216
+ /**
4217
+ * 卸载时收掉本包起过的全部作业:无主的经 `list()` 枚举,有主的只有台账知道 id
4218
+ * (注册表对没有 caller 的视图隐藏它们,且 kill/remove 必须带 owner 作 caller)。
4219
+ */
4220
+ function reclaimAllJobs(jobs, reason, ledger) {
4221
+ for (const job of jobs.list()) if (job.kind === OCR_JOB_KIND) {
4222
+ if (isLiveOcrStatus(job.status)) jobs.kill(job.id, void 0, reason);
4223
+ else jobs.remove(job.id);
2968
4224
  }
2969
- };
4225
+ const owned = new Map(ledger);
4226
+ ledger.clear();
4227
+ for (const [id, owner] of owned) if (owner !== void 0) reclaimOwnedJob(jobs, id, owner, reason);
4228
+ }
4229
+ /** 工具调用发起时的会话 id;形状不对就当没有(agentless)。 */
4230
+ function sessionIdOf(exec) {
4231
+ const id = exec.agent?.session?.id;
4232
+ return typeof id === "string" && id.length > 0 ? id : void 0;
4233
+ }
4234
+ /** 该调用可绑定的作业 owner:会话 id 存在、且宿主注册表里确有这条活 Agent。
4235
+ * 给不出活 Agent 时退回 agentless —— 拿一个注册表不认的 id 去 start 只会整条失败。 */
4236
+ function ownerOf(host, exec) {
4237
+ const id = sessionIdOf(exec);
4238
+ const agents = host.get("agents");
4239
+ return id !== void 0 && agents?.get(id) !== void 0 ? id : void 0;
4240
+ }
4241
+ /** 当前会话工作区(repo 缺省回退源);非字符串视为缺。
4242
+ * 官方形状:`agent?: Agent` → `session: Session` → `header: SessionHeader` →
4243
+ * `cwd?: string`。下面的逐层 `?.` 与 `typeof` 复核是跨进程契约的运行时闸:宿主半装配
4244
+ * 递来的 agent 可能是 `null`、也可能没有 `session`/`header`(test/host.test.ts 钉住
4245
+ * 这两条 fail-closed 路径),值一律经 `unknown` 读,绝不只信声明。 */
4246
+ function sessionHeaderCwd(exec) {
4247
+ const raw = exec.agent?.session?.header?.cwd;
4248
+ return typeof raw === "string" && raw.length > 0 ? raw : void 0;
4249
+ }
4250
+ /** host 是否为本插件所需服务面(settings/shell/tools/systemPrompt/get/inject 及
4251
+ * 其上要调用的方法)。守卫仅压缩类型面、避开 `as unknown as HostCtx` 断言。
4252
+ * settings 这里点名的两个方法都会被调用:configure 登记本页的页面策略、describe
4253
+ * 跨命名空间读(provider 清单 + 官方 locale 偏好);inject 挂那条页面策略的 effect。
4254
+ * settings 面没有 register/get,多点名一个就是永不成立的死分支。 */
4255
+ function isOcrReviewHost(value) {
4256
+ if (!isRecord(value)) return false;
4257
+ const { settings, shell, tools, systemPrompt, inject, get } = value;
4258
+ return hasMethods(settings, ["configure", "describe"]) && hasMethods(shell, ["resolve", "execute"]) && hasMethods(tools, ["register"]) && hasMethods(systemPrompt, ["section"]) && typeof inject === "function" && typeof get === "function";
4259
+ }
4260
+ /** 从插件 Context 解析宿主服务面(守卫收窄,不做 `as unknown as` 断言)。 */
4261
+ function resolveHost(ctx) {
4262
+ if (!isOcrReviewHost(ctx)) throw new Error("ocr-review: 宿主缺少所需的 shell/tools/settings/systemPrompt/inject 服务界面(或其方法)");
4263
+ return ctx;
4264
+ }
4265
+ /** describe() 行里按命名空间取解析值(跨命名空间读只有 describe() 一条路:
4266
+ * `settings.get(ns)` 已删)。未注册该命名空间 → undefined,调用侧按默认值降级。
4267
+ * @param rows - `ctx.settings.describe()` 的返回(全部条目的表单投影)。
4268
+ * @param ns - 要读的命名空间(官方字段名是 `ns`)。
4269
+ */
4270
+ function describedValue(rows, ns) {
4271
+ return rows.find((row) => row.ns === ns)?.value;
4272
+ }
4273
+ /**
4274
+ * 取本包 host 侧文案(按官方 locale 偏好现取现用):设置页「常规」里换语言后,
4275
+ * 下一次工具调用/端点请求即换新文案,不需要重启或重装插件。
4276
+ * locale 命名空间未注册(无官方 client-locale 包)→ describe() 里没有这条 →
4277
+ * undefined → 中文默认。
4278
+ */
4279
+ function localeMessages(host) {
4280
+ const preference = describedValue(host.settings.describe(), LOCALE_SETTINGS_NAMESPACE);
4281
+ return messagesFor(MESSAGES, resolveLocalePreference(preference));
4282
+ }
4283
+ /**
4284
+ * render 只做 model-facing 展示:把 canonical value 本身 pretty-print 成文本块。
4285
+ * execute 直返结构化对象(不再 stringify 进 `{ text }`),所以这里 stringify 的是
4286
+ * 第一层也是唯一一层 JSON;循环结构/不可序列化值的两级兜底是防御,不是常规路径。
4287
+ */
4288
+ function textBlock(value) {
4289
+ let text;
4290
+ try {
4291
+ const json = JSON.stringify(value, null, 2);
4292
+ text = typeof json === "string" ? json : String(value);
4293
+ } catch {
4294
+ try {
4295
+ text = String(value);
4296
+ } catch {
4297
+ text = "[unrenderable value]";
4298
+ }
4299
+ }
4300
+ return [{
4301
+ type: "text",
4302
+ text
4303
+ }];
4304
+ }
2970
4305
  /**
2971
4306
  * ocr 子进程一律按 `danger-full-access` 请求(官方按调用携带的 `SandboxExecutionPolicy`)。
2972
4307
  *
@@ -2985,7 +4320,7 @@ const SESSION_OUTPUT_SCHEMA = {
2985
4320
  * 路径、没有自定义 HOME、没有目录级豁免;而 `SandboxMode` 的取值只有
2986
4321
  * `read-only | workspace-write | danger-full-access` 三档,中间那一档的可写根就是
2987
4322
  * `workspaceRoot` 一枚。两处声明都取自本包实际解析到的那版(dsh-sandbox-policy 与
2988
- * dsh-sandbox 的 0.2.1-rc.2,`lib/types/index.d.ts`)。
4323
+ * dsh-sandbox 的 0.2.1-rc.3,`lib/types/index.d.ts`)。
2989
4324
  * 可选的收紧路径只有「改 ocr 自己的状态落点」或「等上游加字段」,两者都不是本包能在
2990
4325
  * 一次补丁里做完的事。故此处维持放开,但把边界写明:放开的是 ocr 这一个子进程的整棵
2991
4326
  * 文件系统写权限,不是宿主其它能力;且 ocr 读什么、写什么仍由上面那条 argv 闸与
@@ -3122,7 +4457,7 @@ async function outcomeWhenSettled(proc) {
3122
4457
  * @param job 这次后台评审的描述
3123
4458
  * @returns 后台启动回执(canonical 对象,模型侧契约)
3124
4459
  */
3125
- async function startBackground(host, job) {
4460
+ async function startBackground(host, job, ledger) {
3126
4461
  const registry = host.get("jobs");
3127
4462
  if (registry === void 0) throw new Error("background review unavailable: the host provides no ctx.jobs (load @deepseek-ai/dsh-jobs-local); run it with wait:true instead");
3128
4463
  const spec = host.shell.resolve({
@@ -3134,13 +4469,14 @@ async function startBackground(host, job) {
3134
4469
  sandboxPolicy: ocrSandboxPolicy(job.workdir)
3135
4470
  });
3136
4471
  const proc = await host.shell.execute(spec);
3137
- pruneOcrJobHistory(registry);
4472
+ pruneOcrJobHistory(registry, job.owner, ledger);
3138
4473
  const messages = localeMessages(host);
3139
4474
  let jobId;
3140
4475
  try {
3141
4476
  jobId = startUnderOcrController(registry, {
3142
4477
  kind: OCR_JOB_KIND,
3143
4478
  label: `${job.tool} ${job.repo}`,
4479
+ ...job.owner === void 0 ? {} : { owner: job.owner },
3144
4480
  output: reviewSources(proc, messages),
3145
4481
  run: () => ({
3146
4482
  cancel: () => {
@@ -3153,6 +4489,7 @@ async function startBackground(host, job) {
3153
4489
  killProc(proc);
3154
4490
  throw error;
3155
4491
  }
4492
+ ledger.set(jobId, job.owner);
3156
4493
  const backstopMinutes = Math.round(job.backgroundMaxMs / 6e4);
3157
4494
  enforceOcrDeadline(registry, jobId, format(messages.jobReasonTimeout, { minutes: String(backstopMinutes) }), job.backgroundMaxMs);
3158
4495
  return {
@@ -3191,7 +4528,7 @@ async function runCollect(host, command, workdir, run) {
3191
4528
  }
3192
4529
  function readEffort(config) {
3193
4530
  const raw = config.effort.get();
3194
- return raw === "low" || raw === "high" ? raw : "medium";
4531
+ return raw === "auto" || raw === "low" || raw === "high" ? raw : "medium";
3195
4532
  }
3196
4533
  function readLanguage(config) {
3197
4534
  const lang = config.language.get();
@@ -3360,10 +4697,11 @@ function tuningParams(messages, unit, maxToolsTemplate) {
3360
4697
  * 外部仍以 `Config` 名导入(export as),公开 API 不变。 */
3361
4698
  const configSchema = Schema.object({
3362
4699
  effort: Schema.union([
4700
+ "auto",
3363
4701
  "low",
3364
4702
  "medium",
3365
4703
  "high"
3366
- ]).default("medium").volatile(),
4704
+ ]).default("auto").volatile(),
3367
4705
  language: Schema.union(["中文", "English"]).default("中文").volatile(),
3368
4706
  autoVerify: Schema.boolean().default(true).volatile(),
3369
4707
  maxComments: Schema.natural().default(MAX_COMMENTS_DEFAULT).volatile(),
@@ -3399,7 +4737,7 @@ function pluginPaths(config, messages) {
3399
4737
  /**
3400
4738
  * ocr 子进程的 HOME 重定向值(工具执行侧专用,**不抛错**):设置项命中
3401
4739
  * `<X>/.opencodereview/config.json` 布局 → X;未设置/坏设置 → undefined。工具不
3402
- * 走 pluginPaths(那会在坏设置下把五个工具一并拖死),坏设置的错误由端点侧的
4740
+ * 走 pluginPaths(那会在坏设置下把七个工具一并拖死),坏设置的错误由端点侧的
3403
4741
  * resolveOcrConfigPath 报给卡片——「工具与设置照常可用」的口径不变。
3404
4742
  */
3405
4743
  function ocrHome(config) {
@@ -3412,7 +4750,7 @@ function ocrHome(config) {
3412
4750
  * 为什么必须在这一层而不是启动时:config.json 里的脚本路径是**写那一刻**的插件安装
3413
4751
  * 目录,而插件会被重装、换 profile(desktop / browser / 任意自定义名)、换发布形态。
3414
4752
  * 目录一变那条命令就悬空,ocr 侧报的是「resolve LLM endpoint 失败」,与真因毫无
3415
- * 关系——五个工具一次性全挂,而配置文件里没有任何线索指向「本包被挪了地方」。
4753
+ * 关系——七个工具一次性全挂,而配置文件里没有任何线索指向「本包被挪了地方」。
3416
4754
  * 当前路径一律取自 PLUGIN_DIR(import.meta.dirname),与 profile 命名
3417
4755
  * 无关,故 desktop / browser / 自定义名走的是同一条路径。
3418
4756
  *
@@ -3451,7 +4789,7 @@ function probeFailureReason(messages, exitCode, timedOut) {
3451
4789
  * **全局安装**(`scripts/update.js` 见到更新版本就 spawnSync `npm i -g <pkg>@<latest>`,
3452
4790
  * 默认 18 分钟冷却一次)。状态读侧不该有写侧副作用,所以这条不受 `ocrNoAutoUpdate`
3453
4791
  * 那个部署开关影响,恒定带上。
3454
- * - HOME 与五个工具走同一个重定向口。launcher 那句「自动更新没成功,请手工执行」的
4792
+ * - HOME 与七个工具走同一个重定向口。launcher 那句「自动更新没成功,请手工执行」的
3455
4793
  * 提示写在 `$HOME/.opencodereview/` 下,不带重定向就会去读另一个世界的状态,
3456
4794
  * 卡片报的与评审实际吃到的就不是同一份。
3457
4795
  * @param host 宿主服务面
@@ -3541,7 +4879,7 @@ function cliStatusPayload(status, config, messages, measured) {
3541
4879
  * @returns 同一份摘要,或 hint 尾部多了一句的副本。
3542
4880
  */
3543
4881
  function withCliVersionNote(summary, measured, messages) {
3544
- if (measured === null || measured === "1.13.0") return summary;
4882
+ if (measured === null || measured === "1.13.3") return summary;
3545
4883
  const note = format(messages.versionMismatch, {
3546
4884
  actual: measured,
3547
4885
  expected: EXPECTED_OCR_CLI_VERSION
@@ -3552,7 +4890,7 @@ function withCliVersionNote(summary, measured, messages) {
3552
4890
  };
3553
4891
  }
3554
4892
  /** 运行 ocr llm test 校验当前配置(select 后自动验证 / 手动测试共用)。
3555
- * 命令词与五个工具同一解析源(buildLlmTestCommand 走 ocrCommand():卡片验的那一份与评审
4893
+ * 命令词与七个工具同一解析源(buildLlmTestCommand 走 ocrCommand():卡片验的那一份与评审
3556
4894
  * 吃的那一份必须同一个二进制),HOME 随 ocrConfigPath 重定向。墙钟与输出上限都是 entry
3557
4895
  * config 字段(非 volatile,改值随重启生效)。 */
3558
4896
  async function runLlmTest(host, config) {
@@ -3566,8 +4904,8 @@ async function runLlmTest(host, config) {
3566
4904
  output
3567
4905
  };
3568
4906
  }
3569
- /** effort 入参闸:undefined/null → 设置卡默认;字符串 → 白名单钳制(未知值回落
3570
- * medium,与 --format 同一纪律);其余类型抛错——静默回落会改变审查深度。 */
4907
+ /** effort 入参闸:undefined/null → 设置卡默认(auto 表示跟随 OCR 配置);字符串 →
4908
+ * 白名单钳制(未知值回落 auto,与 --format 同一纪律);其余类型抛错——静默回落会改变审查深度。 */
3571
4909
  function effortOf(value, config, messages) {
3572
4910
  if (value === void 0 || value === null) return readEffort(config);
3573
4911
  if (typeof value !== "string") throw new TypeError(format(messages.mustBeString, {
@@ -3590,6 +4928,19 @@ function waitOf(value, messages) {
3590
4928
  if (typeof value !== "boolean") throw new TypeError(format(messages.waitMustBeBoolean, { received: describeValue(value) }));
3591
4929
  return value;
3592
4930
  }
4931
+ /** session action 的只读归一:错类型交给 builder 的消息表闸。 */
4932
+ function actionOf(value) {
4933
+ return typeof value === "string" ? value.trim().toLowerCase() : "";
4934
+ }
4935
+ /** preview 入参闸:缺省 false;非布尔抛错。 */
4936
+ function previewOf(value, messages) {
4937
+ if (value === void 0 || value === null) return false;
4938
+ if (typeof value !== "boolean") throw new TypeError(format(messages.boolMustBeBoolean, {
4939
+ name: "preview",
4940
+ received: describeValue(value)
4941
+ }));
4942
+ return value;
4943
+ }
3593
4944
  /** 服务界面齐备才算可用(get() 可能返回半装配对象,也可能返回 undefined)。 */
3594
4945
  function usableService(value, methods) {
3595
4946
  return value !== void 0 && hasMethods(value, methods) ? value : void 0;
@@ -3809,13 +5160,93 @@ async function testReply(deps) {
3809
5160
  };
3810
5161
  }
3811
5162
  }
5163
+ async function mcpReply(deps) {
5164
+ const msgs = deps.messages();
5165
+ try {
5166
+ const result = await readMcpServers(pluginPaths(deps.config, msgs).ocrConfigJson, msgs);
5167
+ return result.ok ? {
5168
+ ok: true,
5169
+ json: result.json
5170
+ } : {
5171
+ ok: false,
5172
+ error: result.error,
5173
+ status: 400
5174
+ };
5175
+ } catch (error) {
5176
+ return {
5177
+ ok: false,
5178
+ error: errorText(error),
5179
+ status: 500
5180
+ };
5181
+ }
5182
+ }
5183
+ async function saveMcpReply(json, deps) {
5184
+ const msgs = deps.messages();
5185
+ if (typeof json !== "string") return {
5186
+ ok: false,
5187
+ error: format(msgs.mustBeString, {
5188
+ name: "json",
5189
+ received: describeValue(json)
5190
+ }),
5191
+ status: 400
5192
+ };
5193
+ const result = await saveMcpServers(pluginPaths(deps.config, msgs).ocrConfigJson, json, msgs);
5194
+ return result.ok ? {
5195
+ ok: true,
5196
+ count: result.count
5197
+ } : {
5198
+ ok: false,
5199
+ error: result.error,
5200
+ status: 400
5201
+ };
5202
+ }
5203
+ async function authReply(deps) {
5204
+ try {
5205
+ const { exitCode, output } = await runCollect(deps.host, withHomeEnv(buildAuthStatusCommand(), ocrHome(deps.config)), process.env["HOME"] ?? "/", {
5206
+ timeoutMs: deps.config.llmTestTimeoutMs,
5207
+ stdoutMaxBytes: deps.config.stdoutMaxBytes,
5208
+ env: ocrEnv(deps.config)
5209
+ });
5210
+ return {
5211
+ ok: exitCode === 0,
5212
+ output,
5213
+ loginCommand: CHATGPT_LOGIN_COMMAND
5214
+ };
5215
+ } catch (error) {
5216
+ return {
5217
+ ok: false,
5218
+ error: errorText(error),
5219
+ status: 400
5220
+ };
5221
+ }
5222
+ }
5223
+ async function chatGptReply(model, deps) {
5224
+ const msgs = deps.messages();
5225
+ if (typeof model !== "string") return {
5226
+ ok: false,
5227
+ error: format(msgs.mustBeString, {
5228
+ name: "model",
5229
+ received: describeValue(model)
5230
+ }),
5231
+ status: 400
5232
+ };
5233
+ const result = await applyChatGptModel(pluginPaths(deps.config, msgs).ocrConfigJson, model, msgs);
5234
+ return result.ok ? {
5235
+ ok: true,
5236
+ model: model.trim()
5237
+ } : {
5238
+ ok: false,
5239
+ error: result.error,
5240
+ status: 400
5241
+ };
5242
+ }
3812
5243
  /**
3813
- * 设置卡的五条读/写方法(providers / select / migrate / test / probe)。
5244
+ * 设置卡的九条读/写方法(providers / select / migrate / test / probe / mcp / saveMcp / auth / chatGpt)。
3814
5245
  *
3815
5246
  * ⚠ 服务在**构造时**注册(cordis `Service` 的契约:`ctx.reflect.provide`,随所属 fiber
3816
5247
  * 卸载自动撤),所以调用方拿实例没有任何用处——装入口 `installOcrRemote` 说明了这一点。
3817
- * 五个方法都不依赖可选宿主服务(gateway 与取用口都由 apply 交进来),故它跟着 apply
3818
- * 无条件上线:宿主没有 web 层(TUI)时这五条照样在。
5248
+ * 九个方法都不依赖可选宿主服务(gateway 与取用口都由 apply 交进来),故它跟着 apply
5249
+ * 无条件上线:宿主没有 web 层(TUI)时这九条照样在。
3819
5250
  */
3820
5251
  var OcrReviewRemoteService = class extends TypertRemoteService {
3821
5252
  deps;
@@ -3849,6 +5280,22 @@ var OcrReviewRemoteService = class extends TypertRemoteService {
3849
5280
  versionSink: this.deps.extras.versionSink
3850
5281
  });
3851
5282
  }
5283
+ /** 读当前 `mcp_servers`(只回这一支,不带其他配置/keys)。 */
5284
+ async mcp() {
5285
+ return mcpReply(this.deps);
5286
+ }
5287
+ /** 校验并写入 `mcp_servers`;其他配置原样保留。 */
5288
+ async saveMcp(request) {
5289
+ return saveMcpReply(isRecord(request) ? request.json : void 0, this.deps);
5290
+ }
5291
+ /** 只读查询 ChatGPT 登录态;登录命令原样带回给卡片显示。 */
5292
+ async auth() {
5293
+ return authReply(this.deps);
5294
+ }
5295
+ /** 切到 ChatGPT provider 并写模型;key 由 OCR 的 auth 凭据库管,不写明文。 */
5296
+ async chatGpt(request) {
5297
+ return chatGptReply(isRecord(request) ? request.model : void 0, this.deps);
5298
+ }
3852
5299
  };
3853
5300
  /**
3854
5301
  * 标准装饰器的**程序化调用**:`@Remote` 的全部作用就是往类原型写一枚描述符,而标准装饰器
@@ -3873,6 +5320,10 @@ markRemote(OcrReviewRemoteService.prototype, "select");
3873
5320
  markRemote(OcrReviewRemoteService.prototype, "migrate");
3874
5321
  markRemote(OcrReviewRemoteService.prototype, "test");
3875
5322
  markRemote(OcrReviewRemoteService.prototype, "probe");
5323
+ markRemote(OcrReviewRemoteService.prototype, "mcp");
5324
+ markRemote(OcrReviewRemoteService.prototype, "saveMcp");
5325
+ markRemote(OcrReviewRemoteService.prototype, "auth");
5326
+ markRemote(OcrReviewRemoteService.prototype, "chatGpt");
3876
5327
  /**
3877
5328
  * 把读面装到这个上下文上(构造即注册,见类文档;host 侧唯一该调的口)。
3878
5329
  *
@@ -3910,7 +5361,71 @@ const productionProbe = () => resolveOcrAvailability();
3910
5361
  function assertOcrPresent(status, messages, install) {
3911
5362
  if (status.source === "none") throw new Error(format(messages.ocrBinaryMissing, { install }));
3912
5363
  }
3913
- function registerReviewTool(host, config, probe, versionSink) {
5364
+ /** review 的预览支路:零 LLM、前台、临时 --output;抽出来只为把注册函数压回行数上限。 */
5365
+ async function runReviewPreview(run) {
5366
+ const { host, config, args, repo, signal, messages } = run;
5367
+ if (args["wait"] !== void 0 && args["wait"] !== null && !waitOf(args["wait"], messages)) throw new Error(messages.previewNeedsForeground);
5368
+ const tmp = mkdtempSync(path.join(tmpdir(), "ocr-review-preview-"));
5369
+ const outPath = path.join(tmp, "preview.json");
5370
+ try {
5371
+ const { command, workdir } = buildReviewCommand({
5372
+ ...args,
5373
+ repo,
5374
+ preview: true
5375
+ }, outPath, messages);
5376
+ await runForeground(host, withHomeEnv(command, ocrHome(config)), workdir, tuningOf(config), signal);
5377
+ const text = readOutFile(outPath, messages);
5378
+ const parsed = parsePreviewOutput(text);
5379
+ if (!parsed.ok) return format(messages.parseFailedWithReason, {
5380
+ tool: "ocr review --preview",
5381
+ message: messages.previewParseFailed,
5382
+ limit: String(RAW_ECHO_LIMIT),
5383
+ raw: echoRaw(text, RAW_ECHO_LIMIT)
5384
+ });
5385
+ return {
5386
+ ...parsed.view,
5387
+ hint: messages.filePreviewHint
5388
+ };
5389
+ } finally {
5390
+ rmSync(tmp, {
5391
+ recursive: true,
5392
+ force: true
5393
+ });
5394
+ }
5395
+ }
5396
+ /** scan 的预览支路(与 review 同形,只是不归一化 git 顶层)。 */
5397
+ async function runScanPreview(run) {
5398
+ const { host, config, args, repo, signal, messages } = run;
5399
+ if (args["wait"] !== void 0 && args["wait"] !== null && !waitOf(args["wait"], messages)) throw new Error(messages.previewNeedsForeground);
5400
+ const tmp = mkdtempSync(path.join(tmpdir(), "ocr-scan-preview-"));
5401
+ const outPath = path.join(tmp, "preview.json");
5402
+ try {
5403
+ const { command, workdir } = buildScanCommand({
5404
+ ...args,
5405
+ repo,
5406
+ preview: true
5407
+ }, outPath, messages);
5408
+ await runForeground(host, withHomeEnv(command, ocrHome(config)), workdir, tuningOf(config), signal);
5409
+ const text = readOutFile(outPath, messages);
5410
+ const parsed = parsePreviewOutput(text);
5411
+ if (!parsed.ok) return format(messages.parseFailedWithReason, {
5412
+ tool: "ocr scan --preview",
5413
+ message: messages.previewParseFailed,
5414
+ limit: String(RAW_ECHO_LIMIT),
5415
+ raw: echoRaw(text, RAW_ECHO_LIMIT)
5416
+ });
5417
+ return {
5418
+ ...parsed.view,
5419
+ hint: messages.filePreviewHint
5420
+ };
5421
+ } finally {
5422
+ rmSync(tmp, {
5423
+ recursive: true,
5424
+ force: true
5425
+ });
5426
+ }
5427
+ }
5428
+ function registerReviewTool(host, config, probe, versionSink, ledger) {
3914
5429
  const msgs = localeMessages(host);
3915
5430
  define(host, {
3916
5431
  name: "ocr_review",
@@ -3978,6 +5493,26 @@ function registerReviewTool(host, config, probe, versionSink) {
3978
5493
  resume: {
3979
5494
  type: "string",
3980
5495
  description: msgs.paramResume
5496
+ },
5497
+ preview: {
5498
+ type: "boolean",
5499
+ description: msgs.paramPreview
5500
+ },
5501
+ noFilter: {
5502
+ type: "boolean",
5503
+ description: msgs.paramNoFilter
5504
+ },
5505
+ maxGitProcs: {
5506
+ type: "integer",
5507
+ description: msgs.paramMaxGitProcs
5508
+ },
5509
+ rule: {
5510
+ type: "string",
5511
+ description: msgs.paramRule
5512
+ },
5513
+ backgroundFile: {
5514
+ type: "string",
5515
+ description: msgs.paramBackgroundFile
3981
5516
  }
3982
5517
  }
3983
5518
  },
@@ -3987,6 +5522,14 @@ function registerReviewTool(host, config, probe, versionSink) {
3987
5522
  const cliStatus = probe();
3988
5523
  assertOcrPresent(cliStatus, runMsgs, ocrInstallText(cliStatus, config, runMsgs));
3989
5524
  const repo = await canonicalReviewRoot(host, ocrRepo(args, exec, runMsgs));
5525
+ if (previewOf(args["preview"], runMsgs)) return runReviewPreview({
5526
+ host,
5527
+ config,
5528
+ args,
5529
+ repo,
5530
+ signal: exec.signal,
5531
+ messages: runMsgs
5532
+ });
3990
5533
  const eff = effortOf(args["effort"], config, runMsgs);
3991
5534
  const tmp = waitOf(args["wait"], runMsgs) ? mkdtempSync(path.join(tmpdir(), "ocr-review-")) : void 0;
3992
5535
  const outPath = tmp === void 0 ? void 0 : path.join(tmp, "out.json");
@@ -4004,10 +5547,11 @@ function registerReviewTool(host, config, probe, versionSink) {
4004
5547
  workdir,
4005
5548
  tool: "ocr_review",
4006
5549
  repo,
5550
+ owner: ownerOf(host, exec),
4007
5551
  stdoutMaxBytes: config.stdoutMaxBytes,
4008
5552
  backgroundMaxMs: config.ocrBackgroundMaxMs,
4009
5553
  env: ocrEnv(config)
4010
- });
5554
+ }, ledger);
4011
5555
  try {
4012
5556
  await runForeground(host, guarded, workdir, tuningOf(config), exec.signal);
4013
5557
  } catch (error) {
@@ -4031,7 +5575,7 @@ function registerReviewTool(host, config, probe, versionSink) {
4031
5575
  }
4032
5576
  });
4033
5577
  }
4034
- function registerScanTool(host, config, probe, versionSink) {
5578
+ function registerScanTool(host, config, probe, versionSink, ledger) {
4035
5579
  const msgs = localeMessages(host);
4036
5580
  define(host, {
4037
5581
  name: "ocr_scan",
@@ -4079,6 +5623,34 @@ function registerScanTool(host, config, probe, versionSink) {
4079
5623
  wait: {
4080
5624
  type: "boolean",
4081
5625
  description: msgs.paramWait
5626
+ },
5627
+ preview: {
5628
+ type: "boolean",
5629
+ description: msgs.paramPreview
5630
+ },
5631
+ noPlan: {
5632
+ type: "boolean",
5633
+ description: msgs.paramNoPlan
5634
+ },
5635
+ noDedup: {
5636
+ type: "boolean",
5637
+ description: msgs.paramNoDedup
5638
+ },
5639
+ noSummary: {
5640
+ type: "boolean",
5641
+ description: msgs.paramNoSummary
5642
+ },
5643
+ resume: {
5644
+ type: "string",
5645
+ description: msgs.paramScanResume
5646
+ },
5647
+ maxGitProcs: {
5648
+ type: "integer",
5649
+ description: msgs.paramMaxGitProcs
5650
+ },
5651
+ rule: {
5652
+ type: "string",
5653
+ description: msgs.paramRule
4082
5654
  }
4083
5655
  }
4084
5656
  },
@@ -4088,6 +5660,14 @@ function registerScanTool(host, config, probe, versionSink) {
4088
5660
  const cliStatus = probe();
4089
5661
  assertOcrPresent(cliStatus, runMsgs, ocrInstallText(cliStatus, config, runMsgs));
4090
5662
  const repo = ocrRepo(args, exec, runMsgs);
5663
+ if (previewOf(args["preview"], runMsgs)) return runScanPreview({
5664
+ host,
5665
+ config,
5666
+ args,
5667
+ repo,
5668
+ signal: exec.signal,
5669
+ messages: runMsgs
5670
+ });
4091
5671
  const tmp = waitOf(args["wait"], runMsgs) ? mkdtempSync(path.join(tmpdir(), "ocr-scan-")) : void 0;
4092
5672
  const outPath = tmp === void 0 ? void 0 : path.join(tmp, "out.json");
4093
5673
  await healKeyScriptPointer(host, config);
@@ -4103,10 +5683,11 @@ function registerScanTool(host, config, probe, versionSink) {
4103
5683
  workdir,
4104
5684
  tool: "ocr_scan",
4105
5685
  repo,
5686
+ owner: ownerOf(host, exec),
4106
5687
  stdoutMaxBytes: config.stdoutMaxBytes,
4107
5688
  backgroundMaxMs: config.ocrBackgroundMaxMs,
4108
5689
  env: ocrEnv(config)
4109
- });
5690
+ }, ledger);
4110
5691
  try {
4111
5692
  await runForeground(host, guarded, workdir, tuningOf(config), exec.signal);
4112
5693
  } catch (error) {
@@ -4276,7 +5857,9 @@ function registerSessionTool(host, config, probe) {
4276
5857
  enum: [
4277
5858
  "list",
4278
5859
  "show",
4279
- "comments"
5860
+ "comments",
5861
+ "compare",
5862
+ "export"
4280
5863
  ],
4281
5864
  description: msgs.paramSessionAction
4282
5865
  },
@@ -4284,6 +5867,24 @@ function registerSessionTool(host, config, probe) {
4284
5867
  type: "string",
4285
5868
  description: msgs.paramSessionId
4286
5869
  },
5870
+ before: {
5871
+ type: "string",
5872
+ description: msgs.paramSessionBefore
5873
+ },
5874
+ after: {
5875
+ type: "string",
5876
+ description: msgs.paramSessionAfter
5877
+ },
5878
+ severity: {
5879
+ type: "array",
5880
+ items: { type: "string" },
5881
+ description: msgs.paramSessionSeverity
5882
+ },
5883
+ category: {
5884
+ type: "array",
5885
+ items: { type: "string" },
5886
+ description: msgs.paramSessionCategory
5887
+ },
4287
5888
  limit: {
4288
5889
  type: "integer",
4289
5890
  description: msgs.paramSessionLimit
@@ -4296,6 +5897,47 @@ function registerSessionTool(host, config, probe) {
4296
5897
  const cliStatus = probe();
4297
5898
  assertOcrPresent(cliStatus, runMsgs, ocrInstallText(cliStatus, config, runMsgs));
4298
5899
  const repo = await canonicalReviewRoot(host, ocrRepo(args, exec, runMsgs));
5900
+ const action = actionOf(args["action"]);
5901
+ const before = typeof args["before"] === "string" ? args["before"].trim() : "";
5902
+ const after = typeof args["after"] === "string" ? args["after"].trim() : "";
5903
+ if (action === "compare" || action === "" && (before !== "" || after !== "")) {
5904
+ const { command, workdir } = buildSessionCommand({
5905
+ ...args,
5906
+ repo
5907
+ }, runMsgs);
5908
+ const stdout = await runForeground(host, withHomeEnv(command, ocrHome(config)), workdir, tuningOf(config), exec.signal);
5909
+ const parsed = parseSessionCompare(stdout.text);
5910
+ if (parsed.ok) return {
5911
+ ...parsed.view,
5912
+ hint: runMsgs.sessionCompareHint
5913
+ };
5914
+ return {
5915
+ sessionOutput: echoRaw(stdout.text, RAW_ECHO_LIMIT) + stdoutTruncationNote(config.stdoutMaxBytes, stdout, runMsgs),
5916
+ hint: runMsgs.sessionCompareHint
5917
+ };
5918
+ }
5919
+ if (action === "export") {
5920
+ const exportDir = mkdtempSync(path.join(tmpdir(), "ocr-session-export-"));
5921
+ const output = path.join(exportDir, "review.html");
5922
+ try {
5923
+ const { command, workdir } = buildSessionCommand({
5924
+ ...args,
5925
+ repo,
5926
+ output
5927
+ }, runMsgs);
5928
+ await runForeground(host, withHomeEnv(command, ocrHome(config)), workdir, tuningOf(config), exec.signal);
5929
+ return {
5930
+ sessionOutput: output,
5931
+ hint: runMsgs.sessionExportHint
5932
+ };
5933
+ } catch (error) {
5934
+ rmSync(exportDir, {
5935
+ recursive: true,
5936
+ force: true
5937
+ });
5938
+ throw error;
5939
+ }
5940
+ }
4299
5941
  const { command, workdir } = buildSessionCommand({
4300
5942
  ...args,
4301
5943
  repo
@@ -4312,6 +5954,83 @@ function registerSessionTool(host, config, probe) {
4312
5954
  }
4313
5955
  });
4314
5956
  }
5957
+ function registerRulesCheckTool(host, config, probe) {
5958
+ const msgs = localeMessages(host);
5959
+ define(host, {
5960
+ name: "ocr_rules_check",
5961
+ description: msgs.rulesCheckToolDescription,
5962
+ parameters: {
5963
+ type: "object",
5964
+ additionalProperties: false,
5965
+ properties: {
5966
+ repo: {
5967
+ type: "string",
5968
+ description: msgs.paramRepo
5969
+ },
5970
+ path: {
5971
+ type: "string",
5972
+ description: msgs.paramRulesPath
5973
+ },
5974
+ rule: {
5975
+ type: "string",
5976
+ description: msgs.paramRule
5977
+ }
5978
+ },
5979
+ required: ["path"]
5980
+ },
5981
+ schema: TEXT_OUTPUT_SCHEMA,
5982
+ run: async (args, exec) => {
5983
+ const runMsgs = localeMessages(host);
5984
+ const cliStatus = probe();
5985
+ assertOcrPresent(cliStatus, runMsgs, ocrInstallText(cliStatus, config, runMsgs));
5986
+ const repo = ocrRepo(args, exec, runMsgs);
5987
+ const { command, workdir } = buildRulesCheckCommand({
5988
+ ...args,
5989
+ repo
5990
+ }, runMsgs);
5991
+ const stdout = await runForeground(host, withHomeEnv(command, ocrHome(config)), workdir, tuningOf(config), exec.signal);
5992
+ const { text } = stdout;
5993
+ return {
5994
+ output: (text.length > 12e3 ? `${echoRaw(text, 11900)}\n${format(runMsgs.textTruncated, { limit: "12000" })}` : redactKeyMaterial(text)) + stdoutTruncationNote(config.stdoutMaxBytes, stdout, runMsgs),
5995
+ hint: runMsgs.rulesCheckHint
5996
+ };
5997
+ }
5998
+ });
5999
+ }
6000
+ function registerLlmTool(host, config, probe) {
6001
+ const msgs = localeMessages(host);
6002
+ define(host, {
6003
+ name: "ocr_llm",
6004
+ description: msgs.llmToolDescription,
6005
+ parameters: {
6006
+ type: "object",
6007
+ additionalProperties: false,
6008
+ properties: { action: {
6009
+ type: "string",
6010
+ enum: ["providers", "models"],
6011
+ description: msgs.paramLlmAction
6012
+ } },
6013
+ required: ["action"]
6014
+ },
6015
+ schema: TEXT_OUTPUT_SCHEMA,
6016
+ run: async (args, exec) => {
6017
+ const runMsgs = localeMessages(host);
6018
+ const cliStatus = probe();
6019
+ assertOcrPresent(cliStatus, runMsgs, ocrInstallText(cliStatus, config, runMsgs));
6020
+ const rawAction = args["action"];
6021
+ if (rawAction !== "providers" && rawAction !== "models") throw new Error(format(runMsgs.invalidAction, {
6022
+ action: typeof rawAction === "string" ? rawAction : describeValue(rawAction),
6023
+ expected: "providers/models"
6024
+ }));
6025
+ const stdout = await runForeground(host, withHomeEnv(buildLlmCommand(rawAction), ocrHome(config)), process.cwd(), tuningOf(config), exec.signal);
6026
+ const { text } = stdout;
6027
+ return {
6028
+ output: (text.length > 12e3 ? `${echoRaw(text, 11900)}\n${format(runMsgs.textTruncated, { limit: "12000" })}` : redactKeyMaterial(text)) + stdoutTruncationNote(config.stdoutMaxBytes, stdout, runMsgs),
6029
+ hint: runMsgs.llmHint
6030
+ };
6031
+ }
6032
+ });
6033
+ }
4315
6034
  /** 装载期自检结论里保留的输出上限(字符):卡片只用来提示,留全量没有意义。 */
4316
6035
  const ENDPOINT_VERIFY_CHARS = 400;
4317
6036
  /** 装载期端点自检(fire-and-forget):跑一次 `ocr llm test`,把结论写进本次 apply 的闭包,
@@ -4355,29 +6074,34 @@ function apply(ctx, config, probe) {
4355
6074
  const host = resolveHost(ctx);
4356
6075
  const endpointVerify = { value: null };
4357
6076
  const versionSink = { value: null };
6077
+ const jobLedger = /* @__PURE__ */ new Map();
4358
6078
  if (config.startupVerify) {
4359
6079
  probeEndpointAtApply(host, config, endpointVerify);
4360
6080
  probeVersionAtApply(host, config, versionSink);
4361
6081
  }
4362
6082
  ctx.effect(() => () => {
4363
6083
  const jobs = ctx.get("jobs");
4364
- if (jobs !== void 0) reclaimOcrJobs(jobs, localeMessages(ctx).jobReasonUnload);
6084
+ if (jobs !== void 0) reclaimAllJobs(jobs, localeMessages(ctx).jobReasonUnload, jobLedger);
4365
6085
  }, "ocr-review: background jobs");
4366
- ctx.effect(() => ctx.on("session/disposed", () => {
6086
+ ctx.effect(() => ctx.on("session/disposed", (session) => {
4367
6087
  const jobs = ctx.get("jobs");
4368
6088
  if (jobs === void 0) return;
6089
+ const owner = session.id;
6090
+ if (typeof owner !== "string" || owner.length === 0) return;
4369
6091
  try {
4370
- reclaimOcrJobs(jobs, localeMessages(ctx).jobReasonSessionStopped);
6092
+ reclaimSessionJobs(jobs, localeMessages(ctx).jobReasonSessionStopped, owner, jobLedger);
4371
6093
  } catch {}
4372
6094
  }), "ocr-review: session stop reaps background runs");
4373
6095
  host.inject(["settings"], (child) => {
4374
6096
  child.effect(() => child.settings.configure({ auto: false }, host.fiber));
4375
6097
  });
4376
- registerReviewTool(host, config, probe, versionSink);
4377
- registerScanTool(host, config, probe, versionSink);
6098
+ registerReviewTool(host, config, probe, versionSink, jobLedger);
6099
+ registerScanTool(host, config, probe, versionSink, jobLedger);
4378
6100
  registerDelegatePreviewTool(host, config, probe);
4379
6101
  registerDelegateRuleTool(host, config, probe);
4380
6102
  registerSessionTool(host, config, probe);
6103
+ registerRulesCheckTool(host, config, probe);
6104
+ registerLlmTool(host, config, probe);
4381
6105
  host.systemPrompt.section({
4382
6106
  name: ROUTING_NAME,
4383
6107
  order: ROUTING_ORDER,
@@ -4407,5 +6131,4 @@ var host_default = {
4407
6131
  apply(ctx, config, productionProbe);
4408
6132
  }
4409
6133
  };
4410
- //#endregion
4411
6134
  export { configSchema as Config, OcrReviewRemoteService, apply, assertOcrPresent, createGateway, host_default as default, endpointVerifyOf, installOcrRemote, joinedOutput, ocrEnv, ocrRepo, productionProbe, readLanguage, requiredKeys };