@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/CHANGELOG.md +325 -0
- package/LICENSE +1 -1
- package/README.md +204 -168
- package/README.zh-CN.md +209 -174
- package/THIRD_PARTY_NOTICES.md +11 -7
- package/client.js +1259 -543
- package/cordis.patch.yml +5 -3
- package/docs/DETAIL.md +28 -0
- package/docs/DETAIL.zh.md +28 -0
- package/host.js +2355 -632
- package/locale/en.json +7 -0
- package/locale/zh.json +7 -0
- package/package.json +59 -51
- package/scripts/get-cred.mjs +151 -55
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
|
-
|
|
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
|
|
49
|
+
repoMustBeAbsolute: "repo 必须是绝对路径:{root}",
|
|
50
50
|
noNulBytes: "{name} 不能包含 NUL 字节",
|
|
51
|
-
|
|
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}
|
|
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
|
|
105
|
-
scanToolDescription: "用 open-code-review(ocr)做全文件扫描审查(无需 Git 变更/无 diff,适合审计陌生代码库或指定目录)。--path
|
|
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
|
|
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: "
|
|
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
|
-
-
|
|
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
|
|
199
|
-
|
|
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}.
|
|
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
|
|
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
|
|
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
|
-
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
356
|
-
|
|
357
|
-
|
|
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
|
-
|
|
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}-[
|
|
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]*)(
|
|
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]+)[
|
|
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
|
-
|
|
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
|
-
/**
|
|
428
|
-
|
|
429
|
-
|
|
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")
|
|
470
|
-
else if (type === "array")
|
|
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
|
-
|
|
502
|
-
|
|
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
|
-
*
|
|
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 (
|
|
527
|
-
|
|
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")
|
|
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.
|
|
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
|
-
/**
|
|
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
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
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:
|
|
1688
|
+
command: sessionPartsOf(inferSessionAction(requested, ctx), ctx).join(" "),
|
|
1142
1689
|
workdir: repo
|
|
1143
1690
|
};
|
|
1144
1691
|
}
|
|
1145
|
-
|
|
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
|
|
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(
|
|
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 } =
|
|
1733
|
+
const { level, known } = classifySeverity(comment.severity);
|
|
1188
1734
|
bySeverity[level] += 1;
|
|
1189
1735
|
if (!known) unknownSeverityCount += 1;
|
|
1190
|
-
|
|
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
|
-
|
|
1201
|
-
|
|
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
|
-
*
|
|
1208
|
-
*
|
|
1209
|
-
*
|
|
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
|
-
|
|
1244
|
-
|
|
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 (
|
|
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" &&
|
|
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)
|
|
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
|
|
2080
|
+
ok,
|
|
1477
2081
|
status,
|
|
1478
|
-
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
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
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
|
-
*
|
|
2318
|
+
* 解析 `ocr review --preview --format json` / `ocr scan --preview --format json`。
|
|
1708
2319
|
*
|
|
1709
|
-
*
|
|
1710
|
-
*
|
|
1711
|
-
*
|
|
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
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
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
|
-
/**
|
|
1723
|
-
|
|
1724
|
-
|
|
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
|
-
|
|
1731
|
-
|
|
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
|
-
|
|
1735
|
-
|
|
1736
|
-
if (
|
|
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
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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 (
|
|
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(
|
|
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(
|
|
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 ||
|
|
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
|
-
|
|
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:
|
|
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 =
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
-
}
|
|
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
|
-
|
|
2492
|
-
|
|
4098
|
+
ok: false,
|
|
4099
|
+
error: format(messages.chatGptSaveFailed, { cause: String(error) })
|
|
2493
4100
|
};
|
|
2494
4101
|
}
|
|
2495
4102
|
}
|
|
2496
|
-
|
|
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)))
|
|
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
|
|
2598
|
-
const
|
|
2599
|
-
|
|
2600
|
-
|
|
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
|
-
}
|
|
2959
|
-
/**
|
|
2960
|
-
|
|
2961
|
-
|
|
2962
|
-
|
|
2963
|
-
|
|
2964
|
-
|
|
2965
|
-
|
|
2966
|
-
|
|
2967
|
-
|
|
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.
|
|
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("
|
|
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
|
|
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.
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
5244
|
+
* 设置卡的九条读/写方法(providers / select / migrate / test / probe / mcp / saveMcp / auth / chatGpt)。
|
|
3814
5245
|
*
|
|
3815
5246
|
* ⚠ 服务在**构造时**注册(cordis `Service` 的契约:`ctx.reflect.provide`,随所属 fiber
|
|
3816
5247
|
* 卸载自动撤),所以调用方拿实例没有任何用处——装入口 `installOcrRemote` 说明了这一点。
|
|
3817
|
-
*
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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 };
|